Files
hyperframes/skills/media-use/scripts/lib/heygen-video-provider.test.mjs
T
Miguel Ángel 0a66671fc5 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").
2026-07-17 03:56:57 -04:00

345 lines
12 KiB
JavaScript

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);
}
});