diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index cab43d1..2c9cb51 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -59,6 +59,7 @@ pub mod manual; mod masks_ui; pub mod memory; pub mod merge; +mod merge_thumbs; mod merge_ui; mod net_runtime; mod peaking; diff --git a/ui/dr-ui/src/library_ui/controller.rs b/ui/dr-ui/src/library_ui/controller.rs index 0946e6b..d61b6b0 100644 --- a/ui/dr-ui/src/library_ui/controller.rs +++ b/ui/dr-ui/src/library_ui/controller.rs @@ -113,6 +113,16 @@ pub struct LibraryController { /// two states apart — without it a carried thumbnail either blocks the /// sharper fetch forever or is re-fetched on every scroll. pub(super) thumb_class: RefCell>>, + /// TRACES: FR-MRG-6 + /// Thumbnails a merge made for a composite it has just catalogued, by + /// image, decoded and at each class it made. + /// + /// The grid's own path keys the store on the server's file id, and a + /// composite has none until its upload lands — minutes, on Nextcloud. The + /// merge's pixels are drawn from here until then, so the cell is not + /// blank in the meantime; after, the store holds the same ones. + pub(super) merged_thumbs: + RefCell>>, /// Where in the catalog the current window starts. Scrubbing moves this. pub(super) offset: RefCell, /// The first visible ordinal, kept so returning from the develop view lands @@ -449,6 +459,7 @@ impl LibraryController { needs_metadata: RefCell::new(Vec::new()), captured_at: RefCell::new(Vec::new()), thumb_class: RefCell::new(Vec::new()), + merged_thumbs: RefCell::new(Default::default()), offset: RefCell::new(0), resume_at: std::cell::Cell::new(0), roll_open: std::cell::Cell::new(None), diff --git a/ui/dr-ui/src/library_ui/open.rs b/ui/dr-ui/src/library_ui/open.rs index fd5e8c0..3f8d898 100644 --- a/ui/dr-ui/src/library_ui/open.rs +++ b/ui/dr-ui/src/library_ui/open.rs @@ -81,6 +81,20 @@ pub fn catalogue_composite( match written { Ok(image) => { log::info!("merge: {} catalogued as image {image}", row.source_ref); + let decoded: Vec<(dr_thumbs::ThumbSize, slint::Image)> = c + .thumbnails + .iter() + .filter_map(|(class, t)| { + let (w, h, rgba) = dr_thumbs::decode_rgba(&t.bytes).ok()?; + Some((*class, super::window::to_slint_image(w, h, &rgba))) + }) + .collect(); + if !decoded.is_empty() { + ctl.merged_thumbs.borrow_mut().insert(image, decoded); + } + // Asked for afresh: a row under this name may have been drawn, + // and answered, before this merge wrote over it. + ctl.requested.borrow_mut().retain(|(id, _)| *id != image); reload(window, ctl); } Err(e) => log::warn!("merge: cataloguing {}: {e}", row.source_ref), @@ -114,6 +128,9 @@ pub fn open( let filter = account.format_filter(); *ctl.session.borrow_mut() = Some((conn.clone(), filter.clone())); + // Image ids are one catalog's; another library's would name other + // photographs. + ctl.merged_thumbs.borrow_mut().clear(); // TRACES: FR-UI-8 // Where this library's position is kept, and what this device signs it diff --git a/ui/dr-ui/src/library_ui/window.rs b/ui/dr-ui/src/library_ui/window.rs index 39dfa3e..efd331f 100644 --- a/ui/dr-ui/src/library_ui/window.rs +++ b/ui/dr-ui/src/library_ui/window.rs @@ -467,6 +467,43 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { hold_thumbnails(&previous, &ids, &classes) }; + // A composite a merge has just catalogued is drawn from the merge's own + // thumbnails until the store can serve it (FR-MRG-6), and counts as + // served at that class so no fetch is spent on a file still uploading. + let wanted = dr_thumbs::ThumbSize::for_cell( + window.global::().get_library_cell_size().max(1.0) as u32, + ); + let mut held = held; + { + let merged = ctl.merged_thumbs.borrow(); + for c in &cells { + let Some(made) = merged.get(&c.image_id) else { + continue; + }; + if held + .get(&c.image_id) + .is_some_and(|h| h.class == Some(wanted)) + { + continue; + } + let pick = made + .iter() + .find(|(class, _)| *class == wanted) + .or_else(|| made.iter().max_by_key(|(class, _)| class.edge())); + if let Some((class, image)) = pick { + held.insert( + c.image_id, + Held { + thumbnail: image.clone(), + has_thumb: true, + unavailable: false, + class: Some(*class), + }, + ); + } + } + } + let rows: Vec = cells .iter() .zip(headings) @@ -989,7 +1026,7 @@ fn drain_thumbnails( /// This is a CPU copy, which is acceptable here and not in the develop path: /// a 256px thumbnail is 256 KB and happens once per image, where the canvas /// would pay per frame (ARCH §6.1). -fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { +pub(super) fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { let mut buf = slint::SharedPixelBuffer::::new(width, height); let expected = (width as usize) * (height as usize) * 4; let src = &rgba[..expected.min(rgba.len())]; diff --git a/ui/dr-ui/src/merge.rs b/ui/dr-ui/src/merge.rs index 53433dc..da7952f 100644 --- a/ui/dr-ui/src/merge.rs +++ b/ui/dr-ui/src/merge.rs @@ -266,6 +266,10 @@ pub struct Composite { pub lens: Option, pub iso: Option, pub file_size: u64, + /// Made from the final image as develop would first show it + /// ([`crate::merge_thumbs`]); empty if that failed, and the grid then + /// thumbnails it the ordinary way. + pub thumbnails: Vec<(dr_thumbs::ThumbSize, dr_thumbs::Thumbnail)>, } impl MergeEvent { @@ -662,6 +666,7 @@ fn run_inner( let _ = std::fs::remove_file(part_name(path)); if staged { let _ = std::fs::remove_file(crate::export::destination_record(path)); + let _ = std::fs::remove_file(crate::export::thumbnails_record(path)); } }; let (out_w, out_h) = (report_size(&bounds).0, report_size(&bounds).1); @@ -671,6 +676,8 @@ fn run_inner( let profile = dng_profile(first, white_level); let balance = profile.as_shot_neutral.map(|n| 1.0 / n.max(1e-3)); let (_, mut carried) = crate::export::header_for_file(&first.meta); + // The thumbnails' copy of the profile; the writer takes the original. + let thumb_profile = profile.clone(); // The composite is dated at the middle of its sweep — the mean of the // frames' capture times — so it sorts among the frames it was made // from, not at the first of them and not at the moment of the merge. @@ -683,6 +690,7 @@ fn run_inner( // What the catalog is told about the file (FR-MRG-6), taken before the // header moves into the writer. + let thumb_header = carried.clone(); let described = ( carried.captured_at, carried.captured_offset, @@ -881,6 +889,9 @@ fn run_inner( let total_bands = out_h.div_ceil(rows_per_strip) as usize; let mut bands_done = 0usize; + // The final image, reduced as it goes by, for the thumbnails (FR-MRG-6). + let mut reduced = + crate::merge_thumbs::Reduced::new(out_w, out_h, crate::merge_thumbs::SOURCE_EDGE); progress("Merging", 0, total_bands); let merged = pass.merge( &mut adjust, @@ -935,6 +946,7 @@ fn run_inner( } } } + reduced.push(band.first_row, band.rows, &rgb); band_tx .send(rgb) .map_err(|_| dr_gpu::GpuError::Readback("the writer stopped".into())) @@ -961,10 +973,6 @@ fn run_inner( cleanup(&out_path); return Err(e); } - if let Err(e) = std::fs::rename(&part_path, &out_path) { - cleanup(&out_path); - return Err(format!("{}: {e}", out_path.display())); - } // The rectangle the file opens on, as the writer recorded it. let picture = if fill_cam.is_some() { None @@ -975,6 +983,42 @@ fn run_inner( .map(|i| clamp_crop(i.best(), out_w, out_h)) .filter(|r| r.width > 0 && r.height > 0) }; + + // TRACES: FR-MRG-6 + // The thumbnails, from the final image, developed as develop will first + // show the file. Before the rename, and staged beside the payload before + // it: the rename is what lets a drain take the file, and the drain is + // what puts these in the store once the server has named it. + // The merge's GPU memory back before develop asks for its own. + drop(resident); + drop(pass); + progress("Making thumbnails", 0, 1); + let (pw, ph) = picture.map_or((out_w, out_h), |r| (r.width, r.height)); + let thumbnails = match crate::merge_thumbs::render( + ctx, + reduced, + &thumb_profile, + &thumb_header, + picture, + request.decoder, + &crate::merge_thumbs::classes(pw, ph), + ) { + Ok(t) => t, + Err(e) => { + log::warn!("merge: the thumbnails could not be made: {e}"); + Vec::new() + } + }; + if staged && !thumbnails.is_empty() { + if let Err(e) = crate::export::write_thumbnails(&out_path, &thumbnails) { + log::warn!("merge: the thumbnails could not be staged: {e}"); + } + } + + if let Err(e) = std::fs::rename(&part_path, &out_path) { + cleanup(&out_path); + return Err(format!("{}: {e}", out_path.display())); + } log::info!( "merge: {}×{} written to {} in {:?}", out_w, @@ -1029,14 +1073,15 @@ fn run_inner( .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(), - width: picture.map_or(out_w, |r| r.width), - height: picture.map_or(out_h, |r| r.height), + width: pw, + height: ph, captured_at, captured_offset, camera, lens, iso, file_size: std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0), + thumbnails, }; Ok(Some(MergeEvent::Done { diff --git a/ui/dr-ui/src/merge_thumbs.rs b/ui/dr-ui/src/merge_thumbs.rs new file mode 100644 index 0000000..ceb6174 --- /dev/null +++ b/ui/dr-ui/src/merge_thumbs.rs @@ -0,0 +1,409 @@ +//! TRACES: FR-MRG-6 +//! The composite's thumbnails, made by the merge from its own final image. +//! +//! # Why the merge makes them +//! +//! A linear DNG as the merge writes it carries no embedded preview, and an +//! embedded preview is all the grid's ordinary thumbnail path reads — so a +//! composite sat in the grid as a blank cell until someone opened it. Making +//! the thumbnail afterwards means reading back a file of 800 MB; the merge +//! has every pixel of it in hand as the bands go by. +//! +//! # From the final image, through develop +//! +//! The bands are box-reduced as they are written — after the border fill, +//! so the thumbnail shows what the file holds — into a copy +//! [`SOURCE_EDGE`] pixels long. That copy is written as a linear DNG in +//! memory with the composite's own profile, header and crop, and opened +//! exactly as develop opens a file on first sight (`crate::open_session`): +//! the same decode, the same default graph with its default view transform +//! and tone mapping, the file's as-shot white balance and the conversion +//! from the working space to the display's. The thumbnail is therefore the +//! photograph the photographer will see on opening it, not a second, +//! plausible conversion — the merge's own quick look ([`crate::merge`]'s +//! `Look`) is exactly the naive linear-to-gamma picture this is not. +//! +//! The reduction is develop's too, in effect: a composite past one texture +//! is developed from a box-reduced copy (`DevelopSession::open_owned`), and +//! this is the same box at a coarser step. + +use std::io::Cursor; + +use dr_thumbs::{ThumbSize, Thumbnail}; + +/// The long edge of the reduced copy the thumbnails are developed from: +/// twice the largest thumbnail, so the develop render resamples down rather +/// than up, and small enough to cost the merge a few tens of megabytes. +pub const SOURCE_EDGE: u32 = 4096; + +/// The composite box-reduced as its bands go by. +pub struct Reduced { + width: u32, + height: u32, + factor: u32, + /// Per reduced pixel, per channel: the sum of the full-resolution + /// samples that fall in its box. + sums: Vec, +} + +impl Reduced { + /// A reduction of a `width × height` composite to at most `long_edge` + /// pixels on its longer side, by a whole factor. + pub fn new(width: u32, height: u32, long_edge: u32) -> Self { + let factor = width.max(height).div_ceil(long_edge.max(1)).max(1); + let (w, h) = (width.div_ceil(factor), height.div_ceil(factor)); + Reduced { + width, + height, + factor, + sums: vec![0; w as usize * h as usize * 3], + } + } + + /// The reduced size. + pub fn size(&self) -> (u32, u32) { + ( + self.width.div_ceil(self.factor), + self.height.div_ceil(self.factor), + ) + } + + /// The whole factor each side is divided by. + pub fn factor(&self) -> u32 { + self.factor + } + + /// Fold in `rows` rows of the composite starting at `first_row`, + /// interleaved RGB at the full width. + pub fn push(&mut self, first_row: u32, rows: u32, rgb: &[u16]) { + let (rw, _) = self.size(); + let (w, f) = (self.width as usize, self.factor as usize); + for r in 0..rows as usize { + let y = first_row as usize + r; + if y >= self.height as usize { + break; + } + let row = &rgb[r * w * 3..(r + 1) * w * 3]; + let out = (y / f) * rw as usize * 3; + for (x, px) in row.chunks_exact(3).enumerate() { + let o = out + (x / f) * 3; + self.sums[o] += u32::from(px[0]); + self.sums[o + 1] += u32::from(px[1]); + self.sums[o + 2] += u32::from(px[2]); + } + } + } + + /// The averages, as the DNG's `u16` samples. A box cut short by the + /// edge of the composite is the mean of what it holds. + pub fn finish(self) -> (u32, u32, Vec) { + let (rw, rh) = self.size(); + let f = self.factor; + let mut out = vec![0u16; self.sums.len()]; + for y in 0..rh { + let bh = f.min(self.height - y * f); + for x in 0..rw { + let bw = f.min(self.width - x * f); + let n = bh * bw; + let i = (y * rw + x) as usize * 3; + for c in 0..3 { + out[i + c] = ((self.sums[i + c] + n / 2) / n) as u16; + } + } + } + (rw, rh, out) + } +} + +/// Which size classes a composite of this shape is thumbnailed at. +pub fn classes(width: u32, height: u32) -> Vec { + let _ = (width, height); + vec![ThumbSize::Grid, ThumbSize::Large] +} + +/// Develop the reduced composite as develop would open the file, and render +/// it at each of `classes`. +/// +/// `crop` is the rectangle the file opens on, in the composite's pixels; +/// it is carried to the reduced copy so the thumbnail is of the picture +/// and not of the border around it. +pub fn render( + ctx: &dr_gpu::GpuContext, + reduced: Reduced, + profile: &dr_export::DngProfile, + header: &dr_export::SourceMetadata, + crop: Option, + decoder: &dyn dr_decode::Decoder, + classes: &[ThumbSize], +) -> Result, String> { + let f = reduced.factor(); + let (w, h, samples) = reduced.finish(); + let crop = crop.map(|r| { + let x = (r.x / f).min(w.saturating_sub(1)); + let y = (r.y / f).min(h.saturating_sub(1)); + dr_export::Rect { + x, + y, + width: (r.width / f).clamp(1, w - x), + height: (r.height / f).clamp(1, h - y), + } + }); + let mut file = Cursor::new(Vec::new()); + let mut once = Some(samples); + dr_export::write_linear_dng( + &mut file, + w, + h, + h, + profile, + Some(header), + |_, buf| { + buf.extend_from_slice(&once.take().unwrap_or_default()); + Ok(()) + }, + || crop, + ) + .map_err(|e| e.to_string())?; + let bytes = file.into_inner(); + + // The file as develop reads it on first open: its header, then the + // pixels, through the one function every develop session starts from. + let meta = decoder.metadata(&bytes).map_err(|e| e.to_string())?; + let mut session = crate::open_session(ctx, decoder, &bytes, &meta)?; + let mut out = Vec::with_capacity(classes.len()); + for &class in classes { + let (tw, th, rgba) = session.render_thumbnail(class.edge())?; + let jpeg = dr_thumbs::encode_rgba(tw, th, &rgba).map_err(|e| e.to_string())?; + out.push(( + class, + Thumbnail { + width: tw, + height: th, + bytes: jpeg, + }, + )); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reduction_averages_its_boxes_and_the_ragged_edge() { + // 5 × 3 by 2: boxes of 2 × 2, then 1 × 2 at the right, 2 × 1 and + // 1 × 1 along the bottom. + let mut r = Reduced::new(5, 3, 3); + assert_eq!(r.factor(), 2); + assert_eq!(r.size(), (3, 2)); + let px = |x: u16, y: u16| [x * 10 + y, 0, 1000]; + let rows: Vec = (0..3) + .flat_map(|y| (0..5).flat_map(move |x| px(x, y))) + .collect(); + // In two bands, as the merge hands them over. + r.push(0, 2, &rows[..5 * 2 * 3]); + r.push(2, 1, &rows[5 * 2 * 3..]); + let (w, h, out) = r.finish(); + assert_eq!((w, h), (3, 2)); + let at = |x: usize, y: usize| out[(y * 3 + x) * 3]; + // (0,0),(1,0),(0,1),(1,1) → 0, 10, 1, 11: mean 5.5, rounded. + assert_eq!(at(0, 0), 6); + // The right-hand column is one pixel wide: (4,0) and (4,1). + assert_eq!(at(2, 0), (40 + 41 + 1) / 2); + // The bottom-right is a single pixel. + assert_eq!(at(2, 1), 42); + assert!(out.chunks_exact(3).all(|p| p[2] == 1000)); + } + + /// A synthetic composite in camera space: a sky that brightens to a + /// clipped band, green ground with texture, and a blown sun. + fn composite(width: u32, height: u32, white: u32) -> Vec { + let mut out = Vec::with_capacity((width * height * 3) as usize); + for y in 0..height { + for x in 0..width { + let fy = y as f32 / height as f32; + let fx = x as f32 / width as f32; + let tex = ((x / 7 + y / 5) % 2) as f32 * 0.03; + let cam = if fy < 0.45 { + // Sky: blue-ish in camera space, brighter toward the top + // and toward the right, clipping in the top corner. + let b = (0.25 + 0.9 * (1.0 - fy / 0.45) * fx).min(1.2); + [b * 0.45, b * 0.8, b] + } else { + let g = 0.08 + 0.25 * fx + tex; + [g * 0.6, g, g * 0.4] + }; + let sun = ((fx - 0.8).powi(2) + (fy - 0.2).powi(2)).sqrt() < 0.04; + let cam = if sun { [1.0, 1.0, 1.0] } else { cam }; + for c in cam { + out.push(((c * white as f32).round() as u32).min(white) as u16); + } + } + } + out + } + + fn profile(white: u32) -> dr_export::DngProfile { + // A Canon 6D's D65 matrix and as-shot neutral, as the fixture's + // composite carries them. + dr_export::DngProfile { + unique_model: "Canon EOS 6D".into(), + calibrations: vec![( + 21, + [ + [0.7034, -0.0804, -0.1014], + [-0.4420, 1.2564, 0.2058], + [-0.0851, 0.1994, 0.5758], + ], + )], + as_shot_neutral: [0.4985, 1.0, 0.6505], + white_level: white, + } + } + + /// Luma, in 0..255, for each pixel of an RGBA buffer. + fn luma(rgba: &[u8]) -> Vec { + rgba.chunks_exact(4) + .map(|p| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) + .collect() + } + + fn stats(mut l: Vec) -> (f32, f32, f32) { + let mean = l.iter().sum::() / l.len() as f32; + l.sort_by(f32::total_cmp); + let at = |q: f32| l[((l.len() - 1) as f32 * q).round() as usize]; + (mean, at(0.95), at(0.995)) + } + + /// FR-MRG-6's acceptance: the thumbnail made during the merge is the + /// picture develop shows on first opening the composite — its overall + /// level and its highlights — and not a naive conversion of the linear + /// data, which comes out a stop and more away on both. + #[test] + fn the_thumbnail_is_what_develop_shows_on_first_open() { + let Some(ctx) = crate::develop::test_support::headless() else { + eprintln!("no GPU; skipping"); + return; + }; + let (width, height, white) = (3000u32, 760u32, 13_023u32); + let pixels = composite(width, height, white); + let profile = profile(white); + let header = dr_export::SourceMetadata { + make: Some("Canon".into()), + model: Some("Canon EOS 6D".into()), + captured_at: Some(1_600_000_000), + ..Default::default() + }; + let crop = dr_export::Rect { + x: 40, + y: 30, + width: 2900, + height: 700, + }; + + // Develop's side: the whole composite written as the merge writes + // it, opened as develop opens it, rendered for the grid. + let rows_per_strip = 256u32; + let mut file = Cursor::new(Vec::new()); + dr_export::write_linear_dng( + &mut file, + width, + height, + rows_per_strip, + &profile, + Some(&header), + |k, buf| { + let from = k * (rows_per_strip * width * 3) as usize; + let to = (from + (rows_per_strip * width * 3) as usize).min(pixels.len()); + buf.extend_from_slice(&pixels[from..to]); + Ok(()) + }, + || Some(crop), + ) + .unwrap(); + let bytes = file.into_inner(); + let decoder = dr_decode::default(); + let meta = decoder.metadata(&bytes).unwrap(); + let mut developed = crate::open_session(&ctx, decoder, &bytes, &meta).unwrap(); + + // The merge's side: the same bands, reduced as they pass. + let mut reduced = Reduced::new(width, height, 1500); + for first in (0..height).step_by(rows_per_strip as usize) { + let rows = rows_per_strip.min(height - first); + let from = (first * width * 3) as usize; + reduced.push( + first, + rows, + &pixels[from..from + (rows * width * 3) as usize], + ); + } + let thumbs = render( + &ctx, + reduced, + &profile, + &header, + Some(crop), + decoder, + &[ThumbSize::Grid, ThumbSize::Large], + ) + .unwrap(); + + // The naive picture: the linear camera values balanced and gamma + // encoded, with no matrix, no curve and no highlight handling — what + // the merge's preview draws. + let naive = { + let mut l = Vec::new(); + for y in (crop.y..crop.y + crop.height).step_by(4) { + for x in (crop.x..crop.x + crop.width).step_by(4) { + let i = ((y * width + x) * 3) as usize; + let v = |c: usize| { + let lin = pixels[i + c] as f32 / white as f32 / profile.as_shot_neutral[c]; + lin.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0 + }; + l.push(0.2126 * v(0) + 0.7152 * v(1) + 0.0722 * v(2)); + } + } + stats(l) + }; + + for (class, thumb) in thumbs { + let (tw, th, ours) = dr_thumbs::decode_rgba(&thumb.bytes).unwrap(); + let (dw, dh, theirs) = developed.render_thumbnail(class.edge()).unwrap(); + // Through the store's encoding too: both are JPEGs in the store, + // and it is the encoding, not the development, that rounds a + // blown sun off to 247. + let theirs = { + let jpeg = dr_thumbs::encode_rgba(dw, dh, &theirs).unwrap(); + dr_thumbs::decode_rgba(&jpeg).unwrap().2 + }; + // The picture, not the border: the crop's shape at the class's + // size, give or take the reduction's rounding. + assert!( + (tw as i64 - dw as i64).abs() <= 2 && (th as i64 - dh as i64).abs() <= 2, + "{class:?}: {tw}×{th} against develop's {dw}×{dh}" + ); + let (m0, p95_0, p995_0) = stats(luma(&ours)); + let (m1, p95_1, p995_1) = stats(luma(&theirs)); + eprintln!( + "{class:?}: mean {m0:.1}/{m1:.1}, p95 {p95_0:.1}/{p95_1:.1}, \ + p99.5 {p995_0:.1}/{p995_1:.1}; naive {naive:?}" + ); + assert!((m0 - m1).abs() <= 3.0, "{class:?} mean {m0} vs {m1}"); + // And the tolerance means something: the conversion this + // replaces is nowhere near it. + assert!( + (naive.0 - m1).abs() > 12.0 || (naive.1 - p95_1).abs() > 12.0, + "a naive conversion ({naive:?}) would pass as well" + ); + assert!( + (p95_0 - p95_1).abs() <= 4.0, + "{class:?} p95 {p95_0} vs {p95_1}" + ); + assert!( + (p995_0 - p995_1).abs() <= 4.0, + "{class:?} p99.5 {p995_0} vs {p995_1}" + ); + } + } +}