feat(plugins): figma-extract asset rasterisation second pass (Phase 6 entry slice)

Plan Q2 / spec §10.3.1 / §21.3.1.

apps/daemon/src/plugins/atoms/figma-extract.ts now honours
`offlineAssets: false` end-to-end. After the GET /v1/files pass,
the runner picks visible non-text leaf nodes that have a bounding
box, calls Figma's `GET /v1/images/<key>?ids=...&format=svg`
in 50-id chunks, and downloads each returned URL into
`<cwd>/figma/assets/<safe-id>.<ext>`.

Knobs:

  - `assetFormat`:    'svg' (default) | 'png' | 'jpg' | 'pdf'
  - `assetMaxBytes`:  per-asset cap, default 5 MiB
  - `offlineAssets`:  default true (preserves prior behaviour)

Per-id failures DO NOT abort the run — every issue lands in
`meta.unsupportedNodes` with reason='asset-too-large' / 'no
download URL returned' / 'download <status> <statusText>' / 'image
fetch error: <msg>' / 'figma error: <msg>'. The atom continues
through the remaining ids so a single CDN hiccup doesn't lose the
whole batch.

Filename safety: ids like '3:2' are passed through unchanged
(SQLite + JSON friendly); only chars outside [A-Za-z0-9_:-] are
collapsed to '-'.

atomDigest is re-computed after the asset pass settles so the
digest reflects which assets succeeded, even though the raw
bytes themselves are not part of the digest input (we hash the
issues list shape, not blobs).

Daemon tests: 1614 → 1618 (+4 cases on plugins-figma-extract:
multi-id download success, per-id null-URL surfaces issue without
aborting, assetMaxBytes skip + reason capture, offlineAssets=true
default keeps assets/ empty).

Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
Cursor Agent 2026-05-09 15:40:13 +00:00
parent 85beeb58c9
commit d408d98a4c
No known key found for this signature in database
2 changed files with 246 additions and 2 deletions

View file

