more stuff to improve robustness
This commit is contained in:
@ -44,37 +44,38 @@ const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | 'help' | null;
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
projectFilePath,
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
speedRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
isTranscribing,
|
||||
transcriptionStatus,
|
||||
markInTime,
|
||||
markOutTime,
|
||||
loadVideo,
|
||||
setProjectFilePath,
|
||||
setBackendUrl,
|
||||
clearMarkRange,
|
||||
setTranscription,
|
||||
setTranscriptionModel,
|
||||
setTranscribing,
|
||||
selectedWordIndices,
|
||||
addCutRange,
|
||||
addMuteRange,
|
||||
addGainRange,
|
||||
addSpeedRange,
|
||||
} = useEditorStore();
|
||||
const {
|
||||
projectFilePath,
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
speedRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
isTranscribing,
|
||||
transcriptionStatus,
|
||||
markInTime,
|
||||
markOutTime,
|
||||
loadVideo,
|
||||
setProjectFilePath,
|
||||
setBackendUrl,
|
||||
clearMarkRange,
|
||||
setTranscription,
|
||||
setTranscriptionModel,
|
||||
setTranscribing,
|
||||
selectedWordIndices,
|
||||
addCutRange,
|
||||
addMuteRange,
|
||||
addGainRange,
|
||||
addSpeedRange,
|
||||
backendUrl,
|
||||
} = useEditorStore();
|
||||
|
||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||
const [projectName, setProjectName] = useState<string | null>(null);
|
||||
@ -142,6 +143,10 @@ export default function App() {
|
||||
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
||||
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
||||
const [showFileMenu, setShowFileMenu] = useState(false);
|
||||
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
|
||||
const [recoveryData, setRecoveryData] = useState<any>(null);
|
||||
const [recoveryMinutesAgo, setRecoveryMinutesAgo] = useState(0);
|
||||
const [backendDown, setBackendDown] = useState(false);
|
||||
const canEdit = useLicenseStore((s) => s.canEdit);
|
||||
const licenseStatus = useLicenseStore((s) => s.status);
|
||||
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
|
||||
@ -180,7 +185,10 @@ export default function App() {
|
||||
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
|
||||
|
||||
const loadProjectFromData = (data: any) => {
|
||||
useEditorStore.getState().loadProject(data);
|
||||
const removedCount = useEditorStore.getState().loadProject(data);
|
||||
if (removedCount > 0) {
|
||||
window.alert(`${removedCount} invalid zones were removed from the loaded project.`);
|
||||
}
|
||||
const loadedSignature = JSON.stringify({
|
||||
videoPath: data.videoPath,
|
||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||
@ -211,6 +219,18 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
useLicenseStore.getState().checkStatus();
|
||||
window.electronAPI?.readAutosave().then((data) => {
|
||||
if (data) {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const savedAt = parsed.savedAt;
|
||||
const minutesAgo = savedAt ? Math.round((Date.now() - savedAt) / 60000) : 0;
|
||||
setRecoveryData(parsed);
|
||||
setRecoveryMinutesAgo(minutesAgo);
|
||||
setShowRecoveryDialog(true);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle Escape key to exit timeline zone modes and close menus
|
||||
@ -248,6 +268,36 @@ export default function App() {
|
||||
sessionStorage.removeItem(LAST_MEDIA_PATH_KEY);
|
||||
}, [videoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoPath) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const state = useEditorStore.getState();
|
||||
const hasData = state.words.length > 0 || state.cutRanges.length > 0 || state.muteRanges.length > 0 || state.gainRanges.length > 0 || state.speedRanges.length > 0;
|
||||
if (!hasData) return;
|
||||
const autosaveData = {
|
||||
savedAt: Date.now(),
|
||||
videoPath: state.videoPath,
|
||||
words: state.words,
|
||||
segments: state.segments,
|
||||
cutRanges: state.cutRanges,
|
||||
muteRanges: state.muteRanges,
|
||||
gainRanges: state.gainRanges,
|
||||
speedRanges: state.speedRanges,
|
||||
globalGainDb: state.globalGainDb,
|
||||
silenceTrimGroups: state.silenceTrimGroups,
|
||||
transcriptionModel: state.transcriptionModel,
|
||||
language: state.language,
|
||||
markInTime: state.markInTime,
|
||||
markOutTime: state.markOutTime,
|
||||
timelineMarkers: state.timelineMarkers,
|
||||
};
|
||||
window.electronAPI.writeAutosave(JSON.stringify(autosaveData));
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [videoPath]);
|
||||
|
||||
const handleLoadProject = async () => {
|
||||
await runGuarded(async () => {
|
||||
try {
|
||||
@ -404,6 +454,26 @@ export default function App() {
|
||||
setPendingProceedAction(null);
|
||||
};
|
||||
|
||||
const handleRecoverAutosave = () => {
|
||||
if (!recoveryData) return;
|
||||
const data = recoveryData;
|
||||
const removedCount = useEditorStore.getState().loadProject(data);
|
||||
if (removedCount > 0) {
|
||||
window.alert(`${removedCount} invalid zones were removed from the loaded project.`);
|
||||
}
|
||||
if (data.markInTime != null) useEditorStore.getState().setMarkInTime(data.markInTime);
|
||||
if (data.markOutTime != null) useEditorStore.getState().setMarkOutTime(data.markOutTime);
|
||||
window.electronAPI.deleteAutosave();
|
||||
setShowRecoveryDialog(false);
|
||||
setRecoveryData(null);
|
||||
};
|
||||
|
||||
const handleDismissRecovery = () => {
|
||||
window.electronAPI.deleteAutosave();
|
||||
setShowRecoveryDialog(false);
|
||||
setRecoveryData(null);
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Panel) => {
|
||||
setActivePanel((prev) => (prev === panel ? null : panel));
|
||||
};
|
||||
@ -606,6 +676,21 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// Health check timer
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/health`);
|
||||
setBackendDown(!res.ok);
|
||||
} catch {
|
||||
setBackendDown(true);
|
||||
}
|
||||
};
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [videoPath, backendUrl]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
|
||||
{/* Top bar */}
|
||||
@ -1009,6 +1094,44 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRecoveryDialog && (
|
||||
<div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4"
|
||||
onClick={handleDismissRecovery}
|
||||
>
|
||||
<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">Recover unsaved work?</h3>
|
||||
<p className="text-xs text-editor-text-muted leading-relaxed">
|
||||
TalkEdit recovered a project from {recoveryMinutesAgo} minute{recoveryMinutesAgo !== 1 ? 's' : ''} ago.
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleDismissRecovery}
|
||||
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRecoverAutosave}
|
||||
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
|
||||
>
|
||||
Recover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backendDown && (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-[90] flex items-center justify-center gap-2 px-4 py-2 bg-amber-500/15 border-t border-amber-500/30 text-amber-400 text-xs font-medium">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
Backend disconnected — retrying...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Terminal, ChevronDown, ChevronUp, Play, Wifi } from 'lucide-react';
|
||||
import { Terminal, ChevronDown, ChevronUp, Play, Wifi, AlertTriangle } from 'lucide-react';
|
||||
|
||||
export default function DevPanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pathInput, setPathInput] = useState('');
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
const { backendUrl, videoPath, loadVideo } = useEditorStore();
|
||||
|
||||
@ -121,6 +122,37 @@ export default function DevPanel() {
|
||||
{testResult}
|
||||
</pre>
|
||||
)}
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-[#ef4444] uppercase tracking-wider text-[9px]">Danger Zone</div>
|
||||
{!showResetConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
className="w-full px-2 py-1.5 rounded border border-red-500/40 text-red-400 hover:bg-red-500/10 text-xs flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Reset Editor State
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-[#1e1020] border border-red-500/40 rounded p-2 space-y-1.5">
|
||||
<p className="text-[#fca5a5] text-[10px]">This will clear all editor data and reload the page. Unsaved changes will be lost.</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(false)}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px] text-[#6b7280] hover:text-white hover:bg-[#2a2d3e]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { useEditorStore.getState().reset(); window.location.reload(); }}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px] border border-red-500/40 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
Confirm Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
90
frontend/src/components/ErrorBoundary.tsx
Normal file
90
frontend/src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { Component, type ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info.componentStack);
|
||||
try {
|
||||
window.electronAPI?.logError?.(error.message, error.stack || '', info.componentStack || '');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleReset = () => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
} catch {}
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center gap-6 bg-editor-bg px-6">
|
||||
<div className="flex flex-col items-center gap-3 max-w-md text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-editor-text">Something went wrong</h2>
|
||||
<p className="text-xs text-editor-text-muted leading-relaxed">
|
||||
An unexpected error occurred. Your work may still be recoverable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{this.state.error && (
|
||||
<details className="max-w-md w-full">
|
||||
<summary className="text-xs text-editor-text-muted cursor-pointer hover:text-editor-text">
|
||||
Error details
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 rounded bg-editor-surface border border-editor-border text-[10px] text-red-300 overflow-auto max-h-32 whitespace-pre-wrap">
|
||||
{this.state.error.message}
|
||||
{'\n'}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Reload App
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="text-xs text-editor-text-muted hover:text-editor-text underline transition-colors"
|
||||
>
|
||||
Reset & Clear All Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
|
||||
import type { ExportOptions } from '../types/project';
|
||||
import { assert } from '../lib/assert';
|
||||
|
||||
export default function ExportDialog() {
|
||||
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } =
|
||||
@ -136,6 +137,7 @@ export default function ExportDialog() {
|
||||
setExportError(null);
|
||||
try {
|
||||
const keepSegments = getKeepSegments();
|
||||
assert(words.length > 0, 'handleExport: words is empty before building keep segments');
|
||||
const deletedSet = getDeletedSet();
|
||||
|
||||
// Map frontend camelCase gain/speed fields to backend snake_case
|
||||
|
||||
@ -3,6 +3,7 @@ import { useEditorStore } from '../store/editorStore';
|
||||
import { useLicenseStore } from '../store/licenseStore';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
|
||||
import { assert } from '../lib/assert';
|
||||
|
||||
interface TranscriptEditorProps {
|
||||
cutMode: boolean;
|
||||
@ -206,6 +207,8 @@ export default function TranscriptEditor({
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (zoneDragStart.current !== null && zoneDragRange) {
|
||||
assert(zoneDragRange.start >= 0 && zoneDragRange.start < words.length, 'handleMouseUp: zoneDragRange.start out of bounds');
|
||||
assert(zoneDragRange.end >= 0 && zoneDragRange.end < words.length, 'handleMouseUp: zoneDragRange.end out of bounds');
|
||||
const startWord = words[zoneDragRange.start];
|
||||
const endWord = words[zoneDragRange.end];
|
||||
if (startWord && endWord && canEdit) {
|
||||
@ -269,6 +272,7 @@ export default function TranscriptEditor({
|
||||
|
||||
// Snapshot indices and word timings before the async gap
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
assert(sorted[0] >= 0 && sorted[sorted.length - 1] < words.length, 'handleReTranscribe: sorted indices out of bounds');
|
||||
const startWord = words[sorted[0]];
|
||||
const endWord = words[sorted[sorted.length - 1]];
|
||||
if (!startWord || !endWord) {
|
||||
@ -336,6 +340,8 @@ export default function TranscriptEditor({
|
||||
const cutSelectedWords = useCallback(() => {
|
||||
if (selectedWordIndices.length === 0) return;
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
assert(sorted[0] >= 0 && sorted[0] < words.length, 'cutSelectedWords: sorted[0] out of bounds');
|
||||
assert(sorted[sorted.length - 1] >= 0 && sorted[sorted.length - 1] < words.length, 'cutSelectedWords: sorted[last] out of bounds');
|
||||
const startTime = words[sorted[0]].start;
|
||||
const endTime = words[sorted[sorted.length - 1]].end;
|
||||
addCutRange(startTime, endTime);
|
||||
|
||||
@ -3,6 +3,7 @@ import { useEditorStore } from '../store/editorStore';
|
||||
import { useLicenseStore } from '../store/licenseStore';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { extractThumbnails } from '../lib/thumbnails';
|
||||
import { assert } from '../lib/assert';
|
||||
|
||||
const RULER_H = 20; // px reserved at top of canvas for the time ruler
|
||||
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
||||
@ -821,6 +822,7 @@ export default function WaveformTimeline({
|
||||
}, [displayDuration, setCurrentTime, timelineSegments]);
|
||||
|
||||
const clientXToTime = useCallback((clientX: number): number => {
|
||||
assert(headCanvasRef.current !== null, 'clientXToTime: headCanvasRef.current is null');
|
||||
const canvas = headCanvasRef.current;
|
||||
const dur = waveformDataRef.current?.duration;
|
||||
if (!canvas || !dur) return 0;
|
||||
@ -1122,6 +1124,8 @@ export default function WaveformTimeline({
|
||||
setIsDragging(false);
|
||||
|
||||
if (selectionStartRef.current !== null && selectionEndRef.current !== null) {
|
||||
assert(selectionStartRef.current !== null, 'handleMouseDown: selectionStartRef is null');
|
||||
assert(selectionEndRef.current !== null, 'handleMouseDown: selectionEndRef is null');
|
||||
const start = Math.min(selectionStartRef.current, selectionEndRef.current);
|
||||
const end = Math.max(selectionStartRef.current, selectionEndRef.current);
|
||||
const minDuration = 0.01;
|
||||
|
||||
11
frontend/src/lib/assert.ts
Normal file
11
frontend/src/lib/assert.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
const error = new Error(`Assertion failed: ${message}`);
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[Assertion]', message, error.stack);
|
||||
throw error;
|
||||
} else {
|
||||
console.warn('[Assertion] (prod silenced):', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -120,4 +120,20 @@ window.electronAPI = {
|
||||
deleteModel: (path: string): Promise<void> => {
|
||||
return invoke('delete_model', { path });
|
||||
},
|
||||
|
||||
logError: (message: string, stack: string, componentStack: string): Promise<void> => {
|
||||
return invoke('log_error', { message, stack, componentStack });
|
||||
},
|
||||
|
||||
writeAutosave: (data: string): Promise<void> => {
|
||||
return invoke('write_autosave', { data });
|
||||
},
|
||||
|
||||
readAutosave: (): Promise<string | null> => {
|
||||
return invoke('read_autosave');
|
||||
},
|
||||
|
||||
deleteAutosave: (): Promise<void> => {
|
||||
return invoke('delete_autosave');
|
||||
},
|
||||
};
|
||||
|
||||
@ -5,10 +5,30 @@ import './lib/dev-logger';
|
||||
// Tauri bridge polyfill: must be imported before App so window.electronAPI is available to all components
|
||||
import './lib/tauri-bridge';
|
||||
import App from './App';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import './index.css';
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
if (e.error) {
|
||||
try {
|
||||
console.error('[GlobalError]', e.error.message, e.error.stack);
|
||||
window.electronAPI?.logError?.(e.error.message, e.error.stack || '', '');
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = e.reason instanceof Error ? e.reason : new Error(String(e.reason));
|
||||
try {
|
||||
console.error('[UnhandledRejection]', reason.message, reason.stack);
|
||||
window.electronAPI?.logError?.(reason.message, reason.stack || '', '');
|
||||
} catch {}
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { temporal } from 'zundo';
|
||||
import { assert } from '../lib/assert';
|
||||
import type {
|
||||
Word,
|
||||
Segment,
|
||||
@ -109,7 +110,7 @@ interface EditorActions {
|
||||
replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void;
|
||||
getKeepSegments: () => Array<{ start: number; end: number }>;
|
||||
getWordAtTime: (time: number) => number;
|
||||
loadProject: (projectData: any) => void;
|
||||
loadProject: (projectData: any) => number;
|
||||
reset: () => void;
|
||||
setZoomConfig: (config: Partial<ZoomConfig>) => void;
|
||||
addAdditionalClip: (path: string, label?: string) => void;
|
||||
@ -203,7 +204,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
|
||||
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
||||
|
||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||
setTranscriptionModel: (model) => {
|
||||
if (model === null || model === '') return;
|
||||
set({ transcriptionModel: model });
|
||||
},
|
||||
|
||||
saveProject: (): ProjectFile => {
|
||||
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath, zoomConfig, additionalClips, backgroundMusic } = get();
|
||||
@ -239,6 +243,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
loadVideo: (path) => {
|
||||
if (!path) return;
|
||||
const { backendUrl, zonePreviewPaddingSeconds } = get();
|
||||
const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`;
|
||||
set({
|
||||
@ -252,6 +257,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
setTranscription: (result) => {
|
||||
if (!result.words || result.words.length === 0) {
|
||||
set({ words: [], segments: [], selectedWordIndices: [] });
|
||||
return;
|
||||
}
|
||||
let globalIdx = 0;
|
||||
const annotatedSegments = result.segments.map((seg) => {
|
||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||
@ -269,8 +278,14 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
setCurrentTime: (time) => set({ currentTime: time }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setMarkInTime: (time) => set({ markInTime: time }),
|
||||
setMarkOutTime: (time) => set({ markOutTime: time }),
|
||||
setMarkInTime: (time) => {
|
||||
if (time !== null && !isFinite(time)) return;
|
||||
set({ markInTime: time });
|
||||
},
|
||||
setMarkOutTime: (time) => {
|
||||
if (time !== null && !isFinite(time)) return;
|
||||
set({ markOutTime: time });
|
||||
},
|
||||
clearMarkRange: () => set({ markInTime: null, markOutTime: null }),
|
||||
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
|
||||
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
||||
@ -294,7 +309,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
|
||||
updateWordText: (index, text) => {
|
||||
const { words, segments } = get();
|
||||
if (index < 0 || index >= words.length) return;
|
||||
if (index < 0 || index >= words.length || !text) return;
|
||||
const newWords = words.map((w, i) =>
|
||||
i === index ? { ...w, word: text } : w
|
||||
);
|
||||
@ -320,7 +335,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
addCutRange: (start, end, trimGroupId) => {
|
||||
const { cutRanges } = get();
|
||||
const { cutRanges, duration } = get();
|
||||
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
|
||||
const newRange: CutRange = {
|
||||
id: `cut_${nextRangeId++}`,
|
||||
start,
|
||||
@ -331,7 +347,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
addMuteRange: (start, end) => {
|
||||
const { muteRanges } = get();
|
||||
const { muteRanges, duration } = get();
|
||||
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
|
||||
const newRange: MuteRange = {
|
||||
id: `mute_${nextRangeId++}`,
|
||||
start,
|
||||
@ -341,7 +358,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
addGainRange: (start, end, gainDb) => {
|
||||
const { gainRanges } = get();
|
||||
const { gainRanges, duration } = get();
|
||||
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
|
||||
const newRange: GainRange = {
|
||||
id: `gain_${nextRangeId++}`,
|
||||
start,
|
||||
@ -352,7 +370,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
addSpeedRange: (start, end, speed) => {
|
||||
const { speedRanges } = get();
|
||||
const { speedRanges, duration } = get();
|
||||
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
|
||||
const newRange: SpeedRange = {
|
||||
id: `speed_${nextRangeId++}`,
|
||||
start,
|
||||
@ -437,6 +456,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
setGlobalGainDb: (gainDb) => {
|
||||
if (!isFinite(gainDb)) {
|
||||
set({ globalGainDb: 0 });
|
||||
return;
|
||||
}
|
||||
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
|
||||
},
|
||||
|
||||
@ -482,11 +505,13 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
addTimelineMarker: (time, label, color) => {
|
||||
const { timelineMarkers } = get();
|
||||
if (!isFinite(time) || time < 0) return;
|
||||
const { timelineMarkers, duration } = get();
|
||||
if (time > duration) return;
|
||||
const newMarker: TimelineMarker = {
|
||||
id: `marker_${nextRangeId++}`,
|
||||
time,
|
||||
label: label || `Marker ${timelineMarkers.length + 1}`,
|
||||
label: label || 'Marker',
|
||||
color: color || '#6366f1',
|
||||
};
|
||||
set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) });
|
||||
@ -529,6 +554,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
}),
|
||||
|
||||
setZonePreviewPaddingSeconds: (seconds) => {
|
||||
if (!isFinite(seconds)) return;
|
||||
const nextSeconds = Math.max(0, Math.min(10, seconds));
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds));
|
||||
@ -538,6 +564,9 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
|
||||
replaceWordRange: (startIndex, endIndex, newWords) => {
|
||||
const { words } = get();
|
||||
assert(startIndex >= 0 && startIndex < words.length, 'replaceWordRange: startIndex out of bounds');
|
||||
assert(endIndex >= 0 && endIndex < words.length, 'replaceWordRange: endIndex out of bounds');
|
||||
assert(startIndex <= endIndex, 'replaceWordRange: startIndex > endIndex');
|
||||
if (startIndex < 0 || endIndex >= words.length || startIndex > endIndex) return;
|
||||
|
||||
// Replace words in the range with new words
|
||||
@ -647,6 +676,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
setBackgroundMusic: (config) => {
|
||||
if (!config || !config.path) {
|
||||
set({ backgroundMusic: null });
|
||||
return;
|
||||
}
|
||||
set({ backgroundMusic: config });
|
||||
},
|
||||
|
||||
@ -657,9 +690,28 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
loadProject: (data) => {
|
||||
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath } = get();
|
||||
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath, duration } = get();
|
||||
const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`;
|
||||
|
||||
const isValidZone = (r: { start: number; end: number }) =>
|
||||
isFinite(r.start) && isFinite(r.end) && r.start >= 0 && r.end - r.start >= 0.01 && (duration <= 0 || r.end <= duration);
|
||||
|
||||
let removed = 0;
|
||||
const filterZones = <T extends { start: number; end: number }>(ranges: T[]): T[] => {
|
||||
const result: T[] = [];
|
||||
for (const r of ranges) {
|
||||
if (isValidZone(r)) { result.push(r); } else { removed++; }
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
|
||||
const legacyCuts = (data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end }));
|
||||
const cleanedCutRanges = filterZones<CutRange>([...(data.cutRanges || []), ...legacyCuts]);
|
||||
const cleanedMuteRanges = filterZones<MuteRange>(data.muteRanges || []);
|
||||
const cleanedGainRanges = filterZones<GainRange>(data.gainRanges || []);
|
||||
const cleanedSpeedRanges = filterZones<SpeedRange>(data.speedRanges || []);
|
||||
|
||||
let globalIdx = 0;
|
||||
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||
@ -676,14 +728,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
videoUrl: url,
|
||||
words: data.words || [],
|
||||
segments: annotatedSegments,
|
||||
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
|
||||
cutRanges: [
|
||||
...(data.cutRanges || []),
|
||||
...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end })),
|
||||
],
|
||||
muteRanges: data.muteRanges || [],
|
||||
gainRanges: data.gainRanges || [],
|
||||
speedRanges: data.speedRanges || [],
|
||||
cutRanges: cleanedCutRanges,
|
||||
muteRanges: cleanedMuteRanges,
|
||||
gainRanges: cleanedGainRanges,
|
||||
speedRanges: cleanedSpeedRanges,
|
||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||
timelineMarkers: data.timelineMarkers || [],
|
||||
@ -694,6 +742,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
additionalClips: data.additionalClips || [],
|
||||
backgroundMusic: data.backgroundMusic || null,
|
||||
});
|
||||
|
||||
return removed;
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
|
||||
4
frontend/src/vite-env.d.ts
vendored
4
frontend/src/vite-env.d.ts
vendored
@ -33,6 +33,10 @@ interface DesktopAPI {
|
||||
hasLicenseFeature: (feature: string) => Promise<boolean>;
|
||||
listModels: () => Promise<ModelInfo[]>;
|
||||
deleteModel: (path: string) => Promise<void>;
|
||||
logError: (message: string, stack: string, componentStack: string) => Promise<void>;
|
||||
writeAutosave: (data: string) => Promise<void>;
|
||||
readAutosave: () => Promise<string | null>;
|
||||
deleteAutosave: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
Reference in New Issue
Block a user