2026-03-03 06:31:04 -05:00
|
|
|
import { useEffect, useState, 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';
|
2026-03-30 18:36:41 -06:00
|
|
|
import DevPanel from './components/DevPanel';
|
2026-04-03 12:05:44 -06:00
|
|
|
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
2026-03-03 06:31:04 -05:00
|
|
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
|
|
|
|
import {
|
|
|
|
|
Film,
|
|
|
|
|
FolderOpen,
|
|
|
|
|
Settings,
|
|
|
|
|
Sparkles,
|
|
|
|
|
Download,
|
|
|
|
|
FolderSearch,
|
|
|
|
|
FileInput,
|
2026-03-30 18:36:41 -06:00
|
|
|
Save,
|
2026-04-03 11:14:31 -06:00
|
|
|
Scissors,
|
|
|
|
|
VolumeX,
|
2026-04-11 19:42:30 -06:00
|
|
|
FilePlus2,
|
|
|
|
|
RefreshCw,
|
2026-03-03 06:31:04 -05:00
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
const IS_ELECTRON = !!window.electronAPI;
|
2026-04-09 01:36:28 -06:00
|
|
|
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
2026-03-03 06:31:04 -05:00
|
|
|
|
2026-04-03 12:05:44 -06:00
|
|
|
type Panel = 'ai' | 'settings' | 'export' | 'silence' | null;
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
export default function App() {
|
|
|
|
|
const {
|
|
|
|
|
videoPath,
|
|
|
|
|
words,
|
2026-04-11 19:42:30 -06:00
|
|
|
transcriptionModel,
|
2026-03-03 06:31:04 -05:00
|
|
|
isTranscribing,
|
|
|
|
|
transcriptionProgress,
|
2026-03-26 00:58:57 -06:00
|
|
|
transcriptionStatus,
|
2026-03-03 06:31:04 -05:00
|
|
|
loadVideo,
|
|
|
|
|
setBackendUrl,
|
|
|
|
|
setTranscription,
|
2026-04-11 19:42:30 -06:00
|
|
|
setTranscriptionModel,
|
2026-03-03 06:31:04 -05:00
|
|
|
setTranscribing,
|
|
|
|
|
backendUrl,
|
2026-04-03 11:14:31 -06:00
|
|
|
selectedWordIndices,
|
|
|
|
|
addCutRange,
|
|
|
|
|
addMuteRange,
|
2026-03-03 06:31:04 -05:00
|
|
|
} = useEditorStore();
|
|
|
|
|
|
|
|
|
|
const [activePanel, setActivePanel] = useState<Panel>(null);
|
|
|
|
|
const [manualPath, setManualPath] = useState('');
|
|
|
|
|
const [whisperModel, setWhisperModel] = useState('base');
|
2026-04-03 11:14:31 -06:00
|
|
|
const [cutMode, setCutMode] = useState(false);
|
|
|
|
|
const [muteMode, setMuteMode] = useState(false);
|
2026-04-11 19:42:30 -06:00
|
|
|
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
|
2026-03-03 06:31:04 -05:00
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
useKeyboardShortcuts();
|
|
|
|
|
|
2026-04-03 11:14:31 -06:00
|
|
|
// Handle Escape key to exit cut/mute modes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
setCutMode(false);
|
|
|
|
|
setMuteMode(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (IS_ELECTRON) {
|
|
|
|
|
window.electronAPI!.getBackendUrl().then(setBackendUrl);
|
|
|
|
|
}
|
2026-03-30 18:36:41 -06:00
|
|
|
// In Tauri on Linux/WebKit2GTK the ipc:// custom protocol is blocked by
|
|
|
|
|
// WebKit internals; postMessage fallback works but logs noisy warnings.
|
|
|
|
|
// The backend URL is fixed at 127.0.0.1:8000 so we rely on the store default.
|
2026-03-03 06:31:04 -05:00
|
|
|
}, [setBackendUrl]);
|
|
|
|
|
|
2026-04-09 01:36:28 -06:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!IS_ELECTRON || videoPath) return;
|
|
|
|
|
const savedPath = sessionStorage.getItem(LAST_MEDIA_PATH_KEY);
|
|
|
|
|
if (savedPath) {
|
|
|
|
|
loadVideo(savedPath);
|
|
|
|
|
}
|
|
|
|
|
}, [videoPath, loadVideo]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!IS_ELECTRON) return;
|
|
|
|
|
if (videoPath) {
|
|
|
|
|
sessionStorage.setItem(LAST_MEDIA_PATH_KEY, videoPath);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
sessionStorage.removeItem(LAST_MEDIA_PATH_KEY);
|
|
|
|
|
}, [videoPath]);
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
const handleLoadProject = async () => {
|
|
|
|
|
if (!IS_ELECTRON) return;
|
|
|
|
|
try {
|
|
|
|
|
const projectPath = await window.electronAPI!.openProject();
|
|
|
|
|
if (!projectPath) return;
|
|
|
|
|
const content = await window.electronAPI!.readFile(projectPath);
|
|
|
|
|
const data = JSON.parse(content);
|
|
|
|
|
useEditorStore.getState().loadProject(data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to load project:', err);
|
|
|
|
|
alert(`Failed to load project: ${err}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-30 18:36:41 -06:00
|
|
|
const handleSaveProject = async () => {
|
|
|
|
|
if (!IS_ELECTRON) return;
|
|
|
|
|
try {
|
|
|
|
|
const savePath = await window.electronAPI!.saveProject();
|
|
|
|
|
if (!savePath) return;
|
|
|
|
|
const data = useEditorStore.getState().saveProject();
|
|
|
|
|
const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
|
|
|
|
|
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to save project:', err);
|
|
|
|
|
alert(`Failed to save project: ${err}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
const handleOpenFile = async () => {
|
|
|
|
|
if (IS_ELECTRON) {
|
|
|
|
|
const path = await window.electronAPI!.openFile();
|
|
|
|
|
if (path) {
|
|
|
|
|
loadVideo(path);
|
|
|
|
|
await transcribeVideo(path);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Browser: use the manual path input
|
|
|
|
|
const path = manualPath.trim();
|
|
|
|
|
if (path) {
|
|
|
|
|
loadVideo(path);
|
|
|
|
|
await transcribeVideo(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-11 19:42:30 -06:00
|
|
|
const handleNewProject = () => {
|
|
|
|
|
const shouldReset = window.confirm('Start a new project? Unsaved changes in the current session will be lost.');
|
|
|
|
|
if (!shouldReset) return;
|
|
|
|
|
|
|
|
|
|
useEditorStore.getState().reset();
|
|
|
|
|
setActivePanel(null);
|
|
|
|
|
setManualPath('');
|
|
|
|
|
setCutMode(false);
|
|
|
|
|
setMuteMode(false);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
const handleManualSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const path = manualPath.trim();
|
|
|
|
|
if (!path) return;
|
|
|
|
|
loadVideo(path);
|
|
|
|
|
await transcribeVideo(path);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const transcribeVideo = async (path: string) => {
|
2026-03-26 00:58:57 -06:00
|
|
|
setTranscribing(true, 0, 'Checking model...');
|
2026-03-03 06:31:04 -05:00
|
|
|
try {
|
2026-03-26 00:58:57 -06:00
|
|
|
if (!window.electronAPI?.transcribe) {
|
|
|
|
|
throw new Error('Transcription not available');
|
|
|
|
|
}
|
|
|
|
|
// Step 1: ensure model is downloaded (may take a while on first run)
|
2026-04-03 10:25:48 -06:00
|
|
|
const MODEL_SIZES: Record<string, string> = {
|
|
|
|
|
'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';
|
2026-03-26 00:58:57 -06:00
|
|
|
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);
|
2026-03-03 06:31:04 -05:00
|
|
|
setTranscription(data);
|
2026-04-11 19:42:30 -06:00
|
|
|
setTranscriptionModel(whisperModel);
|
2026-03-03 06:31:04 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Transcription error:', err);
|
|
|
|
|
alert(`Transcription failed. Check the console for details.\n\n${err}`);
|
|
|
|
|
} finally {
|
|
|
|
|
setTranscribing(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-11 19:42:30 -06:00
|
|
|
const handleReprocessProject = async () => {
|
|
|
|
|
if (!videoPath) return;
|
|
|
|
|
if (!window.electronAPI?.transcribe) {
|
|
|
|
|
alert('Reprocessing is only available in desktop mode.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setShowReprocessConfirm(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const confirmReprocessProject = async () => {
|
|
|
|
|
if (!videoPath) return;
|
|
|
|
|
setShowReprocessConfirm(false);
|
|
|
|
|
await transcribeVideo(videoPath);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-03 12:05:44 -06:00
|
|
|
const togglePanel = (panel: Panel) => {
|
|
|
|
|
setActivePanel((prev) => (prev === panel ? null : panel));
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-03 11:14:31 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
if (!videoPath) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-screen flex flex-col items-center justify-center gap-8 bg-editor-bg px-6">
|
|
|
|
|
<div className="flex flex-col items-center gap-3">
|
|
|
|
|
<Film className="w-14 h-14 text-editor-accent opacity-80" />
|
2026-03-25 01:41:40 -06:00
|
|
|
<h1 className="text-3xl font-semibold tracking-tight">TalkEdit</h1>
|
2026-03-03 06:31:04 -05:00
|
|
|
<p className="text-editor-text-muted text-sm max-w-sm text-center">
|
2026-03-25 01:41:40 -06:00
|
|
|
Offline AI-powered video editor.
|
2026-03-03 06:31:04 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Whisper model selector */}
|
|
|
|
|
<div className="flex items-center gap-3">
|
2026-04-03 10:25:48 -06:00
|
|
|
<label className="text-xs text-editor-text-muted whitespace-nowrap">Model:</label>
|
2026-03-03 06:31:04 -05:00
|
|
|
<select
|
|
|
|
|
value={whisperModel}
|
|
|
|
|
onChange={(e) => setWhisperModel(e.target.value)}
|
2026-04-03 10:34:04 -06:00
|
|
|
className="px-3 py-1.5 bg-editor-surface border border-editor-border rounded-lg text-xs text-black focus:outline-none focus:border-editor-accent"
|
2026-03-03 06:31:04 -05:00
|
|
|
>
|
2026-04-03 10:25:48 -06:00
|
|
|
<optgroup label="Multilingual (any language)">
|
|
|
|
|
<option value="tiny">tiny — ~75 MB · fastest, low accuracy</option>
|
|
|
|
|
<option value="base">base — ~140 MB · fast, decent accuracy</option>
|
|
|
|
|
<option value="small">small — ~460 MB · good balance</option>
|
|
|
|
|
<option value="medium">medium — ~1.5 GB · better accuracy</option>
|
|
|
|
|
<option value="large-v2">large-v2 — ~2.9 GB · high accuracy</option>
|
|
|
|
|
<option value="large-v3">large-v3 — ~2.9 GB · best overall ★</option>
|
|
|
|
|
<option value="large-v3-turbo">large-v3-turbo — ~1.6 GB · fast + accurate ★</option>
|
|
|
|
|
<option value="distil-large-v3">distil-large-v3 — ~1.5 GB · fast, near large-v3 quality</option>
|
|
|
|
|
</optgroup>
|
|
|
|
|
<optgroup label="English-only (faster & more accurate for English)">
|
|
|
|
|
<option value="tiny.en">tiny.en — ~75 MB · fastest English</option>
|
|
|
|
|
<option value="base.en">base.en — ~140 MB · fast English</option>
|
|
|
|
|
<option value="small.en">small.en — ~460 MB · good English</option>
|
|
|
|
|
<option value="medium.en">medium.en — ~1.5 GB · great English</option>
|
|
|
|
|
<option value="distil-small.en">distil-small.en — ~190 MB · fast English ★</option>
|
|
|
|
|
<option value="distil-medium.en">distil-medium.en — ~750 MB · best fast English ★</option>
|
|
|
|
|
</optgroup>
|
2026-03-03 06:31:04 -05:00
|
|
|
</select>
|
|
|
|
|
</div>
|
2026-04-03 10:25:48 -06:00
|
|
|
<p className="text-[11px] text-editor-text-muted text-center max-w-sm">
|
|
|
|
|
For noisy/YouTube videos use <span className="text-white">large-v3</span> or <span className="text-white">large-v3-turbo</span>.
|
|
|
|
|
English-only models are ~10% faster and more accurate for English content.
|
|
|
|
|
</p>
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
{IS_ELECTRON ? (
|
|
|
|
|
<div className="flex flex-col items-center gap-3">
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleOpenFile}
|
|
|
|
|
className="flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white font-medium transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<FolderOpen className="w-5 h-5" />
|
|
|
|
|
Open Video File
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleLoadProject}
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<FileInput className="w-4 h-4" />
|
|
|
|
|
Load Project (.aive)
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
/* Browser: manual path input */
|
|
|
|
|
<div className="w-full max-w-lg space-y-3">
|
|
|
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-editor-warning/10 border border-editor-warning/30 rounded-lg">
|
|
|
|
|
<span className="text-editor-warning text-xs">
|
|
|
|
|
Running in browser — paste the full path to your video file below.
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<form onSubmit={handleManualSubmit} className="flex gap-2">
|
|
|
|
|
<div className="flex-1 relative">
|
|
|
|
|
<FolderSearch className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-editor-text-muted pointer-events-none" />
|
|
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="text"
|
|
|
|
|
value={manualPath}
|
|
|
|
|
onChange={(e) => setManualPath(e.target.value)}
|
|
|
|
|
placeholder="C:\Videos\my-video.mp4"
|
|
|
|
|
className="w-full pl-9 pr-3 py-2.5 bg-editor-surface border border-editor-border rounded-lg text-sm text-editor-text placeholder:text-editor-text-muted/40 focus:outline-none focus:border-editor-accent"
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={!manualPath.trim()}
|
|
|
|
|
className="flex items-center gap-2 px-5 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm text-white font-medium transition-colors whitespace-nowrap"
|
|
|
|
|
>
|
|
|
|
|
<Film className="w-4 h-4" />
|
|
|
|
|
Load & Transcribe
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
<p className="text-[11px] text-editor-text-muted text-center">
|
|
|
|
|
Supported: MP4, AVI, MOV, MKV, WebM, M4A
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
2026-04-11 19:42:30 -06:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-03-03 06:31:04 -05:00
|
|
|
</div>
|
2026-04-11 19:42:30 -06:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<FilePlus2 className="w-4 h-4" />}
|
|
|
|
|
label="New"
|
|
|
|
|
onClick={handleNewProject}
|
|
|
|
|
/>
|
2026-03-03 06:31:04 -05:00
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<FolderOpen className="w-4 h-4" />}
|
|
|
|
|
label="Open"
|
|
|
|
|
onClick={IS_ELECTRON ? handleOpenFile : () => useEditorStore.getState().reset()}
|
|
|
|
|
/>
|
2026-03-30 18:36:41 -06:00
|
|
|
{IS_ELECTRON && (
|
|
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<Save className="w-4 h-4" />}
|
|
|
|
|
label="Save"
|
|
|
|
|
onClick={handleSaveProject}
|
|
|
|
|
disabled={words.length === 0}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{IS_ELECTRON && (
|
|
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<FileInput className="w-4 h-4" />}
|
|
|
|
|
label="Load"
|
|
|
|
|
onClick={handleLoadProject}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-04-03 11:14:31 -06:00
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<Scissors className="w-4 h-4" />}
|
|
|
|
|
label="Cut"
|
|
|
|
|
onClick={handleCut}
|
|
|
|
|
active={cutMode}
|
|
|
|
|
/>
|
|
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<VolumeX className="w-4 h-4" />}
|
|
|
|
|
label="Mute"
|
|
|
|
|
onClick={handleMute}
|
|
|
|
|
active={muteMode}
|
|
|
|
|
/>
|
2026-04-03 12:05:44 -06:00
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<span className="text-[10px] font-semibold">PA</span>}
|
|
|
|
|
label="Pause Trim"
|
|
|
|
|
active={activePanel === 'silence'}
|
|
|
|
|
onClick={() => togglePanel('silence')}
|
|
|
|
|
disabled={!videoPath}
|
|
|
|
|
/>
|
2026-04-11 19:42:30 -06:00
|
|
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
|
|
|
|
|
<select
|
|
|
|
|
value={whisperModel}
|
|
|
|
|
onChange={(e) => setWhisperModel(e.target.value)}
|
|
|
|
|
className="bg-transparent text-xs text-editor-text focus:outline-none"
|
|
|
|
|
title="Transcription model"
|
|
|
|
|
>
|
|
|
|
|
<optgroup label="Multilingual">
|
|
|
|
|
<option value="tiny">tiny</option>
|
|
|
|
|
<option value="base">base</option>
|
|
|
|
|
<option value="small">small</option>
|
|
|
|
|
<option value="medium">medium</option>
|
|
|
|
|
<option value="large-v2">large-v2</option>
|
|
|
|
|
<option value="large-v3">large-v3</option>
|
|
|
|
|
<option value="large-v3-turbo">large-v3-turbo</option>
|
|
|
|
|
<option value="distil-large-v3">distil-large-v3</option>
|
|
|
|
|
</optgroup>
|
|
|
|
|
<optgroup label="English">
|
|
|
|
|
<option value="tiny.en">tiny.en</option>
|
|
|
|
|
<option value="base.en">base.en</option>
|
|
|
|
|
<option value="small.en">small.en</option>
|
|
|
|
|
<option value="medium.en">medium.en</option>
|
|
|
|
|
<option value="distil-small.en">distil-small.en</option>
|
|
|
|
|
<option value="distil-medium.en">distil-medium.en</option>
|
|
|
|
|
</optgroup>
|
|
|
|
|
</select>
|
|
|
|
|
<button
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} />
|
|
|
|
|
Reprocess
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-03-03 06:31:04 -05:00
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<Sparkles className="w-4 h-4" />}
|
|
|
|
|
label="AI"
|
|
|
|
|
active={activePanel === 'ai'}
|
|
|
|
|
onClick={() => togglePanel('ai')}
|
|
|
|
|
disabled={words.length === 0}
|
|
|
|
|
/>
|
|
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<Download className="w-4 h-4" />}
|
|
|
|
|
label="Export"
|
|
|
|
|
active={activePanel === 'export'}
|
|
|
|
|
onClick={() => togglePanel('export')}
|
|
|
|
|
disabled={words.length === 0}
|
|
|
|
|
/>
|
|
|
|
|
<ToolbarButton
|
|
|
|
|
icon={<Settings className="w-4 h-4" />}
|
|
|
|
|
label="Settings"
|
|
|
|
|
active={activePanel === 'settings'}
|
|
|
|
|
onClick={() => togglePanel('settings')}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{/* Main content */}
|
|
|
|
|
<div className="flex-1 flex overflow-hidden">
|
|
|
|
|
{/* Left: video + transcript */}
|
|
|
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
|
|
|
<div className="flex-1 flex min-h-0">
|
|
|
|
|
{/* Video player */}
|
|
|
|
|
<div className="w-1/2 p-3 flex items-center justify-center bg-black/20">
|
|
|
|
|
<VideoPlayer />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Transcript */}
|
|
|
|
|
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
|
|
|
|
|
{isTranscribing ? (
|
2026-03-26 00:58:57 -06:00
|
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-5">
|
|
|
|
|
{/* Animated waveform */}
|
|
|
|
|
<div className="flex items-end gap-[3px] h-10">
|
|
|
|
|
{[35, 60, 45, 80, 55, 70, 40, 65, 50, 75, 40, 58].map((h, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className="w-[5px] rounded-full bg-editor-accent wave-bar"
|
|
|
|
|
style={{
|
|
|
|
|
height: `${h}%`,
|
|
|
|
|
animationDelay: `${i * 75}ms`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-center space-y-1">
|
|
|
|
|
<p className="text-sm font-medium text-editor-text">Processing audio</p>
|
|
|
|
|
<p className="text-xs text-editor-text-muted">{transcriptionStatus || 'Please wait...'}</p>
|
|
|
|
|
</div>
|
2026-03-03 06:31:04 -05:00
|
|
|
</div>
|
|
|
|
|
) : words.length > 0 ? (
|
|
|
|
|
<TranscriptEditor />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex-1 flex items-center justify-center text-editor-text-muted text-sm">
|
|
|
|
|
No transcript yet
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Waveform timeline */}
|
|
|
|
|
<div className="h-32 border-t border-editor-border shrink-0">
|
2026-04-03 11:14:31 -06:00
|
|
|
<WaveformTimeline cutMode={cutMode} muteMode={muteMode} />
|
2026-03-03 06:31:04 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Right panel (AI / Export / Settings) */}
|
|
|
|
|
{activePanel && (
|
|
|
|
|
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
|
2026-04-03 12:05:44 -06:00
|
|
|
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
2026-03-03 06:31:04 -05:00
|
|
|
{activePanel === 'ai' && <AIPanel />}
|
|
|
|
|
{activePanel === 'export' && <ExportDialog />}
|
|
|
|
|
{activePanel === 'settings' && <SettingsPanel />}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-30 18:36:41 -06:00
|
|
|
{import.meta.env.DEV && <DevPanel />}
|
2026-04-11 19:42:30 -06:00
|
|
|
|
|
|
|
|
{showReprocessConfirm && (
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
|
|
|
|
|
onClick={() => setShowReprocessConfirm(false)}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<h3 className="text-sm font-semibold">Reprocess transcript?</h3>
|
|
|
|
|
<p className="text-xs text-editor-text-muted leading-relaxed">
|
|
|
|
|
This will reprocess the current file with <span className="text-editor-text font-medium">{whisperModel}</span> and replace the current transcript words and timings.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowReprocessConfirm(false)}
|
|
|
|
|
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={confirmReprocessProject}
|
|
|
|
|
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
|
|
|
|
|
>
|
|
|
|
|
Reprocess Now
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-03 06:31:04 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ToolbarButton({
|
|
|
|
|
icon,
|
|
|
|
|
label,
|
|
|
|
|
active,
|
|
|
|
|
onClick,
|
|
|
|
|
disabled,
|
|
|
|
|
}: {
|
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
label: string;
|
|
|
|
|
active?: boolean;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
title={label}
|
|
|
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
|
|
|
|
active
|
|
|
|
|
? 'bg-editor-accent text-white'
|
|
|
|
|
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
|
|
|
|
|
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
|
|
|
|
>
|
|
|
|
|
{icon}
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|