mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
Merge PR #995: LR-VIEW-CLIPPING: ⌥-drag previews for Blacks, Whites, Exposure, Sharpening and NR (#937)
This commit is contained in:
@@ -152,6 +152,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌥-drag views on a profiled display (issue #937): the histogram stays the photo's sRGB one
|
||||
/// (not the view's, nor the display-primary pixels'), and the clipping view is tested and
|
||||
/// drawn in sRGB, so it stays black and white.
|
||||
#[test]
|
||||
fn diagnostic_views_keep_the_photos_histogram() {
|
||||
use lightcraft_pipeline::Overlay;
|
||||
let mut session = crate::Session::with_demo();
|
||||
let id = session.visible_cloned()[0];
|
||||
let d = std::sync::Arc::new(p3());
|
||||
let mut run = |o, d: Option<std::sync::Arc<Display>>| {
|
||||
session.loupe_job(id, 320, 240, true).unwrap().with_display(d).with_overlay(o).run().rendered.unwrap()
|
||||
};
|
||||
let plain = run(Overlay::None, Some(d.clone()));
|
||||
let clip = run(Overlay::ShadowClip, Some(d.clone()));
|
||||
let (a, b) = (plain.histogram.mean_luma(), clip.histogram.mean_luma());
|
||||
assert!((a - b).abs() < 0.02, "the photo's histogram: {a} vs {b}");
|
||||
// each channel's mean level: the photo's, as a render without a display profile counts it
|
||||
let means = |h: &Histogram| {
|
||||
[&h.r, &h.g, &h.b].map(|c| c.iter().enumerate().map(|(i, n)| i as f64 * f64::from(*n)).sum::<f64>() / f64::from(h.total.max(1)))
|
||||
};
|
||||
let srgb = means(&run(Overlay::None, None).histogram);
|
||||
for o in [Overlay::ShadowClip, Overlay::HighlightClip, Overlay::Grey, Overlay::SharpenMask] {
|
||||
let v = means(&run(o, Some(d.clone())).histogram);
|
||||
assert!(srgb.iter().zip(v).all(|(p, v)| (p - v).abs() < 0.05), "{o:?}: the photo's sRGB histogram {srgb:?}, got {v:?}");
|
||||
}
|
||||
let px = &clip.image.data;
|
||||
// (the profile may round white to 254)
|
||||
let binary = px.iter().filter(|p| p[..3].iter().all(|v| *v <= 2 || *v >= 253)).count();
|
||||
assert!(binary as f64 > 0.99 * px.len() as f64, "{binary} of {}", px.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reports_and_rejects() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id()));
|
||||
|
||||
@@ -996,11 +996,24 @@ impl RenderJob {
|
||||
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
|
||||
// the GPU when there is one.
|
||||
let gpu = self.cache.is_none();
|
||||
let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
// a clipping or grey view: rendered in sRGB (the clipping test and the histogram
|
||||
// read sRGB values), then converted for the display below
|
||||
let srgb_view = self.request.overlay.renders_in_srgb();
|
||||
let request = if srgb_view { RenderRequest { display: None, ..self.request } } else { self.request };
|
||||
let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &request, self.stages.as_deref(), gpu);
|
||||
// for a display profile: the image goes to the display's values, the histogram
|
||||
// and the cached view preview stay sRGB
|
||||
let mut srgb = None;
|
||||
let display = match (&self.display, self.request.display) {
|
||||
// a diagnostic view: the histogram stays the photo's (taken before the overlay
|
||||
// was drawn), and nothing goes to the view cache
|
||||
(Some(d), Some(_)) if self.request.overlay != lightcraft_pipeline::Overlay::None => {
|
||||
let converted = if srgb_view { d.profile.from_srgb(&mut rendered.image) } else { d.profile.correct(&mut rendered.image) };
|
||||
if let Err(e) = converted {
|
||||
log::warn!("display profile: {e}");
|
||||
}
|
||||
Some(d.id())
|
||||
}
|
||||
(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);
|
||||
|
||||
@@ -120,7 +120,8 @@ pub fn last_fallback() -> Option<String> {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Fallback {
|
||||
/// The GPU was not tried: it is unavailable, disabled or closing ([`unavailable_reason`]), or
|
||||
/// the request is one only the CPU renders (high bit depth, soft proof, HDR range overlay).
|
||||
/// the request is one only the CPU renders (high bit depth, soft proof, HDR range or sharpening
|
||||
/// mask overlay).
|
||||
NotTried,
|
||||
/// The render was tried on the GPU and failed, for this reason (the same text
|
||||
/// [`last_fallback`] reports). The CPU renders it instead.
|
||||
@@ -441,10 +442,13 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
|
||||
|
||||
/// Whether the GPU renders requests of this kind at all (a given render can still fall back to
|
||||
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on
|
||||
/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it,
|
||||
/// the CPU, and so does Visualize HDR, which needs the float HDR render, and the sharpening mask,
|
||||
/// which needs the detail planes. [`try_render`] follows it,
|
||||
/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart.
|
||||
pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
|
||||
depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange
|
||||
depth == lightcraft_pipeline::OutputDepth::U8
|
||||
&& !proofed
|
||||
&& !matches!(overlay, lightcraft_pipeline::Overlay::HdrRange | lightcraft_pipeline::Overlay::SharpenMask)
|
||||
}
|
||||
|
||||
/// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU
|
||||
|
||||
@@ -680,6 +680,27 @@ fn overlays_match() {
|
||||
let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) };
|
||||
check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req);
|
||||
}
|
||||
// The ⌥-drag views (issue #937): clipping and grey, on an edit that clips both ends.
|
||||
let mut s = DevelopSettings::default();
|
||||
typical(&mut s);
|
||||
s.light.whites = 60.0;
|
||||
s.light.blacks = -60.0;
|
||||
check("grey overlay", &src, &raw, &s, &RenderRequest { overlay: Overlay::Grey, ..RenderRequest::fit(640, 640) });
|
||||
// The clipping views test each channel for exactly 0 / 255 (both renderers draw them with
|
||||
// `visualize::apply` on their 8-bit image): like Visualize Spots, a 1-LSB difference at the
|
||||
// threshold flips a pixel, so compare the share of differing pixels, and that the views agree
|
||||
// on what clips.
|
||||
for (overlay, edge) in [(Overlay::ShadowClip, 0u8), (Overlay::HighlightClip, 255)] {
|
||||
let req = RenderRequest { overlay, ..RenderRequest::fit(640, 640) };
|
||||
let plain = render(&src, &raw, &s, &RenderRequest::fit(640, 640)).image;
|
||||
let cpu = render(&src, &raw, &s, &req).image;
|
||||
let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image;
|
||||
let differ = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a != b).count() as f64 / cpu.data.len() as f64;
|
||||
let clipped = plain.data.iter().filter(|p| p[..3].contains(&edge)).count() as f64 / plain.data.len() as f64;
|
||||
eprintln!("{overlay:<28?} clipped {:.3}% differing {:.4}%", clipped * 100.0, differ * 100.0);
|
||||
assert!(clipped > 0.001, "{overlay:?}: the edit clips nothing to compare");
|
||||
assert!(differ < 0.002, "{overlay:?}: {differ}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,7 @@ use lightcraft_color::spline::{Lut1, MonotoneCurve};
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Process, ToneCurve, VignetteStyle};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_raster::Rgba8;
|
||||
use lightcraft_raster::{Plane, Rgba8};
|
||||
|
||||
use crate::colorops::ColorOps;
|
||||
use crate::geometry::Frame;
|
||||
@@ -14,6 +14,36 @@ use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc}
|
||||
use crate::tone::ToneMap;
|
||||
use crate::{Prepared, SourceInfo, for_rows};
|
||||
|
||||
/// Sharpening's Masking as 0..1.
|
||||
fn sharpen_masking(s: &DevelopSettings) -> f32 {
|
||||
(s.detail.sharpen_masking / 100.0) as f32
|
||||
}
|
||||
|
||||
/// How much sharpening applies at a pixel whose fine detail (log luminance minus its texture
|
||||
/// blur) is `det`, with Masking `masking` (0..1): everywhere at 0, only on stronger edges above.
|
||||
#[inline]
|
||||
fn sharpen_weight(masking: f32, det: f32) -> f32 {
|
||||
if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// [`sharpen_weight`] at every pixel of `p` (the view [`crate::Overlay::SharpenMask`] shows).
|
||||
pub(crate) fn sharpen_weight_plane(p: &Prepared, s: &DevelopSettings) -> Plane {
|
||||
let masking = sharpen_masking(s);
|
||||
if masking <= 0.0 {
|
||||
return Plane::filled(p.log_l.width, p.log_l.height, 1.0);
|
||||
}
|
||||
// the detail band the render sharpens (made here when sharpening is off)
|
||||
let fresh;
|
||||
let blur = match &p.texture_blur {
|
||||
Some(b) if (b.width, b.height) == (p.log_l.width, p.log_l.height) => &**b,
|
||||
_ => {
|
||||
fresh = lightcraft_raster::blur::gaussian(&p.log_l, crate::local::texture_sigma(p.px_per_long));
|
||||
&fresh
|
||||
}
|
||||
};
|
||||
p.log_l.zip_map(blur, |l, b| sharpen_weight(masking, l - b))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
@@ -244,7 +274,7 @@ impl FinishParams {
|
||||
tex,
|
||||
dehaze,
|
||||
sharpen: (s.detail.sharpen_amount / 150.0) as f32,
|
||||
sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32,
|
||||
sharpen_mask: sharpen_masking(s),
|
||||
air: air_pre * gain,
|
||||
air_pre,
|
||||
gain,
|
||||
@@ -509,8 +539,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs());
|
||||
delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame;
|
||||
if sp != 0.0 {
|
||||
let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 };
|
||||
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m;
|
||||
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * sharpen_weight(sharpen_mask, det);
|
||||
}
|
||||
}
|
||||
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
|
||||
|
||||
@@ -663,8 +663,11 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings,
|
||||
}
|
||||
|
||||
/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a
|
||||
/// fresh evaluation.
|
||||
/// fresh evaluation. For [`Overlay::SharpenMask`], the weight sharpening gets at each pixel.
|
||||
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
|
||||
if o == Overlay::SharpenMask {
|
||||
return Some(finish::sharpen_weight_plane(prep, &plan.settings));
|
||||
}
|
||||
let m = o.mask(&plan.settings)?;
|
||||
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
|
||||
return Some(e.alpha.clone());
|
||||
|
||||
@@ -488,6 +488,11 @@ pub struct PlaneSigmas {
|
||||
pub chroma: Option<f32>,
|
||||
}
|
||||
|
||||
/// Gaussian sigma of the texture / sharpening detail band at `px_per_long` pixels per long edge.
|
||||
pub(crate) fn texture_sigma(px_per_long: f64) -> f32 {
|
||||
(0.0018 * px_per_long as f32).max(0.6)
|
||||
}
|
||||
|
||||
pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas {
|
||||
let ppl = px_per_long as f32;
|
||||
let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0);
|
||||
@@ -508,7 +513,7 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
|
||||
|| local_any(|a| a.sharpness)
|
||||
|| local_any(|a| a.noise)
|
||||
|| s.masks.iter().any(|m| m.adjust.defringe > 0.0))
|
||||
.then(|| (0.0018 * ppl).max(0.6));
|
||||
.then(|| texture_sigma(px_per_long));
|
||||
let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0));
|
||||
let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0));
|
||||
let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 };
|
||||
|
||||
@@ -9,12 +9,20 @@
|
||||
//! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour
|
||||
//! tint on a black-and-white image, the image on black / white, or the alpha as white on black
|
||||
//! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`].
|
||||
//! - **Clipping while ⌥-dragging** Blacks / Whites / Exposure ([`Overlay::ShadowClip`],
|
||||
//! [`Overlay::HighlightClip`]): every output channel at 0 (or 255) shown, everything else blank,
|
||||
//! so the clipped areas, and the channels that clip, are all that's left to see.
|
||||
//! - **Sharpening mask while ⌥-dragging Masking** ([`Overlay::SharpenMask`]): the weight the
|
||||
//! render gives sharpening at each pixel, white on black (the CPU renderer draws it: it needs the
|
||||
//! detail planes).
|
||||
//! - **Grey while ⌥-dragging** Sharpening or luminance Noise Reduction ([`Overlay::Grey`]): the
|
||||
//! render in grey, so detail and noise are judged without colour.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020};
|
||||
use lightcraft_color::transfer::srgb_to_linear;
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_color::{LUMA_709, REC2020, SRGB};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_raster::{Plane, Rgba8};
|
||||
|
||||
@@ -39,6 +47,17 @@ pub enum Overlay {
|
||||
/// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR
|
||||
/// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]).
|
||||
HdrRange,
|
||||
/// Shadow clipping (⌥ on Blacks): white, black where every channel is 0, and where only some
|
||||
/// are, the colour of the channels that aren't.
|
||||
ShadowClip,
|
||||
/// Highlight clipping (⌥ on Whites / Exposure): black, white where every channel is 255, and
|
||||
/// where only some are, the colour of those channels.
|
||||
HighlightClip,
|
||||
/// Sharpening's weight after Masking (⌥ on Masking): white where it sharpens, black where
|
||||
/// Masking holds it back.
|
||||
SharpenMask,
|
||||
/// The render in grey (⌥ on the Sharpening and luminance Noise Reduction sliders).
|
||||
Grey,
|
||||
}
|
||||
|
||||
/// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white.
|
||||
@@ -125,6 +144,10 @@ impl Overlay {
|
||||
Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64),
|
||||
Overlay::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64),
|
||||
Overlay::HdrRange => (4, 0.0),
|
||||
Overlay::ShadowClip => (6, 0.0),
|
||||
Overlay::HighlightClip => (7, 0.0),
|
||||
Overlay::SharpenMask => (8, 0.0),
|
||||
Overlay::Grey => (9, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +166,10 @@ impl Overlay {
|
||||
opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8,
|
||||
}
|
||||
}
|
||||
6 => Overlay::ShadowClip,
|
||||
7 => Overlay::HighlightClip,
|
||||
8 => Overlay::SharpenMask,
|
||||
9 => Overlay::Grey,
|
||||
_ => Overlay::None,
|
||||
}
|
||||
}
|
||||
@@ -156,9 +183,20 @@ impl Overlay {
|
||||
Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity),
|
||||
Overlay::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity),
|
||||
Overlay::HdrRange => 0x4000,
|
||||
Overlay::ShadowClip => 0x5000,
|
||||
Overlay::HighlightClip => 0x6000,
|
||||
Overlay::SharpenMask => 0x7000,
|
||||
Overlay::Grey => 0x8000,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ⌥-drag views that show none of the photo's colours: the clipping views (which test the
|
||||
/// sRGB values the histogram counts) and the grey ones. A display render draws them in sRGB,
|
||||
/// then converts, so their histogram (taken before the overlay) is the photo's sRGB one.
|
||||
pub fn renders_in_srgb(self) -> bool {
|
||||
matches!(self, Overlay::ShadowClip | Overlay::HighlightClip | Overlay::SharpenMask | Overlay::Grey)
|
||||
}
|
||||
|
||||
/// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components).
|
||||
pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> {
|
||||
match self {
|
||||
@@ -181,12 +219,21 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) {
|
||||
}
|
||||
|
||||
/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask
|
||||
/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]); `masks` is the set of
|
||||
/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components.
|
||||
/// an [`Overlay::Mask`] shows, or the sharpening weight of an [`Overlay::SharpenMask`], at the
|
||||
/// image's size (see [`Overlay::mask`]); `masks` is the set of alphas an [`Overlay::AllMasks`]
|
||||
/// shows, one per visible mask with components.
|
||||
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) {
|
||||
match o {
|
||||
// drawn by the renderer from the HDR rendition ([`hdr_range`])
|
||||
Overlay::None | Overlay::HdrRange => {}
|
||||
Overlay::ShadowClip => clipping(img, false),
|
||||
Overlay::HighlightClip => clipping(img, true),
|
||||
Overlay::Grey => grey(img),
|
||||
Overlay::SharpenMask => {
|
||||
if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) {
|
||||
mask_view(img, a, MaskView::WhiteOnBlack, [255; 3], 100);
|
||||
}
|
||||
}
|
||||
Overlay::PointColorRange(i) => {
|
||||
if let Some(p) = plan.settings.point_colors.get(i as usize) {
|
||||
point_color_range(img, &PointK::new(p));
|
||||
@@ -224,7 +271,7 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let a = a.data[y * w + x].clamp(0.0, 1.0);
|
||||
let p = [px[0] as f32, px[1] as f32, px[2] as f32];
|
||||
let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2];
|
||||
let grey = luma8(*px);
|
||||
let out: [f32; 3] = match view {
|
||||
MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op),
|
||||
MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op),
|
||||
@@ -238,6 +285,25 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa
|
||||
});
|
||||
}
|
||||
|
||||
/// Rec. 709 luma of an 8-bit pixel (0..255).
|
||||
fn luma8(p: [u8; 4]) -> f32 {
|
||||
LUMA_709[0] * p[0] as f32 + LUMA_709[1] * p[1] as f32 + LUMA_709[2] * p[2] as f32
|
||||
}
|
||||
|
||||
/// The clipping view: with `high`, each channel at 255 on, the rest off (black where nothing
|
||||
/// clips); otherwise each channel at 0 off, the rest on (white where nothing clips).
|
||||
fn clipping(img: &mut Rgba8, high: bool) {
|
||||
let on = move |v: u8| if (high && v == 255) || (!high && v != 0) { 255 } else { 0 };
|
||||
img.map_in_place(|p| [on(p[0]), on(p[1]), on(p[2]), 255]);
|
||||
}
|
||||
|
||||
fn grey(img: &mut Rgba8) {
|
||||
img.map_in_place(|p| {
|
||||
let g = luma8(p).round().clamp(0.0, 255.0) as u8;
|
||||
[g, g, g, 255]
|
||||
});
|
||||
}
|
||||
|
||||
/// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge.
|
||||
const SPOT_SIGMA: f64 = 0.006;
|
||||
|
||||
@@ -255,7 +321,7 @@ fn spots(img: &mut Rgba8, t: u8, ppl: f64) {
|
||||
}
|
||||
let l = lightcraft_raster::Plane::from_fn(w, h, |x, y| {
|
||||
let p = img.data[y * w + x];
|
||||
(0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0
|
||||
luma8(p) / 255.0
|
||||
});
|
||||
let b = lightcraft_raster::blur::gaussian(&l, (SPOT_SIGMA * ppl).max(0.8) as f32);
|
||||
let thr = spot_threshold(t);
|
||||
@@ -275,7 +341,7 @@ fn point_color_range(img: &mut Rgba8, k: &PointK) {
|
||||
let c = std::array::from_fn(|r| m[r][0] * lin[0] + m[r][1] * lin[1] + m[r][2] * lin[2]);
|
||||
let [l, ch, h] = lab_to_lch(oklab_from_2020(c));
|
||||
let a = k.weight(l, ch, h);
|
||||
let grey = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32;
|
||||
let grey = luma8(*px);
|
||||
for c in 0..3 {
|
||||
px[c] = (grey + (px[c] as f32 - grey) * a).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
@@ -296,7 +362,7 @@ pub fn hdr_range(img: &mut Rgba8, hdr: &crate::DeepImage, luma: [f32; 3]) {
|
||||
let i = (y * w + x) * 3;
|
||||
let Some(c) = v.get(i..i + 3) else { continue };
|
||||
let lum = luma[0] * c[0] + luma[1] * c[1] + luma[2] * c[2];
|
||||
let grey = (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) * 0.55;
|
||||
let grey = luma8(*p) * 0.55;
|
||||
if lum > 1.0 {
|
||||
let stops = lum.log2();
|
||||
let band = HDR_BANDS[(stops as usize).min(HDR_BANDS.len() - 1)];
|
||||
@@ -343,6 +409,66 @@ mod tests {
|
||||
assert_eq!(Overlay::from_parts(k, v), Overlay::Spots(37));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_views_show_only_the_clipped_channels() {
|
||||
// left to right: black, a red that clips only in red, mid grey, white
|
||||
let src = Rgb32f::from_fn(40, 10, |x, _| match x / 10 {
|
||||
0 => [0.0; 3],
|
||||
1 => [0.9, 0.02, 0.02],
|
||||
2 => [0.18; 3],
|
||||
_ => [8.0; 3],
|
||||
});
|
||||
let (info, s) = (SourceInfo::default(), DevelopSettings::default());
|
||||
let shot = |o: Overlay| render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(40, 10) });
|
||||
let plain = shot(Overlay::None).image;
|
||||
let at = |i: &Rgba8, x: usize| i.data[5 * 40 + x];
|
||||
assert_eq!((at(&plain, 5), at(&plain, 35)), ([0, 0, 0, 255], [255; 4]), "the scene clips at both ends");
|
||||
assert_eq!(at(&plain, 15)[0], 255, "the red clips in red");
|
||||
let hi = shot(Overlay::HighlightClip);
|
||||
assert_eq!([5, 15, 25, 35].map(|x| at(&hi.image, x)), [[0, 0, 0, 255], [255, 0, 0, 255], [0, 0, 0, 255], [255; 4]]);
|
||||
let lo = shot(Overlay::ShadowClip);
|
||||
assert_eq!(at(&lo.image, 5), [0, 0, 0, 255], "black where every channel clips");
|
||||
assert_eq!([25, 35].map(|x| at(&lo.image, x)), [[255; 4]; 2], "white where none does");
|
||||
// only the view changes: the histogram is the photo's
|
||||
assert_eq!(hi.histogram.clipping(), shot(Overlay::None).histogram.clipping());
|
||||
for o in [Overlay::ShadowClip, Overlay::HighlightClip, Overlay::SharpenMask, Overlay::Grey] {
|
||||
let (k, v) = o.to_parts();
|
||||
assert_eq!(Overlay::from_parts(k, v), o);
|
||||
assert_ne!(o.key(), Overlay::None.key());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharpen_mask_view_follows_masking() {
|
||||
// a hard vertical edge between two flat halves
|
||||
let src = Rgb32f::from_fn(200, 100, |x, _| if x < 100 { [0.05; 3] } else { [0.4; 3] });
|
||||
let info = SourceInfo::default();
|
||||
let mut s = DevelopSettings::default();
|
||||
s.detail.sharpen_amount = 60.0;
|
||||
let shot =
|
||||
|s: &DevelopSettings| render(&src, &info, s, &RenderRequest { overlay: Overlay::SharpenMask, ..RenderRequest::fit(200, 100) }).image;
|
||||
let at = |i: &Rgba8, x: usize| i.data[50 * 200 + x][0];
|
||||
// Masking 0: sharpening applies everywhere
|
||||
let open = shot(&s);
|
||||
assert!(open.data.iter().all(|p| *p == [255; 4]), "all white at Masking 0");
|
||||
// Masking 100: only the edge is sharpened
|
||||
s.detail.sharpen_masking = 100.0;
|
||||
let edge = shot(&s);
|
||||
assert_eq!((at(&edge, 20), at(&edge, 180)), (0, 0), "flat areas held back");
|
||||
assert!(at(&edge, 100).max(at(&edge, 99)) > 200, "the edge sharpened: {}", at(&edge, 100));
|
||||
// the mask shows with sharpening off too
|
||||
s.detail.sharpen_amount = 0.0;
|
||||
assert_eq!(shot(&s).data, edge.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grey_view_drops_the_colour() {
|
||||
let src = Rgb32f::from_fn(8, 8, |_, _| [0.4, 0.1, 0.05]);
|
||||
let req = RenderRequest { overlay: Overlay::Grey, ..RenderRequest::fit(8, 8) };
|
||||
let v = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &req).image;
|
||||
assert!(v.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[0] > 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_color_range_keeps_the_selected_colour_only() {
|
||||
// left half orange, right half blue
|
||||
|
||||
@@ -54,6 +54,8 @@ mod tests_album_picker;
|
||||
#[cfg(test)]
|
||||
mod tests_album_remove;
|
||||
#[cfg(test)]
|
||||
mod tests_alt_preview;
|
||||
#[cfg(test)]
|
||||
mod tests_appearance;
|
||||
#[cfg(test)]
|
||||
mod tests_before_after;
|
||||
|
||||
@@ -335,11 +335,11 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
|
||||
|
||||
/// Whether an overlay can be rendered on a region tile without changing what the tile means.
|
||||
/// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask
|
||||
/// alphas as the full render; the other diagnostic overlays either need whole-frame context or
|
||||
/// deliberately replace the loupe.
|
||||
/// alphas as the full render, and the clipping and grey views work per pixel of it; the other
|
||||
/// diagnostic overlays need whole-frame context or deliberately replace the loupe.
|
||||
pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool {
|
||||
use lightcraft_pipeline::Overlay;
|
||||
matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. })
|
||||
matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. } | Overlay::ShadowClip | Overlay::HighlightClip | Overlay::Grey)
|
||||
}
|
||||
|
||||
/// What a window render of the loupe needs to know about the view it belongs to.
|
||||
@@ -537,7 +537,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
app.image_rect = Some(img_rect);
|
||||
// request renders: the loupe at display resolution (drafts during drags)
|
||||
let interacting = app.session.interaction.is_some();
|
||||
let overlay = view_overlay(app, &d);
|
||||
let overlay = view_overlay(app, &d, ui.input(|i| i.modifiers.alt && i.pointer.primary_down()));
|
||||
// a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`;
|
||||
// `ready` first: `available` would wait for a device still being created)
|
||||
let gpu = interacting
|
||||
@@ -1398,13 +1398,42 @@ fn targeted_drag(app: &mut LightcraftApp, resp: &egui::Response, map: &CanvasMap
|
||||
}
|
||||
}
|
||||
|
||||
/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range,
|
||||
/// The view holding ⌥ (Option) while dragging slider `control` shows in the loupe, as in Lightroom
|
||||
/// (issue #937): clipping on Blacks (shadows) and Whites / Exposure (highlights), the sharpening
|
||||
/// mask on Masking, the image in grey on the other Sharpening and the luminance Noise Reduction
|
||||
/// sliders. Releasing ⌥ or the slider brings the photo back; nothing is saved.
|
||||
pub(crate) fn slider_preview(control: &str) -> Option<lightcraft_pipeline::Overlay> {
|
||||
SLIDER_PREVIEWS.iter().find(|(id, _)| *id == control).map(|(_, o)| *o)
|
||||
}
|
||||
|
||||
/// The controls with a [`slider_preview`], and the view each shows.
|
||||
pub(crate) const SLIDER_PREVIEWS: [(&str, lightcraft_pipeline::Overlay); 10] = {
|
||||
use lightcraft_pipeline::Overlay::{Grey, HighlightClip, ShadowClip, SharpenMask};
|
||||
[
|
||||
("light.blacks", ShadowClip),
|
||||
("light.whites", HighlightClip),
|
||||
("light.exposure", HighlightClip),
|
||||
("detail.sharpenMasking", SharpenMask),
|
||||
("detail.sharpenAmount", Grey),
|
||||
("detail.sharpenRadius", Grey),
|
||||
("detail.sharpenDetail", Grey),
|
||||
("detail.nrLuminance", Grey),
|
||||
("detail.nrDetail", Grey),
|
||||
("detail.nrContrast", Grey),
|
||||
]
|
||||
};
|
||||
|
||||
/// The diagnostic overlay the loupe shows (the ⌥-drag preview of the slider being dragged while
|
||||
/// `alt`: ⌥ is held with the button down; the selected mask, Point Color's visualized range,
|
||||
/// Visualize Spots).
|
||||
pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
|
||||
pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings, alt: bool) -> lightcraft_pipeline::Overlay {
|
||||
use lightcraft_pipeline::{MaskView, Overlay};
|
||||
if app.ui.fullscreen {
|
||||
return Overlay::None;
|
||||
}
|
||||
if alt && let Some(o) = app.ui.dragging_control.as_deref().and_then(slider_preview) {
|
||||
return o;
|
||||
}
|
||||
// Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay
|
||||
// so the shortcut is not shadowed by the always-on single-mask overlay.
|
||||
if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Headless tests of the ⌥-drag previews (issue #937): holding ⌥ (Option) while dragging Blacks,
|
||||
//! Whites, Exposure or a Sharpening / Noise Reduction slider shows a diagnostic view in the loupe.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use lightcraft_pipeline::Overlay;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::panels::detail::{SLIDER_PREVIEWS, slider_preview};
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn detail() -> Headless {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1400.0, 1400.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
if !h.app.ui.section_open("light") {
|
||||
h.app.ui.toggle_section("light");
|
||||
}
|
||||
h.app.renderer.keep_pixels = true;
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
/// Share of the loupe render's pixels whose channels are all 0 or 255 (a clipping view).
|
||||
fn binary_share(h: &Headless) -> f64 {
|
||||
let t = h.app.renderer.textures.get(&crate::render::Slot::Main).expect("loupe render");
|
||||
let px = &t.pixels.as_ref().expect("pixels kept").pixels;
|
||||
let binary = px.iter().filter(|c| [c.r(), c.g(), c.b()].iter().all(|v| *v == 0 || *v == 255)).count();
|
||||
binary as f64 / px.len().max(1) as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_during_a_blacks_drag_shows_shadow_clipping_until_released() {
|
||||
let mut h = detail();
|
||||
assert!(binary_share(&h) < 0.5, "the photo itself is no clipping view");
|
||||
let track = h.app.widgets.iter().find(|(w, _)| w == "slider:light.blacks").map(|(_, r)| *r).expect("Blacks slider on screen");
|
||||
let from = track.center();
|
||||
let before = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks;
|
||||
// a plain drag to the left, button held: the photo as usual
|
||||
h.app.synthetic.push(Event::PointerMoved(from));
|
||||
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
for i in 1..=8 {
|
||||
h.app.synthetic.push(Event::PointerMoved(from - egui::vec2(6.0 * i as f32, 0.0)));
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.ui.dragging_control.as_deref(), Some("light.blacks"));
|
||||
assert!(binary_share(&h) < 0.5, "no preview without ⌥");
|
||||
// ⌥ pressed mid-drag: the shadow clipping view
|
||||
h.app.synthetic.push(Event::ModifiersChanged(Modifiers::ALT));
|
||||
h.settle(SETTLE);
|
||||
assert!(binary_share(&h) > 0.99, "the clipping view: {}", binary_share(&h));
|
||||
// ⌥ released: the photo again, the drag still on
|
||||
h.app.synthetic.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.settle(SETTLE);
|
||||
assert!(binary_share(&h) < 0.5, "the photo is back");
|
||||
h.app.synthetic.push(Event::PointerButton {
|
||||
pos: from - egui::vec2(48.0, 0.0),
|
||||
button: PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.ui.dragging_control, None);
|
||||
let after = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks;
|
||||
assert!(after < before, "the drag itself still moved Blacks: {before} → {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_slider_has_its_preview() {
|
||||
assert_eq!(slider_preview("light.blacks"), Some(Overlay::ShadowClip));
|
||||
for c in ["light.whites", "light.exposure"] {
|
||||
assert_eq!(slider_preview(c), Some(Overlay::HighlightClip), "{c}");
|
||||
}
|
||||
assert_eq!(slider_preview("detail.sharpenMasking"), Some(Overlay::SharpenMask));
|
||||
for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.nrLuminance", "detail.nrDetail", "detail.nrContrast"] {
|
||||
assert_eq!(slider_preview(c), Some(Overlay::Grey), "{c}");
|
||||
}
|
||||
for c in ["light.contrast", "color.saturation", "detail.nrColor"] {
|
||||
assert_eq!(slider_preview(c), None, "{c}");
|
||||
}
|
||||
// every mapped id is a real control, once (a rename would silently drop its preview)
|
||||
for (i, (c, _)) in SLIDER_PREVIEWS.iter().enumerate() {
|
||||
assert!(lightcraft_develop::controls::find(c).is_some(), "{c}");
|
||||
assert!(!SLIDER_PREVIEWS[..i].iter().any(|(d, _)| d == c), "{c} twice");
|
||||
}
|
||||
}
|
||||
@@ -159,23 +159,23 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() {
|
||||
let d = develop(&h);
|
||||
assert!(d.masks[0].adjust.exposure > 0.0);
|
||||
assert!(d.masks[0].visible, "hiding the overlay must not disable the mask");
|
||||
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None);
|
||||
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None);
|
||||
let mask = d.masks[0].clone();
|
||||
|
||||
let r = h.request("ui.key", json!({"key": "o"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.app.ui.mask_overlay, "O can restore the overlay");
|
||||
assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged");
|
||||
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. }));
|
||||
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::Mask { .. }));
|
||||
|
||||
// Ctrl+Shift+O / Cmd+Shift+O shows every visible mask's region at once (the new command), ahead of the selected
|
||||
// mask overlay so it is not shadowed by the always-on single-mask overlay.
|
||||
exec(&mut h, "view.maskOverlayAll", json!({}));
|
||||
assert!(h.app.ui.mask_overlay_all, "the all-masks overlay command turns it on");
|
||||
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. }));
|
||||
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. }));
|
||||
exec(&mut h, "view.maskOverlayAll", json!({}));
|
||||
assert!(!h.app.ui.mask_overlay_all, "the all-masks overlay command toggles it off");
|
||||
assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. }));
|
||||
assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. }));
|
||||
|
||||
// Failed edits and global adjustments must not hide the mask overlay.
|
||||
assert!(h.app.run("mask.adjust", json!({"id": 999, "values": {"exposure": 1.0}})).is_err());
|
||||
@@ -274,12 +274,12 @@ fn mask_overlay_keys_and_pins() {
|
||||
assert_eq!(h.app.session.active_mask, Some(2));
|
||||
// the loupe asks the renderer for the selected mask's overlay
|
||||
let d = develop(&h);
|
||||
let o = crate::panels::detail::view_overlay(&h.app, &d);
|
||||
let o = crate::panels::detail::view_overlay(&h.app, &d, false);
|
||||
assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 });
|
||||
// O toggles it, Shift+O cycles the colour (and leaves the crop overlay alone)
|
||||
h.request("ui.key", json!({"key": "o"}), T);
|
||||
assert!(!h.app.ui.mask_overlay);
|
||||
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None);
|
||||
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None);
|
||||
h.request("ui.key", json!({"key": "o"}), T);
|
||||
let crop = h.app.ui.crop_overlay;
|
||||
let colour = h.app.ui.mask_overlay_color;
|
||||
|
||||
@@ -212,7 +212,10 @@ mod in_the_loupe {
|
||||
h.settle(SETTLE);
|
||||
let id = h.app.session.active().expect("a photo");
|
||||
let d = h.app.session.develop_of(id).expect("settings");
|
||||
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown");
|
||||
assert!(
|
||||
matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::Mask { .. }),
|
||||
"the mask overlay is shown"
|
||||
);
|
||||
assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan");
|
||||
let main = rendered_long_edge(&h);
|
||||
assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}");
|
||||
@@ -250,7 +253,7 @@ mod in_the_loupe {
|
||||
let id = h.app.session.active().expect("a photo");
|
||||
let d = h.app.session.develop_of(id).expect("settings");
|
||||
assert!(
|
||||
matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::AllMasks { .. }),
|
||||
matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::AllMasks { .. }),
|
||||
"the all-masks overlay is shown"
|
||||
);
|
||||
let main = rendered_long_edge(&h);
|
||||
|
||||
+12
-11
@@ -1,6 +1,6 @@
|
||||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (⌥-drag previews on Blacks / Whites / Exposure / Sharpening / luminance NR, #937; LR-VIEW-CLIPPING and LR-EDIT-DETAIL-SHARPEN downgraded to 🟡: J doesn't paint clipped pixels, Radius / Detail unused) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||
@@ -28,10 +28,10 @@ cited here still exists. Formerly docs/parity.md.
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) |
|
||||
| B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) |
|
||||
| C. Views & navigation (VIEW) | 16 | 1 | 1 | 0 | 9/9 (100%) | 4/4 (100%) |
|
||||
| C. Views & navigation (VIEW) | 15 | 2 | 1 | 0 | 8/9 (89%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 43 | 3 | 3 | 1 | 27/28 (96%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) |
|
||||
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
|
||||
| I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
@@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md.
|
||||
| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) |
|
||||
| Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) |
|
||||
| **Total** | 419 | 61 | 59 | 35 | 194/201 (97%) | 148/161 (92%) |
|
||||
| **Total** | 417 | 63 | 59 | 35 | 192/201 (96%) | 148/161 (92%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.4%** of 539 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 55.7% of 176.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.2%** of 539 in-scope rows — P0 97.8% of 201 · P1 95.0% of 161 · P2 55.7% of 176.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps and shortcut notes
|
||||
@@ -140,7 +140,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) |
|
||||
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement |
|
||||
| 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-CLIPPING | Clipping indicators | P0 | 🟡 | `cmd:view.clipping`, `crates/ui-egui/src/panels/detail.rs` (`clipping_overlay`, `slider_preview`) | J only frames the photo red (highlights) / blue (shadows) when any pixel clips; it doesn't paint the clipped pixels. ⌥-dragging Blacks / Whites / Exposure shows the clipped pixels and channels (`Overlay::ShadowClip` / `HighlightClip`, issue #937) |
|
||||
| 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; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag |
|
||||
| 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; |
|
||||
|
||||
@@ -180,12 +180,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | a Lightroom-like recipe calibrated black-box on Lightroom's Auto results for 24 Sony raws: Exposure from the scene's scene-linear log-average luminance (Lightroom's observed slope, -0.41 EV per EV), Highlights -66 and Shadows +52 (Lightroom's average Auto values), Contrast +68 and Vibrance +30 fitted on our scales. Held out one photo at a time: on the 16 photos whose start Sony's DRO doesn't brighten, a median dE76 of 6.4 from Lightroom's Auto results against 8.3 unedited; on all 24, 7.7 against 7.9 (issues #318, #209). Calibrated on Sony files only; Whites and Blacks stay 0 |
|
||||
| LR-EDIT-BW | Black & white | P0 | ✅ | `cmd:develop.treatment` | |
|
||||
| LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on); see Q. HDR |
|
||||
| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | |
|
||||
| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | ⌥-drag: highlight clipping view (#937) |
|
||||
| LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | |
|
||||
| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp |
|
||||
| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632) |
|
||||
| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | |
|
||||
| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | |
|
||||
| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | ⌥-drag: highlight clipping view, black where nothing clips (#937) |
|
||||
| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | ⌥-drag: shadow clipping view, white where nothing clips (#937) |
|
||||
| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | |
|
||||
| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag; ⌥-drag moves it a tenth as far for fine adjustment, issue #1363); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON |
|
||||
| LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | |
|
||||
@@ -206,8 +206,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | |
|
||||
| LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section |
|
||||
| LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | |
|
||||
| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview |
|
||||
| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960) |
|
||||
| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | 🟡 | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | ⌥-drag: Masking shows the sharpening mask white on black, the other sliders the image in grey (#937). Radius and Detail are saved but the render doesn't use them yet (fixed band at 0.18% of the long edge, `local::texture_sigma`) |
|
||||
| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960); ⌥-drag: the image in grey (#937) |
|
||||
| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | preview-size independent like luminance NR (#960) |
|
||||
| LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) |
|
||||
| LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | |
|
||||
@@ -876,6 +876,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) |
|
||||
| 2026-10-13 | patch | LR-IMP-LOCAL: browsing a Local folder again forgets the untouched records of files deleted or moved by another app (issue #1392); status unchanged |
|
||||
| 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged |
|
||||
| 2026-10-11 | minor | ⌥-drag previews (issue #937) noted on LR-EDIT-LIGHT-EXPOSURE / WHITES / BLACKS and LR-EDIT-DETAIL-NR; LR-VIEW-CLIPPING ✅ → 🟡 (J frames the photo, doesn't paint clipped pixels), LR-EDIT-DETAIL-SHARPEN ✅ → 🟡 (Radius and Detail don't reach the render) |
|
||||
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
|
||||
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
|
||||
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
|
||||
|
||||
Reference in New Issue
Block a user