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:
parent
40188493ac
commit
8a94a3f3a5
@ -0,0 +1,200 @@
|
|||||||
|
// Phase 6 gate — export.
|
||||||
|
//
|
||||||
|
// The parity claim is structural: the exporter has no render path of its own, it
|
||||||
|
// drives Show.renderFrame exactly as the preview does. What still needs verifying
|
||||||
|
// is that the claim survives contact with the encoder — that rendering at export
|
||||||
|
// resolution does not change any look decision, that the requested frame range is
|
||||||
|
// honoured, and that the container is well-formed.
|
||||||
|
|
||||||
|
import { check, expect } from './framework.js';
|
||||||
|
import { Show } from '../Show.js';
|
||||||
|
import { generateLook } from '../look/LookGenerator.js';
|
||||||
|
import { FeatureTrack } from '../audio/FeatureTrack.js';
|
||||||
|
import { synthesizeSectioned } from '../audio/synth.js';
|
||||||
|
import { Exporter, isSupported, PRESETS } from '../export/Exporter.js';
|
||||||
|
import { frameDistance } from '../engine/hash.js';
|
||||||
|
|
||||||
|
let cached = null;
|
||||||
|
function track6() {
|
||||||
|
if (!cached) {
|
||||||
|
cached = FeatureTrack.fromAudioBuffer(
|
||||||
|
synthesizeSectioned({ bpm: 128, duration: 40, changeAt: 20 }), { fps: 60 });
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeShow(width = 320, height = 180) {
|
||||||
|
const show = new Show({ width, height });
|
||||||
|
const track = track6();
|
||||||
|
show.useTrack(track, generateLook(track, { seed: 8642 }));
|
||||||
|
// Give it an audioBuffer so the AAC path is exercised too.
|
||||||
|
show.audioBuffer = synthesizeSectioned({ bpm: 128, duration: 40, changeAt: 20 });
|
||||||
|
show.fileName = 'check';
|
||||||
|
return show;
|
||||||
|
}
|
||||||
|
|
||||||
|
check(6, 'WebCodecs VideoEncoder is available', () =>
|
||||||
|
expect(isSupported(), isSupported()
|
||||||
|
? 'VideoEncoder and VideoFrame present'
|
||||||
|
: 'unavailable — export cannot run in this browser'));
|
||||||
|
|
||||||
|
check(6, 'render resolution does not change any look decision', () => {
|
||||||
|
// Resolution must change only the sampling, never which scene is on screen or
|
||||||
|
// what its parameters are. If it did, a preview would be lying about the export.
|
||||||
|
const show = makeShow(320, 180);
|
||||||
|
try {
|
||||||
|
const probe = (frames) => frames.map((f) => {
|
||||||
|
show.renderFrame(f);
|
||||||
|
return `${show.arc.state.sceneName}|${show.arc.state.kind}|` +
|
||||||
|
JSON.stringify(show.arc.activeLayers.map((l) => l.baseParams));
|
||||||
|
});
|
||||||
|
const frames = [300, 900, 1500];
|
||||||
|
const small = probe(frames);
|
||||||
|
show.setSize(1920, 1080);
|
||||||
|
const large = probe(frames);
|
||||||
|
|
||||||
|
const same = small.every((v, i) => v === large[i]);
|
||||||
|
return expect(same, same
|
||||||
|
? '320x180 and 1920x1080 agree on scene and params at 3 probe frames'
|
||||||
|
: 'look decisions changed with resolution');
|
||||||
|
} finally {
|
||||||
|
show.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
check(6, 'audio-clock preview and frame-counted export agree', () => {
|
||||||
|
// Preview derives the frame index from a playback position; export counts.
|
||||||
|
// Same Show, same size — the images must be identical.
|
||||||
|
const show = makeShow(320, 180);
|
||||||
|
try {
|
||||||
|
const start = 600;
|
||||||
|
const count = 40;
|
||||||
|
|
||||||
|
show.engine.compositor.reset();
|
||||||
|
const preview = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
show.timeline.syncToAudio((start + i) / show.fps);
|
||||||
|
preview.push(show.engine.hashCurrent(show.renderFrame(show.timeline.frame)));
|
||||||
|
}
|
||||||
|
|
||||||
|
show.engine.compositor.reset();
|
||||||
|
const exported = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
exported.push(show.engine.hashCurrent(show.renderFrame(start + i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mismatches = preview.filter((h, i) => h !== exported[i]).length;
|
||||||
|
return expect(mismatches === 0, `${mismatches}/${count} frames differed`);
|
||||||
|
} finally {
|
||||||
|
show.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
check(6, 'a warmed export range matches sequential playback into it', () => {
|
||||||
|
// The exporter warms up before a mid-track range so the first exported frame
|
||||||
|
// carries the feedback state continuous playback would have given it.
|
||||||
|
const show = makeShow(256, 144);
|
||||||
|
try {
|
||||||
|
show.look.feedback.amount = 0.6;
|
||||||
|
show.look.feedback.decay = 0.92;
|
||||||
|
const start = 1200;
|
||||||
|
|
||||||
|
show.engine.compositor.reset();
|
||||||
|
for (let f = 400; f < start; f++) show.renderFrame(f);
|
||||||
|
const sequential = Uint8Array.from(show.readPixels(show.renderFrame(start)));
|
||||||
|
|
||||||
|
show.warmUp(start, show.warmupFrames());
|
||||||
|
const warmed = Uint8Array.from(show.readPixels(show.renderFrame(start)));
|
||||||
|
|
||||||
|
const distance = frameDistance(sequential, warmed);
|
||||||
|
return expect(distance < 0.01,
|
||||||
|
`mean distance ${distance.toFixed(5)} after ${show.warmupFrames()} warm-up frames`);
|
||||||
|
} finally {
|
||||||
|
show.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
check(6, 'every export preset resolves a supported encoder configuration', async () => {
|
||||||
|
if (!isSupported()) return expect(false, 'WebCodecs unavailable');
|
||||||
|
const results = [];
|
||||||
|
for (const [name, p] of Object.entries(PRESETS)) {
|
||||||
|
let ok = false;
|
||||||
|
for (const codec of ['avc1.640034', 'avc1.640033', 'avc1.4d0034', 'avc1.42003e']) {
|
||||||
|
try {
|
||||||
|
const support = await VideoEncoder.isConfigSupported({
|
||||||
|
codec, width: p.width, height: p.height, bitrate: p.bitrate, framerate: 60,
|
||||||
|
});
|
||||||
|
if (support.supported) { ok = true; break; }
|
||||||
|
} catch { /* try the next candidate */ }
|
||||||
|
}
|
||||||
|
results.push(`${name}:${ok ? 'ok' : 'NO'}`);
|
||||||
|
}
|
||||||
|
return expect(!results.some((r) => r.endsWith('NO')), results.join(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
check(6, 'a short export produces a well-formed mp4', async () => {
|
||||||
|
if (!isSupported()) return expect(false, 'WebCodecs unavailable');
|
||||||
|
const show = makeShow(320, 180);
|
||||||
|
try {
|
||||||
|
const blob = await new Exporter(show).export({
|
||||||
|
preset: '720p', frameRange: [600, 720], // 2 seconds
|
||||||
|
});
|
||||||
|
const head = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
|
||||||
|
const brand = String.fromCharCode(...head.slice(4, 8));
|
||||||
|
// Only the degenerate cases are worth rejecting; encoder settings and
|
||||||
|
// content move real sizes around a lot.
|
||||||
|
const plausible = blob.size > 20_000 && blob.size < 40_000_000;
|
||||||
|
return expect(brand === 'ftyp' && plausible,
|
||||||
|
`${(blob.size / 1024).toFixed(0)} KB · box '${brand}' · ${blob.type}`);
|
||||||
|
} finally {
|
||||||
|
show.dispose();
|
||||||
|
}
|
||||||
|
}, { slow: true });
|
||||||
|
|
||||||
|
check(6, 'export honours the requested frame range', async () => {
|
||||||
|
if (!isSupported()) return expect(false, 'WebCodecs unavailable');
|
||||||
|
const show = makeShow(256, 144);
|
||||||
|
try {
|
||||||
|
const short = await new Exporter(show).export({ preset: '720p', frameRange: [600, 660] });
|
||||||
|
const long = await new Exporter(show).export({ preset: '720p', frameRange: [600, 780] });
|
||||||
|
return expect(long.size > short.size * 1.5,
|
||||||
|
`1s ${(short.size / 1024).toFixed(0)} KB vs 3s ${(long.size / 1024).toFixed(0)} KB`);
|
||||||
|
} finally {
|
||||||
|
show.dispose();
|
||||||
|
}
|
||||||
|
}, { slow: true });
|
||||||
|
|
||||||
|
check(6, 'export restores the preview resolution afterwards', async () => {
|
||||||
|
if (!isSupported()) return expect(false, 'WebCodecs unavailable');
|
||||||
|
const show = makeShow(320, 180);
|
||||||
|
try {
|
||||||
|
await new Exporter(show).export({ preset: '720p', frameRange: [600, 630] });
|
||||||
|
return expect(show.engine.width === 320 && show.engine.height === 180,
|
||||||
|
`back to ${show.engine.width}x${show.engine.height} after a 1280x720 export`);
|
||||||
|
} finally {
|
||||||
|
show.dispose();
|
||||||
|
}
|
||||||
|
}, { slow: true });
|
||||||
|
|
||||||
|
check(6, 'a cancelled export stops and restores state', async () => {
|
||||||
|
if (!isSupported()) return expect(false, 'WebCodecs unavailable');
|
||||||
|
const show = makeShow(256, 144);
|
||||||
|
try {
|
||||||
|
const exporter = new Exporter(show);
|
||||||
|
const promise = exporter.export({ preset: '720p', frameRange: [600, 2000] });
|
||||||
|
setTimeout(() => exporter.cancel(), 60);
|
||||||
|
let message = '';
|
||||||
|
try { await promise; } catch (err) { message = err.message; }
|
||||||
|
return expect(message.includes('cancel') && show.engine.width === 256,
|
||||||
|
`threw "${message}", size restored to ${show.engine.width}x${show.engine.height}`);
|
||||||
|
} finally {
|
||||||
|
show.dispose();
|
||||||
|
}
|
||||||
|
}, { slow: true });
|
||||||
|
|
||||||
|
check(6, 'upload to the real video platform', () =>
|
||||||
|
// Deliberately manual. Container quirks are far cheaper to find with a short
|
||||||
|
// file now than after a 4K render, and nothing local substitutes for the
|
||||||
|
// platform's own transcoder accepting the file.
|
||||||
|
({ pass: true, detail: 'MANUAL: export a 20s test render and upload it once before trusting a full export' }),
|
||||||
|
{ manual: true });
|
||||||
@ -9,8 +9,9 @@ import { Muxer, ArrayBufferTarget } from 'mp4-muxer';
|
|||||||
* own, so there is nothing for it to diverge from.
|
* own, so there is nothing for it to diverge from.
|
||||||
*
|
*
|
||||||
* Video goes through WebCodecs VideoEncoder (hardware accelerated where
|
* Video goes through WebCodecs VideoEncoder (hardware accelerated where
|
||||||
* available) into an mp4. Audio is re-encoded to AAC from the already-decoded
|
* available) into an mp4. Audio is re-encoded from the already-decoded PCM —
|
||||||
* PCM, so the file carries the same samples the analysis ran on.
|
* AAC where available, Opus otherwise — so the file carries the same samples
|
||||||
|
* the analysis ran on.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PRESETS = {
|
export const PRESETS = {
|
||||||
@ -42,6 +43,30 @@ async function pickVideoConfig(width, height, bitrate, fps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick an audio codec the browser can actually encode.
|
||||||
|
*
|
||||||
|
* AAC is the obvious choice for mp4 but is absent from Chromium builds without
|
||||||
|
* proprietary codecs — which still ship H.264 encoding, so video succeeds and
|
||||||
|
* only audio fails. Opus in mp4 is well supported by players and by every
|
||||||
|
* platform worth uploading to, so it is the fallback rather than an error.
|
||||||
|
*/
|
||||||
|
async function pickAudioConfig(sampleRate, numberOfChannels) {
|
||||||
|
const candidates = [
|
||||||
|
{ codec: 'mp4a.40.2', muxerCodec: 'aac', bitrate: 192_000 },
|
||||||
|
{ codec: 'opus', muxerCodec: 'opus', bitrate: 160_000 },
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const support = await AudioEncoder.isConfigSupported({
|
||||||
|
codec: candidate.codec, sampleRate, numberOfChannels, bitrate: candidate.bitrate,
|
||||||
|
});
|
||||||
|
if (support.supported) return candidate;
|
||||||
|
} catch { /* try the next one */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export class Exporter {
|
export class Exporter {
|
||||||
constructor(show) {
|
constructor(show) {
|
||||||
this.show = show;
|
this.show = show;
|
||||||
@ -73,16 +98,24 @@ export class Exporter {
|
|||||||
const videoConfig = await pickVideoConfig(width, height, bitrate, fps);
|
const videoConfig = await pickVideoConfig(width, height, bitrate, fps);
|
||||||
if (!videoConfig) throw new Error('no supported H.264 configuration found');
|
if (!videoConfig) throw new Error('no supported H.264 configuration found');
|
||||||
|
|
||||||
const hasAudio = !!show.audioBuffer && typeof AudioEncoder !== 'undefined';
|
const channels = show.audioBuffer ? Math.min(2, show.audioBuffer.numberOfChannels) : 0;
|
||||||
|
const audioConfig = show.audioBuffer && typeof AudioEncoder !== 'undefined'
|
||||||
|
? await pickAudioConfig(show.audioBuffer.sampleRate, channels)
|
||||||
|
: null;
|
||||||
|
const hasAudio = !!audioConfig;
|
||||||
|
this.warnings = [];
|
||||||
|
if (show.audioBuffer && !hasAudio) {
|
||||||
|
this.warnings.push('no supported audio encoder — exporting video only');
|
||||||
|
}
|
||||||
|
|
||||||
const muxer = new Muxer({
|
const muxer = new Muxer({
|
||||||
target: new ArrayBufferTarget(),
|
target: new ArrayBufferTarget(),
|
||||||
video: { codec: 'avc', width, height, frameRate: fps },
|
video: { codec: 'avc', width, height, frameRate: fps },
|
||||||
...(hasAudio ? {
|
...(hasAudio ? {
|
||||||
audio: {
|
audio: {
|
||||||
codec: 'aac',
|
codec: audioConfig.muxerCodec,
|
||||||
sampleRate: show.audioBuffer.sampleRate,
|
sampleRate: show.audioBuffer.sampleRate,
|
||||||
numberOfChannels: Math.min(2, show.audioBuffer.numberOfChannels),
|
numberOfChannels: channels,
|
||||||
},
|
},
|
||||||
} : {}),
|
} : {}),
|
||||||
fastStart: 'in-memory',
|
fastStart: 'in-memory',
|
||||||
@ -145,7 +178,13 @@ export class Exporter {
|
|||||||
|
|
||||||
if (hasAudio) {
|
if (hasAudio) {
|
||||||
onProgress && onProgress({ frame: total, total, fraction: 1, stage: 'encoding audio' });
|
onProgress && onProgress({ frame: total, total, fraction: 1, stage: 'encoding audio' });
|
||||||
await this._encodeAudio(muxer, startFrame, endFrame, fps);
|
try {
|
||||||
|
await this._encodeAudio(muxer, audioConfig, startFrame, endFrame, fps);
|
||||||
|
} catch (err) {
|
||||||
|
// A finished silent video beats losing a long render outright.
|
||||||
|
// The muxer tolerates an audio track that received no chunks.
|
||||||
|
this.warnings.push(`audio encoding failed (${err.message}) — video only`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
muxer.finalize();
|
muxer.finalize();
|
||||||
@ -157,7 +196,7 @@ export class Exporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Encode the exported time range of the decoded PCM to AAC and mux it. */
|
/** Encode the exported time range of the decoded PCM to AAC and mux it. */
|
||||||
async _encodeAudio(muxer, startFrame, endFrame, fps) {
|
async _encodeAudio(muxer, audioConfig, startFrame, endFrame, fps) {
|
||||||
const buffer = this.show.audioBuffer;
|
const buffer = this.show.audioBuffer;
|
||||||
const sampleRate = buffer.sampleRate;
|
const sampleRate = buffer.sampleRate;
|
||||||
const channels = Math.min(2, buffer.numberOfChannels);
|
const channels = Math.min(2, buffer.numberOfChannels);
|
||||||
@ -173,7 +212,10 @@ export class Exporter {
|
|||||||
error: (e) => errors.push(e),
|
error: (e) => errors.push(e),
|
||||||
});
|
});
|
||||||
encoder.configure({
|
encoder.configure({
|
||||||
codec: 'mp4a.40.2', sampleRate, numberOfChannels: channels, bitrate: 192_000,
|
codec: audioConfig.codec,
|
||||||
|
sampleRate,
|
||||||
|
numberOfChannels: channels,
|
||||||
|
bitrate: audioConfig.bitrate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const chunkFrames = 1024;
|
const chunkFrames = 1024;
|
||||||
@ -207,11 +249,13 @@ 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, { seconds = 20, preset = '1080p', onProgress } = {}) {
|
export async function exportSegment(show, centreFrame,
|
||||||
|
{ seconds = 20, preset = '1080p', onProgress, exporter = 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);
|
||||||
return new Exporter(show).export({ preset, frameRange: [start, end], onProgress });
|
// Accept a caller-supplied Exporter so the caller can read `warnings` and cancel.
|
||||||
|
return (exporter || new Exporter(show)).export({ preset, frameRange: [start, end], onProgress });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadBlob(blob, filename) {
|
export function downloadBlob(blob, filename) {
|
||||||
|
|||||||
@ -355,15 +355,22 @@ async function runExport(segment) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const preset = currentPreset();
|
const preset = currentPreset();
|
||||||
|
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 })
|
{ seconds: 20, preset, onProgress: exportProgress, exporter })
|
||||||
: await new Exporter(state.show).export({ preset, onProgress: exportProgress });
|
: await exporter.export({ preset, onProgress: exportProgress });
|
||||||
|
|
||||||
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`);
|
||||||
const status = document.getElementById('export-status');
|
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) {
|
} catch (err) {
|
||||||
const status = document.getElementById('export-status');
|
const status = document.getElementById('export-status');
|
||||||
if (status) status.textContent = `failed: ${err.message}`;
|
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);
|
requestAnimationFrame(frame);
|
||||||
resize();
|
resize();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user