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