Nakodo has a vertical reel, 1080 by 1920, 30fps, drawn entirely from the app's own React illustration components and with every sound synthesised by a script in the repository. It is one Remotion composition: a kingfisher called Alcy lands in a tree, pecks the branch until platform icons rain down, the icons pour into a waterfall, the waterfall fills the screen with a blue page, the camera glides down five steps, and the water drains away to a call to action.
Until this week there was a sixth screen. Between the waterfall and the first step the reel held a headline for 112 frames, nearly four seconds, with the middle word on a slot machine drum that spun through "creators" and "businesses" and landed on "partners".
I deleted it. This post is about what that cost, which was not the 175 lines.
What went
The scene was a drum, not a crossfade:
// The words sit round a drum, 70 degrees apart, so they squash as they turn // out of the window, and only the one in front shows when it stops. const STEP = (70 * Math.PI) / 180; const DRUM = 140; const WINDOW = { top: 850, height: 184, middle: 75 }; Three spins with a windup, an overshoot of 0.2 of a row and a five frame settle, the row position integrated from an easing function so it could be sampled half a frame either side to get the speed, and the speed turned into vertical motion blur:
const speed = Math.abs(reelRow(t + 0.5) - reelRow(t - 0.5)) * DRUM * STEP; const blur = Math.min(36, speed * 0.3); // Its own id per frame, so frames shown side by side don't share one blur. const id = `reel-blur-${Math.round(t * 100)}`; I was fond of it. It also spent four of the reel's twenty-six seconds restating the sentence that is already the first thing on nakodo.app: "Find partners who say yes." A reel has no time to say anything twice, and those frames were worth more spent on the five steps, which now hold a quarter longer each.
reels/package.json | 1 + 1 - reels/src/Reel.tsx | 11 + 10 - reels/src/scenes/cta.tsx | 2 + 2 - reels/src/scenes/opening.tsx | 6 + 3 - reels/src/scenes/story.tsx | 26 + 175 - reels/src/sfx.tsx | 6 + 4 - The deletion was free because the timeline is arithmetic
No frame number in this reel is typed in twice. The step timings are derived:
// How long each step holds once the camera has arrived [...] // The camera takes 16 frames to move down to each step. const HOLD = [36, 56, 52, 56, 56]; export const MOVES = HOLD.reduce<number[]>((m, _, i) => [...m, i === 0 ? FILL_TO : m[i - 1] + 16 + HOLD[i - 1]], []); export const ARRIVE = MOVES.map((m) => m + 16); export const DRAIN_FROM = ARRIVE[4] + HOLD[4]; The only line that changed there is the seed of the reduce. It used to be HEADLINE + 112, the end of the headline's hold; it is now FILL_TO, the frame the water finishes filling, so the camera sets off for the first step as the page arrives rather than after a sentence. Everything downstream, five camera moves, five perches, five sound cues, the drain and the call to action, renumbered itself.
The screens moved up too, by one:
const stepTop = (i: number) => i * H; It was (i + 1) * H, because the headline was screen zero.
That is the whole argument for deriving animation timings instead of writing them: a four second scene came out of the middle of a timeline and nothing had to be hand-renumbered.
Then the second half got slower
Cutting four seconds and keeping the pacing would have given a shorter reel. What I wanted was the same length with the steps breathing, so everything after the waterfall plays at 80% of the speed it is authored at:
// Everything after the waterfall plays at 80% of the speed it's timed at: // story frames keep pace with the reel's frames up to FILL_TO, then run // slower, so the steps and the call to action last a quarter longer. const PACE = 0.8; /** The story frame at reel frame f. */ export const storyFrame = (f: number) => (f < FILL_TO ? f : FILL_TO + (f - FILL_TO) * PACE); /** The reel frame a story frame falls on. */ export const reelFrame = (s: number) => Math.round(s < FILL_TO ? s : FILL_TO + (s - FILL_TO) / PACE); Two piecewise functions, identity before frame 150, a constant stretch after it.
Why not slow the whole composition, or drop the frame rate? Because the first 150 frames are timed to impacts. Alcy's bill hits the branch on an exact frame and a knock plays on that frame. Slowed to 80%, pecking looks like somebody mining. The warp has to start where the sound stops being percussive, which is the frame the water covers him.
So there are now two clocks, and the only interesting design question is which code knows about them.
Only the boundaries convert
The answer: scenes keep their own clock, the one their keyframes were authored in. Every conversion happens where a scene meets the composition.
The composition:
export const REEL_FRAMES = reelFrame(CTA_FROM + 24 + 150); <Sequence from={reelFrame(CTA_FROM)}> <Cta /> </Sequence> <Sequence from={FILL_FROM} durationInFrames={reelFrame(CTA_FROM + 30) - FILL_FROM}> <Story /> </Sequence> The audio cues, which are written in story frames next to the scenes that caused them:
{CUES.map(({ at, sound, volume = 1, rate = 1 }, i) => ( <Sequence key={i} from={reelFrame(at)} durationInFrames={60} layout="none"> <Audio src={staticFile(`sfx/${sound}.wav`)} volume={volume} playbackRate={rate} /> </Sequence> ))} One reelFrame call for every sound in the reel. The cue list did not change at all when the pace did, which is the entire point: a knock is "when the bill strikes", and the bill strikes at a story frame.
The story scene, whose sequence starts before the warp:
const f = storyFrame(useCurrentFrame() + FILL_FROM); And the call to action, which earns the strangest line in the project:
const t = storyFrame(useCurrentFrame() + reelFrame(CTA_FROM)) - CTA_FROM; Read it right to left. useCurrentFrame() inside a Sequence is local to that sequence, so adding the sequence's start gives the reel frame; storyFrame turns that into the story clock; subtracting CTA_FROM rebases it to zero at the moment the drain starts, which is what every keyframe in that scene was written against. Three operations, each undoing a different offset, and the alternative was rewriting a scene's worth of keyframes.
The two scenes before the boundary (the tree and the icon rain) still call useCurrentFrame() raw. They never cross frame 150, so the mapping there is the identity and converting would be noise.
The pair is not exactly invertible, on purpose
reelFrame rounds and storyFrame does not. A frame is an integer when you hand it to Remotion and a real number when you interpolate with it, so the rounding belongs on the side that produces frame numbers:
reelFrame(486) = 570 storyFrame(570) = 486 reelFrame(487) = 571 storyFrame(571) = 486.8 Up to 0.4 of a story frame of drift on a round trip. Invisible in motion, because every scene interpolates continuously, and necessary for audio, because a Sequence starts on an integer frame.
The reel is longer than it was
REEL_FRAMES = 788, which is 26.27s at 30fps It was 772 frames, 25.73s. Cutting four seconds of headline and stretching the rest by a quarter came out 16 frames longer, which is close enough to the same reel that nobody would guess a scene is missing.
Two small things fell out of the cut.
Alcy used to stop pecking once the icons were all loose. Now he keeps going until the water takes him:
/** Then on at the same pace, knocking nothing more loose, until the water has filled the screen at 150. */ export const PECKS_3 = [127, 130, 133, 136, 139, 142, 145, 148]; // Fading as the water covers him. ...PECKS_3.map((p, i): Cue => ({ at: p, sound: "knock", volume: 0.8 - i * 0.07, rate: 1.2 + (i % 2) * 0.03 })), Eight knocks fading from 0.8 to about 0.31, with the playback rate alternating by 0.03 so the repetition does not read as one sound looped.
And the thumbnail broke, because the frame we used for it was inside the deleted scene:
- "cover": "remotion still Reel out/cover.png --frame=255", + "cover": "remotion still Reel out/cover.png --frame=645", Old frame 255 was the slot machine mid-hold. Reel frame 645 maps to story frame 546, which is 60 frames after the drain starts: the call to action, water gone, Alcy on the button. npx tsc --noEmit in reels/ is clean after all of it, which for a change that deleted an exported constant is the one check worth running before a render.
Go and look
The reel's content is the product's content, so the sentences in it are checkable:
- nakodo.app opens with "Find partners who say yes.", the line the deleted slot machine used to assemble one word at a time, and the page's own step section tells the same story the reel's five screens do. The illustrations are the same components the reel imports.
- How it works is the long form of those five screens: the brief, the search, the score and the emailing, in the order the camera visits them.
- The about page introduces Alcy, the kingfisher doing the pecking, and explains the name: nakōdo is the go-between who makes an introduction.
If you build timelines in Remotion: derive every frame number from the one before it, keep each scene on the clock its keyframes were written in, and convert only where a scene is mounted. A time warp then costs two functions and one line per boundary, and deleting a scene costs nothing.