From c58f60ca4aafd41a22ad4bddeaea963f473c5829 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Mon, 7 Sep 2026 01:59:05 -0700 Subject: [PATCH] feat: formalize clip prepare export lifecycle --- frontend/scripts/smoke-clip-batch-export.mjs | 58 +++++- frontend/scripts/smoke-clip-drafts.mjs | 92 ++++++++ .../scripts/smoke-clip-prepare-export.mjs | 14 ++ .../scripts/smoke-create-clips-workspace.mjs | 13 +- frontend/scripts/smoke-editor-state.mjs | 16 ++ frontend/src/components/AIPanel.tsx | 197 +++++++++++++----- frontend/src/features/clips/ClipDraftCard.tsx | 51 +++-- .../clips/ClipPrepareExportControls.tsx | 21 +- frontend/src/hooks/useKeyboardShortcuts.ts | 21 +- frontend/src/store/editorStore.ts | 37 ++++ frontend/src/utils/clipBatchExport.ts | 13 +- frontend/src/utils/clipDrafts.ts | 101 ++++++++- frontend/src/utils/clipWorkspace.ts | 6 +- 13 files changed, 555 insertions(+), 85 deletions(-) diff --git a/frontend/scripts/smoke-clip-batch-export.mjs b/frontend/scripts/smoke-clip-batch-export.mjs index 5665abb..ee6e4d9 100644 --- a/frontend/scripts/smoke-clip-batch-export.mjs +++ b/frontend/scripts/smoke-clip-batch-export.mjs @@ -53,6 +53,7 @@ const { validateClipDraftForExport } = loadTsModule('../src/utils/clipDrafts.ts' const { INTERRUPTED_CLIP_EXPORT_ERROR, getClipBatchExportCandidates, + getCurrentClipBatchDraftForExport, getClipBatchProgressSummary, hasRecoverableClipExports, recoverInterruptedClipDraft, @@ -90,17 +91,35 @@ const candidates = [ draft('exporting', 'exporting'), draft('exported', 'exported'), draft('invalid', 'draft', { title: '' }), + draft('invalid-packaged', 'packaged', { title: '' }), ]; assert.deepEqual( getClipBatchExportCandidates(candidates, words, '/tmp/video.mp4').map((item) => item.id), - ['draft', 'packaged', 'failed'], + ['packaged', 'failed'], ); assert.deepEqual( getClipBatchExportCandidates([candidates[2], candidates[0]], words, '/tmp/video.mp4').map((item) => item.id), - ['failed', 'draft'], + ['failed'], ); assert.equal(hasRecoverableClipExports(candidates, words, '/tmp/video.mp4'), true); assert.equal(validateClipDraftForExport(candidates[6], words, '/tmp/video.mp4').ready, false); +assert.equal(validateClipDraftForExport(candidates[7], words, '/tmp/video.mp4').ready, false); +assert.equal( + getCurrentClipBatchDraftForExport( + [draft('planned', 'draft')], + 'planned', + words, + '/tmp/video.mp4', + ), + null, + 'a planned batch item demoted back to draft must not be submitted from an old packaged snapshot', +); +const currentPrepared = draft('planned', 'packaged', { title: 'Current durable title' }); +assert.equal( + getCurrentClipBatchDraftForExport([currentPrepared], 'planned', words, '/tmp/video.mp4'), + currentPrepared, + 'batch export must use the current durable prepared draft', +); const recovered = recoverInterruptedClipDraft(draft('interrupted', 'exporting')); assert.equal(recovered.status, 'failed'); @@ -149,6 +168,13 @@ assert.doesNotMatch(serialized, /runtime-video-authority|runtime-srt-authority/) const reopened = autosave.parseProjectFile(serialized); assert.equal(reopened.aiWorkspace?.clipDrafts?.[2]?.srtPath, '/tmp/clip.srt'); assert.deepEqual(reopened.aiWorkspace?.clipDrafts?.[2]?.exportWarnings, ['Captions were delivered as an SRT sidecar.']); +const preparedProject = autosave.normalizeProjectFile(project([draft('loads-packaged', 'packaged')])); +assert.equal(preparedProject.aiWorkspace?.clipDrafts?.[0]?.status, 'packaged'); +assert.equal( + autosave.parseProjectFile(autosave.serializeProjectFile(preparedProject)).aiWorkspace?.clipDrafts?.[0]?.status, + 'packaged', + 'prepared clip lifecycle state must survive project v1 save/reopen', +); assert.equal(autosave.normalizeProjectFile(project(undefined)).version, 1); assert.equal(autosave.normalizeProjectFile(project([])).schema, 'scriptcut.project.v1'); @@ -174,22 +200,44 @@ assert.match(panelSource, /for \(let index = 0; index < exportableDrafts.length; assert.doesNotMatch(panelSource, /Promise\.all\([^)]*export/); assert.match(panelSource, /const exportBusy = isBatchExporting \|\| exportingDraftId !== null/); assert.match(panelSource, /exportBusy=\{exportBusy\}/); -assert.match(controlsSource, /disabled=\{exportBusy \|\| readyDraftCount === 0\}/); +assert.match(controlsSource, /disabled=\{exportBusy \|\| exportableDraftCount === 0\}/); assert.match(cardSource, /disabled=\{!canExport \|\| exportBusy \|\| isExporting \|\| exportActive\}/); -assert.match(cardSource, /disabled=\{exportBusy\}/); +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.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(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(controlsSource, /Stopping after current clip/); assert.match(cardSource, /Retry export/); assert.match(cardSource, /const exportRetryable = status === 'failed'/); -assert.match(panelSource, /fetch\(`\$\{backendUrl\}\/jobs\/\$\{job\.id\}\/retry`/); +assert.match(cardSource, /const exportActive =[\s\S]*?isExporting[\s\S]*?status === 'exporting'/); +assert.match(cardSource, /disabled=\{!exportJobCancelable\}/); +assert.match(panelSource, /import \{ isClipTimelineMutationBlocked, useEditorStore \} from '\.\.\/store\/editorStore';/); +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.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/); diff --git a/frontend/scripts/smoke-clip-drafts.mjs b/frontend/scripts/smoke-clip-drafts.mjs index 264c714..a8019b2 100644 --- a/frontend/scripts/smoke-clip-drafts.mjs +++ b/frontend/scripts/smoke-clip-drafts.mjs @@ -20,15 +20,92 @@ const run = new Function('exports', 'module', 'require', compiled.outputText); run(module.exports, module, require); const { + clipDraftPatchInvalidatesPreparation, + getClipTimelineExportFingerprint, + getClipDraftUserEditResult, getClipTranscript, getClipDraftReadinessScore, buildClipExportCaptionWords, getClipExportSegments, getWordIndicesForClip, + invalidateClipDraftsForTimelineChange, normalizeClipDraftRange, + prepareReadyClipDraftsForExport, validateClipDraftForExport, } = module.exports; +assert.equal(clipDraftPatchInvalidatesPreparation({ title: 'Updated title' }), true); +assert.equal(clipDraftPatchInvalidatesPreparation({ captions: 'sidecar' }), true); +assert.equal(clipDraftPatchInvalidatesPreparation({ reframe: { x: 40, y: 50 } }), true); +assert.equal(clipDraftPatchInvalidatesPreparation({ hook: 'Publishing hook only' }), false); +assert.equal(clipDraftPatchInvalidatesPreparation({ exportDirectory: '/tmp/exports' }), false); + +const packagedEdit = getClipDraftUserEditResult( + { id: 'packaged', status: 'packaged', exportPath: '/tmp/old.mp4' }, + { title: 'Updated title' }, +); +assert.equal(packagedEdit.invalidated, true); +assert.equal(packagedEdit.blocked, false); +assert.equal(packagedEdit.patch.status, 'draft'); +assert.equal(packagedEdit.patch.exportPath, undefined); + +const failedEdit = getClipDraftUserEditResult( + { id: 'failed', status: 'failed', lastError: 'Old failure' }, + { resolution: '720p' }, +); +assert.equal(failedEdit.invalidated, true); +assert.equal(failedEdit.patch.status, 'draft'); +assert.equal(failedEdit.patch.lastError, undefined); + +const exportedEdit = getClipDraftUserEditResult( + { id: 'exported', status: 'exported', exportPath: '/tmp/old.mp4', srtPath: '/tmp/old.srt', exportedAt: '2026-09-07T00:00:00.000Z' }, + { captions: 'none' }, +); +assert.equal(exportedEdit.invalidated, true); +assert.equal(exportedEdit.patch.status, 'draft'); +assert.equal(exportedEdit.patch.exportPath, undefined); +assert.equal(exportedEdit.patch.srtPath, undefined); +assert.equal(exportedEdit.patch.exportedAt, undefined); + +const publishingOnlyEdit = getClipDraftUserEditResult( + { id: 'exported', status: 'exported', exportPath: '/tmp/clip.mp4' }, + { hook: 'New publishing hook' }, +); +assert.equal(publishingOnlyEdit.invalidated, false); +assert.equal(publishingOnlyEdit.blocked, false); +assert.equal(publishingOnlyEdit.patch.hook, 'New publishing hook'); + +const exportingEdit = getClipDraftUserEditResult( + { id: 'exporting', status: 'exporting' }, + { aspectRatio: 'square' }, +); +assert.equal(exportingEdit.blocked, true); +assert.deepEqual(exportingEdit.patch, {}); + +const timelineInvalidated = invalidateClipDraftsForTimelineChange([ + { id: 'draft', status: 'draft' }, + { id: 'packaged', status: 'packaged', exportPath: '/tmp/prepared.mp4' }, + { id: 'failed', status: 'failed', lastError: 'Retry me' }, + { id: 'exported', status: 'exported', exportPath: '/tmp/done.mp4', srtPath: '/tmp/done.srt' }, + { id: 'exporting', status: 'exporting' }, +]); +assert.deepEqual(timelineInvalidated.map((item) => item.status), ['draft', 'draft', 'draft', 'draft', 'exporting']); +assert.equal(timelineInvalidated[1].exportPath, undefined); +assert.equal(timelineInvalidated[2].lastError, undefined); +assert.equal(timelineInvalidated[3].srtPath, undefined); + +const baseTimelineFingerprint = getClipTimelineExportFingerprint([], []); +assert.equal( + getClipTimelineExportFingerprint([], [{ id: 'speaker', kind: 'speaker-label', start: 0, end: 1, wordIndices: [0] }]), + baseTimelineFingerprint, + 'speaker labels do not affect exported clip bytes', +); +assert.notEqual( + getClipTimelineExportFingerprint([], [{ id: 'mute', kind: 'mute', start: 0, end: 1, wordIndices: [0] }]), + baseTimelineFingerprint, + 'mute edits must invalidate prepared clip output', +); + const words = [ { word: 'This', start: 0, end: 0.4, confidence: 1 }, { word: 'is', start: 0.4, end: 0.7, confidence: 1 }, @@ -61,6 +138,21 @@ assert.equal(validateClipDraftForExport({ ...draft, title: '' }, words, '/tmp/vi assert.equal(validateClipDraftForExport({ ...draft, status: 'suggested' }, words, '/tmp/video.mp4').ready, false); assert.equal(validateClipDraftForExport(draft, words, '/tmp/video.mp4').ready, true); +const preparedLifecycle = prepareReadyClipDraftsForExport( + [ + draft, + { ...draft, id: 'invalid-draft', title: '' }, + { ...draft, id: 'already-packaged', status: 'packaged' }, + { ...draft, id: 'failed-draft', status: 'failed', lastError: 'Retry me' }, + { ...draft, id: 'exported-draft', status: 'exported', exportPath: '/tmp/done.mp4' }, + ], + words, + '/tmp/video.mp4', +); +assert.deepEqual(preparedLifecycle.map((item) => item.status), ['packaged', 'draft', 'packaged', 'failed', 'exported']); +assert.equal(preparedLifecycle[3].lastError, 'Retry me'); +assert.equal(preparedLifecycle[4].exportPath, '/tmp/done.mp4'); + const clipSegments = getClipExportSegments( { startTime: 0, endTime: 1.6 }, [{ id: 'cut_1', start: 0.4, end: 1, wordIndices: [1, 2] }], diff --git a/frontend/scripts/smoke-clip-prepare-export.mjs b/frontend/scripts/smoke-clip-prepare-export.mjs index f224251..2ee34bd 100644 --- a/frontend/scripts/smoke-clip-prepare-export.mjs +++ b/frontend/scripts/smoke-clip-prepare-export.mjs @@ -36,6 +36,20 @@ assert.match(cardSource, /exported: 'Exported'/); assert.match(cardSource, /failed: 'Needs retry'/); assert.doesNotMatch(cardSource, /draft: 'Approved'/); assert.doesNotMatch(cardSource, /packaged: 'Approved'/); +assert.match(cardSource, /const canPrepare = status === 'draft' && exportValidation\.ready/); +assert.match(cardSource, /const canExport = exportValidation\.ready && status === 'packaged'/); +assert.match(cardSource, /Ready for export/); +assert.match(cardSource, /Retry required/); +assert.match(panelSource, /prepareReadyDraftsForExport/); +assert.match(panelSource, /prepareReadyClipDraftsForExport\(current, words, videoPath\)/); +assert.match(panelSource, /getClipDraftUserEditResult\(draft, patch\)/); +assert.match(panelSource, /if \(edit\.blocked\) return/); +assert.match(panelSource, /if \(edit\.invalidated\)/); +assert.match(panelSource, /clearClipExportAttempt\(draft\.id\)/); +assert.match(panelSource, /exportPath: undefined/); +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(cardSource, /Clip ready/); diff --git a/frontend/scripts/smoke-create-clips-workspace.mjs b/frontend/scripts/smoke-create-clips-workspace.mjs index 98a9936..3ea3756 100644 --- a/frontend/scripts/smoke-create-clips-workspace.mjs +++ b/frontend/scripts/smoke-create-clips-workspace.mjs @@ -84,7 +84,8 @@ assert.doesNotMatch(reviewSource, /Remove/); assert.match(prepareExportSource, /Export (Ready Clips|all ready clips)/); assert.match(panelSource, /setClipStage\(discovery\.stage\)/); assert.match(panelSource, /setClipStage\('prepare'\)/); -assert.match(prepareExportSource, /Review \{readyDraftCount\} ready/); +assert.match(prepareExportSource, /Prepare \{preparableDraftCount\} ready/); +assert.match(panelSource, /status: 'packaged'/); assert.match(panelSource, /appendDiscoveredClipDrafts/); assert.match(panelSource, /status: 'draft'/); assert.match(reviewActionsSource, /['"]speaker-turn['"]/); @@ -184,6 +185,7 @@ const suggestion = { }; const matchingDraft = { ...suggestion, id: 'clip_1', status: 'suggested', format: 'mp4', resolution: '1080p', aspectRatio: 'vertical' }; const approvedDraft = { ...matchingDraft, id: 'clip_2', status: 'draft' }; +const packagedDraft = { ...matchingDraft, id: 'clip_prepared', status: 'packaged' }; const exportedDraft = { ...matchingDraft, id: 'clip_3', status: 'exported' }; const manualDraft = { ...matchingDraft, id: 'clip_4', status: 'draft', source: 'speaker-turn' }; const unmatchedSuggestion = { ...suggestion, startWordIndex: 5, endWordIndex: 9 }; @@ -210,6 +212,8 @@ assert.equal(emptyDiscovery.shortfall, 5); assert.equal(getInitialClipWorkspaceStage([matchingDraft], []), 'review'); assert.equal(getInitialClipWorkspaceStage([approvedDraft], []), 'prepare'); assert.equal(getInitialClipWorkspaceStage([manualDraft], []), 'prepare'); +assert.equal(getInitialClipWorkspaceStage([packagedDraft], []), 'export'); +assert.equal(getInitialClipWorkspaceStage([approvedDraft, packagedDraft], []), 'prepare'); assert.equal(getInitialClipWorkspaceStage([exportedDraft], []), 'export'); assert.equal(getInitialClipWorkspaceStage([approvedDraft], [suggestion]), 'prepare'); assert.deepEqual(getNewManualClipDrafts([restoredManualDraft], new Set(['manual-restored'])), []); @@ -270,10 +274,13 @@ assert.equal(getPendingReviewItems([], [suggestion], { [getClipReviewKey(suggest assert.equal(getSkippedReviewItems([], [suggestion], { [getClipReviewKey(suggestion)]: 'skipped' }).length, 1); assert.equal(isClipDraftInStage(matchingDraft, 'review'), true); assert.equal(isClipDraftInStage(approvedDraft, 'prepare'), true); -assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'packaged' }, 'prepare'), true); +assert.equal(isClipDraftInStage(packagedDraft, 'prepare'), false); +assert.equal(isClipDraftInStage(packagedDraft, 'export'), true); assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'exporting' }, 'prepare'), false); assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'failed' }, 'prepare'), false); +assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'failed' }, 'export'), true); assert.equal(isClipDraftInStage(exportedDraft, 'export'), true); +assert.equal(isClipDraftInStage(approvedDraft, 'export'), false); assert.equal(isClipDraftInStage(matchingDraft, 'export'), false); const queueSummary = getClipQueueSummary( [ @@ -287,7 +294,7 @@ const queueSummary = getClipQueueSummary( ], new Set(['failed-retryable']), ); -assert.deepEqual(queueSummary, { suggested: 1, prepare: 2, exporting: 1, retry: 1, exported: 1, failed: 2 }); +assert.deepEqual(queueSummary, { suggested: 1, prepare: 1, exporting: 1, retry: 1, exported: 1, failed: 2 }); assert.match(panelSource, /getClipBatchExportCandidates/); assert.match(panelSource, /updateClipDraft\(id, \{ status: 'draft', lastError: undefined \}\)/); assert.match(transcriptSource, /Draft clip/); diff --git a/frontend/scripts/smoke-editor-state.mjs b/frontend/scripts/smoke-editor-state.mjs index 572e4ab..8d02e9c 100644 --- a/frontend/scripts/smoke-editor-state.mjs +++ b/frontend/scripts/smoke-editor-state.mjs @@ -112,11 +112,27 @@ assert.equal(historyStore.temporal.getState().pastStates.length, 1, 'transient p const appSource = readFileSync(resolve(__dirname, '../src/App.tsx'), 'utf8'); const editorStoreSource = readFileSync(resolve(__dirname, '../src/store/editorStore.ts'), 'utf8'); +const keyboardSource = readFileSync(resolve(__dirname, '../src/hooks/useKeyboardShortcuts.ts'), 'utf8'); assert.match(appSource, /onClick=\{\(\) => void handleOpenFile\(currentWorkflowIntent\)\}/); assert.doesNotMatch(appSource, /onClick=\{handleOpenFile\}/); assert.match(editorStoreSource, /partialize:\s*partializeEditorHistory/); assert.match(editorStoreSource, /equality:\s*editorHistoryEqual/); assert.doesNotMatch(editorStoreSource, /nextRangeId/); +assert.match(editorStoreSource, /invalidateClipDraftsForTimelineChange/); +assert.match(editorStoreSource, /isClipTimelineMutationBlocked\(\)/); +assert.ok( + (editorStoreSource.match(/invalidateClipPreparationForTimelineChange\(\)/g) || []).length >= 5, + 'output-affecting timeline edits and restores must invalidate prepared clip state', +); +assert.match(keyboardSource, /getClipTimelineExportFingerprint/); +assert.ok( + (keyboardSource.match(/isClipTimelineMutationBlocked\(\)/g) || []).length >= 2, + 'undo and redo must be blocked while clip export is active', +); +assert.ok( + (keyboardSource.match(/invalidateClipPreparationForTimelineChange\(\)/g) || []).length >= 2, + 'undo and redo must invalidate prepared clips when output-affecting timeline state changes', +); assert.ok( (editorStoreSource.match(/temporal\.getState\(\)\.clear\(\)/g) || []).length >= 4, 'new media, transcription, project load, and reset must clear cross-project undo history', diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index 9eb15b9..25f036f 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -1,15 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useEditorStore } from '../store/editorStore'; +import { isClipTimelineMutationBlocked, useEditorStore } from '../store/editorStore'; import { useAIStore } from '../store/aiStore'; import { Sparkles, Scissors, Film, Loader2, Check, X, Play, RotateCcw, Filter } from 'lucide-react'; import type { ClipDraft, ClipSuggestion, EditPlanReviewDecision, EditPlanResult, EditPlanSuggestion, FillerReviewDecision, FillerWordResult } from '../types/project'; import { + getClipDraftUserEditResult, getClipDraftReadinessScore, buildClipExportCaptionWords, getClipExportSegments, getClipTranscript, getWordIndicesForClip, normalizeClipDraftRange, + prepareReadyClipDraftsForExport, validateClipDraftForExport, } from '../utils/clipDrafts'; import { @@ -37,6 +39,7 @@ import { } from '../utils/clipWorkspace'; import { getClipBatchExportCandidates, + getCurrentClipBatchDraftForExport, getClipBatchProgressSummary, type ClipBatchProgressInput, } from '../utils/clipBatchExport'; @@ -298,6 +301,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }, [deletedWordMap, editPlanDecisions, editPlanResult]); const acceptVisibleFillerDeletions = useCallback(() => { + if (isClipTimelineMutationBlocked()) return; const sorted = visibleFillerWords .filter((fw) => fillerDecisions[fw.index] !== 'rejected' && !deletedWordMap.has(fw.index)) .sort((a, b) => b.index - a.index); @@ -320,6 +324,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const acceptEditSuggestion = useCallback( (suggestion: EditPlanSuggestion) => { + if (isClipTimelineMutationBlocked()) return; if (!isEditSuggestionAlreadyCut(suggestion, deletedWordMap)) { deleteWordRange(suggestion.startWordIndex, suggestion.endWordIndex); } @@ -336,6 +341,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { ); const applyPendingEditSuggestions = useCallback(() => { + if (isClipTimelineMutationBlocked()) return; const sorted = [...pendingEditSuggestions].sort((a, b) => b.startWordIndex - a.startWordIndex); for (const suggestion of sorted) { deleteWordRange(suggestion.startWordIndex, suggestion.endWordIndex); @@ -659,6 +665,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const applyFillerDeletions = useCallback(() => { if (!fillerResult) return; + if (isClipTimelineMutationBlocked()) return; const sorted = fillerResult.fillerWords .filter((fw) => fillerDecisions[fw.index] !== 'rejected' && !deletedWordMap.has(fw.index)) .sort((a, b) => b.index - a.index); @@ -674,6 +681,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const acceptSafeFillerDeletions = useCallback(() => { if (!fillerResult) return; + if (isClipTimelineMutationBlocked()) return; const sorted = fillerResult.fillerWords .filter( (fw) => @@ -705,6 +713,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const acceptFiller = useCallback( (index: number) => { + if (isClipTimelineMutationBlocked()) return; if (!deletedWordMap.has(index)) { deleteWordRange(index, index); } @@ -719,6 +728,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const restoreAcceptedFiller = useCallback( (index: number) => { + if (isClipTimelineMutationBlocked()) return; const rangeId = deletedWordMap.get(index); if (rangeId) restoreRange(rangeId); setFillerDecisions((current) => { @@ -782,6 +792,14 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { () => getClipBatchExportCandidates(clipDrafts, words, videoPath), [clipDrafts, videoPath, words], ); + const preparableDrafts = useMemo( + () => clipDrafts.filter( + (draft) => + (draft.status || 'draft') === 'draft' && + validateClipDraftForExport(draft, words, videoPath).ready, + ), + [clipDrafts, videoPath, words], + ); const retryableDraftIds = useMemo( () => new Set( exportCandidateDrafts @@ -801,12 +819,27 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { () => Object.values(clipReviewDecisions).filter((decision) => decision === 'approved').length, [clipReviewDecisions], ); - const readyDraftCount = exportCandidateDrafts.length; + const readyDraftCount = useMemo( + () => clipDrafts.filter( + (draft) => + draft.status === 'packaged' && + validateClipDraftForExport(draft, words, videoPath).ready, + ).length, + [clipDrafts, videoPath, words], + ); + const exportableDraftCount = exportCandidateDrafts.length; const clipStageDrafts = useMemo( () => clipDrafts.filter((draft) => isClipDraftInStage(draft, clipStage)), [clipDrafts, clipStage], ); + useEffect(() => { + if (mode !== 'clips' || clipStage !== 'export') return; + const hasPrepareDrafts = clipDrafts.some((draft) => (draft.status || 'draft') === 'draft'); + const hasExportStageDrafts = clipDrafts.some((draft) => isClipDraftInStage(draft, 'export')); + if (hasPrepareDrafts && !hasExportStageDrafts) setClipStage('prepare'); + }, [clipDrafts, clipStage, mode]); + useEffect(() => { if (!activeClipDraftId) return; const draft = clipDrafts.find((candidate) => candidate.id === activeClipDraftId); @@ -848,6 +881,55 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { updateClipDraft(id, { status: 'draft', lastError: undefined }); }, [updateClipDraft]); + const prepareClipDraft = useCallback((draft: ClipDraft) => { + if ((draft.status || 'draft') !== 'draft') return; + if (!validateClipDraftForExport(draft, words, videoPath).ready) return; + setActiveClipDraftId(draft.id); + updateClipDraft(draft.id, { status: 'packaged', lastError: undefined }); + const hasOtherDrafts = clipDrafts.some( + (candidate) => candidate.id !== draft.id && (candidate.status || 'draft') === 'draft', + ); + if (!hasOtherDrafts) setClipStage('export'); + }, [clipDrafts, updateClipDraft, videoPath, words]); + + const prepareReadyDraftsForExport = useCallback(() => { + if (preparableDrafts.length === 0) return; + let hasRemainingDrafts = false; + setClipDrafts((current) => { + const next = prepareReadyClipDraftsForExport(current, words, videoPath); + hasRemainingDrafts = next.some((draft) => (draft.status || 'draft') === 'draft'); + return next; + }); + 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; + if (edit.invalidated) { + clearClipExportAttempt(draft.id); + updateClipDraft(draft.id, edit.patch); + setClipStage('prepare'); + return; + } + updateClipDraft(draft.id, edit.patch); + }, [clearClipExportAttempt, updateClipDraft]); + const { approveReviewItem, skipReviewItem, @@ -865,6 +947,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }); const chooseClipExportDirectory = useCallback(async () => { + if (exportBusy) return; if (window.electronAPI?.openDirectory) { const directory = await window.electronAPI.openDirectory({ title: 'Choose clip export folder', @@ -876,9 +959,10 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { window.localStorage.setItem(CLIP_EXPORT_DIRECTORY_KEY, directory); setClipDrafts((current) => current.map((draft) => ({ ...draft, exportDirectory: directory }))); } - }, [clipExportDirectory, isCurrentClipWorkspace, setClipDrafts, videoPath]); + }, [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); @@ -886,7 +970,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { ...draft, exportDirectory: directory || undefined, }))); - }, [setClipDrafts]); + }, [exportBusy, setClipDrafts]); const duplicateClipDraft = useCallback( (draft: ClipDraft) => { @@ -923,14 +1007,15 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const trimClipDraft = useCallback( (draft: ClipDraft, patch: Pick, 'startTime' | 'endTime'>) => { + if (draft.status === 'exporting') return; const normalizedPatch = normalizeClipDraftRange(draft, patch, words); const nextDraft = { ...draft, ...normalizedPatch }; setActiveClipDraftId(draft.id); - updateClipDraft(draft.id, normalizedPatch); + changeClipDraft(draft, normalizedPatch); setSelectedWordIndices(getWordIndicesForClip(words, nextDraft)); requestSeek(nextDraft.startTime, 'forward', false); }, - [requestSeek, setSelectedWordIndices, updateClipDraft, words], + [changeClipDraft, requestSeek, setSelectedWordIndices, words], ); const pollClipExportJob = useCallback( @@ -1000,6 +1085,10 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { (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' }, @@ -1034,7 +1123,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const { job_id: jobId } = await res.json(); if (!isCurrentClipWorkspace()) throw new Error('The media workspace changed while this export was running.'); if (settings?.id) { - updateClipDraft(settings.id, { status: 'exporting', lastError: undefined }); setClipExportJobs((current) => ({ ...current, [settings.id!]: { @@ -1092,43 +1180,32 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { if (res.ok) { const canceledJob = (await res.json()) as ExportJob; setClipExportJobs((current) => ({ ...current, [draftId]: canceledJob })); - updateClipDraft(draftId, { status: 'failed', lastError: canceledJob.error || canceledJob.message || 'Export canceled' }); } - setExportingDraftId((current) => (current === draftId ? null : current)); }, - [backendUrl, clipExportJobs, updateClipDraft], + [backendUrl, clipExportJobs], ); const retryDraftExport = useCallback( async (draft: ClipDraft) => { if (exportBusy) return; - const job = clipExportJobs[draft.id]; - setExportingDraftId(draft.id); + 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 { - updateClipDraft(draft.id, { status: 'exporting', lastError: undefined }); - let output: ClipExportOutput; - if (job && ['failed', 'canceled'].includes(job.status)) { - const res = await fetch(`${backendUrl}/jobs/${job.id}/retry`, { method: 'POST' }); - if (res.ok) { - const { job_id: jobId } = await res.json(); - setClipExportJobs((current) => ({ - ...current, - [draft.id]: { - id: jobId, - status: 'queued', - progress: 0, - message: 'Retry queued', - logs: [], - }, - })); - output = await pollClipExportJob(jobId, draft.id); - } else { - output = await handleExportClip(draft, draft, true); - } - } else { - output = await handleExportClip(draft, draft, true); - } - updateClipDraft(draft.id, { + const output = await handleExportClip(currentDraft, currentDraft, true); + updateClipDraft(currentDraft.id, { status: 'exported', exportPath: output.outputPath, srtPath: output.srtPath, @@ -1136,7 +1213,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { exportedAt: new Date().toISOString(), lastError: undefined, }); - setClipExportOutputs((current) => ({ ...current, [draft.id]: output })); + setClipExportOutputs((current) => ({ ...current, [currentDraft.id]: output })); setCreatorNotice({ tone: 'success', title: 'Clip exported', @@ -1146,13 +1223,13 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }); } catch (err) { console.error(err); - updateClipDraft(draft.id, { status: 'failed', lastError: err instanceof Error ? err.message : String(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); } }, - [backendUrl, clipExportJobs, exportBusy, handleExportClip, pollClipExportJob, updateClipDraft], + [clearClipExportAttempt, exportBusy, handleExportClip, updateClipDraft, videoPath, words], ); const copyPublishingCopy = useCallback( @@ -1209,14 +1286,24 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const handleExportDraft = useCallback( async (draft: ClipDraft) => { if (exportBusy) return; - const validation = validateClipDraftForExport(draft, words, videoPath); + 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(draft.id); + setExportingDraftId(currentDraft.id); try { - await handleExportClip(draft, draft); + await handleExportClip(currentDraft, currentDraft); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (!message.toLowerCase().includes('canceled')) { @@ -1245,10 +1332,21 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }), ); const results: ClipBatchExportResult[] = []; + let pausedForDraftChange = false; try { for (let index = 0; index < exportableDrafts.length; index++) { if (stopBatchExportRef.current) break; - const draft = exportableDrafts[index]; + 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 }; @@ -1274,7 +1372,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { 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 && remainingDrafts.length > 0; + const stopped = (stopBatchExportRef.current || pausedForDraftChange) && remainingDrafts.length > 0; let manifestPath = ''; let manifestWarning = ''; try { @@ -1294,10 +1392,11 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { } 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 || manifestWarning ? 'warning' : 'success', + tone: failedCount > 0 || pausedForDraftChange || manifestWarning ? 'warning' : 'success', title: successCount > 0 ? `${successCount} clip${successCount === 1 ? '' : 's'} ready` : stopped @@ -1788,7 +1887,8 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
setClipStage('export')} + onPrepareReady={prepareReadyDraftsForExport} onChooseExportDirectory={chooseClipExportDirectory} onExportDirectoryChange={updateClipExportDirectory} /> @@ -1824,9 +1924,10 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { isActive={activeClipDraftId === draft.id} exportValidation={validateClipDraftForExport(draft, words, videoPath)} readinessScore={getClipDraftReadinessScore(draft, words, videoPath)} - onChange={(patch) => updateClipDraft(draft.id, patch)} + onChange={(patch) => changeClipDraft(draft, patch)} onTrim={(patch) => trimClipDraft(draft, patch)} onApprove={() => approveClipDraft(draft.id)} + onPrepare={() => prepareClipDraft(draft)} onPreview={() => handlePreviewClip(draft)} onExport={() => handleExportDraft(draft)} onCancelExport={() => cancelDraftExport(draft.id)} diff --git a/frontend/src/features/clips/ClipDraftCard.tsx b/frontend/src/features/clips/ClipDraftCard.tsx index 1244676..5e52fa4 100644 --- a/frontend/src/features/clips/ClipDraftCard.tsx +++ b/frontend/src/features/clips/ClipDraftCard.tsx @@ -28,6 +28,7 @@ export default function ClipDraftCard({ onChange, onTrim, onApprove, + onPrepare, onPreview, onExport, onCancelExport, @@ -57,6 +58,7 @@ export default function ClipDraftCard({ onChange: (patch: Partial) => void; onTrim: (patch: Pick, 'startTime' | 'endTime'>) => void; onApprove: () => void; + onPrepare: () => void; onPreview: () => void; onExport: () => void; onCancelExport: () => void; @@ -71,11 +73,17 @@ export default function ClipDraftCard({ }) { const [advancedExportOpen, setAdvancedExportOpen] = useState(false); const [publishingCopyOpen, setPublishingCopyOpen] = useState(false); - const exportActive = exportJob?.status === 'queued' || exportJob?.status === 'running' || exportJob?.status === 'canceling'; const status = draft.status || 'draft'; - const exportRetryable = status === 'failed' || exportJob?.status === 'failed' || exportJob?.status === 'canceled'; + const exportJobCancelable = exportJob?.status === 'queued' || exportJob?.status === 'running'; + const exportActive = + isExporting || + status === 'exporting' || + exportJobCancelable || + exportJob?.status === 'canceling'; + const exportRetryable = status === 'failed'; const isSuggested = status === 'suggested'; - const canExport = exportValidation.ready && !isSuggested; + const canPrepare = status === 'draft' && exportValidation.ready; + const canExport = exportValidation.ready && status === 'packaged'; const socialPack = buildSocialPublishingPack(draft); const publishingCopyState = getPublishingCopyState(draft); const hasGeneratedPublishingCopy = Boolean( @@ -591,25 +599,40 @@ export default function ClipDraftCard({ > Preview - + {status === 'draft' ? ( + + ) : status === 'packaged' ? ( + + ) : ( +
+ {status === 'failed' ? 'Retry required' : status === 'exported' ? 'Export complete' : 'Export in progress'} +
+ )} {exportActive ? ( ) : exportRetryable ? (
- {stage === 'prepare' && readyDraftCount > 0 && ( + {stage === 'prepare' && preparableDraftCount > 0 && ( )} @@ -83,12 +85,13 @@ export default function ClipPrepareExportControls({
Export folder {canChooseDirectory && ( - + )}
onExportDirectoryChange(event.target.value)} + disabled={exportBusy} placeholder={defaultExportDirectory || 'Default export folder'} className="w-full rounded border border-editor-border bg-editor-bg px-2 py-1.5 text-[11px] text-editor-text focus:border-editor-accent focus:outline-none" /> diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 3b2d696..1b794f7 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -1,5 +1,10 @@ import { useEffect, useRef } from 'react'; -import { useEditorStore } from '../store/editorStore'; +import { + invalidateClipPreparationForTimelineChange, + isClipTimelineMutationBlocked, + useEditorStore, +} from '../store/editorStore'; +import { getClipTimelineExportFingerprint } from '../utils/clipDrafts'; import { createProjectSnapshot, serializeProjectFile } from './useProjectAutosave'; export function useKeyboardShortcuts() { @@ -23,12 +28,26 @@ export function useKeyboardShortcuts() { // --- Undo / Redo --- case e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey: { e.preventDefault(); + if (isClipTimelineMutationBlocked()) return; + const before = useEditorStore.getState(); + const beforeFingerprint = getClipTimelineExportFingerprint(before.deletedRanges, before.editOperations); useEditorStore.temporal.getState().redo(); + const after = useEditorStore.getState(); + if (getClipTimelineExportFingerprint(after.deletedRanges, after.editOperations) !== beforeFingerprint) { + invalidateClipPreparationForTimelineChange(); + } return; } case e.key === 'z' && (e.ctrlKey || e.metaKey): { e.preventDefault(); + if (isClipTimelineMutationBlocked()) return; + const before = useEditorStore.getState(); + const beforeFingerprint = getClipTimelineExportFingerprint(before.deletedRanges, before.editOperations); useEditorStore.temporal.getState().undo(); + const after = useEditorStore.getState(); + if (getClipTimelineExportFingerprint(after.deletedRanges, after.editOperations) !== beforeFingerprint) { + invalidateClipPreparationForTimelineChange(); + } return; } diff --git a/frontend/src/store/editorStore.ts b/frontend/src/store/editorStore.ts index 1c62dec..cb39023 100644 --- a/frontend/src/store/editorStore.ts +++ b/frontend/src/store/editorStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import { temporal } from 'zundo'; import type { Word, Segment, DeletedRange, EditOperation, EditOperationKind, ProjectExportOptions, TranscriptionResult } from '../types/project'; import type { ClipPresentationPreview } from '../utils/clipPresentation'; +import { useAIStore } from './aiStore'; +import { invalidateClipDraftsForTimelineChange } from '../utils/clipDrafts'; import { collectEditIds, createUniqueEditId, normalizeLoadedEditIds } from '../utils/editIds'; import { editorHistoryEqual, partializeEditorHistory, type EditorHistoryState } from '../utils/editorHistory'; @@ -150,6 +152,16 @@ const initialState: EditorState = { backendUrl: 'http://localhost:8642', }; +export function isClipTimelineMutationBlocked() { + return useAIStore.getState().clipDrafts.some((draft) => draft.status === 'exporting'); +} + +export function invalidateClipPreparationForTimelineChange() { + const aiState = useAIStore.getState(); + const nextDrafts = invalidateClipDraftsForTimelineChange(aiState.clipDrafts); + if (nextDrafts !== aiState.clipDrafts) aiState.setClipDrafts(nextDrafts); +} + export const useEditorStore = create()( temporal( (set, get) => ({ @@ -232,6 +244,7 @@ export const useEditorStore = create()( deleteSelectedWords: () => { const { selectedWordIndices, words, deletedRanges, editOperations } = get(); if (selectedWordIndices.length === 0) return; + if (isClipTimelineMutationBlocked()) return; const sorted = [...selectedWordIndices].sort((a, b) => a - b); const startWord = words[sorted[0]]; @@ -250,6 +263,7 @@ export const useEditorStore = create()( editOperations: [...editOperations, deletedRangeToOperation(newRange)], selectedWordIndices: [], }); + invalidateClipPreparationForTimelineChange(); }, muteSelectedWords: () => { @@ -268,6 +282,7 @@ export const useEditorStore = create()( }, deleteWordRange: (startIndex, endIndex) => { + if (isClipTimelineMutationBlocked()) return; const { words, deletedRanges, editOperations } = get(); const indices = []; for (let i = startIndex; i <= endIndex; i++) indices.push(i); @@ -284,11 +299,13 @@ export const useEditorStore = create()( deletedRanges: [...deletedRanges, newRange], editOperations: [...editOperations, deletedRangeToOperation(newRange)], }); + invalidateClipPreparationForTimelineChange(); }, deleteWordIndices: (indices) => { const { words, deletedRanges, editOperations } = get(); if (indices.length === 0) return; + if (isClipTimelineMutationBlocked()) return; const sorted = [...new Set(indices)] .filter((index) => index >= 0 && index < words.length) @@ -327,11 +344,16 @@ export const useEditorStore = create()( editOperations: [...editOperations, ...ranges.map(deletedRangeToOperation)], selectedWordIndices: [], }); + invalidateClipPreparationForTimelineChange(); }, addEditOperation: (kind, indices) => { const { words, deletedRanges, editOperations } = get(); if (indices.length === 0) return; + if ( + (kind === 'mute' || kind === 'room-tone' || kind === 'caption-only' || kind === 'delete') && + isClipTimelineMutationBlocked() + ) return; const sorted = [...new Set(indices)] .filter((index) => index >= 0 && index < words.length) @@ -370,6 +392,9 @@ export const useEditorStore = create()( editOperations: [...editOperations, ...ranges], selectedWordIndices: [], }); + if (kind === 'mute' || kind === 'room-tone' || kind === 'caption-only' || kind === 'delete') { + invalidateClipPreparationForTimelineChange(); + } }, renameSpeaker: (speaker, label) => { @@ -428,10 +453,13 @@ export const useEditorStore = create()( restoreRange: (rangeId) => { const { deletedRanges, editOperations } = get(); + if (!deletedRanges.some((range) => range.id === rangeId)) return; + if (isClipTimelineMutationBlocked()) return; set({ deletedRanges: deletedRanges.filter((r) => r.id !== rangeId), editOperations: editOperations.filter((operation) => operation.id !== rangeId), }); + invalidateClipPreparationForTimelineChange(); }, restoreEditOperation: (operationId) => { @@ -439,11 +467,19 @@ export const useEditorStore = create()( const operation = editOperations.find((candidate) => candidate.id === operationId); if (!operation) return; + const outputAffecting = + operation.kind === 'delete' || + operation.kind === 'mute' || + operation.kind === 'room-tone' || + operation.kind === 'caption-only'; + if (outputAffecting && isClipTimelineMutationBlocked()) return; + if (operation.kind === 'delete') { set({ deletedRanges: get().deletedRanges.filter((range) => range.id !== operationId), editOperations: editOperations.filter((candidate) => candidate.id !== operationId), }); + invalidateClipPreparationForTimelineChange(); return; } @@ -475,6 +511,7 @@ export const useEditorStore = create()( } set({ editOperations: editOperations.filter((operation) => operation.id !== operationId) }); + if (outputAffecting) invalidateClipPreparationForTimelineChange(); }, setTranscribing: (active, progress) => diff --git a/frontend/src/utils/clipBatchExport.ts b/frontend/src/utils/clipBatchExport.ts index b18b4db..e7d8a35 100644 --- a/frontend/src/utils/clipBatchExport.ts +++ b/frontend/src/utils/clipBatchExport.ts @@ -4,7 +4,7 @@ import { validateClipDraftForExport } from './clipDrafts'; export const INTERRUPTED_CLIP_EXPORT_ERROR = 'Export was interrupted before ScriptCut could confirm completion. Retry this clip.'; -const BATCH_EXPORT_STATUSES = new Set(['draft', 'packaged', 'failed']); +const BATCH_EXPORT_STATUSES = new Set(['packaged', 'failed']); export function getClipBatchExportCandidates( drafts: ClipDraft[], @@ -18,6 +18,17 @@ export function getClipBatchExportCandidates( ); } +export function getCurrentClipBatchDraftForExport( + drafts: ClipDraft[], + plannedDraftId: string, + words: Word[], + videoPath: string | null, +) { + const currentDraft = drafts.find((draft) => draft.id === plannedDraftId); + if (!currentDraft) return null; + return getClipBatchExportCandidates([currentDraft], words, videoPath)[0] || null; +} + export function hasRecoverableClipExports( drafts: ClipDraft[], words: Word[], diff --git a/frontend/src/utils/clipDrafts.ts b/frontend/src/utils/clipDrafts.ts index 4a89f54..10a3451 100644 --- a/frontend/src/utils/clipDrafts.ts +++ b/frontend/src/utils/clipDrafts.ts @@ -1,4 +1,4 @@ -import type { ClipDraft, ClipSuggestion, DeletedRange, Word } from '../types/project'; +import type { ClipDraft, ClipSuggestion, DeletedRange, EditOperation, Word } from '../types/project'; export type ClipDraftExportValidation = { ready: boolean; @@ -16,6 +16,93 @@ export type ClipExportSegment = { end: number; }; +const PREPARATION_INVALIDATING_FIELDS = new Set([ + 'title', + 'startWordIndex', + 'endWordIndex', + 'startTime', + 'endTime', + 'format', + 'resolution', + 'aspectRatio', + 'reframe', + 'enhanceAudio', + 'captions', + 'captionStyle', + 'backgroundRemoval', +]); + +export function clipDraftPatchInvalidatesPreparation(patch: Partial) { + return (Object.keys(patch) as Array).some((key) => + PREPARATION_INVALIDATING_FIELDS.has(key), + ); +} + +export function getClipDraftUserEditResult( + draft: ClipDraft, + patch: Partial, +): { patch: Partial; invalidated: boolean; blocked: boolean } { + const invalidatesPreparation = clipDraftPatchInvalidatesPreparation(patch); + if (!invalidatesPreparation) return { patch, invalidated: false, blocked: false }; + if (draft.status === 'exporting') return { patch: {}, invalidated: false, blocked: true }; + if (draft.status !== 'packaged' && draft.status !== 'failed' && draft.status !== 'exported') { + return { patch, invalidated: false, blocked: false }; + } + return { + patch: { + ...patch, + status: 'draft', + lastError: undefined, + exportPath: undefined, + srtPath: undefined, + exportWarnings: undefined, + exportedAt: undefined, + }, + invalidated: true, + blocked: false, + }; +} + +export function invalidateClipDraftsForTimelineChange(drafts: ClipDraft[]): ClipDraft[] { + let changed = false; + const next = drafts.map((draft) => { + if (draft.status !== 'packaged' && draft.status !== 'failed' && draft.status !== 'exported') return draft; + changed = true; + return { + ...draft, + status: 'draft' as const, + lastError: undefined, + exportPath: undefined, + srtPath: undefined, + exportWarnings: undefined, + exportedAt: undefined, + }; + }); + return changed ? next : drafts; +} + +export function getClipTimelineExportFingerprint( + deletedRanges: DeletedRange[], + editOperations: EditOperation[], +) { + const outputOperations = editOperations.filter((operation) => + operation.kind === 'delete' || + operation.kind === 'mute' || + operation.kind === 'room-tone' || + operation.kind === 'caption-only', + ); + return JSON.stringify({ + deletedRanges: deletedRanges.map((range) => [range.id, range.start, range.end, range.wordIndices]), + editOperations: outputOperations.map((operation) => [ + operation.id, + operation.kind, + operation.start, + operation.end, + operation.wordIndices, + ]), + }); +} + export function findWordIndexAtOrAfter(words: Word[], time: number) { if (words.length === 0) return -1; const target = Math.max(0, time); @@ -98,6 +185,18 @@ export function validateClipDraftForExport( }; } +export function prepareReadyClipDraftsForExport( + drafts: ClipDraft[], + words: Word[], + videoPath: string | null, +): ClipDraft[] { + return drafts.map((draft) => { + if ((draft.status || 'draft') !== 'draft') return draft; + if (!validateClipDraftForExport(draft, words, videoPath).ready) return draft; + return { ...draft, status: 'packaged' as const, lastError: undefined }; + }); +} + /** * Returns the source segments that remain after applying transcript cuts to a * clip. The renderer concatenates these segments, so clips stay faithful to diff --git a/frontend/src/utils/clipWorkspace.ts b/frontend/src/utils/clipWorkspace.ts index 470292e..c1fe79b 100644 --- a/frontend/src/utils/clipWorkspace.ts +++ b/frontend/src/utils/clipWorkspace.ts @@ -83,8 +83,8 @@ export function readClipDiscoveryResult(result: unknown): { } const REVIEW_STATUSES = new Set(['suggested']); -const PREPARE_STATUSES = new Set(['draft', 'packaged']); -const EXPORT_STATUSES = new Set(['exporting', 'exported', 'failed']); +const PREPARE_STATUSES = new Set(['draft']); +const EXPORT_STATUSES = new Set(['packaged', 'exporting', 'exported', 'failed']); export type ClipQueueSummary = { suggested: number; @@ -265,6 +265,6 @@ export function isClipDraftInStage(draft: ClipDraft, stage: ClipWorkspaceStage) const status = draft.status || 'draft'; if (stage === 'review') return status === 'suggested'; if (stage === 'prepare') return PREPARE_STATUSES.has(status); - if (stage === 'export') return !REVIEW_STATUSES.has(status); + if (stage === 'export') return EXPORT_STATUSES.has(status); return false; }