Files
music-video-gen/flow-state/src/main.js
T
DejvinoandClaude 7736390f9c Comfort: agitation ceiling, composition validator and whole-screen caps
Whole-screen flashing/coherent motion is now a gated comfort signal
rather than a hunt-and-tweak. Three-arm agitation (flash/3,
tile-motion/0.32, structure/0.12) over a 1s floating window, worst
window wins — engine/agitation.js.

Policy centralised in look/comfort.js: director comfort appetite,
per-scene agitationOf proxy, agitationWeight lean and stackAgitation.
Canvas beat/flux capped in Layer.resolveParams (0.15/0.25, spike→smooth)
so one contract change tames every full-frame scene; lint warns above
the cap.

Selection now filters before casting (look/scene-filter.js, 12+ ranged
metrics from checks/scene-metrics.js, SCHEMA 7) and validates after
stacking (look/composition-validator.js) with a deterministic retune:
drop overlay → swap quiet composable → reselect filtered shot. Fix
validator attempt-3 ground-layer bug that crashed ?song=dusk
(LookGenerator now passes module, not layer).

Eight canvas shaders window their beat/flux (focusField + per-seed hash
mask) and report the scaled amount to Layer so the cap stays authoritative:
scan-tear, gate-corridor, analog-wow, block-mosh, metaballs,
pitch-shatter, scale-mosaic, tide-rings.

Checks: scene-gate comfort gate (agitation ≤ 0.70), filmstrip/gallery
meters + per-scene drawer, metadata SCHEMA 7 with ranged metrics and
fingerprint over agitation/comfort sources. main.js surfaces
COMPOSITION_REFUSED as a user-facing error.

Lint: 114 files clean, 71 scenes ok. All 17 songs generate looks.
Build passes. metadata.json refresh deferred to gallery → Refresh
metadata (SCHEMA 7 writer in vite.config).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 19:42:50 +02:00

