DEV Community

Daniel Pertu
Daniel Pertu

Posted on

24 clips and 44 icons come out of one Remotion bundle, and the only thing separating them is durationInFrames === 1

Munchable's social video used to be finished reels: a complete 1080x1920 piece, rendered end to end from React, ready to post. They were fine. They were also the wrong artefact, because the videos that actually work for this product have a person in them talking about their own shopping, and a finished reel cannot be in the same frame as a person.

So the package now renders no finished videos at all. It renders the parts that go on top of footage: a phone that lifts into the shot, a caption, a verdict sticker, the app's scan guide, a basket that packs drop into. Plus one end card, which is the only clip with a background.

That change is mostly a rendering-configuration change, and it has consequences that reach much further into the code than I expected.

Alpha first, because alpha restricts everything else

The whole config is five lines:

// packages/reels/remotion.config.ts
Config.setVideoImageFormat('png');
Config.setCodec('prores');
Config.setProResProfile('4444');
Config.setPixelFormat('yuva444p10le');
Config.setOverwriteOutput(true);
Enter fullscreen mode Exit fullscreen mode

Each line is load-bearing:

  • PNG frames. The intermediate frames Remotion captures have to carry an alpha channel. JPEG has no alpha, so a JPEG pipeline silently flattens your transparency onto black long before the encoder sees it.
  • ProRes 4444. The 4444 profile is the one with the alpha plane. Plain ProRes 422 will encode happily and hand you an opaque rectangle.
  • yuva444p10le. The a is the point. Pick a pixel format without it and the codec is capable of alpha while the stream is not carrying any.
  • The same settings in the Studio. Because the config file is what the Studio's Render button reads, pressing Render after tuning props gives you a transparent file too. A rendering setup where the CLI and the GUI disagree about transparency is a setup that produces one broken clip a week.

The .mov files that come out are laid over the footage in a desktop editor: CapCut, Premiere, Final Cut, DaVinci. The file is big. A caption runs about 5 MB a second and a phone clip about 15 MB a second, which is the actual price of a lossless alpha channel, and it is irrelevant for a 4 second overlay that gets re-encoded to h264 by the platform anyway.

The renderer treats stills and clips as the same thing

scripts/render-all.mjs is one bundle, one pass, and this line is the entire type system:

const isIcon = (c) => c.durationInFrames === 1;
Enter fullscreen mode Exit fullscreen mode

Everything registered in the Remotion root comes back from getCompositions. One frame means it is an icon and goes through renderStill to out/icons/<id>.png. Anything else goes through renderMedia to out/<id>.mov as ProRes 4444. There is no separate icon pipeline, no second script, no manifest listing which is which.

That works because the registrations are honest about what they are. The transparent PNG assets are registered with Remotion's <Still>:

<Folder name="Packs">
  {Object.keys(PRODUCTS).flatMap((id) =>
    [false, true].map((back) => (
      <Still id={`pack-${kebab(id)}${back ? '-back' : ''}`} component={PackIcon}
             defaultProps={{ product: id, back }} {...PACK_ICON} />
    )),
  )}
</Folder>
Enter fullscreen mode Exit fullscreen mode

16 illustrated packs, front and back, from one flatMap. Add the four verdict discs, the four verdict pills and four brand marks, and pnpm render writes 44 PNGs. On the clip side there are 13 overlays, 10 scan presets kept from the old reels, and the end card, so 24 .mov files. One command, one bundle, two output directories, zero configuration listing any of it.

Two details I would copy into any Remotion project:

const PROOFS = ['cast-sheet'];
const all = (await getCompositions(serveUrl)).filter((c) => !PROOFS.includes(c.id));
Enter fullscreen mode Exit fullscreen mode

There is one composition that is not an asset: a contact sheet of every illustrated pack, front and back, for proofreading the label text on the drawings. It is registered so the Studio can show it and excluded by id so a bulk render never writes it. A "render everything" script needs a concept of a thing that is not for shipping, or someone eventually posts the proof sheet.

