diff --git a/frontend/scripts/smoke-transcription-lifecycle.mjs b/frontend/scripts/smoke-transcription-lifecycle.mjs index 0cab5d2..b5f297f 100644 --- a/frontend/scripts/smoke-transcription-lifecycle.mjs +++ b/frontend/scripts/smoke-transcription-lifecycle.mjs @@ -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/); +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;/); diff --git a/frontend/scripts/smoke-transcription-ux.mjs b/frontend/scripts/smoke-transcription-ux.mjs index 92d2bdc..ad01ceb 100644 --- a/frontend/scripts/smoke-transcription-ux.mjs +++ b/frontend/scripts/smoke-transcription-ux.mjs @@ -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'); @@ -18,16 +19,18 @@ assert.match(appSource, /useState\('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/); +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'\)/); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8261ed6..4e48f49 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, @@ -57,11 +58,6 @@ 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; @@ -69,15 +65,6 @@ const ONBOARDING_DISMISSED_KEY = 'scriptcut.onboarding.dismissed.v1'; type Panel = EditorPanel; -interface BackendJob { - 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, @@ -88,8 +75,6 @@ export default function App() { transcriptionProgress, loadVideo, setBackendUrl, - setTranscription, - setTranscribing, setExportOptions, setPreviewAspectRatio, backendUrl, @@ -102,18 +87,10 @@ export default function App() { const [transcriptionEngine, setTranscriptionEngine] = useState('auto'); const [transcriptionModel, setTranscriptionModel] = useState(AUTOMATIC_TRANSCRIPTION_MODEL); const [transcriptionEngineStatus, setTranscriptionEngineStatus] = useState(null); - const [transcriptionMessage, setTranscriptionMessage] = useState(''); - const [transcriptionError, setTranscriptionError] = useState(''); - const [transcriptionLogs, setTranscriptionLogs] = useState>([]); - const [lastTranscriptionJobId, setLastTranscriptionJobId] = useState(''); const [browserUploadName, setBrowserUploadName] = useState(''); const [browserUploadError, setBrowserUploadError] = useState(''); 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); const [recoveryError, setRecoveryError] = useState(''); @@ -133,6 +110,27 @@ export default function App() { const fileInputRef = useRef(null); const moreToolsButtonRef = useRef(null); + const handleTranscriptionCompleted = useCallback((intent: WorkflowIntent | null) => { + setActivePanel(getPostTranscriptionPanel(intent)); + }, []); + const { + cancelTranscription, + clearTranscriptionError, + invalidateTranscriptionRun, + lastTranscriptionJobId, + retryTranscription, + startTranscriptionWithSettings, + transcribeVideo, + transcriptionError, + transcriptionLogs, + transcriptionMessage, + } = useTranscriptionController({ + backendUrl, + transcriptionEngine, + transcriptionModel, + onCompleted: handleTranscriptionCompleted, + }); + useKeyboardShortcuts(); const autosave = useProjectAutosave(); @@ -224,41 +222,9 @@ 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 = async (data: ReturnType) => { invalidateTranscriptionRun(); + setActivePanel(null); const videoUrl = await resolveBackendFileUrl(backendUrl, data.videoPath); loadProjectState(data, videoUrl); setWorkspaceRevision((current) => current + 1); @@ -269,6 +235,7 @@ export default function App() { const resetMediaAIWorkspaceForNewMedia = useCallback(() => { invalidateTranscriptionRun(); + setActivePanel(null); useAIStore.getState().resetMediaAIWorkspace(); const editorState = useEditorStore.getState(); editorState.clearClipPresentationPreview(); @@ -435,7 +402,7 @@ export default function App() { const uploadBrowserFile = async (file: File, intent: WorkflowIntent) => { setBrowserUploadName(file.name); setBrowserUploadError(''); - setTranscriptionError(''); + clearTranscriptionError(); setIsBrowserUploading(true); try { @@ -501,141 +468,6 @@ export default function App() { return false; }; - const completeTranscription = ( - data: Parameters[0], - run: TranscriptionRunContext, - ) => { - 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, - ): 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); - - 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)); diff --git a/frontend/src/features/transcription/useTranscriptionController.ts b/frontend/src/features/transcription/useTranscriptionController.ts new file mode 100644 index 0000000..33243ef --- /dev/null +++ b/frontend/src/features/transcription/useTranscriptionController.ts @@ -0,0 +1,256 @@ +import { useCallback, useRef, useState } from 'react'; +import { useEditorStore } from '../../store/editorStore'; +import type { TranscriptionResult } from '../../types/project'; +import { + createTranscriptionRunContext, + isCurrentTranscriptionRun as isCurrentTranscriptionRunContext, + type TranscriptionRunContext, +} from '../../utils/transcriptionLifecycle'; +import type { TranscriptionEngine } from '../../utils/transcriptionModels'; + +type TranscriptionLog = { + time: string; + message: string; +}; + +type BackendJob = { + status: 'queued' | 'running' | 'canceling' | 'succeeded' | 'failed' | 'canceled'; + progress: number; + message: string; + logs?: TranscriptionLog[]; + result?: T; + error?: string; +}; + +type UseTranscriptionControllerOptions = { + backendUrl: string; + transcriptionEngine: TranscriptionEngine; + transcriptionModel: string; + onCompleted: (intent: Intent | null) => void; +}; + +export function useTranscriptionController({ + backendUrl, + transcriptionEngine, + transcriptionModel, + onCompleted, +}: UseTranscriptionControllerOptions) { + const setTranscription = useEditorStore((state) => state.setTranscription); + const setTranscribing = useEditorStore((state) => state.setTranscribing); + + const [transcriptionMessage, setTranscriptionMessage] = useState(''); + const [transcriptionError, setTranscriptionError] = useState(''); + const [transcriptionLogs, setTranscriptionLogs] = useState([]); + const [lastTranscriptionJobId, setLastTranscriptionJobId] = useState(''); + const [lastTranscriptionPath, setLastTranscriptionPath] = useState(''); + const transcriptionIntentRef = useRef(null); + const transcriptionRunEpochRef = useRef(0); + const transcriptionRunRef = useRef | null>(null); + + const isCurrentTranscriptionRun = useCallback( + (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); + }, [setTranscribing]); + + const clearTranscriptionError = useCallback(() => { + setTranscriptionError(''); + }, []); + + const beginTranscriptionRun = useCallback((path: string, intent?: Intent) => { + 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 completeTranscription = useCallback(( + data: TranscriptionResult, + run: TranscriptionRunContext, + ) => { + if (!isCurrentTranscriptionRun(run)) return; + setTranscription(data); + if (!isCurrentTranscriptionRun(run)) return; + onCompleted(run.intent); + }, [isCurrentTranscriptionRun, onCompleted, setTranscription]); + + const pollTranscriptionJob = useCallback(async ( + jobId: string, + run: TranscriptionRunContext, + ): Promise => { + 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; + 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}`); + } + } + }, [backendUrl, isCurrentTranscriptionRun, setTranscribing]); + + const transcribeVideo = useCallback(async (path: string, intent?: Intent) => { + 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); + } + } + }, [ + backendUrl, + beginTranscriptionRun, + completeTranscription, + isCurrentTranscriptionRun, + pollTranscriptionJob, + setTranscribing, + transcriptionEngine, + transcriptionModel, + ]); + + const cancelTranscription = useCallback(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); + } + }, [backendUrl, isCurrentTranscriptionRun, lastTranscriptionJobId, setTranscribing]); + + const retryTranscription = useCallback(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); + } + } + }, [ + backendUrl, + beginTranscriptionRun, + completeTranscription, + isCurrentTranscriptionRun, + lastTranscriptionJobId, + pollTranscriptionJob, + setTranscribing, + ]); + + const startTranscriptionWithSettings = useCallback(async () => { + if (!lastTranscriptionPath || useEditorStore.getState().videoPath !== lastTranscriptionPath) return; + await transcribeVideo(lastTranscriptionPath, transcriptionIntentRef.current ?? undefined); + }, [lastTranscriptionPath, transcribeVideo]); + + return { + cancelTranscription, + clearTranscriptionError, + invalidateTranscriptionRun, + lastTranscriptionJobId, + retryTranscription, + startTranscriptionWithSettings, + transcribeVideo, + transcriptionError, + transcriptionLogs, + transcriptionMessage, + }; +}