diff --git a/dev/convert.html b/dev/convert.html index f88a0c8..f3036a2 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -18,14 +18,12 @@ const target = new Metamuxer.BufferTarget(); const outputFormat = new Metamuxer.WebMOutputFormat() - const abortController = new AbortController(); const button = document.createElement('button'); button.textContent = 'Cancel'; - button.onclick = () => abortController.abort(); + button.onclick = () => conversion.cancel(); document.body.append(button); - console.time() - const res = await Metamuxer.convert({ + const conversion = await Metamuxer.Conversion.init({ input: new Metamuxer.Input({ formats: Metamuxer.ALL_FORMATS, source @@ -77,13 +75,15 @@ start: 0, end: 10 }, - onProgress: (event) => { - progress.value = event.completion; - }, - abortSignal: abortController.signal + computeProgress: true }); + console.log(conversion); + + conversion.onProgress = () => progress.value = conversion.progress; + + console.time(); + await conversion.execute(); console.timeEnd() - console.log(res) console.log("Done", target.buffer); diff --git a/src/conversion.ts b/src/conversion.ts index 93bdcc6..df84e7d 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -62,13 +62,66 @@ export type ConversionOptions = { end: number; }; - onProgress?: (event: { completion: number }) => unknown; - abortSignal?: AbortSignal; + computeProgress?: boolean; +}; + +const FALLBACK_NUMBER_OF_CHANNELS = 2; +const FALLBACK_SAMPLE_RATE = 48000; + +/** @public */ +export const convert = async (options: ConversionOptions) => { + const conversion = await Conversion.init(options); + await conversion.execute(); + return conversion; }; /** @public */ -export type ConversionInfo = { - utilizedTracks: InputTrack[]; +export class Conversion { + /** @internal */ + _options: ConversionOptions; + /** @internal */ + _input: Input; + /** @internal */ + _output: Output; + /** @internal */ + _startTimestamp: number; + /** @internal */ + _endTimestamp: number; + + /** @internal */ + _addedCounts: Record = { + video: 0, + audio: 0, + subtitle: 0, + }; + + /** @internal */ + _totalTrackCount = 0; + + /** @internal */ + _trackPromises: Promise[] = []; + + /** @internal */ + _started: Promise; + /** @internal */ + _start: () => void; + /** @internal */ + _executed = false; + + /** @internal */ + _synchronizer = new TrackSynchronizer(); + + /** @internal */ + _totalDuration: number | null = null; + /** @internal */ + _maxTimestamps = new Map(); // Track ID -> timestamp + + /** @internal */ + _canceled = false; + + progress = 0; + onProgress?: () => unknown = undefined; + utilizedTracks: InputTrack[] = []; discardedTracks: { track: InputTrack; reason: @@ -78,45 +131,16 @@ export type ConversionInfo = { | 'unknownSourceCodec' | 'undecodableSourceCodec' | 'noEncodableTargetCodec'; - }[]; -}; + }[] = []; -const FALLBACK_NUMBER_OF_CHANNELS = 2; -const FALLBACK_SAMPLE_RATE = 48000; + static async init(options: ConversionOptions) { + const conversion = new Conversion(options); + await conversion._init(); -/** @public */ -export const convert = (options: ConversionOptions) => { - const conversion = new Conversion(options); - return conversion.execute(); -}; + return conversion; + } -class Conversion { - input: Input; - output: Output; - startTimestamp: number; - endTimestamp: number; - - addedCounts: Record = { - video: 0, - audio: 0, - subtitle: 0, - }; - - totalTrackCount = 0; - - trackPromises: Promise[] = []; - - started: Promise; - start: () => void; - - synchronizer = new TrackSynchronizer(); - - totalDuration: number | null = null; - maxTimestamps = new Map(); // Track ID -> timestamp - - result: ConversionInfo; - - constructor(public options: ConversionOptions) { + private constructor(options: ConversionOptions) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } @@ -223,50 +247,38 @@ class Conversion { && options.trim.start >= options.trim.end) { throw new TypeError('options.trim.start must be less than options.trim.end.'); } - if (options.onProgress !== undefined && typeof options.onProgress !== 'function') { - throw new TypeError('options.onProgress, when provided, must be a function.'); - } - if (options.abortSignal !== undefined && !(options.abortSignal instanceof AbortSignal)) { - throw new TypeError('options.abortSignal, when provided, must be an AbortSignal.'); + if (options.computeProgress !== undefined && typeof options.computeProgress !== 'boolean') { + throw new TypeError('options.computeProgress, when provided, must be a boolean.'); } - this.input = options.input; - this.output = options.output; + this._options = options; + this._input = options.input; + this._output = options.output; - this.startTimestamp = options.trim?.start ?? 0; - this.endTimestamp = options.trim?.end ?? Infinity; + this._startTimestamp = options.trim?.start ?? 0; + this._endTimestamp = options.trim?.end ?? Infinity; const { promise: started, resolve: start } = promiseWithResolvers(); - this.started = started; - this.start = start; - - this.result = { - utilizedTracks: [], - discardedTracks: [], - }; - - options.abortSignal?.addEventListener('abort', () => { - if (!this.output.isFinalizing) { - void this.output.cancel(); - } - }); + this._started = started; + this._start = start; } - async execute() { - const inputTracks = await this.input.getTracks(); - const outputTrackCounts = this.output.format.getSupportedTrackCounts(); + /** @internal */ + async _init() { + const inputTracks = await this._input.getTracks(); + const outputTrackCounts = this._output.format.getSupportedTrackCounts(); for (const track of inputTracks) { - if (this.totalTrackCount === outputTrackCounts.total.max) { - this.result.discardedTracks.push({ + if (this._totalTrackCount === outputTrackCounts.total.max) { + this.discardedTracks.push({ track, reason: 'maxTrackCountReached', }); continue; } - if (this.addedCounts[track.type] === outputTrackCounts[track.type].max) { - this.result.discardedTracks.push({ + if (this._addedCounts[track.type] === outputTrackCounts[track.type].max) { + this.discardedTracks.push({ track, reason: 'maxTrackCountOfTypeReached', }); @@ -274,52 +286,80 @@ class Conversion { } if (track.isVideoTrack()) { - if (this.options.video?.discard) { - this.result.discardedTracks.push({ + if (this._options.video?.discard) { + this.discardedTracks.push({ track, reason: 'discardedByUser', }); continue; } - await this.processVideoTrack(track); + await this._processVideoTrack(track); } else if (track.isAudioTrack()) { - if (this.options.audio?.discard) { - this.result.discardedTracks.push({ + if (this._options.audio?.discard) { + this.discardedTracks.push({ track, reason: 'discardedByUser', }); continue; } - await this.processAudioTrack(track); + await this._processAudioTrack(track); } } - if (this.options.onProgress) { - this.totalDuration = Math.min( - await this.input.computeDuration() - this.startTimestamp, - this.endTimestamp - this.startTimestamp, + if (this._options.computeProgress) { + this._totalDuration = Math.min( + await this._input.computeDuration() - this._startTimestamp, + this._endTimestamp - this._startTimestamp, ); - this.options.onProgress({ completion: 0 }); + this.onProgress?.(); } - - await this.output.start(); - this.start(); - - await Promise.all(this.trackPromises); - - await this.output.finalize(); - - this.options.onProgress?.({ completion: 1 }); - - return this.result; } - async processVideoTrack(track: InputVideoTrack) { + async execute() { + if (this._executed) { + throw new Error('Conversion cannot be executed twice.'); + } + + this._executed = true; + + await this._output.start(); + this._start(); + + await Promise.all(this._trackPromises); + + if (this._canceled) { + await new Promise(() => {}); // Never resolve + } + + await this._output.finalize(); + + if (this._options.computeProgress) { + this.progress = 1; + this.onProgress?.(); + } + } + + async cancel() { + if (this._output.state === 'finalizing' || this._output.state === 'finalized') { + return; + } + + if (this._canceled) { + console.warn('Conversion already canceled.'); + return; + } + + this._canceled = true; + await this._output.cancel(); + } + + /** @internal */ + async _processVideoTrack(track: InputVideoTrack) { const sourceCodec = track.codec; if (!sourceCodec) { - this.result.discardedTracks.push({ + this.discardedTracks.push({ track, reason: 'unknownSourceCodec', }); @@ -328,8 +368,8 @@ class Conversion { let videoSource: VideoSource; - const totalRotation = normalizeRotation(track.rotation + (this.options.video?.rotate ?? 0)); - const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata; + const totalRotation = normalizeRotation(track.rotation + (this._options.video?.rotate ?? 0)); + const outputSupportsRotation = this._output.format.supportsVideoRotationMetadata; const [originalWidth, originalHeight] = totalRotation % 180 === 0 ? [track.codedWidth, track.codedHeight] @@ -342,78 +382,78 @@ class Conversion { // A lot of video encoders require that the dimensions be multiples of 2 const ceilToMultipleOfTwo = (value: number) => Math.ceil(value / 2) * 2; - if (this.options.video?.width !== undefined && this.options.video.height === undefined) { - width = ceilToMultipleOfTwo(this.options.video.width); + if (this._options.video?.width !== undefined && this._options.video.height === undefined) { + width = ceilToMultipleOfTwo(this._options.video.width); height = ceilToMultipleOfTwo(Math.round(width / aspectRatio)); - } else if (this.options.video?.width === undefined && this.options.video?.height !== undefined) { - height = ceilToMultipleOfTwo(this.options.video.height); + } else if (this._options.video?.width === undefined && this._options.video?.height !== undefined) { + height = ceilToMultipleOfTwo(this._options.video.height); width = ceilToMultipleOfTwo(Math.round(height * aspectRatio)); - } else if (this.options.video?.width !== undefined && this.options.video.height !== undefined) { - width = ceilToMultipleOfTwo(this.options.video.width); - height = ceilToMultipleOfTwo(this.options.video.height); + } else if (this._options.video?.width !== undefined && this._options.video.height !== undefined) { + width = ceilToMultipleOfTwo(this._options.video.width); + height = ceilToMultipleOfTwo(this._options.video.height); } const firstTimestamp = await track.getFirstTimestamp(); - const needsReencode = !!this.options.video?.forceReencode || this.startTimestamp > 0 || firstTimestamp < 0; + const needsReencode = !!this._options.video?.forceReencode || this._startTimestamp > 0 || firstTimestamp < 0; const needsRerender = width !== originalWidth || height !== originalHeight || (totalRotation !== 0 && !outputSupportsRotation); - let videoCodecs = this.output.format.getSupportedVideoCodecs(); + let videoCodecs = this._output.format.getSupportedVideoCodecs(); if ( !needsReencode - && !this.options.video?.bitrate + && !this._options.video?.bitrate && !needsRerender && videoCodecs.includes(sourceCodec) - && (!this.options.video?.codec || this.options.video?.codec === sourceCodec) + && (!this._options.video?.codec || this._options.video?.codec === sourceCodec) ) { // Fast path, we can simply copy over the encoded packets const source = new EncodedVideoPacketSource(sourceCodec); videoSource = source; - this.trackPromises.push((async () => { - await this.started; + this._trackPromises.push((async () => { + await this._started; const sink = new EncodedPacketSink(track); const decoderConfig = await track.getDecoderConfig(); const meta: EncodedVideoChunkMetadata = { decoderConfig: decoderConfig ?? undefined }; - for await (const packet of sink.packets(undefined, this.endTimestamp)) { - if (this.synchronizer.shouldWait(track.id, packet.timestamp)) { - await this.synchronizer.wait(packet.timestamp); + for await (const packet of sink.packets(undefined, this._endTimestamp)) { + if (this._synchronizer.shouldWait(track.id, packet.timestamp)) { + await this._synchronizer.wait(packet.timestamp); } - if (this.options.abortSignal?.aborted) { + if (this._canceled) { return; } await source.add(packet, meta); - this.reportProgress(track.id, packet.timestamp + packet.duration); + this._reportProgress(track.id, packet.timestamp + packet.duration); } await source.close(); - this.synchronizer.closeTrack(track.id); + this._synchronizer.closeTrack(track.id); })()); } else { // We need to decode & reencode the video const canDecode = await track.canDecode(); if (!canDecode) { - this.result.discardedTracks.push({ + this.discardedTracks.push({ track, reason: 'undecodableSourceCodec', }); return; } - if (this.options.video?.codec) { - videoCodecs = videoCodecs.filter(codec => codec === this.options.video?.codec); + if (this._options.video?.codec) { + videoCodecs = videoCodecs.filter(codec => codec === this._options.video?.codec); } const encodableCodecs = await getEncodableVideoCodecs(videoCodecs, { width, height }); if (encodableCodecs.length === 0) { - this.result.discardedTracks.push({ + this.discardedTracks.push({ track, reason: 'noEncodableTargetCodec', }); @@ -422,37 +462,37 @@ class Conversion { const encodingConfig: VideoEncodingConfig = { codec: encodableCodecs[0]!, - bitrate: this.options.video?.bitrate ?? QUALITY_HIGH, - onEncodedPacket: sample => this.reportProgress(track.id, sample.timestamp + sample.duration), + bitrate: this._options.video?.bitrate ?? QUALITY_HIGH, + onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration), }; if (needsRerender) { const source = new VideoFrameSource(encodingConfig); videoSource = source; - this.trackPromises.push((async () => { - await this.started; + this._trackPromises.push((async () => { + await this._started; const sink = new CanvasSink(track, { width, height, - fit: this.options.video?.fit ?? 'fill', + fit: this._options.video?.fit ?? 'fill', rotation: totalRotation, // Bake the rotation into the output poolSize: 1, }); - const iterator = sink.canvases(this.startTimestamp, this.endTimestamp); + const iterator = sink.canvases(this._startTimestamp, this._endTimestamp); for await (const { canvas, timestamp, duration } of iterator) { - if (this.synchronizer.shouldWait(track.id, timestamp)) { - await this.synchronizer.wait(timestamp); + if (this._synchronizer.shouldWait(track.id, timestamp)) { + await this._synchronizer.wait(timestamp); } - if (this.options.abortSignal?.aborted) { + if (this._canceled) { return; } await source.add(new VideoFrame(canvas, { - timestamp: 1e6 * Math.max(timestamp - this.startTimestamp, 0), + timestamp: 1e6 * Math.max(timestamp - this._startTimestamp, 0), duration: 1e6 * duration, })); } @@ -461,21 +501,21 @@ class Conversion { const source = new VideoFrameSource(encodingConfig); videoSource = source; - this.trackPromises.push((async () => { - await this.started; + this._trackPromises.push((async () => { + await this._started; const sink = new VideoFrameSink(track); - for await (const { frame, timestamp } of sink.frames(this.startTimestamp, this.endTimestamp)) { - if (this.synchronizer.shouldWait(track.id, timestamp)) { - await this.synchronizer.wait(timestamp); + for await (const { frame, timestamp } of sink.frames(this._startTimestamp, this._endTimestamp)) { + if (this._synchronizer.shouldWait(track.id, timestamp)) { + await this._synchronizer.wait(timestamp); } const clone = setVideoFrameTiming(frame, { - timestamp: Math.max(timestamp - this.startTimestamp, 0), + timestamp: Math.max(timestamp - this._startTimestamp, 0), }); - if (this.options.abortSignal?.aborted) { + if (this._canceled) { return; } @@ -484,25 +524,26 @@ class Conversion { } await source.close(); - this.synchronizer.closeTrack(track.id); + this._synchronizer.closeTrack(track.id); })()); } } - this.output.addVideoTrack(videoSource, { + this._output.addVideoTrack(videoSource, { languageCode: track.languageCode, rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output }); - this.addedCounts.video++; - this.totalTrackCount++; + this._addedCounts.video++; + this._totalTrackCount++; - this.result.utilizedTracks.push(track); + this.utilizedTracks.push(track); } - async processAudioTrack(track: InputAudioTrack) { + /** @internal */ + async _processAudioTrack(track: InputAudioTrack) { const sourceCodec = track.codec; if (!sourceCodec) { - this.result.discardedTracks.push({ + this.discardedTracks.push({ track, reason: 'unknownSourceCodec', }); @@ -516,55 +557,55 @@ class Conversion { const firstTimestamp = await track.getFirstTimestamp(); - let numberOfChannels = this.options.audio?.numberOfChannels ?? originalNumberOfChannels; - let sampleRate = this.options.audio?.sampleRate ?? originalSampleRate; + let numberOfChannels = this._options.audio?.numberOfChannels ?? originalNumberOfChannels; + let sampleRate = this._options.audio?.sampleRate ?? originalSampleRate; let needsResample = numberOfChannels !== originalNumberOfChannels || sampleRate !== originalSampleRate - || this.startTimestamp > 0 + || this._startTimestamp > 0 || firstTimestamp < 0; - let audioCodecs = this.output.format.getSupportedAudioCodecs(); + let audioCodecs = this._output.format.getSupportedAudioCodecs(); if ( - !this.options.audio?.forceReencode - && !this.options.audio?.bitrate + !this._options.audio?.forceReencode + && !this._options.audio?.bitrate && !needsResample && audioCodecs.includes(sourceCodec) - && (!this.options.audio?.codec || this.options.audio.codec === sourceCodec) + && (!this._options.audio?.codec || this._options.audio.codec === sourceCodec) ) { // Fast path, we can simply copy over the encoded packets const source = new EncodedAudioPacketSource(sourceCodec); audioSource = source; - this.trackPromises.push((async () => { - await this.started; + this._trackPromises.push((async () => { + await this._started; const sink = new EncodedPacketSink(track); const decoderConfig = await track.getDecoderConfig(); const meta: EncodedAudioChunkMetadata = { decoderConfig: decoderConfig ?? undefined }; - for await (const packet of sink.packets(undefined, this.endTimestamp)) { - if (this.synchronizer.shouldWait(track.id, packet.timestamp)) { - await this.synchronizer.wait(packet.timestamp); + for await (const packet of sink.packets(undefined, this._endTimestamp)) { + if (this._synchronizer.shouldWait(track.id, packet.timestamp)) { + await this._synchronizer.wait(packet.timestamp); } - if (this.options.abortSignal?.aborted) { + if (this._canceled) { return; } await source.add(packet, meta); - this.reportProgress(track.id, packet.timestamp + packet.duration); + this._reportProgress(track.id, packet.timestamp + packet.duration); } await source.close(); - this.synchronizer.closeTrack(track.id); + this._synchronizer.closeTrack(track.id); })()); } else { // We need to decode & reencode the audio const canDecode = await track.canDecode(); if (!canDecode) { - this.result.discardedTracks.push({ + this.discardedTracks.push({ track, reason: 'undecodableSourceCodec', }); @@ -573,8 +614,8 @@ class Conversion { let codecOfChoice: AudioCodec | null = null; - if (this.options.audio?.codec) { - audioCodecs = audioCodecs.filter(codec => codec === this.options.audio!.codec); + if (this._options.audio?.codec) { + audioCodecs = audioCodecs.filter(codec => codec === this._options.audio!.codec); } const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, { @@ -611,7 +652,7 @@ class Conversion { } if (codecOfChoice === null) { - this.result.discardedTracks.push({ + this.discardedTracks.push({ track, reason: 'noEncodableTargetCodec', }); @@ -619,25 +660,25 @@ class Conversion { } if (needsResample) { - audioSource = this.resampleAudio(track, codecOfChoice, numberOfChannels, sampleRate); + audioSource = this._resampleAudio(track, codecOfChoice, numberOfChannels, sampleRate); } else { const source = new AudioDataSource({ codec: codecOfChoice, - bitrate: this.options.audio?.bitrate ?? QUALITY_HIGH, - onEncodedPacket: packet => this.reportProgress(track.id, packet.timestamp + packet.duration), + bitrate: this._options.audio?.bitrate ?? QUALITY_HIGH, + onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration), }); audioSource = source; - this.trackPromises.push((async () => { - await this.started; + this._trackPromises.push((async () => { + await this._started; const sink = new AudioDataSink(track); - for await (const { data, timestamp } of sink.data(undefined, this.endTimestamp)) { - if (this.synchronizer.shouldWait(track.id, timestamp)) { - await this.synchronizer.wait(timestamp); + for await (const { data, timestamp } of sink.data(undefined, this._endTimestamp)) { + if (this._synchronizer.shouldWait(track.id, timestamp)) { + await this._synchronizer.wait(timestamp); } - if (this.options.abortSignal?.aborted) { + if (this._canceled) { return; } @@ -646,25 +687,26 @@ class Conversion { } await source.close(); - this.synchronizer.closeTrack(track.id); + this._synchronizer.closeTrack(track.id); })()); } } - this.output.addAudioTrack(audioSource, { + this._output.addAudioTrack(audioSource, { languageCode: track.languageCode, }); - this.addedCounts.audio++; - this.totalTrackCount++; + this._addedCounts.audio++; + this._totalTrackCount++; - this.result.utilizedTracks.push(track); + this.utilizedTracks.push(track); } /** * Resamples the audio by decoding it, playing it onto an OfflineAudioContext and encoding the * resulting AudioBuffer. + * @internal */ - resampleAudio( + _resampleAudio( track: InputAudioTrack, codec: AudioCodec, targetNumberOfChannels: number, @@ -672,16 +714,16 @@ class Conversion { ) { const source = new AudioBufferSource({ codec, - bitrate: this.options.audio?.bitrate ?? QUALITY_HIGH, - onEncodedPacket: packet => this.reportProgress(track.id, packet.timestamp + packet.duration), + bitrate: this._options.audio?.bitrate ?? QUALITY_HIGH, + onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration), }); - this.trackPromises.push((async () => { - await this.started; + this._trackPromises.push((async () => { + await this._started; const trackDuration = Math.min( - await track.computeDuration() - this.startTimestamp, - this.endTimestamp - this.startTimestamp, + await track.computeDuration() - this._startTimestamp, + this._endTimestamp - this._startTimestamp, ); const totalFrameCount = Math.round(trackDuration * targetSampleRate); const maxChunkLength = 5 * targetSampleRate; @@ -694,12 +736,14 @@ class Conversion { }); const sink = new AudioBufferSink(track); - for await (const { buffer, timestamp, duration } of sink.buffers(this.startTimestamp, this.endTimestamp)) { - if (this.synchronizer.shouldWait(track.id, timestamp)) { - await this.synchronizer.wait(timestamp); + const iterator = sink.buffers(this._startTimestamp, this._endTimestamp); + + for await (const { buffer, timestamp, duration } of iterator) { + if (this._synchronizer.shouldWait(track.id, timestamp)) { + await this._synchronizer.wait(timestamp); } - const offsetTimestamp = timestamp - this.startTimestamp; + const offsetTimestamp = timestamp - this._startTimestamp; const endTimestamp = offsetTimestamp + duration; // while loop, as a single source buffer may span multiple audio contexts @@ -725,7 +769,7 @@ class Conversion { // Render the audio const renderedBuffer = await currentContext.startRendering(); - if (this.options.abortSignal?.aborted) { + if (this._canceled) { return; } @@ -753,7 +797,7 @@ class Conversion { if (currentContext) { const renderedBuffer = await currentContext.startRendering(); - if (this.options.abortSignal?.aborted) { + if (this._canceled) { return; } @@ -761,30 +805,30 @@ class Conversion { } await source.close(); - this.synchronizer.closeTrack(track.id); + this._synchronizer.closeTrack(track.id); })()); return source; } - reportProgress(trackId: number, endTimestamp: number) { - if (!this.options.onProgress) { + /** @internal */ + _reportProgress(trackId: number, endTimestamp: number) { + if (!this._options.computeProgress) { return; } - assert(this.totalDuration !== null); + assert(this._totalDuration !== null); - this.maxTimestamps.set(trackId, Math.max(endTimestamp, this.maxTimestamps.get(trackId) ?? -Infinity)); + this._maxTimestamps.set(trackId, Math.max(endTimestamp, this._maxTimestamps.get(trackId) ?? -Infinity)); let totalTimestamps = 0; - for (const [, timestamp] of this.maxTimestamps) { + for (const [, timestamp] of this._maxTimestamps) { totalTimestamps += timestamp; } - const averageTimestamp = totalTimestamps / this.totalTrackCount; + const averageTimestamp = totalTimestamps / this._totalTrackCount; - this.options.onProgress({ - completion: 0.99 * clamp(averageTimestamp / this.totalDuration, 0, 1), - }); + this.progress = 0.99 * clamp(averageTimestamp / this._totalDuration, 0, 1); + this.onProgress?.(); } } diff --git a/src/index.ts b/src/index.ts index fe7098b..24ca56a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,7 +109,7 @@ export { AudioBufferSink, WrappedAudioBuffer, } from './media-sink'; -export { convert, ConversionOptions, ConversionInfo } from './conversion'; +export { convert, ConversionOptions, Conversion } from './conversion'; export { CustomVideoDecoder, CustomAudioDecoder, diff --git a/src/media-source.ts b/src/media-source.ts index 6b54752..aaf2767 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -44,16 +44,16 @@ export abstract class MediaSource { throw new Error('Source is not connected to an output track.'); } - if (this._connectedTrack.output._canceled) { + if (this._connectedTrack.output.state === 'canceled') { throw new Error('Output has been canceled.'); } - if (!this._connectedTrack.output._started) { - throw new Error('Output has not started.'); + if (this._connectedTrack.output.state === 'finalizing' || this._connectedTrack.output.state === 'finalized') { + throw new Error('Output has been finalized.'); } - if (this._connectedTrack.output._finalizing) { - throw new Error('Output is finalizing.'); + if (this._connectedTrack.output.state === 'pending') { + throw new Error('Output has not started.'); } if (this._closed) { @@ -77,7 +77,7 @@ export abstract class MediaSource { throw new Error('Cannot call close without connecting the source to an output track.'); } - if (!connectedTrack.output._started) { + if (connectedTrack.output.state === 'pending') { throw new Error('Cannot call close before output has been started.'); } @@ -86,7 +86,7 @@ export abstract class MediaSource { this._closed = true; - if (connectedTrack.output._finalizing) { + if (connectedTrack.output.state === 'finalizing' || connectedTrack.output.state === 'finalized') { return; } diff --git a/src/output.ts b/src/output.ts index b2ae1cd..6589d6e 100644 --- a/src/output.ts +++ b/src/output.ts @@ -72,6 +72,7 @@ export class Output< > { format: F; target: T; + state: 'pending' | 'started' | 'canceled' | 'finalizing' | 'finalized' = 'pending'; /** @internal */ _muxer: Muxer; @@ -80,32 +81,14 @@ export class Output< /** @internal */ _tracks: OutputTrack[] = []; /** @internal */ - _started = false; + _startPromise: Promise | null = null; /** @internal */ - _canceled = false; + _cancelPromise: Promise | null = null; /** @internal */ - _finalizing = false; - /** @internal */ - _finalized = false; + _finalizePromise: Promise | null = null; /** @internal */ _mutex = new AsyncMutex(); - get isStarted() { - return this._started; - } - - get isCanceled() { - return this._canceled; - } - - get isFinalizing() { - return this._finalizing; - } - - get isFinalized() { - return this._finalized; - } - constructor(options: OutputOptions) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); @@ -172,8 +155,8 @@ export class Output< /** @internal */ private _addTrack(type: OutputTrack['type'], source: MediaSource, metadata: object) { - if (this._started) { - throw new Error('Cannot add track after output has started.'); + if (this.state !== 'pending') { + throw new Error('Cannot add track after output has been started or canceled.'); } if (source._connectedTrack) { throw new Error('Source is already used for a track.'); @@ -262,13 +245,6 @@ export class Output< } async start() { - if (this._canceled) { - throw new Error('Output has been canceled.'); - } - if (this._started) { - throw new Error('Output already started.'); - } - // Verify minimum track count constraints const supportedTrackCounts = this.format.getSupportedTrackCounts(); for (const trackType of ALL_TRACK_TYPES) { @@ -298,60 +274,82 @@ export class Output< ); } - this._started = true; - this._writer.start(); - - const release = await this._mutex.acquire(); - - await this._muxer.start(); - - for (const track of this._tracks) { - track.source._start(); + if (this.state === 'canceled') { + throw new Error('Output has been canceled.'); } - release(); + if (this._startPromise) { + console.warn('Output has already been started.'); + return this._startPromise; + } + + return this._startPromise = (async () => { + this.state = 'started'; + this._writer.start(); + + const release = await this._mutex.acquire(); + + await this._muxer.start(); + + for (const track of this._tracks) { + track.source._start(); + } + + release(); + })(); } async cancel() { - if (this._finalizing) { - throw new Error('Cannot cancel after calling finalize.'); + if (this._cancelPromise) { + console.warn('Output has already been canceled.'); + return this._cancelPromise; + } else if (this.state === 'finalizing' || this.state === 'finalized') { + console.warn('Output has already been finalized.'); + return; } - if (this._canceled) { - throw new Error('Output already canceled.'); - } - this._canceled = true; - const release = await this._mutex.acquire(); + return this._cancelPromise = (async () => { + this.state = 'canceled'; - const promises = this._tracks.map(x => x.source._flushOrWaitForClose()); - await Promise.all(promises); + const release = await this._mutex.acquire(); - await this._writer.close(); + const promises = this._tracks.map(x => x.source._flushOrWaitForClose()); + await Promise.all(promises); - release(); + await this._writer.close(); + + release(); + })(); } async finalize() { - if (!this._started) { + if (this.state === 'pending') { throw new Error('Cannot finalize before starting.'); } - if (this._finalizing) { - throw new Error('Cannot call finalize twice.'); + if (this.state === 'canceled') { + throw new Error('Cannot finalize after canceling.'); + } + if (this._finalizePromise) { + console.warn('Output has already been finalized.'); + return this._finalizePromise; } - this._finalizing = true; - const release = await this._mutex.acquire(); + return this._finalizePromise = (async () => { + this.state = 'finalizing'; - const promises = this._tracks.map(x => x.source._flushOrWaitForClose()); - await Promise.all(promises); + const release = await this._mutex.acquire(); - await this._muxer.finalize(); + const promises = this._tracks.map(x => x.source._flushOrWaitForClose()); + await Promise.all(promises); - await this._writer.flush(); - await this._writer.finalize(); + await this._muxer.finalize(); - this._finalized = true; + await this._writer.flush(); + await this._writer.finalize(); - release(); + this.state = 'finalized'; + + release(); + })(); } } diff --git a/todo.txt b/todo.txt index dab71aa..6afd86e 100644 --- a/todo.txt +++ b/todo.txt @@ -2,5 +2,4 @@ - 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 -- configurable fragmented mp4 fragment size, like the mp4-muxer PR -- cancel in convert causes error \ No newline at end of file +- configurable fragmented mp4 fragment size, like the mp4-muxer PR \ No newline at end of file