Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
116 changes: 116 additions & 0 deletions frontend/scripts/smoke-transcription-lifecycle.mjs
Original file line number Diff line number Diff line change
@@ -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<typeof parseProjectFile>\) => \{\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 =/);
7 changes: 3 additions & 4 deletions frontend/scripts/smoke-transcription-ux.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'\)/);

Expand Down
109 changes: 90 additions & 19 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -105,6 +110,8 @@ export default function App() {
const [isBrowserUploading, setIsBrowserUploading] = useState(false);
const [browserWorkflowIntent, setBrowserWorkflowIntent] = useState<WorkflowIntent>('full-video');
const transcriptionIntentRef = useRef<WorkflowIntent | null>(null);
const transcriptionRunEpochRef = useRef(0);
const transcriptionRunRef = useRef<TranscriptionRunContext<WorkflowIntent | null> | null>(null);
const [lastTranscriptionPath, setLastTranscriptionPath] = useState('');
const [manualSaveStatus, setManualSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [recoveryCandidate, setRecoveryCandidate] = useState<AutosaveCandidate | null>(null);
Expand Down Expand Up @@ -216,7 +223,41 @@ export default function App() {
refreshRecentProjects();
};

const isCurrentTranscriptionRun = (run: TranscriptionRunContext<WorkflowIntent | null>) =>
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<typeof parseProjectFile>) => {
invalidateTranscriptionRun();
loadProjectState(data);
setWorkspaceRevision((current) => current + 1);
const workflow = getProjectWorkflow(data.aiWorkspace);
Expand All @@ -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;
Expand Down Expand Up @@ -452,16 +494,18 @@ export default function App() {

const completeTranscription = (
data: Parameters<typeof setTranscription>[0],
intent?: WorkflowIntent | null,
run: TranscriptionRunContext<WorkflowIntent | null>,
) => {
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('');
Expand All @@ -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 {
Expand All @@ -484,64 +529,90 @@ 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);
}
};

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<WorkflowIntent | null>,
): Promise<Parameters<typeof setTranscription>[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<Parameters<typeof setTranscription>[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);
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/utils/transcriptionLifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export interface TranscriptionRunContext<Intent = string | null> {
epoch: number;
mediaPath: string;
intent: Intent;
}

export function createTranscriptionRunContext<Intent>(
epoch: number,
mediaPath: string,
intent: Intent,
): TranscriptionRunContext<Intent> {
return { epoch, mediaPath, intent };
}

export function isCurrentTranscriptionRun<Intent>(
context: TranscriptionRunContext<Intent>,
currentEpoch: number,
currentMediaPath: string | null,
): boolean {
return context.epoch === currentEpoch && context.mediaPath === currentMediaPath;
}