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
@@ -1,31 +1,1364 @@
1
1
  #!/usr/bin/env node
2
+
2
3
  import { spawnSync } from "node:child_process";
3
- import { existsSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { resolveNpxInvocation } from "./lib/npx-sync.mjs";
7
-
8
- // Installed plugins have no monorepo packages/ tree. The plugin launcher passes
9
- // the release version and suppression environment through to this legacy shim.
10
- const pluginVersion = process.env.HYPERFRAMES_PLUGIN_VERSION;
11
- if (pluginVersion) {
12
- const invocation = resolveNpxInvocation(
13
- ["--yes", `hyperframes@${pluginVersion}`, "media-use", "resolve", ...process.argv.slice(2)],
14
- { stdio: "inherit" },
4
+ import { existsSync, statSync, writeFileSync, renameSync, rmSync, realpathSync } from "node:fs";
5
+ import { resolve, join, extname, basename, relative, isAbsolute, sep } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+ import {
8
+ AGENT_SOURCES,
9
+ appendRecord,
10
+ latestRecordFor,
11
+ recordInPlace,
12
+ findByPrompt,
13
+ findByEntity,
14
+ nextId,
15
+ typeDirPath,
16
+ withMediaLock,
17
+ withReservedFile,
18
+ withReservedFileSync,
19
+ } from "./lib/manifest.mjs";
20
+ import { regenerateIndex } from "./lib/index-gen.mjs";
21
+ import { cacheGet, cacheGetByEntity, importFromCache, cachePut } from "./lib/cache.mjs";
22
+ import {
23
+ runCapability,
24
+ listTypes,
25
+ providerMatches,
26
+ providerNamesFor,
27
+ providerTierFor,
28
+ } from "./lib/registry.mjs";
29
+ import { freezeUrl, freezeLocalFile, isDirectMediaUrl } from "./lib/freeze.mjs";
30
+ import { findExistingAsset } from "./lib/adopt.mjs";
31
+ import { probe as probeMedia } from "./lib/probe.mjs";
32
+ import { track } from "./lib/telemetry.mjs";
33
+ import { recordMiss } from "./lib/misses.mjs";
34
+ import { buildStats } from "./lib/stats.mjs";
35
+ import { typesMatch } from "./lib/match.mjs";
36
+ import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./lib/candidates.mjs";
37
+ import { findGlobalBySha } from "./lib/cache.mjs";
38
+ import { heygenAuthMethod } from "../audio/scripts/lib/heygen.mjs";
39
+ import { buildCube, paramsFromIntent } from "./lib/cube-build.mjs";
40
+ import { validateCubeFile } from "./lib/cube-validate.mjs";
41
+ import { analyzeMediaGrade, formatMeasuredNote } from "./lib/grade-analyzer.mjs";
42
+ import { FfBinarySettingError, ffmpegBinary, ffprobeBinary } from "./lib/ff-binaries.mjs";
43
+ import {
44
+ freezeLibraryLut,
45
+ isLibraryLutOfflineMiss,
46
+ matchColorLook,
47
+ } from "./lib/lut-preset-provider.mjs";
48
+ import {
49
+ HEYGEN_AUTH_COMMAND,
50
+ HEYGEN_INSTALL_COMMAND,
51
+ HEYGEN_MIN_VERSION,
52
+ HEYGEN_UPDATE_COMMAND,
53
+ consumeHeygenRemediation,
54
+ heygenMiss,
55
+ firstSemver,
56
+ flushHeygenFailureTracking,
57
+ versionLessThan,
58
+ } from "./lib/heygen-cli.mjs";
59
+ import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs";
60
+ import { OPENVERSE_SFX_PROVIDER } from "./lib/sfx-provider.mjs";
61
+ import { writeSfxCredit } from "./lib/openverse.mjs";
62
+ import {
63
+ fetchMediaVectors,
64
+ mediaVectorRows,
65
+ rankMediaRowsWithVectors,
66
+ } from "./lib/local-media-search.mjs";
67
+
68
+ const INGEST_TYPES = listTypes();
69
+ const DEFAULT_EXT = {
70
+ bgm: ".wav",
71
+ sfx: ".mp3",
72
+ voice: ".wav",
73
+ image: ".jpg",
74
+ icon: ".svg",
75
+ logo: ".svg",
76
+ brand: ".png",
77
+ video: ".mp4",
78
+ grade: ".cube",
79
+ lut: ".cube",
80
+ };
81
+
82
+ // resolve shells `fetch`/`freezeUrl` and modern ESM; 18 is the floor where those
83
+ // exist without flags. Named so the --doctor node check verifies something real
84
+ // (O2). Declared before the top-level `--doctor` branch that calls runDoctor().
85
+ const MIN_NODE_VERSION = "18.0.0";
86
+
87
+ const { values: args } = parseArgs({
88
+ options: {
89
+ type: { type: "string", short: "t" },
90
+ intent: { type: "string", short: "i" },
91
+ entity: { type: "string", short: "e" },
92
+ project: { type: "string", short: "p", default: "." },
93
+ adopt: { type: "boolean", default: false },
94
+ candidates: { type: "boolean", default: false },
95
+ doctor: { type: "boolean", default: false },
96
+ stats: { type: "boolean", default: false },
97
+ days: { type: "string" },
98
+ "dry-run": { type: "boolean", default: false },
99
+ reuse: { type: "string" },
100
+ from: { type: "string" },
101
+ source: { type: "string" },
102
+ params: { type: "string" },
103
+ for: { type: "string" },
104
+ analyze: { type: "boolean", default: false },
105
+ "local-only": { type: "boolean", default: false },
106
+ provider: { type: "string" },
107
+ "avatar-id": { type: "string" },
108
+ "voice-id": { type: "string" },
109
+ json: { type: "boolean", default: false },
110
+ help: { type: "boolean", short: "h", default: false },
111
+ },
112
+ strict: true,
113
+ });
114
+
115
+ if (args.help) {
116
+ console.log(`media-use resolve — turn a media need into a frozen local file
117
+
118
+ Usage:
119
+ node resolve.mjs --type <type> --intent "<description>" [--project <dir>]
120
+
121
+ Types: ${listTypes().join(", ")}
122
+
123
+ Options:
124
+ --type, -t Media type (required)
125
+ --intent, -i What you need (required)
126
+ --entity, -e Entity name for cache matching (optional)
127
+ --project, -p Project directory (default: .)
128
+ --adopt Adopt all existing assets/ files into the manifest
129
+ --candidates List reusable assets (project + global cache) for --type; no
130
+ download, no mutation. Read them and decide reuse yourself.
131
+ --doctor Check local CLI dependencies; no manifest changes.
132
+ --stats Print local usage stats from .media and ~/.media; no mutation.
133
+ --days <N> Limit --stats to records/misses from the last N days when
134
+ timestamps are available.
135
+ --reuse <sha> Import a specific global-cache asset (by content sha/prefix,
136
+ from --candidates) into this project
137
+ --from <file> Freeze a local file or direct public URL (ingest)
138
+ --source <how> With --from: how the file was made (${AGENT_SOURCES.join(" | ")}).
139
+ A file already inside the project is then recorded where it is
140
+ --params <json> Build an explicit parametric LUT (lut/grade only)
141
+ --for <media> Analyze a local image/video and add measured grade adjust
142
+ suggestions (grade only)
143
+ --analyze Return --for grade evidence without recording a candidate
144
+ --local-only Offline: skip every network provider
145
+ --provider Force one generator (e.g. codex, mflux, kokoro, heygen)
146
+ --avatar-id Override the default avatar for heygen.video generation
147
+ --voice-id Override the default voice for voice/heygen.video generation
148
+ --json Output JSON instead of one-line result
149
+ --help, -h Show this help`);
150
+ process.exit(0);
151
+ }
152
+
153
+ const projectDir = resolve(args.project);
154
+ const type = args.type;
155
+ const intent = args.intent;
156
+ const entity = args.entity || null;
157
+
158
+ if (args.adopt) {
159
+ const { adoptExistingAssets } = await import("./lib/adopt.mjs");
160
+ let adopted;
161
+ try {
162
+ adopted = adoptExistingAssets(projectDir);
163
+ } catch (err) {
164
+ exitError(err.message);
165
+ }
166
+ if (args.json) {
167
+ console.log(JSON.stringify({ ok: true, adopted: adopted.length, assets: adopted }));
168
+ } else if (adopted.length === 0) {
169
+ console.log("no new assets to adopt (assets/ empty or already registered)");
170
+ } else {
171
+ console.log(`adopted ${adopted.length} asset${adopted.length === 1 ? "" : "s"} from assets/`);
172
+ for (const r of adopted) console.log(` ${r.id} → ${r.path} (${r.type})`);
173
+ }
174
+ process.exit(0);
175
+ }
176
+
177
+ // Candidates: side-effect-free listing of reusable assets (project + global
178
+ // cache) for --type. No download, no provider, no mutation. The agent reads
179
+ // these and decides semantic fit itself.
180
+ if (args.candidates || args["dry-run"]) {
181
+ await showCandidates();
182
+ process.exit(0);
183
+ }
184
+
185
+ if (args.doctor) {
186
+ const doctor = runDoctor();
187
+ const failed = doctor.checks.filter((check) => !check.ok);
188
+ // Non-PII: instrument the exact question the feature exists to answer — how
189
+ // often is --doctor run and which check fails most. Awaited so a short-lived
190
+ // run flushes before exit.
191
+ await track("media_use_doctor_run", {
192
+ ok: doctor.ok,
193
+ checks_failed: failed.length,
194
+ failed: failed.map((check) => check.name),
195
+ });
196
+ if (args.json) {
197
+ console.log(JSON.stringify({ ok: doctor.ok, checks: doctor.checks }));
198
+ } else {
199
+ printDoctor(doctor.checks);
200
+ }
201
+ process.exit(doctor.ok ? 0 : 1);
202
+ }
203
+
204
+ if (args.stats) {
205
+ const report = buildStats({
206
+ projectDir,
207
+ days: args.days ? Number(args.days) : undefined,
208
+ });
209
+ if (args.json) {
210
+ console.log(JSON.stringify(report));
211
+ } else {
212
+ printStats(report);
213
+ }
214
+ process.exit(0);
215
+ }
216
+
217
+ // Reuse a global-cache asset selected by content sha or prefix.
218
+ if (args.reuse !== undefined) {
219
+ await reuseGlobal(args.reuse);
220
+ process.exit(0);
221
+ }
222
+
223
+ if (args.source && !args.from) {
224
+ console.error("error: --source goes with --from <file>");
225
+ process.exit(2);
226
+ }
227
+
228
+ // Ingest: freeze a user-supplied local file or direct public URL (no search).
229
+ if (args.from) {
230
+ await ingest(args.from);
231
+ process.exit(0);
232
+ }
233
+
234
+ if (args.analyze) {
235
+ if (type !== "grade" || !args.for) {
236
+ console.error("error: --analyze requires --type grade and --for <media>");
237
+ process.exit(2);
238
+ }
239
+ const mediaPath = resolve(args.for);
240
+ if (!existsSync(mediaPath)) {
241
+ console.error(`error: --for file not found: ${mediaPath}`);
242
+ process.exit(2);
243
+ }
244
+ let analysis;
245
+ try {
246
+ analysis = analyzeMediaGrade(mediaPath, {
247
+ ffmpegPath: ffmpegBinary(),
248
+ ffprobePath: ffprobeBinary(),
249
+ });
250
+ } catch (err) {
251
+ exitError(err.message);
252
+ }
253
+ if (args.json) {
254
+ console.log(JSON.stringify({ ok: true, type: "grade-analysis", ...analysis }));
255
+ } else {
256
+ console.log(formatMeasuredNote(mediaPath, analysis.measured));
257
+ console.log(`suggested adjust: ${JSON.stringify(analysis.adjust)}`);
258
+ }
259
+ process.exit(0);
260
+ }
261
+
262
+ // Resolve named recipe bundles without a provider.
263
+ if (type === "recipe") {
264
+ const { useRecipe } = await import("./lib/recipe-store.mjs");
265
+ const name = (entity || intent || "").trim();
266
+ if (!name) exitError("--type recipe needs --entity <name> (or --intent <name>)", 2);
267
+ try {
268
+ const used = useRecipe({ projectDir, name });
269
+ if (args.json) {
270
+ console.log(JSON.stringify({ ok: true, ...used }));
271
+ } else {
272
+ console.log(
273
+ `resolved recipe ${used.recipe.name} (v${used.recipe.version}, ${used.recipe.workflow})`,
274
+ );
275
+ console.log(` frame spec → ${used.frameSpecPath} (copied over)`);
276
+ console.log(` storyboard skeleton → ${used.skeletonPath}`);
277
+ if (used.briefSkeletonPath) console.log(` brief skeleton → ${used.briefSkeletonPath}`);
278
+ }
279
+ process.exit(0);
280
+ } catch (err) {
281
+ exitError(err.message, 1);
282
+ }
283
+ }
284
+
285
+ if (args.params !== undefined) {
286
+ if (type !== "lut" && type !== "grade") {
287
+ exitError(
288
+ type
289
+ ? `--params only supports --type lut or grade (got ${type})`
290
+ : "--params requires --type lut or grade",
291
+ 2,
292
+ );
293
+ }
294
+ try {
295
+ await runParams();
296
+ process.exit(0);
297
+ } catch (err) {
298
+ exitError(err.message, 1);
299
+ }
300
+ }
301
+
302
+ if (!args.type || !args.intent || !args.intent.trim()) {
303
+ console.error("error: --type and a non-empty --intent are required");
304
+ process.exit(2);
305
+ }
306
+
307
+ if (!listTypes().includes(args.type)) {
308
+ console.error(`error: unknown media type: ${args.type} (known: ${listTypes().join(", ")})`);
309
+ process.exit(2);
310
+ }
311
+
312
+ // Forced-provider validation: reject an unknown/unavailable provider name up
313
+ // front so a typo reads as a typo, not a catalog miss (`no provider could
314
+ // resolve`). Match rule mirrors runProviders (full name or dotted prefix).
315
+ if (args.provider && !providerMatches(args.type, args.provider)) {
316
+ console.error(
317
+ `error: unknown provider "${args.provider}" for type ${args.type} (available: ${providerNamesFor(args.type).join(", ")})`,
318
+ );
319
+ process.exit(2);
320
+ }
321
+
322
+ function recordAvailable(projectDir, record) {
323
+ if (!record) return false;
324
+ if (record.path) return existsSync(join(projectDir, record.path));
325
+ return record.type === "grade" && record.grading;
326
+ }
327
+
328
+ // Sparse `{ authMethod }` for a heygen-family provider name (e.g. "heygen.tts"),
329
+ // else `{}` — keeps auth_method telemetry absent for every non-heygen resolve
330
+ // instead of implying an auth method that doesn't apply.
331
+ function heygenAuthMethodFor(provider) {
332
+ if (!provider || !provider.startsWith("heygen.")) return {};
333
+ const authMethod = heygenAuthMethod();
334
+ return authMethod ? { authMethod } : {};
335
+ }
336
+
337
+ function localizeImportedRecord(record, localPath) {
338
+ if (record?.type === "grade" && record.grading?.lut) {
339
+ record.grading = {
340
+ ...record.grading,
341
+ lut: { ...record.grading.lut, src: localPath },
342
+ };
343
+ }
344
+ return record;
345
+ }
346
+
347
+ async function run() {
348
+ // A forced provider bypasses reuse and pins the provider cascade.
349
+ const forced = !!args.provider;
350
+
351
+ // 1. project manifest — exact-prompt match
352
+ const projectHit = forced ? null : findByPrompt(projectDir, intent, type);
353
+ if (recordAvailable(projectDir, projectHit)) {
354
+ return result(projectHit, "cached");
355
+ }
356
+
357
+ // 1b. entity match in project. icon and image are interchangeable for
358
+ // entity hits — both live in images/, and figma-imported brand marks are
359
+ // always recorded as type image while agents ask for logos as type icon.
360
+ if (!forced && entity) {
361
+ const entityHit = findByEntity(projectDir, entity);
362
+ if (entityHit && typesMatch(entityHit.type, type) && recordAvailable(projectDir, entityHit)) {
363
+ return result(entityHit, "cached");
364
+ }
365
+ }
366
+
367
+ // 1c. scan existing assets/ directory for unregistered matches
368
+ const existingAsset =
369
+ forced || type === "grade" || type === "lut"
370
+ ? null
371
+ : findExistingAsset(projectDir, intent, type);
372
+ if (existingAsset) {
373
+ const recorded = latestRecordFor(projectDir, existingAsset.relativePath);
374
+ if (recorded) return result(recorded, "cached");
375
+ const id = nextId(projectDir, type);
376
+ const record = {
377
+ id,
378
+ type: existingAsset.type,
379
+ path: existingAsset.relativePath,
380
+ source: "existing",
381
+ description: existingAsset.name.replace(/[-_]/g, " "),
382
+ provenance: { provider: "local", adopted: true, prompt: intent },
383
+ };
384
+ appendRecord(projectDir, record);
385
+ regenerateIndex(projectDir);
386
+ return result(record, "existing");
387
+ }
388
+
389
+ // 2. global cache — exact-prompt or entity match
390
+ const cacheHit = forced ? null : cacheGet(intent, type);
391
+ if (cacheHit) {
392
+ const ext = extname(cacheHit.cached_path);
393
+ const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) =>
394
+ localizeImportedRecord(importFromCache(cacheHit, projectDir, id, localPath), localPath),
395
+ );
396
+ if (imported) {
397
+ appendRecord(projectDir, imported);
398
+ regenerateIndex(projectDir);
399
+ return result(imported, "reused");
400
+ }
401
+ }
402
+
403
+ if (!forced && entity) {
404
+ const entityCacheHit = cacheGetByEntity(entity);
405
+ if (entityCacheHit && typesMatch(entityCacheHit.type, type)) {
406
+ const ext = extname(entityCacheHit.cached_path);
407
+ const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) =>
408
+ localizeImportedRecord(
409
+ importFromCache(entityCacheHit, projectDir, id, localPath),
410
+ localPath,
411
+ ),
412
+ );
413
+ if (imported) {
414
+ appendRecord(projectDir, imported);
415
+ regenerateIndex(projectDir);
416
+ return result(imported, "reused");
417
+ }
418
+ }
419
+ }
420
+
421
+ // --local-only skips every remote provider.
422
+ const localOnly = args["local-only"];
423
+ const ctx = {
424
+ entity,
425
+ projectDir,
426
+ localOnly,
427
+ provider: args.provider,
428
+ avatarId: args["avatar-id"],
429
+ voiceId: args["voice-id"],
430
+ };
431
+
432
+ // Suggest candidates before a new fetch.
433
+ try {
434
+ const { similar } = listCandidates({ projectDir, type, intent, cap: CANDIDATE_CAP });
435
+ if (similar > 0) {
436
+ console.error(
437
+ `media-use: ${similar} similar cached asset${similar === 1 ? "" : "s"} already ${similar === 1 ? "exists" : "exist"} — run \`resolve --candidates --type ${type} --intent "${intent}"\` to review and reuse instead of fetching.`,
438
+ );
439
+ }
440
+ } catch {
441
+ // hint is best-effort; never block a resolve
442
+ }
443
+
444
+ if (type === "grade" || type === "lut") {
445
+ return resolveColor(type, intent, { projectDir });
446
+ }
447
+
448
+ // SFX search is Openverse, the bundled library, then the local media index.
449
+ let searchResult = null;
450
+ let providerFailure = null;
451
+ let localIndexFailure = null;
452
+ try {
453
+ searchResult = await runCapability(type, "search", intent, ctx);
454
+ if (!searchResult && type === "sfx" && !args.provider && !localOnly) {
455
+ const registry =
456
+ process.env.HYPERFRAMES_REGISTRY ||
457
+ "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry";
458
+ try {
459
+ await fetchMediaVectors(registry);
460
+ const ranked = await rankMediaRowsWithVectors(
461
+ intent,
462
+ mediaVectorRows().filter((row) => row.kind === "sfx"),
463
+ );
464
+ const row = ranked.rows[0];
465
+ const candidates = row
466
+ ? [resolve(row.file), join(import.meta.dirname, "..", "..", "..", row.file)]
467
+ : [];
468
+ const localPath = candidates.find((candidate) => existsSync(candidate));
469
+ if (row && localPath) {
470
+ searchResult = {
471
+ localPath,
472
+ ext: extname(localPath),
473
+ source: "local-index",
474
+ metadata: {
475
+ description: row.description,
476
+ duration: row.duration ?? null,
477
+ provider: "catalog.local",
478
+ provenance: { library_key: row.id, tier: ranked.tier },
479
+ },
480
+ };
481
+ }
482
+ } catch (error) {
483
+ localIndexFailure = error;
484
+ }
485
+ }
486
+ } catch (error) {
487
+ providerFailure = error;
488
+ // search failed, try generate
489
+ }
490
+
491
+ if (localIndexFailure) {
492
+ throw new Error(`local SFX index unavailable: ${localIndexFailure.message}`, {
493
+ cause: localIndexFailure,
494
+ });
495
+ }
496
+
497
+ // 4. generate fallback — same ordered cascade for the generate capability
498
+ if (!searchResult) {
499
+ try {
500
+ searchResult = await runCapability(type, "generate", intent, ctx);
501
+ } catch (error) {
502
+ providerFailure ??= error;
503
+ // generate failed too
504
+ }
505
+ }
506
+
507
+ // Flush provider failure telemetry before the process can exit.
508
+ await flushHeygenFailureTracking();
509
+
510
+ if (!searchResult) {
511
+ await track("media_use_resolve_miss", {
512
+ type,
513
+ local_only: !!localOnly,
514
+ provider_override: !!args.provider,
515
+ });
516
+ recordMiss({
517
+ type,
518
+ intent,
519
+ provider_override: !!args.provider,
520
+ local_only: !!args["local-only"],
521
+ });
522
+ // brand stays local: no frame.md/design.md -> upsell the HyperFrames design
523
+ // flow rather than reporting a generic miss (B5).
524
+ const ownFailure =
525
+ providerFailure instanceof BundledSfxAssetsError ||
526
+ providerFailure instanceof FfBinarySettingError;
527
+ const heygen = consumeHeygenRemediation();
528
+ const miss = heygen && !ownFailure ? heygenMiss(type, heygen) : null;
529
+ const typed = providerFailure instanceof BundledSfxAssetsError ? providerFailure : miss;
530
+ const msg = ownFailure
531
+ ? providerFailure.message
532
+ : miss
533
+ ? miss.error
534
+ : type === "brand"
535
+ ? "no brand spec found — add a frame.md or design.md (colors/font/logo) to this project. Run the HyperFrames design flow to create one; brand tokens are read locally for deterministic rendering."
536
+ : args.provider
537
+ ? `provider "${args.provider}" could not resolve ${type}: "${intent}"${localOnly ? " (--local-only skips network providers; drop it or the --provider override)" : ""}`
538
+ : `no provider could resolve ${type}: "${intent}"`;
539
+ if (args.json) {
540
+ console.log(
541
+ JSON.stringify({
542
+ ok: false,
543
+ ...(typed ? { code: typed.code, fix: typed.fix } : {}),
544
+ error: msg,
545
+ }),
546
+ );
547
+ } else {
548
+ console.error(`error: ${msg}`);
549
+ }
550
+ process.exit(1);
551
+ }
552
+
553
+ // 5. freeze and register atomically.
554
+ const ext = searchResult.ext || extFromUrl(searchResult.url || "") || defaultExt(type);
555
+ const { id, localPath, fullPath } = await withReservedFile(
556
+ projectDir,
557
+ type,
558
+ ext,
559
+ async (reservation) => {
560
+ if (searchResult.localPath) {
561
+ freezeLocalFile(searchResult.localPath, reservation.fullPath);
562
+ } else if (searchResult.url) {
563
+ await freezeUrl(searchResult.url, reservation.fullPath);
564
+ } else {
565
+ throw new Error("provider returned no url or localPath");
566
+ }
567
+ return reservation;
568
+ },
569
+ );
570
+
571
+ const record = {
572
+ id,
573
+ type,
574
+ path: localPath,
575
+ source: searchResult.source || "search",
576
+ description: searchResult.metadata?.description || intent,
577
+ ...(searchResult.metadata?.duration != null && {
578
+ duration: Math.round(searchResult.metadata.duration * 10) / 10,
579
+ }),
580
+ ...(searchResult.metadata?.width != null && { width: searchResult.metadata.width }),
581
+ ...(searchResult.metadata?.height != null && { height: searchResult.metadata.height }),
582
+ ...(searchResult.metadata?.transparent != null && {
583
+ transparent: searchResult.metadata.transparent,
584
+ }),
585
+ ...(entity && { entity }),
586
+ provenance: {
587
+ provider: searchResult.metadata?.provider || "unknown",
588
+ prompt: intent,
589
+ // Keep auth method sparse for non-HeyGen providers.
590
+ ...heygenAuthMethodFor(searchResult.metadata?.provider),
591
+ ...searchResult.metadata?.provenance,
592
+ },
593
+ };
594
+
595
+ appendRecord(projectDir, record);
596
+ regenerateIndex(projectDir);
597
+ // Promote fetched assets into the global cache for reuse.
598
+ try {
599
+ cachePut(fullPath, record);
600
+ } catch {
601
+ // promotion is best-effort; a resolve still succeeds locally
602
+ }
603
+ return result(record, searchResult.source || "search");
604
+ }
605
+
606
+ function mergeSmartAdjust(block) {
607
+ if (!args.for) return block;
608
+ const mediaPath = resolve(args.for);
609
+ // Clear upfront error beats an ffmpeg "No such file" stack on a typo'd path.
610
+ if (!existsSync(mediaPath)) throw new Error(`--for file not found: ${mediaPath}`);
611
+ const analysis = analyzeMediaGrade(mediaPath, {
612
+ ffmpegPath: ffmpegBinary(),
613
+ ffprobePath: ffprobeBinary(),
614
+ });
615
+ console.error(formatMeasuredNote(mediaPath, analysis.measured));
616
+ return {
617
+ ...block,
618
+ adjust: {
619
+ ...(block.adjust || {}),
620
+ ...analysis.adjust,
621
+ },
622
+ };
623
+ }
624
+
625
+ function freezeGeneratedLut(
626
+ params,
627
+ {
628
+ projectDir,
629
+ type,
630
+ description = "parametric color grade",
631
+ validationErrorPrefix = "generated LUT failed validation",
632
+ },
633
+ ) {
634
+ return withReservedFileSync(projectDir, type, ".cube", ({ id, localPath, fullPath }) => {
635
+ const tmpPath = `${fullPath}.tmp`;
636
+ try {
637
+ // Write + validate at .tmp, then atomic rename, so a crash between write and
638
+ // validate can't leave an invalid .cube at the final path.
639
+ writeFileSync(tmpPath, buildCube(params));
640
+ const check = validateCubeFile(tmpPath);
641
+ if (!check.ok) throw new Error(check.error);
642
+ renameSync(tmpPath, fullPath);
643
+ } catch (err) {
644
+ rmSync(tmpPath, { force: true });
645
+ throw new Error(`${validationErrorPrefix}: ${err.message}`);
646
+ }
647
+ return {
648
+ id,
649
+ localPath,
650
+ fullPath,
651
+ lut: { src: localPath, intensity: 1 },
652
+ source: "generated",
653
+ description,
654
+ metadata: {
655
+ provider: "cube_lut.builder",
656
+ provenance: { params },
657
+ },
658
+ };
659
+ });
660
+ }
661
+
662
+ function exitError(message, status = 1) {
663
+ if (args.json) {
664
+ console.log(JSON.stringify({ ok: false, error: message }));
665
+ } else {
666
+ console.error(`error: ${message}`);
667
+ }
668
+ process.exit(status);
669
+ }
670
+
671
+ function parseExplicitParams() {
672
+ try {
673
+ return JSON.parse(args.params);
674
+ } catch (err) {
675
+ throw new Error(`invalid --params JSON: ${err.message}`);
676
+ }
677
+ }
678
+
679
+ async function runParams() {
680
+ if (type === "lut" && args.for) {
681
+ throw new Error("--for is only supported with --type grade");
682
+ }
683
+ const params = parseExplicitParams();
684
+ const description =
685
+ typeof intent === "string" && intent.trim()
686
+ ? intent.trim()
687
+ : `custom parametric ${type === "lut" ? "lut" : "grade"}`;
688
+ const frozen = freezeGeneratedLut(params, {
689
+ projectDir,
690
+ type,
691
+ description,
692
+ validationErrorPrefix: "--params produced an invalid LUT",
693
+ });
694
+ const record = {
695
+ id: frozen.id,
696
+ type,
697
+ path: frozen.localPath,
698
+ source: frozen.source,
699
+ description: frozen.description,
700
+ ...(type === "grade" && { grading: mergeSmartAdjust({ intensity: 1, lut: frozen.lut }) }),
701
+ provenance: {
702
+ provider: frozen.metadata.provider,
703
+ ...frozen.metadata.provenance,
704
+ },
705
+ };
706
+ return finalizeColorRecord(record, frozen.source, frozen.fullPath);
707
+ }
708
+
709
+ async function finalizeColorRecord(record, source, fullPath = null) {
710
+ appendRecord(projectDir, record);
711
+ regenerateIndex(projectDir);
712
+ if (fullPath) {
713
+ try {
714
+ cachePut(fullPath, record);
715
+ } catch {
716
+ // promotion is best-effort
717
+ }
718
+ }
719
+ return result(record, source);
720
+ }
721
+
722
+ async function colorMiss(type, intent) {
723
+ await track("media_use_resolve_miss", {
724
+ type,
725
+ local_only: !!args["local-only"],
726
+ provider_override: !!args.provider,
727
+ });
728
+ recordMiss({
729
+ type,
730
+ intent,
731
+ provider_override: !!args.provider,
732
+ local_only: !!args["local-only"],
733
+ });
734
+ const msg = `no local color grade could resolve ${type}: "${intent}"`;
735
+ if (args.json) {
736
+ console.log(JSON.stringify({ ok: false, error: msg }));
737
+ } else {
738
+ console.error(`error: ${msg}`);
739
+ }
740
+ process.exit(1);
741
+ }
742
+
743
+ async function resolveGrade(intent, { projectDir }) {
744
+ const match = matchColorLook(intent);
745
+ if (match?.kind === "preset") {
746
+ const id = nextId(projectDir, "grade");
747
+ const grading = mergeSmartAdjust({ preset: match.preset, intensity: 1 });
748
+ const record = {
749
+ id,
750
+ type: "grade",
751
+ source: "preset",
752
+ description: intent,
753
+ grading,
754
+ provenance: {
755
+ provider: "color_grade.local",
756
+ prompt: intent,
757
+ preset: match.preset,
758
+ },
759
+ };
760
+ return finalizeColorRecord(record, "preset");
761
+ }
762
+
763
+ if (match?.kind === "library") {
764
+ let frozen;
765
+ try {
766
+ frozen = await freezeLibraryLut(match, {
767
+ projectDir,
768
+ type: "grade",
769
+ localOnly: args["local-only"],
770
+ });
771
+ } catch (err) {
772
+ if (isLibraryLutOfflineMiss(err)) return colorMiss("grade", intent);
773
+ throw err;
774
+ }
775
+ const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut });
776
+ const record = {
777
+ id: frozen.id,
778
+ type: "grade",
779
+ path: frozen.localPath,
780
+ source: frozen.source,
781
+ description: frozen.description,
782
+ grading,
783
+ provenance: {
784
+ provider: frozen.metadata.provider,
785
+ prompt: intent,
786
+ ...frozen.metadata.provenance,
787
+ },
788
+ };
789
+ return finalizeColorRecord(record, frozen.source, frozen.fullPath);
790
+ }
791
+
792
+ const params = paramsFromIntent(intent);
793
+ if (!params) {
794
+ // No creative look matched. With --for, the measured adjust block is a
795
+ // valid grade on its own (footage auto-correction); only a true miss
796
+ // (no look AND no analysis) aborts.
797
+ if (args.for) {
798
+ const grading = mergeSmartAdjust({ intensity: 1 });
799
+ const record = {
800
+ id: nextId(projectDir, "grade"),
801
+ type: "grade",
802
+ source: "measured",
803
+ description: intent,
804
+ grading,
805
+ provenance: { provider: "color_grade.local", prompt: intent, measured: true },
806
+ };
807
+ return finalizeColorRecord(record, "measured");
808
+ }
809
+ return colorMiss("grade", intent);
810
+ }
811
+ const frozen = freezeGeneratedLut(params, { projectDir, type: "grade" });
812
+ const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut });
813
+ const record = {
814
+ id: frozen.id,
815
+ type: "grade",
816
+ path: frozen.localPath,
817
+ source: frozen.source,
818
+ description: intent,
819
+ grading,
820
+ provenance: {
821
+ provider: frozen.metadata.provider,
822
+ prompt: intent,
823
+ ...frozen.metadata.provenance,
824
+ },
825
+ };
826
+ return finalizeColorRecord(record, frozen.source, frozen.fullPath);
827
+ }
828
+
829
+ async function resolveLut(intent, { projectDir }) {
830
+ if (args.for) {
831
+ throw new Error("--for is only supported with --type grade");
832
+ }
833
+ const match = matchColorLook(intent);
834
+ if (match?.kind === "library") {
835
+ let frozen;
836
+ try {
837
+ frozen = await freezeLibraryLut(match, {
838
+ projectDir,
839
+ type: "lut",
840
+ localOnly: args["local-only"],
841
+ });
842
+ } catch (err) {
843
+ if (isLibraryLutOfflineMiss(err)) return colorMiss("lut", intent);
844
+ throw err;
845
+ }
846
+ const record = {
847
+ id: frozen.id,
848
+ type: "lut",
849
+ path: frozen.localPath,
850
+ source: frozen.source,
851
+ description: frozen.description,
852
+ provenance: {
853
+ provider: frozen.metadata.provider,
854
+ prompt: intent,
855
+ ...frozen.metadata.provenance,
856
+ },
857
+ };
858
+ return finalizeColorRecord(record, frozen.source, frozen.fullPath);
859
+ }
860
+
861
+ const params = paramsFromIntent(intent);
862
+ if (!params) return colorMiss("lut", intent);
863
+ const frozen = freezeGeneratedLut(params, { projectDir, type: "lut" });
864
+ const record = {
865
+ id: frozen.id,
866
+ type: "lut",
867
+ path: frozen.localPath,
868
+ source: frozen.source,
869
+ description: intent,
870
+ provenance: {
871
+ provider: frozen.metadata.provider,
872
+ prompt: intent,
873
+ ...frozen.metadata.provenance,
874
+ },
875
+ };
876
+ return finalizeColorRecord(record, frozen.source, frozen.fullPath);
877
+ }
878
+
879
+ async function resolveColor(type, intent, options) {
880
+ if (type === "grade") return resolveGrade(intent, options);
881
+ return resolveLut(intent, options);
882
+ }
883
+
884
+ async function ingest(src) {
885
+ if (!type || !INGEST_TYPES.includes(type)) {
886
+ console.error(`error: --from requires --type (one of: ${INGEST_TYPES.join(", ")})`);
887
+ process.exit(2);
888
+ }
889
+ const isUrl = /^https?:\/\//i.test(src);
890
+ if (isUrl && !isDirectMediaUrl(src)) {
891
+ console.error(
892
+ `error: --from takes a direct public media URL or a local file; "${src}" is not a direct media link (no platform pages / yt-dlp)`,
893
+ );
894
+ process.exit(2);
895
+ }
896
+ if (!isUrl && !existsSync(resolve(src))) {
897
+ console.error(`error: file not found: ${src}`);
898
+ process.exit(2);
899
+ }
900
+ // Refuse 0-byte input: an empty asset would register clean but fail at render
901
+ // (freezeUrl already rejects empty responses; this covers local files).
902
+ if (!isUrl && statSync(resolve(src)).size === 0) {
903
+ console.error(`error: refusing to ingest a 0-byte file: ${src}`);
904
+ process.exit(2);
905
+ }
906
+ if (args.source && !AGENT_SOURCES.includes(args.source)) {
907
+ console.error(`error: --source takes one of: ${AGENT_SOURCES.join(", ")}`);
908
+ process.exit(2);
909
+ }
910
+ if (args.source && (type === "lut" || type === "grade")) {
911
+ console.error("error: --source records media files; a LUT or grade is ingested without it");
912
+ process.exit(2);
913
+ }
914
+ const real = (path) => (existsSync(path) ? realpathSync(path) : path);
915
+ const inProject = args.source && !isUrl ? relative(real(projectDir), real(resolve(src))) : null;
916
+ if (
917
+ inProject &&
918
+ inProject !== ".." &&
919
+ !inProject.startsWith(`..${sep}`) &&
920
+ !isAbsolute(inProject)
921
+ ) {
922
+ return recordProjectFile(inProject.split(sep).join("/"));
923
+ }
924
+ const ext = extname(isUrl ? new URL(src).pathname : src) || defaultExt(type);
925
+ const { id, localPath, fullPath } = await withReservedFile(
926
+ projectDir,
927
+ type,
928
+ ext,
929
+ async (reservation) => {
930
+ if (isUrl) await freezeUrl(src, reservation.fullPath);
931
+ else freezeLocalFile(resolve(src), reservation.fullPath);
932
+ return reservation;
933
+ },
934
+ );
935
+ if (type === "lut" || type === "grade") {
936
+ try {
937
+ const check = validateCubeFile(fullPath);
938
+ if (!check.ok) throw new Error(check.error);
939
+ } catch (err) {
940
+ rmSync(fullPath, { force: true });
941
+ exitError(`ingested LUT is invalid: ${err.message}`, 1);
942
+ }
943
+ }
944
+ const record = {
945
+ id,
946
+ type,
947
+ path: localPath,
948
+ source: args.source || "ingested",
949
+ description: basename(src.split("?")[0]),
950
+ provenance: { provider: "local", from: src },
951
+ };
952
+ appendRecord(projectDir, record);
953
+ regenerateIndex(projectDir);
954
+ try {
955
+ cachePut(fullPath, record); // surface ingested assets globally too (B3)
956
+ } catch {
957
+ // best-effort
958
+ }
959
+ await result(record, "ingested");
960
+ }
961
+
962
+ async function recordProjectFile(path) {
963
+ let duration;
964
+ try {
965
+ duration = probeMedia(join(projectDir, path)).duration;
966
+ } catch (err) {
967
+ exitError(err.message);
968
+ }
969
+ const record = recordInPlace(projectDir, {
970
+ type,
971
+ path,
972
+ source: args.source,
973
+ description: intent,
974
+ duration,
975
+ provenance: {
976
+ provider: args.provider || "local",
977
+ from: path,
978
+ ...(intent && { prompt: intent }),
979
+ },
980
+ });
981
+ regenerateIndex(projectDir);
982
+ // "recorded", not the record's source, so usage counts keep meaning fetches.
983
+ await result(record, "recorded");
984
+ }
985
+
986
+ async function showCandidates() {
987
+ const projectDir = resolve(args.project);
988
+ const type = args.type;
989
+ if (!type || !listTypes().includes(type)) {
990
+ console.error(`error: --candidates requires --type (one of: ${listTypes().join(", ")})`);
991
+ process.exit(2);
992
+ }
993
+ const intent = args.intent || "";
994
+ const { candidates, truncated, total, similar } = listCandidates({
995
+ projectDir,
996
+ type,
997
+ intent,
998
+ cap: CANDIDATE_CAP,
999
+ });
1000
+ await track("media_use_candidates", {
1001
+ type,
1002
+ project_n: total.project,
1003
+ global_n: total.global,
1004
+ local_only: !!args["local-only"],
1005
+ });
1006
+ if (args.json) {
1007
+ console.log(JSON.stringify({ ok: true, candidates, truncated, total, similar }));
1008
+ } else {
1009
+ console.log(formatCandidates(candidates, { truncated, total }));
1010
+ }
1011
+ }
1012
+
1013
+ // Best-effort latest stable CLI tag from the CDN (the install script's source of
1014
+ // truth). null on any failure (offline, no curl) — treated as "unknown", never fatal.
1015
+ function latestHeygenStable() {
1016
+ const probe = runCommand("curl", [
1017
+ "-fsSL",
1018
+ "--max-time",
1019
+ "4",
1020
+ "https://static.heygen.ai/cli/stable",
1021
+ ]);
1022
+ return probe.status === 0 ? firstSemver(commandText(probe)) : null;
1023
+ }
1024
+
1025
+ function heygenAuthCheck() {
1026
+ // heygen auth status emits JSON by default; parse that output directly.
1027
+ const authProbe = runCommand("heygen", ["auth", "status"]);
1028
+ // spawnSync sets .error/.signal on a timeout or spawn failure (status then
1029
+ // null). A stalled auth endpoint (transient network/DNS) must not be reported
1030
+ // as an authoritative "not authenticated" with a re-login fix.
1031
+ const timedOut = authProbe.error?.code === "ETIMEDOUT" || authProbe.signal != null;
1032
+ const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null;
1033
+ return {
1034
+ name: "heygen authenticated",
1035
+ ok: !!email,
1036
+ detail: email
1037
+ ? `heygen authenticated as ${email}`
1038
+ : timedOut
1039
+ ? "heygen auth status timed out — possible network issue, not proof of sign-out"
1040
+ : "heygen not authenticated",
1041
+ fix: email ? "" : timedOut ? "check network, then re-run --doctor" : HEYGEN_AUTH_COMMAND,
1042
+ };
1043
+ }
1044
+
1045
+ function runDoctor() {
1046
+ const checks = [];
1047
+ const bundledSfx = inspectBundledSfxAssets();
1048
+ checks.push({
1049
+ name: "bundled SFX assets",
1050
+ ok: bundledSfx.ok,
1051
+ detail: bundledSfx.detail,
1052
+ fix: bundledSfx.fix,
1053
+ });
1054
+ const heygenVersionProbe = runCommand("heygen", ["--version"]);
1055
+ const heygenOnPath = heygenVersionProbe.status === 0;
1056
+ const heygenVersionText = commandText(heygenVersionProbe);
1057
+ const heygenVersion = firstSemver(heygenVersionText);
1058
+
1059
+ checks.push({
1060
+ name: "heygen on PATH",
1061
+ ok: heygenOnPath,
1062
+ // Just "is the binary here" — the version row below owns the version string,
1063
+ // so this row must not also render `heygen v0.3.0` (two byte-identical lines).
1064
+ detail: heygenOnPath ? "heygen found on PATH" : "heygen not found",
1065
+ fix: heygenOnPath ? "" : HEYGEN_INSTALL_COMMAND,
1066
+ });
1067
+
1068
+ if (!heygenOnPath) {
1069
+ checks.push({
1070
+ name: "heygen version",
1071
+ ok: false,
1072
+ detail: "heygen version unavailable",
1073
+ fix: HEYGEN_INSTALL_COMMAND,
1074
+ });
1075
+ checks.push({
1076
+ name: "heygen authenticated",
1077
+ ok: false,
1078
+ detail: "heygen auth status unavailable",
1079
+ fix: HEYGEN_INSTALL_COMMAND,
1080
+ });
1081
+ } else if (heygenVersion) {
1082
+ const versionOk = !versionLessThan(heygenVersion, HEYGEN_MIN_VERSION);
1083
+ // Keep it latest: even when the installed version clears the floor, nudge
1084
+ // `heygen update` if a newer stable exists. Best-effort — silently skipped
1085
+ // when the CDN is unreachable, so it never blocks the check.
1086
+ const latest = versionOk ? latestHeygenStable() : null;
1087
+ const behind = latest && versionLessThan(heygenVersion, latest);
1088
+ checks.push({
1089
+ name: "heygen version",
1090
+ ok: versionOk,
1091
+ detail: versionOk
1092
+ ? `heygen v${heygenVersion}${behind ? ` (latest v${latest} available)` : ""}`
1093
+ : `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`,
1094
+ fix: versionOk ? (behind ? HEYGEN_UPDATE_COMMAND : "") : HEYGEN_UPDATE_COMMAND,
1095
+ });
1096
+
1097
+ // Older CLI versions cannot provide the auth status used here.
1098
+ checks.push(
1099
+ versionOk
1100
+ ? heygenAuthCheck()
1101
+ : {
1102
+ name: "heygen authenticated",
1103
+ ok: false,
1104
+ detail: "skipped — update heygen first",
1105
+ fix: HEYGEN_UPDATE_COMMAND,
1106
+ },
1107
+ );
1108
+ } else {
1109
+ // Fail-open: heygen ran but printed no semver (dev/stripped build). We can't
1110
+ // verify the version, so we don't block on it — but say so rather than a bare
1111
+ // green check that implies a real version comparison happened.
1112
+ checks.push({
1113
+ name: "heygen version",
1114
+ ok: true,
1115
+ detail: "heygen present; version unverifiable (no semver in --version output)",
1116
+ fix: "",
1117
+ });
1118
+
1119
+ checks.push(heygenAuthCheck());
1120
+ }
1121
+
1122
+ checks.push(ffDoctorCheck("ffmpeg", ffmpegBinary), ffDoctorCheck("ffprobe", ffprobeBinary));
1123
+
1124
+ const nodeOk = !versionLessThan(process.versions.node, MIN_NODE_VERSION);
1125
+ checks.push({
1126
+ name: "node version",
1127
+ ok: nodeOk,
1128
+ detail: `${process.version} (need >= v${MIN_NODE_VERSION})`,
1129
+ fix: nodeOk ? "" : `upgrade Node to >= v${MIN_NODE_VERSION}`,
1130
+ });
1131
+
1132
+ // ffmpeg AND ffprobe are both strictly required (see references/setup-providers.md); the exit code
1133
+ // must reflect that so a script gating on `--doctor` doesn't pass with ffprobe
1134
+ // missing and then break at the first probe call.
1135
+ const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH");
1136
+ const ffprobe = checks.find((check) => check.name === "ffprobe on PATH");
1137
+ return { ok: bundledSfx.ok && !!ffmpeg?.ok && !!ffprobe?.ok, checks };
1138
+ }
1139
+
1140
+ function printDoctor(checks) {
1141
+ const heygenChecks = new Set(["heygen on PATH", "heygen version", "heygen authenticated"]);
1142
+ for (const check of checks) {
1143
+ const prefix = check.ok ? "✓" : "✗";
1144
+ const freePath = heygenChecks.has(check.name)
1145
+ ? " — free-usage path: bgm/image/voice/avatar-video"
1146
+ : "";
1147
+ const fix = check.ok || !check.fix ? "" : ` — fix: ${check.fix}`;
1148
+ console.log(`${prefix} ${check.detail}${freePath}${fix}`);
1149
+ }
1150
+ }
1151
+
1152
+ function printStats(report) {
1153
+ console.log("media-use stats");
1154
+ console.log(`total resolves: ${report.total_resolves}`);
1155
+ console.log(`misses: ${report.misses}`);
1156
+ console.log(
1157
+ `hit rate: ${report.hit_rate == null ? "n/a" : `${Math.round(report.hit_rate * 100)}%`}`,
1158
+ );
1159
+ printMap("by type", report.by_type);
1160
+ printMap("by source", report.by_source);
1161
+ printMap("by provider", report.by_provider);
1162
+ printMap("by via", report.by_via);
1163
+ console.log(`global cache assets: ${report.global_cache_assets}`);
1164
+ console.log(`global cache disk: ${report.global_cache_disk_bytes} bytes`);
1165
+ console.log(`cross-project reuse: ${report.cross_project_reuse}`);
1166
+ console.log("top missed intents:");
1167
+ const entries = Object.entries(report.top_missed_intents);
1168
+ if (entries.length === 0) {
1169
+ console.log(" none");
1170
+ return;
1171
+ }
1172
+ for (const [type, misses] of entries) {
1173
+ console.log(` ${type}:`);
1174
+ for (const miss of misses) console.log(` ${miss.count} ${miss.intent}`);
1175
+ }
1176
+ }
1177
+
1178
+ function printMap(label, values) {
1179
+ const entries = Object.entries(values);
1180
+ console.log(`${label}:`);
1181
+ if (entries.length === 0) {
1182
+ console.log(" none");
1183
+ return;
1184
+ }
1185
+ for (const [key, value] of entries) console.log(` ${key}: ${value}`);
1186
+ }
1187
+
1188
+ function ffDoctorCheck(name, binary) {
1189
+ let bin;
1190
+ try {
1191
+ bin = binary();
1192
+ } catch (err) {
1193
+ return {
1194
+ name: `${name} on PATH`,
1195
+ ok: false,
1196
+ detail: err.message,
1197
+ fix: "fix or unset that variable",
1198
+ };
1199
+ }
1200
+ const probe = runCommand(bin, ["-version"]);
1201
+ const ok = probe.status === 0;
1202
+ return {
1203
+ name: `${name} on PATH`,
1204
+ ok,
1205
+ detail: ok ? firstLine(probe.stdout) : `${name} not found`,
1206
+ fix: ok ? "" : "brew install ffmpeg",
1207
+ };
1208
+ }
1209
+
1210
+ function runCommand(bin, argv) {
1211
+ return spawnSync(bin, argv, {
1212
+ encoding: "utf8",
1213
+ timeout: 15000,
1214
+ });
1215
+ }
1216
+
1217
+ function commandText(result) {
1218
+ return [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
1219
+ }
1220
+
1221
+ function firstLine(text) {
1222
+ return (
1223
+ String(text || "")
1224
+ .trim()
1225
+ .split(/\r?\n/)[0] || ""
15
1226
  );
16
- const result = spawnSync(invocation.cmd, invocation.args, invocation.opts);
17
- if (result.error) console.error(result.error.message);
18
- process.exit(result.status ?? 1);
19
- }
20
- const root = join(fileURLToPath(new URL("../../../", import.meta.url)));
21
- const dist = join(root, "packages/cli/dist/cli.js");
22
- const result = existsSync(dist)
23
- ? spawnSync(process.execPath, [dist, "media-use", "resolve", ...process.argv.slice(2)], {
24
- stdio: "inherit",
25
- })
26
- : spawnSync(
27
- "bun",
28
- [join(root, "packages/cli/src/cli.ts"), "media-use", "resolve", ...process.argv.slice(2)],
29
- { stdio: "inherit" },
1227
+ }
1228
+
1229
+ function emailFromAuthStatus(text) {
1230
+ // JSON only (auth status emits JSON by default). No prose regex fallback: a
1231
+ // human-format body like "Session expired. Contact support@heygen.ai" would
1232
+ // otherwise report the user as authenticated as support@heygen.ai.
1233
+ const trimmed = String(text || "").trim();
1234
+ if (!trimmed.startsWith("{")) return null;
1235
+ try {
1236
+ const parsed = JSON.parse(trimmed);
1237
+ return parsed?.data?.email || parsed?.email || null;
1238
+ } catch {
1239
+ return null;
1240
+ }
1241
+ }
1242
+
1243
+ async function reuseGlobal(shaArg) {
1244
+ const projectDir = resolve(args.project);
1245
+ const type = args.type;
1246
+ if (!type || !listTypes().includes(type)) {
1247
+ console.error(`error: --reuse requires --type (one of: ${listTypes().join(", ")})`);
1248
+ process.exit(2);
1249
+ }
1250
+ if (!shaArg || !shaArg.trim()) {
1251
+ console.error("error: --reuse needs a content sha/prefix (from `resolve --candidates`)");
1252
+ process.exit(2);
1253
+ }
1254
+ const rec = findGlobalBySha(shaArg);
1255
+ if (rec && rec.ambiguous) {
1256
+ console.error(
1257
+ `error: sha prefix "${shaArg}" is ambiguous (${rec.count} matches) — use more characters`,
1258
+ );
1259
+ process.exit(2);
1260
+ }
1261
+ if (!rec) {
1262
+ console.error(`error: no reusable global asset matches sha "${shaArg}"`);
1263
+ process.exit(1);
1264
+ }
1265
+ // Do not import an asset under the wrong type.
1266
+ if (!typesMatch(rec.type, type)) {
1267
+ console.error(`error: sha "${shaArg}" is a ${rec.type} asset, not ${type}`);
1268
+ process.exit(2);
1269
+ }
1270
+ const ext = extname(rec.cached_path || "") || defaultExt(type);
1271
+ const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) =>
1272
+ localizeImportedRecord(importFromCache(rec, projectDir, id, localPath), localPath),
1273
+ );
1274
+ if (!imported) {
1275
+ console.error(`error: cache entry for "${shaArg}" is incomplete or missing on disk`);
1276
+ process.exit(1);
1277
+ }
1278
+ // Distinguish an explicit agent reuse from an automatic normalize-exact hit.
1279
+ imported.source = "reused-explicit";
1280
+ imported.provenance = { ...imported.provenance, reused_by: "agent" };
1281
+ appendRecord(projectDir, imported);
1282
+ regenerateIndex(projectDir);
1283
+ await result(imported, "reused-explicit");
1284
+ }
1285
+
1286
+ async function result(record, source) {
1287
+ const { provider, license, credit, source_url: sourceUrl } = record.provenance ?? {};
1288
+ // Under the media lock: two resolves at once each read, then rewrite, the whole credits file.
1289
+ if (provider === OPENVERSE_SFX_PROVIDER)
1290
+ withMediaLock(projectDir, () =>
1291
+ writeSfxCredit(join(typeDirPath(projectDir, "sfx"), "CREDITS.md"), record.path, {
1292
+ license,
1293
+ credit,
1294
+ sourceUrl,
1295
+ }),
1296
+ );
1297
+ // Non-PII usage event: which media type, how it resolved, which provider won.
1298
+ // Never the intent text or paths. Awaited so a short-lived run flushes it.
1299
+ await track("media_use_resolve", {
1300
+ type: record.type,
1301
+ source,
1302
+ provider: record.provenance?.provider,
1303
+ // How a library LUT resolved: "url" (CDN), "params-fallback" (CDN failed →
1304
+ // parametric), or "params" (offline). Surfaces silent CDN→params downgrades
1305
+ // in prod, which --doctor can't (it only answers "reachable now?").
1306
+ via: record.provenance?.via,
1307
+ // OAuth vs. API-key HeyGen paths are sparse for non-HeyGen providers.
1308
+ // signal about the fetch that actually consumed a heygen credit, not
1309
+ // about the (free, no-credential) act of copying a cached file.
1310
+ auth_method: record.provenance?.authMethod,
1311
+ // Provider tiers stay sparse and follow the registry's A/N/P declaration.
1312
+ provider_tier: providerTierFor(record.provenance?.provider),
1313
+ local_only: !!args["local-only"],
1314
+ provider_override: !!args.provider,
1315
+ });
1316
+ if (args.json) {
1317
+ const grading = record.type === "grade" && record.grading ? record.grading : null;
1318
+ console.log(
1319
+ JSON.stringify({
1320
+ ok: true,
1321
+ ...record,
1322
+ ...(grading || {}),
1323
+ ...(grading && { grading }),
1324
+ _source: source,
1325
+ }),
30
1326
  );
31
- process.exit(result.status ?? 1);
1327
+ } else {
1328
+ const meta = formatMeta(record, source);
1329
+ console.log(`resolved ${record.id} → ${record.path || "inline"} (${meta})`);
1330
+ }
1331
+ }
1332
+
1333
+ function formatMeta(record, source) {
1334
+ const parts = [record.type];
1335
+ if (record.grading?.preset) parts.push(`preset ${record.grading.preset}`);
1336
+ if (record.grading?.lut) parts.push("lut");
1337
+ if (record.duration != null) parts.push(`${record.duration}s`);
1338
+ if (record.width && record.height) parts.push(`${record.width}×${record.height}`);
1339
+ if (record.transparent) parts.push("transparent");
1340
+ if (source === "reused" || source === "reused-explicit") parts.push("reused");
1341
+ if (source === "generated") parts.push("generated");
1342
+ return parts.join(", ");
1343
+ }
1344
+
1345
+ function extFromUrl(url) {
1346
+ try {
1347
+ return extname(new URL(url).pathname) || null;
1348
+ } catch {
1349
+ return null;
1350
+ }
1351
+ }
1352
+
1353
+ function defaultExt(type) {
1354
+ return DEFAULT_EXT[type] || ".bin";
1355
+ }
1356
+
1357
+ run().catch((err) => {
1358
+ if (args.json) {
1359
+ console.log(JSON.stringify({ ok: false, error: err.message }));
1360
+ } else {
1361
+ console.error(`error: ${err.message}`);
1362
+ }
1363
+ process.exit(1);
1364
+ });