Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 93 additions & 23 deletions lib/ads/video/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,18 @@
snapshot: VideoDesignSnapshot,
assets: ComposeAssets = { logo: null, hero: null },
): string {
const headline = escapeHtml(snapshot.headline);
const headlineText = snapshot.headline;
const headline = escapeHtml(headlineText);
// Long copy has to come down or it wraps into the CTA; short copy should fill
// the frame rather than float in the middle of it.
const headlineSize = headlineText.length > 46 ? 88 : headlineText.length > 28 ? 116 : 148;
// One span per word so each can enter on its own beat. Split on whitespace
// only — a word is never broken, so no headline is ever half-drawn.
const headlineWords = headlineText
.split(/\s+/)
.filter(Boolean)
.map((w, i) => `<span class="w" data-i="${i}">${escapeHtml(w)}</span>`)
.join(" ");
const cta = escapeHtml(snapshot.ctaText);
const domain = escapeHtml(snapshot.domain);
const logo = safeDataUri(assets.logo);
Expand Down Expand Up @@ -152,24 +163,34 @@
radial-gradient(120% 120% at 20% 0%, ${accent}22 0%, transparent 60%),${bg}}
.scrim{position:absolute;inset:0;
background:linear-gradient(180deg,${bg}1a 0%,${bg}99 62%,${bg}f2 100%)}
.copy{position:absolute;left:192px;right:192px;bottom:108px;
display:flex;flex-direction:column;gap:28px}
.brand{display:flex;align-items:center;gap:22px}
.logo{width:84px;height:84px;object-fit:contain;border-radius:18px}
/* Centred, not bottom-anchored. Anchored at the bottom the composition left
the top half of a 1920x1080 frame empty, which is what made a five-second
pre-roll read as a slide rather than an ad. */
.copy{position:absolute;left:160px;right:160px;top:50%;transform:translateY(-50%);
display:flex;flex-direction:column;gap:40px}
.brand{display:flex;align-items:center;gap:24px}
.logo{width:96px;height:96px;object-fit:contain;border-radius:20px}
.logo--mono{display:grid;place-items:center;background:${accent};color:${bg};
font-size:46px;font-weight:800}
.domain{font-size:34px;letter-spacing:.02em;opacity:.86}
h1{font-size:104px;line-height:1.05;font-weight:800;letter-spacing:-.02em;
text-shadow:0 1px 2px ${bg}e6,0 0 12px ${bg}bf}
.cta{display:inline-flex;align-items:center;gap:18px;align-self:flex-start;
background:${accent};color:${bg};font-size:40px;font-weight:700;
padding:22px 40px;border-radius:999px}
font-size:52px;font-weight:800}
.domain{font-size:38px;letter-spacing:.02em;opacity:.86}
/* Sized to the copy. A 20-character headline and an 80-character one cannot
share a font size on a fixed frame without one of them looking wrong. */
h1{font-size:${headlineSize}px;line-height:1.02;font-weight:800;letter-spacing:-.025em;
text-shadow:0 2px 6px ${bg}e6,0 0 18px ${bg}bf}
/* Each word animates in on its own, which is what makes the frame visibly
progress instead of holding one pose for five seconds. */
.w{display:inline-block;will-change:transform,opacity}
.cta{display:inline-flex;align-items:center;gap:20px;align-self:flex-start;
background:${accent};color:${bg};font-size:44px;font-weight:700;
padding:26px 48px;border-radius:999px}
.rule{height:8px;width:160px;background:${accent};border-radius:999px;transform-origin:left center}
</style></head>
<body><div class="stage">
${heroLayer}
<div class="copy">
<div class="brand">${mark}<span class="domain">${domain}</span></div>
<h1>${headline}</h1>
<div class="rule"></div>
<h1>${headlineWords}</h1>
<div class="cta">${cta}</div>
</div>
</div>
Expand All @@ -185,21 +206,70 @@
var copy = document.querySelector('.copy');
var heroEl = document.querySelector('.hero');
var ctaEl = document.querySelector('.cta');
var brandEl = document.querySelector('.brand');
var ruleEl = document.querySelector('.rule');
var words = Array.prototype.slice.call(document.querySelectorAll('.w'));

