mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-11 16:31:32 -06:00
Conflicts resolved: - crates/engine/src/media.rs: main split it; the render and display-profile hunks are ported into media/render.rs - crates/ui-egui/src/lib.rs, panels/detail.rs: main split them; alt_held is set in app/frame.rs, view_overlay and overlay_supports_window ported (panels/detail/window.rs) - crates/ui-egui/src/tests_alt_preview.rs moved to tests/alt_preview.rs - crates/pipeline/src/finish.rs, local.rs: main's code kept, sharpen_mask_weight and texture_sigma applied to it - docs: both sides kept; summary counts from main (regenerated later)
This commit is contained in:
@@ -152,6 +152,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt-drag previews on a profiled display (issue #937): the histogram stays the photo's (not
|
||||
/// the preview's), 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| session.loupe_job(id, 320, 240, true).unwrap().with_display(Some(d.clone())).with_overlay(o).run().rendered.unwrap();
|
||||
let plain = run(Overlay::None);
|
||||
let clip = run(Overlay::ClipShadows);
|
||||
let (a, b) = (plain.histogram.mean_luma(), clip.histogram.mean_luma());
|
||||
assert!((a - b).abs() < 0.02, "the photo's histogram: {a} vs {b}");
|
||||
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()));
|
||||
|
||||
@@ -270,11 +270,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 preview tests sRGB values, like the histogram: rendered in sRGB, then
|
||||
// converted for the display below
|
||||
let clip_view = self.request.overlay.is_clipping();
|
||||
let request = if clip_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 clip_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);
|
||||
|
||||
@@ -783,6 +783,14 @@ pub fn render(
|
||||
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref())
|
||||
}
|
||||
});
|
||||
// a sharpening view: the detail planes this render sharpened with
|
||||
let overlay_mask = overlay_mask.or_else(|| {
|
||||
req.overlay.reads_detail().then(|| {
|
||||
let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone();
|
||||
let tex = prep.texture.as_deref().map(|b| cx.read_plane(b, w, h));
|
||||
lightcraft_pipeline::visualize::sharpen_plane(req.overlay, &plan.settings, &l, tex.as_ref(), plan.px_per_long)
|
||||
})?
|
||||
});
|
||||
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref());
|
||||
// spots grew the window the render worked on: cut it back to the request
|
||||
if let Some(k) = plan.keep {
|
||||
|
||||
@@ -693,6 +693,34 @@ 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);
|
||||
}
|
||||
// Alt-drag previews: clipping and greyscale on the finished image; the sharpening views from
|
||||
// each renderer's own detail planes (with sharpening on, and off: no texture plane then).
|
||||
let mut s = DevelopSettings::default();
|
||||
(s.light.whites, s.light.blacks, s.detail.sharpen_masking) = (80.0, -80.0, 40.0);
|
||||
for amount in [50.0, 0.0] {
|
||||
s.detail.sharpen_amount = amount;
|
||||
for o in [Overlay::ClipHighlights, Overlay::ClipShadows, Overlay::Grayscale, Overlay::SharpenDetail, Overlay::SharpenMask] {
|
||||
let req = RenderRequest { overlay: o, ..RenderRequest::fit(640, 640) };
|
||||
if matches!(o, Overlay::ClipHighlights | Overlay::ClipShadows) {
|
||||
// a binary view: a 1-LSB difference at 0 / 255 flips a channel, so compare shares
|
||||
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;
|
||||
assert!(differ < 0.002, "{o:?} sharpen {amount}: {differ}");
|
||||
} else if o == Overlay::Grayscale {
|
||||
check(&format!("{o:?} preview"), &src, &raw, &s, &req);
|
||||
} else {
|
||||
// the sharpening views amplify the detail planes (the mask through a steep
|
||||
// smoothstep): allow a few larger differences where the planes differ slightly
|
||||
let cpu = render(&src, &raw, &s, &req).image;
|
||||
let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image;
|
||||
let (mean, max, _) = diff(&cpu, &gpu);
|
||||
let far = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a[0].abs_diff(b[0]) > 8).count() as f64 / cpu.data.len() as f64;
|
||||
eprintln!("{o:?} sharpen {amount:<4} mean {mean:.4} max {max} >8: {:.4}%", far * 100.0);
|
||||
assert!(mean < 1.0 && far < 0.005, "{o:?} sharpen {amount}: mean {mean:.4}, >8 LSB {far}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,15 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// How much of the sharpening a pixel gets (0..1) under Masking `masking` (0..1), from its fine
|
||||
/// detail `det` (log luminance minus its texture blur): 1 everywhere at 0, only stronger edges
|
||||
/// as it rises. The GPU kernel (`finish.wgsl`) computes the same; Alt-drag on Masking shows it
|
||||
/// ([`crate::Overlay::SharpenMask`]).
|
||||
#[inline]
|
||||
pub fn sharpen_mask_weight(masking: f32, det: f32) -> f32 {
|
||||
if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Parametric region curve (encoded domain) composed with the master point curve.
|
||||
fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> {
|
||||
let parametric = c.highlights != 0.0 || c.lights != 0.0 || c.darks != 0.0 || c.shadows != 0.0;
|
||||
@@ -647,7 +656,7 @@ fn local_tone_delta(p: &Prepared, fp: &FinishParams, index: usize, base: f32, l1
|
||||
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, detail.abs());
|
||||
delta += texture * 1.1 * detail.clamp(-1.0, 1.0) * tame;
|
||||
if sharpen != 0.0 {
|
||||
let mask = if fp.sharpen_mask > 0.0 { smooth(fp.sharpen_mask * 0.25, fp.sharpen_mask * 0.25 + 0.15, detail.abs()) } else { 1.0 };
|
||||
let mask = sharpen_mask_weight(fp.sharpen_mask, detail);
|
||||
delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,9 +664,12 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings,
|
||||
Some(acc.map(|v| v / n))
|
||||
}
|
||||
|
||||
/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a
|
||||
/// fresh evaluation.
|
||||
/// The plane an overlay shows: a sharpening view's detail plane, or the alpha of a mask overlay
|
||||
/// (the one the render evaluated, or for a hidden mask a fresh evaluation).
|
||||
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
|
||||
if o.reads_detail() {
|
||||
return visualize::sharpen_plane(o, &plan.settings, &prep.log_l, prep.texture_blur.as_deref(), plan.px_per_long);
|
||||
}
|
||||
let m = o.mask(&plan.settings)?;
|
||||
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
|
||||
return Some(e.alpha.clone());
|
||||
|
||||
@@ -557,6 +557,11 @@ pub struct PlaneSigmas {
|
||||
pub chroma: Option<f32>,
|
||||
}
|
||||
|
||||
/// Gaussian sigma (pixels) of the texture / sharpening band at `px_per_long`.
|
||||
pub 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;
|
||||
// A hidden mask, or one with no components, does not change the picture. Its sliders must
|
||||
@@ -581,7 +586,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().filter(live).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().filter(live).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,6 +9,11 @@
|
||||
//! - **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`].
|
||||
//! - **Alt/Option-drag previews** (Lightroom's temporary views while a slider is dragged with the
|
||||
//! modifier held; [`Overlay::for_control`] picks one per control): shadow / highlight clipping
|
||||
//! (white or black, with the clipped channels shown), greyscale (Sharpening Amount), the fine
|
||||
//! detail band sharpening works on (Radius / Detail) and the sharpening mask (Masking). The
|
||||
//! sharpening views read the render's own detail planes ([`sharpen_plane`]).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -39,6 +44,19 @@ 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 (Alt-drag on Blacks / Shadows): white, a channel at 0 drops out of the
|
||||
/// colour, so pixels clipped in every channel are black and partly clipped ones are coloured.
|
||||
ClipShadows,
|
||||
/// Highlight clipping (Alt-drag on Exposure / Highlights / Whites): black, a channel at 255
|
||||
/// shows in the colour, so pixels clipped in every channel are white.
|
||||
ClipHighlights,
|
||||
/// The photo in greyscale (Alt-drag on Sharpening Amount).
|
||||
Grayscale,
|
||||
/// The fine detail band sharpening amplifies, as grey ± detail (Alt-drag on Sharpening Radius
|
||||
/// / Detail).
|
||||
SharpenDetail,
|
||||
/// The sharpening mask, white where sharpening applies (Alt-drag on Sharpening Masking).
|
||||
SharpenMask,
|
||||
}
|
||||
|
||||
/// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white.
|
||||
@@ -125,6 +143,11 @@ 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::ClipShadows => (6, 0.0),
|
||||
Overlay::ClipHighlights => (7, 0.0),
|
||||
Overlay::Grayscale => (8, 0.0),
|
||||
Overlay::SharpenDetail => (9, 0.0),
|
||||
Overlay::SharpenMask => (10, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +166,11 @@ impl Overlay {
|
||||
opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8,
|
||||
}
|
||||
}
|
||||
6 => Overlay::ClipShadows,
|
||||
7 => Overlay::ClipHighlights,
|
||||
8 => Overlay::Grayscale,
|
||||
9 => Overlay::SharpenDetail,
|
||||
10 => Overlay::SharpenMask,
|
||||
_ => Overlay::None,
|
||||
}
|
||||
}
|
||||
@@ -156,9 +184,44 @@ 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::ClipShadows => 0x5000,
|
||||
Overlay::ClipHighlights => 0x5001,
|
||||
Overlay::Grayscale => 0x5002,
|
||||
Overlay::SharpenDetail => 0x5003,
|
||||
Overlay::SharpenMask => 0x5004,
|
||||
}
|
||||
}
|
||||
|
||||
/// The temporary preview Lightroom shows while develop control `id` is dragged with Alt
|
||||
/// (Option on macOS) held, if it has one.
|
||||
pub fn for_control(id: &str) -> Option<Overlay> {
|
||||
match id {
|
||||
"light.exposure" | "light.highlights" | "light.whites" => Some(Overlay::ClipHighlights),
|
||||
"light.shadows" | "light.blacks" => Some(Overlay::ClipShadows),
|
||||
"detail.sharpenAmount" | "detail.nrLuminance" | "detail.nrDetail" | "detail.nrContrast" => Some(Overlay::Grayscale),
|
||||
"detail.sharpenRadius" | "detail.sharpenDetail" => Some(Overlay::SharpenDetail),
|
||||
"detail.sharpenMasking" => Some(Overlay::SharpenMask),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipping previews, which test the sRGB values the histogram counts (a render for a
|
||||
/// display profile draws them in sRGB, then converts).
|
||||
pub fn is_clipping(self) -> bool {
|
||||
matches!(self, Overlay::ClipShadows | Overlay::ClipHighlights)
|
||||
}
|
||||
|
||||
/// Whether the overlay draws from the render's detail planes ([`sharpen_plane`]).
|
||||
pub fn reads_detail(self) -> bool {
|
||||
matches!(self, Overlay::SharpenDetail | Overlay::SharpenMask)
|
||||
}
|
||||
|
||||
/// Whether the overlay works pixel by pixel on the finished image, so a window render shows
|
||||
/// the same as the whole frame would.
|
||||
pub fn per_pixel(self) -> bool {
|
||||
matches!(self, Overlay::None | Overlay::Mask { .. } | Overlay::ClipShadows | Overlay::ClipHighlights | Overlay::Grayscale)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -180,10 +243,45 @@ 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.
|
||||
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) {
|
||||
/// The plane a sharpening view draws ([`Overlay::reads_detail`]), from the render's log luminance
|
||||
/// `log_l` and its texture blur (`None` when the render didn't need it: blurred here with the same
|
||||
/// sigma). [`Overlay::SharpenDetail`]: the detail band (log luminance minus its blur, in EV);
|
||||
/// [`Overlay::SharpenMask`]: the sharpening weight under the photo's Masking. Both renderers call
|
||||
/// this with their own planes.
|
||||
pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, texture: Option<&Plane>, px_per_long: f64) -> Option<Plane> {
|
||||
if !o.reads_detail() {
|
||||
return None;
|
||||
}
|
||||
let own;
|
||||
let blur = match texture.filter(|b| (b.width, b.height) == (log_l.width, log_l.height)) {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
own = lightcraft_raster::blur::gaussian(log_l, crate::local::texture_sigma(px_per_long));
|
||||
&own
|
||||
}
|
||||
};
|
||||
let masking = (s.detail.sharpen_masking / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let mask = o == Overlay::SharpenMask;
|
||||
let data = log_l
|
||||
.data
|
||||
.iter()
|
||||
.zip(&blur.data)
|
||||
.map(|(l, b)| {
|
||||
let det = l - b;
|
||||
if mask { crate::finish::sharpen_mask_weight(masking, det) } else { det }
|
||||
})
|
||||
.collect();
|
||||
Some(Plane { width: log_l.width, height: log_l.height, data })
|
||||
}
|
||||
|
||||
/// [`Overlay::SharpenDetail`]: encoded grey per EV of detail (±0.4 EV spans black to white).
|
||||
const DETAIL_GAIN: f32 = 320.0;
|
||||
|
||||
/// Draw overlay `o` over `img` (rendered with `plan`). `plane` is what the overlay shows, at the
|
||||
/// image's size: the evaluated alpha of the mask an [`Overlay::Mask`] shows (see
|
||||
/// [`Overlay::mask`]) or a sharpening view's [`sharpen_plane`]; `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<'_>, plane: Option<&Plane>, masks: Option<&[Plane]>) {
|
||||
match o {
|
||||
// drawn by the renderer from the HDR rendition ([`hdr_range`])
|
||||
Overlay::None | Overlay::HdrRange => {}
|
||||
@@ -194,10 +292,27 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>,
|
||||
}
|
||||
Overlay::Spots(t) => spots(img, t, plan.px_per_long),
|
||||
Overlay::Mask { view, color, opacity, .. } => {
|
||||
if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) {
|
||||
if let Some(a) = plane.filter(|a| (a.width, a.height) == (img.width, img.height)) {
|
||||
mask_view(img, a, view, color, opacity);
|
||||
}
|
||||
}
|
||||
Overlay::ClipShadows => clipping(img, false),
|
||||
Overlay::ClipHighlights => clipping(img, true),
|
||||
Overlay::Grayscale => grayscale(img),
|
||||
Overlay::SharpenDetail | Overlay::SharpenMask => {
|
||||
if let Some(p) = plane.filter(|a| (a.width, a.height) == (img.width, img.height)) {
|
||||
let detail = o == Overlay::SharpenDetail;
|
||||
let w = img.width;
|
||||
for_rows(&mut img.data, w, |y, row| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let v = p.data[y * w + x];
|
||||
let g = if detail { 128.0 + v * DETAIL_GAIN } else { 255.0 * v };
|
||||
let g = g.round().clamp(0.0, 255.0) as u8;
|
||||
*px = [g, g, g, 255];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Overlay::AllMasks { view, color, opacity } => {
|
||||
// one view of the union of the masks: painting the planes one after another
|
||||
// overwrites (White on Black, Image on Black…) or compounds (Color) the earlier ones
|
||||
@@ -215,6 +330,36 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Overlay::ClipHighlights`] (`high`) or [`Overlay::ClipShadows`]: each channel shows only
|
||||
/// whether it is clipped, on black (highlights) or white (shadows).
|
||||
pub fn clipping(img: &mut Rgba8, high: bool) {
|
||||
let w = img.width;
|
||||
for_rows(&mut img.data, w, |_, row| {
|
||||
for px in row.iter_mut() {
|
||||
for c in &mut px[..3] {
|
||||
*c = if high {
|
||||
if *c == 255 { 255 } else { 0 }
|
||||
} else if *c == 0 {
|
||||
0
|
||||
} else {
|
||||
255
|
||||
};
|
||||
}
|
||||
px[3] = 255;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn grayscale(img: &mut Rgba8) {
|
||||
let w = img.width;
|
||||
for_rows(&mut img.data, w, |_, row| {
|
||||
for px in row.iter_mut() {
|
||||
let g = (0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32).round().clamp(0.0, 255.0) as u8;
|
||||
*px = [g, g, g, 255];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw mask alpha `a` over `img` as `view`.
|
||||
pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opacity: u8) {
|
||||
let op = opacity.min(100) as f32 / 100.0;
|
||||
@@ -409,6 +554,121 @@ mod tests {
|
||||
assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_views_show_the_clipped_channels() {
|
||||
let mut img = Rgba8 {
|
||||
width: 5,
|
||||
height: 1,
|
||||
data: vec![[0, 0, 0, 255], [255, 255, 255, 255], [255, 40, 0, 255], [0, 128, 200, 255], [120, 130, 140, 255]],
|
||||
};
|
||||
let mut hi = img.clone();
|
||||
clipping(&mut hi, true);
|
||||
// black background, white where every channel clips, the clipped channels' colour otherwise
|
||||
assert_eq!(hi.data, vec![[0, 0, 0, 255], [255; 4], [255, 0, 0, 255], [0, 0, 0, 255], [0, 0, 0, 255]]);
|
||||
clipping(&mut img, false);
|
||||
// white background, black where every channel clips, the unclipped channels' colour otherwise
|
||||
assert_eq!(img.data, vec![[0, 0, 0, 255], [255; 4], [255, 255, 0, 255], [0, 255, 255, 255], [255; 4]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_previews_follow_the_rendered_clipping() {
|
||||
// left: deep shadow, middle: grey, right: bright highlight
|
||||
let src = Rgb32f::from_fn(60, 20, |x, _| match x / 20 {
|
||||
0 => [0.0; 3],
|
||||
1 => [0.18; 3],
|
||||
_ => [3.0; 3],
|
||||
});
|
||||
let info = SourceInfo::default();
|
||||
let mut s = DevelopSettings::default();
|
||||
s.light.blacks = -100.0;
|
||||
s.light.whites = 100.0;
|
||||
let req = |o| RenderRequest { overlay: o, ..RenderRequest::fit(60, 20) };
|
||||
let plain = render(&src, &info, &s, &req(Overlay::None));
|
||||
let hi = render(&src, &info, &s, &req(Overlay::ClipHighlights));
|
||||
let lo = render(&src, &info, &s, &req(Overlay::ClipShadows));
|
||||
let at = |i: &Rgba8, x: usize| i.data[10 * 60 + x];
|
||||
assert_eq!(at(&plain.image, 55), [255; 4], "the highlight clips");
|
||||
assert_eq!(at(&plain.image, 5), [0, 0, 0, 255], "the shadow clips");
|
||||
assert_eq!((at(&hi.image, 55), at(&hi.image, 30), at(&hi.image, 5)), ([255; 4], [0, 0, 0, 255], [0, 0, 0, 255]));
|
||||
assert_eq!((at(&lo.image, 5), at(&lo.image, 30), at(&lo.image, 55)), ([0, 0, 0, 255], [255; 4], [255; 4]));
|
||||
// a preview only: the histogram is the photo's
|
||||
assert_eq!(hi.histogram, plain.histogram);
|
||||
assert_eq!(render(&src, &info, &s, &req(Overlay::None)).image, plain.image);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharpening_previews_show_the_mask_and_detail() {
|
||||
// a smooth ramp (left) and a hard edge (at x = 60)
|
||||
let src = Rgb32f::from_fn(80, 40, |x, _| if x < 60 { [0.05 + 0.002 * x as f32; 3] } else { [0.6; 3] });
|
||||
let info = SourceInfo::default();
|
||||
let mut s = DevelopSettings::default();
|
||||
s.detail.sharpen_amount = 60.0;
|
||||
let shot = |s: &DevelopSettings, o| render(&src, &info, s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
|
||||
let (flat, edge) = (20 * 80 + 20, 20 * 80 + 60);
|
||||
// Masking 0 sharpens everything: an all-white mask
|
||||
let m = shot(&s, Overlay::SharpenMask);
|
||||
assert!(m.data.iter().all(|p| *p == [255; 4]));
|
||||
// Masking 100 keeps the edge, protects the ramp
|
||||
s.detail.sharpen_masking = 100.0;
|
||||
let m = shot(&s, Overlay::SharpenMask);
|
||||
assert_eq!((m.data[edge], m.data[flat]), ([255; 4], [0, 0, 0, 255]));
|
||||
// the same without sharpening on (the render has no texture plane then)
|
||||
s.detail.sharpen_amount = 0.0;
|
||||
assert_eq!(shot(&s, Overlay::SharpenMask), m);
|
||||
// detail: mid grey on the ramp, light / dark either side of the edge
|
||||
let d = shot(&s, Overlay::SharpenDetail);
|
||||
assert!(d.data[flat][0].abs_diff(128) <= 3, "{:?}", d.data[flat]);
|
||||
assert!(d.data[edge][0] > 200 && d.data[edge - 1][0] < 60, "{:?} {:?}", d.data[edge], d.data[edge - 1]);
|
||||
// greyscale
|
||||
let src = Rgb32f::from_fn(8, 8, |_, _| [0.4, 0.1, 0.05]);
|
||||
let g = render(&src, &info, &s, &RenderRequest { overlay: Overlay::Grayscale, ..RenderRequest::fit(8, 8) }).image;
|
||||
assert!(g.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[0] > 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_previews_map_to_controls_and_round_trip() {
|
||||
assert_eq!(Overlay::for_control("light.blacks"), Some(Overlay::ClipShadows));
|
||||
assert_eq!(Overlay::for_control("light.shadows"), Some(Overlay::ClipShadows));
|
||||
assert_eq!(Overlay::for_control("light.whites"), Some(Overlay::ClipHighlights));
|
||||
assert_eq!(Overlay::for_control("light.exposure"), Some(Overlay::ClipHighlights));
|
||||
assert_eq!(Overlay::for_control("light.highlights"), Some(Overlay::ClipHighlights));
|
||||
assert_eq!(Overlay::for_control("detail.sharpenAmount"), Some(Overlay::Grayscale));
|
||||
assert_eq!(Overlay::for_control("detail.sharpenDetail"), Some(Overlay::SharpenDetail));
|
||||
assert_eq!(Overlay::for_control("detail.sharpenMasking"), Some(Overlay::SharpenMask));
|
||||
for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast"] {
|
||||
assert_eq!(Overlay::for_control(c), Some(Overlay::Grayscale), "{c}");
|
||||
}
|
||||
assert_eq!(Overlay::for_control("detail.nrColor"), None);
|
||||
assert_eq!(Overlay::for_control("light.contrast"), None);
|
||||
// every mapped id is a real control (a rename would silently drop its preview)
|
||||
for c in [
|
||||
"light.exposure",
|
||||
"light.highlights",
|
||||
"light.whites",
|
||||
"light.shadows",
|
||||
"light.blacks",
|
||||
"detail.sharpenAmount",
|
||||
"detail.sharpenRadius",
|
||||
"detail.sharpenDetail",
|
||||
"detail.sharpenMasking",
|
||||
"detail.nrLuminance",
|
||||
"detail.nrDetail",
|
||||
"detail.nrContrast",
|
||||
] {
|
||||
assert!(lightcraft_develop::controls::find(c).is_some(), "{c}");
|
||||
assert!(Overlay::for_control(c).is_some(), "{c}");
|
||||
}
|
||||
assert!(Overlay::ClipShadows.is_clipping() && Overlay::ClipHighlights.is_clipping() && !Overlay::Grayscale.is_clipping());
|
||||
let all = [Overlay::ClipShadows, Overlay::ClipHighlights, Overlay::Grayscale, Overlay::SharpenDetail, Overlay::SharpenMask];
|
||||
for o in all {
|
||||
let (k, v) = o.to_parts();
|
||||
assert_eq!(Overlay::from_parts(k, v), o);
|
||||
assert_eq!(all.iter().filter(|p| p.key() == o.key()).count(), 1);
|
||||
assert_ne!(o.key(), Overlay::HdrRange.key());
|
||||
}
|
||||
assert!(Overlay::ClipShadows.per_pixel() && !Overlay::SharpenDetail.per_pixel());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_masks_overlay_shows_every_visible_mask_at_once() {
|
||||
use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape};
|
||||
|
||||
@@ -230,6 +230,8 @@ impl LightcraftApp {
|
||||
self.import_dropped_files(ctx);
|
||||
}
|
||||
self.check_external_editor(ctx);
|
||||
// Alt (Option on macOS) while a slider is dragged shows its temporary preview
|
||||
self.ui.alt_held = ctx.input(|i| i.modifiers.alt);
|
||||
// Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI
|
||||
// phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract).
|
||||
panels::library_problem::logic(self, ctx);
|
||||
|
||||
@@ -911,13 +911,20 @@ 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,
|
||||
/// Visualize Spots).
|
||||
/// The diagnostic overlay the loupe shows (an Alt-drag preview, the selected mask, Point Color's
|
||||
/// visualized range, Visualize Spots).
|
||||
pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
|
||||
use lightcraft_pipeline::{MaskView, Overlay};
|
||||
if app.ui.fullscreen {
|
||||
return Overlay::None;
|
||||
}
|
||||
// Lightroom's temporary previews: clipping, greyscale, sharpening detail / mask
|
||||
if app.ui.right == RightPanel::Edit
|
||||
&& app.ui.alt_held
|
||||
&& let Some(o) = app.ui.dragging_control.as_deref().and_then(Overlay::for_control)
|
||||
{
|
||||
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()) {
|
||||
|
||||
@@ -9,6 +9,9 @@ pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) ->
|
||||
use lightcraft_pipeline::Overlay as O;
|
||||
match overlay {
|
||||
O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true,
|
||||
// clipping and greyscale previews act pixel by pixel on the finished image
|
||||
o if o.per_pixel() => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,10 @@ pub struct UiState {
|
||||
/// The develop control whose slider is being dragged (geometry sliders show a grid).
|
||||
#[serde(skip)]
|
||||
pub dragging_control: Option<String>,
|
||||
/// Alt (Option on macOS) is held this frame: a dragged Edit slider shows its temporary
|
||||
/// preview ([`lightcraft_pipeline::Overlay::for_control`]).
|
||||
#[serde(skip)]
|
||||
pub alt_held: bool,
|
||||
pub search: String,
|
||||
/// Focus the search field on the next frame (Edit → Find…).
|
||||
#[serde(skip)]
|
||||
@@ -418,6 +422,7 @@ impl Default for UiState {
|
||||
unnamed_focus: false,
|
||||
hidden_locations: Vec::new(),
|
||||
dragging_control: None,
|
||||
alt_held: false,
|
||||
external_edits: Vec::new(),
|
||||
was_focused: true,
|
||||
auto_import_at: 0.0,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod activity;
|
||||
mod album_picker;
|
||||
mod album_remove;
|
||||
mod alt_preview;
|
||||
mod appearance;
|
||||
mod before_after;
|
||||
mod browse_active;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Headless test of the Alt-drag previews (issue #937) through real input: a slider drag, Alt
|
||||
//! (Option) pressed and released during it, and the loupe's pixels at each step.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
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 Alt");
|
||||
// Alt 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));
|
||||
// Alt 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}");
|
||||
}
|
||||
@@ -2385,6 +2385,43 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() {
|
||||
assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_while_dragging_an_edit_slider_shows_its_preview() {
|
||||
use crate::state::RightPanel;
|
||||
use lightcraft_pipeline::Overlay;
|
||||
let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "edit"}));
|
||||
let id = h.app.session.active().expect("active photo");
|
||||
let d = (*h.app.session.develop_of(id).unwrap_or_default()).clone();
|
||||
let view = |h: &Headless| crate::panels::detail::view_overlay(&h.app, &d);
|
||||
assert_eq!(h.app.ui.right, RightPanel::Edit);
|
||||
h.app.ui.dragging_control = Some("light.blacks".into());
|
||||
assert_eq!(view(&h), Overlay::None, "dragging without Alt shows the photo");
|
||||
h.app.ui.alt_held = true;
|
||||
assert_eq!(view(&h), Overlay::ClipShadows);
|
||||
for (c, o) in [
|
||||
("light.whites", Overlay::ClipHighlights),
|
||||
("light.exposure", Overlay::ClipHighlights),
|
||||
("detail.sharpenAmount", Overlay::Grayscale),
|
||||
("detail.sharpenRadius", Overlay::SharpenDetail),
|
||||
("detail.sharpenMasking", Overlay::SharpenMask),
|
||||
("light.contrast", Overlay::None),
|
||||
] {
|
||||
h.app.ui.dragging_control = Some(c.into());
|
||||
assert_eq!(view(&h), o, "{c}");
|
||||
}
|
||||
// Alt alone (no drag) or released mid-drag: back to the photo
|
||||
h.app.ui.dragging_control = None;
|
||||
assert_eq!(view(&h), Overlay::None);
|
||||
h.app.ui.dragging_control = Some("light.whites".into());
|
||||
h.app.ui.alt_held = false;
|
||||
assert_eq!(view(&h), Overlay::None);
|
||||
// the Masking panel's local Blacks slider is not a global clipping preview
|
||||
h.app.ui.alt_held = true;
|
||||
h.app.ui.right = RightPanel::Masking;
|
||||
h.app.ui.dragging_control = Some("light.blacks".into());
|
||||
assert_eq!(view(&h), Overlay::None);
|
||||
}
|
||||
|
||||
/// An album's context menu ▸ Import ▸ Import from Folder… (and Import Photos…, Import from Device)
|
||||
/// opens the import review with that album chosen; a folder row doesn't offer it, and an `album`
|
||||
/// the review can't offer (a folder, or an album deleted while the scan ran) leaves its album empty.
|
||||
|
||||
@@ -178,12 +178,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` | Alt/Option-drag shows highlight clipping (black, clipped channels in colour) |
|
||||
| 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-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. Alt/Option-drag shows highlight clipping |
|
||||
| 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). Alt/Option-drag shows shadow clipping (white, clipped channels in colour) |
|
||||
| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites`, `crates/pipeline/src/visualize.rs` | Alt/Option-drag shows highlight clipping (black, clipped channels in colour) |
|
||||
| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks`, `crates/pipeline/src/visualize.rs` | Alt/Option-drag shows shadow clipping (white, clipped channels in colour) |
|
||||
| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*`, `crates/pipeline/src/curve.rs` | the graph draws the renderer's own curve model; dragging the line moves the region under the pointer (one undo step) |
|
||||
| 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`) | |
|
||||
@@ -204,8 +204,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`, `crates/pipeline/src/visualize.rs` | Amount and Masking work; Radius and Detail are stored (XMP round trip) but do not change the render yet (the band is a fixed 0.18 % of the long edge). Alt/Option-drag previews: Amount greyscale, Radius / Detail the detail band sharpening amplifies, Masking the mask (white = sharpened) |
|
||||
| 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). Alt/Option-drag on Luminance, Detail and Contrast shows the photo in greyscale |
|
||||
| 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 | ⬜ | | |
|
||||
@@ -925,6 +925,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 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-21 | minor | LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; reference pins stay within their catalog, unavailable or malformed photo ids refused, deleted reference not drawn and its pane released, a switch leaves Reference view; statuses unchanged |
|
||||
| 2026-10-11 | minor | Alt/Option-drag previews on Exposure, Highlights, Shadows, Whites, Blacks and Sharpening (issue #937); LR-EDIT-DETAIL-SHARPEN ✅ → 🟡: Radius and Detail don't change 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 |
|
||||
|
||||
@@ -76,6 +76,15 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
|
||||
**Keyword sets (KEYC-METADATA):** `⌥0` / `⌥⇧0` (`Alt+0` / `Alt+Shift+0` on Windows and Linux) make the next / previous keyword set current (`keyword.nextSet`, `keyword.previousSet`), in the Keyword Set menu's order: Recent Keywords, then the saved sets. After the last set comes Recent Keywords, and before Recent Keywords comes the last set (not documented by Adobe, our choice). `⌥1`–`⌥9` then apply the new set's keywords. Both keys can be reassigned in Help → Keyboard Shortcuts.
|
||||
|
||||
**Alt/Option-drag previews (issue #937):** hold Alt (Option on macOS) while dragging an Edit slider, before or during
|
||||
the drag. Exposure, Highlights and Whites show highlight clipping (black, clipped channels in their colour, white where
|
||||
all three clip); Shadows and Blacks show shadow clipping (white, black where all three clip). Sharpening Amount shows
|
||||
greyscale, Radius and Detail the detail band sharpening amplifies, Masking the mask (white = sharpened); luminance Noise
|
||||
Reduction (Luminance, Detail, Contrast) shows greyscale. Releasing the key or the slider brings the photo back; the
|
||||
edit, its history and the histogram are unchanged (on a profiled display too). Not yet: mask range sliders and the
|
||||
Masking panel's local sliders. On Linux desktops that move windows with Alt+drag (KDE Plasma's
|
||||
default, also some GNOME / Xfce setups) the window manager takes the drag first: set its window-move modifier to Meta.
|
||||
|
||||
**Still missing / broken:**
|
||||
- Tab / ⇧Tab (hide the side panels / all panels): Tab moves the keyboard focus here (accessibility), so it is
|
||||
not bound; F5 (hide the top panel) is not bound either.
|
||||
@@ -168,5 +177,6 @@ establish OS keyboard delivery or cross-platform acceptance.
|
||||
| 2026-10-14 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed |
|
||||
| 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h |
|
||||
| 2026-10-21 | minor | Reference Escape exits to Detail; catalog-local pins and missing/deleted reference handling verified with regressions and native Windows smoke |
|
||||
| 2026-10-11 | minor | Alt/Option-drag slider previews (clipping, sharpening) and the Linux window-manager note |
|
||||
| 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing |
|
||||
| 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |
|
||||
|
||||
@@ -903,6 +903,18 @@
|
||||
- The choice is saved in export presets and Export with Previous. Agents pass
|
||||
`destinationMode: "sameAsOriginal"` (or `"specificFolder"`) to `app.export`; `dir` is then not needed.
|
||||
|
||||
### Alt/Option-drag previews
|
||||
- Hold Alt (Option on macOS) while dragging Exposure, Highlights or Whites to see which areas clip to white: the
|
||||
photo turns black, fully clipped areas show white and areas clipped in one or two channels show those channels'
|
||||
colour. Shadows and Blacks do the same for clipping to black, on white (issue #937).
|
||||
- In Detail ▸ Sharpening the same key shows Amount in greyscale, Radius and Detail as the fine detail being
|
||||
sharpened, and Masking as the mask: white is sharpened, black is protected. Radius and Detail don't change the
|
||||
render yet, so their preview stays the same while you drag them. Noise Reduction's Luminance, Detail and Contrast
|
||||
show the photo in greyscale.
|
||||
- The preview lasts while the key and the slider are held, and never changes the edit, its history or exports.
|
||||
- Linux: KDE Plasma (and some other desktops) move windows with Alt+drag. If Alt+drag moves the window instead,
|
||||
set the window manager's move modifier to Meta.
|
||||
|
||||
### Open Library knows a Lightroom catalog
|
||||
- Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /
|
||||
`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The
|
||||
|
||||
Reference in New Issue
Block a user