DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Every clip our video package renders keeps its alpha channel, and the five-line config is the smallest part of that

Munchable markets itself with video where a person is on camera holding a food packet. That ruled out the obvious thing, which is rendering finished vertical reels, because a finished reel has no room in it for the person. What the repo produces instead is a set of transparent overlays: the phone, the caption, the sticker, the scan guide, each one a clip you drop on a track above your own footage.

That is a Remotion package of 13 overlays, one end card, a pile of icons, and two scripts. Here is what turned out to matter.

The config is five lines and applies to half the renders

import { Config } from '@remotion/cli/config';

Config.setVideoImageFormat('png');
Config.setCodec('prores');
Config.setProResProfile('4444');
Config.setPixelFormat('yuva444p10le');
Config.setOverwriteOutput(true);
Enter fullscreen mode Exit fullscreen mode

Each line earns its place. png for the frames, because JPEG has no alpha and the transparency is gone before the encoder ever sees it. ProRes 4444 because it is the codec desktop editors import as a transparent layer without being told. yuva444p10le is the pixel format with the alpha plane, and ProRes without it is just ProRes.

The catch: remotion.config.ts is read by the CLI and by the Studio. It is not read by @remotion/renderer when you call it from Node. So the batch script has to say the same thing again, in its own vocabulary:

await renderMedia({
  composition,
  serveUrl,
  codec: 'prores',
  proResProfile: '4444',
  pixelFormat: 'yuva444p10le',
  imageFormat: 'png',
  outputLocation: join(root, 'out', `${composition.id}.mov`),
  onProgress: ({ progress }) => process.stdout.write(`\r  ${Math.round(progress * 100)}%`),
});
Enter fullscreen mode Exit fullscreen mode

Keeping the config file anyway is deliberate: it is what makes the Studio's own Render button produce the same transparent file. Tuning a clip's props in the Studio and pressing Render is the normal way a clip gets changed here, and a Studio that renders opaque MP4s while the script renders transparent MOVs is a trap with a day in it.

Compositions cropped to their content, not to the platform frame

The instinct is to make every composition 1080x1920, because that is what the platform wants. For an overlay that is wrong. If the phone is a 1080x1920 frame with a phone in the middle of it, the editor has to scale and reposition a mostly empty rectangle, and every repositioning is a guess about what is behind it.

So each piece declares the canvas its content needs:

const fullFrame = { fps: FPS, width: 1080, height: 1920, durationInFrames: 1 };
const phone     = { fps: FPS, ...PHONE_CANVAS, durationInFrames: 1 };
const cropped   = (canvas) => ({ fps: FPS, ...canvas, durationInFrames: 1 });
Enter fullscreen mode Exit fullscreen mode
/** 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

One phone comes out 860x1570: the phone at full size, centred, with just enough room for a shadow that falls further below than above. The sticker, the pack and the logo each get their own canvas computed from their props the same way. Two clips keep the full 1080x1920 frame, and both for the same reason: where they sit is part of the content. The caption has to respect the platforms' safe zone, and the scan guide has to line up with the packet a real hand is holding up in the footage.

The overlays also lift in and fade away rather than entering from a frame edge, which is the other half of the same idea. An overlay that slides in from the left has an opinion about where the frame is, and in somebody else's edit it will be wrong.

Icons are Stills, and the renderer tells them apart by duration

Icons are transparent PNGs rendered from <Still>, drawn large so they scale down cleanly. The batch script separates clips from icons with one predicate:

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

That works because every real clip computes its own length from its props through calculateMetadata, so a durationInFrames: 1 at registration is only a placeholder that gets replaced before a render. It is a pun, and I would not defend it in a library, but in a 64-line script that knows its own compositions it reads better than a second list to keep in sync.

The rest of the script is three habits worth stealing for any Remotion batch render:

// The renderer and bundler are dependencies of the CLI, so they resolve from it.
const fromCli = createRequire(createRequire(import.meta.url).resolve('@remotion/cli/package.json'));
const { bundle } = fromCli('@remotion/bundler');
const { getCompositions, renderMedia, renderStill } = fromCli('@remotion/renderer');
Enter fullscreen mode Exit fullscreen mode

One: resolve @remotion/bundler and @remotion/renderer through the CLI's own require rather than adding them to the package's dependencies, so there is exactly one version of the renderer and pnpm cannot hoist a second one.

Two: bundle once, then loop. bundle() is the expensive step and getCompositions(serveUrl) gives you the registered list from the same bundle, so rendering every clip in the package bundles once rather than once per clip.

Three: validate the arguments against the registered ids.

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

A typo in a render id otherwise renders nothing, successfully, and prints a cheerful summary of zero files. The error also lists what it does know, which is the difference between a message and a usable one.

There is also a proof sheet composition in the project that is excluded from render by name. It exists to check label copy at a glance and is not for posting, so the batch script filters it out rather than leaving a judgement call in the output folder.

What it costs and what it buys

ProRes 4444 is large: roughly 5 MB a second for a caption, 15 MB a second for a phone. For a 3 second overlay that is nothing, and the output never leaves the editing machine. What you get is one import, no keying, no matte, and no quality discussion: the alpha is real, the shadow edges stay soft, and the gold sweep over the scan guide does not fringe.

Two other decisions that are easy to skip and expensive later:

  • Everything is silent. No clip has audio. The music bed goes on in the edit, and the app has no sound effects either, so an overlay that chimed would be advertising a feature that does not exist.
  • Everything on screen comes from the app. The colours, the type and the screens are imported from the same shared packages the product uses. An earlier version kept a hand-copied palette here, and a copy drifts silently: changing a colour in the app left the videos showing the old one with no type error anywhere to tell you.

See the originals

The phones in these clips are drawing the app's real screens, so the quickest way to judge how close the overlays get is to look at the product: munchable.app for the home screen and the answer card, and the public recipe library for the list the recipes overlay scrolls through. The about page is the one with the publisher details, which is the end card's job in video form.

If you are rendering overlays rather than finished videos: PNG frames, ProRes 4444, yuva444p10le, and crop the composition to the content. Then say all four of those twice, once for the CLI and once for the renderer.

Top comments (0)