mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(media-use): core infrastructure — manifest, cache, adopt, probe (#1682)
* 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> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
eee376cb5e
commit
73f6d3e5be
@@ -0,0 +1 @@
|
||||
eval-report.html
|
||||
@@ -0,0 +1,112 @@
|
||||
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";
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
export function findExistingAsset(projectDir, intent, type) {
|
||||
const assetsDir = join(projectDir, "assets");
|
||||
if (!existsSync(assetsDir)) return null;
|
||||
const lower = intent.toLowerCase();
|
||||
for (const rel of walkDir(assetsDir)) {
|
||||
const t = inferType(rel);
|
||||
if (!t || (type && t !== type)) continue;
|
||||
const name = basename(rel, extname(rel)).toLowerCase().replace(/[-_]/g, " ");
|
||||
if (name.includes(lower) || lower.includes(name)) {
|
||||
return { relativePath: `assets/${rel}`, type: t, name: basename(rel, extname(rel)) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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 } 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");
|
||||
}
|
||||
|
||||
function readGlobalManifest() {
|
||||
return readManifest(globalMediaDir());
|
||||
}
|
||||
|
||||
function validateCacheHit(match) {
|
||||
if (!match?.sha) return null;
|
||||
return isComplete(cacheEntryDir(globalMediaDir(), match.sha)) ? match : null;
|
||||
}
|
||||
|
||||
export function cacheGet(prompt, type) {
|
||||
return validateCacheHit(
|
||||
readGlobalManifest().find(
|
||||
(r) => r.reusable && r.provenance?.prompt === prompt && (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);
|
||||
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(globalMediaDir(), 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,26 @@
|
||||
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 res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status} for ${String(url).slice(0, 80)}`);
|
||||
const bytes = Buffer.from(await res.arrayBuffer());
|
||||
if (bytes.length === 0)
|
||||
throw new Error(`freeze failed: empty response for ${String(url).slice(0, 80)}`);
|
||||
if (bytes.length > MAX_FREEZE_BYTES)
|
||||
throw new Error(
|
||||
`freeze failed: ${bytes.length} bytes exceeds ${MAX_FREEZE_BYTES} cap for ${String(url).slice(0, 80)}`,
|
||||
);
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
writeFileSync(destPath, bytes);
|
||||
return bytes.length;
|
||||
}
|
||||
|
||||
export function freezeLocalFile(srcPath, destPath) {
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
copyFileSync(srcPath, destPath);
|
||||
}
|
||||
@@ -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,91 @@
|
||||
import { readFileSync, appendFileSync, mkdirSync, existsSync } 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",
|
||||
brand: "images",
|
||||
video: "video",
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function findByPrompt(projectDir, prompt, type) {
|
||||
const records = readManifest(projectDir);
|
||||
return (
|
||||
records.find((r) => r.provenance?.prompt === prompt && (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")}`;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
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,
|
||||
manifestPath,
|
||||
mediaDir,
|
||||
typeDirPath,
|
||||
} 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("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("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);
|
||||
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,36 @@
|
||||
import { execSync } 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 {
|
||||
const raw = execSync(
|
||||
`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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user