From 493600743d89294bf2ab16243b42e8f61a74b5c6 Mon Sep 17 00:00:00 2001 From: peiyan0 Date: Fri, 9 Oct 2026 00:43:19 +0800 Subject: [PATCH] engine, ui: support imported LUT profiles in profile dropdown and browser (#328) --- crates/engine/src/library.rs | 28 +++++----- crates/engine/src/presets.rs | 11 ++++ crates/engine/src/tests_import.rs | 6 ++- crates/ui-egui/src/panels/edit.rs | 63 +++++++++++++--------- crates/ui-egui/src/panels/profiles.rs | 77 ++++++++++++++++++--------- docs/parity.md | 2 +- 6 files changed, 123 insertions(+), 64 deletions(-) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 6a63e378..f49c6aa5 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -314,20 +314,6 @@ impl Session { journal.snapshot(&self.catalog)?; } let mut settings = SettingsLoad::default(); - // presets - if let Some(f) = settings.read::(files.as_mut(), "presets.json") { - for p in &mut self.presets { - p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite); - } - for u in f.user { - if !self.presets.iter().any(|p| p.id == u.id) { - self.presets.push(u); - } - } - let known = |id: &String| crate::presets::profile(id).is_some(); - self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect(); - self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect(); - } // preferences let prefs = settings.read::(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; @@ -349,6 +335,20 @@ impl Session { if let Some(d) = &self.smart_previews_dir { self.media.smart_dir = Some(d.clone()); } + // presets + if let Some(f) = settings.read::(files.as_mut(), "presets.json") { + for p in &mut self.presets { + p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite); + } + for u in f.user { + if !self.presets.iter().any(|p| p.id == u.id) { + self.presets.push(u); + } + } + let known = |id: &String| crate::presets::profile(id).is_some() || self.lut_profiles.iter().any(|p| &p.id == id); + self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect(); + self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect(); + } // view state if let Some(v) = settings.read::(files.as_mut(), "view.json") { self.source = v.source; diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 57a82ac5..f77b435d 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -446,6 +446,17 @@ pub fn profile(id: &str) -> Option<&'static ProfileInfo> { pub const RECENT_PROFILES: usize = 5; impl Session { + /// A profile by id, looking up built-in profiles first and imported LUT profiles second. + pub fn profile_info(&self, id: &str) -> Option<(&str, &str)> { + if let Some(p) = profile(id) { + return Some((p.name, p.group)); + } + self.lut_profiles + .iter() + .find(|p| p.id == id) + .map(|p| (p.name.as_str(), p.group.as_str())) + } + /// Remember `id` as the most recently applied profile. pub fn note_profile_used(&mut self, id: &str) { self.profile_recent.retain(|p| p != id); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index af106765..2a508fc8 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -544,13 +544,17 @@ fn cube_luts_become_profiles() { 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"); + assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); + s.execute("profile.favorite", &json!({"id": id})).unwrap(); + assert!(s.profile_favorites.contains(&id)); // 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!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen"); + assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); 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()); diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index cddb984e..2d79f5f5 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -139,7 +139,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 14, bottom: 14 }).show(ui, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim)); - let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); + let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color"); let r = crate::widgets::dropdown(ui, "profile", crate::i18n::tr(name), t.font(15.0), t.text_label); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -345,36 +345,37 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { /// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and /// Browse…. fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { - use lightcraft_engine::presets::{PROFILES, profile, profile_groups}; + use lightcraft_engine::presets::{PROFILES, profile_groups}; let t = Tokens::get(ui.ctx()); ui.set_min_width(200.0); let cur = d.profile.id.as_str(); // (clicked, hovered) - let mut pick: (Option<&'static str>, Option<&'static str>) = (None, None); - let item = |ui: &mut egui::Ui, - key: &str, - p: &'static lightcraft_engine::presets::ProfileInfo, - pick: &mut (Option<&'static str>, Option<&'static str>)| { - let r = ui.selectable_label(p.id == cur, crate::i18n::tr(p.name)); - register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect); + let mut pick: (Option, Option) = (None, None); + let mut item = |ui: &mut egui::Ui, + key: &str, + id: &str, + name: &str, + pick: &mut (Option, Option)| { + let r = ui.selectable_label(id == cur, crate::i18n::tr(name)); + register(ui.ctx(), format!("profileMenu:{key}:{id}"), r.rect); if r.clicked() { - pick.0 = Some(p.id); + pick.0 = Some(id.to_string()); } if r.hovered() { - pick.1 = Some(p.id); + pick.1 = Some(id.to_string()); } }; let heading = |ui: &mut egui::Ui, s: &str| { ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim)); }; for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] { - let list: Vec<_> = ids.iter().filter_map(|id| profile(id)).collect(); + let list: Vec<_> = ids.iter().filter_map(|id| app.session.profile_info(id).map(|(name, _)| (id.as_str(), name))).collect(); if list.is_empty() { continue; } heading(ui, title); - for p in list { - item(ui, key, p, &mut pick); + for (id, name) in list { + item(ui, key, id, name, &mut pick); } ui.separator(); } @@ -382,23 +383,36 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) let r = ui.menu_button(crate::i18n::tr(g), |ui| { ui.set_min_width(170.0); for p in PROFILES.iter().filter(|p| p.group == g) { - item(ui, "group", p, &mut pick); + item(ui, "group", p.id, p.name, &mut pick); + } + }); + register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); + } + // imported LUT profile groups + let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); + lut_groups.sort_unstable(); + lut_groups.dedup(); + for g in &lut_groups { + let r = ui.menu_button(crate::i18n::tr(g), |ui| { + ui.set_min_width(170.0); + for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { + item(ui, "group", &p.id, &p.name, &mut pick); } }); register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); } ui.separator(); - if let Some(p) = profile(cur) { - let fav = app.session.profile_favorites.iter().any(|f| f == p.id); + if let Some((name, _)) = app.session.profile_info(cur) { + let fav = app.session.profile_favorites.iter().any(|f| f == cur); let label = if fav { - crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::tr(p.name)) + crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::tr(name)) } else { - crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::tr(p.name)) + crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::tr(name)) }; let r = ui.button(label); register(ui.ctx(), "profileMenu:toggleFavorite", r.rect); if r.clicked() { - let _ = app.run("profile.favorite", json!({"id": p.id})); + let _ = app.run("profile.favorite", json!({"id": cur})); } } let r = ui.button(crate::i18n::tr("Browse…")); @@ -407,13 +421,14 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) let _ = app.run("panel.profiles", json!({})); } // resting on a profile previews it in the loupe - if let Some(p) = pick.1.and_then(profile) - && p.id != cur + if let Some(hovered_id) = &pick.1 + && hovered_id != cur + && let Some((name, _)) = app.session.profile_info(hovered_id) { let mut s = d.clone(); - s.profile.id = p.id.to_string(); + s.profile.id = hovered_id.clone(); s.profile.amount = 100.0; - app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s }); + app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(name)), settings: s }); } if let Some(id) = pick.0 { let _ = app.run("develop.profile", json!({"id": id})); diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index ec4ddc57..1ae321f5 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -5,7 +5,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use lightcraft_catalog::PhotoId; use lightcraft_develop::DevelopSettings; -use lightcraft_engine::presets::{PROFILES, ProfileInfo, profile, profile_groups}; +use lightcraft_engine::presets::{PROFILES, profile_groups}; use serde_json::json; use super::presets::cover_uv; @@ -33,7 +33,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { divider(ui); // the applied profile and its amount let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); + let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color"); ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim); ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr(name), t.font(14.0), t.text_label); if d.profile.id != "lc.color" { @@ -41,13 +41,32 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(6.0); } divider(ui); - let mut sections: Vec<(&str, &str, Vec<&'static ProfileInfo>)> = Vec::new(); - let favs: Vec<&'static ProfileInfo> = app.session.profile_favorites.iter().filter_map(|f| profile(f)).collect(); + let mut sections: Vec<(&str, &str, Vec<(String, String, String)>)> = Vec::new(); + let favs: Vec<(String, String, String)> = app + .session + .profile_favorites + .iter() + .filter_map(|f| app.session.profile_info(f).map(|(name, group)| (f.clone(), name.to_string(), group.to_string()))) + .collect(); if !favs.is_empty() { sections.push(("Favorites", "fav", favs)); } for g in profile_groups() { - sections.push((g, "", PROFILES.iter().filter(|p| p.group == g).collect())); + sections.push((g, "", PROFILES.iter().filter(|p| p.group == g).map(|p| (p.id.to_string(), p.name.to_string(), p.group.to_string())).collect())); + } + // imported LUT profile groups + let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); + lut_groups.sort_unstable(); + lut_groups.dedup(); + for g in &lut_groups { + let profiles = app + .session + .lut_profiles + .iter() + .filter(|p| &p.group == g) + .map(|p| (p.id.clone(), p.name.clone(), p.group.clone())) + .collect(); + sections.push((g.as_str(), "", profiles)); } for (title, tag, list) in sections { let open_id = egui::Id::new(("profile-group", title)); @@ -87,34 +106,44 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.hover_preview.is_none() && let Some((saved_id, saved_profile_id)) = ui.data(|m| m.get_temp::<(PhotoId, String)>(last_hover_id)) && saved_id == id - && let Some(p) = profile(&saved_profile_id) + && let Some((p_name, _)) = app.session.profile_info(&saved_profile_id) { - let s = with_profile(&d, p); - app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s }); + let s = with_profile_id(&d, &saved_profile_id); + app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p_name)), settings: s }); } } else { ui.data_mut(|m| m.remove::<(PhotoId, String)>(last_hover_id)); } } -/// `d` with profile `p` (at 100 % unless it is the applied one). -fn with_profile(d: &DevelopSettings, p: &ProfileInfo) -> DevelopSettings { +/// `d` with profile `profile_id` (at 100 % unless it is the applied one). +fn with_profile_id(d: &DevelopSettings, profile_id: &str) -> DevelopSettings { let mut s = d.clone(); - if s.profile.id != p.id { - s.profile.id = p.id.to_string(); + if s.profile.id != profile_id { + s.profile.id = profile_id.to_string(); s.profile.amount = 100.0; } s } #[allow(clippy::too_many_arguments)] -fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: PhotoId, tag: &str, p: &'static ProfileInfo, cell: Rect, th: f32) { +fn cell_ui( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + d: &DevelopSettings, + id: PhotoId, + tag: &str, + p: &(String, String, String), + cell: Rect, + th: f32, +) { + let (p_id, p_name, p_group) = (p.0.as_str(), p.1.as_str(), p.2.as_str()); let t = Tokens::get(ui.ctx()); - let key = if tag.is_empty() { format!("profileCell:{}", p.id) } else { format!("profileCell:{tag}:{}", p.id) }; + let key = if tag.is_empty() { format!("profileCell:{p_id}") } else { format!("profileCell:{tag}:{p_id}") }; let resp = ui.interact(cell, egui::Id::new(&key), Sense::click()); register(ui.ctx(), key, cell); let thumb = Rect::from_min_size(cell.min, vec2(cell.width(), th)); - let s = with_profile(d, p); + let s = with_profile_id(d, p_id); let painter = ui.painter(); painter.rect_filled(thumb, 3.0, t.canvas); if let Some(job) = app.session.variant_job(id, &s, THUMB_EDGE) @@ -122,33 +151,33 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: { painter.image(tex.tex.id(), thumb, cover_uv(thumb, tex.size), Color32::WHITE); } - let applied = d.profile.id == p.id; + let applied = d.profile.id == p_id; if applied { painter.rect_stroke(thumb.expand(1.0), 4.0, Stroke::new(2.0, t.pick), egui::StrokeKind::Outside); } else if resp.hovered() { painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); } let name_color = if applied { t.text } else { t.text_label }; - painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, crate::i18n::tr(p.name), t.font(12.0), name_color); + painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, crate::i18n::tr(p_name), t.font(12.0), name_color); // favourite star (always shown when set, on hover otherwise) - let fav = app.session.profile_favorites.iter().any(|f| f == p.id); + let fav = app.session.profile_favorites.iter().any(|f| f == p_id); let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0)); - register(ui.ctx(), format!("profileStar:{}{}", if tag.is_empty() { "" } else { "fav:" }, p.id), star); + register(ui.ctx(), format!("profileStar:{}{}", if tag.is_empty() { "" } else { "fav:" }, p_id), star); let over_star = resp.hover_pos().is_some_and(|q| star.contains(q)); if fav || resp.hovered() { painter.circle_filled(star.center(), 10.0, Color32::from_black_alpha(110)); paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE }); } if resp.hovered() && !applied && !over_star { - app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s.clone() }); - ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p.id.to_string()))); + app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p_name)), settings: s.clone() }); + ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p_id.to_string()))); } - let resp = resp.on_hover_text(format!("{} ({})", crate::i18n::tr(p.name), crate::i18n::tr(p.group))); + let resp = resp.on_hover_text(format!("{} ({})", crate::i18n::tr(p_name), crate::i18n::tr(p_group))); if resp.clicked() { if resp.interact_pointer_pos().is_some_and(|q| star.contains(q)) { - let _ = app.run("profile.favorite", json!({"id": p.id})); + let _ = app.run("profile.favorite", json!({"id": p_id})); } else if !applied { - let _ = app.run("develop.profile", json!({"id": p.id, "amount": 100})); + let _ = app.run("develop.profile", json!({"id": p_id, "amount": 100})); ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover"))); } } diff --git a/docs/parity.md b/docs/parity.md index d5a31ea9..97e23881 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -281,7 +281,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 | 🟡 | `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 | +| LR-PROF-IMPORT | Import profiles | P1 | ✅ | `cmd:profile.import`, `cmd:profile.deleteImported`, `crates/pipeline/src/lut.rs`, `crates/engine/src/cmd/lut_profiles.rs`, `crates/ui-egui/src/panels/edit.rs`, `crates/ui-egui/src/panels/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)