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
33 changes: 19 additions & 14 deletions frontend/scripts/smoke-transcription-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,42 +9,47 @@ 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 controllerSource = readSource('../src/features/transcription/useTranscriptionController.ts');
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(appSource, /useTranscriptionController<WorkflowIntent>/);
assert.doesNotMatch(appSource, /transcriptionRunEpochRef|transcriptionRunRef|pollTranscriptionJob|\/jobs\/transcribe/);
assert.doesNotMatch(controllerSource, /useAIStore|getPostTranscriptionPanel|setActivePanel|ExportDialog|AIPanel/);
assert.match(controllerSource, /transcriptionRunEpochRef/);
assert.match(controllerSource, /transcriptionRunRef/);
assert.match(controllerSource, /useEditorStore\.getState\(\)\.videoPath/);
assert.match(controllerSource, /isCurrentTranscriptionRunContext/);
assert.match(controllerSource, /const run = beginTranscriptionRun\(path, intent\)/);
assert.match(controllerSource, /const run = beginTranscriptionRun\(previousRun\.mediaPath, previousRun\.intent/);
assert.match(controllerSource, /const pollTranscriptionJob = useCallback\(async \(\s*jobId: string,\s*run:/);

const completeBody = controllerSource.match(/const completeTranscription = useCallback\([\s\S]*?\n\s*const pollTranscriptionJob/)?.[0] || '';
assert.match(completeBody, /if \(!isCurrentTranscriptionRun\(run\)\) return;/);
assert.match(completeBody, /setTranscription\(data\)/);
assert.match(completeBody, /setActivePanel\(getPostTranscriptionPanel\(run\.intent\)\)/);
assert.match(completeBody, /onCompleted\(run\.intent\)/);
assert.match(appSource, /setActivePanel\(getPostTranscriptionPanel\(intent\)\)/);

const pollBody = appSource.match(/const pollTranscriptionJob = async \([\s\S]*?\n\s*\};\n\n\s*const togglePanel/)?.[0] || '';
const pollBody = controllerSource.match(/const pollTranscriptionJob = useCallback\([\s\S]*?\n\s*const transcribeVideo/)?.[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] || '';
const transcribeBody = controllerSource.match(/const transcribeVideo = useCallback\([\s\S]*?\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] || '';
const retryBody = controllerSource.match(/const retryTranscription = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[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] || '';
const cancelBody = controllerSource.match(/const cancelTranscription = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[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;/);
Expand Down
21 changes: 12 additions & 9 deletions frontend/scripts/smoke-transcription-ux.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));

const readSource = (relativePath) => readFileSync(resolve(__dirname, relativePath), 'utf8');
const appSource = readSource('../src/App.tsx');
const controllerSource = readSource('../src/features/transcription/useTranscriptionController.ts');
const homeSource = readSource('../src/components/HomeScreen.tsx');
const optionsSource = readSource('../src/components/TranscriptionOptions.tsx');
const statusSource = readSource('../src/components/TranscriptionStatus.tsx');
Expand All @@ -18,16 +19,18 @@ assert.match(appSource, /useState<TranscriptionEngine>\('auto'\)/);
assert.match(appSource, /useState\(AUTOMATIC_TRANSCRIPTION_MODEL\)/);
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\/\$\{previousJobId\}\/retry/);
assert.match(appSource, /\/jobs\/\$\{jobId\}\/cancel/);
assert.match(appSource, /setTimeout\(resolve, 700\)/);
assert.match(appSource, /transcriptionIntentRef/);
assert.match(appSource, /useTranscriptionController<WorkflowIntent>/);
assert.doesNotMatch(appSource, /\/jobs\/transcribe|transcriptionIntentRef|pollTranscriptionJob/);
assert.match(controllerSource, /engine: transcriptionEngine, model: transcriptionModel/);
assert.match(controllerSource, /\/jobs\/transcribe/);
assert.match(controllerSource, /\/jobs\/\$\{previousJobId\}\/retry/);
assert.match(controllerSource, /\/jobs\/\$\{jobId\}\/cancel/);
assert.match(controllerSource, /setTimeout\(resolve, 700\)/);
assert.match(controllerSource, /transcriptionIntentRef/);
assert.match(appSource, /startTranscriptionWithSettings/);
assert.match(appSource, /transcribeVideo\(lastTranscriptionPath, transcriptionIntentRef\.current/);
assert.match(appSource, /const completeTranscription =/);
assert.match(appSource, /completeTranscription\(data, run\)/);
assert.match(controllerSource, /transcribeVideo\(lastTranscriptionPath, transcriptionIntentRef\.current/);
assert.match(controllerSource, /const completeTranscription =/);
assert.match(controllerSource, /completeTranscription\(data, run\)/);
assert.match(appSource, /getPostTranscriptionPanel/);
assert.doesNotMatch(appSource, /setActivePanel\(resolvedIntent === 'short' \? 'ai' : 'export'\)/);

Expand Down
218 changes: 25 additions & 193 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type TranscriptionEngine,
type TranscriptionEngineStatus,
} from './utils/transcriptionModels';
import { useTranscriptionController } from './features/transcription/useTranscriptionController';
import { saveProject, useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import {
getAutosaveCandidatePaths,
Expand Down Expand Up @@ -57,27 +58,13 @@ import {
type EditorWorkflow,
} from './utils/editorTask';
import { getCreatorErrorPresentation } from './utils/creatorErrors';
import {
createTranscriptionRunContext,
isCurrentTranscriptionRun as isCurrentTranscriptionRunContext,
type TranscriptionRunContext,
} from './utils/transcriptionLifecycle';
import { resolveBackendFileUrl } from './utils/backendFile';

const IS_ELECTRON = !!window.electronAPI;
const ONBOARDING_DISMISSED_KEY = 'scriptcut.onboarding.dismissed.v1';

type Panel = EditorPanel;

interface BackendJob<T> {
status: 'queued' | 'running' | 'canceling' | 'succeeded' | 'failed' | 'canceled';
progress: number;
message: string;
logs?: Array<{ time: string; message: string }>;
result?: T;
error?: string;
}

export default function App() {
const {
videoPath,
Expand All @@ -88,8 +75,6 @@ export default function App() {
transcriptionProgress,
loadVideo,
setBackendUrl,
setTranscription,
setTranscribing,
setExportOptions,
setPreviewAspectRatio,
backendUrl,
Expand All @@ -102,18 +87,10 @@ export default function App() {
const [transcriptionEngine, setTranscriptionEngine] = useState<TranscriptionEngine>('auto');
const [transcriptionModel, setTranscriptionModel] = useState(AUTOMATIC_TRANSCRIPTION_MODEL);
const [transcriptionEngineStatus, setTranscriptionEngineStatus] = useState<TranscriptionEngineStatus | null>(null);
const [transcriptionMessage, setTranscriptionMessage] = useState('');
const [transcriptionError, setTranscriptionError] = useState('');
const [transcriptionLogs, setTranscriptionLogs] = useState<Array<{ time: string; message: string }>>([]);
const [lastTranscriptionJobId, setLastTranscriptionJobId] = useState('');
const [browserUploadName, setBrowserUploadName] = useState('');
const [browserUploadError, setBrowserUploadError] = useState('');
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);
const [recoveryError, setRecoveryError] = useState('');
Expand All @@ -133,6 +110,27 @@ export default function App() {
const fileInputRef = useRef<HTMLInputElement>(null);
const moreToolsButtonRef = useRef<HTMLButtonElement>(null);

const handleTranscriptionCompleted = useCallback((intent: WorkflowIntent | null) => {
setActivePanel(getPostTranscriptionPanel(intent));
}, []);
const {
cancelTranscription,
clearTranscriptionError,
invalidateTranscriptionRun,
lastTranscriptionJobId,
retryTranscription,
startTranscriptionWithSettings,
transcribeVideo,
transcriptionError,
transcriptionLogs,
transcriptionMessage,
} = useTranscriptionController<WorkflowIntent>({
backendUrl,
transcriptionEngine,
transcriptionModel,
onCompleted: handleTranscriptionCompleted,
});

useKeyboardShortcuts();
const autosave = useProjectAutosave();

Expand Down Expand Up @@ -224,41 +222,9 @@ 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 = async (data: ReturnType<typeof parseProjectFile>) => {
invalidateTranscriptionRun();
setActivePanel(null);
const videoUrl = await resolveBackendFileUrl(backendUrl, data.videoPath);
loadProjectState(data, videoUrl);
setWorkspaceRevision((current) => current + 1);
Expand All @@ -269,6 +235,7 @@ export default function App() {

const resetMediaAIWorkspaceForNewMedia = useCallback(() => {
invalidateTranscriptionRun();
setActivePanel(null);
useAIStore.getState().resetMediaAIWorkspace();
const editorState = useEditorStore.getState();
editorState.clearClipPresentationPreview();
Expand Down Expand Up @@ -435,7 +402,7 @@ export default function App() {
const uploadBrowserFile = async (file: File, intent: WorkflowIntent) => {
setBrowserUploadName(file.name);
setBrowserUploadError('');
setTranscriptionError('');
clearTranscriptionError();
setIsBrowserUploading(true);

try {
Expand Down Expand Up @@ -501,141 +468,6 @@ export default function App() {
return false;
};

const completeTranscription = (
data: Parameters<typeof setTranscription>[0],
run: TranscriptionRunContext<WorkflowIntent | null>,
) => {
if (!isCurrentTranscriptionRun(run)) return;
setTranscription(data);
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);
setTranscribing(true, 0);
setTranscriptionMessage('Preparing your transcript');
setTranscriptionError('');
setTranscriptionLogs([]);
setLastTranscriptionJobId('');
try {
const res = await fetch(`${backendUrl}/jobs/transcribe`, {
method: 'POST',
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 {
const errorData = await res.json();
detail = errorData.detail || JSON.stringify(errorData);
} catch {
// Keep the HTTP status text when the backend response is not JSON.
}
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, 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 {
if (isCurrentTranscriptionRun(run)) {
setTranscriptionMessage('');
setTranscribing(false);
}
}
};

const cancelTranscription = async () => {
const run = transcriptionRunRef.current;
const jobId = lastTranscriptionJobId;
if (!run || !jobId || !isCurrentTranscriptionRun(run)) return;
try {
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 () => {
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/${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, 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 {
if (isCurrentTranscriptionRun(run)) {
setTranscriptionMessage('');
setTranscribing(false);
}
}
};

const startTranscriptionWithSettings = async () => {
if (!lastTranscriptionPath || useEditorStore.getState().videoPath !== lastTranscriptionPath) return;
await transcribeVideo(lastTranscriptionPath, transcriptionIntentRef.current ?? undefined);
};

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);

if (job.status === 'succeeded') {
if (!job.result) throw new Error('Transcription job finished without a result');
return job.result;
}
if (job.status === 'failed' || job.status === 'canceled') {
throw new Error(job.error || job.message || `Transcription ${job.status}`);
}
}
};

const togglePanel = (panel: Panel) =>
setActivePanel((prev) => (prev === panel ? null : panel));

Expand Down
Loading