motiscope

under the hood

A screenshot has no time axis.

That one sentence is the whole design. Everything motiscope does follows from it — and everything it refuses to do follows from it too.

Nothing here is a claim you have to take on faith. Every constant, filter string and figure on this page is read out of the code, and the repo ships ffmpeg-synthesised clips with known ground truth so you can check the maths yourself. The exact-numbers version lives in how-it-works.md.

The problem

why “AI, copy this animation” doesn’t work

A still frame shows you every shape, every colour and every position. It tells you nothing about time. Duration, easing, the gap between two beats, the period of a loop — none of it survives a screenshot. So a vision model can describe an animation it cannot reproduce, because reproduction needs the one axis a picture doesn’t have.

motiscope does exactly the one thing a model can’t, and refuses to do anything a model does better.

The numbers measure the WHEN.
Timing, easing curves, segment boundaries, stagger intervals, loop period. Dense, exact, and effectively free.
The frames carry the WHAT.
A handful of curated keyframes. The model looks and names the elements and the effect — with no fixed vocabulary.

Everything below is in service of that split.

Two artifacts, wildly different costs

this is why the analysis can afford to be dense

ArtifactSamplingCost
Motion-energy curve, motion grid, ffmpeg signalsnative fps (capped at 60, ≤1200 samples)zero image tokens — it’s arithmetic
Curated keyframes8–48 PNGs after dedup~300–400 image tokens each

Frame count tracks motion complexity, not video length. A 10-second clip typically yields about ten frames. The numeric pass can be as dense as physics allows, because it costs nothing to look.

Pass A — the motion-energy curve

one decode, 32×32 grayscale thumbnails, mean absolute difference

ffmpeg -i <video> -vf "fps=<native>,scale=32:32,format=gray" -f rawvideo -

Energy at frame i is the mean absolute difference against frame i−1. No pixels ever reach Python at full size. That series is the source of truth for everything temporal.

The ambient loop's motion-energy curve over 2.28 seconds, with a dashed hold threshold near the bottom and the peak marked at 1.09 seconds.
The ambient-loop example, real data: 137 samples at 60fps across 2.28s. Above the dashed line is motion; below it is a hold. Cost to produce: zero image tokens.

Localized energy — why small elements don’t vanish

A whole-frame average is the wrong statistic. One button sliding across a 1280×720 page moves a rounding error’s worth of pixels; average that over the frame and it reads as nothing happened.

So the frame is split into an 8×8 grid, and the signal is the mean of the most active 10% of cells — not of all of them. On the ambient loop, straight from its motion.json:

SignalMean energy
Whole-frame average1.763
Localized (top-K cells)6.998

A 4× amplification of exactly the thing you care about. This wasn’t theoretical: three cards animating on a 1280×720 canvas were being classified as hold before it existed.

The hold threshold, and why it isn’t a fraction of the peak

motion_ref = max(percentile_75(energy), 0.05)
threshold  = max(0.05, 0.18 × motion_ref)

The 75th percentile, not the maximum. A hard cut or a full-screen fade produces near-maximal energy. Anchor the threshold to the peak and one 0.27-second transition raises the bar above every real element animation in the clip — which then all report as hold.

This is a bug this project shipped and then fixed. On a landing-page walkthrough the peak-anchored threshold was 24.5 and the analysis found zero move segments. With a percentile-anchored reference the threshold fell to 1.55 and the animations appeared.

Pass B — ffmpeg signal filters

a second decode, five filters, parsed from stderr

FilterWhat it givesNote
scdethard cuts≈0 on single-shot UI animations — expected
blackdetect + signalstatsfades, brightness rampsproxy for global opacity
freezedetectwhole-frame stillnessconfirmation only
sitispatial / temporal information→ content profile

freezedetect is deliberately demoted. It measures whole-frame stillness, so on a large canvas it fires while a small element is mid-animation. It may confirm a hold the energy curve already found; it may never turn a move into a hold. Letting it do so was a bug.

Easing — the part people assume is impossible

you do not need to see the element to measure how it accelerated

