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:
+67
-2
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user