Phase 6 WIP
This commit is contained in:
+431
-2
@@ -1,2 +1,431 @@
|
||||
// Application entry point — built up as the phases land.
|
||||
console.log("flow-state");
|
||||
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 { renderClickTrack, audioBufferToWavBlob } from './audio/clicktrack.js';
|
||||
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported } from './export/Exporter.js';
|
||||
|
||||
const QUALITY = {
|
||||
draft: 0.5, // half resolution — for scrubbing heavy stacks
|
||||
full: 1.0,
|
||||
};
|
||||
|
||||
const dom = {
|
||||
canvas: document.getElementById('canvas'),
|
||||
stage: document.getElementById('stage'),
|
||||
overlay: document.getElementById('overlay'),
|
||||
dropzone: document.getElementById('dropzone'),
|
||||
analysing: document.getElementById('analysing'),
|
||||
anStep: document.querySelector('#analysing .an-step'),
|
||||
anFill: document.querySelector('#analysing .an-fill'),
|
||||
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'),
|
||||
play: document.getElementById('btn-play'),
|
||||
};
|
||||
|
||||
const state = {
|
||||
show: new Show({ canvas: dom.canvas, width: 1280, height: 720 }),
|
||||
playing: false,
|
||||
quality: 'full',
|
||||
loopSection: -1,
|
||||
tab: 'look',
|
||||
hudVisible: false,
|
||||
busy: false,
|
||||
rerollSalt: 0,
|
||||
lastFrameTime: 0,
|
||||
fps: 0,
|
||||
};
|
||||
|
||||
const strip = new TimelineStrip(dom.timelineCanvas, { onSeek: seekTo });
|
||||
const paramPanel = new ParamPanel(document.createElement('div'), onParamChange);
|
||||
|
||||
// ---------------------------------------------------------------- loading
|
||||
|
||||
async function loadFile(file) {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
dom.dropzone.hidden = true;
|
||||
dom.analysing.hidden = false;
|
||||
|
||||
try {
|
||||
await state.show.load(file, (stage, fraction) => {
|
||||
dom.anStep.textContent = stage;
|
||||
dom.anFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
|
||||
});
|
||||
|
||||
dom.audio.src = URL.createObjectURL(file);
|
||||
dom.overlay.hidden = true;
|
||||
strip.setShow(state.show);
|
||||
resize();
|
||||
seekTo(0);
|
||||
renderPanel();
|
||||
document.title = `flow-state · ${state.show.fileName}`;
|
||||
console.info('[flow-state]', describeLook(state.show.look));
|
||||
} catch (err) {
|
||||
dom.anStep.textContent = `failed: ${err.message}`;
|
||||
console.error(err);
|
||||
setTimeout(() => {
|
||||
dom.analysing.hidden = true;
|
||||
dom.dropzone.hidden = false;
|
||||
}, 4000);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
dom.dropzone.addEventListener('click', () => dom.fileInput.click());
|
||||
dom.fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files[0]) loadFile(e.target.files[0]);
|
||||
});
|
||||
document.addEventListener('dragover', (e) => e.preventDefault());
|
||||
document.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const file = [...e.dataTransfer.files].find(
|
||||
(f) => f.type.startsWith('audio/') || /\.(mp3|flac|wav|ogg|m4a)$/i.test(f.name));
|
||||
if (file) loadFile(file);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- transport
|
||||
|
||||
function seekTo(frame) {
|
||||
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);
|
||||
strip.setFrame(clamped);
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!state.show.ready) return;
|
||||
state.playing = !state.playing;
|
||||
if (state.playing) dom.audio.play(); 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;
|
||||
});
|
||||
document.getElementById('sel-quality').addEventListener('change', (e) => {
|
||||
state.quality = e.target.value;
|
||||
resize();
|
||||
});
|
||||
|
||||
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 ',': seekTo(state.show.timeline.frame - 1); break;
|
||||
case '.': seekTo(state.show.timeline.frame + 1); break;
|
||||
default: break;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- look edits
|
||||
|
||||
document.getElementById('btn-reroll').addEventListener('click', () => {
|
||||
if (!state.show.ready) return;
|
||||
state.show.reroll((state.show.look.seed ^ (++state.rerollSalt * 0x9e3779b9)) >>> 0);
|
||||
refreshAfterLookChange();
|
||||
});
|
||||
|
||||
document.getElementById('btn-reroll-section').addEventListener('click', () => {
|
||||
if (!state.show.ready) return;
|
||||
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
|
||||
state.show.rerollSection(index, ++state.rerollSalt);
|
||||
refreshAfterLookChange();
|
||||
});
|
||||
|
||||
document.getElementById('btn-lock').addEventListener('click', () => {
|
||||
if (!state.show.ready) return;
|
||||
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
|
||||
const section = state.show.look.sections[index];
|
||||
section.locked = !section.locked;
|
||||
renderPanel();
|
||||
});
|
||||
|
||||
function refreshAfterLookChange() {
|
||||
state.show.seek(state.show.timeline.frame);
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
function onParamChange(name, value) {
|
||||
if (!state.show.ready) return;
|
||||
const index = state.show.track.sectionIndexAt(state.show.timeline.frame);
|
||||
state.show.setSectionParam(index, name, value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- panel
|
||||
|
||||
dom.panelTabs.addEventListener('click', (e) => {
|
||||
const tab = e.target.dataset.tab;
|
||||
if (!tab) return;
|
||||
state.tab = tab;
|
||||
[...dom.panelTabs.children].forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
|
||||
renderPanel();
|
||||
});
|
||||
|
||||
function renderPanel() {
|
||||
if (!state.show.ready) return;
|
||||
const show = state.show;
|
||||
const index = show.track.sectionIndexAt(show.timeline.frame);
|
||||
const section = show.look.sections[index];
|
||||
dom.panelBody.innerHTML = '';
|
||||
dom.panelBody.oninput = null;
|
||||
|
||||
if (state.tab === 'scene') {
|
||||
paramPanel.container = dom.panelBody;
|
||||
paramPanel.build(section.layers[0].module, section.layers[0].params);
|
||||
if (section.layers.length > 1) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'pp-reactive';
|
||||
note.innerHTML = '<div class="pp-sub">accent layer</div>' +
|
||||
section.layers.slice(1).map((l) =>
|
||||
`<div class="pp-react-row"><span>${l.module.name}</span>` +
|
||||
`<span class="pp-feature">${l.blend}</span>` +
|
||||
`<span class="pp-amount">${l.opacity.toFixed(2)}</span></div>`).join('');
|
||||
dom.panelBody.appendChild(note);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.tab === 'look') {
|
||||
const summary = show.track.summary;
|
||||
dom.panelBody.innerHTML = `
|
||||
<div class="pp-heading"><span class="pp-name">${show.fileName || 'track'}</span></div>
|
||||
<div class="kv"><span>seed</span><b>${show.look.seed.toString(16)}</b></div>
|
||||
<div class="kv"><span>bpm</span><b>${summary.bpm.toFixed(1)}</b></div>
|
||||
<div class="kv"><span>tempo conf.</span><b>${show.track.tempo.confidence.toFixed(2)}</b></div>
|
||||
<div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div>
|
||||
<div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div>
|
||||
<div class="kv"><span>scheme</span><b>${show.look.paletteScheme}</b></div>
|
||||
<div class="kv"><span>brightness</span><b>${summary.meanCentroid.toFixed(3)}</b></div>
|
||||
<div class="kv"><span>dynamics</span><b>${summary.dynamicRange.toFixed(3)}</b></div>
|
||||
<div class="swatches">${show.look.palette.map((c) =>
|
||||
`<span class="sw" style="background:${toHex(c)}" title="${toHex(c)}"></span>`).join('')}</div>
|
||||
<div class="pp-sub">sections</div>
|
||||
${show.look.sections.map((s, i) => `
|
||||
<div class="kv ${i === index ? 'current' : ''}">
|
||||
<span>${s.kind}${s.locked ? ' 🔒' : ''}</span>
|
||||
<b>${s.layers.map((l) => l.module.name).join(' + ')}</b>
|
||||
</div>`).join('')}
|
||||
<button id="btn-clicktrack" 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-clicktrack').addEventListener('click', downloadClickTrack);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.tab === 'post') {
|
||||
const post = show.look.post;
|
||||
const fb = show.look.feedback;
|
||||
const wide = new Set(['contrast', 'saturation', 'exposure']);
|
||||
const rows = Object.entries(post).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">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);
|
||||
}
|
||||
};
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- export
|
||||
|
||||
function currentPreset() {
|
||||
const el = document.getElementById('sel-preset');
|
||||
return el ? el.value : '1080p';
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
try {
|
||||
const preset = currentPreset();
|
||||
const blob = segment
|
||||
? await exportSegment(state.show, state.show.timeline.frame,
|
||||
{ seconds: 20, preset, onProgress: exportProgress })
|
||||
: await new Exporter(state.show).export({ preset, onProgress: exportProgress });
|
||||
|
||||
const suffix = segment ? `-segment-${state.show.timeline.frame}` : '';
|
||||
downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`);
|
||||
const status = document.getElementById('export-status');
|
||||
if (status) status.textContent = `done — ${(blob.size / 1e6).toFixed(1)} MB`;
|
||||
} catch (err) {
|
||||
const status = document.getElementById('export-status');
|
||||
if (status) status.textContent = `failed: ${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-clicktrack');
|
||||
button.textContent = 'rendering…';
|
||||
try {
|
||||
const buffer = await renderClickTrack(state.show.audioBuffer, state.show.track.tempo);
|
||||
downloadBlob(audioBufferToWavBlob(buffer), `${state.show.fileName}-clicktrack.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;
|
||||
|
||||
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) { state.playing = false; dom.play.textContent = '▶'; }
|
||||
}
|
||||
|
||||
show.present(show.renderFrame(show.timeline.frame));
|
||||
|
||||
strip.setFrame(show.timeline.frame);
|
||||
strip.draw();
|
||||
|
||||
const dt = now - state.lastFrameTime;
|
||||
state.lastFrameTime = now;
|
||||
if (dt > 0) state.fps = state.fps * 0.9 + (1000 / dt) * 0.1;
|
||||
|
||||
dom.timeDisplay.textContent = `${formatTime(show.timeline.time)} / ${formatTime(show.duration)}`;
|
||||
|
||||
const sectionIndex = show.track.sectionIndexAt(show.timeline.frame);
|
||||
const arc = show.arc.state;
|
||||
dom.sectionDisplay.textContent =
|
||||
`${arc.kind || ''} · ${arc.sceneName || ''}` +
|
||||
`${arc.crossfade > 0 ? ` · fade ${arc.crossfade.toFixed(2)}` : ''}`;
|
||||
|
||||
if (sectionIndex !== lastPanelSection) {
|
||||
lastPanelSection = sectionIndex;
|
||||
if (state.tab === 'scene' || state.tab === 'look') renderPanel();
|
||||
}
|
||||
|
||||
if (state.hudVisible) {
|
||||
const f = show.track.at(show.timeline.frame);
|
||||
dom.hud.innerHTML =
|
||||
`<div>${state.fps.toFixed(0)} fps · frame ${show.timeline.frame}/${show.frameCount}</div>` +
|
||||
`<div>${show.engine.width}×${show.engine.height} · ${state.quality}</div>` +
|
||||
`<div>section ${sectionIndex} ${arc.kind} · ${arc.sceneName}</div>` +
|
||||
`<div>layers ${show.arc.activeLayers.length} · build ${(f.buildSlope || 0).toFixed(2)}</div>` +
|
||||
`<div>loud ${f.loudness.toFixed(2)} low ${f.bandLow.toFixed(2)} high ${f.bandHigh.toFixed(2)}</div>` +
|
||||
`<div>beat ${f.beat.toFixed(2)} bar ${f.barPhase.toFixed(2)} flux ${f.flux.toFixed(2)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
resize();
|
||||
|
||||
Reference in New Issue
Block a user