Energy is mean pixel change per frame, which is a proxy for speed. Integrate speed and you get displacement. So:

The running integral of the energy curve, normalised, is the segment’s displacement-vs-time curve — which is exactly what a CSS timing function describes.

Normalise it, then match against a library of seven real curves and keep the closest by RMSE. The output is a genuine cubic-bezier, not a coarse label — usable directly as CSS cubic-bezier(...), a Framer array, or a GSAP CustomEase.

Displacement against time: the measured curve lies on top of the x-squared ground truth, while the fitted cubic-bezier runs slightly above both.
Read this one carefully, because it shows the strength and the limit.

Frame curation

the only step that costs money

Candidate timestamps are the union of t=0, t=end, every segment boundary, the energy-curve extrema (keyposes and peaks), and a sparse uniform backbone. Then each candidate is reduced to a grayscale thumbnail of DEDUP_THUMB = 16 squared, and dropped if it is within a mean-absolute-difference of DEDUP_THRESHOLD = 2.0 of the frame already kept. What survives is evenly sampled down to the preset’s budget.

PresetFramesResolutionFor
draft12512pxquick look, tight budget
balanced (default)32 (usually 8–20 after dedup)640pxmost cases
detailed48960pxdense sequences, reading on-screen text
landing441280pxweb walkthroughs — each section plus its motion

Auto-decompose

For clips of 8 seconds or more with two or more motion beats, the budget follows the motion instead of the clock. Every move segment is drilled densely; every hold gets one representative frame. Move segments are allocated frames in proportion to duration × peak_energy, so a fast, intense beat earns more frames than a slow one.

Transitions are capped at two frames (three if longer than half a second), because a fade is monotonic — its endpoints tell you everything. Without that cap, a single 0.27-second fade once consumed 33 of 48 frames, leaving the actual animations with nothing.

Loop detection

autocorrelation, with three guards against wishful thinking

The strongest autocorrelation lag above a minimum period is the candidate loop length. A loop is only reported if the clip is mostly moving (a static shot with one periodic blip is not a loop), the peak correlation is at least 0.7, and the second harmonic also correlates — a one-off burst does not repeat at 2×.

Two caveats stated up front. Energy is speed-based, so back-and-forth motion reports a period that is half the visual cycle; the caller is told, so recreation can choose repeat versus yoyo. And a constant-velocity loop — a uniform rotation — has flat energy and is intentionally not detected. There is no periodicity in the signal to find.

What motiscope deliberately does not do

the restraint is the feature

It does not classify animation types. There is no enum of fade | slide | scale. The model looks at the frames and names what it sees — a mask reveal, a path draw, a morph, a 3D flip, a text split, particles, a clip-path wipe. Any hand-coded taxonomy would be a ceiling on what can be recreated.

Two features were built and then removed for exactly that reason:

Measured vs estimated

the distinction is load-bearing — trust the first column

Measured (reliable)Estimated (from frames)Not recoverable
duration, fps, canvas sizewhich elements moveexact bezier control points
segment / beat boundariestransform magnitudes (px, scale, deg)sub-pixel & sub-frame motion
easing shape + fitted beziercolours under compressiontrue 3D and z-order
stagger intervalovershoot amountspring stiffness
loop periodspring vs bezierauthored Lottie vector data

One known artifact: a very gentle ease-in can read as a short leading hold, because sub-pixel motion is invisible in a 32×32 thumbnail. The dominant easing is still recovered — check the first frames.

Verify it yourself

the repo ships clips whose ground truth is known by construction

bash tests/make_test_clip.sh
python3 -m unittest tests.test_analyze_motion tests.test_integrations
ClipAuthored asmotiscope reports
test-ease.mp4position ∝ t²move · ease-in · cubic-bezier(0.42, 0, 1, 1)
test-linear.mp4constant velocitymove · linear
test-fade.mp4alpha ramp, then stillfade-in, then hold
test-hold.mp4move, then freezemove, then hold
test-loop.mp4looping overshootloop detected · back.out · cubic-bezier(0.34, 1.56, 0.64, 1)