Fix error when aborting conversion, expose Conversion class for more control, refactor Output state

This commit is contained in:
Vanilagy
2025-03-02 17:32:56 +01:00
parent 453f8b6884
commit 00975b456c
6 changed files with 321 additions and 280 deletions
+9 -9
View File
@@ -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);
+242 -198
View File
@@ -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<TrackType, number> = {
video: 0,
audio: 0,
subtitle: 0,
};
/** @internal */
_totalTrackCount = 0;
/** @internal */
_trackPromises: Promise<void>[] = [];
/** @internal */
_started: Promise<void>;
/** @internal */
_start: () => void;
/** @internal */
_executed = false;
/** @internal */
_synchronizer = new TrackSynchronizer();
/** @internal */
_totalDuration: number | null = null;
/** @internal */
_maxTimestamps = new Map<number, number>(); // 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<TrackType, number> = {
video: 0,
audio: 0,
subtitle: 0,
};
totalTrackCount = 0;
trackPromises: Promise<void>[] = [];
started: Promise<void>;
start: () => void;
synchronizer = new TrackSynchronizer();
totalDuration: number | null = null;
maxTimestamps = new Map<number, number>(); // 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?.();
}
}
+1 -1
View File
@@ -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,
+7 -7
View File
@@ -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;
}
+61 -63
View File
@@ -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<void> | null = null;
/** @internal */
_canceled = false;
_cancelPromise: Promise<void> | null = null;
/** @internal */
_finalizing = false;
/** @internal */
_finalized = false;
_finalizePromise: Promise<void> | 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<F, T>) {
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();
})();
}
}
+1 -2
View File
@@ -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
- configurable fragmented mp4 fragment size, like the mp4-muxer PR