Munchable's social video is me on camera in a supermarket, with the app composited on top. Not a screen recording, and not a finished animated reel: the video package renders transparent overlays that drop onto a track above the footage in an editor. The app's real screens are what they draw, down to the tokens and the type.
There are 69 registered compositions in there. 24 are clips, 44 are stills, one is a proof sheet that is never posted. This is about the parts of that setup that were not obvious when I started.
Transparency decides the whole render config
// Overlays sit on top of footage, so every render keeps its alpha channel:
// PNG frames (JPEG has no alpha) into ProRes 4444, which desktop editors
// (CapCut, Premiere, Final Cut, DaVinci) import as a transparent layer. These
// are also the defaults the Studio's Render button picks up.
Config.setVideoImageFormat('png');
Config.setCodec('prores');
Config.setProResProfile('4444');
Config.setPixelFormat('yuva444p10le');
Four lines, and three of them are the same decision. The intermediate frame format has to be PNG because JPEG cannot carry alpha, the codec has to be one with an alpha-capable profile, and the pixel format has to be the one with the a in it. Miss any one and the render succeeds, looks right in the preview, and arrives in the editor on an opaque black rectangle.
Putting them in remotion.config.ts rather than only in the render script matters more than it looks: the Studio's own Render button reads that config. Otherwise the interactive path, which is the one I actually use when tuning a clip, silently renders a different thing from the batch script.
The price is file size, and the README is blunt about it:
ProRes is large: about 5 MB a second for a caption, 15 MB for a phone.
That is fine for something that exists for the twenty minutes between render and edit, and it would be absurd for anything shipped to a browser.
Length is computed, so the declared length is a lie
Every clip is registered with a one-frame duration:
const fullFrame = { ...size, durationInFrames: 1 };
const phone = { fps: FPS, ...PHONE_CANVAS, durationInFrames: 1 };
/**
* Every overlay works out its own length from its props; the 1 is only the
* placeholder before it does.
*/
Remotion requires durationInFrames on a <Composition>, and calculateMetadata replaces it from the props. For a caption that is arithmetic:
export const captionMetadata: CalculateMetadataFunction<Props> = ({ props }) => ({
durationInFrames: frames(props.seconds),
});
For the phone scanning a handful of packs in turn, the length is whatever the timeline comes to:
/** The first pack lands in the viewfinder here, once the phone has settled. */
export const FIRST_ARRIVE = 40;
/** Frames from one answer sliding away to the next pack landing: the old pack slides out, the new one in. */
const BETWEEN = 24;
/** The phone sinks away this long before the last answer would slide away, so it leaves on the answer. */
const EXIT_EARLY = 4;
export const phoneScanMetadata: CalculateMetadataFunction<PhoneScanProps> = ({ props }) => ({
durationInFrames: timeline(props).duration,
});
The same timeline(props) function is called by the component to decide what to draw on frame n, and by calculateMetadata to decide how many frames there are. One pack or three, two seconds on the answer or six, and the clip is exactly as long as it needs to be with nothing trimmed in the editor.
EXIT_EARLY = 4 is my favourite line in the package. The phone starts sinking away four frames before the last answer would have slid off, so the clip ends on the answer rather than on an empty frame. It is a four-frame opinion about what the viewer should be looking at last.
The clip-or-still test, and why it is safe
The batch renderer has to send clips to renderMedia and stills to renderStill, and it decides like this:
const all = (await getCompositions(serveUrl)).filter((c) => !PROOFS.includes(c.id));
const isIcon = (c) => c.durationInFrames === 1;
Which looks like it cannot possibly work, because one frame is precisely what every clip declares.
It works because getCompositions() evaluates calculateMetadata() for every composition before returning. Remotion is explicit about this if you go looking: the delayRender handle it opens while doing so is labelled with the alternative.
const handle = delayRender(
`Running calculateMetadata() for composition ${c.id}. If you didn't want to evaluate this composition, use "selectComposition()" instead of "getCompositions()"`
);
So by the time the script sees them, every clip reports its real computed length and only the <Still> entries are still at one. The two API names are a real fork in the road: selectComposition() resolves one composition's metadata, getCompositions() resolves all of them, and a script that lists compositions to decide what to do with them wants the second one precisely because of the side effect.
The heuristic has one way to break: a clip whose props genuinely resolve to a single frame. The schema makes that unreachable:
export const seconds = z.number().min(1).max(15);
At 30 fps the shortest possible clip is 30 frames, so nothing animated can collide with the stills. That is an accident I only noticed while writing this, and it is now the reason I would not lower that minimum.
Every canvas is cropped to its content, except two
A full 1080x1920 transparent frame with a phone in the middle is mostly empty pixels that the editor then has to be told to move. So most compositions are sized to their subject:
/** 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,
};
80 above, 150 below, because a drop shadow is not symmetrical and a clipped shadow edge is instantly visible against real footage.
Two compositions keep the full frame, and the reason is in the registration file:
* Full-frame overlays (caption, scan guide) keep the 1080x1920 frame because
* where they sit matters; the rest are cropped to their content, centred, for
* you to place and scale in the edit.
A phone can go anywhere in the shot. A caption cannot: it has to sit inside the platforms' safe zone, which is a fact about the frame rather than about the caption. So the safe zone is encoded in the prop schema itself, as a range:
/** Pixels from the top of the frame. 230 is the top of the platforms' safe zone; keep the caption above 1500. */
top: z.number().min(0).max(1700),
And the same reasoning, for the other axis, is in the README rather than in code:
When placing a phone, the left side of the shot is the safer one: the
platforms stack their like, comment and share buttons down the right edge.
The props are the editing surface
Every overlay's props are a zod schema, which Remotion's Studio turns into real form controls, so tuning a clip is a dropdown and a number field rather than a code edit and a reload:
/**
* The props every overlay can be tuned with, as zod schemas so the Studio
* shows them as dropdowns and number fields: pick a product, a profile, how
* long the answer stays up, and render.
*/
The small piece of type machinery that makes those dropdowns possible is this:
function keys<T extends string>(o: Partial<Record<T, unknown>>): [T, ...T[]] {
return Object.keys(o) as [T, ...T[]];
}
export const productId = z.enum(keys(PRODUCTS));
z.enum wants a non-empty tuple of literals, and Object.keys gives you string[]. One helper with one assertion, and every dropdown in the Studio is generated from the data rather than typed out again next to it. Add an illustrated pack and it appears in every overlay's product list and gets its own pair of icon stills, front and back.
What the batch script refuses to do
Two guards, both of them about my own mistakes rather than anyone else's.
/** Proof sheets are for checking label copy, not for posting. */
const PROOFS = ['cast-sheet'];
One composition exists to be read, not posted: every pack front and back on one frame, for proofreading the label copy on the illustrated packs. It is registered so the Studio can show it, and filtered out by name so a bare pnpm render never writes it.
// Checked against the registered compositions, so a typo says so instead of
// quietly rendering nothing.
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);
}
pnpm render phone-sacn used to exit zero having rendered nothing at all, which is the worst possible outcome for a command whose success condition is a file appearing in a folder you are not looking at.
One clip is allowed a background
`cta` is the one clip with a background: cream wipes up from the bottom, the
whole cookie is bitten into the logo, then "Trust your gut again" and "just
search munchable.app". Where the wipe has not reached yet the frame is
transparent, so overlap it with the last moment of your footage and the cream
rises over you.
Even the opaque one is transparent where it has not painted yet, which is what lets it be an end card and a transition at the same time.
The last constraint is the one that surprises people: everything in the package is silent, and so is the app. No sound effects, anywhere. The music goes on in the editor, and a phone that beeps at you in a supermarket aisle is not the product.
If you want to see what the overlays are copying, the real thing is public: the recipe list the recipes overlay mirrors, and the eight conditions whose stickers sit above the phones.
Top comments (0)