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,435 @@
1
+ import { strict as assert } from "node:assert";
2
+ import {
3
+ chmodSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ statSync,
8
+ symlinkSync,
9
+ readdirSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import http from "node:http";
13
+ import { tmpdir } from "node:os";
14
+ import { basename, dirname, join } from "node:path";
15
+ import { test } from "node:test";
16
+ import { HEYGEN_NOT_AUTHENTICATED_MESSAGE } from "./heygen-cli.mjs";
17
+ import { AVATAR_VIDEO_SIGNIN_MESSAGE } from "./heygen-video-provider.mjs";
18
+
19
+ function cleanupDownload(localPath) {
20
+ if (
21
+ basename(localPath) === "video.mp4" &&
22
+ basename(dirname(localPath)).startsWith("media-use-heygen-video-")
23
+ ) {
24
+ rmSync(dirname(localPath), { recursive: true, force: true });
25
+ } else {
26
+ rmSync(localPath, { force: true });
27
+ }
28
+ }
29
+
30
+ const VIDEO_FIXTURE = Buffer.from("tiny heygen video fixture");
31
+ let importCount = 0;
32
+
33
+ async function freshGenerate() {
34
+ importCount += 1;
35
+ const module = await import(`./heygen-video-provider.mjs?test=${importCount}`);
36
+ return module.heygenVideoGenerate;
37
+ }
38
+
39
+ async function listenVideoServer(t) {
40
+ const server = http.createServer((req, res) => {
41
+ if (req.url !== "/video.mp4") {
42
+ res.writeHead(404).end();
43
+ return;
44
+ }
45
+ res.writeHead(200, {
46
+ "content-length": VIDEO_FIXTURE.length,
47
+ "content-type": "video/mp4",
48
+ });
49
+ res.end(VIDEO_FIXTURE);
50
+ });
51
+ await new Promise((resolve) => server.listen(0, resolve));
52
+ const address = server.address();
53
+ assert.ok(address && typeof address !== "string");
54
+ // Keep the provider URL public; only the test transport reaches the local fixture.
55
+ const url = "https://media.example.com/video.mp4";
56
+ const originalFetch = globalThis.fetch;
57
+ t.mock.method(globalThis, "fetch", (requested, options) => {
58
+ assert.equal(requested, url);
59
+ assert.equal(options.redirect, "manual");
60
+ return originalFetch(`http://127.0.0.1:${address.port}/video.mp4`, options);
61
+ });
62
+ return { server, url };
63
+ }
64
+
65
+ async function listenFailingVideoServer(t) {
66
+ const server = http.createServer((req, res) => {
67
+ res.writeHead(500).end();
68
+ });
69
+ await new Promise((resolve) => server.listen(0, resolve));
70
+ const address = server.address();
71
+ assert.ok(address && typeof address !== "string");
72
+ // Keep the provider URL public; only the test transport reaches the local fixture.
73
+ const url = "https://media.example.com/video.mp4";
74
+ const originalFetch = globalThis.fetch;
75
+ t.mock.method(globalThis, "fetch", (requested, options) => {
76
+ assert.equal(requested, url);
77
+ assert.equal(options.redirect, "manual");
78
+ return originalFetch(`http://127.0.0.1:${address.port}/video.mp4`, options);
79
+ });
80
+ return { server, url };
81
+ }
82
+
83
+ function closeServer(server) {
84
+ return new Promise((resolve, reject) => {
85
+ server.close((err) => (err ? reject(err) : resolve()));
86
+ });
87
+ }
88
+
89
+ async function withFakeHeygen(options, run) {
90
+ const dir = mkdtempSync(join(tmpdir(), "media-use-heygen-video-provider-"));
91
+ const capturePath = join(dir, "argv.log");
92
+ const heygenPath = join(dir, "heygen");
93
+ const previousEnv = {
94
+ PATH: process.env.PATH,
95
+ HEYGEN_CAPTURE_PATH: process.env.HEYGEN_CAPTURE_PATH,
96
+ HEYGEN_VIDEO_MODE: process.env.HEYGEN_VIDEO_MODE,
97
+ HEYGEN_VIDEO_RESPONSE: process.env.HEYGEN_VIDEO_RESPONSE,
98
+ HEYGEN_DISCOVERY_MODE: process.env.HEYGEN_DISCOVERY_MODE,
99
+ HYPERFRAMES_NO_TELEMETRY: process.env.HYPERFRAMES_NO_TELEMETRY,
100
+ };
101
+
102
+ writeFileSync(
103
+ heygenPath,
104
+ `#!/bin/sh
105
+ printf '%s\\n' "$*" >> "$HEYGEN_CAPTURE_PATH"
106
+ case "$*" in
107
+ *"avatar list"*)
108
+ case "$HEYGEN_DISCOVERY_MODE" in
109
+ auth) printf '%s\\n' 'HTTP 401 Unauthorized' >&2; exit 1 ;;
110
+ *) printf '%s\\n' '{"data":[{"avatar_id":"avatar-public-1"}]}' ;;
111
+ esac
112
+ ;;
113
+ *"voice list"*)
114
+ case "$HEYGEN_DISCOVERY_MODE" in
115
+ auth) printf '%s\\n' 'HTTP 401 Unauthorized' >&2; exit 1 ;;
116
+ *) printf '%s\\n' '{"data":[{"voice_id":"voice-starfish-1"}]}' ;;
117
+ esac
118
+ ;;
119
+ *"video create"*)
120
+ case "$HEYGEN_VIDEO_MODE" in
121
+ auth) printf '%s\\n' 'HTTP 401 Unauthorized' >&2; exit 1 ;;
122
+ other) printf '%s\\n' 'provider unavailable' >&2; exit 1 ;;
123
+ *) printf '%s\\n' "$HEYGEN_VIDEO_RESPONSE" ;;
124
+ esac
125
+ ;;
126
+ esac
127
+ `,
128
+ );
129
+ chmodSync(heygenPath, 0o755);
130
+ process.env.PATH = `${dir}:${previousEnv.PATH ?? ""}`;
131
+ process.env.HEYGEN_CAPTURE_PATH = capturePath;
132
+ process.env.HEYGEN_VIDEO_MODE = options.mode ?? "success";
133
+ process.env.HEYGEN_VIDEO_RESPONSE = options.response ?? "";
134
+ process.env.HEYGEN_DISCOVERY_MODE = options.discoveryMode ?? "";
135
+ process.env.HYPERFRAMES_NO_TELEMETRY = "1";
136
+
137
+ try {
138
+ return await run({
139
+ invocations: () => readFileSync(capturePath, "utf8").trim().split("\n"),
140
+ });
141
+ } finally {
142
+ for (const [key, value] of Object.entries(previousEnv)) {
143
+ if (value === undefined) delete process.env[key];
144
+ else process.env[key] = value;
145
+ }
146
+ rmSync(dir, { recursive: true, force: true });
147
+ }
148
+ }
149
+
150
+ function bodyFromInvocation(invocation) {
151
+ const marker = " -d ";
152
+ const start = invocation.indexOf(marker);
153
+ assert.notEqual(start, -1);
154
+ return JSON.parse(invocation.slice(start + marker.length));
155
+ }
156
+
157
+ test("downloads a generated avatar video and returns the generated MP4 result", async (t) => {
158
+ const { server, url } = await listenVideoServer(t);
159
+ let localPath;
160
+ try {
161
+ await withFakeHeygen(
162
+ { response: JSON.stringify({ data: { video_url: url } }) },
163
+ async ({ invocations }) => {
164
+ const heygenVideoGenerate = await freshGenerate();
165
+ const intent = "Welcome to the HyperFrames launch";
166
+ const result = await heygenVideoGenerate(intent, {});
167
+ localPath = result?.localPath;
168
+ const calls = invocations();
169
+ const create = calls.find((call) => call.includes("video create"));
170
+
171
+ assert.ok(create);
172
+ assert.match(create, /--headers X-HeyGen-Client-Source: media-use/);
173
+ assert.deepEqual(bodyFromInvocation(create), {
174
+ type: "avatar",
175
+ avatar_id: "avatar-public-1",
176
+ script: intent,
177
+ voice_id: "voice-starfish-1",
178
+ });
179
+ assert.ok(result);
180
+ assert.equal(join(tmpdir(), result.localPath.slice(tmpdir().length + 1)), result.localPath);
181
+ assert.match(result.localPath, /media-use-heygen-video-[^/\\]+[/\\]video\.mp4$/);
182
+ if (process.platform !== "win32") {
183
+ assert.equal(statSync(dirname(result.localPath)).mode & 0o777, 0o700);
184
+ }
185
+ assert.deepEqual(result, {
186
+ localPath: result.localPath,
187
+ ext: ".mp4",
188
+ source: "generated",
189
+ metadata: {
190
+ description: intent,
191
+ provider: "heygen.video",
192
+ provenance: { prompt: intent },
193
+ },
194
+ });
195
+ assert.deepEqual(readFileSync(result.localPath), VIDEO_FIXTURE);
196
+ },
197
+ );
198
+ } finally {
199
+ if (localPath) cleanupDownload(localPath);
200
+ await closeServer(server);
201
+ }
202
+ });
203
+
204
+ test("tags video creation but not avatar or voice discovery", async (t) => {
205
+ const { server, url } = await listenVideoServer(t);
206
+ let localPath;
207
+ try {
208
+ await withFakeHeygen(
209
+ { response: JSON.stringify({ data: { video_url: url } }) },
210
+ async ({ invocations }) => {
211
+ const heygenVideoGenerate = await freshGenerate();
212
+ const result = await heygenVideoGenerate("Header regression guard", {});
213
+ localPath = result?.localPath;
214
+ const calls = invocations();
215
+
216
+ assert.equal(calls.length, 3);
217
+ assert.match(calls[0], /^avatar list --ownership public --limit 1$/);
218
+ assert.doesNotMatch(calls[0], /X-HeyGen-Client-Source/);
219
+ assert.match(calls[1], /^voice list --engine starfish --limit 1$/);
220
+ assert.doesNotMatch(calls[1], /X-HeyGen-Client-Source/);
221
+ assert.match(calls[2], /video create/);
222
+ assert.match(calls[2], /X-HeyGen-Client-Source: media-use/);
223
+ },
224
+ );
225
+ } finally {
226
+ if (localPath) cleanupDownload(localPath);
227
+ await closeServer(server);
228
+ }
229
+ });
230
+
231
+ test("uses explicit avatar and voice overrides without discovery", async (t) => {
232
+ const { server, url } = await listenVideoServer(t);
233
+ let localPath;
234
+ try {
235
+ await withFakeHeygen(
236
+ { response: JSON.stringify({ data: { video_url: url } }) },
237
+ async ({ invocations }) => {
238
+ const heygenVideoGenerate = await freshGenerate();
239
+ const result = await heygenVideoGenerate("Use my presenter", {
240
+ avatarId: "avatar-override",
241
+ voiceId: "voice-override",
242
+ });
243
+ localPath = result?.localPath;
244
+ const calls = invocations();
245
+
246
+ assert.equal(calls.length, 1);
247
+ assert.match(calls[0], /video create/);
248
+ assert.deepEqual(bodyFromInvocation(calls[0]), {
249
+ type: "avatar",
250
+ avatar_id: "avatar-override",
251
+ script: "Use my presenter",
252
+ voice_id: "voice-override",
253
+ });
254
+ },
255
+ );
256
+ } finally {
257
+ if (localPath) cleanupDownload(localPath);
258
+ await closeServer(server);
259
+ }
260
+ });
261
+
262
+ test("caches discovered avatar and voice IDs for the process", async (t) => {
263
+ const { server, url } = await listenVideoServer(t);
264
+ const localPaths = new Set();
265
+ try {
266
+ await withFakeHeygen(
267
+ { response: JSON.stringify({ data: { video_url: url } }) },
268
+ async ({ invocations }) => {
269
+ const heygenVideoGenerate = await freshGenerate();
270
+ for (const intent of ["First avatar video", "Second avatar video"]) {
271
+ const result = await heygenVideoGenerate(intent, {});
272
+ if (result) localPaths.add(result.localPath);
273
+ }
274
+ const calls = invocations();
275
+
276
+ assert.equal(calls.filter((call) => call.startsWith("avatar list ")).length, 1);
277
+ assert.equal(calls.filter((call) => call.startsWith("voice list ")).length, 1);
278
+ assert.equal(calls.filter((call) => call.includes("video create")).length, 2);
279
+ },
280
+ );
281
+ } finally {
282
+ for (const localPath of localPaths) cleanupDownload(localPath);
283
+ await closeServer(server);
284
+ }
285
+ });
286
+
287
+ test("prints auth onboarding and reports an unauthenticated create failure", async (t) => {
288
+ const errors = [];
289
+ t.mock.method(console, "error", (message) => errors.push(message));
290
+
291
+ await withFakeHeygen({ mode: "auth" }, async () => {
292
+ const heygenVideoGenerate = await freshGenerate();
293
+ const result = await heygenVideoGenerate("Sign-in failure", {
294
+ avatarId: "avatar-override",
295
+ voiceId: "voice-override",
296
+ });
297
+
298
+ assert.equal(result, null);
299
+ assert.ok(errors.includes(AVATAR_VIDEO_SIGNIN_MESSAGE));
300
+ assert.ok(errors.includes(HEYGEN_NOT_AUTHENTICATED_MESSAGE));
301
+ });
302
+ });
303
+
304
+ test("reports other create failures without auth onboarding", async (t) => {
305
+ const errors = [];
306
+ t.mock.method(console, "error", (message) => errors.push(message));
307
+
308
+ await withFakeHeygen({ mode: "other" }, async () => {
309
+ const heygenVideoGenerate = await freshGenerate();
310
+ const result = await heygenVideoGenerate("Provider failure", {
311
+ avatarId: "avatar-override",
312
+ voiceId: "voice-override",
313
+ });
314
+
315
+ assert.equal(result, null);
316
+ assert.ok(!errors.includes(AVATAR_VIDEO_SIGNIN_MESSAGE));
317
+ assert.ok(errors.includes("media-use: `heygen video create` failed: provider unavailable"));
318
+ });
319
+ });
320
+
321
+ test("falls through on non-JSON and error responses", async (t) => {
322
+ t.mock.method(console, "error", () => {});
323
+
324
+ for (const response of ["not JSON", '{"error":{"message":"render failed"}}']) {
325
+ await withFakeHeygen({ response }, async () => {
326
+ const heygenVideoGenerate = await freshGenerate();
327
+ const result = await heygenVideoGenerate("Unusable response", {
328
+ avatarId: "avatar-override",
329
+ voiceId: "voice-override",
330
+ });
331
+
332
+ assert.equal(result, null);
333
+ });
334
+ }
335
+ });
336
+
337
+ test("onboards and returns null when avatar/voice discovery itself is unauthenticated", async (t) => {
338
+ const errors = [];
339
+ t.mock.method(console, "error", (message) => errors.push(message));
340
+
341
+ // Both avatar list AND voice list would fail unauthenticated (discoveryMode
342
+ // "auth" applies to both in the fake CLI) -- the short-circuit after the
343
+ // first failure must mean only one is ever attempted, so the onboarding
344
+ // message and the provider-error telemetry ping each fire exactly once
345
+ // instead of double-firing for what's really one auth failure.
346
+ await withFakeHeygen({ discoveryMode: "auth" }, async ({ invocations }) => {
347
+ const heygenVideoGenerate = await freshGenerate();
348
+ const result = await heygenVideoGenerate("Discovery auth failure", {});
349
+
350
+ assert.equal(result, null);
351
+ assert.equal(
352
+ errors.filter((message) => message === AVATAR_VIDEO_SIGNIN_MESSAGE).length,
353
+ 1,
354
+ "onboarding message must fire exactly once, not once per failed discovery call",
355
+ );
356
+ const calls = invocations();
357
+ assert.equal(calls.length, 1, "must short-circuit after the first discovery failure");
358
+ assert.match(calls[0], /^avatar list /);
359
+ });
360
+ });
361
+
362
+ test("download failure after a successful create returns null and logs a diagnostic", async (t) => {
363
+ const { server, url } = await listenFailingVideoServer(t);
364
+ try {
365
+ await withFakeHeygen({ response: JSON.stringify({ data: { video_url: url } }) }, async () => {
366
+ const heygenVideoGenerate = await freshGenerate();
367
+ const result = await heygenVideoGenerate("Download failure", {
368
+ avatarId: "avatar-override",
369
+ voiceId: "voice-override",
370
+ });
371
+
372
+ assert.equal(result, null);
373
+ });
374
+ } finally {
375
+ await closeServer(server);
376
+ }
377
+ });
378
+
379
+ test("uses private unique downloads even when time is fixed and the old name is planted", async (t) => {
380
+ const root = mkdtempSync(join(tmpdir(), "hf-video-temp-test-"));
381
+ const previousTmpdir = process.env.TMPDIR;
382
+ process.env.TMPDIR = root;
383
+ t.mock.method(Date, "now", () => 123456);
384
+ t.mock.method(globalThis, "fetch", async () => ({
385
+ ok: true,
386
+ headers: { get: () => "4" },
387
+ body: [Buffer.from("new!")],
388
+ }));
389
+ try {
390
+ const victim = join(root, "victim");
391
+ writeFileSync(victim, "unchanged");
392
+ symlinkSync(victim, join(root, `media-use-heygen-video-${process.pid}-123456.mp4`));
393
+ await withFakeHeygen(
394
+ { response: JSON.stringify({ data: { video_url: "https://example.invalid/video.mp4" } }) },
395
+ async () => {
396
+ const generate = await freshGenerate();
397
+ const first = await generate("First", { avatarId: "a", voiceId: "v" });
398
+ const second = await generate("Second", { avatarId: "a", voiceId: "v" });
399
+ assert.ok(first && second);
400
+ assert.notEqual(first.localPath, second.localPath);
401
+ assert.equal(readFileSync(first.localPath, "utf8"), "new!");
402
+ assert.equal(readFileSync(second.localPath, "utf8"), "new!");
403
+ assert.equal(readFileSync(victim, "utf8"), "unchanged");
404
+ },
405
+ );
406
+ } finally {
407
+ if (previousTmpdir === undefined) delete process.env.TMPDIR;
408
+ else process.env.TMPDIR = previousTmpdir;
409
+ rmSync(root, { recursive: true, force: true });
410
+ }
411
+ });
412
+
413
+ test("removes private download staging on failure while returning null", async (t) => {
414
+ const root = mkdtempSync(join(tmpdir(), "hf-video-temp-fail-"));
415
+ const previousTmpdir = process.env.TMPDIR;
416
+ process.env.TMPDIR = root;
417
+ t.mock.method(globalThis, "fetch", async () => {
418
+ throw new Error("download failed");
419
+ });
420
+ t.mock.method(console, "error", () => {});
421
+ try {
422
+ await withFakeHeygen(
423
+ { response: JSON.stringify({ data: { video_url: "https://example.invalid/video.mp4" } }) },
424
+ async () => {
425
+ const generate = await freshGenerate();
426
+ assert.equal(await generate("Failure", { avatarId: "a", voiceId: "v" }), null);
427
+ },
428
+ );
429
+ assert.deepEqual(readdirSync(root), []);
430
+ } finally {
431
+ if (previousTmpdir === undefined) delete process.env.TMPDIR;
432
+ else process.env.TMPDIR = previousTmpdir;
433
+ rmSync(root, { recursive: true, force: true });
434
+ }
435
+ });
@@ -0,0 +1,44 @@
1
+ import { heygenSearch } from "./heygen-search.mjs";
2
+
3
+ export const imageProvider = {
4
+ async search(intent) {
5
+ const results = heygenSearch("asset search", intent, { type: "image" });
6
+ if (!results) return null;
7
+ const best = results[0];
8
+ return {
9
+ url: best.url,
10
+ source: "search",
11
+ // ext derived from the asset URL by resolve.mjs (.jpg/.png/.webp)
12
+ metadata: {
13
+ description: intent,
14
+ width: best.width || null,
15
+ height: best.height || null,
16
+ transparent: best.is_transparent || false,
17
+ provider: "heygen.asset.search",
18
+ provenance: { asset_id: best.id, score: best.score },
19
+ },
20
+ };
21
+ },
22
+ };
23
+
24
+ export const iconProvider = {
25
+ async search(intent) {
26
+ // No minScore: the `asset search` backend rejects --min-score and returns no score field.
27
+ const results = heygenSearch("asset search", intent, { type: "icon" });
28
+ if (!results) return null;
29
+ const best = results[0];
30
+ return {
31
+ url: best.url,
32
+ source: "search",
33
+ // ext derived from the asset URL by resolve.mjs — catalog icons are .png, not .svg
34
+ metadata: {
35
+ description: intent,
36
+ width: best.width || null,
37
+ height: best.height || null,
38
+ transparent: best.is_transparent ?? true,
39
+ provider: "heygen.asset.search",
40
+ provenance: { asset_id: best.id, score: best.score, type: "icon" },
41
+ },
42
+ };
43
+ },
44
+ };
@@ -0,0 +1,32 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const DEFAULT_DIRECTORY = join(homedir(), ".hyperframes", "catalog");
6
+
7
+ function directory() {
8
+ return process.env.HYPERFRAMES_CATALOG_ARTIFACT_DIR || DEFAULT_DIRECTORY;
9
+ }
10
+
11
+ export async function fetchMediaVectors(registryBaseUrl, options = {}) {
12
+ const target = options.directory || directory();
13
+ const base = registryBaseUrl.replace(/\/+$/, "");
14
+ mkdirSync(target, { recursive: true, mode: 0o700 });
15
+ const fetched = [];
16
+ for (const file of ["media-vectors.json", "media-vectors.bin"]) {
17
+ const response = await fetch(`${base}/catalog-artifact/${file}`);
18
+ if (!response.ok) return false;
19
+ fetched.push([file, Buffer.from(await response.arrayBuffer())]);
20
+ }
21
+ for (const [file, bytes] of fetched) writeFileSync(join(target, file), bytes, { mode: 0o600 });
22
+ return true;
23
+ }
24
+
25
+ export function mediaVectorRows(target = directory()) {
26
+ const metadataPath = join(target, "media-vectors.json");
27
+ if (!existsSync(metadataPath)) return [];
28
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
29
+ return Array.isArray(metadata.rows) ? metadata.rows : [];
30
+ }
31
+
32
+ export { rankMediaRowsWithVectors } from "./media-search.mjs";