(DESIGN_FORMAT_IDS);
// 6- or 8-digit: the editor's opacity slider writes #rrggbbaa.
const HEX = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
diff --git a/lib/ads/creative.ts b/lib/ads/creative.ts
index 795475a..a26bdc3 100644
--- a/lib/ads/creative.ts
+++ b/lib/ads/creative.ts
@@ -15,6 +15,10 @@ export { summaryParagraphs } from "./feeditem";
import {
AD_FORMATS,
AD_FORMAT_IDS,
+ DESIGN_FORMAT_IDS,
+ STREAMING_FORMAT_IDS,
+ VIDEO_FORMAT_ID,
+ isStreamingFormat,
brandInitial,
formatSpec,
hexToRgba,
@@ -41,7 +45,16 @@ import {
// Re-export the client-safe format primitives so existing server importers of
// this module keep working; client components should import from ./formats.
-export { AD_FORMATS, AD_FORMAT_IDS, formatSpec, paletteFor };
+export {
+ AD_FORMATS,
+ AD_FORMAT_IDS,
+ DESIGN_FORMAT_IDS,
+ STREAMING_FORMAT_IDS,
+ VIDEO_FORMAT_ID,
+ isStreamingFormat,
+ formatSpec,
+ paletteFor,
+};
export { renderCreativeText, renderTerminalHtml };
export type { AdCreative, AdFormatId };
@@ -263,7 +276,11 @@ function copyToCreatives(brand: SiteBrand, copy: AdCopy, heroUrl: string | null)
body: copy.body,
ctaText: copy.ctaText || "Learn more",
};
- return AD_FORMAT_IDS.map((format) => ({
+ // DESIGN_FORMAT_IDS, not AD_FORMAT_IDS: the streaming pre-roll is media the
+ // video worker encodes, not a design object an HTML renderer can take. See
+ // the constant's comment in ./formats for why fanning out over the whole
+ // registry is the bug this avoids.
+ return DESIGN_FORMAT_IDS.map((format) => ({
format,
// tiny banner uses the short headline; others use the full one
headline: format === "banner_320x50" ? copy.shortHeadline || copy.headline : copy.headline,
diff --git a/lib/ads/formats.ts b/lib/ads/formats.ts
index ab4fc31..9f48da7 100644
--- a/lib/ads/formats.ts
+++ b/lib/ads/formats.ts
@@ -72,11 +72,68 @@ export const AD_FORMATS = [
// pixel box: w/h is only the nominal size the web preview uses when the same
// creative is rendered as HTML. The real dimension is "one item".
{ id: "feed_item", label: "Feed (RSS/Atom/JSON)", w: 600, h: 120 },
+ // Five-second streaming pre-roll. The only format whose creative is *media*
+ // rather than markup: w/h is the real 16:9 master frame, not a preview size.
+ //
+ // It is deliberately unlike every format above it. The others are generated
+ // synchronously as a design object and rendered to HTML or text at serve
+ // time; this one has to be rendered to an MP4 by a background worker before
+ // it can be shown to anybody, which is why it carries a published media
+ // revision (see ad_video_assets) and why an unrendered one must never reach
+ // a viewer. Keep it out of every list that feeds an HTML or text renderer —
+ // DESIGN_FORMAT_IDS below is the list those call sites want.
+ { id: "video_preroll_5s", label: "Streaming Pre-roll (5s)", w: 1920, h: 1080 },
] as const;
export type AdFormatId = (typeof AD_FORMATS)[number]["id"];
export const AD_FORMAT_IDS = AD_FORMATS.map((f) => f.id) as AdFormatId[];
+// The streaming pre-roll id. Named for the same reason TERMINAL_FORMAT_ID and
+// FEED_FORMAT_ID are: several call sites have to branch on it, and this module
+// stays the one place a format id is spelled out.
+export const VIDEO_FORMAT_ID = "video_preroll_5s" as const;
+
+/**
+ * Formats delivered as media the viewer plays, not as a document a renderer
+ * produces.
+ *
+ * Everything outside this list shares one pipeline: copy in, a common design
+ * object out, HTML or ASCII at serve time. A streaming format shares none of
+ * it — there is nothing to render into an iframe, a , or a feed item, and
+ * the bytes only exist once a worker has encoded them.
+ */
+export const STREAMING_FORMAT_IDS: AdFormatId[] = [VIDEO_FORMAT_ID];
+
+export function isStreamingFormat(id: string | null | undefined): boolean {
+ return !!id && (STREAMING_FORMAT_IDS as string[]).includes(id);
+}
+
+/**
+ * The formats the copy generator fans a campaign's approved copy out into.
+ *
+ * This is what `creativesFromCopy` iterates, and it used to be AD_FORMAT_IDS
+ * outright — every registered format got a row in the same common design
+ * object. That assumption is exactly what a media format breaks: fanning out
+ * over the full list would mint a `video_preroll_5s` creative carrying a
+ * headline, a palette and an image URL, mark it `ready` like its siblings, and
+ * hand an HTML renderer a format it has no template for. The result reads as a
+ * banner, which is the one outcome a five-second pre-roll must never have.
+ *
+ * So the design fan-out is the complement of STREAMING_FORMAT_IDS rather than
+ * the whole registry. Video creatives are created by the video pipeline, which
+ * owns their readiness separately.
+ */
+export const DESIGN_FORMAT_IDS: AdFormatId[] = AD_FORMAT_IDS.filter((f) => !isStreamingFormat(f));
+
+// The same list with its label and nominal box, for the dashboard preview strip
+// and format editors. Those surfaces render every entry through ,
+// which only knows how to draw markup — so they must iterate this rather than
+// AD_FORMATS. They currently skip any format with no creative row, which hides
+// the problem while nothing mints a video creative; that stops being true the
+// moment the video pipeline lands, and a silent banner-shaped video preview is
+// not a failure anyone would notice in review.
+export const DESIGN_FORMATS = AD_FORMATS.filter((f) => !isStreamingFormat(f.id));
+
// Sizes offered to publishers on the Monetize page — the ones they can copy an
// embed for and install. A subset of AD_FORMATS that grows as each size is
// surfaced (one PR per size). Keep the medium rectangle first: it's the safe
@@ -84,7 +141,10 @@ export const AD_FORMAT_IDS = AD_FORMATS.map((f) => f.id) as AdFormatId[];
//
// WEB ONLY: these are the formats rendered as an