mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 13:53:53 +02:00
Rewrite HLS segmentation algo AGAIN, add a bunch of HLS segmentation tests, add VideoEncoder/AudioEncoder timing refinement logic, fix HLS output spec violations
This commit is contained in:
+3
-2
@@ -11,7 +11,6 @@
|
|||||||
document.body.append(fileInput);
|
document.body.append(fileInput);
|
||||||
|
|
||||||
fileInput.addEventListener('change', async () => {
|
fileInput.addEventListener('change', async () => {
|
||||||
/*
|
|
||||||
const file = fileInput.files[0];
|
const file = fileInput.files[0];
|
||||||
const input = new Mediabunny.Input({
|
const input = new Mediabunny.Input({
|
||||||
formats: Mediabunny.ALL_FORMATS,
|
formats: Mediabunny.ALL_FORMATS,
|
||||||
@@ -19,11 +18,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const track = await input.getPrimaryAudioTrack();
|
const track = await input.getPrimaryAudioTrack();
|
||||||
|
const sink3 = new Mediabunny.EncodedPacketSink(track);
|
||||||
|
console.log((await sink3.getFirstPacket()).data.join(', '));
|
||||||
|
return;
|
||||||
console.log(await track.getDurationFromMetadata(), await track.computeDuration());
|
console.log(await track.getDurationFromMetadata(), await track.computeDuration());
|
||||||
|
|
||||||
//console.log(await input.getDurationFromMetadata(), await input.computeDuration());
|
//console.log(await input.getDurationFromMetadata(), await input.computeDuration());
|
||||||
return;
|
return;
|
||||||
*/
|
|
||||||
|
|
||||||
const manifest = new Mediabunny.Input({
|
const manifest = new Mediabunny.Input({
|
||||||
entryPath: 'https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8',
|
entryPath: 'https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8',
|
||||||
|
|||||||
+173
-123
@@ -1,6 +1,6 @@
|
|||||||
import { MediaCodec, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec';
|
import { MediaCodec, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec';
|
||||||
import { EncodedAudioPacketSource, EncodedVideoPacketSource, MediaSource } from '../media-source';
|
import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../media-source';
|
||||||
import { assert, joinPaths, textEncoder, UNDETERMINED_LANGUAGE } from '../misc';
|
import { arrayArgmax, assert, findLastIndex, joinPaths, textEncoder, UNDETERMINED_LANGUAGE } from '../misc';
|
||||||
import { Muxer } from '../muxer';
|
import { Muxer } from '../muxer';
|
||||||
import {
|
import {
|
||||||
Output,
|
Output,
|
||||||
@@ -32,7 +32,6 @@ type HlsAudioTrackData = HlsTrackData & { info: { type: 'audio' } };
|
|||||||
|
|
||||||
type Playlist = {
|
type Playlist = {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
|
||||||
path: string;
|
path: string;
|
||||||
tracks: OutputTrack[];
|
tracks: OutputTrack[];
|
||||||
segmentFormat: OutputFormat;
|
segmentFormat: OutputFormat;
|
||||||
@@ -349,6 +348,10 @@ export class HlsMuxer extends Muxer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const registerPlaylist = async (tracks: OutputTrack[]) => {
|
const registerPlaylist = async (tracks: OutputTrack[]) => {
|
||||||
|
if (tracks.some(track => this.playlists.some(playlist => playlist.tracks.includes(track)))) {
|
||||||
|
throw new Error('Internal error: track is already registered in a playlist.'); // Should be unreachable
|
||||||
|
}
|
||||||
|
|
||||||
const id = this.playlists.length + 1;
|
const id = this.playlists.length + 1;
|
||||||
const path = await this.getPlaylistPath({
|
const path = await this.getPlaylistPath({
|
||||||
n: id,
|
n: id,
|
||||||
@@ -363,12 +366,10 @@ export class HlsMuxer extends Muxer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = tracks.map(x => x.id).join('-');
|
|
||||||
const format = deduceSegmentFormat(tracks);
|
const format = deduceSegmentFormat(tracks);
|
||||||
|
|
||||||
const playlist: Playlist = {
|
const playlist: Playlist = {
|
||||||
id: this.playlists.length + 1,
|
id: this.playlists.length + 1,
|
||||||
key,
|
|
||||||
path,
|
path,
|
||||||
tracks,
|
tracks,
|
||||||
segmentFormat: format,
|
segmentFormat: format,
|
||||||
@@ -393,8 +394,7 @@ export class HlsMuxer extends Muxer {
|
|||||||
for (let i = 0; i < group.tracks.length; i++) {
|
for (let i = 0; i < group.tracks.length; i++) {
|
||||||
const track = group.tracks[i]!;
|
const track = group.tracks[i]!;
|
||||||
|
|
||||||
const key = track.id.toString();
|
let playlist = this.playlists.find(x => x.tracks[0]!.id === track.id);
|
||||||
let playlist = this.playlists.find(x => x.key === key);
|
|
||||||
playlist ??= await registerPlaylist([track]);
|
playlist ??= await registerPlaylist([track]);
|
||||||
|
|
||||||
this.playlistDeclarations.push({
|
this.playlistDeclarations.push({
|
||||||
@@ -407,8 +407,8 @@ export class HlsMuxer extends Muxer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const variant of variantStreams) {
|
for (const variant of variantStreams) {
|
||||||
const key = variant.tracks.map(x => x.id).join('-');
|
// Since tracks can only be assigned to one playlist, the first track's ID acts as a "playlist key"
|
||||||
let playlist = this.playlists.find(x => x.key === key);
|
let playlist = this.playlists.find(x => x.tracks[0]!.id === variant.tracks[0]!.id);
|
||||||
playlist ??= await registerPlaylist(variant.tracks);
|
playlist ??= await registerPlaylist(variant.tracks);
|
||||||
|
|
||||||
this.playlistDeclarations.push({
|
this.playlistDeclarations.push({
|
||||||
@@ -423,7 +423,7 @@ export class HlsMuxer extends Muxer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getMimeType(): Promise<string> {
|
async getMimeType(): Promise<string> {
|
||||||
throw new Error('TODO');
|
return 'application/vnd.apple.mpegurl';
|
||||||
}
|
}
|
||||||
|
|
||||||
private allTracksAreKnown(playlist: Playlist) {
|
private allTracksAreKnown(playlist: Playlist) {
|
||||||
@@ -590,85 +590,131 @@ export class HlsMuxer extends Muxer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trackDatas = this.trackDatas.filter(x => playlist.tracks.includes(x.track));
|
||||||
|
const videoTrack = trackDatas.find(x => x.info.type === 'video') as HlsVideoTrackData | undefined;
|
||||||
|
const audioTrack = trackDatas.find(x => x.info.type === 'audio') as HlsAudioTrackData | undefined;
|
||||||
|
|
||||||
|
const videoClosed = videoTrack
|
||||||
|
? videoTrack.track.source._closed || isFinalCall
|
||||||
|
: true;
|
||||||
|
const audioClosed = audioTrack
|
||||||
|
? audioTrack.track.source._closed || isFinalCall
|
||||||
|
: true;
|
||||||
|
|
||||||
|
// Loop in case we can finalize multiple segments
|
||||||
while (true) {
|
while (true) {
|
||||||
const currentSegmentEndTimestamp = playlist.currentSegmentStartTimestamp + this.targetSegmentDuration;
|
const currentSegmentEndTimestamp = playlist.currentSegmentStartTimestamp + this.targetSegmentDuration;
|
||||||
|
|
||||||
let videoKeyEndTimestamp: number | null = null;
|
// These store the index (exclusive) until when packets can be added to the next segment
|
||||||
let videoEndTimestamp: number | null = null;
|
let videoEndIndex = 0;
|
||||||
let audioEndTimestamp: number | null = null;
|
let audioEndIndex = 0;
|
||||||
let flushAllVideo = false;
|
|
||||||
let flushAllAudio = false;
|
|
||||||
|
|
||||||
const trackDatas = this.trackDatas.filter(x => playlist.tracks.includes(x.track));
|
if (videoTrack && (!videoClosed || videoTrack.packets.length > 0)) {
|
||||||
|
const allBelow = videoTrack.packets.every(x => x.timestamp < currentSegmentEndTimestamp);
|
||||||
|
|
||||||
for (const trackData of trackDatas) {
|
let bestKeyPacket: EncodedPacket | null = null;
|
||||||
for (let i = 0; i < trackData.packets.length; i++) {
|
let bestKeyPacketIndex: number | null = null;
|
||||||
const packet = trackData.packets[i]!;
|
|
||||||
const endTimestamp = packet.timestamp + packet.duration;
|
|
||||||
|
|
||||||
if (trackData.info.type === 'video') {
|
if (allBelow) {
|
||||||
videoEndTimestamp = Math.max(videoEndTimestamp ?? -Infinity, endTimestamp);
|
if (!videoClosed) {
|
||||||
|
// Not enough data yet
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Find the best key packet timestamp
|
||||||
|
for (let i = 0; i < videoTrack.packets.length; i++) {
|
||||||
|
const packet = videoTrack.packets[i]!;
|
||||||
|
|
||||||
if (i > 0 && packet.type === 'key') {
|
if (bestKeyPacket !== null && packet.timestamp > currentSegmentEndTimestamp) {
|
||||||
if (
|
|
||||||
videoKeyEndTimestamp !== null
|
|
||||||
&& packet.timestamp > currentSegmentEndTimestamp
|
|
||||||
) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
videoKeyEndTimestamp = packet.timestamp;
|
|
||||||
}
|
|
||||||
} else if (trackData.info.type === 'audio') {
|
|
||||||
if (
|
|
||||||
audioEndTimestamp !== null
|
|
||||||
&& packet.timestamp > currentSegmentEndTimestamp
|
|
||||||
) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
audioEndTimestamp = packet.timestamp;
|
if (i > 0 && packet.type === 'key') {
|
||||||
|
bestKeyPacket = packet;
|
||||||
|
bestKeyPacketIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const endTimestamp = packet.timestamp + packet.duration;
|
if (bestKeyPacketIndex !== null) {
|
||||||
if (endTimestamp <= currentSegmentEndTimestamp) {
|
videoEndIndex = bestKeyPacketIndex;
|
||||||
audioEndTimestamp = Math.max(audioEndTimestamp ?? -Infinity, endTimestamp);
|
|
||||||
if (i === trackData.packets.length - 1) {
|
if (audioTrack) {
|
||||||
flushAllAudio = true;
|
// The audio track must go at least until the video key frame
|
||||||
|
const index = audioTrack.packets.findIndex(x => x.timestamp >= bestKeyPacket!.timestamp);
|
||||||
|
if (index !== -1) {
|
||||||
|
audioEndIndex = index;
|
||||||
|
} else {
|
||||||
|
if (audioClosed) {
|
||||||
|
audioEndIndex = audioTrack.packets.length;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!videoClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the entire rest of the video (since there's no key frame to split it on)
|
||||||
|
videoEndIndex = videoTrack.packets.length;
|
||||||
|
const maxIndex = arrayArgmax(videoTrack.packets, x => x.timestamp);
|
||||||
|
const maxPacket = videoTrack.packets[maxIndex];
|
||||||
|
assert(maxPacket);
|
||||||
|
|
||||||
|
if (audioTrack) {
|
||||||
|
if (maxPacket.timestamp < currentSegmentEndTimestamp) {
|
||||||
|
// The audio must go until at least the start of the next segment
|
||||||
|
const index = audioTrack.packets.findIndex(x => x.timestamp >= currentSegmentEndTimestamp);
|
||||||
|
if (index !== -1) {
|
||||||
|
audioEndIndex = index;
|
||||||
|
} else {
|
||||||
|
if (audioClosed) {
|
||||||
|
audioEndIndex = audioTrack.packets.length;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The audio must go beyond the last video packet
|
||||||
|
const index = audioTrack.packets.findIndex(x => x.timestamp > maxPacket.timestamp);
|
||||||
|
if (index !== -1) {
|
||||||
|
audioEndIndex = index;
|
||||||
|
} else {
|
||||||
|
if (audioClosed) {
|
||||||
|
audioEndIndex = audioTrack.packets.length;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if (audioTrack && (!audioClosed || audioTrack.packets.length > 0)) {
|
||||||
|
const allBelow = audioTrack.packets.every(x => x.timestamp < currentSegmentEndTimestamp);
|
||||||
|
|
||||||
let endTimestamp: number | null = null;
|
if (allBelow) {
|
||||||
if (videoKeyEndTimestamp !== null && videoEndTimestamp! > currentSegmentEndTimestamp) {
|
if (audioClosed) {
|
||||||
endTimestamp = videoKeyEndTimestamp;
|
audioEndIndex = audioTrack.packets.length;
|
||||||
} else {
|
} else {
|
||||||
if (isFinalCall) {
|
|
||||||
endTimestamp = videoEndTimestamp;
|
|
||||||
flushAllVideo = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioEndTimestamp !== null) {
|
|
||||||
endTimestamp = Math.max(endTimestamp ?? -Infinity, audioEndTimestamp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endTimestamp === null) {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
if (!isFinalCall && endTimestamp < currentSegmentEndTimestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const trackData of trackDatas) {
|
|
||||||
const closed = trackData.track.source._closed || isFinalCall;
|
|
||||||
if (!closed && !trackData.packets.some(x => x.timestamp >= endTimestamp)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const index = findLastIndex(audioTrack.packets, x => x.timestamp <= currentSegmentEndTimestamp);
|
||||||
|
if (index !== -1) {
|
||||||
|
audioEndIndex = index;
|
||||||
|
} else {
|
||||||
|
audioEndIndex = 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (videoEndIndex === 0 && audioEndIndex === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can finalize a new segment! Let's first get the path
|
||||||
const relativeSegmentPath = await this.getSegmentPath({
|
const relativeSegmentPath = await this.getSegmentPath({
|
||||||
n: playlist.nextSegmentId,
|
n: playlist.nextSegmentId,
|
||||||
format: playlist.segmentFormat,
|
format: playlist.segmentFormat,
|
||||||
@@ -705,67 +751,49 @@ export class HlsMuxer extends Muxer {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let maxEndTimestamp = -Infinity;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const packetSources = new Map<HlsTrackData, MediaSource>();
|
let videoSource: EncodedVideoPacketSource | null = null;
|
||||||
|
let audioSource: EncodedAudioPacketSource | null = null;
|
||||||
|
|
||||||
for (const trackData of trackDatas) {
|
if (videoTrack) {
|
||||||
if (trackData.packets.length === 0) {
|
// Always add the track, no matter if it has packets or not (maintains underlying IDs)
|
||||||
continue;
|
videoSource = new EncodedVideoPacketSource((videoTrack.track as OutputVideoTrack).source._codec);
|
||||||
}
|
output.addVideoTrack(videoSource, videoTrack.track.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
if (trackData.info.type === 'video') {
|
if (audioTrack) {
|
||||||
const outputTrack = trackData.track as OutputVideoTrack;
|
// Always add the track, no matter if it has packets or not (maintains underlying IDs)
|
||||||
const source = new EncodedVideoPacketSource(outputTrack.source._codec);
|
audioSource = new EncodedAudioPacketSource((audioTrack.track as OutputAudioTrack).source._codec);
|
||||||
output.addVideoTrack(source, outputTrack.metadata);
|
output.addAudioTrack(audioSource, audioTrack.track.metadata);
|
||||||
|
|
||||||
packetSources.set(trackData, source);
|
|
||||||
} else if (trackData.info.type === 'audio') {
|
|
||||||
const outputTrack = trackData.track as OutputAudioTrack;
|
|
||||||
const source = new EncodedAudioPacketSource(outputTrack.source._codec);
|
|
||||||
output.addAudioTrack(source, outputTrack.metadata);
|
|
||||||
|
|
||||||
packetSources.set(trackData, source);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await output.start();
|
await output.start();
|
||||||
|
|
||||||
for (const trackData of trackDatas) {
|
// Add all of the packets
|
||||||
const source = packetSources.get(trackData);
|
|
||||||
if (!source) {
|
if (videoTrack) {
|
||||||
continue;
|
assert(videoSource);
|
||||||
|
const meta = { decoderConfig: videoTrack.info.decoderConfig };
|
||||||
|
|
||||||
|
for (let i = 0; i < videoEndIndex; i++) {
|
||||||
|
const packet = videoTrack.packets[i]!;
|
||||||
|
|
||||||
|
await videoSource.add(packet, meta);
|
||||||
|
maxEndTimestamp = Math.max(maxEndTimestamp, packet.timestamp + packet.duration);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (trackData.info.type === 'video') {
|
if (audioTrack) {
|
||||||
const videoPacketSource = source as EncodedVideoPacketSource;
|
assert(audioSource);
|
||||||
const meta = { decoderConfig: trackData.info.decoderConfig };
|
const meta = { decoderConfig: audioTrack.info.decoderConfig };
|
||||||
|
|
||||||
while (trackData.packets.length > 0) {
|
for (let i = 0; i < audioEndIndex; i++) {
|
||||||
const nextPacket = trackData.packets[0]!;
|
const packet = audioTrack.packets[i]!;
|
||||||
if (!flushAllVideo && nextPacket.timestamp >= endTimestamp) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackData.packets.shift();
|
await audioSource.add(packet, meta);
|
||||||
await videoPacketSource.add(nextPacket, meta);
|
maxEndTimestamp = Math.max(maxEndTimestamp, packet.timestamp + packet.duration);
|
||||||
}
|
|
||||||
|
|
||||||
videoPacketSource.close();
|
|
||||||
} else if (trackData.info.type === 'audio') {
|
|
||||||
const audioPacketSource = source as EncodedAudioPacketSource;
|
|
||||||
const meta = { decoderConfig: trackData.info.decoderConfig };
|
|
||||||
|
|
||||||
while (trackData.packets.length > 0) {
|
|
||||||
const nextPacket = trackData.packets[0]!;
|
|
||||||
if (!flushAllAudio && nextPacket.timestamp >= endTimestamp) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackData.packets.shift();
|
|
||||||
await audioPacketSource.add(nextPacket, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
audioPacketSource.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,13 +803,35 @@ export class HlsMuxer extends Muxer {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (videoEndIndex > 0) {
|
||||||
|
assert(videoTrack);
|
||||||
|
videoTrack.packets.splice(0, videoEndIndex);
|
||||||
|
}
|
||||||
|
if (audioEndIndex > 0) {
|
||||||
|
assert(audioTrack);
|
||||||
|
audioTrack.packets.splice(0, audioEndIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
let minNextTimestamp = Infinity;
|
||||||
|
if (videoTrack && videoTrack.packets.length > 0) {
|
||||||
|
minNextTimestamp = videoTrack.packets[0]!.timestamp;
|
||||||
|
}
|
||||||
|
if (audioTrack && audioTrack.packets.length > 0) {
|
||||||
|
minNextTimestamp = Math.min(minNextTimestamp, audioTrack.packets[0]!.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSegmentStartTimestamp = minNextTimestamp < Infinity
|
||||||
|
? minNextTimestamp
|
||||||
|
: maxEndTimestamp; // Happens for the last segment for example
|
||||||
|
assert(Number.isFinite(nextSegmentStartTimestamp));
|
||||||
|
|
||||||
playlist.writtenSegments.push({
|
playlist.writtenSegments.push({
|
||||||
path: relativeSegmentPath,
|
path: relativeSegmentPath,
|
||||||
duration: endTimestamp - playlist.currentSegmentStartTimestamp,
|
duration: nextSegmentStartTimestamp - playlist.currentSegmentStartTimestamp,
|
||||||
byteSize: segmentSize,
|
byteSize: segmentSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
playlist.currentSegmentStartTimestamp = endTimestamp;
|
playlist.currentSegmentStartTimestamp = nextSegmentStartTimestamp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,11 +872,11 @@ export class HlsMuxer extends Muxer {
|
|||||||
const playlistText = '#EXTM3U\n'
|
const playlistText = '#EXTM3U\n'
|
||||||
+ '#EXT-X-VERSION:3\n'
|
+ '#EXT-X-VERSION:3\n'
|
||||||
+ '#EXT-X-PLAYLIST-TYPE:VOD\n'
|
+ '#EXT-X-PLAYLIST-TYPE:VOD\n'
|
||||||
+ `#EXT-X-TARGETDURATION:${+targetDuration.toPrecision(13)}\n`
|
+ `#EXT-X-TARGETDURATION:${Math.ceil(targetDuration)}\n` // Must be a "decimal-integer"
|
||||||
+ '\n'
|
+ '\n'
|
||||||
+ (playlist.writtenSegments
|
+ (playlist.writtenSegments
|
||||||
.map(segment => (
|
.map(segment => (
|
||||||
`#EXTINF:${+segment.duration.toPrecision(13)}\n`
|
`#EXTINF:${+segment.duration.toFixed(12)},\n` // Trailing comma mandated by spec
|
||||||
+ `${segment.path}\n`
|
+ `${segment.path}\n`
|
||||||
))
|
))
|
||||||
.join(''))
|
.join(''))
|
||||||
@@ -914,7 +964,7 @@ export class HlsMuxer extends Muxer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (videoTrack.metadata.frameRate !== undefined) {
|
if (videoTrack.metadata.frameRate !== undefined) {
|
||||||
masterPlaylistText += `,FRAME-RATE=${videoTrack.metadata.frameRate}`;
|
masterPlaylistText += `,FRAME-RATE=${+videoTrack.metadata.frameRate.toFixed(16)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export {
|
|||||||
FlacOutputFormat,
|
FlacOutputFormat,
|
||||||
FlacOutputFormatOptions,
|
FlacOutputFormatOptions,
|
||||||
HlsOutputFormat,
|
HlsOutputFormat,
|
||||||
|
HlsOutputFormatOptions,
|
||||||
IsobmffOutputFormat,
|
IsobmffOutputFormat,
|
||||||
IsobmffOutputFormatOptions,
|
IsobmffOutputFormatOptions,
|
||||||
MkvOutputFormat,
|
MkvOutputFormat,
|
||||||
@@ -158,6 +159,7 @@ export {
|
|||||||
AdtsInputFormat,
|
AdtsInputFormat,
|
||||||
FlacInputFormat,
|
FlacInputFormat,
|
||||||
IsobmffInputFormat,
|
IsobmffInputFormat,
|
||||||
|
HlsInputFormat,
|
||||||
MatroskaInputFormat,
|
MatroskaInputFormat,
|
||||||
Mp3InputFormat,
|
Mp3InputFormat,
|
||||||
Mp4InputFormat,
|
Mp4InputFormat,
|
||||||
@@ -167,8 +169,10 @@ export {
|
|||||||
WaveInputFormat,
|
WaveInputFormat,
|
||||||
WebMInputFormat,
|
WebMInputFormat,
|
||||||
ALL_FORMATS,
|
ALL_FORMATS,
|
||||||
|
HLS_FORMATS,
|
||||||
ADTS,
|
ADTS,
|
||||||
FLAC,
|
FLAC,
|
||||||
|
HLS,
|
||||||
MATROSKA,
|
MATROSKA,
|
||||||
MP3,
|
MP3,
|
||||||
MP4,
|
MP4,
|
||||||
|
|||||||
@@ -703,3 +703,5 @@ export const HLS = /* #__PURE__ */ new HlsInputFormat();
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export const ALL_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, FLAC, MP3, ADTS, MPEG_TS];
|
export const ALL_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, FLAC, MP3, ADTS, MPEG_TS];
|
||||||
|
|
||||||
|
export const HLS_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MP3, ADTS, MPEG_TS];
|
||||||
|
|||||||
+73
-2
@@ -22,12 +22,14 @@ import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack }
|
|||||||
import {
|
import {
|
||||||
assert,
|
assert,
|
||||||
assertNever,
|
assertNever,
|
||||||
|
binarySearchLessOrEqual,
|
||||||
CallSerializer,
|
CallSerializer,
|
||||||
clamp,
|
clamp,
|
||||||
clearIntervalUnthrottled,
|
clearIntervalUnthrottled,
|
||||||
isFirefox,
|
isFirefox,
|
||||||
last,
|
last,
|
||||||
promiseWithResolvers,
|
promiseWithResolvers,
|
||||||
|
roundToDivisor,
|
||||||
setInt24,
|
setInt24,
|
||||||
setIntervalUnthrottled,
|
setIntervalUnthrottled,
|
||||||
setUint24,
|
setUint24,
|
||||||
@@ -213,6 +215,16 @@ class VideoEncoderWrapper {
|
|||||||
private codedHeight: number | null = null;
|
private codedHeight: number | null = null;
|
||||||
private resizeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
|
private resizeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
|
||||||
|
|
||||||
|
// VideoEncoder converts everything to microseconds, so we need to do some bookkeeping to restore the original
|
||||||
|
// timing information
|
||||||
|
private preciseTimings: {
|
||||||
|
microsecondTimestamp: number;
|
||||||
|
timestamp: number;
|
||||||
|
duration: number;
|
||||||
|
timestampIsValid: boolean;
|
||||||
|
durationIsValid: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
private customEncoder: CustomVideoEncoder | null = null;
|
private customEncoder: CustomVideoEncoder | null = null;
|
||||||
private customEncoderCallSerializer = new CallSerializer();
|
private customEncoderCallSerializer = new CallSerializer();
|
||||||
private customEncoderQueueSize = 0;
|
private customEncoderQueueSize = 0;
|
||||||
@@ -353,6 +365,38 @@ class VideoEncoderWrapper {
|
|||||||
|
|
||||||
const videoFrame = videoSample.toVideoFrame();
|
const videoFrame = videoSample.toVideoFrame();
|
||||||
|
|
||||||
|
const preciseTimingIndex = binarySearchLessOrEqual(
|
||||||
|
this.preciseTimings,
|
||||||
|
videoFrame.timestamp,
|
||||||
|
x => x.microsecondTimestamp,
|
||||||
|
);
|
||||||
|
const existingEntry = preciseTimingIndex !== -1
|
||||||
|
? this.preciseTimings[preciseTimingIndex]
|
||||||
|
: null;
|
||||||
|
if (existingEntry && existingEntry.microsecondTimestamp === videoFrame.timestamp) {
|
||||||
|
if (existingEntry.timestamp !== videoSample.timestamp) {
|
||||||
|
// Mapping isn't unique, can't use the timestamp
|
||||||
|
existingEntry.timestampIsValid = false;
|
||||||
|
}
|
||||||
|
if (existingEntry.duration !== videoSample.duration) {
|
||||||
|
// Mapping isn't unique, can't use the duration
|
||||||
|
existingEntry.durationIsValid = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.preciseTimings.splice(preciseTimingIndex + 1, 0, {
|
||||||
|
microsecondTimestamp: videoFrame.timestamp,
|
||||||
|
timestamp: videoSample.timestamp,
|
||||||
|
duration: videoSample.duration,
|
||||||
|
timestampIsValid: true,
|
||||||
|
durationIsValid: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure it doesn't grow indefinitely
|
||||||
|
if (this.preciseTimings.length > 128) {
|
||||||
|
this.preciseTimings.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.alphaEncoder) {
|
if (!this.alphaEncoder) {
|
||||||
// No alpha encoder, simple case
|
// No alpha encoder, simple case
|
||||||
this.encoder.encode(videoFrame, finalEncodeOptions);
|
this.encoder.encode(videoFrame, finalEncodeOptions);
|
||||||
@@ -515,7 +559,25 @@ class VideoEncoderWrapper {
|
|||||||
sideData.alpha = alphaData;
|
sideData.alpha = alphaData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const packet = EncodedPacket.fromEncodedChunk(colorChunk, sideData);
|
let packet = EncodedPacket.fromEncodedChunk(colorChunk, sideData);
|
||||||
|
|
||||||
|
const preciseTimingIndex = binarySearchLessOrEqual(
|
||||||
|
this.preciseTimings,
|
||||||
|
colorChunk.timestamp,
|
||||||
|
x => x.microsecondTimestamp,
|
||||||
|
);
|
||||||
|
const entry = preciseTimingIndex !== -1
|
||||||
|
? this.preciseTimings[preciseTimingIndex]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// If there's a relevant timing entry, refine the packet's timing data to get better accuracy than
|
||||||
|
// microseconds
|
||||||
|
if (entry && entry.microsecondTimestamp === colorChunk.timestamp) {
|
||||||
|
packet = packet.clone({
|
||||||
|
timestamp: entry.timestampIsValid ? entry.timestamp : undefined,
|
||||||
|
duration: entry.durationIsValid ? entry.duration : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
||||||
void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta)
|
void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta)
|
||||||
@@ -1766,7 +1828,16 @@ class AudioEncoderWrapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const packet = EncodedPacket.fromEncodedChunk(chunk);
|
let packet = EncodedPacket.fromEncodedChunk(chunk);
|
||||||
|
|
||||||
|
// Snap the timing information to the sample rate to recover information lost in microsecond
|
||||||
|
// conversion
|
||||||
|
packet = packet.clone({
|
||||||
|
timestamp: roundToDivisor(packet.timestamp, encoderConfig.sampleRate),
|
||||||
|
duration: chunk.duration != null
|
||||||
|
? roundToDivisor(packet.duration, encoderConfig.sampleRate)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
||||||
void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta)
|
void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta)
|
||||||
|
|||||||
@@ -791,6 +791,14 @@ export const joinPaths = (basePath: string, relativePath: string) => {
|
|||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip query parameters from URL base paths so their contents don't mess up the join
|
||||||
|
if (basePath.includes('://')) {
|
||||||
|
const queryIndex = basePath.indexOf('?');
|
||||||
|
if (queryIndex !== -1) {
|
||||||
|
basePath = basePath.slice(0, queryIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let result: string;
|
let result: string;
|
||||||
|
|
||||||
if (relativePath.startsWith('/')) {
|
if (relativePath.startsWith('/')) {
|
||||||
|
|||||||
@@ -1189,6 +1189,11 @@ export class HlsOutputFormat extends OutputFormat {
|
|||||||
get supportsTimestampedMediaData(): boolean {
|
get supportsTimestampedMediaData(): boolean {
|
||||||
return true; // I guess??
|
return true; // I guess??
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
override _codecUnsupportedHint(codec: MediaCodec): string {
|
||||||
|
return ` Using different segment formats may grant support for this codec.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HLS_OUTPUT_FORMATS_DEFAULT = [new AdtsOutputFormat(), new MpegTsOutputFormat()];
|
export const HLS_OUTPUT_FORMATS_DEFAULT = [new AdtsOutputFormat(), new MpegTsOutputFormat()];
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from 'vitest';
|
import { expect, test } from 'vitest';
|
||||||
import { joinPaths } from '../../src/misc.js';
|
import { joinPaths } from '../../src/misc.js';
|
||||||
|
|
||||||
test('joinPaths handles all path combinations correctly', () => {
|
test('joinPaths', () => {
|
||||||
// Simple relative paths
|
// Simple relative paths
|
||||||
expect(joinPaths('path/to/entry.m3u8', 'other.m3u8')).toBe('path/to/other.m3u8');
|
expect(joinPaths('path/to/entry.m3u8', 'other.m3u8')).toBe('path/to/other.m3u8');
|
||||||
expect(joinPaths('entry.m3u8', 'other.m3u8')).toBe('other.m3u8');
|
expect(joinPaths('entry.m3u8', 'other.m3u8')).toBe('other.m3u8');
|
||||||
@@ -36,6 +36,19 @@ test('joinPaths handles all path combinations correctly', () => {
|
|||||||
expect(joinPaths('path/to/entry.m3u8', './../other.m3u8')).toBe('path/other.m3u8');
|
expect(joinPaths('path/to/entry.m3u8', './../other.m3u8')).toBe('path/other.m3u8');
|
||||||
expect(joinPaths('path/to/entry.m3u8', '../foo/../other.m3u8')).toBe('path/other.m3u8');
|
expect(joinPaths('path/to/entry.m3u8', '../foo/../other.m3u8')).toBe('path/other.m3u8');
|
||||||
|
|
||||||
|
// URL base path with query parameters
|
||||||
|
expect(joinPaths('https://example.com/path/to/entry.m3u8?token=abc', 'other.m3u8'))
|
||||||
|
.toBe('https://example.com/path/to/other.m3u8');
|
||||||
|
expect(joinPaths('https://example.com/path/to/entry.m3u8?redirect=/foo/bar', 'other.m3u8'))
|
||||||
|
.toBe('https://example.com/path/to/other.m3u8');
|
||||||
|
expect(joinPaths('https://example.com/path/to/entry.m3u8?token=abc', '/other.m3u8'))
|
||||||
|
.toBe('https://example.com/other.m3u8');
|
||||||
|
expect(joinPaths('https://example.com/path/to/entry.m3u8?token=abc', '../other.m3u8'))
|
||||||
|
.toBe('https://example.com/path/other.m3u8');
|
||||||
|
|
||||||
|
// Filesystem paths with ? in the name are preserved
|
||||||
|
expect(joinPaths('path/to/sus?directory/file.m3u8', 'other.m3u8')).toBe('path/to/sus?directory/other.m3u8');
|
||||||
|
|
||||||
// Second argument is a full URL with protocol
|
// Second argument is a full URL with protocol
|
||||||
expect(joinPaths('path/to/entry.m3u8', 'https://example.com/other.m3u8'))
|
expect(joinPaths('path/to/entry.m3u8', 'https://example.com/other.m3u8'))
|
||||||
.toBe('https://example.com/other.m3u8');
|
.toBe('https://example.com/other.m3u8');
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
- Retaining Input instances for segments without clogging up memory indefinitely
|
|
||||||
|
|
||||||
idea: discontinuities with extra decoder config on the packet.
|
idea: discontinuities with extra decoder config on the packet.
|
||||||
Also, why not just have the packet metadata on the packet? I think that would make things easier overall.
|
Also, why not just have the packet metadata on the packet? I think that would make things easier overall.
|
||||||
|
|
||||||
- ALL_FORMATS but for HLS only
|
|
||||||
|
|
||||||
- keep input/demuxer.isSupported()?
|
- keep input/demuxer.isSupported()?
|
||||||
- getCodecParamterString() hack feels dirty; idk. well its not really a hack but, surface it over a different field?
|
- getCodecParamterString() hack feels dirty; idk. well its not really a hack but, surface it over a different field?
|
||||||
|
|
||||||
|
- Add an HLS "live mode". Challenge, where does bitrate come from?
|
||||||
|
|
||||||
|
events:
|
||||||
|
- onPlaylist
|
||||||
|
- onSegment
|
||||||
|
- onMaster
|
||||||
|
|
||||||
|
- hls muxer starting offset
|
||||||
|
- hls demuxer offset by segment number * targetduration
|
||||||
Reference in New Issue
Block a user