Large documents stay interactive: the canvas re-renders only what a pan uncovers or an edit touches, the layers panel lays out only the rows in view, and per-frame work no longer walks the whole page. - Benchmark: `cargo xtask bench [SIZES]` builds synthetic pages (`figcraft_engine::synthetic`: screens with an auto layout header, a card group, button instances and shapes/text) at 1k/10k/50k layers and prints engine/render timings (`figcraft-cli perf --layers 1000,10000`) and headless whole-UI frame timings (an ignored ui-egui test). - Retained canvas rendering (`figcraft_render::Retained`, used by the render worker): views snap to whole device pixels, so a pan shifts the previous image in place and renders only the uncovered strips; an edit renders only where changed layers were and are (`Document::changed_ids` diffs the two versions skipping shared structure; ancestors that paint from their children's pixels - booleans, effects, masks - count whole; background blur reach and noise are allowed for). Zoom, page, options, font or page-level changes, or large changes, render the whole view as before. `ui.inspect` reports `canvas.renderWork`. - The texture image is converted on the render thread, not the UI thread. - Layers panel: only visible rows are laid out (stable ids while scrolling); rows are cached per document version, page, expanded set and filter. - Frame labels and layout grids of off-screen top-level frames are skipped; the Prototype tab's connection sources are cached per document version. - Text: the layout cache keys by a cheap fingerprint confirmed by `TextData` equality (no Debug formatting per lookup, 32k entries) and hands out shared layouts; a layout generation marks renders stale when fonts arrive. Overflowing text is culled by its glyphs, so it no longer vanishes while its box is off screen. Before -> after (release, Windows, 1440x900 view, ms; 10k / 50k layers): - move one layer, re-render @100%: 7.65 / 6.49 -> 0.18 / 0.81 - move one layer, re-render zoomed out: 11.95 / 24.10 -> 0.16 / 0.77 - pan 40 px @100%: 7.53 / 6.82 -> 0.69 / 0.81 - pan 40 px zoomed out: 12.07 / 24.30 -> 1.62 / 4.17 - full render @100% / zoomed to fit: 7.78 / 44.22 -> 6.56 / 38.83 (10k @100%, 50k fit) - UI frame, every screen expanded in layers: 6.16 / 39.07 -> 0.24 / 0.57 - UI frame, scrolling that panel: 6.11 / 40.18 -> 0.43 / 0.40 - UI frame, layers filtered by name: 1.03 / 6.92 -> 0.17 / 0.36 - UI frame, Prototype tab: 0.89 / 6.45 -> 0.18 / 0.80 - UI frame, idle: 0.31 / 1.43 -> 0.11 / 0.39 Unchanged and within budget: single-layer edits through the engine ~0.01 ms, normalize after a one-node edit < 0.01 ms, hit test 26 / 93 us. Left: save/load at 50k (~0.7 s each), zoomed-out renders at 50k (~40 ms). Tests: retained renders match full renders after pans, moves, recolours, hide/remove/add, undo, masks, overflowing text, edits under a background blur and an inner-shadow/noise layer, a drop- shadowed group, and a blur panned in from the view's edge; zoom/page/options/page background render full; junk views don't panic. Layer-row and prototype-source caches follow edits, undo, page switches, expansion and the filter. `changed_ids`, the text cache, and the perf CLI. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
4.3 KiB
figcraft-cli
FigCraft without a window: inspect, export, convert and render documents, run command scripts, list
commands, serve MCP and measure performance. Build it with
cargo build --release -p figcraft-cli (binary target/release/figcraft-cli).
Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input.
Subcommands
info <file> [--json]
Pages, node counts, top-level frames (id, type, name, size), style/variable/image counts. Repairs
of a damaged file and import warnings go to stderr as note: lines (notes with --json).
Every subcommand that reads a file also takes the public REST API file JSON (the response of
GET /v1/files/:key or of its nodes endpoint, written to a .json file): it is imported on the fly.
Image fills come from images/<imageRef>.png (or .jpg, …) next to the JSON when present, else a
grey placeholder.
$ figcraft-cli info app.figcraft
app.figcraft — App (412 nodes, 6 styles, 12 variables, 3 images)
page 1 "Screens": 380 nodes, 4 top-level
2 FRAME "Home" 393.0×852.0
…
export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME]
<out>is a directory: one file per top-level frame of the page (default the first page), named after the frame (Home.png,Settings.png…); with no frames, one file per top-level layer.<out>has an image extension: one file with the given--node, or the whole page composited.--nodetakes a layer id (12or0:12) or a layer name.- The format comes from
--format, else<out>'s extension, else png.--scale(default 1, max 64) applies to raster formats.
figcraft-cli export app.figcraft -o exports/ --scale 2
figcraft-cli export app.figcraft -o hero.svg --node "Hero"
convert <in> <out>
By extension. Inputs: .figcraft, .json (FigCraft or REST API file JSON), .svg (becomes vectors), .png/.jpg/.webp/.gif/.bmp
(an image layer). Outputs: .figcraft, .json (FigCraft JSON, pretty-printed), .svg,
.png/.jpg/.webp (the current page rendered at 1x with its background), .pdf (when PDF export is
available).
render <file> -o <out.png> [--scale N] [--page NAME]
The whole page (default the first) with its background, as png, jpg or webp. Large pages are scaled down to fit 16384 px.
run <script.json|script.jsonl> [--in <file>] [--out <file>]
Runs commands in a headless session (a new document, or --in), printing one JSON line per step:
{"command":"frame.create","ok":true,"result":{"id":2},"step":0}
A failing step prints "ok": false with the error and exits with code 1 (nothing is saved).
--out saves the result by extension like convert.
Script formats: a JSON array of {"command": id, "params": {...}}, an object
{"commands": [...]}, or JSON lines (one step per line; blank lines and // lines are skipped).
[
{"command": "frame.create", "params": {"preset": "iPhone 16", "x": 0, "y": 0, "name": "Home"}},
{"command": "node.create", "params": {"type": "text", "parent": 2, "x": 24, "y": 64, "text": "Welcome", "props": {"fontSize": 32}}},
{"command": "layout.add", "params": {"ids": [2], "direction": "vertical"}}
]
Ids are assigned in order (a new document's first page is 1, so the first layer is 2), which lets a script refer to layers it created.
commands [--json] [--filter TEXT]
Every command with its label, shortcut, undoable flag (*) and parameter docs; --json for
machines.
mcp [--attach PORT]
Serve MCP on stdio: headless by default, or attached to a running app started with
figcraft --control PORT. See mcp.md.
perf [--layers N[,N...]]
Builds synthetic documents of each size (default 1000,10000 layers: screens with an auto layout
header, a card group, button instances and a grid of shapes and text) and prints a Markdown table of
the hot paths: build + normalise, save/load, 1440×900 renders at 100% and zoomed to fit, hit testing
and hover, single-layer edits through the engine (fill, move, text in auto layout, a main component
with all its instances), undo/redo, a drag preview frame, normalize after a one-node edit,
document.inspect and select-all + move. Use a release build for meaningful numbers;
cargo xtask bench runs it at 1k/10k/50k layers together with the UI frame benchmarks.