Phase 6 WIP
This commit is contained in:
parent
59180948d0
commit
968101c532
24
flow-state/package-lock.json
generated
24
flow-state/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
// Phase 6 gate — filled in when the phase lands.
|
|
||||||
226
flow-state/src/export/Exporter.js
Normal file
226
flow-state/src/export/Exporter.js
Normal 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);
|
||||||
|
}
|
||||||
@ -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 ? ' 🔒' : ''}</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();
|
||||||
|
|||||||
152
flow-state/src/ui/TimelineStrip.js
Normal file
152
flow-state/src/ui/TimelineStrip.js
Normal 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 };
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user