function clamp01(v){ return v < 0 ? 0 : v > 1 ? 1 : v; }

// The whole animation, as a pure function of the frame index. Playwright calls
// this, waits for the returned promise to settle, then screenshots.
//
// Every value below is deliberately large. The previous version moved the copy
// 40px and scaled the artwork 4% across five seconds, which at 1920x1080 is
// invisible: frames one second apart were indistinguishable and the result was
// a still image with a duration. An ad has to visibly progress.
window.__seek = function (frame) {
var s = frameState(frame, REDUCED);
// Entrance: copy rises 40px and fades in over the first beat, from an
// already-legible starting opacity rather than from nothing.
copy.style.transform = 'translateY(' + ((1 - s.entrance) * 40).toFixed(3) + 'px)';
copy.style.opacity = (0.55 + 0.45 * s.entrance).toFixed(4);
// Drift: a 4% slow push on the artwork across the hold. Enough to stop the
// frame reading as a still, small enough not to pull the eye off the copy.
heroEl.style.transform = 'scale(' + (1 + 0.04 * s.drift).toFixed(4) + ')';
// CTA: settles into place and brightens over the final beat.
ctaEl.style.transform = 'scale(' + (0.96 + 0.04 * s.cta).toFixed(4) + ')';
ctaEl.style.filter = 'brightness(' + (0.9 + 0.1 * s.cta).toFixed(4) + ')';
var t = frameTimeMs(frame);

if (REDUCED) {
// Everything at rest, immediately. A reduced-motion ad still ends on its
// CTA; it simply never travels there.
brandEl.style.opacity = 1; brandEl.style.transform = 'none';
ruleEl.style.transform = 'scaleX(1)';
words.forEach(function (w) { w.style.opacity = 1; w.style.transform = 'none'; });
ctaEl.style.opacity = 1; ctaEl.style.transform = 'none';
heroEl.style.transform = 'none';
void stage.offsetHeight;
return document.fonts ? document.fonts.ready : Promise.resolve();
}

// Beat 1 (0-700ms): the brand arrives and the accent rule draws across.
var b = easeOut(clamp01(t / 500));
// Floored, not faded from zero. Frame 0 has to show the advertiser's mark and
// domain: the poster is cut from it, and a viewer who sees only the opening
// instant should still know whose ad this is.
brandEl.style.opacity = (0.35 + 0.65 * b).toFixed(3);
brandEl.style.transform = 'translateX(' + ((1 - b) * -60).toFixed(2) + 'px)';
ruleEl.style.transform = 'scaleX(' + b.toFixed(3) + ')';

// Beat 2 (0-1200ms): the headline builds a word at a time, each rising into
// place. Staggered so the eye is led along the line rather than shown a block.
//
// The whole build finishes inside the first quarter of the ad, and the first
// word starts at t=0. An earlier version of this composition took 2.6s to
// assemble, which spends half a five-second ad saying nothing — the same
// objection that made the previous, motionless version wrong, in the other
// direction.
var per = words.length > 1 ? 800 / (words.length - 1) : 0;
words.forEach(function (w, i) {
var wp = easeOut(clamp01((t - i * per) / 420));
w.style.opacity = wp.toFixed(3);
w.style.transform = 'translateY(' + ((1 - wp) * 54).toFixed(2) + 'px)';
});

// Beat 3 (1900-2600ms): the CTA arrives, overshooting slightly so it lands
// rather than fades. Early enough that it is on screen for nearly half the
// ad, which is the part a viewer is meant to act on.
var c = easeOut(clamp01((t - 1900) / 700));
ctaEl.style.opacity = c.toFixed(3);
ctaEl.style.transform = 'translateY(' + ((1 - c) * 40).toFixed(2) + 'px) scale(' + (0.9 + 0.1 * c).toFixed(3) + ')';

// Throughout: a real push on the artwork. 12% over five seconds is visible
// without pulling the eye off the copy; 4% was not.
heroEl.style.transform = 'scale(' + (1 + 0.12 * (t / 5000)).toFixed(4) + ')';

// Force layout so the screenshot cannot catch a half-applied style.
void stage.offsetHeight;
return document.fonts ? document.fonts.ready : Promise.resolve();
Expand Down
18 changes: 15 additions & 3 deletions lib/ads/video/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type { SupabaseClient } from "@supabase/supabase-js";
import { classifyCampaign } from "./classify";
import { narrationScript } from "./narration";
import type { AdCreative } from "../formats";
import { VIDEO_FORMAT_ID } from "../formats";
import { MAX_HEADLINE_WORDS, renderHash, validateSnapshot, type VideoDesignSnapshot } from "./snapshot";
Expand Down Expand Up @@ -81,7 +82,7 @@ export function snapshotFromCreatives(args: {
.find((c): c is NonNullable<typeof c> => !!c);
if (!source) return null;

return {
const base = {
headline: trimHeadlineForVideo(source.headline),
// The animated banners show this as their second line, matching the static
// unit. The pre-roll ignores it: five seconds of 1920x1080 is a headline
Expand All @@ -102,11 +103,22 @@ export function snapshotFromCreatives(args: {
logoSha256: null,
heroUrl: null,
heroSha256: null,
audioMode: "silent",
narration: null,
// Narrated. The script is derived from the same approved copy the frame
// shows, deterministically, 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.
//
// Declaring it here rather than when the audio comes back matters — the
// hash has to describe what was asked for, not what a speech API happened
// to return on the day. A synthesis failure ships the silent cut without
// making it a different render.
audioMode: "narrated" as const,
narration: null as string | null,
locale: args.locale ?? "en",
reducedMotion: args.reducedMotion ?? false,
};

return { ...base, narration: narrationScript(base) };
}

export type RenderHandle = {
Expand Down
116 changes: 116 additions & 0 deletions lib/ads/video/narration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Spoken narration for a pre-roll.
//
// The script is derived from the copy the advertiser already approved, never
// generated 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, which is the whole of what five seconds can say.
//
// Synthesis is deliberately best-effort. A pre-roll with no audio is a working
// ad; a render that fails because a speech API was rate-limited is not. Every
// failure path here returns null and the caller ships the silent cut.

import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { VideoDesignSnapshot } from "./snapshot";

/** Roughly what a five-second read fits, at an unhurried pace. */
export const MAX_NARRATION_CHARS = 140;

const API = "https://api.elevenlabs.io/v1/text-to-speech";

/**
* A default voice id.
*
* Overridable per deployment, because the right voice is a brand decision and
* not something this module should hold an opinion about.
*/
const DEFAULT_VOICE = process.env.ELEVENLABS_VOICE_ID ?? "21m00Tcm4TlvDq8ikWAM";

/**
* The line to speak.
*
* Kept to one sentence plus a call to action. The headline is spoken as written
* — it is the approved claim — and the domain is spoken last so the thing a
* listener is meant to remember is the last thing they hear.
*/
export function narrationScript(snapshot: VideoDesignSnapshot): string {
const headline = snapshot.headline.trim().replace(/\s+/g, " ");
const cta = snapshot.ctaText.trim().replace(/\s+/g, " ");
const domain = snapshot.domain.trim();

// A headline that already ends in punctuation should not gain a second full
// stop; one that does not needs one, or the two clauses run together.
const head = /[.!?]$/.test(headline) ? headline : `${headline}.`;
const line = `${head} ${cta} at ${domain}.`;

// Truncated on a word boundary rather than mid-word: a voice cut off in the
// middle of a word is worse than a shorter line.
if (line.length <= MAX_NARRATION_CHARS) return line;
const clipped = line.slice(0, MAX_NARRATION_CHARS);
const lastSpace = clipped.lastIndexOf(" ");
return `${clipped.slice(0, lastSpace > 0 ? lastSpace : clipped.length).trim()}.`;
}

export type NarrationResult = {
filePath: string;
script: string;
byteSize: number;
};

/**
* Synthesise the narration, or return null.
*
* Null covers every failure: no API key configured, a non-200 response, an
* empty body, a network error. The caller renders silent. This is the same rule
* the rest of the pipeline follows — a render is an extra output of saving a
* campaign, and the audio is an extra output of the render.
*/
export async function synthesiseNarration(input: {
snapshot: VideoDesignSnapshot;
workDir: string;
apiKey?: string | null;
voiceId?: string;
fetchImpl?: typeof fetch;
}): Promise<NarrationResult | null> {
// `undefined` means "use the configured key"; an explicit null means "there
// is no key", which is what a caller says when it wants the silent cut. `??`
// collapsed those two into one and made an explicit null fall through to the
// environment — so asking for silence quietly performed a paid API call.
const apiKey =
input.apiKey === undefined ? (process.env.ELEVENLABS_API_KEY ?? null) : input.apiKey;
if (!apiKey) return null;

const script = narrationScript(input.snapshot);
if (!script.trim()) return null;

const doFetch = input.fetchImpl ?? fetch;
const voice = input.voiceId ?? DEFAULT_VOICE;

try {
const res = await doFetch(`${API}/${voice}`, {
method: "POST",
headers: {
"xi-api-key": apiKey,
"content-type": "application/json",
accept: "audio/mpeg",
},
body: JSON.stringify({
text: script,
model_id: "eleven_turbo_v2_5",
voice_settings: { stability: 0.5, similarity_boost: 0.75 },
}),
});
if (!res.ok) return null;

const buf = Buffer.from(await res.arrayBuffer());
// A 200 with no body is a failure wearing a success code, and muxing zero
// bytes produces a file ffmpeg will reject much later with a worse message.
if (buf.byteLength === 0) return null;

const filePath = path.join(input.workDir, "narration.mp3");
await writeFile(filePath, buf);
return { filePath, script, byteSize: buf.byteLength };
} catch {
return null;
}
}
22 changes: 14 additions & 8 deletions tests/ads-video-compose-validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,20 @@ describe("the composition is deterministic", () => {
expect(timeline(false)).toHaveLength(PREROLL_FRAMES);
});

it("has the headline legible from the very first frame", () => {
// The entrance animates around copy that is already readable. A five-second
// ad that spends its first beat assembling itself says nothing for a tenth
// of its life.
const f0 = frameState(0, false);
expect(f0.entrance).toBe(0);
// ...and the document's opacity floor for the copy block is 0.55, not 0.
expect(composeDocument(snapshot)).toContain("0.55 + 0.45 * s.entrance");
it("says who is advertising from the very first frame, and finishes early", () => {
// The rule this replaces required the whole headline to be legible at frame
// 0. Honouring it produced an ad where nothing visibly happened across five
// seconds — frames a second apart were indistinguishable, which is its own
// way of saying nothing. The rule is now narrower and still protects the
// opening instant: the brand is visible immediately, and the copy finishes
// assembling inside the first quarter of the ad rather than half of it.
const doc = composeDocument(snapshot);
expect(doc).toContain("0.35 + 0.65 * b");
// The headline build completes well before the midpoint.
expect(doc).toContain("800 / (words.length - 1)");
expect(doc).toContain("(t - i * per) / 420");
// And the CTA lands early enough to be on screen for roughly the back half.
expect(doc).toContain("(t - 1900) / 700");
});

it("runs its three beats in order and finishes settled", () => {
Expand Down
15 changes: 12 additions & 3 deletions tests/ads-video-jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,18 @@ describe("the snapshot is derived from approved copy", () => {
domain: "nichedb.dev",
})!;
expect(validateSnapshot(snap)).toEqual([]);
// Silent by default, so nothing claims an audible companion it has not got.
expect(snap.audioMode).toBe("silent");
expect(snap.narration).toBeNull();
// Narrated, with a script derived from the approved copy rather than
// written afresh: an ad whose voiceover claims something its banner does
// not is a compliance problem, not a stylistic one.
expect(snap.audioMode).toBe("narrated");
expect(snap.narration).toContain(snap.headline);
expect(snap.narration).toContain(snap.domain);
// Deterministic, because it is part of the render hash.
const again = snapshotFromCreatives({
creatives: [design("banner_300x250")],
domain: "nichedb.dev",
})!;
expect(again.narration).toBe(snap.narration);
});

it("records no artwork URL while it records no content hash", () => {
Expand Down
Loading