Notifio's ad clips are a library of Remotion compositions, rendered to video from React. Ten of the 28 are overlays: a notification banner, a "NEW" badge, a lower third, a little email card. They are meant to be dropped on top of other footage, so they need a real alpha channel.
Here is the bug that cost me an afternoon, and it is a good one, because every single check you would think to run says the file is fine.
The file says it has alpha. It does not have alpha.
The obvious encoder choice for transparent web video is VP9 in WebM. It is newer than VP8, better compression, better supported, and ffmpeg takes the pixel format without complaining:
--codec=vp9 --pixel-format=yuva420p
That renders. No error, no warning. The resulting .webm reports yuva420p. ffprobe shows the Matroska alpha_mode: 1 tag, which is the container-level flag that means "this stream has an alpha plane".
Drop it over footage and you get a solid black rectangle.
webm: {
ext: "webm",
/**
* VP8, not VP9. This is not a style preference.
*
* ffmpeg's libvpx-vp9 encoder accepts `yuva420p` and writes the Matroska
* `alpha_mode: 1` tag, then silently discards the alpha plane. The file
* looks transparent by every metadata check and composites as a solid black
* rectangle. libvpx (VP8) is the only ffmpeg encoder that actually writes
* the alpha plane, so transparent WebM has to be VP8.
*/
flags: [
"--codec=vp8",
"--pixel-format=yuva420p",
"--image-format=png",
"--muted",
],
},
The reason this is expensive rather than merely annoying: every layer of verification lies in the same direction. The encoder accepts the pixel format. The container writes the flag. The metadata reads back correctly. The only tool that tells you the truth is compositing it over something and looking at it with your eyes.
If you are rendering transparent WebM out of ffmpeg today, use VP8. Not because VP8 is better, but because libvpx is the encoder that actually writes the plane. And more generally: when you are testing a property like transparency, test the property, not the metadata that claims the property. I checked alpha_mode: 1 three times before I thought to open the file in an editor.
Four output targets, because "the video" is not one thing
Once you accept that overlays and full-frame clips are different deliverables, the rest of the pipeline falls out. There are four encodings and each clip declares which it needs:
const TARGETS = [
{ id: "speed-race", folder: "02-problem", encodings: ["master", "prores422"] },
{ id: "activity-log", folder: "03-mechanism", encodings: ["master", "prores422"] },
// Overlays composite over other footage, so they need alpha: ProRes 4444 for
// editors, VP8 webm for web. No H.264 master, H.264 has no alpha channel.
{ id: "overlay-toast-ios", folder: "overlays", encodings: ["prores4444", "webm"] },
{ id: "overlay-badge-new", folder: "overlays", encodings: ["prores4444", "webm"] },
];
-
masteris H.264, yuv420p, CRF 14. The file you can hand to anyone, that imports into anything, CapCut included. -
prores422is ProRes 422 HQ, 10-bit. The quality master for the opaque clips, for when the clip is going through several generations of edit and you do not want to stack compression artefacts. -
prores4444is ProRes 4444 with alpha, for overlays going into a real editor. -
webmis the VP8 above, same job as prores4444 but for the web and for editors that choke on ProRes on Windows.
Note what the overlays do not get: an H.264 master. Not an oversight. H.264 has no alpha channel, so a "convenience copy" of an overlay in H.264 is a file whose only possible use is to be dropped in by mistake. Not generating it is cheaper than explaining it.
The 53 milliseconds
This is my favourite line in the whole pipeline, and it is one CLI flag:
// Without this Remotion writes a silent AAC track, which serves no purpose
// when voiceover is added later and pads the container out to 4.053s on a
// 4.000s clip. Editors snapping to clip boundaries notice that.
"--muted",
None of these clips have audio. The voiceover goes on in the edit. So Remotion helpfully writes a silent AAC track, which is a reasonable default, except that AAC encodes in fixed-size frames and the track therefore does not end exactly where the video does. The container duration becomes the longer of the two.
Your 4.000 second clip is 4.053 seconds. Which does not matter at all, right up until you drop twelve of them on a timeline with snapping on, and every single boundary has a 53 millisecond sliver of nothing in it that you then have to find and close by hand.
This is the class of bug I find most interesting in generated media: it is not wrong, it is not visible, no test catches it, and it is only discovered by the person downstream doing the boring manual work.
Rendering at 2x, and why that is not an upscale
/**
* Render scale, applied to every job.
*
* 2x -> 2160x3840. Kept as one constant rather than baked into each preset so
* the whole library moves resolution together and never drifts between codecs.
*/
const SCALE = "--scale=2";
The compositions are authored at 1080x1920, which is the native size TikTok, Reels and Shorts actually serve. Rendering at 2x gives 2160x3840.
With camera footage that would be a pointless upscale. With DOM it is not: the whole frame is text, borders, gradients and SVG, so at 2x every one of those is re-rasterised at the higher resolution. It is genuinely resolved detail. That gives a 4K master to grade, reframe and punch into, downscaled to 1080x1920 for delivery.
Keeping the scale as one constant shared by all four presets rather than in each preset is a small thing that prevents a specific bad day: an overlay rendered at 1x composited over a 2x background, discovered at the end of an edit.
The runner is a for loop, deliberately
for (const [i, job] of jobs.entries()) {
console.log(`\n[${i + 1}/${jobs.length}] ${job.id} -> ${job.out}`);
await run(job);
}
28 targets, 56 output files, rendered strictly one at a time with stdio: "inherit".
Remotion already parallelises internally across browser tabs and all your cores. Running four renders concurrently on top of that does not give you four times the throughput, it gives you four processes fighting over the same CPU, four progress bars interleaved into unreadable mush, and an out-of-memory kill somewhere around the third ProRes job. Sequential with inherited stdio means the progress bar works, and a failure tells you which target failed without scrolling.
One more small thing that is pure quality of life:
// `pnpm exec` rather than `npx`: npx reads .npmrc and warns about the
// pnpm-only keys in it on every single render.
56 renders, 56 identical warnings. Worth the one line.
Go and look
- notifio.app, and the download page for the app all this is advertising
- What it costs, which is the claim the end card has to get right
- The sites it monitors
- The previous post covers the composition side: ad slots, safe zones, and the global slow-down that is not allowed near a clock
If there is a general lesson, it is the one from the top. A pipeline that generates media has no user-visible errors, only user-visible results, so every check that reads metadata instead of pixels is a check that can pass on a broken file.
Top comments (0)