summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-12 13:23:34 +0100
committerskal <pascal.massimino@gmail.com>2026-02-12 13:23:34 +0100
commit82d34d198b3c916df4d5d39142095edb410e7500 (patch)
tree90b0bea0195872a1601de54ff8247380bb8799fe
parentdeda5868d070a09ff18b2eae19b20cd68bc9c2b6 (diff)
Timeline editor: streamline code structure
Factorize common patterns: POST_PROCESS_EFFECTS constant, helper functions (beatsToTime, timeToBeats, beatRange, detectConflicts). Reduce verbosity with modern JS features (nullish coalescing, optional chaining, Object.assign). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
-rw-r--r--tools/timeline_editor/index.html311
1 files changed, 141 insertions, 170 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html
index ed2f332..2c66ddd 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -177,6 +177,11 @@
</div>
<script>
+ // Constants
+ const POST_PROCESS_EFFECTS = new Set(['FadeEffect', 'FlashEffect', 'GaussianBlurEffect',
+ 'SolarizeEffect', 'VignetteEffect', 'ChromaAberrationEffect', 'DistortEffect',
+ 'ThemeModulationEffect', 'CNNEffect', 'CNNv2Effect']);
+
// State
const state = {
sequences: [], currentFile: null, selectedItem: null, pixelsPerSecond: 100,
@@ -272,18 +277,41 @@
return { sequences, bpm };
}
+ // Helpers
+ const beatsToTime = (beats) => beats * 60.0 / state.bpm;
+ const timeToBeats = (seconds) => seconds * state.bpm / 60.0;
+ const beatRange = (start, end) => {
+ const s = start.toFixed(1), e = end.toFixed(1);
+ const ss = beatsToTime(start).toFixed(1), es = beatsToTime(end).toFixed(1);
+ return state.showBeats ? `${s}-${e}b (${ss}-${es}s)` : `${ss}-${es}s (${s}-${e}b)`;
+ };
+
+ function detectConflicts(seq) {
+ const conflicts = new Set();
+ const priorityGroups = {};
+ seq.effects.forEach((effect, idx) => {
+ if (POST_PROCESS_EFFECTS.has(effect.className)) {
+ if (!priorityGroups[effect.priority]) priorityGroups[effect.priority] = [];
+ priorityGroups[effect.priority].push(idx);
+ }
+ });
+ for (const priority in priorityGroups) {
+ if (priorityGroups[priority].length > 1) {
+ for (const idx of priorityGroups[priority]) conflicts.add(idx);
+ }
+ }
+ return conflicts;
+ }
+
function serializeSeqFile(sequences) {
let output = `# Demo Timeline\n# Generated by Timeline Editor\n# BPM ${state.bpm}\n\n`;
for (const seq of sequences) {
- const seqLine = `SEQUENCE ${seq.startTime.toFixed(2)} ${seq.priority}`;
- output += seq.name ? `${seqLine} "${seq.name}"\n` : `${seqLine}\n`;
+ output += `SEQUENCE ${seq.startTime.toFixed(2)} ${seq.priority}${seq.name ? ` "${seq.name}"` : ''}\n`;
for (const effect of seq.effects) {
const modifier = effect.priorityModifier || '+';
+ const cleanArgs = effect.args?.replace(/\s*#\s*Priority:\s*\d+/i, '').trim();
output += ` EFFECT ${modifier} ${effect.className} ${effect.startTime.toFixed(2)} ${effect.endTime.toFixed(2)}`;
- if (effect.args) {
- const cleanArgs = effect.args.replace(/\s*#\s*Priority:\s*\d+/i, '').trim();
- if (cleanArgs) output += ` ${cleanArgs}`;
- }
+ if (cleanArgs) output += ` ${cleanArgs}`;
output += '\n';
}
output += '\n';
@@ -311,123 +339,90 @@
function renderWaveform() {
if (!state.audioBuffer) return;
const canvas = dom.waveformCanvas, ctx = canvas.getContext('2d');
- const audioDurationBeats = state.audioDuration * state.bpm / 60.0;
- const canvasWidth = audioDurationBeats * state.pixelsPerSecond, canvasHeight = 80;
- canvas.width = canvasWidth; canvas.height = canvasHeight;
- canvas.style.width = `${canvasWidth}px`; canvas.style.height = `${canvasHeight}px`;
- dom.waveformPlaybackIndicator.style.height = `${canvasHeight}px`;
- ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+ const w = timeToBeats(state.audioDuration) * state.pixelsPerSecond, h = 80;
+ canvas.width = w; canvas.height = h;
+ canvas.style.width = `${w}px`; canvas.style.height = `${h}px`;
+ dom.waveformPlaybackIndicator.style.height = `${h}px`;
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, w, h);
+
const channelData = state.audioBuffer.getChannelData(0);
- const samplesPerPixel = Math.ceil(channelData.length / canvasWidth);
+ const samplesPerPixel = Math.ceil(channelData.length / w);
+ const centerY = h / 2, amplitudeScale = h * 0.4;
+
ctx.strokeStyle = '#4ec9b0'; ctx.lineWidth = 1; ctx.beginPath();
- const centerY = canvasHeight / 2, amplitudeScale = canvasHeight * 0.4;
- for (let x = 0; x < canvasWidth; x++) {
- const startSample = Math.floor(x * samplesPerPixel);
- const endSample = Math.min(startSample + samplesPerPixel, channelData.length);
+ for (let x = 0; x < w; x++) {
+ const start = Math.floor(x * samplesPerPixel);
+ const end = Math.min(start + samplesPerPixel, channelData.length);
let min = 1.0, max = -1.0;
- for (let i = startSample; i < endSample; i++) {
- const sample = channelData[i];
- if (sample < min) min = sample;
- if (sample > max) max = sample;
+ for (let i = start; i < end; i++) {
+ min = Math.min(min, channelData[i]);
+ max = Math.max(max, channelData[i]);
}
const yMin = centerY - min * amplitudeScale, yMax = centerY - max * amplitudeScale;
- if (x === 0) ctx.moveTo(x, yMin); else ctx.lineTo(x, yMin);
+ x === 0 ? ctx.moveTo(x, yMin) : ctx.lineTo(x, yMin);
ctx.lineTo(x, yMax);
}
ctx.stroke();
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; ctx.lineWidth = 1; ctx.beginPath();
- ctx.moveTo(0, centerY); ctx.lineTo(canvasWidth, centerY); ctx.stroke();
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
+ ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(w, centerY); ctx.stroke();
}
function computeCPULoad() {
if (state.sequences.length === 0) return { maxTime: 60, loads: [], conflicts: [] };
- let maxTime = 60;
- for (const seq of state.sequences) {
- for (const effect of seq.effects) {
- maxTime = Math.max(maxTime, seq.startTime + effect.endTime);
- }
- }
- if (state.audioDuration > 0) maxTime = Math.max(maxTime, state.audioDuration * state.bpm / 60.0);
- const resolution = 0.1;
- const numSamples = Math.ceil(maxTime / resolution);
+ let maxTime = Math.max(60, ...state.sequences.flatMap(seq =>
+ seq.effects.map(eff => seq.startTime + eff.endTime)));
+ if (state.audioDuration > 0) maxTime = Math.max(maxTime, timeToBeats(state.audioDuration));
+
+ const resolution = 0.1, numSamples = Math.ceil(maxTime / resolution);
const loads = new Array(numSamples).fill(0);
const conflicts = new Array(numSamples).fill(false);
- const postProcessEffects = new Set(['FadeEffect', 'FlashEffect', 'GaussianBlurEffect', 'SolarizeEffect', 'VignetteEffect', 'ChromaAberrationEffect', 'DistortEffect', 'ThemeModulationEffect', 'CNNEffect', 'CNNv2Effect']);
+
+ const markConflict = (seq, effect) => {
+ const start = Math.floor((seq.startTime + effect.startTime) / resolution);
+ const end = Math.ceil((seq.startTime + effect.endTime) / resolution);
+ for (let i = start; i < end && i < numSamples; i++) conflicts[i] = true;
+ };
// Track load
- for (const seq of state.sequences) {
- for (const effect of seq.effects) {
- const startBeat = seq.startTime + effect.startTime;
- const endBeat = seq.startTime + effect.endTime;
- const startIdx = Math.floor(startBeat / resolution);
- const endIdx = Math.ceil(endBeat / resolution);
- for (let i = startIdx; i < endIdx && i < numSamples; i++) {
- loads[i] += 1.0;
- }
- }
- }
+ state.sequences.forEach(seq => seq.effects.forEach(effect => {
+ const start = Math.floor((seq.startTime + effect.startTime) / resolution);
+ const end = Math.ceil((seq.startTime + effect.endTime) / resolution);
+ for (let i = start; i < end && i < numSamples; i++) loads[i] += 1.0;
+ }));
- // Detect within-sequence conflicts (same priority in one sequence)
- for (const seq of state.sequences) {
+ // Detect within-sequence conflicts
+ state.sequences.forEach(seq => {
const priorityGroups = {};
- seq.effects.forEach(effect => {
- if (postProcessEffects.has(effect.className)) {
- if (!priorityGroups[effect.priority]) priorityGroups[effect.priority] = [];
- priorityGroups[effect.priority].push(effect);
+ seq.effects.forEach(eff => {
+ if (POST_PROCESS_EFFECTS.has(eff.className)) {
+ (priorityGroups[eff.priority] ??= []).push(eff);
}
});
- for (const priority in priorityGroups) {
- const group = priorityGroups[priority];
- if (group.length > 1) {
- for (const effect of group) {
- const startBeat = seq.startTime + effect.startTime;
- const endBeat = seq.startTime + effect.endTime;
- const startIdx = Math.floor(startBeat / resolution);
- const endIdx = Math.ceil(endBeat / resolution);
- for (let i = startIdx; i < endIdx && i < numSamples; i++) {
- conflicts[i] = true;
- }
- }
- }
- }
- }
+ Object.values(priorityGroups).forEach(group => {
+ if (group.length > 1) group.forEach(eff => markConflict(seq, eff));
+ });
+ });
- // Detect cross-sequence conflicts (same priority across sequences starting at same time)
+ // Detect cross-sequence conflicts
const timeGroups = {};
- state.sequences.forEach((seq, idx) => {
- const key = seq.startTime.toFixed(2);
- if (!timeGroups[key]) timeGroups[key] = [];
- timeGroups[key].push(idx);
- });
- for (const startTime in timeGroups) {
- const seqIndices = timeGroups[startTime];
- if (seqIndices.length > 1) {
- const crossPriorityMap = {};
- for (const idx of seqIndices) {
- const seq = state.sequences[idx];
- for (const effect of seq.effects) {
- if (postProcessEffects.has(effect.className)) {
- if (!crossPriorityMap[effect.priority]) crossPriorityMap[effect.priority] = [];
- crossPriorityMap[effect.priority].push({ effect, seq });
- }
- }
- }
- for (const priority in crossPriorityMap) {
- const group = crossPriorityMap[priority];
- if (group.length > 1) {
- for (const { effect, seq } of group) {
- const startBeat = seq.startTime + effect.startTime;
- const endBeat = seq.startTime + effect.endTime;
- const startIdx = Math.floor(startBeat / resolution);
- const endIdx = Math.ceil(endBeat / resolution);
- for (let i = startIdx; i < endIdx && i < numSamples; i++) {
- conflicts[i] = true;
- }
- }
+ state.sequences.forEach((seq, idx) =>
+ (timeGroups[seq.startTime.toFixed(2)] ??= []).push(idx));
+
+ Object.values(timeGroups).forEach(seqIndices => {
+ if (seqIndices.length < 2) return;
+ const crossPriorityMap = {};
+ seqIndices.forEach(idx => {
+ const seq = state.sequences[idx];
+ seq.effects.forEach(eff => {
+ if (POST_PROCESS_EFFECTS.has(eff.className)) {
+ (crossPriorityMap[eff.priority] ??= []).push({ effect: eff, seq });
}
- }
- }
- }
+ });
+ });
+ Object.values(crossPriorityMap).forEach(group => {
+ if (group.length > 1) group.forEach(({ effect, seq }) => markConflict(seq, effect));
+ });
+ });
return { maxTime, loads, conflicts, resolution };
}
@@ -435,30 +430,23 @@
function renderCPULoad() {
const canvas = dom.cpuLoadCanvas, ctx = canvas.getContext('2d');
const { maxTime, loads, conflicts, resolution } = computeCPULoad();
- const canvasWidth = maxTime * state.pixelsPerSecond, canvasHeight = 10;
- canvas.width = canvasWidth; canvas.height = canvasHeight;
- canvas.style.width = `${canvasWidth}px`; canvas.style.height = `${canvasHeight}px`;
- ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+ const w = maxTime * state.pixelsPerSecond, h = 10;
+ canvas.width = w; canvas.height = h;
+ canvas.style.width = `${w}px`; canvas.style.height = `${h}px`;
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, w, h);
if (loads.length === 0) return;
+
const barWidth = resolution * state.pixelsPerSecond;
- for (let i = 0; i < loads.length; i++) {
- const load = loads[i];
- const hasConflict = conflicts[i];
- const normalizedLoad = Math.min(load / 8, 1.0);
- let r, g, b, a = 0.7;
- if (hasConflict) {
- r = 200; g = 100; b = 90;
- } else if (normalizedLoad < 0.5) {
- const t = normalizedLoad * 2;
- r = Math.floor(120 + t * 50); g = Math.floor(180 + t * 20); b = 140;
- } else {
- const t = (normalizedLoad - 0.5) * 2;
- r = Math.floor(170 + t * 30); g = Math.floor(200 - t * 50); b = 140;
- }
- const x = i * barWidth;
- ctx.fillStyle = load === 0 ? 'rgba(0, 0, 0, 0.3)' : `rgba(${r}, ${g}, ${b}, ${a})`;
- ctx.fillRect(x, 0, barWidth, canvasHeight);
- }
+ loads.forEach((load, i) => {
+ if (load === 0) return;
+ const n = Math.min(load / 8, 1.0);
+ let r, g, b;
+ if (conflicts[i]) { r = 200; g = 100; b = 90; }
+ else if (n < 0.5) { const t = n * 2; r = 120 + t * 50; g = 180 + t * 20; b = 140; }
+ else { const t = (n - 0.5) * 2; r = 170 + t * 30; g = 200 - t * 50; b = 140; }
+ ctx.fillStyle = `rgba(${r|0}, ${g|0}, ${b|0}, 0.7)`;
+ ctx.fillRect(i * barWidth, 0, barWidth, h);
+ });
}
function clearAudio() {
@@ -502,18 +490,13 @@
function updatePlaybackPosition() {
if (!state.isPlaying) return;
const elapsed = state.audioContext.currentTime - state.playbackStartTime;
- const currentTime = state.playbackOffset + elapsed, currentBeats = currentTime * state.bpm / 60.0;
+ const currentTime = state.playbackOffset + elapsed;
+ const currentBeats = timeToBeats(currentTime);
dom.playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`;
const indicatorX = currentBeats * state.pixelsPerSecond;
- dom.playbackIndicator.style.left = `${indicatorX}px`;
- dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
- const viewportWidth = dom.timelineContent.clientWidth;
- const targetScrollX = indicatorX - viewportWidth * 0.4;
- const currentScrollX = dom.timelineContent.scrollLeft;
- const scrollDiff = targetScrollX - currentScrollX;
- if (Math.abs(scrollDiff) > 5) {
- dom.timelineContent.scrollLeft += scrollDiff * 0.1;
- }
+ dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
+ const scrollDiff = indicatorX - dom.timelineContent.clientWidth * 0.4 - dom.timelineContent.scrollLeft;
+ if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollLeft += scrollDiff * 0.1;
expandSequenceAtTime(currentBeats);
state.animationFrameId = requestAnimationFrame(updatePlaybackPosition);
}
@@ -600,43 +583,33 @@
seqDiv.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); seq._collapsed = !seq._collapsed; renderTimeline(); });
dom.timeline.appendChild(seqDiv);
if (!seq._collapsed) {
- // Known post-process effects (from seq_compiler.cc)
- const postProcessEffects = new Set(['FadeEffect', 'FlashEffect', 'GaussianBlurEffect', 'SolarizeEffect', 'VignetteEffect', 'ChromaAberrationEffect', 'DistortEffect', 'ThemeModulationEffect', 'CNNEffect', 'CNNv2Effect']);
- // Detect conflicts: multiple post-process effects with same priority (matches C++ logic)
- const conflicts = new Set();
- const priorityGroups = {};
- seq.effects.forEach((effect, idx) => {
- if (postProcessEffects.has(effect.className)) {
- if (!priorityGroups[effect.priority]) priorityGroups[effect.priority] = [];
- priorityGroups[effect.priority].push(idx);
- }
- });
- for (const priority in priorityGroups) {
- const group = priorityGroups[priority];
- if (group.length > 1) {
- for (const idx of group) conflicts.add(idx);
- }
- }
+ const conflicts = detectConflicts(seq);
seq.effects.forEach((effect, effectIndex) => {
- const effectDiv = document.createElement('div'); effectDiv.className = 'effect';
+ const effectDiv = document.createElement('div');
+ effectDiv.className = 'effect';
if (conflicts.has(effectIndex)) effectDiv.classList.add('conflict');
- effectDiv.dataset.seqIndex = seqIndex; effectDiv.dataset.effectIndex = effectIndex;
- const effectStart = (seq.startTime + effect.startTime) * state.pixelsPerSecond;
- const effectWidth = (effect.endTime - effect.startTime) * state.pixelsPerSecond;
- effectDiv.style.left = `${effectStart}px`; effectDiv.style.top = `${seq._yPosition + 20 + effectIndex * 30}px`;
- effectDiv.style.width = `${effectWidth}px`; effectDiv.style.height = '26px';
- const startBeat = effect.startTime.toFixed(1), endBeat = effect.endTime.toFixed(1);
- const startSec = (effect.startTime * 60.0 / state.bpm).toFixed(1), endSec = (effect.endTime * 60.0 / state.bpm).toFixed(1);
- const timeDisplay = state.showBeats ? `${startBeat}-${endBeat}b (${startSec}-${endSec}s)` : `${startSec}-${endSec}s (${startBeat}-${endBeat}b)`;
+ Object.assign(effectDiv.dataset, { seqIndex, effectIndex });
+ Object.assign(effectDiv.style, {
+ left: `${(seq.startTime + effect.startTime) * state.pixelsPerSecond}px`,
+ top: `${seq._yPosition + 20 + effectIndex * 30}px`,
+ width: `${(effect.endTime - effect.startTime) * state.pixelsPerSecond}px`,
+ height: '26px'
+ });
effectDiv.innerHTML = `<div class="effect-handle left"></div><small>${effect.className}</small><div class="effect-handle right"></div>`;
- const conflictWarning = conflicts.has(effectIndex) ? '\n⚠️ CONFLICT: Multiple post-process effects share priority ' + effect.priority : '';
- effectDiv.title = `${effect.className}\n${timeDisplay}\nPriority: ${effect.priority}${conflictWarning}\n${effect.args || '(no args)'}`;
- if (state.selectedItem && state.selectedItem.type === 'effect' && state.selectedItem.seqIndex === seqIndex && state.selectedItem.effectIndex === effectIndex) effectDiv.classList.add('selected');
- const leftHandle = effectDiv.querySelector('.effect-handle.left');
- const rightHandle = effectDiv.querySelector('.effect-handle.right');
- leftHandle.addEventListener('mousedown', e => { e.stopPropagation(); startHandleDrag(e, 'left', seqIndex, effectIndex); });
- rightHandle.addEventListener('mousedown', e => { e.stopPropagation(); startHandleDrag(e, 'right', seqIndex, effectIndex); });
- effectDiv.addEventListener('mousedown', e => { if (!e.target.classList.contains('effect-handle')) { e.stopPropagation(); startDrag(e, 'effect', seqIndex, effectIndex); } });
+ const conflictWarning = conflicts.has(effectIndex) ?
+ `\n⚠️ CONFLICT: Multiple post-process effects share priority ${effect.priority}` : '';
+ effectDiv.title = `${effect.className}\n${beatRange(effect.startTime, effect.endTime)}\nPriority: ${effect.priority}${conflictWarning}\n${effect.args || '(no args)'}`;
+ if (state.selectedItem?.type === 'effect' && state.selectedItem.seqIndex === seqIndex && state.selectedItem.effectIndex === effectIndex)
+ effectDiv.classList.add('selected');
+ effectDiv.querySelector('.effect-handle.left').addEventListener('mousedown', e => {
+ e.stopPropagation(); startHandleDrag(e, 'left', seqIndex, effectIndex);
+ });
+ effectDiv.querySelector('.effect-handle.right').addEventListener('mousedown', e => {
+ e.stopPropagation(); startHandleDrag(e, 'right', seqIndex, effectIndex);
+ });
+ effectDiv.addEventListener('mousedown', e => {
+ if (!e.target.classList.contains('effect-handle')) { e.stopPropagation(); startDrag(e, 'effect', seqIndex, effectIndex); }
+ });
effectDiv.addEventListener('click', e => { e.stopPropagation(); selectItem('effect', seqIndex, effectIndex); });
dom.timeline.appendChild(effectDiv);
});
@@ -813,10 +786,8 @@
function updateStats() {
const effectCount = state.sequences.reduce((sum, seq) => sum + seq.effects.length, 0);
- const maxTime = state.sequences.reduce((max, seq) => {
- const seqMax = seq.effects.reduce((m, e) => Math.max(m, seq.startTime + e.endTime), seq.startTime);
- return Math.max(max, seqMax);
- }, 0);
+ const maxTime = Math.max(0, ...state.sequences.flatMap(seq =>
+ seq.effects.map(e => seq.startTime + e.endTime).concat(seq.startTime)));
dom.stats.innerHTML = `📊 Sequences: ${state.sequences.length} | 🎬 Effects: ${effectCount} | ⏱️ Duration: ${maxTime.toFixed(2)}s`;
}