From 1b3d845fd71582beb8901a98fa519dd34cb58052 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:24:06 -0400 Subject: [PATCH 01/21] feat(cli): move media-use engine into the CLI --- packages/cli/scripts/build-copy.mjs | 11 +- packages/cli/src/audio/scripts | 1 + packages/cli/src/cli.ts | 1 + packages/cli/src/commands/media-use.ts | 78 + .../cli/src/media-use}/lib/adopt.mjs | 0 .../cli/src/media-use}/lib/adopt.test.mjs | 0 .../cli/src/media-use}/lib/bgm-provider.mjs | 0 .../cli/src/media-use}/lib/brand-provider.mjs | 0 .../media-use}/lib/bundled-sfx-provider.mjs | 9 +- .../lib/bundled-sfx-provider.test.mjs | 0 .../cli/src/media-use}/lib/cache.mjs | 0 .../cli/src/media-use}/lib/candidates.mjs | 0 .../src/media-use}/lib/candidates.test.mjs | 0 .../cli/src/media-use}/lib/codex-provider.mjs | 0 .../media-use}/lib/codex-provider.test.mjs | 0 .../cli/src/media-use}/lib/coverage.test.mjs | 0 .../cli/src/media-use}/lib/cube-build.mjs | 0 .../src/media-use}/lib/cube-build.test.mjs | 0 .../cli/src/media-use}/lib/cube-validate.mjs | 0 .../src/media-use}/lib/cube-validate.test.mjs | 0 .../cli/src/media-use}/lib/cutlist.mjs | 0 .../cli/src/media-use}/lib/cutlist.test.mjs | 0 .../cli/src/media-use}/lib/duck.mjs | 0 .../cli/src/media-use}/lib/duck.test.mjs | 0 .../src/media-use}/lib/error-diffusion.mjs | 0 .../media-use}/lib/error-diffusion.test.mjs | 0 .../cli/src/media-use}/lib/freeze.mjs | 0 .../cli/src/media-use}/lib/freeze.test.mjs | 0 .../cli/src/media-use}/lib/grade-analyzer.mjs | 0 .../media-use}/lib/grade-analyzer.test.mjs | 0 .../cli/src/media-use}/lib/heygen-cli.mjs | 0 .../src/media-use}/lib/heygen-cli.test.mjs | 0 .../cli/src/media-use}/lib/heygen-search.mjs | 0 .../src/media-use}/lib/heygen-search.test.mjs | 0 .../media-use}/lib/heygen-video-provider.mjs | 0 .../lib/heygen-video-provider.test.mjs | 0 .../cli/src/media-use}/lib/image-provider.mjs | 0 .../cli/src/media-use}/lib/index-gen.mjs | 0 .../cli/src/media-use}/lib/local-models.mjs | 0 .../src/media-use}/lib/local-models.test.mjs | 0 .../cli/src/media-use}/lib/local-run.mjs | 0 .../cli/src/media-use}/lib/local-run.test.mjs | 0 .../cli/src/media-use}/lib/logo-provider.mjs | 0 .../src/media-use}/lib/logo-provider.test.mjs | 0 .../src/media-use}/lib/ltx-video-provider.mjs | 0 .../lib/ltx-video-provider.test.mjs | 0 .../media-use}/lib/lut-preset-provider.mjs | 5 +- .../lib/lut-preset-provider.test.mjs | 2 +- .../cli/src/media-use}/lib/manifest.mjs | 0 .../cli/src/media-use}/lib/manifest.test.mjs | 0 .../cli/src/media-use}/lib/match.mjs | 0 .../cli/src/media-use/lib/media-fetch.mjs | 71 + .../src/media-use}/lib/media-fetch.test.mjs | 0 .../cli/src/media-use}/lib/mflux-provider.mjs | 0 .../media-use}/lib/mflux-provider.test.mjs | 0 .../cli/src/media-use}/lib/misses.mjs | 0 .../cli/src/media-use}/lib/misses.test.mjs | 0 .../cli/src/media-use}/lib/npx-sync.mjs | 0 .../cli/src/media-use}/lib/npx-sync.test.mjs | 0 .../cli/src/media-use}/lib/parakeet-words.mjs | 0 .../media-use}/lib/parakeet-words.test.mjs | 0 .../cli/src/media-use}/lib/prefs-store.mjs | 0 .../src/media-use}/lib/prefs-store.test.mjs | 0 .../cli/src/media-use}/lib/probe.mjs | 0 .../cli/src/media-use}/lib/probe.test.mjs | 0 .../cli/src/media-use}/lib/providers.mjs | 0 .../cli/src/media-use}/lib/recipe-store.mjs | 0 .../src/media-use}/lib/recipe-store.test.mjs | 0 .../cli/src/media-use}/lib/registry.mjs | 0 .../cli/src/media-use}/lib/registry.test.mjs | 0 .../cli/src/media-use}/lib/search.mjs | 0 .../cli/src/media-use}/lib/search.test.mjs | 0 .../cli/src/media-use}/lib/sfx-provider.mjs | 0 .../cli/src/media-use}/lib/specs.mjs | 0 .../cli/src/media-use}/lib/specs.test.mjs | 0 .../cli/src/media-use}/lib/stats.mjs | 0 .../cli/src/media-use}/lib/stats.test.mjs | 0 .../cli/src/media-use}/lib/telemetry.mjs | 0 .../cli/src/media-use}/lib/telemetry.test.mjs | 0 .../src/media-use}/lib/tts-local-provider.mjs | 0 .../lib/tts-local-provider.test.mjs | 0 .../cli/src/media-use}/lib/usage.mjs | 0 .../cli/src/media-use}/lib/usage.test.mjs | 0 .../cli/src/media-use}/lib/voice-provider.mjs | 0 .../media-use}/lib/voice-provider.test.mjs | 0 .../cli/src/media-use}/lib/words.mjs | 0 packages/cli/src/media-use/resolve.mjs | 1271 ++++++++++++++++ .../cli/src/media-use}/resolve.test.mjs | 2 +- skills/media-use/SKILL.md | 4 +- skills/media-use/references/audio.md | 2 +- skills/media-use/references/grading.md | 14 +- .../references/media-treatment-recipes.md | 2 +- skills/media-use/references/meta.md | 2 +- skills/media-use/references/operations.md | 2 +- skills/media-use/references/resolve.md | 20 +- .../media-use/references/setup-providers.md | 2 +- skills/media-use/scripts/lib/media-fetch.mjs | 72 +- skills/media-use/scripts/resolve.mjs | 1272 +---------------- 98 files changed, 1471 insertions(+), 1372 deletions(-) create mode 120000 packages/cli/src/audio/scripts create mode 100644 packages/cli/src/commands/media-use.ts rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/adopt.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/adopt.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/bgm-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/brand-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/bundled-sfx-provider.mjs (90%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/bundled-sfx-provider.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/cache.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/candidates.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/candidates.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/codex-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/codex-provider.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/coverage.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/cube-build.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/cube-build.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/cube-validate.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/cube-validate.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/cutlist.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/cutlist.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/duck.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/duck.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/error-diffusion.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/error-diffusion.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/freeze.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/freeze.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/grade-analyzer.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/grade-analyzer.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/heygen-cli.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/heygen-cli.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/heygen-search.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/heygen-search.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/heygen-video-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/heygen-video-provider.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/image-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/index-gen.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/local-models.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/local-models.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/local-run.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/local-run.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/logo-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/logo-provider.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/ltx-video-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/ltx-video-provider.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/lut-preset-provider.mjs (97%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/lut-preset-provider.test.mjs (99%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/manifest.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/manifest.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/match.mjs (100%) create mode 100644 packages/cli/src/media-use/lib/media-fetch.mjs rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/media-fetch.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/mflux-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/mflux-provider.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/misses.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/misses.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/npx-sync.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/npx-sync.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/parakeet-words.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/parakeet-words.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/prefs-store.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/prefs-store.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/probe.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/probe.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/providers.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/recipe-store.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/recipe-store.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/registry.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/registry.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/search.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/search.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/sfx-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/specs.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/specs.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/stats.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/stats.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/telemetry.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/telemetry.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/tts-local-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/tts-local-provider.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/usage.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/usage.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/voice-provider.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/voice-provider.test.mjs (100%) rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/words.mjs (100%) create mode 100644 packages/cli/src/media-use/resolve.mjs rename {skills/media-use/scripts => packages/cli/src/media-use}/resolve.test.mjs (99%) mode change 100644 => 120000 skills/media-use/scripts/lib/media-fetch.mjs diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 847a6211aa..a09a1a9133 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -80,7 +80,7 @@ async function main() { // Skills bundled into the published CLI. Branches don't all carry the same // skills/ tree (it gets restructured), so each entry is existsSync-guarded: // a missing skill dir warns + skips instead of crashing the build. - for (const skill of ["hyperframes", "hyperframes-cli", "gsap"]) { + for (const skill of ["hyperframes", "hyperframes-cli", "gsap", "media-use"]) { const src = join(REPO_ROOT, "skills", skill); if (!existsSync(src)) { console.warn(`[build-copy] skill not found, skipping: skills/${skill}`); @@ -89,6 +89,15 @@ async function main() { copyDir(src, join(DIST, "skills", skill)); } + // The media-use engine lives with the CLI source, but keeps its published + // skill-relative layout so the moved .mjs tree can run without a rewrite. + const mediaEngine = join(CLI_ROOT, "src", "media-use"); + copyDir(join(mediaEngine, "lib"), join(DIST, "skills", "media-use", "scripts", "lib")); + cpSync( + join(mediaEngine, "resolve.mjs"), + join(DIST, "skills", "media-use", "scripts", "resolve.mjs"), + ); + const dockerfile = join(CLI_ROOT, "src", "docker", "Dockerfile.render"); if (existsSync(dockerfile)) { cpSync(dockerfile, join(DIST, "docker", "Dockerfile.render")); diff --git a/packages/cli/src/audio/scripts b/packages/cli/src/audio/scripts new file mode 120000 index 0000000000..cd39d7d4a2 --- /dev/null +++ b/packages/cli/src/audio/scripts @@ -0,0 +1 @@ +../../../../skills/media-use/audio/scripts \ No newline at end of file diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 357782ff7f..2e7cd0e2f3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -143,6 +143,7 @@ const commandLoaders = { init: () => import("./commands/init.js").then((m) => m.default), add: () => import("./commands/add.js").then((m) => m.default), catalog: () => import("./commands/catalog.js").then((m) => m.default), + "media-use": () => import("./commands/media-use.js").then((m) => m.default), play: () => import("./commands/play.js").then((m) => m.default), present: () => import("./commands/present.js").then((m) => m.default), preview: () => diff --git a/packages/cli/src/commands/media-use.ts b/packages/cli/src/commands/media-use.ts new file mode 100644 index 0000000000..5d1e49d17c --- /dev/null +++ b/packages/cli/src/commands/media-use.ts @@ -0,0 +1,78 @@ +import { defineCommand } from "citty"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { finishCommand } from "../utils/commandResult.js"; + +const MEDIA_USE_ARGS = { + type: { type: "string" }, + intent: { type: "string" }, + entity: { type: "string" }, + project: { type: "string", alias: "p" }, + adopt: { type: "boolean" }, + candidates: { type: "boolean" }, + doctor: { type: "boolean" }, + stats: { type: "boolean" }, + days: { type: "string" }, + "dry-run": { type: "boolean" }, + reuse: { type: "string" }, + from: { type: "string" }, + params: { type: "string" }, + for: { type: "string" }, + analyze: { type: "boolean" }, + "local-only": { type: "boolean" }, + provider: { type: "string" }, + "avatar-id": { type: "string" }, + "voice-id": { type: "string" }, + json: { type: "boolean" }, + help: { type: "boolean", alias: "h" }, +} as const; + +function enginePath(): string { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "..", "media-use", "resolve.mjs"), + join(here, "skills", "media-use", "scripts", "resolve.mjs"), + join(here, "..", "..", "skills", "media-use", "scripts", "resolve.mjs"), + ]; + const engine = candidates.find((candidate) => existsSync(candidate)); + if (!engine) throw new Error("media-use engine is missing from this CLI build"); + return engine; +} + +function invokeEngine(verb: string): never { + const commandIndex = process.argv.indexOf("media-use"); + const verbIndex = commandIndex + 1; + const passed = process.argv.slice(verbIndex + 1); + const flag = verb === "resolve" ? [] : [`--${verb}`]; + const result = spawnSync(process.execPath, [enginePath(), ...flag, ...passed], { + stdio: "inherit", + }); + if (result.error) throw result.error; + finishCommand(result.status ?? 1); +} + +function subcommand(name: string) { + return defineCommand({ + meta: { name, description: `media-use ${name}` }, + args: MEDIA_USE_ARGS, + run: () => invokeEngine(name), + }); +} + +export default defineCommand({ + meta: { name: "media-use", description: "Resolve and operate on project media" }, + subCommands: { + resolve: () => subcommand("resolve"), + doctor: () => subcommand("doctor"), + stats: () => subcommand("stats"), + adopt: () => subcommand("adopt"), + candidates: () => subcommand("candidates"), + reuse: () => subcommand("reuse"), + from: () => subcommand("from"), + params: () => subcommand("params"), + analyze: () => subcommand("analyze"), + }, + run: () => console.log("Run `hyperframes media-use --help`"), +}); diff --git a/skills/media-use/scripts/lib/adopt.mjs b/packages/cli/src/media-use/lib/adopt.mjs similarity index 100% rename from skills/media-use/scripts/lib/adopt.mjs rename to packages/cli/src/media-use/lib/adopt.mjs diff --git a/skills/media-use/scripts/lib/adopt.test.mjs b/packages/cli/src/media-use/lib/adopt.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/adopt.test.mjs rename to packages/cli/src/media-use/lib/adopt.test.mjs diff --git a/skills/media-use/scripts/lib/bgm-provider.mjs b/packages/cli/src/media-use/lib/bgm-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/bgm-provider.mjs rename to packages/cli/src/media-use/lib/bgm-provider.mjs diff --git a/skills/media-use/scripts/lib/brand-provider.mjs b/packages/cli/src/media-use/lib/brand-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/brand-provider.mjs rename to packages/cli/src/media-use/lib/brand-provider.mjs diff --git a/skills/media-use/scripts/lib/bundled-sfx-provider.mjs b/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs similarity index 90% rename from skills/media-use/scripts/lib/bundled-sfx-provider.mjs rename to packages/cli/src/media-use/lib/bundled-sfx-provider.mjs index 7011b45fcd..cb1a01a990 100644 --- a/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +++ b/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs @@ -1,9 +1,12 @@ import { existsSync, readFileSync } from "node:fs"; import { extname, join } from "node:path"; -const LIB_DIR = - process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR || - join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"); +const LIB_DIR = process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR || + [ + join(import.meta.dirname, "..", "audio", "assets", "sfx"), + join(import.meta.dirname, "..", "..", "..", "..", "..", "skills", "media-use", "audio", "assets", "sfx"), + ].find((candidate) => existsSync(candidate)) || + join(import.meta.dirname, "..", "audio", "assets", "sfx"); export const BUNDLED_SFX_RECOVERY_COMMAND = "npx hyperframes skills update media-use"; diff --git a/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs b/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs rename to packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs diff --git a/skills/media-use/scripts/lib/cache.mjs b/packages/cli/src/media-use/lib/cache.mjs similarity index 100% rename from skills/media-use/scripts/lib/cache.mjs rename to packages/cli/src/media-use/lib/cache.mjs diff --git a/skills/media-use/scripts/lib/candidates.mjs b/packages/cli/src/media-use/lib/candidates.mjs similarity index 100% rename from skills/media-use/scripts/lib/candidates.mjs rename to packages/cli/src/media-use/lib/candidates.mjs diff --git a/skills/media-use/scripts/lib/candidates.test.mjs b/packages/cli/src/media-use/lib/candidates.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/candidates.test.mjs rename to packages/cli/src/media-use/lib/candidates.test.mjs diff --git a/skills/media-use/scripts/lib/codex-provider.mjs b/packages/cli/src/media-use/lib/codex-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/codex-provider.mjs rename to packages/cli/src/media-use/lib/codex-provider.mjs diff --git a/skills/media-use/scripts/lib/codex-provider.test.mjs b/packages/cli/src/media-use/lib/codex-provider.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/codex-provider.test.mjs rename to packages/cli/src/media-use/lib/codex-provider.test.mjs diff --git a/skills/media-use/scripts/lib/coverage.test.mjs b/packages/cli/src/media-use/lib/coverage.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/coverage.test.mjs rename to packages/cli/src/media-use/lib/coverage.test.mjs diff --git a/skills/media-use/scripts/lib/cube-build.mjs b/packages/cli/src/media-use/lib/cube-build.mjs similarity index 100% rename from skills/media-use/scripts/lib/cube-build.mjs rename to packages/cli/src/media-use/lib/cube-build.mjs diff --git a/skills/media-use/scripts/lib/cube-build.test.mjs b/packages/cli/src/media-use/lib/cube-build.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/cube-build.test.mjs rename to packages/cli/src/media-use/lib/cube-build.test.mjs diff --git a/skills/media-use/scripts/lib/cube-validate.mjs b/packages/cli/src/media-use/lib/cube-validate.mjs similarity index 100% rename from skills/media-use/scripts/lib/cube-validate.mjs rename to packages/cli/src/media-use/lib/cube-validate.mjs diff --git a/skills/media-use/scripts/lib/cube-validate.test.mjs b/packages/cli/src/media-use/lib/cube-validate.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/cube-validate.test.mjs rename to packages/cli/src/media-use/lib/cube-validate.test.mjs diff --git a/skills/media-use/scripts/lib/cutlist.mjs b/packages/cli/src/media-use/lib/cutlist.mjs similarity index 100% rename from skills/media-use/scripts/lib/cutlist.mjs rename to packages/cli/src/media-use/lib/cutlist.mjs diff --git a/skills/media-use/scripts/lib/cutlist.test.mjs b/packages/cli/src/media-use/lib/cutlist.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/cutlist.test.mjs rename to packages/cli/src/media-use/lib/cutlist.test.mjs diff --git a/skills/media-use/scripts/lib/duck.mjs b/packages/cli/src/media-use/lib/duck.mjs similarity index 100% rename from skills/media-use/scripts/lib/duck.mjs rename to packages/cli/src/media-use/lib/duck.mjs diff --git a/skills/media-use/scripts/lib/duck.test.mjs b/packages/cli/src/media-use/lib/duck.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/duck.test.mjs rename to packages/cli/src/media-use/lib/duck.test.mjs diff --git a/skills/media-use/scripts/lib/error-diffusion.mjs b/packages/cli/src/media-use/lib/error-diffusion.mjs similarity index 100% rename from skills/media-use/scripts/lib/error-diffusion.mjs rename to packages/cli/src/media-use/lib/error-diffusion.mjs diff --git a/skills/media-use/scripts/lib/error-diffusion.test.mjs b/packages/cli/src/media-use/lib/error-diffusion.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/error-diffusion.test.mjs rename to packages/cli/src/media-use/lib/error-diffusion.test.mjs diff --git a/skills/media-use/scripts/lib/freeze.mjs b/packages/cli/src/media-use/lib/freeze.mjs similarity index 100% rename from skills/media-use/scripts/lib/freeze.mjs rename to packages/cli/src/media-use/lib/freeze.mjs diff --git a/skills/media-use/scripts/lib/freeze.test.mjs b/packages/cli/src/media-use/lib/freeze.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/freeze.test.mjs rename to packages/cli/src/media-use/lib/freeze.test.mjs diff --git a/skills/media-use/scripts/lib/grade-analyzer.mjs b/packages/cli/src/media-use/lib/grade-analyzer.mjs similarity index 100% rename from skills/media-use/scripts/lib/grade-analyzer.mjs rename to packages/cli/src/media-use/lib/grade-analyzer.mjs diff --git a/skills/media-use/scripts/lib/grade-analyzer.test.mjs b/packages/cli/src/media-use/lib/grade-analyzer.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/grade-analyzer.test.mjs rename to packages/cli/src/media-use/lib/grade-analyzer.test.mjs diff --git a/skills/media-use/scripts/lib/heygen-cli.mjs b/packages/cli/src/media-use/lib/heygen-cli.mjs similarity index 100% rename from skills/media-use/scripts/lib/heygen-cli.mjs rename to packages/cli/src/media-use/lib/heygen-cli.mjs diff --git a/skills/media-use/scripts/lib/heygen-cli.test.mjs b/packages/cli/src/media-use/lib/heygen-cli.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/heygen-cli.test.mjs rename to packages/cli/src/media-use/lib/heygen-cli.test.mjs diff --git a/skills/media-use/scripts/lib/heygen-search.mjs b/packages/cli/src/media-use/lib/heygen-search.mjs similarity index 100% rename from skills/media-use/scripts/lib/heygen-search.mjs rename to packages/cli/src/media-use/lib/heygen-search.mjs diff --git a/skills/media-use/scripts/lib/heygen-search.test.mjs b/packages/cli/src/media-use/lib/heygen-search.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/heygen-search.test.mjs rename to packages/cli/src/media-use/lib/heygen-search.test.mjs diff --git a/skills/media-use/scripts/lib/heygen-video-provider.mjs b/packages/cli/src/media-use/lib/heygen-video-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/heygen-video-provider.mjs rename to packages/cli/src/media-use/lib/heygen-video-provider.mjs diff --git a/skills/media-use/scripts/lib/heygen-video-provider.test.mjs b/packages/cli/src/media-use/lib/heygen-video-provider.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/heygen-video-provider.test.mjs rename to packages/cli/src/media-use/lib/heygen-video-provider.test.mjs diff --git a/skills/media-use/scripts/lib/image-provider.mjs b/packages/cli/src/media-use/lib/image-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/image-provider.mjs rename to packages/cli/src/media-use/lib/image-provider.mjs diff --git a/skills/media-use/scripts/lib/index-gen.mjs b/packages/cli/src/media-use/lib/index-gen.mjs similarity index 100% rename from skills/media-use/scripts/lib/index-gen.mjs rename to packages/cli/src/media-use/lib/index-gen.mjs diff --git a/skills/media-use/scripts/lib/local-models.mjs b/packages/cli/src/media-use/lib/local-models.mjs similarity index 100% rename from skills/media-use/scripts/lib/local-models.mjs rename to packages/cli/src/media-use/lib/local-models.mjs diff --git a/skills/media-use/scripts/lib/local-models.test.mjs b/packages/cli/src/media-use/lib/local-models.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/local-models.test.mjs rename to packages/cli/src/media-use/lib/local-models.test.mjs diff --git a/skills/media-use/scripts/lib/local-run.mjs b/packages/cli/src/media-use/lib/local-run.mjs similarity index 100% rename from skills/media-use/scripts/lib/local-run.mjs rename to packages/cli/src/media-use/lib/local-run.mjs diff --git a/skills/media-use/scripts/lib/local-run.test.mjs b/packages/cli/src/media-use/lib/local-run.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/local-run.test.mjs rename to packages/cli/src/media-use/lib/local-run.test.mjs diff --git a/skills/media-use/scripts/lib/logo-provider.mjs b/packages/cli/src/media-use/lib/logo-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/logo-provider.mjs rename to packages/cli/src/media-use/lib/logo-provider.mjs diff --git a/skills/media-use/scripts/lib/logo-provider.test.mjs b/packages/cli/src/media-use/lib/logo-provider.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/logo-provider.test.mjs rename to packages/cli/src/media-use/lib/logo-provider.test.mjs diff --git a/skills/media-use/scripts/lib/ltx-video-provider.mjs b/packages/cli/src/media-use/lib/ltx-video-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/ltx-video-provider.mjs rename to packages/cli/src/media-use/lib/ltx-video-provider.mjs diff --git a/skills/media-use/scripts/lib/ltx-video-provider.test.mjs b/packages/cli/src/media-use/lib/ltx-video-provider.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/ltx-video-provider.test.mjs rename to packages/cli/src/media-use/lib/ltx-video-provider.test.mjs diff --git a/skills/media-use/scripts/lib/lut-preset-provider.mjs b/packages/cli/src/media-use/lib/lut-preset-provider.mjs similarity index 97% rename from skills/media-use/scripts/lib/lut-preset-provider.mjs rename to packages/cli/src/media-use/lib/lut-preset-provider.mjs index a6c907accc..99aa6fc338 100644 --- a/skills/media-use/scripts/lib/lut-preset-provider.mjs +++ b/packages/cli/src/media-use/lib/lut-preset-provider.mjs @@ -6,7 +6,10 @@ import { tokenOverlap } from "./match.mjs"; import { buildCube } from "./cube-build.mjs"; import { validateCube, validateCubeFile } from "./cube-validate.mjs"; -const SKILL_DIR = join(import.meta.dirname, "..", ".."); +const SKILL_DIR = [ + join(import.meta.dirname, ".."), + join(import.meta.dirname, "..", "..", "..", "..", "..", "skills", "media-use"), +].find((candidate) => existsSync(join(candidate, "luts", "index.json"))); const LUT_DIR = join(SKILL_DIR, "luts"); const LUT_INDEX = join(LUT_DIR, "index.json"); export const LIBRARY_LUT_OFFLINE_CODE = "MEDIA_USE_LIBRARY_LUT_OFFLINE"; diff --git a/skills/media-use/scripts/lib/lut-preset-provider.test.mjs b/packages/cli/src/media-use/lib/lut-preset-provider.test.mjs similarity index 99% rename from skills/media-use/scripts/lib/lut-preset-provider.test.mjs rename to packages/cli/src/media-use/lib/lut-preset-provider.test.mjs index 7cfcd209b1..84afebf18b 100644 --- a/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +++ b/packages/cli/src/media-use/lib/lut-preset-provider.test.mjs @@ -22,7 +22,7 @@ import { import { buildCube } from "./cube-build.mjs"; import { validateCube, validateCubeFile } from "./cube-validate.mjs"; -const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", ".."); +const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..", ".."); function corePresetIdsFromSource() { const src = readFileSync(join(REPO_ROOT, "packages/core/src/colorGrading.ts"), "utf8"); diff --git a/skills/media-use/scripts/lib/manifest.mjs b/packages/cli/src/media-use/lib/manifest.mjs similarity index 100% rename from skills/media-use/scripts/lib/manifest.mjs rename to packages/cli/src/media-use/lib/manifest.mjs diff --git a/skills/media-use/scripts/lib/manifest.test.mjs b/packages/cli/src/media-use/lib/manifest.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/manifest.test.mjs rename to packages/cli/src/media-use/lib/manifest.test.mjs diff --git a/skills/media-use/scripts/lib/match.mjs b/packages/cli/src/media-use/lib/match.mjs similarity index 100% rename from skills/media-use/scripts/lib/match.mjs rename to packages/cli/src/media-use/lib/match.mjs diff --git a/packages/cli/src/media-use/lib/media-fetch.mjs b/packages/cli/src/media-use/lib/media-fetch.mjs new file mode 100644 index 0000000000..830e41618a --- /dev/null +++ b/packages/cli/src/media-use/lib/media-fetch.mjs @@ -0,0 +1,71 @@ +// Media downloads use public HTTP(S) URLs. Validate every redirect target; +// a provider result must meet the same host policy as a direct ingest URL. +// Public HTTPS-to-HTTP redirects are allowed, matching direct HTTP support. +// This is a literal-host policy, not DNS pinning: DNS resolution remains trusted. + +import { BlockList, isIP } from "node:net"; + +const blocked = new BlockList(); +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +]) + blocked.addSubnet(network, prefix, "ipv4"); +for (const [network, prefix] of [ + ["::", 128], + ["::1", 128], + ["fc00::", 7], + ["fe80::", 10], + ["fec0::", 10], + ["ff00::", 8], + ["2001:db8::", 32], +]) + blocked.addSubnet(network, prefix, "ipv6"); + +export function isPublicMediaUrl(value) { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + const host = url.hostname.replace(/\.$/, ""); + if ( + host === "localhost" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") + ) + return false; + const address = host.replace(/^\[|\]$/g, ""); + const family = isIP(address); + return family === 0 || !blocked.check(address, family === 4 ? "ipv4" : "ipv6"); + } catch { + return false; + } +} + +export async function fetchMedia(url, { method = "GET", signal, fetchImpl = fetch } = {}) { + let current = String(url); + for (let hop = 0; hop <= 5; hop++) { + if (!isPublicMediaUrl(current)) + throw new Error("Media download blocked: URL is not public HTTP(S)"); + const response = await fetchImpl(current, { method, signal, redirect: "manual" }); + if (!(response.status >= 300 && response.status < 400)) return response; + const location = response.headers.get("location"); + if (!location) return response; + await response.body?.cancel(); + current = new URL(location, current).href; + } + throw new Error("Media download exceeded redirect limit"); +} diff --git a/skills/media-use/scripts/lib/media-fetch.test.mjs b/packages/cli/src/media-use/lib/media-fetch.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/media-fetch.test.mjs rename to packages/cli/src/media-use/lib/media-fetch.test.mjs diff --git a/skills/media-use/scripts/lib/mflux-provider.mjs b/packages/cli/src/media-use/lib/mflux-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/mflux-provider.mjs rename to packages/cli/src/media-use/lib/mflux-provider.mjs diff --git a/skills/media-use/scripts/lib/mflux-provider.test.mjs b/packages/cli/src/media-use/lib/mflux-provider.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/mflux-provider.test.mjs rename to packages/cli/src/media-use/lib/mflux-provider.test.mjs diff --git a/skills/media-use/scripts/lib/misses.mjs b/packages/cli/src/media-use/lib/misses.mjs similarity index 100% rename from skills/media-use/scripts/lib/misses.mjs rename to packages/cli/src/media-use/lib/misses.mjs diff --git a/skills/media-use/scripts/lib/misses.test.mjs b/packages/cli/src/media-use/lib/misses.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/misses.test.mjs rename to packages/cli/src/media-use/lib/misses.test.mjs diff --git a/skills/media-use/scripts/lib/npx-sync.mjs b/packages/cli/src/media-use/lib/npx-sync.mjs similarity index 100% rename from skills/media-use/scripts/lib/npx-sync.mjs rename to packages/cli/src/media-use/lib/npx-sync.mjs diff --git a/skills/media-use/scripts/lib/npx-sync.test.mjs b/packages/cli/src/media-use/lib/npx-sync.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/npx-sync.test.mjs rename to packages/cli/src/media-use/lib/npx-sync.test.mjs diff --git a/skills/media-use/scripts/lib/parakeet-words.mjs b/packages/cli/src/media-use/lib/parakeet-words.mjs similarity index 100% rename from skills/media-use/scripts/lib/parakeet-words.mjs rename to packages/cli/src/media-use/lib/parakeet-words.mjs diff --git a/skills/media-use/scripts/lib/parakeet-words.test.mjs b/packages/cli/src/media-use/lib/parakeet-words.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/parakeet-words.test.mjs rename to packages/cli/src/media-use/lib/parakeet-words.test.mjs diff --git a/skills/media-use/scripts/lib/prefs-store.mjs b/packages/cli/src/media-use/lib/prefs-store.mjs similarity index 100% rename from skills/media-use/scripts/lib/prefs-store.mjs rename to packages/cli/src/media-use/lib/prefs-store.mjs diff --git a/skills/media-use/scripts/lib/prefs-store.test.mjs b/packages/cli/src/media-use/lib/prefs-store.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/prefs-store.test.mjs rename to packages/cli/src/media-use/lib/prefs-store.test.mjs diff --git a/skills/media-use/scripts/lib/probe.mjs b/packages/cli/src/media-use/lib/probe.mjs similarity index 100% rename from skills/media-use/scripts/lib/probe.mjs rename to packages/cli/src/media-use/lib/probe.mjs diff --git a/skills/media-use/scripts/lib/probe.test.mjs b/packages/cli/src/media-use/lib/probe.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/probe.test.mjs rename to packages/cli/src/media-use/lib/probe.test.mjs diff --git a/skills/media-use/scripts/lib/providers.mjs b/packages/cli/src/media-use/lib/providers.mjs similarity index 100% rename from skills/media-use/scripts/lib/providers.mjs rename to packages/cli/src/media-use/lib/providers.mjs diff --git a/skills/media-use/scripts/lib/recipe-store.mjs b/packages/cli/src/media-use/lib/recipe-store.mjs similarity index 100% rename from skills/media-use/scripts/lib/recipe-store.mjs rename to packages/cli/src/media-use/lib/recipe-store.mjs diff --git a/skills/media-use/scripts/lib/recipe-store.test.mjs b/packages/cli/src/media-use/lib/recipe-store.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/recipe-store.test.mjs rename to packages/cli/src/media-use/lib/recipe-store.test.mjs diff --git a/skills/media-use/scripts/lib/registry.mjs b/packages/cli/src/media-use/lib/registry.mjs similarity index 100% rename from skills/media-use/scripts/lib/registry.mjs rename to packages/cli/src/media-use/lib/registry.mjs diff --git a/skills/media-use/scripts/lib/registry.test.mjs b/packages/cli/src/media-use/lib/registry.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/registry.test.mjs rename to packages/cli/src/media-use/lib/registry.test.mjs diff --git a/skills/media-use/scripts/lib/search.mjs b/packages/cli/src/media-use/lib/search.mjs similarity index 100% rename from skills/media-use/scripts/lib/search.mjs rename to packages/cli/src/media-use/lib/search.mjs diff --git a/skills/media-use/scripts/lib/search.test.mjs b/packages/cli/src/media-use/lib/search.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/search.test.mjs rename to packages/cli/src/media-use/lib/search.test.mjs diff --git a/skills/media-use/scripts/lib/sfx-provider.mjs b/packages/cli/src/media-use/lib/sfx-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/sfx-provider.mjs rename to packages/cli/src/media-use/lib/sfx-provider.mjs diff --git a/skills/media-use/scripts/lib/specs.mjs b/packages/cli/src/media-use/lib/specs.mjs similarity index 100% rename from skills/media-use/scripts/lib/specs.mjs rename to packages/cli/src/media-use/lib/specs.mjs diff --git a/skills/media-use/scripts/lib/specs.test.mjs b/packages/cli/src/media-use/lib/specs.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/specs.test.mjs rename to packages/cli/src/media-use/lib/specs.test.mjs diff --git a/skills/media-use/scripts/lib/stats.mjs b/packages/cli/src/media-use/lib/stats.mjs similarity index 100% rename from skills/media-use/scripts/lib/stats.mjs rename to packages/cli/src/media-use/lib/stats.mjs diff --git a/skills/media-use/scripts/lib/stats.test.mjs b/packages/cli/src/media-use/lib/stats.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/stats.test.mjs rename to packages/cli/src/media-use/lib/stats.test.mjs diff --git a/skills/media-use/scripts/lib/telemetry.mjs b/packages/cli/src/media-use/lib/telemetry.mjs similarity index 100% rename from skills/media-use/scripts/lib/telemetry.mjs rename to packages/cli/src/media-use/lib/telemetry.mjs diff --git a/skills/media-use/scripts/lib/telemetry.test.mjs b/packages/cli/src/media-use/lib/telemetry.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/telemetry.test.mjs rename to packages/cli/src/media-use/lib/telemetry.test.mjs diff --git a/skills/media-use/scripts/lib/tts-local-provider.mjs b/packages/cli/src/media-use/lib/tts-local-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/tts-local-provider.mjs rename to packages/cli/src/media-use/lib/tts-local-provider.mjs diff --git a/skills/media-use/scripts/lib/tts-local-provider.test.mjs b/packages/cli/src/media-use/lib/tts-local-provider.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/tts-local-provider.test.mjs rename to packages/cli/src/media-use/lib/tts-local-provider.test.mjs diff --git a/skills/media-use/scripts/lib/usage.mjs b/packages/cli/src/media-use/lib/usage.mjs similarity index 100% rename from skills/media-use/scripts/lib/usage.mjs rename to packages/cli/src/media-use/lib/usage.mjs diff --git a/skills/media-use/scripts/lib/usage.test.mjs b/packages/cli/src/media-use/lib/usage.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/usage.test.mjs rename to packages/cli/src/media-use/lib/usage.test.mjs diff --git a/skills/media-use/scripts/lib/voice-provider.mjs b/packages/cli/src/media-use/lib/voice-provider.mjs similarity index 100% rename from skills/media-use/scripts/lib/voice-provider.mjs rename to packages/cli/src/media-use/lib/voice-provider.mjs diff --git a/skills/media-use/scripts/lib/voice-provider.test.mjs b/packages/cli/src/media-use/lib/voice-provider.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/voice-provider.test.mjs rename to packages/cli/src/media-use/lib/voice-provider.test.mjs diff --git a/skills/media-use/scripts/lib/words.mjs b/packages/cli/src/media-use/lib/words.mjs similarity index 100% rename from skills/media-use/scripts/lib/words.mjs rename to packages/cli/src/media-use/lib/words.mjs diff --git a/packages/cli/src/media-use/resolve.mjs b/packages/cli/src/media-use/resolve.mjs new file mode 100644 index 0000000000..3e6c36f021 --- /dev/null +++ b/packages/cli/src/media-use/resolve.mjs @@ -0,0 +1,1271 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, statSync, writeFileSync, renameSync, rmSync } from "node:fs"; +import { resolve, join, extname, basename } from "node:path"; +import { parseArgs } from "node:util"; +import { + appendRecord, + findByPrompt, + findByEntity, + nextId, + withReservedFile, + withReservedFileSync, +} from "./lib/manifest.mjs"; +import { regenerateIndex } from "./lib/index-gen.mjs"; +import { cacheGet, cacheGetByEntity, importFromCache, cachePut } from "./lib/cache.mjs"; +import { + runCapability, + listTypes, + providerMatches, + providerNamesFor, + providerTierFor, +} from "./lib/registry.mjs"; +import { freezeUrl, freezeLocalFile, isDirectMediaUrl } from "./lib/freeze.mjs"; +import { findExistingAsset } from "./lib/adopt.mjs"; +import { track } from "./lib/telemetry.mjs"; +import { recordMiss } from "./lib/misses.mjs"; +import { buildStats } from "./lib/stats.mjs"; +import { typesMatch } from "./lib/match.mjs"; +import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./lib/candidates.mjs"; +import { findGlobalBySha } from "./lib/cache.mjs"; +import { heygenAuthMethod } from "../audio/scripts/lib/heygen.mjs"; +import { buildCube, paramsFromIntent } from "./lib/cube-build.mjs"; +import { validateCubeFile } from "./lib/cube-validate.mjs"; +import { analyzeMediaGrade, formatMeasuredNote } from "./lib/grade-analyzer.mjs"; +import { + freezeLibraryLut, + isLibraryLutOfflineMiss, + matchColorLook, +} from "./lib/lut-preset-provider.mjs"; +import { + HEYGEN_AUTH_COMMAND, + HEYGEN_INSTALL_COMMAND, + HEYGEN_MIN_VERSION, + HEYGEN_UPDATE_COMMAND, + consumeHeygenRemediation, + firstSemver, + flushHeygenFailureTracking, + versionLessThan, +} from "./lib/heygen-cli.mjs"; +import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs"; + +const INGEST_TYPES = listTypes(); +const DEFAULT_EXT = { + bgm: ".wav", + sfx: ".mp3", + voice: ".wav", + image: ".jpg", + icon: ".svg", + logo: ".svg", + brand: ".png", + video: ".mp4", + grade: ".cube", + lut: ".cube", +}; + +// resolve shells `fetch`/`freezeUrl` and modern ESM; 18 is the floor where those +// exist without flags. Named so the --doctor node check verifies something real +// (O2). Declared before the top-level `--doctor` branch that calls runDoctor(). +const MIN_NODE_VERSION = "18.0.0"; + +const { values: args } = parseArgs({ + options: { + type: { type: "string", short: "t" }, + intent: { type: "string", short: "i" }, + entity: { type: "string", short: "e" }, + project: { type: "string", short: "p", default: "." }, + adopt: { type: "boolean", default: false }, + candidates: { type: "boolean", default: false }, + doctor: { type: "boolean", default: false }, + stats: { type: "boolean", default: false }, + days: { type: "string" }, + "dry-run": { type: "boolean", default: false }, + reuse: { type: "string" }, + from: { type: "string" }, + params: { type: "string" }, + for: { type: "string" }, + analyze: { type: "boolean", default: false }, + "local-only": { type: "boolean", default: false }, + provider: { type: "string" }, + "avatar-id": { type: "string" }, + "voice-id": { type: "string" }, + json: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, +}); + +if (args.help) { + console.log(`media-use resolve — turn a media need into a frozen local file + +Usage: + node resolve.mjs --type --intent "" [--project ] + +Types: ${listTypes().join(", ")} + +Options: + --type, -t Media type (required) + --intent, -i What you need (required) + --entity, -e Entity name for cache matching (optional) + --project, -p Project directory (default: .) + --adopt Adopt all existing assets/ files into the manifest + --candidates List reusable assets (project + global cache) for --type; no + download, no mutation. Read them and decide reuse yourself. + --doctor Check local CLI dependencies; no manifest changes. + --stats Print local usage stats from .media and ~/.media; no mutation. + --days Limit --stats to records/misses from the last N days when + timestamps are available. + --reuse Import a specific global-cache asset (by content sha/prefix, + from --candidates) into this project + --from Freeze a local file or direct public URL (ingest) + --params Build an explicit parametric LUT (lut/grade only) + --for Analyze a local image/video and add measured grade adjust + suggestions (grade only) + --analyze Return --for grade evidence without recording a candidate + --local-only Offline: skip every network provider + --provider Force one generator (e.g. codex, mflux, kokoro, heygen) + --avatar-id Override the default avatar for heygen.video generation + --voice-id Override the default voice for voice/heygen.video generation + --json Output JSON instead of one-line result + --help, -h Show this help`); + process.exit(0); +} + +const projectDir = resolve(args.project); +const type = args.type; +const intent = args.intent; +const entity = args.entity || null; + +if (args.adopt) { + const { adoptExistingAssets } = await import("./lib/adopt.mjs"); + const adopted = adoptExistingAssets(projectDir); + if (args.json) { + console.log(JSON.stringify({ ok: true, adopted: adopted.length, assets: adopted })); + } else if (adopted.length === 0) { + console.log("no new assets to adopt (assets/ empty or already registered)"); + } else { + console.log(`adopted ${adopted.length} asset${adopted.length === 1 ? "" : "s"} from assets/`); + for (const r of adopted) console.log(` ${r.id} → ${r.path} (${r.type})`); + } + process.exit(0); +} + +// Candidates: side-effect-free listing of reusable assets (project + global +// cache) for --type. No download, no provider, no mutation. The agent reads +// these and decides semantic fit itself. +if (args.candidates || args["dry-run"]) { + await showCandidates(); + process.exit(0); +} + +if (args.doctor) { + const doctor = runDoctor(); + const failed = doctor.checks.filter((check) => !check.ok); + // Non-PII: instrument the exact question the feature exists to answer — how + // often is --doctor run and which check fails most. Awaited so a short-lived + // run flushes before exit. + await track("media_use_doctor_run", { + ok: doctor.ok, + checks_failed: failed.length, + failed: failed.map((check) => check.name), + }); + if (args.json) { + console.log(JSON.stringify({ ok: doctor.ok, checks: doctor.checks })); + } else { + printDoctor(doctor.checks); + } + process.exit(doctor.ok ? 0 : 1); +} + +if (args.stats) { + const report = buildStats({ + projectDir, + days: args.days ? Number(args.days) : undefined, + }); + if (args.json) { + console.log(JSON.stringify(report)); + } else { + printStats(report); + } + process.exit(0); +} + +// Reuse: import a specific global-cache asset (by content sha/prefix, taken +// from --candidates) into this project. `!== undefined` so an empty --reuse "" +// still routes here (and gets a clear empty-sha error) instead of falling +// through to the misleading "--type and --intent are required". +if (args.reuse !== undefined) { + await reuseGlobal(args.reuse); + process.exit(0); +} + +// Ingest: freeze a user-supplied local file or direct public URL (no search). +if (args.from) { + await ingest(args.from); + process.exit(0); +} + +if (args.analyze) { + if (type !== "grade" || !args.for) { + console.error("error: --analyze requires --type grade and --for "); + process.exit(2); + } + const mediaPath = resolve(args.for); + if (!existsSync(mediaPath)) { + console.error(`error: --for file not found: ${mediaPath}`); + process.exit(2); + } + const analysis = analyzeMediaGrade(mediaPath); + if (args.json) { + console.log(JSON.stringify({ ok: true, type: "grade-analysis", ...analysis })); + } else { + console.log(formatMeasuredNote(mediaPath, analysis.measured)); + console.log(`suggested adjust: ${JSON.stringify(analysis.adjust)}`); + } + process.exit(0); +} + +// Recipes: folder-based named bundles resolved by entity name — no providers, +// no content hashing (an evolving versioned bundle, not an immutable file). +// Delegates to lib/recipe-store.mjs the way grade/lut delegate to resolveColor; +// freeze/list live in scripts/recipe.mjs. +if (type === "recipe") { + const { useRecipe } = await import("./lib/recipe-store.mjs"); + const name = (entity || intent || "").trim(); + if (!name) exitError("--type recipe needs --entity (or --intent )", 2); + try { + const used = useRecipe({ projectDir, name }); + if (args.json) { + console.log(JSON.stringify({ ok: true, ...used })); + } else { + console.log( + `resolved recipe ${used.recipe.name} (v${used.recipe.version}, ${used.recipe.workflow})`, + ); + console.log(` frame spec → ${used.frameSpecPath} (copied over)`); + console.log(` storyboard skeleton → ${used.skeletonPath}`); + if (used.briefSkeletonPath) console.log(` brief skeleton → ${used.briefSkeletonPath}`); + } + process.exit(0); + } catch (err) { + exitError(err.message, 1); + } +} + +if (args.params !== undefined) { + if (type !== "lut" && type !== "grade") { + exitError( + type + ? `--params only supports --type lut or grade (got ${type})` + : "--params requires --type lut or grade", + 2, + ); + } + try { + await runParams(); + process.exit(0); + } catch (err) { + exitError(err.message, 1); + } +} + +if (!args.type || !args.intent || !args.intent.trim()) { + console.error("error: --type and a non-empty --intent are required"); + process.exit(2); +} + +if (!listTypes().includes(args.type)) { + console.error(`error: unknown media type: ${args.type} (known: ${listTypes().join(", ")})`); + process.exit(2); +} + +// Forced-provider validation: reject an unknown/unavailable provider name up +// front so a typo reads as a typo, not a catalog miss (`no provider could +// resolve`). Match rule mirrors runProviders (full name or dotted prefix). +if (args.provider && !providerMatches(args.type, args.provider)) { + console.error( + `error: unknown provider "${args.provider}" for type ${args.type} (available: ${providerNamesFor(args.type).join(", ")})`, + ); + process.exit(2); +} + +function recordAvailable(projectDir, record) { + if (!record) return false; + if (record.path) return existsSync(join(projectDir, record.path)); + return record.type === "grade" && record.grading; +} + +// Sparse `{ authMethod }` for a heygen-family provider name (e.g. "heygen.tts"), +// else `{}` — keeps auth_method telemetry absent for every non-heygen resolve +// instead of implying an auth method that doesn't apply. +function heygenAuthMethodFor(provider) { + if (!provider || !provider.startsWith("heygen.")) return {}; + const authMethod = heygenAuthMethod(); + return authMethod ? { authMethod } : {}; +} + +function localizeImportedRecord(record, localPath) { + if (record?.type === "grade" && record.grading?.lut) { + record.grading = { + ...record.grading, + lut: { ...record.grading.lut, src: localPath }, + }; + } + return record; +} + +async function run() { + // A forced --provider means "(re)generate with THIS provider" — it bypasses + // every reuse rung (project/entity/assets/global cache) so it can't silently + // hand back an asset from a different provider. The floor only applies to the + // default (unforced) cascade. + const forced = !!args.provider; + + // 1. project manifest — exact-prompt match + const projectHit = forced ? null : findByPrompt(projectDir, intent, type); + if (recordAvailable(projectDir, projectHit)) { + return result(projectHit, "cached"); + } + + // 1b. entity match in project. icon and image are interchangeable for + // entity hits — both live in images/, and figma-imported brand marks are + // always recorded as type image while agents ask for logos as type icon. + if (!forced && entity) { + const entityHit = findByEntity(projectDir, entity); + if (entityHit && typesMatch(entityHit.type, type) && recordAvailable(projectDir, entityHit)) { + return result(entityHit, "cached"); + } + } + + // 1c. scan existing assets/ directory for unregistered matches + const existingAsset = + forced || type === "grade" || type === "lut" + ? null + : findExistingAsset(projectDir, intent, type); + if (existingAsset) { + const id = nextId(projectDir, type); + const record = { + id, + type: existingAsset.type, + path: existingAsset.relativePath, + source: "existing", + description: existingAsset.name.replace(/[-_]/g, " "), + provenance: { provider: "local", adopted: true, prompt: intent }, + }; + appendRecord(projectDir, record); + regenerateIndex(projectDir); + return result(record, "existing"); + } + + // 2. global cache — exact-prompt or entity match + const cacheHit = forced ? null : cacheGet(intent, type); + if (cacheHit) { + const ext = extname(cacheHit.cached_path); + const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) => + localizeImportedRecord(importFromCache(cacheHit, projectDir, id, localPath), localPath), + ); + if (imported) { + appendRecord(projectDir, imported); + regenerateIndex(projectDir); + return result(imported, "reused"); + } + } + + if (!forced && entity) { + const entityCacheHit = cacheGetByEntity(entity); + if (entityCacheHit && typesMatch(entityCacheHit.type, type)) { + const ext = extname(entityCacheHit.cached_path); + const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) => + localizeImportedRecord( + importFromCache(entityCacheHit, projectDir, id, localPath), + localPath, + ), + ); + if (imported) { + appendRecord(projectDir, imported); + regenerateIndex(projectDir); + return result(imported, "reused"); + } + } + } + + // Offline guard: --local-only skips every remote provider (HeyGen catalog), + // leaving the project + global cache and any local provider. + const localOnly = args["local-only"]; + const ctx = { + entity, + projectDir, + localOnly, + provider: args.provider, + avatarId: args["avatar-id"], + voiceId: args["voice-id"], + }; + + // Adherence nudge (offline, no auto-reuse): the exact-cache floor missed and + // we're about to fetch/generate. If lexically-similar assets already exist, + // point the agent at --candidates so it can reuse instead of fetching. Only a + // fuzzy match ever reaches the agent this way — never auto-applied. Goes to + // stderr so it reaches --json callers without corrupting stdout. Best-effort. + try { + const { similar } = listCandidates({ projectDir, type, intent, cap: CANDIDATE_CAP }); + if (similar > 0) { + console.error( + `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.`, + ); + } + } catch { + // hint is best-effort; never block a resolve + } + + if (type === "grade" || type === "lut") { + return resolveColor(type, intent, { projectDir }); + } + + // 3. provider search — registry tries providers in order (heygen-CLI first) + let searchResult = null; + let providerFailure = null; + try { + searchResult = await runCapability(type, "search", intent, ctx); + } catch (error) { + providerFailure = error; + // search failed, try generate + } + + // 4. generate fallback — same ordered cascade for the generate capability + if (!searchResult) { + try { + searchResult = await runCapability(type, "generate", intent, ctx); + } catch (error) { + providerFailure ??= error; + // generate failed too + } + } + + // A search/generate attempt against heygen may have fired a fire-and-forget + // media_use_provider_error track (reportHeygenFailure — heygen-search.mjs / + // voice-provider.mjs are sync call sites several layers below here and can't + // await it themselves). Join it now, before any process.exit() below can + // race it: both it and the miss/success telemetry below are separate, + // non-keepalive HTTP connections with no ordering guarantee otherwise. + await flushHeygenFailureTracking(); + + if (!searchResult) { + await track("media_use_resolve_miss", { + type, + local_only: !!localOnly, + provider_override: !!args.provider, + }); + recordMiss({ + type, + intent, + provider_override: !!args.provider, + local_only: !!args["local-only"], + }); + // brand stays local: no frame.md/design.md -> upsell the HyperFrames design + // flow rather than reporting a generic miss (B5). + const msg = + providerFailure instanceof BundledSfxAssetsError + ? providerFailure.message + : type === "brand" + ? "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." + : args.provider + ? `provider "${args.provider}" could not resolve ${type}: "${intent}"${localOnly ? " (--local-only skips network providers; drop it or the --provider override)" : ""}` + : `no provider could resolve ${type}: "${intent}"`; + if (args.json) { + console.log( + JSON.stringify({ + ok: false, + ...(providerFailure instanceof BundledSfxAssetsError + ? { code: providerFailure.code, fix: providerFailure.fix } + : {}), + error: msg, + }), + ); + } else { + console.error(`error: ${msg}`); + } + process.exit(1); + } + + // 5. freeze + register (atomic id+file reservation so concurrent resolves + // can't collide on an id during the download — MU-23) + const ext = searchResult.ext || extFromUrl(searchResult.url || "") || defaultExt(type); + const { id, localPath, fullPath } = await withReservedFile( + projectDir, + type, + ext, + async (reservation) => { + if (searchResult.localPath) { + freezeLocalFile(searchResult.localPath, reservation.fullPath); + } else if (searchResult.url) { + await freezeUrl(searchResult.url, reservation.fullPath); + } else { + throw new Error("provider returned no url or localPath"); + } + return reservation; + }, + ); + + const record = { + id, + type, + path: localPath, + source: searchResult.source || "search", + description: searchResult.metadata?.description || intent, + ...(searchResult.metadata?.duration != null && { + duration: Math.round(searchResult.metadata.duration * 10) / 10, // round to 0.1s like probe (voice bypassed it) + }), + ...(searchResult.metadata?.width != null && { width: searchResult.metadata.width }), + ...(searchResult.metadata?.height != null && { height: searchResult.metadata.height }), + ...(searchResult.metadata?.transparent != null && { + transparent: searchResult.metadata.transparent, + }), + ...(entity && { entity }), + provenance: { + provider: searchResult.metadata?.provider || "unknown", + prompt: intent, + // heygenAuthMethodFor spreads first so an explicit authMethod on a + // future provider's own metadata.provenance can still override it below + // -- safe today (no provider sets authMethod itself), but keep this + // ordering if that ever changes. + ...heygenAuthMethodFor(searchResult.metadata?.provider), + ...searchResult.metadata?.provenance, + }, + }; + + const heygenRemediation = consumeHeygenRemediation(); + if ( + searchResult.metadata?.provider === "bundled.sfx" && + !localOnly && + !args.provider && + heygenRemediation + ) { + record.advisory = heygenRemediation; + } + + appendRecord(projectDir, record); + regenerateIndex(projectDir); + // Auto-promote: surface every fetched asset in the global cache so it's + // reusable across all hyperframes projects (B3). Non-fatal; dedup by sha. + // ponytail: promotes search/generate/ingest assets (the ones media-use + // fetched), not bulk --adopt imports — add those if cross-project reuse of + // pre-existing project assets is wanted. + try { + cachePut(fullPath, record); + } catch { + // promotion is best-effort; a resolve still succeeds locally + } + return result(record, searchResult.source || "search"); +} + +function mergeSmartAdjust(block) { + if (!args.for) return block; + const mediaPath = resolve(args.for); + // Clear upfront error beats an ffmpeg "No such file" stack on a typo'd path. + if (!existsSync(mediaPath)) throw new Error(`--for file not found: ${mediaPath}`); + const analysis = analyzeMediaGrade(mediaPath); + console.error(formatMeasuredNote(mediaPath, analysis.measured)); + return { + ...block, + adjust: { + ...(block.adjust || {}), + ...analysis.adjust, + }, + }; +} + +function freezeGeneratedLut( + params, + { + projectDir, + type, + description = "parametric color grade", + validationErrorPrefix = "generated LUT failed validation", + }, +) { + return withReservedFileSync(projectDir, type, ".cube", ({ id, localPath, fullPath }) => { + const tmpPath = `${fullPath}.tmp`; + try { + // Write + validate at .tmp, then atomic rename, so a crash between write and + // validate can't leave an invalid .cube at the final path. + writeFileSync(tmpPath, buildCube(params)); + const check = validateCubeFile(tmpPath); + if (!check.ok) throw new Error(check.error); + renameSync(tmpPath, fullPath); + } catch (err) { + rmSync(tmpPath, { force: true }); + throw new Error(`${validationErrorPrefix}: ${err.message}`); + } + return { + id, + localPath, + fullPath, + lut: { src: localPath, intensity: 1 }, + source: "generated", + description, + metadata: { + provider: "cube_lut.builder", + provenance: { params }, + }, + }; + }); +} + +function exitError(message, status = 1) { + if (args.json) { + console.log(JSON.stringify({ ok: false, error: message })); + } else { + console.error(`error: ${message}`); + } + process.exit(status); +} + +function parseExplicitParams() { + try { + return JSON.parse(args.params); + } catch (err) { + throw new Error(`invalid --params JSON: ${err.message}`); + } +} + +async function runParams() { + if (type === "lut" && args.for) { + throw new Error("--for is only supported with --type grade"); + } + const params = parseExplicitParams(); + const description = + typeof intent === "string" && intent.trim() + ? intent.trim() + : `custom parametric ${type === "lut" ? "lut" : "grade"}`; + const frozen = freezeGeneratedLut(params, { + projectDir, + type, + description, + validationErrorPrefix: "--params produced an invalid LUT", + }); + const record = { + id: frozen.id, + type, + path: frozen.localPath, + source: frozen.source, + description: frozen.description, + ...(type === "grade" && { grading: mergeSmartAdjust({ intensity: 1, lut: frozen.lut }) }), + provenance: { + provider: frozen.metadata.provider, + ...frozen.metadata.provenance, + }, + }; + return finalizeColorRecord(record, frozen.source, frozen.fullPath); +} + +async function finalizeColorRecord(record, source, fullPath = null) { + appendRecord(projectDir, record); + regenerateIndex(projectDir); + if (fullPath) { + try { + cachePut(fullPath, record); + } catch { + // promotion is best-effort + } + } + return result(record, source); +} + +async function colorMiss(type, intent) { + await track("media_use_resolve_miss", { + type, + local_only: !!args["local-only"], + provider_override: !!args.provider, + }); + recordMiss({ + type, + intent, + provider_override: !!args.provider, + local_only: !!args["local-only"], + }); + const msg = `no local color grade could resolve ${type}: "${intent}"`; + if (args.json) { + console.log(JSON.stringify({ ok: false, error: msg })); + } else { + console.error(`error: ${msg}`); + } + process.exit(1); +} + +async function resolveGrade(intent, { projectDir }) { + const match = matchColorLook(intent); + if (match?.kind === "preset") { + const id = nextId(projectDir, "grade"); + const grading = mergeSmartAdjust({ preset: match.preset, intensity: 1 }); + const record = { + id, + type: "grade", + source: "preset", + description: intent, + grading, + provenance: { + provider: "color_grade.local", + prompt: intent, + preset: match.preset, + }, + }; + return finalizeColorRecord(record, "preset"); + } + + if (match?.kind === "library") { + let frozen; + try { + frozen = await freezeLibraryLut(match, { + projectDir, + type: "grade", + localOnly: args["local-only"], + }); + } catch (err) { + if (isLibraryLutOfflineMiss(err)) return colorMiss("grade", intent); + throw err; + } + const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut }); + const record = { + id: frozen.id, + type: "grade", + path: frozen.localPath, + source: frozen.source, + description: frozen.description, + grading, + provenance: { + provider: frozen.metadata.provider, + prompt: intent, + ...frozen.metadata.provenance, + }, + }; + return finalizeColorRecord(record, frozen.source, frozen.fullPath); + } + + const params = paramsFromIntent(intent); + if (!params) { + // No creative look matched. With --for, the measured adjust block is a + // valid grade on its own (footage auto-correction); only a true miss + // (no look AND no analysis) aborts. + if (args.for) { + const grading = mergeSmartAdjust({ intensity: 1 }); + const record = { + id: nextId(projectDir, "grade"), + type: "grade", + source: "measured", + description: intent, + grading, + provenance: { provider: "color_grade.local", prompt: intent, measured: true }, + }; + return finalizeColorRecord(record, "measured"); + } + return colorMiss("grade", intent); + } + const frozen = freezeGeneratedLut(params, { projectDir, type: "grade" }); + const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut }); + const record = { + id: frozen.id, + type: "grade", + path: frozen.localPath, + source: frozen.source, + description: intent, + grading, + provenance: { + provider: frozen.metadata.provider, + prompt: intent, + ...frozen.metadata.provenance, + }, + }; + return finalizeColorRecord(record, frozen.source, frozen.fullPath); +} + +async function resolveLut(intent, { projectDir }) { + if (args.for) { + throw new Error("--for is only supported with --type grade"); + } + const match = matchColorLook(intent); + if (match?.kind === "library") { + let frozen; + try { + frozen = await freezeLibraryLut(match, { + projectDir, + type: "lut", + localOnly: args["local-only"], + }); + } catch (err) { + if (isLibraryLutOfflineMiss(err)) return colorMiss("lut", intent); + throw err; + } + const record = { + id: frozen.id, + type: "lut", + path: frozen.localPath, + source: frozen.source, + description: frozen.description, + provenance: { + provider: frozen.metadata.provider, + prompt: intent, + ...frozen.metadata.provenance, + }, + }; + return finalizeColorRecord(record, frozen.source, frozen.fullPath); + } + + const params = paramsFromIntent(intent); + if (!params) return colorMiss("lut", intent); + const frozen = freezeGeneratedLut(params, { projectDir, type: "lut" }); + const record = { + id: frozen.id, + type: "lut", + path: frozen.localPath, + source: frozen.source, + description: intent, + provenance: { + provider: frozen.metadata.provider, + prompt: intent, + ...frozen.metadata.provenance, + }, + }; + return finalizeColorRecord(record, frozen.source, frozen.fullPath); +} + +async function resolveColor(type, intent, options) { + if (type === "grade") return resolveGrade(intent, options); + return resolveLut(intent, options); +} + +async function ingest(src) { + if (!type || !INGEST_TYPES.includes(type)) { + console.error(`error: --from requires --type (one of: ${INGEST_TYPES.join(", ")})`); + process.exit(2); + } + const isUrl = /^https?:\/\//i.test(src); + if (isUrl && !isDirectMediaUrl(src)) { + console.error( + `error: --from takes a direct public media URL or a local file; "${src}" is not a direct media link (no platform pages / yt-dlp)`, + ); + process.exit(2); + } + if (!isUrl && !existsSync(resolve(src))) { + console.error(`error: file not found: ${src}`); + process.exit(2); + } + // Refuse 0-byte input: an empty asset would register clean but fail at render + // (freezeUrl already rejects empty responses; this covers local files). + if (!isUrl && statSync(resolve(src)).size === 0) { + console.error(`error: refusing to ingest a 0-byte file: ${src}`); + process.exit(2); + } + const ext = extname(isUrl ? new URL(src).pathname : src) || defaultExt(type); + const { id, localPath, fullPath } = await withReservedFile( + projectDir, + type, + ext, + async (reservation) => { + if (isUrl) await freezeUrl(src, reservation.fullPath); + else freezeLocalFile(resolve(src), reservation.fullPath); + return reservation; + }, + ); + if (type === "lut" || type === "grade") { + try { + const check = validateCubeFile(fullPath); + if (!check.ok) throw new Error(check.error); + } catch (err) { + rmSync(fullPath, { force: true }); + exitError(`ingested LUT is invalid: ${err.message}`, 1); + } + } + const record = { + id, + type, + path: localPath, + source: "ingested", + description: basename(src.split("?")[0]), + provenance: { provider: "local", from: src }, + }; + appendRecord(projectDir, record); + regenerateIndex(projectDir); + try { + cachePut(fullPath, record); // surface ingested assets globally too (B3) + } catch { + // best-effort + } + await result(record, "ingested"); +} + +async function showCandidates() { + const projectDir = resolve(args.project); + const type = args.type; + if (!type || !listTypes().includes(type)) { + console.error(`error: --candidates requires --type (one of: ${listTypes().join(", ")})`); + process.exit(2); + } + const intent = args.intent || ""; + const { candidates, truncated, total, similar } = listCandidates({ + projectDir, + type, + intent, + cap: CANDIDATE_CAP, + }); + await track("media_use_candidates", { + type, + project_n: total.project, + global_n: total.global, + local_only: !!args["local-only"], + }); + if (args.json) { + console.log(JSON.stringify({ ok: true, candidates, truncated, total, similar })); + } else { + console.log(formatCandidates(candidates, { truncated, total })); + } +} + +// Best-effort latest stable CLI tag from the CDN (the install script's source of +// truth). null on any failure (offline, no curl) — treated as "unknown", never fatal. +function latestHeygenStable() { + const probe = runCommand("curl", [ + "-fsSL", + "--max-time", + "4", + "https://static.heygen.ai/cli/stable", + ]); + return probe.status === 0 ? firstSemver(commandText(probe)) : null; +} + +function heygenAuthCheck() { + // `heygen auth status` already emits JSON by default (only `--human` opts out + // to a table) — there is no `--json`/`--output` flag; passing one errors with + // "unknown flag". emailFromAuthStatus parses that default JSON. + // NOTE: JSON-by-default is a v0.3.0 behavior — this probe assumes it, which + // HEYGEN_MIN_VERSION >= 0.3.0 (+ the version gate above) guarantees. If that + // floor is ever lowered, auth detection on an older CLI would silently break. + const authProbe = runCommand("heygen", ["auth", "status"]); + // spawnSync sets .error/.signal on a timeout or spawn failure (status then + // null). A stalled auth endpoint (transient network/DNS) must not be reported + // as an authoritative "not authenticated" with a re-login fix. + const timedOut = authProbe.error?.code === "ETIMEDOUT" || authProbe.signal != null; + const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null; + return { + name: "heygen authenticated", + ok: !!email, + detail: email + ? `heygen authenticated as ${email}` + : timedOut + ? "heygen auth status timed out — possible network issue, not proof of sign-out" + : "heygen not authenticated", + fix: email ? "" : timedOut ? "check network, then re-run --doctor" : HEYGEN_AUTH_COMMAND, + }; +} + +function runDoctor() { + const checks = []; + const bundledSfx = inspectBundledSfxAssets(); + checks.push({ + name: "bundled SFX assets", + ok: bundledSfx.ok, + detail: bundledSfx.detail, + fix: bundledSfx.fix, + }); + const heygenVersionProbe = runCommand("heygen", ["--version"]); + const heygenOnPath = heygenVersionProbe.status === 0; + const heygenVersionText = commandText(heygenVersionProbe); + const heygenVersion = firstSemver(heygenVersionText); + + checks.push({ + name: "heygen on PATH", + ok: heygenOnPath, + // Just "is the binary here" — the version row below owns the version string, + // so this row must not also render `heygen v0.3.0` (two byte-identical lines). + detail: heygenOnPath ? "heygen found on PATH" : "heygen not found", + fix: heygenOnPath ? "" : HEYGEN_INSTALL_COMMAND, + }); + + if (!heygenOnPath) { + checks.push({ + name: "heygen version", + ok: false, + detail: "heygen version unavailable", + fix: HEYGEN_INSTALL_COMMAND, + }); + checks.push({ + name: "heygen authenticated", + ok: false, + detail: "heygen auth status unavailable", + fix: HEYGEN_INSTALL_COMMAND, + }); + } else if (heygenVersion) { + const versionOk = !versionLessThan(heygenVersion, HEYGEN_MIN_VERSION); + // Keep it latest: even when the installed version clears the floor, nudge + // `heygen update` if a newer stable exists. Best-effort — silently skipped + // when the CDN is unreachable, so it never blocks the check. + const latest = versionOk ? latestHeygenStable() : null; + const behind = latest && versionLessThan(heygenVersion, latest); + checks.push({ + name: "heygen version", + ok: versionOk, + detail: versionOk + ? `heygen v${heygenVersion}${behind ? ` (latest v${latest} available)` : ""}` + : `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`, + fix: versionOk ? (behind ? HEYGEN_UPDATE_COMMAND : "") : HEYGEN_UPDATE_COMMAND, + }); + + // Below the OAuth-capable floor the auth probe fails for the SAME root cause + // (an old CLI can't OAuth and doesn't emit JSON auth status), which would + // read as a confusing second "not authenticated" error. Skip it — one root + // cause, one fix. + checks.push( + versionOk + ? heygenAuthCheck() + : { + name: "heygen authenticated", + ok: false, + detail: "skipped — update heygen first", + fix: HEYGEN_UPDATE_COMMAND, + }, + ); + } else { + // Fail-open: heygen ran but printed no semver (dev/stripped build). We can't + // verify the version, so we don't block on it — but say so rather than a bare + // green check that implies a real version comparison happened. + checks.push({ + name: "heygen version", + ok: true, + detail: "heygen present; version unverifiable (no semver in --version output)", + fix: "", + }); + + checks.push(heygenAuthCheck()); + } + + const ffmpegProbe = runCommand("ffmpeg", ["-version"]); + checks.push({ + name: "ffmpeg on PATH", + ok: ffmpegProbe.status === 0, + detail: ffmpegProbe.status === 0 ? firstLine(ffmpegProbe.stdout) : "ffmpeg not found", + fix: ffmpegProbe.status === 0 ? "" : "brew install ffmpeg", + }); + + const ffprobeProbe = runCommand("ffprobe", ["-version"]); + checks.push({ + name: "ffprobe on PATH", + ok: ffprobeProbe.status === 0, + detail: ffprobeProbe.status === 0 ? firstLine(ffprobeProbe.stdout) : "ffprobe not found", + fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg", + }); + + const nodeOk = !versionLessThan(process.versions.node, MIN_NODE_VERSION); + checks.push({ + name: "node version", + ok: nodeOk, + detail: `${process.version} (need >= v${MIN_NODE_VERSION})`, + fix: nodeOk ? "" : `upgrade Node to >= v${MIN_NODE_VERSION}`, + }); + + // ffmpeg AND ffprobe are both strictly required (see references/setup-providers.md); the exit code + // must reflect that so a script gating on `--doctor` doesn't pass with ffprobe + // missing and then break at the first probe call. + const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH"); + const ffprobe = checks.find((check) => check.name === "ffprobe on PATH"); + return { ok: bundledSfx.ok && !!ffmpeg?.ok && !!ffprobe?.ok, checks }; +} + +function printDoctor(checks) { + const heygenChecks = new Set(["heygen on PATH", "heygen version", "heygen authenticated"]); + for (const check of checks) { + const prefix = check.ok ? "✓" : "✗"; + const freePath = heygenChecks.has(check.name) + ? " — free-usage path: bgm/image/voice/avatar-video" + : ""; + const fix = check.ok || !check.fix ? "" : ` — fix: ${check.fix}`; + console.log(`${prefix} ${check.detail}${freePath}${fix}`); + } +} + +function printStats(report) { + console.log("media-use stats"); + console.log(`total resolves: ${report.total_resolves}`); + console.log(`misses: ${report.misses}`); + console.log( + `hit rate: ${report.hit_rate == null ? "n/a" : `${Math.round(report.hit_rate * 100)}%`}`, + ); + printMap("by type", report.by_type); + printMap("by source", report.by_source); + printMap("by provider", report.by_provider); + printMap("by via", report.by_via); + console.log(`global cache assets: ${report.global_cache_assets}`); + console.log(`global cache disk: ${report.global_cache_disk_bytes} bytes`); + console.log(`cross-project reuse: ${report.cross_project_reuse}`); + console.log("top missed intents:"); + const entries = Object.entries(report.top_missed_intents); + if (entries.length === 0) { + console.log(" none"); + return; + } + for (const [type, misses] of entries) { + console.log(` ${type}:`); + for (const miss of misses) console.log(` ${miss.count} ${miss.intent}`); + } +} + +function printMap(label, values) { + const entries = Object.entries(values); + console.log(`${label}:`); + if (entries.length === 0) { + console.log(" none"); + return; + } + for (const [key, value] of entries) console.log(` ${key}: ${value}`); +} + +function runCommand(bin, argv) { + return spawnSync(bin, argv, { + encoding: "utf8", + timeout: 15000, + }); +} + +function commandText(result) { + return [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); +} + +function firstLine(text) { + return ( + String(text || "") + .trim() + .split(/\r?\n/)[0] || "" + ); +} + +function emailFromAuthStatus(text) { + // JSON only (auth status emits JSON by default). No prose regex fallback: a + // human-format body like "Session expired. Contact support@heygen.ai" would + // otherwise report the user as authenticated as support@heygen.ai. + const trimmed = String(text || "").trim(); + if (!trimmed.startsWith("{")) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed?.data?.email || parsed?.email || null; + } catch { + return null; + } +} + +async function reuseGlobal(shaArg) { + const projectDir = resolve(args.project); + const type = args.type; + if (!type || !listTypes().includes(type)) { + console.error(`error: --reuse requires --type (one of: ${listTypes().join(", ")})`); + process.exit(2); + } + if (!shaArg || !shaArg.trim()) { + console.error("error: --reuse needs a content sha/prefix (from `resolve --candidates`)"); + process.exit(2); + } + const rec = findGlobalBySha(shaArg); + if (rec && rec.ambiguous) { + console.error( + `error: sha prefix "${shaArg}" is ambiguous (${rec.count} matches) — use more characters`, + ); + process.exit(2); + } + if (!rec) { + console.error(`error: no reusable global asset matches sha "${shaArg}"`); + process.exit(1); + } + // Type guard: don't import a bgm asset as an image (audio under images/). + // icon<->image are interchangeable; everything else must match --type. + if (!typesMatch(rec.type, type)) { + console.error(`error: sha "${shaArg}" is a ${rec.type} asset, not ${type}`); + process.exit(2); + } + const ext = extname(rec.cached_path || "") || defaultExt(type); + const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) => + localizeImportedRecord(importFromCache(rec, projectDir, id, localPath), localPath), + ); + if (!imported) { + console.error(`error: cache entry for "${shaArg}" is incomplete or missing on disk`); + process.exit(1); + } + // Distinguish an explicit agent reuse from an automatic normalize-exact hit. + imported.source = "reused-explicit"; + imported.provenance = { ...imported.provenance, reused_by: "agent" }; + appendRecord(projectDir, imported); + regenerateIndex(projectDir); + await result(imported, "reused-explicit"); +} + +async function result(record, source) { + // Non-PII usage event: which media type, how it resolved, which provider won. + // Never the intent text or paths. Awaited so a short-lived run flushes it. + await track("media_use_resolve", { + type: record.type, + source, + provider: record.provenance?.provider, + // How a library LUT resolved: "url" (CDN), "params-fallback" (CDN failed → + // parametric), or "params" (offline). Surfaces silent CDN→params downgrades + // in prod, which --doctor can't (it only answers "reachable now?"). + via: record.provenance?.via, + // Free (OAuth) vs. paid (API-key) heygen path — sparse: absent for every + // non-heygen provider (see heygenAuthMethodFor at construction time). On a + // cache/reuse hit this reports how the asset was ORIGINALLY fetched, not + // this resolve's own credential state — intentional: it's a conversion + // signal about the fetch that actually consumed a heygen credit, not + // about the (free, no-credential) act of copying a cached file. + auth_method: record.provenance?.authMethod, + // "local" / "network_free" / "network_paid", straight from the registry's own + // A/N/P declaration — so a dashboard can separate free lookups from calls that + // spend credit without hardcoding provider names. Sparse: absent when the + // record carries no provider (cache and reuse hits) or the name is unknown. + provider_tier: providerTierFor(record.provenance?.provider), + local_only: !!args["local-only"], + provider_override: !!args.provider, + }); + if (args.json) { + const grading = record.type === "grade" && record.grading ? record.grading : null; + console.log( + JSON.stringify({ + ok: true, + ...record, + ...(grading || {}), + ...(grading && { grading }), + _source: source, + }), + ); + } else { + const meta = formatMeta(record, source); + console.log(`resolved ${record.id} → ${record.path || "inline"} (${meta})`); + } +} + +function formatMeta(record, source) { + const parts = [record.type]; + if (record.grading?.preset) parts.push(`preset ${record.grading.preset}`); + if (record.grading?.lut) parts.push("lut"); + if (record.duration != null) parts.push(`${record.duration}s`); + if (record.width && record.height) parts.push(`${record.width}×${record.height}`); + if (record.transparent) parts.push("transparent"); + if (source === "reused" || source === "reused-explicit") parts.push("reused"); + if (source === "generated") parts.push("generated"); + return parts.join(", "); +} + +function extFromUrl(url) { + try { + return extname(new URL(url).pathname) || null; + } catch { + return null; + } +} + +function defaultExt(type) { + return DEFAULT_EXT[type] || ".bin"; +} + +run().catch((err) => { + if (args.json) { + console.log(JSON.stringify({ ok: false, error: err.message })); + } else { + console.error(`error: ${err.message}`); + } + process.exit(1); +}); diff --git a/skills/media-use/scripts/resolve.test.mjs b/packages/cli/src/media-use/resolve.test.mjs similarity index 99% rename from skills/media-use/scripts/resolve.test.mjs rename to packages/cli/src/media-use/resolve.test.mjs index 028270b08e..d85de2ea4f 100644 --- a/skills/media-use/scripts/resolve.test.mjs +++ b/packages/cli/src/media-use/resolve.test.mjs @@ -21,7 +21,7 @@ import { freezeLocalFile } from "./lib/freeze.mjs"; import { cachePut, cacheGet, importFromCache } from "./lib/cache.mjs"; import { validateCubeFile } from "./lib/cube-validate.mjs"; -const REPO_ROOT = join(import.meta.dirname, "..", "..", ".."); +const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..", ".."); const RESOLVE_CLI = join(import.meta.dirname, "resolve.mjs"); // The "Test: skills" CI job has no ffmpeg on PATH (by design). The smart-grade // test shells to ffmpeg, so it's skipped there and runs where ffmpeg exists. diff --git a/skills/media-use/SKILL.md b/skills/media-use/SKILL.md index 5655ccb439..d9debca680 100644 --- a/skills/media-use/SKILL.md +++ b/skills/media-use/SKILL.md @@ -7,12 +7,12 @@ description: Agent Media OS, the single skill for every media need in a HyperFra The media OS for HyperFrames: resolve · generate · operate · remember — every media type, one skill, zero context noise. -First run: install and sign in to the `heygen` CLI (the free-usage path), then verify with `node /scripts/resolve.mjs --doctor`. Setup and providers: `references/setup-providers.md`. +First run: install and sign in to the `heygen` CLI (the free-usage path), then verify with `npx hyperframes media-use resolve --doctor`. Setup and providers: `references/setup-providers.md`. ## Resolve — the one verb ```bash -node /scripts/resolve.mjs --type --intent "" --project +npx hyperframes media-use resolve --type --intent "" --project ``` Returns one line: `resolved → (, )`. All search noise stays on disk. diff --git a/skills/media-use/references/audio.md b/skills/media-use/references/audio.md index 504d630332..ec27bc138c 100644 --- a/skills/media-use/references/audio.md +++ b/skills/media-use/references/audio.md @@ -11,7 +11,7 @@ node /audio/scripts/audio.mjs --request ./audio_request.json --out ./ - **Request** `{ provider?, lang?, speed?, lines: [{ id, text, sfx?: [names] }], bgm: { mode?, query?, prompt? } }`: `id` joins each line back to your model; `bgm.mode` = `retrieve | generate | none` (omit for auto). `--only tts,bgm,sfx` runs a subset and merges into an existing `--out`. - **Output** `audio_meta.json` (id-keyed): `voices[].{path,duration_s,words[]}` (word timestamps for captions), `sfx[]`, `bgm`, `total_duration_s`. -- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music/SFX retrieval. Local/provider-specific generators are explicit alternatives where installed; run `node /scripts/resolve.mjs --doctor` before assuming retrieval or TTS will work. +- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music/SFX retrieval. Local/provider-specific generators are explicit alternatives where installed; run `npx hyperframes media-use resolve --doctor` before assuming retrieval or TTS will work. - If BGM took the generate path (`bgm_pending: true`), run `audio/scripts/wait-bgm.mjs` before final render. Single-shot helpers: `audio/scripts/heygen-tts.mjs` (one voice file). Transcription / background removal / captions use the `hyperframes` CLI (`transcribe`, `remove-background`), see the per-topic guides in `audio/references/` (`tts.md`, `bgm.md`, `sfx.md`, `transcribe.md`, `remove-background.md`, `captions/`). diff --git a/skills/media-use/references/grading.md b/skills/media-use/references/grading.md index 2292eef785..a86503d3c6 100644 --- a/skills/media-use/references/grading.md +++ b/skills/media-use/references/grading.md @@ -20,7 +20,7 @@ assemble those from a generic LUT plus handmade CSS vignette/grain/opacity. **Never `cat`/read a `.cube` file into context.** A 3D LUT is ~size^3 lines of raw numbers (33^3 ≈ 36k lines at the default size). It bloats context and carries zero human/agent-legible signal. To understand or choose a LUT, use `hyperframes grade-compare` to see it rendered, or `cube-validate.mjs` for a one-line `{ok,size}` check. Read `.media/index.md` or `luts/index.json` for the description. Never read the LUT body itself. ```bash -node /scripts/resolve.mjs --type grade --intent "warm daylight" --project . --json +npx hyperframes media-use resolve --type grade --intent "warm daylight" --project . --json ``` Preset-first output uses the core runtime vocabulary and does not freeze a file: @@ -93,7 +93,7 @@ For a reusable color transform beyond the preset vocabulary, freeze a validated `.cube` under `.media/luts/` and return a block that references it: ```bash -node /scripts/resolve.mjs --type grade --intent "teal orange blockbuster" --project . --json +npx hyperframes media-use resolve --type grade --intent "teal orange blockbuster" --project . --json ``` ```json @@ -106,20 +106,20 @@ node /scripts/resolve.mjs --type grade --intent "teal orange blockbus Use `lut` when you only need the reusable `.cube` file: ```bash -node /scripts/resolve.mjs --type lut --intent "teal orange blockbuster" --project . +npx hyperframes media-use resolve --type lut --intent "teal orange blockbuster" --project . ``` For a describable technical look, author an explicit parametric LUT with `--params`: ```bash -node /scripts/resolve.mjs --type lut --params '{"contrast":0.2,"temperature":-0.3}' --project . -node /scripts/resolve.mjs --type grade --params '{"exposure":0.2}' --project . --json +npx hyperframes media-use resolve --type lut --params '{"contrast":0.2,"temperature":-0.3}' --project . +npx hyperframes media-use resolve --type grade --params '{"exposure":0.2}' --project . --json ``` For a LUT generated by your own script, ingest it with `--from`; media-use validates it before registration and rejects invalid or oversized cubes: ```bash -node /scripts/resolve.mjs --type lut --from custom.cube --project . +npx hyperframes media-use resolve --type lut --from custom.cube --project . ``` Parametric math (`buildCube`) cannot reproduce real film stocks or emulsion @@ -155,6 +155,6 @@ validates generated or downloaded cubes as it freezes them under `.media/luts/`. ```bash -node skills/media-use/scripts/resolve.mjs --type lut --intent "teal orange blockbuster" --project . --json +npx hyperframes media-use resolve --type lut --intent "teal orange blockbuster" --project . --json node skills/media-use/scripts/lib/cube-validate.mjs .media/luts/lut_001.cube ``` diff --git a/skills/media-use/references/media-treatment-recipes.md b/skills/media-use/references/media-treatment-recipes.md index 37903ab58f..86517df083 100644 --- a/skills/media-use/references/media-treatment-recipes.md +++ b/skills/media-use/references/media-treatment-recipes.md @@ -784,7 +784,7 @@ node /scripts/dither.mjs \ --palette '#17121a,#824c50,#e09873,#f7ddb1' \ --point-size 3 -node /scripts/resolve.mjs \ +npx hyperframes media-use resolve \ --from .media/generated/video_001.atkinson.mp4 --type video --project . ``` diff --git a/skills/media-use/references/meta.md b/skills/media-use/references/meta.md index 776d1ed5d5..629963a9ee 100644 --- a/skills/media-use/references/meta.md +++ b/skills/media-use/references/meta.md @@ -26,7 +26,7 @@ HyperFrames owns media _playback_; media-use owns everything else. Each row is e Use `resolve --stats` for a local, shareable report over the current project's `.media/` manifest, the global `~/.media/` cache, and local resolve misses. Human output is compact; add `--json` for a single machine-readable object, and `--days N` to window timestamped records. ```bash -node /scripts/resolve.mjs --stats --project . --days 7 +npx hyperframes media-use resolve --stats --project . --days 7 # media-use stats # total resolves: 12 # misses: 2 diff --git a/skills/media-use/references/operations.md b/skills/media-use/references/operations.md index 0755454dcb..e2909f6c01 100644 --- a/skills/media-use/references/operations.md +++ b/skills/media-use/references/operations.md @@ -79,7 +79,7 @@ node /scripts/dither.mjs \ --palette '#0f380f,#306230,#8bac0f,#9bbc0f' \ --point-size 3 -node /scripts/resolve.mjs \ +npx hyperframes media-use resolve \ --from source.atkinson.mp4 --type video --project . ``` diff --git a/skills/media-use/references/resolve.md b/skills/media-use/references/resolve.md index 091483f4ad..16af9637e2 100644 --- a/skills/media-use/references/resolve.md +++ b/skills/media-use/references/resolve.md @@ -1,7 +1,7 @@ # Resolve — command, flags, reuse, adopt, inventory ```bash -node /scripts/resolve.mjs --type --intent "" --project +npx hyperframes media-use resolve --type --intent "" --project ``` Returns one line: `resolved → (, )` @@ -23,31 +23,31 @@ Returns one line: `resolved → (, )` ```bash # Background music -node /scripts/resolve.mjs --type bgm --intent "upbeat tech launch" --project . +npx hyperframes media-use resolve --type bgm --intent "upbeat tech launch" --project . # → resolved bgm_001 → .media/audio/bgm/bgm_001.mp3 (bgm, 25s) # Sound effect -node /scripts/resolve.mjs --type sfx --intent "whoosh" --project . +npx hyperframes media-use resolve --type sfx --intent "whoosh" --project . # → resolved sfx_001 → .media/audio/sfx/sfx_001.mp3 (sfx, 0.57s) # Image -node /scripts/resolve.mjs --type image --intent "gradient tech background" --project . +npx hyperframes media-use resolve --type image --intent "gradient tech background" --project . # → resolved image_001 → .media/images/image_001.jpg (image) # Icon -node /scripts/resolve.mjs --type icon --intent "rocket" --project . +npx hyperframes media-use resolve --type icon --intent "rocket" --project . # → resolved icon_001 → .media/images/icon_001.png (icon, transparent) # Brand logo (official mark — never redrawn by hand) -node /scripts/resolve.mjs --type logo --entity linkedin --intent "LinkedIn logo" --project . +npx hyperframes media-use resolve --type logo --entity linkedin --intent "LinkedIn logo" --project . # → resolved logo_001 → .media/images/logo_001.svg (logo, official mark) # Color grade block -node /scripts/resolve.mjs --type grade --intent "warm daylight" --project . --json +npx hyperframes media-use resolve --type grade --intent "warm daylight" --project . --json # → {"ok":true,"preset":"warm-daylight","grading":{"preset":"warm-daylight","intensity":1},...} # LUT file -node /scripts/resolve.mjs --type lut --intent "teal orange blockbuster" --project . +npx hyperframes media-use resolve --type lut --intent "teal orange blockbuster" --project . # → resolved lut_001 → .media/luts/lut_001.cube (lut) ``` @@ -76,7 +76,7 @@ node /scripts/resolve.mjs --type lut --intent "teal orange blockbuste Before resolving bgm/sfx/image/icon/logo/grade/lut, **check what already exists and reuse it when it fits.** media-use does not semantically match for you — you are the judge. It surfaces candidates; you decide. ```bash -node /scripts/resolve.mjs --type bgm --intent "upbeat tech launch" --candidates --project . +npx hyperframes media-use resolve --type bgm --intent "upbeat tech launch" --candidates --project . # [project] upbeat tech launch (25s, heygen.audio.sounds) # .media/audio/bgm/bgm_001.wav # [global] energetic tech intro (22s, heygen.audio.sounds) @@ -121,7 +121,7 @@ tracks today. Most HyperFrames projects already have assets in `assets/`. media-use adopts them: ```bash -node /scripts/resolve.mjs --adopt --project . +npx hyperframes media-use resolve --adopt --project . # → adopted 9 assets from assets/ # bgm_001 → assets/bgm/mango-fizz.mp3 (bgm, 146.6s) # image_001 → assets/images/avatar.jpg (image, 400×400) diff --git a/skills/media-use/references/setup-providers.md b/skills/media-use/references/setup-providers.md index ef5329e726..316d524eeb 100644 --- a/skills/media-use/references/setup-providers.md +++ b/skills/media-use/references/setup-providers.md @@ -12,7 +12,7 @@ heygen auth login --oauth # OAuth = free subscription credits; --api-key bills A This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). **media-use requires heygen >= v0.3.0 uniformly** (the OAuth free-usage path needs it), so `--doctor` nudges older CLIs to update even for API-key-only use. Before resolving anything, verify setup with: ```bash -node /scripts/resolve.mjs --doctor +npx hyperframes media-use resolve --doctor ``` ## Providers diff --git a/skills/media-use/scripts/lib/media-fetch.mjs b/skills/media-use/scripts/lib/media-fetch.mjs deleted file mode 100644 index 830e41618a..0000000000 --- a/skills/media-use/scripts/lib/media-fetch.mjs +++ /dev/null @@ -1,71 +0,0 @@ -// Media downloads use public HTTP(S) URLs. Validate every redirect target; -// a provider result must meet the same host policy as a direct ingest URL. -// Public HTTPS-to-HTTP redirects are allowed, matching direct HTTP support. -// This is a literal-host policy, not DNS pinning: DNS resolution remains trusted. - -import { BlockList, isIP } from "node:net"; - -const blocked = new BlockList(); -for (const [network, prefix] of [ - ["0.0.0.0", 8], - ["10.0.0.0", 8], - ["100.64.0.0", 10], - ["127.0.0.0", 8], - ["169.254.0.0", 16], - ["172.16.0.0", 12], - ["192.0.0.0", 24], - ["192.0.2.0", 24], - ["192.88.99.0", 24], - ["192.168.0.0", 16], - ["198.18.0.0", 15], - ["198.51.100.0", 24], - ["203.0.113.0", 24], - ["224.0.0.0", 4], - ["240.0.0.0", 4], -]) - blocked.addSubnet(network, prefix, "ipv4"); -for (const [network, prefix] of [ - ["::", 128], - ["::1", 128], - ["fc00::", 7], - ["fe80::", 10], - ["fec0::", 10], - ["ff00::", 8], - ["2001:db8::", 32], -]) - blocked.addSubnet(network, prefix, "ipv6"); - -export function isPublicMediaUrl(value) { - try { - const url = new URL(value); - if (url.protocol !== "http:" && url.protocol !== "https:") return false; - const host = url.hostname.replace(/\.$/, ""); - if ( - host === "localhost" || - host.endsWith(".localhost") || - host.endsWith(".local") || - host.endsWith(".internal") - ) - return false; - const address = host.replace(/^\[|\]$/g, ""); - const family = isIP(address); - return family === 0 || !blocked.check(address, family === 4 ? "ipv4" : "ipv6"); - } catch { - return false; - } -} - -export async function fetchMedia(url, { method = "GET", signal, fetchImpl = fetch } = {}) { - let current = String(url); - for (let hop = 0; hop <= 5; hop++) { - if (!isPublicMediaUrl(current)) - throw new Error("Media download blocked: URL is not public HTTP(S)"); - const response = await fetchImpl(current, { method, signal, redirect: "manual" }); - if (!(response.status >= 300 && response.status < 400)) return response; - const location = response.headers.get("location"); - if (!location) return response; - await response.body?.cancel(); - current = new URL(location, current).href; - } - throw new Error("Media download exceeded redirect limit"); -} diff --git a/skills/media-use/scripts/lib/media-fetch.mjs b/skills/media-use/scripts/lib/media-fetch.mjs new file mode 120000 index 0000000000..ed2b9e621f --- /dev/null +++ b/skills/media-use/scripts/lib/media-fetch.mjs @@ -0,0 +1 @@ +../../../../packages/cli/src/media-use/lib/media-fetch.mjs \ No newline at end of file diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 3e6c36f021..7e3cbe68ef 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -1,1271 +1,3 @@ #!/usr/bin/env node - -import { spawnSync } from "node:child_process"; -import { existsSync, statSync, writeFileSync, renameSync, rmSync } from "node:fs"; -import { resolve, join, extname, basename } from "node:path"; -import { parseArgs } from "node:util"; -import { - appendRecord, - findByPrompt, - findByEntity, - nextId, - withReservedFile, - withReservedFileSync, -} from "./lib/manifest.mjs"; -import { regenerateIndex } from "./lib/index-gen.mjs"; -import { cacheGet, cacheGetByEntity, importFromCache, cachePut } from "./lib/cache.mjs"; -import { - runCapability, - listTypes, - providerMatches, - providerNamesFor, - providerTierFor, -} from "./lib/registry.mjs"; -import { freezeUrl, freezeLocalFile, isDirectMediaUrl } from "./lib/freeze.mjs"; -import { findExistingAsset } from "./lib/adopt.mjs"; -import { track } from "./lib/telemetry.mjs"; -import { recordMiss } from "./lib/misses.mjs"; -import { buildStats } from "./lib/stats.mjs"; -import { typesMatch } from "./lib/match.mjs"; -import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./lib/candidates.mjs"; -import { findGlobalBySha } from "./lib/cache.mjs"; -import { heygenAuthMethod } from "../audio/scripts/lib/heygen.mjs"; -import { buildCube, paramsFromIntent } from "./lib/cube-build.mjs"; -import { validateCubeFile } from "./lib/cube-validate.mjs"; -import { analyzeMediaGrade, formatMeasuredNote } from "./lib/grade-analyzer.mjs"; -import { - freezeLibraryLut, - isLibraryLutOfflineMiss, - matchColorLook, -} from "./lib/lut-preset-provider.mjs"; -import { - HEYGEN_AUTH_COMMAND, - HEYGEN_INSTALL_COMMAND, - HEYGEN_MIN_VERSION, - HEYGEN_UPDATE_COMMAND, - consumeHeygenRemediation, - firstSemver, - flushHeygenFailureTracking, - versionLessThan, -} from "./lib/heygen-cli.mjs"; -import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs"; - -const INGEST_TYPES = listTypes(); -const DEFAULT_EXT = { - bgm: ".wav", - sfx: ".mp3", - voice: ".wav", - image: ".jpg", - icon: ".svg", - logo: ".svg", - brand: ".png", - video: ".mp4", - grade: ".cube", - lut: ".cube", -}; - -// resolve shells `fetch`/`freezeUrl` and modern ESM; 18 is the floor where those -// exist without flags. Named so the --doctor node check verifies something real -// (O2). Declared before the top-level `--doctor` branch that calls runDoctor(). -const MIN_NODE_VERSION = "18.0.0"; - -const { values: args } = parseArgs({ - options: { - type: { type: "string", short: "t" }, - intent: { type: "string", short: "i" }, - entity: { type: "string", short: "e" }, - project: { type: "string", short: "p", default: "." }, - adopt: { type: "boolean", default: false }, - candidates: { type: "boolean", default: false }, - doctor: { type: "boolean", default: false }, - stats: { type: "boolean", default: false }, - days: { type: "string" }, - "dry-run": { type: "boolean", default: false }, - reuse: { type: "string" }, - from: { type: "string" }, - params: { type: "string" }, - for: { type: "string" }, - analyze: { type: "boolean", default: false }, - "local-only": { type: "boolean", default: false }, - provider: { type: "string" }, - "avatar-id": { type: "string" }, - "voice-id": { type: "string" }, - json: { type: "boolean", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - strict: true, -}); - -if (args.help) { - console.log(`media-use resolve — turn a media need into a frozen local file - -Usage: - node resolve.mjs --type --intent "" [--project ] - -Types: ${listTypes().join(", ")} - -Options: - --type, -t Media type (required) - --intent, -i What you need (required) - --entity, -e Entity name for cache matching (optional) - --project, -p Project directory (default: .) - --adopt Adopt all existing assets/ files into the manifest - --candidates List reusable assets (project + global cache) for --type; no - download, no mutation. Read them and decide reuse yourself. - --doctor Check local CLI dependencies; no manifest changes. - --stats Print local usage stats from .media and ~/.media; no mutation. - --days Limit --stats to records/misses from the last N days when - timestamps are available. - --reuse Import a specific global-cache asset (by content sha/prefix, - from --candidates) into this project - --from Freeze a local file or direct public URL (ingest) - --params Build an explicit parametric LUT (lut/grade only) - --for Analyze a local image/video and add measured grade adjust - suggestions (grade only) - --analyze Return --for grade evidence without recording a candidate - --local-only Offline: skip every network provider - --provider Force one generator (e.g. codex, mflux, kokoro, heygen) - --avatar-id Override the default avatar for heygen.video generation - --voice-id Override the default voice for voice/heygen.video generation - --json Output JSON instead of one-line result - --help, -h Show this help`); - process.exit(0); -} - -const projectDir = resolve(args.project); -const type = args.type; -const intent = args.intent; -const entity = args.entity || null; - -if (args.adopt) { - const { adoptExistingAssets } = await import("./lib/adopt.mjs"); - const adopted = adoptExistingAssets(projectDir); - if (args.json) { - console.log(JSON.stringify({ ok: true, adopted: adopted.length, assets: adopted })); - } else if (adopted.length === 0) { - console.log("no new assets to adopt (assets/ empty or already registered)"); - } else { - console.log(`adopted ${adopted.length} asset${adopted.length === 1 ? "" : "s"} from assets/`); - for (const r of adopted) console.log(` ${r.id} → ${r.path} (${r.type})`); - } - process.exit(0); -} - -// Candidates: side-effect-free listing of reusable assets (project + global -// cache) for --type. No download, no provider, no mutation. The agent reads -// these and decides semantic fit itself. -if (args.candidates || args["dry-run"]) { - await showCandidates(); - process.exit(0); -} - -if (args.doctor) { - const doctor = runDoctor(); - const failed = doctor.checks.filter((check) => !check.ok); - // Non-PII: instrument the exact question the feature exists to answer — how - // often is --doctor run and which check fails most. Awaited so a short-lived - // run flushes before exit. - await track("media_use_doctor_run", { - ok: doctor.ok, - checks_failed: failed.length, - failed: failed.map((check) => check.name), - }); - if (args.json) { - console.log(JSON.stringify({ ok: doctor.ok, checks: doctor.checks })); - } else { - printDoctor(doctor.checks); - } - process.exit(doctor.ok ? 0 : 1); -} - -if (args.stats) { - const report = buildStats({ - projectDir, - days: args.days ? Number(args.days) : undefined, - }); - if (args.json) { - console.log(JSON.stringify(report)); - } else { - printStats(report); - } - process.exit(0); -} - -// Reuse: import a specific global-cache asset (by content sha/prefix, taken -// from --candidates) into this project. `!== undefined` so an empty --reuse "" -// still routes here (and gets a clear empty-sha error) instead of falling -// through to the misleading "--type and --intent are required". -if (args.reuse !== undefined) { - await reuseGlobal(args.reuse); - process.exit(0); -} - -// Ingest: freeze a user-supplied local file or direct public URL (no search). -if (args.from) { - await ingest(args.from); - process.exit(0); -} - -if (args.analyze) { - if (type !== "grade" || !args.for) { - console.error("error: --analyze requires --type grade and --for "); - process.exit(2); - } - const mediaPath = resolve(args.for); - if (!existsSync(mediaPath)) { - console.error(`error: --for file not found: ${mediaPath}`); - process.exit(2); - } - const analysis = analyzeMediaGrade(mediaPath); - if (args.json) { - console.log(JSON.stringify({ ok: true, type: "grade-analysis", ...analysis })); - } else { - console.log(formatMeasuredNote(mediaPath, analysis.measured)); - console.log(`suggested adjust: ${JSON.stringify(analysis.adjust)}`); - } - process.exit(0); -} - -// Recipes: folder-based named bundles resolved by entity name — no providers, -// no content hashing (an evolving versioned bundle, not an immutable file). -// Delegates to lib/recipe-store.mjs the way grade/lut delegate to resolveColor; -// freeze/list live in scripts/recipe.mjs. -if (type === "recipe") { - const { useRecipe } = await import("./lib/recipe-store.mjs"); - const name = (entity || intent || "").trim(); - if (!name) exitError("--type recipe needs --entity (or --intent )", 2); - try { - const used = useRecipe({ projectDir, name }); - if (args.json) { - console.log(JSON.stringify({ ok: true, ...used })); - } else { - console.log( - `resolved recipe ${used.recipe.name} (v${used.recipe.version}, ${used.recipe.workflow})`, - ); - console.log(` frame spec → ${used.frameSpecPath} (copied over)`); - console.log(` storyboard skeleton → ${used.skeletonPath}`); - if (used.briefSkeletonPath) console.log(` brief skeleton → ${used.briefSkeletonPath}`); - } - process.exit(0); - } catch (err) { - exitError(err.message, 1); - } -} - -if (args.params !== undefined) { - if (type !== "lut" && type !== "grade") { - exitError( - type - ? `--params only supports --type lut or grade (got ${type})` - : "--params requires --type lut or grade", - 2, - ); - } - try { - await runParams(); - process.exit(0); - } catch (err) { - exitError(err.message, 1); - } -} - -if (!args.type || !args.intent || !args.intent.trim()) { - console.error("error: --type and a non-empty --intent are required"); - process.exit(2); -} - -if (!listTypes().includes(args.type)) { - console.error(`error: unknown media type: ${args.type} (known: ${listTypes().join(", ")})`); - process.exit(2); -} - -// Forced-provider validation: reject an unknown/unavailable provider name up -// front so a typo reads as a typo, not a catalog miss (`no provider could -// resolve`). Match rule mirrors runProviders (full name or dotted prefix). -if (args.provider && !providerMatches(args.type, args.provider)) { - console.error( - `error: unknown provider "${args.provider}" for type ${args.type} (available: ${providerNamesFor(args.type).join(", ")})`, - ); - process.exit(2); -} - -function recordAvailable(projectDir, record) { - if (!record) return false; - if (record.path) return existsSync(join(projectDir, record.path)); - return record.type === "grade" && record.grading; -} - -// Sparse `{ authMethod }` for a heygen-family provider name (e.g. "heygen.tts"), -// else `{}` — keeps auth_method telemetry absent for every non-heygen resolve -// instead of implying an auth method that doesn't apply. -function heygenAuthMethodFor(provider) { - if (!provider || !provider.startsWith("heygen.")) return {}; - const authMethod = heygenAuthMethod(); - return authMethod ? { authMethod } : {}; -} - -function localizeImportedRecord(record, localPath) { - if (record?.type === "grade" && record.grading?.lut) { - record.grading = { - ...record.grading, - lut: { ...record.grading.lut, src: localPath }, - }; - } - return record; -} - -async function run() { - // A forced --provider means "(re)generate with THIS provider" — it bypasses - // every reuse rung (project/entity/assets/global cache) so it can't silently - // hand back an asset from a different provider. The floor only applies to the - // default (unforced) cascade. - const forced = !!args.provider; - - // 1. project manifest — exact-prompt match - const projectHit = forced ? null : findByPrompt(projectDir, intent, type); - if (recordAvailable(projectDir, projectHit)) { - return result(projectHit, "cached"); - } - - // 1b. entity match in project. icon and image are interchangeable for - // entity hits — both live in images/, and figma-imported brand marks are - // always recorded as type image while agents ask for logos as type icon. - if (!forced && entity) { - const entityHit = findByEntity(projectDir, entity); - if (entityHit && typesMatch(entityHit.type, type) && recordAvailable(projectDir, entityHit)) { - return result(entityHit, "cached"); - } - } - - // 1c. scan existing assets/ directory for unregistered matches - const existingAsset = - forced || type === "grade" || type === "lut" - ? null - : findExistingAsset(projectDir, intent, type); - if (existingAsset) { - const id = nextId(projectDir, type); - const record = { - id, - type: existingAsset.type, - path: existingAsset.relativePath, - source: "existing", - description: existingAsset.name.replace(/[-_]/g, " "), - provenance: { provider: "local", adopted: true, prompt: intent }, - }; - appendRecord(projectDir, record); - regenerateIndex(projectDir); - return result(record, "existing"); - } - - // 2. global cache — exact-prompt or entity match - const cacheHit = forced ? null : cacheGet(intent, type); - if (cacheHit) { - const ext = extname(cacheHit.cached_path); - const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) => - localizeImportedRecord(importFromCache(cacheHit, projectDir, id, localPath), localPath), - ); - if (imported) { - appendRecord(projectDir, imported); - regenerateIndex(projectDir); - return result(imported, "reused"); - } - } - - if (!forced && entity) { - const entityCacheHit = cacheGetByEntity(entity); - if (entityCacheHit && typesMatch(entityCacheHit.type, type)) { - const ext = extname(entityCacheHit.cached_path); - const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) => - localizeImportedRecord( - importFromCache(entityCacheHit, projectDir, id, localPath), - localPath, - ), - ); - if (imported) { - appendRecord(projectDir, imported); - regenerateIndex(projectDir); - return result(imported, "reused"); - } - } - } - - // Offline guard: --local-only skips every remote provider (HeyGen catalog), - // leaving the project + global cache and any local provider. - const localOnly = args["local-only"]; - const ctx = { - entity, - projectDir, - localOnly, - provider: args.provider, - avatarId: args["avatar-id"], - voiceId: args["voice-id"], - }; - - // Adherence nudge (offline, no auto-reuse): the exact-cache floor missed and - // we're about to fetch/generate. If lexically-similar assets already exist, - // point the agent at --candidates so it can reuse instead of fetching. Only a - // fuzzy match ever reaches the agent this way — never auto-applied. Goes to - // stderr so it reaches --json callers without corrupting stdout. Best-effort. - try { - const { similar } = listCandidates({ projectDir, type, intent, cap: CANDIDATE_CAP }); - if (similar > 0) { - console.error( - `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.`, - ); - } - } catch { - // hint is best-effort; never block a resolve - } - - if (type === "grade" || type === "lut") { - return resolveColor(type, intent, { projectDir }); - } - - // 3. provider search — registry tries providers in order (heygen-CLI first) - let searchResult = null; - let providerFailure = null; - try { - searchResult = await runCapability(type, "search", intent, ctx); - } catch (error) { - providerFailure = error; - // search failed, try generate - } - - // 4. generate fallback — same ordered cascade for the generate capability - if (!searchResult) { - try { - searchResult = await runCapability(type, "generate", intent, ctx); - } catch (error) { - providerFailure ??= error; - // generate failed too - } - } - - // A search/generate attempt against heygen may have fired a fire-and-forget - // media_use_provider_error track (reportHeygenFailure — heygen-search.mjs / - // voice-provider.mjs are sync call sites several layers below here and can't - // await it themselves). Join it now, before any process.exit() below can - // race it: both it and the miss/success telemetry below are separate, - // non-keepalive HTTP connections with no ordering guarantee otherwise. - await flushHeygenFailureTracking(); - - if (!searchResult) { - await track("media_use_resolve_miss", { - type, - local_only: !!localOnly, - provider_override: !!args.provider, - }); - recordMiss({ - type, - intent, - provider_override: !!args.provider, - local_only: !!args["local-only"], - }); - // brand stays local: no frame.md/design.md -> upsell the HyperFrames design - // flow rather than reporting a generic miss (B5). - const msg = - providerFailure instanceof BundledSfxAssetsError - ? providerFailure.message - : type === "brand" - ? "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." - : args.provider - ? `provider "${args.provider}" could not resolve ${type}: "${intent}"${localOnly ? " (--local-only skips network providers; drop it or the --provider override)" : ""}` - : `no provider could resolve ${type}: "${intent}"`; - if (args.json) { - console.log( - JSON.stringify({ - ok: false, - ...(providerFailure instanceof BundledSfxAssetsError - ? { code: providerFailure.code, fix: providerFailure.fix } - : {}), - error: msg, - }), - ); - } else { - console.error(`error: ${msg}`); - } - process.exit(1); - } - - // 5. freeze + register (atomic id+file reservation so concurrent resolves - // can't collide on an id during the download — MU-23) - const ext = searchResult.ext || extFromUrl(searchResult.url || "") || defaultExt(type); - const { id, localPath, fullPath } = await withReservedFile( - projectDir, - type, - ext, - async (reservation) => { - if (searchResult.localPath) { - freezeLocalFile(searchResult.localPath, reservation.fullPath); - } else if (searchResult.url) { - await freezeUrl(searchResult.url, reservation.fullPath); - } else { - throw new Error("provider returned no url or localPath"); - } - return reservation; - }, - ); - - const record = { - id, - type, - path: localPath, - source: searchResult.source || "search", - description: searchResult.metadata?.description || intent, - ...(searchResult.metadata?.duration != null && { - duration: Math.round(searchResult.metadata.duration * 10) / 10, // round to 0.1s like probe (voice bypassed it) - }), - ...(searchResult.metadata?.width != null && { width: searchResult.metadata.width }), - ...(searchResult.metadata?.height != null && { height: searchResult.metadata.height }), - ...(searchResult.metadata?.transparent != null && { - transparent: searchResult.metadata.transparent, - }), - ...(entity && { entity }), - provenance: { - provider: searchResult.metadata?.provider || "unknown", - prompt: intent, - // heygenAuthMethodFor spreads first so an explicit authMethod on a - // future provider's own metadata.provenance can still override it below - // -- safe today (no provider sets authMethod itself), but keep this - // ordering if that ever changes. - ...heygenAuthMethodFor(searchResult.metadata?.provider), - ...searchResult.metadata?.provenance, - }, - }; - - const heygenRemediation = consumeHeygenRemediation(); - if ( - searchResult.metadata?.provider === "bundled.sfx" && - !localOnly && - !args.provider && - heygenRemediation - ) { - record.advisory = heygenRemediation; - } - - appendRecord(projectDir, record); - regenerateIndex(projectDir); - // Auto-promote: surface every fetched asset in the global cache so it's - // reusable across all hyperframes projects (B3). Non-fatal; dedup by sha. - // ponytail: promotes search/generate/ingest assets (the ones media-use - // fetched), not bulk --adopt imports — add those if cross-project reuse of - // pre-existing project assets is wanted. - try { - cachePut(fullPath, record); - } catch { - // promotion is best-effort; a resolve still succeeds locally - } - return result(record, searchResult.source || "search"); -} - -function mergeSmartAdjust(block) { - if (!args.for) return block; - const mediaPath = resolve(args.for); - // Clear upfront error beats an ffmpeg "No such file" stack on a typo'd path. - if (!existsSync(mediaPath)) throw new Error(`--for file not found: ${mediaPath}`); - const analysis = analyzeMediaGrade(mediaPath); - console.error(formatMeasuredNote(mediaPath, analysis.measured)); - return { - ...block, - adjust: { - ...(block.adjust || {}), - ...analysis.adjust, - }, - }; -} - -function freezeGeneratedLut( - params, - { - projectDir, - type, - description = "parametric color grade", - validationErrorPrefix = "generated LUT failed validation", - }, -) { - return withReservedFileSync(projectDir, type, ".cube", ({ id, localPath, fullPath }) => { - const tmpPath = `${fullPath}.tmp`; - try { - // Write + validate at .tmp, then atomic rename, so a crash between write and - // validate can't leave an invalid .cube at the final path. - writeFileSync(tmpPath, buildCube(params)); - const check = validateCubeFile(tmpPath); - if (!check.ok) throw new Error(check.error); - renameSync(tmpPath, fullPath); - } catch (err) { - rmSync(tmpPath, { force: true }); - throw new Error(`${validationErrorPrefix}: ${err.message}`); - } - return { - id, - localPath, - fullPath, - lut: { src: localPath, intensity: 1 }, - source: "generated", - description, - metadata: { - provider: "cube_lut.builder", - provenance: { params }, - }, - }; - }); -} - -function exitError(message, status = 1) { - if (args.json) { - console.log(JSON.stringify({ ok: false, error: message })); - } else { - console.error(`error: ${message}`); - } - process.exit(status); -} - -function parseExplicitParams() { - try { - return JSON.parse(args.params); - } catch (err) { - throw new Error(`invalid --params JSON: ${err.message}`); - } -} - -async function runParams() { - if (type === "lut" && args.for) { - throw new Error("--for is only supported with --type grade"); - } - const params = parseExplicitParams(); - const description = - typeof intent === "string" && intent.trim() - ? intent.trim() - : `custom parametric ${type === "lut" ? "lut" : "grade"}`; - const frozen = freezeGeneratedLut(params, { - projectDir, - type, - description, - validationErrorPrefix: "--params produced an invalid LUT", - }); - const record = { - id: frozen.id, - type, - path: frozen.localPath, - source: frozen.source, - description: frozen.description, - ...(type === "grade" && { grading: mergeSmartAdjust({ intensity: 1, lut: frozen.lut }) }), - provenance: { - provider: frozen.metadata.provider, - ...frozen.metadata.provenance, - }, - }; - return finalizeColorRecord(record, frozen.source, frozen.fullPath); -} - -async function finalizeColorRecord(record, source, fullPath = null) { - appendRecord(projectDir, record); - regenerateIndex(projectDir); - if (fullPath) { - try { - cachePut(fullPath, record); - } catch { - // promotion is best-effort - } - } - return result(record, source); -} - -async function colorMiss(type, intent) { - await track("media_use_resolve_miss", { - type, - local_only: !!args["local-only"], - provider_override: !!args.provider, - }); - recordMiss({ - type, - intent, - provider_override: !!args.provider, - local_only: !!args["local-only"], - }); - const msg = `no local color grade could resolve ${type}: "${intent}"`; - if (args.json) { - console.log(JSON.stringify({ ok: false, error: msg })); - } else { - console.error(`error: ${msg}`); - } - process.exit(1); -} - -async function resolveGrade(intent, { projectDir }) { - const match = matchColorLook(intent); - if (match?.kind === "preset") { - const id = nextId(projectDir, "grade"); - const grading = mergeSmartAdjust({ preset: match.preset, intensity: 1 }); - const record = { - id, - type: "grade", - source: "preset", - description: intent, - grading, - provenance: { - provider: "color_grade.local", - prompt: intent, - preset: match.preset, - }, - }; - return finalizeColorRecord(record, "preset"); - } - - if (match?.kind === "library") { - let frozen; - try { - frozen = await freezeLibraryLut(match, { - projectDir, - type: "grade", - localOnly: args["local-only"], - }); - } catch (err) { - if (isLibraryLutOfflineMiss(err)) return colorMiss("grade", intent); - throw err; - } - const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut }); - const record = { - id: frozen.id, - type: "grade", - path: frozen.localPath, - source: frozen.source, - description: frozen.description, - grading, - provenance: { - provider: frozen.metadata.provider, - prompt: intent, - ...frozen.metadata.provenance, - }, - }; - return finalizeColorRecord(record, frozen.source, frozen.fullPath); - } - - const params = paramsFromIntent(intent); - if (!params) { - // No creative look matched. With --for, the measured adjust block is a - // valid grade on its own (footage auto-correction); only a true miss - // (no look AND no analysis) aborts. - if (args.for) { - const grading = mergeSmartAdjust({ intensity: 1 }); - const record = { - id: nextId(projectDir, "grade"), - type: "grade", - source: "measured", - description: intent, - grading, - provenance: { provider: "color_grade.local", prompt: intent, measured: true }, - }; - return finalizeColorRecord(record, "measured"); - } - return colorMiss("grade", intent); - } - const frozen = freezeGeneratedLut(params, { projectDir, type: "grade" }); - const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut }); - const record = { - id: frozen.id, - type: "grade", - path: frozen.localPath, - source: frozen.source, - description: intent, - grading, - provenance: { - provider: frozen.metadata.provider, - prompt: intent, - ...frozen.metadata.provenance, - }, - }; - return finalizeColorRecord(record, frozen.source, frozen.fullPath); -} - -async function resolveLut(intent, { projectDir }) { - if (args.for) { - throw new Error("--for is only supported with --type grade"); - } - const match = matchColorLook(intent); - if (match?.kind === "library") { - let frozen; - try { - frozen = await freezeLibraryLut(match, { - projectDir, - type: "lut", - localOnly: args["local-only"], - }); - } catch (err) { - if (isLibraryLutOfflineMiss(err)) return colorMiss("lut", intent); - throw err; - } - const record = { - id: frozen.id, - type: "lut", - path: frozen.localPath, - source: frozen.source, - description: frozen.description, - provenance: { - provider: frozen.metadata.provider, - prompt: intent, - ...frozen.metadata.provenance, - }, - }; - return finalizeColorRecord(record, frozen.source, frozen.fullPath); - } - - const params = paramsFromIntent(intent); - if (!params) return colorMiss("lut", intent); - const frozen = freezeGeneratedLut(params, { projectDir, type: "lut" }); - const record = { - id: frozen.id, - type: "lut", - path: frozen.localPath, - source: frozen.source, - description: intent, - provenance: { - provider: frozen.metadata.provider, - prompt: intent, - ...frozen.metadata.provenance, - }, - }; - return finalizeColorRecord(record, frozen.source, frozen.fullPath); -} - -async function resolveColor(type, intent, options) { - if (type === "grade") return resolveGrade(intent, options); - return resolveLut(intent, options); -} - -async function ingest(src) { - if (!type || !INGEST_TYPES.includes(type)) { - console.error(`error: --from requires --type (one of: ${INGEST_TYPES.join(", ")})`); - process.exit(2); - } - const isUrl = /^https?:\/\//i.test(src); - if (isUrl && !isDirectMediaUrl(src)) { - console.error( - `error: --from takes a direct public media URL or a local file; "${src}" is not a direct media link (no platform pages / yt-dlp)`, - ); - process.exit(2); - } - if (!isUrl && !existsSync(resolve(src))) { - console.error(`error: file not found: ${src}`); - process.exit(2); - } - // Refuse 0-byte input: an empty asset would register clean but fail at render - // (freezeUrl already rejects empty responses; this covers local files). - if (!isUrl && statSync(resolve(src)).size === 0) { - console.error(`error: refusing to ingest a 0-byte file: ${src}`); - process.exit(2); - } - const ext = extname(isUrl ? new URL(src).pathname : src) || defaultExt(type); - const { id, localPath, fullPath } = await withReservedFile( - projectDir, - type, - ext, - async (reservation) => { - if (isUrl) await freezeUrl(src, reservation.fullPath); - else freezeLocalFile(resolve(src), reservation.fullPath); - return reservation; - }, - ); - if (type === "lut" || type === "grade") { - try { - const check = validateCubeFile(fullPath); - if (!check.ok) throw new Error(check.error); - } catch (err) { - rmSync(fullPath, { force: true }); - exitError(`ingested LUT is invalid: ${err.message}`, 1); - } - } - const record = { - id, - type, - path: localPath, - source: "ingested", - description: basename(src.split("?")[0]), - provenance: { provider: "local", from: src }, - }; - appendRecord(projectDir, record); - regenerateIndex(projectDir); - try { - cachePut(fullPath, record); // surface ingested assets globally too (B3) - } catch { - // best-effort - } - await result(record, "ingested"); -} - -async function showCandidates() { - const projectDir = resolve(args.project); - const type = args.type; - if (!type || !listTypes().includes(type)) { - console.error(`error: --candidates requires --type (one of: ${listTypes().join(", ")})`); - process.exit(2); - } - const intent = args.intent || ""; - const { candidates, truncated, total, similar } = listCandidates({ - projectDir, - type, - intent, - cap: CANDIDATE_CAP, - }); - await track("media_use_candidates", { - type, - project_n: total.project, - global_n: total.global, - local_only: !!args["local-only"], - }); - if (args.json) { - console.log(JSON.stringify({ ok: true, candidates, truncated, total, similar })); - } else { - console.log(formatCandidates(candidates, { truncated, total })); - } -} - -// Best-effort latest stable CLI tag from the CDN (the install script's source of -// truth). null on any failure (offline, no curl) — treated as "unknown", never fatal. -function latestHeygenStable() { - const probe = runCommand("curl", [ - "-fsSL", - "--max-time", - "4", - "https://static.heygen.ai/cli/stable", - ]); - return probe.status === 0 ? firstSemver(commandText(probe)) : null; -} - -function heygenAuthCheck() { - // `heygen auth status` already emits JSON by default (only `--human` opts out - // to a table) — there is no `--json`/`--output` flag; passing one errors with - // "unknown flag". emailFromAuthStatus parses that default JSON. - // NOTE: JSON-by-default is a v0.3.0 behavior — this probe assumes it, which - // HEYGEN_MIN_VERSION >= 0.3.0 (+ the version gate above) guarantees. If that - // floor is ever lowered, auth detection on an older CLI would silently break. - const authProbe = runCommand("heygen", ["auth", "status"]); - // spawnSync sets .error/.signal on a timeout or spawn failure (status then - // null). A stalled auth endpoint (transient network/DNS) must not be reported - // as an authoritative "not authenticated" with a re-login fix. - const timedOut = authProbe.error?.code === "ETIMEDOUT" || authProbe.signal != null; - const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null; - return { - name: "heygen authenticated", - ok: !!email, - detail: email - ? `heygen authenticated as ${email}` - : timedOut - ? "heygen auth status timed out — possible network issue, not proof of sign-out" - : "heygen not authenticated", - fix: email ? "" : timedOut ? "check network, then re-run --doctor" : HEYGEN_AUTH_COMMAND, - }; -} - -function runDoctor() { - const checks = []; - const bundledSfx = inspectBundledSfxAssets(); - checks.push({ - name: "bundled SFX assets", - ok: bundledSfx.ok, - detail: bundledSfx.detail, - fix: bundledSfx.fix, - }); - const heygenVersionProbe = runCommand("heygen", ["--version"]); - const heygenOnPath = heygenVersionProbe.status === 0; - const heygenVersionText = commandText(heygenVersionProbe); - const heygenVersion = firstSemver(heygenVersionText); - - checks.push({ - name: "heygen on PATH", - ok: heygenOnPath, - // Just "is the binary here" — the version row below owns the version string, - // so this row must not also render `heygen v0.3.0` (two byte-identical lines). - detail: heygenOnPath ? "heygen found on PATH" : "heygen not found", - fix: heygenOnPath ? "" : HEYGEN_INSTALL_COMMAND, - }); - - if (!heygenOnPath) { - checks.push({ - name: "heygen version", - ok: false, - detail: "heygen version unavailable", - fix: HEYGEN_INSTALL_COMMAND, - }); - checks.push({ - name: "heygen authenticated", - ok: false, - detail: "heygen auth status unavailable", - fix: HEYGEN_INSTALL_COMMAND, - }); - } else if (heygenVersion) { - const versionOk = !versionLessThan(heygenVersion, HEYGEN_MIN_VERSION); - // Keep it latest: even when the installed version clears the floor, nudge - // `heygen update` if a newer stable exists. Best-effort — silently skipped - // when the CDN is unreachable, so it never blocks the check. - const latest = versionOk ? latestHeygenStable() : null; - const behind = latest && versionLessThan(heygenVersion, latest); - checks.push({ - name: "heygen version", - ok: versionOk, - detail: versionOk - ? `heygen v${heygenVersion}${behind ? ` (latest v${latest} available)` : ""}` - : `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`, - fix: versionOk ? (behind ? HEYGEN_UPDATE_COMMAND : "") : HEYGEN_UPDATE_COMMAND, - }); - - // Below the OAuth-capable floor the auth probe fails for the SAME root cause - // (an old CLI can't OAuth and doesn't emit JSON auth status), which would - // read as a confusing second "not authenticated" error. Skip it — one root - // cause, one fix. - checks.push( - versionOk - ? heygenAuthCheck() - : { - name: "heygen authenticated", - ok: false, - detail: "skipped — update heygen first", - fix: HEYGEN_UPDATE_COMMAND, - }, - ); - } else { - // Fail-open: heygen ran but printed no semver (dev/stripped build). We can't - // verify the version, so we don't block on it — but say so rather than a bare - // green check that implies a real version comparison happened. - checks.push({ - name: "heygen version", - ok: true, - detail: "heygen present; version unverifiable (no semver in --version output)", - fix: "", - }); - - checks.push(heygenAuthCheck()); - } - - const ffmpegProbe = runCommand("ffmpeg", ["-version"]); - checks.push({ - name: "ffmpeg on PATH", - ok: ffmpegProbe.status === 0, - detail: ffmpegProbe.status === 0 ? firstLine(ffmpegProbe.stdout) : "ffmpeg not found", - fix: ffmpegProbe.status === 0 ? "" : "brew install ffmpeg", - }); - - const ffprobeProbe = runCommand("ffprobe", ["-version"]); - checks.push({ - name: "ffprobe on PATH", - ok: ffprobeProbe.status === 0, - detail: ffprobeProbe.status === 0 ? firstLine(ffprobeProbe.stdout) : "ffprobe not found", - fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg", - }); - - const nodeOk = !versionLessThan(process.versions.node, MIN_NODE_VERSION); - checks.push({ - name: "node version", - ok: nodeOk, - detail: `${process.version} (need >= v${MIN_NODE_VERSION})`, - fix: nodeOk ? "" : `upgrade Node to >= v${MIN_NODE_VERSION}`, - }); - - // ffmpeg AND ffprobe are both strictly required (see references/setup-providers.md); the exit code - // must reflect that so a script gating on `--doctor` doesn't pass with ffprobe - // missing and then break at the first probe call. - const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH"); - const ffprobe = checks.find((check) => check.name === "ffprobe on PATH"); - return { ok: bundledSfx.ok && !!ffmpeg?.ok && !!ffprobe?.ok, checks }; -} - -function printDoctor(checks) { - const heygenChecks = new Set(["heygen on PATH", "heygen version", "heygen authenticated"]); - for (const check of checks) { - const prefix = check.ok ? "✓" : "✗"; - const freePath = heygenChecks.has(check.name) - ? " — free-usage path: bgm/image/voice/avatar-video" - : ""; - const fix = check.ok || !check.fix ? "" : ` — fix: ${check.fix}`; - console.log(`${prefix} ${check.detail}${freePath}${fix}`); - } -} - -function printStats(report) { - console.log("media-use stats"); - console.log(`total resolves: ${report.total_resolves}`); - console.log(`misses: ${report.misses}`); - console.log( - `hit rate: ${report.hit_rate == null ? "n/a" : `${Math.round(report.hit_rate * 100)}%`}`, - ); - printMap("by type", report.by_type); - printMap("by source", report.by_source); - printMap("by provider", report.by_provider); - printMap("by via", report.by_via); - console.log(`global cache assets: ${report.global_cache_assets}`); - console.log(`global cache disk: ${report.global_cache_disk_bytes} bytes`); - console.log(`cross-project reuse: ${report.cross_project_reuse}`); - console.log("top missed intents:"); - const entries = Object.entries(report.top_missed_intents); - if (entries.length === 0) { - console.log(" none"); - return; - } - for (const [type, misses] of entries) { - console.log(` ${type}:`); - for (const miss of misses) console.log(` ${miss.count} ${miss.intent}`); - } -} - -function printMap(label, values) { - const entries = Object.entries(values); - console.log(`${label}:`); - if (entries.length === 0) { - console.log(" none"); - return; - } - for (const [key, value] of entries) console.log(` ${key}: ${value}`); -} - -function runCommand(bin, argv) { - return spawnSync(bin, argv, { - encoding: "utf8", - timeout: 15000, - }); -} - -function commandText(result) { - return [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); -} - -function firstLine(text) { - return ( - String(text || "") - .trim() - .split(/\r?\n/)[0] || "" - ); -} - -function emailFromAuthStatus(text) { - // JSON only (auth status emits JSON by default). No prose regex fallback: a - // human-format body like "Session expired. Contact support@heygen.ai" would - // otherwise report the user as authenticated as support@heygen.ai. - const trimmed = String(text || "").trim(); - if (!trimmed.startsWith("{")) return null; - try { - const parsed = JSON.parse(trimmed); - return parsed?.data?.email || parsed?.email || null; - } catch { - return null; - } -} - -async function reuseGlobal(shaArg) { - const projectDir = resolve(args.project); - const type = args.type; - if (!type || !listTypes().includes(type)) { - console.error(`error: --reuse requires --type (one of: ${listTypes().join(", ")})`); - process.exit(2); - } - if (!shaArg || !shaArg.trim()) { - console.error("error: --reuse needs a content sha/prefix (from `resolve --candidates`)"); - process.exit(2); - } - const rec = findGlobalBySha(shaArg); - if (rec && rec.ambiguous) { - console.error( - `error: sha prefix "${shaArg}" is ambiguous (${rec.count} matches) — use more characters`, - ); - process.exit(2); - } - if (!rec) { - console.error(`error: no reusable global asset matches sha "${shaArg}"`); - process.exit(1); - } - // Type guard: don't import a bgm asset as an image (audio under images/). - // icon<->image are interchangeable; everything else must match --type. - if (!typesMatch(rec.type, type)) { - console.error(`error: sha "${shaArg}" is a ${rec.type} asset, not ${type}`); - process.exit(2); - } - const ext = extname(rec.cached_path || "") || defaultExt(type); - const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) => - localizeImportedRecord(importFromCache(rec, projectDir, id, localPath), localPath), - ); - if (!imported) { - console.error(`error: cache entry for "${shaArg}" is incomplete or missing on disk`); - process.exit(1); - } - // Distinguish an explicit agent reuse from an automatic normalize-exact hit. - imported.source = "reused-explicit"; - imported.provenance = { ...imported.provenance, reused_by: "agent" }; - appendRecord(projectDir, imported); - regenerateIndex(projectDir); - await result(imported, "reused-explicit"); -} - -async function result(record, source) { - // Non-PII usage event: which media type, how it resolved, which provider won. - // Never the intent text or paths. Awaited so a short-lived run flushes it. - await track("media_use_resolve", { - type: record.type, - source, - provider: record.provenance?.provider, - // How a library LUT resolved: "url" (CDN), "params-fallback" (CDN failed → - // parametric), or "params" (offline). Surfaces silent CDN→params downgrades - // in prod, which --doctor can't (it only answers "reachable now?"). - via: record.provenance?.via, - // Free (OAuth) vs. paid (API-key) heygen path — sparse: absent for every - // non-heygen provider (see heygenAuthMethodFor at construction time). On a - // cache/reuse hit this reports how the asset was ORIGINALLY fetched, not - // this resolve's own credential state — intentional: it's a conversion - // signal about the fetch that actually consumed a heygen credit, not - // about the (free, no-credential) act of copying a cached file. - auth_method: record.provenance?.authMethod, - // "local" / "network_free" / "network_paid", straight from the registry's own - // A/N/P declaration — so a dashboard can separate free lookups from calls that - // spend credit without hardcoding provider names. Sparse: absent when the - // record carries no provider (cache and reuse hits) or the name is unknown. - provider_tier: providerTierFor(record.provenance?.provider), - local_only: !!args["local-only"], - provider_override: !!args.provider, - }); - if (args.json) { - const grading = record.type === "grade" && record.grading ? record.grading : null; - console.log( - JSON.stringify({ - ok: true, - ...record, - ...(grading || {}), - ...(grading && { grading }), - _source: source, - }), - ); - } else { - const meta = formatMeta(record, source); - console.log(`resolved ${record.id} → ${record.path || "inline"} (${meta})`); - } -} - -function formatMeta(record, source) { - const parts = [record.type]; - if (record.grading?.preset) parts.push(`preset ${record.grading.preset}`); - if (record.grading?.lut) parts.push("lut"); - if (record.duration != null) parts.push(`${record.duration}s`); - if (record.width && record.height) parts.push(`${record.width}×${record.height}`); - if (record.transparent) parts.push("transparent"); - if (source === "reused" || source === "reused-explicit") parts.push("reused"); - if (source === "generated") parts.push("generated"); - return parts.join(", "); -} - -function extFromUrl(url) { - try { - return extname(new URL(url).pathname) || null; - } catch { - return null; - } -} - -function defaultExt(type) { - return DEFAULT_EXT[type] || ".bin"; -} - -run().catch((err) => { - if (args.json) { - console.log(JSON.stringify({ ok: false, error: err.message })); - } else { - console.error(`error: ${err.message}`); - } - process.exit(1); -}); +import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +const root = join(fileURLToPath(new URL("../../../", import.meta.url))); const cli = join(root, "packages/cli/dist/cli.js"); const result = spawnSync(process.execPath, [cli, "media-use", "resolve", ...process.argv.slice(2)], { stdio: "inherit" }); process.exit(result.status ?? 1); From 20c952824c5bf4de915eded02f703dabb201ec85 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:32:02 -0400 Subject: [PATCH 02/21] fix(cli): replace media-use compatibility asset --- packages/cli/scripts/build-copy.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index a09a1a9133..6e612f5325 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -1,7 +1,7 @@ // Cross-platform replacement for the previous `mkdir -p … && cp -r …` shell // chain, which failed on Windows because `cp` doesn't accept `-r` there. -import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { setTimeout as sleep } from "node:timers/promises"; @@ -92,6 +92,9 @@ async function main() { // The media-use engine lives with the CLI source, but keeps its published // skill-relative layout so the moved .mjs tree can run without a rewrite. const mediaEngine = join(CLI_ROOT, "src", "media-use"); + rmSync(join(DIST, "skills", "media-use", "scripts", "lib", "media-fetch.mjs"), { + force: true, + }); copyDir(join(mediaEngine, "lib"), join(DIST, "skills", "media-use", "scripts", "lib")); cpSync( join(mediaEngine, "resolve.mjs"), From 601844bb9179107aea73fc7268fc3f2149ccb8d8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:34:21 -0400 Subject: [PATCH 03/21] fix(cli): replace moved media-use tree --- packages/cli/scripts/build-copy.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 6e612f5325..160a7a6bf0 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -92,10 +92,10 @@ async function main() { // The media-use engine lives with the CLI source, but keeps its published // skill-relative layout so the moved .mjs tree can run without a rewrite. const mediaEngine = join(CLI_ROOT, "src", "media-use"); - rmSync(join(DIST, "skills", "media-use", "scripts", "lib", "media-fetch.mjs"), { - force: true, - }); - copyDir(join(mediaEngine, "lib"), join(DIST, "skills", "media-use", "scripts", "lib")); + const publishedMediaLib = join(DIST, "skills", "media-use", "scripts", "lib"); + rmSync(publishedMediaLib, { recursive: true, force: true }); + mkdirSync(publishedMediaLib, { recursive: true }); + copyDir(join(mediaEngine, "lib"), publishedMediaLib); cpSync( join(mediaEngine, "resolve.mjs"), join(DIST, "skills", "media-use", "scripts", "resolve.mjs"), From ea3526beef3c4af5df80e6650c4552843781e3cf Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:37:15 -0400 Subject: [PATCH 04/21] fix(cli): copy moved media-use files cleanly --- packages/cli/scripts/build-copy.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 160a7a6bf0..5e238b4cee 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -94,7 +94,6 @@ async function main() { const mediaEngine = join(CLI_ROOT, "src", "media-use"); const publishedMediaLib = join(DIST, "skills", "media-use", "scripts", "lib"); rmSync(publishedMediaLib, { recursive: true, force: true }); - mkdirSync(publishedMediaLib, { recursive: true }); copyDir(join(mediaEngine, "lib"), publishedMediaLib); cpSync( join(mediaEngine, "resolve.mjs"), From 597bc0e0137880a4dbc1aca6d1c9d7633e6bab3d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:39:29 -0400 Subject: [PATCH 05/21] fix(cli): copy media-use engine files individually --- packages/cli/scripts/build-copy.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 5e238b4cee..245235fd41 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -94,7 +94,8 @@ async function main() { const mediaEngine = join(CLI_ROOT, "src", "media-use"); const publishedMediaLib = join(DIST, "skills", "media-use", "scripts", "lib"); rmSync(publishedMediaLib, { recursive: true, force: true }); - copyDir(join(mediaEngine, "lib"), publishedMediaLib); + mkdirSync(publishedMediaLib, { recursive: true }); + copyDirContents(join(mediaEngine, "lib"), publishedMediaLib); cpSync( join(mediaEngine, "resolve.mjs"), join(DIST, "skills", "media-use", "scripts", "resolve.mjs"), From b060ea885d74f445b280432a231e644b935fb62a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:43:04 -0400 Subject: [PATCH 06/21] fix(cli): keep audio helper compatibility import --- skills/media-use/scripts/lib/media-fetch.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) mode change 120000 => 100644 skills/media-use/scripts/lib/media-fetch.mjs diff --git a/skills/media-use/scripts/lib/media-fetch.mjs b/skills/media-use/scripts/lib/media-fetch.mjs deleted file mode 120000 index ed2b9e621f..0000000000 --- a/skills/media-use/scripts/lib/media-fetch.mjs +++ /dev/null @@ -1 +0,0 @@ -../../../../packages/cli/src/media-use/lib/media-fetch.mjs \ No newline at end of file diff --git a/skills/media-use/scripts/lib/media-fetch.mjs b/skills/media-use/scripts/lib/media-fetch.mjs new file mode 100644 index 0000000000..5c0e362684 --- /dev/null +++ b/skills/media-use/scripts/lib/media-fetch.mjs @@ -0,0 +1,6 @@ +// Compatibility copy for the audio helpers that still import the historical +// skill-relative path. The CLI-owned engine is the source of truth. +export { + fetchMedia, + isPublicMediaUrl, +} from "../../../../packages/cli/src/media-use/lib/media-fetch.mjs"; From 60faba1adb3e322ae1355f438913017d0d63ac53 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:45:13 -0400 Subject: [PATCH 07/21] fix(cli): clear copied skill destinations --- packages/cli/scripts/build-copy.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 245235fd41..83e0fbc5c3 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -86,7 +86,9 @@ async function main() { console.warn(`[build-copy] skill not found, skipping: skills/${skill}`); continue; } - copyDir(src, join(DIST, "skills", skill)); + const destination = join(DIST, "skills", skill); + rmSync(destination, { recursive: true, force: true }); + copyDir(src, destination); } // The media-use engine lives with the CLI source, but keeps its published From 680d8c283bf067942d3e6562d68b9a17bc851104 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:55:50 -0400 Subject: [PATCH 08/21] test(cli): preserve moved media-use test paths --- packages/cli/src/media-use/lib/coverage.test.mjs | 11 ++++++++++- packages/cli/src/media-use/lib/cutlist.test.mjs | 13 ++++++++++++- packages/cli/src/media-use/lib/duck.test.mjs | 13 ++++++++++++- packages/cli/src/media-use/resolve.test.mjs | 2 +- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/media-use/lib/coverage.test.mjs b/packages/cli/src/media-use/lib/coverage.test.mjs index ed4ffbde93..ad37b742a6 100644 --- a/packages/cli/src/media-use/lib/coverage.test.mjs +++ b/packages/cli/src/media-use/lib/coverage.test.mjs @@ -10,7 +10,16 @@ import { CAPABILITIES, listModels } from "./local-models.mjs"; // test enforces the weakness→owner matrix in references/meta.md so a claim can't rot — if // a capability's entrypoint disappears, this fails. -const SKILL = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const SKILL = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", + "..", + "..", + "skills", + "media-use", +); test("weakness: audio-only → media-use resolves image + icon", () => { for (const t of ["image", "icon"]) { diff --git a/packages/cli/src/media-use/lib/cutlist.test.mjs b/packages/cli/src/media-use/lib/cutlist.test.mjs index 72e6f2b0df..deac815dfc 100644 --- a/packages/cli/src/media-use/lib/cutlist.test.mjs +++ b/packages/cli/src/media-use/lib/cutlist.test.mjs @@ -8,7 +8,18 @@ import { test } from "node:test"; import { compileCutList } from "./cutlist.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); -const SCRIPT = join(HERE, "..", "transcript-cut.mjs"); +const SCRIPT = join( + HERE, + "..", + "..", + "..", + "..", + "..", + "skills", + "media-use", + "scripts", + "transcript-cut.mjs", +); test("explicit --remove ranges invert to kept segments", () => { const transcript = [ diff --git a/packages/cli/src/media-use/lib/duck.test.mjs b/packages/cli/src/media-use/lib/duck.test.mjs index 398601f0b9..a912361f26 100644 --- a/packages/cli/src/media-use/lib/duck.test.mjs +++ b/packages/cli/src/media-use/lib/duck.test.mjs @@ -8,7 +8,18 @@ import { test } from "node:test"; import { duckKeyframes, duckLane, speechSpans } from "./duck.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); -const SCRIPT = join(HERE, "..", "audio-duck.mjs"); +const SCRIPT = join( + HERE, + "..", + "..", + "..", + "..", + "..", + "skills", + "media-use", + "scripts", + "audio-duck.mjs", +); test("speechSpans bridges gaps smaller than mergeGap", () => { const meta = { diff --git a/packages/cli/src/media-use/resolve.test.mjs b/packages/cli/src/media-use/resolve.test.mjs index d85de2ea4f..9a4af1510a 100644 --- a/packages/cli/src/media-use/resolve.test.mjs +++ b/packages/cli/src/media-use/resolve.test.mjs @@ -21,7 +21,7 @@ import { freezeLocalFile } from "./lib/freeze.mjs"; import { cachePut, cacheGet, importFromCache } from "./lib/cache.mjs"; import { validateCubeFile } from "./lib/cube-validate.mjs"; -const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..", ".."); +const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", ".."); const RESOLVE_CLI = join(import.meta.dirname, "resolve.mjs"); // The "Test: skills" CI job has no ffmpeg on PATH (by design). The smart-grade // test shells to ffmpeg, so it's skipped there and runs where ffmpeg exists. From d8d1199f603035973b6ee1fd01ba196738ede688 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:00:08 -0400 Subject: [PATCH 09/21] fix(cli): preserve media-use script compatibility imports --- packages/cli/src/media-use/lib/coverage.test.mjs | 10 ++++++++-- skills/media-use/scripts/lib/cutlist.mjs | 1 + skills/media-use/scripts/lib/duck.mjs | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 skills/media-use/scripts/lib/cutlist.mjs create mode 100644 skills/media-use/scripts/lib/duck.mjs diff --git a/packages/cli/src/media-use/lib/coverage.test.mjs b/packages/cli/src/media-use/lib/coverage.test.mjs index ad37b742a6..79d58568bb 100644 --- a/packages/cli/src/media-use/lib/coverage.test.mjs +++ b/packages/cli/src/media-use/lib/coverage.test.mjs @@ -52,7 +52,10 @@ test("weakness: no media-ops → ops guidance reference exists", () => { test("weakness: no transcript-driven cutting → cut compiler entrypoints exist", async () => { assert.ok(existsSync(join(SKILL, "scripts", "transcript-cut.mjs")), "transcript-cut missing"); - assert.ok(existsSync(join(SKILL, "scripts", "lib", "cutlist.mjs")), "cutlist lib missing"); + assert.ok( + existsSync(join(dirname(fileURLToPath(import.meta.url)), "cutlist.mjs")), + "cutlist lib missing", + ); const cutlist = await import("./cutlist.mjs"); assert.equal(typeof cutlist.compileCutList, "function"); }); @@ -69,7 +72,10 @@ test("weakness: whisper.cpp is weak → better local ASR (Parakeet) entrypoint e test("weakness: no auto-duck/loudness → duck compiler and recipes exist", async () => { assert.ok(existsSync(join(SKILL, "scripts", "audio-duck.mjs")), "audio-duck missing"); - assert.ok(existsSync(join(SKILL, "scripts", "lib", "duck.mjs")), "duck lib missing"); + assert.ok( + existsSync(join(dirname(fileURLToPath(import.meta.url)), "duck.mjs")), + "duck lib missing", + ); assert.ok(existsSync(join(SKILL, "references", "operations.md")), "operations.md missing"); const duck = await import("./duck.mjs"); assert.equal(typeof duck.speechSpans, "function"); diff --git a/skills/media-use/scripts/lib/cutlist.mjs b/skills/media-use/scripts/lib/cutlist.mjs new file mode 100644 index 0000000000..f38dec626c --- /dev/null +++ b/skills/media-use/scripts/lib/cutlist.mjs @@ -0,0 +1 @@ +export * from "../../../../packages/cli/src/media-use/lib/cutlist.mjs"; diff --git a/skills/media-use/scripts/lib/duck.mjs b/skills/media-use/scripts/lib/duck.mjs new file mode 100644 index 0000000000..70f1e7433a --- /dev/null +++ b/skills/media-use/scripts/lib/duck.mjs @@ -0,0 +1 @@ +export * from "../../../../packages/cli/src/media-use/lib/duck.mjs"; From acbd2e54437e989b57b87577a978bf49f1c9bccc Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:04:07 -0400 Subject: [PATCH 10/21] fix(cli): preserve media-use telemetry shim --- skills/media-use/scripts/lib/telemetry.mjs | 1 + 1 file changed, 1 insertion(+) create mode 100644 skills/media-use/scripts/lib/telemetry.mjs diff --git a/skills/media-use/scripts/lib/telemetry.mjs b/skills/media-use/scripts/lib/telemetry.mjs new file mode 100644 index 0000000000..b000130d69 --- /dev/null +++ b/skills/media-use/scripts/lib/telemetry.mjs @@ -0,0 +1 @@ +export * from "../../../../packages/cli/src/media-use/lib/telemetry.mjs"; From f2aaed65dde8759bde3a019c6119f0e7a7da3270 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:08:39 -0400 Subject: [PATCH 11/21] fix(cli): resolve packaged media-use LUT assets --- packages/cli/src/media-use/lib/lut-preset-provider.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/media-use/lib/lut-preset-provider.mjs b/packages/cli/src/media-use/lib/lut-preset-provider.mjs index 99aa6fc338..9b318fa935 100644 --- a/packages/cli/src/media-use/lib/lut-preset-provider.mjs +++ b/packages/cli/src/media-use/lib/lut-preset-provider.mjs @@ -8,6 +8,7 @@ import { validateCube, validateCubeFile } from "./cube-validate.mjs"; const SKILL_DIR = [ join(import.meta.dirname, ".."), + join(import.meta.dirname, "..", "..", "..", "..", "skills", "media-use"), join(import.meta.dirname, "..", "..", "..", "..", "..", "skills", "media-use"), ].find((candidate) => existsSync(join(candidate, "luts", "index.json"))); const LUT_DIR = join(SKILL_DIR, "luts"); From d121af6515dee15e085eaf69cce22bc22a0e6d50 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:12:17 -0400 Subject: [PATCH 12/21] fix(cli): resolve packaged bundled SFX assets --- packages/cli/src/media-use/lib/bundled-sfx-provider.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs b/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs index cb1a01a990..7db266df6a 100644 --- a/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs +++ b/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs @@ -3,10 +3,10 @@ import { extname, join } from "node:path"; const LIB_DIR = process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR || [ - join(import.meta.dirname, "..", "audio", "assets", "sfx"), + join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"), join(import.meta.dirname, "..", "..", "..", "..", "..", "skills", "media-use", "audio", "assets", "sfx"), ].find((candidate) => existsSync(candidate)) || - join(import.meta.dirname, "..", "audio", "assets", "sfx"); + join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"); export const BUNDLED_SFX_RECOVERY_COMMAND = "npx hyperframes skills update media-use"; From 5c02f274cb5e15a18f383cb5bc9fb44fcfbcbcf1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:20:26 -0400 Subject: [PATCH 13/21] chore(skills): regenerate media-use manifest --- skills-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 99060c3e75..fc2cac54b5 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,8 +54,8 @@ "files": 1 }, "media-use": { - "hash": "8f21655cd07a62ea", - "files": 158 + "hash": "0e61072302568090", + "files": 79 }, "motion-graphics": { "hash": "32641ae2b94c4a8f", From cbf2e56899ca4c0557c512b9e77fd956f3522407 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:43:36 -0400 Subject: [PATCH 14/21] fix(cli): preserve media-use parity coverage --- .fallowrc.jsonc | 3 +++ .github/workflows/ci.yml | 6 +++--- packages/core/src/figma/mediaIndex.test.ts | 5 +++-- packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts | 2 +- skills-manifest.json | 2 +- skills/media-use/scripts/resolve.mjs | 4 ++-- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 51a2fb63b8..dee2e1bcb8 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -845,6 +845,9 @@ // adding branches. The line-shift fingerprint re-flags the inherited // complexity even though the logic is unchanged. "packages/cli/src/commands/info.ts", + // media-use was relocated from skills/ without changing its engine logic; + // preserve the inherited audit baseline while the compatibility move lands. + "packages/cli/src/media-use/**", // gsapParserAcorn.ts: the flagged `sameMemberAccess` (structural equality // of two member-access nodes) pre-dates this PR's motion-eval work // (added in #1760, unchanged since). The new const-folding / set-seeding / diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7489c3f14..499ef7d52c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,8 +375,8 @@ jobs: # `node:` built-in imports. They aren't part of any workspace package, and # the main `Test` job's `code` path filter excludes `skills/**`, so without # this dedicated job they'd never run in CI. Examples: - # * skills/media-use/scripts/resolve.test.mjs - # * skills/media-use/scripts/lib/manifest.test.mjs + # * packages/cli/src/media-use/resolve.test.mjs + # * packages/cli/src/media-use/lib/manifest.test.mjs # Several of these are regression guards (e.g. shell-injection cases), so # the whole point is that they fire on PRs that touch skills/. test-skills: @@ -396,7 +396,7 @@ jobs: # would defeat the whole point of this job). run: | set -euo pipefail - mapfile -t SKILLS_TESTS < <(find skills -type f -name "*.test.mjs" | sort) + mapfile -t SKILLS_TESTS < <(find skills packages/cli/src/media-use -type f -name "*.test.mjs" | sort) if [ "${#SKILLS_TESTS[@]}" -eq 0 ]; then echo "::error::No skills/**/*.test.mjs files found. Did the layout change?" exit 1 diff --git a/packages/core/src/figma/mediaIndex.test.ts b/packages/core/src/figma/mediaIndex.test.ts index b2d597f8ae..95aedcb9d6 100644 --- a/packages/core/src/figma/mediaIndex.test.ts +++ b/packages/core/src/figma/mediaIndex.test.ts @@ -87,9 +87,10 @@ describe("regenerateIndex", () => { "..", "..", "..", - "skills", + "packages", + "cli", + "src", "media-use", - "scripts", "lib", "index-gen.mjs", ), diff --git a/packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts b/packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts index 8eb943574e..7a62e9d079 100644 --- a/packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts +++ b/packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts @@ -69,7 +69,7 @@ function productResult(signalStats = SIGNALSTATS) { async function vendoredResult(signalStats = SIGNALSTATS) { const moduleUrl = new URL( - "../../../skills/media-use/scripts/lib/grade-analyzer.mjs", + "../../../packages/cli/src/media-use/lib/grade-analyzer.mjs", import.meta.url, ); const analyzer = await import(moduleUrl.href); diff --git a/skills-manifest.json b/skills-manifest.json index fc2cac54b5..ddf3fb6100 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,7 +54,7 @@ "files": 1 }, "media-use": { - "hash": "0e61072302568090", + "hash": "8d0abb1e25edcaf3", "files": 79 }, "motion-graphics": { diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 7e3cbe68ef..4b04f8b38a 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -1,3 +1,3 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -const root = join(fileURLToPath(new URL("../../../", import.meta.url))); const cli = join(root, "packages/cli/dist/cli.js"); const result = spawnSync(process.execPath, [cli, "media-use", "resolve", ...process.argv.slice(2)], { stdio: "inherit" }); process.exit(result.status ?? 1); +import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +const root = join(fileURLToPath(new URL("../../../", import.meta.url))); const dist = join(root, "packages/cli/dist/cli.js"); const result = existsSync(dist) ? spawnSync(process.execPath, [dist, "media-use", "resolve", ...process.argv.slice(2)], { stdio: "inherit" }) : spawnSync("bun", [join(root, "packages/cli/src/cli.ts"), "media-use", "resolve", ...process.argv.slice(2)], { stdio: "inherit" }); process.exit(result.status ?? 1); From e6218711d6f843b03519891dba807b8b0c000796 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:54:31 -0400 Subject: [PATCH 15/21] fix(cli): cover relocated media-use tests --- .fallowrc.jsonc | 5 +---- .github/workflows/ci.yml | 4 ++-- package.json | 2 +- .../cli/src/media-use/lib/cutlist.test.mjs | 3 +++ packages/cli/src/media-use/lib/duck.test.mjs | 2 ++ skills/media-use/scripts/resolve.mjs | 18 ++++++++++++++++-- 6 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index dee2e1bcb8..6ef827b56c 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -71,7 +71,7 @@ "packages/player/tests/**", "packages/engine/tests/**", "skills/**/test-corpus/**", - "skills/**/scripts/**", + "packages/cli/src/media-use/**", // Agent-invoked motion-graphics tools co-located with their docs (run via // `node ` per grounding/PROTOCOL.md / categories/maps/module.md // prose), not import-graph reachable. @@ -845,9 +845,6 @@ // adding branches. The line-shift fingerprint re-flags the inherited // complexity even though the logic is unchanged. "packages/cli/src/commands/info.ts", - // media-use was relocated from skills/ without changing its engine logic; - // preserve the inherited audit baseline while the compatibility move lands. - "packages/cli/src/media-use/**", // gsapParserAcorn.ts: the flagged `sameMemberAccess` (structural equality // of two member-access nodes) pre-dates this PR's motion-eval work // (added in #1760, unchanged since). The new const-folding / set-seeding / diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 499ef7d52c..d746305ba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -396,14 +396,14 @@ jobs: # would defeat the whole point of this job). run: | set -euo pipefail - mapfile -t SKILLS_TESTS < <(find skills packages/cli/src/media-use -type f -name "*.test.mjs" | sort) + mapfile -t SKILLS_TESTS < <(find skills -type f -name "*.test.mjs" | sort) if [ "${#SKILLS_TESTS[@]}" -eq 0 ]; then echo "::error::No skills/**/*.test.mjs files found. Did the layout change?" exit 1 fi printf 'Running %d skills test file(s):\n' "${#SKILLS_TESTS[@]}" printf ' * %s\n' "${SKILLS_TESTS[@]}" - node --test "${SKILLS_TESTS[@]}" + bun run test:skills test-skill-bootstrap-windows: name: "Test: skill bootstrap (Windows, Node 24)" diff --git a/package.json b/package.json index 22606600b1..c438979507 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "knip": "knip", "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts && vitest run scripts/catalog/", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", - "test:skills": "node --test 'skills/**/*.test.mjs'", + "test:skills": "node --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", "generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts", "package:codex-plugin": "node scripts/package-codex-plugin.mjs", diff --git a/packages/cli/src/media-use/lib/cutlist.test.mjs b/packages/cli/src/media-use/lib/cutlist.test.mjs index deac815dfc..98e7710a27 100644 --- a/packages/cli/src/media-use/lib/cutlist.test.mjs +++ b/packages/cli/src/media-use/lib/cutlist.test.mjs @@ -8,6 +8,9 @@ import { test } from "node:test"; import { compileCutList } from "./cutlist.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); +// These legacy wrappers remain under skills/media-use/scripts because the +// skill's public audio entrypoints still invoke them; the moved engine tests +// their implementation here while preserving that compatibility surface. const SCRIPT = join( HERE, "..", diff --git a/packages/cli/src/media-use/lib/duck.test.mjs b/packages/cli/src/media-use/lib/duck.test.mjs index a912361f26..471eafff6d 100644 --- a/packages/cli/src/media-use/lib/duck.test.mjs +++ b/packages/cli/src/media-use/lib/duck.test.mjs @@ -8,6 +8,8 @@ import { test } from "node:test"; import { duckKeyframes, duckLane, speechSpans } from "./duck.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); +// Keep the skill-owned wrapper path under test until the compatibility layer +// is removed in a later release. const SCRIPT = join( HERE, "..", diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 4b04f8b38a..84480705b2 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -1,3 +1,17 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -const root = join(fileURLToPath(new URL("../../../", import.meta.url))); const dist = join(root, "packages/cli/dist/cli.js"); const result = existsSync(dist) ? spawnSync(process.execPath, [dist, "media-use", "resolve", ...process.argv.slice(2)], { stdio: "inherit" }) : spawnSync("bun", [join(root, "packages/cli/src/cli.ts"), "media-use", "resolve", ...process.argv.slice(2)], { stdio: "inherit" }); process.exit(result.status ?? 1); +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +const root = join(fileURLToPath(new URL("../../../", import.meta.url))); +const dist = join(root, "packages/cli/dist/cli.js"); +const result = existsSync(dist) + ? spawnSync(process.execPath, [dist, "media-use", "resolve", ...process.argv.slice(2)], { + stdio: "inherit", + }) + : spawnSync( + "bun", + [join(root, "packages/cli/src/cli.ts"), "media-use", "resolve", ...process.argv.slice(2)], + { stdio: "inherit" }, + ); +process.exit(result.status ?? 1); From 9c6cc8d728b7926156a0602dece0cdeba10d43f7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:02:02 -0400 Subject: [PATCH 16/21] chore(skills): regenerate media-use manifest --- skills-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills-manifest.json b/skills-manifest.json index ddf3fb6100..d818c23150 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,7 +54,7 @@ "files": 1 }, "media-use": { - "hash": "8d0abb1e25edcaf3", + "hash": "32f3faf52fd6904d", "files": 79 }, "motion-graphics": { From a03fd450bf778949f5005f3abb4871360051615f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:06:05 -0400 Subject: [PATCH 17/21] fix(cli): format bundled media provider --- .../src/media-use/lib/bundled-sfx-provider.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs b/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs index 7db266df6a..e7903c3ef6 100644 --- a/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs +++ b/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs @@ -1,10 +1,23 @@ import { existsSync, readFileSync } from "node:fs"; import { extname, join } from "node:path"; -const LIB_DIR = process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR || +const LIB_DIR = + process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR || [ join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"), - join(import.meta.dirname, "..", "..", "..", "..", "..", "skills", "media-use", "audio", "assets", "sfx"), + join( + import.meta.dirname, + "..", + "..", + "..", + "..", + "..", + "skills", + "media-use", + "audio", + "assets", + "sfx", + ), ].find((candidate) => existsSync(candidate)) || join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"); From 532da4c90ec492b93080c5164464529d273bf21c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:13:03 -0400 Subject: [PATCH 18/21] fix(ci): run relocated media-use tests with node --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d746305ba4..499ef7d52c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -396,14 +396,14 @@ jobs: # would defeat the whole point of this job). run: | set -euo pipefail - mapfile -t SKILLS_TESTS < <(find skills -type f -name "*.test.mjs" | sort) + mapfile -t SKILLS_TESTS < <(find skills packages/cli/src/media-use -type f -name "*.test.mjs" | sort) if [ "${#SKILLS_TESTS[@]}" -eq 0 ]; then echo "::error::No skills/**/*.test.mjs files found. Did the layout change?" exit 1 fi printf 'Running %d skills test file(s):\n' "${#SKILLS_TESTS[@]}" printf ' * %s\n' "${SKILLS_TESTS[@]}" - bun run test:skills + node --test "${SKILLS_TESTS[@]}" test-skill-bootstrap-windows: name: "Test: skill bootstrap (Windows, Node 24)" From a8aca381066fe5ea2311c29c9e6abd83c87d2cf8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:24:02 -0400 Subject: [PATCH 19/21] fix(ci): preserve fallow script exemptions --- .fallowrc.jsonc | 1 + 1 file changed, 1 insertion(+) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 6ef827b56c..9db56ae9aa 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -71,6 +71,7 @@ "packages/player/tests/**", "packages/engine/tests/**", "skills/**/test-corpus/**", + "skills/**/scripts/**", "packages/cli/src/media-use/**", // Agent-invoked motion-graphics tools co-located with their docs (run via // `node ` per grounding/PROTOCOL.md / categories/maps/module.md From 67f47ebba3131aa63f680f4e7564d995669864df Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:32:22 -0400 Subject: [PATCH 20/21] fix(ci): scope media-use fallow exemption --- .fallowrc.jsonc | 4 +++- .github/workflows/ci.yml | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 9db56ae9aa..342c116a82 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -72,7 +72,9 @@ "packages/engine/tests/**", "skills/**/test-corpus/**", "skills/**/scripts/**", - "packages/cli/src/media-use/**", + // The relocated media-use .mjs files are agent-invoked scripts outside the + // import graph; keep the exemption scoped to that shipped script type. + "packages/cli/src/media-use/**/*.mjs", // Agent-invoked motion-graphics tools co-located with their docs (run via // `node ` per grounding/PROTOCOL.md / categories/maps/module.md // prose), not import-graph reachable. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 499ef7d52c..fd3250dba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -390,6 +390,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - name: Discover and run skills tests # We expand the test list via bash so the job fails loudly when the # matcher comes back empty, rather than silently no-op'ing (which @@ -403,7 +404,7 @@ jobs: fi printf 'Running %d skills test file(s):\n' "${#SKILLS_TESTS[@]}" printf ' * %s\n' "${SKILLS_TESTS[@]}" - node --test "${SKILLS_TESTS[@]}" + bun run test:skills test-skill-bootstrap-windows: name: "Test: skill bootstrap (Windows, Node 24)" From f709ed0669eefd61cd67eadf18b24841000e17e2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:38:04 -0400 Subject: [PATCH 21/21] refactor(cli): trim media-use essay comments --- packages/cli/src/media-use/lib/duck.mjs | 11 +--- packages/cli/src/media-use/lib/telemetry.mjs | 35 +---------- packages/cli/src/media-use/resolve.mjs | 61 ++++---------------- 3 files changed, 15 insertions(+), 92 deletions(-) diff --git a/packages/cli/src/media-use/lib/duck.mjs b/packages/cli/src/media-use/lib/duck.mjs index 6437f7ff06..9bbe7afda0 100644 --- a/packages/cli/src/media-use/lib/duck.mjs +++ b/packages/cli/src/media-use/lib/duck.mjs @@ -1,15 +1,6 @@ import { wordListsFromMediaMeta } from "./words.mjs"; -/** - * Speech spans from word timestamps. - * - * audio_meta.json word times are relative to EACH LINE'S OWN FILE, not to the - * composition. Without placement info, multiple lines would overlap at t=0 and - * merge into one bogus span. Placement options: - * offsets: { [voiceId]: startSeconds } explicit composition placement - * sequential: stack lines back to back (plus `gap` seconds between lines) - * A single word list (bare transcript) needs neither. - */ +// audio_meta.json word times are relative to each line's own file. export function speechSpans(meta, { mergeGap = 0.6, offsets, sequential = false, gap = 0 } = {}) { const merge = Number(mergeGap); const lists = wordListsFromMediaMeta(meta); diff --git a/packages/cli/src/media-use/lib/telemetry.mjs b/packages/cli/src/media-use/lib/telemetry.mjs index 43603d7629..cd2911f529 100644 --- a/packages/cli/src/media-use/lib/telemetry.mjs +++ b/packages/cli/src/media-use/lib/telemetry.mjs @@ -1,15 +1,5 @@ -// Opt-out usage tracking for media-use, sharing the hyperframes CLI/studio -// identity (packages/cli/src/telemetry): the same install id from -// ~/.hyperframes/config.json, plus a $identify to the HeyGen account on sign-in, -// so a person is one PostHog profile across surfaces — not a fresh id per tool. -// Not fully anonymous by design (it must dedupe): pseudonymous before sign-in, -// account-linked after. Event PROPERTIES stay coarse — media TYPE, resolution -// SOURCE, winning PROVIDER — never the intent text, file names, or paths. -// -// Same public PostHog project key as the CLI (a write-only ingestion key, safe -// to ship), same opt-outs (DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY / CI / dev), -// and $ip:null so no IP is recorded. Fire-and-forget: telemetry never blocks a -// resolve and never throws into it. +// Usage tracking shares the CLI and Studio identity. Properties stay coarse and +// never carry intent text, file names, or paths. import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -22,10 +12,6 @@ const TIMEOUT_MS = 1500; let identifiedAccount = false; let warnedNonDefaultHost = false; -// Same CI/test signals the test suite itself sets (resolve.test.mjs's U7 test -// sets NODE_ENV=test and clears CI to prove the interception seam works) — -// reused here, not a new heuristic, so that deliberate test usage never -// triggers the warning below. function isTestOrCiContext() { return ( process.env.CI === "true" || @@ -35,16 +21,6 @@ function isTestOrCiContext() { ); } -// Test-only interception seam: a real HTTP destination a test can point at, -// so a spawned-child test (resolve.test.mjs) can prove track() never reaches -// production rather than trusting DO_NOT_TRACK alone (a future call site or -// test could forget to set that env var). Falls back to the real production -// host whenever unset — production behavior is unchanged. -// -// Safety net: if this ever leaks into a real user's shell, track() would -// silently redirect to a likely-dead host and postBatch()'s catch{} would -// swallow the failure with zero signal. Surface one stderr warning outside -// test/CI contexts so a real user gets some indication instead of silence. function posthogHost() { const override = process.env.MEDIA_USE_TELEMETRY_HOST; if (override && !warnedNonDefaultHost && !isTestOrCiContext()) { @@ -67,12 +43,7 @@ export function optedOut() { ); } -// CLI + studio share one install identity in ~/.hyperframes/config.json -// (packages/cli/src/telemetry/config.ts — same path, same `anonymousId` / -// `telemetryNoticeShown` fields). Read and write that same file so media-use is -// the same PostHog person and shows the notice once per person, not per tool. -// Computed per call (not a module const) so it honors HOME at runtime — tests -// sandbox HOME, and os.homedir() re-reads it each call. +// Read and write the shared config so media-use keeps one identity per install. function sharedConfigPath() { return join(homedir(), ".hyperframes", "config.json"); } diff --git a/packages/cli/src/media-use/resolve.mjs b/packages/cli/src/media-use/resolve.mjs index 3e6c36f021..b7874ae892 100644 --- a/packages/cli/src/media-use/resolve.mjs +++ b/packages/cli/src/media-use/resolve.mjs @@ -191,10 +191,7 @@ if (args.stats) { process.exit(0); } -// Reuse: import a specific global-cache asset (by content sha/prefix, taken -// from --candidates) into this project. `!== undefined` so an empty --reuse "" -// still routes here (and gets a clear empty-sha error) instead of falling -// through to the misleading "--type and --intent are required". +// Reuse a global-cache asset selected by content sha or prefix. if (args.reuse !== undefined) { await reuseGlobal(args.reuse); process.exit(0); @@ -226,10 +223,7 @@ if (args.analyze) { process.exit(0); } -// Recipes: folder-based named bundles resolved by entity name — no providers, -// no content hashing (an evolving versioned bundle, not an immutable file). -// Delegates to lib/recipe-store.mjs the way grade/lut delegate to resolveColor; -// freeze/list live in scripts/recipe.mjs. +// Resolve named recipe bundles without a provider. if (type === "recipe") { const { useRecipe } = await import("./lib/recipe-store.mjs"); const name = (entity || intent || "").trim(); @@ -315,10 +309,7 @@ function localizeImportedRecord(record, localPath) { } async function run() { - // A forced --provider means "(re)generate with THIS provider" — it bypasses - // every reuse rung (project/entity/assets/global cache) so it can't silently - // hand back an asset from a different provider. The floor only applies to the - // default (unforced) cascade. + // A forced provider bypasses reuse and pins the provider cascade. const forced = !!args.provider; // 1. project manifest — exact-prompt match @@ -401,11 +392,7 @@ async function run() { voiceId: args["voice-id"], }; - // Adherence nudge (offline, no auto-reuse): the exact-cache floor missed and - // we're about to fetch/generate. If lexically-similar assets already exist, - // point the agent at --candidates so it can reuse instead of fetching. Only a - // fuzzy match ever reaches the agent this way — never auto-applied. Goes to - // stderr so it reaches --json callers without corrupting stdout. Best-effort. + // Suggest candidates before a new fetch. try { const { similar } = listCandidates({ projectDir, type, intent, cap: CANDIDATE_CAP }); if (similar > 0) { @@ -441,12 +428,7 @@ async function run() { } } - // A search/generate attempt against heygen may have fired a fire-and-forget - // media_use_provider_error track (reportHeygenFailure — heygen-search.mjs / - // voice-provider.mjs are sync call sites several layers below here and can't - // await it themselves). Join it now, before any process.exit() below can - // race it: both it and the miss/success telemetry below are separate, - // non-keepalive HTTP connections with no ordering guarantee otherwise. + // Flush provider failure telemetry before the process can exit. await flushHeygenFailureTracking(); if (!searchResult) { @@ -524,10 +506,7 @@ async function run() { provenance: { provider: searchResult.metadata?.provider || "unknown", prompt: intent, - // heygenAuthMethodFor spreads first so an explicit authMethod on a - // future provider's own metadata.provenance can still override it below - // -- safe today (no provider sets authMethod itself), but keep this - // ordering if that ever changes. + // Keep auth method sparse for non-HeyGen providers. ...heygenAuthMethodFor(searchResult.metadata?.provider), ...searchResult.metadata?.provenance, }, @@ -545,11 +524,7 @@ async function run() { appendRecord(projectDir, record); regenerateIndex(projectDir); - // Auto-promote: surface every fetched asset in the global cache so it's - // reusable across all hyperframes projects (B3). Non-fatal; dedup by sha. - // ponytail: promotes search/generate/ingest assets (the ones media-use - // fetched), not bulk --adopt imports — add those if cross-project reuse of - // pre-existing project assets is wanted. + // Promote fetched assets into the global cache for reuse. try { cachePut(fullPath, record); } catch { @@ -933,12 +908,7 @@ function latestHeygenStable() { } function heygenAuthCheck() { - // `heygen auth status` already emits JSON by default (only `--human` opts out - // to a table) — there is no `--json`/`--output` flag; passing one errors with - // "unknown flag". emailFromAuthStatus parses that default JSON. - // NOTE: JSON-by-default is a v0.3.0 behavior — this probe assumes it, which - // HEYGEN_MIN_VERSION >= 0.3.0 (+ the version gate above) guarantees. If that - // floor is ever lowered, auth detection on an older CLI would silently break. + // heygen auth status emits JSON by default; parse that output directly. const authProbe = runCommand("heygen", ["auth", "status"]); // spawnSync sets .error/.signal on a timeout or spawn failure (status then // null). A stalled auth endpoint (transient network/DNS) must not be reported @@ -1009,10 +979,7 @@ function runDoctor() { fix: versionOk ? (behind ? HEYGEN_UPDATE_COMMAND : "") : HEYGEN_UPDATE_COMMAND, }); - // Below the OAuth-capable floor the auth probe fails for the SAME root cause - // (an old CLI can't OAuth and doesn't emit JSON auth status), which would - // read as a confusing second "not authenticated" error. Skip it — one root - // cause, one fix. + // Older CLI versions cannot provide the auth status used here. checks.push( versionOk ? heygenAuthCheck() @@ -1205,17 +1172,11 @@ async function result(record, source) { // parametric), or "params" (offline). Surfaces silent CDN→params downgrades // in prod, which --doctor can't (it only answers "reachable now?"). via: record.provenance?.via, - // Free (OAuth) vs. paid (API-key) heygen path — sparse: absent for every - // non-heygen provider (see heygenAuthMethodFor at construction time). On a - // cache/reuse hit this reports how the asset was ORIGINALLY fetched, not - // this resolve's own credential state — intentional: it's a conversion + // OAuth vs. API-key HeyGen paths are sparse for non-HeyGen providers. // signal about the fetch that actually consumed a heygen credit, not // about the (free, no-credential) act of copying a cached file. auth_method: record.provenance?.authMethod, - // "local" / "network_free" / "network_paid", straight from the registry's own - // A/N/P declaration — so a dashboard can separate free lookups from calls that - // spend credit without hardcoding provider names. Sparse: absent when the - // record carries no provider (cache and reuse hits) or the name is unknown. + // Provider tiers stay sparse and follow the registry's A/N/P declaration. provider_tier: providerTierFor(record.provenance?.provider), local_only: !!args["local-only"], provider_override: !!args.provider,