Export: fall back to VP9/AV1 when H.264 unavailable and warn about software encoding

Firefox on Linux exposes VideoEncoder but reports every avc1.* config as
unsupported (OpenH264 is decode-only), so pickVideoConfig returned null and
export threw "no supported H.264 configuration found". Probe VP9/AV1 families
after H.264 and pick the first profile that isConfigSupported and does not
reorder (B-frame) — same 12-frame ordering test. Map the chosen family to
mp4-muxer's video codec (avc/vp9/av1).

Before any rendering, probe the codec for the chosen preset and show a
modal when we fell off H.264 onto a software VP9/AV1 path — that's what the
user felt as "super slow". The modal names the codec, explains render vs
encode cost, suggests Chromium/Chrome for hardware H.264 (or 720p/lower
preset), and offers Continue anyway / Cancel (Esc/backdrop also cancel) plus
"Don't warn again this session". Injected pick is threaded through
Exporter.export({ videoPick }) and exportSegment so we don't probe twice.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-29 18:17:53 +02:00
co-authored by Claude
parent b2c9497bae
commit e5484c9c38
7 changed files with 487 additions and 36 deletions
+67 -2
View File
@@ -6,7 +6,7 @@ import { describeLook } from './look/LookGenerator.js';
import { toHex } from './look/palette.js';
import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js';
import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.js';
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported } from './export/Exporter.js';
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported, probeExportCodec } from './export/Exporter.js';
import { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
import { coverageOf as sceneCoverage } from './scenes/surface.js';
@@ -37,6 +37,11 @@ const dom = {
thStep: document.getElementById('th-step'),
thFill: document.getElementById('th-fill'),
changeTrack: document.getElementById('btn-change-track'),
exportWarn: document.getElementById('export-warn'),
exportWarnBody: document.getElementById('ew-body'),
exportWarnContinue: document.getElementById('ew-continue'),
exportWarnCancel: document.getElementById('ew-cancel'),
exportWarnDontAsk: document.getElementById('ew-dontask'),
};
const state = {
@@ -549,6 +554,56 @@ function grainRows(grain) {
// ---------------------------------------------------------------- export
const exportWarnState = { dismissedForSession: false };
function maybeWarnSoftwareEncoder(pick, preset) {
// Only warn when we fell off H.264 onto a software VP9/AV1 path.
// H.264 is taken as hardware (or at least the fast path) on the machines
// we care about; a VP9 software encode is what the user just felt as "super slow".
if (!pick || !pick.isSoftwareFallback || exportWarnState.dismissedForSession) return Promise.resolve(true);
const isFirefox = /Firefox\//.test(navigator.userAgent);
const codecLabel = pick.muxerCodec === 'vp9' ? 'VP9' : pick.muxerCodec === 'av1' ? 'AV1' : pick.codec;
const lines = [
`This browser will encode as ${codecLabel} (${pick.codec}) — H.264 (hardware) is not available here, so encoding is in software and will be much slower.`,
'',
`Preset ${preset}: the render itself is the same cost everywhere; the difference is how fast frames are encoded afterwards.`,
isFirefox
? 'Fastest fix: open this page in Chromium or Chrome (H.264 hardware) and export there. On Firefox, VP9 is the best available.'
: 'Try Chromium/Chrome for hardware H.264, or lower the preset (720p) for a faster encode.',
'You can also enable hardware encoding if your browser/OS offers it and then reload.',
];
return showExportWarning(lines.join('\n'));
}
function showExportWarning(message) {
const warn = dom.exportWarn;
const body = dom.exportWarnBody;
if (!warn || !body) return Promise.resolve(true);
return new Promise((resolve) => {
body.textContent = message;
warn.hidden = false;
const cleanup = (proceed) => {
warn.hidden = true;
if (dom.exportWarnDontAsk && dom.exportWarnDontAsk.checked) {
exportWarnState.dismissedForSession = true;
}
dom.exportWarnContinue.removeEventListener('click', onContinue);
dom.exportWarnCancel.removeEventListener('click', onCancel);
warn.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey);
resolve(proceed);
};
const onContinue = () => cleanup(true);
const onCancel = () => cleanup(false);
const onBackdrop = (e) => { if (e.target.classList.contains('ew-backdrop')) cleanup(false); };
const onKey = (e) => { if (e.key === 'Escape') cleanup(false); };
dom.exportWarnContinue.addEventListener('click', onContinue);
dom.exportWarnCancel.addEventListener('click', onCancel);
warn.addEventListener('click', onBackdrop);
document.addEventListener('keydown', onKey);
});
}
function currentPreset() {
const el = document.getElementById('sel-preset');
return el ? el.value : '1080p';
@@ -590,11 +645,21 @@ async function runExport(segment) {
if (dom.toast) dom.toast.hidden = true;
try {
const preset = currentPreset();
const { width, height, bitrate } = PRESETS[preset] || PRESETS['1080p'];
// Probe codec before any rendering so the warning is cheap and can be
// dismissed without paying for a warm-up.
const pick = await probeExportCodec(width, height, bitrate, state.show.fps);
const proceed = await maybeWarnSoftwareEncoder(pick, preset);
if (!proceed) {
const status = document.getElementById('export-status');
if (status) status.textContent = 'export cancelled — software encoding warning dismissed';
return;
}
const exporter = new Exporter(state.show);
const blob = segment
? await exportSegment(state.show, state.show.timeline.frame,
{ seconds: 20, preset, onProgress: exportProgress, exporter })
: await exporter.export({ preset, onProgress: exportProgress });
: await exporter.export({ preset, onProgress: exportProgress, videoPick: pick });
const suffix = segment ? `-segment-${state.show.timeline.frame}` : '';
downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`);