import { useEffect, useState, useMemo, useCallback, useRef } 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 MarkersPanel from './components/MarkersPanel'; import SilenceTrimmerPanel from './components/SilenceTrimmerPanel'; import ZoneEditor from './components/ZoneEditor'; import BackgroundMusicPanel from './components/BackgroundMusicPanel'; import AppendClipPanel from './components/AppendClipPanel'; import LicenseDialog from './components/LicenseDialog'; import HelpContent from './components/HelpContent'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useLicenseStore } from './store/licenseStore'; import { Film, FolderOpen, Settings, Sparkles, Download, FileInput, Save, Scissors, VolumeX, SlidersHorizontal, Gauge, FilePlus2, RefreshCw, Grid3x3, MapPin, Music, ListVideo, Clock, AlertTriangle, HelpCircle, } from 'lucide-react'; const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath'; type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | 'help' | null; export default function App() { const { projectFilePath, videoPath, exportedAudioPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, isTranscribing, transcriptionStatus, markInTime, markOutTime, loadVideo, setProjectFilePath, setBackendUrl, clearMarkRange, setTranscription, setTranscriptionModel, setTranscribing, selectedWordIndices, addCutRange, addMuteRange, addGainRange, addSpeedRange, } = useEditorStore(); const [activePanel, setActivePanel] = useState(null); const [projectName, setProjectName] = useState(null); const [splitRatio, setSplitRatio] = useState(() => { try { return Number(localStorage.getItem('talkedit:splitRatio')) || 0.5; } catch { return 0.5; } }); const splitRef = useRef(null); const isDraggingSplit = useRef(false); const startSplitDrag = useCallback((e: React.MouseEvent) => { e.preventDefault(); isDraggingSplit.current = true; const container = splitRef.current?.parentElement; if (!container) return; const rect = container.getBoundingClientRect(); const onMove = (me: MouseEvent) => { if (!isDraggingSplit.current) return; const pct = (me.clientX - rect.left) / rect.width; const clamped = Math.max(0.15, Math.min(0.85, pct)); setSplitRatio(clamped); localStorage.setItem('talkedit:splitRatio', String(clamped)); }; const onUp = () => { isDraggingSplit.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }, []); // Draggable right sidebar const [sidebarWidth, setSidebarWidth] = useState(() => { try { return Number(localStorage.getItem('talkedit:sidebarWidth')) || 320; } catch { return 320; } }); const isDraggingSidebar = useRef(false); const startSidebarDrag = useCallback((e: React.MouseEvent) => { e.preventDefault(); isDraggingSidebar.current = true; const container = document.querySelector('.main-content') as HTMLElement; if (!container) return; const rect = container.getBoundingClientRect(); const onMove = (me: MouseEvent) => { if (!isDraggingSidebar.current) return; const w = rect.right - me.clientX; const clamped = Math.max(180, Math.min(600, w)); setSidebarWidth(clamped); localStorage.setItem('talkedit:sidebarWidth', String(clamped)); }; const onUp = () => { isDraggingSidebar.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }, []); 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 [showWelcomeOverlay, setShowWelcomeOverlay] = useState(() => { return localStorage.getItem('talkedit:welcomeDismissed') !== 'true'; }); const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false); const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise) | null>(null); const [lastSavedSignature, setLastSavedSignature] = useState(null); const [showFileMenu, setShowFileMenu] = useState(false); const canEdit = useLicenseStore((s) => s.canEdit); const licenseStatus = useLicenseStore((s) => s.status); const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog); const projectSignature = useMemo(() => { if (!videoPath) return null; return JSON.stringify({ videoPath, exportedAudioPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, }); }, [ videoPath, exportedAudioPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, 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 || [], cutRanges: [...(data.cutRanges || []), ...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end }))], muteRanges: data.muteRanges || [], gainRanges: data.gainRanges || [], speedRanges: data.speedRanges || [], 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(); useEffect(() => { useLicenseStore.getState().checkStatus(); }, []); // Handle Escape key to exit timeline zone modes and close menus useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setCutMode(false); setMuteMode(false); setGainMode(false); setSpeedMode(false); setShowFileMenu(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); setProjectFilePath(projectPath); loadProjectFromData(data); setProjectName(projectPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null); } catch (err) { console.error('Failed to load project:', err); alert(`Failed to load project: ${err}`); } }); }; const writeProjectToPath = async (path: string): Promise => { try { const data = useEditorStore.getState().saveProject(); const resolvedPath = path.endsWith('.aive') ? path : `${path}.aive`; await window.electronAPI!.writeFile(resolvedPath, JSON.stringify(data, null, 2)); setProjectFilePath(resolvedPath); setProjectName(resolvedPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null); if (projectSignature) { setLastSavedSignature(projectSignature); } return true; } catch (err) { console.error('Failed to save project:', err); alert(`Failed to save project: ${err}`); return false; } }; const handleSaveProjectAs = async (): Promise => { const savePath = await window.electronAPI!.saveProject(); if (!savePath) return false; return writeProjectToPath(savePath); }; const handleSaveProject = async (): Promise => { if (!projectFilePath) { return handleSaveProjectAs(); } return writeProjectToPath(projectFilePath); }; const handleOpenFile = async () => { await runGuarded(async () => { const path = await window.electronAPI!.openFile(); if (path) { setLastSavedSignature(null); setProjectFilePath(null); setProjectName(null); loadVideo(path); if (canEdit) { await transcribeVideo(path); } } }); }; const handleNewProject = () => { runGuarded(async () => { useEditorStore.getState().reset(); setLastSavedSignature(null); setActivePanel(null); setProjectFilePath(null); setProjectName(null); setCutMode(false); setMuteMode(false); setGainMode(false); setSpeedMode(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 (markInTime !== null && markOutTime !== null) { const startTime = Math.min(markInTime, markOutTime); const endTime = Math.max(markInTime, markOutTime); if (endTime - startTime >= 0.01) { addCutRange(startTime, endTime); setActivePanel('zones'); } clearMarkRange(); return; } 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 setSpeedMode(false); // Exit speed mode } }; const handleMute = () => { if (markInTime !== null && markOutTime !== null) { const startTime = Math.min(markInTime, markOutTime); const endTime = Math.max(markInTime, markOutTime); if (endTime - startTime >= 0.01) { addMuteRange(startTime, endTime); setActivePanel('zones'); } clearMarkRange(); return; } 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 setSpeedMode(false); // Exit speed mode } }; const handleGain = () => { if (markInTime !== null && markOutTime !== null) { const startTime = Math.min(markInTime, markOutTime); const endTime = Math.max(markInTime, markOutTime); if (endTime - startTime >= 0.01) { addGainRange(startTime, endTime, gainModeDb); setActivePanel('zones'); } clearMarkRange(); return; } 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); setSpeedMode(false); } }; const handleSpeed = () => { if (markInTime !== null && markOutTime !== null) { const startTime = Math.min(markInTime, markOutTime); const endTime = Math.max(markInTime, markOutTime); if (endTime - startTime >= 0.01) { addSpeedRange(startTime, endTime, speedModeValue); setActivePanel('zones'); } clearMarkRange(); return; } 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); } }; 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.

