Add fastStart: 'reserve' option (closes #119)

This commit is contained in:
Vanilagy
2025-09-17 12:26:24 +02:00
parent ec910bdd2c
commit c5523516c7
8 changed files with 270 additions and 47 deletions
+5 -5
View File
@@ -330,17 +330,17 @@ export const ftyp = (details: {
/** Movie Sample Data Box. Contains the actual frames/samples of the media. */
export const mdat = (reserveLargeSize: boolean): Box => ({ type: 'mdat', largeSize: reserveLargeSize });
/** Free Space Box: A box that designates unused space in the movie data file. */
export const free = (size: number): Box => ({ type: 'free', size });
/**
* Movie Box: Used to specify the information that defines a movie - that is, the information that allows
* an application to interpret the sample data that is stored elsewhere.
*/
export const moov = (
muxer: IsobmffMuxer,
fragmented = false,
) => box('moov', undefined, [
export const moov = (muxer: IsobmffMuxer) => box('moov', undefined, [
mvhd(muxer.creationTime, muxer.trackDatas),
...muxer.trackDatas.map(x => trak(x, muxer.creationTime)),
fragmented ? mvex(muxer.trackDatas) : null,
muxer.isFragmented ? mvex(muxer.trackDatas) : null,
udta(muxer),
]);
+123 -14
View File
@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Box, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes';
import { Box, free, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
import { BufferTargetWriter, Writer } from '../writer';
@@ -142,7 +142,7 @@ export class IsobmffMuxer extends Muxer {
private writer: Writer;
private boxWriter: IsobmffBoxWriter;
private fastStart: NonNullable<IsobmffOutputFormatOptions['fastStart']>;
private isFragmented: boolean;
isFragmented: boolean;
isQuickTime: boolean;
@@ -151,6 +151,7 @@ export class IsobmffMuxer extends Muxer {
private auxBoxWriter = new IsobmffBoxWriter(this.auxWriter);
private mdat: Box | null = null;
private ftypSize: number | null = null;
trackDatas: IsobmffTrackData[] = [];
private allTracksKnown = promiseWithResolvers();
@@ -208,8 +209,22 @@ export class IsobmffMuxer extends Muxer {
}
}
this.ftypSize = this.writer.getPos();
if (this.fastStart === 'in-memory') {
this.mdat = mdat(false);
// We're write at finalization
} else if (this.fastStart === 'reserve') {
// Validate that all tracks have set maximumPacketCount
for (const track of this.output._tracks) {
if (track.metadata.maximumPacketCount === undefined) {
throw new Error(
'All tracks must specify maximumPacketCount in their metadata when using'
+ ' fastStart: \'reserve\'.',
);
}
}
// We'll start writing once we know all tracks
} else if (this.isFragmented) {
// We write the moov box once we write out the first fragment to make sure we get the decoder configs
} else {
@@ -830,6 +845,8 @@ export class IsobmffMuxer extends Muxer {
if (this.isFragmented) {
trackData.sampleQueue.push(sample);
await this.interleaveSamples();
} else if (this.fastStart === 'reserve') {
await this.registerSampleFastStartReserve(trackData, sample);
} else {
await this.addSampleToTrack(trackData, sample);
}
@@ -838,6 +855,18 @@ export class IsobmffMuxer extends Muxer {
private async addSampleToTrack(trackData: IsobmffTrackData, sample: Sample) {
if (!this.isFragmented) {
trackData.samples.push(sample);
if (this.fastStart === 'reserve') {
const maximumPacketCount = trackData.track.metadata.maximumPacketCount;
assert(maximumPacketCount !== undefined);
if (trackData.samples.length > maximumPacketCount) {
throw new Error(
`Track #${trackData.track.id} has already reached the maximum packet count`
+ ` (${maximumPacketCount}). Either add less packets or increase the maximum packet count.`,
);
}
}
}
let beginNewChunk = false;
@@ -946,10 +975,8 @@ export class IsobmffMuxer extends Muxer {
private async interleaveSamples(isFinalCall = false) {
assert(this.isFragmented);
if (!isFinalCall) {
if (!this.allTracksAreKnown()) {
return; // We can't interleave yet as we don't yet know how many tracks we'll truly have
}
if (!isFinalCall && !this.allTracksAreKnown()) {
return; // We can't interleave yet as we don't yet know how many tracks we'll truly have
}
outer:
@@ -988,7 +1015,7 @@ export class IsobmffMuxer extends Muxer {
}
// Write the moov box now that we have all decoder configs
const movieBox = moov(this, true);
const movieBox = moov(this);
this.boxWriter.writeBox(movieBox);
if (this.format._options.onMoov) {
@@ -1077,6 +1104,72 @@ export class IsobmffMuxer extends Muxer {
}
}
private async registerSampleFastStartReserve(trackData: IsobmffTrackData, sample: Sample) {
if (this.allTracksAreKnown()) {
if (!this.mdat) {
// We finally know all tracks, let's reserve space for the moov box
const moovBox = moov(this);
const moovSize = this.boxWriter.measureBox(moovBox);
const reservedSize = moovSize
+ this.computeSampleTableSizeUpperBound()
+ 4096; // Just a little extra headroom
assert(this.ftypSize !== null);
this.writer.seek(this.ftypSize + reservedSize);
if (this.format._options.onMdat) {
this.writer.startTrackingWrites();
}
this.mdat = mdat(true);
this.boxWriter.writeBox(this.mdat);
// Now write everything that was queued
for (const trackData of this.trackDatas) {
for (const sample of trackData.sampleQueue) {
await this.addSampleToTrack(trackData, sample);
}
trackData.sampleQueue.length = 0;
}
}
await this.addSampleToTrack(trackData, sample);
} else {
// Queue it for when we know all tracks
trackData.sampleQueue.push(sample);
}
}
private computeSampleTableSizeUpperBound() {
assert(this.fastStart === 'reserve');
let upperBound = 0;
for (const trackData of this.trackDatas) {
const n = trackData.track.metadata.maximumPacketCount;
assert(n !== undefined); // We validated this earlier
// Given the max allowed packet count, compute the space they'll take up in the Sample Table Box, assuming
// the worst case for each individual box:
// stts box - since it is compactly coded, the maximum length of this table will be 2/3n
upperBound += (4 + 4) * Math.ceil(2 / 3 * n);
// stss box - 1 entry per sample
upperBound += 4 * n;
// ctts box - since it is compactly coded, the maximum length of this table will be 2/3n
upperBound += (4 + 4) * Math.ceil(2 / 3 * n);
// stsc box - since it is compactly coded, the maximum length of this table will be 2/3n
upperBound += (4 + 4 + 4) * Math.ceil(2 / 3 * n);
// stsz box - 1 entry per sample
upperBound += 4 * n;
// co64 box - we assume 1 sample per chunk and 64-bit chunk offsets (co64 instead of stco)
upperBound += 8 * n;
}
return upperBound;
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
override async onTrackClose(track: OutputTrack) {
const release = await this.mutex.acquire();
@@ -1128,7 +1221,7 @@ export class IsobmffMuxer extends Muxer {
}
if (this.fastStart === 'in-memory') {
assert(this.mdat);
this.mdat = mdat(false);
let mdatSize: number;
// We know how many chunks there are, but computing the chunk positions requires an iterative approach:
@@ -1214,12 +1307,28 @@ export class IsobmffMuxer extends Muxer {
this.format._options.onMdat(data, start);
}
if (this.format._options.onMoov) {
this.writer.startTrackingWrites();
}
const movieBox = moov(this);
this.boxWriter.writeBox(movieBox);
if (this.fastStart === 'reserve') {
assert(this.ftypSize !== null);
this.writer.seek(this.ftypSize);
if (this.format._options.onMoov) {
this.writer.startTrackingWrites();
}
this.boxWriter.writeBox(movieBox);
// Fill the remaining space with a free box. If there are less than 8 bytes left, sucks I guess
const remainingSpace = this.boxWriter.offsets.get(this.mdat)! - this.writer.getPos();
this.boxWriter.writeBox(free(remainingSpace));
} else {
if (this.format._options.onMoov) {
this.writer.startTrackingWrites();
}
this.boxWriter.writeBox(movieBox);
}
if (this.format._options.onMoov) {
const { data, start } = this.writer.stopTrackingWrites();
+2 -4
View File
@@ -854,10 +854,8 @@ export class MatroskaMuxer extends Muxer {
}
private async interleaveChunks(isFinalCall = false) {
if (!isFinalCall) {
if (!this.allTracksAreKnown()) {
return; // We can't interleave yet as we don't yet know how many tracks we'll truly have
}
if (!isFinalCall && !this.allTracksAreKnown()) {
return; // We can't interleave yet as we don't yet know how many tracks we'll truly have
}
outer:
+13 -3
View File
@@ -115,6 +115,11 @@ export type IsobmffOutputFormatOptions = {
* finalized. This produces a high-quality and compact output at the cost of a more expensive finalization step and
* higher memory requirements. Data will be written monotonically (in order) when this option is set.
*
* Use `'reserve'` to reserve space at the start of the file into which the metadata will be written later. This
* produces a file with Fast Start but requires knowledge about the expected length of the file beforehand. When
* using this option, you must set the {@link BaseTrackMetadata.maximumPacketCount} field in the track metadata
* for all tracks.
*
* Use `'fragmented'` to place metadata at the start of the file by creating a fragmented file (fMP4). In a
* fragmented file, chunks of media and their metadata are written to the file in "fragments", eliminating the need
* to put all metadata in one place. Fragmented files are useful for streaming contexts, as each fragment can be
@@ -126,7 +131,7 @@ export type IsobmffOutputFormatOptions = {
* When this field is not defined, either `false` or `'in-memory'` will be used, automatically determined based on
* the type of output target used.
*/
fastStart?: false | 'in-memory' | 'fragmented';
fastStart?: false | 'in-memory' | 'reserve' | 'fragmented';
/**
* When using `fastStart: 'fragmented'`, this field controls the minimum duration of each fragment, in seconds.
@@ -184,8 +189,13 @@ export abstract class IsobmffOutputFormat extends OutputFormat {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.fastStart !== undefined && ![false, 'in-memory', 'fragmented'].includes(options.fastStart)) {
throw new TypeError('options.fastStart, when provided, must be false, "in-memory", or "fragmented".');
if (
options.fastStart !== undefined
&& ![false, 'in-memory', 'reserve', 'fragmented'].includes(options.fastStart)
) {
throw new TypeError(
'options.fastStart, when provided, must be false, \'in-memory\', \'reserve\', or \'fragmented\'.',
);
}
if (
options.minimumFragmentDuration !== undefined
+21
View File
@@ -74,6 +74,21 @@ export type BaseTrackMetadata = {
languageCode?: string;
/** A user-defined name for this track, like "English" or "Director Commentary". */
name?: string;
/**
* The maximum amount of encoded packets that will be added to this track. Setting this field provides the muxer
* with an additional signal that it can use to preallocate space in the file.
*
* When this field is set, it is an error to provide more packets than whatever this field specifies.
*
* Predicting the maximum packet count requires considering both the maximum duration as well as the codec.
* - For video codecs, you can assume one packet per frame.
* - For audio codecs, there is one packet for each "audio chunk", the duration of which depends on the codec. For
* simplicity, you can assume each packet is roughly 10 ms or 512 samples long, whichever is shorter.
* - For subtitles, assume each cue and each gap in the subtitles adds a packet.
*
* If you're not fully sure, make sure to add a buffer of around 33% to make sure you stay below the maximum.
*/
maximumPacketCount?: number;
};
/**
@@ -114,6 +129,12 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => {
if (metadata.name !== undefined && typeof metadata.name !== 'string') {
throw new TypeError('metadata.name, when provided, must be a string.');
}
if (
metadata.maximumPacketCount !== undefined
&& (!Number.isInteger(metadata.maximumPacketCount) || metadata.maximumPacketCount < 0)
) {
throw new TypeError('metadata.maximumPacketCount, when provided, must be a non-negative integer.');
}
};
/**