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.
160 lines
4.5 KiB
TypeScript
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";
|
|
}
|