Motion Design for the Timeline — Technical Design
Status: implemented (July 2026). Companion doc: motion-design-tasks.md (implementation plan, agent-consumable tasks).
Goal
Jitter-style motion design inside the existing timeline editor: clips animate —
slide in, pop, drift, loop — without the user placing keyframes. The authoring
unit is a named animation preset attached to a clip (fadeIn, slideIn,
kenBurns, …) with duration, delay, easing, and a few parameters. The primary
authoring surface is the agent: a user says “make the title pop in and
float”, the agent applies animations via ui_timeline_* tools, previews the
result frame-by-frame, and adjusts. Manual UI (inspector panel, clip badges) is
a thin layer over the same data.
Presets compile to an internal keyframe/curve form evaluated by one sampler, so a curve editor or per-keyframe tools can be added later without a schema break.
Implementation baseline
The timeline supports preset motion on visual clips, including authored text and shapes. The table below records the baseline that this design extended.
| Concern | Where it lives today |
|---|---|
| Data model | packages/timeline/src/types.ts — TimelineSequence, TimelineTrack, TimelineClip. Clips carry a static transform?: ClipTransform (position px from canvas center, scale multiplier on contain-fit, rotation radians, anchor normalized), opacity, effects[], transitionIn (crossfade), fadeInMs/fadeOutMs (audio-only, applied in web/src/components/timeline/preview/AudioGraph.ts). |
| Scene resolution | web/src/components/timeline/preview/sceneModel.ts — pure computeActiveLayersWithHorizon(tracks, clips, currentTimeMs) returns ActiveLayer[] plus nextChangeMs, the change horizon that lets the rAF loop skip recomputation between boundaries. |
| Live preview | web/src/components/timeline/preview/PreviewCompositor.tsx + preview/gpu/ (WebGPU with Canvas2D fallback; affine math in preview/gpu/transform.ts). |
| Export | web/src/components/timeline/render/TimelineRenderer.ts — steps in exact 1/fps increments, composites via the same scene model and GPU compositor, encodes MP4 via WebCodecs (mediabunny). Server ffmpeg path (packages/video-nodes/src/nodes/timeline.ts) is an explicit rough cut and stays one. |
| Edit ops | packages/timeline/src/splitClip.ts, trimClip.ts (pure); Zustand web/src/stores/timeline/TimelineStore.ts (patchClip(clipId, patch)), temporal undo/redo. |
| Persistence | TimelineDocument JSON blob in timeline-sequences (packages/models/src/timeline-sequence.ts); zod wire schema packages/protocol/src/api-schemas/timeline.ts. Fields absent from the zod schema are stripped on PATCH. |
| Agent surface | web/src/lib/tools/builtin/timeline.ts (13 ui_timeline_* tools) → TimelineAgentHandler interface in web/src/components/timeline/timelineAgentBridge.ts, implemented by web/src/hooks/timeline/useTimelineAgentBridge.ts. ui_timeline_get_clip_frames already lets the agent see rendered frames. |
Data model
New module: packages/timeline/src/animation/ (pure, no DOM/GPU/store access),
re-exported from packages/timeline/src/index.ts.
// packages/timeline/src/animation/types.ts
export type AnimationRole = "in" | "out" | "emphasis" | "loop";
export type EasingId =
| "linear"
| "easeIn"
| "easeOut"
| "easeInOut" // cubic
| "easeOutBack" // overshoot (pop)
| "easeOutElastic"
| "easeOutBounce";
export interface ClipAnimation {
id: string; // crypto.randomUUID() at creation
role: AnimationRole;
/** An AnimationPresetId. Typed `string` on purpose: documents saved by a
* newer client may carry ids this build doesn't know — they parse fine and
* are skipped at compile time (forward compat). Tool inputs and the UI
* constrain to the catalog union. */
preset: string;
/** Animation length in ms. For "loop", the period of one cycle. */
durationMs: number;
/**
* "in" | "emphasis" | "loop": offset from clip start to window start.
* "out": offset from clip END back to window end (0 = ends exactly at clip end).
* Default 0.
*/
delayMs?: number;
easing?: EasingId; // default per preset
/** Default true. Disabled animations are kept but not evaluated. */
enabled?: boolean;
/** Preset-specific knobs; unknown keys ignored. See catalog. */
params?: Record<string, number | string | boolean>;
}
TimelineClip gains one optional field (after transitionIn):
/** Motion-design animations evaluated at render time. Evaluation is
* order-independent (the fold is commutative — see animation/sample.ts);
* array order is presentation order in the UI only. */
animations?: ClipAnimation[];
Compiled form (internal substrate)
Presets never render directly; they compile to curves. This is the extension point for a future curve editor and for baked custom motion.
// packages/timeline/src/animation/compile.ts
export type AnimatedProperty =
| "offsetX"
| "offsetY" // canvas px, resolved from normalized preset units
| "scale" // uniform multiplier on ClipTransform.scale
| "rotation" // radians, added to ClipTransform.rotation
| "opacity" // multiplier on the layer's resolved opacity
| "wipeProgress" // 0 hidden → 1 revealed (drives CompiledAnimation.mask)
| "blur" // source px, added to the layer's blur radius
| "brightness" // -1..1, added to the color grade's brightness term
| "saturation"; // 0..4, multiplies the color grade's saturation
export interface Keyframe {
t: number;
value: number;
easing?: EasingId;
}
// t normalized 0..1 within the animation window; keyframes sorted by t;
// first t === 0, last t === 1.
export interface PropertyCurve {
property: AnimatedProperty;
keyframes: Keyframe[];
}
export interface CompiledAnimation {
role: AnimationRole;
windowStartMs: number; // clip-local, resolved from role + delay + duration
windowEndMs: number;
loop: boolean; // repeat the window until clip end. Set by the
// preset's compile, not derived from role alone:
// most "loop"-role presets compile loop:true, but
// kenBurns compiles loop:false (one-shot full clip)
/** Hold curve endpoint values outside the window (in: hold t=0 before window;
* out: hold t=1 after window). false → identity outside the window. */
holdBefore: boolean; // true for "in"
holdAfter: boolean; // true for "out"
curves: PropertyCurve[];
}
/** Pure. canvas = sequence {width, height}; clipDurationMs for window math. */
export function compileClipAnimations(
animations: ClipAnimation[],
clipDurationMs: number,
canvas: { width: number; height: number }
): CompiledAnimation[];
Preset position units are normalized (fraction of canvas width/height) inside
the catalog; compileClipAnimations resolves them to px so the sampler and GPU
stay unit-free.
Sampler
// packages/timeline/src/animation/sample.ts
export interface AnimationSample {
offsetX: number; // px, add to transform.position.x
offsetY: number;
scale: number; // multiply transform.scale.x and .y
rotation: number; // radians, add to transform.rotation
opacity: number; // 0..1, multiply layer opacity
}
export const IDENTITY_SAMPLE: AnimationSample; // {0, 0, 1, 0, 1}
/** Pure. localMs = currentTimeMs - clip.startMs. */
export function sampleAnimations(
compiled: CompiledAnimation[],
localMs: number
): AnimationSample;
Composition rules (deterministic, order-independent across roles):
- Evaluate every compiled animation independently, then fold: offsets and
rotation add, scale and opacity multiply. After the fold, clamp the
resulting opacity to [0,1] — overshoot easings (
easeOutBack,easeOutElastic) may push individual samples past 1. Scale is clamped to ≥ 0. - Within a window: map
localMsto normalizedt, find the bracketing keyframes, apply the segment’s easing (the easing stored on the right keyframe of the segment;ClipAnimation.easingoverrides every segment), lerp. "in"before its window: hold thet=0value — a delayedfadeInkeeps the clip invisible during the delay. After the window: identity."out"after its window: hold thet=1value (clip stays out). Before: identity."emphasis"outside its window: identity."loop": identity beforewindowStartMs; from there,t = ((localMs - start) % durationMs) / durationMsuntil clip end. Loop curves must start and end at the same value to avoid pops (catalog invariant, asserted in tests).- Speed (
speedMultiplier) does not rescale animation time: animations run on the timeline clock, not the source clock. (Matches how fades and crossfades behave today.)
Preset catalog v1
packages/timeline/src/animation/presets.ts. Each entry: id, allowed roles,
default duration/easing, params with defaults, and a curve generator
(params, canvas) => PropertyCurve[]. AnimationPresetId is the union of ids.
| Preset | Roles | Params (defaults) | Motion |
|---|---|---|---|
fade |
in, out | — | opacity 0↔1 |
slide |
in, out | direction: "left"\|"right"\|"up"\|"down" (“left”), distance (0.3 = fraction of canvas) |
offset from/to direction, opacity 0↔1 |
pop |
in, out | overshoot (1.08) |
scale 0.6↔1 with easeOutBack, opacity 0↔1 |
spin |
in, out | turns (0.25) |
rotation ±turns·2π, opacity 0↔1 |
wipe |
in, out | direction: "left"\|"right"\|"up"\|"down" (“left”; the edge the reveal starts from — “up” = top, “down” = bottom), softness (0.05 = feather width as fraction of the wipe axis, 0 = hard edge) |
directional mask reveal. Added after v1 with per-layer compositor masking: the preset compiles a wipeProgress curve (0 = hidden, 1 = revealed), direction/softness ride on CompiledAnimation.mask, and AnimationSample.mask carries the resolved mask to both compositors. Overlapping wipes fold by keeping the smaller progress (more hidden wins). The mask lives in the layer’s own quad space, so the wipe edge rotates with the clip |
blur |
in, out | amount (12 = blur radius in source px, 0..40) |
blur amount→0 with an opacity fade (rack focus); reversed for out |
colorFade |
in, out | — | saturation 0→1 (grayscale → full color); reversed for out |
pulse |
emphasis | intensity (0.06) |
scale 1→1+i→1 |
flash |
emphasis | intensity (0.6, 0..1) |
brightness 0→+i→0 (brief exposure spike) |
shake |
emphasis | intensity (0.02), cycles (4) |
offsetX zig-zag, fraction of canvas width |
bounce |
emphasis | height (0.05) |
offsetY dip with easeOutBounce |
kenBurns |
loop | zoom (0.12), direction (“in”), driftX/driftY (0.02) |
slow scale 1→1+z (or reverse) + drift. Compiles loop:false, window = full clip, holdAfter:true — it runs once over the whole clip, so durationMs and delayMs are ignored and the monotonic curve is exempt from the loop start==end invariant |
float |
loop | amplitude (0.015), periodMs via durationMs |
offsetY sine bob (piecewise-cubic approximation over ≥8 keyframes) |
breathe |
loop | intensity (0.03) |
scale sine |
rotate |
loop | rpm via durationMs |
rotation 0→2π per cycle |
Defaults: in/out 500 ms, emphasis 600 ms, loop 3000 ms. in defaults
to easeOut, out to easeIn, others per table.
blur, flash, and colorFade animate GPU effect parameters. They drive
blur/brightness/saturation curves that the sampler folds like the effect
pre-pass aggregates (blur and brightness add, saturation multiplies). At the
render site resolveAnimatedLayerProps composes them with the clip’s static
effects: animated blur adds to the static blur radius, animated brightness
adds to the grade’s brightness term, animated saturation multiplies the grade’s
saturation — emitted as synthesized ClipEffects so both compositors reuse the
existing color-grade / Gaussian-blur pipeline, keeping preview/export parity.
Rendering integration
The layer set stays cacheable; animated properties are resolved per frame.
computeActiveLayersWithHorizon and its change-horizon contract are untouched
— animation windows never add or remove layers, so nextChangeMs stays
correct for set membership.
New pure helper (in sceneModel.ts, since it is shared preview/export
vocabulary):
export interface AnimatedLayerProps {
transform?: ClipTransform;
opacity: number;
}
/** Compose a layer's static transform/opacity with its animation sample at t.
* Returns the inputs unchanged when the clip has no enabled animations. */
export function resolveAnimatedLayerProps(
layer: ActiveLayer,
currentTimeMs: number,
canvas: { width: number; height: number },
cache?: AnimationCompileCache // keyed by clip id + animations ref
): AnimatedLayerProps;
Call sites (the only two, keeping 1:1 preview/export parity):
- Live preview —
PreviewCompositor.tsx, where eachActiveLayer’stransform/opacityis handed to the GPU layer list. While any active layer has an enabled animation whose window (or loop tail) covers the current time, the compositor must redraw every rAF tick even when paused- idle short-circuits would otherwise skip drawing — exposehasActiveAnimation(layers, timeMs)next to the resolver for that check. - Export —
render/TimelineRenderer.ts, same resolver per stepped frame. No other change: WebCodecs encode path is agnostic.
Composition into ClipTransform (identity when clip has none:
position {0,0}, scale {1,1}, rotation 0, anchor {0.5,0.5}):
position.x += sample.offsetX scale.x *= sample.scale
position.y += sample.offsetY scale.y *= sample.scale
rotation += sample.rotation opacity *= sample.opacity
sample.opacity multiplies the already-resolved layer opacity (base ×
crossfade), so animations compose with transitionIn rather than fighting it.
Caption layers (kind: "caption") get the same treatment — they carry the
clip’s transform already.
Compilation is memoized per clip (WeakMap on the animations array ref, or a
Map<clipId, {ref, compiled}>) — the per-frame cost is sampling only:
a handful of lerps per animated layer, no allocation in the steady state
(sampler writes into a scratch AnimationSample).
Persistence
TimelineDocumentis a JSON blob — no DB migration. Old documents lackanimations; every consumer treatsundefinedas “no animations”.- Required: add
animationsto the clip zod schema inpackages/protocol/src/api-schemas/timeline.ts(see the existingstoryboardBoardIdcomment intypes.ts: fields missing from the schema are stripped by PATCH). Use a permissivez.array(clipAnimation)withpreset: z.string()(not the enum) so older servers don’t reject documents from newer clients; unknown presets are skipped at compile time with a console warning. - Autosave/undo need no changes:
patchClipflows through the temporal middleware like any clip field.
Editing semantics
Implemented where the pure ops live (packages/timeline/src/):
splitClip: left half keepsrole: "in"animations, right half keeps"out";"emphasis"and"loop"are copied to both halves (loop windows re-derive naturally from each half’s duration). Ids on the right half are regenerated.trimClip/ duration changes: windows are clamped at sample time (compileClipAnimationsclampswindowEndMstoclipDurationMs, and drops a window whose start ≥ clip duration), so trims need no data rewrite. When in+out overlap because the clip got too short, both still evaluate; fold rules keep the result continuous.duplicateClip: copiesanimationswith fresh ids.- Linked clips / speed / bake: no interaction — animations live on the timeline clock.
Agent surface
Extend TimelineAgentHandler
(web/src/components/timeline/timelineAgentBridge.ts, implemented in
web/src/hooks/timeline/useTimelineAgentBridge.ts) and register three tools in
web/src/lib/tools/builtin/timeline.ts:
ui_timeline_animate_clip—{ target, animations: ClipAnimationInput[], mode?: "add" | "replace" }(replacedefault;addappends). Each input:{ role, preset, durationMs?, delayMs?, easing?, params? }— ids and defaults filled in by the handler. Returns the updated clip including resolved animations. On validation errors (unknown preset, role not allowed for the preset) the handler throws with a message listing the valid options — the tool layer surfaces handler throws back to the agent, same as every existingui_timeline_*tool.ui_timeline_clear_animations—{ target, role? }.ui_timeline_list_animation_presets— no args; returns the catalog (id, roles, params with defaults and ranges, one-line description, default duration/easing). This is the runtime vocabulary discovery; tool descriptions carry a compact summary so simple requests skip the extra call.
Also: include animations in getSnapshot() clip entries so
ui_timeline_get_state shows current motion.
The critique loop needs no new tools: ui_timeline_get_clip_frames +
ui_timeline_seek already exist. Tool descriptions should state the intended
loop: get state → animate → get frames at window boundaries → adjust.
Manual UI (thin, after the agent surface)
- Inspector: an “Animate” section in
web/src/components/timeline/Inspector/(pattern:ClipAdjustments.tsx, primitives fromInspectorPrimitives.tsx). Per role: preset select, duration, delay, easing, preset params.ui_primitivesonly;SPACING/TYPOGRAPHY/MOTIONtokens (seedocs/DESIGN.md). - Clip chips: in
web/src/components/timeline/Tracks/Clip.tsx, small in/out zone markers (left/right wedge spanning the window width at current zoom) and a loop glyph — read-only affordance, click selects the clip and opens the inspector section.
Phase 2 — text and shape clips
Jitter is mostly motion typography; this design animates whatever a clip can draw, so text arrives as a new clip kind, not an animation change:
mediaType: "text"onTimelineClip+textStyle?: ClipTextStyle(content, font family/size/weight, fill, alignment, max width fraction).- Rendered as a rasterized layer: reuse the caption rasterization approach
(
preview/captionRender.ts) — draw to an offscreen canvas at sequence resolution × device scale, hand the bitmap to the compositor as an image source keyed by a style hash. Animation then applies for free via the sameresolveAnimatedLayerProps. - Agent tool
ui_timeline_add_text_clip. - Shapes (rect/ellipse/line) follow the same rasterize-to-layer pattern later.
Non-goals
- Lottie/GIF export — MP4 via the existing WebCodecs path only.
- Curve editor UI / dope sheet — the compiled-curve substrate exists for it, but no UI in this effort.
- Server-side render parity — the ffmpeg rough cut ignores animations, exactly as it ignores effects and transitions today.
- Per-property keyframe authoring by agents — presets + params only until real demand appears.
Testing
packages/timeline/src/animation/__tests__/(Vitest): easings (endpoint + monotonicity), window math for every role incl. delays and clamping, sampler fold rules, loop wraparound and start==end invariant, split/trim semantics, compile determinism.web/src/components/timeline/preview/__tests__/(Jest):resolveAnimatedLayerPropscomposition against hand-computed frames;hasActiveAnimationhorizon behavior; export/preview parity by sampling both paths at the same t.- Visual: one debug-harness / Playwright pass rendering a seeded sequence with
each preset at t = window midpoints, snapshot-compared (existing
web/tests/visual patterns).
Risks
- Skipped redraws during playback: the preview skips redraws while the
cached layer set is valid (
nextChangeMshorizon). With animations, any tick where the playhead moved must redraw even though the set is cached, or motion freezes mid-clip. A paused playhead needs only one draw — samples are pure functions of time. Caught by the T8 visual test and manual check, not by unit tests. - Schema strip: forgetting the protocol zod field silently loses animations on save. Caught by a round-trip test in the protocol package.
- Perf: per-frame sampling is O(active animated layers × curves); with memoized compilation this is negligible next to video decode. Do not compile in the rAF loop.