import React, { useState, useRef, useEffect, useCallback, Suspense, lazy } from 'react'; import './index.css'; import { useAppStore } from './store'; import SearchableSelect from './components/SearchableSelect'; import DirectionDialog from './components/DirectionDialog'; // Lazy-load heavy/conditional components so they don't bloat the initial bundle. const AudioTrimmer = lazy(() => import('./components/AudioTrimmer')); const Launchpad = lazy(() => import('./pages/Launchpad')); const CloneDesignTab = lazy(() => import('./pages/CloneDesignTab')); const DubTab = lazy(() => import('./pages/DubTab')); const Sidebar = lazy(() => import('./components/Sidebar')); const CompareModal = lazy(() => import('./components/CompareModal')); const Settings = lazy(() => import('./pages/Settings')); const VoiceProfile = lazy(() => import('./pages/VoiceProfile')); const BatchQueue = lazy(() => import('./pages/BatchQueue')); const ToolsPage = lazy(() => import('./pages/ToolsPage')); const SetupWizard = lazy(() => import('./pages/SetupWizard')); const KeyboardCheatsheet = lazy(() => import('./components/KeyboardCheatsheet')); const VoicePreview = lazy(() => import('./components/VoicePreview')); const LogsFooter = lazy(() => import('./components/LogsFooter')); const ProjectsPage = lazy(() => import('./pages/Projects')); const VoiceGallery = lazy(() => import('./pages/VoiceGallery')); const DonatePage = lazy(() => import('./pages/DonatePage')); const EnterprisePage = lazy(() => import('./pages/EnterprisePage')); const TranscriptionsPage = lazy(() => import('./pages/Transcriptions')); const StoriesEditor = lazy(() => import('./components/StoriesEditor')); import Header from './components/Header'; import NavRail from './components/NavRail'; import ErrorBoundary from './components/ErrorBoundary'; import FloatingPill from './components/FloatingPill'; import useRealtimeEvents from './hooks/useRealtimeEvents'; import { BootstrapSplash, useBootstrapStage } from './components/BootstrapSplash'; import './components/Misc.css'; import { askConfirm } from './utils/dialog'; import useRecording from './hooks/useRecording'; import useSegmentEditing from './hooks/useSegmentEditing'; import useAppData from './hooks/useAppData'; import useProfiles from './hooks/useProfiles'; import useTTS from './hooks/useTTS'; import useDubWorkflow from './hooks/useDubWorkflow'; const LazyFallback = () =>
Loading…
; import { Toaster, toast } from 'react-hot-toast'; import { POPULAR_LANGS, POPULAR_ISO, TAGS, CATEGORIES, PRESETS, CLONE_MAX_SECONDS, } from './utils/constants'; import { LANG_CODES } from './utils/languages'; import { formatTime } from './utils/format'; import { API, apiPost } from './api/client'; import { flushMemory as apiFlushMemory } from './api/system'; import { saveProject as apiSaveProject, loadProject as apiLoadProject, deleteProject as apiDeleteProject } from './api/projects'; import { exportAction, exportReveal, exportRecord } from './api/exports'; import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio, playPing } from './utils/media'; function App() { // First-run bootstrap: Rust spawns uv sync in a background thread and // publishes progress via the `bootstrap_status` Tauri command. Hook below // polls every 1 s; until `ready`, we render BootstrapSplash instead of the // normal app shell, so the user sees real progress instead of a hung UI. const { stage: bootstrapStage, message: bootstrapMessage } = useBootstrapStage(); // UI navigation state now lives in the Zustand `uiSlice` (Phase 2.2). // Mode + uiScale + sidebar-collapsed persist across reloads automatically // via the store's `partialize`; active project / voice ids stay transient. const uiScale = useAppStore(s => s.uiScale); const setUiScale = useAppStore(s => s.setUiScale); const theme = useAppStore(s => s.theme); // Hydrate the theme on mount so that persisted preference takes effect. useEffect(() => { if (theme && theme !== 'gruvbox') { document.documentElement.setAttribute('data-theme', theme); } }, []); // eslint-disable-line react-hooks/exhaustive-deps const mode = useAppStore(s => s.mode); const setMode = useAppStore(s => s.setMode); const [navRailSide, setNavRailSide] = useState(() => { try { return localStorage.getItem('omnivoice.navRailSide') || 'left'; } catch { return 'left'; } }); const showCheatsheet = useAppStore(s => s.showCheatsheet); const setShowCheatsheet = useAppStore(s => s.setShowCheatsheet); // Global '?' → open cheatsheet useEffect(() => { const h = (e) => { const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; if (e.key === '?' || (e.shiftKey && e.key === '/')) { e.preventDefault(); setShowCheatsheet(v => !v); } }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, []); // Listen for tray navigation events (Tauri desktop) useEffect(() => { let unlisten; (async () => { try { const { listen } = await import('@tauri-apps/api/event'); unlisten = await listen('tray-navigate', (ev) => { if (ev.payload) setMode(ev.payload); }); } catch { /* not in Tauri */ } })(); return () => { if (unlisten) unlisten(); }; }, [setMode]); const flipNavRailSide = useCallback(() => { setNavRailSide(prev => { const next = prev === 'left' ? 'right' : 'left'; try { localStorage.setItem('omnivoice.navRailSide', next); } catch {} return next; }); }, []); // Voice-profile navigation — slice owns "remember where I was" for Back. const activeVoiceId = useAppStore(s => s.activeVoiceId); const openVoiceProfile = useAppStore(s => s.openVoiceProfile); const closeVoiceProfile = useAppStore(s => s.closeVoiceProfile); const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice' || mode === 'donate' || mode === 'queue' || mode === 'tools' || mode === 'projects' || mode === 'gallery' || mode === 'enterprise' || mode === 'transcriptions' || mode === 'stories'; const availableSidebarTabs = mode === 'dub' ? ['projects', 'history', 'downloads'] : (mode === 'clone' || mode === 'design') ? ['projects', 'history'] : []; // Generate-tab prefs now live in `generateSlice` (Phase 2.2). Persisted // knobs survive reloads via the store's `partialize`. const text = useAppStore(s => s.text); const setText = useAppStore(s => s.setText); const refText = useAppStore(s => s.refText); const setRefText = useAppStore(s => s.setRefText); const instruct = useAppStore(s => s.instruct); const setInstruct = useAppStore(s => s.setInstruct); const language = useAppStore(s => s.language); const setLanguage = useAppStore(s => s.setLanguage); const speed = useAppStore(s => s.speed); const setSpeed = useAppStore(s => s.setSpeed); const steps = useAppStore(s => s.steps); const setSteps = useAppStore(s => s.setSteps); const cfg = useAppStore(s => s.cfg); const setCfg = useAppStore(s => s.setCfg); const denoise = useAppStore(s => s.denoise); const setDenoise = useAppStore(s => s.setDenoise); const tShift = useAppStore(s => s.tShift); const setTShift = useAppStore(s => s.setTShift); const posTemp = useAppStore(s => s.posTemp); const setPosTemp = useAppStore(s => s.setPosTemp); const classTemp = useAppStore(s => s.classTemp); const setClassTemp = useAppStore(s => s.setClassTemp); const layerPenalty = useAppStore(s => s.layerPenalty); const setLayerPenalty = useAppStore(s => s.setLayerPenalty); const postprocess = useAppStore(s => s.postprocess); const setPostprocess = useAppStore(s => s.setPostprocess); const duration = useAppStore(s => s.duration); const setDuration = useAppStore(s => s.setDuration); const vdStates = useAppStore(s => s.vdStates); const setVdStates = useAppStore(s => s.setVdStates); // ═══ EXTRACTED HOOKS ═══ const { profiles, history, dubHistory, studioProjects, exportHistory, showOverrides, setShowOverrides, sysStats, modelStatus, loadProfiles, loadHistory, loadDubHistory, loadProjects, loadExportHistory, } = useAppData(); const { selectedProfile, setSelectedProfile, showSaveProfile, setShowSaveProfile, profileName, setProfileName, previewLoading, segmentPreviewLoading, isVoicePreviewOpen, setIsVoicePreviewOpen, voicePreviewProfileId, setVoicePreviewProfileId, handleSaveProfile: _handleSaveProfile, handleDeleteProfile, handleSelectProfile, handlePreviewVoice, handleSegmentPreview, handleSaveHistoryAsProfile, handleLockProfile, handleUnlockProfile, } = useProfiles({ loadHistory, loadProfiles }); const { refAudio, setRefAudio, pendingTrimFile, setPendingTrimFile, isGenerating, generationTime, textAreaRef, ingestRefAudio, insertTag, applyPreset, handleGenerate, } = useTTS({ selectedProfile, setSelectedProfile, loadHistory }); const handleSaveProfile = () => _handleSaveProfile(refAudio, refText, instruct, language); // A/B Voice Comparison State const [isCompareModalOpen, setIsCompareModalOpen] = useState(false); const [compareVoiceA, setCompareVoiceA] = useState(""); const [compareVoiceB, setCompareVoiceB] = useState(""); const [compareText, setCompareText] = useState("The quick brown fox jumps over the lazy dog, proving that this voice sounds much better."); const [compareResultA, setCompareResultA] = useState(null); const [compareResultB, setCompareResultB] = useState(null); const [isComparing, setIsComparing] = useState(false); const [compareProgress, setCompareProgress] = useState(""); // ═══ MIC RECORDING ═══ const { isRecording, isCleaning, recordingTime, startRecording, stopRecording, } = useRecording(ingestRefAudio); // ═══ DUB STATE ═══ const dubJobId = useAppStore(s => s.dubJobId); const setDubJobId = useAppStore(s => s.setDubJobId); const dubStep = useAppStore(s => s.dubStep); const setDubStep = useAppStore(s => s.setDubStep); const dubSegments = useAppStore(s => s.dubSegments); const setDubSegments = useAppStore(s => s.setDubSegments); const dubLang = useAppStore(s => s.dubLang); const setDubLang = useAppStore(s => s.setDubLang); const dubLangCode = useAppStore(s => s.dubLangCode); const setDubLangCode = useAppStore(s => s.setDubLangCode); const dubInstruct = useAppStore(s => s.dubInstruct); const setDubInstruct = useAppStore(s => s.setDubInstruct); const dubProgress = useAppStore(s => s.dubProgress); const setDubProgress = useAppStore(s => s.setDubProgress); const dubFilename = useAppStore(s => s.dubFilename); const setDubFilename = useAppStore(s => s.setDubFilename); const dubDuration = useAppStore(s => s.dubDuration); const setDubDuration = useAppStore(s => s.setDubDuration); const dubError = useAppStore(s => s.dubError); const setDubError = useAppStore(s => s.setDubError); const dubTracks = useAppStore(s => s.dubTracks); const setDubTracks = useAppStore(s => s.setDubTracks); const dubTranscript = useAppStore(s => s.dubTranscript); const setDubTranscript = useAppStore(s => s.setDubTranscript); const isTranslating = useAppStore(s => s.isTranslating); const setIsTranslating = useAppStore(s => s.setIsTranslating); const preserveBg = useAppStore(s => s.preserveBg); const setPreserveBg = useAppStore(s => s.setPreserveBg); const defaultTrack = useAppStore(s => s.defaultTrack); const setDefaultTrack = useAppStore(s => s.setDefaultTrack); const exportTracks = useAppStore(s => s.exportTracks); const setExportTracks = useAppStore(s => s.setExportTracks); const previewSegIds = useAppStore(s => s.previewSegIds); const setPreviewSegIds = useAppStore(s => s.setPreviewSegIds); const speakerClones = useAppStore(s => s.speakerClones); const setSpeakerClones = useAppStore(s => s.setSpeakerClones); const dubTaskId = useAppStore(s => s.dubTaskId); const setDubTaskId = useAppStore(s => s.setDubTaskId); const dubPrepStage = useAppStore(s => s.dubPrepStage); const setDubPrepStage = useAppStore(s => s.setDubPrepStage); const translateQuality = useAppStore(s => s.translateQuality); const setTranslateQuality = useAppStore(s => s.setTranslateQuality); const glossaryTerms = useAppStore(s => s.glossaryTerms); const setGlossaryTerms = useAppStore(s => s.setGlossaryTerms); const dualSubs = useAppStore(s => s.dualSubs); const burnSubs = useAppStore(s => s.burnSubs); const setDualSubs = useAppStore(s => s.setDualSubs); // ── UNDO / REDO + SEGMENT EDITING ── // Must come before useDubWorkflow because the dub generate handler needs // setLastGenFingerprints to keep the incremental-regen plan in sync. const { undo, redo, editSegments, segmentEditField, segmentDelete, segmentRestoreOriginal, segmentSplit, segmentMerge, selectedSegIds, setSelectedSegIds, toggleSegSelect, selectAllSegs, clearSegSelection, bulkApplyToSelected, bulkDeleteSelected, directionSegId, openDirection, closeDirection, saveDirection, lastGenFingerprints, setLastGenFingerprints, incrementalPlan, setIncrementalPlan, recomputeIncremental, } = useSegmentEditing(); useEffect(() => { recomputeIncremental(); }, [recomputeIncremental]); const { translateProvider, setTranslateProvider, showTranscript, setShowTranscript, previewAudios, setPreviewAudios, transcribeElapsed, handleDubUpload: _handleDubUpload, handleDubIngestUrl, handleDubAbort, handleDubRetryTranscribe, handleDubStop, handleDubGenerate, handleCleanupSegments, handleTranslateAll, handleDubImportSrt, } = useDubWorkflow({ loadProjects, loadProfiles, loadDubHistory, setLastGenFingerprints }); const [dubVideoFile, setDubVideoFile] = useState(null); const [dubLocalBlobUrl, setDubLocalBlobUrl] = useState(null); const dubBlobUrlRef = useRef(null); useEffect(() => { dubBlobUrlRef.current = dubLocalBlobUrl; }, [dubLocalBlobUrl]); useEffect(() => () => { const urls = dubBlobUrlRef.current; if (urls?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(urls.videoUrl); if (urls?.audioUrl?.startsWith('blob:') && urls.audioUrl !== urls.videoUrl) URL.revokeObjectURL(urls.audioUrl); }, []); const handleDubUpload = () => _handleDubUpload(dubVideoFile); // ═══ STUDIO PROJECTS ═══ const activeProjectId = useAppStore(s => s.activeProjectId); const activeProjectName = useAppStore(s => s.activeProjectName); const setActiveProject = useAppStore(s => s.setActiveProject); const sidebarTab = useAppStore(s => s.sidebarTab); const setSidebarTab = useAppStore(s => s.setSidebarTab); // Snap sidebar to a valid tab when view changes useEffect(() => { if (availableSidebarTabs.length && !availableSidebarTabs.includes(sidebarTab)) { setSidebarTab(availableSidebarTabs[0]); } }, [mode]); // eslint-disable-line react-hooks/exhaustive-deps const isSidebarProjectsCollapsed = useAppStore(s => s.isSidebarProjectsCollapsed); const setIsSidebarProjectsCollapsed = useAppStore(s => s.setIsSidebarProjectsCollapsed); const isSidebarCollapsed = useAppStore(s => s.isSidebarCollapsed); const setIsSidebarCollapsed = useAppStore(s => s.setIsSidebarCollapsed); // First-run gate — `/setup/status` reports whether required HF models are // on disk. If not, we render in place of the main studio so // the user actually SEES the download instead of a silent 5 GB hang. // // Packaged .app note: the frozen backend sidecar takes several seconds to // import torch/torchaudio/whisper/etc. before it can serve /setup/status. // A single fetch on mount lands during that window, fails, and the wizard // would never render. So we retry with backoff until we get a response or // the user gives up. `setupChecked` gates main-UI render so we don't flash // the studio in front of a user who actually needs the wizard. const [setupNeeded, setSetupNeeded] = useState(false); const [setupChecked, setSetupChecked] = useState(false); useEffect(() => { let cancelled = false; (async () => { const { setupStatus } = await import('./api/setup'); // ~30 attempts × ~1s ≈ 30s ceiling; enough for a cold sidecar on slow disks. for (let attempt = 0; attempt < 30 && !cancelled; attempt++) { try { const s = await setupStatus(); if (cancelled) return; setSetupNeeded(!s.models_ready); setSetupChecked(true); return; } catch { await new Promise(r => setTimeout(r, 1000)); } } if (!cancelled) setSetupChecked(true); })(); return () => { cancelled = true; }; }, []); // ── Tauri auto-updater ── // On boot, ask GitHub Releases if a newer build is available. If yes, // prompt the user, download the signed bundle, restart into the new // version. Only runs in packaged .app (not `tauri dev`) — the updater // endpoint 404s until the first signed release is published, and we // don't want that noise in the dev console. useEffect(() => { if (typeof window === 'undefined') return; if (!('__TAURI_INTERNALS__' in window)) return; if (import.meta.env.DEV) return; let cancelled = false; (async () => { try { const [{ check }, { relaunch }, { ask }] = await Promise.all([ import('@tauri-apps/plugin-updater'), import('@tauri-apps/plugin-process'), import('@tauri-apps/plugin-dialog'), ]); const update = await check(); if (cancelled || !update) return; const proceed = await ask( `A new version (${update.version}) of OmniVoice Studio is available.\n\nWhat's new:\n${update.body || '— see release notes'}\n\nDownload and install now?`, { title: 'Update available', kind: 'info' }, ); if (!proceed) return; await update.downloadAndInstall(); await relaunch(); } catch (e) { // eslint-disable-next-line no-console console.debug('Updater check failed (non-fatal):', e); } })(); return () => { cancelled = true; }; }, []); // ── DESKTOP NATIVE INTEGRATION ── useEffect(() => { if (typeof window === 'undefined') return; // 1. Prevent default right-click to hide web nature const handleContextMenu = (e) => { // allow on inputs/textareas for copy/paste if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return; e.preventDefault(); }; // 2. Prevent keyboard quicks (reload, zoom, print) const handleKeyDown = (e) => { if (!e.metaKey && !e.ctrlKey) return; if (['r', 'p', '=', '-', '+'].includes(e.key.toLowerCase())) { e.preventDefault(); } }; // 3. Prevent pinch-to-zoom const handleWheel = (e) => { if (e.ctrlKey) e.preventDefault(); }; // 4. Global Drag and drop for seamless native feeling const handleDrop = (e) => { e.preventDefault(); const file = e.dataTransfer?.files[0]; if (!file) return; const isVideo = file.name.match(/\.(mp4|mov|mkv|webm|avi)$/i); const isAudio = file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i); if (isVideo || isAudio) { setMode('dub'); setDubVideoFile(file); fileToMediaUrl(file, null).then(urls => setDubLocalBlobUrl(urls)); setDubFilename(file.name); setDubStep('idle'); } }; const handleDragOver = (e) => e.preventDefault(); window.addEventListener('contextmenu', handleContextMenu); window.addEventListener('keydown', handleKeyDown); window.addEventListener('wheel', handleWheel, { passive: false }); window.addEventListener('drop', handleDrop); window.addEventListener('dragover', handleDragOver); return () => { window.removeEventListener('contextmenu', handleContextMenu); window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('wheel', handleWheel); window.removeEventListener('drop', handleDrop); window.removeEventListener('dragover', handleDragOver); }; }, []); // ── KEYBOARD SHORTCUTS ── useEffect(() => { const handler = (e) => { // ⌘+Enter or Ctrl+Enter → Generate if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); if (mode === 'dub') { if (dubStep === 'editing' && dubSegments.length > 0) handleDubGenerate(); } else { if (!isGenerating) handleGenerate(); } return; } // ⌘+S or Ctrl+S → Save project if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); if (mode === 'dub') saveProject(); return; } // ⌘+Z → Undo if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } // ⌘+Shift+Z → Redo if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) { e.preventDefault(); redo(); return; } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }); const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => { if (e) { e.preventDefault(); e.stopPropagation(); } try { const { save } = await import('@tauri-apps/plugin-dialog'); const ext = fallbackName.includes('.') ? fallbackName.split('.').pop() : 'wav'; const destPath = await save({ defaultPath: fallbackName, filters: [{ name: 'Media', extensions: [ext] }] }); if (!destPath) return; // User cancelled await exportAction({ source_filename: sourceIdentifier, destination_path: destPath, mode }); toast.success(`Exported: ${fallbackName}`); loadExportHistory(); } catch (err) { console.error(err); toast.error(`Export failed: ${err?.message || err}`); } }; const revealInFolder = async (filePath) => { try { await exportReveal({ path: filePath }); } catch (err) { toast.error(`Could not open folder: ${err.message}`); } }; const parseFilenameFromContentDisposition = (header) => { if (!header) return null; const utf8 = header.match(/filename\*=(?:UTF-8|utf-8)''([^;]+)/i); if (utf8) { try { return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, '')); } catch { /* ignore */ } } const plain = header.match(/filename="?([^";]+)"?/i); return plain ? plain[1].trim() : null; }; const triggerDownload = async (url, fallbackName) => { const extGuess = (fallbackName.includes('.') ? fallbackName.split('.').pop() : 'bin').toLowerCase(); const modeGuess = ['mp4','mov','mkv','webm'].includes(extGuess) ? 'video' : ['wav','mp3','flac'].includes(extGuess) ? 'audio' : 'file'; // In Tauri, WebKit silently drops blob downloads. Use native save dialog // + server-side copy so the file actually lands on disk at a known path. if (isTauri) { try { const { save } = await import('@tauri-apps/plugin-dialog'); const destPath = await save({ defaultPath: fallbackName, filters: [{ name: modeGuess === 'video' ? 'Video' : 'Audio', extensions: [extGuess] }], }); if (!destPath) return; // user cancelled toast.loading(`Saving ${fallbackName}...`, { id: fallbackName }); const sep = url.includes('?') ? '&' : '?'; const res = await fetch(`${url}${sep}save_path=${encodeURIComponent(destPath)}`); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || 'Save failed'); } const data = await res.json(); toast.success(`Saved: ${data.path}`, { id: fallbackName }); try { await exportRecord({ filename: data.display_name || fallbackName, destination_path: data.path, mode: modeGuess }); loadExportHistory(); } catch (err) { console.warn('exportRecord (Tauri save path) failed:', err); } } catch (err) { console.error(err); toast.error(`Save error: ${err.message}`, { id: fallbackName }); } return; } // Browser path: standard blob download. try { toast.loading(`Processing ${fallbackName}...`, { id: fallbackName }); const response = await fetch(url); if (!response.ok) throw new Error("Download failed"); const serverName = parseFilenameFromContentDisposition(response.headers.get('content-disposition')); const finalName = serverName || fallbackName || 'download'; const blob = await response.blob(); const localUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = localUrl; a.download = finalName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(localUrl); toast.success(`Downloaded ${finalName}`, { id: fallbackName }); try { await exportRecord({ filename: finalName, destination_path: `~/Downloads/${finalName}`, mode: modeGuess }); loadExportHistory(); } catch (err) { console.warn('exportRecord (browser download path) failed:', err); } } catch (err) { console.error(err); toast.error(`Download error: ${err.message}`, { id: fallbackName }); } }; // Pre-flight for audio/video exports. If any segments are at preview // quality (num_step=8, from a "Regen changed" click), re-render those at // full quality first so the user's exported file isn't carrying preview // artifacts. No-op when previewSegIds is empty. const finalizeTtsBeforeExport = async () => { if (!previewSegIds || previewSegIds.length === 0) return; toast(`Upgrading ${previewSegIds.length} preview-quality segment${previewSegIds.length === 1 ? '' : 's'} to full quality…`, { icon: '✨' }); await handleDubGenerate({ regenOnly: previewSegIds, preview: false }); }; const handleDubDownload = async () => { await finalizeTtsBeforeExport(); // Build selected tracks from all known tracks, matching the checkbox `!== false` logic const selected = []; if (exportTracks['original'] !== false) selected.push('original'); dubTracks.forEach(t => { if (exportTracks[t] !== false) selected.push(t); }); const tracksParam = selected.join(','); const burnParam = burnSubs ? `&burn_subs=1&dual=${dualSubs ? 1 : 0}` : ''; triggerDownload(`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${defaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}${burnParam}`, 'dubbed_video.mp4'); }; const handleDubAudioDownload = async () => { await finalizeTtsBeforeExport(); triggerDownload(`${API}/dub/download-audio/${dubJobId}/dubbed_audio.wav?preserve_bg=${preserveBg}`, 'dubbed_audio.wav'); }; // Generic audio export wrapper — MP3, Clips, Stems all need preview segs // upgraded before mux. Subtitle exports (SRT/VTT) skip this. const handleAudioExport = async (url, filename) => { await finalizeTtsBeforeExport(); triggerDownload(url, filename); }; const resetDub = () => { setDubJobId(null); setDubStep('idle'); setDubSegments([]); setDubFilename(''); setDubDuration(0); setDubError(''); setDubVideoFile(null); setDubTracks([]); setDubProgress({ current: 0, total: 0, text: '' }); setDubTranscript(''); setShowTranscript(false); setPreviewAudios({}); setDubLocalBlobUrl(prev => { if (prev?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(prev.videoUrl); if (prev?.audioUrl?.startsWith('blob:') && prev.audioUrl !== prev.videoUrl) URL.revokeObjectURL(prev.audioUrl); return null; }); setActiveProject(null); }; // ═══ STUDIO PROJECT CRUD ═══ const saveProject = async () => { if (dubStep === 'idle') { toast.error("Please click 'Upload & Transcribe' first so the video is processed on the server before saving."); return; } const name = activeProjectName || dubFilename || `Project ${new Date().toLocaleString()}`; const statePayload = { name, video_path: dubFilename || null, duration: dubDuration || null, state: { dubJobId, dubFilename, dubDuration, dubSegments, dubLang, dubLangCode, dubInstruct, dubTracks, dubStep, dubTranscript, preserveBg, defaultTrack, speakerClones, }, }; try { const data = await apiSaveProject(statePayload, activeProjectId); setActiveProject(data.id, name); toast.success(activeProjectId ? 'Project saved' : 'Project created'); loadProjects(); } catch (err) { toast.error('Save failed: ' + err.message); } }; const loadProject = async (projectOrId) => { const pid = typeof projectOrId === 'string' ? projectOrId : projectOrId?.id; try { const data = await apiLoadProject(pid); const s = data.state || {}; setMode('dub'); setActiveProject(data.id, data.name); setDubJobId(s.dubJobId || null); setDubFilename(s.dubFilename || data.video_path || ''); setDubDuration(s.dubDuration || data.duration || 0); setDubSegments((s.dubSegments || []).map(x => ({ ...x, text_original: x.text_original || x.text || '' }))); setDubLang(s.dubLang || 'Auto'); setDubLangCode(s.dubLangCode || 'en'); setDubInstruct(s.dubInstruct || ''); setDubTracks(s.dubTracks || []); setDubTranscript(s.dubTranscript || ''); setPreserveBg(s.preserveBg !== undefined ? s.preserveBg : true); setDefaultTrack(s.defaultTrack !== undefined ? s.defaultTrack : 'original'); setDubStep(s.dubStep === 'done' ? 'done' : (s.dubSegments?.length ? 'editing' : 'idle')); // Phase 4.5 — rehydrate per-segment fingerprints. The incremental plan // immediately shows "N segments changed" for any segments edited after // the last generate. setLastGenFingerprints(s.segHashes || {}); setSpeakerClones(s.speakerClones || {}); toast.success(`Opened: ${data.name}`); } catch (err) { toast.error(err.message); } }; const deleteProject = async (projectId, e) => { if (e) e.stopPropagation(); if (!(await askConfirm('Delete this project? This cannot be undone.'))) return; try { await apiDeleteProject(projectId); if (activeProjectId === projectId) { setActiveProject(null); } loadProjects(); toast.success('Project deleted'); } catch (err) { toast.error(err.message); } }; const restoreDubHistory = (item) => { try { if (!item.job_data) return; const job = JSON.parse(item.job_data); setMode('dub'); setDubJobId(item.id); setDubFilename(job.filename || ''); setDubDuration(job.duration || 0); setDubSegments((job.segments || []).map((s, i) => ({ ...s, id: s.id != null ? String(s.id) : String(i), text_original: s.text_original || s.text || '' }))); setDubTranscript(job.full_transcript || ''); setDubLang(item.language || 'Auto'); setDubLangCode(item.language_code || 'und'); setDubTracks(Object.keys(job.dubbed_tracks || {})); setDubStep(Object.keys(job.dubbed_tracks || {}).length > 0 ? 'done' : 'editing'); // Phase 4.5 — seg_hashes are written per successful segment by // dub_generate.py. Reloading a half-generated dub lets the "Regen N // changed" button resume right where the crash happened. setLastGenFingerprints(job.seg_hashes || {}); // Rehydrate the auto-extracted speaker clones so the CAST dropdown's // "🎤 From video" option reappears after a reload. Projects that // predate the speaker-clone feature have an empty map; the Extract // Voices button in the CAST strip handles those. setSpeakerClones(job.speaker_clones || {}); } catch (e) { console.error("Failed to restore job_data", e); } }; const restoreHistory = (item) => { if (item.mode) setMode(item.mode); if (item.text) setText(item.text); if (item.language) setLanguage(item.language); if (item.profile_id) setSelectedProfile(item.profile_id); // Switch to studio tab setSidebarTab('projects'); toast.success('Restored previous generation state'); }; const deleteHistory = async (id, type) => { if (!(await askConfirm('Delete this history item?'))) return; try { const endpoint = type === 'dub' ? `${API}/dub/history/${id}` : `${API}/history/${id}`; await fetch(endpoint, { method: 'DELETE' }); if (type === 'dub') { loadDubHistory(); } else { loadHistory(); } toast.success('History item deleted'); } catch (err) { toast.error(err.message); } }; // First-run gate: if /setup/status says models aren't on disk yet, render // the wizard instead of the main studio. Dismisses itself once the user // completes the download (or clicks "Skip" if they want to limp along). // Also blocks render until we've heard back from the backend at least once // — the frozen sidecar's cold-start import is ~5-10 s and without this we // flash the empty studio before the wizard has a chance to mount. if (!setupChecked) { return (
); } if (setupNeeded) { // Render outside the `app-container` grid so the wizard spans the full // viewport instead of getting squeezed into whatever grid cell the // studio layout reserves for the main content column. return (
{/* Invisible drag strip across the top 28 px of the wizard — matches the macOS traffic-light zone so the window can be dragged / double-click-zoomed from anywhere along the top. */}
{ if ('__TAURI_INTERNALS__' in window) { import('@tauri-apps/api/window').then(m => m.getCurrentWindow().toggleMaximize() ).catch(() => {}); } }} className="app-wizard-dragstrip" /> }> setSetupNeeded(false)} />
); } // Block the main UI until Rust reports the backend is ready. In dev web // (no Tauri), the hook returns 'ready' immediately so this is a no-op. if (bootstrapStage !== 'ready') { return ; } return (
{pendingTrimFile && ( }> setPendingTrimFile(null)} onConfirm={(trimmed) => { setPendingTrimFile(null); setRefAudio(trimmed); setSelectedProfile(null); toast.success('Trimmed audio loaded'); }} /> )}
{ try { const r = await apiFlushMemory(unloadModel); toast.success(`Flushed — RAM ${r.ram_after}G · VRAM ${r.vram_after}G${r.unloaded_model ? ' · model unloaded' : ''}`); } catch (e) { toast.error('Flush failed: ' + e.message); } }} />
{/* ═══ LAUNCHPAD TAB ═══ */} {mode === 'settings' ? ( }> ) : mode === 'voice' ? ( }> { loadProject(id); }} onDeleted={() => { loadProfiles(); closeVoiceProfile(); }} /> ) : mode === 'queue' ? ( }> setMode('launchpad')} /> ) : mode === 'tools' ? ( }> setMode('launchpad')} /> ) : mode === 'projects' ? ( }> { loadProject(id); setMode('dub'); }} onOpenProfile={(id) => { openVoiceProfile(id); }} onRevealExport={(path) => { exportReveal({ path }).catch(() => {}); }} /> ) : mode === 'gallery' ? ( }> ) : mode === 'transcriptions' ? ( }> ) : mode === 'stories' ? ( }> ) : mode === 'donate' ? ( }> setMode('launchpad')} onEnterprise={() => setMode('enterprise')} /> ) : mode === 'enterprise' ? ( }> setMode('launchpad')} /> ) : mode === 'launchpad' ? ( }> ) : mode === 'dub' ? ( }> ) : ( }> )}
{/* ── SIDEBAR ── */} }> { setVoicePreviewProfileId(profileId || ''); setIsVoicePreviewOpen(true); }} restoreHistory={restoreHistory} restoreDubHistory={restoreDubHistory} handleSaveHistoryAsProfile={handleSaveHistoryAsProfile} handleNativeExport={handleNativeExport} revealInFolder={revealInFolder} deleteHistory={deleteHistory} loadHistory={loadHistory} loadDubHistory={loadDubHistory} /> {/* ═══ DIRECTION DIALOG (Phase 4.2) ═══ */} s.id === directionSegId) : null} onSave={saveDirection} onClose={closeDirection} /> {/* ═══ A/B VOICE COMPARISON MODAL ═══ */} {isCompareModalOpen && ( }> setIsCompareModalOpen(false)} profiles={profiles} compareText={compareText} setCompareText={setCompareText} compareVoiceA={compareVoiceA} setCompareVoiceA={setCompareVoiceA} compareVoiceB={compareVoiceB} setCompareVoiceB={setCompareVoiceB} compareResultA={compareResultA} setCompareResultA={setCompareResultA} compareResultB={compareResultB} setCompareResultB={setCompareResultB} compareProgress={compareProgress} setCompareProgress={setCompareProgress} isComparing={isComparing} setIsComparing={setIsComparing} steps={steps} cfg={cfg} speed={speed} denoise={denoise} postprocess={postprocess} fileToMediaUrl={fileToMediaUrl} loadHistory={loadHistory} /> )} {/* ═══ KEYBOARD CHEATSHEET ( ? ) ═══ */} {showCheatsheet && ( setShowCheatsheet(false)} /> )} {/* ═══ VOICE PREVIEW FLOATING CARD ═══ */} {isVoicePreviewOpen && ( setIsVoicePreviewOpen(false)} profiles={profiles} initialProfileId={voicePreviewProfileId} fileToMediaUrl={fileToMediaUrl} /> )} {/* ═══ BOTTOM LOGS PANEL (VSCode-style) ═══ */}
); } export default App;