Communitygithub.com

yazzang-homelab/motion-studio

Make motion feel physical with closed-form springs from lib/motion.js: presets, springFromFeel, track() for multi-target values, loopTrack loops, indicators, text swaps. Use for 'feels linear', 'add bounce', 'easing to springs'.

What is motion-studio?

motion-studio is a Claude Code agent skill that make motion feel physical with closed-form springs from lib/motion.js: presets, springFromFeel, track() for multi-target values, loopTrack loops, indicators, text swaps. Use for 'feels linear', 'add bounce', 'easing to springs'.

Works with✓Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/yazzang-homelab/motion-studio/tree/HEAD/skills/springs

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

Springs (course step 08)

Cheap motion eases from A to B on a fixed curve. Motion with mass accelerates, overshoots a hair and settles. The springs in lib/motion.js are closed-form step responses, so they stay a pure function of time: frame 812 renders without simulating frames 0 to 811, and seek(t) stays deterministic.

Import from the film project's lib/motion.js (paths relative to the file: ../lib/motion.js from film/film.js, ../../lib/motion.js from film/scenes/*.js). Full preset numbers live in ${CLAUDE_SKILL_DIR}/references/presets.md (references/presets.md).

1. API

FunctionReturnsNotes
sp(t, preset = 'default')0 to 1the everyday call; t is time since release (lt - 0.2), 0 before release
spring(t, k, d, m = 1) / springVel(...) / springPV(...)x / v / [x, v]raw k/d form; exact under-, critically and overdamped
SPRINGS{ snappy, default, heavy, playful }{k, d} pairs, see the table below
resolveSpring(p){k, d}accepts a preset name, {k, d} or {duration, bounce} everywhere a spring is expected
springFromFeel({ duration, bounce }) / feelFromSpring({k, d}){k, d} / {duration, bounce}designer-friendly parameters (Apple convention)
track(t, keys, spring = 'default') / trackVel(...)value / velocitykeys = [[time, value], ...] sorted; one spring per change, summed; an optional third element overrides the spring for that change ([2.4, 420, 'heavy'])
loopTrack(t, keys, dur, spring = 'default', cycles = null)valueseamless loops. Key times are within one cycle, 0..dur, and are not wrapped (outside throws). Last value must equal the first. A key exactly at dur is the seam change
indicator(t, stops, { width = 120, lead = 'snappy', trail = {k: 140, d: 22} }){ left, right }tab indicator that stretches: edges on different springs
swapAlpha(t, tIn, tOut, { inDelay = 0.08, inDur = 0.12, outLead = 0.1, outDur = 0.1 })0 to 1text inside a morphing box: in after the morph starts, out before the next
stagger(i, step = 0.03)secondsdelay for item i
stepTime(t, rate = 12)stepped t"on twos" for drawings only, never camera or global fades. Pass c.frameLt (or c.frameT), the output frame's time, for a hard cut between steps; c.t / lt are subframe times and blend the two steps on the frames where a step changes
loopT(t, dur)t wrapped into [0, dur)time wrap; not enough on its own for springs (use loopTrack)
settleTime(spring, eps = 0.01) / overshoot(spring)seconds / fractionplan holds and cut points
easeOutCubic, easeInOutCubic0 to 1only for non-physical fades (opacity); never for things that move

2. Presets

Presetk / dDamping ratioOvershootSettles (1%)Use for
snappy320 / 300.8390.79%0.24 sbuttons, toggles, leading edges, cursor
default170 / 260.99700.51 scards, containers, camera
heavy90 / 201.05400.78 sbig type, 3D objects, logo lockups
playful220 / 140.47218.6%0.59 smascots, stickers

House rule: tiny overshoot on UI (snappy), none on type (default or heavy for scale and position of hero text), playful only where the overshoot is the joke. Full numbers, feel conversions and formulas: references/presets.md.

3. Recipes

Inside a scene draw(g, lt, c): lt is the time since the scene started, c.t the film time, c.L the layout, c.brand the brand tokens, c.grid the beat grid, c.film.dur the film length (c.dur is the scene length). With motion blur on, c.t and lt are the subframe time being painted (motion blurs by itself); c.frameT / c.frameLt are the output frame's time and c.frame its index, identical for every subframe. The snippets assume const { t, L } = c; and imports from lib/motion.js, lib/draw.js and lib/rng.js.

One-shot entrance (release 0.15 s into the scene):

const s = sp(lt - 0.15, 'snappy');
withTransform(g, { x: L.cx, y: lerp(L.cy + 80 * L.u, L.cy, s), s: lerp(0.92, 1, s) }, () => { /* draw */ });

This one starts visible: position and scale are at their start values while the spring is 0, so frame 0 is not empty. It would be empty if s drove the only thing on screen through opacity or a clip. For a scene that is on screen at frame 0 (the hook), open mid-action by releasing the spring BEFORE the scene: sp(lt + 0.3, 'snappy'), or kinetic(g, 'MAKE IT MOVE.', x, y, lt + 0.3, { ... }), or kinetic(..., { delay: -0.3 }). At lt = 0 the type is then 0.3 s into its rise instead of not drawn (kinetic skips every glyph whose spring is still at 0). The critique flags an empty frame 0 as a P0 (frame 0 is nearly empty).

