diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index f364464561..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 /// @@ -643,6 +642,133 @@ 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,"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( + 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,"api":[7] + }} + }]})) + .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_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" + ); + 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..a36732e082 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)] @@ -162,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, @@ -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) @@ -320,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 @@ -382,7 +458,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 +554,22 @@ 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: 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()), + burl: bid.video.as_ref().and_then(|_| bid.burl.clone()), ext, ..Default::default() }; @@ -608,6 +709,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 +747,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 +812,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 +873,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 +1070,256 @@ 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 { + apis: vec![7], + 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_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(); + 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 +1484,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 +1520,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 +1562,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 +2326,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 +2356,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 +2434,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..9516335552 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,148 @@ 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 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); + } + 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 { + apis, + protocol, + duration, + width: creative_width, + height: creative_height, + }), + }) +} + +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), + 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 +601,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 +611,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 +682,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 +1028,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 +1041,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 +1058,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..8a1b11078a 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,423 @@ 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_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 { + 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..52b8f2ac90 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,80 @@ 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 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. + 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 +315,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 +493,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 +641,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 +749,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..805b4518df 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -2,6 +2,99 @@ 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. 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. 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. + +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