Add special-cased HLS muxer behavior with single-file mode and fMP4 in order to create a standalone playable fMP4 file (closes #394)

This commit is contained in:
Vanilagy
2026-08-07 23:03:48 +02:00
parent eceb350806
commit 7ec538e175
4 changed files with 377 additions and 130 deletions
+214 -97
View File
@@ -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<HlsOutputFormatOptions['getPlaylistPath']>;
@@ -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);
}
+70 -33
View File
@@ -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);
}
}
+10
View File
@@ -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';
}
}
/**
+83
View File
@@ -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<string>();
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<string, number>();