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