Skip to content

Render the five-second pre-roll: compositor, encoder, validation (package B) - #273

Merged
ralyodio merged 2 commits into
worktree-crawlproof-video-prerollfrom
crawlproof-video-render
Sep 24, 2026
Merged

ralyodio merged 2 commits into
worktree-crawlproof-video-prerollfrom
crawlproof-video-render

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Stacked on #272 (package A) — base is that branch, not master, so this diff shows only package B. Merge #272 first and this retargets cleanly.

A design snapshot now produces the media a pre-roll needs: three MP4 renditions, an fMP4 HLS package, a poster, and captions + an audible companion where there is narration. Nothing selects or serves it yet — that's package D.

Two properties the pipeline is built around

It is deterministic. The composition exposes window.__seek(frame) and every animated value is a pure function of that index. Nothing reads a clock; there's no CSS animation or rAF loop whose output would depend on how quickly Playwright got round to the screenshot. That's what lets a render be cached by a hash of its inputs without the hash being a lie. RENDERER_VERSION is part of that hash, so changing the look invalidates cached encodes instead of serving the old bytes forever. The snapshot stores artwork content hashes rather than URLs — the same URL can serve different bytes, and a URL-keyed cache would reuse a render of artwork the advertiser has since replaced.

It is closed. Artwork reaches the compositor only as a data: URI the caller already fetched and hashed; advertiser copy is escaped before entering a document a browser executes; the capture context aborts every request that isn't data:/blob:/about:. A renderer that fetched a URL for itself would be a request-forgery primitive running on our own network.

Validation asks the decoded output, not the job

Check Why it's stated that way
-count_frames "150 frames" means 150 decoded, not whatever the muxer wrote in the header
Exactly 30/1 29.97 fails — it drifts the quartile boundaries off the frames they name
One AAC frame of slack 21.333 ms at 48 kHz is legitimate rounding; two frames is the wrong length
GOP pinned to 60, -sc_threshold 0 HLS boundaries land on 0/2/4s regardless of artwork
HLS by stream copy Re-encoding would move the keyframes and ship media the advertiser never approved
No EXT-X-INDEPENDENT-SEGMENTS Our segments open on a keyframe but aren't independently decodable in the sense that tag asserts, and a player acts on the claim

The ad is never padded to six seconds to land on a round segment size.

Correctness details worth a reviewer's eye

  • Publishing is a compare-and-swap against requested_revision. Two quick edits queue revisions 4 and 5; if 4 finishes second, a naive write points the creative back at media for a design already replaced. Conditioned on requested_revision still being 4, the stale write matches no rows and is discarded.
  • Job ids are colon-free and derived only from immutable inputs. BullMQ parses a colon-bearing custom id as a structured key and rejects anything that isn't exactly three parts; an id derived from state the job resets would either stop deduping or collide with a finished job and drop the retry silently. renderJobId() throws rather than letting one through.
  • A narrated snapshot with no audio track is refused, rather than encoding a silent ad that gets recorded as an audible one.
  • Redis connection parsing moved out of lib/prober-queue into lib/redis-connection now that there are two queues. Prober behaviour unchanged.

Verification

tests/ads-video-pipeline.test.ts injects a synthetic frame capturer (ffmpeg testsrc2, so frames genuinely differ — 150 identical frames would compress to nothing and let a broken bitrate ladder pass) and then runs real ffmpeg and ffprobe:

  • 150 decoded frames at exactly 30 fps in all three renditions, h264/yuv420p, correct dimensions, each inside its byte budget
  • both HLS media playlists valid with EXT-X-MAP + EXT-X-ENDLIST, segments summing to 5000 ms
  • 720p-init.mp4 and .m4s segments actually on disk; multivariant playlist carries both resolutions
  • a 149-frame capture is rejected

Passed locally in ~14 s. It skips if ffmpeg/ffprobe are absent — if this CI runner has no ffmpeg, that test will not have run here, and a green check alone shouldn't be read as it passing. It logs a warning rather than skipping silently.

