Black bars above and below a frame are usually a ratio problem: the canvas is 16:9, the content is wider, and the player fills the rest with black. What arrived this time was different. The bars were already pixels inside the PNGs, and left alone they would be painted onto the hero canvas as if they were part of the picture.
The client delivered a new property walkthrough PNG sequence for the site's hero, 196 frames at 1920×1080, dropped into the source folder assets/seq-src/. It was replacing an older sequence of 291 frames. When I opened it, every frame had a 38px black bar at the top and another 38px at the bottom baked into the pixels. The real content area was 1920×1004, and 1920 divided by 1004 is roughly 1.91, so the material was 1.91:1, not the 16:9 the file dimensions claimed.
Measuring the bars instead of guessing
The number 38 could have been eyeballed from the screen, but ffmpeg has a filter built for exactly this. cropdetect scans the frame, looks for the edges of the non-black area, and reports the rectangle it found in a form that pastes straight into the crop filter. For these frames it returned crop=1916:1004:2:38, which confirmed the 38px bars.
The value I ended up using at encode time differs slightly from that report:
# cropdetect first: crop=1916:1004:2:38
ffmpeg -i in.png -vf crop=1920:1004:0:38 -c:v libwebp -q:v 85 -preset photo out.webpcropdetect offered a width of 1916 with an x offset of 2. The arithmetic is simple: 1920 minus the offset of 2 minus the width of 1916 leaves 2 columns at the right edge, so the report means 2 columns on the left and 2 on the right were counted as dark enough to be bars. All I was after was the letterbox, so from that report I took only the height of 1004 and the y offset of 38, while the width stayed at the full 1920 with an x offset of 0. The rest is the encoder: libwebp with -q:v 85 and -preset photo, run as a batch over the 196 files.
Why the bars could not stay
This hero is not a <video> element. It is a canvas whose frames are drawn under ScrollTrigger. The painter uses the usual cover pattern:
const scale = Math.max(cw / iw, ch / ih);To a canvas, a letterbox that has become pixels is just content. The painter has no idea which rows are bars and which are picture. If the 1920×1080 PNGs had been converted as they were and shown in a 16:9 viewport, the scale would be exactly 1, and the 38px of black at the top and bottom would appear as part of the hero.
Once the bars are gone, that same painter needs no changes. From a 1920×1004 source into a 16:9 viewport of, say, 1920×1080, cw/iw is 1 and ch/ih is 1080 divided by 1004, about 1.076. Math.max picks 1.076, the image scales to roughly 2065×1080, and the extra width of about 145px is split between the left and right sides and cropped off. The viewport fills completely with no black, and all that is lost is a sliver on each side.
The numbers after conversion
The files at assets/seq-webp/frame_NNNN.webp were replaced wholesale, 291 old files out and 196 new files in. Because the renderer reads its frame count from the front page template, the data-total-frames="291" attribute there was changed to "196".
The encode came out at 196 frames totalling 24 MB, and that total is the actual folder size. The per-frame average of about 135KB in my notes is only an estimate, and it does not multiply out exactly to that total. My working notes wrote the baseline as 291 frames times roughly 900KB, about 262 MB, and called the reduction about 91%. The inventory at the start of the same wave recorded a different figure for the old sequence that was actually served: 291 lossless WebP frames at 1920x1080 weighing 167MB, converted from 291 master PNGs at 355MB. I never reconciled those two baselines in the notes, so I am reporting both. Against 167MB, 24 divided by 167 is roughly 0.14, so about 86% smaller. Either way the order of magnitude is the same.
What matters more at runtime is the preload. The renderer eagerly loads the first 30 frames and looks ahead 24 frames during scroll. With the old sequence averaging about 575KB per frame, those first 30 frames were 30 times 575KB, roughly 17 MB. With the new sequence, 30 times 135KB is roughly 4 MB.
The old sequence was lossless, and that was the client's call
The per-frame size gap above is not only about the 38px that got removed. The old sequence was encoded as lossless WebP at effort 6, because the client explicitly chose lossless over lossy, and it averaged around 575KB per frame. The converter back then was not ffmpeg but a sharp-based script at scripts/frames-to-webp.mjs.
Before the new sequence arrived, there was already an open note to monitor load metrics for that 167MB lossless asset, with lossy q90 at around 22MB as the fallback. The new sequence was encoded lossy at q=85 through ffmpeg, and its 24 MB lands in the same range as the fallback that had already been worked out.
Keep the source PNGs out of the deploy
There is one tail that is easy to forget. After the conversion, the assets/seq-src/ folder with its 196 PNGs at roughly 262MB was still sitting in the theme, even though the runtime only reads assets/seq-webp/ at 24MB. That folder was deleted during pre-deploy hardening on 2026-05-25, right before the production migration. The frame conversion itself was logged in an earlier wave without a date.
The lesson I wrote down at the time was short: source PNGs and optimized WebP, pick one for production. The WebP is the one that decodes fast and stays light on bandwidth, so the source PNGs must not enter the deploy package. The theme folder that had been 542MB, including node_modules, brand assets, source PNGs, and videos, came down to a 28MB production payload.
What I took away
- A letterbox baked into the pixels is no longer a ratio issue, it is content. A cover painter on a canvas will draw it like any other part of the image.
cropdetecthands you numbers you can use directly, but you are allowed to take only part of its report. Here the height and y offset were taken, and the width stayed full.- Cropping and format conversion can be a single ffmpeg command per frame, so there is no intermediate file.
- When the frame count changes, hunt down every place the number is hardcoded. Here it was a data attribute in the front page template.
- When two baselines in the notes disagree, report both with the arithmetic rather than picking the one that reads best.
- Source PNGs and converted WebP should never both ship to production.