diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-15 13:04:49 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-15 13:04:49 +0100 |
| commit | f7227fb28aabd1899832cd769fe72692ea4890e6 (patch) | |
| tree | f78e58e60c2a3ee2dd3747f38e48a0cd4109eba2 /tools/timeline_editor | |
| parent | d040d7e04622d89e9110f699e87f3bc1d7bc385d (diff) | |
refactor(timeline-editor): extract viewport and playback to ES6 modules
Extract zoom/scroll/playback code from monolithic index.html into
separate modules for better code organization:
- timeline-viewport.js: Zoom, scroll sync, indicator positioning (133 lines)
- timeline-playback.js: Audio loading, playback, waveform rendering (303 lines)
- index.html: Reduced from 1093 to 853 lines (-22%)
Requires HTTP server for ES6 module imports. Updated README with usage.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/timeline_editor')
| -rw-r--r-- | tools/timeline_editor/README.md | 13 | ||||
| -rw-r--r-- | tools/timeline_editor/index.html | 317 | ||||
| -rw-r--r-- | tools/timeline_editor/timeline-playback.js | 303 | ||||
| -rw-r--r-- | tools/timeline_editor/timeline-viewport.js | 133 |
4 files changed, 486 insertions, 280 deletions
diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md index 72b5ae0..66e39bd 100644 --- a/tools/timeline_editor/README.md +++ b/tools/timeline_editor/README.md @@ -39,7 +39,12 @@ This helps identify performance hotspots in your timeline. ## Usage -1. **Open:** `open tools/timeline_editor/index.html` or double-click in browser +1. **Open:** Requires HTTP server (ES6 modules): + ```bash + cd tools/timeline_editor + python3 -m http.server 8080 + ``` + Then open: `http://localhost:8080` 2. **Load timeline:** Click "📂 Load timeline.seq" → select `workspaces/main/timeline.seq` 3. **Load audio:** Click "🎵 Load Audio (WAV)" → select audio file 4. **Auto-load via URL:** `index.html?seq=timeline.seq&wav=audio.wav` @@ -125,7 +130,11 @@ open "tools/timeline_editor/index.html?seq=../../workspaces/main/timeline.seq" ## Technical Notes -- Pure HTML/CSS/JavaScript (no dependencies, works offline) +- Modular ES6 structure (requires HTTP server, not file://) + - `index.html` - Main editor and rendering + - `timeline-viewport.js` - Zoom/scroll/indicator control + - `timeline-playback.js` - Audio playback and waveform +- No external dependencies - **Internal representation uses beats** (not seconds) - Sequences have absolute times (beats), effects are relative to parent sequence - BPM used for seconds conversion (tooltips, audio waveform alignment) diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index 775330f..4131782 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -176,16 +176,15 @@ <div class="stats" id="stats"></div> </div> - <script> + <script type="module"> + import { ViewportController } from './timeline-viewport.js'; + import { PlaybackController } from './timeline-playback.js'; + // Constants const POST_PROCESS_EFFECTS = new Set(['FadeEffect', 'FlashEffect', 'GaussianBlurEffect', 'SolarizeEffect', 'VignetteEffect', 'ChromaAberrationEffect', 'DistortEffect', 'ThemeModulationEffect', 'CNNEffect', 'CNNv2Effect']); - const TIMELINE_LEFT_PADDING = 20; - const SCROLL_VIEWPORT_FRACTION = 0.4; - const SMOOTH_SCROLL_SPEED = 0.1; - const VERTICAL_SCROLL_SPEED = 0.3; const SEQUENCE_GAP = 10; const SEQUENCE_DEFAULT_WIDTH = 10; const SEQUENCE_DEFAULT_DURATION = 16; @@ -195,7 +194,6 @@ const SEQUENCE_BOTTOM_PADDING = 5; const EFFECT_SPACING = 30; const EFFECT_HEIGHT = 26; - const WAVEFORM_AMPLITUDE_SCALE = 0.4; // State const state = { @@ -301,6 +299,13 @@ return state.showBeats ? `${s}-${e}b (${ss}-${es}s)` : `${ss}-${es}s (${s}-${e}b)`; }; + // Utilities + function showMessage(text, type) { + if (type === 'error') console.error(text); + dom.messageArea.innerHTML = `<div class="${type}">${text}</div>`; + setTimeout(() => dom.messageArea.innerHTML = '', 3000); + } + function detectConflicts(seq) { const conflicts = new Set(); const priorityGroups = {}; @@ -334,76 +339,8 @@ return output; } - // Audio - async function loadAudioFile(file) { - try { - const arrayBuffer = await file.arrayBuffer(); - if (!state.audioContext) state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); - state.audioBuffer = await state.audioContext.decodeAudioData(arrayBuffer); - state.audioDuration = state.audioBuffer.duration; - renderWaveform(); - dom.playbackControls.style.display = 'flex'; - dom.playbackIndicator.style.display = 'block'; - dom.clearAudioBtn.disabled = false; - dom.replayBtn.disabled = false; - showMessage(`Audio loaded: ${state.audioDuration.toFixed(2)}s`, 'success'); - renderTimeline(); - } catch (err) { - showMessage(`Error loading audio: ${err.message}`, 'error'); - } - } - - function renderWaveform() { - if (!state.audioBuffer) return; - const canvas = dom.waveformCanvas, ctx = canvas.getContext('2d'); - - // Calculate maxTime same as timeline to ensure alignment - let maxTime = 60; - for (const seq of state.sequences) { - maxTime = Math.max(maxTime, seq.startTime + SEQUENCE_DEFAULT_DURATION); - 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 w = maxTime * state.pixelsPerSecond, h = 80; - 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); - - const channelData = state.audioBuffer.getChannelData(0); - const audioBeats = timeToBeats(state.audioDuration); - const audioPixelWidth = audioBeats * state.pixelsPerSecond; - const samplesPerPixel = Math.ceil(channelData.length / audioPixelWidth); - const centerY = h / 2, amplitudeScale = h * WAVEFORM_AMPLITUDE_SCALE; - - ctx.strokeStyle = '#4ec9b0'; ctx.lineWidth = 1; ctx.beginPath(); - for (let x = 0; x < audioPixelWidth; 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 = 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; - 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.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(audioPixelWidth, centerY); ctx.stroke(); - - // Draw beat markers across full maxTime width - ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; - ctx.lineWidth = 1; - for (let beat = 0; beat <= maxTime; beat++) { - const x = beat * state.pixelsPerSecond; - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, h); - ctx.stroke(); - } - } + // Controllers - initialized after DOM setup + let viewportController, playbackController; function computeCPULoad() { if (state.sequences.length === 0) return { maxTime: 60, loads: [], conflicts: [] }; @@ -487,89 +424,6 @@ }); } - function clearAudio() { - stopPlayback(); state.audioBuffer = null; state.audioDuration = 0; state.playbackOffset = 0; - state.playStartPosition = 0; - dom.playbackControls.style.display = 'none'; - dom.playbackIndicator.style.display = 'none'; - dom.clearAudioBtn.disabled = true; - dom.replayBtn.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() { - if (!state.audioBuffer || !state.audioContext) return; - if (state.audioSource) try { state.audioSource.stop(); } catch (e) {} state.audioSource = null; - if (state.audioContext.state === 'suspended') await state.audioContext.resume(); - try { - state.audioSource = state.audioContext.createBufferSource(); - state.audioSource.buffer = state.audioBuffer; - state.audioSource.connect(state.audioContext.destination); - state.audioSource.start(0, state.playbackOffset); - state.playbackStartTime = state.audioContext.currentTime; - state.isPlaying = true; dom.playPauseBtn.textContent = '⏸ Pause'; - updatePlaybackPosition(); - state.audioSource.onended = () => { if (state.isPlaying) stopPlayback(); }; - } catch (e) { - console.error('Failed to start playback:', e); showMessage('Playback failed: ' + e.message, 'error'); - state.audioSource = null; state.isPlaying = false; - } - } - - function stopPlayback(savePosition = true) { - if (state.audioSource) try { state.audioSource.stop(); } catch (e) {} state.audioSource = null; - if (state.animationFrameId) { cancelAnimationFrame(state.animationFrameId); state.animationFrameId = null; } - if (state.isPlaying && savePosition) { - const elapsed = state.audioContext.currentTime - state.playbackStartTime; - state.playbackOffset = Math.min(state.playbackOffset + elapsed, state.audioDuration); - } - state.isPlaying = false; dom.playPauseBtn.textContent = '▶ Play'; - } - - function updatePlaybackPosition() { - if (!state.isPlaying) return; - const elapsed = state.audioContext.currentTime - state.playbackStartTime; - const currentTime = state.playbackOffset + elapsed; - const currentBeats = timeToBeats(currentTime); - dom.playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`; - updateIndicatorPosition(currentBeats, true); - expandSequenceAtTime(currentBeats); - state.animationFrameId = requestAnimationFrame(updatePlaybackPosition); - } - - function expandSequenceAtTime(currentBeats) { - let activeSeqIndex = -1; - for (let i = 0; i < state.sequences.length; i++) { - const seq = state.sequences[i]; - const seqEndBeats = seq.startTime + (seq.effects.length > 0 ? Math.max(...seq.effects.map(e => e.endTime)) : 0); - if (currentBeats >= seq.startTime && currentBeats <= seqEndBeats) { activeSeqIndex = i; break; } - } - if (activeSeqIndex !== state.lastExpandedSeqIndex) { - const seqDivs = dom.timeline.querySelectorAll('.sequence'); - if (state.lastExpandedSeqIndex >= 0 && seqDivs[state.lastExpandedSeqIndex]) { - seqDivs[state.lastExpandedSeqIndex].classList.remove('active-playing'); - } - if (activeSeqIndex >= 0 && seqDivs[activeSeqIndex]) { - seqDivs[activeSeqIndex].classList.add('active-playing'); - } - state.lastExpandedSeqIndex = activeSeqIndex; - } - } - - function updateIndicatorPosition(beats, smoothScroll = false) { - const timelineX = beats * state.pixelsPerSecond; - const scrollLeft = dom.timelineContent.scrollLeft; - dom.playbackIndicator.style.left = `${timelineX - scrollLeft + TIMELINE_LEFT_PADDING}px`; - if (smoothScroll) { - const targetScroll = timelineX - dom.timelineContent.clientWidth * SCROLL_VIEWPORT_FRACTION; - const scrollDiff = targetScroll - scrollLeft; - if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollLeft += scrollDiff * SMOOTH_SCROLL_SPEED; - } - } - // Render function renderTimeline() { renderCPULoad(); @@ -826,13 +680,6 @@ updateProperties(); } - // Utilities - function showMessage(text, type) { - if (type === 'error') console.error(text); - dom.messageArea.innerHTML = `<div class="${type}">${text}</div>`; - setTimeout(() => dom.messageArea.innerHTML = '', 3000); - } - function updateStats() { const effectCount = state.sequences.reduce((sum, seq) => sum + seq.effects.length, 0); const maxTime = Math.max(0, ...state.sequences.flatMap(seq => @@ -853,16 +700,16 @@ state.currentFile = seqURL.split('/').pop(); state.playbackOffset = 0; renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false; - updateIndicatorPosition(0, false); + if (viewportController) viewportController.updateIndicatorPosition(0, false); showMessage(`Loaded ${state.currentFile} from URL`, 'success'); } catch (err) { showMessage(`Error loading seq file: ${err.message}`, 'error'); } } - if (wavURL) { + if (wavURL && playbackController) { try { const response = await fetch(wavURL); if (!response.ok) throw new Error(`HTTP ${response.status}`); const blob = await response.blob(), file = new File([blob], wavURL.split('/').pop(), { type: 'audio/wav' }); - await loadAudioFile(file); + await playbackController.loadAudioFile(file); } catch (err) { showMessage(`Error loading audio file: ${err.message}`, 'error'); } } } @@ -880,7 +727,7 @@ dom.currentBPM.value = state.bpm; dom.bpmSlider.value = state.bpm; state.playbackOffset = 0; renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false; - updateIndicatorPosition(0, false); + if (viewportController) viewportController.updateIndicatorPosition(0, false); showMessage(`Loaded ${state.currentFile} - ${state.sequences.length} sequences`, 'success'); } catch (err) { showMessage(`Error parsing file: ${err.message}`, 'error'); } }; @@ -894,41 +741,7 @@ showMessage('File saved', 'success'); }); - dom.audioInput.addEventListener('change', e => { const file = e.target.files[0]; if (file) loadAudioFile(file); }); - dom.clearAudioBtn.addEventListener('click', () => { clearAudio(); dom.audioInput.value = ''; }); - dom.playPauseBtn.addEventListener('click', async () => { - if (state.isPlaying) stopPlayback(); - else { - if (state.playbackOffset >= state.audioDuration) state.playbackOffset = 0; - state.playStartPosition = state.playbackOffset; - await startPlayback(); - } - }); - - dom.replayBtn.addEventListener('click', async () => { - stopPlayback(false); - state.playbackOffset = state.playStartPosition; - const replayBeats = timeToBeats(state.playbackOffset); - dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${replayBeats.toFixed(2)}b)`; - updateIndicatorPosition(replayBeats, false); - await startPlayback(); - }); - - dom.waveformContainer.addEventListener('click', async e => { - if (!state.audioBuffer) return; - const rect = dom.waveformContainer.getBoundingClientRect(); - 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 pausedBeats = timeToBeats(state.playbackOffset); - dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; - updateIndicatorPosition(pausedBeats, false); - if (wasPlaying) await startPlayback(); - }); + // Audio/playback event handlers - managed by PlaybackController dom.addSequenceBtn.addEventListener('click', () => { state.sequences.push({ type: 'sequence', startTime: 0, priority: 0, effects: [], _collapsed: true }); @@ -963,20 +776,14 @@ showMessage('Sequences re-ordered by start time', 'success'); }); - dom.zoomSlider.addEventListener('input', e => { - state.pixelsPerSecond = parseInt(e.target.value); - dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`; - if (state.audioBuffer) renderWaveform(); - renderTimeline(); - updateIndicatorPosition(timeToBeats(state.playbackOffset), false); - }); + // Zoom handler - managed by ViewportController dom.bpmSlider.addEventListener('input', e => { state.bpm = parseInt(e.target.value); dom.currentBPM.value = state.bpm; - if (state.audioBuffer) renderWaveform(); + if (state.audioBuffer && playbackController) playbackController.renderWaveform(); renderTimeline(); - updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false); }); dom.currentBPM.addEventListener('change', e => { @@ -984,9 +791,9 @@ if (!isNaN(bpm) && bpm >= 60 && bpm <= 200) { state.bpm = bpm; dom.bpmSlider.value = bpm; - if (state.audioBuffer) renderWaveform(); + if (state.audioBuffer && playbackController) playbackController.renderWaveform(); renderTimeline(); - updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false); } else { e.target.value = state.bpm; } @@ -998,22 +805,15 @@ 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; dom.addEffectBtn.disabled = true; renderTimeline(); updateProperties(); }); - dom.timeline.addEventListener('dblclick', async e => { + dom.timeline.addEventListener('dblclick', e => { if (e.target !== dom.timeline) return; + if (!playbackController || !state.audioBuffer) return; const containerRect = dom.timelineContent.getBoundingClientRect(); - const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft - TIMELINE_LEFT_PADDING; + const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft - viewportController.TIMELINE_LEFT_PADDING; 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 = timeToBeats(state.playbackOffset); - dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; - updateIndicatorPosition(pausedBeats, false); - if (wasPlaying) await startPlayback(); - showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success'); - } + const result = playbackController.seekTo(clickBeats, clickTime); + if (result) showMessage(`Seek to ${result.clickTime.toFixed(2)}s (${result.clickBeats.toFixed(2)}b)`, 'success'); }); document.addEventListener('keydown', e => { @@ -1030,60 +830,21 @@ } }); - dom.timelineContent.addEventListener('scroll', () => { - const scrollLeft = dom.timelineContent.scrollLeft; - dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`; - dom.waveformCanvas.style.left = `-${scrollLeft}px`; - document.getElementById('timeMarkers').style.transform = `translateX(-${scrollLeft}px)`; - updateIndicatorPosition(timeToBeats(state.playbackOffset), false); - }); + // Scroll/wheel handlers - managed by ViewportController - const handleWheel = e => { - e.preventDefault(); - if (e.ctrlKey || e.metaKey) { - const rect = dom.timelineContent.getBoundingClientRect(), mouseX = e.clientX - rect.left; - const scrollLeft = dom.timelineContent.scrollLeft, timeUnderCursor = (scrollLeft + mouseX) / state.pixelsPerSecond; - const zoomDelta = e.deltaY > 0 ? -10 : 10; - const newPixelsPerSecond = Math.max(10, Math.min(500, state.pixelsPerSecond + zoomDelta)); - if (newPixelsPerSecond !== state.pixelsPerSecond) { - state.pixelsPerSecond = newPixelsPerSecond; - dom.zoomSlider.value = state.pixelsPerSecond; - dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`; - if (state.audioBuffer) renderWaveform(); - renderTimeline(); - dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX; - updateIndicatorPosition(timeToBeats(state.playbackOffset), false); - } - return; - } - dom.timelineContent.scrollLeft += e.deltaY; - const currentScrollLeft = dom.timelineContent.scrollLeft, viewportWidth = dom.timelineContent.clientWidth; - const slack = (viewportWidth / state.pixelsPerSecond) * 0.1, currentTime = (currentScrollLeft / state.pixelsPerSecond) + slack; - let targetSeqIndex = 0; - for (let i = 0; i < state.sequences.length; i++) { - if (state.sequences[i].startTime <= currentTime) targetSeqIndex = i; else break; - } - if (targetSeqIndex !== state.lastActiveSeqIndex && state.sequences.length > 0) { - state.lastActiveSeqIndex = targetSeqIndex; - const seqDivs = dom.timeline.querySelectorAll('.sequence'); - if (seqDivs[targetSeqIndex]) { - seqDivs[targetSeqIndex].classList.add('active-flash'); - setTimeout(() => seqDivs[targetSeqIndex]?.classList.remove('active-flash'), 600); - } + // Initialize controllers + const renderCallback = (trigger) => { + if (trigger === 'zoom' || trigger === 'zoomWheel') { + if (state.audioBuffer && playbackController) playbackController.renderWaveform(); + renderTimeline(); + if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + } else { + renderTimeline(); } - const targetScrollTop = state.sequences[targetSeqIndex]?._yPosition || 0; - const currentScrollTop = dom.timelineContent.scrollTop, scrollDiff = targetScrollTop - currentScrollTop; - if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollTop += scrollDiff * VERTICAL_SCROLL_SPEED; }; - dom.timelineContent.addEventListener('wheel', handleWheel, { passive: false }); - dom.waveformContainer.addEventListener('wheel', handleWheel, { passive: false }); - - // Prevent wheel events from bubbling up from UI containers - document.querySelector('header').addEventListener('wheel', e => e.stopPropagation()); - dom.propertiesPanel.addEventListener('wheel', e => e.stopPropagation()); - document.querySelector('.zoom-controls').addEventListener('wheel', e => e.stopPropagation()); - document.querySelector('.stats').addEventListener('wheel', e => e.stopPropagation()); + viewportController = new ViewportController(state, dom, renderCallback); + playbackController = new PlaybackController(state, dom, viewportController, renderCallback, showMessage); window.addEventListener('resize', renderTimeline); renderTimeline(); loadFromURLParams(); diff --git a/tools/timeline_editor/timeline-playback.js b/tools/timeline_editor/timeline-playback.js new file mode 100644 index 0000000..1bcdcd0 --- /dev/null +++ b/tools/timeline_editor/timeline-playback.js @@ -0,0 +1,303 @@ +// timeline-playback.js - Audio playback and waveform rendering + +export class PlaybackController { + constructor(state, dom, viewportController, renderCallback, showMessage) { + this.state = state; + this.dom = dom; + this.viewport = viewportController; + this.renderCallback = renderCallback; + this.showMessage = showMessage; + + // Constants + this.WAVEFORM_AMPLITUDE_SCALE = 0.4; + this.SEQUENCE_DEFAULT_DURATION = 16; + + this.init(); + } + + init() { + this.dom.audioInput.addEventListener('change', e => { + const file = e.target.files[0]; + if (file) this.loadAudioFile(file); + }); + + this.dom.clearAudioBtn.addEventListener('click', () => { + this.clearAudio(); + this.dom.audioInput.value = ''; + }); + + this.dom.playPauseBtn.addEventListener('click', async () => { + if (this.state.isPlaying) this.stopPlayback(); + else { + if (this.state.playbackOffset >= this.state.audioDuration) { + this.state.playbackOffset = 0; + } + this.state.playStartPosition = this.state.playbackOffset; + await this.startPlayback(); + } + }); + + this.dom.replayBtn.addEventListener('click', async () => { + this.stopPlayback(false); + this.state.playbackOffset = this.state.playStartPosition; + const replayBeats = this.timeToBeats(this.state.playbackOffset); + this.dom.playbackTime.textContent = `${this.state.playbackOffset.toFixed(2)}s (${replayBeats.toFixed(2)}b)`; + this.viewport.updateIndicatorPosition(replayBeats, false); + await this.startPlayback(); + }); + + this.dom.waveformContainer.addEventListener('click', async e => { + if (!this.state.audioBuffer) return; + const rect = this.dom.waveformContainer.getBoundingClientRect(); + const canvasOffset = parseFloat(this.dom.waveformCanvas.style.left) || 0; + const clickX = e.clientX - rect.left - canvasOffset; + const clickBeats = clickX / this.state.pixelsPerSecond; + const clickTime = this.beatsToTime(clickBeats); + + const wasPlaying = this.state.isPlaying; + if (wasPlaying) this.stopPlayback(false); + this.state.playbackOffset = Math.max(0, Math.min(clickTime, this.state.audioDuration)); + const pausedBeats = this.timeToBeats(this.state.playbackOffset); + this.dom.playbackTime.textContent = `${this.state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; + this.viewport.updateIndicatorPosition(pausedBeats, false); + if (wasPlaying) await this.startPlayback(); + }); + } + + async loadAudioFile(file) { + try { + const arrayBuffer = await file.arrayBuffer(); + if (!this.state.audioContext) { + this.state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } + this.state.audioBuffer = await this.state.audioContext.decodeAudioData(arrayBuffer); + this.state.audioDuration = this.state.audioBuffer.duration; + this.renderWaveform(); + this.dom.playbackControls.style.display = 'flex'; + this.dom.playbackIndicator.style.display = 'block'; + this.dom.clearAudioBtn.disabled = false; + this.dom.replayBtn.disabled = false; + this.showMessage(`Audio loaded: ${this.state.audioDuration.toFixed(2)}s`, 'success'); + this.renderCallback('audioLoaded'); + } catch (err) { + this.showMessage(`Error loading audio: ${err.message}`, 'error'); + } + } + + renderWaveform() { + if (!this.state.audioBuffer) return; + const canvas = this.dom.waveformCanvas; + const ctx = canvas.getContext('2d'); + + // Calculate maxTime same as timeline + let maxTime = 60; + for (const seq of this.state.sequences) { + maxTime = Math.max(maxTime, seq.startTime + this.SEQUENCE_DEFAULT_DURATION); + for (const effect of seq.effects) { + maxTime = Math.max(maxTime, seq.startTime + effect.endTime); + } + } + if (this.state.audioDuration > 0) { + maxTime = Math.max(maxTime, this.state.audioDuration * this.state.bpm / 60.0); + } + + const w = maxTime * this.state.pixelsPerSecond; + const h = 80; + 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); + + const channelData = this.state.audioBuffer.getChannelData(0); + const audioBeats = this.timeToBeats(this.state.audioDuration); + const audioPixelWidth = audioBeats * this.state.pixelsPerSecond; + const samplesPerPixel = Math.ceil(channelData.length / audioPixelWidth); + const centerY = h / 2; + const amplitudeScale = h * this.WAVEFORM_AMPLITUDE_SCALE; + + ctx.strokeStyle = '#4ec9b0'; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let x = 0; x < audioPixelWidth; 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 = start; i < end; i++) { + min = Math.min(min, channelData[i]); + max = Math.max(max, channelData[i]); + } + + const yMin = centerY - min * amplitudeScale; + const yMax = centerY - max * amplitudeScale; + + if (x === 0) ctx.moveTo(x, yMin); + else ctx.lineTo(x, yMin); + ctx.lineTo(x, yMax); + } + ctx.stroke(); + + // Center line + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.beginPath(); + ctx.moveTo(0, centerY); + ctx.lineTo(audioPixelWidth, centerY); + ctx.stroke(); + + // Beat markers + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 1; + for (let beat = 0; beat <= maxTime; beat++) { + const x = beat * this.state.pixelsPerSecond; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + } + + clearAudio() { + this.stopPlayback(); + this.state.audioBuffer = null; + this.state.audioDuration = 0; + this.state.playbackOffset = 0; + this.state.playStartPosition = 0; + + this.dom.playbackControls.style.display = 'none'; + this.dom.playbackIndicator.style.display = 'none'; + this.dom.clearAudioBtn.disabled = true; + this.dom.replayBtn.disabled = true; + + const ctx = this.dom.waveformCanvas.getContext('2d'); + ctx.clearRect(0, 0, this.dom.waveformCanvas.width, this.dom.waveformCanvas.height); + + this.renderCallback('audioClear'); + this.showMessage('Audio cleared', 'success'); + } + + async startPlayback() { + if (!this.state.audioBuffer || !this.state.audioContext) return; + + if (this.state.audioSource) { + try { this.state.audioSource.stop(); } catch (e) {} + this.state.audioSource = null; + } + + if (this.state.audioContext.state === 'suspended') { + await this.state.audioContext.resume(); + } + + try { + this.state.audioSource = this.state.audioContext.createBufferSource(); + this.state.audioSource.buffer = this.state.audioBuffer; + this.state.audioSource.connect(this.state.audioContext.destination); + this.state.audioSource.start(0, this.state.playbackOffset); + this.state.playbackStartTime = this.state.audioContext.currentTime; + this.state.isPlaying = true; + this.dom.playPauseBtn.textContent = '⏸ Pause'; + + this.updatePlaybackPosition(); + + this.state.audioSource.onended = () => { + if (this.state.isPlaying) this.stopPlayback(); + }; + } catch (e) { + console.error('Failed to start playback:', e); + this.showMessage('Playback failed: ' + e.message, 'error'); + this.state.audioSource = null; + this.state.isPlaying = false; + } + } + + stopPlayback(savePosition = true) { + if (this.state.audioSource) { + try { this.state.audioSource.stop(); } catch (e) {} + this.state.audioSource = null; + } + + if (this.state.animationFrameId) { + cancelAnimationFrame(this.state.animationFrameId); + this.state.animationFrameId = null; + } + + if (this.state.isPlaying && savePosition) { + const elapsed = this.state.audioContext.currentTime - this.state.playbackStartTime; + this.state.playbackOffset = Math.min(this.state.playbackOffset + elapsed, this.state.audioDuration); + } + + this.state.isPlaying = false; + this.dom.playPauseBtn.textContent = '▶ Play'; + } + + updatePlaybackPosition() { + if (!this.state.isPlaying) return; + + const elapsed = this.state.audioContext.currentTime - this.state.playbackStartTime; + const currentTime = this.state.playbackOffset + elapsed; + const currentBeats = this.timeToBeats(currentTime); + + this.dom.playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`; + this.viewport.updateIndicatorPosition(currentBeats, true); + this.expandSequenceAtTime(currentBeats); + + this.state.animationFrameId = requestAnimationFrame(() => this.updatePlaybackPosition()); + } + + expandSequenceAtTime(currentBeats) { + let activeSeqIndex = -1; + + for (let i = 0; i < this.state.sequences.length; i++) { + const seq = this.state.sequences[i]; + const seqEndBeats = seq.startTime + (seq.effects.length > 0 ? + Math.max(...seq.effects.map(e => e.endTime)) : 0); + + if (currentBeats >= seq.startTime && currentBeats <= seqEndBeats) { + activeSeqIndex = i; + break; + } + } + + if (activeSeqIndex !== this.state.lastExpandedSeqIndex) { + const seqDivs = this.dom.timeline.querySelectorAll('.sequence'); + + if (this.state.lastExpandedSeqIndex >= 0 && seqDivs[this.state.lastExpandedSeqIndex]) { + seqDivs[this.state.lastExpandedSeqIndex].classList.remove('active-playing'); + } + + if (activeSeqIndex >= 0 && seqDivs[activeSeqIndex]) { + seqDivs[activeSeqIndex].classList.add('active-playing'); + } + + this.state.lastExpandedSeqIndex = activeSeqIndex; + } + } + + seekTo(clickBeats, clickTime) { + if (!this.state.audioBuffer) return; + + const wasPlaying = this.state.isPlaying; + if (wasPlaying) this.stopPlayback(false); + + this.state.playbackOffset = Math.max(0, Math.min(clickTime, this.state.audioDuration)); + const pausedBeats = this.timeToBeats(this.state.playbackOffset); + this.dom.playbackTime.textContent = `${this.state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; + this.viewport.updateIndicatorPosition(pausedBeats, false); + + if (wasPlaying) this.startPlayback(); + + return { clickTime, clickBeats }; + } + + // Helpers + beatsToTime(beats) { + return beats * 60.0 / this.state.bpm; + } + + timeToBeats(seconds) { + return seconds * this.state.bpm / 60.0; + } +} diff --git a/tools/timeline_editor/timeline-viewport.js b/tools/timeline_editor/timeline-viewport.js new file mode 100644 index 0000000..becf8e9 --- /dev/null +++ b/tools/timeline_editor/timeline-viewport.js @@ -0,0 +1,133 @@ +// timeline-viewport.js - Viewport zoom/scroll control + +export class ViewportController { + constructor(state, dom, renderCallback) { + this.state = state; + this.dom = dom; + this.renderCallback = renderCallback; + + // Constants + this.TIMELINE_LEFT_PADDING = 20; + this.SCROLL_VIEWPORT_FRACTION = 0.4; + this.SMOOTH_SCROLL_SPEED = 0.1; + this.VERTICAL_SCROLL_SPEED = 0.3; + + this.init(); + } + + init() { + // Zoom controls + this.dom.zoomSlider.addEventListener('input', e => this.handleZoomSlider(e)); + + // Scroll sync + this.dom.timelineContent.addEventListener('scroll', () => this.handleScroll()); + + // Wheel handling + const wheelHandler = e => this.handleWheel(e); + this.dom.timelineContent.addEventListener('wheel', wheelHandler, { passive: false }); + this.dom.waveformContainer.addEventListener('wheel', wheelHandler, { passive: false }); + + // Prevent wheel bubbling from UI containers + document.querySelector('header').addEventListener('wheel', e => e.stopPropagation()); + this.dom.propertiesPanel.addEventListener('wheel', e => e.stopPropagation()); + document.querySelector('.zoom-controls').addEventListener('wheel', e => e.stopPropagation()); + document.querySelector('.stats').addEventListener('wheel', e => e.stopPropagation()); + } + + handleZoomSlider(e) { + this.state.pixelsPerSecond = parseInt(e.target.value); + this.dom.zoomLevel.textContent = `${this.state.pixelsPerSecond}%`; + this.renderCallback('zoom'); + } + + handleScroll() { + const scrollLeft = this.dom.timelineContent.scrollLeft; + this.dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`; + this.dom.waveformCanvas.style.left = `-${scrollLeft}px`; + document.getElementById('timeMarkers').style.transform = `translateX(-${scrollLeft}px)`; + this.updateIndicatorPosition(this.timeToBeats(this.state.playbackOffset), false); + } + + handleWheel(e) { + e.preventDefault(); + + // Zoom with ctrl/cmd + if (e.ctrlKey || e.metaKey) { + this.handleZoomWheel(e); + return; + } + + // Horizontal scroll + this.dom.timelineContent.scrollLeft += e.deltaY; + + // Auto-scroll to active sequence + this.autoScrollToSequence(); + } + + handleZoomWheel(e) { + const rect = this.dom.timelineContent.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const scrollLeft = this.dom.timelineContent.scrollLeft; + const timeUnderCursor = (scrollLeft + mouseX) / this.state.pixelsPerSecond; + + const zoomDelta = e.deltaY > 0 ? -10 : 10; + const newPixelsPerSecond = Math.max(10, Math.min(500, this.state.pixelsPerSecond + zoomDelta)); + + if (newPixelsPerSecond !== this.state.pixelsPerSecond) { + this.state.pixelsPerSecond = newPixelsPerSecond; + this.dom.zoomSlider.value = this.state.pixelsPerSecond; + this.dom.zoomLevel.textContent = `${this.state.pixelsPerSecond}%`; + this.renderCallback('zoomWheel'); + this.dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX; + this.updateIndicatorPosition(this.timeToBeats(this.state.playbackOffset), false); + } + } + + autoScrollToSequence() { + const currentScrollLeft = this.dom.timelineContent.scrollLeft; + const viewportWidth = this.dom.timelineContent.clientWidth; + const slack = (viewportWidth / this.state.pixelsPerSecond) * 0.1; + const currentTime = (currentScrollLeft / this.state.pixelsPerSecond) + slack; + + let targetSeqIndex = 0; + for (let i = 0; i < this.state.sequences.length; i++) { + if (this.state.sequences[i].startTime <= currentTime) targetSeqIndex = i; + else break; + } + + if (targetSeqIndex !== this.state.lastActiveSeqIndex && this.state.sequences.length > 0) { + this.state.lastActiveSeqIndex = targetSeqIndex; + const seqDivs = this.dom.timeline.querySelectorAll('.sequence'); + if (seqDivs[targetSeqIndex]) { + seqDivs[targetSeqIndex].classList.add('active-flash'); + setTimeout(() => seqDivs[targetSeqIndex]?.classList.remove('active-flash'), 600); + } + } + + const targetScrollTop = this.state.sequences[targetSeqIndex]?._yPosition || 0; + const currentScrollTop = this.dom.timelineContent.scrollTop; + const scrollDiff = targetScrollTop - currentScrollTop; + if (Math.abs(scrollDiff) > 5) { + this.dom.timelineContent.scrollTop += scrollDiff * this.VERTICAL_SCROLL_SPEED; + } + } + + updateIndicatorPosition(beats, smoothScroll = false) { + const timelineX = beats * this.state.pixelsPerSecond; + const scrollLeft = this.dom.timelineContent.scrollLeft; + this.dom.playbackIndicator.style.left = `${timelineX - scrollLeft + this.TIMELINE_LEFT_PADDING}px`; + + if (smoothScroll) { + const targetScroll = timelineX - this.dom.timelineContent.clientWidth * this.SCROLL_VIEWPORT_FRACTION; + const scrollDiff = targetScroll - scrollLeft; + if (Math.abs(scrollDiff) > 5) { + this.dom.timelineContent.scrollLeft += scrollDiff * this.SMOOTH_SCROLL_SPEED; + } + } + } + + // Helper + timeToBeats(seconds) { + return seconds * this.state.bpm / 60.0; + } +} |
