diff --git a/frontend/scripts/smoke-create-clips-workspace.mjs b/frontend/scripts/smoke-create-clips-workspace.mjs index 5365a48..7ab2f27 100644 --- a/frontend/scripts/smoke-create-clips-workspace.mjs +++ b/frontend/scripts/smoke-create-clips-workspace.mjs @@ -22,6 +22,9 @@ const projectSchemaSource = readSource('../../shared/project-schema.json'); assert.match(panelSource, /export default function AIPanel\(\{ mode = 'general' \}/); assert.match(panelSource, /getInitialClipWorkspaceStage\(clipDrafts, clipSuggestions\)/); +assert.match(panelSource, /getNewManualClipDrafts\(clipDrafts, knownClipDraftIdsRef\.current\)/); +assert.match(panelSource, /if \(mode !== 'clips' \|\| newlyAddedManualDrafts\.length === 0\) return/); +assert.match(panelSource, /setClipStage\('prepare'\);\s+setActiveClipDraftId\(draft\.id\);\s+setSelectedWordIndices\(getWordIndicesForClip\(words, draft\)\)/); assert.match(panelSource, /Find moments with AI/); assert.match(panelSource, />Find moments<\/h3>/); assert.match(panelSource, /Choose moments yourself/); @@ -43,6 +46,11 @@ assert.match(panelSource, /source: 'ai-director'/); assert.match(panelSource, /updateClipDraft\(id, \{ status: 'draft', lastError: undefined \}\)/); assert.match(panelSource, /const pendingReviewItems = useMemo/); assert.match(panelSource, /Review \$\{pendingReviewItems\.length\}/); +assert.match(panelSource, /Prepare \$\{clipQueueSummary\.prepare\}/); +assert.match(panelSource, /label="Exporting"/); +assert.match(panelSource, /label="Retry"/); +assert.match(panelSource, /label="Failed"/); +assert.match(panelSource, /label="Exported"/); assert.match(panelSource, /removeMatchingClipSuggestions/); assert.match(panelSource, /createClipDraft\(clip, 'ai', undefined, false\)/); assert.match(panelSource, /requestPreviewRange\(previewRange\.start, previewRange\.end\)/); @@ -104,8 +112,10 @@ new Function('exports', 'module', 'require', compiled.outputText)(module.exports const { getInitialClipWorkspaceStage, + getClipQueueSummary, getClipPreviewRange, getClipReviewKey, + getNewManualClipDrafts, getPendingReviewItems, getSkippedReviewItems, getReviewCounts, @@ -129,6 +139,8 @@ const approvedDraft = { ...matchingDraft, id: 'clip_2', status: 'draft' }; 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 }; +const restoredManualDraft = { ...matchingDraft, id: 'manual-restored', status: 'draft', source: 'transcript-selection' }; +const newManualDraft = { ...matchingDraft, id: 'manual-new', status: 'draft', source: 'transcript-selection' }; assert.equal(getInitialClipWorkspaceStage([], []), 'find'); const rankedDiscovery = readClipDiscoveryResult({ @@ -152,6 +164,11 @@ assert.equal(getInitialClipWorkspaceStage([approvedDraft], []), 'prepare'); assert.equal(getInitialClipWorkspaceStage([manualDraft], []), 'prepare'); assert.equal(getInitialClipWorkspaceStage([exportedDraft], []), 'export'); assert.equal(getInitialClipWorkspaceStage([approvedDraft], [suggestion]), 'prepare'); +assert.deepEqual(getNewManualClipDrafts([restoredManualDraft], new Set(['manual-restored'])), []); +assert.deepEqual( + getNewManualClipDrafts([restoredManualDraft, newManualDraft], new Set(['manual-restored'])), + [newManualDraft], +); assert.deepEqual( getUnmatchedLegacyClipSuggestions([suggestion, unmatchedSuggestion], [matchingDraft]), [unmatchedSuggestion], @@ -205,8 +222,24 @@ 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({ ...approvedDraft, status: 'exporting' }, 'prepare'), false); +assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'failed' }, 'prepare'), false); assert.equal(isClipDraftInStage(exportedDraft, 'export'), true); assert.equal(isClipDraftInStage(matchingDraft, 'export'), false); +const queueSummary = getClipQueueSummary( + [ + { ...matchingDraft, id: 'suggested-1', status: 'suggested' }, + { ...approvedDraft, id: 'draft-1', status: 'draft' }, + { ...approvedDraft, id: 'packaged-1', status: 'packaged' }, + { ...approvedDraft, id: 'exporting-1', status: 'exporting' }, + { ...exportedDraft, id: 'exported-1', status: 'exported' }, + { ...approvedDraft, id: 'failed-retryable', status: 'failed' }, + { ...approvedDraft, id: 'failed-invalid', status: 'failed' }, + ], + new Set(['failed-retryable']), +); +assert.deepEqual(queueSummary, { suggested: 1, prepare: 2, 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/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index 8ffb617..301d9ca 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -29,8 +29,10 @@ import { } from '../utils/hookFrames'; import { getInitialClipWorkspaceStage, + getClipQueueSummary, getClipPreviewRange, getClipReviewKey, + getNewManualClipDrafts, getPendingReviewItems, getSkippedReviewItems, isSameClipRange, @@ -164,16 +166,6 @@ const SHORTS_DRAFT_DEFAULTS = { const CLIP_EXPORT_DIRECTORY_KEY = 'scriptcut.clipExport.directory'; -function getClipQueueSummary(drafts: ClipDraft[]) { - return { - suggested: drafts.filter((draft) => (draft.status || 'draft') === 'suggested').length, - approved: drafts.filter((draft) => ['draft', 'packaged', 'failed'].includes(draft.status || 'draft')).length, - copyReady: drafts.filter((draft) => getPublishingCopyState(draft).ready).length, - exported: drafts.filter((draft) => (draft.status || 'draft') === 'exported').length, - failed: drafts.filter((draft) => (draft.status || 'draft') === 'failed').length, - }; -} - export type AIPanelMode = 'general' | 'clips'; export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { @@ -235,6 +227,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const [activeClipPreviewKey, setActiveClipPreviewKey] = useState(null); const [clipExportDirectory, setClipExportDirectory] = useState(() => window.localStorage.getItem(CLIP_EXPORT_DIRECTORY_KEY) || ''); const [creatorNotice, setCreatorNotice] = useState(null); + const knownClipDraftIdsRef = useRef(new Set(clipDrafts.map((draft) => draft.id))); const secondaryToolsVisible = mode === 'clips' && (showSecondaryTools || activeTab === 'edit' || activeTab === 'filler'); const isCurrentClipWorkspace = useCallback( @@ -291,6 +284,17 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { setClipExportDirectory((current) => current || getPathDirectory(videoPath)); }, [videoPath]); + useEffect(() => { + const newlyAddedManualDrafts = getNewManualClipDrafts(clipDrafts, knownClipDraftIdsRef.current); + for (const draft of clipDrafts) knownClipDraftIdsRef.current.add(draft.id); + if (mode !== 'clips' || newlyAddedManualDrafts.length === 0) return; + + const draft = newlyAddedManualDrafts[newlyAddedManualDrafts.length - 1]; + setClipStage('prepare'); + setActiveClipDraftId(draft.id); + setSelectedWordIndices(getWordIndicesForClip(words, draft)); + }, [clipDrafts, mode, setSelectedWordIndices, words]); + const reviewedCount = useMemo(() => { if (!fillerResult) return 0; return fillerResult.fillerWords.filter( @@ -828,11 +832,30 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const exportBusy = isBatchExporting || exportingDraftId !== null; const stopBatchExportRef = useRef(false); const [publishingCopyDraftId, setPublishingCopyDraftId] = useState(null); - const clipQueueSummary = useMemo(() => getClipQueueSummary(clipDrafts), [clipDrafts]); - const readyDraftCount = useMemo( - () => getClipBatchExportCandidates(clipDrafts, words, videoPath).length, + const exportCandidateDrafts = useMemo( + () => getClipBatchExportCandidates(clipDrafts, words, videoPath), [clipDrafts, videoPath, words], ); + const retryableDraftIds = useMemo( + () => new Set( + exportCandidateDrafts + .filter((draft) => (draft.status || 'draft') === 'failed') + .map((draft) => draft.id), + ), + [exportCandidateDrafts], + ); + const clipQueueSummary = useMemo( + () => ({ + ...getClipQueueSummary(clipDrafts, retryableDraftIds), + copyReady: clipDrafts.filter((draft) => getPublishingCopyState(draft).ready).length, + }), + [clipDrafts, retryableDraftIds], + ); + const approvedReviewCount = useMemo( + () => Object.values(clipReviewDecisions).filter((decision) => decision === 'approved').length, + [clipReviewDecisions], + ); + const readyDraftCount = exportCandidateDrafts.length; const clipStageDrafts = useMemo( () => clipDrafts.filter((draft) => isClipDraftInStage(draft, clipStage)), [clipDrafts, clipStage], @@ -1816,7 +1839,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { {([ { stage: 'find', label: 'Find' }, { stage: 'review', label: `Review ${pendingReviewItems.length}` }, - { stage: 'prepare', label: `Prepare ${clipQueueSummary.approved}` }, + { stage: 'prepare', label: `Prepare ${clipQueueSummary.prepare}` }, { stage: 'export', label: `Export ${readyDraftCount} ready` }, ] as Array<{ stage: ClipWorkspaceStage; label: string }>).map(({ stage, label }) => (