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
5 changes: 5 additions & 0 deletions .changeset/image-carousel-card-image-sizes.md
Original file line number Diff line number Diff line change
@@ -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`.
11 changes: 11 additions & 0 deletions docs/creative/channels/carousels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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).
4 changes: 4 additions & 0 deletions docs/reference/whats-new-in-3-3.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
41 changes: 33 additions & 8 deletions server/src/training-agent/task-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2041,11 +2041,12 @@ function packageNeedsCreative(pkg: PackageState, session: SessionState, tracksCo
return pkg.creativeAssignments.length === 0;
}

function packageReadinessFields(pkg: PackageState, session: SessionState): Record<string, unknown> {
function packageReadinessFields(pkg: PackageState, session: SessionState, ctx: TrainingContext): Record<string, unknown> {
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),
};
}

Expand Down Expand Up @@ -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<T>(
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;
};
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> };
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));
Expand Down
6 changes: 6 additions & 0 deletions static/examples/products/canonical/meta_carousel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
24 changes: 23 additions & 1 deletion static/schemas/source/formats/canonical/image_carousel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion tests/mcp-schema-projection.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
1 change: 1 addition & 0 deletions tests/x-added-in-3-3-sweep.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']],
Expand Down
Loading