Provide "in-band" encoding errors

This commit is contained in:
Vanilagy
2025-06-05 15:31:38 +02:00
parent d0d847edf8
commit c600b60ee0
6 changed files with 87 additions and 48 deletions
+16 -14
View File
@@ -7,9 +7,9 @@
fileInput.type = 'file'; fileInput.type = 'file';
document.body.append(fileInput); document.body.append(fileInput);
const progress = document.createElement('progress'); const progressElement = document.createElement('progress');
progress.max = 1; progressElement.max = 1;
document.body.append(progress); document.body.append(progressElement);
fileInput.addEventListener('change', async () => { fileInput.addEventListener('change', async () => {
const file = fileInput.files[0]; const file = fileInput.files[0];
@@ -21,7 +21,7 @@
chunked: true, chunked: true,
chunkSize: 2**20 chunkSize: 2**20
}); });
const outputFormat = new Metamuxer.WavOutputFormat(); const outputFormat = new Metamuxer.Mp4OutputFormat();
const button = document.createElement('button'); const button = document.createElement('button');
button.textContent = 'Cancel'; button.textContent = 'Cancel';
@@ -38,10 +38,10 @@
target target
}), }),
audio: { audio: {
numberOfChannels: 1, //numberOfChannels: 1,
sampleRate: 4000 //sampleRate: 4000
//discard: true //discard: true
//forceReencode: true, forceReencode: true,
}, },
/* /*
video: { video: {
@@ -68,11 +68,11 @@
}, },
*/ */
video: { video: {
discard: true, //discard: true,
width: 1280, //width: 1280,
//discard: true, //discard: true,
//width: 640 //width: 640
//forceReencode: true, forceReencode: true,
//rotate: 90 //rotate: 90
//width: 720 ?? 2160, //width: 720 ?? 2160,
//height: 1280 ?? 3840, //height: 1280 ?? 3840,
@@ -87,14 +87,16 @@
start: 0, start: 0,
end: 40 end: 40
}, },
computeProgress: true
}); });
console.log(conversion); console.log(conversion);
function updateProgress() { let progress = 0;
progress.value = conversion.progress; conversion.onProgress = newProgress => progress = newProgress;
if (conversion.progress === 1) { function updateProgress() {
progressElement.value = progress;
if (progress === 1) {
return; return;
} }
+2 -2
View File
@@ -115,7 +115,7 @@
*/ */
let videoSource = new Metamuxer.CanvasSource(canvas, { let videoSource = new Metamuxer.CanvasSource(canvas, {
codec: 'hevc', codec: 'avc',
//fullCodecString: 'avc1.42001f', //fullCodecString: 'avc1.42001f',
bitrate: 1e6 bitrate: 1e6
}); });
@@ -206,5 +206,5 @@ Testing... <00:17.350>One... <00:18.125>Two...
await output.finalize(); await output.finalize();
console.log(target); console.log(target);
download(new Blob([target.buffer]), 'test' + format.fileExtension); //download(new Blob([target.buffer]), 'test' + format.fileExtension);
</script> </script>
-8
View File
@@ -59,9 +59,6 @@ type VideoEncodingConfig = {
packet: EncodedPacket, packet: EncodedPacket,
meta: EncodedVideoChunkMetadata | undefined meta: EncodedVideoChunkMetadata | undefined
) => unknown; ) => unknown;
onEncoderError?: (
error: Error
) => unknown;
onEncoderConfig?: ( onEncoderConfig?: (
config: AudioEncoderConfig config: AudioEncoderConfig
) => unknown; ) => unknown;
@@ -73,7 +70,6 @@ type VideoEncodingConfig = {
- `keyFrameInterval`: The maximum interval in seconds between two adjacent key frames. Defaults to 5 seconds. More frequent key frames improve seeking behavior but increase file size. When using multiple video tracks, this value should be set to the same value for all tracks. - `keyFrameInterval`: The maximum interval in seconds between two adjacent key frames. Defaults to 5 seconds. More frequent key frames improve seeking behavior but increase file size. When using multiple video tracks, this value should be set to the same value for all tracks.
- `fullCodecString`: Allows you to optionally specify the full codec string used by the video encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'avc1.42001f'` when using AVC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically. - `fullCodecString`: Allows you to optionally specify the full codec string used by the video encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'avc1.42001f'` when using AVC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress. - `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
- `onEncoderError`: Called when an error occurs within [VideoEncoder](https://developer.mozilla.org/en-US/docs/Web/API/VideoEncoder).
- `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string. - `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string.
### Audio encoding config ### Audio encoding config
@@ -89,9 +85,6 @@ type AudioEncodingConfig = {
packet: EncodedPacket, packet: EncodedPacket,
meta: EncodedAudioChunkMetadata | undefined meta: EncodedAudioChunkMetadata | undefined
) => unknown; ) => unknown;
onEncoderError?: (
error: Error
) => unknown;
onEncoderConfig?: ( onEncoderConfig?: (
config: AudioEncoderConfig config: AudioEncoderConfig
) => unknown; ) => unknown;
@@ -101,7 +94,6 @@ type AudioEncodingConfig = {
- `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities). - `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities).
- `fullCodecString`: Allows you to optionally specify the full codec string used by the audio encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'mp4a.40.2'` when using AAC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically. - `fullCodecString`: Allows you to optionally specify the full codec string used by the audio encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'mp4a.40.2'` when using AAC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress. - `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
- `onEncoderError`: Called when an error occurs within [AudioEncoder](https://developer.mozilla.org/en-US/docs/Web/API/AudioEncoder).
- `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string. - `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string.
### Subjective qualities ### Subjective qualities
+1 -1
View File
@@ -756,7 +756,7 @@ export class Quality {
/** @internal */ /** @internal */
_toAudioBitrate(codec: AudioCodec) { _toAudioBitrate(codec: AudioCodec) {
if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec) || codec === 'flac') { if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec) || codec === 'flac') {
return -1; return undefined;
} }
const baseRates = { const baseRates = {
+8 -9
View File
@@ -1,8 +1,8 @@
import { import {
AUDIO_CODECS, AUDIO_CODECS,
AudioCodec, AudioCodec,
getFirstEncodableVideoCodec,
getEncodableAudioCodecs, getEncodableAudioCodecs,
getEncodableVideoCodecs,
NON_PCM_AUDIO_CODECS, NON_PCM_AUDIO_CODECS,
Quality, Quality,
QUALITY_HIGH, QUALITY_HIGH,
@@ -520,8 +520,8 @@ export class Conversion {
const bitrate = this._options.video?.bitrate ?? QUALITY_HIGH; const bitrate = this._options.video?.bitrate ?? QUALITY_HIGH;
const encodableCodecs = await getEncodableVideoCodecs(videoCodecs, { width, height, bitrate }); const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, { width, height, bitrate });
if (encodableCodecs.length === 0) { if (!encodableCodec) {
this.discardedTracks.push({ this.discardedTracks.push({
track, track,
reason: 'no_encodable_target_codec', reason: 'no_encodable_target_codec',
@@ -530,7 +530,7 @@ export class Conversion {
} }
const encodingConfig: VideoEncodingConfig = { const encodingConfig: VideoEncodingConfig = {
codec: encodableCodecs[0]!, codec: encodableCodec,
bitrate, bitrate,
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration), onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
}; };
@@ -714,13 +714,12 @@ export class Conversion {
bitrate, bitrate,
}); });
if ( const nonPcmCodec = encodableCodecsWithDefaultParams
encodableCodecsWithDefaultParams .find(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec));
.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec)) if (nonPcmCodec) {
) {
// We are able to encode using a non-PCM codec, but it'll require resampling // We are able to encode using a non-PCM codec, but it'll require resampling
needsResample = true; needsResample = true;
codecOfChoice = encodableCodecsWithDefaultParams[0]!; codecOfChoice = nonPcmCodec;
numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS; numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS;
sampleRate = FALLBACK_SAMPLE_RATE; sampleRate = FALLBACK_SAMPLE_RATE;
} }
+60 -14
View File
@@ -201,8 +201,6 @@ export type VideoEncodingConfig = {
/** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */
onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown; onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown;
/** Called when an error occurs within VideoEncoder. */
onEncoderError?: (error: Error) => unknown;
/** Called when the internal encoder config, as used by the WebCodecs API, is created. */ /** Called when the internal encoder config, as used by the WebCodecs API, is created. */
onEncoderConfig?: (config: VideoEncoderConfig) => unknown; onEncoderConfig?: (config: VideoEncoderConfig) => unknown;
}; };
@@ -238,9 +236,6 @@ const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') { if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedChunk, when provided, must be a function.'); throw new TypeError('config.onEncodedChunk, when provided, must be a function.');
} }
if (config.onEncoderError !== undefined && typeof config.onEncoderError !== 'function') {
throw new TypeError('config.onEncodingError, when provided, must be a function.');
}
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') { if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.'); throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
} }
@@ -259,9 +254,17 @@ class VideoEncoderWrapper {
private customEncoderCallSerializer = new CallSerializer(); private customEncoderCallSerializer = new CallSerializer();
private customEncoderQueueSize = 0; private customEncoderQueueSize = 0;
/**
* Encoders typically throw their errors "out of band", meaning asynchronously in some other execution context.
* However, we want to surface these errors to the user within the normal control flow, so they don't go uncaught.
* So, we keep track of the encoder error and throw it as soon as we get the chance.
*/
private encoderError: Error | null = null;
constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {} constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {}
async add(videoSample: VideoSample, shouldClose: boolean, encodeOptions?: VideoEncoderEncodeOptions) { async add(videoSample: VideoSample, shouldClose: boolean, encodeOptions?: VideoEncoderEncodeOptions) {
this.checkForEncoderError();
this.source._ensureValidAdd(); this.source._ensureValidAdd();
// Ensure video sample size remains constant // Ensure video sample size remains constant
@@ -316,6 +319,9 @@ class VideoEncoderWrapper {
if (shouldClose) { if (shouldClose) {
videoSample.close(); videoSample.close();
} }
})
.catch((error: Error) => {
this.encoderError ??= error;
}); });
if (this.customEncoderQueueSize >= 4) { if (this.customEncoderQueueSize >= 4) {
@@ -411,7 +417,9 @@ class VideoEncoderWrapper {
this.encodingConfig.onEncodedPacket?.(packet, meta); this.encodingConfig.onEncodedPacket?.(packet, meta);
void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta); void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta);
}, },
error: this.encodingConfig.onEncoderError ?? (error => console.error('VideoEncoder error:', error)), error: (error) => {
this.encoderError ??= error;
},
}); });
this.encoder.configure(encoderConfig); this.encoder.configure(encoderConfig);
} }
@@ -424,6 +432,8 @@ class VideoEncoderWrapper {
} }
async flushAndClose() { async flushAndClose() {
this.checkForEncoderError();
if (this.customEncoder) { if (this.customEncoder) {
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush()); void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
await this.customEncoderCallSerializer.call(() => this.customEncoder!.close()); await this.customEncoderCallSerializer.call(() => this.customEncoder!.close());
@@ -431,6 +441,8 @@ class VideoEncoderWrapper {
await this.encoder.flush(); await this.encoder.flush();
this.encoder.close(); this.encoder.close();
} }
this.checkForEncoderError();
} }
getQueueSize() { getQueueSize() {
@@ -440,6 +452,13 @@ class VideoEncoderWrapper {
return this.encoder?.encodeQueueSize ?? 0; return this.encoder?.encodeQueueSize ?? 0;
} }
} }
checkForEncoderError() {
if (this.encoderError) {
this.encoderError.stack = new Error().stack; // Provide a more useful stack trace
throw this.encoderError;
}
}
} }
/** /**
@@ -581,7 +600,11 @@ export class MediaStreamVideoTrackSource extends VideoSource {
return; return;
} }
void this._encoder.add(new VideoSample(videoFrame), true); void this._encoder.add(new VideoSample(videoFrame), true)
.catch((error) => {
this._abortController?.abort();
throw error;
});
}, },
}); });
@@ -681,8 +704,6 @@ export type AudioEncodingConfig = {
/** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */
onEncodedPacket?: (packet: EncodedPacket, meta: EncodedAudioChunkMetadata | undefined) => unknown; onEncodedPacket?: (packet: EncodedPacket, meta: EncodedAudioChunkMetadata | undefined) => unknown;
/** Called when an error occurs within AudioEncoder. */
onEncoderError?: (error: Error) => unknown;
/** Called when the internal encoder config, as used by the WebCodecs API, is created. */ /** Called when the internal encoder config, as used by the WebCodecs API, is created. */
onEncoderConfig?: (config: AudioEncoderConfig) => unknown; onEncoderConfig?: (config: AudioEncoderConfig) => unknown;
}; };
@@ -719,9 +740,6 @@ const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') { if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedChunk, when provided, must be a function.'); throw new TypeError('config.onEncodedChunk, when provided, must be a function.');
} }
if (config.onEncoderError !== undefined && typeof config.onEncoderError !== 'function') {
throw new TypeError('config.onEncodingError, when provided, must be a function.');
}
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') { if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.'); throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
} }
@@ -743,9 +761,17 @@ class AudioEncoderWrapper {
private customEncoderCallSerializer = new CallSerializer(); private customEncoderCallSerializer = new CallSerializer();
private customEncoderQueueSize = 0; private customEncoderQueueSize = 0;
/**
* Encoders typically throw their errors "out of band", meaning asynchronously in some other execution context.
* However, we want to surface these errors to the user within the normal control flow, so they don't go uncaught.
* So, we keep track of the encoder error and throw it as soon as we get the chance.
*/
private encoderError: Error | null = null;
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {} constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
async add(audioSample: AudioSample, shouldClose: boolean) { async add(audioSample: AudioSample, shouldClose: boolean) {
this.checkForEncoderError();
this.source._ensureValidAdd(); this.source._ensureValidAdd();
// Ensure audio parameters remain constant // Ensure audio parameters remain constant
@@ -790,6 +816,9 @@ class AudioEncoderWrapper {
if (shouldClose) { if (shouldClose) {
audioSample.close(); audioSample.close();
} }
})
.catch((error: Error) => {
this.encoderError ??= error;
}); });
if (this.customEncoderQueueSize >= 4) { if (this.customEncoderQueueSize >= 4) {
@@ -957,7 +986,9 @@ class AudioEncoderWrapper {
this.encodingConfig.onEncodedPacket?.(packet, meta); this.encodingConfig.onEncodedPacket?.(packet, meta);
void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta); void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta);
}, },
error: this.encodingConfig.onEncoderError ?? (error => console.error('AudioEncoder error:', error)), error: (error) => {
this.encoderError ??= error;
},
}); });
this.encoder.configure(encoderConfig); this.encoder.configure(encoderConfig);
} }
@@ -1051,6 +1082,8 @@ class AudioEncoderWrapper {
} }
async flushAndClose() { async flushAndClose() {
this.checkForEncoderError();
if (this.customEncoder) { if (this.customEncoder) {
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush()); void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
await this.customEncoderCallSerializer.call(() => this.customEncoder!.close()); await this.customEncoderCallSerializer.call(() => this.customEncoder!.close());
@@ -1058,6 +1091,8 @@ class AudioEncoderWrapper {
await this.encoder.flush(); await this.encoder.flush();
this.encoder.close(); this.encoder.close();
} }
this.checkForEncoderError();
} }
getQueueSize() { getQueueSize() {
@@ -1069,6 +1104,13 @@ class AudioEncoderWrapper {
return this.encoder?.encodeQueueSize ?? 0; return this.encoder?.encodeQueueSize ?? 0;
} }
} }
checkForEncoderError() {
if (this.encoderError) {
this.encoderError.stack = new Error().stack; // Provide a more useful stack trace
throw this.encoderError;
}
}
} }
/** /**
@@ -1234,7 +1276,11 @@ export class MediaStreamAudioTrackSource extends AudioSource {
return; return;
} }
void this._encoder.add(new AudioSample(audioData), true); void this._encoder.add(new AudioSample(audioData), true)
.catch((error) => {
this._abortController?.abort();
throw error;
});
}, },
}); });