Files
hyperframes/skills/media-use/scripts/resolve.mjs
T
Miguel ÁngelandClaude Opus 4.8 b2dc353725 feat(media-use): resolve engine + BGM/SFX/image/icon providers (#1683)
* feat(media-use): core infrastructure — manifest, cache, adopt, probe

Foundation for media-use — the media resolution layer for HyperFrames.

- manifest.mjs: JSONL read/write/find for .media/manifest.jsonl
- index-gen.mjs: regenerate agent-readable index.md from manifest
- cache.mjs: content-addressed global cache at ~/.media/ (SHA-256, sentinel)
- freeze.mjs: download URL or copy local file to .media/
- probe.mjs: extract duration/dimensions via ffprobe
- adopt.mjs: scan assets/ directory, register existing files with metadata
- 19 passing tests (manifest round-trip, cache, promote, index generation)

* fix(media-use): oxfmt formatting + cap freeze download size

Format adopt/cache/probe/manifest.test (CI oxfmt --check gate).
Cap freezeUrl downloads at 256MB so a hostile/runaway URL can't fill
the disk (addresses CodeQL #670: network data written to file).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(media-use): resolve engine + all providers + brand from frame.md

- resolve.mjs: cheapest-first cascade
- BGM/SFX via heygen --headers, Image/Icon via heygen asset search
- Brand tokens from frame.md / design.md (local, no API)
- SKILL.md: full agent docs + hyperframes.dev/design redirect
- Router skill + workflow skill references

* fix(media-use): oxfmt formatting for resolve + providers

Format brand/heygen-search/providers/sfx providers + resolve.mjs
(CI oxfmt --check gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(media-use): align providers with the real heygen CLI surface (v0.1.6)

Verified live against the official Go `heygen` CLI v0.1.6 with a valid key:

- Caller attribution: pass `--headers 'X-HeyGen-Client-Source: media-use'`
  (the allowlisted flag the CLI added for media-use in v0.1.6). The old
  `--x-source media-use` was never a real flag and broke every call.
- Command is `asset search` (the `list` leaf was dropped in v0.1.6), not
  `asset search list`.
- `--min-score` is sent server-side: honored by `audio sounds list`, but the
  `asset search` backend rejects it and returns no score field, so only the
  audio providers pass it (image/icon don't).
- Drop hardcoded `ext` so resolve.mjs derives it from the URL: catalog icons
  are .png (not .svg), some BGM is .wav (not .mp3).

Also: surface CLI/auth failures on stderr instead of swallowing them as
'no results', carry icon width/height through, and document the heygen CLI
install + >= v0.1.6 requirement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:29:04 -04:00

248 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { resolve, join, extname } from "node:path";
import { parseArgs } from "node:util";
import { appendRecord, findByPrompt, findByEntity, nextId, typeSubdir } from "./lib/manifest.mjs";
import { regenerateIndex } from "./lib/index-gen.mjs";
import { cacheGet, cacheGetByEntity, importFromCache } from "./lib/cache.mjs";
import { getProvider, listTypes } from "./lib/providers.mjs";
import { freezeUrl, freezeLocalFile } from "./lib/freeze.mjs";
import { findExistingAsset } from "./lib/adopt.mjs";
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 },
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 <type> --intent "<description>" [--project <dir>]
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
--json Output JSON instead of one-line result
--help, -h Show this help`);
process.exit(0);
}
if (args.adopt) {
const { adoptExistingAssets } = await import("./lib/adopt.mjs");
const projectDir = resolve(args.project);
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);
}
if (!args.type || !args.intent) {
console.error("error: --type and --intent are required");
process.exit(2);
}
const projectDir = resolve(args.project);
const type = args.type;
const intent = args.intent;
const entity = args.entity || null;
async function run() {
// 1. project manifest — exact-prompt match
const projectHit = findByPrompt(projectDir, intent, type);
if (projectHit && existsSync(join(projectDir, projectHit.path))) {
return result(projectHit, "cached");
}
// 1b. entity match in project
if (entity) {
const entityHit = findByEntity(projectDir, entity);
if (entityHit && entityHit.type === type && existsSync(join(projectDir, entityHit.path))) {
return result(entityHit, "cached");
}
}
// 1c. scan existing assets/ directory for unregistered matches
const existingAsset = 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 = cacheGet(intent, type);
if (cacheHit) {
const id = nextId(projectDir, type);
const ext = extname(cacheHit.cached_path);
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
const imported = importFromCache(cacheHit, projectDir, id, localPath);
if (imported) {
appendRecord(projectDir, imported);
regenerateIndex(projectDir);
return result(imported, "reused");
}
}
if (entity) {
const entityCacheHit = cacheGetByEntity(entity);
if (entityCacheHit && entityCacheHit.type === type) {
const id = nextId(projectDir, type);
const ext = extname(entityCacheHit.cached_path);
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
const imported = importFromCache(entityCacheHit, projectDir, id, localPath);
if (imported) {
appendRecord(projectDir, imported);
regenerateIndex(projectDir);
return result(imported, "reused");
}
}
}
// 3. provider search
const provider = getProvider(type);
let searchResult = null;
try {
searchResult = await provider.search(intent, { entity, projectDir });
} catch {
// search failed, try generate
}
// 4. generate fallback
if (!searchResult && provider.generate) {
try {
searchResult = await provider.generate(intent, { entity, projectDir });
} catch {
// generate failed too
}
}
if (!searchResult) {
if (args.json) {
console.log(
JSON.stringify({ ok: false, error: `no provider could resolve ${type}: "${intent}"` }),
);
} else {
console.error(`error: no provider could resolve ${type}: "${intent}"`);
}
process.exit(1);
}
// 5. freeze + register
const id = nextId(projectDir, type);
const ext = searchResult.ext || extFromUrl(searchResult.url || "") || defaultExt(type);
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
const fullPath = join(projectDir, localPath);
if (searchResult.localPath) {
freezeLocalFile(searchResult.localPath, fullPath);
} else if (searchResult.url) {
await freezeUrl(searchResult.url, fullPath);
} else {
console.error("error: provider returned no url or localPath");
process.exit(1);
}
const record = {
id,
type,
path: localPath,
source: searchResult.source || "search",
description: searchResult.metadata?.description || intent,
...(searchResult.metadata?.duration != null && { duration: searchResult.metadata.duration }),
...(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,
...searchResult.metadata?.provenance,
},
};
appendRecord(projectDir, record);
regenerateIndex(projectDir);
return result(record, searchResult.source || "search");
}
function result(record, source) {
if (args.json) {
console.log(JSON.stringify({ ok: true, ...record, _source: source }));
} else {
const meta = formatMeta(record, source);
console.log(`resolved ${record.id}${record.path} (${meta})`);
}
}
function formatMeta(record, source) {
const parts = [record.type];
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") 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;
}
}
const DEFAULT_EXT = {
bgm: ".wav",
sfx: ".mp3",
voice: ".wav",
image: ".jpg",
icon: ".svg",
brand: ".png",
};
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);
});