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:
+17
-3
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user