import { useEffect, useState, useMemo } from 'react'; import { useEditorStore } from './store/editorStore'; import VideoPlayer from './components/VideoPlayer'; import TranscriptEditor from './components/TranscriptEditor'; import WaveformTimeline from './components/WaveformTimeline'; import AIPanel from './components/AIPanel'; import ExportDialog from './components/ExportDialog'; import SettingsPanel from './components/SettingsPanel'; import DevPanel from './components/DevPanel'; import SilenceTrimmerPanel from './components/SilenceTrimmerPanel'; import ZoneEditor from './components/ZoneEditor'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { Film, FolderOpen, Settings, Sparkles, Download, FileInput, Save, Scissors, VolumeX, SlidersHorizontal, FilePlus2, RefreshCw, Grid3x3, } from 'lucide-react'; const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath'; type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | null; export default function App() { const { videoPath, exportedAudioPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, isTranscribing, transcriptionStatus, loadVideo, setBackendUrl, setTranscription, setTranscriptionModel, setTranscribing, selectedWordIndices, addCutRange, addMuteRange, addGainRange, } = useEditorStore(); const [activePanel, setActivePanel] = useState(null); const [whisperModel, setWhisperModel] = useState('base'); const [cutMode, setCutMode] = useState(false); const [muteMode, setMuteMode] = useState(false); const [gainMode, setGainMode] = useState(false); const [gainModeDb, setGainModeDb] = useState(3); const [showReprocessConfirm, setShowReprocessConfirm] = useState(false); const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false); const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise) | null>(null); const [lastSavedSignature, setLastSavedSignature] = useState(null); const projectSignature = useMemo(() => { if (!videoPath) return null; return JSON.stringify({ videoPath, exportedAudioPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, }); }, [ videoPath, exportedAudioPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, ]); const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature; const loadProjectFromData = (data: any) => { useEditorStore.getState().loadProject(data); const loadedSignature = JSON.stringify({ videoPath: data.videoPath, exportedAudioPath: data.exportedAudioPath ?? null, words: data.words || [], segments: data.segments || [], deletedRanges: data.deletedRanges || [], cutRanges: data.cutRanges || [], muteRanges: data.muteRanges || [], gainRanges: data.gainRanges || [], globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0, silenceTrimGroups: data.silenceTrimGroups || [], transcriptionModel: data.transcriptionModel ?? null, language: data.language || '', }); setLastSavedSignature(loadedSignature); }; const runGuarded = async (action: () => Promise) => { if (!hasUnsavedChanges) { await action(); return; } setPendingProceedAction(() => action); setShowUnsavedPrompt(true); }; useKeyboardShortcuts(); // Handle Escape key to exit timeline zone modes useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setCutMode(false); setMuteMode(false); setGainMode(false); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); useEffect(() => { window.electronAPI!.getBackendUrl().then(setBackendUrl); }, [setBackendUrl]); useEffect(() => { if (videoPath) return; const savedPath = sessionStorage.getItem(LAST_MEDIA_PATH_KEY); if (savedPath) { loadVideo(savedPath); } }, [videoPath, loadVideo]); useEffect(() => { if (videoPath) { sessionStorage.setItem(LAST_MEDIA_PATH_KEY, videoPath); return; } sessionStorage.removeItem(LAST_MEDIA_PATH_KEY); }, [videoPath]); const handleLoadProject = async () => { await runGuarded(async () => { try { const projectPath = await window.electronAPI!.openProject(); if (!projectPath) return; const content = await window.electronAPI!.readFile(projectPath); const data = JSON.parse(content); loadProjectFromData(data); } catch (err) { console.error('Failed to load project:', err); alert(`Failed to load project: ${err}`); } }); }; const handleSaveProject = async (): Promise => { try { const savePath = await window.electronAPI!.saveProject(); if (!savePath) return false; const data = useEditorStore.getState().saveProject(); const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`; await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2)); if (projectSignature) { setLastSavedSignature(projectSignature); } return true; } catch (err) { console.error('Failed to save project:', err); alert(`Failed to save project: ${err}`); return false; } }; const handleOpenFile = async () => { await runGuarded(async () => { const path = await window.electronAPI!.openFile(); if (path) { setLastSavedSignature(null); loadVideo(path); await transcribeVideo(path); } }); }; const handleNewProject = () => { runGuarded(async () => { useEditorStore.getState().reset(); setLastSavedSignature(null); setActivePanel(null); setCutMode(false); setMuteMode(false); setGainMode(false); }); }; const transcribeVideo = async (path: string) => { setTranscribing(true, 0, 'Checking model...'); try { // Step 1: ensure model is downloaded (may take a while on first run) const MODEL_SIZES: Record = { 'tiny': '~75 MB', 'tiny.en': '~75 MB', 'base': '~140 MB', 'base.en': '~140 MB', 'small': '~460 MB', 'small.en': '~460 MB', 'medium': '~1.5 GB', 'medium.en': '~1.5 GB', 'large': '~2.9 GB', 'large-v2': '~2.9 GB', 'large-v3': '~2.9 GB', 'large-v3-turbo': '~1.6 GB', 'distil-large-v3': '~1.5 GB', 'distil-medium.en': '~750 MB', 'distil-small.en': '~190 MB', }; const modelLabel = MODEL_SIZES[whisperModel] ?? 'unknown size'; setTranscribing(true, 5, `Downloading ${whisperModel} model (${modelLabel})...`); await window.electronAPI.ensureModel(whisperModel); // Step 2: run transcription setTranscribing(true, 20, 'Transcribing audio...'); const data = await window.electronAPI.transcribe(path, whisperModel); setTranscription(data); setTranscriptionModel(whisperModel); } catch (err) { console.error('Transcription error:', err); alert(`Transcription failed. Check the console for details.\n\n${err}`); } finally { setTranscribing(false); } }; const handleReprocessProject = async () => { if (!videoPath) return; await runGuarded(async () => { setShowReprocessConfirm(true); }); }; const confirmReprocessProject = async () => { if (!videoPath) return; setShowReprocessConfirm(false); await transcribeVideo(videoPath); }; const handleUnsavedSaveAndContinue = async () => { const action = pendingProceedAction; if (!action) { setShowUnsavedPrompt(false); return; } const didSave = await handleSaveProject(); if (!didSave) return; setShowUnsavedPrompt(false); setPendingProceedAction(null); await action(); }; const handleUnsavedDiscardAndContinue = async () => { const action = pendingProceedAction; setShowUnsavedPrompt(false); setPendingProceedAction(null); if (action) { await action(); } }; const handleUnsavedCancel = () => { setShowUnsavedPrompt(false); setPendingProceedAction(null); }; const togglePanel = (panel: Panel) => { setActivePanel((prev) => (prev === panel ? null : panel)); }; const handleCut = () => { if (selectedWordIndices.length > 0) { // If words are selected, apply cut immediately const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addCutRange(startTime, endTime); } else { // Toggle cut mode setCutMode(!cutMode); setMuteMode(false); // Exit mute mode setGainMode(false); // Exit gain mode } }; const handleMute = () => { if (selectedWordIndices.length > 0) { // If words are selected, apply mute immediately const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startTime = words[sorted[0]].start; const endTime = words[sorted[sorted.length - 1]].end; addMuteRange(startTime, endTime); } else { // Toggle mute mode setMuteMode(!muteMode); setCutMode(false); // Exit cut mode setGainMode(false); // Exit gain mode } }; const handleGain = () => { 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; addGainRange(startTime, endTime, gainModeDb); } else { setGainMode(!gainMode); setCutMode(false); setMuteMode(false); } }; if (!videoPath) { return (

