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
offsetX, offsetY, scale, rotation, opacity, wipeProgress, blur,
brightness, saturation — the same set presets drive
(ANIMATED_PROPERTIES in packages/timeline/src/animation/types.ts).
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.
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.
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)
and warn custom_animation_unsourced when baked curves name neither a script
nor code, so nothing could re-bake them.
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 |
| Validation | packages/execution/src/timeline-debug/validate.ts |
Not built yet
No editor UI. A custom animation is authored through the bake endpoint and written onto the clip by whatever holds the document — the agent tools, the CLI, or a client calling the route directly.