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
@@ -101,4 +101,56 @@ describe("runAssetImport", () => {
|
||||
/node/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("records description + entity and regenerates .media/index.md", async () => {
|
||||
const dir = scratch();
|
||||
const out = await runAssetImport(
|
||||
"KEY:1-2",
|
||||
{ format: "png", description: "hero illustration", entity: "Acme hero" },
|
||||
deps(dir),
|
||||
);
|
||||
expect(out.record.description).toBe("hero illustration");
|
||||
expect(out.record.entity).toBe("Acme hero");
|
||||
expect(out.snippet.html).toContain('alt="hero illustration"');
|
||||
const index = readFileSync(join(dir, ".media", "index.md"), "utf8");
|
||||
expect(index).toContain("hero illustration");
|
||||
expect(index).toContain(out.record.id);
|
||||
});
|
||||
|
||||
it("applies --description/--entity on a reuse hit instead of dropping them", async () => {
|
||||
const dir = scratch();
|
||||
const first = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir));
|
||||
expect(first.record.description).toBeUndefined();
|
||||
const again = await runAssetImport(
|
||||
"KEY:1-2",
|
||||
{ format: "png", description: "Acme logo", entity: "Acme logo" },
|
||||
deps(dir),
|
||||
);
|
||||
expect(again.reused).toBe(true);
|
||||
expect(again.record.description).toBe("Acme logo");
|
||||
expect(again.snippet.html).toContain('alt="Acme logo"');
|
||||
const index = readFileSync(join(dir, ".media", "index.md"), "utf8");
|
||||
expect(index).toContain("Acme logo");
|
||||
expect(index).not.toContain("image_002");
|
||||
});
|
||||
|
||||
it("reuses against ANY matching tuple, not just the oldest row", async () => {
|
||||
const dir = scratch();
|
||||
await runAssetImport("KEY:1-2", { format: "svg" }, deps(dir)); // image_001 (svg)
|
||||
const png = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir)); // image_002
|
||||
expect(png.reused).toBe(false);
|
||||
const pngAgain = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir));
|
||||
expect(pngAgain.reused).toBe(true);
|
||||
expect(pngAgain.record.id).toBe(png.record.id);
|
||||
});
|
||||
|
||||
it("flattens whitespace in descriptions so index.md rows stay single-line", async () => {
|
||||
const dir = scratch();
|
||||
const out = await runAssetImport(
|
||||
"KEY:1-2",
|
||||
{ format: "png", description: "line one\nline two" },
|
||||
deps(dir),
|
||||
);
|
||||
expect(out.record.description).toBe("line one line two");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
appendRecord,
|
||||
buildAssetSnippet,
|
||||
createFigmaClient,
|
||||
findByFigmaNode,
|
||||
findAllByFigmaNode,
|
||||
freezeBytes,
|
||||
nextId,
|
||||
parseFigmaRef,
|
||||
regenerateIndex,
|
||||
sanitizeSvg,
|
||||
typeDirPath,
|
||||
updateRecord,
|
||||
type AssetSnippet,
|
||||
type FigmaAssetFormat,
|
||||
type FigmaClient,
|
||||
@@ -28,6 +30,10 @@ import { withFigmaErrors } from "./cliError.js";
|
||||
export interface AssetImportOptions {
|
||||
format: FigmaAssetFormat;
|
||||
scale?: number;
|
||||
/** human description — lands in the manifest + index.md + <img alt> */
|
||||
description?: string;
|
||||
/** media-use interop: entity name for `resolve --entity` cache hits */
|
||||
entity?: string;
|
||||
}
|
||||
|
||||
export interface AssetImportDeps {
|
||||
@@ -55,21 +61,39 @@ export async function runAssetImport(
|
||||
);
|
||||
|
||||
const { version } = await deps.client.fileVersion(ref.fileKey);
|
||||
const description = normalizeMeta(opts.description);
|
||||
const entity = normalizeMeta(opts.entity);
|
||||
|
||||
// Cache key per spec §5: fileKey:nodeId:format:scale:version → reuse.
|
||||
// Unspecified scale is canonically 1 on both sides (figma's default), so
|
||||
// `--scale 1` and no flag dedupe to the same record. Reuse also requires
|
||||
// the frozen file to still exist — a deleted file falls through to
|
||||
// re-import instead of returning a snippet that points at nothing.
|
||||
const existing = findByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId);
|
||||
if (
|
||||
existing &&
|
||||
existing.provenance.format === opts.format &&
|
||||
(existing.provenance.scale ?? 1) === (opts.scale ?? 1) &&
|
||||
existing.provenance.version === version &&
|
||||
existsSync(join(deps.projectDir, existing.path))
|
||||
) {
|
||||
return { record: existing, snippet: buildAssetSnippet(existing), reused: true };
|
||||
// Check EVERY row for the node (a node can legitimately have several
|
||||
// format/scale/version tuples — the oldest-row shortcut minted duplicates
|
||||
// forever once a second tuple existed). Unspecified scale is canonically 1
|
||||
// on both sides (figma's default). Reuse also requires the frozen file to
|
||||
// still exist — a deleted file falls through to re-import.
|
||||
const existing = findAllByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId).find(
|
||||
(r) =>
|
||||
r.provenance.format === opts.format &&
|
||||
(r.provenance.scale ?? 1) === (opts.scale ?? 1) &&
|
||||
r.provenance.version === version &&
|
||||
existsSync(join(deps.projectDir, r.path)),
|
||||
);
|
||||
if (existing) {
|
||||
// Metadata supplied on a re-import still lands: upsert the row instead
|
||||
// of silently discarding the flags.
|
||||
let record = existing;
|
||||
if (
|
||||
(description !== undefined && description !== existing.description) ||
|
||||
(entity !== undefined && entity !== existing.entity)
|
||||
) {
|
||||
record = {
|
||||
...existing,
|
||||
...(description !== undefined && { description }),
|
||||
...(entity !== undefined && { entity }),
|
||||
};
|
||||
updateRecord(deps.projectDir, record);
|
||||
}
|
||||
safeRegenerateIndex(deps.projectDir);
|
||||
return { record, snippet: buildAssetSnippet(record), reused: true };
|
||||
}
|
||||
|
||||
const rendered = await deps.client.renderNode(ref, opts);
|
||||
@@ -92,6 +116,8 @@ export async function runAssetImport(
|
||||
type: "image",
|
||||
path: relative(deps.projectDir, destAbs),
|
||||
source: `figma:${ref.fileKey}/${ref.nodeId}`,
|
||||
...(description !== undefined && { description }),
|
||||
...(entity !== undefined && { entity }),
|
||||
provenance: {
|
||||
source: "figma",
|
||||
fileKey: ref.fileKey,
|
||||
@@ -102,9 +128,29 @@ export async function runAssetImport(
|
||||
},
|
||||
};
|
||||
appendRecord(deps.projectDir, record);
|
||||
safeRegenerateIndex(deps.projectDir);
|
||||
return { record, snippet: buildAssetSnippet(record), reused: false };
|
||||
}
|
||||
|
||||
/** index.md is a single table row per record — newlines/tabs in a
|
||||
* description would corrupt the whole table. */
|
||||
function normalizeMeta(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const cleaned = value.replace(/\s+/g, " ").trim();
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
/** Keep the agent-readable inventory in step with the manifest (media-use
|
||||
* regenerates the same file after its writes). Best-effort: the import is
|
||||
* already durable, so an index write failure must not fail the command. */
|
||||
function safeRegenerateIndex(projectDir: string): void {
|
||||
try {
|
||||
regenerateIndex(projectDir);
|
||||
} catch (err) {
|
||||
console.warn(`index.md regeneration failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const FORMATS: readonly FigmaAssetFormat[] = ["png", "svg", "jpg", "pdf"];
|
||||
|
||||
function parseFormat(raw: string): FigmaAssetFormat {
|
||||
@@ -122,6 +168,14 @@ export default defineCommand({
|
||||
},
|
||||
format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" },
|
||||
scale: { type: "string", description: "export scale (e.g. 2)" },
|
||||
description: {
|
||||
type: "string",
|
||||
description: "what this asset is (index.md + <img alt>); e.g. the layer's purpose",
|
||||
},
|
||||
entity: {
|
||||
type: "string",
|
||||
description: 'entity name for media-use cache lookups (e.g. "Acme logo")',
|
||||
},
|
||||
dir: { type: "string", description: "project directory", default: "." },
|
||||
},
|
||||
async run({ args }) {
|
||||
@@ -133,6 +187,8 @@ export default defineCommand({
|
||||
{
|
||||
format: parseFormat(args.format),
|
||||
scale: args.scale !== undefined ? Number(args.scale) : undefined,
|
||||
description: args.description,
|
||||
entity: args.entity,
|
||||
},
|
||||
{ projectDir: args.dir, client, download: downloadRender },
|
||||
);
|
||||
|
||||
@@ -67,12 +67,14 @@ export async function runComponentImport(
|
||||
// The search key must match the EMITTED (html-escaped) node id, and
|
||||
// replaceAll covers the same node appearing twice in the tree.
|
||||
let html = mapped.html;
|
||||
const frozenAssets: string[] = [];
|
||||
for (const req of mapped.rasterize) {
|
||||
const asset = await runAssetImport(
|
||||
`${ref.fileKey}:${req.nodeId}`,
|
||||
{ format: "svg" },
|
||||
{ format: "svg", description: req.name },
|
||||
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
|
||||
);
|
||||
frozenAssets.push(asset.record.path);
|
||||
// src is a URL — always forward slashes, even when relative() yields
|
||||
// windows separators.
|
||||
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
|
||||
@@ -99,9 +101,11 @@ export async function runComponentImport(
|
||||
target: `compositions/components/${name}/${name}.html`,
|
||||
type: "hyperframes:snippet",
|
||||
},
|
||||
...mapped.rasterize.map((r) => ({
|
||||
path: `${r.slug}.svg`,
|
||||
target: `.media/images/${r.slug}.svg`,
|
||||
// The paths the frozen files ACTUALLY landed at (image_NNN.svg), which
|
||||
// is also what the emitted HTML references — not the slug names.
|
||||
...frozenAssets.map((p) => ({
|
||||
path: p.split("/").pop() ?? p,
|
||||
target: p.replaceAll("\\", "/"),
|
||||
type: "hyperframes:asset",
|
||||
})),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user