From 50f852b741d4a5367e9960a6c8e4f5b9ad538074 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 15:59:25 +0000 Subject: [PATCH] Render an animated banner for each display size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three looping GIFs to every render: 300x250, 728x90 and 320x50, the same IAB units the static creatives already use, so a publisher slot that takes the static banner takes the animated one with no layout change. They come off the same design snapshot as the pre-roll, so a campaign reads as one thing across its video and its display units, and the motion is deliberately the pre-roll's rather than a second vocabulary: a short entrance rise, a slow accent drift through the hold, a CTA that gains emphasis on the final beat, plus one specular sweep. An advertiser who has seen their video should recognise the banner as the same campaign. What does not carry over is the layout. The pre-roll lays out once at 1920x1080 and lets each rendition downscale the same pixels; a banner cannot work that way, because 320x50 is not a scaled 300x250 — it is a different composition with different copy priorities. Each unit is laid out at its native size, mirroring the static creative it replaces: the rectangle stacks, the leaderboard and mobile banner run as a row, and the mobile banner drops the body line it has no room for. The snapshot gains a subhead so the banners can show the same approved second line the static units show, instead of inventing one. The pre-roll ignores it: five seconds of 1920x1080 is a headline and a CTA. Encoding is two-pass. GIF carries 256 colours and a default palette is built from the first frame alone, so an accent that only appears once the CTA lifts has no palette entry and bands badly; palettegen with stats_mode=diff weighs what actually changes. Dithering is ordered rather than error-diffused, because Floyd-Steinberg noise differs frame to frame, defeats inter-frame compression and can double the file for a banner that barely moves. diff_mode=rectangle stores only the changed region per frame. 150KB is the budget — the ceiling most ad networks enforce, so a file over it is not an asset regardless of what this codebase would accept. Flat brand colour is what makes 50 frames affordable at all. A banner that fails to encode does not fail the revision. The video is what the advertiser is waiting for, and the problems are recorded either way. RENDERER_VERSION goes to 2. Both the new banners and the HLS codec correction are baked into rendered output rather than read at serve time, so the only way to apply them to the 174 existing revisions is to render again — which is what changing the version does, since it feeds the dedupe hash. Co-Authored-By: Claude Opus 5 (1M context) --- app/actions/ads.ts | 15 +- components/ads/video-render-card.tsx | 34 +++ lib/ads/campaigns.ts | 1 + lib/ads/gif/compose.ts | 246 ++++++++++++++++++ lib/ads/gif/encode.ts | 111 ++++++++ lib/ads/gif/render.ts | 112 ++++++++ lib/ads/video/jobs.ts | 19 +- lib/ads/video/profiles.ts | 61 ++++- lib/ads/video/render.ts | 48 ++++ lib/ads/video/snapshot.ts | 16 +- scripts/backfill-ad-videos.ts | 3 +- .../20260924160000_ad_video_gif_profiles.sql | 26 ++ tests/ads-gif-pipeline.test.ts | 228 ++++++++++++++++ tests/ads-video-compose-validate.test.ts | 1 + tests/ads-video-contract.test.ts | 1 + tests/ads-video-pipeline.test.ts | 1 + worker/video.ts | 12 + 17 files changed, 929 insertions(+), 6 deletions(-) create mode 100644 lib/ads/gif/compose.ts create mode 100644 lib/ads/gif/encode.ts create mode 100644 lib/ads/gif/render.ts create mode 100644 supabase/migrations/20260924160000_ad_video_gif_profiles.sql create mode 100644 tests/ads-gif-pipeline.test.ts diff --git a/app/actions/ads.ts b/app/actions/ads.ts index 0ecb841..43c91c7 100644 --- a/app/actions/ads.ts +++ b/app/actions/ads.ts @@ -11,6 +11,7 @@ import { queueCampaignVideo, renderStateLabel, streamingReady, + animatedBanners, type RenderState, } from "@/lib/ads/video/jobs"; import { @@ -334,6 +335,7 @@ export async function videoRenderStatus(input: { jobId: string }): Promise< downloadUrl: string | null; downloadBytes: number | null; posterUrl: string | null; + banners: { profile: string; url: string; byteSize: number; width: number | null; height: number | null }[]; errorCode: string | null; } | { ok: false; error: string } @@ -361,6 +363,16 @@ export async function videoRenderStatus(input: { jobId: string }): Promise< streamingReady: streamingReady(status), downloadUrl: master?.url ?? null, downloadBytes: master?.byteSize ?? null, + // Optional by design: a revision rendered before the animated banners + // existed simply has none, and the card shows the video alone rather than + // an error. + banners: animatedBanners(status).map((b) => ({ + profile: b.profile, + url: b.url, + byteSize: b.byteSize, + width: b.width, + height: b.height, + })), posterUrl: status.assets.find((a) => a.profile === "poster")?.url ?? null, errorCode: status.errorCode, }; @@ -470,7 +482,7 @@ async function requeueCampaignVideo( const { data: rows } = await supabase .from("ad_creatives") - .select("format, headline, cta_text, bg_color, fg_color, accent_color, font_family, logo_url, image_url") + .select("format, headline, body, cta_text, bg_color, fg_color, accent_color, font_family, logo_url, image_url") .eq("campaign_id", args.campaignId) .eq("owner_id", args.ownerId); if (!rows?.length) return; @@ -484,6 +496,7 @@ async function requeueCampaignVideo( creatives: rows.map((r) => ({ format: r.format, headline: r.headline ?? "", + body: r.body ?? null, ctaText: r.cta_text ?? "", bgColor: r.bg_color, fgColor: r.fg_color, diff --git a/components/ads/video-render-card.tsx b/components/ads/video-render-card.tsx index 326153c..72da057 100644 --- a/components/ads/video-render-card.tsx +++ b/components/ads/video-render-card.tsx @@ -120,6 +120,40 @@ export function VideoRenderCard({ {formatBytes(status.downloadBytes)} · revision {status.revision} + {status.banners.length > 0 && ( +
+

