diff --git a/flow-state/index.html b/flow-state/index.html index 07d9c90..029b189 100644 --- a/flow-state/index.html +++ b/flow-state/index.html @@ -21,6 +21,18 @@ +
diff --git a/flow-state/package-lock.json b/flow-state/package-lock.json index 4f1c94b..324f2e2 100644 --- a/flow-state/package-lock.json +++ b/flow-state/package-lock.json @@ -13,6 +13,7 @@ "three": "^0.181.1" }, "devDependencies": { + "@playwright/test": "^1.62.1", "vite": "^7.2.2" } }, @@ -475,6 +476,22 @@ "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": { "version": "4.62.4", "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" } }, + "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": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", diff --git a/flow-state/package.json b/flow-state/package.json index 8cebde8..897625e 100644 --- a/flow-state/package.json +++ b/flow-state/package.json @@ -16,6 +16,7 @@ }, "license": "ISC", "devDependencies": { + "@playwright/test": "^1.62.1", "vite": "^7.2.2" }, "dependencies": { diff --git a/flow-state/src/export/Exporter.js b/flow-state/src/export/Exporter.js index 9cc7e66..fdf739c 100644 --- a/flow-state/src/export/Exporter.js +++ b/flow-state/src/export/Exporter.js @@ -85,43 +85,88 @@ async function emitsInPresentationOrder(config) { } /** - * Probe for a codec configuration the browser will actually accept — and will - * encode in presentation order. - * - * 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. + * Families in preference order — H.264 first where available (most compatible), + * then VP9, then AV1. Each family maps to one mp4-muxer codec ("avc"/"vp9"/"av1"). + * Inside a family we walk high→low profiles; the first that does not reorder wins. */ +const VIDEO_FAMILIES = [ + { + 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' }, + ], + }, +]; + async function pickVideoConfig(width, height, bitrate, fps) { - const candidates = [ - 'avc1.640034', 'avc1.640033', 'avc1.4d0034', 'avc1.42003e', - ]; - const supported = []; - for (const codec of candidates) { - const config = { - codec, width, height, bitrate, framerate: fps, - avc: { format: 'avc' }, - }; - try { - const support = await VideoEncoder.isConfigSupported(config); - if (!support.supported) continue; - } catch { continue; } - supported.push(config); - if (await emitsInPresentationOrder(config)) return config; + const attempted = []; + const reorderFailures = []; + 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 { + const sup = await VideoEncoder.isConfigSupported(config); + supported = !!sup.supported; + negotiatedHardware = sup.config ? sup.config.hardwareAcceleration || '' : ''; + } catch { supported = false; } + 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 }; + } + reorderFailures.push(`${cand.codec}`); + } } - // Every supported profile reorders. Refuse rather than write a file that - // silently loses most of its frames — see emitsInPresentationOrder. - if (supported.length) { + if (reorderFailures.length) { throw new Error( - 'every supported H.264 profile emits frames out of order on this browser ' + - `(tried ${supported.map((c) => c.codec).join(', ')}), which this exporter ` + + 'every supported codec profile emits frames out of order on this browser ' + + `(tried ${reorderFailures.join(', ')} — all reordered), which this exporter ` + '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; } +/** + * 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. * @@ -222,7 +267,7 @@ export class Exporter { * @param {(progress: {frame, total, fraction, stage}) => void} [options.onProgress] * @returns {Promise} */ - async export({ preset = '1080p', frameRange = null, onProgress = null } = {}) { + async export({ preset = '1080p', frameRange = null, onProgress = null, videoPick = null } = {}) { if (!isSupported()) { 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 total = Math.max(1, endFrame - startFrame); - const videoConfig = await pickVideoConfig(width, height, bitrate, fps); - if (!videoConfig) throw new Error('no supported H.264 configuration found'); + const picked = videoPick || await pickVideoConfig(width, height, bitrate, fps); + 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 audioConfig = show.audioBuffer && typeof AudioEncoder !== 'undefined' @@ -258,7 +311,7 @@ export class Exporter { const muxer = new Muxer({ target: new ArrayBufferTarget(), - video: { codec: 'avc', width, height, frameRate: fps }, + video: { codec: muxerVideoCodec, width, height, frameRate: fps }, ...(hasAudio ? { audio: { 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. */ 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 start = Math.max(0, centreFrame - half); const end = Math.min(show.frameCount, centreFrame + half); // 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) { diff --git a/flow-state/src/main.js b/flow-state/src/main.js index c899f2c..5b2fab9 100644 --- a/flow-state/src/main.js +++ b/flow-state/src/main.js @@ -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`); diff --git a/flow-state/src/scenes/metadata.json b/flow-state/src/scenes/metadata.json index 25bb9d6..237d9cf 100644 --- a/flow-state/src/scenes/metadata.json +++ b/flow-state/src/scenes/metadata.json @@ -1,5 +1,5 @@ { - "fingerprint": "ab24b461", + "fingerprint": "6a922435", "schema": 5, "measured": "2026-08-20", "contexts": [ @@ -8306,6 +8306,251 @@ 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." } } } diff --git a/flow-state/src/ui/style.css b/flow-state/src/ui/style.css index f986a6f..758b5de 100644 --- a/flow-state/src/ui/style.css +++ b/flow-state/src/ui/style.css @@ -108,6 +108,17 @@ body { 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); } #timeline { height: 46px; } #timeline-canvas { width: 100%; height: 46px; display: block; cursor: pointer; }