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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/actions/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ async function requeueCampaignVideo(
domain:
(campaign.destination_domain as string | null) ??
domainOf(campaign.destination_url as string),
destinationUrl: campaign.destination_url as string,
creatives: rows.map((r) => ({
format: r.format,
headline: r.headline ?? "",
Expand Down
1 change: 1 addition & 0 deletions lib/ads/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export async function createCampaignForUrl(input: {
campaignId: campaign.id,
ownerId: userId,
domain,
destinationUrl: request.url,
creatives: generated.creatives.map((c) => ({
format: c.format,
headline: c.headline ?? "",
Expand Down
67 changes: 41 additions & 26 deletions lib/ads/gif/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
// 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.
// entrance rise, one accent sweep, 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";
Expand All @@ -42,18 +42,28 @@ export function gifUnit(id: string): GifUnit {
return u;
}

/** Beat boundaries, in milliseconds of a 4s loop. */
/**
* Beat boundaries, in milliseconds of a 4s loop.
*
* The still stretches between them are deliberate and are the reason the file
* fits. GIF stores a frame as a rectangle of changed pixels, so a frame
* identical to the one before it costs almost nothing — while a full-width
* sweep running the whole loop makes every single frame a full frame. Motion is
* therefore confined to short windows with the unit at rest between them, which
* reads as a deliberate beat rather than as a limitation.
*/
export const GIF_TIMELINE = {
entranceEndMs: 700,
holdEndMs: 2800,
entranceEndMs: 600,
sweepStartMs: 2200,
sweepEndMs: 3200,
ctaStartMs: 3200,
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;
Expand All @@ -80,17 +90,18 @@ export function gifFrameState(frame: number, reducedMotion: boolean): GifFrameSt
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: 1, 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),
cta: easeOut(phase(timeMs, GIF_TIMELINE.ctaStartMs, GIF_TIMELINE.endMs)),
// One pass across the unit, confined to its own window. Off-screen at both
// ends so the loop point never shows a sweep frozen mid-unit — and, just as
// importantly, every frame outside that window is identical to its
// neighbour and costs the encoder almost nothing.
sweep: -1 + 3 * phase(timeMs, GIF_TIMELINE.sweepStartMs, GIF_TIMELINE.sweepEndMs),
};
}

Expand Down Expand Up @@ -160,8 +171,8 @@ export function gifDocument(input: GifComposeInput): string {
const cta = `<div id="cta" style="flex:0 0 auto"><span style="display:inline-block;background:${accentColor};color:${bgColor};font-weight:600;border-radius:6px;padding:${ctaPad};font-size:${ctaSize}px;white-space:nowrap">${escapeHtml(ctaText)}</span></div>`;

const inner = unit.row
? `<div class="stage" style="display:flex;align-items:center;gap:12px;width:100%;height:100%;padding:0 12px">${mark}${copy}${cta}</div>`
: `<div class="stage" style="display:flex;flex-direction:column;height:100%;padding:14px">
? `<div class="content" style="display:flex;align-items:center;gap:12px;width:100%;height:100%;padding:0 12px">${mark}${copy}${cta}</div>`
: `<div class="content" style="display:flex;flex-direction:column;height:100%;padding:14px">
<div style="display:flex;align-items:center;gap:8px">${mark}</div>
<div style="margin-top:auto">${copy}</div>
<div style="margin-top:10px">${cta}</div>
Expand All @@ -174,22 +185,27 @@ export function gifDocument(input: GifComposeInput): string {
/* Text rendering is pinned so a frame captured now matches one captured
on a differently-configured container. */
-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision}
/* .stage is what the capturer screenshots, so it must BE the unit. It used
to sit on the inner content div, inside this element's 1px border, and
every banner came out two pixels short in each dimension — an off-size
creative, which ad networks reject outright. */
#unit{position:relative;width:${unit.width}px;height:${unit.height}px;overflow:hidden;
border:1px solid ${accentColor}33}
/* The accent wash keeps the middle of a flat unit from reading as a dead
block, and it is what the drift moves. */
block. It is deliberately static: animating it changed every pixel of
every frame, which is a full frame of palette each time. */
#wash{position:absolute;inset:0;z-index:0;
background:radial-gradient(120% 140% at 12% 0%, ${accentColor}22, transparent 60%)}
/* A single specular pass. Cheap in GIF terms because it is the same few
palette entries moving, not new colour. */
#sweep{position:absolute;top:0;bottom:0;width:38%;z-index:1;pointer-events:none;
background:linear-gradient(100deg, transparent, ${accentColor}1f 45%, transparent);
transform:translateX(-120%)}
.stage{position:relative;z-index:2}
.content{position:relative;z-index:2}
#domain{position:absolute;right:6px;bottom:4px;z-index:3;font-size:9px;letter-spacing:.06em;
color:${fgColor};opacity:.45}
</style></head><body>
<div id="unit">
<div id="unit" class="stage">
<div id="wash"></div>
<div id="sweep"></div>
${inner}
Expand All @@ -203,13 +219,12 @@ export function gifDocument(input: GifComposeInput): string {
function phase(ms,a,b){if(b<=a)return ms>=b?1:0;return Math.min(1,Math.max(0,(ms-a)/(b-a)));}
function state(frame){
var timeMs=(frame/GIF_FPS)*1000;
if(REDUCED)return{timeMs:timeMs,entrance:1,drift:0,cta:1,sweep:2};
if(REDUCED)return{timeMs:timeMs,entrance:1,cta:1,sweep:2};
return{
timeMs:timeMs,
entrance:easeOut(phase(timeMs,0,T.entranceEndMs)),
drift:phase(timeMs,T.entranceEndMs,T.holdEndMs),
cta:easeOut(phase(timeMs,T.holdEndMs,T.endMs)),
sweep:-1+3*phase(timeMs,T.entranceEndMs,T.holdEndMs)
cta:easeOut(phase(timeMs,T.ctaStartMs,T.endMs)),
sweep:-1+3*phase(timeMs,T.sweepStartMs,T.sweepEndMs)
};
}
// Drive every animated value from the frame index. No CSS transitions or
Expand All @@ -221,7 +236,6 @@ export function gifDocument(input: GifComposeInput): string {
var mark = document.getElementById('mark');
var cta = document.getElementById('cta');
var sweep = document.getElementById('sweep');
var wash = document.getElementById('wash');

// Entrance: copy rises a few pixels into place and fades up. Small, because
// a banner is read in a glance and a long entrance wastes most of the loop.
Expand All @@ -230,9 +244,10 @@ export function gifDocument(input: GifComposeInput): string {
copy.style.opacity = (0.15 + 0.85 * s.entrance).toFixed(3);
mark.style.opacity = (0.3 + 0.7 * s.entrance).toFixed(3);

// Hold: the wash drifts slowly so the unit is never completely static,
// which is the whole reason an animated banner outperforms a flat one.
wash.style.transform = 'translateX(' + (s.drift * 4).toFixed(2) + 'px)';
// The wash is fixed. It used to drift across the hold, which changed every
// pixel of every frame and accounted for most of the file size, in service
// of motion nobody could see. The sweep is the thing that moves, and only
// inside its window.
sweep.style.transform = 'translateX(' + (s.sweep * 120).toFixed(2) + '%)';

// Final beat: the CTA lifts slightly and reaches full strength. It ends the
Expand Down
20 changes: 20 additions & 0 deletions lib/ads/video/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// never a precondition for one.

import type { SupabaseClient } from "@supabase/supabase-js";
import { classifyCampaign } from "./classify";
import type { AdCreative } from "../formats";
import { VIDEO_FORMAT_ID } from "../formats";
import { MAX_HEADLINE_WORDS, renderHash, validateSnapshot, type VideoDesignSnapshot } from "./snapshot";
Expand Down Expand Up @@ -339,11 +340,30 @@ export async function queueCampaignVideo(
campaignId: string;
ownerId: string;
domain: string;
/**
* The campaign's destination, used to decide whether it gets media at all.
* Optional only so a caller without it degrades to rendering rather than to
* silently skipping.
*/
destinationUrl?: string | null;
creatives: Parameters<typeof snapshotFromCreatives>[0]["creatives"];
bumpRevision: boolean;
},
): Promise<RenderHandle | null> {
try {
// Product ads only. The backfill classified campaigns before queueing them,
// but every other path — the dashboard save, and the shared creator behind
// the public API — queued a render for anything, so campaigns pointing at
// blog posts and social profiles were getting video and animated banners
// that were explicitly out of scope.
//
// Deciding it here rather than in each caller is the same lesson the API
// gap taught: a rule enforced at one of three call sites is a rule that
// holds until someone adds a fourth.
if (args.destinationUrl && classifyCampaign(args.destinationUrl) !== "product") {
return null;
}

const snapshot = snapshotFromCreatives({ creatives: args.creatives, domain: args.domain });
if (!snapshot) return null;

Expand Down
15 changes: 9 additions & 6 deletions lib/ads/video/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,16 @@ export function isGifProfile(id: string): id is GifProfileId {
* 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.
* of frame rates are exactly representable: 10fps is exactly 10cs, where 12 or
* 15 would drift and make the loop stutter.
*
* 40 frames rather than 50, because size is the binding constraint and every
* frame costs. What actually keeps the file small is not the frame count
* though — it is holding most of those frames still, so the encoder's
* per-frame diff is a small rectangle instead of the whole unit.
*/
export const GIF_FPS = 12.5;
export const GIF_FRAMES = 50;
export const GIF_FPS = 10;
export const GIF_FRAMES = 40;
export const GIF_MS = (GIF_FRAMES / GIF_FPS) * 1000; // 4000

export type VideoProfile = {
Expand Down
1 change: 1 addition & 0 deletions scripts/backfill-ad-videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ for (const [i, c] of targets.entries()) {
campaignId: c.id,
ownerId: c.owner_id,
domain: c.destination_domain ?? new URL(c.destination_url).hostname.replace(/^www\./, ""),
destinationUrl: c.destination_url,
creatives: usable.map((r) => ({
format: r.format,
headline: r.headline ?? "",
Expand Down
26 changes: 21 additions & 5 deletions tests/ads-gif-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,30 @@ describe("the banner timeline is a pure function of the frame index", () => {
// 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
// Nearly 1, deliberately not exactly 1. The 40 frames cover [0, 4000) at
// 100ms each, so the final frame sits at 3900ms 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);
expect(last.timeMs).toBe(3900);
});

it("holds most of the loop perfectly still", () => {
// This is what keeps the file inside the budget. GIF stores a frame as the
// rectangle of pixels that changed, so a frame identical to its neighbour
// is nearly free — and a sweep running the whole loop would make every
// frame a full frame. Asserted because it is a size requirement wearing a
// timeline's clothes: lose it and the banners silently stop fitting.
const t = gifTimeline(false);
let moving = 0;
for (let i = 1; i < t.length; i++) {
const a = t[i - 1];
const b = t[i];
if (a.entrance !== b.entrance || a.cta !== b.cta || a.sweep !== b.sweep) moving++;
}
expect(moving).toBeLessThan(t.length * 0.62);
});

it("ends the loop with the sweep off the unit", () => {
Expand All @@ -68,7 +84,7 @@ describe("the banner timeline is a pure function of the frame index", () => {
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 });
expect(s).toMatchObject({ entrance: 1, cta: 1, sweep: 2 });
}
});
});
Expand Down Expand Up @@ -163,7 +179,7 @@ describe("validation refuses what cannot be trafficked", () => {
});
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, frames: 12 })[0]).toMatch(/expected 40 frames/);
expect(validateGif({ ...base, byteSize: 200 * 1024 })[0]).toMatch(/exceeds/);
});
});
Expand Down
66 changes: 66 additions & 0 deletions tests/ads-video-jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ensureVideoCreative,
MAX_RENDER_ATTEMPTS,
renderStateLabel,
queueCampaignVideo,
snapshotFromCreatives,
streamingReady,
trimHeadlineForVideo,
Expand Down Expand Up @@ -332,3 +333,68 @@ describe("status presentation", () => {
}
});
});

describe("only product campaigns get media", () => {
const creatives = [design("banner_300x250")];

it("skips a campaign that points at a blog post", async () => {
// The backfill classified before queueing, but the dashboard save and the
// public API queued anything — so blog campaigns were getting video and
// animated banners that were explicitly out of scope. The rule belongs
// where all three paths pass through.
const db = fakeDb({ existingJob: null, existingCreative: null });
const res = await queueCampaignVideo(db.client as never, {
campaignId: "c",
ownerId: "o",
domain: "dev.to",
destinationUrl: "https://dev.to/chovy/some-post-abc",
creatives,
bumpRevision: false,
});
expect(res).toBeNull();
// Nothing written at all: no creative row, no job.
expect(db.inserts).toHaveLength(0);
});

it("skips a social profile", async () => {
const db = fakeDb({ existingJob: null, existingCreative: null });
const res = await queueCampaignVideo(db.client as never, {
campaignId: "c",
ownerId: "o",
domain: "x.com",
destinationUrl: "https://x.com/someone",
creatives,
bumpRevision: false,
});
expect(res).toBeNull();
expect(db.inserts).toHaveLength(0);
});

it("renders a product campaign", async () => {
const db = fakeDb({ existingJob: null, existingCreative: null });
const res = await queueCampaignVideo(db.client as never, {
campaignId: "c",
ownerId: "o",
domain: "moshcoding.com",
destinationUrl: "https://moshcoding.com/",
creatives,
bumpRevision: false,
});
expect(res).not.toBeNull();
});

it("renders when no destination is known, rather than silently skipping", async () => {
// Degrading to "render it" is the safer default: a caller that forgets to
// pass the URL produces an extra video, not a campaign that mysteriously
// never gets one.
const db = fakeDb({ existingJob: null, existingCreative: null });
const res = await queueCampaignVideo(db.client as never, {
campaignId: "c",
ownerId: "o",
domain: "example.com",
creatives,
bumpRevision: false,
});
expect(res).not.toBeNull();
});
});