From d0bc5de7b2d6fee5077ca0280e98551cc1a6e6e9 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Tue, 13 Oct 2026 20:49:42 -0700 Subject: [PATCH 1/2] Support instream video through the auction API Preserve video-only inventory through standard and PBS routing. Return inline VAST to a caller-owned player and keep banner consumers isolated from video bids. Refs #361. --- .../src/auction/endpoints.rs | 120 ++++++ .../src/auction/formats.rs | 389 ++++++++++++++++-- .../src/auction/openrtb.rs | 202 ++++++++- .../src/auction/openrtb/tests.rs | 327 +++++++++++++++ .../src/auction/orchestrator.rs | 12 + .../src/auction/profile.rs | 5 + .../src/auction/routing.rs | 31 +- .../src/auction/telemetry.rs | 50 ++- .../src/auction/test_support.rs | 1 + .../trusted-server-core/src/auction/types.rs | 80 ++++ .../src/creative_opportunities.rs | 1 + .../src/integrations/adserver_mock.rs | 9 + .../src/integrations/aps.rs | 3 + .../src/integrations/prebid.rs | 15 +- crates/trusted-server-core/src/openrtb.rs | 3 +- crates/trusted-server-core/src/publisher.rs | 28 ++ .../trusted-server-js/lib/src/core/auction.ts | 3 + .../lib/test/core/auction.test.ts | 18 + docs/guide/auction-orchestration.md | 85 ++++ docs/guide/integrations/prebid.md | 2 +- 20 files changed, 1325 insertions(+), 59 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index f364464561..8d2dc4e3fe 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -643,6 +643,126 @@ mod tests { use serde_json::json; use std::sync::{Arc, Mutex}; + #[test] + fn video_api_round_trip_selects_compatible_vast_from_standard_and_pbs() { + futures::executor::block_on(async { + for profile in ["standard", "prebid-server"] { + let mut settings = create_test_settings(); + settings.publisher.domain = "publisher.example.com".to_string(); + settings.request_signing = None; + settings.auction.enabled = true; + settings.auction.mediator = None; + settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = true; + settings.auction.providers.clear(); + settings.auction.providers.insert( + "video-provider".parse().expect("should parse provider ID"), + crate::auction::plan::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: profile.to_string(), + endpoint: "https://bidder.example.com/openrtb".to_string(), + timeout_ms: None, + routing: if profile == "prebid-server" { + crate::auction::plan::RoutingMode::Explicit + } else { + crate::auction::plan::RoutingMode::AllEligible + }, + notifications: crate::auction::plan::NotificationConfig::default(), + profile_config: json!({}), + }, + ); + let plan = Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile video plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let http = Arc::new(StubHttpClient::new()); + let vast = " \nExampleExample video00:00:15\n"; + http.push_response(200, serde_json::to_vec(&json!({"seatbid":[{"seat":"example-seat","bid":[ + {"id":"lower-video","impid":"example-video","price":1.0,"mtype":2,"adm":vast,"protocol":7,"dur":15}, + {"id":"winning-video","impid":"example-video","price":2.0,"mtype":2,"adm":vast,"protocol":7,"dur":15}, + {"id":"incompatible-banner","impid":"example-video","price":20.0,"mtype":1,"adm":"
"} + ]}]})).expect("should serialize bidder response")); + let services = crate::platform::test_support::build_services_with_http_client( + Arc::clone(&http) as Arc<_>, + ); + let request = Request::builder() + .method(http::Method::POST) + .uri("https://publisher.example.com/auction") + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from( + serde_json::to_vec(&json!({"adUnits":[{ + "code":"example-video", "mediaTypes":{"video":{ + "context":"instream","playerSize":[640,360], + "mimes":["video/mp4","application/vnd.apple.mpegurl","application/x-mpegURL","application/dash+xml"], + "protocols":[7,8],"minduration":0,"maxduration":30,"plcmt":1 + }} + }]})) + .expect("should serialize client video input"), + )) + .expect("should build the API request"); + let mut ec = make_ec_context(Jurisdiction::NonRegulated, None); + let response = handle_auction( + &settings, + &orchestrator, + None, + None, + &mut ec, + &services, + request, + ) + .await + .expect("should complete the video API auction"); + assert_eq!( + response.status(), + StatusCode::OK, + "should return the video result" + ); + let returned: serde_json::Value = serde_json::from_slice( + &response + .into_body() + .into_bytes() + .expect("should read API response"), + ) + .expect("should parse API JSON"); + let bid = &returned["seatbid"][0]["bid"][0]; + assert_eq!( + bid["id"], "winning-video", + "should select the highest compatible bid" + ); + assert_eq!(bid["mtype"], 2, "should identify the player handoff"); + assert_eq!( + bid["adm"], vast, + "should preserve VAST despite enabled HTML processing" + ); + assert_eq!(bid["protocol"], 7, "should retain VAST protocol"); + assert_eq!(bid["dur"], 15, "should retain duration"); + assert!( + bid.get("w").is_none() && bid.get("h").is_none(), + "should not present player dimensions as declared creative dimensions" + ); + let dispatched: serde_json::Value = + serde_json::from_slice(&http.recorded_request_bodies()[0]) + .expect("should parse captured provider inventory"); + assert!( + dispatched["imp"][0].get("banner").is_none(), + "should avoid banner substitution" + ); + assert_eq!( + dispatched["imp"][0]["video"]["w"], 640, + "should forward player requirements" + ); + if profile == "prebid-server" { + assert_eq!( + dispatched["imp"][0]["ext"]["prebid"]["storedrequest"]["id"], + "example-video", + "should retain PBS stored-request routing" + ); + } + } + }); + } + #[derive(Default)] struct RecordingTelemetrySink { batches: Mutex>, diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index a0df63d94a..c7bb89b644 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -30,7 +30,7 @@ use crate::settings::Settings; use super::orchestrator::OrchestrationResult; use super::types::{ AdFormat, AdSlot, AuctionRequest, BidRenderer, DeviceInfo, MediaType, OrchestratorExt, - ProviderSummary, PublisherInfo, SiteInfo, UserInfo, + ProviderSummary, PublisherInfo, SiteInfo, UserInfo, VideoPlacement, }; /// Request body for `POST /auction` (tsjs / Prebid.js wire format). @@ -82,6 +82,41 @@ pub struct BidConfig { #[serde(rename_all = "camelCase")] pub struct MediaTypes { pub banner: Option, + pub video: Option, +} + +/// Instream player input accepted by the direct auction API. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VideoUnit { + /// The supported player context is `instream`. + pub context: String, + /// One player size, in flat or single-pair form. + pub player_size: VideoPlayerSize, + /// Only linear video is supported by this API contract. + pub linearity: Option, + /// Typed requirements shared with the routed impression. + #[serde(flatten)] + pub placement: VideoPlacement, +} + +/// Equivalent wire representations of one player size. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum VideoPlayerSize { + /// A width and height pair. + Flat([u32; 2]), + /// A single nested width and height pair. + Nested([[u32; 2]; 1]), +} + +impl VideoPlayerSize { + fn dimensions(&self) -> [u32; 2] { + match self { + Self::Flat(size) => *size, + Self::Nested([size]) => *size, + } + } } #[derive(Debug, Deserialize)] @@ -175,13 +210,14 @@ pub fn convert_tsjs_to_auction_request( ) -> Result> { let ec_id = ec_id.map(str::to_owned); - // Convert ad units to slots + // Validate supported media before provider dispatch. let mut slots = Vec::new(); for unit in &body.ad_units { - if let Some(media_types) = &unit.media_types - && let Some(banner) = &media_types.banner - { - let mut formats = Vec::new(); + let Some(media_types) = &unit.media_types else { + continue; + }; + let mut formats = Vec::new(); + if let Some(banner) = &media_types.banner { for size in &banner.sizes { ensure!( size.len() == 2, @@ -189,30 +225,69 @@ pub fn convert_tsjs_to_auction_request( message: "Invalid banner size; expected [width, height]".to_string(), } ); - formats.push(AdFormat { width: size[0], height: size[1], media_type: MediaType::Banner, }); } - - // Extract bidder params from the bids array - let mut bidders = HashMap::new(); - if let Some(bids) = &unit.bids { - for bid in bids { - bidders.insert(bid.bidder.clone(), bid.params.clone()); + } + let video = if let Some(video) = &media_types.video { + let [width, height] = video.player_size.dimensions(); + ensure!( + video.context == "instream" + && video.linearity.is_none_or(|value| value == 1) + && video.placement.is_valid() + && i32::try_from(width).is_ok_and(|value| value > 0) + && i32::try_from(height).is_ok_and(|value| value > 0), + TrustedServerError::BadRequest { + message: format!("Invalid instream video parameters for slot '{}'", unit.code), } - } - - slots.push(AdSlot { - id: unit.code.clone(), - formats, - floor_price: None, - targeting: HashMap::new(), - bidders, + ); + ensure!( + !settings.auction.has_mediator(), + TrustedServerError::BadRequest { + message: "Video auctions require direct provider selection without a mediator" + .to_string(), + } + ); + formats.push(AdFormat { + width, + height, + media_type: MediaType::Video, }); + Some(video.placement.clone()) + } else { + None + }; + if formats.is_empty() && media_types.banner.is_none() { + continue; } + let bidders = unit + .bids + .as_deref() + .unwrap_or_default() + .iter() + .map(|bid| (bid.bidder.clone(), bid.params.clone())) + .collect(); + slots.push(AdSlot { + id: unit.code.clone(), + formats, + video, + floor_price: None, + targeting: HashMap::new(), + bidders, + }); + } + + if slots.iter().any(|slot| slot.video.is_some()) { + let mut seen = HashSet::new(); + ensure!( + slots.iter().all(|slot| seen.insert(slot.id.as_str())), + TrustedServerError::BadRequest { + message: "Video auctions require unique ad unit codes".to_string(), + } + ); } // Build device info with user-agent (always) and geo (if available) @@ -382,7 +457,19 @@ pub(crate) fn convert_to_openrtb_response_with_report( }) .to_ext() }; - let (adm, ext) = if let Some(raw_creative) = bid + let (adm, ext) = if bid.video.is_some() { + let Some(vast) = bid + .creative + .as_deref() + .filter(|value| !value.trim().is_empty() && value.len() <= 1024 * 1024) + else { + delivery.record_drop("invalid_video_creative"); + continue; + }; + // The caller's player owns VAST handling. HTML processing would + // alter XML, tracking URLs, and CDATA on this transport path. + (Some(vast.to_owned()), None) + } else if let Some(raw_creative) = bid .creative .as_deref() .filter(|creative| !creative.trim().is_empty()) @@ -466,9 +553,18 @@ pub(crate) fn convert_to_openrtb_response_with_report( adm, adid: bid.ad_id.clone(), crid: bid.creative_id.clone(), - w: width, - h: height, + w: bid.video.as_ref().map_or(width, |video| { + video.width.and_then(|value| i32::try_from(value).ok()) + }), + h: bid.video.as_ref().map_or(height, |video| { + video.height.and_then(|value| i32::try_from(value).ok()) + }), adomain: bid.adomain.clone().unwrap_or_default(), + mtype: bid.video.as_ref().map(|_| 2), + protocol: bid.video.as_ref().and_then(|video| video.protocol), + dur: bid.video.as_ref().and_then(|video| video.duration), + nurl: bid.video.as_ref().and_then(|_| bid.nurl.clone()), + burl: bid.video.as_ref().and_then(|_| bid.burl.clone()), ext, ..Default::default() }; @@ -608,6 +704,7 @@ mod tests { AuctionRequest { id: "auction-1".to_string(), slots: vec![AdSlot { + video: None, id: "div-gpt-top".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -645,6 +742,7 @@ mod tests { fn make_bid(slot_id: &str, bidder: &str, price: Option) -> Bid { Bid { + video: None, slot_id: slot_id.to_string(), price, currency: "USD".to_string(), @@ -709,6 +807,7 @@ mod tests { ad_units: vec![AdUnit { code: "div-gpt-top".to_string(), media_types: Some(MediaTypes { + video: None, banner: Some(BannerUnit { sizes: vec![vec![300, 250], vec![728, 90]], }), @@ -769,6 +868,7 @@ mod tests { ad_units: vec![AdUnit { code: "stored-slot".to_string(), media_types: Some(MediaTypes { + video: None, banner: Some(BannerUnit { sizes: vec![vec![300, 250]], }), @@ -965,6 +1065,237 @@ mod tests { } } + fn video_wire_value() -> JsonValue { + json!({"adUnits": [{"code": "example-video", "mediaTypes": {"video": { + "context": "instream", "playerSize": [640, 360], "mimes": ["video/mp4"], + "protocols": [7, 8], "minduration": 0, "maxduration": 30, "plcmt": 1 + }}}]}) + } + + #[test] + fn video_input_accepts_equivalent_player_sizes_and_mixed_inventory() { + for size in [json!([640, 360]), json!([[640, 360]])] { + let mut value = video_wire_value(); + value["adUnits"][0]["mediaTypes"]["video"]["playerSize"] = size; + value["adUnits"][0]["mediaTypes"]["video"]["mimes"] = json!([ + "video/mp4", + "application/vnd.apple.mpegurl", + "application/x-mpegURL", + "application/dash+xml" + ]); + value["adUnits"][0]["mediaTypes"]["banner"] = json!({"sizes": [[300, 250]]}); + let body = serde_json::from_value(value).expect("should parse supported video input"); + let request = convert_body_to_auction_request(&body, &make_settings()); + assert_eq!( + request.slots.len(), + 1, + "should retain one mixed-media impression" + ); + assert_eq!( + request.slots[0].formats.len(), + 2, + "should retain both media formats" + ); + let video = request.slots[0] + .video + .as_ref() + .expect("should retain video requirements"); + assert_eq!( + video.protocols, + vec![7, 8], + "should retain requested VAST protocols" + ); + assert_eq!( + video.mimes, + vec![ + "video/mp4", + "application/vnd.apple.mpegurl", + "application/x-mpegURL", + "application/dash+xml" + ], + "should retain MP4, HLS and DASH player capabilities" + ); + } + } + + #[test] + fn video_input_rejects_invalid_player_requirements_before_routing() { + let invalid = [ + ("context", json!("outstream")), + ("linearity", json!(2)), + ("playerSize", json!([0, 360])), + ("playerSize", json!([4294967295_u32, 360])), + ("playerSize", json!([[640, 360], [300, 250]])), + ("mimes", json!([])), + ("mimes", json!(["not-a-mime"])), + ("mimes", json!(["audio/mp3"])), + ("mimes", json!(["application/octet-stream"])), + ("protocols", json!([])), + ("protocols", json!([9])), + ("protocols", json!([99])), + ("minduration", json!(-1)), + ("minduration", json!(31)), + ("maxduration", json!(0)), + ("plcmt", json!(4)), + ("api", json!([99])), + ("playbackmethod", json!([0])), + ("startdelay", json!(-3)), + ("skip", json!(2)), + ]; + for (key, bad) in invalid { + let mut value = video_wire_value(); + value["adUnits"][0]["mediaTypes"]["video"][key] = bad; + let Ok(body) = serde_json::from_value::(value) else { + continue; + }; + let result = convert_tsjs_to_auction_request( + &body, + &make_settings(), + &noop_services(), + &make_request(), + ConsentContext::default(), + None, + None, + ); + assert!(result.is_err(), "should reject invalid {key}"); + } + for key in [ + "context", + "playerSize", + "mimes", + "protocols", + "minduration", + "maxduration", + "plcmt", + ] { + let mut value = video_wire_value(); + value["adUnits"][0]["mediaTypes"]["video"] + .as_object_mut() + .expect("should find video input") + .remove(key); + assert!( + serde_json::from_value::(value).is_err(), + "should require {key}" + ); + } + } + + #[test] + fn video_input_rejects_duplicate_codes_and_configured_mediation() { + let mut value = video_wire_value(); + let duplicate = value["adUnits"][0].clone(); + value["adUnits"] + .as_array_mut() + .expect("should find units") + .push(duplicate); + let body = serde_json::from_value(value).expect("should parse duplicate units"); + let services = noop_services(); + let request = make_request(); + assert!( + convert_tsjs_to_auction_request( + &body, + &make_settings(), + &services, + &request, + ConsentContext::default(), + None, + None + ) + .is_err(), + "should reject ambiguous impression IDs" + ); + let body = serde_json::from_value(video_wire_value()).expect("should parse video input"); + let mut settings = make_settings(); + settings.auction.mediator = Some("adserver_mock".to_string()); + assert!( + convert_tsjs_to_auction_request( + &body, + &settings, + &services, + &request, + ConsentContext::default(), + None, + None + ) + .is_err(), + "should reject unsupported video mediation" + ); + } + + #[test] + fn video_vast_response_bypasses_html_processing_and_preserves_player_facts() { + let mut settings = make_settings(); + settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = true; + let vast = " \n\n"; + let mut bid = make_bid("div-gpt-top", "example-server", Some(2.0)); + bid.video = Some(crate::auction::types::VideoBid { + protocol: Some(7), + duration: Some(15), + width: Some(1920), + height: Some(1080), + }); + bid.creative = Some(vast.to_string()); + bid.width = 1920; + bid.height = 1080; + bid.nurl = Some("https://notify.example.com/win?p=${AUCTION_PRICE}".to_string()); + let result = make_result(bid); + let value = response_json( + convert_to_openrtb_response(&result, &settings, &make_auction_request(), true) + .expect("should serialize video delivery"), + ); + let delivered = &value["seatbid"][0]["bid"][0]; + assert_eq!(delivered["adm"], vast, "should preserve VAST byte for byte"); + assert_eq!(delivered["mtype"], 2, "should identify video to the player"); + assert_eq!( + delivered["protocol"], 7, + "should retain the provider protocol" + ); + assert_eq!(delivered["dur"], 15, "should retain the provider duration"); + assert_eq!(delivered["w"], 1920, "should retain creative dimensions"); + assert_eq!( + delivered["nurl"], "https://notify.example.com/win?p=${AUCTION_PRICE}", + "should retain lifecycle notifications for the caller" + ); + } + + #[test] + fn video_only_wire_request_preserves_the_video_slot() { + let settings = make_settings(); + let body: AdRequest = serde_json::from_value(json!({ + "adUnits": [{ + "code": "example-video", + "mediaTypes": {"video": { + "context": "instream", + "playerSize": [640, 360], + "mimes": ["video/mp4"], + "protocols": [7, 8], + "minduration": 0, + "maxduration": 30, + "plcmt": 1 + }}, + "bids": [{"bidder": "example-server", "params": {"placementId": "example"}}] + }] + })) + .expect("should parse the video request"); + let request = convert_body_to_auction_request(&body, &settings); + + assert_eq!( + request.slots.len(), + 1, + "should preserve video-only inventory" + ); + assert_eq!( + request.slots[0].id, "example-video", + "should preserve the slot ID" + ); + assert_eq!( + request.slots[0].formats[0].media_type, + MediaType::Video, + "should preserve the media type" + ); + } + #[test] fn convert_tsjs_to_auction_request_maps_banner_sizes_to_formats() { let settings = make_settings(); @@ -1129,6 +1460,7 @@ mod tests { ad_units: vec![AdUnit { code: "div-gpt-top".to_string(), media_types: Some(MediaTypes { + video: None, banner: Some(BannerUnit { sizes: vec![] }), }), bids: None, @@ -1164,6 +1496,7 @@ mod tests { ad_units: vec![AdUnit { code: "div-gpt-top".to_string(), media_types: Some(MediaTypes { + video: None, banner: Some(BannerUnit { sizes: vec![vec![300, 250, 1]], }), @@ -1205,7 +1538,10 @@ mod tests { }, AdUnit { code: "no-banner".to_string(), - media_types: Some(MediaTypes { banner: None }), + media_types: Some(MediaTypes { + banner: None, + video: None, + }), bids: None, }, ], @@ -1966,6 +2302,7 @@ mod convert_tests { ad_units: vec![AdUnit { code: "atf_sidebar_ad".to_string(), media_types: Some(MediaTypes { + video: None, banner: Some(BannerUnit { sizes: vec![vec![300, 250]], }), @@ -1995,6 +2332,7 @@ mod convert_tests { ad_units: vec![AdUnit { code: "homepage_header_ad".to_string(), media_types: Some(MediaTypes { + video: None, banner: Some(BannerUnit { sizes: vec![vec![970, 90]], }), @@ -2072,6 +2410,7 @@ mod convert_tests { ad_units: vec![AdUnit { code: "bad_slot".to_string(), media_types: Some(MediaTypes { + video: None, banner: Some(BannerUnit { sizes: vec![vec![300, 250, 99]], // invalid — 3 elements }), diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index 74553c9bce..e6296367e9 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -16,12 +16,12 @@ use super::profile::{ use super::routing::{ PrebidTransportHeaders, ProviderAuctionInput, ProviderSlotInput, RoutedAuction, }; -use super::types::{AdFormat, AdSlot, AuctionResponse, Bid}; +use super::types::{AdFormat, AdSlot, AuctionResponse, Bid, MediaType, VideoBid, VideoPlacement}; use crate::consent::ConsentSource; use crate::error::TrustedServerError; use crate::openrtb::{ Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, - RegsExt, Site, ToExt as _, TrustedServerExt, User, UserExt, to_openrtb_i32, + RegsExt, Site, ToExt as _, TrustedServerExt, User, UserExt, Video, to_openrtb_i32, }; use crate::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; @@ -37,6 +37,8 @@ pub(crate) enum BidRejectionReason { UnrequestedImpression, DimensionMismatch, AmbiguousDimensions, + UnsupportedMediaType, + AmbiguousMediaType, } impl BidRejectionReason { @@ -46,6 +48,8 @@ impl BidRejectionReason { Self::UnrequestedImpression => "unrequested_impression", Self::DimensionMismatch => "dimension_mismatch", Self::AmbiguousDimensions => "ambiguous_dimensions", + Self::UnsupportedMediaType => "unsupported_media_type", + Self::AmbiguousMediaType => "ambiguous_media_type", } } } @@ -112,6 +116,7 @@ pub(crate) struct SlotBidDimensions { by_width: BTreeMap, by_height: BTreeMap, unqualified: Option, + video: Option<((u32, u32), VideoPlacement)>, } impl SlotBidDimensions { @@ -121,7 +126,10 @@ impl SlotBidDimensions { let mut by_height = BTreeMap::new(); let mut unqualified: Option = None; - for format in formats { + for format in formats + .iter() + .filter(|format| format.media_type == MediaType::Banner) + { let dimensions = (format.width, format.height); if !exact.insert(dimensions) { continue; @@ -145,6 +153,7 @@ impl SlotBidDimensions { by_width, by_height, unqualified, + video: None, } } } @@ -165,9 +174,19 @@ pub(crate) fn build_bid_dimension_index_from_slots<'a>( ) -> BidDimensionIndex { let mut index = BidDimensionIndex::new(); for slot in slots { - index - .entry(slot.id.clone()) - .or_insert_with(|| SlotBidDimensions::from_formats(slot.formats.as_slice())); + index.entry(slot.id.clone()).or_insert_with(|| { + let mut dimensions = SlotBidDimensions::from_formats(slot.formats.as_slice()); + dimensions.video = slot + .video + .as_ref() + .zip( + slot.formats + .iter() + .find(|format| format.media_type == MediaType::Video), + ) + .map(|(placement, format)| ((format.width, format.height), placement.clone())); + dimensions + }); } index } @@ -236,6 +255,116 @@ pub(crate) fn resolve_bid_dimensions( } } +/// Media-aware admission facts shared by standard and PBS profiles. +pub(crate) struct AdmittedBidMedia { + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) video: Option, +} + +/// Admit only media offered to the provider, preserving banner dimension rules. +pub(crate) fn resolve_bid_media( + value: &Value, + slot_id: &str, + dimensions_by_slot: &BidDimensionIndex, +) -> Result { + let requested = dimensions_by_slot + .get(slot_id) + .ok_or(BidRejectionReason::UnrequestedImpression)?; + let declared = value + .get("mtype") + .map(|value| { + value + .as_i64() + .filter(|value| matches!(value, 1 | 2)) + .ok_or(BidRejectionReason::UnsupportedMediaType) + }) + .transpose()?; + let prebid_type = value + .pointer("/ext/prebid/type") + .map(|value| match value.as_str() { + Some("banner") => Ok(1), + Some("video") => Ok(2), + _ => Err(BidRejectionReason::UnsupportedMediaType), + }) + .transpose()?; + if declared + .zip(prebid_type) + .is_some_and(|(declared, prebid)| declared != prebid) + { + return Err(BidRejectionReason::UnsupportedMediaType); + } + let media_type = match declared.or(prebid_type) { + Some(value) => value, + None if requested.video.is_some() && requested.exact.is_empty() => 2, + None if requested.video.is_none() && !requested.exact.is_empty() => 1, + None => return Err(BidRejectionReason::AmbiguousMediaType), + }; + let width = parse_optional_bid_dimension(value, "w")?; + let height = parse_optional_bid_dimension(value, "h")?; + if media_type == 1 { + if requested.exact.is_empty() { + return Err(BidRejectionReason::UnsupportedMediaType); + } + let (width, height) = resolve_bid_dimensions(dimensions_by_slot, slot_id, width, height)?; + return Ok(AdmittedBidMedia { + width, + height, + video: None, + }); + } + let ((player_width, player_height), placement) = requested + .video + .as_ref() + .ok_or(BidRejectionReason::UnsupportedMediaType)?; + value + .get("adm") + .and_then(Value::as_str) + .filter(|creative| !creative.trim().is_empty() && creative.len() <= 1024 * 1024) + .ok_or(BidRejectionReason::InvalidBid)?; + // Creative dimensions are not constrained to the player's display size. + let creative_width = width; + let creative_height = height; + let width = width.unwrap_or(*player_width); + let height = height.unwrap_or(*player_height); + if !i32::try_from(width).is_ok_and(|value| value > 0) + || !i32::try_from(height).is_ok_and(|value| value > 0) + { + return Err(BidRejectionReason::InvalidBid); + } + let protocol = optional_video_integer(value, "protocol")?; + if protocol.is_some_and(|value| !placement.protocols.contains(&value)) { + return Err(BidRejectionReason::UnsupportedMediaType); + } + let duration = optional_video_integer(value, "dur")?; + if duration.is_some_and(|value| { + value < placement.minduration || value > placement.maxduration || value <= 0 + }) { + return Err(BidRejectionReason::InvalidBid); + } + Ok(AdmittedBidMedia { + width, + height, + video: Some(VideoBid { + protocol, + duration, + width: creative_width, + height: creative_height, + }), + }) +} + +fn optional_video_integer(value: &Value, key: &str) -> Result, BidRejectionReason> { + match value.get(key) { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_i64() + .and_then(|value| i32::try_from(value).ok()) + .map(Some) + .ok_or(BidRejectionReason::InvalidBid), + } +} + /// Result of request construction before transport. #[derive(Debug)] #[allow( @@ -440,6 +569,7 @@ fn build_imp(slot: &ProviderSlotInput, policy: ProfilePolicy<'_>) -> Option .slot() .formats .iter() + .filter(|format| format.media_type == MediaType::Banner) .filter_map(|format| { Some(Format { w: to_openrtb_i32(format.width, "format.w", "routed slot"), @@ -449,18 +579,49 @@ fn build_imp(slot: &ProviderSlotInput, policy: ProfilePolicy<'_>) -> Option .filter(|value| value.w.is_some() && value.h.is_some()) }) .collect::>(); - let first_width = formats.first()?.w; - let first_height = formats.first()?.h; let aps_banner = matches!(policy, ProfilePolicy::Aps(_)); + let banner = formats.first().map(|first| Banner { + w: aps_banner.then_some(first.w).flatten(), + h: aps_banner.then_some(first.h).flatten(), + topframe: aps_banner.then_some(false), + format: formats.clone(), + ..Default::default() + }); + let video = if aps_banner { + None + } else { + slot.slot() + .video + .as_ref() + .zip( + slot.slot() + .formats + .iter() + .find(|format| format.media_type == MediaType::Video), + ) + .map(|(placement, format)| Video { + mimes: placement.mimes.clone(), + protocols: placement.protocols.clone(), + minduration: Some(placement.minduration), + maxduration: Some(placement.maxduration), + plcmt: Some(placement.plcmt), + linearity: Some(1), + w: to_openrtb_i32(format.width, "video.w", "routed slot"), + h: to_openrtb_i32(format.height, "video.h", "routed slot"), + api: placement.api.clone(), + playbackmethod: placement.playbackmethod.clone(), + startdelay: placement.startdelay, + skip: placement.skip.map(|value| value == 1), + ..Default::default() + }) + }; + if banner.is_none() && video.is_none() { + return None; + } Some(Imp { id: Some(slot.slot().id.clone()), - banner: Some(Banner { - format: formats, - w: aps_banner.then_some(first_width).flatten(), - h: aps_banner.then_some(first_height).flatten(), - topframe: aps_banner.then_some(false), - ..Default::default() - }), + banner, + video, tagid: matches!(policy, ProfilePolicy::Prebid(_)).then(|| slot.slot().id.clone()), bidfloor: slot.slot().floor_price, bidfloorcur: slot @@ -489,7 +650,7 @@ fn apply_prebid( _routed: &RoutedAuction, plan: &PrebidProfilePlan, ) -> Result<(), Report> { - // Routing admits only slots with positive banner dimensions that fit in i32, + // Routing admits supported slots with positive dimensions that fit in i32, // so build_imp produces one impression per routed slot, in the same order. debug_assert_eq!( request.imp.len(), @@ -835,9 +996,7 @@ fn extract_standard_bid( .filter(|slot_id| !slot_id.is_empty()) .ok_or(BidRejectionReason::InvalidBid)? .to_string(); - let width = parse_optional_bid_dimension(value, "w")?; - let height = parse_optional_bid_dimension(value, "h")?; - let (width, height) = resolve_bid_dimensions(dimensions_by_slot, &slot_id, width, height)?; + let media = resolve_bid_media(value, &slot_id, dimensions_by_slot)?; let price = value .get("price") .and_then(Value::as_f64) @@ -850,6 +1009,7 @@ fn extract_standard_bid( .map(str::to_string) .ok_or(BidRejectionReason::InvalidBid)?; Ok(Bid { + video: media.video, slot_id, price: Some(price), currency: DEFAULT_CURRENCY.to_string(), @@ -866,8 +1026,8 @@ fn extract_standard_bid( }), bidder: returned_seat.unwrap_or("unknown").to_string(), returned_seat: returned_seat.map(str::to_string), - width, - height, + width: media.width, + height: media.height, nurl: value .get("nurl") .and_then(Value::as_str) diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index f47efa475e..c73cd96145 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -626,6 +626,7 @@ fn pbs_filtering_keeps_slot_pairs_and_drops_demandless_trusted_routes() { ] .into_iter() .map(|(id, envelope)| AdSlot { + video: None, id: id.to_string(), bidders: HashMap::from([("trustedServer".to_string(), envelope)]), ..template.clone() @@ -983,6 +984,7 @@ fn defensive_no_impression_outcome_does_not_build_transportable_request() { let (plan, mut routed) = routed("standard", json!({})); let mut common = routed.inputs()[0].common_request().clone(); common.slots = vec![AdSlot { + video: None, id: "video-only".to_string(), formats: vec![AdFormat { media_type: MediaType::Video, @@ -1569,3 +1571,328 @@ fn malformed_top_level_standard_response_is_error() { assert_eq!(response.status, BidStatus::Error); assert_eq!(response.metadata["error_type"], "parse_response"); } + +fn video_request_fixture(mixed: bool) -> crate::auction::types::AuctionRequest { + let mut request = canonical_parity_auction_request(); + request.slots[0].formats = vec![AdFormat { + media_type: MediaType::Video, + width: 640, + height: 360, + }]; + if mixed { + request.slots[0].formats.insert( + 0, + AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }, + ); + } + request.slots[0].video = Some(VideoPlacement { + mimes: vec![ + "video/mp4".to_string(), + "application/vnd.apple.mpegurl".to_string(), + "application/x-mpegURL".to_string(), + "application/dash+xml".to_string(), + ], + protocols: vec![7, 8], + minduration: 0, + maxduration: 30, + plcmt: 1, + api: vec![7], + playbackmethod: vec![1], + startdelay: Some(0), + skip: Some(1), + }); + request +} + +#[test] +fn video_requests_reach_standard_and_pbs_without_becoming_banners() { + for profile in ["standard", "prebid-server"] { + let value = serde_json::to_value(build_with_request( + profile, + json!({}), + video_request_fixture(false), + None, + )) + .expect("should serialize video request"); + let imp = &value["imp"][0]; + assert_eq!( + imp["id"], "fictional-slot", + "should preserve the routed impression ID" + ); + assert!( + imp.get("banner").is_none(), + "should not invent banner inventory" + ); + assert_eq!(imp["video"]["w"], 640, "should send the player width"); + assert_eq!(imp["video"]["h"], 360, "should send the player height"); + assert_eq!( + imp["video"]["mimes"], + json!([ + "video/mp4", + "application/vnd.apple.mpegurl", + "application/x-mpegURL", + "application/dash+xml" + ]), + "should preserve MIME types" + ); + assert_eq!( + imp["video"]["protocols"], + json!([7, 8]), + "should preserve VAST protocols" + ); + assert_eq!( + imp["video"]["minduration"], 0, + "should preserve minimum duration" + ); + assert_eq!( + imp["video"]["maxduration"], 30, + "should preserve maximum duration" + ); + assert_eq!( + imp["video"]["plcmt"], 1, + "should preserve explicit placement" + ); + assert_eq!( + imp["video"]["api"], + json!([7]), + "should preserve player APIs" + ); + assert_eq!( + imp["video"]["skip"], 1, + "should use OpenRTB integer booleans" + ); + assert_eq!(value["tmax"], 321, "should preserve the auction budget"); + assert!( + value.get("regs").is_some(), + "should preserve privacy signaling" + ); + } +} + +#[test] +fn video_only_and_mixed_inventory_respect_profile_capabilities() { + let aps = json!({"account_id": "example-account-id"}); + let (_, video_only) = + routed_with_request("aps", aps.clone(), video_request_fixture(false), None); + assert!( + video_only.inputs().is_empty(), + "should skip unsupported APS video inventory" + ); + assert_eq!( + video_only.skipped_no_eligible_provider_ids().len(), + 1, + "should record the skipped provider" + ); + for profile in ["standard", "prebid-server", "aps"] { + let config = if profile == "aps" { + aps.clone() + } else { + json!({}) + }; + let value = serde_json::to_value(build_with_request( + profile, + config, + video_request_fixture(true), + None, + )) + .expect("should serialize mixed inventory"); + assert!( + value["imp"][0].get("banner").is_some(), + "should preserve supported banner inventory" + ); + assert_eq!( + value["imp"][0].get("video").is_some(), + profile != "aps", + "should gate video by profile" + ); + } +} + +#[test] +fn video_pbs_stored_fallback_keeps_the_video_impression() { + let mut request = video_request_fixture(false); + request.slots[0].bidders.clear(); + let value = serde_json::to_value(build_with_request( + "prebid-server", + json!({}), + request, + None, + )) + .expect("should serialize stored video request"); + assert_eq!( + value["imp"][0]["ext"]["prebid"]["storedrequest"]["id"], "fictional-slot", + "should retain stored-request fallback" + ); + assert!( + value["imp"][0].get("video").is_some(), + "should preserve video requirements" + ); +} + +#[test] +fn video_response_admission_preserves_valid_siblings_and_creative_dimensions() { + let (_, routed) = + routed_with_request("standard", json!({}), video_request_fixture(false), None); + let value = json!({"seatbid":[{"seat":"example-server","bid":[ + {"id":"valid","impid":"fictional-slot","price":2.0,"mtype":2,"adm":"","w":1920,"h":1080,"protocol":7,"dur":15}, + {"impid":"fictional-slot","price":10.0,"mtype":1,"adm":"
"}, + {"impid":"fictional-slot","price":10.0,"mtype":4,"adm":"{}"}, + {"impid":"fictional-slot","price":10.0,"mtype":"video","adm":""}, + {"impid":"fictional-slot","price":10.0,"mtype":2,"adm":" "}, + {"impid":"fictional-slot","price":10.0,"mtype":2,"adm":"","protocol":15}, + {"impid":"fictional-slot","price":10.0,"mtype":2,"adm":"","dur":31}, + {"impid":"unrequested","price":10.0,"mtype":2,"adm":""} + ]}]}); + let response = extract_standard_response("fictional-provider", &routed.inputs()[0], &value, 0); + assert_eq!( + response.bids.len(), + 1, + "should isolate incompatible sibling bids" + ); + let bid = &response.bids[0]; + assert_eq!( + (bid.width, bid.height), + (1920, 1080), + "should accept media dimensions different from player size" + ); + assert_eq!( + bid.video.as_ref().expect("should mark video").protocol, + Some(7), + "should retain protocol" + ); + assert_eq!( + response.metadata["response_admission"]["rejected_bid_count"], 7, + "should report fixed admission diagnostics" + ); +} + +#[test] +fn video_response_requires_unambiguous_media_and_inline_markup() { + let (_, routed) = routed_with_request("standard", json!({}), video_request_fixture(true), None); + let input = &routed.inputs()[0]; + for value in [ + json!({"impid":"fictional-slot","price":2.0,"adm":""}), + json!({"impid":"fictional-slot","price":2.0,"mtype":2}), + json!({"impid":"fictional-slot","price":2.0,"mtype":2,"adm":"x".repeat(1024*1024+1)}), + json!({"impid":"fictional-slot","price":2.0,"mtype":2,"adm":"","ext":{"prebid":{"type":"banner"}}}), + json!({"impid":"fictional-slot","price":2.0,"adm":"","ext":{"prebid":{"type":"native"}}}), + ] { + let parsed = extract_standard_response( + "fictional-provider", + input, + &json!({"seatbid":[{"bid":[value]}]}), + 0, + ); + assert!( + parsed.bids.is_empty(), + "should reject unsupported video admission" + ); + } + for value in [ + json!({"impid":"fictional-slot","price":2.0,"mtype":2,"adm":""}), + json!({"impid":"fictional-slot","price":2.0,"adm":"","ext":{"prebid":{"type":"video"}}}), + ] { + let parsed = extract_standard_response( + "fictional-provider", + input, + &json!({"seatbid":[{"bid":[value]}]}), + 0, + ); + assert_eq!( + parsed.bids.len(), + 1, + "should use an explicit media declaration" + ); + assert!( + parsed.bids[0].video.is_some(), + "should retain video identity" + ); + assert_eq!( + (parsed.bids[0].width, parsed.bids[0].height), + (640, 360), + "should retain player dimensions when creative dimensions are omitted" + ); + } +} + +#[test] +fn video_provider_round_trip_uses_active_standard_and_pbs_parsers() { + futures::executor::block_on(async { + for profile in ["standard", "prebid-server"] { + let (plan, routed) = + routed_with_request(profile, json!({}), video_request_fixture(false), None); + let provider = GenericOpenRtbProvider::new(plan.providers()[0].clone()); + let client = Arc::new(StubHttpClient::new()); + let vast = " \n\n"; + client.push_response(200, serde_json::to_vec(&json!({"seatbid":[{"seat":"example-server","bid":[ + {"id":"video-bid","impid":"fictional-slot","price":2.0,"mtype":2,"adm":vast,"protocol":7,"dur":15}, + {"id":"wrong-type","impid":"fictional-slot","price":20.0,"adm":"
","ext":{"prebid":{"type":"banner"}}} + ]}]})).expect("should serialize the fictional bidder response")); + let services = build_services_with_backend_and_http_client( + Arc::new(StubBackend), + Arc::clone(&client) as Arc, + ); + let outcome = provider + .request_bids_routed( + &routed.inputs()[0], + &routed, + 321, + 321, + None, + &services, + &mut HashSet::new(), + ) + .await + .expect("should dispatch video inventory"); + let ProviderRequestOutcome::Pending { + request, + parse_state, + .. + } = outcome + else { + panic!("should dispatch a pending video request"); + }; + let selected = services + .http_client() + .select(vec![request]) + .await + .expect("should collect the provider response"); + let parsed = provider + .parse_response_with_state( + selected.ready.expect("should receive a response"), + 1, + parse_state.as_deref(), + ) + .await + .expect("should parse the video response"); + assert_eq!( + parsed.bids.len(), + 1, + "should isolate the incompatible sibling" + ); + assert_eq!( + parsed.bids[0].creative.as_deref(), + Some(vast), + "should preserve the raw VAST response" + ); + assert!( + parsed.bids[0].video.is_some(), + "should preserve video identity" + ); + let sent: Value = serde_json::from_slice(&client.recorded_request_bodies()[0]) + .expect("should parse dispatched inventory"); + assert!( + sent["imp"][0].get("video").is_some(), + "should send video inventory" + ); + assert!( + sent["imp"][0].get("banner").is_none(), + "should not send video as banner" + ); + } + }); +} diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 204201e605..afcc76097d 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -2707,6 +2707,7 @@ mod tests { AuctionRequest { id: "fictional-auction".to_string(), slots: vec![AdSlot { + video: None, id: "fictional-slot".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -3739,6 +3740,7 @@ mod tests { }) }); Bid { + video: None, slot_id: "slot-1".to_string(), price: Some(price), currency: "USD".to_string(), @@ -3765,6 +3767,7 @@ mod tests { fn mediated_bid(nurl: Option) -> Bid { Bid { + video: None, slot_id: "header-banner".to_string(), price: Some(2.5), currency: "USD".to_string(), @@ -4282,6 +4285,7 @@ mod tests { id: "test-auction-123".to_string(), slots: vec![ AdSlot { + video: None, id: "header-banner".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -4293,6 +4297,7 @@ mod tests { bidders: HashMap::new(), }, AdSlot { + video: None, id: "sidebar".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -4620,6 +4625,7 @@ mod tests { winning_bids.insert( "slot-1".to_string(), Bid { + video: None, slot_id: "slot-1".to_string(), price: Some(0.50), currency: "USD".to_string(), @@ -4644,6 +4650,7 @@ mod tests { winning_bids.insert( "slot-2".to_string(), Bid { + video: None, slot_id: "slot-2".to_string(), price: Some(2.00), currency: "USD".to_string(), @@ -5981,6 +5988,7 @@ mod tests { }), ); request.slots.push(AdSlot { + video: None, id: "synthetic-no-pbs".to_string(), bidders: HashMap::from([( "trustedServer".to_string(), @@ -5991,6 +5999,7 @@ mod tests { for index in 0..inline_providers { let bidder = if index == 0 { "alpha" } else { "beta" }; request.slots.push(AdSlot { + video: None, id:format!("inline-{index}"), bidders:HashMap::from([("trustedServer".to_string(),serde_json::json!({"storedRequest":false,"bidderParams":{bidder:{"placement":index}}}))]), ..template.clone() @@ -7979,6 +7988,7 @@ mod tests { winning_bids.insert( "slot-1".to_string(), Bid { + video: None, slot_id: "slot-1".to_string(), price: None, currency: "USD".to_string(), @@ -8024,6 +8034,7 @@ mod tests { winning_bids.insert( "atf".to_string(), Bid { + video: None, slot_id: "atf".to_string(), price: Some(0.30), // decoded APS price — below $0.50 floor currency: "USD".to_string(), @@ -8064,6 +8075,7 @@ mod tests { winning_bids.insert( "atf".to_string(), Bid { + video: None, slot_id: "atf".to_string(), price: Some(0.75), // decoded APS price — above floor currency: "USD".to_string(), diff --git a/crates/trusted-server-core/src/auction/profile.rs b/crates/trusted-server-core/src/auction/profile.rs index 51a91e69c6..c4ff7e4f25 100644 --- a/crates/trusted-server-core/src/auction/profile.rs +++ b/crates/trusted-server-core/src/auction/profile.rs @@ -85,6 +85,11 @@ impl CompiledOpenRtbProfile { pub(crate) fn is_prebid_server(&self) -> bool { matches!(self, Self::PrebidServer(_)) } + + /// Return whether the profile supports the direct API video contract. + pub(crate) fn supports_video(&self) -> bool { + !matches!(self, Self::Aps(_)) + } } /// Validated static extension object. diff --git a/crates/trusted-server-core/src/auction/routing.rs b/crates/trusted-server-core/src/auction/routing.rs index 2b8135da3c..eecd526fec 100644 --- a/crates/trusted-server-core/src/auction/routing.rs +++ b/crates/trusted-server-core/src/auction/routing.rs @@ -294,6 +294,7 @@ struct ProviderInputBuilder { provider_id: ProviderId, timeout_ms: u32, is_prebid: bool, + supports_video: bool, routing: RoutingMode, slots: Vec, } @@ -345,6 +346,7 @@ pub(crate) fn route_auction_with_trusted_routes( provider_id: provider.id.clone(), timeout_ms: provider.timeout_ms, is_prebid: provider.profile.is_prebid_server(), + supports_video: provider.profile.supports_video(), routing: provider.routing, slots: Vec::new(), }) @@ -356,7 +358,7 @@ pub(crate) fn route_auction_with_trusted_routes( .collect::>(); for (slot_index, slot) in slots.into_iter().enumerate() { - let Some(common_slot) = eligible_banner_slot(&slot) else { + let Some(common_slot) = eligible_slot(&slot, true) else { continue; }; let demand = normalize_slot_demand(&slot.bidders, &mut diagnostics); @@ -393,8 +395,11 @@ pub(crate) fn route_auction_with_trusted_routes( if !include { continue; } + let Some(provider_slot) = eligible_slot(&common_slot, builder.supports_video) else { + continue; + }; builder.slots.push(ProviderSlotInput { - slot: common_slot.clone(), + slot: provider_slot, bidder_params, prebid_zone: builder .is_prebid @@ -432,12 +437,24 @@ pub(crate) fn route_auction_with_trusted_routes( } } -fn eligible_banner_slot(slot: &AdSlot) -> Option { +fn eligible_slot(slot: &AdSlot, supports_video: bool) -> Option { + let video_eligible = supports_video + && slot + .video + .as_ref() + .is_some_and(super::types::VideoPlacement::is_valid) + && slot + .formats + .iter() + .filter(|format| format.media_type == MediaType::Video) + .count() + == 1; let formats = slot .formats .iter() .filter(|format| { - format.media_type == MediaType::Banner + (format.media_type == MediaType::Banner + || (video_eligible && format.media_type == MediaType::Video)) && i32::try_from(format.width).is_ok_and(|width| width > 0) && i32::try_from(format.height).is_ok_and(|height| height > 0) }) @@ -447,6 +464,11 @@ fn eligible_banner_slot(slot: &AdSlot) -> Option { return None; } Some(AdSlot { + video: formats + .iter() + .any(|format| format.media_type == MediaType::Video) + .then(|| slot.video.clone()) + .flatten(), id: slot.id.clone(), formats, floor_price: slot.floor_price, @@ -660,6 +682,7 @@ mod tests { fn slot(bidders: HashMap) -> AdSlot { AdSlot { + video: None, id: "slot-1".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index e0a819ba38..f0b7a3bbe7 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -816,7 +816,11 @@ fn bid_row( row.slot_id = Some(bid.slot_id.clone()); row.slot_w = Some(u16::try_from(bid.width).unwrap_or(u16::MAX)); row.slot_h = Some(u16::try_from(bid.height).unwrap_or(u16::MAX)); - row.media_type = media_type_for_slot(request, &bid.slot_id).map(str::to_owned); + row.media_type = if bid.video.is_some() { + Some("video".to_owned()) + } else { + media_type_for_slot(request, &bid.slot_id).map(str::to_owned) + }; row.seat = Some( bid.returned_seat .clone() @@ -1055,6 +1059,7 @@ mod tests { AuctionRequest { id: id.to_owned(), slots: vec![AdSlot { + video: None, id: "slot-1".to_owned(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -1082,6 +1087,7 @@ mod tests { fn bid(slot_id: &str, bidder: &str, ad_id: Option<&str>, price: Option) -> Bid { Bid { + video: None, slot_id: slot_id.to_owned(), price, currency: "USD".to_owned(), @@ -1534,4 +1540,46 @@ mod tests { "should not serialize internal auction request IDs or EC values" ); } + #[test] + fn video_bid_telemetry_uses_selected_media_in_mixed_inventory() { + let mut request = test_request("example-auction"); + request.slots[0].formats.push(AdFormat { + media_type: MediaType::Video, + width: 640, + height: 360, + }); + request.slots[0].video = Some(crate::auction::types::VideoPlacement { + mimes: vec!["video/mp4".to_string()], + protocols: vec![7], + minduration: 0, + maxduration: 30, + plcmt: 1, + api: Vec::new(), + playbackmethod: Vec::new(), + startdelay: None, + skip: None, + }); + let mut video = bid("slot-1", "example-server", None, Some(2.0)); + video.video = Some(crate::auction::types::VideoBid { + protocol: Some(7), + duration: Some(15), + ..Default::default() + }); + let observation = + AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/auction", 1); + let row = bid_row( + &observation, + "2026-01-01T00:00:00Z", + &request, + "example-server", + &video, + 1, + Some(2.0), + ); + assert_eq!( + row.media_type.as_deref(), + Some("video"), + "should report the selected media rather than the first offered format" + ); + } } diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index a83f8899a8..3dd48e29fa 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -43,6 +43,7 @@ pub(crate) fn canonical_parity_auction_request() -> AuctionRequest { AuctionRequest { id: "fictional-auction".to_string(), slots: vec![AdSlot { + video: None, id: "fictional-slot".to_string(), formats: vec![ AdFormat { diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 4069157068..f2b01c3c8b 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -40,6 +40,9 @@ pub struct AdSlot { pub id: String, /// Media types and formats supported pub formats: Vec, + /// Validated player requirements for API video inventory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub video: Option, /// Floor price if any pub floor_price: Option, /// Slot-specific targeting @@ -56,6 +59,77 @@ pub struct AdFormat { pub height: u32, } +/// Player requirements forwarded to an `OpenRTB` video impression. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VideoPlacement { + /// Supported video media MIME types. + pub mimes: Vec, + /// Supported VAST protocol codes. + pub protocols: Vec, + /// Minimum creative duration in seconds. + pub minduration: i32, + /// Maximum creative duration in seconds. + pub maxduration: i32, + /// Explicit modern IAB placement classification. + pub plcmt: i32, + /// Player API framework codes. + #[serde(default)] + pub api: Vec, + /// Player playback method codes. + #[serde(default)] + pub playbackmethod: Vec, + /// Start delay in seconds, or the IAB mid-roll/post-roll sentinel. + pub startdelay: Option, + /// Whether the player accepts skippable ads, encoded as zero or one. + pub skip: Option, +} + +impl VideoPlacement { + pub(crate) fn is_valid(&self) -> bool { + !self.mimes.is_empty() + && self.mimes.iter().all(|value| { + value.parse::().is_ok_and(|value| { + value.type_() == mime::VIDEO + || matches!( + value.essence_str(), + "application/vnd.apple.mpegurl" + | "application/x-mpegurl" + | "application/dash+xml" + ) + }) + }) + && !self.protocols.is_empty() + && self + .protocols + .iter() + .all(|value| matches!(value, 1..=8 | 11..=16)) + && self.minduration >= 0 + && self.maxduration > 0 + && self.minduration <= self.maxduration + && matches!(self.plcmt, 1 | 2) + && self.api.iter().all(|value| matches!(value, 1..=9)) + && self + .playbackmethod + .iter() + .all(|value| matches!(value, 1..=6)) + && self.startdelay.is_none_or(|value| value >= -2) + && self.skip.is_none_or(|value| matches!(value, 0 | 1)) + } +} + +/// Video response facts retained for the caller's player. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct VideoBid { + /// Declared VAST protocol, when supplied by the provider. + pub protocol: Option, + /// Declared creative duration in seconds, when supplied by the provider. + pub duration: Option, + /// Actual creative width, when supplied by the provider. + pub width: Option, + /// Actual creative height, when supplied by the provider. + pub height: Option, +} + /// Media type enumeration. /// /// `Default` is `Banner` for programmatic construction only. Do **not** add @@ -238,6 +312,9 @@ impl BidRenderer { /// Individual bid from a provider. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Bid { + /// Video identity and response facts. Absent for existing banner bids. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub video: Option, /// Slot this bid is for pub slot_id: String, /// Bid price in CPM. @@ -413,6 +490,7 @@ mod tests { fn make_bid(bidder: &str) -> Bid { Bid { + video: None, slot_id: "slot-1".to_owned(), price: Some(1.0), currency: "USD".to_owned(), @@ -560,6 +638,7 @@ mod tests { #[test] fn bid_with_cache_fields_round_trips_through_json() { let bid = Bid { + video: None, slot_id: "atf".to_string(), price: Some(1.50), currency: "USD".to_string(), @@ -667,6 +746,7 @@ mod tests { #[test] fn bid_has_ad_id_field() { let bid = Bid { + video: None, slot_id: "s".to_string(), price: Some(1.0), currency: "USD".to_string(), diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 8a20c72965..8d5e911226 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -967,6 +967,7 @@ impl CreativeOpportunitySlot { } } AdSlot { + video: None, id: self.id.clone(), formats: self .formats diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 640574e031..1bf33278d9 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -325,6 +325,7 @@ impl AdServerMockProvider { }; all_bids.push(Bid { + video: None, slot_id, price: bid["price"].as_f64(), currency: "USD".to_string(), @@ -639,6 +640,7 @@ mod tests { AuctionRequest { id: "test-auction-123".to_string(), slots: vec![AdSlot { + video: None, id: "header-banner".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -670,6 +672,7 @@ mod tests { fn aps_bid(bid_id: &str, price: f64) -> Bid { Bid { + video: None, slot_id: "header-banner".to_string(), price: Some(price), currency: "USD".to_string(), @@ -720,6 +723,7 @@ mod tests { provider: "aps".to_string(), status: BidStatus::Success, bids: vec![Bid { + video: None, slot_id: "header-banner".to_string(), price: Some(3.00), currency: "USD".to_string(), @@ -747,6 +751,7 @@ mod tests { provider: "test-bidder".to_string(), status: BidStatus::Success, bids: vec![Bid { + video: None, slot_id: "header-banner".to_string(), price: Some(3.50), currency: "USD".to_string(), @@ -914,6 +919,7 @@ mod tests { "mocktioneer".to_string(), ), Bid { + video: None, slot_id: "header-banner".to_string(), price: Some(0.20), currency: "USD".to_string(), @@ -1028,6 +1034,7 @@ mod tests { "example-bidder".to_string(), ), Bid { + video: None, slot_id: "header-banner".to_string(), price: Some(0.20), currency: "USD".to_string(), @@ -1162,6 +1169,7 @@ mod tests { let auction_request = AuctionRequest { id: "test-auction".to_string(), slots: vec![AdSlot { + video: None, id: "slot-1".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -1190,6 +1198,7 @@ mod tests { provider: "aps".to_string(), status: BidStatus::Success, bids: vec![Bid { + video: None, slot_id: "slot-1".to_string(), price: Some(1.75), currency: "USD".to_string(), diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4f45fbeb9c..0ef184b2f2 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -724,6 +724,7 @@ fn planned_aps_parse_bid( }); Ok(Bid { + video: None, slot_id: slot_id.to_string(), price: Some(price), currency: DEFAULT_CURRENCY.to_string(), @@ -1426,6 +1427,7 @@ impl ApsAuctionProvider { }); Ok(Bid { + video: None, slot_id: slot_id.to_string(), price: Some(price), currency: DEFAULT_CURRENCY.to_string(), @@ -1998,6 +2000,7 @@ mod tests { AuctionRequest { id: "fictional-auction".to_string(), slots: vec![AdSlot { + video: None, id: "fictional-slot".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index f1d1d0b509..d318b2915a 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -23,7 +23,7 @@ use validator::{Validate, ValidationError}; use crate::auction::openrtb::{ BidDimensionIndex, BidRejectionReason, ResponseAdmissionDiagnostics, build_bid_dimension_index, - parse_optional_bid_dimension, resolve_bid_dimensions, + resolve_bid_media, }; use crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS; use crate::auction::plan::AuctionPlan; @@ -2338,9 +2338,7 @@ fn parse_planned_prebid_bid( .filter(|slot_id| !slot_id.is_empty()) .ok_or(BidRejectionReason::InvalidBid)? .to_string(); - let width = parse_optional_bid_dimension(bid, "w")?; - let height = parse_optional_bid_dimension(bid, "h")?; - let (width, height) = resolve_bid_dimensions(dimensions_by_slot, &slot_id, width, height)?; + let media = resolve_bid_media(bid, &slot_id, dimensions_by_slot)?; let price = bid .get("price") .and_then(Json::as_f64) @@ -2378,6 +2376,7 @@ fn parse_planned_prebid_bid( } Ok(AuctionBid { + video: media.video, slot_id, price: Some(price), currency: DEFAULT_CURRENCY.to_string(), @@ -2391,8 +2390,8 @@ fn parse_planned_prebid_bid( }), bidder: delivery_bidder.to_string(), returned_seat: returned_seat.map(str::to_string), - width, - height, + width: media.width, + height: media.height, nurl: bid.get("nurl").and_then(Json::as_str).map(String::from), burl: bid.get("burl").and_then(Json::as_str).map(String::from), bid_id: bid @@ -3317,6 +3316,7 @@ impl PrebidAuctionProvider { } Ok(AuctionBid { + video: None, slot_id, price: Some(price), // Prebid provides decoded prices currency: DEFAULT_CURRENCY.to_string(), @@ -3835,6 +3835,7 @@ mod tests { AuctionRequest { id: "auction-123".to_string(), slots: vec![AdSlot { + video: None, id: "slot-1".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -6551,6 +6552,7 @@ external_bundle_sri = "sha384-AAAA" // Set dimensions that overflow i32 — they will be filtered out, // leaving an empty format list. auction_request.slots = vec![AdSlot { + video: None, id: "oversized-slot".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -7409,6 +7411,7 @@ external_bundle_sri = "sha384-AAAA" fn make_slot(id: &str, bidders: HashMap) -> AdSlot { AdSlot { + video: None, id: id.to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 4aded74882..749af2fc3a 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -8,7 +8,8 @@ pub type OpenRtbResponse = trusted_server_openrtb::BidResponse; pub type OpenRtbBid = trusted_server_openrtb::Bid; pub use trusted_server_openrtb::{ - Banner, Bid, BidResponse, Device, Format, Geo, Imp, Publisher, Regs, SeatBid, Site, ToExt, User, + Banner, Bid, BidResponse, Device, Format, Geo, Imp, Publisher, Regs, SeatBid, Site, ToExt, + User, Video, }; /// Convert a `u32` value to `i32` for `OpenRTB` fields, logging a warning and diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 682ecc0f15..238dfc082f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5726,6 +5726,7 @@ pub(crate) fn build_bid_map_with_auction_id( }; winning_bids .iter() + .filter(|(_, bid)| bid.video.is_none()) .filter_map(|(slot_id, bid)| { bid.price.and_then(|cpm| { let bucket = price_bucket(cpm, granularity); @@ -8128,6 +8129,7 @@ mod tests { fn make_test_bid_with_creative(creative: &str) -> Bid { Bid { + video: None, slot_id: "slot".to_string(), price: Some(1.0), currency: "USD".to_string(), @@ -9007,6 +9009,7 @@ mod tests { AuctionRequest { id: "test-auction".to_string(), slots: vec![AdSlot { + video: None, id: "atf".to_string(), formats: vec![AdFormat { media_type: MediaType::Banner, @@ -10357,6 +10360,7 @@ mod tests { Ok(crate::auction::types::AuctionResponse::success( STUB_BIDDER, vec![Bid { + video: None, slot_id: "test-slot".to_string(), price: Some(self.price), currency: "USD".to_string(), @@ -21959,6 +21963,7 @@ mod tests { burl: &str, ) -> Bid { Bid { + video: None, slot_id: slot_id.to_string(), price: Some(price), currency: "USD".to_string(), @@ -22554,6 +22559,7 @@ mod tests { // adm, which would undo sanitization and the size cap entirely. fn cached_bid_with_creative(creative: &str) -> Bid { Bid { + video: None, slot_id: "atf_sidebar_ad".to_string(), price: Some(1.50), currency: "USD".to_string(), @@ -22717,6 +22723,23 @@ mod tests { ); } + #[test] + fn build_bid_map_keeps_video_out_of_the_gpt_banner_bridge() { + let settings = crate::test_support::tests::create_test_settings(); + let mut video = cached_bid_with_creative(""); + video.video = Some(crate::auction::types::VideoBid { + protocol: Some(7), + duration: Some(15), + ..Default::default() + }); + let winners = HashMap::from([("example-video".to_string(), video)]); + let map = build_bid_map(&winners, PriceGranularity::Dense, &settings, "", false); + assert!( + map.is_empty(), + "should leave video delivery to the caller's player" + ); + } + #[test] fn build_bid_map_uses_request_origin_for_inline_urls() { // The inline adm's absolute first-party URLs must resolve against the @@ -22958,6 +22981,7 @@ mod tests { winning_bids.insert( "atf_sidebar_ad".to_string(), Bid { + video: None, slot_id: "atf_sidebar_ad".to_string(), price: Some(1.50), currency: "USD".to_string(), @@ -23014,6 +23038,7 @@ mod tests { winning_bids.insert( "atf_sidebar_ad".to_string(), Bid { + video: None, slot_id: "atf_sidebar_ad".to_string(), price: Some(0.50), currency: "USD".to_string(), @@ -23127,6 +23152,7 @@ mod tests { winning_bids.insert( "atf_sidebar_ad".to_string(), Bid { + video: None, slot_id: "atf_sidebar_ad".to_string(), price: Some(0.50), currency: "USD".to_string(), @@ -23172,6 +23198,7 @@ mod tests { winning_bids.insert( "no-price-slot".to_string(), Bid { + video: None, slot_id: "no-price-slot".to_string(), price: None, currency: "USD".to_string(), @@ -23550,6 +23577,7 @@ mod tests { ) -> Result> { let bids = if self.winning_bid { vec![Bid { + video: None, slot_id: "atf".to_string(), price: Some(1.50), currency: "USD".to_string(), diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index a026843624..2db90261f9 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -135,6 +135,9 @@ export function parseAuctionResponse(body: any): AuctionBid[] { if (!Array.isArray(seatBids)) continue; for (const bid of seatBids) { + // Existing browser consumers render banner HTML. Direct API video + // responses belong to the caller's player. + if (bid?.mtype !== undefined && bid.mtype !== 1) continue; const impid = typeof bid?.impid === 'string' ? bid.impid : ''; const renderer = parseApsRendererDescriptor(bid?.ext?.trusted_server?.renderer); const width = typeof bid?.w === 'number' ? bid.w : (renderer?.width ?? 300); diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 674957febb..41d20b41f6 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -183,6 +183,24 @@ describe('auction/buildAdRequest', () => { }); describe('auction/parseAuctionResponse', () => { + it('keeps video API responses out of banner rendering while preserving banner siblings', () => { + const bids = parseAuctionResponse({ + seatbid: [ + { + seat: 'example-server', + bid: [ + { impid: 'video', mtype: 2, adm: '', price: 10 }, + { impid: 'native', mtype: 4, adm: '{}', price: 10 }, + { impid: 'malformed', mtype: 'video', adm: '', price: 10 }, + { impid: 'banner', mtype: 1, adm: '
Banner
', price: 2 }, + { impid: 'legacy', adm: '
Legacy
', price: 1 }, + ], + }, + ], + }); + expect(bids.map((bid) => bid.impid)).toEqual(['banner', 'legacy']); + }); + it('parses a standard OpenRTB seatbid response', () => { const body = { seatbid: [ diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index edbc384719..5c8c9e5524 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -2,6 +2,91 @@ Learn how Trusted Server coordinates multiple demand sources in parallel to maximize revenue and minimize latency. +## Use the direct auction API for instream video + +`POST /auction` accepts linear instream video inventory through the `standard` +and `prebid-server` OpenRTB 2.6 profiles. The caller consumes the returned inline +VAST with its own video player. Use this API directly for video; the current +Trusted Server browser Prebid adapter and GPT render bridge deliver banners. + +```json +{ + "adUnits": [ + { + "code": "example-video", + "mediaTypes": { + "video": { + "context": "instream", + "playerSize": [640, 360], + "mimes": ["video/mp4"], + "protocols": [7, 8], + "minduration": 0, + "maxduration": 30, + "plcmt": 1, + "api": [7], + "playbackmethod": [1], + "startdelay": 0, + "skip": 1 + } + }, + "bids": [ + { + "bidder": "example-server", + "params": { "placementId": "example" } + } + ] + } + ] +} +``` + +Configure `example-server` in `[auction.bidders]` for the intended provider. +PBS stored-request fallback remains available when no inline bidder parameters +are supplied. Configure video auctions without a mediator. Video input with a +configured mediator receives a bad-request error before providers are called. + +The video contract requires `context: "instream"`, one positive `playerSize` +within the OpenRTB integer range, nonempty video MIME types and VAST protocol +lists, nonnegative minimum duration, a positive maximum duration at least as +large as the minimum, and explicit `plcmt` of 1 or 2. A single nested player size, +`[[640, 360]]`, is equivalent to the flat pair. `context` does not infer the IAB +placement classification. Optional `linearity` must be 1. The API forwards only +the defined player fields; arbitrary video extensions are not forwarded. + +MIME capabilities accept `video/*` types, the HLS playlist types +`application/vnd.apple.mpegurl` and `application/x-mpegURL`, and the DASH manifest +type `application/dash+xml`. Manifest types describe the player's video +capability; this API does not load the media. + +`api`, `playbackmethod`, `startdelay`, and `skip` are optional. API codes must be +1 through 9, playback methods 1 through 6, and start delay at least -2. The skip +flag must be either 0 or 1. Protocols accept VAST codes 1 through 8 and 11 +through 16. DAAST codes 9 and 10 are unsupported. Invalid or unsupported video +input receives a bad-request error. Video auctions require unique ad-unit codes. + +A placement can offer both banner and video inventory, or the request can +contain separate banner and video units. Standard and PBS profiles receive the +offered video requirements in `imp.video`. APS receives eligible banner +inventory and skips video-only impressions. A mixed-media response must declare +its media through `mtype` or the PBS `ext.prebid.type` field. Omitted media type +is inferred only for an impression offering one supported type. + +A returned video bid has `mtype: 2` and nonempty inline VAST in `adm`. The API +preserves the VAST string, including whitespace, CDATA, tracking URLs and +auction macros, through response serialization even when HTML creative +sanitization and rewriting are enabled. Optional response `protocol` and `dur` +are checked against the requested requirements and retained. Creative video +sizes can differ from the player's dimensions. Missing creative dimensions use +the requested player dimensions for internal delivery metadata. + +The caller validates VAST, resolves auction macros at the applicable clearing +price, loads media, handles wrappers and playback, and fires tracking and any +returned `nurl` or `burl` at their appropriate lifecycle events. Trusted Server +transports these fields and does not execute them. Cache-only video responses, +VAST URL delivery, nonlinear video, outstream rendering, and video mediation +need separate delivery contracts. PBS settings or stored requests that set +`returnCreative: false` cannot supply this inline-VAST flow. + ## Overview The auction orchestrator is the core system that manages server-side ad auctions. It launches bid requests to multiple demand providers simultaneously, collects responses, and selects winners. diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 18db20e237..52cbfc5c0a 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -437,7 +437,7 @@ Full OpenRTB protocol conversion: - Converts ad units to OpenRTB `imp` objects - Injects publisher domain and page URL - Injects EC ID into bid requests for user recognition -- Supports banner formats (video and native are currently not emitted by the Prebid provider) +- Supports banner formats and [direct API instream video](../auction-orchestration.md#use-the-direct-auction-api-for-instream-video). Native formats are not emitted. ### EC ID Injection From bb4877950d5b2c13f01538b96478a3149ef1254a Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Tue, 13 Oct 2026 21:18:01 -0700 Subject: [PATCH 2/2] Validate video APIs and retain bid media identity Emit explicit media identity for banner winners so mixed inventory callers can select the correct renderer. Check video API declarations against the player capabilities before choosing a winner, normalize legacy declarations, and preserve the accepted APIs in the response. Cover mixed banner delivery, malformed and unsupported declarations, and compatible video selection through standard and PBS providers. --- .../src/auction/endpoints.rs | 20 ++-- .../src/auction/formats.rs | 32 ++++++- .../src/auction/openrtb.rs | 32 +++++++ .../src/auction/openrtb/tests.rs | 95 +++++++++++++++++++ .../trusted-server-core/src/auction/types.rs | 3 + docs/guide/auction-orchestration.md | 12 ++- 6 files changed, 181 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 8d2dc4e3fe..d93d1ebc99 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -83,11 +83,10 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// /// ## Response /// -/// Returns an `OpenRTB 2.x` response. Creative HTML is inlined in each bid's -/// `adm` field after mandatory server-side sanitization. First-party resource -/// and click URL rewriting plus creative TSJS injection are enabled by default; -/// setting [`auction.rewrite_creatives`][`crate::auction_config_types::AuctionConfig::rewrite_creatives`] -/// to `false` skips only that rewrite pass. +/// Returns an `OpenRTB 2.x` response with an explicit `mtype` for each bid. +/// Banner HTML in `adm` follows the configured sanitization and rewriting +/// policy. Video `adm` carries inline VAST unchanged for the caller's player; +/// it does not enter HTML processing. Both paths enforce the creative size cap. /// /// ## Scroll, refresh, and SPA navigation /// @@ -680,7 +679,9 @@ mod tests { let vast = " \nExampleExample video00:00:15\n"; http.push_response(200, serde_json::to_vec(&json!({"seatbid":[{"seat":"example-seat","bid":[ {"id":"lower-video","impid":"example-video","price":1.0,"mtype":2,"adm":vast,"protocol":7,"dur":15}, - {"id":"winning-video","impid":"example-video","price":2.0,"mtype":2,"adm":vast,"protocol":7,"dur":15}, + {"id":"winning-video","impid":"example-video","price":2.0,"mtype":2,"adm":vast,"protocol":7,"dur":15,"apis":[7]}, + {"id":"unsupported-legacy-api","impid":"example-video","price":40.0,"mtype":2,"adm":vast,"api":1}, + {"id":"unsupported-player-api","impid":"example-video","price":30.0,"mtype":2,"adm":vast,"apis":[1]}, {"id":"incompatible-banner","impid":"example-video","price":20.0,"mtype":1,"adm":"
"} ]}]})).expect("should serialize bidder response")); let services = crate::platform::test_support::build_services_with_http_client( @@ -695,7 +696,7 @@ mod tests { "code":"example-video", "mediaTypes":{"video":{ "context":"instream","playerSize":[640,360], "mimes":["video/mp4","application/vnd.apple.mpegurl","application/x-mpegURL","application/dash+xml"], - "protocols":[7,8],"minduration":0,"maxduration":30,"plcmt":1 + "protocols":[7,8],"minduration":0,"maxduration":30,"plcmt":1,"api":[7] }} }]})) .expect("should serialize client video input"), @@ -737,6 +738,11 @@ mod tests { ); assert_eq!(bid["protocol"], 7, "should retain VAST protocol"); assert_eq!(bid["dur"], 15, "should retain duration"); + assert_eq!( + bid["apis"], + json!([7]), + "should retain supported player APIs" + ); assert!( bid.get("w").is_none() && bid.get("h").is_none(), "should not present player dimensions as declared creative dimensions" diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index c7bb89b644..a36732e082 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -197,8 +197,8 @@ fn publisher_page_url(req: &Request, publisher_domain: &str) -> String /// /// # Errors /// -/// Returns an error if the request contains invalid banner sizes -/// (must be `[width, height]`). +/// Returns an error for malformed banner sizes, invalid video player requirements, +/// duplicate ad-unit codes in a video auction, or video with configured mediation. pub fn convert_tsjs_to_auction_request( body: &AdRequest, settings: &Settings, @@ -395,7 +395,8 @@ pub(crate) struct OpenRtbResponseConversion { /// [`AuctionConfig::rewrite_creatives`], default-on); with both disabled the /// creative ships exactly as the bidder returned it, subject to the 1 MiB /// per-creative cap. Typed renderers are serialized in the response extension -/// instead of entering that pipeline at all. +/// instead of entering that pipeline at all. Video VAST is returned unchanged, +/// subject to the same size cap, for validation and playback by the caller. /// /// [`AuctionConfig::sanitize_creatives`]: crate::auction_config_types::AuctionConfig::sanitize_creatives /// [`AuctionConfig::rewrite_creatives`]: crate::auction_config_types::AuctionConfig::rewrite_creatives @@ -560,7 +561,11 @@ pub(crate) fn convert_to_openrtb_response_with_report( video.height.and_then(|value| i32::try_from(value).ok()) }), adomain: bid.adomain.clone().unwrap_or_default(), - mtype: bid.video.as_ref().map(|_| 2), + mtype: Some(if bid.video.is_some() { 2 } else { 1 }), + apis: bid + .video + .as_ref() + .map_or_else(Vec::new, |video| video.apis.clone()), protocol: bid.video.as_ref().and_then(|video| video.protocol), dur: bid.video.as_ref().and_then(|video| video.duration), nurl: bid.video.as_ref().and_then(|_| bid.nurl.clone()), @@ -1230,6 +1235,7 @@ mod tests { let vast = " \n\n"; let mut bid = make_bid("div-gpt-top", "example-server", Some(2.0)); bid.video = Some(crate::auction::types::VideoBid { + apis: vec![7], protocol: Some(7), duration: Some(15), width: Some(1920), @@ -1259,6 +1265,24 @@ mod tests { ); } + #[test] + fn video_mixed_inventory_retains_banner_winner_identity() { + let mut value = video_wire_value(); + value["adUnits"][0]["mediaTypes"]["banner"] = json!({"sizes": [[300, 250]]}); + let body = serde_json::from_value(value).expect("should parse mixed inventory"); + let settings = make_settings(); + let request = convert_body_to_auction_request(&body, &settings); + let bid = make_bid("example-video", "example-server", Some(2.0)); + let returned = response_json( + convert_to_openrtb_response(&make_result(bid), &settings, &request, true) + .expect("should serialize the banner winner"), + ); + assert_eq!( + returned["seatbid"][0]["bid"][0]["mtype"], 1, + "should identify banner markup for a caller offering both media types" + ); + } + #[test] fn video_only_wire_request_preserves_the_video_slot() { let settings = make_settings(); diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index e6296367e9..9516335552 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -332,6 +332,7 @@ pub(crate) fn resolve_bid_media( { return Err(BidRejectionReason::InvalidBid); } + let apis = admitted_video_apis(value, placement)?; let protocol = optional_video_integer(value, "protocol")?; if protocol.is_some_and(|value| !placement.protocols.contains(&value)) { return Err(BidRejectionReason::UnsupportedMediaType); @@ -346,6 +347,7 @@ pub(crate) fn resolve_bid_media( width, height, video: Some(VideoBid { + apis, protocol, duration, width: creative_width, @@ -354,6 +356,36 @@ pub(crate) fn resolve_bid_media( }) } +fn admitted_video_apis( + value: &Value, + placement: &VideoPlacement, +) -> Result, BidRejectionReason> { + let mut apis = match value.get("apis") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(values)) => values + .iter() + .map(|value| { + value + .as_i64() + .and_then(|value| i32::try_from(value).ok()) + .ok_or(BidRejectionReason::InvalidBid) + }) + .collect::, _>>()?, + Some(_) => return Err(BidRejectionReason::InvalidBid), + }; + // Preserve legacy declarations too; a second field cannot hide an API + // the player did not offer. Return the normalized OpenRTB 2.6 array. + if let Some(api) = optional_video_integer(value, "api")? + && !apis.contains(&api) + { + apis.push(api); + } + if apis.iter().any(|api| !placement.api.contains(api)) { + return Err(BidRejectionReason::UnsupportedMediaType); + } + Ok(apis) +} + fn optional_video_integer(value: &Value, key: &str) -> Result, BidRejectionReason> { match value.get(key) { None | Some(Value::Null) => Ok(None), diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index c73cd96145..8a1b11078a 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -1819,6 +1819,101 @@ fn video_response_requires_unambiguous_media_and_inline_markup() { } } +#[test] +fn video_response_checks_and_retains_declared_player_apis() { + let (_, routed) = + routed_with_request("standard", json!({}), video_request_fixture(false), None); + let input = &routed.inputs()[0]; + for declaration in [ + json!({"api": 1}), + json!({"apis": [1]}), + json!({"apis": [7, 1]}), + json!({"apis": [7], "api": 1}), + json!({"apis": "7"}), + json!({"apis": ["7"]}), + json!({"apis": [7.5]}), + json!({"apis": [4294967296_u64]}), + json!({"apis": [0]}), + json!({"api": "7"}), + ] { + let mut bid = json!({"impid":"fictional-slot","price":2.0,"mtype":2,"adm":""}); + bid.as_object_mut() + .expect("should find the bid object") + .extend( + declaration + .as_object() + .expect("should find API fields") + .clone(), + ); + let parsed = extract_standard_response( + "fictional-provider", + input, + &json!({"seatbid":[{"bid":[bid]}]}), + 0, + ); + assert!( + parsed.bids.is_empty(), + "should reject incompatible or malformed APIs: {declaration}" + ); + } + for (declaration, expected) in [ + (json!({"apis": [7]}), vec![7]), + (json!({"api": 7}), vec![7]), + (json!({"apis": [7], "api": 7}), vec![7]), + (json!({"apis": []}), vec![]), + (json!({"apis": null, "api": null}), vec![]), + (json!({}), vec![]), + ] { + let mut bid = json!({"impid":"fictional-slot","price":2.0,"mtype":2,"adm":""}); + bid.as_object_mut() + .expect("should find the bid object") + .extend( + declaration + .as_object() + .expect("should find API fields") + .clone(), + ); + let parsed = extract_standard_response( + "fictional-provider", + input, + &json!({"seatbid":[{"bid":[bid]}]}), + 0, + ); + assert_eq!( + parsed.bids.len(), + 1, + "should accept supported or omitted APIs: {declaration}" + ); + assert_eq!( + parsed.bids[0] + .video + .as_ref() + .expect("should mark video") + .apis, + expected, + "should retain normalized API declarations" + ); + } + let mut no_api_request = video_request_fixture(false); + no_api_request.slots[0] + .video + .as_mut() + .expect("should find player requirements") + .api + .clear(); + let (_, routed) = routed_with_request("standard", json!({}), no_api_request, None); + let parsed = extract_standard_response( + "fictional-provider", + &routed.inputs()[0], + &json!({"seatbid":[{"bid":[{"impid":"fictional-slot","price":2.0,"mtype":2,"adm":"","apis":[7]}]}]}), + 0, + ); + assert!( + parsed.bids.is_empty(), + "should not assume support for an unoffered API" + ); +} + #[test] fn video_provider_round_trip_uses_active_standard_and_pbs_parsers() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index f2b01c3c8b..52b8f2ac90 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -120,6 +120,9 @@ impl VideoPlacement { /// Video response facts retained for the caller's player. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct VideoBid { + /// Declared markup APIs, including any legacy single API declaration. + #[serde(default)] + pub apis: Vec, /// Declared VAST protocol, when supplied by the provider. pub protocol: Option, /// Declared creative duration in seconds, when supplied by the provider. diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 5c8c9e5524..805b4518df 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -69,13 +69,21 @@ contain separate banner and video units. Standard and PBS profiles receive the offered video requirements in `imp.video`. APS receives eligible banner inventory and skips video-only impressions. A mixed-media response must declare its media through `mtype` or the PBS `ext.prebid.type` field. Omitted media type -is inferred only for an impression offering one supported type. +is inferred only for an impression offering one supported type. The API returns +explicit `mtype: 1` for banner winners and `mtype: 2` for video winners, including +mixed impressions. A returned video bid has `mtype: 2` and nonempty inline VAST in `adm`. The API preserves the VAST string, including whitespace, CDATA, tracking URLs and auction macros, through response serialization even when HTML creative sanitization and rewriting are enabled. Optional response `protocol` and `dur` -are checked against the requested requirements and retained. Creative video +are checked against the requested requirements and retained. This API also +requires every declared response `apis` or legacy `api` framework to appear in +the requested player `api` list; an omitted player list offers no frameworks. +Malformed or unsupported declarations are rejected before winner selection. +Accepted declarations are returned in `apis`, with legacy `api` normalized into +that array. Missing response declarations do not infer any framework support, +and the caller still validates the actual VAST. Creative video sizes can differ from the player's dimensions. Missing creative dimensions use the requested player dimensions for internal delivery metadata.