From 01b9a6181aff8f15487122ece59a5ac2005daf40 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 21:11:46 -0500 Subject: [PATCH] Probe real encoder output, order codecs by it, and add a quality slider Your machine's H.264/HEVC encoders report support and then emit no video samples at all, which no configure-time check can see. The optimizer now test-encodes three frames per codec (cached for 30 days), shows the result as diagnostics in the modal (AVC x / VP9 (hardware) / ...), feeds the working codecs to the worker so Auto tries them first, and tells you to switch to WebM when a container has no usable encoder. Video quality is now a 10-100 slider with a predicted bitrate and size for this clip (mirroring Mediabunny's mapping), which makes the trade-off visible instead of guessing from presets. --- .../src/features/optimize/OptimizeModal.tsx | 139 +++++++++++----- frontend/src/features/optimize/bitrate.ts | 33 ++++ frontend/src/features/optimize/detect.ts | 153 +++++++++++++++--- .../src/features/optimize/optimize.worker.ts | 8 +- frontend/src/features/optimize/types.ts | 19 +-- frontend/src/features/optimize/video.ts | 28 ++-- 6 files changed, 294 insertions(+), 86 deletions(-) create mode 100644 frontend/src/features/optimize/bitrate.ts diff --git a/frontend/src/features/optimize/OptimizeModal.tsx b/frontend/src/features/optimize/OptimizeModal.tsx index ec70786..f4c284b 100644 --- a/frontend/src/features/optimize/OptimizeModal.tsx +++ b/frontend/src/features/optimize/OptimizeModal.tsx @@ -8,25 +8,17 @@ import { cn } from "@/lib/cn"; import { formatBytes } from "@/lib/format"; import type { MediaItem } from "@/lib/types"; -import { detectVideoCodecs } from "./detect"; +import { estimateVideoBitrate, VIDEO_CODEC_CANDIDATES } from "./bitrate"; +import { probeVideoEncoders, workingCodecs } from "./detect"; import { deleteStoredFile } from "./opfs"; import { DEFAULT_OPTIONS, kindForExtension, type OptimizeKind, type OptimizeOptions, - type VideoQualityLevel, } from "./types"; import { useOptimizer } from "./useOptimizer"; -const QUALITY_LEVELS: { value: VideoQualityLevel; label: string }[] = [ - { value: "very-high", label: "Visually lossless" }, - { value: "high", label: "High (recommended)" }, - { value: "medium", label: "Balanced" }, - { value: "low", label: "Small" }, - { value: "very-low", label: "Smallest" }, -]; - const DIMENSION_CHOICES: { value: number | null; label: string }[] = [ { value: null, label: "Keep original" }, { value: 2560, label: "Max 2560 px" }, @@ -190,12 +182,20 @@ export function OptimizeModal({ const [resultInfo, setResultInfo] = useState(null); const codecQuery = useQuery({ - queryKey: ["optimize-codecs"], - queryFn: detectVideoCodecs, + queryKey: ["optimize-encoders"], + queryFn: () => probeVideoEncoders(), enabled: kind === "video", staleTime: Infinity, retry: 0, }); + const probes = codecQuery.data ?? []; + const container: "mp4" | "webm" = + options.container === "keep" + ? item.extension === "webm" + ? "webm" + : "mp4" + : options.container; + const usableCodecs = workingCodecs(probes, container); const applyMutation = useMutation({ mutationFn: async () => { @@ -240,15 +240,45 @@ export function OptimizeModal({ : null; const isVideo = kind === "video"; - const hardwareAvailable = - codecQuery.data?.some((entry) => entry.supported && entry.hardware) ?? - false; const truncated = isVideo && originalInfo?.duration != null && resultInfo?.duration != null && resultInfo.duration + 1 < originalInfo.duration; + // Predicted output size, mirroring Mediabunny's quality -> bitrate mapping. + const estimateCodec = + options.codec !== "auto" + ? options.codec + : (usableCodecs[0] ?? VIDEO_CODEC_CANDIDATES[container][0]); + const estimate = + isVideo && + originalInfo?.duration && + originalInfo.width && + originalInfo.height + ? (() => { + const sourceHeight = originalInfo.height as number; + const sourceWidth = originalInfo.width as number; + const targetHeight = + options.height && options.height < sourceHeight + ? options.height + : sourceHeight; + const targetWidth = Math.round( + sourceWidth * (targetHeight / sourceHeight), + ); + const bitrate = estimateVideoBitrate( + estimateCodec, + targetWidth, + targetHeight, + options.quality, + ); + return { + bitrate, + size: ((bitrate + 128_000) / 8) * (originalInfo.duration as number), + }; + })() + : null; + function process() { setApplied(false); setApplyError(null); @@ -259,6 +289,7 @@ export function OptimizeModal({ filename: item.filename, kind, options, + preferredCodecs: usableCodecs, }); } @@ -531,24 +562,28 @@ export function OptimizeModal({ {kind === "video" ? ( <> - - setOptions((current) => ({ ...current, - qualityLevel: event.target.value as VideoQualityLevel, + quality: Number(event.target.value), })) } - > - {QUALITY_LEVELS.map((level) => ( - - ))} - + className="w-full accent-ctp-mauve" + /> + {estimate ? ( +

+ ≈ {(estimate.bitrate / 1_000_000).toFixed(1)} Mbps · about{" "} + {formatBytes(estimate.size)} for{" "} + {originalInfo?.duration?.toFixed(1)} s +

+ ) : null} @@ -625,13 +661,34 @@ export function OptimizeModal({ /> Prefer hardware encoding -

- {codecQuery.isPending - ? "Checking the encoders this browser offers…" - : hardwareAvailable - ? "Hardware-accelerated encoders are available on this machine." - : "No hardware encoder detected — encoding will use the CPU."} -

+ {codecQuery.isPending ? ( +

+ Test-encoding a few frames to see which encoders really + work here… +

+ ) : usableCodecs.length === 0 ? ( +

+ This browser cannot produce {container.toUpperCase()} video + samples — switch the container to{" "} + {container === "mp4" ? "WebM" : "MP4"}. +

+ ) : ( +

+ Encoders:{" "} + {probes + .map( + (probe) => + `${probe.codec.toUpperCase()}${ + probe.supported && probe.works + ? probe.hardware + ? " (hardware)" + : " ✓" + : " ✗" + }`, + ) + .join(" · ")} +

+ )} ) : null} diff --git a/frontend/src/features/optimize/bitrate.ts b/frontend/src/features/optimize/bitrate.ts new file mode 100644 index 0000000..b1ddd9e --- /dev/null +++ b/frontend/src/features/optimize/bitrate.ts @@ -0,0 +1,33 @@ +import type { VideoCodec } from "mediabunny"; + +export const VIDEO_CODEC_CANDIDATES: Record<"mp4" | "webm", VideoCodec[]> = { + mp4: ["avc", "hevc"], + webm: ["vp9", "av1"], +}; + +/** Codec efficiency factors, mirroring Mediabunny's bitrate mapping. */ +const CODEC_EFFICIENCY: Record = { + avc: 1.0, + hevc: 0.6, + vp9: 0.6, + av1: 0.4, + vp8: 1.2, +}; + +/** + * Mirrors Mediabunny's quality -> bitrate mapping so the UI can predict the + * size of the result before encoding. + */ +export function estimateVideoBitrate( + codec: VideoCodec, + width: number, + height: number, + qualityPercent: number, +): number { + const quality = Math.min(Math.max(qualityPercent / 100, 0.05), 1); + const factor = 0.3 * Math.exp(2.5538 * quality); + const pixels = Math.max(width * height, 1); + const scale = Math.pow(pixels / (1920 * 1080), 0.95); + const base = 3_000_000 * scale * (CODEC_EFFICIENCY[codec] ?? 1); + return Math.ceil((base * factor) / 1000) * 1000; +} diff --git a/frontend/src/features/optimize/detect.ts b/frontend/src/features/optimize/detect.ts index edc53cd..1844c17 100644 --- a/frontend/src/features/optimize/detect.ts +++ b/frontend/src/features/optimize/detect.ts @@ -1,32 +1,139 @@ -import { canEncodeVideo, getEncodableVideoCodecs, type VideoCodec } from "mediabunny"; +import { canEncodeVideo, type VideoCodec } from "mediabunny"; -export interface VideoCodecSupport { +import { VIDEO_CODEC_CANDIDATES } from "./bitrate"; + +export interface VideoEncoderProbe { codec: VideoCodec; supported: boolean; + /** A hardware encoder responds for 1080p (a hint, never a guarantee). */ hardware: boolean; + /** A tiny real encode produced samples — the only reliable check. */ + works: boolean; } -const VIDEO_CODEC_CANDIDATES: VideoCodec[] = ["avc", "hevc", "vp9", "av1"]; +const CACHE_KEY = "j621.optimize.encoder-probe"; +const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // re-probe monthly +const CODEC_STRINGS: Record = { + avc: "avc1.42E01E", + hevc: "hvc1.1.6.L93.B0", + vp9: "vp09.00.10.08", + av1: "av01.0.04M.08", +}; -/** Which codecs this browser can encode, and whether hardware is preferred. */ -export async function detectVideoCodecs(): Promise { - const supported = new Set(await getEncodableVideoCodecs()); - const results: VideoCodecSupport[] = []; - for (const codec of VIDEO_CODEC_CANDIDATES) { - const encodable = supported.has(codec); - let hardware = false; - if (encodable) { - try { - hardware = await canEncodeVideo(codec, { - width: 1920, - height: 1080, - hardwareAcceleration: "prefer-hardware", - }); - } catch { - hardware = false; - } - } - results.push({ codec, supported: encodable, hardware }); +const ALL_CODECS: VideoCodec[] = ["avc", "hevc", "vp9", "av1"]; + +interface CacheEntry { + at: number; + probes: VideoEncoderProbe[]; +} + +function readCache(): VideoEncoderProbe[] | null { + try { + const raw = window.localStorage.getItem(CACHE_KEY); + if (!raw) return null; + const entry = JSON.parse(raw) as CacheEntry; + if (!Array.isArray(entry.probes)) return null; + if (Date.now() - entry.at > CACHE_TTL_MS) return null; + return entry.probes; + } catch { + return null; } - return results; +} + +function writeCache(probes: VideoEncoderProbe[]): void { + try { + const entry: CacheEntry = { at: Date.now(), probes }; + window.localStorage.setItem(CACHE_KEY, JSON.stringify(entry)); + } catch { + // Storage disabled: probing just runs again next time. + } +} + +/** Encoders that this browser can actually produce video samples with. */ +export async function probeVideoEncoders( + force = false, +): Promise { + if (!force) { + const cached = readCache(); + if (cached) return cached; + } + const probes: VideoEncoderProbe[] = []; + for (const codec of ALL_CODECS) { + const supported = await canEncodeVideo(codec, { + width: 640, + height: 360, + }).catch(() => false); + let hardware = false; + let works = false; + if (supported) { + hardware = await canEncodeVideo(codec, { + width: 1920, + height: 1080, + hardwareAcceleration: "prefer-hardware", + }).catch(() => false); + works = await testEncode(codec); + } + probes.push({ codec, supported, hardware, works }); + } + writeCache(probes); + return probes; +} + +/** Some browsers claim support and then emit nothing; do a real micro-encode. */ +async function testEncode(codec: VideoCodec): Promise { + const codecString = CODEC_STRINGS[codec]; + if (typeof VideoEncoder === "undefined" || !codecString) return false; + let chunks = 0; + let failed = false; + const encoder = new VideoEncoder({ + output: () => { + chunks += 1; + }, + error: () => { + failed = true; + }, + }); + try { + const config: VideoEncoderConfig = { + codec: codecString, + width: 640, + height: 360, + bitrate: 600_000, + framerate: 30, + }; + const support = await VideoEncoder.isConfigSupported(config); + if (!support.supported) return false; + encoder.configure(config); + const canvas = new OffscreenCanvas(640, 360); + const context = canvas.getContext("2d"); + if (!context) return false; + for (let index = 0; index < 3; index += 1) { + context.fillStyle = index % 2 === 0 ? "#224466" : "#cc8844"; + context.fillRect(0, 0, 640, 360); + const frame = new VideoFrame(canvas, { timestamp: index * 33_333 }); + encoder.encode(frame, { keyFrame: index === 0 }); + frame.close(); + } + await encoder.flush(); + return !failed && chunks > 0; + } catch { + return false; + } finally { + try { + encoder.close(); + } catch { + // Already closed by the error path. + } + } +} + +export function workingCodecs( + probes: VideoEncoderProbe[], + container: "mp4" | "webm", +): VideoCodec[] { + const candidates = VIDEO_CODEC_CANDIDATES[container]; + return probes + .filter((probe) => probe.supported && probe.works) + .map((probe) => probe.codec) + .filter((codec) => candidates.includes(codec)); } diff --git a/frontend/src/features/optimize/optimize.worker.ts b/frontend/src/features/optimize/optimize.worker.ts index 70b7957..6c3cb26 100644 --- a/frontend/src/features/optimize/optimize.worker.ts +++ b/frontend/src/features/optimize/optimize.worker.ts @@ -20,7 +20,13 @@ scope.onmessage = async (event: MessageEvent) => { if (kind === "video") { // Mediabunny streams the source with range requests — never download a // whole video into memory. - result = await optimizeVideo(url, filename, options, report); + result = await optimizeVideo( + url, + filename, + options, + report, + event.data.preferredCodecs ?? [], + ); } else { report(0.01, "Downloading the original…"); const response = await fetch(url); diff --git a/frontend/src/features/optimize/types.ts b/frontend/src/features/optimize/types.ts index 76ecce7..bd98a6c 100644 --- a/frontend/src/features/optimize/types.ts +++ b/frontend/src/features/optimize/types.ts @@ -1,17 +1,12 @@ /** Shared types for the client-side optimization pipeline. */ +import type { VideoCodec } from "mediabunny"; + export type OptimizeKind = "image" | "animation" | "video"; -export type VideoQualityLevel = - | "very-high" - | "high" - | "medium" - | "low" - | "very-low"; - export interface OptimizeOptions { - // Images (jpg / png / webp) - quality: number; // 1-100 for lossy formats + // Images (jpg / png / webp) and video quality (1-100) + quality: number; pngLevel: number; // 0-6, higher = smaller/slower maxDimension: number | null; // longest edge in px, null keeps the size // Animations (gif / apng) @@ -19,8 +14,7 @@ export interface OptimizeOptions { fps: number | null; // frame-rate cap colors: number; // palette size // Video (mp4 / webm) - codec: "auto" | "avc" | "hevc" | "vp9" | "av1"; - qualityLevel: VideoQualityLevel; + codec: "auto" | VideoCodec; container: "keep" | "mp4" | "webm"; height: number | null; // target height, null keeps the size preferHardware: boolean; @@ -34,7 +28,6 @@ export const DEFAULT_OPTIONS: OptimizeOptions = { fps: null, colors: 192, codec: "auto", - qualityLevel: "high", container: "keep", height: null, preferHardware: true, @@ -45,6 +38,8 @@ export interface OptimizeRequest { filename: string; kind: OptimizeKind; options: OptimizeOptions; + /** Codecs that actually produced samples on this machine, best first. */ + preferredCodecs?: VideoCodec[]; } export interface OptimizeResult { diff --git a/frontend/src/features/optimize/video.ts b/frontend/src/features/optimize/video.ts index 25a24bc..01e1c78 100644 --- a/frontend/src/features/optimize/video.ts +++ b/frontend/src/features/optimize/video.ts @@ -11,6 +11,7 @@ import { } from "mediabunny"; import { assertPlayable } from "./mp4"; +import { VIDEO_CODEC_CANDIDATES } from "./bitrate"; import { createStreamOutput, type StreamOutput } from "./stream"; import { extensionOf, @@ -19,11 +20,6 @@ import { type ProgressReporter, } from "./types"; -const CODEC_CANDIDATES: Record<"mp4" | "webm", VideoCodec[]> = { - mp4: ["avc", "hevc"], - webm: ["vp9", "av1"], -}; - const PREFERENCE_LABEL: Record = { "prefer-hardware": "hardware", "prefer-software": "software", @@ -45,6 +41,7 @@ export async function optimizeVideo( filename: string, options: OptimizeOptions, report: ProgressReporter, + preferredCodecs: VideoCodec[] = [], ): Promise { if (typeof VideoEncoder === "undefined") { throw new Error( @@ -89,11 +86,21 @@ export async function optimizeVideo( // per-frame quantizer that qualitative presets would otherwise use, which // made results inconsistent (and sometimes tiny). const quality = new Quality({ - quality: options.qualityLevel, + quality: Math.min(Math.max(options.quality / 100, 0.05), 1), preferBitrate: true, }); + const containerCodecs = VIDEO_CODEC_CANDIDATES[container]; const codecs: VideoCodec[] = - options.codec === "auto" ? CODEC_CANDIDATES[container] : [options.codec]; + options.codec === "auto" + ? [ + ...new Set([ + ...preferredCodecs.filter((codec) => + containerCodecs.includes(codec), + ), + ...containerCodecs, + ]), + ] + : [options.codec]; const preferences: Attempt["preference"][] = options.preferHardware ? ["prefer-hardware", "prefer-software"] : ["prefer-software"]; @@ -178,9 +185,12 @@ export async function optimizeVideo( } } + const hint = + container === "mp4" + ? "This browser's H.264/HEVC encoders produced no video samples — switch the Container to WebM (VP9) instead." + : "Try another codec or a lower resolution."; throw new Error( - `Video encoding failed on every configuration (${issues.join("; ")}). ` + - "Try WebM output or a lower resolution.", + `Video encoding failed on every configuration (${issues.join("; ")}). ${hint}`, ); }