Skip to content

Queue a pre-roll render when a campaign is saved or edited (package C) - #275

Merged
ralyodio merged 1 commit into
masterfrom
package-c-video-workflow
Sep 24, 2026
Merged

ralyodio merged 1 commit into
masterfrom
package-c-video-workflow

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Package C of the Crawlproof five-second streaming ads spec. Something finally calls the render pipeline.

Saving a campaign creates its video creative and queues a render; editing copy or regenerating bumps the revision and queues another; the campaign page shows progress and, once ready, a Download MP4 button.

Spec exit condition for C: Generate Ads automatically creates video; download works before activation.

The rule it's built around

A render is an extra output of saving a campaign, never a precondition for one:

  • queueCampaignVideo returns null on every failure rather than throwing
  • the job row is written before the enqueue is attempted, so a missing REDIS_URL loses throughput rather than the request
  • no path waits on an encode

An advertiser saving a campaign gets exactly the response they always did, even if rendering is completely down.

Design decisions worth a reviewer's eye

The snapshot comes from copy the advertiser already approved, not a fresh generation pass. A campaign whose banner and pre-roll make different claims is a compliance problem, not a design inconsistency. The medium rectangle is the preferred source and banner_320x50 is avoided — for the same reason the feed backfill avoids it: that format carries the shortened mobile headline, and a 1920×1080 frame has no width problem that would justify truncated copy.

Headlines are re-clipped for video. Display headlines are capped by characters; a pre-roll has to be legible at 480p in about three seconds. trimHeadlineForVideo clips to eight words on a word boundary, and a test asserts the clipped result actually satisfies validateSnapshot — otherwise every long-headline campaign would queue a job the renderer then refuses.

Artwork is deliberately not carried yet. Recording a hero URL with a null content hash would be worse than recording neither: the dedupe key trusts the hash, and a URL beside a null hash invites a later change to treat the URL as sufficient — when the same URL can serve different bytes. The compositor uses its accent-tinted fallback until hashing lands.

Dedupe is on the render hash, not the campaign, so a design previewed, saved, then regenerated without edits is one encode. The unique index on (render_hash, output_profile) makes that hold under concurrency; select-then-insert is the fast path, the conflict re-select is the correct one.

ensureVideoCreative bumps requested_revision on edits and leaves published_revision alone. That bump is what gives the worker's compare-and-swap something to compare — two quick edits produce revisions N and N+1, and a slow render of N publishes nothing when it lands second. Writing published_revision here would mark a creative servable before any bytes existed.

A leak this package would otherwise have introduced

The campaign detail page maps ad_creatives rows straight into <AdPreview>, which renders markup — so the video creative row this package creates would have been drawn as a banner there. The dashboard forms already iterate DESIGN_FORMATS for exactly that reason, but the detail page reads from the database and wasn't covered by package A. Fixed here with isStreamingFormat, and the render card takes its place in the layout.

Verification

18 new tests in tests/ads-video-jobs.test.ts covering snapshot derivation and source preference, headline clipping, dedupe/reuse, row-before-enqueue, Redis-unavailable, snapshot rejection, revision bump semantics, and the download-vs-streamable distinction.

Repo-wide: 2538 passed / 1 failed — the failure is the pre-existing tests/contract/tracker-geo.test.ts (@ip-location-db/geolite2-city-mmdb not installed locally), unrelated. Root and worker typechecks both clean.

Not done here

No selection, decision endpoint, serving, player integration or accounting — packages D–F. A rendered MP4 is downloadable by its advertiser; nothing can serve one to a viewer yet, and published_revision is still only ever set by the worker after ffprobe validates an encode.

Preview-before-save renders (the spec's owner-scoped preview job from the Generate Ads screen) are not wired yet either — the render is queued at save time. ensureRenderJob already supports a null campaign for that, so it's a small follow-up.

Something finally calls the render pipeline. Saving a campaign now creates its
video creative and queues a render; editing copy or regenerating bumps the
revision and queues another; the campaign page shows progress and, once ready,
a Download MP4 button.

The rule this is built around is that a render is an extra output of saving a
campaign, never a precondition for one. queueCampaignVideo returns null on
every failure rather than throwing, the row is written before the enqueue is
attempted so a missing REDIS_URL loses throughput rather than the request, and
no path waits on an encode. An advertiser who saves a campaign gets the same
response they always did.

The snapshot is derived from a creative the advertiser already approved rather
than generated afresh, so the pre-roll makes the same claim in the same palette
as the display ads. The medium rectangle is preferred as the source and
banner_320x50 avoided, for the same reason the feed backfill avoids it: that
format carries the shortened mobile headline, and a 1920x1080 frame has no
width problem that would justify truncated copy. Display headlines are capped
by characters, which is a different constraint from what five seconds can hold,
so trimHeadlineForVideo clips to eight words on a word boundary.

Artwork is deliberately not carried yet. Recording a hero URL with a null
content hash would be worse than recording neither: the dedupe key trusts the
hash, and a URL sitting beside a null hash invites a later change to treat the
URL as sufficient when the same URL can serve different bytes. The compositor
renders its accent-tinted fallback until hashing lands.

Dedupe is on the render hash, not the campaign, so a design previewed, saved,
then regenerated without edits is one encode. The unique index on (render_hash,
output_profile) is what makes that hold under concurrency; the select-then-
insert is the fast path and the conflict re-select is the correct one.

ensureVideoCreative bumps requested_revision on edits and leaves
published_revision alone. That bump is what gives the worker's compare-and-swap
something to compare: two quick edits produce revisions N and N+1, and a slow
render of N publishes nothing when it lands second. Writing published_revision
here would mark a creative servable before any bytes existed.

Also fixes a leak this package would otherwise have introduced. The campaign
detail page maps ad_creatives rows straight into <AdPreview>, which renders
markup — so the video creative row this package creates would have been drawn
as a banner there. The dashboard forms already iterate DESIGN_FORMATS for that
reason, but the detail page reads from the database and was not covered.

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