Skip to content

feat(cli): add hyperframes/api entry with transcribe and removeBackground - #5453

Open
jrusso1020 wants to merge 4 commits into
mainfrom
feat/cli-api-entry
Open

jrusso1020 wants to merge 4 commits into
mainfrom
feat/cli-api-entry

Conversation

@jrusso1020

@jrusso1020 jrusso1020 commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

What

Adds a programmatic entry to the existing CLI package: hyperframes/api (in the monorepo, @hyperframes/cli/api). This first slice covers two commands, transcribe and remove-background. No new npm package.

transcribe(options: TranscribeOptions): Promise<TranscribeResult>
removeBackground(options: RemoveBackgroundOptions): Promise<RemoveBackgroundResult>

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 TranscribeError or RemoveBackgroundError, with a code (for example input_not_found, whisper_unavailable, cancelled, failed) and the original error as cause. The doc comment at the top of api.ts says 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 a utilityProcess. 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.ts and src/api/removeBackground.ts hold the logic that used to live in the two command files. src/api.ts is the public entry.
  • The two commands now keep only flag handling, printing and exit codes. They call the same functions, and map each error code back to the exact output and exit code they had before.
  • transcribe has three callbacks, because each one changes what runs underneath:
    • onProgress gets the typed events --json writes to stderr. Whisper adds --print-progress only when a caller passes it, as before.
    • onStatus gets the spinner's status lines.
    • onEngine says which runner started, and fires again when auto mode falls back from Parakeet. It reports the model whisper actually runs (small for Spanish, not small.en), from the same resolver whisper uses, now in whisper/modelForLanguage.ts.
  • Cancelling: library callers pass an AbortSignal.
    • A pre-aborted signal throws before any setup.
    • The signal reaches every runner. Sherpa already took it. parakeet-mlx now runs as an async child, so the signal can kill it; its failures keep the synchronous run's message, status and stderr. Whisper checks it before each setup stage and passes it to the model download.
    • The whisper runtime install (brew or a source build) is still synchronous, so it can only be checked before and after.
    • The CLI keeps its own Ctrl-C handling, installed at the same moments as before.
  • All imports in the API modules are at module scope. One cost: 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.
  • whisper's internal TranscribeOptions/TranscribeResult types are renamed WhisperOptions/RunnerResult, so the public names are unique.
  • tsup: new api entry, plus a d.ts for that entry only.
  • package.json gains an exports map, generated by scripts/package-subpaths.mjs from a new package-subpaths.json, like the sibling packages: "./api" (dist JS plus d.ts) and "./package.json". The source and runtime paths are both dist, so no publishConfig.exports is needed. That matters because the CLI is published with npm publish, which ignores publishConfig.exports.
  • knip.config.ts: src/api.ts added as an entry.

Things a reviewer should know:

  • There is no "." export. On main the package has no main or exports, so import "hyperframes" already fails with ERR_MODULE_NOT_FOUND. I checked this against the published 0.8.146 manifest, so there was nothing to keep.
  • The exports map blocks deep imports such as hyperframes/dist/... and hyperframes/bin/.... I found no code that imports those through module resolution, in this repo or in Desktop:
    • Desktop finds bin/hyperframes.mjs and package.json by joining file paths, which an exports map does not affect.
    • Desktop's one module import is hyperframes/package.json, which stays exported.
  • Some API error messages still name CLI flags (for example --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

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

What I measured:

  • Existing tests pass. Per-file counts are the same before and after, and the test names are the same:
    • src/commands/transcribe.test.ts: 63 and 63
    • src/background-removal/*: 50 and 50
    • remove-background has no command-level test file.
    • No "Uncaught" in the output.
    • One line of commands/transcribe.test.ts changed: transcribeMock is now created with vi.hoisted, because its mock factory runs during imports once the API imports whisper at module scope. No assertion changed.
  • New tests:
    • 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.
    • Each review fix has a test that fails with the fix reverted.
  • I ran the built CLI from this branch and the published hyperframes@0.8.146 (the current main release) 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.
  • I ran the old synchronous parakeet-mlx code and the new async code against a failing stand-in, with and without stderr. The error message, status, signal and stderr matched; only the random temp folder name differed.
  • I called removeBackground through the built dist/api.js from 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 typed RemoveBackgroundError for a bad output extension.
  • bun run verify:packed-manifests passes. It packs the CLI, installs it in a clean consumer, type-checks and imports @hyperframes/cli/api, and checks size budgets.
  • Also passing: bun run lint, oxfmt check, tsc --noEmit for the CLI, the package-subpaths and packed-manifest script tests, test reachability, the comment ratchet, and fallow audit --base origin/main.
  • The full CLI suite has 2 failures in src/browser/launch.process.test.ts. They fail the same way on main on my machine (a headless Chrome launch timeout), and they don't touch this code.

What I did not exercise:

  • A real speech run, whisper or Parakeet. Neither is installed on my machine. The success and fallback paths are covered by the existing command tests with mocked runners, and parakeet-mlx cancellation by a stand-in script.
  • A real video background removal. Only an image went through the real pipeline.
  • Desktop consuming the entry. That is the next PR, in hyperframes-internal.
  • No vitest test resolves the built package, because the CLI test job does not build the CLI first. The packed-consumer check in the Build job covers that.

— Rames

🤖 Generated with Claude Code

jrusso1020 and others added 2 commits October 11, 2026 07:22
…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>
Comment thread packages/cli/src/api/transcribe.ts Fixed
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>
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 2061 (base branch 2061), smooth 1542 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@jerrai-bot-heygen jerrai-bot-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/cli/src/api/transcribe.ts Outdated
sidecar: CaptionSidecar | undefined,
options: TranscribeRequest,
): Promise<TranscribeResult> {
const { transcribe } = await import("../whisper/transcribe.js");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/cli/src/api/transcribe.ts Outdated
}

const model = options.model ?? DEFAULT_MODEL;
options.onEngine?.({ runner, engine: engineOf(runner), model });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants