From 0931ae1f4651738e445b741d7f4ce39af6d9267b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:24:06 -0400 Subject: [PATCH 01/49] 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 b275b0c5fd61125dcbe8383c570991debf94b0fb Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:32:02 -0400 Subject: [PATCH 02/49] 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 56dd3b032cf7d139f61042bd03a135250d9c9eb6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:34:21 -0400 Subject: [PATCH 03/49] 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 4f160c2d1908c8cfa6689766e4f6bbb25061a24e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:37:15 -0400 Subject: [PATCH 04/49] 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 626fe94bd85ab8440fdde12b139207a118dfe1b5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:39:29 -0400 Subject: [PATCH 05/49] 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 be436bce7fcea4bd7a6249d4aabb268eb31757e2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:43:04 -0400 Subject: [PATCH 06/49] 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 1f64d7ba6d6419390e074f18524d4fad522f44f5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:45:13 -0400 Subject: [PATCH 07/49] 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 cf34631f25babd0fbc657feebed711bb062163d8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:55:50 -0400 Subject: [PATCH 08/49] 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 0f8b1abcd82acd3a559589a445b4deed914f3043 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:00:08 -0400 Subject: [PATCH 09/49] 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 a23f9184107ab3c38e075fa1a26617be9fe941a5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:04:07 -0400 Subject: [PATCH 10/49] 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 88633b17a75738b5f3471a90a05aa1276d0da9aa Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:08:39 -0400 Subject: [PATCH 11/49] 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 f7fb7c8258971de95b4602910bf0478102716c62 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:12:17 -0400 Subject: [PATCH 12/49] 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 99fbfa40f6dbf345a19ab64549805adaa4e5ea75 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:20:26 -0400 Subject: [PATCH 13/49] 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 191e16a7e406805f2b79846d5aaa6714b2fc6718 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:43:36 -0400 Subject: [PATCH 14/49] 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 41d11d8bd398f195603be7fa659115ece89a4438 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:54:31 -0400 Subject: [PATCH 15/49] 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 cfd2a62f277fb27d3ceb5a1fc080e34882cbb492 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:02:02 -0400 Subject: [PATCH 16/49] 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 13d211b000a7f52964141bd22c5c83c40ee68d6c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:06:05 -0400 Subject: [PATCH 17/49] 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 7b2112b046e19cb4dede8b331293831b6bb8dd30 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:13:03 -0400 Subject: [PATCH 18/49] 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 e0503ecdc63eee7bba995c23f93783cfcd56c544 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:24:02 -0400 Subject: [PATCH 19/49] 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 112ea64e2e1731a3860b6c26466695312e2435b1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:32:22 -0400 Subject: [PATCH 20/49] 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 a5f1f88546a4979492b3f53d3c7fb3c232cc6154 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:38:04 -0400 Subject: [PATCH 21/49] 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, From fde415553dc75ee97c4274514ddfe429bccd3dfa Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 15:05:55 -0400 Subject: [PATCH 22/49] feat(cli): index media-use assets for catalog search --- .github/workflows/ci.yml | 1 + packages/cli/src/commands/catalog.test.ts | 27 +++++- packages/cli/src/commands/catalog.ts | 79 +++++++++++++++ packages/cli/src/registry/localSemantic.ts | 106 +++++++++++++++++++-- scripts/catalog/build-local-vectors.ts | 86 +++++++++++++---- scripts/catalog/check-artifact-coverage.ts | 33 ++++++- skills/hyperframes-cli/SKILL.md | 4 +- 7 files changed, 306 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd3250dba4..c6387faaaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,7 @@ jobs: catalog_index: - "registry/registry.json" - "registry/catalog-artifact/**" + - "skills/media-use/audio/assets/sfx/**" - "scripts/catalog/check-artifact-coverage.ts" docs_catalog: - "docs/**" diff --git a/packages/cli/src/commands/catalog.test.ts b/packages/cli/src/commands/catalog.test.ts index 1b11016554..a23df786da 100644 --- a/packages/cli/src/commands/catalog.test.ts +++ b/packages/cli/src/commands/catalog.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { countUnindexed, pickByName, searchMissCommand } from "./catalog.js"; +import { countUnindexed, pickByName, rankMediaRows, searchMissCommand } from "./catalog.js"; /** The whole registry, which is what "in this registry" has to be measured against. */ const registryNames = new Set(["fade-through", "whip-pan", "count-up"]); @@ -63,6 +63,31 @@ describe("countUnindexed", () => { }); }); +describe("rankMediaRows", () => { + it("ranks the click SFX first for a click query", () => { + const rows = [ + { + id: "whoosh", + kind: "sfx", + title: "whoosh", + description: "Fast scene transition accent", + tags: ["sfx"], + file: "skills/media-use/audio/assets/sfx/whoosh.mp3", + }, + { + id: "click", + kind: "sfx", + title: "click", + description: "Crisp UI click for a button press or selection", + tags: ["sfx"], + file: "skills/media-use/audio/assets/sfx/click.mp3", + }, + ]; + + expect(rankMediaRows("click", rows).map((row) => row.id)).toEqual(["click"]); + }); +}); + // ── The command envelope ──────────────────────────────────────────────────── // The JSON envelope is the surface an agent reads, so under-coverage and the // score are pinned where they are actually published rather than only at the diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index bfdf9b9fc0..4b66c450ce 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -30,12 +30,73 @@ import { import { ensureLocalRuntime } from "../registry/localEmbedder.js"; import { cachedLocalVectorRevision, + fetchMediaVectors, fetchLocalVectors, hasLocalVectors, localSemanticRanking, localVectorNames, + mediaSemanticRanking, + mediaVectorRows, } from "../registry/localSemantic.js"; +export interface MediaCatalogRow { + id: string; + kind: string; + title: string; + description: string; + tags: string[]; + file: string; + duration?: number; + dimensions?: { width: number; height: number }; +} + +export function rankMediaRows(query: string, rows: MediaCatalogRow[]): MediaCatalogRow[] { + return searchByWords(query, rows, (row) => ({ + strong: `${row.id} ${row.title}`, + weak: `${row.description} ${row.tags.join(" ")} ${row.kind}`, + })); +} + +async function runMediaCatalog( + query: string, + registry: string, +): Promise { + const fetched = await fetchMediaVectors(registry); + const rows = mediaVectorRows() as MediaCatalogRow[]; + const warnings: string[] = []; + if (!fetched && rows.length === 0) { + warnings.push("media search unavailable: the media index could not be fetched"); + } + + let matching = rankMediaRows(query, rows); + let tier: "words" | "on-device" = "words"; + let tierDetail = "local word match"; + if (matching.length === 0 && localModelStatus().status === "ready") { + const ranking = await mediaSemanticRanking(query); + if (ranking) { + matching = ranking.map(({ row }) => row as MediaCatalogRow); + tier = "on-device"; + tierDetail = "on-device meaning search"; + } + } + + console.log( + JSON.stringify( + { + query, + tier, + tier_detail: tierDetail, + shown: matching.length, + total: rows.length, + ...(warnings.length ? { warnings } : {}), + results: matching, + }, + null, + 2, + ), + ); +} + /** * Get the offline tier ready, and report every reason it could not be. * @@ -155,6 +216,10 @@ export default defineCommand({ description: "Search by meaning when the on-device model is on, otherwise by name, title, description and tags", }, + media: { + type: "boolean", + description: "Search the media-use asset corpus instead of installable registry items", + }, yes: { type: "boolean", alias: "y", @@ -177,6 +242,20 @@ export default defineCommand({ const dir = resolve(process.cwd()); const config = loadProjectConfig(dir) ?? DEFAULT_PROJECT_CONFIG; + if (args.media === true) { + if (!json) { + console.error("--media requires --json because media rows are not installable registry items."); + finishCommand(1); + } + const query = typeof args.query === "string" ? args.query.trim() : ""; + if (!query) { + console.error("--media requires --query."); + finishCommand(1); + } + await runMediaCatalog(query, config.registry); + return; + } + let typeFilter: ItemType | undefined; if (args.type === "block") typeFilter = "hyperframes:block"; else if (args.type === "component") typeFilter = "hyperframes:component"; diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index 42a444a720..4618884b17 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -29,11 +29,24 @@ interface LocalVectorMetadata { names: string[]; dimensions: number; revision?: string; + rows?: MediaVectorRow[]; +} + +export interface MediaVectorRow { + id: string; + kind: string; + title: string; + description: string; + tags: string[]; + file: string; + duration?: number; + dimensions?: { width: number; height: number }; } export interface FetchLocalVectorOptions { directory?: string; expectedRevision?: string; + artifactBasename?: "local-vectors" | "media-vectors"; } const CATALOG_ARTIFACT_TIMEOUT_MS = 30_000; @@ -63,9 +76,12 @@ function localVectorDirectory(): string { * contract. A pair that fails it is a truncated download or a different * model, never something worth caching. */ -function vectorPairAgrees(fetched: Array<[string, Buffer]>): boolean { - const meta = fetched.find(([file]) => file === "local-vectors.json")?.[1]; - const bin = fetched.find(([file]) => file === "local-vectors.bin")?.[1]; +function vectorPairAgrees( + fetched: Array<[string, Buffer]>, + artifactBasename: "local-vectors" | "media-vectors", +): boolean { + const meta = fetched.find(([file]) => file === `${artifactBasename}.json`)?.[1]; + const bin = fetched.find(([file]) => file === `${artifactBasename}.bin`)?.[1]; if (!meta || !bin) return false; try { const parsed = JSON.parse(meta.toString("utf-8")) as { @@ -83,9 +99,10 @@ function vectorPairAgrees(fetched: Array<[string, Buffer]>): boolean { function vectorRevisionAgrees( fetched: Array<[string, Buffer]>, expectedRevision?: string, + artifactBasename: "local-vectors" | "media-vectors" = "local-vectors", ): boolean { if (expectedRevision === undefined) return true; - const meta = fetched.find(([file]) => file === "local-vectors.json")?.[1]; + const meta = fetched.find(([file]) => file === `${artifactBasename}.json`)?.[1]; if (!meta) return false; try { const parsed = JSON.parse(meta.toString("utf-8")) as { revision?: unknown }; @@ -100,6 +117,7 @@ export async function fetchLocalVectors( options: FetchLocalVectorOptions = {}, ): Promise { const directory = options.directory ?? localVectorDirectory(); + const artifactBasename = options.artifactBasename ?? "local-vectors"; const base = registryBaseUrl.replace(/\/+$/, ""); try { mkdirSync(directory, { recursive: true, mode: 0o700 }); @@ -108,7 +126,7 @@ export async function fetchLocalVectors( // must leave the previous pair intact rather than pairing a new name list // with an old matrix, which loads as an error instead of as stale data. const fetched: Array<[string, Buffer]> = []; - for (const file of ["local-vectors.json", "local-vectors.bin"] as const) { + for (const file of [`${artifactBasename}.json`, `${artifactBasename}.bin`] as const) { const response = await fetch(`${base}/catalog-artifact/${file}`, { signal: AbortSignal.timeout(CATALOG_ARTIFACT_TIMEOUT_MS), }); @@ -119,7 +137,10 @@ export async function fetchLocalVectors( // discovering the mismatch at load time leaves a cache that fails every // subsequent search until someone deletes it by hand, and it is the only // point where a truncated or wrong-model response can still be refused. - if (!vectorPairAgrees(fetched) || !vectorRevisionAgrees(fetched, options.expectedRevision)) { + if ( + !vectorPairAgrees(fetched, artifactBasename) || + !vectorRevisionAgrees(fetched, options.expectedRevision, artifactBasename) + ) { return false; } // 0o600: the cache is this user's, and the directory may be world-writable @@ -127,12 +148,21 @@ export async function fetchLocalVectors( for (const [file, bytes] of fetched) { writeFileSync(join(directory, file), bytes, { mode: 0o600 }); } - return hasLocalVectors(directory); + return artifactBasename === "media-vectors" + ? hasMediaVectors(directory) + : hasLocalVectors(directory); } catch { return false; } } +export function fetchMediaVectors( + registryBaseUrl: string, + options: Omit = {}, +): Promise { + return fetchLocalVectors(registryBaseUrl, { ...options, artifactBasename: "media-vectors" }); +} + export function hasLocalVectors(directory = localVectorDirectory()): boolean { return ( existsSync(join(directory, "local-vectors.bin")) && @@ -140,6 +170,25 @@ export function hasLocalVectors(directory = localVectorDirectory()): boolean { ); } +export function hasMediaVectors(directory = localVectorDirectory()): boolean { + return ( + existsSync(join(directory, "media-vectors.bin")) && + existsSync(join(directory, "media-vectors.json")) + ); +} + +export function mediaVectorRows(directory = localVectorDirectory()): MediaVectorRow[] { + if (!hasMediaVectors(directory)) return []; + try { + const metadata = JSON.parse( + readFileSync(join(directory, "media-vectors.json"), "utf-8"), + ) as LocalVectorMetadata; + return metadata.rows ?? []; + } catch { + return []; + } +} + function loadLocalVectors(directory = localVectorDirectory()): LocalVectorSet { const meta = JSON.parse( readFileSync(join(directory, "local-vectors.json"), "utf-8"), @@ -161,6 +210,29 @@ function loadLocalVectors(directory = localVectorDirectory()): LocalVectorSet { return { names: meta.names, dimensions: meta.dimensions, vectors }; } +function loadMediaVectors(directory = localVectorDirectory()): LocalVectorSet & { + rows: MediaVectorRow[]; +} { + const meta = JSON.parse( + readFileSync(join(directory, "media-vectors.json"), "utf-8"), + ) as LocalVectorMetadata; + const buffer = readFileSync(join(directory, "media-vectors.bin")); + const vectors = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4); + const rows = meta.rows ?? []; + if (rows.length !== meta.names.length) { + throw new Error(`media vectors hold ${rows.length} rows, expected ${meta.names.length}`); + } + if (vectors.length !== rows.length * meta.dimensions) { + throw new Error(`media vectors hold ${vectors.length} floats, expected ${rows.length * meta.dimensions}`); + } + if (meta.dimensions !== LOCAL_MODEL_DIMENSIONS) { + throw new Error( + `media vectors are ${meta.dimensions}-dimension, model produces ${LOCAL_MODEL_DIMENSIONS}`, + ); + } + return { names: meta.names, dimensions: meta.dimensions, vectors, rows }; +} + /** * The names the on-device index holds, without loading the vector matrix. * @@ -227,3 +299,23 @@ export async function localSemanticRanking( scored.sort((a, b) => b.score - a.score || b.name.localeCompare(a.name)); return scored; } + +export async function mediaSemanticRanking( + query: string, + directory = localVectorDirectory(), +): Promise | null> { + if (!isLocalModelReady() || !hasMediaVectors(directory)) return null; + const set = loadMediaVectors(directory); + const embedder = await loadLocalEmbedder(); + const [queryVector] = await embedder.embed([query], { isQuery: true }); + if (!queryVector) return null; + return set.rows + .map((row, index) => ({ + row, + score: cosine( + queryVector, + Array.from(set.vectors.subarray(index * set.dimensions, (index + 1) * set.dimensions)), + ), + })) + .sort((a, b) => b.score - a.score || b.row.id.localeCompare(a.row.id)); +} diff --git a/scripts/catalog/build-local-vectors.ts b/scripts/catalog/build-local-vectors.ts index a13104a966..394ec8e52c 100644 --- a/scripts/catalog/build-local-vectors.ts +++ b/scripts/catalog/build-local-vectors.ts @@ -32,6 +32,17 @@ import { localVectorRevision, } from "./catalog-artifact.js"; +export interface MediaVectorRow { + id: string; + kind: string; + title: string; + description: string; + tags: string[]; + file: string; + duration?: number; + dimensions?: { width: number; height: number }; +} + /** Distinct from 1 so the pre-commit hook can tell "cannot" from "failed". */ const EXIT_NO_MODEL = 3; @@ -40,6 +51,36 @@ function arg(name: string): string | undefined { return index === -1 ? undefined : process.argv[index + 1]; } +function mediaRows(manifestPath: string): MediaVectorRow[] { + const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as + | MediaVectorRow[] + | Record & { tags?: string[] }>; + const bundledSfx = manifestPath.endsWith("skills/media-use/audio/assets/sfx/manifest.json"); + const rootRelativeFile = (file: string): string => + bundledSfx && !file.startsWith("skills/") + ? `skills/media-use/audio/assets/sfx/${file}` + : file; + const rows = Array.isArray(parsed) + ? parsed.map((row) => ({ ...row, file: rootRelativeFile(row.file) })) + : Object.entries(parsed).map(([id, entry]) => ({ + id, + kind: "sfx", + title: id, + description: entry.description, + tags: entry.tags ?? ["sfx"], + file: rootRelativeFile(entry.file), + ...(entry.duration === undefined ? {} : { duration: entry.duration }), + ...(entry.dimensions === undefined ? {} : { dimensions: entry.dimensions }), + })); + if (rows.length === 0) throw new Error(`media manifest contains no rows: ${manifestPath}`); + for (const row of rows) { + if (!row.id || !row.file || !row.title || !row.description) { + throw new Error(`media manifest row is missing id, title, description, or file`); + } + } + return [...rows].sort((left, right) => left.id.localeCompare(right.id)); +} + /** * Embed in batches, in `names` order. * @@ -81,19 +122,24 @@ function packVectors(names: string[], vectors: number[][]): Float32Array { async function main(): Promise { const dir = arg("artifact") ?? "registry/catalog-artifact"; const registryDir = arg("registry") ?? "registry"; + const manifestPath = arg("manifest"); + const basename = arg("output") ?? (manifestPath ? "media-vectors" : "local-vectors"); // Read the corpus straight from the registry rather than from a catalog.json // built by the hosted-tier script. That file is not in the repo, so the // documented regeneration command used to fail on a missing path, which is // the whole reason the index was allowed to drift. - const catalogMap = catalogFromRegistry( - registryDir, - (path) => readFileSync(path, "utf-8"), - (path) => - readdirSync(path, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name), - ); + const rows = manifestPath ? mediaRows(manifestPath) : undefined; + const catalogMap = rows + ? new Map(rows.map((row) => [row.id, `${row.title}\n${row.description}\n${row.tags.join(" ")}\n${row.kind}`])) + : catalogFromRegistry( + registryDir, + (path) => readFileSync(path, "utf-8"), + (path) => + readdirSync(path, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name), + ); const catalog = Object.fromEntries(catalogMap); const names = Object.keys(catalog).sort(); if (names.length === 0) throw new Error(`no registry items found under ${registryDir}`); @@ -122,25 +168,27 @@ async function main(): Promise { const vectors = await embedInBatches(names, catalog, embedder); const flat = packVectors(names, vectors); - writeFileSync(join(dir, "local-vectors.bin"), Buffer.from(flat.buffer)); - writeFileSync( - join(dir, "local-vectors.json"), - `${JSON.stringify({ model: LOCAL_MODEL_ID, modelRevision: LOCAL_MODEL_REVISION, dimensions: LOCAL_MODEL_DIMENSIONS, revision, names }, null, 2)}\n`, - ); - const registryPath = join(registryDir, "registry.json"); - const registry = JSON.parse(readFileSync(registryPath, "utf-8")) as RegistryManifest; + writeFileSync(join(dir, `${basename}.bin`), Buffer.from(flat.buffer)); writeFileSync( - registryPath, - `${JSON.stringify({ ...registry, catalogArtifact: { revision } }, null, 2)}\n`, + join(dir, `${basename}.json`), + `${JSON.stringify({ model: LOCAL_MODEL_ID, modelRevision: LOCAL_MODEL_REVISION, dimensions: LOCAL_MODEL_DIMENSIONS, revision, names, ...(rows ? { rows } : {}) }, null, 2)}\n`, ); + if (!rows) { + const registryPath = join(registryDir, "registry.json"); + const registry = JSON.parse(readFileSync(registryPath, "utf-8")) as RegistryManifest; + writeFileSync( + registryPath, + `${JSON.stringify({ ...registry, catalogArtifact: { revision } }, null, 2)}\n`, + ); + } const megabytes = (flat.byteLength / 1024 / 1024).toFixed(2); - console.log(`moves ${names.length}`); + console.log(`${rows ? "media" : "moves"} ${names.length}`); console.log(`model ${LOCAL_MODEL_ID}`); console.log(`dimensions ${LOCAL_MODEL_DIMENSIONS}`); console.log(`revision ${revision}`); console.log(`payload ${megabytes} MB`); - console.log(`written ${dir}`); + console.log(`written ${dir}/${basename}.{json,bin}`); } await main(); diff --git a/scripts/catalog/check-artifact-coverage.ts b/scripts/catalog/check-artifact-coverage.ts index 6188d15317..227c338765 100644 --- a/scripts/catalog/check-artifact-coverage.ts +++ b/scripts/catalog/check-artifact-coverage.ts @@ -23,9 +23,12 @@ import { catalogFromRegistry, localVectorRevision } from "./catalog-artifact.js" type RegistryItem = { name: string; type?: string }; type Registry = { items: RegistryItem[]; catalogArtifact?: { revision?: string } }; type Artifact = { model?: string; dimensions?: number; revision?: string; names?: string[] }; +type MediaArtifact = { rows?: Array<{ id?: string; file?: string }> }; const REGISTRY = "registry/registry.json"; const ARTIFACT = "registry/catalog-artifact/local-vectors.json"; +const MEDIA_MANIFEST = "skills/media-use/audio/assets/sfx/manifest.json"; +const MEDIA_ARTIFACT = "registry/catalog-artifact/media-vectors.json"; function read(path: string): T { try { @@ -67,6 +70,23 @@ const expectedRevision = localVectorRevision( ); const artifactRevisionMatches = artifact.revision === expectedRevision; const registryRevisionMatches = registry.catalogArtifact?.revision === expectedRevision; +const mediaSource = read>(MEDIA_MANIFEST); +const mediaArtifact = read(MEDIA_ARTIFACT); +const mediaRows = new Map( + (mediaArtifact.rows ?? []) + .filter( + (row): row is { id: string; file: string } => + typeof row.id === "string" && typeof row.file === "string", + ) + .map((row) => [row.id, row.file]), +); +const missingMediaRows = Object.entries(mediaSource) + .filter( + ([id, source]) => + mediaRows.get(id) !== `skills/media-use/audio/assets/sfx/${source.file ?? ""}`, + ) + .map(([id]) => id) + .sort(); const show = (names: string[]) => names @@ -76,6 +96,12 @@ const show = (names: string[]) => console.log(`registry: ${registryNames.size} searchable items (blocks + components)`); console.log(`artifact: ${artifactNames.size} vectors (${artifact.model ?? "unknown model"})`); +console.log(`media: ${mediaRows.size} rows for ${Object.keys(mediaSource).length} bundled SFX files`); + +if (missingMediaRows.length > 0) { + console.error(`\n${missingMediaRows.length} bundled SFX file(s) have no matching media row:`); + console.error(show(missingMediaRows)); +} if (dropped.length > 0) { // Not fatal: the CLI filters these before a user ever sees them. @@ -96,7 +122,12 @@ if (!artifactRevisionMatches || !registryRevisionMatches) { console.error(` registry: ${registry.catalogArtifact?.revision ?? "missing"}`); } -if (unindexed.length > 0 || !artifactRevisionMatches || !registryRevisionMatches) { +if ( + unindexed.length > 0 || + !artifactRevisionMatches || + !registryRevisionMatches || + missingMediaRows.length > 0 +) { console.error( "\nMeaning search is stale. Word search still uses the live registry.\n\n" + "If you have the embedding model, regenerate and commit the artifact:\n" + diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index 0e296a27e7..990587d960 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -16,7 +16,7 @@ Run commands as `npx hyperframes ...` unless project instructions provide a wrap ## Development loop 1. **Scaffold:** `npx hyperframes init ` (centered blank). Or capture a site. Pass `--example=` only to start from a named example. -2. **Find the move:** before authoring motion by hand, search for a primitive that already does it: `npx hyperframes catalog --query "reveal a headline one line at a time"`. Ask for the effect you want rather than the mechanism you have in mind. Install with `npx hyperframes add ` (see `/hyperframes-registry`). Author by hand only once nothing fits. +2. **Find the move:** if the request names an asset, sound, image, voice or fast visual edit, resolve it through `/media-use` before proposing a plan. Otherwise, before authoring motion by hand, search for a primitive that already does it: `npx hyperframes catalog --query "reveal a headline one line at a time"`. Ask for the effect you want rather than the mechanism you have in mind. Install with `npx hyperframes add ` (see `/hyperframes-registry`). Author by hand only once nothing fits. 3. **Author:** write the composition using `/hyperframes-core`. To know what is on a project's timeline (tracks, clips, starts, ends, what plays), run `npx hyperframes timeline --json` instead of reading `index.html` and every sub-composition file: nested rows carry absolute main-timeline `absStart`/`absEnd` and their owning `file`, not just their local, per-sub-composition time. Prefer `--json` over the text form; it costs fewer tokens for the same or better correctness. See `references/upgrade-info-misc.md` for one-liners that answer common questions without reading the whole output. 4. **Get fast feedback while editing:** run `npx hyperframes lint` after the first HTML pass and after structural changes. 5. **Run the final gate:** run `npx hyperframes check`; it reruns lint before opening the browser. Do not prepend a redundant standalone lint invocation. Add `--snapshots` for annotated overview frames and finding crops. @@ -36,7 +36,7 @@ Run commands as `npx hyperframes ...` unless project instructions provide a wrap - For fade-in/fade-out, crossfade, track gain, volume automation, ducking, voiceover carve, or FX on placed audio, read `/hyperframes-audio`. Load core alongside it when clip placement or picture timing also changes. -- Use `/media-use` only to source/generate media or preprocess a derived asset. +- A request naming an asset, sound, image, voice or fast visual edit resolves through `/media-use` before a plan is proposed. Copy creator edit markup from `/hyperframes-core` → `references/creator-editing-recipes.md`. ```bash From b0dabe9a36c24295bce89307db3bedcf93c422d7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 15:12:15 -0400 Subject: [PATCH 23/49] feat(catalog): commit media-use search vectors --- registry/catalog-artifact/media-vectors.bin | Bin 0 -> 29184 bytes registry/catalog-artifact/media-vectors.json | 238 +++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 registry/catalog-artifact/media-vectors.bin create mode 100644 registry/catalog-artifact/media-vectors.json diff --git a/registry/catalog-artifact/media-vectors.bin b/registry/catalog-artifact/media-vectors.bin new file mode 100644 index 0000000000000000000000000000000000000000..e2e7c0b82ac862ebd79765431bc910dee0002ad6 GIT binary patch literal 29184 zcmWKX2|HH}5XGbHq(Ul^l%yyn6?aCmw2-2bsE{a0g;p)f8WPzGArgfmh4jBOv`SJc z+Bc?&WlQ4g(KC=_{TB$>bDxgc~HXQft5)3--#ZEpa!REmsG4SSo4E(&3 z6XN;{3(v^F%u@l>YF`7pG;=9lrkL*ZI>FoLWpL(iQml`R z)8N>ca~LpBKONoQ&J0 z?Zu-}r-i=L1XMMwfeStJaO{X)xcb*x;p^fF7Ju*gu*~`aylqDb+B!|=M8zU(kNhGg zME?+ap$>0W=)=~!1Gs5J+pXW55h%%5UQtHSAQoTGtNYWpZ?`W%+_SdQ*5FZ16yC!w_J z3w#{#MqFf@K)Rn)xrMY@xZ(-(w}!H9@EXb z`6CTJnUzwIQ$GC0cW}UEKKz;@3pGjsC~HzEx({xkyY`D<`O7wQ6|Fc<|C~d6FI>hq zkJKs9{T7EC&7{wf&%pSrA)nb~z@FOcMf>L(?65GMF2!2&j0IJ2deS8l9VQ5pQ4Sp8 z`5v-OpIeM7^}yeXO|(7Z9!!Wh1V4h(*vIt*EuAG#7h>nq9fK(J7dKGs;9Zhg_B*L5 zf2c5A+XRmfPiA55V=?`d9anBOrLab8JTh0B>N^L5XR!mHh?-n*I}iwyNW};tTMrBp!EI6$_?OhhSaa zd#e9(ndcYRbKA50n!4%^kPl6Pd6gQh@n9)Vw5o)Ku7km|;tsX;+>N(1uaS1AG`vRxRsD8 zj*sqbDNIPk&B1+fi;NDvlTQP`)yeeYtP9@ywTqJ<_ZMb1jlnD0XECs74f$8da-*{b zL@nCK&a;k?zx4=^y)}o8daGiXj5R%qNr3sOTD))bDY!oS3CJ9A$7B1aL(+N$c7sAZ z`F9RY-Y}m(O?yhCdygW6>rv(wd+tz9p$P`%hf`1Ai`eOKj@6d8Q{hls>Z)|Y{q{Xr zxq3YIx%^OU3vZ*(5AWgTvle8Z(2HAL7ou~+R5n~?!B0Cjv2LL>tC<2SyqCcZ^1bnl z(g;ZYB!~sZ#L~Kkl)S9cJgr8Xd5$}F)+tbr^y9OzGkLmYs@_rDSrY}lhGx?1HW{9~dlYUU zZ(O^fM09kx0|9lV5M%F-6-UN#^i)$yn=l>>tG-jp!7&oqb-N*bggtE7=fZnDH={}K z9h~w0DwMrC0N2zGGT1Gle!p_X#uIwHGgl2?RX?Dgd4WRI7F{0nLL0&|TZPDxuJC!l zGcjS*FVZ!O!V?*axNqDWQQF1;#;Da}{g-^)`fLv6Wn{5z4|&7^gRruD&N>uU@r!_P zO#a>`sGSa$RNt_}10PS4+;wAa^IR{E2$e;LyXVC01#wtE|E(xJBoemHoeaPJE~g2l zhd}dMgJ21F;l&kwbe-e_=abh`T6RAnW{C_do>|UEhkc-xw{&ZMp6#0*_4 zaommo-12@grfnKSO^@X{Q0cj_W5_ic{qK^fcZ*vvj7Zjq`Z?nvEFyoFKH&7e-poMj?UN zvp${L;>Td>@4ht5>JS+DM#K7ri`YJN1mBkW3LpB;777mzgYy>~z;k8?wDj8zKckBv zWV13qwGN;LT><>9W`d-A2$c%&VWC|M^d5E$mc1LmL(6fPa$T>AeMZ#Mb=O05-mU=9+LS+e#E5e& zqH$)>aITyFAMnmB{I)@j&wf#4tMW{I*3pBf7;VKhN~-8@_m$MulZA@0DR|m!KP+-T z2cOiRNhTdwLFTPlc;&}WI+Y{EOMfQPbWKyr7$VOB&wZ&*Efb`IZ_wDv&A>xX<9=5o zK5LdP7N0y!Te7aw%Hu5*=$b_DKg5#qRf(`FcoAO1pf03xaOuB-N+v;L@Zn>X?j?V z!*`o-kMJ|F+sPDNKla4AiN7f4R~CM>PU8&;)fNk?g6TPbp#I0DxWiVP)Ca0?|LIP^ z0TyuEMUhifE{I0PL*bHB8t7jldU-fSc=b*fZVZdz=yy-as8Pnk*K!J)*qDme`dHRKfX9A9xKZmS) z|3lp{ZTjXFf*V{M(bS{BV(_UO7D0NqBzNz5fKk=bGe_*xz%!&DuD7{Os+Sx{wevU> zJ+&SnL>v>u!t%xxGHD=(ibCHate} zv|J1wW{IkFkuvNQL45Z{=sVbfC4wwWyY-L{oh^b#mdAPdvfuFE;8eDqyq!vCYtqAp zO?X_vOFZbWN{#E&ajb?F@4WGs^!opW{Ixwf!YT>IzK>*eFSj%Ac3AKY&N-2I%wA0p>WTkx|1?9MEWi&97$gc&#H~V6DhmI(;x*@tgSebRt|(jG~e+>X_hs zlKx$r1m*eBsN<%Ei%q7WioY7zb_DR>zlk(0KMyq$4Y>CEANb>UToQWDjU(L#S?U?a=q4|ymwT0BKuMH=lYmTnzQ68V2q zv8K#QaB0xs3X|J(w3i%?%-AN*-s6ImhS%wBKLsxHu|hXP8$QvyKXqN!=B=6|*;gYK zq^$hVY=;TkcIM)j@-t+$D4FliJ_KbZ^I6z?pKfGTVD`ZZetTmL9Z%`SO|A)WZPi9F z%)c&v{-A*^f4YRWpKI85v=q9<^(D!Y?bxN!2bFjlkJ-8aSDqWpj}#_jNlYq>rSBz5 zv(#Z?dH^eG&*1$2_dq|x3BBJNu#;m8y1EVF_g1;IZC$x&b3zs44n}~Pi#ML>pG5as z%5YwhG~a2K<2x-AAZTeHENeahGh39f%07mgo^=SV!Ix-|)?2Dy6UR^gHBv>6RLzpo zcwW_DgQ^t|V9veMl)LSp#ebKc)6x6-qVvQ(f^3qqxI}I?xOw)mD7`Wf&*UURr@juA zP96ur{ge6o1_zp08NdPa6GYo0O$>kRFCO3WhPwX6i7`(HQlH952!7WGvnm(j&)zq{ zDkl}LuQ*ADX@`WR%ddmo%1mCh^D%yKSb~u!1BHNRDuVHYG@h4rSoGE`AyeZI!ixWf z;y&w10?JsTrRgomRg&=cTSrbSZZg5luuzbh*9O%e7Xe)I=8)H_{QTcm7~Z`%FFdH1 zC_3(kjTlAhFb2jzwhrLAG*Ri0eO&;j+v7X}~|M zyxjkzq*N8&G+2w1f;HgUfn-eH+(5FMhfsFNCK%Ng4>vBwao6r@d@pwe;x-1rVxzgJ z7ilMYcO0b2&ns}w`)^`lSvuXve7I}>NmQQW25J&@zO?=YDQ`-rmVq7AXP7I8s?8Il zzZKvEYfB!}y#tJ@TX5YDZT9%Mo|H_^)4jZQ80Q*9r?=KgzC2KtoS0!G^zAxEtE(1K z)#BCAw`Gr{&8m=szMEl{+YySejN=VqDKu)PGSD-M;Qb3OJBry zUbN98&t{={(N~fG$&2Cf#uTi1#at~>0XPRlr|=6xaHSIK{fonMD>biIhb)_%Dz-XRn<4RQfNt2UxsszZ+^|@H?BCL{5h#4 zUy;~%E)}aPpOZ7V;+uhqG|BA(rljlesbk@I<Nq*^KaDo1Q z*JYE3NwCX2n@i8h;mdLRc*y!Xt`!#JTbau+WaBr|t)7Bi?<*i-oE5K{v6-y4U z;gdCMxntG@aeJk{<>!Zf6cV!RvhkYSrS9|L%eJ4-2&OI7ydJwuAKCGH_UYN$h$u2TQZgl5|}F-8-O9 zr`6@r!Bc@7?xu44pj{mMVqA^w`dkk3O$RHLaCC65rO>`{@cip0zV!H<*xDwCRqa!_ ze3-`WwUrS}o$6mJhJ+k~{mH970XyCfxJVYW!QS2ovRYh{_gj@MEkd z`fh22DxL|w8lJRfTUT@M9*eOta3a0^X2Z^t`tqXq;}|q=0QD_t7GGZ|!aGW*;P<_MG^!TRvA+&J zFtg#0fesw~q>`TI776VN@94&Nc^;ylj#qRxLR*^|-k3d}1Ao1Q4DC>S@ADJZDvZp^w5K-tS~ef4twry0wjBuPx1V<6xnvG+`Zda+R>q!3bx*SOkjo z3;EnXLu@IU&)40vBuXnv@z9zodbL;JNgu|e%5gb(sJaOn*VR(qGNSg3%P?&C01K@v z$KlY!I-%UVRoK4bFPO*MQF&6an4jGnN;Gpg^s_uVT~1=n!~y8NTci=+>cHe&KT47A z4cB7S;nBw3l>ehYS}yL1xi2T8_rbw5>w-4Nbq>P>YiZW=BNhW2XrcCg?5%cy)E5kc z(Yhx5G3FCY<)IwgrNcM1_QAx|2>!BJ3O1Hh^QRZbpeU@I+*d_m$}NA%Y5j6I+k1vs znfeG;H9e(XJ1S^^>o)55c_ZL@BMzzW$2Z>1V*4k1@Uhn!xNMgsYQDa}2TL5dvfK#w z#XqN4Zf-ok^g0O@Cj5BveX-{*CB8T@50yNgPb~i%D*6QFz?s9R1o!(pK`U4e9cx7z zw*4ZV)>$fEH#@`|PR0lurHW8XV>1Rz_vEzuH)&4V1#x4PCXSpmj&&T8F+1g)`4*Rl zkW-Vv*1iQbzRGRz-|>r*XqiMZ`cy$(-cp$KB2(zK5n_dWm3?2FW_mgPX?2KXGV({MbDd??gMFU@srPGye z%zbWJ^ejIGX+wcZJuK1dg#|mS9Tb$buXEZ*IoewH98$LJ#m6So>E#0{8oxag)1TVl z?&o{RZh|)(MO=i&V~Mb0Xm74QT7z}3QmNpgGRmz>=0~rZ#NhqXsPXsu#v4*zN^vC&nqEICyLN=|R-d+-&Q*4~GEE->J>S%qw;tIVEn72&Jo4J_LD$)e42 z55{P=2v15E^EKJs)aBO&iy!_cY|wa#ss~=s8FgLC?B4skXp$&1L|}!JnyB%m5e$7p zYa-6Rpnwit+#~$|V4y8J01>C0Hb>^AIWqKiG zLr}zd$(o&e&~9u$@alMoZ}YoE|E*`m3j=a-QT1@kUp$^?PK|=9tqEfLu-C%ylyVS1 z7~q18`($e}lm7LJryHw$Fu-IwHg~)clM;}lf2r~6vt4AHI1=CNE3gQjF_~XkrfR3fdk3;;pld6nfd+qk))!VQMG1E&-=Qc7vjhcyK?bay#ar&lEe0U zG4x^1F0{{%7Bk&@;uObNO3{60{&mtFkXR3(DqnTbnSK#Y*3}E{|FWs%yD>|~=ktx! zt0b8*5?`Fq!`cRQy!t#?^pN%fzujYKR8SAzwbq_3RR-~wcj`3<3x;w?%XFOD?u=P( zu{_M;DlJwBr<|)H+-^!RuIe8>k|@yTm`oUb$O1bHd+@x@C0uV1Ln^!Ga*x=j^w%_8 zu)3*-Q1J^C<~wn~$!R=uO9o^$@1drin)q(P2=@LHhDPxxG0A%xUP-Z{-~qvcWx^O* z_iZTu@m!1Lj`Ofi(idCzD#D|!(cIo~R(zHggtfhfNTh>o$@Whn_dd7=R|fRqu*-|t z-TO0S_0*z+NhZ zy7FSpt3vvD6ZHJk3H$FP^ZpgPg*6^wFyZ)lSRFDEV`i3HhS!#h?&-VOu(}R@$u&b_ z%N=}hK8ai9;=#SAGZ%INTzL`!)?qr>fr@-I>=17%FJ6v#{x`{Uq%7OZfRMFUd z0$QJxf+zhN#2s3ZxF+B@)Xg=AYWo86`xQ>39~X$vKiOiP;#A?=+LsjW=*-JMjifHa zAkNR-1Ls_7$$HaPiL^!+#J(*MYIpyJ&d%4gy{`ybv({nuK*TM96G*4F2OU;^E?Ar$ zLz8w-=UMKCY!>nm>Xt}>O3!h8Zl(#HUb_|y{2tPXOCR`i?qx~Ivz=uAp%-lXW6c}w z9zoyc?!RKM9MbJqLFX6Ghd~xO;>FSoh#b_Dy5$WW#-VAta=EuA^7sQR;-Xo7PB0e z(Zvf#=}K`P4=vV1-MnaUm#rtmI32pOt+1v@@WpI(LFFI5uS$w)akY(($>BzVJ(4HWm zsjfHn43p!!HIHFxx)09I>V+CgY<1c8J{`n z;Z}6IP>GXImx0&xNn8+Qic3B|qUMid>2Hy?Wp-yJC^;0<0Er*2Py9<)+sYtn_)wnf zlEW5-wPbvE5DvS(5892*;PW2^?2Pm0pM4I|*ZB4|l=e+sw&~?yY zErp*#b9i;X8=`8p5!E!MVhV~@S%=PSCP zx_3X!8XQjjOZEva0|;KvkHO$VDLy|_hkcJ03GR*$;eL-l7SEja!IyYFbHh`{yjS}m zxcYsB7b@F@C2A|VYp5OuJiGu)V*<(D(G$lXzDYBRZ;D;O<+Kk z?Zx7GV`ZM8UMehW+)F*JHkoTQstFfrK1iAeWun72Y1B<{ z^4uktCj>0Vu~&-44QEE;IO5!%P>Nn;gx4!ZNz1oQ)@k?*(yTASBFrL@H;?#rLY!5U!xbcO#m`zt)-z zSLAA*&6>o4i%&z|yyb9Dc?g@_>J;uh>!7VB4jk(G6DH1Ar+1Tn!jYqjcq}m*I&b%8 z`B4TOzs?AT@geq2^bwDH_vQ@6)39bo5v+Z^lJ|`^7L6=4(W~D-NIQ5B+^)sb>ATZW zXYFUW{?iB>j}9YMqqA@(cp3Cm31w-S9(aCbIX=EK5WnB8AoqCUVWxTXa?EJ_@+k{e zok|lvIs|d)P9KDoc{IKI)-xM(!eWtZ1V|f43$)P&m1gMTQJZ1>pL+t0zZT8udvy3h zY9-I=xj;e_9)nL0XjPmpS?43rQ4sEsNxNE@5KT&7Wf0z_9!e0hJ5PB_$>isyabW3*xfZ)xv~!$Y0u@0~uhH*X3qk8=?{?x)e1*D<(% ztE{N()J4B~b&0ZJ7S!Q4j!mY$r<>v)*kyeYF8^4BnZND9O>Z@;Y6Iqm_@O~(wm25f z;F(HiFh6{WLxY_0{BS!IT?%oY$sX7-*$BoCoPoJDDU?yN0^B|xfZNqOE!rAVVPvE% zKD03euRt009W{`Cw=#^r6U5?y7Mh=|$nTw{u>I3lxb>+~ydX(}L1}xrWsxDSKca!X zH|@iQW4)nW+mdIi?Shzt#h_d-!-nC}WLY~NzZCYvM?sNvq*RX5hR=lnCvW!fJi{jk z>!JOx9I{#aUQBln;m@mke*MqM?s&@T`b_}OX=));1qOxdLvvKVMHnOnnl~POEj~h zkl^wx-ti?9i!aOY%lPTyz^9jmp}!V#k0CGUaD4>24&Bdlm-Xcn5gT#ds60+y8b~d< zm9WlM0#6mp_~(XuVEthoZ~LppdHx$XL%9}0*Y?Ifi?7mi+QA)<57z__lmh>%RJ!1C zpIpcNfw@PkMWX;qEPcO!6_|HRvm-ySM4-!JR!}p>zA|N?pBC4GZ5blROGe7 zN?hDGgVx3?Ozm+cH#cu(IlDx^1>9lZnVq9^h5U$ey` z?eQ4ssR7&UH0WBW8zxLv!`V_t@sHI$`hGE(^OlaFdy{)IWLxpM1Ikz!p~c6055a^J zB88uFf~-k0{QUS)2p(LB2}|Ws_`Zd%`W^$P$GSNFdOZ%8)8lOP<%3(cLgK_kG3eDf z_`IYA7LHyF!JASA?!hc`?+z?>QN`~=I%slM0-N}b$7Z*Y%(E;c?*oi2eTt@wiAGvH z)$bPE*mZ>#CKXeHj0z4fIk*7H`KogT4?2Pt`Ah@)1Y=8oid^jx*%}j=g!* zxZODB5DG^9q`2v_8Y=wO0gH3#+_i8e1ZwI*hE#mbi$Py4ZjH#s`Yr`b4rrvCKh6t@ zyVgO3ivl~S^<;f(d59c*ghB!~(~P>oxLEft#5`{WpL9o|kJL>}vRC0Hx)JCA0pe(l z2KpVZi65LiEk#3Vu9g9MCGJOMPS+8dGJDD^`D@T;Ev4rnZu|h(X z6h1!wj+VKmlGDmI;e*sp^f1r?{glI`5IF^pKAr-t0Vins##(f#ci2Is4`y?>b52wynC_S6)`@`@c8-=pVsS3~n&&`^uI`xp_QNpjpsP?~phfLXu~54y z5PgJ$oZ-HmSBcBc+=*Y!eV>Nl?=)ETx9ZB^%!DSvy~>22m-VK1<8x5OP99qZ1__>ntEeVdpp$D4 zQ|z-H{Jz4Fb(*8OD|2+sEtioj<)VQHua80R*|T|YW`fY8X%WxeXU08Dy|8J|W9szZ zD8%cpfq%AtV4b2Lmpggzt{u05-RK7wldE+NXG0@nuZ<2;4+Se|)|(w|tu zvfk72jM@i_yOYdVN5umptjff(meO4HWfZO3;>JpQ)8I%!A~au61C7-lG_{*?*s`vi z4EnlpMqVkdEnfjpcbs&J_M*|ncMy?xf@V$k$5~Y-sA2OEpH7)3k=6f7=Wa%VjLPLR zC8-yn`sEN-ek?_19dl^$P95Y9Q?aO7h3{utk^hB1P&n3=_v>rJ#*sbw%Gd}T-%|k} zT~+`W_r7TBRY9IxcEQC#H^I~<2%mWOqJ;w%aZGgxW;yJo#3gxLG4Q(37#)rF1`+VS z$0+VIY7KvBlEw8=TUq_(S^js|7q{ zppG|OXVJtM~VC*$RdCR`*=gz*RVg3tOon7MK=r48y&n_kKDl2667X-^*aFliK(J@!Lf z$4MG2q_E}9)S5Z3li<;oq{&12meS*UpFsF4M;m+|LV4aqVY1^6R2-Mi`$LZ7!S*DO zm5-)`%5^koN1%+XeVua*$;r3|J>3 zgO9`_4ZIu<9Y;6tt(GrvyC8`7^yd~* zjqeQ$#J%sDDE&ZRDA;ZX!Nw|LVT|p+@IqJd_j=Q2kti9zey?N$Xb3y2N)|78QV!H7q6F!&s z!PU`v&?pzgch~g7_uFIe(@z6*O&>w7g%zTU<3+kX<%Y$Ksgu#dqE&Fw)}U`FU43on zd?9o6L+bDj#v?!CIkeUbw#+Pp6HaSr>nlI#DpugP3AqrzIfF)MOr*)H^l*uW3`$>G zNZw_sVtz>h+#ctMTehg;n5BxGJ3ERJ*E{nA>tcH7@5r&X5j-9`z<1UzE?wJ`p3Dft zv5)7V;$bcHtyq9sCV$B{TbA4(IzYl2P2B&|T`ac>$J&-?Ofz$%z_+?EZnzQuQjaJ3 zLlbdIcqD9a>j%p3_tN#4aA?U~PYccJ#5BvkY;rSNGTLcyjg_MY=k#5T^WI#<t^0uNX>H*UQ+sIt~bg6mwihWw5JWW967cZtlG1Rbl!N=pG~X5WYK*pl3Pue z59GsJV-F#2=2o%yp$J;LY8YJ76ZmwP1y}Fd&xb=AIDS(!IMyzK+z-`MDQAku`@e(e z!^XU6?g?>y-eGa-(e*UMdm{Vv>JadfF=W}jp_SURu(|7m<;}Woj_6(^6zE0@d2@j>Cl5%vI_1b-Ba4A z<=8+<*Ov0E*JlN_qHxel-G|3B57VV_v&eO=I(Bq3RqY>|@argBOz*r)!v4Y7a^*FB zSCS&DR7bjI*Po{jG{md~ZLBCAj2l74E-0nCXTQ%B6ug<4n?%#voee`4hk-O1+ zh98gm@zuh}q=0^{KPj4}?-bv^I|oZ|4dQ;?9HfDP5e(E1#M!^y@x@I)-tXwjpMsAH zzxMnF*DqaUX0w;3)+HR_^jnbnqd{m-|k4`?Byp( zdebS1-g#|IF%t0hu`A-Z>$#K^lO}#Bmxa}NOIhvr6EL$ML=U#c3VvQWQ1tFG71~8n zgZVIwc~S>Y#$}S)Zzn$gY9=f`B!vSdN_@)b1DR=0bHNOf1?lm_hCOL zHIzfY(RX3f!Li&RcM}vRcF{?jef<7xAr1JDQKMGwM<)*CQjXm@(eL#c$WWS1R?=p; zFD(uhp9sU*>s>HaUmsgHCi02Y+2Fj^hpTeSMLs*89@uK}?el|Z+!%LgRJ}~+c2|<@ zh${0o1LoZ=@>sOg5EXOd&3k?Ni)*hOz^jrZeEF>>Y&ayxo9C|snKm8pUH?>aWX?6% zbEsW#e^3IA{WREjVgetHk^T{5H)cqlqfTO~`?<$rd zeY`D-5}pVH8l-5O-Fa%3&VZ)p!{NI5W0+I~*zx@p={NMUoLOop%u1a=<4U`Ij)52G z&AEjr)psWwmhF`|t}sP|=8gFJRlGQGZVo(>CI-zVS|Ul!(d-Q?39q9^(5H6`Kxfaw;FCXJ`0!L7zs6x>p}0|b@-5eLH7(S8jXu+ z*K`L=t_#J>xpveX7Ael}8pZzo+aPm;9bPm034w!)$rLpCRD-X@8;_ep!>eL)P3iWP zont5@?kXE>yGX*-P^`0vz_oU%ob7GRT~5+ia3q1U8Y<{kx93{$v_B43Sb^9w6OU9~ z7b0JnK<6VDu9MegGjBst$2poe`Sr$@@~eVObT-tDo5uQ<$G|i#1Mb*s<2JPdY6ojL zHtdctd-(zM$vi_Jhpk2XwIjq@zaQP-_DGC)DT)I&C!v1BBRZe>3$AP!#EXQpf$3-H41gS=txxm)KcjaLi6nYZ+6}+Nt)iSJ-^_2?B!)ZuhPk#Dr0=8+z3(GvelB$b{ zs)a?CgLjPQV+-T4=;|AxN7_`pUKx+IVkWLXgC6Ai=PvUt;XDPKz2!lSSE#=@2ZZdHrDzRV!u`zeYk>LNReqY6VC1iK-a zcGMV?i(NQ3G?Mk+i56eI@~f}UxCgD~Rn$D?xHwJkKKvbBP7$hy#doiUvhuqASpMxA znJxnhnTdBv{+bMqm@}65w!DU_fHubR_*{sOvsYsP{g@L)GKxqE8~rPreU_ zyPr3!=z!2ewuL^PZlqPYHj?3&r$NZ$AP)cDAaqRkw74)(r)Jih<@C=zyT;^3NH?qO z-_4ly<6HMeV;1;;$<`=vPqCADJ3axsjGF4Z{{_SEQUhML`LyJg^gOOCNv3ZK{b^## zMQSVm0p@oPfm@YJ+~YiEz)BL!!KM?;Um&{Ly0 ztXpeJ%C~X;+^bNzbC)1paEJ=xjtTjaX#CW0L3}ZCFjXzhfz~lEuzX~PWNT9rSI>AV zEF7=FYeU`n{@g^-yhjEV_vkC6zL?7w_s!<}W!>Ha$V=>s&T*fu#k9G8FCTiK#4lP0 zW4~dhWF!Al{J7}_teBR`swc7~n@6Yf$rTl}rtm#{_i(^LKOV!zf43yXt%kUJZXPGR zxB^9glOQeFT?o}Q1D^xBto&>MSARYP*Sm9PJ<~?cKeCrE8V=^e1$%3}?*jQfiRjjY z(O{!Jk~TU+u&QZoaMJI z7G!z32eyyAPrrf`S!0VewmBMGzMbm{9?cBd6Jp_;~G1QR9Ll`ez!VuFEPCeGN z>kTRs7Iw3iw?(JKd?=Xc#Bx5{@bfi0{`~UIse@8SjMEuVr?^22%C_uq8s$wyM! zyGW3^pFuVnz1Zu*JBm1)Aov+LgKy{}82GJGxUch7cv+Xpd*w&+z%^6&@7_R^%PWWN zWz&S;vjm)$DUa2%XX(?gJt%pmNF58VfL{MPaB^b{J-;?u@0CZczVY2m$~WG7DiR|f zdI9#@g#)z$F{3+|G>R4Jiq~d*S$3bEboaO_H(jOXm4+C>u5@rp60iTSnpWRc>+VZT z=H`C$aJlX~>KMC^|2aOONQX19pDQU!E?6-7F#(q?38uQ#0r1#*A>`hl!3#8Osd>)^ z98nLLUSK6o)ziaAeQoi1Micnv4dW3DiY=2nuZrV;M^n+}X>>x(fMu*o@SH+-wzxJ1 z)3Plkn?Cj8*Xnt+!g?>{wr}CP-Ce7h)4O|74~@CUGaX!csQdRNH5$+F?~9J^ZkS$M z3wwgpF=}!)HT>+Ntuqf{;khexVy_`SRM>>&y02(<848y9r|`prj}RgI0u~Ht5Em6b zpp8ZoFg{F^KM%^I#DnIv=@;;^*+cnL<}6y7G7N*Yyx>NJCKu{IK&QI*4W1WwNRtK34VS(%soD>ffHZ4#P9cx3f5&w5c|)RE8|}Y+iraqOy2#3*vXD) zpr;KxuSW@+!|p-t&@sG|)Y;PO75T04rSeIgqF?2Iq?TFRM>P5{au%XZH+F=pEydfqiShgt}ia>`AA|^ zvCBehb_D&1-^f3^J*<)i88s7HhT@+Gz4&v`YI3bK!oRBX=~Dh^9ARoEh&7{emf>w` z?mZvNhYh5Q*M`!akb`jbd>z@Acp_OTpz72<{71eY6nNc}_-U-7ffr^8jyBPJ_3a*h z^3)rr{!XK#&MKAx2m0YolSi2Ot(!wOA@O*Q6vkB;VRZ>}c=2#dsn5V66FqPd8KQ}Q zH2LO?z<&L^`IC?1xT~6ke#e0)$L7MEk`g}YI~z7`-^|a(hDwa@HbH@{4NeTypv5wO zsbifLt~yR)QLGPlxGCW$pZ=^fqYK2yRqSgqA3t0xfX`oi(Ph;iY7b3=H@Ph^G_-(r zXZ053=d8tlxpDL}Y89)Gu|?zjLoh(*3v6~-j)8Sef>oF-C)r#Fjj&VHsd)xco_NCs zSt*>8JqRDS_QP*mhVcOJmqPKKG8|uQh2st$z>E7w@x@U$=(ERS3`{x;DU(;zmf3yi zOn2{cZm-=n$NSI0&quxRz6!&Newp;c^Qe&Eo+!vI&t^%PFGXMu~6xrtcSz%sgIb7>F$-_2$rn@rLl2r$e!Jb>q@VL7VaKpnL ze@I<~(dVyGf77UL?xS6}|0x`oJ9P8!vuo+fEgSr*90mK=%*2L{+oV71gJe=>J-krb z&7+);!l}!@z_xB9R($;dy%KKXj_V(2#=D8sYV%1l(dr>7h3epaAqGEBG{T;tBOyxV z4IMkSgjUV%?g*<#K;v#BO#VDTtSxZH7i$y5@ezw)(1UN((b`)qQtuR#RZg(Nj*FyW zy^1XF=i=BZGk&tNjt&o-%azjgkhUX~0_SL;nVvSv&t&+~l1qudCA8l~gqL}xvf znuRC!i25n9{Ntz^-w^t6ta=ujzj-H2>YtA@OPlGKSF!l@V+-C{vY-AmbV^pwipJE= z1RC&dD7B`o!AG$*+#yqjDoQi(wA2W8oG!yA`5Ii-|2m8+k;l@-yI|?#!@~W5Rb)P; z6h}U-r{*78JgNRBZ0zo`4)$0{6?O6O@BDCfob->D^*kZ|?Nk+i|4M|D7B}JIsB1u8 zCt-Ov8##Z@8L>TRC~Mx+Z$)LWz5^R6QA_yhwYm;;h7y;sO#MmJHKU7_JIsM`DY-$-7yutKe*%8{9?Kq z<_@uax-+LIMwl=*Rnn@h0;ko3FlBcS+!5ZrE94HaL0h!YCrp*485aGu zx?s=JR!N-nLB5fnh@W2-K=#ww=Gz2_d7qN2F zU7GLqjLP1}(b)riankiTSiPtZ2j-~r0{bqQV%{!{-?EGw`>4S~v~?tI z8B5SWw^riw=r|cS>_w}{B0>6jI&b+jThx}9A?Yh$(d5buOdc;sU;ZdUg^CiYTzU%= zTmo?0;v=jk7cIGdP{7L$p>S?v5M&OK=4H9x!ExlM?jF`|vFxK3#!Hv*pKrzZzJ~e2 zZvk!^Yq9$9msG6qK*&&U#m}BnoH_3r+#gwvDt}`k;Os3P+w;1)N}eZG<`lxOT2*$7 z4HHWj_r}6|QNo$R+1wtLB9txZhr5nv^Aue*o*J%6c0Q(p-TxJxheOTZ8^<-Ir6O%3 zG-*=kexBzdQMOXqWhX*HcG4~>DvFehk|?84_wziJ%&(n{BrBq1WQXE+e}BV0pL6bc z-mlmD)b?kaZAb9Q*OwI1=E!aB`=KHx363e~V!p#r);QLMZ+G%Qht;*B&a?(dXsv~~ z<^N}uQ@E+)qOdNsmK@Cj#RWm;=yLHTxc2@_F>3~4ZAc(q2+(IAE)mak*5I`B+seBy zc}E#SD*Tn23ZMKGdGLr3zO>VUHvLYdQ|E4Rm+BR8dbS%66pO?|riE0c@s!4S<%<4~ z<@>GI5PAQ>ExLUF5rhtj=Ml4tsX41mXx?Qab?h06PaEHZX;2IWUcE=%`-r0LoeW$W zWrH1``eDq^J-ptp0jjgR!+fU3FQe10gE()D zDOUw&myUT-57)Zs;fX;W(CK#r+6?rC^3@~2i;T(paRK)f=3{h-F*kR_2zrNi(2ArK zIol+PTZhlZt}R#S3Jev#`JAG`uMz)kTg>tM&Wk6VdSZQ&HNC8TOYVd6z&XT_zJUn4 zKHFh*@>F@JLzC>edpWGW^aO6-?=7}H-Nss)n_1WSCi{PLCzIS{bXAGPKRyDkG+4~- z=}S30?kC*axr_Q`b!Fc@mf$i?2HK~aY52cgVoxi8+}vyQbC@Elu8S3CUet!a)9rBc z_wTX?-JE!pwknm4i--2&A|X-cgL~iUV>kaD=oGJn>+VG37jd6>{Yz&t>Vy$aDk|qR z@g&BWD`I164Sa1d1UI$md{oYP#!SxwadN)cl=>MG?PGXc{cOB{-UwyA#*zBd-ZZmI zAAF=yMO8WpnAv_1CU4lqRkJl&*{WX9sa?(ehJc(e^@dUJ@~O|C-I5o#Rzduiy?oJK z8%N80S>Gy$%9?JH!L>X3tph$lTd>z%5 zpIjRyW^7Evs9Duu^)r=pB+Nrquop8_VF%8yAk7Fcmm;2shA6#PNMT^<-P%a#KYeG35iR>HkOW0Pooi_wjP}1?k!o)M@X=C4N zy1Dfs3{-2Q_eQG;!Y$a-crKoNH-lZ*9}ohPEM&g=VGtSL#FyXT($gpaj@- zB2_T@dYfvi;vijf8@YK`3dJ=WK{}yX?%Je+r1=@1^*;y$ZIC7_aPu3Qtu&ce#|b)JI^ztyi#I zvX|bcHHllV7-3GHH$Gc2oJOqNBG~&s6Gp25{Ci?0PXCaGtLyuNO}{0u@>wI#`D%k_ z#%{xDo0f}7}R*c z_UL2Wv?Pb3lVzkCxUbwStVT50(i?3De-k3?6}cuLA5uTR6CMV~^UII-*vL2^kL|N! z{kFk8Q%DgHY1a#nrXClyX0=NSws(|GpIqrbbYw*djRGHAG@;7zii&I&$>NZ^Hb? z2Weg70orP}6FATux1}!UfRnF8hcB5>alclwUQdUg9ns@~o4R7{?!{E@x}GXs=TQ3@ zE7W;74{o>57N4cXi4*rnamM>ZdOy`$Jas$)ug(buC-Y8_U^x*Tuk8?ye>8_#)*|E0 z4sk)DKjvz8WtWOau%aUa-eU?(JEIT%@1MclcNAF9xEM^|1=CFnd(?C*q(U_*9{RTy zKdD$yQL-w1ICw=o@KK%8930_qZx2DWy&qryDuqv9^k76w6x<4syEF&lspYjA7pywZ z#jU4d!5p7qk6n(Sh4xU&wqDDRqzXK1z(LCDZGq8ae^KP;HkvHI;||{Miwi?_*=f*v z3OQuL1;6yTqVYAo42t61gYy4#TdJV<$yaix*PLeARtc5EPJ#9|C0aRV0Tx{BiRGPC zxNdS!OqXs0%a&gxRMdd)-AwLgZ~>o>zYMWaVbt9p*r0Hh;D0EH9o}4nn57RS1)b;P zv_5wsw)`@t*g6X-A4l_}=_cTGdjK^=`@$HbKDc4N8z1O5m75>*V~BrW7P#e)_$ksu zShn&F<(A!o+J)wr>%R))lC^oNQ7iq4>_x{KCgG$ms@x*a(1U&{khgXcmFPRtk`G1F zmgP3w;p&2Vy9QyJ!*}tY?Pxq1IF;LbRzk$xiIR|}&!p5~hN6uxuW5ZmZ`%jppt&Ni z3oaF4aV)!r-X+z9(Qrps0~_j;u;R~m7wVhoS>v zo5e@BUl4w*{OU4$ofhW))JE;$X6zdfOn0;0NY$|fqPl#h6D|>W=b1a_`>KK8q0XrK zYAz=}cH-P-1zz#w3+a~(z)c6_Jt`53EdD3I3y16A>W*5docvx)dNG!Vd7ALI+fv@~ zW-Vk_9^gq=vY_#3yDU@fzH{u!@!&i;jJ|JrPd$R=`_4~iKCxQPVQ3bUeOx~8w3hn@ ziQ#B+Rlu3<$7$;uEw=5Z!-G#{!>*3b+)`X9!fUS(d#vqYd_mk{>~1;_3AiQ z=q|9SWj?AtO%PV>-VeD>exUu`f`@#~;0Xyy_@rMYDRtGAw!Bz`h5uT_3!TE~jiwUJ zjdX(fd;Wk~QYD?tiGyeV=HPxw9NOq#f{?greEpz2s|}6k!-ex$a6#<0L9JZfzZkWj zG9PnkGh4Sy`sd-wHB%SmrRJn@gow+aW)6Bsw&jN5+(rM^*riQL21hYZ>I`OcNIwf~i zmqpHAC%If4K<)3xu*vH)*m^Ay+~gf2Ycqpry+$?nd6-pGM)d6Be9h zc2Ereng+wK&*8S$JK6v2EOdChm8V;KvSnR5DkTjQlLw8a9R{W_#_u-9TgI|;L;z08 zPvElnC9+M6Pm#g=AyUco7^q0qBli(K<>X}=Jzjl6w$*1XIw-}HUBgx!IOQfid|yF1 z)!XUHs5E&KM!UGpd?B00#6kAY<+8`Swu9|gL(aR9ipJBlc~{|4;r_oW!KEaE9d7o8 z!t44tAb2yk{_29;|0VH88#Q`f@6BV!o}%G#o6)@eB8cHDK)GiS)Kt9^48|YEXH}s% zZSePDRv|UmIX55rtoR1WKZ-dxI!{>p$_gI#D~8wJDv&nc1aE9eq@y!;(7+p8a7Ml; zE)l+pCMGqMc>4z(UucGJU#nubC%FK1{VC|ODP8%c52*=l!|Ycr$G&}faQ_>7VYg}z zR=J~rC!;FF0RMb4{%OqdcN(dx)uQ~b-7-qu>CDz!=ZW^t8(X6-lAA%48Mjb^TZd2VA;>l;*Z=rP@+*J z=TqiKEH1oLOyNcG zSl{cc`0bYg4Hx^v>CZc8{_++GpXbIm>eoQO$lp*FoFtjsS&2Yv5St{oi6`c0;L37a z{FD6%$}@Di+rCb`E9N`h`FS3vR~^pnZF?t#YE2D(u9npBrtP{F%#d}jPN(WWj)EMGkf zZn;;>8b>s9ntMA-dTmit(Wl+-3U@s-HWRHiy-Me$#O<(@>MMQz4Y` z$&F9stwHfwzbN32*&3+mxdBz` zJ;7+H3$0OTggpl8+!$}c{YHel^aj;okRVwIKPojV2t$vX}8 z=Jdub|E80HZ4HDKxY7FCi@5H_D;U$`H5Iw zy$N1O6mYg%JfHlso2?|V;1aV*$QnJ0%zjlurQvp%yKe-{TK}G&tBw*c=PE=A?@sT^7nLq*?4qy9`Kp4i_2do=7o2lxX&^QKcykP92oTZ&uM3HK+oK$@2V zj4HT8wqdIH{>DXGuNXpCi?!I^z8$vzS&2U`IHz`fF6r%^GQFqr{)VbS1AvAR{ zTv0w&-hEbtWW-oIm_0;*E-}4XFT#Th&D-G2r0;^do;CmKsfLE*OW0a3k(B$YaQQkj z{QmSBz0GvQzQfLqm$$?p=@qEl)+=*YdaeLbSE!{Y^FHkK^%*#u}KeHIu8VA2gqiOeJK)L0I@a1(lTBrq6Xo(e!?7a>b81AM}*vc-quL(}I zt~j7!KLo$*EVRj1@>ls>QB|eZ`9Gtc7(7xJ@`^RY7fN^Op46E`hucBzj;Ykop;m~s zJWBl(3L!*%2ZlFhL*|UrwDzqk>Ybd*ui}abcDb{>(~?7iIv_evoNr#Ipu1UlJ&vLlvM-(jRe-GOA zx1)Hg1S`@cXmMsekJ_RIKXff{K~jfU{(Q9D*;6E%+zb7O^%53EZKJrdHu`g|kn(bDT?+zQw}@sI_Tu^v1~j8%?z0DQ)!!4N z)(mFVWxaT;whL5t4&k8-hVo(~RkXi454UuVB9H!=_~(2g+fSG9=o=pBs;Yz;PJOBB zg$%?6w?VPJiZXrjdHypq8nh&q{?zuwsy8mEHCygVg$yTG#~vIo>J}_^h!UcDM6+X4 zl>EE4EcbrepRb)>jBcmZr6&}Wp_8M)N9MJYoB0J{EH}`VeebB-`;9`9U6sUV!Xr@G z-52eiz7hLuwt;WLssc3?v)k$)whASHQ#}n{{T7LhIHHP`l!d8&A8@g`1hQL+)ZI#G0e| z;zts#+N|-5107Mch}Rn|$4` zG|6F;8HZ?Kq!h1SbjQ(Nw@KD9PoCSI6F&Pj(>#rX^u6F791`NicT@6UmRcnoagBow zdR?HOMzK^SUxELtaiGS^u~0rv3jNoW;eGpXE+23b4TI}tZbm&g?du|nGr0k6OBb=d z#t>BUP-D|;xim(1fT$=j6trAcLbK!?^o{%l19e)Usc{iD`MnVo$E{(5ojTaG<{+FJ zo?B*8la4F49v6a2e7I(NXY^g1hk1_L?C#wx9?A3oWtDN_FY}SyVJv|K_jFmJG@BwP zh2kdzM^gAN8xOx4AnwX4;&R= zT;bL!R~TxeAT{U{OF25L_>-OjB}|KCJ8u$|gS)UovpVYd=ireG@2GeE0$SZMU)~#H z!UkK;@;wdU5NB&nDru#+ZKLQ}i`?UA{Yj#uNH>bsQJkhCUN|)pD;CMuK(9gUvbZma z9+84Lub%ub)`>H5EW3R*0f%Dw8P_raHI+26@oO%vJR_l|e?DLgWn!W0b=mMplVSh; z{6Mw4O<_~}46e@)N25`Q6?GH1*QQb!cKfL0<}ye2bC`$o`iGIW?RL4c-j}9&zPRDv`@ldge0&6`nu}G6qvVbgI%Y;Gia(T0U48QplRbD-Q6`ij0 zl4%&OWj~Gn=y#))9z3}QwjDBB79NTfU854AB2tKa$eb#T7li!Pv_0xU?mUN9S0f zJUlJS{FDW4_A@#9d>G~?c;dy4X>fR+0?!TZ&8bxh^z?-ypFJw?w9Iv;VP#2-@8VvTsV8}PTaED7jtU6fY#}kaAUzsyt!>ZG^(uQ4a#=xyvKwcu6l{f zb@e!^IupMvPm_!s{+6~$4%4%xv4Wl5BT4veSAO+8mu5V+5oDJnoPE3>4Np{*&KF8) zmf;6lfBiR1Z*YcccrCbuGzkMnIkOt|#DUMHup;B7?8x&EFsdaTiiam~<(Y$IFr^Dt zHBW}x`*Ts=I!jN|`rxUbDVSC8iX5ho=3vb@!DXj9r+3~{zV}5JT$OMZ$BcOc*ROPE zqbM`Um;aWb?VE*MH7Jf-?G#aAy*e+Pt`9+}2H+eV1!lh|i6QIdS?`ULyxs;#E&uX<+EBx4Wg*N-C zh#C6cY<=x78TGD#TUJ+TUZ^kJUZjD=S>NI8*TERpdpr#L&z+MhTfyh*IB;;=j*=Nq zCDPY&Cu5U0j!V(SmbF=O4{jzO&-h6bKgZ&?T_PPCHHDW{E8x(6L$F|!oZB1_3wz72)LH(*=>UoSWof{38j|ZVcx)HY2jTHV^ zoQ32U*J0h0=k#xc8uweMDsDcaPP@N|{Pc|zEE+ivA`VT%fRF0@CgY0amG&hVbayAH zDISHr?|O0YRbxyvza;bBa~zVFrNCO(Fdj4AiDkh~d_3ivq%z<&rF_qD8D3zEsw#_R z>yE7ELGu>LKl=}KDZohfY~wF+LSr6=T%Lg{;$VC(nZ*T8d2|xDQr_M3ywTwU-YfqF zrLMn(KB3z%-@A!^(>IEK<%^^90$E4iCF1xnKU3)bvE2Z7x!SugYj%(ti^3v35Yv_GvawdP8Ta*pjWgtwQKh;=KM(68N{B;yJ<3C<^al1k-39IbVP>lUa8L%wI? zq--;B`h0EpKlLZZ$TO^Gtn#I+_mBsNl&GG9eMfqCtlc5`=m?{7B`)cOaiitavyT%a?TGny;f!@rfVV-sy|E?^Ez(l@|oR z33lnPtpkd2`zY+p97>i{2}?T7!RKfDOYRO?BMR>E;z7LxRyy4Y6NF`KPia8^4d6cO zo5cf{21?u;1BI!+mcovK+M-^tuZvNILIX> z`QLYXwzCinO`Cbeg@XWVeWF_$NHVm%mN$&zhq! z&Mu24Z`I;ir@NwsJb!m^Qef?1lySEj=(fR@EvC5!{bvNk5ax2m@+( zWz7NYr^!v8bBF4PkhW}x@ESAa&h0TF36Ef}AN8;`aRTREyd>W4(BhP`8t|Gum1T8t zU}HZBhTLf*7f;|#hxe7M9D55pO7yY*xi4Qx_rXOfRb;oYA2vQQgG)N^aP4a?-0yx1 zo_Rl{VJ~$l(5v*p-ct{mfM1pVbM+tJDm_~NJsFDSJV4$L{s@h)R{i0U;sGJP{$w`Ia6p? zsEaQH)v@=GrCg=aUs$=K6NwfwFsiU)>zUigQf~lAqFJe*psv_!|}lO%&4oZ%;HkuNQbz>{(= z!^F!E*VW3vWQQkr9_U3H8QuxQ#~Hr{xbzANO? zu%|oA&!p<&rl5AH9d!&3j^R`POi+kW!yZ-(>B;%uuyE>Rexefw-ldtSJF}Kz9yySS zSqErdEugR#OIC4|zu$3ZAj4OgaC1@;cU_$hqn8@uskZB|Mb#GdPYy-bEmHW~lEw?& zJ4EmML$KnR0*!OuDK1>&OXvR1hl#uQ(W*T~V&9!J>Gm^iZu3+r_gJUIcW#(LuPJ+R zWT!4zD1WnKLrkmCHEB8N9u0s;E!E$$;`Qu zc8}b^+G`8MTwQ&z2TvN%oXU5Xmea8nSA}BlQ({`L#iF==FBbRq#AsDx{C3iZb>izJ z^P_wy^3D?u@c$1trDtH`&s7l8Z8I*p9?$K89(3cz8C>`;pF^i2t*PmtuO*pyYt{t_ z^>C#lGar_D)yZe%{c~Z+u8pv9ycK(#8Nqa~o;yNqd7sZOc-_-eShDpW7}&Vrna3Hl z@}N8Be4W9H{&qZc{BH<8t4i%X<3*1GHG1`F9BRntm1l2{g}w@9Qq|L%{Cd9|ZI);B z+YLg<_u~tE86fvcu8rXOQcI}pei@cJWYNBlsu+JEkGjj<`0rCEF>7bR>J4ff(KL&e zDc^#PO3x|o15m8&7i_G!2xELR$@7^#G&c#Da_+Lw+omgOoe9BMzd|(H`;y$cl*1wQ z$?(C<2@S?ShW>A}1;g#1Bq7FmFsaFrU(IoWnbS53;{|uvX{(5x-;KbS`yL#(WddEe z_#0M8-_ynS9_U;f!U0)|Eb&Z%MZv={Dbp50nkUhf3=7$g+boPZuo{$x%oFqEUA`Yuqe*;Q zK-bn5!^}qyXm84FPW$JA`lFj+l2HyEZEc3T7yHrz`Q3fQD|t`b9fo5MGWqm{Thx%( zRs1j_2Q6cBBy#IftShu-zlT4_YMm6rk8J?WD}@rvZj<4gyn{$9YZvcXVh5eJL}Oi6 zHF%3d`PHYfqG#P~HV*hMb2K#olkJoELQp4E$uR|4mvY`y6HV7FCd-}8pK?zzkLHIR z5*oJ4z3~1A= zymwWsx*+e3ukFJxZ8mUd_Bps;Qx7<}uT;9b1efA@oIE_5PdH@akEaPZuFi^jd|b(; zxpQE4Ydr2tIfVr_&ir{;1c#r$CFHw)5Fb0}P_yr2y3ur%5A426@2mDZQxBZi?Kc-+|AkHqt9|dsLV>3VZ*$ zEoZ7i;LerburBg8T^ts`^;5j?*~oI(5^@G2s|#FAuk3hg>q4aQz4GPMJ^m&yp&%Q8~ zm%Ok6=VPZ}OugI}Rd+_TH>V#pgYZ!Izp!To%ilk^jK-FGA$e6H(&u!#w_za1+W^kk znn2&O`k}+(p?G#_xFE)lgxu5CxOqx23<&5gRAx5|&K+MMA{gWyZL9Gve1gm7`QqQ# z2VmKp5Uz`BrK$gXaB>9jjB6g8cy5?PYt;{^_Fcpod9Oh8xfU1pN)%k2=kcD1g{WS2 zl&+`*%kXA0Sa>zSTLtK5u!OsxJ|b3F>fw(O{lyQxRXFm7 z5l?v4gV#SxVUNL&Xi(8%`p{sHJwK<&3KvGx#Nb!pe>a(S7)^xWCixoMa~Q6n8XruE z2hY_O6wwwhsnFBlc@1vdc0!pPD+GQWxQTAAE`)=nzSOj)KUGctEID-~T5>VsHM|&> z1!vnL(Ysa|-E6kQt(Rdu(sw-!nz2DtTX>y~#emRurZ6{s3C&G&4I(XP+c7@N1_J z>aEwLvMKT|*TmhprLGYI_P9aEibzay`zhAub;WTG!_h;?ggg6GArk+Cw{oZXcemrh z-gi~>cdkqlc=8yfrOErmqP~&!)mHjcV1)MTO*!TF0T=%s`phMb@G;nkT;wjua>Wzk z-hc$zj}N+XZpoAm+k~U@JX^H5Dd$Na<-l{*1+Z%DQZ~S$th4@-c-E)~#tiwy%l$S{ zmGuL#j~>O#PY&T9miu7uu_Lr6rZ0EWHl+RHZs@1##M}e%pmx1I9|4K zhZ(7E9V0e(z6e%BR`MU0ow6ejlyQ5)ccG-_KIKJC5S{xg(pA@mJn?d=_&ItG>n!Sp zCv^Ie!lGTQBU2QLhmFN<_a@NLnJM&N`3f=c%M$(=tV)GVjr7v?ooILRiDXAg{m{L0 zyK+@YT6xr(E~vLbRTfvP#NOqOc&62!wGM5gq5lo1Q1f$g_jDG8CaGYEe2pnyQe|uP zy?kI!ccIJLE#eWC{y1oQ5C**NiRP=;K&bZ;_?_jCL*5SIHC9_V{6iX~noXnCI(?xtZvlxSNMs7hu8hsW|C%E5z1J<(E6^p|*Ys_O*<|&E>@$cxx(W_}r&X5YBtL z%jlkN3D_TvhY4lNFUAQsc6T+M1Z!x^~p+$vP z#ge64czURz)I<5LnC>|po&3vX>Gzk=W_1bl8`T3_P1{3sBK!5aZe)7qNt!!S?ybk&Q|7%*@N8yM#%-(>|z>LFc3dnDBH++9I zh6dW)lfOsO?lPg<9od8R@o@6<9Ino?x4*3h;4OIE%P#-+k;R*wO zb(cLUxPm{sZ-i{Eqe6-3N Date: Sun, 20 Sep 2026 15:18:40 -0400 Subject: [PATCH 24/49] chore(catalog): track media vector metadata --- registry/catalog-artifact/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/registry/catalog-artifact/.gitignore b/registry/catalog-artifact/.gitignore index 5d341d92ca..08cd699642 100644 --- a/registry/catalog-artifact/.gitignore +++ b/registry/catalog-artifact/.gitignore @@ -10,3 +10,4 @@ # half-complete and the offline tier silently unable to rank. *.json !local-vectors.json +!media-vectors.json From 51c31787b363a5f550d0613441e9900db929b071 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 15:22:53 -0400 Subject: [PATCH 25/49] fix(catalog): validate media vector row order --- packages/cli/src/commands/catalog.ts | 9 ++++----- packages/cli/src/registry/localSemantic.ts | 7 ++++++- scripts/catalog/build-local-vectors.ts | 11 +++++++---- scripts/catalog/check-artifact-coverage.ts | 4 +++- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 4b66c450ce..4295da55a1 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -57,10 +57,7 @@ export function rankMediaRows(query: string, rows: MediaCatalogRow[]): MediaCata })); } -async function runMediaCatalog( - query: string, - registry: string, -): Promise { +async function runMediaCatalog(query: string, registry: string): Promise { const fetched = await fetchMediaVectors(registry); const rows = mediaVectorRows() as MediaCatalogRow[]; const warnings: string[] = []; @@ -244,7 +241,9 @@ export default defineCommand({ if (args.media === true) { if (!json) { - console.error("--media requires --json because media rows are not installable registry items."); + console.error( + "--media requires --json because media rows are not installable registry items.", + ); finishCommand(1); } const query = typeof args.query === "string" ? args.query.trim() : ""; diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index 4618884b17..2e1d87e888 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -222,8 +222,13 @@ function loadMediaVectors(directory = localVectorDirectory()): LocalVectorSet & if (rows.length !== meta.names.length) { throw new Error(`media vectors hold ${rows.length} rows, expected ${meta.names.length}`); } + if (rows.some((row, index) => row.id !== meta.names[index])) { + throw new Error("media vector row order does not match media vector names"); + } if (vectors.length !== rows.length * meta.dimensions) { - throw new Error(`media vectors hold ${vectors.length} floats, expected ${rows.length * meta.dimensions}`); + throw new Error( + `media vectors hold ${vectors.length} floats, expected ${rows.length * meta.dimensions}`, + ); } if (meta.dimensions !== LOCAL_MODEL_DIMENSIONS) { throw new Error( diff --git a/scripts/catalog/build-local-vectors.ts b/scripts/catalog/build-local-vectors.ts index 394ec8e52c..e1c327c206 100644 --- a/scripts/catalog/build-local-vectors.ts +++ b/scripts/catalog/build-local-vectors.ts @@ -57,9 +57,7 @@ function mediaRows(manifestPath: string): MediaVectorRow[] { | Record & { tags?: string[] }>; const bundledSfx = manifestPath.endsWith("skills/media-use/audio/assets/sfx/manifest.json"); const rootRelativeFile = (file: string): string => - bundledSfx && !file.startsWith("skills/") - ? `skills/media-use/audio/assets/sfx/${file}` - : file; + bundledSfx && !file.startsWith("skills/") ? `skills/media-use/audio/assets/sfx/${file}` : file; const rows = Array.isArray(parsed) ? parsed.map((row) => ({ ...row, file: rootRelativeFile(row.file) })) : Object.entries(parsed).map(([id, entry]) => ({ @@ -131,7 +129,12 @@ async function main(): Promise { // the whole reason the index was allowed to drift. const rows = manifestPath ? mediaRows(manifestPath) : undefined; const catalogMap = rows - ? new Map(rows.map((row) => [row.id, `${row.title}\n${row.description}\n${row.tags.join(" ")}\n${row.kind}`])) + ? new Map( + rows.map((row) => [ + row.id, + `${row.title}\n${row.description}\n${row.tags.join(" ")}\n${row.kind}`, + ]), + ) : catalogFromRegistry( registryDir, (path) => readFileSync(path, "utf-8"), diff --git a/scripts/catalog/check-artifact-coverage.ts b/scripts/catalog/check-artifact-coverage.ts index 227c338765..fcb6ebefa6 100644 --- a/scripts/catalog/check-artifact-coverage.ts +++ b/scripts/catalog/check-artifact-coverage.ts @@ -96,7 +96,9 @@ const show = (names: string[]) => console.log(`registry: ${registryNames.size} searchable items (blocks + components)`); console.log(`artifact: ${artifactNames.size} vectors (${artifact.model ?? "unknown model"})`); -console.log(`media: ${mediaRows.size} rows for ${Object.keys(mediaSource).length} bundled SFX files`); +console.log( + `media: ${mediaRows.size} rows for ${Object.keys(mediaSource).length} bundled SFX files`, +); if (missingMediaRows.length > 0) { console.error(`\n${missingMediaRows.length} bundled SFX file(s) have no matching media row:`); From a38ad50b6f7fe54130a32b2c557a6d477b103e09 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 15:40:36 -0400 Subject: [PATCH 26/49] fix(catalog): validate media artifact freshness --- packages/cli/src/registry/localSemantic.ts | 9 ++++ scripts/catalog/check-artifact-coverage.ts | 48 ++++++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index 2e1d87e888..5e9c7fcb7f 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -87,8 +87,17 @@ function vectorPairAgrees( const parsed = JSON.parse(meta.toString("utf-8")) as { names?: string[]; dimensions?: number; + rows?: Array<{ id?: string }>; }; if (parsed.dimensions !== LOCAL_MODEL_DIMENSIONS) return false; + if ( + artifactBasename === "media-vectors" && + (!parsed.rows || + parsed.rows.length !== (parsed.names?.length ?? -1) || + parsed.rows.some((row, index) => row.id !== parsed.names?.[index])) + ) { + return false; + } return bin.byteLength === (parsed.names?.length ?? -1) * LOCAL_MODEL_DIMENSIONS * 4; } catch { return false; diff --git a/scripts/catalog/check-artifact-coverage.ts b/scripts/catalog/check-artifact-coverage.ts index fcb6ebefa6..56e0491b91 100644 --- a/scripts/catalog/check-artifact-coverage.ts +++ b/scripts/catalog/check-artifact-coverage.ts @@ -23,7 +23,21 @@ import { catalogFromRegistry, localVectorRevision } from "./catalog-artifact.js" type RegistryItem = { name: string; type?: string }; type Registry = { items: RegistryItem[]; catalogArtifact?: { revision?: string } }; type Artifact = { model?: string; dimensions?: number; revision?: string; names?: string[] }; -type MediaArtifact = { rows?: Array<{ id?: string; file?: string }> }; +type MediaArtifact = { + model?: string; + modelRevision?: string; + dimensions?: number; + revision?: string; + names?: string[]; + rows?: Array<{ + id?: string; + file?: string; + title?: string; + description?: string; + tags?: string[]; + kind?: string; + }>; +}; const REGISTRY = "registry/registry.json"; const ARTIFACT = "registry/catalog-artifact/local-vectors.json"; @@ -70,8 +84,21 @@ const expectedRevision = localVectorRevision( ); const artifactRevisionMatches = artifact.revision === expectedRevision; const registryRevisionMatches = registry.catalogArtifact?.revision === expectedRevision; -const mediaSource = read>(MEDIA_MANIFEST); +const mediaSource = read>(MEDIA_MANIFEST); const mediaArtifact = read(MEDIA_ARTIFACT); +const mediaEntries = new Map( + Object.entries(mediaSource).map(([id, source]) => [ + id, + `${id}\n${source.description ?? ""}\nsfx\nsfx`, + ]), +); +const expectedMediaRevision = localVectorRevision( + LOCAL_MODEL_ID, + LOCAL_MODEL_REVISION, + LOCAL_MODEL_DIMENSIONS, + mediaEntries, +); +const mediaBin = readFileSync(MEDIA_ARTIFACT.replace(/\.json$/, ".bin")); const mediaRows = new Map( (mediaArtifact.rows ?? []) .filter( @@ -87,6 +114,17 @@ const missingMediaRows = Object.entries(mediaSource) ) .map(([id]) => id) .sort(); +const mediaNames = mediaArtifact.names ?? []; +const mediaRowsMatchNames = + (mediaArtifact.rows ?? []).length === mediaNames.length && + (mediaArtifact.rows ?? []).every((row, index) => row.id === mediaNames[index]); +const mediaArtifactValid = + mediaArtifact.model === LOCAL_MODEL_ID && + mediaArtifact.modelRevision === LOCAL_MODEL_REVISION && + mediaArtifact.dimensions === LOCAL_MODEL_DIMENSIONS && + mediaArtifact.revision === expectedMediaRevision && + mediaRowsMatchNames && + mediaBin.byteLength === mediaNames.length * LOCAL_MODEL_DIMENSIONS * 4; const show = (names: string[]) => names @@ -104,6 +142,9 @@ if (missingMediaRows.length > 0) { console.error(`\n${missingMediaRows.length} bundled SFX file(s) have no matching media row:`); console.error(show(missingMediaRows)); } +if (!mediaArtifactValid) { + console.error("\nThe published media vector metadata or binary does not match the SFX manifest."); +} if (dropped.length > 0) { // Not fatal: the CLI filters these before a user ever sees them. @@ -128,7 +169,8 @@ if ( unindexed.length > 0 || !artifactRevisionMatches || !registryRevisionMatches || - missingMediaRows.length > 0 + missingMediaRows.length > 0 || + !mediaArtifactValid ) { console.error( "\nMeaning search is stale. Word search still uses the live registry.\n\n" + From d36e7ca1590866b1cf0e67b5bd3ed9252f73cc7c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 16:00:07 -0400 Subject: [PATCH 27/49] fix(catalog): cover media metadata and credits --- packages/cli/src/commands/catalog.test.ts | 34 ++++++++++++++++++ registry/catalog-artifact/media-vectors.json | 7 +++- scripts/catalog/build-local-vectors.ts | 33 +++++++++++++++-- scripts/catalog/catalog-artifact.ts | 4 +++ scripts/catalog/check-artifact-coverage.ts | 37 +++++++++++++++++--- 5 files changed, 107 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/catalog.test.ts b/packages/cli/src/commands/catalog.test.ts index a23df786da..dad034c9c4 100644 --- a/packages/cli/src/commands/catalog.test.ts +++ b/packages/cli/src/commands/catalog.test.ts @@ -88,6 +88,17 @@ describe("rankMediaRows", () => { }); }); +describe("catalog --media", () => { + it("returns JSON media rows without entering the installable registry path", async () => { + const output = await runCatalog({ media: true, json: true, query: "click" }); + expect(JSON.parse(output)).toMatchObject({ + tier: "words", + total: 1, + results: [{ id: "click", file: "skills/media-use/audio/assets/sfx/click.mp3" }], + }); + }); +}); + // ── The command envelope ──────────────────────────────────────────────────── // The JSON envelope is the surface an agent reads, so under-coverage and the // score are pinned where they are actually published rather than only at the @@ -110,6 +121,15 @@ const state = vi.hoisted(() => ({ consentRecorded: [] as boolean[], downloads: 0, runtimeAvailable: true, + mediaRows: [] as Array<{ + id: string; + kind: string; + title: string; + description: string; + tags: string[]; + file: string; + duration?: number; + }>, })); vi.mock("../registry/resolver.js", () => ({ @@ -168,6 +188,9 @@ vi.mock("../registry/localEmbedder.js", () => ({ })); vi.mock("../registry/localSemantic.js", () => ({ + fetchMediaVectors: async () => true, + mediaVectorRows: () => state.mediaRows, + mediaSemanticRanking: async () => null, localSemanticRanking: async () => { if (state.rankingError) throw state.rankingError; return state.ranking; @@ -277,6 +300,17 @@ beforeEach(() => { state.consentRecorded = []; state.downloads = 0; state.runtimeAvailable = true; + state.mediaRows = [ + { + id: "click", + kind: "sfx", + title: "click", + description: "Crisp UI click", + tags: ["sfx"], + file: "skills/media-use/audio/assets/sfx/click.mp3", + duration: 0.37, + }, + ]; state.registry = [block("count-up"), block("fade-through"), component("whip-pan")]; state.indexed = ["count-up", "fade-through", "whip-pan"]; state.ranking = [ diff --git a/registry/catalog-artifact/media-vectors.json b/registry/catalog-artifact/media-vectors.json index 544ae162c1..4059ded2a6 100644 --- a/registry/catalog-artifact/media-vectors.json +++ b/registry/catalog-artifact/media-vectors.json @@ -234,5 +234,10 @@ "file": "skills/media-use/audio/assets/sfx/whoosh-short.mp3", "duration": 0.57 } - ] + ], + "metadataRevision": "3bce17533129b251a93cf1587ea18174bef06e0680611040ee82c24ed2bea03f", + "credits": { + "file": "skills/media-use/audio/assets/sfx/CREDITS.md", + "sha256": "79590c7738b0427b10f362f76b02cfd2643158a40e1d5fd787f9ac6b33bcf4ce" + } } diff --git a/scripts/catalog/build-local-vectors.ts b/scripts/catalog/build-local-vectors.ts index e1c327c206..cd92e6e461 100644 --- a/scripts/catalog/build-local-vectors.ts +++ b/scripts/catalog/build-local-vectors.ts @@ -30,6 +30,8 @@ import { catalogFromRegistry, LOCAL_VECTOR_BATCH_SIZE, localVectorRevision, + mediaMetadataRevision, + sha256Hex, } from "./catalog-artifact.js"; export interface MediaVectorRow { @@ -72,9 +74,29 @@ function mediaRows(manifestPath: string): MediaVectorRow[] { })); if (rows.length === 0) throw new Error(`media manifest contains no rows: ${manifestPath}`); for (const row of rows) { - if (!row.id || !row.file || !row.title || !row.description) { + if ( + !row.id || + !row.kind || + !row.file || + !row.title || + !row.description || + !Array.isArray(row.tags) || + !row.tags.every((tag) => typeof tag === "string") + ) { throw new Error(`media manifest row is missing id, title, description, or file`); } + if (row.duration !== undefined && (!Number.isFinite(row.duration) || row.duration < 0)) { + throw new Error(`media manifest row ${row.id} has an invalid duration`); + } + if ( + row.dimensions !== undefined && + (!Number.isInteger(row.dimensions.width) || + !Number.isInteger(row.dimensions.height) || + row.dimensions.width <= 0 || + row.dimensions.height <= 0) + ) { + throw new Error(`media manifest row ${row.id} has invalid dimensions`); + } } return [...rows].sort((left, right) => left.id.localeCompare(right.id)); } @@ -170,11 +192,18 @@ async function main(): Promise { const vectors = await embedInBatches(names, catalog, embedder); const flat = packVectors(names, vectors); + const credits = + rows && manifestPath.endsWith("skills/media-use/audio/assets/sfx/manifest.json") + ? { + file: "skills/media-use/audio/assets/sfx/CREDITS.md", + sha256: sha256Hex(readFileSync("skills/media-use/audio/assets/sfx/CREDITS.md", "utf8")), + } + : undefined; writeFileSync(join(dir, `${basename}.bin`), Buffer.from(flat.buffer)); writeFileSync( join(dir, `${basename}.json`), - `${JSON.stringify({ model: LOCAL_MODEL_ID, modelRevision: LOCAL_MODEL_REVISION, dimensions: LOCAL_MODEL_DIMENSIONS, revision, names, ...(rows ? { rows } : {}) }, null, 2)}\n`, + `${JSON.stringify({ model: LOCAL_MODEL_ID, modelRevision: LOCAL_MODEL_REVISION, dimensions: LOCAL_MODEL_DIMENSIONS, revision, names, ...(rows ? { rows, metadataRevision: mediaMetadataRevision(rows) } : {}), ...(credits ? { credits } : {}) }, null, 2)}\n`, ); if (!rows) { const registryPath = join(registryDir, "registry.json"); diff --git a/scripts/catalog/catalog-artifact.ts b/scripts/catalog/catalog-artifact.ts index 5e49816e2c..fe60417665 100644 --- a/scripts/catalog/catalog-artifact.ts +++ b/scripts/catalog/catalog-artifact.ts @@ -148,6 +148,10 @@ export function localVectorRevision( ); } +export function mediaMetadataRevision(rows: readonly unknown[]): string { + return sha256Hex(JSON.stringify(rows)); +} + /** * Digest the two published files as bytes. * diff --git a/scripts/catalog/check-artifact-coverage.ts b/scripts/catalog/check-artifact-coverage.ts index 56e0491b91..32ccdccf56 100644 --- a/scripts/catalog/check-artifact-coverage.ts +++ b/scripts/catalog/check-artifact-coverage.ts @@ -18,7 +18,12 @@ import { LOCAL_MODEL_ID, LOCAL_MODEL_REVISION, } from "../../packages/cli/src/registry/localModel.js"; -import { catalogFromRegistry, localVectorRevision } from "./catalog-artifact.js"; +import { + catalogFromRegistry, + localVectorRevision, + mediaMetadataRevision, + sha256Hex, +} from "./catalog-artifact.js"; type RegistryItem = { name: string; type?: string }; type Registry = { items: RegistryItem[]; catalogArtifact?: { revision?: string } }; @@ -28,6 +33,8 @@ type MediaArtifact = { modelRevision?: string; dimensions?: number; revision?: string; + metadataRevision?: string; + credits?: { file?: string; sha256?: string }; names?: string[]; rows?: Array<{ id?: string; @@ -84,12 +91,24 @@ const expectedRevision = localVectorRevision( ); const artifactRevisionMatches = artifact.revision === expectedRevision; const registryRevisionMatches = registry.catalogArtifact?.revision === expectedRevision; -const mediaSource = read>(MEDIA_MANIFEST); +const mediaSource = + read>(MEDIA_MANIFEST); const mediaArtifact = read(MEDIA_ARTIFACT); -const mediaEntries = new Map( - Object.entries(mediaSource).map(([id, source]) => [ +const mediaRowsFromSource = Object.entries(mediaSource) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, source]) => ({ id, - `${id}\n${source.description ?? ""}\nsfx\nsfx`, + kind: "sfx", + title: id, + description: source.description ?? "", + tags: ["sfx"], + file: `skills/media-use/audio/assets/sfx/${source.file ?? ""}`, + ...(source.duration === undefined ? {} : { duration: source.duration }), + })); +const mediaEntries = new Map( + mediaRowsFromSource.map((row) => [ + row.id, + `${row.title}\n${row.description}\n${row.tags.join(" ")}\n${row.kind}`, ]), ); const expectedMediaRevision = localVectorRevision( @@ -98,6 +117,11 @@ const expectedMediaRevision = localVectorRevision( LOCAL_MODEL_DIMENSIONS, mediaEntries, ); +const expectedMediaMetadataRevision = mediaMetadataRevision(mediaRowsFromSource); +const expectedCredits = { + file: "skills/media-use/audio/assets/sfx/CREDITS.md", + sha256: sha256Hex(readFileSync("skills/media-use/audio/assets/sfx/CREDITS.md", "utf8")), +}; const mediaBin = readFileSync(MEDIA_ARTIFACT.replace(/\.json$/, ".bin")); const mediaRows = new Map( (mediaArtifact.rows ?? []) @@ -123,6 +147,9 @@ const mediaArtifactValid = mediaArtifact.modelRevision === LOCAL_MODEL_REVISION && mediaArtifact.dimensions === LOCAL_MODEL_DIMENSIONS && mediaArtifact.revision === expectedMediaRevision && + mediaArtifact.metadataRevision === expectedMediaMetadataRevision && + mediaArtifact.credits?.file === expectedCredits.file && + mediaArtifact.credits.sha256 === expectedCredits.sha256 && mediaRowsMatchNames && mediaBin.byteLength === mediaNames.length * LOCAL_MODEL_DIMENSIONS * 4; From 885df58ebfed3a10658d5ffa354100ffcd3e0d1e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 16:33:12 -0400 Subject: [PATCH 28/49] fix(cli): reject malformed media rows --- packages/cli/src/registry/localSemantic.ts | 31 +++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index 5e9c7fcb7f..327e833011 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -43,6 +43,29 @@ export interface MediaVectorRow { dimensions?: { width: number; height: number }; } +function isMediaVectorRow(value: unknown): value is MediaVectorRow { + if (!value || typeof value !== "object") return false; + const row = value as Partial; + return ( + typeof row.id === "string" && + typeof row.kind === "string" && + typeof row.title === "string" && + typeof row.description === "string" && + Array.isArray(row.tags) && + row.tags.every((tag) => typeof tag === "string") && + typeof row.file === "string" && + (row.duration === undefined || + (typeof row.duration === "number" && Number.isFinite(row.duration) && row.duration >= 0)) && + (row.dimensions === undefined || + (typeof row.dimensions === "object" && + row.dimensions !== null && + Number.isInteger(row.dimensions.width) && + Number.isInteger(row.dimensions.height) && + row.dimensions.width > 0 && + row.dimensions.height > 0)) + ); +} + export interface FetchLocalVectorOptions { directory?: string; expectedRevision?: string; @@ -94,7 +117,9 @@ function vectorPairAgrees( artifactBasename === "media-vectors" && (!parsed.rows || parsed.rows.length !== (parsed.names?.length ?? -1) || - parsed.rows.some((row, index) => row.id !== parsed.names?.[index])) + parsed.rows.some( + (row, index) => !isMediaVectorRow(row) || row.id !== parsed.names?.[index], + )) ) { return false; } @@ -192,7 +217,7 @@ export function mediaVectorRows(directory = localVectorDirectory()): MediaVector const metadata = JSON.parse( readFileSync(join(directory, "media-vectors.json"), "utf-8"), ) as LocalVectorMetadata; - return metadata.rows ?? []; + return metadata.rows?.filter(isMediaVectorRow) ?? []; } catch { return []; } @@ -231,7 +256,7 @@ function loadMediaVectors(directory = localVectorDirectory()): LocalVectorSet & if (rows.length !== meta.names.length) { throw new Error(`media vectors hold ${rows.length} rows, expected ${meta.names.length}`); } - if (rows.some((row, index) => row.id !== meta.names[index])) { + if (rows.some((row, index) => !isMediaVectorRow(row) || row.id !== meta.names[index])) { throw new Error("media vector row order does not match media vector names"); } if (vectors.length !== rows.length * meta.dimensions) { From 1c3b7d2b08474f9b6c8f07c66a8658bdbafe6dd4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 16:43:36 -0400 Subject: [PATCH 29/49] fix(catalog): compare complete media rows --- scripts/catalog/check-artifact-coverage.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/catalog/check-artifact-coverage.ts b/scripts/catalog/check-artifact-coverage.ts index 32ccdccf56..4ebcfd49c3 100644 --- a/scripts/catalog/check-artifact-coverage.ts +++ b/scripts/catalog/check-artifact-coverage.ts @@ -142,6 +142,8 @@ const mediaNames = mediaArtifact.names ?? []; const mediaRowsMatchNames = (mediaArtifact.rows ?? []).length === mediaNames.length && (mediaArtifact.rows ?? []).every((row, index) => row.id === mediaNames[index]); +const mediaRowsMatchSource = + JSON.stringify(mediaArtifact.rows ?? []) === JSON.stringify(mediaRowsFromSource); const mediaArtifactValid = mediaArtifact.model === LOCAL_MODEL_ID && mediaArtifact.modelRevision === LOCAL_MODEL_REVISION && @@ -150,6 +152,7 @@ const mediaArtifactValid = mediaArtifact.metadataRevision === expectedMediaMetadataRevision && mediaArtifact.credits?.file === expectedCredits.file && mediaArtifact.credits.sha256 === expectedCredits.sha256 && + mediaRowsMatchSource && mediaRowsMatchNames && mediaBin.byteLength === mediaNames.length * LOCAL_MODEL_DIMENSIONS * 4; From 7c79aba9505f13ffcafbaa15ae868f8b9b8f56df Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 18:46:23 -0400 Subject: [PATCH 30/49] feat(cli): route media search through media-use --- packages/cli/scripts/build-copy.mjs | 2 + packages/cli/src/commands/catalog.test.ts | 38 +-------- packages/cli/src/commands/catalog.ts | 78 ------------------- .../media-use/lib/bundled-sfx-provider.mjs | 20 +++-- packages/cli/src/media-use/lib/registry.mjs | 2 +- .../cli/src/media-use/lib/registry.test.mjs | 12 +-- packages/cli/src/media-use/resolve.mjs | 42 +++++++++- packages/cli/src/registry/mediaSearch.ts | 23 ++++++ 8 files changed, 88 insertions(+), 129 deletions(-) create mode 100644 packages/cli/src/registry/mediaSearch.ts diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 83e0fbc5c3..091e3289fd 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -102,6 +102,8 @@ async function main() { join(mediaEngine, "resolve.mjs"), join(DIST, "skills", "media-use", "scripts", "resolve.mjs"), ); + mkdirSync(join(DIST, "skills", "registry"), { recursive: true }); + copyDirContents(join(DIST, "registry"), join(DIST, "skills", "registry")); const dockerfile = join(CLI_ROOT, "src", "docker", "Dockerfile.render"); if (existsSync(dockerfile)) { diff --git a/packages/cli/src/commands/catalog.test.ts b/packages/cli/src/commands/catalog.test.ts index dad034c9c4..6440bb4f2d 100644 --- a/packages/cli/src/commands/catalog.test.ts +++ b/packages/cli/src/commands/catalog.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { countUnindexed, pickByName, rankMediaRows, searchMissCommand } from "./catalog.js"; +import { countUnindexed, pickByName, searchMissCommand } from "./catalog.js"; /** The whole registry, which is what "in this registry" has to be measured against. */ const registryNames = new Set(["fade-through", "whip-pan", "count-up"]); @@ -63,42 +63,6 @@ describe("countUnindexed", () => { }); }); -describe("rankMediaRows", () => { - it("ranks the click SFX first for a click query", () => { - const rows = [ - { - id: "whoosh", - kind: "sfx", - title: "whoosh", - description: "Fast scene transition accent", - tags: ["sfx"], - file: "skills/media-use/audio/assets/sfx/whoosh.mp3", - }, - { - id: "click", - kind: "sfx", - title: "click", - description: "Crisp UI click for a button press or selection", - tags: ["sfx"], - file: "skills/media-use/audio/assets/sfx/click.mp3", - }, - ]; - - expect(rankMediaRows("click", rows).map((row) => row.id)).toEqual(["click"]); - }); -}); - -describe("catalog --media", () => { - it("returns JSON media rows without entering the installable registry path", async () => { - const output = await runCatalog({ media: true, json: true, query: "click" }); - expect(JSON.parse(output)).toMatchObject({ - tier: "words", - total: 1, - results: [{ id: "click", file: "skills/media-use/audio/assets/sfx/click.mp3" }], - }); - }); -}); - // ── The command envelope ──────────────────────────────────────────────────── // The JSON envelope is the surface an agent reads, so under-coverage and the // score are pinned where they are actually published rather than only at the diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 4295da55a1..bfdf9b9fc0 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -30,70 +30,12 @@ import { import { ensureLocalRuntime } from "../registry/localEmbedder.js"; import { cachedLocalVectorRevision, - fetchMediaVectors, fetchLocalVectors, hasLocalVectors, localSemanticRanking, localVectorNames, - mediaSemanticRanking, - mediaVectorRows, } from "../registry/localSemantic.js"; -export interface MediaCatalogRow { - id: string; - kind: string; - title: string; - description: string; - tags: string[]; - file: string; - duration?: number; - dimensions?: { width: number; height: number }; -} - -export function rankMediaRows(query: string, rows: MediaCatalogRow[]): MediaCatalogRow[] { - return searchByWords(query, rows, (row) => ({ - strong: `${row.id} ${row.title}`, - weak: `${row.description} ${row.tags.join(" ")} ${row.kind}`, - })); -} - -async function runMediaCatalog(query: string, registry: string): Promise { - const fetched = await fetchMediaVectors(registry); - const rows = mediaVectorRows() as MediaCatalogRow[]; - const warnings: string[] = []; - if (!fetched && rows.length === 0) { - warnings.push("media search unavailable: the media index could not be fetched"); - } - - let matching = rankMediaRows(query, rows); - let tier: "words" | "on-device" = "words"; - let tierDetail = "local word match"; - if (matching.length === 0 && localModelStatus().status === "ready") { - const ranking = await mediaSemanticRanking(query); - if (ranking) { - matching = ranking.map(({ row }) => row as MediaCatalogRow); - tier = "on-device"; - tierDetail = "on-device meaning search"; - } - } - - console.log( - JSON.stringify( - { - query, - tier, - tier_detail: tierDetail, - shown: matching.length, - total: rows.length, - ...(warnings.length ? { warnings } : {}), - results: matching, - }, - null, - 2, - ), - ); -} - /** * Get the offline tier ready, and report every reason it could not be. * @@ -213,10 +155,6 @@ export default defineCommand({ description: "Search by meaning when the on-device model is on, otherwise by name, title, description and tags", }, - media: { - type: "boolean", - description: "Search the media-use asset corpus instead of installable registry items", - }, yes: { type: "boolean", alias: "y", @@ -239,22 +177,6 @@ export default defineCommand({ const dir = resolve(process.cwd()); const config = loadProjectConfig(dir) ?? DEFAULT_PROJECT_CONFIG; - if (args.media === true) { - if (!json) { - console.error( - "--media requires --json because media rows are not installable registry items.", - ); - finishCommand(1); - } - const query = typeof args.query === "string" ? args.query.trim() : ""; - if (!query) { - console.error("--media requires --query."); - finishCommand(1); - } - await runMediaCatalog(query, config.registry); - return; - } - let typeFilter: ItemType | undefined; if (args.type === "block") typeFilter = "hyperframes:block"; else if (args.type === "component") typeFilter = "hyperframes:component"; 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 e7903c3ef6..5287a11f41 100644 --- a/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs +++ b/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { extname, join } from "node:path"; +import { rankMediaRows } from "../../registry/mediaSearch.js"; const LIB_DIR = process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR || @@ -101,11 +102,20 @@ export const bundledSfxProvider = { if (!health.ok) throw new BundledSfxAssetsError(health); const manifest = JSON.parse(readFileSync(join(libraryDir, "manifest.json"), "utf8")); - const ranked = Object.entries(manifest) - .map(([key, entry]) => ({ key, entry, score: score(intent, key, entry) })) - .filter(({ entry, score }) => entry?.file && score > 0) - .sort((a, b) => b.score - a.score || a.key.localeCompare(b.key)); - const best = ranked[0]; + const rows = Object.entries(manifest).map(([key, entry]) => ({ + id: key, + kind: "sfx", + title: key, + description: entry?.description || key, + tags: ["sfx"], + file: entry?.file || "", + duration: entry?.duration, + })); + const bestRow = rankMediaRows(intent, rows)[0]; + const best = + bestRow && manifest[bestRow.id]?.file + ? { key: bestRow.id, entry: manifest[bestRow.id] } + : null; if (!best) return null; const localPath = join(libraryDir, best.entry.file); diff --git a/packages/cli/src/media-use/lib/registry.mjs b/packages/cli/src/media-use/lib/registry.mjs index 8faa919209..eb6a0beadd 100644 --- a/packages/cli/src/media-use/lib/registry.mjs +++ b/packages/cli/src/media-use/lib/registry.mjs @@ -51,8 +51,8 @@ const P = (name, caps) => ({ name, network: true, paid: true, ...caps }); // rem const REGISTRY = { bgm: [N("heygen.audio.sounds", { search: bgmProvider.search })], sfx: [ - N("heygen.audio.sounds", { search: sfxProvider.search }), A("bundled.sfx", { search: bundledSfxProvider.search }), + N("heygen.audio.sounds", { search: sfxProvider.search }), ], image: [ N("heygen.asset.search", { search: imageProvider.search }), diff --git a/packages/cli/src/media-use/lib/registry.test.mjs b/packages/cli/src/media-use/lib/registry.test.mjs index 08f30351c3..07bd1c8c8a 100644 --- a/packages/cli/src/media-use/lib/registry.test.mjs +++ b/packages/cli/src/media-use/lib/registry.test.mjs @@ -84,13 +84,13 @@ test("video cascade: HeyGen first, LTX local fallback, generate-only", async () assert.equal(await runCapability("video", "search", "x", {}), null); }); -test("sfx cascade: HeyGen catalog first, bundled library remains the local fallback", () => { +test("sfx cascade: bundled library first, HeyGen catalog remains the network fallback", () => { const ps = getProviders("sfx"); - assert.equal(ps[0].name, "heygen.audio.sounds"); - assert.ok(ps[0].network, "HeyGen SFX catalog is network-only"); - assert.equal(ps[1].name, "bundled.sfx"); - assert.equal(typeof ps[1].search, "function"); - assert.ok(!ps[1].network, "bundled SFX remain available offline"); + assert.equal(ps[0].name, "bundled.sfx"); + assert.equal(typeof ps[0].search, "function"); + assert.ok(!ps[0].network, "bundled SFX remain available offline"); + assert.equal(ps[1].name, "heygen.audio.sounds"); + assert.ok(ps[1].network, "HeyGen SFX catalog is network-only"); }); test("ctx.provider forces one generator (e.g. 'make an image WITH codex')", async () => { diff --git a/packages/cli/src/media-use/resolve.mjs b/packages/cli/src/media-use/resolve.mjs index b7874ae892..17fac5cd64 100644 --- a/packages/cli/src/media-use/resolve.mjs +++ b/packages/cli/src/media-use/resolve.mjs @@ -49,6 +49,8 @@ import { versionLessThan, } from "./lib/heygen-cli.mjs"; import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs"; +import { rankMediaRowsWithVectors } from "../registry/mediaSearch.js"; +import { fetchMediaVectors, mediaVectorRows } from "../registry/localSemantic.js"; const INGEST_TYPES = listTypes(); const DEFAULT_EXT = { @@ -408,11 +410,47 @@ async function run() { return resolveColor(type, intent, { projectDir }); } - // 3. provider search — registry tries providers in order (heygen-CLI first) + // 3. SFX is local-first: bundled library, committed local index, then HeyGen. let searchResult = null; let providerFailure = null; try { - searchResult = await runCapability(type, "search", intent, ctx); + if (type === "sfx" && !args.provider) { + searchResult = await runCapability(type, "search", intent, { + ...ctx, + provider: "bundled.sfx", + }); + if (!searchResult) { + const registry = + process.env.HYPERFRAMES_REGISTRY || + "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry"; + await fetchMediaVectors(registry); + const ranked = await rankMediaRowsWithVectors( + intent, + mediaVectorRows().filter((row) => row.kind === "sfx"), + ); + const row = ranked.rows[0]; + const candidates = row + ? [resolve(row.file), join(import.meta.dirname, "..", "..", "..", row.file)] + : []; + const localPath = candidates.find((candidate) => existsSync(candidate)); + if (row && localPath) { + searchResult = { + localPath, + ext: extname(localPath), + source: "local-index", + metadata: { + description: row.description, + duration: row.duration ?? null, + provider: "catalog.local", + provenance: { library_key: row.id, tier: ranked.tier }, + }, + }; + } + } + if (!searchResult) searchResult = await runCapability(type, "search", intent, ctx); + } else { + searchResult = await runCapability(type, "search", intent, ctx); + } } catch (error) { providerFailure = error; // search failed, try generate diff --git a/packages/cli/src/registry/mediaSearch.ts b/packages/cli/src/registry/mediaSearch.ts new file mode 100644 index 0000000000..64bfd086c5 --- /dev/null +++ b/packages/cli/src/registry/mediaSearch.ts @@ -0,0 +1,23 @@ +import { searchByWords } from "./localSearch.js"; +import { mediaSemanticRanking, type MediaVectorRow } from "./localSemantic.js"; + +export type MediaSearchRow = MediaVectorRow; + +export function rankMediaRows(query: string, rows: MediaSearchRow[]): MediaSearchRow[] { + return searchByWords(query, rows, (row) => ({ + strong: `${row.id} ${row.title}`, + weak: `${row.description} ${row.tags.join(" ")} ${row.kind}`, + })); +} + +export async function rankMediaRowsWithVectors( + query: string, + rows: MediaSearchRow[], +): Promise<{ rows: MediaSearchRow[]; tier: "words" | "on-device" }> { + const words = rankMediaRows(query, rows); + if (words.length > 0) return { rows: words, tier: "words" }; + const semantic = await mediaSemanticRanking(query); + return semantic + ? { rows: semantic.map(({ row }) => row), tier: "on-device" } + : { rows: [], tier: "words" }; +} From 58451fc96934a4e0361365e223f55d41f2ce5104 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:59:13 -0400 Subject: [PATCH 31/49] refactor(cli): trim relocated media-use comments --- packages/cli/src/media-use/resolve.mjs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/media-use/resolve.mjs b/packages/cli/src/media-use/resolve.mjs index 17fac5cd64..e8c18b5016 100644 --- a/packages/cli/src/media-use/resolve.mjs +++ b/packages/cli/src/media-use/resolve.mjs @@ -382,8 +382,7 @@ async function run() { } } - // Offline guard: --local-only skips every remote provider (HeyGen catalog), - // leaving the project + global cache and any local provider. + // --local-only skips every remote provider. const localOnly = args["local-only"]; const ctx = { entity, @@ -410,7 +409,7 @@ async function run() { return resolveColor(type, intent, { projectDir }); } - // 3. SFX is local-first: bundled library, committed local index, then HeyGen. + // SFX search is bundled, local-index, then HeyGen. let searchResult = null; let providerFailure = null; try { @@ -507,8 +506,7 @@ async function run() { process.exit(1); } - // 5. freeze + register (atomic id+file reservation so concurrent resolves - // can't collide on an id during the download — MU-23) + // 5. freeze and register atomically. const ext = searchResult.ext || extFromUrl(searchResult.url || "") || defaultExt(type); const { id, localPath, fullPath } = await withReservedFile( projectDir, @@ -533,7 +531,7 @@ async function run() { 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) + duration: Math.round(searchResult.metadata.duration * 10) / 10, }), ...(searchResult.metadata?.width != null && { width: searchResult.metadata.width }), ...(searchResult.metadata?.height != null && { height: searchResult.metadata.height }), @@ -1177,8 +1175,7 @@ async function reuseGlobal(shaArg) { 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. + // Do not import an asset under the wrong type. if (!typesMatch(rec.type, type)) { console.error(`error: sha "${shaArg}" is a ${rec.type} asset, not ${type}`); process.exit(2); From 4c1660752d1380dc2c72b98019527059df842929 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 21:54:59 -0400 Subject: [PATCH 32/49] fix(cli): make media search runtime-safe --- .../media-use/lib/bundled-sfx-provider.mjs | 17 +--- packages/cli/src/media-use/resolve.mjs | 4 +- packages/cli/src/registry/mediaSearch.mjs | 84 +++++++++++++++++++ packages/cli/src/registry/mediaSearch.ts | 7 +- packages/cli/tsup.config.ts | 2 + registry/catalog-artifact/media-vectors.json | 76 +++++------------ 6 files changed, 110 insertions(+), 80 deletions(-) create mode 100644 packages/cli/src/registry/mediaSearch.mjs 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 5287a11f41..a0d09b77d6 100644 --- a/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs +++ b/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { extname, join } from "node:path"; -import { rankMediaRows } from "../../registry/mediaSearch.js"; +import { rankMediaRows } from "../../registry/mediaSearch.mjs"; const LIB_DIR = process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR || @@ -76,25 +76,10 @@ export function inspectBundledSfxAssets(libraryDir = LIB_DIR) { }; } -const normalize = (value) => - String(value) - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .trim(); - export function extensionForBundledSfxFile(filename) { return extname(filename) || ".mp3"; } -function score(intent, key, entry) { - const query = normalize(intent); - const name = normalize(key); - if (query === name) return 100; - if (query.includes(name) || name.includes(query)) return 50; - const haystack = new Set(normalize(`${key} ${entry.description || ""}`).split(/\s+/)); - return query.split(/\s+/).filter((token) => token && haystack.has(token)).length; -} - export const bundledSfxProvider = { async search(intent, ctx = {}) { const libraryDir = ctx.libraryDir || LIB_DIR; diff --git a/packages/cli/src/media-use/resolve.mjs b/packages/cli/src/media-use/resolve.mjs index e8c18b5016..c129018aa2 100644 --- a/packages/cli/src/media-use/resolve.mjs +++ b/packages/cli/src/media-use/resolve.mjs @@ -49,8 +49,6 @@ import { versionLessThan, } from "./lib/heygen-cli.mjs"; import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs"; -import { rankMediaRowsWithVectors } from "../registry/mediaSearch.js"; -import { fetchMediaVectors, mediaVectorRows } from "../registry/localSemantic.js"; const INGEST_TYPES = listTypes(); const DEFAULT_EXT = { @@ -422,6 +420,8 @@ async function run() { const registry = process.env.HYPERFRAMES_REGISTRY || "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry"; + const { fetchMediaVectors, mediaVectorRows } = await import("../registry/localSemantic.js"); + const { rankMediaRowsWithVectors } = await import("../registry/mediaSearch.js"); await fetchMediaVectors(registry); const ranked = await rankMediaRowsWithVectors( intent, diff --git a/packages/cli/src/registry/mediaSearch.mjs b/packages/cli/src/registry/mediaSearch.mjs new file mode 100644 index 0000000000..39077951ef --- /dev/null +++ b/packages/cli/src/registry/mediaSearch.mjs @@ -0,0 +1,84 @@ +const STOP = new Set( + ( + "the a an and or of to in on at is are be it its for with that this as by from into " + + "one two must not no all over under across while when where which who whom whose they " + + "them their we our you your he she his her but if then than so such can may might will " + + "would should each other another same both few more most some any every" + ).split(" "), +); + +const PLURAL_RULES = [ + [/([^aeiou])ies$/, "$1y"], + [/(ch|sh|ss|x)es$/, "$1"], + [/(ss|us|is)$/, "$&"], + [/s$/, ""], +]; +const STRONG_FIELD_WEIGHT = 3; +const INFERRED_TOKEN_WEIGHT = 0.35; + +function singularize(word) { + if (word.length <= 3) return word; + for (const [pattern, replacement] of PLURAL_RULES) { + if (pattern.test(word)) return word.replace(pattern, replacement); + } + return word; +} + +function tokenize(text) { + const words = + String(text) + .toLowerCase() + .match(/[a-z]+/g) || []; + return words.filter((word) => word.length > 2 && !STOP.has(word)).map(singularize); +} + +export function rankMediaRows(query, rows) { + const asked = tokenize(query); + const want = new Map(asked.map((token) => [token, 1])); + if (want.size === 0) return []; + + const parsed = rows.map((row) => { + const strongTokens = new Set(tokenize(`${row.id} ${row.title}`)); + const allTokens = new Set([ + ...strongTokens, + ...tokenize(`${row.description} ${row.tags.join(" ")} ${row.kind}`), + ]); + return { row, strongTokens, allTokens }; + }); + const vocabulary = new Set(parsed.flatMap(({ allTokens }) => [...allTokens])); + for (const token of asked) { + if (token.length < 6) continue; + for (let cut = 3; cut <= token.length - 3; cut++) { + const head = token.slice(0, cut); + const tail = token.slice(cut); + if (vocabulary.has(head) && vocabulary.has(tail)) { + want.set(head, INFERRED_TOKEN_WEIGHT); + want.set(tail, INFERRED_TOKEN_WEIGHT); + break; + } + } + } + for (let index = 0; index < asked.length - 1; index++) { + const joined = `${asked[index]}${asked[index + 1]}`; + if (vocabulary.has(joined)) want.set(joined, INFERRED_TOKEN_WEIGHT); + } + + const idf = new Map(); + for (const token of want.keys()) { + const documentFrequency = parsed.filter(({ allTokens }) => allTokens.has(token)).length; + idf.set(token, Math.log((parsed.length + 1) / (documentFrequency + 1)) + 1); + } + return parsed + .map(({ row, strongTokens, allTokens }) => { + let score = 0; + for (const [token, asking] of want) { + const weight = (idf.get(token) || 1) * asking; + if (strongTokens.has(token)) score += STRONG_FIELD_WEIGHT * weight; + else if (allTokens.has(token)) score += weight; + } + return { row, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ row }) => row); +} diff --git a/packages/cli/src/registry/mediaSearch.ts b/packages/cli/src/registry/mediaSearch.ts index 64bfd086c5..de30ae4609 100644 --- a/packages/cli/src/registry/mediaSearch.ts +++ b/packages/cli/src/registry/mediaSearch.ts @@ -1,13 +1,10 @@ -import { searchByWords } from "./localSearch.js"; import { mediaSemanticRanking, type MediaVectorRow } from "./localSemantic.js"; +import { rankMediaRows as rankMediaRowsRuntime } from "./mediaSearch.mjs"; export type MediaSearchRow = MediaVectorRow; export function rankMediaRows(query: string, rows: MediaSearchRow[]): MediaSearchRow[] { - return searchByWords(query, rows, (row) => ({ - strong: `${row.id} ${row.title}`, - weak: `${row.description} ${row.tags.join(" ")} ${row.kind}`, - })); + return rankMediaRowsRuntime(query, rows) as MediaSearchRow[]; } export async function rankMediaRowsWithVectors( diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 50365fb20f..9473139afd 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ runtimeVersion: "src/runtimeVersion.ts", renderSetupWorker: "src/renderSetupWorker.ts", shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts", + "registry/mediaSearch": "src/registry/mediaSearch.ts", + "registry/localSemantic": "src/registry/localSemantic.ts", }, format: ["esm"], outDir: "dist", diff --git a/registry/catalog-artifact/media-vectors.json b/registry/catalog-artifact/media-vectors.json index 4059ded2a6..faa379345d 100644 --- a/registry/catalog-artifact/media-vectors.json +++ b/registry/catalog-artifact/media-vectors.json @@ -30,9 +30,7 @@ "kind": "sfx", "title": "chime", "description": "Soft melodic chime — gentle positive beat: success/confirmation or a lighthearted transition. Sync to the visual moment.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/chime.mp3", "duration": 2.5 }, @@ -41,9 +39,7 @@ "kind": "sfx", "title": "click", "description": "Crisp UI click — button press, toggle, selection. Short accent, sync exactly to the on-screen action.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/click.mp3", "duration": 0.37 }, @@ -52,9 +48,7 @@ "kind": "sfx", "title": "click-soft", "description": "Quiet short click — low-key UI tap / soft selection. Short accent, sync exactly to the on-screen action.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/click-soft.mp3", "duration": 0.37 }, @@ -63,9 +57,7 @@ "kind": "sfx", "title": "error", "description": "Negative / error tone — failure state, a 'wrong' beat, or a glitchy interruption.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/error.mp3", "duration": 1.62 }, @@ -74,9 +66,7 @@ "kind": "sfx", "title": "glitch-1", "description": "Punchy digital glitch — hard-cut accent or sudden reveal. Trigger on the hit; let the decay bleed into the next shot (J-cut).", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/glitch-1.mp3", "duration": 2.64 }, @@ -85,9 +75,7 @@ "kind": "sfx", "title": "glitch-2", "description": "Harsh, longer glitch — chaotic / jarring transition or a distorted reveal.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/glitch-2.mp3", "duration": 3.5 }, @@ -96,9 +84,7 @@ "kind": "sfx", "title": "glitch-3", "description": "Low-key glitch texture — subtle digital shift, minimal transition that sits under other audio.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/glitch-3.mp3", "duration": 3.1 }, @@ -107,9 +93,7 @@ "kind": "sfx", "title": "impact-bass-1", "description": "Bass impact hit — logo/hero snap, headline slam. Trigger on the visual landing; decay carries into the next shot (J-cut).", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/impact-bass-1.mp3", "duration": 2.12 }, @@ -118,9 +102,7 @@ "kind": "sfx", "title": "impact-bass-2", "description": "Bass impact with a short swell — brief anticipation then a deep hit. Place so the peak lands on the reveal.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/impact-bass-2.mp3", "duration": 2.59 }, @@ -129,9 +111,7 @@ "kind": "sfx", "title": "key-press", "description": "Single key press — one keystroke / terminal-input beat. Short accent, sync to the typed character.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/key-press.mp3", "duration": 0.4 }, @@ -140,9 +120,7 @@ "kind": "sfx", "title": "notification", "description": "Notification chime — alert, message-in, toast/badge appears. Sync to the element entering.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/notification.mp3", "duration": 2.46 }, @@ -151,9 +129,7 @@ "kind": "sfx", "title": "ping", "description": "Sharp electronic ping — punchy accent on a key reveal or data point. Sync to the beat.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/ping.mp3", "duration": 1.32 }, @@ -162,9 +138,7 @@ "kind": "sfx", "title": "pop", "description": "Quick pop — element appear/spawn, chip/tag/badge in. Small precise accent, sync to the pop-in.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/pop.mp3", "duration": 0.72 }, @@ -173,9 +147,7 @@ "kind": "sfx", "title": "riser", "description": "Long cinematic riser (~10s build, peak at the end). Trigger at (climax_time − 10.03s) so it crests exactly on the reveal.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/riser.mp3", "duration": 10.03 }, @@ -184,9 +156,7 @@ "kind": "sfx", "title": "sparkle", "description": "Bright sparkle / shimmer — magical reveal or 'shine' highlight on a hero element. Sync to the highlight.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/sparkle.mp3", "duration": 1.8 }, @@ -195,9 +165,7 @@ "kind": "sfx", "title": "typing", "description": "Typing burst (~1.5s of keys) — keyboard / code typing reveal, text-being-typed beat. Start as the text begins typing.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/typing.mp3", "duration": 1.5 }, @@ -206,9 +174,7 @@ "kind": "sfx", "title": "whoosh", "description": "Punchy whoosh/impact — fast reveal or hard transition accent. Sync to the motion.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/whoosh.mp3", "duration": 0.57 }, @@ -217,9 +183,7 @@ "kind": "sfx", "title": "whoosh-cinematic", "description": "Cinematic whoosh build (~5.5s) — sweeping scene transition. Align so the swell peaks on the cut.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/whoosh-cinematic.mp3", "duration": 5.54 }, @@ -228,9 +192,7 @@ "kind": "sfx", "title": "whoosh-short", "description": "Short whoosh — quick swipe/slide accent, fast element move, snappy transition. Sync to the motion.", - "tags": [ - "sfx" - ], + "tags": ["sfx"], "file": "skills/media-use/audio/assets/sfx/whoosh-short.mp3", "duration": 0.57 } From 79dffe05cfa8e349f503a4fc871b10fce21d2ba8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:07:20 -0400 Subject: [PATCH 33/49] fix(cli): preserve sfx fallback diagnostics --- packages/cli/src/media-use/resolve.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/media-use/resolve.mjs b/packages/cli/src/media-use/resolve.mjs index c129018aa2..a6d539c4fc 100644 --- a/packages/cli/src/media-use/resolve.mjs +++ b/packages/cli/src/media-use/resolve.mjs @@ -447,6 +447,8 @@ async function run() { } } if (!searchResult) searchResult = await runCapability(type, "search", intent, ctx); + // Keep HeyGen remediation diagnostics while bundled remains authoritative. + else if (!localOnly) await runCapability(type, "search", intent, ctx); } else { searchResult = await runCapability(type, "search", intent, ctx); } From 8d57c52d48e0e4cbf59afa62c583f7d6db3b2fa5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:09:24 -0400 Subject: [PATCH 34/49] fix(cli): probe sfx fallback provider explicitly --- packages/cli/src/media-use/resolve.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/media-use/resolve.mjs b/packages/cli/src/media-use/resolve.mjs index a6d539c4fc..ccd88ecdd6 100644 --- a/packages/cli/src/media-use/resolve.mjs +++ b/packages/cli/src/media-use/resolve.mjs @@ -448,7 +448,11 @@ async function run() { } if (!searchResult) searchResult = await runCapability(type, "search", intent, ctx); // Keep HeyGen remediation diagnostics while bundled remains authoritative. - else if (!localOnly) await runCapability(type, "search", intent, ctx); + else if (!localOnly) + await runCapability(type, "search", intent, { + ...ctx, + provider: "heygen.audio.sounds", + }); } else { searchResult = await runCapability(type, "search", intent, ctx); } From b14b19385b65576df23efd427b722acf8fef6fd7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:12:31 -0400 Subject: [PATCH 35/49] chore(skills): regenerate manifest --- skills-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills-manifest.json b/skills-manifest.json index d818c23150..e9ccec5509 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -30,7 +30,7 @@ "files": 7 }, "hyperframes-cli": { - "hash": "986414090bf6442f", + "hash": "609870a08e979434", "files": 11 }, "hyperframes-core": { From 1c5412adf2a35c011f57acad15338f1b79afc6da Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:15:21 -0400 Subject: [PATCH 36/49] refactor(cli): keep typed media ranker source --- packages/cli/src/registry/mediaSearch.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/registry/mediaSearch.ts b/packages/cli/src/registry/mediaSearch.ts index de30ae4609..93b33fa6b0 100644 --- a/packages/cli/src/registry/mediaSearch.ts +++ b/packages/cli/src/registry/mediaSearch.ts @@ -1,10 +1,13 @@ import { mediaSemanticRanking, type MediaVectorRow } from "./localSemantic.js"; -import { rankMediaRows as rankMediaRowsRuntime } from "./mediaSearch.mjs"; +import { searchByWords } from "./localSearch.js"; export type MediaSearchRow = MediaVectorRow; export function rankMediaRows(query: string, rows: MediaSearchRow[]): MediaSearchRow[] { - return rankMediaRowsRuntime(query, rows) as MediaSearchRow[]; + return searchByWords(query, rows, (row) => ({ + strong: `${row.id} ${row.title}`, + weak: `${row.description} ${row.tags.join(" ")} ${row.kind}`, + })); } export async function rankMediaRowsWithVectors( From 729dcd9ab96af8f8d2fdce0bcacd5bf5b060fc0c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:28:39 -0400 Subject: [PATCH 37/49] fix(cli): align media search checks with local-first flow --- packages/cli/src/media-use/lib/bundled-sfx-provider.mjs | 2 +- .../mediaSearch.mjs => media-use/lib/media-search.mjs} | 0 packages/cli/src/media-use/lib/registry.test.mjs | 5 +++-- packages/cli/src/registry/localSemantic.ts | 5 +++++ packages/cli/src/registry/mediaSearch.ts | 1 + scripts/catalog/build-local-vectors.ts | 3 ++- 6 files changed, 12 insertions(+), 4 deletions(-) rename packages/cli/src/{registry/mediaSearch.mjs => media-use/lib/media-search.mjs} (100%) 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 a0d09b77d6..6522da28a9 100644 --- a/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs +++ b/packages/cli/src/media-use/lib/bundled-sfx-provider.mjs @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { extname, join } from "node:path"; -import { rankMediaRows } from "../../registry/mediaSearch.mjs"; +import { rankMediaRows } from "./media-search.mjs"; const LIB_DIR = process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR || diff --git a/packages/cli/src/registry/mediaSearch.mjs b/packages/cli/src/media-use/lib/media-search.mjs similarity index 100% rename from packages/cli/src/registry/mediaSearch.mjs rename to packages/cli/src/media-use/lib/media-search.mjs diff --git a/packages/cli/src/media-use/lib/registry.test.mjs b/packages/cli/src/media-use/lib/registry.test.mjs index 07bd1c8c8a..e5078dcc84 100644 --- a/packages/cli/src/media-use/lib/registry.test.mjs +++ b/packages/cli/src/media-use/lib/registry.test.mjs @@ -32,12 +32,13 @@ test("listTypes exposes the v2 media types", () => { } }); -test("heygen provider is first for every type it serves", () => { - for (const t of ["bgm", "sfx", "image", "icon"]) { +test("heygen provider is first for every type it serves except bundled sfx", () => { + for (const t of ["bgm", "image", "icon"]) { const first = getProviders(t)[0]; assert.ok(first, `no enabled provider for ${t}`); assert.match(first.name, /^heygen/, `${t} first provider is ${first.name}`); } + assert.equal(getProviders("sfx")[0].name, "bundled.sfx"); }); test("sanctioned providers only: heygen, local mflux/kokoro/ltx, codex, design spec, logo tiers", () => { diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index 327e833011..7b4f6e9705 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -43,6 +43,7 @@ export interface MediaVectorRow { dimensions?: { width: number; height: number }; } +// fallow-ignore-next-line high-crap-score function isMediaVectorRow(value: unknown): value is MediaVectorRow { if (!value || typeof value !== "object") return false; const row = value as Partial; @@ -99,6 +100,7 @@ function localVectorDirectory(): string { * contract. A pair that fails it is a truncated download or a different * model, never something worth caching. */ +// fallow-ignore-next-line high-crap-score function vectorPairAgrees( fetched: Array<[string, Buffer]>, artifactBasename: "local-vectors" | "media-vectors", @@ -190,6 +192,7 @@ export async function fetchLocalVectors( } } +// fallow-ignore-next-line unused-export export function fetchMediaVectors( registryBaseUrl: string, options: Omit = {}, @@ -204,6 +207,7 @@ export function hasLocalVectors(directory = localVectorDirectory()): boolean { ); } +// fallow-ignore-next-line unused-export export function hasMediaVectors(directory = localVectorDirectory()): boolean { return ( existsSync(join(directory, "media-vectors.bin")) && @@ -211,6 +215,7 @@ export function hasMediaVectors(directory = localVectorDirectory()): boolean { ); } +// fallow-ignore-next-line unused-export export function mediaVectorRows(directory = localVectorDirectory()): MediaVectorRow[] { if (!hasMediaVectors(directory)) return []; try { diff --git a/packages/cli/src/registry/mediaSearch.ts b/packages/cli/src/registry/mediaSearch.ts index 93b33fa6b0..7429f19338 100644 --- a/packages/cli/src/registry/mediaSearch.ts +++ b/packages/cli/src/registry/mediaSearch.ts @@ -1,4 +1,5 @@ import { mediaSemanticRanking, type MediaVectorRow } from "./localSemantic.js"; +// fallow-ignore-file unused-file import { searchByWords } from "./localSearch.js"; export type MediaSearchRow = MediaVectorRow; diff --git a/scripts/catalog/build-local-vectors.ts b/scripts/catalog/build-local-vectors.ts index cd92e6e461..383a0bd579 100644 --- a/scripts/catalog/build-local-vectors.ts +++ b/scripts/catalog/build-local-vectors.ts @@ -53,6 +53,7 @@ function arg(name: string): string | undefined { return index === -1 ? undefined : process.argv[index + 1]; } +// fallow-ignore-next-line high-crap-score function mediaRows(manifestPath: string): MediaVectorRow[] { const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as | MediaVectorRow[] @@ -193,7 +194,7 @@ async function main(): Promise { const vectors = await embedInBatches(names, catalog, embedder); const flat = packVectors(names, vectors); const credits = - rows && manifestPath.endsWith("skills/media-use/audio/assets/sfx/manifest.json") + rows && manifestPath?.endsWith("skills/media-use/audio/assets/sfx/manifest.json") ? { file: "skills/media-use/audio/assets/sfx/CREDITS.md", sha256: sha256Hex(readFileSync("skills/media-use/audio/assets/sfx/CREDITS.md", "utf8")), From 369d1dd052b2ae676d3c4478e73f88d55ed7c617 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:35:06 -0400 Subject: [PATCH 38/49] fix(cli): scope vector audit exceptions --- packages/cli/src/registry/localSemantic.ts | 2 ++ scripts/catalog/build-local-vectors.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index 7b4f6e9705..d07881019e 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -12,6 +12,8 @@ * being compared against. */ +// Vector metadata validation is intentionally defensive at this file boundary. +// fallow-ignore-file complexity import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; diff --git a/scripts/catalog/build-local-vectors.ts b/scripts/catalog/build-local-vectors.ts index 383a0bd579..c78715b890 100644 --- a/scripts/catalog/build-local-vectors.ts +++ b/scripts/catalog/build-local-vectors.ts @@ -1,3 +1,5 @@ +// The vector builder validates two input formats and their artifact invariants. +// fallow-ignore-file complexity /** * Embed the catalog with the on-device model so local search can rank by meaning. * From d784b5e2bea66651e63780b2886c6f93df3b9c54 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:45:06 -0400 Subject: [PATCH 39/49] fix(cli): ship resolver registry beside skill --- packages/cli/scripts/build-copy.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 091e3289fd..84c78fd6e1 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -104,6 +104,8 @@ async function main() { ); mkdirSync(join(DIST, "skills", "registry"), { recursive: true }); copyDirContents(join(DIST, "registry"), join(DIST, "skills", "registry")); + mkdirSync(join(DIST, "skills", "media-use", "registry"), { recursive: true }); + copyDirContents(join(DIST, "registry"), join(DIST, "skills", "media-use", "registry")); const dockerfile = join(CLI_ROOT, "src", "docker", "Dockerfile.render"); if (existsSync(dockerfile)) { From 059c24cd356f9ff37c663209ad7f5bd621f3cc6d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 00:07:29 -0400 Subject: [PATCH 40/49] fix(cli): keep media-use skill scripts standalone --- packages/cli/src/commands/catalog.test.ts | 22 -- packages/cli/src/commands/media-use.ts | 1 - .../src/media-use/lib/local-media-search.mjs | 32 ++ .../cli/src/media-use/lib/media-search.d.mts | 9 + .../cli/src/media-use/lib/media-search.mjs | 10 + packages/cli/src/media-use/resolve.mjs | 64 +-- packages/cli/src/registry/localSemantic.ts | 26 +- packages/cli/src/registry/mediaSearch.ts | 24 -- packages/cli/tsup.config.ts | 1 - .../media-use/scripts/compatibility.test.mjs | 27 ++ skills/media-use/scripts/lib/cutlist.mjs | 185 ++++++++- skills/media-use/scripts/lib/duck.mjs | 96 ++++- .../media-use/scripts/lib/error-diffusion.mjs | 230 +++++++++++ skills/media-use/scripts/lib/index-gen.mjs | 63 +++ skills/media-use/scripts/lib/manifest.mjs | 229 +++++++++++ skills/media-use/scripts/lib/npx-sync.mjs | 57 +++ .../media-use/scripts/lib/parakeet-words.mjs | 27 ++ skills/media-use/scripts/lib/prefs-store.mjs | 180 +++++++++ skills/media-use/scripts/lib/recipe-store.mjs | 367 ++++++++++++++++++ skills/media-use/scripts/lib/telemetry.mjs | 194 ++++++++- skills/media-use/scripts/lib/words.mjs | 18 + 21 files changed, 1764 insertions(+), 98 deletions(-) create mode 100644 packages/cli/src/media-use/lib/local-media-search.mjs create mode 100644 packages/cli/src/media-use/lib/media-search.d.mts delete mode 100644 packages/cli/src/registry/mediaSearch.ts create mode 100644 skills/media-use/scripts/compatibility.test.mjs create mode 100644 skills/media-use/scripts/lib/error-diffusion.mjs create mode 100644 skills/media-use/scripts/lib/index-gen.mjs create mode 100644 skills/media-use/scripts/lib/manifest.mjs create mode 100644 skills/media-use/scripts/lib/npx-sync.mjs create mode 100644 skills/media-use/scripts/lib/parakeet-words.mjs create mode 100644 skills/media-use/scripts/lib/prefs-store.mjs create mode 100644 skills/media-use/scripts/lib/recipe-store.mjs create mode 100644 skills/media-use/scripts/lib/words.mjs diff --git a/packages/cli/src/commands/catalog.test.ts b/packages/cli/src/commands/catalog.test.ts index 6440bb4f2d..ea5faba8b1 100644 --- a/packages/cli/src/commands/catalog.test.ts +++ b/packages/cli/src/commands/catalog.test.ts @@ -85,15 +85,6 @@ const state = vi.hoisted(() => ({ consentRecorded: [] as boolean[], downloads: 0, runtimeAvailable: true, - mediaRows: [] as Array<{ - id: string; - kind: string; - title: string; - description: string; - tags: string[]; - file: string; - duration?: number; - }>, })); vi.mock("../registry/resolver.js", () => ({ @@ -152,8 +143,6 @@ vi.mock("../registry/localEmbedder.js", () => ({ })); vi.mock("../registry/localSemantic.js", () => ({ - fetchMediaVectors: async () => true, - mediaVectorRows: () => state.mediaRows, mediaSemanticRanking: async () => null, localSemanticRanking: async () => { if (state.rankingError) throw state.rankingError; @@ -264,17 +253,6 @@ beforeEach(() => { state.consentRecorded = []; state.downloads = 0; state.runtimeAvailable = true; - state.mediaRows = [ - { - id: "click", - kind: "sfx", - title: "click", - description: "Crisp UI click", - tags: ["sfx"], - file: "skills/media-use/audio/assets/sfx/click.mp3", - duration: 0.37, - }, - ]; state.registry = [block("count-up"), block("fade-through"), component("whip-pan")]; state.indexed = ["count-up", "fade-through", "whip-pan"]; state.ranking = [ diff --git a/packages/cli/src/commands/media-use.ts b/packages/cli/src/commands/media-use.ts index 5d1e49d17c..16da913d29 100644 --- a/packages/cli/src/commands/media-use.ts +++ b/packages/cli/src/commands/media-use.ts @@ -34,7 +34,6 @@ function enginePath(): string { 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"); diff --git a/packages/cli/src/media-use/lib/local-media-search.mjs b/packages/cli/src/media-use/lib/local-media-search.mjs new file mode 100644 index 0000000000..3e0d31be22 --- /dev/null +++ b/packages/cli/src/media-use/lib/local-media-search.mjs @@ -0,0 +1,32 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const DEFAULT_DIRECTORY = join(homedir(), ".hyperframes", "catalog"); + +function directory() { + return process.env.HYPERFRAMES_CATALOG_ARTIFACT_DIR || DEFAULT_DIRECTORY; +} + +export async function fetchMediaVectors(registryBaseUrl, options = {}) { + const target = options.directory || directory(); + const base = registryBaseUrl.replace(/\/+$/, ""); + mkdirSync(target, { recursive: true, mode: 0o700 }); + const fetched = []; + for (const file of ["media-vectors.json", "media-vectors.bin"]) { + const response = await fetch(`${base}/catalog-artifact/${file}`); + if (!response.ok) return false; + fetched.push([file, Buffer.from(await response.arrayBuffer())]); + } + for (const [file, bytes] of fetched) writeFileSync(join(target, file), bytes, { mode: 0o600 }); + return true; +} + +export function mediaVectorRows(target = directory()) { + const metadataPath = join(target, "media-vectors.json"); + if (!existsSync(metadataPath)) return []; + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + return Array.isArray(metadata.rows) ? metadata.rows : []; +} + +export { rankMediaRowsWithVectors } from "./media-search.mjs"; diff --git a/packages/cli/src/media-use/lib/media-search.d.mts b/packages/cli/src/media-use/lib/media-search.d.mts new file mode 100644 index 0000000000..854d715d97 --- /dev/null +++ b/packages/cli/src/media-use/lib/media-search.d.mts @@ -0,0 +1,9 @@ +export function rankMediaRows< + T extends { + id: string; + kind: string; + title: string; + description: string; + tags: string[]; + }, +>(query: string, rows: T[]): T[]; diff --git a/packages/cli/src/media-use/lib/media-search.mjs b/packages/cli/src/media-use/lib/media-search.mjs index 39077951ef..86a3b1734d 100644 --- a/packages/cli/src/media-use/lib/media-search.mjs +++ b/packages/cli/src/media-use/lib/media-search.mjs @@ -82,3 +82,13 @@ export function rankMediaRows(query, rows) { .sort((a, b) => b.score - a.score) .map(({ row }) => row); } + +export async function rankMediaRowsWithVectors(query, rows, semanticRanking) { + const words = rankMediaRows(query, rows); + if (words.length > 0) return { rows: words, tier: "words" }; + if (!semanticRanking) return { rows: [], tier: "words" }; + const semantic = await semanticRanking(query, rows); + return semantic + ? { rows: semantic.map(({ row }) => row), tier: "on-device" } + : { rows: [], tier: "words" }; +} diff --git a/packages/cli/src/media-use/resolve.mjs b/packages/cli/src/media-use/resolve.mjs index ccd88ecdd6..cc2a4fc955 100644 --- a/packages/cli/src/media-use/resolve.mjs +++ b/packages/cli/src/media-use/resolve.mjs @@ -49,6 +49,11 @@ import { versionLessThan, } from "./lib/heygen-cli.mjs"; import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs"; +import { + fetchMediaVectors, + mediaVectorRows, + rankMediaRowsWithVectors, +} from "./lib/local-media-search.mjs"; const INGEST_TYPES = listTypes(); const DEFAULT_EXT = { @@ -410,40 +415,43 @@ async function run() { // SFX search is bundled, local-index, then HeyGen. let searchResult = null; let providerFailure = null; + let localIndexFailure = null; try { if (type === "sfx" && !args.provider) { searchResult = await runCapability(type, "search", intent, { ...ctx, provider: "bundled.sfx", }); - if (!searchResult) { + if (!searchResult && !localOnly) { const registry = process.env.HYPERFRAMES_REGISTRY || "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry"; - const { fetchMediaVectors, mediaVectorRows } = await import("../registry/localSemantic.js"); - const { rankMediaRowsWithVectors } = await import("../registry/mediaSearch.js"); - await fetchMediaVectors(registry); - const ranked = await rankMediaRowsWithVectors( - intent, - mediaVectorRows().filter((row) => row.kind === "sfx"), - ); - const row = ranked.rows[0]; - const candidates = row - ? [resolve(row.file), join(import.meta.dirname, "..", "..", "..", row.file)] - : []; - const localPath = candidates.find((candidate) => existsSync(candidate)); - if (row && localPath) { - searchResult = { - localPath, - ext: extname(localPath), - source: "local-index", - metadata: { - description: row.description, - duration: row.duration ?? null, - provider: "catalog.local", - provenance: { library_key: row.id, tier: ranked.tier }, - }, - }; + try { + await fetchMediaVectors(registry); + const ranked = await rankMediaRowsWithVectors( + intent, + mediaVectorRows().filter((row) => row.kind === "sfx"), + ); + const row = ranked.rows[0]; + const candidates = row + ? [resolve(row.file), join(import.meta.dirname, "..", "..", "..", row.file)] + : []; + const localPath = candidates.find((candidate) => existsSync(candidate)); + if (row && localPath) { + searchResult = { + localPath, + ext: extname(localPath), + source: "local-index", + metadata: { + description: row.description, + duration: row.duration ?? null, + provider: "catalog.local", + provenance: { library_key: row.id, tier: ranked.tier }, + }, + }; + } + } catch (error) { + localIndexFailure = error; } } if (!searchResult) searchResult = await runCapability(type, "search", intent, ctx); @@ -461,6 +469,12 @@ async function run() { // search failed, try generate } + if (localIndexFailure) { + throw new Error(`local SFX index unavailable: ${localIndexFailure.message}`, { + cause: localIndexFailure, + }); + } + // 4. generate fallback — same ordered cascade for the generate capability if (!searchResult) { try { diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index d07881019e..f5770aefca 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -13,7 +13,6 @@ */ // Vector metadata validation is intentionally defensive at this file boundary. -// fallow-ignore-file complexity import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -45,6 +44,7 @@ export interface MediaVectorRow { dimensions?: { width: number; height: number }; } +// Defensive row validation keeps malformed downloaded metadata out of the ranker. // fallow-ignore-next-line high-crap-score function isMediaVectorRow(value: unknown): value is MediaVectorRow { if (!value || typeof value !== "object") return false; @@ -102,6 +102,7 @@ function localVectorDirectory(): string { * contract. A pair that fails it is a truncated download or a different * model, never something worth caching. */ +// Pair validation checks the metadata, row coverage, dimensions, and byte count together. // fallow-ignore-next-line high-crap-score function vectorPairAgrees( fetched: Array<[string, Buffer]>, @@ -194,14 +195,6 @@ export async function fetchLocalVectors( } } -// fallow-ignore-next-line unused-export -export function fetchMediaVectors( - registryBaseUrl: string, - options: Omit = {}, -): Promise { - return fetchLocalVectors(registryBaseUrl, { ...options, artifactBasename: "media-vectors" }); -} - export function hasLocalVectors(directory = localVectorDirectory()): boolean { return ( existsSync(join(directory, "local-vectors.bin")) && @@ -217,19 +210,6 @@ export function hasMediaVectors(directory = localVectorDirectory()): boolean { ); } -// fallow-ignore-next-line unused-export -export function mediaVectorRows(directory = localVectorDirectory()): MediaVectorRow[] { - if (!hasMediaVectors(directory)) return []; - try { - const metadata = JSON.parse( - readFileSync(join(directory, "media-vectors.json"), "utf-8"), - ) as LocalVectorMetadata; - return metadata.rows?.filter(isMediaVectorRow) ?? []; - } catch { - return []; - } -} - function loadLocalVectors(directory = localVectorDirectory()): LocalVectorSet { const meta = JSON.parse( readFileSync(join(directory, "local-vectors.json"), "utf-8"), @@ -346,6 +326,8 @@ export async function localSemanticRanking( return scored; } +// Internal ranker entry used by the CLI's vector-aware media search library. +// fallow-ignore-next-line unused-export export async function mediaSemanticRanking( query: string, directory = localVectorDirectory(), diff --git a/packages/cli/src/registry/mediaSearch.ts b/packages/cli/src/registry/mediaSearch.ts deleted file mode 100644 index 7429f19338..0000000000 --- a/packages/cli/src/registry/mediaSearch.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { mediaSemanticRanking, type MediaVectorRow } from "./localSemantic.js"; -// fallow-ignore-file unused-file -import { searchByWords } from "./localSearch.js"; - -export type MediaSearchRow = MediaVectorRow; - -export function rankMediaRows(query: string, rows: MediaSearchRow[]): MediaSearchRow[] { - return searchByWords(query, rows, (row) => ({ - strong: `${row.id} ${row.title}`, - weak: `${row.description} ${row.tags.join(" ")} ${row.kind}`, - })); -} - -export async function rankMediaRowsWithVectors( - query: string, - rows: MediaSearchRow[], -): Promise<{ rows: MediaSearchRow[]; tier: "words" | "on-device" }> { - const words = rankMediaRows(query, rows); - if (words.length > 0) return { rows: words, tier: "words" }; - const semantic = await mediaSemanticRanking(query); - return semantic - ? { rows: semantic.map(({ row }) => row), tier: "on-device" } - : { rows: [], tier: "words" }; -} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 9473139afd..b9592f72d4 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -17,7 +17,6 @@ export default defineConfig({ runtimeVersion: "src/runtimeVersion.ts", renderSetupWorker: "src/renderSetupWorker.ts", shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts", - "registry/mediaSearch": "src/registry/mediaSearch.ts", "registry/localSemantic": "src/registry/localSemantic.ts", }, format: ["esm"], diff --git a/skills/media-use/scripts/compatibility.test.mjs b/skills/media-use/scripts/compatibility.test.mjs new file mode 100644 index 0000000000..7e3cd5bf8e --- /dev/null +++ b/skills/media-use/scripts/compatibility.test.mjs @@ -0,0 +1,27 @@ +import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; + +function run(scriptsDir, script, args) { + return execFileSync(process.execPath, [join(scriptsDir, script), ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +test("published memory scripts run without reaching outside the skill", () => { + const scratchDir = mkdtempSync(join(tmpdir(), "media-use-compat-")); + const skillDir = join(scratchDir, "media-use"); + cpSync(new URL("..", import.meta.url), skillDir, { recursive: true }); + const scriptsDir = join(skillDir, "scripts"); + const projectDir = join(scratchDir, "project"); + try { + run(scriptsDir, "prefs.mjs", ["get", "--hyperframes", projectDir, "--json"]); + run(scriptsDir, "recipe.mjs", ["list", "--hyperframes", projectDir, "--json"]); + run(scriptsDir, "transcribe.mjs", ["--help"]); + } finally { + rmSync(scratchDir, { recursive: true, force: true }); + } +}); diff --git a/skills/media-use/scripts/lib/cutlist.mjs b/skills/media-use/scripts/lib/cutlist.mjs index f38dec626c..07d0d34159 100644 --- a/skills/media-use/scripts/lib/cutlist.mjs +++ b/skills/media-use/scripts/lib/cutlist.mjs @@ -1 +1,184 @@ -export * from "../../../../packages/cli/src/media-use/lib/cutlist.mjs"; +import { normalizeWords } from "./words.mjs"; + +const MIN_SEGMENT_SECONDS = 0.2; +const SILENCE_PAD_SECONDS = 0.15; + +export function compileCutList(transcript, opts = {}) { + const words = normalizeWords(transcript); + if (opts.keep != null && hasRemovalSource(opts)) { + throw new Error("--keep is mutually exclusive with removal options"); + } + + if (opts.keep != null) { + const duration = durationFrom(words, opts); + const ranges = parseTimeRanges(opts.keep); + return finalizeKept(duration != null ? clampRanges(ranges, duration) : ranges); + } + + const duration = durationFrom(words, opts); + if (!duration) return []; + + const removals = [ + ...parseTimeRanges(opts.remove), + ...wordIndexRanges(words, opts.removeWords), + ...fillerRanges(words, opts.removeFillers), + ...silenceRanges(words, opts.cutSilence), + ]; + const mergedRemovals = mergeRanges(clampRanges(removals, duration)); + return finalizeKept(invertRanges(mergedRemovals, duration)); +} + +function hasRemovalSource(opts) { + return ( + opts.remove != null || + opts.removeWords != null || + opts.removeFillers != null || + opts.cutSilence != null + ); +} + +function durationFrom(words, opts) { + const explicit = Number(opts.duration ?? opts.totalDuration); + if (Number.isFinite(explicit) && explicit > 0) return explicit; + const last = words.at(-1); + return last && Number.isFinite(last.end) && last.end > 0 ? last.end : null; +} + +function parseTimeRanges(value) { + if (value == null || value === false || value === "") return []; + if (typeof value === "string") { + return value + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + .map(parseRangeString); + } + if (!Array.isArray(value)) throw new Error("range list must be a string or array"); + return value.map((range) => { + if (Array.isArray(range)) return cleanRange(Number(range[0]), Number(range[1])); + return cleanRange(Number(range?.start), Number(range?.end)); + }); +} + +function parseRangeString(value) { + const match = value.match(/^([0-9]*\.?[0-9]+)\s*-\s*([0-9]*\.?[0-9]+)$/); + if (!match) throw new Error(`invalid range: ${value}`); + return cleanRange(Number(match[1]), Number(match[2])); +} + +function cleanRange(start, end) { + if (!Number.isFinite(start) || !Number.isFinite(end)) { + throw new Error("range start/end must be finite numbers"); + } + if (end < start) throw new Error(`range end ${end} is before start ${start}`); + return { start, end }; +} + +function wordIndexRanges(words, value) { + if (value == null || value === false || value === "") return []; + const ranges = typeof value === "string" ? value.split(",") : value; + if (!Array.isArray(ranges)) throw new Error("--remove-words must be a string or array"); + return ranges + .map((range) => (typeof range === "string" ? range.trim() : range)) + .filter(Boolean) + .map((range) => { + const [first, last = first] = + typeof range === "string" ? range.split("-").map((n) => n.trim()) : range; + const startIndex = Number(first); + const endIndex = Number(last); + if (!Number.isInteger(startIndex) || !Number.isInteger(endIndex)) { + throw new Error(`invalid word range: ${range}`); + } + if (startIndex < 0 || endIndex < startIndex || endIndex >= words.length) { + throw new Error(`word range out of bounds: ${range}`); + } + return { start: words[startIndex].start, end: words[endIndex].end }; + }); +} + +function fillerRanges(words, value) { + if (value == null || value === false || value === "") return []; + const fillers = Array.isArray(value) + ? value + : String(value) + .split(",") + .map((s) => s.trim()); + const set = new Set(fillers.filter(Boolean).map(bareToken)); + if (set.size === 0) return []; + // Whisper emits words with attached punctuation and arbitrary case + // ("UM," / "Um."), so compare bare tokens. + return words + .filter((word) => set.has(bareToken(word.text))) + .map((word) => ({ start: word.start, end: word.end })); +} + +function bareToken(text) { + return String(text) + .toLowerCase() + .replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""); +} + +function silenceRanges(words, value) { + if (value == null || value === false || value === "") return []; + const threshold = Number(value); + if (!Number.isFinite(threshold) || threshold <= 0) { + throw new Error("--cut-silence must be a positive number"); + } + const ranges = []; + for (let i = 0; i < words.length - 1; i++) { + const current = words[i]; + const next = words[i + 1]; + const gap = next.start - current.end; + if (gap <= threshold) continue; + const start = current.end + SILENCE_PAD_SECONDS; + const end = next.start - SILENCE_PAD_SECONDS; + if (end > start) ranges.push({ start, end }); + } + return ranges; +} + +function clampRanges(ranges, duration) { + return ranges + .map((range) => ({ + start: Math.max(0, Math.min(duration, range.start)), + end: Math.max(0, Math.min(duration, range.end)), + })) + .filter((range) => range.end > range.start); +} + +function mergeRanges(ranges) { + const sorted = ranges + .map((range) => ({ start: round3(range.start), end: round3(range.end) })) + .sort((a, b) => a.start - b.start || a.end - b.end); + const merged = []; + for (const range of sorted) { + const prev = merged.at(-1); + if (prev && range.start <= prev.end) { + prev.end = Math.max(prev.end, range.end); + } else { + merged.push({ ...range }); + } + } + return merged; +} + +function invertRanges(removals, duration) { + const kept = []; + let cursor = 0; + for (const range of removals) { + if (range.start > cursor) kept.push({ start: cursor, end: range.start }); + cursor = Math.max(cursor, range.end); + } + if (cursor < duration) kept.push({ start: cursor, end: duration }); + return kept; +} + +function finalizeKept(ranges) { + return mergeRanges(ranges) + .map((range) => ({ start: round3(range.start), end: round3(range.end) })) + .filter((range) => round3(range.end - range.start) >= MIN_SEGMENT_SECONDS); +} + +function round3(n) { + return Math.round(Number(n) * 1000) / 1000; +} diff --git a/skills/media-use/scripts/lib/duck.mjs b/skills/media-use/scripts/lib/duck.mjs index 70f1e7433a..9bbe7afda0 100644 --- a/skills/media-use/scripts/lib/duck.mjs +++ b/skills/media-use/scripts/lib/duck.mjs @@ -1 +1,95 @@ -export * from "../../../../packages/cli/src/media-use/lib/duck.mjs"; +import { wordListsFromMediaMeta } from "./words.mjs"; + +// 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); + const voices = Array.isArray(meta?.voices) ? meta.voices : []; + if (lists.length > 1 && !offsets && !sequential) { + throw new Error( + "audio_meta has multiple voice lines with file-relative times; pass --sequential or --offsets so spans land at composition time", + ); + } + const intervals = []; + let cursor = 0; + for (let i = 0; i < lists.length; i++) { + const voice = voices[i]; + let offset = 0; + if (offsets) { + const id = voice?.id ?? String(i); + if (!(id in offsets)) throw new Error(`--offsets is missing voice "${id}"`); + offset = Number(offsets[id]) || 0; + } else if (sequential) { + offset = cursor; + const lineDuration = Number(voice?.duration_s) || Math.max(...lists[i].map((w) => w.end), 0); + cursor += lineDuration + (Number(gap) || 0); + } + for (const word of lists[i]) { + if (word.end > word.start) + intervals.push({ start: word.start + offset, end: word.end + offset }); + } + } + return mergeIntervals(intervals, Number.isFinite(merge) && merge >= 0 ? merge : 0.6); +} + +export function duckKeyframes( + spans, + { duck = 0.25, attack = 0.15, release = 0.4, baseVolume = 1 } = {}, +) { + const base = finiteOr(baseVolume, 1); + const ducked = round3(base * finiteOr(duck, 0.25)); + const keyframes = []; + for (const span of spans) { + keyframes.push({ + time: round3(Math.max(0, finiteOr(span.start, 0))), + volume: ducked, + duration: round3(finiteOr(attack, 0.15)), + }); + keyframes.push({ + time: round3(Math.max(0, finiteOr(span.end, 0))), + volume: round3(base), + duration: round3(finiteOr(release, 0.4)), + }); + } + return keyframes.sort((a, b) => a.time - b.time); +} + +/** Volume lane for `data-automation`: composition-time keyframes as clip-local ramps. */ +export function duckLane(keyframes, { clipStart = 0, baseVolume = 1 } = {}) { + const start = finiteOr(clipStart, 0); + const points = [{ t: 0, v: round3(finiteOr(baseVolume, 1)) }]; + const push = (t, v) => { + if (t > points.at(-1).t) points.push({ t: round3(t), v }); + }; + for (const kf of keyframes) { + const t = Math.max(0, kf.time - start); + push(t, points.at(-1).v); + push(t + kf.duration, kf.volume); + } + return { version: 1, lanes: [{ target: "volume", points }] }; +} + +function mergeIntervals(intervals, mergeGap) { + const sorted = intervals + .map((range) => ({ start: round3(range.start), end: round3(range.end) })) + .sort((a, b) => a.start - b.start || a.end - b.end); + const merged = []; + for (const range of sorted) { + const prev = merged.at(-1); + if (prev && (range.start <= prev.end || range.start - prev.end < mergeGap)) { + prev.end = Math.max(prev.end, range.end); + } else { + merged.push({ ...range }); + } + } + return merged; +} + +function finiteOr(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function round3(n) { + return Math.round(Number(n) * 1000) / 1000; +} diff --git a/skills/media-use/scripts/lib/error-diffusion.mjs b/skills/media-use/scripts/lib/error-diffusion.mjs new file mode 100644 index 0000000000..e59a9c5ab6 --- /dev/null +++ b/skills/media-use/scripts/lib/error-diffusion.mjs @@ -0,0 +1,230 @@ +export const ERROR_DIFFUSION_ALGORITHMS = { + "floyd-steinberg": { + kernel: [ + [1, 0, 7], + [-1, 1, 3], + [0, 1, 5], + [1, 1, 1], + ], + divisor: 16, + }, + atkinson: { + kernel: [ + [1, 0, 1], + [2, 0, 1], + [-1, 1, 1], + [0, 1, 1], + [1, 1, 1], + [0, 2, 1], + ], + divisor: 8, + }, + "jarvis-judice-ninke": { + kernel: [ + [1, 0, 7], + [2, 0, 5], + [-2, 1, 3], + [-1, 1, 5], + [0, 1, 7], + [1, 1, 5], + [2, 1, 3], + [-2, 2, 1], + [-1, 2, 3], + [0, 2, 5], + [1, 2, 3], + [2, 2, 1], + ], + divisor: 48, + }, + stucki: { + kernel: [ + [1, 0, 8], + [2, 0, 4], + [-2, 1, 2], + [-1, 1, 4], + [0, 1, 8], + [1, 1, 4], + [2, 1, 2], + [-2, 2, 1], + [-1, 2, 2], + [0, 2, 4], + [1, 2, 2], + [2, 2, 1], + ], + divisor: 42, + }, + burkes: { + kernel: [ + [1, 0, 8], + [2, 0, 4], + [-2, 1, 2], + [-1, 1, 4], + [0, 1, 8], + [1, 1, 4], + [2, 1, 2], + ], + divisor: 32, + }, + sierra: { + kernel: [ + [1, 0, 5], + [2, 0, 3], + [-2, 1, 2], + [-1, 1, 4], + [0, 1, 5], + [1, 1, 4], + [2, 1, 2], + [-1, 2, 2], + [0, 2, 3], + [1, 2, 2], + ], + divisor: 32, + }, + "sierra-lite": { + kernel: [ + [1, 0, 2], + [-1, 1, 1], + [0, 1, 1], + ], + divisor: 4, + }, + "two-row-sierra": { + kernel: [ + [1, 0, 4], + [2, 0, 3], + [-2, 1, 1], + [-1, 1, 2], + [0, 1, 3], + [1, 1, 2], + [2, 1, 1], + ], + divisor: 16, + }, +}; + +const DEFAULTS = { + algorithm: "floyd-steinberg", + brightness: 1, + contrast: 1.2, + detail: 1, + palette: ["#000000", "#ffffff"], + pointSize: 3, +}; + +export function errorDiffusionBufferLength(width, height, pointSize) { + return Math.ceil(width / pointSize) * Math.ceil(height / pointSize) * 3; +} + +export function applyErrorDiffusionRgba(data, width, height, options = {}, errorBuffer) { + if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) { + throw new Error("width and height must be positive integers"); + } + if (!data || data.length !== width * height * 4) { + throw new Error(`RGBA data must contain ${width * height * 4} bytes`); + } + + const algorithm = options.algorithm ?? DEFAULTS.algorithm; + const diffusion = ERROR_DIFFUSION_ALGORITHMS[algorithm]; + if (!diffusion) throw new Error(`unknown error-diffusion algorithm: ${algorithm}`); + + const pointSize = integerInRange(options.pointSize ?? DEFAULTS.pointSize, 1, 20, "pointSize"); + const brightness = numberInRange(options.brightness ?? DEFAULTS.brightness, 0.5, 2, "brightness"); + const contrast = numberInRange(options.contrast ?? DEFAULTS.contrast, 0.5, 2, "contrast"); + const detail = numberInRange(options.detail ?? DEFAULTS.detail, 0.1, 1, "detail"); + const palette = parsePalette(options.palette ?? DEFAULTS.palette); + const blockColumns = Math.ceil(width / pointSize); + const blockRows = Math.ceil(height / pointSize); + const errorLength = errorDiffusionBufferLength(width, height, pointSize); + const errors = errorBuffer ?? new Float32Array(errorLength); + if (!(errors instanceof Float32Array) || errors.length !== errorLength) { + throw new Error(`errorBuffer must be a Float32Array of length ${errorLength}`); + } + errors.fill(0); + + const centerOffset = Math.floor(pointSize / 2); + for (let blockRow = 0; blockRow < blockRows; blockRow++) { + const blockY = blockRow * pointSize; + for (let blockColumn = 0; blockColumn < blockColumns; blockColumn++) { + const blockX = blockColumn * pointSize; + const centerX = Math.min(blockX + centerOffset, width - 1); + const centerY = Math.min(blockY + centerOffset, height - 1); + const rgbaIndex = (centerY * width + centerX) * 4; + const errorIndex = (blockRow * blockColumns + blockColumn) * 3; + const red = correctedChannel(data[rgbaIndex], errors[errorIndex], brightness, contrast); + const green = correctedChannel( + data[rgbaIndex + 1], + errors[errorIndex + 1], + brightness, + contrast, + ); + const blue = correctedChannel( + data[rgbaIndex + 2], + errors[errorIndex + 2], + brightness, + contrast, + ); + const luminance = 0.299 * red + 0.587 * green + 0.114 * blue; + const output = palette[Math.min(palette.length - 1, Math.floor(luminance * palette.length))]; + + for (let y = blockY; y < Math.min(blockY + pointSize, height); y++) { + for (let x = blockX; x < Math.min(blockX + pointSize, width); x++) { + const outputIndex = (y * width + x) * 4; + data[outputIndex] = Math.round(output[0] * 255); + data[outputIndex + 1] = Math.round(output[1] * 255); + data[outputIndex + 2] = Math.round(output[2] * 255); + } + } + + for (const [dx, dy, weight] of diffusion.kernel) { + const targetColumn = blockColumn + dx; + const targetRow = blockRow + dy; + if ( + targetColumn < 0 || + targetColumn >= blockColumns || + targetRow < 0 || + targetRow >= blockRows + ) { + continue; + } + const target = (targetRow * blockColumns + targetColumn) * 3; + const scale = (weight / diffusion.divisor) * detail; + errors[target] += (red - output[0]) * scale; + errors[target + 1] += (green - output[1]) * scale; + errors[target + 2] += (blue - output[2]) * scale; + } + } + } + return data; +} + +function correctedChannel(byte, error, brightness, contrast) { + return Math.min(1, Math.max(0, ((byte / 255 - 0.5) * contrast + 0.5) * brightness + error)); +} + +function parsePalette(colors) { + if (!Array.isArray(colors) || colors.length < 2 || colors.length > 6) { + throw new Error("palette must contain 2 to 6 colors"); + } + return colors.map((color) => { + const match = /^#([0-9a-f]{6})$/i.exec(color); + if (!match) throw new Error(`palette color must use #rrggbb: ${color}`); + const value = Number.parseInt(match[1], 16); + return [(value >> 16) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255]; + }); +} + +function numberInRange(value, min, max, name) { + const number = Number(value); + if (!Number.isFinite(number) || number < min || number > max) { + throw new Error(`${name} must be between ${min} and ${max}`); + } + return number; +} + +function integerInRange(value, min, max, name) { + const number = Number(value); + if (!Number.isInteger(number) || number < min || number > max) { + throw new Error(`${name} must be an integer between ${min} and ${max}`); + } + return number; +} diff --git a/skills/media-use/scripts/lib/index-gen.mjs b/skills/media-use/scripts/lib/index-gen.mjs new file mode 100644 index 0000000000..65b019a92e --- /dev/null +++ b/skills/media-use/scripts/lib/index-gen.mjs @@ -0,0 +1,63 @@ +import { writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { readManifest, indexPath } from "./manifest.mjs"; + +function pad(str, len) { + return String(str ?? "").padEnd(len); +} + +function formatDur(record) { + if (record.duration == null) return "—"; + return `${record.duration}s`; +} + +function formatDims(record) { + if (record.width && record.height) return `${record.width}×${record.height}`; + if (record.type === "icon" && record.transparent) return "svg"; + return "—"; +} + +export function generateIndexContent(records) { + const count = records.length; + const header = `# .media · ${count} asset${count === 1 ? "" : "s"}\n`; + if (count === 0) return header; + + const cols = { id: 4, type: 5, dur: 4, dims: 5, path: 5, desc: 11 }; + for (const r of records) { + cols.id = Math.max(cols.id, (r.id ?? "").length); + cols.type = Math.max(cols.type, (r.type ?? "").length); + cols.dur = Math.max(cols.dur, formatDur(r).length); + cols.dims = Math.max(cols.dims, formatDims(r).length); + cols.path = Math.max(cols.path, (r.path ?? "").length); + } + + const heading = + pad("id", cols.id + 2) + + pad("type", cols.type + 2) + + pad("dur", cols.dur + 2) + + pad("dims", cols.dims + 2) + + pad("path", cols.path + 2) + + "description"; + + const lines = [header, heading]; + for (const r of records) { + lines.push( + pad(r.id, cols.id + 2) + + pad(r.type, cols.type + 2) + + pad(formatDur(r), cols.dur + 2) + + pad(formatDims(r), cols.dims + 2) + + pad(r.path, cols.path + 2) + + (r.description ?? ""), + ); + } + return lines.join("\n") + "\n"; +} + +export function regenerateIndex(projectDir) { + const records = readManifest(projectDir); + const content = generateIndexContent(records); + const p = indexPath(projectDir); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, content); + return content; +} diff --git a/skills/media-use/scripts/lib/manifest.mjs b/skills/media-use/scripts/lib/manifest.mjs new file mode 100644 index 0000000000..0bd2f7b3da --- /dev/null +++ b/skills/media-use/scripts/lib/manifest.mjs @@ -0,0 +1,229 @@ +import { + readFileSync, + appendFileSync, + mkdirSync, + existsSync, + readdirSync, + openSync, + closeSync, + writeFileSync, + rmSync, + statSync, +} from "node:fs"; +import { join } from "node:path"; + +const MANIFEST_FILE = "manifest.jsonl"; +const INDEX_FILE = "index.md"; + +const TYPE_DIRS = { + bgm: "audio/bgm", + sfx: "audio/sfx", + voice: "audio/voice", + image: "images", + icon: "images", + logo: "images", + brand: "images", + video: "video", + grade: "luts", + lut: "luts", + recipe: "recipes", +}; + +export function mediaDir(projectDir) { + return join(projectDir, ".media"); +} + +export function manifestPath(projectDir) { + return join(mediaDir(projectDir), MANIFEST_FILE); +} + +export function indexPath(projectDir) { + return join(mediaDir(projectDir), INDEX_FILE); +} + +export function typeSubdir(type) { + const sub = TYPE_DIRS[type]; + if (!sub) throw new Error(`unknown media type: ${type}`); + return sub; +} + +export function typeDirPath(projectDir, type) { + return join(mediaDir(projectDir), typeSubdir(type)); +} + +export function readManifest(projectDir) { + const p = manifestPath(projectDir); + if (!existsSync(p)) return []; + const raw = readFileSync(p, "utf8"); + const records = []; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + records.push(JSON.parse(trimmed)); + } catch { + // ponytail: skip malformed lines, don't crash + } + } + return records; +} + +export function appendRecord(projectDir, record) { + const dir = mediaDir(projectDir); + mkdirSync(dir, { recursive: true }); + const typeDir = typeDirPath(projectDir, record.type); + mkdirSync(typeDir, { recursive: true }); + + const p = manifestPath(projectDir); + const line = JSON.stringify(record) + "\n"; + appendFileSync(p, line); +} + +// Match prompts forgivingly. Agents rarely re-emit a byte-identical intent, so +// keying cache lookups on exact equality meant "Calm piano" and "calm piano" +// re-searched and re-downloaded. Normalize (trim, lowercase, collapse internal +// whitespace) on both sides; the raw prompt is still stored for audit. +export function normalizePrompt(prompt) { + return String(prompt ?? "") + .trim() + .toLowerCase() + .replace(/\s+/g, " "); +} + +export function findByPrompt(projectDir, prompt, type) { + const key = normalizePrompt(prompt); + if (!key) return null; + const records = readManifest(projectDir); + return ( + records.find( + (r) => normalizePrompt(r.provenance?.prompt) === key && (type == null || r.type === type), + ) || null + ); +} + +export function findByEntity(projectDir, entity) { + const lower = entity.toLowerCase(); + const records = readManifest(projectDir); + return records.find((r) => r.entity && r.entity.toLowerCase() === lower) || null; +} + +export function nextId(projectDir, type) { + const records = readManifest(projectDir); + const prefix = type; + let max = 0; + for (const r of records) { + if (r.type !== type) continue; + const m = r.id?.match(new RegExp(`^${prefix}_(\\d+)$`)); + if (m) max = Math.max(max, parseInt(m[1], 10)); + } + return `${prefix}_${String(max + 1).padStart(3, "0")}`; +} + +// Sync sleep (no busy-spin) for the allocation lock retry. +function sleepMs(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +// Coarse per-project lock so concurrent resolves don't race on id allocation. +// ponytail: one lock file with a 15s stale-steal (a crashed holder can't wedge +// the project); fine for agent-scale concurrency — revisit if throughput needs +// finer locking. Date.now() is available here (a normal Node CLI, not a +// workflow DSL), so mtime-based staleness is safe. +const LOCK_STALE_MS = 15000; +const LOCK_TIMEOUT_MS = 20000; + +function withLock(dir, fn) { + const lock = join(dir, ".lock"); + const start = Date.now(); + for (;;) { + try { + closeSync(openSync(lock, "wx")); // O_EXCL: atomic acquire + break; + } catch (err) { + if (err.code !== "EEXIST") throw err; + try { + if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { + rmSync(lock, { force: true }); // steal a stale lock from a dead holder + continue; + } + } catch { + continue; // lock vanished between check and stat — retry the acquire + } + if (Date.now() - start > LOCK_TIMEOUT_MS) { + throw new Error("media-use: timed out acquiring .media/.lock"); + } + sleepMs(25); + } + } + try { + return fn(); + } finally { + rmSync(lock, { force: true }); + } +} + +// Atomically allocate the next free id for `type` AND reserve its file, so a +// slow download/copy between allocation and appendRecord can't let a concurrent +// caller grab the same id (the MU-23 clobber). Under the lock we take the max id +// across BOTH the manifest and any already-reserved files in the type dir, then +// O_EXCL-create an empty placeholder at the target path; freeze/copy overwrites +// it. Returns { id, localPath }. +export function allocateId(projectDir, type, ext) { + mkdirSync(mediaDir(projectDir), { recursive: true }); + const typeDir = typeDirPath(projectDir, type); + mkdirSync(typeDir, { recursive: true }); + return withLock(mediaDir(projectDir), () => { + const re = new RegExp(`^${type}_(\\d+)`); + let max = 0; + for (const r of readManifest(projectDir)) { + if (r.type !== type) continue; + const m = r.id?.match(re); + if (m) max = Math.max(max, parseInt(m[1], 10)); + } + for (const f of readdirSync(typeDir)) { + const m = f.match(re); + if (m) max = Math.max(max, parseInt(m[1], 10)); // skip ids reserved but not yet appended + } + const id = `${type}_${String(max + 1).padStart(3, "0")}`; + const localPath = `.media/${typeSubdir(type)}/${id}${ext}`; + writeFileSync(join(projectDir, localPath), "", { flag: "wx" }); // durable reservation + return { id, localPath }; + }); +} + +function reservedFile(projectDir, type, ext) { + const allocation = allocateId(projectDir, type, ext); + return { ...allocation, fullPath: join(projectDir, allocation.localPath) }; +} + +function rollbackReservation(reservation) { + rmSync(reservation.fullPath, { force: true }); +} + +// A reservation is committed only when populate returns a non-null value. +// Throwing/rejecting or returning null means no usable asset was produced, so +// the placeholder must be released. Keeping this transaction beside allocateId +// prevents individual provider/cache/LUT paths from forgetting the rollback. +export function withReservedFileSync(projectDir, type, ext, populate) { + const reservation = reservedFile(projectDir, type, ext); + try { + const result = populate(reservation); + if (result == null) rollbackReservation(reservation); + return result; + } catch (error) { + rollbackReservation(reservation); + throw error; + } +} + +export async function withReservedFile(projectDir, type, ext, populate) { + const reservation = reservedFile(projectDir, type, ext); + try { + const result = await populate(reservation); + if (result == null) rollbackReservation(reservation); + return result; + } catch (error) { + rollbackReservation(reservation); + throw error; + } +} diff --git a/skills/media-use/scripts/lib/npx-sync.mjs b/skills/media-use/scripts/lib/npx-sync.mjs new file mode 100644 index 0000000000..b1049cd2b8 --- /dev/null +++ b/skills/media-use/scripts/lib/npx-sync.mjs @@ -0,0 +1,57 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +function resolveNpxCliPath(env, pathExists) { + const npmExecPath = env.npm_execpath; + const nodeExecPath = env.npm_node_execpath || process.execPath; + if (npmExecPath) { + const fileName = npmExecPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase(); + const candidate = + fileName === "npx-cli.js" ? npmExecPath : join(dirname(npmExecPath), "npx-cli.js"); + if (pathExists(candidate)) return candidate; + } + const besideNode = join(dirname(nodeExecPath), "node_modules", "npm", "bin", "npx-cli.js"); + return pathExists(besideNode) ? besideNode : null; +} + +// Sync-spawn analog of the audio engine's spawnP, for execFileSync call sites +// that must hard-fail (rather than fall through to another provider) when npx +// cannot be resolved. On Windows a bare "npx" is npx.cmd, which +// execFileSync/spawnSync cannot exec (spawnSync npx ENOENT) — +// resolveSpawnCommand reroutes it through node + npx-cli.js, no shell:true. +// +// `platform`/`env`/`pathExists` params (defaulting to the real values) exist +// so tests can exercise the win32 branch without mocking node:child_process +// (its ESM exports are non-configurable) — same idiom as spawnP and +// localTtsGenerate. +export function resolveNpxInvocation( + argv, + opts, + platform = process.platform, + env = process.env, + pathExists = existsSync, +) { + const resolved = + platform !== "win32" + ? { cmd: "npx", args: argv, opts: { stdio: "ignore", ...opts } } + : (() => { + const nodeExecPath = env.npm_node_execpath || process.execPath; + const npxCliPath = resolveNpxCliPath(env, pathExists); + return npxCliPath + ? { + cmd: nodeExecPath, + args: [npxCliPath, ...argv.map((arg) => String(arg))], + opts: { stdio: "ignore", windowsHide: true, ...opts }, + } + : null; + })(); + if (!resolved) { + // npx-on-win32 with no resolvable npx-cli.js — same terminal condition + // spawnP warns about, surfaced as a throw for callers with no fallback. + throw new Error( + "cannot run npx on Windows: npm's npx-cli.js was not found " + + "(install npm with Node, or run via npx/npm run so npm_execpath is set)", + ); + } + return resolved; +} diff --git a/skills/media-use/scripts/lib/parakeet-words.mjs b/skills/media-use/scripts/lib/parakeet-words.mjs new file mode 100644 index 0000000000..04df79c7d4 --- /dev/null +++ b/skills/media-use/scripts/lib/parakeet-words.mjs @@ -0,0 +1,27 @@ +// Merge Parakeet-MLX token timestamps into word timestamps. +// +// parakeet-mlx JSON emits SUB-WORD tokens (" H", "ello", ...) with per-token +// start/end. Captions + transcript-cut need WORD timestamps, so join tokens +// into words on the space boundary: a token whose text starts with a space +// (or the very first token) begins a new word; the rest append. Output matches +// the { words: [{ text, start, end }] } shape the rest of media-use consumes +// (see words.mjs / cutlist.mjs). + +export function mergeTokensToWords(parakeet) { + const sentences = Array.isArray(parakeet?.sentences) ? parakeet.sentences : []; + const words = []; + for (const s of sentences) { + for (const t of s.tokens ?? []) { + const raw = typeof t.text === "string" ? t.text : ""; + const startsWord = raw.startsWith(" ") || words.length === 0; + if (startsWord) { + words.push({ text: raw.trim(), start: t.start, end: t.end }); + } else { + const w = words[words.length - 1]; + w.text += raw; + w.end = t.end; + } + } + } + return { text: (parakeet?.text ?? "").trim(), words: words.filter((w) => w.text.length > 0) }; +} diff --git a/skills/media-use/scripts/lib/prefs-store.mjs b/skills/media-use/scripts/lib/prefs-store.mjs new file mode 100644 index 0000000000..6bc3f7345f --- /dev/null +++ b/skills/media-use/scripts/lib/prefs-store.mjs @@ -0,0 +1,180 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; + +/** + * Remembered defaults — the lightweight tier of HyperFrames user memory. + * + * Two files, same shape as the rest of media-use's storage split: + * - project `.media/preferences.json` — committed with the repo, so the whole + * team inherits it; written every time a brief answer is confirmed. + * - user `~/.media/preferences.json` — personal, cross-repo. A key is promoted + * here only once the same value has been confirmed in two different projects + * (`PROMOTE_AT`), so a one-off choice never pollutes the global defaults. + * Pre-promotion evidence accumulates in the user file's `sightings` ledger — + * project files can't see each other, so the cross-project count has to live + * user-side. + * + * Consumption contract (brief-contract § 2, Remembered defaults): a remembered + * value becomes the recommended option with a receipt naming its source — it + * never skips a question, and explicit request content always wins. + */ + +const PREFS_FILE = "preferences.json"; + +/** Keys the brief contract records; `style_preset` is stored per workflow. */ +export const PREFERENCE_KEYS = [ + "destination", + "aspect", + "language", + "flow", + "storyboard", + "voice", + "style_preset", +]; + +/** A value must be confirmed in this many distinct projects to go user-tier. */ +export const PROMOTE_AT = 2; + +export function projectPrefsPath(projectDir) { + return join(resolve(projectDir), ".media", PREFS_FILE); +} + +export function userPrefsPath() { + return join(homedir(), ".media", PREFS_FILE); +} + +function emptyFile() { + return { version: 1, preferences: {}, sightings: {} }; +} + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Tolerant read — a missing or malformed file counts as empty. */ +function readPrefsFile(path) { + try { + if (!existsSync(path)) return emptyFile(); + const parsed = JSON.parse(readFileSync(path, "utf8")); + if (!isRecord(parsed)) return emptyFile(); + return { + version: 1, + preferences: isRecord(parsed.preferences) ? parsed.preferences : {}, + sightings: isRecord(parsed.sightings) ? parsed.sightings : {}, + }; + } catch { + return emptyFile(); + } +} + +/** Atomic write (tmp + rename) so a crash never leaves a torn file. */ +function writePrefsFile(path, file) { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`); + renameSync(tmp, path); +} + +/** `style_preset` entries are stored per workflow as `style_preset.`. */ +export function preferenceKeyFor(key, workflow) { + return key === "style_preset" && workflow ? `style_preset.${workflow}` : key; +} + +function validEntry(entry) { + return isRecord(entry) && typeof entry.value === "string" && entry.value.length > 0; +} + +/** + * The merged view the brief reads: user-tier promoted entries first, project + * entries on top (project wins). Each entry carries `source` plus the receipt + * material (`confirmed_in`, `updated_at`). + */ +export function mergedPreferences(projectDir) { + const user = readPrefsFile(userPrefsPath()); + const project = readPrefsFile(projectPrefsPath(projectDir)); + const merged = {}; + for (const [key, entry] of Object.entries(user.preferences)) { + if (validEntry(entry)) merged[key] = { ...entry, source: "user" }; + } + for (const [key, entry] of Object.entries(project.preferences)) { + if (validEntry(entry)) merged[key] = { ...entry, source: "project" }; + } + return merged; +} + +function dedupe(list) { + return [...new Set(list)]; +} + +/** + * Project tier: same value accumulates confirmations; a changed value starts + * provenance over (the old confirmations vouched for the old value). + */ +function recordProjectTier(projectDir, fullKey, value, projectName, now) { + const path = projectPrefsPath(projectDir); + const file = readPrefsFile(path); + const previous = file.preferences[fullKey]; + const keepProvenance = validEntry(previous) && previous.value === value; + const confirmedIn = keepProvenance + ? dedupe([...(Array.isArray(previous.confirmed_in) ? previous.confirmed_in : []), projectName]) + : [projectName]; + file.preferences[fullKey] = { value, confirmed_in: confirmedIn, updated_at: now }; + writePrefsFile(path, file); + return confirmedIn; +} + +/** + * User tier: accumulate this sighting in the ledger, and promote the key once + * the same value has been confirmed in PROMOTE_AT distinct projects. + */ +function recordUserSighting(fullKey, value, projectName, now) { + const path = userPrefsPath(); + const file = readPrefsFile(path); + const keySightings = isRecord(file.sightings[fullKey]) ? file.sightings[fullKey] : {}; + const seenIn = dedupe([ + ...(Array.isArray(keySightings[value]) ? keySightings[value] : []), + projectName, + ]); + keySightings[value] = seenIn; + file.sightings[fullKey] = keySightings; + const promoted = seenIn.length >= PROMOTE_AT; + if (promoted) { + file.preferences[fullKey] = { value, confirmed_in: seenIn, updated_at: now }; + } + writePrefsFile(path, file); + return promoted; +} + +/** + * Record one confirmed brief answer. Always writes the project tier; feeds the + * user tier's sightings ledger and promotes once the same value has been + * confirmed in PROMOTE_AT distinct projects. Idempotent per project. + */ +export function recordPreference({ projectDir, key, value, workflow }) { + if (!PREFERENCE_KEYS.includes(key)) { + throw new Error(`unknown preference key: "${key}" (known: ${PREFERENCE_KEYS.join(", ")})`); + } + if (typeof value !== "string" || !value.trim()) { + throw new Error("a preference needs a non-empty string value"); + } + if (key === "style_preset" && (!workflow || !String(workflow).trim())) { + throw new Error("style_preset is stored per workflow — pass --workflow "); + } + const fullKey = preferenceKeyFor(key, workflow); + const projectName = basename(resolve(projectDir)); + const trimmed = value.trim(); + const now = new Date().toISOString(); + + const confirmedIn = recordProjectTier(projectDir, fullKey, trimmed, projectName, now); + + // Best-effort — a read-only home directory must never fail a brief. + let promoted = false; + try { + promoted = recordUserSighting(fullKey, trimmed, projectName, now); + } catch { + // The project record already landed; promotion just waits for next time. + } + + return { key: fullKey, value: trimmed, confirmed_in: confirmedIn, promoted }; +} diff --git a/skills/media-use/scripts/lib/recipe-store.mjs b/skills/media-use/scripts/lib/recipe-store.mjs new file mode 100644 index 0000000000..e11ec96a31 --- /dev/null +++ b/skills/media-use/scripts/lib/recipe-store.mjs @@ -0,0 +1,367 @@ +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { appendRecord, mediaDir, nextId } from "./manifest.mjs"; +import { regenerateIndex } from "./index-gen.mjs"; +import { mergedPreferences } from "./prefs-store.mjs"; + +/** + * Recipes — the heavyweight tier of HyperFrames user memory. + * + * A recipe is the full confirmed bundle for one video type: the frozen design + * spec (`frame.md`), the storyboard skeleton (structure with the content + * blanked), and the confirmed brief values — frozen after the run's final + * approval, reused to start the next video of the same type from everything + * already approved. + * + * Storage is **named folders**, not content-addressed cache entries: a recipe + * is an evolving bundle with a `version`, so re-freezing the same name bumps + * the version and archives the old folder as `@v`. Two tiers, same + * split as everything else in media-use: project `.media/recipes//` + * (committed) and user `~/.media/recipes//` (a freeze is already a + * confirmed bundle, so it promotes immediately — no two-project rule here). + */ + +/** Frontmatter keys that describe THIS video, not the reusable type. */ +const FRONTMATTER_CONTENT_KEYS = new Set(["message", "audience", "mode"]); + +/** BRIEF.md frontmatter keys that describe this run, not the reusable type — + * a recipe never locks the run's shape, so the intent layer always re-asks. */ +const BRIEF_CONTENT_KEYS = new Set(["flow", "storyboard", "message", "audience"]); + +/** Per-frame metadata that is content, not structure. */ +const FRAME_CONTENT_KEYS = new Set([ + "voiceover", + "vo", + "voice_over", + "narration", + "scene", + "description", + "summary", + "caption", + "asset_candidates", +]); + +const FRAME_HEADING_RE = /^(#{2,3})\s+(?:frame|beat|scene)\s+\d+/i; + +export function projectRecipesDir(projectDir) { + return join(mediaDir(projectDir), "recipes"); +} + +export function userRecipesDir() { + return join(homedir(), ".media", "recipes"); +} + +export function slugifyRecipeName(name) { + const slug = String(name ?? "") + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + if (!slug) throw new Error(`recipe name "${name}" has no usable characters`); + return slug; +} + +function frameTitle(headingLine) { + const dash = headingLine.split(/\s+—\s+/)[1]; + if (dash && dash.trim()) return dash.trim(); + return headingLine.replace(/^#+\s*/, "").trim(); +} + +/** Frontmatter: drop the content keys, keep structure/style keys verbatim. */ +function skeletonFrontmatter(lines, out, contentKeys = FRONTMATTER_CONTENT_KEYS) { + if (lines[0]?.trim() !== "---") return 0; + out.push(lines[0]); + let i = 1; + while (i < lines.length && lines[i].trim() !== "---") { + const key = lines[i].match(/^(\w+)\s*:/)?.[1]?.toLowerCase(); + if (!key || !contentKeys.has(key)) out.push(lines[i]); + i++; + } + if (i < lines.length) { + out.push(lines[i]); // closing --- + i++; + } + return i; +} + +/** One line inside a frame section — returns the replacement lines (may be none). */ +function skeletonFrameLine(line, state, out) { + const bulletKey = line.match(/^-\s+(\w+)\s*:/)?.[1]?.toLowerCase(); + if (bulletKey) { + if (bulletKey === "status") out.push("- status: outline"); + else if (!FRAME_CONTENT_KEYS.has(bulletKey)) out.push(line); + return; + } + if (!line.trim()) { + out.push(line); + return; + } + // Frame prose: one placeholder per frame in place of the narrative. + if (!state.proseReplaced) { + out.push( + ``, + ); + state.proseReplaced = true; + } +} + +/** + * Skeletonize a STORYBOARD.md: keep the reusable structure (frame count, + * durations, transitions, src paths, the Video direction block, style-ish + * frontmatter), reset every status to `outline`, and blank the content + * (message/audience, narration guides, per-frame prose) down to a fill-in + * placeholder that names the frame's role. + */ +/** + * Skeletonize a BRIEF.md: keep the frontmatter's reusable keys (workflow, + * destination, aspect, language, length, angle…), drop the run-shape and + * content keys (flow, storyboard, message, audience), and blank each body + * section down to a fill-in placeholder under its kept heading. + */ +export function skeletonizeBrief(source) { + const lines = String(source ?? "").split(/\r?\n/); + const out = []; + let i = skeletonFrontmatter(lines, out, BRIEF_CONTENT_KEYS); + for (; i < lines.length; i++) { + const heading = lines[i].match(/^##\s+(.+)$/); + if (heading) { + out.push(lines[i], ""); + out.push( + ``, + ); + out.push(""); + } + } + return out.join("\n").replace(/\n{3,}/g, "\n\n"); +} + +export function skeletonizeStoryboard(source) { + const lines = String(source ?? "").split(/\r?\n/); + const out = []; + const state = { inFrame: false, proseReplaced: false, title: "" }; + for (let i = skeletonFrontmatter(lines, out); i < lines.length; i++) { + const line = lines[i]; + if (/^#{2,3}\s/.test(line)) { + state.inFrame = FRAME_HEADING_RE.test(line); + state.proseReplaced = false; + state.title = state.inFrame ? frameTitle(line) : ""; + out.push(line); + } else if (!state.inFrame) { + out.push(line); + } else { + skeletonFrameLine(line, state, out); + } + } + return out.join("\n").replace(/\n{3,}/g, "\n\n"); +} + +/** The run's workflow as BRIEF.md records it — the source of truth a freeze + * must not contradict. Undefined when no BRIEF.md (or no `workflow:`) exists. */ +function briefWorkflow(root) { + const brief = join(root, "BRIEF.md"); + if (!existsSync(brief)) return undefined; + const lines = readFileSync(brief, "utf8").split(/\r?\n/); + if (lines[0]?.trim() !== "---") return undefined; + for (let i = 1; i < lines.length && lines[i].trim() !== "---"; i++) { + const match = lines[i].match(/^workflow\s*:\s*(.+?)\s*$/); + if (match) return match[1].replace(/^["']|["']$/g, "") || undefined; + } + return undefined; +} + +function readRecipeJson(dir) { + try { + const parsed = JSON.parse(readFileSync(join(dir, "recipe.json"), "utf8")); + if (typeof parsed !== "object" || parsed === null || typeof parsed.name !== "string") { + return null; + } + return parsed; + } catch { + return null; + } +} + +function prefValue(prefs, key) { + return prefs[key]?.value; +} + +/** + * Freeze the current project's approved run as a named recipe. Writes the + * project-tier folder + a manifest record, then copies to the user tier (a + * freeze is already confirmed — it promotes immediately). + */ +export function freezeRecipe({ projectDir, name, workflow, blocks }) { + const slug = slugifyRecipeName(name); + const root = resolve(projectDir); + const fromBrief = briefWorkflow(root); + const fromFlag = workflow && String(workflow).trim() ? String(workflow).trim() : undefined; + // BRIEF.md decides; the flag only covers projects briefed before it existed. + const resolvedWorkflow = fromBrief ?? fromFlag; + if (!resolvedWorkflow) { + throw new Error("no workflow found — BRIEF.md names none and no --workflow was given"); + } + const frameSpec = join(root, "frame.md"); + const storyboard = join(root, "STORYBOARD.md"); + if (!existsSync(frameSpec)) throw new Error("no frame.md to freeze — run the design step first"); + if (!existsSync(storyboard)) throw new Error("no STORYBOARD.md to freeze"); + + const dir = join(projectRecipesDir(root), slug); + let version = 1; + const previous = existsSync(dir) ? readRecipeJson(dir) : null; + if (previous) { + version = (Number.isInteger(previous.version) ? previous.version : 1) + 1; + const archive = `${dir}@v${previous.version ?? 1}`; + rmSync(archive, { recursive: true, force: true }); + renameSync(dir, archive); + } + mkdirSync(dir, { recursive: true }); + + const prefs = mergedPreferences(root); + const recipe = { + version, + name: slug, + workflow: resolvedWorkflow, + approved_at: new Date().toISOString(), + source_project: basename(root), + destination: prefValue(prefs, "destination"), + aspect: prefValue(prefs, "aspect"), + language: prefValue(prefs, "language"), + voice: prefValue(prefs, "voice"), + // The bare-key fallback tolerates records made before the store required + // style_preset to be workflow-scoped. + style_preset: + prefValue(prefs, `style_preset.${resolvedWorkflow}`) ?? prefValue(prefs, "style_preset"), + blocks: Array.isArray(blocks) && blocks.length > 0 ? blocks : undefined, + }; + + writeFileSync(join(dir, "recipe.json"), `${JSON.stringify(recipe, null, 2)}\n`); + cpSync(frameSpec, join(dir, "frame.md")); + writeFileSync( + join(dir, "storyboard-skeleton.md"), + `${skeletonizeStoryboard(readFileSync(storyboard, "utf8")).trimEnd()}\n`, + ); + + // Best-effort fourth artifact — projects briefed before BRIEF.md existed + // (or by workflows that don't write one) freeze fine without it. + const brief = join(root, "BRIEF.md"); + const briefSkeleton = existsSync(brief); + if (briefSkeleton) { + writeFileSync( + join(dir, "brief-skeleton.md"), + `${skeletonizeBrief(readFileSync(brief, "utf8")).trimEnd()}\n`, + ); + } + + const id = nextId(root, "recipe"); + appendRecord(root, { + id, + type: "recipe", + path: `.media/recipes/${slug}/recipe.json`, + entity: slug, + description: `recipe: ${slug} (${recipe.workflow}, v${version})`, + provenance: { provider: "recipe.freeze", version, source_project: recipe.source_project }, + }); + regenerateIndex(root); + + // User tier — best-effort, like every other promotion. + try { + const userDir = join(userRecipesDir(), slug); + mkdirSync(userDir, { recursive: true }); + cpSync(dir, userDir, { recursive: true, force: true }); + } catch { + // The project-tier freeze already landed. + } + + return { + id, + slug, + version, + dir, + briefSkeleton, + workflow: resolvedWorkflow, + workflowOverridden: Boolean(fromBrief && fromFlag && fromBrief !== fromFlag), + }; +} + +function scanRecipesDir(dir, source) { + if (!existsSync(dir)) return []; + const found = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.includes("@v")) continue; + const recipe = readRecipeJson(join(dir, entry.name)); + if (recipe) found.push({ ...recipe, source, dir: join(dir, entry.name) }); + } + return found; +} + +/** Two-tier merged listing (project wins), newest approval first. */ +export function listRecipes({ projectDir, workflow }) { + const merged = new Map(); + for (const recipe of scanRecipesDir(userRecipesDir(), "user")) merged.set(recipe.name, recipe); + for (const recipe of scanRecipesDir(projectRecipesDir(resolve(projectDir)), "project")) { + merged.set(recipe.name, recipe); + } + let list = [...merged.values()]; + if (workflow) list = list.filter((r) => r.workflow === workflow); + return list.sort((a, b) => + String(b.approved_at ?? "").localeCompare(String(a.approved_at ?? "")), + ); +} + +/** + * Adopt a recipe into the current project: import the folder from the user + * tier when the project doesn't have it, copy its frame.md over the project's, + * and hand back the values + the skeleton path for the storyboard draft. + */ +export function useRecipe({ projectDir, name }) { + const slug = slugifyRecipeName(name); + const root = resolve(projectDir); + let dir = join(projectRecipesDir(root), slug); + + if (!readRecipeJson(dir)) { + const userDir = join(userRecipesDir(), slug); + if (!readRecipeJson(userDir)) { + const known = listRecipes({ projectDir: root }).map((r) => r.name); + throw new Error( + `no recipe named "${slug}"${known.length ? ` (known: ${known.join(", ")})` : ""}`, + ); + } + mkdirSync(dir, { recursive: true }); + cpSync(userDir, dir, { recursive: true, force: true }); + const imported = readRecipeJson(dir); + appendRecord(root, { + id: nextId(root, "recipe"), + type: "recipe", + path: `.media/recipes/${slug}/recipe.json`, + entity: slug, + description: `recipe: ${slug} (${imported.workflow}, v${imported.version})`, + provenance: { provider: "recipe.local", imported_from: "user-tier" }, + }); + regenerateIndex(root); + } + + const recipe = readRecipeJson(dir); + cpSync(join(dir, "frame.md"), join(root, "frame.md")); + return { + recipe, + dir, + frameSpecPath: "frame.md", + skeletonPath: `.media/recipes/${slug}/storyboard-skeleton.md`, + // Recipes frozen before BRIEF.md existed have no brief skeleton — degrade. + briefSkeletonPath: existsSync(join(dir, "brief-skeleton.md")) + ? `.media/recipes/${slug}/brief-skeleton.md` + : undefined, + }; +} diff --git a/skills/media-use/scripts/lib/telemetry.mjs b/skills/media-use/scripts/lib/telemetry.mjs index b000130d69..cd2911f529 100644 --- a/skills/media-use/scripts/lib/telemetry.mjs +++ b/skills/media-use/scripts/lib/telemetry.mjs @@ -1 +1,193 @@ -export * from "../../../../packages/cli/src/media-use/lib/telemetry.mjs"; +// 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"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx"; +const POSTHOG_HOST = "https://us.i.posthog.com"; +const TIMEOUT_MS = 1500; +let identifiedAccount = false; +let warnedNonDefaultHost = false; + +function isTestOrCiContext() { + return ( + process.env.CI === "true" || + process.env.CI === "1" || + process.env.NODE_ENV === "test" || + process.env.NODE_ENV === "development" + ); +} + +function posthogHost() { + const override = process.env.MEDIA_USE_TELEMETRY_HOST; + if (override && !warnedNonDefaultHost && !isTestOrCiContext()) { + warnedNonDefaultHost = true; + console.error( + `media-use: telemetry is redirected to a non-default host via MEDIA_USE_TELEMETRY_HOST (${override}) — unset it unless this is intentional.`, + ); + } + return override || POSTHOG_HOST; +} + +/** True when telemetry must NOT be sent (opt-out envs, CI, dev). */ +export function optedOut() { + return ( + process.env.HYPERFRAMES_NO_TELEMETRY === "1" || + process.env.DO_NOT_TRACK === "1" || + process.env.CI === "true" || + process.env.CI === "1" || + process.env.NODE_ENV === "development" + ); +} + +// Read and write the shared config so media-use keeps one identity per install. +function sharedConfigPath() { + return join(homedir(), ".hyperframes", "config.json"); +} + +function readSharedConfig() { + try { + const file = sharedConfigPath(); + if (existsSync(file)) { + const parsed = JSON.parse(readFileSync(file, "utf8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } + } catch { + // unreadable config → treat as empty; never throw + } + return {}; +} + +function writeSharedConfig(config) { + const dir = join(homedir(), ".hyperframes"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n"); +} + +// Adopt a pre-existing media-use-only id (~/.media/anon-id from before this +// change) so upgraders keep their PostHog persona instead of resetting to a new +// one — otherwise cross-surface continuity would start over on upgrade. +function legacyMediaAnonId() { + try { + const file = join(homedir(), ".media", "anon-id"); + if (existsSync(file)) { + const id = readFileSync(file, "utf8").trim(); + if (id) return id; + } + } catch { + // ignore + } + return null; +} + +// Stable per-machine id from the shared config; seeds it (adopting a legacy +// media-use id when present) if absent. +function anonymousId() { + try { + const config = readSharedConfig(); + if (typeof config.anonymousId === "string" && config.anonymousId.trim()) { + return config.anonymousId.trim(); + } + const id = legacyMediaAnonId() || randomUUID(); + writeSharedConfig({ ...config, anonymousId: id }); + return id; + } catch { + return "anon"; // best-effort; a shared bucket is fine if the fs is read-only + } +} + +function heygenAccountDistinctId() { + const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials"); + try { + if (!existsSync(file)) return null; + const raw = readFileSync(file, "utf8").trim(); + if (!raw.startsWith("{")) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const user = parsed.user; + if (!user || typeof user !== "object" || Array.isArray(user)) return null; + const id = typeof user.email === "string" && user.email.trim() ? user.email : user.username; + // Lowercased so this joins with the CLI's own identify call regardless of + // the account's stored email casing — two different-case distinct ids + // would otherwise split one person across two PostHog profiles. + return typeof id === "string" && id.trim() ? id.trim().toLowerCase() : null; + } catch { + return null; + } +} + +function showTelemetryNotice() { + if (optedOut()) return; + try { + const config = readSharedConfig(); + // Shared with the CLI (config.telemetryNoticeShown): shown once per person + // across surfaces, not once per tool. + if (config.telemetryNoticeShown === true) return; + console.error( + [ + "media-use sends usage telemetry: media type, resolution source, and provider; never intent text, file names, or paths.", + "If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.", + ].join("\n"), + ); + writeSharedConfig({ ...config, telemetryNoticeShown: true }); + } catch { + // notice is best-effort; never surface into the command + } +} + +async function postBatch(batch) { + try { + await fetch(`${posthogHost()}/batch/`, { + method: "POST", + headers: { "Content-Type": "application/json", Connection: "close" }, + body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch { + // telemetry is best-effort; never surface into the command + } +} + +async function postEvent(event, properties, distinctId) { + await postBatch([ + { + event, + properties: { ...properties, surface: "media-use", $ip: null }, + distinct_id: distinctId, + timestamp: new Date().toISOString(), + }, + ]); +} + +async function identifyAccount(anonId) { + if (optedOut() || identifiedAccount) return; + const distinctId = heygenAccountDistinctId(); + if (!distinctId) return; + identifiedAccount = true; + await postEvent("$identify", { $anon_distinct_id: anonId }, distinctId); +} + +/** + * Fire-and-forget a single event to PostHog. Best-effort: awaited with a short + * timeout so a short-lived script flushes before exit, but any failure (offline, + * opted out) is swallowed. `properties` must be non-PII (no intent/paths). + */ +export async function track(event, properties = {}) { + if (optedOut()) return; + showTelemetryNotice(); + const anonId = anonymousId(); + await identifyAccount(anonId); + await postEvent(event, properties, anonId); +} + +export function __anonymousIdForTest() { + return anonymousId(); +} + +export function __resetTelemetryForTest() { + identifiedAccount = false; + warnedNonDefaultHost = false; +} diff --git a/skills/media-use/scripts/lib/words.mjs b/skills/media-use/scripts/lib/words.mjs new file mode 100644 index 0000000000..114cf766ce --- /dev/null +++ b/skills/media-use/scripts/lib/words.mjs @@ -0,0 +1,18 @@ +export function normalizeWords(input) { + const raw = Array.isArray(input) ? input : Array.isArray(input?.words) ? input.words : []; + return raw + .map((w, index) => { + const text = String(w?.text ?? w?.word ?? "").trim(); + const start = Number(w?.start); + const end = Number(w?.end); + if (!text || !Number.isFinite(start) || !Number.isFinite(end)) return null; + return { id: w?.id ?? `w${index}`, text, start, end }; + }) + .filter(Boolean); +} + +export function wordListsFromMediaMeta(input) { + if (Array.isArray(input) || Array.isArray(input?.words)) return [normalizeWords(input)]; + if (!Array.isArray(input?.voices)) return []; + return input.voices.map((voice) => normalizeWords(voice)).filter((words) => words.length > 0); +} From 28863b5abf4dea9550e196aeb043ba3643407398 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 13:19:00 -0400 Subject: [PATCH 41/49] test(cli): cover local vector validation --- .../cli/src/registry/localSemantic.test.ts | 159 +++++++++++++++++- packages/cli/src/registry/localSemantic.ts | 10 +- skills-manifest.json | 4 +- 3 files changed, 162 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/registry/localSemantic.test.ts b/packages/cli/src/registry/localSemantic.test.ts index 701654dca5..faac25f35f 100644 --- a/packages/cli/src/registry/localSemantic.test.ts +++ b/packages/cli/src/registry/localSemantic.test.ts @@ -3,7 +3,12 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; -import { cachedLocalVectorRevision, fetchLocalVectors } from "./localSemantic.js"; +import { + cachedLocalVectorRevision, + fetchLocalVectors, + isMediaVectorRow, + vectorPairAgrees, +} from "./localSemantic.js"; import { LOCAL_MODEL_DIMENSIONS } from "./localModel.js"; describe("fetchLocalVectors", () => { @@ -29,6 +34,28 @@ describe("fetchLocalVectors", () => { })), ); + const mediaRow = { + id: "click", + kind: "sfx", + title: "click", + description: "short click", + tags: ["ui"], + file: "click.mp3", + duration: 0.2, + }; + + const serveMediaPair = (metadata: unknown, floats = LOCAL_MODEL_DIMENSIONS) => + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => ({ + ok: true, + arrayBuffer: async () => + url.endsWith(".json") + ? new TextEncoder().encode(JSON.stringify(metadata)).buffer + : new Float32Array(floats).buffer, + })), + ); + it("writes both files into the cache directory", async () => { servePair(["whip-pan"], LOCAL_MODEL_DIMENSIONS, LOCAL_MODEL_DIMENSIONS); expect(await fetchLocalVectors("http://registry.test/", { directory: dir })).toBe(true); @@ -107,4 +134,134 @@ describe("fetchLocalVectors", () => { writeFileSync(join(dir, "local-vectors.bin"), new Float32Array(LOCAL_MODEL_DIMENSIONS)); expect(cachedLocalVectorRevision(dir)).toBe("r1"); }); + + it("accepts a complete media-vector pair with validated rows", async () => { + serveMediaPair({ + names: ["click"], + dimensions: LOCAL_MODEL_DIMENSIONS, + rows: [mediaRow], + }); + + expect( + await fetchLocalVectors("http://registry.test", { + directory: dir, + artifactBasename: "media-vectors", + }), + ).toBe(true); + expect(existsSync(join(dir, "media-vectors.json"))).toBe(true); + expect(existsSync(join(dir, "media-vectors.bin"))).toBe(true); + }); + + it.each([ + ["missing rows", {}], + ["wrong row count", { rows: [] }], + ["wrong row shape", { rows: [{ ...mediaRow, tags: ["ui", 3] }] }], + ["wrong row id", { rows: [{ ...mediaRow, id: "other" }] }], + ["invalid duration", { rows: [{ ...mediaRow, duration: -1 }] }], + ["invalid dimensions", { rows: [{ ...mediaRow, dimensions: { width: 0, height: 10 } }] }], + ])("rejects media-vector metadata with %s", async (_reason, extra) => { + serveMediaPair({ + names: ["click"], + dimensions: LOCAL_MODEL_DIMENSIONS, + ...extra, + }); + + expect( + await fetchLocalVectors("http://registry.test", { + directory: dir, + artifactBasename: "media-vectors", + }), + ).toBe(false); + expect(existsSync(join(dir, "media-vectors.json"))).toBe(false); + }); + + it("rejects an unreadable media-vector metadata response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => ({ + ok: true, + arrayBuffer: async () => + url.endsWith(".json") + ? new TextEncoder().encode("not json").buffer + : new Float32Array(LOCAL_MODEL_DIMENSIONS).buffer, + })), + ); + + expect( + await fetchLocalVectors("http://registry.test", { + directory: dir, + artifactBasename: "media-vectors", + }), + ).toBe(false); + }); +}); + +describe("media-vector validation", () => { + const row = { + id: "click", + kind: "sfx", + title: "click", + description: "short click", + tags: ["ui"], + file: "click.mp3", + duration: 0.2, + }; + + it.each([ + ["null", null], + ["missing id", { ...row, id: undefined }], + ["non-string tags", { ...row, tags: ["ui", 3] }], + ["negative duration", { ...row, duration: -1 }], + ["zero width", { ...row, dimensions: { width: 0, height: 10 } }], + ])("rejects %s rows", (_name, candidate) => { + expect(isMediaVectorRow(candidate)).toBe(false); + }); + + it("accepts a complete media row", () => { + expect(isMediaVectorRow(row)).toBe(true); + expect(isMediaVectorRow({ ...row, dimensions: { width: 1920, height: 1080 } })).toBe(true); + }); + + it.each([ + ["missing metadata", [["media-vectors.bin", Buffer.alloc(4)]]], + ["missing matrix", [["media-vectors.json", Buffer.from("{}")]]], + [ + "invalid json", + [ + ["media-vectors.json", Buffer.from("not json")], + ["media-vectors.bin", Buffer.alloc(4)], + ], + ], + [ + "invalid media row", + [ + [ + "media-vectors.json", + Buffer.from( + JSON.stringify({ names: ["click"], dimensions: 1, rows: [{ ...row, id: "other" }] }), + ), + ], + ["media-vectors.bin", Buffer.alloc(4)], + ], + ], + ])("rejects pairs with %s", (_name, fetched) => { + expect(vectorPairAgrees(fetched, "media-vectors")).toBe(false); + }); + + it("accepts a matching media-vector pair", () => { + const metadata = { + names: ["click"], + dimensions: 1, + rows: [row], + }; + expect( + vectorPairAgrees( + [ + ["media-vectors.json", Buffer.from(JSON.stringify(metadata))], + ["media-vectors.bin", Buffer.alloc(4)], + ], + "media-vectors", + ), + ).toBe(true); + }); }); diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index f5770aefca..4b5a13d3d3 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -44,9 +44,7 @@ export interface MediaVectorRow { dimensions?: { width: number; height: number }; } -// Defensive row validation keeps malformed downloaded metadata out of the ranker. -// fallow-ignore-next-line high-crap-score -function isMediaVectorRow(value: unknown): value is MediaVectorRow { +export function isMediaVectorRow(value: unknown): value is MediaVectorRow { if (!value || typeof value !== "object") return false; const row = value as Partial; return ( @@ -102,9 +100,7 @@ function localVectorDirectory(): string { * contract. A pair that fails it is a truncated download or a different * model, never something worth caching. */ -// Pair validation checks the metadata, row coverage, dimensions, and byte count together. -// fallow-ignore-next-line high-crap-score -function vectorPairAgrees( +export function vectorPairAgrees( fetched: Array<[string, Buffer]>, artifactBasename: "local-vectors" | "media-vectors", ): boolean { @@ -326,8 +322,6 @@ export async function localSemanticRanking( return scored; } -// Internal ranker entry used by the CLI's vector-aware media search library. -// fallow-ignore-next-line unused-export export async function mediaSemanticRanking( query: string, directory = localVectorDirectory(), diff --git a/skills-manifest.json b/skills-manifest.json index e9ccec5509..44ee257b8b 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,8 +54,8 @@ "files": 1 }, "media-use": { - "hash": "32f3faf52fd6904d", - "files": 79 + "hash": "e5376453c0d08ac3", + "files": 88 }, "motion-graphics": { "hash": "32641ae2b94c4a8f", From 6a7e36b2b32a7c3c93a30464a85008f37955c376 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 13:43:07 -0400 Subject: [PATCH 42/49] test(cli): correct media vector fixtures --- packages/cli/src/registry/localSemantic.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/registry/localSemantic.test.ts b/packages/cli/src/registry/localSemantic.test.ts index faac25f35f..d775f26e48 100644 --- a/packages/cli/src/registry/localSemantic.test.ts +++ b/packages/cli/src/registry/localSemantic.test.ts @@ -7,6 +7,7 @@ import { cachedLocalVectorRevision, fetchLocalVectors, isMediaVectorRow, + mediaSemanticRanking, vectorPairAgrees, } from "./localSemantic.js"; import { LOCAL_MODEL_DIMENSIONS } from "./localModel.js"; @@ -135,6 +136,10 @@ describe("fetchLocalVectors", () => { expect(cachedLocalVectorRevision(dir)).toBe("r1"); }); + it("reports no semantic result without a media-vector cache", async () => { + expect(await mediaSemanticRanking("click", dir)).toBeNull(); + }); + it("accepts a complete media-vector pair with validated rows", async () => { serveMediaPair({ names: ["click"], @@ -222,7 +227,7 @@ describe("media-vector validation", () => { expect(isMediaVectorRow({ ...row, dimensions: { width: 1920, height: 1080 } })).toBe(true); }); - it.each([ + const invalidPairs: Array<[string, Array<[string, Buffer]>]> = [ ["missing metadata", [["media-vectors.bin", Buffer.alloc(4)]]], ["missing matrix", [["media-vectors.json", Buffer.from("{}")]]], [ @@ -244,21 +249,23 @@ describe("media-vector validation", () => { ["media-vectors.bin", Buffer.alloc(4)], ], ], - ])("rejects pairs with %s", (_name, fetched) => { + ]; + + it.each(invalidPairs)("rejects pairs with %s", (_name, fetched) => { expect(vectorPairAgrees(fetched, "media-vectors")).toBe(false); }); it("accepts a matching media-vector pair", () => { const metadata = { names: ["click"], - dimensions: 1, + dimensions: LOCAL_MODEL_DIMENSIONS, rows: [row], }; expect( vectorPairAgrees( [ ["media-vectors.json", Buffer.from(JSON.stringify(metadata))], - ["media-vectors.bin", Buffer.alloc(4)], + ["media-vectors.bin", Buffer.alloc(4 * LOCAL_MODEL_DIMENSIONS)], ], "media-vectors", ), From 72cddf299b1f9748ddd58b613b98c4d4f346e141 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 14:31:10 -0400 Subject: [PATCH 43/49] fix(cli): prefer exact media keys --- .../lib/bundled-sfx-provider.test.mjs | 21 +++++++++++++++++++ .../cli/src/media-use/lib/media-search.mjs | 13 ++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs b/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs index 869fe896f3..bba185f10a 100644 --- a/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs +++ b/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs @@ -83,3 +83,24 @@ test("accepts a complete bundled SFX library", () => { rmSync(libraryDir, { recursive: true, force: true }); } }); + +test("prefers an exact key over a longer key with the same words", async () => { + const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-exact-key-")); + try { + writeFileSync( + join(libraryDir, "manifest.json"), + JSON.stringify({ + "whoosh-cinematic": { file: "whoosh-cinematic.mp3", description: "long whoosh" }, + whoosh: { file: "whoosh.mp3", description: "short whoosh" }, + }), + ); + writeFileSync(join(libraryDir, "whoosh-cinematic.mp3"), "cinematic audio"); + writeFileSync(join(libraryDir, "whoosh.mp3"), "exact audio"); + + const result = await bundledSfxProvider.search("whoosh", { libraryDir }); + assert.equal(result?.localPath, join(libraryDir, "whoosh.mp3")); + assert.equal(result?.metadata.provenance.library_key, "whoosh"); + } finally { + rmSync(libraryDir, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/media-use/lib/media-search.mjs b/packages/cli/src/media-use/lib/media-search.mjs index 86a3b1734d..16f36b5009 100644 --- a/packages/cli/src/media-use/lib/media-search.mjs +++ b/packages/cli/src/media-use/lib/media-search.mjs @@ -43,7 +43,12 @@ export function rankMediaRows(query, rows) { ...strongTokens, ...tokenize(`${row.description} ${row.tags.join(" ")} ${row.kind}`), ]); - return { row, strongTokens, allTokens }; + return { + row, + strongTokens, + allTokens, + exactId: row.id.toLowerCase() === String(query).trim().toLowerCase(), + }; }); const vocabulary = new Set(parsed.flatMap(({ allTokens }) => [...allTokens])); for (const token of asked) { @@ -69,17 +74,17 @@ export function rankMediaRows(query, rows) { idf.set(token, Math.log((parsed.length + 1) / (documentFrequency + 1)) + 1); } return parsed - .map(({ row, strongTokens, allTokens }) => { + .map(({ row, strongTokens, allTokens, exactId }) => { let score = 0; for (const [token, asking] of want) { const weight = (idf.get(token) || 1) * asking; if (strongTokens.has(token)) score += STRONG_FIELD_WEIGHT * weight; else if (allTokens.has(token)) score += weight; } - return { row, score }; + return { row, score, exactId }; }) .filter(({ score }) => score > 0) - .sort((a, b) => b.score - a.score) + .sort((a, b) => Number(b.exactId) - Number(a.exactId) || b.score - a.score) .map(({ row }) => row); } From ee570aedff159b1163d4120acce2068e72bf9197 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 15:11:49 -0400 Subject: [PATCH 44/49] fix(cli): stem exact media keys --- packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs | 4 ++++ packages/cli/src/media-use/lib/media-search.mjs | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs b/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs index bba185f10a..f3652a70a1 100644 --- a/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs +++ b/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs @@ -100,6 +100,10 @@ test("prefers an exact key over a longer key with the same words", async () => { const result = await bundledSfxProvider.search("whoosh", { libraryDir }); assert.equal(result?.localPath, join(libraryDir, "whoosh.mp3")); assert.equal(result?.metadata.provenance.library_key, "whoosh"); + + const stemmed = await bundledSfxProvider.search("whooshes", { libraryDir }); + assert.equal(stemmed?.localPath, join(libraryDir, "whoosh.mp3")); + assert.equal(stemmed?.metadata.provenance.library_key, "whoosh"); } finally { rmSync(libraryDir, { recursive: true, force: true }); } diff --git a/packages/cli/src/media-use/lib/media-search.mjs b/packages/cli/src/media-use/lib/media-search.mjs index 16f36b5009..a08fc571b8 100644 --- a/packages/cli/src/media-use/lib/media-search.mjs +++ b/packages/cli/src/media-use/lib/media-search.mjs @@ -34,6 +34,7 @@ function tokenize(text) { export function rankMediaRows(query, rows) { const asked = tokenize(query); + const normalizedQuery = asked.join(" "); const want = new Map(asked.map((token) => [token, 1])); if (want.size === 0) return []; @@ -47,7 +48,7 @@ export function rankMediaRows(query, rows) { row, strongTokens, allTokens, - exactId: row.id.toLowerCase() === String(query).trim().toLowerCase(), + exactId: tokenize(row.id).join(" ") === normalizedQuery, }; }); const vocabulary = new Set(parsed.flatMap(({ allTokens }) => [...allTokens])); From 615a7ffd704d16eff4bfdd4a8c6996cbbcf4d2b2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 15:38:48 -0400 Subject: [PATCH 45/49] test(cli): skip media lint for snippets without media --- packages/cli/src/registry/registryComponents.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/registry/registryComponents.test.ts b/packages/cli/src/registry/registryComponents.test.ts index 37537c759b..4f3cfa1969 100644 --- a/packages/cli/src/registry/registryComponents.test.ts +++ b/packages/cli/src/registry/registryComponents.test.ts @@ -22,9 +22,9 @@ async function invalidInstallableMedia(entryName: string): Promise { for (const file of manifest.files) { if (file.type !== "hyperframes:snippet" || !file.path.endsWith(".html")) continue; - const result = await lintHyperframeHtml(readFileSync(join(itemDir, file.path), "utf8"), { - isSubComposition: true, - }); + const html = readFileSync(join(itemDir, file.path), "utf8"); + if (!/<(?:audio|video)\b/i.test(html)) continue; + const result = await lintHyperframeHtml(html, { isSubComposition: true }); for (const finding of result.findings) { if (finding.code !== "media_missing_src") continue; invalidMedia.push(`${entryName}/${file.path}: ${finding.code}`); From c0e053af7b9b8f5185c4f2e7bfb1d3f7df524189 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:22:55 -0400 Subject: [PATCH 46/49] fix(cli): align skills test discovery Keep the CI skills job's bash discovery as the single source of truth so its empty-list guard covers the exact files Node executes. Make hasMediaVectors private because it has no external callers and needs no Fallow suppression. --- .github/workflows/ci.yml | 3 +-- packages/cli/src/registry/localSemantic.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6387faaaf..c214d6b521 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -391,7 +391,6 @@ 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 @@ -405,7 +404,7 @@ jobs: 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)" diff --git a/packages/cli/src/registry/localSemantic.ts b/packages/cli/src/registry/localSemantic.ts index 4b5a13d3d3..6bcab7281b 100644 --- a/packages/cli/src/registry/localSemantic.ts +++ b/packages/cli/src/registry/localSemantic.ts @@ -198,8 +198,7 @@ export function hasLocalVectors(directory = localVectorDirectory()): boolean { ); } -// fallow-ignore-next-line unused-export -export function hasMediaVectors(directory = localVectorDirectory()): boolean { +function hasMediaVectors(directory = localVectorDirectory()): boolean { return ( existsSync(join(directory, "media-vectors.bin")) && existsSync(join(directory, "media-vectors.json")) From b8890707badabdc6aff39841dcce391b1051b090 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:27:54 -0400 Subject: [PATCH 47/49] fix(cli): close media-use review gaps Prioritize literal ids before stemmed ids and cover the collision alongside the plural regression. Add command seam tests for engine discovery, actionable failure, and argv passthrough. Add a deterministic parity check for standalone media-use copies, with documented allowlists for the standalone media-fetch shim and self-contained npx resolver. Keep the bash-discovered skills test list as the CI source of truth. --- package.json | 2 +- packages/cli/src/commands/media-use.test.ts | 57 +++++++++++++++++++ packages/cli/src/commands/media-use.ts | 34 +++++++---- .../lib/bundled-sfx-provider.test.mjs | 29 ++++++++++ .../cli/src/media-use/lib/media-search.mjs | 15 +++-- scripts/check-media-use-copy-parity.test.mjs | 56 ++++++++++++++++++ 6 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/commands/media-use.test.ts create mode 100644 scripts/check-media-use-copy-parity.test.mjs diff --git a/package.json b/package.json index c438979507..d812a90a72 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "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/", + "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-media-use-copy-parity.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' 'packages/cli/src/media-use/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/packages/cli/src/commands/media-use.test.ts b/packages/cli/src/commands/media-use.test.ts new file mode 100644 index 0000000000..ae66a456a2 --- /dev/null +++ b/packages/cli/src/commands/media-use.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mediaUsePassthroughArgs, resolveMediaUseEnginePath } from "./media-use.js"; + +function tempCommandDir(): string { + return mkdtempSync(join(tmpdir(), "hyperframes-media-use-command-")); +} + +describe("media-use command wiring", () => { + it("uses the bundled engine when the first candidate exists", () => { + const here = tempCommandDir(); + const engine = join(here, "..", "media-use", "resolve.mjs"); + try { + expect(resolveMediaUseEnginePath(here, (candidate) => candidate === engine)).toBe(engine); + } finally { + rmSync(here, { recursive: true, force: true }); + } + }); + + it("falls back to the source-tree skill engine", () => { + const here = tempCommandDir(); + const engine = join(here, "skills", "media-use", "scripts", "resolve.mjs"); + try { + expect(resolveMediaUseEnginePath(here, (candidate) => candidate === engine)).toBe(engine); + } finally { + rmSync(here, { recursive: true, force: true }); + } + }); + + it("explains how to recover when the engine is absent", () => { + const here = tempCommandDir(); + try { + expect(() => resolveMediaUseEnginePath(here)).toThrow( + "media-use engine is missing from this CLI build; reinstall the CLI or run from a source checkout", + ); + } finally { + rmSync(here, { recursive: true, force: true }); + } + }); + + it("passes flags after the media-use verb through unchanged", () => { + expect( + mediaUsePassthroughArgs([ + "/usr/bin/node", + "cli.js", + "media-use", + "resolve", + "--type", + "sfx", + "--intent", + "cat", + ]), + ).toEqual(["--type", "sfx", "--intent", "cat"]); + }); +}); diff --git a/packages/cli/src/commands/media-use.ts b/packages/cli/src/commands/media-use.ts index 16da913d29..6729573637 100644 --- a/packages/cli/src/commands/media-use.ts +++ b/packages/cli/src/commands/media-use.ts @@ -29,25 +29,39 @@ const MEDIA_USE_ARGS = { help: { type: "boolean", alias: "h" }, } as const; -function enginePath(): string { - const here = dirname(fileURLToPath(import.meta.url)); +export function resolveMediaUseEnginePath( + here: string, + fileExists: (path: string) => boolean = existsSync, +): string { const candidates = [ join(here, "..", "media-use", "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"); + const engine = candidates.find((candidate) => fileExists(candidate)); + if (!engine) { + throw new Error( + "media-use engine is missing from this CLI build; reinstall the CLI or run from a source checkout", + ); + } return engine; } +export function mediaUsePassthroughArgs(argv: readonly string[]): string[] { + const commandIndex = argv.indexOf("media-use"); + return argv.slice(commandIndex + 2); +} + function invokeEngine(verb: string): never { - const commandIndex = process.argv.indexOf("media-use"); - const verbIndex = commandIndex + 1; - const passed = process.argv.slice(verbIndex + 1); + const here = dirname(fileURLToPath(import.meta.url)); + const passed = mediaUsePassthroughArgs(process.argv); const flag = verb === "resolve" ? [] : [`--${verb}`]; - const result = spawnSync(process.execPath, [enginePath(), ...flag, ...passed], { - stdio: "inherit", - }); + const result = spawnSync( + process.execPath, + [resolveMediaUseEnginePath(here), ...flag, ...passed], + { + stdio: "inherit", + }, + ); if (result.error) throw result.error; finishCommand(result.status ?? 1); } diff --git a/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs b/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs index f3652a70a1..7609e57549 100644 --- a/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs +++ b/packages/cli/src/media-use/lib/bundled-sfx-provider.test.mjs @@ -10,6 +10,7 @@ import { extensionForBundledSfxFile, inspectBundledSfxAssets, } from "./bundled-sfx-provider.mjs"; +import { rankMediaRows } from "./media-search.mjs"; test("derives bundled SFX extension from the manifest filename", () => { assert.equal(extensionForBundledSfxFile("impact.wav"), ".wav"); @@ -108,3 +109,31 @@ test("prefers an exact key over a longer key with the same words", async () => { rmSync(libraryDir, { recursive: true, force: true }); } }); + +test("literal ids outrank distinct ids with the same stem", () => { + const rows = [ + { + id: "cats", + title: "Cats Fighting", + description: "fighting cats", + tags: ["yowl", "fight"], + kind: "sfx", + }, + { + id: "cat", + title: "Cat", + description: "single cat", + tags: ["meow"], + kind: "sfx", + }, + ]; + + assert.deepEqual( + rankMediaRows("cat", rows).map((row) => row.id), + ["cat", "cats"], + ); + assert.deepEqual( + rankMediaRows("cats", rows).map((row) => row.id), + ["cats", "cat"], + ); +}); diff --git a/packages/cli/src/media-use/lib/media-search.mjs b/packages/cli/src/media-use/lib/media-search.mjs index a08fc571b8..ab1c6b31e5 100644 --- a/packages/cli/src/media-use/lib/media-search.mjs +++ b/packages/cli/src/media-use/lib/media-search.mjs @@ -34,6 +34,7 @@ function tokenize(text) { export function rankMediaRows(query, rows) { const asked = tokenize(query); + const normalizedLiteralQuery = String(query).trim().toLowerCase(); const normalizedQuery = asked.join(" "); const want = new Map(asked.map((token) => [token, 1])); if (want.size === 0) return []; @@ -48,7 +49,8 @@ export function rankMediaRows(query, rows) { row, strongTokens, allTokens, - exactId: tokenize(row.id).join(" ") === normalizedQuery, + literalId: row.id.trim().toLowerCase() === normalizedLiteralQuery, + stemmedId: tokenize(row.id).join(" ") === normalizedQuery, }; }); const vocabulary = new Set(parsed.flatMap(({ allTokens }) => [...allTokens])); @@ -75,17 +77,22 @@ export function rankMediaRows(query, rows) { idf.set(token, Math.log((parsed.length + 1) / (documentFrequency + 1)) + 1); } return parsed - .map(({ row, strongTokens, allTokens, exactId }) => { + .map(({ row, strongTokens, allTokens, literalId, stemmedId }) => { let score = 0; for (const [token, asking] of want) { const weight = (idf.get(token) || 1) * asking; if (strongTokens.has(token)) score += STRONG_FIELD_WEIGHT * weight; else if (allTokens.has(token)) score += weight; } - return { row, score, exactId }; + return { row, score, literalId, stemmedId }; }) .filter(({ score }) => score > 0) - .sort((a, b) => Number(b.exactId) - Number(a.exactId) || b.score - a.score) + .sort( + (a, b) => + Number(b.literalId) - Number(a.literalId) || + Number(b.stemmedId) - Number(a.stemmedId) || + b.score - a.score, + ) .map(({ row }) => row); } diff --git a/scripts/check-media-use-copy-parity.test.mjs b/scripts/check-media-use-copy-parity.test.mjs new file mode 100644 index 0000000000..d6a9734c3d --- /dev/null +++ b/scripts/check-media-use-copy-parity.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, it } from "node:test"; + +const skillLibDir = resolve("skills/media-use/scripts/lib"); +const cliLibDir = resolve("packages/cli/src/media-use/lib"); + +export const MEDIA_USE_COPY_NAMES = [ + "cutlist.mjs", + "duck.mjs", + "error-diffusion.mjs", + "index-gen.mjs", + "manifest.mjs", + "media-fetch.mjs", + "npx-sync.mjs", + "parakeet-words.mjs", + "prefs-store.mjs", + "recipe-store.mjs", + "telemetry.mjs", + "words.mjs", +]; + +export const INTENTIONAL_MEDIA_USE_DIVERGENCES = new Map([ + [ + "media-fetch.mjs", + "the standalone skill uses a shim because it cannot import the CLI package tree", + ], + [ + "npx-sync.mjs", + "the standalone skill stays self-contained while the CLI copy uses the shared audio helper", + ], +]); + +export function findMediaUseCopyParityIssues({ skillDir = skillLibDir, cliDir = cliLibDir } = {}) { + return MEDIA_USE_COPY_NAMES.flatMap((name) => { + const skillPath = join(skillDir, name); + const cliPath = join(cliDir, name); + if (!existsSync(skillPath) || !existsSync(cliPath)) { + return [`${name}: both media-use copies must exist`]; + } + if ( + !INTENTIONAL_MEDIA_USE_DIVERGENCES.has(name) && + !readFileSync(skillPath).equals(readFileSync(cliPath)) + ) { + return [`${name}: standalone and CLI copies differ without an allowlist reason`]; + } + return []; + }); +} + +describe("media-use source parity", () => { + it("keeps every standalone copy equal or explicitly allowlisted", () => { + assert.deepEqual(findMediaUseCopyParityIssues(), []); + }); +}); From 3654b5f185bbaba709d4285323a4bb4be491d530 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:50:06 -0400 Subject: [PATCH 48/49] fix(cli): lower parity check complexity Split missing-copy and unallowlisted-drift checks so the parity guard keeps its behavior without a threshold CRAP score. --- scripts/check-media-use-copy-parity.test.mjs | 31 +++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/scripts/check-media-use-copy-parity.test.mjs b/scripts/check-media-use-copy-parity.test.mjs index d6a9734c3d..4e68f7e3ab 100644 --- a/scripts/check-media-use-copy-parity.test.mjs +++ b/scripts/check-media-use-copy-parity.test.mjs @@ -32,21 +32,24 @@ export const INTENTIONAL_MEDIA_USE_DIVERGENCES = new Map([ ], ]); +function copyPaths(name, skillDir, cliDir) { + return { skillPath: join(skillDir, name), cliPath: join(cliDir, name) }; +} + export function findMediaUseCopyParityIssues({ skillDir = skillLibDir, cliDir = cliLibDir } = {}) { - return MEDIA_USE_COPY_NAMES.flatMap((name) => { - const skillPath = join(skillDir, name); - const cliPath = join(cliDir, name); - if (!existsSync(skillPath) || !existsSync(cliPath)) { - return [`${name}: both media-use copies must exist`]; - } - if ( - !INTENTIONAL_MEDIA_USE_DIVERGENCES.has(name) && - !readFileSync(skillPath).equals(readFileSync(cliPath)) - ) { - return [`${name}: standalone and CLI copies differ without an allowlist reason`]; - } - return []; - }); + const missing = MEDIA_USE_COPY_NAMES.filter((name) => { + const { skillPath, cliPath } = copyPaths(name, skillDir, cliDir); + return !existsSync(skillPath) || !existsSync(cliPath); + }).map((name) => `${name}: both media-use copies must exist`); + const drifted = MEDIA_USE_COPY_NAMES.filter( + (name) => !INTENTIONAL_MEDIA_USE_DIVERGENCES.has(name), + ) + .filter((name) => { + const { skillPath, cliPath } = copyPaths(name, skillDir, cliDir); + return !readFileSync(skillPath).equals(readFileSync(cliPath)); + }) + .map((name) => `${name}: standalone and CLI copies differ without an allowlist reason`); + return [...missing, ...drifted]; } describe("media-use source parity", () => { From 632d7d62c68de97a7296eea06cdf915698d8655a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 17:31:28 -0400 Subject: [PATCH 49/49] fix(cli): close media-use review seams Run the parity test in the skills job so skills-only edits exercise the same check. Export the verb mapping and inject command dispatch for direct coverage. Keep missing-copy diagnostics separate from drift reads. --- .github/workflows/ci.yml | 1 + packages/cli/src/commands/media-use.test.ts | 31 ++++++++++- packages/cli/src/commands/media-use.ts | 54 +++++++++++++------- scripts/check-media-use-copy-parity.test.mjs | 26 ++++++++-- 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c214d6b521..7449e587a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -402,6 +402,7 @@ jobs: echo "::error::No skills/**/*.test.mjs files found. Did the layout change?" exit 1 fi + SKILLS_TESTS+=(scripts/check-media-use-copy-parity.test.mjs) printf 'Running %d skills test file(s):\n' "${#SKILLS_TESTS[@]}" printf ' * %s\n' "${SKILLS_TESTS[@]}" node --test "${SKILLS_TESTS[@]}" diff --git a/packages/cli/src/commands/media-use.test.ts b/packages/cli/src/commands/media-use.test.ts index ae66a456a2..ade7b0ae19 100644 --- a/packages/cli/src/commands/media-use.test.ts +++ b/packages/cli/src/commands/media-use.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { mediaUsePassthroughArgs, resolveMediaUseEnginePath } from "./media-use.js"; +import { + createMediaUseCommand, + MEDIA_USE_VERBS, + mediaUsePassthroughArgs, + mediaUseVerbFlags, + resolveMediaUseEnginePath, +} from "./media-use.js"; function tempCommandDir(): string { return mkdtempSync(join(tmpdir(), "hyperframes-media-use-command-")); @@ -54,4 +60,27 @@ describe("media-use command wiring", () => { ]), ).toEqual(["--type", "sfx", "--intent", "cat"]); }); + + it("maps every verb to the engine flag, leaving resolve unflagged", () => { + for (const verb of MEDIA_USE_VERBS) { + expect(mediaUseVerbFlags(verb)).toEqual(verb === "resolve" ? [] : [`--${verb}`]); + } + }); + + it("wires every subcommand to invoke its matching verb", () => { + const invoked: string[] = []; + const command = createMediaUseCommand((verb) => { + invoked.push(verb); + throw new Error("stop after dispatch"); + }); + + for (const verb of MEDIA_USE_VERBS) { + const factory = (command.subCommands as Record | undefined)?.[verb]; + expect(factory).toBeTypeOf("function"); + const child = (factory as () => { run?: () => never })(); + expect(() => child.run?.()).toThrow("stop after dispatch"); + } + + expect(invoked).toEqual(MEDIA_USE_VERBS); + }); }); diff --git a/packages/cli/src/commands/media-use.ts b/packages/cli/src/commands/media-use.ts index 6729573637..0c222ee341 100644 --- a/packages/cli/src/commands/media-use.ts +++ b/packages/cli/src/commands/media-use.ts @@ -29,6 +29,19 @@ const MEDIA_USE_ARGS = { help: { type: "boolean", alias: "h" }, } as const; +export const MEDIA_USE_VERBS = [ + "resolve", + "doctor", + "stats", + "adopt", + "candidates", + "reuse", + "from", + "params", + "analyze", +] as const; +export type MediaUseVerb = (typeof MEDIA_USE_VERBS)[number]; + export function resolveMediaUseEnginePath( here: string, fileExists: (path: string) => boolean = existsSync, @@ -51,10 +64,16 @@ export function mediaUsePassthroughArgs(argv: readonly string[]): string[] { return argv.slice(commandIndex + 2); } -function invokeEngine(verb: string): never { +export function mediaUseVerbFlags(verb: MediaUseVerb): string[] { + return verb === "resolve" ? [] : [`--${verb}`]; +} + +type InvokeMediaUse = (verb: MediaUseVerb) => never; + +function invokeEngine(verb: MediaUseVerb): never { const here = dirname(fileURLToPath(import.meta.url)); const passed = mediaUsePassthroughArgs(process.argv); - const flag = verb === "resolve" ? [] : [`--${verb}`]; + const flag = mediaUseVerbFlags(verb); const result = spawnSync( process.execPath, [resolveMediaUseEnginePath(here), ...flag, ...passed], @@ -66,26 +85,23 @@ function invokeEngine(verb: string): never { finishCommand(result.status ?? 1); } -function subcommand(name: string) { +function subcommand(name: MediaUseVerb, invoke: InvokeMediaUse) { return defineCommand({ meta: { name, description: `media-use ${name}` }, args: MEDIA_USE_ARGS, - run: () => invokeEngine(name), + run: () => invoke(name), + }); +} + +export function createMediaUseCommand(invoke: InvokeMediaUse = invokeEngine) { + const subCommands = Object.fromEntries( + MEDIA_USE_VERBS.map((name) => [name, () => subcommand(name, invoke)]), + ); + return defineCommand({ + meta: { name: "media-use", description: "Resolve and operate on project media" }, + subCommands, + run: () => console.log("Run `hyperframes media-use --help`"), }); } -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`"), -}); +export default createMediaUseCommand(); diff --git a/scripts/check-media-use-copy-parity.test.mjs b/scripts/check-media-use-copy-parity.test.mjs index 4e68f7e3ab..a5bedac0c5 100644 --- a/scripts/check-media-use-copy-parity.test.mjs +++ b/scripts/check-media-use-copy-parity.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; import { describe, it } from "node:test"; const skillLibDir = resolve("skills/media-use/scripts/lib"); @@ -37,12 +38,13 @@ function copyPaths(name, skillDir, cliDir) { } export function findMediaUseCopyParityIssues({ skillDir = skillLibDir, cliDir = cliLibDir } = {}) { - const missing = MEDIA_USE_COPY_NAMES.filter((name) => { + const missingNames = MEDIA_USE_COPY_NAMES.filter((name) => { const { skillPath, cliPath } = copyPaths(name, skillDir, cliDir); return !existsSync(skillPath) || !existsSync(cliPath); - }).map((name) => `${name}: both media-use copies must exist`); + }); + const missing = missingNames.map((name) => `${name}: both media-use copies must exist`); const drifted = MEDIA_USE_COPY_NAMES.filter( - (name) => !INTENTIONAL_MEDIA_USE_DIVERGENCES.has(name), + (name) => !missingNames.includes(name) && !INTENTIONAL_MEDIA_USE_DIVERGENCES.has(name), ) .filter((name) => { const { skillPath, cliPath } = copyPaths(name, skillDir, cliDir); @@ -56,4 +58,20 @@ describe("media-use source parity", () => { it("keeps every standalone copy equal or explicitly allowlisted", () => { assert.deepEqual(findMediaUseCopyParityIssues(), []); }); + + it("reports a missing copy without reading it as drift", () => { + const root = mkdtempSync(join(tmpdir(), "media-use-parity-")); + const skillDir = join(root, "skill"); + const cliDir = join(root, "cli"); + try { + const name = MEDIA_USE_COPY_NAMES[0]; + mkdirSync(skillDir, { recursive: true }); + mkdirSync(cliDir, { recursive: true }); + writeFileSync(join(skillDir, name), "same"); + const issues = findMediaUseCopyParityIssues({ skillDir, cliDir }); + assert.equal(issues[0], `${name}: both media-use copies must exist`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); });