One palette for a whole song reads as one colour once five minutes have passed. The track now keeps 2-4 audio-tilted palettes (forced scheme/hue diversity so switches are legible) and the director owns how they are traversed per cue — sequential/pong/kindLed/storyLed/ contrast — with cut or OKLCH blend timed to the cue crossfade. The fine OKLCH paletteArc drift still rides on top of whichever base is active. The look tab renders the full set as a list; the row matching ArcDriver.paletteIndexAt(frame) gets an active border/glow and follows playback/scrub live, with a blend badge while two palettes interpolate. Co-Authored-By: internal-model
769 lines
33 KiB
JavaScript
769 lines
33 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 } 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'),
|
||
};
|
||
|
||
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) {
|
||
dom.thLabel.textContent = 'analysis failed';
|
||
dom.thStep.textContent = `failed: ${err.message}`;
|
||
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
|
||
|
||
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 exporter = new Exporter(state.show);
|
||
const blob = segment
|
||
? await exportSegment(state.show, state.show.timeline.frame,
|
||
{ seconds: 20, preset, onProgress: exportProgress, exporter })
|
||
: await exporter.export({ preset, onProgress: exportProgress });
|
||
|
||
const suffix = segment ? `-segment-${state.show.timeline.frame}` : '';
|
||
downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`);
|
||
const status = document.getElementById('export-status');
|
||
// 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));
|
||
}
|