Checking an animation by watching it is close to useless. The eye accepts almost anything at 30 frames a second, and the mistakes that matter are the ones you can only see by stopping: a wing that is on the wrong side of the body for two frames, a bird that lands and then slides four pixels.
So the Remotion project for Nakodo's reels has four compositions, and two of them are not videos. They are sheets of frames, built to be rendered as a single still and opened in a preview window.
Writing this post is also how I discovered that one of them does not work at all.
Lab: a one frame composition
<Composition id="Lab" component={Lab} durationInFrames={1} fps={FPS} width={W} height={H} /> A composition one frame long. It renders a 4 by 5 grid of our bird in twenty different poses, each one labelled:
const POSES: [string, Pose][] = [ ...Array.from({ length: 8 }, (_, f): [string, Pose] => [`beat ${f}/8 (${beat(f).toFixed(2)})`, { fly: 1, flap: beat(f) }]), ...[0.85, 0.7, 0.55, 0.45, 0.3, 0.15, 0.05, 0].map((fly): [string, Pose] => [`fly ${fly}`, { fly, flap: 0.7 }]), ["peck", { lean: -25, head: -38 }], ["envelope", { envelope: true }], ["binoculars", { binoculars: true }], ["blink", { blink: true }], ]; Row one and two: one complete wing beat, eight frames of it, side by side. Rows three and four: the landing, which is a single fly parameter running from 0.85 (in the air, wings out, legs tucked) to 0 (perched). Then the props.
The bird is drawn by one component with one pose object, so the sheet is not a set of illustrations, it is the actual drawing code called twenty times with different arguments. If a pose is broken it is broken here, in a still, where you can stare at it.
The label is the useful half. beat 3/8 (-0.94) tells you both which frame you are looking at and what the function returned for it, which is what you need when the fix is "the wing is 0.1 too far down at the top of the stroke".
That function, by the way, is the one place the asymmetry of a wing beat lives:
export const beat = (frame: number, period = 8) => { const t = (((frame / period) % 1) + 1) % 1; return t < 0.42 ? Math.cos((t / 0.42) * Math.PI) : -Math.cos(((t - 0.42) / 0.58) * Math.PI); }; Two half cosines of different lengths: the downstroke takes 42% of the beat and the recovery takes 58%. A single sine wave reads as a mechanical flap, and you can see that instantly on a sheet and not at all in motion.
Sheet: eighteen frames of a reel at once
export type SheetProps = { reel: "Reel" | "Gust" | "Guide"; from: number; every: number; crop?: [number, number, number] }; The second review composition takes a reel, a starting frame and a stride, and renders 18 frames of it in a 6 by 3 grid, each one labelled with its frame number in red:
<Freeze frame={frame}> <Shown /> </Freeze> Freeze is the whole trick. It renders a child component as though the current frame were a fixed number, so eighteen copies of the same reel, each frozen at a different moment, lay the animation out in space instead of time.
crop zooms every tile into one 9:16 region of the reel, given in the reel's own pixel coordinates:
const [cx, cy, cw] = crop; const k = tw / cw; <div style={{ transform: `scale(${k}) translate(${-cx}px, ${-cy}px)`, transformOrigin: "0 0" }}> That exists because the mistakes you cannot see in motion are usually small. A contact sheet of a whole 1080 by 1920 frame at thumbnail size tells you nothing about whether a bird's foot is landing on a button or three pixels above it.
Driving it is a twelve line shell script:
FROM=$1; EVERY=${2:-2} REEL=${REEL:-Reel} if [ -n "$5" ]; then CROP=",\"crop\":[$3,$4,$5]"; fi npx remotion still Sheet "$OUT" --frame=0 \ --props="{\"reel\":\"$REEL\",\"from\":$FROM,\"every\":$EVERY$CROP}" --scale=0.5 --log=error remotion still with --props is the part worth remembering if you use Remotion: a composition's props are a command line argument, which means a review view can be parameterised from a shell script without a UI, a dev server or a single click.
And it has never worked
While writing the paragraph above I ran the script to get a sheet to look at:
$ sh scripts/sheet.sh 600 2 Error: Could not find composition with ID Sheet. Available compositions: Reel, Gust, Guide, Lab Sheet is imported at the top of Root.tsx and never passed to a <Composition>. The component is written, the script that renders it is written, the file comment explains how long the composition has to be and why, and the one line that registers it does not exist.
The comment is the giveaway, now that I read it again:
It is as long as the longest reel, because a frozen frame can't pass the composition's end.
That sentence describes a durationInFrames that was never typed.
Why nothing caught it
An unused import is the single most catchable mistake in JavaScript, and two separate tools should have had it.
// eslint.config.mjs // The Remotion reels are their own project with their own packages. "reels/**", That is an ignore entry. The reels folder is a separate pnpm workspace with its own React and its own Remotion, and rather than give it a second lint config it was excluded from the first. So no no-unused-vars.
And its tsconfig.json is a short one without noUnusedLocals, so tsc --noEmit passes cleanly, which I have just confirmed it does.
The lesson is not "turn on the flag", though that is the obvious next commit. It is that the directory you excluded from your checks is exactly where this class of bug will accumulate, and the amount of time between committing a broken dev tool and finding out can be as long as you like, because nobody else runs it.
Why a product has review tools at all
The reels are adverts. They open on a tree, or a gust of leaves, or a field guide, and they end on a card with the same promise as the top of the home page and the same three steps as how it works. They are the first thing a lot of people will see of the product, which is a strange amount of weight to put on 26 seconds of hand-written motion.
Hand-written motion needs a way to be reviewed that is not "watch it again". Both of these compositions are about forty lines and neither of them ships in anything. One of them renders a sheet I can stare at. The other is a good idea with no <Composition> under it, which is a fair description of how review tooling usually dies: not refused, just never finished, in the one directory nothing checks.