On merge: the HDR histogram is applied in develop_reporting (#712 split develop), is skipped for
draft renders (a second render per drag tick), and HdrBins::bin clamps a hand-built split so it
can't underflow (test hdr_bins_with_any_split_stay_in_range).
This commit is contained in:
Claude committed 2026-10-11 13:09:34 +00:00
commit 4ebb934c56
10 files changed
+326 -23

No files matched your search

+7 -2
View File
@@ -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)
}
}
+3 -11
View File
@@ -127,11 +127,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
Ok((r, stored)) => {
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 {
+18 -1
View File
@@ -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<String>) {
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<Rgb32f>,
info: &SourceInfo,
s: &DevelopSettings,
req: &RenderRequest,
stages: Option<&StageCache>,
gpu: bool,
) -> (Rendered, Option<String>) {
// LUT profiles have no GPU stage: they render on the CPU
let fallback = if gpu
+15
View File
@@ -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");
}
+31
View File
@@ -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<Histogram> {
// 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)
+33
View File
@@ -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());
}
+162 -1
View File
@@ -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<u32>,
pub luma: Vec<u32>,
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<HdrBins>,
}
/// 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<HdrBins>) -> 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<Item = [u8; 4]>, 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<u32> {
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<Histogram> {
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::<u32>();
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}");
}
}
}
}
}
+1 -1
View File
@@ -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<T: Send>(data: &mut [T], row_len: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
+54 -5
View File
@@ -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<f32> {
(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));
+2 -2
View File
@@ -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)