diff --git a/skills/media-use/.gitignore b/skills/media-use/.gitignore new file mode 100644 index 000000000..17df1e2db --- /dev/null +++ b/skills/media-use/.gitignore @@ -0,0 +1 @@ +eval-report.html diff --git a/skills/media-use/scripts/lib/adopt.mjs b/skills/media-use/scripts/lib/adopt.mjs new file mode 100644 index 000000000..6461262ba --- /dev/null +++ b/skills/media-use/scripts/lib/adopt.mjs @@ -0,0 +1,109 @@ +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; +} diff --git a/skills/media-use/scripts/lib/cache.mjs b/skills/media-use/scripts/lib/cache.mjs new file mode 100644 index 000000000..d2809b895 --- /dev/null +++ b/skills/media-use/scripts/lib/cache.mjs @@ -0,0 +1,126 @@ +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); +} diff --git a/skills/media-use/scripts/lib/freeze.mjs b/skills/media-use/scripts/lib/freeze.mjs new file mode 100644 index 000000000..ab62f568d --- /dev/null +++ b/skills/media-use/scripts/lib/freeze.mjs @@ -0,0 +1,18 @@ +import { writeFileSync, copyFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +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)}`); + mkdirSync(dirname(destPath), { recursive: true }); + writeFileSync(destPath, bytes); + return bytes.length; +} + +export function freezeLocalFile(srcPath, destPath) { + mkdirSync(dirname(destPath), { recursive: true }); + copyFileSync(srcPath, destPath); +} diff --git a/skills/media-use/scripts/lib/index-gen.mjs b/skills/media-use/scripts/lib/index-gen.mjs new file mode 100644 index 000000000..65b019a92 --- /dev/null +++ b/skills/media-use/scripts/lib/index-gen.mjs @@ -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; +} diff --git a/skills/media-use/scripts/lib/manifest.mjs b/skills/media-use/scripts/lib/manifest.mjs new file mode 100644 index 000000000..a32ea181b --- /dev/null +++ b/skills/media-use/scripts/lib/manifest.mjs @@ -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")}`; +} diff --git a/skills/media-use/scripts/lib/manifest.test.mjs b/skills/media-use/scripts/lib/manifest.test.mjs new file mode 100644 index 000000000..b03ce59f7 --- /dev/null +++ b/skills/media-use/scripts/lib/manifest.test.mjs @@ -0,0 +1,295 @@ +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(); diff --git a/skills/media-use/scripts/lib/probe.mjs b/skills/media-use/scripts/lib/probe.mjs new file mode 100644 index 000000000..854b230e5 --- /dev/null +++ b/skills/media-use/scripts/lib/probe.mjs @@ -0,0 +1,34 @@ +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 }; + } +}