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
contentorextents. It is paint, like padding and every effect. If a clip could resize a layer, a wipe would reflow itsflowBoxon every frame of its own entrance. - A mask inherits downward exactly as opacity does (below): through
flow membership and
pinType: "full", not through apositionpin or abackground'sof. A mask on aflowBoxclips 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¶
- Positioning —
pin'spinType: "full"andpositionvariants 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
solveproduces, 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-compositionsexportssolveSceneandanimateScene, and each surface calls those same functions for the box it draws, selects, or paints. This replacesuseLayerLayouts.tsrunning its own copy ofinterpolateAnimatedPropsanduseRenderedLayerSizes.tsmeasuring the DOM: one evaluation, read by everything, is the only way afloworpinlayer'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.