feat(media-use): add video generation (HeyGen avatar-video + local LTX fallback) (#2614)

* fix(media-use): tag HeyGen TTS generation with attribution header

Centralizes the X-HeyGen-Client-Source header into HEYGEN_CLIENT_SOURCE_ARGV
in heygen-cli.mjs and reuses it in heygen-search.mjs (dropping the duplicated
inline literal) so voice-provider's `voice speech create` call carries it too.
The generation call was previously untagged, making media-use TTS usage
invisible in HeyGen's billing/analytics warehouse; the read-only `voice list`
discovery call intentionally stays untagged.

* feat(media-use): add local LTX video generate provider

* feat(media-use): add HeyGen avatar-video generate provider

* feat(media-use): register video as a real provider type

* docs(media-use): document the wired video type and full HeyGen tagging coverage

resolve --type video is now the default path (HeyGen avatar video first,
local LTX fallback, sign-in nudge on auth failure) instead of a manual
recipe; correct the claim that only search requests are tagged now that
TTS and avatar-video generation carry the attribution header too.

* fix(media-use): wire --avatar-id/--voice-id CLI flags and close video-provider auth/cache gaps

- resolve.mjs never implemented the --avatar-id/--voice-id override that
  operations.md documented, so following the docs crashed with
  ERR_PARSE_ARGS_UNKNOWN_OPTION; wire the flags through to ctx.
- defaultAvatarId/defaultStarfishVoiceId cached a failed discovery lookup
  as a permanent null, disabling heygen.video after one transient miss;
  cache only a truthy id, matching the same fix in voice-provider.mjs's
  defaultVoiceId.
- the avatar-video onboarding nudge only fired on a video-create failure,
  never when avatar/voice discovery itself was unauthenticated (the
  common unauthenticated case) -- propagate the discovery failure reason
  so onboarding fires either way.
- dedupe the CLI-shelling JSON helper (heygen-cli.mjs's new runHeygenJson)
  and the local-model argv-template builder (local-models.mjs's new
  buildArgv) instead of leaving byte-identical copies in each provider.

* fix(media-use): address avatar-video PR review feedback

- heygenVideoGenerate short-circuits after the first discovery-call
  failure instead of always attempting both avatar list and voice list,
  so an unauthenticated caller gets one onboarding message and one
  provider-error telemetry ping instead of a double-fire.
- runHeygenJson logs a diagnostic when a CLI call succeeds but returns
  unparseable JSON, instead of silently returning null.
- dedupe the "avatar video is free" onboarding string into one constant
  (was duplicated across three call sites).

* fix(media-use): match review-requested naming and message conventions

- export AVATAR_VIDEO_SIGNIN_MESSAGE from heygen-video-provider.mjs so
  the test imports the canonical string instead of redeclaring it.
- runHeygenJson's non-JSON diagnostic now matches heygen-search.mjs's
  existing wording ("returned non-JSON output").
This commit is contained in:
Miguel Ángel
2026-07-17 03:56:57 -04:00
committed by GitHub
parent f084a7217d
commit 0a66671fc5
18 changed files with 1021 additions and 122 deletions
+2 -2
View File
@@ -46,8 +46,8 @@
"files": 10 "files": 10
}, },
"media-use": { "media-use": {
"hash": "b036210167a3ca39", "hash": "f15fce08a493d70e",
"files": 133 "files": 139
}, },
"motion-graphics": { "motion-graphics": {
"hash": "cc93e3d220a0ebf5", "hash": "cc93e3d220a0ebf5",
+31 -26
View File
@@ -25,20 +25,20 @@ node <SKILL_DIR>/scripts/resolve.mjs --doctor
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 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 | | HyperFrames gap | media-use owns it via |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) | | 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 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`) | | 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) | | 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 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 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 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 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 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 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 + image-to-video (animate any still into a talking clip), photo-avatar, dub/translate `heygen` CLI free-usage path (`references/operations.md`); optional spec-gated local LTX (`videogen` in `scripts/lib/local-models.mjs`) | | No video generation | `resolve --type video` — HeyGen avatar video first (free-usage path, sign-in nudge on auth failure), local LTX fallback (`videogen` in `scripts/lib/local-models.mjs`); image-to-video, photo-avatar, dub/translate remain manual `heygen` CLI recipes (`references/operations.md`) |
| 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`) | | 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 ## When to use
@@ -249,20 +249,21 @@ transcription, and LTX for local video generation. `resolve` spec-checks
AVAILABLE RAM for those local ladders (`describeModelLadder`); the agent can AVAILABLE RAM for those local ladders (`describeModelLadder`); the agent can
see the ladder and override. see the ladder and override.
| Type | Provider / path | | Type | Provider / path |
| --------- | ----------------------------------------------------------------------------------------------------------- | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| bgm/sfx | heygen catalog free-usage path | | bgm/sfx | heygen catalog free-usage path |
| image | heygen search free-usage path; optional local mflux; codex `image_gen` upsell | | 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) | | voice | heygen tts free-usage path; optional local **Kokoro** (free, on-device) |
| icon | heygen asset search free-usage path | | icon | heygen asset search free-usage path |
| logo | svgl, then simple-icons, then GitHub org avatar, then domain favicon (all free) | | 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 | | grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback |
| video | heygen avatar / image-to-video / photo-avatar / dub free-usage path; optional local LTX (`videogen` ladder) | | video | heygen avatar video free-usage path (sign-in nudge on auth failure); optional local LTX (`videogen` ladder). Image-to-video / photo-avatar / dub stay manual `heygen` recipes |
Local Kokoro (voice), mflux (image), and LTX (video) run on-device (free, Local Kokoro (voice), mflux (image), and LTX (video) run on-device (free,
private, offline once cached). The `codex` CLI remains the ChatGPT-sub image 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; upsell. Cost rule (X4): the agent confirms before an agent-initiated paid call;
a user-requested one just runs. a user-requested one just runs`heygen.video` is flagged paid (metered free
allowance) so an agent-initiated `resolve --type video` confirms first.
To force a specific generator (e.g. a user says "make this image with codex"), 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 pass `--provider codex`: it pins resolution to that provider and skips the
@@ -425,8 +426,12 @@ 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). 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 `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 runs. Every media-use call that shells `heygen` — catalog search AND every
(v0.3.0+). generating call (TTS, avatar video) — tags requests with the allowlisted
`X-HeyGen-Client-Source: media-use` header (v0.3.0+), sourced from one shared
constant (`HEYGEN_CLIENT_SOURCE_ARGV` in `scripts/lib/heygen-cli.mjs`) so a
future call site can't silently ship untagged. Read-only discovery calls
(`voice list`, `avatar list`) are intentionally left untagged.
## Telemetry ## Telemetry
+51 -31
View File
@@ -197,44 +197,64 @@ GENERATES. Two paths, best-for-the-machine picked automatically:
`--local-only` keeps mflux (once cached) and skips codex (network). `--local-only` keeps mflux (once cached) and skips codex (network).
## Generate: video (local first, HeyGen avatar upsell) ## Generate: video (`resolve --type video`, HeyGen avatar first)
Operate-on-video ships now; GENERATING video is local-first with a HeyGen `resolve --type video "<intent>"` is the default path. It generates a
avatar upsell (decision X3). script-driven HeyGen avatar video first (the free-usage allowance — OAuth
sessions ride the web-plan free avatar-video quota where eligible, API keys
follow normal API billing), falling back to local generative LTX only when
HeyGen is unavailable, uncredentialed, or `--local-only` is passed. The two
are non-substitutable outputs (a real presenter vs. a generic generative
clip), so treat the fallback as "HeyGen wasn't reachable," not "upgrade the
quality":
- **Local (default): LTX 2.3 on MLX** via `dgrauet/ltx-2-mlx`, the `videogen` - **HeyGen avatar video (default, free for new API users):**
ladder in `local-models.mjs`. Generative clips (t2v / i2v), spec-gated to RAM. `heygenVideoGenerate` (`scripts/lib/heygen-video-provider.mjs`) shells the
Verified on 24GB: 512x320 x 33f with audio. `heygen` CLI — never the raw API — auto-picking a public avatar and a
- **HeyGen avatar upsell (better, script-driven): the `heygen` CLI**, NOT the starfish voice (override with `--avatar-id`/`--voice-id`, threaded through
raw API. For a talking-head / avatar video, `heygen video create` (avatar as `ctx.avatarId`/`ctx.voiceId`). If the CLI reports `not_authenticated`,
engine IV by default) beats a generative clip when you want a real presenter. the provider prints an onboarding recommendation (avatar video is free for
Browser OAuth uses the web-plan/free avatar-video allowance where eligible; new API users — sign in) to stderr and falls through to LTX instead of
API keys follow the normal API billing path. Always pass hard-failing.
`--headers "X-HeyGen-Client-Source: media-use"` on any generating `heygen` - **Local fallback: LTX 2.3 on MLX** via `dgrauet/ltx-2-mlx`, the `videogen`
command (`video create`, `avatar create`, `video-translate`) — it's the ladder in `local-models.mjs` (`ltx-video-provider.mjs`). Generative clips
allowlisted attribution header (persistent flag, works on every subcommand) that (t2v), spec-gated to RAM. Verified on 24GB: 512x320 x 33f with audio.
tags the usage as media-use in billing/resource meta so it shows up in the API
dashboards. Read-only discovery (`avatar list`, `voice list`) doesn't need it.
```bash Every generating `heygen` call from media-use — TTS, avatar video, and
# discover an avatar + a starfish voice, then create + wait catalog search — sends the allowlisted `X-HeyGen-Client-Source: media-use`
heygen avatar list --ownership public --limit 5 header (persistent flag, works on every subcommand) via the shared
heygen voice list --engine starfish --limit 5 `HEYGEN_CLIENT_SOURCE_ARGV` constant (`scripts/lib/heygen-cli.mjs`), so usage
heygen video create --headers "X-HeyGen-Client-Source: media-use" --wait -d '{ tags correctly in billing/resource meta and shows up in the API dashboards.
"type": "avatar", Read-only discovery (`avatar list`, `voice list`) doesn't need it.
"avatar_id": "<avatar-id>",
"script": "Your narration here.",
"voice_id": "<voice-id>"
}'
```
Avatar videos are deterministic + script-driven (lip-sync from a script or a For structured bodies `resolve --type video` doesn't expose yet (a specific
pre-recorded `audio_url`), distinct from the generative LTX clips. After it `avatar_id`/`voice_id` combination beyond the ctx overrides, or a
renders, `resolve --from <downloaded.mp4> --type video` to ledger it. pre-recorded `audio_url` instead of a script), the raw `heygen video create`
recipe below remains the escape hatch:
```bash
# discover an avatar + a starfish voice, then create + wait
heygen avatar list --ownership public --limit 5
heygen voice list --engine starfish --limit 5
heygen video create --headers "X-HeyGen-Client-Source: media-use" --wait -d '{
"type": "avatar",
"avatar_id": "<avatar-id>",
"script": "Your narration here.",
"voice_id": "<voice-id>"
}'
```
Avatar videos are deterministic + script-driven (lip-sync from a script or a
pre-recorded `audio_url`), distinct from the generative LTX clips. After a
manual recipe renders, `resolve --from <downloaded.mp4> --type video` to
ledger it (not needed when generating via `resolve --type video` directly —
that already ledgers the result).
### Image-to-video (animate any still into a talking clip) ### Image-to-video (animate any still into a talking clip)
`heygen video create` takes the raw `POST /v3/videos` body, so switching `type` Not wired into `resolve --type video` (deferred — the `avatar` type covers
the default script-driven case). `heygen video create` takes the raw
`POST /v3/videos` body, so switching `type`
from `avatar` to `image` animates **any image of a person** into a lip-synced from `avatar` to `image` animates **any image of a person** into a lip-synced
talking video, with no avatar/photo-avatar creation step first. Point `image` at a talking video, with no avatar/photo-avatar creation step first. Point `image` at a
public URL or an uploaded `asset_id`, and drive speech with a `script`+`voice_id` public URL or an uploaded `asset_id`, and drive speech with a `script`+`voice_id`
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { track } from "./telemetry.mjs"; import { track } from "./telemetry.mjs";
// v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it // v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it
@@ -10,6 +11,7 @@ export const HEYGEN_INSTALL_COMMAND =
"curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth"; "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_AUTH_COMMAND = "heygen auth login --oauth";
export const HEYGEN_UPDATE_COMMAND = "heygen update"; export const HEYGEN_UPDATE_COMMAND = "heygen update";
export const HEYGEN_CLIENT_SOURCE_ARGV = ["--headers", "X-HeyGen-Client-Source: media-use"];
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_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_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
@@ -132,6 +134,31 @@ export async function flushHeygenFailureTracking() {
await Promise.all(pendingFailureTracking); await Promise.all(pendingFailureTracking);
} }
// Shared discovery/generation helper for the CLI-shelling providers (voice,
// avatar-video): run a heygen JSON subcommand, report+classify on failure,
// and hand the caller the classified reason via onError (used by callers that
// need to distinguish e.g. not_authenticated from other failures).
export function runHeygenJson(bin, argv, label, onError) {
let out;
try {
out = execFileSync(bin, argv, {
encoding: "utf8",
timeout: 120000,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (err) {
reportHeygenFailure(err, `${bin} ${label}`);
onError?.(classifyHeygenErrorCode(err));
return null;
}
try {
return JSON.parse(out);
} catch {
console.error(`media-use: \`${bin} ${label}\` returned non-JSON output`);
return null;
}
}
export function firstSemver(text) { export function firstSemver(text) {
const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/); const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/);
return match ? `${match[1]}.${match[2]}.${match[3]}` : null; return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
@@ -1,18 +1,12 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { reportHeygenFailure } from "./heygen-cli.mjs"; import { HEYGEN_CLIENT_SOURCE_ARGV, reportHeygenFailure } from "./heygen-cli.mjs";
export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = {}) { export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = {}) {
// execFileSync with an argv array (no shell), so query/type/etc. are passed as // 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 // literal arguments — no quoting tricks, no command injection. subcommand is a
// hardcoded multi-word string (e.g. "audio sounds list"), split into tokens. // hardcoded multi-word string (e.g. "audio sounds list"), split into tokens.
// Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.3.0). // Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.3.0).
const args = [ const args = [...HEYGEN_CLIENT_SOURCE_ARGV, ...subcommand.split(" "), "--query", query];
"--headers",
"X-HeyGen-Client-Source: media-use",
...subcommand.split(" "),
"--query",
query,
];
if (type) args.push("--type", type); if (type) args.push("--type", type);
args.push("--limit", String(limit)); args.push("--limit", String(limit));
// Server-side score floor. Honored by `audio sounds list`; the `asset search` // Server-side score floor. Honored by `audio sounds list`; the `asset search`
@@ -0,0 +1,49 @@
import { strict as assert } from "node:assert";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { HEYGEN_CLIENT_SOURCE_HEADERS } from "../../audio/scripts/lib/heygen.mjs";
import { HEYGEN_CLIENT_SOURCE_ARGV } from "./heygen-cli.mjs";
import { heygenSearch } from "./heygen-search.mjs";
test("tags HeyGen searches with the shared media-use client source", () => {
const dir = mkdtempSync(join(tmpdir(), "media-use-heygen-search-"));
const capturePath = join(dir, "argv.log");
const heygenPath = join(dir, "heygen");
const previousPath = process.env.PATH;
const previousCapturePath = process.env.HEYGEN_CAPTURE_PATH;
writeFileSync(
heygenPath,
`#!/bin/sh
printf '%s\\n' "$*" >> "$HEYGEN_CAPTURE_PATH"
printf '%s\\n' '{"data":[{"id":"x"}]}'
`,
);
chmodSync(heygenPath, 0o755);
process.env.PATH = `${dir}:${previousPath ?? ""}`;
process.env.HEYGEN_CAPTURE_PATH = capturePath;
try {
const result = heygenSearch("audio sounds list", "ocean", { limit: 1 });
const argv = readFileSync(capturePath, "utf8").trim();
assert.deepEqual(result, [{ id: "x" }]);
assert.match(argv, /X-HeyGen-Client-Source: media-use/);
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
if (previousCapturePath === undefined) delete process.env.HEYGEN_CAPTURE_PATH;
else process.env.HEYGEN_CAPTURE_PATH = previousCapturePath;
rmSync(dir, { recursive: true, force: true });
}
});
test("keeps CLI and REST media-use client source headers in lockstep", () => {
const [entry] = Object.entries(HEYGEN_CLIENT_SOURCE_HEADERS);
assert.ok(entry);
const [key, value] = entry;
assert.equal(HEYGEN_CLIENT_SOURCE_ARGV[1], `${key}: ${value}`);
});
@@ -0,0 +1,127 @@
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { freezeUrl } from "./freeze.mjs";
import {
classifyHeygenErrorCode,
HEYGEN_AUTH_COMMAND,
HEYGEN_CLIENT_SOURCE_ARGV,
reportHeygenFailure,
runHeygenJson,
} from "./heygen-cli.mjs";
export const AVATAR_VIDEO_SIGNIN_MESSAGE = `media-use: avatar video is free for new API users — sign in: ${HEYGEN_AUTH_COMMAND}`;
// Cache only a truthy id -- a transient discovery failure must not poison the
// cache with `null` and permanently disable heygen.video for the rest of the
// process. `onError` lets the caller distinguish not_authenticated (and nudge
// onboarding) from any other discovery failure.
let cachedAvatarId;
function defaultAvatarId(onError) {
if (cachedAvatarId) return cachedAvatarId;
const j = runHeygenJson(
"heygen",
["avatar", "list", "--ownership", "public", "--limit", "1"],
"avatar list",
onError,
);
cachedAvatarId = j?.data?.[0]?.avatar_id || null;
return cachedAvatarId;
}
let cachedStarfishVoiceId;
function defaultStarfishVoiceId(onError) {
if (cachedStarfishVoiceId) return cachedStarfishVoiceId;
const j = runHeygenJson(
"heygen",
["voice", "list", "--engine", "starfish", "--limit", "1"],
"voice list",
onError,
);
cachedStarfishVoiceId = j?.data?.[0]?.voice_id || null;
return cachedStarfishVoiceId;
}
export async function heygenVideoGenerate(intent, ctx) {
let discoveryFailureReason = null;
const captureReason = (reason) => {
discoveryFailureReason ??= reason;
};
// Short-circuit: once one discovery call fails, the result is null either
// way, so don't attempt the second -- that would double-fire the onboarding
// message and the provider-error telemetry ping for what's really one failure.
const avatarId = ctx?.avatarId || defaultAvatarId(captureReason);
if (!avatarId) {
if (discoveryFailureReason === "not_authenticated") console.error(AVATAR_VIDEO_SIGNIN_MESSAGE);
return null;
}
const voiceId = ctx?.voiceId || defaultStarfishVoiceId(captureReason);
if (!voiceId) {
if (discoveryFailureReason === "not_authenticated") console.error(AVATAR_VIDEO_SIGNIN_MESSAGE);
return null;
}
let out;
try {
out = execFileSync(
"heygen",
[
...HEYGEN_CLIENT_SOURCE_ARGV,
"video",
"create",
"--wait",
"-d",
JSON.stringify({
type: "avatar",
avatar_id: avatarId,
script: intent,
voice_id: voiceId,
}),
],
{
encoding: "utf8",
timeout: 300000,
stdio: ["pipe", "pipe", "pipe"],
},
);
} catch (err) {
if (classifyHeygenErrorCode(err) === "not_authenticated") {
console.error(AVATAR_VIDEO_SIGNIN_MESSAGE);
}
reportHeygenFailure(err, "heygen video create");
return null;
}
let parsed;
try {
parsed = JSON.parse(out);
} catch {
console.error("media-use: `heygen video create` returned invalid JSON");
return null;
}
const videoUrl = parsed?.data?.video_url;
if (typeof videoUrl !== "string" || !videoUrl) {
console.error("media-use: `heygen video create` returned no video URL");
return null;
}
const tmpPath = join(tmpdir(), `media-use-heygen-video-${process.pid}-${Date.now()}.mp4`);
try {
await freezeUrl(videoUrl, tmpPath);
} catch (err) {
console.error(`media-use: heygen video download failed: ${err.message}`);
return null;
}
return {
localPath: tmpPath,
ext: ".mp4",
source: "generated",
metadata: {
description: intent,
provider: "heygen.video",
provenance: { prompt: intent },
},
};
}
@@ -0,0 +1,344 @@
import { strict as assert } from "node:assert";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import http from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { HEYGEN_NOT_AUTHENTICATED_MESSAGE } from "./heygen-cli.mjs";
import { AVATAR_VIDEO_SIGNIN_MESSAGE } from "./heygen-video-provider.mjs";
const VIDEO_FIXTURE = Buffer.from("tiny heygen video fixture");
let importCount = 0;
async function freshGenerate() {
importCount += 1;
const module = await import(`./heygen-video-provider.mjs?test=${importCount}`);
return module.heygenVideoGenerate;
}
async function listenVideoServer() {
const server = http.createServer((req, res) => {
if (req.url !== "/video.mp4") {
res.writeHead(404).end();
return;
}
res.writeHead(200, {
"content-length": VIDEO_FIXTURE.length,
"content-type": "video/mp4",
});
res.end(VIDEO_FIXTURE);
});
await new Promise((resolve) => server.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string");
return {
server,
url: `http://127.0.0.1:${address.port}/video.mp4`,
};
}
async function listenFailingVideoServer() {
const server = http.createServer((req, res) => {
res.writeHead(500).end();
});
await new Promise((resolve) => server.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string");
return {
server,
url: `http://127.0.0.1:${address.port}/video.mp4`,
};
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
async function withFakeHeygen(options, run) {
const dir = mkdtempSync(join(tmpdir(), "media-use-heygen-video-provider-"));
const capturePath = join(dir, "argv.log");
const heygenPath = join(dir, "heygen");
const previousEnv = {
PATH: process.env.PATH,
HEYGEN_CAPTURE_PATH: process.env.HEYGEN_CAPTURE_PATH,
HEYGEN_VIDEO_MODE: process.env.HEYGEN_VIDEO_MODE,
HEYGEN_VIDEO_RESPONSE: process.env.HEYGEN_VIDEO_RESPONSE,
HEYGEN_DISCOVERY_MODE: process.env.HEYGEN_DISCOVERY_MODE,
HYPERFRAMES_NO_TELEMETRY: process.env.HYPERFRAMES_NO_TELEMETRY,
};
writeFileSync(
heygenPath,
`#!/bin/sh
printf '%s\\n' "$*" >> "$HEYGEN_CAPTURE_PATH"
case "$*" in
*"avatar list"*)
case "$HEYGEN_DISCOVERY_MODE" in
auth) printf '%s\\n' 'HTTP 401 Unauthorized' >&2; exit 1 ;;
*) printf '%s\\n' '{"data":[{"avatar_id":"avatar-public-1"}]}' ;;
esac
;;
*"voice list"*)
case "$HEYGEN_DISCOVERY_MODE" in
auth) printf '%s\\n' 'HTTP 401 Unauthorized' >&2; exit 1 ;;
*) printf '%s\\n' '{"data":[{"voice_id":"voice-starfish-1"}]}' ;;
esac
;;
*"video create"*)
case "$HEYGEN_VIDEO_MODE" in
auth) printf '%s\\n' 'HTTP 401 Unauthorized' >&2; exit 1 ;;
other) printf '%s\\n' 'provider unavailable' >&2; exit 1 ;;
*) printf '%s\\n' "$HEYGEN_VIDEO_RESPONSE" ;;
esac
;;
esac
`,
);
chmodSync(heygenPath, 0o755);
process.env.PATH = `${dir}:${previousEnv.PATH ?? ""}`;
process.env.HEYGEN_CAPTURE_PATH = capturePath;
process.env.HEYGEN_VIDEO_MODE = options.mode ?? "success";
process.env.HEYGEN_VIDEO_RESPONSE = options.response ?? "";
process.env.HEYGEN_DISCOVERY_MODE = options.discoveryMode ?? "";
process.env.HYPERFRAMES_NO_TELEMETRY = "1";
try {
return await run({
invocations: () => readFileSync(capturePath, "utf8").trim().split("\n"),
});
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
rmSync(dir, { recursive: true, force: true });
}
}
function bodyFromInvocation(invocation) {
const marker = " -d ";
const start = invocation.indexOf(marker);
assert.notEqual(start, -1);
return JSON.parse(invocation.slice(start + marker.length));
}
test("downloads a generated avatar video and returns the generated MP4 result", async () => {
const { server, url } = await listenVideoServer();
let localPath;
try {
await withFakeHeygen(
{ response: JSON.stringify({ data: { video_url: url } }) },
async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
const intent = "Welcome to the HyperFrames launch";
const result = await heygenVideoGenerate(intent, {});
localPath = result?.localPath;
const calls = invocations();
const create = calls.find((call) => call.includes("video create"));
assert.ok(create);
assert.match(create, /--headers X-HeyGen-Client-Source: media-use/);
assert.deepEqual(bodyFromInvocation(create), {
type: "avatar",
avatar_id: "avatar-public-1",
script: intent,
voice_id: "voice-starfish-1",
});
assert.ok(result);
assert.equal(join(tmpdir(), result.localPath.slice(tmpdir().length + 1)), result.localPath);
assert.match(result.localPath, /media-use-heygen-video-\d+-\d+\.mp4$/);
assert.deepEqual(result, {
localPath: result.localPath,
ext: ".mp4",
source: "generated",
metadata: {
description: intent,
provider: "heygen.video",
provenance: { prompt: intent },
},
});
assert.deepEqual(readFileSync(result.localPath), VIDEO_FIXTURE);
},
);
} finally {
if (localPath) rmSync(localPath, { force: true });
await closeServer(server);
}
});
test("tags video creation but not avatar or voice discovery", async () => {
const { server, url } = await listenVideoServer();
let localPath;
try {
await withFakeHeygen(
{ response: JSON.stringify({ data: { video_url: url } }) },
async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Header regression guard", {});
localPath = result?.localPath;
const calls = invocations();
assert.equal(calls.length, 3);
assert.match(calls[0], /^avatar list --ownership public --limit 1$/);
assert.doesNotMatch(calls[0], /X-HeyGen-Client-Source/);
assert.match(calls[1], /^voice list --engine starfish --limit 1$/);
assert.doesNotMatch(calls[1], /X-HeyGen-Client-Source/);
assert.match(calls[2], /video create/);
assert.match(calls[2], /X-HeyGen-Client-Source: media-use/);
},
);
} finally {
if (localPath) rmSync(localPath, { force: true });
await closeServer(server);
}
});
test("uses explicit avatar and voice overrides without discovery", async () => {
const { server, url } = await listenVideoServer();
let localPath;
try {
await withFakeHeygen(
{ response: JSON.stringify({ data: { video_url: url } }) },
async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Use my presenter", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
localPath = result?.localPath;
const calls = invocations();
assert.equal(calls.length, 1);
assert.match(calls[0], /video create/);
assert.deepEqual(bodyFromInvocation(calls[0]), {
type: "avatar",
avatar_id: "avatar-override",
script: "Use my presenter",
voice_id: "voice-override",
});
},
);
} finally {
if (localPath) rmSync(localPath, { force: true });
await closeServer(server);
}
});
test("caches discovered avatar and voice IDs for the process", async () => {
const { server, url } = await listenVideoServer();
const localPaths = new Set();
try {
await withFakeHeygen(
{ response: JSON.stringify({ data: { video_url: url } }) },
async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
for (const intent of ["First avatar video", "Second avatar video"]) {
const result = await heygenVideoGenerate(intent, {});
if (result) localPaths.add(result.localPath);
}
const calls = invocations();
assert.equal(calls.filter((call) => call.startsWith("avatar list ")).length, 1);
assert.equal(calls.filter((call) => call.startsWith("voice list ")).length, 1);
assert.equal(calls.filter((call) => call.includes("video create")).length, 2);
},
);
} finally {
for (const localPath of localPaths) rmSync(localPath, { force: true });
await closeServer(server);
}
});
test("prints auth onboarding and reports an unauthenticated create failure", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
await withFakeHeygen({ mode: "auth" }, async () => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Sign-in failure", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
assert.equal(result, null);
assert.ok(errors.includes(AVATAR_VIDEO_SIGNIN_MESSAGE));
assert.ok(errors.includes(HEYGEN_NOT_AUTHENTICATED_MESSAGE));
});
});
test("reports other create failures without auth onboarding", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
await withFakeHeygen({ mode: "other" }, async () => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Provider failure", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
assert.equal(result, null);
assert.ok(!errors.includes(AVATAR_VIDEO_SIGNIN_MESSAGE));
assert.ok(errors.includes("media-use: `heygen video create` failed: provider unavailable"));
});
});
test("falls through on non-JSON and error responses", async (t) => {
t.mock.method(console, "error", () => {});
for (const response of ["not JSON", '{"error":{"message":"render failed"}}']) {
await withFakeHeygen({ response }, async () => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Unusable response", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
assert.equal(result, null);
});
}
});
test("onboards and returns null when avatar/voice discovery itself is unauthenticated", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
// Both avatar list AND voice list would fail unauthenticated (discoveryMode
// "auth" applies to both in the fake CLI) -- the short-circuit after the
// first failure must mean only one is ever attempted, so the onboarding
// message and the provider-error telemetry ping each fire exactly once
// instead of double-firing for what's really one auth failure.
await withFakeHeygen({ discoveryMode: "auth" }, async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Discovery auth failure", {});
assert.equal(result, null);
assert.equal(
errors.filter((message) => message === AVATAR_VIDEO_SIGNIN_MESSAGE).length,
1,
"onboarding message must fire exactly once, not once per failed discovery call",
);
const calls = invocations();
assert.equal(calls.length, 1, "must short-circuit after the first discovery failure");
assert.match(calls[0], /^avatar list /);
});
});
test("download failure after a successful create returns null and logs a diagnostic", async () => {
const { server, url } = await listenFailingVideoServer();
try {
await withFakeHeygen({ response: JSON.stringify({ data: { video_url: url } }) }, async () => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Download failure", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
assert.equal(result, null);
});
} finally {
await closeServer(server);
}
});
@@ -205,6 +205,17 @@ export function listModels(capability) {
return tableFor(capability).slice(); return tableFor(capability).slice();
} }
// Tokenize an `invoke` template on whitespace first, then substitute each
// token, so a `{prompt}`/`{model_path}` value with spaces stays a single argv
// entry. Shared by every local-model provider (mflux, LTX) that builds argv
// from a MODELS[...].invoke template.
export function buildArgv(template, vars) {
return template
.trim()
.split(/\s+/)
.map((tok) => tok.replace(/\{(\w+)\}/g, (_, k) => (k in vars ? String(vars[k]) : `{${k}}`)));
}
/** Does this machine meet a model's needs? Apple Silicon unified memory counts as VRAM. */ /** Does this machine meet a model's needs? Apple Silicon unified memory counts as VRAM. */
export function meetsSpecs(model, specs) { export function meetsSpecs(model, specs) {
const n = model.needs || {}; const n = model.needs || {};
@@ -0,0 +1,70 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { probeSpecs } from "./specs.mjs";
import { buildArgv, selectModel } from "./local-models.mjs";
export async function ltxVideoGenerate(
intent,
ctx,
execFn = execFileSync,
pathExists = existsSync,
) {
const specs = ctx?.specs || probeSpecs();
const sel = selectModel("videogen", specs, { preferTier: ctx?.preferTier });
if (sel.recommend) {
console.error(
`media-use: local video gen not enabled (${sel.reason}). Enable a fitting free on-device LTX model to use this provider.`,
);
return null;
}
const { model } = sel;
const bin = model.invoke.trim().split(/\s+/)[0];
try {
execFn("which", [bin], { stdio: ["ignore", "ignore", "ignore"] });
} catch {
console.error(
`media-use: local video gen not enabled (\`${bin}\` not on PATH). Install for free on-device LTX: ${model.install}`,
);
return null;
}
const outPath = join(tmpdir(), `media-use-ltx-${process.pid}-${Date.now()}.mp4`);
const width = ctx?.width || 512;
const height = ctx?.height || 320;
const frames = ctx?.frames || 33;
const argv = buildArgv(model.invoke, {
prompt: intent,
w: width,
h: height,
frames,
out: outPath,
});
argv.shift();
try {
execFn(bin, argv, {
encoding: "utf8",
timeout: 1_800_000,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
console.error(
`media-use: local video gen (${model.id}) failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`,
);
return null;
}
if (!pathExists(outPath)) return null;
return {
localPath: outPath,
ext: ".mp4",
source: "generated",
metadata: {
description: intent,
provider: "ltx.local",
provenance: { prompt: intent },
},
};
}
@@ -0,0 +1,136 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { dirname } from "node:path";
import { tmpdir } from "node:os";
import { ltxVideoGenerate } from "./ltx-video-provider.mjs";
const fittingSpecs = { availableRamMB: 20000, gpu: { present: true } };
test("no fitting local model: falls through without checking for a binary", async (t) => {
t.mock.method(console, "error", () => {});
const calls = [];
const result = await ltxVideoGenerate(
"a calm ocean wave at sunset",
{ specs: { availableRamMB: 100, gpu: { present: true } } },
(...call) => calls.push(call),
() => true,
);
assert.equal(result, null);
assert.deepEqual(calls, []);
});
test("binary missing from PATH: prints the model install hint and falls through", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
const calls = [];
const fakeExec = (...call) => {
calls.push(call);
throw new Error("not found");
};
const result = await ltxVideoGenerate(
"a calm ocean wave at sunset",
{ specs: fittingSpecs },
fakeExec,
);
assert.equal(result, null);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0].slice(0, 2), ["which", ["ltx-2-mlx"]]);
assert.equal(errors.length, 1);
assert.match(errors[0], /git clone https:\/\/github\.com\/dgrauet\/ltx-2-mlx/);
});
test("generate argv substitutes a spaced prompt after tokenizing and uses verified defaults", async () => {
const calls = [];
const checkedPaths = [];
const fakeExec = (...call) => calls.push(call);
const pathExists = (path) => {
checkedPaths.push(path);
return false;
};
const intent = "a calm ocean wave at sunset";
const result = await ltxVideoGenerate(intent, { specs: fittingSpecs }, fakeExec, pathExists);
assert.equal(result, null);
assert.equal(calls.length, 2);
const [bin, argv, opts] = calls[1];
assert.equal(bin, "ltx-2-mlx");
assert.equal(opts.timeout, 1_800_000);
const expectedPairs = [
["--prompt", intent],
["--width", "512"],
["--height", "320"],
["--frames", "33"],
["--output", checkedPaths[0]],
];
let previousIndex = -1;
for (const [flag, value] of expectedPairs) {
const index = argv.indexOf(flag);
assert.ok(index > previousIndex, `${flag} should follow the previous required option`);
assert.equal(argv[index + 1], value);
previousIndex = index;
}
assert.equal(argv.filter((arg) => arg === intent).length, 1);
});
test("successful generation returns the generated MP4 result", async () => {
const calls = [];
const fakeExec = (...call) => calls.push(call);
const intent = "a calm ocean wave at sunset";
const result = await ltxVideoGenerate(intent, { specs: fittingSpecs }, fakeExec, () => true);
assert.ok(result);
assert.equal(calls.length, 2);
assert.equal(dirname(result.localPath), tmpdir());
assert.match(result.localPath, /media-use-ltx-\d+-\d+\.mp4$/);
assert.deepEqual(result, {
localPath: result.localPath,
ext: ".mp4",
source: "generated",
metadata: {
description: intent,
provider: "ltx.local",
provenance: { prompt: intent },
},
});
});
test("generate failure returns null instead of throwing", async (t) => {
t.mock.method(console, "error", () => {});
let calls = 0;
const fakeExec = () => {
calls += 1;
if (calls === 2) {
const error = new Error("generation failed");
error.stderr = "LTX failed";
throw error;
}
};
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: fittingSpecs },
fakeExec,
() => true,
);
assert.equal(result, null);
assert.equal(calls, 2);
});
test("missing generated output returns null", async () => {
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: fittingSpecs },
() => {},
() => false,
);
assert.equal(result, null);
});
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { probeSpecs } from "./specs.mjs"; import { probeSpecs } from "./specs.mjs";
import { selectModel } from "./local-models.mjs"; import { buildArgv, selectModel } from "./local-models.mjs";
// Local image generation via mflux (FLUX-on-MLX), the Mac-native runner. // Local image generation via mflux (FLUX-on-MLX), the Mac-native runner.
// Spec-gated: selectModel("imagegen", specs) returns the best FLUX-class model // Spec-gated: selectModel("imagegen", specs) returns the best FLUX-class model
@@ -15,15 +15,6 @@ import { selectModel } from "./local-models.mjs";
// non-gated community 4-bit re-uploads; the repo is resolved to a local snapshot // non-gated community 4-bit re-uploads; the repo is resolved to a local snapshot
// (hf download, idempotent) because a bare repo id breaks mlx unflatten. // (hf download, idempotent) because a bare repo id breaks mlx unflatten.
// Tokenize the invoke template on whitespace FIRST, then substitute each token,
// so a {prompt}/{model_path} value with spaces stays a single argv entry.
function buildArgv(template, vars) {
return template
.trim()
.split(/\s+/)
.map((tok) => tok.replace(/\{(\w+)\}/g, (_, k) => (k in vars ? String(vars[k]) : `{${k}}`)));
}
// Resolve an HF repo to its local snapshot dir. `hf download` is idempotent and // Resolve an HF repo to its local snapshot dir. `hf download` is idempotent and
// prints the snapshot path as its last line. // prints the snapshot path as its last line.
function resolveSnapshot(repo) { function resolveSnapshot(repo) {
@@ -32,6 +32,8 @@ import {
faviconSearch, faviconSearch,
} from "./logo-provider.mjs"; } from "./logo-provider.mjs";
import { heygenTtsGenerate } from "./voice-provider.mjs"; import { heygenTtsGenerate } from "./voice-provider.mjs";
import { heygenVideoGenerate } from "./heygen-video-provider.mjs";
import { ltxVideoGenerate } from "./ltx-video-provider.mjs";
import { localTtsGenerate } from "./tts-local-provider.mjs"; import { localTtsGenerate } from "./tts-local-provider.mjs";
import { codexImageGenerate } from "./codex-provider.mjs"; import { codexImageGenerate } from "./codex-provider.mjs";
import { mfluxImageGenerate } from "./mflux-provider.mjs"; import { mfluxImageGenerate } from "./mflux-provider.mjs";
@@ -83,6 +85,12 @@ const REGISTRY = {
P("heygen.tts", { generate: heygenTtsGenerate }), P("heygen.tts", { generate: heygenTtsGenerate }),
A("kokoro.local", { generate: localTtsGenerate }), A("kokoro.local", { generate: localTtsGenerate }),
], ],
video: [
// HeyGen avatar video first when credentialed; --local-only skips it and
// keeps LTX as the local fallback.
P("heygen.video", { generate: heygenVideoGenerate }),
A("ltx.local", { generate: ltxVideoGenerate }),
],
brand: [ brand: [
// Local design spec, not heygen — reads frame.md / design.md tokens. // Local design spec, not heygen — reads frame.md / design.md tokens.
A("design_spec", { search: brandProvider.search }), A("design_spec", { search: brandProvider.search }),
+34 -4
View File
@@ -1,12 +1,31 @@
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import { test } from "node:test"; import { test } from "node:test";
import { getProviders, getProvider, listTypes, runProviders, runCapability } from "./registry.mjs"; import {
getProviders,
getProvider,
listTypes,
providerMatches,
providerNamesFor,
runProviders,
runCapability,
} from "./registry.mjs";
// --- registry shape ------------------------------------------------------- // --- registry shape -------------------------------------------------------
test("listTypes exposes the v2 media types", () => { test("listTypes exposes the v2 media types", () => {
const types = listTypes(); const types = listTypes();
for (const t of ["bgm", "sfx", "image", "icon", "logo", "voice", "brand", "grade", "lut"]) { for (const t of [
"bgm",
"sfx",
"image",
"icon",
"logo",
"voice",
"video",
"brand",
"grade",
"lut",
]) {
assert.ok(types.includes(t), `missing type: ${t}`); assert.ok(types.includes(t), `missing type: ${t}`);
} }
}); });
@@ -19,9 +38,9 @@ test("heygen provider is first for every type it serves", () => {
} }
}); });
test("sanctioned providers only: heygen, local mflux/kokoro, codex, design spec, logo tiers", () => { test("sanctioned providers only: heygen, local mflux/kokoro/ltx, codex, design spec, logo tiers", () => {
const allowed = const allowed =
/^heygen|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/; /^heygen|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^ltx\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/;
for (const t of listTypes()) { for (const t of listTypes()) {
for (const p of getProviders(t)) { for (const p of getProviders(t)) {
assert.ok(allowed.test(p.name), `${t} lists unsanctioned provider: ${p.name}`); assert.ok(allowed.test(p.name), `${t} lists unsanctioned provider: ${p.name}`);
@@ -52,6 +71,17 @@ test("voice cascade: HeyGen TTS first, Kokoro remains the local fallback", () =>
assert.ok(!ps[1].paid, "local Kokoro is free"); assert.ok(!ps[1].paid, "local Kokoro is free");
}); });
test("video cascade: HeyGen first, LTX local fallback, generate-only", async () => {
assert.deepEqual(providerNamesFor("video"), ["heygen.video", "ltx.local"]);
assert.equal(providerMatches("video", "ltx.local"), true);
const ps = getProviders("video");
assert.ok(ps[0].network, "HeyGen video is network (skipped under --local-only)");
assert.ok(ps[0].paid, "HeyGen video may bill after the OAuth free allowance");
assert.ok(!ps[1].network, "local LTX is kept under --local-only");
assert.equal(await runCapability("video", "search", "x", {}), null);
});
test("sfx cascade: HeyGen catalog first, bundled library remains the local fallback", () => { test("sfx cascade: HeyGen catalog first, bundled library remains the local fallback", () => {
const ps = getProviders("sfx"); const ps = getProviders("sfx");
assert.equal(ps[0].name, "heygen.audio.sounds"); assert.equal(ps[0].name, "heygen.audio.sounds");
+17 -26
View File
@@ -1,29 +1,9 @@
import { execFileSync } from "node:child_process"; import { HEYGEN_CLIENT_SOURCE_ARGV, runHeygenJson } from "./heygen-cli.mjs";
import { reportHeygenFailure } from "./heygen-cli.mjs";
// Voice / TTS generation via the HeyGen CLI — the only external CLI media-use // 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). // shells (CLI-only invariant: media-use holds no keys; the CLI owns auth).
// Flags verified against `heygen voice speech create --help` (v0.3.0). // Flags verified against `heygen voice speech create --help` (v0.3.0).
function runJson(bin, argv, label) {
let out;
try {
out = execFileSync(bin, argv, {
encoding: "utf8",
timeout: 120000,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (err) {
reportHeygenFailure(err, `${bin} ${label}`);
return null;
}
try {
return JSON.parse(out);
} catch {
return null;
}
}
function result(url, duration, provider, intent) { function result(url, duration, provider, intent) {
if (!url) return null; if (!url) return null;
return { return {
@@ -41,11 +21,13 @@ function result(url, duration, provider, intent) {
// HeyGen TTS requires a starfish-engine voice. Default to the first one the // HeyGen TTS requires a starfish-engine voice. Default to the first one the
// catalog returns (deterministic order); pass ctx.voiceId to override. // catalog returns (deterministic order); pass ctx.voiceId to override.
// ponytail: listed once per process; the resolved asset is frozen + cached after // ponytail: listed once per process; the resolved asset is frozen + cached after
// first use, so the network list only happens on a cache miss. // first use, so the network list only happens on a cache miss. Cache only a
// truthy id -- a transient list failure must not poison the cache with `null`
// and permanently disable TTS for the rest of the process.
let cachedVoiceId; let cachedVoiceId;
function defaultVoiceId() { function defaultVoiceId() {
if (cachedVoiceId !== undefined) return cachedVoiceId; if (cachedVoiceId) return cachedVoiceId;
const j = runJson( const j = runHeygenJson(
"heygen", "heygen",
["voice", "list", "--engine", "starfish", "--limit", "1"], ["voice", "list", "--engine", "starfish", "--limit", "1"],
"voice list", "voice list",
@@ -57,9 +39,18 @@ function defaultVoiceId() {
export async function heygenTtsGenerate(intent, ctx) { export async function heygenTtsGenerate(intent, ctx) {
const voiceId = ctx?.voiceId || defaultVoiceId(); const voiceId = ctx?.voiceId || defaultVoiceId();
if (!voiceId) return null; if (!voiceId) return null;
const p = runJson( const p = runHeygenJson(
"heygen", "heygen",
["voice", "speech", "create", "--text", intent, "--voice-id", voiceId], [
...HEYGEN_CLIENT_SOURCE_ARGV,
"voice",
"speech",
"create",
"--text",
intent,
"--voice-id",
voiceId,
],
"tts", "tts",
); );
return result(p?.data?.audio_url, p?.data?.duration, "heygen.tts", intent); return result(p?.data?.audio_url, p?.data?.duration, "heygen.tts", intent);
@@ -0,0 +1,46 @@
import { strict as assert } from "node:assert";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { heygenTtsGenerate } from "./voice-provider.mjs";
test("tags TTS generation but not voice discovery with the media-use client source", async () => {
const dir = mkdtempSync(join(tmpdir(), "media-use-voice-provider-"));
const capturePath = join(dir, "argv.log");
const heygenPath = join(dir, "heygen");
const previousPath = process.env.PATH;
const previousCapturePath = process.env.HEYGEN_CAPTURE_PATH;
writeFileSync(
heygenPath,
`#!/bin/sh
printf '%s\\n' "$*" >> "$HEYGEN_CAPTURE_PATH"
case "$*" in
*"voice list"*) printf '%s\\n' '{"data":[{"voice_id":"voice-123"}]}' ;;
*"voice speech create"*) printf '%s\\n' '{"data":{"audio_url":"https://example.com/voice.mp3","duration":1.5}}' ;;
esac
`,
);
chmodSync(heygenPath, 0o755);
process.env.PATH = `${dir}:${previousPath ?? ""}`;
process.env.HEYGEN_CAPTURE_PATH = capturePath;
try {
const result = await heygenTtsGenerate("Hello from media-use", {});
const invocations = readFileSync(capturePath, "utf8").trim().split("\n");
assert.equal(invocations.length, 2);
assert.match(invocations[0], /^voice list /);
assert.doesNotMatch(invocations[0], /X-HeyGen-Client-Source/);
assert.match(invocations[1], /voice speech create/);
assert.match(invocations[1], /X-HeyGen-Client-Source: media-use/);
assert.equal(result?.url, "https://example.com/voice.mp3");
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
if (previousCapturePath === undefined) delete process.env.HEYGEN_CAPTURE_PATH;
else process.env.HEYGEN_CAPTURE_PATH = previousCapturePath;
rmSync(dir, { recursive: true, force: true });
}
});
+25 -15
View File
@@ -37,7 +37,19 @@ import {
} from "./lib/heygen-cli.mjs"; } from "./lib/heygen-cli.mjs";
import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs"; import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs";
const INGEST_TYPES = [...listTypes(), "video"]; const INGEST_TYPES = listTypes();
const DEFAULT_EXT = {
bgm: ".wav",
sfx: ".mp3",
voice: ".wav",
image: ".jpg",
icon: ".svg",
logo: ".svg",
brand: ".png",
video: ".mp4",
grade: ".cube",
lut: ".cube",
};
// resolve shells `fetch`/`freezeUrl` and modern ESM; 18 is the floor where those // 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 // exist without flags. Named so the --doctor node check verifies something real
@@ -62,6 +74,8 @@ const { values: args } = parseArgs({
for: { type: "string" }, for: { type: "string" },
"local-only": { type: "boolean", default: false }, "local-only": { type: "boolean", default: false },
provider: { type: "string" }, provider: { type: "string" },
"avatar-id": { type: "string" },
"voice-id": { type: "string" },
json: { type: "boolean", default: false }, json: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false }, help: { type: "boolean", short: "h", default: false },
}, },
@@ -96,6 +110,8 @@ Options:
suggestions (grade only) suggestions (grade only)
--local-only Offline: skip every network provider --local-only Offline: skip every network provider
--provider Force one generator (e.g. codex, mflux, kokoro, heygen) --provider Force one generator (e.g. codex, mflux, kokoro, heygen)
--avatar-id Override the default avatar for heygen.video generation
--voice-id Override the default voice for voice/heygen.video generation
--json Output JSON instead of one-line result --json Output JSON instead of one-line result
--help, -h Show this help`); --help, -h Show this help`);
process.exit(0); process.exit(0);
@@ -342,7 +358,14 @@ async function run() {
// Offline guard: --local-only skips every remote provider (HeyGen catalog), // Offline guard: --local-only skips every remote provider (HeyGen catalog),
// leaving the project + global cache and any local provider. // leaving the project + global cache and any local provider.
const localOnly = args["local-only"]; const localOnly = args["local-only"];
const ctx = { entity, projectDir, localOnly, provider: args.provider }; const ctx = {
entity,
projectDir,
localOnly,
provider: args.provider,
avatarId: args["avatar-id"],
voiceId: args["voice-id"],
};
// Adherence nudge (offline, no auto-reuse): the exact-cache floor missed and // Adherence nudge (offline, no auto-reuse): the exact-cache floor missed and
// we're about to fetch/generate. If lexically-similar assets already exist, // we're about to fetch/generate. If lexically-similar assets already exist,
@@ -1187,19 +1210,6 @@ function extFromUrl(url) {
} }
} }
const DEFAULT_EXT = {
bgm: ".wav",
sfx: ".mp3",
voice: ".wav",
image: ".jpg",
icon: ".svg",
logo: ".svg",
brand: ".png",
video: ".mp4",
grade: ".cube",
lut: ".cube",
};
function defaultExt(type) { function defaultExt(type) {
return DEFAULT_EXT[type] || ".bin"; return DEFAULT_EXT[type] || ".bin";
} }
+40
View File
@@ -492,6 +492,46 @@ test("--from registers a derived video as documented", () => {
cleanup(); cleanup();
}); });
test("--from type error lists video exactly once", () => {
const result = spawnResolve(["--from", "missing.mp4"]);
assert.equal(result.status, 2);
assert.match(result.stderr, /--from requires --type \(one of:/);
assert.equal(result.stderr.match(/\bvideo\b/g)?.length, 1);
});
test("--from uses .mp4 as the default video extension", () => {
setup();
const source = join(tmp, "extensionless-video");
writeFileSync(source, "video bytes");
const out = runResolve(["--from", source, "--type", "video", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.match(parsed.path, /^\.media\/video\/video_001\.mp4$/);
cleanup();
});
test("--avatar-id/--voice-id parse as real CLI flags (regression guard: docs promise them, parseArgs must not reject them)", () => {
setup();
const result = spawnResolve(
[
"--type",
"video",
"--intent",
"regression guard",
"--local-only",
"--avatar-id",
"avatar-override",
"--voice-id",
"voice-override",
"--project",
tmp,
],
{ stdio: "pipe" },
);
assert.doesNotMatch(result.stderr || "", /ERR_PARSE_ARGS_UNKNOWN_OPTION/);
cleanup();
});
test("unknown type error lists grade and lut", () => { test("unknown type error lists grade and lut", () => {
try { try {
runResolve(["--type", "bogus", "--intent", "x"], { stdio: "pipe" }); runResolve(["--type", "bogus", "--intent", "x"], { stdio: "pipe" });