hyperframes 0.8.81 → 0.8.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/backgroundChecksWorker.js +890 -0
  2. package/dist/cli.js +2928 -1809
  3. package/dist/commands/layout-audit.browser.js +55 -25
  4. package/dist/fontLocalizeCli.js +417 -134
  5. package/dist/hyperframe-runtime.js +53 -53
  6. package/dist/hyperframe.manifest.json +1 -1
  7. package/dist/hyperframe.runtime.iife.js +53 -53
  8. package/dist/hyperframes-player.global.js +3 -3
  9. package/dist/registry/localSemantic.js +7 -3
  10. package/dist/renderSetupWorker.js +62 -32
  11. package/dist/sherpaWorker.js +179 -0
  12. package/dist/skills/hyperframes/SKILL.md +2 -0
  13. package/dist/skills/hyperframes/references/plugin-installation.md +43 -0
  14. package/dist/skills/hyperframes/references/skill-lifecycle.md +2 -0
  15. package/dist/skills/hyperframes/scripts/plugin-cli.mjs +88 -0
  16. package/dist/skills/hyperframes/scripts/plugin-cli.test.mjs +117 -0
  17. package/dist/skills/hyperframes-cli/SKILL.md +2 -0
  18. package/dist/skills/hyperframes-cli/references/upgrade-info-misc.md +1 -0
  19. package/dist/skills/media-use/SKILL.md +2 -0
  20. package/dist/skills/media-use/references/operations.md +7 -5
  21. package/dist/skills/media-use/references/setup-providers.md +9 -9
  22. package/dist/skills/media-use/registry/localSemantic.js +7 -3
  23. package/dist/skills/media-use/scripts/lib/ff-binaries.mjs +37 -0
  24. package/dist/skills/media-use/scripts/lib/probe.mjs +3 -1
  25. package/dist/skills/media-use/scripts/lib/probe.test.mjs +118 -1
  26. package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +5 -3
  27. package/dist/skills/media-use/scripts/resolve-plugin.test.mjs +62 -0
  28. package/dist/skills/media-use/scripts/resolve.mjs +45 -19
  29. package/dist/skills/media-use/scripts/transcribe.mjs +39 -25
  30. package/dist/skills/media-use/scripts/transcribe.test.mjs +112 -0
  31. package/dist/skills/registry/localSemantic.js +7 -3
  32. package/dist/studio/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  33. package/dist/studio/assets/{index-CMzPGv9P.js → index-BiLCTunu.js} +1 -1
  34. package/dist/studio/assets/{index-hNVM9Pd2.js → index-DSazIt7R.js} +189 -189
  35. package/dist/studio/assets/{index-CS5SImRT.js → index-DStmfnpZ.js} +1 -1
  36. package/dist/studio/index.d.ts +172 -7
  37. package/dist/studio/index.html +1 -1
  38. package/dist/studio/index.js +2414 -2172
  39. package/dist/studio/index.js.map +1 -1
  40. package/dist/templates/_shared/AGENTS.md +5 -1
  41. package/dist/templates/_shared/CLAUDE.md +5 -1
  42. package/package.json +1 -1
@@ -112,14 +112,16 @@ engine directly.
112
112
  # install once: uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx
113
113
  node <SKILL_DIR>/scripts/transcribe.mjs --input talk.mp4 --out talk.transcribe.json
114
114
 
115
- # equivalently, the hyperframes CLI has Parakeet built in (auto-detects it, whisper fallback):
115
+ # the hyperframes CLI runs Parakeet on macOS, Linux and Windows once it is installed:
116
+ npx hyperframes models install parakeet # once, ~640 MB, ask the user first
116
117
  npx hyperframes transcribe talk.mp4 --engine parakeet # or --engine auto (default)