Animated banners

+

+ The same design as a looping GIF, at the three display sizes. Drop-in + replacements for the static units. +

+
+ {status.banners.map((b) => ( +
+ {/* The GIF itself, playing. A still preview of an animated + banner tells the advertiser nothing about the motion + they are approving. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {`${b.width}x${b.height} +
+ + {b.width}×{b.height} GIF + + {formatBytes(b.byteSize)} +
+
+ ))} +
+
+ )} +

{status.streamingReady ? "Ready to stream. Downloading works whether or not the campaign is active." diff --git a/lib/ads/campaigns.ts b/lib/ads/campaigns.ts index 1fdc752..221ba67 100644 --- a/lib/ads/campaigns.ts +++ b/lib/ads/campaigns.ts @@ -280,6 +280,7 @@ export async function createCampaignForUrl(input: { creatives: generated.creatives.map((c) => ({ format: c.format, headline: c.headline ?? "", + body: c.body ?? null, ctaText: c.ctaText ?? "", bgColor: c.bgColor ?? null, fgColor: c.fgColor ?? null, diff --git a/lib/ads/gif/compose.ts b/lib/ads/gif/compose.ts new file mode 100644 index 0000000..a635d12 --- /dev/null +++ b/lib/ads/gif/compose.ts @@ -0,0 +1,246 @@ +// The animated banner document. +// +// Same contract as the pre-roll compositor: the page defines `window.__seek(n)` +// and every animated value is a pure function of the frame index, so the +// renderer drives time explicitly instead of screenshotting a wall clock and +// hoping. That is what makes a capture reproducible. +// +// What differs is the layout. The pre-roll lays out once at 1920x1080 and lets +// each rendition downscale the same pixels; a banner cannot work that way, +// because 320x50 is not a scaled 300x250 — it is a different composition with +// different copy priorities. So each unit is laid out at its own native size, +// mirroring the static creative it replaces: the rectangle stacks, the +// leaderboard and mobile banner run as a row. +// +// The motion is deliberately the pre-roll's, not a new vocabulary: a short +// entrance rise, a slow accent drift through the hold, and a CTA that gains +// emphasis on the final beat. An advertiser who has seen their video should +// recognise the banner as the same campaign. + +import { GIF_FPS, GIF_FRAMES } from "../video/profiles"; +import { escapeHtml, safeDataUri } from "../video/compose"; + +export type GifUnit = { + id: "gif_300x250" | "gif_728x90" | "gif_320x50"; + width: number; + height: number; + /** Row units put the mark, copy and CTA on one line. */ + row: boolean; + /** The mobile banner has no room for a body line. */ + showBody: boolean; +}; + +export const GIF_UNITS: GifUnit[] = [ + { id: "gif_300x250", width: 300, height: 250, row: false, showBody: true }, + { id: "gif_728x90", width: 728, height: 90, row: true, showBody: true }, + { id: "gif_320x50", width: 320, height: 50, row: true, showBody: false }, +]; + +export function gifUnit(id: string): GifUnit { + const u = GIF_UNITS.find((x) => x.id === id); + if (!u) throw new Error(`Unknown animated banner unit: ${id}`); + return u; +} + +/** Beat boundaries, in milliseconds of a 4s loop. */ +export const GIF_TIMELINE = { + entranceEndMs: 700, + holdEndMs: 2800, + endMs: 4000, +} as const; + +export type GifFrameState = { + frame: number; + timeMs: number; + entrance: number; + drift: number; + cta: number; + /** The accent sweep's position, -1 to 2 across the unit. */ + sweep: number; +}; + +function easeOut(t: number): number { + const c = Math.min(1, Math.max(0, t)); + return 1 - Math.pow(1 - c, 3); +} + +function phase(ms: number, startMs: number, endMs: number): number { + if (endMs <= startMs) return ms >= endMs ? 1 : 0; + return Math.min(1, Math.max(0, (ms - startMs) / (endMs - startMs))); +} + +/** + * The animated state at a frame. + * + * Exported so tests can assert the timeline's shape without decoding pixels, + * and because the document below embeds this same function verbatim. + */ +export function gifFrameState(frame: number, reducedMotion: boolean): GifFrameState { + const timeMs = (frame / GIF_FPS) * 1000; + if (reducedMotion) { + // A still banner, held at its resting state. It still reads as the finished + // ad — headline up, CTA emphasised — it simply never moves toward it. + return { frame, timeMs, entrance: 1, drift: 0, cta: 1, sweep: 2 }; + } + return { + frame, + timeMs, + entrance: easeOut(phase(timeMs, 0, GIF_TIMELINE.entranceEndMs)), + drift: phase(timeMs, GIF_TIMELINE.entranceEndMs, GIF_TIMELINE.holdEndMs), + cta: easeOut(phase(timeMs, GIF_TIMELINE.holdEndMs, GIF_TIMELINE.endMs)), + // One pass across the unit during the hold. Starts off-screen and ends + // off-screen, so the loop point never shows a sweep frozen mid-unit. + sweep: -1 + 3 * phase(timeMs, GIF_TIMELINE.entranceEndMs, GIF_TIMELINE.holdEndMs), + }; +} + +export function gifTimeline(reducedMotion: boolean): GifFrameState[] { + return Array.from({ length: GIF_FRAMES }, (_, i) => gifFrameState(i, reducedMotion)); +} + +function initial(s: string): string { + const m = s.match(/[a-z0-9]/i); + return m ? m[0].toUpperCase() : "★"; +} + +export type GifComposeInput = { + unit: GifUnit; + headline: string; + body: string; + ctaText: string; + domain: string; + bgColor: string; + fgColor: string; + accentColor: string; + fontFamily: string; + logoDataUri: string | null; + reducedMotion: boolean; +}; + +/** + * A self-contained, offline document for one animated banner. + * + * No network references survive: the logo arrives as a data URI or not at all, + * and the font stack is whatever the rendering container has. A banner that + * waits on a webfont would capture its first frames unstyled, and every one of + * those frames ships. + */ +export function gifDocument(input: GifComposeInput): string { + const { + unit, + headline, + body, + ctaText, + domain, + bgColor, + fgColor, + accentColor, + fontFamily, + logoDataUri, + reducedMotion, + } = input; + + const logo = safeDataUri(logoDataUri); + const markSize = unit.id === "gif_320x50" ? 20 : 28; + const headlineSize = unit.id === "gif_320x50" ? 13 : unit.row ? 16 : 18; + const bodySize = unit.row ? 12 : 13; + const ctaPad = unit.id === "gif_320x50" ? "4px 8px" : "7px 12px"; + const ctaSize = unit.id === "gif_320x50" ? 11 : 13; + + const mark = logo + ? `` + : `

${escapeHtml(initial(domain))}
`; + + const copy = ` +
+
${escapeHtml(headline)}
+ ${unit.showBody ? `
${escapeHtml(body)}
` : ""} +
`; + + const cta = `
${escapeHtml(ctaText)}
`; + + const inner = unit.row + ? `
${mark}${copy}${cta}
` + : `
+
${mark}
+
${copy}
+
${cta}
+
`; + + return ` +
+
+
+ ${inner} + ${unit.row ? "" : `
${escapeHtml(domain)}
`} +
+ `; +} diff --git a/lib/ads/gif/encode.ts b/lib/ads/gif/encode.ts new file mode 100644 index 0000000..1de2f31 --- /dev/null +++ b/lib/ads/gif/encode.ts @@ -0,0 +1,111 @@ +// Frames to an animated GIF. +// +// Two passes, because GIF carries at most 256 colours and the default palette +// is built from the first frame alone. On a banner whose accent only appears +// once the CTA lifts, a first-frame palette has no entry for it and the final +// beat bands badly. `palettegen` with stats_mode=diff looks at the whole +// sequence and weights what actually changes, which is the part a viewer is +// watching. +// +// The size ceiling is the real constraint: every GIF frame is a full frame of +// indexed colour, so file size scales with frames x area and not with how much +// moved. Flat brand colour is what makes 50 frames affordable; a photographic +// banner would not fit and is not what this renders. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +import { GIF_FPS } from "../video/profiles"; + +const run = promisify(execFile); + +/** Palette size. 128 is ample for flat UI colour and meaningfully smaller than 256. */ +export const GIF_PALETTE_COLORS = 128; + +/** + * Pass one: derive a palette from the whole sequence. + * + * `stats_mode=diff` weights pixels that change between frames, so the palette + * is spent on the moving parts rather than on a large static background that + * would be well served by a handful of entries anyway. + */ +export function paletteArgs(framePattern: string, palettePath: string): string[] { + return [ + "-y", + "-framerate", String(GIF_FPS), + "-i", framePattern, + "-vf", `palettegen=max_colors=${GIF_PALETTE_COLORS}:stats_mode=diff`, + palettePath, + ]; +} + +/** + * Pass two: map the frames through that palette. + * + * `dither=bayer:bayer_scale=5` rather than the default error-diffusion. Floyd + * Steinberg dithering decorrelates neighbouring frames — noise that differs + * frame to frame defeats GIF's inter-frame compression and can double the file + * for a banner that barely moves. Ordered dithering is stable across frames, so + * unchanged regions stay compressible. + * + * `diff_mode=rectangle` lets the encoder store only the changed rectangle per + * frame, which is the single biggest win on a unit whose background is still. + */ +export function gifArgs(framePattern: string, palettePath: string, outPath: string): string[] { + return [ + "-y", + "-framerate", String(GIF_FPS), + "-i", framePattern, + "-i", palettePath, + "-lavfi", "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", + // 0 means loop forever. A banner that plays once and stops is a still image + // for the rest of the impression. + "-loop", "0", + outPath, + ]; +} + +export async function encodeGif(input: { + frameDir: string; + outPath: string; + ffmpegPath?: string; +}): Promise { + const ffmpeg = input.ffmpegPath ?? "ffmpeg"; + const pattern = path.join(input.frameDir, "frame-%04d.png"); + const palette = path.join(input.frameDir, "palette.png"); + + await run(ffmpeg, paletteArgs(pattern, palette), { maxBuffer: 16 * 1024 * 1024 }); + await run(ffmpeg, gifArgs(pattern, palette, input.outPath), { maxBuffer: 16 * 1024 * 1024 }); +} + +/** + * What a decoded GIF must satisfy to be shippable. + * + * Frame count is checked by decoding rather than by trusting the header, + * for the same reason the pre-roll does it: a truncated write still parses. + */ +export function validateGif(input: { + width: number; + height: number; + frames: number; + byteSize: number; + expectedWidth: number; + expectedHeight: number; + expectedFrames: number; + maxBytes: number; +}): string[] { + const problems: string[] = []; + if (input.width !== input.expectedWidth || input.height !== input.expectedHeight) { + problems.push( + `expected ${input.expectedWidth}x${input.expectedHeight}, got ${input.width}x${input.height}`, + ); + } + if (input.frames !== input.expectedFrames) { + problems.push(`expected ${input.expectedFrames} frames, decoded ${input.frames}`); + } + if (input.byteSize <= 0) problems.push("empty file"); + if (input.byteSize > input.maxBytes) { + problems.push(`${input.byteSize} bytes exceeds the ${input.maxBytes} byte ceiling`); + } + return problems; +} diff --git a/lib/ads/gif/render.ts b/lib/ads/gif/render.ts new file mode 100644 index 0000000..3072568 --- /dev/null +++ b/lib/ads/gif/render.ts @@ -0,0 +1,112 @@ +// Render the three animated banners for one design snapshot. +// +// Orchestration only. Frame capture is injected exactly as the pre-roll does +// it, so the whole pipeline can be driven in a test by a capturer that writes +// synthetic frames — no Chromium needed to prove the encode, the validation and +// the size budget behave. +// +// Each unit is captured at its own native size. That is the point of animating +// the banner rather than downscaling the pre-roll: 320x50 is not a small +// 300x250, it is a different composition, and a legibility check at one size +// says nothing about the other. + +import { mkdir, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { GIF_FRAMES, videoProfile, type GifProfileId } from "../video/profiles"; +import type { VideoDesignSnapshot } from "../video/snapshot"; +import type { FrameCapturer } from "../video/render"; +import { GIF_UNITS, gifDocument } from "./compose"; +import { encodeGif, validateGif } from "./encode"; + +export type GifProbe = (file: string) => Promise<{ width: number; height: number; frames: number }>; + +export type RenderedGif = { + profile: GifProfileId; + file: string; + width: number; + height: number; + frames: number; + byteSize: number; + contentType: "image/gif"; + problems: string[]; +}; + +export async function renderAnimatedBanners(args: { + snapshot: VideoDesignSnapshot; + workDir: string; + captureFrames: FrameCapturer; + probeGif: GifProbe; + ffmpegPath?: string; +}): Promise { + const { snapshot, workDir, captureFrames, probeGif } = args; + const out: RenderedGif[] = []; + + for (const unit of GIF_UNITS) { + const spec = videoProfile(unit.id); + const framesDir = path.join(workDir, `${unit.id}-frames`); + await mkdir(framesDir, { recursive: true }); + + const html = gifDocument({ + unit, + headline: snapshot.headline, + // The body line is the pre-roll's subhead where there is one. A banner + // with an empty second line looks broken rather than minimal, so the + // domain stands in — it is true, and it is what the static unit shows. + body: snapshot.subhead || snapshot.domain, + ctaText: snapshot.ctaText, + domain: snapshot.domain, + bgColor: snapshot.bgColor, + fgColor: snapshot.fgColor, + accentColor: snapshot.accentColor, + fontFamily: snapshot.fontFamily, + logoDataUri: null, + reducedMotion: snapshot.reducedMotion ?? false, + }); + + await captureFrames({ + html, + outDir: framesDir, + frames: GIF_FRAMES, + width: unit.width, + height: unit.height, + }); + + // The compositor is the only thing that decides how many frames exist. If + // the capture disagrees, the timeline did not run and encoding whatever + // landed would ship a banner that is silently short. + const captured = (await readdir(framesDir)).filter((f) => f.endsWith(".png")); + if (captured.length !== GIF_FRAMES) { + throw new Error( + `${unit.id}: captured ${captured.length} frames, expected ${GIF_FRAMES}`, + ); + } + + const file = path.join(workDir, `${unit.id}.gif`); + await encodeGif({ frameDir: framesDir, outPath: file, ffmpegPath: args.ffmpegPath }); + + const probed = await probeGif(file); + const byteSize = (await stat(file)).size; + + out.push({ + profile: unit.id, + file, + width: probed.width, + height: probed.height, + frames: probed.frames, + byteSize, + contentType: "image/gif", + problems: validateGif({ + width: probed.width, + height: probed.height, + frames: probed.frames, + byteSize, + expectedWidth: unit.width, + expectedHeight: unit.height, + expectedFrames: GIF_FRAMES, + maxBytes: spec.maxBytes ?? Number.MAX_SAFE_INTEGER, + }), + }); + } + + return out; +} diff --git a/lib/ads/video/jobs.ts b/lib/ads/video/jobs.ts index 9653adf..a9eac8b 100644 --- a/lib/ads/video/jobs.ts +++ b/lib/ads/video/jobs.ts @@ -11,7 +11,7 @@ import type { AdCreative } from "../formats"; import { VIDEO_FORMAT_ID } from "../formats"; import { MAX_HEADLINE_WORDS, renderHash, validateSnapshot, type VideoDesignSnapshot } from "./snapshot"; import { enqueueRender } from "./queue"; -import { VIDEO_PROFILES, type VideoProfileId } from "./profiles"; +import { VIDEO_PROFILES, type VideoProfileId, GIF_PROFILE_IDS} from "./profiles"; /** The one output profile a job is keyed on; a job renders the whole set. */ export const DEFAULT_OUTPUT_PROFILE = "default"; @@ -52,7 +52,7 @@ export function trimHeadlineForVideo(headline: string): string { export function snapshotFromCreatives(args: { creatives: Pick< AdCreative, - "format" | "headline" | "ctaText" | "bgColor" | "fgColor" | "accentColor" | "fontFamily" | "logoUrl" | "imageUrl" + "format" | "headline" | "body" | "ctaText" | "bgColor" | "fgColor" | "accentColor" | "fontFamily" | "logoUrl" | "imageUrl" >[]; domain: string; locale?: string; @@ -73,6 +73,10 @@ export function snapshotFromCreatives(args: { return { 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 + // and a CTA, and a body line there is copy nobody reads. + subhead: (source.body ?? "").trim() || null, ctaText: source.ctaText || "Learn more", domain: args.domain, bgColor: source.bgColor, @@ -437,6 +441,17 @@ export function renderStateLabel(state: RenderState): string { /** The profile an advertiser downloads: the 1080p master. */ export const DOWNLOAD_PROFILE: VideoProfileId = "master_1080p"; +/** + * The animated banners of a ready revision, in the order the dashboard lists + * them. Empty for a revision rendered before they existed, which is why the + * card treats them as optional rather than missing. + */ +export function animatedBanners(status: RenderStatus) { + return GIF_PROFILE_IDS.map((id) => status.assets.find((a) => a.profile === id)).filter( + (a): a is NonNullable => !!a, + ); +} + export function downloadableAsset(status: RenderStatus) { return status.assets.find((a) => a.profile === DOWNLOAD_PROFILE) ?? null; } diff --git a/lib/ads/video/profiles.ts b/lib/ads/video/profiles.ts index 0bf64ff..35c07b3 100644 --- a/lib/ads/video/profiles.ts +++ b/lib/ads/video/profiles.ts @@ -68,7 +68,31 @@ export type VideoProfileId = | "hls" | "poster" | "captions" - | "audio"; + | "audio" + | "gif_300x250" + | "gif_728x90" + | "gif_320x50"; + +/** The animated banner profiles, in the order the dashboard lists them. */ +export const GIF_PROFILE_IDS = ["gif_300x250", "gif_728x90", "gif_320x50"] as const; +export type GifProfileId = (typeof GIF_PROFILE_IDS)[number]; + +export function isGifProfile(id: string): id is GifProfileId { + return (GIF_PROFILE_IDS as readonly string[]).includes(id); +} + +/** + * Animated banners run slower and shorter than the pre-roll. + * + * GIF stores an inter-frame delay in hundredths of a second, so only a handful + * of frame rates are exactly representable: 12.5fps is 8cs and lands on a whole + * number, where 12 or 15 would drift and make the loop stutter. Four seconds + * keeps the file inside what ad networks accept — every frame is a full frame + * of palette, so duration is the main lever on size. + */ +export const GIF_FPS = 12.5; +export const GIF_FRAMES = 50; +export const GIF_MS = (GIF_FRAMES / GIF_FPS) * 1000; // 4000 export type VideoProfile = { id: VideoProfileId; @@ -164,6 +188,41 @@ export const VIDEO_PROFILES: VideoProfile[] = [ // genuinely required — see audioRequiredFor() below. required: false, }, + // The animated banners. Sized to the IAB units the display creatives already + // use, so a publisher slot that takes the static banner takes this instead + // with no layout change. + // + // 150KB is the ceiling most ad networks enforce, so it is the budget here + // even though nothing in this codebase would reject a larger file: a banner + // that cannot be trafficked is not an asset. Flat brand colour compresses far + // below that, which is what makes 50 frames affordable at all. + { + id: "gif_300x250", + label: "Animated Medium Rectangle", + width: 300, + height: 250, + maxBytes: 150 * KB, + contentType: "image/gif", + required: false, + }, + { + id: "gif_728x90", + label: "Animated Leaderboard", + width: 728, + height: 90, + maxBytes: 150 * KB, + contentType: "image/gif", + required: false, + }, + { + id: "gif_320x50", + label: "Animated Mobile Banner", + width: 320, + height: 50, + maxBytes: 150 * KB, + contentType: "image/gif", + required: false, + }, ]; export function videoProfile(id: VideoProfileId): VideoProfile { diff --git a/lib/ads/video/render.ts b/lib/ads/video/render.ts index 4050394..58a52fd 100644 --- a/lib/ads/video/render.ts +++ b/lib/ads/video/render.ts @@ -15,6 +15,7 @@ import { withinBudget, type VideoProfileId, } from "./profiles"; +import { renderAnimatedBanners, type GifProbe } from "../gif/render"; import { composeDocument, type ComposeAssets } from "./compose"; import { validateSnapshot, type VideoDesignSnapshot } from "./snapshot"; import { @@ -156,6 +157,8 @@ export async function renderPreroll(args: { assets?: ComposeAssets; workDir: string; captureFrames: FrameCapturer; + /** Supplied by the worker; omitted in tests that only exercise the video. */ + probeGif?: GifProbe; audioPath?: string | null; audioSlotSupported?: boolean; }): Promise { @@ -348,6 +351,51 @@ export async function renderPreroll(args: { } } + // The animated banners. Rendered from the same snapshot so the campaign reads + // as one thing across the pre-roll and the display units, but laid out at + // each unit's own size rather than downscaled — 320x50 is not a small + // 300x250, it is a different composition. + // + // They are optional profiles: a banner that fails to encode must not fail a + // revision whose video is fine, because the video is what the advertiser is + // waiting for. The problems are recorded either way. + if (args.probeGif) { + try { + const gifs = await renderAnimatedBanners({ + snapshot, + workDir, + captureFrames, + probeGif: args.probeGif, + }); + for (const g of gifs) { + const facts = await fileFacts(g.file); + assets.push({ + profile: g.profile, + filePath: g.file, + contentType: g.contentType, + byteSize: facts.byteSize, + sha256: facts.sha256, + width: g.width, + height: g.height, + // A GIF's duration is its frame delays summed; the loop is what + // matters and it is fixed, so nothing here needs to carry it. + durationMs: null, + codecs: null, + validation: { + ok: g.problems.length === 0, + problems: g.problems.map((p) => ({ check: g.profile, expected: "conforming", actual: p })), + }, + }); + } + } catch (err) { + problems.push({ + check: "animated banners", + expected: "three units", + actual: (err as Error).message, + }); + } + } + return { assets, problems }; } diff --git a/lib/ads/video/snapshot.ts b/lib/ads/video/snapshot.ts index e35657a..86ed10f 100644 --- a/lib/ads/video/snapshot.ts +++ b/lib/ads/video/snapshot.ts @@ -17,7 +17,12 @@ import type { VideoProfileId } from "./profiles"; * without a version in the key a fixed compositor would keep serving the old * bytes forever. Bumping it is the deliberate cost of changing the look. */ -export const RENDERER_VERSION = "1"; +// Bumped to 2: a revision now also carries three animated banners, and the HLS +// playlists written by version 1 declare a codec string that does not match +// what was encoded. Both are baked into rendered output rather than read at +// serve time, so the only way to correct them is to render again — which is +// exactly what changing this does, since it feeds the dedupe hash. +export const RENDERER_VERSION = "2"; export type AudioMode = "silent" | "narrated"; @@ -33,6 +38,15 @@ export type AudioMode = "silent" | "narrated"; */ export type VideoDesignSnapshot = { headline: string; + /** + * The approved body line, where the source creative has one. + * + * Carried so the animated banners show the same second line the static + * banners do. Nullable because the mobile banner has no body and some + * creatives never had one; a unit with room for it falls back to the domain + * rather than rendering an empty row, which reads as broken. + */ + subhead: string | null; ctaText: string; /** Bare host, e.g. "nichedb.dev" — shown as the destination, never a full URL. */ domain: string; diff --git a/scripts/backfill-ad-videos.ts b/scripts/backfill-ad-videos.ts index dd816b1..19c41e9 100644 --- a/scripts/backfill-ad-videos.ts +++ b/scripts/backfill-ad-videos.ts @@ -114,7 +114,7 @@ for (const [i, c] of targets.entries()) { const { data: creatives } = await supabase .from("ad_creatives") - .select("format, headline, cta_text, bg_color, fg_color, accent_color, font_family, logo_url, image_url") + .select("format, headline, body, cta_text, bg_color, fg_color, accent_color, font_family, logo_url, image_url") .eq("campaign_id", c.id) .neq("format", "video_preroll_5s") .neq("status", "rejected"); @@ -139,6 +139,7 @@ for (const [i, c] of targets.entries()) { creatives: usable.map((r) => ({ format: r.format, headline: r.headline ?? "", + body: r.body ?? null, ctaText: r.cta_text ?? "", bgColor: r.bg_color, fgColor: r.fg_color, diff --git a/supabase/migrations/20260924160000_ad_video_gif_profiles.sql b/supabase/migrations/20260924160000_ad_video_gif_profiles.sql new file mode 100644 index 0000000..20073d2 --- /dev/null +++ b/supabase/migrations/20260924160000_ad_video_gif_profiles.sql @@ -0,0 +1,26 @@ +-- Animated banner profiles for ad_video_assets. +-- +-- The three IAB display sizes the static creatives already use, so a publisher +-- slot that takes the static banner takes the animated one with no layout +-- change. They ride on the existing video job rather than getting a pipeline of +-- their own: the same design snapshot produces the pre-roll and the banners, so +-- a campaign reads as one thing across both, and one render either succeeds or +-- fails as a unit. + +alter table public.ad_video_assets + drop constraint if exists ad_video_assets_profile_check; + +alter table public.ad_video_assets + add constraint ad_video_assets_profile_check + check (profile = any (array[ + 'master_1080p'::text, + 'mp4_720p'::text, + 'mp4_480p'::text, + 'hls'::text, + 'poster'::text, + 'captions'::text, + 'audio'::text, + 'gif_300x250'::text, + 'gif_728x90'::text, + 'gif_320x50'::text + ])); diff --git a/tests/ads-gif-pipeline.test.ts b/tests/ads-gif-pipeline.test.ts new file mode 100644 index 0000000..21aced7 --- /dev/null +++ b/tests/ads-gif-pipeline.test.ts @@ -0,0 +1,228 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { GIF_UNITS, gifDocument, gifFrameState, gifTimeline, GIF_TIMELINE } from "@/lib/ads/gif/compose"; +import { GIF_PALETTE_COLORS, gifArgs, paletteArgs, validateGif } from "@/lib/ads/gif/encode"; +import { renderAnimatedBanners } from "@/lib/ads/gif/render"; +import { GIF_FPS, GIF_FRAMES, videoProfile } from "@/lib/ads/video/profiles"; +import type { VideoDesignSnapshot } from "@/lib/ads/video/snapshot"; + +const run = promisify(execFile); + +const snapshot: VideoDesignSnapshot = { + headline: "Sources in, feeds out", + subhead: "An open directory of independent blogs", + ctaText: "Start free", + domain: "rssamplifier.com", + bgColor: "#12161f", + fgColor: "#e7e9ee", + accentColor: "#6ee7b7", + fontFamily: "system-ui, sans-serif", + logoUrl: null, + logoSha256: null, + heroUrl: null, + heroSha256: null, + audioMode: "silent", + narration: null, + locale: "en", + reducedMotion: false, +}; + +describe("the banner timeline is a pure function of the frame index", () => { + it("starts legible and ends at rest", () => { + const first = gifFrameState(0, false); + const last = gifFrameState(GIF_FRAMES - 1, false); + // Frame 0 already shows the ad. A banner that fades in from nothing wastes + // the impressions of anyone who scrolls past during the entrance. + expect(first.entrance).toBeGreaterThanOrEqual(0); + expect(last.entrance).toBe(1); + // Nearly 1, deliberately not exactly 1. The 50 frames cover [0, 4000) at + // 80ms each, so the final frame sits at 3920ms and the 4000ms mark IS + // frame 0 of the next loop. A timeline that put a frame exactly on the end + // would render the loop point twice and the banner would hitch once per + // cycle. + expect(last.cta).toBeGreaterThan(0.99); + expect(last.cta).toBeLessThan(1); + expect(last.timeMs).toBe(3920); + }); + + it("ends the loop with the sweep off the unit", () => { + // The sweep wraps back to frame 0 on every loop. If it were frozen + // mid-unit at the last frame, every loop would show a visible snap. + const last = gifFrameState(GIF_FRAMES - 1, false); + expect(last.sweep).toBeGreaterThanOrEqual(1.5); + }); + + it("covers exactly the four-second loop", () => { + const t = gifTimeline(false); + expect(t).toHaveLength(GIF_FRAMES); + expect(t[0].timeMs).toBe(0); + expect(t.at(-1)!.timeMs).toBeCloseTo(((GIF_FRAMES - 1) / GIF_FPS) * 1000, 5); + expect(GIF_TIMELINE.endMs).toBe(4000); + }); + + it("holds every beat at rest under reduced motion", () => { + for (const f of [0, 10, GIF_FRAMES - 1]) { + const s = gifFrameState(f, true); + expect(s).toMatchObject({ entrance: 1, drift: 0, cta: 1 }); + } + }); +}); + +describe("the document is self-contained", () => { + it("defines the seek hook and no CSS animation", () => { + const html = gifDocument({ + unit: GIF_UNITS[0], + headline: snapshot.headline, + body: snapshot.subhead!, + ctaText: snapshot.ctaText, + domain: snapshot.domain, + bgColor: snapshot.bgColor, + fgColor: snapshot.fgColor, + accentColor: snapshot.accentColor, + fontFamily: snapshot.fontFamily, + logoDataUri: null, + reducedMotion: false, + }); + expect(html).toContain("window.__seek"); + // A CSS transition or keyframe runs on its own clock and would + // desynchronise from a frame-by-frame capture. + expect(html).not.toMatch(/@keyframes|transition:/); + // Nothing may be fetched: a banner waiting on a webfont captures its first + // frames unstyled, and every one of those frames ships. + expect(html).not.toMatch(/https?:\/\//); + }); + + it("escapes copy rather than interpolating it", () => { + const html = gifDocument({ + unit: GIF_UNITS[0], + headline: '', + body: "x", + ctaText: "Go", + domain: "example.com", + bgColor: "#000", + fgColor: "#fff", + accentColor: "#0f0", + fontFamily: "sans-serif", + logoDataUri: null, + reducedMotion: false, + }); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + }); + + it("omits the body line on the unit with no room for it", () => { + const mobile = GIF_UNITS.find((u) => u.id === "gif_320x50")!; + expect(mobile.showBody).toBe(false); + const html = gifDocument({ + unit: mobile, + headline: "Head", + body: "SHOULD NOT APPEAR", + ctaText: "Go", + domain: "example.com", + bgColor: "#000", + fgColor: "#fff", + accentColor: "#0f0", + fontFamily: "sans-serif", + logoDataUri: null, + reducedMotion: false, + }); + expect(html).not.toContain("SHOULD NOT APPEAR"); + }); +}); + +describe("the encoder arguments say what they mean", () => { + it("derives the palette from the whole sequence, not frame one", () => { + const a = paletteArgs("f-%04d.png", "p.png").join(" "); + expect(a).toContain(`palettegen=max_colors=${GIF_PALETTE_COLORS}:stats_mode=diff`); + }); + + it("uses ordered dithering and rectangle diffs", () => { + const a = gifArgs("f-%04d.png", "p.png", "o.gif").join(" "); + // Error-diffusion dithering decorrelates neighbouring frames and defeats + // GIF's inter-frame compression on a banner that barely moves. + expect(a).toContain("dither=bayer"); + expect(a).toContain("diff_mode=rectangle"); + // 0 is loop forever; a banner that stops is a still image for the rest of + // the impression. + expect(a).toContain("-loop 0"); + }); +}); + +describe("validation refuses what cannot be trafficked", () => { + const base = { + width: 300, height: 250, frames: GIF_FRAMES, byteSize: 1000, + expectedWidth: 300, expectedHeight: 250, expectedFrames: GIF_FRAMES, maxBytes: 150 * 1024, + }; + it("accepts a conforming banner", () => { + expect(validateGif(base)).toEqual([]); + }); + it("rejects the wrong size, a short loop and an oversized file", () => { + expect(validateGif({ ...base, width: 728 })[0]).toMatch(/expected 300x250/); + expect(validateGif({ ...base, frames: 12 })[0]).toMatch(/expected 50 frames/); + expect(validateGif({ ...base, byteSize: 200 * 1024 })[0]).toMatch(/exceeds/); + }); +}); + +// The real encode, driven by a synthetic capturer. No browser is involved: +// this proves the ffmpeg pipeline, the probe and the budget, which is what +// breaks silently. +describe("end to end through real ffmpeg", () => { + it("produces three looping banners inside their size budgets", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "gif-pipeline-")); + try { + // Frames that actually change, so palettegen and the diff encoder have + // something to do — a constant image would prove nothing about either. + const captureFrames = async (a: { + outDir: string; frames: number; width: number; height: number; + }) => { + for (let i = 0; i < a.frames; i++) { + const shift = Math.round((i / a.frames) * a.width); + const svg = ` + + + + `; + const svgPath = path.join(a.outDir, `f-${i}.svg`); + await writeFile(svgPath, svg, "utf8"); + await run("ffmpeg", ["-y", "-v", "error", "-i", svgPath, + path.join(a.outDir, `frame-${String(i).padStart(4, "0")}.png`)]); + await rm(svgPath); + } + }; + + const probeGif = async (file: string) => { + const { stdout } = await run("ffprobe", ["-v", "error", "-count_frames", + "-select_streams", "v:0", "-show_entries", "stream=width,height,nb_read_frames", + "-of", "json", file]); + const st = JSON.parse(stdout).streams[0]; + return { width: Number(st.width), height: Number(st.height), frames: Number(st.nb_read_frames) }; + }; + + const out = await renderAnimatedBanners({ snapshot, workDir: dir, captureFrames, probeGif }); + + expect(out).toHaveLength(3); + for (const g of out) { + const spec = videoProfile(g.profile); + expect(g.problems, `${g.profile}: ${g.problems.join("; ")}`).toEqual([]); + expect(g.width).toBe(spec.width); + expect(g.height).toBe(spec.height); + expect(g.frames).toBe(GIF_FRAMES); + expect(g.byteSize).toBeGreaterThan(0); + expect(g.byteSize).toBeLessThanOrEqual(spec.maxBytes!); + // It must actually be a GIF: the first bytes are the signature, and a + // trailing 0x3B is the terminator a truncated write would lack. + const bytes = await readFile(g.file); + expect(bytes.subarray(0, 6).toString("ascii")).toMatch(/^GIF8[79]a$/); + expect(bytes.at(-1)).toBe(0x3b); + // NETSCAPE2.0 is how a GIF declares an infinite loop. + expect(bytes.includes(Buffer.from("NETSCAPE2.0", "ascii"))).toBe(true); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 180_000); +}); diff --git a/tests/ads-video-compose-validate.test.ts b/tests/ads-video-compose-validate.test.ts index ceafa86..c1306ef 100644 --- a/tests/ads-video-compose-validate.test.ts +++ b/tests/ads-video-compose-validate.test.ts @@ -19,6 +19,7 @@ import { PREROLL_FRAMES, TIMELINE } from "@/lib/ads/video/profiles"; const snapshot: VideoDesignSnapshot = { headline: "Sources in, feeds out", + subhead: "Every source, one feed.", ctaText: "Start free", domain: "nichedb.dev", bgColor: "#12161f", diff --git a/tests/ads-video-contract.test.ts b/tests/ads-video-contract.test.ts index fc859f1..5ff4c9f 100644 --- a/tests/ads-video-contract.test.ts +++ b/tests/ads-video-contract.test.ts @@ -23,6 +23,7 @@ import { contentTypeFor, objectKey, revisionPrefix } from "@/lib/ads/video/stora const snapshot: VideoDesignSnapshot = { headline: "Sources in, feeds out", + subhead: "Every source, one feed.", ctaText: "Start free", domain: "nichedb.dev", bgColor: "#12161f", diff --git a/tests/ads-video-pipeline.test.ts b/tests/ads-video-pipeline.test.ts index dcdde2a..c6b3762 100644 --- a/tests/ads-video-pipeline.test.ts +++ b/tests/ads-video-pipeline.test.ts @@ -30,6 +30,7 @@ async function haveFfmpeg(): Promise { const snapshot: VideoDesignSnapshot = { headline: "Sources in, feeds out", + subhead: "Every source, one feed.", ctaText: "Start free", domain: "nichedb.dev", bgColor: "#12161f", diff --git a/worker/video.ts b/worker/video.ts index bd1d549..b1f3c76 100644 --- a/worker/video.ts +++ b/worker/video.ts @@ -9,6 +9,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; 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 { uploadRenderedAssets } from "../lib/ads/video/storage"; import { captureFrames } from "./frames"; @@ -60,6 +61,17 @@ export async function processRenderJob( }); const { assets, problems } = await renderPreroll({ + // 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) => { + const probe = await probeMedia(file); + const v = probe.streams.find((st) => st.codec_type === "video"); + return { + width: Number(v?.width ?? 0), + height: Number(v?.height ?? 0), + frames: Number(v?.nb_read_frames ?? 0), + }; + }, snapshot: d.snapshot, workDir, captureFrames,