Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions skills/media-use/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
eval-report.html
109 changes: 109 additions & 0 deletions skills/media-use/scripts/lib/adopt.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
126 changes: 126 additions & 0 deletions skills/media-use/scripts/lib/cache.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
18 changes: 18 additions & 0 deletions skills/media-use/scripts/lib/freeze.mjs
Original file line number Diff line number Diff line change
@@ -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);

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
return bytes.length;
}

export function freezeLocalFile(srcPath, destPath) {
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(srcPath, destPath);
}
63 changes: 63 additions & 0 deletions skills/media-use/scripts/lib/index-gen.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading