mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
* feat(media-use): core infrastructure — manifest, cache, adopt, probe Foundation for media-use — the media resolution layer for HyperFrames. - manifest.mjs: JSONL read/write/find for .media/manifest.jsonl - index-gen.mjs: regenerate agent-readable index.md from manifest - cache.mjs: content-addressed global cache at ~/.media/ (SHA-256, sentinel) - freeze.mjs: download URL or copy local file to .media/ - probe.mjs: extract duration/dimensions via ffprobe - adopt.mjs: scan assets/ directory, register existing files with metadata - 19 passing tests (manifest round-trip, cache, promote, index generation) * fix(media-use): oxfmt formatting + cap freeze download size Format adopt/cache/probe/manifest.test (CI oxfmt --check gate). Cap freezeUrl downloads at 256MB so a hostile/runaway URL can't fill the disk (addresses CodeQL #670: network data written to file). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(media-use): resolve engine + all providers + brand from frame.md - resolve.mjs: cheapest-first cascade - BGM/SFX via heygen --headers, Image/Icon via heygen asset search - Brand tokens from frame.md / design.md (local, no API) - SKILL.md: full agent docs + hyperframes.dev/design redirect - Router skill + workflow skill references * fix(media-use): oxfmt formatting for resolve + providers Format brand/heygen-search/providers/sfx providers + resolve.mjs (CI oxfmt --check gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(media-use): align providers with the real heygen CLI surface (v0.1.6) Verified live against the official Go `heygen` CLI v0.1.6 with a valid key: - Caller attribution: pass `--headers 'X-HeyGen-Client-Source: media-use'` (the allowlisted flag the CLI added for media-use in v0.1.6). The old `--x-source media-use` was never a real flag and broke every call. - Command is `asset search` (the `list` leaf was dropped in v0.1.6), not `asset search list`. - `--min-score` is sent server-side: honored by `audio sounds list`, but the `asset search` backend rejects it and returns no score field, so only the audio providers pass it (image/icon don't). - Drop hardcoded `ext` so resolve.mjs derives it from the URL: catalog icons are .png (not .svg), some BGM is .wav (not .mp3). Also: surface CLI/auth failures on stderr instead of swallowing them as 'no results', carry icon width/height through, and document the heygen CLI install + >= v0.1.6 requirement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(studio): redesign Asset tab + fix beat analysis auto-trigger Asset tab: categorized sections, filter chips, text search, audio spectrum visualizer, "in use" badge, manifest metadata, panel tokens. Beat fix: only run analysis when a beats file exists on disk. * fix(studio): oxfmt formatting for AssetsTab CI oxfmt --check gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(media-use): resolve tests + eval harness 12 resolve engine tests + eval against 7 real registry blocks. * fix(media-use): oxfmt formatting for eval + resolve tests CI oxfmt --check gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
249 lines
7.5 KiB
JavaScript
249 lines
7.5 KiB
JavaScript
import { strict as assert } from "node:assert";
|
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { execSync } 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";
|
|
|
|
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..");
|
|
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,
|
|
};
|
|
}
|
|
|
|
function resolveCmd(args) {
|
|
return `node skills/media-use/scripts/resolve.mjs ${args}`;
|
|
}
|
|
|
|
const tests = [];
|
|
function test(name, fn) {
|
|
tests.push({ name, fn });
|
|
}
|
|
|
|
// --- manifest cache hit ---
|
|
|
|
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 = execSync(resolveCmd(`--type bgm --intent "cached query" --project "${tmp}" --json`), {
|
|
cwd: REPO_ROOT,
|
|
encoding: "utf8",
|
|
});
|
|
const parsed = JSON.parse(out.trim());
|
|
assert.equal(parsed.ok, true);
|
|
assert.equal(parsed.id, "bgm_001");
|
|
assert.equal(parsed._source, "cached");
|
|
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 = execSync(resolveCmd(`--adopt --project "${tmp}" --json`), {
|
|
cwd: REPO_ROOT,
|
|
encoding: "utf8",
|
|
});
|
|
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");
|
|
|
|
execSync(resolveCmd(`--adopt --project "${tmp}" --json`), { cwd: REPO_ROOT, encoding: "utf8" });
|
|
const out = execSync(resolveCmd(`--adopt --project "${tmp}" --json`), {
|
|
cwd: REPO_ROOT,
|
|
encoding: "utf8",
|
|
});
|
|
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 = execSync(
|
|
resolveCmd(`--type bgm --intent "ambient track" --project "${tmp}" --json`),
|
|
{ cwd: REPO_ROOT, encoding: "utf8" },
|
|
);
|
|
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 = execSync(resolveCmd("--help"), { cwd: REPO_ROOT, encoding: "utf8" });
|
|
assert.ok(out.includes("media-use resolve"));
|
|
assert.ok(out.includes("--type"));
|
|
});
|
|
|
|
test("missing required args exits 2", () => {
|
|
try {
|
|
execSync(resolveCmd(""), { cwd: REPO_ROOT, encoding: "utf8", 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 {
|
|
execSync(resolveCmd(`--type bgm --intent "stub fail" --project "${tmp}" --json`), {
|
|
cwd: REPO_ROOT,
|
|
encoding: "utf8",
|
|
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("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 = execSync(resolveCmd(`--type bgm --intent "format test" --project "${tmp}"`), {
|
|
cwd: REPO_ROOT,
|
|
encoding: "utf8",
|
|
});
|
|
assert.match(out.trim(), /^resolved bgm_001 → .media\/audio\/bgm\/bgm_001\.wav \(bgm/);
|
|
cleanup();
|
|
});
|
|
|
|
// --- 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();
|