mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
Voice is POST /v1/audio/speech and word timings come from POST /v1/audio/transcriptions over the rendered audio. Music, sound effects, images, and video generate through /v1/audio/music, /v1/audio/foley, /v1/images/generations, and /v1/videos/generations. The shared catalog is a listing under media/<type>/ in our own object store, browsed through /v1/s3 — an empty prefix reports itself as empty rather than reading as a bad query. One client (scripts/lib/api.mjs) holds every route and the one credential: a Hanzo IAM token in $HANZO_API_KEY, read from Hanzo KMS. No credential file, no per-repo .env, no per-provider CLI to install or keep current. $HANZO_BASE_URL points the same code at a local Hanzo Engine. Word timings are passed through, never interpolated: a caption cut on invented boundaries reads as correct and drifts against the voice. When the service returns text without them, the word array is empty and the caller is told. Deletes the provider cascade this replaces, its model ladders, and the account linking in telemetry — the install id stands alone.
1118 lines
36 KiB
JavaScript
1118 lines
36 KiB
JavaScript
#!/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 } 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 { base as apiBase, ready } from "./lib/api.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 { 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" },
|
||
"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 <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
|
||
--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 <N> Limit --stats to records/misses from the last N days when
|
||
timestamps are available.
|
||
--reuse <sha> Import a specific global-cache asset (by content sha/prefix,
|
||
from --candidates) into this project
|
||
--from <file> Freeze a local file or direct public URL (ingest)
|
||
--params <json> Build an explicit parametric LUT (lut/grade only)
|
||
--for <media> 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 Pin one provider (e.g. catalog, hanzo.image)
|
||
--voice-id Voice id for voice generation (omit for the service default)
|
||
--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 <media>");
|
||
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 <name> (or --intent <name>)", 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;
|
||
}
|
||
|
||
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 (the shared catalog),
|
||
// leaving the project + global cache and any local provider.
|
||
const localOnly = args["local-only"];
|
||
const ctx = {
|
||
entity,
|
||
projectDir,
|
||
localOnly,
|
||
provider: args.provider,
|
||
voice: 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 (catalog 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
|
||
}
|
||
}
|
||
|
||
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 Frames 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 Frames 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,
|
||
...searchResult.metadata?.provenance,
|
||
},
|
||
};
|
||
|
||
appendRecord(projectDir, record);
|
||
regenerateIndex(projectDir);
|
||
// Auto-promote: surface every fetched asset in the global cache so it's
|
||
// reusable across all frames 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 }));
|
||
}
|
||
}
|
||
|
||
function runDoctor() {
|
||
const checks = [];
|
||
const bundledSfx = inspectBundledSfxAssets();
|
||
checks.push({
|
||
name: "bundled SFX assets",
|
||
ok: bundledSfx.ok,
|
||
detail: bundledSfx.detail,
|
||
fix: bundledSfx.fix,
|
||
});
|
||
checks.push({
|
||
name: "hanzo credential",
|
||
ok: ready(),
|
||
detail: ready()
|
||
? `HANZO_API_KEY set · ${apiBase()}`
|
||
: "HANZO_API_KEY not set — voice, generated media, and the catalog are unavailable",
|
||
fix: ready() ? "" : "export HANZO_API_KEY (read it from Hanzo KMS at kms.hanzo.ai)",
|
||
});
|
||
|
||
const tenant = !!process.env.HANZO_ORG;
|
||
checks.push({
|
||
name: "hanzo org",
|
||
ok: tenant,
|
||
detail: tenant
|
||
? `HANZO_ORG=${process.env.HANZO_ORG}`
|
||
: "HANZO_ORG not set — the shared media catalog is unavailable (generation still works)",
|
||
fix: tenant ? "" : "export HANZO_ORG (the tenant that owns the media bucket)",
|
||
});
|
||
|
||
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) {
|
||
for (const check of checks) {
|
||
const prefix = check.ok ? "✓" : "✗";
|
||
const fix = check.ok || !check.fix ? "" : ` — fix: ${check.fix}`;
|
||
console.log(`${prefix} ${check.detail}${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] || ""
|
||
);
|
||
}
|
||
|
||
// Numeric semver compare, for the node-version check.
|
||
function versionLessThan(version, minimum) {
|
||
const parts = (v) => {
|
||
const m = String(v || "").match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
||
return m ? m.slice(1, 4).map(Number) : null;
|
||
};
|
||
const left = parts(version);
|
||
const right = parts(minimum);
|
||
if (!left || !right) return false;
|
||
for (let i = 0; i < 3; i++) {
|
||
if (left[i] < right[i]) return true;
|
||
if (left[i] > right[i]) return false;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
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,
|
||
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);
|
||
});
|