Merge track r3-t09

# Conflicts:
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/ui-parity.md
#	docs/whats-new.md
This commit is contained in:
aiongg
2026-10-31 02:31:25 +00:00
47 changed files with 1369 additions and 117 deletions
+32 -3
View File
@@ -56,6 +56,10 @@ impl MonotoneCurve {
}
pub fn eval(&self, x: f64) -> f64 {
// NaN passes both bound checks below and would send the search past either end
if x.is_nan() {
return x;
}
let n = self.xs.len();
if x <= self.xs[0] {
return self.ys[0] + self.ms[0] * (x - self.xs[0]).min(0.0) * 0.0;
@@ -76,8 +80,9 @@ impl MonotoneCurve {
+ (t3 - t2) * h * self.ms[i + 1]
}
/// Sample into a LUT of `n` entries over `0..1`.
/// Sample into a LUT of `n` entries (at least 2) over `0..1`.
pub fn to_lut(&self, n: usize) -> Lut1 {
let n = n.max(2);
Lut1 { v: (0..n).map(|i| self.eval(i as f64 / (n - 1) as f64) as f32).collect() }
}
@@ -93,16 +98,24 @@ pub struct Lut1 {
}
impl Lut1 {
/// At least 2 entries, like [`Lut1::from_fn`].
pub fn identity(n: usize) -> Lut1 {
Lut1 { v: (0..n).map(|i| i as f32 / (n - 1) as f32).collect() }
Lut1::from_fn(n, |x| x)
}
/// `f` sampled at `n` evenly spaced points over `0..1` (at least 2).
pub fn from_fn(n: usize, f: impl Fn(f32) -> f32) -> Lut1 {
let n = n.max(2);
Lut1 { v: (0..n).map(|i| f(i as f32 / (n - 1) as f32)).collect() }
}
/// `v` is public, so a table shorter than 2 entries is read as constant (1) or the identity (0).
#[inline]
pub fn eval(&self, x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
let n = self.v.len();
let f = x.clamp(0.0, 1.0) * (n - 1) as f32;
if n < 2 {
return self.v.first().copied().unwrap_or(x);
}
let f = x * (n - 1) as f32;
let i = (f as usize).min(n - 2);
let t = f - i as f32;
self.v[i] + (self.v[i + 1] - self.v[i]) * t
@@ -163,6 +176,22 @@ mod tests {
assert!((c.eval(0.3) - 0.3).abs() < 2e-3);
}
#[test]
fn nan_input_and_short_luts_do_not_panic() {
let c = MonotoneCurve::new(&[(0.0, 0.0), (0.5, 0.6), (1.0, 1.0)]);
assert!(c.eval(f64::NAN).is_nan());
assert!(c.eval(-f64::NAN).is_nan());
for n in [0, 1] {
assert_eq!(c.to_lut(n).v.len(), 2, "{n}");
assert!(Lut1::identity(n).is_identity(), "{n}");
assert_eq!(Lut1::from_fn(n, |x| x).v.len(), 2, "{n}");
}
assert_eq!(Lut1 { v: vec![] }.eval(0.3), 0.3);
assert_eq!(Lut1 { v: vec![] }.eval(-1.0), 0.0, "inputs outside 0..1 clamp, as for any table");
assert_eq!(Lut1 { v: vec![] }.eval(2.0), 1.0);
assert_eq!(Lut1 { v: vec![0.7] }.eval(0.3), 0.7);
}
proptest::proptest! {
#[test]
fn monotone_for_increasing_data(ys in proptest::collection::vec(0.0f64..1.0, 3..8)) {
+30
View File
@@ -97,6 +97,10 @@ impl ControlSpec {
}
pub fn format(&self, v: f64) -> String {
let s = format!("{:.*}", self.decimals as usize, v);
if v.is_finite() && !s.bytes().any(|b| matches!(b, b'1'..=b'9')) {
// Rounds to zero: no sign.
return s.trim_start_matches('-').to_string();
}
if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s }
}
/// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin
@@ -410,6 +414,9 @@ pub static INDEXED: &[ControlSpec] = &[
fn split_indexed(id: &str) -> Option<(&str, usize, &str)> {
let mut it = id.splitn(3, '.');
let (fam, i, field) = (it.next()?, it.next()?, it.next()?);
if i.is_empty() || !i.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some((fam, i.parse().ok()?, field))
}
@@ -493,6 +500,17 @@ pub fn in_section(section: Section) -> impl Iterator<Item = &'static ControlSpec
mod tests {
use super::*;
#[test]
fn format_shows_no_sign_on_a_value_that_rounds_to_zero() {
let e = find("light.exposure").unwrap();
assert_eq!(e.format(0.004), "0.00");
assert_eq!(e.format(-0.004), "0.00");
assert_eq!(e.format(0.5), "+0.50");
assert_eq!(e.format(-0.5), "-0.50");
assert_eq!(e.format(f64::INFINITY), "+inf");
assert_eq!(e.format(f64::NEG_INFINITY), "-inf");
}
#[test]
fn every_control_reads_its_default() {
let s = DevelopSettings::default();
@@ -538,6 +556,18 @@ mod tests {
}
}
#[test]
fn signed_indexes_are_unknown_ids() {
let mut s = DevelopSettings::default();
s.point_colors.push(Default::default());
let before = s.point_colors[0].hue_shift;
assert!(!set(&mut s, "pointColor.+0.hueShift", 10.0), "signed index");
assert_eq!(s.point_colors[0].hue_shift, before);
assert_eq!(get(&s, "pointColor.+0.hueShift"), None);
assert!(find("pointColor.+0.hueShift").is_none());
assert!(find("redEye.+0.darken").is_none());
}
#[test]
fn formatting() {
let e = find("light.exposure").unwrap();
+27 -1
View File
@@ -155,7 +155,8 @@ impl DevelopSettings {
}
}
/// Reset every control in `section` to its default.
/// Reset every control in `section` to its default, and the settings of the section that are not
/// controls (the creative profile, the curve, the white balance mode).
pub fn reset_section(&mut self, section: Section) {
for c in controls::in_section(section) {
controls::set(self, c.id, c.default);
@@ -167,6 +168,11 @@ impl DevelopSettings {
}
Section::Color => self.wb.mode = WbMode::AsShot,
Section::Detail => self.enhance.denoise_on = None,
// the creative profile is not a control: the chosen look goes back to the default one,
// and so does the camera's own local tone mapping
Section::Profile => {
self.profile = Profile::default();
}
_ => {}
}
}
@@ -326,6 +332,26 @@ mod tests {
assert!(s.is_unedited());
}
#[test]
fn resetting_the_profile_section_restores_the_default_profile() {
// the chosen creative profile is not a control, so resetting the section must restore it too
let mut s = DevelopSettings::default();
s.profile.id = "lc.muted.matte-soft".into();
s.profile.amount = 40.0;
s.reset_section(Section::Profile);
assert_eq!(s.profile.id, "lc.color", "the creative profile is reset to the default one");
assert_eq!(s.profile.amount, 100.0);
}
#[test]
fn resetting_the_profile_section_clears_the_camera_local_tone() {
// the camera's local tone mapping is not a control either: a Profile reset switches it off
let mut s = DevelopSettings::default();
s.profile.camera_local_tone = true;
s.reset_section(Section::Profile);
assert!(!s.profile.camera_local_tone);
}
#[test]
fn section_toggles() {
let mut s = DevelopSettings::default();
+5
View File
@@ -134,6 +134,7 @@ pub struct DevelopSettings {
pub orientation: Orientation,
pub masks: Vec<Mask>,
pub spots: Vec<Spot>,
/// Red eye corrections; the first [`MAX_RED_EYES`] render.
pub red_eye: Vec<RedEye>,
pub lens_blur: LensBlur,
pub enhance: Enhance,
@@ -1069,6 +1070,10 @@ impl Default for Spot {
}
}
/// Most red eye / pet eye corrections a photo renders. Every output pixel goes through every eye, so render cost is
/// pixels x eyes; a sidecar, preset or agent with thousands could stall a render for hours (issue #2556).
pub const MAX_RED_EYES: usize = 64;
/// A red eye / pet eye correction: the user's ellipse (centre normalized, radii as fractions of
/// the long edge); the pupil inside it is found automatically.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -409,7 +409,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Section",
[],
None,
"{section: light|curve|color|mixer|grading|effects|vignette|grain|detail|optics|geometry}",
"{section: profile|light|curve|color|mixer|grading|effects|vignette|grain|detail|optics|geometry}",
has_active,
|s, p| {
let sec = section_param(p, "develop.resetSection")?;
+4 -1
View File
@@ -1,6 +1,6 @@
//! Masking and Remove (spot) commands on the active photo.
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode};
use lightcraft_develop::{BrushStroke, LocalAdjustments, MAX_RED_EYES, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode};
use lightcraft_geom::Point;
use serde_json::{Value, json};
@@ -1025,6 +1025,9 @@ pub fn specs() -> Vec<CommandSpec> {
catchlight: None,
};
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
if s.develop_of(id).is_some_and(|d| d.red_eye.len() >= MAX_RED_EYES) {
return Err(bad(c, format!("at most {MAX_RED_EYES} red eye corrections")));
}
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
dd.red_eye.push(eye);
let n = dd.red_eye.len();
+19
View File
@@ -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()));
+14 -1
View File
@@ -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);
+6
View File
@@ -75,6 +75,12 @@ fn red_eye_commands_and_controls() {
assert_eq!(active_dev(&s).red_eye.len(), 1);
assert!(s.execute("redeye.delete", &json!({"index": 5})).is_err());
assert!(s.execute("redeye.add", &json!({"rx": 0.03})).is_err());
// at most MAX_RED_EYES: every pixel goes through every eye (issue #2556)
while active_dev(&s).red_eye.len() < lightcraft_develop::MAX_RED_EYES {
s.execute("redeye.add", &json!({"center": [0.5, 0.5], "rx": 0.01, "ry": 0.01})).unwrap();
}
assert!(s.execute("redeye.add", &json!({"center": [0.5, 0.5], "rx": 0.01, "ry": 0.01})).is_err());
assert_eq!(active_dev(&s).red_eye.len(), lightcraft_develop::MAX_RED_EYES);
let d = active_dev(&s);
assert_eq!(lightcraft_develop::DevelopSettings::from_json(&d.to_json()).unwrap(), d);
}
+8
View File
@@ -786,6 +786,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 {
+28
View File
@@ -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]
+11 -2
View File
@@ -31,7 +31,7 @@ impl DateTime {
// chrono rejects impossible days (leap years and all); the time is checked
// by hand so a leap second (`second == 60`) stays acceptable.
let day_ok = self.year > 0 && chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)).is_some();
let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.abs() <= 18 * 60);
let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.unsigned_abs() <= 18 * 60);
ok.then_some(self)
}
@@ -164,7 +164,8 @@ impl DateTime {
}
if let Some(o) = self.offset_minutes {
let sign = if o < 0 { '-' } else { '+' };
s += &format!("{sign}{:02}:{:02}", o.abs() / 60, o.abs() % 60);
let o = o.unsigned_abs();
s += &format!("{sign}{:02}:{:02}", o / 60, o % 60);
}
s
}
@@ -246,6 +247,14 @@ mod tests {
assert_eq!(DateTime::parse_offset("+99:00"), None);
}
#[test]
fn extreme_offsets_do_not_overflow() {
// `DateTime` deserializes with public fields, so an offset can arrive without passing `valid()`
let d = DateTime { offset_minutes: Some(i16::MIN), ..DateTime::parse_iso("2024-06-01T13:45:59").unwrap() };
assert_eq!(d.to_iso(), "2024-06-01T13:45:59-546:08");
assert_eq!(d.valid(), None);
}
#[test]
fn unicode_offsets_are_rejected_without_panicking() {
for offset in ["+1€", "-1€", "+€1", "-€1"] {
+34 -4
View File
@@ -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;
@@ -78,6 +87,10 @@ pub const GRAIN_HASH: [u32; 4] = [0x8da6_b343, 0xd816_3841, 0xcb1a_b31f, 0x5bd1_
#[inline]
fn grain_noise(x: f32, y: f32, seed: u32) -> f32 {
// A degenerate frame (zero-width crop) maps pixels to NaN/inf coordinates: no grain there.
if !x.is_finite() || !y.is_finite() {
return 0.0;
}
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let h = |i: i32, j: i32| {
@@ -208,7 +221,15 @@ impl FinishParams {
};
let ev = s.light.exposure as f32;
let gain = 2f32.powf(ev);
let grain = (s.grain.amount > 0.0 && effects).then(|| {
// A degenerate frame (zero-width crop, zero scale) has no finite grain mapping: skip grain (CPU and GPU).
let out_to_norm = frame.out_to_norm(w, h);
let finite_frame = px_per_long.is_finite()
&& px_per_long > 0.0
&& frame.ow.is_finite()
&& frame.oh.is_finite()
&& frame.ow.max(frame.oh) > 0.0
&& out_to_norm.0.iter().all(|v| v.is_finite());
let grain = (s.grain.amount > 0.0 && effects && finite_frame).then(|| {
let cell = (0.0006 + (s.grain.size / 100.0) as f32 * 0.0024) * px_per_long as f32;
((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed)
});
@@ -240,7 +261,7 @@ impl FinishParams {
gain,
ev,
grain,
out_to_norm: frame.out_to_norm(w, h),
out_to_norm,
ow: frame.ow,
oh: frame.oh,
w,
@@ -511,7 +532,9 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
lum = lum.clamp(0.0, 1.0);
}
let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e.map(|v| v + k);
if k.is_finite() {
e = e.map(|v| v + k);
}
}
let e = match &p_lut {
Some((l, k)) if hdr => {
@@ -647,7 +670,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;
}
}
@@ -764,6 +787,13 @@ fn enc(v: f32) -> u8 {
mod tests {
use super::*;
#[test]
fn grain_noise_is_finite_for_degenerate_coordinates() {
for (x, y) in [(f32::NAN, 1.0), (1.0, f32::INFINITY), (f32::NEG_INFINITY, f32::NAN)] {
assert_eq!(grain_noise(x, y, 7), 0.0);
}
}
#[test]
fn refine_saturation_restores_the_pre_curve_saturation() {
let before = [0.5, 0.3, 0.2];
+5 -2
View File
@@ -680,9 +680,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());
+6 -1
View File
@@ -590,6 +590,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
@@ -614,7 +619,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 };
+11
View File
@@ -142,6 +142,7 @@ pub fn resolve(src: &Rgb32f, eyes: &[RedEye], orientation: Orientation, frame: &
}
let to_out = frame.norm_to_out(w, h);
eyes.iter()
.take(lightcraft_develop::MAX_RED_EYES)
.map(|e| {
let p = detect(src, orientation, frame.ow, frame.oh, e);
let r = (p.radius * (0.5 + e.pupil_size.clamp(0.0, 100.0) / 100.0)).min(e.rx.max(e.ry) * 1.1);
@@ -246,6 +247,16 @@ mod tests {
assert!(!detect(&plain, Orientation::Normal, 200.0, 100.0, &eye).found);
}
#[test]
fn at_most_max_red_eyes_render() {
// a sidecar, preset or agent can hold any number; each one costs a pass over every pixel (issue #2556)
let src = eye_image(40, 20, 18.0, 10.0, 2.0, [0.5, 0.04, 0.04]);
let s = lightcraft_develop::DevelopSettings { red_eye: vec![RedEye::default(); 10_000], ..Default::default() };
let frame = Frame::new(40, 20, &s, true);
let eyes = resolve(&src, &s.red_eye, Orientation::Normal, &frame, 40, 20, 40.0);
assert_eq!(eyes.len(), lightcraft_develop::MAX_RED_EYES);
}
#[test]
fn renders_fix_the_red_pupil_at_any_size() {
use crate::{RenderRequest, SourceInfo, render};
+16
View File
@@ -840,3 +840,19 @@ fn sliver_crops_render_quickly_with_wide_stages_on() {
assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
}
}
/// A crop of no width used to give grain NaN coordinates (and NaN pixels); the render stays finite.
#[test]
fn zero_width_crop_with_grain_renders_finite_pixels() {
let mut s = DevelopSettings::default();
s.grain.amount = 60.0;
s.crop.geometry.rect = lightcraft_geom::Rect::new(0.5, 0.1, 0.5, 0.9);
let mut req = RenderRequest::fit(240, 240);
req.depth = crate::OutputDepth::F32Linear;
let r = render(&scene(), &SourceInfo::default(), &s, &req);
if let Some(d) = r.deep
&& let crate::DeepSamples::F32(v) = d.samples
{
assert!(v.iter().all(|x| x.is_finite()), "non-finite sample in a zero-width crop with grain");
}
}
+265 -5
View File
@@ -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};
+7
View File
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 8cb6b5999a8279e655a148a18b220d75b02c519f644583f4593166286297a3f4 # shrinks to data = [0, 0, 0, 8], reads = [(false, 13, 0), (true, 0, 15)]
+61
View File
@@ -260,6 +260,53 @@ mod tests {
assert!(xp > 20.0 && xp >= xb - 0.5, "xtrans directional {xp:.1} bilinear {xb:.1}");
}
/// Mean anisotropy of a demosaiced picture's fine luminance detail: per pixel, how much more its 3 × 3 high-pass
/// correlates with its horizontal neighbours than with its vertical ones (or the reverse). Noise has none; the
/// short horizontal and vertical strokes of a maze pattern have a lot.
fn anisotropy(img: &Rgb32f) -> f64 {
let (w, h) = (img.width, img.height);
let lum: Vec<f32> = img.data.iter().map(|p| 0.25 * p[0] + 0.5 * p[1] + 0.25 * p[2]).collect();
let mean3 = |x: usize, y: usize| (0..9).map(|k| lum[(y + k / 3 - 1) * w + x + k % 3 - 1]).sum::<f32>() / 9.0;
let hp: Vec<f32> =
(0..w * h).map(|i| if i % w == 0 || i % w == w - 1 || i < w || i >= w * (h - 1) { 0.0 } else { lum[i] - mean3(i % w, i / w) }).collect();
let (mut sum, mut n) = (0f64, 0f64);
for y in 2..h - 2 {
for x in 2..w - 2 {
let i = y * w + x;
sum += f64::from((hp[i] * (hp[i - 1] + hp[i + 1] - hp[i - w] - hp[i + w])).abs());
n += 1.0;
}
}
sum / n
}
#[test]
fn flat_noise_does_not_turn_into_a_maze() {
// a flat grey patch with sensor-like noise: AHD picked horizontal or vertical candidates per pixel by a
// homogeneity count that noise alone decides, so the picture became short horizontal and vertical strokes
let (w, h) = (192, 160);
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut noise = || {
// sum of four uniforms ≈ Gaussian, σ ≈ 0.02
(0..4)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 40) as f32 / (1u64 << 24) as f32 - 0.5
})
.sum::<f32>()
* 0.035
};
let cfa = Cfa::bayer("RGGB").unwrap();
let data = (0..w * h).map(|_| 0.2 + noise()).collect();
let n = Normalized { width: w, height: h, cpp: 1, data, cfa: Some(cfa) };
let (ahd, bilinear) = (anisotropy(&demosaic(&n, Method::Ahd)), anisotropy(&demosaic(&n, Method::Bilinear)));
eprintln!("anisotropy: ahd {ahd:.3e}, bilinear {bilinear:.3e}");
// the hard choice gave about 11 times bilinear's, the blend about 7.5 times
assert!(ahd < 9.0 * bilinear, "ahd {ahd:.3e} bilinear {bilinear:.3e}");
}
#[test]
fn tiny_and_degenerate_inputs() {
let cfa = Cfa::bayer("RGGB").unwrap();
@@ -277,6 +324,20 @@ mod tests {
assert_eq!(demosaic(&n, Method::Ahd).data, vec![[0.1, 0.2, 0.3]]);
}
#[test]
fn a_nan_sample_spreads_no_further_than_the_chosen_candidate_takes_it() {
// vertical stripes: the vertical candidate wins outright beside them, and the horizontal one, which reads two
// samples to each side, is the only one that sees a NaN two columns away
let img = Rgb32f::from_fn(32, 24, |x, _| if (x / 2) % 2 == 0 { [0.2, 0.3, 0.25] } else { [0.7, 0.8, 0.6] });
let cfa = Cfa::bayer("RGGB").unwrap();
let mut n = mosaic_from_rgb(&img, &cfa);
n.data[12 * 32 + 16] = f32::NAN;
let out = demosaic(&n, Method::Ahd);
let bad = out.data.iter().filter(|p| p.iter().any(|v| !v.is_finite())).count();
// the pixels that read the NaN sample themselves, as many as the outright pick gave; the blend made it 33
assert!(bad <= 9, "{bad} pixels not finite");
}
#[test]
fn non_finite_samples_do_not_panic() {
// regression: NaN samples (a float DNG) gave AHD and PPG NaN bounds for `f32::clamp`, which panics
+14 -4
View File
@@ -6,7 +6,8 @@
//! 2. Complete each candidate with red/blue by colour-difference (C − G) interpolation.
//! 3. Convert both candidates to CIELab and measure, per pixel, how many of its 4 neighbours lie within the
//! adaptive luminance / chrominance tolerances (ε_L, ε_C from the paper) — the homogeneity.
//! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate (average on ties).
//! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate. The paper picks one outright;
//! we blend the two by how far apart their sums are (see [`BLEND`]), so a clear edge still takes one candidate.
//!
//! Work is done in overlapping tiles (bounded memory, rayon-parallel), one band of tiles at a time written
//! straight into the output. Each step runs only where the tile's output depends on it: the choice reads the
@@ -19,6 +20,11 @@ use rayon::prelude::*;
const TILE: usize = 128;
const MARGIN: usize = 6;
/// Difference of the two candidates' 3×3 homogeneity sums (each 0–36) from which the more homogeneous one is taken
/// alone; closer sums blend the two linearly, equal sums half and half. In a flat, noisy area noise alone decides
/// which sum is larger, and picking outright turned the noise into short horizontal and vertical strokes (a maze,
/// coloured where white balance is strong). 12 keeps the PSNR of the outright pick on the edge tests.
const BLEND: f32 = 12.0;
pub(crate) fn ahd(m: &Mosaic) -> Rgb32f {
ahd_window(m, 0, 0, m.w, m.h)
@@ -157,12 +163,16 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]>
sh += hh[j] as u32;
sv += hv[j] as u32;
}
out.push(if sh > sv {
let wh = (0.5 + (sh as f32 - sv as f32) / (2.0 * BLEND)).clamp(0.0, 1.0);
// a candidate taken alone is copied, and one that isn't finite (beside a float raw's NaN sample) is left out,
// so the other's value doesn't become 0 · NaN
let finite = |p: &[f32; 3]| p.iter().all(|v| v.is_finite());
out.push(if wh >= 1.0 || !finite(&rv[i]) {
rh[i]
} else if sv > sh {
} else if wh <= 0.0 || !finite(&rh[i]) {
rv[i]
} else {
[(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5]
[0, 1, 2].map(|c| rh[i][c] * wh + rv[i][c] * (1.0 - wh))
});
}
out
+4 -2
View File
@@ -469,10 +469,12 @@ fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> {
let be32 = |at: usize| bytes.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize);
let (Some(size), Some(len)) = (be32(start), be32(start + 20)) else { continue };
let j = start + 24;
if len < 4 || j + len > start + size.max(24) || j + len > bytes.len() {
// 32-bit box lengths can wrap `usize` on wasm32: check the sums, then slice with `get`
let (Some(end), Some(lim)) = (j.checked_add(len), start.checked_add(size.max(24))) else { continue };
if len < 4 || end > lim {
continue;
}
let jpeg = &bytes[j..j + len];
let Some(jpeg) = bytes.get(j..end) else { continue };
// One that doesn't fit the budget left is skipped; a smaller one later may still fit.
if walked + jpeg.len() > CR3_MAX_WALK {
continue;
+14
View File
@@ -199,6 +199,20 @@ fn picks_largest_dct_jpeg() {
}
}
/// #2569: `PRVW` box and JPEG lengths near `u32::MAX` (which wrap `usize` on wasm32) find no preview, never panic.
#[test]
fn cr3_preview_box_huge_lengths_are_rejected() {
let j = fake_jpeg(300);
for (size, len) in [(u32::MAX, u32::MAX), (u32::MAX, u32::MAX - 23), (24 + j.len() as u32, u32::MAX - 3)] {
let mut cr3 = b"\x00\x00\x00\x18ftypcrx \x00\x00\x00\x01crx isom".to_vec();
cr3.extend_from_slice(&size.to_be_bytes());
cr3.extend_from_slice(b"PRVW\x00\x00\x00\x00\x00\x01\x06T\x048\x00\x01");
cr3.extend_from_slice(&len.to_be_bytes());
cr3.extend_from_slice(&j);
assert_eq!(cr3_preview_boxes(&cr3), None, "size {size:#x}, len {len:#x}");
}
}
/// A JPEG like [`fake_jpeg`] whose frame header says `w` x `h` with `nc` components.
fn sized_jpeg(w: u16, h: u16, nc: u8, n: usize) -> Vec<u8> {
let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 8 + 3 * nc, 8];
+45 -3
View File
@@ -54,6 +54,14 @@ pub fn check_image(data: &[u8], info: &ImageInfo) -> Result<usize> {
if chunks.len() < expected {
return Err(RawError::Corrupt(format!("{} image data chunks, expected {expected}", chunks.len())));
}
// Tile sizes don't depend on the image size, and every tile is decoded at its nominal size and held until the copy:
// a grid of tiles no larger than the image covers less than twice its width and height, so more than four times
// its samples (or one tile of more than TILE_SAMPLES_FLOOR on a tiny image) is no picture (issue #2554).
let ccpp = if info.planar == 2 { 1 } else { cpp as u64 };
let nominal = chunks.iter().fold(0u64, |sum, c| sum.saturating_add(u64::from(c.width).saturating_mul(u64::from(c.height)).saturating_mul(ccpp)));
if nominal > (total as u64).saturating_mul(4).max(TILE_SAMPLES_FLOOR) {
return Err(RawError::Limit("image data chunks far larger than the image"));
}
// Plausibility bound before allocating: no supported coding stores more than ~2000 samples per byte
// (Deflate of constant data is the extreme), so tiny files cannot trigger huge allocations.
let available: u64 = chunks.iter().map(|c| chunk_bytes(data, c).map_or(0, |s| s.len() as u64)).sum();
@@ -95,6 +103,9 @@ pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, or
}
}
/// The nominal samples of all chunks [`check_image`] allows whatever the image size: one 2048 × 2048 tile.
const TILE_SAMPLES_FLOOR: u64 = 1 << 22;
/// Decode all chunks of `info` into one buffer of `width × height × cpp` samples.
pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Packing) -> Result<RawData> {
let (w, h) = (info.width as usize, info.height as usize);
@@ -493,8 +504,9 @@ mod lossy_tests {
assert!(read_image(&file, &past_end, ByteOrder::Little, Packing::Msb).is_err());
}
/// A small image whose uncompressed tile claims to be enormous (16384 x 16384 nominal, a few bytes of data) is
/// refused before that tile is allocated (issue #1512); an edge tile stored at its full nominal size decodes.
/// A small image whose uncompressed tile claims to be enormous (2048 x 2048 nominal, a few bytes of data) is
/// refused before that tile is allocated (issue #1512); an edge tile stored at its full nominal size decodes. (A
/// tile larger still is refused by `check_image` before any decode: `tiles_far_larger_than_the_image_are_refused`.)
#[test]
fn a_huge_nominal_uncompressed_tile_with_little_data_is_refused() {
let info = |tile: u32, bytes: u64| ImageInfo {
@@ -515,7 +527,37 @@ mod lossy_tests {
let small = vec![1u8; 2 * 16 * 16];
assert!(read_image(&small, &info(16, small.len() as u64), ByteOrder::Little, Packing::Msb).is_ok());
let data = vec![1u8; 4096];
assert!(matches!(read_image(&data, &info(16384, data.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))));
assert!(matches!(read_image(&data, &info(2048, data.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))));
}
/// A 16 × 16 image whose lossless-JPEG tile claims to be 16384 × 16384 (coded only as tall as the image, so the
/// file is small) is refused before the tile is allocated, by the header check too (issue #2554); a 256 × 256
/// tile on the same image, as a writer with a fixed tile size stores it, still decodes.
#[test]
fn tiles_far_larger_than_the_image_are_refused() {
let read = |tile: u32, mode: crate::Mode| {
let t = ljpeg::encode(&vec![300u16; tile as usize * 16], tile as usize, 16, 1, 12, 1, 0);
let info = ImageInfo {
width: 16,
height: 16,
bits_per_sample: vec![12],
samples_per_pixel: 1,
compression: comp::JPEG,
photometric: 32803,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: tile, tile_height: tile },
offsets: vec![0],
byte_counts: vec![t.len() as u64],
};
read_image_in(mode, &t, &info, ByteOrder::Little, Packing::Msb)
};
assert_eq!(read(256, crate::Mode::Full), Ok(RawData::U16(vec![300; 16 * 16])));
for mode in [crate::Mode::Header, crate::Mode::Full] {
assert!(matches!(read(16384, mode), Err(RawError::Limit(_))));
}
}
/// A lossless-JPEG tile may be coded only as tall as the part inside the image, never shorter.
+52
View File
@@ -225,9 +225,61 @@ fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> {
x.iter().all(|v| v.is_finite()).then_some(x)
}
/// Test helper: make the single-strip `StripByteCounts` entry (LONG, count 1) that holds `len` declare `declared`
/// bytes instead, as a file cut short or a hostile header does.
#[cfg(test)]
pub(crate) fn declare_strip_len(file: &mut [u8], len: u32, declared: u32) {
let big = file.starts_with(b"MM");
let entry = |v: u32| -> Vec<u8> {
if big {
[&279u16.to_be_bytes()[..], &4u16.to_be_bytes(), &1u32.to_be_bytes(), &v.to_be_bytes()].concat()
} else {
[&279u16.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat()
}
};
let (old, new) = (entry(len), entry(declared));
let at: Vec<usize> = file.windows(old.len()).enumerate().filter(|(_, w)| *w == old.as_slice()).map(|(i, _)| i).collect();
assert_eq!(at.len(), 1, "one StripByteCounts entry of {len} bytes");
file[at[0]..at[0] + new.len()].copy_from_slice(&new);
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_tiff::tags as t;
/// `file`, whose image IFD holds one strip, as two strips at the same offset whose StripByteCounts are LONG8 (which
/// classic TIFF readers accept) `[u64::MAX, 1]`: their sum is past `u64::MAX`.
pub(super) fn huge_strip_counts(mut file: Vec<u8>) -> Vec<u8> {
let tiff = Tiff::parse(&file).unwrap();
let o = tiff.order;
let ifd = tiff.all_ifds().into_iter().find(|i| i.contains(t::STRIP_OFFSETS)).unwrap();
let (base, rows, offset) = (ifd.offset as usize, ifd.u32(t::IMAGE_LENGTH).unwrap(), ifd.u32(t::STRIP_OFFSETS).unwrap());
assert!(rows >= 2);
let at = file.len() as u32;
// appended: the two strip offsets (LONG), then the two byte counts (LONG8)
let mut tail = Vec::new();
o.put_u32(&mut tail, offset);
o.put_u32(&mut tail, offset);
o.put_u64(&mut tail, u64::MAX);
o.put_u64(&mut tail, 1);
for i in 0..usize::from(o.read_u16(&file, base).unwrap()) {
let e = base + 2 + 12 * i;
let (ty, count, value) = match o.read_u16(&file, e).unwrap() {
t::STRIP_OFFSETS => (4u16, 2u32, at),
t::ROWS_PER_STRIP => (4, 1, rows.div_ceil(2)),
t::STRIP_BYTE_COUNTS => (16, 2, at + 8),
_ => continue,
};
let mut entry = Vec::new();
o.put_u16(&mut entry, ty);
o.put_u32(&mut entry, count);
o.put_u32(&mut entry, value);
file[e + 2..e + 12].copy_from_slice(&entry);
}
file.extend_from_slice(&tail);
file
}
#[test]
fn black_columns_by_parity() {
+40 -1
View File
@@ -628,7 +628,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let bits = info.bits() as u32;
let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR;
let chunks = info.chunks(bytes.len() as u64);
let strip_len: u64 = chunks.iter().map(|c| c.len).sum();
let strip_len = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len));
// The row-per-w-bytes (ARW2) layout stores exactly one byte per pixel in a single strip; all 108 such files in the test
// archive measure exactly width x height. A200/A230/A350 files carry a strip 4-23 % larger than that (and A290/A390 one
// smaller), which is a different packing, so they are refused rather than read with the wrong layout.
@@ -642,6 +642,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
}
32767 if one_byte_per_sample => {
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
if src.len() as u64 != strip_len {
return Err(RawError::Corrupt("raw strip shorter than its rows".into()));
}
let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100]));
let mut data = vec![0u16; samples as usize];
data.par_chunks_mut(w).enumerate().for_each(|(y, out)| {
@@ -659,6 +662,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
}
32767 if packed12 => {
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
if src.len() as u64 != strip_len {
return Err(RawError::Corrupt("raw strip shorter than its rows".into()));
}
let row_bytes = w / 2 * 3;
let mut data = vec![0u16; samples as usize];
data.par_chunks_mut(w).enumerate().for_each(|(y, out)| {
@@ -900,6 +906,15 @@ mod tests {
TiffWriter::default().write(&[raw]).unwrap()
}
/// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow.
#[test]
fn strip_byte_counts_past_u64_max_do_not_overflow() {
let file = crate::vendor::tests::huge_strip_counts(arw_with_strip(32 * 4));
for mode in [Mode::Header, Mode::Full] {
let _ = super::decode(&file, mode);
}
}
/// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as
/// one plane, also with a CA table (lateral CA is off by default); another body's tables don't (see
/// [`super::super::arw_lens_rm6`]).
@@ -1561,4 +1576,28 @@ mod tests {
assert!(is_quad_tiled(&file, &info));
assert_eq!(read_quad_tiles(&file, &info).unwrap(), mosaic);
}
/// #2561: a small file whose one-byte or packed 12-bit strip declares a large frame is refused before the full
/// decode allocates the frame.
#[test]
fn a_strip_cut_short_is_refused_before_allocating() {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
let (w, h) = (3000u32, 2000u32);
for declared in [w * h, w * h / 2 * 3] {
let mut raw = IfdBuilder::new();
raw.set(t::MAKE, Value::Ascii("SONY".into()));
raw.set(t::MODEL, Value::Ascii("DSLR-A100".into()));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA]));
raw.set(t::COMPRESSION, Value::Short(vec![32767]));
raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100]));
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![0u8; 12]] });
let mut f = TiffWriter::default().write(&[raw]).unwrap();
crate::vendor::declare_strip_len(&mut f, 12, declared);
assert!(matches!(decode(&f, Mode::Full), Err(RawError::Corrupt(_))), "{declared} bytes declared");
}
}
}
+10 -1
View File
@@ -206,7 +206,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let (w, h) = (info.width as usize, info.height as usize);
let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
let chunks = info.chunks(bytes.len() as u64);
let total: u64 = chunks.iter().map(|c| c.len).sum();
let total = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len));
let stated = cfa_from_exif(&tiff);
let mut two_field = false;
let (mut data, bits) = if info.compression != 1 {
@@ -394,6 +394,15 @@ mod tests {
b
}
/// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow.
#[test]
fn strip_byte_counts_past_u64_max_do_not_overflow() {
let file = crate::vendor::tests::huge_strip_counts(orf(4, 2, 16, vec![0; 16]));
for mode in [Mode::Header, Mode::Full] {
let _ = super::decode(&file, mode);
}
}
/// An old-style little-endian Olympus maker note (`OLYMP\0`, version 1, then the IFD) of SHORT (type 3) or
/// UNDEFINED (type 7) entries. Values longer than four bytes follow the IFD; their offsets count from the TIFF
/// header, so `at` is where the note sits in the file.
+14 -2
View File
@@ -267,14 +267,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let chunks = info.chunks(bytes.len() as u64);
let first = chunks.first().ok_or_else(|| RawError::Corrupt("PEF without image data".into()))?;
let mut c = *first;
c.len = chunks.iter().map(|c| c.len).sum::<u64>().max(first.len);
c.len = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len)).max(first.len);
let src = chunk_bytes(bytes, &c)
.or_else(|| bytes.get(first.offset as usize..))
.ok_or_else(|| RawError::Corrupt("PEF data outside file".into()))?;
if read == Mode::Full { RawData::U16(decode_huffman(src, &huff, w, hgt, bits)?) } else { RawData::U16(Vec::new()) }
}
1 => {
let strip: u64 = info.chunks(bytes.len() as u64).iter().map(|c| c.len).sum();
let strip = info.chunks(bytes.len() as u64).iter().fold(0u64, |a, c| a.saturating_add(c.len));
let packing = if strip >= (w * hgt * 2) as u64 { Packing::Word16 } else { Packing::Msb };
read_image_in(read, bytes, &info, tiff.order, packing)?
}
@@ -707,4 +707,16 @@ mod tests {
let file = pef_with_black_point(w, h, 64, |x, y| 64 + ((x * 7 + y * 13) % 5) as u16);
assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w, h));
}
/// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow,
/// for the Huffman-coded and the uncompressed samples.
#[test]
fn strip_byte_counts_past_u64_max_do_not_overflow() {
for compression in [65535u16, 1] {
let file = crate::vendor::tests::huge_strip_counts(pef_with_pattern(compression, [0, 1, 1, 2]));
for mode in [Mode::Header, Mode::Full] {
let _ = decode(&file, mode);
}
}
}
}
+81 -7
View File
@@ -114,26 +114,57 @@ impl<'a> Bits<'a> {
Self { src, pos: 0, acc: 0, available: 0 }
}
/// Tops the accumulator up to at least 57 bits, or to every remaining byte near the end of the stream.
#[inline]
fn get(&mut self, n: u32) -> Result<u32> {
while self.available < n {
let b = self.src.get(self.pos).copied().ok_or_else(|| corrupt("truncated entropy stream"))?;
fn refill(&mut self) {
while self.available <= 56 {
let Some(&b) = self.src.get(self.pos) else { return };
self.pos += 1;
self.acc = (self.acc << 8) | u64::from(b);
self.available += 8;
}
}
#[inline]
fn get(&mut self, n: u32) -> Result<u32> {
if self.available < n {
self.refill();
if self.available < n {
return Err(corrupt("truncated entropy stream"));
}
}
self.available -= n;
Ok(((self.acc >> self.available) & ((1u64 << n) - 1)) as u32)
}
fn residual(&mut self, k: u32, bits: u32, range: u32, limit: u32) -> Result<i32> {
/// Reads the unary prefix: the zeros before the next one bit, which is consumed. Errors exactly where reading
/// bit by bit would: more than `limit` zeros, or a stream that ends first.
#[inline]
fn zeros(&mut self, limit: u32) -> Result<u32> {
let mut zeros = 0;
while self.get(1)? == 0 {
zeros += 1;
if zeros > limit {
loop {
if self.available == 0 {
self.refill();
if self.available == 0 {
return Err(corrupt("truncated entropy stream"));
}
}
// the unread bits, left-aligned; the zeros shifted in below them are cut off by `min`
let run = (self.acc << (64 - self.available)).leading_zeros().min(self.available);
if zeros + run > limit {
return Err(corrupt("invalid residual escape"));
}
zeros += run;
if run < self.available {
self.available -= run + 1;
return Ok(zeros);
}
self.available = 0;
}
}
fn residual(&mut self, k: u32, bits: u32, range: u32, limit: u32) -> Result<i32> {
let zeros = self.zeros(limit)?;
let code = if zeros == limit { self.get(bits)? + 1 } else { (zeros << k) | self.get(k)? };
if code > range {
return Err(corrupt(&format!(
@@ -501,5 +532,48 @@ mod tests {
src.extend_from_slice(&data);
let _ = decode(&src, 780, 6, bits, xtrans, Mode::Full);
}
/// The word-at-a-time reader returns what reading bit by bit returns, and fails where it fails: long zero
/// runs, the escape limit and the end of the stream included.
#[test]
fn bit_reader_matches_reading_bit_by_bit(
data in prop::collection::vec(prop_oneof![3 => Just(0u8), 1 => any::<u8>()], 0..64),
reads in prop::collection::vec((any::<bool>(), 0u32..=24, 0u32..=63), 1..200),
) {
let bit = |i: usize| data.get(i / 8).map(|b| (b >> (7 - i % 8)) & 1 == 1);
let mut at = 0usize;
let mut reader = Bits::new(&data);
for (prefix, n, limit) in reads {
let expected = if prefix {
let mut zeros = 0;
loop {
let Some(b) = bit(at) else { break Err("truncated") };
at += 1;
if b {
break Ok(zeros);
}
zeros += 1;
if zeros > limit {
break Err("escape");
}
}
} else if at + n as usize > data.len() * 8 {
Err("truncated")
} else {
let value = (0..n as usize).fold(0, |v, i| (v << 1) | u32::from(bit(at + i) == Some(true)));
at += n as usize;
Ok(value)
};
let actual = if prefix { reader.zeros(limit) } else { reader.get(n) };
match (expected, actual) {
(Ok(want), Ok(got)) => prop_assert_eq!(want, got),
(Err(want), Err(got)) => {
prop_assert!(got.to_string().contains(want), "{got}");
break;
}
(want, got) => prop_assert!(false, "expected {want:?}, got {got:?}"),
}
}
}
}
}
+68 -8
View File
@@ -62,7 +62,8 @@ fn keyed(mn: &Ifd, tag: u16, sign: i64) -> Option<Vec<i64>> {
let key = mn.u64s(ENCRYPTION_KEY).filter(|k| k.len() == 11)?;
let vals = mn.f64s(tag)?;
// 32-bit entries: the stored value wraps modulo 2^32 (a negative number stored as a LONG)
Some(vals.iter().enumerate().map(|(i, &v)| i64::from((v as i64).wrapping_add(sign * key[i % 11] as i64) as i32)).collect())
// (a key value past i64::MAX must not overflow the multiply: only the low 32 bits are kept)
Some(vals.iter().enumerate().map(|(i, &v)| i64::from((v as i64).wrapping_add((key[i % 11] as i64).wrapping_mul(sign)) as i32)).collect())
}
/// The raw image's IFD: the one with a private compression value.
@@ -97,7 +98,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let (w, h) = (info.width as usize, info.height as usize);
let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
let chunks = info.chunks(bytes.len() as u64);
let strip: u64 = chunks.iter().map(|c| c.len).sum();
let strip = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len));
let Some(coding) = coding(info.compression, w, h, strip).filter(|_| chunks.len() == 1) else {
return Err(RawError::Unsupported(format!(
"Samsung compressed raw (compression {}, {:.2} stored bits per sample)",
@@ -107,6 +108,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
};
let data = if mode == Mode::Full {
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("SRW strip outside file".into()))?;
if src.len() as u64 != strip {
return Err(RawError::Corrupt("SRW strip shorter than its rows".into()));
}
let mut d = vec![0u16; n];
match coding {
Coding::Words => read_u16s(src, ByteOrder::Little, &mut d),
@@ -283,19 +287,25 @@ mod tests {
/// A maker note in the Samsung layout: a big-endian IFD of LONG vectors, offsets relative to the note's start.
fn note(entries: &[(u16, Vec<u32>)]) -> Vec<u8> {
let typed: Vec<_> = entries.iter().map(|(tag, v)| (*tag, 4, v.len() as u32, v.iter().flat_map(|x| x.to_be_bytes()).collect())).collect();
note_typed(&typed)
}
/// [`note`] with any entry types: `(tag, type, count, big-endian value bytes)`.
fn note_typed(entries: &[(u16, u16, u32, Vec<u8>)]) -> Vec<u8> {
let mut head = Vec::new();
let mut tail = Vec::new();
head.extend_from_slice(&(entries.len() as u16).to_be_bytes());
let values_at = 2 + 12 * entries.len() + 4;
for (tag, v) in entries {
for (tag, kind, count, v) in entries {
head.extend_from_slice(&tag.to_be_bytes());
head.extend_from_slice(&4u16.to_be_bytes());
head.extend_from_slice(&(v.len() as u32).to_be_bytes());
if v.len() == 1 {
head.extend_from_slice(&v[0].to_be_bytes());
head.extend_from_slice(&kind.to_be_bytes());
head.extend_from_slice(&count.to_be_bytes());
if v.len() <= 4 {
head.extend(v.iter().copied().chain(std::iter::repeat(0)).take(4));
} else {
head.extend_from_slice(&((values_at + tail.len()) as u32).to_be_bytes());
tail.extend(v.iter().flat_map(|x| x.to_be_bytes()));
tail.extend_from_slice(v);
}
}
head.extend_from_slice(&[0; 4]);
@@ -355,6 +365,23 @@ mod tests {
TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
/// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow.
#[test]
fn strip_byte_counts_past_u64_max_do_not_overflow() {
let file = crate::vendor::tests::huge_strip_counts(srw(Spec {
w: 4,
h: 2,
compression: 32769,
strip: vec![0; 16],
pattern: None,
note: None,
picture: None,
}));
for mode in [Mode::Header, Mode::Full] {
let _ = super::decode(&file, mode);
}
}
/// The camera-stated description every test file carries: JPEG framing, white balance, levels.
fn full_note(area: [u32; 4], white: i64, black: i64) -> Vec<u8> {
let mut levels = vec![0i64; 4];
@@ -510,6 +537,27 @@ mod tests {
assert!(matches!(decode(&bytes), Err(RawError::Unsupported(w)) if w.contains("CFAPattern")));
}
/// An encryption key value past i64::MAX (LONG8) overflowed `-1 * key` in debug builds.
#[test]
fn a_huge_encryption_key_does_not_panic() {
let key: Vec<u8> = std::iter::repeat_n((1u64 << 63).to_be_bytes(), 11).flatten().collect();
let note = note_typed(&[(ENCRYPTION_KEY, 16, 11, key), (WB_UNCORRECTED, 4, 4, [0u8; 16].to_vec()), (WB_BLACK, 4, 4, [0u8; 16].to_vec())]);
let bytes = srw(Spec {
w: 64,
h: 32,
compression: 32770,
strip: pack12(&ramp(64, 32, 4096)),
pattern: Some([2, 1, 1, 0]),
note: Some(note),
picture: None,
});
let mn = maker_note(&bytes, &Tiff::parse(&bytes).unwrap()).unwrap();
assert_eq!(mn.u64s(ENCRYPTION_KEY).map(|k| k[0]), Some(1 << 63), "the key reaches keyed()");
assert_eq!(keyed(&mn, WB_BLACK, -1), Some(vec![0; 4]), "(2^63 mod 2^32 is 0)");
assert!(decode(&bytes).is_ok());
assert!(probe_info(&bytes).is_ok());
}
/// A framing rectangle outside the array, or reversed, is ignored; truncated files fail without panicking.
#[test]
fn hostile_framing_and_truncation_do_not_panic() {
@@ -540,4 +588,16 @@ mod tests {
let _ = probe_info(&bytes[..cut]);
}
}
/// #2561: a small file whose strip declares a large frame is refused before the full decode allocates the frame.
#[test]
fn a_strip_cut_short_is_refused_before_allocating() {
let (w, h) = (3000u32, 2000u32);
for (compression, declared) in [(32769, w * h * 2), (32770, w * h / 2 * 3)] {
let mut f = srw(Spec { w, h, compression, strip: vec![0u8; 12], pattern: Some([0, 1, 1, 2]), note: None, picture: None });
crate::vendor::declare_strip_len(&mut f, 12, declared);
assert_eq!(probe(&f), Some(crate::RawFormat::Srw));
assert!(matches!(decode(&f), Err(RawError::Corrupt(_))), "compression {compression}");
}
}
}
+2
View File
@@ -263,6 +263,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);
+9 -2
View File
@@ -919,13 +919,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,
}
}
+16 -31
View File
@@ -28,9 +28,10 @@ use grading::grading;
pub(crate) use grading::wheel_pointer_frame_end;
#[cfg(test)]
pub(crate) use grading::{WheelGrab, WheelLock, WheelMods, wheel_pick};
use histogram::{histogram, soft_proofing};
pub(crate) use histogram::histogram;
use histogram::soft_proofing;
use mixer::{mixer, point_color};
use sliders::{REL_TEMP, REL_TINT, hdr_row, k_to_rel, profile_menu, quick_develop, rel_to_k, reset_tip, section};
use sliders::{REL_TEMP, REL_TINT, hdr_row, header_buttons, k_to_rel, profile_menu, quick_develop, rel_to_k, reset_tip, section};
pub use sliders::{apply_slider_out, sub_title};
pub(crate) use sliders::{control, reload_preview_only};
@@ -56,9 +57,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
let preview_only = app.session.catalog.photo(id).and_then(|p| p.preview_only.clone());
if app.ui.histogram {
histogram(app, ui, id);
}
if app.ui.soft_proof {
soft_proofing(app, ui, id);
}
@@ -67,32 +65,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, crate::i18n::tr("Edit"), t.semibold(15.0), t.text);
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if text_button(ui, "auto", crate::i18n::tr("Auto"), false).clicked() {
let _ = app.run("develop.auto", json!({}));
app.toast(ui.ctx(), crate::i18n::tr("Auto settings applied"));
}
let bw = crate::is_bw(&d);
if text_button(ui, "bw", crate::i18n::tr("B&W"), bw).clicked() {
let _ = app.run("develop.treatment", json!({}));
}
// HDR editing, next to B&W as Lightroom has it
if text_button(ui, "hdr", crate::i18n::tr("HDR"), d.hdr.enabled)
.on_hover_text(crate::i18n::tr("Edit in HDR: highlights above SDR white, for HDR exports and displays"))
.clicked()
{
let _ = app.run("develop.hdr", json!({"enabled": !d.hdr.enabled}));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if text_button(ui, "reset", crate::i18n::tr("Reset"), false)
.on_hover_text(reset_tip(&app.ui.settings.keymap, ui.ctx().os() == egui::os::OperatingSystem::Mac))
.clicked()
{
reset_clicked = true;
}
});
});
let tip = reset_tip(&app.ui.settings.keymap, ui.ctx().os() == egui::os::OperatingSystem::Mac);
let clicks = header_buttons(ui, crate::is_bw(&d), d.hdr.enabled, &tip);
if clicks.auto {
let _ = app.run("develop.auto", json!({}));
app.toast(ui.ctx(), crate::i18n::tr("Auto settings applied"));
}
if clicks.bw {
let _ = app.run("develop.treatment", json!({}));
}
if clicks.hdr {
let _ = app.run("develop.hdr", json!({"enabled": !d.hdr.enabled}));
}
reset_clicked = clicks.reset;
});
if let Some(why) = &preview_only {
let reload = egui::Frame::NONE
+60 -21
View File
@@ -33,7 +33,16 @@ fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist:
}
}
pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
/// The histogram of the last drag's small HDR render, while an HDR edit of `id` is being dragged. It stays in
/// `Renderer` after the drag, so an edit with HDR switched off must not read it: its drags would show the old HDR
/// histogram (`dragging`: a drag of an HDR edit is on).
fn live_hdr_histogram(live: Option<&(PhotoId, u64, Histogram)>, id: PhotoId, dragging: bool) -> Option<Histogram> {
live.filter(|l| dragging && l.0 == id).map(|l| l.2.clone())
}
/// The histogram, its clipping buttons and the EXIF strip, drawn above the Edit panel's scrolling sliders
/// ([`crate::panels::right::show`]).
pub(crate) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let hdr_edit = app.session.develop_of(id).is_some_and(|d| d.hdr.enabled);
@@ -48,7 +57,7 @@ pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId)
app.renderer.request(Slot::HdrHistogram, job, 95);
}
}
let live = app.renderer.live_hdr_histogram.as_ref().filter(|l| interacting && l.0 == id).map(|l| l.2.clone());
let live = live_hdr_histogram(app.renderer.live_hdr_histogram.as_ref(), id, interacting && hdr_edit);
// else the loupe render of this photo, else its thumbnail (grid view before the loupe has
// rendered it)
let hist = live.or_else(|| preview_histogram(app, id));
@@ -155,8 +164,8 @@ pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId)
}
}
let mut clicked = resp.clicked();
// the panel's floating scroll bar widens over the right edge when hovered: both buttons keep
// clear of it, the left one too so they stay symmetric (#1640)
// both buttons keep clear of the panel's right edge, where its floating scroll bar widens when
// hovered beside the sliders, the left one too so they stay symmetric (#1640)
let [shadow_rect, highlight_rect] = crate::panels::histogram::clipping_rects(ui, plot);
for (name, rect, label, clipped) in [
("shadows", shadow_rect, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)),
@@ -169,26 +178,12 @@ pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId)
let active = app.ui.show_clipping;
// Keep the hit target comfortable, with a smaller box and clear space to the plot edges.
let box_rect = rect.shrink2(vec2(2.0, 2.0));
p.rect_filled(
box_rect,
3.0,
if active {
t.pressed
} else if response.hovered() {
t.hover
} else {
Color32::from_gray(60)
},
);
let (tile, mark) = clip_button_colors(&t, active, response.hovered(), clipped == Some(true));
p.rect_filled(box_rect, 3.0, tile);
if active {
p.rect_stroke(box_rect, 3.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
}
paint(
p,
Rect::from_center_size(rect.center(), vec2(16.0, 16.0)),
Icon::ChevronDown,
if clipped == Some(true) || active { t.text } else { t.text_dim },
);
paint(p, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::ChevronDown, mark);
let status = match clipped {
Some(true) => "Clipping detected in this preview.",
Some(false) => "No clipping detected in this preview.",
@@ -317,10 +312,39 @@ pub(super) fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: Phot
divider(ui);
}
/// The tile and chevron colours of a clipping button, from the theme's own raised fills: the tile
/// sits one step above the plot's backdrop (`button`) and the chevron (bright when clipping shows, dim otherwise) stands 3:1 on it in every
/// state and theme.
fn clip_button_colors(t: &Tokens, active: bool, hovered: bool, clipped: bool) -> (Color32, Color32) {
let tile = if active || hovered { t.pressed } else { t.hover };
(tile, if clipped || active { t.text } else { t.text_dim })
}
#[cfg(test)]
mod tests {
use super::*;
/// The two clipping chevrons were a fixed dark-grey tile (`from_gray(60)`) with a dimmed mark: on
/// a light theme they were near-black tiles on a light panel, and the mark barely read.
#[test]
fn the_clipping_chevron_stands_3_to_1_against_its_tile_in_every_theme() {
use crate::theme::ThemeKind;
for kind in ThemeKind::ALL {
let t = Tokens::for_kind(kind);
for (active, hovered, clipped) in
[(false, false, false), (false, false, true), (false, true, false), (true, false, false), (true, true, true)]
{
let (tile, mark) = clip_button_colors(&t, active, hovered, clipped);
let c = crate::widgets::button::contrast(mark, tile);
assert!(c >= 3.0, "{kind:?} active={active} hovered={hovered} clipped={clipped}: chevron {c:.2}:1 on its tile");
}
// the idle tile is the theme's own raised fill, not a fixed gray, and a quiet one next to the plot's backdrop
let (idle, _) = clip_button_colors(&t, false, false, false);
assert_ne!(idle, t.button, "{kind:?}: the tile vanishes into the histogram's backdrop");
assert!(crate::widgets::button::contrast(idle, t.button) < 3.0, "{kind:?}: the tile is a heavy block, not a quiet raised fill");
}
}
#[test]
fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() {
let ctx = egui::Context::default();
@@ -342,6 +366,21 @@ mod tests {
assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr));
}
/// Issue #2819: the last HDR drag's histogram is kept in `Renderer`; with HDR switched off, a drag must not read it.
#[test]
fn a_drag_with_hdr_off_does_not_read_the_last_hdr_drags_histogram() {
let id = PhotoId(7);
let kept = (id, 3, Histogram::of_hdr(&[[2.0, 2.0, 2.0]], 5.0));
assert!(kept.2.hdr.is_some());
// a drag of an HDR edit: the small HDR render's histogram
assert_eq!(live_hdr_histogram(Some(&kept), id, true), Some(kept.2.clone()));
// HDR switched off, a drag of the SDR edit: the panel's own (SDR) histogram
assert_eq!(live_hdr_histogram(Some(&kept), id, false), None);
// another photo, or nothing rendered yet
assert_eq!(live_hdr_histogram(Some(&kept), PhotoId(8), true), None);
assert_eq!(live_hdr_histogram(None, id, true), None);
}
/// Issue #1751: Windows and Linux read Ctrl where macOS reads Cmd, and a rebound shortcut shows.
#[test]
fn reset_tooltip_names_the_shortcut_of_this_platform() {
+120
View File
@@ -296,6 +296,75 @@ pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context,
}
}
/// Which of the Edit header's buttons were clicked.
#[derive(Default)]
pub(super) struct HeaderClicks {
pub(super) auto: bool,
pub(super) bw: bool,
pub(super) hdr: bool,
pub(super) reset: bool,
}
/// The Edit header's buttons: Auto, B&W and HDR on the left, Reset on the right. A button that doesn't fit on the
/// row starts the next one (long translations in a narrow panel, such as German *Zurücksetzen* beside HDR), so none
/// covers another; Reset stays right-aligned on whichever row it lands.
pub(super) fn header_buttons(ui: &mut egui::Ui, bw: bool, hdr: bool, reset_tip: &str) -> HeaderClicks {
const GAP: f32 = 8.0;
let mut clicks = HeaderClicks::default();
let avail = ui.available_width();
let left = [("auto", "Auto", false), ("bw", "B&W", bw), ("hdr", "HDR", hdr)];
// rows of `left` indices, filled greedily; `used` is the width taken on the current (last) row
let mut rows: Vec<Vec<usize>> = Vec::new();
let mut row: Vec<usize> = Vec::new();
let mut used = 0.0;
for (i, (_, label, _)) in left.iter().enumerate() {
let w = crate::widgets::text_button_size(ui, label);
if row.is_empty() {
used = w;
} else if used + GAP + w > avail {
rows.push(std::mem::take(&mut row));
used = w;
} else {
used += GAP + w;
}
row.push(i);
}
rows.push(row);
let reset_on_last = used + GAP + crate::widgets::text_button_size(ui, "Reset") <= avail;
let reset = |ui: &mut egui::Ui, clicks: &mut HeaderClicks| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
clicks.reset = crate::widgets::text_button(ui, "reset", crate::i18n::tr("Reset"), false).on_hover_text(reset_tip).clicked();
});
};
let last = rows.len() - 1;
for (r, row) in rows.iter().enumerate() {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = GAP;
for &i in row {
let (id, label, active) = left[i];
let mut resp = crate::widgets::text_button(ui, id, crate::i18n::tr(label), active);
if id == "hdr" {
// HDR editing, next to B&W as Lightroom has it
resp = resp.on_hover_text(crate::i18n::tr("Edit in HDR: highlights above SDR white, for HDR exports and displays"));
}
let clicked = resp.clicked();
match id {
"auto" => clicks.auto = clicked,
"bw" => clicks.bw = clicked,
_ => clicks.hdr = clicked,
}
}
if r == last && reset_on_last {
reset(ui, &mut clicks);
}
});
}
if !reset_on_last {
ui.horizontal(|ui| reset(ui, &mut clicks));
}
clicks
}
/// The Reset button's tooltip with the reset shortcut as this platform writes it (issue #1751).
pub(super) fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String {
let base = crate::i18n::tr("Reset all edits");
@@ -304,3 +373,54 @@ pub(super) fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String
None => base.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The Edit header's buttons never cover each other or run past the panel, in any language: a button that doesn't
/// fit starts the next row, and Reset stays right-aligned. Before, Reset covered HDR in 11 of the 18 languages at
/// the default 222 pt (German *Zurücksetzen* among them) and in all but the two Chinese ones at some width. 200 pt
/// is about the narrowest panel (`RIGHT_WIDTH.min` less the margins).
#[test]
fn edit_header_buttons_do_not_overlap_in_any_language() {
use crate::i18n::Locale;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx);
let mut wrapped = Vec::new();
for &locale in Locale::ALL {
crate::i18n::set_language(locale);
for width in [200.0, 222.0, 260.0, 400.0] {
let mut rects = Vec::new();
for frame in 0..2 {
let raw = crate::headless::HeadlessView::raw_input(vec2(width, 200.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(raw, |ui| {
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
header_buttons(ui, false, false, "");
});
});
out.textures_delta.clear();
rects = crate::widgets::take_registry(&ctx);
}
let get = |id: &str| rects.iter().find(|(i, _)| i == id).map(|(_, r)| *r).unwrap();
let buttons = ["button:auto", "button:bw", "button:hdr", "button:reset"].map(get);
for (i, a) in buttons.iter().enumerate() {
assert!(a.right() <= width + 0.5, "{locale:?} {width}: button {i} past the edge: {a:?}");
for b in &buttons[i + 1..] {
assert!(!a.intersects(b.shrink(0.5)), "{locale:?} {width}: {a:?} overlaps {b:?}");
}
}
let [_, _, hdr, reset] = buttons;
assert!((reset.right() - width).abs() < 0.5, "{locale:?} {width}: Reset stays right-aligned: {reset:?}");
if reset.top() > hdr.top() {
wrapped.push((locale, width));
}
}
}
crate::i18n::set_language(Locale::En);
assert!(wrapped.contains(&(Locale::De, 222.0)), "German wraps in the default panel: {wrapped:?}");
// English keeps its single row from the default width up
assert!(!wrapped.iter().any(|&(l, w)| l == Locale::En && w >= 222.0), "{wrapped:?}");
}
}
+5
View File
@@ -59,6 +59,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
return;
};
// the Edit panel's histogram stays above its sliders as they scroll (#2679)
if app.ui.right == RightPanel::Edit && app.ui.histogram {
ui.spacing_mut().item_spacing.y = 0.0;
super::edit::histogram(app, ui, id);
}
let mut area = egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]);
if let Some(offset) = held {
area = area.vertical_scroll_offset(offset);
+5
View File
@@ -185,6 +185,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)]
@@ -429,6 +433,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
View File
@@ -1,6 +1,7 @@
mod activity;
mod album_picker;
mod album_remove;
mod alt_preview;
mod appearance;
mod before_after;
mod browse_active;
+71
View File
@@ -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}");
}
+61
View File
@@ -2614,6 +2614,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.
@@ -2804,6 +2841,30 @@ fn histogram_corners_toggle_clipping_warnings() {
assert!(!h.app.ui.show_clipping);
}
/// Issue #2679: the histogram stays at the top of the Edit panel while its sliders scroll under it.
#[test]
fn the_histogram_stays_in_view_while_the_sliders_scroll() {
let mut h = demo(
[1400.0, 700.0],
json!({"view": "detail", "right": "edit", "histogram": true, "openSections": ["light", "color", "effects", "detail", "optics"]}),
);
let (hist, exposure) = (widget(&h, "histogram"), widget(&h, "slider:light.exposure"));
let panel = widget(&h, "panel:right_panel");
let r = h.request("ui.move", json!({"x": panel.center().x, "y": panel.bottom() - 40.0}), T);
assert_eq!(r["ok"], true, "{r}");
for _ in 0..4 {
let r = h.request("ui.scroll", json!({"dy": -300.0}), T);
assert_eq!(r["ok"], true, "{r}");
}
h.step();
let moved = h.app.widgets.iter().find(|(w, _)| w == "slider:light.exposure").map(|(_, r)| *r);
assert!(moved.is_none_or(|r| r.top() < exposure.top() - 100.0), "the sliders scrolled: {exposure:?} → {moved:?}");
assert_eq!(widget(&h, "histogram"), hist, "the histogram stayed where it was");
for id in ["histogram:shadows", "histogram:highlights"] {
assert!(hist.contains_rect(widget(&h, id)), "{id} is still on the histogram");
}
}
#[test]
fn the_histogram_clipping_buttons_stay_clear_of_the_hovered_scroll_bar() {
// every section open: the Edit panel scrolls, so its scroll bar runs past the histogram
+13 -1
View File
@@ -924,7 +924,7 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response
let t = Tokens::get(ui.ctx());
let font = t.semibold(11.5);
let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text);
let size = vec2((galley.size().x + 18.0).max(37.0), 24.0);
let size = vec2(text_button_width(galley.size().x), 24.0);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label));
register(ui, format!("button:{id}"), r);
@@ -941,6 +941,18 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response
resp
}
/// The width of a [`text_button`] whose label is `text_width` wide.
fn text_button_width(text_width: f32) -> f32 {
(text_width + 18.0).max(37.0)
}
/// The width [`text_button`] takes for `label` (translated as it would be), to plan a row before drawing it.
pub fn text_button_size(ui: &Ui, label: &str) -> f32 {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(crate::i18n::tr(label).to_string(), t.semibold(11.5), t.text);
text_button_width(galley.size().x)
}
/// A segmented control: `labels` share the available width equally, `per_row` per row. Each segment is
/// addressable as `button:{id}-{key}` (keys parallel to labels). Returns the clicked index.
pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option<usize>, per_row: usize) -> Option<usize> {
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -17,7 +17,7 @@ Fujifilm now uses a guarded colour/tone estimate from each file’s own embedded
- Signed prediction errors use adaptive Rice coding: a unary prefix followed by a context-dependent number of bits, with a bounded escape for large errors. Three pair phases have separate even/odd statistics. The 81 signed gradient contexts fold into 41 states.
- Lossy quantizers change the error range and reconstruction step. Low-gradient sites retain finer precision (0, 1 or 2), with five folded contexts per precision and statistics that survive main-quantizer changes. The main context statistics reset when its quantizer changes. Their initial sum is `max(2, (range + 32) / 64)`; using 16 as the minimum breaks more heavily quantized files.
Stripes decode in parallel using the existing Rayon dependency. Offsets, sizes, dimensions and entropy reads are checked; truncated data, impossible codes and inconsistent headers return errors. Unknown container compression modes, disagreements between container and stream modes, and compressed X-Trans layouts other than the supported CFA return errors. Header-only probing validates the tables and stripe ranges without decompressing the image.
Stripes decode in parallel using the existing Rayon dependency. The entropy reader keeps up to 64 bits buffered and counts a unary prefix's zeros in one step instead of bit by bit; a property test checks it against reading bit by bit, errors included. Offsets, sizes, dimensions and entropy reads are checked; truncated data, impossible codes and inconsistent headers return errors. Unknown container compression modes, disagreements between container and stream modes, and compressed X-Trans layouts other than the supported CFA return errors. Header-only probing validates the tables and stripe ranges without decompressing the image.
## Verification
@@ -36,7 +36,7 @@ The 17 CC0 files live in the gitignored corpus. Their published SHA-256 identiti
Unconditional procedural tests cover Bayer/X-Trans, 12/14/16 bits, partial final stripes, quantizer changes, residual signs and escapes, malformed headers and truncated/random entropy streams. Regressions also check overflowing uncompressed row strides, unsupported modes and CFA layouts, and container/stream mode mismatches.
Local release decode measurements: about 0.16–0.24 seconds for 24–40 MP X-Trans files, 0.29 seconds for 50 MP Bayer, and 0.5–0.6 seconds for 100 MP Bayer. These measure sensor decoding, not the full develop/export pipeline.
Local release decode measurements: about 0.16–0.24 seconds for 24–40 MP X-Trans files, 0.29 seconds for 50 MP Bayer, and 0.5–0.6 seconds for 100 MP Bayer. These measure sensor decoding, not the full develop/export pipeline. The buffered prefix count took 13–17 % off the decode time on an Apple M2 (8 cores, minimum of 5 runs, old and new build alternated): X-T2 178 → 152 ms, X-S10 lossless 265 → 226 ms, X-E5 442 → 385 ms, X-H2 448 → 378 ms, GFX 50S 522 → 435 ms, GFX 100 762 → 644 ms, with sensor arrays unchanged bit for bit.
App verification imported all four supplied compressed files, confirmed `previewOnly: null`, applied +2 EV exposure edits and inspected headless UI screenshots. CLI JPEG exports at a 1600-pixel long edge succeeded for all four files (0.58–0.76 seconds each, including process startup).
+3 -2
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Sony ILCE-6700 in-file distortion; Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Sony ILCE-6700 in-file distortion; Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices; AHD no longer turns flat noise into a maze pattern) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera
does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by
@@ -194,7 +194,7 @@ sample files. A pointer for contributors, not legal advice.
| Stage | Target | LightCraft | Status |
|---|---|---|---|
| Bayer demosaic | Adobe's own, plus AI "Raw Details" | AHD (default), PPG, bilinear (`crates/raw/src/demosaic`) | 🟡 quality unmeasured against Lightroom; no Raw Details |
| Bayer demosaic | Adobe's own, plus AI "Raw Details" | AHD (default; blends its horizontal and vertical candidates where neither is clearly more homogeneous, so flat noise doesn't become a maze), PPG, bilinear (`crates/raw/src/demosaic`) | 🟡 quality unmeasured against Lightroom; demosaics before white balance (#1328, PR #1463); no Raw Details |
| X-Trans demosaic | ✅ | in-house edge-weighted directional | 🟡 unmeasured |
| Foveon | ✅ (older bodies) | — | ⬜ |
| Monochrome / LinearRaw | ✅ | handled, no demosaic | ✅ |
@@ -331,4 +331,5 @@ Beyond decoding:
| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) |
| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again |
| 2026-10-18 | minor | Minolta MRW decoded (`vendor/mrw.rs`): DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified; 551 verified, 656 unverified, 201 preview (re-measured with `coverage.py`) |
| 2026-10-23 | minor | AHD blends its horizontal and vertical candidates where their homogeneity is close, so flat noise no longer becomes a maze pattern; Bayer demosaic row notes the pre-white-balance demosaic (#1328); scores unchanged |
| 2026-10-30 | patch | RAF camera look fitted on the vignetting-corrected sensor (`LOOK_VERSION` 13): renders were up to 0.3 EV too bright in midtones and 0.6 EV in shadows; X-S10 mean ΔE00 vs the camera JPEG 2.71 → 2.55 on 60 photos |
+10
View File
@@ -87,6 +87,15 @@ auto-repeat used to flip the zoom back and forth (`crates/ui-egui/src/space_hand
**Keyword shortcut keys (KEYC-METADATA):** `⌘⌥⇧K` (`Ctrl+Alt+Shift+K` on Windows and Linux) is Set Keyword Shortcut… (`dialog.keywordShortcut`): it asks for one or more keywords with commas between them and shows the ones set before; a new name goes inside the default parent, as in the Keywording box. A keyword's Use This as Keyword Shortcut in the Keyword List sets that keyword alone, and the list shows a + after each keyword of the keyword shortcut. `Shift+K` (`keyword.toggleShortcut`) adds the keyword shortcut's keywords to the selected photos, or removes them when every selected photo has them all (the help pages do not say how a mixed selection behaves; this rule is our choice); with no keyword shortcut it does nothing. A photo's context menu offers Add Keyword with the keyword shortcut's keywords. The keyword shortcut is a mark on its keywords in the library's keyword list (`keywordShortcut`, like Put New Keywords Inside This Keyword): a new name is listed, the mark moves with a renamed, moved or merged keyword and goes with a deleted one, and setting it is one undo step. The tests are in `crates/ui-egui/src/tests/keyword_list.rs`, `crates/engine/src/tests/keyword_list.rs` and `crates/catalog/src/keywords/tests.rs`.
**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.
@@ -183,5 +192,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 |
+43
View File
@@ -30,6 +30,20 @@
any screen (such as `1e308`) used to queue an infinite or NaN position. A debug build panicked on it and a release
build handed it to every widget. Numbers outside ±1,000,000 are now an error, and nothing is queued.
### The histogram stays SDR when HDR is switched off
- After an HDR edit was dragged and HDR was then switched off, dragging a slider showed the old HDR histogram (with its
SDR / HDR colour bar) again until the drag ended. The histogram now follows the edit: an SDR edit shows the SDR
histogram while you drag, too.
### At most 64 red eye corrections per photo
- Every pixel of a render goes through every red eye correction, so a sidecar, preset or agent with thousands of
them could stall a preview or export for hours. A photo now renders its first 64, and Red Eye refuses a 65th.
### Reliability
- A damaged or crafted Olympus ORF, Samsung SRW, Sony ARW or Pentax PEF whose strip sizes add up past the largest
64-bit number no longer crashes LightCraft (debug builds) when it is read: the file is refused, or read as far as
its data go.
## 2026-10-29
### A person's page opens in seconds in a large library
@@ -59,6 +73,11 @@
library given with `--library` or `LIGHTCRAFT_LIBRARY`, and the default location on first launch, are still
created as before.
### The histogram's clipping buttons read in light themes
- The two buttons at the histogram's top corners (shadow and highlight clipping) are no longer near-black tiles with a
grey chevron. They take the theme's own raised fill, and the chevron stands out from it in Charcoal, Midnight, Silver
and Paper alike.
## 2026-10-28
### The empty photo grid names Import Photos' shortcut as it is bound
@@ -324,6 +343,18 @@
closed the window. Those three now have the Cut / Copy / Paste menu too, and what is typed in Copyright or
Creator is saved when another tab is clicked, where it used to be dropped.
### The histogram stays in view
- The histogram stays at the top of the Edit panel while its sliders scroll under it (issue #2679). It used to scroll
away with them, so it was out of sight while using the sliders further down the panel.
### No maze pattern in noisy raws
- Flat areas of high-ISO raws, such as a dim wall or a blurred background, could show short horizontal and vertical
strokes instead of grain, often orange and blue under warm light. Noise reduction made them stand out more, because
it smooths the grain around them. The demosaic chose between a horizontal and a vertical guess at each pixel, and
in a flat area the noise alone decided which. Where neither guess is clearly better it now blends the two, so the
noise looks like grain. Edges are as sharp as before. Thumbnails and previews that LightCraft rendered from a raw
are redrawn; the camera's own embedded preview, shown for an unedited raw until its render is ready, is unchanged.
### Tab leaves the name field in New Album, New Smart Album and the name prompts
- In New Album, New Smart Album, Save Metadata Preset and the other dialogs with one name field, the field has the
@@ -1285,6 +1316,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