@flatkit/compiler 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  // src/cli/render.ts
2
2
  import { FlatPlayer } from "@flatkit/player";
3
+ import { isGroup, isInstance } from "@flatkit/engine/layers";
3
4
  import { mkdtempSync, writeFileSync, rmSync } from "fs";
4
5
  import { tmpdir } from "os";
5
6
  import { join } from "path";
@@ -33,24 +34,25 @@ function registerFonts(doc, FontLibrary) {
33
34
  }
34
35
  return { dir, families };
35
36
  }
36
- async function renderDocToPng(doc, opts = {}) {
37
+ async function createRenderer(doc, opts = {}) {
37
38
  const skiaPkg = "skia-canvas";
38
39
  let skia;
39
40
  try {
40
41
  skia = await import(skiaPkg);
41
42
  } catch {
42
43
  throw new Error(
43
- 'skia-canvas is required for --render. Install it as a dev dependency: `npm i -D skia-canvas` (pnpm users: also allow its build script with `pnpm approve-builds` or add "skia-canvas" to pnpm.onlyBuiltDependencies, then reinstall). If the native binary is still missing afterwards, run `node node_modules/skia-canvas/lib/prebuild.mjs download`.'
44
+ 'skia-canvas is required for rendering. Install it as a dev dependency: `npm i -D skia-canvas` (pnpm users: also allow its build script with `pnpm approve-builds` or add "skia-canvas" to pnpm.onlyBuiltDependencies, then reinstall). If the native binary is still missing afterwards, run `node node_modules/skia-canvas/lib/prebuild.mjs download`.'
44
45
  );
45
46
  }
46
47
  const { Canvas, loadImage, Path2D, FontLibrary, DOMMatrix } = skia;
47
48
  const scale = opts.scale && opts.scale > 0 ? opts.scale : 2;
48
49
  const W = doc.width, H = doc.height;
49
- const { dir: fontDir, families } = registerFonts(doc, FontLibrary);
50
+ const withParams = opts.params ? applyParams(doc, opts.params) : doc;
51
+ const { dir: fontDir, families } = registerFonts(withParams, FontLibrary);
50
52
  if (families.length) process.stderr.write(`flatc: registered ${families.length} font famil${families.length > 1 ? "ies" : "y"}: ${families.join(", ")}
51
53
  `);
52
54
  const images = /* @__PURE__ */ new Map();
53
- for (const a of doc.assets ?? []) {
55
+ for (const a of withParams.assets ?? []) {
54
56
  if (a.kind === "svg" || a.kind === "image" || /^data:image\//.test(a.data ?? "")) {
55
57
  try {
56
58
  images.set(a.id, await loadImage(a.data));
@@ -77,7 +79,9 @@ async function renderDocToPng(doc, opts = {}) {
77
79
  set("cancelAnimationFrame", () => {
78
80
  });
79
81
  set("document", { createElement: (t) => t === "canvas" ? new Canvas(1, 1) : {} });
80
- const canvas = new Canvas(Math.max(1, Math.round(W * scale)), Math.max(1, Math.round(H * scale)));
82
+ const pxW = Math.max(1, Math.round(W * scale));
83
+ const pxH = Math.max(1, Math.round(H * scale));
84
+ const canvas = new Canvas(pxW, pxH);
81
85
  const el = canvas;
82
86
  Object.assign(el, {
83
87
  getBoundingClientRect: () => ({ width: W, height: H, left: 0, top: 0, right: W, bottom: H }),
@@ -87,31 +91,79 @@ async function renderDocToPng(doc, opts = {}) {
87
91
  },
88
92
  style: {}
89
93
  });
90
- try {
91
- const pl = new FlatPlayer(el, doc, { input: false, audio: false, padding: 0, image: (id) => images.get(id) ?? null });
92
- if (opts.vars) for (const [k, v] of Object.entries(opts.vars)) pl.setVar(k, v);
93
- pl.seek(opts.frame ?? 0);
94
- if (opts.steps && opts.steps > 0) {
95
- const n = Math.min(Math.floor(opts.steps), MAX_RENDER_STEPS);
96
- if (opts.steps > MAX_RENDER_STEPS) process.stderr.write(`flatc: --steps clamped to ${MAX_RENDER_STEPS} (was ${opts.steps})
94
+ let player = new FlatPlayer(el, withParams, { input: false, audio: false, padding: 0, image: (id) => images.get(id) ?? null });
95
+ return {
96
+ width: pxW,
97
+ height: pxH,
98
+ async frame(frame, frameOpts = {}) {
99
+ if (!player) throw new Error("renderer is closed");
100
+ if (frameOpts.vars) for (const [k, v] of Object.entries(frameOpts.vars)) player.setVar(k, v);
101
+ player.seek(frame);
102
+ const steps = frameOpts.steps ?? 0;
103
+ if (steps > 0) {
104
+ const n = Math.min(Math.floor(steps), MAX_RENDER_STEPS);
105
+ if (steps > MAX_RENDER_STEPS) process.stderr.write(`flatc: steps clamped to ${MAX_RENDER_STEPS} (was ${steps})
97
106
  `);
98
- pl.stepSim(n);
99
- }
100
- const png = await el.toBuffer("png");
101
- pl.destroy();
102
- return png;
103
- } finally {
104
- for (const k in saved) {
105
- if (saved[k] === void 0) delete g[k];
106
- else g[k] = saved[k];
107
+ player.stepSim(n);
108
+ }
109
+ return el.toBuffer("png");
110
+ },
111
+ close() {
112
+ player?.destroy();
113
+ player = null;
114
+ for (const k in saved) {
115
+ if (saved[k] === void 0) delete g[k];
116
+ else g[k] = saved[k];
117
+ }
118
+ if (fontDir) try {
119
+ rmSync(fontDir, { recursive: true, force: true });
120
+ } catch {
121
+ }
107
122
  }
108
- if (fontDir) try {
109
- rmSync(fontDir, { recursive: true, force: true });
110
- } catch {
123
+ };
124
+ }
125
+ function applyParams(doc, params) {
126
+ const entries = Object.entries(params);
127
+ if (!entries.length) return doc;
128
+ const exposed = /* @__PURE__ */ new Map();
129
+ for (const sym of doc.symbols) {
130
+ exposed.set(sym.id, /* @__PURE__ */ new Set([...(sym.states ?? []).map((st) => st.param), ...(sym.params ?? []).map((p) => p.name)]));
131
+ }
132
+ const out = structuredClone(doc);
133
+ const applied = /* @__PURE__ */ new Set();
134
+ const visit = (items) => {
135
+ for (const it of items) {
136
+ if (isInstance(it)) {
137
+ const known = exposed.get(it.symbolId);
138
+ if (known) {
139
+ for (const [k, v] of entries) {
140
+ if (!known.has(k)) continue;
141
+ (it.params ??= {})[k] = v;
142
+ applied.add(k);
143
+ }
144
+ }
145
+ }
146
+ if (isGroup(it)) for (const l of it.layers) visit(l.items);
111
147
  }
148
+ };
149
+ for (const l of out.layers) visit(l.items);
150
+ for (const sym of out.symbols) for (const l of sym.layers) visit(l.items);
151
+ for (const [k] of entries) {
152
+ if (!applied.has(k)) process.stderr.write(`flatc: no symbol exposes a param named "${k}"
153
+ `);
154
+ }
155
+ return out;
156
+ }
157
+ async function renderDocToPng(doc, opts = {}) {
158
+ const renderer = await createRenderer(doc, { scale: opts.scale, params: opts.params });
159
+ try {
160
+ return await renderer.frame(opts.frame ?? 0, { vars: opts.vars, steps: opts.steps });
161
+ } finally {
162
+ renderer.close();
112
163
  }
113
164
  }
114
165
  export {
166
+ createRenderer,
115
167
  renderDocToPng
116
168
  };
117
169
  //# sourceMappingURL=render.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/cli/render.ts"],"sourcesContent":["// -----------------------------------------------------------------------------\n// render.ts -- HEADLESS PNG rendering of a Doc (skia-canvas backend), for `flatc --render`.\n//\n// Gives agents/CI an IMAGE of what they author (the \"blind positioning\" safeguard). We replay the\n// real `FlatPlayer` on a skia canvas: same paths, gradients, filters (glow/shadow via `ctx.filter`)\n// and SVG as the browser. skia-canvas is loaded through a DYNAMIC import with a NON-LITERAL specifier,\n// so it is never part of the public build graph -- install it on demand only when `--render` is used.\n// -----------------------------------------------------------------------------\nimport { FlatPlayer } from '@flatkit/player'\nimport type { Doc } from '@flatkit/types'\nimport { mkdtempSync, writeFileSync, rmSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join } from 'node:path'\n\nexport type RenderOpts = { frame?: number; vars?: Record<string, number>; scale?: number; steps?: number }\n\n/** Cap on `--steps` (anti-DoS: an untrusted doc must not freeze the render host). One step = 1/60 s of sim. */\nconst MAX_RENDER_STEPS = 10_000\n\n/** Minimal structural view of the `skia-canvas` surface we use. Kept local so the public build needs\n * no native binary; the real module is resolved at runtime only when `--render` runs. */\ninterface SkiaCanvas {\n Canvas: new (w: number, h: number) => HTMLCanvasElement & { toBuffer(fmt: string): Promise<Uint8Array> }\n loadImage: (src: string) => Promise<unknown>\n Path2D: typeof Path2D\n /** Browser global under Node: the mask/clip path builder does `new DOMMatrix([...])` (drawScene). */\n DOMMatrix: typeof DOMMatrix\n /** Global font registry. `use(paths)` reads each file's intrinsic name-table family; the 2-arg\n * `use(family, paths)` form FORCES that alias (fixes variable-font statics whose name table lies). */\n FontLibrary: {\n use(fontPaths: readonly string[]): Array<{ family: string }>\n use(family: string, fontPaths: readonly string[]): Array<{ family: string }>\n }\n}\n\n/** Map a font asset's MIME (or its data-URI) to a file extension skia-canvas can parse. */\nconst FONT_EXT: Record<string, string> = {\n 'font/woff2': '.woff2', 'font/woff': '.woff', 'font/ttf': '.ttf', 'font/otf': '.otf',\n 'font/collection': '.ttc', 'application/font-woff': '.woff', 'application/x-font-ttf': '.ttf',\n}\n\n/** Registers every embedded `font` asset with skia's FontLibrary so headless text uses the authored\n * faces (not host fallbacks). Fonts are baked as base64 data-URIs by flatc; FontLibrary only reads\n * FILE paths, so we materialize each into a temp dir, register it, and return that dir for cleanup. */\nfunction registerFonts(doc: Doc, FontLibrary: SkiaCanvas['FontLibrary']): { dir: string | null; families: string[] } {\n const fonts = (doc.assets ?? []).filter((a) => a.kind === 'font' && /^data:/.test(a.data ?? ''))\n if (fonts.length === 0) return { dir: null, families: [] }\n const dir = mkdtempSync(join(tmpdir(), 'flatc-fonts-'))\n const families: string[] = []\n for (const a of fonts) {\n const b64 = a.data.slice(a.data.indexOf(',') + 1)\n const ext = FONT_EXT[a.mime] ?? `.${(a.mime.split('/')[1] || 'ttf').replace(/[^\\w]/g, '')}`\n const file = join(dir, a.id.replace(/[^\\w.-]/g, '_') + ext)\n try {\n writeFileSync(file, Buffer.from(b64, 'base64'))\n // `family` alias (from `asset … font \"Name\"`) forces the registered name, so the face matches the\n // text's `font \"Name\"` even when the file's own name table is wrong (variable-font static export).\n const used = a.family ? FontLibrary.use(a.family, [file]) : FontLibrary.use([file])\n for (const f of used) if (!families.includes(f.family)) families.push(f.family)\n } catch (e) {\n process.stderr.write(`flatc: skipped font asset \"${a.id}\" (${a.mime}): ${(e as Error).message}\\n`)\n }\n }\n return { dir, families }\n}\n\n/** Render `doc` to a PNG (Buffer). `frame` = target image; `vars` = state override (`--at`); `scale` = x-px (default 2). */\nexport async function renderDocToPng(doc: Doc, opts: RenderOpts = {}): Promise<Uint8Array> {\n // Non-literal specifier: tsc does not resolve it, so skia-canvas is not a build dependency.\n const skiaPkg: string = 'skia-canvas'\n let skia: SkiaCanvas\n try { skia = (await import(skiaPkg)) as unknown as SkiaCanvas }\n catch {\n throw new Error(\n 'skia-canvas is required for --render. Install it as a dev dependency: `npm i -D skia-canvas` ' +\n '(pnpm users: also allow its build script with `pnpm approve-builds` or add \"skia-canvas\" to ' +\n 'pnpm.onlyBuiltDependencies, then reinstall). If the native binary is still missing afterwards, ' +\n 'run `node node_modules/skia-canvas/lib/prebuild.mjs download`.',\n )\n }\n const { Canvas, loadImage, Path2D, FontLibrary, DOMMatrix } = skia\n const scale = opts.scale && opts.scale > 0 ? opts.scale : 2\n const W = doc.width, H = doc.height\n\n // Register embedded fonts up front so the player's `ctx.font` resolves to the authored faces.\n const { dir: fontDir, families } = registerFonts(doc, FontLibrary)\n if (families.length) process.stderr.write(`flatc: registered ${families.length} font famil${families.length > 1 ? 'ies' : 'y'}: ${families.join(', ')}\\n`)\n\n // Pre-decode the image assets (skia reads SVG/PNG/... from the data-URIs embedded by flatc).\n const images = new Map<string, CanvasImageSource>()\n for (const a of doc.assets ?? []) {\n if (a.kind === 'svg' || a.kind === 'image' || /^data:image\\//.test(a.data ?? '')) {\n try { images.set(a.id, (await loadImage(a.data)) as unknown as CanvasImageSource) } catch { /* unreadable asset: ignored (placeholder) */ }\n }\n }\n\n // Globals the player/rendering expects under Node (restored on exit).\n const g = globalThis as Record<string, unknown>\n const saved: Record<string, unknown> = {}\n const set = (k: string, v: unknown) => { saved[k] = g[k]; g[k] = v }\n set('Path2D', Path2D)\n set('DOMMatrix', DOMMatrix) // mask/clip layers build their clip path with `new DOMMatrix([...])` (no browser global under Node)\n set('window', { addEventListener() {}, removeEventListener() {}, devicePixelRatio: scale })\n set('addEventListener', () => {})\n set('removeEventListener', () => {})\n set('requestAnimationFrame', () => 0)\n set('cancelAnimationFrame', () => {})\n set('document', { createElement: (t: string) => (t === 'canvas' ? new Canvas(1, 1) : {}) }) // off-screen canvas (filters/tint)\n\n const canvas = new Canvas(Math.max(1, Math.round(W * scale)), Math.max(1, Math.round(H * scale)))\n const el = canvas as unknown as HTMLCanvasElement & { toBuffer(fmt: string): Promise<Uint8Array> }\n Object.assign(el, {\n getBoundingClientRect: () => ({ width: W, height: H, left: 0, top: 0, right: W, bottom: H }),\n addEventListener: () => {}, removeEventListener: () => {}, style: {},\n })\n\n try {\n const pl = new FlatPlayer(el, doc, { input: false, audio: false, padding: 0, image: (id) => images.get(id) ?? null })\n if (opts.vars) for (const [k, v] of Object.entries(opts.vars)) pl.setVar(k, v)\n pl.seek(opts.frame ?? 0) // applies frame + state\n if (opts.steps && opts.steps > 0) {\n const n = Math.min(Math.floor(opts.steps), MAX_RENDER_STEPS)\n if (opts.steps > MAX_RENDER_STEPS) process.stderr.write(`flatc: --steps clamped to ${MAX_RENDER_STEPS} (was ${opts.steps})\\n`)\n pl.stepSim(n) // run N fixed sim steps (onEnterFrame) so a stateful act unfolds before capture\n }\n const png = await el.toBuffer('png')\n pl.destroy()\n return png\n } finally {\n for (const k in saved) { if (saved[k] === undefined) delete g[k]; else g[k] = saved[k] }\n // skia keeps the parsed faces in memory after `use()`, so the temp files are safe to drop now.\n if (fontDir) try { rmSync(fontDir, { recursive: true, force: true }) } catch { /* best-effort cleanup */ }\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/cli/render.ts"],"sourcesContent":["// -----------------------------------------------------------------------------\n// render.ts -- HEADLESS PNG rendering of a Doc (skia-canvas backend), for `flatc --render`.\n//\n// Gives agents/CI an IMAGE of what they author (the \"blind positioning\" safeguard). We replay the\n// real `FlatPlayer` on a skia canvas: same paths, gradients, filters (glow/shadow via `ctx.filter`)\n// and SVG as the browser. skia-canvas is loaded through a DYNAMIC import with a NON-LITERAL specifier,\n// so it is never part of the public build graph -- install it on demand only when `--render` is used.\n// -----------------------------------------------------------------------------\nimport { FlatPlayer } from '@flatkit/player'\nimport type { Doc, Item } from '@flatkit/types'\nimport { isGroup, isInstance } from '@flatkit/engine/layers'\nimport { mkdtempSync, writeFileSync, rmSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join } from 'node:path'\n\nexport type RenderOpts = { frame?: number; vars?: Record<string, number>; scale?: number; steps?: number; params?: Record<string, string> }\n\n/** Cap on `--steps` (anti-DoS: an untrusted doc must not freeze the render host). One step = 1/60 s of sim. */\nconst MAX_RENDER_STEPS = 10_000\n\n/** Minimal structural view of the `skia-canvas` surface we use. Kept local so the public build needs\n * no native binary; the real module is resolved at runtime only when `--render` runs. */\ninterface SkiaCanvas {\n Canvas: new (w: number, h: number) => HTMLCanvasElement & { toBuffer(fmt: string): Promise<Uint8Array> }\n loadImage: (src: string) => Promise<unknown>\n Path2D: typeof Path2D\n /** Browser global under Node: the mask/clip path builder does `new DOMMatrix([...])` (drawScene). */\n DOMMatrix: typeof DOMMatrix\n /** Global font registry. `use(paths)` reads each file's intrinsic name-table family; the 2-arg\n * `use(family, paths)` form FORCES that alias (fixes variable-font statics whose name table lies). */\n FontLibrary: {\n use(fontPaths: readonly string[]): Array<{ family: string }>\n use(family: string, fontPaths: readonly string[]): Array<{ family: string }>\n }\n}\n\n/** Map a font asset's MIME (or its data-URI) to a file extension skia-canvas can parse. */\nconst FONT_EXT: Record<string, string> = {\n 'font/woff2': '.woff2', 'font/woff': '.woff', 'font/ttf': '.ttf', 'font/otf': '.otf',\n 'font/collection': '.ttc', 'application/font-woff': '.woff', 'application/x-font-ttf': '.ttf',\n}\n\n/** Registers every embedded `font` asset with skia's FontLibrary so headless text uses the authored\n * faces (not host fallbacks). Fonts are baked as base64 data-URIs by flatc; FontLibrary only reads\n * FILE paths, so we materialize each into a temp dir, register it, and return that dir for cleanup. */\nfunction registerFonts(doc: Doc, FontLibrary: SkiaCanvas['FontLibrary']): { dir: string | null; families: string[] } {\n const fonts = (doc.assets ?? []).filter((a) => a.kind === 'font' && /^data:/.test(a.data ?? ''))\n if (fonts.length === 0) return { dir: null, families: [] }\n const dir = mkdtempSync(join(tmpdir(), 'flatc-fonts-'))\n const families: string[] = []\n for (const a of fonts) {\n const b64 = a.data.slice(a.data.indexOf(',') + 1)\n const ext = FONT_EXT[a.mime] ?? `.${(a.mime.split('/')[1] || 'ttf').replace(/[^\\w]/g, '')}`\n const file = join(dir, a.id.replace(/[^\\w.-]/g, '_') + ext)\n try {\n writeFileSync(file, Buffer.from(b64, 'base64'))\n // `family` alias (from `asset … font \"Name\"`) forces the registered name, so the face matches the\n // text's `font \"Name\"` even when the file's own name table is wrong (variable-font static export).\n const used = a.family ? FontLibrary.use(a.family, [file]) : FontLibrary.use([file])\n for (const f of used) if (!families.includes(f.family)) families.push(f.family)\n } catch (e) {\n process.stderr.write(`flatc: skipped font asset \"${a.id}\" (${a.mime}): ${(e as Error).message}\\n`)\n }\n }\n return { dir, families }\n}\n\n/**\n * A renderer held OPEN over a document: the expensive setup is paid once, then any number of frames.\n *\n * Every consumer that needed more than one image — a GIF, an MP4, a contact sheet, a loop check —\n * reimplemented this on top of the player, because the only thing on offer rendered a single frame and\n * paid the whole cost each time: the dynamic `skia-canvas` import, writing the embedded fonts to a temp\n * dir and registering them, decoding every image asset, building a `FlatPlayer`, installing ten globals.\n * Four such harnesses exist across two neighbouring repos, ~430 lines, each rediscovering the same\n * shims — including that `document` must exist or every `filter` and `tint` is dropped in SILENCE.\n */\nexport type Renderer = {\n /** PNG of one frame. `vars` overrides state for this frame; `steps` runs N sim steps before capture. */\n frame(frame: number, opts?: { vars?: Record<string, number>; steps?: number }): Promise<Uint8Array>\n /** The document's pixel size at the renderer's scale. */\n readonly width: number\n readonly height: number\n /** Restore the globals and drop the temp font files. Always call it — it is not optional. */\n close(): void\n}\n\n/**\n * Open a renderer over `doc`. The globals it installs (`document`, `Path2D`, `DOMMatrix`, …) stay in\n * place until `close()`, which is what makes a frame loop cheap; it also means one renderer at a time\n * per process. `params` sets a SYMBOL's exposed params (a state name or a number) before the first\n * frame — the reason a consumer had to write its own harness to preview anything with a state.\n */\nexport async function createRenderer(doc: Doc, opts: { scale?: number; params?: Record<string, string> } = {}): Promise<Renderer> {\n // Non-literal specifier: tsc does not resolve it, so skia-canvas is not a build dependency.\n const skiaPkg: string = 'skia-canvas'\n let skia: SkiaCanvas\n try { skia = (await import(skiaPkg)) as unknown as SkiaCanvas }\n catch {\n throw new Error(\n 'skia-canvas is required for rendering. Install it as a dev dependency: `npm i -D skia-canvas` ' +\n '(pnpm users: also allow its build script with `pnpm approve-builds` or add \"skia-canvas\" to ' +\n 'pnpm.onlyBuiltDependencies, then reinstall). If the native binary is still missing afterwards, ' +\n 'run `node node_modules/skia-canvas/lib/prebuild.mjs download`.',\n )\n }\n const { Canvas, loadImage, Path2D, FontLibrary, DOMMatrix } = skia\n const scale = opts.scale && opts.scale > 0 ? opts.scale : 2\n const W = doc.width, H = doc.height\n\n const withParams = opts.params ? applyParams(doc, opts.params) : doc\n\n // Register embedded fonts up front so the player's `ctx.font` resolves to the authored faces.\n const { dir: fontDir, families } = registerFonts(withParams, FontLibrary)\n if (families.length) process.stderr.write(`flatc: registered ${families.length} font famil${families.length > 1 ? 'ies' : 'y'}: ${families.join(', ')}\\n`)\n\n // Pre-decode the image assets (skia reads SVG/PNG/... from the data-URIs embedded by flatc).\n const images = new Map<string, CanvasImageSource>()\n for (const a of withParams.assets ?? []) {\n if (a.kind === 'svg' || a.kind === 'image' || /^data:image\\//.test(a.data ?? '')) {\n try { images.set(a.id, (await loadImage(a.data)) as unknown as CanvasImageSource) } catch { /* unreadable asset: ignored (placeholder) */ }\n }\n }\n\n // Globals the player/rendering expects under Node (restored by close()).\n const g = globalThis as Record<string, unknown>\n const saved: Record<string, unknown> = {}\n const set = (k: string, v: unknown) => { saved[k] = g[k]; g[k] = v }\n set('Path2D', Path2D)\n set('DOMMatrix', DOMMatrix) // mask/clip layers build their clip path with `new DOMMatrix([...])` (no browser global under Node)\n set('window', { addEventListener() {}, removeEventListener() {}, devicePixelRatio: scale })\n set('addEventListener', () => {})\n set('removeEventListener', () => {})\n set('requestAnimationFrame', () => 0)\n set('cancelAnimationFrame', () => {})\n set('document', { createElement: (t: string) => (t === 'canvas' ? new Canvas(1, 1) : {}) }) // off-screen canvas (filters/tint)\n\n const pxW = Math.max(1, Math.round(W * scale))\n const pxH = Math.max(1, Math.round(H * scale))\n const canvas = new Canvas(pxW, pxH)\n const el = canvas as unknown as HTMLCanvasElement & { toBuffer(fmt: string): Promise<Uint8Array> }\n Object.assign(el, {\n getBoundingClientRect: () => ({ width: W, height: H, left: 0, top: 0, right: W, bottom: H }),\n addEventListener: () => {}, removeEventListener: () => {}, style: {},\n })\n\n let player: FlatPlayer | null = new FlatPlayer(el, withParams, { input: false, audio: false, padding: 0, image: (id) => images.get(id) ?? null })\n\n return {\n width: pxW,\n height: pxH,\n async frame(frame, frameOpts = {}) {\n if (!player) throw new Error('renderer is closed')\n if (frameOpts.vars) for (const [k, v] of Object.entries(frameOpts.vars)) player.setVar(k, v)\n player.seek(frame) // applies frame + state\n const steps = frameOpts.steps ?? 0\n if (steps > 0) {\n const n = Math.min(Math.floor(steps), MAX_RENDER_STEPS)\n if (steps > MAX_RENDER_STEPS) process.stderr.write(`flatc: steps clamped to ${MAX_RENDER_STEPS} (was ${steps})\\n`)\n player.stepSim(n) // run N fixed sim steps (onEnterFrame) so a stateful act unfolds before capture\n }\n return el.toBuffer('png')\n },\n close() {\n player?.destroy()\n player = null\n for (const k in saved) { if (saved[k] === undefined) delete g[k]; else g[k] = saved[k] }\n // skia keeps the parsed faces in memory after `use()`, so the temp files are safe to drop now.\n if (fontDir) try { rmSync(fontDir, { recursive: true, force: true }) } catch { /* best-effort cleanup */ }\n },\n }\n}\n\n/**\n * A copy of `doc` with the scene's instances carrying `params` — a state NAME or a number, the same\n * spelling `--preview --set` accepts, resolved by the renderer per the symbol's ParamDef / state\n * machines. Rendering a program could only override document `var`s, so previewing a door in its `open`\n * state meant writing a harness that mutated the symbol by hand. That was one of four such harnesses.\n *\n * Set on the INSTANCE, not on the symbol's defaults: that is where a call-site value belongs, and it is\n * what `--preview` already does.\n */\nfunction applyParams(doc: Doc, params: Record<string, string>): Doc {\n const entries = Object.entries(params)\n if (!entries.length) return doc\n // Which names each symbol exposes: its typed params, plus the param each state machine drives.\n const exposed = new Map<string, Set<string>>()\n for (const sym of doc.symbols) {\n exposed.set(sym.id, new Set([...(sym.states ?? []).map((st) => st.param), ...(sym.params ?? []).map((p) => p.name)]))\n }\n const out = structuredClone(doc)\n const applied = new Set<string>()\n const visit = (items: Item[]): void => {\n for (const it of items) {\n if (isInstance(it)) {\n const known = exposed.get(it.symbolId)\n if (known) {\n for (const [k, v] of entries) {\n if (!known.has(k)) continue\n ;(it.params ??= {})[k] = v\n applied.add(k)\n }\n }\n }\n if (isGroup(it)) for (const l of it.layers) visit(l.items)\n }\n }\n for (const l of out.layers) visit(l.items)\n for (const sym of out.symbols) for (const l of sym.layers) visit(l.items)\n for (const [k] of entries) {\n if (!applied.has(k)) process.stderr.write(`flatc: no symbol exposes a param named \"${k}\"\\n`)\n }\n return out\n}\n\n/** Render `doc` to a PNG (Buffer). `frame` = target image; `vars` = state override; `scale` = x-px (default 2).\n * A one-shot convenience over `createRenderer` — reach for the renderer itself as soon as you want two. */\nexport async function renderDocToPng(doc: Doc, opts: RenderOpts = {}): Promise<Uint8Array> {\n const renderer = await createRenderer(doc, { scale: opts.scale, params: opts.params })\n try {\n return await renderer.frame(opts.frame ?? 0, { vars: opts.vars, steps: opts.steps })\n } finally {\n renderer.close()\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  run
3
- } from "./chunk-RNQ2IQLD.js";
3
+ } from "./chunk-Z36EDKPW.js";
4
4
  import {
5
5
  allScopeVariables,
6
6
  checkProgram,
@@ -23,7 +23,7 @@ import {
23
23
  scopeProgram,
24
24
  scopeRegions,
25
25
  splitScopeProgram
26
- } from "./chunk-IYPM3CU5.js";
26
+ } from "./chunk-ZAQUEPIX.js";
27
27
  import {
28
28
  compileFlatpack,
29
29
  packToJSON
package/docs/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # FlatInk DSL — documentation
2
+
3
+ FlatInk is a small text language for **animations and interactive scenes** that compile to a single
4
+ self-contained `.flatpack` and play in any `<canvas>`. You can write it by hand, generate it from a
5
+ script or an LLM, or export it from the [FlatInk editor](http://flatink.zwyk-studio.com/).
6
+
7
+ ## The mental model
8
+
9
+ A `.flatink` file has **two halves**:
10
+
11
+ ```
12
+ size 480 320
13
+
14
+ scene { ← THE SCENE: what you see (shapes, text, images, groups)
15
+ layer "game" {
16
+ group "Star" at 240,160 { layer "art" { circle 0 0 40 fill #ffcc00 } }
17
+ }
18
+ }
19
+
20
+ object "Star" { ← THE BEHAVIOR: how it moves and reacts
21
+ when clicked { score = score + 1 }
22
+ rotation = clock * 30
23
+ }
24
+ ```
25
+
26
+ > Behavior attaches **by name**, and the name must be a **group** (or instance/text/image) — those alone
27
+ > carry a pose. A bare `circle` can be named, but never animated. `clock` is the monotone second count;
28
+ > `time` restarts on every timeline loop.
29
+
30
+ The **scene** is composition; the **behavior** (everything after `scene { … }`) is logic — events,
31
+ expressions, drag/drop, interactors. They don't share the same grammar.
32
+
33
+ ## Guides
34
+
35
+ Read in order if you're new, or jump to a topic:
36
+
37
+ 1. **[Getting started](getting-started.md)** — your first `.flatink`, compiled and played.
38
+ 2. **[Scene & drawing](scene-and-drawing.md)** — layers, shapes, text, images, paints, filters, transforms.
39
+ 3. **[Animating a symbol](animating-symbols.md)** — the timeline/cel/pose model, `rotate`/`scale`/`spin`, pivots, tweens, easing.
40
+ 4. **[Behavior & interactions](behavior-and-interactions.md)** — events, actions, drag/drop, interactors, feedback.
41
+ 5. **[Expressions & stdlib](expressions-and-stdlib.md)** — the expression language, math, `self`/`mouse`/`time`, and the `use "…"` packages.
42
+ 6. **[Tooling](tooling.md)** — the `flatc` CLI: compile, render, headless play, gesture scripts, CI.
43
+ 7. **[Host integration](host-integration.md)** — mounting `@flatkit/player` in an app: receive `send` events, drive the state variables, keyboard and teardown.
44
+ 8. **[Embedding fonts](embedding-fonts.md)** — register the doc's embedded fonts (browser `FontFace` / skia `FontLibrary`) so text uses the authored faces.
45
+
46
+ **Appendix — [Gotchas & best practices](dsl-gotchas.md)**: hard-won pitfalls from real production. Skim
47
+ it once you know the basics.
48
+
49
+ ## Two layers, one format
50
+
51
+ - **Layer A — composition & animation**: the timeline, tweens, motion paths, the scene tree.
52
+ - **Layer B — interaction & state**: `var`s, events, expressions, the declarative interactors.
53
+
54
+ You can use just Layer A (a pure animation) or both (an interactive activity). Either way the output is
55
+ one `.flatpack` that `@flatkit/player` plays.
@@ -0,0 +1,357 @@
1
+ # Animating a symbol (`.flat`)
2
+
3
+ > How a `.flat` symbol moves over time: the **timeline / cel / pose** model (Flash-style), the `pose`
4
+ > keywords (`rotate`, `scale`, `opacity`, `spin`…), pivots, tweens, and how to preview the result.
5
+ > If you only need static composition, see [Scene & drawing](scene-and-drawing.md).
6
+
7
+ ## The model in one paragraph
8
+
9
+ A symbol owns a **timeline** (`timeline <fps> <durationFrames>`). Each animated **layer** is a time
10
+ track: a sequence of **cels** (layer-wide keyframes). A cel lists the **poses** of the containers
11
+ present at that frame (a `pose` per roster item) and, optionally, the **matter** (static drawing) at
12
+ that key. Between two cels the layer either **holds** the last key or **tweens** toward the next one.
13
+
14
+ ```
15
+ symbol "Wheel" {
16
+ timeline 24 24 ← 24 fps, 24 frames (loops once per second)
17
+ layer "spin" {
18
+ group "Rim" at 100,100 pivot 0,0 { ← the roster: declared ONCE, posed by the cels below
19
+ layer "art" { circle 0 0 40 nofill stroke #333 8 }
20
+ }
21
+ cel 0 tween { pose "Rim" rotate 0 }
22
+ cel 24 { pose "Rim" rotate 360 } ← one full turn around the pivot, in DEGREES
23
+ }
24
+ }
25
+ ```
26
+
27
+ Preview it without authoring a wrapper:
28
+
29
+ ```
30
+ flatc --preview Wheel.flat --render -o wheel.png # a PNG (frame 0)
31
+ flatc --preview Wheel.flat -o wheel.flatpack # a playable .flatpack for the browser player
32
+ ```
33
+
34
+ ## `pose` — the keyframe of a container
35
+
36
+ ```
37
+ pose "Name" [at <x>,<y>] [rotate <deg>] [scale <s> | scaleX <sx> scaleY <sy>]
38
+ [opacity <o>] [tint <#color> <amount>] [spin cw|ccw] [turns <n>] [filter …]
39
+ ```
40
+
41
+ - **`rotate <deg>` and `scale`/`scaleX`/`scaleY` are in human units** (degrees, multipliers) and apply
42
+ **around the group's `pivot`** — no matrices, no radians, no trigonometry. `rotate 90` is a quarter
43
+ turn; `scale 2` is double size.
44
+ - **`at <x>,<y>`** places the container's local **origin** in parent space. `rotate`/`scale` then turn
45
+ and scale around the `pivot` point (see below), keeping it anchored.
46
+ - **`matrix(a,b,c,d,e,f)`** is still accepted as an escape hatch, but you almost never need it.
47
+
48
+ ### Patch semantics — a pose only overrides what it states
49
+
50
+ A pose **inherits** every channel it does not mention from the container's resting pose (its declaration
51
+ in the roster) — position, rotation, scale, opacity, tint, filters. So:
52
+
53
+ ```
54
+ pose "Boat" opacity 0.5 ← keeps the Boat's declared position/rotation/scale; only dims it
55
+ pose "Boat" rotate 3 ← keeps its position and scale; only tilts it 3°
56
+ ```
57
+
58
+ You do **not** re-state `at x,y` in every cel just to change opacity. (This is a change from older
59
+ builds where a partial pose snapped to `0,0`.)
60
+
61
+ ## Pivot vs `at` — where things turn
62
+
63
+ - **`pivot <x>,<y>`** (set on the container in the roster, in its **local** coordinates) is the center
64
+ of rotation **and** scale **and** tween interpolation. Default is the local origin `0,0`.
65
+ - **`at <x>,<y>`** (on the pose) is where the local origin lands in the parent.
66
+
67
+ **Rule of thumb:** set the group's `pivot` to its visual center, then `rotate`/`scale`/`spin` turn it in
68
+ place. A wheel whose art is centered on its local origin needs no pivot; a wheel drawn off-origin must
69
+ set `pivot` to its hub, or it will **orbit** instead of spin.
70
+
71
+ ```
72
+ group "Hand" at 200,200 pivot 0,-60 { ← pivot at the clock center, 60px below the hand's tip
73
+ layer "art" { rect -4 -60 8 60 fill #111 }
74
+ }
75
+ …
76
+ cel 0 tween { pose "Hand" rotate 0 }
77
+ cel 60 { pose "Hand" rotate 360 } ← sweeps around the pivot, not its own middle
78
+ ```
79
+
80
+ ## Tweens, easing, spin
81
+
82
+ - **`cel N tween { … }`** interpolates this cel → the next for every container present in both. Without
83
+ `tween`, the cel **holds** until the next key.
84
+ - **`ease <curve>`** on the cel: `linear` · `easeIn` · `easeOut` · `easeInOut` · `cubic(a,b,c,d)`.
85
+ - **`spin cw|ccw`** + **`turns <n>`** force the rotation **direction** and add full turns across the
86
+ tween, so a 350° → 10° move can go the short way (`ccw`) or wind several times (`turns 2`). The spin
87
+ is **around the pivot**, like every other rotation.
88
+ - **`morph`** on a cel tweens the *shape* of the `matter` (drawing) toward the next key.
89
+
90
+ ## Presence across cels — a cel is a full snapshot
91
+
92
+ Each `cel` is a **keyframe = the full set of containers present at that instant** (Flash style). A container
93
+ is shown only on the cels that **pose** it; one omitted from a cel **disappears** there. So a container
94
+ visible across a span must be posed on **each cel of that span** (it's per-*keyframe*, not per-frame — three
95
+ keyframes ⇒ three poses, not one per frame). This is also how a symbol **exits**: stop posing it.
96
+
97
+ Two ways to avoid re-typing an unchanged container:
98
+
99
+ - **Static element → its own layer WITHOUT cels.** A cel-less layer renders its items at every frame, so a
100
+ static base/background is declared **once** and never flickers. (Same idea as the render-order note below.)
101
+ - **`cel N hold { … }`** — carry the previous cel's poses forward for every container this cel does *not*
102
+ mention, then apply the stated overrides. Pure authoring sugar (the compiler expands it to full cels), so
103
+ you only write what changes:
104
+
105
+ ```
106
+ cel 0 tween { pose "Base" at 0,0 pose "Ring" scale 1 }
107
+ cel 30 hold tween { pose "Ring" scale 4 } # Base carried automatically
108
+ cel 60 hold { pose "Ring" scale 1 }
109
+ ```
110
+
111
+ `hold` is opt-in per cel; without it the default (an omitted container is removed) is unchanged — so
112
+ exits still work.
113
+
114
+ ## Frame-by-frame — a different DRAWING on each cel
115
+
116
+ A cel carries the poses **and** the layer's **matter**: the drawing at that key, written as a
117
+ `matter { … }` block. Change it from cel to cel and you get classic cel animation — one drawing per
118
+ frame, no tween:
119
+
120
+ ```
121
+ symbol "Blink" {
122
+ timeline 12 3
123
+ layer "draw" {
124
+ cel 0 { matter { circle 0 0 30 fill #e33 } }
125
+ cel 1 { matter { rect -30 -30 60 60 fill #3a3 } }
126
+ cel 2 { matter { path "M -30 30 L 0 -30 L 30 30 Z" fill #33e } }
127
+ }
128
+ }
129
+ ```
130
+
131
+ - The matter is **held** until the next cel that defines one — a drawing kept over several frames is
132
+ written **once**, on the cel where it appears. An explicit `matter { }` (empty) blanks it.
133
+ - **`morph`** on the cel interpolates the *shape* toward the next cel's matter instead of cutting.
134
+ - A cel can carry **both**: a `matter { … }` for that frame's drawing *plus* `pose "…"` for the
135
+ containers animated on top of it (matter always draws **behind** the posed containers).
136
+
137
+ Two other ways to author the same thing, when the drawings already exist as objects:
138
+
139
+ - **Swap containers** — declare each drawing as a group in the roster, then pose only the one you want on
140
+ each cel (a container omitted from a cel disappears — see above).
141
+ - **Image sequence** — the same idiom with `image` items: `image "f1" 100 100 as "F1" at -50,-50` in the
142
+ roster, `cel 0 { pose "F1" }`. The media is declared in the `.flatink` (`asset "f1" "f1.png" image`).
143
+
144
+ > **The trap:** in a layer that **has cels**, a bare shape written directly in the layer is **never
145
+ > drawn** — such a layer renders the current cel's `matter` plus the containers that cel poses, and
146
+ > nothing else. Put the shape inside a `matter { … }`, or on a **cel-less layer** if it is static decor.
147
+ > `flatc --check` warns about it, and about the two sibling silent drops: a `pose "X"` naming no roster
148
+ > item, and a roster item that no cel ever poses.
149
+
150
+ ## Driving a channel with an expression (`expr`)
151
+
152
+ Instead of keyframes you can bind a channel to an expression on the container itself:
153
+
154
+ ```
155
+ group "Fan" pivot 0,0 expr rotation "turns(time)" { … } ← one turn per second
156
+ ```
157
+
158
+ - The animatable channels are `x`, `y`, `scaleX`, `scaleY`, `rotation`, `opacity`.
159
+ - **`rotation` is in RADIANS** (like `sin`/`cos`/`atan2`). Use the helpers to stay in degrees:
160
+ - `rad(deg)` → radians, e.g. `expr rotation "rad(45)"`
161
+ - `turns(n)` → `n` full turns in radians, e.g. `expr rotation "turns(time)"` or `"turns(time * 0.5)"`
162
+ - `deg(rad)` → the inverse, for readouts.
163
+
164
+ ## Stateful "feel": `spring` / `smooth`
165
+
166
+ `expr` is **pure** — it recomputes from `time`/params each frame, with no memory. When you want a channel to
167
+ *react over time* (lag behind a moving target, swing and settle), use a **modifier** instead. It carries
168
+ per-instance state that **integrates** toward a target each frame — so an asset's physical "feel" lives **in
169
+ the asset**, with no scene code:
170
+
171
+ ```
172
+ group "Suspente" spring rotation "crochetX" stiffness 0.08 damping 0.86 { … } # cable swings, then settles
173
+ group "Aiguille" smooth rotationDeg "valeur * 270" k 0.18 { … } # needle eases to its value
174
+ ```
175
+
176
+ - `smooth <channel> "<target>" k <0..1>` — 1st-order lag: each step `value += (target − value) * k`. Small
177
+ `k` = slow/heavy; `k = 1` = instant (no lag).
178
+ - `spring <channel> "<target>" stiffness <0..1> damping <0..1>` — 2nd-order spring: overshoots then settles.
179
+ Lower `damping` = more bounce. (Both params are per fixed 60 Hz step; out-of-range values are clamped.)
180
+ - `<target>` is an ordinary expression (params, `time`, `self.x`, …) — the resting value the channel chases.
181
+ Authoring sugar like `expr`: `rotate` = `rotation`; `rotationDeg` reads degrees (wraps the target in `rad()`).
182
+ - A modifier **wins** over a plain `expr` / keyframes on the same channel.
183
+
184
+ **React to MOVEMENT, not value — `velocity(expr)`.** Inside a modifier target (only there), `velocity(x)` is
185
+ the per-second rate of change of `x`. It's **0 at rest** and while scrubbing/rendering, non-zero only while `x`
186
+ is actually moving — perfect for a pendulum on a moving pivot (a crane cable that swings when the trolley moves,
187
+ then hangs vertical again, with no scene code):
188
+
189
+ ```
190
+ group "Suspente" spring rotation "rad(-velocity(crochetX) * 40)" stiffness 0.06 damping 0.22 { … }
191
+ ```
192
+
193
+ At rest `velocity = 0` → target `0` → vertical, automatically; on a scrub/`--render` it's also `0` → snaps to
194
+ rest. Composable in any target (`rad(-velocity(crochetX)*40 + 2*sin(time))`). `velocity()` is **only** valid in a
195
+ modifier target — `flatc --check` flags it elsewhere.
196
+
197
+ **Per instance.** State is keyed per instance, so two cranes side by side swing **independently** (even when
198
+ the spring is on a group *inside* the symbol).
199
+
200
+ **Live vs. static.** The spring animates during **playback** (gallery autoplay, an activity). On **random
201
+ access** — a timeline scrub, `--render`, a contact sheet — there is no time to integrate, so the channel
202
+ **snaps to its target** (the rest pose). So tune a spring by *playing* the preview, not by scrubbing.
203
+
204
+ **Also scene-side.** The same modifier works in a `.flatink` `object` block — the target is then an ordinary
205
+ (unquoted) FlatInk expression — for a one-off spring on a scene object, when the feel isn't baked into a `.flat`:
206
+
207
+ ```
208
+ object "Hero" {
209
+ spring rotation = crochetX { stiffness 0.08 damping 0.86 }
210
+ smooth opacity = lit { k 0.18 }
211
+ }
212
+ ```
213
+
214
+ ## Looping & instancing
215
+
216
+ The timeline loops over `[0, durationFrames)`. An `instance` of a symbol chooses **how its own timeline
217
+ advances** (Flash's symbol-instance models), written after the instance's attributes:
218
+
219
+ ```
220
+ instance "Walk" as "legs" # synced (default)
221
+ instance "Walk" as "legs" loop # independent (MovieClip)
222
+ instance "Splash" as "fx" once # play once, then hold the last frame
223
+ ```
224
+
225
+ | mode | clock | behavior |
226
+ |---|---|---|
227
+ | *(default)* / `synced` | the parent's frame | **Graphic symbol**: scrubbed and *truncated* by the parent — if an ancestor's timeline is shorter than (or not a multiple of) the sub-loop, it snaps mid-cycle. Best for lip-sync, deterministic scrub. |
228
+ | `loop` (`independent`) | the runtime's monotone clock | **MovieClip**: loops on its *own* duration, immune to any ancestor's loop length. Use for state-loops and idles that must keep their phase across the parent's wrap. |
229
+ | `once` | the monotone clock, clamped | plays through **once**, then **holds** the last frame — a one-shot (a splash, an explosion, a pose that stays). |
230
+ | `singleFrame` | — | frozen on a fixed frame. |
231
+
232
+ A `loop`/`once` instance runs on the global heartbeat, so it never needs its parent padded to a common
233
+ multiple ("LCM") of its sub-loops. In the **editor** it shows frame 0 (MovieClip-style authoring); it plays
234
+ at runtime — edit its keyframes by opening the symbol itself. `--preview` sizes its window to show a nested
235
+ `loop`/`once` looping cleanly, without touching the previewed symbol's own authored duration.
236
+
237
+ ## Exposed parameters (`params`)
238
+
239
+ A symbol can publish a small, named **interface** instead of exposing its internals — useful for restyling
240
+ an asset (hull/sail colors), tuning an animation (amplitude, speed), or toggling a detail, including
241
+ "after the fact" by a small model.
242
+
243
+ ```
244
+ symbol "Boat" {
245
+ params {
246
+ color hull = #c0392b "Hull color"
247
+ color sail = #2980b9 "Sail color"
248
+ number wave = 1 range 0 2 "Bob amplitude"
249
+ bool flag = true "Show the pennant"
250
+ }
251
+ layer "body" {
252
+ path "…" fill hull // a color param used as a fill
253
+ group "Deck" expr y "sin(clock*3) * wave" { … } // a number param read in an expression
254
+ group "Flag" expr opacity "flag ? 1 : 0" { … } // a bool param as a toggle
255
+ }
256
+ }
257
+ ```
258
+
259
+ - `params { <type> <name> = <default> [range <min> <max>] ["doc"] … }` — `<type>` is `color`, `number`,
260
+ or `bool`. The default, range, and doc string make the interface self-describing.
261
+ - **`color` params** are used as a paint — `fill hull`, `stroke hull <width>`, a **gradient stop**
262
+ (`0:hull@0.8`, optional `@alpha`), or a **`tint hull <amount>`** (anywhere a `#color` literal goes).
263
+ Resolved per instance at render; *not* available in numeric expressions.
264
+ - **`number` / `bool` params** become **variables in the symbol's expressions** (`wave`, `flag`). `bool`
265
+ reads as `1`/`0`. (`flatc --check` knows them — reading a declared param in an `expr` is not an "unknown
266
+ variable".)
267
+
268
+ > The `timeline`, `params`, and `states` header blocks may appear in **any order** before the layers.
269
+
270
+ Set params at the instance **call-site** (literals), in `--preview`, or — for `number`/`bool` — at
271
+ runtime (`Boat.wave = 1.5`, see below):
272
+
273
+ ```
274
+ instance "Boat" as "Hero" at center { hull = #1a5f3a, wave = 1.5, flag = false }
275
+ flatc --preview Boat.flat --render --set hull=#1a5f3a,wave=1.5 -o boat.png
276
+ ```
277
+
278
+ > A `state` (below) is just another exposed param — same call-site/preview/runtime surface.
279
+
280
+ ## Named states (`states`)
281
+
282
+ A symbol can expose **named states** — points on its own timeline — and let a consumer switch between
283
+ them. A door is the canonical case: the symbol animates from `closed` (frame 0) to `open` (frame 24),
284
+ and exposes that as a single param.
285
+
286
+ ```
287
+ symbol "Door" {
288
+ timeline 24 24
289
+ states door { closed at 0 open at 24 initial closed transition 12 ease easeInOut }
290
+ layer "panel" {
291
+ group "Panel" at 60,10 pivot 0,0 { layer "art" { rect 0 0 40 80 fill #884422 } }
292
+ cel 0 tween { pose "Panel" rotate 0 } // closed
293
+ cel 24 { pose "Panel" rotate 80 } // open
294
+ }
295
+ }
296
+ ```
297
+
298
+ - `states <param> { <name> at <frame> … }` declares the state machine. `<param>` is the exposed
299
+ variable (`door`); each `<name> at <frame>` anchors a state to a frame of the symbol's timeline.
300
+ - `initial <name>` is the resting state (default: the first). `transition <n> [ease <e>]` is the default
301
+ move between states.
302
+ - **The param drives the symbol's local playhead.** `door = 0` (or `closed`) → frame 0; `door = 1`
303
+ (or `open`) → frame 24; a fractional `door = 0.5` → frame 12, i.e. the authored in-between animation.
304
+ So **animating the variable from 0→1 plays the open animation** — states live inside the ordinary
305
+ variable system, no special runtime.
306
+
307
+ Select a state in a preview (a state name or a number):
308
+
309
+ ```
310
+ flatc --preview Door.flat --render --set door=open -o open.png
311
+ flatc --preview Door.flat --render --set door=0.5 -o half.png # mid-transition
312
+ ```
313
+
314
+ ### Driving a state from a program (`set Name.param = state`)
315
+
316
+ In a `.flatink` program, address an instance **by name** and set its state — the player plays the
317
+ declared `transition` automatically. Each instance keeps its **own** state, so two doors are independent.
318
+
319
+ ```
320
+ scene {
321
+ layer "stage" { instance "Door" as "FrontDoor" at 100,100 }
322
+ }
323
+ object "FrontDoor" {
324
+ when clicked { FrontDoor.door = open } // animates closed → open over `transition` frames
325
+ }
326
+ ```
327
+
328
+ The right-hand side is a **state name** (`open`) or an expression (`FrontDoor.door = score > 5 ? open : closed`
329
+ isn't valid — names aren't expressions; use a number there, e.g. `… ? 1 : 0`). `transition 0` snaps instantly.
330
+
331
+ > **Scope note:** the state value drives the instance's playhead and is visible to that instance's own
332
+ > expressions. Reading another object's state back by name (`FrontDoor.door` in an unrelated expression)
333
+ > and the broader typed `params {}` interface (colors/numbers/toggles, `fill hull`) are still to come.
334
+
335
+ ## Render order (a real caveat)
336
+
337
+ Within **one animated layer**, the **matter (static drawing) always renders behind the posed
338
+ containers** — declaration order between a bare `path` and an animated `group` is **not** preserved,
339
+ because the cel model stores matter and the container roster separately.
340
+
341
+ **If a static shape must sit IN FRONT of an animated group**, give it its own group (so it becomes a
342
+ posed container too) or, simpler, **put it on its own layer** above. Layers always honor their stacking
343
+ order. This is the reliable way to control z-order around animation.
344
+
345
+ ## Previewing without clipping
346
+
347
+ `flatc --preview` auto-sizes the stage to the symbol's bounds. By default (`--bbox all`) it measures the
348
+ **union over every frame** (sub-timelines unfrozen), so a part that drifts, rotates, or grows is **never
349
+ clipped**. Use `--bbox frame0` for the old frame-0-only measure, and `--pad N` to add a margin.
350
+
351
+ ```
352
+ flatc --preview Boat.flat --render -o boat.png # union bbox (default) — full motion fits
353
+ flatc --preview Boat.flat --bbox frame0 --pad 40 -o b.png # frame-0 bounds + 40px margin
354
+ ```
355
+
356
+ See also: [Tooling](tooling.md) for the full `flatc` reference, and
357
+ [Gotchas](dsl-gotchas.md) for sharp edges.