Skip to content

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

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

ralyodio merged 2 commits into
masterfrom
crawlproof-video-render

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Package B of the Crawlproof five-second streaming ads spec, retargeted at master.

Why this PR exists: #273 merged package B into the package-A branch, but #272 had already squash-merged package A to master — so package B landed on a branch that was no longer going anywhere and never reached master. This branch is rebased onto current master, so it replays only package B (no package-A content is re-applied; lib/ads/formats.ts and the migration are untouched here).

A design snapshot 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 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, on this branch rebased onto current master. That test skips itself when ffmpeg is absent, so this PR also adds an ffmpeg install step to ci.yml — otherwise the one test that proves a pre-roll decodes to 150 frames would quietly never run in CI and a green check would say nothing about it.

Repo-wide: 2520 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 to this diff. Root and worker typechecks both clean.

Not live after this merges

Merging this does not make video pre-roll live. Package A's migration is still unapplied (prod history diverged; it needs psql over the pooler), nothing enqueues a render yet, and there is no decision endpoint, selection, player integration or accounting — packages C–F.

ralyodio and others added 2 commits September 24, 2026 12:39
…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>
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>
@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.

Comment thread lib/ads/video/render.ts

const master = multivariantPlaylist(renditions);
const masterPath = path.join(hlsDir, "master.m3u8");
await writeFile(masterPath, master, "utf8");
Comment thread lib/ads/video/render.ts
// 5. Captions and the audible companion, when there is narration to carry.
if (narrated && snapshot.narration) {
const vttPath = path.join(outDir, "captions.vtt");
await writeFile(vttPath, narrationVtt(snapshot.narration), "utf8");
@ralyodio
ralyodio merged commit 84e868a into master Sep 24, 2026
9 of 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.

2 participants