Skip to content

Render an animated banner for each display size - #283

Merged
ralyodio merged 1 commit into
masterfrom
animated-gif-ads
Sep 24, 2026
Merged

ralyodio merged 1 commit into
masterfrom
animated-gif-ads

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

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 subhead so 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=diff weighs 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=rectangle stores 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/GIF89a signature, ends with the 0x3B terminator a truncated write would lack, and contains the NETSCAPE2.0 block that declares an infinite loop.

Repo-wide: 2563 passed / 1 failed — the pre-existing tracker-geo failure (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.sql widens the ad_video_assets profile check; already applied to production.

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>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

48 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 31 | LOW: 15

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:48
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM js-dynamic-code-execution lib/crawl-limits.ts:67
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:41
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:324
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:99
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:14
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:24
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:20
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:24
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:25
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:26
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:31
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:35
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit dc43831 into master Sep 24, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant