Skip to content

Foundations: The Motion Graph

The motion graph is the mechanism that runs the Animate phase, and every layer reads from it — not just the ones doing relative positioning or dynamic layout. An ordinary absolute layer with its own independent animation asks the graph for its resolved transform exactly the same way a pinned layer does; it just happens to have no incoming edges. Positioning's pin, Background's spanning-member tracking, and Keyframe Animation's per-property interpolation are the consumers that need edges, but the graph is the single source of truth for every layer's transform, not a mechanism reserved for the relative-positioning cases. See Foundations: Pipeline & Architecture for where Animate sits in the pipeline.

Nodes, edges, and the one-way rule

Every layer's resolved per-frame state — its transform (position, scale, rotation) and its opacity — is a node in the graph, sitting on top of the frame-invariant box solve gave it. pin targets, flowBox membership, and background-member unions are its edges — whatever relationship makes one layer's state depend on another's.

A layer never inspects its own JSON, its nesting, or any other layer directly. It asks the graph for its own resolved transform, and the graph is the only thing that knows the topology. Information flows one way: layer asks, graph answers. No consumer — the renderer, an editor's selection overlay, a future feature — needs its own copy of "how do I read another layer's current state," because none of them ever do; they all read the same graph.

Internal representation: a 4×4 matrix per layer

Every layer's fully-composed transform resolves internally to a 4×4 homogeneous matrix. This is purely internal to the graph — authors never see or edit a matrix, only the familiar x/y/w/ h/scale/rotation fields (Coordinate System & Sizing). Matrices are the right representation specifically because cumulative composition — a pin target chained into a flow member chained into another pin, however deep — is exactly what matrix multiplication does, with no bespoke formula needed per level of nesting. A composed matrix also maps directly onto transform: matrix3d(…), the actual CSS the renderer hands to the DOM regardless of how many edges produced it.

4×4 rather than 2D affine because rotation.x/rotation.y are real, animatable, and in shipped templates (betta-product-scroll, designer-appliances-campaign-offer-and-lifestyle, others), and a 2D matrix cannot hold a tilt. The camera is a property of the container, not the layer: one perspective per scene-like container, at the canvas centre, at the AE-matched distance transform3d.ts already computes ((50/36) × canvas width). Each layer's matrix composes under that single camera, so two tilted layers share a vanishing point the way they would in AE — today each layer carries its own perspective() prefix with the vanishing point at its own transform-origin, which is the inconsistency this replaces. pinType: "full" composes the target's full matrix, tilt included: a badge pinned full to a card that flips edge-on flips with it.

The matrix also settles what a bounds box or anchor point is once a target is rotated or scaled: every layer's content/extents bounds are defined in its own local, untransformed frame, and an anchor point on them (pinToAnchor, a background's member edge) is pushed through that layer's composed matrix into canvas space before anyone reads it. So pinToAnchor: "top-right" on a target resting at 30° is the rotated corner where it actually sits on screen, never the corner of an axis-aligned box drawn around it.

