diff --git a/app/actions/ads.ts b/app/actions/ads.ts index 43c91c7..b3bd5b3 100644 --- a/app/actions/ads.ts +++ b/app/actions/ads.ts @@ -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 ?? "", diff --git a/lib/ads/campaigns.ts b/lib/ads/campaigns.ts index 221ba67..2d5bb37 100644 --- a/lib/ads/campaigns.ts +++ b/lib/ads/campaigns.ts @@ -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 ?? "", diff --git a/lib/ads/gif/compose.ts b/lib/ads/gif/compose.ts index a635d12..dd7b910 100644 --- a/lib/ads/gif/compose.ts +++ b/lib/ads/gif/compose.ts @@ -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"; @@ -42,10 +42,21 @@ 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; @@ -53,7 +64,6 @@ 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; @@ -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), }; } @@ -160,8 +171,8 @@ export function gifDocument(input: GifComposeInput): string { const cta = `
${escapeHtml(ctaText)}
`; const inner = unit.row - ? `
${mark}${copy}${cta}
` - : `
+ ? `
${mark}${copy}${cta}
` + : `
${mark}
${copy}
${cta}
@@ -174,10 +185,15 @@ 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 @@ -185,11 +201,11 @@ export function gifDocument(input: GifComposeInput): string { #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} -
+
${inner} @@ -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 @@ -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. @@ -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 diff --git a/lib/ads/video/jobs.ts b/lib/ads/video/jobs.ts index 51fd77e..fbc0d38 100644 --- a/lib/ads/video/jobs.ts +++ b/lib/ads/video/jobs.ts @@ -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"; @@ -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[0]["creatives"]; bumpRevision: boolean; }, ): Promise { 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; diff --git a/lib/ads/video/profiles.ts b/lib/ads/video/profiles.ts index 35c07b3..dcd03d0 100644 --- a/lib/ads/video/profiles.ts +++ b/lib/ads/video/profiles.ts @@ -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 = { diff --git a/scripts/backfill-ad-videos.ts b/scripts/backfill-ad-videos.ts index 19c41e9..ae74164 100644 --- a/scripts/backfill-ad-videos.ts +++ b/scripts/backfill-ad-videos.ts @@ -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 ?? "", diff --git a/tests/ads-gif-pipeline.test.ts b/tests/ads-gif-pipeline.test.ts index 3224fc1..91a0dc9 100644 --- a/tests/ads-gif-pipeline.test.ts +++ b/tests/ads-gif-pipeline.test.ts @@ -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", () => { @@ -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 }); } }); }); @@ -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/); }); }); diff --git a/tests/ads-video-jobs.test.ts b/tests/ads-video-jobs.test.ts index ca28865..40131d0 100644 --- a/tests/ads-video-jobs.test.ts +++ b/tests/ads-video-jobs.test.ts @@ -6,6 +6,7 @@ import { ensureVideoCreative, MAX_RENDER_ATTEMPTS, renderStateLabel, + queueCampaignVideo, snapshotFromCreatives, streamingReady, trimHeadlineForVideo, @@ -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(); + }); +});