diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b4dc6465..6469609078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping, define no slot where the band asks for no size, and keep every format when the mapping fails. The bundle's refresh auction offers a bidder only the sizes of the band the viewport is in for a slot defined with that mapping, and offers a slot the publisher defined its formats as before. A winning bid with no size of its own takes the band's first size, and `ts config ad-templates --details` prints each format's bounds. A slot whose formats name no bound behaves as before. - A device module may answer the confidence that a person is looking at the page, `DeviceSignals::human_confidence`, which nothing reads yet, which is not stored in the identity graph with the other signals, and which stays `Unknown` for every module that does not score the request, and may tell a bidder what the device is through `DeviceModule::advertising_attributes`, which reaches the bid request's device object as its type, make, model, operating system and screen. That call carries the permissions resolved for the request, a backend the module registers during it waits no longer than the auction's timeout, and core drops a device type outside the AdCOM 1.0 device types list, a screen size that is not positive and a blank string. A module that resolves nothing adds no device object. - A prefix module, selected by `[prefix] module`, gives the start of every name the browser holds for a publisher and of every key the publisher's entries take in a shared store. Core's own module gives `ts` and `ts.` and runs when nothing is selected, so a default deployment's names are unchanged. The page's bundle tag tells the client script the public prefix as `data-ts-public-prefix`, and a template stored under one prefix is never served under another. - `[shared.]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table whose name is not lower case letters, digits, `_` or `-` is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared, resolved and checked at push to name a key as a module's own are. A shared table, or a module's own, that cannot be read or fails its validation is refused naming the setting at fault and never its value. diff --git a/crates/ad-tag/google/src/fixtures/page-change.recorded.html b/crates/ad-tag/google/src/fixtures/page-change.recorded.html index 3d3a347ffd..0cb4951f41 100644 --- a/crates/ad-tag/google/src/fixtures/page-change.recorded.html +++ b/crates/ad-tag/google/src/fixtures/page-change.recorded.html @@ -360,6 +360,68 @@ ); } + // The sizes the slot's viewport band asks for at the current viewport width, + // an empty list where that band asks for none, or null when the slot names + // no band. Bands come widest first, so the first one the width reaches holds. + function viewportBandSizes(slot) { + var bands = slot.viewport_sizes; + if (!bands || !bands.length) return null; + var width = window.innerWidth; + var band = bands.find(function (candidate) { + return width >= candidate[0][0]; + }); + return band ? band[1] : []; + } + + // Defines the slot and applies its viewport bands as a size mapping, recording + // the slot in ts.viewportMappedSlots once the mapping is applied. Defines + // nothing where the band at the current viewport width asks for no size, as + // no ad can serve there and GPT would otherwise size or collapse the slot's + // container after the page has painted. A mapping that throws leaves the slot + // defined with every format, so the caller still adds it to the ad service. + function defineWithViewportSizes(api, slot, elementId) { + var bandSizes = viewportBandSizes(slot); + if (bandSizes && !bandSizes.length) { + if (ts.log && typeof ts.log.debug === "function") { + ts.log.debug("GPT slot asks for no size at this viewport width", { + divId: slot.div_id, + }); + } + return null; + } + var definedSlot = api.defineSlot( + slot.gam_unit_path, + slot.formats, + elementId, + ); + var bands = slot.viewport_sizes; + if ( + definedSlot && + bands && + bands.length && + api.sizeMapping && + definedSlot.defineSizeMapping + ) { + try { + var builder = api.sizeMapping(); + bands.forEach(function (band) { + builder.addSize(band[0], band[1]); + }); + definedSlot.defineSizeMapping(builder.build()); + ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet(); + ts.viewportMappedSlots.add(definedSlot); + } catch (err) { + if (ts.log && typeof ts.log.warn === "function") { + ts.log.warn("GPT size mapping failed; slot keeps every format", { + divId: slot.div_id, + error: err, + }); + } + } + } + return definedSlot; + } + function normalizedGptFormats(formats) { return formats.length === 2 && formats.every(function (format) { @@ -682,11 +744,7 @@ var tsOwned = false; if (!gptSlot) { gptSlot = runHandoffInternal(function () { - return window.googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - element.id, - ); + return defineWithViewportSizes(window.googletag, slot, element.id); }); if (!gptSlot) { releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); @@ -834,11 +892,7 @@ // Define TS's fallback on the publisher's actual div. The scoped // handoff wrapper returns this slot if the publisher defines it later. s = runHandoffInternal(function () { - return googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - actualDivId, - ); + return defineWithViewportSizes(googletag, slot, actualDivId); }); if (!s) { releaseTrustedServerFirstImpressionClaim(el, tsClaim); diff --git a/crates/ad-tag/google/src/gpt_bootstrap.js b/crates/ad-tag/google/src/gpt_bootstrap.js index afae775350..1ec0ed21de 100644 --- a/crates/ad-tag/google/src/gpt_bootstrap.js +++ b/crates/ad-tag/google/src/gpt_bootstrap.js @@ -358,6 +358,68 @@ ); } + // The sizes the slot's viewport band asks for at the current viewport width, + // an empty list where that band asks for none, or null when the slot names + // no band. Bands come widest first, so the first one the width reaches holds. + function viewportBandSizes(slot) { + var bands = slot.viewport_sizes; + if (!bands || !bands.length) return null; + var width = window.innerWidth; + var band = bands.find(function (candidate) { + return width >= candidate[0][0]; + }); + return band ? band[1] : []; + } + + // Defines the slot and applies its viewport bands as a size mapping, recording + // the slot in ts.viewportMappedSlots once the mapping is applied. Defines + // nothing where the band at the current viewport width asks for no size, as + // no ad can serve there and GPT would otherwise size or collapse the slot's + // container after the page has painted. A mapping that throws leaves the slot + // defined with every format, so the caller still adds it to the ad service. + function defineWithViewportSizes(api, slot, elementId) { + var bandSizes = viewportBandSizes(slot); + if (bandSizes && !bandSizes.length) { + if (ts.log && typeof ts.log.debug === "function") { + ts.log.debug("GPT slot asks for no size at this viewport width", { + divId: slot.div_id, + }); + } + return null; + } + var definedSlot = api.defineSlot( + slot.gam_unit_path, + slot.formats, + elementId, + ); + var bands = slot.viewport_sizes; + if ( + definedSlot && + bands && + bands.length && + api.sizeMapping && + definedSlot.defineSizeMapping + ) { + try { + var builder = api.sizeMapping(); + bands.forEach(function (band) { + builder.addSize(band[0], band[1]); + }); + definedSlot.defineSizeMapping(builder.build()); + ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet(); + ts.viewportMappedSlots.add(definedSlot); + } catch (err) { + if (ts.log && typeof ts.log.warn === "function") { + ts.log.warn("GPT size mapping failed; slot keeps every format", { + divId: slot.div_id, + error: err, + }); + } + } + } + return definedSlot; + } + function normalizedGptFormats(formats) { return formats.length === 2 && formats.every(function (format) { @@ -680,11 +742,7 @@ var tsOwned = false; if (!gptSlot) { gptSlot = runHandoffInternal(function () { - return window.googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - element.id, - ); + return defineWithViewportSizes(window.googletag, slot, element.id); }); if (!gptSlot) { releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); @@ -832,11 +890,7 @@ // Define TS's fallback on the publisher's actual div. The scoped // handoff wrapper returns this slot if the publisher defines it later. s = runHandoffInternal(function () { - return googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - actualDivId, - ); + return defineWithViewportSizes(googletag, slot, actualDivId); }); if (!s) { releaseTrustedServerFirstImpressionClaim(el, tsClaim); diff --git a/crates/ad-tag/google/src/lib.rs b/crates/ad-tag/google/src/lib.rs index ecc6e529e7..cfb5e82157 100644 --- a/crates/ad-tag/google/src/lib.rs +++ b/crates/ad-tag/google/src/lib.rs @@ -1375,7 +1375,7 @@ mod tests { "bootstrap should install idempotent GPT handoff wrappers" ); assert!( - combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), + combined.contains("return defineWithViewportSizes(googletag, slot, actualDivId)"), "bootstrap should define the TS fallback on the actual inner div" ); assert!( diff --git a/crates/trusted-server-cli/src/commands/config/ad_templates.rs b/crates/trusted-server-cli/src/commands/config/ad_templates.rs index 20f96b5f51..00821fb85c 100644 --- a/crates/trusted-server-cli/src/commands/config/ad_templates.rs +++ b/crates/trusted-server-cli/src/commands/config/ad_templates.rs @@ -487,13 +487,26 @@ fn format_slot(slot: &CreativeOpportunitySlot, gam_network_id: &str, section: &s ) } +/// Formats one format for `--details`, with any viewport bound under the name +/// the configuration gives it. fn format_format(format: &CreativeOpportunityFormat) -> String { let media_type = match format.media_type { MediaType::Banner => "banner", MediaType::Video => "video", MediaType::Native => "native", }; - format!("{}x{} {media_type}", format.width, format.height) + let bounds: String = [ + format + .min_viewport_width + .map(|min| format!(" min_viewport_width={min}")), + format + .max_viewport_width + .map(|max| format!(" max_viewport_width={max}")), + ] + .into_iter() + .flatten() + .collect(); + format!("{}x{} {media_type}{bounds}", format.width, format.height) } fn format_providers(slot: &CreativeOpportunitySlot) -> String { @@ -615,6 +628,44 @@ mod tests { ); } + #[test] + fn match_details_include_each_formats_viewport_bounds() { + let config_text = format!( + "{}\n\ + [[creative_opportunities.slot]]\n\ + id = \"billboard\"\n\ + page_patterns = [\"/video/*\"]\n\ + formats = [\n\ + {{ width = 970, height = 250, min_viewport_width = 1040 }},\n\ + {{ width = 728, height = 90, min_viewport_width = 800, max_viewport_width = 1039 }},\n\ + {{ width = 320, height = 50, max_viewport_width = 599 }},\n\ + ]\n", + config_with_slots() + ); + let (_temp, config) = project_with_config(&config_text); + let mut out = Vec::new(); + + run_ad_templates_with_writer( + &AdTemplatesCommand::Match(AdTemplatesMatchArgs { + config, + path_or_url: "/video/clip".to_string(), + details: true, + }), + &mut out, + ) + .expect("should match slots"); + + let output = String::from_utf8(out).expect("should be utf8"); + assert!( + output.contains( + "formats=[970x250 banner min_viewport_width=1040, \ + 728x90 banner min_viewport_width=800 max_viewport_width=1039, \ + 320x50 banner max_viewport_width=599]" + ), + "should print each format's viewport bounds: {output}" + ); + } + #[test] fn check_rejects_unexpected_extra_slots_by_default() { let (_temp, config) = project_with_config(&config_with_slots()); diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 0c88c8d2c2..e84a000095 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -942,6 +942,45 @@ impl CreativeOpportunitySlot { self.div_id.as_deref().unwrap_or(&self.id) } + /// The sizes this slot asks for at each viewport width. + /// + /// Each band is the narrowest viewport width of the band and the + /// `[width, height]` sizes asked for from that width up to the next band's, + /// widest first. A band starts at zero and at every width where the formats + /// on offer change, and a band may be empty, meaning the slot asks for + /// nothing at those widths. [`None`] when no format names a viewport bound, + /// so the slot asks for every format at every width. + #[must_use] + pub fn viewport_sizes(&self) -> Option { + if self.formats.iter().all(|format| { + format.min_viewport_width.is_none() && format.max_viewport_width.is_none() + }) { + return None; + } + let mut edges = vec![0_u32]; + for format in &self.formats { + edges.extend(format.min_viewport_width); + edges.extend(format.max_viewport_width.and_then(|max| max.checked_add(1))); + } + edges.sort_unstable(); + edges.dedup(); + let mut bands: ViewportSizes = edges + .into_iter() + .map(|edge| { + let sizes = self + .formats + .iter() + .filter(|format| format.offered_at(edge)) + .map(|format| [format.width, format.height]) + .collect(); + (edge, sizes) + }) + .collect(); + bands.dedup_by(|later, earlier| later.1 == earlier.1); + bands.reverse(); + Some(bands) + } + /// Converts this slot into an [`AdSlot`] ready for use in an auction request. /// /// Prebid Server bidder params are wired into the `bidders` map keyed by @@ -995,8 +1034,17 @@ pub struct CreativeOpportunityFormat { /// Media type for this format. Defaults to `Banner`. #[serde(default)] pub media_type: MediaType, + /// Narrowest viewport width, in CSS pixels, at which the page asks for this format. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_viewport_width: Option, + /// Widest viewport width, in CSS pixels, at which the page asks for this format. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_viewport_width: Option, } +/// The bands [`CreativeOpportunitySlot::viewport_sizes`] gives. +pub type ViewportSizes = Vec<(u32, Vec<[u32; 2]>)>; + impl CreativeOpportunityFormat { fn validate_runtime(&self, slot_id: &str) -> Result<(), String> { if self.width == 0 || self.height == 0 { @@ -1004,10 +1052,28 @@ impl CreativeOpportunityFormat { "slot `{slot_id}` format must have positive width and height" )); } + if let (Some(min), Some(max)) = (self.min_viewport_width, self.max_viewport_width) + && min > max + { + return Err(format!( + "slot `{slot_id}` format {}x{} has min_viewport_width {min} above \ + max_viewport_width {max}", + self.width, self.height + )); + } Ok(()) } + /// Whether the page asks for this format at a viewport this wide. + fn offered_at(&self, viewport_width: u32) -> bool { + self.min_viewport_width + .is_none_or(|min| viewport_width >= min) + && self + .max_viewport_width + .is_none_or(|max| viewport_width <= max) + } + fn to_ad_format(&self) -> AdFormat { AdFormat { media_type: self.media_type.clone(), @@ -1455,6 +1521,8 @@ mod tests { width: 300, height: 250, media_type: crate::auction::types::MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, }], floor_price: Some(0.50), targeting: Default::default(), @@ -1464,6 +1532,146 @@ mod tests { } } + fn banded( + width: u32, + height: u32, + min_viewport_width: Option, + max_viewport_width: Option, + ) -> CreativeOpportunityFormat { + CreativeOpportunityFormat { + width, + height, + media_type: crate::auction::types::MediaType::Banner, + min_viewport_width, + max_viewport_width, + } + } + + #[test] + fn viewport_sizes_is_none_when_no_format_names_a_bound() { + let slot = make_slot("atf", vec!["/**"]); + + assert!( + slot.viewport_sizes().is_none(), + "a slot without bounds should ask for every format at every width" + ); + } + + #[test] + fn viewport_sizes_gives_each_band_only_the_formats_that_fit_it() { + let mut slot = make_slot("billboard", vec!["/**"]); + slot.formats = vec![ + banded(970, 250, Some(1040), None), + banded(728, 90, Some(800), Some(1039)), + banded(300, 250, Some(600), Some(799)), + banded(320, 50, None, Some(599)), + ]; + + assert_eq!( + slot.viewport_sizes(), + Some(vec![ + (1040, vec![[970, 250]]), + (800, vec![[728, 90]]), + (600, vec![[300, 250]]), + (0, vec![[320, 50]]), + ]), + "should band the formats widest first" + ); + } + + #[test] + fn viewport_sizes_keeps_an_unbounded_format_in_every_band() { + let mut slot = make_slot("rail", vec!["/**"]); + slot.formats = vec![ + banded(300, 600, Some(1040), None), + banded(300, 250, None, None), + ]; + + assert_eq!( + slot.viewport_sizes(), + Some(vec![ + (1040, vec![[300, 600], [300, 250]]), + (0, vec![[300, 250]]), + ]), + "should offer the unbounded format at every width" + ); + } + + #[test] + fn viewport_sizes_leaves_a_band_empty_when_no_format_fits() { + let mut slot = make_slot("leaderboard", vec!["/**"]); + slot.formats = vec![banded(728, 90, Some(800), None)]; + + assert_eq!( + slot.viewport_sizes(), + Some(vec![(800, vec![[728, 90]]), (0, vec![])]), + "should ask for nothing below the narrowest bound" + ); + } + + #[test] + fn viewport_sizes_merges_neighbouring_bands_that_offer_the_same_formats() { + let mut slot = make_slot("footer", vec!["/**"]); + slot.formats = vec![ + banded(728, 90, Some(800), None), + banded(300, 250, None, Some(799)), + banded(320, 50, None, Some(799)), + ]; + + assert_eq!( + slot.viewport_sizes(), + Some(vec![ + (800, vec![[728, 90]]), + (0, vec![[300, 250], [320, 50]]), + ]), + "two bounds at the same width should make one band" + ); + } + + #[test] + fn validate_runtime_refuses_a_format_whose_min_viewport_exceeds_its_max() { + let mut slot = make_slot("atf", vec!["/**"]); + slot.formats = vec![banded(300, 250, Some(800), Some(600))]; + slot.compile_patterns(); + + let error = slot + .validate_runtime() + .expect_err("should refuse an inverted viewport range"); + + assert!( + error.contains("min_viewport_width 800 above max_viewport_width 600"), + "should name both bounds: {error}" + ); + } + + #[test] + fn a_format_without_bounds_serializes_without_them() { + let value = + serde_json::to_value(banded(300, 250, None, None)).expect("should serialize a format"); + + assert!( + value.get("min_viewport_width").is_none() && value.get("max_viewport_width").is_none(), + "an unbounded format should serialize as it did before: {value}" + ); + } + + #[test] + fn a_format_reads_its_viewport_bounds() { + let format: CreativeOpportunityFormat = serde_json::from_value(serde_json::json!({ + "width": 728, + "height": 90, + "min_viewport_width": 800, + "max_viewport_width": 1039, + })) + .expect("should read both bounds"); + + assert_eq!( + (format.min_viewport_width, format.max_viewport_width), + (Some(800), Some(1039)), + "should keep the bounds as written" + ); + } + #[test] fn compile_patterns_populates_cache_and_match_uses_it() { let mut slot = make_slot("atf", vec!["/20**", "/about"]); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 8025c8a587..0156904dab 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -6647,9 +6647,10 @@ pub(crate) fn build_bid_map_with_auction_id( serde_json::Value::String(auction_id.to_owned()), ); } - // Winning creative dimensions — the bridge sizes the inline - // render from these, falling back to the first configured slot - // format only when absent, which mis-sizes a multi-size slot. + // Winning creative dimensions. The bridge sizes the inline + // render from these. When they are absent it falls back to the + // first size of the slot's viewport band, then to the first + // configured format, which mis-sizes a multi-size slot. // Omit a zero dimension (missing OpenRTB w/h parse to 0) so the // bridge falls back rather than sizing the frame to 0. if bid.width > 0 { @@ -6995,8 +6996,9 @@ pub(crate) fn build_empty_bids_script() -> String { /// [`handle_page_bids`] (SPA navigation) so the slot wire shape has a single /// definition and the two paths cannot silently diverge. Property names match /// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`, -/// `formats`, and `targeting`. Returns `None` when the slot's dynamic GAM unit -/// path exceeds its rendering limit. +/// `formats`, and `targeting`, with `viewport_sizes` added when a format names +/// a viewport bound. Returns `None` when the slot's dynamic GAM unit path +/// exceeds its rendering limit. pub(crate) fn build_slot_json( slot: &crate::creative_opportunities::CreativeOpportunitySlot, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, @@ -7014,13 +7016,22 @@ pub(crate) fn build_slot_json( .iter() .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) .collect(); - Some(serde_json::json!({ + let mut json = serde_json::json!({ "id": slot.id, "gam_unit_path": gam_path, "div_id": div_id, "formats": formats, "targeting": targeting, - })) + }); + // Each band of `CreativeOpportunitySlot::viewport_sizes` as the browser + // takes it, with a `[width, 0]` viewport. + if let Some(bands) = slot.viewport_sizes() { + json["viewport_sizes"] = bands + .into_iter() + .map(|(width, sizes)| serde_json::json!([[width, 0], sizes])) + .collect(); + } + Some(json) } /// Match creative-opportunity slots and omit dynamic GAM paths that cannot be @@ -7696,7 +7707,8 @@ fn permissions_json_or_empty(permissions_json: &str) -> Cow<'_, str> { /// Build the `tsjs.adSlots` `