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() { -
-

- 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" - /> - -
-

- {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" - /> - -
- ))} -
-
- )} -
- ); -} 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')}
)} - {/* 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

${rows}

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); }