diff --git a/README.md b/README.md
index 1cf517475..2a6e72be3 100644
--- a/README.md
+++ b/README.md
@@ -224,9 +224,10 @@ LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estima
| RAW: DNG, CR2, ARW, NEF (uncompressed) | ✅ · compressed NEF, CR3, RAF, ORF, RW2, PEF… 🚧 |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source) | ✅ · content-aware fill (PatchMatch), Red Eye 🚧 |
-| Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch | ✅ · metadata policy, watermark, DNG ⬜ |
-| MCP server, CLI, control channel | ✅ |
-| Optics, Geometry/Upright, GPU pipeline, persistent library | 🚧 |
+| Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch, metadata policy, text watermark | ✅ · DNG export, image watermark ⬜ |
+| Library persistence (crash-safe op log + snapshots), disk thumbnail cache, import with duplicate detection | ✅ |
+| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
+| Optics, Geometry/Upright, XMP sidecars, GPU pipeline | 🚧 |
| Web build (same UI in the browser via WASM; in-memory imports, export downloads) | ✅ · persistence, workers 🚧 |
diff --git a/ROADMAP.md b/ROADMAP.md
index 584ed3e7c..91d7f54df 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -11,7 +11,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | 🚧 (UI, pipeline, engine, control channel ✅; MCP/CLI, web 🚧) |
| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | 🚧 (import, codecs, EXIF/XMP, albums, search ✅; persistence 🚧) |
| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ⬜ |
-| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW ✅) |
+| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed, embedded previews ✅; NEF Huffman ⬜) |
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ⬜ |
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | ⬜ |
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | ⬜ |
@@ -19,7 +19,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | ⬜ |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ⬜ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | ⬜ |
-| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | ⬜ |
+| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ⬜ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
@@ -44,6 +44,30 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
HEVC (HEIC).
- **Look parity** with Adobe's default rendering is subjective tuning against our own reference targets.
+## Raw format coverage and known gaps
+
+Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
+LJ92, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed, RAF uncompressed (Bayer and
+X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
+supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
+it can't decode yet.
+
+Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
+- **Nikon Huffman NEF** (lossless / lossy). Black-box findings so far: maker note `0x0096` holds per-parity predictor
+ seeds (2048 for 14-bit lossless); ≈ 9 bits per pixel; the category-0 code word is a 6-bit rotation of `011111`. The
+ Huffman tables are not stored in the files, and an automatic table search (beam search over prefix codes scored by
+ smoothness and bit rate, validated on Pentax files whose tables *are* stored) did not converge.
+- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit
+ blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences
+ from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
+- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
+
+**Camera colour matrices:** non-DNG raws use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
+`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
+matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax/Panasonic equivalents) and our
+own chart-based calibration (M11.4). Adobe matrices are never used.
+
## Log
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
+- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs
index 21817cda9..c902a2589 100644
--- a/apps/lightcraft-web/src/web.rs
+++ b/apps/lightcraft-web/src/web.rs
@@ -89,6 +89,9 @@ fn services(store: MemStore, ctx: egui::Context) -> Services {
open_picker(store.clone(), ctx.clone());
Vec::new() // files arrive asynchronously and are imported on a later frame
})),
+ // Preset files: browser pickers are asynchronous; not wired on the web yet.
+ pick_preset_files: None,
+ save_preset_file: None,
write: Some(Box::new(download)),
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs
index 5b39ee4e6..0d10bab7e 100644
--- a/apps/lightcraft/src/main.rs
+++ b/apps/lightcraft/src/main.rs
@@ -78,13 +78,37 @@ fn services() -> Services {
Services {
pick_files: Some(Box::new(|| {
rfd::FileDialog::new()
- .add_filter("Photos", &["jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "nef", "arw", "psd", "jxl", "gif", "bmp"])
+ .add_filter(
+ "Photos",
+ &[
+ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd",
+ "jxl", "gif", "bmp",
+ ],
+ )
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
+ pick_preset_files: Some(Box::new(|| {
+ rfd::FileDialog::new()
+ .set_title("Import Presets")
+ .add_filter("Presets", &["lcpreset", "xmp"])
+ .pick_files()
+ .unwrap_or_default()
+ .into_iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect()
+ })),
+ save_preset_file: Some(Box::new(|name: &str| {
+ rfd::FileDialog::new()
+ .set_title("Export Presets")
+ .add_filter("LightCraft Preset", &["lcpreset"])
+ .set_file_name(name)
+ .save_file()
+ .map(|p| p.to_string_lossy().to_string())
+ })),
write: Some(Box::new(|p: &str, b: &[u8]| {
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 3b3cd5e24..3e1b8c540 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -8,7 +8,9 @@ mod develop;
mod edit;
mod library;
mod masks;
+mod preset_files;
mod query;
+mod xmp;
use serde::Serialize;
use serde_json::Value;
@@ -101,6 +103,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(develop::specs());
v.extend(masks::specs());
v.extend(query::specs());
+ v.extend(xmp::specs());
+ v.extend(preset_files::specs());
v
})
}
diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs
new file mode 100644
index 000000000..71f32af32
--- /dev/null
+++ b/crates/engine/src/cmd/preset_files.rs
@@ -0,0 +1,86 @@
+//! Preset files: import `.lcpreset` / XMP presets, export `.lcpreset`.
+
+use serde_json::{Value, json};
+
+use super::{CommandSpec, always, bad, cmd, str_param};
+use crate::presets::{LCPRESET_EXT, expand_preset_paths, parse_preset_file, to_lcpreset};
+use crate::{Result, Session};
+
+fn strings(p: &Value, key: &str) -> Option> {
+ p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
+}
+
+fn import(s: &mut Session, p: &Value) -> Result {
+ let paths = strings(p, "paths").filter(|v| !v.is_empty()).ok_or_else(|| bad("preset.import", "no paths"))?;
+ let mut imported = Vec::new();
+ let mut failed = Vec::new();
+ let mut skipped = 0usize;
+ for f in expand_preset_paths(&paths) {
+ let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| parse_preset_file(&f, &b));
+ match parsed {
+ Ok(list) => {
+ let n = list.len();
+ let ids = s.add_presets(list);
+ skipped += n - ids.len();
+ for id in ids {
+ if let Some(pr) = s.presets.iter().find(|x| x.id == id) {
+ imported.push(json!({"id": pr.id, "name": pr.name, "group": pr.group}));
+ }
+ }
+ }
+ Err(e) => failed.push(json!([f, e])),
+ }
+ }
+ Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
+}
+
+fn export(s: &mut Session, p: &Value) -> Result {
+ let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad("preset.export", "missing path"))?;
+ let mut path = std::path::PathBuf::from(path);
+ if path.extension().is_none() {
+ path.set_extension(LCPRESET_EXT);
+ }
+ let ids = strings(p, "ids");
+ let group = str_param(p, "group");
+ let chosen: Vec<_> = s
+ .presets
+ .iter()
+ .filter(|x| match (&ids, group) {
+ (Some(ids), _) => ids.contains(&x.id),
+ (None, Some(g)) => x.group == g,
+ (None, None) => !x.builtin,
+ })
+ .cloned()
+ .collect();
+ if chosen.is_empty() {
+ return Err(bad("preset.export", "no presets to export (create one first, or pass ids/group)"));
+ }
+ if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
+ std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
+ }
+ std::fs::write(&path, to_lcpreset(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
+ Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
+}
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(
+ "preset.import",
+ "Import Presets",
+ [],
+ None,
+ "{paths: [.lcpreset / .xmp file or folder]} — groups preserved; XMP presets map crs: fields (docs/xmp-interop.md) → {imported: [{id,name,group}], skipped, failed}",
+ always,
+ import
+ ),
+ cmd!(
+ "preset.export",
+ "Export Presets",
+ [],
+ None,
+ "{path, ids?: [presetId], group?: name} (default: all user presets) → {path, count}; writes a .lcpreset JSON file",
+ always,
+ export
+ ),
+ ]
+}
diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs
new file mode 100644
index 000000000..7b7d4325e
--- /dev/null
+++ b/crates/engine/src/cmd/xmp.rs
@@ -0,0 +1,93 @@
+//! XMP sidecar commands: save/read metadata to/from files, sidecar preferences.
+
+use lightcraft_catalog::Op;
+use serde_json::{Value, json};
+
+use super::{CommandSpec, always, bad, cmd, has_selection, str_param};
+use crate::sidecar::SidecarNaming;
+use crate::{Result, Session};
+
+fn save(s: &mut Session, p: &Value) -> Result {
+ let mut written = Vec::new();
+ let mut failed = Vec::new();
+ for id in s.targets(p) {
+ match s.save_sidecar(id) {
+ Ok(path) => written.push(path.display().to_string()),
+ Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
+ }
+ }
+ Ok(json!({"written": written, "failed": failed}))
+}
+
+fn read(s: &mut Session, p: &Value) -> Result {
+ let mut ops = Vec::new();
+ let mut read = Vec::new();
+ let mut failed = Vec::new();
+ for id in s.targets(p) {
+ match s.read_sidecar_op(id) {
+ Ok(Some((op, from))) => {
+ ops.push(op);
+ read.push(json!({"id": id.0, "from": from.display().to_string()}));
+ }
+ Ok(None) => failed.push(json!({"id": id.0, "error": "no XMP sidecar or embedded XMP"})),
+ Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
+ }
+ }
+ if !ops.is_empty() {
+ s.commit("Read Metadata from File", Op::Batch { ops })?;
+ }
+ Ok(json!({"read": read, "failed": failed}))
+}
+
+fn prefs(s: &mut Session, p: &Value) -> Result {
+ if let Some(a) = p.get("autoWrite").and_then(Value::as_bool) {
+ s.xmp.auto_write = a;
+ }
+ if let Some(n) = str_param(p, "naming") {
+ s.xmp.naming = match n {
+ "stem" => SidecarNaming::Stem,
+ "full" => SidecarNaming::Full,
+ other => return Err(bad("library.xmpPreferences", format!("unknown naming `{other}` (stem|full)"))),
+ };
+ }
+ if p.as_object().is_some_and(|o| !o.is_empty()) {
+ s.save_prefs()?;
+ }
+ Ok(serde_json::to_value(s.xmp).unwrap_or_default())
+}
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(
+ "photo.saveMetadataToFile",
+ "Save Metadata to File",
+ ["Photo"],
+ Some("Cmd+S"),
+ "{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original → {written: [path], failed}",
+ has_selection,
+ save
+ ),
+ cmd!(
+ "photo.readMetadataFromFile",
+ "Read Metadata from File",
+ ["Photo"],
+ None,
+ "{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed}",
+ has_selection,
+ read
+ ),
+ cmd!(
+ "library.xmpPreferences",
+ "XMP Sidecar Preferences",
+ [],
+ None,
+ "{autoWrite?: bool, naming?: stem (IMG_1.xmp, default) | full (IMG_1.CR3.xmp)} → current preferences (saved with the library)",
+ always,
+ prefs
+ ),
+ cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| {
+ let on = !s.xmp.auto_write;
+ prefs(s, &json!({"autoWrite": on}))
+ }),
+ ]
+}
diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs
new file mode 100644
index 000000000..d35641e7e
--- /dev/null
+++ b/crates/engine/src/crs.rs
@@ -0,0 +1,446 @@
+//! Interchange: camera-raw-settings (`crs:`) develop fields from XMP → our develop settings.
+//!
+//! Other raw developers store their edits as `crs:` properties in XMP sidecars, in DNG-embedded
+//! XMP and in XMP preset files. This module maps the commonly used, documented-by-observation
+//! fields to a *partial* [`DevelopSettings`](lightcraft_develop::DevelopSettings) JSON object
+//! (only the fields present in the packet), which is then merged like a preset. Our pipeline is
+//! not theirs, so the result is a best-effort approximation of the look, not a pixel match.
+//! The full table is in `docs/xmp-interop.md`.
+//!
+//! Implemented from the public XMP specification (ISO 16684-1) and black-box observation of what
+//! each field means; no third-party code or preset content was used.
+
+use std::collections::BTreeMap;
+
+use lightcraft_develop::{MIXER_BANDS, Preset};
+use serde_json::{Map, Value, json};
+
+/// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]).
+pub type Props = BTreeMap>;
+
+/// Band names as they appear in `crs:` field names, in our mixer order.
+const CRS_BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
+
+/// `crs:` fields that describe the packet rather than an adjustment.
+const NON_ADJUSTMENT: &[&str] = &[
+ "crs:Version",
+ "crs:ProcessVersion",
+ "crs:Name",
+ "crs:ShortName",
+ "crs:Group",
+ "crs:UUID",
+ "crs:PresetType",
+ "crs:SupportsAmount",
+ "crs:SupportsColor",
+ "crs:SupportsMonochrome",
+ "crs:SupportsHighDynamicRange",
+ "crs:SupportsNormalDynamicRange",
+ "crs:SupportsSceneReferred",
+ "crs:SupportsOutputReferred",
+ "crs:CameraModelRestriction",
+ "crs:Copyright",
+ "crs:ContactInfo",
+ "crs:HasSettings",
+ "crs:AlreadyApplied",
+ "crs:RawFileName",
+ "crs:HasCrop",
+];
+
+/// True if the packet carries any `crs:` adjustment (not just bookkeeping fields).
+pub fn has_adjustments(props: &Props) -> bool {
+ if props.get("crs:AlreadyApplied").and_then(|v| v.first()).is_some_and(|s| s.eq_ignore_ascii_case("true")) {
+ // The pixels already contain these settings (e.g. an exported/rendered file).
+ return false;
+ }
+ props.keys().any(|k| k.starts_with("crs:") && !NON_ADJUSTMENT.contains(&k.split('/').next().unwrap_or(k)))
+}
+
+fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
+ props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
+}
+
+fn num(props: &Props, k: &str) -> Option {
+ let s = first(props, k)?;
+ let s = s.strip_prefix('+').unwrap_or(s);
+ if let Some((n, d)) = s.split_once('/') {
+ let (n, d) = (n.trim().parse::().ok()?, d.trim().parse::().ok()?);
+ return (d != 0.0).then_some(n / d).filter(|v| v.is_finite());
+ }
+ s.parse::().ok().filter(|v| v.is_finite())
+}
+
+fn boolean(props: &Props, k: &str) -> Option {
+ first(props, k).map(|s| s.eq_ignore_ascii_case("true") || s == "1")
+}
+
+/// Insert `v` at the dotted `path` of a JSON object, creating intermediate objects.
+fn put(out: &mut Value, path: &str, v: Value) {
+ let mut cur = out;
+ let mut parts = path.split('.').peekable();
+ while let Some(p) = parts.next() {
+ let Value::Object(m) = cur else { return };
+ if parts.peek().is_none() {
+ m.insert(p.to_string(), v);
+ return;
+ }
+ cur = m.entry(p.to_string()).or_insert_with(|| Value::Object(Map::new()));
+ }
+}
+
+/// Relative temperature (−100..100, used for rendered files) → Kelvin, matching the UI's scale
+/// (a mired shift around 6500 K).
+pub fn rel_to_kelvin(r: f64) -> f64 {
+ 1e6 / (1e6 / 6500.0 - r.clamp(-100.0, 100.0) * 0.8)
+}
+
+/// Parse a point curve stored as an `rdf:Seq` of `"x, y"` strings in 0..255.
+fn curve(props: &Props, k: &str) -> Option {
+ let items = props.get(k)?;
+ let mut pts = Vec::new();
+ for it in items {
+ let (x, y) = it.split_once(',')?;
+ let (x, y) = (x.trim().parse::().ok()?, y.trim().parse::().ok()?);
+ pts.push(json!({"x": (x / 255.0).clamp(0.0, 1.0), "y": (y / 255.0).clamp(0.0, 1.0)}));
+ }
+ if pts.len() < 2 {
+ return None;
+ }
+ // A straight 0,0 → 255,255 line is the identity: store it as "no curve".
+ let identity = pts.len() == 2 && items.iter().all(|it| it.split(',').map(|v| v.trim()).collect::>().windows(2).all(|w| w[0] == w[1]));
+ Some(if identity { json!([]) } else { Value::Array(pts) })
+}
+
+/// Map the `crs:` fields of an XMP packet to a partial develop-settings JSON object.
+///
+/// `raw`: the target is a raw file (absolute Kelvin white balance) — `Some(false)` prefers the
+/// relative `Incremental*` white balance used for rendered files, `None` (presets) takes whichever
+/// is present, absolute first.
+pub fn to_partial(props: &Props, raw: Option) -> Value {
+ let mut out = Value::Object(Map::new());
+ let o = &mut out;
+ let n = |o: &mut Value, crs: &str, path: &str| {
+ if let Some(v) = num(props, &format!("crs:{crs}")) {
+ put(o, path, json!(v));
+ }
+ };
+ // ---- Light (process version 2012+ names)
+ n(o, "Exposure2012", "light.exposure");
+ n(o, "Contrast2012", "light.contrast");
+ n(o, "Highlights2012", "light.highlights");
+ n(o, "Shadows2012", "light.shadows");
+ n(o, "Whites2012", "light.whites");
+ n(o, "Blacks2012", "light.blacks");
+ // ---- Presence
+ n(o, "Texture", "effects.texture");
+ n(o, "Clarity2012", "effects.clarity");
+ n(o, "Dehaze", "effects.dehaze");
+ n(o, "Vibrance", "color.vibrance");
+ n(o, "Saturation", "color.saturation");
+
+ // ---- White balance
+ let mode = first(props, "crs:WhiteBalance").map(|m| match m.to_ascii_lowercase().replace(' ', "").as_str() {
+ "asshot" => "asShot",
+ "auto" => "auto",
+ "daylight" => "daylight",
+ "cloudy" => "cloudy",
+ "shade" => "shade",
+ "tungsten" => "tungsten",
+ "fluorescent" => "fluorescent",
+ "flash" => "flash",
+ _ => "custom",
+ });
+ let abs = num(props, "crs:Temperature").map(|t| (t, num(props, "crs:Tint")));
+ let rel = num(props, "crs:IncrementalTemperature")
+ .or(num(props, "crs:IncrementalTint").map(|_| 0.0))
+ .map(|t| (rel_to_kelvin(t), num(props, "crs:IncrementalTint")));
+ let wb = match raw {
+ Some(true) => abs,
+ Some(false) => rel.or(abs),
+ None => abs.or(rel),
+ };
+ match (mode, wb) {
+ (Some(m), _) if m != "custom" => put(o, "wb.mode", json!(m)),
+ (m, Some((t, tint))) => {
+ put(o, "wb.mode", json!(m.unwrap_or("custom")));
+ put(o, "wb.temp", json!(t));
+ if let Some(tint) = tint {
+ put(o, "wb.tint", json!(tint));
+ }
+ }
+ (Some(m), None) => put(o, "wb.mode", json!(m)),
+ (None, None) => {}
+ }
+
+ // ---- Treatment + B&W mix
+ if let Some(bw) = boolean(props, "crs:ConvertToGrayscale") {
+ put(o, "treatment", json!(if bw { "bw" } else { "color" }));
+ }
+ for (band, crs) in MIXER_BANDS.iter().zip(CRS_BANDS) {
+ n(o, &format!("HueAdjustment{crs}"), &format!("mixer.{band}.hue"));
+ n(o, &format!("SaturationAdjustment{crs}"), &format!("mixer.{band}.sat"));
+ n(o, &format!("LuminanceAdjustment{crs}"), &format!("mixer.{band}.lum"));
+ n(o, &format!("GrayMixer{crs}"), &format!("bw_mix.{band}"));
+ }
+
+ // ---- Tone curve: parametric regions + point curves
+ n(o, "ParametricShadows", "curve.shadows");
+ n(o, "ParametricDarks", "curve.darks");
+ n(o, "ParametricLights", "curve.lights");
+ n(o, "ParametricHighlights", "curve.highlights");
+ n(o, "ParametricShadowSplit", "curve.split_shadows");
+ n(o, "ParametricMidtoneSplit", "curve.split_mid");
+ n(o, "ParametricHighlightSplit", "curve.split_highlights");
+ 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);
+ }
+ }
+
+ // ---- Color grading (split toning fields are shared with the older split-toning panel)
+ n(o, "SplitToningShadowHue", "grading.shadows.hue");
+ n(o, "SplitToningShadowSaturation", "grading.shadows.sat");
+ n(o, "ColorGradeShadowLum", "grading.shadows.lum");
+ n(o, "SplitToningHighlightHue", "grading.highlights.hue");
+ n(o, "SplitToningHighlightSaturation", "grading.highlights.sat");
+ n(o, "ColorGradeHighlightLum", "grading.highlights.lum");
+ n(o, "ColorGradeMidtoneHue", "grading.midtones.hue");
+ n(o, "ColorGradeMidtoneSat", "grading.midtones.sat");
+ n(o, "ColorGradeMidtoneLum", "grading.midtones.lum");
+ n(o, "ColorGradeGlobalHue", "grading.global.hue");
+ n(o, "ColorGradeGlobalSat", "grading.global.sat");
+ n(o, "ColorGradeGlobalLum", "grading.global.lum");
+ n(o, "ColorGradeBlending", "grading.blending");
+ n(o, "SplitToningBalance", "grading.balance");
+
+ // ---- Detail
+ n(o, "Sharpness", "detail.sharpen_amount");
+ n(o, "SharpenRadius", "detail.sharpen_radius");
+ n(o, "SharpenDetail", "detail.sharpen_detail");
+ n(o, "SharpenEdgeMasking", "detail.sharpen_masking");
+ n(o, "LuminanceSmoothing", "detail.nr_luminance");
+ n(o, "LuminanceNoiseReductionDetail", "detail.nr_detail");
+ n(o, "LuminanceNoiseReductionContrast", "detail.nr_contrast");
+ n(o, "ColorNoiseReduction", "detail.nr_color");
+ n(o, "ColorNoiseReductionDetail", "detail.nr_color_detail");
+ n(o, "ColorNoiseReductionSmoothness", "detail.nr_color_smoothness");
+
+ // ---- Effects: post-crop vignette + grain
+ n(o, "PostCropVignetteAmount", "vignette.amount");
+ n(o, "PostCropVignetteMidpoint", "vignette.midpoint");
+ n(o, "PostCropVignetteRoundness", "vignette.roundness");
+ n(o, "PostCropVignetteFeather", "vignette.feather");
+ n(o, "PostCropVignetteHighlightContrast", "vignette.highlights");
+ if let Some(st) = num(props, "crs:PostCropVignetteStyle") {
+ let style = match st as i64 {
+ 2 => "colorPriority",
+ 3 => "paintOverlay",
+ _ => "highlightPriority",
+ };
+ put(o, "vignette.style", json!(style));
+ }
+ n(o, "GrainAmount", "grain.amount");
+ 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));
+ }
+ if let Some(b) = boolean(props, "crs:AutoLateralCA") {
+ put(o, "optics.remove_ca", json!(b));
+ }
+ n(o, "LensManualDistortionAmount", "optics.distortion");
+ n(o, "VignetteAmount", "optics.vignetting");
+ n(o, "VignetteMidpoint", "optics.vignetting_midpoint");
+ n(o, "DefringePurpleAmount", "optics.defringe_purple_amount");
+ n(o, "DefringePurpleHueLo", "optics.defringe_purple_hue_lo");
+ n(o, "DefringePurpleHueHi", "optics.defringe_purple_hue_hi");
+ n(o, "DefringeGreenAmount", "optics.defringe_green_amount");
+ n(o, "DefringeGreenHueLo", "optics.defringe_green_hue_lo");
+ n(o, "DefringeGreenHueHi", "optics.defringe_green_hue_hi");
+
+ // ---- Geometry
+ n(o, "PerspectiveVertical", "geometry.vertical");
+ n(o, "PerspectiveHorizontal", "geometry.horizontal");
+ n(o, "PerspectiveRotate", "geometry.rotate");
+ n(o, "PerspectiveScale", "geometry.scale");
+ n(o, "PerspectiveAspect", "geometry.aspect");
+ n(o, "PerspectiveX", "geometry.offset_x");
+ n(o, "PerspectiveY", "geometry.offset_y");
+ if let Some(u) = num(props, "crs:PerspectiveUpright") {
+ let mode = match u as i64 {
+ 1 => "auto",
+ 2 => "level",
+ 3 => "vertical",
+ 4 => "full",
+ 5 => "guided",
+ _ => "off",
+ };
+ put(o, "geometry.upright", json!(mode));
+ }
+
+ // ---- Crop (normalized edges of the unrotated image + straighten angle)
+ match boolean(props, "crs:HasCrop") {
+ Some(true) => {
+ let e = |k: &str, d: f64| num(props, &format!("crs:Crop{k}")).unwrap_or(d).clamp(0.0, 1.0);
+ let (l, t, r, b) = (e("Left", 0.0), e("Top", 0.0), e("Right", 1.0), e("Bottom", 1.0));
+ if r > l && b > t {
+ put(
+ o,
+ "crop.geometry",
+ json!({"rect": {"x0": l, "y0": t, "x1": r, "y1": b}, "angle": num(props, "crs:CropAngle").unwrap_or(0.0).clamp(-45.0, 45.0)}),
+ );
+ }
+ }
+ Some(false) => put(o, "crop.geometry", json!({"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0})),
+ None => {}
+ }
+ out
+}
+
+/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
+/// Returns `None` when the packet has no adjustments we understand.
+pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option {
+ let d = lightcraft_meta::parse_xmp(xmp).ok()?;
+ let props = &d.properties;
+ let settings = to_partial(props, None);
+ if settings.as_object().is_none_or(Map::is_empty) {
+ return None;
+ }
+ let name = first(props, "crs:Name").unwrap_or(fallback_name).to_string();
+ let group = first(props, "crs:Group").unwrap_or("Imported Presets").to_string();
+ let uuid = first(props, "crs:UUID").map(|u| u.to_ascii_lowercase());
+ let id = format!("user.xmp.{}", uuid.unwrap_or_else(|| crate::presets::slug(&format!("{group}-{name}"))));
+ Some(Preset { id, name, group, settings, favorite: false, builtin: false })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use lightcraft_develop::{DevelopSettings, Upright, VignetteStyle, WbMode, apply_partial};
+
+ /// A hand-written sidecar in attribute form (as many tools write it).
+ const SIDECAR: &str = r#"
+
+
+
+ 0, 064, 52192, 205255, 255
+
+
+ 0, 0255, 255
+
+
+
+"#;
+
+ fn props(x: &str) -> Props {
+ lightcraft_meta::parse_xmp(x).unwrap().properties
+ }
+
+ #[test]
+ fn maps_common_fields() {
+ let p = props(SIDECAR);
+ assert!(has_adjustments(&p));
+ let partial = to_partial(&p, Some(true));
+ let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
+ assert_eq!(s.wb.mode, WbMode::Custom);
+ assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0));
+ assert_eq!(s.light.exposure, 0.65);
+ assert_eq!((s.light.contrast, s.light.highlights, s.light.shadows, s.light.whites, s.light.blacks), (-14.0, -58.0, 41.0, 9.0, -17.0));
+ assert_eq!((s.effects.texture, s.effects.clarity, s.effects.dehaze), (6.0, 11.0, 4.0));
+ assert_eq!((s.color.vibrance, s.color.saturation), (19.0, -3.0));
+ assert_eq!(s.mixer.orange.hue, -7.0);
+ assert_eq!(s.mixer.blue.sat, -22.0);
+ assert_eq!(s.mixer.aqua.lum, 13.0);
+ assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0));
+ assert_eq!(s.curve.master.len(), 4);
+ assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9);
+ assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve");
+ assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0));
+ assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0));
+ assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0));
+ assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0));
+ assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0));
+ assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0));
+ assert_eq!(s.vignette.amount, -21.0);
+ assert_eq!(s.vignette.style, VignetteStyle::ColorPriority);
+ assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0));
+ assert_eq!(s.geometry.upright, Upright::Level);
+ assert_eq!(s.geometry.vertical, -10.0);
+ assert!(s.optics.remove_ca);
+ assert_eq!(s.treatment, lightcraft_develop::Treatment::Color);
+ let c = s.crop.geometry;
+ assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5));
+ }
+
+ #[test]
+ fn only_present_fields_are_in_the_partial() {
+ let x = r#""#;
+ let partial = to_partial(&props(x), None);
+ assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}}));
+ }
+
+ #[test]
+ fn element_form_bw_and_incremental_wb() {
+ let x = r#"
+
+ True
+ -35
+ 25
+ -6
+ 4800
+ "#;
+ let p = props(x);
+ let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(false)), 1.0);
+ assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw);
+ assert_eq!(rendered.bw_mix.blue, -35.0);
+ assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0);
+ assert_eq!(rendered.wb.tint, -6.0);
+ let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(true)), 1.0);
+ assert_eq!(raw.wb.temp, 4800.0);
+ }
+
+ #[test]
+ fn named_wb_and_already_applied() {
+ let x = r#""#;
+ let p = props(x);
+ assert_eq!(to_partial(&p, Some(true)), json!({"wb": {"mode": "daylight"}}));
+ assert!(!has_adjustments(&p));
+ let bookkeeping = r#""#;
+ assert!(!has_adjustments(&props(bookkeeping)));
+ }
+
+ #[test]
+ fn xmp_preset_with_name_and_group() {
+ // Written for this test: a minimal preset-shaped packet with localized name/group.
+ let x = r#"
+
+ Punchy Test
+ My Looks
+ "#;
+ let p = preset_from_xmp(x, "file").unwrap();
+ assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Punchy Test", "My Looks", "user.xmp.0123abcd"));
+ assert_eq!(p.settings, json!({"light": {"contrast": 25.0}}));
+ assert!(!p.builtin);
+ assert!(preset_from_xmp("", "f").is_none());
+ }
+}
diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs
index 5645341e1..c4dfa0772 100644
--- a/crates/engine/src/files.rs
+++ b/crates/engine/src/files.rs
@@ -92,7 +92,26 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result {
let m = lightcraft_meta::extract(bytes);
let (meta, captured) = meta_of(&m);
if lightcraft_raw::probe(bytes).is_some() {
- let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
+ let raw = match lightcraft_raw::decode(bytes) {
+ Ok(r) => r,
+ Err(lightcraft_raw::RawError::Unsupported(why)) => {
+ // a raw variant we can't decode yet: describe it from its embedded preview
+ let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?;
+ return Ok(ProbeInfo {
+ width: w,
+ height: h,
+ format: ext_upper(name),
+ kind: MediaKind::Raw,
+ file_size: bytes.len() as u64,
+ captured,
+ meta,
+ as_shot_wb: None,
+ content_hash,
+ ..Default::default()
+ });
+ }
+ Err(e) => return Err(e.to_string()),
+ };
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
if w <= 1 || h <= 1 {
(w, h) = (raw.active_area.width as u32, raw.active_area.height as u32);
@@ -114,6 +133,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result {
meta,
as_shot_wb,
content_hash,
+ xmp: lightcraft_meta::embedded(bytes).xmp,
});
}
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
@@ -142,13 +162,21 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result {
as_shot_wb: None,
content_hash,
embedded_lens: None,
+ xmp: None,
})
}
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
if lightcraft_raw::probe(bytes).is_some() {
- let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
+ let mut raw = match lightcraft_raw::decode(bytes) {
+ Ok(r) => r,
+ Err(lightcraft_raw::RawError::Unsupported(why)) => {
+ // show the camera's embedded JPEG (rendered, not raw) until the variant is supported
+ return load_embedded_preview(bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview"));
+ }
+ Err(e) => return Err(e.to_string()),
+ };
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
let lens = embedded_lens(&raw);
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
@@ -178,6 +206,34 @@ pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo),
Ok((img.oriented(Orientation::from_exif(d.orientation)), SourceInfo::default()))
}
+/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's.
+fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
+ if jpeg_orientation > 1 {
+ return Orientation::from_exif(jpeg_orientation);
+ }
+ lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
+}
+
+/// Oriented size of the embedded preview of a raw file.
+fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
+ let jpeg = lightcraft_raw::embedded_preview(bytes)?;
+ let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
+ let (mut w, mut h) = (d.source_width, d.source_height);
+ if preview_orientation(bytes, d.orientation).swaps_axes() {
+ std::mem::swap(&mut w, &mut h);
+ }
+ Some((w, h))
+}
+
+/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
+pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
+ let jpeg = lightcraft_raw::embedded_preview(bytes)?;
+ let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
+ let img = d.to_working();
+ let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
+ Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
+}
+
/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead.
pub fn fs_hooks() -> (FileLoader, FileProbe) {
let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| {
@@ -200,3 +256,36 @@ impl crate::Session {
self
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
+
+ /// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box.
+ fn cr3_with_preview(w: u32, h: u32) -> Vec {
+ let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
+ let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
+ let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec();
+ b.extend_from_slice(&((24 + jpeg.len()) as u32).to_be_bytes());
+ b.extend_from_slice(b"PRVW\0\0\0\0\0\x01");
+ b.extend_from_slice(&(w as u16).to_be_bytes());
+ b.extend_from_slice(&(h as u16).to_be_bytes());
+ b.extend_from_slice(b"\0\x01");
+ b.extend_from_slice(&(jpeg.len() as u32).to_be_bytes());
+ b.extend_from_slice(&jpeg);
+ b
+ }
+
+ #[test]
+ fn unsupported_raw_falls_back_to_embedded_preview() {
+ let b = cr3_with_preview(48, 32);
+ let p = probe_bytes("x.cr3", &b).unwrap();
+ assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3"));
+ let (img, src) = load_bytes(&b, 24).unwrap();
+ assert_eq!((img.width, img.height), (24, 16));
+ assert!(!src.raw);
+ // no preview at all: a clear error, not a panic
+ assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err());
+ }
+}
diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs
index f02028555..3aac02034 100644
--- a/crates/engine/src/import.rs
+++ b/crates/engine/src/import.rs
@@ -8,7 +8,9 @@
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
-//! 6. Commit all new photos as one undoable op.
+//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
+//! metadata and develop settings are restored (see [`crate::sidecar`]).
+//! 7. Commit all new photos as one undoable op.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -21,8 +23,8 @@ use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
pub const EXTENSIONS: &[&str] = &[
- "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", "heic",
- "avif",
+ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp",
+ "heic", "avif",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -51,6 +53,8 @@ pub struct ImportReport {
pub failed: Vec<(String, String)>,
/// Files found after expanding folders.
pub scanned: usize,
+ /// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP).
+ pub sidecars: usize,
}
pub fn is_supported(path: &Path) -> bool {
@@ -224,6 +228,20 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
// built-in (file-embedded) lens corrections are on by default, like the camera intended
std::sync::Arc::make_mut(&mut p.develop).optics.lens_profile = true;
}
+ let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
+ let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
+ .and_then(|f| std::fs::read_to_string(f).ok())
+ .or_else(|| info.xmp.clone().filter(|_| raw));
+ if let Some(x) = packet {
+ match crate::sidecar::parse_sidecar(&x, raw) {
+ Ok(sc) if sc != crate::sidecar::SidecarData::default() => {
+ crate::sidecar::merge_into(&mut p, &sc, &now);
+ report.sidecars += 1;
+ }
+ Ok(_) => {}
+ Err(e) => log::warn!("import {path}: XMP: {e}"),
+ }
+ }
report.imported.push(id.0);
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 01ae5da90..f40c07570 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -11,6 +11,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
+pub mod crs;
pub mod demo;
pub mod export;
pub mod files;
@@ -18,6 +19,7 @@ pub mod import;
pub mod library;
pub mod media;
pub mod presets;
+pub mod sidecar;
mod view;
use std::sync::Arc;
@@ -90,6 +92,8 @@ pub struct Session {
pub active_mask: Option,
/// The persistent library this session writes to (`None` = in-memory only).
pub library: Option,
+ /// XMP sidecar preferences (persisted with the library).
+ pub xmp: sidecar::XmpPrefs,
}
impl Default for Session {
@@ -121,6 +125,7 @@ impl Session {
depth: 0,
active_mask: None,
library: None,
+ xmp: sidecar::XmpPrefs::default(),
}
}
@@ -137,6 +142,7 @@ impl Session {
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
+ let log_start = self.pending_log.len();
self.depth += 1;
let r = (spec.run)(self, params);
self.depth -= 1;
@@ -146,6 +152,9 @@ impl Session {
self.journal.drain(..1000);
}
}
+ if r.is_ok() && self.depth == 0 && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
+ self.auto_write_sidecars(&self.pending_log[log_start..]);
+ }
if self.depth == 0 && self.library.is_some() {
// Make the command durable before reporting success (on failure the ops stay pending,
// are retried after the next command, and `library.info` reports the error).
@@ -311,3 +320,5 @@ mod tests;
mod tests_import;
#[cfg(test)]
mod tests_library;
+#[cfg(test)]
+mod tests_xmp;
diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs
index 1ec5c4f41..e9979354c 100644
--- a/crates/engine/src/library.rs
+++ b/crates/engine/src/library.rs
@@ -5,6 +5,7 @@
//! LightCraft Library/
//! catalog.snap catalog.log (lightcraft-catalog journal)
//! presets.json view.json (user presets + favourites; last source/filter/sort/selection)
+//! prefs.json (library preferences: XMP sidecars)
//! thumbs/ (rendered thumbnail cache, safe to delete)
//! Originals/ (photos imported with "copy into library")
//! ```
@@ -72,6 +73,12 @@ impl Library {
}
}
+#[derive(Default, Serialize, Deserialize)]
+#[serde(default)]
+struct PrefsFile {
+ xmp: crate::sidecar::XmpPrefs,
+}
+
fn presets_json(presets: &[Preset]) -> String {
let f = PresetsFile {
user: presets.iter().filter(|p| !p.builtin).cloned().collect(),
@@ -115,6 +122,9 @@ impl Session {
}
}
}
+ // preferences
+ self.xmp =
+ std::fs::read(dir.join("prefs.json")).ok().and_then(|b| serde_json::from_slice::(&b).ok()).map(|p| p.xmp).unwrap_or_default();
// view state
if let Ok(bytes) = std::fs::read(dir.join("view.json"))
&& let Ok(v) = serde_json::from_slice::(&bytes)
@@ -189,6 +199,13 @@ impl Session {
Ok(())
}
+ /// Save the library preferences (no-op for in-memory sessions).
+ pub fn save_prefs(&self) -> Result<()> {
+ let Some(lib) = &self.library else { return Ok(()) };
+ let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp }).unwrap_or_default();
+ write_atomic(&lib.dir, "prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
+ }
+
/// Compact the log into a snapshot now.
pub fn compact_library(&mut self) -> Result<()> {
self.persist()?;
diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs
index 7c3285283..b4a4f6e6a 100644
--- a/crates/engine/src/media.rs
+++ b/crates/engine/src/media.rs
@@ -307,6 +307,8 @@ pub struct ProbeInfo {
pub content_hash: Option,
/// Lens corrections embedded in the file (DNG opcodes).
pub embedded_lens: Option,
+ /// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
+ pub xmp: Option,
}
pub type FileProbe = Arc Result + Send + Sync>;
diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs
index 86bbd0f97..f7994f061 100644
--- a/crates/engine/src/presets.rs
+++ b/crates/engine/src/presets.rs
@@ -1,8 +1,140 @@
-//! Built-in presets and profiles. All values are our own (no third-party preset content).
+//! Built-in presets and profiles (all values are our own; no third-party preset content), and
+//! preset files: our `.lcpreset` JSON (import + export, groups preserved) and XMP presets
+//! (`crs:` fields, read only — see [`crate::crs`]).
+
+use std::path::Path;
use lightcraft_develop::Preset;
-use serde::Serialize;
-use serde_json::json;
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+use crate::Session;
+
+/// File extension of LightCraft preset files.
+pub const LCPRESET_EXT: &str = "lcpreset";
+/// The `format` tag of a `.lcpreset` file.
+pub const LCPRESET_FORMAT: &str = "lightcraft.preset";
+
+/// A `.lcpreset` file: one or more presets with their groups.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct PresetFile {
+ pub format: String,
+ pub version: u32,
+ pub presets: Vec,
+}
+
+/// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`).
+pub fn slug(s: &str) -> String {
+ let mut out = String::new();
+ for c in s.chars() {
+ if c.is_ascii_alphanumeric() {
+ out.push(c.to_ascii_lowercase());
+ } else if !out.ends_with('-') && !out.is_empty() {
+ out.push('-');
+ }
+ }
+ let out = out.trim_end_matches('-').to_string();
+ if out.is_empty() { "preset".into() } else { out }
+}
+
+/// Serialise presets as a `.lcpreset` file (favourite/built-in flags are not exported).
+pub fn to_lcpreset(presets: &[Preset]) -> String {
+ let presets = presets.iter().map(|p| Preset { favorite: false, builtin: false, ..p.clone() }).collect();
+ serde_json::to_string_pretty(&PresetFile { format: LCPRESET_FORMAT.into(), version: 1, presets }).unwrap_or_default()
+}
+
+/// Read a preset file by name: `.lcpreset` (a [`PresetFile`], a bare preset object or an array of
+/// presets) or `.xmp` (a `crs:` preset).
+pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result, String> {
+ let stem = Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Preset".into());
+ let ext = Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
+ let text = String::from_utf8_lossy(bytes);
+ let text = text.trim_start_matches('\u{feff}');
+ if ext == "xmp" || (ext != LCPRESET_EXT && text.trim_start().starts_with('<')) {
+ return crate::crs::preset_from_xmp(text, &stem).map(|p| vec![p]).ok_or_else(|| "no develop settings in this XMP file".into());
+ }
+ let v: Value = serde_json::from_str(text).map_err(|e| format!("not a preset file: {e}"))?;
+ let list = match &v {
+ Value::Object(o) if o.contains_key("presets") => {
+ let f: PresetFile = serde_json::from_value(v.clone()).map_err(|e| e.to_string())?;
+ if f.format != LCPRESET_FORMAT {
+ return Err(format!("unknown preset format `{}`", f.format));
+ }
+ f.presets
+ }
+ Value::Array(_) => serde_json::from_value(v).map_err(|e| e.to_string())?,
+ _ => vec![serde_json::from_value(v).map_err(|e| e.to_string())?],
+ };
+ let mut out = Vec::new();
+ for mut p in list {
+ if !p.settings.is_object() {
+ return Err(format!("preset `{}` has no settings object", p.name));
+ }
+ p.builtin = false;
+ p.favorite = false;
+ if p.name.trim().is_empty() {
+ p.name = stem.clone();
+ }
+ if p.group.trim().is_empty() {
+ p.group = "Imported Presets".into();
+ }
+ out.push(p);
+ }
+ Ok(out)
+}
+
+/// Expand files/folders (recursively) into preset files (`.lcpreset`, `.xmp`).
+pub fn expand_preset_paths(paths: &[String]) -> Vec {
+ fn walk(p: &Path, out: &mut Vec, top: bool) {
+ if p.is_dir() {
+ let Ok(rd) = std::fs::read_dir(p) else { return };
+ let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
+ v.sort();
+ for c in v {
+ if !c.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
+ walk(&c, out, false);
+ }
+ }
+ } else {
+ let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
+ if top || ext == LCPRESET_EXT || ext == "xmp" {
+ out.push(p.to_string_lossy().to_string());
+ }
+ }
+ }
+ let mut out = Vec::new();
+ for p in paths {
+ walk(Path::new(p), &mut out, true);
+ }
+ out
+}
+
+impl Session {
+ /// Add imported presets. Identical presets (same name, group and settings) already present are
+ /// skipped; id clashes get a fresh id. Returns the ids added.
+ pub fn add_presets(&mut self, presets: Vec) -> Vec {
+ let mut added = Vec::new();
+ for mut p in presets {
+ if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) {
+ continue;
+ }
+ if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) {
+ let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
+ let mut id = base.clone();
+ let mut n = 2;
+ while self.presets.iter().any(|q| q.id == id) {
+ id = format!("{base}-{n}");
+ n += 1;
+ }
+ p.id = id;
+ }
+ p.builtin = false;
+ added.push(p.id.clone());
+ self.presets.push(p);
+ }
+ added
+ }
+}
fn p(group: &str, id: &str, name: &str, settings: serde_json::Value) -> Preset {
Preset { id: format!("lc.{id}"), name: name.into(), group: group.into(), settings, favorite: false, builtin: true }
@@ -147,4 +279,94 @@ mod tests {
assert_ne!(out, base, "{} had no effect", pr.id);
}
}
+
+ #[test]
+ fn slugs() {
+ assert_eq!(slug("Warm & Soft!"), "warm-soft");
+ assert_eq!(slug(" "), "preset");
+ assert_eq!(slug("B&W/Film 2"), "b-w-film-2");
+ }
+
+ fn dir(tag: &str) -> std::path::PathBuf {
+ let d = std::env::temp_dir().join(format!("lc-presets-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ d
+ }
+
+ #[test]
+ fn lcpreset_export_import_roundtrip_keeps_groups() {
+ let d = dir("rt");
+ let mut s = Session::with_demo();
+ s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.6})).unwrap();
+ s.execute("preset.create", &json!({"name": "Bright", "group": "Travel"})).unwrap();
+ s.execute("develop.set", &json!({"control": "effects.clarity", "value": 30})).unwrap();
+ s.execute("preset.create", &json!({"name": "Crisp", "group": "Street"})).unwrap();
+ let mine: Vec = s.presets.iter().filter(|p| !p.builtin).cloned().collect();
+ assert_eq!(mine.len(), 2);
+ let path = d.join("mine");
+ let r = s.execute("preset.export", &json!({"path": path.to_string_lossy()})).unwrap();
+ assert_eq!(r["count"], 2);
+ let file = d.join("mine.lcpreset");
+ assert!(file.is_file());
+ let one = d.join("travel.lcpreset");
+ s.execute("preset.export", &json!({"path": one.to_string_lossy(), "group": "Travel"})).unwrap();
+ assert!(s.execute("preset.export", &json!({"path": one.to_string_lossy(), "group": "Nope"})).is_err());
+
+ // a fresh session imports both files from the folder; the duplicate is skipped
+ let mut t = Session::new();
+ let r = t.execute("preset.import", &json!({"paths": [d.to_string_lossy()]})).unwrap();
+ assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
+ assert_eq!(r["skipped"], 1, "{r}");
+ for p in &mine {
+ let q = t.presets.iter().find(|q| q.name == p.name).unwrap();
+ assert_eq!((&q.group, &q.settings, q.builtin), (&p.group, &p.settings, false));
+ }
+ // importing into the session that has them: everything is skipped, nothing duplicated
+ let n = s.presets.len();
+ let r = s.execute("preset.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
+ assert_eq!((r["skipped"].as_u64(), s.presets.len()), (Some(2), n));
+ let _ = std::fs::remove_dir_all(&d);
+ }
+
+ #[test]
+ fn id_clashes_get_fresh_ids_and_bad_files_are_reported() {
+ let d = dir("clash");
+ let builtin = builtin().remove(0);
+ let mut clash = builtin.clone();
+ clash.settings = json!({"light": {"exposure": 1.0}});
+ std::fs::write(d.join("a.lcpreset"), serde_json::to_string(&clash).unwrap()).unwrap();
+ std::fs::write(d.join("b.lcpreset"), "{not json").unwrap();
+ std::fs::write(d.join("c.lcpreset"), r#"{"format": "other", "version": 1, "presets": []}"#).unwrap();
+ std::fs::write(d.join("notes.txt"), "ignored inside folders").unwrap();
+ let mut s = Session::new();
+ let r = s.execute("preset.import", &json!({"paths": [d.to_string_lossy()]})).unwrap();
+ assert_eq!(r["failed"].as_array().unwrap().len(), 2, "{r}");
+ let id = r["imported"][0]["id"].as_str().unwrap();
+ assert_ne!(id, builtin.id);
+ assert!(id.starts_with("user."));
+ assert!(s.presets.iter().find(|p| p.id == builtin.id).unwrap().builtin, "built-in untouched");
+ let _ = std::fs::remove_dir_all(&d);
+ }
+
+ #[test]
+ fn xmp_preset_file_imports_and_applies() {
+ let d = dir("xmp");
+ // Hand-written for this test (not a third-party preset).
+ let x = r#"
+
+ Test Group
+ "#;
+ std::fs::write(d.join("Soft Lift.xmp"), x).unwrap();
+ let mut s = Session::with_demo();
+ let r = s.execute("preset.import", &json!({"paths": [d.join("Soft Lift.xmp").to_string_lossy()]})).unwrap();
+ let id = r["imported"][0]["id"].as_str().unwrap().to_string();
+ assert_eq!(r["imported"][0]["name"], "Soft Lift", "file name when crs:Name is missing");
+ assert_eq!(r["imported"][0]["group"], "Test Group");
+ s.execute("preset.apply", &json!({"id": id})).unwrap();
+ let dv = s.develop_of(s.active().unwrap()).unwrap();
+ assert_eq!((dv.light.exposure, dv.light.shadows, dv.grain.amount), (0.3, 20.0, 12.0));
+ let _ = std::fs::remove_dir_all(&d);
+ }
}
diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs
new file mode 100644
index 000000000..c84c3eb11
--- /dev/null
+++ b/crates/engine/src/sidecar.rs
@@ -0,0 +1,369 @@
+//! XMP sidecars: `.xmp` (default) or `.xmp` next to the original.
+//!
+//! **Write** ([`Session::save_sidecar`], `photo.saveMetadataToFile`, or automatically after every
+//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
+//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
+//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
+//!
+//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; develop
+//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
+//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use lightcraft_catalog::{ColorLabel, Flag, MediaKind, Op, Photo, PhotoId, Source};
+use lightcraft_develop::DevelopSettings;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::{EngineError, Result, Session};
+
+/// Sidecar file naming.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SidecarNaming {
+ /// `IMG_0001.xmp` (the common convention; shared by `IMG_0001.CR3` and `IMG_0001.JPG`).
+ #[default]
+ Stem,
+ /// `IMG_0001.CR3.xmp` (unambiguous when raw+JPEG pairs live side by side).
+ Full,
+}
+
+/// Library preferences for XMP sidecars.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct XmpPrefs {
+ /// Write the sidecar after every change to a photo's metadata or develop settings.
+ pub auto_write: bool,
+ pub naming: SidecarNaming,
+}
+
+/// The sidecar path for an original, by naming convention.
+pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf {
+ match naming {
+ SidecarNaming::Stem => Path::new(original).with_extension("xmp"),
+ SidecarNaming::Full => PathBuf::from(format!("{original}.xmp")),
+ }
+}
+
+/// An existing sidecar for `original`: the preferred naming first, then the other one (also
+/// accepting an upper-case `.XMP`).
+pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option {
+ let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
+ [naming, other]
+ .into_iter()
+ .flat_map(|n| {
+ let p = sidecar_path(original, n);
+ let upper = p.with_extension("XMP");
+ [p, upper]
+ })
+ .find(|p| p.is_file())
+}
+
+/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct SidecarData {
+ pub rating: Option,
+ pub flag: Option,
+ pub label: Option