diff options
Diffstat (limited to 'tools/timeline_editor/index.html')
| -rw-r--r-- | tools/timeline_editor/index.html | 377 |
1 files changed, 274 insertions, 103 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index 21bedd1..eca7b97 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -4,6 +4,7 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Timeline Editor - timeline.seq</title> + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' fill='%231e1e1e'/><rect x='10' y='30' width='15' height='40' fill='%234ec9b0'/><rect x='30' y='20' width='15' height='60' fill='%234ec9b0'/><rect x='50' y='35' width='15' height='30' fill='%234ec9b0'/><rect x='70' y='15' width='15' height='70' fill='%234ec9b0'/></svg>"> <style> :root { --bg-dark: #1e1e1e; @@ -45,7 +46,8 @@ .sticky-header { position: sticky; top: 0; background: var(--bg-medium); z-index: 100; padding: 20px 20px 10px 20px; border-bottom: 2px solid var(--bg-light); flex-shrink: 0; } .waveform-container { position: relative; height: 80px; overflow: hidden; background: rgba(0, 0, 0, 0.3); border-radius: var(--radius); cursor: crosshair; } - #waveformCanvas { position: absolute; left: 0; top: 0; height: 80px; display: block; } + #cpuLoadCanvas { position: absolute; left: 0; bottom: 0; height: 10px; display: block; z-index: 1; } + #waveformCanvas { position: absolute; left: 0; top: 0; height: 80px; display: block; z-index: 2; } .playback-indicator { position: absolute; top: 0; left: 0; width: 2px; background: var(--accent-red); box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); pointer-events: none; z-index: 90; display: block; } @@ -66,7 +68,7 @@ 100% { box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); border-color: var(--accent-blue); } } - .sequence-header { position: absolute; top: 0; left: 0; right: 0; padding: 8px; z-index: 5; cursor: pointer; user-select: none; } + .sequence-header { position: absolute; top: 0; left: 0; right: 0; padding: 8px; z-index: 5; cursor: move; user-select: none; } .sequence-header-name { font-size: 14px; font-weight: bold; color: #ffffff; } .sequence:not(.collapsed) .sequence-header-name { display: none; } .sequence-name { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9), -1px -1px 4px rgba(0, 0, 0, 0.7); pointer-events: none; white-space: nowrap; opacity: 1; transition: opacity 0.3s ease; z-index: 10; } @@ -75,6 +77,8 @@ .effect { position: absolute; background: #3a3d41; border: 1px solid var(--border-color); border-radius: 3px; padding: 4px 8px; cursor: move; font-size: 11px; transition: box-shadow 0.2s; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .effect:hover { box-shadow: 0 0 8px rgba(133, 133, 133, 0.5); background: #45484d; } .effect.selected { border-color: var(--accent-orange); box-shadow: 0 0 8px rgba(206, 145, 120, 0.5); } + .effect.conflict { background: #4a1d1d; border-color: var(--accent-red); box-shadow: 0 0 8px rgba(244, 135, 113, 0.6); } + .effect.conflict:hover { background: #5a2424; } .effect-handle { position: absolute; top: 0; width: 6px; height: 100%; background: rgba(78, 201, 176, 0.8); cursor: ew-resize; display: none; z-index: 10; } .effect.selected .effect-handle { display: block; } .effect-handle.left { left: 0; border-radius: 3px 0 0 3px; } @@ -111,6 +115,7 @@ <label class="file-label">šµ Load Audio (WAV)<input type="file" id="audioInput" accept=".wav"></label> <button id="clearAudioBtn" disabled>ā Clear Audio</button> <button id="addSequenceBtn" disabled>ā Add Sequence</button> + <button id="addEffectBtn" disabled>⨠Add Effect</button> <button id="deleteBtn" disabled>šļø Delete Selected</button> <button id="reorderBtn" disabled>š Re-order by Time</button> </div> @@ -124,6 +129,17 @@ <label class="checkbox-label" style="margin-left: 20px"> <input type="checkbox" id="showBeatsCheckbox" checked>Show Beats </label> + <label style="margin-left: 20px">Quantize: + <select id="quantizeSelect"> + <option value="0">Off</option> + <option value="32">1/32</option> + <option value="16">1/16</option> + <option value="8">1/8</option> + <option value="4">1/4</option> + <option value="2">1/2</option> + <option value="1" selected>1 beat</option> + </select> + </label> <div id="playbackControls" style="display: none; margin-left: 20px; gap: 10px; align-items: center;"> <span id="playbackTime">0.00s (0.00b)</span> <button id="playPauseBtn">ā¶ Play</button> @@ -134,9 +150,10 @@ <div class="timeline-container"> <div class="sticky-header"> - <div class="waveform-container" id="waveformContainer" style="display: none;"> - <div class="playback-indicator" id="waveformPlaybackIndicator"></div> + <div class="waveform-container" id="waveformContainer"> + <canvas id="cpuLoadCanvas"></canvas> <canvas id="waveformCanvas"></canvas> + <div class="playback-indicator" id="waveformPlaybackIndicator"></div> </div> <div class="time-markers" id="timeMarkers"></div> </div> @@ -160,14 +177,19 @@ </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, - showBeats: true, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 }, - lastActiveSeqIndex: -1, isDraggingHandle: false, handleType: null, + showBeats: true, quantizeUnit: 1, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 }, + lastActiveSeqIndex: -1, isDraggingHandle: false, handleType: null, handleDragOffset: 0, audioBuffer: null, audioDuration: 0, audioSource: null, audioContext: null, isPlaying: false, playbackStartTime: 0, playbackOffset: 0, animationFrameId: null, - lastExpandedSeqIndex: -1 + lastExpandedSeqIndex: -1, dragMoved: false }; // DOM @@ -180,7 +202,9 @@ clearAudioBtn: document.getElementById('clearAudioBtn'), waveformCanvas: document.getElementById('waveformCanvas'), waveformContainer: document.getElementById('waveformContainer'), + cpuLoadCanvas: document.getElementById('cpuLoadCanvas'), addSequenceBtn: document.getElementById('addSequenceBtn'), + addEffectBtn: document.getElementById('addEffectBtn'), deleteBtn: document.getElementById('deleteBtn'), reorderBtn: document.getElementById('reorderBtn'), propertiesPanel: document.getElementById('propertiesPanel'), @@ -198,7 +222,8 @@ panelCollapseBtn: document.getElementById('panelCollapseBtn'), bpmSlider: document.getElementById('bpmSlider'), currentBPM: document.getElementById('currentBPM'), - showBeatsCheckbox: document.getElementById('showBeatsCheckbox') + showBeatsCheckbox: document.getElementById('showBeatsCheckbox'), + quantizeSelect: document.getElementById('quantizeSelect') }; // Parser @@ -252,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'; @@ -279,7 +327,6 @@ state.audioBuffer = await state.audioContext.decodeAudioData(arrayBuffer); state.audioDuration = state.audioBuffer.duration; renderWaveform(); - dom.waveformContainer.style.display = 'block'; dom.playbackControls.style.display = 'flex'; dom.clearAudioBtn.disabled = false; showMessage(`Audio loaded: ${state.audioDuration.toFixed(2)}s`, 'success'); @@ -292,38 +339,123 @@ 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 = 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 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 + 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 + state.sequences.forEach(seq => { + const priorityGroups = {}; + seq.effects.forEach(eff => { + if (POST_PROCESS_EFFECTS.has(eff.className)) { + (priorityGroups[eff.priority] ??= []).push(eff); + } + }); + Object.values(priorityGroups).forEach(group => { + if (group.length > 1) group.forEach(eff => markConflict(seq, eff)); + }); + }); + + // Detect cross-sequence conflicts + const timeGroups = {}; + 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 }; + } + + function renderCPULoad() { + const canvas = dom.cpuLoadCanvas, ctx = canvas.getContext('2d'); + const { maxTime, loads, conflicts, resolution } = computeCPULoad(); + 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; + 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() { stopPlayback(); state.audioBuffer = null; state.audioDuration = 0; - dom.waveformContainer.style.display = 'none'; dom.playbackControls.style.display = 'none'; - dom.clearAudioBtn.disabled = true; renderTimeline(); showMessage('Audio cleared', 'success'); + dom.playbackControls.style.display = 'none'; + dom.clearAudioBtn.disabled = true; + const ctx = dom.waveformCanvas.getContext('2d'); + ctx.clearRect(0, 0, dom.waveformCanvas.width, dom.waveformCanvas.height); + renderTimeline(); showMessage('Audio cleared', 'success'); } async function startPlayback() { @@ -358,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); } @@ -395,6 +522,7 @@ // Render function renderTimeline() { + renderCPULoad(); dom.timeline.innerHTML = ''; document.getElementById('timeMarkers').innerHTML = ''; let maxTime = 60; for (const seq of state.sequences) { @@ -442,7 +570,6 @@ const headerName = document.createElement('span'); headerName.className = 'sequence-header-name'; headerName.textContent = seq.name || `Sequence ${seqIndex + 1}`; seqHeaderDiv.appendChild(headerName); - seqHeaderDiv.addEventListener('mousedown', e => e.stopPropagation()); seqHeaderDiv.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); seq._collapsed = !seq._collapsed; renderTimeline(); }); seqDiv.appendChild(seqHeaderDiv); const seqNameDiv = document.createElement('div'); seqNameDiv.className = 'sequence-name'; @@ -453,26 +580,36 @@ seqDiv.addEventListener('mouseleave', () => seqDiv.classList.remove('hovered')); seqDiv.addEventListener('mousedown', e => startDrag(e, 'sequence', seqIndex)); seqDiv.addEventListener('click', e => { e.stopPropagation(); selectItem('sequence', seqIndex); }); + seqDiv.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); seq._collapsed = !seq._collapsed; renderTimeline(); }); dom.timeline.appendChild(seqDiv); if (!seq._collapsed) { + const conflicts = detectConflicts(seq); seq.effects.forEach((effect, effectIndex) => { - const effectDiv = document.createElement('div'); effectDiv.className = 'effect'; - 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)`; + const effectDiv = document.createElement('div'); + effectDiv.className = 'effect'; + if (conflicts.has(effectIndex)) effectDiv.classList.add('conflict'); + 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>`; - effectDiv.title = `${effect.className}\n${timeDisplay}\nPriority: ${effect.priority}\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); }); @@ -485,26 +622,27 @@ // Drag function startDrag(e, type, seqIndex, effectIndex = null) { - e.preventDefault(); state.isDragging = true; - const timelineRect = dom.timeline.getBoundingClientRect(); + state.isDragging = true; + state.dragMoved = false; + const containerRect = dom.timelineContent.getBoundingClientRect(); const currentLeft = parseFloat(e.currentTarget.style.left) || 0; - state.dragOffset.x = e.clientX - timelineRect.left - currentLeft; + state.dragOffset.x = e.clientX - containerRect.left + dom.timelineContent.scrollLeft - currentLeft; state.dragOffset.y = e.clientY - e.currentTarget.getBoundingClientRect().top; state.selectedItem = { type, index: seqIndex, seqIndex, effectIndex }; - renderTimeline(); updateProperties(); document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); } function onDrag(e) { if (!state.isDragging || !state.selectedItem) return; - const timelineRect = dom.timeline.getBoundingClientRect(); - let newTime = Math.max(0, (e.clientX - timelineRect.left - state.dragOffset.x) / state.pixelsPerSecond); - if (state.showBeats) newTime = Math.round(newTime); - if (state.selectedItem.type === 'sequence') state.sequences[state.selectedItem.index].startTime = Math.round(newTime * 100) / 100; + state.dragMoved = true; + const containerRect = dom.timelineContent.getBoundingClientRect(); + let newTime = Math.max(0, (e.clientX - containerRect.left + dom.timelineContent.scrollLeft - state.dragOffset.x) / state.pixelsPerSecond); + if (state.quantizeUnit > 0) newTime = Math.round(newTime * state.quantizeUnit) / state.quantizeUnit; + if (state.selectedItem.type === 'sequence') state.sequences[state.selectedItem.index].startTime = newTime; else if (state.selectedItem.type === 'effect') { const seq = state.sequences[state.selectedItem.seqIndex], effect = seq.effects[state.selectedItem.effectIndex]; const duration = effect.endTime - effect.startTime, relativeTime = newTime - seq.startTime; - effect.startTime = Math.round(relativeTime * 100) / 100; effect.endTime = effect.startTime + duration; + effect.startTime = relativeTime; effect.endTime = effect.startTime + duration; } renderTimeline(); updateProperties(); } @@ -512,35 +650,46 @@ function stopDrag() { state.isDragging = false; document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag); + if (state.dragMoved) { + renderTimeline(); updateProperties(); + } } function startHandleDrag(e, type, seqIndex, effectIndex) { e.preventDefault(); state.isDraggingHandle = true; state.handleType = type; state.selectedItem = { type: 'effect', seqIndex, effectIndex, index: seqIndex }; - renderTimeline(); updateProperties(); + const seq = state.sequences[seqIndex], effect = seq.effects[effectIndex]; + const containerRect = dom.timelineContent.getBoundingClientRect(); + const mouseTimeBeats = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerSecond; + const handleTimeBeats = seq.startTime + (type === 'left' ? effect.startTime : effect.endTime); + state.handleDragOffset = handleTimeBeats - mouseTimeBeats; document.addEventListener('mousemove', onHandleDrag); document.addEventListener('mouseup', stopHandleDrag); } function onHandleDrag(e) { if (!state.isDraggingHandle || !state.selectedItem) return; - const timelineRect = dom.timeline.getBoundingClientRect(); - let newTime = Math.max(0, (e.clientX - timelineRect.left) / state.pixelsPerSecond); - if (state.showBeats) newTime = Math.round(newTime); + const containerRect = dom.timelineContent.getBoundingClientRect(); + let newTime = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerSecond + state.handleDragOffset; + newTime = Math.max(0, newTime); + if (state.quantizeUnit > 0) newTime = Math.round(newTime * state.quantizeUnit) / state.quantizeUnit; const seq = state.sequences[state.selectedItem.seqIndex], effect = seq.effects[state.selectedItem.effectIndex]; const relativeTime = newTime - seq.startTime; - if (state.handleType === 'left') effect.startTime = Math.min(Math.round(relativeTime * 100) / 100, effect.endTime - 0.1); - else if (state.handleType === 'right') effect.endTime = Math.max(effect.startTime + 0.1, Math.round(relativeTime * 100) / 100); + if (state.handleType === 'left') effect.startTime = Math.min(relativeTime, effect.endTime - 0.1); + else if (state.handleType === 'right') effect.endTime = Math.max(effect.startTime + 0.1, relativeTime); renderTimeline(); updateProperties(); } function stopHandleDrag() { state.isDraggingHandle = false; state.handleType = null; document.removeEventListener('mousemove', onHandleDrag); document.removeEventListener('mouseup', stopHandleDrag); + renderTimeline(); updateProperties(); } function selectItem(type, seqIndex, effectIndex = null) { state.selectedItem = { type, index: seqIndex, seqIndex, effectIndex }; - renderTimeline(); updateProperties(); dom.deleteBtn.disabled = false; + renderTimeline(); updateProperties(); + dom.deleteBtn.disabled = false; + dom.addEffectBtn.disabled = type !== 'sequence'; } // Properties @@ -550,11 +699,13 @@ if (state.selectedItem.type === 'sequence') { const seq = state.sequences[state.selectedItem.index]; dom.propertiesContent.innerHTML = ` - <div class="property-group"><label>Name</label><input type="text" id="propName" value="${seq.name || ''}" placeholder="Sequence name"></div> - <div class="property-group"><label>Start Time (seconds)</label><input type="number" id="propStartTime" value="${seq.startTime}" step="0.1" min="0"></div> + <div class="property-group"><label>Name</label><input type="text" id="propName" value="${seq.name || ''}" placeholder="Sequence name" inputmode="text"></div> + <div class="property-group"><label>Start Time (beats)</label><input type="number" id="propStartTime" value="${seq.startTime}" step="0.1" min="0"></div> + <div class="property-group"><button id="propDeleteBtn" style="width: 100%; background: var(--accent-red);">šļø Delete Sequence</button></div> `; document.getElementById('propName').addEventListener('input', applyProperties); document.getElementById('propStartTime').addEventListener('input', applyProperties); + document.getElementById('propDeleteBtn').addEventListener('click', () => dom.deleteBtn.click()); } else if (state.selectedItem.type === 'effect') { const effect = state.sequences[state.selectedItem.seqIndex].effects[state.selectedItem.effectIndex]; const effects = state.sequences[state.selectedItem.seqIndex].effects; @@ -572,6 +723,7 @@ </div> <button id="togglePriorityBtn" style="width: 100%;">${samePriority ? 'ā Same as Above (=)' : 'Increment (+)'}</button> </div> + <div class="property-group"><button id="propDeleteBtn" style="width: 100%; background: var(--accent-red);">šļø Delete Effect</button></div> `; document.getElementById('propClassName').addEventListener('input', applyProperties); document.getElementById('propStartTime').addEventListener('input', applyProperties); @@ -580,6 +732,7 @@ document.getElementById('moveUpBtn').addEventListener('click', moveEffectUp); document.getElementById('moveDownBtn').addEventListener('click', moveEffectDown); document.getElementById('togglePriorityBtn').addEventListener('click', toggleSamePriority); + document.getElementById('propDeleteBtn').addEventListener('click', () => dom.deleteBtn.click()); } } @@ -633,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`; } @@ -700,16 +851,17 @@ dom.waveformContainer.addEventListener('click', async e => { if (!state.audioBuffer) return; const rect = dom.waveformContainer.getBoundingClientRect(); - const clickX = e.clientX - rect.left + dom.timelineContent.scrollLeft; - const clickTime = (clickX / state.pixelsPerSecond) * 60.0 / state.bpm; + const canvasOffset = parseFloat(dom.waveformCanvas.style.left) || 0; + const clickX = e.clientX - rect.left - canvasOffset; + const clickBeats = clickX / state.pixelsPerSecond; + const clickTime = beatsToTime(clickBeats); const wasPlaying = state.isPlaying; if (wasPlaying) stopPlayback(false); state.playbackOffset = Math.max(0, Math.min(clickTime, state.audioDuration)); - const clickBeats = state.playbackOffset * state.bpm / 60.0; - dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${clickBeats.toFixed(2)}b)`; - const indicatorX = clickBeats * state.pixelsPerSecond; - dom.playbackIndicator.style.left = `${indicatorX}px`; - dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; + const pausedBeats = timeToBeats(state.playbackOffset); + dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; + const indicatorX = pausedBeats * state.pixelsPerSecond; + dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; if (wasPlaying) await startPlayback(); }); @@ -718,11 +870,19 @@ renderTimeline(); showMessage('New sequence added', 'success'); }); + dom.addEffectBtn.addEventListener('click', () => { + if (!state.selectedItem || state.selectedItem.type !== 'sequence') return; + const seq = state.sequences[state.selectedItem.index]; + seq.effects.push({ type: 'effect', className: 'Effect', startTime: 0, endTime: 10, priority: 0, priorityModifier: '+', args: '' }); + seq._collapsed = false; + renderTimeline(); showMessage('New effect added', 'success'); + }); + dom.deleteBtn.addEventListener('click', () => { if (!state.selectedItem) return; if (state.selectedItem.type === 'sequence') state.sequences.splice(state.selectedItem.index, 1); else if (state.selectedItem.type === 'effect') state.sequences[state.selectedItem.seqIndex].effects.splice(state.selectedItem.effectIndex, 1); - state.selectedItem = null; dom.deleteBtn.disabled = true; renderTimeline(); updateProperties(); + state.selectedItem = null; dom.deleteBtn.disabled = true; dom.addEffectBtn.disabled = true; renderTimeline(); updateProperties(); showMessage('Item deleted', 'success'); }); @@ -749,35 +909,46 @@ }); dom.showBeatsCheckbox.addEventListener('change', e => { state.showBeats = e.target.checked; renderTimeline(); }); + dom.quantizeSelect.addEventListener('change', e => { state.quantizeUnit = parseFloat(e.target.value); }); dom.panelToggle.addEventListener('click', () => { dom.propertiesPanel.classList.add('collapsed'); dom.panelCollapseBtn.classList.add('visible'); dom.panelToggle.textContent = 'ā² Expand'; }); dom.panelCollapseBtn.addEventListener('click', () => { dom.propertiesPanel.classList.remove('collapsed'); dom.panelCollapseBtn.classList.remove('visible'); dom.panelToggle.textContent = 'ā¼ Collapse'; }); - dom.timeline.addEventListener('click', () => { state.selectedItem = null; dom.deleteBtn.disabled = true; renderTimeline(); updateProperties(); }); + dom.timeline.addEventListener('click', () => { state.selectedItem = null; dom.deleteBtn.disabled = true; dom.addEffectBtn.disabled = true; renderTimeline(); updateProperties(); }); dom.timeline.addEventListener('dblclick', async e => { if (e.target !== dom.timeline) return; - const timelineRect = dom.timeline.getBoundingClientRect(); - const clickX = e.clientX - timelineRect.left + dom.timelineContent.scrollLeft; - const clickBeats = clickX / state.pixelsPerSecond, clickTime = clickBeats * 60.0 / state.bpm; + const containerRect = dom.timelineContent.getBoundingClientRect(); + const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft; + const clickBeats = clickX / state.pixelsPerSecond; + const clickTime = beatsToTime(clickBeats); if (state.audioBuffer) { const wasPlaying = state.isPlaying; if (wasPlaying) stopPlayback(false); state.playbackOffset = Math.max(0, Math.min(clickTime, state.audioDuration)); - const pausedBeats = state.playbackOffset * state.bpm / 60.0; + const pausedBeats = timeToBeats(state.playbackOffset); dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; const indicatorX = pausedBeats * state.pixelsPerSecond; - dom.playbackIndicator.style.left = `${indicatorX}px`; dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; + dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; if (wasPlaying) await startPlayback(); showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success'); } }); - document.addEventListener('keydown', e => { if (e.code === 'Space' && state.audioBuffer) { e.preventDefault(); dom.playPauseBtn.click(); } }); + document.addEventListener('keydown', e => { + if (e.code === 'Space' && state.audioBuffer) { e.preventDefault(); dom.playPauseBtn.click(); } + // Quantize hotkeys: 0=Off, 1=1beat, 2=1/2, 3=1/4, 4=1/8, 5=1/16, 6=1/32 + const quantizeMap = { '0': '0', '1': '1', '2': '2', '3': '4', '4': '8', '5': '16', '6': '32' }; + if (quantizeMap[e.key]) { + state.quantizeUnit = parseFloat(quantizeMap[e.key]); + dom.quantizeSelect.value = quantizeMap[e.key]; + e.preventDefault(); + } + }); dom.timelineContent.addEventListener('scroll', () => { - if (dom.waveformCanvas) { - dom.waveformCanvas.style.left = `-${dom.timelineContent.scrollLeft}px`; - dom.waveformPlaybackIndicator.style.transform = `translateX(-${dom.timelineContent.scrollLeft}px)`; - } + const scrollLeft = dom.timelineContent.scrollLeft; + dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`; + dom.waveformCanvas.style.left = `-${scrollLeft}px`; + dom.waveformPlaybackIndicator.style.transform = `translateX(-${scrollLeft}px)`; }); dom.timelineContent.addEventListener('wheel', e => { |
