DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One constant slows down every ad we render, and some clips are exempt

Our vertical ads are React. The whole library of 9:16 clips is a Remotion project: components, springs, a design token file, rendered to ProRes and H.264 by a script.

The first pass was bad in a way that took a while to name, because nothing about it was incorrect. Every beat was readable. Every animation resolved before the next one started. The clips looked, on inspection, well timed.

They were exhausting to watch.

Legible and comfortable are different thresholds

Here is the diagnosis, which is now the doc comment on the constant that fixes it:

It exists because the first pass of the library was timed against a stopwatch rather than against a viewer: each beat was set to the shortest interval where the change was still legible, which is the right way to time a UI and the wrong way to time a video.

That distinction is the whole post. When you time an interface, the shortest legible interval is the correct answer, because the user is driving and every millisecond you add is a millisecond of their time you took. When you time a video, the viewer is a passenger. They need to read the beat and absorb it before the next one lands, and the gap between those two thresholds is substantial.

I had been optimising for the wrong one out of habit, because I write UIs.

One number, applied everywhere

The naive fix is to go through twenty clips multiplying every frame number. That is a day of work, it is error prone, and it leaves you with no way to change your mind.

Instead every clip is written in "clip frames" and reads a wrapped hook:

export const PACE = 1.4;

export const useClipFrame = (): number => useCurrentFrame() / PACE;

export const paced = (frames: number): number => Math.round(frames * PACE);
Enter fullscreen mode Exit fullscreen mode

useClipFrame() divides. paced() multiplies. A clip's beat map stays in whatever units it was authored in, and one constant stretches the entire library at once.

The paced() half is the part that is easy to forget and impossible to spot:

Anything a paced clip needs to fit inside has to go through this, or the last beat of a slowed clip lands after its final frame, which is invisible in the render output and obvious only when the video ends mid-sentence.

A composition's durationInFrames is real frames. If you slow the content by 1.4 and leave the duration alone, the tail is simply cut off. There is no error, no warning, and the render succeeds. You find out when you watch the file.

Returning a fractional frame is deliberate too. interpolate and spring both take real numbers, so dividing costs nothing, whereas rounding would put a subtle stepping into every ease in the library.

The comment on the constant also records the range, which is the kind of thing you know for a week and then forget:

Tune this one number to taste. 1 is the original pace; above about 1.8 the clips start to feel like they are waiting for a voiceover that has not arrived.

The clips that are not allowed to be slowed

This is my favourite part, and it is a product rule rather than a rendering one.

Some of these clips show the actual assessments we help people prepare for: a flash trial that appears for a fixed time, a millisecond countdown, a solve clock. Those are calibrated against what the real tests allow.

They do not use useClipFrame():

The clips that deliberately run in real time do not use this: the flash trials, the millisecond countdowns and the solve clocks are calibrated against what the real assessments allow, and stretching those would put a number on screen the test does not back.

If a real trial gives you 700 milliseconds and my ad shows a 700 ms label over an animation that visibly takes a second, I have made a marketing asset that misrepresents the thing it is advertising. Nobody would notice. It would still be a lie, and the whole product is built on being accurate about how these assessments work.

So the pacing system has a documented exemption, and the exemption is the interesting design decision, not the multiplier. Any global transform applied to a creative library needs to answer "which elements represent an external fact", because those cannot be transformed.

The rest of the kit

Two other things earned their place.

Remotion's spring always measures from frame 0, so staggering beats means subtracting offsets by hand at every call site. Doing it once removes an entire category of mistake:

export const springAt = ({ frame, fps, start = 0, damping = 14, mass = 0.8, stiffness = 130, durationInFrames }) => {
  if (frame < start) return 0;
  return spring({ frame: frame - start, fps, config: { damping, mass, stiffness }, durationInFrames });
};
Enter fullscreen mode Exit fullscreen mode

The if (frame < start) return 0 pin matters more than it looks: without it a beat can leak backwards into the head hold of a clip, which reads as a flicker before the clip has started.

And the springs themselves are named by intent rather than by physics, because at the call site you know what you want it to feel like, not what stiffness value produces it:

/** iOS-like drop: settles fast with a small, single overshoot. */
export const SPRING_DROP = { damping: 15, mass: 0.7, stiffness: 135 } as const;

/** A pop for icons and badges, more overshoot, reads as "landed". */
export const SPRING_POP = { damping: 11, mass: 0.6, stiffness: 180 } as const;

/** Layout shifts, where overshoot would look sloppy. Matches the site demos. */
export const SPRING_SETTLE = { damping: 30, mass: 1, stiffness: 340 } as const;
Enter fullscreen mode Exit fullscreen mode

Three named presets is also how the video ends up feeling like the product. SPRING_SETTLE is matched to the animation curves on the actual site, so an ad and the page it links to move the same way.

Render settings are content specific

The batch renderer holds one more thing I did not expect to need:

// Everything in these clips is a low-contrast gradient over near-black, and
// x264's default `medium` preset spends its bit budget badly on exactly that,
// it shows up as blocking in the background bloom. `slower` gives the encoder
// enough analysis to hold the gradients together at the same CRF.
'--x264-preset=slower',
Enter fullscreen mode Exit fullscreen mode

CRF 14 was not the problem. The preset was. If your output has a lot of smooth dark gradient, the encoder's analysis budget matters more than the quality target, and that is not something you can reason about from the docs. You see the blocking, you change one flag, it goes away.

Everything renders at 2x, so 1080x1920 compositions become 2160x3840. It is all DOM and vectors, so that is genuinely resolved detail rather than an upscale, which gives a 4K master to grade and reframe from before downscaling to what the platforms actually display.

Watch the thing they advertise

The clips are assembled from the same design tokens as the product, so if you want to see what the springs and the pacing look like when they are attached to something interactive rather than to a render, the demos on the CogniPrep landing page use the matching curves. The SPRING_SETTLE preset above exists specifically so those two surfaces agree.

If you have a motion library of your own, try this: find the constant you would have to change to slow everything down by 40%. If there isn't one, that is the refactor, not the timing.

Top comments (0)