@flatkit/compiler 0.37.1 → 0.39.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,3 +1,5 @@
1
+ import { SendEvent } from '@flatkit/player';
2
+ import { Gesture } from '@flatkit/player/debug';
1
3
  import { Doc } from '@flatkit/types';
2
4
 
3
5
  type RenderOpts = {
@@ -6,6 +8,14 @@ type RenderOpts = {
6
8
  scale?: number;
7
9
  steps?: number;
8
10
  params?: Record<string, string>;
11
+ script?: Gesture[];
12
+ settle?: number;
13
+ };
14
+ /** What a replayed script reports: the `send`s it emitted, and the `expect` gestures that did not hold. */
15
+ type PlayReport = {
16
+ sends: SendEvent[];
17
+ expectFailures: string[];
18
+ warnings: string[];
9
19
  };
10
20
  /**
11
21
  * A renderer held OPEN over a document: the expensive setup is paid once, then any number of frames.
@@ -18,11 +28,19 @@ type RenderOpts = {
18
28
  * shims — including that `document` must exist or every `filter` and `tint` is dropped in SILENCE.
19
29
  */
20
30
  type Renderer = {
21
- /** PNG of one frame. `vars` overrides state for this frame; `steps` runs N sim steps before capture. */
31
+ /** PNG of one frame. `vars` overrides state for this frame; `script` replays gestures after the seek
32
+ * (an `interactive` renderer only); `steps` then runs N sim steps before capture. */
22
33
  frame(frame: number, opts?: {
23
34
  vars?: Record<string, number>;
24
35
  steps?: number;
36
+ script?: Gesture[];
25
37
  }): Promise<Uint8Array>;
38
+ /** Replays gestures on the live scene, as `flatc --play` does, and leaves it in the state they reach.
39
+ * Successive calls continue the same session (one state, one `expect` window). Needs
40
+ * `createRenderer(doc, { interactive: true })`. */
41
+ play(gestures: Gesture[]): PlayReport;
42
+ /** PNG of the scene AS IT IS NOW — after a `play`, without seeking. */
43
+ capture(): Promise<Uint8Array>;
26
44
  /** The document's pixel size at the renderer's scale. */
27
45
  readonly width: number;
28
46
  readonly height: number;
@@ -38,9 +56,11 @@ type Renderer = {
38
56
  declare function createRenderer(doc: Doc, opts?: {
39
57
  scale?: number;
40
58
  params?: Record<string, string>;
59
+ interactive?: boolean;
60
+ settle?: number;
41
61
  }): Promise<Renderer>;
42
62
  /** Render `doc` to a PNG (Buffer). `frame` = target image; `vars` = state override; `scale` = x-px (default 2).
43
63
  * A one-shot convenience over `createRenderer` — reach for the renderer itself as soon as you want two. */
44
64
  declare function renderDocToPng(doc: Doc, opts?: RenderOpts): Promise<Uint8Array>;
45
65
 
46
- export { type RenderOpts, type Renderer, createRenderer, renderDocToPng };
66
+ export { type PlayReport, type RenderOpts, type Renderer, createRenderer, renderDocToPng };
@@ -1,5 +1,6 @@
1
1
  // src/cli/render.ts
2
2
  import { FlatPlayer } from "@flatkit/player";
3
+ import { createReplayer } from "@flatkit/player/debug";
3
4
  import { isGroup, isInstance } from "@flatkit/engine/layers";
4
5
  import { mkdtempSync, writeFileSync, rmSync } from "fs";
5
6
  import { tmpdir } from "os";
@@ -81,17 +82,34 @@ async function createRenderer(doc, opts = {}) {
81
82
  set("document", { createElement: (t) => t === "canvas" ? new Canvas(1, 1) : {} });
82
83
  const pxW = Math.max(1, Math.round(W * scale));
83
84
  const pxH = Math.max(1, Math.round(H * scale));
85
+ const handlers = {};
84
86
  const canvas = new Canvas(pxW, pxH);
85
87
  const el = canvas;
86
88
  Object.assign(el, {
87
89
  getBoundingClientRect: () => ({ width: W, height: H, left: 0, top: 0, right: W, bottom: H }),
88
- addEventListener: () => {
90
+ // The listeners the player registers are KEPT: a replayed script fires into them (`play`).
91
+ addEventListener: (type, fn) => {
92
+ handlers[type] = fn;
89
93
  },
90
- removeEventListener: () => {
94
+ removeEventListener: (type) => {
95
+ delete handlers[type];
96
+ },
97
+ setPointerCapture: () => {
98
+ },
99
+ releasePointerCapture: () => {
91
100
  },
92
101
  style: {}
93
102
  });
94
- let player = new FlatPlayer(el, withParams, { input: false, audio: false, padding: 0, seed: 1, image: (id) => images.get(id) ?? null });
103
+ const sends = [];
104
+ let player = new FlatPlayer(el, withParams, { input: !!opts.interactive, audio: false, padding: 0, seed: 1, image: (id) => images.get(id) ?? null, onEvent: (e) => sends.push(e) });
105
+ let replayer = null;
106
+ const play = (gestures) => {
107
+ if (!player) throw new Error("renderer is closed");
108
+ if (!opts.interactive) throw new Error("this renderer cannot replay gestures: open it with createRenderer(doc, { interactive: true })");
109
+ replayer ??= createReplayer(player, withParams, handlers, sends, { settle: opts.settle });
110
+ for (const g2 of gestures) replayer.apply(g2);
111
+ return { sends: [...sends], expectFailures: [...replayer.expectFailures], warnings: [...replayer.warnings] };
112
+ };
95
113
  return {
96
114
  width: pxW,
97
115
  height: pxH,
@@ -99,6 +117,7 @@ async function createRenderer(doc, opts = {}) {
99
117
  if (!player) throw new Error("renderer is closed");
100
118
  if (frameOpts.vars) for (const [k, v] of Object.entries(frameOpts.vars)) player.setVar(k, v);
101
119
  player.seek(frame);
120
+ if (frameOpts.script?.length) play(frameOpts.script);
102
121
  const steps = frameOpts.steps ?? 0;
103
122
  if (steps > 0) {
104
123
  const n = Math.min(Math.floor(steps), MAX_RENDER_STEPS);
@@ -106,6 +125,13 @@ async function createRenderer(doc, opts = {}) {
106
125
  `);
107
126
  player.stepSim(n);
108
127
  }
128
+ player.render();
129
+ return el.toBuffer("png");
130
+ },
131
+ play,
132
+ async capture() {
133
+ if (!player) throw new Error("renderer is closed");
134
+ player.render();
109
135
  return el.toBuffer("png");
110
136
  },
111
137
  close() {
@@ -155,9 +181,9 @@ function applyParams(doc, params) {
155
181
  return out;
156
182
  }
157
183
  async function renderDocToPng(doc, opts = {}) {
158
- const renderer = await createRenderer(doc, { scale: opts.scale, params: opts.params });
184
+ const renderer = await createRenderer(doc, { scale: opts.scale, params: opts.params, interactive: !!opts.script?.length, settle: opts.settle });
159
185
  try {
160
- return await renderer.frame(opts.frame ?? 0, { vars: opts.vars, steps: opts.steps });
186
+ return await renderer.frame(opts.frame ?? 0, { vars: opts.vars, steps: opts.steps, script: opts.script });
161
187
  } finally {
162
188
  renderer.close();
163
189
  }
@@ -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, 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, seed: 1, 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":[]}
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, type SendEvent } from '@flatkit/player'\nimport { createReplayer, type Gesture, type Handlers, type Replayer } from '@flatkit/player/debug'\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>; script?: Gesture[]; settle?: number }\n\n/** What a replayed script reports: the `send`s it emitted, and the `expect` gestures that did not hold. */\nexport type PlayReport = { sends: SendEvent[]; expectFailures: string[]; warnings: 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; `script` replays gestures after the seek\n * (an `interactive` renderer only); `steps` then runs N sim steps before capture. */\n frame(frame: number, opts?: { vars?: Record<string, number>; steps?: number; script?: Gesture[] }): Promise<Uint8Array>\n /** Replays gestures on the live scene, as `flatc --play` does, and leaves it in the state they reach.\n * Successive calls continue the same session (one state, one `expect` window). Needs\n * `createRenderer(doc, { interactive: true })`. */\n play(gestures: Gesture[]): PlayReport\n /** PNG of the scene AS IT IS NOW — after a `play`, without seeking. */\n capture(): 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>; interactive?: boolean; settle?: number } = {}): 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 handlers: Handlers = {}\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 // The listeners the player registers are KEPT: a replayed script fires into them (`play`).\n addEventListener: (type: string, fn: Handlers[string]) => { handlers[type] = fn },\n removeEventListener: (type: string) => { delete handlers[type] },\n setPointerCapture: () => {}, releasePointerCapture: () => {}, style: {},\n })\n\n // `interactive`: the player listens to its canvas, so gestures can be replayed on it. Off by default —\n // a renderer that only draws frames registers nothing.\n const sends: SendEvent[] = []\n let player: FlatPlayer | null = new FlatPlayer(el, withParams, { input: !!opts.interactive, audio: false, padding: 0, seed: 1, image: (id) => images.get(id) ?? null, onEvent: (e) => sends.push(e) })\n let replayer: Replayer | null = null\n const play = (gestures: Gesture[]): PlayReport => {\n if (!player) throw new Error('renderer is closed')\n if (!opts.interactive) throw new Error('this renderer cannot replay gestures: open it with createRenderer(doc, { interactive: true })')\n replayer ??= createReplayer(player, withParams, handlers, sends, { settle: opts.settle })\n for (const g of gestures) replayer.apply(g)\n return { sends: [...sends], expectFailures: [...replayer.expectFailures], warnings: [...replayer.warnings] }\n }\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 if (frameOpts.script?.length) play(frameOpts.script)\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 player.render() // a gesture or a step paints only what it changed: draw the frame that is asked for\n return el.toBuffer('png')\n },\n play,\n async capture() {\n if (!player) throw new Error('renderer is closed')\n player.render()\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, interactive: !!opts.script?.length, settle: opts.settle })\n try {\n return await renderer.frame(opts.frame ?? 0, { vars: opts.vars, steps: opts.steps, script: opts.script })\n } finally {\n renderer.close()\n }\n}\n"],"mappings":"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","names":["g"]}
package/dist/compile.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  compileFlatpack,
3
3
  packToJSON
4
- } from "./chunk-KXKX2RMX.js";
4
+ } from "./chunk-BPX2MOW2.js";
5
5
  export {
6
6
  compileFlatpack,
7
7
  packToJSON
package/dist/index.js CHANGED
@@ -22,11 +22,11 @@ import {
22
22
  scopeProgram,
23
23
  scopeRegions,
24
24
  splitScopeProgram
25
- } from "./chunk-43MGBTUK.js";
25
+ } from "./chunk-RQKXBHHM.js";
26
26
  import {
27
27
  compileFlatpack,
28
28
  packToJSON
29
- } from "./chunk-KXKX2RMX.js";
29
+ } from "./chunk-BPX2MOW2.js";
30
30
 
31
31
  // src/manifest.ts
32
32
  import { EXPR_CHANNELS as EXPR_CHANNELS2, OFFSET_CHANNELS } from "@flatkit/engine/timeline";
@@ -27,7 +27,7 @@ Inside `object "Name" { … }`:
27
27
  | `when held` | a long press |
28
28
  | `when dropped on <Zone> [at pointer]` | released over a drop zone (see [drag & drop](#drag--drop)) |
29
29
 
30
- Scene-wide: `when loaded { … }` (once), `every frame { … }` (each tick), `at frame <n> { … }`,
30
+ Scene-wide: `when loaded { … }` (once), `every frame { … }` (each simulation step, 60 Hz — see [how a frame runs](#how-a-frame-runs)), `at frame <n> { … }`,
31
31
  `label <frame> "name"`. These live at the TOP LEVEL of the program, outside any `object` block — inside
32
32
  one they do nothing, and `--check` says so.
33
33
 
@@ -501,6 +501,45 @@ match Word1, Word2 onto Good, Bad {
501
501
  It generates, per item, `<Item>_placed` / `<Item>_ok` / `<Item>_zone` state and the drag+drop handlers;
502
502
  you keep the visual (`var <Item>_x`/`_y` + your channel expressions).
503
503
 
504
+ ## How a frame runs
505
+
506
+ Three things happen, always in this order, and knowing it removes most "it reads the old value" surprises.
507
+
508
+ **1. Events, as they arrive.** A press, a move, a release or a key runs its handlers **at once**, between
509
+ two displays — not at the next step. At a release the order is: the gesture's outputs are written (drag
510
+ position, `link` target), then `when released`, then `when dropped on …` (in declaration order), then
511
+ `when clicked` if the press stayed a tap.
512
+
513
+ **2. Steps of the simulation.** `every frame { … }` runs at a **fixed 60 Hz**: one run is one step of
514
+ exactly 1/60 s, **whatever the `timeline` fps and whatever the display**. `timeline 30 …` only sets the
515
+ speed of the playhead: after 60 steps `clock` has advanced by 1 and `frame` by 30. The step has a name,
516
+ **`DT`** (= 1/60, in seconds), so an integration is written `v = v + a * DT` — never measure it from
517
+ `clock`. In a browser `clock`, `time` and `frame` follow REAL time, once per display, so two steps run in
518
+ the same display read the same `clock`; `clock - previous` is then the display's duration on the first and
519
+ `0` on the second. (`flatc --play` advances them by 1/60 per step, which is why a replay is exact.)
520
+
521
+ How many steps run before each display depends on the display: none or one at 120 Hz, one at 60 Hz, two at
522
+ 30. When the display stalls (a tab in the background, a slow device) the player does **not** catch up: it
523
+ counts at most 0.25 s per display and runs at most 30 steps, dropping the rest. The simulation then runs
524
+ slower than the wall clock; it never jumps. Within a step, the scene's `every frame` runs first, then
525
+ those of the active symbols; `at frame <n>` scripts come after, in the same step.
526
+
527
+ **3. The picture.** Channel bindings (`x = px`, `opacity = lit`) are not statements that run: they are
528
+ read whenever something looks at the object — when it is drawn, when it is hit-tested, when a handler
529
+ reads `Target.x` — and always give the value of NOW. What is drawn between two steps is interpolated
530
+ between them, for smoothness; variables are never changed by that.
531
+
532
+ What follows from it:
533
+
534
+ - **A handler reads a derived value as the last step left it.** If `every frame { double = count * 2 }`
535
+ and a handler does `count = count + 1` then reads `double`, it reads the value from BEFORE its own
536
+ write. Two events between two steps: the second sees what the first *wrote*, not what `every frame`
537
+ derives from it. Derive in the handler what the handler needs, or make it a function (`fn`).
538
+ - **`flatc --play` gives every pointer event one step** (see [tooling](tooling.md#headless-play----play)),
539
+ as a real pointer does; `"settle": 0` replays two events with no step in between, which is the case above.
540
+ - **The step is guaranteed**; the number of steps per display is not. A rule that counts steps counts
541
+ sixtieths of a second of simulated time.
542
+
504
543
  ## See also
505
544
 
506
545
  - The expression language and stdlib → **[Expressions & stdlib](expressions-and-stdlib.md)**
@@ -10,6 +10,11 @@
10
10
  - A `.flatink` file splits in two: the **`scene { … }`** block (the VISUAL composition:
11
11
  `path`/`circle`/`group`/`image`/`text`) and the **behavior** that follows (`object "Name"
12
12
  { … }`, `every frame`, timeline bindings). The two do NOT share the same grammar.
13
+ - **`every frame` is a 60 Hz simulation step, not a timeline frame.** One run is exactly 1/60 s —
14
+ the constant **`DT`** — whatever `timeline <fps>` says and whatever the display does; integrate with
15
+ `v = v + a * DT`, never with a `dt` measured from `clock`. Handlers run when their event arrives,
16
+ BEFORE the next step: a handler that reads a value derived in `every frame` reads the one of the last
17
+ step. The full order is in [how a frame runs](behavior-and-interactions.md#how-a-frame-runs).
13
18
 
14
19
  ## `object "X"` addresses an ANIMATABLE item — a group, instance, text or image
15
20
 
@@ -378,12 +383,17 @@ and composes with `self.x`/`self.y` etc. (same `self`).
378
383
  param = error.
379
384
  - ⚠️ Compile-time sugar (re-serialized as groups, like `def`/`repeat`). For shared
380
385
  BEHAVIOR, see `each` below.
386
+ - **A plain `symbol "X" { … }` may be written in the program too** (before or after the `scene`): it is
387
+ the very block a `.flat` holds — its own `timeline`, cels, `states`, `params` — instanced without
388
+ parens, and it wins over a library symbol of the same name. No dummy parameter needed any more.
389
+ - **`repeat` / `def` / `$()` work in a `.flat` library**, as in a program's scene: `repeat i from 0 to 8
390
+ { circle $(i*20) 0 6 fill #333 }` inside a symbol's layer is unfolded when the library is read.
381
391
  - ⚠️ **`.flatink`-only**: a parameterized `symbol "X"(…)` lives in the **program** (`.flatink`), NOT in a
382
392
  `.flat` library. `.flat` libs hold **non-parameterized** symbols, instanced **without** parens
383
393
  (`instance "Hero" as "H"`); a parameterized one is instanced **with** args (`instance "Card"("A")`).
384
394
  Putting a `(…)` symbol in a `.flat` — or letting `flatc` auto-discover such a `.flat` in the folder —
385
395
  surfaces as a misleading `"{" expected, "("` (the `.flat` parser doesn't take parameters). Rule of
386
- thumb: parens ⇔ parameterized ⇔ inline in the `.flatink`.
396
+ thumb: parens ⇔ parameterized ⇔ program only; no parens ⇔ a real symbol, in a `.flat` or in the program.
387
397
  - **`each "Symbol" as i { … }`**: applies BEHAVIOR to every instance of a symbol, with
388
398
  index `i`.
389
399
  - **Channel bindings** on real instances (`each "Brick" as i { opacity = bricks[i] }`) →
@@ -30,7 +30,8 @@ min max hypot clamp(x, lo, hi) lerp(a, b, t) mod(a, b) between(x, lo, hi)
30
30
  rad(deg) deg(rad) turns(n)
31
31
  ```
32
32
 
33
- Constants: `PI`, `TAU` (2π), `E`.
33
+ Constants: `PI`, `TAU` (2π), `E`, and **`DT`** — the duration of one `every frame` step, in seconds: 1/60,
34
+ whatever the timeline's fps (`v = v + a * DT`). See [how a frame runs](behavior-and-interactions.md#how-a-frame-runs).
34
35
 
35
36
  > **`lerp` is the exponential smoother.** `lerp(v, target, k)` = `v + (target - v) * k` — so
36
37
  > `niv = lerp(niv, target, 0.1)` in `every frame` eases `niv` toward `target` (no new helper needed; the
@@ -62,8 +63,46 @@ per second), `deg(r)` (the inverse, for readouts). Or bind the **`rotationDeg`**
62
63
  var slots = [0, 0, 0]
63
64
  object "P" { x = slots[i] } // computed index
64
65
  slots[i + 1] = 1 // indexed assignment (in actions)
66
+ v = [10, 20, 30][i] // a table written in place, indexed at once
65
67
  ```
66
68
 
69
+ A table written in place — `[a, b, c][i]` — saves a global array for a lookup used once. Its elements and
70
+ its index are expressions, only the element picked is evaluated, and it indexes as an array does (the
71
+ index is rounded; outside the table it is `NaN`, so the binding keeps its fallback). It is not a value on
72
+ its own: `[1, 2, 3]` without an index is an error — to keep a table, declare `var t = [1, 2, 3]`.
73
+
74
+ ## Determinism
75
+
76
+ For someone replaying a simulation elsewhere (a Python replica, a test that compares to the bit), what can
77
+ be counted on. Numbers are IEEE 754 doubles, and an expression is evaluated in the order it is written,
78
+ one rounded operation at a time (no fused multiply-add).
79
+
80
+ - **Exact** — the same bits on every engine: the operators `+ - * / %` and the comparisons, and `abs`
81
+ `floor` `ceil` `round` `sign` `min` `max` `sqrt` `clamp` `lerp` `mod` `between` `rad` `deg` `turns`.
82
+ (`sqrt` is the IEEE square root, correctly rounded on every engine in use, although ECMAScript does not
83
+ formally demand it. `rad`, `deg` and `turns` multiply by the double `PI`: exact, provided the replica
84
+ does the same operations in the same order — `rad(d)` is `d * PI / 180`.)
85
+ - **Engine-dependent** — ECMAScript leaves the last bits to the implementation: `sin` `cos` `tan` `asin`
86
+ `acos` `atan` `atan2` `pow` `exp` `log` `hypot`. Two browsers usually agree, and nothing guarantees
87
+ it; a Python or C library need not agree with either. Integrated over thousands of steps, one bit
88
+ becomes a visible gap. A replica that must match to the bit uses its own polynomial for these, on both
89
+ sides.
90
+
91
+ Three spellings that differ from other languages:
92
+
93
+ - **`%`** is the remainder of the truncated division: it takes the sign of the LEFT operand (`-1 % 3` is
94
+ `-1`), and works on decimals (`3.25 % 12` is `3.25`). It is C's `fmod`, Python's `math.fmod` — not
95
+ Python's `%`. **`mod(a, b)`** is the positive one (`mod(-1, 3)` is `2`), Python's `%` for `b > 0`.
96
+ - **`round`** sends a half UP, toward +∞: `round(2.5)` is `3`, `round(-2.5)` is `-2`. Python's `round`
97
+ sends it to the even neighbour. An array index is rounded the same way.
98
+ - **`random()`** draws from a seeded generator made of integer operations only: the same seed gives the
99
+ same sequence on every engine. A replay is always seeded (`flatc --play`, seed `1`, or `--seed N`); a
100
+ player in a page draws from the browser unless its host passes `seed`.
101
+
102
+ Time: one step of `every frame` is exactly **`DT`** = 1/60 s — integrate with it. `clock`, `time` and
103
+ `frame` follow real time in a browser and are exact only in a replay (see
104
+ [how a frame runs](behavior-and-interactions.md#how-a-frame-runs)).
105
+
67
106
  ## Functions (`fn`)
68
107
 
69
108
  Define reusable helpers — a **value** function (an expression) or a **procedure** (actions):
@@ -212,8 +212,15 @@ at <x>,<y> // translation -- a COMMA between the two, n
212
212
  matrix(a,b,c,d,e,f) // full affine
213
213
  at center · at center,540 · at 120,center // canvas-relative anchor (resolved from `size`)
214
214
  align <point> of "Name" [offset dx,dy] // pin this item's origin onto another item's bbox
215
+ rotate <deg> · scale <s> · scaleX <s> · scaleY <s> // a FIXED rotation / scale, after `at`
215
216
  ```
216
217
 
218
+ `rotate` and `scale` are written among the attributes that follow `at` — `group "G" at 100,100 pivot 20,0
219
+ rotate 45 scale 2 { … }` — in the units of a `pose`: **degrees** and multipliers. They turn around the
220
+ item's `pivot`, which stays where `at` put it, in whatever order the two are written. They are baked into
221
+ the item's matrix: for a rotation that *changes*, bind the channel (`expr rotation "…"`, or `rotationDeg =
222
+ …` in an `object` block). Not combinable with `align`.
223
+
217
224
  > **Placement & naming gotchas.** The order is fixed: **content → `as "…"` → `at …` / `matrix(…)` →
218
225
  > style attributes** (`font`/`box`/`fill`/…). So `text "…" box W H at x,y` fails — write `text "…" at x,y
219
226
  > box W H`; and `image "logo" 80 80 at 0,0 as "L"` fails — write `image "logo" 80 80 as "L" at 0,0`
package/docs/tooling.md CHANGED
@@ -162,9 +162,13 @@ Render a PNG (skia backend, faithful to the browser). Needs the optional `skia-c
162
162
  (`npm i -D skia-canvas`).
163
163
 
164
164
  `skia-canvas` 3 (3.0.8 or later) and 4 (from `4.0.0-rc7`) are both accepted. They do not render to the
165
- same pixels: **version 4 sets text one device pixel lower** (half a unit at the default scale of 2);
166
- shapes are identical. Frames rendered with one must not be mixed with frames rendered with the other in
167
- the same video, and a pixel comparison between two renders needs the same version on both sides.
165
+ same pixels: **version 4 can set a line of text one device pixel higher or lower** than version 3 (half a
166
+ unit at the default scale of 2); shapes are identical. It is a rounding that falls one way or the other
167
+ from one text to the next, not a uniform shift: on a same picture one block may move down, another up, a
168
+ third not at all (measured by a consumer on 545 scenes: 16 identical to the pixel, a median of 0.31% of
169
+ pixels visibly different, 3.4% at most on a page of text). Frames rendered with one must not be mixed
170
+ with frames rendered with the other in the same video, and a pixel comparison between two renders needs
171
+ the same version on both sides. While version 4 is a release candidate, pin the exact version.
168
172
 
169
173
  ```
170
174
  flatc <file> --render -o out.png [--frame N] [--at k=v[,k2=v2]] [--steps N] [--scale S]
@@ -174,6 +178,13 @@ flatc <file> --render -o out.png [--frame N] [--at k=v[,k2=v2]] [--steps N] [--s
174
178
  - `--at score=3,step=2` forces variables → capture a precise state.
175
179
  - **`--steps N`** runs N fixed simulation steps (`every frame`, 60 Hz) *before* capture, so a stateful
176
180
  act unfolds on its own — no need to force every derived ramp variable by hand.
181
+ - **`--script gestures.json`** replays a gesture script first, exactly as `--play` does, and renders the
182
+ state it reaches — the third right answer, the piece dropped, level 2 — instead of a copy of the program
183
+ with other initial `var`s. The order is: `--frame`, `--at`, the script, `--steps`, the capture. A
184
+ **`{ "type": "shot", "name": "after" }`** gesture writes the image *at that point* next to the output
185
+ (`out.after.png`; unnamed shots are numbered), and the output is always the final state. A failed
186
+ `expect` makes the run exit ≠0, images written all the same. `--settle` applies as in `--play`. From
187
+ code: `createRenderer(doc, { interactive: true })`, then `play(gestures)` and `capture()`.
177
188
  - **Embedded fonts render too**: any `asset "id" "font.woff2" font` is registered with skia before
178
189
  capture, so text uses the authored face (matched by the font's intrinsic family name — the same name
179
190
  you put in `text … font "…"`) instead of a host fallback. `.woff2/.woff/.ttf/.otf` are all supported;
@@ -197,7 +208,12 @@ flatc --preview <library.flat> [--symbol NAME] [-o out.flatpack | --render -o ou
197
208
  - `--symbol NAME` picks the symbol (default: the first; others are listed on stderr).
198
209
  - **`--bbox all`** (default) auto-sizes to the UNION of bounds over every frame (sub-timelines unfrozen),
199
210
  so drifting/rotating/growing motion is never clipped. `--bbox frame0` is the old frame-0-only measure;
200
- `--pad N` adds a margin (default 24).
211
+ `--pad N` adds a margin (default 24). The symbol is measured **as it is drawn**: its `expr` channels
212
+ are evaluated with its params — the declared defaults, or the `--set` values — so a bar stretched by
213
+ `expr scaleX "long"` is framed at its real length.
214
+ - **`--frame N` does not move a symbol that has `states`**: its pose is set by the state, not by the
215
+ playhead (only the loops nested in it advance). `flatc` says so; to see a state, or a point between
216
+ two, use `--set <param>=<state name | value>` (`--set pos=0.5` is halfway).
201
217
  - **`--set p=v`** sets the symbol's exposed [params](animating-symbols.md#exposed-parameters-params): a
202
218
  `color` (`hull=#1a5`), a `number`/`bool` (`wave=1.5`), or a `state` by name (`door=open`). Baked into the
203
219
  preview (flatpack + render).
@@ -219,7 +235,7 @@ Use `external` for big media you don't want inflating the JSON; serve the folder
219
235
  Run a scene **without a canvas**, replay a gesture script, and print `{ sends, vars }` — great in CI.
220
236
 
221
237
  ```
222
- flatc <file> --play --script gestures.json [--trace]
238
+ flatc <file> --play --script gestures.json [--trace] [--settle N]
223
239
  ```
224
240
 
225
241
  **Prefer semantic gestures** (by object NAME — robust, the engine resolves coordinates):
@@ -243,10 +259,22 @@ flatc <file> --play --script gestures.json [--trace]
243
259
  - Audio is off in `--play`: a `sound` action is a silent no-op, so a program is replayed as written.
244
260
  - `random()` is seeded in `--play` (seed `1`), so a replay says the same thing twice and an `expect` can
245
261
  assert on a draw. `--seed N` picks another one.
246
- - **`turn`** rotates a `turn`/`turnDeg` target by `angle` (degrees for `turnDeg`, radians for `turn`),
247
- swept in sub-steps so a multi-turn rotation lands. It presses the object where the engine finds it, i.e.
248
- on **whatever is topmost there** — two clock hands overlapping at noon give the gesture to the one on
249
- top. Add **`"from": [x, y]`** to say where the finger lands and pick the other one.
262
+ - **A pointer event takes a frame.** Each press, move and release is followed by one simulation step, as
263
+ a real pointer stays at least one frame on each position — so a rule written in `every frame` sees the
264
+ drag. `--settle N` sets that number for the whole script, and `"settle": N` on a gesture sets it for that
265
+ one. **`--settle 0` is exactly the replay of before 0.38** (`turn` keeps the step it always took between
266
+ its sub-moves). A script that already paces itself with `wait` gestures gets one more frame per event:
267
+ drop the `wait`, or keep the script as it is with `--settle 0`. A script that `expect`s a value which
268
+ decays every frame (a feedback pulse read right after the tap) needs `"settle": 0` on that tap.
269
+ - **`turn`** turns a `turn`/`turnDeg` target **to** `angle`: the value the gesture ENDS at, wherever the
270
+ press was — not a rotation added to the current one. Degrees for `turnDeg`, radians for `turn`; `0` is
271
+ to the right of the pivot, positive is clockwise on screen. It is swept in sub-steps, so several turns
272
+ land (`"angle": 540`). The press goes to the object's position, then to the centre of its drawn box (a
273
+ hand drawn *from* its pivot has its origin on the edge of its shape). **`"from": [x, y]`** names the
274
+ press point — the way to pick one of two hands overlapping at noon. A press that does not grab the
275
+ target is **reported** — a `warnings` entry in the JSON, a `flatc: warning:` line on stderr — naming
276
+ what is grabbed there instead; it used to turn the wrong object, or none, in silence. It is not a
277
+ failure: a script may be proving that a locked dial does not respond.
250
278
  - `set` drives a variable from the host; `wait` runs N fixed 60 Hz steps (advances `every frame` physics).
251
279
  - **`key`** holds a key down (`keys.<name>` reads `1`) for `frames` steps — default `1` — then releases
252
280
  it: the way to test a keyboard-driven scene in CI. Use the authored name (`"ArrowRight"`, `"Space"`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flatkit/compiler",
3
- "version": "0.37.1",
3
+ "version": "0.39.0",
4
4
  "description": "The FlatInk language (parser + AST) and compiler (.flatink → .flatpack). Ships the flatc CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Zwyk Studio",
@@ -57,9 +57,9 @@
57
57
  "docs"
58
58
  ],
59
59
  "dependencies": {
60
- "@flatkit/engine": "0.37.1",
61
- "@flatkit/types": "0.37.1",
62
- "@flatkit/player": "0.37.1"
60
+ "@flatkit/player": "0.39.0",
61
+ "@flatkit/engine": "0.39.0",
62
+ "@flatkit/types": "0.39.0"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "skia-canvas": "^3.0.8 || ^4.0.0-rc7"
@@ -52,7 +52,7 @@ path "M0 60 L80 40 L200 52 L320 44" smooth // the same points joined by a
52
52
  polyline xs ys [count "n"] [closed] // points read from two array vars, every frame
53
53
  text "Hi" font "sans-serif" size 24 align center line 1.2 color #fff box 200 40
54
54
  image "logo" 80 80 at -40,-40 // origin = top-left → center with at -w/2,-h/2
55
- group "Name" at x,y pivot px,py { layer "c" { … } } // nests its own layers
55
+ group "Name" at x,y pivot px,py [rotate deg] [scale s] { layer "c" { … } } // nests its own layers; a FIXED rotate/scale, around the pivot
56
56
  instance "Symbol" as "Name" at x,y // place a symbol from a .flat lib
57
57
  ```
58
58
 
@@ -272,6 +272,7 @@ feedback lift tilt dim shake(<expr>) // one-liner reactions
272
272
  def gap = 70 // compile-time constant, used via $()
273
273
  repeat i from 0 to 4 { circle $(40 + i*gap) 80 6 fill #ffd98a } // $(expr) = compile-time arithmetic
274
274
  symbol "Card"(label, tint = "#fff") { … text "$(label)" … fill $(tint) … } // parameterized symbol
275
+ symbol "Dot" { layer "a" { circle 0 0 10 fill #c33 } } // a plain symbol may live in the program too: instance "Dot" as "D" at 100,100
275
276
  instance "Card"($(i+1)) as "C$(i)" at $(80 + i*90),200
276
277
  each "Key" as i { when clicked { input = input*10 + (i+1) } } // shared behavior over instances
277
278
  match Word1, Word2 onto Good, Bad { // declarative drag+drop pairing
@@ -285,9 +286,11 @@ align top of "Bin" [offset dx,dy] // pin origin onto ano
285
286
  ## Expressions & stdlib
286
287
 
287
288
  Pure & numeric (no booleans: comparisons/logic yield `1`/`0`). Operators: `?: || && == != < > <= >=
288
- + - * / % - ! . [] fn()`.
289
+ + - * / % - ! . [] fn()`. A lookup table can be written in place and indexed at once: `[10, 20, 30][i]`.
289
290
  Built-ins: `sin cos tan asin acos atan atan2 abs sqrt pow exp log floor ceil round sign min max hypot
290
- clamp(x,lo,hi) lerp(a,b,t) mod(a,b) between(x,lo,hi) rad(deg) deg(rad) turns(n)`. Constants `PI TAU E`.
291
+ clamp(x,lo,hi) lerp(a,b,t) mod(a,b) between(x,lo,hi) rad(deg) deg(rad) turns(n)`. Constants `PI TAU E`, and `DT` = 1/60 s: `every frame` is a
292
+ fixed 60 Hz step whatever the timeline's fps, so integrate with `v = v + a * DT`. Handlers run when their event
293
+ arrives, before the next step.
291
294
  Reserved: `time` (seconds, **wraps** every `durationFrames`), `clock` (seconds, **monotone**), `frame`,
292
295
  `value`, `mouse.x/y`, `keys.<Key>`, `self.*`, `<Name>.*`.
293
296
  Packages: `use "collision" | "easing" | "gesture" | "feedback"`; functions are available bare and
@@ -34,7 +34,7 @@ every frame { if score >= 10 { send "win" } }
34
34
  circle cx cy r · ellipse cx cy rx ry · rect x y w h [r | rx ry] · path "M0 0 L10 0 L10 10 Z"
35
35
  text "Hi" font "sans-serif" size 24 align center line 1.2 color #fff box 200 40 [bold] [italic] [wrap]
36
36
  image "id" w h at -w/2,-h/2 // origin top-left → center yourself ; needs: asset "id" "f.png" image
37
- group "Name" at x,y pivot px,py { layer "c" { … } } // nests its own layers
37
+ group "Name" at x,y pivot px,py [rotate deg] [scale s] { layer "c" { … } } // nests its own layers
38
38
  instance "Symbol" as "Name" at x,y // place a symbol from a .flat
39
39
  polyline xs ys [count "n"] [closed] // straight segments through two array vars, live
40
40
  ```
@@ -102,9 +102,9 @@ State/funcs: `var a = 0` · `var arr = fill(8,0)` (also as an ASSIGNMENT: `arr =
102
102
  · `each "Key" as i { when clicked { … } }` · `at center` · `align top of "Bin" [offset dx,dy]`.
103
103
 
104
104
  ## Expressions
105
- Pure numeric, no booleans (compare/logic → 1/0). Ops: `?: || && == != < > <= >= + - * / % - ! . [] fn()`.
105
+ Pure numeric, no booleans (compare/logic → 1/0). Ops: `?: || && == != < > <= >= + - * / % - ! . [] fn()` · inline table `[10, 20, 30][i]`.
106
106
  Funcs: `sin cos tan atan2 abs sqrt pow floor ceil round sign min max hypot clamp(x,lo,hi) lerp(a,b,t)
107
- mod(a,b) between(x,lo,hi) rad(deg) deg(rad) turns(n)`. Const `PI TAU E`.
107
+ mod(a,b) between(x,lo,hi) rad(deg) deg(rad) turns(n)`. Const `PI TAU E` · `DT` = 1/60 s (the `every frame` step, fixed 60 Hz: `v = v + a * DT`).
108
108
  Reserved: `time`(s, **wraps**) `clock`(s, **monotone**) `frame` `value` `mouse.x/y` `keys.<Key>` `self.*` `<Name>.*`.
109
109
  Channels: `x y scaleX scaleY rotation opacity` (absolute) + `dx dy` (additive: `pos = at + (dx, dy)`).
110
110
  Stateful easing: `spring <ch> "<target>" stiffness <0..1> damping <0..1>` · `smooth <ch> "<target>" k <0..1>`
@@ -68,7 +68,7 @@ object "Dial" { spring rotation = aim { stiffness 0.08 damping 0.86 } } // smo
68
68
  Pure numeric expressions (no booleans — logic/compares yield `1`/`0`). Operators `?: || && == != < >
69
69
  <= >= + - * / % - ! . [] fn()`. Built-ins: `sin cos tan atan2 abs sqrt pow exp log floor ceil round
70
70
  sign min max hypot clamp(x,lo,hi) lerp(a,b,t) mod(a,b) between(x,lo,hi) rad deg turns`. Constants
71
- `PI TAU E`. Reserved: `time` (s), `frame`, `value`, `mouse.x/y`, `keys.<Key>`, `self.*`, `<Name>.*`.
71
+ `PI TAU E`, `DT` (= 1/60 s: `every frame` is a fixed 60 Hz step, integrate with `v = v + a * DT`). Reserved: `time` (s), `frame`, `value`, `mouse.x/y`, `keys.<Key>`, `self.*`, `<Name>.*`.
72
72
 
73
73
  Functions: `fn dist(ax,ay,bx,by) = hypot(ax-bx, ay-by)` (value) · `fn reset() { score = 0 }` (procedure).
74
74
 
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/compile.ts"],"sourcesContent":["// ─────────────────────────────────────────────────────────────────────────────\n// compile.ts — the \"modern SWF\" compiler (RFC, step P2).\n//\n// Takes the PROGRAM (`.flatink`) + the ASSETS (`.flat`) and produces a resolved `Doc` —\n// the \".flatpack\" v1 (= the baked doc the player already plays). Steps:\n// 1. parse the assets → symbols;\n// 2. parse the program → composition + behavior (`@Name` refs);\n// 3. RESOLVE refs by name (instances → symbol id), across ALL libs;\n// 4. assemble the Doc.\n//\n// v1: no binary optimization nor media embedding (P2b). Pure, no DOM.\n// ─────────────────────────────────────────────────────────────────────────────\nimport type { Doc, Item, Layer } from '@flatkit/types'\nimport { parseFlatLib, parseProgramFull } from '@flatkit/engine/flatFormat'\nimport { isGroup, isInstance } from '@flatkit/engine/layers'\n\n/** Resolves `symbolId: '@Name'` instances into real ids (recursive, across groups). */\nfunction resolveRefs(layers: Layer[], byName: Map<string, string>): void {\n const walk = (items: Item[]) => {\n for (const it of items) {\n if (isInstance(it) && it.symbolId.startsWith('@')) it.symbolId = byName.get(it.symbolId.slice(1)) ?? it.symbolId\n if (isGroup(it)) it.layers.forEach((l) => walk(l.items))\n }\n }\n layers.forEach((l) => walk(l.items))\n}\n\n/** Source of a media (base64 data-URI) by declared path (`asset … \"path\" …`). */\nexport type MediaMap = Record<string, { mime: string; data: string }>\n\n/**\n * Compile a `.flatink` program + its `.flat` assets → playable `Doc` (the \".flatpack\").\n * `assetSrcs` = the text of each `.flat` lib. `media` = the content of the referenced media files\n * (by path). Symbol refs (by name) are resolved across the whole set of libs; declared media\n * are EMBEDDED (path → data-URI).\n */\nexport function compileFlatpack(programSrc: string, assetSrcs: string[] = [], media: MediaMap = {}): Doc {\n const libs = assetSrcs.map((src) => parseFlatLib(src))\n const symbols = libs.flatMap((l) => l.symbols)\n const folders = libs.flatMap((l) => l.folders) // library folders (organization)\n const prog = parseProgramFull(programSrc)\n const byName = new Map(symbols.map((s) => [s.name, s.id]))\n symbols.forEach((s) => resolveRefs(s.layers, byName)) // cross-lib refs\n resolveRefs(prog.layers, byName) // program refs\n\n // Embed declared media (data = path → provided data-URI). Missing = left as is.\n const assets = (prog.assets ?? []).map((a) => {\n const m = media[a.data]\n const r = m ? { ...a, mime: m.mime, data: m.data } : a\n // A font without an explicit family defaults to its declared id — which is exactly what text targets via\n // `font \"<id>\"`. Gives consumers an explicit `family` instead of relying on a `family || id` fallback.\n return r.kind === 'font' && !r.family ? { ...r, family: r.id } : r\n })\n\n return {\n width: prog.width,\n height: prog.height,\n ...(prog.background ? { background: prog.background } : {}),\n symbols,\n ...(folders.length ? { folders } : {}),\n layers: prog.layers,\n ...(prog.variables && Object.keys(prog.variables).length ? { variables: prog.variables } : {}),\n ...(prog.imports?.length ? { imports: prog.imports } : {}),\n ...(prog.functions?.length ? { functions: prog.functions } : {}),\n timeline: prog.timeline ?? { fps: 24, durationFrames: 60, tracks: [] },\n ...(prog.interactions?.length ? { interactions: prog.interactions } : {}),\n ...(prog.interactors?.length ? { interactors: prog.interactors } : {}),\n ...(assets.length ? { assets } : {}),\n }\n}\n\n/** Serialize the `.flatpack` v1 (JSON of the compiled Doc). */\nexport const packToJSON = (doc: Doc): string => JSON.stringify(doc)\n"],"mappings":";AAaA,SAAS,cAAc,wBAAwB;AAC/C,SAAS,SAAS,kBAAkB;AAGpC,SAAS,YAAY,QAAiB,QAAmC;AACvE,QAAM,OAAO,CAAC,UAAkB;AAC9B,eAAW,MAAM,OAAO;AACtB,UAAI,WAAW,EAAE,KAAK,GAAG,SAAS,WAAW,GAAG,EAAG,IAAG,WAAW,OAAO,IAAI,GAAG,SAAS,MAAM,CAAC,CAAC,KAAK,GAAG;AACxG,UAAI,QAAQ,EAAE,EAAG,IAAG,OAAO,QAAQ,CAAC,MAAM,KAAK,EAAE,KAAK,CAAC;AAAA,IACzD;AAAA,EACF;AACA,SAAO,QAAQ,CAAC,MAAM,KAAK,EAAE,KAAK,CAAC;AACrC;AAWO,SAAS,gBAAgB,YAAoB,YAAsB,CAAC,GAAG,QAAkB,CAAC,GAAQ;AACvG,QAAM,OAAO,UAAU,IAAI,CAAC,QAAQ,aAAa,GAAG,CAAC;AACrD,QAAM,UAAU,KAAK,QAAQ,CAAC,MAAM,EAAE,OAAO;AAC7C,QAAM,UAAU,KAAK,QAAQ,CAAC,MAAM,EAAE,OAAO;AAC7C,QAAM,OAAO,iBAAiB,UAAU;AACxC,QAAM,SAAS,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;AACzD,UAAQ,QAAQ,CAAC,MAAM,YAAY,EAAE,QAAQ,MAAM,CAAC;AACpD,cAAY,KAAK,QAAQ,MAAM;AAG/B,QAAM,UAAU,KAAK,UAAU,CAAC,GAAG,IAAI,CAAC,MAAM;AAC5C,UAAM,IAAI,MAAM,EAAE,IAAI;AACtB,UAAM,IAAI,IAAI,EAAE,GAAG,GAAG,MAAM,EAAE,MAAM,MAAM,EAAE,KAAK,IAAI;AAGrD,WAAO,EAAE,SAAS,UAAU,CAAC,EAAE,SAAS,EAAE,GAAG,GAAG,QAAQ,EAAE,GAAG,IAAI;AAAA,EACnE,CAAC;AAED,SAAO;AAAA,IACL,OAAO,KAAK;AAAA,IACZ,QAAQ,KAAK;AAAA,IACb,GAAI,KAAK,aAAa,EAAE,YAAY,KAAK,WAAW,IAAI,CAAC;AAAA,IACzD;AAAA,IACA,GAAI,QAAQ,SAAS,EAAE,QAAQ,IAAI,CAAC;AAAA,IACpC,QAAQ,KAAK;AAAA,IACb,GAAI,KAAK,aAAa,OAAO,KAAK,KAAK,SAAS,EAAE,SAAS,EAAE,WAAW,KAAK,UAAU,IAAI,CAAC;AAAA,IAC5F,GAAI,KAAK,SAAS,SAAS,EAAE,SAAS,KAAK,QAAQ,IAAI,CAAC;AAAA,IACxD,GAAI,KAAK,WAAW,SAAS,EAAE,WAAW,KAAK,UAAU,IAAI,CAAC;AAAA,IAC9D,UAAU,KAAK,YAAY,EAAE,KAAK,IAAI,gBAAgB,IAAI,QAAQ,CAAC,EAAE;AAAA,IACrE,GAAI,KAAK,cAAc,SAAS,EAAE,cAAc,KAAK,aAAa,IAAI,CAAC;AAAA,IACvE,GAAI,KAAK,aAAa,SAAS,EAAE,aAAa,KAAK,YAAY,IAAI,CAAC;AAAA,IACpE,GAAI,OAAO,SAAS,EAAE,OAAO,IAAI,CAAC;AAAA,EACpC;AACF;AAGO,IAAM,aAAa,CAAC,QAAqB,KAAK,UAAU,GAAG;","names":[]}