Files
music-video-gen/flow-state/src/main.js
T
Dejvino 4d05250294 Clickable previews: exact app deep-links everywhere
Every rendered still links to the app frame that produced it (song + seed + frame + venue/viz pins + probe warm-up, new window), via shared src/ui/preview.js. Supporting work, each measured: app honors ?frame/?venueSel/?viz/?warm= with pre-build pins (Show.applyPins) and primes deep-link loads; shared pinViz/pinVenueShell paths for gallery, filmstrip and app; probes prime and carry their warm-up; bank probes analyse the bank PCM and file decode is fixed-rate with bit-exact WAV parsing so app and probes see identical samples; materials built mid-update compile synchronously (needsCompile). Round trip verified bit-identical gallery-versus-app.
2026-09-20 18:42:53 +02:00

1269 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Show } from './Show.js';
import { TimelineStrip } from './ui/TimelineStrip.js';
import { ParamPanel } from './ui/ParamPanel.js';
import { formatTime } from './audio/decode.js';
import { describeLook } from './look/LookGenerator.js';
import { toHex } from './look/palette.js';
import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js';
import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.js';
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported, probeExportCodec } from './export/Exporter.js';
import { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
import { coverageOf as sceneCoverage } from './scenes/surface.js';
import { getLlmConfig, setLlmConfig } from './llm/config.js';
import { canInvoke, remainingText, getBudgetCounts } from './llm/budget.js';
import { requestSongTweak } from './llm/llmTweak.js';
import { synthesizeSong } from './audio/synth.js';
import { FeatureTrack } from './audio/FeatureTrack.js';
import { generateLook } from './look/LookGenerator.js';
import { SONGS } from './audio/songbank.js';
import { captureThumbnails } from './llm/thumbs.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'),
};
// Show owns the single WebGL2 context. three@0.181 throws when the browser
// blocks context creation (WebGL2 disabled, headless shell without
// SwiftShader) — historically a blank page, since this runs at module scope.
// Fail loudly into the boot-error box instead, with the cause and the fix.
function showBootError(detail) {
const box = document.getElementById('boot-error');
const pre = document.getElementById('be-detail');
if (pre) pre.textContent = detail;
if (box) box.hidden = false;
}
function bootShow() {
try {
return new Show({ canvas: dom.canvas, width: 1280, height: 720 });
} catch (err) {
const msg = err && err.message ? err.message : String(err);
showBootError(
'The WebGL renderer could not start, so nothing on this page can run — ' +
'every preview, gallery and check renders through the same context.\n\n' +
msg,
);
// Tell the boot guard the startup story is over (it would otherwise
// report its own generic text, clobbering the specific cause above),
// then stop: a half-running app with no renderer is worse than none.
window.__FLOW_STATE_READY__ = 'failed';
throw err;
}
}
const state = {
show: bootShow(),
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, pins = null, frame = null, warm = 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, pins });
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();
// Deep link from a preview (filmstrip figure, venue-gallery card):
// pins ride into load() so the arc builds pinned layers once, then
// the exact frame with the probe's warm-up so stateful layers land
// converged like the probe render. Dropped files never carry pins —
// only `?song=` loads pass them.
const deepLink = !!(pins && (pins.venueSel || pins.viz)) || Number.isFinite(frame);
// Prime before the landing seek on deep-link loads: programs link
// asynchronously and a draw against an unlinked program is wrong
// output — the probe pages prime for the same reason, and both sides
// must be linked for the frames to agree. Normal loads skip it.
if (deepLink) state.show.prime();
seekTo(Number.isFinite(frame) ? frame : 0,
Number.isFinite(warm) ? { warmup: warm } : {});
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.
* `&frame=` seeks to the exact frame after load (with warm-up), and
* `&venueSel=` re-applies the pinned venue shell — together they make a
* preview deep-link reproduce its image. `&viz=` pins the subject the same
* way, and `&warm=` replays the probe's own warm-up length so feedback
* lands identically converged; deep-link loads prime first so neither side
* renders against unlinked programs. Built by src/ui/preview.js.
*
* 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();
// Preview deep-links (?frame=, ?venueSel=) ride along: the app lands
// on the exact image the preview showed. See src/ui/preview.js.
const q = new URLSearchParams(location.search);
const frame = q.get('frame');
const warm = q.get('warm');
await loadFile(new File([blob], `${name}.wav`, { type: 'audio/wav' }), {
seed,
pins: { venueSel: q.get('venueSel'), viz: q.get('viz') },
frame: frame === null ? null : Number(frame),
warm: warm === null ? null : Number(warm),
});
} 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
/**
* Persist the current look seed into the URL so a reload restores it.
*
* Only meaningful with `?song=` (bank songs load by name; a dropped file has
* no URL identity, so a seed alone restores nothing). Uses replaceState — no
* reload, no history spam.
*/
function persistSeedInUrl() {
try {
const q = new URLSearchParams(location.search);
if (!q.get('song')) return;
q.set('seed', String(state.show.look.seed >>> 0));
history.replaceState(null, '', `${location.pathname}?${q}`);
} catch { /* non-browser or sandboxed iframe — seeding still works in-memory */ }
}
document.getElementById('btn-reroll').addEventListener('click', () => {
if (!state.show.ready) return;
state.show.reroll((state.show.look.seed ^ (++state.rerollSalt * 0x9e3779b9)) >>> 0);
persistSeedInUrl();
refreshAfterLookChange();
});
document.getElementById('btn-reroll-section').addEventListener('click', () => {
if (!state.show.ready) return;
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
state.show.rerollSection(index, ++state.rerollSalt);
refreshAfterLookChange();
});
document.getElementById('btn-lock').addEventListener('click', () => {
if (!state.show.ready) return;
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
const section = state.show.look.sections[index];
section.locked = !section.locked;
renderPanel();
});
function refreshAfterLookChange() {
state.show.seek(state.show.timeline.frame);
renderPanel();
}
function onParamChange(name, value) {
if (!state.show.ready) return;
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
state.show.setSectionParam(index, name, value);
}
// ---------------------------------------------------------------- panel
dom.panelTabs.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
if (!tab) return;
state.tab = tab;
[...dom.panelTabs.children].forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
renderPanel();
});
function renderPanel() {
if (!state.show.ready) return;
const show = state.show;
const index = show.track.sectionIndexAt(show.timeline.frame);
const section = show.look.sections[index];
dom.panelBody.innerHTML = '';
dom.panelBody.oninput = null;
if (state.tab === 'scene') {
paramPanel.container = dom.panelBody;
const subject = subjectOf(section.layers);
paramPanel.build(subject.module, subject.params);
// The section's stage visuals, with the one currently on screen marked.
// Params above edit the anchor (variant 0) — the image the section opens
// and returns to.
if (section.variants && section.variants.length > 1) {
const active = show.arc.state.variant || 0;
const shots = section.shots || [];
const list = document.createElement('div');
list.className = 'pp-reactive';
list.innerHTML = `<div class="pp-sub">stage visuals · ${shots.length} shots</div>` +
section.variants.map((stack, v) =>
`<div class="pp-react-row${v === active ? ' current' : ''}">` +
`<span>${v === 0 ? '&#9679;' : '&#9675;'} ${subjectOf(stack).module.name}</span>` +
`<span class="pp-feature">${shots.filter((s) => s.variant === v).length}&times;</span>` +
`</div>`).join('');
dom.panelBody.appendChild(list);
}
// The rest of the stack, named by the job each layer is doing rather
// than by its index: what is under the shot and what is over it are
// different questions, and the panel used to call both "layered over".
const ground = groundOf(section.layers);
const overlays = overlaysOf(section.layers);
if (ground || overlays.length) {
const row = (l, label) =>
`<div class="pp-react-row"><span>${l.module.name}</span>` +
`<span class="pp-feature">${label}</span>` +
`<span class="pp-amount">${(sceneCoverage(l.module) * 100).toFixed(0)}%</span></div>`;
const note = document.createElement('div');
note.className = 'pp-reactive';
note.innerHTML =
`<div class="pp-sub">stack · ${(stackCoverage(section.layers) * 100).toFixed(0)}% painted</div>` +
(ground ? row(ground, 'ground') : '') +
row(subject, 'shot') +
overlays.map((l) => row(l, l.blend)).join('');
dom.panelBody.appendChild(note);
}
return;
}
if (state.tab === 'look') {
const summary = show.track.summary;
// The track's production design. Scenes that cannot express what it is
// built on were never cast — see look/Personality.js.
const personality = show.look.personality;
const palettes = show.look.palettes || [show.look.palette];
const schemes = show.look.paletteSchemes || [show.look.paletteScheme];
const plan = show.look.palettePlan;
const activeIdx = show.arc ? show.arc.paletteIndexAt(show.timeline.frame) : 0;
const blend = show.arc ? show.arc.paletteBlendAt(show.timeline.frame) : null;
const planLabel = plan
? `${plan.progression}/${plan.transition} · ${palettes.length} palettes`
: `${schemes[0] || ''}`;
dom.panelBody.innerHTML = `
<div class="pp-heading"><span class="pp-name">${show.fileName || 'track'}</span></div>
<div class="kv"><span>seed</span><b>${show.look.seed.toString(16)}</b></div>
<div class="kv"><span>bpm</span><b>${summary.bpm.toFixed(1)}</b></div>
<div class="kv"><span>tempo conf.</span><b>${show.track.tempo.confidence.toFixed(2)}</b></div>
<div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div>
<div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div>
<div class="kv"><span>venue</span><b>${show.look.venue ? `${show.look.venue.name} (${show.look.venue.archetype})${show.look.venueEnabled ? '' : ' · off'}` : 'off'}${show.arc && show.arc.state && show.arc.state.venueState ? ` · light ${show.arc.state.venueState.light.toFixed(2)} fog ${show.arc.state.venueState.fog.toFixed(2)} pop ${show.arc.state.venueState.popMul.toFixed(2)}` : ''}</b> <a href="#" id="link-venue-toggle" title="Toggle venue (ground) — ?venue=1 enables for this page">${show.look.venueEnabled ? 'disable' : 'enable'}</a> · <a href="/venue-gallery.html" target="_blank">gallery</a></div>
<div class="kv"><span>director</span><b>${show.look.director}${plan ? ` · ${plan.progression}` : ''}</b></div>
<div class="kv"><span>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div>
<div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${
personality.shape.sides ? '-sided' : ''}</b></div>
<div class="kv"><span>camera</span><b>${(personality.camera.driftRate * 100).toFixed(1)} drift · ${
personality.camera.spin >= 0 ? '+' : ''}${personality.camera.spin.toFixed(3)} spin</b></div>
<div class="kv"><span>art</span><b>${personality.style.symmetry > 1
? personality.style.symmetry + '-fold · ' : ''}line ${personality.style.lineWeight.toFixed(2)}</b></div>
<div class="kv"><span>brightness</span><b>${summary.meanCentroid.toFixed(3)}</b></div>
<div class="kv"><span>dynamics</span><b>${summary.dynamicRange.toFixed(3)}</b></div>
<div class="pp-sub">palettes · ${planLabel}</div>
<div id="palette-list">${palettes.map((pal, i) => {
const sc = schemes[i] || schemes[0] || '';
const isActive = i === activeIdx;
const isBlendSrc = blend && (i === blend.from || i === blend.to);
const cls = isActive ? 'active' : (isBlendSrc ? 'blending' : '');
const tag = isActive
? (blend ? `● blend ${(blend.t * 100).toFixed(0)}%` : '● active')
: (isBlendSrc ? '○ blend' : '');
return `<div class="pal-row ${cls}" data-pal="${i}">
<div class="pal-meta"><span class="pal-idx">#${i + 1}</span><span class="pal-scheme">${sc}</span><span class="pal-tag">${tag}</span></div>
<div class="swatches pal-swatches">${pal.map((c) =>
`<span class="sw" style="background:${toHex(c)}" title="${toHex(c)}"></span>`).join('')}</div>
</div>`;
}).join('')}</div>
<div id="palette-blend-hint" class="hint"${blend ? '' : ' hidden'}>${blend ? `blending #${blend.from + 1} → #${blend.to + 1} · ${(blend.t * 100).toFixed(0)}% through cut` : ''}</div>
<div class="pp-sub">sections</div>
${show.look.sections.map((s, i) => `
<div class="kv ${i === index ? 'current' : ''}">
<span>${s.kind}${s.locked ? ' &#128274;' : ''}
${s.shots ? `<i class="dim">${s.shots.length} shots</i>` : ''}</span>
<b>${(s.variants || [s.layers]).map((v) => subjectOf(v).module.name).join(' / ')}</b>
</div>`).join('')}
<button id="btn-metronome" class="wide">download click track</button>
<div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on
the beat, tempo detection is wrong and everything downstream inherits it.</div>`;
document.getElementById('btn-metronome').addEventListener('click', downloadClickTrack);
document.getElementById('link-venue-toggle')?.addEventListener('click', (e)=>{ e.preventDefault(); show.setVenueEnabled(!show.look.venueEnabled); renderPanel(); show.seek(show.timeline.frame, {warmup:false}); });
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));
}
// ---------------------------------------------------------------- LLM song tweak
let llmTweakInFlight = false;
let currentSongSpec = null; // last synthesizeSong spec (for bank songs); null for dropped files
function specForTrack(track) {
// For bank songs we have the original spec; for dropped files we derive
// a plausible spec from the measured summary so the LLM has something to edit.
if (currentSongSpec) return { ...currentSongSpec };
if (!track || !track.summary) return null;
const s = track.summary;
return {
bpm: Math.round(s.bpm || 120),
brightness: Math.max(0, Math.min(1, s.meanCentroid ?? 0.5)),
noise: Math.max(0, Math.min(1, s.meanFlatness ?? 0.15)),
noiseColour: null,
dynamics: Math.max(0, Math.min(1, s.dynamicRange ?? 0.6)),
transition: 0.5,
arrangement: 'classic',
duration: Math.round(track.duration || 120),
seed: track.summary ? 1 : 1,
};
}
function setLlmTweakButtonState() {
const btn = document.getElementById('btn-llm-tweak');
const gear = document.getElementById('btn-llm-config');
if (!btn) return;
const gate = canInvoke();
const cfg = getLlmConfig();
const ready = state.show && state.show.ready;
btn.disabled = llmTweakInFlight || !ready || !gate.ok;
if (!ready) btn.title = 'Load a track first';
else if (!cfg.isConfigured) btn.title = 'Set an AI Gateway API key — click ⚙️';
else if (!gate.ok) btn.title = gate.detail;
else btn.title = `Let an LLM tune the generated song · ${remainingText()} · click to review`;
if (gear) {
const dot = cfg.isConfigured ? '●' : '○';
gear.textContent = `${dot} ⚙️`;
gear.title = cfg.isConfigured
? `LLM: ${cfg.model} @ ${cfg.gatewayUrl} · ${remainingText()} — click to configure`
: 'LLM not configured — click to set gateway URL / model / API key';
gear.classList.toggle('llm-not-configured', !cfg.isConfigured);
}
}
function openLlmConfigDialog() {
const cfg = getLlmConfig();
const counts = getBudgetCounts();
const overlay = document.createElement('div');
overlay.className = 'llm-overlay';
overlay.innerHTML = `
<div class="llm-backdrop"></div>
<div class="llm-box" role="dialog" aria-modal="true" aria-labelledby="llm-cfg-title">
<div class="llm-title" id="llm-cfg-title">LLM gateway settings</div>
<div class="hint" style="margin-bottom:10px;">Stored in <code>localStorage</code> (<code>flow-state:llm</code>). Never committed. Leave API key empty to disable.</div>
<label class="llm-field"><span>Gateway URL</span><input id="llm-gw" type="text" placeholder="${cfg.defaults.gatewayUrl}" value="${escapeHtml(cfg.stored.gatewayUrl || '')}"></label>
<label class="llm-field"><span>Model</span><input id="llm-model" type="text" placeholder="${cfg.defaults.model}" value="${escapeHtml(cfg.stored.model || '')}"></label>
<label class="llm-field"><span>API key</span><input id="llm-key" type="password" autocomplete="off" placeholder="(empty to disable — stored locally)" value="${escapeHtml(cfg.stored.apiKey || '')}"></label>
<div class="hint">Gateway URL / model may also come from <code>.env.local</code> (<code>VITE_AI_GATEWAY_URL</code> / <code>VITE_AI_MODEL</code>); API key is browser-only via this dialog and never baked into the build. See <code>.env.example</code>.</div>
<div class="hint">Budget: ${counts.loadCount}/${counts.perLoad} this load · ${counts.sessionCount}/${counts.perSession} this session. Counters reset on reload / tab close.</div>
<div class="llm-actions">
<button id="llm-cancel">Cancel</button>
<button id="llm-save" class="primary">Save</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('.llm-backdrop').addEventListener('click', close);
overlay.querySelector('#llm-cancel').addEventListener('click', close);
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
overlay.querySelector('#llm-save').addEventListener('click', () => {
const gw = overlay.querySelector('#llm-gw').value.trim();
const model = overlay.querySelector('#llm-model').value.trim();
const key = overlay.querySelector('#llm-key').value;
setLlmConfig({
gatewayUrl: gw || null,
model: model || null,
apiKey: key,
});
close();
setLlmTweakButtonState();
showToast('LLM settings saved', { ok: true, timeout: 2000 });
});
overlay.querySelector('#llm-gw').focus();
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function openLlmTweakDialog(result, specBefore, specAfter, trackBefore, trackAfter, lookBefore, lookAfter) {
const diffRows = Object.keys(specAfter || {}).filter((k) => {
if (k === 'seed' || k === 'duration') return false;
return specBefore[k] !== specAfter[k];
}).map((k) => {
const a = specBefore[k];
const b = specAfter[k];
const fmt = (v) => v == null ? '∅' : (typeof v === 'number' ? (Number.isInteger(v) ? String(v) : v.toFixed(2)) : String(v));
return `<div class="kv"><span>${k}</span><b>${fmt(a)} → ${fmt(b)}</b></div>`;
}).join('') || '<div class="hint">No song knobs changed (model returned empty edits — this dialog should not have opened).</div>';
const overlay = document.createElement('div');
overlay.className = 'llm-overlay';
overlay.innerHTML = `
<div class="llm-backdrop"></div>
<div class="llm-box llm-diff" role="dialog" aria-modal="true" aria-labelledby="llm-diff-title">
<div class="llm-title" id="llm-diff-title">LLM suggestion — review before applying</div>
<div class="llm-rationale">${escapeHtml(result.rationale || '')}</div>
<div class="llm-confidence hint">confidence ${(result.confidence ?? 0).toFixed(2)}${result.usage ? ` · tokens ${result.usage.totalTokens ?? result.usage.total_tokens ?? '—'}` : ''}</div>
<div class="pp-sub" style="margin-top:10px;">song spec diff</div>
${diffRows}
<div class="pp-sub">effect (approx)</div>
<div class="hint">Re-synthesized and re-analysed locally — what you see when you Apply is what you get.</div>
<div class="llm-actions">
<button id="llm-dismiss">Dismiss</button>
<button id="llm-apply" class="primary">Apply</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('.llm-backdrop').addEventListener('click', close);
overlay.querySelector('#llm-dismiss').addEventListener('click', close);
overlay.querySelector('#llm-apply').addEventListener('click', () => {
try {
const newDuration = specAfter.duration || trackBefore.duration;
// Synthesize a fresh buffer from the edited spec, derive a new FeatureTrack,
// then install it via Show.useTrack so prewarm/arc are correct.
const buf = synthesizeSong({
bpm: specAfter.bpm,
duration: newDuration,
arrangement: specAfter.arrangement,
brightness: specAfter.brightness,
noise: specAfter.noise,
noiseColour: specAfter.noiseColour,
dynamics: specAfter.dynamics,
transition: specAfter.transition,
seed: specAfter.seed ?? (specBefore.seed ?? 1),
});
const newTrack = FeatureTrack.fromAudioBuffer(buf, { fps: state.show.fps });
// Derive a seed from the new spec so generateLook is deterministic on the edit.
const newSeed = specAfter.seed != null ? (specAfter.seed >>> 0) : (specBefore.seed != null ? (specBefore.seed >>> 0) + 1 : 1);
const newLook = generateLook(newTrack, { seed: newSeed });
// Swap in — useTrack handles timeline/provider/arc/prewarm
currentSongSpec = { ...specAfter, seed: newSeed };
state.show.useTrack(newTrack, newLook);
strip.setShow(state.show);
seekTo(0);
renderPanel();
showToast(`Applied LLM tweak: ${result.rationale.slice(0, 120)}`, { ok: true, timeout: 4000 });
} catch (err) {
showToast(`Failed to apply LLM tweak: ${err.message}`);
console.error(err);
} finally {
close();
setLlmTweakButtonState();
}
});
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
// focus Apply for quick keyboard confirm
setTimeout(() => overlay.querySelector('#llm-apply')?.focus(), 50);
}
async function handleLlmTweak() {
if (llmTweakInFlight) return;
if (!state.show || !state.show.ready) {
showToast('Load a track first');
return;
}
const cfg = getLlmConfig();
if (!cfg.isConfigured) {
openLlmConfigDialog();
return;
}
const gate = canInvoke();
if (!gate.ok) {
showToast(gate.detail || 'LLM budget exhausted');
return;
}
// Confirm — preview cost/budget
const remaining = remainingText();
const hint = window.prompt(
`Let the LLM tune the generated song?` +
`\nBudget: ${remaining}` +
`\nModel: ${cfg.model}` +
`\n` +
`\nOptional hint for the model (e.g. "make the drop calmer") — leave empty to skip:`,
''
);
if (hint === null) return; // cancelled
const spec = specForTrack(state.show.track);
if (!spec) {
showToast('No song spec available for this track');
return;
}
// Capture before for diff
const specBefore = { ...spec };
const trackBefore = state.show.track;
const lookBefore = state.show.look;
llmTweakInFlight = true;
setLlmTweakButtonState();
const btn = document.getElementById('btn-llm-tweak');
const prevLabel = btn ? btn.textContent : '';
if (btn) btn.textContent = '… tuning';
// Live progress: start capturing thumbs, then stream. Toast updates as stream arrives.
let llmProgressToast = `Contacting LLM (${cfg.model})…`;
showToast(llmProgressToast, { ok: true });
console.info('[llm] handleLlmTweak start', {
model: cfg.model, gateway: cfg.gatewayUrl,
remaining, hint: hint || '(none)',
specBefore, trackSummary: trackBefore && trackBefore.summary,
lookDirector: lookBefore && lookBefore.director,
});
// Capture 32×32 thumbnails — low-res strip for the vision model, non-blocking.
showToast('Capturing thumbnails…', { ok: true });
let llmFrames = [];
try {
// Keep the preview responsive — allow one paint before capture.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
llmFrames = await captureThumbnails(state.show, { count: 8, size: 32 });
console.info('[llm] captured frames', llmFrames.length, llmFrames.map((f) => `${f.kind}@${f.time.toFixed(1)}s`));
} catch (e) {
console.warn('[llm] captureThumbnails failed', e);
}
showToast(`Contacting LLM (${cfg.model}) · ${llmFrames.length ? llmFrames.length + ' frames' : 'no frames'}…`, { ok: true });
try {
const result = await requestSongTweak({
spec: specBefore,
track: trackBefore,
look: lookBefore,
hint: hint || '',
frames: llmFrames,
config: cfg,
onProgress: (evt) => {
if (!evt) return;
const text = evt.text ? evt.text.slice(0, 80) : '';
if (evt.stage === 'partial') {
const partialKeys = evt.partial && evt.partial.edits ? Object.keys(evt.partial.edits).filter((k) => evt.partial.edits[k] != null) : [];
showToast(`LLM streaming… ${text}${partialKeys.length ? ` · ${partialKeys.join(', ')}` : ''}`, { ok: true });
console.info('[llm] stream', evt);
} else if (evt.stage === 'streaming') {
showToast(`LLM streaming… ${text}`, { ok: true });
}
},
});
// Log what came back before any synthesis — this is the audit trail.
console.groupCollapsed(`[llm] got result — ${Object.keys(result.edits).join(', ') || 'no edits'} · confidence ${String(result.confidence)}`);
console.log('[llm] rationale:', result.rationale);
console.log('[llm] edits:', result.edits);
console.log('[llm] raw:', result.raw, 'usage:', result.usage);
console.groupEnd();
const editsSummary = Object.keys(result.edits).length
? Object.entries(result.edits).map(([k, v]) => `${k}→${v == null ? '∅' : String(v)}`).join(' · ')
: '(no knobs changed)';
showToast(`LLM replied: ${result.rationale.slice(0, 160)} — edits: ${editsSummary}`, { ok: true, timeout: 5000 });
const specAfter = { ...specBefore, ...result.edits };
// Validate locally by re-synthesizing and measuring — show diff before applying.
// Do a lightweight preview synthesis so we can report section/agitation delta
// without mutating the live Show.
let trackAfter = null, lookAfter = null;
try {
const buf = synthesizeSong({
bpm: specAfter.bpm,
duration: specAfter.duration,
arrangement: specAfter.arrangement,
brightness: specAfter.brightness,
noise: specAfter.noise,
noiseColour: specAfter.noiseColour,
dynamics: specAfter.dynamics,
transition: specAfter.transition,
seed: specAfter.seed ?? specBefore.seed ?? 1,
});
trackAfter = FeatureTrack.fromAudioBuffer(buf, { fps: state.show.fps });
const previewSeed = specAfter.seed != null ? (specAfter.seed >>> 0) : (specBefore.seed != null ? (specBefore.seed >>> 0) + 1 : 1);
lookAfter = generateLook(trackAfter, { seed: previewSeed });
console.info('[llm] preview look', lookAfter ? `${lookAfter.director} · ${lookAfter.sections.length} sections` : 'n/a',
trackAfter ? `sections ${trackAfter.sections.length}` : '');
} catch (e) {
console.warn('[llm] preview synthesis failed', e);
}
openLlmTweakDialog(result, specBefore, specAfter, trackBefore, trackAfter, lookBefore, lookAfter);
} catch (err) {
console.warn('[llm] request failed', err && err.stack ? err.stack : err);
const code = err && err.code;
if (code === 'NO_EDITS') {
showToast(err.message, { ok: true, timeout: 5000 });
} else if (code === 'BUDGET_PER_LOAD' || code === 'BUDGET_PER_SESSION' || code === 'BUDGET') {
showToast(err.message);
} else if (code === 'AUTH') {
showToast(`LLM auth failed (401) — check your gateway API key in ⚙️\n${err.message}`);
} else if (code === 'RATE_LIMIT') {
showToast(`LLM rate limited (429) — try again shortly\n${err.message}`);
} else if (code === 'NOT_CONFIGURED') {
openLlmConfigDialog();
} else {
showToast(`LLM tweak failed: ${err.message}`);
}
} finally {
llmTweakInFlight = false;
if (btn) btn.textContent = prevLabel || '✨ tune';
setLlmTweakButtonState();
}
}
document.getElementById('btn-llm-tweak')?.addEventListener('click', handleLlmTweak);
document.getElementById('btn-llm-config')?.addEventListener('click', openLlmConfigDialog);
// Keep the tune button in sync with ready/config/budget — renderPanel is
// called after every load/reroll/param change.
const __prevRenderPanel = renderPanel;
renderPanel = function llmAwareRenderPanel(...args) {
const r = __prevRenderPanel.apply(this, args);
setLlmTweakButtonState();
return r;
};
// Initial state — disabled until a track is loaded or a previous session had one
setLlmTweakButtonState();
// Keep loadBankSong in sync with LLM spec tracking
const __origLoadBankSong = loadBankSong;
loadBankSong = async function trackedLoadBankSong(name, seed) {
const spec = SONGS.find((s) => s.name === name);
if (spec) {
currentSongSpec = {
bpm: spec.bpm,
brightness: spec.brightness,
noise: spec.noise,
noiseColour: spec.noiseColour ?? null,
dynamics: spec.dynamics,
transition: spec.transition ?? 0.5,
arrangement: spec.arrangement,
duration: 120,
seed: seed != null ? (Number(seed) >>> 0) : (1 + SONGS.indexOf(spec)),
};
}
return __origLoadBankSong(name, seed);
};