Skip to content

Keyframe Animation

The generalized per-property keyframe timeline — the animation-generalization counterpart to Cues, since both are time-addressing concerns that plug into the same pipeline stage, but a distinct feature in its own right. Depends directly on Cues' addressing primitive: every keyframe's at field addresses a cue.

Named animations are the authoring unit; keyframes are the engine's unit

Most motion in Retail Studio is, and increasingly will be, "pop", "pulse", "slide in", dropped onto a layer — not hand-placed keyframes. So the authoring unit is a named animation: an id plus parameters, which a pure function expands into keyframe tracks once the layer's resting box is known, at the end of solve, before the first frame animates. A named animation may expand onto the layer's own node or onto its mask node, which is how one wipe entry produces a reveal without the author authoring a mask by hand. The engine only ever sees keyframes. It has no idea a name was involved.

"animations": [
  { "animation": "pop", "params": { "from": 0.6, "durationInFrames": 18 },
    "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 6 } },
  { "property": "opacity", "keyframes": [ /* explicit, when the library doesn't have the shape */ ] }
]

A named animation is exactly (params) → KeyframeTrack[] — a function that receives parameters and returns keyframes, nothing more. The built-in library holds the ones every template wants, and because expansion happens after solve a named animation can read the layer's own resting box (a slide-in "from one width to the left" is -1 × w, whatever w turned out to be) or a manifest value the same way an explicit keyframe's value: { kind: "manifest" } does, so one pop works on any drop target. The library's parameter vocabulary is pinned once, here, so every author and every example spells it the same way:

Named animation Params Expands to
fade from (opacity, default 0), durationInFrames one opacity track
pop from (scale, default 0.6), durationInFrames, overshoot? scale + opacity tracks about the layer's content centre
slideFade from: top\|bottom\|left\|right, distance (px, or { value, unit: "self" } for a multiple of the layer's own size on that axis), durationInFrames x or y track + opacity track
pulse scale (peak), periodInFrames, delay? a continuous scale track: there-and-back each period, from delay to the layer's end
wipe direction: from-left\|from-right\|from-top\|from-bottom, durationInFrames a mask the size of the layer, parked one width or height outside it, with an x or y track sliding it to zero
slideTo x?, y?, durationInFrames an x and/or y track from the node's resting value to the given one; the primitive wipe uses on a mask
they stop being schema. continuous stays, as its own track kind rather than a named animation,
because "runs for the layer's whole lifetime with no fixed duration" isn't a set of keyframes.

This is the same idea as a component library one level down: a component is JSON in, layers out; a named animation is params in, keyframes out. Both keep the engine ignorant of the vocabulary authors actually use.

Two fields every animation entry carries

Whether an entry is a named animation or a hand-authored track, it carries these:

{ "animation": "pulse", "params": { "scale": 1.05, "periodInFrames": 40 },
  "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 6 },
  "enabled": true,
  "pivot": "container" }

enabled is a plain boolean, default true, and it is the answer to "a pulse toggle per panel", which is on almost every row of the Betta catalogue. It has to be a field rather than adding or removing the entry, because a manifest binding writes leaf values and cannot add or remove an array member (Positioning). Three semantics worth stating, because each is a place an implementer would otherwise guess:

  • Disabling never re-times anything else. Every entry addresses a cue independently (Cues), so there is no sequence for a disabled entry to leave a hole in. Turning off the second panel's wipe does not pull the third panel's wipe earlier.
  • A container's disabled animation is not inherited. enabled: false on a flowBox's pulse means its members inherit nothing, which is what "turn the whole lockup's pulse off" means.
  • A member disabling its own animation does not decline an inherited one. The two are separate entries on separate nodes. A member that must sit still inside a moving container is a member that should not be in that container.

pivot says what a scale or rotation track turns about. It is a small enum, not a coordinate and not a layer reference, so it cannot reopen what the rejected per-layer pivot field was rejected for (Coordinate System & Sizing):

pivot Turns about
self (default) the layer's own content centre, the fixed convention everywhere else
container the centre of whatever the layer's chain starts at: its flowBox for a flow member, its target for a pinned layer, its scene for an absolute one

container is what makes a lockup breathe as one object: five panels each carrying their own pulse, each scaling about the row's centre rather than its own, so they move toward a shared point instead of each swelling in place. An arbitrary point that is neither of these is still a pinPoint, which is the escape hatch that already has to exist.

One control, many panels: fan-out is already how bindings work

"Turn off the pulse" as a single switch in the campaign editor, driving five panels at once, needs no new mechanism. A manifestBinding pairs one path with one fieldId, and nothing says two bindings can't name the same fieldIdapplyBindings looks the field up per binding and writes each path from the same campaign value. So five bindings pointing at five layers' animations[0].enabled, all carrying fieldId: "pulse", give the operator one switch. This is worth writing down because it is exactly what a component's single pulse prop bought for free, and decomposing a component into layers looks like it loses it until you notice the binding layer already covers it.

Under the names, the keyframe model:

  • Easing attaches per segment (between adjacent keyframes), not per whole animation, matching the CSS and Web Animations model. return's existing { from, to } per-ramp easing, two independent easings rather than one, is a working precedent already in the schema, in miniature.
  • Spring is its own discriminated easing kind, { kind: "spring", stiffness, damping, mass? }, not a string value living in the same enum as a bezier or named curve — a spring is a physically simulated, non-fixed-duration segment; cramming it next to ease-in-cubic papers over a real structural difference.
  • A keyframe's value generalizes today's animatableTargetSchema (manifest | current | explicit) down to the per-keyframe level — this is what makes both drag-and-drop presets and campaign-varying values (a price box that auto-sizes differently per campaign) work correctly: a keyframe can point at "whatever this resolves to right now" rather than a value baked in at authoring time.
  • Pivot and anchor do not live here — that's Coordinate System & Sizing's own territory. Motion only interpolates properties layout already defines; it doesn't own any geometric concept of its own.
  • Hold/step easing (snap, no interpolation — AE's Hold Keyframe, CSS's steps()) is currently entirely absent from the schema. Cheap to add now as a fourth easing.kind; expensive to retrofit once presets are authored assuming only continuous curves exist.

The keyframe shape (illustrative — the at field's cueOffset/cueFraction addressing is now real Zod, built by Cues' addressing primitive; value, easing, and the rest of the keyframe/track shape below are still first-draft and depend on the generalized keyframe timeline this feature builds):

{
  "property": "x",
  "keyframes": [
    {
      "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 20 },
      "value": { "kind": "explicit", "value": 100 },
      "easing": { "kind": "ease-out-cubic" }
    },
    {
      "at": { "mode": "cueFraction", "fromCue": "start", "toCue": "end", "fraction": 0.35 },
      "value": { "kind": "manifest" },
      "easing": { "kind": "spring", "stiffness": 200, "damping": 20 }
    },
    {
      "at": { "mode": "cueOffset", "cue": "scene-2.end", "offsetFrames": -15 },
      "value": { "kind": "current" }
    }
  ]
}
Field Meaning
property Which layer property this track animates: x, y, scale, rotation, or opacity — the motion graph's node values, and nothing else (the motion graph).
at Replaces a bare frame number; always resolves against a cue, never a raw absolute frame. Two addressing modes: cueOffset ("N frames before/after cue X," offsetFrames can be negative) and cueFraction ("N% of the distance between cue X and cue Y").
value Reuses the manifest/current/explicit kinds, per keyframe.
easing Attaches to the segment running from this keyframe to the next; meaningless, and absent, on a track's last keyframe.
continuous Its own keyframe/track kind, not a bare property track, since "runs for the layer's whole lifetime, no fixed duration" doesn't fit the two-fixed-points shape above at all.

Why at-addressing matters: this is what makes a keyframe track survive retiming. Cues move when voiceover duration changes (Cues → the full cue system); every at re-resolves against the cues' new positions, and no keyframe ever needs touching by hand.

Considered and explicitly shelved: a roaming/stretchy keyframe

AE's roving keyframe adapted — a keyframe whose time is a percentage distance between its two neighbouring keyframes, so squishing those neighbours shifts it proportionally. Worked through directly and the reasoning didn't hold up: it only works if both neighbours are themselves fixed points to measure a distance between, but a neighbour that's a cueFraction keyframe is itself already computed relative to two cues — nesting a roaming keyframe's distance calculation on top of an already-relative neighbour has no stable thing left to measure from. Shelved, not rejected — revisit only once cueFraction addressing has shipped and a concrete need for it shows up in practice.

A smaller, cheaper, closely related stepping stone worth building before or alongside the full keyframe system, not instead of it: lift audioStartSchema's existing { layerId, animation, anchor: start | middle | end, offsetFrames } cross-layer reference shape onto animationStartSchema, so a visual layer's animation can anchor to another layer's animation the same way audio already can. Reuses the existing resolver almost unchanged. The dependency it introduces — animation A's start is defined by animation B's — is a time dependency, resolved in resolve alongside the cue map in the same topological pass that already orders cue references; it never touches the motion graph, which orders spatial dependencies and knows nothing about when anything starts.

Risks

— easing and keyframe interpolation are well-precedented (CSS Transitions/Web Animations). The risk it inherits is Cues' retiming half, and that one is partial, not blank: a real chunk of the retiming math already exists in the aerender-engine (retiming.jsx's getLayerRetimeRegions and calculateStretchFactor, both pure) and ports as-is, but the parts of that file that are AE's own imperative plumbing — markers, timeRemap keyframes, AVLayer manipulation — don't transfer at all, and the question of whether protected-versus-stretchy regions are even the right model outside AE's constraints has no ready-made answer to port. So the math a keyframe track re-resolves against is half known, half designed fresh.

Open questions

  • Whether scene transitions and enter/exit motion fold into named animations on the scene itself. They are already the same shape — a scene's enter is a named animation applied to the scene node in the motion graph — so one vocabulary is plausible, but TransitionSeries-style overlap between scenes has timeline semantics a per-node animation doesn't carry. Not decided.
  • Protected/stretchy regions (Cues) affect whether a keyframe track's cues can move non-uniformly within a scene.

Done when

A template with a keyframed track survives a voiceover-duration change (cues retime, every dependent keyframe re-resolves without hand-editing); spring, hold, and per-segment easing all render correctly against the same pipeline as the fixed archetypes they're replacing.