diff --git a/lib/ads/video/compose.ts b/lib/ads/video/compose.ts index 9524965d..d43a61f2 100644 --- a/lib/ads/video/compose.ts +++ b/lib/ads/video/compose.ts @@ -116,7 +116,18 @@ export function composeDocument( 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) => `${escapeHtml(w)}`) + .join(" "); const cta = escapeHtml(snapshot.ctaText); const domain = escapeHtml(snapshot.domain); const logo = safeDataUri(assets.logo); @@ -152,24 +163,34 @@ export function composeDocument( 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}
${heroLayer}
${mark}${domain}
-

${headline}

+
+

${headlineWords}

${cta}
@@ -185,21 +206,70 @@ var stage = document.querySelector('.stage'); 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(); diff --git a/lib/ads/video/jobs.ts b/lib/ads/video/jobs.ts index fbc0d385..1ede4e88 100644 --- a/lib/ads/video/jobs.ts +++ b/lib/ads/video/jobs.ts @@ -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"; @@ -81,7 +82,7 @@ export function snapshotFromCreatives(args: { .find((c): c is NonNullable => !!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 @@ -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 = { diff --git a/lib/ads/video/narration.ts b/lib/ads/video/narration.ts new file mode 100644 index 00000000..48272548 --- /dev/null +++ b/lib/ads/video/narration.ts @@ -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 { + // `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; + } +} diff --git a/tests/ads-video-compose-validate.test.ts b/tests/ads-video-compose-validate.test.ts index c1306ef0..6a834b4b 100644 --- a/tests/ads-video-compose-validate.test.ts +++ b/tests/ads-video-compose-validate.test.ts @@ -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", () => { diff --git a/tests/ads-video-jobs.test.ts b/tests/ads-video-jobs.test.ts index 40131d0c..ff0b92e0 100644 --- a/tests/ads-video-jobs.test.ts +++ b/tests/ads-video-jobs.test.ts @@ -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", () => { diff --git a/tests/ads-video-narration.test.ts b/tests/ads-video-narration.test.ts new file mode 100644 index 00000000..759e054a --- /dev/null +++ b/tests/ads-video-narration.test.ts @@ -0,0 +1,144 @@ +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + MAX_NARRATION_CHARS, + narrationScript, + synthesiseNarration, +} from "@/lib/ads/video/narration"; +import type { VideoDesignSnapshot } from "@/lib/ads/video/snapshot"; + +const snapshot = (over: Partial = {}): VideoDesignSnapshot => ({ + headline: "All fixtures, one feed", + subhead: "Every source in one place", + ctaText: "View feeds", + domain: "nichedb.dev", + bgColor: "#12161f", + fgColor: "#e7e9ee", + accentColor: "#6ee7b7", + fontFamily: "system-ui, sans-serif", + logoUrl: null, + logoSha256: null, + heroUrl: null, + heroSha256: null, + audioMode: "narrated", + narration: null, + locale: "en", + reducedMotion: false, + ...over, +}); + +describe("the script is the approved copy, spoken", () => { + it("says the headline, then the call to action and the domain", () => { + // Derived, never written afresh: an ad whose voiceover claims something its + // banner does not is a compliance problem, not a stylistic one. + expect(narrationScript(snapshot())).toBe( + "All fixtures, one feed. View feeds at nichedb.dev.", + ); + }); + + it("does not double the full stop on a headline that has one", () => { + expect(narrationScript(snapshot({ headline: "Sources in, feeds out!" }))).toBe( + "Sources in, feeds out! View feeds at nichedb.dev.", + ); + }); + + it("is deterministic, because it is part of the render hash", () => { + expect(narrationScript(snapshot())).toBe(narrationScript(snapshot())); + }); + + it("clips a long line on a word boundary", () => { + const long = narrationScript( + snapshot({ headline: "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen" }), + ); + expect(long.length).toBeLessThanOrEqual(MAX_NARRATION_CHARS + 1); + // A voice cut off mid-word is worse than a shorter line. + expect(long).not.toMatch(/\s\w{1,2}\.$/); + expect(long.endsWith(".")).toBe(true); + }); + + it("collapses whitespace so the read is not shaped by the authoring", () => { + expect(narrationScript(snapshot({ headline: "All fixtures,\n one feed" }))).toContain( + "All fixtures, one feed", + ); + }); +}); + +describe("synthesis never fails a render", () => { + it("returns null with no API key", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "narr-")); + try { + expect(await synthesiseNarration({ snapshot: snapshot(), workDir: dir, apiKey: null })).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("returns null on a non-200, rather than throwing", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "narr-")); + try { + const res = await synthesiseNarration({ + snapshot: snapshot(), + workDir: dir, + apiKey: "k", + fetchImpl: (async () => new Response("rate limited", { status: 429 })) as typeof fetch, + }); + expect(res).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("returns null on a 200 with an empty body", async () => { + // A success code with no bytes is a failure wearing a success code, and + // muxing zero bytes fails much later with a worse message. + const dir = await mkdtemp(path.join(tmpdir(), "narr-")); + try { + const res = await synthesiseNarration({ + snapshot: snapshot(), + workDir: dir, + apiKey: "k", + fetchImpl: (async () => new Response(new ArrayBuffer(0), { status: 200 })) as typeof fetch, + }); + expect(res).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("returns null when the network throws", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "narr-")); + try { + const res = await synthesiseNarration({ + snapshot: snapshot(), + workDir: dir, + apiKey: "k", + fetchImpl: (async () => { + throw new Error("ECONNRESET"); + }) as typeof fetch, + }); + expect(res).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("writes the audio and reports the script it spoke", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "narr-")); + try { + const bytes = new Uint8Array([0x49, 0x44, 0x33, 1, 2, 3, 4, 5]); + const res = await synthesiseNarration({ + snapshot: snapshot(), + workDir: dir, + apiKey: "k", + fetchImpl: (async () => new Response(bytes, { status: 200 })) as typeof fetch, + }); + expect(res).not.toBeNull(); + expect(res!.script).toBe("All fixtures, one feed. View feeds at nichedb.dev."); + expect((await stat(res!.filePath)).size).toBe(bytes.byteLength); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/worker/video.ts b/worker/video.ts index 95dd8468..8cc5b79b 100644 --- a/worker/video.ts +++ b/worker/video.ts @@ -10,6 +10,7 @@ import { redisConnectionOptions } from "../lib/redis-connection"; import { VIDEO_RENDER_QUEUE, type VideoRenderJobData } from "../lib/ads/video/queue"; import { renderPreroll, RenderError } from "../lib/ads/video/render"; import { probeMedia } from "../lib/ads/video/validate"; +import { synthesiseNarration } from "../lib/ads/video/narration"; import { uploadRenderedAssets } from "../lib/ads/video/storage"; import { captureFrames } from "./frames"; @@ -60,7 +61,18 @@ export async function processRenderJob( attempts: job.attemptsMade + 1, }); + // Narration, best effort. A silent pre-roll is a working ad; a render that + // fails because a speech API was rate-limited is not — so this returns null + // on every failure and the silent cut ships. + const narration = await synthesiseNarration({ snapshot: d.snapshot, workDir }); + if (narration) { + console.log(`[render] narration: ${narration.byteSize} bytes — "${narration.script}"`); + } else { + console.log("[render] narration: none (no key or synthesis failed), rendering silent"); + } + const { assets, problems } = await renderPreroll({ + audioPath: narration?.filePath ?? null, // GIF frame counts come from a decode, not the header, for the same // reason the video's do: a truncated write still parses. probeGif: async (file: string) => {