Phase 6 WIP

This commit is contained in:
Dejvino 2026-08-05 11:56:52 +02:00
parent 59180948d0
commit 968101c532
7 changed files with 983 additions and 4 deletions

View File

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"mp4-muxer": "^5.2.2",
"three": "^0.181.1" "three": "^0.181.1"
}, },
"devDependencies": { "devDependencies": {
@ -824,6 +825,12 @@
"win32" "win32"
] ]
}, },
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -831,6 +838,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/wicg-file-system-access": {
"version": "2020.9.8",
"resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz",
"integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==",
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.1", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@ -906,6 +919,17 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/mp4-muxer": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz",
"integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==",
"deprecated": "This library is superseded by Mediabunny. Please migrate to it.",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.6",
"@types/wicg-file-system-access": "^2020.9.5"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.17", "version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",

View File

@ -15,6 +15,7 @@
"vite": "^7.2.2" "vite": "^7.2.2"
}, },
"dependencies": { "dependencies": {
"mp4-muxer": "^5.2.2",
"three": "^0.181.1" "three": "^0.181.1"
} }
} }

View File

@ -1 +0,0 @@
// Phase 6 gate — filled in when the phase lands.

View File

@ -0,0 +1,226 @@
import { Muxer, ArrayBufferTarget } from 'mp4-muxer';
/**
* Offline export.
*
* Drives the same Show object the preview does, with the timeline in fixed-step
* mode the frame index is counted, never derived from a clock. That is the
* whole basis of preview/export parity: the exporter has no render path of its
* own, so there is nothing for it to diverge from.
*
* Video goes through WebCodecs VideoEncoder (hardware accelerated where
* available) into an mp4. Audio is re-encoded to AAC from the already-decoded
* PCM, so the file carries the same samples the analysis ran on.
*/
export const PRESETS = {
'720p': { width: 1280, height: 720, bitrate: 8_000_000 },
'1080p': { width: 1920, height: 1080, bitrate: 16_000_000 },
'1440p': { width: 2560, height: 1440, bitrate: 28_000_000 },
'4K': { width: 3840, height: 2160, bitrate: 45_000_000 },
};
export function isSupported() {
return typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined';
}
/** Probe for a codec configuration the browser will actually accept. */
async function pickVideoConfig(width, height, bitrate, fps) {
const candidates = [
'avc1.640034', 'avc1.640033', 'avc1.4d0034', 'avc1.42003e',
];
for (const codec of candidates) {
const config = {
codec, width, height, bitrate, framerate: fps,
avc: { format: 'avc' },
};
try {
const support = await VideoEncoder.isConfigSupported(config);
if (support.supported) return config;
} catch { /* try the next one */ }
}
return null;
}
export class Exporter {
constructor(show) {
this.show = show;
this.cancelled = false;
}
cancel() { this.cancelled = true; }
/**
* @param {object} options
* @param {string} options.preset key of PRESETS
* @param {[number, number]} [options.frameRange] inclusive-exclusive frame range
* @param {(progress: {frame, total, fraction, stage}) => void} [options.onProgress]
* @returns {Promise<Blob>}
*/
async export({ preset = '1080p', frameRange = null, onProgress = null } = {}) {
if (!isSupported()) {
throw new Error('WebCodecs VideoEncoder is unavailable in this browser');
}
const show = this.show;
if (!show.ready) throw new Error('no track loaded');
const { width, height, bitrate } = PRESETS[preset] || PRESETS['1080p'];
const fps = show.fps;
const [startFrame, endFrame] = frameRange || [0, show.frameCount];
const total = Math.max(1, endFrame - startFrame);
const videoConfig = await pickVideoConfig(width, height, bitrate, fps);
if (!videoConfig) throw new Error('no supported H.264 configuration found');
const hasAudio = !!show.audioBuffer && typeof AudioEncoder !== 'undefined';
const muxer = new Muxer({
target: new ArrayBufferTarget(),
video: { codec: 'avc', width, height, frameRate: fps },
...(hasAudio ? {
audio: {
codec: 'aac',
sampleRate: show.audioBuffer.sampleRate,
numberOfChannels: Math.min(2, show.audioBuffer.numberOfChannels),
},
} : {}),
fastStart: 'in-memory',
});
const errors = [];
const videoEncoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: (e) => errors.push(e),
});
videoEncoder.configure(videoConfig);
// Render at export resolution. The preview's own size is restored after.
const previousWidth = show.engine.width;
const previousHeight = show.engine.height;
show.setSize(width, height);
try {
// Warm-up so the first exported frame has the same feedback state it
// would have had in sequential playback from the range start.
if (startFrame > 0) {
onProgress && onProgress({ frame: 0, total, fraction: 0, stage: 'warming up' });
show.warmUp(startFrame, show.warmupFrames());
} else {
show.engine.compositor.reset();
}
for (let i = 0; i < total; i++) {
if (this.cancelled) throw new Error('export cancelled');
const frameIndex = startFrame + i;
const target = show.renderFrame(frameIndex);
show.present(target); // encode from the canvas, which now holds this frame
const timestamp = Math.round(((frameIndex - startFrame) * 1e6) / fps);
const videoFrame = new VideoFrame(show.engine.renderer.canvas, {
timestamp,
duration: Math.round(1e6 / fps),
});
// Keyframe every two seconds: seekable output without bloating size.
videoEncoder.encode(videoFrame, { keyFrame: i % (fps * 2) === 0 });
videoFrame.close();
// Yield periodically so the progress UI paints and the encoder
// queue drains rather than growing without bound.
if (i % 10 === 0) {
while (videoEncoder.encodeQueueSize > 30) {
await new Promise((r) => setTimeout(r, 4));
}
onProgress && onProgress({
frame: i, total, fraction: i / total, stage: 'rendering',
});
await new Promise((r) => setTimeout(r, 0));
}
if (errors.length) throw errors[0];
}
onProgress && onProgress({ frame: total, total, fraction: 1, stage: 'finishing video' });
await videoEncoder.flush();
if (hasAudio) {
onProgress && onProgress({ frame: total, total, fraction: 1, stage: 'encoding audio' });
await this._encodeAudio(muxer, startFrame, endFrame, fps);
}
muxer.finalize();
return new Blob([muxer.target.buffer], { type: 'video/mp4' });
} finally {
try { videoEncoder.close(); } catch { /* already closed */ }
show.setSize(previousWidth, previousHeight);
}
}
/** Encode the exported time range of the decoded PCM to AAC and mux it. */
async _encodeAudio(muxer, startFrame, endFrame, fps) {
const buffer = this.show.audioBuffer;
const sampleRate = buffer.sampleRate;
const channels = Math.min(2, buffer.numberOfChannels);
const startSample = Math.floor((startFrame / fps) * sampleRate);
const endSample = Math.min(buffer.length, Math.ceil((endFrame / fps) * sampleRate));
const length = Math.max(0, endSample - startSample);
if (!length) return;
const errors = [];
const encoder = new AudioEncoder({
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
error: (e) => errors.push(e),
});
encoder.configure({
codec: 'mp4a.40.2', sampleRate, numberOfChannels: channels, bitrate: 192_000,
});
const chunkFrames = 1024;
const interleaved = new Float32Array(chunkFrames * channels);
const sources = [];
for (let c = 0; c < channels; c++) sources.push(buffer.getChannelData(c));
for (let offset = 0; offset < length; offset += chunkFrames) {
const count = Math.min(chunkFrames, length - offset);
for (let i = 0; i < count; i++) {
for (let c = 0; c < channels; c++) {
interleaved[i * channels + c] = sources[c][startSample + offset + i];
}
}
const data = new AudioData({
format: 'f32',
sampleRate,
numberOfFrames: count,
numberOfChannels: channels,
timestamp: Math.round((offset / sampleRate) * 1e6),
data: interleaved.slice(0, count * channels),
});
encoder.encode(data);
data.close();
if (errors.length) throw errors[0];
if (offset % (chunkFrames * 64) === 0) await new Promise((r) => setTimeout(r, 0));
}
await encoder.flush();
encoder.close();
}
}
/** Render a short range around a frame — the "test render" bridge before a full export. */
export async function exportSegment(show, centreFrame, { seconds = 20, preset = '1080p', onProgress } = {}) {
const half = Math.round((seconds * show.fps) / 2);
const start = Math.max(0, centreFrame - half);
const end = Math.min(show.frameCount, centreFrame + half);
return new Exporter(show).export({ preset, frameRange: [start, end], onProgress });
}
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}

