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: falseon aflowBox'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 fieldId — applyBindings 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 toease-in-cubicpapers 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 fourtheasing.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
transitionsandenter/exitmotion fold into named animations on the scene itself. They are already the same shape — a scene'senteris 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.