Render an animated banner for each display size - #283
Merged
Merged
Conversation
Adds three looping GIFs to every render: 300x250, 728x90 and 320x50, the same IAB units the static creatives already use, so a publisher slot that takes the static banner takes the animated one with no layout change. They come off the same design snapshot as the pre-roll, so a campaign reads as one thing across its video and its display units, and the motion is deliberately the pre-roll's rather than a second vocabulary: a short entrance rise, a slow accent drift through the hold, a CTA that gains emphasis on the final beat, plus one specular sweep. An advertiser who has seen their video should recognise the banner as the same campaign. What does not carry over is the layout. The pre-roll lays out once at 1920x1080 and lets each rendition downscale the same pixels; a banner cannot work that way, because 320x50 is not a scaled 300x250 — it is a different composition with different copy priorities. Each unit is laid out at its native size, mirroring the static creative it replaces: the rectangle stacks, the leaderboard and mobile banner run as a row, and the mobile banner drops the body line it has no room for. The snapshot gains a subhead so the banners can show the same approved second line the static units show, instead of inventing one. The pre-roll ignores it: five seconds of 1920x1080 is a headline and a CTA. Encoding is two-pass. GIF carries 256 colours and a default palette is built from the first frame alone, so an accent that only appears once the CTA lifts has no palette entry and bands badly; palettegen with stats_mode=diff weighs what actually changes. Dithering is ordered rather than error-diffused, because Floyd-Steinberg noise differs frame to frame, defeats inter-frame compression and can double the file for a banner that barely moves. diff_mode=rectangle stores only the changed region per frame. 150KB is the budget — the ceiling most ad networks enforce, so a file over it is not an asset regardless of what this codebase would accept. Flat brand colour is what makes 50 frames affordable at all. A banner that fails to encode does not fail the revision. The video is what the advertiser is waiting for, and the problems are recorded either way. RENDERER_VERSION goes to 2. Both the new banners and the HLS codec correction are baked into rendered output rather than read at serve time, so the only way to apply them to the 174 existing revisions is to render again — which is what changing the version does, since it feeds the dedupe hash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThreatCrush Security Scan48 finding(s) HIGH/CRITICAL: 2 | MEDIUM: 31 | LOW: 15
Snippets are redacted; ThreatCrush never prints matched credential material. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds three looping GIFs to every render — 300×250, 728×90, 320×50 — the same IAB units the static creatives already use, so a publisher slot that takes the static banner takes the animated one with no layout change.
Our style, not the reference's
The reference you linked is a multi-scene product-screenshot ad. This isn't a copy of it — the motion is deliberately the pre-roll's, so a campaign reads as one thing across its video and its display units: a short entrance rise, a slow accent drift through the hold, a CTA that gains emphasis on the final beat, plus one specular sweep. An advertiser who has seen their video should recognise the banner as the same campaign.
They come off the same design snapshot as the pre-roll, so the palette, headline, CTA and mark are the approved ones.
What does not carry over: the layout
The pre-roll lays out once at 1920×1080 and lets each rendition downscale the same pixels. A banner can't work that way — 320×50 is not a scaled 300×250, it's a different composition with different copy priorities. Each unit is laid out at its native size, mirroring the static creative it replaces: the rectangle stacks, the leaderboard and mobile banner run as a row, and the mobile banner drops the body line it has no room for.
The snapshot gains a
subheadso the banners show the same approved second line the static units show rather than inventing one. The pre-roll ignores it — five seconds of 1920×1080 is a headline and a CTA.Encoding, and why it's two-pass
GIF carries 256 colours and a default palette is built from the first frame alone — so an accent that only appears once the CTA lifts has no palette entry and bands badly.
palettegen=stats_mode=diffweighs what actually changes.Dithering is ordered, not error-diffused: Floyd-Steinberg noise differs frame to frame, defeats GIF's inter-frame compression, and can double the file for a banner that barely moves.
diff_mode=rectanglestores only the changed region per frame.150KB is the budget — the ceiling most ad networks enforce, so a file over it isn't an asset regardless of what this codebase would accept. Flat brand colour is what makes 50 frames affordable at all.
A banner that fails to encode does not fail the revision. The video is what the advertiser is waiting for; the problems are recorded either way.
Loop correctness
50 frames cover
[0, 4000)at 80ms each — 12.5fps because GIF stores delays in hundredths of a second, so 8cs is exact where 12 or 15fps would drift and make the loop stutter. The final frame sits at 3920ms, deliberately not at 4000ms: that mark is frame 0 of the next loop, and a timeline with a frame exactly on the end renders the loop point twice and hitches once per cycle. The sweep is off-unit at both ends so the wrap is invisible. All three are asserted.Verification
12 new tests. The end-to-end one drives real ffmpeg with a synthetic capturer and checks each output is inside its budget, decodes to exactly 50 frames at the right size, starts with a
GIF87a/GIF89asignature, ends with the0x3Bterminator a truncated write would lack, and contains theNETSCAPE2.0block that declares an infinite loop.Repo-wide: 2563 passed / 1 failed — the pre-existing
tracker-geofailure (mmdb not installed locally). Root and worker typechecks clean.I could not render a real browser frame locally: Playwright refuses to install on ubuntu 26.04 and the cached Chromium is missing
libatk. The encode, probe, budget and loop logic are proven by the tests above; the visual check happens against production after deploy, on the real renderer, and I'll report what it looks like before calling this done.RENDERER_VERSION → 2
Both the new banners and the earlier HLS codec correction are baked into rendered output rather than read at serve time, so the only way to apply them to the 174 existing revisions is to render again — which is exactly what changing the version does, since it feeds the dedupe hash. Re-running the backfill after this deploys will re-render all 174 with banners and correct playlists, roughly 3-4 hours.
Migration
20260924160000_ad_video_gif_profiles.sqlwidens thead_video_assetsprofile check; already applied to production.