mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
Claude Fable 5
parent
cf594403ef
commit
3900caaaa9
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user