Totals: 2520 passed / 1 failed repo-wide. The failure is the pre-existing tests/contract/tracker-geo.test.ts (@ip-location-db/geolite2-city-mmdb not installed), unrelated to this diff. Root and worker typechecks both clean — the worker tsconfig covers files root tsc does not, and caught a real error here.

Not done here

No selection, decision, serving, accounting, dashboard workflow or API endpoint (packages C–F). The worker consumer is wired into worker/index.ts and ffmpeg is added to the worker Dockerfile, but nothing enqueues a render yet — that's package C.

…kage B)

A design snapshot now produces the media a pre-roll actually needs: three MP4
renditions, an fMP4 HLS package, a poster, and captions plus an audible
companion where there is narration to carry. Package B of the streaming-ads
spec. Nothing selects or serves this yet — that is package D.

Two properties the pipeline is built around.

It is deterministic. The composition exposes window.__seek(frame) and every
animated value is a pure function of that index; nothing reads a clock, and
there is no CSS animation or rAF loop whose output would depend on how quickly
Playwright got round to the screenshot. That is what lets a render be cached by
a hash of its inputs without the hash being a lie. RENDERER_VERSION is part of
that hash, so changing the look invalidates every cached encode rather than
serving the old bytes forever.

It is closed. Artwork reaches the compositor only as a data: URI the caller
already fetched and hashed, advertiser copy is escaped before it enters a
document a browser executes, and the capture context aborts every request that
is not data:/blob:/about:. A renderer that fetched a URL for itself would be a
request-forgery primitive running on our own network, and it would also make
renders depend on what that URL served today. The snapshot stores artwork
content hashes rather than URLs for the same reason: the same URL can serve
different bytes, and a cache keyed on the URL would reuse a render of artwork
the advertiser has replaced.

Validation asks the decoded output, never the job that produced it. ffprobe
runs with -count_frames, so "150 frames" means 150 frames decoded rather than
whatever the muxer wrote in the header; 29.97 fails where 30 passes; audio gets
exactly one AAC frame (21.333ms at 48kHz) of endpoint rounding and no more, and
the ad is never padded to six seconds to land on a round segment size. The GOP
is pinned to 60 frames with scene-change detection off so the HLS boundaries
land on 0/2/4s regardless of artwork, and HLS is packaged by stream copy so the
segments carry the media the advertiser approved.
EXT-X-INDEPENDENT-SEGMENTS is deliberately never emitted: our segments open on
a keyframe but are not independently decodable in the sense that tag asserts,
and a player acts on the claim.

Publishing a revision is a compare-and-swap against requested_revision. Two
quick edits queue revisions 4 and 5; if 4 finishes second, a naive write would
point the creative back at media for a design already replaced. Conditioned on
requested_revision still being 4, the stale write matches no rows and is
discarded.

Render jobs dedupe on a hash of immutable inputs only, and the BullMQ job id is
colon-free — bullmq parses a colon-bearing custom id as a structured key and
rejects anything that is not exactly three parts, and an id derived from state
the job itself resets would either stop deduping or collide with a finished job
and drop the retry silently.

The Redis connection parser moved out of lib/prober-queue into
lib/redis-connection now that there are two queues; prober behaviour is
unchanged.

Frame capture is injected rather than imported so Playwright stays in the
worker image, and so the pipeline can be driven in a test without Chromium.
tests/ads-video-pipeline.test.ts does exactly that and then runs real ffmpeg:
150 decoded frames at 30fps in every rendition, each inside its byte budget,
segments summing to five seconds, init map and ENDLIST present. The spec is
explicit that a filename or a five-second timer is not evidence a pre-roll
works, so the one test that could have been faked is the one that is not.

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.

tests/ads-video-pipeline.test.ts skips itself when ffmpeg/ffprobe are absent,
which is right for a developer machine and wrong for CI: the one test that
proves a pre-roll decodes to 150 frames at 30fps would quietly never run, and
a green check would say nothing about it. Installing the binaries makes the
check mean what it appears to mean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio marked this pull request as ready for review September 24, 2026 12:37
@ralyodio
ralyodio merged commit e9c2e37 into worktree-crawlproof-video-preroll Sep 24, 2026
4 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