View File

@ -1,2 +1,431 @@
// Application entry point — built up as the phases land. import { Show } from './Show.js';
console.log("flow-state"); 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 ? ' &#128274;' : ''}</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();

View File

@ -0,0 +1,152 @@
// The transport strip: sections coloured by kind, bar ticks, scene-change
// markers, playhead.
//
// This is the review surface. Segmentation problems are visible here at a glance
// in a way they are not from watching the video — a boundary in the wrong place
// shows up as a coloured block that doesn't line up with what you're hearing.
const KIND_COLORS = {
intro: '#2f4858',
build: '#8a5a2b',
drop: '#b5323c',
sustain: '#3a6b52',
breakdown: '#3d3a6b',
outro: '#43404a',
};
export class TimelineStrip {
constructor(canvas, { onSeek = null } = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.onSeek = onSeek;
this.show = null;
this.frame = 0;
this.hoverFrame = -1;
this.loopSection = -1;
canvas.addEventListener('pointerdown', (e) => this._seekFromEvent(e));
canvas.addEventListener('pointermove', (e) => {
const rect = canvas.getBoundingClientRect();
this.hoverFrame = this._frameAt(e.clientX - rect.left, rect.width);
if (e.buttons & 1) this._seekFromEvent(e);
});
canvas.addEventListener('pointerleave', () => { this.hoverFrame = -1; });
}
setShow(show) { this.show = show; }
setFrame(frame) { this.frame = frame; }
setLoopSection(index) { this.loopSection = index; }
_frameAt(x, width) {
if (!this.show || !this.show.ready) return 0;
return Math.round((x / Math.max(1, width)) * (this.show.frameCount - 1));
}
_seekFromEvent(event) {
if (!this.onSeek || !this.show || !this.show.ready) return;
const rect = this.canvas.getBoundingClientRect();
this.onSeek(this._frameAt(event.clientX - rect.left, rect.width));
}
resize() {
const rect = this.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(rect.width * dpr));
this.canvas.height = Math.max(1, Math.round(rect.height * dpr));
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this._w = rect.width;
this._h = rect.height;
}
draw() {
const ctx = this.ctx;
const w = this._w || this.canvas.width;
const h = this._h || this.canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#0e1016';
ctx.fillRect(0, 0, w, h);
const show = this.show;
if (!show || !show.ready) return;
const track = show.track;
const total = show.frameCount;
const toX = (frame) => (frame / total) * w;
// Sections.
for (const section of track.sections) {
const x0 = toX(section.startFrame);
const x1 = toX(section.endFrame);
const look = show.look.sections[section.index];
ctx.fillStyle = KIND_COLORS[section.kind] || '#333';
ctx.fillRect(x0, 0, Math.max(1, x1 - x0), h);
if (look && look.locked) {
ctx.save();
ctx.strokeStyle = 'rgba(250, 204, 21, 0.85)';
ctx.lineWidth = 2;
ctx.strokeRect(x0 + 1, 1, Math.max(1, x1 - x0) - 2, h - 2);
ctx.restore();
}
if (section.index === this.loopSection) {
ctx.fillStyle = 'rgba(255,255,255,0.10)';
ctx.fillRect(x0, 0, Math.max(1, x1 - x0), h);
}
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, Math.max(0, x1 - x0), h);
ctx.clip();
ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.font = '10px ui-monospace, monospace';
ctx.fillText(section.kind, x0 + 5, 13);
if (look) {
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.fillText(look.layers[0].module.name, x0 + 5, h - 6);
}
ctx.restore();
// Boundary line.
ctx.strokeStyle = 'rgba(0,0,0,0.65)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x0 + 0.5, 0);
ctx.lineTo(x0 + 0.5, h);
ctx.stroke();
}
// Bar ticks — sparse enough to stay readable on a long track.
const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / track.fps;
const pixelsPerBar = (barSeconds * track.fps / total) * w;
if (pixelsPerBar > 6) {
const every = pixelsPerBar > 18 ? 1 : 4;
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.beginPath();
let bar = 0;
for (let t = 0; t < track.duration; t += barSeconds, bar++) {
if (bar % every) continue;
const x = Math.round(toX(t * track.fps)) + 0.5;
ctx.moveTo(x, h - 7);
ctx.lineTo(x, h);
}
ctx.stroke();
}
// Hover.
if (this.hoverFrame >= 0) {
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.fillRect(toX(this.hoverFrame), 0, 1, h);
}
// Playhead.
const px = toX(this.frame);
ctx.fillStyle = '#f8fafc';
ctx.fillRect(px - 1, 0, 2, h);
ctx.fillStyle = 'rgba(248,250,252,0.25)';
ctx.fillRect(0, h - 2, px, 2);
}
}
export { KIND_COLORS };

