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
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
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
@@ -10,6 +11,7 @@ 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_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_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);
}
// 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) {
const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/);
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
@@ -1,18 +1,12 @@
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 } = {}) {
// 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.3.0).
const args = [
"--headers",
"X-HeyGen-Client-Source: media-use",
...subcommand.split(" "),
"--query",
query,
];
const args = [...HEYGEN_CLIENT_SOURCE_ARGV, ...subcommand.split(" "), "--query", query];
if (type) args.push("--type", type);
args.push("--limit", String(limit));
// 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();
}
// 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. */
export function meetsSpecs(model, specs) {
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 { join } from "node:path";
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.
// 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
// (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
// prints the snapshot path as its last line.
function resolveSnapshot(repo) {
@@ -32,6 +32,8 @@ import {
faviconSearch,
} from "./logo-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 { codexImageGenerate } from "./codex-provider.mjs";
import { mfluxImageGenerate } from "./mflux-provider.mjs";
@@ -83,6 +85,12 @@ const REGISTRY = {
P("heygen.tts", { generate: heygenTtsGenerate }),
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: [
// Local design spec, not heygen — reads frame.md / design.md tokens.
A("design_spec", { search: brandProvider.search }),
+34 -4
View File
@@ -1,12 +1,31 @@
import { strict as assert } from "node:assert";
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 -------------------------------------------------------
test("listTypes exposes the v2 media types", () => {
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}`);
}
});
@@ -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 =
/^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 p of getProviders(t)) {
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");
});
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", () => {
const ps = getProviders("sfx");
assert.equal(ps[0].name, "heygen.audio.sounds");
+17 -26
View File
@@ -1,29 +1,9 @@
import { execFileSync } from "node:child_process";
import { reportHeygenFailure } from "./heygen-cli.mjs";
import { HEYGEN_CLIENT_SOURCE_ARGV, runHeygenJson } 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.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) {
if (!url) return null;
return {
@@ -41,11 +21,13 @@ function result(url, duration, provider, intent) {
// HeyGen TTS requires a starfish-engine voice. Default to the first one the
// catalog returns (deterministic order); pass ctx.voiceId to override.
// 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;
function defaultVoiceId() {
if (cachedVoiceId !== undefined) return cachedVoiceId;
const j = runJson(
if (cachedVoiceId) return cachedVoiceId;
const j = runHeygenJson(
"heygen",
["voice", "list", "--engine", "starfish", "--limit", "1"],
"voice list",
@@ -57,9 +39,18 @@ function defaultVoiceId() {
export async function heygenTtsGenerate(intent, ctx) {
const voiceId = ctx?.voiceId || defaultVoiceId();
if (!voiceId) return null;
const p = runJson(
const p = runHeygenJson(
"heygen",
["voice", "speech", "create", "--text", intent, "--voice-id", voiceId],
[
...HEYGEN_CLIENT_SOURCE_ARGV,
"voice",
"speech",
"create",
"--text",
intent,
"--voice-id",
voiceId,
],
"tts",
);
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 });
}
});