diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1bc18fb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test + working-directory: src-tauri + - run: cargo check --release + working-directory: src-tauri + - run: cargo clippy -- -D warnings + working-directory: src-tauri + continue-on-error: true + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + working-directory: frontend + - run: npx tsc --noEmit + working-directory: frontend + - run: npx vitest run + working-directory: frontend + + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install pytest + - run: python -m pytest backend/tests/ || true diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bde83d7..eea9bd8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [projectName, setProjectName] = useState(null); @@ -142,6 +143,10 @@ export default function App() { const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise) | null>(null); const [lastSavedSignature, setLastSavedSignature] = useState(null); const [showFileMenu, setShowFileMenu] = useState(false); + const [showRecoveryDialog, setShowRecoveryDialog] = useState(false); + const [recoveryData, setRecoveryData] = useState(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 (
{/* Top bar */} @@ -1009,6 +1094,44 @@ export default function App() {
)} + + {showRecoveryDialog && ( +
+
e.stopPropagation()} + > +

Recover unsaved work?

+

+ TalkEdit recovered a project from {recoveryMinutesAgo} minute{recoveryMinutesAgo !== 1 ? 's' : ''} ago. +

+
+ + +
+
+
+ )} + + {backendDown && ( +
+ + Backend disconnected — retrying... +
+ )} ); } diff --git a/frontend/src/components/DevPanel.tsx b/frontend/src/components/DevPanel.tsx index 524fc3a..ce8468d 100644 --- a/frontend/src/components/DevPanel.tsx +++ b/frontend/src/components/DevPanel.tsx @@ -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(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} )} + {/* Danger Zone */} +
+
Danger Zone
+ {!showResetConfirm ? ( + + ) : ( +
+

This will clear all editor data and reload the page. Unsaved changes will be lost.

+
+ + +
+
+ )} +
)} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..31a597e --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( +
+
+
+ + + +
+

Something went wrong

+

+ An unexpected error occurred. Your work may still be recoverable. +

+
+ + {this.state.error && ( +
+ + Error details + +
+                {this.state.error.message}
+                {'\n'}
+                {this.state.error.stack}
+              
+
+ )} + +
+ + +
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/src/components/ExportDialog.tsx b/frontend/src/components/ExportDialog.tsx index b955ca8..7a7ba14 100644 --- a/frontend/src/components/ExportDialog.tsx +++ b/frontend/src/components/ExportDialog.tsx @@ -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 diff --git a/frontend/src/components/TranscriptEditor.tsx b/frontend/src/components/TranscriptEditor.tsx index 558206d..1983090 100644 --- a/frontend/src/components/TranscriptEditor.tsx +++ b/frontend/src/components/TranscriptEditor.tsx @@ -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); diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx index 39494c4..5e40762 100644 --- a/frontend/src/components/WaveformTimeline.tsx +++ b/frontend/src/components/WaveformTimeline.tsx @@ -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; diff --git a/frontend/src/lib/assert.ts b/frontend/src/lib/assert.ts new file mode 100644 index 0000000..ca94cd4 --- /dev/null +++ b/frontend/src/lib/assert.ts @@ -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); + } + } +} diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index 6deb994..a840ea4 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -120,4 +120,20 @@ window.electronAPI = { deleteModel: (path: string): Promise => { return invoke('delete_model', { path }); }, + + logError: (message: string, stack: string, componentStack: string): Promise => { + return invoke('log_error', { message, stack, componentStack }); + }, + + writeAutosave: (data: string): Promise => { + return invoke('write_autosave', { data }); + }, + + readAutosave: (): Promise => { + return invoke('read_autosave'); + }, + + deleteAutosave: (): Promise => { + return invoke('delete_autosave'); + }, }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0ea679a..3f2e146 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - + + + , ); diff --git a/frontend/src/store/editorStore.ts b/frontend/src/store/editorStore.ts index df9c7ce..eaf19d6 100644 --- a/frontend/src/store/editorStore.ts +++ b/frontend/src/store/editorStore.ts @@ -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) => void; addAdditionalClip: (path: string, label?: string) => void; @@ -203,7 +204,10 @@ export const useEditorStore = create()( 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()( }, 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()( }, 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()( 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()( 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()( }, 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()( }, 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()( }, 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()( }, 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()( }, 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()( }, 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()( }), 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()( 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()( }, setBackgroundMusic: (config) => { + if (!config || !config.path) { + set({ backgroundMusic: null }); + return; + } set({ backgroundMusic: config }); }, @@ -657,9 +690,28 @@ export const useEditorStore = create()( }, 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 = (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([...(data.cutRanges || []), ...legacyCuts]); + const cleanedMuteRanges = filterZones(data.muteRanges || []); + const cleanedGainRanges = filterZones(data.gainRanges || []); + const cleanedSpeedRanges = filterZones(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()( 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()( additionalClips: data.additionalClips || [], backgroundMusic: data.backgroundMusic || null, }); + + return removed; }, reset: () => { diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 7890d71..4d47d46 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -33,6 +33,10 @@ interface DesktopAPI { hasLicenseFeature: (feature: string) => Promise; listModels: () => Promise; deleteModel: (path: string) => Promise; + logError: (message: string, stack: string, componentStack: string) => Promise; + writeAutosave: (data: string) => Promise; + readAutosave: () => Promise; + deleteAutosave: () => Promise; } interface Window { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3586492..6752e78 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -208,6 +208,17 @@ async fn save_captions(content: String, output_path: String) -> Result Result, String> { @@ -310,6 +321,39 @@ async fn remove_background_on_export(input_path: String, output_path: String, re .map_err(|e| format!("Task error: {:?}", e))? } +/// Write autosave data to the app data directory +#[tauri::command] +fn write_autosave(app_handle: tauri::AppHandle, data: String) -> Result<(), String> { + let data_dir = app_handle.path().app_data_dir().map_err(|e| format!("No app data directory: {e}"))?; + let path = data_dir.join("autosave.json"); + std::fs::write(&path, data).map_err(|e| format!("Failed to write autosave: {e}"))?; + Ok(()) +} + +/// Read autosave data if it exists +#[tauri::command] +fn read_autosave(app_handle: tauri::AppHandle) -> Result, String> { + let data_dir = app_handle.path().app_data_dir().map_err(|e| format!("No app data directory: {e}"))?; + let path = data_dir.join("autosave.json"); + match std::fs::read_to_string(&path) { + Ok(data) => Ok(Some(data)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("Failed to read autosave: {e}")), + } +} + +/// Delete the autosave file +#[tauri::command] +fn delete_autosave(app_handle: tauri::AppHandle) -> Result<(), String> { + let data_dir = app_handle.path().app_data_dir().map_err(|e| format!("No app data directory: {e}"))?; + let path = data_dir.join("autosave.json"); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to delete autosave: {e}")), + } +} + // --- App entry point --- #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -377,6 +421,10 @@ pub fn run() { has_license_feature, list_models, delete_model, + log_error, + write_autosave, + read_autosave, + delete_autosave, ]) .run(tauri::generate_context!()) .expect("error while running tauri application");