View File

@ -1 +1,149 @@
/* styles */ :root {
color-scheme: dark;
--bg: #07080c;
--panel: #0e1016;
--line: #1c2029;
--text: #d6dae3;
--dim: #7d8698;
--accent: #4ade80;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
overflow: hidden;
}
#app {
display: grid;
grid-template-columns: 1fr 300px;
grid-template-rows: 1fr auto;
grid-template-areas: "stage panel" "transport panel";
height: 100vh;
gap: 1px;
background: var(--line);
}
#stage {
grid-area: stage;
position: relative;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#canvas { display: block; max-width: 100%; max-height: 100%; }
#overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
#dropzone {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
border: 1px dashed var(--line);
}
#dropzone:hover { background: rgba(255,255,255,0.02); }
.dz-inner { text-align: center; }
.dz-title { font-size: 26px; letter-spacing: .34em; text-transform: uppercase; margin-bottom: 12px; }
.dz-sub { color: var(--dim); }
.dz-hint { color: #4b5563; font-size: 11px; margin-top: 6px; }
#analysing { text-align: center; min-width: 260px; }
.an-title { letter-spacing: .3em; text-transform: uppercase; color: var(--dim); }
.an-step { margin: 8px 0; color: var(--text); }
.an-bar { height: 3px; background: var(--line); overflow: hidden; margin-top: 8px; }
.an-fill { height: 100%; width: 0; background: var(--accent); transition: width .12s linear; }
#hud {
position: absolute; top: 10px; left: 10px;
background: rgba(7,8,12,0.82);
border: 1px solid var(--line);
padding: 8px 10px;
font-size: 11px; line-height: 1.65;
color: #9aa3b4;
pointer-events: none;
}
#transport { grid-area: transport; background: var(--panel); }
#timeline { height: 46px; }
#timeline-canvas { width: 100%; height: 46px; display: block; cursor: pointer; }
#controls {
display: flex; align-items: center; gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--line);
flex-wrap: wrap;
}
#controls .spacer { flex: 1; }
#time-display, #section-display { color: var(--dim); font-size: 12px; }
button, select {
background: #171a22;
color: var(--text);
border: 1px solid var(--line);
padding: 5px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
button:hover, select:hover { background: #1f232d; }
button.primary { border-color: #2f6f4a; color: var(--accent); }
button.wide { width: 100%; margin-top: 12px; }
.ctl { color: var(--dim); display: flex; align-items: center; gap: 5px; }
#panel {
grid-area: panel;
background: var(--panel);
display: flex; flex-direction: column;
overflow: hidden;
}
#panel-tabs { display: flex; border-bottom: 1px solid var(--line); }
#panel-tabs button { flex: 1; border: 0; border-right: 1px solid var(--line); background: transparent; color: var(--dim); }
#panel-tabs button.active { color: var(--text); background: #141821; }
#panel-body { flex: 1; overflow-y: auto; padding: 10px; }
.pp-heading { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
.pp-name { font-size: 14px; }
.pp-family { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; }
.pp-sub { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; margin: 14px 0 6px; }
.pp-row { display: grid; grid-template-columns: 84px 1fr 46px; gap: 8px; align-items: center; margin-bottom: 5px; }
.pp-label { color: var(--dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
.pp-value { color: var(--text); font-size: 11px; text-align: right; }
.pp-input { width: 100%; }
input[type=range] { accent-color: var(--accent); background: transparent; }
.pp-reactive { margin-top: 12px; }
.pp-react-row { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; font-size: 11px; color: var(--dim); }
.pp-feature { color: #6b8afd; }
.pp-amount { color: var(--text); }
.kv { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 2px 0; }
.kv span { color: var(--dim); }
.kv b { font-weight: 500; text-align: right; }
.kv.current { background: rgba(74,222,128,0.08); margin: 0 -4px; padding: 2px 4px; }
.swatches { display: flex; gap: 3px; margin: 10px 0; }
.sw { flex: 1; height: 26px; border: 1px solid rgba(0,0,0,0.4); }
.hint { color: #4b5563; font-size: 11px; margin-top: 8px; line-height: 1.5; }
@media (max-width: 900px) {
#app {
grid-template-columns: 1fr;
grid-template-areas: "stage" "transport" "panel";
grid-template-rows: auto auto 1fr;
}
}