From 1f1e079e88fa16d6313681fb270734353cf612e4 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Mon, 7 Sep 2026 13:13:09 -0700 Subject: [PATCH] fix: harden ai job lifecycle --- frontend/scripts/smoke-clip-publishing.mjs | 14 +- .../scripts/smoke-clips-first-lifecycle.mjs | 25 +- .../scripts/smoke-errors-accessibility.mjs | 8 +- frontend/src/components/AIPanel.tsx | 332 ++++++++++++++---- frontend/src/features/clips/ClipDraftCard.tsx | 4 +- 5 files changed, 297 insertions(+), 86 deletions(-) diff --git a/frontend/scripts/smoke-clip-publishing.mjs b/frontend/scripts/smoke-clip-publishing.mjs index 094ec75..1ee864e 100644 --- a/frontend/scripts/smoke-clip-publishing.mjs +++ b/frontend/scripts/smoke-clip-publishing.mjs @@ -122,9 +122,19 @@ assert.equal(mergeGeneratedPublishingCopy(exportReadyWithoutCopy, { hook: ' ' }) assert.match(cardSource, /Generate publishing copy/); assert.match(cardSource, /Refresh publishing copy/); assert.match(cardSource, /Copy ready/); +assert.match(cardSource, /aiBusy: boolean/); +assert.match(cardSource, /disabled=\{isSuggested \|\| aiBusy \|\| isGeneratingPublishingCopy\}/); +assert.match(panelSource, /aiBusy=\{isProcessing\}/); assert.match(cardSource, /onChange\(\{ title: suggestion \}\)/); -assert.match(panelSource, /mergeGeneratedPublishingCopy\(draft, data\)/); -assert.match(panelSource, /mergeGeneratedPublishingCopy\(draft, metadata\)/); +assert.match(panelSource, /function getClipMetadataInputFingerprint\(draft: ClipDraft, words: Word\[\]\)/); +assert.match(panelSource, /inputFingerprint = getClipMetadataInputFingerprint\(draft, words\)/); +assert.match(panelSource, /useAIStore\.getState\(\)\.clipDrafts\.find\(\(item\) => item\.id === draft\.id\)/); +assert.match(panelSource, /useEditorStore\.getState\(\)\.words/); +assert.match(panelSource, /getClipMetadataInputFingerprint\(currentDraft, currentWords\) !== inputFingerprint/); +assert.match(panelSource, /mergeGeneratedPublishingCopy\(currentDraft, data\)/); +assert.match(panelSource, /sourceJob\.inputFingerprint/); +assert.match(panelSource, /getClipMetadataInputFingerprint\(currentDraft, currentWords\) !== sourceJob\.inputFingerprint/); +assert.match(panelSource, /mergeGeneratedPublishingCopy\(currentDraft, metadata\)/); assert.doesNotMatch(`${panelSource}\n${cardSource}`, /\bPackage\b|\bPackaged\b/); const generationBody = panelSource.match(/const generatePublishingCopy = useCallback\(([\s\S]*?)\n\s{2}\);\n\s{2}\n\s{2}const retryAIJob/)?.[1] || ''; assert.doesNotMatch(generationBody, /lastError/); diff --git a/frontend/scripts/smoke-clips-first-lifecycle.mjs b/frontend/scripts/smoke-clips-first-lifecycle.mjs index 6f22a74..c3d1625 100644 --- a/frontend/scripts/smoke-clips-first-lifecycle.mjs +++ b/frontend/scripts/smoke-clips-first-lifecycle.mjs @@ -46,9 +46,28 @@ assert.match(aiStoreSource, /clipReviewDecisions: \{\}/); assert.match(aiStoreSource, /providers: \{/); assert.match(panelSource, /if \(!isCurrentClipWorkspace\(\)\) return;\s+setEditPlanResult\(data\);/); assert.match(panelSource, /if \(!isCurrentClipWorkspace\(\)\) return;\s+setFillerResult\(data\);/); -assert.match(panelSource, /const result = await pollAIJob[\s\S]*if \(!isCurrentClipWorkspace\(\)\) return;/); -assert.match(panelSource, /const cancelAIJob = useCallback\([\s\S]*if \(!isCurrentClipWorkspace\(\)\) return;/); -assert.match(panelSource, /finally \{\s+if \(isCurrentClipWorkspace\(\)\) setProcessing\(false\);/); +assert.match(panelSource, /type AIJobRunContext = \{[\s\S]*workspaceEpoch: number/); +assert.match(panelSource, /const activeAIJobRunRef = useRef\(null\)/); +assert.match(panelSource, /Another AI action is still running/); +assert.match(panelSource, /const isCurrentAIJobRun = useCallback/); +assert.match(panelSource, /let jobRes: Response;[\s\S]*jobRes = await fetch\(`\$\{backendUrl\}\/jobs\/\$\{jobId\}`\)[\s\S]*Connection interrupted; checking AI job status[\s\S]*continue;/); +assert.match(panelSource, /if \(!jobRes\.ok\) \{[\s\S]*jobRes\.status === 404[\s\S]*AI job status unavailable; retrying[\s\S]*continue;/); +assert.match(panelSource, /let job: AIJob;[\s\S]*job = \(await jobRes\.json\(\)\) as AIJob[\s\S]*AI job status was unreadable; retrying[\s\S]*continue;/); +assert.match(panelSource, /setActiveAIJob\(\{ \.\.\.job, \.\.\.context, workspaceEpoch: run\.workspaceEpoch, runId: run\.id \}\)/); +assert.match(panelSource, /workspaceEpoch: run\.workspaceEpoch/); +assert.match(panelSource, /runId: run\.id/); +assert.match(panelSource, /current && current\.workspaceEpoch !== clipWorkspaceEpoch \? null : current/); +const cancelAIJobBody = panelSource.match(/const cancelAIJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || ''; +assert.match(cancelAIJobBody, /const run = activeAIJobRunRef\.current/); +assert.match(cancelAIJobBody, /if \(job\.workspaceEpoch !== run\.workspaceEpoch \|\| job\.runId !== run\.id \|\| !isCurrentAIJobRun\(run\)\) return/); +assert.match(cancelAIJobBody, /await fetch\([\s\S]*if \(!isCurrentAIJobRun\(run\)\) return/); +assert.doesNotMatch(cancelAIJobBody, /setProcessing\(false/); +const retryAIJobBody = panelSource.match(/const retryAIJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || ''; +assert.match(retryAIJobBody, /const sourceJob = activeAIJob/); +assert.match(retryAIJobBody, /sourceJob\.workspaceEpoch !== useAIStore\.getState\(\)\.clipWorkspaceEpoch/); +assert.match(retryAIJobBody, /run = beginAIJobRun\(\)/); +assert.match(retryAIJobBody, /pollAIJob\(jobId, sourceJob\.label, context, run\)/); +assert.match(retryAIJobBody, /if \(!isCurrentAIJobRun\(run\)\) return/); const openFileBody = appSource.match(/const handleOpenFile = async \(intent: WorkflowIntent = 'full-video'\) => \{([\s\S]*?)\n\s*\};\s*const handleBrowserFileChange/)?.[1] || ''; const browserChangeBody = appSource.match(/const handleBrowserFileChange = async \(e: React\.ChangeEvent\) => \{([\s\S]*?)\n\s*\};\s*const handleBrowserDrop/)?.[1] || ''; diff --git a/frontend/scripts/smoke-errors-accessibility.mjs b/frontend/scripts/smoke-errors-accessibility.mjs index 6b8dfd8..7a35f03 100644 --- a/frontend/scripts/smoke-errors-accessibility.mjs +++ b/frontend/scripts/smoke-errors-accessibility.mjs @@ -82,12 +82,14 @@ assert.match(waveform, /aria-label=\{followPlayhead \? 'Stop following playhead' assert.match(waveform, /aria-label="Zoom out"/); assert.match(waveform, /aria-label="Zoom in"/); -assert.match(ai, /activeAIJob\.id.*\/jobs\/\$\{activeAIJob\.id\}\/retry|\/jobs\/\$\{activeAIJob\.id\}\/retry/); +assert.match(ai, /\/jobs\/\$\{sourceJob\.id\}\/retry/); assert.match(ai, /ai:clip-metadata/); assert.match(ai, /appendDiscoveredClipDrafts/); assert.match(ai, /setCreatorNotice/); -assert.match(ai, /setCreatorNotice\(null\);\s*const startRes/); -assert.match(ai, /setCreatorNotice\(null\);\s*setProcessing\(true, `Retrying/); +assert.match(ai, /setCreatorNotice\(null\);\s*setProcessing\(true, processingMessage\);\s*try \{\s*const startRes/); +assert.match(ai, /setCreatorNotice\(null\);\s*setProcessing\(true, `Retrying \$\{sourceJob\.label\}/); +assert.match(ai, /Another AI action is still running/); +assert.match(ai, /const canRetry = !busy && \(job\.status === 'failed' \|\| job\.status === 'canceled'\)/); const generatePublishingCopyBody = ai.match(/const generatePublishingCopy = useCallback\(([\s\S]*?)\n \);\n\n const retryAIJob/)?.[1] || ''; assert.match(generatePublishingCopyBody, /getCreatorErrorPresentation\('ai-action', err\)/); assert.doesNotMatch(generatePublishingCopyBody, /getCreatorErrorPresentation\('clip-action', err\)/); diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index 91689d2..f6b822d 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 type { ClipDraft, ClipSuggestion, EditPlanReviewDecision, EditPlanResult, EditPlanSuggestion, FillerReviewDecision, FillerWordResult, Word } from '../types/project'; import { getClipDraftUserEditResult, getClipDraftReadinessScore, @@ -69,6 +69,17 @@ type AIJob = { type AIJobContext = { label: string; draftId?: string; + inputFingerprint?: string; +}; + +type AIJobRunContext = { + id: number; + workspaceEpoch: number; +}; + +type ActiveAIJob = AIJob & AIJobContext & { + workspaceEpoch: number; + runId: number; }; type ClipDiscoveryResult = { @@ -87,6 +98,14 @@ type ClipMetadataResult = { hashtags?: string[]; }; +function getClipMetadataInputFingerprint(draft: ClipDraft, words: Word[]) { + const transcriptFingerprint = words + .slice(draft.startWordIndex, draft.endWordIndex + 1) + .map((word) => `${word.start}:${word.end}:${word.word}`) + .join('\u001f'); + return `${draft.startWordIndex}:${draft.endWordIndex}:${transcriptFingerprint}`; +} + export type AIPanelMode = 'general' | 'clips'; export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { @@ -141,7 +160,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { ); const [fillerQueueFilter, setFillerQueueFilter] = useState('all'); const [fillerReasonFilter, setFillerReasonFilter] = useState('all'); - const [activeAIJob, setActiveAIJob] = useState<(AIJob & AIJobContext) | null>(null); + const [activeAIJob, setActiveAIJob] = useState(null); const [backgroundCapabilities, setBackgroundCapabilities] = useState(null); const [activeClipDraftId, setActiveClipDraftId] = useState(null); const [activeClipPreviewKey, setActiveClipPreviewKey] = useState(null); @@ -165,12 +184,41 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { updateClipExportDirectory, } = useClipExportController({ setCreatorNotice }); const knownClipDraftIdsRef = useRef(new Set(clipDrafts.map((draft) => draft.id))); + const aiJobRunEpochRef = useRef(0); + const activeAIJobRunRef = useRef(null); const secondaryToolsVisible = mode === 'clips' && (showSecondaryTools || activeTab === 'edit' || activeTab === 'filler'); const isCurrentClipWorkspace = useCallback( () => useAIStore.getState().clipWorkspaceEpoch === clipWorkspaceEpoch, [clipWorkspaceEpoch], ); + const isCurrentAIJobRun = useCallback((run: AIJobRunContext) => { + const activeRun = activeAIJobRunRef.current; + return ( + activeRun?.id === run.id && + activeRun.workspaceEpoch === run.workspaceEpoch && + useAIStore.getState().clipWorkspaceEpoch === run.workspaceEpoch + ); + }, []); + const beginAIJobRun = useCallback(() => { + const workspaceEpoch = useAIStore.getState().clipWorkspaceEpoch; + const activeRun = activeAIJobRunRef.current; + if (activeRun?.workspaceEpoch === workspaceEpoch) { + throw new Error('Another AI action is still running. Wait for it to finish or cancel it first.'); + } + const run = { + id: aiJobRunEpochRef.current + 1, + workspaceEpoch, + }; + aiJobRunEpochRef.current = run.id; + activeAIJobRunRef.current = run; + return run; + }, []); + const finishAIJobRun = useCallback((run: AIJobRunContext) => { + if (activeAIJobRunRef.current?.id === run.id) { + activeAIJobRunRef.current = null; + } + }, []); const activeReviewPreviewKey = isPlaying && previewRangeEnd !== null ? activeClipPreviewKey : null; @@ -180,6 +228,15 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { setShowSecondaryTools(false); }, [mode]); + useEffect(() => { + setActiveAIJob((current) => + current && current.workspaceEpoch !== clipWorkspaceEpoch ? null : current, + ); + if (activeAIJobRunRef.current?.workspaceEpoch !== clipWorkspaceEpoch) { + activeAIJobRunRef.current = null; + } + }, [clipWorkspaceEpoch]); + useEffect(() => { if (mode === 'clips') return () => clearClipPresentationPreview(); clearClipPresentationPreview(); @@ -387,14 +444,49 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }, [words]); const pollAIJob = useCallback( - async (jobId: string, fallbackMessage: string, context: AIJobContext) => { + async (jobId: string, fallbackMessage: string, context: AIJobContext, run: AIJobRunContext) => { for (;;) { await new Promise((resolve) => window.setTimeout(resolve, 700)); - const jobRes = await fetch(`${backendUrl}/jobs/${jobId}`); - if (!jobRes.ok) throw new Error(`${fallbackMessage} status failed: ${jobRes.statusText}`); - const job = (await jobRes.json()) as AIJob; - if (!isCurrentClipWorkspace()) throw new Error('The media workspace changed while this job was running.'); - setActiveAIJob({ ...job, ...context }); + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + let jobRes: Response; + try { + jobRes = await fetch(`${backendUrl}/jobs/${jobId}`); + } catch { + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + setActiveAIJob((current) => + current?.runId === run.id + ? { ...current, message: 'Connection interrupted; checking AI job status...' } + : current, + ); + setProcessing(true, 'Connection interrupted; checking AI job status...'); + continue; + } + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + if (!jobRes.ok) { + if (jobRes.status === 404) throw new Error(`${fallbackMessage} job is no longer available`); + setActiveAIJob((current) => + current?.runId === run.id + ? { ...current, message: 'AI job status unavailable; retrying...' } + : current, + ); + setProcessing(true, 'AI job status unavailable; retrying...'); + continue; + } + let job: AIJob; + try { + job = (await jobRes.json()) as AIJob; + } catch { + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + setActiveAIJob((current) => + current?.runId === run.id + ? { ...current, message: 'AI job status was unreadable; retrying...' } + : current, + ); + setProcessing(true, 'AI job status was unreadable; retrying...'); + continue; + } + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was running.'); + setActiveAIJob({ ...job, ...context, workspaceEpoch: run.workspaceEpoch, runId: run.id }); setProcessing( job.status === 'queued' || job.status === 'running' || job.status === 'canceling', job.message || fallbackMessage, @@ -409,42 +501,65 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { } } }, - [backendUrl, isCurrentClipWorkspace, setProcessing], + [backendUrl, isCurrentAIJobRun, setProcessing], ); const startAIJob = useCallback( - async (path: string, body: unknown, fallbackMessage: string, context?: Partial) => { + async ( + path: string, + body: unknown, + fallbackMessage: string, + context?: Partial, + processingMessage = fallbackMessage, + ) => { + const run = beginAIJobRun(); setCreatorNotice(null); - const startRes = await fetch(`${backendUrl}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!startRes.ok) { - const errorData = await startRes.json().catch(() => null); - throw new Error(errorData?.detail || `${fallbackMessage} start failed`); - } + setProcessing(true, processingMessage); + try { + const startRes = await fetch(`${backendUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was starting.'); + if (!startRes.ok) { + const errorData = await startRes.json().catch(() => null); + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was starting.'); + throw new Error(errorData?.detail || `${fallbackMessage} start failed`); + } - const { job_id: jobId } = await startRes.json(); - const jobContext = { label: context?.label || fallbackMessage, draftId: context?.draftId }; - setActiveAIJob({ - id: jobId, - kind: path.replace('/jobs/', ''), - status: 'queued', - progress: 0, - message: 'Queued', - logs: [], - ...jobContext, - }); - return pollAIJob(jobId, fallbackMessage, jobContext); + const { job_id: jobId } = await startRes.json(); + if (!isCurrentAIJobRun(run)) throw new Error('The media workspace changed while this AI job was starting.'); + const jobContext = { + label: context?.label || fallbackMessage, + draftId: context?.draftId, + inputFingerprint: context?.inputFingerprint, + }; + setActiveAIJob({ + id: jobId, + kind: path.replace('/jobs/', ''), + status: 'queued', + progress: 0, + message: 'Queued', + logs: [], + ...jobContext, + workspaceEpoch: run.workspaceEpoch, + runId: run.id, + }); + return await pollAIJob(jobId, fallbackMessage, jobContext, run); + } finally { + if (isCurrentAIJobRun(run)) { + finishAIJobRun(run); + setProcessing(false); + } + } }, - [backendUrl, pollAIJob], + [backendUrl, beginAIJobRun, finishAIJobRun, isCurrentAIJobRun, pollAIJob, setProcessing], ); const createEditPlan = useCallback(async () => { const instruction = editPlanInstruction.trim(); if (words.length === 0 || !instruction) return; - setProcessing(true, 'Planning edits...'); try { const config = providers[defaultProvider]; const transcript = words.map((w) => w.word).join(' '); @@ -466,14 +581,13 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }, 'Edit planning', { label: 'Edit planning' }, + 'Planning edits...', ); if (!isCurrentClipWorkspace()) return; setEditPlanResult(data); } catch (err) { console.error(err); setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) }); - } finally { - if (isCurrentClipWorkspace()) setProcessing(false); } }, [ defaultProvider, @@ -481,7 +595,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { isCurrentClipWorkspace, providers, setEditPlanResult, - setProcessing, startAIJob, words, ]); @@ -489,7 +602,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const createDirectorPlan = useCallback(async () => { const instruction = editPlanInstruction.trim() || 'Create a fast, high-retention 60 second Short from the strongest moment.'; if (words.length === 0) return; - setProcessing(true, 'Directing short-form edit...'); try { const config = providers[defaultProvider]; const transcript = words.map((w) => w.word).join(' '); @@ -514,6 +626,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }, 'AI Director', { label: 'AI Director' }, + 'Directing short-form edit...', ); if (!isCurrentClipWorkspace()) return; setEditPlanResult(data); @@ -536,8 +649,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { } catch (err) { console.error(err); setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) }); - } finally { - if (isCurrentClipWorkspace()) setProcessing(false); } }, [ defaultProvider, @@ -547,7 +658,6 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { setClipDrafts, setClipStage, setEditPlanResult, - setProcessing, startAIJob, words, ]); @@ -587,19 +697,28 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }, [isCurrentClipWorkspace, setClipDrafts, setClipReviewDecisions, setClipStage, setClipSuggestions]); const cancelAIJob = useCallback(async () => { - if (!activeAIJob || !['queued', 'running'].includes(activeAIJob.status)) return; - const res = await fetch(`${backendUrl}/jobs/${activeAIJob.id}/cancel`, { method: 'POST' }); - if (!isCurrentClipWorkspace()) return; + const job = activeAIJob; + const run = activeAIJobRunRef.current; + if (!job || !run || !['queued', 'running'].includes(job.status)) return; + if (job.workspaceEpoch !== run.workspaceEpoch || job.runId !== run.id || !isCurrentAIJobRun(run)) return; + const res = await fetch(`${backendUrl}/jobs/${job.id}/cancel`, { method: 'POST' }); + if (!isCurrentAIJobRun(run)) return; if (res.ok) { - const job = (await res.json()) as AIJob; - setActiveAIJob({ ...job, label: activeAIJob.label, draftId: activeAIJob.draftId }); + const canceledJob = (await res.json()) as AIJob; + if (!isCurrentAIJobRun(run)) return; + setActiveAIJob({ + ...canceledJob, + label: job.label, + draftId: job.draftId, + inputFingerprint: job.inputFingerprint, + workspaceEpoch: job.workspaceEpoch, + runId: job.runId, + }); } - setProcessing(false); - }, [activeAIJob, backendUrl, isCurrentClipWorkspace, setProcessing]); + }, [activeAIJob, backendUrl, isCurrentAIJobRun]); const detectFillers = useCallback(async () => { if (words.length === 0) return; - setProcessing(true, 'Detecting filler words...'); try { const config = providers[defaultProvider]; const transcript = words.map((w) => w.word).join(' '); @@ -616,20 +735,18 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }, 'Filler detection', { label: 'Filler detection' }, + 'Detecting filler words...', ); if (!isCurrentClipWorkspace()) return; setFillerResult(data); } catch (err) { console.error(err); setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) }); - } finally { - if (isCurrentClipWorkspace()) setProcessing(false); } - }, [words, defaultProvider, providers, customFillerWords, isCurrentClipWorkspace, setProcessing, setFillerResult, startAIJob]); + }, [words, defaultProvider, providers, customFillerWords, isCurrentClipWorkspace, setFillerResult, startAIJob]); const createClips = useCallback(async () => { if (words.length === 0) return; - setProcessing(true, 'Finding best clip segments...'); try { const config = providers[defaultProvider]; const transcript = words.map((w) => w.word).join(' '); @@ -651,15 +768,14 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { }, 'Clip discovery', { label: 'Clip discovery' }, + 'Finding best clip segments...', ); applyClipDiscoveryResult(data); } catch (err) { console.error(err); setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) }); - } finally { - if (isCurrentClipWorkspace()) setProcessing(false); } - }, [applyClipDiscoveryResult, defaultProvider, isCurrentClipWorkspace, providers, setProcessing, startAIJob, words]); + }, [applyClipDiscoveryResult, defaultProvider, providers, startAIJob, words]); const applyFillerDeletions = useCallback(() => { if (!fillerResult) return; @@ -1009,6 +1125,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { setPublishingCopyDraftId(draft.id); try { const config = providers[defaultProvider]; + const inputFingerprint = getClipMetadataInputFingerprint(draft, words); const transcript = words .slice(draft.startWordIndex, draft.endWordIndex + 1) .map((word) => word.word) @@ -1024,11 +1141,14 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { base_url: config.baseUrl || undefined, }, 'Generate publishing copy', - { label: 'Generate publishing copy', draftId: draft.id }, + { label: 'Generate publishing copy', draftId: draft.id, inputFingerprint }, ); - const patch = mergeGeneratedPublishingCopy(draft, data); + const currentDraft = useAIStore.getState().clipDrafts.find((item) => item.id === draft.id); + const currentWords = useEditorStore.getState().words; + if (!currentDraft || getClipMetadataInputFingerprint(currentDraft, currentWords) !== inputFingerprint) return; + const patch = mergeGeneratedPublishingCopy(currentDraft, data); if (!patch) throw new Error('Publishing copy generation returned no usable copy.'); - updateClipDraft(draft.id, patch); + updateClipDraft(currentDraft.id, patch); } catch (err) { console.error(err); setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) }); @@ -1040,42 +1160,97 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { ); const retryAIJob = useCallback(async () => { - if (!activeAIJob || !['failed', 'canceled'].includes(activeAIJob.status)) return; + const sourceJob = activeAIJob; + if (!sourceJob || !['failed', 'canceled'].includes(sourceJob.status)) return; + if (sourceJob.workspaceEpoch !== useAIStore.getState().clipWorkspaceEpoch) { + setActiveAIJob(null); + return; + } + if (sourceJob.kind === 'ai:clip-metadata') { + const currentDraft = sourceJob.draftId + ? useAIStore.getState().clipDrafts.find((item) => item.id === sourceJob.draftId) + : undefined; + const currentWords = useEditorStore.getState().words; + if ( + !currentDraft || + !sourceJob.inputFingerprint || + getClipMetadataInputFingerprint(currentDraft, currentWords) !== sourceJob.inputFingerprint + ) { + setActiveAIJob(null); + return; + } + } + + let run: AIJobRunContext; + try { + run = beginAIJobRun(); + } catch (err) { + setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) }); + return; + } setCreatorNotice(null); - setProcessing(true, `Retrying ${activeAIJob.label}...`); + setProcessing(true, `Retrying ${sourceJob.label}...`); try { - const retryRes = await fetch(`${backendUrl}/jobs/${activeAIJob.id}/retry`, { method: 'POST' }); + const retryRes = await fetch(`${backendUrl}/jobs/${sourceJob.id}/retry`, { method: 'POST' }); + if (!isCurrentAIJobRun(run)) return; if (!retryRes.ok) throw new Error(`Retry failed: ${retryRes.statusText}`); const { job_id: jobId } = await retryRes.json(); - const context = { label: activeAIJob.label, draftId: activeAIJob.draftId }; - const result = await pollAIJob(jobId, activeAIJob.label, context); - if (!isCurrentClipWorkspace()) return; + if (!isCurrentAIJobRun(run)) return; + const context = { + label: sourceJob.label, + draftId: sourceJob.draftId, + inputFingerprint: sourceJob.inputFingerprint, + }; + setActiveAIJob({ + id: jobId, + kind: sourceJob.kind, + status: 'queued', + progress: 0, + message: 'Queued', + logs: [], + ...context, + workspaceEpoch: run.workspaceEpoch, + runId: run.id, + }); + const result = await pollAIJob(jobId, sourceJob.label, context, run); + if (!isCurrentAIJobRun(run)) return; - if (activeAIJob.kind === 'ai:filler-removal') { + if (sourceJob.kind === 'ai:filler-removal') { setFillerResult(result as FillerWordResult); - } else if (activeAIJob.kind === 'ai:create-clip') { + } else if (sourceJob.kind === 'ai:create-clip') { applyClipDiscoveryResult(result as ClipDiscoveryResult, 'suggested_clip_retry'); - } else if (activeAIJob.kind === 'ai:edit-plan') { + } else if (sourceJob.kind === 'ai:edit-plan') { setEditPlanResult(result as EditPlanResult); - } else if (activeAIJob.kind === 'ai:clip-metadata' && activeAIJob.draftId) { + } else if (sourceJob.kind === 'ai:clip-metadata' && sourceJob.draftId) { const metadata = result as ClipMetadataResult; - const draft = clipDrafts.find((item) => item.id === activeAIJob.draftId); - const patch = draft ? mergeGeneratedPublishingCopy(draft, metadata) : null; + const currentDraft = useAIStore.getState().clipDrafts.find((item) => item.id === sourceJob.draftId); + const currentWords = useEditorStore.getState().words; + if ( + !currentDraft || + !sourceJob.inputFingerprint || + getClipMetadataInputFingerprint(currentDraft, currentWords) !== sourceJob.inputFingerprint + ) return; + const patch = mergeGeneratedPublishingCopy(currentDraft, metadata); if (!patch) throw new Error('Publishing copy generation returned no usable copy.'); - updateClipDraft(activeAIJob.draftId, patch); + updateClipDraft(currentDraft.id, patch); } } catch (err) { + if (!isCurrentAIJobRun(run)) return; console.error(err); setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) }); } finally { - if (isCurrentClipWorkspace()) setProcessing(false); + if (isCurrentAIJobRun(run)) { + finishAIJobRun(run); + setProcessing(false); + } } }, [ activeAIJob, applyClipDiscoveryResult, backendUrl, - clipDrafts, - isCurrentClipWorkspace, + beginAIJobRun, + finishAIJobRun, + isCurrentAIJobRun, pollAIJob, setEditPlanResult, setFillerResult, @@ -1156,7 +1331,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
{creatorNotice && } {activeAIJob && ( - + )} {activeTab === 'edit' && ( @@ -1521,6 +1696,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { onCopyHookFrame={(frame) => copyHookFrameBrief(draft, frame)} onDuplicate={() => duplicateClipDraft(draft)} onRemove={() => removeClipDraft(draft.id)} + aiBusy={isProcessing} isGeneratingPublishingCopy={publishingCopyDraftId === draft.id} /> ))} @@ -1634,15 +1810,17 @@ function getFillerReasonBucket(word: string, reason: string) { function AIJobStatusCard({ job, + busy, onCancel, onRetry, }: { - job: AIJob & AIJobContext; + job: ActiveAIJob; + busy: boolean; onCancel: () => void; onRetry: () => void; }) { const canCancel = job.status === 'queued' || job.status === 'running'; - const canRetry = job.status === 'failed' || job.status === 'canceled'; + const canRetry = !busy && (job.status === 'failed' || job.status === 'canceled'); const latestLogs = (job.logs || []) .filter((log) => !log.message.includes('Traceback (most recent call last):')) .slice(-4); diff --git a/frontend/src/features/clips/ClipDraftCard.tsx b/frontend/src/features/clips/ClipDraftCard.tsx index 5e52fa4..35707fd 100644 --- a/frontend/src/features/clips/ClipDraftCard.tsx +++ b/frontend/src/features/clips/ClipDraftCard.tsx @@ -14,6 +14,7 @@ export default function ClipDraftCard({ draft, isExporting, exportBusy, + aiBusy, isGeneratingPublishingCopy, exportJob, exportResult, @@ -44,6 +45,7 @@ export default function ClipDraftCard({ draft: ClipDraft; isExporting: boolean; exportBusy: boolean; + aiBusy: boolean; isGeneratingPublishingCopy: boolean; exportJob?: ExportJob; exportResult?: ClipExportOutput; @@ -153,7 +155,7 @@ export default function ClipDraftCard({