mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(media-use): fast heygen CLI onboarding — actionable diagnostics, --doctor, free-usage framing (#2065)
* feat(media-use): fast heygen CLI onboarding — actionable diagnostics, --doctor, free-usage framing media-use resolves bgm/sfx/image/icon (catalog), voice (TTS), and avatar video through the heygen CLI — the free-usage path. Agents hit a dead end when it's missing/unauthed. This guides them to install it fast, at the point of need. - Centralized actionable diagnostics (lib/heygen-cli.mjs): every heygen-backed resolve, on failure, prints the exact fix on stderr — not-installed (curl install one-liner), not-authenticated (heygen auth login), outdated (heygen update). Routed through heygen-search + voice-provider. stdout stays clean JSON. - resolve --doctor preflight (human + --json): checks heygen present/version/ auth, ffmpeg, ffprobe, node, a fix per gap. Exit 0 unless ffmpeg missing. - SKILL reframe: install-first callout; heygen as the free-usage gateway for bgm/image/voice/avatar-video; removed the false "degrades gracefully" claim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * fix(media-use): address #2065 review — classifier blocker, doctor contract, telemetry - Blocker: classifyHeygenError no longer treats a bare "not found" as CLI-missing (a stale voiceId → "voice not found" was sending users to reinstall a working CLI); keep only ENOENT + "command not found". Regression test added. - 401 now matches \b401\b, not any "401" substring (request IDs no longer misread). - --doctor: top-level ok requires ffmpeg AND ffprobe (matches SKILL.md); emits media_use_doctor_run telemetry; auth status queried with --json + JSON-only parse; auth timeout softened (network issue, not a false "unauthenticated"); node version gated on >= 18; version-without-semver labeled, not silently green. - Nits: install cmd uses && ; dropped the runResolveStatus alias. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * fix(media-use): require OAuth-capable heygen CLI (v0.3.0), fix auth-status probe E2E against the live free-usage backend surfaced three issues: - HEYGEN_MIN_VERSION was 0.1.6, but that CLI can't use OAuth ("heygen-cli can't use OAuth yet") — free usage needs >= v0.3.0. Bumped the floor; --doctor now also nudges `heygen update` when a newer stable exists (always-latest). - Onboarding pointed at `heygen auth login --key` (API credits / billing); the free path is `--oauth` (subscription/free credits). Fixed install + auth guidance and SKILL.md accordingly. - `heygen auth status --json` is an unknown flag on v0.3.0 (JSON is the default output) — the added --json broke auth detection. Dropped it; verified --doctor reports authenticated on a real free (OAuth) account. Tests assert against the exported message constants instead of brittle literals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * fix(media-use): address #2065 review nits — one root cause on old CLI, floor policy - --doctor skips the auth check when the version check fails (below v0.3.0): an old CLI's auth probe fails for the same root cause, so users no longer see two errors ("outdated" + "not authenticated") — one root cause, one fix. - Comment links the auth-status probe's JSON-default assumption to HEYGEN_MIN_VERSION >= 0.3.0 so the floor isn't silently lowered later. - SKILL.md states the uniform v0.3.0 requirement (nudged even for API-key use). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * fix(media-use): doctor prints one heygen row per fact The 'heygen on PATH' and 'heygen version' checks both rendered their detail as `heygen v0.3.0`, so --doctor printed two byte-identical green lines. Make the PATH row report presence ("heygen found on PATH") and let the version row own the version string — one row per fact, no duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3b93f516b4
commit
cdb8d736f1
+71
-53
@@ -7,24 +7,38 @@ description: Agent Media OS, the single skill for every media need in a HyperFra
|
||||
|
||||
The media OS for HyperFrames: resolve · generate · operate · remember, every media type, one skill, zero context noise.
|
||||
|
||||
## Setup — install heygen first (free-usage path)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://static.heygen.ai/cli/install.sh | bash
|
||||
heygen update # free usage needs the OAuth-capable CLI (v0.3.0+)
|
||||
heygen auth login --oauth # OAuth = free subscription credits; --api-key bills API credits
|
||||
```
|
||||
|
||||
This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). **media-use requires heygen >= v0.3.0 uniformly** (the OAuth free-usage path needs it), so `--doctor` nudges older CLIs to update even for API-key-only use. Before resolving anything, verify setup with:
|
||||
|
||||
```bash
|
||||
node <SKILL_DIR>/scripts/resolve.mjs --doctor
|
||||
```
|
||||
|
||||
## What it owns (the gaps HyperFrames leaves)
|
||||
|
||||
HyperFrames owns media _playback_; media-use owns everything else. Each row is enforced by `scripts/lib/coverage.test.mjs` so the claim can't rot.
|
||||
|
||||
| HyperFrames gap | media-use owns it via |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) |
|
||||
| No third-party brand logos | `resolve --type logo` (svgl → simple-icons → GitHub org avatar → domain favicon) |
|
||||
| No voice / audio generation | `resolve --type voice` + the audio engine (`audio/scripts/audio.mjs`) |
|
||||
| Scattered/duplicated audio engine | one consolidated engine under `audio/` (hyperframes-media retired) |
|
||||
| No agent media-ops (cut/reframe/transform) | `references/operations.md` + `resolve --from` to register outputs |
|
||||
| No transcript-driven cutting | `scripts/transcript-cut.mjs` compiles word-timestamp edits into cut lists |
|
||||
| No auto-duck / publish loudness | `scripts/audio-duck.mjs` + `references/operations.md` loudnorm/sidechain recipes |
|
||||
| No cross-project memory | global content-addressed cache + auto-promote (`~/.media`) |
|
||||
| No color-grade authoring | `resolve --type grade` emits a paste-ready `data-color-grading` block; `resolve --type lut` freezes validated `.cube` files |
|
||||
| No image generation | RAM-graded local mflux (FLUX) via `scripts/lib/mflux-provider.mjs`, codex `image_gen` upsell (`scripts/lib/codex-provider.mjs`) |
|
||||
| No video generation | spec-gated local LTX (`videogen` in `scripts/lib/local-models.mjs`); `heygen video create` avatar upsell |
|
||||
| Weak local-model defaults | free-usage HeyGen first (TTS, bg-removal) via the `heygen` CLI; local open-source only as an opt-out fallback (`scripts/lib/local-run.mjs`) |
|
||||
| HyperFrames gap | media-use owns it via |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) |
|
||||
| No third-party brand logos | `resolve --type logo` (svgl → simple-icons → GitHub org avatar → domain favicon) |
|
||||
| No voice / audio generation | `resolve --type voice` (HeyGen TTS free-usage path; optional local Kokoro) + the audio engine (`audio/scripts/audio.mjs`) |
|
||||
| Scattered/duplicated audio engine | one consolidated engine under `audio/` (hyperframes-media retired) |
|
||||
| No agent media-ops (cut/reframe/transform) | `references/operations.md` + `resolve --from` to register outputs |
|
||||
| No transcript-driven cutting | `scripts/transcript-cut.mjs` compiles word-timestamp edits into cut lists |
|
||||
| No auto-duck / publish loudness | `scripts/audio-duck.mjs` + `references/operations.md` loudnorm/sidechain recipes |
|
||||
| No cross-project memory | global content-addressed cache + auto-promote (`~/.media`) |
|
||||
| No color-grade authoring | `resolve --type grade` emits a paste-ready `data-color-grading` block; `resolve --type lut` freezes validated `.cube` files |
|
||||
| No image generation | RAM-graded local mflux (FLUX) via `scripts/lib/mflux-provider.mjs`, codex `image_gen` upsell (`scripts/lib/codex-provider.mjs`) |
|
||||
| No video generation | HeyGen avatar video free-usage path; optional spec-gated local LTX (`videogen` in `scripts/lib/local-models.mjs`) |
|
||||
| Weak local-model defaults | HeyGen free-usage path via the `heygen` CLI; local open-source tools only as opt-in alternatives (`scripts/lib/local-run.mjs`) |
|
||||
|
||||
## When to use
|
||||
|
||||
@@ -69,7 +83,7 @@ Returns one line: `resolved <id> → <path> (<type>, <metadata>)`
|
||||
| `image` | Photos, backgrounds | HeyGen asset search (75k+ vectors) |
|
||||
| `icon` | Icons, symbols | HeyGen asset search (type=icon) |
|
||||
| `logo` | Official brand marks | svgl → simple-icons → GitHub org avatar → domain favicon |
|
||||
| `voice` | TTS voiceover | HeyGen TTS (OAuth free allowance); Kokoro local fallback |
|
||||
| `voice` | TTS voiceover | HeyGen TTS free-usage path; optional local Kokoro |
|
||||
| `grade` | HyperFrames color-grading blocks | Core preset → look index params/CDN LUT → deterministic cube |
|
||||
| `lut` | Reusable `.cube` LUT files | Look index params/CDN LUT → deterministic cube |
|
||||
|
||||
@@ -120,6 +134,7 @@ node <SKILL_DIR>/scripts/resolve.mjs --type lut --intent "teal orange blockbuste
|
||||
| `--local-only` | Offline: skip every network provider (cache + local only) |
|
||||
| `--provider` | Force one generator (e.g. `codex`, `mflux`, `kokoro`, `heygen`) |
|
||||
| `--adopt` | Bulk-import existing assets/ into manifest |
|
||||
| `--doctor` | Check local CLI dependencies; no manifest changes |
|
||||
| `--json` | Output JSON instead of one-line result |
|
||||
|
||||
## Reuse before you resolve
|
||||
@@ -225,35 +240,36 @@ node skills/media-use/scripts/lib/cube-validate.mjs .media/luts/lut_001.cube
|
||||
## Providers
|
||||
|
||||
media-use holds no keys; every external tool owns its auth. Generation is
|
||||
local-first with a cloud upsell where one helps. `resolve` spec-checks
|
||||
AVAILABLE RAM and auto-picks the best local model that fits (a RAM-graded
|
||||
ladder, `describeModelLadder`); the agent can see the ladder and override.
|
||||
centered on the HeyGen CLI free-usage path. Install and authenticate `heygen`
|
||||
before resolving bgm/sfx/image/icon/voice/avatar-video. Local tools are opt-in
|
||||
alternatives where they exist: mflux for image, Kokoro for voice, Parakeet for
|
||||
transcription, and LTX for local video generation. `resolve` spec-checks
|
||||
AVAILABLE RAM for those local ladders (`describeModelLadder`); the agent can
|
||||
see the ladder and override.
|
||||
|
||||
| Type | Provider (in order) |
|
||||
| ------------- | --------------------------------------------------------------------------------------- |
|
||||
| bgm/sfx | heygen catalog (free) |
|
||||
| image | heygen search, then local mflux (best FLUX for your RAM), then codex `image_gen` upsell |
|
||||
| voice | **heygen tts** via OAuth/web-plan allowance, then local **Kokoro** fallback |
|
||||
| icon | heygen asset search |
|
||||
| logo | svgl, then simple-icons, then GitHub org avatar, then domain favicon (all free) |
|
||||
| grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback |
|
||||
| video (local) | local LTX (`videogen` ladder); `heygen video create` avatar upsell |
|
||||
| Type | Provider / path |
|
||||
| --------- | -------------------------------------------------------------------------------- |
|
||||
| bgm/sfx | heygen catalog free-usage path |
|
||||
| image | heygen search free-usage path; optional local mflux; codex `image_gen` upsell |
|
||||
| voice | heygen tts free-usage path; optional local **Kokoro** (free, on-device) |
|
||||
| icon | heygen asset search free-usage path |
|
||||
| logo | svgl, then simple-icons, then GitHub org avatar, then domain favicon (all free) |
|
||||
| grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback |
|
||||
| video | heygen avatar video free-usage path; optional local LTX (`videogen` ladder) |
|
||||
|
||||
Local Kokoro (voice), mflux (image), and LTX (video) run on-device (free,
|
||||
private, offline once cached). Credentialed HeyGen TTS should be tried first for
|
||||
voice so OAuth CLI users consume the free web-plan allowance (10 min/month);
|
||||
API-key usage and overage follow the user's HeyGen billing path. Paid/cloud
|
||||
upsells remain the `codex` CLI (ChatGPT sub) for a better image and the
|
||||
`heygen` CLI for avatar video. Cost rule (X4): the agent confirms before an
|
||||
agent-initiated paid call; a user-requested one just runs.
|
||||
private, offline once cached). The `codex` CLI remains the ChatGPT-sub image
|
||||
upsell. Cost rule (X4): the agent confirms before an agent-initiated paid call;
|
||||
a user-requested one just runs.
|
||||
|
||||
To force a specific generator (e.g. a user says "make this image with codex"),
|
||||
pass `--provider codex`: it pins resolution to that provider and skips the
|
||||
free-first default. See `references/operations.md` for the RAM ladders and
|
||||
upsell recipes.
|
||||
free-usage default. See `references/operations.md` for the RAM ladders and
|
||||
provider recipes.
|
||||
|
||||
`--local-only` skips every network provider, including the free HeyGen ones,
|
||||
leaving the project + global cache and any local provider.
|
||||
leaving the project + global cache and any installed local provider. For
|
||||
HeyGen-only types, that means no fresh resolve.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -319,7 +335,7 @@ node <SKILL_DIR>/audio/scripts/audio.mjs --request ./audio_request.json --out ./
|
||||
|
||||
- **Request** `{ provider?, lang?, speed?, lines: [{ id, text, sfx?: [names] }], bgm: { mode?, query?, prompt? } }`: `id` joins each line back to your model; `bgm.mode` = `retrieve | generate | none` (omit for auto). `--only tts,bgm,sfx` runs a subset and merges into an existing `--out`.
|
||||
- **Output** `audio_meta.json` (id-keyed): `voices[].{path,duration_s,words[]}` (word timestamps for captions), `sfx[]`, `bgm`, `total_duration_s`.
|
||||
- **Auto-degrades on one switch**: HeyGen credential present → HeyGen TTS + music/SFX retrieval; absent → ElevenLabs/Kokoro TTS, Lyria/MusicGen BGM generation, and the bundled SFX library (no credential needed).
|
||||
- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music/SFX retrieval. Local/provider-specific generators are explicit alternatives where installed; run `node <SKILL_DIR>/scripts/resolve.mjs --doctor` before assuming retrieval or TTS will work.
|
||||
- If BGM took the generate path (`bgm_pending: true`), run `audio/scripts/wait-bgm.mjs` before final render.
|
||||
|
||||
Single-shot helpers: `audio/scripts/heygen-tts.mjs` (one voice file). Transcription / background removal / captions use the `hyperframes` CLI (`transcribe`, `remove-background`), see the per-topic guides in `audio/references/` (`tts.md`, `bgm.md`, `sfx.md`, `transcribe.md`, `remove-background.md`, `captions/`).
|
||||
@@ -335,30 +351,32 @@ cache.
|
||||
|
||||
## CLI tools used (what to run, and how to enable each)
|
||||
|
||||
`resolve` auto-cascades; each provider shells one CLI. Local tools are OPT-IN:
|
||||
if a local tool is absent, resolve degrades gracefully to the free/cloud path,
|
||||
so nothing here is strictly required except `ffmpeg`/`ffprobe`. Install a local
|
||||
tool to unlock its free, private, on-device path. media-use holds no keys.
|
||||
`resolve` auto-cascades; each provider shells one CLI. HeyGen is the
|
||||
free-usage path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar
|
||||
video, so those capabilities need `heygen` installed and authenticated. Local
|
||||
tools are OPT-IN alternatives where they exist; install one to unlock its free,
|
||||
private, on-device path instead of or ahead of HeyGen for that type. Only
|
||||
`ffmpeg`/`ffprobe` are strictly required for the tool to run at all.
|
||||
|
||||
| Tool | Serves | Install |
|
||||
| ------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
|
||||
| `heygen` | catalog (bgm/sfx/image/icon), TTS + avatar upsell | `curl -fsSL https://static.heygen.ai/cli/install.sh \| bash` then `heygen auth login --oauth` or API-key login (needs >= v0.1.6) |
|
||||
| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
|
||||
| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
|
||||
| `parakeet-mlx` | local transcription (default ASR, best) | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
|
||||
| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
|
||||
| `npx hyperframes` | Kokoro TTS (voice), whisper.cpp (transcribe fallback), remove-background | bundled with the hyperframes CLI |
|
||||
| Tool | Serves | Install |
|
||||
| ------------------ | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
|
||||
| `heygen` | catalog (bgm/sfx/image/icon) + TTS (voice) + avatar video — the free-usage path | `curl -fsSL https://static.heygen.ai/cli/install.sh \| bash` then `heygen auth login --oauth` (needs >= v0.3.0) |
|
||||
| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
|
||||
| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
|
||||
| `parakeet-mlx` | local transcription (default ASR, best) | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
|
||||
| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
|
||||
| `npx hyperframes` | Kokoro TTS (voice), whisper.cpp (transcribe fallback), remove-background | bundled with the hyperframes CLI |
|
||||
|
||||
The RAM-graded local-model shortlist + exact per-tier install/invoke lives in
|
||||
`scripts/lib/local-models.mjs` (the agent can read `describeModelLadder(cap, specs)`
|
||||
to see which model fits this machine). Without a tool on PATH, its provider
|
||||
prints a one-line diagnostic to stderr and resolve falls through to the next
|
||||
provider (e.g. no `mflux` -> codex image upsell; no `parakeet-mlx` -> whisper.cpp).
|
||||
prints a one-line diagnostic to stderr and resolve falls through where another
|
||||
provider exists (e.g. no `mflux` -> codex image upsell; no `parakeet-mlx` -> whisper.cpp).
|
||||
|
||||
`heygen asset search` is a pre-launch command hidden from `heygen --help`, but it
|
||||
runs; providers tag requests with the allowlisted `X-HeyGen-Client-Source` header
|
||||
(v0.1.6+).
|
||||
(v0.3.0+).
|
||||
|
||||
## Telemetry
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it
|
||||
// ("heygen-cli can't use OAuth yet"), and OAuth is what the free-usage path
|
||||
// needs — so anything below this can't authenticate for free usage at all.
|
||||
export const HEYGEN_MIN_VERSION = "0.3.0";
|
||||
// Free-usage path is OAuth (`--oauth` → subscription/free credits); `--api-key`
|
||||
// bills API credits, so the onboarding steers to OAuth.
|
||||
export const HEYGEN_INSTALL_COMMAND =
|
||||
"curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth";
|
||||
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
|
||||
export const HEYGEN_UPDATE_COMMAND = "heygen update";
|
||||
|
||||
export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: ${HEYGEN_INSTALL_COMMAND}`;
|
||||
export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
|
||||
export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`;
|
||||
|
||||
const ACTIONABLE_MESSAGES = new Set([
|
||||
HEYGEN_NOT_FOUND_MESSAGE,
|
||||
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
|
||||
HEYGEN_OUTDATED_MESSAGE,
|
||||
]);
|
||||
|
||||
export function classifyHeygenError(err) {
|
||||
const detail = heygenErrorDetail(err);
|
||||
const text = [err?.stderr, err?.stdout, err?.message, detail]
|
||||
.map((value) => textOf(value))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
// Only ENOENT (spawn of a missing binary) or a shell's "command not found"
|
||||
// mean the CLI itself is absent. A bare "not found" would misfire on the CLI's
|
||||
// own resource errors (e.g. a stale voiceId → "voice not found"), whose message
|
||||
// embeds the `heygen ...` command line — sending users to reinstall a CLI they
|
||||
// just ran successfully. Keep this narrow.
|
||||
if (err?.code === "ENOENT" || lower.includes("command not found")) {
|
||||
return HEYGEN_NOT_FOUND_MESSAGE;
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("unauthorized") ||
|
||||
lower.includes("unauthenticated") ||
|
||||
// \b401\b, not a bare "401" substring — otherwise request IDs (req-401abc),
|
||||
// URLs, and retry-after headers would misclassify as an auth failure.
|
||||
/\b401\b/.test(lower) ||
|
||||
lower.includes("not logged in") ||
|
||||
lower.includes("no api key") ||
|
||||
lower.includes("missing api key") ||
|
||||
lower.includes("invalid api key") ||
|
||||
lower.includes("login required") ||
|
||||
lower.includes("auth required") ||
|
||||
lower.includes("authentication required")
|
||||
) {
|
||||
return HEYGEN_NOT_AUTHENTICATED_MESSAGE;
|
||||
}
|
||||
|
||||
const version = firstSemver(text);
|
||||
if (version && versionLessThan(version, HEYGEN_MIN_VERSION)) {
|
||||
return HEYGEN_OUTDATED_MESSAGE;
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
export function reportHeygenFailure(err, context) {
|
||||
const message = classifyHeygenError(err);
|
||||
if (ACTIONABLE_MESSAGES.has(message)) {
|
||||
console.error(message);
|
||||
} else {
|
||||
console.error(`media-use: \`${context}\` failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function firstSemver(text) {
|
||||
const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/);
|
||||
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
|
||||
}
|
||||
|
||||
export function versionLessThan(version, minimum) {
|
||||
const left = versionParts(version);
|
||||
const right = versionParts(minimum);
|
||||
if (!left || !right) return false;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (left[i] < right[i]) return true;
|
||||
if (left[i] > right[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function heygenErrorDetail(err) {
|
||||
return textOf(err?.stderr) || textOf(err?.stdout) || err?.message || String(err);
|
||||
}
|
||||
|
||||
function textOf(value) {
|
||||
return value == null ? "" : String(value).trim();
|
||||
}
|
||||
|
||||
function versionParts(version) {
|
||||
const match = String(version || "").match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
||||
return match ? match.slice(1).map((part) => Number.parseInt(part, 10)) : null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
classifyHeygenError,
|
||||
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
|
||||
HEYGEN_NOT_FOUND_MESSAGE,
|
||||
HEYGEN_OUTDATED_MESSAGE,
|
||||
} from "./heygen-cli.mjs";
|
||||
|
||||
test("classifies ENOENT-style missing heygen errors with install instructions", () => {
|
||||
const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" });
|
||||
|
||||
assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
|
||||
});
|
||||
|
||||
test("classifies auth failures with login instructions", () => {
|
||||
const message = classifyHeygenError({ stderr: Buffer.from("Error: not logged in") });
|
||||
|
||||
assert.equal(message, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
|
||||
});
|
||||
|
||||
test("classifies a real 401 as auth, but not a bare 401 substring in prose", () => {
|
||||
assert.equal(
|
||||
classifyHeygenError({ stderr: Buffer.from("HTTP 401 Unauthorized") }),
|
||||
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
|
||||
);
|
||||
// A request id that merely contains "401" must NOT read as an auth failure.
|
||||
const noise = classifyHeygenError({ stderr: Buffer.from("upload failed (request req-401abc)") });
|
||||
assert.notEqual(noise, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
|
||||
});
|
||||
|
||||
test("classifies old heygen versions with update instructions", () => {
|
||||
const message = classifyHeygenError({
|
||||
stderr: Buffer.from("heygen v0.1.5 does not support --headers"),
|
||||
});
|
||||
|
||||
assert.equal(message, HEYGEN_OUTDATED_MESSAGE);
|
||||
});
|
||||
|
||||
test("does not misclassify a resource 'not found' error as a missing CLI", () => {
|
||||
// A stale voiceId makes `heygen voice speech create` fail with "voice not
|
||||
// found"; the error message embeds the `heygen ...` command line. This must
|
||||
// pass through as detail, not send the user to reinstall a working CLI.
|
||||
const message = classifyHeygenError({
|
||||
stderr: Buffer.from("Error: voice not found (id: stale-123)"),
|
||||
message: "Command failed: heygen voice speech create --voice stale-123",
|
||||
});
|
||||
|
||||
assert.notEqual(message, HEYGEN_NOT_FOUND_MESSAGE);
|
||||
assert.equal(message, "Error: voice not found (id: stale-123)");
|
||||
});
|
||||
|
||||
test("classifies a shell 'command not found' as a missing CLI", () => {
|
||||
const message = classifyHeygenError({ stderr: Buffer.from("bash: heygen: command not found") });
|
||||
|
||||
assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
|
||||
});
|
||||
|
||||
test("passes through unrelated errors", () => {
|
||||
const message = classifyHeygenError({
|
||||
stderr: Buffer.from("rate limit exceeded"),
|
||||
message: "Command failed",
|
||||
});
|
||||
|
||||
assert.equal(message, "rate limit exceeded");
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { reportHeygenFailure } from "./heygen-cli.mjs";
|
||||
|
||||
export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = {}) {
|
||||
// execFileSync with an argv array (no shell), so query/type/etc. are passed as
|
||||
// literal arguments — no quoting tricks, no command injection. subcommand is a
|
||||
// hardcoded multi-word string (e.g. "audio sounds list"), split into tokens.
|
||||
// Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.1.6).
|
||||
// Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.3.0).
|
||||
const args = [
|
||||
"--headers",
|
||||
"X-HeyGen-Client-Source: media-use",
|
||||
@@ -28,8 +29,7 @@ export function heygenSearch(subcommand, query, { type, limit = 5, minScore } =
|
||||
} catch (err) {
|
||||
// Don't swallow a broken command / auth failure as "no results" — that turns
|
||||
// a typo or expired key into a silent dead end. Surface it, then give up.
|
||||
const detail = err.stderr?.toString().trim() || err.stdout?.toString().trim() || err.message;
|
||||
console.error(`media-use: \`heygen ${subcommand}\` failed: ${detail}`);
|
||||
reportHeygenFailure(err, `heygen ${subcommand}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { reportHeygenFailure } from "./heygen-cli.mjs";
|
||||
|
||||
// Voice / TTS generation via the HeyGen CLI — the only external CLI media-use
|
||||
// shells (CLI-only invariant: media-use holds no keys; the CLI owns auth).
|
||||
// Flags verified against `heygen voice speech create --help` (v0.1.6).
|
||||
// Flags verified against `heygen voice speech create --help` (v0.3.0).
|
||||
|
||||
function runJson(bin, argv, label) {
|
||||
let out;
|
||||
@@ -13,9 +14,7 @@ function runJson(bin, argv, label) {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`media-use: \`${bin}\` ${label} failed: ${err.stderr?.toString().trim() || err.message}`,
|
||||
);
|
||||
reportHeygenFailure(err, `${bin} ${label}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, statSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { resolve, join, extname, basename } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
@@ -21,6 +22,19 @@ import {
|
||||
isLibraryLutOfflineMiss,
|
||||
matchColorLook,
|
||||
} from "./lib/lut-preset-provider.mjs";
|
||||
import {
|
||||
HEYGEN_AUTH_COMMAND,
|
||||
HEYGEN_INSTALL_COMMAND,
|
||||
HEYGEN_MIN_VERSION,
|
||||
HEYGEN_UPDATE_COMMAND,
|
||||
firstSemver,
|
||||
versionLessThan,
|
||||
} from "./lib/heygen-cli.mjs";
|
||||
|
||||
// resolve shells `fetch`/`freezeUrl` and modern ESM; 18 is the floor where those
|
||||
// exist without flags. Named so the --doctor node check verifies something real
|
||||
// (O2). Declared before the top-level `--doctor` branch that calls runDoctor().
|
||||
const MIN_NODE_VERSION = "18.0.0";
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
@@ -30,6 +44,7 @@ const { values: args } = parseArgs({
|
||||
project: { type: "string", short: "p", default: "." },
|
||||
adopt: { type: "boolean", default: false },
|
||||
candidates: { type: "boolean", default: false },
|
||||
doctor: { type: "boolean", default: false },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
reuse: { type: "string" },
|
||||
from: { type: "string" },
|
||||
@@ -59,6 +74,7 @@ Options:
|
||||
--adopt Adopt all existing assets/ files into the manifest
|
||||
--candidates List reusable assets (project + global cache) for --type; no
|
||||
download, no mutation. Read them and decide reuse yourself.
|
||||
--doctor Check local CLI dependencies; no manifest changes.
|
||||
--reuse <sha> Import a specific global-cache asset (by content sha/prefix,
|
||||
from --candidates) into this project
|
||||
--from <file> Freeze a local file or direct public URL (ingest)
|
||||
@@ -99,6 +115,25 @@ if (args.candidates || args["dry-run"]) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args.doctor) {
|
||||
const doctor = runDoctor();
|
||||
const failed = doctor.checks.filter((check) => !check.ok);
|
||||
// Non-PII: instrument the exact question the feature exists to answer — how
|
||||
// often is --doctor run and which check fails most. Awaited so a short-lived
|
||||
// run flushes before exit.
|
||||
await track("media_use_doctor_run", {
|
||||
ok: doctor.ok,
|
||||
checks_failed: failed.length,
|
||||
failed: failed.map((check) => check.name),
|
||||
});
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({ ok: doctor.ok, checks: doctor.checks }));
|
||||
} else {
|
||||
printDoctor(doctor.checks);
|
||||
}
|
||||
process.exit(doctor.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
// Reuse: import a specific global-cache asset (by content sha/prefix, taken
|
||||
// from --candidates) into this project. `!== undefined` so an empty --reuse ""
|
||||
// still routes here (and gets a clear empty-sha error) instead of falling
|
||||
@@ -709,6 +744,193 @@ async function showCandidates() {
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort latest stable CLI tag from the CDN (the install script's source of
|
||||
// truth). null on any failure (offline, no curl) — treated as "unknown", never fatal.
|
||||
function latestHeygenStable() {
|
||||
const probe = runCommand("curl", [
|
||||
"-fsSL",
|
||||
"--max-time",
|
||||
"4",
|
||||
"https://static.heygen.ai/cli/stable",
|
||||
]);
|
||||
return probe.status === 0 ? firstSemver(commandText(probe)) : null;
|
||||
}
|
||||
|
||||
function heygenAuthCheck() {
|
||||
// `heygen auth status` already emits JSON by default (only `--human` opts out
|
||||
// to a table) — there is no `--json`/`--output` flag; passing one errors with
|
||||
// "unknown flag". emailFromAuthStatus parses that default JSON.
|
||||
// NOTE: JSON-by-default is a v0.3.0 behavior — this probe assumes it, which
|
||||
// HEYGEN_MIN_VERSION >= 0.3.0 (+ the version gate above) guarantees. If that
|
||||
// floor is ever lowered, auth detection on an older CLI would silently break.
|
||||
const authProbe = runCommand("heygen", ["auth", "status"]);
|
||||
// spawnSync sets .error/.signal on a timeout or spawn failure (status then
|
||||
// null). A stalled auth endpoint (transient network/DNS) must not be reported
|
||||
// as an authoritative "not authenticated" with a re-login fix.
|
||||
const timedOut = authProbe.error?.code === "ETIMEDOUT" || authProbe.signal != null;
|
||||
const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null;
|
||||
return {
|
||||
name: "heygen authenticated",
|
||||
ok: !!email,
|
||||
detail: email
|
||||
? `heygen authenticated as ${email}`
|
||||
: timedOut
|
||||
? "heygen auth status timed out — possible network issue, not proof of sign-out"
|
||||
: "heygen not authenticated",
|
||||
fix: email ? "" : timedOut ? "check network, then re-run --doctor" : HEYGEN_AUTH_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
function runDoctor() {
|
||||
const checks = [];
|
||||
const heygenVersionProbe = runCommand("heygen", ["--version"]);
|
||||
const heygenOnPath = heygenVersionProbe.status === 0;
|
||||
const heygenVersionText = commandText(heygenVersionProbe);
|
||||
const heygenVersion = firstSemver(heygenVersionText);
|
||||
|
||||
checks.push({
|
||||
name: "heygen on PATH",
|
||||
ok: heygenOnPath,
|
||||
// Just "is the binary here" — the version row below owns the version string,
|
||||
// so this row must not also render `heygen v0.3.0` (two byte-identical lines).
|
||||
detail: heygenOnPath ? "heygen found on PATH" : "heygen not found",
|
||||
fix: heygenOnPath ? "" : HEYGEN_INSTALL_COMMAND,
|
||||
});
|
||||
|
||||
if (!heygenOnPath) {
|
||||
checks.push({
|
||||
name: "heygen version",
|
||||
ok: false,
|
||||
detail: "heygen version unavailable",
|
||||
fix: HEYGEN_INSTALL_COMMAND,
|
||||
});
|
||||
checks.push({
|
||||
name: "heygen authenticated",
|
||||
ok: false,
|
||||
detail: "heygen auth status unavailable",
|
||||
fix: HEYGEN_INSTALL_COMMAND,
|
||||
});
|
||||
} else if (heygenVersion) {
|
||||
const versionOk = !versionLessThan(heygenVersion, HEYGEN_MIN_VERSION);
|
||||
// Keep it latest: even when the installed version clears the floor, nudge
|
||||
// `heygen update` if a newer stable exists. Best-effort — silently skipped
|
||||
// when the CDN is unreachable, so it never blocks the check.
|
||||
const latest = versionOk ? latestHeygenStable() : null;
|
||||
const behind = latest && versionLessThan(heygenVersion, latest);
|
||||
checks.push({
|
||||
name: "heygen version",
|
||||
ok: versionOk,
|
||||
detail: versionOk
|
||||
? `heygen v${heygenVersion}${behind ? ` (latest v${latest} available)` : ""}`
|
||||
: `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`,
|
||||
fix: versionOk ? (behind ? HEYGEN_UPDATE_COMMAND : "") : HEYGEN_UPDATE_COMMAND,
|
||||
});
|
||||
|
||||
// Below the OAuth-capable floor the auth probe fails for the SAME root cause
|
||||
// (an old CLI can't OAuth and doesn't emit JSON auth status), which would
|
||||
// read as a confusing second "not authenticated" error. Skip it — one root
|
||||
// cause, one fix.
|
||||
checks.push(
|
||||
versionOk
|
||||
? heygenAuthCheck()
|
||||
: {
|
||||
name: "heygen authenticated",
|
||||
ok: false,
|
||||
detail: "skipped — update heygen first",
|
||||
fix: HEYGEN_UPDATE_COMMAND,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Fail-open: heygen ran but printed no semver (dev/stripped build). We can't
|
||||
// verify the version, so we don't block on it — but say so rather than a bare
|
||||
// green check that implies a real version comparison happened.
|
||||
checks.push({
|
||||
name: "heygen version",
|
||||
ok: true,
|
||||
detail: "heygen present; version unverifiable (no semver in --version output)",
|
||||
fix: "",
|
||||
});
|
||||
|
||||
checks.push(heygenAuthCheck());
|
||||
}
|
||||
|
||||
const ffmpegProbe = runCommand("ffmpeg", ["-version"]);
|
||||
checks.push({
|
||||
name: "ffmpeg on PATH",
|
||||
ok: ffmpegProbe.status === 0,
|
||||
detail: ffmpegProbe.status === 0 ? firstLine(ffmpegProbe.stdout) : "ffmpeg not found",
|
||||
fix: ffmpegProbe.status === 0 ? "" : "brew install ffmpeg",
|
||||
});
|
||||
|
||||
const ffprobeProbe = runCommand("ffprobe", ["-version"]);
|
||||
checks.push({
|
||||
name: "ffprobe on PATH",
|
||||
ok: ffprobeProbe.status === 0,
|
||||
detail: ffprobeProbe.status === 0 ? firstLine(ffprobeProbe.stdout) : "ffprobe not found",
|
||||
fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg",
|
||||
});
|
||||
|
||||
const nodeOk = !versionLessThan(process.versions.node, MIN_NODE_VERSION);
|
||||
checks.push({
|
||||
name: "node version",
|
||||
ok: nodeOk,
|
||||
detail: `${process.version} (need >= v${MIN_NODE_VERSION})`,
|
||||
fix: nodeOk ? "" : `upgrade Node to >= v${MIN_NODE_VERSION}`,
|
||||
});
|
||||
|
||||
// ffmpeg AND ffprobe are both strictly required (see SKILL.md); the exit code
|
||||
// must reflect that so a script gating on `--doctor` doesn't pass with ffprobe
|
||||
// missing and then break at the first probe call.
|
||||
const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH");
|
||||
const ffprobe = checks.find((check) => check.name === "ffprobe on PATH");
|
||||
return { ok: !!ffmpeg?.ok && !!ffprobe?.ok, checks };
|
||||
}
|
||||
|
||||
function printDoctor(checks) {
|
||||
const heygenChecks = new Set(["heygen on PATH", "heygen version", "heygen authenticated"]);
|
||||
for (const check of checks) {
|
||||
const prefix = check.ok ? "✓" : "✗";
|
||||
const freePath = heygenChecks.has(check.name)
|
||||
? " — free-usage path: bgm/image/voice/avatar-video"
|
||||
: "";
|
||||
const fix = check.ok || !check.fix ? "" : ` — fix: ${check.fix}`;
|
||||
console.log(`${prefix} ${check.detail}${freePath}${fix}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(bin, argv) {
|
||||
return spawnSync(bin, argv, {
|
||||
encoding: "utf8",
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
function commandText(result) {
|
||||
return [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
||||
}
|
||||
|
||||
function firstLine(text) {
|
||||
return (
|
||||
String(text || "")
|
||||
.trim()
|
||||
.split(/\r?\n/)[0] || ""
|
||||
);
|
||||
}
|
||||
|
||||
function emailFromAuthStatus(text) {
|
||||
// JSON only (auth status emits JSON by default). No prose regex fallback: a
|
||||
// human-format body like "Session expired. Contact support@heygen.ai" would
|
||||
// otherwise report the user as authenticated as support@heygen.ai.
|
||||
const trimmed = String(text || "").trim();
|
||||
if (!trimmed.startsWith("{")) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parsed?.data?.email || parsed?.email || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function reuseGlobal(shaArg) {
|
||||
const projectDir = resolve(args.project);
|
||||
const type = args.type;
|
||||
|
||||
@@ -330,6 +330,39 @@ test("--json returns error JSON on stub provider failure", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("--doctor --json reports dependency checks and top-level ok requires ffmpeg and ffprobe", () => {
|
||||
const result = spawnResolve(["--doctor", "--json"]);
|
||||
assert.match(result.stdout.trim(), /^\{/);
|
||||
assert.equal(result.stderr, "");
|
||||
assert.ok(result.status === 0 || result.status === 1);
|
||||
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
assert.ok(Array.isArray(parsed.checks));
|
||||
|
||||
const expected = [
|
||||
"heygen on PATH",
|
||||
"heygen version",
|
||||
"heygen authenticated",
|
||||
"ffmpeg on PATH",
|
||||
"ffprobe on PATH",
|
||||
"node version",
|
||||
];
|
||||
const byName = new Map(parsed.checks.map((check) => [check.name, check]));
|
||||
for (const name of expected) {
|
||||
assert.ok(byName.has(name), `missing check: ${name}`);
|
||||
const check = byName.get(name);
|
||||
assert.equal(typeof check.ok, "boolean", `${name}.ok`);
|
||||
assert.equal(typeof check.detail, "string", `${name}.detail`);
|
||||
assert.ok("fix" in check, `${name}.fix`);
|
||||
}
|
||||
|
||||
const ffmpeg = byName.get("ffmpeg on PATH");
|
||||
const ffprobe = byName.get("ffprobe on PATH");
|
||||
const strictOk = ffmpeg.ok && ffprobe.ok;
|
||||
assert.equal(parsed.ok, strictOk);
|
||||
assert.equal(result.status, strictOk ? 0 : 1);
|
||||
});
|
||||
|
||||
test("one-line output format matches contract", () => {
|
||||
setup();
|
||||
const record = makeRecord({ provenance: { prompt: "format test", provider: "test" } });
|
||||
|
||||
Reference in New Issue
Block a user