diff --git a/flow-state/package-lock.json b/flow-state/package-lock.json index 895e1ec..4f1c94b 100644 --- a/flow-state/package-lock.json +++ b/flow-state/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "mp4-muxer": "^5.2.2", "three": "^0.181.1" }, "devDependencies": { @@ -824,6 +825,12 @@ "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": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -831,6 +838,12 @@ "dev": true, "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": { "version": "0.28.1", "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_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": { "version": "3.3.17", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", diff --git a/flow-state/package.json b/flow-state/package.json index ccf76c9..17b7959 100644 --- a/flow-state/package.json +++ b/flow-state/package.json @@ -15,6 +15,7 @@ "vite": "^7.2.2" }, "dependencies": { + "mp4-muxer": "^5.2.2", "three": "^0.181.1" } } diff --git a/flow-state/src/checks/phase6.js b/flow-state/src/checks/phase6.js index 1d617fd..e69de29 100644 --- a/flow-state/src/checks/phase6.js +++ b/flow-state/src/checks/phase6.js @@ -1 +0,0 @@ -// Phase 6 gate — filled in when the phase lands. diff --git a/flow-state/src/export/Exporter.js b/flow-state/src/export/Exporter.js new file mode 100644 index 0000000..05a0ca7 --- /dev/null +++ b/flow-state/src/export/Exporter.js @@ -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} + */ + 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); +} diff --git a/flow-state/src/main.js b/flow-state/src/main.js index 493a3ce..72c57d2 100644 --- a/flow-state/src/main.js +++ b/flow-state/src/main.js @@ -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 = '
accent layer
' + + section.layers.slice(1).map((l) => + `
${l.module.name}` + + `${l.blend}` + + `${l.opacity.toFixed(2)}
`).join(''); + dom.panelBody.appendChild(note); + } + return; + } + + if (state.tab === 'look') { + const summary = show.track.summary; + 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}
+
scheme${show.look.paletteScheme}
+
brightness${summary.meanCentroid.toFixed(3)}
+
dynamics${summary.dynamicRange.toFixed(3)}
+
${show.look.palette.map((c) => + ``).join('')}
+
sections
+ ${show.look.sections.map((s, i) => ` +
+ ${s.kind}${s.locked ? ' 🔒' : ''} + ${s.layers.map((l) => l.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-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]) => ` +
+ + + ${(+value).toFixed(2)} +
`).join(''); + const fbRows = Object.entries(fb).map(([key, value]) => ` +
+ + + ${(+value).toFixed(3)} +
`).join(''); + dom.panelBody.innerHTML = + `
post
${rows}
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); + } + }; + 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.'}
+
`; + } +} + +// ---------------------------------------------------------------- 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 = + `
${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)}
` + + `
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)}
`; + } +} + +requestAnimationFrame(frame); +resize(); diff --git a/flow-state/src/ui/TimelineStrip.js b/flow-state/src/ui/TimelineStrip.js new file mode 100644 index 0000000..5d4be34 --- /dev/null +++ b/flow-state/src/ui/TimelineStrip.js @@ -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 }; diff --git a/flow-state/src/ui/style.css b/flow-state/src/ui/style.css index f1dd30b..5804a14 100644 --- a/flow-state/src/ui/style.css +++ b/flow-state/src/ui/style.css @@ -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; + } +}