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:
@@ -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) {
|
||||
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<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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user