--- name: motion-principles description: Choose timing, easing, stagger, anticipation, and follow-through for a NodeTool timeline. Use when motion feels stiff, floaty, busy, or late, or when an entrance, transition, or beat-bound animation needs a duration and curve. The op contract lives in timeline-edit-ops; the build loop is motion-graphics. featured: true --- # Motion Principles → the numbers before the call `timeline-edit-ops` is how to write an animation onto a clip with `edit_timeline`. This is how to decide what to write. Every number here is in the units `animate_clip` takes: milliseconds, a normalized 0..1 distance, an easing string. ## The same numbers, written as code Building with `@nodetool-ai/sandbox-timeline` instead (`motion-graphics` picks the surface)? Every number below still applies — only the unit changes. `durationMs` becomes `dur` in seconds (divide by 1000: 400ms is `dur: 0.4`). A normalized 0..1 distance becomes actual px, against the frame's own `width`/`height` (0.3 on a 1920-wide frame is `offsetX: 576` in `from`). Easing strings are identical — `ease: "outExpo"` or a raw curve — and every role still means the same thing: `el.enter()` is `in`, `el.exit()` is `out`, `el.animate()` covers `emphasis` and general keyframing, `el.loop()` is `loop`. `el.count()`, `el.draw()`, `el.scramble()`, `el.typewriter()`, `el.expr()` and `el.morph()` have no ops equivalent below; they are shortcuts for a ticker, a `trimEnd` draw-on, a scramble reveal, a whole-clip typewriter entrance, a hand-written keyframe function, and a path outline morph. ## Settle three things before picking a number | Pillar | The question | What it decides | |---|---|---| | Emotional intent | What should the viewer feel? | Easing, duration, overshoot | | Visual narrative | Setup, action, resolution — what enters when? | Sequencing, `delayMs`, stagger | | Motion craft | Why does it look believable? | Anticipation, follow-through, arcs | A number chosen before the intent is a number you will change twice. ## Three layers, or the frame reads flat Plan three levels of attention. Track index controls their z-order, while timing decides which one the eye follows: - **Primary** — the one move the eye follows. The clip the shot is about. - **Secondary** — supporting motion reacting to the primary: a scrim settling late, a label arriving 80ms after the plate. - **Ambient** — background life that never asks for attention. A `loop` preset at low amplitude on the bed. `motion-direction` ranks the same three as Hero / Support / Texture and decides which elements earn each. Two clips carrying primary motion at once is the most common cause of a busy frame. ## Easing is the biggest lever `linear` reads as robotic. Keep it for motion that never lands: the `loop` presets `float`, `rotate` and `hueShift`, and the `shake` emphasis, whose zig-zag is linear by default. Everything the eye reads as an event decelerates into rest. | Action | Easing | Why | |---|---|---| | Entrance (`in`) | `easeOut`, or `cubic-bezier(0.16,1,0.3,1)` for a sharper landing | Arrives with energy, settles | | Exit (`out`) | `easeIn`, or `cubic-bezier(0.7,0,0.84,0)` | Accelerates away | | A move that stays on screen | `easeInOut`, or `cubic-bezier(0.65,0,0.35,1)` | Symmetric, no false weight | | Playful landing | `easeOutBack`, or `cubic-bezier(0.34,1.56,0.64,1)` | The value past 1 is the overshoot | | Physical settle | `spring(180,12,1)` one overshoot, `spring(180,26,1)` none | Solves a real spring; all three constants positive | | Held pose, then jump | `hold` on the ending keyframe's segment | Keeps the prior value until the segment ends | The role defaults already do the right thing: `in` gets `easeOut`, `out` gets `easeIn`. Leave `easing` unset unless you want something the default is not. A string the parser cannot read eases linearly and reports `unknown_easing` — `easeOut`, never `ease-out`. Overshoot reads as toy-like. Keep `easeOutBack`, `easeOutElastic` and `easeOutBounce` off anything financial, medical or corporate. ## Duration carries weight | Element | `durationMs` | |---|---| | Micro beat: a `flash`, a `pulse` on a small element | 150–250 | | Title, lower third, overlay entrance | 300–500 | | Hero element, full-frame plate, a `blur` rack | 500–800 | | Camera move: `kenBurns`, a slow push | 1500–4000 | Distance and area scale it. A caption travelling `distance: 0.05` and a full-bleed plate travelling `0.5` must not share 400ms — the plate looks weightless. Add roughly 30–50% when the travel or the area doubles. Two limits the engine enforces rather than warns about in prose: - `in` + `out` must fit the clip with hold left over. A 3000ms title with 500ms each way holds 2000ms. Under about 1000ms of hold the element never settles and reads as a glitch. Over-long windows report `animation_exceeds_clip`. - A `loop` cycle longer than the clip shows a fragment. `kenBurns` at its 3000ms default on a 2000ms clip is a drift that stops halfway. ## Stagger is rhythm, and it is clamped Never reveal a group in one event. On a text clip, `stagger` splits the animation per word, grapheme or wrapped line. Which `unit` counts what, and how the count changes with the frame, is in `motion-graphics`. | Content | `offsetMs` | |---|---| | A phrase that should arrive with texture | 60–100 | | A short punchline landing word by word | 150–250 | | More than five words | do not stagger — the reader is waiting on the sentence | The span is `durationMs + offsetMs × (units − 1)`, halved for `from: "center"`. Work it out and compare it against the clip's `durationMs` minus `delayMs`. A span that does not fit is **compressed silently**: the engine shrinks the offset, never the per-unit duration, so the line lands flatter than written and only `validate_timeline` says so, as `stagger_compressed`. For a group of separate clips — three lower-third parts, five icons — stagger by giving each clip's `in` animation a `delayMs` 60–80ms apart, and cap the whole reveal near 700ms. Past that the tail drags. ## The 1/3 rule, in both forms - **Distance.** Nothing travels more than about a third of the frame in one unbroken move. `slide` with `distance` above 0.35 reads as "the template moved". Break it with a scale or opacity change on the same window, or shorten the travel. - **Simultaneity.** With three or more elements, keep at most a third in active motion at any instant. The rest hold, or run ambient loops. ## Anticipation, follow-through, arcs The three that separate animated from moved. None has a preset; each is a `custom` animation, which is where a short `code` body earns its place. **Anticipation** — a counter-move of 60–120ms before the main action. Ask for a dip before a pop: ```json {"role": "in", "preset": "custom", "durationMs": 520, "curves": [{"property": "scale", "keyframes": [ {"t": 0, "value": 1}, {"t": 0.2, "value": 0.94, "easing": "easeInOut"}, {"t": 0.75, "value": 1.06, "easing": "easeOut"}, {"t": 1, "value": 1}]}]} ``` **Follow-through** — attached parts settle 40–80ms after the body. Give the child clip the same preset and a `delayMs` 60ms later, rather than animating the whole group as one block. **Arcs** — straight-line translation of anything organic looks mechanical. Drive `offsetX` linearly and `offsetY` as a shallow curve over the same window; the two fold as `add`, so they compose. A decaying bounce, an arc, or a settle written as a function of time is a `code` body returning `samples` — one bag per point, `{t, offsetY}`, at `inputs.sampleCount` points. The body runs once at author time, hermetically, and reads `role`, `durationMs`, `clipDurationMs`, `canvasWidth`, `canvasHeight`, `params`, `staggerCount` and `sampleCount` off `inputs`: ```js // fragment const n = inputs.sampleCount; const h = 0.06 * inputs.canvasHeight; const samples = []; for (let i = 0; i < n; i++) { const t = i / (n - 1); // three decaying hops, ending exactly at rest const y = -h * Math.abs(Math.sin(Math.PI * 3 * t)) * (1 - t); samples.push({ t, offsetY: y }); } return { samples }; ``` Land the last sample on the identity value — 0 for an offset, 1 for a scale or an opacity — when the motion should settle. An `in` curve contributes nothing after its window, an `out` curve holds its last value after the window, and an `emphasis` curve returns to its base outside the window. ## Rhythm against audio Land impact keyframes on the beat. At 120 BPM a beat is 500ms and the eighth grid is 250ms. `beat-sync-editing` turns detected onsets into markers and snaps. For motion that should follow later tempo edits, store `beat` on the animation in the full timeline document. Its `index` is one-based, `scope` is `clip` or `sequence`, and `offsetMs` can place anticipation before the hit. `edit_timeline`'s `animate_clip` input does not author this field; use `get_timeline` and `set_timeline_document` as described in `motion-graphics`. ```json {"id":"impact","role":"emphasis","preset":"pulse","durationMs":240,"beat":{"index":3,"scope":"sequence","offsetMs":-20}} ``` A move ending 80ms after a hit reads late, and one ending 80ms before it can weaken the impact. Use `offsetMs` deliberately for anticipation. With no music, keep one major event per 500–800ms so the piece breathes at a constant pulse. ## Spring or duration `spring(stiffness,damping,mass)` shapes the motion inside the animation's `durationMs`; it is scaled to settle near the end of that window. Use it for a physical-looking landing, then set `durationMs` against the cut as you would for any other easing. For a precise held pose or hard timecode, write keyframes and put `hold` on the segment that should stay still. ## Frame texture Use per-clip `motionBlur` when a fast move strobes. Start with a 180-degree shutter and raise `samplesPerFrame` only as far as the frame needs; the highest requested sample count is shared by the scene. Use `steppedTime.fps` for a deliberate low-frame-rate look, or `temporalEcho` for delayed copies. These are stored clip fields written through `set_timeline_document`, not `animate_clip` parameters. ## Diagnosing | It feels | Because | Fix | |---|---|---| | Stiff, robotic | `linear` or a symmetric curve on an entrance | `easeOut`, or drop `easing` and take the role default | | Floaty, slow | Duration too long, curve too gentle | Cut 30%, sharpen to `cubic-bezier(0.16,1,0.3,1)` | | Cheap, janky | Everything arrives at once, one duration for every size | Stagger, and scale duration with area | | Mechanical despite easing | No anticipation, no overlap, straight-line paths | A `custom` dip before the move; 60ms offsets on attached parts | | Busy | Two primary moves in one frame | Demote one to a loop or hold it still | | Staggered text does not look staggered | Span exceeded the clip and was compressed | Lengthen the clip or lower `offsetMs` yourself | ## Quick reference | Need | Value | |---|---| | Entrance | `easeOut` / `cubic-bezier(0.16,1,0.3,1)`, 300–500ms | | Exit | `easeIn` / `cubic-bezier(0.7,0,0.84,0)`, 200–300ms | | Move in place | `easeInOut`, 300–400ms | | Playful landing | `easeOutBack`, 400–600ms | | Word stagger | 60–100ms, five words or fewer | | Clip-to-clip stagger | 60–80ms `delayMs` steps, whole reveal ≤ 700ms | | Continuous loop | `linear` when constant speed is intended | | Travel cap | `distance` ≤ 0.35 without a second channel |