# Conflicts:
#	crates/pipeline/src/spots.rs
#	crates/ui-egui/src/panels/edit.rs
#	docs/parity-checklist.md
This commit is contained in:
bflatastic committed 2026-10-13 11:06:46 +07:00
commit fa3dfbbc4a
4 files changed
+108 -3

No files matched your search

+4
View File
@@ -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
+61 -2
View File
@@ -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<lightcraft_rast
.or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone()))
}
/// An HDR edit's histogram stays an HDR histogram: some renders carry only their 8-bit image's
/// (a drag's drafts, a cached thumbnail or stand-in), and showing those would swap the panel to
/// the SDR layout and back while a slider moves. Every render with HDR bins replaces the one kept for the photo, so
/// the histogram still follows the edit live; an SDR edit shows what it gets.
fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: Option<Histogram>) -> Option<Histogram> {
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::<Histogram>(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));
}
}
+41 -1
View File
@@ -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<Slot, (u64, u32)>,
pub textures: HashMap<Slot, Tex>,
/// 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<u64> = 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();
+2
View File
@@ -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)