diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/timeline_editor/index.html | 311 |
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`; } |
