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 @@
+
+
+
+
Software encoding — export will be slow
+
+
+
+
+
+
+
+
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; }