From 7e0250e11e45f928cbab966b30ee7d4c0a06aafc Mon Sep 17 00:00:00 2001 From: Exalted5373 <247056922+Exalted5373@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:01:23 +0000 Subject: [PATCH 1/3] LR-IMP-PROFILES: don't report preset bookkeeping fields as unmapped XMP presets carry fields that describe the preset or the photo rather than an adjustment (Cluster, SupportsAmount2, RequiresRGBTables, SortName, Description, CropConstrainToWarp, OverrideLookVignette, AsShotTemperature / AsShotTint). preset.import listed them as settings that couldn't be carried over; so did an HDREditMode of 0 and PointColors slots that are all -1. They are now treated as bookkeeping; an HDR edit, a used Point Color slot and unknown adjustments are still reported. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/crs.rs | 55 ++++++++++++++++++++++++++++++++++++++-- docs/parity.md | 2 +- docs/whats-new.md | 3 +++ docs/xmp-interop.md | 2 +- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index dce623d02..d7cbe67f3 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -44,6 +44,17 @@ const NON_ADJUSTMENT: &[&str] = &[ "crs:AlreadyApplied", "crs:RawFileName", "crs:HasCrop", + // preset/library bookkeeping and tool options that don't change the rendering + "crs:Cluster", + "crs:SupportsAmount2", + "crs:RequiresRGBTables", + "crs:SortName", + "crs:Description", + "crs:CropConstrainToWarp", + "crs:OverrideLookVignette", + // the file's as-shot white, recorded next to the edit (ours comes from the file itself) + "crs:AsShotTemperature", + "crs:AsShotTint", ]; /// True if the packet carries any `crs:` adjustment (not just bookkeeping fields). @@ -373,8 +384,19 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect(); } } - // fields that only switch a panel on/off or name things: not adjustments by themselves - let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix"; + // fields that only switch a panel on/off or name things: not adjustments by themselves; an + // HDR edit mode that is off, and Point Color slots that are all empty (-1) + let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v)); + let off = |v: &[String]| v.iter().all(|s| s.trim() == "0"); + let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::().is_ok_and(|x| x == -1.0) || n.trim().is_empty())); + let quiet = |k: &str| { + k.starts_with("Enable") + || k.starts_with("ToneCurveName") + || k == "AutoTone" + || k == "AutoGrayscaleMix" + || (k == "HDREditMode" && value_is(k, &off)) + || (k == "PointColors" && value_is(k, &empty_points)) + }; let mut unmapped: Vec = props .keys() .filter(|k| k.starts_with("crs:")) @@ -539,4 +561,33 @@ mod tests { assert!(!p.builtin); assert!(preset_from_xmp("", "f").is_none()); } + + /// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry. + fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String { + format!( + r#" + + + + {extra_elems} + "# + ) + } + + fn unmapped(x: &str) -> Vec { + to_partial_report(&props(x), None, None, 1.5).1 + } + + #[test] + fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { + let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; + assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); + // an HDR edit, a used Point Color slot and an unknown adjustment still are + let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; + let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); + assert_eq!(got, ["FutureSlider", "HDREditMode", "PointColors"]); + } } diff --git a/docs/parity.md b/docs/parity.md index b9c5eed5a..25d2ea9be 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -132,7 +132,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 99ed3157b..acf63a293 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -45,6 +45,8 @@ - Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders with a counterpart here come along, the rest is listed. - 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. +- Importing XMP presets no longer lists bookkeeping fields (`Cluster`, `SortName`, `SupportsAmount2`, the as-shot + white, empty Point Color slots…) as settings that couldn't be carried over. - Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay favourites across restarts (issue #328). @@ -82,6 +84,7 @@ ### Library - Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341). +) ### Reliability - If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 003b8774d..32fc7e49f 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -170,7 +170,7 @@ carry over are listed in `preset.import`'s `unmapped` as `Mask: `. |---|---| | Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. | | Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. | -| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | +| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, an off `HDREditMode`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | | XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. | LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`). From 09d5284e5ad74b39616711f5755e5103df09a011 Mon Sep 17 00:00:00 2001 From: Exalted5373 <247056922+Exalted5373@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:03:08 +0000 Subject: [PATCH 2/3] LR-EDIT-OPTICS-PROFILE: a preset's LensProfileEnable=0 keeps embedded lens corrections XMP presets usually say LensProfileEnable=0, which turned optics.lens_profile off and with it the DNG-embedded WarpRectilinear / FixVignetteRadial corrections (iPhone ProRAW and other files that carry them). Lightroom applies a file's built-in corrections whatever that switch says (our inference), and LightCraft's profile corrections are only those embedded ones, so crs import now carries over only an enabling switch. The Enable Lens Corrections checkbox and the amount sliders keep working as before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/crs.rs | 21 ++++++++++++++++++--- docs/parity.md | 3 ++- docs/whats-new.md | 3 +++ docs/xmp-interop.md | 2 +- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index d7cbe67f3..0d9b7a771 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -298,9 +298,12 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { n(o, "GrainSize", "grain.size"); n(o, "GrainFrequency", "grain.roughness"); - // ---- Optics (manual corrections; lens profiles are ours, only the switch carries over) - if let Some(b) = boolean(props, "crs:LensProfileEnable") { - put(o, "optics.lens_profile", json!(b)); + // ---- Optics (manual corrections; lens profiles are ours, only the switch carries over). + // Our profile corrections are the file's own (DNG-embedded) ones, which Lightroom applies + // whatever its "Enable Profile Corrections" box says [inferred]: so only an enabling switch + // carries over, and `LensProfileEnable=0` (the default in most presets) leaves ours as it is. + if boolean(props, "crs:LensProfileEnable") == Some(true) { + put(o, "optics.lens_profile", json!(true)); } if let Some(b) = boolean(props, "crs:AutoLateralCA") { put(o, "optics.remove_ca", json!(b)); @@ -590,4 +593,16 @@ mod tests { let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); assert_eq!(got, ["FutureSlider", "HDREditMode", "PointColors"]); } + + #[test] + fn disabled_lens_profile_switch_keeps_embedded_corrections() { + // Lightroom applies a file's built-in lens corrections regardless of its profile switch, + // and ours are only those: a preset's `LensProfileEnable=0` must not turn them off + let p = props(&preset_packet("", "")); + let mut d = DevelopSettings::default(); + d.optics.lens_profile = true; + assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile); + let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); + assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, None), 1.0).optics.lens_profile); + } } diff --git a/docs/parity.md b/docs/parity.md index 25d2ea9be..c8bc33dad 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -265,7 +265,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database | +) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | | LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index acf63a293..f128c9dbb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -47,6 +47,8 @@ - 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. - Importing XMP presets no longer lists bookkeeping fields (`Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots…) as settings that couldn't be carried over. +- A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW + and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere. - Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay favourites across restarts (issue #328). @@ -85,6 +87,7 @@ - Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341). ) +) ### Reliability - If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 32fc7e49f..85b41a958 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -128,7 +128,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`). | `PostCropVignetteAmount/Midpoint/Roundness/Feather/HighlightContrast` | `vignette.amount/midpoint/roundness/feather/highlights` | | | `PostCropVignetteStyle` | `vignette.style` | 1 highlight priority, 2 colour priority, 3 paint overlay | | `GrainAmount`, `GrainSize`, `GrainFrequency` | `grain.amount`, `grain.size`, `grain.roughness` | | -| `LensProfileEnable`, `AutoLateralCA` | `optics.lens_profile`, `optics.remove_ca` | the switch only; lens profiles are our own | +| `LensProfileEnable`, `AutoLateralCA` | `optics.lens_profile`, `optics.remove_ca` | lens profiles are the file's own (DNG-embedded) corrections, which Lightroom applies whatever its profile switch says (our inference): only `LensProfileEnable=1` carries over, `0` leaves the switch as it is | | `LensManualDistortionAmount`, `VignetteAmount`, `VignetteMidpoint` | `optics.distortion`, `optics.vignetting`, `optics.vignetting_midpoint` | | | `DefringePurple/GreenAmount/HueLo/HueHi` | `optics.defringe_*` | | | `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 | From 31aa638383fa489e0f3df3fe51e362cf74ca27da Mon Sep 17 00:00:00 2001 From: Exalted5373 <247056922+Exalted5373@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:53:36 -0700 Subject: [PATCH 3/3] LR-IMP-PROFILES: red / green / blue curves only with the master curve; neutral test values Lightroom Classic reads `ToneCurvePV2012Red/Green/Blue` only together with `ToneCurvePV2012`: a packet with a channel curve and no master curve leaves the photo unchanged there (rendered on a chart: the channel curve alone has no effect, with an identity master curve it applies). Imports now do the same. The preset-packet test helper uses made-up as-shot values. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/crs.rs | 33 +++++++++++++++++++++++++++------ docs/whats-new.md | 1 + docs/xmp-interop.md | 2 +- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 0d9b7a771..0b0c83b94 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -244,11 +244,14 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { put(o, "curve.master", c); } } - for (crs, ch) in - [("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] - { - if let Some(c) = curve(props, &format!("crs:{crs}")) { - put(o, &format!("curve.{ch}"), c); + // Lightroom Classic reads the red / green / blue curves only together with the master 2012 + // curve: a channel curve without `ToneCurvePV2012` leaves the photo unchanged + if let Some(c) = curve(props, "crs:ToneCurvePV2012") { + put(o, "curve.master", c); + for (crs, ch) in [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] { + if let Some(c) = curve(props, &format!("crs:{crs}")) { + put(o, &format!("curve.{ch}"), c); + } } } @@ -572,7 +575,7 @@ mod tests { + crs:CropConstrainToWarp="0" crs:AsShotTemperature="5500" crs:AsShotTint="10" {extra_attrs}> {extra_elems} @@ -580,6 +583,24 @@ mod tests { ) } + #[test] + fn channel_curves_need_the_master_curve() { + // Lightroom ignores a red / green / blue curve without `ToneCurvePV2012` + let packet = |master: &str| { + format!( + r#" + {master} + 0, 064, 40255, 255 + "# + ) + }; + let alone = to_partial(&props(&packet("")), Some(true)); + assert!(alone.pointer("/curve/green").is_none(), "{alone}"); + let master = "0, 0255, 255"; + let with = to_partial(&props(&packet(master)), Some(true)); + assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}"); + } + fn unmapped(x: &str) -> Vec { to_partial_report(&props(x), None, None, 1.5).1 } diff --git a/docs/whats-new.md b/docs/whats-new.md index f128c9dbb..9e884c14c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -49,6 +49,7 @@ white, empty Point Color slots…) as settings that couldn't be carried over. - A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere. +- A red / green / blue curve in an XMP preset without the master curve is ignored, as Lightroom ignores it. - Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay favourites across restarts (issue #328). diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 85b41a958..735432312 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -117,7 +117,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`). | `ParametricShadows`, `ParametricDarks`, `ParametricLights`, `ParametricHighlights` | `curve.shadows/darks/lights/highlights` | | | `ParametricShadowSplit`, `ParametricMidtoneSplit`, `ParametricHighlightSplit` | `curve.split_shadows/split_mid/split_highlights` | | | `CurveRefineSaturation` | `curve.refine_saturation` | 0..100 (100 = curve saturation unchanged) | -| `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve | +| `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve; the red / green / blue curves are read only with `ToneCurvePV2012` (Lightroom Classic ignores them without it) | | `SplitToningShadowHue/Saturation`, `SplitToningHighlightHue/Saturation` | `grading.shadows/highlights.hue/sat` | | | `ColorGradeShadowLum`, `ColorGradeHighlightLum` | `grading.shadows/highlights.lum` | | | `ColorGradeMidtoneHue/Sat/Lum`, `ColorGradeGlobalHue/Sat/Lum` | `grading.midtones/global.*` | |