const unknown = args.filter((a) => a !== 'icons' && !all.some((c) => c.id === a));
if (unknown.length > 0) {
  console.error(`Unknown composition${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
  console.error(`Known: icons, ${all.filter((c) => !isIcon(c)).map((c) => c.id).join(', ')}`);
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

pnpm render phone-scan cta renders two clips. pnpm render phone-scna tells you it does not know that name and prints the list, rather than succeeding with an empty loop, which is what a naive filter does and what cost me a confused ten minutes once.

Every composition is registered with one frame and a lie

Here is the part that looks wrong in a diff:

const phone = { fps: FPS, ...PHONE_CANVAS, durationInFrames: 1 };

<Composition id="phone-scan" component={PhoneScan} schema={phoneScanSchema}
             defaultProps={PHONE_SCAN_DEFAULTS} calculateMetadata={phoneScanMetadata} {...phone} />
Enter fullscreen mode Exit fullscreen mode

A clip registered with a single frame. The comment in the file explains it:

Every overlay works out its own length from its props; the 1 is only the placeholder before it does.

calculateMetadata is what supplies the real value, from the props the clip is being rendered with:

export const phoneScanMetadata: CalculateMetadataFunction<PhoneScanProps> = ({ props }) => ({
  durationInFrames: timeline(props).duration,
});
Enter fullscreen mode Exit fullscreen mode

So "scan three packs and hold each answer for five seconds" is not a number anybody typed into a composition. It is derived: the timeline knows how long a lift-in takes, how long each pack takes, how long the sink-away takes, and the duration falls out. Change answerSeconds in the Studio's props panel and the clip gets longer by exactly the right amount, with no chance of the composition length and the animation disagreeing, which in a hand-numbered setup shows up as a frozen last frame or an animation cut off mid-bounce.

And because isIcon is durationInFrames === 1, this placeholder could have been a disaster. It is not, because getCompositions runs calculateMetadata with the default props before returning, so by the time the renderer sees a clip it already has its real length. Stills are the only things that are genuinely one frame.

The canvas is a prop too

Overlays get cropped to their content, because something that will be moved and scaled over footage should not arrive inside 1080x1920 of transparent nothing. So calculateMetadata returns dimensions as well as duration:

/** Room for the pop's overshoot, the bob and the drop shadow. */
export function packCanvas(height: number) {
  return { width: even(artWidth(height) * 1.15 + 140), height: even(height * 1.15 + 160) };
}

export const packPopMetadata: CalculateMetadataFunction<PackProps> = ({ props }) => ({
  durationInFrames: frames(props.seconds),
  ...packCanvas(props.height),
});
Enter fullscreen mode Exit fullscreen mode

Ask for a 900px pack and the file is sized for a 900px pack plus its overshoot, its bob and its shadow. The even() is not decoration: video encoders want even dimensions, and a canvas computed from arbitrary props will absolutely hand you an odd number.

The phone canvas is the same idea with asymmetric padding:

/** Room round the phone for its drop shadow, which reaches further below than above. */
export const PHONE_PAD = { side: 100, top: 80, bottom: 150 } as const;
export const PHONE_CANVAS = { width: PHONE_W + 2 * PHONE_PAD.side, height: PHONE_H + PHONE_PAD.top + PHONE_PAD.bottom };
Enter fullscreen mode Exit fullscreen mode

660 by 1340 of phone becomes an 860 by 1570 file, and the extra 70px at the bottom is there because the shadow falls down and to the side. Crop tight to the device and you clip the shadow, which reads as a hard edge the moment the clip moves.

Exactly two clips keep the full frame, and the reason is composition rather than content: the caption and the scan guide. Where those sit in the frame is the point. A caption has to land inside the platforms' safe zone, so it is positioned in the render rather than in the edit, and the guide has to line up with the pack somebody is holding up on camera. Everything else lifts in and fades away instead of entering from a frame edge, so it works wherever you drop it in the shot.

One more constraint that is about the platforms and not about rendering: when placing a phone, the left side of the shot is safer, because the like, comment and share buttons stack down the right edge.

What stays out

Two absences, both deliberate.

Every clip is silent. Not quiet, silent: there are no sound effects anywhere in the product or its video pieces, and the music bed goes on in the editor. A rendered overlay with a baked-in whoosh is an overlay you can only use in a video whose music happens to permit a whoosh.

And nothing in the clips is hand-written marketing copy about what the app would say. The phones in the overlays run the app's own engine at render time over an illustrated pack's real ingredient list, so a clip cannot promise an answer the product would not give. The two clips that cannot work that way, the sticker and the scan guide, are for the real pack in your hand, which the renderer has never seen, so their verdict is a prop you set to the one the app actually gave you. You can see the same answers on the site, for example at munchable.app/answers for a single ingredient, or munchable.app/conditions for the conditions the app covers, and the recipes in the recipe overlay are the public library at munchable.app/recipes.

A finished ad is a promise written by hand. A transparent overlay is the product's own output, with the person using it left in the frame.

Top comments (0)