UI improvements, moved file name and moved buttons left

This commit is contained in:
2026-04-15 19:54:39 -06:00
parent 7479acd3ee
commit b7a795f986
9 changed files with 500 additions and 70 deletions

View File

@ -21,6 +21,7 @@ import {
Scissors,
VolumeX,
SlidersHorizontal,
Gauge,
FilePlus2,
RefreshCw,
Grid3x3,
@ -40,6 +41,7 @@ export default function App() {
cutRanges,
muteRanges,
gainRanges,
speedRanges,
globalGainDb,
silenceTrimGroups,
transcriptionModel,
@ -55,14 +57,18 @@ export default function App() {
addCutRange,
addMuteRange,
addGainRange,
addSpeedRange,
} = useEditorStore();
const [activePanel, setActivePanel] = useState<Panel>(null);
const [whisperModel, setWhisperModel] = useState('base');
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
const [cutMode, setCutMode] = useState(false);
const [muteMode, setMuteMode] = useState(false);
const [gainMode, setGainMode] = useState(false);
const [gainModeDb, setGainModeDb] = useState(3);
const [speedMode, setSpeedMode] = useState(false);
const [speedModeValue, setSpeedModeValue] = useState(1.25);
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
@ -79,6 +85,7 @@ export default function App() {
cutRanges,
muteRanges,
gainRanges,
speedRanges,
globalGainDb,
silenceTrimGroups,
transcriptionModel,
@ -93,6 +100,7 @@ export default function App() {
cutRanges,
muteRanges,
gainRanges,
speedRanges,
globalGainDb,
silenceTrimGroups,
transcriptionModel,
@ -112,6 +120,7 @@ export default function App() {
cutRanges: data.cutRanges || [],
muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [],
speedRanges: data.speedRanges || [],
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
silenceTrimGroups: data.silenceTrimGroups || [],
transcriptionModel: data.transcriptionModel ?? null,
@ -138,6 +147,7 @@ export default function App() {
setCutMode(false);
setMuteMode(false);
setGainMode(false);
setSpeedMode(false);
}
};
@ -217,6 +227,7 @@ export default function App() {
setCutMode(false);
setMuteMode(false);
setGainMode(false);
setSpeedMode(false);
});
};
@ -315,6 +326,7 @@ export default function App() {
setCutMode(!cutMode);
setMuteMode(false); // Exit mute mode
setGainMode(false); // Exit gain mode
setSpeedMode(false); // Exit speed mode
}
};
@ -330,6 +342,7 @@ export default function App() {
setMuteMode(!muteMode);
setCutMode(false); // Exit cut mode
setGainMode(false); // Exit gain mode
setSpeedMode(false); // Exit speed mode
}
};
@ -343,6 +356,21 @@ export default function App() {
setGainMode(!gainMode);
setCutMode(false);
setMuteMode(false);
setSpeedMode(false);
}
};
const handleSpeed = () => {
if (selectedWordIndices.length > 0) {
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addSpeedRange(startTime, endTime, speedModeValue);
} else {
setSpeedMode(!speedMode);
setCutMode(false);
setMuteMode(false);
setGainMode(false);
}
};
@ -413,19 +441,8 @@ export default function App() {
return (
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
{/* Top bar */}
<header className="h-12 flex items-center justify-between px-4 border-b border-editor-border shrink-0">
<div className="flex items-center gap-3">
<Film className="w-5 h-5 text-editor-accent" />
<span className="text-sm font-medium truncate max-w-[300px]">
{videoPath.split(/[\\/]/).pop()}
</span>
{transcriptionModel && (
<span className="px-2 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted">
Model: {transcriptionModel}
</span>
)}
</div>
<div className="flex items-center gap-2">
<header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
<div className="flex items-center gap-0.5">
<ToolbarButton
icon={<FilePlus2 className="w-4 h-4" />}
label="New"
@ -477,6 +494,24 @@ export default function App() {
title="Gain dB for new gain zones"
/>
</div>
<div className="flex items-center gap-1">
<ToolbarButton
icon={<Gauge className="w-4 h-4" />}
label="Speed Zone"
onClick={handleSpeed}
active={speedMode}
/>
<input
type="number"
min={0.25}
max={4}
step={0.05}
value={speedModeValue}
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
title="Playback rate for new speed zones"
/>
</div>
<ToolbarButton
icon={<Grid3x3 className="w-4 h-4" />}
label="Zones"
@ -495,7 +530,7 @@ export default function App() {
<select
value={whisperModel}
onChange={(e) => setWhisperModel(e.target.value)}
className="bg-transparent text-xs text-editor-text focus:outline-none"
className="bg-editor-surface text-xs text-editor-text focus:outline-none [color-scheme:dark]"
title="Transcription model"
>
<optgroup label="Multilingual">
@ -521,7 +556,7 @@ export default function App() {
onClick={handleReprocessProject}
disabled={isTranscribing || !videoPath}
title="Reprocess transcript with selected model"
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed"
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} />
Reprocess
@ -562,6 +597,16 @@ export default function App() {
{/* Transcript */}
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
{videoPath && (
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
<span className="text-xs font-medium truncate text-editor-text">{videoPath.split(/[\/]/).pop()}</span>
{transcriptionModel && (
<span className="px-1.5 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted shrink-0">
{transcriptionModel}
</span>
)}
</div>
)}
{isTranscribing ? (
<div className="flex-1 flex flex-col items-center justify-center gap-5">
{/* Animated waveform */}
@ -588,6 +633,8 @@ export default function App() {
muteMode={muteMode}
gainMode={gainMode}
gainModeDb={gainModeDb}
speedMode={speedMode}
speedModeValue={speedModeValue}
/>
) : (
<div className="flex-1 flex items-center justify-center text-editor-text-muted text-sm">
@ -599,7 +646,14 @@ export default function App() {
{/* Waveform timeline */}
<div className="h-32 border-t border-editor-border shrink-0">
<WaveformTimeline cutMode={cutMode} muteMode={muteMode} gainMode={gainMode} gainModeDb={gainModeDb} />
<WaveformTimeline
cutMode={cutMode}
muteMode={muteMode}
gainMode={gainMode}
gainModeDb={gainModeDb}
speedMode={speedMode}
speedModeValue={speedModeValue}
/>
</div>
</div>

View File

@ -4,7 +4,7 @@ import { Download, Loader2, Zap, Cog, Info } from 'lucide-react';
import type { ExportOptions } from '../types/project';
export default function ExportDialog() {
const { videoPath, words, deletedRanges, muteRanges, gainRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
const { videoPath, words, deletedRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
useEditorStore();
const hasCuts = deletedRanges.length > 0;
@ -48,6 +48,7 @@ export default function ExportDialog() {
keep_segments: keepSegments,
mute_ranges: muteRanges,
gain_ranges: gainRanges,
speed_ranges: speedRanges,
global_gain_db: globalGainDb,
words: options.captions !== 'none' ? words : undefined,
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
@ -60,7 +61,7 @@ export default function ExportDialog() {
console.error('Export error:', err);
setExporting(false);
}
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, deletedRanges, muteRanges, gainRanges, globalGainDb, words]);
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, deletedRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words]);
return (
<div className="p-4 space-y-5">

View File

@ -1,22 +1,32 @@
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Virtuoso } from 'react-virtuoso';
import { Scissors, VolumeX, SlidersHorizontal, RotateCcw } from 'lucide-react';
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw } from 'lucide-react';
interface TranscriptEditorProps {
cutMode: boolean;
muteMode: boolean;
gainMode: boolean;
gainModeDb: number;
speedMode: boolean;
speedModeValue: number;
}
export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainModeDb }: TranscriptEditorProps) {
export default function TranscriptEditor({
cutMode,
muteMode,
gainMode,
gainModeDb,
speedMode,
speedModeValue,
}: TranscriptEditorProps) {
const words = useEditorStore((s) => s.words);
const segments = useEditorStore((s) => s.segments);
const deletedRanges = useEditorStore((s) => s.deletedRanges);
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 selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex);
const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices);
@ -25,9 +35,11 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
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 addCutRange = useEditorStore((s) => s.addCutRange);
const addMuteRange = useEditorStore((s) => s.addMuteRange);
const addGainRange = useEditorStore((s) => s.addGainRange);
const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
const selectionStart = useRef<number | null>(null);
@ -84,7 +96,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
return;
}
if (cutMode || muteMode || gainMode) {
if (cutMode || muteMode || gainMode || speedMode) {
zoneDragStart.current = index;
setZoneDragRange({ start: index, end: index });
selectionStart.current = null;
@ -104,7 +116,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
setSelectedWordIndices([index]);
}
},
[words, selectedWordIndices, setSelectedWordIndices, cutMode, muteMode, gainMode],
[words, selectedWordIndices, setSelectedWordIndices, cutMode, muteMode, gainMode, speedMode],
);
const handleWordMouseEnter = useCallback(
@ -137,12 +149,13 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
if (cutMode) addCutRange(startWord.start, endWord.end);
if (muteMode) addMuteRange(startWord.start, endWord.end);
if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb);
if (speedMode) addSpeedRange(startWord.start, endWord.end, speedModeValue);
}
}
zoneDragStart.current = null;
setZoneDragRange(null);
selectionStart.current = null;
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, addCutRange, addMuteRange, addGainRange]);
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange]);
const handleClickOutside = useCallback(
(e: React.MouseEvent) => {
@ -186,6 +199,14 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
addGainRange(startTime, endTime, gainModeDb);
}, [selectedWordIndices, words, addGainRange, gainModeDb]);
const speedSelectedWords = useCallback(() => {
if (selectedWordIndices.length === 0) return;
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addSpeedRange(startTime, endTime, speedModeValue);
}, [selectedWordIndices, words, addSpeedRange, speedModeValue]);
const getCutRangeForWord = useCallback(
(wordIndex: number) => {
const word = words[wordIndex];
@ -213,6 +234,15 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
[words, gainRanges],
);
const getSpeedRangeForWord = useCallback(
(wordIndex: number) => {
const word = words[wordIndex];
if (!word) return null;
return speedRanges.find((r) => word.start >= r.start && word.end <= r.end);
},
[words, speedRanges],
);
const renderSegment = useCallback(
(index: number) => {
const segment = segments[index];
@ -238,6 +268,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
const cutRange = getCutRangeForWord(globalIndex);
const muteRange = getMuteRangeForWord(globalIndex);
const gainRange = getGainRangeForWord(globalIndex);
const speedRange = getSpeedRangeForWord(globalIndex);
return (
<span
@ -254,12 +285,14 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
${cutRange ? 'bg-red-500/20 text-red-100' : ''}
${muteRange ? 'bg-blue-500/20 text-blue-100' : ''}
${gainRange ? 'bg-amber-500/20 text-amber-100' : ''}
${speedRange ? 'bg-emerald-500/20 text-emerald-100' : ''}
${isZoneDragSelected && cutMode ? 'bg-red-500/30 ring-1 ring-red-400/60' : ''}
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
${isSelected && !isDeleted && !cutRange && !muteRange && !gainRange ? 'bg-editor-word-selected text-white' : ''}
${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange ? 'bg-editor-word-hover' : ''}
${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
${isSelected && !isDeleted && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
`}
>
{word.word}{' '}
@ -274,13 +307,14 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
<RotateCcw className="w-2.5 h-2.5" /> Restore
</button>
)}
{(cutRange || muteRange || gainRange) && isHovered && (
{(cutRange || muteRange || gainRange || speedRange) && isHovered && (
<button
onClick={(e) => {
e.stopPropagation();
if (cutRange) removeCutRange(cutRange.id);
if (muteRange) removeMuteRange(muteRange.id);
if (gainRange) removeGainRange(gainRange.id);
if (speedRange) removeSpeedRange(speedRange.id);
}}
className="absolute -top-5 left-1/2 -translate-x-1/2 flex items-center gap-0.5 px-1.5 py-0.5 bg-editor-surface border border-editor-border rounded text-[10px] text-editor-success whitespace-nowrap z-10"
>
@ -294,7 +328,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
</div>
);
},
[segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, restoreRange, removeCutRange, removeMuteRange, removeGainRange, zoneDragRange, cutMode, muteMode, gainMode],
[segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, restoreRange, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode],
);
return (
@ -302,6 +336,7 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
<div className="flex items-center gap-2 px-4 py-2 border-b border-editor-border shrink-0">
<span className="text-xs text-editor-text-muted flex-1">
{words.length} words &middot; {deletedRanges.length} cuts &middot; {cutRanges.length} cut ranges &middot; {muteRanges.length} mute ranges &middot; {gainRanges.length} gain ranges
&middot; {speedRanges.length} speed ranges
</span>
{selectedWordIndices.length > 0 && (
<div className="flex items-center gap-1">
@ -326,6 +361,13 @@ export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainMode
<SlidersHorizontal className="w-3 h-3" />
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
</button>
<button
onClick={speedSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors"
>
<Gauge className="w-3 h-3" />
Speed {speedModeValue.toFixed(2)}x
</button>
</div>
)}
</div>

View File

@ -108,11 +108,15 @@ export default function WaveformTimeline({
muteMode,
gainMode,
gainModeDb,
speedMode,
speedModeValue,
}: {
cutMode: boolean;
muteMode: boolean;
gainMode: boolean;
gainModeDb: number;
speedMode: boolean;
speedModeValue: number;
}) {
const waveCanvasRef = useRef<HTMLCanvasElement>(null);
const headCanvasRef = useRef<HTMLCanvasElement>(null);
@ -127,16 +131,20 @@ export default function WaveformTimeline({
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<WaveformData | null>(null);
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
@ -150,12 +158,13 @@ export default function WaveformTimeline({
const selectionEndRef = useRef<number | null>(null);
const [selectionStart, setSelectionStart] = useState<number | null>(null);
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain', id: string} | null>(null);
const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain' | 'speed', id: string} | null>(null);
const [hoverCursor, setHoverCursor] = useState<string>('crosshair');
const editingZoneRef = useRef<{type: 'cut' | 'mute' | 'gain', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
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);
useEffect(() => {
if (!videoUrl || !videoPath) return;
@ -394,16 +403,53 @@ 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 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 = 'middle';
if (centerX > 12 && centerX < width - 12) {
ctx.fillText(`${range.speed.toFixed(2)}x`, centerX, waveTop + waveH / 2);
}
ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic';
}
// Draw selection overlay (when in zone mode)
if ((cutMode || muteMode || gainMode) && selectionStart !== null && selectionEnd !== null) {
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 fillColor = cutMode
? 'rgba(239, 68, 68, 0.5)'
: muteMode
? 'rgba(59, 130, 246, 0.5)'
: 'rgba(245, 158, 11, 0.5)';
const strokeColor = cutMode ? '#ef4444' : muteMode ? '#3b82f6' : '#f59e0b';
: 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);
@ -441,15 +487,18 @@ export default function WaveformTimeline({
cutRanges,
muteRanges,
gainRanges,
speedRanges,
selectionStart,
selectionEnd,
cutMode,
muteMode,
gainMode,
speedMode,
selectedZone,
showCutZones,
showMuteZones,
showGainZones,
showSpeedZones,
]);
// Keep the ref in sync with the latest drawStaticWaveform closure
@ -715,8 +764,43 @@ 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 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, selectedZone, showCutZones, showMuteZones, showGainZones]);
}, [cutRanges, muteRanges, gainRanges, speedRanges, selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDragging) return; // Don't change cursor while dragging
@ -752,7 +836,9 @@ export default function WaveformTimeline({
? cutRanges.find(r => r.id === zoneHit.id)
: zoneHit.type === 'mute'
? muteRanges.find(r => r.id === zoneHit.id)
: gainRanges.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;
@ -784,8 +870,10 @@ export default function WaveformTimeline({
updateCutRange(editingZoneRef.current.id, newStart, newEnd);
} else if (editingZoneRef.current.type === 'mute') {
updateMuteRange(editingZoneRef.current.id, newStart, newEnd);
} else {
} else if (editingZoneRef.current.type === 'gain') {
updateGainRangeBounds(editingZoneRef.current.id, newStart, newEnd);
} else {
updateSpeedRangeBounds(editingZoneRef.current.id, newStart, newEnd);
}
}
};
@ -806,7 +894,7 @@ export default function WaveformTimeline({
// Clear selection if clicking elsewhere
setSelectedZone(null);
if (cutMode || muteMode || gainMode) {
if (cutMode || muteMode || gainMode || speedMode) {
// Range selection mode
const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime;
@ -838,6 +926,8 @@ export default function WaveformTimeline({
addMuteRange(start, end);
} else if (end - start >= minDuration && gainMode) {
addGainRange(start, end, gainModeDb);
} else if (end - start >= minDuration && speedMode) {
addSpeedRange(start, end, speedModeValue);
}
}
@ -873,7 +963,7 @@ export default function WaveformTimeline({
window.addEventListener('mouseup', onUp);
}
},
[cutMode, muteMode, gainMode, gainModeDb, clientXToTime, seekToClientX, addCutRange, addMuteRange, addGainRange, getZoneAtPosition, cutRanges, muteRanges, gainRanges, duration, updateCutRange, updateMuteRange, updateGainRangeBounds],
[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
@ -896,8 +986,10 @@ export default function WaveformTimeline({
removeCutRange(selectedZone.id);
} else if (selectedZone.type === 'mute') {
removeMuteRange(selectedZone.id);
} else {
} else if (selectedZone.type === 'gain') {
removeGainRange(selectedZone.id);
} else {
removeSpeedRange(selectedZone.id);
}
setSelectedZone(null);
editingZoneRef.current = null;
@ -908,14 +1000,15 @@ export default function WaveformTimeline({
// 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]);
}, [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);
}, [selectedZone, showCutZones, showMuteZones, showGainZones]);
if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null);
}, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
if (!videoUrl) {
return (
@ -935,6 +1028,7 @@ export default function WaveformTimeline({
{cutMode && <span className="text-[10px] text-red-400">Cut mode</span>}
{muteMode && <span className="text-[10px] text-blue-400">Mute mode</span>}
{gainMode && <span className="text-[10px] text-amber-400">Gain mode ({gainModeDb.toFixed(1)} dB)</span>}
{speedMode && <span className="text-[10px] text-emerald-400">Speed mode ({speedModeValue.toFixed(2)}x)</span>}
</div>
<div className="flex items-center gap-2">
<button
@ -958,6 +1052,13 @@ export default function WaveformTimeline({
>
Gain
</button>
<button
onClick={() => setShowSpeedZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showSpeedZones ? 'border-emerald-500/60 text-emerald-300 bg-emerald-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle speed zones"
>
Speed
</button>
<span className="text-[10px] text-editor-text-muted">
Scroll · Ctrl+Scroll to zoom
</span>

View File

@ -1,25 +1,28 @@
import { useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Trash2, Scissors, Volume2, SlidersHorizontal } from 'lucide-react';
import { Trash2, Scissors, Volume2, SlidersHorizontal, Gauge } from 'lucide-react';
export default function ZoneEditor() {
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain'>('all');
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain' | 'speed'>('all');
const {
cutRanges,
muteRanges,
gainRanges,
speedRanges,
globalGainDb,
setGlobalGainDb,
removeCutRange,
removeMuteRange,
removeGainRange,
removeSpeedRange,
updateGainRange,
updateSpeedRange,
} = useEditorStore();
const totalZones = cutRanges.length + muteRanges.length + gainRanges.length;
const totalZones = cutRanges.length + muteRanges.length + gainRanges.length + speedRanges.length;
const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain') => {
const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain' | 'speed') => {
switch (type) {
case 'cut':
return 'border-red-500/40 bg-red-500/5';
@ -27,6 +30,8 @@ export default function ZoneEditor() {
return 'border-orange-500/40 bg-orange-500/5';
case 'gain':
return 'border-amber-500/40 bg-amber-500/5';
case 'speed':
return 'border-emerald-500/40 bg-emerald-500/5';
}
};
@ -85,6 +90,16 @@ export default function ZoneEditor() {
>
Gain
</button>
<button
onClick={() => setViewMode('speed')}
className={`px-2 py-1 text-xs rounded transition-colors ${
viewMode === 'speed'
? 'bg-emerald-500/30 text-emerald-500'
: 'text-editor-text-muted hover:text-editor-text'
}`}
>
Speed
</button>
</div>
</div>
@ -230,6 +245,50 @@ export default function ZoneEditor() {
</div>
</div>
)}
{/* Speed Zones */}
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2">
<Gauge className="w-3.5 h-3.5" />
Speed Zones ({speedRanges.length})
</div>
<div className="space-y-1">
{speedRanges.map((range) => (
<div
key={range.id}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('speed')}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{range.start.toFixed(2)}s {range.end.toFixed(2)}s
</div>
<div className="text-editor-text-muted text-[10px]">
{range.speed.toFixed(2)}x
</div>
</div>
<input
type="number"
min={0.25}
max={4}
step={0.05}
value={range.speed}
onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)}
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"
/>
<button
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"
title="Delete speed zone"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>

View File

@ -7,6 +7,7 @@ import type {
CutRange,
MuteRange,
GainRange,
SpeedRange,
TranscriptionResult,
ProjectFile,
SilenceDetectionRange,
@ -24,6 +25,7 @@ interface EditorState {
cutRanges: CutRange[];
muteRanges: MuteRange[];
gainRanges: GainRange[];
speedRanges: SpeedRange[];
globalGainDb: number;
silenceTrimGroups: SilenceTrimGroup[];
transcriptionModel: string | null;
@ -63,13 +65,17 @@ interface EditorActions {
addCutRange: (start: number, end: number, trimGroupId?: string) => void;
addMuteRange: (start: number, end: number) => void;
addGainRange: (start: number, end: number, gainDb: number) => void;
addSpeedRange: (start: number, end: number, speed: number) => void;
updateCutRange: (id: string, start: number, end: number) => void;
updateMuteRange: (id: string, start: number, end: number) => void;
updateGainRangeBounds: (id: string, start: number, end: number) => void;
updateGainRange: (id: string, gainDb: number) => void;
updateSpeedRangeBounds: (id: string, start: number, end: number) => void;
updateSpeedRange: (id: string, speed: number) => void;
removeCutRange: (id: string) => void;
removeMuteRange: (id: string) => void;
removeGainRange: (id: string) => void;
removeSpeedRange: (id: string) => void;
setGlobalGainDb: (gainDb: number) => void;
applySilenceTrimGroup: (args: {
groupId?: string;
@ -95,6 +101,7 @@ const initialState: EditorState = {
cutRanges: [],
muteRanges: [],
gainRanges: [],
speedRanges: [],
globalGainDb: 0,
silenceTrimGroups: [],
transcriptionModel: null,
@ -152,7 +159,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
saveProject: (): ProjectFile => {
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
if (!videoPath) throw new Error('No video loaded');
const now = new Date().toISOString();
// Strip globalStartIndex (runtime-only field) before persisting.
@ -172,6 +179,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
cutRanges,
muteRanges,
gainRanges,
speedRanges,
globalGainDb,
silenceTrimGroups,
language,
@ -286,6 +294,17 @@ export const useEditorStore = create<EditorState & EditorActions>()(
set({ gainRanges: [...gainRanges, newRange] });
},
addSpeedRange: (start, end, speed) => {
const { speedRanges } = get();
const newRange: SpeedRange = {
id: `speed_${nextRangeId++}`,
start,
end,
speed: Math.max(0.25, Math.min(4, speed)),
};
set({ speedRanges: [...speedRanges, newRange] });
},
updateCutRange: (id, start, end) => {
const { cutRanges } = get();
set({
@ -322,6 +341,24 @@ export const useEditorStore = create<EditorState & EditorActions>()(
});
},
updateSpeedRangeBounds: (id, start, end) => {
const { speedRanges } = get();
set({
speedRanges: speedRanges.map((r) =>
r.id === id ? { ...r, start, end } : r
),
});
},
updateSpeedRange: (id, speed) => {
const { speedRanges } = get();
set({
speedRanges: speedRanges.map((r) =>
r.id === id ? { ...r, speed: Math.max(0.25, Math.min(4, speed)) } : r
),
});
},
removeCutRange: (id) => {
const { cutRanges } = get();
set({ cutRanges: cutRanges.filter((r) => r.id !== id) });
@ -337,6 +374,11 @@ export const useEditorStore = create<EditorState & EditorActions>()(
set({ gainRanges: gainRanges.filter((r) => r.id !== id) });
},
removeSpeedRange: (id) => {
const { speedRanges } = get();
set({ speedRanges: speedRanges.filter((r) => r.id !== id) });
},
setGlobalGainDb: (gainDb) => {
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
},
@ -470,6 +512,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
cutRanges: data.cutRanges || [],
muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [],
speedRanges: data.speedRanges || [],
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
silenceTrimGroups: data.silenceTrimGroups || [],
transcriptionModel: data.transcriptionModel ?? null,

View File

@ -40,6 +40,11 @@ export interface GainRange extends TimeRange {
gainDb: number;
}
export interface SpeedRange extends TimeRange {
id: string;
speed: number;
}
export interface SilenceDetectionRange extends TimeRange {
duration: number;
}
@ -70,6 +75,7 @@ export interface ProjectFile {
cutRanges: CutRange[];
muteRanges: MuteRange[];
gainRanges?: GainRange[];
speedRanges?: SpeedRange[];
globalGainDb?: number;
silenceTrimGroups?: SilenceTrimGroup[];
language: string;