Under 150 KB, or the log says so
150 000 bytes. Google's help page for uploaded display ads allows 150 KB per banner and never says which kilobyte. So the limit in MerryAd's config is the reading that passes both: 150 000, not 153 600. Over the limit, the upload is refused outright. So is a 728×91 file sent for a 728×90 slot. One brief has to become eight such files: 300×250, 336×280, 728×90, 970×250, 160×600, 300×600, 320×100 and 320×50.
MerryAd gets each picture from an AI image model, at the nearest ratio it offers.
A leaderboard is 8:1, the source is 21:9
Source pictures come in ten aspect ratios, from 9:16 to 21:9. The widest is 2.33 to 1. A leaderboard is 8.09 to 1. Each size gets the nearest ratio on offer, and the server closes the gap.
fit_banner_to_exact_pixels measures the gap as the relative difference between the two ratios. Up to 2.8%, the picture is simply scaled. That is the only stretch the code allows. Up to 17%, it gets a letterbox, with bars in the picture's own average colour. 300×250 comes from 5:4, 4.2% off, and gets a five-pixel band top and bottom.
Past 17%, targets wider than 3:1 or narrower than 0.34:1 take a progressive blend between letterbox and cover. The blend is the share of cover, and it is capped by size: 0.12 for 320×50, 0.30 for 728×90, 320×100 and 160×600, 0.45 for 970×250. Of the eight sizes, 300×250, 336×280 and 300×600 get a letterbox, and the other five get the blend. The other branch is a full cover crop, and none of the eight sizes reaches it today.
Filling the slot by cropping is the obvious move. On 728×90 from a 21:9 frame, it would keep 29% of the height. The blend keeps 57% and pays for it with side bars. That surviving band is handed upstream before the picture is drawn, so the headline and faces are placed inside it.
The cut goes through the quiet rows
Where a crop is needed, the window is chosen by edge energy. A greyscale copy is scaled to 480 px. For every row, the code sums the absolute differences between neighbouring pixels. The window is placed where the rows at its two edges score lowest, with a small pull toward the centre. The cut runs through sky or a plain wall, not through an eye.
On 970×250 the target also shifts up, with a top bias of 0.45: people usually sit in the upper part of the frame. That is the one size where edge energy runs today.
The narrow and short sizes skip it on purpose. On 160×600 the edge search kept slicing text at the sides, so the skyscraper is cropped from the centre, where the headline was drawn. Banners up to 120 px tall are cropped from the centre too.
PNG until it does not fit
encode_banner tries lossless PNG first. If it is over banner_max_bytes, the code walks a JPEG quality ladder: 95, 92, 90, 87, 85, 82, 80, 76, 72, 68, 64, 60. The first step that fits wins, so the file gets the best quality the limit allows. Usually the first step is enough: a 970×250 that weighs 212 KB as PNG comes out at 60 KB at quality 95.
Palette PNG would also shrink the file. We did not use it: banners are photographic, and 256 colours band on gradients. JPEG is saved with chroma subsampling off, because 4:2:0 blurs the edges of small coloured letters.
The ladder stops at 60. Below that, artefacts show on text. If even 60 does not fit, the file ships with a warning in the log, not a ruined picture.
The format is stored nowhere. sniff_image_kind reads the first bytes: the eight-byte PNG signature, or FF D8 for JPEG. Packs made before compression existed are plain PNG files on disk, with no format field to read. The .png or .jpg in a banner URL is part of the file name, not a promise.
A lost pack is refunded once
By default a pack costs 8 tokens, or 5 with your own saved Replicate key, taken when it starts. Regenerating one size costs 1 and is returned if it fails. The welcome balance is 9: one pack and one regeneration. On the public API, tokens are charged only when an asset comes back.
A pack runs as a background task inside the server process, so a deploy or a crash can kill it halfway. A sweeper catches that. At startup, every unfinished pack is marked failed and refunded, since no task survives a restart. After that, every five minutes, it does the same for packs still unfinished 30 minutes after they started. It also cancels the task, which would otherwise keep paying for images in a pack already refunded.
Two parties can refund a pack: the task, on its own error, and the sweeper. Their order is not defined, so "check, then insert" is not safe. The refund carries the idempotency key pack_refund:<session_id>, and a unique index in the token ledger rejects the second one. The user sees one line: "Generation was interrupted by a server restart; tokens have been refunded."