Phase 6: preview UI and export

Full preview surface plus WebCodecs export, both driving the same Show —
the exporter has no render path of its own, which is what makes parity
structural rather than something to keep in sync.

Preview: transport with section jumping and looping, drag-scrub, timeline
strip showing sections coloured by kind with bar ticks and lock state,
generated param controls, reroll (whole track or one section), lock,
draft/full quality, debug HUD, click-track download, and a 20s test render
at full export quality.

Export: probes for a supported H.264 config, warms up before a mid-track
range so the first frame carries the feedback state continuous playback
would have given it, and encodes audio from the decoded PCM.

One real bug found by the gate: AAC is absent from Chromium builds without
proprietary codecs, which still ship H.264 encoding — so video succeeded
and audio killed the whole export with "Cannot call 'encode' on a closed
codec". The exporter now probes AAC then Opus, and a failure mid-encode
degrades to video-only rather than losing a long render. Fallbacks are
surfaced in the UI; a video that quietly lost its audio is worse than one
that says so.

Also adds a dev-only window.__flowState handle. The render loop is
rAF-driven and rAF does not fire in headless/automated contexts, so this
provides a way to step the app by hand.

Gate 10/10 (one manual: upload a test render to the real platform once
before trusting a full export). Real mp4s verified — ftyp box, honoured
frame ranges, resolution restored, cancellation clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-05 18:20:36 +02:00
co-authored by Claude Opus 5
parent 40188493ac
commit 8a94a3f3a5
3 changed files with 271 additions and 13 deletions
+17 -3
View File
@@ -355,15 +355,22 @@ async function runExport(segment) {
try {
const preset = currentPreset();
const exporter = new Exporter(state.show);
const blob = segment
? await exportSegment(state.show, state.show.timeline.frame,
{ seconds: 20, preset, onProgress: exportProgress })
: await new Exporter(state.show).export({ preset, onProgress: exportProgress });
{ seconds: 20, preset, onProgress: exportProgress, exporter })
: await exporter.export({ preset, onProgress: exportProgress });
const suffix = segment ? `-segment-${state.show.timeline.frame}` : '';
downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`);
const status = document.getElementById('export-status');
if (status) status.textContent = `done — ${(blob.size / 1e6).toFixed(1)} MB`;
// Codec fallbacks must never be silent: a video that quietly lost its
// audio track is worse than one that says so.
const warnings = (exporter.warnings || []).join(' · ');
if (status) {
status.textContent = `done — ${(blob.size / 1e6).toFixed(1)} MB` +
(warnings ? ` (${warnings})` : '');
}
} catch (err) {
const status = document.getElementById('export-status');
if (status) status.textContent = `failed: ${err.message}`;
@@ -461,5 +468,12 @@ function frame(now) {
}
}
// Dev-only handle for the console and for browser automation. The render loop is
// requestAnimationFrame-driven, and rAF does not fire in some headless/automated
// contexts, so `tick()` provides a way to advance the app by hand.
if (import.meta.env && import.meta.env.DEV) {
window.__flowState = { state, dom, strip, tick: (t) => frame(t ?? 0), resize, seekTo, renderPanel };
}
requestAnimationFrame(frame);
resize();