more stuff to improve robustness

This commit is contained in:
2026-05-06 14:25:23 -06:00
parent 9a301fe2a2
commit 4004312994
13 changed files with 505 additions and 54 deletions

View File

@ -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>
);
}

View File

@ -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>

View 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;
}
}

View File

@ -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

View File

@ -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);

View File

@ -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;

View 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);
}
}
}

View File

@ -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');
},
};

View File

@ -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>,
);

View File

@ -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: () => {

View File

@ -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 {