Skip to content

Add motion/: one folder per animation asset, discovered like the boards - #13

Open
Jing-yilin wants to merge 24 commits into
mainfrom
worktree-remotion-skill
Open

Jing-yilin wants to merge 24 commits into
mainfrom
worktree-remotion-skill

Conversation

@Jing-yilin

@Jing-yilin Jing-yilin commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Adds motion/, the video half of what canvas/ already does for the artboards: drop a folder into motion/src/<bucket>/<slug>/ and it becomes a Remotion composition named after the folder, shows up in the studio, and — once rendered — plays on the canvas. No registry, no build step, nothing to keep in step with the filesystem.

Ships with sixteen templates — spatial-gallery, plus fifteen taken off a single brand film, one motion effect each — plus Remotion's official agent skills vendored under .agents/.

Why motion/ sits where it does

remotion.config.ts points the public dir at ../mockups, so a composition reaches a board with staticFile("canvases/luma-ios/01-guest-top.html") and a photo with staticFile("canvases/apple-photos/assets/photos/01-minerva-1.jpg"). Motion consumes mockups, which is why it is a sibling of mockups/ rather than a folder inside it — the same relationship canvas/ has.

The bit worth reviewing: meta.json

Two bundlers have to agree on one composition's box. rspack builds the compositions; Vite builds the canvas and needs that box to size the preview. JSON is the one format both parse, so meta.json is a sidecar rather than a field in the TSX:

export { default as meta } from "./meta.json";   // rspack, via Root.tsx
import.meta.glob("../../motion/src/*/*/meta.json", { eager: true })   // Vite, via motionLibrary.ts

Discovery in Root.tsx uses require.context — that is rspack's API. import.meta.glob is Vite-only and does not exist in that bundler.

On the canvas

Rendered mp4s land on a Motion page (?canvas=motion), each looping at 478pt wide — the artboards' own column pitch, so a video lines up with the boards it was made from. server.fs.allow opens the repo root because ?url assets outside canvas/ are served, unlike the boards' ?raw HTML which is inlined at transform time.

The mp4 in out/ is committed (2.1 MB): it is the only way to see an asset without running the project, it is what the canvas plays, and it is what makes a diff reviewable.

tools/motionkit.py

The motion half of refkit.py, applying the repo's measurement rule to time. probe, flow, sheet, compare, selftest — ffmpeg piped straight into numpy, no temp files.

flow is the one that earns its keep. spatial-gallery looks like a slow camera move and is in fact two momentum flicks with a dead hold between them, which is a thing you measure, not a thing you notice.

Verification

  • npx remotion compositions lists spatial-gallery 30 1080x864 120 from folder structure alone
  • motionkit probe prints meta.json byte-identical to the committed file
  • motionkit flow on our own render: peak 84.8 px/frame at f9, total 1706×570, axis 18.5° — against the reference's ~80 at f8–12, 1630×582, 19.7°
  • motionkit selftest passes (phase correlation recovers known shifts exactly)
  • canvas lint / test / build clean; motion tsc clean

The measurements taken off the third-party reference clip are committed; the clip itself is not — the same split .gitignore already makes for ref-*.html boards.

The fifteen

One 68-second film taken apart into the fifteen things it actually does:

ground mesh-gradient
type count-up, word-cascade, text-marker, word-swap, word-grid
spheres orb-bloom, bokeh-orbit, particle-form, depth-flythrough
camera card-stack, focus-pull, lens-reveal
chrome pill-expand, logo-outro

They exist to be cut together, and that is the whole reason for the interface: every one takes durationInFrames as a prop rather than reading it from useVideoConfig. Inside a <Sequence>, useVideoConfig().durationInFrames still reports the composition's length, so a template that trusts it stretches wrong the moment it is placed in a cut. useDuration(override?) takes the prop and falls back to the composition, which keeps each one scrubbable on its own in the studio. Every one is also fully settled well before its own last frame, so two can butt against each other with nothing to hide the seam.

src/lib/ holds what more than one of them needs — the palette with the frame each colour came off, the two faces, the timing helpers, the mesh ground, the orb. It sits outside Root.tsx's require.context on purpose, so a shared module never becomes an empty entry in the studio sidebar.

Three things that cost real time

  • radial-gradient(circle 55% at ...) is invalid CSS — a circle's radius may not be a percentage — and one invalid layer silently drops the whole background shorthand. The mesh painted no blobs at all, with no error anywhere, until this became ellipse R% R%.
  • A bare width/height does not survive being a flex item next to something wide: it shrinks on the cross axis and the sphere comes out an egg. The flex: none guard lives in Orb.tsx, not at the one call site that showed the bug.
  • Unpinned, loadFont() fires 126 requests for Inter at the head of every render. fonts.ts pins weights and subset.

All three are now comments where they bit.

What is not reproduced

The motion — timings, easings, geometry, palette, type scale. Not the film's wordmark and not its ad copy: templates carry this repo's own strings and logo-outro sets the mark to "Motion", which is a rights line and also the point, since a template with someone else's copy baked in is a screenshot. The clip stays out of the repo, as .gitignore already requires.

src/lib/README.md carries the shared provenance and the commands to reproduce any number in it. Each template's own README carries its measurements, its props, and its deviations.

