Skip to content

Commit 4e5ccef

Browse files
ralyodioclaude
andauthored
Register the five-second streaming pre-roll format (work package A) (#272)
Schema and format registration for Crawlproof video pre-roll ads. No rendering, selection or serving — those are packages B and D. The exit condition this carries is the narrow one from the spec: existing formats still work, and an unrendered video cannot serve. Registering a media format in AD_FORMATS is not enough on its own, and is actively unsafe: creativesFromCopy() fanned out over the whole registry into one common design object, so the format would have minted a video_preroll_5s creative carrying a headline and a palette, marked ready beside its siblings, and handed an HTML renderer something it would draw as a banner. So the fan-out is now DESIGN_FORMAT_IDS — the complement of the new STREAMING_FORMAT_IDS — and the dashboard preview strips and format editors iterate DESIGN_FORMATS for the same reason. They skip formats with no creative row today, which hides the problem only until the render pipeline lands. Three places now refuse video rather than relying on configuration: * fitAdFormat(), the single gate in front of serveAd(), refuses any streaming format outright — so the refusal holds even for a slot that has somehow been given one. * cleanCreative()'s allowlist is the design formats, so a crafted updateCreatives payload cannot write a medialess video creative. The DB would have refused the write anyway; this makes it a clean rejection rather than a constraint violation. * ad_creatives.published_revision is null until ffprobe has validated an encode, so a video creative is unservable by construction rather than by remembering to check. The migration deliberately breaks from the terminal_ascii and feed_item precedents: it widens the creative CHECK but adds nothing to slot inventory and backfills no creatives. A backfill can clone a headline; it cannot clone a 150-frame H.264 encode. Adds ad_video_jobs, ad_video_assets, ad_streaming_properties, ad_video_decisions and ad_video_events. The decision table's unique (property, session, placement) key is the whole per-session pre-roll rule: a reconnect, a playlist reload or a retry returns the row that exists instead of stacking a second ad. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ff28d0e commit 4e5ccef

9 files changed

Lines changed: 524 additions & 14 deletions

File tree

‎app/(app)/dashboard/ads/[id]/edit/edit-form.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useState, useTransition } from "react";
44
import { useRouter } from "next/navigation";
55
import { updateCampaign, updateCreatives, uploadAdAsset } from "@/app/actions/ads";
6-
import { AD_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
6+
import { DESIGN_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
77
import { AdPreview } from "@/components/ads/ad-preview";
88
import { ColorField } from "@/components/ads/color-field";
99
import type { AdTheme } from "@/lib/ads/theme";
@@ -185,7 +185,7 @@ export function EditCampaignForm({
185185
{current && (
186186
<div className="card space-y-4 p-5">
187187
<div className="flex flex-wrap items-start gap-4">
188-
{AD_FORMATS.map((f) => {
188+
{DESIGN_FORMATS.map((f) => {
189189
const c = creatives.find((x) => x.format === f.id);
190190
if (!c) return null;
191191
return (

‎app/(app)/dashboard/ads/new/form.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useState, useTransition, useRef } from "react";
44
import { useRouter } from "next/navigation";
55
import { previewAds, saveCampaign, uploadAdAsset } from "@/app/actions/ads";
6-
import { AD_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
6+
import { DESIGN_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
77
import type { SiteBrand } from "@/lib/ads/brand";
88
import { AdPreview } from "@/components/ads/ad-preview";
99
import { ColorField } from "@/components/ads/color-field";
@@ -215,7 +215,7 @@ export function NewAdForm() {
215215
)}
216216
</div>
217217
<div className="flex flex-wrap items-start gap-6">
218-
{AD_FORMATS.map((f) => {
218+
{DESIGN_FORMATS.map((f) => {
219219
const c = creatives.find((x) => x.format === f.id);
220220
if (!c) return null;
221221
return (
@@ -242,7 +242,7 @@ export function NewAdForm() {
242242
{/* Editor for the active format */}
243243
<div className="card space-y-4 p-5">
244244
<h2 className="font-semibold">
245-
Edit — {AD_FORMATS.find((f) => f.id === active)?.label}
245+
Edit — {DESIGN_FORMATS.find((f) => f.id === active)?.label}
246246
</h2>
247247
<div className="grid gap-4 sm:grid-cols-2">
248248
<label className="block">

‎app/(marketing)/ads/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "@/lib/ads/pricing";
1212
import {
1313
AD_FORMATS,
14+
DESIGN_FORMATS,
1415
PUBLISHER_FORMAT_IDS,
1516
TERMINAL_FORMAT_ID,
1617
TERMINAL_COLS_LABEL,
@@ -102,7 +103,7 @@ export default async function AdsMarketingPage() {
102103
/>
103104
<Card
104105
title="Every size at once"
105-
body={`${AD_FORMATS.length} formats from one campaign: the standard display sizes, a borderless text link that reads as part of the page, an ASCII unit for terminals, and a syndication item for RSS and Atom feeds.`}
106+
body={`${DESIGN_FORMATS.length} formats from one campaign: the standard display sizes, a borderless text link that reads as part of the page, an ASCII unit for terminals, and a syndication item for RSS and Atom feeds.`}
106107
/>
107108
<Card
108109
title="A budget that actually stops"

‎app/actions/ads.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { isAllowedTargetUrl } from "@/lib/rateLimit";
77
import { getOrCreateDefaultOrg } from "@/lib/orgs";
88
import {
99
generateAdCreatives,
10-
AD_FORMAT_IDS,
10+
DESIGN_FORMAT_IDS,
1111
cleanSummary,
1212
summaryDomain,
1313
type AdCreative,
@@ -115,7 +115,14 @@ export async function previewAds(input: { url: string }): Promise<
115115
}
116116
}
117117

118-
const ALLOWED_FORMATS = new Set<AdFormatId>(AD_FORMAT_IDS);
118+
// Design formats only. cleanCreative() below produces a design object —
119+
// headline, body, palette — from a client payload, and the streaming pre-roll
120+
// has no design object: its creative is media a worker encodes. Allowing it
121+
// here would let a crafted payload mint a video creative with a headline and no
122+
// bytes behind it, which is the one row selection must never be able to find.
123+
// The DB's ad_creatives_revision_check would refuse the write anyway; refusing
124+
// it here makes that a clean rejection rather than a constraint violation.
125+
const ALLOWED_FORMATS = new Set<AdFormatId>(DESIGN_FORMAT_IDS);
119126
// 6- or 8-digit: the editor's opacity slider writes #rrggbbaa.
120127
const HEX = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
121128

‎lib/ads/creative.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export { summaryParagraphs } from "./feeditem";
1515
import {
1616
AD_FORMATS,
1717
AD_FORMAT_IDS,
18+
DESIGN_FORMAT_IDS,
19+
STREAMING_FORMAT_IDS,
20+
VIDEO_FORMAT_ID,
21+
isStreamingFormat,
1822
brandInitial,
1923
formatSpec,
2024
hexToRgba,
@@ -41,7 +45,16 @@ import {
4145

4246
// Re-export the client-safe format primitives so existing server importers of
4347
// this module keep working; client components should import from ./formats.
44-
export { AD_FORMATS, AD_FORMAT_IDS, formatSpec, paletteFor };
48+
export {
49+
AD_FORMATS,
50+
AD_FORMAT_IDS,
51+
DESIGN_FORMAT_IDS,
52+
STREAMING_FORMAT_IDS,
53+
VIDEO_FORMAT_ID,
54+
isStreamingFormat,
55+
formatSpec,
56+
paletteFor,
57+
};
4558
export { renderCreativeText, renderTerminalHtml };
4659
export type { AdCreative, AdFormatId };
4760

@@ -263,7 +276,11 @@ function copyToCreatives(brand: SiteBrand, copy: AdCopy, heroUrl: string | null)
263276
body: copy.body,
264277
ctaText: copy.ctaText || "Learn more",
265278
};
266-
return AD_FORMAT_IDS.map((format) => ({
279+
// DESIGN_FORMAT_IDS, not AD_FORMAT_IDS: the streaming pre-roll is media the
280+
// video worker encodes, not a design object an HTML renderer can take. See
281+
// the constant's comment in ./formats for why fanning out over the whole
282+
// registry is the bug this avoids.
283+
return DESIGN_FORMAT_IDS.map((format) => ({
267284
format,
268285
// tiny banner uses the short headline; others use the full one
269286
headline: format === "banner_320x50" ? copy.shortHeadline || copy.headline : copy.headline,

‎lib/ads/formats.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,19 +72,79 @@ export const AD_FORMATS = [
7272
// pixel box: w/h is only the nominal size the web preview uses when the same
7373
// creative is rendered as HTML. The real dimension is "one item".
7474
{ id: "feed_item", label: "Feed (RSS/Atom/JSON)", w: 600, h: 120 },
75+
// Five-second streaming pre-roll. The only format whose creative is *media*
76+
// rather than markup: w/h is the real 16:9 master frame, not a preview size.
77+
//
78+
// It is deliberately unlike every format above it. The others are generated
79+
// synchronously as a design object and rendered to HTML or text at serve
80+
// time; this one has to be rendered to an MP4 by a background worker before
81+
// it can be shown to anybody, which is why it carries a published media
82+
// revision (see ad_video_assets) and why an unrendered one must never reach
83+
// a viewer. Keep it out of every list that feeds an HTML or text renderer —
84+
// DESIGN_FORMAT_IDS below is the list those call sites want.
85+
{ id: "video_preroll_5s", label: "Streaming Pre-roll (5s)", w: 1920, h: 1080 },
7586
] as const;
7687

7788
export type AdFormatId = (typeof AD_FORMATS)[number]["id"];
7889
export const AD_FORMAT_IDS = AD_FORMATS.map((f) => f.id) as AdFormatId[];
7990

91+
// The streaming pre-roll id. Named for the same reason TERMINAL_FORMAT_ID and
92+
// FEED_FORMAT_ID are: several call sites have to branch on it, and this module
93+
// stays the one place a format id is spelled out.
94+
export const VIDEO_FORMAT_ID = "video_preroll_5s" as const;
95+
96+
/**
97+
* Formats delivered as media the viewer plays, not as a document a renderer
98+
* produces.
99+
*
100+
* Everything outside this list shares one pipeline: copy in, a common design
101+
* object out, HTML or ASCII at serve time. A streaming format shares none of
102+
* it — there is nothing to render into an iframe, a <pre>, or a feed item, and
103+
* the bytes only exist once a worker has encoded them.
104+
*/
105+
export const STREAMING_FORMAT_IDS: AdFormatId[] = [VIDEO_FORMAT_ID];
106+
107+
export function isStreamingFormat(id: string | null | undefined): boolean {
108+
return !!id && (STREAMING_FORMAT_IDS as string[]).includes(id);
109+
}
110+
111+
/**
112+
* The formats the copy generator fans a campaign's approved copy out into.
113+
*
114+
* This is what `creativesFromCopy` iterates, and it used to be AD_FORMAT_IDS
115+
* outright — every registered format got a row in the same common design
116+
* object. That assumption is exactly what a media format breaks: fanning out
117+
* over the full list would mint a `video_preroll_5s` creative carrying a
118+
* headline, a palette and an image URL, mark it `ready` like its siblings, and
119+
* hand an HTML renderer a format it has no template for. The result reads as a
120+
* banner, which is the one outcome a five-second pre-roll must never have.
121+
*
122+
* So the design fan-out is the complement of STREAMING_FORMAT_IDS rather than
123+
* the whole registry. Video creatives are created by the video pipeline, which
124+
* owns their readiness separately.
125+
*/
126+
export const DESIGN_FORMAT_IDS: AdFormatId[] = AD_FORMAT_IDS.filter((f) => !isStreamingFormat(f));
127+
128+
// The same list with its label and nominal box, for the dashboard preview strip
129+
// and format editors. Those surfaces render every entry through <AdPreview>,
130+
// which only knows how to draw markup — so they must iterate this rather than
131+
// AD_FORMATS. They currently skip any format with no creative row, which hides
132+
// the problem while nothing mints a video creative; that stops being true the
133+
// moment the video pipeline lands, and a silent banner-shaped video preview is
134+
// not a failure anyone would notice in review.
135+
export const DESIGN_FORMATS = AD_FORMATS.filter((f) => !isStreamingFormat(f.id));
136+
80137
// Sizes offered to publishers on the Monetize page — the ones they can copy an
81138
// embed for and install. A subset of AD_FORMATS that grows as each size is
82139
// surfaced (one PR per size). Keep the medium rectangle first: it's the safe
83140
// default the auto-installer falls back to.
84141
//
85142
// WEB ONLY: these are the formats rendered as an <iframe> by /ad.js and dropped
86143
// into HTML by the GitHub auto-installer. Text/terminal formats must not be in
87-
// this list — they're fetched, not embedded.
144+
// this list — they're fetched, not embedded. Neither must a streaming format:
145+
// the auto-installer would drop a video unit into somebody's HTML with no
146+
// player, no entitlement check and no session, which is three different kinds
147+
// of wrong. Streaming publishers register a property instead.
88148
export const PUBLISHER_FORMAT_IDS: AdFormatId[] = [
89149
"banner_300x250",
90150
"banner_728x90",
@@ -124,6 +184,14 @@ export function fitAdFormat(
124184
width: number | null | undefined,
125185
allowed: readonly string[] | null | undefined,
126186
): AdFormatId | null {
187+
// A streaming pre-roll is never negotiated here. This function is the single
188+
// gate in front of serveAd(), which renders HTML and ASCII — so refusing the
189+
// format at this one point is what makes "an unrendered video cannot serve"
190+
// true no matter how a slot is configured, including a slot that somehow
191+
// lists it. Video is selected by the playback decision endpoint instead,
192+
// which checks for a published, validated media revision first.
193+
if (isStreamingFormat(requested)) return null;
194+
127195
const offers = (f: AdFormatId) => !Array.isArray(allowed) || allowed.includes(f);
128196
if (!offers(requested)) return null;
129197

0 commit comments

Comments
 (0)