import { ALL_FORMATS, Conversion, Input, Mp4OutputFormat, Output, Quality, StreamTarget, UrlSource, WebMOutputFormat, type StreamTargetChunk, type VideoCodec, } from "mediabunny"; import { extensionOf, type OptimizeOptions, type OptimizeResult, type ProgressReporter, } from "./types"; const CODEC_CANDIDATES: Record<"mp4" | "webm", VideoCodec[]> = { mp4: ["avc", "hevc"], webm: ["vp9", "av1"], }; function even(value: number): number { return Math.max(2, value - (value % 2)); } interface CollectedChunk { blob: Blob; position: number; sequence: number; } /** * Stream the muxed output into Blob parts instead of one giant ArrayBuffer — * a 4K file would otherwise blow the tab's memory. Chunks may arrive out of * order (the muxer patches headers), so later writes win per byte range when * the final blob is assembled. */ function createStreamOutput(): { target: StreamTarget; toBlob: (type: string) => Blob; } { const chunks: CollectedChunk[] = []; let sequence = 0; const writable = new WritableStream({ write(chunk) { chunks.push({ blob: new Blob([chunk.data.slice()]), position: chunk.position, sequence: sequence++, }); }, }); return { target: new StreamTarget(writable, { chunked: true, chunkSize: 8 * 1024 * 1024, }), toBlob: (type: string) => assembleBlob(chunks, type), }; } function assembleBlob(chunks: CollectedChunk[], type: string): Blob { if (chunks.length === 0) { throw new Error("Encoding produced no output."); } // The muxer may rewrite earlier ranges (header patching), so apply writes // in arrival order — the newest write wins per byte range — and only then // lay the segments out by position. const ordered = [...chunks].sort((a, b) => a.sequence - b.sequence); interface Segment { start: number; end: number; sequence: number; blob: Blob; } let segments: Segment[] = []; for (const chunk of ordered) { const start = chunk.position; const end = start + chunk.blob.size; const next: Segment[] = []; for (const segment of segments) { if (segment.end <= start || segment.start >= end) { next.push(segment); continue; } if (segment.start < start) { next.push({ ...segment, end: start }); } if (segment.end > end) { next.push({ ...segment, start: end, blob: segment.blob.slice(end - segment.start), }); } } next.push({ start, end, sequence: chunk.sequence, blob: chunk.blob }); segments = next; } segments.sort((a, b) => a.start - b.start); const parts: Blob[] = []; let cursor = 0; for (const segment of segments) { if (segment.start !== cursor) { throw new Error("The muxer produced an incomplete file."); } parts.push(segment.blob); cursor = segment.end; } return new Blob(parts, { type }); } /** Transcode with WebCodecs, streaming the source and the result. */ export async function optimizeVideo( url: string, filename: string, 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 probe = new Input({ source: new UrlSource(url, { maxCacheSize: 4 * 1024 * 1024 }), formats: ALL_FORMATS, }); 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" ? extension === "webm" ? "webm" : "mp4" : options.container; 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); 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…"); 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 browser could not configure ${container.toUpperCase()} encoding for ` + `${outputWidth}×${outputHeight} (tried ${tried.join(", ")}). ` + "Try another codec, container or resolution.", ); } winner.conversion.onProgress = (progress: number) => { report(0.08 + Math.min(Math.max(progress, 0), 1) * 0.85, "Encoding…"); }; try { await winner.conversion.execute(); } catch (error) { throw new Error( `Video encoding failed: ${ error instanceof Error ? error.message : "unknown error" }`, ); } finally { winner.input.dispose?.(); } report(0.97, "Packaging…"); return { blob: winner.stream.toBlob(container === "webm" ? "video/webm" : "video/mp4"), width: outputWidth, height: outputHeight, note: `${winner.codec} · ${container}`, }; }