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,