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 = `
stage visuals · ${shots.length} shots
` + section.variants.map((stack, v) => `
` + `${v === 0 ? '●' : '○'} ${subjectOf(stack).module.name}` + `${shots.filter((s) => s.variant === v).length}×` + `
`).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) => `
${l.module.name}` + `${label}` + `${(sceneCoverage(l.module) * 100).toFixed(0)}%
`; const note = document.createElement('div'); note.className = 'pp-reactive'; note.innerHTML = `
stack · ${(stackCoverage(section.layers) * 100).toFixed(0)}% painted
` + (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 = `
${show.fileName || 'track'}
seed${show.look.seed.toString(16)}
bpm${summary.bpm.toFixed(1)}
tempo conf.${show.track.tempo.confidence.toFixed(2)}
duration${formatTime(show.duration)}
sections${show.track.sections.length}
venue${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)}` : ''} ${show.look.venueEnabled ? 'disable' : 'enable'} · gallery
director${show.look.director}${plan ? ` · ${plan.progression}` : ''}
built on${personality.signature.join(' + ') || 'nothing'}
form${personality.shape.sides || 'round'}${ personality.shape.sides ? '-sided' : ''}
camera${(personality.camera.driftRate * 100).toFixed(1)} drift · ${ personality.camera.spin >= 0 ? '+' : ''}${personality.camera.spin.toFixed(3)} spin
art${personality.style.symmetry > 1 ? personality.style.symmetry + '-fold · ' : ''}line ${personality.style.lineWeight.toFixed(2)}
brightness${summary.meanCentroid.toFixed(3)}
dynamics${summary.dynamicRange.toFixed(3)}
palettes · ${planLabel}
${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 `
#${i + 1}${sc}${tag}
${pal.map((c) => ``).join('')}
`; }).join('')}
${blend ? `blending #${blend.from + 1} → #${blend.to + 1} · ${(blend.t * 100).toFixed(0)}% through cut` : ''}
sections
${show.look.sections.map((s, i) => `
${s.kind}${s.locked ? ' 🔒' : ''} ${s.shots ? `${s.shots.length} shots` : ''} ${(s.variants || [s.layers]).map((v) => subjectOf(v).module.name).join(' / ')}
`).join('')}
Mixes clicks onto the detected beat grid. If they don't sit on the beat, tempo detection is wrong and everything downstream inherits it.
`; 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]) => `
${(+value).toFixed(2)}
`).join(''); const fbRows = Object.entries(fb).map(([key, value]) => `
${(+value).toFixed(3)}
`).join(''); dom.panelBody.innerHTML = `
post
${rows}` + `
grain
${grainRows(show.look.grain)}` + `
feedback
${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 = `
export
${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.'}
`; } } /** * 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) => `
${(+value).toFixed(3)}
`; const modeRow = `
`; if (grain.mode === 'off') { return modeRow + `
${describeGrain(grain)}
`; } const maskRow = `
`; const kindsRow = grain.mode !== 'sections' ? '' : `
${['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'].map((k) => ` `).join(' ')}
`; 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 + `
${describeGrain(grain)}
`; } // ---------------------------------------------------------------- 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 = `
${state.fps.toFixed(0)} fps · frame ${show.timeline.frame}/${show.frameCount}
` + `
${show.engine.width}×${show.engine.height} · ${state.quality}
` + `
section ${sectionIndex} ${arc.kind} · ${arc.sceneName}
` + `
layers ${show.arc.activeLayers.length} · build ${(f.buildSlope || 0).toFixed(2)}
` + // 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. `
${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)}
` + `
loud ${f.loudness.toFixed(2)} low ${f.bandLow.toFixed(2)} high ${f.bandHigh.toFixed(2)}
` + `
beat ${f.beat.toFixed(2)} bar ${f.barPhase.toFixed(2)} flux ${f.flux.toFixed(2)}
`; } } // 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 = `
`; 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, '&').replace(/"/g, '"').replace(//g, '>'); } 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 `
${k}${fmt(a)} → ${fmt(b)}
`; }).join('') || '
No song knobs changed (model returned empty edits — this dialog should not have opened).
'; const overlay = document.createElement('div'); overlay.className = 'llm-overlay'; overlay.innerHTML = `
`; 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); };