diff --git a/.changeset/image-carousel-cta-values.md b/.changeset/image-carousel-cta-values.md new file mode 100644 index 0000000000..d3d9b00abb --- /dev/null +++ b/.changeset/image-carousel-cta-values.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +`image_carousel` gains an optional `cta_values` parameter (adcp#8960), the same type as `cta_values` on `native_in_feed` and `image`. It governs each card's `cta`: when declared, every card's `cta` must be one of the listed values, and a seller refuses any other value with `CREATIVE_VALUE_NOT_ALLOWED`, with `error.field` pointing at that card's `cta`. A card may still omit `cta`. Without `cta_values`, a card's `cta` takes free text and a seller does not refuse it as `CREATIVE_VALUE_NOT_ALLOWED`. Additive and optional: existing declarations are unchanged. A seller that carried `cta_values` on a carousel option as an undeclared extra parameter can keep the same value; it is now the declared field. diff --git a/docs/creative/channels/carousels.mdx b/docs/creative/channels/carousels.mdx index dde06f2c35..4f50748175 100644 --- a/docs/creative/channels/carousels.mdx +++ b/docs/creative/channels/carousels.mdx @@ -15,6 +15,7 @@ Use `image_carousel` when one creative contains an ordered set of cards. Card co "max_cards": 10, "card_aspect_ratio": "1:1", "card_image_sizes": [{ "width": 1080, "height": 1080 }], + "cta_values": ["SHOP_NOW", "LEARN_MORE"], "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 } @@ -31,18 +32,21 @@ Use `image_carousel` when one creative contains an ordered set of cards. Card co { "asset_type": "card", "headline": "Trail Pro", + "cta": "SHOP_NOW", "media": { "asset_type": "image", "url": "https://cdn.acmeoutdoor.example/trail-pro.jpg" }, "landing_page_url": { "asset_type": "url", "url": "https://acmeoutdoor.example/trail-pro" } }, { "asset_type": "card", "headline": "Summit Pack", + "cta": "SHOP_NOW", "media": { "asset_type": "image", "url": "https://cdn.acmeoutdoor.example/summit-pack.jpg" }, "landing_page_url": { "asset_type": "url", "url": "https://acmeoutdoor.example/summit-pack" } }, { "asset_type": "card", "headline": "Alpine Shell", + "cta": "LEARN_MORE", "media": { "asset_type": "image", "url": "https://cdn.acmeoutdoor.example/alpine-shell.jpg" }, "landing_page_url": { "asset_type": "url", "url": "https://acmeoutdoor.example/alpine-shell" } } @@ -60,6 +64,16 @@ Use `image_carousel` when one creative contains an ordered set of cards. Card co - **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. +## Per-card CTA + +Each card can carry its own `cta` label. `cta_values` (added in AdCP 3.3) makes that label a closed set, the same parameter `native_in_feed` and `image` use for their `cta` slot. On `image_carousel` it governs the `cta` field of every card in the `cards` slot. + +- **Closed set when declared.** When the option declares `cta_values`, every card's `cta` MUST be one of the listed values. A seller refuses any other value with `CREATIVE_VALUE_NOT_ALLOWED` and sets `error.field` to that card's `cta`, for example `creatives[0].assets.cards[2].cta` on `sync_creatives`. +- **Optional per card.** `cta_values` does not make `cta` required. A card that omits it is not refused on this parameter. +- **Free text when absent.** Without `cta_values`, a card's `cta` takes free text, and a seller does not refuse it as `CREATIVE_VALUE_NOT_ALLOWED`. + +In the example above, the option declares `["SHOP_NOW", "LEARN_MORE"]`, and each card's `cta` is one of those values. A card with `"cta": "BUY_TICKETS"` would be refused. + 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 db0425b15c..4d48049547 100644 --- a/docs/reference/whats-new-in-3-3.mdx +++ b/docs/reference/whats-new-in-3-3.mdx @@ -159,6 +159,10 @@ The `pixel_tracker` `event` enum adds `start`, `first_quartile`, `midpoint`, `th `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). +## Carousel per-card CTA values + +`image_carousel` gains an optional `cta_values` list, the same type as `cta_values` on `native_in_feed` and `image`. When it is declared, every card's `cta` must be one of the listed values, and a seller refuses any other value with `CREATIVE_VALUE_NOT_ALLOWED`. Without it, a card's `cta` stays free text. See [Carousel ads](/docs/creative/channels/carousels#per-card-cta) and [#8960](https://github.com/adcontextprotocol/adcp/issues/8960). + ## 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/static/examples/products/canonical/meta_carousel.json b/static/examples/products/canonical/meta_carousel.json index 46c302f7c7..9c7f4686a8 100644 --- a/static/examples/products/canonical/meta_carousel.json +++ b/static/examples/products/canonical/meta_carousel.json @@ -60,6 +60,11 @@ "card_video_max_duration_ms": 240000, "primary_text_max_chars": 125, "card_headline_max_chars": 40, + "cta_values": [ + "SHOP_NOW", + "LEARN_MORE", + "SIGN_UP" + ], "ssl_required": true, "composition_model": "deterministic", "slots": [ diff --git a/static/schemas/source/formats/canonical/image_carousel.json b/static/schemas/source/formats/canonical/image_carousel.json index a50946fd20..47bcd1d882 100644 --- a/static/schemas/source/formats/canonical/image_carousel.json +++ b/static/schemas/source/formats/canonical/image_carousel.json @@ -94,6 +94,12 @@ "minimum": 1, "description": "Per-card description character limit. Governs the `description` field on each card-asset in the `cards` slot. Distinct from `card_headline_max_chars`: description is longer body copy (typically 100-500 chars); headline is the short label (typically 25-40 chars)." }, + "cta_values": { + "x-added-in": "3.3.0", + "type": "array", + "items": { "type": "string" }, + "description": "Permitted values for each card's `cta` (e.g., ['SHOP_NOW', 'LEARN_MORE']). When set, every card's `cta` MUST be one of these values, and a seller refuses any other value with `CREATIVE_VALUE_NOT_ALLOWED`, with `error.field` pointing at that card's `cta`. A card may still omit `cta`. When absent, a card's `cta` takes free text, and a seller does not refuse it as `CREATIVE_VALUE_NOT_ALLOWED`." + }, "ssl_required": { "type": "boolean" } diff --git a/tests/canonical-format-conventions.test.cjs b/tests/canonical-format-conventions.test.cjs index 94364778fa..03d0fa78f0 100644 --- a/tests/canonical-format-conventions.test.cjs +++ b/tests/canonical-format-conventions.test.cjs @@ -25,8 +25,10 @@ * * 5. Slot-param consistency: when a format declaration's params include `cta_values` or * `*_max_chars` for a content type, the canonical's default slots OR the product's - * override slots SHOULD declare a matching slot. Soft-warn (does not fail) until the - * canonicals are updated to add default slots for cta and primary_text. + * override slots SHOULD declare a matching slot. `cta_values` matches the `cta` slot, + * except on image_carousel, where it governs each card's `cta` in the `cards` slot. + * Soft-warn (does not fail) until the canonicals are updated to add default slots for + * cta and primary_text. * * Run: node tests/canonical-format-conventions.test.cjs */ @@ -72,6 +74,10 @@ const CANONICAL_DEFAULT_SLOTS = (() => { return out; })(); +// The slot a canonical's `cta_values` governs, where it is not the `cta` slot. +// image_carousel has no `cta` slot; its `cta_values` governs each card's `cta`. +const CTA_VALUES_SLOT = { image_carousel: 'cards' }; + let errors = 0; let warnings = 0; @@ -143,8 +149,9 @@ function checkDeclaration(decl, ctx, file) { ? new Set(overrideSlots.map(s => s.asset_group_id)) : inheritedDefaults; - if (params.cta_values && !effectiveSlotIds.has('cta')) { - warn(file, `${ctx} declares cta_values but effective slots[] has no 'cta' slot (canonical=${decl.format_kind})`); + const ctaSlotId = CTA_VALUES_SLOT[decl.format_kind] || 'cta'; + if (params.cta_values && !effectiveSlotIds.has(ctaSlotId)) { + warn(file, `${ctx} declares cta_values but effective slots[] has no '${ctaSlotId}' slot (canonical=${decl.format_kind})`); } if (params.primary_text_max_chars && !effectiveSlotIds.has('primary_text')) { warn(file, `${ctx} declares primary_text_max_chars but effective slots[] has no 'primary_text' slot (canonical=${decl.format_kind})`); diff --git a/tests/canonical-negative-fixtures.test.cjs b/tests/canonical-negative-fixtures.test.cjs index 9f3e4b8e3d..7140c36620 100644 --- a/tests/canonical-negative-fixtures.test.cjs +++ b/tests/canonical-negative-fixtures.test.cjs @@ -434,6 +434,23 @@ const NEGATIVE_CASES = { doc: { width: 300, height: 250, pixel_ratios: [] }, }, ], + '/schemas/formats/canonical/image_carousel.json': [ + { + label: 'per-card cta_values accepts a list of strings (positive control)', + expected: true, + doc: { cta_values: ['SHOP_NOW', 'LEARN_MORE'] }, + }, + { + label: 'per-card cta_values rejects a bare string', + expected: false, + doc: { cta_values: 'SHOP_NOW' }, + }, + { + label: 'per-card cta_values rejects a non-string value', + expected: false, + doc: { cta_values: ['SHOP_NOW', 1] }, + }, + ], '/schemas/core/assets/image-asset.json': [ { label: 'image asset accepts positive pixel_ratio metadata', diff --git a/tests/x-added-in-3-3-sweep.test.cjs b/tests/x-added-in-3-3-sweep.test.cjs index e908bc51e2..c6a5633895 100644 --- a/tests/x-added-in-3-3-sweep.test.cjs +++ b/tests/x-added-in-3-3-sweep.test.cjs @@ -81,6 +81,7 @@ const PROPERTIES_3_3 = [ ['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']], + ['formats/canonical/image_carousel.json', ['properties', 'cta_values']], ['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']],