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
parent b2c9497bae
commit e5484c9c38
7 changed files with 487 additions and 36 deletions

View File

@ -21,6 +21,18 @@
</div> </div>
<div id="hud" hidden></div> <div id="hud" hidden></div>
<div id="toast" hidden></div> <div id="toast" hidden></div>
<div id="export-warn" hidden>
<div class="ew-backdrop"></div>
<div class="ew-box" role="dialog" aria-modal="true" aria-labelledby="ew-title">
<div id="ew-title" class="ew-title">Software encoding — export will be slow</div>
<div id="ew-body" class="ew-body"></div>
<div class="ew-actions">
<button id="ew-cancel">Cancel</button>
<button id="ew-continue" class="primary">Continue anyway</button>
</div>
<label class="ew-opt"><input type="checkbox" id="ew-dontask"> Don't warn again this session</label>
</div>
</div>
</div> </div>
<div id="transport"> <div id="transport">

View File

@ -13,6 +13,7 @@
"three": "^0.181.1" "three": "^0.181.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1",
"vite": "^7.2.2" "vite": "^7.2.2"
} }
}, },
@ -475,6 +476,22 @@
"node": "^22.20 || ^24.12 || >=25" "node": "^22.20 || ^24.12 || >=25"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.4", "version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
@ -969,6 +986,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.25", "version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",

View File

