chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { duckKeyframes, speechSpans } from "./lib/duck.mjs";
|
||||
import { track } from "./lib/telemetry.mjs";
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
meta: { type: "string" },
|
||||
target: { type: "string" },
|
||||
duck: { type: "string", default: "0.25" },
|
||||
attack: { type: "string", default: "0.15" },
|
||||
release: { type: "string", default: "0.4" },
|
||||
"merge-gap": { type: "string", default: "0.6" },
|
||||
sequential: { type: "boolean", default: false },
|
||||
gap: { type: "string", default: "0" },
|
||||
offsets: { type: "string" },
|
||||
composition: { type: "string" },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (args.help) {
|
||||
console.log(`media-use audio-duck — generate GSAP volume ducking keyframes
|
||||
|
||||
Usage:
|
||||
node audio-duck.mjs --meta audio_meta.json --target "#bgm"
|
||||
|
||||
Options:
|
||||
--meta audio_meta.json or JSON word transcript
|
||||
--target GSAP selector for the background audio element
|
||||
--duck Duck multiplier (default: 0.25)
|
||||
--attack Duck-in duration seconds (default: 0.15)
|
||||
--release Restore duration seconds (default: 0.4)
|
||||
--merge-gap Bridge speech gaps smaller than this many seconds (default: 0.6)
|
||||
--sequential Place multi-line meta back to back at composition time
|
||||
--gap Extra seconds between sequential lines (default: 0)
|
||||
--offsets Explicit placement, "l1=0,l2=3.4" (voice id = start seconds)
|
||||
--composition Read target data-volume from this HTML file
|
||||
--json Output { spans, keyframes }
|
||||
--help, -h Show this help`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
run();
|
||||
await track("media_use_duck", { sequential: !!args.sequential });
|
||||
} catch (err) {
|
||||
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
|
||||
else console.error(`error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (!args.meta || !args.target) throw new Error("--meta and --target are required");
|
||||
const meta = JSON.parse(readFileSync(resolve(args.meta), "utf8"));
|
||||
const target = args.target;
|
||||
const baseVolume = readBaseVolume(args.composition, target);
|
||||
const offsets = args.offsets
|
||||
? Object.fromEntries(
|
||||
args.offsets.split(",").map((pair) => {
|
||||
const [id, t] = pair.split("=");
|
||||
return [id.trim(), Number(t)];
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
const spans = speechSpans(meta, {
|
||||
mergeGap: Number(args["merge-gap"]),
|
||||
sequential: args.sequential,
|
||||
gap: Number(args.gap),
|
||||
offsets,
|
||||
});
|
||||
const keyframes = duckKeyframes(spans, {
|
||||
duck: Number(args.duck),
|
||||
attack: Number(args.attack),
|
||||
release: Number(args.release),
|
||||
baseVolume,
|
||||
});
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({ spans, keyframes }));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`// auto-duck: ${target} under narration (generated; base volume ${fmt(baseVolume)})`,
|
||||
);
|
||||
for (const keyframe of keyframes) {
|
||||
console.log(
|
||||
`tl.to(${JSON.stringify(target)}, { volume: ${fmt(keyframe.volume)}, duration: ${fmt(
|
||||
keyframe.duration,
|
||||
)} }, ${fmt(keyframe.time)});`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readBaseVolume(composition, target) {
|
||||
if (!composition || !target.startsWith("#")) return 1;
|
||||
const id = target.slice(1);
|
||||
const html = readFileSync(resolve(composition), "utf8");
|
||||
// ponytail: regex is enough here because this only reads one attribute from
|
||||
// one user-authored composition element, not arbitrary HTML.
|
||||
const tag = html.match(new RegExp(`<[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "i"))?.[0];
|
||||
const raw = tag?.match(/\bdata-volume=["']([^"']+)["']/i)?.[1];
|
||||
const volume = Number(raw);
|
||||
return Number.isFinite(volume) ? volume : 1;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return Number(n)
|
||||
.toFixed(3)
|
||||
.replace(/\.?0+$/, "");
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* media-use eval — compare baseline (no media-use) vs. with media-use
|
||||
* on real registry blocks. Produces an HTML report.
|
||||
*/
|
||||
|
||||
import {
|
||||
mkdtempSync,
|
||||
cpSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, basename, resolve, dirname } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(SCRIPT_DIR, "..", "..", "..");
|
||||
const RESOLVE_SCRIPT = join(SCRIPT_DIR, "resolve.mjs");
|
||||
|
||||
const TEST_BLOCKS = [
|
||||
"registry/blocks/nyc-paris-flight",
|
||||
"registry/blocks/macos-tahoe-liquid-glass",
|
||||
"registry/blocks/blue-sweater-intro-video",
|
||||
"registry/blocks/vpn-youtube-spot",
|
||||
"registry/blocks/apple-money-count",
|
||||
"registry/blocks/liquid-glass-notification",
|
||||
"registry/blocks/instagram-follow",
|
||||
];
|
||||
|
||||
// Run resolve.mjs with args as a literal argv array (no shell), so values
|
||||
// interpolated from manifest metadata (--intent prompt, --type) can't inject
|
||||
// shell. Mirrors the execFileSync fix in probe.mjs / heygen-search.mjs.
|
||||
function run(args, opts = {}) {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
output: execFileSync(process.execPath, [RESOLVE_SCRIPT, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 15000,
|
||||
stdio: "pipe",
|
||||
...opts,
|
||||
}).trim(),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, output: (err.stdout || "") + (err.stderr || ""), code: err.status };
|
||||
}
|
||||
}
|
||||
|
||||
function countAssetFiles(dir) {
|
||||
const assetsDir = join(dir, "assets");
|
||||
if (!existsSync(assetsDir)) return { count: 0, files: [] };
|
||||
const files = [];
|
||||
function walk(d, base = "") {
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
const rel = base ? `${base}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) walk(join(d, e.name), rel);
|
||||
else files.push(rel);
|
||||
}
|
||||
}
|
||||
walk(assetsDir);
|
||||
return { count: files.length, files };
|
||||
}
|
||||
|
||||
function evalBlock(blockPath) {
|
||||
const fullPath = join(REPO_ROOT, blockPath);
|
||||
if (!existsSync(fullPath)) return null;
|
||||
|
||||
const name = basename(blockPath);
|
||||
const tmp = mkdtempSync(join(tmpdir(), `mu-eval-${name}-`));
|
||||
|
||||
try {
|
||||
cpSync(fullPath, tmp, { recursive: true });
|
||||
|
||||
// baseline: what the agent sees WITHOUT media-use
|
||||
const baseline = countAssetFiles(tmp);
|
||||
const htmlFiles = readdirSync(tmp).filter((f) => f.endsWith(".html"));
|
||||
|
||||
// parse compositions for asset references
|
||||
const assetRefs = [];
|
||||
for (const hf of htmlFiles) {
|
||||
const html = readFileSync(join(tmp, hf), "utf8");
|
||||
const srcMatches = html.matchAll(/src=["']([^"']+?)["']/g);
|
||||
for (const m of srcMatches) {
|
||||
const ref = m[1];
|
||||
if (ref.startsWith("data:") || ref.startsWith("http")) continue;
|
||||
assetRefs.push({ composition: hf, ref });
|
||||
}
|
||||
const urlMatches = html.matchAll(/url\(["']?([^"')]+?)["']?\)/g);
|
||||
for (const m of urlMatches) {
|
||||
const ref = m[1];
|
||||
if (ref.startsWith("data:") || ref.startsWith("http") || ref.startsWith("#")) continue;
|
||||
assetRefs.push({ composition: hf, ref });
|
||||
}
|
||||
}
|
||||
|
||||
// with media-use: run --adopt
|
||||
const adoptResult = run(["--adopt", "--project", tmp, "--json"]);
|
||||
let adopted = { ok: false, adopted: 0, assets: [] };
|
||||
if (adoptResult.ok) {
|
||||
try {
|
||||
adopted = JSON.parse(adoptResult.output);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
// read the generated index
|
||||
const indexPath = join(tmp, ".media", "index.md");
|
||||
const indexContent = existsSync(indexPath)
|
||||
? readFileSync(indexPath, "utf8")
|
||||
: "(no index generated)";
|
||||
|
||||
// read manifest for detail
|
||||
const manifestPath = join(tmp, ".media", "manifest.jsonl");
|
||||
const manifest = existsSync(manifestPath)
|
||||
? readFileSync(manifestPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => {
|
||||
try {
|
||||
return JSON.parse(l);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
// test resolve cache hit: try resolving something that was adopted
|
||||
let resolveTest = null;
|
||||
if (manifest.length > 0) {
|
||||
const first = manifest[0];
|
||||
const prompt = first.provenance?.prompt || first.description;
|
||||
const r = run(["--type", first.type, "--intent", prompt, "--project", tmp, "--json"]);
|
||||
if (r.ok) {
|
||||
try {
|
||||
resolveTest = JSON.parse(r.output);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// test resolve miss: try resolving something that doesn't exist
|
||||
const missResult = run([
|
||||
"--type",
|
||||
"bgm",
|
||||
"--intent",
|
||||
"nonexistent query xyz",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
let resolveMiss = null;
|
||||
if (!missResult.ok) {
|
||||
try {
|
||||
resolveMiss = JSON.parse(missResult.output);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
// coverage: which composition refs are covered by the manifest
|
||||
const manifestPaths = new Set(manifest.map((m) => m.path));
|
||||
const coverage = assetRefs.map((r) => ({
|
||||
...r,
|
||||
covered: manifestPaths.has(r.ref),
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
baseline: { fileCount: baseline.count, files: baseline.files, htmlCount: htmlFiles.length },
|
||||
compositions: htmlFiles,
|
||||
assetRefs: coverage,
|
||||
adopted: { count: adopted.adopted, assets: adopted.assets || [] },
|
||||
index: indexContent,
|
||||
manifest,
|
||||
resolveTest,
|
||||
resolveMiss,
|
||||
};
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function generateReport(results) {
|
||||
const all = results.filter(Boolean);
|
||||
const passed = all.filter((r) => r.adopted.count > 0);
|
||||
|
||||
const rows = results
|
||||
.filter(Boolean)
|
||||
.map((r) => {
|
||||
const hasMetadata = r.manifest.some((m) => m.duration || m.width);
|
||||
const cacheHit = r.resolveTest?._source === "cached";
|
||||
const missHandled = r.resolveMiss?.ok === false;
|
||||
|
||||
return `<tr>
|
||||
<td><strong>${r.name}</strong></td>
|
||||
<td>${r.baseline.fileCount} files, ${r.baseline.htmlCount} comp${r.baseline.htmlCount === 1 ? "" : "s"}</td>
|
||||
<td>${r.adopted.count} adopted</td>
|
||||
<td>${hasMetadata ? "<span class='pass'>with metadata</span>" : "<span class='warn'>no metadata</span>"}</td>
|
||||
<td>${cacheHit ? "<span class='pass'>cache hit</span>" : "<span class='warn'>no hit</span>"}</td>
|
||||
<td>${missHandled ? "<span class='pass'>handled</span>" : "<span class='fail'>unexpected</span>"}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const details = results
|
||||
.filter(Boolean)
|
||||
.filter((r) => r.adopted.count > 0)
|
||||
.map((r) => {
|
||||
const assetRows = r.manifest
|
||||
.map((m) => {
|
||||
const dur = m.duration != null ? `${m.duration}s` : "—";
|
||||
const dims = m.width && m.height ? `${m.width}×${m.height}` : "—";
|
||||
return `<tr><td>${m.id}</td><td>${m.type}</td><td>${dur}</td><td>${dims}</td><td class="path">${m.path}</td><td>${m.description || ""}</td></tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const coveredCount = r.assetRefs.filter((c) => c.covered).length;
|
||||
const totalRefs = r.assetRefs.length;
|
||||
const coveragePct = totalRefs > 0 ? Math.round((coveredCount / totalRefs) * 100) : 100;
|
||||
|
||||
const refRows = r.assetRefs
|
||||
.map(
|
||||
(c) =>
|
||||
`<tr><td class="path">${c.composition}</td><td class="path">${c.ref}</td><td>${c.covered ? "<span class='pass'>covered</span>" : "<span class='warn'>not in manifest</span>"}</td></tr>`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `<div class="block-detail">
|
||||
<h3>${r.name}</h3>
|
||||
<p style="font-size:13px;color:var(--muted)">${r.compositions.length} composition${r.compositions.length === 1 ? "" : "s"}: ${r.compositions.join(", ")}</p>
|
||||
|
||||
<div class="comparison">
|
||||
<div class="col">
|
||||
<h4>Baseline (no media-use)</h4>
|
||||
<p>Agent sees: ${r.baseline.fileCount} raw files in assets/<br>No metadata, no type info, no relationship to compositions.</p>
|
||||
<pre class="file-list">${r.baseline.files.join("\n") || "(no assets)"}</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>With media-use (after --adopt)</h4>
|
||||
<p>Agent reads index.md — structured, typed, with metadata:</p>
|
||||
<pre class="index">${escapeHtml(r.index)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
totalRefs > 0
|
||||
? `<h4>Composition → asset coverage <span class="${coveragePct === 100 ? "pass" : "warn"}">${coveragePct}%</span> (${coveredCount}/${totalRefs} refs)</h4>
|
||||
<table class="manifest">
|
||||
<thead><tr><th>composition</th><th>asset reference</th><th>in manifest?</th></tr></thead>
|
||||
<tbody>${refRows}</tbody>
|
||||
</table>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<h4>Manifest records</h4>
|
||||
<table class="manifest">
|
||||
<thead><tr><th>id</th><th>type</th><th>dur</th><th>dims</th><th>path</th><th>description</th></tr></thead>
|
||||
<tbody>${assetRows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `<title>media-use eval report</title>
|
||||
<style>
|
||||
:root { --bg: #fafaf7; --text: #1b1b18; --muted: #7a756a; --accent: #0d7377; --good: #1a7a3a; --warn: #b45309; --fail: #dc2626; --border: #e8e5df; --surface: #fff; --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; --sans: system-ui, -apple-system, sans-serif; --serif: Georgia, serif }
|
||||
* { box-sizing: border-box; margin: 0 } body { background: var(--bg); color: var(--text); font-family: var(--serif); line-height: 1.6; font-size: 15px; padding: 40px 24px }
|
||||
.wrap { max-width: 1100px; margin: 0 auto }
|
||||
h1 { font-family: var(--sans); font-size: 28px; font-weight: 700; margin-bottom: 8px; letter-spacing: -.02em }
|
||||
h2 { font-family: var(--sans); font-size: 20px; font-weight: 650; margin: 32px 0 12px; letter-spacing: -.01em }
|
||||
h3 { font-family: var(--sans); font-size: 17px; font-weight: 650; margin: 24px 0 8px }
|
||||
h4 { font-family: var(--sans); font-size: 14px; font-weight: 600; margin: 16px 0 6px; color: var(--muted) }
|
||||
p { margin-bottom: 10px }
|
||||
.meta { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-bottom: 24px }
|
||||
.summary { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap }
|
||||
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; flex: 1; min-width: 140px }
|
||||
.stat .num { font-family: var(--sans); font-size: 28px; font-weight: 700; color: var(--accent) }
|
||||
.stat .label { font-family: var(--mono); font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--sans); margin: 8px 0 }
|
||||
th { text-align: left; font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); border-bottom: 2px solid var(--border); padding: 6px 8px; font-weight: 700 }
|
||||
td { border-bottom: 1px solid var(--border); padding: 7px 8px; vertical-align: top }
|
||||
td.path { font-family: var(--mono); font-size: 12px; color: var(--muted); max-width: 300px; overflow: hidden; text-overflow: ellipsis }
|
||||
.pass { color: var(--good); font-weight: 600 } .warn { color: var(--warn); font-weight: 600 } .fail { color: var(--fail); font-weight: 600 }
|
||||
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 12px 0 }
|
||||
@media(max-width:700px) { .comparison { grid-template-columns: 1fr } }
|
||||
.col { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px }
|
||||
.col h4 { margin-top: 0 }
|
||||
pre { font-family: var(--mono); font-size: 12px; background: #1b1b18; color: #d4d0c8; border-radius: 6px; padding: 12px 14px; overflow-x: auto; margin: 6px 0; line-height: 1.5 }
|
||||
pre.file-list { background: var(--bg); color: var(--muted); border: 1px solid var(--border) }
|
||||
pre.index { white-space: pre; }
|
||||
.block-detail { border-top: 1px solid var(--border); padding-top: 20px; margin-top: 20px }
|
||||
.verdict { margin-top: 24px; padding: 16px 20px; border-radius: 8px; font-family: var(--sans); font-size: 15px }
|
||||
.verdict.ship { background: #edfbf0; border: 1px solid #1a7a3a; color: #1a7a3a }
|
||||
.verdict.wait { background: #fff3ec; border: 1px solid #d94f04; color: #d94f04 }
|
||||
</style>
|
||||
<div class="wrap">
|
||||
<h1>media-use eval report</h1>
|
||||
<p class="meta">${new Date().toISOString().slice(0, 10)} · ${all.length} blocks evaluated · baseline vs. media-use --adopt</p>
|
||||
|
||||
<div class="summary">
|
||||
<div class="stat"><div class="num">${all.length}</div><div class="label">blocks tested</div></div>
|
||||
<div class="stat"><div class="num">${passed.length}</div><div class="label">with assets</div></div>
|
||||
<div class="stat"><div class="num">${all.reduce((s, r) => s + r.adopted.count, 0)}</div><div class="label">assets adopted</div></div>
|
||||
<div class="stat"><div class="num">${all.filter((r) => r.manifest.some((m) => m.duration || m.width)).length}</div><div class="label">with ffprobe metadata</div></div>
|
||||
<div class="stat"><div class="num">${(() => {
|
||||
const refs = all.flatMap((r) => r.assetRefs);
|
||||
const covered = refs.filter((c) => c.covered).length;
|
||||
return refs.length > 0 ? Math.round((covered / refs.length) * 100) + "%" : "—";
|
||||
})()}</div><div class="label">composition coverage</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Results matrix</h2>
|
||||
<table>
|
||||
<thead><tr><th>Block</th><th>Baseline</th><th>Adopted</th><th>Metadata</th><th>Cache hit</th><th>Miss handling</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Before / after comparisons</h2>
|
||||
${details}
|
||||
|
||||
<div class="verdict ${passed.length >= 3 ? "ship" : "wait"}">
|
||||
${
|
||||
passed.length >= 3
|
||||
? `<strong>Ship it.</strong> ${passed.length}/${all.length} blocks adopted successfully with metadata. Resolve cache hits work. Miss handling is clean.`
|
||||
: `<strong>Needs work.</strong> Only ${passed.length} blocks adopted. Check the failures above.`
|
||||
}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
console.log("media-use eval · running against registry blocks...\n");
|
||||
|
||||
const results = [];
|
||||
for (const block of TEST_BLOCKS) {
|
||||
const fullPath = join(REPO_ROOT, block);
|
||||
if (!existsSync(fullPath)) {
|
||||
console.log(` skip ${basename(block)} (not found)`);
|
||||
results.push(null);
|
||||
continue;
|
||||
}
|
||||
process.stdout.write(` ${basename(block)}...`);
|
||||
const result = evalBlock(block);
|
||||
if (result) {
|
||||
console.log(
|
||||
` ${result.adopted.count} adopted, ${result.manifest.filter((m) => m.duration || m.width).length} with metadata`,
|
||||
);
|
||||
} else {
|
||||
console.log(" failed");
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const report = generateReport(results);
|
||||
const outPath = join(SCRIPT_DIR, "..", "eval-report.html");
|
||||
writeFileSync(outPath, report);
|
||||
console.log(`\nReport: ${outPath}`);
|
||||
@@ -0,0 +1,128 @@
|
||||
import { readdirSync, statSync, existsSync } from "node:fs";
|
||||
import { join, extname, basename } from "node:path";
|
||||
import { readManifest, appendRecord, nextId } from "./manifest.mjs";
|
||||
import { regenerateIndex } from "./index-gen.mjs";
|
||||
import { probe } from "./probe.mjs";
|
||||
import { matchTokens } from "./match.mjs";
|
||||
|
||||
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
|
||||
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);
|
||||
const VIDEO_EXT = new Set([".mp4", ".webm", ".mov"]);
|
||||
|
||||
function inferType(filePath) {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
if (AUDIO_EXT.has(ext)) {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.includes("/bgm/") || lower.includes("/music/") || lower.startsWith("bgm/"))
|
||||
return "bgm";
|
||||
if (lower.includes("/sfx/") || lower.includes("/sound") || lower.startsWith("sfx/"))
|
||||
return "sfx";
|
||||
if (lower.includes("/voice/") || lower.includes("/narrat") || lower.startsWith("voice/"))
|
||||
return "voice";
|
||||
return "bgm";
|
||||
}
|
||||
if (IMAGE_EXT.has(ext)) {
|
||||
if (ext === ".svg" || ext === ".ico") return "icon";
|
||||
return "image";
|
||||
}
|
||||
if (VIDEO_EXT.has(ext)) return "video";
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkDir(dir, base = "") {
|
||||
const files = [];
|
||||
if (!existsSync(dir)) return files;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const rel = base ? `${base}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walkDir(join(dir, entry.name), rel));
|
||||
} else {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function scanExistingAssets(projectDir) {
|
||||
const assetsDir = join(projectDir, "assets");
|
||||
if (!existsSync(assetsDir)) return [];
|
||||
|
||||
const files = walkDir(assetsDir);
|
||||
const found = [];
|
||||
for (const rel of files) {
|
||||
const type = inferType(rel);
|
||||
if (!type) continue;
|
||||
const fullPath = join(assetsDir, rel);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.size === 0) {
|
||||
// A 0-byte asset would register clean but fail at render — skip it loudly
|
||||
// rather than adopt a broken file.
|
||||
console.error(`media-use: skipping 0-byte asset assets/${rel}`);
|
||||
continue;
|
||||
}
|
||||
const meta = probe(fullPath);
|
||||
found.push({
|
||||
relativePath: `assets/${rel}`,
|
||||
type,
|
||||
size: stat.size,
|
||||
name: basename(rel, extname(rel)),
|
||||
...meta,
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function adoptExistingAssets(projectDir) {
|
||||
const existing = scanExistingAssets(projectDir);
|
||||
if (existing.length === 0) return [];
|
||||
|
||||
const manifest = readManifest(projectDir);
|
||||
const knownPaths = new Set(manifest.map((r) => r.path));
|
||||
|
||||
const adopted = [];
|
||||
for (const asset of existing) {
|
||||
if (knownPaths.has(asset.relativePath)) continue;
|
||||
|
||||
const id = nextId(projectDir, asset.type);
|
||||
const record = {
|
||||
id,
|
||||
type: asset.type,
|
||||
path: asset.relativePath,
|
||||
source: "existing",
|
||||
description: asset.name.replace(/[-_]/g, " "),
|
||||
...(asset.duration != null && { duration: asset.duration }),
|
||||
...(asset.width != null && { width: asset.width }),
|
||||
...(asset.height != null && { height: asset.height }),
|
||||
provenance: { provider: "local", adopted: true },
|
||||
};
|
||||
appendRecord(projectDir, record);
|
||||
adopted.push(record);
|
||||
}
|
||||
|
||||
if (adopted.length > 0) regenerateIndex(projectDir);
|
||||
return adopted;
|
||||
}
|
||||
|
||||
// Adopt a pre-existing assets/ file only when it shares a meaningful word with
|
||||
// the intent. The old test — `name.includes(intent) || intent.includes(name)` —
|
||||
// silently returned the WRONG file: "whoosh" grabbed a stray who.mp3, and a
|
||||
// one-letter filename matched every intent. A false negative just falls through
|
||||
// to a catalog search (safe); a false positive ships the wrong asset. So bias to
|
||||
// precision: require a shared token, don't guess from substrings.
|
||||
export function findExistingAsset(projectDir, intent, type) {
|
||||
const assetsDir = join(projectDir, "assets");
|
||||
if (!existsSync(assetsDir)) return null;
|
||||
const intentTokens = matchTokens(intent);
|
||||
if (intentTokens.size === 0) return null;
|
||||
for (const rel of walkDir(assetsDir)) {
|
||||
const t = inferType(rel);
|
||||
if (!t || (type && t !== type)) continue;
|
||||
const stem = basename(rel, extname(rel));
|
||||
for (const tok of matchTokens(stem)) {
|
||||
if (intentTokens.has(tok)) {
|
||||
return { relativePath: `assets/${rel}`, type: t, name: stem };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { findExistingAsset, adoptExistingAssets } from "./adopt.mjs";
|
||||
|
||||
let tmp;
|
||||
function setup() {
|
||||
tmp = mkdtempSync(join(tmpdir(), "mu-adopt-test-"));
|
||||
}
|
||||
function cleanup() {
|
||||
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
function drop(rel) {
|
||||
const full = join(tmp, "assets", rel);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, "x");
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push({ name, fn });
|
||||
|
||||
test("does NOT false-match a short filename stem (who.mp3 vs 'whoosh')", () => {
|
||||
setup();
|
||||
drop("sfx/who.mp3");
|
||||
assert.equal(findExistingAsset(tmp, "whoosh", "sfx"), null);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("does NOT match a one-letter filename against any intent", () => {
|
||||
setup();
|
||||
drop("images/a.jpg");
|
||||
assert.equal(findExistingAsset(tmp, "gradient tech background", "image"), null);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("matches on a shared meaningful word", () => {
|
||||
setup();
|
||||
drop("images/hero-shot.jpg");
|
||||
const hit = findExistingAsset(tmp, "hero image", "image");
|
||||
assert.ok(hit);
|
||||
assert.equal(hit.relativePath, "assets/images/hero-shot.jpg");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("matches multi-word overlap", () => {
|
||||
setup();
|
||||
drop("images/gradient-tech-bg.jpg");
|
||||
assert.ok(findExistingAsset(tmp, "gradient tech background", "image"));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("a shared stopword alone does not match", () => {
|
||||
setup();
|
||||
drop("video/the-video.mp4");
|
||||
assert.equal(findExistingAsset(tmp, "the rocket", null), null);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("respects the type filter", () => {
|
||||
setup();
|
||||
drop("sfx/rocket.mp3");
|
||||
assert.equal(findExistingAsset(tmp, "rocket", "image"), null, "wrong type is skipped");
|
||||
assert.ok(findExistingAsset(tmp, "rocket", "sfx"), "right type matches");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("adoptExistingAssets still imports every typed file (unaffected by match rule)", () => {
|
||||
setup();
|
||||
drop("sfx/who.mp3");
|
||||
drop("images/a.jpg");
|
||||
assert.equal(adoptExistingAssets(tmp).length, 2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
for (const { name, fn } of tests) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` \x1b[32m✓\x1b[0m ${name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(` \x1b[31m✗\x1b[0m ${name}`);
|
||||
console.log(` ${err.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
console.log("media-use · adopt / findExistingAsset tests\n");
|
||||
runTests();
|
||||
@@ -0,0 +1,20 @@
|
||||
import { heygenSearch } from "./heygen-search.mjs";
|
||||
|
||||
export const bgmProvider = {
|
||||
async search(intent) {
|
||||
const results = heygenSearch("audio sounds list", intent, { type: "music" });
|
||||
if (!results) return null;
|
||||
const best = results[0];
|
||||
return {
|
||||
url: best.audio_url,
|
||||
source: "search",
|
||||
// ext derived from audio_url by resolve.mjs — catalog tracks are .mp3 or .wav
|
||||
metadata: {
|
||||
description: best.description || intent,
|
||||
duration: best.duration || null,
|
||||
provider: "heygen.audio.sounds",
|
||||
provenance: { track_id: best.id, score: best.score, query: intent },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
function findDesignSpec(projectDir) {
|
||||
for (const name of ["frame.md", "design.md", "DESIGN.md"]) {
|
||||
const p = join(projectDir, name);
|
||||
if (existsSync(p)) return { path: p, name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content) {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return null;
|
||||
const yaml = match[1];
|
||||
const tokens = {};
|
||||
for (const line of yaml.split("\n")) {
|
||||
const m = line.match(/^\s*(\w[\w-]*):\s*(.+)/);
|
||||
if (m) tokens[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function extractColors(tokens) {
|
||||
const colors = [];
|
||||
for (const [k, v] of Object.entries(tokens)) {
|
||||
if (typeof v === "string" && /^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
||||
colors.push({ name: k, hex: v });
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
export const brandProvider = {
|
||||
async search(intent, { projectDir } = {}) {
|
||||
if (!projectDir) return null;
|
||||
const spec = findDesignSpec(projectDir);
|
||||
if (!spec) return null;
|
||||
const content = readFileSync(spec.path, "utf8");
|
||||
const tokens = parseFrontmatter(content);
|
||||
if (!tokens) return null;
|
||||
const colors = extractColors(tokens);
|
||||
return {
|
||||
localPath: spec.path,
|
||||
source: "local",
|
||||
ext: ".md",
|
||||
metadata: {
|
||||
description: "Brand tokens from " + spec.name,
|
||||
provider: "design_spec",
|
||||
provenance: {
|
||||
file: spec.name,
|
||||
colors,
|
||||
font: tokens.font || tokens.typography || null,
|
||||
logo: tokens.logo || null,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
|
||||
const LIB_DIR = join(import.meta.dirname, "..", "..", "audio", "assets", "sfx");
|
||||
|
||||
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) {
|
||||
const manifestPath = join(LIB_DIR, "manifest.json");
|
||||
if (!existsSync(manifestPath)) return null;
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
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];
|
||||
if (!best) return null;
|
||||
|
||||
const localPath = join(LIB_DIR, best.entry.file);
|
||||
if (!existsSync(localPath)) return null;
|
||||
return {
|
||||
localPath,
|
||||
ext: extensionForBundledSfxFile(best.entry.file),
|
||||
source: "bundled",
|
||||
metadata: {
|
||||
description: best.entry.description || best.key,
|
||||
duration: best.entry.duration ?? null,
|
||||
provider: "bundled.sfx",
|
||||
provenance: { library_key: best.key },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { extensionForBundledSfxFile } from "./bundled-sfx-provider.mjs";
|
||||
|
||||
test("derives bundled SFX extension from the manifest filename", () => {
|
||||
assert.equal(extensionForBundledSfxFile("impact.wav"), ".wav");
|
||||
assert.equal(extensionForBundledSfxFile("whoosh.ogg"), ".ogg");
|
||||
assert.equal(extensionForBundledSfxFile("extensionless"), ".mp3");
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
|
||||
import { join, basename } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { readManifest, appendRecord, normalizePrompt } from "./manifest.mjs";
|
||||
|
||||
const SCHEMA_PREFIX = "mu-v1-";
|
||||
const KEY_HEX_CHARS = 16;
|
||||
const COMPLETE_SENTINEL = ".hf-complete";
|
||||
|
||||
export function globalMediaDir() {
|
||||
return join(homedir(), ".media");
|
||||
}
|
||||
|
||||
export function contentHash(filePath) {
|
||||
const bytes = readFileSync(filePath);
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function cacheEntryDir(rootDir, sha) {
|
||||
return join(rootDir, SCHEMA_PREFIX + sha.slice(0, KEY_HEX_CHARS));
|
||||
}
|
||||
|
||||
function isComplete(entryDir) {
|
||||
return existsSync(join(entryDir, COMPLETE_SENTINEL));
|
||||
}
|
||||
|
||||
function markComplete(entryDir) {
|
||||
writeFileSync(join(entryDir, COMPLETE_SENTINEL), "", "utf8");
|
||||
}
|
||||
|
||||
// The manifest helpers append their own ".media" to the dir they get, so the
|
||||
// global manifest must be addressed by HOME, not by globalMediaDir() — passing
|
||||
// the latter nested it at ~/.media/.media/manifest.jsonl, invisible to the
|
||||
// Studio /api/assets/global route (which reads the documented flat path).
|
||||
export function readGlobalManifest() {
|
||||
return readManifest(homedir());
|
||||
}
|
||||
|
||||
// Resolve a content-sha (full or unambiguous prefix) to a reusable global-cache
|
||||
// record, for `resolve --reuse <sha>`. Returns null on no match, or
|
||||
// { ambiguous: true, count } when a prefix matches multiple distinct entries.
|
||||
// Completeness (the .hf-complete sentinel) is left to importFromCache so the
|
||||
// caller can surface an "incomplete cache entry" error distinctly from a miss.
|
||||
export function findGlobalBySha(shaPrefix) {
|
||||
const p = String(shaPrefix || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
if (!p) return null;
|
||||
const matches = readGlobalManifest().filter(
|
||||
(r) => r.reusable && typeof r.sha === "string" && r.sha.startsWith(p),
|
||||
);
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length > 1) {
|
||||
const exact = matches.find((r) => r.sha === p);
|
||||
if (exact) return exact;
|
||||
return { ambiguous: true, count: matches.length };
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function validateCacheHit(match) {
|
||||
if (!match?.sha) return null;
|
||||
return isComplete(cacheEntryDir(globalMediaDir(), match.sha)) ? match : null;
|
||||
}
|
||||
|
||||
export function cacheGet(prompt, type) {
|
||||
const key = normalizePrompt(prompt);
|
||||
if (!key) return null;
|
||||
return validateCacheHit(
|
||||
readGlobalManifest().find(
|
||||
(r) =>
|
||||
r.reusable &&
|
||||
normalizePrompt(r.provenance?.prompt) === key &&
|
||||
(type == null || r.type === type),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function cacheGetByEntity(entity) {
|
||||
const lower = entity.toLowerCase();
|
||||
return validateCacheHit(
|
||||
readGlobalManifest().find((r) => r.reusable && r.entity && r.entity.toLowerCase() === lower),
|
||||
);
|
||||
}
|
||||
|
||||
export function cachePut(filePath, record) {
|
||||
const sha = contentHash(filePath);
|
||||
// Idempotent: same content already promoted -> don't duplicate the global
|
||||
// record. ponytail: skips usage_count bump; add it when the metric is needed.
|
||||
const existing = readGlobalManifest().find((r) => r.sha === sha);
|
||||
if (existing) return { sha, cached_path: existing.cached_path, deduped: true };
|
||||
|
||||
const dir = globalMediaDir();
|
||||
const entryDir = cacheEntryDir(dir, sha);
|
||||
mkdirSync(entryDir, { recursive: true });
|
||||
|
||||
const dest = join(entryDir, basename(filePath));
|
||||
copyFileSync(filePath, dest);
|
||||
markComplete(entryDir);
|
||||
|
||||
const globalRecord = {
|
||||
...record,
|
||||
sha,
|
||||
reusable: true,
|
||||
cached_path: dest,
|
||||
};
|
||||
appendRecord(homedir(), globalRecord);
|
||||
return { sha, cached_path: dest };
|
||||
}
|
||||
|
||||
export function importFromCache(cacheRecord, projectDir, localId, localPath) {
|
||||
const sha = cacheRecord.sha;
|
||||
const entryDir = cacheEntryDir(globalMediaDir(), sha);
|
||||
if (!isComplete(entryDir)) return null;
|
||||
|
||||
const cachedFile = cacheRecord.cached_path;
|
||||
if (!cachedFile || !existsSync(cachedFile)) return null;
|
||||
|
||||
mkdirSync(join(projectDir, ".media"), { recursive: true });
|
||||
const fullDest = join(projectDir, localPath);
|
||||
mkdirSync(join(fullDest, ".."), { recursive: true });
|
||||
copyFileSync(cachedFile, fullDest);
|
||||
|
||||
const projectRecord = {
|
||||
...cacheRecord,
|
||||
id: localId,
|
||||
path: localPath,
|
||||
provenance: {
|
||||
...cacheRecord.provenance,
|
||||
imported_from: sha,
|
||||
},
|
||||
};
|
||||
delete projectRecord.sha;
|
||||
delete projectRecord.reusable;
|
||||
delete projectRecord.cached_path;
|
||||
|
||||
return projectRecord;
|
||||
}
|
||||
|
||||
export function promote(projectDir, id) {
|
||||
const records = readManifest(projectDir);
|
||||
const record = records.find((r) => r.id === id);
|
||||
if (!record) throw new Error(`asset not found in project manifest: ${id}`);
|
||||
|
||||
const filePath = join(projectDir, record.path);
|
||||
if (!existsSync(filePath)) throw new Error(`asset file not found: ${filePath}`);
|
||||
|
||||
return cachePut(filePath, record);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Reuse candidates: a side-effect-free view of assets already available to this
|
||||
// project (its own manifest) and across every project (the global ~/.media
|
||||
// cache), so the calling agent can judge semantic fit itself. No download, no
|
||||
// provider, no mutation. The ranker only *surfaces* — it orders by lexical
|
||||
// overlap but never filters a candidate out on zero overlap (that would
|
||||
// pre-empt the agent's judgment); the agent does the semantic call.
|
||||
|
||||
import { readManifest } from "./manifest.mjs";
|
||||
import { readGlobalManifest } from "./cache.mjs";
|
||||
import { tokenOverlap, typesMatch } from "./match.mjs";
|
||||
|
||||
export const CANDIDATE_CAP = 8;
|
||||
|
||||
function shape(record, scope, intent) {
|
||||
const description = record.description || record.provenance?.prompt || "";
|
||||
const prompt = record.provenance?.prompt || null;
|
||||
return {
|
||||
id: record.id,
|
||||
type: record.type,
|
||||
scope,
|
||||
description,
|
||||
prompt,
|
||||
provider: record.provenance?.provider || null,
|
||||
duration: record.duration ?? null,
|
||||
width: record.width ?? null,
|
||||
height: record.height ?? null,
|
||||
// Only global records carry a content sha — it is the stable reuse handle
|
||||
// for `resolve --reuse <sha>`. Project assets are reused by referencing
|
||||
// their path directly, so they need no handle.
|
||||
sha: scope === "global" ? record.sha || null : null,
|
||||
path: scope === "project" ? record.path : null,
|
||||
score: intent ? tokenOverlap(intent, `${description} ${prompt || ""}`) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Rank one scope: type-matched (icon<->image aware), newest-first within equal
|
||||
// overlap, ordered by overlap desc. Returns the full ranked list (uncapped).
|
||||
function rankScope(records, scope, type, intent) {
|
||||
return records
|
||||
.filter((r) => typesMatch(r.type, type))
|
||||
.reverse() // manifest is append-order (oldest first); newest-first at equal score
|
||||
.map((r) => shape(r, scope, intent))
|
||||
.sort((a, b) => b.score - a.score); // Array.sort is stable → recency preserved
|
||||
}
|
||||
|
||||
// List reuse candidates for `type`, capped per scope. Returns:
|
||||
// candidates: capped project candidates followed by capped global candidates
|
||||
// truncated: true if either scope had more than `cap`
|
||||
// total: { project, global } counts before the cap (machine-readable)
|
||||
// similar: count of candidates with lexical overlap > 0 (drives the nudge)
|
||||
export function listCandidates({ projectDir, type, intent = "", cap = CANDIDATE_CAP }) {
|
||||
const project = rankScope(readManifest(projectDir), "project", type, intent);
|
||||
const global = rankScope(readGlobalManifest(), "global", type, intent);
|
||||
const candidates = [...project.slice(0, cap), ...global.slice(0, cap)];
|
||||
return {
|
||||
candidates,
|
||||
truncated: project.length > cap || global.length > cap,
|
||||
total: { project: project.length, global: global.length },
|
||||
similar: [...project, ...global].filter((c) => c.score > 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
function meta(c) {
|
||||
const parts = [];
|
||||
if (c.duration != null) parts.push(`${c.duration}s`);
|
||||
if (c.width && c.height) parts.push(`${c.width}x${c.height}`);
|
||||
if (c.provider) parts.push(c.provider);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
// Human-readable listing. The agent can read this directly; --json is for
|
||||
// programmatic use. Reuse handle differs by scope: path for project, sha for
|
||||
// global.
|
||||
export function formatCandidates(candidates, { truncated, total } = {}) {
|
||||
if (candidates.length === 0) return "no reuse candidates found (project or global cache)";
|
||||
const lines = [`${candidates.length} reuse candidate${candidates.length === 1 ? "" : "s"}:`, ""];
|
||||
for (const c of candidates) {
|
||||
const handle =
|
||||
c.scope === "global" ? `--reuse ${String(c.sha).slice(0, 16)}` : c.path || `manifest:${c.id}`;
|
||||
const m = meta(c);
|
||||
lines.push(` [${c.scope}] ${c.description}${m ? ` (${m})` : ""}`);
|
||||
lines.push(` ${handle}`);
|
||||
}
|
||||
if (truncated && total) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
` (showing top ${CANDIDATE_CAP} per scope; ${total.project} project / ${total.global} global total — refine --intent to narrow)`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { test } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./candidates.mjs";
|
||||
import { findGlobalBySha } from "./cache.mjs";
|
||||
|
||||
// candidates + findGlobalBySha are offline (no heygen), so we can override HOME
|
||||
// to a temp dir and seed a fake global ~/.media manifest deterministically.
|
||||
function sandbox() {
|
||||
const root = mkdtempSync(join(tmpdir(), "mu-cand-"));
|
||||
const project = join(root, "proj");
|
||||
const home = join(root, "home");
|
||||
process.env.HOME = home;
|
||||
return { root, project, home };
|
||||
}
|
||||
function seedManifest(dir, records) {
|
||||
const md = join(dir, ".media");
|
||||
mkdirSync(md, { recursive: true });
|
||||
writeFileSync(md + "/manifest.jsonl", records.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
||||
}
|
||||
function proj(id, type, description, prompt) {
|
||||
return { id, type, path: `.media/audio/bgm/${id}.wav`, description, provenance: { prompt } };
|
||||
}
|
||||
function glob(id, type, description, prompt, sha) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
sha,
|
||||
reusable: true,
|
||||
cached_path: `/x/${sha}/${id}.wav`,
|
||||
description,
|
||||
provenance: { prompt, provider: "heygen.audio.sounds" },
|
||||
};
|
||||
}
|
||||
|
||||
test("ranks project + global by overlap, tags scope", () => {
|
||||
const { root, project, home } = sandbox();
|
||||
try {
|
||||
seedManifest(project, [proj("bgm_001", "bgm", "calm ambient piano", "calm ambient piano")]);
|
||||
seedManifest(home, [
|
||||
glob("bgm_009", "bgm", "energetic tech launch", "energetic tech launch", "a".repeat(64)),
|
||||
glob("bgm_010", "bgm", "sad corporate piano", "sad corporate piano", "b".repeat(64)),
|
||||
]);
|
||||
const { candidates } = listCandidates({
|
||||
projectDir: project,
|
||||
type: "bgm",
|
||||
intent: "tech launch",
|
||||
});
|
||||
assert.equal(candidates[0].scope, "project"); // project listed first
|
||||
const g = candidates.filter((c) => c.scope === "global");
|
||||
assert.equal(g[0].description, "energetic tech launch"); // higher overlap ranks first
|
||||
assert.ok(g[0].score >= g[1].score);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("zero-overlap intent still lists candidates (no hard filter)", () => {
|
||||
const { root, project, home } = sandbox();
|
||||
try {
|
||||
seedManifest(project, []);
|
||||
seedManifest(home, [glob("bgm_009", "bgm", "driving synth", "driving synth", "c".repeat(64))]);
|
||||
const { candidates, similar } = listCandidates({
|
||||
projectDir: project,
|
||||
type: "bgm",
|
||||
intent: "totally unrelated words xyz",
|
||||
});
|
||||
assert.equal(candidates.length, 1, "listed despite zero overlap");
|
||||
assert.equal(candidates[0].score, 0);
|
||||
assert.equal(similar, 0, "similar counts only overlap>0");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("caps per scope and reports truncation + totals", () => {
|
||||
const { root, project, home } = sandbox();
|
||||
try {
|
||||
const many = Array.from({ length: CANDIDATE_CAP + 3 }, (_, i) =>
|
||||
glob(`bgm_${i}`, "bgm", `track ${i}`, `track ${i}`, String(i).padStart(64, "0")),
|
||||
);
|
||||
seedManifest(project, []);
|
||||
seedManifest(home, many);
|
||||
const { candidates, truncated, total } = listCandidates({ projectDir: project, type: "bgm" });
|
||||
assert.equal(candidates.length, CANDIDATE_CAP);
|
||||
assert.equal(truncated, true);
|
||||
assert.equal(total.global, CANDIDATE_CAP + 3);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("honors icon<->image adjacency", () => {
|
||||
const { root, project, home } = sandbox();
|
||||
try {
|
||||
seedManifest(project, []);
|
||||
seedManifest(home, [glob("image_1", "image", "rocket logo", "rocket logo", "d".repeat(64))]);
|
||||
const { candidates } = listCandidates({ projectDir: project, type: "icon", intent: "rocket" });
|
||||
assert.equal(candidates.length, 1, "image asset surfaces for icon request");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("sha only on global, path only on project", () => {
|
||||
const { root, project, home } = sandbox();
|
||||
try {
|
||||
seedManifest(project, [proj("bgm_001", "bgm", "x", "x")]);
|
||||
seedManifest(home, [glob("bgm_009", "bgm", "y", "y", "e".repeat(64))]);
|
||||
const { candidates } = listCandidates({ projectDir: project, type: "bgm" });
|
||||
const p = candidates.find((c) => c.scope === "project");
|
||||
const g = candidates.find((c) => c.scope === "global");
|
||||
assert.ok(p.path && !p.sha);
|
||||
assert.ok(g.sha && !g.path);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findGlobalBySha resolves unique prefix, flags ambiguity, misses cleanly", () => {
|
||||
const { root, project, home } = sandbox();
|
||||
try {
|
||||
seedManifest(project, []);
|
||||
seedManifest(home, [
|
||||
glob("bgm_1", "bgm", "a", "a", "abc" + "0".repeat(61)),
|
||||
glob("bgm_2", "bgm", "b", "b", "abd" + "0".repeat(61)),
|
||||
glob("bgm_3", "bgm", "c", "c", "fff" + "0".repeat(61)),
|
||||
]);
|
||||
assert.equal(findGlobalBySha("fff").id, "bgm_3", "unique prefix resolves");
|
||||
assert.deepEqual(
|
||||
{ ambiguous: findGlobalBySha("ab").ambiguous, count: findGlobalBySha("ab").count },
|
||||
{ ambiguous: true, count: 2 },
|
||||
"ambiguous prefix flagged",
|
||||
);
|
||||
assert.equal(findGlobalBySha("zzz"), null, "miss returns null");
|
||||
assert.equal(findGlobalBySha(""), null, "empty returns null");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("formatCandidates shows reuse handles by scope; empty message", () => {
|
||||
const { candidates } = {
|
||||
candidates: [
|
||||
{ scope: "project", description: "p", path: ".media/audio/bgm/bgm_001.wav" },
|
||||
{ scope: "global", description: "g", sha: "f".repeat(64) },
|
||||
],
|
||||
};
|
||||
const out = formatCandidates(candidates, {});
|
||||
assert.match(out, /\.media\/audio\/bgm\/bgm_001\.wav/);
|
||||
assert.match(out, /--reuse ffffffffffffffff/);
|
||||
assert.match(formatCandidates([], {}), /no reuse candidates/);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Image generation via the OpenAI Codex CLI's built-in image tool (gpt-image-2)
|
||||
// on the user's ChatGPT subscription: the codex CLI owns auth, media-use holds
|
||||
// no key (CLI-only). The image UPSELL behind local mflux; skipped by --local-only.
|
||||
//
|
||||
// Retrieval mirrors illo-skill rather than trusting the model to save a file:
|
||||
// `--enable imagegenext` makes the built-in tool drop the rendered artifact into
|
||||
// $CODEX_HOME/generated_images/, and we fetch the freshest file that postdates
|
||||
// this run. The save-to-path instruction is only a best-effort verify-first.
|
||||
|
||||
const TIMEOUT_MS = 600000; // codex exec round-trips the sub; first-run tool spin-up is slow
|
||||
const MTIME_SKEW_MS = 2000; // tolerate mtime granularity / clock skew (illo uses 2s)
|
||||
|
||||
function codexGeneratedDir() {
|
||||
// Codex relocates CODEX_HOME on some hosts, so resolve it at run time.
|
||||
return join(process.env.CODEX_HOME || join(homedir(), ".codex"), "generated_images");
|
||||
}
|
||||
|
||||
// Newest artifact that postdates `sinceMs` (minus skew), so a stale prior render
|
||||
// or a concurrent session's file can't be mistaken for this run's output.
|
||||
function freshestGeneratedImage(sinceMs) {
|
||||
const dir = codexGeneratedDir();
|
||||
if (!existsSync(dir)) return null;
|
||||
const floor = sinceMs - MTIME_SKEW_MS;
|
||||
let best = null;
|
||||
for (const name of readdirSync(dir)) {
|
||||
let st;
|
||||
try {
|
||||
st = statSync(join(dir, name));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!st.isFile() || st.mtimeMs < floor) continue;
|
||||
if (!best || st.mtimeMs > best.mtimeMs) best = { path: join(dir, name), mtimeMs: st.mtimeMs };
|
||||
}
|
||||
return best?.path ?? null;
|
||||
}
|
||||
|
||||
// Short `codex` subcommand → combined stdout+stderr, or null if it can't run.
|
||||
function codexRun(args) {
|
||||
try {
|
||||
return execFileSync("codex", args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 10000,
|
||||
});
|
||||
} catch (err) {
|
||||
return `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}` || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-fast host check (mirrors illo): don't burn a minutes-long exec when Codex
|
||||
// isn't usable. Returns null when ready, else a human reason. imagegenext ships
|
||||
// default-disabled ("under development"), so we check the ROW is present (the
|
||||
// capability signal) — the exec enables it per-render with --enable.
|
||||
function codexUnavailableReason() {
|
||||
try {
|
||||
const which = process.platform === "win32" ? "where" : "which";
|
||||
execFileSync(which, ["codex"], { stdio: ["ignore", "ignore", "ignore"], timeout: 5000 });
|
||||
} catch {
|
||||
// A shell alias (e.g. `codex → /Applications/Codex.app/...`) is NOT enough:
|
||||
// aliases live only in the interactive shell, so a spawned subprocess's PATH
|
||||
// lookup can't see them. Symlink the real binary onto PATH.
|
||||
return 'codex CLI not reachable on PATH (a shell alias won\'t work — spawned processes can\'t see aliases; symlink the real binary onto PATH, e.g. ln -s "$(readlink -f "$(command -v codex)")" ~/.local/bin/codex)';
|
||||
}
|
||||
// Auth marker: presence of the credentials file, NOT `codex login status`.
|
||||
// That command prints "Logged in using ChatGPT" only to a human stream
|
||||
// (stderr / TTY) and exits 0, so its piped stdout — how media-use spawns it —
|
||||
// is empty, and the gate falsely reported "not logged in", blocking codex
|
||||
// image gen in every headless / CI / agent run even when fully authed.
|
||||
// auth.json is the durable, TTY-independent signal; token validity is proven
|
||||
// by the exec itself, which fails cleanly if the login is stale.
|
||||
const authPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json");
|
||||
if (!existsSync(authPath)) return "codex not logged in (run: codex login)";
|
||||
const feats = codexRun(["features", "list"]);
|
||||
if (feats == null) return "could not read `codex features list`";
|
||||
if (!/\bimage_generation\b/.test(feats)) return "codex image_generation feature unavailable";
|
||||
if (!/\bimagegenext\b/.test(feats)) return "codex imagegenext unavailable (upgrade Codex CLI)";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function codexImageGenerate(intent) {
|
||||
const unavailable = codexUnavailableReason();
|
||||
if (unavailable) {
|
||||
console.error(`media-use: codex image upsell unavailable: ${unavailable}`);
|
||||
return null;
|
||||
}
|
||||
const outPath = join(tmpdir(), `media-use-codex-${process.pid}-${Date.now()}.png`);
|
||||
const prompt =
|
||||
`${intent}\n\n` +
|
||||
`Use your built-in image generation tool to render this, then save the image ` +
|
||||
`to ${outPath} (overwrite if it exists). Do not ask for confirmation. ` +
|
||||
`If you have no built-in image tool, do nothing (no PIL/matplotlib/SVG substitute).`;
|
||||
try {
|
||||
unlinkSync(outPath); // clear any prior file so verify-first can't accept a stale render
|
||||
} catch {
|
||||
/* no prior file */
|
||||
}
|
||||
const started = Date.now();
|
||||
try {
|
||||
execFileSync(
|
||||
"codex",
|
||||
[
|
||||
"exec",
|
||||
"--cd",
|
||||
tmpdir(),
|
||||
"-s",
|
||||
"workspace-write",
|
||||
"--skip-git-repo-check",
|
||||
"--enable",
|
||||
"imagegenext",
|
||||
"-",
|
||||
],
|
||||
{ input: prompt, encoding: "utf8", timeout: TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`media-use: \`codex exec\` image generation failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// Verify-first (save-to-path may have worked), else fetch the imagegenext artifact.
|
||||
const produced =
|
||||
existsSync(outPath) && statSync(outPath).size > 0 ? outPath : freshestGeneratedImage(started);
|
||||
if (!produced) return null;
|
||||
if (produced !== outPath) {
|
||||
try {
|
||||
copyFileSync(produced, outPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
localPath: outPath,
|
||||
ext: ".png",
|
||||
source: "generated",
|
||||
metadata: { description: intent, provider: "codex.image_gen", provenance: { prompt: intent } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { listTypes, getProviders } from "./registry.mjs";
|
||||
import { CAPABILITIES, listModels } from "./local-models.mjs";
|
||||
|
||||
// Capstone: media-use must actually OWN each hyperframes media weakness. This
|
||||
// test enforces the weakness→owner matrix in SKILL.md so a claim can't rot — if
|
||||
// a capability's entrypoint disappears, this fails.
|
||||
|
||||
const SKILL = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
test("weakness: audio-only → media-use resolves image + icon", () => {
|
||||
for (const t of ["image", "icon"]) {
|
||||
assert.ok(getProviders(t).length > 0, `no provider for ${t}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("weakness: no third-party brand logos → media-use resolves logo", () => {
|
||||
assert.ok(listTypes().includes("logo"), "logo type missing");
|
||||
assert.ok(getProviders("logo").length >= 4, "logo cascade incomplete");
|
||||
});
|
||||
|
||||
test("weakness: no voice/audio gen → media-use exposes voice + the audio engine", () => {
|
||||
assert.ok(listTypes().includes("voice"), "voice type missing");
|
||||
assert.ok(getProviders("voice").length > 0, "no enabled voice provider (Bin approved)");
|
||||
assert.ok(existsSync(join(SKILL, "audio", "scripts", "audio.mjs")), "audio engine missing");
|
||||
});
|
||||
|
||||
test("weakness: scattered audio engine → consolidated under media-use (hyperframes-media gone)", () => {
|
||||
assert.ok(existsSync(join(SKILL, "audio", "scripts", "lib", "tts.mjs")), "tts engine missing");
|
||||
assert.ok(
|
||||
existsSync(join(SKILL, "audio", "assets", "sfx", "manifest.json")),
|
||||
"bundled SFX missing",
|
||||
);
|
||||
});
|
||||
|
||||
test("weakness: no media-ops → ops guidance reference exists", () => {
|
||||
assert.ok(existsSync(join(SKILL, "references", "operations.md")), "operations.md missing");
|
||||
});
|
||||
|
||||
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");
|
||||
const cutlist = await import("./cutlist.mjs");
|
||||
assert.equal(typeof cutlist.compileCutList, "function");
|
||||
});
|
||||
|
||||
test("weakness: whisper.cpp is weak → better local ASR (Parakeet) entrypoint exists", async () => {
|
||||
assert.ok(existsSync(join(SKILL, "scripts", "transcribe.mjs")), "transcribe.mjs missing");
|
||||
const pw = await import("./parakeet-words.mjs");
|
||||
assert.equal(typeof pw.mergeTokensToWords, "function", "token->word merge missing");
|
||||
const lm = await import("./local-models.mjs");
|
||||
const asr = lm.listModels("asr");
|
||||
const parakeet = asr.find((m) => m.id === "parakeet-mlx");
|
||||
assert.ok(parakeet && parakeet.rank === 0, "Parakeet must be the rank-0 preferred ASR");
|
||||
});
|
||||
|
||||
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(SKILL, "references", "operations.md")), "operations.md missing");
|
||||
const duck = await import("./duck.mjs");
|
||||
assert.equal(typeof duck.speechSpans, "function");
|
||||
assert.equal(typeof duck.duckKeyframes, "function");
|
||||
});
|
||||
|
||||
test("weakness: no cross-project memory → global cache + ingest entrypoints exist", async () => {
|
||||
const cache = await import("./cache.mjs");
|
||||
assert.equal(typeof cache.cachePut, "function");
|
||||
assert.equal(typeof cache.promote, "function");
|
||||
assert.equal(typeof cache.globalMediaDir, "function");
|
||||
const freeze = await import("./freeze.mjs");
|
||||
assert.equal(typeof freeze.isDirectMediaUrl, "function", "ingest URL guard missing");
|
||||
});
|
||||
|
||||
// Wenbo (06-29): heygen free-usage is the default; local models are the opt-out
|
||||
// fallback ("if user no, then local"). We still assert the fallback table is
|
||||
// populated so the opt-out path stays real.
|
||||
test("weakness: weak local defaults → local models exist as the opt-out fallback (tts/asr/upscale)", () => {
|
||||
for (const cap of ["tts", "asr", "upscale"]) {
|
||||
assert.ok(CAPABILITIES.includes(cap), `capability ${cap} missing`);
|
||||
assert.ok(listModels(cap).length > 0, `no local models for ${cap}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("weakness: no image generation → local mflux (RAM-graded) + codex upsell", async () => {
|
||||
const ps = getProviders("image");
|
||||
assert.ok(
|
||||
ps.some((p) => p.name === "mflux.local" && typeof p.generate === "function"),
|
||||
"local image gen missing",
|
||||
);
|
||||
assert.ok(
|
||||
ps.some((p) => p.name === "codex.image_gen" && typeof p.generate === "function"),
|
||||
"codex image upsell missing",
|
||||
);
|
||||
const lm = await import("./local-models.mjs");
|
||||
assert.ok(lm.CAPABILITIES.includes("imagegen"), "imagegen capability missing");
|
||||
assert.ok(lm.listModels("imagegen").length >= 3, "imagegen RAM ladder too small");
|
||||
assert.equal(typeof lm.describeModelLadder, "function", "agent-facing ladder missing");
|
||||
});
|
||||
|
||||
test("weakness: no video generation → local videogen ladder + heygen avatar upsell", async () => {
|
||||
const lm = await import("./local-models.mjs");
|
||||
assert.ok(lm.CAPABILITIES.includes("videogen"), "videogen capability missing");
|
||||
assert.ok(lm.listModels("videogen").length >= 2, "videogen ladder too small");
|
||||
const ops = existsSync(join(SKILL, "references", "operations.md"));
|
||||
assert.ok(ops, "operations.md (avatar-upsell recipe) missing");
|
||||
});
|
||||
|
||||
test("every resolve type has at least one enabled provider", () => {
|
||||
for (const t of listTypes()) {
|
||||
assert.ok(getProviders(t).length > 0, `type ${t} has no enabled provider`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
const DEFAULT_SIZE = 33;
|
||||
const MAX_SIZE = 64;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function clampUnit(value) {
|
||||
return clamp(value, 0, 1);
|
||||
}
|
||||
|
||||
function readParam(params, key, min, max) {
|
||||
return clamp(Number(params?.[key] ?? 0), min, max);
|
||||
}
|
||||
|
||||
function luma([r, g, b]) {
|
||||
// Rec.709 luma weightings (matches the color space the grading runtime uses).
|
||||
return r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||
}
|
||||
|
||||
function smoothstep(edge0, edge1, value) {
|
||||
const t = clampUnit((value - edge0) / (edge1 - edge0));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
function applyLiftGain(color, params) {
|
||||
const y = luma(color);
|
||||
const blacks = readParam(params, "blacks", -1, 1);
|
||||
const shadows = readParam(params, "shadows", -1, 1);
|
||||
const highlights = readParam(params, "highlights", -1, 1);
|
||||
const whites = readParam(params, "whites", -1, 1);
|
||||
const shadowMask = 1 - smoothstep(0.18, 0.62, y);
|
||||
const highlightMask = smoothstep(0.38, 0.82, y);
|
||||
const offset =
|
||||
blacks * 0.08 + shadows * 0.12 * shadowMask + highlights * 0.12 * highlightMask + whites * 0.08;
|
||||
return color.map((channel) => clampUnit(channel + offset));
|
||||
}
|
||||
|
||||
function applyExposure(color, params) {
|
||||
const exposure = readParam(params, "exposure", -2, 2);
|
||||
const gain = 2 ** exposure;
|
||||
const lift = Math.max(0, exposure) * 0.015;
|
||||
return color.map((channel) => clampUnit(channel * gain + lift));
|
||||
}
|
||||
|
||||
function applyContrast(color, params) {
|
||||
const contrast = readParam(params, "contrast", -1, 1);
|
||||
if (contrast === 0) return color;
|
||||
const factor = 1 + contrast * 1.2;
|
||||
return color.map((channel) => clampUnit(0.5 + (channel - 0.5) * factor));
|
||||
}
|
||||
|
||||
function applyWhiteBalance(color, params) {
|
||||
const temperature = readParam(params, "temperature", -1, 1);
|
||||
const tint = readParam(params, "tint", -1, 1);
|
||||
const redScale = 1 + temperature * 0.28 + tint * 0.08;
|
||||
const greenScale = 1 - Math.abs(tint) * 0.1 - tint * 0.08;
|
||||
const blueScale = 1 - temperature * 0.28 + tint * 0.08;
|
||||
return [
|
||||
clampUnit(color[0] * redScale),
|
||||
clampUnit(color[1] * greenScale),
|
||||
clampUnit(color[2] * blueScale),
|
||||
];
|
||||
}
|
||||
|
||||
function applySplitTone(color, params) {
|
||||
const split = params?.splitTone;
|
||||
if (!split) return color;
|
||||
const intensity = clampUnit(Number(split.intensity ?? 0));
|
||||
if (intensity === 0) return color;
|
||||
const balance = clampUnit(Number(split.balance ?? 0.5));
|
||||
const y = luma(color);
|
||||
const shadowMask = 1 - smoothstep(balance - 0.25, balance + 0.2, y);
|
||||
const highlightMask = smoothstep(balance - 0.2, balance + 0.25, y);
|
||||
const shadows = Array.isArray(split.shadows) ? split.shadows : [0, 0, 0];
|
||||
const highlights = Array.isArray(split.highlights) ? split.highlights : [0, 0, 0];
|
||||
return color.map((channel, i) =>
|
||||
clampUnit(
|
||||
channel +
|
||||
Number(shadows[i] ?? 0) * shadowMask * intensity +
|
||||
Number(highlights[i] ?? 0) * highlightMask * intensity,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function applySaturation(color, params) {
|
||||
const saturation = readParam(params, "saturation", -1, 1);
|
||||
const vibrance = readParam(params, "vibrance", -1, 1);
|
||||
if (saturation === 0 && vibrance === 0) return color;
|
||||
const y = luma(color);
|
||||
const currentSat = Math.max(
|
||||
Math.abs(color[0] - y),
|
||||
Math.abs(color[1] - y),
|
||||
Math.abs(color[2] - y),
|
||||
);
|
||||
const vibranceWeight = 1 - clampUnit(currentSat * 2);
|
||||
const factor = clamp(1 + saturation + vibrance * vibranceWeight, 0, 2.5);
|
||||
return color.map((channel) => clampUnit(y + (channel - y) * factor));
|
||||
}
|
||||
|
||||
function applyParams(color, params) {
|
||||
let out = applyLiftGain(color, params);
|
||||
out = applyExposure(out, params);
|
||||
out = applyContrast(out, params);
|
||||
out = applyWhiteBalance(out, params);
|
||||
out = applySplitTone(out, params);
|
||||
out = applySaturation(out, params);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return clampUnit(value).toFixed(6);
|
||||
}
|
||||
|
||||
export function buildCube(params = {}, size = DEFAULT_SIZE) {
|
||||
if (!Number.isInteger(size) || size < 2 || size > MAX_SIZE) {
|
||||
throw new Error(`LUT size must be an integer from 2 to ${MAX_SIZE}`);
|
||||
}
|
||||
const lines = [
|
||||
`TITLE "media-use parametric grade"`,
|
||||
"DOMAIN_MIN 0 0 0",
|
||||
"DOMAIN_MAX 1 1 1",
|
||||
`LUT_3D_SIZE ${size}`,
|
||||
];
|
||||
const denom = size - 1;
|
||||
for (let b = 0; b < size; b++) {
|
||||
for (let g = 0; g < size; g++) {
|
||||
for (let r = 0; r < size; r++) {
|
||||
const out = applyParams([r / denom, g / denom, b / denom], params);
|
||||
lines.push(`${formatNumber(out[0])} ${formatNumber(out[1])} ${formatNumber(out[2])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function paramsFromIntent(intent) {
|
||||
const text = String(intent ?? "").toLowerCase();
|
||||
const params = {};
|
||||
let matched = false;
|
||||
|
||||
if (/\b(warm|golden|sunlit|sunny)\b/.test(text)) {
|
||||
params.temperature = 0.18;
|
||||
matched = true;
|
||||
} else if (/\b(cool|blue|icy|crisp)\b/.test(text)) {
|
||||
params.temperature = -0.16;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if (/\b(cinematic|film|movie)\b/.test(text)) {
|
||||
params.contrast = 0.08;
|
||||
params.saturation = 0.04;
|
||||
matched = true;
|
||||
}
|
||||
if (/\b(punchy|contrast|dramatic|bold)\b/.test(text)) {
|
||||
params.contrast = Math.max(params.contrast ?? 0, 0.22);
|
||||
matched = true;
|
||||
}
|
||||
if (/\b(bright|airy|lift)\b/.test(text)) {
|
||||
params.exposure = 0.16;
|
||||
params.shadows = 0.08;
|
||||
matched = true;
|
||||
}
|
||||
if (/\b(dark|moody|low-key)\b/.test(text)) {
|
||||
params.exposure = -0.12;
|
||||
params.contrast = Math.max(params.contrast ?? 0, 0.12);
|
||||
matched = true;
|
||||
}
|
||||
if (/\b(vibrant|saturated|colorful)\b/.test(text)) {
|
||||
params.saturation = Math.max(params.saturation ?? 0, 0.16);
|
||||
params.vibrance = 0.12;
|
||||
matched = true;
|
||||
}
|
||||
if (/\b(muted|desaturated|washed)\b/.test(text)) {
|
||||
params.saturation = Math.min(params.saturation ?? 0, -0.16);
|
||||
matched = true;
|
||||
}
|
||||
|
||||
return matched ? params : null;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { buildCube, paramsFromIntent } from "./cube-build.mjs";
|
||||
import { validateCube } from "./cube-validate.mjs";
|
||||
|
||||
function rows(cube) {
|
||||
return cube
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^[+-]?(?:\d|\.\d)/.test(line))
|
||||
.map((line) => line.split(/\s+/).map(Number));
|
||||
}
|
||||
|
||||
function rowAt(cubeRows, size, r, g, b) {
|
||||
return cubeRows[(b * size + g) * size + r];
|
||||
}
|
||||
|
||||
function luma(row) {
|
||||
return row[0] * 0.2126 + row[1] * 0.7152 + row[2] * 0.0722;
|
||||
}
|
||||
|
||||
test("all-zero params produce a near-identity LUT", () => {
|
||||
const cube = buildCube({}, 3);
|
||||
assert.equal(validateCube(cube).ok, true);
|
||||
const parsed = rows(cube);
|
||||
for (let b = 0; b < 3; b++) {
|
||||
for (let g = 0; g < 3; g++) {
|
||||
for (let r = 0; r < 3; r++) {
|
||||
const row = rowAt(parsed, 3, r, g, b);
|
||||
assert.ok(Math.abs(row[0] - r / 2) < 0.000001);
|
||||
assert.ok(Math.abs(row[1] - g / 2) < 0.000001);
|
||||
assert.ok(Math.abs(row[2] - b / 2) < 0.000001);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("positive exposure increases unclipped output luma", () => {
|
||||
const identity = rows(buildCube({}, 5));
|
||||
const exposed = rows(buildCube({ exposure: 0.3 }, 5));
|
||||
for (let i = 0; i < identity.length; i++) {
|
||||
const before = luma(identity[i]);
|
||||
if (before > 0.02 && before < 0.95) {
|
||||
assert.ok(luma(exposed[i]) > before, `row ${i} should brighten`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("positive temperature warms mid-gray", () => {
|
||||
const parsed = rows(buildCube({ temperature: 0.2 }, 3));
|
||||
const mid = rowAt(parsed, 3, 1, 1, 1);
|
||||
assert.ok(mid[0] > 0.5, "red channel should rise");
|
||||
assert.ok(mid[2] < 0.5, "blue channel should fall");
|
||||
});
|
||||
|
||||
test("positive contrast darkens shadows and brightens highlights", () => {
|
||||
const parsed = rows(buildCube({ contrast: 0.3 }, 5));
|
||||
const shadow = rowAt(parsed, 5, 1, 1, 1);
|
||||
const highlight = rowAt(parsed, 5, 3, 3, 3);
|
||||
assert.ok(luma(shadow) < 0.25, "below-mid gray should darken");
|
||||
assert.ok(luma(highlight) > 0.75, "above-mid gray should brighten");
|
||||
});
|
||||
|
||||
test("outputs validate at the default size and are deterministic", () => {
|
||||
const params = { exposure: 0.15, contrast: 0.2, temperature: -0.1, saturation: 0.12 };
|
||||
const a = buildCube(params);
|
||||
const b = buildCube(params);
|
||||
assert.equal(a, b);
|
||||
assert.equal(validateCube(a).ok, true);
|
||||
assert.equal(validateCube(a).size, 33);
|
||||
});
|
||||
|
||||
test("paramsFromIntent declines zero-overlap prompts and maps technical words", () => {
|
||||
assert.equal(paramsFromIntent("zqxv imaginary neutron look"), null);
|
||||
assert.deepEqual(paramsFromIntent("warm cinematic"), {
|
||||
temperature: 0.18,
|
||||
contrast: 0.08,
|
||||
saturation: 0.04,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Standalone mirror of packages/core/src/colorLuts.ts. media-use cannot import
|
||||
// the TypeScript source at runtime, so cube-validate.test.mjs mirrors core
|
||||
// parser cases to catch drift in accepted .cube files before freezing them.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const DEFAULT_MAX_CUBE_LUT_SIZE = 64;
|
||||
|
||||
const DEFAULT_DOMAIN_MIN = [0, 0, 0];
|
||||
const DEFAULT_DOMAIN_MAX = [1, 1, 1];
|
||||
|
||||
class CubeValidateError extends Error {
|
||||
constructor(message, lineNumber = null) {
|
||||
super(lineNumber == null ? message : `${message} at line ${lineNumber}`);
|
||||
this.name = "CubeValidateError";
|
||||
this.lineNumber = lineNumber;
|
||||
}
|
||||
}
|
||||
|
||||
function stripComment(line) {
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (char === '"') inQuote = !inQuote;
|
||||
if (char === "#" && !inQuote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value, lineNumber) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new CubeValidateError(`Invalid number "${value}"`, lineNumber);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseVec3(parts, keyword, lineNumber) {
|
||||
if (parts.length !== 3) {
|
||||
throw new CubeValidateError(`${keyword} expects three numbers`, lineNumber);
|
||||
}
|
||||
return [
|
||||
parseFiniteNumber(parts[0], lineNumber),
|
||||
parseFiniteNumber(parts[1], lineNumber),
|
||||
parseFiniteNumber(parts[2], lineNumber),
|
||||
];
|
||||
}
|
||||
|
||||
function parseSize(value, keyword, lineNumber) {
|
||||
if (!value) throw new CubeValidateError(`${keyword} expects a size`, lineNumber);
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 2) {
|
||||
throw new CubeValidateError(`${keyword} must be an integer greater than 1`, lineNumber);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validateDomain(domainMin, domainMax) {
|
||||
if (
|
||||
domainMax[0] <= domainMin[0] ||
|
||||
domainMax[1] <= domainMin[1] ||
|
||||
domainMax[2] <= domainMin[2]
|
||||
) {
|
||||
throw new CubeValidateError("DOMAIN_MAX values must be greater than DOMAIN_MIN values");
|
||||
}
|
||||
}
|
||||
|
||||
function isNumericDataLine(token) {
|
||||
return /^[+-]?(?:\d|\.\d)/.test(token);
|
||||
}
|
||||
|
||||
function parseCube(input, options = {}) {
|
||||
const maxSize = options.maxSize ?? DEFAULT_MAX_CUBE_LUT_SIZE;
|
||||
let domainMin = DEFAULT_DOMAIN_MIN;
|
||||
let domainMax = DEFAULT_DOMAIN_MAX;
|
||||
let lut1dSize = null;
|
||||
let lut3dSize = null;
|
||||
let rows = 0;
|
||||
|
||||
const lines = String(input)
|
||||
.replace(/^\uFEFF/, "")
|
||||
.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNumber = i + 1;
|
||||
const line = stripComment(lines[i] ?? "").trim();
|
||||
if (!line) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
const keyword = (parts[0] ?? "").toUpperCase();
|
||||
const rest = parts.slice(1);
|
||||
|
||||
if (keyword === "TITLE") continue;
|
||||
if (keyword === "DOMAIN_MIN") {
|
||||
domainMin = parseVec3(rest, keyword, lineNumber);
|
||||
continue;
|
||||
}
|
||||
if (keyword === "DOMAIN_MAX") {
|
||||
domainMax = parseVec3(rest, keyword, lineNumber);
|
||||
continue;
|
||||
}
|
||||
if (keyword === "LUT_3D_INPUT_RANGE") {
|
||||
if (rest.length !== 2) {
|
||||
throw new CubeValidateError(`${keyword} expects two numbers`, lineNumber);
|
||||
}
|
||||
const min = parseFiniteNumber(rest[0], lineNumber);
|
||||
const max = parseFiniteNumber(rest[1], lineNumber);
|
||||
if (max <= min) {
|
||||
throw new CubeValidateError("LUT_3D_INPUT_RANGE max must exceed min", lineNumber);
|
||||
}
|
||||
domainMin = [min, min, min];
|
||||
domainMax = [max, max, max];
|
||||
continue;
|
||||
}
|
||||
if (keyword === "LUT_1D_SIZE") {
|
||||
lut1dSize = parseSize(rest[0], keyword, lineNumber);
|
||||
continue;
|
||||
}
|
||||
if (keyword === "LUT_3D_SIZE") {
|
||||
lut3dSize = parseSize(rest[0], keyword, lineNumber);
|
||||
if (lut3dSize > maxSize) {
|
||||
throw new CubeValidateError(`LUT_3D_SIZE ${lut3dSize} exceeds max ${maxSize}`, lineNumber);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isNumericDataLine(keyword)) {
|
||||
if (keyword.startsWith("LUT_")) {
|
||||
throw new CubeValidateError(`Unsupported cube keyword ${keyword}`, lineNumber);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!lut3dSize) {
|
||||
if (lut1dSize) {
|
||||
throw new CubeValidateError("1D cube LUTs are not supported yet", lineNumber);
|
||||
}
|
||||
throw new CubeValidateError("LUT data appears before LUT_3D_SIZE", lineNumber);
|
||||
}
|
||||
if (parts.length !== 3) {
|
||||
throw new CubeValidateError("LUT data rows must contain three numbers", lineNumber);
|
||||
}
|
||||
parseFiniteNumber(parts[0], lineNumber);
|
||||
parseFiniteNumber(parts[1], lineNumber);
|
||||
parseFiniteNumber(parts[2], lineNumber);
|
||||
rows++;
|
||||
}
|
||||
|
||||
if (lut1dSize && lut3dSize) {
|
||||
throw new CubeValidateError("Mixed 1D and 3D cube LUTs are not supported yet");
|
||||
}
|
||||
if (!lut3dSize) {
|
||||
if (lut1dSize) throw new CubeValidateError("1D cube LUTs are not supported yet");
|
||||
throw new CubeValidateError("Missing LUT_3D_SIZE");
|
||||
}
|
||||
validateDomain(domainMin, domainMax);
|
||||
|
||||
const expectedRows = lut3dSize * lut3dSize * lut3dSize;
|
||||
if (rows !== expectedRows) {
|
||||
throw new CubeValidateError(
|
||||
`Expected ${expectedRows} LUT rows for size ${lut3dSize}, found ${rows}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { size: lut3dSize };
|
||||
}
|
||||
|
||||
export function validateCube(input, options = {}) {
|
||||
try {
|
||||
const parsed = parseCube(input, options);
|
||||
return { ok: true, size: parsed.size };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCubeFile(filePath, options = {}) {
|
||||
return validateCube(readFileSync(filePath, "utf8"), options);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const file = argv[2];
|
||||
if (!file) {
|
||||
console.error("usage: cube-validate.mjs <file.cube>");
|
||||
process.exit(2);
|
||||
}
|
||||
const result = validateCubeFile(file);
|
||||
if (!result.ok) {
|
||||
console.error(`error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`ok: LUT_3D_SIZE ${result.size}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolvePath(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main(process.argv);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { test } from "node:test";
|
||||
import { validateCube } from "./cube-validate.mjs";
|
||||
|
||||
const IDENTITY_2 = `
|
||||
# comment
|
||||
TITLE "Identity 2"
|
||||
DOMAIN_MIN 0 0 0
|
||||
DOMAIN_MAX 1 1 1
|
||||
LUT_3D_SIZE 2
|
||||
0 0 0
|
||||
1 0 0
|
||||
0 1 0
|
||||
1 1 0
|
||||
0 0 1
|
||||
1 0 1
|
||||
0 1 1
|
||||
1 1 1
|
||||
`;
|
||||
|
||||
test("accepts a valid minimal 3D cube LUT", () => {
|
||||
const result = validateCube(IDENTITY_2);
|
||||
assert.deepEqual(result, { ok: true, size: 2 });
|
||||
});
|
||||
|
||||
test("rejects oversize LUTs with the core parser message", () => {
|
||||
const result = validateCube("LUT_3D_SIZE 65", { maxSize: 64 });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /LUT_3D_SIZE 65 exceeds max 64/);
|
||||
});
|
||||
|
||||
test("rejects data rows before LUT_3D_SIZE", () => {
|
||||
const result = validateCube("0 0 0\nLUT_3D_SIZE 2");
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /LUT data appears before LUT_3D_SIZE/);
|
||||
});
|
||||
|
||||
test("rejects missing LUT_3D_SIZE", () => {
|
||||
const result = validateCube('TITLE "No Size"');
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /Missing LUT_3D_SIZE/);
|
||||
});
|
||||
|
||||
test("rejects row count mismatches with the core parser message", () => {
|
||||
const result = validateCube("LUT_3D_SIZE 2\n0 0 0");
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /Expected 8 LUT rows/);
|
||||
});
|
||||
|
||||
test("rejects inverted domains", () => {
|
||||
const result = validateCube(`
|
||||
DOMAIN_MIN 0 0 0
|
||||
DOMAIN_MAX 1 0 1
|
||||
LUT_3D_SIZE 2
|
||||
0 0 0
|
||||
1 0 0
|
||||
0 1 0
|
||||
1 1 0
|
||||
0 0 1
|
||||
1 0 1
|
||||
0 1 1
|
||||
1 1 1
|
||||
`);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /DOMAIN_MAX values must be greater than DOMAIN_MIN values/);
|
||||
});
|
||||
|
||||
test("rejects unsupported 1D and mixed cube LUTs", () => {
|
||||
const oneD = validateCube("LUT_1D_SIZE 2\n0 0 0\n1 1 1");
|
||||
assert.equal(oneD.ok, false);
|
||||
assert.match(oneD.error, /1D cube LUTs are not supported yet/);
|
||||
|
||||
const mixed = validateCube(`
|
||||
LUT_1D_SIZE 2
|
||||
LUT_3D_SIZE 2
|
||||
0 0 0
|
||||
1 0 0
|
||||
0 1 0
|
||||
1 1 0
|
||||
0 0 1
|
||||
1 0 1
|
||||
0 1 1
|
||||
1 1 1
|
||||
`);
|
||||
assert.equal(mixed.ok, false);
|
||||
assert.match(mixed.error, /Mixed 1D and 3D cube LUTs are not supported yet/);
|
||||
});
|
||||
|
||||
test("CLI exits zero for valid files and non-zero for invalid files", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mu-cube-validate-"));
|
||||
try {
|
||||
const valid = join(dir, "valid.cube");
|
||||
const invalid = join(dir, "invalid.cube");
|
||||
writeFileSync(valid, IDENTITY_2);
|
||||
writeFileSync(invalid, "LUT_3D_SIZE 65");
|
||||
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
[new URL("./cube-validate.mjs", import.meta.url).pathname, valid],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
assert.match(out, /ok: LUT_3D_SIZE 2/);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[new URL("./cube-validate.mjs", import.meta.url).pathname, invalid],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
},
|
||||
),
|
||||
(err) => err.status === 1 && String(err.stderr).includes("exceeds max 64"),
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { compileCutList } from "./cutlist.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT = join(HERE, "..", "transcript-cut.mjs");
|
||||
|
||||
test("explicit --remove ranges invert to kept segments", () => {
|
||||
const transcript = [
|
||||
word("w0", "alpha", 0, 1),
|
||||
word("w1", "beta", 1.2, 2),
|
||||
word("w2", "gamma", 2.2, 5),
|
||||
];
|
||||
|
||||
assert.deepEqual(compileCutList(transcript, { remove: "1-2.5" }), [
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 2.5, end: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("--remove-words resolves inclusive word-index ranges to time ranges", () => {
|
||||
const transcript = [
|
||||
word("w0", "zero", 0, 0.5),
|
||||
word("w1", "one", 0.6, 1),
|
||||
word("w2", "two", 1.1, 1.5),
|
||||
word("w3", "three", 2, 3),
|
||||
];
|
||||
|
||||
assert.deepEqual(compileCutList(transcript, { removeWords: "1-2" }), [
|
||||
{ start: 0, end: 0.6 },
|
||||
{ start: 1.5, end: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("--remove-fillers drops case-insensitive matching words", () => {
|
||||
const transcript = [
|
||||
word("w0", "Hello", 0, 0.5),
|
||||
word("w1", "Um", 0.5, 0.7),
|
||||
word("w2", "world", 0.8, 1.2),
|
||||
word("w3", "LIKE", 1.3, 1.5),
|
||||
word("w4", "done", 1.6, 2),
|
||||
];
|
||||
|
||||
assert.deepEqual(compileCutList(transcript, { removeFillers: "um,like" }), [
|
||||
{ start: 0, end: 0.5 },
|
||||
{ start: 0.7, end: 1.3 },
|
||||
{ start: 1.5, end: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("--cut-silence removes only the center of long inter-word gaps", () => {
|
||||
const transcript = [word("w0", "a", 0, 0.5), word("w1", "b", 2, 2.5), word("w2", "c", 2.7, 3)];
|
||||
|
||||
assert.deepEqual(compileCutList(transcript, { cutSilence: 0.8 }), [
|
||||
{ start: 0, end: 0.65 },
|
||||
{ start: 1.85, end: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("overlapping removal sources merge before inversion", () => {
|
||||
const transcript = [
|
||||
word("w0", "start", 0, 0.5),
|
||||
word("w1", "um", 0.9, 1.1),
|
||||
word("w2", "middle", 2.5, 2.8),
|
||||
word("w3", "more", 3.1, 3.4),
|
||||
word("w4", "end", 5.5, 6),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
compileCutList(transcript, {
|
||||
remove: "1-2.7",
|
||||
removeWords: "2-3",
|
||||
removeFillers: "um",
|
||||
}),
|
||||
[
|
||||
{ start: 0, end: 0.9 },
|
||||
{ start: 3.4, end: 6 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("kept slivers shorter than 0.2s are dropped", () => {
|
||||
const transcript = [word("w0", "start", 0, 0.5), word("w1", "end", 2.5, 3)];
|
||||
|
||||
assert.deepEqual(compileCutList(transcript, { remove: "0.1-2.95" }), []);
|
||||
});
|
||||
|
||||
test("--keep is inverse mode and coalesces direct kept ranges", () => {
|
||||
const transcript = [word("w0", "start", 0, 0.5), word("w1", "end", 4.5, 5)];
|
||||
|
||||
assert.deepEqual(compileCutList(transcript, { keep: "3-4,1-2,1.5-2.5,4.1-4.2" }), [
|
||||
{ start: 1, end: 2.5 },
|
||||
{ start: 3, end: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("--plan on a fixture transcript prints the exact segment JSON", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "media-use-cutlist-"));
|
||||
try {
|
||||
const transcriptPath = join(dir, "fixture.json");
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
JSON.stringify([
|
||||
word("w0", "hello", 0, 0.4),
|
||||
word("w1", "um", 0.5, 0.65),
|
||||
word("w2", "there", 0.7, 1),
|
||||
word("w3", "pause", 2.2, 2.5),
|
||||
word("w4", "end", 2.7, 3.2),
|
||||
]),
|
||||
);
|
||||
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
SCRIPT,
|
||||
"--input",
|
||||
"ignored.mp4",
|
||||
"--transcript",
|
||||
transcriptPath,
|
||||
"--remove",
|
||||
"0.9-1.2",
|
||||
"--remove-fillers",
|
||||
"um",
|
||||
"--cut-silence",
|
||||
"0.8",
|
||||
"--plan",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.deepEqual(JSON.parse(out), [
|
||||
{ start: 0, end: 0.5 },
|
||||
{ start: 0.65, end: 0.9 },
|
||||
{ start: 2.05, end: 3.2 },
|
||||
]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function word(id, text, start, end) {
|
||||
return { id, text, start, end };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { duckKeyframes, speechSpans } from "./duck.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT = join(HERE, "..", "audio-duck.mjs");
|
||||
|
||||
test("speechSpans bridges gaps smaller than mergeGap", () => {
|
||||
const meta = {
|
||||
words: [word("w0", "one", 0, 0.5), word("w1", "two", 0.8, 1), word("w2", "three", 2, 2.2)],
|
||||
};
|
||||
|
||||
assert.deepEqual(speechSpans(meta, { mergeGap: 0.4 }), [
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 2, end: 2.2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("speechSpans refuses multi-line meta without placement (file-relative times)", () => {
|
||||
const meta = {
|
||||
voices: [
|
||||
{ id: "a", words: [word("w0", "one", 0, 1)] },
|
||||
{ id: "b", words: [word("w1", "two", 0, 1)] },
|
||||
],
|
||||
};
|
||||
assert.throws(() => speechSpans(meta, { mergeGap: 0.2 }), /--sequential or --offsets/);
|
||||
});
|
||||
|
||||
test("speechSpans sequential stacks lines by duration plus gap", () => {
|
||||
const meta = {
|
||||
voices: [
|
||||
{ id: "a", duration_s: 2, words: [word("w0", "one", 0.1, 1.9)] },
|
||||
{ id: "b", duration_s: 1, words: [word("w1", "two", 0.1, 0.9)] },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(speechSpans(meta, { mergeGap: 0.2, sequential: true, gap: 0.5 }), [
|
||||
{ start: 0.1, end: 1.9 },
|
||||
{ start: 2.6, end: 3.4 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("speechSpans explicit offsets place each line at composition time", () => {
|
||||
const meta = {
|
||||
voices: [
|
||||
{ id: "a", words: [word("w0", "one", 0, 1)] },
|
||||
{ id: "b", words: [word("w1", "two", 0, 1)] },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(speechSpans(meta, { mergeGap: 0.2, offsets: { a: 0, b: 4 } }), [
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 4, end: 5 },
|
||||
]);
|
||||
assert.throws(() => speechSpans(meta, { offsets: { a: 0 } }), /missing voice "b"/);
|
||||
});
|
||||
|
||||
test("speechSpans returns empty spans for empty input", () => {
|
||||
assert.deepEqual(speechSpans({ voices: [] }, { mergeGap: 0.6 }), []);
|
||||
});
|
||||
|
||||
test("duckKeyframes shapes attack and release from base volume", () => {
|
||||
assert.deepEqual(
|
||||
duckKeyframes([{ start: 3, end: 5 }], {
|
||||
duck: 0.25,
|
||||
attack: 0.15,
|
||||
release: 0.4,
|
||||
baseVolume: 0.6,
|
||||
}),
|
||||
[
|
||||
{ time: 3, volume: 0.15, duration: 0.15 },
|
||||
{ time: 5, volume: 0.6, duration: 0.4 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("--json spans match --merge-gap semantics exactly", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "media-use-duck-"));
|
||||
try {
|
||||
const metaPath = join(dir, "audio_meta.json");
|
||||
writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({
|
||||
voices: [
|
||||
{
|
||||
id: "narration",
|
||||
words: [
|
||||
word("w0", "one", 0, 0.4),
|
||||
word("w1", "two", 0.9, 1.2),
|
||||
word("w2", "three", 1.8, 2.1),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
[SCRIPT, "--meta", metaPath, "--target", "#bgm", "--merge-gap", "0.6", "--json"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(out);
|
||||
assert.deepEqual(parsed.spans, [
|
||||
{ start: 0, end: 1.2 },
|
||||
{ start: 1.8, end: 2.1 },
|
||||
]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function word(id, text, start, end) {
|
||||
return { id, text, start, end };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { writeFileSync, copyFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
// ponytail: bound the download so a hostile/runaway URL can't fill the disk.
|
||||
// 256MB covers any real media asset; raise if 4K video sources ever exceed it.
|
||||
const MAX_FREEZE_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
export async function freezeUrl(url, destPath) {
|
||||
const where = String(url).slice(0, 80);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status} for ${where}`);
|
||||
|
||||
// Fail fast on an advertised oversize body before reading a single byte.
|
||||
const declared = Number(res.headers.get("content-length"));
|
||||
if (declared > MAX_FREEZE_BYTES)
|
||||
throw new Error(
|
||||
`freeze failed: ${declared} bytes exceeds ${MAX_FREEZE_BYTES} cap for ${where}`,
|
||||
);
|
||||
|
||||
// Stream and abort once the cap is crossed, so a lying/chunked hostile URL
|
||||
// can't buffer the whole payload into memory before the check (M1).
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for await (const chunk of res.body) {
|
||||
total += chunk.length;
|
||||
if (total > MAX_FREEZE_BYTES)
|
||||
throw new Error(`freeze failed: stream exceeds ${MAX_FREEZE_BYTES} cap for ${where}`);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (total === 0) throw new Error(`freeze failed: empty response for ${where}`);
|
||||
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
writeFileSync(destPath, Buffer.concat(chunks, total));
|
||||
return total;
|
||||
}
|
||||
|
||||
export function freezeLocalFile(srcPath, destPath) {
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
copyFileSync(srcPath, destPath);
|
||||
}
|
||||
|
||||
// Ingest accepts a DIRECT public media URL only — not a platform page. yt-dlp is
|
||||
// deliberately out (cloud IPs get blocked, and it's brittle); the supported case
|
||||
// is "user points at their own file or a direct asset link". A direct URL is a
|
||||
// non-platform host whose path ends in a known media extension.
|
||||
const PLATFORM_HOSTS =
|
||||
/(^|\.)(youtube\.com|youtu\.be|vimeo\.com|tiktok\.com|instagram\.com|twitter\.com|x\.com|facebook\.com|dailymotion\.com)$/i;
|
||||
const MEDIA_EXT = /\.(mp3|wav|m4a|aac|ogg|flac|mp4|mov|webm|mkv|png|jpe?g|webp|gif|svg|avif)$/i;
|
||||
|
||||
// SSRF guard (m11): a user-supplied --from URL must not point at the local host
|
||||
// or a private network. Blocks loopback/localhost, RFC1918, link-local, and the
|
||||
// IPv6 equivalents on the literal hostname.
|
||||
// ponytail: literal-host check only; a DNS name that *resolves* to a private IP
|
||||
// (rebinding) still passes — add resolve-then-check if --from ever fetches from
|
||||
// untrusted hostnames at scale.
|
||||
const PRIVATE_HOST =
|
||||
/^(localhost|.*\.local|.*\.internal|127\.|10\.|0\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|\[?(::1|::ffff:127\.|f[cd][0-9a-f]{2}:|fe80:))/i;
|
||||
|
||||
export function isDirectMediaUrl(u) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(u);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||
if (PLATFORM_HOSTS.test(url.hostname)) return false;
|
||||
if (PRIVATE_HOST.test(url.hostname)) return false;
|
||||
return MEDIA_EXT.test(url.pathname);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { isDirectMediaUrl } from "./freeze.mjs";
|
||||
|
||||
test("accepts direct public media URLs", () => {
|
||||
assert.equal(isDirectMediaUrl("https://cdn.example.com/clip.mp4"), true);
|
||||
assert.equal(isDirectMediaUrl("https://example.com/a/b/track.mp3"), true);
|
||||
assert.equal(isDirectMediaUrl("http://example.com/logo.svg"), true);
|
||||
});
|
||||
|
||||
test("rejects platform pages (no yt-dlp)", () => {
|
||||
assert.equal(isDirectMediaUrl("https://www.youtube.com/watch?v=abc"), false);
|
||||
assert.equal(isDirectMediaUrl("https://youtu.be/abc"), false);
|
||||
assert.equal(isDirectMediaUrl("https://vimeo.com/12345"), false);
|
||||
assert.equal(isDirectMediaUrl("https://x.com/u/status/1"), false);
|
||||
});
|
||||
|
||||
test("rejects non-direct / non-media URLs", () => {
|
||||
assert.equal(isDirectMediaUrl("https://example.com/page"), false, "no media extension");
|
||||
assert.equal(isDirectMediaUrl("ftp://example.com/a.mp4"), false, "non-http(s)");
|
||||
assert.equal(isDirectMediaUrl("not a url"), false);
|
||||
});
|
||||
|
||||
test("rejects local / private hosts (SSRF guard, m11)", () => {
|
||||
for (const u of [
|
||||
"http://localhost/a.mp4",
|
||||
"http://127.0.0.1/a.mp4",
|
||||
"http://127.1.2.3/a.mp4",
|
||||
"http://0.0.0.0/a.mp4",
|
||||
"http://10.0.0.5/a.mp4",
|
||||
"http://192.168.1.1/a.mp4",
|
||||
"http://172.16.0.1/a.mp4",
|
||||
"http://172.31.255.255/a.mp4",
|
||||
"http://169.254.169.254/a.mp4", // cloud metadata endpoint
|
||||
"http://printer.local/a.mp4",
|
||||
"http://svc.internal/a.mp4",
|
||||
"http://[::1]/a.mp4",
|
||||
"http://[fe80::1]/a.mp4",
|
||||
"http://[fd00::1]/a.mp4",
|
||||
]) {
|
||||
assert.equal(isDirectMediaUrl(u), false, `should block ${u}`);
|
||||
}
|
||||
// A public host that merely starts with similar digits is still allowed.
|
||||
assert.equal(isDirectMediaUrl("https://172.40.0.1/a.mp4"), true, "172.40 is public");
|
||||
assert.equal(isDirectMediaUrl("https://11.example.com/a.mp4"), true);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { basename, extname } from "node:path";
|
||||
|
||||
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"]);
|
||||
const SAMPLE_FRAMES = 5;
|
||||
// A long HD clip on slow storage can exceed the default 15s signalstats window;
|
||||
// override without a code change via HYPERFRAMES_ANALYZE_TIMEOUT_MS.
|
||||
const SIGNALSTATS_TIMEOUT_MS = Number(process.env.HYPERFRAMES_ANALYZE_TIMEOUT_MS) || 15000;
|
||||
|
||||
const ADJUST_LIMITS = {
|
||||
exposure: { min: -2, max: 2 },
|
||||
contrast: { min: -1, max: 1 },
|
||||
highlights: { min: -1, max: 1 },
|
||||
shadows: { min: -1, max: 1 },
|
||||
whites: { min: -1, max: 1 },
|
||||
blacks: { min: -1, max: 1 },
|
||||
temperature: { min: -1, max: 1 },
|
||||
tint: { min: -1, max: 1 },
|
||||
vibrance: { min: -1, max: 1 },
|
||||
saturation: { min: -1, max: 1 },
|
||||
};
|
||||
|
||||
function clamp(value, key) {
|
||||
const limit = ADJUST_LIMITS[key];
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(limit.max, Math.max(limit.min, value));
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function avg(values) {
|
||||
if (values.length === 0) return 0;
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function probeDuration(mediaPath) {
|
||||
try {
|
||||
const raw = execFileSync(
|
||||
"ffprobe",
|
||||
["-v", "quiet", "-print_format", "json", "-show_format", mediaPath],
|
||||
{ encoding: "utf8", timeout: 5000 },
|
||||
);
|
||||
const parsed = JSON.parse(raw);
|
||||
const duration = Number(parsed.format?.duration);
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function filterFor(mediaPath) {
|
||||
const ext = extname(mediaPath).toLowerCase();
|
||||
if (IMAGE_EXT.has(ext)) return "signalstats,metadata=print:file=-";
|
||||
const duration = probeDuration(mediaPath);
|
||||
if (!duration || duration <= 1) return "signalstats,metadata=print:file=-";
|
||||
const fps = Math.max(0.1, Math.min(2, SAMPLE_FRAMES / duration));
|
||||
return `fps=${fps.toFixed(4)},signalstats,metadata=print:file=-`;
|
||||
}
|
||||
|
||||
function parseSignalStats(raw) {
|
||||
const frames = [];
|
||||
let current = null;
|
||||
for (const line of String(raw).split(/\r?\n/)) {
|
||||
const frameMatch = line.match(/^frame:/);
|
||||
if (frameMatch) {
|
||||
if (current) frames.push(current);
|
||||
current = {};
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/lavfi\.signalstats\.([A-Z]+)=([+-]?(?:\d+(?:\.\d+)?|\.\d+))/);
|
||||
if (!match) continue;
|
||||
if (!current) current = {};
|
||||
current[match[1]] = Number(match[2]);
|
||||
}
|
||||
if (current) frames.push(current);
|
||||
const complete = frames.filter(
|
||||
(frame) =>
|
||||
Number.isFinite(frame.YMIN) &&
|
||||
Number.isFinite(frame.YMAX) &&
|
||||
Number.isFinite(frame.YAVG) &&
|
||||
Number.isFinite(frame.UAVG) &&
|
||||
Number.isFinite(frame.VAVG),
|
||||
);
|
||||
if (complete.length === 0) {
|
||||
throw new Error("no signalstats frames found");
|
||||
}
|
||||
return {
|
||||
frames: complete.length,
|
||||
yMin: Math.min(...complete.map((frame) => frame.YMIN)),
|
||||
yMax: Math.max(...complete.map((frame) => frame.YMAX)),
|
||||
yAvg: avg(complete.map((frame) => frame.YAVG)),
|
||||
uAvg: avg(complete.map((frame) => frame.UAVG)),
|
||||
vAvg: avg(complete.map((frame) => frame.VAVG)),
|
||||
};
|
||||
}
|
||||
|
||||
export function statsToAdjust(stats) {
|
||||
const yMin = Number(stats.yMin);
|
||||
const yMax = Number(stats.yMax);
|
||||
const yAvg = Number(stats.yAvg);
|
||||
const uAvg = Number(stats.uAvg);
|
||||
const vAvg = Number(stats.vAvg);
|
||||
const spread = (yMax - yMin) / 255;
|
||||
const normalizedAvg = yAvg / 255;
|
||||
const exposure = clamp((0.45 - normalizedAvg) * 1.8, "exposure");
|
||||
const contrast = clamp((0.42 - spread) * 0.9, "contrast");
|
||||
const whites =
|
||||
yMax > 230 ? clamp(-((yMax - 230) / 40 + Math.max(0, normalizedAvg - 0.74)), "whites") : 0;
|
||||
const blacks = yMin < 12 ? clamp((12 - yMin) / 80, "blacks") : 0;
|
||||
const chromaWarmth = (vAvg - 128 + (128 - uAvg)) / 128;
|
||||
const temperature = clamp(-chromaWarmth * 0.7, "temperature");
|
||||
const tint = clamp(-(uAvg - 128 + (vAvg - 128)) / 256, "tint");
|
||||
|
||||
return {
|
||||
adjust: {
|
||||
exposure: round(exposure),
|
||||
contrast: round(contrast),
|
||||
blacks: round(blacks),
|
||||
whites: round(whites),
|
||||
temperature: round(temperature),
|
||||
tint: round(tint),
|
||||
},
|
||||
measured: {
|
||||
frames: Number(stats.frames ?? 1),
|
||||
yMin: round(yMin),
|
||||
yMax: round(yMax),
|
||||
yAvg: round(yAvg),
|
||||
uAvg: round(uAvg),
|
||||
vAvg: round(vAvg),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeMediaGrade(mediaPath) {
|
||||
try {
|
||||
const raw = execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
mediaPath,
|
||||
"-vf",
|
||||
filterFor(mediaPath),
|
||||
"-frames:v",
|
||||
String(SAMPLE_FRAMES),
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
],
|
||||
{ encoding: "utf8", timeout: SIGNALSTATS_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
return statsToAdjust(parseSignalStats(raw));
|
||||
} catch (err) {
|
||||
throw new Error(`grade analysis failed for ${mediaPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMeasuredNote(mediaPath, measured) {
|
||||
return `media-use: measured ${basename(mediaPath)}: frames=${measured.frames}, YMIN=${measured.yMin}, YMAX=${measured.yMax}, YAVG=${measured.yAvg}, UAVG=${measured.uAvg}, VAVG=${measured.vAvg}; adjust is a starting suggestion`;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { test } from "node:test";
|
||||
import { analyzeMediaGrade, formatMeasuredNote, statsToAdjust } from "./grade-analyzer.mjs";
|
||||
|
||||
// The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by
|
||||
// design — skills tests are meant to be node-builtin-only). Tests that shell to
|
||||
// ffmpeg skip there and run wherever ffmpeg is present (locally, dev).
|
||||
const FFMPEG_SKIP =
|
||||
spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0
|
||||
? false
|
||||
: "ffmpeg not on PATH";
|
||||
|
||||
const ADJUST_LIMITS = {
|
||||
exposure: { min: -2, max: 2 },
|
||||
contrast: { min: -1, max: 1 },
|
||||
highlights: { min: -1, max: 1 },
|
||||
shadows: { min: -1, max: 1 },
|
||||
whites: { min: -1, max: 1 },
|
||||
blacks: { min: -1, max: 1 },
|
||||
temperature: { min: -1, max: 1 },
|
||||
tint: { min: -1, max: 1 },
|
||||
vibrance: { min: -1, max: 1 },
|
||||
saturation: { min: -1, max: 1 },
|
||||
};
|
||||
|
||||
function makeFrame(dir, name, color) {
|
||||
const out = join(dir, name);
|
||||
execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`color=c=${color}:s=64x64`,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-y",
|
||||
out,
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function assertWithinLimits(adjust) {
|
||||
for (const [key, value] of Object.entries(adjust)) {
|
||||
const limit = ADJUST_LIMITS[key];
|
||||
assert.ok(limit, `unexpected adjust key ${key}`);
|
||||
assert.ok(value >= limit.min && value <= limit.max, `${key} out of range: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
test("under-exposed synthetic frame suggests positive exposure", { skip: FFMPEG_SKIP }, () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mu-grade-under-"));
|
||||
try {
|
||||
const file = makeFrame(dir, "under.png", "0x202020");
|
||||
const { adjust, measured } = analyzeMediaGrade(file);
|
||||
assert.ok(measured.frames >= 1);
|
||||
assert.ok(adjust.exposure > 0, `expected positive exposure, got ${adjust.exposure}`);
|
||||
assertWithinLimits(adjust);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("over-exposed synthetic frame pulls exposure and whites down", { skip: FFMPEG_SKIP }, () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mu-grade-over-"));
|
||||
try {
|
||||
const file = makeFrame(dir, "over.png", "white");
|
||||
const { adjust } = analyzeMediaGrade(file);
|
||||
assert.ok(adjust.exposure < 0, `expected negative exposure, got ${adjust.exposure}`);
|
||||
assert.ok(adjust.whites < 0, `expected negative whites, got ${adjust.whites}`);
|
||||
assertWithinLimits(adjust);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"warm-cast synthetic frame suggests negative temperature correction",
|
||||
{ skip: FFMPEG_SKIP },
|
||||
() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mu-grade-warm-"));
|
||||
try {
|
||||
const file = makeFrame(dir, "warm.png", "orange");
|
||||
const { adjust } = analyzeMediaGrade(file);
|
||||
assert.ok(adjust.temperature < 0, `expected cooling correction, got ${adjust.temperature}`);
|
||||
assertWithinLimits(adjust);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("low-spread stats suggest positive contrast", () => {
|
||||
const { adjust } = statsToAdjust({
|
||||
frames: 1,
|
||||
yMin: 104,
|
||||
yMax: 116,
|
||||
yAvg: 110,
|
||||
uAvg: 128,
|
||||
vAvg: 128,
|
||||
});
|
||||
assert.ok(adjust.contrast > 0, `expected positive contrast, got ${adjust.contrast}`);
|
||||
assertWithinLimits(adjust);
|
||||
});
|
||||
|
||||
test("malformed media fails cleanly", () => {
|
||||
assert.throws(
|
||||
() => analyzeMediaGrade(join(tmpdir(), "does-not-exist.png")),
|
||||
/grade analysis failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"media path with shell metacharacters is passed as argv, not a shell string",
|
||||
{
|
||||
skip: FFMPEG_SKIP,
|
||||
},
|
||||
() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mu-grade-shell-"));
|
||||
const sentinel = join(process.cwd(), "mu-grade-shell-sentinel.png");
|
||||
try {
|
||||
if (existsSync(sentinel)) unlinkSync(sentinel);
|
||||
const file = makeFrame(dir, "frame; touch mu-grade-shell-sentinel.png", "orange");
|
||||
const result = analyzeMediaGrade(file);
|
||||
assert.ok(result.measured.frames >= 1);
|
||||
assert.equal(existsSync(sentinel), false);
|
||||
} finally {
|
||||
if (existsSync(sentinel)) unlinkSync(sentinel);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("measured note is a stderr-safe single-line summary", () => {
|
||||
const note = formatMeasuredNote("/tmp/frame.png", {
|
||||
frames: 1,
|
||||
yMin: 10,
|
||||
yMax: 240,
|
||||
yAvg: 80,
|
||||
uAvg: 120,
|
||||
vAvg: 140,
|
||||
});
|
||||
assert.match(note, /^media-use: measured /);
|
||||
assert.match(note, /YAVG=80/);
|
||||
assert.equal(note.includes("\n"), false);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { track } from "./telemetry.mjs";
|
||||
|
||||
// v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it
|
||||
// ("heygen-cli can't use OAuth yet"), and OAuth is what the free-usage path
|
||||
// needs — so anything below this can't authenticate for free usage at all.
|
||||
export const HEYGEN_MIN_VERSION = "0.3.0";
|
||||
// Free-usage path is OAuth (`--oauth` → subscription/free credits); `--api-key`
|
||||
// bills API credits, so the onboarding steers to OAuth.
|
||||
export const HEYGEN_INSTALL_COMMAND =
|
||||
"curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth";
|
||||
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
|
||||
export const HEYGEN_UPDATE_COMMAND = "heygen update";
|
||||
|
||||
export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: ${HEYGEN_INSTALL_COMMAND}`;
|
||||
export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
|
||||
export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`;
|
||||
|
||||
const ACTIONABLE_MESSAGES = new Set([
|
||||
HEYGEN_NOT_FOUND_MESSAGE,
|
||||
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
|
||||
HEYGEN_OUTDATED_MESSAGE,
|
||||
]);
|
||||
|
||||
export function classifyHeygenError(err) {
|
||||
return classifyHeygenErrorResult(err).message;
|
||||
}
|
||||
|
||||
export function classifyHeygenErrorCode(err) {
|
||||
return classifyHeygenErrorResult(err).code;
|
||||
}
|
||||
|
||||
function classifyHeygenErrorResult(err) {
|
||||
const detail = heygenErrorDetail(err);
|
||||
const text = [err?.stderr, err?.stdout, err?.message, detail]
|
||||
.map((value) => textOf(value))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
// Only ENOENT (spawn of a missing binary) or a shell's "command not found"
|
||||
// mean the CLI itself is absent. A bare "not found" would misfire on the CLI's
|
||||
// own resource errors (e.g. a stale voiceId → "voice not found"), whose message
|
||||
// embeds the `heygen ...` command line — sending users to reinstall a CLI they
|
||||
// just ran successfully. Keep this narrow.
|
||||
if (err?.code === "ENOENT" || lower.includes("command not found")) {
|
||||
return { code: "not_found", message: HEYGEN_NOT_FOUND_MESSAGE };
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("unauthorized") ||
|
||||
lower.includes("unauthenticated") ||
|
||||
// \b401\b, not a bare "401" substring — otherwise request IDs (req-401abc),
|
||||
// URLs, and retry-after headers would misclassify as an auth failure.
|
||||
/\b401\b/.test(lower) ||
|
||||
lower.includes("not logged in") ||
|
||||
lower.includes("no api key") ||
|
||||
lower.includes("missing api key") ||
|
||||
lower.includes("invalid api key") ||
|
||||
lower.includes("login required") ||
|
||||
lower.includes("auth required") ||
|
||||
lower.includes("authentication required")
|
||||
) {
|
||||
return { code: "not_authenticated", message: HEYGEN_NOT_AUTHENTICATED_MESSAGE };
|
||||
}
|
||||
|
||||
const version = firstSemver(text);
|
||||
if (version && versionLessThan(version, HEYGEN_MIN_VERSION)) {
|
||||
return { code: "outdated", message: HEYGEN_OUTDATED_MESSAGE };
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("quota") ||
|
||||
lower.includes("insufficient credit") ||
|
||||
lower.includes("too many requests") ||
|
||||
lower.includes("throttled") ||
|
||||
/\b429\b/.test(lower)
|
||||
) {
|
||||
return { code: "rate_limited", message: detail };
|
||||
}
|
||||
|
||||
return { code: "other", message: detail };
|
||||
}
|
||||
|
||||
// reportHeygenFailure's callers (voice-provider.mjs, heygen-search.mjs) are
|
||||
// synchronous and several layers below the CLI's process.exit() calls, so
|
||||
// they can't await this tracking call themselves. Stash each attempt's
|
||||
// promise here so a caller closer to exit (resolve.mjs) can join it first —
|
||||
// same "awaited so a short-lived run flushes it" discipline telemetry.mjs's
|
||||
// track() already documents, just reachable from a sync call site.
|
||||
const pendingFailureTracking = new Set();
|
||||
// resolve.mjs is a single-shot CLI (one resolve per process), so one shared
|
||||
// consume-once slot is sufficient. If resolve becomes an in-process/concurrent
|
||||
// API, move this state into a per-resolve context before reusing that path.
|
||||
let pendingRemediation = null;
|
||||
|
||||
export function consumeHeygenRemediation() {
|
||||
const remediation = pendingRemediation;
|
||||
pendingRemediation = null;
|
||||
return remediation;
|
||||
}
|
||||
|
||||
export function reportHeygenFailure(err, context, trackEvent = track) {
|
||||
const { code, message } = classifyHeygenErrorResult(err);
|
||||
if (code === "not_found" || code === "outdated") {
|
||||
pendingRemediation = { code, message };
|
||||
}
|
||||
if (ACTIONABLE_MESSAGES.has(message)) {
|
||||
console.error(message);
|
||||
} else {
|
||||
console.error(`media-use: \`${context}\` failed: ${message}`);
|
||||
}
|
||||
try {
|
||||
const tracked = Promise.resolve(
|
||||
trackEvent("media_use_provider_error", { provider: "heygen", reason: code }),
|
||||
).catch(() => {});
|
||||
pendingFailureTracking.add(tracked);
|
||||
void tracked.finally(() => pendingFailureTracking.delete(tracked));
|
||||
return tracked;
|
||||
} catch {
|
||||
// Telemetry must never affect the provider failure path.
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
// Awaits every provider-error track fired since the last flush, so a caller
|
||||
// about to process.exit() doesn't orphan one mid-request (both are separate,
|
||||
// non-keepalive HTTP connections with no ordering guarantee otherwise).
|
||||
// Never rejects: each tracked promise already swallows its own failure.
|
||||
export async function flushHeygenFailureTracking() {
|
||||
if (pendingFailureTracking.size === 0) return;
|
||||
await Promise.all(pendingFailureTracking);
|
||||
}
|
||||
|
||||
export function firstSemver(text) {
|
||||
const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/);
|
||||
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
|
||||
}
|
||||
|
||||
export function versionLessThan(version, minimum) {
|
||||
const left = versionParts(version);
|
||||
const right = versionParts(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;
|
||||
}
|
||||
|
||||
function heygenErrorDetail(err) {
|
||||
return textOf(err?.stderr) || textOf(err?.stdout) || err?.message || String(err);
|
||||
}
|
||||
|
||||
function textOf(value) {
|
||||
return value == null ? "" : String(value).trim();
|
||||
}
|
||||
|
||||
function versionParts(version) {
|
||||
const match = String(version || "").match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
||||
return match ? match.slice(1).map((part) => Number.parseInt(part, 10)) : null;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
classifyHeygenError,
|
||||
classifyHeygenErrorCode,
|
||||
consumeHeygenRemediation,
|
||||
flushHeygenFailureTracking,
|
||||
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
|
||||
HEYGEN_NOT_FOUND_MESSAGE,
|
||||
HEYGEN_OUTDATED_MESSAGE,
|
||||
reportHeygenFailure,
|
||||
} from "./heygen-cli.mjs";
|
||||
|
||||
function captureFailureReport(err, context, trackEvent) {
|
||||
const originalError = console.error;
|
||||
const stderrCalls = [];
|
||||
console.error = (...args) => stderrCalls.push(args);
|
||||
try {
|
||||
if (trackEvent) {
|
||||
reportHeygenFailure(err, context, trackEvent);
|
||||
} else {
|
||||
reportHeygenFailure(err, context);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
return stderrCalls;
|
||||
}
|
||||
|
||||
test("classifies ENOENT-style missing heygen errors with install instructions", () => {
|
||||
const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" });
|
||||
|
||||
assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
|
||||
});
|
||||
|
||||
test("classifies auth failures with login instructions", () => {
|
||||
const message = classifyHeygenError({ stderr: Buffer.from("Error: not logged in") });
|
||||
|
||||
assert.equal(message, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
|
||||
});
|
||||
|
||||
test("classifies a real 401 as auth, but not a bare 401 substring in prose", () => {
|
||||
assert.equal(
|
||||
classifyHeygenError({ stderr: Buffer.from("HTTP 401 Unauthorized") }),
|
||||
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
|
||||
);
|
||||
// A request id that merely contains "401" must NOT read as an auth failure.
|
||||
const noise = classifyHeygenError({ stderr: Buffer.from("upload failed (request req-401abc)") });
|
||||
assert.notEqual(noise, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
|
||||
});
|
||||
|
||||
test("classifies old heygen versions with update instructions", () => {
|
||||
const message = classifyHeygenError({
|
||||
stderr: Buffer.from("heygen v0.1.5 does not support --headers"),
|
||||
});
|
||||
|
||||
assert.equal(message, HEYGEN_OUTDATED_MESSAGE);
|
||||
});
|
||||
|
||||
test("does not misclassify a resource 'not found' error as a missing CLI", () => {
|
||||
// A stale voiceId makes `heygen voice speech create` fail with "voice not
|
||||
// found"; the error message embeds the `heygen ...` command line. This must
|
||||
// pass through as detail, not send the user to reinstall a working CLI.
|
||||
const message = classifyHeygenError({
|
||||
stderr: Buffer.from("Error: voice not found (id: stale-123)"),
|
||||
message: "Command failed: heygen voice speech create --voice stale-123",
|
||||
});
|
||||
|
||||
assert.notEqual(message, HEYGEN_NOT_FOUND_MESSAGE);
|
||||
assert.equal(message, "Error: voice not found (id: stale-123)");
|
||||
});
|
||||
|
||||
test("classifies a shell 'command not found' as a missing CLI", () => {
|
||||
const message = classifyHeygenError({ stderr: Buffer.from("bash: heygen: command not found") });
|
||||
|
||||
assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
|
||||
});
|
||||
|
||||
test("passes through unrelated errors", () => {
|
||||
const message = classifyHeygenError({
|
||||
stderr: Buffer.from("rate limit exceeded"),
|
||||
message: "Command failed",
|
||||
});
|
||||
|
||||
assert.equal(message, "rate limit exceeded");
|
||||
});
|
||||
|
||||
test("classifies existing HeyGen failures with stable reason codes", () => {
|
||||
assert.equal(classifyHeygenErrorCode({ code: "ENOENT" }), "not_found");
|
||||
assert.equal(
|
||||
classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 401 Unauthorized") }),
|
||||
"not_authenticated",
|
||||
);
|
||||
assert.equal(
|
||||
classifyHeygenErrorCode({ stderr: Buffer.from("heygen v0.1.5 is unsupported") }),
|
||||
"outdated",
|
||||
);
|
||||
assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from("provider unavailable") }), "other");
|
||||
});
|
||||
|
||||
test("classifies rate-limit text case-insensitively", () => {
|
||||
assert.equal(
|
||||
classifyHeygenErrorCode({ stderr: Buffer.from("RATE LIMIT exceeded") }),
|
||||
"rate_limited",
|
||||
);
|
||||
});
|
||||
|
||||
test("classifies quota and insufficient-credit errors as rate limited", () => {
|
||||
for (const detail of ["Quota exhausted", "INSUFFICIENT CREDIT remaining"]) {
|
||||
assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited");
|
||||
}
|
||||
});
|
||||
|
||||
test("classifies the literal 429 reason phrase and throttling language as rate limited", () => {
|
||||
for (const detail of ["Too Many Requests", "Error: throttled by upstream, retry later"]) {
|
||||
assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited");
|
||||
}
|
||||
});
|
||||
|
||||
test("does not misclassify unrelated errors that share a word with the new phrasing", () => {
|
||||
// Shares "too many" with "too many requests" but is a distinct failure (fd
|
||||
// exhaustion, not a rate limit) — the match must require the full phrase.
|
||||
assert.equal(
|
||||
classifyHeygenErrorCode({ stderr: Buffer.from("Too many open file descriptors") }),
|
||||
"other",
|
||||
);
|
||||
});
|
||||
|
||||
test("classifies a bare 429 as rate limited without matching request IDs", () => {
|
||||
assert.equal(
|
||||
classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 429 Too Many Requests") }),
|
||||
"rate_limited",
|
||||
);
|
||||
assert.equal(
|
||||
classifyHeygenErrorCode({ stderr: Buffer.from("request req-429abc failed") }),
|
||||
"other",
|
||||
);
|
||||
});
|
||||
|
||||
test("tracks not-found failures without changing actionable output", () => {
|
||||
const trackingCalls = [];
|
||||
const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search", (...args) =>
|
||||
trackingCalls.push(args),
|
||||
);
|
||||
|
||||
assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
|
||||
assert.deepEqual(trackingCalls, [
|
||||
["media_use_provider_error", { provider: "heygen", reason: "not_found" }],
|
||||
]);
|
||||
});
|
||||
|
||||
test("records missing and outdated CLI remediation once", () => {
|
||||
consumeHeygenRemediation();
|
||||
captureFailureReport({ code: "ENOENT" }, "heygen audio sounds list", () => {});
|
||||
assert.deepEqual(consumeHeygenRemediation(), {
|
||||
code: "not_found",
|
||||
message: HEYGEN_NOT_FOUND_MESSAGE,
|
||||
});
|
||||
assert.equal(consumeHeygenRemediation(), null);
|
||||
|
||||
captureFailureReport(
|
||||
{ stderr: Buffer.from("heygen v0.1.5 does not support --headers") },
|
||||
"heygen audio sounds list",
|
||||
() => {},
|
||||
);
|
||||
assert.deepEqual(consumeHeygenRemediation(), {
|
||||
code: "outdated",
|
||||
message: HEYGEN_OUTDATED_MESSAGE,
|
||||
});
|
||||
assert.equal(consumeHeygenRemediation(), null);
|
||||
});
|
||||
|
||||
test("does not record non-install remediation", () => {
|
||||
consumeHeygenRemediation();
|
||||
for (const error of [
|
||||
{ stderr: Buffer.from("HTTP 401 Unauthorized") },
|
||||
{ stderr: Buffer.from("quota exhausted") },
|
||||
{ stderr: Buffer.from("provider unavailable") },
|
||||
]) {
|
||||
captureFailureReport(error, "heygen audio sounds list", () => {});
|
||||
assert.equal(consumeHeygenRemediation(), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("tracks generic failures without including raw detail", () => {
|
||||
const trackingCalls = [];
|
||||
const stderrCalls = captureFailureReport(
|
||||
{ stderr: Buffer.from("private provider detail") },
|
||||
"heygen asset search",
|
||||
(...args) => trackingCalls.push(args),
|
||||
);
|
||||
|
||||
assert.deepEqual(stderrCalls, [
|
||||
["media-use: `heygen asset search` failed: private provider detail"],
|
||||
]);
|
||||
assert.deepEqual(trackingCalls, [
|
||||
["media_use_provider_error", { provider: "heygen", reason: "other" }],
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps failure output observable when telemetry is opted out", () => {
|
||||
const previousOptOut = process.env.HYPERFRAMES_NO_TELEMETRY;
|
||||
process.env.HYPERFRAMES_NO_TELEMETRY = "1";
|
||||
try {
|
||||
const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search");
|
||||
|
||||
assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
|
||||
} finally {
|
||||
if (previousOptOut === undefined) {
|
||||
delete process.env.HYPERFRAMES_NO_TELEMETRY;
|
||||
} else {
|
||||
process.env.HYPERFRAMES_NO_TELEMETRY = previousOptOut;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps failure output observable when tracking throws synchronously", () => {
|
||||
const originalError = console.error;
|
||||
const stderrCalls = [];
|
||||
let thrown;
|
||||
console.error = (...args) => stderrCalls.push(args);
|
||||
try {
|
||||
try {
|
||||
reportHeygenFailure({ code: "ENOENT" }, "heygen asset search", () => {
|
||||
throw new Error("tracking failed");
|
||||
});
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
|
||||
assert.equal(thrown, undefined);
|
||||
});
|
||||
|
||||
test("does not leave rejected tracking promises unhandled", () => {
|
||||
const moduleUrl = new URL("./heygen-cli.mjs", import.meta.url).href;
|
||||
const script = `
|
||||
import { reportHeygenFailure } from ${JSON.stringify(moduleUrl)};
|
||||
reportHeygenFailure(
|
||||
{ stderr: "provider unavailable" },
|
||||
"heygen asset search",
|
||||
() => Promise.reject(new Error("tracking failed")),
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
`;
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
["--unhandled-rejections=strict", "--input-type=module", "--eval", script],
|
||||
{ encoding: "utf8", timeout: 5000 },
|
||||
);
|
||||
|
||||
assert.equal(child.error, undefined);
|
||||
assert.equal(child.signal, null);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.equal(child.stderr, "media-use: `heygen asset search` failed: provider unavailable\n");
|
||||
});
|
||||
|
||||
test("flushHeygenFailureTracking waits for a pending report before resolving", async () => {
|
||||
const events = [];
|
||||
let releaseTrack;
|
||||
const gate = new Promise((resolve) => {
|
||||
releaseTrack = resolve;
|
||||
});
|
||||
|
||||
// Mirrors the real call sites (voice-provider.mjs, heygen-search.mjs):
|
||||
// fire-and-forget, the return value is never awaited by the caller.
|
||||
reportHeygenFailure({ code: "ENOENT" }, "heygen voice speech", () =>
|
||||
gate.then(() => {
|
||||
events.push("track-settled");
|
||||
}),
|
||||
);
|
||||
|
||||
const flushed = flushHeygenFailureTracking().then(() => {
|
||||
events.push("flush-resolved");
|
||||
});
|
||||
|
||||
// Let several pending microtasks drain before releasing the gate, so this
|
||||
// proves flush is genuinely still waiting on the tracked promise -- not
|
||||
// merely that it hasn't had a tick yet.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(events, [], "flush must not resolve while the tracked promise is still pending");
|
||||
|
||||
releaseTrack();
|
||||
await flushed;
|
||||
|
||||
assert.deepEqual(
|
||||
events,
|
||||
["track-settled", "flush-resolved"],
|
||||
"flush must resolve only after the pending track settles, in that order",
|
||||
);
|
||||
});
|
||||
|
||||
test("flushHeygenFailureTracking resolves immediately when nothing is pending", async () => {
|
||||
await flushHeygenFailureTracking();
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { reportHeygenFailure } from "./heygen-cli.mjs";
|
||||
|
||||
export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = {}) {
|
||||
// execFileSync with an argv array (no shell), so query/type/etc. are passed as
|
||||
// literal arguments — no quoting tricks, no command injection. subcommand is a
|
||||
// hardcoded multi-word string (e.g. "audio sounds list"), split into tokens.
|
||||
// Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.3.0).
|
||||
const args = [
|
||||
"--headers",
|
||||
"X-HeyGen-Client-Source: media-use",
|
||||
...subcommand.split(" "),
|
||||
"--query",
|
||||
query,
|
||||
];
|
||||
if (type) args.push("--type", type);
|
||||
args.push("--limit", String(limit));
|
||||
// Server-side score floor. Honored by `audio sounds list`; the `asset search`
|
||||
// backend rejects it, so only audio providers pass minScore (see image-provider).
|
||||
if (minScore != null) args.push("--min-score", String(minScore));
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = execFileSync("heygen", args, {
|
||||
encoding: "utf8",
|
||||
timeout: 15000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
// Don't swallow a broken command / auth failure as "no results" — that turns
|
||||
// a typo or expired key into a silent dead end. Surface it, then give up.
|
||||
reportHeygenFailure(err, `heygen ${subcommand}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(out);
|
||||
} catch {
|
||||
console.error(`media-use: \`heygen ${subcommand}\` returned non-JSON output`);
|
||||
return null;
|
||||
}
|
||||
if (parsed?.error) {
|
||||
const e = parsed.error;
|
||||
console.error(`media-use: \`heygen ${subcommand}\` error: ${e.message ?? JSON.stringify(e)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = parsed?.data;
|
||||
return Array.isArray(data) && data.length > 0 ? data : null;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { heygenSearch } from "./heygen-search.mjs";
|
||||
|
||||
export const imageProvider = {
|
||||
async search(intent) {
|
||||
const results = heygenSearch("asset search", intent, { type: "image" });
|
||||
if (!results) return null;
|
||||
const best = results[0];
|
||||
return {
|
||||
url: best.url,
|
||||
source: "search",
|
||||
// ext derived from the asset URL by resolve.mjs (.jpg/.png/.webp)
|
||||
metadata: {
|
||||
description: intent,
|
||||
width: best.width || null,
|
||||
height: best.height || null,
|
||||
transparent: best.is_transparent || false,
|
||||
provider: "heygen.asset.search",
|
||||
provenance: { asset_id: best.id, score: best.score },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const iconProvider = {
|
||||
async search(intent) {
|
||||
// No minScore: the `asset search` backend rejects --min-score and returns no score field.
|
||||
const results = heygenSearch("asset search", intent, { type: "icon" });
|
||||
if (!results) return null;
|
||||
const best = results[0];
|
||||
return {
|
||||
url: best.url,
|
||||
source: "search",
|
||||
// ext derived from the asset URL by resolve.mjs — catalog icons are .png, not .svg
|
||||
metadata: {
|
||||
description: intent,
|
||||
width: best.width || null,
|
||||
height: best.height || null,
|
||||
transparent: best.is_transparent ?? true,
|
||||
provider: "heygen.asset.search",
|
||||
provenance: { asset_id: best.id, score: best.score, type: "icon" },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// Declarative table of USER-INSTALLED local models, for the spec-gated fallback.
|
||||
//
|
||||
// These models run on the user's own machine for their own use; media-use
|
||||
// recommends, spec-checks, and assists install; it does not bundle, redistribute,
|
||||
// or sell them. Because nothing is redistributed, selection is purely by
|
||||
// quality / size / spec-fit / word-timestamp support (there is deliberately NO
|
||||
// license field gating availability).
|
||||
//
|
||||
// Tiers (`small`|`medium`|`large`|`xlarge`) are human labels; `needs.ramMB` is
|
||||
// what selection actually gates on. selectModel() returns the best model that
|
||||
// fits the machine's AVAILABLE RAM, best-first: by explicit `rank` when set
|
||||
// (quality that is NOT size, e.g. ASR), else by RAM footprint (the quality
|
||||
// proxy for generation). No fit -> recommend the CLI/cloud path.
|
||||
//
|
||||
// Picks reflect the 2026 research pass, verified live where noted.
|
||||
|
||||
export const CAPABILITIES = ["tts", "asr", "upscale", "videogen", "imagegen"];
|
||||
|
||||
const MODELS = {
|
||||
tts: [
|
||||
{
|
||||
id: "kokoro",
|
||||
tier: "medium",
|
||||
sizeMB: 330,
|
||||
needs: { ramMB: 2048, gpu: false },
|
||||
wordTimestamps: "native",
|
||||
install: "pip install kokoro",
|
||||
invoke: "python -m kokoro --text {text} --voice {voice} --out {out}",
|
||||
notes: "CPU, faster-than-realtime, native per-word timestamps. Default floor.",
|
||||
},
|
||||
{
|
||||
id: "fish-speech",
|
||||
tier: "large",
|
||||
sizeMB: 1100,
|
||||
needs: { ramMB: 16000, gpu: true, vramMB: 12000 },
|
||||
wordTimestamps: "whisperx", // needs forced alignment (run ASR over output)
|
||||
install: "pip install fish-speech",
|
||||
invoke: "fish-speech synth --text {text} --ref {ref} --out {out}",
|
||||
notes: "Expressive zero-shot voice cloning; meeting pick. WhisperX for word timing.",
|
||||
},
|
||||
],
|
||||
asr: [
|
||||
// Parakeet is BETTER than Whisper yet SMALLER (0.6B vs 1.5B), so quality is
|
||||
// not size here: `rank` pins it ahead of whisper regardless of footprint.
|
||||
// Open ASR Leaderboard avg WER: Parakeet ~6.05% vs whisper-large-v3 7.44%
|
||||
// (~19% better); on NOISY test-other 4.73% vs 5.96%, and whisper-v3
|
||||
// hallucinated to 308% WER on meetings where Parakeet held. 5-10x faster.
|
||||
//
|
||||
// Cohere Transcribe 2B tops the leaderboard (5.42%) and is nominally the most
|
||||
// accurate, but its mlx-audio community MLX quants (4bit AND 8bit, with and
|
||||
// without --language en) produced multilingual token-soup garbage AND ran
|
||||
// 40-70x slower than Parakeet on a 24GB Mac (live-tested 2026-07). Excluded
|
||||
// until the mlx-audio Cohere decoder stabilizes; Parakeet is the default.
|
||||
{
|
||||
id: "parakeet-mlx",
|
||||
tier: "small",
|
||||
rank: 0,
|
||||
sizeMB: 2400,
|
||||
needs: { ramMB: 4000, gpu: true },
|
||||
wordTimestamps: "tokens", // sub-word tokens; merged to words by parakeet-words.mjs
|
||||
repo: "mlx-community/parakeet-tdt-0.6b-v3",
|
||||
install:
|
||||
"uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx",
|
||||
invoke:
|
||||
"parakeet-mlx {audio} --model mlx-community/parakeet-tdt-0.6b-v3 --output-format json --output-dir {outdir}",
|
||||
notes:
|
||||
"NVIDIA Parakeet-TDT 0.6B via parakeet-mlx. VERIFIED on 24GB: accurate transcript, ~3s (cached model) for 8s audio, word timestamps drive transcript-cut. English + 25 European languages. Beats whisper.cpp on accuracy (6.05% vs 7.44% WER) AND speed (5-10x).",
|
||||
},
|
||||
{
|
||||
id: "whisperx",
|
||||
tier: "medium",
|
||||
rank: 1,
|
||||
sizeMB: 1500,
|
||||
needs: { ramMB: 4096, gpu: false },
|
||||
wordTimestamps: "native", // faster-whisper + wav2vec2 forced alignment
|
||||
install: "pip install whisperx",
|
||||
invoke: "whisperx {audio} --output_format json --out {out}",
|
||||
notes:
|
||||
"CPU-only fallback (no GPU): faster-whisper + wav2vec2 forced alignment, native word timestamps. The packaged `hyperframes transcribe` (whisper.cpp) is the zero-setup baseline below this.",
|
||||
},
|
||||
],
|
||||
upscale: [
|
||||
{
|
||||
id: "real-esrgan",
|
||||
tier: "medium",
|
||||
sizeMB: 70,
|
||||
needs: { ramMB: 2048, gpu: false },
|
||||
wordTimestamps: false,
|
||||
install: "brew install real-esrgan-ncnn-vulkan # or download the ncnn binary",
|
||||
invoke: "realesrgan-ncnn-vulkan -i {in} -o {out} -s 4",
|
||||
notes: "ncnn-vulkan binary, CPU-capable. GFPGAN for faces.",
|
||||
},
|
||||
{
|
||||
id: "seedvr2",
|
||||
tier: "large",
|
||||
sizeMB: 6000,
|
||||
needs: { ramMB: 24000, gpu: true, vramMB: 16000 },
|
||||
wordTimestamps: false,
|
||||
install: "pip install seedvr2",
|
||||
invoke: "seedvr2 upscale --in {in} --out {out}",
|
||||
notes: "Diffusion upscaler, GPU-only. Video2X for video.",
|
||||
},
|
||||
],
|
||||
videogen: [
|
||||
// 2026-07 X research pass + live verification on a 24GB M-series Mac.
|
||||
// The Mac-local video story is LTX 2.3 on MLX via dgrauet/ltx-2-mlx (the
|
||||
// pipeline these weights were converted for; also powers Phosphene).
|
||||
// Wan 2.x MLX exists only as A14B conversions (too large for consumer
|
||||
// unified memory); revisit when a 5B Wan MLX conversion lands.
|
||||
// IMPORTANT: download the weights with a targeted include list first;
|
||||
// pointing tools at the repo blind snapshot-downloads all 60 GB:
|
||||
// hf download dgrauet/ltx-2.3-mlx-q4 --include \
|
||||
// transformer-distilled-1.1.safetensors connector.safetensors \
|
||||
// "vae_*.safetensors" audio_vae.safetensors vocoder.safetensors "*.json"
|
||||
{
|
||||
id: "ltx-2.3-mlx-q4",
|
||||
tier: "medium",
|
||||
sizeMB: 20000, // distilled subset; gemma-3-12b-4bit text encoder adds ~7GB
|
||||
needs: { ramMB: 16384, gpu: true },
|
||||
wordTimestamps: false,
|
||||
install:
|
||||
"git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras",
|
||||
invoke:
|
||||
"ltx-2-mlx generate --prompt {prompt} --distilled --low-ram --model dgrauet/ltx-2.3-mlx-q4 --width {w} --height {h} --frames {frames} --frame-rate 24 --output {out}",
|
||||
notes:
|
||||
"LTX 2.3 int4 on MLX. Verified on 24GB unified: 512x320 x 33 frames in ~19 min cold (incl. text-encoder download), t2v with audio. Dims must be multiples of 64. i2v, retake/extend, keyframe interpolation supported.",
|
||||
},
|
||||
{
|
||||
id: "ltx-2.3-mlx-bf16",
|
||||
tier: "large",
|
||||
sizeMB: 45000,
|
||||
needs: { ramMB: 32768, gpu: true },
|
||||
wordTimestamps: false,
|
||||
install:
|
||||
"git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras",
|
||||
invoke:
|
||||
"ltx-2-mlx generate --prompt {prompt} --two-stage --model dgrauet/ltx-2.3-mlx-bf16 --width {w} --height {h} --frames {frames} --frame-rate 24 --output {out}",
|
||||
notes:
|
||||
"Full-precision two-stage pipeline (upstream production default). 32GB with --low-ram block streaming; 64-128GB Macs for long/HD runs (the 25s multi-scene spots seen in the wild).",
|
||||
},
|
||||
],
|
||||
imagegen: [
|
||||
// 2026-07 X research + live verification on a 24GB M-series Mac. mflux
|
||||
// (FLUX-on-MLX) is the Mac-native runner; FLUX is the quality leader. Two
|
||||
// hard-won findings baked into `needs.ramMB`:
|
||||
// 1. The OFFICIAL FLUX repos are HF-gated (license wall). Point --path at a
|
||||
// non-gated community 4-bit re-upload (self-contained, incl. VAE).
|
||||
// 2. Without --low-ram, FLUX's T5-XXL text encoder + transformer blow past
|
||||
// 24GB into swap: a 768x512 run took 90 MINUTES. With --low-ram (streams
|
||||
// components from disk) the SAME machine did 512x512 in ~20s at 7.6GB
|
||||
// free. So the medium tier's needs.ramMB is the streamed floor, not the
|
||||
// resident footprint; the large tiers are the no-streaming thresholds.
|
||||
// The runner resolves `repo` to a local snapshot (hf download) before --path;
|
||||
// a bare repo id in --path breaks mlx unflatten.
|
||||
{
|
||||
id: "flux-schnell-mflux-q4",
|
||||
tier: "medium",
|
||||
sizeMB: 8700,
|
||||
needs: { ramMB: 8000, gpu: true },
|
||||
repo: "dhairyashil/FLUX.1-schnell-mflux-4bit",
|
||||
wordTimestamps: false,
|
||||
install: "uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6",
|
||||
invoke:
|
||||
"mflux-generate --model schnell --path {model_path} --low-ram --steps 4 --prompt {prompt} --width {w} --height {h} --seed {seed} --output {out}",
|
||||
notes:
|
||||
"FLUX.1 schnell int4. VERIFIED on 24GB (7.6GB free): --low-ram 512x512 in ~20s, photoreal. --low-ram is MANDATORY at this tier (streams to avoid swap). Few-step, fast.",
|
||||
},
|
||||
{
|
||||
id: "flux2-klein-mflux-q4",
|
||||
tier: "large",
|
||||
sizeMB: 12000,
|
||||
needs: { ramMB: 32000, gpu: true },
|
||||
repo: "Runpod/FLUX.2-klein-4B-mflux-4bit",
|
||||
wordTimestamps: false,
|
||||
install: "uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux",
|
||||
invoke:
|
||||
"mflux-generate --base-model flux2-klein-4b --path {model_path} --steps 8 --prompt {prompt} --width {w} --height {h} --seed {seed} --output {out}",
|
||||
notes:
|
||||
"FLUX.2 Klein 4B int4 (most-downloaded mflux community repo). Newer, higher quality than schnell; full-resident (no streaming) so needs 32GB+ to stay fast. Needs mflux >= 0.18 for the flux2-klein base model.",
|
||||
},
|
||||
{
|
||||
id: "qwen-image-mflux",
|
||||
tier: "xlarge",
|
||||
sizeMB: 40000,
|
||||
needs: { ramMB: 64000, gpu: true },
|
||||
repo: "Qwen/Qwen-Image",
|
||||
wordTimestamps: false,
|
||||
install: "uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux",
|
||||
invoke:
|
||||
"mflux-generate --base-model qwen --steps 20 --prompt {prompt} --width {w} --height {h} --seed {seed} --output {out}",
|
||||
notes:
|
||||
"Qwen-Image, top-tier quality. Heavy: 'several minutes' even on 128GB M4 Max, 'almost fried' a 32GB M4 Pro. 64GB+ only. Below that, the cloud upsell (codex) is faster and better.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function tableFor(capability) {
|
||||
const t = MODELS[capability];
|
||||
if (!t) throw new Error(`unknown local-model capability: ${capability}`);
|
||||
return t;
|
||||
}
|
||||
|
||||
/** All local models for a capability. */
|
||||
export function listModels(capability) {
|
||||
return tableFor(capability).slice();
|
||||
}
|
||||
|
||||
/** Does this machine meet a model's needs? Apple Silicon unified memory counts as VRAM. */
|
||||
export function meetsSpecs(model, specs) {
|
||||
const n = model.needs || {};
|
||||
// Gate on AVAILABLE RAM when the probe reported it (the real budget with the
|
||||
// OS + open apps resident); fall back to total RAM otherwise. Older specs
|
||||
// objects (and unit fixtures) that only set ramMB keep working unchanged.
|
||||
const budget = specs.availableRamMB ?? specs.ramMB;
|
||||
if (n.ramMB && budget < n.ramMB) return false;
|
||||
if (n.gpu && !specs.gpu?.present) return false;
|
||||
if (n.vramMB) {
|
||||
const vram = specs.gpu?.vramMB ?? 0;
|
||||
if (vram < n.vramMB) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// "Best model the machine can run" == best-first among those that fit. Ordering:
|
||||
// 1. explicit `rank` (lower = better) when a model declares it. Needed where
|
||||
// quality is NOT size: Parakeet-0.6B beats Whisper-large-1.5B at ASR, so
|
||||
// footprint would pick the wrong one.
|
||||
// 2. otherwise RAM footprint descending, the quality proxy for generation
|
||||
// (a 40GB image model out-renders a 12GB one).
|
||||
function rankedByPreference(table) {
|
||||
return [...table].sort((a, b) => {
|
||||
const ra = a.rank ?? Infinity;
|
||||
const rb = b.rank ?? Infinity;
|
||||
if (ra !== rb) return ra - rb;
|
||||
return (b.needs?.ramMB ?? 0) - (a.needs?.ramMB ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best local model the machine can run for a capability: the
|
||||
* highest-footprint model that fits the available-RAM budget (and GPU/VRAM).
|
||||
* `preferTier` pins the search to one tier (e.g. force a smaller/faster model).
|
||||
* Returns `{ model, tier }`, or `{ recommend: "cli", reason }` when nothing fits.
|
||||
*/
|
||||
export function selectModel(capability, specs, { preferTier } = {}) {
|
||||
const table = tableFor(capability);
|
||||
const pool = preferTier ? table.filter((m) => m.tier === preferTier) : table;
|
||||
for (const model of rankedByPreference(pool)) {
|
||||
if (meetsSpecs(model, specs)) return { model, tier: model.tier };
|
||||
}
|
||||
const smallest = table.reduce((a, b) => (a.sizeMB <= b.sizeMB ? a : b));
|
||||
return {
|
||||
recommend: "cli",
|
||||
reason: `machine does not meet specs for any local ${capability} model (smallest needs ~${smallest.needs.ramMB}MB RAM${smallest.needs.gpu ? " + GPU" : ""}); use the CLI path instead`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-facing ladder: every model for a capability, best-first, each flagged
|
||||
* with whether it fits this machine and why. Lets the agent see the RAM-graded
|
||||
* options and choose (e.g. trade the auto-picked best for a smaller/faster one,
|
||||
* or step up to a cloud upsell) rather than only getting one auto-selection.
|
||||
*/
|
||||
export function describeModelLadder(capability, specs) {
|
||||
const budget = specs.availableRamMB ?? specs.ramMB;
|
||||
return rankedByPreference(tableFor(capability)).map((model) => {
|
||||
const fits = meetsSpecs(model, specs);
|
||||
return {
|
||||
id: model.id,
|
||||
tier: model.tier,
|
||||
needsRamMB: model.needs?.ramMB ?? 0,
|
||||
fits,
|
||||
reason: fits
|
||||
? `fits (needs ~${model.needs?.ramMB}MB, ${budget}MB available)`
|
||||
: `too big (needs ~${model.needs?.ramMB}MB${model.needs?.gpu ? " + GPU" : ""}, ${budget}MB available)`,
|
||||
notes: model.notes,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
listModels,
|
||||
meetsSpecs,
|
||||
selectModel,
|
||||
describeModelLadder,
|
||||
CAPABILITIES,
|
||||
} from "./local-models.mjs";
|
||||
|
||||
const TIERS = ["small", "medium", "large", "xlarge"];
|
||||
|
||||
const strongGpu = {
|
||||
ramMB: 64000,
|
||||
gpu: { present: true, kind: "nvidia", vramMB: 24000 },
|
||||
appleSilicon: false,
|
||||
};
|
||||
const cpuOnly = { ramMB: 16000, gpu: { present: false, vramMB: 0 }, appleSilicon: false };
|
||||
const tiny = { ramMB: 1024, gpu: { present: false, vramMB: 0 }, appleSilicon: false };
|
||||
|
||||
test("every capability table is non-empty and well-formed", () => {
|
||||
for (const cap of CAPABILITIES) {
|
||||
const models = listModels(cap);
|
||||
assert.ok(models.length > 0, `no models for ${cap}`);
|
||||
for (const m of models) {
|
||||
assert.ok(m.id && m.tier && m.needs, `${cap}/${m.id} missing fields`);
|
||||
assert.ok(TIERS.includes(m.tier), `${cap}/${m.id} bad tier: ${m.tier}`);
|
||||
assert.equal(typeof m.install, "string", `${cap}/${m.id} needs an install command`);
|
||||
assert.equal(typeof m.invoke, "string", `${cap}/${m.id} needs an invoke command`);
|
||||
// user-installed, local-use-only: there is NO license gate on selection
|
||||
assert.equal("license" in m, false, `${cap}/${m.id} must not carry a license gate`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("meetsSpecs enforces RAM, GPU presence, and VRAM", () => {
|
||||
const gpuModel = { needs: { ramMB: 8000, gpu: true, vramMB: 12000 } };
|
||||
assert.equal(meetsSpecs(gpuModel, strongGpu), true);
|
||||
assert.equal(meetsSpecs(gpuModel, cpuOnly), false, "no GPU -> fails a GPU model");
|
||||
const cpuModel = { needs: { ramMB: 2000, gpu: false } };
|
||||
assert.equal(meetsSpecs(cpuModel, cpuOnly), true);
|
||||
assert.equal(meetsSpecs(cpuModel, tiny), false, "too little RAM");
|
||||
});
|
||||
|
||||
test("Apple Silicon unified memory counts as VRAM", () => {
|
||||
const apple = {
|
||||
ramMB: 24000,
|
||||
appleSilicon: true,
|
||||
gpu: { present: true, kind: "apple", vramMB: 24000 },
|
||||
};
|
||||
const gpuModel = { needs: { ramMB: 8000, gpu: true, vramMB: 16000 } };
|
||||
assert.equal(meetsSpecs(gpuModel, apple), true);
|
||||
});
|
||||
|
||||
test("selectModel picks the large tier on a strong machine", () => {
|
||||
const r = selectModel("tts", strongGpu);
|
||||
assert.equal(r.tier, "large");
|
||||
assert.ok(r.model.id);
|
||||
});
|
||||
|
||||
test("selectModel falls back to medium on a CPU-only machine", () => {
|
||||
const r = selectModel("tts", cpuOnly);
|
||||
assert.equal(r.tier, "medium");
|
||||
assert.equal(r.model.id, "kokoro", "Kokoro is the CPU/medium default (native word timestamps)");
|
||||
});
|
||||
|
||||
test("selectModel recommends the CLI path when no tier fits", () => {
|
||||
const r = selectModel("tts", tiny);
|
||||
assert.equal(r.recommend, "cli");
|
||||
assert.ok(r.reason && /spec/i.test(r.reason));
|
||||
assert.equal(r.model, undefined);
|
||||
});
|
||||
|
||||
test("preferTier:'medium' avoids the large model even on a strong machine", () => {
|
||||
const r = selectModel("tts", strongGpu, { preferTier: "medium" });
|
||||
assert.equal(r.tier, "medium");
|
||||
});
|
||||
|
||||
test("selectModel gates on AVAILABLE RAM, not total, when both are present", () => {
|
||||
// 64GB total but only 6GB free right now -> the large tier must not be chosen.
|
||||
const busy = {
|
||||
ramMB: 64000,
|
||||
availableRamMB: 6000,
|
||||
appleSilicon: true,
|
||||
gpu: { present: true, kind: "apple", vramMB: 64000 },
|
||||
};
|
||||
const r = selectModel("tts", busy);
|
||||
assert.equal(r.tier, "medium", "available RAM (6GB) rules out the 16GB large tier");
|
||||
});
|
||||
|
||||
test("imagegen is a RAM-graduated ladder; agent picks the best that fits", () => {
|
||||
const ladder = describeModelLadder("imagegen", {
|
||||
ramMB: 24000,
|
||||
availableRamMB: 12000,
|
||||
appleSilicon: true,
|
||||
gpu: { present: true, kind: "apple", vramMB: 24000 },
|
||||
});
|
||||
// best-first order, each flagged with fit
|
||||
assert.ok(ladder.length >= 3, "imagegen offers multiple RAM tiers");
|
||||
assert.ok(
|
||||
ladder[0].needsRamMB >= ladder[ladder.length - 1].needsRamMB,
|
||||
"ladder is ordered best (biggest) first",
|
||||
);
|
||||
// on 24GB / 12GB-free the schnell --low-ram tier fits, the 32GB+ tiers do not
|
||||
const fitting = ladder.filter((m) => m.fits);
|
||||
assert.ok(fitting.length >= 1, "at least the low-ram tier fits a 24GB Mac");
|
||||
assert.ok(
|
||||
fitting.every((m) => m.needsRamMB <= 12000),
|
||||
"only sub-budget models flagged as fitting",
|
||||
);
|
||||
const pick = selectModel("imagegen", {
|
||||
ramMB: 24000,
|
||||
availableRamMB: 12000,
|
||||
gpu: { present: true, vramMB: 24000 },
|
||||
});
|
||||
assert.equal(
|
||||
pick.model.id,
|
||||
"flux-schnell-mflux-q4",
|
||||
"best fit on 24GB is the low-ram schnell tier",
|
||||
);
|
||||
});
|
||||
|
||||
test("imagegen on a 64GB Mac steps up to the higher-quality tier", () => {
|
||||
const pick = selectModel("imagegen", {
|
||||
ramMB: 96000,
|
||||
availableRamMB: 80000,
|
||||
gpu: { present: true, vramMB: 96000 },
|
||||
});
|
||||
assert.equal(pick.tier, "xlarge", "80GB free unlocks the top-quality model");
|
||||
});
|
||||
|
||||
test("ASR prefers Parakeet by rank even though it is smaller than whisper", () => {
|
||||
// quality != size for ASR: Parakeet 0.6B beats whisper-1.5B, so `rank` wins
|
||||
// over footprint. On a capable machine both fit; Parakeet must be chosen.
|
||||
const capable = {
|
||||
ramMB: 24000,
|
||||
availableRamMB: 12000,
|
||||
appleSilicon: true,
|
||||
gpu: { present: true, kind: "apple", vramMB: 24000 },
|
||||
};
|
||||
const pick = selectModel("asr", capable);
|
||||
assert.equal(pick.model.id, "parakeet-mlx", "Parakeet is the rank-0 preferred ASR");
|
||||
// whisperx (rank 1, CPU-only) is the fallback when no GPU
|
||||
const cpu = { ramMB: 16000, availableRamMB: 12000, gpu: { present: false, vramMB: 0 } };
|
||||
assert.equal(selectModel("asr", cpu).model.id, "whisperx", "CPU-only falls back to whisperx");
|
||||
});
|
||||
|
||||
test("ASR offers word-timestamp-capable models (better than plain whisper)", () => {
|
||||
const asr = listModels("asr");
|
||||
assert.ok(
|
||||
asr.every((m) => m.wordTimestamps),
|
||||
"every ASR model must support word timestamps",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { selectModel } from "./local-models.mjs";
|
||||
import { probeSpecs } from "./specs.mjs";
|
||||
|
||||
// Run a USER-INSTALLED local model for a capability (tts/asr/upscale).
|
||||
// Picks the best tier the machine supports (selectModel), checks the tool is on
|
||||
// PATH, fills the model's invoke template, and runs it. Returns:
|
||||
// { model, tier, out } on success
|
||||
// { recommend:"install", model, command, reason } when the tool isn't installed
|
||||
// { recommend:"cli", reason } when no tier fits the machine
|
||||
// `exec` / `which` are injectable for tests.
|
||||
//
|
||||
// ponytail: "installed" = the invoke's first token is on PATH (e.g. `whisperx`,
|
||||
// `realesrgan-ncnn-vulkan`). For `python -m kokoro` this only proves python
|
||||
// exists; good enough to gate — the recommend.command names the real package.
|
||||
// Upgrade to a per-tool probe if a "python present but package missing" run ever
|
||||
// produces a confusing error instead of a clean recommend.
|
||||
|
||||
function defaultWhich(bin) {
|
||||
execFileSync("command", ["-v", bin], { stdio: "ignore", shell: true });
|
||||
}
|
||||
|
||||
function defaultExec(cmd) {
|
||||
execFileSync(cmd, { stdio: ["ignore", "pipe", "pipe"], shell: true, timeout: 600000 });
|
||||
}
|
||||
|
||||
const fill = (tpl, vars) =>
|
||||
tpl.replace(/\{(\w+)\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ""));
|
||||
|
||||
export function runLocalModel(capability, opts = {}) {
|
||||
const {
|
||||
specs = probeSpecs(),
|
||||
exec = defaultExec,
|
||||
which = defaultWhich,
|
||||
vars = {},
|
||||
preferTier,
|
||||
} = opts;
|
||||
const sel = selectModel(capability, specs, { preferTier });
|
||||
if (sel.recommend) return sel; // no tier fits -> recommend the CLI path
|
||||
|
||||
const { model } = sel;
|
||||
const bin = model.invoke.split(/\s+/)[0];
|
||||
try {
|
||||
which(bin);
|
||||
} catch {
|
||||
return {
|
||||
recommend: "install",
|
||||
model: model.id,
|
||||
command: model.install,
|
||||
reason: `${model.id} not installed`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
exec(fill(model.invoke, vars));
|
||||
} catch (e) {
|
||||
return {
|
||||
recommend: "install",
|
||||
model: model.id,
|
||||
command: model.install,
|
||||
reason: e.message || String(e),
|
||||
};
|
||||
}
|
||||
return { model: model.id, tier: sel.tier, out: vars.out };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { runLocalModel } from "./local-run.mjs";
|
||||
|
||||
const strongCpu = { ramMB: 16000, gpu: { present: false, vramMB: 0 }, appleSilicon: false };
|
||||
const tiny = { ramMB: 512, gpu: { present: false, vramMB: 0 }, appleSilicon: false };
|
||||
const ok = () => {}; // which/exec that succeed
|
||||
|
||||
test("recommends the CLI path when no local tier fits the machine", () => {
|
||||
const r = runLocalModel("tts", { specs: tiny, which: ok, exec: ok });
|
||||
assert.equal(r.recommend, "cli");
|
||||
});
|
||||
|
||||
test("recommends install when the tool is not on PATH", () => {
|
||||
const r = runLocalModel("tts", {
|
||||
specs: strongCpu,
|
||||
which: () => {
|
||||
throw new Error("not found");
|
||||
},
|
||||
exec: ok,
|
||||
vars: { text: "hi", out: "/tmp/v.wav" },
|
||||
});
|
||||
assert.equal(r.recommend, "install");
|
||||
assert.equal(r.model, "kokoro");
|
||||
assert.match(r.command, /pip install kokoro/);
|
||||
});
|
||||
|
||||
test("runs the model and returns the output path when installed", () => {
|
||||
let ran = "";
|
||||
const r = runLocalModel("tts", {
|
||||
specs: strongCpu,
|
||||
which: ok,
|
||||
exec: (cmd) => {
|
||||
ran = cmd;
|
||||
},
|
||||
vars: { text: "hello world", voice: "af_heart", out: "/tmp/v.wav" },
|
||||
});
|
||||
assert.equal(r.model, "kokoro");
|
||||
assert.equal(r.out, "/tmp/v.wav");
|
||||
assert.match(ran, /hello world/, "invoke template filled with vars");
|
||||
assert.match(ran, /\/tmp\/v\.wav/);
|
||||
});
|
||||
|
||||
test("a failing run degrades to an install recommendation, never throws", () => {
|
||||
const r = runLocalModel("upscale", {
|
||||
specs: strongCpu,
|
||||
which: ok,
|
||||
exec: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
vars: { in: "a.png", out: "b.png" },
|
||||
});
|
||||
assert.equal(r.recommend, "install");
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
// Official brand marks — the `logo` type's provider tiers, tried in registry
|
||||
// order. Every tier was verified against a 54-brand stress test (2026-07,
|
||||
// 100% cascade hit). Hit counts below are a snapshot of that run — they
|
||||
// drift as the alias/org maps grow; re-run the stress test to refresh them.
|
||||
//
|
||||
// 1. svgl — official full-color vector SVGs (+ wordmark variants);
|
||||
// 40/54 first-hits. Search is substring-based, so
|
||||
// entities go through alias normalization first
|
||||
// ("nextjs" never matches "Next.js" raw).
|
||||
// 2. simple-icons — monochrome official glyphs; caught the long tail the
|
||||
// others miss (nike, visa, toyota, wechat, bytedance).
|
||||
// Pinned CDN build for determinism.
|
||||
// 3. github avatar — the org's official logo for brands with a GitHub
|
||||
// presence. Known orgs only: guessing a login risks a
|
||||
// same-named personal account.
|
||||
// 4. domain favicon — small-raster last resort (DuckDuckGo ip3). Responses
|
||||
// under ~500B are DDG's globe placeholder, not a hit.
|
||||
//
|
||||
// HeyGen asset search is deliberately absent: for brand queries it returns
|
||||
// generic look-alike icons (0/3 in testing) — worse than a miss. A total miss
|
||||
// falls through to resolve's normal failure path (`no provider could resolve
|
||||
// logo`, exit 1).
|
||||
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SVGL_API = "https://api.svgl.app";
|
||||
const SIMPLE_ICONS_CDN = "https://cdn.jsdelivr.net/npm/simple-icons@16.25.0/icons";
|
||||
const FAVICON_MIN_BYTES = 500;
|
||||
|
||||
// svgl search queries per entity, tried in order after the raw entity.
|
||||
const SVGL_ALIASES = {
|
||||
nextjs: ["next.js", "next"],
|
||||
aws: ["amazon web services"],
|
||||
huggingface: ["hugging face"],
|
||||
cocacola: ["coca-cola"],
|
||||
mcdonalds: ["mcdonald's"],
|
||||
};
|
||||
|
||||
// simple-icons slugs that differ from the normalized entity.
|
||||
const SIMPLE_ICON_SLUGS = {
|
||||
nextjs: "nextdotjs",
|
||||
aws: "amazonwebservices",
|
||||
};
|
||||
|
||||
// Known GitHub orgs. Only mapped entities resolve at this tier — a brand name
|
||||
// is NOT a GitHub login, and guessing hits same-named personal accounts.
|
||||
const GITHUB_ORGS = {
|
||||
slack: "slackhq",
|
||||
meta: "facebook",
|
||||
google: "google",
|
||||
microsoft: "microsoft",
|
||||
aws: "aws",
|
||||
vercel: "vercel",
|
||||
nextjs: "vercel",
|
||||
alibaba: "alibaba",
|
||||
heygen: "heygen-com",
|
||||
};
|
||||
|
||||
// Favicon domains that aren't `<entity>.com`.
|
||||
const FAVICON_DOMAINS = {
|
||||
cocacola: "coca-cola.com",
|
||||
aws: "aws.amazon.com",
|
||||
nextjs: "nextjs.org",
|
||||
};
|
||||
|
||||
const norm = (s) =>
|
||||
String(s)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
|
||||
/** The brand entity for a query: --entity wins; else the intent minus filler. */
|
||||
export function entityFrom(intent, entity) {
|
||||
if (entity) return entity.toLowerCase().trim();
|
||||
return String(intent)
|
||||
.toLowerCase()
|
||||
.replace(/\b(logo|logos|icon|brand|official|mark)\b/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Exact match after stripping case/spacing/punctuation — "Next.js" ≡ "nextjs". */
|
||||
export function titleMatches(title, entity) {
|
||||
return norm(title) === norm(entity);
|
||||
}
|
||||
|
||||
export function svglQueriesFor(entity) {
|
||||
return [entity, ...(SVGL_ALIASES[norm(entity)] || [])];
|
||||
}
|
||||
|
||||
export function simpleIconSlugsFor(entity) {
|
||||
const slugs = [norm(entity)];
|
||||
const alias = SIMPLE_ICON_SLUGS[norm(entity)];
|
||||
if (alias) slugs.push(alias);
|
||||
return slugs;
|
||||
}
|
||||
|
||||
export function githubOrgFor(entity) {
|
||||
return GITHUB_ORGS[norm(entity)] || null;
|
||||
}
|
||||
|
||||
export function faviconDomainFor(entity) {
|
||||
return FAVICON_DOMAINS[norm(entity)] || `${norm(entity)}.com`;
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function urlExists(url) {
|
||||
const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(10_000) });
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function svglSearch(intent, ctx = {}) {
|
||||
const entity = entityFrom(intent, ctx.entity);
|
||||
for (const q of svglQueriesFor(entity)) {
|
||||
let items;
|
||||
try {
|
||||
items = await fetchJson(`${SVGL_API}?search=${encodeURIComponent(q)}`);
|
||||
} catch {
|
||||
return null; // network down — let the next tier try its own host
|
||||
}
|
||||
if (!Array.isArray(items)) continue;
|
||||
const hit = items.find((it) => titleMatches(it.title, q) || titleMatches(it.title, entity));
|
||||
if (!hit) continue;
|
||||
const route = typeof hit.route === "string" ? hit.route : hit.route?.light;
|
||||
if (!route) continue;
|
||||
return {
|
||||
url: route,
|
||||
ext: ".svg",
|
||||
source: "search",
|
||||
metadata: {
|
||||
description: `${hit.title} logo (official mark)`,
|
||||
provider: "svgl",
|
||||
provenance: { entity, query: q, route, wordmark: Boolean(hit.wordmark) },
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function simpleIconsSearch(intent, ctx = {}) {
|
||||
const entity = entityFrom(intent, ctx.entity);
|
||||
for (const slug of simpleIconSlugsFor(entity)) {
|
||||
const url = `${SIMPLE_ICONS_CDN}/${slug}.svg`;
|
||||
let ok;
|
||||
try {
|
||||
ok = await urlExists(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!ok) continue;
|
||||
return {
|
||||
url,
|
||||
ext: ".svg",
|
||||
source: "search",
|
||||
metadata: {
|
||||
description: `${entity} logo (official monochrome glyph)`,
|
||||
provider: "simple-icons",
|
||||
provenance: { entity, slug, pinned: "simple-icons@16.25.0" },
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function githubAvatarSearch(intent, ctx = {}) {
|
||||
const entity = entityFrom(intent, ctx.entity);
|
||||
const org = githubOrgFor(entity);
|
||||
if (!org) return null;
|
||||
const url = `https://github.com/${org}.png?size=460`;
|
||||
try {
|
||||
if (!(await urlExists(url))) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
ext: ".png",
|
||||
source: "search",
|
||||
metadata: {
|
||||
description: `${entity} logo (GitHub org avatar)`,
|
||||
provider: "github.avatar",
|
||||
provenance: { entity, org },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function faviconSearch(intent, ctx = {}) {
|
||||
const entity = entityFrom(intent, ctx.entity);
|
||||
const domain = faviconDomainFor(entity);
|
||||
const url = `https://icons.duckduckgo.com/ip3/${domain}.ico`;
|
||||
let body;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) return null;
|
||||
body = Buffer.from(await res.arrayBuffer());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (body.byteLength < FAVICON_MIN_BYTES) return null; // DDG placeholder, not a logo
|
||||
// Hand the verified bytes over as a local file: the freeze step copies it
|
||||
// instead of re-downloading, so the size check is authoritative over what
|
||||
// gets frozen and the favicon tier costs one network round-trip, not two.
|
||||
const bytes = body.byteLength;
|
||||
const tmp = join(mkdtempSync(join(tmpdir(), "media-use-logo-")), `${domain}.ico`);
|
||||
writeFileSync(tmp, body);
|
||||
return {
|
||||
localPath: tmp,
|
||||
ext: ".ico",
|
||||
source: "search",
|
||||
metadata: {
|
||||
description: `${entity} favicon (small raster — chip-size use only)`,
|
||||
provider: "favicon.ddg",
|
||||
provenance: { entity, domain, bytes, low_res: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
entityFrom,
|
||||
titleMatches,
|
||||
svglQueriesFor,
|
||||
simpleIconSlugsFor,
|
||||
githubOrgFor,
|
||||
faviconDomainFor,
|
||||
svglSearch,
|
||||
simpleIconsSearch,
|
||||
githubAvatarSearch,
|
||||
faviconSearch,
|
||||
} from "./logo-provider.mjs";
|
||||
import { getProviders, runProviders } from "./registry.mjs";
|
||||
|
||||
test("entityFrom strips filler words from the intent; --entity wins", () => {
|
||||
assert.equal(entityFrom("LinkedIn logo"), "linkedin");
|
||||
assert.equal(entityFrom("official Slack brand mark"), "slack");
|
||||
assert.equal(entityFrom("anything", "Notion"), "notion");
|
||||
});
|
||||
|
||||
test("titleMatches ignores case, spacing, punctuation — and rejects lookalikes", () => {
|
||||
assert.ok(titleMatches("Next.js", "nextjs"));
|
||||
assert.ok(titleMatches("Coca-Cola", "coca cola"));
|
||||
assert.ok(!titleMatches("Slackware", "slack"));
|
||||
});
|
||||
|
||||
test("svgl queries include the alias forms the raw entity can't match", () => {
|
||||
assert.ok(svglQueriesFor("nextjs").includes("next.js"));
|
||||
assert.ok(svglQueriesFor("aws").includes("amazon web services"));
|
||||
assert.deepEqual(svglQueriesFor("figma"), ["figma"]);
|
||||
});
|
||||
|
||||
test("simple-icons slugs cover the renamed entries", () => {
|
||||
assert.ok(simpleIconSlugsFor("nextjs").includes("nextdotjs"));
|
||||
assert.ok(simpleIconSlugsFor("aws").includes("amazonwebservices"));
|
||||
assert.deepEqual(simpleIconSlugsFor("nike"), ["nike"]);
|
||||
});
|
||||
|
||||
test("github avatar tier never guesses an org", () => {
|
||||
assert.equal(githubOrgFor("slack"), "slackhq");
|
||||
assert.equal(githubOrgFor("heygen"), "heygen-com");
|
||||
assert.equal(githubOrgFor("some-random-startup"), null);
|
||||
});
|
||||
|
||||
test("favicon domain defaults to <entity>.com with explicit overrides", () => {
|
||||
assert.equal(faviconDomainFor("cocacola"), "coca-cola.com");
|
||||
assert.equal(faviconDomainFor("stripe"), "stripe.com");
|
||||
});
|
||||
|
||||
// --- async tiers, network mocked -------------------------------------------
|
||||
// The 54-brand stress test is a manual snapshot; these pin the same behavior
|
||||
// as CI gates: descriptor shape, alias retry, error→null fallthrough, the
|
||||
// placeholder filter, and the real cascade order under a mocked network.
|
||||
|
||||
const json = (data) => new Response(JSON.stringify(data), { status: 200 });
|
||||
const status = (code) => new Response(null, { status: code });
|
||||
const bin = (n) => new Response(new Uint8Array(n), { status: 200 });
|
||||
|
||||
test("svglSearch returns the descriptor shape on an exact title hit", async (t) => {
|
||||
t.mock.method(globalThis, "fetch", async () =>
|
||||
json([{ title: "Figma", route: "https://svgl.app/library/figma.svg" }]),
|
||||
);
|
||||
const res = await svglSearch("Figma logo", {});
|
||||
assert.equal(res.url, "https://svgl.app/library/figma.svg");
|
||||
assert.equal(res.ext, ".svg");
|
||||
assert.equal(res.metadata.provider, "svgl");
|
||||
});
|
||||
|
||||
test("svglSearch skips a non-array payload and retries with the alias query", async (t) => {
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, "fetch", async (url) => {
|
||||
seen.push(decodeURIComponent(String(url)));
|
||||
return seen.length === 1
|
||||
? json({ error: "unexpected shape" })
|
||||
: json([{ title: "Next.js", route: "https://svgl.app/library/nextjs.svg" }]);
|
||||
});
|
||||
const res = await svglSearch("nextjs logo", {});
|
||||
assert.equal(res.metadata.provenance.query, "next.js", "hit came from the alias query");
|
||||
assert.ok(seen.length >= 2, "raw query then alias");
|
||||
});
|
||||
|
||||
test("svglSearch returns null when the network is down — the cascade falls through", async (t) => {
|
||||
t.mock.method(globalThis, "fetch", async () => {
|
||||
throw new Error("network down");
|
||||
});
|
||||
assert.equal(await svglSearch("figma logo", {}), null);
|
||||
});
|
||||
|
||||
test("simpleIconsSearch falls to the next slug on a 404", async (t) => {
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, "fetch", async (url) => {
|
||||
seen.push(String(url));
|
||||
return String(url).includes("amazonwebservices") ? status(200) : status(404);
|
||||
});
|
||||
const res = await simpleIconsSearch("aws logo", {});
|
||||
assert.ok(res.url.endsWith("amazonwebservices.svg"));
|
||||
assert.equal(seen.length, 2, "plain slug 404s first, alias slug hits");
|
||||
});
|
||||
|
||||
test("faviconSearch rejects DDG's sub-500B placeholder with null", async (t) => {
|
||||
t.mock.method(globalThis, "fetch", async () => bin(120));
|
||||
assert.equal(await faviconSearch("someco logo", {}), null);
|
||||
});
|
||||
|
||||
test("faviconSearch hands verified bytes over as a local file — one fetch, no re-download", async (t) => {
|
||||
const fetchMock = t.mock.method(globalThis, "fetch", async () => bin(600));
|
||||
const res = await faviconSearch("someco logo", {});
|
||||
assert.ok(res.localPath, "returns a localPath, not a url");
|
||||
assert.equal(readFileSync(res.localPath).byteLength, 600, "frozen bytes are the verified bytes");
|
||||
assert.equal(fetchMock.mock.callCount(), 1, "single network round-trip");
|
||||
assert.equal(res.metadata.provenance.low_res, true);
|
||||
});
|
||||
|
||||
test("githubAvatarSearch never touches the network for an unmapped entity", async (t) => {
|
||||
const fetchMock = t.mock.method(globalThis, "fetch", async () => status(200));
|
||||
assert.equal(await githubAvatarSearch("some-random-startup logo", {}), null);
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("the real logo cascade falls through tier by tier to the first hit", async (t) => {
|
||||
t.mock.method(globalThis, "fetch", async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("api.svgl.app")) return json([]); // tier 1: no hit
|
||||
if (u.includes("jsdelivr")) return status(404); // tier 2: no such slug
|
||||
// tier 3 (github) is never called: entity is unmapped
|
||||
if (u.includes("duckduckgo")) return bin(600); // tier 4: real favicon
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
});
|
||||
const res = await runProviders(getProviders("logo"), "search", "zzzbrand logo", {
|
||||
entity: "zzzbrand",
|
||||
});
|
||||
assert.ok(res, "cascade must land on the favicon tier");
|
||||
assert.equal(res.metadata.provider, "favicon.ddg");
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { allocateId } from "./manifest.mjs";
|
||||
import { freezeUrl } from "./freeze.mjs";
|
||||
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 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";
|
||||
|
||||
// Mirrored from packages/core/src/colorGrading.ts HfColorGradingPresetId.
|
||||
// Keep this list in lockstep with the core runtime contract.
|
||||
export const CORE_PRESET_IDS = [
|
||||
"neutral",
|
||||
"natural-lift",
|
||||
"fresh-pop",
|
||||
"warm-daylight",
|
||||
"clean-studio",
|
||||
"skin-soft",
|
||||
"food-pop",
|
||||
"night-lift",
|
||||
"muted-editorial",
|
||||
"vintage-wash",
|
||||
"mono-clean",
|
||||
"mono-fade",
|
||||
"warm-clean",
|
||||
"cool-clean",
|
||||
"soft-boost",
|
||||
"bright-pop",
|
||||
"deep-contrast",
|
||||
];
|
||||
|
||||
const PRESET_SYNONYMS = {
|
||||
neutral: ["neutral", "identity", "none", "ungraded", "natural base"],
|
||||
"natural-lift": ["natural lift", "natural light", "gentle lift", "soft natural"],
|
||||
"fresh-pop": ["fresh pop", "fresh", "bright fresh", "clean colorful"],
|
||||
"warm-daylight": [
|
||||
"warm daylight",
|
||||
"warm natural light",
|
||||
"golden daylight",
|
||||
"sunlit",
|
||||
"warm sunny",
|
||||
],
|
||||
"clean-studio": ["clean studio", "studio clean", "cool studio", "product studio"],
|
||||
"skin-soft": ["skin soft", "soft skin", "portrait soft", "beauty skin"],
|
||||
"food-pop": ["food pop", "food vibrant", "appetizing", "restaurant color"],
|
||||
"night-lift": ["night lift", "night", "low light lift", "city night"],
|
||||
"muted-editorial": ["muted editorial", "editorial muted", "magazine muted"],
|
||||
"vintage-wash": ["vintage wash", "vintage", "retro wash", "aged film"],
|
||||
"mono-clean": ["mono clean", "black white clean", "monochrome clean"],
|
||||
"mono-fade": ["mono fade", "black white fade", "faded monochrome"],
|
||||
"warm-clean": ["warm clean", "clean warm", "warm product"],
|
||||
"cool-clean": ["cool clean", "clean cool", "cool crisp"],
|
||||
"soft-boost": ["soft boost", "soft bright", "gentle boost"],
|
||||
"bright-pop": ["bright pop", "bright punchy", "vivid bright"],
|
||||
"deep-contrast": ["deep contrast", "high contrast punchy", "punchy contrast", "bold contrast"],
|
||||
};
|
||||
|
||||
function presetCandidates() {
|
||||
return CORE_PRESET_IDS.map((id) => ({
|
||||
kind: "preset",
|
||||
preset: id,
|
||||
synonyms: PRESET_SYNONYMS[id] ?? [],
|
||||
text: [id, ...(PRESET_SYNONYMS[id] ?? [])].join(" "),
|
||||
}));
|
||||
}
|
||||
|
||||
export function readBundledLutIndex() {
|
||||
if (!existsSync(LUT_INDEX)) return [];
|
||||
const parsed = JSON.parse(readFileSync(LUT_INDEX, "utf8"));
|
||||
const entries = Array.isArray(parsed) ? parsed : parsed.looks;
|
||||
if (!Array.isArray(entries)) return [];
|
||||
return entries.map((entry) => {
|
||||
const params =
|
||||
entry.params && typeof entry.params === "object" && !Array.isArray(entry.params)
|
||||
? entry.params
|
||||
: null;
|
||||
const url = typeof entry.url === "string" && entry.url.trim() ? entry.url.trim() : null;
|
||||
return {
|
||||
id: String(entry.id),
|
||||
description: String(entry.description ?? entry.id),
|
||||
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
|
||||
intensity: Number.isFinite(Number(entry.intensity)) ? Number(entry.intensity) : 1,
|
||||
...(params && { params }),
|
||||
...(url && { url }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function libraryCandidates() {
|
||||
return readBundledLutIndex().map((entry) => ({
|
||||
kind: "library",
|
||||
...entry,
|
||||
text: [entry.id, entry.description, ...entry.tags].join(" "),
|
||||
}));
|
||||
}
|
||||
|
||||
export function matchColorLook(intent) {
|
||||
const normalized = String(intent ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
for (const candidate of presetCandidates()) {
|
||||
if (candidate.preset === normalized) {
|
||||
return { kind: "preset", preset: candidate.preset, score: 99 };
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [...presetCandidates(), ...libraryCandidates()]
|
||||
.map((candidate, index) => ({
|
||||
...candidate,
|
||||
index,
|
||||
score: tokenOverlap(intent, candidate.text),
|
||||
}))
|
||||
.filter((candidate) => candidate.score >= 2)
|
||||
.sort((a, b) => b.score - a.score || a.index - b.index);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
const best = candidates[0];
|
||||
if (best.kind === "preset") {
|
||||
return { kind: "preset", preset: best.preset, score: best.score };
|
||||
}
|
||||
return {
|
||||
kind: "library",
|
||||
id: best.id,
|
||||
description: best.description,
|
||||
tags: best.tags,
|
||||
intensity: best.intensity,
|
||||
...(best.params && { params: best.params }),
|
||||
...(best.url && { url: best.url }),
|
||||
score: best.score,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLibraryLutOfflineMiss(err) {
|
||||
return err?.code === LIBRARY_LUT_OFFLINE_CODE;
|
||||
}
|
||||
|
||||
function libraryRecord(match, { id, localPath, fullPath, via }) {
|
||||
return {
|
||||
id,
|
||||
localPath,
|
||||
fullPath,
|
||||
lut: { src: localPath, intensity: match.intensity },
|
||||
source: "library",
|
||||
description: match.description,
|
||||
metadata: {
|
||||
provider: "cube_lut.library",
|
||||
provenance: {
|
||||
look_id: match.id,
|
||||
tags: match.tags,
|
||||
via,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertValidCubeText(cube, label) {
|
||||
const check = validateCube(cube);
|
||||
if (!check.ok) throw new Error(`${label}: ${check.error}`);
|
||||
}
|
||||
|
||||
function assertValidCubeFile(path, label) {
|
||||
const check = validateCubeFile(path);
|
||||
if (!check.ok) throw new Error(`${label}: ${check.error}`);
|
||||
}
|
||||
|
||||
function offlineLibraryMiss(match) {
|
||||
const err = new Error(`library LUT "${match.id}" is CDN-only and --local-only is set`);
|
||||
err.code = LIBRARY_LUT_OFFLINE_CODE;
|
||||
return err;
|
||||
}
|
||||
|
||||
export async function freezeLibraryLut(match, { projectDir, type, localOnly = false }) {
|
||||
if (!match || match.kind !== "library") {
|
||||
throw new Error("freezeLibraryLut requires a library match");
|
||||
}
|
||||
|
||||
// Prefer the CDN url so looks download on-demand (like bgm/image). Fall back
|
||||
// to deterministic buildCube params when offline (--local-only) or if the
|
||||
// download/validation fails, so resolution is never blocked on the network.
|
||||
if (match.url && !localOnly) {
|
||||
const { id, localPath } = allocateId(projectDir, type, ".cube");
|
||||
const fullPath = join(projectDir, localPath);
|
||||
const tmpPath = `${fullPath}.tmp`;
|
||||
try {
|
||||
// Download + validate at a .tmp path, then atomically rename. A crash
|
||||
// (SIGKILL/OOM) between write and validate can't orphan an invalid .cube
|
||||
// at the final path — only a validated cube is ever renamed into place.
|
||||
await freezeUrl(match.url, tmpPath);
|
||||
assertValidCubeFile(tmpPath, `downloaded library LUT ${match.id} failed validation`);
|
||||
renameSync(tmpPath, fullPath);
|
||||
return libraryRecord(match, { id, localPath, fullPath, via: "url" });
|
||||
} catch (err) {
|
||||
rmSync(tmpPath, { force: true });
|
||||
if (!match.params) {
|
||||
throw new Error(`failed to freeze library LUT ${match.id}: ${err.message}`);
|
||||
}
|
||||
// else: fall through to the params fallback below
|
||||
}
|
||||
}
|
||||
|
||||
if (match.params) {
|
||||
const { id, localPath } = allocateId(projectDir, type, ".cube");
|
||||
const fullPath = join(projectDir, localPath);
|
||||
const tmpPath = `${fullPath}.tmp`;
|
||||
try {
|
||||
const cube = buildCube(match.params);
|
||||
assertValidCubeText(cube, `invalid library LUT ${match.id}`);
|
||||
// Write + validate at .tmp, then atomic rename — same no-orphan guarantee
|
||||
// as the url path above.
|
||||
writeFileSync(tmpPath, cube);
|
||||
assertValidCubeFile(tmpPath, `invalid frozen LUT ${localPath}`);
|
||||
renameSync(tmpPath, fullPath);
|
||||
} catch (err) {
|
||||
rmSync(tmpPath, { force: true });
|
||||
throw err;
|
||||
}
|
||||
return libraryRecord(match, {
|
||||
id,
|
||||
localPath,
|
||||
fullPath,
|
||||
via: match.url ? "params-fallback" : "params",
|
||||
});
|
||||
}
|
||||
|
||||
if (match.url) throw offlineLibraryMiss(match); // url-only entry, offline
|
||||
throw new Error(`misconfigured library LUT "${match.id}": expected params or url`);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
CORE_PRESET_IDS,
|
||||
LIBRARY_LUT_OFFLINE_CODE,
|
||||
freezeLibraryLut,
|
||||
matchColorLook,
|
||||
readBundledLutIndex,
|
||||
} from "./lut-preset-provider.mjs";
|
||||
import { buildCube } from "./cube-build.mjs";
|
||||
import { validateCube, validateCubeFile } from "./cube-validate.mjs";
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..");
|
||||
|
||||
function corePresetIdsFromSource() {
|
||||
const src = readFileSync(join(REPO_ROOT, "packages/core/src/colorGrading.ts"), "utf8");
|
||||
const match = src.match(/export type HfColorGradingPresetId =([\s\S]*?);/);
|
||||
assert.ok(match, "core preset union should be readable");
|
||||
return [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
test("warm daylight and warm natural light resolve to the core warm-daylight preset", () => {
|
||||
assert.deepEqual(matchColorLook("warm daylight"), {
|
||||
kind: "preset",
|
||||
preset: "warm-daylight",
|
||||
score: 2,
|
||||
});
|
||||
assert.equal(matchColorLook("warm natural light").preset, "warm-daylight");
|
||||
});
|
||||
|
||||
test("high contrast punchy resolves to deep-contrast", () => {
|
||||
assert.equal(matchColorLook("high contrast punchy").preset, "deep-contrast");
|
||||
});
|
||||
|
||||
test("library look freezes a validated cube from params offline (--local-only)", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-provider-"));
|
||||
try {
|
||||
const match = matchColorLook("teal orange blockbuster");
|
||||
assert.equal(match.kind, "library");
|
||||
// localOnly forces the deterministic params path (no network); online, the
|
||||
// same look downloads its .cube from the CDN url (via "url").
|
||||
const frozen = await freezeLibraryLut(match, { projectDir, type: "grade", localOnly: true });
|
||||
assert.match(frozen.localPath, /^\.media\/luts\/grade_001\.cube$/);
|
||||
assert.ok(existsSync(join(projectDir, frozen.localPath)));
|
||||
assert.equal(validateCubeFile(join(projectDir, frozen.localPath)).ok, true);
|
||||
assert.equal(frozen.lut.src, frozen.localPath);
|
||||
assert.equal(frozen.metadata.provenance.via, "params-fallback");
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("preset IDs stay in sync with packages/core/src/colorGrading.ts", () => {
|
||||
assert.deepEqual(CORE_PRESET_IDS, corePresetIdsFromSource());
|
||||
for (const id of CORE_PRESET_IDS) {
|
||||
const match = matchColorLook(id);
|
||||
assert.equal(match.kind, "preset");
|
||||
assert.equal(match.preset, id);
|
||||
}
|
||||
});
|
||||
|
||||
test("zero-overlap intent returns no preset or library match", () => {
|
||||
assert.equal(matchColorLook("zqxv imaginary neutron look"), null);
|
||||
});
|
||||
|
||||
test("bundled LUT index entries resolve from params or url", () => {
|
||||
for (const entry of readBundledLutIndex()) {
|
||||
assert.ok(entry.id);
|
||||
assert.ok(entry.description);
|
||||
assert.ok(entry.params || entry.url, `${entry.id} should define params or url`);
|
||||
if (entry.params) {
|
||||
assert.equal(typeof entry.params, "object");
|
||||
assert.equal(validateCube(buildCube(entry.params)).ok, true, `${entry.id} params validate`);
|
||||
}
|
||||
if (entry.url) assert.equal(typeof entry.url, "string");
|
||||
}
|
||||
});
|
||||
|
||||
test("url library entries respect localOnly and freeze through fetch", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-url-provider-"));
|
||||
const match = {
|
||||
kind: "library",
|
||||
id: "cdn-look",
|
||||
description: "CDN-hosted look",
|
||||
tags: ["cdn"],
|
||||
intensity: 0.7,
|
||||
url: "https://example.invalid/look.cube",
|
||||
};
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchCalls = 0;
|
||||
try {
|
||||
globalThis.fetch = async () => {
|
||||
fetchCalls++;
|
||||
throw new Error("network should be skipped under localOnly");
|
||||
};
|
||||
await assert.rejects(
|
||||
freezeLibraryLut(match, { projectDir, type: "lut", localOnly: true }),
|
||||
(err) => {
|
||||
assert.equal(err.code, LIBRARY_LUT_OFFLINE_CODE);
|
||||
assert.match(err.message, /--local-only/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(fetchCalls, 0);
|
||||
|
||||
const cube = buildCube({ contrast: 0.1 });
|
||||
const body = Buffer.from(cube);
|
||||
globalThis.fetch = async (url) => {
|
||||
fetchCalls++;
|
||||
assert.equal(url, match.url);
|
||||
return {
|
||||
ok: true,
|
||||
headers: { get: () => String(body.length) },
|
||||
body: [body],
|
||||
};
|
||||
};
|
||||
|
||||
const frozen = await freezeLibraryLut(match, { projectDir, type: "lut" });
|
||||
assert.equal(fetchCalls, 1);
|
||||
assert.match(frozen.localPath, /^\.media\/luts\/lut_001\.cube$/);
|
||||
assert.equal(validateCubeFile(join(projectDir, frozen.localPath)).ok, true);
|
||||
assert.equal(frozen.metadata.provenance.via, "url");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
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",
|
||||
};
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
readManifest,
|
||||
appendRecord,
|
||||
findByPrompt,
|
||||
findByEntity,
|
||||
nextId,
|
||||
allocateId,
|
||||
normalizePrompt,
|
||||
manifestPath,
|
||||
mediaDir,
|
||||
typeDirPath,
|
||||
typeSubdir,
|
||||
} from "./manifest.mjs";
|
||||
import { regenerateIndex, generateIndexContent } from "./index-gen.mjs";
|
||||
import {
|
||||
contentHash,
|
||||
cachePut,
|
||||
cacheGet,
|
||||
cacheGetByEntity,
|
||||
importFromCache,
|
||||
promote,
|
||||
} from "./cache.mjs";
|
||||
|
||||
let tmp;
|
||||
|
||||
function setup() {
|
||||
tmp = mkdtempSync(join(tmpdir(), "mu-test-"));
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeRecord(overrides = {}) {
|
||||
return {
|
||||
id: "bgm_001",
|
||||
type: "bgm",
|
||||
path: ".media/audio/bgm/bgm_001.wav",
|
||||
source: "search",
|
||||
description: "soft minimal ambient",
|
||||
duration: 11,
|
||||
provenance: { provider: "heygen.audio.sounds", prompt: "subtle tech" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
const tests = [];
|
||||
|
||||
function test(name, fn) {
|
||||
tests.push({ name, fn });
|
||||
}
|
||||
|
||||
// --- manifest.mjs ---
|
||||
|
||||
test("readManifest returns empty array when no manifest exists", () => {
|
||||
setup();
|
||||
const result = readManifest(tmp);
|
||||
assert.deepStrictEqual(result, []);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("appendRecord writes valid JSONL and readManifest parses it back", () => {
|
||||
setup();
|
||||
const record = makeRecord();
|
||||
appendRecord(tmp, record);
|
||||
const records = readManifest(tmp);
|
||||
assert.equal(records.length, 1);
|
||||
assert.deepStrictEqual(records[0], record);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("appendRecord creates .media/ and type subdirs on first write", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord());
|
||||
assert.ok(existsSync(mediaDir(tmp)));
|
||||
assert.ok(existsSync(typeDirPath(tmp, "bgm")));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("lut and grade artifacts use the shared .media/luts subdir", () => {
|
||||
assert.equal(typeSubdir("lut"), "luts");
|
||||
assert.equal(typeSubdir("grade"), "luts");
|
||||
|
||||
setup();
|
||||
const allocated = allocateId(tmp, "lut", ".cube");
|
||||
assert.equal(allocated.localPath, ".media/luts/lut_001.cube");
|
||||
assert.ok(existsSync(join(tmp, allocated.localPath)));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("appendRecord appends multiple records", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
|
||||
appendRecord(tmp, makeRecord({ id: "bgm_002", provenance: { prompt: "energetic" } }));
|
||||
const records = readManifest(tmp);
|
||||
assert.equal(records.length, 2);
|
||||
assert.equal(records[0].id, "bgm_001");
|
||||
assert.equal(records[1].id, "bgm_002");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("findByPrompt returns exact-match record", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord());
|
||||
const found = findByPrompt(tmp, "subtle tech", "bgm");
|
||||
assert.ok(found);
|
||||
assert.equal(found.id, "bgm_001");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("findByPrompt returns null on miss", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord());
|
||||
assert.equal(findByPrompt(tmp, "nonexistent", "bgm"), null);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("findByPrompt filters by type", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord({ type: "sfx" }));
|
||||
assert.equal(findByPrompt(tmp, "subtle tech", "bgm"), null);
|
||||
assert.ok(findByPrompt(tmp, "subtle tech", "sfx"));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("findByPrompt matches across case and whitespace variants", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord({ provenance: { provider: "x", prompt: "calm ambient piano" } }));
|
||||
assert.ok(findByPrompt(tmp, "Calm Ambient Piano", "bgm"), "case-insensitive");
|
||||
assert.ok(findByPrompt(tmp, " calm ambient piano ", "bgm"), "whitespace-insensitive");
|
||||
assert.equal(findByPrompt(tmp, "calm ambient guitar", "bgm"), null, "still a real miss");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("normalizePrompt trims, lowercases, collapses whitespace", () => {
|
||||
assert.equal(normalizePrompt(" Upbeat Tech Launch "), "upbeat tech launch");
|
||||
assert.equal(normalizePrompt(null), "");
|
||||
});
|
||||
|
||||
test("allocateId reserves the id on disk so a pre-append caller can't reuse it (MU-23)", () => {
|
||||
setup();
|
||||
const a = allocateId(tmp, "bgm", ".wav");
|
||||
assert.equal(a.id, "bgm_001");
|
||||
assert.ok(existsSync(join(tmp, a.localPath)), "placeholder reserved on disk");
|
||||
// Second allocation BEFORE any manifest append (the download window) must not
|
||||
// hand back bgm_001 again, even with a different extension.
|
||||
const b = allocateId(tmp, "bgm", ".mp3");
|
||||
assert.equal(b.id, "bgm_002");
|
||||
assert.notEqual(a.localPath, b.localPath);
|
||||
// Lock file is released (not left behind).
|
||||
assert.ok(!existsSync(join(tmp, ".media", ".lock")), "lock released");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("allocateId continues past the highest manifest id", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord({ id: "bgm_005" }));
|
||||
assert.equal(allocateId(tmp, "bgm", ".wav").id, "bgm_006");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("findByEntity matches case-insensitively", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));
|
||||
assert.ok(findByEntity(tmp, "github"));
|
||||
assert.ok(findByEntity(tmp, "GITHUB"));
|
||||
assert.equal(findByEntity(tmp, "gitlab"), null);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("nextId generates sequential ids", () => {
|
||||
setup();
|
||||
assert.equal(nextId(tmp, "bgm"), "bgm_001");
|
||||
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
|
||||
assert.equal(nextId(tmp, "bgm"), "bgm_002");
|
||||
appendRecord(tmp, makeRecord({ id: "bgm_002" }));
|
||||
assert.equal(nextId(tmp, "bgm"), "bgm_003");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- index-gen.mjs ---
|
||||
|
||||
test("regenerateIndex produces plain-column table", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord());
|
||||
regenerateIndex(tmp);
|
||||
const content = readFileSync(join(tmp, ".media", "index.md"), "utf8");
|
||||
assert.ok(content.includes("# .media · 1 asset"));
|
||||
assert.ok(content.includes("bgm_001"));
|
||||
assert.ok(content.includes("soft minimal ambient"));
|
||||
assert.ok(content.includes("11s"));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("regenerateIndex handles empty manifest", () => {
|
||||
setup();
|
||||
mkdirSync(join(tmp, ".media"), { recursive: true });
|
||||
writeFileSync(manifestPath(tmp), "");
|
||||
regenerateIndex(tmp);
|
||||
const content = readFileSync(join(tmp, ".media", "index.md"), "utf8");
|
||||
assert.ok(content.includes("# .media · 0 assets"));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("generateIndexContent includes dims for images", () => {
|
||||
const records = [
|
||||
makeRecord({ id: "img_001", type: "image", width: 1920, height: 1080, duration: null }),
|
||||
];
|
||||
const content = generateIndexContent(records);
|
||||
assert.ok(content.includes("1920×1080"));
|
||||
assert.ok(content.includes("img_001"));
|
||||
});
|
||||
|
||||
test("regenerateIndex matches manifest content after multiple writes", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
|
||||
appendRecord(
|
||||
tmp,
|
||||
makeRecord({ id: "sfx_001", type: "sfx", description: "whoosh", duration: 3 }),
|
||||
);
|
||||
regenerateIndex(tmp);
|
||||
const content = readFileSync(join(tmp, ".media", "index.md"), "utf8");
|
||||
assert.ok(content.includes("# .media · 2 assets"));
|
||||
assert.ok(content.includes("bgm_001"));
|
||||
assert.ok(content.includes("sfx_001"));
|
||||
assert.ok(content.includes("whoosh"));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- cache.mjs ---
|
||||
|
||||
test("cacheGet returns null when cache is empty", () => {
|
||||
const result = cacheGet("nonexistent prompt", "bgm");
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("cachePut + cacheGet round-trip", () => {
|
||||
setup();
|
||||
const filePath = join(tmp, "test.wav");
|
||||
writeFileSync(filePath, "fake audio bytes for testing");
|
||||
const record = makeRecord({ provenance: { prompt: "cache test" } });
|
||||
|
||||
const { sha } = cachePut(filePath, record);
|
||||
assert.ok(sha);
|
||||
assert.equal(sha.length, 64);
|
||||
|
||||
const found = cacheGet("cache test", "bgm");
|
||||
assert.ok(found);
|
||||
assert.equal(found.reusable, true);
|
||||
assert.equal(found.sha, sha);
|
||||
|
||||
// cross-project reuse must survive trivial prompt variation, not just
|
||||
// byte-identical intents (the whole point of normalizePrompt).
|
||||
assert.ok(cacheGet(" Cache Test ", "bgm"), "cacheGet is case/whitespace-insensitive");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("cacheGetByEntity finds cached asset", () => {
|
||||
setup();
|
||||
const filePath = join(tmp, "logo.png");
|
||||
writeFileSync(filePath, "fake png bytes");
|
||||
const record = makeRecord({
|
||||
type: "icon",
|
||||
entity: "TestCorp",
|
||||
provenance: { prompt: "TestCorp logo" },
|
||||
});
|
||||
|
||||
cachePut(filePath, record);
|
||||
const found = cacheGetByEntity("testcorp");
|
||||
assert.ok(found);
|
||||
assert.equal(found.entity, "TestCorp");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("contentHash is deterministic", () => {
|
||||
setup();
|
||||
const filePath = join(tmp, "det.bin");
|
||||
writeFileSync(filePath, "deterministic content");
|
||||
const h1 = contentHash(filePath);
|
||||
const h2 = contentHash(filePath);
|
||||
assert.equal(h1, h2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("promote copies project asset to global cache", () => {
|
||||
setup();
|
||||
const record = makeRecord();
|
||||
appendRecord(tmp, record);
|
||||
const filePath = join(tmp, record.path);
|
||||
mkdirSync(join(filePath, ".."), { recursive: true });
|
||||
writeFileSync(filePath, "promotable audio data");
|
||||
|
||||
const { sha } = promote(tmp, "bgm_001");
|
||||
assert.ok(sha);
|
||||
|
||||
const cached = cacheGet("subtle tech", "bgm");
|
||||
assert.ok(cached);
|
||||
assert.equal(cached.sha, sha);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("importFromCache copies cached file into project", () => {
|
||||
setup();
|
||||
const filePath = join(tmp, "source.wav");
|
||||
writeFileSync(filePath, "importable audio");
|
||||
const record = makeRecord({ provenance: { prompt: "import test" } });
|
||||
const { sha } = cachePut(filePath, record);
|
||||
|
||||
const cached = cacheGet("import test", "bgm");
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "mu-import-"));
|
||||
const imported = importFromCache(cached, projectDir, "bgm_001", ".media/audio/bgm/bgm_001.wav");
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal(imported.id, "bgm_001");
|
||||
assert.equal(imported.provenance.imported_from, sha);
|
||||
assert.ok(existsSync(join(projectDir, ".media/audio/bgm/bgm_001.wav")));
|
||||
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- run ---
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
for (const { name, fn } of tests) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` \x1b[32m✓\x1b[0m ${name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(` \x1b[31m✗\x1b[0m ${name}`);
|
||||
console.log(` ${err.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
console.log("media-use · manifest/index/cache tests\n");
|
||||
runTests();
|
||||
@@ -0,0 +1,47 @@
|
||||
// Shared lexical-matching helpers used by both the assets/ scan (adopt.mjs) and
|
||||
// the reuse-candidate ranker (candidates.mjs), and the type-equivalence check
|
||||
// used by resolve.mjs and candidates.mjs. Kept in one place so the icon<->image
|
||||
// equivalence and the token rules can't drift between the "do" path (resolve)
|
||||
// and the "look" path (candidates).
|
||||
|
||||
// Common filler words that should never, on their own, make two strings match.
|
||||
const MATCH_STOPWORDS = new Set([
|
||||
"the",
|
||||
"and",
|
||||
"for",
|
||||
"with",
|
||||
"from",
|
||||
"this",
|
||||
"that",
|
||||
"your",
|
||||
"our",
|
||||
]);
|
||||
|
||||
// Split into lowercased word tokens of length >= 3, minus stopwords.
|
||||
export function matchTokens(text) {
|
||||
return new Set(
|
||||
String(text)
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((t) => t.length >= 3 && !MATCH_STOPWORDS.has(t)),
|
||||
);
|
||||
}
|
||||
|
||||
// Count of shared meaningful word tokens between two strings. 0 = no lexical
|
||||
// overlap (the candidate ranker still surfaces these, ordered after overlaps).
|
||||
export function tokenOverlap(a, b) {
|
||||
const ta = matchTokens(a);
|
||||
const tb = matchTokens(b);
|
||||
let n = 0;
|
||||
for (const t of ta) if (tb.has(t)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// icon, image, and logo are interchangeable: all live in images/, and
|
||||
// figma-imported brand marks are recorded as type image while agents ask for
|
||||
// logos as icon or logo.
|
||||
export function typesMatch(a, b) {
|
||||
if (a === b) return true;
|
||||
const visual = new Set(["icon", "image", "logo"]);
|
||||
return visual.has(a) && visual.has(b);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { probeSpecs } from "./specs.mjs";
|
||||
import { selectModel } from "./local-models.mjs";
|
||||
|
||||
// Local image generation via mflux (FLUX-on-MLX), the Mac-native runner.
|
||||
// Spec-gated: selectModel("imagegen", specs) returns the best FLUX-class model
|
||||
// the machine's AVAILABLE RAM can actually run (medium FLUX-schnell --low-ram on
|
||||
// ~24GB, up to Qwen-Image on 64GB+). When nothing local fits, this returns null
|
||||
// so the registry falls through to the codex image upsell.
|
||||
//
|
||||
// The official FLUX repos are HF-gated, so the model entries point --path at
|
||||
// non-gated community 4-bit re-uploads; the repo is resolved to a local snapshot
|
||||
// (hf download, idempotent) because a bare repo id breaks mlx unflatten.
|
||||
|
||||
// Tokenize the invoke template on whitespace FIRST, then substitute each token,
|
||||
// so a {prompt}/{model_path} value with spaces stays a single argv entry.
|
||||
function buildArgv(template, vars) {
|
||||
return template
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((tok) => tok.replace(/\{(\w+)\}/g, (_, k) => (k in vars ? String(vars[k]) : `{${k}}`)));
|
||||
}
|
||||
|
||||
// Resolve an HF repo to its local snapshot dir. `hf download` is idempotent and
|
||||
// prints the snapshot path as its last line.
|
||||
function resolveSnapshot(repo) {
|
||||
const out = execFileSync("hf", ["download", repo], {
|
||||
encoding: "utf8",
|
||||
timeout: 1_800_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const path = out.trim().split(/\r?\n/).pop()?.trim();
|
||||
return path && existsSync(path) ? path : null;
|
||||
}
|
||||
|
||||
export async function mfluxImageGenerate(intent, ctx) {
|
||||
const specs = ctx?.specs || probeSpecs();
|
||||
const sel = selectModel("imagegen", specs, { preferTier: ctx?.preferTier });
|
||||
if (sel.recommend) return null; // no local model fits -> codex upsell/fallback
|
||||
|
||||
const { model } = sel;
|
||||
const bin = model.invoke.trim().split(/\s+/)[0];
|
||||
// Not installed? Surface the exact enable-command FIRST (before the model
|
||||
// download) so the agent learns the free local path is available instead of
|
||||
// silently taking the codex upsell.
|
||||
try {
|
||||
execFileSync("which", [bin], { stdio: ["ignore", "ignore", "ignore"] });
|
||||
} catch {
|
||||
console.error(
|
||||
`media-use: local image gen not enabled (\`${bin}\` not on PATH). Install for free on-device FLUX: ${model.install}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const outPath = join(tmpdir(), `media-use-mflux-${process.pid}-${Date.now()}.png`);
|
||||
const width = ctx?.width || 512;
|
||||
const height = ctx?.height || 512;
|
||||
const seed = ctx?.seed ?? 42;
|
||||
|
||||
const vars = { prompt: intent, w: width, h: height, seed, out: outPath };
|
||||
if (model.repo && model.invoke.includes("{model_path}")) {
|
||||
const snap = model.repo ? resolveSnapshot(model.repo) : null;
|
||||
if (!snap) return null;
|
||||
vars.model_path = snap;
|
||||
}
|
||||
|
||||
const argv = buildArgv(model.invoke, vars);
|
||||
argv.shift(); // drop the bin (already validated)
|
||||
try {
|
||||
execFileSync(bin, argv, {
|
||||
encoding: "utf8",
|
||||
timeout: 1_800_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`media-use: local image gen (${model.id}) failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!existsSync(outPath)) return null;
|
||||
return {
|
||||
localPath: outPath,
|
||||
ext: ".png",
|
||||
source: "generated",
|
||||
metadata: {
|
||||
description: intent,
|
||||
provider: `mflux.${model.id}`,
|
||||
provenance: { model: model.id, tier: model.tier, prompt: intent },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const MISSES_FILE = "misses.jsonl";
|
||||
|
||||
function missesPath() {
|
||||
return join(homedir(), ".media", MISSES_FILE);
|
||||
}
|
||||
|
||||
export function recordMiss({ type, intent, provider_override, local_only }) {
|
||||
try {
|
||||
const dir = join(homedir(), ".media");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(
|
||||
join(dir, MISSES_FILE),
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
type,
|
||||
intent,
|
||||
provider_override: !!provider_override,
|
||||
local_only: !!local_only,
|
||||
}) + "\n",
|
||||
);
|
||||
} catch {
|
||||
// local miss logging is best-effort; never surface into resolve
|
||||
}
|
||||
}
|
||||
|
||||
export function readMisses() {
|
||||
const p = missesPath();
|
||||
try {
|
||||
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 {
|
||||
// skip malformed local lines, don't crash stats
|
||||
}
|
||||
}
|
||||
return records;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readMisses, recordMiss } from "./misses.mjs";
|
||||
|
||||
function sandbox() {
|
||||
const root = mkdtempSync(join(tmpdir(), "mu-misses-"));
|
||||
const home = join(root, "home");
|
||||
mkdirSync(home, { recursive: true });
|
||||
process.env.HOME = home;
|
||||
return { root, home };
|
||||
}
|
||||
|
||||
function restoreEnv(saved) {
|
||||
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
|
||||
test("recordMiss appends a well-formed local miss", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home } = sandbox();
|
||||
try {
|
||||
recordMiss({ type: "bgm", intent: "moody synth pulse", provider_override: true });
|
||||
const misses = readMisses();
|
||||
assert.equal(misses.length, 1);
|
||||
assert.equal(misses[0].type, "bgm");
|
||||
assert.equal(misses[0].intent, "moody synth pulse");
|
||||
assert.equal(misses[0].provider_override, true);
|
||||
assert.equal(misses[0].local_only, false);
|
||||
assert.ok(!Number.isNaN(Date.parse(misses[0].ts)));
|
||||
|
||||
const raw = readFileSync(join(home, ".media/misses.jsonl"), "utf8");
|
||||
assert.match(raw, /moody synth pulse/);
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("recordMiss swallows filesystem failures", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home } = sandbox();
|
||||
try {
|
||||
writeFileSync(join(home, ".media"), "not a directory");
|
||||
assert.doesNotThrow(() =>
|
||||
recordMiss({ type: "image", intent: "unwritable", local_only: true }),
|
||||
);
|
||||
assert.equal(existsSync(join(home, ".media/misses.jsonl")), false);
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readMisses skips corrupt lines", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home } = sandbox();
|
||||
try {
|
||||
mkdirSync(join(home, ".media"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".media/misses.jsonl"),
|
||||
[
|
||||
JSON.stringify({ ts: "2026-07-09T00:00:00.000Z", type: "bgm", intent: "one" }),
|
||||
"{not json",
|
||||
JSON.stringify({ ts: "2026-07-09T00:00:01.000Z", type: "sfx", intent: "two" }),
|
||||
].join("\n"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
readMisses().map((miss) => miss.intent),
|
||||
["one", "two"],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { mergeTokensToWords } from "./parakeet-words.mjs";
|
||||
|
||||
test("mergeTokensToWords joins sub-word tokens on the space boundary", () => {
|
||||
const parakeet = {
|
||||
text: "Hello everyone. Um,",
|
||||
sentences: [
|
||||
{
|
||||
tokens: [
|
||||
{ text: " H", start: 0.0, end: 0.24 },
|
||||
{ text: "ello", start: 0.24, end: 0.48 },
|
||||
{ text: " everyone.", start: 0.48, end: 1.28 },
|
||||
{ text: " Um,", start: 1.28, end: 1.92 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { words } = mergeTokensToWords(parakeet);
|
||||
assert.deepEqual(words, [
|
||||
{ text: "Hello", start: 0.0, end: 0.48 },
|
||||
{ text: "everyone.", start: 0.48, end: 1.28 },
|
||||
{ text: "Um,", start: 1.28, end: 1.92 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("mergeTokensToWords spans multiple sentences and drops empties", () => {
|
||||
const parakeet = {
|
||||
text: "Hi there",
|
||||
sentences: [
|
||||
{ tokens: [{ text: "Hi", start: 0, end: 0.2 }] },
|
||||
{ tokens: [{ text: " there", start: 0.5, end: 0.9 }] },
|
||||
],
|
||||
};
|
||||
const { words } = mergeTokensToWords(parakeet);
|
||||
assert.equal(words.length, 2);
|
||||
assert.equal(words[1].text, "there");
|
||||
assert.equal(words[1].start, 0.5);
|
||||
});
|
||||
|
||||
test("mergeTokensToWords tolerates missing sentences/tokens", () => {
|
||||
assert.deepEqual(mergeTokensToWords({}).words, []);
|
||||
assert.deepEqual(mergeTokensToWords({ sentences: [{}] }).words, []);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { extname } from "node:path";
|
||||
|
||||
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);
|
||||
|
||||
export function probe(filePath) {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
if (ext === ".svg") return { width: null, height: null, duration: null, codec: "svg" };
|
||||
|
||||
try {
|
||||
// execFileSync (no shell) so a hostile filename like `"; rm -rf ~; ".png`
|
||||
// can't break out of the quoting — filePath is passed as a literal argv entry.
|
||||
const raw = execFileSync(
|
||||
"ffprobe",
|
||||
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
|
||||
{ encoding: "utf8", timeout: 5000 },
|
||||
);
|
||||
const info = JSON.parse(raw);
|
||||
const stream = info.streams?.[0];
|
||||
const format = info.format;
|
||||
|
||||
const isImage = IMAGE_EXT.has(ext);
|
||||
const duration = isImage
|
||||
? null
|
||||
: parseFloat(format?.duration) || parseFloat(stream?.duration) || null;
|
||||
const width = parseInt(stream?.width, 10) || null;
|
||||
const height = parseInt(stream?.height, 10) || null;
|
||||
const codec = stream?.codec_name || null;
|
||||
|
||||
return {
|
||||
duration: duration != null ? Math.round(duration * 10) / 10 : null,
|
||||
width,
|
||||
height,
|
||||
codec,
|
||||
};
|
||||
} catch {
|
||||
return { duration: null, width: null, height: null, codec: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { probe } from "./probe.mjs";
|
||||
|
||||
// Regression for the shell-injection fix: probe() must pass the path as a literal
|
||||
// argv entry, never through a shell. A filename containing shell metacharacters
|
||||
// must NOT execute. Under the old execSync(`ffprobe ... "${path}"`) the embedded
|
||||
// `touch` ran and created the marker; under execFileSync it cannot, regardless of
|
||||
// whether ffprobe is installed (the injected command never reaches a shell).
|
||||
test("probe does not execute shell metacharacters in a filename", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "probe-inject-"));
|
||||
const marker = join(dir, "INJECTED");
|
||||
// Slash-free basename (a real on-disk filename) that breaks out of the old
|
||||
// double-quoted interpolation and would `touch INJECTED` in the cwd.
|
||||
const evil = join(dir, `clip"; touch INJECTED; echo ".mp4`);
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
writeFileSync(evil, "not real media");
|
||||
process.chdir(dir); // so a leaked `touch INJECTED` would land next to `marker`
|
||||
const meta = probe(evil);
|
||||
assert.equal(existsSync(marker), false, "injected `touch` must not have run");
|
||||
// Bogus/unreadable media still returns the null-shaped result, never throws.
|
||||
assert.deepEqual(Object.keys(meta).sort(), ["codec", "duration", "height", "width"]);
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// Back-compat surface for the v1 provider API. The ordered, capability-based
|
||||
// registry now lives in registry.mjs; this re-exports the v1 helpers so existing
|
||||
// callers keep working. New code should import from registry.mjs directly
|
||||
// (getProviders / runCapability).
|
||||
export { getProvider, listTypes } from "./registry.mjs";
|
||||
@@ -0,0 +1,172 @@
|
||||
// Provider registry — the v2 contract.
|
||||
//
|
||||
// Each media type maps to an ORDERED list of provider entries. Providers are
|
||||
// tried in order; the first to return a non-null result wins, which keeps
|
||||
// resolution deterministic (same request -> same provider -> same file ->
|
||||
// reproducible renders). heygen-CLI is always first for the types it serves.
|
||||
//
|
||||
// An entry exposes any of three capability methods — search / generate /
|
||||
// process — plus { name }. media-use holds no keys; each external tool owns its
|
||||
// own auth. Providers, by type:
|
||||
// - heygen CLI: catalog + TTS, first for every type it serves (OAuth free
|
||||
// allowance first, then the user's HeyGen billing path)
|
||||
// - mflux: local FLUX-class image gen, spec-selected to the machine's RAM
|
||||
// (free, private, offline once cached)
|
||||
// - codex CLI: image gen on the user's ChatGPT sub — the better-quality upsell
|
||||
// and the fallback when no local model fits
|
||||
// - Kokoro (via the hyperframes CLI): local voiceover, free/private fallback
|
||||
// when HeyGen credentials are absent or --local-only is requested
|
||||
//
|
||||
// Generation is local-first, cloud-upsell. `ctx.provider` forces one provider
|
||||
// (e.g. "make an image with codex").
|
||||
|
||||
import { bgmProvider } from "./bgm-provider.mjs";
|
||||
import { sfxProvider } from "./sfx-provider.mjs";
|
||||
import { bundledSfxProvider } from "./bundled-sfx-provider.mjs";
|
||||
import { imageProvider, iconProvider } from "./image-provider.mjs";
|
||||
import { brandProvider } from "./brand-provider.mjs";
|
||||
import {
|
||||
svglSearch,
|
||||
simpleIconsSearch,
|
||||
githubAvatarSearch,
|
||||
faviconSearch,
|
||||
} from "./logo-provider.mjs";
|
||||
import { heygenTtsGenerate } from "./voice-provider.mjs";
|
||||
import { localTtsGenerate } from "./tts-local-provider.mjs";
|
||||
import { codexImageGenerate } from "./codex-provider.mjs";
|
||||
import { mfluxImageGenerate } from "./mflux-provider.mjs";
|
||||
|
||||
// Provider markers: `network` = hits a remote service (skipped by --local-only).
|
||||
// `paid` = may cost wallet credits after any OAuth/web-plan free allowance
|
||||
// (documentation for the agent's cost judgment, X4: agent-initiated paid should
|
||||
// confirm). HeyGen catalog SEARCH is free; HeyGen TTS is free for eligible
|
||||
// OAuth CLI users up to the monthly allowance, then follows the user's billing.
|
||||
const A = (name, caps) => ({ name, ...caps }); // local, free
|
||||
const N = (name, caps) => ({ name, network: true, ...caps }); // remote, free
|
||||
const P = (name, caps) => ({ name, network: true, paid: true, ...caps }); // remote, paid
|
||||
|
||||
// heygen-CLI first. All remote providers are skipped by --local-only.
|
||||
const REGISTRY = {
|
||||
bgm: [N("heygen.audio.sounds", { search: bgmProvider.search })],
|
||||
sfx: [
|
||||
N("heygen.audio.sounds", { search: sfxProvider.search }),
|
||||
A("bundled.sfx", { search: bundledSfxProvider.search }),
|
||||
],
|
||||
image: [
|
||||
N("heygen.asset.search", { search: imageProvider.search }),
|
||||
// Catalog miss -> generate. Local first (best FLUX-class model the machine's
|
||||
// RAM can run, spec-selected; free, private, kept under --local-only), then
|
||||
// the codex CLI on the user's ChatGPT sub as the better-quality upsell and
|
||||
// the fallback when no local model fits.
|
||||
A("mflux.local", { generate: mfluxImageGenerate }),
|
||||
N("codex.image_gen", { generate: codexImageGenerate }),
|
||||
],
|
||||
icon: [N("heygen.asset.search", { search: iconProvider.search })],
|
||||
logo: [
|
||||
// Official brand marks. Tiers verified by a 54-brand stress test (100%
|
||||
// cascade hit); HeyGen asset search is deliberately absent — it returns
|
||||
// generic look-alike icons for brand queries. All free, all network →
|
||||
// --local-only leaves only the cache rungs.
|
||||
N("svgl", { search: svglSearch }),
|
||||
N("simple-icons", { search: simpleIconsSearch }),
|
||||
N("github.avatar", { search: githubAvatarSearch }),
|
||||
N("favicon.ddg", { search: faviconSearch }),
|
||||
],
|
||||
voice: [
|
||||
// HeyGen TTS first when credentialed so CLI/OAuth users consume the free
|
||||
// web-plan allowance (10 min/month) before any paid path. --local-only skips
|
||||
// it and keeps Kokoro as the private/offline fallback.
|
||||
// Deliberately kept `paid` (X4 confirm-before-call) even though the first
|
||||
// 10 min/month are free: the client can't know the remaining allowance, so
|
||||
// confirming is safer than risking a silent charge once it's spent. (A
|
||||
// tri-state "quota-first, paid after" would need backend quota state.)
|
||||
P("heygen.tts", { generate: heygenTtsGenerate }),
|
||||
A("kokoro.local", { generate: localTtsGenerate }),
|
||||
],
|
||||
brand: [
|
||||
// Local design spec, not heygen — reads frame.md / design.md tokens.
|
||||
A("design_spec", { search: brandProvider.search }),
|
||||
],
|
||||
grade: [
|
||||
// Local deterministic cascade handled by resolve.mjs so grade records can
|
||||
// carry an inline block as well as an optional frozen .cube file.
|
||||
A("color_grade.local", { search: async () => null, generate: async () => null }),
|
||||
],
|
||||
lut: [
|
||||
// Lower-level local LUT generation/freezing path handled by resolve.mjs.
|
||||
A("cube_lut.local", { search: async () => null, generate: async () => null }),
|
||||
],
|
||||
};
|
||||
|
||||
function listFor(type) {
|
||||
const list = REGISTRY[type];
|
||||
if (!list) throw new Error(`unknown media type: ${type}`);
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Ordered providers for a type. */
|
||||
export function getProviders(type) {
|
||||
return listFor(type);
|
||||
}
|
||||
|
||||
/** All declared media types. */
|
||||
export function listTypes() {
|
||||
return Object.keys(REGISTRY);
|
||||
}
|
||||
|
||||
/** Provider names available for a type, in cascade order (for --provider validation). */
|
||||
export function providerNamesFor(type) {
|
||||
return listFor(type).map((p) => p.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does an override token (full name like "codex.image_gen" or a prefix like
|
||||
* "codex") match any provider declared for the type? Same match rule as
|
||||
* runProviders, so validation and dispatch never disagree.
|
||||
*/
|
||||
export function providerMatches(type, want) {
|
||||
return providerNamesFor(type).some((n) => n === want || n.startsWith(`${want}.`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat shim for the v1 single-provider API. Returns the first declared
|
||||
* provider for the type (tagged with `type`); throws for an unknown type.
|
||||
* Kept for v1 callers only — new code should use getProviders/runCapability.
|
||||
*/
|
||||
export function getProvider(type) {
|
||||
const first = listFor(type)[0] || {};
|
||||
return { ...first, type };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a capability across an explicit ordered provider list. Tries each in
|
||||
* order, returns the first non-null result, skips providers that don't expose
|
||||
* the capability. Pure over its input — the unit-testable core of the cascade.
|
||||
*
|
||||
* Offline guard: a `network` provider is skipped when `ctx.localOnly` is set —
|
||||
* unconditionally, even under a `ctx.provider` override. --local-only is a hard
|
||||
* safety flag: it must never make a network call. Forcing a network provider
|
||||
* while offline yields a clean miss (the caller explains the conflict), never a
|
||||
* silent network request.
|
||||
* Provider override: `ctx.provider` (a full name like "codex.image_gen" or a
|
||||
* prefix like "codex") pins resolution to matching providers only — this is how
|
||||
* a user "make an image WITH codex" forces the upsell instead of taking the
|
||||
* free-first default.
|
||||
*/
|
||||
export async function runProviders(providers, capability, intent, ctx) {
|
||||
const want = ctx?.provider;
|
||||
for (const p of providers) {
|
||||
if (want && p.name !== want && !p.name.startsWith(`${want}.`)) continue;
|
||||
if (p.network && ctx?.localOnly) continue; // --local-only wins, even over --provider
|
||||
const fn = p[capability];
|
||||
if (typeof fn !== "function") continue;
|
||||
const res = await fn(intent, ctx);
|
||||
if (res) return res;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run a capability over the providers for a type (deterministic, heygen-first). */
|
||||
export async function runCapability(type, capability, intent, ctx) {
|
||||
return runProviders(getProviders(type), capability, intent, ctx);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { getProviders, getProvider, listTypes, runProviders, runCapability } from "./registry.mjs";
|
||||
|
||||
// --- registry shape -------------------------------------------------------
|
||||
|
||||
test("listTypes exposes the v2 media types", () => {
|
||||
const types = listTypes();
|
||||
for (const t of ["bgm", "sfx", "image", "icon", "logo", "voice", "brand", "grade", "lut"]) {
|
||||
assert.ok(types.includes(t), `missing type: ${t}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("heygen provider is first for every type it serves", () => {
|
||||
for (const t of ["bgm", "sfx", "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}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("sanctioned providers only: heygen, local mflux/kokoro, codex, design spec, logo tiers", () => {
|
||||
const allowed =
|
||||
/^heygen|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/;
|
||||
for (const t of listTypes()) {
|
||||
for (const p of getProviders(t)) {
|
||||
assert.ok(allowed.test(p.name), `${t} lists unsanctioned provider: ${p.name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("image cascade: heygen catalog, then local mflux, then the codex upsell", () => {
|
||||
const ps = getProviders("image");
|
||||
assert.match(ps[0].name, /^heygen/, "heygen catalog first");
|
||||
const names = ps.map((p) => p.name);
|
||||
const mflux = ps.find((p) => p.name === "mflux.local");
|
||||
const codex = ps.find((p) => p.name === "codex.image_gen");
|
||||
assert.ok(mflux && typeof mflux.generate === "function", "local mflux registered");
|
||||
assert.ok(codex && typeof codex.generate === "function", "codex upsell registered");
|
||||
assert.ok(names.indexOf("mflux.local") < names.indexOf("codex.image_gen"), "local before codex");
|
||||
assert.ok(!mflux.network, "local mflux is kept under --local-only");
|
||||
assert.ok(codex.network, "codex is network (skipped under --local-only)");
|
||||
});
|
||||
|
||||
test("voice cascade: HeyGen TTS first, Kokoro remains the local fallback", () => {
|
||||
const ps = getProviders("voice");
|
||||
assert.equal(ps[0].name, "heygen.tts", "HeyGen TTS is first when credentials exist");
|
||||
assert.ok(ps[0].network, "HeyGen TTS is network (skipped under --local-only)");
|
||||
assert.ok(ps[0].paid, "HeyGen TTS may bill after the OAuth free allowance");
|
||||
assert.equal(ps[1].name, "kokoro.local", "local Kokoro is the offline fallback");
|
||||
assert.ok(!ps[1].network, "local Kokoro kept under --local-only");
|
||||
assert.ok(!ps[1].paid, "local Kokoro is free");
|
||||
});
|
||||
|
||||
test("sfx cascade: HeyGen catalog first, bundled library remains the local 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");
|
||||
});
|
||||
|
||||
test("ctx.provider forces one generator (e.g. 'make an image WITH codex')", async () => {
|
||||
const providers = [
|
||||
{ name: "heygen.asset.search", network: true, search: async () => null },
|
||||
{ name: "mflux.local", generate: async () => ({ hit: "local" }) },
|
||||
{ name: "codex.image_gen", network: true, generate: async () => ({ hit: "codex" }) },
|
||||
];
|
||||
// no override: local wins (first generate to return non-null)
|
||||
assert.deepEqual(await runProviders(providers, "generate", "x", {}), { hit: "local" });
|
||||
// override to codex: skip local, use codex even though local would have worked
|
||||
assert.deepEqual(await runProviders(providers, "generate", "x", { provider: "codex" }), {
|
||||
hit: "codex",
|
||||
});
|
||||
// override matches the full name too
|
||||
assert.deepEqual(
|
||||
await runProviders(providers, "generate", "x", { provider: "codex.image_gen" }),
|
||||
{ hit: "codex" },
|
||||
);
|
||||
// --local-only wins even over a forced network provider: no network call,
|
||||
// clean miss (the caller surfaces the conflict). A forced LOCAL provider under
|
||||
// --local-only still runs.
|
||||
assert.equal(
|
||||
await runProviders(providers, "generate", "x", { provider: "codex", localOnly: true }),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await runProviders(providers, "generate", "x", { provider: "mflux", localOnly: true }),
|
||||
{ hit: "local" },
|
||||
);
|
||||
});
|
||||
|
||||
test("getProvider returns the first provider with its type, throws for unknown", () => {
|
||||
const p = getProvider("bgm");
|
||||
assert.equal(p.type, "bgm");
|
||||
assert.equal(typeof p.search, "function");
|
||||
assert.throws(() => getProvider("unknown_type"), /unknown media type/);
|
||||
});
|
||||
|
||||
test("getProviders throws for unknown type", () => {
|
||||
assert.throws(() => getProviders("nope"), /unknown media type/);
|
||||
});
|
||||
|
||||
// --- deterministic capability execution (runProviders core) ---------------
|
||||
|
||||
test("runProviders calls providers in order and returns the first non-null", async () => {
|
||||
const calls = [];
|
||||
const providers = [
|
||||
{
|
||||
name: "a",
|
||||
enabled: true,
|
||||
search: async () => {
|
||||
calls.push("a");
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "b",
|
||||
enabled: true,
|
||||
search: async () => {
|
||||
calls.push("b");
|
||||
return { hit: "b" };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "c",
|
||||
enabled: true,
|
||||
search: async () => {
|
||||
calls.push("c");
|
||||
return { hit: "c" };
|
||||
},
|
||||
},
|
||||
];
|
||||
const res = await runProviders(providers, "search", "x", {});
|
||||
assert.deepEqual(res, { hit: "b" });
|
||||
assert.deepEqual(calls, ["a", "b"], "must stop at first non-null, never call c");
|
||||
});
|
||||
|
||||
test("runProviders skips providers missing the requested capability", async () => {
|
||||
const providers = [
|
||||
{ name: "a", enabled: true /* no search */ },
|
||||
{ name: "b", enabled: true, search: async () => ({ hit: "b" }) },
|
||||
];
|
||||
const res = await runProviders(providers, "search", "x", {});
|
||||
assert.deepEqual(res, { hit: "b" });
|
||||
});
|
||||
|
||||
test("runProviders returns null when no provider yields a result", async () => {
|
||||
const providers = [{ name: "a", enabled: true, search: async () => null }];
|
||||
assert.equal(await runProviders(providers, "search", "x", {}), null);
|
||||
});
|
||||
|
||||
test("runCapability('bgm','process') is null — process slot is graceful when unfilled", async () => {
|
||||
assert.equal(await runCapability("bgm", "process", "x", {}), null);
|
||||
});
|
||||
|
||||
test("--local-only skips every network provider (even free remote ones)", async () => {
|
||||
let remoteRan = false;
|
||||
const providers = [
|
||||
{
|
||||
name: "heygen",
|
||||
network: true,
|
||||
search: async () => {
|
||||
remoteRan = true;
|
||||
return { hit: "net" };
|
||||
},
|
||||
},
|
||||
{ name: "local", search: async () => ({ hit: "local" }) },
|
||||
];
|
||||
assert.deepEqual(await runProviders(providers, "search", "x", { localOnly: true }), {
|
||||
hit: "local",
|
||||
});
|
||||
assert.equal(remoteRan, false, "the remote provider must not be called offline");
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// Paginated text search over a manifest (project or global) by id / description
|
||||
// / entity. Plain substring match — no vector DB (B4): the agent's own file
|
||||
// search covers semantics; this just keeps result pages small enough to not
|
||||
// blow the context window.
|
||||
|
||||
const PAGE = 10;
|
||||
|
||||
export function searchRecords(records, query, { page = 1, pageSize = PAGE } = {}) {
|
||||
const q = String(query || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const matched = q
|
||||
? records.filter((r) =>
|
||||
[r.id, r.description, r.entity, r.type].some(
|
||||
(f) => f && String(f).toLowerCase().includes(q),
|
||||
),
|
||||
)
|
||||
: records.slice();
|
||||
const total = matched.length;
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const p = Math.min(Math.max(1, page), pages);
|
||||
const start = (p - 1) * pageSize;
|
||||
return { results: matched.slice(start, start + pageSize), total, page: p, pages, pageSize };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { searchRecords } from "./search.mjs";
|
||||
|
||||
const recs = Array.from({ length: 23 }, (_, i) => ({
|
||||
id: `bgm_${i}`,
|
||||
type: i % 2 ? "bgm" : "sfx",
|
||||
description: i === 5 ? "energetic tech whoosh" : `track ${i}`,
|
||||
}));
|
||||
|
||||
test("matches id / description / type, case-insensitively", () => {
|
||||
assert.equal(searchRecords(recs, "ENERGETIC").results.length, 1);
|
||||
assert.equal(searchRecords(recs, "energetic").results[0].id, "bgm_5");
|
||||
assert.ok(searchRecords(recs, "sfx").total > 0);
|
||||
});
|
||||
|
||||
test("caps page size and reports pagination", () => {
|
||||
const r = searchRecords(recs, "", { pageSize: 10 });
|
||||
assert.equal(r.results.length, 10, "never returns more than a page");
|
||||
assert.equal(r.total, 23);
|
||||
assert.equal(r.pages, 3);
|
||||
assert.equal(r.page, 1);
|
||||
});
|
||||
|
||||
test("page navigation returns the right slice, clamps out-of-range", () => {
|
||||
assert.equal(searchRecords(recs, "", { page: 3, pageSize: 10 }).results.length, 3);
|
||||
assert.equal(searchRecords(recs, "", { page: 99, pageSize: 10 }).page, 3, "clamped to last page");
|
||||
});
|
||||
|
||||
test("empty query returns everything (paginated)", () => {
|
||||
assert.equal(searchRecords(recs, "").total, 23);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { heygenSearch } from "./heygen-search.mjs";
|
||||
|
||||
export const sfxProvider = {
|
||||
async search(intent) {
|
||||
const results = heygenSearch("audio sounds list", intent, {
|
||||
type: "sound_effects",
|
||||
minScore: 0.4,
|
||||
});
|
||||
if (!results) return null;
|
||||
const best = results[0];
|
||||
return {
|
||||
url: best.audio_url,
|
||||
source: "search",
|
||||
// ext derived from audio_url by resolve.mjs — catalog SFX are .mp3 or .wav
|
||||
metadata: {
|
||||
description: best.description || best.name || intent,
|
||||
duration: best.duration || null,
|
||||
provider: "heygen.audio.sounds",
|
||||
provenance: { track_id: best.id, score: best.score, query: intent },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
// Machine-capability probe for the spec-gated local-model fallback.
|
||||
//
|
||||
// Local models are USER-INSTALLED and local-use-only — media-use recommends,
|
||||
// spec-checks, and assists install, but never bundles or runs them as a service.
|
||||
// This probe answers "what tier can this machine actually run?" so selection can
|
||||
// offer a medium/large local model, or fall back to recommending the CLI path.
|
||||
//
|
||||
// `osMod` and `exec` are injectable for tests. `exec(cmd)` returns the command's
|
||||
// stdout as a string, or throws / returns null on failure.
|
||||
|
||||
import os from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
function defaultExec(cmd) {
|
||||
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 });
|
||||
}
|
||||
|
||||
// Available (not just total) RAM — the real budget for "will this model fit
|
||||
// alongside the OS + open apps". On unified-memory Macs the model shares system
|
||||
// RAM, so a big model on a busy machine OOMs/thrashes even if total RAM looks
|
||||
// ample. macOS: vm_stat free + inactive + speculative + purgeable pages (all
|
||||
// reclaimable). Linux: /proc/meminfo MemAvailable. Fallback: total (optimistic
|
||||
// but stable when the probe is unavailable, e.g. in tests).
|
||||
function availableRamMB(platform, exec, totalMB) {
|
||||
try {
|
||||
if (platform === "darwin") {
|
||||
const out = String(exec("vm_stat"));
|
||||
const pageSize = parseInt((out.match(/page size of (\d+)/) || [])[1] || "16384", 10);
|
||||
const pages = (name) => {
|
||||
const m = out.match(new RegExp(`${name}:\\s+(\\d+)`));
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
};
|
||||
const reclaimable =
|
||||
pages("Pages free") +
|
||||
pages("Pages inactive") +
|
||||
pages("Pages speculative") +
|
||||
pages("Pages purgeable");
|
||||
const mb = Math.round((reclaimable * pageSize) / (1024 * 1024));
|
||||
if (mb > 0) return mb;
|
||||
} else if (platform === "linux") {
|
||||
const out = String(exec("cat /proc/meminfo"));
|
||||
const m = out.match(/MemAvailable:\s+(\d+)\s+kB/);
|
||||
if (m) return Math.round(parseInt(m[1], 10) / 1024);
|
||||
}
|
||||
} catch {
|
||||
// probe unavailable — fall through to the total-RAM estimate
|
||||
}
|
||||
return totalMB;
|
||||
}
|
||||
|
||||
function detectGpu(platform, arch, ramMB, exec) {
|
||||
// Apple Silicon: Metal GPU with unified memory — VRAM tracks system RAM.
|
||||
if (platform === "darwin" && arch === "arm64") {
|
||||
return { present: true, kind: "apple", vramMB: ramMB };
|
||||
}
|
||||
// NVIDIA: query total VRAM. Any failure (no driver, no GPU) -> no GPU.
|
||||
try {
|
||||
const out = exec("nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits");
|
||||
const mb = parseInt(String(out).trim().split(/\r?\n/)[0], 10);
|
||||
if (Number.isFinite(mb) && mb > 0) return { present: true, kind: "nvidia", vramMB: mb };
|
||||
} catch {
|
||||
// fall through — no usable GPU
|
||||
}
|
||||
return { present: false, kind: null, vramMB: 0 };
|
||||
}
|
||||
|
||||
export function probeSpecs({ osMod = os, exec = defaultExec } = {}) {
|
||||
const platform = osMod.platform();
|
||||
const arch = osMod.arch();
|
||||
const cpuCores = osMod.cpus().length;
|
||||
const ramMB = Math.round(osMod.totalmem() / (1024 * 1024));
|
||||
return {
|
||||
platform,
|
||||
arch,
|
||||
cpuCores,
|
||||
ramMB,
|
||||
availableRamMB: availableRamMB(platform, exec, ramMB),
|
||||
appleSilicon: platform === "darwin" && arch === "arm64",
|
||||
gpu: detectGpu(platform, arch, ramMB, exec),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { probeSpecs } from "./specs.mjs";
|
||||
|
||||
// Fake os module + exec so the probe is deterministic across CI machines.
|
||||
const fakeOs = (over = {}) => ({
|
||||
platform: () => over.platform ?? "linux",
|
||||
arch: () => over.arch ?? "x64",
|
||||
cpus: () => Array.from({ length: over.cores ?? 8 }),
|
||||
totalmem: () => (over.ramMB ?? 16384) * 1024 * 1024,
|
||||
});
|
||||
|
||||
test("probeSpecs reports structured caps", () => {
|
||||
const s = probeSpecs({ osMod: fakeOs({ cores: 12, ramMB: 32768 }), exec: () => null });
|
||||
assert.equal(s.cpuCores, 12);
|
||||
assert.equal(s.ramMB, 32768);
|
||||
assert.equal(s.platform, "linux");
|
||||
assert.equal(s.gpu.present, false);
|
||||
// probe unavailable (exec returns null) -> availableRamMB falls back to total
|
||||
assert.equal(s.availableRamMB, 32768);
|
||||
});
|
||||
|
||||
test("availableRamMB is read from /proc/meminfo on Linux", () => {
|
||||
const exec = (cmd) =>
|
||||
cmd.includes("meminfo") ? "MemTotal: 33554432 kB\nMemAvailable: 8388608 kB\n" : null;
|
||||
const s = probeSpecs({ osMod: fakeOs({ platform: "linux", ramMB: 32768 }), exec });
|
||||
assert.equal(s.availableRamMB, 8192, "8388608 kB -> 8192 MB");
|
||||
});
|
||||
|
||||
test("availableRamMB is summed from reclaimable vm_stat pages on macOS", () => {
|
||||
const vmStat =
|
||||
"Mach Virtual Memory Statistics: (page size of 16384 bytes)\n" +
|
||||
"Pages free: 100000.\n" +
|
||||
"Pages inactive: 200000.\n" +
|
||||
"Pages speculative: 50000.\n" +
|
||||
"Pages purgeable: 10000.\n";
|
||||
const exec = (cmd) => (cmd.includes("vm_stat") ? vmStat : null);
|
||||
const s = probeSpecs({
|
||||
osMod: fakeOs({ platform: "darwin", arch: "arm64", ramMB: 24576 }),
|
||||
exec,
|
||||
});
|
||||
// (100000+200000+50000+10000) pages * 16384 B / 1MiB = 5625 MB
|
||||
assert.equal(s.availableRamMB, 5625);
|
||||
});
|
||||
|
||||
test("Apple Silicon is detected as a unified-memory GPU", () => {
|
||||
const s = probeSpecs({
|
||||
osMod: fakeOs({ platform: "darwin", arch: "arm64", ramMB: 24576 }),
|
||||
exec: () => null,
|
||||
});
|
||||
assert.equal(s.appleSilicon, true);
|
||||
assert.equal(s.gpu.present, true);
|
||||
assert.equal(s.gpu.kind, "apple");
|
||||
// unified memory: VRAM tracks system RAM
|
||||
assert.equal(s.gpu.vramMB, 24576);
|
||||
});
|
||||
|
||||
test("NVIDIA GPU is detected via nvidia-smi VRAM query", () => {
|
||||
const exec = (cmd) => (cmd.includes("nvidia-smi") ? "24564" : null);
|
||||
const s = probeSpecs({ osMod: fakeOs({ platform: "linux" }), exec });
|
||||
assert.equal(s.gpu.present, true);
|
||||
assert.equal(s.gpu.kind, "nvidia");
|
||||
assert.equal(s.gpu.vramMB, 24564);
|
||||
});
|
||||
|
||||
test("no GPU when nvidia-smi is absent / fails", () => {
|
||||
const s = probeSpecs({
|
||||
osMod: fakeOs({ platform: "linux" }),
|
||||
exec: () => {
|
||||
throw new Error("command not found");
|
||||
},
|
||||
});
|
||||
assert.equal(s.gpu.present, false);
|
||||
assert.equal(s.gpu.vramMB, 0);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { readGlobalManifest } from "./cache.mjs";
|
||||
import { readManifest } from "./manifest.mjs";
|
||||
import { readMisses } from "./misses.mjs";
|
||||
|
||||
const TOP_MISSES = 5;
|
||||
|
||||
function emptyReport() {
|
||||
return {
|
||||
total_resolves: 0,
|
||||
by_type: {},
|
||||
by_source: {},
|
||||
by_provider: {},
|
||||
by_via: {},
|
||||
misses: 0,
|
||||
hit_rate: null,
|
||||
top_missed_intents: {},
|
||||
global_cache_assets: 0,
|
||||
global_cache_disk_bytes: 0,
|
||||
cross_project_reuse: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function increment(map, key) {
|
||||
if (!key) return;
|
||||
map[key] = (map[key] || 0) + 1;
|
||||
}
|
||||
|
||||
function timestampOf(record) {
|
||||
return record?.ts || record?.timestamp || record?.created_at || record?.createdAt || null;
|
||||
}
|
||||
|
||||
function inWindow(record, cutoff) {
|
||||
if (!cutoff) return true;
|
||||
const ts = timestampOf(record);
|
||||
// Older manifest records may not carry a timestamp; keep them in the report
|
||||
// because --days can only window records/misses that carry a ts/timestamp.
|
||||
if (!ts) return true;
|
||||
const time = Date.parse(ts);
|
||||
return Number.isNaN(time) ? true : time >= cutoff;
|
||||
}
|
||||
|
||||
function sourceOf(record) {
|
||||
return record?._source || record?.source || record?.provenance?.source || "unknown";
|
||||
}
|
||||
|
||||
function normalizeIntent(intent) {
|
||||
return String(intent ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function topMissedIntents(misses) {
|
||||
const grouped = {};
|
||||
for (const miss of misses) {
|
||||
const type = miss?.type || "unknown";
|
||||
const intent = normalizeIntent(miss?.intent);
|
||||
if (!intent) continue;
|
||||
grouped[type] ||= {};
|
||||
grouped[type][intent] = (grouped[type][intent] || 0) + 1;
|
||||
}
|
||||
const out = {};
|
||||
for (const [type, intents] of Object.entries(grouped)) {
|
||||
out[type] = Object.entries(intents)
|
||||
.map(([intent, count]) => ({ intent, count }))
|
||||
.sort((a, b) => b.count - a.count || a.intent.localeCompare(b.intent))
|
||||
.slice(0, TOP_MISSES);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function diskBytes(records) {
|
||||
let total = 0;
|
||||
for (const record of records) {
|
||||
const p = record?.cached_path || record?.path;
|
||||
if (!p) continue;
|
||||
try {
|
||||
total += statSync(p).size;
|
||||
} catch {
|
||||
// cache entries can outlive files; stats skips missing files
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function buildStats({ projectDir, days, now = Date.now() } = {}) {
|
||||
// Only a positive finite --days windows the report; null / NaN / <= 0 mean
|
||||
// "all time" rather than silently excluding everything (a negative cutoff
|
||||
// would land in the future and drop every record). The reads below are each
|
||||
// best-effort (they return [] / skip on IO errors), so there is no top-level
|
||||
// catch masking a real logic bug as an all-zero "no usage" report.
|
||||
const n = Number(days);
|
||||
const cutoff = Number.isFinite(n) && n > 0 ? Number(now) - n * 24 * 60 * 60 * 1000 : null;
|
||||
const records = (projectDir ? readManifest(projectDir) : []).filter((r) => inWindow(r, cutoff));
|
||||
const misses = readMisses().filter((miss) => inWindow(miss, cutoff));
|
||||
const globalRecords = readGlobalManifest();
|
||||
const report = emptyReport();
|
||||
|
||||
report.total_resolves = records.length;
|
||||
report.misses = misses.length;
|
||||
for (const record of records) {
|
||||
increment(report.by_type, record?.type || "unknown");
|
||||
increment(report.by_source, sourceOf(record));
|
||||
increment(report.by_provider, record?.provenance?.provider);
|
||||
increment(report.by_via, record?.provenance?.via);
|
||||
}
|
||||
|
||||
const attempts = report.total_resolves + report.misses;
|
||||
report.hit_rate = attempts === 0 ? null : report.total_resolves / attempts;
|
||||
report.top_missed_intents = topMissedIntents(misses);
|
||||
report.global_cache_assets = globalRecords.length;
|
||||
report.global_cache_disk_bytes = diskBytes(globalRecords);
|
||||
report.cross_project_reuse = globalRecords.filter((r) => r?.provenance?.reused_by).length;
|
||||
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildStats } from "./stats.mjs";
|
||||
|
||||
function sandbox() {
|
||||
const root = mkdtempSync(join(tmpdir(), "mu-stats-"));
|
||||
const home = join(root, "home");
|
||||
const projectDir = join(root, "project");
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
process.env.HOME = home;
|
||||
return { root, home, projectDir };
|
||||
}
|
||||
|
||||
function restoreEnv(saved) {
|
||||
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
|
||||
function seedManifest(dir, records) {
|
||||
mkdirSync(join(dir, ".media"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".media/manifest.jsonl"),
|
||||
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
function isoDaysAgo(days) {
|
||||
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
test("buildStats aggregates resolves, misses, providers, sources, and reuse", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home, projectDir } = sandbox();
|
||||
try {
|
||||
seedManifest(projectDir, [
|
||||
{
|
||||
id: "bgm_001",
|
||||
type: "bgm",
|
||||
source: "search",
|
||||
ts: isoDaysAgo(0),
|
||||
provenance: { provider: "heygen.audio.sounds", prompt: "upbeat", via: "url" },
|
||||
},
|
||||
{
|
||||
id: "image_001",
|
||||
type: "image",
|
||||
_source: "reused-explicit",
|
||||
timestamp: isoDaysAgo(0),
|
||||
provenance: { provider: "local", reused_by: "agent" },
|
||||
},
|
||||
{
|
||||
id: "bgm_002",
|
||||
type: "bgm",
|
||||
source: "generated",
|
||||
provenance: { provider: "codex" },
|
||||
},
|
||||
]);
|
||||
const cachedFile = join(home, ".media/mu-v1-cache/asset.wav");
|
||||
mkdirSync(join(cachedFile, ".."), { recursive: true });
|
||||
writeFileSync(cachedFile, "12345");
|
||||
seedManifest(home, [
|
||||
{
|
||||
id: "bgm_009",
|
||||
type: "bgm",
|
||||
reusable: true,
|
||||
cached_path: cachedFile,
|
||||
provenance: { provider: "heygen.audio.sounds", reused_by: "agent" },
|
||||
},
|
||||
]);
|
||||
writeFileSync(
|
||||
join(home, ".media/misses.jsonl"),
|
||||
JSON.stringify({
|
||||
ts: isoDaysAgo(0),
|
||||
type: "bgm",
|
||||
intent: "dark cinematic riser",
|
||||
}) + "\n",
|
||||
{ flag: "a" },
|
||||
);
|
||||
|
||||
const stats = buildStats({ projectDir });
|
||||
assert.equal(stats.total_resolves, 3);
|
||||
assert.deepEqual(stats.by_type, { bgm: 2, image: 1 });
|
||||
assert.deepEqual(stats.by_source, { search: 1, "reused-explicit": 1, generated: 1 });
|
||||
assert.equal(stats.by_provider["heygen.audio.sounds"], 1);
|
||||
assert.equal(stats.by_via.url, 1);
|
||||
assert.equal(stats.misses, 1);
|
||||
assert.equal(stats.hit_rate, 0.75);
|
||||
assert.deepEqual(stats.top_missed_intents.bgm[0], {
|
||||
intent: "dark cinematic riser",
|
||||
count: 1,
|
||||
});
|
||||
assert.equal(stats.global_cache_assets, 1);
|
||||
assert.equal(stats.global_cache_disk_bytes, 5);
|
||||
assert.equal(stats.cross_project_reuse, 1);
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("buildStats returns a zeroed report when nothing exists", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, projectDir } = sandbox();
|
||||
try {
|
||||
const stats = buildStats({ projectDir });
|
||||
assert.equal(stats.total_resolves, 0);
|
||||
assert.deepEqual(stats.by_type, {});
|
||||
assert.deepEqual(stats.by_source, {});
|
||||
assert.deepEqual(stats.by_provider, {});
|
||||
assert.deepEqual(stats.by_via, {});
|
||||
assert.equal(stats.misses, 0);
|
||||
assert.equal(stats.hit_rate, null);
|
||||
assert.deepEqual(stats.top_missed_intents, {});
|
||||
assert.equal(stats.global_cache_assets, 0);
|
||||
assert.equal(stats.global_cache_disk_bytes, 0);
|
||||
assert.equal(stats.cross_project_reuse, 0);
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("buildStats returns JSON-safe output", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, projectDir } = sandbox();
|
||||
try {
|
||||
const stats = buildStats({ projectDir });
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(stats)), stats);
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("buildStats applies days window to timestamped records and misses", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home, projectDir } = sandbox();
|
||||
try {
|
||||
seedManifest(projectDir, [
|
||||
{ id: "bgm_old", type: "bgm", ts: isoDaysAgo(100), provenance: { provider: "old" } },
|
||||
{ id: "bgm_new", type: "bgm", ts: isoDaysAgo(1), provenance: { provider: "new" } },
|
||||
{ id: "image_untimed", type: "image", provenance: { provider: "none" } },
|
||||
]);
|
||||
mkdirSync(join(home, ".media"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".media/misses.jsonl"),
|
||||
[
|
||||
JSON.stringify({ ts: isoDaysAgo(100), type: "bgm", intent: "old miss" }),
|
||||
JSON.stringify({ ts: isoDaysAgo(1), type: "bgm", intent: "new miss" }),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const stats = buildStats({ projectDir, days: 7 });
|
||||
assert.equal(stats.total_resolves, 2);
|
||||
assert.deepEqual(stats.by_type, { bgm: 1, image: 1 });
|
||||
assert.equal(stats.misses, 1);
|
||||
assert.equal(stats.top_missed_intents.bgm[0].intent, "new miss");
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
// 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.
|
||||
|
||||
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;
|
||||
|
||||
// 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" ||
|
||||
process.env.CI === "1" ||
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.NODE_ENV === "development"
|
||||
);
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
chmodSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { __anonymousIdForTest, __resetTelemetryForTest, optedOut, track } from "./telemetry.mjs";
|
||||
|
||||
function sandbox() {
|
||||
const root = mkdtempSync(join(tmpdir(), "mu-telemetry-"));
|
||||
const home = join(root, "home");
|
||||
mkdirSync(home, { recursive: true });
|
||||
process.env.HOME = home;
|
||||
return { root, home };
|
||||
}
|
||||
|
||||
function restoreEnv(saved) {
|
||||
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
|
||||
function withoutTelemetryOptOut() {
|
||||
for (const k of ["DO_NOT_TRACK", "HYPERFRAMES_NO_TELEMETRY", "CI", "NODE_ENV"])
|
||||
delete process.env[k];
|
||||
}
|
||||
|
||||
function parseFetchBodies(calls) {
|
||||
return calls.flatMap((call) => JSON.parse(call.options.body).batch);
|
||||
}
|
||||
|
||||
test("optedOut respects DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY / CI", () => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
for (const k of ["DO_NOT_TRACK", "HYPERFRAMES_NO_TELEMETRY", "CI", "NODE_ENV"])
|
||||
delete process.env[k];
|
||||
assert.equal(optedOut(), false, "default: tracking allowed");
|
||||
process.env.DO_NOT_TRACK = "1";
|
||||
assert.equal(optedOut(), true, "DO_NOT_TRACK opts out");
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
process.env.HYPERFRAMES_NO_TELEMETRY = "1";
|
||||
assert.equal(optedOut(), true, "HYPERFRAMES_NO_TELEMETRY opts out");
|
||||
delete process.env.HYPERFRAMES_NO_TELEMETRY;
|
||||
process.env.CI = "true";
|
||||
assert.equal(optedOut(), true, "CI opts out");
|
||||
} finally {
|
||||
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test("track is a no-op (no network, resolves) when opted out", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
const { root, home } = sandbox();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (...args) => {
|
||||
calls.push(args);
|
||||
return { ok: true };
|
||||
};
|
||||
process.env.DO_NOT_TRACK = "1";
|
||||
try {
|
||||
// must resolve immediately without throwing or hitting the network
|
||||
await track("media_use_resolve", { type: "bgm", source: "search" });
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(existsSync(join(home, ".hyperframes/config.json")), false);
|
||||
assert.equal(existsSync(join(home, ".media/telemetry-notice-shown")), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("anonymous id uses the shared hyperframes config", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home } = sandbox();
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
mkdirSync(join(home, ".hyperframes"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".hyperframes/config.json"),
|
||||
JSON.stringify({ anonymousId: "shared-install-id", keep: true }),
|
||||
);
|
||||
mkdirSync(join(home, ".media"), { recursive: true });
|
||||
writeFileSync(join(home, ".media/anon-id"), "old-media-id");
|
||||
|
||||
assert.equal(__anonymousIdForTest(), "shared-install-id");
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("anonymous id seeds missing config once and reuses it", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home } = sandbox();
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
const first = __anonymousIdForTest();
|
||||
const configPath = join(home, ".hyperframes/config.json");
|
||||
assert.ok(existsSync(configPath));
|
||||
assert.match(
|
||||
first,
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
const second = __anonymousIdForTest();
|
||||
assert.equal(second, first);
|
||||
assert.equal(JSON.parse(readFileSync(configPath, "utf8")).anonymousId, first);
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("anonymous id adopts a legacy ~/.media/anon-id on upgrade (persona continuity)", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home } = sandbox();
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
mkdirSync(join(home, ".media"), { recursive: true });
|
||||
writeFileSync(join(home, ".media/anon-id"), "legacy-media-id");
|
||||
// no ~/.hyperframes/config.json yet — the old media-use-only id must carry over
|
||||
assert.equal(__anonymousIdForTest(), "legacy-media-id");
|
||||
// and it is persisted into the shared config so CLI/studio see the same id
|
||||
assert.equal(
|
||||
JSON.parse(readFileSync(join(home, ".hyperframes/config.json"), "utf8")).anonymousId,
|
||||
"legacy-media-id",
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("track identifies a signed-in HeyGen account once and still sends events", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
const { root, home } = sandbox();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return { ok: true };
|
||||
};
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
mkdirSync(join(home, ".hyperframes"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".hyperframes/config.json"),
|
||||
JSON.stringify({ anonymousId: "anon-1" }),
|
||||
);
|
||||
mkdirSync(join(home, ".heygen"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".heygen/credentials"),
|
||||
JSON.stringify({ user: { email: "alice@example.com", username: "alice" } }),
|
||||
);
|
||||
|
||||
await track("media_use_resolve", { type: "bgm", source: "search" });
|
||||
await track("media_use_resolve", { type: "image", source: "generated" });
|
||||
|
||||
const batch = parseFetchBodies(calls);
|
||||
const identify = batch.filter((item) => item.event === "$identify");
|
||||
assert.equal(identify.length, 1);
|
||||
assert.equal(identify[0].distinct_id, "alice@example.com");
|
||||
assert.equal(identify[0].properties.$anon_distinct_id, "anon-1");
|
||||
assert.equal(batch.filter((item) => item.event === "media_use_resolve").length, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("track identifies with a lowercased email regardless of stored casing", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
const { root, home } = sandbox();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return { ok: true };
|
||||
};
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
mkdirSync(join(home, ".hyperframes"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".hyperframes/config.json"),
|
||||
JSON.stringify({ anonymousId: "anon-3" }),
|
||||
);
|
||||
mkdirSync(join(home, ".heygen"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".heygen/credentials"),
|
||||
JSON.stringify({ user: { email: "Alice@Example.com", username: "alice" } }),
|
||||
);
|
||||
|
||||
await track("media_use_resolve", { type: "bgm", source: "search" });
|
||||
|
||||
const batch = parseFetchBodies(calls);
|
||||
const identify = batch.filter((item) => item.event === "$identify");
|
||||
assert.equal(identify.length, 1);
|
||||
// Lowercased so this joins with heygen-cli's own identify call regardless
|
||||
// of the account's stored email casing -- otherwise the same person could
|
||||
// split into two PostHog profiles by email case alone.
|
||||
assert.equal(identify[0].distinct_id, "alice@example.com");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("track does not identify when signed out", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
const { root, home } = sandbox();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return { ok: true };
|
||||
};
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
mkdirSync(join(home, ".hyperframes"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".hyperframes/config.json"),
|
||||
JSON.stringify({ anonymousId: "anon-2" }),
|
||||
);
|
||||
|
||||
await track("media_use_resolve", { type: "bgm", source: "search" });
|
||||
|
||||
const batch = parseFetchBodies(calls);
|
||||
assert.equal(
|
||||
batch.some((item) => item.event === "$identify"),
|
||||
false,
|
||||
);
|
||||
assert.equal(batch[0].event, "media_use_resolve");
|
||||
assert.equal(batch[0].distinct_id, "anon-2");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("first run notice prints to stderr once and never stdout", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalError = console.error;
|
||||
const originalLog = console.log;
|
||||
const { root, home } = sandbox();
|
||||
const stderr = [];
|
||||
const stdout = [];
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
console.error = (...args) => stderr.push(args.join(" "));
|
||||
console.log = (...args) => stdout.push(args.join(" "));
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
await track("media_use_resolve", { type: "bgm" });
|
||||
await track("media_use_resolve", { type: "sfx" });
|
||||
|
||||
assert.equal(stderr.length, 1);
|
||||
assert.match(stderr[0], /media-use sends usage telemetry/);
|
||||
assert.equal(stdout.length, 0);
|
||||
// notice-shown lives in the shared config (config.telemetryNoticeShown), so
|
||||
// the CLI and media-use show it once per person — not a media-use-only marker.
|
||||
assert.equal(
|
||||
JSON.parse(readFileSync(join(home, ".hyperframes/config.json"), "utf8")).telemetryNoticeShown,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
console.error = originalError;
|
||||
console.log = originalLog;
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("warns once on stderr when MEDIA_USE_TELEMETRY_HOST is set outside a test/CI context", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalError = console.error;
|
||||
const { root } = sandbox();
|
||||
const stderr = [];
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
console.error = (...args) => stderr.push(args.join(" "));
|
||||
try {
|
||||
// No opt-out, no CI/NODE_ENV signal — mirrors a real user's shell where
|
||||
// this test-only var leaked in by accident and tracking is not disabled.
|
||||
withoutTelemetryOptOut();
|
||||
process.env.MEDIA_USE_TELEMETRY_HOST = "http://127.0.0.1:1"; // nothing listens; irrelevant here
|
||||
await track("media_use_resolve", { type: "bgm" });
|
||||
await track("media_use_resolve", { type: "bgm" });
|
||||
|
||||
const warnings = stderr.filter((line) => line.includes("MEDIA_USE_TELEMETRY_HOST"));
|
||||
assert.equal(warnings.length, 1, "expected exactly one warning across repeated calls");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
console.error = originalError;
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not warn when MEDIA_USE_TELEMETRY_HOST is set the way the U7 interception test sets it", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalError = console.error;
|
||||
const { root } = sandbox();
|
||||
const stderr = [];
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
console.error = (...args) => stderr.push(args.join(" "));
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
// Same env shape resolve.test.mjs's U7 test uses to allow tracking through:
|
||||
// DO_NOT_TRACK=0, CI unset/empty, NODE_ENV=test.
|
||||
process.env.DO_NOT_TRACK = "0";
|
||||
process.env.CI = "";
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.MEDIA_USE_TELEMETRY_HOST = "http://127.0.0.1:1";
|
||||
await track("media_use_resolve", { type: "bgm" });
|
||||
|
||||
assert.equal(
|
||||
stderr.some((line) => line.includes("MEDIA_USE_TELEMETRY_HOST")),
|
||||
false,
|
||||
"the U7 test's own use of the override must not print a spurious warning",
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
console.error = originalError;
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("read-only telemetry state degrades without throwing", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
const { root, home } = sandbox();
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
writeFileSync(join(home, ".hyperframes"), "not a directory");
|
||||
writeFileSync(join(home, ".media"), "not a directory");
|
||||
await track("media_use_resolve", { type: "bgm" });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
try {
|
||||
chmodSync(join(home, ".hyperframes"), 0o600);
|
||||
} catch {
|
||||
// best effort for cleanup on platforms with different chmod behavior
|
||||
}
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Local voiceover via the packaged Kokoro-82M TTS (the `hyperframes tts` CLI),
|
||||
// the free/private default now that HeyGen TTS costs wallet credits. Kokoro runs
|
||||
// on-device (CPU, faster-than-realtime, bundled voices, native word timestamps),
|
||||
// so no key and no per-call charge. When Kokoro is not set up, this returns null
|
||||
// and the registry falls through to the HeyGen TTS upsell.
|
||||
//
|
||||
// Delegated to the hyperframes CLI (same as transcribe / remove-background), not
|
||||
// re-implemented here. ffprobe reads the duration back for the ledger.
|
||||
|
||||
function probeDurationSeconds(file) {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"ffprobe",
|
||||
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file],
|
||||
{ encoding: "utf8", timeout: 15000 },
|
||||
);
|
||||
const d = parseFloat(String(out).trim());
|
||||
return Number.isFinite(d) ? d : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function localTtsGenerate(intent, ctx) {
|
||||
const outPath = join(tmpdir(), `media-use-kokoro-${process.pid}-${Date.now()}.wav`);
|
||||
const argv = ["hyperframes", "tts", intent, "--output", outPath];
|
||||
if (ctx?.voice) argv.push("--voice", ctx.voice);
|
||||
if (ctx?.lang && ctx.lang !== "en") argv.push("--lang", ctx.lang);
|
||||
try {
|
||||
execFileSync("npx", argv, {
|
||||
encoding: "utf8",
|
||||
timeout: 300000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
// `hyperframes tts` prints its "kokoro-onnx not installed" hint to stdout
|
||||
// (clack UI), so read both streams and surface the actionable enable-command
|
||||
// rather than a bare "Command failed": otherwise resolve silently falls
|
||||
// through to the PAID HeyGen TTS upsell when free local voice was one pip away.
|
||||
const out = `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim();
|
||||
const hint = /not installed|pip install kokoro/i.test(out)
|
||||
? "install for free on-device voice: pip install kokoro-onnx soundfile (or set HYPERFRAMES_PYTHON to a venv that has it)"
|
||||
: out.slice(-200) || err.message;
|
||||
console.error(`media-use: local voice not enabled (kokoro). ${hint}`);
|
||||
return null;
|
||||
}
|
||||
if (!existsSync(outPath) || statSync(outPath).size === 0) return null;
|
||||
return {
|
||||
localPath: outPath,
|
||||
ext: ".wav",
|
||||
source: "generated",
|
||||
metadata: {
|
||||
description: intent,
|
||||
provider: "kokoro.local",
|
||||
duration: probeDurationSeconds(outPath),
|
||||
provenance: { engine: "kokoro-82m", prompt: intent },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Asset in-use detection: which manifest assets are actually referenced by the
|
||||
// project's compositions. Answers "is this safe to prune?" from the CLI. (The
|
||||
// Studio Asset tab computes its in-use filter separately from the live timeline;
|
||||
// this is the skill-side equivalent for headless use.)
|
||||
//
|
||||
// ponytail: substring match of each asset's filename against the .html text
|
||||
// (covers src= / href= / url() / data-* without parsing HTML). False positives
|
||||
// only if a filename literally appears in prose; fine for a filter. Upgrade to
|
||||
// attribute parsing if that ever bites.
|
||||
|
||||
function compositionHtml(projectDir) {
|
||||
let files = [];
|
||||
try {
|
||||
files = readdirSync(projectDir).filter((f) => f.endsWith(".html"));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
const sub = join(projectDir, "compositions");
|
||||
if (existsSync(sub)) {
|
||||
try {
|
||||
for (const f of readdirSync(sub))
|
||||
if (f.endsWith(".html")) files.push(join("compositions", f));
|
||||
} catch {
|
||||
// ignore unreadable subdir
|
||||
}
|
||||
}
|
||||
return files
|
||||
.map((f) => {
|
||||
try {
|
||||
return readFileSync(join(projectDir, f), "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Tag each record with `inUse` (referenced by some composition). */
|
||||
export function tagUsage(records, projectDir) {
|
||||
const html = compositionHtml(projectDir);
|
||||
return records.map((r) => {
|
||||
const file = r.path ? r.path.split("/").pop() : r.id;
|
||||
return { ...r, inUse: Boolean(file) && html.includes(file) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Split records into { used, unused } for the filter. */
|
||||
export function partitionUsage(records, projectDir) {
|
||||
const tagged = tagUsage(records, projectDir);
|
||||
return {
|
||||
used: tagged.filter((r) => r.inUse),
|
||||
unused: tagged.filter((r) => !r.inUse),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { tagUsage, partitionUsage } from "./usage.mjs";
|
||||
|
||||
function project(html) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mu-usage-"));
|
||||
writeFileSync(join(dir, "index.html"), html);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const records = [
|
||||
{ id: "bgm_001", path: ".media/audio/bgm/bgm_001.wav", description: "used track" },
|
||||
{ id: "bgm_002", path: ".media/audio/bgm/bgm_002.wav", description: "orphan track" },
|
||||
];
|
||||
|
||||
test("an asset referenced by a composition is marked in-use", () => {
|
||||
const dir = project(`<audio src="assets/bgm_001.wav"></audio>`);
|
||||
try {
|
||||
const tagged = tagUsage(records, dir);
|
||||
assert.equal(tagged.find((r) => r.id === "bgm_001").inUse, true);
|
||||
assert.equal(tagged.find((r) => r.id === "bgm_002").inUse, false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("partitionUsage splits used vs unused for the filter", () => {
|
||||
const dir = project(`<img src="bgm_001.wav">`);
|
||||
try {
|
||||
const { used, unused } = partitionUsage(records, dir);
|
||||
assert.deepEqual(
|
||||
used.map((r) => r.id),
|
||||
["bgm_001"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
unused.map((r) => r.id),
|
||||
["bgm_002"],
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("no compositions -> everything reads as unused (safe default)", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mu-usage-"));
|
||||
try {
|
||||
assert.equal(partitionUsage(records, dir).used.length, 0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { reportHeygenFailure } from "./heygen-cli.mjs";
|
||||
|
||||
// Voice / TTS generation via the HeyGen CLI — the only external CLI media-use
|
||||
// shells (CLI-only invariant: media-use holds no keys; the CLI owns auth).
|
||||
// Flags verified against `heygen voice speech create --help` (v0.3.0).
|
||||
|
||||
function runJson(bin, argv, label) {
|
||||
let out;
|
||||
try {
|
||||
out = execFileSync(bin, argv, {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
reportHeygenFailure(err, `${bin} ${label}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(out);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function result(url, duration, provider, intent) {
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
source: "generated",
|
||||
metadata: {
|
||||
description: intent,
|
||||
provider,
|
||||
...(duration != null && { duration }),
|
||||
provenance: { prompt: intent },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// HeyGen TTS requires a starfish-engine voice. Default to the first one the
|
||||
// catalog returns (deterministic order); pass ctx.voiceId to override.
|
||||
// ponytail: listed once per process; the resolved asset is frozen + cached after
|
||||
// first use, so the network list only happens on a cache miss.
|
||||
let cachedVoiceId;
|
||||
function defaultVoiceId() {
|
||||
if (cachedVoiceId !== undefined) return cachedVoiceId;
|
||||
const j = runJson(
|
||||
"heygen",
|
||||
["voice", "list", "--engine", "starfish", "--limit", "1"],
|
||||
"voice list",
|
||||
);
|
||||
cachedVoiceId = j?.data?.[0]?.voice_id || null;
|
||||
return cachedVoiceId;
|
||||
}
|
||||
|
||||
export async function heygenTtsGenerate(intent, ctx) {
|
||||
const voiceId = ctx?.voiceId || defaultVoiceId();
|
||||
if (!voiceId) return null;
|
||||
const p = runJson(
|
||||
"heygen",
|
||||
["voice", "speech", "create", "--text", intent, "--voice-id", voiceId],
|
||||
"tts",
|
||||
);
|
||||
return result(p?.data?.audio_url, p?.data?.duration, "heygen.tts", intent);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,883 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
chmodSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createServer } from "node:http";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { appendRecord, readManifest } from "./lib/manifest.mjs";
|
||||
import { regenerateIndex } from "./lib/index-gen.mjs";
|
||||
import { getProvider } from "./lib/providers.mjs";
|
||||
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 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.
|
||||
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0;
|
||||
// The core-conformance test imports core's TypeScript via tsx. The dependency-free
|
||||
// "Test: skills" CI job has neither tsx nor installed deps, so skip it there; it
|
||||
// runs wherever the workspace is installed (locally, the main Test job).
|
||||
const CAN_TSX =
|
||||
spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", "0"], {
|
||||
stdio: "ignore",
|
||||
}).status === 0;
|
||||
let tmp;
|
||||
|
||||
function setup() {
|
||||
tmp = mkdtempSync(join(tmpdir(), "mu-resolve-test-"));
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeRecord(overrides = {}) {
|
||||
return {
|
||||
id: "bgm_001",
|
||||
type: "bgm",
|
||||
path: ".media/audio/bgm/bgm_001.wav",
|
||||
source: "search",
|
||||
description: "soft minimal ambient",
|
||||
duration: 11,
|
||||
provenance: { provider: "test", prompt: "test prompt" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Run resolve.mjs with argv passed as a literal array (no shell). Each token is
|
||||
// a separate argv entry, so a value with spaces or shell metacharacters can't
|
||||
// break out — never build a command string and hand it to a shell.
|
||||
function runResolve(args, opts = {}) {
|
||||
const { env, ...rest } = opts;
|
||||
return execFileSync(process.execPath, [RESOLVE_CLI, ...args], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, DO_NOT_TRACK: "1", ...env },
|
||||
...rest,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnResolve(args, opts = {}) {
|
||||
const { env, ...rest } = opts;
|
||||
return spawnSync(process.execPath, [RESOLVE_CLI, ...args], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, DO_NOT_TRACK: "1", ...env },
|
||||
...rest,
|
||||
});
|
||||
}
|
||||
|
||||
function makeFrame(dir, name, color) {
|
||||
const out = join(dir, name);
|
||||
execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`color=c=${color}:s=64x64`,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-y",
|
||||
out,
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeWithCoreSource(grading) {
|
||||
const sourcePath = join(REPO_ROOT, "packages/core/src/colorGrading.ts");
|
||||
const code = `
|
||||
import { normalizeHfColorGrading } from ${JSON.stringify(sourcePath)};
|
||||
const grading = JSON.parse(process.env.HF_GRADING_JSON);
|
||||
const normalized = normalizeHfColorGrading(grading);
|
||||
if (!normalized) process.exit(2);
|
||||
console.log(JSON.stringify({
|
||||
preset: normalized.preset,
|
||||
intensity: normalized.intensity,
|
||||
adjust: normalized.adjust,
|
||||
lut: normalized.lut,
|
||||
colorSpace: normalized.colorSpace
|
||||
}));
|
||||
`;
|
||||
return JSON.parse(
|
||||
execFileSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", code], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, HF_GRADING_JSON: JSON.stringify(grading) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const tests = [];
|
||||
function test(name, fn) {
|
||||
tests.push({ name, fn });
|
||||
}
|
||||
|
||||
// --- manifest cache hit ---
|
||||
|
||||
test("bundled SFX resolve without HeyGen on PATH", () => {
|
||||
setup();
|
||||
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
|
||||
env: { HOME: tmp, PATH: tmp },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.provenance.provider, "bundled.sfx");
|
||||
assert.match(parsed.advisory?.message ?? "", /Install: curl -fsSL/);
|
||||
assert.ok(existsSync(join(tmp, parsed.path)));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function writeFakeHeygen(body, exitCode = 0) {
|
||||
const binDir = join(tmp, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const command = join(binDir, "heygen");
|
||||
writeFileSync(command, `#!/bin/sh\n${body}\nexit ${exitCode}\n`);
|
||||
chmodSync(command, 0o755);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
test("bundled SFX advises update when the HeyGen CLI is outdated", () => {
|
||||
setup();
|
||||
const binDir = writeFakeHeygen('echo "heygen v0.1.5 does not support --headers" >&2', 1);
|
||||
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
|
||||
env: { HOME: tmp, PATH: binDir },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.provenance.provider, "bundled.sfx");
|
||||
assert.match(parsed.advisory?.message ?? "", /heygen update/);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("bundled SFX does not advise installation after a healthy catalog miss", () => {
|
||||
setup();
|
||||
const binDir = writeFakeHeygen(`echo '{"data":[]}'`);
|
||||
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
|
||||
env: { HOME: tmp, PATH: binDir },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.provenance.provider, "bundled.sfx");
|
||||
assert.equal(parsed.advisory, undefined);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("explicit local bundled SFX resolution does not advise installation", () => {
|
||||
for (const extraArgs of [["--local-only"], ["--provider", "bundled.sfx"]]) {
|
||||
setup();
|
||||
const result = spawnResolve(
|
||||
["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json", ...extraArgs],
|
||||
{ env: { HOME: tmp, PATH: tmp } },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.provenance.provider, "bundled.sfx");
|
||||
assert.equal(parsed.advisory, undefined);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("human bundled fallback prints the install hint once", () => {
|
||||
setup();
|
||||
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp], {
|
||||
env: { HOME: tmp, PATH: tmp },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr.match(/Install: curl -fsSL/g)?.length, 1);
|
||||
assert.match(result.stdout, /resolved sfx_001/);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("project manifest hit skips providers", () => {
|
||||
setup();
|
||||
const record = makeRecord({ provenance: { prompt: "cached query", provider: "test" } });
|
||||
appendRecord(tmp, record);
|
||||
const filePath = join(tmp, record.path);
|
||||
mkdirSync(join(filePath, ".."), { recursive: true });
|
||||
writeFileSync(filePath, "cached audio");
|
||||
|
||||
const out = runResolve(["--type", "bgm", "--intent", "cached query", "--project", tmp, "--json"]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.id, "bgm_001");
|
||||
assert.equal(parsed._source, "cached");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("entity hit matches across icon/image (figma-imported brand marks)", () => {
|
||||
setup();
|
||||
const record = makeRecord({
|
||||
id: "image_001",
|
||||
type: "image",
|
||||
path: ".media/images/image_001.svg",
|
||||
description: "Acme logo",
|
||||
entity: "Acme logo",
|
||||
provenance: { source: "figma", fileKey: "KEY", nodeId: "1:2", version: "1", format: "svg" },
|
||||
});
|
||||
delete record.duration;
|
||||
appendRecord(tmp, record);
|
||||
const filePath = join(tmp, record.path);
|
||||
mkdirSync(join(filePath, ".."), { recursive: true });
|
||||
writeFileSync(filePath, "<svg/>");
|
||||
|
||||
const out = runResolve([
|
||||
"--type",
|
||||
"icon",
|
||||
"--intent",
|
||||
"acme brand mark",
|
||||
"--entity",
|
||||
"Acme logo",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.id, "image_001");
|
||||
assert.equal(parsed._source, "cached");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- auth_method provenance (U6) ---
|
||||
|
||||
test("manifest hit for an OAuth-credentialed heygen resolve surfaces authMethod: oauth", () => {
|
||||
setup();
|
||||
const record = makeRecord({
|
||||
id: "voice_001",
|
||||
type: "voice",
|
||||
path: ".media/audio/voice/voice_001.wav",
|
||||
provenance: { provider: "heygen.tts", authMethod: "oauth", prompt: "oauth voice" },
|
||||
});
|
||||
appendRecord(tmp, record);
|
||||
const filePath = join(tmp, record.path);
|
||||
mkdirSync(join(filePath, ".."), { recursive: true });
|
||||
writeFileSync(filePath, "cached voice");
|
||||
|
||||
const out = runResolve([
|
||||
"--type",
|
||||
"voice",
|
||||
"--intent",
|
||||
"oauth voice",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.provenance.authMethod, "oauth");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("manifest hit for an API-key-credentialed heygen resolve surfaces authMethod: api_key", () => {
|
||||
setup();
|
||||
const record = makeRecord({
|
||||
id: "voice_001",
|
||||
type: "voice",
|
||||
path: ".media/audio/voice/voice_001.wav",
|
||||
provenance: { provider: "heygen.tts", authMethod: "api_key", prompt: "api key voice" },
|
||||
});
|
||||
appendRecord(tmp, record);
|
||||
const filePath = join(tmp, record.path);
|
||||
mkdirSync(join(filePath, ".."), { recursive: true });
|
||||
writeFileSync(filePath, "cached voice");
|
||||
|
||||
const out = runResolve([
|
||||
"--type",
|
||||
"voice",
|
||||
"--intent",
|
||||
"api key voice",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.provenance.authMethod, "api_key");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("manifest hit for a non-heygen provider omits authMethod entirely", () => {
|
||||
setup();
|
||||
const record = makeRecord({
|
||||
id: "logo_001",
|
||||
type: "logo",
|
||||
path: ".media/images/logo_001.svg",
|
||||
provenance: { provider: "svgl", prompt: "acme logo" },
|
||||
});
|
||||
appendRecord(tmp, record);
|
||||
const filePath = join(tmp, record.path);
|
||||
mkdirSync(join(filePath, ".."), { recursive: true });
|
||||
writeFileSync(filePath, "<svg/>");
|
||||
|
||||
const out = runResolve(["--type", "logo", "--intent", "acme logo", "--project", tmp, "--json"]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("authMethod" in parsed.provenance, false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- global cache hit ---
|
||||
|
||||
test("global cache hit copies to project and registers", () => {
|
||||
setup();
|
||||
const sourceFile = join(tmp, "source.wav");
|
||||
writeFileSync(sourceFile, "cached globally for resolve");
|
||||
const record = makeRecord({ provenance: { prompt: "global resolve test" } });
|
||||
cachePut(sourceFile, record);
|
||||
|
||||
const cached = cacheGet("global resolve test", "bgm");
|
||||
assert.ok(cached);
|
||||
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "mu-resolve-proj-"));
|
||||
const imported = importFromCache(cached, projectDir, "bgm_001", ".media/audio/bgm/bgm_001.wav");
|
||||
assert.ok(imported);
|
||||
assert.ok(existsSync(join(projectDir, ".media/audio/bgm/bgm_001.wav")));
|
||||
|
||||
appendRecord(projectDir, imported);
|
||||
regenerateIndex(projectDir);
|
||||
const manifest = readManifest(projectDir);
|
||||
assert.equal(manifest.length, 1);
|
||||
assert.equal(manifest[0].provenance.imported_from, cached.sha);
|
||||
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- provider interface ---
|
||||
|
||||
test("getProvider returns provider with type", () => {
|
||||
const p = getProvider("bgm");
|
||||
assert.equal(p.type, "bgm");
|
||||
assert.ok(typeof p.search === "function");
|
||||
});
|
||||
|
||||
test("getProvider throws for unknown type", () => {
|
||||
assert.throws(() => getProvider("unknown_type"), /unknown media type/);
|
||||
});
|
||||
|
||||
// --- freeze ---
|
||||
|
||||
test("freezeLocalFile creates parent dirs and copies", () => {
|
||||
setup();
|
||||
const src = join(tmp, "src.bin");
|
||||
writeFileSync(src, "freeze test data");
|
||||
const dest = join(tmp, "deep/nested/dir/file.bin");
|
||||
freezeLocalFile(src, dest);
|
||||
assert.ok(existsSync(dest));
|
||||
assert.equal(readFileSync(dest, "utf8"), "freeze test data");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- adopt existing assets ---
|
||||
|
||||
test("--adopt registers existing assets/ files", () => {
|
||||
setup();
|
||||
mkdirSync(join(tmp, "assets/bgm"), { recursive: true });
|
||||
mkdirSync(join(tmp, "assets/icons"), { recursive: true });
|
||||
writeFileSync(join(tmp, "assets/bgm/track.mp3"), "fake mp3");
|
||||
writeFileSync(join(tmp, "assets/icons/logo.svg"), "fake svg");
|
||||
|
||||
const out = runResolve(["--adopt", "--project", tmp, "--json"]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.adopted, 2);
|
||||
assert.ok(parsed.assets.some((a) => a.path === "assets/bgm/track.mp3"));
|
||||
assert.ok(parsed.assets.some((a) => a.path === "assets/icons/logo.svg"));
|
||||
|
||||
const manifest = readManifest(tmp);
|
||||
assert.equal(manifest.length, 2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("--adopt skips already-registered assets", () => {
|
||||
setup();
|
||||
mkdirSync(join(tmp, "assets/bgm"), { recursive: true });
|
||||
writeFileSync(join(tmp, "assets/bgm/track.mp3"), "fake mp3");
|
||||
|
||||
runResolve(["--adopt", "--project", tmp, "--json"]);
|
||||
const out = runResolve(["--adopt", "--project", tmp, "--json"]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.adopted, 0);
|
||||
|
||||
const manifest = readManifest(tmp);
|
||||
assert.equal(manifest.length, 1);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("resolve finds existing unregistered asset before hitting providers", () => {
|
||||
setup();
|
||||
mkdirSync(join(tmp, "assets/bgm"), { recursive: true });
|
||||
writeFileSync(join(tmp, "assets/bgm/ambient-track.mp3"), "existing bgm");
|
||||
|
||||
const out = runResolve([
|
||||
"--type",
|
||||
"bgm",
|
||||
"--intent",
|
||||
"ambient track",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.path, "assets/bgm/ambient-track.mp3");
|
||||
assert.equal(parsed._source, "existing");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- CLI interface ---
|
||||
|
||||
test("--help exits 0", () => {
|
||||
const out = runResolve(["--help"]);
|
||||
assert.ok(out.includes("media-use resolve"));
|
||||
assert.ok(out.includes("--type"));
|
||||
assert.ok(out.includes("--for"));
|
||||
assert.ok(out.includes("--from"));
|
||||
assert.ok(out.includes("--local-only"));
|
||||
assert.ok(out.includes("--stats"));
|
||||
});
|
||||
|
||||
test("--from registers a derived video as documented", () => {
|
||||
setup();
|
||||
const source = join(tmp, "derived.mp4");
|
||||
writeFileSync(source, "derived video bytes");
|
||||
|
||||
const out = runResolve(["--from", source, "--type", "video", "--project", tmp, "--json"]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.type, "video");
|
||||
assert.match(parsed.path, /^\.media\/video\/video_001\.mp4$/);
|
||||
assert.equal(readManifest(tmp)[0]?.type, "video");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("unknown type error lists grade and lut", () => {
|
||||
try {
|
||||
runResolve(["--type", "bogus", "--intent", "x"], { stdio: "pipe" });
|
||||
assert.fail("should have exited");
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 2);
|
||||
assert.match(String(err.stderr), /known: .*grade.*lut/);
|
||||
}
|
||||
});
|
||||
|
||||
test("missing required args exits 2", () => {
|
||||
try {
|
||||
runResolve([], { stdio: "pipe" });
|
||||
assert.fail("should have exited");
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("--json returns error JSON on stub provider failure", () => {
|
||||
setup();
|
||||
try {
|
||||
runResolve(["--type", "bgm", "--intent", "stub fail", "--project", tmp, "--json"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
assert.fail("should have exited");
|
||||
} catch (err) {
|
||||
const output = err.stdout || "";
|
||||
const parsed = JSON.parse(output.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.ok(parsed.error.includes("no provider"));
|
||||
}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("--doctor --json reports dependency checks and top-level ok requires ffmpeg and ffprobe", () => {
|
||||
const result = spawnResolve(["--doctor", "--json"]);
|
||||
assert.match(result.stdout.trim(), /^\{/);
|
||||
assert.equal(result.stderr, "");
|
||||
assert.ok(result.status === 0 || result.status === 1);
|
||||
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
assert.ok(Array.isArray(parsed.checks));
|
||||
|
||||
const expected = [
|
||||
"heygen on PATH",
|
||||
"heygen version",
|
||||
"heygen authenticated",
|
||||
"ffmpeg on PATH",
|
||||
"ffprobe on PATH",
|
||||
"node version",
|
||||
];
|
||||
const byName = new Map(parsed.checks.map((check) => [check.name, check]));
|
||||
for (const name of expected) {
|
||||
assert.ok(byName.has(name), `missing check: ${name}`);
|
||||
const check = byName.get(name);
|
||||
assert.equal(typeof check.ok, "boolean", `${name}.ok`);
|
||||
assert.equal(typeof check.detail, "string", `${name}.detail`);
|
||||
assert.ok("fix" in check, `${name}.fix`);
|
||||
}
|
||||
|
||||
const ffmpeg = byName.get("ffmpeg on PATH");
|
||||
const ffprobe = byName.get("ffprobe on PATH");
|
||||
const strictOk = ffmpeg.ok && ffprobe.ok;
|
||||
assert.equal(parsed.ok, strictOk);
|
||||
assert.equal(result.status, strictOk ? 0 : 1);
|
||||
});
|
||||
|
||||
test("one-line output format matches contract", () => {
|
||||
setup();
|
||||
const record = makeRecord({ provenance: { prompt: "format test", provider: "test" } });
|
||||
appendRecord(tmp, record);
|
||||
const filePath = join(tmp, record.path);
|
||||
mkdirSync(join(filePath, ".."), { recursive: true });
|
||||
writeFileSync(filePath, "format check");
|
||||
|
||||
const out = runResolve(["--type", "bgm", "--intent", "format test", "--project", tmp]);
|
||||
assert.match(out.trim(), /^resolved bgm_001 → .media\/audio\/bgm\/bgm_001\.wav \(bgm/);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- color grading ---
|
||||
|
||||
test("grade resolves a preset-only look with no cube file", () => {
|
||||
setup();
|
||||
const out = runResolve([
|
||||
"--type",
|
||||
"grade",
|
||||
"--intent",
|
||||
"warm daylight",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.type, "grade");
|
||||
assert.equal(parsed.grading.preset, "warm-daylight");
|
||||
assert.equal(parsed.grading.lut, undefined);
|
||||
assert.equal(parsed.path, undefined);
|
||||
assert.equal(readManifest(tmp).length, 1);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("grade resolves a library LUT look and freezes a validated cube", () => {
|
||||
setup();
|
||||
const out = runResolve([
|
||||
"--type",
|
||||
"grade",
|
||||
"--intent",
|
||||
"teal orange blockbuster",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.match(parsed.grading.lut.src, /^\.media\/luts\/grade_001\.cube$/);
|
||||
assert.equal(parsed.path, parsed.grading.lut.src);
|
||||
assert.ok(existsSync(join(tmp, parsed.grading.lut.src)));
|
||||
assert.equal(validateCubeFile(join(tmp, parsed.grading.lut.src)).ok, true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("smart grade merges measured adjust and keeps stdout valid JSON", () => {
|
||||
if (!HAS_FFMPEG) {
|
||||
console.log(" (skipped: ffmpeg not on PATH)");
|
||||
return;
|
||||
}
|
||||
setup();
|
||||
const frame = makeFrame(tmp, "under.png", "0x202020");
|
||||
const proc = spawnResolve([
|
||||
"--type",
|
||||
"grade",
|
||||
"--intent",
|
||||
"warm cinematic",
|
||||
"--for",
|
||||
frame,
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
assert.equal(proc.status, 0, proc.stderr);
|
||||
const parsed = JSON.parse(proc.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.ok(parsed.grading.adjust.exposure > 0, "under-exposed frame should suggest lift");
|
||||
assert.match(proc.stderr, /media-use: measured/);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("emitted grading block survives the core normalizeHfColorGrading contract", () => {
|
||||
if (!CAN_TSX) {
|
||||
console.log(" (skipped: tsx / core source unavailable)");
|
||||
return;
|
||||
}
|
||||
setup();
|
||||
const out = runResolve([
|
||||
"--type",
|
||||
"grade",
|
||||
"--intent",
|
||||
"teal orange blockbuster",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
const normalized = normalizeWithCoreSource(parsed.grading);
|
||||
assert.equal(normalized.lut.src, parsed.grading.lut.src);
|
||||
assert.equal(normalized.lut.intensity, parsed.grading.lut.intensity);
|
||||
assert.equal(normalized.colorSpace, "rec709");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("lut resolves only the frozen cube path", () => {
|
||||
setup();
|
||||
const out = runResolve([
|
||||
"--type",
|
||||
"lut",
|
||||
"--intent",
|
||||
"teal orange blockbuster",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.type, "lut");
|
||||
assert.match(parsed.path, /^\.media\/luts\/lut_001\.cube$/);
|
||||
assert.equal(parsed.grading, undefined);
|
||||
assert.equal(validateCubeFile(join(tmp, parsed.path)).ok, true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("lut --params builds, validates, and freezes a cube", () => {
|
||||
setup();
|
||||
const params = { contrast: 0.2, temperature: -0.3 };
|
||||
const out = runResolve(["-t", "lut", "--params", JSON.stringify(params), "-p", tmp, "--json"]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.type, "lut");
|
||||
assert.match(parsed.path, /^\.media\/luts\/lut_001\.cube$/);
|
||||
assert.equal(parsed.description, "custom parametric lut");
|
||||
assert.equal(parsed.provenance.provider, "cube_lut.builder");
|
||||
assert.deepEqual(parsed.provenance.params, params);
|
||||
assert.ok(existsSync(join(tmp, parsed.path)));
|
||||
assert.equal(validateCubeFile(join(tmp, parsed.path)).ok, true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("grade --params returns a grading block with a frozen valid cube", () => {
|
||||
setup();
|
||||
const out = runResolve([
|
||||
"-t",
|
||||
"grade",
|
||||
"--params",
|
||||
JSON.stringify({ exposure: 0.2 }),
|
||||
"-p",
|
||||
tmp,
|
||||
"--json",
|
||||
]);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.type, "grade");
|
||||
assert.equal(parsed.grading.intensity, 1);
|
||||
assert.match(parsed.grading.lut.src, /^\.media\/luts\/grade_001\.cube$/);
|
||||
assert.equal(parsed.lut.src, parsed.grading.lut.src);
|
||||
assert.equal(parsed.path, parsed.grading.lut.src);
|
||||
assert.equal(validateCubeFile(join(tmp, parsed.grading.lut.src)).ok, true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("--params malformed JSON errors cleanly without freezing a cube", () => {
|
||||
setup();
|
||||
const proc = spawnResolve(["-t", "lut", "--params", "{not json", "-p", tmp, "--json"]);
|
||||
assert.equal(proc.status, 1, proc.stderr);
|
||||
const parsed = JSON.parse(proc.stdout);
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /^invalid --params JSON:/);
|
||||
assert.equal(readManifest(tmp).length, 0);
|
||||
assert.equal(existsSync(join(tmp, ".media/luts")), false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// buildCube clamps every accepted parameter and resolve.mjs does not expose
|
||||
// the size argument, so there is no CLI input that can make --params emit a
|
||||
// structurally invalid cube. Invalid cube cleanup is covered through --from.
|
||||
test("--from rejects invalid lut cube without registering or leaving a frozen file", () => {
|
||||
setup();
|
||||
const broken = join(tmp, "broken.cube");
|
||||
writeFileSync(broken, "LUT_3D_SIZE 999\n");
|
||||
const proc = spawnResolve(["--from", broken, "-t", "lut", "-p", tmp, "--json"]);
|
||||
assert.equal(proc.status, 1, proc.stderr);
|
||||
const parsed = JSON.parse(proc.stdout);
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /^ingested LUT is invalid: LUT_3D_SIZE 999 exceeds max 64/);
|
||||
assert.equal(readManifest(tmp).length, 0);
|
||||
const lutDir = join(tmp, ".media/luts");
|
||||
assert.deepEqual(existsSync(lutDir) ? readdirSync(lutDir) : [], []);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("grade miss exits explicitly with no partial file", () => {
|
||||
setup();
|
||||
const missIntent = `zqxv imaginary neutron ${process.pid}`;
|
||||
try {
|
||||
runResolve(["--type", "grade", "--intent", missIntent, "--project", tmp, "--json"]);
|
||||
assert.fail("should have exited");
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 1);
|
||||
const parsed = JSON.parse(String(err.stdout));
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /no local color grade could resolve/);
|
||||
assert.equal(readManifest(tmp).length, 0);
|
||||
assert.equal(existsSync(join(tmp, ".media/luts")), false);
|
||||
}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("identical grade resolve hits the project cache without re-freezing", () => {
|
||||
setup();
|
||||
const first = JSON.parse(
|
||||
runResolve([
|
||||
"--type",
|
||||
"grade",
|
||||
"--intent",
|
||||
"teal orange blockbuster",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]),
|
||||
);
|
||||
const second = JSON.parse(
|
||||
runResolve([
|
||||
"--type",
|
||||
"grade",
|
||||
"--intent",
|
||||
"teal orange blockbuster",
|
||||
"--project",
|
||||
tmp,
|
||||
"--json",
|
||||
]),
|
||||
);
|
||||
assert.equal(second._source, "cached");
|
||||
assert.equal(second.id, first.id);
|
||||
assert.equal(second.path, first.path);
|
||||
assert.equal(readManifest(tmp).length, 1);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- telemetry isolation (U7) ---
|
||||
|
||||
// Every other test relies on runResolve/spawnResolve's default DO_NOT_TRACK:
|
||||
// "1" to keep track() a no-op. That default is fragile on its own (a future
|
||||
// call site or test could forget to set it), so telemetry.mjs also exposes a
|
||||
// MEDIA_USE_TELEMETRY_HOST override read at the point the POST URL is built.
|
||||
// This test proves that seam actually intercepts a real event end to end: a
|
||||
// resolve that reaches track("media_use_resolve", ...) with tracking allowed
|
||||
// posts to a local HTTP server instead of production, and the server actually
|
||||
// receives it (not just "nothing happened because nothing was listening").
|
||||
test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real interception", async () => {
|
||||
setup();
|
||||
const received = [];
|
||||
const server = createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => (body += chunk));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
received.push(JSON.parse(body));
|
||||
} catch {
|
||||
// ignore malformed body; assertions below fail on empty `received`
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end("{}");
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
const sandboxHome = mkdtempSync(join(tmpdir(), "mu-resolve-telemetry-home-"));
|
||||
|
||||
try {
|
||||
const record = makeRecord({
|
||||
provenance: { prompt: "telemetry seam test", provider: "test" },
|
||||
});
|
||||
appendRecord(tmp, record);
|
||||
const filePath = join(tmp, record.path);
|
||||
mkdirSync(join(filePath, ".."), { recursive: true });
|
||||
writeFileSync(filePath, "telemetry seam audio");
|
||||
|
||||
// Override this one invocation's env only: allow tracking (DO_NOT_TRACK
|
||||
// default flipped off), sandbox HOME so anonymousId()/showTelemetryNotice()
|
||||
// never touch the real developer machine, and point the host at the local
|
||||
// server. HEYGEN_CONFIG_DIR is sandboxed too -- runResolve's env is
|
||||
// {...process.env, ...env}, so a developer with that var set to a real
|
||||
// credentials dir would otherwise have heygenAccountDistinctId() read
|
||||
// their real email into this test's local-server payload despite HOME
|
||||
// being sandboxed (HEYGEN_CONFIG_DIR, not HOME, resolves the credentials
|
||||
// path). Every other test in this file keeps its untouched default env.
|
||||
runResolve(["--type", "bgm", "--intent", "telemetry seam test", "--project", tmp, "--json"], {
|
||||
env: {
|
||||
DO_NOT_TRACK: "0",
|
||||
HYPERFRAMES_NO_TELEMETRY: "0",
|
||||
CI: "",
|
||||
NODE_ENV: "test",
|
||||
HOME: sandboxHome,
|
||||
HEYGEN_CONFIG_DIR: join(sandboxHome, ".heygen"),
|
||||
MEDIA_USE_TELEMETRY_HOST: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
});
|
||||
|
||||
// runResolve blocks synchronously (execFileSync) until the child exits, which
|
||||
// pauses this process's own event loop for that whole span — the child's
|
||||
// request to our local server sits accepted-but-unprocessed in the kernel
|
||||
// backlog until control returns here. Poll briefly to let the event loop
|
||||
// drain it rather than asserting before the server has had a turn to run.
|
||||
for (let i = 0; i < 100 && received.length === 0; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
rmSync(sandboxHome, { recursive: true, force: true });
|
||||
cleanup();
|
||||
}
|
||||
|
||||
assert.ok(received.length > 0, "expected the local telemetry server to receive a POST");
|
||||
const resolveEvent = received[0].batch.find((event) => event.event === "media_use_resolve");
|
||||
assert.ok(resolveEvent, "expected a media_use_resolve event in the intercepted batch");
|
||||
assert.equal(resolveEvent.properties.provider, "test");
|
||||
assert.equal(resolveEvent.properties.type, "bgm");
|
||||
});
|
||||
|
||||
// --- run ---
|
||||
|
||||
async function main() {
|
||||
console.log("media-use · resolve engine tests\n");
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
for (const { name, fn } of tests) {
|
||||
try {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` \x1b[32m✓\x1b[0m ${name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(` \x1b[31m✗\x1b[0m ${name}`);
|
||||
console.log(` ${err.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
copyFileSync,
|
||||
renameSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { mergeTokensToWords } from "./lib/parakeet-words.mjs";
|
||||
import { track } from "./lib/telemetry.mjs";
|
||||
|
||||
// The DEFAULT local transcription path. Prefers NVIDIA Parakeet-TDT via
|
||||
// parakeet-mlx, which beats whisper.cpp on the Open ASR Leaderboard (~6.05% vs
|
||||
// 7.44% avg WER, and 4.73% vs 5.96% on noisy test-other) and is 5-10x faster
|
||||
// with native punctuation. Emits { text, words:[{text,start,end}] } (word
|
||||
// timestamps merged from Parakeet's sub-word tokens) for transcript-cut /
|
||||
// captions / the audio engine.
|
||||
//
|
||||
// Parakeet v3 covers English + 25 European languages. For other languages, or
|
||||
// when parakeet-mlx is not installed, it falls back to the packaged whisper.cpp
|
||||
// (`hyperframes transcribe`, 99 languages). `--engine` forces one.
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
input: { type: "string", short: "i" },
|
||||
out: { type: "string", short: "o" },
|
||||
engine: { type: "string", default: "auto" }, // auto | parakeet | whisper
|
||||
model: { type: "string", default: "mlx-community/parakeet-tdt-0.6b-v3" },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (args.help) {
|
||||
console.log(`media-use transcribe: better-than-whisper local ASR (Parakeet), whisper.cpp fallback
|
||||
|
||||
Usage:
|
||||
node transcribe.mjs --input audio.wav [--out audio.transcribe.json] [--engine auto|parakeet|whisper]
|
||||
|
||||
Parakeet (default) beats whisper.cpp on accuracy + speed for English/European
|
||||
languages; whisper.cpp (99 languages) is the fallback. Install Parakeet once:
|
||||
uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!args.input) {
|
||||
console.error("error: --input is required");
|
||||
process.exit(2);
|
||||
}
|
||||
const inputPath = resolve(args.input);
|
||||
if (!existsSync(inputPath)) {
|
||||
console.error(`error: input not found: ${inputPath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const outPath = resolve(
|
||||
args.out || `${inputPath.slice(0, -extname(inputPath).length)}.transcribe.json`,
|
||||
);
|
||||
|
||||
// Locate the parakeet-mlx runner the same way the CLI does: env override, then
|
||||
// the documented ~/.venvs/parakeet install, then PATH. Checking the venv (not
|
||||
// just PATH) is what keeps a user who followed the install docs verbatim from
|
||||
// silently falling through to whisper. Returns the runner path, or null.
|
||||
function resolveParakeet() {
|
||||
for (const p of [
|
||||
process.env.HYPERFRAMES_PARAKEET,
|
||||
join(homedir(), ".venvs", "parakeet", "bin", "parakeet-mlx"),
|
||||
]) {
|
||||
if (p && existsSync(p)) return p;
|
||||
}
|
||||
try {
|
||||
execFileSync("parakeet-mlx", ["--help"], {
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
timeout: 20000,
|
||||
});
|
||||
return "parakeet-mlx";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Write via a sibling temp + atomic rename so a SIGKILL mid-write can't leave a
|
||||
// truncated transcript at outPath (downstream reads it as valid JSON).
|
||||
function atomicWrite(target, data) {
|
||||
const tmp = `${target}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, data);
|
||||
renameSync(tmp, target);
|
||||
}
|
||||
|
||||
function report(engine, wordCount) {
|
||||
if (args.json) console.log(JSON.stringify({ ok: true, out: outPath, engine, words: wordCount }));
|
||||
else
|
||||
console.log(
|
||||
`transcribed ${basename(inputPath)} -> ${outPath}${wordCount != null ? ` (${wordCount} words,` : " ("}${engine})`,
|
||||
);
|
||||
}
|
||||
|
||||
function runParakeet(runner) {
|
||||
const workDir = mkdtempSync(join(tmpdir(), "media-use-asr-"));
|
||||
try {
|
||||
execFileSync(
|
||||
runner,
|
||||
[inputPath, "--model", args.model, "--output-format", "json", "--output-dir", workDir],
|
||||
{ stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 },
|
||||
);
|
||||
const jsonPath = join(workDir, `${basename(inputPath, extname(inputPath))}.json`);
|
||||
if (!existsSync(jsonPath)) throw new Error("parakeet produced no JSON");
|
||||
const merged = mergeTokensToWords(JSON.parse(readFileSync(jsonPath, "utf8")));
|
||||
atomicWrite(outPath, JSON.stringify(merged, null, 2));
|
||||
report("parakeet", merged.words.length);
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// whisper.cpp via the packaged CLI: writes transcript.json into --dir; relocate to --out.
|
||||
function runWhisper() {
|
||||
const workDir = mkdtempSync(join(tmpdir(), "media-use-whisper-"));
|
||||
try {
|
||||
execFileSync("npx", ["hyperframes", "transcribe", inputPath, "--dir", workDir], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 1_800_000,
|
||||
});
|
||||
const produced = join(workDir, "transcript.json");
|
||||
if (!existsSync(produced)) throw new Error("whisper produced no transcript.json");
|
||||
const tmp = `${outPath}.tmp-${process.pid}`;
|
||||
copyFileSync(produced, tmp);
|
||||
renameSync(tmp, outPath); // atomic publish
|
||||
let words;
|
||||
try {
|
||||
const t = JSON.parse(readFileSync(outPath, "utf8"));
|
||||
words = Array.isArray(t?.words) ? t.words.length : undefined;
|
||||
} catch {
|
||||
/* leave undefined */
|
||||
}
|
||||
report("whisper", words);
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parakeetBin = resolveParakeet();
|
||||
const engine =
|
||||
args.engine === "parakeet" || args.engine === "whisper"
|
||||
? args.engine
|
||||
: parakeetBin
|
||||
? "parakeet"
|
||||
: "whisper";
|
||||
if (engine === "parakeet") {
|
||||
if (!parakeetBin) {
|
||||
throw new Error(
|
||||
"parakeet-mlx not found (checked $HYPERFRAMES_PARAKEET, ~/.venvs/parakeet, and PATH). Install: uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx (or use --engine whisper)",
|
||||
);
|
||||
}
|
||||
runParakeet(parakeetBin);
|
||||
} else {
|
||||
runWhisper();
|
||||
}
|
||||
await track("media_use_transcribe", { engine });
|
||||
} catch (err) {
|
||||
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
|
||||
else console.error(`error: transcription failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { compileCutList } from "./lib/cutlist.mjs";
|
||||
import { track } from "./lib/telemetry.mjs";
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
input: { type: "string" },
|
||||
transcript: { type: "string" },
|
||||
remove: { type: "string" },
|
||||
"remove-words": { type: "string" },
|
||||
"remove-fillers": { type: "string" },
|
||||
"cut-silence": { type: "string" },
|
||||
keep: { type: "string" },
|
||||
copy: { type: "boolean", default: false },
|
||||
plan: { type: "boolean", default: false },
|
||||
out: { type: "string" },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (args.help) {
|
||||
console.log(`media-use transcript-cut — compile transcript edits into video cuts
|
||||
|
||||
Usage:
|
||||
node transcript-cut.mjs --input in.mp4 --transcript transcript.json --remove "12-15" --out out.mp4
|
||||
|
||||
Options:
|
||||
--input Source video/audio file
|
||||
--transcript JSON word transcript, array or { words: [...] }
|
||||
--remove Time ranges to remove, seconds: a-b,c-d
|
||||
--remove-words Word-index ranges to remove: 12-18,40-41
|
||||
--remove-fillers Comma list of filler words to remove
|
||||
--cut-silence Remove inter-word gaps longer than this many seconds
|
||||
--keep Inverse mode: direct kept ranges, mutually exclusive with removal
|
||||
--copy Use stream copy for faster, keyframe-snapped cuts
|
||||
--plan Print kept segment JSON and exit without ffmpeg
|
||||
--out Output file
|
||||
--json Output JSON status
|
||||
--help, -h Show this help`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
run();
|
||||
await track("media_use_transcript_cut", {
|
||||
mode: args.plan ? "plan" : "encode",
|
||||
remove_fillers: !!args["remove-fillers"],
|
||||
cut_silence: !!args["cut-silence"],
|
||||
ranges: !!args.remove,
|
||||
keep: !!args.keep,
|
||||
});
|
||||
} catch (err) {
|
||||
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
|
||||
else console.error(`error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (!args.transcript) throw new Error("--transcript is required");
|
||||
const transcript = JSON.parse(readFileSync(resolve(args.transcript), "utf8"));
|
||||
const segments = compileCutList(transcript, {
|
||||
remove: args.remove,
|
||||
removeWords: args["remove-words"],
|
||||
removeFillers: args["remove-fillers"],
|
||||
cutSilence: args["cut-silence"],
|
||||
keep: args.keep,
|
||||
});
|
||||
|
||||
if (args.plan) {
|
||||
console.log(JSON.stringify(segments));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.input || !args.out)
|
||||
throw new Error("--input and --out are required unless --plan is set");
|
||||
if (segments.length === 0) throw new Error("cut list has no kept segments");
|
||||
|
||||
const inputPath = resolve(args.input);
|
||||
const outPath = resolve(args.out);
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "media-use-cut-"));
|
||||
const keptSeconds = sumDurations(segments);
|
||||
const totalSeconds = probeDuration(inputPath);
|
||||
|
||||
try {
|
||||
const parts = segments.map((segment, index) => {
|
||||
const out = join(
|
||||
tmpDir,
|
||||
`segment-${String(index).padStart(4, "0")}${extname(outPath) || ".mp4"}`,
|
||||
);
|
||||
cutSegment(inputPath, segment, out, Boolean(args.copy));
|
||||
return out;
|
||||
});
|
||||
const listPath = join(tmpDir, "list.txt");
|
||||
writeFileSync(
|
||||
listPath,
|
||||
parts.map((part) => `file '${escapeConcatPath(part)}'`).join("\n") + "\n",
|
||||
);
|
||||
// Encode to a sibling temp (same extension so ffmpeg picks the right muxer),
|
||||
// then atomic-rename so a SIGKILL mid-encode can't leave a truncated outPath.
|
||||
const tmpOut = `${outPath}.part${extname(outPath) || ".mp4"}`;
|
||||
execFileSync(
|
||||
"ffmpeg",
|
||||
["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", tmpOut],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
renameSync(tmpOut, outPath);
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Stream copy can only cut on keyframes; on sparse-keyframe footage the snap
|
||||
// can silently swallow the whole cut. Compare, then surface the drift in BOTH
|
||||
// the stderr warning (human) and the --json result (pipelines).
|
||||
let copyDrift = null;
|
||||
if (args.copy) {
|
||||
const outSeconds = probeDuration(outPath);
|
||||
if (Math.abs(outSeconds - keptSeconds) > 1) {
|
||||
copyDrift = { produced_s: round3(outSeconds), expected_s: round3(keptSeconds) };
|
||||
if (!args.json) {
|
||||
console.error(
|
||||
`warning: --copy keyframe snapping produced ${round3(outSeconds)}s instead of ${round3(keptSeconds)}s kept; drop --copy for frame-accurate cuts`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
input: inputPath,
|
||||
out: outPath,
|
||||
segments,
|
||||
kept_s: round3(keptSeconds),
|
||||
total_s: round3(totalSeconds),
|
||||
...(copyDrift && { copy_drift: copyDrift }),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`cut ${inputPath} -> ${outPath} (${segments.length} segments, ${fmt(keptSeconds)}s kept of ${fmt(
|
||||
totalSeconds,
|
||||
)}s)`,
|
||||
);
|
||||
console.log(`next: resolve --from ${outPath} --type <type>`);
|
||||
}
|
||||
|
||||
function cutSegment(inputPath, segment, outPath, copy) {
|
||||
const argv = [
|
||||
"-y",
|
||||
"-nostdin",
|
||||
"-ss",
|
||||
fmt(segment.start),
|
||||
"-i",
|
||||
inputPath,
|
||||
"-to",
|
||||
fmt(segment.end - segment.start),
|
||||
];
|
||||
if (copy) {
|
||||
argv.push("-c", "copy", "-avoid_negative_ts", "make_zero");
|
||||
} else {
|
||||
argv.push(...encodeArgsFor(extname(outPath).toLowerCase()));
|
||||
}
|
||||
argv.push(outPath);
|
||||
execFileSync("ffmpeg", argv, { stdio: "ignore" });
|
||||
}
|
||||
|
||||
// Codec set per output container. Audio-only outputs must not get the
|
||||
// video-centric aac/x264 set (aac inside .wav breaks timing entirely).
|
||||
function encodeArgsFor(ext) {
|
||||
if (ext === ".wav") return ["-c:a", "pcm_s16le"];
|
||||
if (ext === ".mp3") return ["-c:a", "libmp3lame", "-q:a", "2"];
|
||||
if (ext === ".m4a" || ext === ".aac") return ["-c:a", "aac"];
|
||||
if (ext === ".flac") return ["-c:a", "flac"];
|
||||
return [
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"18",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
];
|
||||
}
|
||||
|
||||
function probeDuration(filePath) {
|
||||
const raw = execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
filePath,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const duration = Number(raw.trim());
|
||||
if (!Number.isFinite(duration) || duration <= 0)
|
||||
throw new Error(`could not probe duration: ${filePath}`);
|
||||
return duration;
|
||||
}
|
||||
|
||||
function escapeConcatPath(filePath) {
|
||||
return filePath.replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
function sumDurations(segments) {
|
||||
return segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return round3(n)
|
||||
.toFixed(3)
|
||||
.replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function round3(n) {
|
||||
return Math.round(Number(n) * 1000) / 1000;
|
||||
}
|
||||
Reference in New Issue
Block a user