feat(core,cli): media-use interop — shared index.md regen + description/entity on figma imports (#1927)

Post-release review of media-use ↔ figma coupling (spec §13.1):
- figma asset imports now regenerate .media/index.md, the agent-readable
  inventory media-use maintains — format locked byte-identical via a
  cross-runner parity test against media-use's own index-gen.mjs
- figma asset --description/--entity land in the manifest record, the
  index table, and <img alt>; component rasterize auto-describes with
  the node name. Named brand marks become visible to media-use's
  resolve --entity lookups.
- spec §13.1 records the review verdict (loose coupling correct) and
  the follow-up queue (shared media-ledger module, global cache for
  figma assets, media-use version-keyed idempotency)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-03 22:56:43 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent cf594403ef
commit 3900caaaa9
12 changed files with 418 additions and 33 deletions
+3
View File
@@ -25,12 +25,15 @@ export {
mediaDir,
manifestPath,
typeDirPath,
updateRecord,
isFigmaManifestRecord,
readManifest,
appendRecord,
findAllByFigmaNode,
findByFigmaNode,
nextId,
} from "./manifest";
export { regenerateIndex } from "./mediaIndex";
export { buildAssetSnippet } from "./assetSnippet";
export { sanitizeSvg } from "./sanitizeSvg";
export {
+34 -8
View File
@@ -1,4 +1,4 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { readJsonlValues } from "./jsonl";
import type { FigmaManifestRecord } from "./types";
@@ -68,15 +68,41 @@ export function findByFigmaNode(
fileKey: string,
nodeId: string,
): FigmaManifestRecord | null {
for (const r of readManifest(projectDir)) {
if (
return findAllByFigmaNode(projectDir, fileKey, nodeId)[0] ?? null;
}
/** EVERY row for a node — reuse gates must check all (format, scale, version)
* tuples, not just the oldest, or a second tuple defeats idempotency forever. */
export function findAllByFigmaNode(
projectDir: string,
fileKey: string,
nodeId: string,
): FigmaManifestRecord[] {
return readManifest(projectDir).filter(
(r) =>
r.provenance.source === "figma" &&
r.provenance.fileKey === fileKey &&
r.provenance.nodeId === nodeId
)
return r;
}
return null;
r.provenance.nodeId === nodeId,
);
}
/** Rewrite one row in place by id, preserving every other line (other
* writers' rows included) byte-for-byte. */
export function updateRecord(projectDir: string, record: FigmaManifestRecord): void {
const p = manifestPath(projectDir);
const lines = readFileSync(p, "utf8").split(/\r?\n/);
const out = lines.map((line) => {
const trimmed = line.trim();
if (trimmed.length === 0) return line;
try {
const parsed: unknown = JSON.parse(trimmed);
if (isFigmaManifestRecord(parsed) && parsed.id === record.id) return JSON.stringify(record);
} catch {
// non-JSON line — preserve untouched
}
return line;
});
writeFileSync(p, out.join("\n"));
}
export function nextId(projectDir: string, type: FigmaManifestRecord["type"]): string {
+107
View File
@@ -0,0 +1,107 @@
// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import { generateIndexContent, indexPath, regenerateIndex } from "./mediaIndex";
import { manifestPath } from "./manifest";
const dirs: string[] = [];
function project(): string {
const d = mkdtempSync(join(tmpdir(), "hf-media-index-"));
dirs.push(d);
mkdirSync(join(d, ".media"), { recursive: true });
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
const MEDIA_USE_ROW = {
id: "bgm_001",
type: "bgm",
path: ".media/audio/bgm/bgm_001.mp3",
source: "search",
description: "upbeat tech launch",
duration: 25,
provenance: { provider: "heygen-audio", prompt: "upbeat tech launch" },
};
const IMAGE_ROW = {
id: "image_001",
type: "image",
path: ".media/images/image_001.jpg",
source: "search",
description: "gradient tech background",
width: 1920,
height: 1080,
provenance: { provider: "heygen-asset", prompt: "gradient tech background" },
};
const ICON_ROW = {
id: "icon_001",
type: "icon",
path: ".media/images/icon_001.svg",
source: "search",
description: "rocket",
transparent: true,
provenance: { provider: "heygen-asset", prompt: "rocket" },
};
const FIGMA_ROW = {
id: "image_002",
type: "image",
path: ".media/images/image_002.svg",
source: "figma:KEY/1:2",
description: "hero illustration",
entity: "Acme hero",
provenance: { source: "figma", fileKey: "KEY", nodeId: "1:2", version: "9", format: "svg" },
};
// media-use renders every JSON-parseable row, shape or no shape — selection
// parity matters as much as format parity.
const JUNK_ROW = { note: "not a media record" };
const ALL_ROWS = [MEDIA_USE_ROW, IMAGE_ROW, ICON_ROW, FIGMA_ROW, JUNK_ROW];
describe("regenerateIndex", () => {
it("renders every writer's rows (media-use + figma) into one table", () => {
const p = project();
for (const row of ALL_ROWS) appendFileSync(manifestPath(p), JSON.stringify(row) + "\n");
regenerateIndex(p);
const index = readFileSync(indexPath(p), "utf8");
expect(index).toContain("# .media · 5 assets");
expect(index).toContain("25s");
expect(index).toContain("1920×1080");
expect(index).toContain("hero illustration");
});
it("matches media-use's index-gen output byte-for-byte on the same rows", () => {
// Covers duration, width×height, icon+transparent, no-dims, and junk-row
// selection — the full set of branches both generators format.
const ours = generateIndexContent(ALL_ROWS as Record<string, unknown>[]);
// Run the actual media-use generator on identical input. Resolve the
// script relative to THIS file (cwd varies per test runner) and hand it
// over as a file:// URL so the specifier is valid on windows too.
const genUrl = pathToFileURL(
join(
fileURLToPath(new URL(".", import.meta.url)),
"..",
"..",
"..",
"..",
"skills",
"media-use",
"scripts",
"lib",
"index-gen.mjs",
),
).href;
const script = `
import { generateIndexContent } from ${JSON.stringify(genUrl)};
const rows = ${JSON.stringify(ALL_ROWS)};
process.stdout.write(generateIndexContent(rows));
`;
const theirs = execFileSync("node", ["--input-type=module", "-e", script], {
encoding: "utf8",
});
expect(ours).toBe(theirs);
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Regenerate .media/index.md — the agent-readable inventory table — after a
* figma import, exactly the way media-use does after a resolve. Both writers
* regenerate the SAME file from the full manifest (all writers' rows), so the
* output format AND row selection here must stay byte-identical with
* skills/media-use/scripts/lib/index-gen.mjs — including rendering every
* JSON-parseable row (no shape filtering), or the file would flip-flop
* depending on which writer ran last.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { readJsonlValues } from "./jsonl";
import { manifestPath, mediaDir } from "./manifest";
type IndexRow = Record<string, unknown>;
function isRow(value: unknown): value is IndexRow {
return typeof value === "object" && value !== null;
}
export function indexPath(projectDir: string): string {
return join(mediaDir(projectDir), "index.md");
}
function pad(str: unknown, len: number): string {
return String(str ?? "").padEnd(len);
}
function formatDur(r: IndexRow): string {
if (r.duration == null) return "—";
return `${String(r.duration)}s`;
}
function formatDims(r: IndexRow): string {
if (r.width && r.height) return `${String(r.width)}×${String(r.height)}`;
if (r.type === "icon" && r.transparent) return "svg";
return "—";
}
function len(value: unknown): number {
return String(value ?? "").length;
}
export function generateIndexContent(records: IndexRow[]): string {
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 };
for (const r of records) {
cols.id = Math.max(cols.id, len(r.id));
cols.type = Math.max(cols.type, len(r.type));
cols.dur = Math.max(cols.dur, formatDur(r).length);
cols.dims = Math.max(cols.dims, formatDims(r).length);
cols.path = Math.max(cols.path, len(r.path));
}
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) +
String(r.description ?? ""),
);
}
return lines.join("\n") + "\n";
}
/** Rebuild index.md from EVERY writer's manifest rows (media-use + figma). */
export function regenerateIndex(projectDir: string): string {
const records = readJsonlValues(manifestPath(projectDir)).filter(isRow);
const content = generateIndexContent(records);
const p = indexPath(projectDir);
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, content);
return content;
}
+2
View File
@@ -20,6 +20,8 @@ export interface FigmaManifestRecord {
path: string;
source: string;
description?: string;
/** media-use interop: lets `resolve --entity` find figma-imported assets */
entity?: string;
width?: number;
height?: number;
provenance: FigmaProvenance;