From 4a490db2619847f84b0b8dfddce8e47a8a66405e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:20:39 -0400 Subject: [PATCH] LR-PROF-IMPORT: .cube 3D LUTs as creative profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lightcraft-pipeline gains lut.rs: a .cube parser (LUT_3D_SIZE, DOMAIN_MIN/MAX, TITLE), a trilinear lookup and an in-process registry; the finishing stage applies a `lut:` profile to the display-encoded colour, blended by the profile amount (0–200 %); LUT profiles render on the CPU. profile.import {paths} reads files, folders and zips, copies each LUT into the library's Profiles/ folder, lists them in prefs and registers them when the library opens; the profile browser shows them under their folder / zip name; profile.deleteImported. File ▸ Import Profiles & Presets…, the file picker and drag & drop take .cube files. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/main.rs | 2 +- crates/engine/src/cmd/develop.rs | 4 +- crates/engine/src/cmd/lut_profiles.rs | 161 ++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 3 + crates/engine/src/library.rs | 5 + crates/engine/src/media.rs | 6 +- crates/engine/src/presets.rs | 30 ++++- crates/engine/src/tests_import.rs | 57 +++++++++ crates/pipeline/src/finish.rs | 11 ++ crates/pipeline/src/lib.rs | 1 + crates/pipeline/src/lut.rs | 143 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 29 ++++- docs/parity.md | 16 +-- docs/xmp-interop.md | 9 ++ 16 files changed, 461 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/cmd/lut_profiles.rs create mode 100644 crates/pipeline/src/lut.rs diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index d6c56145..8a97265d 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -161,7 +161,7 @@ fn services() -> Services { pick_preset_files: Some(Box::new(|| { rfd::FileDialog::new() .set_title("Import Presets") - .add_filter("Presets", &["lcpreset", "xmp", "lrtemplate", "zip", "dng"]) + .add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "cube"]) .pick_files() .unwrap_or_default() .into_iter() diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index f5c6e7a1..c1bbb51d 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -310,7 +310,7 @@ pub fn specs() -> Vec { }), cmd!("develop.profile", "Set Profile", [], None, "{id: profile id (see profiles.list), amount?: 0..200}", has_active, |s, p| { let id = str_param(p, "id").ok_or_else(|| bad("develop.profile", "missing id"))?.to_string(); - if !crate::presets::PROFILES.iter().any(|x| x.id == id) { + if !crate::presets::PROFILES.iter().any(|x| x.id == id) && !s.lut_profiles.iter().any(|x| x.id == id) { return Err(bad("develop.profile", format!("unknown profile `{id}`"))); } edit(s, "develop.profile", "Profile", |d| { @@ -323,7 +323,7 @@ pub fn specs() -> Vec { }), cmd!("profile.favorite", "Favorite Profile", [], None, "{id, favorite?: bool} (toggles when omitted)", always, |s, p| { let id = str_param(p, "id").ok_or_else(|| bad("profile.favorite", "missing id"))?.to_string(); - if crate::presets::profile(&id).is_none() { + if crate::presets::profile(&id).is_none() && !s.lut_profiles.iter().any(|x| x.id == id) { return Err(bad("profile.favorite", format!("unknown profile `{id}`"))); } let on = s.profile_favorites.contains(&id); diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs new file mode 100644 index 00000000..5e3febd9 --- /dev/null +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -0,0 +1,161 @@ +//! Imported profiles: `.cube` 3D LUTs used as creative profiles (Amount 0–200 % like the +//! built-in ones). A library on disk keeps a copy of each file in its `Profiles/` folder; the +//! list lives in the library's prefs and the LUTs are registered with the pipeline when the +//! library opens. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::{Result, Session}; + +/// One imported LUT profile. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct LutProfile { + /// `lut:`. + pub id: String, + pub name: String, + pub group: String, + /// The `.cube` file (the library's copy when there is a library on disk). + pub file: String, +} + +fn profiles_dir(s: &Session) -> Option { + s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Profiles")) +} + +/// Register every imported profile's LUT with the pipeline (missing files are skipped). +pub fn register_all(s: &Session) { + for p in &s.lut_profiles { + if let Some(l) = std::fs::read_to_string(&p.file).ok().and_then(|t| lightcraft_pipeline::lut::Lut3d::parse_cube(&t).ok()) { + lightcraft_pipeline::lut::register(&p.id, l); + } + } +} + +/// `.cube` files in `paths` (folders searched, zip bundles opened). +fn cube_files(paths: &[String]) -> Vec<(String, String, Option)> { + // (name, text, group) + let mut out = Vec::new(); + for p in paths { + let path = Path::new(p); + if path.is_dir() { + let mut stack = vec![path.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { continue }; + for e in rd.flatten() { + let ep = e.path(); + if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) { + continue; + } + if ep.is_dir() { + stack.push(ep); + } else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube")) + && let Ok(t) = std::fs::read_to_string(&ep) + { + let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string()); + out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group)); + } + } + } + } else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) { + let Ok(b) = std::fs::read(path) else { continue }; + for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() { + if inner.to_ascii_lowercase().ends_with(".cube") { + let ip = Path::new(&inner); + let group = ip + .parent() + .and_then(|d| d.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .or_else(|| path.file_stem().map(|n| n.to_string_lossy().to_string())); + out.push(( + ip.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), + String::from_utf8_lossy(&data).to_string(), + group, + )); + } + } + } else if let Ok(t) = std::fs::read_to_string(path) { + out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None)); + } + } + out +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "profile.import"; + let paths: Vec = + p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + if paths.is_empty() { + return Err(bad(C, "no paths")); + } + let dir = profiles_dir(s); + if let Some(d) = &dir { + std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?; + } + let (mut imported, mut failed) = (Vec::new(), Vec::new()); + for (name, text, group) in cube_files(&paths) { + let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) { + Ok(l) => l, + Err(e) => { + failed.push(json!([name, e])); + continue; + } + }; + let display = lut.title.clone().filter(|t| !t.trim().is_empty()).unwrap_or_else(|| name.clone()); + let group = super::super::preset_import::group_from_dir(&group.unwrap_or_default()).unwrap_or_else(|| "Imported".into()); + let slug = crate::presets::slug(&format!("{group}-{display}")); + let mut id = format!("lut:{slug}"); + let mut n = 2; + while s.lut_profiles.iter().any(|x| x.id == id) { + id = format!("lut:{slug}-{n}"); + n += 1; + } + let file = match &dir { + Some(d) => { + let f = d.join(format!("{}.cube", id.trim_start_matches("lut:"))); + std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?; + f.to_string_lossy().to_string() + } + None => { + // no library on disk: remember the original (the in-memory session ends with the app) + let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:"))); + std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?; + f.to_string_lossy().to_string() + } + }; + lightcraft_pipeline::lut::register(&id, lut); + s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file }); + imported.push(json!({"id": id, "name": display, "group": group})); + } + s.save_prefs()?; + Ok(json!({"imported": imported, "failed": failed})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "profile.import", + "Import Profiles", + [], + None, + "{paths: [.cube file, folder or .zip]} — 3D LUTs become creative profiles (Amount 0–200 %; rendered on the CPU) in the profile browser's groups (folder / zip name, else Imported) → {imported: [{id, name, group}], failed}", + always, + import + ), + cmd!("profile.deleteImported", "Delete Imported Profile", [], None, "{id: lut:…}", always, |s, p| { + let id = str_param(p, "id").ok_or_else(|| bad("profile.deleteImported", "missing id"))?.to_string(); + let Some(i) = s.lut_profiles.iter().position(|x| x.id == id) else { + return Err(bad("profile.deleteImported", format!("no imported profile `{id}`"))); + }; + let gone = s.lut_profiles.remove(i); + if profiles_dir(s).is_some_and(|d| Path::new(&gone.file).starts_with(d)) { + let _ = std::fs::remove_file(&gone.file); + } + lightcraft_pipeline::lut::unregister(&id); + s.save_prefs()?; + Ok(json!({"deleted": id})) + }), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f6096c01..7ff286e8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ mod export; pub mod filters; pub mod keywords; mod library; +pub mod lut_profiles; pub mod manage; mod masks; mod merge; @@ -121,6 +122,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(keywords::specs()); v.extend(manage::specs()); v.extend(previews::specs()); + v.extend(lut_profiles::specs()); v.extend(convert::specs()); v.extend(convert::edit_specs()); v.extend(merge::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 87e3d073..026186e2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -126,6 +126,8 @@ pub struct Session { pub filter_presets: Vec, /// Saved colour-label name sets. pub label_sets: Vec, + /// Imported `.cube` LUT profiles. + pub lut_profiles: Vec, /// The target album B adds to (`None` = the Quick Collection). pub target_album: Option, /// Auto import: files seen in the watched folder and their size then (a file is imported once @@ -191,6 +193,7 @@ impl Session { metadata_presets: Vec::new(), filter_presets: Vec::new(), label_sets: Vec::new(), + lut_profiles: Vec::new(), target_album: None, auto_import_seen: Default::default(), keyword_sets: Vec::new(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 7926f97e..096bef99 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -114,6 +114,8 @@ struct PrefsFile { filter_presets: Vec, /// Colour-label name sets. label_sets: Vec, + /// Imported LUT profiles. + lut_profiles: Vec, /// Keyword sets, the one in use, recent keywords. keyword_sets: Vec, keyword_set: Option, @@ -188,6 +190,8 @@ impl Session { self.metadata_presets = prefs.metadata_presets; self.filter_presets = prefs.filter_presets; self.label_sets = prefs.label_sets; + self.lut_profiles = prefs.lut_profiles; + crate::cmd::lut_profiles::register_all(self); self.keyword_sets = prefs.keyword_sets; self.keyword_set = prefs.keyword_set; self.recent_keywords = prefs.recent_keywords; @@ -306,6 +310,7 @@ impl Session { metadata_presets: self.metadata_presets.clone(), filter_presets: self.filter_presets.clone(), label_sets: self.label_sets.clone(), + lut_profiles: self.lut_profiles.clone(), keyword_sets: self.keyword_sets.clone(), keyword_set: self.keyword_set.clone(), recent_keywords: self.recent_keywords.clone(), diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 209bbc66..444203bd 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -428,7 +428,11 @@ impl RenderJob { /// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see /// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU. pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered { - if gpu && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) { + // LUT profiles have no GPU stage: they render on the CPU + if gpu + && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) + && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) + { return r; } match stages { diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index bb432142..5b0a6871 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -457,14 +457,34 @@ impl Session { pub fn profile_menu(&self) -> Value { let item = |p: &ProfileInfo| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.iter().any(|f| f == p.id)}); - let list = |ids: &[String]| ids.iter().filter_map(|id| profile(id)).map(item).collect::>(); + let lut_item = |id: &str| { + self.lut_profiles + .iter() + .find(|p| p.id == id) + .map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.contains(&p.id), "imported": true})) + }; + let list = |ids: &[String]| ids.iter().filter_map(|id| profile(id).map(item).or_else(|| lut_item(id))).collect::>(); + let mut groups: Vec = profile_groups() + .into_iter() + .map(|g| json!({"name": g, "profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::>()})) + .collect(); + // imported LUT profiles, by their groups + let mut lut_groups: Vec<&str> = self.lut_profiles.iter().map(|p| p.group.as_str()).collect(); + lut_groups.sort_unstable(); + lut_groups.dedup(); + for g in lut_groups { + let profiles: Vec = self + .lut_profiles + .iter() + .filter(|p| p.group == g) + .map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.contains(&p.id), "imported": true})) + .collect(); + groups.push(json!({"name": g, "profiles": profiles, "imported": true})); + } json!({ "favorites": list(&self.profile_favorites), "recent": list(&self.profile_recent), - "groups": profile_groups().into_iter().map(|g| json!({ - "name": g, - "profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::>(), - })).collect::>(), + "groups": groups, }) } } diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index dcce7d61..f074ee90 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -320,3 +320,60 @@ fn smart_previews_stand_in_for_offline_originals() { let _ = std::fs::remove_dir_all(src.with_extension("offline")); let _ = std::fs::remove_dir_all(&lib); } + +/// LUT profiles: a .cube imports as a creative profile (into the library), renders differently +/// (Amount 0 = no change), is listed in the profile browser and comes back with the library. +#[test] +fn cube_luts_become_profiles() { + let src = temp_dir("cubesrc"); + let lib = temp_dir("cubelib"); + // a warm look: red up, blue down + let n = 9; + let mut cube = String::from("TITLE \"Warm Test\"\nLUT_3D_SIZE 9\n"); + for b in 0..n { + for g in 0..n { + for r in 0..n { + let f = |v: usize| v as f32 / (n - 1) as f32; + cube.push_str(&format!("{} {} {}\n", (f(r) * 1.15).min(1.0), f(g), f(b) * 0.8)); + } + } + } + std::fs::create_dir_all(src.join("Film Looks")).unwrap(); + std::fs::write(src.join("Film Looks/warm.cube"), &cube).unwrap(); + std::fs::write(src.join("Film Looks/broken.cube"), "LUT_3D_SIZE 4\n0 0 0\n").unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, true).unwrap(); + let r = s.execute("profile.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((r["imported"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (1, 1), "{r}"); + let id = r["imported"][0]["id"].as_str().unwrap().to_string(); + assert_eq!((r["imported"][0]["name"].as_str(), r["imported"][0]["group"].as_str()), (Some("Warm Test"), Some("Film Looks"))); + let menu = s.profile_menu(); + assert!(menu["groups"].as_array().unwrap().iter().any(|g| g["name"] == "Film Looks"), "{menu}"); + let photo = s.catalog.photos().next().unwrap().id; + s.execute("library.select", &json!({"ids": [photo.0]})).unwrap(); + let base = s.render_now(photo, 48, 32).unwrap().image; + s.execute("develop.profile", &json!({"id": id, "amount": 100})).unwrap(); + let warm = s.render_now(photo, 48, 32).unwrap().image; + let mean = |img: &lightcraft_raster::Rgba8, k: usize| img.data.iter().map(|p| p[k] as f64).sum::() / img.data.len() as f64; + assert!( + mean(&warm, 0) > mean(&base, 0) && mean(&warm, 2) < mean(&base, 2), + "warmer: R {} → {}, B {} → {}", + mean(&base, 0), + mean(&warm, 0), + mean(&base, 2), + mean(&warm, 2) + ); + s.execute("develop.profile", &json!({"id": id, "amount": 0})).unwrap(); + assert_eq!(s.render_now(photo, 48, 32).unwrap().image.data, base.data, "Amount 0 = the photo as it was"); + // the library keeps it + drop(s); + lightcraft_pipeline::lut::unregister(&id); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert!(s.lut_profiles.iter().any(|p| p.id == id)); + assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open"); + s.execute("profile.deleteImported", &json!({"id": id})).unwrap(); + assert!(lightcraft_pipeline::lut::get(&id).is_none()); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 022448ae..93a1cef7 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -150,6 +150,8 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] { /// Everything the per-pixel stage computes once per render: the CPU loop below and the GPU kernel /// (`lightcraft-gpu`) both read their parameters from here, so the two cannot drift apart. pub struct FinishParams { + /// A LUT profile and its amount (0..2), applied to the display-encoded colour. + pub lut: Option<(std::sync::Arc, f32)>, pub tone: ToneMap, pub ops: ColorOps, /// Calibration: primaries matrix (row-major, linear Rec.2020) and shadows tint (−1..1). @@ -222,6 +224,7 @@ impl FinishParams { } else { ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks) }, + lut: crate::lut::get(&s.profile.id).map(|l| (l, (s.profile.amount / 100.0).clamp(0.0, 2.0) as f32)), ops: ColorOps::new(s), curves: curve_luts(&s.curve), refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32, @@ -298,6 +301,7 @@ pub(crate) fn finish_with( store: impl Fn([f32; 3]) -> T + Sync + Send, ) -> Vec { let (w, h) = (p.img.width, p.img.height); + let p_lut = fp.lut.clone(); let FinishParams { tone, ops, @@ -542,6 +546,13 @@ pub(crate) fn finish_with( let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e.map(|v| v + k); } + let e = match &p_lut { + Some((l, k)) => { + let m = l.apply(e.map(|v| v.clamp(0.0, 1.0))); + [e[0] + (m[0] - e[0]) * k, e[1] + (m[1] - e[1]) * k, e[2] + (m[2] - e[2]) * k] + } + None => e, + }; *px = store(e); } }); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 6623b819..0122359f 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -27,6 +27,7 @@ pub mod colorops; pub mod finish; pub mod geometry; pub mod local; +pub mod lut; pub mod masks; pub mod optics; pub mod output; diff --git a/crates/pipeline/src/lut.rs b/crates/pipeline/src/lut.rs new file mode 100644 index 00000000..b66c3444 --- /dev/null +++ b/crates/pipeline/src/lut.rs @@ -0,0 +1,143 @@ +//! 3D LUT profiles: `.cube` files (the plain-text Cube LUT format: `LUT_3D_SIZE`, optional +//! `DOMAIN_MIN` / `DOMAIN_MAX`, then size³ "r g b" lines with red changing fastest), applied to +//! the display-encoded output colour and blended by the profile amount. Profiles are registered +//! here by id (`lut:…`) and found by the finishing stage; LUT profiles render on the CPU. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock, RwLock}; + +/// A 3D colour lookup table. +#[derive(Clone, Debug, PartialEq)] +pub struct Lut3d { + pub size: usize, + pub data: Vec<[f32; 3]>, + pub domain_min: [f32; 3], + pub domain_max: [f32; 3], + pub title: Option, +} + +impl Lut3d { + /// Parse a `.cube` file. + pub fn parse_cube(text: &str) -> Result { + let (mut size, mut title) = (0usize, None); + let (mut min, mut max) = ([0.0f32; 3], [1.0f32; 3]); + let mut data = Vec::new(); + let three = |rest: &str| -> Option<[f32; 3]> { + let v: Vec = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect(); + (v.len() == 3).then(|| [v[0], v[1], v[2]]) + }; + for line in text.lines() { + let l = line.trim(); + if l.is_empty() || l.starts_with('#') { + continue; + } + if let Some(r) = l.strip_prefix("TITLE") { + title = Some(r.trim().trim_matches('"').to_string()); + } else if let Some(r) = l.strip_prefix("LUT_3D_SIZE") { + size = r.trim().parse().map_err(|_| format!("bad LUT_3D_SIZE `{}`", r.trim()))?; + if !(2..=256).contains(&size) { + return Err(format!("LUT_3D_SIZE {size} out of range")); + } + } else if l.starts_with("LUT_1D_SIZE") { + return Err("1D LUTs are not supported (only 3D .cube files)".into()); + } else if let Some(r) = l.strip_prefix("DOMAIN_MIN") { + min = three(r).ok_or("bad DOMAIN_MIN")?; + } else if let Some(r) = l.strip_prefix("DOMAIN_MAX") { + max = three(r).ok_or("bad DOMAIN_MAX")?; + } else if l.starts_with(|c: char| c.is_ascii_alphabetic()) { + // other keywords (LUT_IN_VIDEO_RANGE…) are ignored + } else { + data.push(three(l).ok_or_else(|| format!("bad line `{l}`"))?); + } + } + if size == 0 { + return Err("not a 3D .cube LUT (no LUT_3D_SIZE)".into()); + } + if data.len() != size * size * size { + return Err(format!("expected {} entries, found {}", size * size * size, data.len())); + } + Ok(Lut3d { size, data, domain_min: min, domain_max: max, title }) + } + + /// Look up `c` (trilinear). + pub fn apply(&self, c: [f32; 3]) -> [f32; 3] { + let n = self.size; + let s = (n - 1) as f32; + let mut idx = [0usize; 3]; + let mut frac = [0.0f32; 3]; + for k in 0..3 { + let span = (self.domain_max[k] - self.domain_min[k]).max(1e-6); + let x = (((c[k] - self.domain_min[k]) / span).clamp(0.0, 1.0)) * s; + let i = (x.floor() as usize).min(n - 2); + idx[k] = i; + frac[k] = x - i as f32; + } + let at = |r: usize, g: usize, b: usize| self.data[r + n * (g + n * b)]; + let lerp = |a: [f32; 3], b: [f32; 3], t: f32| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; + let (r, g, b) = (idx[0], idx[1], idx[2]); + let c00 = lerp(at(r, g, b), at(r + 1, g, b), frac[0]); + let c10 = lerp(at(r, g + 1, b), at(r + 1, g + 1, b), frac[0]); + let c01 = lerp(at(r, g, b + 1), at(r + 1, g, b + 1), frac[0]); + let c11 = lerp(at(r, g + 1, b + 1), at(r + 1, g + 1, b + 1), frac[0]); + lerp(lerp(c00, c10, frac[1]), lerp(c01, c11, frac[1]), frac[2]) + } +} + +fn registry() -> &'static RwLock>> { + static R: OnceLock>>> = OnceLock::new(); + R.get_or_init(Default::default) +} + +/// Make a LUT available to renders under profile id `id` (`lut:…`). +pub fn register(id: &str, lut: Lut3d) { + registry().write().unwrap_or_else(|e| e.into_inner()).insert(id.to_string(), Arc::new(lut)); +} + +/// The LUT registered for profile `id`. +pub fn get(id: &str) -> Option> { + if !id.starts_with("lut:") { + return None; + } + registry().read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} + +/// Forget a LUT profile. +pub fn unregister(id: &str) { + registry().write().unwrap_or_else(|e| e.into_inner()).remove(id); +} + +/// Profile ids that render with a LUT (and so on the CPU). +pub fn is_lut_profile(id: &str) -> bool { + id.starts_with("lut:") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cube(f: impl Fn([f32; 3]) -> [f32; 3], n: usize) -> String { + let mut s = format!("TITLE \"test\"\nLUT_3D_SIZE {n}\n"); + for b in 0..n { + for g in 0..n { + for r in 0..n { + let c = f([r as f32 / (n - 1) as f32, g as f32 / (n - 1) as f32, b as f32 / (n - 1) as f32]); + s.push_str(&format!("{} {} {}\n", c[0], c[1], c[2])); + } + } + } + s + } + + #[test] + fn identity_and_swap() { + let id = Lut3d::parse_cube(&cube(|c| c, 17)).unwrap(); + assert_eq!(id.title.as_deref(), Some("test")); + let v = id.apply([0.2, 0.55, 0.9]); + assert!(v.iter().zip([0.2, 0.55, 0.9]).all(|(a, b)| (a - b).abs() < 1e-5), "{v:?}"); + let swap = Lut3d::parse_cube(&cube(|c| [c[2], c[1], c[0]], 9)).unwrap(); + let v = swap.apply([0.1, 0.5, 0.8]); + assert!((v[0] - 0.8).abs() < 1e-5 && (v[2] - 0.1).abs() < 1e-5, "{v:?}"); + assert!(Lut3d::parse_cube("LUT_1D_SIZE 4\n0 0 0").is_err()); + assert!(Lut3d::parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err(), "too few entries"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bdc02363..4b638495 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -609,7 +609,7 @@ pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool { /// Files dropped on the window that are presets rather than photos. pub fn is_preset_file(path: &str) -> bool { let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ["lcpreset", "lrtemplate", "xmp", "zip"].contains(&ext.as_str()) + ["lcpreset", "lrtemplate", "xmp", "zip", "cube"].contains(&ext.as_str()) } #[cfg(test)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 207eb7db..726cb448 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -108,7 +108,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("photo.locate", "Locate Missing File…", None, ""), ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), - ("file.importPresets", "Import Presets…", None, "File"), + ("file.importPresets", "Import Profiles & Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), ("app.openLibrary", "Open Library…", None, "File"), @@ -847,7 +847,32 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option = paths + .iter() + .filter(|p| { + let l = p.to_ascii_lowercase(); + l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() + }) + .collect(); + let profiles = if lut_paths.is_empty() { + 0 + } else { + app.session + .execute("profile.import", &json!({"paths": lut_paths})) + .ok() + .and_then(|v| v["imported"].as_array().map(Vec::len)) + .unwrap_or(0) + }; + let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); + let r = if preset_paths.is_empty() { + Ok(json!({"imported": [], "failed": [], "skipped": 0})) + } else { + app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) + }; + if profiles > 0 { + app.toast(&ctx, format!("Imported {profiles} profile{} (Profile browser ▸ their groups)", if profiles == 1 { "" } else { "s" })); + } if let Ok(v) = &r { let n = v["imported"].as_array().map_or(0, Vec::len); let failed = v["failed"].as_array().map_or(0, Vec::len); diff --git a/docs/parity.md b/docs/parity.md index 37d92f26..8b6ce537 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,13 +21,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| -| A. Import (IMP) | 9 | 2 | 2 | 1 | 3/4 (75%) | 3/4 (75%) | +| A. Import (IMP) | 10 | 1 | 2 | 1 | 3/4 (75%) | 4/4 (100%) | | B. Library management (LIB) | 22 | 0 | 3 | 2 | 9/9 (100%) | 9/9 (100%) | | C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 10 | 0 | 3 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | -| G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) | +| G. Profiles (PROF) | 6 | 1 | 3 | 0 | 3/3 (100%) | 2/3 (67%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | @@ -45,12 +45,12 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 14 | 1 | 2 | 1 | 8/8 (100%) | 5/5 (100%) | -| Y. Menus | 79 | 2 | 4 | 8 | 46/47 (98%) | 22/23 (96%) | +| Y. Menus | 80 | 1 | 4 | 8 | 46/47 (98%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 23 | 9 | 47 | 9 | — | 19/21 (90%) | -| **Total** | 371 | 25 | 104 | 37 | 193/198 (97%) | 133/144 (92%) | +| **Total** | 373 | 24 | 103 | 37 | 193/198 (97%) | 135/144 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **76.7%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.8% of 144 · P2 32.6% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.0%** of 500 in-scope rows — P0 98.7% of 198 · P1 95.8% of 144 · P2 32.6% of 158. ## Top gaps @@ -106,7 +106,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`, `crates/engine/src/preset_import.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), .zip bundles, folders (folder → group), drag & drop; masks (gradients, radial, brush, subject/sky, luminance/depth range) carried over; unmapped settings reported; no profile import (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` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; 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), `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; edits go to XMP sidecars; importing promotes them | | 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, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode | @@ -263,7 +263,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | | LR-PROF-AMOUNT | Profile amount | P1 | ✅ | `ctl:profile.amount` | | -| LR-PROF-IMPORT | Import profiles | P1 | ⬜ | | | +| LR-PROF-IMPORT | Import profiles | P1 | 🟡 | `cmd:profile.import`, `cmd:profile.deleteImported`, `crates/pipeline/src/lut.rs`, `crates/engine/src/cmd/lut_profiles.rs` | `.cube` 3D LUTs (files, folders, zips) become creative profiles with Amount, grouped in the profile browser, kept with the library; rendered on the CPU; Adobe profile formats deliberately unsupported | ## H. Crop & rotate (CROP) @@ -510,7 +510,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | `cmd:dialog.newAlbum` | | | MENU-FILE-NEWFOLDER | New Folder… | P0 | ✅ | `cmd:dialog.newFolder` | ⇧⌘N | | MENU-FILE-NEWSMART | New Smart Album… | P1 | ✅ | `cmd:dialog.newSmartAlbum` | saves the current view (source + filter) | -| MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | 🟡 | `cmd:file.importPresets` | presets in every common format (see LR-IMP-PROFILES); profiles not | +| MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | ✅ | `cmd:file.importPresets` | presets in every common format and .cube LUT profiles (see LR-IMP-PROFILES) | | MENU-FILE-EXPORT | Export… | P0 | ✅ | `cmd:dialog.export` | | | MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E | | MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ✅ | `cmd:app.export` (`preset`), `crates/ui-egui/src/menubar.rs` (Export with Preset) | built-ins, then user presets, then Custom… (the dialog); exports to the last folder | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index dab57443..bf8a059f 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -116,3 +116,12 @@ carry over are listed in `preset.import`'s `unmapped` as `Mask: `. | 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`). + +## Profiles: 3D LUTs (`.cube`) + +`profile.import {paths}` (File ▸ Import Profiles & Presets…, drag & drop) reads `.cube` 3D LUTs — single files, folders +or `.zip` bundles — as creative profiles: they appear in the profile browser under their folder's (or zip's) name and +take the Amount slider (0–200 %) like the built-in looks. The LUT is applied to the finished, display-encoded colour +(trilinear; `DOMAIN_MIN` / `DOMAIN_MAX` honoured; 1D LUTs are not supported); photos with a LUT profile render on the +CPU. A library on disk keeps a copy of each file in its `Profiles/` folder. Adobe's own profile formats (`.dcp`, XMP +camera/creative profiles with embedded tables) are deliberately not read.