import { useRef, useEffect, useCallback, useState, useMemo } from 'react'; import { useEditorStore } from '../store/editorStore'; import { AlertTriangle } from 'lucide-react'; const RULER_H = 20; // px reserved at top of canvas for the time ruler const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08; type WaveformData = { samples: Float32Array; sampleRate: number; duration: number; }; function parsePcm16Wav(arrayBuffer: ArrayBuffer): WaveformData { const view = new DataView(arrayBuffer); if (view.byteLength < 44) { throw new Error('WAV file too small'); } const text = (offset: number, length: number) => { let s = ''; for (let i = 0; i < length; i++) { s += String.fromCharCode(view.getUint8(offset + i)); } return s; }; if (text(0, 4) !== 'RIFF' || text(8, 4) !== 'WAVE') { throw new Error('Not a RIFF/WAVE file'); } let fmtOffset = -1; let dataOffset = -1; let dataSize = 0; let offset = 12; while (offset + 8 <= view.byteLength) { const chunkId = text(offset, 4); const chunkSize = view.getUint32(offset + 4, true); const chunkDataStart = offset + 8; if (chunkId === 'fmt ') { fmtOffset = chunkDataStart; } else if (chunkId === 'data') { dataOffset = chunkDataStart; dataSize = chunkSize; break; } offset = chunkDataStart + chunkSize + (chunkSize % 2); } if (fmtOffset < 0 || dataOffset < 0) { throw new Error('Missing WAV fmt/data chunk'); } const audioFormat = view.getUint16(fmtOffset, true); const channels = view.getUint16(fmtOffset + 2, true); const sampleRate = view.getUint32(fmtOffset + 4, true); const bitsPerSample = view.getUint16(fmtOffset + 14, true); if (audioFormat !== 1 || bitsPerSample !== 16) { throw new Error(`Unsupported WAV format (format=${audioFormat}, bits=${bitsPerSample})`); } if (channels < 1) { throw new Error('Invalid channel count in WAV'); } const bytesPerSample = bitsPerSample / 8; const frameCount = Math.floor(dataSize / (channels * bytesPerSample)); const samples = new Float32Array(frameCount); let p = dataOffset; for (let i = 0; i < frameCount; i++) { const sample = view.getInt16(p, true); samples[i] = sample / 32768; p += channels * bytesPerSample; } return { samples, sampleRate, duration: frameCount / sampleRate, }; } function formatTime(secs: number): string { const m = Math.floor(secs / 60); const s = secs % 60; if (m > 0) return `${m}:${String(Math.floor(s)).padStart(2, '0')}.${Math.floor((s % 1) * 10)}`; return `${s.toFixed(1)}s`; } function pickInterval(pxPerSec: number): { major: number; minor: number } { const NICE = [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; let major = NICE[NICE.length - 1]; for (const n of NICE) { if (n * pxPerSec >= 70) { major = n; break; } } let minor = major; for (const n of NICE) { if (n * pxPerSec >= 6 && n < major) { minor = n; } } return { major, minor }; } type TimelineSegment = { kind: 'keep' | 'cut'; sourceStart: number; sourceEnd: number; displayStart: number; displayEnd: number; }; function mergeCutRanges(ranges: Array<{ start: number; end: number }>, maxDuration: number) { const sorted = ranges .map((range) => ({ start: Math.max(0, Math.min(maxDuration, range.start)), end: Math.max(0, Math.min(maxDuration, range.end)), })) .filter((range) => range.end > range.start) .sort((a, b) => a.start - b.start); const merged: Array<{ start: number; end: number }> = []; for (const range of sorted) { const last = merged[merged.length - 1]; if (!last || range.start > last.end) { merged.push({ ...range }); } else { last.end = Math.max(last.end, range.end); } } return merged; } function buildTimelineSegments(sourceDuration: number, cutRanges: Array<{ start: number; end: number }>) { if (sourceDuration <= 0) { return { segments: [] as TimelineSegment[], displayDuration: 0 }; } const mergedCuts = mergeCutRanges(cutRanges, sourceDuration); const segments: TimelineSegment[] = []; let sourceCursor = 0; let displayCursor = 0; for (const cutRange of mergedCuts) { if (cutRange.start > sourceCursor) { const keepDuration = cutRange.start - sourceCursor; segments.push({ kind: 'keep', sourceStart: sourceCursor, sourceEnd: cutRange.start, displayStart: displayCursor, displayEnd: displayCursor + keepDuration, }); displayCursor += keepDuration; } const cutDisplayDuration = Math.min(cutRange.end - cutRange.start, COLLAPSED_CUT_DISPLAY_SECONDS); segments.push({ kind: 'cut', sourceStart: cutRange.start, sourceEnd: cutRange.end, displayStart: displayCursor, displayEnd: displayCursor + cutDisplayDuration, }); displayCursor += cutDisplayDuration; sourceCursor = cutRange.end; } if (sourceCursor < sourceDuration) { const keepDuration = sourceDuration - sourceCursor; segments.push({ kind: 'keep', sourceStart: sourceCursor, sourceEnd: sourceDuration, displayStart: displayCursor, displayEnd: displayCursor + keepDuration, }); displayCursor += keepDuration; } return { segments, displayDuration: displayCursor }; } function sourceToDisplayTime(time: number, segments: TimelineSegment[], sourceDuration: number) { if (segments.length === 0) return Math.max(0, Math.min(sourceDuration, time)); const clampedTime = Math.max(0, Math.min(sourceDuration, time)); for (const segment of segments) { if (clampedTime <= segment.sourceEnd || segment === segments[segments.length - 1]) { const sourceSpan = Math.max(segment.sourceEnd - segment.sourceStart, 1e-6); const displaySpan = segment.displayEnd - segment.displayStart; const ratio = (clampedTime - segment.sourceStart) / sourceSpan; return segment.displayStart + ratio * displaySpan; } } return segments[segments.length - 1].displayEnd; } function displayToSourceTime(time: number, segments: TimelineSegment[], displayDuration: number, sourceDuration: number) { if (segments.length === 0) return Math.max(0, Math.min(sourceDuration, time)); const clampedTime = Math.max(0, Math.min(displayDuration, time)); for (const segment of segments) { if (clampedTime <= segment.displayEnd || segment === segments[segments.length - 1]) { const displaySpan = Math.max(segment.displayEnd - segment.displayStart, 1e-6); const sourceSpan = segment.sourceEnd - segment.sourceStart; const ratio = (clampedTime - segment.displayStart) / displaySpan; return segment.sourceStart + ratio * sourceSpan; } } return sourceDuration; } export default function WaveformTimeline({ cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, }: { cutMode: boolean; muteMode: boolean; gainMode: boolean; gainModeDb: number; speedMode: boolean; speedModeValue: number; }) { const waveCanvasRef = useRef(null); const headCanvasRef = useRef(null); const containerRef = useRef(null); const [audioError, setAudioError] = useState(null); const videoUrl = useEditorStore((s) => s.videoUrl); const videoPath = useEditorStore((s) => s.videoPath); const backendUrl = useEditorStore((s) => s.backendUrl); const duration = useEditorStore((s) => s.duration); const cutRanges = useEditorStore((s) => s.cutRanges); const muteRanges = useEditorStore((s) => s.muteRanges); const gainRanges = useEditorStore((s) => s.gainRanges); const speedRanges = useEditorStore((s) => s.speedRanges); const setCurrentTime = useEditorStore((s) => s.setCurrentTime); const addCutRange = useEditorStore((s) => s.addCutRange); const addMuteRange = useEditorStore((s) => s.addMuteRange); const addGainRange = useEditorStore((s) => s.addGainRange); const addSpeedRange = useEditorStore((s) => s.addSpeedRange); const updateCutRange = useEditorStore((s) => s.updateCutRange); const updateMuteRange = useEditorStore((s) => s.updateMuteRange); const updateGainRangeBounds = useEditorStore((s) => s.updateGainRangeBounds); const updateSpeedRangeBounds = useEditorStore((s) => s.updateSpeedRangeBounds); const removeCutRange = useEditorStore((s) => s.removeCutRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const removeGainRange = useEditorStore((s) => s.removeGainRange); const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange); const waveformDataRef = useRef(null); const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in const scrollSecsRef = useRef(0); // seconds scrolled from left const rafRef = useRef(0); // Ref so the RAF loop can call drawStaticWaveform without a stale closure const drawStaticWaveformRef = useRef<() => void>(() => {}); const isDraggingRef = useRef(false); const [isDragging, setIsDragging] = useState(false); const selectionStartRef = useRef(null); const selectionEndRef = useRef(null); const [selectionStart, setSelectionStart] = useState(null); const [selectionEnd, setSelectionEnd] = useState(null); const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain' | 'speed', id: string} | null>(null); const [hoverCursor, setHoverCursor] = useState('crosshair'); const editingZoneRef = useRef<{type: 'cut' | 'mute' | 'gain' | 'speed', id: string, edge: 'start' | 'end' | 'move'} | null>(null); const [showCutZones, setShowCutZones] = useState(true); const [showMuteZones, setShowMuteZones] = useState(true); const [showGainZones, setShowGainZones] = useState(true); const [showSpeedZones, setShowSpeedZones] = useState(true); const sourceDuration = duration || waveformDataRef.current?.duration || 0; const { segments: timelineSegments, displayDuration } = useMemo( () => buildTimelineSegments(sourceDuration, cutRanges), [sourceDuration, cutRanges], ); useEffect(() => { if (!videoUrl || !videoPath) return; setAudioError(null); let cancelled = false; const controller = new AbortController(); const loadAudio = async () => { const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; try { const waveformUrl = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath!)}`; console.log('[WaveformTimeline] req=', requestId, 'backendUrl=', backendUrl, 'videoPath=', videoPath); console.log('[WaveformTimeline] req=', requestId, 'fetching=', waveformUrl); const startedAt = performance.now(); const response = await fetch(waveformUrl, { signal: controller.signal }); if (cancelled) return; const elapsedMs = Math.round(performance.now() - startedAt); if (!response.ok) { const body = await response.text().catch(() => ''); console.error( `[WaveformTimeline] req=${requestId} fetch failed — HTTP ${response.status} ${response.statusText}`, { url: waveformUrl, decodedPath: videoPath, elapsedMs, body, } ); throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const contentType = response.headers.get('content-type') ?? 'unknown'; const contentLength = response.headers.get('content-length'); console.log( `[WaveformTimeline] req=${requestId} fetch ok — content-type: ${contentType}, size: ${contentLength ?? 'unknown'} bytes, elapsed: ${elapsedMs}ms` ); const arrayBuffer = await response.arrayBuffer(); if (cancelled) return; console.log(`[WaveformTimeline] req=${requestId} arrayBuffer size: ${arrayBuffer.byteLength} bytes`); if (arrayBuffer.byteLength === 0) { throw new Error('Server returned an empty file'); } const waveformData = parsePcm16Wav(arrayBuffer); console.log( `[WaveformTimeline] req=${requestId} parsed wav ok — duration: ${waveformData.duration.toFixed(2)}s, ` + `sampleRate: ${waveformData.sampleRate}Hz, samples: ${waveformData.samples.length}` ); if (cancelled) return; waveformDataRef.current = waveformData; drawStaticWaveformRef.current(); } catch (err) { if (cancelled || (err instanceof DOMException && err.name === 'AbortError')) { console.log('[WaveformTimeline] req=', requestId, 'aborted/cancelled'); return; } console.error('[WaveformTimeline] waveform load failed', { requestId, error: err, videoPath, backendUrl, encodedPath: encodeURIComponent(videoPath ?? ''), }); const waveformUrl2 = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath ?? '')}`; setAudioError(`Waveform unavailable — ${err instanceof Error ? err.message : 'audio could not be decoded'} [URL: ${waveformUrl2}]`); } }; loadAudio(); return () => { cancelled = true; controller.abort(); }; }, [videoUrl, videoPath, backendUrl]); const drawStaticWaveform = useCallback(() => { const canvas = waveCanvasRef.current; const waveformData = waveformDataRef.current; if (!canvas || !waveformData) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); const width = rect.width; const height = rect.height; const dur = waveformData.duration; const timelineDur = displayDuration || dur; const zoom = zoomRef.current; const scroll = scrollSecsRef.current; const pxPerSec = (width * zoom) / timelineDur; const sampleRate = waveformData.sampleRate; const channelData = waveformData.samples; ctx.clearRect(0, 0, width, height); // --- Ruler background --- ctx.fillStyle = '#13141f'; ctx.fillRect(0, 0, width, RULER_H); // Separator line ctx.strokeStyle = '#2a2d3e'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, RULER_H); ctx.lineTo(width, RULER_H); ctx.stroke(); // --- Ruler ticks & labels --- const { major, minor } = pickInterval(pxPerSec); const visibleDur = width / pxPerSec; // Minor ticks const minorStart = Math.floor(scroll / minor) * minor; ctx.strokeStyle = '#3a3d52'; ctx.lineWidth = 1; for (let t = minorStart; t <= scroll + visibleDur + minor; t = Math.round((t + minor) * 1e6) / 1e6) { const x = (t - scroll) * pxPerSec; if (x < 0 || x > width) continue; ctx.beginPath(); ctx.moveTo(x, RULER_H); ctx.lineTo(x, RULER_H * 0.45); ctx.stroke(); } // Major ticks + labels const majorStart = Math.floor(scroll / major) * major; ctx.lineWidth = 1; ctx.font = `9px "JetBrains Mono", "Courier New", monospace`; ctx.textBaseline = 'top'; for (let t = majorStart; t <= scroll + visibleDur + major; t = Math.round((t + major) * 1e6) / 1e6) { const x = (t - scroll) * pxPerSec; if (x < -50 || x > width + 50) continue; ctx.strokeStyle = '#4a4f6a'; ctx.beginPath(); ctx.moveTo(x, RULER_H); ctx.lineTo(x, 0); ctx.stroke(); if (x >= 2 && x < width - 2) { ctx.fillStyle = '#6b7280'; ctx.fillText(formatTime(t), x + 3, 2); } } // --- Waveform --- const waveTop = RULER_H + 1; const waveH = height - waveTop; // Draw cut ranges (red overlays) for (const range of showCutZones ? cutRanges : []) { const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec; const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec; const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id; ctx.fillStyle = isSelected ? 'rgba(239, 68, 68, 0.5)' : 'rgba(239, 68, 68, 0.3)'; ctx.fillRect(x1, waveTop, x2 - x1, waveH); if (isSelected) { ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.strokeRect(x1, waveTop, x2 - x1, waveH); // Draw resize handles ctx.fillStyle = '#ef4444'; ctx.beginPath(); ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.fill(); ctx.beginPath(); ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.fill(); } } // Draw mute ranges (blue overlays) for (const range of showMuteZones ? muteRanges : []) { const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec; const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec; const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id; ctx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.3)'; ctx.fillRect(x1, waveTop, x2 - x1, waveH); if (isSelected) { ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; ctx.strokeRect(x1, waveTop, x2 - x1, waveH); // Draw resize handles ctx.fillStyle = '#3b82f6'; ctx.beginPath(); ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.fill(); ctx.beginPath(); ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.fill(); } } // Draw gain ranges (amber overlays) for (const range of showGainZones ? gainRanges : []) { const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec; const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec; const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id; ctx.fillStyle = isSelected ? 'rgba(245, 158, 11, 0.55)' : 'rgba(245, 158, 11, 0.35)'; ctx.fillRect(x1, waveTop, x2 - x1, waveH); if (isSelected) { ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 2; ctx.strokeRect(x1, waveTop, x2 - x1, waveH); ctx.fillStyle = '#f59e0b'; ctx.beginPath(); ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.fill(); ctx.beginPath(); ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.fill(); } } // Draw speed ranges (emerald overlays) for (const range of showSpeedZones ? speedRanges : []) { const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec; const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec; const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id; ctx.fillStyle = isSelected ? 'rgba(16, 185, 129, 0.55)' : 'rgba(16, 185, 129, 0.35)'; ctx.fillRect(x1, waveTop, x2 - x1, waveH); if (isSelected) { ctx.strokeStyle = '#10b981'; ctx.lineWidth = 2; ctx.strokeRect(x1, waveTop, x2 - x1, waveH); ctx.fillStyle = '#10b981'; ctx.beginPath(); ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.fill(); ctx.beginPath(); ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.fill(); } const centerX = (x1 + x2) / 2; ctx.fillStyle = '#d1fae5'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; if (centerX > 12 && centerX < width - 12) { ctx.fillText(`${range.speed.toFixed(2)}x`, centerX, waveTop + 4); } ctx.textAlign = 'start'; ctx.textBaseline = 'alphabetic'; } // Draw selection overlay (when in zone mode) if ((cutMode || muteMode || gainMode || speedMode) && selectionStart !== null && selectionEnd !== null) { const x1 = (sourceToDisplayTime(Math.min(selectionStart, selectionEnd), timelineSegments, dur) - scroll) * pxPerSec; const x2 = (sourceToDisplayTime(Math.max(selectionStart, selectionEnd), timelineSegments, dur) - scroll) * pxPerSec; const fillColor = cutMode ? 'rgba(239, 68, 68, 0.5)' : muteMode ? 'rgba(59, 130, 246, 0.5)' : gainMode ? 'rgba(245, 158, 11, 0.5)' : 'rgba(16, 185, 129, 0.5)'; const strokeColor = cutMode ? '#ef4444' : muteMode ? '#3b82f6' : gainMode ? '#f59e0b' : '#10b981'; ctx.fillStyle = fillColor; ctx.fillRect(x1, waveTop, x2 - x1, waveH); // Add border ctx.strokeStyle = strokeColor; ctx.lineWidth = 2; ctx.strokeRect(x1, waveTop, x2 - x1, waveH); } const mid = waveTop + waveH / 2; ctx.beginPath(); ctx.strokeStyle = '#4a4d5e'; ctx.lineWidth = 1; for (let x = 0; x < width; x++) { const tStart = displayToSourceTime(scroll + x / pxPerSec, timelineSegments, timelineDur, dur); const tEnd = displayToSourceTime(scroll + (x + 1) / pxPerSec, timelineSegments, timelineDur, dur); const sStart = Math.floor(tStart * sampleRate); const sEnd = Math.min(Math.ceil(tEnd * sampleRate), channelData.length); if (sStart >= channelData.length) break; let min = 0, max = 0; for (let i = sStart; i < sEnd; i++) { if (channelData[i] < min) min = channelData[i]; if (channelData[i] > max) max = channelData[i]; } const amp = (waveH / 2) * 0.9; ctx.moveTo(x, mid + min * amp); ctx.lineTo(x, mid + max * amp); } ctx.stroke(); }, [ cutRanges, muteRanges, gainRanges, speedRanges, selectionStart, selectionEnd, cutMode, muteMode, gainMode, speedMode, selectedZone, displayDuration, showCutZones, showMuteZones, showGainZones, showSpeedZones, timelineSegments, ]); // Keep the ref in sync with the latest drawStaticWaveform closure useEffect(() => { drawStaticWaveformRef.current = drawStaticWaveform; }, [drawStaticWaveform]); // Redraw static layer when cutRanges change useEffect(() => { drawStaticWaveform(); }, [drawStaticWaveform]); // Lightweight RAF loop for playhead only -- reads video.currentTime directly, // never triggers React re-renders useEffect(() => { const headCanvas = headCanvasRef.current; const waveCanvas = waveCanvasRef.current; if (!headCanvas || !waveCanvas) return; const tick = () => { const ctx = headCanvas.getContext('2d'); if (!ctx) { rafRef.current = requestAnimationFrame(tick); return; } const video = document.querySelector('video') as HTMLVideoElement | null; const dur = waveformDataRef.current?.duration ?? 0; const timelineDur = displayDuration || dur; const dpr = window.devicePixelRatio || 1; const rect = headCanvas.getBoundingClientRect(); if (headCanvas.width !== waveCanvas.width || headCanvas.height !== waveCanvas.height) { headCanvas.width = rect.width * dpr; headCanvas.height = rect.height * dpr; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const width = rect.width; const height = rect.height; ctx.clearRect(0, 0, width, height); if (dur > 0 && video) { const pxPerSec = (width * zoomRef.current) / timelineDur; const displayTime = sourceToDisplayTime(video.currentTime, timelineSegments, dur); let px = (displayTime - scrollSecsRef.current) * pxPerSec; // If the playhead is off-screen (e.g. after a seek from the transcript), // scroll so it's centered and redraw the static waveform layer. if (px < 0 || px > width) { const visibleSecs = width / pxPerSec; const maxScroll = Math.max(0, timelineDur - visibleSecs); scrollSecsRef.current = Math.max(0, Math.min(maxScroll, displayTime - visibleSecs / 2)); drawStaticWaveformRef.current(); px = (displayTime - scrollSecsRef.current) * pxPerSec; } ctx.beginPath(); ctx.strokeStyle = '#6366f1'; ctx.lineWidth = 2; ctx.moveTo(px, 0); ctx.lineTo(px, height); ctx.stroke(); } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafRef.current); }, [videoUrl, displayDuration, timelineSegments]); useEffect(() => { const observer = new ResizeObserver(() => { drawStaticWaveform(); }); if (containerRef.current) observer.observe(containerRef.current); return () => observer.disconnect(); }, [drawStaticWaveform]); const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const canvas = waveCanvasRef.current; if (!canvas) return; const dur = waveformDataRef.current?.duration; if (!dur) return; const width = canvas.getBoundingClientRect().width; const timelineDur = displayDuration || dur; if (e.ctrlKey || e.metaKey) { // Zoom around the cursor position const mouseX = e.clientX - canvas.getBoundingClientRect().left; const pxPerSecBefore = (width * zoomRef.current) / timelineDur; const timeCursor = scrollSecsRef.current + mouseX / pxPerSecBefore; const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25; zoomRef.current = Math.max(1, Math.min(100, zoomRef.current * factor)); const pxPerSecAfter = (width * zoomRef.current) / timelineDur; scrollSecsRef.current = timeCursor - mouseX / pxPerSecAfter; } else { // Scroll horizontally const pxPerSec = (width * zoomRef.current) / timelineDur; scrollSecsRef.current += (e.deltaY || e.deltaX) / pxPerSec * 1.5; } // Clamp scroll const pxPerSec = (width * zoomRef.current) / timelineDur; const maxScroll = Math.max(0, timelineDur - width / pxPerSec); scrollSecsRef.current = Math.max(0, Math.min(scrollSecsRef.current, maxScroll)); drawStaticWaveform(); }, [displayDuration, drawStaticWaveform]); const seekToClientX = useCallback((clientX: number) => { const canvas = headCanvasRef.current; const dur = waveformDataRef.current?.duration; if (!canvas || !dur) return; const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left; const timelineDur = displayDuration || dur; const pxPerSec = (rect.width * zoomRef.current) / timelineDur; const displayTime = Math.max(0, Math.min(timelineDur, scrollSecsRef.current + x / pxPerSec)); const newTime = displayToSourceTime(displayTime, timelineSegments, timelineDur, dur); setCurrentTime(newTime); const video = document.querySelector('video') as HTMLVideoElement | null; if (video) video.currentTime = newTime; }, [displayDuration, setCurrentTime, timelineSegments]); const clientXToTime = useCallback((clientX: number): number => { const canvas = headCanvasRef.current; const dur = waveformDataRef.current?.duration; if (!canvas || !dur) return 0; const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left; const timelineDur = displayDuration || dur; const pxPerSec = (rect.width * zoomRef.current) / timelineDur; const displayTime = Math.max(0, Math.min(timelineDur, scrollSecsRef.current + x / pxPerSec)); return displayToSourceTime(displayTime, timelineSegments, timelineDur, dur); }, [displayDuration, timelineSegments]); const getZoneAtPosition = useCallback((clientX: number, clientY: number, forHover: boolean = false) => { const dur = waveformDataRef.current?.duration; const canvas = waveCanvasRef.current; if (!canvas || !dur) return null; const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left; const y = clientY - rect.top; const timelineDur = displayDuration || dur; const pxPerSec = (rect.width * zoomRef.current) / timelineDur; const scroll = scrollSecsRef.current; const waveTop = RULER_H + 1; const waveH = canvas.height - waveTop; // Check if click is in waveform area if (y < waveTop || y > waveTop + waveH) return null; const handleSize = forHover ? 6 : 8; // Smaller hit area for hover, larger for click // Check cut ranges for (const range of showCutZones ? cutRanges : []) { const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec; const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec; const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id; if (forHover && isSelected) { // For hover on selected zones, check edges if (Math.abs(x - rangeX1) <= handleSize) { return { type: 'cut' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize) { return { type: 'cut' as const, id: range.id, edge: 'end' as const }; } } else if (!forHover) { // For click detection, check handles and body if (isSelected) { // For selected zones, allow clicking on edges for resizing if (Math.abs(x - rangeX1) <= handleSize) { return { type: 'cut' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize) { return { type: 'cut' as const, id: range.id, edge: 'end' as const }; } } else { // For unselected zones, check small handle circles // Check start handle if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { return { type: 'cut' as const, id: range.id, edge: 'start' as const }; } // Check end handle if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { return { type: 'cut' as const, id: range.id, edge: 'end' as const }; } } // Check range body if (x >= rangeX1 && x <= rangeX2) { return { type: 'cut' as const, id: range.id, edge: 'move' as const }; } } } // Check mute ranges for (const range of showMuteZones ? muteRanges : []) { const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec; const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec; const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id; if (forHover && isSelected) { // For hover on selected zones, check edges if (Math.abs(x - rangeX1) <= handleSize) { return { type: 'mute' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize) { return { type: 'mute' as const, id: range.id, edge: 'end' as const }; } } else if (!forHover) { // For click detection, check handles and body if (isSelected) { // For selected zones, allow clicking on edges for resizing if (Math.abs(x - rangeX1) <= handleSize) { return { type: 'mute' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize) { return { type: 'mute' as const, id: range.id, edge: 'end' as const }; } } else { // For unselected zones, check small handle circles // Check start handle if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { return { type: 'mute' as const, id: range.id, edge: 'start' as const }; } // Check end handle if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { return { type: 'mute' as const, id: range.id, edge: 'end' as const }; } } // Check range body if (x >= rangeX1 && x <= rangeX2) { return { type: 'mute' as const, id: range.id, edge: 'move' as const }; } } } // Check gain ranges for (const range of showGainZones ? gainRanges : []) { const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec; const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec; const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id; if (forHover && isSelected) { if (Math.abs(x - rangeX1) <= handleSize) { return { type: 'gain' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize) { return { type: 'gain' as const, id: range.id, edge: 'end' as const }; } } else if (!forHover) { if (isSelected) { if (Math.abs(x - rangeX1) <= handleSize) { return { type: 'gain' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize) { return { type: 'gain' as const, id: range.id, edge: 'end' as const }; } } else { if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { return { type: 'gain' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { return { type: 'gain' as const, id: range.id, edge: 'end' as const }; } } if (x >= rangeX1 && x <= rangeX2) { return { type: 'gain' as const, id: range.id, edge: 'move' as const }; } } } // Check speed ranges for (const range of showSpeedZones ? speedRanges : []) { const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec; const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec; const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id; if (forHover && isSelected) { if (Math.abs(x - rangeX1) <= handleSize) { return { type: 'speed' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize) { return { type: 'speed' as const, id: range.id, edge: 'end' as const }; } } else if (!forHover) { if (isSelected) { if (Math.abs(x - rangeX1) <= handleSize) { return { type: 'speed' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize) { return { type: 'speed' as const, id: range.id, edge: 'end' as const }; } } else { if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { return { type: 'speed' as const, id: range.id, edge: 'start' as const }; } if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { return { type: 'speed' as const, id: range.id, edge: 'end' as const }; } } if (x >= rangeX1 && x <= rangeX2) { return { type: 'speed' as const, id: range.id, edge: 'move' as const }; } } } return null; }, [cutRanges, muteRanges, gainRanges, speedRanges, selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones, displayDuration, timelineSegments]); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (isDragging) return; // Don't change cursor while dragging const zoneHit = getZoneAtPosition(e.clientX, e.clientY, true); if (zoneHit && selectedZone && zoneHit.id === selectedZone.id) { if (zoneHit.edge === 'start' || zoneHit.edge === 'end') { setHoverCursor('ew-resize'); return; } } setHoverCursor('crosshair'); }, [isDragging, getZoneAtPosition, selectedZone]); const handleMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); // Check if clicking on a zone const zoneHit = getZoneAtPosition(e.clientX, e.clientY); if (zoneHit) { if (zoneHit.edge === 'move') { setSelectedZone({ type: zoneHit.type, id: zoneHit.id }); } else { setSelectedZone({ type: zoneHit.type, id: zoneHit.id }); editingZoneRef.current = zoneHit; } isDraggingRef.current = true; setIsDragging(true); const startTime = clientXToTime(e.clientX); const originalRange = zoneHit.type === 'cut' ? cutRanges.find(r => r.id === zoneHit.id) : zoneHit.type === 'mute' ? muteRanges.find(r => r.id === zoneHit.id) : zoneHit.type === 'gain' ? gainRanges.find(r => r.id === zoneHit.id) : speedRanges.find(r => r.id === zoneHit.id); if (!originalRange) return; const onMove = (ev: MouseEvent) => { if (!isDraggingRef.current || !editingZoneRef.current) return; const currentTime = clientXToTime(ev.clientX); const delta = currentTime - startTime; const minZoneDuration = 0.05; let newStart = originalRange.start; let newEnd = originalRange.end; if (editingZoneRef.current.edge === 'start') { newStart = Math.max(0, Math.min(originalRange.end - minZoneDuration, originalRange.start + delta)); newEnd = originalRange.end; // Keep end fixed when dragging start } else if (editingZoneRef.current.edge === 'end') { newStart = originalRange.start; // Keep start fixed when dragging end newEnd = Math.min(duration, Math.max(newStart + minZoneDuration, originalRange.end + delta)); } else if (editingZoneRef.current.edge === 'move') { const zoneDuration = originalRange.end - originalRange.start; const maxStart = Math.max(0, duration - zoneDuration); newStart = Math.max(0, Math.min(maxStart, originalRange.start + delta)); newEnd = newStart + zoneDuration; } // Ensure valid range if (newStart < newEnd) { if (editingZoneRef.current.type === 'cut') { updateCutRange(editingZoneRef.current.id, newStart, newEnd); } else if (editingZoneRef.current.type === 'mute') { updateMuteRange(editingZoneRef.current.id, newStart, newEnd); } else if (editingZoneRef.current.type === 'gain') { updateGainRangeBounds(editingZoneRef.current.id, newStart, newEnd); } else { updateSpeedRangeBounds(editingZoneRef.current.id, newStart, newEnd); } } }; const onUp = () => { isDraggingRef.current = false; setIsDragging(false); editingZoneRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return; } // Clear selection if clicking elsewhere setSelectedZone(null); if (cutMode || muteMode || gainMode || speedMode) { // Range selection mode const startTime = clientXToTime(e.clientX); selectionStartRef.current = startTime; selectionEndRef.current = startTime; setSelectionStart(startTime); setSelectionEnd(startTime); isDraggingRef.current = true; setIsDragging(true); const onMove = (ev: MouseEvent) => { if (!isDraggingRef.current) return; const currentTime = clientXToTime(ev.clientX); selectionEndRef.current = currentTime; setSelectionEnd(currentTime); }; const onUp = () => { isDraggingRef.current = false; setIsDragging(false); if (selectionStartRef.current !== null && selectionEndRef.current !== null) { const start = Math.min(selectionStartRef.current, selectionEndRef.current); const end = Math.max(selectionStartRef.current, selectionEndRef.current); const minDuration = 0.01; if (end - start >= minDuration && cutMode) { addCutRange(start, end); } else if (end - start >= minDuration && muteMode) { addMuteRange(start, end); } else if (end - start >= minDuration && gainMode) { addGainRange(start, end, gainModeDb); } else if (end - start >= minDuration && speedMode) { addSpeedRange(start, end, speedModeValue); } } // Reset selection selectionStartRef.current = null; selectionEndRef.current = null; setSelectionStart(null); setSelectionEnd(null); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); } else { // Normal seek mode isDraggingRef.current = true; setIsDragging(true); seekToClientX(e.clientX); const onMove = (ev: MouseEvent) => { if (!isDraggingRef.current) return; seekToClientX(ev.clientX); }; const onUp = () => { isDraggingRef.current = false; setIsDragging(false); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); } }, [cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, clientXToTime, seekToClientX, addCutRange, addMuteRange, addGainRange, addSpeedRange, getZoneAtPosition, cutRanges, muteRanges, gainRanges, speedRanges, duration, updateCutRange, updateMuteRange, updateGainRangeBounds, updateSpeedRangeBounds], ); // Handle keyboard shortcuts for zone editing useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')) { return; } if (e.key === 'Escape') { setSelectedZone(null); editingZoneRef.current = null; } else if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedZone) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (selectedZone.type === 'cut') { removeCutRange(selectedZone.id); } else if (selectedZone.type === 'mute') { removeMuteRange(selectedZone.id); } else if (selectedZone.type === 'gain') { removeGainRange(selectedZone.id); } else { removeSpeedRange(selectedZone.id); } setSelectedZone(null); editingZoneRef.current = null; } } }; // Capture phase ensures zone delete runs before app-level bubble shortcuts. window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); }, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange]); useEffect(() => { if (!selectedZone) return; if (selectedZone.type === 'cut' && !showCutZones) setSelectedZone(null); if (selectedZone.type === 'mute' && !showMuteZones) setSelectedZone(null); if (selectedZone.type === 'gain' && !showGainZones) setSelectedZone(null); if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null); }, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]); if (!videoUrl) { return (
Load a video to see the waveform
); } return (
Timeline {cutMode && Cut mode} {muteMode && Mute mode} {gainMode && Gain mode ({gainModeDb.toFixed(1)} dB)} {speedMode && Speed mode ({speedModeValue.toFixed(2)}x)}
Scroll · Ctrl+Scroll to zoom
{audioError ? (
            {audioError}
          
) : (
)}
); }