name: motion-from-reference description: >- Reverse-engineer UI animation from a reference video (e.g. another app's screen recording) and reimplement it faithfully — ffmpeg frame extraction and montages, zero-dependency pixel measurement, spring/stagger parameter derivation, then re-record and verify with the same pipeline. Use when asked to "match this animation", "make ours feel like theirs", or to diagnose why an implementation feels flatter than a reference video.
Motion from reference
Turn "make our animation feel like this video" from a taste argument into a measurement problem. The pipeline: probe → extract → locate → zoom → measure → derive parameters → implement → re-record → verify with the same pipeline.
Run the reference video AND a recording of the current implementation through the same analysis. The delta list that falls out is the implementation spec ("icons: no travel vs scale→0.55"; "rows: one block vs 25ms/row cascade").
Beat accounting — the discipline that keeps the feel. Write the reference's choreography as a numbered beat inventory (recoil → launch → cascade → settle, each with its measurements), and give every beat an explicit land / adapt / drop decision in the summary. Do not let architecture friction silently demote a measured beat to "close enough" — a beat quietly adapted away is exactly the one the user will report as "theirs still feels better". The user's named callouts are a floor, not the spec: the measurements routinely surface beats they felt but didn't name.
1. Probe & extract
ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate,duration -of default=noprint_wrappers=1 ref.mov
mkdir -p frames && ffmpeg -v error -i ref.mov -vsync 0 frames/f%04d.png
-vsync 0keeps every stored frame. iPhone screen recordings are usually constant 60fps; simulator/desk captures are often VFR — frames land only when the screen changes, but each animation is still a full 60fps burst. Map frame → time withffprobe -show_entries frame=pts_time -of csv=p=0; the gaps in the pts list locate every transition without watching anything.- Off-by-one trap:
select=between(n,a,b)uses 0-indexed decode order;f%04d.pngfiles are 1-indexed. Tile k of a montage overnot(mod(n,S))is filef(S*(k-1)+1).
When inheriting an old motion-matching pass, inventory the archive before
measuring: look for the raw reference video, extracted reference frames, derived
evidence strips, measurement scripts, and the current-app recording. If only
montage PNGs remain, say so explicitly; they are good for rough visual checks,
but they are not a substitute for rerunnable pixel measurements. When creating a
new archive, keep either ref.mov or the extracted frames/ directory, or add a
short manifest/README entry that names the external source and exact extraction
geometry. Future runs should not have to rediscover whether missing frames are an
oversight or an intentional large-file omission.
2. Locate transitions with contact sheets, then zoom
# whole-video overview (every 6th frame)
ffmpeg -v error -i ref.mov -vf "select='not(mod(n,6))',scale=220:-1,tile=13x2" -vsync 0 sheet.png
# dense per-transition montage — CROP to the animating region first; it buys 3-4x readable zoom
ffmpeg -v error -pattern_type glob -i 'frames/f0*.png' \
-vf "select='between(n,5,40)',crop=1030:1500:60:110,scale=235:-1,tile=6x6:margin=4:padding=4:color=red" \
-vsync 0 -frames:v 1 expand.png
- Red padding grid makes tile boundaries unambiguous when reading as an agent.
- zsh gotcha:
set -- $vardoesn't word-split in zsh — write montage commands per-range instead of looping over "name lo hi" strings. - The screen-recording touch indicator (gray circle) shows up as a bright blob: don't mistake it for UI, and do use it — it timestamps the tap, so "motion starts N frames after touch-up" is readable directly.
3. Measure, don't eyeball
No PIL/numpy needed — decode raw grayscale via ffmpeg and scan bytes in pure
python (buf[y*W + x]):
out = subprocess.run(["ffmpeg","-v","error","-i",f"frames/f{n:04d}.png",
"-f","rawvideo","-pix_fmt","gray","-"],capture_output=True).stdout
Three probes cover most UI motion; run each across the frame range and print a table:
- Edge scan — first pixel above a brightness threshold down a column → container top edge per frame → the growth/shrink curve, overshoot, settle. An edge curve that reverses direction or jumps discontinuously mid-motion is usually tracking two layers crossfading (a dissolving outgoing surface, then the incoming one) — zoom the raw frames before trusting it; the crossfade itself is a finding (e.g. pill and card are separate layers, not one morphing shape).
- Bright-cluster centroids — threshold a band, merge x-clusters, weighted
centroids → per-icon travel. Test centroids against a uniform-scale
prediction (
c + (x−c)·s): if one scale factor fits all elements, it's a group scale around a center, not individual translations. (This is exactly how "icons converge inward" resolves toscale: 1→0.55about the pill center rather than N hand-tuned offsets.) - Box-mean brightness over time per label/row → fade onset order and ramp. Judge stagger from onset, not the full ramp: if content rides a springing container, fixed sample boxes get contaminated during overshoot (values dip and re-rise as content slides past) — that contamination is itself evidence the content is anchored to the moving edge.
4. Derive parameters
- Damping ratio from overshoot fraction
os(peak past target / total travel):ζ = ln(1/os) / sqrt(π² + ln²(1/os))— e.g. 5.5% → ζ≈0.68. - Stagger = median onset gap between adjacent elements (frames × frame time); fade duration = onset→90% of one element; base delay = motion start → first element onset.
- Check asymmetry explicitly: expand and collapse are usually different (springy-with-overshoot out, faster critically-damped back; staggered in, one-layer dissolve out). Measure both directions before assuming mirror.
5. Implement — watch the interactive paths
Time-based measurements map cleanly onto enter animations (delay + duration per element), but a morph that is also gesture-scrubbed can't hang everything off wall-clock: keep the scrub path driven by progress interpolation, run the measured stagger only on the discrete open, and guard the stagger against scrubbing (skip it when progress is already past ~0.15 at trigger time, else drag-release blanks visible rows and blinks). Never window per-element fades on progress values near 1.0 — spring overshoot makes progress non-monotonic there and elements flicker.
Anticipation (recoil-then-launch) must be staged in time, not remapped in
progress. Duration-based springs are extremely front-loaded — measured
springs (Linear's and Reanimated's alike) cover ~1/3 of travel in the first
two frames — so a progress-window hold both evaporates in ~10ms of wall-clock
and consumes exactly the explosive frames the recoil was meant to set up.
Instead: run the recoil as its own short timing (~80ms scale dip + content
dissolve) and start the main spring with withDelay(~65ms, withSpring(...)) —
the launch then keeps the spring's full initial velocity, which is the whole
feel. Verify with frames: recoil beat = height flat while content dissolves,
then ~2x growth within one frame of launch.
6. Verify with the same pipeline
Re-record the implementation, re-run the extraction/montage, and build a phase-aligned side-by-side (align by phase — rest / dissolve / growth / cascade / settled — not by timestamp):
ffmpeg -y -i row_ref.png -i row_ours.png -filter_complex "[0]pad=iw:ih+8:0:0:red[a];[a][1]vstack" compare.png
The strip is the deliverable that ends the "does it match?" conversation.
Platform capture mechanics (drivers, VFR sim recording, dark/light toggles)
live with the platform skill — for the Clankie app see clankie-rn-sim
(references/sim-tap-drivers.md, "Animation / motion evidence").
