Skip to content

Fix the banner size, the byte budget and the scope - #288

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

ralyodio merged 1 commit into
masterfrom
gif-fixes

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Three defects in the animated banners, all visible in the first real renders.

1. Every unit was two pixels short

298×248 for a 300×250, 726×88 for a 728×90. The capturer screenshots .stage, and .stage sat on the inner content div — inside the unit's 1px border.

An off-size creative is rejected outright by ad networks, so this was the difference between an asset and a file. .stage is now the unit itself.

2. Over the byte budget, and not for the reason I assumed

327 KB for the rectangle against a 150 KB ceiling. The cause wasn't the frame count — it was that everything moved on every frame. A full-width sweep running the whole loop, plus a drifting background wash, changed every pixel of every frame, so GIF's per-frame rectangle diff had nothing to elide. I was paying full price for 50 frames.

Motion is now confined to three short windows — entrance, one sweep, the CTA beat — with the unit at rest between them. That reads as a deliberate beat rather than a limitation. The wash is static: animating it cost most of the file for motion nobody could see.

10fps / 40 frames rather than 12.5 / 50, because 10fps is exactly 10 centiseconds and GIF stores delays in hundredths.

A test now asserts most of the loop is perfectly still. That's a size requirement wearing a timeline's clothes — lose it and the banners quietly stop fitting.

3. Scope: blog and social campaigns were getting media

You said product ads only. That was enforced by the backfill alone — the dashboard save and the shared creator behind the public API queued a render for any campaign. The first banner to render successfully in production advertised a dev.to article.

The rule now lives in queueCampaignVideo, where all three paths pass through — the same lesson as the API gap: a rule enforced at one of three call sites holds until someone adds a fourth. A caller that passes no destination still renders, so a forgotten argument produces an extra video rather than a campaign that mysteriously never gets one.

Verification

2570 passed / 1 failed repo-wide — the pre-existing tracker-geo failure. Both typechecks clean. Four new scope tests (blog skipped, social skipped, product rendered, unknown destination rendered) and the stillness assertion.

The byte reduction can only be measured against real rendered frames, which needs the container's Chromium — I'll queue a live render after this deploys and report the actual sizes and dimensions before calling it done.

Three defects in the animated banners, all visible in the first real renders.

Size. Every unit came out two pixels short in each dimension — 298x248 for a
300x250 — because the capturer screenshots `.stage` and `.stage` was on the
inner content div, inside the unit's 1px border. An off-size creative is
rejected outright by ad networks, so this was the difference between an asset
and a file. `.stage` is now the unit itself.

Bytes. 327KB for the rectangle against a 150KB ceiling. The cause was not the
frame count, it was that everything moved on every frame: a full-width sweep
running the whole loop, plus a drifting background wash, changed every pixel of
every frame, so GIF's per-frame rectangle diff had nothing to elide. Motion is
now confined to three short windows — entrance, one sweep, the CTA beat — with
the unit at rest between them, which reads as a deliberate beat rather than a
limitation. The wash is static; animating it cost most of the file for motion
nobody could see. 10fps and 40 frames rather than 12.5 and 50, because 10fps is
exactly 10 centiseconds and GIF stores delays in hundredths.

A test now asserts that most of the loop is perfectly still. That is a size
requirement wearing a timeline's clothes: lose it and the banners quietly stop
fitting.

Scope. Product ads only was being enforced by the backfill alone. The dashboard
save and the shared creator behind the public API queued a render for any
campaign, so campaigns pointing at blog posts and social profiles were getting
video and animated banners that were explicitly out of scope — the first banner
rendered in production advertised a dev.to article. The rule now lives in
queueCampaignVideo, where all three paths pass through, for the same reason the
API gap taught: a rule enforced at one of three call sites holds only until
someone adds a fourth. A caller that passes no destination still renders, so a
forgotten argument produces an extra video rather than a campaign that
mysteriously never gets one.

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 cacc861 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