diff --git a/skills-manifest.json b/skills-manifest.json index 0d2a4ec2c..d430d372e 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,8 +46,8 @@ "files": 10 }, "media-use": { - "hash": "22b54ebaa6f93b5e", - "files": 114 + "hash": "14ea99a7f4d750cd", + "files": 116 }, "motion-graphics": { "hash": "96ed2f7d8051b009", diff --git a/skills/media-use/SKILL.md b/skills/media-use/SKILL.md index 86e863a53..c00040dcf 100644 --- a/skills/media-use/SKILL.md +++ b/skills/media-use/SKILL.md @@ -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 /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 (, )` | `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 /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. - -| 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 | +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 / 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 /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 /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. - -| 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 | +`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 (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 diff --git a/skills/media-use/scripts/lib/heygen-cli.mjs b/skills/media-use/scripts/lib/heygen-cli.mjs new file mode 100644 index 000000000..e3db8ceb2 --- /dev/null +++ b/skills/media-use/scripts/lib/heygen-cli.mjs @@ -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; +} diff --git a/skills/media-use/scripts/lib/heygen-cli.test.mjs b/skills/media-use/scripts/lib/heygen-cli.test.mjs new file mode 100644 index 000000000..c58acd6d8 --- /dev/null +++ b/skills/media-use/scripts/lib/heygen-cli.test.mjs @@ -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"); +}); diff --git a/skills/media-use/scripts/lib/heygen-search.mjs b/skills/media-use/scripts/lib/heygen-search.mjs index d873504ea..097aded62 100644 --- a/skills/media-use/scripts/lib/heygen-search.mjs +++ b/skills/media-use/scripts/lib/heygen-search.mjs @@ -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; } diff --git a/skills/media-use/scripts/lib/voice-provider.mjs b/skills/media-use/scripts/lib/voice-provider.mjs index 985ff718b..1bcc7f027 100644 --- a/skills/media-use/scripts/lib/voice-provider.mjs +++ b/skills/media-use/scripts/lib/voice-provider.mjs @@ -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 { diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 59918f533..3bf43d42a 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -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 Import a specific global-cache asset (by content sha/prefix, from --candidates) into this project --from 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; diff --git a/skills/media-use/scripts/resolve.test.mjs b/skills/media-use/scripts/resolve.test.mjs index 2f08fb310..770432792 100644 --- a/skills/media-use/scripts/resolve.test.mjs +++ b/skills/media-use/scripts/resolve.test.mjs @@ -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" } });