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
4 changes: 2 additions & 2 deletions app/(app)/dashboard/ads/[id]/edit/edit-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -185,7 +185,7 @@ export function EditCampaignForm({
{current && (
<div className="card space-y-4 p-5">
<div className="flex flex-wrap items-start gap-4">
{AD_FORMATS.map((f) => {
{DESIGN_FORMATS.map((f) => {
const c = creatives.find((x) => x.format === f.id);
if (!c) return null;
return (
Expand Down
6 changes: 3 additions & 3 deletions app/(app)/dashboard/ads/new/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -215,7 +215,7 @@ export function NewAdForm() {
)}
</div>
<div className="flex flex-wrap items-start gap-6">
{AD_FORMATS.map((f) => {
{DESIGN_FORMATS.map((f) => {
const c = creatives.find((x) => x.format === f.id);
if (!c) return null;
return (
Expand All @@ -242,7 +242,7 @@ export function NewAdForm() {
{/* Editor for the active format */}
<div className="card space-y-4 p-5">
<h2 className="font-semibold">
Edit — {AD_FORMATS.find((f) => f.id === active)?.label}
Edit — {DESIGN_FORMATS.find((f) => f.id === active)?.label}
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
Expand Down
3 changes: 2 additions & 1 deletion app/(marketing)/ads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@/lib/ads/pricing";
import {
AD_FORMATS,
DESIGN_FORMATS,
PUBLISHER_FORMAT_IDS,
TERMINAL_FORMAT_ID,
TERMINAL_COLS_LABEL,
Expand Down Expand Up @@ -102,7 +103,7 @@ export default async function AdsMarketingPage() {
/>
<Card
title="Every size at once"
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.`}
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.`}
/>
<Card
title="A budget that actually stops"
Expand Down
11 changes: 9 additions & 2 deletions app/actions/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { isAllowedTargetUrl } from "@/lib/rateLimit";
import { getOrCreateDefaultOrg } from "@/lib/orgs";
import {
generateAdCreatives,
AD_FORMAT_IDS,
DESIGN_FORMAT_IDS,
cleanSummary,
summaryDomain,
type AdCreative,
Expand Down Expand Up @@ -115,7 +115,14 @@ export async function previewAds(input: { url: string }): Promise<
}
}

const ALLOWED_FORMATS = new Set<AdFormatId>(AD_FORMAT_IDS);
// Design formats only. cleanCreative() below produces a design object —
// headline, body, palette — from a client payload, and the streaming pre-roll
// has no design object: its creative is media a worker encodes. Allowing it
// here would let a crafted payload mint a video creative with a headline and no
// bytes behind it, which is the one row selection must never be able to find.
// The DB's ad_creatives_revision_check would refuse the write anyway; refusing
// it here makes that a clean rejection rather than a constraint violation.
const ALLOWED_FORMATS = new Set<AdFormatId>(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})$/;

Expand Down
21 changes: 19 additions & 2 deletions lib/ads/creative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };

Expand Down Expand Up @@ -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,
Expand Down
70 changes: 69 additions & 1 deletion lib/ads/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,79 @@ 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 <pre>, 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 <AdPreview>,
// 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
// default the auto-installer falls back to.
//
// WEB ONLY: these are the formats rendered as an <iframe> by /ad.js and dropped
// into HTML by the GitHub auto-installer. Text/terminal formats must not be in
// this list — they're fetched, not embedded.
// this list — they're fetched, not embedded. Neither must a streaming format:
// the auto-installer would drop a video unit into somebody's HTML with no
// player, no entitlement check and no session, which is three different kinds
// of wrong. Streaming publishers register a property instead.
export const PUBLISHER_FORMAT_IDS: AdFormatId[] = [
"banner_300x250",
"banner_728x90",
Expand Down Expand Up @@ -124,6 +184,14 @@ export function fitAdFormat(
width: number | null | undefined,
allowed: readonly string[] | null | undefined,
): AdFormatId | null {
// A streaming pre-roll is never negotiated here. This function is the single
// gate in front of serveAd(), which renders HTML and ASCII — so refusing the
// format at this one point is what makes "an unrendered video cannot serve"
// true no matter how a slot is configured, including a slot that somehow
// lists it. Video is selected by the playback decision endpoint instead,
// which checks for a published, validated media revision first.
if (isStreamingFormat(requested)) return null;

const offers = (f: AdFormatId) => !Array.isArray(allowed) || allowed.includes(f);
if (!offers(requested)) return null;

Expand Down
Loading