Motion Design — Implementation Plan
Companion to motion-design.md (the technical design).
Every task below is written to be executed by an agent with no prior
conversation context. Each task’s first step is: read
docs/plans/motion-design.md in full — it is the contract; this file adds
sequencing, file-level pointers, and acceptance criteria.
Ground rules for every task:
- Repo root is a TypeScript monorepo; run
nvm use && npm installifnode_modulesis missing (sandboxed environments:npm install --ignore-scripts, seeAGENTS.md§ Install in sandboxed environments). - After changes:
npm run typecheck && npm run lint, plus the package tests named in the task.npm run dev:nodetool -- affectedmaps changed files to the minimal workspaces to rebuild/test. packages/timelineis pure TypeScript (no DOM, no GPU, no store imports) — keep it that way.- Web UI: primitives from
web/src/components/ui_primitives/only, design tokens perdocs/DESIGN.md. Never import raw MUI. - Commit per task with a descriptive message; do not reformat unrelated code.
Model key (suggested per task):
- Opus 4.8 (
claude-opus-4-8) — design-sensitive core, rendering internals. - Sonnet 5 (
claude-sonnet-5) — well-specified implementation and integration. - Haiku 4.5 (
claude-haiku-4-5-20251001) — mechanical plumbing with exact specs.
Dependency graph:
M1: T1 ──► T2 (schema) T1 ──► T3 (render) T1 ──► T4 (edit ops)
T3 + T4 ──► T5 (agent tools)
M2: T3 + T4 ──► T6 (inspector) T3 ──► T7 (chips) T3 + T5 ──► T8 (verify)
M3: T3 ──► T9 (text clips) ──► T10 (text agent + UI, also needs T5, T6)
Milestone 1 — Animation engine and integration
T1 — Animation core in packages/timeline · Opus 4.8
Goal: the pure animation engine: types, easings, preset catalog, compiler, sampler, and edit-op semantics hooks. No web code.
Read first: docs/plans/motion-design.md §§ Data model, Preset catalog,
Editing semantics. Existing style references: packages/timeline/src/types.ts
(doc-comment conventions), packages/timeline/src/splitClip.ts (pure-op
shape).
Create packages/timeline/src/animation/ with:
types.ts—AnimationRole,EasingId,ClipAnimation,AnimationPresetIdexactly as specified in the design doc.easing.ts—ease(id: EasingId, t: number): number, each easing pure,t ∈ [0,1] → [0,~1](back/elastic may overshoot; clamp opacity at the composition site, not here).presets.ts— the v1 catalog table from the design doc (fade,slide,pop,spin,pulse,shake,bounce,kenBurns,float,breathe,rotate), each entry{ id, roles, defaultDurationMs, defaultEasing, params: {name, default, min?, max?, options?}[], describe: string, curves(params, canvas): PropertyCurve[] }. ExportANIMATION_PRESETSandgetAnimationPreset(id).compile.ts—compileClipAnimations(animations, clipDurationMs, canvas)→CompiledAnimation[](window math per role incl.delayMs, clamping to clip duration, dropping degenerate windows, resolving normalized preset distances to px).kenBurnscompiles as a full-clip one-shot withholdAfter: true, not a repeating loop.sample.ts—IDENTITY_SAMPLE,sampleAnimations(compiled, localMs)implementing the fold rules (add offsets/rotation, multiply scale/opacity; hold-before forin, hold-after forout, modulo forloop). Also exporthasActiveAnimationWindow(compiled, localMs): boolean.index.tsbarrel; re-export everything frompackages/timeline/src/index.ts.
Modify:
packages/timeline/src/types.ts— addanimations?: ClipAnimation[]toTimelineClip(aftertransitionIn), doc comment included.packages/timeline/src/splitClip.ts— left half keepsin, right keepsout,emphasis/loopcopied to both, right-half ids regenerated. Follow how the file already handlescaption/paragraphIdfor split-field precedent.packages/timeline/src/defaults.ts— no new factory needed; verifymakeClipoutput stays valid with the field absent.
Tests (packages/timeline/src/animation/__tests__/, Vitest — mirror the
package’s existing test setup; if the package has no __tests__ yet, add the
standard Vitest config used by sibling packages):
- easings: endpoints 0→0, 1→1; easeOut monotonic; back overshoots.
- window math per role, incl.
outmeasured from clip end, delays, clamping when duration > clip length. - sampler: identity outside windows, hold semantics, fold of
in+loopconcurrently, loop wraparound continuity (start==end value for every compiled animation withloop: true), delayedfade-in holds opacity 0 during delay, folded opacity clamped to [0,1] undereaseOutBack. - split semantics as above.
- determinism: same inputs → deeply equal compiled output.
Acceptance: npm run test --workspace=packages/timeline green;
npm run typecheck && npm run lint green; no imports from web/ or DOM types.
T2 — Protocol schema + persistence round-trip · Haiku 4.5
Goal: animations survives save/load; older documents load unchanged.
Depends on: T1 merged (types exist).
Context: TimelineDocument persists as a JSON blob (no DB migration), but
the wire schema strips unknown fields on PATCH — see the comment on
storyboardBoardId in packages/timeline/src/types.ts.
Modify:
packages/protocol/src/api-schemas/timeline.ts— add to the clip schema:animations: z.array(clipAnimationSchema).optional()whereclipAnimationSchema = z.object({ id: z.string(), role: z.enum(["in","out","emphasis","loop"]), preset: z.string(), durationMs: z.number(), delayMs: z.number().optional(), easing: z.string().optional(), enabled: z.boolean().optional(), params: z.record(z.union([z.number(), z.string(), z.boolean()])).optional() }).preset/easingare plain strings on the wire by design (forward compat); the compiler skips unknown presets.- Check
web/src/hooks/timeline/timelineDocumentPayload.tsandweb/src/hooks/timeline/useLoadTimelineIntoStore.ts: if they pass clips through wholesale, no change; if they enumerate fields, addanimations.
Tests: protocol package round-trip test — a clip with two animations
parses through the zod schema unchanged; a clip without the field parses; an
animation with an unknown preset string parses (validation is not the
schema’s job).
Acceptance: npm run test --workspace=packages/protocol green; save →
reload in the web store preserves animations (covered by the payload check
above); typecheck/lint green.
T3 — Preview + export rendering integration · Opus 4.8
Goal: animated transform/opacity visible in the live preview and identical in the MP4 export.
Depends on: T1.
Read first: design doc § Rendering integration;
web/src/components/timeline/preview/sceneModel.ts (whole file — note the
change-horizon contract on computeActiveLayersWithHorizon, which must NOT
change);
web/src/components/timeline/preview/PreviewCompositor.tsx;
web/src/components/timeline/render/TimelineRenderer.ts;
web/src/components/timeline/preview/gpu/transform.ts (position = canvas px
from center, scale multiplies contain-fit, rotation radians).
Create in sceneModel.ts (keep it pure):
resolveAnimatedLayerProps(layer, currentTimeMs, canvas, cache)→{ transform?: ClipTransform; opacity: number }per the design doc’s composition table. Identity fast-path: no enabled animations → return the layer’s existing values without allocating.hasActiveAnimation(layers, currentTimeMs, cache): boolean.- An
AnimationCompileCache(Map keyed by clip id, invalidated when theanimationsarray reference changes) so compilation never runs in the rAF loop.
Wire up:
PreviewCompositor.tsx: where eachActiveLayer’stransform/opacityfeed the GPU layer list, route throughresolveAnimatedLayerProps. Then fix the redraw condition: the compositor skips redraws while the cached layer set is valid (nextChangeMshorizon) and nothing else changed. With animations, any tick where the playhead time changed andhasActiveAnimation(...)is true must redraw even though the layer set is cached — otherwise motion freezes mid-clip. A paused playhead needs only the one draw after the last seek: samples are pure functions of time, so a static frame stays correct.render/TimelineRenderer.ts: apply the same resolver at each stepped frame. The cache can live for the whole render.- Caption layers get the same resolution (they already carry
transform).
Tests (Jest, web/src/components/timeline/preview/__tests__/):
- composition: static transform
{position:{x:100,y:0}}+slidein from left at window midpoint → hand-computed expected transform/opacity. - parity: for a seeded clip set,
resolveAnimatedLayerPropsoutput at t = {window start, mid, end, past-end} is identical when called the way the compositor calls it and the way the renderer calls it. - horizon:
nextChangeMsfromcomputeActiveLayersWithHorizonis identical with and without animations on a clip — animations must never shrink the horizon; callers keep reusing cached layers and re-resolve properties per frame instead.
Acceptance: cd web && npm test -- --testPathPattern=preview green; manual
check via the debug harness or cd web && npm start optional; typecheck/lint
green. Do not modify computeActiveLayersWithHorizon’s signature or horizon
semantics.
T4 — Store patch action + trim/duplicate semantics · Sonnet 5
Goal: clip animation edits flow through the Zustand store with undo/redo, and duplicate/trim behave per the design doc.
Depends on: T1.
Read first: design doc § Editing semantics;
web/src/stores/timeline/TimelineStore.ts (find patchClip(clipId, patch)
around the store actions; note the temporal undo middleware and pure-reducer
style); packages/timeline/src/trimClip.ts.
Do:
- Add a store action
setClipAnimations(clipId, animations: ClipAnimation[])(thin wrapper overpatchClipis fine ifpatchClipalready handles arbitrary fields — verify it does not shallow-merge arrays incorrectly). duplicateClippath (find the existing duplicate logic — store oruseTimelineAgentBridge.ts): copyanimationswith freshids (crypto.randomUUID()).- Trim: confirm no data rewrite is needed (compile-time clamping from T1
covers it) and add a comment where
trimClipis called noting that. - Verify split flows through the T1
splitClipchange (store calls the pure op — confirm, don’t reimplement).
Tests: store-level Jest test: set animations → undo → redo; split a clip with in+out+loop and assert the halves’ animation roles; duplicate regenerates ids.
Acceptance: cd web && npm test -- --testPathPattern=timeline green;
typecheck/lint green.
T5 — Agent tools · Sonnet 5
Goal: an agent can discover the motion vocabulary, apply animations, and inspect them — the primary authoring surface.
Depends on: T1, T3 (so results are visible), T4 (store action).
Read first: design doc § Agent surface;
web/src/lib/tools/builtin/timeline.ts (register pattern, targetParam,
error convention);
web/src/components/timeline/timelineAgentBridge.ts (TimelineAgentHandler);
web/src/hooks/timeline/useTimelineAgentBridge.ts (handler implementation —
see setClipParams at ~line 377 for the patch pattern).
Do:
- Extend
TimelineAgentHandlerwithsetClipAnimations(target, animations: ClipAnimationInput[], mode: "add" | "replace")andclearClipAnimations(target, role?); implement inuseTimelineAgentBridge.ts(resolve target by id/name/”selected” like the existing methods; fill defaults from the preset catalog; validate preset/role and throw descriptive errors that include the valid options). - Register in
web/src/lib/tools/builtin/timeline.ts:ui_timeline_animate_clip— zod params{ target, mode: z.enum(["add","replace"]).optional(), animations: z.array(z.object({ role, preset, durationMs?, delayMs?, easing?, params? })) }. Description must include the compact vocabulary summary (preset names by role, that durations are ms, and the recommended loop: get_state → animate → get_clip_frames at window boundaries → adjust).ui_timeline_clear_animations—{ target, role? }.ui_timeline_list_animation_presets— no params; returnsANIMATION_PRESETSmapped to{ id, roles, params, defaults, describe }.
- Include
animationsingetSnapshot()clip entries (find the snapshot builder inuseTimelineAgentBridge.ts).
Tests: Jest tests beside existing frontend-tool tests (find the pattern
under web/src/lib/tools/): registering is side-effectful on import, so test
through FrontendToolRegistry.call with a stubbed handler — animate applies
defaults, an unknown preset produces an error whose message lists the valid
preset ids, clear with role filter keeps other roles.
Acceptance: cd web && npm test -- --testPathPattern=tools green;
typecheck/lint green; tool count in the manifest grows by 3.
Milestone 2 — Manual UI + verification
T6 — Inspector “Animate” section · Sonnet 5
Depends on: T3, T4.
Read first: design doc § Manual UI;
web/src/components/timeline/Inspector/ClipAdjustments.tsx (section pattern,
fold behavior via usePersistedFold.ts),
InspectorPrimitives.tsx (labeled rows, sliders, selects),
web/src/components/ui_primitives/STRATEGY.md, docs/DESIGN.md.
Do: an “Animate” section in the clip inspector: list current animations
grouped by role; add-animation flow (role → preset filtered by
roles → defaults applied); per-animation controls: duration (ms), delay,
easing select, preset params (number params as sliders with catalog min/max,
option params as selects); enable toggle; delete. All edits go through the T4 store
action (single patchClip per change so undo granularity is per-edit).
Constraints: ui_primitives only; SPACING/TYPOGRAPHY/MOTION tokens;
no hardcoded px/transition strings; selection comes from the existing
inspector wiring (TimelineInspector.tsx).
Tests: RTL: renders animations from a stub clip; adding a pop in-animation
dispatches the expected store patch; slider change patches params.
Acceptance: cd web && npm test -- --testPathPattern=Inspector green;
typecheck/lint green.
T7 — Clip animation chips in track lanes · Haiku 4.5
Depends on: T3.
Read first: web/src/components/timeline/Tracks/Clip.tsx (how fade
handles / status badges are drawn at clip edges, zoom-dependent widths).
Do: read-only affordances on clips that have animations: a left wedge
spanning the in window’s width at current zoom, a right wedge for out, a
small loop glyph (⟳ icon from the project’s icon set) when a
loop/emphasis animation exists. Clicking them selects the clip (existing
click path). Keep it cheap: pure derivation from clip.animations, no new
store state; hide wedges below a minimum px width (~6 px) to avoid clutter at
low zoom.
Acceptance: visual check in cd web && npm start; no new lint/type
errors; existing Tracks tests still green.
T8 — End-to-end verification pass · Sonnet 5
Depends on: T3, T5.
Goal: prove the loop the feature exists for: an agent animates a clip and the export contains the motion.
Do:
- Add a seeded visual test to the existing web visual/debug-harness suite
(
web/tests/, seeyts806379-everything-claude-code-e2e-testingpatterns in repo tests): build a two-clip sequence in code (image clip +slidein +kenBurnsloop; second clippopin with delay), render frames at fixed timestamps through the real compositor, snapshot-compare. - Export parity: run
renderTimelinefor the same sequence at a low fps (e.g. 5) for 3 s and assert frame N’s decoded pixels match the preview compositor’s output within tolerance (reuse whatever the existing export tests do — locate them underweb/src/components/timeline/render/). - Update
docs/video-editor.md(anddocs/timeline-editor-prd.mdif it lists capabilities) with a short “Animations” section; followdocs/WRITING_STYLE.md.
Acceptance: new tests green in cd web && npm test; docs updated; full
npm run check green at repo root.
Milestone 3 — Text clips (phase 2, separately mergeable)
T9 — text clip type + rasterized rendering · Opus 4.8
Depends on: T3.
Read first: design doc § Phase 2;
web/src/components/timeline/preview/captionRender.ts (existing text
rasterization — reuse its font/scale handling);
web/src/components/timeline/preview/gpu/source.ts (how bitmaps become GPU
sources); packages/timeline/src/types.ts.
Do:
packages/timeline: extendTimelineClip.mediaTypeunion with"text"; addtextStyle?: ClipTextStyle({ text: string; fontFamily?: string; fontSizePx: number; fontWeight?: number; color: string; align?: "left"|"center"|"right"; maxWidthFrac?: number }); protocol schema field (same pattern as T2);makeClipsupport.- Scene model: a text clip contributes a layer with
kind: "image"semantics but sourced from a rasterized bitmap — introducekind: "text"onActiveLayerand teach both compositors (gpu/compositor.ts,gpu/canvas2dCompositor.ts) and the renderer to draw it: rasterizetextStyleto an offscreen canvas at sequence resolution × devicePixelRatio, cache by hash oftextStyle, upload like any image source. - Text clips are
sourceType: "imported"withstatus: "generated"-equivalent drawability — pick the minimal status handling that makeseffectiveAssetId-gated paths treat text as always drawable (they have no asset id; adjustcomputeActiveLayers’ caption-only guard accordingly). - Animations apply unchanged via
resolveAnimatedLayerProps.
Tests: scene-model unit tests (text layer present, no asset id required); rasterizer cache hit test; visual snapshot of a styled text frame.
Acceptance: a text clip with pop in-animation renders in preview and
export; npm run check green.
T10 — Text clip authoring: agent tool + inspector · Sonnet 5
Depends on: T9, T5, T6.
Do:
- Handler + tool
ui_timeline_add_text_clip—{ text, trackId?, startMs?, durationMs?, style? }, defaulting onto an overlay track (create one if none, same fallback logic asgenerateClip’s track selection inuseTimelineAgentBridge.ts), default duration 3000 ms. Extendui_timeline_set_clip_params(or the snapshot + atextStylepatch onsetClipParams) so the agent can restyle existing text clips. - Inspector: text section (content textarea, font size, weight, color, align)
for
mediaType === "text"clips, same primitives/tokens rules as T6. AddClipMenu.tsx: manual “Text” entry.
Tests: tool test (adds clip on overlay track with defaults); RTL inspector
test (edit patches textStyle).
Acceptance: agent flow works end-to-end: ui_timeline_add_text_clip →
ui_timeline_animate_clip (pop in, float loop) → ui_timeline_get_clip_frames
shows animated text; npm run check green.
Sequencing summary
| Order | Task | Model | Parallelizable with |
|---|---|---|---|
| 1 | T1 animation core | Opus 4.8 | — |
| 2 | T2 protocol schema | Haiku 4.5 | T3, T4 |
| 2 | T3 render integration | Opus 4.8 | T2, T4 |
| 2 | T4 store + edit ops | Sonnet 5 | T2, T3 |
| 3 | T5 agent tools | Sonnet 5 | — |
| 4 | T6 inspector | Sonnet 5 | T7 |
| 4 | T7 clip chips | Haiku 4.5 | T6 |
| 5 | T8 verification | Sonnet 5 | — |
| 6 | T9 text clips | Opus 4.8 | — |
| 7 | T10 text authoring | Sonnet 5 | — |
Ship gate for Milestone 1+2: T8 green. Text (M3) is separately mergeable.