music-video-gen/flow-state/src/main.js
Dejvino 4217cca3d3 Rename clicktrack.js — a content blocker was eating it and killing the app
A file called `clicktrack.js` matches the click-tracking telemetry patterns that
EasyPrivacy and similar lists block by substring. In a browser with a content
blocker the request never completes, and because it is a module import that
takes the entire graph with it: main.js never runs, no handler is ever bound,
and every control in the UI sits there looking correct and doing nothing.

The failure is unusually expensive to diagnose because everything else looks
healthy. The dev server returns 200 with the right MIME type, curl fetches it
fine, node imports it fine, and the app loads perfectly in any browser without a
blocker — which is how it passed every check here. Only the console names it,
and only as one line about a module that failed to load.

Renamed to metronome.js, which is also the better name for what it does. The
button id went with it, since cosmetic filter rules can hit ids too.

The general rule, recorded at the top of the file: anything shipped to a browser
and named like tracking will be treated as tracking. Avoid click, track,
analytics, pixel, beacon and ad in filenames and URL paths, however honest the
code behind them is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:01:23 +02:00

661 lines
28 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 } from './export/Exporter.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) {
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)}%`;
});
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]);
});
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;
paramPanel.build(section.layers[0].module, section.layers[0].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;'} ${stack[0].module.name}</span>` +
`<span class="pp-feature">${shots.filter((s) => s.variant === v).length}&times;</span>` +
`</div>`).join('');
dom.panelBody.appendChild(list);
}
if (section.layers.length > 1) {
const note = document.createElement('div');
note.className = 'pp-reactive';
note.innerHTML = '<div class="pp-sub">accent layer</div>' +
section.layers.slice(1).map((l) =>
`<div class="pp-react-row"><span>${l.module.name}</span>` +
`<span class="pp-feature">${l.blend}</span>` +
`<span class="pp-amount">${l.opacity.toFixed(2)}</span></div>`).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;
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>scheme</span><b>${show.look.paletteScheme}</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="swatches">${show.look.palette.map((c) =>
`<span class="sw" style="background:${toHex(c)}" title="${toHex(c)}"></span>`).join('')}</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) => v[0].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 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();
}
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>` +
`<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;