TalkEdit

Offline AI-powered video editor.

{/* Whisper model selector */}

For noisy/YouTube videos use large-v3 or large-v3-turbo. English-only models are ~10% faster and more accurate for English content.

); } return (
{/* Top bar */}
{videoPath.split(/[\\/]/).pop()} {transcriptionModel && ( Model: {transcriptionModel} )}
} label="New" onClick={handleNewProject} /> } label="Open" onClick={handleOpenFile} /> } label="Save" onClick={handleSaveProject} disabled={words.length === 0} /> } label="Load" onClick={handleLoadProject} /> } label="Cut" onClick={handleCut} active={cutMode} /> } label="Mute" onClick={handleMute} active={muteMode} />
} label="Gain Zone" onClick={handleGain} active={gainMode} /> setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))} 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="Gain dB for new gain zones" />
} label="Zones" active={activePanel === 'zones'} onClick={() => togglePanel('zones')} disabled={!videoPath} /> PA} label="Pause Trim" active={activePanel === 'silence'} onClick={() => togglePanel('silence')} disabled={!videoPath} />
} label="AI" active={activePanel === 'ai'} onClick={() => togglePanel('ai')} disabled={words.length === 0} /> } label="Export" active={activePanel === 'export'} onClick={() => togglePanel('export')} disabled={words.length === 0} /> } label="Settings" active={activePanel === 'settings'} onClick={() => togglePanel('settings')} />
{/* Main content */}
{/* Left: video + transcript */}
{/* Video player */}
{/* Transcript */}
{isTranscribing ? (
{/* Animated waveform */}
{[35, 60, 45, 80, 55, 70, 40, 65, 50, 75, 40, 58].map((h, i) => (
))}

Processing audio

{transcriptionStatus || 'Please wait...'}

) : words.length > 0 ? ( ) : (
No transcript yet
)}
{/* Waveform timeline */}
{/* Right panel (AI / Export / Settings) */} {activePanel && (
{activePanel === 'zones' && ( )} {activePanel === 'silence' && } {activePanel === 'ai' && } {activePanel === 'export' && } {activePanel === 'settings' && }
)}
{import.meta.env.DEV && } {showReprocessConfirm && (
setShowReprocessConfirm(false)} >
e.stopPropagation()} >

Reprocess transcript?

This will reprocess the current file with {whisperModel} and replace the current transcript words and timings.

)} {showUnsavedPrompt && (
e.stopPropagation()} >

Save changes first?

There are unsaved changes in this project. Save before continuing?

)}
); } function ToolbarButton({ icon, label, active, onClick, disabled, }: { icon: React.ReactNode; label: string; active?: boolean; onClick: () => void; disabled?: boolean; }) { return ( ); }