diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 848251c65..a54bd620d 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -446,337 +446,4 @@ pub fn refit_radials(partial: &mut Value, from: f64, to: f64) { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape}; - - /// A packet in the shape local corrections take (values written for this test). - const PACKET: &str = r#" - - - - - - - - - - - - - - - - - - - - - - - d 0.1 0.2d 0.15 0.22 - d 0.5 0.5 - - -"#; - - fn settings() -> (DevelopSettings, Vec) { - let d = lightcraft_meta::parse_xmp(PACKET).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped) - } - - #[test] - fn corrections_become_masks() { - let (s, unmapped) = settings(); - assert_eq!(s.light.exposure, 0.2, "global settings still map"); - assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks); - let sky = &s.masks[0]; - assert_eq!(sky.name, "Darker sky"); - assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0)); - assert_eq!(sky.components[0].shape, MaskShape::Sky); - assert_eq!(sky.components[1].op, MaskOp::Intersect); - let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() }; - assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point"); - let rad = &s.masks[1]; - assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0)); - let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() }; - assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9); - assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}"); - assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside"); - assert!(!rad.components[0].invert); - let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() }; - assert!(lo < hi && *lo_feather > 0.0); - let brush = &s.masks[2]; - assert_eq!(brush.adjust.clarity, 40.0); - assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component"); - let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() }; - assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0)); - assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]); - } - - #[test] - fn masks_render() { - let (s, _) = settings(); - let img = lightcraft_scenes::demo_library()[0].render(96, 64); - let render = |d: &DevelopSettings| { - lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data - }; - assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); - } - - #[test] - fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { - // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack - let x = r#" - - - - - - - - -"#; - let d = lightcraft_meta::parse_xmp(x).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).expect("valid settings"); - let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; - assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); - assert_eq!(s.masks[0].adjust.clarity, -30.0); - assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); - } - - #[test] - fn lrtemplate_corrections() { - let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { - { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = { - { What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#; - let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap(); - assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped); - let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); - assert_eq!(s.masks.len(), 1); - assert_eq!(s.masks[0].adjust.exposure, -2.0); - } - - #[test] - fn presets_add_masks_once() { - let (s, _) = settings(); - let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}}); - let p = - lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false }; - let mut mine = DevelopSettings::default(); - mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() }); - let once = p.apply(&mine, 1.0); - assert_eq!(once.masks.len(), 4, "added to the photo's own mask"); - assert_eq!(once.masks[1].id, 8, "fresh ids"); - assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing"); - let half = p.apply(&mine, 0.5); - assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks"); - } - - #[test] - fn sidecar_radials_fit_the_photo() { - let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]}); - refit_radials(&mut v, 1.5, 1.0); - let s = &v["masks"][0]["components"][0]["shape"]; - assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9); - assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); - } - - /// One radial correction in `container`, with `attrs` on its mask component (values written - /// for this test; the component attributes follow what Lightroom Classic writes). - fn radial_packet(container: &str, attrs: &str) -> String { - format!( - r#" - - - - - - - - -"# - ) - } - - fn radial_settings(container: &str, attrs: &str) -> DevelopSettings { - let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - assert!(unmapped.is_empty(), "{unmapped:?}"); - let s = DevelopSettings::default().merged(&partial).unwrap(); - assert_eq!(s.masks.len(), 1, "{:?}", s.masks); - s - } - - /// (the radial's `invert`, its feather, the component's `invert`). - fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) { - let c = &s.masks[0].components[0]; - let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) }; - (*invert, *feather, c.invert) - } - - /// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green). - fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) { - let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] }; - let render = - |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image; - let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() })); - let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]); - (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3)) - } - - #[test] - fn lightroom_radial_flipped_is_inside() { - // as Lightroom Classic writes an ordinary radial mask today (#1815) - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); - assert_eq!(radial_of(&s), (false, 25.0, false)); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}"); - // no MaskInverted at all reads the same - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#); - assert_eq!(radial_of(&s), (false, 25.0, false)); - } - - #[test] - fn lightroom_radial_mask_inverted_is_outside() { - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); - assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's"); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}"); - // version 2 with Flipped="false": outside, the legacy sense of the flag - let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#); - assert_eq!(radial_of(&s), (true, 25.0, false)); - } - - #[test] - fn versionless_group_radial_is_inside_with_half_the_feather() { - // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2" - for flipped in ["true", "false"] { - let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#)); - assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}"); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}"); - } - } - - #[test] - fn legacy_radial_filter_is_outside_unless_flipped() { - // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true" - let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#); - assert_eq!(radial_of(&s), (true, 50.0, false)); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}"); - assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false)); - let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#); - assert_eq!(radial_of(&s), (false, 50.0, false)); - let (inside, outside) = lift_inside_outside(&s); - assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}"); - } - - #[test] - fn lrtemplate_radial_filter_reads_flipped() { - // .lrtemplate presets and catalog develop settings are the same Lua tables - let t = |flipped: &str| { - format!( - r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{ - {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{ - {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"# - ) - }; - for (flipped, outside) in [("true", false), ("false", true)] { - let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap(); - let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); - assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}"); - } - } - - /// Spot removal in the shape Lightroom Classic writes it (values written for this test): - /// a healed circle with an explicit source, a cloned circle with an automatic source, a - /// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. - const SPOTS: &str = r#" - - - centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal - - - - - - - - - - - d 0.1 0.8d 0.12 0.82d 0.14 0.84 - - - - - - - - -"#; - - #[test] - fn retouch_areas_become_spots() { - use lightcraft_develop::SpotMode; - let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).expect("valid settings"); - assert_eq!(s.light.exposure, 0.1, "global settings still map"); - assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots); - let heal = &s.spots[0]; - assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0)); - assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9); - assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions"); - let src = heal.source_offset.expect("explicit source"); - assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}"); - let clone = &s.spots[1]; - assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None)); - let brushed = &s.spots[2]; - assert_eq!((brushed.points.len(), brushed.size), (3, 0.03)); - let src = brushed.source_offset.expect("explicit source"); - assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9); - let circ = &s.spots[3]; - assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9); - assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}"); - } - - #[test] - fn portrait_target_scales_spot_sizes() { - let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); - let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0); - let s = DevelopSettings::default().merged(&partial).unwrap(); - assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size); - } - - #[test] - fn legacy_retouch_info_is_read_on_its_own() { - let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored"); - let d = lightcraft_meta::parse_xmp(&packet).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).unwrap(); - assert_eq!(s.spots.len(), 1, "{:?}", s.spots); - assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal); - assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9); - assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9)); - assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}"); - assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}"); - } - - #[test] - fn spots_render() { - let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); - let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); - let s = DevelopSettings::default().merged(&partial).unwrap(); - let img = lightcraft_scenes::demo_library()[0].render(96, 64); - let render = |d: &DevelopSettings| { - lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data - }; - assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture"); - } -} +mod tests; diff --git a/crates/engine/src/crs_masks/tests.rs b/crates/engine/src/crs_masks/tests.rs new file mode 100644 index 000000000..4e507df3e --- /dev/null +++ b/crates/engine/src/crs_masks/tests.rs @@ -0,0 +1,332 @@ +use super::*; +use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape}; + +/// A packet in the shape local corrections take (values written for this test). +const PACKET: &str = r#" + + + + + + + + + + + + + + + + + + + + + + + d 0.1 0.2d 0.15 0.22 + d 0.5 0.5 + + +"#; + +fn settings() -> (DevelopSettings, Vec) { + let d = lightcraft_meta::parse_xmp(PACKET).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped) +} + +#[test] +fn corrections_become_masks() { + let (s, unmapped) = settings(); + assert_eq!(s.light.exposure, 0.2, "global settings still map"); + assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks); + let sky = &s.masks[0]; + assert_eq!(sky.name, "Darker sky"); + assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0)); + assert_eq!(sky.components[0].shape, MaskShape::Sky); + assert_eq!(sky.components[1].op, MaskOp::Intersect); + let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() }; + assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point"); + let rad = &s.masks[1]; + assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0)); + let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() }; + assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9); + assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}"); + assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside"); + assert!(!rad.components[0].invert); + let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() }; + assert!(lo < hi && *lo_feather > 0.0); + let brush = &s.masks[2]; + assert_eq!(brush.adjust.clarity, 40.0); + assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component"); + let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() }; + assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0)); + assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]); +} + +#[test] +fn masks_render() { + let (s, _) = settings(); + let img = lightcraft_scenes::demo_library()[0].render(96, 64); + let render = |d: &DevelopSettings| { + lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data + }; + assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); +} + +#[test] +fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { + // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack + let x = r#" + + + + + + + + +"#; + let d = lightcraft_meta::parse_xmp(x).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; + assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); + assert_eq!(s.masks[0].adjust.clarity, -30.0); + assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); +} + +#[test] +fn lrtemplate_corrections() { + let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { + { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = { + { What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#; + let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap(); + assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped); + let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); + assert_eq!(s.masks.len(), 1); + assert_eq!(s.masks[0].adjust.exposure, -2.0); +} + +#[test] +fn presets_add_masks_once() { + let (s, _) = settings(); + let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}}); + let p = + lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false }; + let mut mine = DevelopSettings::default(); + mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() }); + let once = p.apply(&mine, 1.0); + assert_eq!(once.masks.len(), 4, "added to the photo's own mask"); + assert_eq!(once.masks[1].id, 8, "fresh ids"); + assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing"); + let half = p.apply(&mine, 0.5); + assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks"); +} + +#[test] +fn sidecar_radials_fit_the_photo() { + let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]}); + refit_radials(&mut v, 1.5, 1.0); + let s = &v["masks"][0]["components"][0]["shape"]; + assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9); + assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); +} + +/// One radial correction in `container`, with `attrs` on its mask component (values written +/// for this test; the component attributes follow what Lightroom Classic writes). +fn radial_packet(container: &str, attrs: &str) -> String { + format!( + r#" + + + + + + + + +"# + ) +} + +fn radial_settings(container: &str, attrs: &str) -> DevelopSettings { + let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + assert!(unmapped.is_empty(), "{unmapped:?}"); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert_eq!(s.masks.len(), 1, "{:?}", s.masks); + s +} + +/// (the radial's `invert`, its feather, the component's `invert`). +fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) { + let c = &s.masks[0].components[0]; + let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) }; + (*invert, *feather, c.invert) +} + +/// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green). +fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) { + let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] }; + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image; + let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() })); + let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]); + (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3)) +} + +#[test] +fn lightroom_radial_flipped_is_inside() { + // as Lightroom Classic writes an ordinary radial mask today (#1815) + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}"); + // no MaskInverted at all reads the same + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); +} + +#[test] +fn lightroom_radial_mask_inverted_is_outside() { + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}"); + // version 2 with Flipped="false": outside, the legacy sense of the flag + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#); + assert_eq!(radial_of(&s), (true, 25.0, false)); +} + +#[test] +fn versionless_group_radial_is_inside_with_half_the_feather() { + // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2" + for flipped in ["true", "false"] { + let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#)); + assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}"); + } +} + +#[test] +fn legacy_radial_filter_is_outside_unless_flipped() { + // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true" + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#); + assert_eq!(radial_of(&s), (true, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}"); + assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false)); + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#); + assert_eq!(radial_of(&s), (false, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}"); +} + +#[test] +fn lrtemplate_radial_filter_reads_flipped() { + // .lrtemplate presets and catalog develop settings are the same Lua tables + let t = |flipped: &str| { + format!( + r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{ + {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{ + {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"# + ) + }; + for (flipped, outside) in [("true", false), ("false", true)] { + let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap(); + let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); + assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}"); + } +} + +/// Spot removal in the shape Lightroom Classic writes it (values written for this test): +/// a healed circle with an explicit source, a cloned circle with an automatic source, a +/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. +const SPOTS: &str = r#" + + + centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal + + + + + + + + + + + d 0.1 0.8d 0.12 0.82d 0.14 0.84 + + + + + + + + +"#; + +#[test] +fn retouch_areas_become_spots() { + use lightcraft_develop::SpotMode; + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + assert_eq!(s.light.exposure, 0.1, "global settings still map"); + assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots); + let heal = &s.spots[0]; + assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0)); + assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9); + assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions"); + let src = heal.source_offset.expect("explicit source"); + assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}"); + let clone = &s.spots[1]; + assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None)); + let brushed = &s.spots[2]; + assert_eq!((brushed.points.len(), brushed.size), (3, 0.03)); + let src = brushed.source_offset.expect("explicit source"); + assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9); + let circ = &s.spots[3]; + assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9); + assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}"); +} + +#[test] +fn portrait_target_scales_spot_sizes() { + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size); +} + +#[test] +fn legacy_retouch_info_is_read_on_its_own() { + let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored"); + let d = lightcraft_meta::parse_xmp(&packet).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert_eq!(s.spots.len(), 1, "{:?}", s.spots); + assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal); + assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9); + assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9)); + assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}"); + assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}"); +} + +#[test] +fn spots_render() { + let d = lightcraft_meta::parse_xmp(SPOTS).unwrap(); + let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(96, 64); + let render = |d: &DevelopSettings| { + lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data + }; + assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture"); +}