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.
This commit is contained in:
@@ -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<MediaInfo | null>(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" ? (
|
||||
<>
|
||||
<Field label="Quality">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={options.qualityLevel}
|
||||
<Field label={`Quality — ${options.quality}`}>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={100}
|
||||
value={options.quality}
|
||||
onChange={(event) =>
|
||||
setOptions((current) => ({
|
||||
...current,
|
||||
qualityLevel: event.target.value as VideoQualityLevel,
|
||||
quality: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{QUALITY_LEVELS.map((level) => (
|
||||
<option key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
className="w-full accent-ctp-mauve"
|
||||
/>
|
||||
</Field>
|
||||
{estimate ? (
|
||||
<p className="-mt-2 text-[11px] text-ctp-overlay0">
|
||||
≈ {(estimate.bitrate / 1_000_000).toFixed(1)} Mbps · about{" "}
|
||||
{formatBytes(estimate.size)} for{" "}
|
||||
{originalInfo?.duration?.toFixed(1)} s
|
||||
</p>
|
||||
) : null}
|
||||
<Field label="Resolution">
|
||||
<select
|
||||
className={inputClass}
|
||||
@@ -584,14 +619,15 @@ export function OptimizeModal({
|
||||
}
|
||||
>
|
||||
<option value="auto">Auto (best available)</option>
|
||||
{(codecQuery.data ?? [])
|
||||
.filter((entry) => entry.supported)
|
||||
.map((entry) => (
|
||||
<option key={entry.codec} value={entry.codec}>
|
||||
{entry.codec.toUpperCase()}
|
||||
{entry.hardware ? " (hardware)" : ""}
|
||||
{usableCodecs.map((codec) => {
|
||||
const probe = probes.find((entry) => entry.codec === codec);
|
||||
return (
|
||||
<option key={codec} value={codec}>
|
||||
{codec.toUpperCase()}
|
||||
{probe?.hardware ? " (hardware)" : ""}
|
||||
</option>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Container">
|
||||
@@ -625,13 +661,34 @@ export function OptimizeModal({
|
||||
/>
|
||||
Prefer hardware encoding
|
||||
</label>
|
||||
{codecQuery.isPending ? (
|
||||
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
|
||||
{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."}
|
||||
Test-encoding a few frames to see which encoders really
|
||||
work here…
|
||||
</p>
|
||||
) : usableCodecs.length === 0 ? (
|
||||
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-2 py-1.5 text-[11px] leading-relaxed text-ctp-red">
|
||||
This browser cannot produce {container.toUpperCase()} video
|
||||
samples — switch the container to{" "}
|
||||
{container === "mp4" ? "WebM" : "MP4"}.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
|
||||
Encoders:{" "}
|
||||
{probes
|
||||
.map(
|
||||
(probe) =>
|
||||
`${probe.codec.toUpperCase()}${
|
||||
probe.supported && probe.works
|
||||
? probe.hardware
|
||||
? " (hardware)"
|
||||
: " ✓"
|
||||
: " ✗"
|
||||
}`,
|
||||
)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<VideoCodecSupport[]> {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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<VideoEncoderProbe[]> {
|
||||
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<boolean> {
|
||||
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 {
|
||||
hardware = false;
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
encoder.close();
|
||||
} catch {
|
||||
// Already closed by the error path.
|
||||
}
|
||||
}
|
||||
results.push({ codec, supported: encodable, hardware });
|
||||
}
|
||||
return results;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,13 @@ scope.onmessage = async (event: MessageEvent<OptimizeRequest>) => {
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"prefer-hardware": "hardware",
|
||||
"prefer-software": "software",
|
||||
@@ -45,6 +41,7 @@ export async function optimizeVideo(
|
||||
filename: string,
|
||||
options: OptimizeOptions,
|
||||
report: ProgressReporter,
|
||||
preferredCodecs: VideoCodec[] = [],
|
||||
): Promise<OptimizeResult> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user