diff --git a/dev/convert.html b/dev/convert.html
index 22e8437..ec062dd 100644
--- a/dev/convert.html
+++ b/dev/convert.html
@@ -16,9 +16,9 @@
const source = new Metamuxer.BlobSource(file);
const target = new Metamuxer.BufferTarget();
- const outputFormat = new Metamuxer.Mp4OutputFormat({
- fastStart: 'fragmented'
- })
+ const outputFormat = new Metamuxer.OggOutputFormat({
+ onPage: console.log
+ });
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -75,13 +75,22 @@
},
trim: {
start: 0,
- end: 10
+ end: 60
},
computeProgress: true
});
console.log(conversion);
- conversion.onProgress = () => progress.value = conversion.progress;
+ function updateProgress() {
+ progress.value = conversion.progress;
+
+ if (conversion.progress === 1) {
+ return;
+ }
+
+ setTimeout(updateProgress, 1000/60);
+ }
+ updateProgress();
console.time();
await conversion.execute();
diff --git a/src/conversion.ts b/src/conversion.ts
index 6cf1bf2..de07c12 100644
--- a/src/conversion.ts
+++ b/src/conversion.ts
@@ -374,6 +374,12 @@ export class Conversion {
}
}
+ const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discardedByUser');
+ if (unintentionallyDiscardedTracks.length > 0) {
+ // Let's give the user a notice/warning about discarded tracks so they aren't confused
+ console.warn('Some tracks had to be discarded from the conversion:', unintentionallyDiscardedTracks);
+ }
+
if (this._options.computeProgress) {
this._totalDuration = Math.min(
await this._input.computeDuration() - this._startTimestamp,
diff --git a/src/index.ts b/src/index.ts
index 5ce839b..af28b88 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -19,8 +19,11 @@ export {
WebMOutputFormat,
WebMOutputFormatOptions,
Mp3OutputFormat,
+ Mp3OutputFormatOptions,
WaveOutputFormat,
+ WaveOutputFormatOptions,
OggOutputFormat,
+ OggOutputFormatOptions,
TrackCountLimits,
InclusiveIntegerRange,
} from './output-format';
diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts
index c8936fa..c18a210 100644
--- a/src/isobmff/isobmff-boxes.ts
+++ b/src/isobmff/isobmff-boxes.ts
@@ -317,9 +317,6 @@ 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.
diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts
index 2283d8a..b7068b9 100644
--- a/src/isobmff/isobmff-muxer.ts
+++ b/src/isobmff/isobmff-muxer.ts
@@ -1,4 +1,4 @@
-import { Box, free, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes';
+import { Box, 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';
@@ -97,6 +97,7 @@ export const intoTimescale = (timeInSeconds: number, timescale: number, round =
};
export class IsobmffMuxer extends Muxer {
+ private format: IsobmffOutputFormat;
private writer: Writer;
private boxWriter: IsobmffBoxWriter;
private isMov: boolean;
@@ -107,7 +108,6 @@ export class IsobmffMuxer extends Muxer {
private auxWriter = this.auxTarget._createWriter();
private auxBoxWriter = new IsobmffBoxWriter(this.auxWriter);
- private ftypSize: number | null = null;
private mdat: Box | null = null;
private trackDatas: IsobmffTrackData[] = [];
@@ -123,6 +123,7 @@ export class IsobmffMuxer extends Muxer {
constructor(output: Output, format: IsobmffOutputFormat) {
super(output);
+ this.format = format;
this.writer = output._writer;
this.boxWriter = new IsobmffBoxWriter(this.writer);
@@ -147,19 +148,32 @@ export class IsobmffMuxer extends Muxer {
const holdsAvc = this.output._tracks.some(x => x.type === 'video' && x.source._codec === 'avc');
// Write the header
- this.boxWriter.writeBox(ftyp({
- isMov: this.isMov,
- holdsAvc: holdsAvc,
- fragmented: this.isFragmented,
- }));
+ {
+ if (this.format._options.onFtyp) {
+ this.writer.startTrackingWrites();
+ }
- this.ftypSize = this.writer.getPos();
+ this.boxWriter.writeBox(ftyp({
+ isMov: this.isMov,
+ holdsAvc: holdsAvc,
+ fragmented: this.isFragmented,
+ }));
+
+ if (this.format._options.onFtyp) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onFtyp(data, start);
+ }
+ }
if (this.fastStart === 'in-memory') {
this.mdat = mdat(false);
} 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 {
+ if (this.format._options.onMdat) {
+ this.writer.startTrackingWrites();
+ }
+
this.mdat = mdat(true); // Reserve large size by default, can refine this when finalizing.
this.boxWriter.writeBox(this.mdat);
}
@@ -802,59 +816,82 @@ export class IsobmffMuxer extends Muxer {
const fragmentNumber = this.nextFragmentNumber++;
if (fragmentNumber === 1) {
+ if (this.format._options.onMoov) {
+ this.writer.startTrackingWrites();
+ }
+
// Write the moov box now that we have all decoder configs
const movieBox = moov(this.trackDatas, this.creationTime, true);
this.boxWriter.writeBox(movieBox);
+
+ if (this.format._options.onMoov) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onMoov(data, start);
+ }
}
// Not all tracks need to be present in every fragment
const tracksInFragment = this.trackDatas.filter(x => x.currentChunk);
- // Write out an initial moof box; will be overwritten later once actual chunk offsets are known
- const moofOffset = this.writer.getPos();
+ // 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);
- this.boxWriter.writeBox(moofBox);
+ const moofOffset = this.writer.getPos();
+ const mdatStartPos = moofOffset + this.boxWriter.measureBox(moofBox);
- // Create the mdat box
- {
- const mdatBox = mdat(false); // Initially assume the fragment is not larger than 4 GiB
- let totalTrackSampleSize = 0;
+ // Header with large size. We always reserve 16 bytes for it even if we don't end up using the large size.
+ const mdatHeaderSize = 16;
- // Compute the size of the mdat box
- for (const trackData of tracksInFragment) {
- for (const sample of trackData.currentChunk!.samples) {
- totalTrackSampleSize += sample.size;
- }
+ let currentPos = mdatStartPos + mdatHeaderSize;
+ let fragmentStartTimestamp = Infinity;
+ for (const trackData of tracksInFragment) {
+ trackData.currentChunk!.offset = currentPos;
+ trackData.currentChunk!.moofOffset = moofOffset;
+
+ for (const sample of trackData.currentChunk!.samples) {
+ currentPos += sample.size;
}
- let mdatSize = this.boxWriter.measureBox(mdatBox) + totalTrackSampleSize;
- if (mdatSize >= 2 ** 32) {
- // Fragment is larger than 4 GiB, we need to use the large size
- mdatBox.largeSize = true;
- mdatSize = this.boxWriter.measureBox(mdatBox) + totalTrackSampleSize;
- }
-
- mdatBox.size = mdatSize;
- this.boxWriter.writeBox(mdatBox);
+ fragmentStartTimestamp = Math.min(fragmentStartTimestamp, trackData.currentChunk!.startTimestamp);
}
+ const mdatSize = currentPos - mdatStartPos;
+
+ if (this.format._options.onMoof) {
+ this.writer.startTrackingWrites();
+ }
+
+ const newMoofBox = moof(fragmentNumber, tracksInFragment);
+ this.boxWriter.writeBox(newMoofBox);
+
+ if (this.format._options.onMoof) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onMoof(data, start, fragmentStartTimestamp);
+ }
+
+ assert(this.writer.getPos() === mdatStartPos);
+
+ if (this.format._options.onMdat) {
+ this.writer.startTrackingWrites();
+ }
+
+ const mdatBox = mdat(mdatSize >= 2 ** 32);
+ mdatBox.size = mdatSize;
+ this.boxWriter.writeBox(mdatBox);
+
+ this.writer.seek(mdatStartPos + mdatHeaderSize);
+
// Write sample data
for (const trackData of tracksInFragment) {
- trackData.currentChunk!.offset = this.writer.getPos();
- trackData.currentChunk!.moofOffset = moofOffset;
-
for (const sample of trackData.currentChunk!.samples) {
this.writer.write(sample.data!);
sample.data = null; // Can be GC'd
}
}
- // Now that we set the actual chunk offsets, fix the moof box
- const endPos = this.writer.getPos();
- this.writer.seek(this.boxWriter.offsets.get(moofBox)!);
- const newMoofBox = moof(fragmentNumber, tracksInFragment);
- this.boxWriter.writeBox(newMoofBox);
- this.writer.seek(endPos);
+ if (this.format._options.onMdat) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onMdat(data, start);
+ }
for (const trackData of tracksInFragment) {
trackData.finalizedChunks.push(trackData.currentChunk!);
@@ -937,9 +974,22 @@ export class IsobmffMuxer extends Muxer {
if (mdatSize >= 2 ** 32) this.mdat.largeSize = true;
}
+ if (this.format._options.onMoov) {
+ this.writer.startTrackingWrites();
+ }
+
const movieBox = moov(this.trackDatas, this.creationTime);
this.boxWriter.writeBox(movieBox);
+ if (this.format._options.onMoov) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onMoov(data, start);
+ }
+
+ if (this.format._options.onMdat) {
+ this.writer.startTrackingWrites();
+ }
+
this.mdat.size = mdatSize!;
this.boxWriter.writeBox(this.mdat);
@@ -950,6 +1000,11 @@ export class IsobmffMuxer extends Muxer {
sample.data = null;
}
}
+
+ if (this.format._options.onMdat) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onMdat(data, start);
+ }
} else if (this.isFragmented) {
// Append the mfra box to the end of the file for better random access
const startPos = this.writer.getPos();
@@ -962,7 +1017,6 @@ export class IsobmffMuxer extends Muxer {
this.boxWriter.writeU32(mfraBoxSize);
} else {
assert(this.mdat);
- assert(this.ftypSize !== null);
const mdatPos = this.boxWriter.offsets.get(this.mdat);
assert(mdatPos !== undefined);
@@ -971,16 +1025,21 @@ 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) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onMdat(data, start);
+ }
+
+ if (this.format._options.onMoov) {
+ this.writer.startTrackingWrites();
+ }
+
const movieBox = moov(this.trackDatas, this.creationTime);
+ this.boxWriter.writeBox(movieBox);
- if (typeof this.fastStart === 'object') {
- this.writer.seek(this.ftypSize);
- this.boxWriter.writeBox(movieBox);
-
- const remainingBytes = mdatPos - this.writer.getPos();
- this.boxWriter.writeBox(free(remainingBytes));
- } else {
- this.boxWriter.writeBox(movieBox);
+ if (this.format._options.onMoov) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onMoov(data, start);
}
}
diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts
index 09e12f7..74cb2be 100644
--- a/src/matroska/matroska-muxer.ts
+++ b/src/matroska/matroska-muxer.ts
@@ -168,6 +168,10 @@ export class MatroskaMuxer extends Muxer {
}
private writeEBMLHeader() {
+ if (this.format._options.onEbmlHeader) {
+ this.writer.startTrackingWrites();
+ }
+
const ebmlHeader: EBML = { id: EBMLId.EBML, data: [
{ id: EBMLId.EBMLVersion, data: 1 },
{ id: EBMLId.EBMLReadVersion, data: 1 },
@@ -178,6 +182,11 @@ export class MatroskaMuxer extends Muxer {
{ id: EBMLId.DocTypeReadVersion, data: 2 },
] };
this.ebmlWriter.writeEBML(ebmlHeader);
+
+ if (this.format._options.onEbmlHeader) {
+ const { data, start } = this.writer.stopTrackingWrites(); // start should be 0
+ this.format._options.onEbmlHeader(data, start);
+ }
}
/**
@@ -365,14 +374,16 @@ export class MatroskaMuxer extends Muxer {
};
this.segment = segment;
+ if (this.format._options.onSegmentHeader) {
+ this.writer.startTrackingWrites();
+ }
+
this.ebmlWriter.writeEBML(segment);
- /*
- if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onHeader) {
- let { data, start } = this.#writer.getTrackedWrites(); // start should be 0
- this.#writer.target.options.onHeader(data, start);
+ if (this.format._options.onSegmentHeader) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onSegmentHeader(data, start);
}
- */
}
private createCues() {
@@ -776,15 +787,13 @@ export class MatroskaMuxer extends Muxer {
/** Creates a new Cluster element to contain media chunks. */
private createNewCluster(msTimestamp: number) {
- if (this.currentCluster && !this.format._options.streamable) {
+ if (this.currentCluster) {
this.finalizeCurrentCluster();
}
- /*
- if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onCluster) {
- this.#writer.startTrackingWrites();
+ if (this.format._options.onCluster) {
+ this.writer.startTrackingWrites();
}
- */
this.currentCluster = {
id: EBMLId.Cluster,
@@ -802,20 +811,23 @@ export class MatroskaMuxer extends Muxer {
private finalizeCurrentCluster() {
assert(this.currentCluster);
- const clusterSize = this.writer.getPos() - this.ebmlWriter.dataOffsets.get(this.currentCluster)!;
- const endPos = this.writer.getPos();
- // Write the size now that we know it
- this.writer.seek(this.ebmlWriter.offsets.get(this.currentCluster)! + 4);
- this.ebmlWriter.writeVarInt(clusterSize, CLUSTER_SIZE_BYTES);
- this.writer.seek(endPos);
+ if (!this.format._options.streamable) {
+ const clusterSize = this.writer.getPos() - this.ebmlWriter.dataOffsets.get(this.currentCluster)!;
+ const endPos = this.writer.getPos();
- /*
- if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onCluster) {
- let { data, start } = this.#writer.getTrackedWrites();
- this.#writer.target.options.onCluster(data, start, this.#currentClusterTimestamp);
+ // Write the size now that we know it
+ this.writer.seek(this.ebmlWriter.offsets.get(this.currentCluster)! + 4);
+ this.ebmlWriter.writeVarInt(clusterSize, CLUSTER_SIZE_BYTES);
+ this.writer.seek(endPos);
+ }
+
+ if (this.format._options.onCluster) {
+ assert(this.currentClusterStartMsTimestamp !== null);
+
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onCluster(data, start, this.currentClusterStartMsTimestamp / 1000);
}
- */
const clusterOffsetFromSegment
= this.ebmlWriter.offsets.get(this.currentCluster)! - this.segmentDataOffset;
@@ -869,7 +881,7 @@ export class MatroskaMuxer extends Muxer {
// Flush any remaining queued chunks to the file
await this.interleaveChunks(true);
- if (!this.format._options.streamable && this.currentCluster) {
+ if (this.currentCluster) {
this.finalizeCurrentCluster();
}
diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts
index 1bee238..9f465e6 100644
--- a/src/mp3/mp3-muxer.ts
+++ b/src/mp3/mp3-muxer.ts
@@ -1,21 +1,24 @@
import { assert, toDataView } from '../misc';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack } from '../output';
+import { Mp3OutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { Writer } from '../writer';
import { getXingOffset, INFO, readFrameHeader, XING } from './mp3-misc';
import { Mp3Writer, XingFrameData } from './mp3-writer';
export class Mp3Muxer extends Muxer {
+ private format: Mp3OutputFormat;
private writer: Writer;
private mp3Writer: Mp3Writer;
private xingFrameData: XingFrameData | null = null;
private frameCount = 0;
private framePositions: number[] = [];
- constructor(output: Output) {
+ constructor(output: Output, format: Mp3OutputFormat) {
super(output);
+ this.format = format;
this.writer = output._writer;
this.mp3Writer = new Mp3Writer(output._writer);
}
@@ -120,8 +123,17 @@ export class Mp3Muxer extends Muxer {
this.xingFrameData.fileSize = endPos;
this.xingFrameData.toc = toc;
+ if (this.format._options.onXingFrame) {
+ this.writer.startTrackingWrites();
+ }
+
this.mp3Writer.writeXingFrame(this.xingFrameData);
+ if (this.format._options.onXingFrame) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onXingFrame(data, start);
+ }
+
this.writer.seek(endPos);
release();
diff --git a/src/ogg/ogg-muxer.ts b/src/ogg/ogg-muxer.ts
index 6e06dbb..a198f80 100644
--- a/src/ogg/ogg-muxer.ts
+++ b/src/ogg/ogg-muxer.ts
@@ -2,6 +2,7 @@ import { OPUS_INTERNAL_SAMPLE_RATE, parseOpusIdentificationHeader } from '../cod
import { assert, setInt64, toDataView, toUint8Array } from '../misc';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack } from '../output';
+import { OggOutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { Writer } from '../writer';
import {
@@ -40,6 +41,7 @@ type Packet = {
};
export class OggMuxer extends Muxer {
+ private format: OggOutputFormat;
private writer: Writer;
private trackDatas: OggTrackData[] = [];
@@ -48,9 +50,10 @@ export class OggMuxer extends Muxer {
private pageBytes = new Uint8Array(MAX_PAGE_SIZE);
private pageView = new DataView(this.pageBytes.buffer);
- constructor(output: Output) {
+ constructor(output: Output, format: OggOutputFormat) {
super(output);
+ this.format = format;
this.writer = output._writer;
}
@@ -399,7 +402,16 @@ export class OggMuxer extends Muxer {
trackData.currentPageSize = 27;
trackData.currentPageStartsWithFreshPacket = true;
+ if (this.format._options.onPage) {
+ this.writer.startTrackingWrites();
+ }
+
this.writer.write(slice);
+
+ if (this.format._options.onPage) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onPage(data, start, trackData.track.source);
+ }
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
diff --git a/src/output-format.ts b/src/output-format.ts
index ea16392..ffc3398 100644
--- a/src/output-format.ts
+++ b/src/output-format.ts
@@ -11,6 +11,7 @@ import {
} from './codec';
import { IsobmffMuxer } from './isobmff/isobmff-muxer';
import { MatroskaMuxer } from './matroska/matroska-muxer';
+import { MediaSource } from './media-source';
import { Mp3Muxer } from './mp3/mp3-muxer';
import { Muxer } from './muxer';
import { OggMuxer } from './ogg/ogg-muxer';
@@ -84,7 +85,7 @@ export abstract class OutputFormat {
}
/**
- * Options controlling the format of an output ISOBMFF file.
+ * ISOBMFF-specific output options.
* @public
*/
export type IsobmffOutputFormatOptions = {
@@ -116,6 +117,41 @@ export type IsobmffOutputFormatOptions = {
* New fragments will only be created when the current fragment is longer than this value. Defaults to 1 second.
*/
minimumFragmentDuration?: number;
+
+ /**
+ * Will be called once the ftyp (File Type) box of the output file has been written.
+ *
+ * @param data - The raw bytes.
+ * @param position - The byte offset of the data in the file.
+ */
+ onFtyp?: (data: Uint8Array, position: number) => unknown;
+
+ /**
+ * Will be called once the moov (Movie) box of the output file has been written.
+ *
+ * @param data - The raw bytes.
+ * @param position - The byte offset of the data in the file.
+ */
+ onMoov?: (data: Uint8Array, position: number) => unknown;
+
+ /**
+ * Will be called for each finalized mdat (Media Data) box of the output file. Usage of this callback is not
+ * recommended when not using `fastStart: 'fragmented'`, as there will be one monolithic mdat box which might
+ * require large amounts of memory.
+ *
+ * @param data - The raw bytes.
+ * @param position - The byte offset of the data in the file.
+ */
+ onMdat?: (data: Uint8Array, position: number) => unknown;
+
+ /**
+ * Will be called for each finalized moof (Movie Fragment) box of the output file.
+ *
+ * @param data - The raw bytes.
+ * @param position - The byte offset of the data in the file.
+ * @param timestamp - The start timestamp of the fragment in seconds.
+ */
+ onMoof?: (data: Uint8Array, position: number, timestamp: number) => unknown;
};
/**
@@ -139,6 +175,18 @@ export abstract class IsobmffOutputFormat extends OutputFormat {
) {
throw new TypeError('options.minimumFragmentDuration, when provided, must be a non-negative number.');
}
+ if (options.onFtyp !== undefined && typeof options.onFtyp !== 'function') {
+ throw new TypeError('options.onFtyp, when provided, must be a function.');
+ }
+ if (options.onMoov !== undefined && typeof options.onMoov !== 'function') {
+ throw new TypeError('options.onMoov, when provided, must be a function.');
+ }
+ if (options.onMdat !== undefined && typeof options.onMdat !== 'function') {
+ throw new TypeError('options.onMdat, when provided, must be a function.');
+ }
+ if (options.onMoof !== undefined && typeof options.onMoof !== 'function') {
+ throw new TypeError('options.onMoof, when provided, must be a function.');
+ }
super();
@@ -228,7 +276,7 @@ export class MovOutputFormat extends IsobmffOutputFormat {
}
/**
- * Options controlling the format of an output Matroska file.
+ * Matroska-specific output options.
* @public
*/
export type MkvOutputFormatOptions = {
@@ -244,6 +292,32 @@ export type MkvOutputFormatOptions = {
* when the current cluster is longer than this value. Defaults to 1 second.
*/
minimumClusterDuration?: number;
+
+ /**
+ * Will be called once the EBML header of the output file has been written.
+ *
+ * @param data - The raw bytes.
+ * @param position - The byte offset of the data in the file.
+ */
+ onEbmlHeader?: (data: Uint8Array, position: number) => void;
+
+ /**
+ * Will be called once the header part of the Matroska Segment element has been written. The header data includes
+ * the Segment element and everything inside it, up to (but excluding) the first Matroska Cluster.
+ *
+ * @param data - The raw bytes.
+ * @param position - The byte offset of the data in the file.
+ */
+ onSegmentHeader?: (data: Uint8Array, position: number) => unknown;
+
+ /**
+ * Will be called for each finalized Matroska Cluster of the output file.
+ *
+ * @param data - The raw bytes.
+ * @param position - The byte offset of the data in the file.
+ * @param timestamp - The start timestamp of the cluster in seconds.
+ */
+ onCluster?: (data: Uint8Array, position: number, timestamp: number) => unknown;
};
/**
@@ -267,6 +341,15 @@ export class MkvOutputFormat extends OutputFormat {
) {
throw new TypeError('options.minimumClusterDuration, when provided, must be a non-negative number.');
}
+ if (options.onEbmlHeader !== undefined && typeof options.onEbmlHeader !== 'function') {
+ throw new TypeError('options.onEbmlHeader, when provided, must be a function.');
+ }
+ if (options.onSegmentHeader !== undefined && typeof options.onSegmentHeader !== 'function') {
+ throw new TypeError('options.onHeader, when provided, must be a function.');
+ }
+ if (options.onCluster !== undefined && typeof options.onCluster !== 'function') {
+ throw new TypeError('options.onCluster, when provided, must be a function.');
+ }
super();
@@ -312,7 +395,7 @@ export class MkvOutputFormat extends OutputFormat {
}
/**
- * Options controlling the format of an output WebM file.
+ * WebM-specific output options.
* @public
*/
export type WebMOutputFormatOptions = MkvOutputFormatOptions;
@@ -349,14 +432,44 @@ export class WebMOutputFormat extends MkvOutputFormat {
}
}
+/**
+ * MP3-specific output options.
+ * @public
+ */
+export type Mp3OutputFormatOptions = {
+ /**
+ * Will be called once the Xing metadata frame is finalized.
+ *
+ * @param data - The raw bytes.
+ * @param position - The byte offset of the data in the file.
+ */
+ onXingFrame?: (data: Uint8Array, position: number) => unknown;
+};
+
/**
* MP3 file format.
* @public
*/
export class Mp3OutputFormat extends OutputFormat {
+ /** @internal */
+ _options: Mp3OutputFormatOptions;
+
+ constructor(options: Mp3OutputFormatOptions = {}) {
+ if (!options || typeof options !== 'object') {
+ throw new TypeError('options must be an object.');
+ }
+ if (options.onXingFrame !== undefined && typeof options.onXingFrame !== 'function') {
+ throw new TypeError('options.onXingFrame, when provided, must be a function.');
+ }
+
+ super();
+
+ this._options = options;
+ }
+
/** @internal */
_createMuxer(output: Output) {
- return new Mp3Muxer(output);
+ return new Mp3Muxer(output, this);
}
/** @internal */
@@ -386,14 +499,42 @@ export class Mp3OutputFormat extends OutputFormat {
}
}
+/**
+ * WAVE-specific output options.
+ * @public
+ */
+export type WaveOutputFormatOptions = {
+ /**
+ * Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the
+ * start of the data chunk (with a placeholder size of 0).
+ */
+ onHeader?: (data: Uint8Array, position: number) => unknown;
+};
+
/**
* WAVE file format, based on RIFF.
* @public
*/
export class WaveOutputFormat extends OutputFormat {
+ /** @internal */
+ _options: WaveOutputFormatOptions;
+
+ constructor(options: WaveOutputFormatOptions = {}) {
+ if (!options || typeof options !== 'object') {
+ throw new TypeError('options must be an object.');
+ }
+ if (options.onHeader !== undefined && typeof options.onHeader !== 'function') {
+ throw new TypeError('options.onHeader, when provided, must be a function.');
+ }
+
+ super();
+
+ this._options = options;
+ }
+
/** @internal */
_createMuxer(output: Output) {
- return new WaveMuxer(output);
+ return new WaveMuxer(output, this);
}
/** @internal */
@@ -427,14 +568,45 @@ export class WaveOutputFormat extends OutputFormat {
}
}
+/**
+ * Ogg-specific output options.
+ * @public
+ */
+export type OggOutputFormatOptions = {
+ /**
+ * Will be called for each Ogg page that is written.
+ *
+ * @param data - The raw bytes.
+ * @param position - The byte offset of the data in the file.
+ * @param source - The media source backing the page's logical bitstream (track).
+ */
+ onPage?: (data: Uint8Array, position: number, source: MediaSource) => unknown;
+};
+
/**
* Ogg file format.
* @public
*/
export class OggOutputFormat extends OutputFormat {
+ /** @internal */
+ _options: OggOutputFormatOptions;
+
+ constructor(options: OggOutputFormatOptions = {}) {
+ if (!options || typeof options !== 'object') {
+ throw new TypeError('options must be an object.');
+ }
+ if (options.onPage !== undefined && typeof options.onPage !== 'function') {
+ throw new TypeError('options.onPage, when provided, must be a function.');
+ }
+
+ super();
+
+ this._options = options;
+ }
+
/** @internal */
_createMuxer(output: Output) {
- return new OggMuxer(output);
+ return new OggMuxer(output, this);
}
/** @internal */
diff --git a/src/packet.ts b/src/packet.ts
index a889f8b..9035bbb 100644
--- a/src/packet.ts
+++ b/src/packet.ts
@@ -21,8 +21,8 @@ export class EncodedPacket {
/** The type of this packet. */
public readonly type: PacketType,
/**
- * The timestamp of this packet in seconds. May be negative. Samples with negative end timestamps should not
- * be presented.
+ * The presentation timestamp of this packet in seconds. May be negative. Samples with negative end timestamps
+ * should not be presented.
*/
public readonly timestamp: number,
/** The duration of this packet in seconds. */
diff --git a/src/sample.ts b/src/sample.ts
index 8b14e87..634bc18 100644
--- a/src/sample.ts
+++ b/src/sample.ts
@@ -22,7 +22,7 @@ export type VideoSampleInit = {
codedHeight?: number;
/** The rotation of the frame in degrees, clockwise. */
rotation?: Rotation;
- /** The timestamp of the frame in seconds. */
+ /** The presentation timestamp of the frame in seconds. */
timestamp?: number;
/** The duration of the frame in seconds. */
duration?: number;
@@ -50,8 +50,8 @@ export class VideoSample {
/** The rotation of the frame in degrees, clockwise. */
readonly rotation!: Rotation;
/**
- * The timestamp of the frame in seconds. May be negative. Frames with negative end timestamps should not
- * be presented.
+ * The presentation timestamp of the frame in seconds. May be negative. Frames with negative end timestamps should
+ * not be presented.
*/
readonly timestamp!: number;
/** The duration of the frame in seconds. */
@@ -69,7 +69,7 @@ export class VideoSample {
return this.rotation % 180 === 0 ? this.codedHeight : this.codedWidth;
}
- /** The timestamp of the frame in microseconds. */
+ /** The presentation timestamp of the frame in microseconds. */
get microsecondTimestamp() {
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp);
}
@@ -437,7 +437,7 @@ export class VideoSample {
(this.rotation as Rotation) = newRotation;
}
- /** Sets the timestamp of this video sample, in seconds. */
+ /** Sets the presentation timestamp of this video sample, in seconds. */
setTimestamp(newTimestamp: number) {
if (!Number.isFinite(newTimestamp)) {
throw new TypeError('newTimestamp must be a number.');
@@ -479,7 +479,7 @@ export type AudioSampleInit = {
numberOfChannels: number;
/** The audio sample rate in hertz. */
sampleRate: number;
- /** The timestamp of the sample in seconds. */
+ /** The presentation timestamp of the sample in seconds. */
timestamp: number;
};
@@ -527,12 +527,12 @@ export class AudioSample {
/** The timestamp of the sample in seconds. */
readonly duration: number;
/**
- * The timestamp of the sample in seconds. May be negative. Samples with negative end timestamps should not
- * be presented.
+ * The presentation timestamp of the sample in seconds. May be negative. Samples with negative end timestamps should
+ * not be presented.
*/
readonly timestamp: number;
- /** The timestamp of the sample in microseconds. */
+ /** The presentation timestamp of the sample in microseconds. */
get microsecondTimestamp() {
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp);
}
@@ -928,7 +928,7 @@ export class AudioSample {
return audioBuffer;
}
- /** Sets the timestamp of this audio sample, in seconds. */
+ /** Sets the presentation timestamp of this audio sample, in seconds. */
setTimestamp(newTimestamp: number) {
if (!Number.isFinite(newTimestamp)) {
throw new TypeError('newTimestamp must be a number.');
diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts
index 218ae1b..a4eca79 100644
--- a/src/wave/wave-muxer.ts
+++ b/src/wave/wave-muxer.ts
@@ -5,16 +5,19 @@ import { WaveFormat } from './wave-demuxer';
import { RiffWriter } from './riff-writer';
import { Writer } from '../writer';
import { EncodedPacket } from '../packet';
+import { WaveOutputFormat } from '../output-format';
export class WaveMuxer extends Muxer {
+ private format: WaveOutputFormat;
private writer: Writer;
private riffWriter: RiffWriter;
private headerWritten = false;
private dataSize = 0;
- constructor(output: Output) {
+ constructor(output: Output, format: WaveOutputFormat) {
super(output);
+ this.format = format;
this.writer = output._writer;
this.riffWriter = new RiffWriter(output._writer);
}
@@ -60,6 +63,10 @@ export class WaveMuxer extends Muxer {
}
private writeHeader(track: OutputAudioTrack, config: AudioDecoderConfig) {
+ if (this.format._options.onHeader) {
+ this.writer.startTrackingWrites();
+ }
+
let format: WaveFormat;
const codec = track.source._codec;
@@ -97,6 +104,11 @@ export class WaveMuxer extends Muxer {
// data chunk
this.riffWriter.writeAscii('data');
this.riffWriter.writeU32(0); // Data size placeholder
+
+ if (this.format._options.onHeader) {
+ const { data, start } = this.writer.stopTrackingWrites();
+ this.format._options.onHeader(data, start);
+ }
}
async finalize() {
diff --git a/src/writer.ts b/src/writer.ts
index 2bb6301..2320074 100644
--- a/src/writer.ts
+++ b/src/writer.ts
@@ -19,6 +19,66 @@ export abstract class Writer {
abstract finalize(): Promise;
/** Closes the writer. */
abstract close(): Promise;
+
+ private trackedWrites: Uint8Array | null = null;
+ private trackedStart = -1;
+ private trackedEnd = -1;
+
+ protected maybeTrackWrites(data: Uint8Array) {
+ if (!this.trackedWrites) {
+ return;
+ }
+
+ // Handle negative relative write positions
+ let pos = this.getPos();
+ if (pos < this.trackedStart) {
+ if (pos + data.byteLength <= this.trackedStart) {
+ return;
+ }
+
+ data = data.subarray(this.trackedStart - pos);
+ pos = 0;
+ }
+
+ const neededSize = pos + data.byteLength - this.trackedStart;
+ let newLength = this.trackedWrites.byteLength;
+ while (newLength < neededSize) {
+ newLength *= 2;
+ }
+
+ // Check if we need to resize the buffer
+ if (newLength !== this.trackedWrites.byteLength) {
+ const copy = new Uint8Array(newLength);
+ copy.set(this.trackedWrites, 0);
+ this.trackedWrites = copy;
+ }
+
+ this.trackedWrites.set(data, pos - this.trackedStart);
+ this.trackedEnd = Math.max(this.trackedEnd, pos + data.byteLength);
+ }
+
+ startTrackingWrites() {
+ this.trackedWrites = new Uint8Array(2 ** 10);
+ this.trackedStart = this.getPos();
+ this.trackedEnd = this.trackedStart;
+ }
+
+ stopTrackingWrites() {
+ if (!this.trackedWrites) {
+ throw new Error('Internal error: Can\'t get tracked writes since nothing was tracked.');
+ }
+
+ const slice = this.trackedWrites.subarray(0, this.trackedEnd - this.trackedStart);
+ const result = {
+ data: slice,
+ start: this.trackedStart,
+ end: this.trackedEnd,
+ };
+
+ this.trackedWrites = null;
+
+ return result;
+ }
}
const ARRAY_BUFFER_INITIAL_SIZE = 2 ** 16;
@@ -83,6 +143,8 @@ export class BufferTargetWriter extends Writer {
}
write(data: Uint8Array) {
+ this.maybeTrackWrites(data);
+
this.ensureSize(this.pos + data.byteLength);
this.bytes.set(data, this.pos);
@@ -139,6 +201,8 @@ export class StreamTargetWriter extends Writer {
}
write(data: Uint8Array) {
+ this.maybeTrackWrites(data);
+
this.sections.push({
data: data.slice(),
start: this.pos,
@@ -273,6 +337,8 @@ export class ChunkedStreamTargetWriter extends Writer {
}
write(data: Uint8Array) {
+ this.maybeTrackWrites(data);
+
this.writeDataIntoChunks(data, this.pos);
this.queueChunksForFlush();
diff --git a/todo.txt b/todo.txt
index e444d1e..3f5dfc6 100644
--- a/todo.txt
+++ b/todo.txt
@@ -1,6 +1,4 @@
-- onHeader, etc callbacks for Matroska
- https://github.com/Vanilagy/mp4-muxer/issues/83 tell him it's possible now
-- is this fixed? https://github.com/Vanilagy/webm-muxer/issues/50
- cross-track offset for streaming sources
- textsubtitlesource, chunked piping
- More efficient MP3 loading when reading sequentially
\ No newline at end of file