A declarative, JSON-based animation specification where every visual property is a math expression. Specs are designed to be written by humans, generated by AI, and rendered by anything.
{
"spec": "equanim/0.1",
"meta": { "title": "Double Pendulum", "duration": 30, "width": 600, "height": 600, "fps": 60, ... },
"variables": {
"L1": { "label": "Arm 1 length (m)", "default": 2.5, "min": 0.5, "max": 5.0 },
"g": { "label": "Gravity (m/s²)", "default": 9.8, "min": 1.0, "max": 30.0 }
},
"scene": {
"objects": [
{
"id": "phys", "type": "ode_system",
"state": { "th1": 2.0, "w1": 0.0, "th2": 2.5, "w2": 0.0 },
"derivatives": {
"th1": "w1",
"w1": "(-g*(2*m1+m2)*sin(th1) - m2*g*sin(th1-2*th2) - 2*sin(th1-th2)*m2*(w2^2*L2+w1^2*L1*cos(th1-th2))) / (L1*(2*m1+m2-m2*cos(2*(th1-th2))))",
"th2": "w2",
"w2": "(2*sin(th1-th2)*(w1^2*L1*(m1+m2)+g*(m1+m2)*cos(th1)+w2^2*L2*m2*cos(th1-th2))) / (L2*(2*m1+m2-m2*cos(2*(th1-th2))))"
},
"solver": "rk4", "step": 0.005
},
{
"id": "arm1", "type": "line",
"equations": {
"x1": "0", "y1": "110",
"x2": "L1*ppm*sin(phys_th1(t*d))", "y2": "110 - L1*ppm*cos(phys_th1(t*d))"
},
"timeline": { "start": 0.0, "end": 1.0 }
}
]
}
}Equanim separates what an animation does from how it's rendered. A spec is pure data — no code, no runtime dependencies, no renderer assumptions. You hand it to a renderer and it plays.
The key idea: every number in every frame is computed from a math expression evaluated at time t. Want a circle that grows? r = 50 * t. Want a wave that decays? y = amplitude * exp(-decay * t) * sin(k * s - omega * t). The entire animation is a function.
Four time/duration variables are always available:
t— local normalised time, 0→1 over the object's own timeline window. Default choice:opacity = talways fades in over whatever duration the object is given.d— local duration in seconds (length of the object's own window). Multiply to get local seconds:t * d.root_t— global normalised time, 0→1 over the full animation. Use to sync effects across objects regardless of their individual windows.root_d— total animation duration in seconds. Multiply to get global seconds:root_t * root_d.
Variables let template authors expose parameters as interactive controls. A renderer can show these as sliders, inputs, or anything else. Changing a variable updates every expression that references it — live, without reloading.
equanim/
├── spec.md ← The spec. Source of truth for the JSON format.
├── renderer/ ← Reference renderer (TypeScript + Vite + mathjs)
│ ├── src/
│ │ ├── types.ts ← TypeScript interfaces mirroring the spec schema
│ │ ├── evaluator.ts ← mathjs expression compiler
│ │ ├── render.ts ← Coordinate transforms, per-frame draw logic
│ │ ├── player.ts ← RAF loop, play/pause/seek state machine
│ │ └── main.ts ← Entry point, variable sliders, drag-and-drop
│ └── specs/ ← Example spec JSON files
└── README.md
spec.md is the canonical document. If there's ever a conflict between the spec and the renderer, the spec wins.
cd renderer
npm install
npx viteOpen http://localhost:5173. The renderer loads specs/double-pendulum.json on startup and loops automatically. Drag and drop any .json spec file onto the canvas to load it.
Each core module has a companion test file. Run them individually with npx tsx:
cd renderer
npx tsx src/evaluator.test.ts
npx tsx src/render.test.ts
npx tsx src/player.test.ts
npx tsx src/ode-solver.test.ts279 assertions, all passing. Tests are plain TypeScript — no test framework dependency. If you add a feature, add tests.
The full format is documented in spec.md. The short version:
- Every object has a
timeline: { start, end }— fractions of the total duration, both in [0, 1]. - Every visual property is a string expression evaluated by mathjs.
- Expressions have access to
t(local 0→1),d(local seconds),root_t(global 0→1),root_d(total seconds),s(parametric domain), anyparamsorvariablesyou define, and namedfunctions. - A
variablesblock defines user-controllable parameters withdefault,min,max, and optionalstep.
The simplest possible spec:
{
"spec": "equanim/0.1",
"meta": {
"title": "Dot",
"duration": 2.0,
"width": 400,
"height": 400,
"fps": 60,
"coordinate_system": "cartesian",
"origin": "center"
},
"scene": {
"id": "root",
"objects": [
{
"id": "line",
"type": "line",
"style": { "stroke": "#ffffff", "stroke_width": 2 },
"equations": {
"x1": "-100 * (1 - t)",
"y1": "0",
"x2": "100 * t",
"y2": "0"
},
"timeline": { "start": 0, "end": 1 }
}
]
}
}The project is in early development. The spec is not stable yet — we're deliberately keeping it loose while we add example specs and find rough edges.
The most useful contributions right now:
- New example specs. Write a spec that does something interesting and surfaces a gap or awkwardness in the format. Good candidates: a bouncing ball, a text fade, a Lissajous figure, a bar chart animating in.
- New primitive types.
circle,rect, andtextare the obvious next ones. Seespec.mdfor the planned list. Adding a primitive means: updatingspec.md, adding the TypeScript interface totypes.ts, adding aprepare+drawpath inrender.ts, and adding tests. - Spec design feedback. If something in the format feels wrong or limiting — open an issue and explain what you were trying to express and what got in the way.
- Alternative renderers. The TypeScript renderer is a reference implementation, not the only one. A Python renderer, a Swift renderer, a server-side PNG exporter — all useful, all welcome.
Before submitting a PR:
- Run all three test files and confirm 0 failures.
- If you changed the spec format, update
spec.mdfirst. - If you changed renderer behaviour, update or add tests. The test count going up is a good sign.
- Keep PRs focused. One thing per PR is easier to review than three.
Opening issues:
There's a list of open design questions at the bottom of spec.md. If you have a view on any of them, an issue is the right place to hash it out before anyone writes code.
Data, not code. A spec is JSON. It has no executable content. A renderer should be able to validate a spec's structure without running any of its expressions.
Renderer-agnostic. The spec says what the animation is. It doesn't say how to render it. The coordinate system is documented so any renderer can implement it consistently.
AI-friendly. The format is designed to be generated by a language model given only spec.md as context. Expressions are strings, not ASTs. The schema is flat and regular. This is intentional.
t-first. The default time variable is t (local, 0→1). This makes specs portable — changing an object's timeline.start and timeline.end changes when and how fast the animation plays, not just whether it's visible. When you need real seconds, multiply: t * d for local, root_t * root_d for global.
Phase 1 — Harden the spec (in progress)
The goal of this phase is to find where the format is weak before locking anything down. More example specs are the primary tool: each one should try to express something the existing examples don't, and surface gaps or awkwardness in doing so. Good candidates: Lissajous figure, orbiting system, bar chart animating in, easing demo.
- Reference renderer — TypeScript + Vite + mathjs, canvas-based
-
parametric_pathprimitive -
lineprimitive -
circleprimitive - Time variable system —
t,d,root_t,root_d - Example specs — dampened wave, bouncing ball, double pendulum
-
ode_systemnode — RK4 numerical integration; state variables exposed as interpolators to sibling objects - More example specs — stress-test the format across different animation types
-
rectprimitive —x,y,width,heightas equations - Spec validator — structured errors on malformed specs, reserved identifier check
- AI generation test — give
spec.mdto an LLM cold and see what it gets wrong
This phase ends when an LLM can generate a valid, interesting spec from spec.md alone without hand-holding.
Phase 2 — Composition
-
groupprimitive — container with its own timeline, exposing<group_id>_tand<group_id>_dto children -
equanim-utilspackage — expression builder helpers (piecewise, lerp, easing functions) that output valid mathjs strings; separate from the renderer
Phase 3 — Export and distribution
- Headless renderer — Node.js + canvas, frame-accurate offline rendering
- Lottie exporter — compile Equanim specs to Lottie JSON for iOS, Android, and web playback
- SVG path primitive — accept a static SVG
dstring as the curve shape, with equations controlling position, scale, and rotation; bridges the "designed in a tool" and "expressed mathematically" worlds
Phase 4 — Ecosystem
-
Community spec library — curated, searchable collection of open spec files (animations, templates, experiments)
-
Individual registry hosting — anyone can publish and share specs at a stable URL, importable by renderers and tools directly
-
equanim-utilspackage (may move earlier depending on community demand) — expression builder helpers for piecewise, lerp, and easing -
textprimitive — position, content, font size as equations; deferred because font metrics and layout are complex and don't stress-test the core expression system, but needed for completeness
equanim/0.1 — early, unstable, evolving. The spec will change. Contributions that help stress-test the format are more valuable right now than contributions that polish the renderer.
