mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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>
138 lines
4.7 KiB
TypeScript
138 lines
4.7 KiB
TypeScript
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { readJsonlValues } from "./jsonl";
|
|
import type { FigmaManifestRecord } from "./types";
|
|
|
|
const MANIFEST_FILE = "manifest.jsonl";
|
|
|
|
// Mirrors the media-use `.media/` layout for interop (one shared inventory).
|
|
const TYPE_DIRS: Record<FigmaManifestRecord["type"], string> = {
|
|
image: "images",
|
|
video: "video",
|
|
};
|
|
|
|
export function mediaDir(projectDir: string): string {
|
|
return join(projectDir, ".media");
|
|
}
|
|
|
|
export function manifestPath(projectDir: string): string {
|
|
return join(mediaDir(projectDir), MANIFEST_FILE);
|
|
}
|
|
|
|
export function typeDirPath(projectDir: string, type: FigmaManifestRecord["type"]): string {
|
|
return join(mediaDir(projectDir), TYPE_DIRS[type]);
|
|
}
|
|
|
|
/**
|
|
* Guards figma-OWNED rows only — media-use rows (bgm/sfx/icon, no
|
|
* provenance.source) legitimately fail this and are filtered out of
|
|
* figma's read-view. The shared file is the inventory; each writer
|
|
* reads its own rows. `nextId` is the one cross-writer concern and
|
|
* scans the full file.
|
|
*/
|
|
export function isFigmaManifestRecord(value: unknown): value is FigmaManifestRecord {
|
|
if (typeof value !== "object" || value === null) return false;
|
|
if (!("id" in value) || !("type" in value) || !("path" in value) || !("source" in value))
|
|
return false;
|
|
if (
|
|
typeof value.id !== "string" ||
|
|
(value.type !== "image" && value.type !== "video") ||
|
|
typeof value.path !== "string"
|
|
)
|
|
return false;
|
|
if (typeof value.source !== "string") return false;
|
|
if (!("provenance" in value)) return false;
|
|
|
|
const provenance = value.provenance;
|
|
if (typeof provenance !== "object" || provenance === null) return false;
|
|
if (!("source" in provenance) || !("fileKey" in provenance) || !("nodeId" in provenance))
|
|
return false;
|
|
return (
|
|
provenance.source === "figma" &&
|
|
typeof provenance.fileKey === "string" &&
|
|
typeof provenance.nodeId === "string"
|
|
);
|
|
}
|
|
|
|
export function readManifest(projectDir: string): FigmaManifestRecord[] {
|
|
return readJsonlValues(manifestPath(projectDir)).filter(isFigmaManifestRecord);
|
|
}
|
|
|
|
export function appendRecord(projectDir: string, record: FigmaManifestRecord): void {
|
|
mkdirSync(typeDirPath(projectDir, record.type), { recursive: true });
|
|
appendFileSync(manifestPath(projectDir), JSON.stringify(record) + "\n");
|
|
}
|
|
|
|
export function findByFigmaNode(
|
|
projectDir: string,
|
|
fileKey: string,
|
|
nodeId: string,
|
|
): FigmaManifestRecord | null {
|
|
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,
|
|
);
|
|
}
|
|
|
|
/** 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 {
|
|
const re = new RegExp(`^${type}_(\\d+)$`);
|
|
let max = 0;
|
|
// Scan EVERY writer's rows (media-use included), not just figma-owned ones —
|
|
// ids and file paths are shared across the inventory, so a figma id minted
|
|
// from a figma-only view would collide with media-use's image_NNN files.
|
|
const p = manifestPath(projectDir);
|
|
if (!existsSync(p)) return `${type}_001`;
|
|
for (const line of readFileSync(p, "utf8").split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.length === 0) continue;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (
|
|
typeof parsed !== "object" ||
|
|
parsed === null ||
|
|
!("id" in parsed) ||
|
|
typeof parsed.id !== "string"
|
|
)
|
|
continue;
|
|
const m = parsed.id.match(re);
|
|
const n = m?.[1];
|
|
if (n !== undefined) max = Math.max(max, Number.parseInt(n, 10));
|
|
}
|
|
return `${type}_${String(max + 1).padStart(3, "0")}`;
|
|
}
|