diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 6fbb156e8..27d5d0543 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -471,6 +471,10 @@ pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::bor /// Long edge (pixels) of the small HDR render [`hdr_histogram`] measures. pub const HDR_HISTOGRAM_EDGE: usize = 384; +/// Long edge of the render that gives a slider drag its live HDR histogram (drafts have none: a +/// second render per tick would slow them down), run beside the drafts by the UI. +pub const HDR_HISTOGRAM_DRAG_EDGE: usize = 192; + /// 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 diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index d5e8f0159..76a54171f 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -6,11 +6,13 @@ use egui::{Align2, Color32, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; use lightcraft_catalog::PhotoId; use lightcraft_develop::{ControlSpec, DevelopSettings, Section, Track, WbMode, controls}; use lightcraft_geom::Point; +use lightcraft_raster::Histogram; use serde_json::{Value, json}; use crate::LightcraftApp; use crate::icons::{Icon, paint}; use crate::render::Slot; +use crate::state::RightPanel; use crate::theme::Tokens; use crate::widgets::{BAND_COLORS, SliderOut, divider, flyout_row, hex, register, section_header, slider, text_button}; @@ -549,11 +551,42 @@ fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option) -> Option { + let key = egui::Id::new(("hdrHistogram", id)); + match hist { + Some(h) if h.hdr.is_some() => { + ctx.data_mut(|d| d.insert_temp(key, h.clone())); + Some(h) + } + h if hdr_edit => ctx.data(|d| d.get_temp::(key)).or(h), + h => h, + } +} + fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); - // the loupe render of this photo, else its thumbnail - let hist = preview_histogram(app, id); + let hdr_edit = app.session.develop_of(id).is_some_and(|d| d.hdr.enabled); + // a drag of an HDR edit: its drafts have no HDR histogram, so a small render beside them + // gives one (at its own pace: the drafts don't wait for it) + let interacting = app.session.interaction.is_some(); + if interacting && hdr_edit { + let edge = lightcraft_pipeline::HDR_HISTOGRAM_DRAG_EDGE; + if let Some(job) = app.session.render_job(id, edge, edge, false, app.ui.right != RightPanel::Crop) + && app.renderer.live_hdr_histogram.as_ref().is_none_or(|l| l.1 != job.key) + { + app.renderer.request(Slot::HdrHistogram, job, 95); + } + } + let live = app.renderer.live_hdr_histogram.as_ref().filter(|l| interacting && l.0 == id).map(|l| l.2.clone()); + // else the loupe render of this photo, else its thumbnail (grid view before the loupe has + // rendered it) + let hist = live.or_else(|| preview_histogram(app, id)); + let hist = steady_hdr_histogram(ui.ctx(), id, hdr_edit, hist); // 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); @@ -1664,3 +1697,29 @@ mod curve_histogram_tests { assert!(values.iter().any(|v| *v > 0.0)); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() { + let ctx = egui::Context::default(); + let id = PhotoId(7); + let sdr = Histogram::of_hdr(&[], 5.0); + let sdr = Histogram { hdr: None, ..sdr }; + let hdr = |v: f32| Histogram::of_hdr(&[[v, v, v]], 5.0); + // nothing kept yet: what arrives + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(2.0))), Some(hdr(2.0))); + // a cached thumbnail's 8-bit histogram (or none) doesn't swap the panel to SDR + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(2.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, None), Some(hdr(2.0))); + // the next HDR render replaces it: the histogram follows the slider + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(4.0))), Some(hdr(4.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(4.0))); + // HDR turned off: the SDR histogram at once; other photos keep their own + assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); + } +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index beeb5557e..fd0f0e9bf 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -55,6 +55,9 @@ pub enum Slot { Import(u32), /// The second window's view of the active photo. Second, + /// A slider drag's live HDR histogram: a small full-quality render of an HDR edit, beside the + /// drafts (which have no HDR histogram), with no texture ([`Renderer::live_hdr_histogram`]). + HdrHistogram, } pub struct Tex { @@ -187,6 +190,8 @@ pub struct Renderer { /// Slot → (key, priority) of the request in flight (queued or running). pending: HashMap, pub textures: HashMap, + /// The last [`Slot::HdrHistogram`] result: (photo, job key, histogram). + pub live_hdr_histogram: Option<(PhotoId, u64, Histogram)>, pub last_main_ms: f64, /// Jobs finished since start (for inspect/perf). pub completed: u64, @@ -232,7 +237,7 @@ impl Slot { /// An interactive view of the open photo: slider drags redo only the stages they feed, and a /// draft that finishes late still replaces older pixels. pub fn is_view(self) -> bool { - matches!(self, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before | Slot::Hover) + matches!(self, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before | Slot::Hover | Slot::HdrHistogram) } } @@ -263,6 +268,7 @@ impl Renderer { seq: 0, pending: HashMap::new(), textures: HashMap::new(), + live_hdr_histogram: None, last_main_ms: 0.0, completed: 0, keep_pixels: false, @@ -752,6 +758,13 @@ impl Renderer { if matches!(slot, Slot::Prefetch(_)) { continue; } + if slot == Slot::HdrHistogram { + if rendered.histogram.hdr.is_some() { + self.live_hdr_histogram = Some((r.photo, r.key, rendered.histogram)); + changed = true; + } + continue; + } if let Slot::ThumbQuick(id) = slot { if self.textures.contains_key(&Slot::Thumb(id)) { continue; // the real thumbnail won the race @@ -1278,6 +1291,33 @@ mod thumbnail_tests { assert!(!app.renderer.is_pending(Slot::Main)); } + #[test] + fn a_drag_gets_its_live_hdr_histogram_beside_the_drafts() { + let (mut app, work, ctx) = fixture(); + let mut jobs = Vec::new(); + for exposure in [1.0, 2.0] { + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.hdr.enabled = true; + edit.light.exposure = exposure; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); + let job = app.session.render_job(PhotoId(1), 32, 32, false, true).unwrap(); + app.renderer.request(Slot::HdrHistogram, job, 95); + jobs.push(take_job(&work).1); + } + let keys: Vec = jobs.iter().map(|j| j.key).collect(); + let mut jobs = jobs.into_iter(); + let (first, last) = (jobs.next().unwrap(), jobs.next().unwrap()); + // superseded while the drag goes on, it still lands: the histogram keeps moving + finish(&mut app, &work, &ctx, Slot::HdrHistogram, first.run()); + let live = app.renderer.live_hdr_histogram.clone().expect("a live HDR histogram"); + assert_eq!((live.0, live.1), (PhotoId(1), keys[0])); + assert!(live.2.hdr.is_some(), "HDR bins"); + finish(&mut app, &work, &ctx, Slot::HdrHistogram, last.run()); + assert_eq!(app.renderer.live_hdr_histogram.as_ref().map(|l| l.1), Some(keys[1])); + // only the histogram is kept: no texture + assert!(!app.renderer.textures.contains_key(&Slot::HdrHistogram)); + } + #[test] fn library_reset_rejects_old_job_even_when_photo_id_and_key_are_reused() { let (mut app, work, ctx) = fixture(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index fa57f06d0..0860d391c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -152,6 +152,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | | 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 | ⬜ | `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet | +| 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; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag; no drag-to-adjust on the histogram | +| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | | ## D. Search & filter (FILT)