+ 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 */}
+
+
{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,