LR-PROF-IMPORT: .cube 3D LUTs as creative profiles
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 <noreply@anthropic.com>
This commit is contained in:
parent
81f77d74df
commit
4a490db261
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
}),
|
||||
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<CommandSpec> {
|
|||
}),
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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:<slug>`.
|
||||
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<PathBuf> {
|
||||
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<String>)> {
|
||||
// (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<Value> {
|
||||
const C: &str = "profile.import";
|
||||
let paths: Vec<String> =
|
||||
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<CommandSpec> {
|
||||
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}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ pub struct Session {
|
|||
pub filter_presets: Vec<cmd::filters::FilterPreset>,
|
||||
/// Saved colour-label name sets.
|
||||
pub label_sets: Vec<cmd::manage::LabelSet>,
|
||||
/// Imported `.cube` LUT profiles.
|
||||
pub lut_profiles: Vec<cmd::lut_profiles::LutProfile>,
|
||||
/// The target album B adds to (`None` = the Quick Collection).
|
||||
pub target_album: Option<lightcraft_catalog::AlbumId>,
|
||||
/// 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(),
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ struct PrefsFile {
|
|||
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
|
||||
/// Colour-label name sets.
|
||||
label_sets: Vec<crate::cmd::manage::LabelSet>,
|
||||
/// Imported LUT profiles.
|
||||
lut_profiles: Vec<crate::cmd::lut_profiles::LutProfile>,
|
||||
/// Keyword sets, the one in use, recent keywords.
|
||||
keyword_sets: Vec<crate::cmd::keywords::KeywordSet>,
|
||||
keyword_set: Option<String>,
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<Rgb32f>, 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 {
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
let mut groups: Vec<Value> = profile_groups()
|
||||
.into_iter()
|
||||
.map(|g| json!({"name": g, "profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::<Vec<_>>()}))
|
||||
.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<Value> = 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::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"groups": groups,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<f64>() / 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<crate::lut::Lut3d>, 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<T: Copy + Default + Send>(
|
|||
store: impl Fn([f32; 3]) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
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<T: Copy + Default + Send>(
|
|||
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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
impl Lut3d {
|
||||
/// Parse a `.cube` file.
|
||||
pub fn parse_cube(text: &str) -> Result<Lut3d, String> {
|
||||
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<f32> = 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<HashMap<String, Arc<Lut3d>>> {
|
||||
static R: OnceLock<RwLock<HashMap<String, Arc<Lut3d>>>> = 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<Arc<Lut3d>> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<Re
|
|||
if paths.is_empty() {
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let r = app.session.execute("preset.import", &json!({"paths": paths})).map_err(|e| e.to_string());
|
||||
// .cube LUTs (and those inside zips / folders) become profiles
|
||||
let lut_paths: Vec<&String> = 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);
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
<!-- parity:summary -->
|
||||
| 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.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 |
|
||||
|
|
|
|||
|
|
@ -116,3 +116,12 @@ carry over are listed in `preset.import`'s `unmapped` as `Mask: <kind>`.
|
|||
| 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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue