mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
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:
+214
-97
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user