Changing it
New motion
A new family is one file and one line in the registry. The codebase is built around that seam on purpose: everything downstream — the panel, the thumbnail, the timeline, the export — is generated from what the template declares, so there is nothing else to wire up.
The contract
A template is a meta block, a list of controls, and one function:
transform(frame, index, count, values, ctx) -> poseIt is asked where layer index of count sits at frame, and answers a pose: x, y, scale, rotation, alpha, depth. Canvas centre is 0,0; rotation is radians; depth is the sort order, higher drawn nearer.
ctx carries the frame it is living in — canvas size, fps, clip length, the resolved card shape — plus the scene’s easing as ease and easedPhase.
Reach for the right pose field
Several of these look interchangeable and are not. Getting them wrong is the most common way a new family looks subtly broken.
| Want | Use | Not |
|---|---|---|
| A card is far away | dim — it darkens toward black | alpha, which makes it see-through so whatever it overlaps ghosts through and the field reads as broken glass |
| A card is genuinely arriving or leaving | alpha | dim |
| An edge uncovers a still image | clip — the card does not move at all | Translating it (that slides) or scaling it (that distorts) |
| A card tilts out of the plane | taper, drawn through a perspective mesh | skew or scale, which keep opposite edges parallel and equal — no affine transform can say “this edge turned away” |
taper is the expensive path, so use it only when a card really is folding; everything else stays on the cheaper sprite path.
Stay pure
- Same inputs, same output, always. No
Math.random— scattered fields use a seeded hash of the index, so the scatter is stable across a reload and identical in the preview and the export. - Read nothing outside
valuesandctx. No store, no DOM, no clock. - No side effects. The function is called for every layer of every frame, by the preview, the thumbnails and the encoder.
Make it loop
Route your phase through ctx.easedPhase so the scene’s easing applies, and quantize speed with the loop helper so the clip holds a whole number of motion cycles — that is what makes frame 0 identical to the last frame. Conveyors pass their card count as the period so each image lands back on its own slot. If your motion is genuinely one-shot, skip the helper rather than faking a loop.
Register it
Add it to the registry in templates/index.ts. The sidebar group, the control panel, the easing block, the live thumbnail and the export all pick it up from there. Ship variations as preset bundles rather than as copies of the transform.
Keep the id stable once it has shipped: ids are what saved projects reference. Display names can change freely.
If it needs real perspective
Set the engine to the 3D backend and add a second pose function returning true 3D positions and rotations, plus a camera. The 2D transform stays as the thumbnail and fallback projection, so the catalogue card still previews correctly.
Before you call it done
npm test # invariants over every registered template
npx tsc --noEmit # the required checkThe suites run without a browser and check that geometry is well formed and finite, that the template holds in every canvas aspect and card shape, that its render context is complete and its thumbnail stays inside budget. A family that breaks one of those breaks it for the whole catalogue, which is why the check is cheap and mandatory rather than a review comment.