Skip to content

Commit 2db7682

Browse files
committed
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)
1 parent 3a6742b commit 2db7682

8 files changed

Lines changed: 737 additions & 0 deletions

File tree

skills/media-use/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
eval-report.html
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { readdirSync, statSync, existsSync } from "node:fs";
2+
import { join, extname, basename } from "node:path";
3+
import { readManifest, appendRecord, nextId } from "./manifest.mjs";
4+
import { regenerateIndex } from "./index-gen.mjs";
5+
import { probe } from "./probe.mjs";
6+
7+
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
8+
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);
9+
const VIDEO_EXT = new Set([".mp4", ".webm", ".mov"]);
10+
11+
function inferType(filePath) {
12+
const ext = extname(filePath).toLowerCase();
13+
if (AUDIO_EXT.has(ext)) {
14+
const lower = filePath.toLowerCase();
15+
if (lower.includes("/bgm/") || lower.includes("/music/") || lower.startsWith("bgm/")) return "bgm";
16+
if (lower.includes("/sfx/") || lower.includes("/sound") || lower.startsWith("sfx/")) return "sfx";
17+
if (lower.includes("/voice/") || lower.includes("/narrat") || lower.startsWith("voice/")) return "voice";
18+
return "bgm";
19+
}
20+
if (IMAGE_EXT.has(ext)) {
21+
if (ext === ".svg" || ext === ".ico") return "icon";
22+
return "image";
23+
}
24+
if (VIDEO_EXT.has(ext)) return "video";
25+
return null;
26+
}
27+
28+
function walkDir(dir, base = "") {
29+
const files = [];
30+
if (!existsSync(dir)) return files;
31+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
32+
const rel = base ? `${base}/${entry.name}` : entry.name;
33+
if (entry.isDirectory()) {
34+
files.push(...walkDir(join(dir, entry.name), rel));
35+
} else {
36+
files.push(rel);
37+
}
38+
}
39+
return files;
40+
}
41+
42+
export function scanExistingAssets(projectDir) {
43+
const assetsDir = join(projectDir, "assets");
44+
if (!existsSync(assetsDir)) return [];
45+
46+
const files = walkDir(assetsDir);
47+
const found = [];
48+
for (const rel of files) {
49+
const type = inferType(rel);
50+
if (!type) continue;
51+
const fullPath = join(assetsDir, rel);
52+
const stat = statSync(fullPath);
53+
const meta = probe(fullPath);
54+
found.push({
55+
relativePath: `assets/${rel}`,
56+
type,
57+
size: stat.size,
58+
name: basename(rel, extname(rel)),
59+
...meta,
60+
});
61+
}
62+
return found;
63+
}
64+
65+
export function adoptExistingAssets(projectDir) {
66+
const existing = scanExistingAssets(projectDir);
67+
if (existing.length === 0) return [];
68+
69+
const manifest = readManifest(projectDir);
70+
const knownPaths = new Set(manifest.map((r) => r.path));
71+
72+
const adopted = [];
73+
for (const asset of existing) {
74+
if (knownPaths.has(asset.relativePath)) continue;
75+
76+
const id = nextId(projectDir, asset.type);
77+
const record = {
78+
id,
79+
type: asset.type,
80+
path: asset.relativePath,
81+
source: "existing",
82+
description: asset.name.replace(/[-_]/g, " "),
83+
...(asset.duration != null && { duration: asset.duration }),
84+
...(asset.width != null && { width: asset.width }),
85+
...(asset.height != null && { height: asset.height }),
86+
provenance: { provider: "local", adopted: true },
87+
};
88+
appendRecord(projectDir, record);
89+
adopted.push(record);
90+
}
91+
92+
if (adopted.length > 0) regenerateIndex(projectDir);
93+
return adopted;
94+
}
95+
96+
export function findExistingAsset(projectDir, intent, type) {
97+
const assetsDir = join(projectDir, "assets");
98+
if (!existsSync(assetsDir)) return null;
99+
const lower = intent.toLowerCase();
100+
for (const rel of walkDir(assetsDir)) {
101+
const t = inferType(rel);
102+
if (!t || (type && t !== type)) continue;
103+
const name = basename(rel, extname(rel)).toLowerCase().replace(/[-_]/g, " ");
104+
if (name.includes(lower) || lower.includes(name)) {
105+
return { relativePath: `assets/${rel}`, type: t, name: basename(rel, extname(rel)) };
106+
}
107+
}
108+
return null;
109+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {
2+
readFileSync,
3+
writeFileSync,
4+
mkdirSync,
5+
existsSync,
6+
copyFileSync,
7+
} from "node:fs";
8+
import { join, basename } from "node:path";
9+
import { createHash } from "node:crypto";
10+
import { homedir } from "node:os";
11+
import { readManifest, appendRecord } from "./manifest.mjs";
12+
13+
const SCHEMA_PREFIX = "mu-v1-";
14+
const KEY_HEX_CHARS = 16;
15+
const COMPLETE_SENTINEL = ".hf-complete";
16+
17+
export function globalMediaDir() {
18+
return join(homedir(), ".media");
19+
}
20+
21+
export function contentHash(filePath) {
22+
const bytes = readFileSync(filePath);
23+
return createHash("sha256").update(bytes).digest("hex");
24+
}
25+
26+
function cacheEntryDir(rootDir, sha) {
27+
return join(rootDir, SCHEMA_PREFIX + sha.slice(0, KEY_HEX_CHARS));
28+
}
29+
30+
function isComplete(entryDir) {
31+
return existsSync(join(entryDir, COMPLETE_SENTINEL));
32+
}
33+
34+
function markComplete(entryDir) {
35+
writeFileSync(join(entryDir, COMPLETE_SENTINEL), "", "utf8");
36+
}
37+
38+
function readGlobalManifest() {
39+
return readManifest(globalMediaDir());
40+
}
41+
42+
function validateCacheHit(match) {
43+
if (!match?.sha) return null;
44+
return isComplete(cacheEntryDir(globalMediaDir(), match.sha)) ? match : null;
45+
}
46+
47+
export function cacheGet(prompt, type) {
48+
return validateCacheHit(
49+
readGlobalManifest().find(
50+
(r) =>
51+
r.reusable &&
52+
r.provenance?.prompt === prompt &&
53+
(type == null || r.type === type),
54+
),
55+
);
56+
}
57+
58+
export function cacheGetByEntity(entity) {
59+
const lower = entity.toLowerCase();
60+
return validateCacheHit(
61+
readGlobalManifest().find(
62+
(r) => r.reusable && r.entity && r.entity.toLowerCase() === lower,
63+
),
64+
);
65+
}
66+
67+
export function cachePut(filePath, record) {
68+
const sha = contentHash(filePath);
69+
const dir = globalMediaDir();
70+
const entryDir = cacheEntryDir(dir, sha);
71+
mkdirSync(entryDir, { recursive: true });
72+
73+
const dest = join(entryDir, basename(filePath));
74+
copyFileSync(filePath, dest);
75+
markComplete(entryDir);
76+
77+
const globalRecord = {
78+
...record,
79+
sha,
80+
reusable: true,
81+
cached_path: dest,
82+
};
83+
appendRecord(globalMediaDir(), globalRecord);
84+
return { sha, cached_path: dest };
85+
}
86+
87+
export function importFromCache(cacheRecord, projectDir, localId, localPath) {
88+
const sha = cacheRecord.sha;
89+
const entryDir = cacheEntryDir(globalMediaDir(), sha);
90+
if (!isComplete(entryDir)) return null;
91+
92+
const cachedFile = cacheRecord.cached_path;
93+
if (!cachedFile || !existsSync(cachedFile)) return null;
94+
95+
mkdirSync(join(projectDir, ".media"), { recursive: true });
96+
const fullDest = join(projectDir, localPath);
97+
mkdirSync(join(fullDest, ".."), { recursive: true });
98+
copyFileSync(cachedFile, fullDest);
99+
100+
const projectRecord = {
101+
...cacheRecord,
102+
id: localId,
103+
path: localPath,
104+
provenance: {
105+
...cacheRecord.provenance,
106+
imported_from: sha,
107+
},
108+
};
109+
delete projectRecord.sha;
110+
delete projectRecord.reusable;
111+
delete projectRecord.cached_path;
112+
113+
return projectRecord;
114+
}
115+
116+
export function promote(projectDir, id) {
117+
const records = readManifest(projectDir);
118+
const record = records.find((r) => r.id === id);
119+
if (!record) throw new Error(`asset not found in project manifest: ${id}`);
120+
121+
const filePath = join(projectDir, record.path);
122+
if (!existsSync(filePath))
123+
throw new Error(`asset file not found: ${filePath}`);
124+
125+
return cachePut(filePath, record);
126+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { writeFileSync, copyFileSync, mkdirSync } from "node:fs";
2+
import { dirname } from "node:path";
3+
4+
export async function freezeUrl(url, destPath) {
5+
const res = await fetch(url);
6+
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status} for ${String(url).slice(0, 80)}`);
7+
const bytes = Buffer.from(await res.arrayBuffer());
8+
if (bytes.length === 0)
9+
throw new Error(`freeze failed: empty response for ${String(url).slice(0, 80)}`);
10+
mkdirSync(dirname(destPath), { recursive: true });
11+
writeFileSync(destPath, bytes);
12+
return bytes.length;
13+
}
14+
15+
export function freezeLocalFile(srcPath, destPath) {
16+
mkdirSync(dirname(destPath), { recursive: true });
17+
copyFileSync(srcPath, destPath);
18+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { writeFileSync, mkdirSync } from "node:fs";
2+
import { dirname } from "node:path";
3+
import { readManifest, indexPath } from "./manifest.mjs";
4+
5+
function pad(str, len) {
6+
return String(str ?? "").padEnd(len);
7+
}
8+
9+
function formatDur(record) {
10+
if (record.duration == null) return "—";
11+
return `${record.duration}s`;
12+
}
13+
14+
function formatDims(record) {
15+
if (record.width && record.height) return `${record.width}×${record.height}`;
16+
if (record.type === "icon" && record.transparent) return "svg";
17+
return "—";
18+
}
19+
20+
export function generateIndexContent(records) {
21+
const count = records.length;
22+
const header = `# .media · ${count} asset${count === 1 ? "" : "s"}\n`;
23+
if (count === 0) return header;
24+
25+
const cols = { id: 4, type: 5, dur: 4, dims: 5, path: 5, desc: 11 };
26+
for (const r of records) {
27+
cols.id = Math.max(cols.id, (r.id ?? "").length);
28+
cols.type = Math.max(cols.type, (r.type ?? "").length);
29+
cols.dur = Math.max(cols.dur, formatDur(r).length);
30+
cols.dims = Math.max(cols.dims, formatDims(r).length);
31+
cols.path = Math.max(cols.path, (r.path ?? "").length);
32+
}
33+
34+
const heading =
35+
pad("id", cols.id + 2) +
36+
pad("type", cols.type + 2) +
37+
pad("dur", cols.dur + 2) +
38+
pad("dims", cols.dims + 2) +
39+
pad("path", cols.path + 2) +
40+
"description";
41+
42+
const lines = [header, heading];
43+
for (const r of records) {
44+
lines.push(
45+
pad(r.id, cols.id + 2) +
46+
pad(r.type, cols.type + 2) +
47+
pad(formatDur(r), cols.dur + 2) +
48+
pad(formatDims(r), cols.dims + 2) +
49+
pad(r.path, cols.path + 2) +
50+
(r.description ?? ""),
51+
);
52+
}
53+
return lines.join("\n") + "\n";
54+
}
55+
56+
export function regenerateIndex(projectDir) {
57+
const records = readManifest(projectDir);
58+
const content = generateIndexContent(records);
59+
const p = indexPath(projectDir);
60+
mkdirSync(dirname(p), { recursive: true });
61+
writeFileSync(p, content);
62+
return content;
63+
}

0 commit comments

Comments
 (0)