#!/usr/bin/env node 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, allocateId } 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 } 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 { typesMatch } from "./lib/match.mjs"; import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./lib/candidates.mjs"; import { findGlobalBySha } from "./lib/cache.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"; 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 }, "dry-run": { type: "boolean", default: false }, reuse: { type: "string" }, from: { type: "string" }, params: { type: "string" }, for: { type: "string" }, "local-only": { type: "boolean", default: false }, provider: { 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. --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) --local-only Offline: skip every network provider --provider Force one generator (e.g. codex, mflux, kokoro, heygen) --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); } // 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.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; } 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 { id, localPath } = allocateId(projectDir, type, ext); const imported = 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 { id, localPath } = allocateId(projectDir, type, ext); const imported = 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 }; // 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; try { searchResult = await runCapability(type, "search", intent, ctx); } catch { // 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 { // generate failed too } } if (!searchResult) { await track("media_use_resolve_miss", { type, local_only: !!localOnly, provider_override: !!args.provider, }); // brand stays local: no frame.md/design.md -> upsell the HyperFrames design // flow rather than reporting a generic miss (B5). const msg = 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, 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 } = allocateId(projectDir, type, 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: 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, ...searchResult.metadata?.provenance, }, }; 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", }, ) { const { id, localPath } = allocateId(projectDir, type, ".cube"); const fullPath = join(projectDir, localPath); 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, }); 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 || !listTypes().includes(type)) { console.error(`error: --from requires --type (one of: ${listTypes().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 } = allocateId(projectDir, type, ext); const fullPath = join(projectDir, localPath); if (isUrl) await freezeUrl(src, fullPath); else freezeLocalFile(resolve(src), fullPath); 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 })); } } 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 { id, localPath } = allocateId(projectDir, type, ext); const imported = 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, 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; } } const DEFAULT_EXT = { bgm: ".wav", sfx: ".mp3", voice: ".wav", image: ".jpg", icon: ".svg", logo: ".svg", brand: ".png", grade: ".cube", lut: ".cube", }; 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); });