839 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Show } from './Show.js';
import { TimelineStrip } from './ui/TimelineStrip.js';
import { ParamPanel } from './ui/ParamPanel.js';
import { formatTime } from './audio/decode.js';
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, probeExportCodec } from './export/Exporter.js';
import { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
import { coverageOf as sceneCoverage } from './scenes/surface.js';
const QUALITY = {
draft: 0.5, // half resolution — for scrubbing heavy stacks
full: 1.0,
};
const dom = {
canvas: document.getElementById('canvas'),
stage: document.getElementById('stage'),
overlay: document.getElementById('overlay'),
dropzone: document.getElementById('dropzone'),
fileInput: document.getElementById('file-input'),
audio: document.getElementById('audio'),
timelineCanvas: document.getElementById('timeline-canvas'),
timeDisplay: document.getElementById('time-display'),
sectionDisplay: document.getElementById('section-display'),
panelBody: document.getElementById('panel-body'),
panelTabs: document.getElementById('panel-tabs'),
hud: document.getElementById('hud'),
toast: document.getElementById('toast'),
osd: document.getElementById('btn-osd'),
play: document.getElementById('btn-play'),
thLabel: document.getElementById('th-label'),
thName: document.getElementById('th-name'),
thProgress: document.getElementById('th-progress'),
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 = {
show: new Show({ canvas: dom.canvas, width: 1280, height: 720 }),
playing: false,
quality: 'full',
loopSection: -1,
tab: 'look',
hudVisible: false,
osdVisible: true,
busy: false,
rerollSalt: 0,
lastFrameTime: 0,
fps: 0,
};
const strip = new TimelineStrip(dom.timelineCanvas, { onSeek: seekTo });
const paramPanel = new ParamPanel(document.createElement('div'), onParamChange);
// ---------------------------------------------------------------- loading
async function loadFile(file, { seed = null } = {}) {
if (state.busy) return;
state.busy = true;
stopPlayback();
if (dom.overlay) {
dom.overlay.hidden = true;
dom.overlay.style.display = 'none';
}
dom.thLabel.textContent = 'loading track';
dom.thName.hidden = false;
dom.thName.textContent = file.name.replace(/\.[^/.]+$/, '');
dom.thName.title = file.name;
dom.thProgress.hidden = false;
dom.changeTrack.hidden = true;
try {
await state.show.load(file, (stage, fraction) => {
dom.thStep.textContent = stage;
dom.thFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
}, { seed });
dom.audio.src = URL.createObjectURL(file);
dom.thLabel.textContent = 'loaded track';
dom.thName.textContent = state.show.fileName;
dom.thName.title = state.show.fileName;
dom.thProgress.hidden = true;
dom.changeTrack.hidden = false;
dom.changeTrack.textContent = 'change track';
strip.setShow(state.show);
if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
resize();
seekTo(0);
renderPanel();
document.title = `flow-state · ${state.show.fileName}`;
console.info('[flow-state]', describeLook(state.show.look));
} catch (err) {
const refused = err && err.code === 'COMPOSITION_REFUSED';
dom.thLabel.textContent = refused ? 'high agitation — refuses to render' : 'analysis failed';
const detail = refused
? `director refused song: ${err.message} — try another seed, or a calmer song (murk is dark-noisy bright-hiss; validator expected static bed + small floaters)`
: `failed: ${err.message}`;
dom.thStep.textContent = detail;
if (refused) dom.thStep.title = detail;
console.error(err);
dom.changeTrack.hidden = false;
dom.changeTrack.textContent = 'try again';
if (dom.overlay) {
dom.overlay.hidden = false;
dom.overlay.style.display = 'flex';
}
} finally {
state.busy = false;
}
}
if (dom.dropzone) dom.dropzone.addEventListener('click', () => dom.fileInput.click());
if (dom.changeTrack) dom.changeTrack.addEventListener('click', () => dom.fileInput.click());
dom.fileInput.addEventListener('change', (e) => {
if (e.target.files[0]) loadFile(e.target.files[0]);
});
/**
* `?song=centre` — open a bank song straight from a debug page.
*
* The filmstrip reduces a song to nine stills; the only way to argue with that
* reading is to watch the thing move, and until now that meant remembering
* which of seventeen near-identically-named wavs to drag in. `&seed=` carries
* the strip's seed across so the video you watch is the one it measured.
*
* Dev-only in practice: `test/songs/` is served by vite from the project root
* and is not part of a build. The name is matched against a bare word rather
* than the bank list so the app does not have to import the synth.
*/
async function loadBankSong(name, seed) {
if (!/^[a-z0-9_-]+$/i.test(name)) return;
const url = `/test/songs/${name}.wav`;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const blob = await res.blob();
await loadFile(new File([blob], `${name}.wav`, { type: 'audio/wav' }), { seed });
} catch (err) {
dom.thLabel.textContent = 'no such song';
dom.thName.hidden = false;
dom.thName.textContent = name;
dom.thStep.textContent = `${url}: ${err.message} — run npm run build:songs`;
console.error(err);
}
}
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', (e) => {
e.preventDefault();
const file = [...e.dataTransfer.files].find(
(f) => f.type.startsWith('audio/') || /\.(mp3|flac|wav|ogg|m4a)$/i.test(f.name));
if (file) loadFile(file);
});
// ---------------------------------------------------------------- transport
function seekTo(frame, options = {}) {
if (!state.show.ready) return;
const clamped = Math.max(0, Math.min(state.show.frameCount - 1, Math.round(frame)));
dom.audio.currentTime = clamped / state.show.fps;
state.show.seek(clamped, options);
strip.setFrame(clamped);
}
/**
* Force the transport to a stopped state.
*
* Loading a track replaces `audio.src`, which stops playback without telling
* anyone — so `state.playing` stayed true, the button stayed on ❚❚, and the
* first click after a track change only toggled the flag back rather than
* starting anything. Every path that stops playback behind the UI's back has
* to come through here.
*/
function stopPlayback() {
state.playing = false;
dom.audio.pause();
dom.play.textContent = '▶';
}
function togglePlay() {
if (!state.show.ready) return;
state.playing = !state.playing;
if (state.playing) {
dom.audio.play().catch((err) => {
console.warn('[flow-state] playback failed:', err);
state.playing = false;
dom.play.textContent = '▶';
});
} else {
dom.audio.pause();
}
dom.play.textContent = state.playing ? '❚❚' : '▶';
}
function jumpSection(direction) {
if (!state.show.ready) return;
seekTo(state.show.track.boundaryFrame(state.show.timeline.frame, direction));
}
function toggleLoop() {
if (!state.show.ready) return;
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
state.loopSection = state.loopSection === index ? -1 : index;
strip.setLoopSection(state.loopSection);
}
dom.play.addEventListener('click', togglePlay);
document.getElementById('btn-prev-section').addEventListener('click', () => jumpSection(-1));
document.getElementById('btn-next-section').addEventListener('click', () => jumpSection(1));
document.getElementById('btn-loop').addEventListener('click', toggleLoop);
document.getElementById('btn-hud').addEventListener('click', () => {
state.hudVisible = !state.hudVisible;
dom.hud.hidden = !state.hudVisible;
});
if (dom.osd) dom.osd.addEventListener('click', () => setOSDVisible(!state.osdVisible));
document.getElementById('sel-quality').addEventListener('change', (e) => {
state.quality = e.target.value;
resize();
});
function setOSDVisible(visible) {
state.osdVisible = !!visible;
state.show.setOSDEnabled(state.osdVisible);
if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
}
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
switch (e.key) {
case ' ': e.preventDefault(); togglePlay(); break;
case 'ArrowLeft': jumpSection(-1); break;
case 'ArrowRight': jumpSection(1); break;
case 'l': case 'L': toggleLoop(); break;
case 'd': case 'D':
state.hudVisible = !state.hudVisible;
dom.hud.hidden = !state.hudVisible;
break;
case 'o': case 'O': setOSDVisible(!state.osdVisible); break;
case ',': seekTo(state.show.timeline.frame - 1); break;
case '.': seekTo(state.show.timeline.frame + 1); break;
default: break;
}
});
// ---------------------------------------------------------------- look edits
document.getElementById('btn-reroll').addEventListener('click', () => {
if (!state.show.ready) return;
state.show.reroll((state.show.look.seed ^ (++state.rerollSalt * 0x9e3779b9)) >>> 0);
refreshAfterLookChange();
});
document.getElementById('btn-reroll-section').addEventListener('click', () => {
if (!state.show.ready) return;
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
state.show.rerollSection(index, ++state.rerollSalt);
refreshAfterLookChange();
});
document.getElementById('btn-lock').addEventListener('click', () => {
if (!state.show.ready) return;
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
const section = state.show.look.sections[index];
section.locked = !section.locked;
renderPanel();
});
function refreshAfterLookChange() {
state.show.seek(state.show.timeline.frame);
renderPanel();
}
function onParamChange(name, value) {
if (!state.show.ready) return;
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
state.show.setSectionParam(index, name, value);
}
// ---------------------------------------------------------------- panel
dom.panelTabs.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
if (!tab) return;
state.tab = tab;
[...dom.panelTabs.children].forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
renderPanel();
});
function renderPanel() {
if (!state.show.ready) return;
const show = state.show;
const index = show.track.sectionIndexAt(show.timeline.frame);
const section = show.look.sections[index];
dom.panelBody.innerHTML = '';
dom.panelBody.oninput = null;
if (state.tab === 'scene') {
paramPanel.container = dom.panelBody;
const subject = subjectOf(section.layers);
paramPanel.build(subject.module, subject.params);
// The section's stage visuals, with the one currently on screen marked.
// Params above edit the anchor (variant 0) — the image the section opens
// and returns to.
if (section.variants && section.variants.length > 1) {
const active = show.arc.state.variant || 0;
const shots = section.shots || [];
const list = document.createElement('div');
list.className = 'pp-reactive';
list.innerHTML = `<div class="pp-sub">stage visuals · ${shots.length} shots</div>` +
section.variants.map((stack, v) =>
`<div class="pp-react-row${v === active ? ' current' : ''}">` +
`<span>${v === 0 ? '&#9679;' : '&#9675;'} ${subjectOf(stack).module.name}</span>` +
`<span class="pp-feature">${shots.filter((s) => s.variant === v).length}&times;</span>` +
`</div>`).join('');
dom.panelBody.appendChild(list);
}
// The rest of the stack, named by the job each layer is doing rather
// than by its index: what is under the shot and what is over it are
// different questions, and the panel used to call both "layered over".
const ground = groundOf(section.layers);
const overlays = overlaysOf(section.layers);
if (ground || overlays.length) {
const row = (l, label) =>
`<div class="pp-react-row"><span>${l.module.name}</span>` +
`<span class="pp-feature">${label}</span>` +
`<span class="pp-amount">${(sceneCoverage(l.module) * 100).toFixed(0)}%</span></div>`;
const note = document.createElement('div');
note.className = 'pp-reactive';
note.innerHTML =
`<div class="pp-sub">stack · ${(stackCoverage(section.layers) * 100).toFixed(0)}% painted</div>` +
(ground ? row(ground, 'ground') : '') +
row(subject, 'shot') +
overlays.map((l) => row(l, l.blend)).join('');
dom.panelBody.appendChild(note);
}
return;
}
if (state.tab === 'look') {
const summary = show.track.summary;
// The track's production design. Scenes that cannot express what it is
// built on were never cast — see look/Personality.js.
const personality = show.look.personality;
const palettes = show.look.palettes || [show.look.palette];
const schemes = show.look.paletteSchemes || [show.look.paletteScheme];
const plan = show.look.palettePlan;
const activeIdx = show.arc ? show.arc.paletteIndexAt(show.timeline.frame) : 0;
const blend = show.arc ? show.arc.paletteBlendAt(show.timeline.frame) : null;
const planLabel = plan
? `${plan.progression}/${plan.transition} · ${palettes.length} palettes`
: `${schemes[0] || ''}`;
dom.panelBody.innerHTML = `
<div class="pp-heading"><span class="pp-name">${show.fileName || 'track'}</span></div>
<div class="kv"><span>seed</span><b>${show.look.seed.toString(16)}</b></div>
<div class="kv"><span>bpm</span><b>${summary.bpm.toFixed(1)}</b></div>
<div class="kv"><span>tempo conf.</span><b>${show.track.tempo.confidence.toFixed(2)}</b></div>
<div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div>
<div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div>
<div class="kv"><span>director</span><b>${show.look.director}${plan ? ` · ${plan.progression}` : ''}</b></div>
<div class="kv"><span>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div>
<div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${
personality.shape.sides ? '-sided' : ''}</b></div>
<div class="kv"><span>camera</span><b>${(personality.camera.driftRate * 100).toFixed(1)} drift · ${
personality.camera.spin >= 0 ? '+' : ''}${personality.camera.spin.toFixed(3)} spin</b></div>
<div class="kv"><span>art</span><b>${personality.style.symmetry > 1
? personality.style.symmetry + '-fold · ' : ''}line ${personality.style.lineWeight.toFixed(2)}</b></div>
<div class="kv"><span>brightness</span><b>${summary.meanCentroid.toFixed(3)}</b></div>
<div class="kv"><span>dynamics</span><b>${summary.dynamicRange.toFixed(3)}</b></div>
<div class="pp-sub">palettes · ${planLabel}</div>
<div id="palette-list">${palettes.map((pal, i) => {
const sc = schemes[i] || schemes[0] || '';
const isActive = i === activeIdx;
const isBlendSrc = blend && (i === blend.from || i === blend.to);
const cls = isActive ? 'active' : (isBlendSrc ? 'blending' : '');
const tag = isActive
? (blend ? `● blend ${(blend.t * 100).toFixed(0)}%` : '● active')
: (isBlendSrc ? '○ blend' : '');
return `<div class="pal-row ${cls}" data-pal="${i}">
<div class="pal-meta"><span class="pal-idx">#${i + 1}</span><span class="pal-scheme">${sc}</span><span class="pal-tag">${tag}</span></div>
<div class="swatches pal-swatches">${pal.map((c) =>
`<span class="sw" style="background:${toHex(c)}" title="${toHex(c)}"></span>`).join('')}</div>
</div>`;
}).join('')}</div>
<div id="palette-blend-hint" class="hint"${blend ? '' : ' hidden'}>${blend ? `blending #${blend.from + 1} → #${blend.to + 1} · ${(blend.t * 100).toFixed(0)}% through cut` : ''}</div>
<div class="pp-sub">sections</div>
${show.look.sections.map((s, i) => `
<div class="kv ${i === index ? 'current' : ''}">
<span>${s.kind}${s.locked ? ' &#128274;' : ''}
${s.shots ? `<i class="dim">${s.shots.length} shots</i>` : ''}</span>
<b>${(s.variants || [s.layers]).map((v) => subjectOf(v).module.name).join(' / ')}</b>
</div>`).join('')}
<button id="btn-metronome" class="wide">download click track</button>
<div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on
the beat, tempo detection is wrong and everything downstream inherits it.</div>`;
document.getElementById('btn-metronome').addEventListener('click', downloadClickTrack);
return;
}
if (state.tab === 'post') {
const post = show.look.post;
const fb = show.look.feedback;
const wide = new Set(['contrast', 'saturation', 'exposure']);
// Grain has its own block below: its fields are a mode, a mask id and a
// pixel size, none of which are a 0..1 slider.
const rows = Object.entries(post)
.filter(([key]) => !key.startsWith('grain'))
.map(([key, value]) => `
<div class="pp-row">
<label class="pp-label">${key}</label>
<input class="pp-input" type="range" min="0" max="${wide.has(key) ? 2 : 1}"
step="0.01" value="${value}" data-post="${key}">
<span class="pp-value">${(+value).toFixed(2)}</span>
</div>`).join('');
const fbRows = Object.entries(fb).map(([key, value]) => `
<div class="pp-row">
<label class="pp-label">${key}</label>
<input class="pp-input" type="range"
min="${key === 'rotate' ? -0.02 : key === 'zoom' ? 0.97 : 0}"
max="${key === 'zoom' ? 1.03 : key === 'rotate' ? 0.02 : 1}"
step="0.001" value="${value}" data-feedback="${key}">
<span class="pp-value">${(+value).toFixed(3)}</span>
</div>`).join('');
dom.panelBody.innerHTML =
`<div class="pp-sub">post</div>${rows}` +
`<div class="pp-sub">grain</div>${grainRows(show.look.grain)}` +
`<div class="pp-sub">feedback</div>${fbRows}`;
dom.panelBody.oninput = (e) => {
const t = e.target;
if (t.dataset.post) {
post[t.dataset.post] = +t.value;
t.nextElementSibling.textContent = (+t.value).toFixed(2);
}
if (t.dataset.feedback) {
fb[t.dataset.feedback] = +t.value;
t.nextElementSibling.textContent = (+t.value).toFixed(3);
}
if (t.dataset.grain) {
const key = t.dataset.grain;
if (key === 'kinds') {
const kinds = new Set(show.look.grain.kinds);
if (t.checked) kinds.add(t.value); else kinds.delete(t.value);
show.look.grain.kinds = [...kinds];
} else {
show.look.grain[key] = t.tagName === 'SELECT' ? t.value : +t.value;
}
// Turning grain on for a track that was generated without it
// would otherwise select a mode and still show nothing.
if (show.look.grain.mode !== 'off' && show.look.grain.amount <= 0) {
show.look.grain.amount = 0.04;
}
applyGrainToPost(show.look.grain, post);
// The mode decides which of the other controls exist, so it is
// the one edit that has to rebuild the panel.
if (key === 'mode') { renderPanel(); return; }
if (t.nextElementSibling) {
t.nextElementSibling.textContent = (+t.value).toFixed(3);
}
const desc = dom.panelBody.querySelector('#grain-desc');
if (desc) desc.textContent = describeGrain(show.look.grain);
}
};
dom.panelBody.onchange = dom.panelBody.oninput;
return;
}
if (state.tab === 'export') {
dom.panelBody.innerHTML = `
<div class="pp-sub">export</div>
<div class="pp-row">
<label class="pp-label">preset</label>
<select id="sel-preset" class="pp-input">
${Object.keys(PRESETS).map((k) =>
`<option ${k === '1080p' ? 'selected' : ''}>${k}</option>`).join('')}
</select>
<span class="pp-value"></span>
</div>
<div id="export-status" class="hint">${isSupported()
? 'WebCodecs available. Test render first — it uses the same encoder at the same settings.'
: 'WebCodecs VideoEncoder is unavailable here; export will not work in this browser.'}</div>
<div class="an-bar"><div class="an-fill" id="export-fill"></div></div>`;
}
}
/**
* Grain controls for the post tab.
*
* Grain is the one part of the grade with a shape rather than a level — when it
* is present, how coarse it is, how often it refreshes and where it lands — so
* it gets its own block instead of five sliders that all read 0..1.
*/
function grainRows(grain) {
const slider = (key, min, max, step, value) => `
<div class="pp-row">
<label class="pp-label">${key}</label>
<input class="pp-input" type="range" min="${min}" max="${max}" step="${step}"
value="${value}" data-grain="${key}">
<span class="pp-value">${(+value).toFixed(3)}</span>
</div>`;
const modeRow = `
<div class="pp-row">
<label class="pp-label">mode</label>
<select class="pp-input" data-grain="mode">
${GRAIN_MODES.map((m) =>
`<option ${m === grain.mode ? 'selected' : ''}>${m}</option>`).join('')}
</select>
<span class="pp-value"></span>
</div>`;
if (grain.mode === 'off') {
return modeRow + `<div class="hint" id="grain-desc">${describeGrain(grain)}</div>`;
}
const maskRow = `
<div class="pp-row">
<label class="pp-label">mask</label>
<select class="pp-input" data-grain="mask">
${Object.keys(GRAIN_MASKS).map((m) =>
`<option ${m === grain.mask ? 'selected' : ''}>${m}</option>`).join('')}
</select>
<span class="pp-value"></span>
</div>`;
const kindsRow = grain.mode !== 'sections' ? '' : `
<div class="pp-row">
<label class="pp-label">sections</label>
<span class="pp-input">
${['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'].map((k) => `
<label class="dim"><input type="checkbox" data-grain="kinds" value="${k}"
${grain.kinds.includes(k) ? 'checked' : ''}>${k}</label>`).join(' ')}
</span>
</div>`;
const swellRows = grain.mode !== 'swell' ? ''
: slider('period', 4, 60, 1, grain.period) + slider('duty', 0.05, 1, 0.01, grain.duty);
return modeRow
+ slider('amount', 0, 0.25, 0.002, grain.amount)
+ slider('scale', 1, 8, 0.5, grain.scale)
+ slider('rate', 1, 8, 1, grain.rate)
+ maskRow
+ slider('chroma', 0, 1, 0.01, grain.chroma)
+ kindsRow
+ swellRows
+ `<div class="hint" id="grain-desc">${describeGrain(grain)}</div>`;
}
// ---------------------------------------------------------------- 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';
}
/**
* Surface a message over the stage.
*
* Errors stay until dismissed: an export that fails after several minutes of
* rendering must not scroll past unnoticed, which is exactly what the status
* line inside the export panel allowed.
*/
function showToast(message, { ok = false, timeout = 0 } = {}) {
if (!dom.toast) return;
clearTimeout(showToast._timer);
dom.toast.textContent = message;
dom.toast.classList.toggle('ok', ok);
dom.toast.hidden = false;
if (timeout) showToast._timer = setTimeout(() => { dom.toast.hidden = true; }, timeout);
}
if (dom.toast) dom.toast.addEventListener('click', () => { dom.toast.hidden = true; });
function exportProgress(p) {
const status = document.getElementById('export-status');
const fill = document.getElementById('export-fill');
if (status) status.textContent = `${p.stage} — ${Math.round(p.fraction * 100)}% (${p.frame}/${p.total})`;
if (fill) fill.style.width = `${Math.round(p.fraction * 100)}%`;
}
async function runExport(segment) {
if (!state.show.ready || state.busy) return;
if (state.playing) togglePlay();
state.busy = true;
state.tab = 'export';
[...dom.panelTabs.children].forEach((b) => b.classList.toggle('active', b.dataset.tab === 'export'));
renderPanel();
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, videoPick: pick })
: 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`);
const status = document.getElementById('export-status');
// 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})` : '');
}
showToast(`export done — ${(blob.size / 1e6).toFixed(1)} MB` +
(warnings ? `\n${warnings}` : ''), { ok: true, timeout: 6000 });
} catch (err) {
const status = document.getElementById('export-status');
if (status) status.textContent = `failed: ${err.message}`;
showToast(`export failed\n${err.message}`);
console.error(err);
} finally {
state.busy = false;
resize();
state.show.seek(state.show.timeline.frame);
}
}
document.getElementById('btn-export').addEventListener('click', () => runExport(false));
document.getElementById('btn-segment').addEventListener('click', () => runExport(true));
async function downloadClickTrack() {
if (!state.show.ready) return;
const button = document.getElementById('btn-metronome');
button.textContent = 'rendering…';
try {
const buffer = await renderClickTrack(state.show.audioBuffer, state.show.track.tempo);
downloadBlob(audioBufferToWavBlob(buffer), `${state.show.fileName}-metronome.wav`);
button.textContent = 'download click track';
} catch (err) {
button.textContent = `failed: ${err.message}`;
}
}
// ---------------------------------------------------------------- loop
function resize() {
const rect = dom.stage.getBoundingClientRect();
const scale = QUALITY[state.quality];
const width = Math.max(64, Math.round(rect.width * scale));
const height = Math.max(36, Math.round(((rect.width * 9) / 16) * scale));
state.show.setSize(width, height);
dom.canvas.style.width = '100%';
dom.canvas.style.height = 'auto';
strip.resize();
}
window.addEventListener('resize', resize);
let lastPanelSection = -1;
let lastRenderedFrame = -1;
function syncPalettesLive() {
if (state.tab !== 'look' || !state.show.ready || !state.show.arc) return;
const palettes = state.show.look.palettes;
if (!palettes || palettes.length <= 1) return;
const list = document.getElementById('palette-list');
if (!list) return;
const frame = state.show.timeline.frame;
const active = state.show.arc.paletteIndexAt(frame);
const blend = state.show.arc.paletteBlendAt(frame);
for (const row of list.querySelectorAll('.pal-row')) {
const idx = Number(row.dataset.pal);
const isActive = idx === active;
const isBlendSrc = !!blend && (idx === blend.from || idx === blend.to) && !isActive;
row.classList.toggle('active', isActive);
row.classList.toggle('blending', isBlendSrc);
const tag = row.querySelector('.pal-tag');
if (tag) {
if (isActive) tag.textContent = blend ? `● blend ${(blend.t * 100).toFixed(0)}%` : '● active';
else if (isBlendSrc) tag.textContent = '○ blend';
else tag.textContent = '';
}
}
const hint = document.getElementById('palette-blend-hint');
if (hint) {
if (blend) {
hint.hidden = false;
hint.textContent = `blending #${blend.from + 1} → #${blend.to + 1} · ${(blend.t * 100).toFixed(0)}% through cut`;
} else hint.hidden = true;
}
}
function frame(now) {
requestAnimationFrame(frame);
const show = state.show;
if (!show.ready || state.busy) return;
if (state.playing) {
if (state.loopSection >= 0) {
const section = show.track.sections[state.loopSection];
if (show.timeline.frame >= section.endFrame - 1) {
dom.audio.currentTime = section.startFrame / show.fps;
}
}
show.timeline.syncToAudio(dom.audio.currentTime);
if (dom.audio.ended) stopPlayback();
}
if (state.playing || show.timeline.frame !== lastRenderedFrame) {
show.present(show.renderFrame(show.timeline.frame));
lastRenderedFrame = show.timeline.frame;
}
strip.setFrame(show.timeline.frame);
strip.draw();
const dt = now - state.lastFrameTime;
state.lastFrameTime = now;
if (dt > 0) state.fps = state.fps * 0.9 + (1000 / dt) * 0.1;
dom.timeDisplay.textContent = `${formatTime(show.timeline.time)} / ${formatTime(show.duration)}`;
const sectionIndex = show.track.sectionIndexAt(show.timeline.frame);
const arc = show.arc.state;
dom.sectionDisplay.textContent =
`${arc.kind || ''} · ${arc.sceneName || ''}` +
`${arc.crossfade > 0 ? ` · fade ${arc.crossfade.toFixed(2)}` : ''}`;
if (sectionIndex !== lastPanelSection) {
lastPanelSection = sectionIndex;
if (state.tab === 'scene' || state.tab === 'look') renderPanel();
}
syncPalettesLive();
if (state.hudVisible) {
const f = show.track.at(show.timeline.frame);
dom.hud.innerHTML =
`<div>${state.fps.toFixed(0)} fps · frame ${show.timeline.frame}/${show.frameCount}</div>` +
`<div>${show.engine.width}×${show.engine.height} · ${state.quality}</div>` +
`<div>section ${sectionIndex} ${arc.kind} · ${arc.sceneName}</div>` +
`<div>layers ${show.arc.activeLayers.length} · build ${(f.buildSlope || 0).toFixed(2)}</div>` +
// Where the video is in its story, so "is it going anywhere" is
// something you can read off the HUD rather than infer. See
// look/Story.js.
`<div>${show.look.story ? show.look.story.plot : 'no story'} · ${arc.act || ''} · ` +
`tension ${(arc.tension ?? 0.5).toFixed(2)} reveal ${(arc.reveal ?? 0.5).toFixed(2)} ` +
`journey ${(arc.journey ?? 0).toFixed(2)}</div>` +
`<div>loud ${f.loudness.toFixed(2)} low ${f.bandLow.toFixed(2)} high ${f.bandHigh.toFixed(2)}</div>` +
`<div>beat ${f.beat.toFixed(2)} bar ${f.barPhase.toFixed(2)} flux ${f.flux.toFixed(2)}</div>`;
}
}
// 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();
// Tell the boot guard in index.html that the module graph made it all the way
// through. Without this the guard cannot distinguish "still starting" from
// "never going to start", and the failure it exists to catch is exactly the one
// that produces no error at all in the page.
window.__FLOW_STATE_READY__ = true;
const query = new URLSearchParams(location.search);
if (query.get('song')) {
const seed = query.get('seed');
loadBankSong(query.get('song'), seed === null ? null : (Number(seed) >>> 0));
}