From 58f030878ee09315286464cb423e02e39c9661ab Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 20:41:16 -0500 Subject: [PATCH] Fix duplicate React keys and the MP4 encoder probe - MatchCard and IqdbCard are siblings in the library detail aside and both used key={item.j_id}, so React warned about duplicate children (J-81 twice). Their keys are now unique per card. - The video pipeline no longer decides "this browser can't encode" from a single getFirstEncodableVideoCodec probe with source dimensions. It now probes Conversion.init with the real (even) output size across codec candidates and hardware preferences, uses the first valid configuration, and reports exactly what was tried when nothing works. It also fails fast with a clear message if VideoEncoder is missing in the worker. --- .../src/features/library/LibraryDetail.tsx | 4 +- frontend/src/features/optimize/video.ts | 113 +++++++++++------- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/frontend/src/features/library/LibraryDetail.tsx b/frontend/src/features/library/LibraryDetail.tsx index b5f8efa..4a1e2cd 100644 --- a/frontend/src/features/library/LibraryDetail.tsx +++ b/frontend/src/features/library/LibraryDetail.tsx @@ -708,10 +708,10 @@ export function LibraryDetail({ jId }: { jId: string }) {

- + {user ? ( - + ) : null}
diff --git a/frontend/src/features/optimize/video.ts b/frontend/src/features/optimize/video.ts index 3906eca..820c0ef 100644 --- a/frontend/src/features/optimize/video.ts +++ b/frontend/src/features/optimize/video.ts @@ -8,7 +8,6 @@ import { StreamTarget, UrlSource, WebMOutputFormat, - getFirstEncodableVideoCodec, type StreamTargetChunk, type VideoCodec, } from "mediabunny"; @@ -125,18 +124,24 @@ export async function optimizeVideo( options: OptimizeOptions, report: ProgressReporter, ): Promise { + if (typeof VideoEncoder === "undefined") { + throw new Error( + "This browser does not expose WebCodecs (VideoEncoder) inside workers.", + ); + } const extension = extensionOf(filename); report(0.03, "Reading the video…"); - const input = new Input({ + const probe = new Input({ source: new UrlSource(url, { maxCacheSize: 4 * 1024 * 1024 }), formats: ALL_FORMATS, }); - const track = await input.getPrimaryVideoTrack(); + const track = await probe.getPrimaryVideoTrack(); if (!track) { throw new Error("No video track found in this file."); } const sourceWidth = await track.getDisplayWidth(); const sourceHeight = await track.getDisplayHeight(); + probe.dispose?.(); const container: "mp4" | "webm" = options.container === "keep" @@ -147,53 +152,69 @@ export async function optimizeVideo( const format = container === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); + const outputHeight = even(options.height ?? sourceHeight); + const outputWidth = even( + Math.round(sourceWidth * (outputHeight / sourceHeight)), + ); const quality = new Quality(options.qualityLevel); - let codec: VideoCodec | undefined = - options.codec === "auto" ? undefined : options.codec; - if (!codec) { - const candidate = await getFirstEncodableVideoCodec( - CODEC_CANDIDATES[container], - { width: sourceWidth, height: sourceHeight, quality }, - ); - if (!candidate) { - throw new Error( - `This browser cannot encode ${container.toUpperCase()} video.`, - ); - } - codec = candidate; - } - - const outputHeight = options.height ?? sourceHeight; - const outputWidth = Math.round(sourceWidth * (outputHeight / sourceHeight)); - - const { target, toBlob } = createStreamOutput(); - const output = new Output({ format, target }); + const candidates: VideoCodec[] = + options.codec === "auto" ? CODEC_CANDIDATES[container] : [options.codec]; + const preferences: ("prefer-hardware" | "no-preference")[] = + options.preferHardware ? ["prefer-hardware", "no-preference"] : ["no-preference"]; report(0.06, "Preparing the encoder…"); - const conversion = await Conversion.init({ - input, - output, - video: { - codec, - quality, - height: even(outputHeight), - fit: "contain", - hardwareAcceleration: options.preferHardware - ? "prefer-hardware" - : "no-preference", - forceTranscode: true, - }, - }); - if (!conversion.isValid) { + let winner: + | { + conversion: Conversion; + input: Input; + stream: ReturnType; + codec: VideoCodec; + } + | null = null; + const tried: string[] = []; + for (const codec of candidates) { + for (const preference of preferences) { + tried.push(`${codec}${preference === "prefer-hardware" ? " (hw)" : ""}`); + const input = new Input({ + source: new UrlSource(url, { maxCacheSize: 4 * 1024 * 1024 }), + formats: ALL_FORMATS, + }); + const stream = createStreamOutput(); + const output = new Output({ format, target: stream.target }); + const attempt = await Conversion.init({ + input, + output, + video: { + codec, + quality, + width: outputWidth, + height: outputHeight, + fit: "contain", + hardwareAcceleration: preference, + forceTranscode: true, + }, + }); + if (attempt.isValid) { + winner = { conversion: attempt, input, stream, codec }; + break; + } + input.dispose?.(); + } + if (winner) break; + } + if (!winner) { throw new Error( - "This conversion is not supported here — try another codec or container.", + `This browser could not configure ${container.toUpperCase()} encoding for ` + + `${outputWidth}×${outputHeight} (tried ${tried.join(", ")}). ` + + "Try another codec, container or resolution.", ); } - conversion.onProgress = (progress: number) => { + + winner.conversion.onProgress = (progress: number) => { report(0.08 + Math.min(Math.max(progress, 0), 1) * 0.85, "Encoding…"); }; try { - await conversion.execute(); + await winner.conversion.execute(); } catch (error) { throw new Error( `Video encoding failed: ${ @@ -201,14 +222,14 @@ export async function optimizeVideo( }`, ); } finally { - input.dispose?.(); + winner.input.dispose?.(); } report(0.97, "Packaging…"); return { - blob: toBlob(container === "webm" ? "video/webm" : "video/mp4"), - width: even(outputWidth), - height: even(outputHeight), - note: `${codec} · ${container}`, + blob: winner.stream.toBlob(container === "webm" ? "video/webm" : "video/mp4"), + width: outputWidth, + height: outputHeight, + note: `${winner.codec} · ${container}`, }; }