(loadExportHistory);
+ const mountedRef = useRef(true);
+ const visibleExportError = exportError || (exportFailure ? getFriendlyExportError(exportFailure) : '');
const readiness = getExportReadiness(options, hasCuts, words.length, !!window.electronAPI, systemChecks);
const preflight = getExportPreflight({
videoPath,
@@ -358,6 +357,13 @@ export default function ExportDialog() {
systemChecks,
});
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
useEffect(() => {
let canceled = false;
fetch(`${backendUrl}/background/capabilities`)
@@ -529,51 +535,37 @@ export default function ExportDialog() {
window.localStorage.removeItem(EXPORT_HISTORY_KEY);
}, []);
- const pollExportJob = useCallback(
- async (jobId: string) => {
- for (;;) {
- await new Promise((resolve) => window.setTimeout(resolve, 700));
- const res = await fetch(`${backendUrl}/jobs/${jobId}`);
- if (!res.ok) throw new Error(`Could not read export job: ${res.statusText}`);
-
- const job = (await res.json()) as ExportJob;
- setExportMessage(job.message || job.status);
- setExportLogs(job.logs || []);
- setExporting(job.status === 'queued' || job.status === 'running' || job.status === 'canceling', job.progress);
-
- if (job.status === 'succeeded') {
- const outputPath = job.result?.output_path || '';
- const srtPath = job.result?.srt_path;
- setExportResult({
- outputPath,
- srtPath,
- warnings: job.result?.warnings || [],
- downloadUrl: buildBackendFileUrl(backendUrl, outputPath, job.result?.file_capability),
- srtDownloadUrl: buildBackendFileUrl(backendUrl, srtPath, job.result?.srt_file_capability),
- });
- if (outputPath) {
- rememberExport({
- outputPath,
- srtPath,
- exportedAt: new Date().toISOString(),
- preset: options.preset,
- format: options.format,
- });
- }
- setExportJobId('');
- setLastExportJobId(jobId);
- setExporting(false, 100);
- return;
- }
-
- if (job.status === 'failed' || job.status === 'canceled') {
- setLastExportJobId(jobId);
- throw new Error(job.error || job.message || `Export ${job.status}`);
- }
- }
- },
- [backendUrl, options.format, options.preset, rememberExport, setExporting],
- );
+ const applyExportCompletion = useCallback((completion: FullVideoExportCompletion) => {
+ if (!mountedRef.current) return;
+ if (useEditorStore.getState().videoPath !== completion.context.mediaPath) return;
+ if (completion.context.workspaceRevision !== workspaceRevision) return;
+ const outputPath = completion.result.output_path || '';
+ const srtPath = completion.result.srt_path;
+ setExportResult({
+ outputPath,
+ srtPath,
+ warnings: completion.result.warnings || [],
+ downloadUrl: buildBackendFileUrl(backendUrl, outputPath, completion.result.file_capability),
+ srtDownloadUrl: buildBackendFileUrl(backendUrl, srtPath, completion.result.srt_file_capability),
+ });
+ if (outputPath) {
+ rememberExport({
+ outputPath,
+ srtPath,
+ exportedAt: new Date().toISOString(),
+ preset: completion.context.preset,
+ format: completion.context.format,
+ });
+ }
+ }, [backendUrl, rememberExport, workspaceRevision]);
+
+ useEffect(() => {
+ if (!pendingCompletion) return;
+ if (pendingCompletion.completion.context.mediaPath !== videoPath) return;
+ if (pendingCompletion.completion.context.workspaceRevision !== workspaceRevision) return;
+ applyExportCompletion(pendingCompletion.completion);
+ acknowledgeCompletion(pendingCompletion.jobId);
+ }, [acknowledgeCompletion, applyExportCompletion, pendingCompletion, videoPath, workspaceRevision]);
const startExport = useCallback(async (exportOptions: ProjectExportOptions) => {
if (!videoPath) return;
@@ -611,6 +603,7 @@ export default function ExportDialog() {
})
: undefined;
if (window.electronAPI && !outputPath) return;
+ if (useEditorStore.getState().videoPath !== videoPath) return;
if (window.electronAPI && outputPath && !exportPath && !exportDirectory) {
setExportPath(outputPath);
window.localStorage.setItem(EXPORT_PATH_KEY, outputPath);
@@ -618,10 +611,6 @@ export default function ExportDialog() {
setExportError('');
setExportResult(null);
- setExportLogs([]);
- setLastExportJobId('');
- setExporting(true, 1);
- setExportMessage('Starting export');
try {
const keepSegments = getKeepSegments();
@@ -632,10 +621,14 @@ export default function ExportDialog() {
}
for (const idx of getCaptionHiddenIndices()) deletedSet.add(idx);
- const res = await fetch(`${backendUrl}/jobs/export`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
+ await startExportJob({
+ context: {
+ mediaPath: videoPath,
+ workspaceRevision,
+ preset: exportOptions.preset,
+ format: exportOptions.format,
+ },
+ body: {
...exportOptions,
input_path: videoPath,
output_path: outputPath || undefined,
@@ -644,28 +637,14 @@ export default function ExportDialog() {
words: exportOptions.captions !== 'none' ? words : undefined,
deleted_indices: exportOptions.captions !== 'none' ? [...deletedSet] : undefined,
captionStyle: exportOptions.captions === 'burn-in' ? exportOptions.captionStyle : undefined,
- }),
+ },
});
- if (!res.ok) {
- let detail = res.statusText;
- try {
- const data = await res.json();
- detail = data.detail || JSON.stringify(data);
- } catch {
- // Keep the HTTP status text if the backend did not return JSON.
- }
- throw new Error(`Export start failed: ${detail}`);
- }
- const { job_id: jobId } = await res.json();
- setExportJobId(jobId);
- setLastExportJobId(jobId);
- await pollExportJob(jobId);
} catch (err) {
console.error('Export error:', err);
+ if (!mountedRef.current || useEditorStore.getState().videoPath !== videoPath) return;
setExportError(getFriendlyExportError(err instanceof Error ? err.message : String(err)));
- setExporting(false, 0);
}
- }, [videoPath, duration, hasCuts, words, backgroundCapabilities, exportDirectory, exportPath, systemChecks, backendUrl, setExporting, getKeepSegments, getMutedRanges, getCaptionHiddenIndices, deletedRanges, pollExportJob]);
+ }, [videoPath, duration, hasCuts, words, backgroundCapabilities, exportDirectory, exportPath, systemChecks, getKeepSegments, getMutedRanges, getCaptionHiddenIndices, deletedRanges, startExportJob, workspaceRevision]);
const handleExport = useCallback(() => {
void startExport(options);
@@ -678,35 +657,26 @@ export default function ExportDialog() {
}, [options, setExportOptions, startExport]);
const cancelExport = useCallback(async () => {
- if (!exportJobId) return;
- await fetch(`${backendUrl}/jobs/${exportJobId}/cancel`, { method: 'POST' });
- setExportJobId('');
- setExportMessage('Cancel requested');
- }, [backendUrl, exportJobId]);
+ try {
+ await cancelExportJob();
+ } catch (err) {
+ if (!mountedRef.current) return;
+ setExportError(getFriendlyExportError(err instanceof Error ? err.message : String(err)));
+ }
+ }, [cancelExportJob]);
const retryExport = useCallback(async () => {
- if (!lastExportJobId) return;
+ if (!canRetryExport) return;
setExportError('');
setExportResult(null);
- setExporting(true, 1);
- setExportMessage('Retrying export');
- const res = await fetch(`${backendUrl}/jobs/${lastExportJobId}/retry`, { method: 'POST' });
- if (!res.ok) {
- setExporting(false, 0);
- setExportError(`Retry failed: ${res.statusText}`);
- return;
- }
- const { job_id: jobId } = await res.json();
- setExportJobId(jobId);
- setLastExportJobId(jobId);
try {
- await pollExportJob(jobId);
+ await retryExportJob();
} catch (err) {
console.error('Export retry error:', err);
+ if (!mountedRef.current) return;
setExportError(getFriendlyExportError(err instanceof Error ? err.message : String(err)));
- setExporting(false, 0);
}
- }, [backendUrl, lastExportJobId, pollExportJob, setExporting]);
+ }, [canRetryExport, retryExportJob]);
return (
@@ -912,8 +882,9 @@ export default function ExportDialog() {
{isExporting && exportJobId && (
@@ -1074,10 +1045,10 @@ export default function ExportDialog() {
)}
- {exportError && (
+ {visibleExportError && (
-
{exportError}
- {lastExportJobId && (
+
{visibleExportError}
+ {canRetryExport && (