zone previews
This commit is contained in:
@ -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`.
|
- [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.
|
- [ ] [#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.
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { Bot, Cloud, Brain, RefreshCw } from 'lucide-react';
|
|||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
|
const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
|
||||||
const { backendUrl } = useEditorStore();
|
const { backendUrl, zonePreviewPaddingSeconds, setZonePreviewPaddingSeconds } = useEditorStore();
|
||||||
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||||
const [loadingModels, setLoadingModels] = useState(false);
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
|
|
||||||
@ -37,7 +37,34 @@ export default function SettingsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<h3 className="text-sm font-semibold">AI Settings</h3>
|
<h3 className="text-sm font-semibold">Settings</h3>
|
||||||
|
|
||||||
|
<ProviderSection title="Playback" icon={<RefreshCw className="w-4 h-4" />}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-editor-text-muted">Zone preview padding (seconds before and after)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={0.25}
|
||||||
|
value={zonePreviewPaddingSeconds}
|
||||||
|
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
|
||||||
|
className="flex-1 h-1.5"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={0.25}
|
||||||
|
value={zonePreviewPaddingSeconds}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-editor-text-muted w-6">s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProviderSection>
|
||||||
|
|
||||||
{/* Default provider selector */}
|
{/* Default provider selector */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -60,6 +87,8 @@ export default function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-editor-text-muted">AI Settings</h4>
|
||||||
|
|
||||||
{/* Ollama settings */}
|
{/* Ollama settings */}
|
||||||
<ProviderSection title="Ollama (Local)" icon={providerIcons.ollama}>
|
<ProviderSection title="Ollama (Local)" icon={providerIcons.ollama}>
|
||||||
<InputField
|
<InputField
|
||||||
|
|||||||
@ -1,15 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { Trash2, Scissors, Volume2, SlidersHorizontal, Gauge } from 'lucide-react';
|
import { Trash2, Scissors, Volume2, SlidersHorizontal, Gauge, Play } from 'lucide-react';
|
||||||
|
|
||||||
export default function ZoneEditor() {
|
export default function ZoneEditor() {
|
||||||
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain' | 'speed'>('all');
|
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain' | 'speed'>('all');
|
||||||
|
const previewFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
cutRanges,
|
cutRanges,
|
||||||
muteRanges,
|
muteRanges,
|
||||||
gainRanges,
|
gainRanges,
|
||||||
speedRanges,
|
speedRanges,
|
||||||
|
duration,
|
||||||
|
setCurrentTime,
|
||||||
|
zonePreviewPaddingSeconds,
|
||||||
|
setZonePreviewPaddingSeconds,
|
||||||
globalGainDb,
|
globalGainDb,
|
||||||
setGlobalGainDb,
|
setGlobalGainDb,
|
||||||
removeCutRange,
|
removeCutRange,
|
||||||
@ -20,6 +25,57 @@ export default function ZoneEditor() {
|
|||||||
updateSpeedRange,
|
updateSpeedRange,
|
||||||
} = useEditorStore();
|
} = 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) => (
|
||||||
|
<button
|
||||||
|
onClick={() => previewZone(start, end)}
|
||||||
|
className={`p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity ${accentClass}`}
|
||||||
|
title={`Play ${zonePreviewPaddingSeconds.toFixed(2)}s before and after zone`}
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
const totalZones = cutRanges.length + muteRanges.length + gainRanges.length + speedRanges.length;
|
const totalZones = cutRanges.length + muteRanges.length + gainRanges.length + speedRanges.length;
|
||||||
|
|
||||||
const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain' | 'speed') => {
|
const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain' | 'speed') => {
|
||||||
@ -39,14 +95,36 @@ export default function ZoneEditor() {
|
|||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
<div className="w-4 h-4 rounded bg-editor-accent/30" />
|
|
||||||
Zone Editor
|
Zone Editor
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-editor-text-muted">
|
<p className="text-xs text-editor-text-muted">
|
||||||
Manage all timeline zones ({totalZones} total)
|
Manage all timeline zones ({totalZones} total)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-[160px] rounded border border-editor-border bg-editor-surface px-2 py-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-editor-text-muted">Preview</span>
|
||||||
|
<span className="text-[10px] text-editor-text-muted">before/after</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={0.25}
|
||||||
|
value={zonePreviewPaddingSeconds}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-editor-text-muted">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* View Mode Toggle */}
|
{/* View Mode Toggle */}
|
||||||
<div className="flex items-center gap-1 rounded bg-editor-surface border border-editor-border p-1">
|
<div className="flex items-center gap-1 rounded bg-editor-surface border border-editor-border p-1">
|
||||||
@ -130,6 +208,7 @@ export default function ZoneEditor() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeCutRange(range.id)}
|
onClick={() => removeCutRange(range.id)}
|
||||||
className="p-1 rounded hover:bg-red-500/20 text-red-500/70 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 rounded hover:bg-red-500/20 text-red-500/70 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
@ -162,6 +241,7 @@ export default function ZoneEditor() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderPreviewButton(range.start, range.end, 'hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500')}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeMuteRange(range.id)}
|
onClick={() => removeMuteRange(range.id)}
|
||||||
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
@ -233,6 +313,7 @@ export default function ZoneEditor() {
|
|||||||
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
title="Gain dB"
|
title="Gain dB"
|
||||||
/>
|
/>
|
||||||
|
{renderPreviewButton(range.start, range.end, 'hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500')}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeGainRange(range.id)}
|
onClick={() => removeGainRange(range.id)}
|
||||||
className="p-1 rounded hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 rounded hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
@ -277,6 +358,7 @@ export default function ZoneEditor() {
|
|||||||
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
title="Speed multiplier"
|
title="Speed multiplier"
|
||||||
/>
|
/>
|
||||||
|
{renderPreviewButton(range.start, range.end, 'hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500')}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeSpeedRange(range.id)}
|
onClick={() => removeSpeedRange(range.id)}
|
||||||
className="p-1 rounded hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 rounded hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
|||||||
@ -43,6 +43,7 @@ interface EditorState {
|
|||||||
exportProgress: number;
|
exportProgress: number;
|
||||||
|
|
||||||
backendUrl: string;
|
backendUrl: string;
|
||||||
|
zonePreviewPaddingSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorActions {
|
interface EditorActions {
|
||||||
@ -82,12 +83,23 @@ interface EditorActions {
|
|||||||
removeSilenceTrimGroup: (groupId: string) => void;
|
removeSilenceTrimGroup: (groupId: string) => void;
|
||||||
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
|
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
|
||||||
setExporting: (active: boolean, progress?: number) => void;
|
setExporting: (active: boolean, progress?: number) => void;
|
||||||
|
setZonePreviewPaddingSeconds: (seconds: number) => void;
|
||||||
getKeepSegments: () => Array<{ start: number; end: number }>;
|
getKeepSegments: () => Array<{ start: number; end: number }>;
|
||||||
getWordAtTime: (time: number) => number;
|
getWordAtTime: (time: number) => number;
|
||||||
loadProject: (projectData: any) => void;
|
loadProject: (projectData: any) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ZONE_PREVIEW_PADDING_KEY = 'talkedit-zone-preview-padding-seconds';
|
||||||
|
|
||||||
|
function getStoredZonePreviewPaddingSeconds() {
|
||||||
|
if (typeof window === 'undefined') return 1;
|
||||||
|
const stored = window.localStorage.getItem(ZONE_PREVIEW_PADDING_KEY);
|
||||||
|
const parsed = stored ? Number(stored) : 1;
|
||||||
|
if (!Number.isFinite(parsed)) return 1;
|
||||||
|
return Math.max(0, Math.min(10, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: EditorState = {
|
const initialState: EditorState = {
|
||||||
videoPath: null,
|
videoPath: null,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
@ -113,6 +125,7 @@ const initialState: EditorState = {
|
|||||||
isExporting: false,
|
isExporting: false,
|
||||||
exportProgress: 0,
|
exportProgress: 0,
|
||||||
backendUrl: 'http://127.0.0.1:8000',
|
backendUrl: 'http://127.0.0.1:8000',
|
||||||
|
zonePreviewPaddingSeconds: getStoredZonePreviewPaddingSeconds(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let nextRangeId = 1;
|
let nextRangeId = 1;
|
||||||
@ -184,11 +197,12 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadVideo: (path) => {
|
loadVideo: (path) => {
|
||||||
const backend = get().backendUrl;
|
const { backendUrl, zonePreviewPaddingSeconds } = get();
|
||||||
const url = `${backend}/file?path=${encodeURIComponent(path)}`;
|
const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`;
|
||||||
set({
|
set({
|
||||||
...initialState,
|
...initialState,
|
||||||
backendUrl: backend,
|
backendUrl,
|
||||||
|
zonePreviewPaddingSeconds,
|
||||||
videoPath: path,
|
videoPath: path,
|
||||||
videoUrl: url,
|
videoUrl: url,
|
||||||
});
|
});
|
||||||
@ -407,6 +421,14 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
exportProgress: progress ?? (active ? 0 : 100),
|
exportProgress: progress ?? (active ? 0 : 100),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setZonePreviewPaddingSeconds: (seconds) => {
|
||||||
|
const nextSeconds = Math.max(0, Math.min(10, seconds));
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds));
|
||||||
|
}
|
||||||
|
set({ zonePreviewPaddingSeconds: nextSeconds });
|
||||||
|
},
|
||||||
|
|
||||||
getKeepSegments: () => {
|
getKeepSegments: () => {
|
||||||
const { words, cutRanges, duration } = get();
|
const { words, cutRanges, duration } = get();
|
||||||
if (words.length === 0) return [{ start: 0, end: duration }];
|
if (words.length === 0) return [{ start: 0, end: duration }];
|
||||||
@ -456,8 +478,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadProject: (data) => {
|
loadProject: (data) => {
|
||||||
const backend = get().backendUrl;
|
const { backendUrl, zonePreviewPaddingSeconds } = get();
|
||||||
const url = `${backend}/file?path=${encodeURIComponent(data.videoPath)}`;
|
const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`;
|
||||||
|
|
||||||
let globalIdx = 0;
|
let globalIdx = 0;
|
||||||
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
||||||
@ -468,7 +490,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
|
|
||||||
set({
|
set({
|
||||||
...initialState,
|
...initialState,
|
||||||
backendUrl: backend,
|
backendUrl,
|
||||||
|
zonePreviewPaddingSeconds,
|
||||||
videoPath: data.videoPath,
|
videoPath: data.videoPath,
|
||||||
videoUrl: url,
|
videoUrl: url,
|
||||||
words: data.words || [],
|
words: data.words || [],
|
||||||
@ -489,7 +512,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
reset: () => set(initialState),
|
reset: () => {
|
||||||
|
const { zonePreviewPaddingSeconds } = get();
|
||||||
|
set({ ...initialState, zonePreviewPaddingSeconds });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{ limit: 100 },
|
{ limit: 100 },
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user