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>
839 lines
37 KiB
JavaScript
839 lines
37 KiB
JavaScript
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 ? '●' : '○'} ${subjectOf(stack).module.name}</span>` +
|
||
`<span class="pp-feature">${shots.filter((s) => s.variant === v).length}×</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 ? ' 🔒' : ''}
|
||
${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));
|
||
}
|