{licenseStatus?.tag === 'Trial' && (
Free trial: {licenseStatus.days_remaining} day{licenseStatus.days_remaining !== 1 ? 's' : ''} remaining
)} {licenseStatus?.tag === 'Expired' && (
Trial expired
)}
); } return (
{/* Top bar */}
} label="File" onClick={() => setShowFileMenu((p) => !p)} active={showFileMenu} /> {showFileMenu && ( <>
setShowFileMenu(false)} />
} label="New Project" title="Start a new empty project" onClick={() => { setShowFileMenu(false); handleNewProject(); }} /> } label="Open File" title="Open a video or audio file for transcription" onClick={() => { setShowFileMenu(false); handleOpenFile(); }} /> } label="Load Project" title="Open a saved .aive project file" onClick={() => { setShowFileMenu(false); handleLoadProject(); }} />
} label="Save" title="Save current project" onClick={() => { setShowFileMenu(false); handleSaveProject(); }} disabled={words.length === 0} /> } label="Save As" title="Save a copy of the current project" onClick={() => { setShowFileMenu(false); handleSaveProjectAs(); }} disabled={words.length === 0} />
)}
} label="Cut" onClick={handleCut} active={cutMode} disabled={!canEdit} title="Cut selected word range or mark in/out area — removes the segment from output" /> } label="Mute" onClick={handleMute} active={muteMode} disabled={!canEdit} title="Mute selected word range or mark in/out area — silences audio, keeps video" />
} label="Sound Gain" onClick={handleGain} active={gainMode} disabled={!canEdit} title="Add gain zone from selection or mark in/out — adjust volume up or down" /> 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="Volume adjustment in decibels for new gain zones — positive boosts, negative reduces" disabled={!canEdit} />
} label="Speed Adjust" onClick={handleSpeed} active={speedMode} disabled={!canEdit} title="Add speed zone from selection or mark in/out — change playback speed" /> 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 speed multiplier for new speed zones — 1x is normal, 2x is double speed" disabled={!canEdit} />
} label="Edit Zones" active={activePanel === 'zones'} onClick={() => togglePanel('zones')} disabled={!videoPath || !canEdit} title="Open zone editor panel — view and manage all cut, mute, gain, and speed zones" /> PA} label="Trim Silence" active={activePanel === 'silence'} onClick={() => togglePanel('silence')} disabled={!videoPath || !canEdit} title="Detect and remove silent pauses — batch-removes silence above a configurable threshold" /> } label="Chapter Marks" active={activePanel === 'markers'} onClick={() => togglePanel('markers')} disabled={!videoPath || !canEdit} title="Add and manage timeline markers — chapter points, key moments, YouTube timestamps" /> } label="Bkg. Music" active={activePanel === 'music'} onClick={() => togglePanel('music')} disabled={!videoPath || !canEdit} title="Add background music track with auto-ducking — music lowers when someone speaks" /> } label="Add Clips" active={activePanel === 'append'} onClick={() => togglePanel('append')} disabled={!videoPath || !canEdit} title="Append additional video clips — concatenate multiple files during export" />
} label="AI Tools" active={activePanel === 'ai'} onClick={() => togglePanel('ai')} disabled={words.length === 0 || !canEdit} title="AI filler detection, clip suggestions, and transcript analysis" /> } label="Export" active={activePanel === 'export'} onClick={() => togglePanel('export')} disabled={!videoPath} /> } label="Settings" active={activePanel === 'settings'} onClick={() => togglePanel('settings')} /> } label="Help" active={activePanel === 'help'} onClick={() => togglePanel('help')} title="View help and feature documentation" />
{/* Main content */}
{/* Left: video + transcript */}
{/* Video player */}
{/* Draggable divider */}
{/* Transcript */}
{videoPath && (
{projectName && ( {projectName} )} {videoPath.split(/[/\\]/).pop()} {words.length} words · {cutRanges.length} cuts · {muteRanges.length} mutes · {gainRanges.length} gains · {speedRanges.length} speeds {transcriptionModel && ( {transcriptionModel} )}
)} {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 && (
{/* Draggable sidebar divider */}
{activePanel === 'zones' && ( )} {activePanel === 'silence' && } {activePanel === 'markers' && } {activePanel === 'music' && } {activePanel === 'append' && } {activePanel === 'ai' && } {activePanel === 'export' && } {activePanel === 'settings' && } {activePanel === 'help' && }
)}
{import.meta.env.DEV && } {showWelcomeOverlay && (

Welcome to TalkEdit

The offline video editor for long-form content. No account. No subscription. Your data never leaves your machine.

1 Open a video — TalkEdit transcribes it into a word-level transcript.
2 Edit by selecting words — deleting words cuts the matching video. Like editing a doc.
3 Export your final cut — with captions, background music, AI cleanup, and more.

Press ? anytime to see shortcuts, or click Help in the toolbar.

)} {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, title, }: { icon: React.ReactNode; label: string; active?: boolean; onClick: () => void; disabled?: boolean; title?: string; }) { return ( ); } function DropdownItem({ icon, label, onClick, disabled, title, }: { icon: React.ReactNode; label: string; onClick: () => void; disabled?: boolean; title?: string; }) { return ( ); }