pinType: "full" (composing a pin target's live transform, not just its resting box), a spanning background re-deriving its box every frame as its members move, and a flow member inheriting its container's motion are the same problem — a layer's transform depending on another layer's transform, per frame — solved once, centrally, by this graph. Each of these is just another edge in it, not a bespoke composition problem to design separately.

Position-tracking alone isn't enough for pin

Re-resolving a pin target's box every frame already gets positional tracking through scale for free — the target's corner moves outward as it grows. That alone does not make the pinned layer's own size or rotation change to match the target's; that needs the target's full transform actually composed in, which is the real "make me a child of this other layer" relationship the graph exists to provide. pinType: "position" deliberately stops at positional tracking; pinType: "full" is what asks the graph to compose the whole transform.

Opacity along edges

Opacity is a node value but not a transform, so the matrix doesn't carry it; it composes by multiplication down the same edges, the way CSS group opacity does. A pinType: "full" pin and flowBox membership both inherit the parent's opacity — a badge pinned full to a card that fades out fades with it, and a flowBox fading to zero takes its members with it, because in both cases the layer is a child of its parent in every sense. pinType: "position" tracks a point and nothing else, so it stays fully visible while its target fades — the same exclusion attachTo makes today. A background's of edges carry geometry only: the background's box follows its members, its opacity is its own — so a panel that should fade with the group it spans is given the same animation as the group, or the group is a flowBox with a background of its own, which does inherit (Positioning). Scene motion is the existing precedent — a scene's enter/exit opacity already reaches every layer in it.

Masks

A layer may carry one mask: a closed vector path that clips what the layer paints. Vector, not an image; a mask, not a matte — the path is authored geometry, never another layer's alpha.

"mask": {
  "path": [[0, 0], [1, 0], [1, 1], [0, 1]],   // closed polygon, fractions of the layer's extents
  "x": "= -self.extents.w", "y": 0,            // the mask's own transform, in px
  "animations": [
    { "animation": "slideTo", "params": { "x": 0, "durationInFrames": 12 },
      "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } }
  ]
}

A mask is a child node of its layer in the graph, with the same four animatable values every other node has. Its transform composes with its layer's, so a mask on a rotating layer rotates with it, and animating the mask's own x sweeps the clip edge across the layer. That is all a wipe is: a rectangle the size of the layer, parked one width to the left, sliding to zero. The wipe named animation compiles to exactly that (Keyframe Animation), which is why no fifth kind of animatable value was needed to express a reveal.

Two rules keep masks from leaking into layout:

  • A mask never changes content or extents. It is paint, like padding and every effect. If a clip could resize a layer, a wipe would reflow its flowBox on every frame of its own entrance.
  • A mask inherits downward exactly as opacity does (below): through flow membership and pinType: "full", not through a position pin or a background's of. A mask on a flowBox clips its members, which is how a whole lockup wipes on as one.

Paths are polygons in v1 — no curves, no feather, one mask per layer, no boolean combination. Each of those is a widening the shape above can take later without changing what is written here. One known limitation: a moving clip edge produces no motion-blur vector, because nothing about the layer's own transform changed, so a fast wipe will not smear the way a fast move does.

What animates: transform and opacity only

The four node values — position, scale, rotation, opacity — are the complete set of animatable properties under schemaVersion: 2, on a layer node and on a mask node alike. A layer's w/h are inputs to solve, not motion: animating them would reflow every flow sibling, re-derive every spanning background, and re-wrap every line of text per frame, and "Solve builds the topology, Animate walks it" below would stop being literally true. Growth and shrink are expressed as scale, composed about the layer's content centre like every other transform — the same rule CSS applies, where transform never triggers layout. The built text engine currently re-wraps box text against its per-frame animated width (solveScene.ts deliberately takes no measurer for this reason); under this rule it wraps once against the resting box in solve and the animated scale resamples the result.

animatablePropsSchema today admits w and h (and split rotationX/rotationY). The v2 schema removes w/h from it and folds the rotation axes into rotation's scalar-or-structured shape. upgradeV1ToV2 rejects a document that tweens w or h with a message naming the layer, since a size tween has no mechanical equivalent that preserves intent — one shipped template (designer-appliances-campaign-offer-and-lifestyle) does, and is re-authored to scale by hand as part of its migration if it ever moves to v2.

Precedent: scene motion already proves this works

This isn't a new mechanism invented for pin and flow — scene enter/exit motion already composes into every layer inside it today (optional fields; absent means identity, so an ordinary scene with no authored motion contributes nothing extra). pin and flow are the same relationship — "this layer's effective transform composes its own local transform with whatever it's chained to" — just with a different, per-layer-declared parent (a pin target, a flowBox) instead of always being the containing scene.

Overlays fit the same graph without a special case: an overlay is a scene-like container that (today, deliberately) never authors motion of its own, exactly like an ordinary scene with no enter/exit. Every layer's chain simply starts at whichever scene-like container it's directly in — overlays don't need bespoke carve-out logic to avoid inheriting motion they were never going to author. This settles motion inheritance only; where an overlay actually sits in time (its own cue/duration, whether it participates in scene sequencing) is a separate, open question untouched by this.

A free byproduct: motion-blur vectors

Building the graph's composition function once and evaluating it at frame N and frame N−1 — differencing the two results — gives a correctly-varying-by-offset motion-blur vector as a near-free byproduct of the same function that computes position, not a second mechanism to build. This matters because a naive "reuse the parent's vector" visibly breaks the moment there's any real offset: a point further from a rotating parent's pivot traces a bigger arc than the parent's own centre does.

This isn't a new problem the graph introduces — it already exists for component sub-layers. A bespoke component's internal sub-layers (EffectEntry's useSubLayers) already need their own motion-blur vectors, distinct from the component's own, for exactly the same reason a pinned layer needs its own vector distinct from its pin target's.

Solve builds the topology; Animate walks it

solve computes each layer's frame-invariant resting box — this is where the graph's nodes and edges are actually established: which layer pins to which target, which layers belong to which flowBox, which layers a background's union spans. animate is the per-frame walk of that same graph, computing each node's live transform from its edges. The graph's shape doesn't change frame to frame; only the values at each node do.

Solve is one topological sort over every edge kind at once — pinTo, flowIn, and a background's of list — not a pin pass followed by a flow pass followed by a background pass. A background spanning a pinned member of a flowBox is resolved after all three of its dependencies by the same ordering that resolves everything else. Cycle detection covers the same whole graph: once a layer's parent-in-the-chain can be a pin target or a flowBox or a background's member or — transitively — something pinned to one of those, validating each reference kind as if it were independent misses cycles that only exist across them.

The graph is container-local and per aspect ratio. A layer's pinTo, flowIn, or of may only name a layer in the same scene (or the same overlay); a reference across containers fails validation like a dangling one. pinTo, flowIn, and of are per-ratio fields (Positioning), so a layer may pin to one target in 16:9 and another in 9:16, and a background may span a different member set per ratio — which means the topology itself is per (container, ratio), and cycle detection and the depth cap run once per ratio. Each (container, ratio) pair is its own graph, rooted at that container's own motion.

A pure function of (state, frame)

A change to one layer's transform reaches everything downstream of it because that's what an edge means — nothing needs bespoke "does my pin target's animation affect me" logic per feature. That propagation is not a diff against the previous frame: Remotion renders every frame as an independent React render and splits frame ranges across processes, so there is no previous frame to diff against, and the graph is evaluated in full each frame.

Recomputing every frame is the correct choice, not a risk to budget against

The golden rule behind this pipeline: never have two disparate mechanisms computing the same fact, and never recompute per-frame what's cheap to compute once and cache — but be precise about which category a given value falls into. Cue resolution and measurement are campaign-scoped (depend on the voiceover, depend on content, never on frame number), so they're worth caching once. Layout and motion are frame-scoped, cheap, pure functions of (state, frame) — recomputing those every frame is already the correct choice, the same way interpolateAnimatedProps already does it deliberately today, because a pure per-frame function is simpler than a cache with its own invalidation story. The graph doesn't need a performance budget defined against it; it needs to stay a pure function of (state, frame), which it is by construction.

What's broken today, and why this matters now

Pivot and anchor are currently computed independently in at least four places — layer-anchor.ts, transform3d.ts (hardcodes 'center'), motion-blur-vector.ts (explicitly documents that it ignores a layer's real anchor), and FitText.tsx — with no shared resolver. This isn't a minor inconsistency: motion blur is the single most pervasive effect in the product, live in production (it was switched off for a period as a stopgap and is back on), so this divergence already matters on nearly every layer. Fixing it means pivot/anchor becomes part of Coordinate System & Sizing's resolver contract — one function, every consumer reads it, the same discipline this whole graph is built around.

Measurement has the same class of problem on the size side: useRenderedLayerSizes.ts measures live DOM (querySelectorAll('[data-rs-layer-id]') + offsetWidth/offsetHeight), independent of whatever the renderer itself measures — a second, unshared source of truth for size. This one disappears entirely rather than being fixed piecemeal: every primitive has an intrinsic size, and solve has to know every layer's box without touching the DOM anyway or pin and flow couldn't resolve against it. So the solved box is the measurement, for every layer type, and the DOM is never asked.

What reads from the graph

  • Positioningpin's pinType: "full" and position variants are both just edges here, not a harder case.
  • Background — a spanning background re-derives every frame by reading its members' current nodes, not by re-running authoring-time layout logic.
  • Keyframe Animation — a keyframed property interpolates against the resting structure solve produces, which is this graph's own static shape.
  • Every surface that previews or renders a template — the renderer, the Template Builder, the campaign editor, and the coming editor for reusable JSON elements. packages/retail-studio-compositions exports solveScene and animateScene, and each surface calls those same functions for the box it draws, selects, or paints. This replaces useLayerLayouts.ts running its own copy of interpolateAnimatedProps and useRenderedLayerSizes.ts measuring the DOM: one evaluation, read by everything, is the only way a flow or pin layer's selection ring can be right. It's also the precondition for those surfaces sharing one workspace shell — the same canvas, selection, and tool behaviour with a different set of controls exposed per surface — rather than each carrying its own layout reading.

Relationship to existing code

Today's interpolateAnimatedProps/continuousDriftAt machinery does per-frame interpolation against a single layer's own grid layout value; there is no graph, no shared topology, and no diffing today. It's shared today between the renderer and the Builder's own selection overlay via apps/web/src/hooks/retail-studio/useLayerLayouts.ts — proof the "one resolver, everyone reads it" pattern already works in this codebase, just not yet generalised into a graph.

attachTo (layer-attachment.ts) is the closest existing precedent for graph-like composition — it already composes one layer's position against its host's live, per-frame position, including drift compensation — but it's a single hand-written case, not a general graph. See Positioning for the specific gotchas it already ran into, worth reading before building this rather than assuming attachTo's approach scales up unchanged.