diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 7588020..d58a315 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -40,6 +40,7 @@ import { EncodedPacket } from '../packet'; import { SubtitleCue, SubtitleMetadata } from '../subtitles'; import { NullTarget, PathedTarget, Target, TargetRequest } from '../target'; import { HLS_MIME_TYPE } from './hls-misc'; +import type { IsobmffMuxer } from '../isobmff/isobmff-muxer'; type HlsTrackData = { track: OutputTrack; @@ -88,6 +89,11 @@ type Playlist = { path: string; nextOffset: number; info: HlsOutputSegmentInfo; + /** + * Used for the special-cased logic where single file mode is enabled with fMP4. In this case, we write out a + * segments file which is also a perfectly valid standalone fMP4 valid. + */ + fragmentedIsobmffOutput: FragmentedIsobmffOutput | null; } | null; // For HLS, having a single mutex is too coarse. Every playlist is basically independent and therefore we can have @@ -103,6 +109,14 @@ type PlaylistDeclaration = { references: PlaylistDeclaration[]; }; +type FragmentedIsobmffOutput = { + output: Output; + videoSource: EncodedVideoPacketSource | null; + audioSource: EncodedAudioPacketSource | null; + firstMoofPosition: number | null; + currentFileSize: number; +}; + export class HlsMuxer extends Muxer { format: HlsOutputFormat; getPlaylistPath: NonNullable; @@ -857,13 +871,78 @@ export class HlsMuxer extends Muxer { isRoot: false, mimeType: playlist.segmentFormat.mimeType, }); - target._start(); + + let fragmentedIsobmffOutput: FragmentedIsobmffOutput | null = null; + if (playlist.segmentFormat._isFragmentedIsobmff()) { + // HARDCODED SPECIAL CASE: Single file mode with fragmented ISOBMFF. Instead of merely creating + // a single file that's the concatenation of a bunch of smaller files, here we actually produce + // one single fMP4 file that holds all segment media data. The result is a segments file that is + // playable standalone! + + fragmentedIsobmffOutput = { + output: new Output({ + format: playlist.segmentFormat, + target, + }), + videoSource: null, + audioSource: null, + firstMoofPosition: null, + currentFileSize: 0, + }; + + target.on('write', ({ end }) => { + fragmentedIsobmffOutput!.currentFileSize = Math.max( + fragmentedIsobmffOutput!.currentFileSize, + end, + ); + }); + + // Make sure it never auto-finalizes fragments for us; we take full control of fragment + // finalization to line it up perfectly with segments + const muxer = fragmentedIsobmffOutput.output._muxer as IsobmffMuxer; + muxer.minimumFragmentDuration = Infinity; + + // Intercept the first moof to determine init segment size + const originalOnMoof = muxer.formatOptions.onMoof; + muxer.formatOptions.onMoof = (data, position, timestamp) => { + fragmentedIsobmffOutput!.firstMoofPosition = position; + originalOnMoof?.(data, position, timestamp); + muxer.formatOptions.onMoof = originalOnMoof; + }; + + // Add video track + if (videoTrack) { + fragmentedIsobmffOutput.videoSource = new EncodedVideoPacketSource( + (videoTrack.track as OutputVideoTrack).source._codec, + ); + fragmentedIsobmffOutput.output.addVideoTrack( + fragmentedIsobmffOutput.videoSource, + videoTrack.track.metadata, + ); + } + + // Add audio track + if (audioTrack) { + fragmentedIsobmffOutput.audioSource = new EncodedAudioPacketSource( + (audioTrack.track as OutputAudioTrack).source._codec, + ); + fragmentedIsobmffOutput.output.addAudioTrack( + fragmentedIsobmffOutput.audioSource, + audioTrack.track.metadata, + ); + } + + await fragmentedIsobmffOutput.output.start(); + } else { + target._start(); + } playlist.singleFile = { target, path: relativeSegmentPath, nextOffset: 0, info: segmentInfo, + fragmentedIsobmffOutput, }; } else { relativeSegmentPath = playlist.singleFile.path; @@ -889,115 +968,126 @@ export class HlsMuxer extends Muxer { let segmentSize = 0; let outputTarget: Target | null = null; + let maxEndTimestamp = -Infinity; - const output = new Output({ - format: playlist.segmentFormat, - target: new PathedTarget( - fullSegmentPath, - async (request: TargetRequest) => { - const proxiedRequest: TargetRequest = { - ...request, - isRoot: false, - }; + let output: Output | null = null; + let videoSource: EncodedVideoPacketSource | null = null; + let audioSource: EncodedAudioPacketSource | null = null; + + try { + if (playlist.singleFile?.fragmentedIsobmffOutput) { + output = playlist.singleFile.fragmentedIsobmffOutput.output; + videoSource = playlist.singleFile.fragmentedIsobmffOutput.videoSource; + audioSource = playlist.singleFile.fragmentedIsobmffOutput.audioSource; + } else { + // Create the output for this segment + output = new Output({ + format: playlist.segmentFormat, + target: new PathedTarget( + fullSegmentPath, + async (request: TargetRequest) => { + const proxiedRequest: TargetRequest = { + ...request, + isRoot: false, + }; + + if (request.isRoot) { + if (playlist.singleFile) { + const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); + slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); + + return slice; + } else { + const target = await this.output._getTarget(proxiedRequest); + outputTarget = target; + target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); + + return target; + } + } + + return this.output._getTarget(proxiedRequest); + }, + ), + initTarget: async () => { + if (playlist.initSegment) { + // We already have an init segment from a previous segment + return new NullTarget(); + } - if (request.isRoot) { if (playlist.singleFile) { + playlist.initSegment = { + path: playlist.singleFile.path, + duration: 0, + timestamp: 0, + byteSize: 0, + byteOffset: 0, + info: null, + }; + const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); - slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); + slice.on('write', ({ end }) => { + playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); + }); + slice.on('finalized', () => { + playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize; + }); return slice; } else { - const target = await this.output._getTarget(proxiedRequest); - outputTarget = target; - target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); + const playlistInfo = toPlaylistInfo(playlist); + const initPath = await this.getInitPath(playlistInfo); + validateInitPath(initPath); + + playlist.initSegment = { + path: initPath, + duration: 0, + timestamp: 0, + byteSize: 0, + byteOffset: null, + info: null, + }; + + const fullInitPath = joinPaths( + joinPaths(pathedTarget.rootPath, playlist.path), + initPath, + ); + const target = await this.output._getTarget({ + path: fullInitPath, + isRoot: false, + mimeType: playlist.segmentFormat.mimeType, + }); + target.on('write', ({ end }) => { + playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); + }); + target.on('finalized', () => { + this.format._options.onInit?.(target, playlistInfo); + }); return target; } - } + }, + }); - return this.output._getTarget(proxiedRequest); - }, - ), - initTarget: async () => { - if (playlist.initSegment) { - // We already have an init segment from a previous segment - return new NullTarget(); - } - - if (playlist.singleFile) { - playlist.initSegment = { - path: playlist.singleFile.path, - duration: 0, - timestamp: 0, - byteSize: 0, - byteOffset: 0, - info: null, - }; - - const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); - slice.on('write', ({ end }) => { - playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); - }); - slice.on('finalized', () => { - playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize; - }); - - return slice; - } else { - const playlistInfo = toPlaylistInfo(playlist); - const initPath = await this.getInitPath(playlistInfo); - validateInitPath(initPath); - - playlist.initSegment = { - path: initPath, - duration: 0, - timestamp: 0, - byteSize: 0, - byteOffset: null, - info: null, - }; - - const fullInitPath = joinPaths( - joinPaths(pathedTarget.rootPath, playlist.path), - initPath, + if (videoTrack) { + // Always add the track, no matter if it has packets or not (maintains underlying IDs) + videoSource = new EncodedVideoPacketSource( + (videoTrack.track as OutputVideoTrack).source._codec, ); - const target = await this.output._getTarget({ - path: fullInitPath, - isRoot: false, - mimeType: playlist.segmentFormat.mimeType, - }); - target.on('write', ({ end }) => { - playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); - }); - target.on('finalized', () => { - this.format._options.onInit?.(target, playlistInfo); - }); - - return target; + output.addVideoTrack(videoSource, videoTrack.track.metadata); } - }, - }); - let maxEndTimestamp = -Infinity; + if (audioTrack) { + // Always add the track, no matter if it has packets or not (maintains underlying IDs) + audioSource = new EncodedAudioPacketSource( + (audioTrack.track as OutputAudioTrack).source._codec, + ); + output.addAudioTrack(audioSource, audioTrack.track.metadata); + } - try { - let videoSource: EncodedVideoPacketSource | null = null; - let audioSource: EncodedAudioPacketSource | null = null; - - if (videoTrack) { - // Always add the track, no matter if it has packets or not (maintains underlying IDs) - videoSource = new EncodedVideoPacketSource((videoTrack.track as OutputVideoTrack).source._codec); - output.addVideoTrack(videoSource, videoTrack.track.metadata); + await output.start(); } - if (audioTrack) { - // Always add the track, no matter if it has packets or not (maintains underlying IDs) - audioSource = new EncodedAudioPacketSource((audioTrack.track as OutputAudioTrack).source._codec); - output.addAudioTrack(audioSource, audioTrack.track.metadata); - } - - await output.start(); - // Add all of the packets if (videoTrack) { @@ -1024,9 +1114,32 @@ export class HlsMuxer extends Muxer { } } - await output.finalize(); + if (playlist.singleFile?.fragmentedIsobmffOutput) { + const muxer = playlist.singleFile.fragmentedIsobmffOutput.output._muxer as IsobmffMuxer; + await muxer.forceFragmentFinalization(); + + if ( + playlist.singleFile.fragmentedIsobmffOutput.firstMoofPosition !== null + && !playlist.initSegment + ) { + playlist.initSegment = { + path: playlist.singleFile.path, + duration: 0, + timestamp: 0, + byteSize: playlist.singleFile.fragmentedIsobmffOutput.firstMoofPosition, + byteOffset: 0, + info: null, + }; + playlist.singleFile.nextOffset = playlist.singleFile.fragmentedIsobmffOutput.firstMoofPosition; + } + + segmentSize + = playlist.singleFile.fragmentedIsobmffOutput.currentFileSize - playlist.singleFile.nextOffset; + } else { + await output.finalize(); + } } catch (e) { - await output.cancel(); + await output?.cancel(); throw e; } @@ -1102,8 +1215,12 @@ export class HlsMuxer extends Muxer { playlist.done = true; if (playlist.singleFile) { - await playlist.singleFile.target._flush(); - await playlist.singleFile.target._finalize(); + if (playlist.singleFile.fragmentedIsobmffOutput) { + await playlist.singleFile.fragmentedIsobmffOutput.output.finalize(); + } else { + await playlist.singleFile.target._flush(); + await playlist.singleFile.target._finalize(); + } this.format._options.onSegment?.(playlist.singleFile.target, playlist.singleFile.info); } diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index 60f8e97..dc033e3 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -168,6 +168,7 @@ export const intoTimescale = (timeInSeconds: number, timescale: number, round = export class IsobmffMuxer extends Muxer { format: IsobmffOutputFormat; + formatOptions: IsobmffOutputFormatOptions; private writer: Writer | null = null; private boxWriter: IsobmffBoxWriter | null = null; private initWriter: Writer | null = null; @@ -191,21 +192,23 @@ export class IsobmffMuxer extends Muxer { creationTime = Math.floor(Date.now() / 1000) + TIMESTAMP_OFFSET; private finalizedChunks: Chunk[] = []; + private wroteFragmentedHeader = false; private nextFragmentNumber = 1; // Only relevant for fragmented files, to make sure new fragments start with the highest timestamp seen so far private maxWrittenTimestamp = -Infinity; minWrittenTimestamp = Infinity; maxWrittenEndTimestamp = -Infinity; - private minimumFragmentDuration: number; + minimumFragmentDuration: number; private segmentHeaderSize: number | null = null; constructor(output: Output, format: IsobmffOutputFormat) { super(output); this.format = format; + this.formatOptions = { ...format._options }; this.isQuickTime = format instanceof MovOutputFormat; this.isCmaf = format instanceof CmafOutputFormat; - this.minimumFragmentDuration = format._options.minimumFragmentDuration + this.minimumFragmentDuration = this.formatOptions.minimumFragmentDuration ?? (format instanceof CmafOutputFormat ? Infinity : 1); this.auxWriter.start(); @@ -216,15 +219,15 @@ export class IsobmffMuxer extends Muxer { if (!this.isCmaf) { this.writer = await this.output._getRootWriter(target => ( - this.format._options.fastStart !== undefined - ? this.format._options.fastStart === 'fragmented' + this.formatOptions.fastStart !== undefined + ? this.formatOptions.fastStart === 'fragmented' : target instanceof BufferTarget // Since if this is the case we'll use 'in-memory' )); this.boxWriter = new IsobmffBoxWriter(this.writer); // If the fastStart option isn't defined, enable in-memory fast start if the target is an ArrayBuffer, as // the memory usage remains identical - this.fastStart = this.format._options.fastStart + this.fastStart = this.formatOptions.fastStart ?? (this.writer.target instanceof BufferTarget ? 'in-memory' : false); this.isFragmented = this.fastStart === 'fragmented'; } else { @@ -256,7 +259,7 @@ export class IsobmffMuxer extends Muxer { const boxWriter = this.initBoxWriter ?? this.boxWriter; assert(boxWriter); - if (this.format._options.onFtyp) { + if (this.formatOptions.onFtyp) { boxWriter.writer.startTrackingWrites(); } @@ -267,9 +270,9 @@ export class IsobmffMuxer extends Muxer { cmaf: this.isCmaf, })); - if (this.format._options.onFtyp) { + if (this.formatOptions.onFtyp) { const { data, start } = boxWriter.writer.stopTrackingWrites(); - this.format._options.onFtyp(data, start); + this.formatOptions.onFtyp(data, start); } this.ftypSize = boxWriter.writer.getPos(); @@ -299,7 +302,7 @@ export class IsobmffMuxer extends Muxer { assert(this.writer); assert(this.boxWriter); - if (this.format._options.onMdat) { + if (this.formatOptions.onMdat) { this.writer.startTrackingWrites(); } @@ -1180,13 +1183,13 @@ export class IsobmffMuxer extends Muxer { private async finalizeFragment(flushWriter = !this.isCmaf) { assert(this.isFragmented); - const fragmentNumber = this.nextFragmentNumber++; + if (!this.wroteFragmentedHeader) { + this.wroteFragmentedHeader = true; - if (fragmentNumber === 1) { const boxWriter = this.initBoxWriter ?? this.boxWriter; assert(boxWriter); - if (this.format._options.onMoov) { + if (this.formatOptions.onMoov) { boxWriter.writer.startTrackingWrites(); } @@ -1196,9 +1199,9 @@ export class IsobmffMuxer extends Muxer { const movieBox = moov(this); boxWriter.writeBox(movieBox); - if (this.format._options.onMoov) { + if (this.formatOptions.onMoov) { const { data, start } = boxWriter.writer.stopTrackingWrites(); - this.format._options.onMoov(data, start); + this.formatOptions.onMoov(data, start); } if (this.isCmaf) { @@ -1225,6 +1228,18 @@ export class IsobmffMuxer extends Muxer { // Not all tracks need to be present in every fragment const tracksInFragment = this.trackDatas.filter(x => x.currentChunk); + if (tracksInFragment.length === 0) { + // Zero tracks in this fragment and thus no fragment data + + if (flushWriter) { + await this.writer.flush(); + } + + return; + } + + const fragmentNumber = this.nextFragmentNumber++; + // Create an initial moof box and measure it; we need this to know where the following mdat box will begin const moofBox = moof(fragmentNumber, tracksInFragment); const moofOffset = this.writer.getPos(); @@ -1254,21 +1269,21 @@ export class IsobmffMuxer extends Muxer { } } - if (this.format._options.onMoof) { + if (this.formatOptions.onMoof) { this.writer.startTrackingWrites(); } const newMoofBox = moof(fragmentNumber, tracksInFragment); this.boxWriter.writeBox(newMoofBox); - if (this.format._options.onMoof) { + if (this.formatOptions.onMoof) { const { data, start } = this.writer.stopTrackingWrites(); - this.format._options.onMoof(data, start, fragmentStartTimestamp); + this.formatOptions.onMoof(data, start, fragmentStartTimestamp); } assert(this.writer.getPos() === mdatStartPos); - if (this.format._options.onMdat) { + if (this.formatOptions.onMdat) { this.writer.startTrackingWrites(); } @@ -1286,9 +1301,9 @@ export class IsobmffMuxer extends Muxer { } } - if (this.format._options.onMdat) { + if (this.formatOptions.onMdat) { const { data, start } = this.writer.stopTrackingWrites(); - this.format._options.onMdat(data, start); + this.formatOptions.onMdat(data, start); } for (const trackData of tracksInFragment) { @@ -1321,7 +1336,7 @@ export class IsobmffMuxer extends Muxer { assert(this.ftypSize !== null); this.writer.seek(this.ftypSize + reservedSize); - if (this.format._options.onMdat) { + if (this.formatOptions.onMdat) { this.writer.startTrackingWrites(); } @@ -1422,6 +1437,28 @@ export class IsobmffMuxer extends Muxer { } } + /** Internal function for external callers who want to full control fragment boundaries. */ + async forceFragmentFinalization() { + assert(this.isFragmented); + + const release = await this.mutex.acquire(); + + try { + for (const trackData of this.trackDatas) { + if (trackData.type === 'subtitle' && trackData.track.source._codec === 'webvtt') { + await this.processWebVTTCues(trackData, Infinity); + } + + this.processTimestamps(trackData); + } + + await this.interleaveSamples(true); + await this.finalizeFragment(); + } finally { + release(); + } + } + /** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */ async finalize() { const release = await this.mutex.acquire(); @@ -1493,19 +1530,19 @@ export class IsobmffMuxer extends Muxer { if (mdatSize >= 2 ** 32) this.mdat.largeSize = true; } - if (this.format._options.onMoov) { + if (this.formatOptions.onMoov) { this.writer.startTrackingWrites(); } const movieBox = moov(this); this.boxWriter.writeBox(movieBox); - if (this.format._options.onMoov) { + if (this.formatOptions.onMoov) { const { data, start } = this.writer.stopTrackingWrites(); - this.format._options.onMoov(data, start); + this.formatOptions.onMoov(data, start); } - if (this.format._options.onMdat) { + if (this.formatOptions.onMdat) { this.writer.startTrackingWrites(); } @@ -1520,9 +1557,9 @@ export class IsobmffMuxer extends Muxer { } } - if (this.format._options.onMdat) { + if (this.formatOptions.onMdat) { const { data, start } = this.writer.stopTrackingWrites(); - this.format._options.onMdat(data, start); + this.formatOptions.onMdat(data, start); } } else if (this.isFragmented) { if (this.isCmaf) { @@ -1556,9 +1593,9 @@ export class IsobmffMuxer extends Muxer { this.mdat.largeSize = mdatSize >= 2 ** 32; // Only use the large size if we need it this.boxWriter.patchBox(this.mdat); - if (this.format._options.onMdat) { + if (this.formatOptions.onMdat) { const { data, start } = this.writer.stopTrackingWrites(); - this.format._options.onMdat(data, start); + this.formatOptions.onMdat(data, start); } const movieBox = moov(this); @@ -1567,7 +1604,7 @@ export class IsobmffMuxer extends Muxer { assert(this.ftypSize !== null); this.writer.seek(this.ftypSize); - if (this.format._options.onMoov) { + if (this.formatOptions.onMoov) { this.writer.startTrackingWrites(); } @@ -1577,16 +1614,16 @@ export class IsobmffMuxer extends Muxer { const remainingSpace = this.boxWriter.offsets.get(this.mdat)! - this.writer.getPos(); this.boxWriter.writeBox(free(remainingSpace)); } else { - if (this.format._options.onMoov) { + if (this.formatOptions.onMoov) { this.writer.startTrackingWrites(); } this.boxWriter.writeBox(movieBox); } - if (this.format._options.onMoov) { + if (this.formatOptions.onMoov) { const { data, start } = this.writer.stopTrackingWrites(); - this.format._options.onMoov(data, start); + this.formatOptions.onMoov(data, start); } } diff --git a/src/output-format.ts b/src/output-format.ts index 59d6779..0c5707c 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -109,6 +109,11 @@ export abstract class OutputFormat { _codecUnsupportedHint(codec: MediaCodec) { return ''; } + + /** @internal */ + _isFragmentedIsobmff() { + return false; + } } /** @@ -277,6 +282,11 @@ export abstract class IsobmffOutputFormat extends OutputFormat { _createMuxer(output: Output) { return new IsobmffMuxer(output, this); } + + /** @internal */ + override _isFragmentedIsobmff(): boolean { + return this._options.fastStart === 'fragmented'; + } } /** diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts index e275fe0..b4dd8ea 100644 --- a/test/node/hls-output.test.ts +++ b/test/node/hls-output.test.ts @@ -2004,6 +2004,89 @@ test('Single-file mode', async () => { expect(onSegment).toHaveBeenCalledTimes(1); }); +test('Single-file mode with fragmented MP4 produces proper standalone segment file', async () => { + let playlistText: string | null = null; + let segmentBuffer: ArrayBuffer | null = null; + const segmentPaths = new Set(); + + const onSegment = vi.fn(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new Mp4OutputFormat({ + fastStart: 'fragmented', + minimumFragmentDuration: 0, // This is to be ignored + }), + singleFilePerPlaylist: true, + onSegment, + }), + target: new PathedTarget('', (request) => { + const target = new BufferTarget(); + + if (request.path.includes('playlist')) { + target.on('finalized', () => { + playlistText = new TextDecoder().decode(target.buffer!); + }); + } else if (request.path.includes('segment')) { + segmentPaths.add(request.path); + + target.on('finalized', () => { + segmentBuffer = target.buffer!; + }); + } + + return target; + }), + }); + + const source = videoSource(); + output.addVideoTrack(source); + + await output.start(); + + await source.add(new EncodedPacket(avcPacketData, 'key', 0, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0), avcMetadata); + + expect(onSegment).toHaveBeenCalledTimes(0); + + await output.finalize(); + + // Only one segment file should have been created + expect(segmentPaths.size).toBe(1); + + expect(playlistText).not.toBeNull(); + expect(playlistText!.match(/#EXT-X-BYTERANGE/g)).toHaveLength(2); + expect(playlistText).toContain('#EXT-X-VERSION:6'); + + expect(onSegment).toHaveBeenCalledTimes(1); + + assert(segmentBuffer); + + const str = new TextDecoder('ascii').decode(segmentBuffer); + expect(str.includes('mfra')).toBe(true); // It's a proper standalone fMP4 file + expect(str.split('moov')).toHaveLength(2); // Only one moov box + + using input = new Input({ + source: new BufferSource(segmentBuffer), + formats: ALL_FORMATS, + }); + const track = (await input.getPrimaryVideoTrack())!; + const timestamps: number[] = []; + const sink = new EncodedPacketSink(track); + + for await (const packet of sink.packets()) { + timestamps.push(packet.timestamp); + } + + expect(timestamps).toEqual([0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5]); +}); + test('StreamTarget, write is called for each target', async () => { const writeCounts = new Map();