mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Merge PR #2246: Reference app catalog import: turn masks into the photo's upright frame
This commit is contained in:
@@ -693,6 +693,7 @@ fn mapped_settings(
|
||||
aspect: f64,
|
||||
orientation: Orientation,
|
||||
stored_aspect: f64,
|
||||
user: Orientation,
|
||||
) -> Result<(Value, Vec<String>), String> {
|
||||
let mut root = crate::preset_import::parse_lua(text)?;
|
||||
let deferred = strip_lua_sentinels(&mut root);
|
||||
@@ -703,8 +704,13 @@ fn mapped_settings(
|
||||
if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) {
|
||||
props.insert("crs:HasCrop".into(), vec!["True".into()]);
|
||||
}
|
||||
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect);
|
||||
// Mask edges are fractions of the stored image, like the crop's, so radii go to long-edge units
|
||||
// with its aspect: `aspect` is the photo's in LightCraft, which is the shown one for a photo
|
||||
// already in the library and only stands in when the catalog has no file size.
|
||||
let mask_aspect = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { aspect };
|
||||
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, mask_aspect);
|
||||
catalog_crop(&mut partial, orientation, stored_aspect);
|
||||
catalog_masks(&mut partial, in_photo_frame(orientation, user));
|
||||
if deferred > 0 {
|
||||
unknown.push("Deferred Lightroom adjustments (-999999)".into());
|
||||
}
|
||||
@@ -764,6 +770,71 @@ fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f6
|
||||
geometry.insert("angle".into(), json!(angle));
|
||||
}
|
||||
|
||||
/// The catalog's orientation (the stored image to the photo as Lightroom shows it), then the
|
||||
/// photo's own Rotate Left / Right and Flip in LightCraft (`user`), which an update of a photo
|
||||
/// already in the library keeps: the frame LightCraft draws masks in.
|
||||
fn in_photo_frame(catalog: Orientation, user: Orientation) -> Orientation {
|
||||
let (flip, turns) = user.to_parts();
|
||||
let o = if flip { catalog.flipped_h() } else { catalog };
|
||||
(0..turns).fold(o, |o, _| o.rotated(true))
|
||||
}
|
||||
|
||||
/// Lightroom's masks in LightCraft's frame. Like the crop's corners, their positions are on the
|
||||
/// stored image (before orientation), normalized to its size; LightCraft's are on the oriented
|
||||
/// image. So the points go through the orientation, a radial's radii (in long-edge units, from the
|
||||
/// stored image's aspect) swap when the axes do, and a mirror turns its angle the other way. A
|
||||
/// quarter turn keeps the angle: the ellipse turned 90° is the same ellipse with its radii swapped.
|
||||
fn catalog_masks(partial: &mut Value, orientation: Orientation) {
|
||||
if orientation == Orientation::Normal {
|
||||
return;
|
||||
}
|
||||
let point = |p: Option<&mut Value>| {
|
||||
let Some(p) = p.and_then(Value::as_object_mut) else { return };
|
||||
let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else { return };
|
||||
let (u, v) = orientation.map(x, y, 1.0, 1.0);
|
||||
p.insert("x".into(), json!(u));
|
||||
p.insert("y".into(), json!(v));
|
||||
};
|
||||
let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return };
|
||||
let components = masks.iter_mut().filter_map(|m| m.get_mut("components").and_then(Value::as_array_mut)).flatten();
|
||||
for shape in components.filter_map(|c| c.get_mut("shape").and_then(Value::as_object_mut)) {
|
||||
point(shape.get_mut("start"));
|
||||
point(shape.get_mut("end"));
|
||||
point(shape.get_mut("center"));
|
||||
let strokes = shape.get_mut("strokes").and_then(Value::as_array_mut).into_iter().flatten();
|
||||
for points in strokes.filter_map(|s| s.get_mut("points").and_then(Value::as_array_mut)) {
|
||||
points.iter_mut().for_each(|p| point(Some(p)));
|
||||
}
|
||||
if shape.get("kind").and_then(Value::as_str) == Some("radial") {
|
||||
if orientation.swaps_axes()
|
||||
&& let (Some(rx), Some(ry)) = (shape.get("rx").cloned(), shape.get("ry").cloned())
|
||||
{
|
||||
shape.insert("rx".into(), ry);
|
||||
shape.insert("ry".into(), rx);
|
||||
}
|
||||
if orientation.to_parts().0
|
||||
&& let Some(angle) = shape.get("angle").and_then(Value::as_f64)
|
||||
{
|
||||
shape.insert("angle".into(), json!(-angle));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog's XMP masks in LightCraft's frame, as [`catalog_masks`] puts the develop row's: they
|
||||
/// are on the same stored image, and they stay when the row has no masks of its own. The packet's
|
||||
/// radials come fitted to the sidecar default aspect and `sidecar::merge_into` re-fits them to the
|
||||
/// photo's size, so they are fitted to the stored image to be turned (a quarter turn swaps the
|
||||
/// radii) and handed back in the default aspect, for that re-fit to undo.
|
||||
fn catalog_xmp_masks(partial: &mut Value, orientation: Orientation, stored_aspect: f64, photo: &Photo) {
|
||||
use crate::crs_masks::{DEFAULT_ASPECT, refit_radials};
|
||||
let merged = if photo.width > 0 && photo.height > 0 { photo.width as f64 / photo.height as f64 } else { DEFAULT_ASPECT };
|
||||
let fit = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { merged };
|
||||
refit_radials(partial, DEFAULT_ASPECT, fit);
|
||||
catalog_masks(partial, orientation);
|
||||
refit_radials(partial, merged, DEFAULT_ASPECT);
|
||||
}
|
||||
|
||||
fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize {
|
||||
use crate::preset_import::Lua;
|
||||
match value {
|
||||
@@ -1023,11 +1094,14 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
|
||||
if keep_develop {
|
||||
preserved += 1;
|
||||
}
|
||||
let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default();
|
||||
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
|
||||
if !src.xmp.is_empty() {
|
||||
match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) {
|
||||
Ok(mut sc) => {
|
||||
if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop {
|
||||
strip_xmp_sentinels(partial);
|
||||
catalog_xmp_masks(partial, in_photo_frame(orientation, p.develop.orientation), stored_aspect, &p);
|
||||
}
|
||||
crate::sidecar::merge_into(&mut p, &sc, &now);
|
||||
}
|
||||
@@ -1048,14 +1122,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
|
||||
p.meta.keywords = src.keywords.clone();
|
||||
}
|
||||
if !src.settings.is_empty() && !keep_develop {
|
||||
let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default();
|
||||
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
|
||||
match mapped_settings(
|
||||
&src.settings,
|
||||
crate::crs::Target::for_photo(&p),
|
||||
p.width.max(1) as f64 / p.height.max(1) as f64,
|
||||
orientation,
|
||||
stored_aspect,
|
||||
p.develop.orientation,
|
||||
) {
|
||||
Ok((partial, unknown)) => {
|
||||
p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0));
|
||||
|
||||
@@ -173,8 +173,15 @@ fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() {
|
||||
assert!(apply(&mut s, cyclic, false).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), before);
|
||||
assert!(
|
||||
mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
|
||||
.is_err()
|
||||
mapped_settings(
|
||||
&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)),
|
||||
crate::crs::Target::RawAbsolute,
|
||||
1.0,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
Orientation::Normal
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
@@ -191,6 +198,7 @@ fn compressed_xmp_is_bounded_and_settings_are_data_only() {
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], 0.75);
|
||||
@@ -202,11 +210,11 @@ fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() {
|
||||
// relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin
|
||||
let settings =
|
||||
"s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }";
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap();
|
||||
assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}");
|
||||
assert_eq!(partial["light"]["exposure"], 0.5);
|
||||
assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}");
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap();
|
||||
assert_eq!(
|
||||
(partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()),
|
||||
(Some("custom"), Some(3578.0), Some(-5.0))
|
||||
@@ -223,6 +231,7 @@ fn catalog_crops_without_has_crop_are_imported() {
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
)
|
||||
.unwrap();
|
||||
let crop = &partial["crop"]["geometry"];
|
||||
@@ -233,11 +242,13 @@ fn catalog_crops_without_has_crop_are_imported() {
|
||||
assert_eq!(crop["angle"], 0.0);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}");
|
||||
// an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's)
|
||||
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, unknown) =
|
||||
mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap();
|
||||
assert_eq!(partial["crop"]["geometry"]["angle"], 1.5);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}");
|
||||
// no crop fields: the crop stays untouched
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
@@ -261,7 +272,7 @@ fn lightroom_orientation_codes() {
|
||||
fn catalog_crops_follow_the_photo_orientation() {
|
||||
let crop = |o: Orientation| {
|
||||
let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }";
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap();
|
||||
let g = &partial["crop"]["geometry"];
|
||||
let r = &g["rect"];
|
||||
[r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9)
|
||||
@@ -274,16 +285,74 @@ fn catalog_crops_follow_the_photo_orientation() {
|
||||
assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]);
|
||||
// a mirror turns the straighten the other way
|
||||
let angle = |o: Orientation| {
|
||||
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap();
|
||||
partial["crop"]["geometry"]["angle"].as_f64().unwrap()
|
||||
};
|
||||
assert_eq!(angle(Orientation::Rotate90), -2.0);
|
||||
assert_eq!(angle(Orientation::FlipH), 2.0);
|
||||
// no crop: nothing to orient
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap();
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5, Orientation::Normal).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_masks_follow_the_photo_orientation() {
|
||||
// #1806, EMY_9996 (EXIF 8, shown a quarter turn counter-clockwise): a horizon gradient along
|
||||
// the stored image's x axis runs along the shown photo's y axis
|
||||
let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.1, CorrectionMasks = {
|
||||
{ What = "Mask/Gradient", MaskValue = 1, ZeroX = 0.362544, ZeroY = 0.481093, FullX = 0.451639, FullY = 0.480645 } } } } }"#;
|
||||
let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate270, 1.5, Orientation::Normal).unwrap();
|
||||
let g = &partial["masks"][0]["components"][0]["shape"];
|
||||
let close = |v: &Value, want: f64| (v.as_f64().unwrap() - want).abs() < 1e-9;
|
||||
assert!(close(&g["start"]["x"], 0.480645) && close(&g["start"]["y"], 1.0 - 0.451639), "{g}");
|
||||
assert!(close(&g["end"]["x"], 0.481093) && close(&g["end"]["y"], 1.0 - 0.362544), "{g}");
|
||||
|
||||
let masks = |o: Orientation| {
|
||||
let s = r#"s = {
|
||||
MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = {
|
||||
{ What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 },
|
||||
{ What = "Mask/CircularGradient", Left = 0, Right = 0.2, Top = 0.1, Bottom = 0.3, Angle = 10, Feather = 50, Version = 2, Flipped = true },
|
||||
{ What = "Mask/Paint", Radius = 0.05, MaskValue = 1, Dabs = { "d 0.1 0.2", "d 0.3 0.6" } } } } } }"#;
|
||||
let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap();
|
||||
let c = &partial["masks"][0]["components"];
|
||||
let (linear, radial, brush) = (&c[0]["shape"], &c[1]["shape"], &c[2]["shape"]["strokes"][0]["points"]);
|
||||
[
|
||||
&linear["start"]["x"],
|
||||
&linear["start"]["y"],
|
||||
&linear["end"]["x"],
|
||||
&linear["end"]["y"],
|
||||
&radial["center"]["x"],
|
||||
&radial["center"]["y"],
|
||||
&radial["rx"],
|
||||
&radial["ry"],
|
||||
&radial["angle"],
|
||||
&brush[0]["x"],
|
||||
&brush[0]["y"],
|
||||
&brush[1]["x"],
|
||||
&brush[1]["y"],
|
||||
]
|
||||
.map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9)
|
||||
};
|
||||
// (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise;
|
||||
// a radial's radii (in long-edge units) swap with the axes, and a mirror turns its angle the
|
||||
// other way
|
||||
let r = (0.1, (0.1_f64 / 1.5 * 1e9).round() / 1e9);
|
||||
assert_eq!(masks(Orientation::Normal), [0.1, 0.2, 0.3, 0.6, 0.1, 0.2, r.0, r.1, 10.0, 0.1, 0.2, 0.3, 0.6]);
|
||||
assert_eq!(masks(Orientation::Rotate90), [0.8, 0.1, 0.4, 0.3, 0.8, 0.1, r.1, r.0, 10.0, 0.8, 0.1, 0.4, 0.3]);
|
||||
assert_eq!(masks(Orientation::Rotate270), [0.2, 0.9, 0.6, 0.7, 0.2, 0.9, r.1, r.0, 10.0, 0.2, 0.9, 0.6, 0.7]);
|
||||
assert_eq!(masks(Orientation::Rotate180), [0.9, 0.8, 0.7, 0.4, 0.9, 0.8, r.0, r.1, 10.0, 0.9, 0.8, 0.7, 0.4]);
|
||||
assert_eq!(masks(Orientation::FlipH), [0.9, 0.2, 0.7, 0.6, 0.9, 0.2, r.0, r.1, -10.0, 0.9, 0.2, 0.7, 0.6]);
|
||||
assert_eq!(masks(Orientation::Transpose), [0.2, 0.1, 0.6, 0.3, 0.2, 0.1, r.1, r.0, -10.0, 0.2, 0.1, 0.6, 0.3]);
|
||||
// a photo already in the library has its shown size (portrait here), but Lightroom's edges are
|
||||
// fractions of the stored image: a circle on a 3:2 stored image stays a circle
|
||||
let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = {
|
||||
{ What = "Mask/CircularGradient", Left = 0.3, Right = 0.5, Top = 0.3, Bottom = 0.6, Version = 2, Flipped = true } } } } }"#;
|
||||
let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.0 / 1.5, Orientation::Rotate90, 1.5, Orientation::Normal).unwrap();
|
||||
let radial = &partial["masks"][0]["components"][0]["shape"];
|
||||
assert!(close(&radial["rx"], 0.1) && close(&radial["ry"], 0.1), "{radial}");
|
||||
}
|
||||
|
||||
/// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image
|
||||
/// of aspect `w` (height 1) shown with orientation `o`.
|
||||
fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String {
|
||||
@@ -306,7 +375,8 @@ fn straightened_catalog_crops_round_trip() {
|
||||
for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] {
|
||||
for angle in [6.5, -6.5] {
|
||||
let g = CropGeometry { angle, ..g };
|
||||
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
let (partial, _) =
|
||||
mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap();
|
||||
let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap();
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(
|
||||
@@ -336,7 +406,7 @@ fn straightened_catalog_crops_match_lightroom_exports() {
|
||||
(6100.0, 9150.0),
|
||||
),
|
||||
] {
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap();
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0, Orientation::Normal).unwrap();
|
||||
let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap();
|
||||
let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) };
|
||||
let (w, h) = (r.width() * ow, r.height() * oh);
|
||||
@@ -359,6 +429,87 @@ fn imported_crops_use_the_catalog_orientation() {
|
||||
assert_eq!(r, [0.1, 0.1, 0.8, 0.6]);
|
||||
}
|
||||
|
||||
/// Masks only the catalog's XMP has (the develop row has none to replace them) are on the stored
|
||||
/// image too: on a vertical shot (`DA`) they turn like the row's, and a radial keeps its shape on
|
||||
/// the 2:1 stored image, also for a photo already in the library at its shown 10 × 20.
|
||||
#[test]
|
||||
fn catalog_xmp_masks_follow_the_photo_orientation() {
|
||||
let mut data = sample();
|
||||
data.photos.truncate(1);
|
||||
data.photos[0].image.remove("masterImage");
|
||||
data.photos[0].image.insert("orientation".into(), json!("DA"));
|
||||
data.photos[0].image.insert("fileWidth".into(), json!(20));
|
||||
data.photos[0].image.insert("fileHeight".into(), json!(10));
|
||||
data.photos[0].settings = "s = { Exposure2012 = 0.5 }".into();
|
||||
data.photos[0].xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
|
||||
<crs:MaskGroupBasedCorrections><rdf:Seq>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:LocalExposure2012="-0.25">
|
||||
<crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/Gradient" crs:FullX="0.1" crs:FullY="0.2" crs:ZeroX="0.3" crs:ZeroY="0.6"/>
|
||||
<rdf:li crs:What="Mask/CircularGradient" crs:Left="0.3" crs:Right="0.5" crs:Top="0.3" crs:Bottom="0.6" crs:Flipped="true" crs:Version="2"/>
|
||||
</rdf:Seq></crs:CorrectionMasks>
|
||||
</rdf:Description></rdf:li>
|
||||
</rdf:Seq></crs:MaskGroupBasedCorrections>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
.into();
|
||||
let mut s = crate::Session::new();
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let shown = Photo::new(id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 10, 20, "before");
|
||||
s.commit("Browse folder", Op::AddPhoto { photo: Box::new(shown) }).unwrap();
|
||||
apply(&mut s, data, true).unwrap();
|
||||
assert_eq!(s.catalog.len(), 1, "the catalog's photo is the one already in the library");
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
assert_eq!(p.develop.light.exposure, 0.5, "the develop row is applied too");
|
||||
let c = &serde_json::to_value(&p.develop.masks).unwrap()[0]["components"];
|
||||
let (linear, radial) = (&c[0]["shape"], &c[1]["shape"]);
|
||||
let r = [&linear["start"]["x"], &linear["start"]["y"], &linear["end"]["x"], &linear["end"]["y"], &radial["center"]["x"], &radial["center"]["y"]]
|
||||
.map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9);
|
||||
// (x, y) → (y, 1 − x), a quarter turn counter-clockwise
|
||||
assert_eq!(r, [0.2, 0.9, 0.6, 0.7, 0.45, 0.6], "{c}");
|
||||
// 4 × 3 px of a 20 × 10 stored image, in long-edge units, with the axes swapped
|
||||
let radii = [&radial["rx"], &radial["ry"]].map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9);
|
||||
assert_eq!(radii, [0.075, 0.1], "{radial}");
|
||||
}
|
||||
|
||||
/// A photo already in the library keeps its Rotate Left / Right on update, so the catalog's masks
|
||||
/// go through it too: an upright (`AB`) photo turned right in LightCraft gets its gradient turned.
|
||||
#[test]
|
||||
fn updated_masks_follow_the_rotation_kept_from_the_library() {
|
||||
let mut data = sample();
|
||||
data.photos.truncate(1);
|
||||
data.photos[0].image.remove("masterImage");
|
||||
data.photos[0].image.insert("orientation".into(), json!("AB"));
|
||||
data.photos[0].settings =
|
||||
r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = {
|
||||
{ What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 } } } } }"#
|
||||
.into();
|
||||
data.photos[0].xmp = String::new();
|
||||
let mut s = crate::Session::new();
|
||||
apply(&mut s, data.clone(), false).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
let turned = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() };
|
||||
s.set_develop(id, turned, "Rotate Right").unwrap();
|
||||
apply(&mut s, data, true).unwrap();
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
assert_eq!(p.develop.orientation, Orientation::Rotate90, "the update keeps the photo's rotation");
|
||||
let g = &serde_json::to_value(&p.develop.masks).unwrap()[0]["components"][0]["shape"];
|
||||
let r = [&g["start"]["x"], &g["start"]["y"], &g["end"]["x"], &g["end"]["y"]].map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9);
|
||||
// (x, y) → (1 − y, x), a quarter turn clockwise
|
||||
assert_eq!(r, [0.8, 0.1, 0.4, 0.3], "{g}");
|
||||
// the catalog's orientation first, then the photo's own, for every pair
|
||||
for a in 1..=8 {
|
||||
for b in 1..=8 {
|
||||
let (a, b) = (Orientation::from_exif(a), Orientation::from_exif(b));
|
||||
for (x, y) in [(0.1, 0.2), (0.7, 0.4)] {
|
||||
let (u, v) = a.map(x, y, 1.0, 1.0);
|
||||
let ((p, q), (want_p, want_q)) = (in_photo_frame(a, b).map(x, y, 1.0, 1.0), b.map(u, v, 1.0, 1.0));
|
||||
assert!((p - want_p).abs() < 1e-9 && (q - want_q).abs() < 1e-9, "{a:?} then {b:?}: ({p}, {q}) for ({want_p}, {want_q})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_auto_tone_sentinels_never_become_real_slider_values() {
|
||||
let mut data = sample();
|
||||
@@ -374,8 +525,15 @@ fn deferred_auto_tone_sentinels_never_become_real_slider_values() {
|
||||
assert_eq!(p.develop.light.shadows, 65.0);
|
||||
assert_eq!(p.develop.light.blacks, -22.0);
|
||||
assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred"))));
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings(
|
||||
"s = { Exposure2012 = -5, Contrast2012 = -100 }",
|
||||
crate::crs::Target::RawAbsolute,
|
||||
1.0,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], -5.0);
|
||||
assert_eq!(partial["light"]["contrast"], -100.0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user