More motionkit

  • swatch <clip> <frame> --grid WxH area-averages a frame into cells — the shape of a gradient, as hex
  • swatch <clip> <frame> --crop W:H:X:Y censuses one region at full resolution — the exact hex of a chip
  • sheet --from/--to cuts a contact sheet down to one shot, which is how "the gradient moves" became forty frames you can fit a curve to

Both are how every number above was read.

Then measured again, and two of them were the wrong effect

Everything above was reviewed once against contact sheets. A second pass —
render all fifteen, tile them, and go back to the clip for anything that
looks off rather than tuning by eye — found that looking once is not enough.

word-swap was replicating an effect the film does not contain. It
crossfaded two words past each other, blurred and lifting, over a line held
still: a plausible thing to build from memory. Sampled at one frame per tile,
f213–f228 does something else entirely — a gap opens between two words over
six frames, the word changes on one frame with no blur and no dissolve,
and a sphere fades up into the space that was just made for it. The hard cut
is the effect, and it is rewritten around that. The template that blurs
words past each other is word-cascade; this one is deliberately not that.

depth-flythrough scattered its orbs. Each got a random x and y inside a
wander box — the obvious way to build "a crowd of spheres", and it piled
them into a blob with no depth in it. In f1480–f1545 the orbs sit on one
straight line
passing to the left of the camera, converging on x 0.56 /
y 0.50, each 0.63 the size of the one in front. wander and its seed are
gone; vanish and offset replace them, and screen x now runs through the
same perspective divide as the size, which is what turns a handful of circles
into a corridor.

Four more numbers that were guesses rather than measurements:

was is why
orb-bloom to 1.7 2.05 the 16:9 diagonal is 2.04 frame heights, so anything smaller leaves the corners showing
bokeh-orbit count 22 48 the sparseness was the count, not the radius — the reference ring is a dense overlapping chain running off three edges
particle-form rim 0.62 0.35 0.62 rendered a hollow ring
depth-flythrough bubble padding px * k em it was px * k inside a box whose type already scaled by k, so it scaled twice and the near bubble came out all padding

orb-bloom's chips now also leave over the last ten frames, which is where
the film cuts, so the template has an end state and not just a hold — the
whole point of the set being composable.

Eleven README reference ranges were wrong by 50–400 frames. They are corrected
against the clip, and each README now carries the motionkit sheet command
that reproduces its own sheet, so the next reader can check the claim instead
of taking it.

Each of the two rewrites is written up in its own README's Deviations,
mistake first. A template whose numbers came from memory looks exactly like
one whose numbers came from a measurement, right up until someone re-derives
them; saying which is which is the only thing that makes the difference legible.

Verification, this pass

  • npx tsc --noEmit clean; all sixteen compositions render via ./render.sh (5.6 MB of CRF-28 previews total)
  • re-rendering spatial-gallery produced a byte-identical mp4, which is the determinism rule holding
  • stills for all fifteen reviewed against reference contact sheets; seven needed correction and were corrected

The films bucket, and what assembling the set found

src/films/brand-film/ is the fifteen templates cut back into one 43.2 s
piece, in the source film's own shot order. It is a shot list and nothing else
— no animation, easing, colour or copy — and it overrides exactly one prop
per shot, durationInFrames. That is the composability claim the whole set was
built to make, exercised for the first time: inside a <Sequence>,
useVideoConfig().durationInFrames still reports the film's 1296 frames, so a
template that read its length from there would time itself against the film
while occupying 84 frames of it. Nothing dissolves anywhere in the cut; every
template opens settled and settles again before its own last frame.

Then the reason to assemble it. Comparing the cut against the source shot by
shot showed twelve of the fifteen templates had been authored 1.3x to 2.4x
too small
, and every one of them looked right on its own. A template scrubbed
alone has no scale — it fills its own frame whatever size its contents are.
word-cascade 0.19 → 0.247, bokeh-orbit 0.13 → 0.3, orb-bloom 0.045 →
0.09, focus-pull 0.13 → 0.29, logo-outro 0.062 → 0.107, card-stack and
pill-expand whole boxes at ×1.35, and so on; each traced to a named frame.

Three of the corrections were not sizes: orb-bloom's chips are upright (the
italic belongs to word-grid), its bullets are flat pale discs rather than lit
orbs, and card-stack's cards are one key light washing across the row instead
of eight differently coloured cards.

motionkit extent

The measurement is committed rather than left in a scratchpad, because twelve
prop tables now cite it. It subtracts a wide Gaussian from a frame and boxes
what survives, so type and hard chrome register while gradients and bokeh do
not; at the same --width, a 2880-wide reference and a 1920-wide render give
directly comparable fractions of frame.

python3 tools/motionkit.py extent 7481_0.mp4 32
python3 tools/motionkit.py extent src/films/brand-film/out/brand-film.mp4 135
python3 tools/motionkit.py extent 7481_0.mp4 295 --band 0.15,0.35,0.85,0.65

compare cannot answer this question — it wants a frame-aligned pair, and
43.2 s against 68.4 s in a different shot order is not one. selftest covers
extent with a bar of ink on a full-frame gradient: it must find the bar and
not the ramp, within the halo that subtracting a blur necessarily leaves.

src/films/brand-film/README.md carries the cut table, the per-shot ratio
table above, and the honest list of what is deliberately not matched — the
reference's live footage and product UI (most of the missing 25 s), the
wordmark and ad copy, Instrument Serif's missing weight, and our lens-free
render.

🤖 Generated with Claude Code

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant