mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
* 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>
248 lines
7.7 KiB
JavaScript
248 lines
7.7 KiB
JavaScript
#!/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);
|
||
});
|