diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 6b297b94f..e231c47c5 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -188,13 +188,18 @@ impl WorkerCore { } }; let info = src.info_or(job.info()); - Ok(match &job.stages { + let mut rendered = match &job.stages { Some(view) => { let st = self.stages.entry(view.clone()).or_default().clone(); lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st) } None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()), - }) + }; + // an HDR edit's histogram shows its HDR range (as on native) + if let Some(h) = lightcraft_engine::pipeline::hdr_histogram(&src.image, &info, &job.settings, &job.request()) { + rendered.histogram = h; + } + Ok(rendered) } } diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 8059b1d62..f1523c377 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -127,11 +127,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option { let rgba = Uint8Array::from(r.image.as_bytes().as_ref()); let h = &r.histogram; - let hist = Uint32Array::new_with_length(4 * 256 + 1); - for (i, ch) in [&h.r, &h.g, &h.b, &h.luma].into_iter().enumerate() { - hist.subarray((i * 256) as u32, ((i + 1) * 256) as u32).copy_from(ch); - } - hist.set_index(1024, h.total); + let hist = Uint32Array::from(h.to_words().as_slice()); set(&out, "ok", &true.into()); set(&out, "w", &(r.image.width as u32).into()); set(&out, "h", &(r.image.height as u32).into()); @@ -326,12 +322,8 @@ impl Workers { let (wd, ht) = (get(&data, "w").as_f64().unwrap_or(0.0) as usize, get(&data, "h").as_f64().unwrap_or(0.0) as usize); let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec(); let hist = Uint32Array::new(&get(&data, "hist")).to_vec(); - match Rgba8::from_bytes(wd, ht, &rgba) { - Some(image) if hist.len() == 1025 => { - let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec(); - let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] }; - Ok(Rendered { image, histogram, deep: None }) - } + match (Rgba8::from_bytes(wd, ht, &rgba), Histogram::from_words(&hist)) { + (Some(image), Some(histogram)) => Ok(Rendered { image, histogram, deep: None }), _ => Err("render worker: bad image".to_string()), } } else { diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index ef569960f..6f6f0359c 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -846,7 +846,8 @@ impl RenderJob { (Some(d), Some(_)) => { let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); - if let Some(h) = h { + // (an HDR edit's HDR histogram stays) + if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) { rendered.histogram = h; } srgb = s; @@ -909,6 +910,22 @@ pub fn develop_reporting( req: &RenderRequest, stages: Option<&StageCache>, gpu: bool, +) -> (Rendered, Option) { + let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu); + // an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead + if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) { + rendered.histogram = h; + } + (rendered, fallback) +} + +fn develop_image( + src: &Arc, + info: &SourceInfo, + s: &DevelopSettings, + req: &RenderRequest, + stages: Option<&StageCache>, + gpu: bool, ) -> (Rendered, Option) { // LUT profiles have no GPU stage: they render on the CPU let fallback = if gpu diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ee3d17380..6aad89b31 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1163,3 +1163,18 @@ fn undo_and_redo_show_the_photo_they_change() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.selection, before, "a step over several photos keeps the selection"); } + +#[test] +fn hdr_edits_render_with_their_hdr_histogram() { + let mut s = demo(); + let id = s.active().unwrap(); + let plain = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap(); + assert!(plain.histogram.hdr.is_none(), "SDR edits: the image's histogram"); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap(); + let r = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap(); + let bins = r.histogram.hdr.expect("an HDR edit's histogram"); + assert_eq!(bins.stops, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32, "a fixed scale"); + assert!(r.histogram.above_sdr() > 0.0); + assert!(r.deep.is_none(), "the preview itself stays the 8-bit SDR rendition"); +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 88e39778d..6fbb156e8 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -468,6 +468,37 @@ pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::bor if req.depth == OutputDepth::F32Hdr { std::borrow::Cow::Borrowed(s) } else { s.sdr_rendition() } } +/// Long edge (pixels) of the small HDR render [`hdr_histogram`] measures. +pub const HDR_HISTOGRAM_EDGE: usize = 384; + +/// The histogram of an HDR edit's HDR rendition for a preview request `req`: a small float HDR +/// render of the same framing ([`HDR_HISTOGRAM_EDGE`]), binned with SDR on the left half and the +/// whole HDR range ([`Hdr::MAX_EV_LIMIT`](lightcraft_develop::Hdr::MAX_EV_LIMIT) stops above SDR +/// white, whatever the headroom limit, so the scale stays put) on the right ([`Histogram::of_hdr`]). A preview of +/// an HDR edit shows its SDR rendition; this says how far its highlights go. `None` for SDR edits, +/// high-bit-depth, windowed and proofed requests (their histogram stays the image's). +pub fn hdr_histogram(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Option { + // a drag's drafts keep their own histogram: a second render per tick would slow them down + if !s.hdr.enabled || req.depth != OutputDepth::U8 || req.window.is_some() || req.proof.is_some() || req.quality == Quality::Draft { + return None; + } + let k = (HDR_HISTOGRAM_EDGE as f64 / req.max_w.max(req.max_h).max(1) as f64).min(1.0); + let size = |v: usize| ((v as f64 * k).round() as usize).max(1); + let hreq = RenderRequest { + max_w: size(req.max_w), + max_h: size(req.max_h), + depth: OutputDepth::F32Hdr, + overlay: Overlay::None, + proof: None, + window: None, + display: None, + ..*req + }; + let deep = render(src, info, s, &hreq).deep?; + let DeepSamples::F32(v) = &deep.samples else { return None }; + Some(Histogram::of_hdr(v.as_chunks::<3>().0, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32)) +} + /// Render `src` with settings `s`. pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered { render_impl(Src::Borrowed(src), info, s, req, None) diff --git a/crates/pipeline/src/tests_hdr.rs b/crates/pipeline/src/tests_hdr.rs index 158019a4c..68c319b68 100644 --- a/crates/pipeline/src/tests_hdr.rs +++ b/crates/pipeline/src/tests_hdr.rs @@ -137,3 +137,36 @@ fn visualize_hdr_paints_only_what_rises_above_white() { let plain = render(&src, &raw(), &DevelopSettings::default(), &RenderRequest::fit(240, 240)); assert_eq!(off.image.data, plain.image.data); } + +#[test] +fn hdr_histogram_shows_the_range_above_sdr_white_on_a_fixed_scale() { + let src = sunset(); + let req = RenderRequest::fit(240, 240); + // SDR edits, and requests the image's own histogram describes, have none + assert!(crate::hdr_histogram(&src, &raw(), &DevelopSettings::default(), &req).is_none()); + let mut s = hdr_on(3.0); + s.light.exposure = 1.0; + for other in [ + RenderRequest { depth: OutputDepth::U16, ..req }, + RenderRequest { window: Some(crate::PixelWindow { x: 0, y: 0, w: 8, h: 8 }), ..req }, + RenderRequest { proof: Some(crate::Proof::default()), ..req }, + ] { + assert!(crate::hdr_histogram(&src, &raw(), &s, &other).is_none()); + } + let h = crate::hdr_histogram(&src, &raw(), &s, &req).expect("an HDR edit's histogram"); + let bins = h.hdr.expect("HDR bins"); + assert_eq!(bins.stops, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32, "the whole HDR range"); + assert!(h.above_sdr() > 0.0, "highlights above SDR white: {}", h.above_sdr()); + assert!(h.total > 0); + // the scale doesn't follow the headroom limit (a damaged one included); the content does + let highest = |h: &lightcraft_raster::Histogram| h.luma.iter().rposition(|&n| n > 0).unwrap_or(0); + let top3 = highest(&h); + s.hdr.max_ev = 1.5; + let h15 = crate::hdr_histogram(&src, &raw(), &s, &req).unwrap(); + assert_eq!(h15.hdr.unwrap().stops, bins.stops); + assert!(highest(&h15) < top3, "a lower limit pulls the highlights down: {} vs {top3}", highest(&h15)); + s.hdr.max_ev = f64::NAN; + assert_eq!(crate::hdr_histogram(&src, &raw(), &s, &req).unwrap().hdr.unwrap().stops, bins.stops); + // the SDR rendition's own histogram has nothing above white + assert!(render(&src, &raw(), &hdr_on(3.0), &req).histogram.hdr.is_none()); +} diff --git a/crates/raster/src/histogram.rs b/crates/raster/src/histogram.rs index 300fa273b..75667bd1c 100644 --- a/crates/raster/src/histogram.rs +++ b/crates/raster/src/histogram.rs @@ -1,4 +1,8 @@ //! Histograms of display-encoded images (what the Histogram panel shows). +//! +//! An HDR histogram ([`Histogram::of_hdr`]) keeps the same 256 bins: the first +//! [`Histogram::hdr_from`] cover SDR black to SDR white (sRGB-encoded, as an SDR histogram), the +//! rest cover the HDR range above SDR white in equal stops. use serde::Serialize; @@ -11,13 +15,122 @@ pub struct Histogram { pub b: Vec, pub luma: Vec, pub total: u32, + /// HDR histograms: the first bin above SDR white, and the stops above SDR white the last bin + /// reaches. `None` for SDR histograms (every bin is SDR). + #[serde(skip_serializing_if = "Option::is_none")] + pub hdr: Option, +} + +/// The HDR part of a [`Histogram`]: bins `from..256` cover `0..stops` stops above SDR white. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HdrBins { + pub from: usize, + pub stops: f32, +} + +impl HdrBins { + /// The bin of a linear value (1 = SDR white; `NaN` and negatives count as black). + pub fn bin(self, v: f32) -> usize { + let last = Histogram::BINS - 1; + // a split outside 2..=last would leave one side without bins (or underflow) + let from = self.from.clamp(2, last); + if v.is_nan() || v <= 0.0 { + return 0; + } + if v <= 1.0 { + let e = lightcraft_color::transfer::linear_to_srgb(v); + return ((e * (from - 1) as f32 + 0.5) as usize).min(from - 1); + } + let stops = if self.stops.is_finite() { self.stops.max(1e-3) } else { 1e-3 }; + let t = (v.log2() / stops).clamp(0.0, 1.0); + (from + (t * (last - from) as f32 + 0.5) as usize).min(last) + } } impl Histogram { pub const BINS: usize = 256; + /// HDR histograms: the first bin above SDR white (SDR takes the left half). + pub const HDR_FROM: usize = Self::BINS / 2; + + fn empty(hdr: Option) -> Histogram { + Histogram { r: vec![0; 256], g: vec![0; 256], b: vec![0; 256], luma: vec![0; 256], total: 0, hdr } + } + + /// Histogram of linear RGB samples (1 = SDR white) reaching `stops` stops above SDR white. + /// `samples` are visited at most ~1 MP of them (evenly strided). + pub fn of_hdr(samples: &[[f32; 3]], stops: f32) -> Histogram { + let bins = HdrBins { from: Self::HDR_FROM, stops }; + let mut h = Self::empty(Some(bins)); + let step = (samples.len() / 1_000_000).max(1); + for c in samples.iter().step_by(step) { + h.r[bins.bin(c[0])] += 1; + h.g[bins.bin(c[1])] += 1; + h.b[bins.bin(c[2])] += 1; + h.luma[bins.bin(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2])] += 1; + h.total += 1; + } + h + } + + /// HDR histogram of per-pixel bins already computed with `bins` (`[r, g, b, luma]` each), + /// visiting at most ~1 MP of them. + pub fn of_bins(px: impl ExactSizeIterator, bins: HdrBins) -> Histogram { + let mut h = Self::empty(Some(bins)); + let step = (px.len() / 1_000_000).max(1); + for [r, g, b, l] in px.step_by(step) { + h.r[r as usize] += 1; + h.g[g as usize] += 1; + h.b[b as usize] += 1; + h.luma[l as usize] += 1; + h.total += 1; + } + h + } + + /// The histogram as flat words (to cross a worker boundary): r, g, b, luma (256 each), the + /// total, then the first HDR bin (0: SDR) and the HDR stops as `f32` bits. + pub fn to_words(&self) -> Vec { + let mut w = Vec::with_capacity(4 * Self::BINS + 3); + for ch in [&self.r, &self.g, &self.b, &self.luma] { + w.extend(ch.iter().copied().chain(std::iter::repeat(0)).take(Self::BINS)); + } + w.push(self.total); + let (from, stops) = self.hdr.map_or((0, 0.0), |b| (b.from as u32, b.stops)); + w.extend([from, stops.to_bits()]); + w + } + + /// [`Histogram::to_words`] back; also takes the older layout without the HDR words. `None` + /// for anything malformed. + pub fn from_words(w: &[u32]) -> Option { + let n = Self::BINS; + if w.len() != 4 * n + 1 && w.len() != 4 * n + 3 { + return None; + } + let ch = |k: usize| w.get(k * n..(k + 1) * n).map(<[u32]>::to_vec); + let hdr = match (w.get(4 * n + 1), w.get(4 * n + 2)) { + (Some(&from), Some(&bits)) if from > 1 && (from as usize) < n => { + let stops = f32::from_bits(bits); + Some(HdrBins { from: from as usize, stops: if stops.is_finite() && stops > 0.0 { stops } else { 1.0 } }) + } + _ => None, + }; + Some(Histogram { r: ch(0)?, g: ch(1)?, b: ch(2)?, luma: ch(3)?, total: *w.get(4 * n)?, hdr }) + } + + /// Fraction of pixels above SDR white in any channel (0 for SDR histograms). + pub fn above_sdr(&self) -> f32 { + let Some(b) = self.hdr else { return 0.0 }; + if self.total == 0 { + return 0.0; + } + let over = |v: &[u32]| v.iter().skip(b.from).sum::(); + over(&self.r).max(over(&self.g)).max(over(&self.b)) as f32 / self.total as f32 + } pub fn of_srgb8(img: &Rgba8) -> Histogram { - let mut h = Histogram { r: vec![0; 256], g: vec![0; 256], b: vec![0; 256], luma: vec![0; 256], total: 0 }; + let mut h = Self::empty(None); // Sample at most ~1 MP for speed. let step = ((img.len() as f64 / 1_000_000.0).sqrt().ceil() as usize).max(1); for y in (0..img.height).step_by(step) { @@ -83,5 +196,53 @@ mod tests { assert!((h.mean_luma() - 0.5).abs() < 1e-3); assert_eq!(h.percentile(0.25), 0.0); assert_eq!(h.percentile(0.75), 1.0); + assert_eq!(h.above_sdr(), 0.0); + } + + #[test] + fn hdr_bins_split_sdr_and_stops() { + let px = [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [4.0, 4.0, 4.0], [16.0, 16.0, 16.0], [f32::NAN, -1.0, f32::INFINITY]]; + let h = Histogram::of_hdr(&px, 4.0); + let b = h.hdr.unwrap(); + assert_eq!(h.total, 5); + assert_eq!(b.bin(1.0), b.from - 1, "SDR white is the last SDR bin"); + assert_eq!(b.bin(16.0), 255); + assert_eq!(b.bin(1e9), 255); + let two = b.bin(4.0); + assert!(two > b.from && two < 255, "{two}"); + assert_eq!(h.r[0], 2, "black and NaN"); + assert_eq!(h.b[255], 2, "16 and +inf"); + assert!((h.above_sdr() - 0.6).abs() < 1e-6, "{}", h.above_sdr()); + assert_eq!(Histogram::of_hdr(&[], 4.0).above_sdr(), 0.0); + } + + #[test] + fn words_round_trip_and_reject_garbage() { + let img = Rgba8::from_fn(8, 8, |x, y| [(x * 30) as u8, (y * 30) as u8, 9, 255]); + let sdr = Histogram::of_srgb8(&img); + assert_eq!(Histogram::from_words(&sdr.to_words()), Some(sdr.clone())); + let hdr = Histogram::of_hdr(&[[0.5, 2.0, 9.0]], 4.0); + assert_eq!(Histogram::from_words(&hdr.to_words()), Some(hdr)); + // the older layout (no HDR words) is an SDR histogram + assert_eq!(Histogram::from_words(&sdr.to_words()[..1025]), Some(sdr.clone())); + assert_eq!(Histogram::from_words(&[1, 2, 3]), None); + let mut bad = sdr.to_words(); + bad[1025] = 9999; // first HDR bin out of range: SDR + bad[1026] = f32::NAN.to_bits(); + assert_eq!(Histogram::from_words(&bad).unwrap().hdr, None); + bad[1025] = 100; // NaN stops fall back to something drawable + assert_eq!(Histogram::from_words(&bad).unwrap().hdr.unwrap().stops, 1.0); + } + /// A hand-built split outside the bins (any `from`, any `stops`) never panics and stays in range. + #[test] + fn hdr_bins_with_any_split_stay_in_range() { + for from in [0, 1, 2, 128, 255, 256, usize::MAX] { + for stops in [0.0, 3.0, f32::NAN, f32::INFINITY] { + let b = HdrBins { from, stops }; + for v in [f32::NAN, -1.0, 0.0, 0.5, 1.0, 2.0, 1e9] { + assert!(b.bin(v) < Histogram::BINS, "{from} {stops} {v}"); + } + } + } } } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 25571128b..41ae75af8 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -12,7 +12,7 @@ pub mod blur; pub mod histogram; pub mod resample; -pub use histogram::Histogram; +pub use histogram::{HdrBins, Histogram}; /// Run `f` over row chunks, in parallel when the `parallel` feature is on. pub fn par_rows(data: &mut [T], row_len: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index f6c4424bf..ed2ee4635 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -524,11 +524,6 @@ fn section( fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); - let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0), Sense::click()); - register(ui.ctx(), "histogram", r); - let p = ui.painter(); - p.rect_filled(r, 0.0, t.button); - let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0)); // the loupe render of this photo, else its thumbnail (grid view before the loupe has rendered it) let hist = app .renderer @@ -537,6 +532,39 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { .filter(|t| t.photo == id) .and_then(|t| t.histogram.clone()) .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())); + // an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label + // row under the plot + let hdr = hist.as_ref().and_then(|h| h.hdr); + let legend = if hdr.is_some() { 26.0 } else { 0.0 }; + let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click()); + register(ui.ctx(), "histogram", r); + let p = ui.painter(); + p.rect_filled(r, 0.0, t.button); + let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0 - legend)); + if let Some(b) = hdr { + // white for SDR, then one band per stop above SDR white: white too, or (Visualize HDR + // range on) in its colours + let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; + let bar = |a: f32, z: f32, c: Color32| p.rect_filled(Rect::from_min_max(pos2(a, plot.bottom() + 2.0), pos2(z, plot.bottom() + 7.0)), 0.0, c); + let white = Color32::from_rgb(228, 228, 230); + bar(plot.left(), x0 - 1.0, white); + let stops = b.stops.round().max(1.0) as usize; + let bands = lightcraft_pipeline::visualize::HDR_BANDS; + let colours = app.ui.hdr_visualize; + for k in 0..stops { + let c = if colours { bands.get(k).or(bands.last()).copied().unwrap_or([255, 255, 255]) } else { [228, 228, 230] }; + let (a, z) = (x0 + (plot.right() - x0) * k as f32 / stops as f32, x0 + (plot.right() - x0) * (k + 1) as f32 / stops as f32); + bar(a + 1.0, z - 1.0, Color32::from_rgb(c[0], c[1], c[2])); + } + let y = plot.bottom() + 18.0; + p.text(pos2((plot.left() + x0) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("SDR"), t.font(12.5), t.text_label); + p.text(pos2((x0 + plot.right()) / 2.0, y), Align2::CENTER_CENTER, crate::i18n::tr("HDR"), t.font(12.5), t.text_label); + let info = Rect::from_center_size(pos2(plot.right() - 16.0, y), vec2(14.0, 14.0)); + paint(p, info, Icon::Info, t.text_label); + ui.interact(info, egui::Id::new("hdrHistogramInfo"), Sense::hover()).on_hover_text(crate::i18n::tr( + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.", + )); + } if let Some(h) = hist { let smooth = |v: &[u32]| -> Vec { (0..256) @@ -546,6 +574,27 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }) .collect() }; + // HDR: SDR on the left half, a solid line at SDR white and dashed ones between the stops + if let Some(b) = h.hdr { + let x0 = plot.left() + plot.width() * b.from as f32 / 255.0; + let stops = b.stops.round().max(1.0) as usize; + for k in 1..stops { + let x = x0 + (plot.right() - x0) * k as f32 / stops as f32; + let mut y = plot.top(); + while y < plot.bottom() { + p.line_segment([pos2(x, y), pos2(x, (y + 5.0).min(plot.bottom()))], Stroke::new(1.0, Color32::from_gray(150))); + y += 9.0; + } + } + p.line_segment([pos2(x0, plot.top()), pos2(x0, plot.bottom())], Stroke::new(1.5, Color32::from_gray(160))); + // the photo's headroom limit on the fixed scale: marked, the range past it dimmed + let limit = app.session.develop_of(id).map_or(b.stops, |d| d.hdr.max_ev as f32); + if limit.is_finite() && limit >= 0.0 && limit < b.stops - 0.05 { + let x = x0 + (plot.right() - x0) * (limit / b.stops).clamp(0.0, 1.0); + p.rect_filled(Rect::from_min_max(pos2(x, plot.top()), plot.max), 0.0, Color32::from_black_alpha(70)); + p.line_segment([pos2(x, plot.top()), pos2(x, plot.bottom())], Stroke::new(1.0, Color32::from_rgb(230, 150, 60))); + } + } let chans = [(smooth(&h.r), hex("#df3939")), (smooth(&h.g), hex("#44b072")), (smooth(&h.b), hex("#3b6fe0"))]; let peak = chans.iter().flat_map(|(v, _)| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b)); let to = |i: usize, v: f32| pos2(plot.left() + plot.width() * i as f32 / 255.0, plot.bottom() - plot.height() * (v / peak).sqrt().min(1.0)); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 26a8f3964..6e3679685 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -138,7 +138,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | -| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs` | no drag-to-adjust on the histogram | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram | | LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | | ## D. Search & filter (FILT) @@ -365,7 +365,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-HDR-EDIT | HDR editing | P2 | 🟡 | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); `crs:HDREditMode` from XMP / Lightroom catalogs turns HDR editing on or off (the headroom limit and SDR rendition aren't read from crs yet) | | LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands | | LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it | -| LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops | +| LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops; marked on the HDR histogram (whose scale stays at 5 stops) | | LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 PQ AVIF, 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HLG yet; no HEIC (no permissive HEVC encoder) | ## R. Video (VID)