@ -16,6 +16,7 @@
}, },
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1",
"vite": "^7.2.2" "vite": "^7.2.2"
}, },
"dependencies": { "dependencies": {

View File

@ -85,43 +85,88 @@ async function emitsInPresentationOrder(config) {
} }
/** /**
* Probe for a codec configuration the browser will actually accept and will * Families in preference order H.264 first where available (most compatible),
* encode in presentation order. * then VP9, then AV1. Each family maps to one mp4-muxer codec ("avc"/"vp9"/"av1").
* * Inside a family we walk highlow profiles; the first that does not reorder wins.
* The candidates run high profile first for compression efficiency, down to
* baseline last. Baseline forbids B-slices outright, so it is the profile that
* cannot reorder; the earlier entries are tried first because when a browser
* does not reorder there is no reason to give up their quality.
*/ */
async function pickVideoConfig(width, height, bitrate, fps) { const VIDEO_FAMILIES = [
const candidates = [ {
'avc1.640034', 'avc1.640033', 'avc1.4d0034', 'avc1.42003e', muxerCodec: 'avc',
candidates: [
{ codec: 'avc1.640034', avc: { format: 'avc' } },
{ codec: 'avc1.640033', avc: { format: 'avc' } },
{ codec: 'avc1.4d0034', avc: { format: 'avc' } },
{ codec: 'avc1.42003e', avc: { format: 'avc' } },
],
},
{
muxerCodec: 'vp9',
candidates: [
{ codec: 'vp09.00.10.08' },
{ codec: 'vp8' },
],
},
{
muxerCodec: 'av1',
candidates: [
{ codec: 'av01.0.04M.08' },
{ codec: 'av01.0.05M.08' },
],
},
]; ];
const supported = [];
for (const codec of candidates) { async function pickVideoConfig(width, height, bitrate, fps) {
const config = { const attempted = [];
codec, width, height, bitrate, framerate: fps, const reorderFailures = [];
avc: { format: 'avc' }, for (const family of VIDEO_FAMILIES) {
}; for (const cand of family.candidates) {
const config = { codec: cand.codec, width, height, bitrate, framerate: fps };
if (cand.avc) config.avc = cand.avc;
let supported = false;
let negotiatedHardware = '';
try { try {
const support = await VideoEncoder.isConfigSupported(config); const sup = await VideoEncoder.isConfigSupported(config);
if (!support.supported) continue; supported = !!sup.supported;
} catch { continue; } negotiatedHardware = sup.config ? sup.config.hardwareAcceleration || '' : '';
supported.push(config); } catch { supported = false; }
if (await emitsInPresentationOrder(config)) return config; attempted.push(`${family.muxerCodec}:${cand.codec}=${supported ? 'ok' : 'no'}`);
if (!supported) continue;
// Found a supported profile — does it reorder? Baseline AVC never does;
// VP9/AV1 may, so we enforce the same test.
if (await emitsInPresentationOrder(config)) {
return { config, muxerCodec: family.muxerCodec, hardwareAcceleration: negotiatedHardware };
} }
// Every supported profile reorders. Refuse rather than write a file that reorderFailures.push(`${cand.codec}`);
// silently loses most of its frames — see emitsInPresentationOrder. }
if (supported.length) { }
if (reorderFailures.length) {
throw new Error( throw new Error(
'every supported H.264 profile emits frames out of order on this browser ' + 'every supported codec profile emits frames out of order on this browser ' +
`(tried ${supported.map((c) => c.codec).join(', ')}), which this exporter ` + `(tried ${reorderFailures.join(', ')} — all reordered), which this exporter ` +
'cannot mux correctly', 'cannot mux correctly',
); );
} }
// Nothing supported at all — surface what we tried so the caller can explain.
console.warn('[export] no supported video config — tried', attempted.join(' '));
return null; return null;
} }
/**
* What codec would an export at this size actually use, without rendering.
* Used for the "software encoding will be slow" warning the choice depends on
* the browser + resolution, so it is not a constant.
*/
export async function probeExportCodec(width, height, bitrate, fps = 60) {
const picked = await pickVideoConfig(width, height, bitrate, fps);
if (!picked) return null;
return {
codec: picked.config.codec,
muxerCodec: picked.muxerCodec,
hardwareAcceleration: picked.hardwareAcceleration || '',
isSoftwareFallback: picked.muxerCodec !== 'avc',
};
}
/** /**
* Pick an audio codec the browser can actually encode. * Pick an audio codec the browser can actually encode.
* *
@ -222,7 +267,7 @@ export class Exporter {
* @param {(progress: {frame, total, fraction, stage}) => void} [options.onProgress] * @param {(progress: {frame, total, fraction, stage}) => void} [options.onProgress]
* @returns {Promise<Blob>} * @returns {Promise<Blob>}
*/ */
async export({ preset = '1080p', frameRange = null, onProgress = null } = {}) { async export({ preset = '1080p', frameRange = null, onProgress = null, videoPick = null } = {}) {
if (!isSupported()) { if (!isSupported()) {
throw new Error('WebCodecs VideoEncoder is unavailable in this browser'); throw new Error('WebCodecs VideoEncoder is unavailable in this browser');
} }
@ -235,8 +280,16 @@ export class Exporter {
const [startFrame, endFrame] = frameRange || [0, show.frameCount]; const [startFrame, endFrame] = frameRange || [0, show.frameCount];
const total = Math.max(1, endFrame - startFrame); const total = Math.max(1, endFrame - startFrame);
const videoConfig = await pickVideoConfig(width, height, bitrate, fps); const picked = videoPick || await pickVideoConfig(width, height, bitrate, fps);
if (!videoConfig) throw new Error('no supported H.264 configuration found'); if (!picked) {
throw new Error(
'no supported video encoder found — this Firefox build reports H.264 (avc1.*) as unsupported; ' +
'VP9/AV1 were also unavailable. Try Chromium/Chrome, or paste the console output of ' +
'VideoEncoder.isConfigSupported probes so a codec string can be added'
);
}
const videoConfig = picked.config;
const muxerVideoCodec = picked.muxerCodec;
const channels = show.audioBuffer ? Math.min(2, show.audioBuffer.numberOfChannels) : 0; const channels = show.audioBuffer ? Math.min(2, show.audioBuffer.numberOfChannels) : 0;
const audioConfig = show.audioBuffer && typeof AudioEncoder !== 'undefined' const audioConfig = show.audioBuffer && typeof AudioEncoder !== 'undefined'
@ -258,7 +311,7 @@ export class Exporter {
const muxer = new Muxer({ const muxer = new Muxer({
target: new ArrayBufferTarget(), target: new ArrayBufferTarget(),
video: { codec: 'avc', width, height, frameRate: fps }, video: { codec: muxerVideoCodec, width, height, frameRate: fps },
...(hasAudio ? { ...(hasAudio ? {
audio: { audio: {
codec: audioConfig.muxerCodec, codec: audioConfig.muxerCodec,
@ -454,12 +507,12 @@ export class Exporter {
/** Render a short range around a frame — the "test render" bridge before a full export. */ /** Render a short range around a frame — the "test render" bridge before a full export. */
export async function exportSegment(show, centreFrame, export async function exportSegment(show, centreFrame,
{ seconds = 20, preset = '1080p', onProgress, exporter = null } = {}) { { seconds = 20, preset = '1080p', onProgress, exporter = null, videoPick = null } = {}) {
const half = Math.round((seconds * show.fps) / 2); const half = Math.round((seconds * show.fps) / 2);
const start = Math.max(0, centreFrame - half); const start = Math.max(0, centreFrame - half);
const end = Math.min(show.frameCount, centreFrame + half); const end = Math.min(show.frameCount, centreFrame + half);
// Accept a caller-supplied Exporter so the caller can read `warnings` and cancel. // Accept a caller-supplied Exporter so the caller can read `warnings` and cancel.
return (exporter || new Exporter(show)).export({ preset, frameRange: [start, end], onProgress }); return (exporter || new Exporter(show)).export({ preset, frameRange: [start, end], onProgress, videoPick });
} }
export function downloadBlob(blob, filename) { export function downloadBlob(blob, filename) {

View File

@ -6,7 +6,7 @@ import { describeLook } from './look/LookGenerator.js';
import { toHex } from './look/palette.js'; import { toHex } from './look/palette.js';
import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js'; import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js';
import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.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 { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
import { coverageOf as sceneCoverage } from './scenes/surface.js'; import { coverageOf as sceneCoverage } from './scenes/surface.js';
@ -37,6 +37,11 @@ const dom = {
thStep: document.getElementById('th-step'), thStep: document.getElementById('th-step'),
thFill: document.getElementById('th-fill'), thFill: document.getElementById('th-fill'),
changeTrack: document.getElementById('btn-change-track'), 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 = { const state = {
@ -549,6 +554,56 @@ function grainRows(grain) {
// ---------------------------------------------------------------- export // ---------------------------------------------------------------- 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() { function currentPreset() {
const el = document.getElementById('sel-preset'); const el = document.getElementById('sel-preset');
return el ? el.value : '1080p'; return el ? el.value : '1080p';
@ -590,11 +645,21 @@ async function runExport(segment) {
if (dom.toast) dom.toast.hidden = true; if (dom.toast) dom.toast.hidden = true;
try { try {
const preset = currentPreset(); 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 exporter = new Exporter(state.show);
const blob = segment const blob = segment
? await exportSegment(state.show, state.show.timeline.frame, ? await exportSegment(state.show, state.show.timeline.frame,
{ seconds: 20, preset, onProgress: exportProgress, exporter }) { 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}` : ''; const suffix = segment ? `-segment-${state.show.timeline.frame}` : '';
downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`); downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`);

View File

@ -1,5 +1,5 @@
{ {
"fingerprint": "ab24b461", "fingerprint": "6a922435",
"schema": 5, "schema": 5,
"measured": "2026-08-20", "measured": "2026-08-20",
"contexts": [ "contexts": [
@ -8306,6 +8306,251 @@
0 0
] ]
} }
},
"Pylon Field 3D": {
"coverage": 0.014,
"bedCoverage": 0,
"bedCoverageMean": 0.006,
"variety": 0.132,
"blocks": {
"scale": 0.009,
"orient": 0.154,
"layout": 0.17,
"region": 0.312,
"texture": 0.055,
"motion": 0.095,
"colour": 0.531
},
"readsHistory": false,
"profile": {
"scale": [
0.2393,
0.1761,
0.1808,
0.1881,
0.2157
],
"orient": [
0.0911,
0.0988,
0.0465,
0.0758,
0.0445,
0.0532
],
"layout": [
0.0147,
0.0183,
0.0129,
0.0039,
0.074,
0.195,
0.1856,
0.0717,
0.0741,
0.1143,
0.111,
0.0649,
0.0108,
0.0223,
0.0224,
0.0042
],
"region": [
-0.6079,
-0.011,
0.837,
-0.1399,
-0.2597,
-0.3173,
-0.6353,
-0.1502,
0.1133,
0.5919,
0.0501,
0.3099,
1.5848,
0.1487,
1.2557,
0.5846,
0.1699,
0.2694,
-0.5945,
-0.0476,
-0.8741,
-0.2272,
-0.0582,
-0.6888,
-0.5566,
0.158,
-0.9051
],
"texture": [
0.4569,
0.0467,
0.1254,
0.0372,
0.1422,
0.1648,
0.0537
],
"colour": [
0.2171,
0.1161,
0.4889,
0.1754,
0.0009,
0.0016,
0.5458,
0.0331
],
"motion": [
0.3918,
0.2137,
0.1319,
0.1273,
0.1352,
0.0081,
0.0268,
0.0143,
0.001,
0.0521,
0.1993,
0.1588,
0.0503,
0.0876,
0.1811,
0.1411,
0.0471,
0.0062,
0.0097,
0.016,
0.0006
]
}
},
"Synthwave Corridor": {
"coverage": 0.012,
"bedCoverage": 0,
"bedCoverageMean": 0.01,
"variety": 0.12,
"blocks": {
"scale": 0.015,
"orient": 0.084,
"layout": 0.16,
"region": 0.288,
"texture": 0.064,
"motion": 0.11,
"colour": 0.541
},
"readsHistory": false,
"profile": {
"scale": [
0.205,
0.1834,
0.2427,
0.2296,
0.1392
],
"orient": [
0.1676,
0.1722,
0.077,
0.2031,
0.0636,
0.1073
],
"layout": [
0,
0.0111,
0.005,
0,
0.0272,
0.2547,
0.2295,
0.0002,
0.0262,
0.2076,
0.1523,
0,
0,
0.046,
0.0401,
0
],
"region": [
-0.9498,
-0.0083,
-0.9038,
0.3037,
-0.0735,
-0.119,
-1,
0.0332,
-1,
-0.2529,
-0.4383,
1.1061,
4.4222,
0.0955,
3.8589,
-0.9753,
0.0933,
-0.6853,
-1,
0.0332,
-1,
0.452,
0.2317,
-0.2569,
-1,
0.0332,
-1
],
"texture": [
0.1016,
0.0213,
0.0519,
0.0285,
0.2572,
0.2294,
0.0577
],
"colour": [
0.1347,
0.164,
0.4911,
0.1878,
0.0218,
0.0006,
0.0566,
0.0056
],
"motion": [
0.328,
0.2303,
0.1743,
0.1662,
0.1012,
0.0001,
0.0171,
0.0258,
0,
0.0084,
0.2525,
0.2127,
0,
0.0313,
0.3059,
0.1374,
0,
0,
0.0024,
0.0062,
0
]
},
"error": "renders nothing — luminance 0.0012, variance 0.0057. A failed shader compile scores 0.000 on every block; check the console for GLSL errors."
} }
} }
} }

View File

@ -108,6 +108,17 @@ body {
color: var(--dim); font-size: 14px; color: var(--dim); font-size: 14px;
} }
#export-warn[hidden] { display: none !important; }
#export-warn { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 40; }
#export-warn .ew-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.62); backdrop-filter: blur(2px); }
#export-warn .ew-box { position: relative; max-width: 560px; margin: 16px; background: var(--panel); border: 1px solid var(--line); border-left: 3px solid #f59e0b; padding: 16px 18px 12px; box-shadow: 0 20px 40px rgba(0,0,0,0.55); }
#export-warn .ew-title { font-weight: 700; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 8px; }
#export-warn .ew-body { font-size: 12px; line-height: 1.65; color: var(--text); white-space: pre-wrap; }
#export-warn .ew-body code { background: #171a22; border: 1px solid var(--line); padding: 1px 4px; font-size: 11px; }
#export-warn .ew-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
#export-warn .ew-opt { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 11px; color: var(--dim); cursor: pointer; }
#export-warn .ew-opt input { accent-color: var(--accent); }
#transport { grid-area: transport; background: var(--panel); } #transport { grid-area: transport; background: var(--panel); }
#timeline { height: 46px; } #timeline { height: 46px; }
#timeline-canvas { width: 100%; height: 46px; display: block; cursor: pointer; } #timeline-canvas { width: 100%; height: 46px; display: block; cursor: pointer; }