Files
J621/frontend/src/features/optimize/detect.ts
T
JakeBreath 8ae8080426 Name the browser in the encoder diagnostics and offer a container switch
The optimizer now shows which browser is running (e.g. "Firefox 141")
next to the encoder probe results, explains the common Firefox case
("Firefox does not implement H.264/HEVC encoding"), and offers a
one-click switch to the container that has working encoders.
2026-09-17 21:13:49 -05:00

160 lines
4.5 KiB
TypeScript

import { canEncodeVideo, type VideoCodec } from "mediabunny";
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 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",
};
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 {
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));
}
/** Human-readable browser + platform, for the encoder diagnostics line. */
export function browserLabel(): string {
const ua = navigator.userAgent;
const patterns: [RegExp, string][] = [
[/(Firefox|FxiOS)\/([\d.]+)/, "Firefox"],
[/(Edg|EdgA|EdgiOS)\/([\d.]+)/, "Edge"],
[/(OPR)\/([\d.]+)/, "Opera"],
[/(Chrome|CriOS)\/([\d.]+)/, "Chrome"],
[/(Version)\/([\d.]+)[^)]*Safari/, "Safari"],
];
for (const [pattern, name] of patterns) {
const match = pattern.exec(ua);
if (match) {
const version = match[2]?.split(".")[0] ?? "";
return `${name}${version ? ` ${version}` : ""}`;
}
}
return "unknown browser";
}