A value with several targets (never restart a spring; add one per change):

// cursor x in logical px: rests at 200, moves at 1.0 s and 2.4 s, returns at 3.6 s
const cx = track(t, [[0, 200 * L.u], [1.0, 640 * L.u], [2.4, 420 * L.u], [3.6, 200 * L.u]], 'snappy');

The first key's time is ignored: its value is the rest value from the start. Every change after that starts its own spring at its own time, and the sum stays continuous in position and velocity even when a new target arrives before the last one settled.

One-shape morph (container never cuts; every property is a track on the same key times):

const K = [0, 1, 2, 3].map((n) => c.grid.bar(n));       // state changes on downbeats, from the grid
const w = track(t, [[K[0], 320], [K[1], 120], [K[2], 760], [K[3], 320]].map(([k, v]) => [k, v * L.u]));
const h = track(t, [[K[0], 96], [K[1], 120], [K[2], 480], [K[3], 96]].map(([k, v]) => [k, v * L.u]));
const r = track(t, [[K[0], 48], [K[1], 60], [K[2], 32], [K[3], 48]].map(([k, v]) => [k, v * L.u]));
fillRoundRect(g, L.cx - w / 2, L.cy - h / 2, w, h, Math.max(0, r), c.brand.colors.fg);
withAlpha(g, swapAlpha(t, K[1], K[2]), () => ring(g, L.cx, L.cy, 36 * L.u, sp(t - K[1] - 0.1), { color: c.brand.colors.accent }));

Colors: spring a 0-to-1 progress and mix with mixColor(a, b, p) from lib/draw.js (it clamps p), or, for a color with several targets, track each channel and clamp to 0 to 255. Radius and sizes that can overshoot below zero need Math.max(0, ...).

Tab indicator that stretches between stops:

const { left, right } = indicator(t, [[0, x0], [1.5, x1], [3.0, x2]], { width: 140 * L.u });
fillRoundRect(g, left, y, right - left, 6 * L.u, 3 * L.u, c.brand.colors.accent);

Seamless loop (the value and its velocity match across the seam):

// 6 s loop: a at rest, b at 1.5 s, c2 at 3 s, back to a at 4.5 s
const x = loopTrack(t, [[0, a], [1.5, b], [3, c2], [4.5, a]], c.film.dur, 'default');
// the same loop with the return as the seam change: the last key sits exactly at dur
const y = loopTrack(t, [[0, a], [1.5, b], [3, c2], [c.film.dur, a]], c.film.dur, 'default');

Rules of loopTrack (checked against lib/motion.js):

RuleDetail
key times stay in 0..durthey are cycle-relative and never wrapped; a time outside throws loopTrack: key <i> time <t> is outside 0..<dur> (a hair past dur from grid arithmetic is tolerated)
the first key is the rest valueits time is ignored (it must still lie in 0..dur); the value is what the first change starts from
the last value equals the firstotherwise it throws the last value (...) must equal the first (...)
a key at dur is the seam changethe spring starts at t = 0 of the next cycle: frame 0 (= frame dur) shows the pre-seam value and eases to the first value. It equals writing [[0, pre], [0, post], ...]. Write the seam change once, at 0 or at dur
a change just before dur may still be moving at frame 0the sum is exactly periodic, so position and velocity match across the seam whatever the timing. To open on a still frame, keep the last change at least settleTime(preset) before dur (0.51 s for default)
cyclesthe number of summed previous cycles follows the settle time and the latest key, so late keys are safe; cycles overrides it

Plain loopT plus track jumps at the seam because the springs released near the end are still moving at t = 0. loopTrack also sums the springs from previous cycles (enough of them to settle) and gives, with keys [[0, 0], [2, 100], [4, 40], [6, 0]] and dur 6, the values 0.001 / 99.997 / 40.002 at t = 1 / 3 / 5. Check the seam in out/review/<fmt>/loop.png after npm run critique: |last - first| should look like one normal step and |end - first| (the unwrapped end state) should be black.

Staggered pops (per-element seeds, never one shared stream):

for (let i = 0; i < n; i++) {
  const r = rngFor('tile', i);
  const s = sp(lt - stagger(i, 0.035) - range(r, 0, 0.06), 'playful');
  // draw tile i scaled by s
}

Kinetic type: kinetic(g, str, x, y, lt, { size, family, weight, color, spring: 'snappy', stagger: 0.035, rise: 0.6, by: 'char' | 'word', mask, delay, alpha, align, baseline, style, tracking }). family defaults to brand.fonts.display and weight to the nearest weight the family has registered, so leave both out unless the design needs another face. mask: true clips the run to its line box so glyphs rise from behind an edge instead of fading; delay shifts the whole run (negative opens mid-action). For hero type, pass spring: 'default' or 'heavy' when the rise is large enough for 0.8% overshoot to show. Multi-line copy: wrapText(g, text, maxW, font(size, family)) returns the lines (never cutting inside a Hangul syllable); draw them with y + i * 1.2 * size or more and give each line its own delay.

