--- name: motion-curves description: Write NodeTool timeline animation curves by hand or bake a JavaScript body into curves once. Use for overshoot, bounce, anticipation, arcs, path draw-on, style-property animation, or motion a preset cannot express. featured: true --- # Motion Curves → animation nobody had a preset for An `animate_clip` input with `{"preset":"custom"}` carries exactly one of `curves` (keyframes you write) or `code` (a JS body baked into curves once, host-side). Add `mask` when a curve drives `wipeProgress`. A stored document animation can instead carry `styleTracks` or `textAnimator` without numeric custom curves. `motion-graphics` carries the op contract and the preset catalog this file is the escape hatch from. `motion-principles` gives the duration and the shape a curve should hit before you write its keyframes. For numeric custom curves, call `edit_timeline` with an `animate_clip` op or the in-product `ui_timeline_animate_clip` tool. Both accept the flat `curves` or `code` input shown below. For stored `styleTracks`, `textAnimator`, or `beat`, read `get_timeline`, edit the full clip animation in its document, then call `set_timeline_document` with every existing document field. The `animate_clip` input does not author those stored fields. ## When to write one Write curves as soon as a preset is close but not right. The catalog is seven entrance shapes and a handful of beats — where motion starts, not where it stops — and a piece whose every title arrives with `pop` reads like a template. A curve costs one field and a few numbers, and the recipes below are the shapes worth reaching for: an entrance that overshoots twice, a hold in the middle of a move, two channels on different schedules, a decay, an arc, a wiggle, a path draw-on. Two shapes need no curve, and writing one anyway only makes the document harder to read: a single overshoot and a physical settle are already `easing` strings. `cubic-bezier(0.16,1,0.3,1)` and `spring(180,12,1)` on an ordinary preset do both. Everything past that is this file. ## Curves by hand ```json {"op": "animate_clip", "target": "Title", "animations": [ {"role": "in", "preset": "custom", "durationMs": 600, "curves": [ {"property": "offsetY", "keyframes": [ {"t": 0, "value": 80}, {"t": 0.7, "value": -12, "easing": "easeOut"}, {"t": 1, "value": 0, "easing": "easeInOut"}]}, {"property": "opacity", "keyframes": [ {"t": 0, "value": 0}, {"t": 0.4, "value": 1}]}]}]} ``` `t` runs 0..1 over the animation's **window**, not the clip. `easing` on a keyframe eases the segment **ending** there. Keyframes are sorted, `t` is clamped to 0..1, and a curve that stops short of either end is extended by holding its end value — so sampling 0.05..0.95 does what it looks like. Limits: 16 curves per animation, one per property, 4096 keyframes each. `durationMs` defaults to the clip's own duration, so curves span the whole clip unless you say otherwise. ## The channels, and how they combine Several animations can drive one channel. How they fold decides whether the second adds to the first or throws it away. | Fold | Channels | Identity | Result | |---|---|---|---| | add | `offsetX`, `offsetY`, `rotation` (radians), `blur` (px), `brightness` (−1..1), `hue` (deg), `temperature`, `tint` | 0 | Values sum | | multiply | `scale`, `scaleX`, `scaleY`, `opacity` (0..1), `saturation` (0..4), `contrast` (0..4) | 1 | Values multiply | | min | `wipeProgress` (0..1) | 1 | The tightest wipe wins | | replace | `positionX`, `positionY` (canvas px), `anchorX`, `anchorY` (0..1), `trimStart`, `trimEnd` (0..1) | none | The last animation in document order wins; the other is discarded | The replace row is the trap. Two animations driving `positionX` over the same instants means one does nothing, reported as `replace_curves_overlap`. Move a clip with `offsetX` when it should compose with other motion, and with `positionX` only for an absolute placement nothing else touches. Land the last keyframe on the identity value unless you mean the clip to stay where the curve stopped — and remember the role decides whether it stays: an `in` window holds its `t: 0` values before it and contributes nothing after, an `out` window holds its `t: 1` values after it, `emphasis` holds neither, and `loop` repeats its cycle, so a loop curve must end on the value it started on. `list_animation_presets` reports this table from the engine. Read it there rather than from memory when a channel's range matters. ## Style curves in a document Use `styleTracks` on a stored animation when the target is a visual property rather than a numeric motion channel. The target names a clip style such as `text.color`, `shape.cornerRadius`, `mask.featherPx`, or `clip.borderRadius`; an effect target is `effect..` or a supported effect color such as `effect.bg-field.colorB`. A target needs the matching style, mask, or effect on the clip. Color values interpolate through the supported CSS color parser. `shape.d` and `mask.d` paths need matching commands and point counts. Glyph targets need a text stagger. `validate_timeline` reports an invalid target, incompatible path, or unsupported color. This is one complete stored animation object to merge into an existing clip's `animations` array before the full-document write: ```json {"id":"round-in","role":"in","preset":"fade","durationMs":600,"styleTracks":[{"target":"clip.borderRadius","keyframes":[{"t":0,"value":0},{"t":1,"value":30,"easing":"easeOutExpo"}]}]} ``` `styleTracks` can accompany a preset, so a title can fade while its color changes. The `id` is required in the stored document; `animate_clip` creates one for its own inputs. Use `motion-graphics` for links, text animators, beat binding, and the document write boundary. ## The code body A JavaScript body run **once**, at author time, in the QuickJS sandbox. It returns keyframes; the keyframes are stored on the clip; every renderer samples those. Nothing evaluates per frame, which is why a body cannot react to playback and why the same animation looks identical in the preview, the export and the headless compositor. The bake is hermetic: no toolbelt, no `nodetool.*`, no network, no secrets. Just arithmetic, a 10-second ceiling, and `inputs`: | `inputs` field | What it is | |---|---| | `role` | `in`, `out`, `emphasis` or `loop` | | `durationMs` | The animation's own window | | `clipDurationMs` | The clip it sits on | | `canvasWidth`, `canvasHeight` | Sequence pixels — turn a normalized distance into px here | | `params` | The animation's `params`, untouched | | `staggerCount` | Units a text clip splits into, or 0 | | `sampleCount` | Suggested density for a continuous function | Return `{curves}` — the same shape as the JSON above — or `{samples}`, one bag per point in time: ```js const n = inputs.sampleCount; const samples = []; for (let i = 0; i < n; i++) { const t = i / (n - 1); samples.push({ t, offsetY: 80 * (1 - t), opacity: t }); } return { samples }; ``` Returning both is an error, as is returning neither. A property present on some samples and absent from others is an error rather than a hole — interpolating across the gap would invent motion nobody wrote. A sample needs a finite numeric `t`; `easing` on a sample is optional and applies to the segment ending there. At most 4096 samples. Because the bake runs once, a body using `Math.random` is frozen at the value it drew: the result is stable forever after, but re-baking gives different motion. Seed your own generator when the same body has to produce the same wiggle twice. ## Recipes **Overshoot and settle.** Two overshoots, the second smaller — what a single `easeOutBack` cannot express. ```js const p = [[0, 0.6], [0.45, 1.09], [0.68, 0.97], [0.85, 1.02], [1, 1]]; return { curves: [{ property: "scale", keyframes: p.map(([t, value]) => ({ t, value, easing: "easeInOut" })) }] }; ``` **Decaying bounce.** Hops that die out and land exactly at rest. ```js const n = inputs.sampleCount, h = 0.06 * inputs.canvasHeight, hops = 3; const samples = []; for (let i = 0; i < n; i++) { const t = i / (n - 1); samples.push({ t, offsetY: -h * Math.abs(Math.sin(Math.PI * hops * t)) * (1 - t) }); } return { samples }; ``` **Anticipation.** A counter-move before the action, 60–120ms of the window. ```js return { curves: [{ property: "offsetY", keyframes: [ { t: 0, value: 0 }, { t: 0.18, value: 10, easing: "easeInOut" }, { t: 0.8, value: -4, easing: "easeOut" }, { t: 1, value: 0 }] }] }; ``` **Shaped wiggle.** For a steady seeded wiggle, use a stored `animationLinks` entry with `kind: "wiggle"` as described in `motion-graphics`. Bake a curve when its amplitude must fade in or out. This seeded generator reproduces the same shape on a re-bake. ```js const n = inputs.sampleCount; const amp = Number(inputs.params.amplitude ?? 6); let seed = Number(inputs.params.seed ?? 1); const rand = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648 - 0.5; const samples = []; for (let i = 0; i < n; i++) { const t = i / (n - 1); const fade = Math.min(1, 4 * t, 4 * (1 - t)); // rest at both ends samples.push({ t, offsetX: rand() * amp * fade, offsetY: rand() * amp * fade }); } return { samples }; ``` **Inertia.** A trailing part that lags the body and catches up — follow-through without a second clip. ```js const n = inputs.sampleCount, lag = 0.12; const ease = (t) => 1 - Math.pow(1 - t, 3); const samples = []; for (let i = 0; i < n; i++) { const t = i / (n - 1); const behind = ease(Math.max(0, t - lag) / (1 - lag)); samples.push({ t, offsetX: (1 - behind) * -0.1 * inputs.canvasWidth }); } return { samples }; ``` **Arc.** Straight-line travel looks mechanical. Drive one axis linearly and the other as a shallow parabola over the same window; both fold as `add`. ```js const n = inputs.sampleCount; const dx = 0.25 * inputs.canvasWidth, rise = 0.06 * inputs.canvasHeight; const samples = []; for (let i = 0; i < n; i++) { const t = i / (n - 1); samples.push({ t, offsetX: -dx * (1 - t), offsetY: -rise * 4 * t * (1 - t) }); } return { samples }; ``` **Pendulum.** A decaying rotation, in radians. ```js const n = inputs.sampleCount, a = 0.12, cycles = 2.5; const samples = []; for (let i = 0; i < n; i++) { const t = i / (n - 1); samples.push({ t, rotation: a * Math.cos(2 * Math.PI * cycles * t) * (1 - t) }); } return { samples }; ``` **A loop with no visible join.** Any `loop` curve must return to its start, so build it from a whole number of cycles of a periodic function. ```js const n = inputs.sampleCount, amp = 0.02 * inputs.canvasHeight; const samples = []; for (let i = 0; i < n; i++) { const t = i / (n - 1); samples.push({ t, offsetY: -amp * Math.sin(2 * Math.PI * t) }); } return { samples }; ``` **Path draw-on.** `trimEnd` 0 to 1 on a `path` shape clip. Both trim channels fold as `replace`, so one animation drives each and their windows must not overlap. ```js return { curves: [{ property: "trimEnd", keyframes: [{ t: 0, value: 0 }, { t: 1, value: 1, easing: "easeInOut" }] }] }; ``` **Wipe with a shaped edge.** A `wipeProgress` curve needs the mask on the same animation, because direction and softness cannot be inferred: ```json {"role": "in", "preset": "custom", "durationMs": 700, "mask": {"direction": "left", "softness": 0.1}, "curves": [{"property": "wipeProgress", "keyframes": [ {"t": 0, "value": 0}, {"t": 0.6, "value": 0.8, "easing": "easeOut"}, {"t": 1, "value": 1}]}]} ``` ## Failure modes - A body returning nothing, or both shapes, fails the bake with the reason. Read it: the bake returns the error and the body's logs rather than throwing. - A property name outside the channel list fails the bake and names the ones it will take. - A curve driving `wipeProgress` with no `mask` is refused. - An easing string the grammar cannot read eases linearly and shows up as `unknown_easing` on `validate_timeline`. It is `easeOut`, not `ease-out`. - A window that does not fit the clip after its delay is `animation_exceeds_clip` — the motion is clamped or never runs. - A runaway loop hits the 10-second ceiling. A curve generator is arithmetic over a few hundred points; if it is slow, it is wrong. ## Check the shape you wrote Curves are the one place where reading the numbers is not enough. Use `preview_timeline_frame` with a `range` across the animation's window and `sheet: true`, which tiles the sweep into one labelled contact sheet — the way to watch an overshoot actually overshoot rather than checking three instants that all happen to look right.