Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
.
Comment thread
miguel-heygen marked this conversation as resolved.
Dismissed
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