diff --git a/.changeset/image-carousel-card-image-sizes.md b/.changeset/image-carousel-card-image-sizes.md new file mode 100644 index 00000000000..b0a3ae6f713 --- /dev/null +++ b/.changeset/image-carousel-card-image-sizes.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +`image_carousel` gains an optional `card_image_sizes` parameter (adcp#8911): a list of accepted logical `{ width, height }` pairs for card images, the same shape as `native_in_feed`'s `main_image_sizes`. Each image card's `media` must match one listed pair exactly; a larger image at the same ratio does not match. When the `cards` slot declares `pixel_ratios`, the intrinsic size is the matched pair times the selected ratio. All card images in one creative match pairs of the same aspect ratio. The field does not constrain video card media. When `card_aspect_ratio` is also declared, every listed pair must have that ratio within 1%; a declaration where they disagree is invalid. Additive and optional: existing declarations are unchanged, and a seller that omits the field leaves card pixel size unconstrained beyond `card_aspect_ratio`. diff --git a/docs/creative/channels/carousels.mdx b/docs/creative/channels/carousels.mdx index fb5bd990246..dde06f2c35f 100644 --- a/docs/creative/channels/carousels.mdx +++ b/docs/creative/channels/carousels.mdx @@ -13,6 +13,8 @@ Use `image_carousel` when one creative contains an ordered set of cards. Card co "params": { "min_cards": 3, "max_cards": 10, + "card_aspect_ratio": "1:1", + "card_image_sizes": [{ "width": 1080, "height": 1080 }], "slots": [ { "asset_group_id": "cards", "asset_type": "card", "required": true, "min": 3, "max": 10 }, { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } @@ -49,6 +51,15 @@ Use `image_carousel` when one creative contains an ordered set of cards. Card co } ``` +## Card image size + +`card_aspect_ratio` fixes the shape every card shares. `card_image_sizes` (added in AdCP 3.3) fixes the pixel size of card images. It is a list of accepted logical `{ width, height }` pairs, the same shape as `main_image_sizes` on `native_in_feed`. + +- **Exact match.** Each card whose `media` is an `image` asset MUST match one listed pair. A larger image at the same ratio does not match. When the `cards` slot declares `pixel_ratios`, the intrinsic size is the matched pair times the selected ratio, as described under [pixel ratios](/docs/creative/canonical-formats); without it, 1x. +- **One shape per carousel.** Mixed orientations are not allowed, so every card image in one creative matches pairs of the same aspect ratio. A product that accepts 1:1 and 4:5 cards lists both, and each creative picks one. +- **Images only.** `card_image_sizes` does not constrain video card media. Video cards follow `card_video_max_file_size_kb` and `card_video_max_duration_ms`. +- **Agreement with `card_aspect_ratio`.** When both are declared, every listed pair MUST have that ratio, within 1% (so 1200×627 matches `1.91:1`). A declaration where they disagree is invalid; neither field wins. When only `card_image_sizes` is declared, the listed sizes imply the accepted card ratios. When only `card_aspect_ratio` is declared, buyers match the ratio and choose the pixel size. + For catalog-composed collections where the seller reads product rows and renders tiles, use `sponsored_placement` rather than a buyer-authored carousel bundle. See [Catalogs](/docs/creative/catalogs) and [Canonical formats](/docs/creative/canonical-formats). diff --git a/docs/reference/whats-new-in-3-3.mdx b/docs/reference/whats-new-in-3-3.mdx index 60c66a14479..12b7c3ee136 100644 --- a/docs/reference/whats-new-in-3-3.mdx +++ b/docs/reference/whats-new-in-3-3.mdx @@ -155,6 +155,10 @@ A buyer agent can now make itself known to a seller it has never spoken to. The The `pixel_tracker` `event` enum adds `start`, `first_quartile`, `midpoint`, `third_quartile` and `complete`, so a buyer can attach quartile measurement to `video_hosted` and `audio_hosted` files instead of sending `event: custom` names no seller is obliged to recognize. Downgrades to 3.0.x sellers drop these trackers with an advisory, and 3.2 buyers send them as `custom` events named for the 3.3 value until they move. See the [migration guide](/docs/reference/migration/3-2-to-3-3#hosted-video-and-audio-playback-trackers). +## Carousel card image size + +`image_carousel` gains an optional `card_image_sizes` list of accepted `{ width, height }` pairs for card images, the same shape as `native_in_feed`'s `main_image_sizes`. Card images match a listed pair exactly, video cards are not constrained, and a declaration whose sizes disagree with `card_aspect_ratio` is invalid. See [Carousel ads](/docs/creative/channels/carousels#card-image-size) and [#8911](https://github.com/adcontextprotocol/adcp/issues/8911). + ## Platform object IDs have one name `sync_event_sources` and `sync_audiences` results now use optional `platform_id` for the object's ID in the seller's ad platform, matching creatives and catalogs. A seller reporting this ID in 3.3 and later 3.x responses MUST emit both `platform_id` and the deprecated `seller_id` alias with identical values, including discovery responses. Buyers MUST prefer `platform_id` and fall back to `seller_id` only when the new field is absent. Continue using `event_source_id` and `audience_id` in AdCP requests. The aliases keep working throughout 3.x; removal is no earlier than 4.0 under the six-month notice policy. Seller identity in `adagents.json` and publisher identifiers is unchanged. See [Deprecations](/docs/reference/migration/3-2-to-3-3#deprecations). diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index e2207da114e..4c9c2ef3252 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -2041,11 +2041,12 @@ function packageNeedsCreative(pkg: PackageState, session: SessionState, tracksCo return pkg.creativeAssignments.length === 0; } -function packageReadinessFields(pkg: PackageState, session: SessionState): Record { +function packageReadinessFields(pkg: PackageState, session: SessionState, ctx: TrainingContext): Record { if (!pkg.formatsToProvide?.length) return {}; + const snapshot = 'core/package-format-snapshot.json'; return { - formats_to_provide: pkg.formatsToProvide, - formats_pending: formatsPendingForPackage(pkg, session), + formats_to_provide: formatDeclarationsForServedRelease(pkg.formatsToProvide, snapshot, ctx.servedAdcpVersion), + formats_pending: formatDeclarationsForServedRelease(formatsPendingForPackage(pkg, session), snapshot, ctx.servedAdcpVersion), }; } @@ -3155,13 +3156,37 @@ function productForThreeZeroStoryboardCompat(product: Product): Product { return rest as Product; } +/** + * Format declarations without the params a later release adds, such as the + * 3.3 `card_image_sizes` on `image_carousel`, for a buyer pinned to an earlier + * release (DR-0024). Each entry is graded against `schemaFile`. + */ +function formatDeclarationsForServedRelease( + declarations: readonly T[], + schemaFile: string, + servedAdcpVersion: string | undefined, +): T[] { + if (servedAdcpVersion === undefined) return declarations as T[]; + return declarations.map(declaration => omitFieldsAddedAfterRelease(declaration, schemaFile, servedAdcpVersion)); +} + +function productFormatOptionsForServedRelease(product: Product, servedAdcpVersion: string | undefined): Product { + const declared = product.format_options; + if (!Array.isArray(declared)) return product; + const served = formatDeclarationsForServedRelease(declared, 'core/product-format-declaration.json', servedAdcpVersion); + return served.every((option, index) => option === declared[index]) + ? product + : { ...product, format_options: served } as Product; +} + function productForServedAdcpVersion(product: Product, servedAdcpVersion: string | undefined): Product { - if (supportsGetProductsRejected(servedAdcpVersion)) return product; + const servedFormats = productFormatOptionsForServedRelease(product, servedAdcpVersion); + if (supportsGetProductsRejected(servedAdcpVersion)) return servedFormats; const { audience_activation: _audienceActivation, overlay_support: _overlaySupport, ...rest - } = product as Product & { + } = servedFormats as Product & { audience_activation?: unknown; overlay_support?: unknown; }; @@ -17134,7 +17159,7 @@ async function handleCreateMediaBuyUnlocked( start_time: pkg.startTime, end_time: pkg.endTime, ...packageFormatSelectorForWire(pkg, ctx), - ...packageReadinessFields(pkg, session), + ...packageReadinessFields(pkg, session, ctx), ...creativeStartPolicyForWire(ctx), ...(pkg.targeting && { targeting_overlay: targetingForWire(pkg.targeting) }), ...(pkg.targetingResolution && { targeting_resolution: pkg.targetingResolution }), @@ -17338,7 +17363,7 @@ export async function handleGetMediaBuys(args: ToolArgs, ctx: TrainingContext): start_time: pkg.startTime, end_time: pkg.endTime, ...packageFormatSelectorForWire(pkg, ctx), - ...packageReadinessFields(pkg, session), + ...packageReadinessFields(pkg, session, ctx), ...creativeStartPolicyForWire(ctx), creative_approvals: pkg.creativeAssignments.map(cid => ({ creative_id: cid, @@ -20694,7 +20719,7 @@ async function handleUpdateMediaBuyUnlocked( start_time: pkg.startTime, end_time: pkg.endTime, ...packageFormatSelectorForWire(pkg, ctx), - ...packageReadinessFields(pkg, session), + ...packageReadinessFields(pkg, session, ctx), ...creativeStartPolicyForWire(ctx), ...(pkg.targeting && { targeting_overlay: targetingForWire(pkg.targeting) }), ...(pkg.targetingResolution && { targeting_resolution: pkg.targetingResolution }), diff --git a/server/tests/unit/training-agent-list-products-canonical.test.ts b/server/tests/unit/training-agent-list-products-canonical.test.ts index 1f798088366..a831473dacb 100644 --- a/server/tests/unit/training-agent-list-products-canonical.test.ts +++ b/server/tests/unit/training-agent-list-products-canonical.test.ts @@ -268,7 +268,14 @@ describe('list_products canonical product projection', () => { expect([...threeThree.values()].some(product => Array.isArray(product.installments))).toBe(true); for (const product of threeTwo) { const { installments: _installments, ...rest } = threeThree.get(product.product_id)!; - expect(product).toEqual(rest); + // The 3.3 `card_image_sizes` carousel param is withheld from 3.2 too. + const formatOptions = (rest as { format_options?: unknown[] }).format_options?.map(option => { + const { params, ...declaration } = option as { params?: Record }; + if (!params || !('card_image_sizes' in params)) return option; + const { card_image_sizes: _cardImageSizes, ...threeTwoParams } = params; + return { ...declaration, params: threeTwoParams }; + }); + expect(product).toEqual(formatOptions ? { ...rest, format_options: formatOptions } : rest); } const slotProduct = [...threeThree.values()].find(product => Array.isArray(product.installments)); diff --git a/static/examples/products/canonical/meta_carousel.json b/static/examples/products/canonical/meta_carousel.json index 1cf736cc6e3..46c302f7c71 100644 --- a/static/examples/products/canonical/meta_carousel.json +++ b/static/examples/products/canonical/meta_carousel.json @@ -44,6 +44,12 @@ "format_kind": "image_carousel", "params": { "card_aspect_ratio": "1:1", + "card_image_sizes": [ + { + "width": 1080, + "height": 1080 + } + ], "min_cards": 2, "max_cards": 10, "allowed_card_media_asset_types": [ diff --git a/static/schemas/source/formats/canonical/image_carousel.json b/static/schemas/source/formats/canonical/image_carousel.json index 5f5c03b1606..a50946fd20e 100644 --- a/static/schemas/source/formats/canonical/image_carousel.json +++ b/static/schemas/source/formats/canonical/image_carousel.json @@ -4,6 +4,13 @@ "title": "Canonical Format: Image Carousel", "description": "Multi-card swipeable carousel. The buyer ships a `cards` slot whose value is an **array** of [card-asset](/schemas/core/assets/card-asset.json) objects (a single key with an array value — NOT one key per card, NOT dotted/bracketed paths). Each card-asset carries: `asset_type: \"card\"`, `media` (an image or video asset), optional `headline` (text), optional `landing_page_url` (url asset). Per-card structure is the same across all cards; mixed orientations not allowed within a single carousel. Tracking model: per-card impression and engagement pixels + carousel-level engagement (swipe, view-time). Allowed asset types for a card's `media` field: `image` and `video` (Meta-style mixed-media); platforms can narrow to image-only or video-only via `allowed_card_media_asset_types`.\n\nThe manifest's `assets.cards` value is an array of card-asset objects. Example: `\"cards\": [{\"asset_type\": \"card\", \"media\": {\"asset_type\": \"image\", \"url\": \"...\"}, \"headline\": \"Buy now\", \"landing_page_url\": {\"asset_type\": \"url\", \"url_type\": \"clickthrough\", \"url\": \"...\"}}, ...]`. Each card-asset validates against the card schema; per-card platform extensions attach via the card's `platform_extensions` field, never via inline non-canonical keys.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "x-adcp-validation": { + "verifier_constraints": { + "card_size_ratio_agreement": "Every card_image_sizes pair is within 1% of card_aspect_ratio when both are declared; otherwise the declaration is invalid", + "card_image_size_match": "Each image card matches one card_image_sizes pair (times the cards slot pixel ratio), and all image cards in a creative share one aspect ratio" + }, + "spec": "docs/creative/channels/carousels.mdx" + }, "properties": { "v1_translatable": { "default": false, @@ -20,7 +27,22 @@ "card_aspect_ratio": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$", - "description": "Aspect ratio shared across all cards (e.g., '1:1', '1.91:1', '4:5')." + "description": "Aspect ratio shared across all cards (e.g., '1:1', '1.91:1', '4:5'). When `card_image_sizes` is also declared, every listed size MUST have this ratio; see `card_image_sizes`." + }, + "card_image_sizes": { + "x-added-in": "3.3.0", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + "description": "Accepted logical (width, height) pairs for image card media, the same shape as `native_in_feed` `main_image_sizes`. Each card image MUST match one pair exactly (a larger image at the same ratio does not match), times the selected ratio when the `cards` slot declares `pixel_ratios`. All card images in one creative MUST share one aspect ratio. Does not constrain video card media. When `card_aspect_ratio` is also declared, every pair MUST have that ratio within 1% (1200×627 matches '1.91:1'); a disagreeing declaration is invalid and neither field wins. Without `card_aspect_ratio`, the sizes imply the accepted ratios." }, "min_cards": { "type": "integer", diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index 07d6ff758ff..a296add80ce 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -201,7 +201,9 @@ const MODEL_CONTEXT_BUDGET_KIB = { // 1_376_000. // Experimental match keys (#8375) reach the seeded Product through // Product.identity.match_keys: 1_375_833 → 1_383_804 bytes. Bound at 1_384_000. -const PARITY_COMPILE_LIMIT = 1_384_000; +// image_carousel `card_image_sizes` (#8911) reaches the seeded Product's +// format_options: 1_384_855 bytes. Bound at 1_385_000. +const PARITY_COMPILE_LIMIT = 1_385_000; function readJson(filename) { return JSON.parse(fs.readFileSync(filename, 'utf8')); diff --git a/tests/x-added-in-3-3-sweep.test.cjs b/tests/x-added-in-3-3-sweep.test.cjs index 0b867354f16..82ccec22258 100644 --- a/tests/x-added-in-3-3-sweep.test.cjs +++ b/tests/x-added-in-3-3-sweep.test.cjs @@ -80,6 +80,7 @@ const PROPERTIES_3_3 = [ ['protocol/get-adcp-capabilities-response.json', ['properties', 'governance', 'properties', 'supports_budget_periods']], ['protocol/get-adcp-capabilities-response.json', ['properties', 'request_signing', 'properties', 'operation_sources']], ['formats/canonical/video_hosted.json', ['properties', 'loudness']], + ['formats/canonical/image_carousel.json', ['properties', 'card_image_sizes']], ['core/installment-deadlines.json', ['properties', 'change_cutoff']], ['core/reporting-status-issue.json', ['properties', 'reporting_revision_id']], ['governance/check-governance-request.json', ['properties', 'budget_period_id']],