diff --git a/FEATURES.md b/FEATURES.md index c63a818..d0ead12 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -18,7 +18,7 @@ Features are grouped by priority. Check off items as they are implemented. - [x] [#006] **Volume / gain control** — per-selection or global audio gain slider. Every editor has this. Descript users constantly complain it's missing. Backend: `ffmpeg -af volume=Xdb`. -- [ ] [#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. +- [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. diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index d7276f1..42790d6 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -6,7 +6,7 @@ import { Bot, Cloud, Brain, RefreshCw } from 'lucide-react'; export default function SettingsPanel() { const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore(); - const { backendUrl } = useEditorStore(); + const { backendUrl, zonePreviewPaddingSeconds, setZonePreviewPaddingSeconds } = useEditorStore(); const [ollamaModels, setOllamaModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); @@ -37,7 +37,34 @@ export default function SettingsPanel() { return (
-

AI Settings

+

Settings

+ + }> +
+ +
+ setZonePreviewPaddingSeconds(Number(e.target.value) || 0)} + className="flex-1 h-1.5" + /> + setZonePreviewPaddingSeconds(Number(e.target.value) || 0)} + className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent" + /> + s +
+
+
{/* Default provider selector */}
@@ -60,6 +87,8 @@ export default function SettingsPanel() {
+

AI Settings

+ {/* Ollama settings */} ('all'); + const previewFrameRef = useRef(null); const { cutRanges, muteRanges, gainRanges, speedRanges, + duration, + setCurrentTime, + zonePreviewPaddingSeconds, + setZonePreviewPaddingSeconds, globalGainDb, setGlobalGainDb, removeCutRange, @@ -20,6 +25,57 @@ export default function ZoneEditor() { updateSpeedRange, } = useEditorStore(); + const stopPreviewLoop = useCallback(() => { + if (previewFrameRef.current !== null) { + cancelAnimationFrame(previewFrameRef.current); + previewFrameRef.current = null; + } + }, []); + + useEffect(() => stopPreviewLoop, [stopPreviewLoop]); + + const previewZone = useCallback((start: number, end: number) => { + const video = document.querySelector('video'); + if (!(video instanceof HTMLVideoElement)) return; + + stopPreviewLoop(); + + const previewStart = Math.max(0, start - zonePreviewPaddingSeconds); + const maxDuration = Number.isFinite(duration) && duration > 0 ? duration : video.duration; + const previewEnd = Math.min(maxDuration || end + zonePreviewPaddingSeconds, end + zonePreviewPaddingSeconds); + + video.currentTime = previewStart; + setCurrentTime(previewStart); + + const tick = () => { + if (video.paused || video.ended) { + previewFrameRef.current = null; + return; + } + if (video.currentTime >= previewEnd) { + video.pause(); + video.currentTime = previewEnd; + setCurrentTime(previewEnd); + previewFrameRef.current = null; + return; + } + previewFrameRef.current = requestAnimationFrame(tick); + }; + + void video.play(); + previewFrameRef.current = requestAnimationFrame(tick); + }, [duration, setCurrentTime, stopPreviewLoop, zonePreviewPaddingSeconds]); + + const renderPreviewButton = (start: number, end: number, accentClass: string) => ( + + ); + const totalZones = cutRanges.length + muteRanges.length + gainRanges.length + speedRanges.length; const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain' | 'speed') => { @@ -39,13 +95,35 @@ export default function ZoneEditor() {
-

-
- Zone Editor -

-

- Manage all timeline zones ({totalZones} total) -

+
+
+

+ Zone Editor +

+

+ Manage all timeline zones ({totalZones} total) +

+
+
+
+ Preview + before/after +
+
+ setZonePreviewPaddingSeconds(Number(e.target.value) || 0)} + className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded text-xs text-editor-text focus:outline-none focus:border-editor-accent" + title="Preview time before and after each zone" + /> + sec +
+
+
{/* View Mode Toggle */} @@ -130,6 +208,7 @@ export default function ZoneEditor() {
{range.id}
+ {renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}