more stuff to improve robustness
This commit is contained in:
45
.github/workflows/ci.yml
vendored
Normal file
45
.github/workflows/ci.yml
vendored
Normal file
@ -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
|
||||||
@ -44,37 +44,38 @@ const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
|||||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | 'help' | null;
|
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | 'help' | null;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
projectFilePath,
|
projectFilePath,
|
||||||
videoPath,
|
videoPath,
|
||||||
exportedAudioPath,
|
exportedAudioPath,
|
||||||
words,
|
words,
|
||||||
segments,
|
segments,
|
||||||
cutRanges,
|
cutRanges,
|
||||||
muteRanges,
|
muteRanges,
|
||||||
gainRanges,
|
gainRanges,
|
||||||
speedRanges,
|
speedRanges,
|
||||||
globalGainDb,
|
globalGainDb,
|
||||||
silenceTrimGroups,
|
silenceTrimGroups,
|
||||||
transcriptionModel,
|
transcriptionModel,
|
||||||
language,
|
language,
|
||||||
isTranscribing,
|
isTranscribing,
|
||||||
transcriptionStatus,
|
transcriptionStatus,
|
||||||
markInTime,
|
markInTime,
|
||||||
markOutTime,
|
markOutTime,
|
||||||
loadVideo,
|
loadVideo,
|
||||||
setProjectFilePath,
|
setProjectFilePath,
|
||||||
setBackendUrl,
|
setBackendUrl,
|
||||||
clearMarkRange,
|
clearMarkRange,
|
||||||
setTranscription,
|
setTranscription,
|
||||||
setTranscriptionModel,
|
setTranscriptionModel,
|
||||||
setTranscribing,
|
setTranscribing,
|
||||||
selectedWordIndices,
|
selectedWordIndices,
|
||||||
addCutRange,
|
addCutRange,
|
||||||
addMuteRange,
|
addMuteRange,
|
||||||
addGainRange,
|
addGainRange,
|
||||||
addSpeedRange,
|
addSpeedRange,
|
||||||
} = useEditorStore();
|
backendUrl,
|
||||||
|
} = useEditorStore();
|
||||||
|
|
||||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||||
const [projectName, setProjectName] = useState<string | null>(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 [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
||||||
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
||||||
const [showFileMenu, setShowFileMenu] = useState(false);
|
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 canEdit = useLicenseStore((s) => s.canEdit);
|
||||||
const licenseStatus = useLicenseStore((s) => s.status);
|
const licenseStatus = useLicenseStore((s) => s.status);
|
||||||
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
|
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
|
||||||
@ -180,7 +185,10 @@ export default function App() {
|
|||||||
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
|
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
|
||||||
|
|
||||||
const loadProjectFromData = (data: any) => {
|
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({
|
const loadedSignature = JSON.stringify({
|
||||||
videoPath: data.videoPath,
|
videoPath: data.videoPath,
|
||||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||||
@ -211,6 +219,18 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useLicenseStore.getState().checkStatus();
|
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
|
// 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);
|
sessionStorage.removeItem(LAST_MEDIA_PATH_KEY);
|
||||||
}, [videoPath]);
|
}, [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 () => {
|
const handleLoadProject = async () => {
|
||||||
await runGuarded(async () => {
|
await runGuarded(async () => {
|
||||||
try {
|
try {
|
||||||
@ -404,6 +454,26 @@ export default function App() {
|
|||||||
setPendingProceedAction(null);
|
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) => {
|
const togglePanel = (panel: Panel) => {
|
||||||
setActivePanel((prev) => (prev === panel ? null : 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 (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
|
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
@ -1009,6 +1094,44 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
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() {
|
export default function DevPanel() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pathInput, setPathInput] = useState('');
|
const [pathInput, setPathInput] = useState('');
|
||||||
const [testResult, setTestResult] = useState<string | null>(null);
|
const [testResult, setTestResult] = useState<string | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
|
||||||
const { backendUrl, videoPath, loadVideo } = useEditorStore();
|
const { backendUrl, videoPath, loadVideo } = useEditorStore();
|
||||||
|
|
||||||
@ -121,6 +122,37 @@ export default function DevPanel() {
|
|||||||
{testResult}
|
{testResult}
|
||||||
</pre>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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 { useEditorStore } from '../store/editorStore';
|
||||||
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
|
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
|
||||||
import type { ExportOptions } from '../types/project';
|
import type { ExportOptions } from '../types/project';
|
||||||
|
import { assert } from '../lib/assert';
|
||||||
|
|
||||||
export default function ExportDialog() {
|
export default function ExportDialog() {
|
||||||
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } =
|
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);
|
setExportError(null);
|
||||||
try {
|
try {
|
||||||
const keepSegments = getKeepSegments();
|
const keepSegments = getKeepSegments();
|
||||||
|
assert(words.length > 0, 'handleExport: words is empty before building keep segments');
|
||||||
const deletedSet = getDeletedSet();
|
const deletedSet = getDeletedSet();
|
||||||
|
|
||||||
// Map frontend camelCase gain/speed fields to backend snake_case
|
// 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 { useLicenseStore } from '../store/licenseStore';
|
||||||
import { Virtuoso } from 'react-virtuoso';
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
|
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
|
||||||
|
import { assert } from '../lib/assert';
|
||||||
|
|
||||||
interface TranscriptEditorProps {
|
interface TranscriptEditorProps {
|
||||||
cutMode: boolean;
|
cutMode: boolean;
|
||||||
@ -206,6 +207,8 @@ export default function TranscriptEditor({
|
|||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
if (zoneDragStart.current !== null && zoneDragRange) {
|
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 startWord = words[zoneDragRange.start];
|
||||||
const endWord = words[zoneDragRange.end];
|
const endWord = words[zoneDragRange.end];
|
||||||
if (startWord && endWord && canEdit) {
|
if (startWord && endWord && canEdit) {
|
||||||
@ -269,6 +272,7 @@ export default function TranscriptEditor({
|
|||||||
|
|
||||||
// Snapshot indices and word timings before the async gap
|
// Snapshot indices and word timings before the async gap
|
||||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
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 startWord = words[sorted[0]];
|
||||||
const endWord = words[sorted[sorted.length - 1]];
|
const endWord = words[sorted[sorted.length - 1]];
|
||||||
if (!startWord || !endWord) {
|
if (!startWord || !endWord) {
|
||||||
@ -336,6 +340,8 @@ export default function TranscriptEditor({
|
|||||||
const cutSelectedWords = useCallback(() => {
|
const cutSelectedWords = useCallback(() => {
|
||||||
if (selectedWordIndices.length === 0) return;
|
if (selectedWordIndices.length === 0) return;
|
||||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
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 startTime = words[sorted[0]].start;
|
||||||
const endTime = words[sorted[sorted.length - 1]].end;
|
const endTime = words[sorted[sorted.length - 1]].end;
|
||||||
addCutRange(startTime, endTime);
|
addCutRange(startTime, endTime);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useEditorStore } from '../store/editorStore';
|
|||||||
import { useLicenseStore } from '../store/licenseStore';
|
import { useLicenseStore } from '../store/licenseStore';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { extractThumbnails } from '../lib/thumbnails';
|
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 RULER_H = 20; // px reserved at top of canvas for the time ruler
|
||||||
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
||||||
@ -821,6 +822,7 @@ export default function WaveformTimeline({
|
|||||||
}, [displayDuration, setCurrentTime, timelineSegments]);
|
}, [displayDuration, setCurrentTime, timelineSegments]);
|
||||||
|
|
||||||
const clientXToTime = useCallback((clientX: number): number => {
|
const clientXToTime = useCallback((clientX: number): number => {
|
||||||
|
assert(headCanvasRef.current !== null, 'clientXToTime: headCanvasRef.current is null');
|
||||||
const canvas = headCanvasRef.current;
|
const canvas = headCanvasRef.current;
|
||||||
const dur = waveformDataRef.current?.duration;
|
const dur = waveformDataRef.current?.duration;
|
||||||
if (!canvas || !dur) return 0;
|
if (!canvas || !dur) return 0;
|
||||||
@ -1122,6 +1124,8 @@ export default function WaveformTimeline({
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
if (selectionStartRef.current !== null && selectionEndRef.current !== null) {
|
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 start = Math.min(selectionStartRef.current, selectionEndRef.current);
|
||||||
const end = Math.max(selectionStartRef.current, selectionEndRef.current);
|
const end = Math.max(selectionStartRef.current, selectionEndRef.current);
|
||||||
const minDuration = 0.01;
|
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> => {
|
deleteModel: (path: string): Promise<void> => {
|
||||||
return invoke('delete_model', { path });
|
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
|
// Tauri bridge polyfill: must be imported before App so window.electronAPI is available to all components
|
||||||
import './lib/tauri-bridge';
|
import './lib/tauri-bridge';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import './index.css';
|
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(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { temporal } from 'zundo';
|
import { temporal } from 'zundo';
|
||||||
|
import { assert } from '../lib/assert';
|
||||||
import type {
|
import type {
|
||||||
Word,
|
Word,
|
||||||
Segment,
|
Segment,
|
||||||
@ -109,7 +110,7 @@ interface EditorActions {
|
|||||||
replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void;
|
replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void;
|
||||||
getKeepSegments: () => Array<{ start: number; end: number }>;
|
getKeepSegments: () => Array<{ start: number; end: number }>;
|
||||||
getWordAtTime: (time: number) => number;
|
getWordAtTime: (time: number) => number;
|
||||||
loadProject: (projectData: any) => void;
|
loadProject: (projectData: any) => number;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
setZoomConfig: (config: Partial<ZoomConfig>) => void;
|
setZoomConfig: (config: Partial<ZoomConfig>) => void;
|
||||||
addAdditionalClip: (path: string, label?: string) => void;
|
addAdditionalClip: (path: string, label?: string) => void;
|
||||||
@ -203,7 +204,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
|
|
||||||
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
||||||
|
|
||||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
setTranscriptionModel: (model) => {
|
||||||
|
if (model === null || model === '') return;
|
||||||
|
set({ transcriptionModel: model });
|
||||||
|
},
|
||||||
|
|
||||||
saveProject: (): ProjectFile => {
|
saveProject: (): ProjectFile => {
|
||||||
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath, zoomConfig, additionalClips, backgroundMusic } = get();
|
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) => {
|
loadVideo: (path) => {
|
||||||
|
if (!path) return;
|
||||||
const { backendUrl, zonePreviewPaddingSeconds } = get();
|
const { backendUrl, zonePreviewPaddingSeconds } = get();
|
||||||
const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`;
|
const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`;
|
||||||
set({
|
set({
|
||||||
@ -252,6 +257,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setTranscription: (result) => {
|
setTranscription: (result) => {
|
||||||
|
if (!result.words || result.words.length === 0) {
|
||||||
|
set({ words: [], segments: [], selectedWordIndices: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
let globalIdx = 0;
|
let globalIdx = 0;
|
||||||
const annotatedSegments = result.segments.map((seg) => {
|
const annotatedSegments = result.segments.map((seg) => {
|
||||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||||
@ -269,8 +278,14 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
setCurrentTime: (time) => set({ currentTime: time }),
|
setCurrentTime: (time) => set({ currentTime: time }),
|
||||||
setDuration: (duration) => set({ duration }),
|
setDuration: (duration) => set({ duration }),
|
||||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||||
setMarkInTime: (time) => set({ markInTime: time }),
|
setMarkInTime: (time) => {
|
||||||
setMarkOutTime: (time) => set({ markOutTime: 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 }),
|
clearMarkRange: () => set({ markInTime: null, markOutTime: null }),
|
||||||
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
|
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
|
||||||
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
||||||
@ -294,7 +309,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
|
|
||||||
updateWordText: (index, text) => {
|
updateWordText: (index, text) => {
|
||||||
const { words, segments } = get();
|
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) =>
|
const newWords = words.map((w, i) =>
|
||||||
i === index ? { ...w, word: text } : w
|
i === index ? { ...w, word: text } : w
|
||||||
);
|
);
|
||||||
@ -320,7 +335,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addCutRange: (start, end, trimGroupId) => {
|
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 = {
|
const newRange: CutRange = {
|
||||||
id: `cut_${nextRangeId++}`,
|
id: `cut_${nextRangeId++}`,
|
||||||
start,
|
start,
|
||||||
@ -331,7 +347,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addMuteRange: (start, end) => {
|
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 = {
|
const newRange: MuteRange = {
|
||||||
id: `mute_${nextRangeId++}`,
|
id: `mute_${nextRangeId++}`,
|
||||||
start,
|
start,
|
||||||
@ -341,7 +358,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addGainRange: (start, end, gainDb) => {
|
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 = {
|
const newRange: GainRange = {
|
||||||
id: `gain_${nextRangeId++}`,
|
id: `gain_${nextRangeId++}`,
|
||||||
start,
|
start,
|
||||||
@ -352,7 +370,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addSpeedRange: (start, end, speed) => {
|
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 = {
|
const newRange: SpeedRange = {
|
||||||
id: `speed_${nextRangeId++}`,
|
id: `speed_${nextRangeId++}`,
|
||||||
start,
|
start,
|
||||||
@ -437,6 +456,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setGlobalGainDb: (gainDb) => {
|
setGlobalGainDb: (gainDb) => {
|
||||||
|
if (!isFinite(gainDb)) {
|
||||||
|
set({ globalGainDb: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
|
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -482,11 +505,13 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addTimelineMarker: (time, label, color) => {
|
addTimelineMarker: (time, label, color) => {
|
||||||
const { timelineMarkers } = get();
|
if (!isFinite(time) || time < 0) return;
|
||||||
|
const { timelineMarkers, duration } = get();
|
||||||
|
if (time > duration) return;
|
||||||
const newMarker: TimelineMarker = {
|
const newMarker: TimelineMarker = {
|
||||||
id: `marker_${nextRangeId++}`,
|
id: `marker_${nextRangeId++}`,
|
||||||
time,
|
time,
|
||||||
label: label || `Marker ${timelineMarkers.length + 1}`,
|
label: label || 'Marker',
|
||||||
color: color || '#6366f1',
|
color: color || '#6366f1',
|
||||||
};
|
};
|
||||||
set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) });
|
set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) });
|
||||||
@ -529,6 +554,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
setZonePreviewPaddingSeconds: (seconds) => {
|
setZonePreviewPaddingSeconds: (seconds) => {
|
||||||
|
if (!isFinite(seconds)) return;
|
||||||
const nextSeconds = Math.max(0, Math.min(10, seconds));
|
const nextSeconds = Math.max(0, Math.min(10, seconds));
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds));
|
window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds));
|
||||||
@ -538,6 +564,9 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
|
|
||||||
replaceWordRange: (startIndex, endIndex, newWords) => {
|
replaceWordRange: (startIndex, endIndex, newWords) => {
|
||||||
const { words } = get();
|
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;
|
if (startIndex < 0 || endIndex >= words.length || startIndex > endIndex) return;
|
||||||
|
|
||||||
// Replace words in the range with new words
|
// Replace words in the range with new words
|
||||||
@ -647,6 +676,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setBackgroundMusic: (config) => {
|
setBackgroundMusic: (config) => {
|
||||||
|
if (!config || !config.path) {
|
||||||
|
set({ backgroundMusic: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({ backgroundMusic: config });
|
set({ backgroundMusic: config });
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -657,9 +690,28 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadProject: (data) => {
|
loadProject: (data) => {
|
||||||
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath } = get();
|
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath, duration } = get();
|
||||||
const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`;
|
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;
|
let globalIdx = 0;
|
||||||
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
||||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||||
@ -676,14 +728,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
videoUrl: url,
|
videoUrl: url,
|
||||||
words: data.words || [],
|
words: data.words || [],
|
||||||
segments: annotatedSegments,
|
segments: annotatedSegments,
|
||||||
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
|
cutRanges: cleanedCutRanges,
|
||||||
cutRanges: [
|
muteRanges: cleanedMuteRanges,
|
||||||
...(data.cutRanges || []),
|
gainRanges: cleanedGainRanges,
|
||||||
...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end })),
|
speedRanges: cleanedSpeedRanges,
|
||||||
],
|
|
||||||
muteRanges: data.muteRanges || [],
|
|
||||||
gainRanges: data.gainRanges || [],
|
|
||||||
speedRanges: data.speedRanges || [],
|
|
||||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||||
timelineMarkers: data.timelineMarkers || [],
|
timelineMarkers: data.timelineMarkers || [],
|
||||||
@ -694,6 +742,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
additionalClips: data.additionalClips || [],
|
additionalClips: data.additionalClips || [],
|
||||||
backgroundMusic: data.backgroundMusic || null,
|
backgroundMusic: data.backgroundMusic || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return removed;
|
||||||
},
|
},
|
||||||
|
|
||||||
reset: () => {
|
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>;
|
hasLicenseFeature: (feature: string) => Promise<boolean>;
|
||||||
listModels: () => Promise<ModelInfo[]>;
|
listModels: () => Promise<ModelInfo[]>;
|
||||||
deleteModel: (path: string) => Promise<void>;
|
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 {
|
interface Window {
|
||||||
|
|||||||
@ -208,6 +208,17 @@ async fn save_captions(content: String, output_path: String) -> Result<String, S
|
|||||||
.map_err(|e| format!("Task error: {:?}", e))?
|
.map_err(|e| format!("Task error: {:?}", e))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List downloaded models (Whisper + LLM) with sizes.
|
||||||
|
#[tauri::command]
|
||||||
|
fn log_error(message: String, stack: String, component_stack: String) {
|
||||||
|
log::error!(
|
||||||
|
"[Frontend Error] {} — Stack: {} — Component: {}",
|
||||||
|
message,
|
||||||
|
stack,
|
||||||
|
component_stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// List downloaded models (Whisper + LLM) with sizes.
|
/// List downloaded models (Whisper + LLM) with sizes.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn list_models(app_handle: tauri::AppHandle) -> Result<Vec<models::ModelInfo>, String> {
|
fn list_models(app_handle: tauri::AppHandle) -> Result<Vec<models::ModelInfo>, 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))?
|
.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<Option<String>, 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 ---
|
// --- App entry point ---
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@ -377,6 +421,10 @@ pub fn run() {
|
|||||||
has_license_feature,
|
has_license_feature,
|
||||||
list_models,
|
list_models,
|
||||||
delete_model,
|
delete_model,
|
||||||
|
log_error,
|
||||||
|
write_autosave,
|
||||||
|
read_autosave,
|
||||||
|
delete_autosave,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
Reference in New Issue
Block a user