feat(editor): transcribe every media automatically and gate the AI actions on it - #224
Merged
EtienneLescot merged 5 commits intoAug 1, 2026
Conversation
…tions on it Clicking "Smart cuts with AI" was the natural first move in the editor and the one that could not work: nothing produced a transcript until the user found the Media tab or the transcript pane and asked for one. Recognition is local and free, so the editor now produces transcripts by itself, and every affordance that needs one says what it is waiting for instead of doing nothing. - `store/transcriptionStore.ts` owns the queue: one asset at a time (whisper-server is a single process and audio extraction holds decoded frames in renderer memory), auto-enqueued from `useAutoTranscription()` on any document change. The transcript itself still lives on the document — the store only owns the job. - The auto pass cannot loop: an asset is enqueued only when it has no transcript, no job entry (queued / running / failed alike) and no persisted failure, and a job is dropped only after the save carrying its transcript resolved. Runs are stamped with a run id so a manual regenerate can supersede one mid-flight without the outgoing run clearing its successor. - `transcription/status.ts` holds the vocabulary: `deriveAssetStatus` folds job + document into one status (a stored transcript outranks a failed retry — the previous transcript is still usable), `resolveTranscriptGate` folds a set of those into ready / pending / blocked, resolved over the assets the TIMELINE plays rather than over `primaryAssetId` (which in a recording project is the screen capture, routinely the silent one). - Silent media are remembered: a container with no audio track fails the same way every time, so the verdict lands on `asset.transcriptionFailure` (additive schema field, no version bump) and the auto pass stops re-extracting its audio on every project open. Transient failures stay in memory and retry next load. - No local STT engine (browser preview, e2e shim) means no background pass; a manual request still runs. - Removes `projectStore.setTranscript` (it duplicated `withTranscript`) and the `assetStatuses` / `onRegenerateAsset` / `onTranscribe` / `isTranscribing` prop chains — the panes read the store. 25 tests cover the pure logic, the queue (loop guard, supersede, project switch, no engine, silent-verdict persistence) and the rendered gating.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…download The 253 MB model download happens inside the `stt:transcribe` IPC — i.e. inside a run the store has already marked `running` — so it correctly reads as one longer "Transcribing…" phase, with no separate step and nothing falsely clickable. The edges of that first run did not hold up, though: - `SttManager.init` cached a REJECTED `prepare()`, so one dropped connection during the download failed every later transcription in the session: the remaining assets in the queue flipped red in the same frame, and the retry the editor offers was a dead control until the app was restarted — reconnecting changed nothing. The slot is cleared on failure now. - A transient failure now stops the queue: the still-queued assets inherit the verdict instead of each spending a full retry budget and stacking an identical toast. It is the engine that failed, not their media. - The transcript pane's read-only state is scoped PER ASSET again. Widening it to the timeline-wide gate made every other clip's word stream swallow Backspace and hover-bin clicks for the whole background pass, with nothing on screen to say why — the exact "looks live, ignores you" failure mode. A block being rewritten now shows a spinner + "Transcribing…" and dims its stream, and the per-clip empty line no longer tells the user to regenerate an asset that is mid-run. - `mixToMono` hoists its channel arrays out of the sample loop. One WebIDL call per sample per channel (~57 M for a ten-minute stereo recording) froze the window — spinners included — for seconds, which was survivable while the pass was user-triggered and is not now that it is automatic. Found by an adversarial review of the first-run sequence; 4 new tests cover the init retry, the queue stop and the per-asset read-only scoping.
`tsc -p tsconfig.test.json` (its own CI job) rejected the two held-open-run tests: assigning the resolver inside a Promise executor leaves the outer `let releaseFirst: (() => void) | null` narrowed to `null`, so `releaseFirst?.()` is a call on `never`. A small `deferred()` helper hands back a stable 0-arg release instead. Also fixes the instruction that let this reach CI: AGENTS.md said `npx tsc --noEmit` was what CI runs, and that config does not see test files at all.
…warning `.claude/` is gitignored (while `launch.json` is tracked), so its local desktop-dev tweak slipped in through `git add -u` — reverted here, it stays a local change. Also puts a comment in `deferred()`'s placeholder so it stops tripping noEmptyBlockStatements.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Clicking "Smart cuts with AI" was the natural first move in the editor, and the one that could not work: nothing produced a transcript until the user went looking for the Media tab or the transcript pane and ordered one. Transcription is local and free, so the editor now produces transcripts by itself, and every affordance that needs one says what it is waiting for instead of doing nothing.
Automatic, SSOT, no loop —
store/transcriptionStore.tsowns the queue, mounted once viauseAutoTranscription()in the shell and driven by any document change (Media-tab import, or the asset auto-added after a screen recording). One run at a time — whisper-server is a single process and audio extraction holds decoded frames in renderer memory. The transcript itself still lives on the document (document.transcripts[]); the store only owns the job. The auto pass cannot re-trigger on its own result: an asset is enqueued only when it has no transcript, no job entry (queued / running / failed alike) and no persisted failure, and a job is dropped only after the save carrying its transcript has resolved. Runs are stamped with a run id, so a manual regenerate can supersede one mid-flight without the outgoing run clearing its successor.Gating — the Smart-cuts row is disabled unless a usable transcript exists, with a spinner and a subtitle that replaces "With AI" by the actual reason (transcribing / no audio / no speech / failed). Same treatment for the transcript pane, the captions pane, the media cards and the source-transcript modal.
Several media — everything is per asset. The gate is resolved over the assets the timeline plays (falling back to the media bin while it is empty), and pending beats ready: no point letting the agent plan cuts over half the timeline while the other half is still being transcribed.
Errors —
classifyTranscriptionErrorseparates the deterministic verdicts (no audio track, unreadable audio codec) from transient ones. Deterministic ones are written toasset.transcriptionFailure(additive schema field, no version bump), so a silent screen recording is not re-extracted on every project open and the button is correctly greyed with the right message straight from load. Transient failures stay in memory and are retried on the next load. Silence produces no error toast — it is an expected outcome, and the UI already says so.Cleanup along the way — removes
projectStore.setTranscript(it duplicatedwithTranscript) and theassetStatuses/onRegenerateAsset/onTranscribe/isTranscribingprop chains: the panes read the store.Related issue
Type of change
Release impact
Desktop impact
Screenshots / video
None: the states were verified through the DOM rather than visually (the review environment could not composite frames for a screenshot). Evidence from the running editor, with a silent screen capture as the primary asset plus a talking clip that has a transcript:
1 screen-recording.webm … No transcript for this clip — open the asset card and regenerate./2 talking-head.mp4 … Bonjour à tous voici la démo [silence 8.5s]{ state: "ready", reason: null, pendingCount: 0 }→ Smart cuts clickable{ state: "blocked", reason: "no-audio" }, transcript paneThis media has no audio track+ Transcribe now disabled, captions paneThis media has no audio track — there is nothing to transcribe.+ Transcribe video disabledTesting
npx tsc --noEmit,npm run lint,npm run i18n:check(10 new keys × 13 locales),node scripts/check-docs.mjs— all cleannpm run test— 1446 passed / 120 files, of which 29 new:transcription/status.test.ts— error classification, per-asset status precedence (including a stored transcript outranks a failed retry), gate resolution, timeline-relevant assetsstore/transcriptionStore.test.ts— sequential drain, loop guard across repeatedsync, silent-verdict persistence + no auto-retry, transient failure kept in memory, manual supersede of an in-flight run, project switch, asset removal, no-engine,requestTimelineTranscriptstargetingTranscriptPane.gating.test.tsx,CaptionsPane.gating.test.tsx,TranscriptPane.keyboardCut.test.tsx— the rendered enabled/disabled/message states, and the per-asset read-only scoping (cutting still works while another asset transcribes; it stops, visibly, while this one does)electron/stt/index.test.ts— a failed setup is retried instead of cachedtsc && vite buildpasses (renderer + main + preload bundles)primaryAssetIdinstead of the timeline's assets, and a failed job masking an existing usable transcript.window.electronAPI.stt(by design — no engine, no background pass), so the automatic run, the queue and the failure bookkeeping are covered by unit tests rather than by a live recognition pass.First run on a fresh install
On a fresh install the GGML model (≈253 MB, HuggingFace) is not on disk. It is fetched by
SttManager.prepare()inside thestt:transcribeIPC call — i.e. inside a run this store has already markedrunning— so it reads to the user as one single busy phase that simply takes longer the first time: no separate "downloading" step, no progress bar to stare at, nothing falsely clickable in the meantime. That is the intended behaviour, andphase: "model"is deliberately not forwarded to the renderer. Nothing imposes a timeout a slow download could trip (bareipcRenderer.invoke, no per-request deadline infetchWithRetry, whisper-server's 30 s readiness budget only starts after the download resolves), and the model is fetched once for a whole queue.An adversarial review of that first-run sequence found the promise held on the happy path but broke at the edges — fixed in the second commit:
SttManager.initcached a rejectedprepare(). One dropped connection during the download failed every later transcription for the rest of the session: the remaining queued assets flipped red in the same frame, and the retry the editor offers was a dead control until the app was restarted — reconnecting the network changed nothing. The slot is now cleared on failure.busymade every other clip's word stream swallow Backspace and hover-bin clicks for the whole background pass, with nothing on screen to say why. A block being rewritten now shows a spinner + "Transcribing…", dims its stream and setsaria-busy; the per-clip empty line no longer tells the user to regenerate an asset that is mid-run.mixToMonono longer freezes the window. It calledgetChannelDatainside the sample loop — one WebIDL call per sample per channel, ~57 M for a ten-minute stereo recording, seconds of blocked main thread with the spinners stopped mid-animation. Survivable while the pass was user-triggered; not now that it is automatic.Two known cosmetics left, deliberately: the Smart-cuts subtitle shows "Needs a transcript" for the single frame before
useAutoTranscription's effect runs (disabled either way, only the reason differs), and an offline first run surfaces the raw network message in the failure toast. The mixdown is also still synchronous — a worker is the next step if it shows up in practice.🤖 Generated with Claude Code