Spaces:
Sleeping
Sleeping
| 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 = () => <div className="app-lazy-fallback">Loadingβ¦</div>; | |
| 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 <SetupWizard> 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 ( | |
| <div style={{ zoom: uiScale }}> | |
| <BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} /> | |
| <Suspense fallback={null}> | |
| <LogsFooter /> | |
| </Suspense> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div | |
| className="app-wizard-wrap" | |
| style={{ zoom: uiScale }} | |
| > | |
| {/* 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. */} | |
| <div | |
| data-tauri-drag-region | |
| onDoubleClick={() => { | |
| if ('__TAURI_INTERNALS__' in window) { | |
| import('@tauri-apps/api/window').then(m => | |
| m.getCurrentWindow().toggleMaximize() | |
| ).catch(() => {}); | |
| } | |
| }} | |
| className="app-wizard-dragstrip" | |
| /> | |
| <Suspense fallback={<LazyFallback />}> | |
| <SetupWizard onReady={() => setSetupNeeded(false)} /> | |
| </Suspense> | |
| <Suspense fallback={null}> | |
| <LogsFooter /> | |
| </Suspense> | |
| </div> | |
| ); | |
| } | |
| // 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 <BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />; | |
| } | |
| return ( | |
| <div | |
| className={[ | |
| 'app-container', | |
| isSidebarCollapsed ? 'sidebar-collapsed' : '', | |
| hideSidebar ? 'sidebar-hidden' : '', | |
| navRailSide === 'right' ? 'rail-right' : '', | |
| ].filter(Boolean).join(' ')} | |
| style={{ zoom: uiScale }} | |
| > | |
| {pendingTrimFile && ( | |
| <ErrorBoundary name="audio-trimmer"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <AudioTrimmer | |
| file={pendingTrimFile} | |
| maxSeconds={CLONE_MAX_SECONDS} | |
| onCancel={() => setPendingTrimFile(null)} | |
| onConfirm={(trimmed) => { setPendingTrimFile(null); setRefAudio(trimmed); setSelectedProfile(null); toast.success('Trimmed audio loaded'); }} | |
| /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| )} | |
| <Toaster position="top-center" toastOptions={{ | |
| style: { background: 'rgba(40,40,40,0.9)', backdropFilter: 'blur(10px)', color: '#ebdbb2', border: '1px solid rgba(255,255,255,0.08)', fontSize: '0.72rem', padding: '4px 8px' }, | |
| error: { iconTheme: { primary: '#fb4934', secondary: '#fff' } }, | |
| success: { iconTheme: { primary: '#b8bb26', secondary: '#fff' } } | |
| }}/> | |
| <FloatingPill /> | |
| <Header | |
| mode={mode} setMode={setMode} | |
| sysStats={sysStats} modelStatus={modelStatus} | |
| doubleClickMaximize={doubleClickMaximize} | |
| activeProjectName={activeProjectName} | |
| onFlushMemory={async (unloadModel) => { | |
| 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); } | |
| }} | |
| /> | |
| <NavRail mode={mode} setMode={setMode} side={navRailSide} onFlipSide={flipNavRailSide} /> | |
| <div className="main-content"> | |
| {/* βββ LAUNCHPAD TAB βββ */} | |
| {mode === 'settings' ? ( | |
| <ErrorBoundary name="settings"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <Settings /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'voice' ? ( | |
| <ErrorBoundary name="voice-profile"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <VoiceProfile | |
| voiceId={activeVoiceId} | |
| onBack={closeVoiceProfile} | |
| onOpenProject={(id) => { loadProject(id); }} | |
| onDeleted={() => { | |
| loadProfiles(); | |
| closeVoiceProfile(); | |
| }} | |
| /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'queue' ? ( | |
| <ErrorBoundary name="batch-queue"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <BatchQueue onBack={() => setMode('launchpad')} /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'tools' ? ( | |
| <ErrorBoundary name="tools"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <ToolsPage onBack={() => setMode('launchpad')} /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'projects' ? ( | |
| <ErrorBoundary name="projects"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <ProjectsPage | |
| studioProjects={studioProjects} | |
| profiles={profiles} | |
| history={history} | |
| exportHistory={exportHistory} | |
| onOpenDub={(id) => { loadProject(id); setMode('dub'); }} | |
| onOpenProfile={(id) => { openVoiceProfile(id); }} | |
| onRevealExport={(path) => { exportReveal({ path }).catch(() => {}); }} | |
| /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'gallery' ? ( | |
| <ErrorBoundary name="gallery"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <VoiceGallery /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'transcriptions' ? ( | |
| <ErrorBoundary name="transcriptions"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <TranscriptionsPage /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'stories' ? ( | |
| <ErrorBoundary name="stories"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <StoriesEditor profiles={profiles} /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'donate' ? ( | |
| <ErrorBoundary name="donate"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <DonatePage onBack={() => setMode('launchpad')} onEnterprise={() => setMode('enterprise')} /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'enterprise' ? ( | |
| <ErrorBoundary name="enterprise"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <EnterprisePage onBack={() => setMode('launchpad')} /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'launchpad' ? ( | |
| <ErrorBoundary name="launchpad"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <Launchpad | |
| profiles={profiles} | |
| studioProjects={studioProjects} | |
| dubHistory={dubHistory} | |
| setMode={setMode} | |
| setIsCompareModalOpen={setIsCompareModalOpen} | |
| handleSelectProfile={handleSelectProfile} | |
| loadProject={loadProject} | |
| /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : mode === 'dub' ? ( | |
| <ErrorBoundary name="dub"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <DubTab | |
| // Non-serialisable / local state only β all pipeline fields now | |
| // flow through the Zustand store. | |
| dubVideoFile={dubVideoFile} | |
| dubLocalBlobUrl={dubLocalBlobUrl} | |
| transcribeElapsed={transcribeElapsed} | |
| translateProvider={translateProvider} setTranslateProvider={setTranslateProvider} | |
| onGlossaryChange={setGlossaryTerms} | |
| showTranscript={showTranscript} setShowTranscript={setShowTranscript} | |
| profiles={profiles} | |
| segmentPreviewLoading={segmentPreviewLoading} | |
| selectedSegIds={selectedSegIds} | |
| setDubVideoFile={setDubVideoFile} | |
| setDubLocalBlobUrl={setDubLocalBlobUrl} | |
| // Handlers β close over App.jsx scope so stay prop-threaded. | |
| handleDubAbort={handleDubAbort} handleDubUpload={handleDubUpload} handleDubIngestUrl={handleDubIngestUrl} | |
| handleDubRetryTranscribe={handleDubRetryTranscribe} | |
| handleDubStop={handleDubStop} handleDubGenerate={handleDubGenerate} | |
| handleDubDownload={handleDubDownload} handleDubAudioDownload={handleDubAudioDownload} | |
| handleAudioExport={handleAudioExport} | |
| speakerClones={speakerClones} | |
| handleSegmentPreview={handleSegmentPreview} | |
| onDirectSegment={openDirection} | |
| incrementalPlan={incrementalPlan} | |
| handleTranslateAll={handleTranslateAll} | |
| handleCleanupSegments={handleCleanupSegments} | |
| handleDubImportSrt={handleDubImportSrt} | |
| triggerDownload={triggerDownload} | |
| fileToMediaUrl={fileToMediaUrl} | |
| editSegments={editSegments} | |
| saveProject={saveProject} resetDub={resetDub} | |
| segmentEditField={segmentEditField} segmentDelete={segmentDelete} | |
| segmentRestoreOriginal={segmentRestoreOriginal} | |
| segmentSplit={segmentSplit} segmentMerge={segmentMerge} | |
| toggleSegSelect={toggleSegSelect} | |
| selectAllSegs={selectAllSegs} clearSegSelection={clearSegSelection} | |
| bulkApplyToSelected={bulkApplyToSelected} | |
| bulkDeleteSelected={bulkDeleteSelected} | |
| /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| ) : ( | |
| <ErrorBoundary name="clone-design"> | |
| <Suspense fallback={<LazyFallback />}> | |
| <CloneDesignTab | |
| mode={mode} | |
| textAreaRef={textAreaRef} | |
| text={text} setText={setText} | |
| language={language} setLanguage={setLanguage} | |
| steps={steps} setSteps={setSteps} | |
| cfg={cfg} setCfg={setCfg} | |
| speed={speed} setSpeed={setSpeed} | |
| tShift={tShift} setTShift={setTShift} | |
| posTemp={posTemp} setPosTemp={setPosTemp} | |
| classTemp={classTemp} setClassTemp={setClassTemp} | |
| layerPenalty={layerPenalty} setLayerPenalty={setLayerPenalty} | |
| duration={duration} setDuration={setDuration} | |
| denoise={denoise} setDenoise={setDenoise} | |
| postprocess={postprocess} setPostprocess={setPostprocess} | |
| showOverrides={showOverrides} setShowOverrides={setShowOverrides} | |
| isSidebarCollapsed={isSidebarCollapsed} setIsSidebarCollapsed={setIsSidebarCollapsed} | |
| profiles={profiles} | |
| selectedProfile={selectedProfile} setSelectedProfile={setSelectedProfile} | |
| refAudio={refAudio} | |
| refText={refText} setRefText={setRefText} | |
| instruct={instruct} setInstruct={setInstruct} | |
| profileName={profileName} setProfileName={setProfileName} | |
| showSaveProfile={showSaveProfile} setShowSaveProfile={setShowSaveProfile} | |
| isRecording={isRecording} isCleaning={isCleaning} recordingTime={recordingTime} | |
| vdStates={vdStates} setVdStates={setVdStates} | |
| isGenerating={isGenerating} generationTime={generationTime} | |
| applyPreset={applyPreset} insertTag={insertTag} | |
| handleSelectProfile={handleSelectProfile} | |
| handleDeleteProfile={handleDeleteProfile} | |
| handleSaveProfile={handleSaveProfile} | |
| handleGenerate={handleGenerate} | |
| startRecording={startRecording} stopRecording={stopRecording} | |
| ingestRefAudio={ingestRefAudio} | |
| /> | |
| </Suspense> | |
| </ErrorBoundary> | |
| )} | |
| </div> | |
| {/* ββ SIDEBAR ββ */} | |
| <Suspense fallback={<LazyFallback />}> | |
| <Sidebar | |
| availableTabs={availableSidebarTabs} | |
| isSidebarProjectsCollapsed={isSidebarProjectsCollapsed} | |
| setIsSidebarProjectsCollapsed={setIsSidebarProjectsCollapsed} | |
| sidebarTab={sidebarTab} setSidebarTab={setSidebarTab} | |
| studioProjects={studioProjects} | |
| profiles={profiles} | |
| history={history} | |
| dubHistory={dubHistory} | |
| exportHistory={exportHistory} | |
| dubVideoFile={dubVideoFile} | |
| selectedProfile={selectedProfile} | |
| previewLoading={previewLoading} | |
| saveProject={saveProject} | |
| loadProject={loadProject} | |
| deleteProject={deleteProject} | |
| handleSelectProfile={handleSelectProfile} | |
| handleDeleteProfile={handleDeleteProfile} | |
| handleOpenVoiceProfile={openVoiceProfile} | |
| handleUnlockProfile={handleUnlockProfile} | |
| handleLockProfile={handleLockProfile} | |
| handlePreviewVoice={handlePreviewVoice} | |
| onOpenVoicePreview={(profileId) => { | |
| setVoicePreviewProfileId(profileId || ''); | |
| setIsVoicePreviewOpen(true); | |
| }} | |
| restoreHistory={restoreHistory} | |
| restoreDubHistory={restoreDubHistory} | |
| handleSaveHistoryAsProfile={handleSaveHistoryAsProfile} | |
| handleNativeExport={handleNativeExport} | |
| revealInFolder={revealInFolder} | |
| deleteHistory={deleteHistory} | |
| loadHistory={loadHistory} | |
| loadDubHistory={loadDubHistory} | |
| /> | |
| </Suspense> | |
| {/* βββ DIRECTION DIALOG (Phase 4.2) βββ */} | |
| <DirectionDialog | |
| open={!!directionSegId} | |
| seg={directionSegId ? dubSegments.find(s => s.id === directionSegId) : null} | |
| onSave={saveDirection} | |
| onClose={closeDirection} | |
| /> | |
| {/* βββ A/B VOICE COMPARISON MODAL βββ */} | |
| {isCompareModalOpen && ( | |
| <Suspense fallback={<LazyFallback />}> | |
| <CompareModal | |
| open={isCompareModalOpen} | |
| onClose={() => 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} | |
| /> | |
| </Suspense> | |
| )} | |
| {/* βββ KEYBOARD CHEATSHEET ( ? ) βββ */} | |
| {showCheatsheet && ( | |
| <Suspense fallback={null}> | |
| <KeyboardCheatsheet open={showCheatsheet} onClose={() => setShowCheatsheet(false)} /> | |
| </Suspense> | |
| )} | |
| {/* βββ VOICE PREVIEW FLOATING CARD βββ */} | |
| {isVoicePreviewOpen && ( | |
| <Suspense fallback={null}> | |
| <VoicePreview | |
| open={isVoicePreviewOpen} | |
| onClose={() => setIsVoicePreviewOpen(false)} | |
| profiles={profiles} | |
| initialProfileId={voicePreviewProfileId} | |
| fileToMediaUrl={fileToMediaUrl} | |
| /> | |
| </Suspense> | |
| )} | |
| {/* βββ BOTTOM LOGS PANEL (VSCode-style) βββ */} | |
| <Suspense fallback={null}> | |
| <LogsFooter /> | |
| </Suspense> | |
| </div> | |
| ); | |
| } | |
| export default App; | |