Skip to content

Commit 4e9c6a3

Browse files
committed
Add headless Node GIF rendering
1 parent 6ddee82 commit 4e9c6a3

20 files changed

Lines changed: 1263 additions & 200 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ npm install @metta-ts/node # CLI + file import! + a parallel matcher
2323
npm install @metta-ts/browser # web entry + in-memory virtual file system
2424
npm install @metta-ts/py # optional Python interop: pythonia or Pyodide
2525
npm install @metta-ts/prolog # optional Prolog interop: SWI native or SWI-WASM
26+
npm install @metta-ts/grapher # visual editor + browser or Node reduction GIFs
2627
```
2728

2829
For the command-line runner, install `@metta-ts/node` globally (or use `npx`):

examples/grapher-gif.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-FileCopyrightText: 2026 MesTTo
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
// Generate a reduction GIF in plain Node.js. Run with:
6+
// pnpm --filter @metta-ts/examples grapher-gif
7+
8+
import { writeFile } from "node:fs/promises";
9+
import { renderReductionGif } from "@metta-ts/grapher/node";
10+
11+
const source = `
12+
(= (fact $n)
13+
(if (> $n 0)
14+
(* $n (fact (- $n 1)))
15+
1))
16+
(fact 5)
17+
`;
18+
19+
const gif = await renderReductionGif(source, {
20+
view: "blocks",
21+
width: 720,
22+
});
23+
24+
await writeFile("factorial-reduction.gif", gif);
25+
console.log(`Wrote factorial-reduction.gif (${gif.byteLength} bytes)`);

examples/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"transactions": "tsx transactions.ts",
2626
"scaling": "tsx scaling.ts",
2727
"parallel-matcher": "tsx parallel-matcher.ts",
28+
"grapher-gif": "tsx grapher-gif.ts",
2829
"typecheck": "tsc --noEmit"
2930
},
3031
"dependencies": {
@@ -43,6 +44,8 @@
4344
},
4445
"devDependencies": {
4546
"esbuild": "0.27.7",
47+
"gifenc": "1.0.3",
48+
"sharp": "^0.35.3",
4649
"tsx": "^4.19.2",
4750
"typescript": "^5.0.0"
4851
}

packages/grapher/README.md

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# @metta-ts/grapher
22

3-
A browser editor for MeTTa programs. It renders the same atoms as a connected
4-
node graph or nested blocks, evaluates them with `@metta-ts/hyperon`, and
5-
exposes the model and rendering pieces for custom hosts.
3+
A visual editor and reduction renderer for MeTTa programs. It renders the same
4+
atoms as a connected node graph or nested blocks, evaluates them with
5+
`@metta-ts/hyperon`, and can export the reduction from a browser or plain
6+
Node.js.
67

78
## Install
89

@@ -51,12 +52,13 @@ view uses Canvas 2D text measurement, and animated transitions use
5152
`requestAnimationFrame`. Styles are embedded in the generated SVG, so there is
5253
no stylesheet to import.
5354

54-
The parser, graph model, serialization, and evaluation helpers also run
55-
headlessly. Node consumers require Node 20 or newer. `@metta-ts/hyperon` is
56-
installed as a package dependency.
55+
The parser, graph model, serialization, evaluation helpers, SVG-frame builders,
56+
and Node GIF renderer also run headlessly. Node consumers require Node 20 or
57+
newer. The GIF renderer uses Sharp 0.35 and requires Node 20.9 or newer.
58+
`@metta-ts/hyperon` is installed as a package dependency.
5759

58-
GIF export is optional. Install `gifenc` and pass its module to `gif()` or
59-
`exportReductionGif()`:
60+
Browser GIF export is optional. Install `gifenc` and pass its module to `gif()`
61+
or `exportReductionGif()`:
6062

6163
```bash
6264
npm install gifenc
@@ -69,6 +71,40 @@ const blob = await grapher("#metta-graph")
6971
.gif(await import("gifenc"));
7072
```
7173

74+
## Generate a GIF in Node.js
75+
76+
Install the two optional rendering packages:
77+
78+
```bash
79+
npm install @metta-ts/grapher sharp gifenc
80+
```
81+
82+
Then call the Node entry point. It does not create a DOM or open a browser.
83+
84+
```ts
85+
import { writeFile } from "node:fs/promises";
86+
import { renderReductionGif } from "@metta-ts/grapher/node";
87+
88+
const gif = await renderReductionGif("(+ 10 (* 25 2))", {
89+
view: "blocks",
90+
width: 720,
91+
});
92+
93+
await writeFile("reduction.gif", gif);
94+
```
95+
96+
`renderReductionGif()` accepts MeTTa source, one `Atom`, or an array of atoms.
97+
For source and arrays, it loads every atom into the selected engine and traces
98+
the last atom whose head is not `=` or `:`. Pass `{ metta }` to reuse an
99+
existing `MeTTa` space. `view` accepts `"blocks"`, `"graph"`, or
100+
`"side-by-side"`.
101+
102+
The Node renderer uses the same `reduceTrace()`, `blockReductionSvgs()`,
103+
`graphReductionSvgs()`, and `sideBySideReductionSvgs()` pipeline as the browser.
104+
Only SVG rasterization changes by host. The function returns GIF bytes as a
105+
`Uint8Array`; writing, uploading, or returning those bytes is the caller's
106+
choice.
107+
72108
## Exports
73109

74110
The code entry point is `@metta-ts/grapher`. Its main public surfaces are:
@@ -86,8 +122,12 @@ The code entry point is `@metta-ts/grapher`. Its main public surfaces are:
86122
`&grapher` directives.
87123
- `reductionGif`, `graphReductionGif`, and `sideBySideReductionGif` for
88124
caller-supplied GIF encoding.
125+
- `blockReductionSvgs`, `graphReductionSvgs`, `sideBySideReductionSvgs`, and
126+
`encodeSvgAnimation` for host-independent frame generation and encoding.
127+
- `renderReductionGif` from `@metta-ts/grapher/node` for direct Node GIF bytes.
89128

90-
The package also exports `@metta-ts/grapher/package.json`. See the
129+
The package also exports `@metta-ts/grapher/node` and
130+
`@metta-ts/grapher/package.json`. See the
91131
[full API reference](https://mestto.github.io/MeTTa-TS/reference/grapher).
92132

93133
## License

packages/grapher/package.json

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
"version": "1.1.4",
44
"author": "MesTTo",
55
"license": "MIT",
6-
"description": "MeTTaGrapher: a pure-SVG visual node-graph editor for MeTTa. Build a program as connected nodes, evaluate any subtree on the engine, and see the result. A node graph is a MeTTa atom.",
6+
"description": "MeTTa visual editor and reduction renderer for browser and Node.js, with node-graph and nested-block views.",
77
"keywords": [
88
"metta",
99
"hyperon",
1010
"visual",
1111
"editor",
1212
"graph",
13+
"gif",
1314
"svg",
1415
"typescript"
1516
],
@@ -22,6 +23,10 @@
2223
"types": "./dist/index.d.ts",
2324
"default": "./dist/index.js"
2425
},
26+
"./node": {
27+
"types": "./dist/node.d.ts",
28+
"default": "./dist/node.js"
29+
},
2530
"./package.json": "./package.json"
2631
},
2732
"files": [
@@ -32,11 +37,27 @@
3237
},
3338
"sideEffects": false,
3439
"scripts": {
35-
"build": "tsup src/index.ts --format esm --dts --clean --platform browser"
40+
"build": "tsup src/index.ts --format esm --dts --clean --platform browser && tsup src/node.ts --format esm --dts --platform node"
3641
},
3742
"dependencies": {
3843
"@metta-ts/hyperon": "workspace:*"
3944
},
45+
"devDependencies": {
46+
"gifenc": "1.0.3",
47+
"sharp": "^0.35.3"
48+
},
49+
"peerDependencies": {
50+
"gifenc": "^1.0.3",
51+
"sharp": "^0.35.3"
52+
},
53+
"peerDependenciesMeta": {
54+
"gifenc": {
55+
"optional": true
56+
},
57+
"sharp": {
58+
"optional": true
59+
}
60+
},
4061
"repository": {
4162
"type": "git",
4263
"url": "git+https://github.com/MesTTo/MeTTa-TS.git",

packages/grapher/scripts/gen-graph-gif.mjs

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,27 @@
22
//
33
// node scripts/gen-graph-gif.mjs <program.metta> "<query>" <out.gif> [width]
44
//
5-
// The frame math (@metta-ts/grapher's graphReductionSvgs) is pure and runs in Node; the SVG frames are
6-
// rasterized and assembled into a GIF by ImageMagick (`convert`). The reduction endpoint matches Run, so
7-
// this is the same picture the in-browser editor plays.
5+
// Uses the package's Node adapter, so the reduction and animation are the same ones the browser editor plays.
86

9-
import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
10-
import { execFileSync } from "node:child_process";
11-
import { MeTTa } from "@metta-ts/hyperon";
12-
import { parseProgram, reduceTrace, graphReductionSvgs } from "../dist/index.js";
7+
import { readFileSync, writeFileSync } from "node:fs";
8+
import { renderReductionGif } from "../dist/node.js";
139

1410
const [, , programPath, query, outGif, widthArg] = process.argv;
1511
if (!programPath || !query || !outGif) {
1612
console.error('usage: node gen-graph-gif.mjs <program.metta> "<query>" <out.gif> [width]');
1713
process.exit(1);
1814
}
1915

20-
const metta = new MeTTa();
21-
for (const atom of parseProgram(readFileSync(programPath, "utf8"))) metta.space().addAtom(atom);
22-
const states = reduceTrace(parseProgram(query)[0], metta);
23-
24-
// Smoothness is tunable via env: more frames per step + a shorter per-frame delay reads smoother.
25-
const { frames, width, height } = graphReductionSvgs(states, {
26-
width: widthArg ? Number(widthArg) : 1100,
16+
const source = `${readFileSync(programPath, "utf8")}\n${query}`;
17+
const width = widthArg ? Number(widthArg) : 1100;
18+
const bytes = await renderReductionGif(source, {
19+
view: "graph",
20+
width,
2721
framesPerStep: Number(process.env.FPS ?? 8),
2822
holdMs: Number(process.env.HOLD ?? 460),
2923
stepMs: Number(process.env.STEP ?? 33),
3024
maxFrames: Number(process.env.MAXFRAMES ?? 320),
25+
maxSteps: Number(process.env.MAXSTEPS ?? 300),
3126
});
32-
console.log(`states=${states.length} frames=${frames.length} size=${width}x${height}`);
33-
34-
// Write each frame's SVG, then let ImageMagick rasterize and assemble them in one call, applying each
35-
// frame's delay (GIF delays are centiseconds, so milliseconds / 10).
36-
const dir = "/tmp/mg-frames";
37-
rmSync(dir, { recursive: true, force: true });
38-
mkdirSync(dir, { recursive: true });
39-
const assemble = ["-loop", "0"];
40-
frames.forEach((f, i) => {
41-
const path = `${dir}/f${String(i).padStart(4, "0")}.svg`;
42-
writeFileSync(path, f.svg);
43-
assemble.push("-delay", String(Math.max(2, Math.round(f.delay / 10))), path);
44-
});
45-
assemble.push("-layers", "optimize", outGif);
46-
execFileSync("convert", assemble);
47-
console.log("wrote", outGif);
27+
writeFileSync(outGif, bytes);
28+
console.log(`wrote ${outGif} (${bytes.byteLength} bytes, width ${width})`);

packages/grapher/src/block/gif.ts

Lines changed: 26 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22
//
33
// SPDX-License-Identifier: MIT
44

5-
// Export a reduction as an animated GIF. Each frame of the block animation is rendered to an SVG string,
6-
// rasterized on an offscreen canvas, and encoded by a caller-provided GIF encoder. The encoder is passed
7-
// in rather than imported, so this package stays dependency-free: only a consumer that wants GIF export
8-
// installs one (gifenc) and hands it over. The frames are the same morph the live view plays, over a fixed
9-
// view box (the union of every state's bounds) so the animation does not jump or rescale.
5+
// Export a reduction as SVG animation frames or a browser GIF. The browser path rasterizes each SVG on an
6+
// offscreen canvas and uses a caller-provided GIF encoder. The frames use a fixed view box over every
7+
// state, so the animation does not jump or rescale.
108

119
import type { Atom } from "@metta-ts/hyperon";
1210
import { DEFAULT_TRACE_MS } from "../anim";
11+
import { encodeBrowserSvgAnimation, type SvgAnimation } from "../svg-gif";
1312
import type { BlockSettings } from "./settings";
1413
import { placeProgram, type BlockBox } from "./layout";
1514
import { boxesToPrims, interpolate, ease, type Prim } from "./animate";
@@ -129,14 +128,12 @@ export function frameSvg(
129128
return parts.join("");
130129
}
131130

132-
/** Encode a sequence of reduction states into an animated GIF, morphing between them. Each state is a
133-
* frontier (one or more terms), so a nondeterministic step shows every branch. */
134-
export async function reductionGif(
131+
/** Build the nested-block SVG frames for a reduction with explicit block settings. */
132+
export function blockReductionSvgsWithSettings(
135133
states: readonly Atom[][],
136134
s: BlockSettings,
137-
lib: GifEncoderLib,
138135
opts: GifOptions = {},
139-
): Promise<Blob> {
136+
): SvgAnimation {
140137
if (states.length === 0) throw new Error("no reduction states to export");
141138
const width = opts.width ?? 720;
142139
const holdMs = opts.holdMs ?? 260;
@@ -146,38 +143,30 @@ export async function reductionGif(
146143
let vb = boundsOf(placeProgram(states[0]!, s), s);
147144
for (const frontier of states) vb = union(vb, boundsOf(placeProgram(frontier, s), s));
148145
const height = Math.max(1, Math.round((width * vb.h) / vb.w));
149-
150-
const canvas = document.createElement("canvas");
151-
canvas.width = width;
152-
canvas.height = height;
153-
const ctx = canvas.getContext("2d");
154-
if (ctx === null) throw new Error("no 2d canvas context for GIF export");
155-
156-
const gif = lib.GIFEncoder();
157-
const addFrame = async (prims: readonly Prim[], delay: number): Promise<void> => {
158-
const svg = frameSvg(prims, vb, width, height, s.canvas);
159-
const img = new Image();
160-
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
161-
await img.decode();
162-
ctx.fillStyle = s.canvas;
163-
ctx.fillRect(0, 0, width, height);
164-
ctx.drawImage(img, 0, 0, width, height);
165-
const { data } = ctx.getImageData(0, 0, width, height);
166-
const palette = lib.quantize(data, 256);
167-
const index = lib.applyPalette(data, palette);
168-
gif.writeFrame(index, width, height, { palette, delay });
169-
};
170-
146+
const background = opts.background ?? s.canvas;
147+
const frames: SvgAnimation["frames"] = [];
171148
const n = states.length;
172149
const perStep = framesPerStepFor(opts, n - 1);
173-
174-
await addFrame(framePrims[0]!, holdMs);
150+
frames.push({ svg: frameSvg(framePrims[0]!, vb, width, height, background), delay: holdMs });
175151
for (let i = 1; i < n; i++) {
176152
for (let k = 1; k <= perStep; k++) {
177153
const prims = interpolate(framePrims[i - 1]!, framePrims[i]!, ease(k / perStep));
178-
await addFrame(prims, k === perStep ? holdMs : stepMs);
154+
frames.push({
155+
svg: frameSvg(prims, vb, width, height, background),
156+
delay: k === perStep ? holdMs : stepMs,
157+
});
179158
}
180159
}
181-
gif.finish();
182-
return new Blob([gif.bytes() as BlobPart], { type: "image/gif" });
160+
return { frames, width, height, background };
161+
}
162+
163+
/** Encode a sequence of reduction states into an animated GIF, morphing between them. Each state is a
164+
* frontier (one or more terms), so a nondeterministic step shows every branch. */
165+
export async function reductionGif(
166+
states: readonly Atom[][],
167+
s: BlockSettings,
168+
lib: GifEncoderLib,
169+
opts: GifOptions = {},
170+
): Promise<Blob> {
171+
return encodeBrowserSvgAnimation(blockReductionSvgsWithSettings(states, s, opts), lib, 256);
183172
}

packages/grapher/src/gifenc.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// SPDX-FileCopyrightText: 2026 MesTTo
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
declare module "gifenc" {
6+
type Encoder = import("./block/gif").GifEncoderLib;
7+
8+
export const GIFEncoder: Encoder["GIFEncoder"];
9+
export const quantize: Encoder["quantize"];
10+
export const applyPalette: Encoder["applyPalette"];
11+
12+
const gifenc: Encoder;
13+
export default gifenc;
14+
}

packages/grapher/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,18 @@ export {
5757
export { reductionGif, type GifEncoderLib, type GifOptions } from "./block/gif";
5858
export {
5959
sideBySideReductionGif,
60+
sideBySideReductionSvgs,
6061
graphReductionGif,
6162
graphReductionSvgs,
6263
blockReductionSvgs,
6364
type GraphFrame,
6465
} from "./sidebyside-gif";
66+
export {
67+
encodeSvgAnimation,
68+
type SvgAnimation,
69+
type SvgFrame,
70+
type SvgRasterizer,
71+
} from "./svg-gif";
6572
export {
6673
roundedRectPath,
6774
roundedBackingPath,

0 commit comments

Comments
 (0)