dsh-hyperframes 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.en.md +2 -1
  3. package/README.md +2 -1
  4. package/docs/WORKBENCH.md +6 -0
  5. package/lib/client.js +40 -2
  6. package/lib/media-templates.d.ts +1 -1
  7. package/lib/media-templates.js +1 -1
  8. package/package.json +2 -2
  9. package/plugin.json +1 -1
  10. package/skills/NOTICE.md +1 -1
  11. package/skills/UPSTREAM.json +93 -8
  12. package/skills/embedded-captions/SKILL.md +1 -1
  13. package/skills/faceless-explainer/SKILL.md +1 -1
  14. package/skills/general-video/SKILL.md +1 -1
  15. package/skills/hyperframes-animation/SKILL.md +1 -1
  16. package/skills/hyperframes-audio/SKILL.md +1 -1
  17. package/skills/hyperframes-core/SKILL.md +1 -1
  18. package/skills/hyperframes-creative/SKILL.md +1 -1
  19. package/skills/hyperframes-keyframes/SKILL.md +1 -1
  20. package/skills/hyperframes-studio/SKILL.md +1 -1
  21. package/skills/media-use/scripts/lib/adopt.mjs +128 -0
  22. package/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  23. package/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  24. package/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  25. package/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  26. package/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  27. package/skills/media-use/scripts/lib/cache.mjs +162 -0
  28. package/skills/media-use/scripts/lib/cache.test.mjs +110 -0
  29. package/skills/media-use/scripts/lib/candidates.mjs +91 -0
  30. package/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  31. package/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  32. package/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  33. package/skills/media-use/scripts/lib/config-lock.d.mts +3 -0
  34. package/skills/media-use/scripts/lib/config-lock.test.mjs +79 -0
  35. package/skills/media-use/scripts/lib/coverage.test.mjs +136 -0
  36. package/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  37. package/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  38. package/skills/media-use/scripts/lib/cube-validate.mjs +197 -0
  39. package/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  40. package/skills/media-use/scripts/lib/cutlist.test.mjs +174 -0
  41. package/skills/media-use/scripts/lib/duck.test.mjs +319 -0
  42. package/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  43. package/skills/media-use/scripts/lib/ff-binaries.mjs +37 -0
  44. package/skills/media-use/scripts/lib/freeze.mjs +72 -0
  45. package/skills/media-use/scripts/lib/freeze.test.mjs +138 -0
  46. package/skills/media-use/scripts/lib/grade-analyzer.mjs +277 -0
  47. package/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  48. package/skills/media-use/scripts/lib/heygen-cli.mjs +208 -0
  49. package/skills/media-use/scripts/lib/heygen-cli.test.mjs +315 -0
  50. package/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  51. package/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  52. package/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  53. package/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  54. package/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  55. package/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  56. package/skills/media-use/scripts/lib/local-models.mjs +318 -0
  57. package/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  58. package/skills/media-use/scripts/lib/local-run.mjs +78 -0
  59. package/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  60. package/skills/media-use/scripts/lib/logo-provider.mjs +244 -0
  61. package/skills/media-use/scripts/lib/logo-provider.test.mjs +298 -0
  62. package/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  63. package/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  64. package/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  65. package/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  66. package/skills/media-use/scripts/lib/main-module.mjs +13 -0
  67. package/skills/media-use/scripts/lib/manifest.d.mts +20 -0
  68. package/skills/media-use/scripts/lib/manifest.test.mjs +397 -0
  69. package/skills/media-use/scripts/lib/match.mjs +47 -0
  70. package/skills/media-use/scripts/lib/media-fetch.test.mjs +197 -0
  71. package/skills/media-use/scripts/lib/media-home.test.mjs +61 -0
  72. package/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  73. package/skills/media-use/scripts/lib/media-search.mjs +107 -0
  74. package/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  75. package/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  76. package/skills/media-use/scripts/lib/misses.mjs +49 -0
  77. package/skills/media-use/scripts/lib/misses.test.mjs +89 -0
  78. package/skills/media-use/scripts/lib/npx-sync.mjs +2 -28
  79. package/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  80. package/skills/media-use/scripts/lib/openverse.mjs +134 -0
  81. package/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
  82. package/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  83. package/skills/media-use/scripts/lib/prefs-store.concurrent.test.mjs +173 -0
  84. package/skills/media-use/scripts/lib/prefs-store.test.mjs +245 -0
  85. package/skills/media-use/scripts/lib/probe.mjs +46 -0
  86. package/skills/media-use/scripts/lib/probe.test.mjs +221 -0
  87. package/skills/media-use/scripts/lib/providers.mjs +5 -0
  88. package/skills/media-use/scripts/lib/recipe-store.test.mjs +391 -0
  89. package/skills/media-use/scripts/lib/registry.mjs +214 -0
  90. package/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  91. package/skills/media-use/scripts/lib/search.mjs +24 -0
  92. package/skills/media-use/scripts/lib/search.test.mjs +32 -0
  93. package/skills/media-use/scripts/lib/sfx-provider.mjs +33 -0
  94. package/skills/media-use/scripts/lib/specs.mjs +132 -0
  95. package/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  96. package/skills/media-use/scripts/lib/stats.mjs +117 -0
  97. package/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  98. package/skills/media-use/scripts/lib/svg-sanitize.mjs +21 -0
  99. package/skills/media-use/scripts/lib/telemetry.test.mjs +437 -53
  100. package/skills/media-use/scripts/lib/test-home.test.mjs +10 -0
  101. package/skills/media-use/scripts/lib/transcriptCutFade.test.mjs +33 -0
  102. package/skills/media-use/scripts/lib/tts-local-provider.mjs +94 -0
  103. package/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  104. package/skills/media-use/scripts/lib/usage.mjs +57 -0
  105. package/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  106. package/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  107. package/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  108. package/skills/media-use/scripts/resolve.mjs +1360 -27
  109. package/skills/motion-graphics/SKILL.md +1 -1
  110. package/skills/music-to-video/SKILL.md +1 -1
  111. package/skills/pr-to-video/SKILL.md +1 -1
  112. package/skills/product-launch-video/SKILL.md +1 -1
  113. package/skills/talking-head-recut/SKILL.md +1 -1
