diff options
| -rw-r--r-- | tools/timeline_editor/README.md | 14 | ||||
| -rw-r--r-- | tools/timeline_editor/index.html | 62 | ||||
| -rw-r--r-- | workspaces/test/timeline.seq.backup | 8 |
3 files changed, 53 insertions, 31 deletions
diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md index 4fcb2f4..cc13a41 100644 --- a/tools/timeline_editor/README.md +++ b/tools/timeline_editor/README.md @@ -14,7 +14,7 @@ Interactive web-based editor for `timeline.seq` files. - ⚙️ Stack-order based priority system - 🔍 Zoom (10%-200%) with mouse wheel + Ctrl/Cmd - 🎵 Audio waveform visualization (aligned to beats) -- 🎼 Snap-to-beat mode (enabled by default) +- 🎼 Quantize grid (Off, 1/32, 1/16, 1/8, 1/4, 1/2, 1 beat) - 🎛️ BPM slider (60-200 BPM) - 🔄 Re-order sequences by time - 🗑️ Delete sequences/effects @@ -37,10 +37,11 @@ Interactive web-based editor for `timeline.seq` files. - Watch sequences auto-expand/collapse during playback - Red playback indicators on both timeline and waveform show current position 5. **Edit:** - - Drag sequences/effects to reposition - - Double-click sequence header to collapse/expand + - Drag sequences/effects to reposition (works when collapsed or expanded) + - Double-click anywhere on sequence to collapse/expand - Click item to edit properties in side panel - Drag effect handles to resize + - **Quantize:** Use dropdown or hotkeys (0-6) to snap to grid 6. **Zoom:** Ctrl/Cmd + mouse wheel (zooms at cursor position) 7. **Save:** Click "💾 Save timeline.seq" @@ -102,7 +103,9 @@ open "tools/timeline_editor/index.html?seq=../../workspaces/main/timeline.seq" ## Keyboard Shortcuts - **Spacebar**: Play/pause audio playback +- **0-6**: Quantize grid (0=Off, 1=1beat, 2=1/2, 3=1/4, 4=1/8, 5=1/16, 6=1/32) - **Double-click timeline**: Seek to position (continues playing if active) +- **Double-click sequence**: Collapse/expand - **Ctrl/Cmd + Wheel**: Zoom in/out at cursor position ## Technical Notes @@ -113,10 +116,11 @@ open "tools/timeline_editor/index.html?seq=../../workspaces/main/timeline.seq" - BPM used for seconds conversion (tooltips, audio waveform alignment) - Priority determines render order (higher = on top) - Collapsed sequences show 35px title bar, expanded show full effect stack -- Time markers show beats by default (4-beat/bar increments) +- **Show Beats** toggle: Switch time markers between beats and seconds +- Time markers show 4-beat/bar increments (beats) or 1s increments (seconds) - **Waveform and time markers are sticky** at top during scroll/zoom - Vertical grid lines aid alignment -- Snap-to-beat enabled by default for musical alignment +- **Quantize grid**: Independent snap control (works in both beat and second display modes) - **Auto-expand/collapse**: Active sequence expands during playback, previous collapses - **Auto-scroll**: Timeline follows playback indicator (keeps it in middle third of viewport) - **Dual playback indicators**: Red bars on both timeline and waveform (synchronized) diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index 21bedd1..45c9f1f 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -66,7 +66,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; } @@ -124,6 +124,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> @@ -163,11 +174,11 @@ // State const state = { sequences: [], currentFile: null, selectedItem: null, pixelsPerSecond: 100, - showBeats: true, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 }, + showBeats: true, quantizeUnit: 1, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 }, lastActiveSeqIndex: -1, isDraggingHandle: false, handleType: null, audioBuffer: null, audioDuration: 0, audioSource: null, audioContext: null, isPlaying: false, playbackStartTime: 0, playbackOffset: 0, animationFrameId: null, - lastExpandedSeqIndex: -1 + lastExpandedSeqIndex: -1, dragMoved: false }; // DOM @@ -198,7 +209,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 @@ -442,7 +454,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,6 +464,7 @@ 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) { seq.effects.forEach((effect, effectIndex) => { @@ -485,26 +497,27 @@ // Drag function startDrag(e, type, seqIndex, effectIndex = null) { - e.preventDefault(); state.isDragging = true; + state.isDragging = true; + state.dragMoved = false; const timelineRect = dom.timeline.getBoundingClientRect(); const currentLeft = parseFloat(e.currentTarget.style.left) || 0; - state.dragOffset.x = e.clientX - timelineRect.left - currentLeft; + state.dragOffset.x = e.clientX - timelineRect.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; + state.dragMoved = true; 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; + let newTime = Math.max(0, (e.clientX - timelineRect.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,30 +525,33 @@ 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(); 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); + let newTime = Math.max(0, (e.clientX - timelineRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerSecond); + 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) { @@ -749,6 +765,7 @@ }); 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(); }); @@ -771,7 +788,16 @@ } }); - 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) { diff --git a/workspaces/test/timeline.seq.backup b/workspaces/test/timeline.seq.backup deleted file mode 100644 index 100c7da..0000000 --- a/workspaces/test/timeline.seq.backup +++ /dev/null @@ -1,8 +0,0 @@ -# WORKSPACE: test -# Minimal timeline for audio/visual sync testing -# BPM 120 (set in test_demo.track) - -SEQUENCE 0.0 0 "Main Loop" - EFFECT + FlashEffect 0.0 16.0 - -END_DEMO 32b |
