diff --git a/FEATURES.md b/FEATURES.md index d0ead12..6879e59 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -20,9 +20,9 @@ Features are grouped by priority. Check off items as they are implemented. - [x] [#007] **Speed adjustment (4th zone type)** — add speed zones as the fourth editable timeline/transcript zone type (after cut, mute, gain), allowing slow/fast playback per range or globally. Backend: `ffmpeg -filter:v setpts` + `atempo`. Common use case: slightly speed up boring sections. -- [ ] [#008] **Cut preview** — before committing a delete, play what the audio will sound like with that section removed (pre-listen across the edit point). Pure frontend using Web Audio API — splice the AudioBuffer and play the join. +- [x] [#008] **Cut preview** — before committing a delete, play what the audio will sound like with that section removed (pre-listen across the edit point). Pure frontend using Web Audio API — splice the AudioBuffer and play the join. -- [ ] [#009] **Timeline shows output length** — deleted regions should visually collapse (or show as narrow gaps) so the user sees the *output* duration, not just the source duration. +- [x] [#009] **Timeline shows output length** — deleted regions should visually collapse (or show as narrow gaps) so the user sees the *output* duration, not just the source duration. --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35e5bbc..a041d4c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | null; export default function App() { const { + projectFilePath, videoPath, exportedAudioPath, words, @@ -48,6 +49,7 @@ export default function App() { isTranscribing, transcriptionStatus, loadVideo, + setProjectFilePath, setBackendUrl, setTranscription, setTranscriptionModel, @@ -179,6 +181,7 @@ export default function App() { if (!projectPath) return; const content = await window.electronAPI!.readFile(projectPath); const data = JSON.parse(content); + setProjectFilePath(projectPath); loadProjectFromData(data); setProjectName(projectPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null); } catch (err) { @@ -188,14 +191,13 @@ export default function App() { }); }; - const handleSaveProject = async (): Promise => { + const writeProjectToPath = async (path: string): Promise => { try { - const savePath = await window.electronAPI!.saveProject(); - if (!savePath) return false; const data = useEditorStore.getState().saveProject(); - const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`; - await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2)); - setProjectName(path.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null); + const resolvedPath = path.endsWith('.aive') ? path : `${path}.aive`; + await window.electronAPI!.writeFile(resolvedPath, JSON.stringify(data, null, 2)); + setProjectFilePath(resolvedPath); + setProjectName(resolvedPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null); if (projectSignature) { setLastSavedSignature(projectSignature); } @@ -207,11 +209,26 @@ export default function App() { } }; + const handleSaveProjectAs = async (): Promise => { + const savePath = await window.electronAPI!.saveProject(); + if (!savePath) return false; + return writeProjectToPath(savePath); + }; + + const handleSaveProject = async (): Promise => { + if (!projectFilePath) { + return handleSaveProjectAs(); + } + return writeProjectToPath(projectFilePath); + }; + const handleOpenFile = async () => { await runGuarded(async () => { const path = await window.electronAPI!.openFile(); if (path) { setLastSavedSignature(null); + setProjectFilePath(null); + setProjectName(null); loadVideo(path); await transcribeVideo(path); } @@ -223,6 +240,8 @@ export default function App() { useEditorStore.getState().reset(); setLastSavedSignature(null); setActivePanel(null); + setProjectFilePath(null); + setProjectName(null); setCutMode(false); setMuteMode(false); setGainMode(false); @@ -458,6 +477,12 @@ export default function App() { onClick={handleSaveProject} disabled={words.length === 0} /> + } + label="Save As" + onClick={handleSaveProjectAs} + disabled={words.length === 0} + /> } label="Load" diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx index 46c5c52..934aaee 100644 --- a/frontend/src/components/WaveformTimeline.tsx +++ b/frontend/src/components/WaveformTimeline.tsx @@ -1,8 +1,9 @@ -import { useRef, useEffect, useCallback, useState } from 'react'; +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; @@ -103,6 +104,117 @@ function pickInterval(pxPerSec: number): { major: number; minor: number } { 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, @@ -165,6 +277,12 @@ export default function WaveformTimeline({ 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); @@ -261,9 +379,10 @@ export default function WaveformTimeline({ 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) / dur; + const pxPerSec = (width * zoom) / timelineDur; const sampleRate = waveformData.sampleRate; const channelData = waveformData.samples; @@ -323,8 +442,8 @@ export default function WaveformTimeline({ // Draw cut ranges (red overlays) for (const range of showCutZones ? cutRanges : []) { - const x1 = (range.start - scroll) * pxPerSec; - const x2 = (range.end - scroll) * pxPerSec; + 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)'; @@ -348,8 +467,8 @@ export default function WaveformTimeline({ // Draw mute ranges (blue overlays) for (const range of showMuteZones ? muteRanges : []) { - const x1 = (range.start - scroll) * pxPerSec; - const x2 = (range.end - scroll) * pxPerSec; + 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)'; @@ -373,8 +492,8 @@ export default function WaveformTimeline({ // Draw gain ranges (amber overlays) for (const range of showGainZones ? gainRanges : []) { - const x1 = (range.start - scroll) * pxPerSec; - const x2 = (range.end - scroll) * pxPerSec; + 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)'; @@ -397,8 +516,8 @@ export default function WaveformTimeline({ // Draw speed ranges (emerald overlays) for (const range of showSpeedZones ? speedRanges : []) { - const x1 = (range.start - scroll) * pxPerSec; - const x2 = (range.end - scroll) * pxPerSec; + 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)'; @@ -432,8 +551,8 @@ export default function WaveformTimeline({ // Draw selection overlay (when in zone mode) if ((cutMode || muteMode || gainMode || speedMode) && selectionStart !== null && selectionEnd !== null) { - const x1 = (Math.min(selectionStart, selectionEnd) - scroll) * pxPerSec; - const x2 = (Math.max(selectionStart, selectionEnd) - scroll) * pxPerSec; + 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 @@ -457,8 +576,8 @@ export default function WaveformTimeline({ ctx.lineWidth = 1; for (let x = 0; x < width; x++) { - const tStart = scroll + x / pxPerSec; - const tEnd = scroll + (x + 1) / pxPerSec; + 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; @@ -486,10 +605,12 @@ export default function WaveformTimeline({ gainMode, speedMode, selectedZone, + displayDuration, showCutZones, showMuteZones, showGainZones, showSpeedZones, + timelineSegments, ]); // Keep the ref in sync with the latest drawStaticWaveform closure @@ -515,6 +636,7 @@ export default function WaveformTimeline({ 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(); @@ -529,17 +651,18 @@ export default function WaveformTimeline({ ctx.clearRect(0, 0, width, height); if (dur > 0 && video) { - const pxPerSec = (width * zoomRef.current) / dur; - let px = (video.currentTime - scrollSecsRef.current) * pxPerSec; + 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, dur - visibleSecs); - scrollSecsRef.current = Math.max(0, Math.min(maxScroll, video.currentTime - visibleSecs / 2)); + const maxScroll = Math.max(0, timelineDur - visibleSecs); + scrollSecsRef.current = Math.max(0, Math.min(maxScroll, displayTime - visibleSecs / 2)); drawStaticWaveformRef.current(); - px = (video.currentTime - scrollSecsRef.current) * pxPerSec; + px = (displayTime - scrollSecsRef.current) * pxPerSec; } ctx.beginPath(); @@ -555,7 +678,7 @@ export default function WaveformTimeline({ rafRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafRef.current); - }, [videoUrl]); + }, [videoUrl, displayDuration, timelineSegments]); useEffect(() => { const observer = new ResizeObserver(() => { @@ -572,28 +695,29 @@ export default function WaveformTimeline({ 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) / dur; + 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) / dur; + const pxPerSecAfter = (width * zoomRef.current) / timelineDur; scrollSecsRef.current = timeCursor - mouseX / pxPerSecAfter; } else { // Scroll horizontally - const pxPerSec = (width * zoomRef.current) / dur; + const pxPerSec = (width * zoomRef.current) / timelineDur; scrollSecsRef.current += (e.deltaY || e.deltaX) / pxPerSec * 1.5; } // Clamp scroll - const pxPerSec = (width * zoomRef.current) / dur; - const maxScroll = Math.max(0, dur - width / pxPerSec); + 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(); - }, [drawStaticWaveform]); + }, [displayDuration, drawStaticWaveform]); const seekToClientX = useCallback((clientX: number) => { const canvas = headCanvasRef.current; @@ -601,12 +725,14 @@ export default function WaveformTimeline({ if (!canvas || !dur) return; const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left; - const pxPerSec = (rect.width * zoomRef.current) / dur; - const newTime = Math.max(0, Math.min(dur, scrollSecsRef.current + x / pxPerSec)); + 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; - }, [setCurrentTime]); + }, [displayDuration, setCurrentTime, timelineSegments]); const clientXToTime = useCallback((clientX: number): number => { const canvas = headCanvasRef.current; @@ -614,9 +740,11 @@ export default function WaveformTimeline({ if (!canvas || !dur) return 0; const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left; - const pxPerSec = (rect.width * zoomRef.current) / dur; - return Math.max(0, Math.min(dur, scrollSecsRef.current + x / pxPerSec)); - }, []); + 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; @@ -626,7 +754,8 @@ export default function WaveformTimeline({ const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left; const y = clientY - rect.top; - const pxPerSec = (rect.width * zoomRef.current) / dur; + 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; @@ -638,8 +767,8 @@ export default function WaveformTimeline({ // Check cut ranges for (const range of showCutZones ? cutRanges : []) { - const rangeX1 = (range.start - scroll) * pxPerSec; - const rangeX2 = (range.end - scroll) * pxPerSec; + 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) { @@ -680,8 +809,8 @@ export default function WaveformTimeline({ // Check mute ranges for (const range of showMuteZones ? muteRanges : []) { - const rangeX1 = (range.start - scroll) * pxPerSec; - const rangeX2 = (range.end - scroll) * pxPerSec; + 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) { @@ -722,8 +851,8 @@ export default function WaveformTimeline({ // Check gain ranges for (const range of showGainZones ? gainRanges : []) { - const rangeX1 = (range.start - scroll) * pxPerSec; - const rangeX2 = (range.end - scroll) * pxPerSec; + 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) { @@ -757,8 +886,8 @@ export default function WaveformTimeline({ // Check speed ranges for (const range of showSpeedZones ? speedRanges : []) { - const rangeX1 = (range.start - scroll) * pxPerSec; - const rangeX2 = (range.end - scroll) * pxPerSec; + 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) { @@ -791,7 +920,7 @@ export default function WaveformTimeline({ } return null; - }, [cutRanges, muteRanges, gainRanges, speedRanges, selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]); + }, [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 diff --git a/frontend/src/components/ZoneEditor.tsx b/frontend/src/components/ZoneEditor.tsx index efeff0a..548db04 100644 --- a/frontend/src/components/ZoneEditor.tsx +++ b/frontend/src/components/ZoneEditor.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEditorStore } from '../store/editorStore'; import { Trash2, Scissors, Volume2, SlidersHorizontal, Gauge, Play } from 'lucide-react'; export default function ZoneEditor() { const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain' | 'speed'>('all'); + const [focusedZone, setFocusedZone] = useState<{ type: 'cut' | 'mute' | 'gain' | 'speed'; id: string } | null>(null); const previewFrameRef = useRef(null); const { @@ -68,7 +69,10 @@ export default function ZoneEditor() { const renderPreviewButton = (start: number, end: number, accentClass: string) => (