From cef9a0e7caa6b39df19d3cdb4ee0e5bce8e78510 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Mon, 7 Sep 2026 02:15:53 -0700 Subject: [PATCH] refactor: extract clip export controller --- frontend/scripts/smoke-clip-batch-export.mjs | 56 +- .../scripts/smoke-clip-prepare-export.mjs | 5 +- .../scripts/smoke-create-clips-workspace.mjs | 8 +- frontend/src/components/AIPanel.tsx | 458 +-------------- .../features/clips/useClipExportController.ts | 528 ++++++++++++++++++ 5 files changed, 586 insertions(+), 469 deletions(-) create mode 100644 frontend/src/features/clips/useClipExportController.ts diff --git a/frontend/scripts/smoke-clip-batch-export.mjs b/frontend/scripts/smoke-clip-batch-export.mjs index ee6e4d9..173b94f 100644 --- a/frontend/scripts/smoke-clip-batch-export.mjs +++ b/frontend/scripts/smoke-clip-batch-export.mjs @@ -188,6 +188,7 @@ assert.deepEqual( ); const panelSource = sourceFor('../src/components/AIPanel.tsx'); +const controllerSource = sourceFor('../src/features/clips/useClipExportController.ts'); const cardSource = sourceFor('../src/features/clips/ClipDraftCard.tsx'); const controlsSource = sourceFor('../src/features/clips/ClipPrepareExportControls.tsx'); const exportFilesSource = sourceFor('../src/features/clips/clipExportFiles.ts'); @@ -195,26 +196,28 @@ const autosaveSource = sourceFor('../src/hooks/useProjectAutosave.ts'); const projectSource = sourceFor('../src/types/project.ts'); const manifestSource = exportFilesSource.slice(exportFilesSource.indexOf('export function buildClipBatchManifest')); -assert.match(panelSource, /getClipBatchExportCandidates\(clipDrafts, words, videoPath\)/); -assert.match(panelSource, /for \(let index = 0; index < exportableDrafts.length; index\+\+\)/); -assert.doesNotMatch(panelSource, /Promise\.all\([^)]*export/); -assert.match(panelSource, /const exportBusy = isBatchExporting \|\| exportingDraftId !== null/); +assert.match(panelSource, /useClipExportController\(\{ setCreatorNotice \}\)/); +assert.doesNotMatch(panelSource, /pollClipExportJob|setClipExportJobs|stopBatchExportRef|\/jobs\/export/, 'AIPanel must not own clip export job orchestration'); +assert.match(controllerSource, /getClipBatchExportCandidates\(clipDrafts, words, videoPath\)/); +assert.match(controllerSource, /for \(let index = 0; index < exportableDrafts.length; index\+\+\)/); +assert.doesNotMatch(controllerSource, /Promise\.all\([^)]*export/); +assert.match(controllerSource, /const exportBusy = isBatchExporting \|\| exportingDraftId !== null/); assert.match(panelSource, /exportBusy=\{exportBusy\}/); assert.match(controlsSource, /disabled=\{exportBusy \|\| exportableDraftCount === 0\}/); assert.match(cardSource, /disabled=\{!canExport \|\| exportBusy \|\| isExporting \|\| exportActive\}/); assert.match(cardSource, /disabled=\{!exportValidation\.ready \|\| exportBusy\}/); assert.doesNotMatch(controlsSource, /useAIStore|useEditorStore|fetch\(|localStorage/, 'prepare/export controls must stay presentational'); -assert.ok((panelSource.match(/if \(exportBusy\) return;/g) || []).length >= 3, 'export handlers have defensive busy guards'); +assert.ok((controllerSource.match(/if \(exportBusy\) return;/g) || []).length >= 4, 'export handlers have defensive busy guards'); assert.match(controlsSource, /onClick=\{onStopBatchExport\}/); -assert.match(panelSource, /for \(let index = 0; index < exportableDrafts.length; index\+\+\)[\s\S]*?await handleExportClip/); -assert.doesNotMatch(panelSource, /\/jobs\/export-batch|\/export\/v2/); -const cancelHandlerSource = panelSource.slice(panelSource.indexOf('const cancelDraftExport'), panelSource.indexOf('const retryDraftExport')); +assert.match(controllerSource, /for \(let index = 0; index < exportableDrafts.length; index\+\+\)[\s\S]*?await handleExportClip/); +assert.doesNotMatch(controllerSource, /\/jobs\/export-batch|\/export\/v2/); +const cancelHandlerSource = controllerSource.slice(controllerSource.indexOf('const cancelDraftExport'), controllerSource.indexOf('const retryDraftExport')); assert.match(cancelHandlerSource, /\/cancel/); assert.doesNotMatch(cancelHandlerSource, /status: 'failed'/, 'cancel request must not mark the draft terminal before polling confirms cancellation'); assert.doesNotMatch(cancelHandlerSource, /setExportingDraftId/, 'cancel request must keep exportBusy active until the export poll reaches a terminal state'); -assert.match(panelSource, /failedCount/); -assert.match(panelSource, /handleExportClip\(draft, draft, true\)/); -assert.match(panelSource, /if \(stopBatchExportRef\.current\) break/); +assert.match(controllerSource, /failedCount/); +assert.match(controllerSource, /handleExportClip\(draft, draft, true\)/); +assert.match(controllerSource, /if \(stopBatchExportRef\.current\) break/); assert.match(controlsSource, /Stopping after current clip/); assert.match(cardSource, /Retry export/); assert.match(cardSource, /const exportRetryable = status === 'failed'/); @@ -225,25 +228,26 @@ assert.ok( (panelSource.match(/if \(isClipTimelineMutationBlocked\(\)\) return;/g) || []).length >= 7, 'compound filler/edit-plan timeline actions must not update decisions while a clip export blocks timeline mutation', ); -assert.match(panelSource, /const currentDraft = useAIStore\.getState\(\)\.clipDrafts\.find/); -assert.match(panelSource, /clearClipExportAttempt\(currentDraft\.id\)/); -assert.doesNotMatch(panelSource, /\/jobs\/\$\{job\.id\}\/retry/, 'clip retry must not replay an old backend export target'); -assert.match(panelSource, /handleExportClip\(draft, draft, true\)/); -assert.match(panelSource, /handleExportClip\(currentDraft, currentDraft, true\)/); -const handleExportClipSource = panelSource.slice(panelSource.indexOf('const handleExportClip'), panelSource.indexOf('const cancelDraftExport')); +assert.match(controllerSource, /const currentDraft = useAIStore\.getState\(\)\.clipDrafts\.find/); +assert.match(controllerSource, /clearClipExportAttempt\(currentDraft\.id\)/); +assert.doesNotMatch(controllerSource, /\/jobs\/\$\{job\.id\}\/retry/, 'clip retry must not replay an old backend export target'); +assert.match(controllerSource, /handleExportClip\(draft, draft, true\)/); +assert.match(controllerSource, /handleExportClip\(currentDraft, currentDraft, true\)/); +const handleExportClipSource = controllerSource.slice(controllerSource.indexOf('const handleExportClip'), controllerSource.indexOf('const cancelDraftExport')); assert.ok( handleExportClipSource.indexOf("status: 'exporting'") < handleExportClipSource.indexOf('await fetch(`${backendUrl}/jobs/export`'), 'durable exporting claim must happen before the first export request await', ); -assert.match(panelSource, /getCurrentClipBatchDraftForExport\([\s\S]*?plannedDraft\.id/); -assert.match(panelSource, /pausedForDraftChange = true/); -assert.match(panelSource, /Batch paused because a planned clip changed/); -assert.match(panelSource, /outputPath/); -assert.match(panelSource, /srtPath: output\.srtPath/); -assert.match(panelSource, /exportWarnings: output\.warnings/); -assert.match(panelSource, /manifest/); -assert.match(panelSource, /manifestWarning/); -assert.match(panelSource, /writeManifest: window\.electronAPI\?\.writeClipManifest/); +assert.match(controllerSource, /getCurrentClipBatchDraftForExport\([\s\S]*?plannedDraft\.id/); +assert.match(controllerSource, /pausedForDraftChange = true/); +assert.match(controllerSource, /Batch paused because a planned clip changed/); +assert.match(controllerSource, /outputPath/); +assert.match(controllerSource, /srtPath: output\.srtPath/); +assert.match(controllerSource, /exportWarnings: output\.warnings/); +assert.match(controllerSource, /manifest/); +assert.match(controllerSource, /manifestWarning/); +assert.match(controllerSource, /writeManifest: window\.electronAPI\?\.writeClipManifest/); +assert.doesNotMatch(controllerSource, /defaultProvider|providers|startAIJob|clip-metadata/, 'clip export controller must not absorb AI/publishing orchestration'); assert.match(manifestSource, /schema: 'scriptcut\.clipBatchManifest\.v1'/); assert.match(manifestSource, /remaining/); assert.match(manifestSource, /stopped/); diff --git a/frontend/scripts/smoke-clip-prepare-export.mjs b/frontend/scripts/smoke-clip-prepare-export.mjs index 2ee34bd..97afd7d 100644 --- a/frontend/scripts/smoke-clip-prepare-export.mjs +++ b/frontend/scripts/smoke-clip-prepare-export.mjs @@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const panelSource = readFileSync(resolve(__dirname, '../src/components/AIPanel.tsx'), 'utf8'); +const controllerSource = readFileSync(resolve(__dirname, '../src/features/clips/useClipExportController.ts'), 'utf8'); const cardSource = readFileSync(resolve(__dirname, '../src/features/clips/ClipDraftCard.tsx'), 'utf8'); assert.match(cardSource, /const \[advancedExportOpen, setAdvancedExportOpen\] = useState\(false\)/); @@ -51,7 +52,7 @@ assert.match(panelSource, /srtPath: undefined/); assert.match(panelSource, /exportedAt: undefined/); assert.match(cardSource, /disabled=\{!exportValidation\.ready \|\| exportBusy\}/); -assert.match(panelSource, /const \[clipExportOutputs, setClipExportOutputs\]/); +assert.match(controllerSource, /const \[clipExportOutputs, setClipExportOutputs\]/); assert.match(cardSource, /Clip ready/); assert.match(cardSource, /Video output/); assert.match(cardSource, /Reveal in Finder/); @@ -60,4 +61,4 @@ assert.match(cardSource, /SRT sidecar/); assert.match(cardSource, /Download SRT/); assert.match(cardSource, /const srtPath = exportResult\?\.srtPath \|\| draft\.srtPath/); assert.match(cardSource, /const exportWarnings = exportResult\?\.warnings \|\| draft\.exportWarnings \|\| \[\]/); -assert.match(panelSource, /\$\{successCount\} clip\$\{successCount === 1 \? '' : 's'\} ready/); +assert.match(controllerSource, /\$\{successCount\} clip\$\{successCount === 1 \? '' : 's'\} ready/); diff --git a/frontend/scripts/smoke-create-clips-workspace.mjs b/frontend/scripts/smoke-create-clips-workspace.mjs index 3ea3756..ed711bf 100644 --- a/frontend/scripts/smoke-create-clips-workspace.mjs +++ b/frontend/scripts/smoke-create-clips-workspace.mjs @@ -16,6 +16,7 @@ const findStageSource = readSource('../src/features/clips/ClipFindStage.tsx'); const prepareExportSource = readSource('../src/features/clips/ClipPrepareExportControls.tsx'); const clipDraftModelSource = readSource('../src/features/clips/clipDraftModel.ts'); const clipExportFilesSource = readSource('../src/features/clips/clipExportFiles.ts'); +const clipExportControllerSource = readSource('../src/features/clips/useClipExportController.ts'); const reviewActionsSource = readSource('../src/features/clips/useClipReviewActions.ts'); const exportDialogSource = readSource('../src/components/ExportDialog.tsx'); const reviewSource = readSource('../src/features/clips/ClipReviewWorkspace.tsx'); @@ -34,6 +35,7 @@ assert.match(panelSource, /import ClipFindStage from '\.\.\/features\/clips\/Cli assert.match(panelSource, /import ClipPrepareExportControls from '\.\.\/features\/clips\/ClipPrepareExportControls'/); assert.match(panelSource, /import ClipWorkspaceHeader from '\.\.\/features\/clips\/ClipWorkspaceHeader'/); assert.match(panelSource, /import \{ useClipReviewActions \} from '\.\.\/features\/clips\/useClipReviewActions'/); +assert.match(panelSource, /import \{ useClipExportController \} from '\.\.\/features\/clips\/useClipExportController'/); assert.match(panelSource, /(null); const [activeClipDraftId, setActiveClipDraftId] = useState(null); const [activeClipPreviewKey, setActiveClipPreviewKey] = useState(null); - const [clipExportDirectory, setClipExportDirectory] = useState(() => window.localStorage.getItem(CLIP_EXPORT_DIRECTORY_KEY) || ''); const [creatorNotice, setCreatorNotice] = useState(null); + const { + batchExportProgress, + cancelDraftExport, + chooseClipExportDirectory, + clearClipExportAttempt, + clipExportDirectory, + clipExportJobs, + clipExportOutputs, + exportBusy, + exportCandidateDrafts, + exportingDraftId, + handleExportAllDrafts, + handleExportDraft, + isBatchExporting, + retryDraftExport, + stopBatchExport, + updateClipExportDirectory, + } = useClipExportController({ setCreatorNotice }); const knownClipDraftIdsRef = useRef(new Set(clipDrafts.map((draft) => draft.id))); const secondaryToolsVisible = mode === 'clips' && (showSecondaryTools || activeTab === 'edit' || activeTab === 'filler'); @@ -217,11 +220,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }; }, [backendUrl]); - useEffect(() => { - if (!videoPath) return; - setClipExportDirectory((current) => current || getPathDirectory(videoPath)); - }, [videoPath]); - useEffect(() => { const newlyAddedManualDrafts = getNewManualClipDrafts(clipDrafts, knownClipDraftIdsRef.current); for (const draft of clipDrafts) knownClipDraftIdsRef.current.add(draft.id); @@ -769,29 +767,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { [requestPreviewRange, setClipPresentationPreview, setCreatorNotice, setSelectedWordIndices, words], ); - const [exportingDraftId, setExportingDraftId] = useState(null); - const [clipExportJobs, setClipExportJobs] = useState>({}); - const [clipExportOutputs, setClipExportOutputs] = useState>({}); - const [isBatchExporting, setBatchExporting] = useState(false); - const [batchExportProgress, setBatchExportProgress] = useState({ - processed: 0, - total: 0, - exported: 0, - failed: 0, - stopping: false, - }); - const exportBusy = isBatchExporting || exportingDraftId !== null; - const stopBatchExportRef = useRef(false); const [publishingCopyDraftId, setPublishingCopyDraftId] = useState(null); - - useEffect(() => { - setClipExportOutputs({}); - }, [clipWorkspaceEpoch]); - - const exportCandidateDrafts = useMemo( - () => getClipBatchExportCandidates(clipDrafts, words, videoPath), - [clipDrafts, videoPath, words], - ); const preparableDrafts = useMemo( () => clipDrafts.filter( (draft) => @@ -903,21 +879,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { setClipStage(hasRemainingDrafts ? 'prepare' : 'export'); }, [preparableDrafts.length, setClipDrafts, videoPath, words]); - const clearClipExportAttempt = useCallback((draftId: string) => { - setClipExportJobs((current) => { - if (!current[draftId]) return current; - const next = { ...current }; - delete next[draftId]; - return next; - }); - setClipExportOutputs((current) => { - if (!current[draftId]) return current; - const next = { ...current }; - delete next[draftId]; - return next; - }); - }, []); - const changeClipDraft = useCallback((draft: ClipDraft, patch: Partial) => { const edit = getClipDraftUserEditResult(draft, patch); if (edit.blocked) return; @@ -946,32 +907,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { setClipStage, }); - const chooseClipExportDirectory = useCallback(async () => { - if (exportBusy) return; - if (window.electronAPI?.openDirectory) { - const directory = await window.electronAPI.openDirectory({ - title: 'Choose clip export folder', - defaultPath: clipExportDirectory || (videoPath ? getPathDirectory(videoPath) : undefined), - }); - if (!directory) return; - if (!isCurrentClipWorkspace()) return; - setClipExportDirectory(directory); - window.localStorage.setItem(CLIP_EXPORT_DIRECTORY_KEY, directory); - setClipDrafts((current) => current.map((draft) => ({ ...draft, exportDirectory: directory }))); - } - }, [clipExportDirectory, exportBusy, isCurrentClipWorkspace, setClipDrafts, videoPath]); - - const updateClipExportDirectory = useCallback((directory: string) => { - if (exportBusy) return; - setClipExportDirectory(directory); - if (directory) window.localStorage.setItem(CLIP_EXPORT_DIRECTORY_KEY, directory); - else window.localStorage.removeItem(CLIP_EXPORT_DIRECTORY_KEY); - setClipDrafts((current) => current.map((draft) => ({ - ...draft, - exportDirectory: directory || undefined, - }))); - }, [exportBusy, setClipDrafts]); - const duplicateClipDraft = useCallback( (draft: ClipDraft) => { setClipDrafts((current) => [ @@ -1018,220 +953,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { [changeClipDraft, requestSeek, setSelectedWordIndices, words], ); - const pollClipExportJob = useCallback( - async (jobId: string, draftId?: 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 clip export job: ${res.statusText}`); - const job = (await res.json()) as ExportJob; - if (draftId) { - setClipExportJobs((current) => ({ ...current, [draftId]: job })); - } - - if (job.status === 'succeeded') { - const outputPath = job.result?.output_path?.trim() || ''; - if (!outputPath) { - throw new Error('Export completed without a confirmed output file. Retry this clip.'); - } - return { - outputPath, - srtPath: job.result?.srt_path, - fileCapability: job.result?.file_capability, - srtFileCapability: job.result?.srt_file_capability, - warnings: job.result?.warnings || [], - }; - } - if (job.status === 'failed' || job.status === 'canceled') { - throw new Error(job.error || job.message || `Clip export ${job.status}`); - } - } - }, - [backendUrl], - ); - - const handleExportClip = useCallback( - async ( - clip: ClipSuggestion, - settings?: Pick, - silent = false, - ) => { - try { - if (!isCurrentClipWorkspace()) throw new Error('The media workspace changed while this export was running.'); - if (!videoPath) throw new Error('Load a video before exporting a clip.'); - const clipWordIndices = getWordIndicesForClip(words, clip); - if (clipWordIndices.length === 0) throw new Error('This clip has no transcript range to export.'); - if (!Number.isFinite(clip.startTime) || !Number.isFinite(clip.endTime) || clip.endTime - clip.startTime < 0.25) { - throw new Error('Set a clip range of at least 0.25 seconds before exporting.'); - } - if (settings?.id) { - const validation = validateClipDraftForExport(settings as ClipDraft, words, videoPath); - if (!validation.ready) throw new Error(validation.reasons.join('\n')); - } - - const format = settings?.format ?? 'mp4'; - const aspectRatio = settings?.aspectRatio ?? SHORTS_DRAFT_DEFAULTS.aspectRatio; - const captions = settings?.captions ?? SHORTS_DRAFT_DEFAULTS.captions; - const outputDirectory = settings?.exportDirectory || clipExportDirectory || getPathDirectory(videoPath); - if (!outputDirectory) throw new Error('Choose an export folder before exporting this clip.'); - const outputPath = buildClipOutputPath(outputDirectory, clip.title, format, settings?.id); - const captionHidden = new Set(getCaptionHiddenIndices()); - const keepSegments = getClipExportSegments(clip, deletedRanges); - if (keepSegments.length === 0) { - throw new Error('Every part of this clip has been removed in the transcript. Restore content or adjust the clip range.'); - } - const clipWords = buildClipExportCaptionWords(words, clip, keepSegments, captionHidden); - const mutedRanges = getMutedRanges().filter( - (range) => range.end > clip.startTime && range.start < clip.endTime, - ); - - if (settings?.id) { - updateClipDraft(settings.id, { status: 'exporting', lastError: undefined }); - } - - const res = await fetch(`${backendUrl}/jobs/export`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - input_path: videoPath, - output_path: outputPath, - keep_segments: keepSegments, - mode: keepSegments.length === 1 && aspectRatio === 'source' && format === 'mp4' && captions !== 'burn-in' ? 'fast' : 'reencode', - resolution: settings?.resolution ?? '1080p', - aspectRatio, - reframe: settings?.reframe ?? (aspectRatio === 'vertical' ? SHORTS_DRAFT_DEFAULTS.reframe : undefined), - format, - enhanceAudio: !!settings?.enhanceAudio, - captions, - captionStyle: captions === 'burn-in' ? settings?.captionStyle ?? SHORTS_DRAFT_DEFAULTS.captionStyle : undefined, - word_timeline: 'export', - words: captions !== 'none' ? clipWords : undefined, - muted_ranges: mutedRanges, - backgroundRemoval: settings?.backgroundRemoval?.enabled ? settings.backgroundRemoval : undefined, - }), - }); - if (!res.ok) { - let detail = `Export could not start (${res.status}).`; - try { - const body = await res.json() as { detail?: unknown }; - if (typeof body.detail === 'string') detail = body.detail; - } catch { - // Keep the HTTP status when the backend cannot return a JSON error. - } - throw new Error(detail); - } - const { job_id: jobId } = await res.json(); - if (!isCurrentClipWorkspace()) throw new Error('The media workspace changed while this export was running.'); - if (settings?.id) { - setClipExportJobs((current) => ({ - ...current, - [settings.id!]: { - id: jobId, - status: 'queued', - progress: 0, - message: 'Queued', - logs: [], - }, - })); - } - const output = await pollClipExportJob(jobId, settings?.id); - if (!isCurrentClipWorkspace()) throw new Error('The media workspace changed while this export was running.'); - if (settings?.id) { - updateClipDraft(settings.id, { - status: 'exported', - exportPath: output.outputPath, - srtPath: output.srtPath, - exportWarnings: output.warnings, - exportedAt: new Date().toISOString(), - lastError: undefined, - }); - setClipExportOutputs((current) => ({ ...current, [settings.id!]: output })); - } - if (!silent) { - setCreatorNotice({ - tone: 'success', - title: 'Clip exported', - message: output.srtPath ? `Saved to ${output.outputPath}; captions saved to ${output.srtPath}.` : `Saved to ${output.outputPath}.`, - technicalDetails: output.warnings.length ? output.warnings.join('\n') : undefined, - onDismiss: () => setCreatorNotice(null), - }); - } - return output; - } catch (err) { - console.error(err); - const message = err instanceof Error ? err.message : String(err); - if (settings?.id) { - updateClipDraft(settings.id, { status: 'failed', lastError: message }); - } - if (!silent && !message.toLowerCase().includes('canceled')) { - setCreatorNotice({ ...getCreatorErrorPresentation('clip-action', err), onDismiss: () => setCreatorNotice(null) }); - } - throw err; - } - }, - [videoPath, clipExportDirectory, words, getCaptionHiddenIndices, deletedRanges, backendUrl, getMutedRanges, isCurrentClipWorkspace, pollClipExportJob, updateClipDraft], - ); - - const cancelDraftExport = useCallback( - async (draftId: string) => { - const job = clipExportJobs[draftId]; - if (!job || !['queued', 'running'].includes(job.status)) return; - const res = await fetch(`${backendUrl}/jobs/${job.id}/cancel`, { method: 'POST' }); - if (res.ok) { - const canceledJob = (await res.json()) as ExportJob; - setClipExportJobs((current) => ({ ...current, [draftId]: canceledJob })); - } - }, - [backendUrl, clipExportJobs], - ); - - const retryDraftExport = useCallback( - async (draft: ClipDraft) => { - if (exportBusy) return; - const currentDraft = useAIStore.getState().clipDrafts.find((candidate) => candidate.id === draft.id) || draft; - if ((currentDraft.status || 'draft') !== 'failed') return; - const validation = validateClipDraftForExport(currentDraft, words, videoPath); - if (!validation.ready) { - setCreatorNotice({ - tone: 'warning', - title: 'Clip needs preparation before retry', - message: 'Review the clip in Prepare before retrying this export.', - technicalDetails: validation.reasons.join('\n'), - onDismiss: () => setCreatorNotice(null), - }); - return; - } - clearClipExportAttempt(currentDraft.id); - setExportingDraftId(currentDraft.id); - try { - const output = await handleExportClip(currentDraft, currentDraft, true); - updateClipDraft(currentDraft.id, { - status: 'exported', - exportPath: output.outputPath, - srtPath: output.srtPath, - exportWarnings: output.warnings, - exportedAt: new Date().toISOString(), - lastError: undefined, - }); - setClipExportOutputs((current) => ({ ...current, [currentDraft.id]: output })); - setCreatorNotice({ - tone: 'success', - title: 'Clip exported', - message: output.srtPath ? `Saved to ${output.outputPath}; captions saved to ${output.srtPath}.` : `Saved to ${output.outputPath}.`, - technicalDetails: output.warnings.length ? output.warnings.join('\n') : undefined, - onDismiss: () => setCreatorNotice(null), - }); - } catch (err) { - console.error(err); - updateClipDraft(currentDraft.id, { status: 'failed', lastError: err instanceof Error ? err.message : String(err) }); - setCreatorNotice({ ...getCreatorErrorPresentation('clip-action', err), onDismiss: () => setCreatorNotice(null) }); - } finally { - setExportingDraftId(null); - } - }, - [clearClipExportAttempt, exportBusy, handleExportClip, updateClipDraft, videoPath, words], - ); - const copyPublishingCopy = useCallback( async (draft: ClipDraft) => { const publishingCopyText = formatPublishingCopy( @@ -1283,145 +1004,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { [requestSeek], ); - const handleExportDraft = useCallback( - async (draft: ClipDraft) => { - if (exportBusy) return; - const currentDraft = useAIStore.getState().clipDrafts.find((candidate) => candidate.id === draft.id) || draft; - if (currentDraft.status !== 'packaged') { - setCreatorNotice({ - tone: 'warning', - title: 'Prepare this clip first', - message: 'Move the clip through Prepare before exporting it.', - onDismiss: () => setCreatorNotice(null), - }); - return; - } - const validation = validateClipDraftForExport(currentDraft, words, videoPath); - if (!validation.ready) { - setCreatorNotice({ tone: 'warning', title: 'Clip isn’t ready to export', message: 'Review the readiness details before exporting.', technicalDetails: validation.reasons.join('\n'), onDismiss: () => setCreatorNotice(null) }); - return; - } - setExportingDraftId(currentDraft.id); - try { - await handleExportClip(currentDraft, currentDraft); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (!message.toLowerCase().includes('canceled')) { - console.error(err); - } - } finally { - setExportingDraftId(null); - } - }, - [exportBusy, handleExportClip, videoPath, words], - ); - - const handleExportAllDrafts = useCallback(async () => { - if (exportBusy) return; - const exportableDrafts = getClipBatchExportCandidates(clipDrafts, words, videoPath); - if (exportableDrafts.length === 0) return; - stopBatchExportRef.current = false; - setBatchExporting(true); - setBatchExportProgress( - getClipBatchProgressSummary({ - processed: 0, - total: exportableDrafts.length, - exported: 0, - failed: 0, - stopping: false, - }), - ); - const results: ClipBatchExportResult[] = []; - let pausedForDraftChange = false; - try { - for (let index = 0; index < exportableDrafts.length; index++) { - if (stopBatchExportRef.current) break; - const plannedDraft = exportableDrafts[index]; - const draft = getCurrentClipBatchDraftForExport( - useAIStore.getState().clipDrafts, - plannedDraft.id, - words, - videoPath, - ); - if (!draft) { - pausedForDraftChange = true; - break; - } - setExportingDraftId(draft.id); - setClipExportJobs((current) => { - const next = { ...current }; - delete next[draft.id]; - return next; - }); - try { - const output = await handleExportClip(draft, draft, true); - results.push({ draft, outputPath: output.outputPath, srtPath: output.srtPath, warnings: output.warnings }); - } catch (err) { - results.push({ draft, error: err instanceof Error ? err.message : String(err) }); - } - setBatchExportProgress( - getClipBatchProgressSummary({ - processed: index + 1, - total: exportableDrafts.length, - exported: results.filter((result) => result.outputPath).length, - failed: results.filter((result) => result.error).length, - stopping: stopBatchExportRef.current, - }), - ); - } - const successCount = results.filter((result) => result.outputPath).length; - const failedCount = results.filter((result) => result.error).length; - const remainingDrafts = exportableDrafts.slice(results.length); - const stopped = (stopBatchExportRef.current || pausedForDraftChange) && remainingDrafts.length > 0; - let manifestPath = ''; - let manifestWarning = ''; - try { - manifestPath = await writeClipBatchManifest({ - directory: clipExportDirectory || (videoPath ? getPathDirectory(videoPath) : ''), - writeManifest: window.electronAPI?.writeClipManifest, - videoPath, - results, - words, - plannedDraftIds: exportableDrafts.map((draft) => draft.id), - remainingDraftIds: remainingDrafts.map((draft) => draft.id), - stopped, - }); - } catch (err) { - console.error('Clip batch manifest failed:', err); - manifestWarning = "Clips were exported, but ScriptCut couldn't save the batch manifest."; - } - const warningDetails = [ - ...results.flatMap((result) => result.warnings || []), - pausedForDraftChange ? 'Batch paused because a planned clip changed and needs to be prepared again.' : '', - manifestWarning, - ].filter(Boolean); - setCreatorNotice({ - tone: failedCount > 0 || pausedForDraftChange || manifestWarning ? 'warning' : 'success', - title: successCount > 0 - ? `${successCount} clip${successCount === 1 ? '' : 's'} ready` - : stopped - ? 'Export stopped' - : 'Export finished', - message: `${successCount} exported, ${failedCount} failed.${stopped ? ` ${remainingDrafts.length} remaining.` : ''}${manifestPath ? ` Manifest saved to ${manifestPath}.` : ''}`, - technicalDetails: warningDetails.length > 0 ? warningDetails.join('\n') : undefined, - onDismiss: () => setCreatorNotice(null), - }); - } catch (err) { - console.error(err); - setCreatorNotice({ ...getCreatorErrorPresentation('clip-action', err), onDismiss: () => setCreatorNotice(null) }); - } finally { - setExportingDraftId(null); - setBatchExporting(false); - stopBatchExportRef.current = false; - setBatchExportProgress((current) => ({ ...current, stopping: false })); - } - }, [clipDrafts, clipExportDirectory, exportBusy, handleExportClip, videoPath, words]); - - const stopBatchExport = useCallback(() => { - stopBatchExportRef.current = true; - setBatchExportProgress((current) => ({ ...current, stopping: true })); - }, []); - const generatePublishingCopy = useCallback( async (draft: ClipDraft) => { setPublishingCopyDraftId(draft.id); diff --git a/frontend/src/features/clips/useClipExportController.ts b/frontend/src/features/clips/useClipExportController.ts new file mode 100644 index 0000000..8827230 --- /dev/null +++ b/frontend/src/features/clips/useClipExportController.ts @@ -0,0 +1,528 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { CreatorNoticeData } from '../../components/CreatorNotice'; +import { useAIStore } from '../../store/aiStore'; +import { useEditorStore } from '../../store/editorStore'; +import type { ClipDraft, ClipSuggestion } from '../../types/project'; +import { + buildClipExportCaptionWords, + getClipExportSegments, + getWordIndicesForClip, + validateClipDraftForExport, +} from '../../utils/clipDrafts'; +import { + getClipBatchExportCandidates, + getClipBatchProgressSummary, + getCurrentClipBatchDraftForExport, + type ClipBatchProgressInput, +} from '../../utils/clipBatchExport'; +import { getCreatorErrorPresentation } from '../../utils/creatorErrors'; +import { SHORTS_DRAFT_DEFAULTS } from './clipDraftModel'; +import { + buildClipOutputPath, + getPathDirectory, + writeClipBatchManifest, + type ClipBatchExportResult, +} from './clipExportFiles'; +import type { ClipExportOutput, ExportJob } from './types'; + +const CLIP_EXPORT_DIRECTORY_KEY = 'scriptcut.clipExport.directory'; + +type UseClipExportControllerOptions = { + setCreatorNotice: (notice: CreatorNoticeData | null) => void; +}; + +export function useClipExportController({ + setCreatorNotice, +}: UseClipExportControllerOptions) { + const videoPath = useEditorStore((state) => state.videoPath); + const words = useEditorStore((state) => state.words); + const deletedRanges = useEditorStore((state) => state.deletedRanges); + const backendUrl = useEditorStore((state) => state.backendUrl); + const getCaptionHiddenIndices = useEditorStore((state) => state.getCaptionHiddenIndices); + const getMutedRanges = useEditorStore((state) => state.getMutedRanges); + + const clipDrafts = useAIStore((state) => state.clipDrafts); + const setClipDrafts = useAIStore((state) => state.setClipDrafts); + const clipWorkspaceEpoch = useAIStore((state) => state.clipWorkspaceEpoch); + + const [clipExportDirectory, setClipExportDirectory] = useState( + () => window.localStorage.getItem(CLIP_EXPORT_DIRECTORY_KEY) || '', + ); + const [exportingDraftId, setExportingDraftId] = useState(null); + const [clipExportJobs, setClipExportJobs] = useState>({}); + const [clipExportOutputs, setClipExportOutputs] = useState>({}); + const [isBatchExporting, setBatchExporting] = useState(false); + const [batchExportProgress, setBatchExportProgress] = useState({ + processed: 0, + total: 0, + exported: 0, + failed: 0, + stopping: false, + }); + const stopBatchExportRef = useRef(false); + const exportBusy = isBatchExporting || exportingDraftId !== null; + + const isCurrentClipWorkspace = useCallback( + () => useAIStore.getState().clipWorkspaceEpoch === clipWorkspaceEpoch, + [clipWorkspaceEpoch], + ); + + const updateClipDraft = useCallback((id: string, patch: Partial) => { + if (!isCurrentClipWorkspace()) return; + setClipDrafts((current) => current.map((draft) => + draft.id === id ? { ...draft, ...patch } : draft, + )); + }, [isCurrentClipWorkspace, setClipDrafts]); + + useEffect(() => { + if (!videoPath) return; + setClipExportDirectory((current) => current || getPathDirectory(videoPath)); + }, [videoPath]); + + useEffect(() => { + setClipExportOutputs({}); + }, [clipWorkspaceEpoch]); + + const exportCandidateDrafts = useMemo( + () => getClipBatchExportCandidates(clipDrafts, words, videoPath), + [clipDrafts, videoPath, words], + ); + + const clearClipExportAttempt = useCallback((draftId: string) => { + setClipExportJobs((current) => { + if (!current[draftId]) return current; + const next = { ...current }; + delete next[draftId]; + return next; + }); + setClipExportOutputs((current) => { + if (!current[draftId]) return current; + const next = { ...current }; + delete next[draftId]; + return next; + }); + }, []); + + const chooseClipExportDirectory = useCallback(async () => { + if (exportBusy) return; + if (!window.electronAPI?.openDirectory) return; + const directory = await window.electronAPI.openDirectory({ + title: 'Choose clip export folder', + defaultPath: clipExportDirectory || (videoPath ? getPathDirectory(videoPath) : undefined), + }); + if (!directory) return; + if (!isCurrentClipWorkspace()) return; + setClipExportDirectory(directory); + window.localStorage.setItem(CLIP_EXPORT_DIRECTORY_KEY, directory); + setClipDrafts((current) => current.map((draft) => ({ ...draft, exportDirectory: directory }))); + }, [clipExportDirectory, exportBusy, isCurrentClipWorkspace, setClipDrafts, videoPath]); + + const updateClipExportDirectory = useCallback((directory: string) => { + if (exportBusy) return; + setClipExportDirectory(directory); + if (directory) window.localStorage.setItem(CLIP_EXPORT_DIRECTORY_KEY, directory); + else window.localStorage.removeItem(CLIP_EXPORT_DIRECTORY_KEY); + setClipDrafts((current) => current.map((draft) => ({ + ...draft, + exportDirectory: directory || undefined, + }))); + }, [exportBusy, setClipDrafts]); + + const pollClipExportJob = useCallback( + async (jobId: string, draftId?: 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 clip export job: ${res.statusText}`); + const job = (await res.json()) as ExportJob; + if (draftId) { + setClipExportJobs((current) => ({ ...current, [draftId]: job })); + } + + if (job.status === 'succeeded') { + const outputPath = job.result?.output_path?.trim() || ''; + if (!outputPath) { + throw new Error('Export completed without a confirmed output file. Retry this clip.'); + } + return { + outputPath, + srtPath: job.result?.srt_path, + fileCapability: job.result?.file_capability, + srtFileCapability: job.result?.srt_file_capability, + warnings: job.result?.warnings || [], + } satisfies ClipExportOutput; + } + if (job.status === 'failed' || job.status === 'canceled') { + throw new Error(job.error || job.message || `Clip export ${job.status}`); + } + } + }, + [backendUrl], + ); + + const handleExportClip = useCallback( + async ( + clip: ClipSuggestion, + settings?: Pick, + silent = false, + ) => { + try { + if (!isCurrentClipWorkspace()) throw new Error('The media workspace changed while this export was running.'); + if (!videoPath) throw new Error('Load a video before exporting a clip.'); + const clipWordIndices = getWordIndicesForClip(words, clip); + if (clipWordIndices.length === 0) throw new Error('This clip has no transcript range to export.'); + if (!Number.isFinite(clip.startTime) || !Number.isFinite(clip.endTime) || clip.endTime - clip.startTime < 0.25) { + throw new Error('Set a clip range of at least 0.25 seconds before exporting.'); + } + if (settings?.id) { + const validation = validateClipDraftForExport(settings as ClipDraft, words, videoPath); + if (!validation.ready) throw new Error(validation.reasons.join('\n')); + } + + const format = settings?.format ?? 'mp4'; + const aspectRatio = settings?.aspectRatio ?? SHORTS_DRAFT_DEFAULTS.aspectRatio; + const captions = settings?.captions ?? SHORTS_DRAFT_DEFAULTS.captions; + const outputDirectory = settings?.exportDirectory || clipExportDirectory || getPathDirectory(videoPath); + if (!outputDirectory) throw new Error('Choose an export folder before exporting this clip.'); + const outputPath = buildClipOutputPath(outputDirectory, clip.title, format, settings?.id); + const captionHidden = new Set(getCaptionHiddenIndices()); + const keepSegments = getClipExportSegments(clip, deletedRanges); + if (keepSegments.length === 0) { + throw new Error('Every part of this clip has been removed in the transcript. Restore content or adjust the clip range.'); + } + const clipWords = buildClipExportCaptionWords(words, clip, keepSegments, captionHidden); + const mutedRanges = getMutedRanges().filter( + (range) => range.end > clip.startTime && range.start < clip.endTime, + ); + + if (settings?.id) { + updateClipDraft(settings.id, { status: 'exporting', lastError: undefined }); + } + + const res = await fetch(`${backendUrl}/jobs/export`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + input_path: videoPath, + output_path: outputPath, + keep_segments: keepSegments, + mode: keepSegments.length === 1 && aspectRatio === 'source' && format === 'mp4' && captions !== 'burn-in' ? 'fast' : 'reencode', + resolution: settings?.resolution ?? '1080p', + aspectRatio, + reframe: settings?.reframe ?? (aspectRatio === 'vertical' ? SHORTS_DRAFT_DEFAULTS.reframe : undefined), + format, + enhanceAudio: !!settings?.enhanceAudio, + captions, + captionStyle: captions === 'burn-in' ? settings?.captionStyle ?? SHORTS_DRAFT_DEFAULTS.captionStyle : undefined, + word_timeline: 'export', + words: captions !== 'none' ? clipWords : undefined, + muted_ranges: mutedRanges, + backgroundRemoval: settings?.backgroundRemoval?.enabled ? settings.backgroundRemoval : undefined, + }), + }); + if (!res.ok) { + let detail = `Export could not start (${res.status}).`; + try { + const body = await res.json() as { detail?: unknown }; + if (typeof body.detail === 'string') detail = body.detail; + } catch { + // Keep the HTTP status when the backend cannot return a JSON error. + } + throw new Error(detail); + } + const { job_id: jobId } = await res.json(); + if (!isCurrentClipWorkspace()) throw new Error('The media workspace changed while this export was running.'); + if (settings?.id) { + setClipExportJobs((current) => ({ + ...current, + [settings.id!]: { + id: jobId, + status: 'queued', + progress: 0, + message: 'Queued', + logs: [], + }, + })); + } + const output = await pollClipExportJob(jobId, settings?.id); + if (!isCurrentClipWorkspace()) throw new Error('The media workspace changed while this export was running.'); + if (settings?.id) { + updateClipDraft(settings.id, { + status: 'exported', + exportPath: output.outputPath, + srtPath: output.srtPath, + exportWarnings: output.warnings, + exportedAt: new Date().toISOString(), + lastError: undefined, + }); + setClipExportOutputs((current) => ({ ...current, [settings.id!]: output })); + } + if (!silent) { + setCreatorNotice({ + tone: 'success', + title: 'Clip exported', + message: output.srtPath ? `Saved to ${output.outputPath}; captions saved to ${output.srtPath}.` : `Saved to ${output.outputPath}.`, + technicalDetails: output.warnings.length ? output.warnings.join('\n') : undefined, + onDismiss: () => setCreatorNotice(null), + }); + } + return output; + } catch (err) { + console.error(err); + const message = err instanceof Error ? err.message : String(err); + if (settings?.id) { + updateClipDraft(settings.id, { status: 'failed', lastError: message }); + } + if (!silent && !message.toLowerCase().includes('canceled')) { + setCreatorNotice({ ...getCreatorErrorPresentation('clip-action', err), onDismiss: () => setCreatorNotice(null) }); + } + throw err; + } + }, + [ + backendUrl, + clipExportDirectory, + deletedRanges, + getCaptionHiddenIndices, + getMutedRanges, + isCurrentClipWorkspace, + pollClipExportJob, + setCreatorNotice, + updateClipDraft, + videoPath, + words, + ], + ); + + const cancelDraftExport = useCallback( + async (draftId: string) => { + const job = clipExportJobs[draftId]; + if (!job || !['queued', 'running'].includes(job.status)) return; + const res = await fetch(`${backendUrl}/jobs/${job.id}/cancel`, { method: 'POST' }); + if (res.ok) { + const canceledJob = (await res.json()) as ExportJob; + setClipExportJobs((current) => ({ ...current, [draftId]: canceledJob })); + } + }, + [backendUrl, clipExportJobs], + ); + + const retryDraftExport = useCallback( + async (draft: ClipDraft) => { + if (exportBusy) return; + const currentDraft = useAIStore.getState().clipDrafts.find((candidate) => candidate.id === draft.id) || draft; + if ((currentDraft.status || 'draft') !== 'failed') return; + const validation = validateClipDraftForExport(currentDraft, words, videoPath); + if (!validation.ready) { + setCreatorNotice({ + tone: 'warning', + title: 'Clip needs preparation before retry', + message: 'Review the clip in Prepare before retrying this export.', + technicalDetails: validation.reasons.join('\n'), + onDismiss: () => setCreatorNotice(null), + }); + return; + } + clearClipExportAttempt(currentDraft.id); + setExportingDraftId(currentDraft.id); + try { + const output = await handleExportClip(currentDraft, currentDraft, true); + updateClipDraft(currentDraft.id, { + status: 'exported', + exportPath: output.outputPath, + srtPath: output.srtPath, + exportWarnings: output.warnings, + exportedAt: new Date().toISOString(), + lastError: undefined, + }); + setClipExportOutputs((current) => ({ ...current, [currentDraft.id]: output })); + setCreatorNotice({ + tone: 'success', + title: 'Clip exported', + message: output.srtPath ? `Saved to ${output.outputPath}; captions saved to ${output.srtPath}.` : `Saved to ${output.outputPath}.`, + technicalDetails: output.warnings.length ? output.warnings.join('\n') : undefined, + onDismiss: () => setCreatorNotice(null), + }); + } catch (err) { + console.error(err); + updateClipDraft(currentDraft.id, { status: 'failed', lastError: err instanceof Error ? err.message : String(err) }); + setCreatorNotice({ ...getCreatorErrorPresentation('clip-action', err), onDismiss: () => setCreatorNotice(null) }); + } finally { + setExportingDraftId(null); + } + }, + [clearClipExportAttempt, exportBusy, handleExportClip, setCreatorNotice, updateClipDraft, videoPath, words], + ); + + const handleExportDraft = useCallback( + async (draft: ClipDraft) => { + if (exportBusy) return; + const currentDraft = useAIStore.getState().clipDrafts.find((candidate) => candidate.id === draft.id) || draft; + if (currentDraft.status !== 'packaged') { + setCreatorNotice({ + tone: 'warning', + title: 'Prepare this clip first', + message: 'Move the clip through Prepare before exporting it.', + onDismiss: () => setCreatorNotice(null), + }); + return; + } + const validation = validateClipDraftForExport(currentDraft, words, videoPath); + if (!validation.ready) { + setCreatorNotice({ + tone: 'warning', + title: 'Clip isn’t ready to export', + message: 'Review the readiness details before exporting.', + technicalDetails: validation.reasons.join('\n'), + onDismiss: () => setCreatorNotice(null), + }); + return; + } + setExportingDraftId(currentDraft.id); + try { + await handleExportClip(currentDraft, currentDraft); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!message.toLowerCase().includes('canceled')) { + console.error(err); + } + } finally { + setExportingDraftId(null); + } + }, + [exportBusy, handleExportClip, setCreatorNotice, videoPath, words], + ); + + const handleExportAllDrafts = useCallback(async () => { + if (exportBusy) return; + const exportableDrafts = getClipBatchExportCandidates(clipDrafts, words, videoPath); + if (exportableDrafts.length === 0) return; + stopBatchExportRef.current = false; + setBatchExporting(true); + setBatchExportProgress( + getClipBatchProgressSummary({ + processed: 0, + total: exportableDrafts.length, + exported: 0, + failed: 0, + stopping: false, + }), + ); + const results: ClipBatchExportResult[] = []; + let pausedForDraftChange = false; + try { + for (let index = 0; index < exportableDrafts.length; index++) { + if (stopBatchExportRef.current) break; + const plannedDraft = exportableDrafts[index]; + const draft = getCurrentClipBatchDraftForExport( + useAIStore.getState().clipDrafts, + plannedDraft.id, + words, + videoPath, + ); + if (!draft) { + pausedForDraftChange = true; + break; + } + setExportingDraftId(draft.id); + setClipExportJobs((current) => { + const next = { ...current }; + delete next[draft.id]; + return next; + }); + try { + const output = await handleExportClip(draft, draft, true); + results.push({ draft, outputPath: output.outputPath, srtPath: output.srtPath, warnings: output.warnings }); + } catch (err) { + results.push({ draft, error: err instanceof Error ? err.message : String(err) }); + } + setBatchExportProgress( + getClipBatchProgressSummary({ + processed: index + 1, + total: exportableDrafts.length, + exported: results.filter((result) => result.outputPath).length, + failed: results.filter((result) => result.error).length, + stopping: stopBatchExportRef.current, + }), + ); + } + const successCount = results.filter((result) => result.outputPath).length; + const failedCount = results.filter((result) => result.error).length; + const remainingDrafts = exportableDrafts.slice(results.length); + const stopped = (stopBatchExportRef.current || pausedForDraftChange) && remainingDrafts.length > 0; + let manifestPath = ''; + let manifestWarning = ''; + try { + manifestPath = await writeClipBatchManifest({ + directory: clipExportDirectory || (videoPath ? getPathDirectory(videoPath) : ''), + writeManifest: window.electronAPI?.writeClipManifest, + videoPath, + results, + words, + plannedDraftIds: exportableDrafts.map((draft) => draft.id), + remainingDraftIds: remainingDrafts.map((draft) => draft.id), + stopped, + }); + } catch (err) { + console.error('Clip batch manifest failed:', err); + manifestWarning = "Clips were exported, but ScriptCut couldn't save the batch manifest."; + } + const warningDetails = [ + ...results.flatMap((result) => result.warnings || []), + pausedForDraftChange ? 'Batch paused because a planned clip changed and needs to be prepared again.' : '', + manifestWarning, + ].filter(Boolean); + setCreatorNotice({ + tone: failedCount > 0 || pausedForDraftChange || manifestWarning ? 'warning' : 'success', + title: successCount > 0 + ? `${successCount} clip${successCount === 1 ? '' : 's'} ready` + : stopped + ? 'Export stopped' + : 'Export finished', + message: `${successCount} exported, ${failedCount} failed.${stopped ? ` ${remainingDrafts.length} remaining.` : ''}${manifestPath ? ` Manifest saved to ${manifestPath}.` : ''}`, + technicalDetails: warningDetails.length > 0 ? warningDetails.join('\n') : undefined, + onDismiss: () => setCreatorNotice(null), + }); + } catch (err) { + console.error(err); + setCreatorNotice({ ...getCreatorErrorPresentation('clip-action', err), onDismiss: () => setCreatorNotice(null) }); + } finally { + setExportingDraftId(null); + setBatchExporting(false); + stopBatchExportRef.current = false; + setBatchExportProgress((current) => ({ ...current, stopping: false })); + } + }, [ + clipDrafts, + clipExportDirectory, + exportBusy, + handleExportClip, + setCreatorNotice, + videoPath, + words, + ]); + + const stopBatchExport = useCallback(() => { + stopBatchExportRef.current = true; + setBatchExportProgress((current) => ({ ...current, stopping: true })); + }, []); + + return { + batchExportProgress, + cancelDraftExport, + chooseClipExportDirectory, + clearClipExportAttempt, + clipExportDirectory, + clipExportJobs, + clipExportOutputs, + exportBusy, + exportCandidateDrafts, + exportingDraftId, + handleExportAllDrafts, + handleExportDraft, + isBatchExporting, + retryDraftExport, + stopBatchExport, + updateClipExportDirectory, + }; +}