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/index.html | |
| 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/index.html')
| -rw-r--r-- | tools/timeline_editor/index.html | 317 |
1 files changed, 39 insertions, 278 deletions
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(); |