117
118
  ```
118
119
 
119
- VERIFIED on 24GB: accurate, ~3s (cached) for 8s audio. Parakeet covers English +
120
- 25 European languages. For other languages, or when parakeet-mlx is not
121
- installed, transcribe.mjs auto-falls-back to whisper.cpp (99 languages) via
122
- `hyperframes transcribe`. `--engine parakeet|whisper` forces one. (Cohere
120
+ VERIFIED on 24GB: accurate, ~3s (cached) for 8s audio. Parakeet covers 25
121
+ European languages, English included. For other languages, or when parakeet-mlx is not
122
+ installed, transcribe.mjs falls back to `hyperframes transcribe`, which uses the
123
+ CLI's Parakeet when `models install parakeet` has run and whisper.cpp (99
124
+ languages) otherwise. `--engine parakeet|whisper` forces one. (Cohere
123
125
  Transcribe tops the leaderboard on paper but its mlx-audio quants produced
124
126
  garbage and ran 40-70x slower on a Mac in testing, so it is not wired in.)
125
127
 
@@ -59,15 +59,15 @@ tools are OPT-IN alternatives where they exist; install one to unlock its free,
59
59
  private, on-device path instead of or ahead of HeyGen for that type. Only
60
60
  `ffmpeg`/`ffprobe` are strictly required for the tool to run at all.
61
61
 
62
- | Tool | Serves | Install |
63
- | ------------------ | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
64
- | `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
65
- | `heygen` | catalog (bgm/sfx/image/icon) + TTS (voice) + avatar video — the free-usage path | install through [verified HeyGen release instructions](https://developers.heygen.com/cli), then `heygen auth login --oauth` (needs >= v0.3.0) |
66
- | `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
67
- | `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
68
- | `parakeet-mlx` | local transcription (default ASR, best) | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
69
- | `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
70
- | `npx hyperframes` | Kokoro TTS (voice), whisper.cpp (transcribe fallback), remove-background | via the hyperframes CLI; whisper.cpp is built on first use (Homebrew on macOS, else git+cmake), models download from HuggingFace |
62
+ | Tool | Serves | Install |
63
+ | ------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64
+ | `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
65
+ | `heygen` | catalog (bgm/sfx/image/icon) + TTS (voice) + avatar video — the free-usage path | install through [verified HeyGen release instructions](https://developers.heygen.com/cli), then `heygen auth login --oauth` (needs >= v0.3.0) |
66
+ | `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
67
+ | `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
68
+ | `parakeet-mlx` | local transcription (default ASR, best) on Apple Silicon | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
69
+ | `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
70
+ | `npx hyperframes` | Kokoro TTS (voice), Parakeet or whisper.cpp (transcribe), remove-background | via the hyperframes CLI; Parakeet on macOS, Windows and glibc 2.32+ Linux: `npx hyperframes models install parakeet`; whisper.cpp is built on first use (Homebrew on macOS, else git+cmake) |
71
71
 
72
72
  The RAM-graded local-model shortlist + exact per-tier install/invoke lives in
73
73
  `scripts/lib/local-models.mjs` (the agent can read `describeModelLadder(cap, specs)`
@@ -24,6 +24,7 @@ import {
24
24
  realpathSync,
25
25
  renameSync,
26
26
  rmSync,
27
+ statSync,
27
28
  writeFileSync
28
29
  } from "fs";
29
30
  import { createRequire } from "module";
@@ -79,9 +80,9 @@ function pinnedCopyBesideCli(name, cliUrl) {
79
80
  function loadBesideCli(name, cliUrl = import.meta.url) {
80
81
  return pinnedCopyBesideCli(name, cliUrl) ? createRequire(cliUrl)(name) : null;
81
82
  }
82
- function runNpm(args) {
83
+ function runNpm(args, signal) {
83
84
  const npm = buildNpmCommand(args);
84
- const child = spawn(npm.command, npm.args, { stdio: ["ignore", "pipe", "pipe"] });
85
+ const child = spawn(npm.command, npm.args, { stdio: ["ignore", "pipe", "pipe"], signal });
85
86
  return new Promise((resolve, reject) => {
86
87
  let output = "";
87
88
  child.stdout.on("data", (chunk) => output += chunk);
@@ -101,6 +102,7 @@ function isProcessAlive(pid) {
101
102
  return err.code !== "ESRCH";
102
103
  }
103
104
  }
105
+ var STAGING_PID_TRUSTED_FOR_MS = 6 * 60 * 60 * 1e3;
104
106
  function sweepStaleStaging(dir) {
105
107
  const prefix = `${basename(dir)}.tmp-`;
106
108
  const parent = dirname(dir);
@@ -108,8 +110,10 @@ function sweepStaleStaging(dir) {
108
110
  for (const entry of readdirSync(parent)) {
109
111
  if (!entry.startsWith(prefix)) continue;
110
112
  const pid = /^(\d+)(?:-|$)/.exec(entry.slice(prefix.length))?.[1];
111
- if (pid === void 0 || isProcessAlive(Number(pid))) continue;
113
+ if (pid === void 0) continue;
112
114
  const stale = join(parent, entry);
115
+ const age = Date.now() - (statSync(stale, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
116
+ if (isProcessAlive(Number(pid)) && age < STAGING_PID_TRUSTED_FOR_MS) continue;
113
117
  try {
114
118
  rmSync(stale, { recursive: true, force: true });
115
119
  } catch (err) {
@@ -0,0 +1,37 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+
4
+ // The binaries media-use spawns: HYPERFRAMES_FFMPEG_PATH / HYPERFRAMES_FFPROBE_PATH when set, else PATH.
5
+ // A set path that is not a working ffmpeg/ffprobe throws, so a broken override never reads as "no metadata".
6
+ const runsByPath = new Map();
7
+
8
+ export class FfBinarySettingError extends Error {}
9
+
10
+ function configuredOr(name, envVar) {
11
+ const setting = process.env[envVar]?.trim();
12
+ if (!setting) return name;
13
+ const path = resolve(setting);
14
+ if (!runsByPath.has(path)) runsByPath.set(path, isWorking(path, name));
15
+ if (!runsByPath.get(path)) {
16
+ const why = `${envVar} names "${path}", which is not a working ${name}: fix it or unset it.`;
17
+ throw new FfBinarySettingError(why);
18
+ }
19
+ return path;
20
+ }
21
+
22
+ function isWorking(path, name) {
23
+ try {
24
+ const out = execFileSync(path, ["-version"], {
25
+ encoding: "utf8",
26
+ stdio: ["ignore", "pipe", "ignore"],
27
+ timeout: 15000,
28
+ windowsHide: true,
29
+ });
30
+ return out.startsWith(`${name} version`);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export const ffmpegBinary = () => configuredOr("ffmpeg", "HYPERFRAMES_FFMPEG_PATH");
37
+ export const ffprobeBinary = () => configuredOr("ffprobe", "HYPERFRAMES_FFPROBE_PATH");
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { extname } from "node:path";
3
+ import { ffprobeBinary } from "./ff-binaries.mjs";
3
4
 
4
5
  const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);
5
6
 
@@ -7,11 +8,12 @@ export function probe(filePath) {
7
8
  const ext = extname(filePath).toLowerCase();
8
9
  if (ext === ".svg") return { width: null, height: null, duration: null, codec: "svg" };
9
10
 
11
+ const ffprobe = ffprobeBinary();
10
12
  try {
11
13
  // execFileSync (no shell) so a hostile filename like `"; rm -rf ~; ".png`
12
14
  // can't break out of the quoting — filePath is passed as a literal argv entry.
13
15
  const raw = execFileSync(
14
- "ffprobe",
16
+ ffprobe,
15
17
  ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "--", filePath],
16
18
  { encoding: "utf8", timeout: 5000 },
17
19
  );
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
3
+ import { chmodSync, mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { probe } from "./probe.mjs";
@@ -29,3 +29,120 @@ test("probe does not execute shell metacharacters in a filename", () => {
29
29
  rmSync(dir, { recursive: true, force: true });
30
30
  }
31
31
  });
32
+
33
+ test(
34
+ "probe runs the ffprobe HYPERFRAMES_FFPROBE_PATH names",
35
+ { skip: process.platform === "win32" },
36
+ () => {
37
+ const dir = mkdtempSync(join(tmpdir(), "probe-ffprobe-path-"));
38
+ const fake = join(dir, "fake-ffprobe");
39
+ writeFileSync(
40
+ fake,
41
+ `#!/bin/sh\n[ "$1" = -version ] && echo 'ffprobe version fake' && exit 0\necho '{"streams":[{"width":7,"height":3,"codec_name":"fake"}]}'\n`,
42
+ );
43
+ chmodSync(fake, 0o755);
44
+ const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
45
+ process.env.HYPERFRAMES_FFPROBE_PATH = fake;
46
+ try {
47
+ assert.deepEqual(probe(join(dir, "clip.png")), {
48
+ duration: null,
49
+ width: 7,
50
+ height: 3,
51
+ codec: "fake",
52
+ });
53
+ } finally {
54
+ if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
55
+ else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
56
+ rmSync(dir, { recursive: true, force: true });
57
+ }
58
+ },
59
+ );
60
+
61
+ test("probe refuses an HYPERFRAMES_FFPROBE_PATH that cannot run instead of reporting no metadata", () => {
62
+ const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
63
+ process.env.HYPERFRAMES_FFPROBE_PATH = join(tmpdir(), "no-ffprobe-here", "ffprobe");
64
+ try {
65
+ assert.throws(
66
+ () => probe("clip.wav"),
67
+ /HYPERFRAMES_FFPROBE_PATH names ".*no-ffprobe-here.*fix it or unset it/,
68
+ );
69
+ } finally {
70
+ if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
71
+ else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
72
+ }
73
+ });
74
+
75
+ test("probe refuses an HYPERFRAMES_FFPROBE_PATH that names a folder", () => {
76
+ const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
77
+ process.env.HYPERFRAMES_FFPROBE_PATH = tmpdir();
78
+ try {
79
+ assert.throws(() => probe("clip.wav"), /HYPERFRAMES_FFPROBE_PATH names .*fix it or unset it/);
80
+ } finally {
81
+ if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
82
+ else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
83
+ }
84
+ });
85
+
86
+ function withFfprobePath(value, run) {
87
+ const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
88
+ process.env.HYPERFRAMES_FFPROBE_PATH = value;
89
+ try {
90
+ return run();
91
+ } finally {
92
+ if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
93
+ else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
94
+ }
95
+ }
96
+
97
+ test(
98
+ "probe refuses a configured ffprobe that cannot start, exits with an error, or is another tool",
99
+ {
100
+ skip: process.platform === "win32",
101
+ },
102
+ () => {
103
+ const dir = mkdtempSync(join(tmpdir(), "probe-broken-ffprobe-"));
104
+ try {
105
+ const noInterpreter = join(dir, "no-interpreter");
106
+ writeFileSync(noInterpreter, "#!/nonexistent/interpreter\n");
107
+ const failing = join(dir, "failing");
108
+ writeFileSync(failing, "#!/bin/sh\nexit 3\n");
109
+ const otherTool = join(dir, "other-tool");
110
+ writeFileSync(otherTool, "#!/bin/sh\necho 'ffmpeg version 9.9'\n");
111
+ for (const broken of [noInterpreter, failing, otherTool]) {
112
+ chmodSync(broken, 0o755);
113
+ withFfprobePath(broken, () =>
114
+ assert.throws(
115
+ () => probe("clip.wav"),
116
+ /HYPERFRAMES_FFPROBE_PATH names .*fix it or unset it/,
117
+ ),
118
+ );
119
+ }
120
+ } finally {
121
+ rmSync(dir, { recursive: true, force: true });
122
+ }
123
+ },
124
+ );
125
+
126
+ test(
127
+ "probe runs a relative HYPERFRAMES_FFPROBE_PATH from the working folder, not from PATH",
128
+ {
129
+ skip: process.platform === "win32",
130
+ },
131
+ () => {
132
+ const dir = mkdtempSync(join(tmpdir(), "probe-relative-ffprobe-"));
133
+ const prevCwd = process.cwd();
134
+ try {
135
+ writeFileSync(
136
+ join(dir, "local-ffprobe"),
137
+ `#!/bin/sh\n[ "$1" = -version ] && echo 'ffprobe version fake' && exit 0\necho '{"streams":[{"width":5,"height":4,"codec_name":"local"}]}'\n`,
138
+ );
139
+ chmodSync(join(dir, "local-ffprobe"), 0o755);
140
+ process.chdir(dir);
141
+ const meta = withFfprobePath("local-ffprobe", () => probe("clip.png"));
142
+ assert.deepEqual(meta, { duration: null, width: 5, height: 4, codec: "local" });
143
+ } finally {
144
+ process.chdir(prevCwd);
145
+ rmSync(dir, { recursive: true, force: true });
146
+ }
147
+ },
148
+ );
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, statSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { ffprobeBinary } from "./ff-binaries.mjs";
5
6
  import { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";
6
7
 
7
8
  // Local voiceover via the packaged Kokoro-82M TTS (the `hyperframes tts` CLI),
@@ -13,10 +14,10 @@ import { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";
13
14
  // Delegated to the hyperframes CLI (same as transcribe / remove-background), not
14
15
  // re-implemented here. ffprobe reads the duration back for the ledger.
15
16
 
16
- function probeDurationSeconds(file) {
17
+ function probeDurationSeconds(ffprobe, file) {
17
18
  try {
18
19
  const out = execFileSync(
19
- "ffprobe",
20
+ ffprobe,
20
21
  ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", "--", file],
21
22
  { encoding: "utf8", timeout: 15000 },
22
23
  );
@@ -39,6 +40,7 @@ export async function localTtsGenerate(
39
40
  env = process.env,
40
41
  pathExists = existsSync,
41
42
  ) {
43
+ const ffprobe = ffprobeBinary();
42
44
  const outPath = join(tmpdir(), `media-use-kokoro-${process.pid}-${Date.now()}.wav`);
43
45
  const argv = ["hyperframes", "tts", intent, "--output", outPath];
44
46
  if (ctx?.voice) argv.push("--voice", ctx.voice);
@@ -85,7 +87,7 @@ export async function localTtsGenerate(
85
87
  metadata: {
86
88
  description: intent,
87
89
  provider: "kokoro.local",
88
- duration: probeDurationSeconds(outPath),
90
+ duration: probeDurationSeconds(ffprobe, outPath),
89
91
  provenance: { engine: "kokoro-82m", prompt: intent },
90
92
  },
91
93
  };
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { delimiter, join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { test } from "node:test";
8
+
9
+ test(
10
+ "plugin resolver uses the release CLI without a repository checkout",
11
+ { skip: process.platform === "win32" },
12
+ (t) => {
13
+ const dir = mkdtempSync(join(tmpdir(), "hf-resolve-plugin-"));
14
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
15
+ mkdirSync(join(dir, "lib"));
16
+ for (const file of ["resolve.mjs", "lib/npx-sync.mjs"]) {
17
+ copyFileSync(fileURLToPath(new URL(file, import.meta.url)), join(dir, file));
18
+ }
19
+ writeFileSync(
20
+ join(dir, "npx"),
21
+ `#!${process.execPath}\nconsole.log(JSON.stringify({args:process.argv.slice(2),skip:process.env.HYPERFRAMES_SKIP_SKILLS}));process.exit(7);`,
22
+ { mode: 0o755 },
23
+ );
24
+ const result = spawnSync(process.execPath, [join(dir, "resolve.mjs"), "--intent", "a & b"], {
25
+ cwd: dir,
26
+ encoding: "utf8",
27
+ env: {
28
+ ...process.env,
29
+ PATH: `${dir}${delimiter}${process.env.PATH}`,
30
+ HYPERFRAMES_PLUGIN_VERSION: "1.2.3",
31
+ HYPERFRAMES_SKIP_SKILLS: "1",
32
+ },
33
+ });
34
+ assert.equal(result.status, 7, result.stderr);
35
+ assert.deepEqual(JSON.parse(result.stdout), {
36
+ args: ["--yes", "hyperframes@1.2.3", "media-use", "resolve", "--intent", "a & b"],
37
+ skip: "1",
38
+ });
39
+ },
40
+ );
41
+
42
+ test("helper package override keeps a contributor's local CLI", (t) => {
43
+ const root = mkdtempSync(join(tmpdir(), "hf-resolve-local-"));
44
+ t.after(() => rmSync(root, { recursive: true, force: true }));
45
+ const scripts = join(root, "skills/media-use/scripts");
46
+ mkdirSync(join(scripts, "lib"), { recursive: true });
47
+ mkdirSync(join(root, "packages/cli/dist"), { recursive: true });
48
+ for (const file of ["resolve.mjs", "lib/npx-sync.mjs"]) {
49
+ copyFileSync(fileURLToPath(new URL(file, import.meta.url)), join(scripts, file));
50
+ }
51
+ writeFileSync(join(root, "packages/cli/dist/cli.js"), 'console.log("local-cli");');
52
+ const result = spawnSync(process.execPath, [join(scripts, "resolve.mjs")], {
53
+ encoding: "utf8",
54
+ env: {
55
+ ...process.env,
56
+ HYPERFRAMES_PLUGIN_VERSION: "",
57
+ HYPERFRAMES_SKILL_PKG_VERSION: "0.8.79",
58
+ },
59
+ });
60
+ assert.equal(result.status, 0, result.stderr);
61
+ assert.equal(result.stdout.trim(), "local-cli");
62
+ });
@@ -33,6 +33,7 @@ import { heygenAuthMethod } from "../audio/scripts/lib/heygen.mjs";
33
33
  import { buildCube, paramsFromIntent } from "./lib/cube-build.mjs";
34
34
  import { validateCubeFile } from "./lib/cube-validate.mjs";
35
35
  import { analyzeMediaGrade, formatMeasuredNote } from "./lib/grade-analyzer.mjs";
36
+ import { FfBinarySettingError, ffmpegBinary, ffprobeBinary } from "./lib/ff-binaries.mjs";
36
37
  import {
37
38
  freezeLibraryLut,
38
39
  isLibraryLutOfflineMiss,
@@ -144,7 +145,12 @@ const entity = args.entity || null;
144
145
 
145
146
  if (args.adopt) {
146
147
  const { adoptExistingAssets } = await import("./lib/adopt.mjs");
147
- const adopted = adoptExistingAssets(projectDir);
148
+ let adopted;
149
+ try {
150
+ adopted = adoptExistingAssets(projectDir);
151
+ } catch (err) {
152
+ exitError(err.message);
153
+ }
148
154
  if (args.json) {
149
155
  console.log(JSON.stringify({ ok: true, adopted: adopted.length, assets: adopted }));
150
156
  } else if (adopted.length === 0) {
@@ -218,7 +224,15 @@ if (args.analyze) {
218
224
  console.error(`error: --for file not found: ${mediaPath}`);
219
225
  process.exit(2);
220
226
  }
221
- const analysis = analyzeMediaGrade(mediaPath);
227
+ let analysis;
228
+ try {
229
+ analysis = analyzeMediaGrade(mediaPath, {
230
+ ffmpegPath: ffmpegBinary(),
231
+ ffprobePath: ffprobeBinary(),
232
+ });
233
+ } catch (err) {
234
+ exitError(err.message);
235
+ }
222
236
  if (args.json) {
223
237
  console.log(JSON.stringify({ ok: true, type: "grade-analysis", ...analysis }));
224
238
  } else {
@@ -503,7 +517,8 @@ async function run() {
503
517
  // brand stays local: no frame.md/design.md -> upsell the HyperFrames design
504
518
  // flow rather than reporting a generic miss (B5).
505
519
  const msg =
506
- providerFailure instanceof BundledSfxAssetsError
520
+ providerFailure instanceof BundledSfxAssetsError ||
521
+ providerFailure instanceof FfBinarySettingError
507
522
  ? providerFailure.message
508
523
  : type === "brand"
509
524
  ? "no brand spec found — add a frame.md or design.md (colors/font/logo) to this project. Run the HyperFrames design flow to create one; brand tokens are read locally for deterministic rendering."
@@ -594,7 +609,10 @@ function mergeSmartAdjust(block) {
594
609
  const mediaPath = resolve(args.for);
595
610
  // Clear upfront error beats an ffmpeg "No such file" stack on a typo'd path.
596
611
  if (!existsSync(mediaPath)) throw new Error(`--for file not found: ${mediaPath}`);
597
- const analysis = analyzeMediaGrade(mediaPath);
612
+ const analysis = analyzeMediaGrade(mediaPath, {
613
+ ffmpegPath: ffmpegBinary(),
614
+ ffprobePath: ffprobeBinary(),
615
+ });
598
616
  console.error(formatMeasuredNote(mediaPath, analysis.measured));
599
617
  return {
600
618
  ...block,
@@ -1060,21 +1078,7 @@ function runDoctor() {
1060
1078
  checks.push(heygenAuthCheck());
1061
1079
  }
1062
1080
 
1063
- const ffmpegProbe = runCommand("ffmpeg", ["-version"]);
1064
- checks.push({
1065
- name: "ffmpeg on PATH",
1066
- ok: ffmpegProbe.status === 0,
1067
- detail: ffmpegProbe.status === 0 ? firstLine(ffmpegProbe.stdout) : "ffmpeg not found",
1068
- fix: ffmpegProbe.status === 0 ? "" : "brew install ffmpeg",
1069
- });
1070
-
1071
- const ffprobeProbe = runCommand("ffprobe", ["-version"]);
1072
- checks.push({
1073
- name: "ffprobe on PATH",
1074
- ok: ffprobeProbe.status === 0,
1075
- detail: ffprobeProbe.status === 0 ? firstLine(ffprobeProbe.stdout) : "ffprobe not found",
1076
- fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg",
1077
- });
1081
+ checks.push(ffDoctorCheck("ffmpeg", ffmpegBinary), ffDoctorCheck("ffprobe", ffprobeBinary));
1078
1082
 
1079
1083
  const nodeOk = !versionLessThan(process.versions.node, MIN_NODE_VERSION);
1080
1084
  checks.push({
@@ -1140,6 +1144,28 @@ function printMap(label, values) {
1140
1144
  for (const [key, value] of entries) console.log(` ${key}: ${value}`);
1141
1145
  }
1142
1146
 
1147
+ function ffDoctorCheck(name, binary) {
1148
+ let bin;
1149
+ try {
1150
+ bin = binary();
1151
+ } catch (err) {
1152
+ return {
1153
+ name: `${name} on PATH`,
1154
+ ok: false,
1155
+ detail: err.message,
1156
+ fix: "fix or unset that variable",
1157
+ };
1158
+ }
1159
+ const probe = runCommand(bin, ["-version"]);
1160
+ const ok = probe.status === 0;
1161
+ return {
1162
+ name: `${name} on PATH`,
1163
+ ok,
1164
+ detail: ok ? firstLine(probe.stdout) : `${name} not found`,
1165
+ fix: ok ? "" : "brew install ffmpeg",
1166
+ };
1167
+ }
1168
+
1143
1169
  function runCommand(bin, argv) {
1144
1170
  return spawnSync(bin, argv, {
1145
1171
  encoding: "utf8",
@@ -12,7 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { homedir, tmpdir } from "node:os";
14
14
  import { basename, extname, join, resolve } from "node:path";
15
- import { parseArgs } from "node:util";
15
+ import { parseArgs, stripVTControlCharacters } from "node:util";
16
16
  import { mergeTokensToWords } from "./lib/parakeet-words.mjs";
17
17
  import { track } from "./lib/telemetry.mjs";
18
18
  import { resolveNpxInvocation } from "./lib/npx-sync.mjs";
@@ -49,7 +49,8 @@ Usage:
49
49
 
50
50
  Parakeet (default) beats whisper.cpp on accuracy + speed for English/European
51
51
  languages; whisper.cpp (99 languages) is the fallback. Install Parakeet once:
52
- uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx`);
52
+ npx hyperframes models install parakeet (macOS, Windows, Linux with glibc 2.32+)
53
+ uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx (Apple Silicon)`);
53
54
  process.exit(0);
54
55
  }
55
56
 
@@ -122,33 +123,57 @@ function runParakeet(runner) {
122
123
  }
123
124
  }
124
125
 
125
- // whisper.cpp via the hyperframes CLI (fetched/built on first use — see
126
+ // The hyperframes CLI (its Parakeet, else whisper.cpp built on first use — see
126
127
  // SKILL.md): writes transcript.json into --dir; relocate to --out.
127
- function runWhisper() {
128
+ function runCli(cliEngine) {
128
129
  const workDir = mkdtempSync(join(tmpdir(), "media-use-whisper-"));
129
130
  try {
130
131
  // On Windows a bare "npx" is npx.cmd, which execFileSync cannot exec
131
132
  // (spawnSync npx ENOENT) — resolveNpxInvocation reroutes it through
132
133
  // node + npx-cli.js (and throws actionably when it can't), same
133
134
  // mechanism as the audio engine's TTS spawns.
135
+ // Under auto or parakeet the CLI may run its own Parakeet; --json says which engine ran.
134
136
  const resolved = resolveNpxInvocation(
135
- ["hyperframes", "transcribe", inputPath, "--dir", workDir],
137
+ ["hyperframes", "transcribe", inputPath, "--dir", workDir, "--engine", cliEngine, "--json"],
136
138
  { stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 },
137
139
  );
138
- execFileSync(resolved.cmd, resolved.args, resolved.opts);
140
+ let stdout;
141
+ try {
142
+ stdout = String(execFileSync(resolved.cmd, resolved.args, resolved.opts));
143
+ } catch (e) {
144
+ // stderr holds spinner redraws and --json prints the reason on stdout, so keep the last lines.
145
+ const tail = stripVTControlCharacters(`${e.stderr ?? ""}\n${e.stdout ?? ""}`)
146
+ .split(/[\r\n]+/)
147
+ .map((l) => l.trim())
148
+ .filter((l) => l && l !== "│")
149
+ .slice(-3)
150
+ .join("\n");
151
+ throw new Error(
152
+ `hyperframes transcribe failed (exit ${e.status ?? e.code})${tail ? `:\n${tail}` : ""}`,
153
+ );
154
+ }
139
155
  const produced = join(workDir, "transcript.json");
140
- if (!existsSync(produced)) throw new Error("whisper produced no transcript.json");
156
+ if (!existsSync(produced))
157
+ throw new Error("hyperframes transcribe produced no transcript.json");
141
158
  const tmp = `${outPath}.tmp-${process.pid}`;
142
159
  copyFileSync(produced, tmp);
143
160
  renameSync(tmp, outPath); // atomic publish
144
161
  let words;
145
162
  try {
146
163
  const t = JSON.parse(readFileSync(outPath, "utf8"));
147
- words = Array.isArray(t?.words) ? t.words.length : undefined;
164
+ // The CLI writes a flat word array, not { words }.
165
+ words = Array.isArray(t) ? t.length : undefined;
148
166
  } catch {
149
167
  /* leave undefined */
150
168
  }
151
- report("whisper", words);
169
+ let ran = "whisper";
170
+ try {
171
+ ran = JSON.parse(stdout.trim().split("\n").at(-1)).engine === "parakeet" ? "parakeet" : ran;
172
+ } catch {
173
+ /* no JSON line: keep whisper */
174
+ }
175
+ report(ran, words);
176
+ return ran;
152
177
  } finally {
153
178
  rmSync(workDir, { recursive: true, force: true });
154
179
  }
@@ -156,22 +181,11 @@ function runWhisper() {
156
181
 
157
182
  try {
158
183
  const parakeetBin = resolveParakeet();
159
- const engine =
160
- args.engine === "parakeet" || args.engine === "whisper"
161
- ? args.engine
162
- : parakeetBin
163
- ? "parakeet"
164
- : "whisper";
165
- if (engine === "parakeet") {
166
- if (!parakeetBin) {
167
- throw new Error(
168
- "parakeet-mlx not found (checked $HYPERFRAMES_PARAKEET, ~/.venvs/parakeet, and PATH). Install: uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx (or use --engine whisper)",
169
- );
170
- }
171
- runParakeet(parakeetBin);
172
- } else {
173
- runWhisper();
174
- }
184
+ const asked = args.engine === "parakeet" || args.engine === "whisper" ? args.engine : "auto";
185
+ // Without parakeet-mlx, the CLI's own Parakeet (any OS, `hyperframes models install parakeet`).
186
+ let engine = "parakeet";
187
+ if (asked !== "whisper" && parakeetBin) runParakeet(parakeetBin);
188
+ else engine = runCli(asked);
175
189
  await track("media_use_transcribe", { engine });
176
190
  } catch (err) {
177
191
  if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));