@ -83,6 +83,15 @@ export interface FigmaExtractOptions {
// Default true; the daemon flips it off when the run has
// network capability granted.
offlineAssets?: boolean;
// Asset format for the GET /v1/images call. Default 'svg'; the
// Figma REST API also accepts 'png' / 'jpg' / 'pdf'. Spec §10.3.1
// recommends 'svg' for fidelity + replay; binary fixtures only
// when an asset's source is rasterised in Figma to begin with.
assetFormat?: 'svg' | 'png' | 'jpg' | 'pdf';
// Per-asset download size cap (bytes). Default 5 MiB. Above the cap
// the asset is skipped + listed in meta.unsupportedNodes[] with
// reason='asset-too-large' so the human can audit.
assetMaxBytes?: number;
}
const FILE_URL_RE = /^https:\/\/(?:www\.)?figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/;
@ -133,11 +142,128 @@ export async function runFigmaExtract(opts: FigmaExtractOptions): Promise<FigmaE
await fsp.mkdir(assetsDir, { recursive: true });
await fsp.writeFile(path.join(figmaDir, 'tree.json'), JSON.stringify(tree, null, 2) + '\n', 'utf8');
await fsp.writeFile(path.join(figmaDir, 'tokens.json'), JSON.stringify(tokens, null, 2) + '\n', 'utf8');
await fsp.writeFile(path.join(figmaDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
// 3. Asset rasterisation pass — GET /v1/images/<key>?ids=<ids>&format=<fmt>.
//
// Honoured only when offlineAssets !== true. Spec §10.3.1: asset
// exports cover every leaf node the file marks for export; v1
// lifts every leaf node we have a box for and lets the human
// prune from `figma/assets/<id>.<ext>` later.
const assetCandidates = pickAssetCandidates(tree);
if (opts.offlineAssets !== true && assetCandidates.length > 0) {
const assetFormat = opts.assetFormat ?? 'svg';
const assetMaxBytes = opts.assetMaxBytes ?? 5 * 1024 * 1024;
const assetIssues = await downloadAssets({
fileKey,
nodeIds: assetCandidates.map((c) => c.id),
token: opts.token,
fetchFn,
assetsDir,
assetFormat,
assetMaxBytes,
});
for (const issue of assetIssues) unsupportedNodes.push(issue);
meta.unsupportedNodes = unsupportedNodes;
// Re-derive atomDigest now that assets/ has settled (the digest
// is over the JSON shape, not the binary blobs, so this stays
// pure even with the on-disk side effects above).
meta.atomDigest = digestObject({ tree, tokens, assetIssues });
}
await fsp.writeFile(path.join(figmaDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
return { tree, tokens, meta };
}
interface AssetCandidate { id: string; type: string }
function pickAssetCandidates(tree: FigmaNode[]): AssetCandidate[] {
const out: AssetCandidate[] = [];
// We pick visible TEXT-less leaf nodes (no children) that have a
// bounding box and aren't the document / canvas root. The daemon
// already filtered out invisible nodes upstream.
for (const n of tree) {
if (n.type === 'DOCUMENT' || n.type === 'CANVAS') continue;
if (n.children && n.children.length > 0) continue;
if (!n.box) continue;
if (n.text) continue; // skip pure text nodes; the agent renders text natively
out.push({ id: n.id, type: n.type });
}
return out;
}
interface FigmaApiImagesResponse {
err?: string | null;
images?: Record<string, string | null>;
}
async function downloadAssets(args: {
fileKey: string;
nodeIds: string[];
token: string;
fetchFn: typeof fetch;
assetsDir: string;
assetFormat: 'svg' | 'png' | 'jpg' | 'pdf';
assetMaxBytes: number;
}): Promise<FigmaExtractReport['meta']['unsupportedNodes']> {
const issues: FigmaExtractReport['meta']['unsupportedNodes'] = [];
// Figma API caps at ~100 ids per call; chunk for safety.
const chunkSize = 50;
const chunks: string[][] = [];
for (let i = 0; i < args.nodeIds.length; i += chunkSize) {
chunks.push(args.nodeIds.slice(i, i + chunkSize));
}
for (const chunk of chunks) {
const url = `https://api.figma.com/v1/images/${encodeURIComponent(args.fileKey)}?ids=${encodeURIComponent(chunk.join(','))}&format=${args.assetFormat}`;
let res: Response;
try {
res = await args.fetchFn(url, { headers: { 'Authorization': `Bearer ${args.token}` } });
} catch (err) {
for (const id of chunk) {
issues.push({ id, type: 'asset', reason: `image fetch error: ${(err as Error).message}` });
}
continue;
}
if (!res.ok) {
const text = await safeText(res);
for (const id of chunk) {
issues.push({ id, type: 'asset', reason: `${res.status} ${res.statusText} ${text}`.trim() });
}
continue;
}
const body = await res.json() as FigmaApiImagesResponse;
if (body.err) {
for (const id of chunk) issues.push({ id, type: 'asset', reason: `figma error: ${body.err}` });
continue;
}
const images = body.images ?? {};
for (const id of chunk) {
const downloadUrl = images[id];
if (typeof downloadUrl !== 'string' || !downloadUrl) {
issues.push({ id, type: 'asset', reason: 'no download URL returned' });
continue;
}
try {
const dl = await args.fetchFn(downloadUrl);
if (!dl.ok) {
issues.push({ id, type: 'asset', reason: `download ${dl.status} ${dl.statusText}` });
continue;
}
const buf = Buffer.from(await dl.arrayBuffer());
if (buf.byteLength > args.assetMaxBytes) {
issues.push({ id, type: 'asset', reason: `asset-too-large (${buf.byteLength} bytes)` });
continue;
}
const ext = args.assetFormat === 'jpg' ? 'jpg' : args.assetFormat;
const safeId = id.replace(/[^A-Za-z0-9_:-]+/g, '-');
await fsp.writeFile(path.join(args.assetsDir, `${safeId}.${ext}`), buf);
} catch (err) {
issues.push({ id, type: 'asset', reason: `download error: ${(err as Error).message}` });
}
}
}
return issues;
}
function extractFileKey(fileUrl: string | undefined): string | undefined {
if (!fileUrl) return undefined;
const m = FILE_URL_RE.exec(fileUrl);

View file

@ -1,7 +1,7 @@
// Phase 6 entry slice — figma-extract atom impl.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runFigmaExtract } from '../src/plugins/atoms/figma-extract.js';
@ -149,6 +149,124 @@ describe('runFigmaExtract — happy paths', () => {
});
});
describe('runFigmaExtract — asset rasterisation', () => {
// Fixture with two non-text leaf nodes (3:2 + 3:3) so the asset
// candidate set is multi-id. The base fixtureFile only has one
// (3:2 — a RECTANGLE with a gradient fill).
const assetFixture = {
document: {
id: '0:0', name: 'Document', type: 'DOCUMENT',
children: [{
id: '1:1', name: 'Page', type: 'CANVAS',
children: [{
id: '2:1', name: 'Hero', type: 'FRAME',
absoluteBoundingBox: { x: 0, y: 0, width: 1280, height: 720 },
children: [
{ id: '3:2', name: 'Card 1', type: 'RECTANGLE',
absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 },
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }],
},
{ id: '3:3', name: 'Card 2', type: 'RECTANGLE',
absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 },
fills: [{ type: 'SOLID', color: { r: 0, g: 1, b: 0 } }],
},
],
}],
}],
},
};
// Multi-call stub: returns a different response per invocation.
const sequenceFetch = (responses: Array<{ ok?: boolean; status?: number; statusText?: string; body?: unknown; binary?: Buffer; text?: string }>) => {
let idx = 0;
return vi.fn(async (_url: string) => {
const r = responses[Math.min(idx, responses.length - 1)];
idx++;
if (!r) throw new Error('test stub: no response queued');
return {
ok: r.ok ?? true,
status: r.status ?? 200,
statusText: r.statusText ?? 'OK',
headers: { get: () => null },
json: async () => r.body ?? {},
text: async () => r.text ?? '',
arrayBuffer: async () => r.binary ? r.binary.buffer.slice(r.binary.byteOffset, r.binary.byteOffset + r.binary.byteLength) : new ArrayBuffer(0),
} as unknown as Response;
});
};
it('downloads assets per leaf node when offlineAssets=false', async () => {
const fetchFn = sequenceFetch([
{ body: assetFixture },
{ body: { images: { '3:2': 'https://cdn/a.svg', '3:3': 'https://cdn/b.svg' } } },
{ binary: Buffer.from('<svg>a</svg>') },
{ binary: Buffer.from('<svg>b</svg>') },
]);
const report = await runFigmaExtract({
cwd: cwd,
fileUrl: 'https://figma.com/file/ABC123/x',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
offlineAssets: false,
});
const assets = (await readdir(path.join(cwd, 'figma', 'assets'))).sort();
expect(assets).toEqual(['3:2.svg', '3:3.svg']);
expect(report.meta.unsupportedNodes.find((u) => u.id === '3:2' && u.type === 'asset')).toBeUndefined();
});
it('records per-id download issues without aborting the run', async () => {
const fetchFn = sequenceFetch([
{ body: assetFixture },
{ body: { images: { '3:2': null, '3:3': 'https://cdn/b.svg' } } },
{ binary: Buffer.from('<svg>b</svg>') },
]);
const report = await runFigmaExtract({
cwd: cwd,
fileUrl: 'https://figma.com/file/ABC123/x',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
offlineAssets: false,
});
const issues = report.meta.unsupportedNodes.filter((u) => u.type === 'asset');
expect(issues.find((i) => i.id === '3:2')?.reason).toMatch(/no download URL/);
const assets = await readdir(path.join(cwd, 'figma', 'assets'));
expect(assets).toEqual(['3:3.svg']);
});
it('skips assets above assetMaxBytes', async () => {
const fetchFn = sequenceFetch([
{ body: assetFixture },
{ body: { images: { '3:2': 'https://cdn/big.svg', '3:3': 'https://cdn/small.svg' } } },
{ binary: Buffer.alloc(8 * 1024) },
{ binary: Buffer.from('<svg>x</svg>') },
]);
const report = await runFigmaExtract({
cwd: cwd,
fileUrl: 'https://figma.com/file/ABC123/x',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
offlineAssets: false,
assetMaxBytes: 1024,
});
const skipped = report.meta.unsupportedNodes.find((u) => u.id === '3:2' && u.type === 'asset');
expect(skipped?.reason).toMatch(/asset-too-large/);
const assets = await readdir(path.join(cwd, 'figma', 'assets'));
expect(assets).toEqual(['3:3.svg']);
});
it('keeps assets/ empty when offlineAssets=true (default)', async () => {
const fetchFn = sequenceFetch([{ body: fixtureFile }]);
await runFigmaExtract({
cwd: cwd,
fileUrl: 'https://figma.com/file/ABC123/x',
token: 'tok',
fetchFn: fetchFn as unknown as typeof fetch,
});
const assets = await readdir(path.join(cwd, 'figma', 'assets'));
expect(assets).toEqual([]);
});
});
describe('runFigmaExtract — error paths', () => {
it('throws when neither fileUrl nor fileKey resolves', async () => {
await expect(runFigmaExtract({