diff --git a/frontend/package.json b/frontend/package.json index bbf4c45..c6efc8a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "test:clip-presentation": "node scripts/smoke-clip-presentation.mjs", "test:home-onboarding": "node scripts/smoke-home-onboarding.mjs", "test:transcription-ux": "node scripts/smoke-transcription-ux.mjs", + "test:transcription-lifecycle": "node scripts/smoke-transcription-lifecycle.mjs", "test:editor-frame": "node scripts/smoke-editor-frame.mjs", "test:create-clips-workspace": "node scripts/smoke-create-clips-workspace.mjs", "test:clips-first-lifecycle": "node scripts/smoke-clips-first-lifecycle.mjs", diff --git a/frontend/scripts/smoke-transcription-lifecycle.mjs b/frontend/scripts/smoke-transcription-lifecycle.mjs new file mode 100644 index 0000000..d312dc0 --- /dev/null +++ b/frontend/scripts/smoke-transcription-lifecycle.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const readSource = (relativePath) => readFileSync(resolve(__dirname, relativePath), 'utf8'); +const appSource = readSource('../src/App.tsx'); +const packageJson = JSON.parse(readSource('../package.json')); + +assert.equal(packageJson.scripts['test:transcription-lifecycle'], 'node scripts/smoke-transcription-lifecycle.mjs'); +assert.match(appSource, /transcriptionRunEpochRef/); +assert.match(appSource, /transcriptionRunRef/); +assert.match(appSource, /useEditorStore\.getState\(\)\.videoPath/); +assert.match(appSource, /isCurrentTranscriptionRunContext/); +assert.match(appSource, /const run = beginTranscriptionRun\(path, intent\)/); +assert.match(appSource, /const run = beginTranscriptionRun\(previousRun\.mediaPath, previousRun\.intent/); +assert.match(appSource, /const pollTranscriptionJob = async \(\s*jobId: string,\s*run:/); + +const completeBody = appSource.match(/const completeTranscription = \([\s\S]*?\n\s*\};\n\n\s*const transcribeVideo/)?.[0] || ''; +assert.match(completeBody, /if \(!isCurrentTranscriptionRun\(run\)\) return;/); +assert.match(completeBody, /setTranscription\(data\)/); +assert.match(completeBody, /setActivePanel\(getPostTranscriptionPanel\(run\.intent\)\)/); + +const pollBody = appSource.match(/const pollTranscriptionJob = async \([\s\S]*?\n\s*\};\n\n\s*const togglePanel/)?.[0] || ''; +assert.match(pollBody, /await new Promise\(\(resolve\) => window\.setTimeout\(resolve, 700\)\);[\s\S]*if \(!isCurrentTranscriptionRun\(run\)\) return null;/); +assert.match(pollBody, /const res = await fetch\([\s\S]*?if \(!isCurrentTranscriptionRun\(run\)\) return null;/); +assert.match(pollBody, /const job = \(await res\.json\(\)\)[\s\S]*if \(!isCurrentTranscriptionRun\(run\)\) return null;[\s\S]*setTranscriptionMessage/); +assert.match(pollBody, /setTranscriptionLogs\(job\.logs \|\| \[\]\)/); +assert.match(pollBody, /setTranscribing\(/); + +const transcribeBody = appSource.match(/const transcribeVideo = async \([\s\S]*?\n\s*\};\n\n\s*const cancelTranscription/)?.[0] || ''; +assert.match(transcribeBody, /setLastTranscriptionJobId\(jobId\)/); +assert.match(transcribeBody, /if \(!isCurrentTranscriptionRun\(run\)\) return;[\s\S]*setLastTranscriptionJobId/); +assert.match(transcribeBody, /catch \(err\) \{\s*if \(!isCurrentTranscriptionRun\(run\)\) return;/); +assert.match(transcribeBody, /finally \{\s*if \(isCurrentTranscriptionRun\(run\)\) \{[\s\S]*setTranscriptionMessage\(''\)[\s\S]*setTranscribing\(false\)/); + +const retryBody = appSource.match(/const retryTranscription = async \(\) => \{([\s\S]*?)\n\s*\};\n\n\s*const startTranscriptionWithSettings/)?.[1] || ''; +assert.match(retryBody, /const previousRun = transcriptionRunRef\.current/); +assert.match(retryBody, /const run = beginTranscriptionRun\(previousRun\.mediaPath, previousRun\.intent/); +assert.match(retryBody, /if \(!isCurrentTranscriptionRun\(run\)\) return;/g); +assert.match(retryBody, /finally \{\s*if \(isCurrentTranscriptionRun\(run\)\)/); + +const cancelBody = appSource.match(/const cancelTranscription = async \(\) => \{([\s\S]*?)\n\s*\};\n\n\s*const retryTranscription/)?.[1] || ''; +assert.match(cancelBody, /const run = transcriptionRunRef\.current/); +assert.match(cancelBody, /await fetch\([\s\S]*?if \(!isCurrentTranscriptionRun\(run\)\) return;/); +assert.match(cancelBody, /catch \(err\) \{\s*if \(!isCurrentTranscriptionRun\(run\)\) return;/); + +const openFileBody = appSource.match(/const handleOpenFile = async \(intent: WorkflowIntent = 'full-video'\) => \{([\s\S]*?)\n\s*\};\n\n\s*const handleBrowserFileChange/)?.[1] || ''; +assert.match(openFileBody, /const path = await window\.electronAPI!\.openFile\(\);/); +assert.match(openFileBody, /if \(path\) \{[\s\S]*resetMediaAIWorkspaceForNewMedia\(\)/); +assert.doesNotMatch(openFileBody, /if \(!path\)[\s\S]*invalidateTranscriptionRun/); + +const uploadBody = appSource.match(/const uploadBrowserFile = async \(file: File, intent: WorkflowIntent\) => \{([\s\S]*?)\n\s*\};\n\n\s*const tryRestoreAutosave/)?.[1] || ''; +assert.match(uploadBody, /if \(!res\.ok\) \{[\s\S]*throw new Error\([\s\S]*?\n\s*\}/); +assert.match(uploadBody, /const data = \(await res\.json\(\)\)[\s\S]*resetMediaAIWorkspaceForNewMedia\(\)/); +assert.ok(uploadBody.indexOf('resetMediaAIWorkspaceForNewMedia()') > uploadBody.indexOf('const data =')); +assert.match(appSource, /const restoreProject = \(data: ReturnType\) => \{\s*invalidateTranscriptionRun\(\);\s*loadProjectState\(data\)/); +assert.match(appSource, /const resetMediaAIWorkspaceForNewMedia = useCallback\(\(\) => \{\s*invalidateTranscriptionRun\(\);/); +assert.doesNotMatch(appSource, /clipWorkspaceEpoch/); + +function loadTsModule(relativePath) { + const source = readSource(relativePath); + const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }); + const module = { exports: {} }; + new Function('exports', 'module', 'require', compiled.outputText)(module.exports, module, require); + return module.exports; +} + +const { createTranscriptionRunContext, isCurrentTranscriptionRun } = loadTsModule( + '../src/utils/transcriptionLifecycle.ts', +); + +const runA = createTranscriptionRunContext(1, '/media/a.mov', 'short'); +const runB = createTranscriptionRunContext(2, '/media/b.mov', 'full-video'); +assert.equal(isCurrentTranscriptionRun(runA, 1, '/media/a.mov'), true); +assert.equal(isCurrentTranscriptionRun(runB, 2, '/media/b.mov'), true); +assert.equal(isCurrentTranscriptionRun(runA, 2, '/media/b.mov'), false); +assert.equal(isCurrentTranscriptionRun(runB, 2, '/media/a.mov'), false); + +const applied = []; +const applyIfCurrent = (run, epoch, mediaPath, mutation) => { + if (!isCurrentTranscriptionRun(run, epoch, mediaPath)) return; + applied.push(mutation); +}; + +// 1-5, 13: stale result/poll UI/finally/error and active-panel writes are all gated. +applyIfCurrent(runA, 2, '/media/b.mov', 'A transcript'); +applyIfCurrent(runA, 2, '/media/b.mov', 'A message'); +applyIfCurrent(runA, 2, '/media/b.mov', 'A logs'); +applyIfCurrent(runA, 2, '/media/b.mov', 'A progress'); +applyIfCurrent(runA, 2, '/media/b.mov', 'A finally idle'); +applyIfCurrent(runA, 2, '/media/b.mov', 'A error'); +applyIfCurrent(runA, 2, '/media/b.mov', 'A Create Clips panel'); +assert.deepEqual(applied, []); +applyIfCurrent(runB, 2, '/media/b.mov', 'B transcript'); +applyIfCurrent(runB, 2, '/media/b.mov', 'B current UI'); +assert.deepEqual(applied, ['B transcript', 'B current UI']); + +// 6, 11, 12: a POST/job retry result is only usable by its fresh current run. +const retryRun = createTranscriptionRunContext(3, '/media/b.mov', 'full-video'); +assert.equal(isCurrentTranscriptionRun(runA, 3, '/media/b.mov'), false); +assert.equal(isCurrentTranscriptionRun(retryRun, 3, '/media/b.mov'), true); + +// 7-10: transition boundaries are source-guarded; the pure invariant covers their outcome. +assert.match(appSource, /if \(path\) \{[\s\S]*resetMediaAIWorkspaceForNewMedia\(\)/); +assert.match(appSource, /if \(!file\) return;\s*await uploadBrowserFile/); +assert.match(appSource, /const restoreProject =/); diff --git a/frontend/scripts/smoke-transcription-ux.mjs b/frontend/scripts/smoke-transcription-ux.mjs index 7821652..92d2bdc 100644 --- a/frontend/scripts/smoke-transcription-ux.mjs +++ b/frontend/scripts/smoke-transcription-ux.mjs @@ -20,15 +20,14 @@ assert.doesNotMatch(appSource, /setTranscriptionEngine\(status\.default_engine\) assert.doesNotMatch(appSource, /setTranscriptionModel\(status\.default_model\)/); assert.match(appSource, /engine: transcriptionEngine, model: transcriptionModel/); assert.match(appSource, /\/jobs\/transcribe/); -assert.match(appSource, /\/jobs\/\$\{lastTranscriptionJobId\}\/retry/); -assert.match(appSource, /\/jobs\/\$\{lastTranscriptionJobId\}\/cancel/); +assert.match(appSource, /\/jobs\/\$\{previousJobId\}\/retry/); +assert.match(appSource, /\/jobs\/\$\{jobId\}\/cancel/); assert.match(appSource, /setTimeout\(resolve, 700\)/); assert.match(appSource, /transcriptionIntentRef/); assert.match(appSource, /startTranscriptionWithSettings/); assert.match(appSource, /transcribeVideo\(lastTranscriptionPath, transcriptionIntentRef\.current/); assert.match(appSource, /const completeTranscription =/); -assert.match(appSource, /completeTranscription\(data, intent\)/); -assert.match(appSource, /completeTranscription\(data, transcriptionIntentRef\.current\)/); +assert.match(appSource, /completeTranscription\(data, run\)/); assert.match(appSource, /getPostTranscriptionPanel/); assert.doesNotMatch(appSource, /setActivePanel\(resolvedIntent === 'short' \? 'ai' : 'export'\)/); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb7d32e..803d1c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,6 +57,11 @@ import { type EditorWorkflow, } from './utils/editorTask'; import { getCreatorErrorPresentation } from './utils/creatorErrors'; +import { + createTranscriptionRunContext, + isCurrentTranscriptionRun as isCurrentTranscriptionRunContext, + type TranscriptionRunContext, +} from './utils/transcriptionLifecycle'; const IS_ELECTRON = !!window.electronAPI; const ONBOARDING_DISMISSED_KEY = 'scriptcut.onboarding.dismissed.v1'; @@ -105,6 +110,8 @@ export default function App() { const [isBrowserUploading, setIsBrowserUploading] = useState(false); const [browserWorkflowIntent, setBrowserWorkflowIntent] = useState('full-video'); const transcriptionIntentRef = useRef(null); + const transcriptionRunEpochRef = useRef(0); + const transcriptionRunRef = useRef | null>(null); const [lastTranscriptionPath, setLastTranscriptionPath] = useState(''); const [manualSaveStatus, setManualSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [recoveryCandidate, setRecoveryCandidate] = useState(null); @@ -216,7 +223,41 @@ export default function App() { refreshRecentProjects(); }; + const isCurrentTranscriptionRun = (run: TranscriptionRunContext) => + isCurrentTranscriptionRunContext( + run, + transcriptionRunEpochRef.current, + useEditorStore.getState().videoPath, + ); + + const invalidateTranscriptionRun = useCallback(() => { + transcriptionRunEpochRef.current += 1; + transcriptionRunRef.current = null; + transcriptionIntentRef.current = null; + setLastTranscriptionPath(''); + setLastTranscriptionJobId(''); + setTranscriptionMessage(''); + setTranscriptionError(''); + setTranscriptionLogs([]); + setTranscribing(false, 0); + setActivePanel(null); + }, [setTranscribing]); + + const beginTranscriptionRun = (path: string, intent?: WorkflowIntent) => { + const resolvedIntent = intent ?? transcriptionIntentRef.current; + const run = createTranscriptionRunContext( + transcriptionRunEpochRef.current + 1, + path, + resolvedIntent, + ); + transcriptionRunEpochRef.current = run.epoch; + transcriptionRunRef.current = run; + transcriptionIntentRef.current = resolvedIntent; + return run; + }; + const restoreProject = (data: ReturnType) => { + invalidateTranscriptionRun(); loadProjectState(data); setWorkspaceRevision((current) => current + 1); const workflow = getProjectWorkflow(data.aiWorkspace); @@ -225,12 +266,13 @@ export default function App() { }; const resetMediaAIWorkspaceForNewMedia = useCallback(() => { + invalidateTranscriptionRun(); useAIStore.getState().resetMediaAIWorkspace(); const editorState = useEditorStore.getState(); editorState.clearClipPresentationPreview(); editorState.setSelectedWordIndices([]); setWorkspaceRevision((current) => current + 1); - }, []); + }, [invalidateTranscriptionRun]); const handleLoadProject = async () => { if (!IS_ELECTRON) return; @@ -452,16 +494,18 @@ export default function App() { const completeTranscription = ( data: Parameters[0], - intent?: WorkflowIntent | null, + run: TranscriptionRunContext, ) => { + if (!isCurrentTranscriptionRun(run)) return; setTranscription(data); - const resolvedIntent = intent ?? transcriptionIntentRef.current; - setActivePanel(getPostTranscriptionPanel(resolvedIntent)); + if (!isCurrentTranscriptionRun(run)) return; + setActivePanel(getPostTranscriptionPanel(run.intent)); }; const transcribeVideo = async (path: string, intent?: WorkflowIntent) => { + if (useEditorStore.getState().videoPath !== path) return; + const run = beginTranscriptionRun(path, intent); setLastTranscriptionPath(path); - if (intent) transcriptionIntentRef.current = intent; setTranscribing(true, 0); setTranscriptionMessage('Preparing your transcript'); setTranscriptionError(''); @@ -473,6 +517,7 @@ export default function App() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_path: path, engine: transcriptionEngine, model: transcriptionModel }), }); + if (!isCurrentTranscriptionRun(run)) return; if (!res.ok) { let detail = res.statusText; try { @@ -484,25 +529,34 @@ export default function App() { throw new Error(`Transcription start failed: ${detail}`); } const { job_id: jobId } = await res.json(); + if (!isCurrentTranscriptionRun(run)) return; setLastTranscriptionJobId(jobId); - const data = await pollTranscriptionJob(jobId); - completeTranscription(data, intent); + const data = await pollTranscriptionJob(jobId, run); + if (!data || !isCurrentTranscriptionRun(run)) return; + completeTranscription(data, run); } catch (err) { + if (!isCurrentTranscriptionRun(run)) return; console.error('Transcription error:', err); const message = err instanceof Error ? err.message : String(err); setTranscriptionError(message.toLowerCase().includes('canceled') ? 'Transcription canceled' : message); } finally { - setTranscriptionMessage(''); - setTranscribing(false); + if (isCurrentTranscriptionRun(run)) { + setTranscriptionMessage(''); + setTranscribing(false); + } } }; const cancelTranscription = async () => { - if (!lastTranscriptionJobId) return; + const run = transcriptionRunRef.current; + const jobId = lastTranscriptionJobId; + if (!run || !jobId || !isCurrentTranscriptionRun(run)) return; try { - await fetch(`${backendUrl}/jobs/${lastTranscriptionJobId}/cancel`, { method: 'POST' }); + await fetch(`${backendUrl}/jobs/${jobId}/cancel`, { method: 'POST' }); + if (!isCurrentTranscriptionRun(run)) return; setTranscriptionMessage('Cancel requested'); } catch (err) { + if (!isCurrentTranscriptionRun(run)) return; console.error('Transcription cancel error:', err); setTranscriptionError(err instanceof Error ? err.message : String(err)); setTranscribing(false); @@ -510,38 +564,55 @@ export default function App() { }; const retryTranscription = async () => { - if (!lastTranscriptionJobId) return; + const previousRun = transcriptionRunRef.current; + const previousJobId = lastTranscriptionJobId; + if (!previousRun || !previousJobId || !isCurrentTranscriptionRun(previousRun)) return; + const run = beginTranscriptionRun(previousRun.mediaPath, previousRun.intent ?? undefined); setTranscriptionError(''); + setTranscriptionLogs([]); setTranscriptionMessage('Retrying transcription'); + setLastTranscriptionJobId(''); setTranscribing(true, 1); try { - const res = await fetch(`${backendUrl}/jobs/${lastTranscriptionJobId}/retry`, { method: 'POST' }); + const res = await fetch(`${backendUrl}/jobs/${previousJobId}/retry`, { method: 'POST' }); + if (!isCurrentTranscriptionRun(run)) return; if (!res.ok) throw new Error(`Retry failed: ${res.statusText}`); const { job_id: jobId } = await res.json(); + if (!isCurrentTranscriptionRun(run)) return; setLastTranscriptionJobId(jobId); - const data = await pollTranscriptionJob(jobId); - completeTranscription(data, transcriptionIntentRef.current); + const data = await pollTranscriptionJob(jobId, run); + if (!data || !isCurrentTranscriptionRun(run)) return; + completeTranscription(data, run); } catch (err) { + if (!isCurrentTranscriptionRun(run)) return; console.error('Transcription retry error:', err); setTranscriptionError(err instanceof Error ? err.message : String(err)); } finally { - setTranscriptionMessage(''); - setTranscribing(false); + if (isCurrentTranscriptionRun(run)) { + setTranscriptionMessage(''); + setTranscribing(false); + } } }; const startTranscriptionWithSettings = async () => { - if (!lastTranscriptionPath) return; + if (!lastTranscriptionPath || useEditorStore.getState().videoPath !== lastTranscriptionPath) return; await transcribeVideo(lastTranscriptionPath, transcriptionIntentRef.current ?? undefined); }; - const pollTranscriptionJob = async (jobId: string) => { + const pollTranscriptionJob = async ( + jobId: string, + run: TranscriptionRunContext, + ): Promise[0] | null> => { for (;;) { await new Promise((resolve) => window.setTimeout(resolve, 700)); + if (!isCurrentTranscriptionRun(run)) return null; const res = await fetch(`${backendUrl}/jobs/${jobId}`); + if (!isCurrentTranscriptionRun(run)) return null; if (!res.ok) throw new Error(`Could not read transcription job: ${res.statusText}`); const job = (await res.json()) as BackendJob[0]>; + if (!isCurrentTranscriptionRun(run)) return null; setTranscriptionMessage(job.message || job.status); setTranscriptionLogs(job.logs || []); setTranscribing(job.status === 'queued' || job.status === 'running' || job.status === 'canceling', job.progress); diff --git a/frontend/src/utils/transcriptionLifecycle.ts b/frontend/src/utils/transcriptionLifecycle.ts new file mode 100644 index 0000000..c8718cc --- /dev/null +++ b/frontend/src/utils/transcriptionLifecycle.ts @@ -0,0 +1,21 @@ +export interface TranscriptionRunContext { + epoch: number; + mediaPath: string; + intent: Intent; +} + +export function createTranscriptionRunContext( + epoch: number, + mediaPath: string, + intent: Intent, +): TranscriptionRunContext { + return { epoch, mediaPath, intent }; +} + +export function isCurrentTranscriptionRun( + context: TranscriptionRunContext, + currentEpoch: number, + currentMediaPath: string | null, +): boolean { + return context.epoch === currentEpoch && context.mediaPath === currentMediaPath; +}