Custom timeline animations
Motion written as JavaScript instead of picked from the preset catalog.
The body runs once, host-side, and returns keyframes. Those keyframes are stored on the clip and compiled exactly like a preset’s, so nothing evaluates JavaScript at render time.
Why baking rather than per-frame evaluation
Five surfaces sample animations: the WebGPU preview
(PreviewCompositor.tsx), the web export renderer (TimelineRenderer.ts), the
text rasterizer (rasterClipFrames.ts), and the headless compositor
(packages/video-nodes/src/nodes/timeline/compositeRender.ts). They share one
pure sampler in packages/timeline, and there is no JS sandbox in the browser —
QuickJS runs server-side only (web/src/components/jsScript/runJsScript.ts).
Evaluating a body per layer per frame would mean a second engine in the web
bundle, an async hop inside a loop that already has no headroom
(docs/timeline-editor-performance-audit.md, tier 2), and two implementations
to keep bit-identical. Baking gives up one thing — a script cannot react to
playback state — and buys identical output on every surface for free.
Sampling f(t) densely is what makes that equivalent: a body emits its function
at N points and the sampler interpolates, exact to the sampling resolution.
The script contract
A body is a Code-node body. It reads inputs and returns its result through
output():
const samples = [];
for (let i = 0; i <= inputs.sampleCount; i++) {
const t = i / inputs.sampleCount;
samples.push({
t,
opacity: t,
offsetY: (1 - t) * inputs.canvasHeight * 0.1,
});
}
await output("samples", samples);
Inputs
| Field | Meaning |
|---|---|
role |
"in", "out", "emphasis", or "loop" |
durationMs |
The animation’s own window length |
clipDurationMs |
The clip it sits on |
canvasWidth, canvasHeight |
Resolve a normalized distance to px, as a preset does |
params |
The animation’s params, untouched |
staggerCount |
Stagger units the clip splits into (a text clip’s word count), else 0 |
sampleCount |
Suggested density for a body sampling a continuous function |
Outputs
Return exactly one of:
samples— one bag per point in time,{t, opacity, offsetY, …}. A property must be set on every sample or none; a hole would make the sampler invent motion the body never wrote.curves— per-property keyframes,{property, keyframes: [{t, value, easing?}]}, for a body that authored them directly.
A body driving wipeProgress must also output("mask", {direction, softness}).
Direction and softness never animate, and defaulting them would render a wipe
nobody described.
Animatable properties
ANIMATED_PROPERTIES in packages/timeline/src/animation/types.ts is the
list; ANIMATED_PROPERTY_DOCS in animation/custom.ts pairs each channel with
its fold, its identity value and its range, and is what
list_animation_presets prints. Transform: offsetX, offsetY, scale,
scaleX, scaleY, rotation, positionX, positionY, anchorX, anchorY.
Compositing: opacity, wipeProgress. Grade and blur: blur, brightness,
saturation, contrast, hue, temperature, tint. Shape stroke:
trimStart, trimEnd.
A channel folds one of four ways when several animations drive it: add,
multiply, min (wipeProgress — more hidden wins), or replace (the
absolute channels: position, anchor, trim).
Two differences from a preset
No time reversal for "out". A preset authors forward motion and the
compiler reverses it; a body is handed its role and writes the motion it
wants.
Segments default to linear. A preset’s role easing on top of a densely
sampled f(t) would distort values the body already shaped. An explicit
animation.easing, or a per-keyframe easing, still wins.
Storage
The animation carries preset: "custom" and a custom payload
(clipAnimation in packages/protocol/src/api-schemas/timeline.ts):
{
"id": "anim-1",
"role": "in",
"preset": "custom",
"durationMs": 600,
"custom": {
"scriptId": "js-script-row-id", // or "code": "…" — provenance, never run at render time
"bakedAt": "2026-09-01T12:00:00Z",
"curves": [{ "property": "opacity", "keyframes": [{ "t": 0, "value": 0 }, { "t": 1, "value": 1 }] }]
}
}
Limits, enforced at bake and again at compile: 16 curves per animation, 4096 keyframes per curve, one curve per property.
Source-anchored curves
custom.timeBase picks the clock a curve’s keyframes are placed on:
"clip" (the default) normalizes t over the animation window, the way a
preset does. "source" places every keyframe at an absolute sourceMs
instead — a time in the clip’s own media — and the sampler evaluates the curve
at the clip’s current source time (clipSourceMsAt, timeRemap.ts) rather
than at the window’s t. That is the same time the compositor seeks the
clip’s video to, so a speed change, an in-point trim, or a timeRemap moves
the animation exactly the way it moves the footage.
bakedFrom names what produced the curve when a hand edit did not — an audio
loudness bake, a motion tracker — as provenance to re-bake from (kind, and
optionally clipId, assetId, settings), never something rendered.
Because the curve is placed in the media, a trim or a split re-slices it
instead of letting the window stretch it (animation/sourceCurves.ts):
keyframes outside the retained source window are dropped, and an interpolated
keyframe is added at each new edge, so the motion at a timeline instant the
edit did not touch is unchanged. That is exact for a linear segment — the
compiler’s default for a custom animation — and an approximation when a cut
lands inside an eased segment. A clip-based curve carries no sourceMs and is
left alone: stretching with the window is what its t means.
Baking
POST /api/timelines/animations/bake, with code or script_id (a js_scripts
row), the role, the timings, and the canvas:
curl -sX POST localhost:7777/api/timelines/animations/bake \
-H 'content-type: application/json' \
-d '{"code":"await output(\"samples\",[{t:0,opacity:0},{t:1,opacity:1}]);",
"role":"in","duration_ms":500,"clip_duration_ms":3000,
"canvas":{"width":1920,"height":1080}}'
The response carries curves (and mask), the body’s logs, and error when
the body failed — a body that throws is a result to show its author, not a 500.
The run is hermetic: no toolbelt, no secrets, no network, capped at 10s. A curve generator is a function of time, and reach would let the same animation bake differently depending on where it ran.
The agent path
An agent writes a custom animation the way it writes any other: one
animate_clip op through edit_timeline (or ui_timeline_animate_clip in an
open editor), with preset: "custom" and exactly one of curves and
code.
{
"op": "animate_clip",
"target": "Title",
"animations": [{
"role": "in",
"preset": "custom",
"durationMs": 800,
"curves": [{
"property": "offsetY",
"keyframes": [{ "t": 0, "value": 120 }, { "t": 1, "value": 0 }]
}]
}]
}
curves are the keyframes themselves — t runs 0..1 over the animation’s
window, and a curve that stops short of either end is extended by holding its
end value. code is the body above: the host bakes it once through
bakeCustomAnimation — the same hermetic run the endpoint uses — and stores
code beside the curves as provenance. Both paths end at
normalizeCustomCurves, so what is stored is what will render.
Four rules the op enforces, each with the message that says how to fix it:
- Both
curvesandcode, or neither, is refused. One says what the motion is; the other says what computes it. - A property outside
ANIMATED_PROPERTIESis refused, and the message lists the ones a curve may drive. - A
wipeProgresscurve needsmask({direction, softness}) — the same refusal the compiler and the validator raise. - A machine-produced curve goes through
set_baked_animation, not this op. codeneeds a host that can bake it.edit_timelinewires one; a surface with none says so and points atcurvesrather than storing an unbaked body.
Without durationMs the animation spans the clip, so hand-written curves need
no arithmetic to line up with a cut.
list_animation_presets reports the custom contract and every animatable
property with its fold, identity and range, so the channels can be read off the
tool rather than out of this document.
A curve something measured
A bake is re-run — change the sensitivity, bake again — so it cannot use
animate_clip, whose mode is “append everything” or “replace everything”.
set_baked_animation writes one source-anchored curve and carries its
provenance:
{
"op": "set_baked_animation",
"target": "Shot 3",
"animation": {
"property": "scale",
"keyframes": [{ "sourceMs": 0, "value": 1 }, { "sourceMs": 1500, "value": 1.12 }],
"timeBase": "source",
"bakedFrom": { "kind": "audio", "clipId": "clip_music", "assetId": "asset_x" }
}
}
bakedFrom.kind plus the driven property is the identity a re-bake matches: a
second write of the same kind on the same property replaces that animation in
place, keeping its id, and replace: false appends beside it instead. An
animation with no bakedFrom — anything a person keyframed — never matches and
is never overwritten. role defaults to emphasis over the whole clip, and
bakedAt is not stamped: bakedFrom is this path’s provenance, and a
wall-clock field would make two hosts running the same bake write two different
documents.
bake_audio_animation is the producer that exists today. It measures an audio
clip’s own stretch of its file, turns loudness (mode: "envelope") or onsets
(mode: "beats") into keyframes, maps each one from audio-source time through
timeline time into the target clip’s source time, and applies the op. It
refuses a time-remapped clip on either end, because a remap makes “which source
millisecond plays here” a curve over the clip’s window with no inverse to map a
beat through.
Checks
nodetool timeline validate and the validate_timeline tool report
custom_animation_invalid for curves the compiler would skip (an unknown
property, a keyframe with no finite value, a wipeProgress curve with no mask)
Curves that are absent or empty are reported by that same code — an animation
that drives nothing. Inline curves need no script or code behind them: under
the agent path they are the source, not a bake of one.
Code
| Piece | Where |
|---|---|
| Contract, normalization, limits (pure) | packages/timeline/src/animation/custom.ts |
| Compiler path | packages/timeline/src/animation/compile.ts |
| Wire schema | packages/protocol/src/api-schemas/timeline.ts |
| Bake (the one place the body runs) | packages/agents/src/custom-animation-bake.ts |
| HTTP surface | packages/websocket/src/routes/timeline-animations.ts |
| Agent op (one bridge, both surfaces) | packages/agents/src/evals/surfaces/timeline.ts |
| Baked-curve write and its replace rule (pure) | packages/timeline/src/animation/bakedAnimation.ts |
| Audio bake: the three clocks and the two curve shapes | packages/agents/src/capabilities/timeline-audio-bake.ts |
| Validation | packages/execution/src/timeline-debug/validate.ts |
Not built yet
No editor UI. Outside the agent path above, a custom animation is authored through the bake endpoint and written onto the clip by whatever holds the document — the CLI, or a client calling the route directly.