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