Drawings on twos (hand-drawn look): const td = stepTime(c.frameLt, 12) for the drawing's own animation (the output frame's time, so each step is a hard cut even with motion blur on); keep camera moves, fades and UI on continuous lt, or pans step 12 times a second and read as lag.

4. Refactor a whole film

Use the prompt template in the project: read prompts/refactor-springs.txt and follow it (or paste it as a task). It replaces every linear interpolation and stock easing that moves something with presets, track(), loopTrack(), indicator() and swapAlpha(), keeps the timing, and reports each change. Then:

  1. npm run lint (must be clean).
  2. npm run stills and node tools/critique.mjs --strip-at <t> at the busiest moment: strip.png shows 12 consecutive frames, which exposes pops, restarts and slides that a contact sheet hides.
  3. Log the change in the next critique round (/motion-studio:critique-loop).

5. Mistakes to catch

MistakeWhat it looks likeFix
restarting a spring at each target (sp(t - tNew) from the old value)velocity snaps to 0, a visible kinktrack() with all targets
hand-rolled sums of springs with their own start valuesjumps when two changes overlapone track() per value; per-change presets go in the key's third element
loopT alone on a spring valuejump at the seamloopTrack() with keys in 0..dur and last value == first
overshoot on typetext wobbles, reads cheapdefault / heavy for type
playful on UI chrometoy-like interfacesnappy for UI
spring on opacity only while position is linearfades nicely, slides cheaplyspring the position; opacity can follow easeOutCubic
everything on the same preset and delaymechanical, no hierarchylead with snappy, follow with default, stagger() the rest

Next: /motion-studio:sound-design to lock the motion to the beat, then /motion-studio:critique-loop.

Individual skills in this repo

This repo contains 11 individual skills — each has its own dedicated page.

yazzang-homelab/motion-studio

Make Claude watch its own frames: critique.mjs evidence, harsh 7-axis scores, timestamped P0/P1/P2 problems in docs/review_log.md, fix the 3 worst, re-check, pass the gate. Use for 'critique it', 'review the frames', 'is it good'.

yazzang-homelab/motion-studio

Plan long-form or overnight films: director's brief (PLAN FIRST or GO, budget, definition of done), ANIMATION_GUIDE + STORYBOARD, chapter subagents, gates, chunked render. Use for 'overnight film', 'music video', '2-minute film'.

yazzang-homelab/motion-studio

Make a launch video, app or product reel, explainer or motion ad from code, end to end: brand assets from a URL, beat-grid shot list, synced sound, critique loop, all formats. Default for new video requests; not footage edits.

yazzang-homelab/motion-studio

Brand-asset stage of a product film (motion-reel runs the whole film): capture real screenshots, logo, colors and fonts from a URL, never redraw UI; brand story beats; voice + mascot. Use to grab or fix a brand's assets.

yazzang-homelab/motion-studio

Turn a reference (a frame, a video or an image library) into docs/style_guide.md and a beat-grid shot list: measure cuts, palette and pace, take grammar not content, wait for OK. Use when the user supplies or names a look.

yazzang-homelab/motion-studio

Build, debug or speed up the seek(t) render engine: render contract, canvas vs page capture, workers/subframes/chunks, determinism fixes, Remotion or HyperFrames handoff. Use for 'render is slow', 'frames differ', 'use Remotion'.

yazzang-homelab/motion-studio

Ship every format from one timeline, then package it: layout() reframing (never crop), render all formats, per-format checks, deliver.mjs, a brand skill, a service offer. Use for 'export 9:16 and 16:9', 'deliver', 'make a skill'.

yazzang-homelab/motion-studio

The viral one-line 'showreel for a résumé' prompt: why it works (anatomy), the credited quote, and seeded variants that avoid look-alike reels; runs it as an engine test. Use for that prompt, its variations, or a showreel.

yazzang-homelab/motion-studio

Score a film to its beat grid: measure a track (beats.mjs) or synthesize one (score.mjs), cue SFX from the film, add voice, mix to -14 LUFS / -1 dBTP at exact length. Use for 'add music', 'sync to the beat', 'SFX', 'loudness'.

yazzang-homelab/motion-studio

Scaffold a motion-studio film project (seek(t) renderer, springs, beat grid, synthesized sound, critique tools), install it, set up the browser, run doctor. Use for 'set up the motion studio', 'new film project', 'init studio'.

yazzang-homelab/motion-studio

Write a six-part XML state spec for a one-shape UI morph film (inputs, direction, structure, build, gotchas, start): states on downbeats, cursor-driven changes, seamless loop, approval gate. Use for 'UI morph', 'state list'.

Related Skills