diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6af2095..1a32a76 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -646,7 +646,7 @@ export default function App() {
}
- label="Gain Zone"
+ label="Sound Gain"
onClick={handleGain}
active={gainMode}
disabled={!canEdit}
@@ -667,7 +667,7 @@ export default function App() {
}
- label="Speed Zone"
+ label="Speed Adjust"
onClick={handleSpeed}
active={speedMode}
disabled={!canEdit}
@@ -685,9 +685,10 @@ export default function App() {
disabled={!canEdit}
/>
+
}
- label="Zones"
+ label="Edit Zones"
active={activePanel === 'zones'}
onClick={() => togglePanel('zones')}
disabled={!videoPath || !canEdit}
@@ -695,7 +696,7 @@ export default function App() {
/>
PA}
- label="Pause Trim"
+ label="Trim Silence"
active={activePanel === 'silence'}
onClick={() => togglePanel('silence')}
disabled={!videoPath || !canEdit}
@@ -703,7 +704,7 @@ export default function App() {
/>
}
- label="Markers"
+ label="Chapter Marks"
active={activePanel === 'markers'}
onClick={() => togglePanel('markers')}
disabled={!videoPath || !canEdit}
@@ -711,7 +712,7 @@ export default function App() {
/>
}
- label="Music"
+ label="Bkg. Music"
active={activePanel === 'music'}
onClick={() => togglePanel('music')}
disabled={!videoPath || !canEdit}
@@ -719,12 +720,13 @@ export default function App() {
/>
}
- label="Append"
+ label="Add Clips"
active={activePanel === 'append'}
onClick={() => togglePanel('append')}
disabled={!videoPath || !canEdit}
title="Append additional video clips — concatenate multiple files during export"
/>
+
-
+
}
- label="AI"
+ label="AI Tools"
active={activePanel === 'ai'}
onClick={() => togglePanel('ai')}
disabled={words.length === 0 || !canEdit}
@@ -774,7 +768,7 @@ export default function App() {
label="Export"
active={activePanel === 'export'}
onClick={() => togglePanel('export')}
- disabled={words.length === 0}
+ disabled={!videoPath}
/>
}
@@ -800,6 +794,7 @@ export default function App() {
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
onMouseDown={startSplitDrag}
+ title="Drag to resize"
/>
{/* Transcript */}
@@ -877,6 +872,7 @@ export default function App() {
{activePanel === 'zones' && (
@@ -886,7 +882,7 @@ export default function App() {
{activePanel === 'markers' &&
}
{activePanel === 'music' &&
}
{activePanel === 'append' &&
}
- {activePanel === 'ai' &&
}
+ {activePanel === 'ai' &&
}
{activePanel === 'export' &&
}
{activePanel === 'settings' &&
}
diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx
index 59434b3..510945c 100644
--- a/frontend/src/components/AIPanel.tsx
+++ b/frontend/src/components/AIPanel.tsx
@@ -1,10 +1,16 @@
import { useCallback, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { useAIStore } from '../store/aiStore';
-import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download } from 'lucide-react';
+import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, RefreshCw } from 'lucide-react';
import type { ClipSuggestion } from '../types/project';
-export default function AIPanel() {
+interface AIPanelProps {
+ onReprocess: () => void;
+ whisperModel: string;
+ setWhisperModel: (model: string) => void;
+}
+
+export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) {
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
const {
defaultProvider,
@@ -20,10 +26,12 @@ export default function AIPanel() {
setProcessing,
} = useAIStore();
- const [activeTab, setActiveTab] = useState<'filler' | 'clips'>('filler');
+ const [activeTab, setActiveTab] = useState<'filler' | 'clips' | 'reprocess'>('filler');
+ const [error, setError] = useState(null);
const detectFillers = useCallback(async () => {
if (words.length === 0) return;
+ setError(null);
setProcessing(true, 'Detecting filler words...');
try {
const config = providers[defaultProvider];
@@ -41,11 +49,15 @@ export default function AIPanel() {
custom_filler_words: customFillerWords || undefined,
}),
});
- if (!res.ok) throw new Error('Filler detection failed');
+ if (!res.ok) {
+ const errData = await res.json().catch(() => ({}));
+ throw new Error(errData.error || `Filler detection failed (${res.status})`);
+ }
const data = await res.json();
setFillerResult(data);
} catch (err) {
console.error(err);
+ setError(err instanceof Error ? err.message : 'Filler detection failed');
} finally {
setProcessing(false);
}
@@ -53,6 +65,7 @@ export default function AIPanel() {
const createClips = useCallback(async () => {
if (words.length === 0) return;
+ setError(null);
setProcessing(true, 'Finding best clip segments...');
try {
const config = providers[defaultProvider];
@@ -75,11 +88,15 @@ export default function AIPanel() {
target_duration: 60,
}),
});
- if (!res.ok) throw new Error('Clip creation failed');
+ if (!res.ok) {
+ const errData = await res.json().catch(() => ({}));
+ throw new Error(errData.error || `Clip creation failed (${res.status})`);
+ }
const data = await res.json();
setClipSuggestions(data.clips || []);
} catch (err) {
console.error(err);
+ setError(err instanceof Error ? err.message : 'Clip creation failed');
} finally {
setProcessing(false);
}
@@ -159,6 +176,13 @@ export default function AIPanel() {
label="Create Clips"
title="Find the best segments for social media clips"
/>
+ setActiveTab('reprocess')}
+ icon={}
+ label="Reprocess"
+ title="Re-run transcription with a different Whisper model"
+ />
@@ -184,7 +208,7 @@ export default function AIPanel() {
onClick={detectFillers}
disabled={isProcessing || words.length === 0}
title="Scan the entire transcript for filler words (um, uh, like, you know) and mark for removal"
- className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
+ className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
@@ -199,6 +223,17 @@ export default function AIPanel() {
)}
+ {error && (
+
+ {error}
+
+
+ )}
{fillerResult && fillerResult.fillerWords.length > 0 && (
@@ -214,7 +249,7 @@ export default function AIPanel() {
Apply All
+ {error && (
+
+ {error}
+
+
+ )}
{clipSuggestions.length > 0 && (
{clipSuggestions.map((clip, i) => (
@@ -292,7 +338,7 @@ export default function AIPanel() {
onClick={() => handleExportClip(clip, i)}
disabled={exportingClipIndex === i}
title="Export just this segment as a standalone video file"
- className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-50 transition-colors"
+ className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-40 transition-colors"
>
{exportingClipIndex === i ? (
@@ -308,6 +354,52 @@ export default function AIPanel() {
)}
)}
+
+ {activeTab === 'reprocess' && (
+
+
+ Re-run transcription with a different model — replaces the current transcript entirely.
+
+
+
+
+
+
+
+ )}
);
diff --git a/frontend/src/components/ExportDialog.tsx b/frontend/src/components/ExportDialog.tsx
index 1891252..d00bcec 100644
--- a/frontend/src/components/ExportDialog.tsx
+++ b/frontend/src/components/ExportDialog.tsx
@@ -507,7 +507,7 @@ export default function ExportDialog() {
{/* Marker list */}
- {timelineMarkers.length > 0 && (
+ {timelineMarkers.length > 0 ? (
{timelineMarkers.map((m) => (
))}
+ ) : (
+
+
+ No markers yet. Press I and O on the timeline to set mark in/out points, then add a marker here.
+
+
)}
{/* Chapters */}
diff --git a/frontend/src/components/SilenceTrimmerPanel.tsx b/frontend/src/components/SilenceTrimmerPanel.tsx
index 3c4c67d..9acf2cb 100644
--- a/frontend/src/components/SilenceTrimmerPanel.tsx
+++ b/frontend/src/components/SilenceTrimmerPanel.tsx
@@ -190,7 +190,7 @@ export default function SilenceTrimmerPanel() {
{isDetecting ? (
diff --git a/frontend/src/components/VolumePanel.tsx b/frontend/src/components/VolumePanel.tsx
deleted file mode 100644
index c979323..0000000
--- a/frontend/src/components/VolumePanel.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-import { useMemo, useState } from 'react';
-import { useEditorStore } from '../store/editorStore';
-import { Trash2, Volume2 } from 'lucide-react';
-
-interface VolumePanelProps {
- gainMode: boolean;
- onToggleGainMode: () => void;
- timelineGainDb: number;
- onTimelineGainDbChange: (gainDb: number) => void;
-}
-
-export default function VolumePanel({
- gainMode,
- onToggleGainMode,
- timelineGainDb,
- onTimelineGainDbChange,
-}: VolumePanelProps) {
- const {
- words,
- selectedWordIndices,
- globalGainDb,
- gainRanges,
- setGlobalGainDb,
- addGainRange,
- updateGainRange,
- removeGainRange,
- } = useEditorStore();
-
- const [selectionGainDb, setSelectionGainDb] = useState(3);
-
- const canApplySelection = selectedWordIndices.length > 0;
-
- const selectedRange = useMemo(() => {
- if (!canApplySelection) return null;
- const sorted = [...selectedWordIndices].sort((a, b) => a - b);
- const startWord = words[sorted[0]];
- const endWord = words[sorted[sorted.length - 1]];
- if (!startWord || !endWord) return null;
- return {
- start: startWord.start,
- end: endWord.end,
- };
- }, [canApplySelection, selectedWordIndices, words]);
-
- const applySelectionGain = () => {
- if (!selectedRange) return;
- addGainRange(selectedRange.start, selectedRange.end, selectionGainDb);
- };
-
- return (
-
-
-
-
- Volume / Gain
-
-
- Apply global gain or per-selection gain ranges.
-
-
-
-
-
-
setGlobalGainDb(Number(e.target.value))}
- className="w-full"
- />
-
- -24 dB
- {globalGainDb.toFixed(1)} dB
- +24 dB
-
-
-
-
-
-
- onTimelineGainDbChange(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
- className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
- />
-
- {gainMode ? 'Exit Zone Mode' : 'Add Gain Zones'}
-
-
-
- In gain zone mode, drag on the timeline to create a zone with this dB value.
-
-
-
-
-
-
- setSelectionGainDb(Number(e.target.value) || 0)}
- className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
- />
-
- Apply To Selection
-
-
-
- {canApplySelection
- ? `${selectedWordIndices.length} selected words${selectedRange ? ` (${selectedRange.start.toFixed(2)}s - ${selectedRange.end.toFixed(2)}s)` : ''}`
- : 'Select transcript words to apply a gain range.'}
-
-
-
- {gainRanges.length > 0 && (
-
-
Gain Ranges
-
- {gainRanges.map((range) => (
-
-
-
- {range.start.toFixed(2)}s - {range.end.toFixed(2)}s
-
-
{range.id}
-
-
updateGainRange(range.id, Number(e.target.value) || 0)}
- className="w-20 px-2 py-1 text-xs bg-editor-bg border border-editor-border rounded"
- title="Gain dB"
- />
-
removeGainRange(range.id)}
- className="p-1 rounded hover:bg-editor-danger/20 text-editor-danger"
- title="Delete gain range"
- >
-
-
-
- ))}
-
-
- )}
-
- );
-}
diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx
index 3ccd2f4..d74af1a 100644
--- a/frontend/src/components/WaveformTimeline.tsx
+++ b/frontend/src/components/WaveformTimeline.tsx
@@ -1314,6 +1314,13 @@ export default function WaveformTimeline({
{audioError}
+ ) : !waveformDataRef.current ? (
+
+
+
+
Loading waveform...
+
+
) : (
{showThumbnails && thumbnailFrames.size > 0 && (
diff --git a/frontend/src/components/ZoneEditor.tsx b/frontend/src/components/ZoneEditor.tsx
index 2a0bfcb..218ea58 100644
--- a/frontend/src/components/ZoneEditor.tsx
+++ b/frontend/src/components/ZoneEditor.tsx
@@ -94,7 +94,7 @@ export default function ZoneEditor() {
case 'cut':
return 'border-red-500/40 bg-red-500/5';
case 'mute':
- return 'border-orange-500/40 bg-orange-500/5';
+ return 'border-blue-500/40 bg-blue-500/20';
case 'gain':
return 'border-amber-500/40 bg-amber-500/5';
case 'speed':
@@ -212,7 +212,7 @@ export default function ZoneEditor() {
onClick={() => setViewMode('mute')}
className={`px-2 py-1 text-xs rounded transition-colors ${
viewMode === 'mute'
- ? 'bg-orange-500/30 text-orange-500'
+ ? 'bg-blue-500/20 text-blue-400'
: 'text-editor-text-muted hover:text-editor-text'
}`}
title="Show only Mute zones"
@@ -245,7 +245,7 @@ export default function ZoneEditor() {
{totalZones === 0 ? (
-
+
No zones yet. Create zones from the toolbar or by highlighting words.
@@ -264,13 +264,12 @@ export default function ZoneEditor() {
setFocusedZone({ type: 'cut', id: range.id })}
- className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
+ className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
>
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
-
{range.id}
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
0 && (
-
+
Mute Zones ({muteRanges.length})
@@ -301,21 +300,20 @@ export default function ZoneEditor() {
setFocusedZone({ type: 'mute', id: range.id })}
- className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-orange-400 border-orange-400/80 bg-orange-500/12' : ''}`}
+ className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-blue-400 border-blue-400/80 bg-blue-500/20' : ''}`}
>
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
-
{range.id}
- {renderPreviewButton(range.start, range.end, 'hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500')}
+ {renderPreviewButton(range.start, range.end, 'hover:bg-blue-500/20 text-blue-400 hover:text-blue-400')}
{
e.stopPropagation();
removeZone('mute', 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-blue-500/20 text-blue-400 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete mute zone"
>
@@ -326,12 +324,12 @@ export default function ZoneEditor() {
)}
- {/* Gain Zones */}
+ {/* Sound Gain */}
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
- Gain Zones ({gainRanges.length})
+ Sound Gain ({gainRanges.length})
{/* Global Gain Slider */}
@@ -366,7 +364,7 @@ export default function ZoneEditor() {
setFocusedZone({ type: 'gain', id: range.id })}
- className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
+ className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
>
@@ -404,19 +402,19 @@ export default function ZoneEditor() {
)}
- {/* Speed Zones */}
+ {/* Speed Adjust */}
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
- Speed Zones ({speedRanges.length})
+ Speed Adjust ({speedRanges.length})
{speedRanges.map((range) => (
setFocusedZone({ type: 'speed', id: range.id })}
- className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
+ className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
>
diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts
index a8577d9..0305a4d 100644
--- a/frontend/src/hooks/useKeyboardShortcuts.ts
+++ b/frontend/src/hooks/useKeyboardShortcuts.ts
@@ -187,11 +187,23 @@ function toggleCheatsheet(bindings: KeyBinding[]) {
)
.join('');
- overlay.innerHTML = `
+ overlay.innerHTML = `
Keyboard Shortcuts
Customize in Settings • Press ? to close
+
×
`;
document.body.appendChild(overlay);
+
+ const closeBtn = overlay.querySelector('#cheatsheet-close') as HTMLButtonElement;
+ if (closeBtn) closeBtn.onclick = () => overlay.remove();
+
+ const escHandler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ overlay.remove();
+ document.removeEventListener('keydown', escHandler);
+ }
+ };
+ document.addEventListener('keydown', escHandler);
}