@@ -0,0 +1,315 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { spawnSync } from "node:child_process";
3
+ import { test } from "node:test";
4
+ import {
5
+ classifyHeygenError,
6
+ classifyHeygenErrorCode,
7
+ consumeHeygenRemediation,
8
+ flushHeygenFailureTracking,
9
+ HEYGEN_NOT_AUTHENTICATED_MESSAGE,
10
+ HEYGEN_NOT_FOUND_MESSAGE,
11
+ HEYGEN_OUTDATED_MESSAGE,
12
+ heygenMiss,
13
+ reportHeygenFailure,
14
+ } from "./heygen-cli.mjs";
15
+
16
+ function captureFailureReport(err, context, trackEvent) {
17
+ const originalError = console.error;
18
+ const stderrCalls = [];
19
+ console.error = (...args) => stderrCalls.push(args);
20
+ try {
21
+ if (trackEvent) {
22
+ reportHeygenFailure(err, context, trackEvent);
23
+ } else {
24
+ reportHeygenFailure(err, context);
25
+ }
26
+ } finally {
27
+ console.error = originalError;
28
+ }
29
+ return stderrCalls;
30
+ }
31
+
32
+ test("classifies ENOENT-style missing heygen errors with install instructions", () => {
33
+ const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" });
34
+
35
+ assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
36
+ });
37
+
38
+ test("classifies auth failures with login instructions", () => {
39
+ const message = classifyHeygenError({ stderr: Buffer.from("Error: not logged in") });
40
+
41
+ assert.equal(message, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
42
+ });
43
+
44
+ test("classifies a real 401 as auth, but not a bare 401 substring in prose", () => {
45
+ assert.equal(
46
+ classifyHeygenError({ stderr: Buffer.from("HTTP 401 Unauthorized") }),
47
+ HEYGEN_NOT_AUTHENTICATED_MESSAGE,
48
+ );
49
+ // A request id that merely contains "401" must NOT read as an auth failure.
50
+ const noise = classifyHeygenError({ stderr: Buffer.from("upload failed (request req-401abc)") });
51
+ assert.notEqual(noise, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
52
+ });
53
+
54
+ test("classifies old heygen versions with update instructions", () => {
55
+ const message = classifyHeygenError({
56
+ stderr: Buffer.from("heygen v0.1.5 does not support --headers"),
57
+ });
58
+
59
+ assert.equal(message, HEYGEN_OUTDATED_MESSAGE);
60
+ });
61
+
62
+ test("does not misclassify a resource 'not found' error as a missing CLI", () => {
63
+ // A stale voiceId makes `heygen voice speech create` fail with "voice not
64
+ // found"; the error message embeds the `heygen ...` command line. This must
65
+ // pass through as detail, not send the user to reinstall a working CLI.
66
+ const message = classifyHeygenError({
67
+ stderr: Buffer.from("Error: voice not found (id: stale-123)"),
68
+ message: "Command failed: heygen voice speech create --voice stale-123",
69
+ });
70
+
71
+ assert.notEqual(message, HEYGEN_NOT_FOUND_MESSAGE);
72
+ assert.equal(message, "Error: voice not found (id: stale-123)");
73
+ });
74
+
75
+ test("classifies a shell 'command not found' as a missing CLI", () => {
76
+ const message = classifyHeygenError({ stderr: Buffer.from("bash: heygen: command not found") });
77
+
78
+ assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
79
+ });
80
+
81
+ test("passes through unrelated errors", () => {
82
+ const message = classifyHeygenError({
83
+ stderr: Buffer.from("rate limit exceeded"),
84
+ message: "Command failed",
85
+ });
86
+
87
+ assert.equal(message, "rate limit exceeded");
88
+ });
89
+
90
+ test("classifies existing HeyGen failures with stable reason codes", () => {
91
+ assert.equal(classifyHeygenErrorCode({ code: "ENOENT" }), "not_found");
92
+ assert.equal(
93
+ classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 401 Unauthorized") }),
94
+ "not_authenticated",
95
+ );
96
+ assert.equal(
97
+ classifyHeygenErrorCode({ stderr: Buffer.from("heygen v0.1.5 is unsupported") }),
98
+ "outdated",
99
+ );
100
+ assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from("provider unavailable") }), "other");
101
+ });
102
+
103
+ test("classifies rate-limit text case-insensitively", () => {
104
+ assert.equal(
105
+ classifyHeygenErrorCode({ stderr: Buffer.from("RATE LIMIT exceeded") }),
106
+ "rate_limited",
107
+ );
108
+ });
109
+
110
+ test("classifies quota and insufficient-credit errors as rate limited", () => {
111
+ for (const detail of ["Quota exhausted", "INSUFFICIENT CREDIT remaining"]) {
112
+ assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited");
113
+ }
114
+ });
115
+
116
+ test("classifies the literal 429 reason phrase and throttling language as rate limited", () => {
117
+ for (const detail of ["Too Many Requests", "Error: throttled by upstream, retry later"]) {
118
+ assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited");
119
+ }
120
+ });
121
+
122
+ test("does not misclassify unrelated errors that share a word with the new phrasing", () => {
123
+ // Shares "too many" with "too many requests" but is a distinct failure (fd
124
+ // exhaustion, not a rate limit) — the match must require the full phrase.
125
+ assert.equal(
126
+ classifyHeygenErrorCode({ stderr: Buffer.from("Too many open file descriptors") }),
127
+ "other",
128
+ );
129
+ });
130
+
131
+ test("classifies a bare 429 as rate limited without matching request IDs", () => {
132
+ assert.equal(
133
+ classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 429 Too Many Requests") }),
134
+ "rate_limited",
135
+ );
136
+ assert.equal(
137
+ classifyHeygenErrorCode({ stderr: Buffer.from("request req-429abc failed") }),
138
+ "other",
139
+ );
140
+ });
141
+
142
+ test("tracks not-found failures without changing actionable output", () => {
143
+ const trackingCalls = [];
144
+ const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search", (...args) =>
145
+ trackingCalls.push(args),
146
+ );
147
+
148
+ assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
149
+ assert.deepEqual(trackingCalls, [
150
+ ["media_use_provider_error", { provider: "heygen", reason: "not_found" }],
151
+ ]);
152
+ });
153
+
154
+ test("records missing and outdated CLI remediation once", () => {
155
+ consumeHeygenRemediation();
156
+ captureFailureReport({ code: "ENOENT" }, "heygen audio sounds list", () => {});
157
+ assert.deepEqual(consumeHeygenRemediation(), {
158
+ code: "not_found",
159
+ message: HEYGEN_NOT_FOUND_MESSAGE,
160
+ });
161
+ assert.equal(consumeHeygenRemediation(), null);
162
+
163
+ captureFailureReport(
164
+ { stderr: Buffer.from("heygen v0.1.5 does not support --headers") },
165
+ "heygen audio sounds list",
166
+ () => {},
167
+ );
168
+ assert.deepEqual(consumeHeygenRemediation(), {
169
+ code: "outdated",
170
+ message: HEYGEN_OUTDATED_MESSAGE,
171
+ });
172
+ assert.equal(consumeHeygenRemediation(), null);
173
+ });
174
+
175
+ test("does not record non-install remediation", () => {
176
+ consumeHeygenRemediation();
177
+ for (const error of [
178
+ { stderr: Buffer.from("HTTP 401 Unauthorized") },
179
+ { stderr: Buffer.from("quota exhausted") },
180
+ { stderr: Buffer.from("provider unavailable") },
181
+ ]) {
182
+ captureFailureReport(error, "heygen audio sounds list", () => {});
183
+ assert.equal(consumeHeygenRemediation(), null);
184
+ }
185
+ });
186
+
187
+ test("tracks generic failures without including raw detail", () => {
188
+ const trackingCalls = [];
189
+ const stderrCalls = captureFailureReport(
190
+ { stderr: Buffer.from("private provider detail") },
191
+ "heygen asset search",
192
+ (...args) => trackingCalls.push(args),
193
+ );
194
+
195
+ assert.deepEqual(stderrCalls, [
196
+ ["media-use: `heygen asset search` failed: private provider detail"],
197
+ ]);
198
+ assert.deepEqual(trackingCalls, [
199
+ ["media_use_provider_error", { provider: "heygen", reason: "other" }],
200
+ ]);
201
+ });
202
+
203
+ test("keeps failure output observable when telemetry is opted out", () => {
204
+ const previousOptOut = process.env.HYPERFRAMES_NO_TELEMETRY;
205
+ process.env.HYPERFRAMES_NO_TELEMETRY = "1";
206
+ try {
207
+ const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search");
208
+
209
+ assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
210
+ } finally {
211
+ if (previousOptOut === undefined) {
212
+ delete process.env.HYPERFRAMES_NO_TELEMETRY;
213
+ } else {
214
+ process.env.HYPERFRAMES_NO_TELEMETRY = previousOptOut;
215
+ }
216
+ }
217
+ });
218
+
219
+ test("keeps failure output observable when tracking throws synchronously", () => {
220
+ const originalError = console.error;
221
+ const stderrCalls = [];
222
+ let thrown;
223
+ console.error = (...args) => stderrCalls.push(args);
224
+ try {
225
+ try {
226
+ reportHeygenFailure({ code: "ENOENT" }, "heygen asset search", () => {
227
+ throw new Error("tracking failed");
228
+ });
229
+ } catch (err) {
230
+ thrown = err;
231
+ }
232
+ } finally {
233
+ console.error = originalError;
234
+ }
235
+
236
+ assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
237
+ assert.equal(thrown, undefined);
238
+ });
239
+
240
+ test("does not leave rejected tracking promises unhandled", () => {
241
+ const moduleUrl = new URL("./heygen-cli.mjs", import.meta.url).href;
242
+ const script = `
243
+ import { reportHeygenFailure } from ${JSON.stringify(moduleUrl)};
244
+ reportHeygenFailure(
245
+ { stderr: "provider unavailable" },
246
+ "heygen asset search",
247
+ () => Promise.reject(new Error("tracking failed")),
248
+ );
249
+ await new Promise((resolve) => setImmediate(resolve));
250
+ `;
251
+ const child = spawnSync(
252
+ process.execPath,
253
+ ["--unhandled-rejections=strict", "--input-type=module", "--eval", script],
254
+ { encoding: "utf8", timeout: 5000 },
255
+ );
256
+
257
+ assert.equal(child.error, undefined);
258
+ assert.equal(child.signal, null);
259
+ assert.equal(child.status, 0, child.stderr);
260
+ assert.equal(child.stderr, "media-use: `heygen asset search` failed: provider unavailable\n");
261
+ });
262
+
263
+ test("flushHeygenFailureTracking waits for a pending report before resolving", async () => {
264
+ const events = [];
265
+ let releaseTrack;
266
+ const gate = new Promise((resolve) => {
267
+ releaseTrack = resolve;
268
+ });
269
+
270
+ // Mirrors the real call sites (voice-provider.mjs, heygen-search.mjs):
271
+ // fire-and-forget, the return value is never awaited by the caller.
272
+ reportHeygenFailure({ code: "ENOENT" }, "heygen voice speech", () =>
273
+ gate.then(() => {
274
+ events.push("track-settled");
275
+ }),
276
+ );
277
+
278
+ const flushed = flushHeygenFailureTracking().then(() => {
279
+ events.push("flush-resolved");
280
+ });
281
+
282
+ // Let several pending microtasks drain before releasing the gate, so this
283
+ // proves flush is genuinely still waiting on the tracked promise -- not
284
+ // merely that it hasn't had a tick yet.
285
+ await Promise.resolve();
286
+ await Promise.resolve();
287
+ await Promise.resolve();
288
+ assert.deepEqual(events, [], "flush must not resolve while the tracked promise is still pending");
289
+
290
+ releaseTrack();
291
+ await flushed;
292
+
293
+ assert.deepEqual(
294
+ events,
295
+ ["track-settled", "flush-resolved"],
296
+ "flush must resolve only after the pending track settles, in that order",
297
+ );
298
+ });
299
+
300
+ test("flushHeygenFailureTracking resolves immediately when nothing is pending", async () => {
301
+ await flushHeygenFailureTracking();
302
+ });
303
+
304
+ test("only a music miss names the heygen CLI and the host app's own tool", () => {
305
+ for (const type of ["sfx", "image", "icon", "voice", "video"])
306
+ assert.equal(heygenMiss(type, { code: "not_found" }), null, type);
307
+ assert.equal(heygenMiss("bgm", { code: "not_found" }).code, "heygen_cli_missing");
308
+ const bgm = heygenMiss("bgm", { code: "outdated" });
309
+ assert.equal(bgm.code, "heygen_cli_outdated");
310
+ assert.match(bgm.error, /host app's own music tool/);
311
+ });
312
+
313
+ test("a CLI that rejects --headers without printing a version is outdated", () => {
314
+ assert.equal(classifyHeygenErrorCode({ stderr: "Error: unknown flag: --headers" }), "outdated");
315
+ });
@@ -0,0 +1,45 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { HEYGEN_CLIENT_SOURCE_ARGV, reportHeygenFailure } from "./heygen-cli.mjs";
3
+
4
+ export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = {}) {
5
+ // execFileSync with an argv array (no shell), so query/type/etc. are passed as
6
+ // literal arguments — no quoting tricks, no command injection. subcommand is a
7
+ // hardcoded multi-word string (e.g. "audio sounds list"), split into tokens.
8
+ // Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.3.0).
9
+ const args = [...HEYGEN_CLIENT_SOURCE_ARGV, ...subcommand.split(" "), "--query", query];
10
+ if (type) args.push("--type", type);
11
+ args.push("--limit", String(limit));
12
+ // Server-side score floor. Honored by `audio sounds list`; the `asset search`
13
+ // backend rejects it, so only audio providers pass minScore (see image-provider).
14
+ if (minScore != null) args.push("--min-score", String(minScore));
15
+
16
+ let out;
17
+ try {
18
+ out = execFileSync("heygen", args, {
19
+ encoding: "utf8",
20
+ timeout: 15000,
21
+ stdio: ["pipe", "pipe", "pipe"],
22
+ });
23
+ } catch (err) {
24
+ // Don't swallow a broken command / auth failure as "no results" — that turns
25
+ // a typo or expired key into a silent dead end. Surface it, then give up.
26
+ reportHeygenFailure(err, `heygen ${subcommand}`);
27
+ return null;
28
+ }
29
+
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(out);
33
+ } catch {
34
+ console.error(`media-use: \`heygen ${subcommand}\` returned non-JSON output`);
35
+ return null;
36
+ }
37
+ if (parsed?.error) {
38
+ const e = parsed.error;
39
+ console.error(`media-use: \`heygen ${subcommand}\` error: ${e.message ?? JSON.stringify(e)}`);
40
+ return null;
41
+ }
42
+
43
+ const data = parsed?.data;
44
+ return Array.isArray(data) && data.length > 0 ? data : null;
45
+ }
@@ -0,0 +1,49 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { HEYGEN_CLIENT_SOURCE_HEADERS } from "../../audio/scripts/lib/heygen.mjs";
7
+ import { HEYGEN_CLIENT_SOURCE_ARGV } from "./heygen-cli.mjs";
8
+ import { heygenSearch } from "./heygen-search.mjs";
9
+
10
+ test("tags HeyGen searches with the shared media-use client source", () => {
11
+ const dir = mkdtempSync(join(tmpdir(), "media-use-heygen-search-"));
12
+ const capturePath = join(dir, "argv.log");
13
+ const heygenPath = join(dir, "heygen");
14
+ const previousPath = process.env.PATH;
15
+ const previousCapturePath = process.env.HEYGEN_CAPTURE_PATH;
16
+
17
+ writeFileSync(
18
+ heygenPath,
19
+ `#!/bin/sh
20
+ printf '%s\\n' "$*" >> "$HEYGEN_CAPTURE_PATH"
21
+ printf '%s\\n' '{"data":[{"id":"x"}]}'
22
+ `,
23
+ );
24
+ chmodSync(heygenPath, 0o755);
25
+ process.env.PATH = `${dir}:${previousPath ?? ""}`;
26
+ process.env.HEYGEN_CAPTURE_PATH = capturePath;
27
+
28
+ try {
29
+ const result = heygenSearch("audio sounds list", "ocean", { limit: 1 });
30
+ const argv = readFileSync(capturePath, "utf8").trim();
31
+
32
+ assert.deepEqual(result, [{ id: "x" }]);
33
+ assert.match(argv, /X-HeyGen-Client-Source: media-use/);
34
+ } finally {
35
+ if (previousPath === undefined) delete process.env.PATH;
36
+ else process.env.PATH = previousPath;
37
+ if (previousCapturePath === undefined) delete process.env.HEYGEN_CAPTURE_PATH;
38
+ else process.env.HEYGEN_CAPTURE_PATH = previousCapturePath;
39
+ rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test("keeps CLI and REST media-use client source headers in lockstep", () => {
44
+ const [entry] = Object.entries(HEYGEN_CLIENT_SOURCE_HEADERS);
45
+ assert.ok(entry);
46
+ const [key, value] = entry;
47
+
48
+ assert.equal(HEYGEN_CLIENT_SOURCE_ARGV[1], `${key}: ${value}`);
49
+ });
@@ -0,0 +1,138 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { freezeUrl } from "./freeze.mjs";
6
+ import {
7
+ classifyHeygenErrorCode,
8
+ HEYGEN_AUTH_COMMAND,
9
+ HEYGEN_CLIENT_SOURCE_ARGV,
10
+ reportHeygenFailure,
11
+ runHeygenJson,
12
+ } from "./heygen-cli.mjs";
13
+
14
+ export const AVATAR_VIDEO_SIGNIN_MESSAGE = `media-use: avatar video is free for new API users — sign in: ${HEYGEN_AUTH_COMMAND}`;
15
+
16
+ // Cache only a truthy id -- a transient discovery failure must not poison the
17
+ // cache with `null` and permanently disable heygen.video for the rest of the
18
+ // process. `onError` lets the caller distinguish not_authenticated (and nudge
19
+ // onboarding) from any other discovery failure.
20
+ let cachedAvatarId;
21
+ function defaultAvatarId(onError) {
22
+ if (cachedAvatarId) return cachedAvatarId;
23
+ const j = runHeygenJson(
24
+ "heygen",
25
+ ["avatar", "list", "--ownership", "public", "--limit", "1"],
26
+ "avatar list",
27
+ onError,
28
+ );
29
+ cachedAvatarId = j?.data?.[0]?.avatar_id || null;
30
+ return cachedAvatarId;
31
+ }
32
+
33
+ let cachedStarfishVoiceId;
34
+ function defaultStarfishVoiceId(onError) {
35
+ if (cachedStarfishVoiceId) return cachedStarfishVoiceId;
36
+ const j = runHeygenJson(
37
+ "heygen",
38
+ ["voice", "list", "--engine", "starfish", "--limit", "1"],
39
+ "voice list",
40
+ onError,
41
+ );
42
+ cachedStarfishVoiceId = j?.data?.[0]?.voice_id || null;
43
+ return cachedStarfishVoiceId;
44
+ }
45
+
46
+ export async function heygenVideoGenerate(intent, ctx) {
47
+ let discoveryFailureReason = null;
48
+ const captureReason = (reason) => {
49
+ discoveryFailureReason ??= reason;
50
+ };
51
+
52
+ // Short-circuit: once one discovery call fails, the result is null either
53
+ // way, so don't attempt the second -- that would double-fire the onboarding
54
+ // message and the provider-error telemetry ping for what's really one failure.
55
+ const avatarId = ctx?.avatarId || defaultAvatarId(captureReason);
56
+ if (!avatarId) {
57
+ if (discoveryFailureReason === "not_authenticated") console.error(AVATAR_VIDEO_SIGNIN_MESSAGE);
58
+ return null;
59
+ }
60
+ const voiceId = ctx?.voiceId || defaultStarfishVoiceId(captureReason);
61
+ if (!voiceId) {
62
+ if (discoveryFailureReason === "not_authenticated") console.error(AVATAR_VIDEO_SIGNIN_MESSAGE);
63
+ return null;
64
+ }
65
+
66
+ let out;
67
+ try {
68
+ out = execFileSync(
69
+ "heygen",
70
+ [
71
+ ...HEYGEN_CLIENT_SOURCE_ARGV,
72
+ "video",
73
+ "create",
74
+ "--wait",
75
+ "-d",
76
+ JSON.stringify({
77
+ type: "avatar",
78
+ avatar_id: avatarId,
79
+ script: intent,
80
+ voice_id: voiceId,
81
+ }),
82
+ ],
83
+ {
84
+ encoding: "utf8",
85
+ timeout: 300000,
86
+ stdio: ["pipe", "pipe", "pipe"],
87
+ },
88
+ );
89
+ } catch (err) {
90
+ if (classifyHeygenErrorCode(err) === "not_authenticated") {
91
+ console.error(AVATAR_VIDEO_SIGNIN_MESSAGE);
92
+ }
93
+ reportHeygenFailure(err, "heygen video create");
94
+ return null;
95
+ }
96
+
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(out);
100
+ } catch {
101
+ console.error("media-use: `heygen video create` returned invalid JSON");
102
+ return null;
103
+ }
104
+ const videoUrl = parsed?.data?.video_url;
105
+ if (typeof videoUrl !== "string" || !videoUrl) {
106
+ console.error("media-use: `heygen video create` returned no video URL");
107
+ return null;
108
+ }
109
+
110
+ let tmpDir;
111
+ let tmpPath;
112
+ try {
113
+ tmpDir = mkdtempSync(join(tmpdir(), "media-use-heygen-video-"));
114
+ tmpPath = join(tmpDir, "video.mp4");
115
+ await freezeUrl(videoUrl, tmpPath);
116
+ } catch (err) {
117
+ if (tmpDir) {
118
+ try {
119
+ rmSync(tmpDir, { recursive: true, force: true });
120
+ } catch {
121
+ // Preserve the download failure if temporary-directory cleanup fails.
122
+ }
123
+ }
124
+ console.error(`media-use: heygen video download failed: ${err.message}`);
125
+ return null;
126
+ }
127
+
128
+ return {
129
+ localPath: tmpPath,
130
+ ext: ".mp4",
131
+ source: "generated",
132
+ metadata: {
133
+ description: intent,
134
+ provider: "heygen.video",
135
+ provenance: { prompt: intent },
136
+ },
137
+ };
138
+ }