From 4860a4bfe7a6c8dbea9e81a28078b6a411791733 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:11:04 +0200 Subject: [PATCH] Probe encoder support with sample (fixes #90), improve encoder error handling --- dev/convert.html | 13 ++++++------ src/conversion.ts | 50 +++++++++++++++++++++++++++++++++++++-------- src/media-source.ts | 24 ++++++++++++++-------- 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index fd662fd..5558bab 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -18,15 +18,13 @@ const file = fileInput.files[0]; const source = new Mediabunny.BlobSource(file); - const target = new Mediabunny.NullTarget() ?? new Mediabunny.BufferTarget() ?? new Mediabunny.StreamTarget(new WritableStream({ + const target = new Mediabunny.BufferTarget() ?? new Mediabunny.StreamTarget(new WritableStream({ write: console.log }), { chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.Mp4OutputFormat({ - onMoov: console.log - }); + const outputFormat = new Mediabunny.Mp4OutputFormat({}); const button = document.createElement('button'); button.textContent = 'Cancel'; @@ -76,7 +74,8 @@ }, */ video: () => ({ - //codec: 'avc', + forceTranscode: true, + codec: 'avc', //fit: 'contain', //frameRate: 27.123, //width: 320, @@ -98,8 +97,8 @@ //height: 100, }), trim: { - //start: 0, - //end: 20 + start: 0, + end: 5 }, }); console.log(conversion); diff --git a/src/conversion.ts b/src/conversion.ts index c3c1a0a..9738d56 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -46,7 +46,9 @@ import { Rotation, } from './misc'; import { Output, TrackType } from './output'; +import { Mp4OutputFormat } from './output-format'; import { AudioSample, VideoSample } from './sample'; +import { NullTarget } from './target'; /** * The options for media file conversion. @@ -582,7 +584,7 @@ export class Conversion { || this._startTimestamp > 0 || firstTimestamp < 0 || !!trackOptions.frameRate; - const needsRerender = width !== originalWidth + let needsRerender = width !== originalWidth || height !== originalHeight || (totalRotation !== 0 && !outputSupportsRotation); @@ -662,6 +664,43 @@ export class Conversion { const source = new VideoSampleSource(encodingConfig); videoSource = source; + if (!needsRerender) { + // If we're directly passing decoded samples back to the encoder, sometimes the encoder may error due + // to lack of support of certain video frame formats, like when HDR is at play. To check for this, we + // first try to pass a single frame to the encoder to see how it behaves. If it throws, we then fall + // back to the rerender path. + // + // Creating a new temporary Output is sort of hacky, but due to a lack of an isolated encoder API right + // now, this is the simplest way. Will refactor in the future! + + const tempOutput = new Output({ + format: new Mp4OutputFormat(), // Supports all video codecs + target: new NullTarget(), + }); + + const tempSource = new VideoSampleSource(encodingConfig); + tempOutput.addVideoTrack(tempSource); + + await tempOutput.start(); + + const sink = new VideoSampleSink(track); + const firstSample = await sink.getSample(this._startTimestamp); + + if (firstSample) { + try { + await tempSource.add(firstSample); + firstSample.close(); + await tempOutput.finalize(); + } catch (error) { + console.info('Error when probing encoder support. Falling back to rerender path.', error); + needsRerender = true; + void tempOutput.cancel(); + } + } else { + await tempOutput.cancel(); + } + } + if (needsRerender) { this._trackPromises.push((async () => { await this._started; @@ -1084,13 +1123,8 @@ export class Conversion { this._maxTimestamps.set(trackId, Math.max(endTimestamp, this._maxTimestamps.get(trackId) ?? -Infinity)); - let totalTimestamps = 0; - for (const [, timestamp] of this._maxTimestamps) { - totalTimestamps += timestamp; - } - - const averageTimestamp = totalTimestamps / this._totalTrackCount; - const newProgress = clamp(averageTimestamp / this._totalDuration, 0, 1); + const minTimestamp = Math.min(...this._maxTimestamps.values()); + const newProgress = clamp(minTimestamp / this._totalDuration, 0, 1); if (newProgress !== this._lastProgress) { this._lastProgress = newProgress; diff --git a/src/media-source.ts b/src/media-source.ts index 89b5485..b72c791 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -353,6 +353,7 @@ class VideoEncoderWrapper { return; } + const encoderError = new Error(); return this.ensureEncoderPromise = (async () => { const encoderConfig = buildVideoEncoderConfig({ width: videoSample.codedWidth, @@ -411,7 +412,7 @@ class VideoEncoderWrapper { void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta); }, error: (error) => { - error.stack = new Error().stack; // Provide a more useful stack trace + error.stack = encoderError.stack; // Provide a more useful stack trace this.encoderError ??= error; }, }); @@ -426,7 +427,7 @@ class VideoEncoderWrapper { } async flushAndClose(forceClose: boolean) { - this.checkForEncoderError(); + if (!forceClose) this.checkForEncoderError(); if (this.customEncoder) { if (!forceClose) { @@ -439,10 +440,12 @@ class VideoEncoderWrapper { await this.encoder.flush(); } - this.encoder.close(); + if (this.encoder.state !== 'closed') { + this.encoder.close(); + } } - this.checkForEncoderError(); + if (!forceClose) this.checkForEncoderError(); } getQueueSize() { @@ -455,6 +458,7 @@ class VideoEncoderWrapper { checkForEncoderError() { if (this.encoderError) { + this.encoderError.stack = new Error().stack; // Provide an even more useful stack trace throw this.encoderError; } } @@ -990,6 +994,7 @@ class AudioEncoderWrapper { return; } + const encoderError = new Error(); return this.ensureEncoderPromise = (async () => { const { numberOfChannels, sampleRate } = audioSample; @@ -1050,7 +1055,7 @@ class AudioEncoderWrapper { void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta); }, error: (error) => { - error.stack = new Error().stack; // Provide a more useful stack trace + error.stack = encoderError.stack; // Provide a more useful stack trace this.encoderError ??= error; }, }); @@ -1158,7 +1163,7 @@ class AudioEncoderWrapper { } async flushAndClose(forceClose: boolean) { - this.checkForEncoderError(); + if (!forceClose) this.checkForEncoderError(); if (this.customEncoder) { if (!forceClose) { @@ -1171,10 +1176,12 @@ class AudioEncoderWrapper { await this.encoder.flush(); } - this.encoder.close(); + if (this.encoder.state !== 'closed') { + this.encoder.close(); + } } - this.checkForEncoderError(); + if (!forceClose) this.checkForEncoderError(); } getQueueSize() { @@ -1189,6 +1196,7 @@ class AudioEncoderWrapper { checkForEncoderError() { if (this.encoderError) { + this.encoderError.stack = new Error().stack; // Provide an even more useful stack trace throw this.encoderError; } }