Skip to content

Make the pre-roll move, and give it a voice - #291

Merged
ralyodio merged 1 commit into
masterfrom
preroll-rework
Sep 24, 2026
Merged

ralyodio merged 1 commit into
masterfrom
preroll-rework

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

You called the output garbage with no audio. Both fair — here's what was wrong and what changed.

It was a still image with a duration

Frames at 0.2s, 2s and 4s were indistinguishable. The copy sat in the lower-left of a 1920×1080 frame with the top half empty, and the entire motion budget was a 40px rise and a 4% scale spread across five seconds — invisible at that size.

Now:

  • Centred, filling the frame. Not bottom-anchored with half the frame empty.
  • Headline sized to its own length (148 / 116 / 88px). A 20-character headline and an 80-character one cannot share a font size on a fixed frame without one looking wrong.
  • Beats you can see: brand and an accent rule arrive, the headline builds a word at a time, the CTA lands with a small overshoot, and the artwork pushes 12% across the ad instead of 4%.

A rule I deliberately changed

The old test required the whole headline to be legible at frame 0 — "an ad that spends its first beat assembling itself says nothing for a tenth of its life." Honouring that literally is what produced an ad where nothing happened.

The rule is now narrower and still protects the opening instant: the brand is visible immediately (the poster is cut from frame 0), the copy finishes assembling inside the first quarter of the ad rather than half of it, and the CTA is on screen for roughly the back half. The test says so, with the reasoning.

Narration

The script is derived from the approved copy, never written afresh — an ad whose voiceover claims something its banner does not is a compliance problem, not a stylistic one. Headline, then the call to action with the destination:

"All fixtures, one feed. View feeds at nichedb.dev."

It's recorded in the snapshot, so it is part of the render hash: two campaigns with identical copy share one encode, and changing the copy changes the voiceover along with the picture. Captions already follow from the same field.

Synthesis is best-effort in the strict sense. No key, a non-200, an empty body, or a network error all return null and the silent cut ships. A pre-roll with no audio is a working ad; a render that fails because a speech API was rate-limited is not.

One bug found by its own test: an explicit null key fell through to the environment variable, because ?? collapsed "no key" and "use the configured key" into one case — so a caller asking for silence quietly performed a paid API call.

Cost

ElevenLabs currently shows 72,673 characters remaining this period. At ~50 characters a script, 174 campaigns is ~8,700 — comfortably inside it. ELEVENLABS_API_KEY is provisioned on the Railway service (value never printed).

Verification

10 new narration tests, plus the updated composition and snapshot rules. 2580 passed / 1 failed repo-wide — the pre-existing tracker-geo failure. Both typechecks clean.

The visual and audio result can only be judged on a real render, so I'll queue one after this deploys, download it, and look at it before saying it's fixed.

The rendered ads were a still image with a five-second duration. Frames at
0.2s, 2s and 4s were indistinguishable: the copy sat in the lower-left of a
1920x1080 frame with the top half empty, and the motion was a 40px rise and a
4% scale spread across the whole ad, which at that size is invisible.

The composition is now centred and fills the frame, the headline is sized to
its own length rather than fixed, and the animation has beats a viewer can
actually see: the brand and an accent rule arrive, the headline builds a word
at a time, the CTA lands with a small overshoot, and the artwork pushes 12%
across the ad instead of 4%.

The old test required the whole headline to be legible at frame 0. Honouring
that is what produced an ad where nothing happened, so the rule is now
narrower and still protects the opening instant: the brand is visible
immediately — the poster is cut from frame 0 — and the copy finishes
assembling inside the first quarter of the ad rather than half of it, with the
CTA on screen for roughly the back half.

Narration is new. The script is derived from the approved copy rather than
written afresh, because an ad whose voiceover claims something its banner does
not is a compliance problem: headline, then the call to action with the
destination, which is the whole of what five seconds can say. It is recorded in
the snapshot, so it is part of the render hash — two campaigns with identical
copy share one encode, and changing the copy changes the voiceover along with
the picture.

Synthesis is best effort in the strict sense: no key, a non-200, an empty body
or a network error all return null and the silent cut ships. A pre-roll with no
audio is a working ad; a render that fails because a speech API was rate
limited is not.

An explicit null key now means "no key" rather than falling through to the
environment. `??` had collapsed those two cases, so a caller asking for silence
quietly performed a paid API call.

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.

if (buf.byteLength === 0) return null;

const filePath = path.join(input.workDir, "narration.mp3");
await writeFile(filePath, buf);
Comment thread lib/ads/video/compose.ts
): string {
const headline = escapeHtml(snapshot.headline);
const headlineText = snapshot.headline;
const headline = escapeHtml(headlineText);
@ralyodio
ralyodio merged commit aae894a 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.

2 participants