hyperframes 0.8.80 → 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.
- package/dist/backgroundChecksWorker.js +890 -0
- package/dist/cli.js +3682 -2418
- package/dist/commands/layout-audit.browser.js +55 -25
- package/dist/fontLocalizeCli.js +575 -174
- package/dist/hyperframe-runtime.js +53 -53
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +53 -53
- package/dist/hyperframes-player.global.js +3 -3
- package/dist/registry/localSemantic.js +7 -3
- package/dist/renderSetupWorker.js +62 -32
- package/dist/sherpaWorker.js +179 -0
- package/dist/skills/hyperframes/SKILL.md +2 -0
- package/dist/skills/hyperframes/references/plugin-installation.md +43 -0
- package/dist/skills/hyperframes/references/skill-lifecycle.md +2 -0
- package/dist/skills/hyperframes/scripts/plugin-cli.mjs +88 -0
- package/dist/skills/hyperframes/scripts/plugin-cli.test.mjs +117 -0
- package/dist/skills/hyperframes-cli/SKILL.md +2 -0
- package/dist/skills/hyperframes-cli/references/upgrade-info-misc.md +1 -0
- package/dist/skills/media-use/SKILL.md +2 -0
- package/dist/skills/media-use/references/operations.md +7 -5
- package/dist/skills/media-use/references/setup-providers.md +9 -9
- package/dist/skills/media-use/registry/localSemantic.js +7 -3
- package/dist/skills/media-use/scripts/lib/ff-binaries.mjs +37 -0
- package/dist/skills/media-use/scripts/lib/probe.mjs +3 -1
- package/dist/skills/media-use/scripts/lib/probe.test.mjs +118 -1
- package/dist/skills/media-use/scripts/lib/test-home.test.mjs +10 -0
- package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +5 -3
- package/dist/skills/media-use/scripts/resolve-plugin.test.mjs +62 -0
- package/dist/skills/media-use/scripts/resolve.mjs +45 -19
- package/dist/skills/media-use/scripts/transcribe.mjs +39 -25
- package/dist/skills/media-use/scripts/transcribe.test.mjs +112 -0
- package/dist/skills/registry/localSemantic.js +7 -3
- package/dist/studio/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
- package/dist/studio/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
- package/dist/studio/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
- package/dist/studio/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
- package/dist/studio/index.d.ts +172 -7
- package/dist/studio/index.html +1 -1
- package/dist/studio/index.js +2567 -2314
- package/dist/studio/index.js.map +1 -1
- package/dist/templates/_shared/AGENTS.md +5 -1
- package/dist/templates/_shared/CLAUDE.md +5 -1
- 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
|
-
#
|
|
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
|
|
120
|
-
|
|
121
|
-
installed, transcribe.mjs
|
|
122
|
-
`
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
4
|
+
import { sep } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
|
|
7
|
+
test("the skills test run has a home folder of its own, not the user's", () => {
|
|
8
|
+
assert.ok(realpathSync(homedir()).startsWith(realpathSync(tmpdir()) + sep));
|
|
9
|
+
assert.notEqual(homedir(), userInfo().homedir);
|
|
10
|
+
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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 }));
|