Repository navigation
feat(cli): add hyperframes/api entry with transcribe and removeBackground - #5453
jrusso1020 wants to merge 4 commits into
Conversation
…ound HyperFrames Desktop runs the CLI as a child process, relaunching Electron with ELECTRON_RUN_AS_NODE=1. A programmatic entry lets it import plain functions and run them in a utilityProcess instead, so the RunAsNode fuse can be turned off. packages/cli/src/api.ts exports transcribe() and removeBackground(): typed options in, typed result out, progress through callbacks, failures thrown as TranscribeError / RemoveBackgroundError with a code and the original error as cause. Neither prints, reads argv or exits. The transcribe and remove-background commands now keep only flag handling, printing and exit codes, and call these functions. Their output, JSON and exit codes are unchanged. The package gains an exports map with "./api" (built dist plus d.ts) and "./package.json", generated from package-subpaths.json like the sibling packages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CodeQL flags the trailing-dots regex on the Parakeet error now that it sits behind a library entry. Same result, linear time. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Edit accuracy: accurate 2061 (base branch 2061), smooth 1542 of thoseThe gate passes. Quarantined, measured but not gated (0) |
jerrai-bot-heygen
left a comment
There was a problem hiding this comment.
At 93f8f57b060d5f3cb38831b43df5b1aae5f43d3a, the CLI adapter and remove-background API appear consistent on reviewed paths, and the packed-consumer Build job executed successfully. I cannot approve the new transcribe API yet: its advertised AbortSignal does not stop the synchronous Parakeet-MLX runner, Whisper ignores cancellation through runtime/model/audio setup, and onEngine reports the wrong default Whisper model for a non-English language. The newly created API module also contains function-local imports contrary to our no-local-import rule, without a per-instance exception. Inline comments give the concrete paths and regression targets. Current-head CI is green, but its tests do not cover those behaviors. The author's 29-case CLI byte-parity run and live remove-background image run were not independently reproduced; no real speech or Desktop consumer run was performed by me. — Jerrai
| onEvent: onProgress, | ||
| signal: cancellation!.signal, | ||
| }); | ||
| case "parakeet-mlx": |
There was a problem hiding this comment.
The new API advertises signal as stopping a run with TranscribeError("cancelled"), but the parakeet-mlx arm never reads or forwards it. transcribeWithParakeet uses synchronous execFileSync(..., timeout: 1_800_000) without an AbortSignal, so an API caller aborting after this runner starts can wait up to 30 minutes and then receive a success result. The utility-process IPC event loop is blocked during that synchronous child as well. Please make this runner cancellable for library callers (or explicitly restrict/remove the signal promise until it is), and test abort during an active Parakeet run rather than only the Sherpa path.
There was a problem hiding this comment.
Fixed in 6c9a4b7. transcribeWithParakeet is now async: parakeet-mlx runs through spawn with the caller's signal, and the API passes its signal whatever runner starts, so aborting kills the child and the call rejects with TranscribeError("cancelled"). It rejects only after the child's close, so the process is gone when the caller hears back. Failures keep the synchronous run's shape: same Command failed: ... message plus stderr, status, signal and stderr (compared against the old execFileSync version with a failing stand-in, with and without stderr), and spawnSync <file> ETIMEDOUT on the 30-minute timeout. Regression: api.test.ts "stops a running parakeet-mlx when the signal aborts" runs a stand-in that sleeps 30 s, aborts once its PID file appears, and checks the call ends in under 10 s and the PID is gone. With the old synchronous runner it fails (the event loop is blocked, so the abort never fires before the 30 s child exits).
— Rames
| onEvent: onProgress, | ||
| timeoutMs: options.timeoutMs, | ||
| installRuntime: options.installRuntime, | ||
| startCancellation: () => (cancellation ??= startScope()).signal, |
There was a problem hiding this comment.
For Whisper, startCancellation() is first called inside runWhisper, after ensureWhisper, ensureModel (which can download a model), and prepareWav have run. A pre-aborted transcribe({engine:"whisper", signal}) can therefore still install/download/prepare, and an abort during setup cannot stop it. The CLI had a similar timing boundary, but this new library API promises cancellation through its signal. Please propagate/check it before setup and through the long asynchronous setup steps, with a pre-aborted and download-in-progress regression.
There was a problem hiding this comment.
Fixed in 6c9a4b7. A pre-aborted signal now throws cancelled at the top of transcribe(), before any setup. Whisper's transcribe takes signal and checks it before the runtime check, before the model, before audio prep and after it. ensureModel hands it to downloadFile, which gives it to https.get, so an abort mid-download destroys the request and removes the partial file. The runtime install (brew or a source build) is still synchronous, so it can't be stopped mid-step; the signal is checked before and after it. Regressions: whisper/transcribe.signal.test.ts (pre-aborted: ensureWhisper and ensureModel never called; abort during the model download: rejects with the abort reason before audio prep, and the download received the signal), download.test.ts (abort mid-transfer: https.get got the signal, rejection, no partial file left), and api.test.ts (pre-aborted call: whisper never invoked). Each fails with its fix reverted. The CLI path is unchanged: it still installs its Ctrl-C handling at the same moments.
— Rames
| sidecar: CaptionSidecar | undefined, | ||
| options: TranscribeRequest, | ||
| ): Promise<TranscribeResult> { | ||
| const { transcribe } = await import("../whisper/transcribe.js"); |
There was a problem hiding this comment.
This new API module contains function-local dynamic imports here and at lines 235, 249, 288, and 348–354. Our project-wide code-style rule requires imports at module scope, including tests and new library entries, unless Jerry grants a per-instance exception. Please hoist these to top-level imports (checking any startup/cycle consequences), or obtain that explicit exception before approval. Moving pre-existing lazy imports from the command into a newly added public API does not exempt the new file.
There was a problem hiding this comment.
Fixed in 6c9a4b7: every import in api/transcribe.ts is at module scope now, and api.test.ts has a check that no API module contains import(. Two consequences worth knowing: (1) commands/transcribe.test.ts needed one line, const transcribeMock = vi.hoisted(() => vi.fn()), because its vi.mock factory for whisper/transcribe.js now runs during imports; no assertion changed and its 63 tests still pass. (2) transcribe <transcript file> (import/export, no ASR) now loads the whole speech stack up front: about 100-125 ms before vs 240-320 ms after per run on my box. Media runs loaded it anyway. If that cost matters, the alternative is one documented lazy boundary around the ASR modules, which would need Jerry's exception; I didn't add one.
— Rames
| } | ||
|
|
||
| const model = options.model ?? DEFAULT_MODEL; | ||
| options.onEngine?.({ runner, engine: engineOf(runner), model }); |
There was a problem hiding this comment.
This event reports options.model ?? DEFAULT_MODEL before Whisper's language normalization. With {engine:"whisper", language:"es"} and no explicit model, it announces small.en, while whisper/transcribe.ts:448 changes that to small and the result/progress use small. Callers relying on onEngine to show or prefetch the selected model get the wrong one. Please report the effective Whisper model and add a non-English default-model test.
There was a problem hiding this comment.
Fixed in 6c9a4b7. onEngine now reports initialModelForLanguage(model, language), the same resolver whisper uses. It moved from whisper/transcribe.ts to whisper/modelForLanguage.ts (re-exported from transcribe.ts for existing importers) so the API can share it without a copy. Regression: api.test.ts "reports the model whisper will run" with {engine: "whisper", language: "es"} expects small and fails with the fix reverted. The CLI spinner still prints the requested model name as before (Transcribing with small.en...), to keep CLI output byte-for-byte the same; changing that label would be a separate, visible CLI change.
— Rames
Review follow-ups on the api entry: - parakeet-mlx now runs as an async child that the caller's signal can kill. Its failures keep the synchronous run's message, status and stderr. - A pre-aborted signal throws before any setup. Whisper checks the signal before each setup stage, and the model download gets it. - onEngine reports the model whisper runs (small for Spanish, not small.en). The resolver moved to whisper/modelForLanguage.ts so both share it. - api/transcribe.ts imports everything at module scope. The command test hoists its whisper mock, since that module now loads during imports. - whisper's internal TranscribeOptions/TranscribeResult types are renamed WhisperOptions/RunnerResult, so the API's public names are unique. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
What
Adds a programmatic entry to the existing CLI package:
hyperframes/api(in the monorepo,@hyperframes/cli/api). This first slice covers two commands,transcribeandremove-background. No new npm package.Both take typed options and return a typed result. Progress comes through optional callbacks. Neither prints, reads argv or exits the process. Known failures throw
TranscribeErrororRemoveBackgroundError, with acode(for exampleinput_not_found,whisper_unavailable,cancelled,failed) and the original error ascause. The doc comment at the top ofapi.tssays this entry is for HyperFrames' own apps and may change between minor versions.Why
HyperFrames Desktop runs the CLI as a child process for about 10 subcommands. On Mac and Windows it does that by relaunching the Electron binary with
ELECTRON_RUN_AS_NODE=1. We want to turn Electron's RunAsNode fuse off, so Desktop needs plain functions it can import and run in autilityProcess. Desktop already installs the whole CLI (hyperframes, pinned), so a subpath export on this package is enough.Related work
First slice of the Desktop RunAsNode work. The remaining subcommands follow in later PRs.
How
src/api/transcribe.tsandsrc/api/removeBackground.tshold the logic that used to live in the two command files.src/api.tsis the public entry.codeback to the exact output and exit code they had before.transcribehas three callbacks, because each one changes what runs underneath:onProgressgets the typed events--jsonwrites to stderr. Whisper adds--print-progressonly when a caller passes it, as before.onStatusgets the spinner's status lines.onEnginesays which runner started, and fires again when auto mode falls back from Parakeet. It reports the model whisper actually runs (smallfor Spanish, notsmall.en), from the same resolver whisper uses, now inwhisper/modelForLanguage.ts.AbortSignal.transcribe <transcript file>(import or export, no speech engine) now loads the speech stack too, about 100-125 ms per run before vs 240-320 ms after on my machine. Media runs loaded it anyway.TranscribeOptions/TranscribeResulttypes are renamedWhisperOptions/RunnerResult, so the public names are unique.tsup: newapientry, plus a d.ts for that entry only.package.jsongains anexportsmap, generated byscripts/package-subpaths.mjsfrom a newpackage-subpaths.json, like the sibling packages:"./api"(dist JS plus d.ts) and"./package.json". The source and runtime paths are bothdist, so nopublishConfig.exportsis needed. That matters because the CLI is published withnpm publish, which ignorespublishConfig.exports.knip.config.ts:src/api.tsadded as an entry.Things a reviewer should know:
"."export. Onmainthe package has nomainorexports, soimport "hyperframes"already fails with ERR_MODULE_NOT_FOUND. I checked this against the published 0.8.146 manifest, so there was nothing to keep.hyperframes/dist/...andhyperframes/bin/.... I found no code that imports those through module resolution, in this repo or in Desktop:bin/hyperframes.mjsandpackage.jsonby joining file paths, which an exports map does not affect.hyperframes/package.json, which stays exported.--output,--engine), so the CLI output stays byte-for-byte the same. The CLI spinner also still shows the requested model name (Transcribing with small.en...) for the same reason. Both can change once the CLI formats its own text from the API's codes and events.Test plan
What I measured:
src/commands/transcribe.test.ts: 63 and 63src/background-removal/*: 50 and 50remove-backgroundhas no command-level test file.commands/transcribe.test.tschanged:transcribeMockis now created withvi.hoisted, because its mock factory runs during imports once the API imports whisper at module scope. No assertion changed.src/api.test.ts, 18 tests. They call the functions directly and cover import, export, a whisper run with each callback, the effective whisper model, a pre-aborted signal, an AbortSignal stop that adds no process signal handlers, stopping a running parakeet-mlx, error codes with their causes, removeBackground's handoff to the pipeline, and a check that the API modules have no function-local imports. Each test also checks that nothing was printed.whisper/transcribe.signal.test.ts, 2 tests: a pre-aborted signal does no setup, and an abort during the model download stops before audio prep.utils/download.test.ts, 1 new test: an aborted download hands https.get the signal and leaves no partial file.hyperframes@0.8.146(the currentmainrelease) through 31 transcribe and remove-background cases, in identical fresh folders. stdout, stderr, exit code and the files written were identical in all 31. The cases covered import, export, every validation error, whisper unavailable with and without--optional, a Spanish whisper run, and remove-background flag and pipeline errors.removeBackgroundthrough the builtdist/api.jsfrom plain Node, outside the CLI, on a real image. It downloaded the model, wrote the cutout PNG, sent metadata, info and frame progress events, and gave back a typedRemoveBackgroundErrorfor a bad output extension.bun run verify:packed-manifestspasses. It packs the CLI, installs it in a clean consumer, type-checks and imports@hyperframes/cli/api, and checks size budgets.bun run lint, oxfmt check,tsc --noEmitfor the CLI, the package-subpaths and packed-manifest script tests, test reachability, the comment ratchet, andfallow audit --base origin/main.src/browser/launch.process.test.ts. They fail the same way onmainon my machine (a headless Chrome launch timeout), and they don't touch this code.What I did not exercise:
— Rames
🤖 Generated with Claude Code