From e44ac0ae0c54c37c97b2117a8f48ad9d9bfae168 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 12:11:55 +0000 Subject: [PATCH] Register the five-second streaming pre-roll format (work package A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../dashboard/ads/[id]/edit/edit-form.tsx | 4 +- app/(app)/dashboard/ads/new/form.tsx | 6 +- app/(marketing)/ads/page.tsx | 3 +- app/actions/ads.ts | 11 +- lib/ads/creative.ts | 21 +- lib/ads/formats.ts | 70 ++++- .../20260924130000_ad_video_preroll.sql | 291 ++++++++++++++++++ tests/ads-template-copy.test.ts | 8 +- tests/ads-video-format.test.ts | 124 ++++++++ 9 files changed, 524 insertions(+), 14 deletions(-) create mode 100644 supabase/migrations/20260924130000_ad_video_preroll.sql create mode 100644 tests/ads-video-format.test.ts diff --git a/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx b/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx index da3c66b..272852b 100644 --- a/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx +++ b/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx @@ -3,7 +3,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { updateCampaign, updateCreatives, uploadAdAsset } from "@/app/actions/ads"; -import { AD_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; +import { DESIGN_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; import { AdPreview } from "@/components/ads/ad-preview"; import { ColorField } from "@/components/ads/color-field"; import type { AdTheme } from "@/lib/ads/theme"; @@ -185,7 +185,7 @@ export function EditCampaignForm({ {current && (
- {AD_FORMATS.map((f) => { + {DESIGN_FORMATS.map((f) => { const c = creatives.find((x) => x.format === f.id); if (!c) return null; return ( diff --git a/app/(app)/dashboard/ads/new/form.tsx b/app/(app)/dashboard/ads/new/form.tsx index 128b202..848424c 100644 --- a/app/(app)/dashboard/ads/new/form.tsx +++ b/app/(app)/dashboard/ads/new/form.tsx @@ -3,7 +3,7 @@ import { useState, useTransition, useRef } from "react"; import { useRouter } from "next/navigation"; import { previewAds, saveCampaign, uploadAdAsset } from "@/app/actions/ads"; -import { AD_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; +import { DESIGN_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; import type { SiteBrand } from "@/lib/ads/brand"; import { AdPreview } from "@/components/ads/ad-preview"; import { ColorField } from "@/components/ads/color-field"; @@ -215,7 +215,7 @@ export function NewAdForm() { )}
- {AD_FORMATS.map((f) => { + {DESIGN_FORMATS.map((f) => { const c = creatives.find((x) => x.format === f.id); if (!c) return null; return ( @@ -242,7 +242,7 @@ export function NewAdForm() { {/* Editor for the active format */}

- Edit — {AD_FORMATS.find((f) => f.id === active)?.label} + Edit — {DESIGN_FORMATS.find((f) => f.id === active)?.label}