Merge main into worktree-agent-ab72a7623afb95196

# Conflicts:
#	crates/engine/src/import.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/headless.rs
#	crates/ui-egui/src/panels/dialogs.rs
#	crates/ui-egui/src/panels/grid.rs
#	docs/parity.md
This commit is contained in:
Brandon Thomas 2026-10-01 13:33:29 -04:00
commit 22e4f0fd8c
60 changed files with 4042 additions and 383 deletions

1
Cargo.lock generated
View File

@ -2261,6 +2261,7 @@ dependencies = [
"proptest",
"rayon",
"serde",
"serde_json",
]
[[package]]

View File

@ -159,6 +159,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
reveal: None,
pick_folder: None,
}
}

View File

@ -47,7 +47,7 @@ pub struct WireJob {
pub stages: Option<String>,
/// The request's diagnostic overlay (`Overlay::to_parts`).
#[serde(default)]
pub overlay: (u8, f32),
pub overlay: (u8, f64),
}
impl WireJob {
@ -88,6 +88,9 @@ impl WireJob {
quality: if self.draft { Quality::Draft } else { Quality::Full },
apply_crop: self.apply_crop,
overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1),
// workers render previews (exports run in-process)
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
depth: lightcraft_engine::pipeline::OutputDepth::U8,
}
}

View File

@ -97,7 +97,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
match b.read(&key).await.ok().flatten().and_then(|bytes| lightcraft_preview::decode_jpeg(&bytes)) {
Some(image) => {
let histogram = Histogram::of_srgb8(&image);
return Ok((Rendered { image, histogram }, 0));
return Ok((Rendered { image, histogram, deep: None }, 0));
}
None => cache_miss = true,
}
@ -275,7 +275,7 @@ impl Workers {
Some(image) if hist.len() == 1025 => {
let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec();
let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] };
Ok(Rendered { image, histogram })
Ok(Rendered { image, histogram, deep: None })
}
_ => Err("render worker: bad image".to_string()),
}
@ -344,7 +344,7 @@ impl RenderOffload for Workers {
photo: job.photo,
level: job.level,
key: job.key,
rendered: Ok(Rendered { image, histogram }),
rendered: Ok(Rendered { image, histogram, deep: None }),
loaded: None,
quick: None,
};

View File

@ -3,7 +3,8 @@
//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control <port>] [files or folders…]`
//!
//! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else
//! `$LIGHTCRAFT_LIBRARY`, else `~/Pictures/LightCraft Library`; a new library starts with the
//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else
//! `~/Pictures/LightCraft Library`; a new library starts with the
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
//! nothing (demo photos unless files are given; used by the README showcase scripts).
@ -57,16 +58,13 @@ fn config_dir() -> Option<std::path::PathBuf> {
}
}
fn load_prefs(app: &mut LightcraftApp) {
/// The saved UI state and app settings (`<config>/ui.json`), if any.
fn load_prefs() -> Option<UiState> {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = config_dir().map(|d| d.join("ui.json"))
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = serde_json::from_slice::<UiState>(&bytes)
{
app.ui = ui.sanitized();
return None;
}
let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?;
serde_json::from_slice::<UiState>(&bytes).ok().map(UiState::sanitized)
}
fn save_prefs(app: &LightcraftApp) {
@ -83,6 +81,7 @@ fn save_prefs(app: &LightcraftApp) {
fn services() -> Services {
Services {
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
reveal: Some(Box::new(|path: &str| {
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").args(["-R", path]).status()
@ -192,7 +191,7 @@ fn main() -> eframe::Result {
let mut files = Vec::new();
let mut seed_demo = true;
let mut in_memory = false;
let mut library_dir = lightcraft_engine::library::default_dir();
let mut library_dir: Option<std::path::PathBuf> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
@ -215,6 +214,16 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
let prefs = load_prefs();
// --library, else the library last opened from Settings, else the default location
let library_dir = library_dir.or_else(|| {
prefs
.as_ref()
.map(|u| u.settings.library_path.clone())
.filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none())
.map(Into::into)
});
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("LightCraft")
@ -232,7 +241,9 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
let session = open_session(in_memory, library_dir, seed_demo && files.is_empty());
let mut app = LightcraftApp::new(session, services());
load_prefs(&mut app);
if let Some(ui) = prefs {
app.ui = ui;
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());

View File

@ -19,6 +19,9 @@ use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu
use serde_json::Value;
const QUIT: &str = "app.quit";
/// Settings… (the app menu on macOS; Edit elsewhere).
const SETTINGS: &str = "app.settings";
const SETTINGS_KEY: &str = "Cmd+,";
/// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo
/// or the active mask): left to the egui handler, which knows the context.
const CONTEXTUAL: &[&str] = &["X", "Delete"];
@ -117,6 +120,7 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
"=" => Code::Equal,
"-" => Code::Minus,
"'" => Code::Quote,
"," => Code::Comma,
"Delete" => Code::Backspace,
_ => return None,
};
@ -161,6 +165,14 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
s
}
/// Drop a trailing separator (left behind when an item moved to the app menu).
fn tidy_separators(mut nodes: Vec<MenuNode>) -> Vec<MenuNode> {
while matches!(nodes.last(), Some(MenuNode::Separator)) {
nodes.pop();
}
nodes
}
impl NativeMenu {
/// Build the menu bar and install it as the application menu (main thread, after launch).
pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu {
@ -185,10 +197,13 @@ impl NativeMenu {
// the application menu
let app_menu = Submenu::new("LightCraft", true);
let about = MenuItem::with_id("app.about", "About LightCraft", true, None);
let settings = MenuItem::with_id(SETTINGS, "Settings…", true, accelerator(SETTINGS_KEY));
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
let _ = app_menu.append_items(&[
&about,
&PredefinedMenuItem::separator(),
&settings,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(Some("Hide LightCraft")),
@ -208,8 +223,10 @@ impl NativeMenu {
&PredefinedMenuItem::separator(),
]);
}
// About lives in the app menu on macOS
let nodes: Vec<MenuNode> = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about")).cloned().collect();
// About and Settings live in the app menu on macOS
let nodes: Vec<MenuNode> =
nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about" || id == SETTINGS)).cloned().collect();
let nodes = tidy_separators(nodes);
self.append_nodes(&sub, &nodes);
if title == "Window" {
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
@ -281,6 +298,7 @@ impl NativeMenu {
.filter_map(|i| i.shortcut.clone())
.filter(|sc| !(self.text_focus && yields_to_text(sc)))
.collect::<HashSet<_>>();
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
}
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
@ -291,8 +309,8 @@ impl NativeMenu {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
continue;
}
if key == "app.about" {
let _ = app.run("app.about", serde_json::json!({}));
if key == "app.about" || key == SETTINGS {
let _ = app.run(&key, serde_json::json!({}));
continue;
}
if let Some(it) = self.items.get(&key) {

View File

@ -181,6 +181,11 @@ pub struct Photo {
/// Virtual copies' name ("Copy 1").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_name: Option<String>,
/// The settings import gave the photo when a user default (a raw/JPEG default preset, see
/// the engine's import defaults) changed them from [`Photo::camera_defaults`]. They count as
/// unedited, and Reset returns to them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_look: Option<Arc<DevelopSettings>>,
}
impl Photo {
@ -211,11 +216,20 @@ impl Photo {
embedded_lens: None,
copy_of: None,
copy_name: None,
import_look: None,
}
}
/// The develop settings import gives this photo (raws: as-shot white balance and the raw
/// defaults; embedded lens corrections on when the file has them).
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
pub fn import_defaults(&self) -> DevelopSettings {
match &self.import_look {
Some(l) => (**l).clone(),
None => self.camera_defaults(),
}
}
/// The built-in defaults for this photo, before any user default preset: raws start from
/// their as-shot white balance; embedded lens corrections on when the file has them.
pub fn camera_defaults(&self) -> DevelopSettings {
let mut d = match self.as_shot_wb {
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
None => DevelopSettings::default(),

View File

@ -290,15 +290,26 @@ pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<
Ok(out)
}
/// Encode AVIF (8-bit sRGB, 4:4:4) with `ravif` (rav1e). `quality` 1..=100, `speed` 1 (slow) ..= 10.
/// EXIF is embedded; ICC is not supported by the muxer (the file is tagged sRGB via `nclx`).
/// Returns [`Error::Encode`] on wasm32 or when built without the `avif` feature.
/// Encode AVIF (sRGB, 4:4:4, 10-bit AV1) with `ravif` (rav1e). 8-bit samples are widened by the
/// encoder; 16-bit samples keep 10 bits of precision (BT.601 full-range YCbCr computed here).
/// `quality` 1..=100, `speed` 1 (slow) ..= 10. EXIF is embedded; ICC is not supported by the muxer
/// (the file is tagged sRGB via `nclx`). Returns [`Error::Encode`] for float samples, on wasm32 or
/// when built without the `avif` feature.
pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
{
let Samples::U8(s) = img.samples else {
return Err(Error::Encode("AVIF encoder needs 8-bit samples".into()));
let mut enc = ravif::Encoder::new()
.with_quality(quality.clamp(1, 100) as f32)
.with_alpha_quality(quality.clamp(1, 100) as f32)
.with_speed(speed.clamp(1, 10));
if let Some(exif) = meta.exif {
enc = enc.with_exif(exif.to_vec());
}
let s = match img.samples {
Samples::U8(s) => s,
Samples::U16(s) => return avif_10bit(&enc, img, s),
Samples::F32(_) => return Err(Error::Encode("AVIF encoder needs 8- or 16-bit samples".into())),
};
let n = img.width as usize * img.height as usize;
let ch = img.channels as usize;
@ -312,13 +323,6 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
_ => ravif::RGBA8::new(c[0], c[1], c[2], c[3]),
})
.collect();
let mut enc = ravif::Encoder::new()
.with_quality(quality.clamp(1, 100) as f32)
.with_alpha_quality(quality.clamp(1, 100) as f32)
.with_speed(speed.clamp(1, 10));
if let Some(exif) = meta.exif {
enc = enc.with_exif(exif.to_vec());
}
let r = enc.encode_rgba(ravif::Img::new(&px[..], img.width as usize, img.height as usize)).map_err(|e| Error::Encode(e.to_string()))?;
let _ = img.has_alpha();
Ok(r.avif_file)
@ -329,3 +333,32 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
Err(Error::Encode("AVIF encoding is not available in this build".into()))
}
}
/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<u8>> {
const K: [f32; 3] = [0.299, 0.587, 0.114];
let n = img.width as usize * img.height as usize;
let ch = img.channels as usize;
let rgb = |c: &[u16]| -> [f32; 3] {
let v = |x: u16| x as f32 * (1023.0 / 65535.0);
match ch {
1 | 2 => [v(c[0]); 3],
_ => [v(c[0]), v(c[1]), v(c[2])],
}
};
let planes = s.chunks_exact(ch).take(n).map(|c| {
let [r, g, b] = rgb(c);
let y = K[0] * r + K[1] * g + K[2] * b;
let cb = (b - y) * (0.5 / (1.0 - K[2])) + 512.0;
let cr = (r - y) * (0.5 / (1.0 - K[0])) + 512.0;
[y, cb, cr].map(|v| v.round().clamp(0.0, 1023.0) as u16)
});
let alpha_at = |c: &[u16]| if img.has_alpha() { c[ch - 1] } else { u16::MAX };
let has_alpha = s.chunks_exact(ch).take(n).any(|c| alpha_at(c) != u16::MAX);
let alpha = has_alpha.then(|| s.chunks_exact(ch).take(n).map(move |c| ((alpha_at(c) as u32 * 1023 + 32767) / 65535) as u16));
let r = enc
.encode_raw_planes_10_bit(img.width as usize, img.height as usize, planes, alpha, ravif::PixelRange::Full, ravif::MatrixCoefficients::BT601)
.map_err(|e| Error::Encode(e.to_string()))?;
Ok(r.avif_file)
}

View File

@ -146,7 +146,46 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec<u8> {
};
p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)]));
p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())]));
p.encode().unwrap_or_default()
align_tags(&p.encode().unwrap_or_default())
}
/// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to
/// back, so a tag of odd length (e.g. a `mluc` description with an odd number of characters)
/// misaligns every following tag — which some readers (macOS ImageIO for PNG `iCCP`) reject.
fn align_tags(b: &[u8]) -> Vec<u8> {
let be = |i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize);
let Some(n) = be(128) else { return b.to_vec() };
let table_end = 132 + 12 * n;
let tags: Option<Vec<(usize, usize)>> = (0..n).map(|t| Some((be(132 + 12 * t + 4)?, be(132 + 12 * t + 8)?))).collect();
let Some(tags) = tags.filter(|t| table_end <= b.len() && t.iter().all(|&(o, l)| o >= table_end && o + l <= b.len())) else {
return b.to_vec();
};
let mut out = b[..table_end].to_vec();
let mut placed: Vec<((usize, usize), usize)> = Vec::new();
for (t, &(off, len)) in tags.iter().enumerate() {
let new_off = match placed.iter().find(|(k, _)| *k == (off, len)) {
// tags sharing data keep sharing it
Some(&(_, o)) => o,
None => {
while !out.len().is_multiple_of(4) {
out.push(0);
}
let o = out.len();
out.extend_from_slice(&b[off..off + len]);
placed.push(((off, len), o));
o
}
};
out[132 + 12 * t + 4..132 + 12 * t + 8].copy_from_slice(&(new_off as u32).to_be_bytes());
}
while !out.len().is_multiple_of(4) {
out.push(0);
}
let size = out.len() as u32;
out[0..4].copy_from_slice(&size.to_be_bytes());
// the profile ID (MD5) would no longer match: clear it (= "not computed")
out[84..100].fill(0);
out
}
/// A profile for one of the well-known spaces with its standard curve.
@ -221,6 +260,23 @@ mod tests {
}
}
#[test]
fn tags_are_aligned() {
for n in NamedSpace::ALL {
for trc in [n.trc(), Trc::Linear] {
let b = write_matrix_trc(&n.rgb_space(), &trc);
assert_eq!(b.len() % 4, 0, "{n:?}");
assert_eq!(u32::from_be_bytes(b[0..4].try_into().unwrap()) as usize, b.len());
let count = u32::from_be_bytes(b[128..132].try_into().unwrap()) as usize;
for t in 0..count {
let off = u32::from_be_bytes(b[132 + 12 * t + 4..132 + 12 * t + 8].try_into().unwrap());
assert_eq!(off % 4, 0, "{n:?} tag {t}");
}
assert!(parse(&b).is_some_and(|i| i.named == Some(n)), "{n:?}");
}
}
}
#[test]
fn linear_profile() {
let bytes = write_matrix_trc(&PROPHOTO, &Trc::Linear);

View File

@ -118,7 +118,7 @@ pub const PROPHOTO: RgbSpace =
RgbSpace { name: "ProPhoto RGB", r: Xy::new(0.734699, 0.265301), g: Xy::new(0.159597, 0.840403), b: Xy::new(0.036598, 0.000105), white: D50 };
/// The 1998 "Adobe RGB" primaries (a public colour-space definition; name used descriptively).
pub const ADOBE_RGB: RgbSpace =
RgbSpace { name: "Adobe RGB (1998)", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 };
RgbSpace { name: "Adobe RGB (1998) compatible", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 };
/// The develop pipeline's working space: linear Rec.2020 / D65.
pub const WORKING: RgbSpace = REC2020;

View File

@ -128,7 +128,12 @@ pub fn specs() -> Vec<CommandSpec> {
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| {
let fresh = DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() };
// back to the photo's import defaults when a default preset gave it its look
let look = s.catalog.photo(id).and_then(|p| p.import_look.clone());
let fresh = match look {
Some(l) => (*l).clone(),
None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() },
};
s.develop_op(id, fresh, "Reset")
})
.collect();

View File

@ -49,6 +49,7 @@ fn step(s: &mut Session, d: isize) -> Result<Value> {
s.end_interaction()?;
s.selection = Selection::single(vis[next]);
s.active_mask = None;
s.active_spot = None;
Ok(json!({"active": vis[next].0}))
}
@ -193,6 +194,7 @@ pub fn specs() -> Vec<CommandSpec> {
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
}
s.active_mask = None;
s.active_spot = None;
Ok(json!({"selected": s.selection.ids.len()}))
}),
cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {

View File

@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Remove Spot",
[],
None,
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy]}",
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
has_active,
|s, p| {
let mode: SpotMode =
@ -265,31 +265,113 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("spot.add", "missing points"));
}
let d = Spot::default();
let spot = Spot {
let mut spot = Spot {
mode,
points: pts,
size: f64_or(p, "size", d.size),
feather: f64_or(p, "feather", d.feather),
opacity: f64_or(p, "opacity", d.opacity),
size: f64_or(p, "size", d.size).clamp(SPOT_SIZE.0, SPOT_SIZE.1),
feather: f64_or(p, "feather", d.feather).clamp(0.0, 100.0),
opacity: f64_or(p, "opacity", d.opacity).clamp(0.0, 100.0),
source_offset: point(p, "source"),
};
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if spot.source_offset.is_none() {
// resolve the automatic source now: it gets a pin and stays put at every size
spot.source_offset = pick_source(s, id, &dd, &spot, None);
}
dd.spots.push(spot);
let n = dd.spots.len();
s.set_develop(id, dd, "Remove")?;
s.active_spot = Some(n - 1);
Ok(json!({"index": n - 1}))
}
),
cmd!("spot.delete", "Delete Spot", [], None, "{index}", has_active, |s, p| {
let i = super::f64_req(p, "index", "spot.delete")? as usize;
let id = s.active().ok_or_else(|| bad("spot.delete", "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if i >= dd.spots.len() {
return Err(bad("spot.delete", "no such spot"));
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
let n = spots_len(s);
s.active_spot = match p.get("index").and_then(Value::as_u64) {
Some(i) if (i as usize) < n => Some(i as usize),
Some(_) => return Err(bad("spot.select", "no such spot")),
None => None,
};
Ok(json!({"activeSpot": s.active_spot}))
}),
cmd!(
"spot.update",
"Edit Spot",
[],
None,
"{index? (the selected spot), move?: [dx,dy] (the target, normalized), source?: [dx,dy] (offset from the target), moveSource?: [dx,dy], size?, feather?, opacity?, mode?}",
has_active,
|s, p| {
let c = "spot.update";
let i = spot_index(s, p, c)?;
let mode: Option<SpotMode> = match p.get("mode") {
Some(m) => Some(serde_json::from_value(m.clone()).map_err(|e| bad(c, e.to_string()))?),
None => None,
};
spots_edit(s, c, "Edit Spot", |spots| {
let sp = &mut spots[i];
if let Some(d) = point(p, "move") {
sp.points.iter_mut().for_each(|q| *q = Point::new(q.x + d.x, q.y + d.y));
// the source moves along (its offset is relative to the target)
}
if let Some(o) = point(p, "source") {
sp.source_offset = Some(o);
}
if let Some(d) = point(p, "moveSource") {
let o = sp.source_offset.unwrap_or(Point::new(0.0, 0.0));
sp.source_offset = Some(Point::new(o.x + d.x, o.y + d.y));
}
if let Some(v) = p.get("size").and_then(Value::as_f64) {
sp.size = v.clamp(SPOT_SIZE.0, SPOT_SIZE.1);
}
if let Some(v) = p.get("feather").and_then(Value::as_f64) {
sp.feather = v.clamp(0.0, 100.0);
}
if let Some(v) = p.get("opacity").and_then(Value::as_f64) {
sp.opacity = v.clamp(0.0, 100.0);
}
if let Some(m) = mode {
sp.mode = m;
}
Ok(())
})?;
Ok(json!({"index": i}))
}
dd.spots.remove(i);
s.set_develop(id, dd, "Delete Spot")?;
),
cmd!(
"spot.refreshSource",
"Refresh Source",
[],
None,
"{index? (the selected spot)} — picks the next-best source away from the current one",
has_active,
|s, p| {
let c = "spot.refreshSource";
let i = spot_index(s, p, c)?;
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let dd = (*s.develop_of(id).unwrap_or_default()).clone();
let spot = dd.spots[i].clone();
let next = pick_source(s, id, &dd, &spot, spot.source_offset).ok_or_else(|| bad(c, "no other source fits in the photo"))?;
spots_edit(s, c, "Refresh Source", |spots| {
spots[i].source_offset = Some(next);
Ok(())
})?;
Ok(json!({"index": i, "source": [next.x, next.y]}))
}
),
cmd!("spot.delete", "Delete Spot", [], None, "{index? (the selected spot)}", has_active, |s, p| {
let c = "spot.delete";
let i = spot_index(s, p, c)?;
spots_edit(s, c, "Delete Spot", |spots| {
spots.remove(i);
Ok(())
})?;
s.active_spot = match s.active_spot {
Some(a) if a == i => None,
Some(a) if a > i => Some(a - 1),
a => a,
};
ok()
}),
// ---- Red eye / pet eye
@ -358,6 +440,36 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Spot radius limits (fraction of the long edge).
pub const SPOT_SIZE: (f64, f64) = (0.001, 0.25);
fn spots_len(s: &Session) -> usize {
s.active().and_then(|id| s.develop_of(id)).map(|d| d.spots.len()).unwrap_or(0)
}
/// `index` from the params, else the selected spot.
fn spot_index(s: &Session, p: &Value, c: &str) -> Result<usize> {
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
if i >= spots_len(s) {
return Err(bad(c, "no such spot"));
}
Ok(i)
}
fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Spot>) -> Result<()>) -> Result<()> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
f(&mut d.spots)?;
s.set_develop(id, d, label)
}
/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`).
fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option<Point>) -> Option<Point> {
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
let info = crate::media::source_info(s.catalog.photo(id)?);
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
}
fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 };
c(&mut a.exposure, -4.0, 4.0);

View File

@ -13,6 +13,7 @@ mod manage;
mod masks;
mod merge;
mod organize;
mod prefs;
mod preset_files;
mod query;
mod xmp;
@ -115,6 +116,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(query::specs());
v.extend(xmp::specs());
v.extend(preset_files::specs());
v.extend(prefs::specs());
v
})
}

View File

@ -0,0 +1,91 @@
//! Library preferences (Settings → Import / Performance): develop defaults applied on import and
//! the thumbnail cache budget. Saved in the library's `prefs.json` (see [`crate::library`]).
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
use crate::import::{CameraDefault, ImportDefaults};
use crate::{Result, Session};
const ID: &str = "library.preferences";
fn current(s: &Session) -> Value {
json!({
"xmp": s.xmp,
"import": s.import_defaults,
"cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) },
"persistent": s.library.is_some(),
})
}
/// `"default"`, `""` or null = no preset; anything else must name an existing preset.
fn preset_ref(s: &Session, v: &Value) -> Result<Option<String>> {
match v {
Value::Null => Ok(None),
Value::String(x) if x.is_empty() || x == "default" => Ok(None),
Value::String(x) if s.presets.iter().any(|p| p.id == *x) => Ok(Some(x.clone())),
Value::String(x) => Err(bad(ID, format!("unknown preset `{x}` (see preset.list)"))),
other => Err(bad(ID, format!("preset must be a preset id or \"default\", not {other}"))),
}
}
fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
let mut d: ImportDefaults = s.import_defaults.clone();
let mut changed = false;
if let Some(i) = p.get("import") {
if let Some(v) = i.get("rawPreset") {
d.raw_preset = preset_ref(s, v)?;
}
if let Some(v) = i.get("otherPreset") {
d.other_preset = preset_ref(s, v)?;
}
if let Some(v) = i.get("perCamera").and_then(Value::as_bool) {
d.per_camera = v;
}
if let Some(a) = i.get("cameras").and_then(Value::as_array) {
d.cameras.clear();
for c in a {
let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string();
if camera.is_empty() {
return Err(bad(ID, "each camera default needs `camera` (make + model)"));
}
let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?;
d.cameras.push(CameraDefault { camera, preset });
}
}
changed = true;
}
// set or clear one camera's default: {camera: {camera: "Make Model", preset: id|null}}
if let Some(c) = p.get("camera") {
let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string();
if camera.is_empty() {
return Err(bad(ID, "`camera.camera` (make + model) is required"));
}
let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?;
d.cameras.retain(|x| !x.camera.eq_ignore_ascii_case(&camera));
if c.get("remove").and_then(Value::as_bool) != Some(true) {
d.cameras.push(CameraDefault { camera, preset });
}
changed = true;
}
s.import_defaults = d;
if let Some(mb) = p.get("cacheMb") {
let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?;
s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?;
} else if changed {
s.save_prefs()?;
}
Ok(current(s))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"library.preferences",
"Library Preferences",
[],
None,
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}]}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}",
always,
prefs
)]
}

View File

@ -89,6 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"selection": s.selection,
"visibleCount": n,
"activeMask": s.active_mask,
"activeSpot": s.active_spot,
"undo": s.undo.last().map(|u| u.label.clone()),
"redo": s.redo.last().map(|u| u.label.clone()),
"clipboard": s.clipboard.is_some(),

View File

@ -1,9 +1,11 @@
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF with an embedded sRGB profile,
//! optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF in sRGB, Display P3, Adobe RGB
//! (1998) compatible, ProPhoto RGB or Rec. 2020 with an embedded ICC profile we generate from the
//! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
//! app, CLI, MCP and the web build share it; writing the file is the caller's job.
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, TiffCompression, encode, icc};
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, TiffCompression, encode, icc};
use lightcraft_meta::{DateTime, Gps, Metadata};
pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace};
use lightcraft_raster::Rgba8;
use serde::{Deserialize, Serialize};
@ -103,15 +105,49 @@ impl Default for Watermark {
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// Draw `wm` onto `img` (straight sRGB alpha blending).
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
let width = img.width;
watermark_coverage(img.width, img.height, wm, |x, y, k, col| {
let p = &mut img.data[y * width + x];
for c in 0..3 {
p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;
}
});
}
/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float
/// images are linear and blend in linear light).
pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
let (width, trc) = (img.width, img.space.trc());
match &mut img.samples {
DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| {
let i = (y * width + x) * 3;
for c in 0..3 {
let p = v[i + c] as f32;
v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16;
}
}),
DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| {
let i = (y * width + x) * 3;
for c in 0..3 {
let target = trc.decode(col[c] as f32 / 255.0);
v[i + c] += (target - v[i + c]) * k;
}
}),
}
}
/// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for
/// every covered pixel (shadow pass first).
fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point};
let text = wm.text.trim();
if text.is_empty() || img.width == 0 || img.height == 0 {
if text.is_empty() || width == 0 || height == 0 {
return;
}
let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return };
let short = img.width.min(img.height) as f32;
let short = width.min(height) as f32;
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0);
let sf = font.as_scaled(PxScale::from(px));
// Lay out one line.
@ -129,7 +165,7 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
}
let (tw, th) = (x, sf.ascent() - sf.descent());
let inset = wm.inset.clamp(0.0, 0.4) * short;
let (w, h) = (img.width as f32, img.height as f32);
let (w, h) = (width as f32, height as f32);
use Anchor::*;
let ox = match wm.anchor {
TopLeft | Left | BottomLeft => inset,
@ -143,14 +179,10 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
};
let alpha = wm.opacity.clamp(0.0, 1.0);
let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| {
if gx < 0 || gy < 0 || gx >= img.width as i32 || gy >= img.height as i32 {
if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 {
return;
}
let k = (cov * a).clamp(0.0, 1.0);
let p = &mut img.data[gy as usize * img.width + gx as usize];
for c in 0..3 {
p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;
}
blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col);
};
let passes: &[(f32, [u8; 3], f32)] =
if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] };
@ -198,6 +230,11 @@ pub struct ExportOptions {
pub remove_location: bool,
/// Text watermark (none when absent or the text is empty).
pub watermark: Option<Watermark>,
/// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile).
pub color_space: OutputSpace,
/// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
pub bit_depth: Option<u8>,
}
impl Default for ExportOptions {
@ -213,12 +250,15 @@ impl Default for ExportOptions {
metadata: MetadataPolicy::All,
remove_location: false,
watermark: None,
color_space: OutputSpace::Srgb,
bit_depth: None,
}
}
}
impl ExportOptions {
/// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, `sharpenAmount`, `naming`).
/// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`,
/// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`).
pub fn from_json(p: &serde_json::Value) -> Self {
use serde_json::Value;
let d = Self::default();
@ -243,9 +283,40 @@ impl ExportOptions {
_ => None,
}
.filter(|w: &Watermark| !w.text.trim().is_empty()),
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
}
}
/// The sample format the file is written with (unsupported requests fall back to the closest).
pub fn effective_depth(&self) -> OutputDepth {
match (self.format, self.bit_depth) {
(ExportFormat::Jpeg | ExportFormat::Webp, _) => OutputDepth::U8,
(ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16,
(ExportFormat::Png, _) => OutputDepth::U8,
(ExportFormat::Tiff, Some(8)) => OutputDepth::U8,
(ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear,
(ExportFormat::Tiff, _) => OutputDepth::U16,
(ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16,
(ExportFormat::Avif, _) => OutputDepth::U8,
}
}
/// The bit depths `format` offers (value, label); the first is its default.
pub fn bit_depths(format: ExportFormat) -> &'static [(u8, &'static str)] {
match format {
ExportFormat::Jpeg | ExportFormat::Webp => &[(8, "8-bit")],
ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")],
ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")],
ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")],
}
}
/// The colour space the file is actually written in (AVIF: sRGB).
pub fn effective_space(&self) -> OutputSpace {
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
}
/// Output file name for photo `stem` at 1-based position `seq` in a batch.
pub fn file_name(&self, stem: &str, seq: usize) -> String {
let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() };
@ -257,8 +328,43 @@ impl ExportOptions {
/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian).
pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) {
let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect();
if let Some(out) = unsharp(img.width, img.height, &src, target, amount) {
for (p, v) in img.data.iter_mut().zip(out) {
for c in 0..3 {
p[c] = v[c].round().clamp(0.0, 255.0) as u8;
}
}
}
}
/// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light).
pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) {
let (w, h) = (img.width, img.height);
match &mut img.samples {
DeepSamples::U16(v) => {
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
if let Some(out) = unsharp(w, h, &src, target, amount) {
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
*d = s.round().clamp(0.0, 65535.0) as u16;
}
}
}
DeepSamples::F32(v) => {
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
if let Some(out) = unsharp(w, h, &src, target, amount) {
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
*d = s.max(0.0);
}
}
}
}
}
/// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done.
fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option<Vec<[f32; 3]>> {
let (radius, base) = match target {
SharpenFor::None => return,
SharpenFor::None => return None,
SharpenFor::Screen => (1usize, 0.35f32),
SharpenFor::Matte => (2, 0.6),
SharpenFor::Glossy => (1, 0.5),
@ -269,11 +375,9 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount
SharpenAmount::Standard => 1.0,
SharpenAmount::High => 1.5,
};
let (w, h) = (img.width, img.height);
if w < 3 || h < 3 {
return;
return None;
}
let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect();
let r = radius as isize;
let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> {
let mut out = vec![[0.0; 3]; inp.len()];
@ -295,16 +399,12 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount
}
out
};
let blur = box_pass(&box_pass(&src, true), false);
for (i, p) in img.data.iter_mut().enumerate() {
for c in 0..3 {
let v = src[i][c] + (src[i][c] - blur[i][c]) * k;
p[c] = v.round().clamp(0.0, 255.0) as u8;
}
}
let blur = box_pass(&box_pass(src, true), false);
Some(src.iter().zip(&blur).map(|(s, b)| std::array::from_fn(|c| s[c] + (s[c] - b[c]) * k)).collect())
}
/// Encode a rendered (display-referred sRGB) image according to `o`. Resizing to `long_edge` is the
/// Encode a rendered display-referred image according to `o` (its pixels must already be in
/// `o.effective_space()`). Resizing to `long_edge` is the
/// caller's job (render at that size); sharpening is applied here.
pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
encode_with_metadata(img, o, None)
@ -314,10 +414,12 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
let mut img = img.clone();
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
let space = o.effective_space();
if let Some(wm) = &o.watermark {
draw_watermark(&mut img, wm);
let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() };
draw_watermark(&mut img, &wm);
}
let profile = icc::write_named(NamedSpace::Srgb);
let profile = icc::write_named(named_space(space));
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() };
@ -358,6 +460,69 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
r.map_err(|e| e.to_string())
}
/// The codecs' name of an output space (for its ICC profile).
pub fn named_space(s: OutputSpace) -> NamedSpace {
match s {
OutputSpace::Srgb => NamedSpace::Srgb,
OutputSpace::DisplayP3 => NamedSpace::DisplayP3,
OutputSpace::AdobeRgb => NamedSpace::AdobeRgb,
OutputSpace::ProPhoto => NamedSpace::ProPhoto,
OutputSpace::Rec2020 => NamedSpace::Rec2020,
}
}
/// An 8-bit sRGB colour expressed in `space` (encoded with its curve).
pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] {
if space == OutputSpace::Srgb {
return c;
}
let lin = c.map(lightcraft_color::transfer::decode_srgb8);
let m = lightcraft_color::SRGB.to_space(&space.rgb_space()).to_f32();
let t = space.trc();
std::array::from_fn(|i| {
let v = m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2];
(t.encode(v) * 255.0 + 0.5) as u8
})
}
/// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF,
/// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image.
pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
match &r.deep {
Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta),
_ => encode_with_metadata(&r.image, o, meta),
}
}
/// Encode a high-bit-depth image (see [`encode_rendered`]).
pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
let mut img = img.clone();
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(img.space, wm.color), ..wm.clone() };
draw_watermark_deep(&mut img, &wm);
}
let profile = match img.samples {
DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear),
DeepSamples::U16(_) => icc::write_named(named_space(img.space)),
};
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() };
let (w, h) = (img.width as u32, img.height as u32);
let e = match &img.samples {
DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)),
DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)),
};
let r = match o.format {
ExportFormat::Png => encode::encode_png(&e, &meta),
ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta),
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
f => return Err(format!("{f:?} export is 8-bit only")),
};
r.map_err(|e| e.to_string())
}
/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds.
fn parse_shutter(s: &str) -> Option<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
@ -414,8 +579,8 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI
let full = p.width.max(p.height).max(1) as usize;
let size = o.long_edge.map_or(full, |l| l as usize);
let meta = export_metadata(p, o);
let r = session.render_now(id, size, size)?;
let bytes = encode_with_metadata(&r.image, o, meta.as_ref())?;
let r = session.render_export(id, size, o.effective_space(), o.effective_depth())?;
let bytes = encode_rendered(&r, o, meta.as_ref())?;
Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height })
}
@ -516,4 +681,173 @@ mod tests {
assert_eq!(o.long_edge, Some(2048));
assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg");
}
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
fn p3_green(space: OutputSpace) -> Rgba8 {
use lightcraft_color::{DISPLAY_P3, REC2020};
let c = DISPLAY_P3.to_space(&REC2020).apply_f32([0.04, 0.45, 0.04]);
let src = lightcraft_raster::Rgb32f::filled(16, 16, c);
let req = lightcraft_pipeline::RenderRequest { space, ..lightcraft_pipeline::RenderRequest::fit(16, 16) };
lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req).image
}
/// Decode `bytes` and return the centre pixel in linear sRGB primaries (unclamped), plus the
/// recognised space of the embedded profile.
fn decoded_in_srgb(bytes: &[u8]) -> ([f32; 3], Option<NamedSpace>) {
let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes");
let m = d.space.to_space(&lightcraft_color::SRGB);
(m.apply_f32(d.image.get(8, 8)), d.space.named)
}
#[test]
fn p3_colour_survives_a_p3_export_and_is_clipped_in_srgb() {
let o = |space| ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() };
let (p3, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::DisplayP3), &o(OutputSpace::DisplayP3)).unwrap());
assert_eq!(named, Some(NamedSpace::DisplayP3));
assert!(p3[0] < -0.03 || p3[2] < -0.03, "outside sRGB after a P3 round trip: {p3:?}");
let (s, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::Srgb), &o(OutputSpace::Srgb)).unwrap());
assert_eq!(named, Some(NamedSpace::Srgb));
assert!(s.iter().all(|v| *v > -0.002), "{s:?}");
// the green itself is about the same brightness either way
assert!((p3[1] - s[1]).abs() < 0.15, "{p3:?} {s:?}");
}
#[test]
fn every_space_embeds_its_own_profile_and_round_trips() {
let grey = {
let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]);
let base = lightcraft_pipeline::RenderRequest::fit(16, 16);
move |space| {
lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base })
.image
}
};
let reference =
decoded_in_srgb(&encode_image(&grey(OutputSpace::Srgb), &ExportOptions { format: ExportFormat::Tiff, ..Default::default() }).unwrap()).0;
for space in OutputSpace::ALL {
for format in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
let o = ExportOptions { format, color_space: space, ..Default::default() };
let bytes = encode_image(&grey(space), &o).unwrap();
let (px, named) = decoded_in_srgb(&bytes);
assert_eq!(named, Some(named_space(space)), "{space:?} {format:?}");
// a neutral grey decodes to the same linear value whatever the output space
for c in 0..3 {
assert!((px[c] - reference[c]).abs() < 0.01, "{space:?} {format:?}: {px:?} vs {reference:?}");
}
}
let icc = icc::write_named(named_space(space));
let info = icc::parse(&icc).expect("our profile parses");
assert_eq!(info.named, Some(named_space(space)));
assert!(info.description.as_deref().is_some_and(|d| !d.is_empty()));
}
let o = ExportOptions { format: ExportFormat::Avif, color_space: OutputSpace::ProPhoto, ..Default::default() };
assert_eq!(o.effective_space(), OutputSpace::Srgb);
let o = ExportOptions::from_json(&serde_json::json!({"colorSpace": "displayP3"}));
assert_eq!(o.color_space, OutputSpace::DisplayP3);
assert_eq!(ExportOptions::from_json(&serde_json::json!({"colorSpace": "Adobe RGB (1998) compatible"})).color_space, OutputSpace::AdobeRgb);
}
#[test]
fn srgb_watermark_colour_is_converted() {
assert_eq!(srgb8_in(OutputSpace::Srgb, [10, 200, 30]), [10, 200, 30]);
for s in OutputSpace::ALL {
assert_eq!(srgb8_in(s, [255, 255, 255]), [255, 255, 255], "{s:?}");
assert_eq!(srgb8_in(s, [0, 0, 0]), [0, 0, 0], "{s:?}");
}
let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]);
assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}");
}
/// A shallow horizontal grey ramp (few 8-bit levels), rendered at `depth`.
fn ramp(depth: OutputDepth, space: OutputSpace) -> lightcraft_pipeline::Rendered {
let src = lightcraft_raster::Rgb32f::from_fn(1024, 4, |x, _| [0.10 + 0.03 * x as f32 / 1023.0; 3]);
let req = lightcraft_pipeline::RenderRequest { depth, space, ..lightcraft_pipeline::RenderRequest::fit(1024, 4) };
lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req)
}
fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) {
let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes");
let mut v: Vec<u32> = (0..d.image.width).map(|x| d.image.get(x, 1)[1].to_bits()).collect();
v.dedup();
(v.len(), d)
}
#[test]
fn sixteen_bit_tiff_and_png_have_no_banding() {
let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() };
assert_eq!(tiff(None).effective_depth(), OutputDepth::U16, "TIFF defaults to 16-bit");
let r16 = ramp(OutputDepth::U16, OutputSpace::Srgb);
assert!(matches!(r16.deep.as_ref().unwrap().samples, DeepSamples::U16(_)));
let (levels16, d16) = distinct_levels(&encode_rendered(&r16, &tiff(None), None).unwrap());
let r8 = ramp(OutputDepth::U8, OutputSpace::Srgb);
let (levels8, _) = distinct_levels(&encode_rendered(&r8, &tiff(Some(8)), None).unwrap());
eprintln!("gradient levels: 16-bit TIFF {levels16}, 8-bit TIFF {levels8}");
assert!(levels8 < 40, "{levels8}");
assert!(levels16 > 10 * levels8, "16-bit: {levels16} levels vs 8-bit: {levels8}");
assert_eq!(d16.space.named, Some(NamedSpace::Srgb));
// the 8-bit preview of a deep render matches the 8-bit render
for (a, b) in r16.image.data.iter().zip(&r8.image.data) {
assert!(a[1].abs_diff(b[1]) <= 1, "{a:?} {b:?}");
}
let png = ExportOptions { format: ExportFormat::Png, bit_depth: Some(16), color_space: OutputSpace::DisplayP3, ..Default::default() };
let rp = ramp(OutputDepth::U16, OutputSpace::DisplayP3);
let bytes = encode_rendered(&rp, &png, None).unwrap();
assert_eq!(bytes[24], 16, "PNG IHDR bit depth");
let (levels, d) = distinct_levels(&bytes);
assert!(levels > 10 * levels8, "{levels}");
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
// sharpening and watermarking work on deep images too
let o = ExportOptions {
sharpen: SharpenFor::Matte,
watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }),
..tiff(None)
};
assert!(encode_rendered(&r16, &o, None).is_ok());
}
#[test]
fn float_tiff_is_linear_with_a_linear_profile() {
let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(32), color_space: OutputSpace::ProPhoto, ..Default::default() };
assert_eq!(o.effective_depth(), OutputDepth::F32Linear);
let rf = ramp(OutputDepth::F32Linear, OutputSpace::ProPhoto);
let (levels, df) = distinct_levels(&encode_rendered(&rf, &o, None).unwrap());
assert!(levels > 500, "{levels}");
assert_eq!(df.space.named, Some(NamedSpace::ProPhoto));
assert!(df.space.trc.as_ref().is_some_and(|t| t[0].is_linear()), "{:?}", df.space.trc);
// decodes to the same light as the 16-bit gamma-encoded export
let o16 = ExportOptions { bit_depth: Some(16), ..o.clone() };
let (_, d16) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::ProPhoto), &o16, None).unwrap());
for x in [0, 300, 700, 1023] {
let (a, b) = (df.image.get(x, 1)[1], d16.image.get(x, 1)[1]);
assert!((a - b).abs() < 2e-4, "{x}: {a} vs {b}");
}
}
#[test]
fn bit_depth_options_per_format() {
let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth();
assert_eq!(d(ExportFormat::Jpeg, Some(16)), OutputDepth::U8);
assert_eq!(d(ExportFormat::Webp, None), OutputDepth::U8);
assert_eq!(d(ExportFormat::Png, None), OutputDepth::U8);
assert_eq!(d(ExportFormat::Png, Some(16)), OutputDepth::U16);
assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16);
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16));
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None);
for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif] {
let first = ExportOptions::bit_depths(f)[0].0;
let def = d(f, None);
assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default");
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn avif_ten_bit() {
let o = ExportOptions { format: ExportFormat::Avif, bit_depth: Some(10), quality: 60, ..Default::default() };
let r = ramp(OutputDepth::U16, OutputSpace::Srgb);
match encode_rendered(&r, &o, None) {
Ok(b) => assert_eq!(&b[4..8], b"ftyp"),
Err(e) => assert!(e.contains("not available"), "{e}"),
}
}
}

View File

@ -95,14 +95,74 @@ pub struct ImportReport {
/// The develop settings a photo gets on import: raws start from their as-shot white balance with
/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the
/// camera intended).
/// camera intended); a user default preset ([`ImportDefaults`]) goes on top.
pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings {
p.import_defaults()
}
/// Does the photo still look as imported (its embedded camera preview is then a fair stand-in)?
/// Does the photo still look as the camera rendered it (its embedded camera preview is then a
/// fair stand-in)? Not when a default preset changed it on import.
pub fn has_import_look(p: &Photo) -> bool {
*p.develop == import_defaults(p)
*p.develop == p.camera_defaults()
}
/// User defaults applied on import (Settings → Import; saved in the library's `prefs.json`).
/// Presets are referenced by id; a preset that no longer exists is ignored.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ImportDefaults {
/// Preset applied to raw files (`None` = the LightCraft default).
pub raw_preset: Option<String>,
/// Use a camera's own default (below) when the photo's camera has one.
pub per_camera: bool,
/// Per-camera raw defaults, keyed by the camera's make + model as shown in Info
/// (`"Canon EOS R5"`).
pub cameras: Vec<CameraDefault>,
/// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none).
pub other_preset: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CameraDefault {
/// Make + model (`Meta::camera`).
pub camera: String,
/// Preset id; `None` = the LightCraft default for this camera.
pub preset: Option<String>,
}
impl ImportDefaults {
/// The preset id that applies to a photo of this kind and camera, if any.
pub fn preset_for(&self, raw: bool, camera: &str) -> Option<&str> {
if !raw {
return self.other_preset.as_deref();
}
if self.per_camera
&& let Some(c) = self.cameras.iter().find(|c| !camera.is_empty() && c.camera.eq_ignore_ascii_case(camera))
{
return c.preset.as_deref();
}
self.raw_preset.as_deref()
}
}
/// Give a freshly imported photo its default settings: the camera defaults, then the matching
/// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look.
pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
let base = p.camera_defaults();
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id));
match preset {
Some(pr) => {
let look = std::sync::Arc::new(pr.apply(&base, 1.0));
p.develop = look.clone();
p.import_look = (*look != base).then_some(look);
}
None => {
p.develop = std::sync::Arc::new(base);
p.import_look = None;
}
}
}
pub fn is_supported(path: &Path) -> bool {
@ -351,7 +411,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens;
p.develop = std::sync::Arc::new(import_defaults(&p));
apply_import_defaults(s, &mut p);
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())

View File

@ -97,6 +97,8 @@ pub struct Session {
depth: u32,
/// Selected mask (Masking panel), by mask id.
pub active_mask: Option<u32>,
/// Selected spot (Remove panel), by index into the active photo's spots.
pub active_spot: Option<usize>,
/// The persistent library this session writes to (`None` = in-memory only).
pub library: Option<library::Library>,
/// XMP sidecar preferences (persisted with the library).
@ -105,6 +107,10 @@ pub struct Session {
pub last_export: Option<serde_json::Value>,
/// File probes from the last import review (`library.importPreview`), reused by the import.
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
/// Develop defaults applied on import (persisted in prefs.json).
pub import_defaults: import::ImportDefaults,
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
pub cache_mb: u32,
}
impl Default for Session {
@ -137,10 +143,13 @@ impl Session {
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
depth: 0,
active_mask: None,
active_spot: None,
library: None,
xmp: sidecar::XmpPrefs::default(),
last_export: None,
import_probes: Default::default(),
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
}
}
@ -385,4 +394,8 @@ mod tests_merge;
#[cfg(test)]
mod tests_organize;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_spots;
#[cfg(test)]
mod tests_xmp;

View File

@ -5,7 +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)
//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export)
//! thumbs/ (rendered thumbnail cache, safe to delete)
//! Originals/ (photos imported with "copy into library")
//! ```
@ -99,11 +99,16 @@ impl Library {
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
#[serde(default, rename_all = "camelCase")]
struct PrefsFile {
xmp: crate::sidecar::XmpPrefs,
/// Parameters of the last export (for Export with Previous).
#[serde(alias = "last_export")]
last_export: Option<serde_json::Value>,
/// Develop defaults for imported photos.
import: crate::import::ImportDefaults,
/// Thumbnail disk cache budget (MB, 0 = default).
cache_mb: u32,
}
fn presets_json(s: &Session) -> String {
@ -165,6 +170,8 @@ impl Session {
let prefs = read_json::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
self.xmp = prefs.xmp;
self.last_export = prefs.last_export;
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
// view state
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
self.source = v.source;
@ -180,8 +187,10 @@ impl Session {
self.selection = Selection::single(*first);
}
let presets_written = presets_json(self);
// photo ids are per library: drop decoded sources of the previous one
self.media.clear_sources();
if on_disk {
self.media.attach_disk_cache(&dir.join("thumbs"));
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
}
let view_written = self.view_json();
self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
@ -262,11 +271,33 @@ impl Session {
/// Save the library preferences (no-op for in-memory sessions).
pub fn save_prefs(&mut self) -> Result<()> {
let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone() }).unwrap_or_default();
let v = serde_json::to_vec_pretty(&PrefsFile {
xmp: self.xmp,
last_export: self.last_export.clone(),
import: self.import_defaults.clone(),
cache_mb: self.cache_mb,
})
.unwrap_or_default();
let Some(lib) = self.library.as_mut() else { return Ok(()) };
lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
}
/// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default).
pub fn cache_bytes(&self) -> u64 {
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
}
/// Change the thumbnail disk cache budget (MB, 0 = default): re-attaches the cache, which
/// trims it to the new size. Saved with the library preferences.
pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> {
self.cache_mb = mb;
if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) {
let dir = lib.thumbs_dir();
self.media.attach_disk_cache(&dir, self.cache_bytes());
}
self.save_prefs()
}
/// Compact the log into a snapshot now.
pub fn compact_library(&mut self) -> Result<()> {
self.persist()?;

View File

@ -143,8 +143,15 @@ fn source_bytes(img: &Rgb32f) -> usize {
impl MediaCache {
/// Keep rendered thumbnails on disk in `dir` as well.
pub fn attach_disk_cache(&mut self, dir: &std::path::Path) {
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, DISK_CACHE_BYTES));
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) {
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
}
/// Forget every decoded source (photo ids changed meaning, e.g. another library was opened).
pub fn clear_sources(&mut self) {
self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget));
self.previews.clear();
self.full = None;
}
/// Bytes the caches may hold together.
@ -355,7 +362,7 @@ impl RenderJob {
photo: self.photo,
level: self.level,
key: self.key,
rendered: Ok(Rendered { image, histogram }),
rendered: Ok(Rendered { image, histogram, deep: None }),
loaded: None,
quick: None,
};
@ -434,7 +441,7 @@ impl QuickJob {
let done = |rendered: Result<Rendered, String>, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick };
let rendered = |image: Rgba8| {
let histogram = Histogram::of_srgb8(&image);
Ok(Rendered { image, histogram })
Ok(Rendered { image, histogram, deep: None })
};
for (cache, k) in &self.cached {
if let Some(img) = cache.get(*k) {
@ -636,6 +643,24 @@ impl crate::Session {
r.rendered
}
/// Synchronous render for export: like [`Self::render_now`], into colour space `space` with
/// sample format `depth`.
pub fn render_export(
&mut self,
id: PhotoId,
max: usize,
space: lightcraft_pipeline::OutputSpace,
depth: lightcraft_pipeline::OutputDepth,
) -> Result<Rendered, String> {
let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?;
job.request.space = space;
job.request.depth = depth;
job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db);
let r = job.run();
self.accept(&r);
r.rendered
}
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();

View File

@ -0,0 +1,134 @@
//! Library preferences: import defaults (raw / other / per camera) applied by import, and their
//! persistence in prefs.json.
use std::path::{Path, PathBuf};
use lightcraft_catalog::MediaKind;
use lightcraft_develop::Preset;
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-prefs-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn user_preset(id: &str, settings: serde_json::Value) -> Preset {
Preset { id: id.into(), name: id.into(), group: "User Presets".into(), settings, favorite: false, builtin: false }
}
fn with_presets(s: &mut Session) {
s.presets.push(user_preset("user.rawlook", json!({"light": {"exposure": 0.5}, "effects": {"clarity": 20.0}})));
s.presets.push(user_preset("user.canonlook", json!({"light": {"contrast": 35.0}})));
s.presets.push(user_preset("user.jpeglook", json!({"color": {"vibrance": 15.0}})));
}
fn dng(dir: &Path, name: &str, make: &str, model: &str) {
let meta = lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
std::fs::write(dir.join(name), crate::tests_xmp::synthetic_dng_with(None, meta)).unwrap();
}
fn png(path: &Path) {
let (w, h) = (24usize, 16usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i * 7) as u8, (i * 3) as u8, 90, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn photo_named<'a>(s: &'a Session, name: &str) -> &'a lightcraft_catalog::Photo {
s.catalog.photos().find(|p| p.file_name == name).unwrap_or_else(|| panic!("{name} not imported"))
}
#[test]
fn raw_and_other_defaults_are_applied_on_import() {
let src = temp_dir("apply");
dng(&src, "nikon.dng", "Nikon", "Z 9");
dng(&src, "canon.dng", "Canon", "EOS R5");
png(&src.join("plain.png"));
let mut s = Session::new().with_fs();
with_presets(&mut s);
let r = s
.execute(
"library.preferences",
&json!({"import": {"rawPreset": "user.rawlook", "otherPreset": "user.jpeglook", "perCamera": true,
"cameras": [{"camera": "Canon EOS R5", "preset": "user.canonlook"}]}}),
)
.unwrap();
assert_eq!(r["import"]["rawPreset"], "user.rawlook", "{r}");
assert!(s.execute("library.preferences", &json!({"import": {"rawPreset": "nope"}})).is_err(), "unknown presets are rejected");
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(s.catalog.len(), 3);
let nikon = photo_named(&s, "nikon.dng");
assert_eq!(nikon.kind, MediaKind::Raw);
assert_eq!(nikon.meta.camera, "Nikon Z 9");
assert_eq!((nikon.develop.light.exposure, nikon.develop.effects.clarity), (0.5, 20.0), "global raw default");
assert_eq!(nikon.develop.detail.sharpen_amount, 40.0, "raw camera defaults stay underneath the preset");
assert!(!nikon.is_edited(), "the default look counts as unedited");
assert!(!crate::import::has_import_look(nikon), "a preset look isn't what the embedded preview shows");
let canon = photo_named(&s, "canon.dng");
assert_eq!((canon.develop.light.contrast, canon.develop.light.exposure), (35.0, 0.0), "per-camera default wins");
let plain = photo_named(&s, "plain.png");
assert_eq!((plain.develop.color.vibrance, plain.develop.light.exposure), (15.0, 0.0), "non-raw default");
// editing then Reset returns to the default look
let id = nikon.id;
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap();
assert!(s.catalog.photo(id).unwrap().is_edited());
s.execute("develop.reset", &json!({})).unwrap();
let p = s.catalog.photo(id).unwrap();
assert_eq!(p.develop.light.exposure, 0.5);
assert!(!p.is_edited());
// per-camera off: the Canon gets the global raw default; clearing it gives the plain defaults
let mut s2 = Session::new().with_fs();
with_presets(&mut s2);
s2.execute("library.preferences", &json!({"camera": {"camera": "canon eos r5", "preset": "user.canonlook"}})).unwrap();
s2.execute("library.import", &json!({"paths": [src.join("canon.dng").to_string_lossy()]})).unwrap();
let c = s2.catalog.photos().next().unwrap();
assert_eq!(c.develop.light.contrast, 0.0, "per-camera defaults are off unless enabled");
assert!(c.import_look.is_none());
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn preferences_persist_with_the_library() {
let lib = temp_dir("lib");
{
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
with_presets(&mut s);
s.persist().unwrap(); // presets.json
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
s.execute(
"library.preferences",
&json!({"import": {"rawPreset": "user.rawlook", "perCamera": true}, "camera": {"camera": "Canon EOS R5", "preset": "user.canonlook"}, "cacheMb": 512}),
)
.unwrap();
s.close_library().unwrap();
}
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert!(s.xmp.auto_write);
assert_eq!(s.import_defaults.raw_preset.as_deref(), Some("user.rawlook"));
assert!(s.import_defaults.per_camera);
assert_eq!(s.import_defaults.cameras.len(), 1);
assert_eq!(s.import_defaults.preset_for(true, "Canon EOS R5"), Some("user.canonlook"));
assert_eq!(s.cache_mb, 512);
assert_eq!(s.cache_bytes(), 512 << 20);
let r = s.execute("library.preferences", &json!({})).unwrap();
assert_eq!(r["cacheMb"], 512);
assert_eq!(r["persistent"], true);
// older prefs.json files (snake_case `last_export`, no import section) still load
std::fs::write(lib.join("prefs.json"), r#"{"xmp": {"autoWrite": false}, "last_export": {"format": "png"}}"#).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.last_export, Some(json!({"format": "png"})));
assert_eq!(s.import_defaults, Default::default());
let _ = std::fs::remove_dir_all(&lib);
}

View File

@ -0,0 +1,57 @@
//! Remove tool commands: add (automatic source), select, move target/source, edit, refresh, delete.
use serde_json::json;
use crate::Session;
fn spots(s: &Session) -> Vec<lightcraft_develop::Spot> {
s.develop_of(s.active().unwrap()).unwrap().spots.clone()
}
#[test]
fn spots_select_move_edit_refresh_and_delete() {
let mut s = Session::with_demo();
// a remove spot gets a resolved source (a pin to show) and is selected
let r = s.execute("spot.add", &json!({"points": [[0.4, 0.5]], "size": 0.02})).unwrap();
assert_eq!(r["index"], 0);
assert_eq!(s.active_spot, Some(0));
let src0 = spots(&s)[0].source_offset.expect("automatic source resolved");
assert!(src0.x.hypot(src0.y) > 0.02, "the source is outside the spot: {src0:?}");
s.execute("spot.add", &json!({"mode": "clone", "points": [[0.7, 0.3]], "source": [0.1, 0.0]})).unwrap();
assert_eq!(s.active_spot, Some(1));
assert_eq!(s.execute("library.state", &json!({})).unwrap()["activeSpot"], 1);
// select
s.execute("spot.select", &json!({"index": 0})).unwrap();
assert_eq!(s.active_spot, Some(0));
assert!(s.execute("spot.select", &json!({"index": 9})).is_err());
// move the target (the source follows: its offset is relative), then the source alone
s.execute("spot.update", &json!({"move": [0.05, -0.1]})).unwrap();
let sp = &spots(&s)[0];
assert!((sp.points[0].x - 0.45).abs() < 1e-9 && (sp.points[0].y - 0.4).abs() < 1e-9);
assert_eq!(sp.source_offset, Some(src0));
s.execute("spot.update", &json!({"moveSource": [0.01, 0.02]})).unwrap();
let o = spots(&s)[0].source_offset.unwrap();
assert!((o.x - src0.x - 0.01).abs() < 1e-9 && (o.y - src0.y - 0.02).abs() < 1e-9);
// size / feather / opacity of the selected spot (clamped), one undo step each
s.execute("spot.update", &json!({"size": 0.03, "feather": 20, "opacity": 150})).unwrap();
let sp = &spots(&s)[0];
assert_eq!((sp.size, sp.feather, sp.opacity), (0.03, 20.0, 100.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spots(&s)[0].size, 0.02);
// refresh: a different source, at least a spot radius away
let before = spots(&s)[0].source_offset.unwrap();
let r = s.execute("spot.refreshSource", &json!({})).unwrap();
let after = spots(&s)[0].source_offset.unwrap();
assert!((after.x - before.x).hypot(after.y - before.y) > 0.01, "{r}");
// delete the selected spot: the selection clears; deleting before a selected one shifts it
s.execute("spot.delete", &json!({})).unwrap();
assert_eq!((spots(&s).len(), s.active_spot), (1, None));
assert_eq!(spots(&s)[0].mode, lightcraft_develop::SpotMode::Clone);
assert!(s.execute("spot.delete", &json!({})).is_err(), "nothing selected");
s.execute("spot.add", &json!({"points": [[0.2, 0.2]], "source": [0.05, 0.0]})).unwrap();
s.execute("spot.delete", &json!({"index": 0})).unwrap();
assert_eq!(s.active_spot, Some(0));
// another photo: no selection
s.execute("library.next", &json!({})).unwrap();
assert_eq!(s.active_spot, None);
}

View File

@ -174,6 +174,11 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
}
fn synthetic_dng(xmp: &str) -> Vec<u8> {
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
}
/// A tiny Bayer DNG written by our own DNG writer (optional embedded XMP, camera metadata).
pub(crate) fn synthetic_dng_with(xmp: Option<&str>, metadata: lightcraft_meta::Metadata) -> Vec<u8> {
use lightcraft_raw::*;
let (w, h) = (32usize, 24usize);
let cfa = Cfa::bayer("RGGB").unwrap();
@ -203,9 +208,9 @@ fn synthetic_dng(xmp: &str) -> Vec<u8> {
wb_multipliers: None,
linearized: false,
opcodes: OpcodeLists::default(),
metadata: lightcraft_meta::Metadata::default(),
metadata,
};
write_dng(&raw, &DngWriteOptions { xmp: Some(xmp.to_string()), ..Default::default() }).unwrap()
write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap()
}
#[test]

View File

@ -38,7 +38,22 @@ const MODULES: &[Module] = &[
Module {
src: include_str!("wgsl/map.wgsl"),
bindings: &[("a", false, "f32"), ("b", false, "f32"), ("c", false, "f32"), ("dst", true, "f32")],
entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample", "redeye_k"],
entries: &[
"log_lum_k",
"dark_k",
"guided_pre",
"guided_ab",
"guided_apply",
"wb_k",
"nr_lum",
"chroma_k",
"nr_col",
"subsample",
"redeye_k",
"xguided_pre",
"xguided_ab",
"xguided_apply",
],
},
Module {
src: include_str!("wgsl/mask.wgsl"),
@ -214,12 +229,11 @@ pub struct Gpu {
/// WGSL constants shared by every module (generated from the CPU pipeline's values).
fn constants() -> String {
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_TERMS, SRGB_LUT_N};
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_SUMS, MASK_TERMS, SRGB_LUT_N};
use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N};
let mut s = String::new();
let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices();
let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32();
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab), ("TO_SRGB", to_srgb)] {
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab)] {
let rows: Vec<String> = m.iter().map(|r| format!("vec3<f32>({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect();
s += &format!("const {name} = array<vec3<f32>, 3>({});\n", rows.join(", "));
}
@ -227,9 +241,14 @@ fn constants() -> String {
s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV));
let b = lightcraft_pipeline::geometry::BLANK;
s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]);
s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N);
s += &format!(
"const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\nconst MASK_SUMS: u32 = {MASK_SUMS}u;\n",
crate::params::CURVE_N
);
s += &format!("const POINT_WORDS: u32 = {}u;\n", lightcraft_pipeline::colorops::POINT_WORDS);
s += &format!("const EYE_WORDS: u32 = {}u;\n", lightcraft_pipeline::redeye::EYE_WORDS);
use lightcraft_pipeline::masks::{AUTO_TOL_CHROMA, AUTO_TOL_EV};
s += &format!("const AUTO_TOL_EV: f32 = {AUTO_TOL_EV:?};\nconst AUTO_TOL_CHROMA: f32 = {AUTO_TOL_CHROMA:?};\n");
s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT);
for (i, h) in GRAIN_HASH.iter().enumerate() {
s += &format!("const GRAIN_H{i}: u32 = {h}u;\n");

View File

@ -184,7 +184,8 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
#[cfg(not(target_arch = "wasm32"))]
{
if !enabled() {
// the kernel writes 8-bit output: high-bit-depth exports render on the CPU
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 {
return None;
}
let gpu = device()?;

View File

@ -31,6 +31,7 @@ const FIELDS: &[(&str, usize)] = &[
("HAS_CLAR", 1),
("HAS_TEX", 1),
("HAS_DARK", 1),
("HAS_CHROMA", 1),
("OPS_IDENTITY", 1),
("VIBRANCE", 1),
("SATURATION", 1),
@ -66,6 +67,10 @@ const FIELDS: &[(&str, usize)] = &[
("CALIB", 1),
("CALIB_M", 9),
("SHADOW_TINT", 1),
("OUT_M", 9),
("OUT_Y", 3),
("OUT_TRC", 1),
("OUT_GAMMA", 1),
];
/// `(name, index)` of every field (for the WGSL constants).
@ -123,6 +128,8 @@ pub struct Present {
pub clarity: bool,
pub texture: bool,
pub dark: bool,
/// The blurred chromaticity follows the mask planes in the `masks` buffer.
pub chroma: bool,
}
/// The `finish` kernel's parameter block and auxiliary table (tone LUT | sRGB LUT | curve LUTs |
@ -166,6 +173,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
p.b("HAS_CLAR", present.clarity);
p.b("HAS_TEX", present.texture);
p.b("HAS_DARK", present.dark);
p.b("HAS_CHROMA", present.chroma);
let ops = &fp.ops;
p.b("OPS_IDENTITY", ops.is_identity());
@ -212,6 +220,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
p.fs("CALIB_M", m.as_flattened());
}
p.f("SHADOW_TINT", fp.shadow_tint);
p.fs("OUT_M", fp.to_out.as_flattened());
p.fs("OUT_Y", &fp.out_luma);
let (trc, gamma) = fp.out_trc.code();
p.u("OUT_TRC", trc);
p.f("OUT_GAMMA", gamma);
if let Some((amt, cell, rough, seed)) = fp.grain {
p.b("GRAIN", true);
p.f("GRAIN_AMT", amt);

View File

@ -44,6 +44,7 @@ struct Planes {
clarity: Option<(u32, Arc<Buf>)>,
texture: Option<(u32, Arc<Buf>)>,
dark: Option<(u32, Arc<Buf>, f32)>,
chroma: Option<(u32, Arc<Buf>)>,
}
impl GpuStages {
@ -170,6 +171,20 @@ impl<'a> Cx<'a> {
out
}
/// `len` values of `b` from `offset` (both in 32-bit words), as a new buffer (recorded).
pub fn slice(&mut self, b: &Buf, offset: usize, len: usize) -> Buf {
let out = self.gpu.buffer(len);
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
enc.copy_buffer_to_buffer(b.raw(), (offset * 4) as u64, out.raw(), 0, (len * 4) as u64);
out
}
/// Copy all of `src` into `dst` at `offset` (32-bit words; recorded).
pub fn copy_into(&mut self, src: &Buf, dst: &Buf, offset: usize) {
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
enc.copy_buffer_to_buffer(src.raw(), 0, dst.raw(), (offset * 4) as u64, (src.len * 4) as u64);
}
pub fn read_rgb(&mut self, b: &Buf, w: usize, h: usize) -> Rgb32f {
Rgb32f { width: w, height: h, data: self.read(b, w * h * 3) }
}
@ -495,8 +510,13 @@ pub fn render(
lap("masks", &mut t, &mut cx);
// 5. per-pixel stage
let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air);
let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), dark: prep.dark.is_some() };
let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
let present = Present {
clarity: prep.clarity.is_some(),
texture: prep.texture.is_some(),
dark: prep.dark.is_some(),
chroma: masks.is_some() && prep.chroma.is_some(),
};
let (p, aux) = finish_block(&fp, &terms, &present);
let aux = gpu.upload(&aux);
let out = gpu.buffer(n);
@ -516,14 +536,31 @@ pub fn render(
],
groups2(w, h, [16, 16]),
);
// the alpha a mask overlay shows: copied out before the readback below submits
let overlay_mask = req.overlay.mask(s).map(|m| {
let list = s.masks.iter().filter(|m| m.visible && !m.components.is_empty());
match (list.map(|m| m.id).position(|id| id == m.id), masks.as_ref()) {
(Some(mi), Some(all)) => Ok(cx.slice(all, mi * n, n)),
_ => Err(m),
}
});
let data: Vec<[u8; 4]> = cx.read(&out, n);
let image = Rgba8 { width: w, height: h, data };
lap("finish + readback", &mut t, &mut cx);
let histogram = Histogram::of_srgb8(&image);
lap("histogram", &mut t, &mut cx);
let mut image = image;
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan);
Some(Rendered { image, histogram })
let overlay_mask = overlay_mask.map(|r| match r {
Ok(b) => cx.read_plane(&b, w, h),
// a hidden mask: evaluate it on the CPU
Err(m) => {
let img = host.lin.get_or_insert_with(|| Arc::new(cx.read_rgb(&lin, w, h))).clone();
let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone();
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32)
}
});
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
Some(Rendered { image, histogram, deep: None })
}
/// The white-balanced, retouched, denoised image.
@ -590,6 +627,8 @@ struct Prep {
clarity: Option<Arc<Buf>>,
texture: Option<Arc<Buf>>,
dark: Option<Arc<Buf>>,
/// Blurred chromaticity (local Moiré / Noise).
chroma: Option<Arc<Buf>>,
air: f32,
}
@ -644,14 +683,58 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
}
},
};
Prep { log_l, base, clarity, texture, dark, air }
let chroma = sig.chroma.map(|sg| {
plane_at(&mut planes.chroma, sg, || {
let ch = cx.gpu.buffer(n * 3);
map(cx, "chroma_k", n, &[], [Some(lin), None, None], &ch);
gaussian(cx, &ch, w, h, 3, sg)
})
});
Prep { log_l, base, clarity, texture, dark, chroma, air }
}
/// Most brush dabs the mask kernel evaluates per pixel (more: the CPU rasterizes the brush).
const MAX_DABS: usize = 4096;
/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated)
/// and their adjustment terms. Shapes without a kernel (Sky, Subject, …) run on the CPU.
/// The `shape` kernel's brush data for `dabs`: stroke records (12 words: dab offset, dab count,
/// r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto) then the dab centres.
fn brush_aux(dabs: &[&lightcraft_pipeline::masks::BrushDabs], erase: bool) -> Vec<f32> {
let mut aux = Vec::new();
let mut off = 12 * dabs.len();
for d in dabs {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for q in &d.dabs {
(x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r));
}
aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, (erase && d.erase) as u8 as f32]);
aux.extend([x0, y0, x1, y1].map(|v| v as f32));
aux.push(d.auto as u8 as f32);
off += 2 * d.dabs.len();
}
for d in dabs {
aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32]));
}
aux
}
/// Cross guided filter (`masks::guided_cross`) of `p` steered by `guide`, max-combined with `p`.
fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf {
let n = w * h;
let (m0, m1) = (cx.gpu.buffer(2 * n), cx.gpu.buffer(2 * n));
map(cx, "xguided_pre", n, &[0], [Some(guide), Some(p), None], &m0);
map(cx, "xguided_pre", n, &[1], [Some(guide), Some(p), None], &m1);
let (b0, b1) = (gaussian(cx, &m0, w, h, 2, sigma), gaussian(cx, &m1, w, h, 2, sigma));
let ab = cx.gpu.buffer(2 * n);
map(cx, "xguided_ab", n, &[eps.to_bits()], [Some(&b0), Some(&b1), None], &ab);
let ab = gaussian(cx, &ab, w, h, 2, sigma);
let q = cx.gpu.buffer(n);
map(cx, "xguided_apply", n, &[], [Some(guide), Some(&ab), Some(p)], &q);
q
}
/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated,
/// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment
/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU.
fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option<Buf>, Vec<[f32; MASK_TERMS]>) {
use lightcraft_develop::{MaskOp, MaskShape};
let s = &*plan.settings;
@ -667,7 +750,10 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
let [a, b, c, d, e, f] = frame.out_to_norm(w, h).0;
let (kx, ky) = (frame.ow / long, frame.oh / long);
let aff = [a * kx, b * ky, c * kx, d * ky, e * kx, f * ky].map(|v| (v as f32).to_bits());
let alpha = cx.zeroed(n * list.len());
let alpha = cx.zeroed(n * list.len() + if prep.chroma.is_some() { 3 * n } else { 0 });
if let Some(ch) = &prep.chroma {
cx.copy_into(ch, &alpha, n * list.len());
}
let comp_plane = cx.gpu.buffer(n);
for (mi, m) in list.iter().enumerate() {
let mut first = true;
@ -683,6 +769,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
p.extend_from_slice(&aff);
let f32s = |v: &[f64]| v.iter().map(|x| (*x as f32).to_bits()).collect::<Vec<u32>>();
let mut aux: Vec<f32> = Vec::new();
let mut own: Option<Buf> = None;
let kind = match &comp.shape {
MaskShape::Linear { start, end } => {
let (a, b) = (frame.norm_to_long(*start), frame.norm_to_long(*end));
@ -710,29 +797,46 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
}
MaskShape::Brush { strokes } => {
let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect();
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() <= MAX_DABS {
p.push(dabs.len() as u32);
let mut off = 11 * dabs.len();
for d in &dabs {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for q in &d.dabs {
(x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r));
}
aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, d.erase as u8 as f32]);
aux.extend([x0, y0, x1, y1].map(|v| v as f32));
off += 2 * d.dabs.len();
}
for d in &dabs {
aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32]));
}
Some(4)
} else {
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS {
None
} else if dabs.iter().any(|d| d.auto) {
// Auto Mask: stroke by stroke (each one refined before it's combined)
let brush = cx.zeroed(n);
let stroke = cx.gpu.buffer(n);
for d in &dabs {
let mut q = p.clone();
q[2] = 4;
q[3] = 0;
q.push(1);
let aux = cx.gpu.upload(&brush_aux(&[d], false));
cx.run("shape", &q, &[Some(lin), Some(&prep.log_l), Some(&aux), Some(&stroke), Some(&brush)], groups2(w, h, [16, 16]));
let refined = d.auto.then(|| {
let (sigma, eps) = lightcraft_pipeline::masks::auto_refine(d.r);
guided_cross_max(cx, &prep.log_l, &stroke, w, h, sigma, eps)
});
let op = if d.erase { 2 } else { 1 };
cx.run(
"combine",
&[n as u32, 0, 0, op],
&[None, None, None, Some(refined.as_ref().unwrap_or(&stroke)), Some(&brush)],
groups1(n),
);
}
if comp.invert {
cx.run("finalize", &[n as u32, 0, 0, 1, 1f32.to_bits()], &[None, None, None, Some(&stroke), Some(&brush)], groups1(n));
}
own = Some(brush);
None
} else {
p.push(dabs.len() as u32);
aux = brush_aux(&dabs.iter().collect::<Vec<_>>(), true);
Some(4)
}
}
_ => None,
};
let plane = match kind {
_ if own.is_some() => own,
Some(k) => {
p[2] = k;
let aux = (!aux.is_empty()).then(|| cx.gpu.upload(&aux));

View File

@ -1,5 +1,6 @@
// The per-pixel stage: a straight port of `lightcraft_pipeline::finish` (keep in step with it).
// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes), aux
// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes, then the
// blurred chromaticity when HAS_CHROMA), aux
// (tone LUT | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8).
fn tone_apply(y: f32) -> f32 {
@ -218,6 +219,26 @@ fn enc8(v: f32) -> u32 {
return u32(clamp(v, 0.0, 1.0) * 255.0 + 0.5);
}
// sRGB-curve-encoded value → the output space's own curve (`OutputTrc`: 0 sRGB, 1 gamma, 2 Rec.709).
fn out_encode(v: f32) -> f32 {
let kind = pu(F_OUT_TRC);
if (kind == 0u) {
return v;
}
let e = clamp(v, 0.0, 1.0);
var l = e / 12.92;
if (e > 0.04045) {
l = pow((e + 0.055) / 1.055, 2.4);
}
if (kind == 1u) {
return pow(l, 1.0 / pf(F_OUT_GAMMA));
}
if (l < 0.018) {
return l * 4.5;
}
return 1.099 * pow(l, 0.45) - 0.099;
}
@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let w = pu(F_W);
@ -239,7 +260,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let l0 = l_pre + ev;
// --- local (mask) contributions
var lt: array<f32, 14>;
var lt: array<f32, MASK_SUMS>;
var tint_on = false;
var tint_dir = vec2<f32>(0.0, 0.0);
var tint_amt = 0.0;
@ -250,18 +271,42 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
continue;
}
let t = pu(F_MASK_OFF) + m * MASK_TERMS;
for (var k = 0u; k < 14u; k++) {
for (var k = 0u; k < MASK_SUMS; k++) {
lt[k] += a * aux[t + k];
}
if (aux[t + 14u] > 0.0) {
if (aux[t + MASK_SUMS] > 0.0) {
tint_on = true;
tint_dir = vec2<f32>(aux[t + 15u], aux[t + 16u]);
tint_amt = a * aux[t + 17u];
tint_dir = vec2<f32>(aux[t + MASK_SUMS + 1u], aux[t + MASK_SUMS + 2u]);
tint_amt = a * aux[t + MASK_SUMS + 3u];
}
}
let l_exp = lt[0];
let l_temp = lt[1];
let l_tint = lt[2];
let l_noise = lt[14];
// --- local Moiré (and the colour part of Noise): chromaticity towards its blur
let mt = clamp(lt[15] + 0.5 * max(l_noise, 0.0), -1.0, 1.0);
if (mt != 0.0 && pu(F_HAS_CHROMA) != 0u) {
let raw = vec3<f32>(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]);
let y = lum2020(c);
let ch0 = raw / max(lum2020(raw), 1e-6);
let o = nm * n + 3u * i;
let cb = vec3<f32>(masks[o], masks[o + 1u], masks[o + 2u]);
c = max((ch0 + (cb - ch0) * mt) * y, vec3<f32>(0.0));
}
// --- local Defringe: desaturate purple / green fringes along edges
let df = clamp(lt[16], 0.0, 1.0);
if (df > 0.0 && pu(F_HAS_TEX) != 0u) {
let yd = max(lum2020(c), 1e-6);
let purple = (min(c.x, c.z) - c.y) / yd;
let green = (c.y - max(c.x, c.z)) / yd;
let k = df * sstep(0.04, 0.3, abs(l_pre - tex[i])) * max(sstep(0.02, 0.2, purple), sstep(0.02, 0.2, green));
if (k > 0.0) {
let y = lum2020(c);
c = c + (y - c) * k;
}
}
// --- dehaze (scene linear)
let dz = pf(F_DEHAZE) + lt[10];
@ -334,6 +379,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
delta += sp * 1.3 * clamp(det, -0.8, 0.8) * mk;
}
}
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
if (l_noise != 0.0 && pu(F_HAS_TEX) != 0u) {
let det = l_pre - tex[i];
delta -= clamp(l_noise, -1.0, 1.0) * 0.9 * det * (1.0 - sstep(0.1, 0.5, abs(det)));
}
if (delta != 0.0) {
c = c * exp2(delta);
}
@ -399,9 +449,14 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
// --- gamut map to sRGB (desaturate towards luminance until in range)
var r = mul3(TO_SRGB, d);
let yy = clamp(0.2126 * r.x + 0.7152 * r.y + 0.0722 * r.z, 0.0, 1.0);
// --- gamut map to the output space (desaturate towards luminance until in range)
let om = array<vec3<f32>, 3>(
vec3<f32>(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)),
vec3<f32>(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)),
vec3<f32>(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)),
);
var r = mul3(om, d);
let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0);
var tg = 1.0;
for (var k = 0u; k < 3u; k++) {
let cc = r[k];
@ -439,5 +494,6 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e + k;
}
e = vec3<f32>(out_encode(e.x), out_encode(e.y), out_encode(e.z));
out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u);
}

View File

@ -70,6 +70,51 @@ fn guided_apply(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgr
dst[i] = b[2u * i] * a[i] + b[2u * i + 1u];
}
// Cross guided filter (`masks::guided_cross`), a: guide, b: input. P[1] = 0: (guide, input);
// 1: (guide·input, guide²).
@compute @workgroup_size(256)
fn xguided_pre(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
let i = lin_index(g, nw);
if (i >= pu(0u)) {
return;
}
let gi = a[i];
let p = b[i];
if (pu(1u) == 0u) {
dst[2u * i] = gi;
dst[2u * i + 1u] = p;
} else {
dst[2u * i] = gi * p;
dst[2u * i + 1u] = gi * gi;
}
}
// Coefficients (a, b) from blurred means (a: guide, input) and correlations (b: guide·input,
// guide²). P[1] = eps.
@compute @workgroup_size(256)
fn xguided_ab(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
let i = lin_index(g, nw);
if (i >= pu(0u)) {
return;
}
let mi = a[2u * i];
let mp = a[2u * i + 1u];
let v = max(b[2u * i + 1u] - mi * mi, 0.0);
let k = (b[2u * i] - mi * mp) / (v + pf(1u));
dst[2u * i] = k;
dst[2u * i + 1u] = mp - k * mi;
}
// `max(p, clamp(a·guide + b, 0, 1))` (a: guide, b: blurred coefficients, c: the input p).
@compute @workgroup_size(256)
fn xguided_apply(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
let i = lin_index(g, nw);
if (i >= pu(0u)) {
return;
}
dst[i] = max(c[i], clamp(b[2u * i] * a[i] + b[2u * i + 1u], 0.0, 1.0));
}
// White balance: 3×3 matrix (P[1] = has matrix, P[2..11] row-major), clamped at 0.
@compute @workgroup_size(256)
fn wb_k(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {

View File

@ -13,6 +13,19 @@ fn pos(x: u32, y: u32) -> vec2<f32> {
// 2 luminance range: lo, hi, lo feather, hi feather, ev
// 3 colour range: tol, gain, sample count (OkLab triples in aux)
// 4 brush: stroke count (stroke records + dabs in aux)
// `masks::chromaticity` of pixel i.
fn chroma_at(i: u32) -> vec3<f32> {
let c = vec3<f32>(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]);
return c / max(lum2020(c), 1e-6);
}
// `masks::auto_similarity`.
fn auto_similarity(l: f32, ch: vec3<f32>, rl: f32, rch: vec3<f32>) -> f32 {
let dl = (l - rl) / AUTO_TOL_EV;
let dc = length(ch - rch) / AUTO_TOL_CHROMA;
return 1.0 - sstep(0.5, 1.0, sqrt(dl * dl + dc * dc));
}
@compute @workgroup_size(16, 16)
fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
let w = pu(0u);
@ -64,11 +77,14 @@ fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
}
v = 1.0 - sstep(tol * 0.5, tol, best);
} else if (kind == 4u) {
// stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1
// stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto
let px = f32(g.x) + 0.5;
let py = f32(g.y) + 0.5;
let lp = log_l[i];
let chp = chroma_at(i);
for (var k = 0u; k < pu(10u); k++) {
let o = 11u * k;
let o = 12u * k;
let auto_mask = aux[o + 11u] != 0.0;
if (px < aux[o + 7u] || py < aux[o + 8u] || px > aux[o + 9u] || py > aux[o + 10u]) {
continue;
}
@ -89,6 +105,12 @@ fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
if (dd > hard) {
a = 1.0 - sstep(hard, r, dd);
}
if (auto_mask) {
let rx = min(u32(max(floor(aux[off + 2u * j]), 0.0)), w - 1u);
let ry = min(u32(max(floor(aux[off + 2u * j + 1u]), 0.0)), h - 1u);
let ri = ry * w + rx;
a *= auto_similarity(lp, chp, log_l[ri], chroma_at(ri));
}
keep *= 1.0 - a * flow;
}
let sa = min(1.0 - keep, aux[o + 5u]);

View File

@ -275,6 +275,37 @@ fn cases() -> Vec<(&'static str, Edit)> {
},
];
}),
("masks (local noise, moiré, defringe)", |s| {
let radial = MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.3, ry: 0.25, angle: 0.0, feather: 50.0, invert: false };
let linear = MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.7) };
let m = |shape, adjust| Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() };
use lightcraft_develop::LocalAdjustments as L;
s.masks = vec![
m(radial, L { noise: 80.0, moire: 60.0, defringe: 100.0, ..Default::default() }),
m(linear, L { noise: -60.0, moire: -40.0, ..Default::default() }),
];
}),
("masks (auto mask brush)", |s| {
let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke {
points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(),
size: 0.06,
feather: 40.0,
flow: 80.0,
auto_mask,
erase,
..Default::default()
};
let strokes = vec![
st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false),
st(&[(0.3, 0.2), (0.6, 0.25)], false, false),
st(&[(0.5, 0.58)], true, true),
];
s.masks = vec![Mask {
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }],
adjust: lightcraft_develop::LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() },
..Default::default()
}];
}),
("calibration", |s| {
s.calibration.shadows_tint = 40.0;
s.calibration.red_hue = 50.0;
@ -414,6 +445,43 @@ fn overlays_match() {
eprintln!("visualize spots t={t:<3} white {:.3}% differing {:.4}%", white * 100.0, differ * 100.0);
assert!(differ < 0.002, "spots t={t}: {differ}");
}
// Mask overlays: the alpha the GPU evaluated (or, for a hidden mask, the CPU's) drawn the same way.
let mut s = DevelopSettings::default();
let stroke = BrushStroke { points: vec![Point::new(0.2, 0.3), Point::new(0.6, 0.5)], size: 0.05, ..Default::default() };
s.masks = vec![
Mask {
id: 1,
components: vec![MaskComponent {
op: MaskOp::Add,
invert: false,
shape: MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.25, ry: 0.15, angle: 20.0, feather: 60.0, invert: false },
}],
..Default::default()
},
Mask {
id: 2,
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }],
..Default::default()
},
Mask {
id: 3,
visible: false,
components: vec![MaskComponent {
op: MaskOp::Add,
invert: false,
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) },
}],
..Default::default()
},
];
s.masks[0].adjust.exposure = 0.5;
use lightcraft_pipeline::{MaskView, Overlay};
for (id, view) in
[(1, MaskView::Color), (2, MaskView::ColorOnBw), (2, MaskView::WhiteOnBlack), (3, MaskView::ImageOnWhite), (1, MaskView::ImageOnBlack)]
{
let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) };
check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req);
}
}
#[test]
@ -462,3 +530,32 @@ fn red_eye_matches() {
};
check("red eye + spot (cpu stage)", &src, &info, &s, &RenderRequest::fit(600, 600));
}
fn saturated(s: &mut DevelopSettings) {
typical(s);
s.color.saturation = 70.0;
s.color.vibrance = 40.0;
s.curve.highlights = 30.0;
s.curve.shadows = -20.0;
s.grain.amount = 30.0;
}
#[test]
fn output_spaces_match() {
if !gpu() {
return;
}
// Export colour spaces: matrix to the target primaries, gamut mapping into the target gamut and
// its encoding curve, on both renderers. Saturated edits push colours to the gamut edges.
use lightcraft_pipeline::OutputSpace;
let src = scene(0, 900, 600);
let raw = SourceInfo { raw: true, ..Default::default() };
for space in OutputSpace::ALL {
for (name, edit) in [("typical", typical as Edit), ("saturated + curves + grain", saturated as Edit)] {
let mut s = DevelopSettings::default();
edit(&mut s);
let req = RenderRequest { space, ..RenderRequest::fit(640, 640) };
check(&format!("{space:?} {name}"), &src, &raw, &s, &req);
}
}
}

View File

@ -188,6 +188,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"naming": {"type": "string", "description": "e.g. {name}-{seq}"},
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
"removeLocation": {"type": "boolean"},
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"},
"watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"}
}),
&[],
@ -534,6 +536,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"metadata",
"removeLocation",
"watermark",
"colorSpace",
"bitDepth",
],
),
)

View File

@ -20,3 +20,4 @@ serde = { workspace = true }
lightcraft-scenes = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
serde_json = { workspace = true }

View File

@ -1,8 +1,8 @@
//! The per-pixel stage: everything after the spatial planes are ready, in one parallel pass.
use lightcraft_color::luminance_2020;
use lightcraft_color::spline::{Lut1, MonotoneCurve};
use lightcraft_color::transfer::linear_to_srgb;
use lightcraft_color::{REC2020, SRGB, luminance_2020};
use lightcraft_develop::{DevelopSettings, LocalAdjustments, ToneCurve, VignetteStyle};
use lightcraft_geom::Point;
use lightcraft_raster::Rgba8;
@ -10,6 +10,7 @@ use lightcraft_raster::Rgba8;
use crate::colorops::ColorOps;
use crate::geometry::Frame;
use crate::local::log_lum;
use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc};
use crate::tone::ToneMap;
use crate::{Prepared, SourceInfo, for_rows};
@ -105,12 +106,15 @@ fn grain_noise(x: f32, y: f32, seed: u32) -> f32 {
}
/// Number of per-mask terms in [`mask_terms`].
pub const MASK_TERMS: usize = 18;
pub const MASK_TERMS: usize = 21;
/// Number of alpha-weighted terms at the start of [`mask_terms`].
pub const MASK_SUMS: usize = 17;
/// A mask's local adjustments as the per-pixel stage uses them (alpha-weighted sums), in order:
/// exposure, temp, tint, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze,
/// saturation, hue, sharpness, then the colour overlay: on (0/1), cos and sin of its OkLCh hue, and
/// its strength.
/// saturation, hue, sharpness, noise, moiré, defringe, then the colour overlay: on (0/1), cos and
/// sin of its OkLCh hue, and its strength.
pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
let (on, cos, sin, amt) = if j.color_sat > 0.0 {
let hue = crate::colorops::oklch_hue_of_srgb_hue(j.color_hue);
@ -133,6 +137,9 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
(j.saturation / 100.0) as f32,
(j.hue / 100.0) as f32 * 0.6,
(j.sharpness / 100.0) as f32,
(j.noise / 100.0) as f32,
(j.moire / 100.0) as f32,
(j.defringe / 100.0) as f32,
on,
cos,
sin,
@ -153,7 +160,11 @@ pub struct FinishParams {
/// Refine Saturation as 0..1 (1 = the curves' own saturation).
pub refine_sat: f32,
pub vig: Option<Vig>,
pub to_srgb: [[f32; 3]; 3],
/// Linear Rec.2020 → linear output RGB, the output's luminance weights (gamut mapping) and its
/// encoding curve (see [`crate::output`]).
pub to_out: [[f32; 3]; 3],
pub out_luma: [f32; 3],
pub out_trc: OutputTrc,
pub hl: f32,
pub sh: f32,
pub clar: f32,
@ -178,8 +189,18 @@ pub struct FinishParams {
}
impl FinishParams {
/// Parameters for a `w × h` render; `ev`/`air_pre` as in [`crate::Prepared`].
pub fn new(s: &DevelopSettings, frame: &Frame, info: &SourceInfo, w: usize, h: usize, px_per_long: f64, air_pre: f32) -> FinishParams {
/// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`].
#[allow(clippy::too_many_arguments)]
pub fn new(
s: &DevelopSettings,
frame: &Frame,
info: &SourceInfo,
w: usize,
h: usize,
px_per_long: f64,
air_pre: f32,
space: OutputSpace,
) -> FinishParams {
let effects = s.section_enabled("effects");
let (clar, tex, dehaze) = if effects {
((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32)
@ -205,7 +226,9 @@ impl FinishParams {
curves: curve_luts(&s.curve),
refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32,
vig: if effects { vignette(s) } else { None },
to_srgb: REC2020.to_space(&SRGB).to_f32(),
to_out: space.from_working(),
out_luma: space.luma(),
out_trc: space.trc(),
hl: (s.light.highlights / 100.0) as f32,
sh: (s.light.shadows / 100.0) as f32,
clar,
@ -228,10 +251,74 @@ impl FinishParams {
}
}
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo) -> Rgba8 {
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace) -> Rgba8 {
let (w, h) = (p.img.width, p.img.height);
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air);
let FinishParams { tone, ops, curves, vig, to_srgb, hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev, grain, .. } = &fp;
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
let trc = fp.out_trc;
let data = finish_with(p, &fp, false, |e| match trc {
OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255],
t => {
let x = e.map(|v| enc(t.encode(lightcraft_color::transfer::srgb_to_linear(v.clamp(0.0, 1.0)))));
[x[0], x[1], x[2], 255]
}
});
Rgba8 { width: w, height: h, data }
}
/// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries).
pub(crate) fn finish_deep(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, depth: OutputDepth) -> DeepImage {
use lightcraft_color::transfer::srgb_to_linear;
let (w, h) = (p.img.width, p.img.height);
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
let trc = fp.out_trc;
let samples = match depth {
OutputDepth::F32Linear => {
let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.clamp(0.0, 1.0))));
DeepSamples::F32(v.into_flattened())
}
_ => {
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
let v = finish_with(p, &fp, true, |e| match trc {
OutputTrc::Srgb => e.map(q),
t => e.map(|v| q(t.encode(srgb_to_linear(v.clamp(0.0, 1.0))))),
});
DeepSamples::U16(v.into_flattened())
}
};
DeepImage { width: w, height: h, space, samples }
}
/// The per-pixel stage: every output pixel's colour in the output primaries, encoded with the sRGB
/// curve (tone curves and grain applied; not clamped), handed to `store` for the final encoding.
/// `exact`: encode with the exact sRGB curve instead of the (8/10-bit accurate) table.
pub(crate) fn finish_with<T: Copy + Default + Send>(
p: &Prepared,
fp: &FinishParams,
exact: bool,
store: impl Fn([f32; 3]) -> T + Sync + Send,
) -> Vec<T> {
let (w, h) = (p.img.width, p.img.height);
let FinishParams {
tone,
ops,
curves,
vig,
to_out,
out_luma,
hl,
sh,
clar,
tex,
dehaze,
sharpen,
sharpen_mask,
air,
air_pre,
gain,
ev,
grain,
..
} = fp;
let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) =
(*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev);
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
@ -240,8 +327,8 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
let aspect = w as f32 / h as f32;
let srgb = srgb_lut();
let mut out = Rgba8::new(w, h);
for_rows(&mut out.data, w, |y, row| {
let mut out = vec![T::default(); w * h];
for_rows(&mut out, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let i = y * w + x;
let raw = p.img.data[i];
@ -250,21 +337,42 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
let l0 = l_pre + ev;
// --- local (mask) contributions: alpha-weighted sums of the masks' terms
let mut l = [0.0f32; 14];
let mut l = [0.0f32; MASK_SUMS];
let mut tint_col: Option<([f32; 3], f32)> = None;
for (m, t) in p.masks.iter().zip(&terms) {
let a = m.alpha.data[i];
if a <= 0.0 {
continue;
}
for k in 0..14 {
for k in 0..MASK_SUMS {
l[k] += a * t[k];
}
if t[14] > 0.0 {
tint_col = Some(([t[15], t[16], 0.0], a * t[17]));
if t[MASK_SUMS] > 0.0 {
tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3]));
}
}
let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] =
l;
// --- local Moiré (and the colour part of Noise): chromaticity towards its blur
let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0);
if mt != 0.0
&& let Some(cb) = &p.chroma_blur
{
let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]);
c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0));
}
// --- local Defringe: desaturate purple / green fringes along edges
let df = l_defringe.clamp(0.0, 1.0);
if df > 0.0
&& let Some(b) = &p.texture_blur
{
let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]);
if k > 0.0 {
let y = luminance_2020(c);
c = c.map(|v| v + (y - v) * k);
}
}
let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp] = l;
// --- dehaze (scene linear)
let dz = dehaze + l_dehaze;
@ -334,6 +442,13 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m;
}
}
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
if l_noise != 0.0
&& let Some(b) = &p.texture_blur
{
let det = l_pre - b.data[i];
delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs()));
}
if delta != 0.0 {
let g = delta.exp2();
c = c.map(|v| v * g);
@ -388,14 +503,14 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
}
}
// --- gamut map to sRGB (desaturate towards luminance until in range)
let m = to_srgb;
// --- gamut map to the output space (desaturate towards luminance until in range)
let m = to_out;
let mut r = [
m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2],
m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2],
m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2],
];
let yy = (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]).clamp(0.0, 1.0);
let yy = (out_luma[0] * r[0] + out_luma[1] * r[1] + out_luma[2] * r[2]).clamp(0.0, 1.0);
let mut t = 1.0f32;
for c in r {
if c < 0.0 {
@ -409,7 +524,7 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
}
// --- encode, curves, grain
let mut e = r.map(|v| encode_srgb(srgb, v));
let mut e = if exact { r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) } else { r.map(|v| encode_srgb(srgb, v)) };
if let Some(l) = curves {
let e0 = e;
e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])];
@ -427,12 +542,22 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e.map(|v| v + k);
}
*px = [enc(e[0]), enc(e[1]), enc(e[2]), 255];
*px = store(e);
}
});
out
}
/// Local Defringe weight of a scene-linear colour `c` whose log luminance differs by `det` from
/// its fine blur: high on strong edges with a purple or green cast.
#[inline]
pub fn defringe_weight(c: [f32; 3], det: f32) -> f32 {
let y = luminance_2020(c).max(1e-6);
let purple = (c[0].min(c[2]) - c[1]) / y;
let green = (c[1] - c[0].max(c[2])) / y;
smooth(0.04, 0.3, det.abs()) * smooth(0.02, 0.2, purple).max(smooth(0.02, 0.2, green))
}
/// Refine Saturation: scale the curved colour's chroma (around its luma, encoded values) so its
/// saturation (chroma / luma) moves from the curve's towards the pre-curve one: the ratio is
/// `(s_curve / s_before)^refine`, so 1 keeps the curve, 0 restores the original saturation.

View File

@ -11,7 +11,7 @@
//! clarity, local adjustments (masks)
//! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation
//! 4. colour — vibrance, saturation, colour mixer, colour grading, B&W (OkLCh)
//! 5. display — gamut map to sRGB, encode, tone curves (parametric + point), vignette, grain
//! 5. display — gamut map to the output space (sRGB unless [`RenderRequest::space`] says otherwise), encode, tone curves (parametric + point), vignette, grain
//!
//! Spatial parameters are specified relative to the image's long edge, so a 400 px preview and a
//! 60 MP export look alike.
@ -29,6 +29,7 @@ pub mod geometry;
pub mod local;
pub mod masks;
pub mod optics;
pub mod output;
pub mod profiles;
pub mod redeye;
pub mod spots;
@ -37,7 +38,8 @@ pub mod transform;
pub mod upright;
pub mod visualize;
pub use visualize::Overlay;
pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc};
pub use visualize::{MaskView, Overlay};
use lightcraft_develop::{DevelopSettings, Treatment};
use std::any::Any;
@ -84,17 +86,24 @@ pub struct RenderRequest {
pub apply_crop: bool,
/// A diagnostic view drawn over the result (e.g. Point Color's "visualize range").
pub overlay: Overlay,
/// Colour space of the result (exports; previews stay sRGB).
pub space: OutputSpace,
/// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports).
pub depth: OutputDepth,
}
impl RenderRequest {
pub fn fit(max_w: usize, max_h: usize) -> Self {
Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None }
Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb, depth: OutputDepth::U8 }
}
}
pub struct Rendered {
/// The result, 8-bit (for deep renders: `deep` reduced to 8 bits, display-encoded).
pub image: Rgba8,
pub histogram: Histogram,
/// The high-bit-depth result when [`RenderRequest::depth`] asks for one.
pub deep: Option<DeepImage>,
}
/// Everything the per-pixel stage needs, precomputed at output resolution.
@ -110,6 +119,8 @@ pub(crate) struct Prepared {
pub clarity_blur: Option<Arc<Plane>>,
pub texture_blur: Option<Arc<Plane>>,
pub dark: Option<Arc<Plane>>,
/// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise.
pub chroma_blur: Option<Arc<Rgb32f>>,
/// Airlight of `dark` (before exposure).
pub air: f32,
pub masks: Vec<masks::Evaluated>,
@ -377,13 +388,32 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
if let Some((a, c)) = shared {
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
}
let image = finish::finish(&prep, s, frame, info);
if req.depth != OutputDepth::U8 {
let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth);
let image = deep.to_rgba8();
let histogram = Histogram::of_srgb8(&image);
lap("finish (deep)", &mut t);
return Rendered { image, histogram, deep: Some(deep) };
}
let image = finish::finish(&prep, s, frame, info, req.space);
lap("finish", &mut t);
let histogram = Histogram::of_srgb8(&image);
lap("histogram", &mut t);
let mut image = image;
visualize::apply(&mut image, req.overlay, &plan);
Rendered { image, histogram }
let mask = overlay_alpha(req.overlay, &plan, &prep);
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref());
Rendered { image, histogram, deep: None }
}
/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a
/// fresh evaluation.
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
let m = o.mask(&plan.settings)?;
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
return Some(e.alpha.clone());
}
let ev = plan.settings.light.exposure as f32;
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev))
}
/// Convenience: render a before/after pair side by side is up to the UI; this renders "before"
@ -423,3 +453,5 @@ pub(crate) fn for_rows<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut
mod tests;
#[cfg(test)]
mod tests_geometry;
#[cfg(test)]
mod tests_local;

View File

@ -208,6 +208,8 @@ pub(crate) struct Planes {
pub texture: Option<(u32, Arc<Plane>)>,
/// Dark channel and its airlight.
pub dark: Option<(u32, Arc<Plane>, f32)>,
/// Blurred chromaticity (local Moiré / Noise).
pub chroma: Option<(u32, Arc<Rgb32f>)>,
}
/// Reuse `slot` if it was computed at `sigma`, else compute and store it.
@ -232,6 +234,9 @@ pub fn dark_of(c: [f32; 3]) -> f32 {
c[0].min(c[1]).min(c[2])
}
/// Radius of the local Moiré / colour-noise chromaticity blur, as a fraction of the long edge.
pub const CHROMA_SIGMA: f32 = 0.004;
/// Radii (px) of the spatial planes the settings need (`None` = not needed).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlaneSigmas {
@ -243,6 +248,8 @@ pub struct PlaneSigmas {
pub texture: Option<f32>,
/// Dehaze dark channel (Gaussian).
pub dark: Option<f32>,
/// Chromaticity blur for local Moiré / colour noise (Gaussian).
pub chroma: Option<f32>,
}
pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas {
@ -256,10 +263,17 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
if q == Quality::Draft { sigma.min(24.0) } else { sigma }
});
let clarity = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| (0.012 * ppl).max(1.0));
let texture = (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness))
.then(|| (0.0018 * ppl).max(0.6));
// local Noise and Defringe read the fine detail band too
let texture = (s.effects.texture != 0.0
|| s.detail.sharpen_amount != 0.0
|| local_any(|a| a.texture)
|| local_any(|a| a.sharpness)
|| local_any(|a| a.noise)
|| s.masks.iter().any(|m| m.adjust.defringe > 0.0))
.then(|| (0.0018 * ppl).max(0.6));
let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0));
PlaneSigmas { base, clarity, texture, dark }
let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0));
PlaneSigmas { base, clarity, texture, dark, chroma }
}
/// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure),
@ -267,9 +281,18 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
/// one alone scales poorly: the guided filters work on small subsampled grids).
pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality, planes: &mut Planes) -> Prepared {
let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone();
let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma } = plane_sigmas(s, px_per_long, q);
let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } =
plane_sigmas(s, px_per_long, q);
let Planes { base: sb, clarity: sc, texture: st, dark: sd, .. } = planes;
let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes;
let chroma_blur = chroma_sigma.map(|sg| match sch {
Some((k, c)) if *k == sg.to_bits() => c.clone(),
_ => {
let c = timed("chroma", || Arc::new(gaussian(&img.map(masks::chromaticity), sg)));
*sch = Some((sg.to_bits(), c.clone()));
c
}
});
let l = &log_l;
let (base, (clarity_blur, (texture_blur, dark))) = par_join(
|| base_sigma.map(|sg| plane_at(sb, sg, || timed("base", || guided_fast(l, sg, BASE_EPS)))),
@ -302,7 +325,7 @@ pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_p
};
let ev = s.light.exposure as f32;
let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev));
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, air, masks, px_per_long }
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long }
}
/// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel.

View File

@ -11,6 +11,8 @@ use crate::for_rows;
use crate::geometry::Frame;
pub struct Evaluated {
/// The mask's id ([`Mask::id`]).
pub id: u32,
pub alpha: Plane,
pub adjust: LocalAdjustments,
}
@ -21,44 +23,49 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
/// The visible masks with components, evaluated in order.
pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Vec<Evaluated> {
masks
.iter()
.filter(|m| m.visible && !m.components.is_empty())
.map(|m| {
let mut alpha = Plane::new(w, h);
let mut first = true;
for comp in &m.components {
let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev);
if comp.invert {
c.data.iter_mut().for_each(|v| *v = 1.0 - *v);
}
if first && comp.op != MaskOp::Intersect {
alpha = c;
first = false;
continue;
}
for (a, c) in alpha.data.iter_mut().zip(&c.data) {
*a = match comp.op {
MaskOp::Add => a.max(*c),
MaskOp::Subtract => *a * (1.0 - c),
MaskOp::Intersect => *a * c,
};
}
first = false;
}
if m.invert {
alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v);
}
let amt = (m.adjust.amount / 100.0) as f32;
if (amt - 1.0).abs() > 1e-6 {
alpha.data.iter_mut().for_each(|v| *v *= amt);
}
Evaluated { alpha, adjust: m.adjust }
})
.map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev), adjust: m.adjust })
.collect()
}
/// The alpha plane of mask `m` (whether visible or not): its components combined, inverted and
/// scaled by its amount.
pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Plane {
let mut alpha = Plane::new(w, h);
let mut first = true;
for comp in &m.components {
let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev);
if comp.invert {
c.data.iter_mut().for_each(|v| *v = 1.0 - *v);
}
if first && comp.op != MaskOp::Intersect {
alpha = c;
first = false;
continue;
}
for (a, c) in alpha.data.iter_mut().zip(&c.data) {
*a = match comp.op {
MaskOp::Add => a.max(*c),
MaskOp::Subtract => *a * (1.0 - c),
MaskOp::Intersect => *a * c,
};
}
first = false;
}
if m.invert {
alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v);
}
let amt = (m.adjust.amount / 100.0) as f32;
if (amt - 1.0).abs() > 1e-6 {
alpha.data.iter_mut().for_each(|v| *v *= amt);
}
alpha
}
/// Per-pixel positions in long-edge units for output pixel centres.
fn for_each_pos(frame: &Frame, w: usize, h: usize, out: &mut Plane, f: impl Fn(Point, usize) -> f32 + Sync + Send) {
let m = frame.out_to_norm(w, h);
@ -100,7 +107,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
if inv { 1.0 - a } else { a }
});
}
MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, &mut out),
MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, img, log_l, &mut out),
MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => {
let (lo, hi, lf, hf) = (*lo as f32, *hi as f32, (*lo_feather as f32).max(1e-3), (*hi_feather as f32).max(1e-3));
for (v, l) in out.data.iter_mut().zip(&log_l.data) {
@ -181,6 +188,8 @@ pub struct BrushDabs {
pub flow: f32,
pub density: f32,
pub erase: bool,
/// Auto Mask: paint only pixels like the one under each dab, then snap to edges.
pub auto: bool,
}
/// The dabs of stroke `s` in a `w × h` output.
@ -203,15 +212,90 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa
}
dabs.push(q);
}
BrushDabs { dabs, r, hard, flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32, density: (s.density / 100.0).clamp(0.0, 1.0) as f32, erase: s.erase }
BrushDabs {
dabs,
r,
hard,
flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32,
density: (s.density / 100.0).clamp(0.0, 1.0) as f32,
erase: s.erase,
auto: s.auto_mask,
}
}
/// Stamp brush strokes into `out` (max-combine; erase strokes subtract).
fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, out: &mut Plane) {
/// Auto Mask tolerances: a pixel takes a dab's paint fully up to half of these from the colour
/// under the dab centre, none beyond them (log2 luminance, chromaticity `rgb / Y`).
pub const AUTO_TOL_EV: f32 = 0.5;
pub const AUTO_TOL_CHROMA: f32 = 0.25;
/// Chromaticity `rgb / Y` (as colour noise reduction uses it).
#[inline]
pub fn chromaticity(c: [f32; 3]) -> [f32; 3] {
let y = lightcraft_color::luminance_2020(c).max(1e-6);
[c[0] / y, c[1] / y, c[2] / y]
}
/// Auto Mask: how much a pixel (log luminance `l`, chromaticity `ch`) is like the reference.
#[inline]
pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 {
let dl = (l - rl) / AUTO_TOL_EV;
let dc = ((ch[0] - rch[0]).powi(2) + (ch[1] - rch[1]).powi(2) + (ch[2] - rch[2]).powi(2)).sqrt() / AUTO_TOL_CHROMA;
1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt())
}
/// The output pixel a dab at `d` samples its reference colour from.
#[inline]
pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize {
let x = (d.x.floor().max(0.0) as usize).min(w - 1);
let y = (d.y.floor().max(0.0) as usize).min(h - 1);
y * w + x
}
/// Guided-filter refinement of an Auto Mask stroke of radius `r` px: (sigma px, epsilon EV²). The
/// stroke keeps the larger of its own and the refined alpha.
pub fn auto_refine(r: f64) -> (f32, f32) {
((0.25 * r).max(1.0) as f32, 0.02)
}
/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the
/// guide's.
pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane {
use lightcraft_raster::blur::gaussian;
let mi = gaussian(guide, sigma);
let mp = gaussian(p, sigma);
let cip = gaussian(&guide.zip_map(p, |a, b| a * b), sigma);
let cii = gaussian(&guide.map(|a| a * a), sigma);
let mut a = Plane::new(p.width, p.height);
let mut b = Plane::new(p.width, p.height);
for i in 0..p.data.len() {
let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0);
let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps);
a.data[i] = k;
b.data[i] = mp.data[i] - k * mi.data[i];
}
let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma));
let mut q = Plane::new(p.width, p.height);
for i in 0..q.data.len() {
q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0);
}
q
}
/// Stamp brush strokes into `out` (max-combine; erase strokes subtract). Auto Mask strokes weight
/// each dab by [`auto_similarity`] to the pixel under its centre and are refined with
/// [`guided_cross`] on log luminance.
fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, out: &mut Plane) {
if w == 0 || h == 0 {
return;
}
for s in strokes {
let BrushDabs { dabs, r, hard, flow, density: dens, erase } = brush_dabs(s, frame, w, h);
let BrushDabs { dabs, r, hard, flow, density: dens, erase, auto } = brush_dabs(s, frame, w, h);
let mut stroke_alpha = Plane::new(w, h);
for d in &dabs {
let refc = auto.then(|| {
let j = dab_pixel(*d, w, h);
(log_l.data[j], chromaticity(img.data[j]))
});
let (x0, x1) = (((d.x - r).floor().max(0.0)) as usize, ((d.x + r).ceil().max(0.0) as usize).min(w));
let (y0, y1) = (((d.y - r).floor().max(0.0)) as usize, ((d.y + r).ceil().max(0.0) as usize).min(h));
for y in y0..y1 {
@ -220,13 +304,23 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, o
if dd > r {
continue;
}
let a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) };
let v = &mut stroke_alpha.data[y * w + x];
let mut a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) };
let i = y * w + x;
if let Some((rl, rch)) = refc {
a *= auto_similarity(log_l.data[i], chromaticity(img.data[i]), rl, rch);
}
let v = &mut stroke_alpha.data[i];
// flow accumulates within a stroke up to density
*v = (*v + a * flow * (1.0 - *v)).min(dens);
}
}
}
if auto {
let (sigma, eps) = auto_refine(r);
let q = guided_cross(log_l, &stroke_alpha, sigma, eps);
// the refinement fills gaps along edges; it never takes paint away
stroke_alpha = stroke_alpha.zip_map(&q, f32::max);
}
for (o, a) in out.data.iter_mut().zip(&stroke_alpha.data) {
*o = if erase { *o * (1.0 - a) } else { o.max(*a) };
}
@ -282,4 +376,35 @@ mod tests {
assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50));
assert!(a.get(40, 5) < 0.01);
}
#[test]
fn auto_mask_stops_at_edges() {
// dark left half, bright right half; a stroke along the boundary, centred on the dark side
let (w, h) = (200, 100);
let f = frame(w, h);
let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] });
let l = img.map(crate::local::log_lum);
let stroke = |auto_mask| BrushStroke {
points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)],
size: 0.08,
feather: 0.0,
auto_mask,
..Default::default()
};
let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] };
let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0);
let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0);
// without Auto Mask the brush spills over the edge; with it, it stays on the dark side
assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50));
assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50));
assert!(auto.get(110, 50) < 0.02);
assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50));
assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50));
// on a flat area Auto Mask paints like the plain brush
let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]);
let fl = flat.map(crate::local::log_lum);
let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0);
let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0);
assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50));
}
}

View File

@ -0,0 +1,235 @@
//! Output encoding: the colour space (primaries + transfer curve) and sample format a render is
//! delivered in. Previews are always 8-bit sRGB; exports may ask for a wide-gamut space and/or
//! 16-bit or linear float samples.
//!
//! The per-pixel stage works in scene-linear Rec.2020 and ends by converting to the target's
//! primaries, gamut mapping into the *target* gamut, and encoding. Tone curves and grain operate on
//! sRGB-curve-encoded values of the target primaries (identical to the sRGB path when the target is
//! sRGB); afterwards the values are re-encoded with the target's own curve.
use lightcraft_color::{ADOBE_RGB, DISPLAY_P3, PROPHOTO, REC2020, RgbSpace, SRGB};
use serde::{Deserialize, Serialize};
/// The RGB space an image is rendered into.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum OutputSpace {
#[default]
Srgb,
DisplayP3,
/// Primaries and gamma of the published Adobe RGB (1998) specification ("compatible": our own
/// profile, built from the published numbers).
AdobeRgb,
ProPhoto,
Rec2020,
}
/// The encoding curve of an [`OutputSpace`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OutputTrc {
/// IEC 61966-2-1 (sRGB, Display P3).
Srgb,
/// Pure power law: encoded = linear^(1/g).
Gamma(f32),
/// ITU-R BT.709 / BT.2020 OETF.
Rec709,
}
impl OutputTrc {
/// Linear → encoded (input clamped to 0..1).
pub fn encode(self, v: f32) -> f32 {
let v = v.clamp(0.0, 1.0);
match self {
OutputTrc::Srgb => lightcraft_color::transfer::linear_to_srgb(v),
OutputTrc::Gamma(g) => v.powf(1.0 / g),
OutputTrc::Rec709 => {
if v < 0.018 {
v * 4.5
} else {
1.099 * v.powf(0.45) - 0.099
}
}
}
}
/// Encoded → linear (input clamped to 0..1).
pub fn decode(self, e: f32) -> f32 {
let e = e.clamp(0.0, 1.0);
match self {
OutputTrc::Srgb => lightcraft_color::transfer::srgb_to_linear(e),
OutputTrc::Gamma(g) => e.powf(g),
OutputTrc::Rec709 => {
if e < 0.081 {
e / 4.5
} else {
((e + 0.099) / 1.099).powf(1.0 / 0.45)
}
}
}
}
/// Code for the GPU kernel: 0 sRGB, 1 gamma, 2 Rec.709.
pub fn code(self) -> (u32, f32) {
match self {
OutputTrc::Srgb => (0, 1.0),
OutputTrc::Gamma(g) => (1, g),
OutputTrc::Rec709 => (2, 1.0),
}
}
}
impl OutputSpace {
pub const ALL: [OutputSpace; 5] = [OutputSpace::Srgb, OutputSpace::DisplayP3, OutputSpace::AdobeRgb, OutputSpace::ProPhoto, OutputSpace::Rec2020];
pub fn rgb_space(self) -> RgbSpace {
match self {
OutputSpace::Srgb => SRGB,
OutputSpace::DisplayP3 => DISPLAY_P3,
OutputSpace::AdobeRgb => ADOBE_RGB,
OutputSpace::ProPhoto => PROPHOTO,
OutputSpace::Rec2020 => REC2020,
}
}
pub fn trc(self) -> OutputTrc {
match self {
OutputSpace::Srgb | OutputSpace::DisplayP3 => OutputTrc::Srgb,
OutputSpace::AdobeRgb => OutputTrc::Gamma(563.0 / 256.0),
OutputSpace::ProPhoto => OutputTrc::Gamma(1.8),
OutputSpace::Rec2020 => OutputTrc::Rec709,
}
}
/// User-facing name.
pub fn label(self) -> &'static str {
match self {
OutputSpace::Srgb => "sRGB",
OutputSpace::DisplayP3 => "Display P3",
OutputSpace::AdobeRgb => "Adobe RGB (1998) compatible",
OutputSpace::ProPhoto => "ProPhoto RGB",
OutputSpace::Rec2020 => "Rec. 2020",
}
}
pub fn parse(s: &str) -> Option<OutputSpace> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
Some(match k.as_str() {
"srgb" => OutputSpace::Srgb,
"displayp3" | "p3" => OutputSpace::DisplayP3,
"adobergb" | "adobergb1998" | "adobergb1998compatible" | "widegamutrgb" => OutputSpace::AdobeRgb,
"prophoto" | "prophotorgb" | "rommrgb" => OutputSpace::ProPhoto,
"rec2020" | "bt2020" | "itur2020" => OutputSpace::Rec2020,
_ => return None,
})
}
/// Linear Rec.2020 → linear target RGB (Bradford-adapted for D50 targets), row-major.
pub fn from_working(self) -> [[f32; 3]; 3] {
REC2020.to_space(&self.rgb_space()).to_f32()
}
/// Luminance weights of the target's linear RGB, as the gamut mapper uses them. sRGB keeps the
/// rounded BT.709 weights the pipeline has always used (byte-identical previews and exports).
pub fn luma(self) -> [f32; 3] {
match self {
OutputSpace::Srgb => [0.2126, 0.7152, 0.0722],
o => o.rgb_space().luma().map(|v| v as f32),
}
}
}
/// Sample format of a render.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum OutputDepth {
/// 8-bit display-encoded ([`crate::Rendered::image`] only).
#[default]
U8,
/// 16-bit display-encoded RGB in [`crate::Rendered::deep`].
U16,
/// 32-bit float *linear* RGB (target primaries, 0..1) in [`crate::Rendered::deep`].
F32Linear,
}
/// High-bit-depth RGB samples (3 per pixel, interleaved, row-major).
#[derive(Clone, Debug, PartialEq)]
pub enum DeepSamples {
U16(Vec<u16>),
F32(Vec<f32>),
}
/// A high-bit-depth render ([`OutputDepth::U16`] / [`OutputDepth::F32Linear`]).
#[derive(Clone, Debug, PartialEq)]
pub struct DeepImage {
pub width: usize,
pub height: usize,
pub space: OutputSpace,
pub samples: DeepSamples,
}
impl DeepImage {
/// The same image reduced to 8 bits, display-encoded with the space's curve.
pub fn to_rgba8(&self) -> lightcraft_raster::Rgba8 {
let mut out = lightcraft_raster::Rgba8::new(self.width, self.height);
let trc = self.space.trc();
match &self.samples {
DeepSamples::U16(v) => {
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
*p = [c[0], c[1], c[2]].map(|x| ((x as u32 * 255 + 32767) / 65535) as u8).into_rgba();
}
}
DeepSamples::F32(v) => {
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
*p = [c[0], c[1], c[2]].map(|x| (trc.encode(x) * 255.0 + 0.5) as u8).into_rgba();
}
}
}
out
}
}
trait IntoRgba {
fn into_rgba(self) -> [u8; 4];
}
impl IntoRgba for [u8; 3] {
fn into_rgba(self) -> [u8; 4] {
[self[0], self[1], self[2], 255]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trcs_invert() {
for o in OutputSpace::ALL {
let t = o.trc();
for i in 0..=100 {
let x = i as f32 / 100.0;
assert!((t.decode(t.encode(x)) - x).abs() < 1e-5, "{o:?} {x}");
}
}
}
#[test]
fn parse_and_serde_names() {
for o in OutputSpace::ALL {
let s = serde_json::to_value(o).unwrap();
assert_eq!(OutputSpace::parse(s.as_str().unwrap()), Some(o));
assert_eq!(OutputSpace::parse(o.label()), Some(o), "{}", o.label());
}
}
#[test]
fn white_maps_to_white() {
for o in OutputSpace::ALL {
let m = o.from_working();
for row in m {
assert!((row.iter().sum::<f32>() - 1.0).abs() < 1e-4, "{o:?}");
}
assert!((o.luma().iter().sum::<f32>() - 1.0).abs() < 1e-4);
}
}
}

View File

@ -54,20 +54,56 @@ fn score(img: &Rgb32f, target: (f32, f32), source: (f32, f32), r: f32) -> f32 {
s
}
/// Pick a source offset (in output pixels) for a remove/heal spot.
pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) {
let mut best = (f32::MAX, (r * 2.5, 0.0));
/// Candidate source offsets (output pixels) for a spot of radius `r` at `target`, best first
/// (candidates reaching outside the image are left out).
pub fn ranked_sources(img: &Rgb32f, target: (f32, f32), r: f32) -> Vec<(f32, f32)> {
let mut c: Vec<(f32, (f32, f32))> = Vec::new();
for ring in [2.4f32, 3.2, 4.2] {
for i in 0..16 {
let a = i as f32 / 16.0 * std::f32::consts::TAU;
let d = (a.cos() * r * ring, a.sin() * r * ring);
let s = score(img, target, (target.0 + d.0, target.1 + d.1), r);
if s < best.0 {
best = (s, d);
if s < f32::MAX {
c.push((s, d));
}
}
}
best.1
c.sort_by(|a, b| a.0.total_cmp(&b.0));
c.into_iter().map(|(_, d)| d).collect()
}
/// Pick a source offset (in output pixels) for a remove/heal spot.
pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) {
ranked_sources(img, target, r).first().copied().unwrap_or((r * 2.5, 0.0))
}
/// A source offset (normalized, as [`Spot::source_offset`]) for `spot` on `src` developed with
/// `s`: the best match, or with `avoid` (the current offset) the best one at least a spot radius
/// away from it ("refresh source"). `None` when no candidate fits in the image.
pub fn pick_source(
src: &Rgb32f,
info: &crate::SourceInfo,
s: &lightcraft_develop::DevelopSettings,
spot: &Spot,
avoid: Option<Point>,
) -> Option<Point> {
let frame = crate::frame_for(src, info, s, true);
let (w, h) = frame.fit(512, 512);
if w == 0 || h == 0 {
return None;
}
let img = frame.sample(src, w, h);
let (to_out, to_norm) = (frame.norm_to_out(w, h), frame.out_to_norm(w, h));
let t = to_out.apply(*spot.points.first()?);
let r = (spot.size * frame.px_per_long(w)).max(1.0) as f32;
let avoid = avoid.map(|o| {
let (a, b) = (to_out.apply(Point::new(0.0, 0.0)), to_out.apply(o));
((b.x - a.x) as f32, (b.y - a.y) as f32)
});
let far = |d: &(f32, f32)| avoid.is_none_or(|a| ((d.0 - a.0).powi(2) + (d.1 - a.1).powi(2)).sqrt() > r);
let d = ranked_sources(&img, (t.x as f32, t.y as f32), r).into_iter().find(far)?;
let (n0, n1) = (to_norm.apply(t), to_norm.apply(Point::new(t.x + d.0 as f64, t.y + d.1 as f64)));
Some(Point::new(n1.x - n0.x, n1.y - n0.y))
}
/// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit.

View File

@ -0,0 +1,109 @@
//! Pixel tests of the local (mask) Noise, Moiré and Defringe sliders.
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
use lightcraft_raster::{Rgb32f, Rgba8};
use crate::{RenderRequest, SourceInfo, render};
const W: usize = 240;
const H: usize = 120;
/// A deterministic hash in −1..1.
fn noise(x: usize, y: usize, k: u32) -> f32 {
let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ k.wrapping_mul(0xcb1a_b31f);
v ^= v >> 13;
v = v.wrapping_mul(0x5bd1_e995);
v ^= v >> 15;
(v & 0xffff) as f32 / 32768.0 - 1.0
}
/// Settings with one mask covering the left half (x < 0.45), with `adjust`.
fn left_mask(adjust: LocalAdjustments) -> DevelopSettings {
let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.46, 0.5) };
let mut s = DevelopSettings::default();
s.masks.push(Mask { id: 1, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() });
s
}
fn shot(src: &Rgb32f, s: &DevelopSettings) -> Rgba8 {
render(src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image
}
/// Standard deviation of `f(pixel)` over columns `x0..x1` (rows 4..H-4).
fn spread(img: &Rgba8, x0: usize, x1: usize, f: impl Fn([u8; 4]) -> f32) -> f32 {
let v: Vec<f32> = (4..H - 4).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| f(img.data[y * W + x])).collect();
let m = v.iter().sum::<f32>() / v.len() as f32;
(v.iter().map(|a| (a - m).powi(2)).sum::<f32>() / v.len() as f32).sqrt()
}
fn luma(p: [u8; 4]) -> f32 {
0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32
}
fn chroma(p: [u8; 4]) -> f32 {
p[0] as f32 - p[2] as f32
}
#[test]
fn local_noise_smooths_luminance_noise_inside_the_mask() {
let src = Rgb32f::from_fn(W, H, |x, y| [0.18 * (1.0 + 0.12 * noise(x, y, 1)); 3]);
let plain = shot(&src, &DevelopSettings::default());
let base = spread(&plain, 10, 90, luma);
let on = shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() }));
let off = shot(&src, &left_mask(LocalAdjustments { noise: -100.0, ..Default::default() }));
let (smoothed, boosted) = (spread(&on, 10, 90, luma), spread(&off, 10, 90, luma));
assert!(smoothed < 0.6 * base, "noise +100: {smoothed} vs {base}");
assert!(boosted > 1.2 * base, "noise −100: {boosted} vs {base}");
// outside the mask nothing changes
assert_eq!(spread(&on, 150, 230, luma), spread(&plain, 150, 230, luma));
// the mean brightness is kept
let mean = |img: &Rgba8| (4..H - 4).flat_map(|y| (10..90).map(move |x| luma(img.data[y * W + x]))).sum::<f32>() / (80 * (H - 8)) as f32;
assert!((mean(&on) - mean(&plain)).abs() < 2.0);
}
#[test]
fn local_moire_removes_colour_speckle_inside_the_mask() {
let src = Rgb32f::from_fn(W, H, |x, y| {
let n = 0.25 * noise(x, y, 2);
[0.18 * (1.0 + n), 0.18, 0.18 * (1.0 - n)]
});
let plain = shot(&src, &DevelopSettings::default());
let base = spread(&plain, 10, 90, chroma);
let on = shot(&src, &left_mask(LocalAdjustments { moire: 100.0, ..Default::default() }));
let reduced = spread(&on, 10, 90, chroma);
assert!(reduced < 0.4 * base, "moiré +100: {reduced} vs {base}");
assert_eq!(spread(&on, 150, 230, chroma), spread(&plain, 150, 230, chroma));
// luminance is kept
assert!((spread(&on, 10, 90, luma) - spread(&plain, 10, 90, luma)).abs() < 1.0);
// positive Noise also calms colour noise (less than Moiré)
let n = spread(&shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() })), 10, 90, chroma);
assert!(n < 0.8 * base && n > reduced, "{n}");
}
#[test]
fn local_defringe_desaturates_purple_edges_only() {
// dark | bright edge at x = 40 (inside the mask) and x = 200 (outside), each with a purple
// fringe column; a flat purple patch (no edge) at x 60..80
let src = Rgb32f::from_fn(W, H, |x, _| {
let fringe = x == 39 || x == 40 || x == 199 || x == 200;
if fringe {
[0.25, 0.04, 0.3]
} else if (60..80).contains(&x) {
[0.2, 0.08, 0.22]
} else if (40..120).contains(&x) || x >= 200 {
[0.7; 3]
} else {
[0.02; 3]
}
});
let s = left_mask(LocalAdjustments { defringe: 100.0, ..Default::default() });
let (plain, on) = (shot(&src, &DevelopSettings::default()), shot(&src, &s));
let sat = |p: [u8; 4]| p[0].max(p[2]) as f32 - p[1] as f32;
let at = |img: &Rgba8, x: usize| img.data[60 * W + x];
assert!(sat(at(&plain, 40)) > 40.0, "a purple fringe to start with: {:?}", at(&plain, 40));
assert!(sat(at(&on, 40)) < 0.5 * sat(at(&plain, 40)), "fringe in the mask: {:?} vs {:?}", at(&on, 40), at(&plain, 40));
assert_eq!(at(&on, 200), at(&plain, 200), "outside the mask");
let flat = |img: &Rgba8| sat(at(img, 70));
assert!((flat(&on) - flat(&plain)).abs() <= 2.0, "flat purple kept: {} vs {}", flat(&on), flat(&plain));
}

View File

@ -6,6 +6,9 @@
//! - **Visualize Spots** (Remove tool): a high-pass of the luminance thresholded to black/white,
//! so dust spots and specks stand out as white dots. The scale is relative to the image (the
//! same spots show at any preview size); the threshold slider (0..100) raises sensitivity.
//! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour
//! tint on a black-and-white image, the image on black / white, or the alpha as white on black
//! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`].
use std::borrow::Cow;
@ -13,7 +16,7 @@ use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020};
use lightcraft_color::transfer::srgb_to_linear;
use lightcraft_color::{REC2020, SRGB};
use lightcraft_develop::DevelopSettings;
use lightcraft_raster::Rgba8;
use lightcraft_raster::{Plane, Rgba8};
use crate::colorops::PointK;
use crate::{Plan, for_rows};
@ -27,23 +30,99 @@ pub enum Overlay {
PointColorRange(u8),
/// Visualize Spots with a threshold 0..100 (higher = more sensitive).
Spots(u8),
/// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in
/// `color` at `opacity` (0..100; the colour views only).
Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 },
}
/// How [`Overlay::Mask`] draws the mask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MaskView {
/// The photo with the mask tinted in the overlay colour.
#[default]
Color,
/// A black-and-white photo with the mask tinted in the overlay colour.
ColorOnBw,
/// The photo where the mask is, black elsewhere.
ImageOnBlack,
/// The photo where the mask is, white elsewhere.
ImageOnWhite,
/// The mask itself: white on black.
WhiteOnBlack,
}
impl MaskView {
pub const ALL: [MaskView; 5] = [MaskView::Color, MaskView::ColorOnBw, MaskView::ImageOnBlack, MaskView::ImageOnWhite, MaskView::WhiteOnBlack];
/// Stable name (control channel, settings).
pub fn name(self) -> &'static str {
match self {
MaskView::Color => "color",
MaskView::ColorOnBw => "colorOnBw",
MaskView::ImageOnBlack => "imageOnBlack",
MaskView::ImageOnWhite => "imageOnWhite",
MaskView::WhiteOnBlack => "whiteOnBlack",
}
}
pub fn label(self) -> &'static str {
match self {
MaskView::Color => "Color Overlay",
MaskView::ColorOnBw => "Color Overlay on B&W",
MaskView::ImageOnBlack => "Image on Black",
MaskView::ImageOnWhite => "Image on White",
MaskView::WhiteOnBlack => "White on Black",
}
}
pub fn parse(s: &str) -> Option<MaskView> {
MaskView::ALL.into_iter().find(|v| v.name() == s)
}
/// The next view (Shift+O cycles them).
pub fn next(self) -> MaskView {
let i = MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0);
MaskView::ALL[(i + 1) % MaskView::ALL.len()]
}
fn index(self) -> u64 {
MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0) as u64
}
}
/// [`Overlay::Mask`]'s fields in 51 bits (exact in an `f64`): id 16, view 4, colour 24, opacity 7.
fn pack_mask(id: u16, view: MaskView, color: [u8; 3], opacity: u8) -> u64 {
let rgb = (color[0] as u64) << 16 | (color[1] as u64) << 8 | color[2] as u64;
id as u64 | view.index() << 16 | rgb << 20 | (opacity.min(100) as u64) << 44
}
fn unpack_mask(v: u64) -> Overlay {
let rgb = (v >> 20) & 0xff_ffff;
Overlay::Mask {
id: (v & 0xffff) as u16,
view: MaskView::ALL.get(((v >> 16) & 0xf) as usize).copied().unwrap_or_default(),
color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8],
opacity: ((v >> 44) & 0x7f).min(100) as u8,
}
}
impl Overlay {
/// A plain (kind, value) pair, e.g. for sending a request to a web worker.
pub fn to_parts(self) -> (u8, f32) {
pub fn to_parts(self) -> (u8, f64) {
match self {
Overlay::None => (0, 0.0),
Overlay::PointColorRange(i) => (1, i as f32),
Overlay::Spots(t) => (2, t as f32),
Overlay::PointColorRange(i) => (1, i as f64),
Overlay::Spots(t) => (2, t as f64),
Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64),
}
}
/// Inverse of [`Overlay::to_parts`] (unknown kinds: no overlay).
pub fn from_parts(kind: u8, v: f32) -> Overlay {
pub fn from_parts(kind: u8, v: f64) -> Overlay {
match kind {
1 => Overlay::PointColorRange(v as u8),
2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8),
3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64),
_ => Overlay::None,
}
}
@ -54,6 +133,15 @@ impl Overlay {
Overlay::None => 0,
Overlay::PointColorRange(i) => 0x1000 + i as u64,
Overlay::Spots(t) => 0x2000 + t as u64,
Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity),
}
}
/// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components).
pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> {
match self {
Overlay::Mask { id, .. } => s.masks.iter().find(|m| m.id == id as u32 && !m.components.is_empty()),
_ => None,
}
}
}
@ -70,8 +158,9 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) {
}
}
/// Draw overlay `o` over `img` (rendered with `plan`).
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) {
/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask
/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]).
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) {
match o {
Overlay::None => {}
Overlay::PointColorRange(i) => {
@ -80,9 +169,37 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) {
}
}
Overlay::Spots(t) => spots(img, t, plan.px_per_long),
Overlay::Mask { view, color, opacity, .. } => {
if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) {
mask_view(img, a, view, color, opacity);
}
}
}
}
/// Draw mask alpha `a` over `img` as `view`.
pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opacity: u8) {
let op = opacity.min(100) as f32 / 100.0;
let col = color.map(|c| c as f32);
let w = img.width;
for_rows(&mut img.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let a = a.data[y * w + x].clamp(0.0, 1.0);
let p = [px[0] as f32, px[1] as f32, px[2] as f32];
let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2];
let out: [f32; 3] = match view {
MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op),
MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op),
MaskView::ImageOnBlack => p.map(|v| v * a),
MaskView::ImageOnWhite => p.map(|v| v * a + 255.0 * (1.0 - a)),
MaskView::WhiteOnBlack => [255.0 * a; 3],
};
let o = out.map(|v| v.round().clamp(0.0, 255.0) as u8);
*px = [o[0], o[1], o[2], 255];
}
});
}
/// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge.
const SPOT_SIGMA: f64 = 0.006;
@ -184,4 +301,45 @@ mod tests {
let e = render(&src, &info, &s, &RenderRequest::fit(32, 16)).image.data[8 * 32 + 4];
assert!(e[1].abs_diff(px[1]) > 10, "{e:?} vs {px:?}");
}
#[test]
fn mask_overlay_shows_the_evaluated_alpha() {
use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let src = Rgb32f::from_fn(80, 40, |x, _| if x < 40 { [0.18, 0.18, 0.18] } else { [0.05, 0.1, 0.3] });
let info = SourceInfo::default();
let shape = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false };
let mut s = DevelopSettings::default();
s.masks.push(Mask { id: 7, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], ..Default::default() });
let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image;
let shot = |s: &DevelopSettings, view: MaskView| {
let o = Overlay::Mask { id: 7, view, color: [255, 0, 0], opacity: 100 };
render(&src, &info, s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image
};
let (inside, outside) = (20 * 80 + 20, 20 * 80 + 70);
let c = shot(&s, MaskView::Color);
assert_eq!(c.data[inside], [255, 0, 0, 255], "full tint inside the mask");
assert_eq!(c.data[outside], plain.data[outside], "untouched outside");
let bw = shot(&s, MaskView::WhiteOnBlack);
assert_eq!((bw.data[inside], bw.data[outside]), ([255; 4], [0, 0, 0, 255]));
let ib = shot(&s, MaskView::ImageOnBlack);
assert_eq!((ib.data[inside], ib.data[outside]), (plain.data[inside], [0, 0, 0, 255]));
let iw = shot(&s, MaskView::ImageOnWhite);
assert_eq!(iw.data[outside], [255; 4]);
let g = shot(&s, MaskView::ColorOnBw).data[outside];
assert!(g[0] == g[1] && g[1] == g[2], "grey outside: {g:?}");
// a hidden mask still shows (it's the one being edited)
s.masks[0].visible = false;
assert_eq!(shot(&s, MaskView::WhiteOnBlack).data[inside], [255; 4]);
// an unknown mask draws nothing
let o = Overlay::Mask { id: 9, view: MaskView::WhiteOnBlack, color: [0; 3], opacity: 50 };
assert_eq!(render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image, plain);
// parts round trip (web worker requests) and distinct cache keys
let o = Overlay::Mask { id: 513, view: MaskView::ImageOnWhite, color: [12, 200, 255], opacity: 65 };
let (k, v) = o.to_parts();
assert_eq!(Overlay::from_parts(k, v), o);
assert_ne!(o.key(), Overlay::Mask { id: 513, view: MaskView::Color, color: [12, 200, 255], opacity: 65 }.key());
assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color);
assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw));
}
}

View File

@ -112,6 +112,8 @@ fn key_from(name: &str) -> Option<egui::Key> {
"tab" => Some(egui::Key::Tab),
"\\" | "backslash" => Some(egui::Key::Backslash),
"/" | "slash" => Some(egui::Key::Slash),
"[" => Some(egui::Key::OpenBracket),
"]" => Some(egui::Key::CloseBracket),
_ => None,
})
}

View File

@ -657,6 +657,133 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through
/// the engine; the delete confirmation guards ⌫.
#[test]
fn settings_dialog_by_keyboard_and_clicks() {
let mut h = demo([1300.0, 820.0]);
let t = Duration::from_secs(10);
let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() }));
for tab in ["import", "performance", "interface", "general"] {
let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t);
assert_eq!(r["ok"], true, "{tab}: {r}");
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() }));
}
// General: confirm before delete, startup view
h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t);
assert!(h.app.ui.settings.confirm_delete);
h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t);
assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail);
// Interface: filmstrip names off, grid badges always
h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t);
h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t);
assert!(!h.app.ui.settings.film_names);
h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t);
assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always);
// Performance: the thumbnail cache size goes to the library preferences
h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t);
h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t);
assert_eq!(h.app.session.cache_mb, 512);
h.request("ui.clickWidget", json!({"id": "button:settingsPreview-2"}), t);
assert_eq!(h.app.ui.settings.preview_edge, 3840);
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t);
assert_eq!(h.app.ui.settings.memory_mb, 1024);
h.step();
assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget");
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t);
h.step();
assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic");
// Import: per-camera defaults
h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t);
h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t);
assert!(h.app.session.import_defaults.per_camera);
let img = h.snapshot(SETTLE);
assert_eq!(img.size, [1300, 820]);
// Escape closes; ⌫ now asks first
h.request("ui.key", json!({"key": "escape"}), t);
assert_eq!(h.app.ui.dialog, None);
let active = h.app.session.active().unwrap();
h.request("ui.key", json!({"key": "delete"}), t);
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 }));
assert!(!h.app.session.catalog.photo(active).unwrap().deleted);
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.session.catalog.photo(active).unwrap().deleted);
// app settings survive a save/load round trip of the UI state (ui.json)
let saved = serde_json::to_string(&h.app.ui).unwrap();
let back = serde_json::from_str::<crate::UiState>(&saved).unwrap().sanitized();
assert_eq!(back.settings, h.app.ui.settings);
assert_eq!(back.view, crate::state::ViewMode::Detail, "startup view applied on load");
h.settle(SETTLE);
}
/// F: full-screen preview (arrows step, I cycles the info overlay, Esc exits); ⌘I cycles the
/// overlay in Detail; ⇧⌘F asks the host for window full screen.
#[test]
fn full_screen_preview_and_info_overlay_by_keyboard() {
use crate::state::InfoOverlay;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0]]}}), t);
h.request("ui.key", json!({"key": "f"}), t);
assert!(h.app.ui.fullscreen);
h.request("ui.key", json!({"key": "right"}), t);
assert_eq!(h.app.session.active().map(|p| p.0), Some(vis[1]));
let right = h.app.ui.right;
h.request("ui.key", json!({"key": "i"}), t);
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Basic);
assert_eq!(h.app.ui.right, right, "I doesn't open the Info panel in full screen");
let img = h.snapshot(SETTLE);
// no chrome: the frame's corners are black
for (x, y) in [(2usize, 2usize), (997, 2), (2, 697), (997, 697)] {
let c = img.pixels[y * 1000 + x];
assert!(c.r() < 8 && c.g() < 8 && c.b() < 8, "({x},{y}) = {c:?}");
}
assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay"));
h.request("ui.key", json!({"key": "escape"}), t);
assert!(!h.app.ui.fullscreen);
assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid, "Esc leaves full screen only, back to where it was entered");
h.request("ui.set", json!({"view": "detail"}), t);
h.request("ui.key", json!({"key": "i", "cmd": true}), t);
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Exposure);
h.request("ui.key", json!({"key": "i", "cmd": true}), t);
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off);
h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t);
assert!(h.app.window_is_fullscreen);
h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t);
assert!(!h.app.window_is_fullscreen);
h.settle(SETTLE);
}
/// The Navigator appears when zoomed in; clicking it pans to that point.
#[test]
fn navigator_pans_the_zoomed_loupe() {
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
h.settle(SETTLE);
assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "hidden at Fit");
h.request("engine.execute", json!({"command": "view.zoom100"}), t);
h.settle(SETTLE);
assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "navigator shown when zoomed");
h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t);
let (u, v) = h.app.ui.pan;
assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan);
assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100), "the click didn't reach the loupe (which would zoom out)");
h.settle(SETTLE);
let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap();
let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7);
h.request("ui.dragWidget", json!({"id": "canvas:navigator", "fx": 0.5, "fy": 0.5, "toX": to.x, "toY": to.y}), t);
let (u, v) = h.app.ui.pan;
assert!((u - 0.8).abs() < 0.03 && (v - 0.7).abs() < 0.03, "drag pan {:?}", h.app.ui.pan);
h.request("engine.execute", json!({"command": "view.navigator"}), t);
h.settle(SETTLE);
assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off");
}
#[test]
fn screenshot_request_is_answered_without_a_window() {
let mut h = demo([800.0, 500.0]);

View File

@ -20,6 +20,9 @@ pub mod state;
pub mod theme;
pub mod widgets;
#[cfg(test)]
mod tests_masking;
use std::sync::mpsc::{Receiver, Sender};
use lightcraft_engine::Session;
@ -33,6 +36,8 @@ pub type PickFiles = Box<dyn FnMut() -> Vec<String>>;
pub type SaveFile = Box<dyn FnMut(&str) -> Option<String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type PngEncode = Box<dyn Fn(&lightcraft_raster::Rgba8) -> Vec<u8>>;
/// A folder chooser (`None` = cancelled).
pub type PickFolder = Box<dyn FnMut() -> Option<String>>;
/// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux).
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
@ -50,6 +55,8 @@ pub struct Services {
pub png: Option<PngEncode>,
/// Show a file in the system file manager (desktop only).
pub reveal: Option<RevealFn>,
/// Choose a folder (Settings → General → Open Library…; desktop only).
pub pick_folder: Option<PickFolder>,
}
#[derive(Clone, Copy, Debug, Default)]
@ -100,6 +107,12 @@ pub struct LightcraftApp {
/// The look the loupe shows while the pointer rests on a preset or profile (set by the
/// panels each frame; nothing is committed, no history entry).
pub hover_preview: Option<HoverPreview>,
/// The window is in full screen (as last reported by the host, or as last requested).
pub window_is_fullscreen: bool,
/// The GPU preference last applied (`app.gpu`), to apply Settings changes once.
gpu_applied: Option<bool>,
/// The memory budget setting last applied (MB, 0 = automatic).
memory_applied: Option<u32>,
}
impl LightcraftApp {
@ -132,6 +145,9 @@ impl LightcraftApp {
merge: merge::MergeState::default(),
import: None,
hover_preview: None,
window_is_fullscreen: false,
gpu_applied: None,
memory_applied: None,
}
}
@ -304,6 +320,7 @@ impl LightcraftApp {
}
self.last_time = now;
self.drain_control(ctx);
self.apply_settings(ctx);
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
@ -326,6 +343,28 @@ impl LightcraftApp {
}
}
/// Apply settings that act outside the UI state: GPU rendering and window full screen.
fn apply_settings(&mut self, ctx: &egui::Context) {
if self.gpu_applied != Some(self.ui.settings.gpu) {
self.gpu_applied = Some(self.ui.settings.gpu);
let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu}));
}
let mb = self.ui.settings.memory_mb;
// automatic at startup: leave the engine's default alone
if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) {
let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb };
let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb}));
}
self.memory_applied = Some(mb);
if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) {
self.window_is_fullscreen = fs;
}
if let Some(on) = self.ui.window_fullscreen.take() {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
self.window_is_fullscreen = on;
}
}
/// Inject synthetic events (pointer events one per frame; key sequences up to the release).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
@ -351,6 +390,15 @@ impl LightcraftApp {
let t0 = now_ms();
// panels set it again this frame while the pointer rests on a preset or profile
self.hover_preview = None;
if self.ui.fullscreen {
// full-screen preview: the photo alone on black
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui));
panels::dialogs::show(self, &ctx);
panels::toast(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
self.perf.frame_ms = now_ms() - t0;
return;
}
// Order matters: earlier panels take the full edge (top bar spans the window; the tool strip,
// right panels and left panel run to the bottom; the bottom bar sits between them).
panels::topbar::show(self, ui);

View File

@ -54,6 +54,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
&[
"file.addPhotos",
"---",
"app.openLibrary",
"---",
"dialog.newAlbum",
"dialog.newFolder",
"dialog.newSmartAlbum",
@ -70,7 +72,19 @@ const LAYOUT: &[(&str, &[&str])] = &[
),
(
"Edit",
&["edit.undo", "edit.redo", "---", "develop.copy", "dialog.copySettings", "develop.paste", "---", "library.selectAll", "library.selectNone"],
&[
"edit.undo",
"edit.redo",
"---",
"develop.copy",
"dialog.copySettings",
"develop.paste",
"---",
"library.selectAll",
"library.selectNone",
"---",
"app.settings",
],
),
(
"View",
@ -84,6 +98,11 @@ const LAYOUT: &[(&str, &[&str])] = &[
"view.leftPanel",
"view.filmstrip",
"view.histogram",
"view.navigator",
"view.infoOverlay",
"---",
"view.fullScreenPreview",
"view.enterFullScreen",
"---",
"view.showOriginal",
"view.beforeAfter",
@ -97,6 +116,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
"---",
"view.clipping",
"view.maskOverlay",
"view.maskOverlayMode",
"view.maskPins",
"view.cropOverlay",
"---",
"@Sort",
@ -199,11 +220,15 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
"view.histogram" => Some(u.histogram),
"view.clipping" => Some(u.show_clipping),
"view.maskOverlay" => Some(u.mask_overlay),
"view.maskPins" => Some(u.mask_pins),
"view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original),
"view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide),
"view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split),
"view.autoAdvance" => Some(u.auto_advance),
"view.filterBar" => Some(u.filter_bar),
"view.navigator" => Some(u.navigator),
"view.fullScreenPreview" => Some(u.fullscreen),
"view.enterFullScreen" => Some(app.window_is_fullscreen),
"panel.edit" => panel(RightPanel::Edit),
"panel.profiles" => panel(RightPanel::Profiles),
"panel.crop" => panel(RightPanel::Crop),
@ -421,6 +446,9 @@ pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result<Valu
if culling_cmd {
crate::panels::compare::target_active(app, &mut params);
}
if id == "photo.delete" && crate::menus::confirm_delete(app) {
return Ok(Value::Null);
}
let r = app.run(id, params);
if culling_cmd && r.is_ok() && app.ui.auto_advance {
crate::panels::compare::advance(app);

View File

@ -34,10 +34,18 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.clipping", "Show Clipping", Some("J"), "View"),
("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"),
("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"),
// Shift+O in the Masking panel (elsewhere it cycles the crop overlay)
("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"),
("view.maskOverlayColor", "Mask Overlay Color", None, ""),
("view.maskPins", "Show Mask Pins", None, "View"),
("view.visualizeSpots", "Visualize Spots", Some("A"), "View"),
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
("view.back", "Back to Grid", Some("Escape"), ""),
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"),
("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"),
("view.navigator", "Navigator", None, "View"),
("panel.edit", "Edit", Some("E"), "Window"),
("panel.profiles", "Profile Browser", None, "Window"),
("panel.crop", "Crop & Rotate", Some("C"), "Window"),
@ -60,6 +68,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"),
("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"),
("tool.none", "No Tool", None, ""),
// brush size / feather of the active brush (Masking brush, Remove tool and its selected spot)
("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"),
("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"),
("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"),
("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"),
("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"),
("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"),
("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"),
@ -73,6 +86,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"),
("file.importPresets", "Import Presets…", None, "File"),
("file.exportPresets", "Export Presets…", None, "File"),
("app.settings", "Settings…", Some("Cmd+,"), "Edit"),
("app.openLibrary", "Open Library…", None, "File"),
("app.about", "About LightCraft", None, "Help"),
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
("app.export", "Export Now", None, ""),
@ -100,6 +115,45 @@ fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str
let _ = app.session.end_interaction();
}
/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's
/// (and its selected spot's) or the Masking brush's.
fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value {
if app.ui.right == RightPanel::Remove {
app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25);
app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0);
if app.session.active_spot.is_some() {
let mut p = json!({});
if k != 1.0 {
p["size"] = json!(app.ui.remove_size);
}
if df != 0.0 {
p["feather"] = json!(app.ui.remove_feather);
}
let _ = app.run("spot.update", p);
}
json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather})
} else {
app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5);
app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0);
json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather})
}
}
/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255).
pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
if let Some(s) = v.as_str() {
let h = s.strip_prefix('#').unwrap_or(s);
if h.len() != 6 {
return None;
}
let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok();
return Some([c(0)?, c(2)?, c(4)?]);
}
let a = v.as_array()?;
let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8);
Some([c(0)?, c(1)?, c(2)?])
}
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let ctx = egui::Context::default();
@ -131,6 +185,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"view.back" => {
if app.ui.dialog.is_some() {
app.ui.dialog = None;
} else if app.ui.fullscreen {
app.ui.fullscreen = false;
} else if !app.ui.tool.is_empty() {
app.ui.tool.clear();
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
@ -140,6 +196,50 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
Ok(Value::Null)
}
"view.fullScreenPreview" => {
app.ui.fullscreen = !app.ui.fullscreen;
if app.ui.fullscreen {
app.ui.zoom = Zoom::Fit;
app.ui.tool.clear();
let _ = app.session.end_interaction();
}
Ok(json!({"fullscreen": app.ui.fullscreen}))
}
"view.enterFullScreen" => {
// applied by the host's frame logic (it knows the window's current state)
let on = p.get("on").and_then(Value::as_bool);
app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen));
Ok(json!({"windowFullscreen": app.ui.window_fullscreen}))
}
"view.infoOverlay" => {
app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) {
Some(m) => match serde_json::from_value(json!(m)) {
Ok(v) => v,
Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))),
},
None => app.ui.info_overlay.next(),
};
let label = match app.ui.info_overlay {
crate::state::InfoOverlay::Off => "Info Overlay Off",
crate::state::InfoOverlay::Basic => "Info Overlay: File",
crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure",
};
app.toast(&ctx, label);
Ok(json!({"infoOverlay": app.ui.info_overlay}))
}
"view.navigator" => {
app.ui.navigator = !app.ui.navigator;
Ok(json!({"navigator": app.ui.navigator}))
}
"app.settings" => {
let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general");
if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) {
return Some(Err(format!("unknown settings tab `{tab}` (general|import|performance|interface)")));
}
app.ui.dialog = Some(Dialog::Settings { tab: tab.into() });
Ok(Value::Null)
}
"app.openLibrary" => crate::panels::settings::open_library(app, p),
"view.filmstrip" => {
app.ui.filmstrip = !app.ui.filmstrip;
Ok(Value::Null)
@ -203,8 +303,52 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"view.maskOverlay" => {
app.ui.mask_overlay = !app.ui.mask_overlay;
Ok(Value::Null)
app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay);
Ok(json!({"maskOverlay": app.ui.mask_overlay}))
}
"view.maskOverlayMode" => {
use lightcraft_engine::pipeline::MaskView;
let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default();
let next = match p.get("mode").and_then(Value::as_str) {
Some(m) => match MaskView::parse(m) {
Some(v) => v,
None => {
let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect();
return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|"))));
}
},
None => cur.next(),
};
app.ui.mask_overlay_mode = next.name().into();
app.ui.mask_overlay = true;
app.toast(&ctx, next.label());
Ok(json!({"mode": next.name()}))
}
"view.maskOverlayColor" => {
if let Some(c) = p.get("color") {
match parse_rgb(c) {
Some(rgb) => app.ui.mask_overlay_color = rgb,
None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())),
}
}
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32;
}
let [r, g, b] = app.ui.mask_overlay_color;
Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity}))
}
"brush.smaller" | "brush.larger" | "brush.featherLess" | "brush.featherMore" => {
let (k, df) = match id {
"brush.smaller" => (1.0 / 1.2, 0.0),
"brush.larger" => (1.2, 0.0),
"brush.featherLess" => (1.0, -10.0),
_ => (1.0, 10.0),
};
Ok(adjust_brush(app, k, df))
}
"view.maskPins" => {
app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins);
Ok(json!({"maskPins": app.ui.mask_pins}))
}
"view.visualizeSpots" => {
// like Lightroom's A: opens the Remove tool with the view on, or toggles it there
@ -484,6 +628,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
}
"file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
"view.compare" => app.session.catalog.len() > 1,
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
"app.openLibrary" => app.services.pick_folder.is_some(),
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
s if s.starts_with("dialog.merge") => app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none(),
_ => true,
@ -526,6 +672,20 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
v
}
/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead
/// of deleting; true when it did (the dialog's OK runs `photo.delete`).
pub fn confirm_delete(app: &mut LightcraftApp) -> bool {
if !app.ui.settings.confirm_delete {
return false;
}
let count = app.session.targets(&json!({})).len();
if count == 0 {
return false;
}
app.ui.dialog = Some(Dialog::ConfirmDelete { count });
true
}
/// Reveal the active photo's original in the system file manager.
fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
let id = app.session.active().ok_or("no photo selected")?;

View File

@ -23,6 +23,11 @@ pub enum Gesture {
Spot {
points: Vec<Point>,
},
/// Remove tool: dragging spot `spot`'s target (or its source).
SpotMove {
spot: usize,
source: bool,
},
CropHandle {
handle: u8,
start: lightcraft_geom::Rect,
@ -40,8 +45,11 @@ pub enum Gesture {
start: (f32, f32),
at: Pos2,
},
/// Dragging component `comp` of `mask`: its pin (`handle` 0) or a linear gradient's start (1)
/// or end (2).
MaskHandle {
mask: u32,
comp: usize,
handle: u8,
},
/// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates).
@ -119,10 +127,12 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2],
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.max_rect();
let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 };
let fullscreen = app.ui.fullscreen;
let show_film = app.ui.filmstrip && !fullscreen;
let film_h = if show_film { t.film_h } else { 0.0 };
let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h));
app.canvas_rect = Some(canvas);
if app.ui.filmstrip {
if show_film {
filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max));
}
let Some(id) = app.session.active() else {
@ -131,11 +141,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
};
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
let d = (*photo.develop).clone();
let crop_tool = app.ui.right == RightPanel::Crop;
// the full-screen preview shows the photo only: no tool overlays
let right = if fullscreen { RightPanel::None } else { app.ui.right };
let crop_tool = right == RightPanel::Crop;
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref());
let aspect = frame.aspect() as f32;
let ppp = ui.ctx().pixels_per_point();
let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 });
let area = canvas.shrink(if fullscreen {
0.0
} else if crop_tool {
48.0
} else {
24.0
});
let max_edge = app.ui.settings.preview_edge.clamp(512, 8192) as f32;
let native = [photo.width.max(1) as usize, photo.height.max(1) as usize];
// two views (before, after): side by side or stacked
let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom);
@ -163,7 +182,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// request renders: the loupe at display resolution (drafts during drags)
let interacting = app.session.interaction.is_some();
let scale = if interacting { 0.6 } else { 1.0 };
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize;
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(max_edge) as usize;
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
let job = if interacting { job.draft() } else { job };
@ -193,7 +212,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref());
let na = nf.aspect() as f32;
let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan);
let nw = (nr.width().max(nr.height()) * ppp).min(2560.0) as usize;
let nw = (nr.width().max(nr.height()) * ppp).min(max_edge) as usize;
let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) };
if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) {
app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY);
@ -272,22 +291,119 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE));
}
if app.ui.show_clipping && !show_before {
if app.ui.show_clipping && !show_before && !fullscreen {
clipping_overlay(app, &p, img_rect);
}
register(ui.ctx(), "canvas:image", img_rect);
let map = CanvasMap::new(&frame, img_rect);
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
match app.ui.right {
info_overlay(app, &p, canvas, &photo);
match right {
RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id),
RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d),
RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d),
RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d),
_ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect),
}
// drawn and hit-tested above the loupe and its tools: clicks on it pan
navigator(app, ui, canvas, img_rect, id);
resp.context_menu(|ui| super::grid::context_menu(app, ui, id));
}
/// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date
/// and size, or with the camera and exposure.
fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) {
use crate::state::InfoOverlay;
let lines: Vec<String> = match app.ui.info_overlay {
InfoOverlay::Off => return,
InfoOverlay::Basic => {
let date = photo.captured.as_deref().unwrap_or(&photo.imported);
vec![photo.file_name.clone(), format!("{} · {} × {}", pretty_date(date), photo.width, photo.height)]
}
InfoOverlay::Exposure => {
let m = &photo.meta;
let mut exp = Vec::new();
if !m.shutter.is_empty() {
exp.push(format!("{} s", m.shutter));
}
if let Some(a) = m.aperture {
exp.push(format!("f/{a:.1}"));
}
if let Some(i) = m.iso {
exp.push(format!("ISO {i}"));
}
if let Some(f) = m.focal_mm {
exp.push(format!("{f:.0} mm"));
}
let camera = [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" · ");
let mut v = vec![photo.file_name.clone()];
v.push(if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") });
if !camera.is_empty() {
v.push(camera);
}
v
}
};
let t = Tokens::get(p.ctx());
let mut y = canvas.top() + 12.0;
for (i, l) in lines.iter().enumerate() {
let font = if i == 0 { t.semibold(15.0) } else { t.font(12.5) };
let g = p.layout_no_wrap(l.clone(), font, Color32::WHITE);
let at = pos2(canvas.left() + 14.0, y);
// a soft shadow keeps the text readable on bright photos
p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200));
p.galley(at, g.clone(), Color32::WHITE);
y += g.size().y + 3.0;
}
register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y)));
}
/// `2026-09-30T12:00:00` → `2026-09-30 12:00`.
fn pretty_date(iso: &str) -> String {
let d = iso.replacen('T', " ", 1);
d.get(..16).map(str::to_string).unwrap_or(d)
}
/// Size of the Navigator mini map (points).
const NAV_W: f32 = 180.0;
/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the
/// visible region outlined; click or drag on it to pan.
fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) {
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
if !app.ui.navigator || !zoomed || app.ui.fullscreen {
return;
}
let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id));
let aspect = img.width() / img.height().max(1.0);
let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) };
let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h));
let p = ui.painter_at(canvas);
p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190));
match tex {
Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE),
None => p.rect_filled(frame, 0.0, Color32::from_gray(40)),
};
// the visible part of the photo, in normalized image coordinates
let vis = canvas.intersect(img);
let n = |q: Pos2| pos2((q.x - img.left()) / img.width(), (q.y - img.top()) / img.height());
let (a, b) = (n(vis.min), n(vis.max));
let to_nav = |q: Pos2| pos2(frame.left() + q.x * frame.width(), frame.top() + q.y * frame.height());
let view = Rect::from_min_max(to_nav(a), to_nav(b));
p.rect_stroke(view, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside);
p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
register(ui.ctx(), "canvas:navigator", frame);
let resp = ui.interact(frame.expand(5.0), egui::Id::new("navigator"), Sense::click_and_drag());
if (resp.clicked() || resp.dragged())
&& let Some(q) = resp.interact_pointer_pos()
{
// centre the view on the point under the pointer
let u = ((q.x - frame.left()) / frame.width()).clamp(0.0, 1.0);
let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0);
app.ui.pan = (u, v);
}
}
/// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes.
const PREFETCH_PRIORITY: u32 = 4;
@ -332,9 +448,25 @@ fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respon
}
}
/// The diagnostic overlay the loupe shows (Point Color's visualized range).
fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
use lightcraft_pipeline::Overlay;
/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range,
/// Visualize Spots).
pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
use lightcraft_pipeline::{MaskView, Overlay};
if app.ui.fullscreen {
return Overlay::None;
}
if app.ui.right == RightPanel::Masking
&& app.ui.mask_overlay
&& let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id))
&& !m.components.is_empty()
{
return Overlay::Mask {
id: m.id.min(u16::MAX as u32) as u16,
view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(),
color: app.ui.mask_overlay_color,
opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8,
};
}
if app.ui.right == RightPanel::Remove && app.ui.visualize_spots {
return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8);
}
@ -613,26 +745,55 @@ fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] {
// ------------------------------------------------------------------------ masks
/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo.
pub(crate) fn component_pin(shape: &MaskShape) -> Option<Point> {
match shape {
MaskShape::Radial { center, .. } => Some(*center),
MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)),
MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(),
_ => None,
}
}
/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`.
fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point) -> MaskShape {
let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y);
match shape.clone() {
MaskShape::Radial { center, rx, ry, angle, feather, invert } => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert },
MaskShape::Linear { start, end } => match handle {
1 => MaskShape::Linear { start: at, end },
2 => MaskShape::Linear { start, end: at },
_ => MaskShape::Linear { start: mv(start), end: mv(end) },
},
MaskShape::Brush { mut strokes } => {
for st in &mut strokes {
st.points.iter_mut().for_each(|p| *p = mv(*p));
}
MaskShape::Brush { strokes }
}
s => s,
}
}
/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask
/// component (click selects its mask, drag moves the component), and brush painting. The mask
/// itself shows as a rendered overlay ([`view_overlay`]).
fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
let red = Color32::from_rgba_unmultiplied(230, 30, 40, 90);
let tint = {
let [r, g, b] = app.ui.mask_overlay_color;
Color32::from_rgba_unmultiplied(r, g, b, 110)
};
let long = (map.rect.width().max(map.rect.height())) as f64;
let active = app.session.active_mask;
let clip = app.canvas_rect.unwrap_or(map.rect);
let p = &ui.painter_at(clip);
// (mask, component, handle, screen position) of everything that can be grabbed
let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new();
for m in &d.masks {
let sel = Some(m.id) == active;
for c in &m.components {
for (ci, c) in m.components.iter().enumerate() {
match &c.shape {
MaskShape::Brush { strokes } if sel && app.ui.mask_overlay => {
for s in strokes {
let r = (s.size * long) as f32;
for q in &s.points {
p.circle_filled(map.screen(*q), r, if s.erase { Color32::from_black_alpha(60) } else { red });
}
}
}
MaskShape::Radial { center, rx, ry, angle, .. } => {
let c0 = map.screen(*center);
MaskShape::Radial { center, rx, ry, angle, .. } if sel => {
let pts: Vec<Pos2> = (0..64)
.map(|i| {
let a = i as f64 / 64.0 * std::f64::consts::TAU;
@ -642,35 +803,31 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1))
})
.collect();
if sel && app.ui.mask_overlay {
p.add(egui::Shape::convex_polygon(pts.clone(), red, Stroke::NONE));
}
p.add(egui::Shape::closed_line(
pts,
Stroke::new(if sel { 1.5 } else { 1.0 }, Color32::from_white_alpha(if sel { 230 } else { 120 })),
));
pin(p, c0, sel);
if sel {
register(ui.ctx(), format!("maskPin:{}", m.id), Rect::from_center_size(c0, vec2(14.0, 14.0)));
}
p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230))));
}
MaskShape::Linear { start, end } => {
MaskShape::Linear { start, end } if sel => {
let (a, b) = (map.screen(*start), map.screen(*end));
let dir = (b - a).normalized();
let perp = vec2(-dir.y, dir.x) * 3000.0;
for (q, alpha) in [(a, 230), (b, 110)] {
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(if sel { alpha } else { 80 })));
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha)));
}
pin(p, a + (b - a) * 0.5, sel);
if sel {
for (i, q) in [a, b].iter().enumerate() {
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
register(ui.ctx(), format!("maskHandle:{}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0)));
}
for (i, q) in [a, b].iter().enumerate() {
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
register(ui.ctx(), format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0)));
grips.push((m.id, ci, i as u8 + 1, *q));
}
}
_ => {}
}
if app.ui.mask_pins
&& let Some(at) = component_pin(&c.shape)
{
let q = map.screen(at);
pin(p, q, sel);
register(ui.ctx(), format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0)));
grips.push((m.id, ci, 0, q));
}
}
}
// brush tool
@ -690,9 +847,10 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
_ => app.gesture = Some(Gesture::Brush { points: vec![n] }),
}
}
// the stroke being painted (the render shows it once it's committed)
if let Some(Gesture::Brush { points }) = &app.gesture {
for q in points {
p.circle_filled(map.screen(*q), r, red);
p.circle_filled(map.screen(*q), r, tint);
}
}
if (resp.drag_stopped() || resp.clicked())
@ -701,57 +859,53 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
let _ = app.run(
"mask.brushStroke",
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "erase": app.ui.brush_erase}),
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow,
"erase": app.ui.brush_erase, "autoMask": app.ui.brush_auto_mask}),
);
}
return;
}
// drag pins/handles of the active mask
let Some(mid) = active else { return };
let Some(m) = d.masks.iter().find(|m| m.id == mid) else { return };
let Some(comp) = m.components.first() else { return };
if resp.drag_started()
// the grip under the pointer: the selected mask's first, then the nearest
let hit = |q: Pos2| {
grips
.iter()
.filter(|g| g.3.distance(q) < 10.0)
.min_by(|a, b| {
(Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal)
})
.copied()
};
if resp.clicked()
&& let Some(q) = resp.interact_pointer_pos()
&& let Some((mid, ..)) = hit(q)
&& Some(mid) != active
{
let handle = match &comp.shape {
MaskShape::Radial { center, .. } => (map.screen(*center).distance(q) < 14.0).then_some(0),
MaskShape::Linear { start, end } => {
if map.screen(*start).distance(q) < 14.0 {
Some(1)
} else if map.screen(*end).distance(q) < 14.0 {
Some(2)
} else {
Some(0)
}
let _ = app.run("mask.select", json!({"id": mid}));
}
if resp.drag_started()
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
{
let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| {
// anywhere on the photo drags the selected linear gradient
let m = d.masks.iter().find(|m| Some(m.id) == active)?;
matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0))
});
if let Some((mask, comp, handle)) = grip {
if Some(mask) != active {
let _ = app.run("mask.select", json!({"id": mask}));
}
_ => None,
};
if let Some(h) = handle {
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"}));
app.gesture = Some(Gesture::MaskHandle { mask: mid, handle: h });
app.gesture = Some(Gesture::MaskHandle { mask, comp, handle });
}
}
if resp.dragged()
&& let (Some(Gesture::MaskHandle { mask, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
&& let (Some(Gesture::MaskHandle { mask, comp, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
&& let Some(shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| &c.shape)
{
let n = map.norm(q);
let dn = {
let q0 = q - resp.drag_delta();
let n0 = map.norm(q0);
Point::new(n.x - n0.x, n.y - n0.y)
};
let new_shape = match comp.shape.clone() {
MaskShape::Radial { center, rx, ry, angle, feather, invert } => {
MaskShape::Radial { center: Point::new(center.x + dn.x, center.y + dn.y), rx, ry, angle, feather, invert }
}
MaskShape::Linear { start, end } => match handle {
1 => MaskShape::Linear { start: n, end },
2 => MaskShape::Linear { start, end: n },
_ => MaskShape::Linear { start: Point::new(start.x + dn.x, start.y + dn.y), end: Point::new(end.x + dn.x, end.y + dn.y) },
},
s => s,
};
let _ = app.run("mask.update", json!({"id": mask, "shape": new_shape}));
let n0 = map.norm(q - resp.drag_delta());
let new_shape = moved_shape(shape, handle, Point::new(n.x - n0.x, n.y - n0.y), n);
let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape}));
}
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) {
app.gesture = None;
@ -776,19 +930,89 @@ fn pin(p: &egui::Painter, c: Pos2, sel: bool) {
// ------------------------------------------------------------------------ remove
/// The Remove tool on the photo: every spot's outline and pin (the selected one with its source),
/// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one.
fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
let long = (map.rect.width().max(map.rect.height())) as f64;
let p = ui.painter();
for s in &d.spots {
let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect));
let active = app.session.active_spot.filter(|i| *i < d.spots.len());
// (spot, is source, screen centre, radius) of everything that can be grabbed
let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new();
for (i, s) in d.spots.iter().enumerate() {
let r = (s.size * long) as f32;
let sel = Some(i) == active;
let col = Color32::from_white_alpha(if sel { 230 } else { 150 });
for q in &s.points {
p.circle_stroke(map.screen(*q), r, Stroke::new(1.0, Color32::from_white_alpha(200)));
p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col));
}
let Some(&t) = s.points.first() else { continue };
let tq = map.screen(t);
if let Some(o) = s.source_offset.filter(|_| sel) {
let sq = map.screen(Point::new(t.x + o.x, t.y + o.y));
for q in &s.points {
p.circle_stroke(map.screen(Point::new(q.x + o.x, q.y + o.y)), r, Stroke::new(1.0, Color32::from_white_alpha(170)));
}
let dir = (tq - sq).normalized();
p.arrow(sq + dir * r, (tq - sq) - dir * (2.0 * r).min((tq - sq).length()), Stroke::new(1.0, Color32::from_white_alpha(200)));
register(ui.ctx(), format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0)));
grips.push((i, true, sq, r));
}
pin(&p, tq, sel);
register(ui.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0)));
grips.push((i, false, tq, r));
}
let r = (app.ui.remove_size as f64 * long) as f32;
// a target or source under `q`: the selected spot's first, then the nearest pin
let hit = |q: Pos2| {
grips
.iter()
.filter(|g| g.2.distance(q) < g.3.max(8.0))
.min_by(|a, b| {
(Some(a.0) != active, a.2.distance(q)).partial_cmp(&(Some(b.0) != active, b.2.distance(q))).unwrap_or(std::cmp::Ordering::Equal)
})
.copied()
};
let hover = resp.hover_pos().and_then(hit);
if let Some(h) = resp.hover_pos() {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
if hover.is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
} else {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
p.circle_stroke(h, r * (1.0 - app.ui.remove_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(110)));
}
}
let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos());
if resp.drag_started()
&& let Some((spot, source, ..)) = press.and_then(hit)
{
if Some(spot) != active {
let _ = app.run("spot.select", json!({"index": spot}));
}
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"}));
app.gesture = Some(Gesture::SpotMove { spot, source });
}
if let Some(Gesture::SpotMove { spot, source }) = app.gesture.clone() {
if resp.dragged()
&& let Some(q) = resp.interact_pointer_pos()
{
let (n, n0) = (map.norm(q), map.norm(q - resp.drag_delta()));
let dn = [n.x - n0.x, n.y - n0.y];
if dn != [0.0, 0.0] {
let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: dn}));
}
}
if resp.drag_stopped() {
app.gesture = None;
let _ = app.run("develop.endInteraction", json!({}));
}
return;
}
if resp.clicked()
&& let Some((spot, ..)) = press.and_then(hit)
{
let _ = app.run("spot.select", json!({"index": spot}));
return;
}
if let Some(q) = resp.interact_pointer_pos()
&& (resp.drag_started() || resp.dragged() || resp.clicked())
@ -813,7 +1037,10 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo
_ => "remove",
};
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
let _ = app.run("spot.add", json!({"mode": mode, "points": pts, "size": app.ui.remove_size}));
let _ = app.run(
"spot.add",
json!({"mode": mode, "points": pts, "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}),
);
}
}
@ -909,7 +1136,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
} else if resp.hovered() {
p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6));
}
if let Some(ph) = app.session.catalog.photo(*id) {
let names = app.ui.settings.film_names;
if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) {
let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name);
let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() };
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim);
@ -925,6 +1153,11 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
if sel {
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
}
if app.ui.settings.film_badges
&& let Some(ph) = app.session.catalog.photo(*id)
{
film_badges(p, &t, fr, ph);
}
if let Some(st) = app.session.catalog.stack_of(*id) {
let text =
if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) };
@ -950,6 +1183,32 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
});
}
/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface).
fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) {
use crate::icons::{Icon, paint};
use lightcraft_catalog::Flag;
let edited = ph.is_edited();
if ph.rating == 0 && ph.flag == Flag::None && !edited {
return;
}
let bar = Rect::from_min_max(pos2(fr.left(), fr.bottom() - 15.0), fr.right_bottom());
p.rect_filled(bar, 0.0, Color32::from_black_alpha(130));
let y = bar.center().y;
let mut x = bar.left() + 3.0;
for i in 0..ph.rating {
paint(p, Rect::from_min_size(pos2(x + i as f32 * 9.0, y - 4.0), vec2(8.0, 8.0)), Icon::StarFilled, t.star);
}
x += ph.rating as f32 * 9.0 + 2.0;
match ph.flag {
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, t.pick),
Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject),
Flag::None => {}
}
if edited {
paint(p, Rect::from_min_size(pos2(bar.right() - 13.0, y - 5.0), vec2(10.0, 10.0)), Icon::Sliders, t.text_label);
}
}
/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto.
/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle.
fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response) {

View File

@ -34,6 +34,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::CopySettings { .. } => "Choose Edit Settings to Copy",
Dialog::Export { .. } => "Export",
Dialog::Merge { opts } => opts.title(),
Dialog::Settings { .. } => "Settings",
Dialog::ConfirmDelete { .. } => "Delete Photos",
Dialog::About => "About LightCraft",
Dialog::Shortcuts => "Keyboard Shortcuts",
};
@ -236,6 +238,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let n = app.session.selection.ids.len().max(1);
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
ui.add_space(4.0);
let before = opts.format;
choices(
ui,
"Format",
@ -243,6 +246,34 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")],
&mut opts.format,
);
if opts.format != before {
// each format starts at its own default depth (TIFF 16-bit, others 8-bit)
opts.bit_depth = None;
}
let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format);
if depths.len() > 1 {
let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0);
choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd);
opts.bit_depth = Some(bd);
}
if opts.format == F::Avif {
ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim));
} else {
use lightcraft_engine::export::OutputSpace as C;
choices(
ui,
"Color space",
"exportColorSpace",
&[
(C::Srgb, "sRGB"),
(C::DisplayP3, "P3"),
(C::AdobeRgb, "Adobe RGB"),
(C::ProPhoto, "ProPhoto"),
(C::Rec2020, "Rec.2020"),
],
&mut opts.color_space,
);
}
if matches!(opts.format, F::Jpeg | F::Avif) {
let mut q = opts.quality as f64;
if num(ui, &QUALITY, &mut q) {
@ -324,6 +355,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Dialog::Merge { opts } => crate::merge::body(app, ui, opts),
Dialog::Import { opts } => crate::import::body(app, ui, opts),
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
Dialog::ConfirmDelete { count } => {
let what = if *count == 1 { "this photo".to_string() } else { format!("these {count} photos") };
ui.label(format!("Move {what} to Recently Deleted?"));
ui.label(egui::RichText::new("They can be restored from Recently Deleted until it is emptied.").color(t.text_dim));
}
Dialog::About => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
@ -366,7 +403,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
ui.add_space(4.0);
ui.horizontal(|ui| {
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts);
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. });
if !informational && ui.button("Cancel").clicked() {
close = true;
}
@ -378,6 +415,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
add_label.as_str()
}
Dialog::Merge { .. } => "Merge",
Dialog::ConfirmDelete { .. } => "Delete",
_ if informational => "Close",
_ => "OK",
};
@ -456,11 +494,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
"format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb,
"sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir,
"metadata": opts.metadata, "removeLocation": opts.remove_location, "watermark": opts.watermark,
"colorSpace": opts.color_space, "bitDepth": opts.bit_depth,
}),
),
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
Dialog::Import { opts } => crate::import::start(app, opts),
Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null),
Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})),
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null),
}
}

View File

@ -312,7 +312,11 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
p.rect_filled(br, 2.0, Color32::from_gray(26));
p.galley(br.min + vec2(4.0, 1.5), g, t.text_label);
}
let show_badges = resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some();
let show_badges = match app.ui.settings.grid_badges {
crate::state::GridBadges::Auto => resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some(),
crate::state::GridBadges::Always => true,
crate::state::GridBadges::Never => false,
};
if show_badges {
let bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom());
if resp.hovered() || selected {

View File

@ -33,6 +33,8 @@ pub const LOCAL: &[ControlSpec] = &[
spec("saturation", "Saturation", -100.0, 100.0, 1.0, 0, Track::Gradient { from: "#7a7a7a", to: "#e04a3a" }),
spec("sharpness", "Sharpness", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("noise", "Noise", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("moire", "Moiré", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("defringe", "Defringe", -100.0, 100.0, 1.0, 0, Track::Centered),
];
pub fn local_get(a: &LocalAdjustments, key: &str) -> f64 {
@ -142,6 +144,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let _ = app.run("mask.select", json!({"id": m.id}));
}
}
if !d.masks.is_empty() {
ui.add_space(6.0);
overlay_options(app, ui);
}
});
let Some(mid) = app.session.active_mask else { return };
let Some(m) = d.masks.iter().find(|m| m.id == mid).cloned() else { return };
@ -202,6 +208,62 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let _ = Stroke::NONE;
}
/// Overlay colours offered as swatches (the colour of the selected one is used for the tint).
const OVERLAY_COLORS: [[u8; 3]; 5] = [[230, 30, 40], [40, 200, 70], [40, 110, 240], [250, 210, 30], [255, 255, 255]];
/// How the selected mask is shown: overlay mode, colour, opacity, pins.
fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) {
use lightcraft_pipeline::MaskView;
let t = Tokens::get(ui.ctx());
let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Overlay").color(t.text_dim));
let r = crate::widgets::dropdown(ui, "maskOverlayMode", view.label(), t.font(12.5), t.text_label);
egui::Popup::menu(&r).show(|ui| {
for v in MaskView::ALL {
if ui.selectable_label(v == view, v.label()).clicked() {
let _ = app.run("view.maskOverlayMode", json!({"mode": v.name()}));
}
}
});
});
let colored = matches!(view, MaskView::Color | MaskView::ColorOnBw);
if colored {
ui.horizontal(|ui| {
for c in OVERLAY_COLORS {
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r);
ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2]));
if app.ui.mask_overlay_color == c {
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside);
}
if resp.clicked() {
let _ = app.run("view.maskOverlayColor", json!({"color": c}));
}
}
});
}
let spec = ControlSpec {
id: "ui.maskOverlayOpacity",
label: "Opacity",
section: Section::Light,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let out = slider(ui, &spec, app.ui.mask_overlay_opacity as f64, colored && app.ui.mask_overlay, None);
if let Some(v) = out.value {
app.ui.mask_overlay_opacity = v as f32;
}
let mut pins = app.ui.mask_pins;
if ui.checkbox(&mut pins, "Show Pins").changed() {
let _ = app.run("view.maskPins", json!({"show": pins}));
}
}
fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) {
for (kind, label) in [
("brush", "Brush"),
@ -234,6 +296,10 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.ui.brush_erase = true;
}
});
let mut auto = app.ui.brush_auto_mask;
if ui.checkbox(&mut auto, "Auto Mask").on_hover_text("Paint only areas like the one under the brush").changed() {
app.ui.brush_auto_mask = auto;
}
});
for (id, label, min, max, get) in [
("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64),

View File

@ -12,6 +12,7 @@ pub mod masking;
pub mod presets;
pub mod profiles;
pub mod right;
pub mod settings;
pub mod strip;
pub mod topbar;

View File

@ -214,20 +214,59 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.add_space(4.0);
ui.label(egui::RichText::new("Paint over a distraction on the photo to remove it.").color(Tokens::get(ui.ctx()).text_dim));
});
let spec = lightcraft_develop::ControlSpec {
id: "ui.removeSize",
label: "Size",
// brush settings; with a spot selected they edit that spot too
let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone())));
if let Some((i, sp)) = &sel {
(app.ui.remove_size, app.ui.remove_feather, app.ui.remove_opacity) = (sp.size as f32, sp.feather as f32, sp.opacity as f32);
divider(ui);
let mode = match sp.mode {
lightcraft_develop::SpotMode::Heal => "Heal",
lightcraft_develop::SpotMode::Clone => "Clone",
lightcraft_develop::SpotMode::Remove => "Remove",
};
super::edit::sub_title(ui, &format!("{mode} spot {} of {}", i + 1, d.spots.len()));
}
let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec {
id,
label,
section: lightcraft_develop::Section::Detail,
min: 1.0,
max: 100.0,
default: 20.0,
min,
max,
default,
step: 1.0,
decimals: 0,
track: lightcraft_develop::Track::Plain,
};
let out = slider(ui, &spec, (app.ui.remove_size * 1000.0) as f64, true, None);
if let Some(v) = out.value {
app.ui.remove_size = (v / 1000.0) as f32;
let sliders = [
(plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64),
(plain("ui.removeFeather", "Feather", 0.0, 100.0, 50.0), "feather", app.ui.remove_feather as f64),
(plain("ui.removeOpacity", "Opacity", 0.0, 100.0, 100.0), "opacity", app.ui.remove_opacity as f64),
];
for (spec, key, v) in sliders {
let out = slider(ui, &spec, v, true, None);
let scale = if key == "size" { 1000.0 } else { 1.0 };
if let Some(v) = out.value {
match key {
"size" => app.ui.remove_size = (v / scale) as f32,
"feather" => app.ui.remove_feather = v as f32,
_ => app.ui.remove_opacity = v as f32,
}
}
if sel.is_some() {
super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("spot.update", json!({key: v / scale})));
}
}
if sel.is_some() {
padded(ui, |ui| {
ui.horizontal(|ui| {
if text_button(ui, "spotRefresh", "Refresh Source (/)", false).clicked() {
let _ = app.run("spot.refreshSource", json!({}));
}
if text_button(ui, "spotDelete", "Delete (⌫)", false).clicked() {
let _ = app.run("spot.delete", json!({}));
}
});
});
}
// Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out
padded(ui, |ui| {

View File

@ -0,0 +1,337 @@
//! The Settings dialog (⌘,): General, Import, Performance, Interface.
//!
//! Changes apply immediately (no OK/Cancel). Where they are stored:
//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU,
//! preview size, filmstrip/grid badges, last library) and Auto Advance live in the UI state,
//! saved by the host in its config folder (`ui.json`);
//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size) go through the
//! `library.preferences` / `library.xmpPreferences` commands into the library's `prefs.json`,
//! so they travel with the library.
use egui::RichText;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::state::{GridBadges, PREVIEW_EDGES, StartupView};
use crate::theme::Tokens;
use crate::widgets::register;
/// (id, label) of the tabs, in order.
pub const TABS: &[(&str, &str)] = &[("general", "General"), ("import", "Import"), ("performance", "Performance"), ("interface", "Interface")];
/// Thumbnail cache sizes offered (MB).
const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192];
const LABEL_W: f32 = 150.0;
/// The dialog body for `tab` (the tab bar switches `tab`).
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) {
let t = Tokens::get(ui.ctx());
ui.set_min_width(560.0);
ui.set_min_height(330.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for (id, label) in TABS {
if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() {
*tab = id.to_string();
}
}
});
ui.separator();
ui.add_space(2.0);
match tab.as_str() {
"import" => import_tab(app, ui, &t),
"performance" => performance_tab(app, ui, &t),
"interface" => interface_tab(app, ui, &t),
_ => general_tab(app, ui, &t),
}
}
fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
ui.add_space(4.0);
ui.label(RichText::new(text).font(t.semibold(12.5)).color(t.text));
}
fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(LABEL_W);
ui.label(RichText::new(label).color(t.text_label));
});
add(ui)
})
.inner
}
fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) {
ui.label(RichText::new(text).size(11.0).color(t.text_dim));
}
/// A checkbox addressable as `check:{id}`; true when toggled.
fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool {
let r = ui.checkbox(value, label);
register(ui.ctx(), format!("check:{id}"), r.rect);
r.changed()
}
/// Mutually exclusive buttons (`button:{id}-{index}`).
fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool {
let mut changed = false;
ui.spacing_mut().item_spacing.x = 4.0;
for (i, (v, l)) in options.iter().enumerate() {
if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() && *value != *v {
*value = *v;
changed = true;
}
}
changed
}
// ------------------------------------------------------------------------------------- General
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, "Library");
let location = match &app.session.library {
Some(l) => l.dir.display().to_string(),
None => "In memory — nothing is saved".to_string(),
};
row(ui, t, "Location", |ui| {
ui.label(RichText::new(location).color(t.text));
});
row(ui, t, "", |ui| {
let can = app.services.pick_folder.is_some();
let r = ui.add_enabled(can, egui::Button::new("Open Library…"));
register(ui.ctx(), "button:settingsOpenLibrary", r.rect);
if r.clicked() {
let _ = app.run("app.openLibrary", json!({}));
}
if !can {
hint(ui, t, "not available here");
}
});
heading(ui, t, "Startup");
row(ui, t, "Open in", |ui| {
choices(
ui,
"settingsStartup",
&[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail")],
&mut app.ui.settings.startup_view,
);
});
heading(ui, t, "Culling");
check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging");
check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted");
}
// -------------------------------------------------------------------------------------- Import
/// A preset picker: `None` = `none_label`. Returns the new choice when it changed.
fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Option<&str>, none_label: &str) -> Option<Option<String>> {
let name = |pid: &str| app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| format!("{pid} (missing)"));
let text = current.map(name).unwrap_or_else(|| none_label.to_string());
let mut out = None;
let r = egui::ComboBox::from_id_salt(id).width(240.0).selected_text(text).show_ui(ui, |ui| {
if ui.selectable_label(current.is_none(), none_label).clicked() {
out = Some(None);
}
let mut group = "";
for p in &app.session.presets {
if p.group != group {
group = &p.group;
ui.label(RichText::new(group).size(10.5).weak());
}
if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() {
out = Some(Some(p.id.clone()));
}
}
});
register(ui.ctx(), format!("combo:{id}"), r.response.rect);
out.filter(|v| v.as_deref() != current)
}
/// Cameras of the raws in the library plus those with a stored default, sorted.
fn cameras(app: &LightcraftApp) -> Vec<String> {
let mut v: Vec<String> = app
.session
.catalog
.photos()
.filter(|p| p.kind == lightcraft_catalog::MediaKind::Raw && !p.meta.camera.is_empty())
.map(|p| p.meta.camera.clone())
.chain(app.session.import_defaults.cameras.iter().map(|c| c.camera.clone()))
.collect();
v.sort_by_key(|c| c.to_lowercase());
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
v
}
fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let d = app.session.import_defaults.clone();
heading(ui, t, "Raw defaults");
hint(ui, t, "Settings new raw photos start from. Changing them doesn't touch photos already in the library.");
row(ui, t, "Raw photos", |ui| {
if let Some(v) = preset_combo(app, ui, "settingsRawPreset", d.raw_preset.as_deref(), "LightCraft Default") {
let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}}));
}
});
let mut per = d.per_camera;
row(ui, t, "", |ui| {
if check(ui, "settings.perCamera", &mut per, "Use camera-specific defaults") {
let _ = app.run("library.preferences", json!({"import": {"perCamera": per}}));
}
});
if per {
let cams = cameras(app);
if cams.is_empty() {
hint(ui, t, "No raw photos yet: cameras appear here once their photos are in the library.");
}
egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| {
for (i, cam) in cams.iter().enumerate() {
let entry = d.cameras.iter().find(|c| c.camera.eq_ignore_ascii_case(cam));
row(ui, t, cam, |ui| {
// "Raw default" = no entry; otherwise the entry's preset (None = LightCraft Default)
const RAW_DEFAULT: &str = "\u{1}raw";
let current = match entry {
None => Some(RAW_DEFAULT),
Some(e) => e.preset.as_deref(),
};
let mut pick = None;
let label = match current {
Some(RAW_DEFAULT) => "Same as raw default".to_string(),
None => "LightCraft Default".to_string(),
Some(pid) => app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| pid.to_string()),
};
let id = format!("settingsCamera-{i}");
let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| {
if ui.selectable_label(current == Some(RAW_DEFAULT), "Same as raw default").clicked() {
pick = Some(json!({"camera": cam, "remove": true}));
}
if ui.selectable_label(current.is_none(), "LightCraft Default").clicked() {
pick = Some(json!({"camera": cam, "preset": null}));
}
for p in &app.session.presets {
if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() {
pick = Some(json!({"camera": cam, "preset": p.id}));
}
}
});
register(ui.ctx(), format!("combo:{id}"), r.response.rect);
if let Some(c) = pick {
let _ = app.run("library.preferences", json!({"camera": c}));
}
});
}
});
}
heading(ui, t, "Other images (JPEG, PNG, TIFF, HEIC…)");
row(ui, t, "Non-raw photos", |ui| {
if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") {
let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}}));
}
});
heading(ui, t, "XMP sidecars");
let mut xmp = app.session.xmp;
if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") {
let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write}));
}
row(ui, t, "Sidecar names", |ui| {
use lightcraft_engine::sidecar::SidecarNaming as N;
let mut n = xmp.naming;
if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) {
let naming = if n == N::Full { "full" } else { "stem" };
let _ = app.run("library.xmpPreferences", json!({"naming": naming}));
}
});
if app.session.library.is_none() {
hint(ui, t, "In-memory session: these settings last until LightCraft quits.");
}
}
// --------------------------------------------------------------------------------- Performance
fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
use lightcraft_engine::gpu;
heading(ui, t, "Rendering");
check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering");
let status = if !gpu::available() {
"No usable GPU found: rendering on the CPU".to_string()
} else {
format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into()))
};
hint(ui, t, &status);
row(ui, t, "Preview size", |ui| {
let opts: Vec<(u32, String)> = PREVIEW_EDGES.iter().map(|e| (*e, format!("{e} px"))).collect();
let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect();
choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge);
});
hint(ui, t, "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.");
row(ui, t, "Memory for caches", |ui| {
let auto = format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20);
let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")];
choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb);
});
heading(ui, t, "Thumbnail cache");
let cur = (app.session.cache_bytes() >> 20) as u32;
row(ui, t, "Size limit", |ui| {
let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")];
let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 };
if choices(ui, "settingsCache", &opts, &mut v) {
let _ = app.run("library.preferences", json!({"cacheMb": v}));
}
});
let used = app.session.media.rendered.disk().map(|d| d.size());
row(ui, t, "In use", |ui| {
ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text));
let r = ui.button("Clear Cache");
register(ui.ctx(), "button:settingsClearCache", r.rect);
if r.clicked() {
let _ = app.run("library.clearPreviews", json!({}));
}
});
}
// ---------------------------------------------------------------------------------- Interface
fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, "Filmstrip");
check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names");
check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges");
heading(ui, t, "Grid");
row(ui, t, "Ratings & flags", |ui| {
choices(
ui,
"settingsGridBadges",
&[(GridBadges::Auto, "When rated or hovered"), (GridBadges::Always, "Always"), (GridBadges::Never, "Never")],
&mut app.ui.settings.grid_badges,
);
});
check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats");
heading(ui, t, "Detail");
check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in");
row(ui, t, "Info overlay", |ui| {
use crate::state::InfoOverlay as I;
choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay);
});
}
// ------------------------------------------------------------------------------- Open Library…
/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`,
/// or a folder chosen in a dialog. Remembered as the library to open at launch.
pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => x.to_string(),
None => match app.services.pick_folder.as_mut() {
Some(pick) => match pick() {
Some(x) => x,
None => return Ok(Value::Null),
},
None => return Err("no folder dialog on this platform".into()),
},
};
app.session.close_library().map_err(|e| e.to_string())?;
app.session.open_library(&path, false).map_err(|e| e.to_string())?;
app.renderer.forget_all();
app.ui.compare = None;
app.ui.settings.library_path = path.clone();
Ok(json!({"path": path, "photos": app.session.catalog.len()}))
}

View File

@ -211,6 +211,16 @@ impl Renderer {
}
/// Is a request for `slot` queued or running?
/// Drop every texture and cached stage (another library was opened: photo ids changed meaning).
pub fn forget_all(&mut self) {
self.textures.clear();
self.stages.clear();
self.quick_tried.clear();
self.prefetched.clear();
self.pending.clear();
self.queue.clear();
}
pub fn is_pending(&self, slot: Slot) -> bool {
self.pending.contains_key(&slot)
}

View File

@ -15,6 +15,9 @@ pub const ALIASES: &[(&str, &str, &str)] = &[
("Shift+M", "version.create", "{}"),
("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#),
("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#),
// Shift+[ / Shift+] arrive as { / } on most layouts
("Shift+{", "brush.featherLess", "{}"),
("Shift+}", "brush.featherMore", "{}"),
];
pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
@ -41,6 +44,7 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
"[" => Some(Key::OpenBracket),
"]" => Some(Key::CloseBracket),
"'" => Some(Key::Quote),
"," => Some(Key::Comma),
_ => None,
})
}
@ -156,6 +160,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
_ => app.toast(ctx, "Unflagged"),
}
} else {
// in the full-screen preview (no panels) I cycles the info overlay instead
if f == "panel.info" && app.ui.fullscreen {
let _ = app.run("view.infoOverlay", json!({}));
continue;
}
// Delete acts on what's being edited: the active mask in the Masking panel; never the
// photo while retouching (spots are removed from their own panel).
if f == "photo.delete" {
@ -167,9 +176,18 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
}
continue;
}
R::Remove | R::RedEye => continue,
R::Remove => {
if app.session.active_spot.is_some() {
let _ = app.run("spot.delete", json!({}));
}
continue;
}
R::RedEye => continue,
_ => {}
}
if crate::menus::confirm_delete(app) {
continue;
}
}
// X is both reject (library) and swap crop aspect (crop tool)
if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop {
@ -179,6 +197,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop {
continue;
}
// / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere)
if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() {
let _ = app.run("spot.refreshSource", json!({}));
continue;
}
// Shift+O cycles the mask overlay mode while masking (the crop overlay elsewhere)
if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking {
let _ = app.run("view.maskOverlayMode", json!({}));
continue;
}
let _ = app.run(&f, json!({}));
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),

View File

@ -77,6 +77,94 @@ pub enum CropOverlay {
None,
}
/// Which view the app opens in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StartupView {
/// Whatever was showing at quit.
#[default]
Last,
Grid,
Detail,
}
/// When grid cells show their rating / flag / edited badges.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GridBadges {
/// On hover, on the selection, and on rated or flagged photos.
#[default]
Auto,
Always,
Never,
}
/// The loupe's info overlay (Cmd+I cycles; I in the full-screen preview).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum InfoOverlay {
#[default]
Off,
/// File name, capture date and dimensions.
Basic,
/// File name, camera, lens and exposure (shutter, aperture, ISO, focal length).
Exposure,
}
impl InfoOverlay {
pub fn next(self) -> InfoOverlay {
match self {
InfoOverlay::Off => InfoOverlay::Basic,
InfoOverlay::Basic => InfoOverlay::Exposure,
InfoOverlay::Exposure => InfoOverlay::Off,
}
}
}
/// App-level preferences (Settings dialog). They belong to the app, not a library, and are
/// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library
/// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AppSettings {
/// Library opened at launch when no `--library` is given (empty = the default location).
pub library_path: String,
pub startup_view: StartupView,
/// Ask before moving photos to Recently Deleted (keyboard and menu).
pub confirm_delete: bool,
/// GPU rendering allowed (`app.gpu`).
pub gpu: bool,
/// Largest long edge (pixels) the loupe renders at.
pub preview_edge: u32,
/// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`).
pub memory_mb: u32,
/// Filmstrip: file names above the thumbnails.
pub film_names: bool,
/// Filmstrip: rating / flag / edited badges on the thumbnails.
pub film_badges: bool,
/// Grid: when to show the rating / flag / edited badges.
pub grid_badges: GridBadges,
}
impl Default for AppSettings {
fn default() -> Self {
AppSettings {
library_path: String::new(),
startup_view: StartupView::Last,
confirm_delete: false,
gpu: true,
preview_edge: 2560,
memory_mb: 0,
film_names: true,
film_badges: true,
grid_badges: GridBadges::Auto,
}
}
}
/// Preview sizes offered in Settings → Performance.
pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120];
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
@ -101,7 +189,13 @@ pub struct UiState {
pub single_panel: bool,
pub show_clipping: bool,
pub histogram: bool,
/// Masking: show the selected mask as a rendered overlay (O), how (`MaskView` name, ⇧O cycles),
/// in which colour and opacity (0..100, colour views), and whether pins are drawn.
pub mask_overlay: bool,
pub mask_overlay_mode: String,
pub mask_overlay_color: [u8; 3],
pub mask_overlay_opacity: f32,
pub mask_pins: bool,
pub crop_overlay: CropOverlay,
pub show_filenames: bool,
pub search: String,
@ -117,7 +211,12 @@ pub struct UiState {
pub brush_feather: f32,
pub brush_flow: f32,
pub brush_erase: bool,
/// Brush Auto Mask: dabs stick to areas like the one under the brush centre.
pub brush_auto_mask: bool,
/// Remove tool brush: size (fraction of the long edge), feather and opacity (0..100).
pub remove_size: f32,
pub remove_feather: f32,
pub remove_opacity: f32,
/// Selected Point Color sample.
pub point_color: usize,
/// Point Color "Visualize range": the selected sample's range in colour, the rest grey.
@ -132,6 +231,17 @@ pub struct UiState {
pub filter_bar: bool,
/// Culling: after a rating, flag or colour-label key, move to the next photo.
pub auto_advance: bool,
/// Full-screen preview (F): the photo alone on black, no chrome.
pub fullscreen: bool,
/// Info overlay on the loupe.
pub info_overlay: InfoOverlay,
/// Navigator mini map in the loupe while zoomed in.
pub navigator: bool,
/// App preferences (Settings dialog).
pub settings: AppSettings,
/// Window full screen requested (⇧⌘F); the host applies it (`None` = no change pending).
#[serde(skip)]
pub window_fullscreen: Option<bool>,
/// Compare view: (select, candidate) photo ids.
#[serde(skip)]
pub compare: Option<(u64, u64)>,
@ -230,6 +340,14 @@ pub enum Dialog {
Merge {
opts: crate::merge::MergeDialog,
},
/// Settings (preferences): `tab` = general | import | performance | interface.
Settings {
tab: String,
},
/// Confirm moving photos to Recently Deleted.
ConfirmDelete {
count: usize,
},
About,
Shortcuts,
}
@ -253,6 +371,10 @@ impl Default for UiState {
show_clipping: false,
histogram: true,
mask_overlay: true,
mask_overlay_mode: "color".into(),
mask_overlay_color: [230, 30, 40],
mask_overlay_opacity: 50.0,
mask_pins: true,
crop_overlay: CropOverlay::Thirds,
show_filenames: true,
search: String::new(),
@ -264,7 +386,10 @@ impl Default for UiState {
brush_feather: 50.0,
brush_flow: 60.0,
brush_erase: false,
brush_auto_mask: false,
remove_size: 0.02,
remove_feather: 50.0,
remove_opacity: 100.0,
point_color: 0,
point_color_visualize: false,
eye: 0,
@ -272,6 +397,11 @@ impl Default for UiState {
visualize_spots: false,
spots_threshold: 50.0,
auto_advance: false,
fullscreen: false,
info_overlay: InfoOverlay::Off,
navigator: true,
settings: AppSettings::default(),
window_fullscreen: None,
filter_bar: false,
compare: None,
toast: None,
@ -310,6 +440,15 @@ impl UiState {
self.thumb_size = self.thumb_size.clamp(90.0, 480.0);
self.brush_size = self.brush_size.clamp(0.002, 0.5);
self.dialog = None;
self.fullscreen = false;
if !crate::state::PREVIEW_EDGES.contains(&self.settings.preview_edge) {
self.settings.preview_edge = AppSettings::default().preview_edge;
}
match self.settings.startup_view {
StartupView::Last => {}
StartupView::Grid => self.view = ViewMode::PhotoGrid,
StartupView::Detail => self.view = ViewMode::Detail,
}
self
}
}

View File

@ -0,0 +1,170 @@
//! Headless tests of the Masking and Remove tools on the photo: overlay keys, pins, spot editing.
use std::time::Duration;
use lightcraft_develop::MaskShape;
use serde_json::json;
use crate::headless::Headless;
use crate::state::RightPanel;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn detail(panel: &str) -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
let r = h.request("ui.set", json!({"view": "detail"}), T);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("engine.execute", json!({"command": panel}), T);
assert_eq!(r["ok"], true, "{r}");
h
}
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
r["result"].clone()
}
fn pointer(h: &mut Headless, events: serde_json::Value) {
let r = h.request("ui.pointer", json!({"events": events}), T);
assert_eq!(r["ok"], true, "{r}");
}
fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings {
let id = h.app.session.active().expect("active photo");
(*h.app.session.develop_of(id).unwrap_or_default()).clone()
}
#[test]
fn mask_overlay_keys_and_pins() {
use lightcraft_pipeline::{MaskView, Overlay};
let mut h = detail("panel.masking");
assert_eq!(h.app.ui.right, RightPanel::Masking);
exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.4], "rx": 0.1, "ry": 0.1}));
exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.7, 0.2], "end": [0.7, 0.6]}));
assert_eq!(h.app.session.active_mask, Some(2));
// the loupe asks the renderer for the selected mask's overlay
let d = develop(&h);
let o = crate::panels::detail::view_overlay(&h.app, &d);
assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 });
// O toggles it, Shift+O cycles the mode (and leaves the crop overlay alone)
h.request("ui.key", json!({"key": "o"}), T);
assert!(!h.app.ui.mask_overlay);
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None);
h.request("ui.key", json!({"key": "o"}), T);
let crop = h.app.ui.crop_overlay;
h.request("ui.key", json!({"key": "o", "shift": true}), T);
assert_eq!(h.app.ui.mask_overlay_mode, "colorOnBw");
assert_eq!(h.app.ui.crop_overlay, crop);
exec(&mut h, "view.maskOverlayMode", json!({"mode": "whiteOnBlack"}));
exec(&mut h, "view.maskOverlayColor", json!({"color": "#2870f0", "opacity": 80}));
assert_eq!((h.app.ui.mask_overlay_color, h.app.ui.mask_overlay_opacity), ([0x28, 0x70, 0xf0], 80.0));
let r = h.request("engine.execute", json!({"command": "view.maskOverlayMode", "params": {"mode": "nope"}}), T);
assert_eq!(r["ok"], false, "{r}");
// clicking another mask's pin selects that mask
pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "up", "x": 0.3, "y": 0.4}]));
assert_eq!(h.app.session.active_mask, Some(1));
// dragging a pin moves its component (one undo step)
pointer(
&mut h,
json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "drag", "x": 0.35, "y": 0.45}, {"kind": "drag", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}]),
);
let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") };
assert!((center.x - 0.5).abs() < 0.02 && (center.y - 0.6).abs() < 0.02, "{center:?}");
// dragging the linear gradient's pin (a non-selected mask) selects and moves it
pointer(
&mut h,
json!([{"kind": "down", "x": 0.7, "y": 0.4}, {"kind": "drag", "x": 0.72, "y": 0.4}, {"kind": "drag", "x": 0.8, "y": 0.4}, {"kind": "up", "x": 0.8, "y": 0.4}]),
);
assert_eq!(h.app.session.active_mask, Some(2));
let MaskShape::Linear { start, end } = develop(&h).masks[1].components[0].shape.clone() else { panic!("linear") };
assert!((start.x - 0.8).abs() < 0.02 && (end.x - 0.8).abs() < 0.02 && (start.y - 0.2).abs() < 0.02, "{start:?} {end:?}");
// hidden pins can't be grabbed
exec(&mut h, "view.maskPins", json!({"show": false}));
pointer(&mut h, json!([{"kind": "down", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}]));
assert_eq!(h.app.session.active_mask, Some(2));
h.settle(SETTLE);
}
#[test]
fn brush_strokes_carry_auto_mask() {
let mut h = detail("panel.masking");
let r = h.request("ui.set", json!({"brushAutoMask": true}), T);
assert_eq!(r["ok"], true, "{r}");
exec(&mut h, "tool.brush", json!({}));
assert_eq!(h.app.ui.tool, "brush");
pointer(
&mut h,
json!([{"kind": "down", "x": 0.3, "y": 0.5}, {"kind": "drag", "x": 0.4, "y": 0.5}, {"kind": "drag", "x": 0.5, "y": 0.5}, {"kind": "up", "x": 0.5, "y": 0.5}]),
);
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert!(strokes[0].auto_mask && strokes[0].points.len() >= 2, "{strokes:?}");
h.settle(SETTLE);
}
#[test]
fn remove_spots_by_pointer_and_keyboard() {
let mut h = detail("panel.remove");
assert_eq!(h.app.ui.right, RightPanel::Remove);
let spots = |h: &Headless| develop(h).spots;
// paint a spot: it's added with the brush's size/feather/opacity and selected
let r = h.request("ui.set", json!({"removeFeather": 30.0, "removeOpacity": 80.0}), T);
assert_eq!(r["ok"], true, "{r}");
pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}]));
assert_eq!(spots(&h).len(), 1);
assert_eq!((spots(&h)[0].feather, spots(&h)[0].opacity), (30.0, 80.0));
assert_eq!(h.app.session.active_spot, Some(0));
// [ / ] resize the selected spot, Shift+[ / Shift+] feather it
let s0 = spots(&h)[0].size;
h.request("ui.key", json!({"key": "]"}), T);
assert!(spots(&h)[0].size > s0 * 1.1, "{} vs {s0}", spots(&h)[0].size);
h.request("ui.key", json!({"key": "["}), T);
h.request("ui.key", json!({"key": "["}), T);
assert!(spots(&h)[0].size < s0 * 0.9);
h.request("ui.key", json!({"key": "[", "shift": true}), T);
assert_eq!(spots(&h)[0].feather, 20.0);
h.request("ui.key", json!({"key": "]", "shift": true}), T);
assert_eq!(spots(&h)[0].feather, 30.0);
// / picks another source (and leaves the filmstrip alone)
let (src, film) = (spots(&h)[0].source_offset, h.app.ui.filmstrip);
h.request("ui.key", json!({"key": "/"}), T);
assert_ne!(spots(&h)[0].source_offset, src);
assert_eq!(h.app.ui.filmstrip, film);
// a second spot elsewhere; clicking the first one's pin selects it
pointer(&mut h, json!([{"kind": "down", "x": 0.7, "y": 0.3}, {"kind": "up", "x": 0.7, "y": 0.3}]));
assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(1)));
pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}]));
assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(0)));
// drag its target: it moves, its source offset stays
let src = spots(&h)[0].source_offset;
pointer(
&mut h,
json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "drag", "x": 0.32, "y": 0.6}, {"kind": "drag", "x": 0.4, "y": 0.6}, {"kind": "up", "x": 0.4, "y": 0.6}]),
);
let sp = spots(&h)[0].clone();
assert!((sp.points[0].x - 0.4).abs() < 0.01 && (sp.points[0].y - 0.6).abs() < 0.01, "{:?}", sp.points);
assert_eq!(sp.source_offset, src);
// drag its source to a fixed place
let o = sp.source_offset.unwrap();
let (sx, sy) = (sp.points[0].x + o.x, sp.points[0].y + o.y);
pointer(
&mut h,
json!([{"kind": "down", "x": sx, "y": sy}, {"kind": "drag", "x": sx + 0.01, "y": sy}, {"kind": "drag", "x": 0.5, "y": 0.8}, {"kind": "up", "x": 0.5, "y": 0.8}]),
);
let sp = spots(&h)[0].clone();
let o = sp.source_offset.unwrap();
assert!((sp.points[0].x + o.x - 0.5).abs() < 0.01 && (sp.points[0].y + o.y - 0.8).abs() < 0.01, "{o:?}");
// ⌫ deletes the selected spot, not the photo
let photos = h.app.session.visible_cloned().len();
h.request("ui.key", json!({"key": "delete"}), T);
assert_eq!((spots(&h).len(), h.app.session.active_spot), (1, None));
assert_eq!(h.app.session.visible_cloned().len(), photos);
h.request("ui.key", json!({"key": "delete"}), T);
assert_eq!(spots(&h).len(), 1, "nothing selected: nothing deleted");
h.settle(SETTLE);
}

View File

@ -85,7 +85,7 @@ During development you can also point the client at `cargo run --release -p ligh
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle |
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
| `export {path, id?, longEdge?, quality?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) |
| `export {path, id?, longEdge?, quality?, colorSpace?, bitDepth?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) |
Tools taking `id` make that photo active first; without it they act on the active photo.

View File

@ -21,17 +21,17 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
<!-- parity:summary -->
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|---|---:|---:|---:|---:|---:|---:|
| A. Import (IMP) | 2 | 4 | 7 | 1 | 1/4 (25%) | 1/4 (25%) |
| B. Library management (LIB) | 12 | 4 | 9 | 2 | 7/9 (78%) | 4/9 (44%) |
| C. Views & navigation (VIEW) | 9 | 2 | 6 | 0 | 7/9 (78%) | 1/4 (25%) |
| A. Import (IMP) | 5 | 3 | 5 | 1 | 2/4 (50%) | 2/4 (50%) |
| B. Library management (LIB) | 16 | 2 | 7 | 2 | 8/9 (89%) | 7/9 (78%) |
| C. Views & navigation (VIEW) | 13 | 1 | 3 | 0 | 8/9 (89%) | 4/4 (100%) |
| D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) |
| E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) |
| F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) |
| G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) |
| H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) |
| I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) |
| I. Remove / healing (REM) | 5 | 1 | 4 | 2 | 4/4 (100%) | 1/3 (33%) |
| J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) |
| K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) |
| K. Masking (MASK) | 10 | 6 | 7 | 0 | 7/8 (88%) | 3/5 (60%) |
| L. Presets (PRE) | 3 | 2 | 2 | 1 | 2/2 (100%) | 1/2 (50%) |
| M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) |
| N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) |
@ -39,39 +39,27 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
| Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — |
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
| S. Export (EXP) | 4 | 6 | 8 | 0 | 2/7 (29%) | 2/7 (29%) |
| S. Export (EXP) | 6 | 6 | 6 | 0 | 3/7 (43%) | 3/7 (43%) |
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 0 | 3 | 5 | 3 | 0/1 (0%) | 0/4 (0%) |
| V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 10 | 3 | 4 | 1 | 7/8 (88%) | 3/5 (60%) |
| Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) |
| Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) |
| Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) |
| **Total** | 213 | 101 | 186 | 37 | 155/198 (78%) | 49/144 (34%) |
| Y. Menus | 53 | 17 | 15 | 8 | 38/47 (81%) | 13/23 (57%) |
| Z. Keyboard shortcuts (desktop) | 53 | 14 | 13 | 1 | 44/52 (85%) | 9/23 (39%) |
| Lightroom Classic extras | 4 | 20 | 55 | 9 | — | 3/21 (14%) |
| **Total** | 250 | 87 | 163 | 37 | 171/198 (86%) | 69/144 (48%) |
<!-- /parity:summary -->
## Top gaps
Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on.
1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible /
Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort.
2. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask
kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort.
3. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local
Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort.
4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫,
feather/opacity sliders, `[`/`]` size keys. Medium effort.
7. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
XMP prefs, cache, GPU. Medium effort.
9. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
1. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
`crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi.
10. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
2. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
permissive pure-Rust decoder yet). High value, high effort (clean sources needed).
11. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
13. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
14. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
3. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
## Shortcuts: conflicts and missing bindings
@ -100,10 +88,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
**Still missing / broken:**
- No command yet: F full-screen preview, ⇧⌘F window full screen, ⇧⌘V paste selected,
⌘, settings, ⌘F focus search, ⌘G / ⇧⌘G stacks, A visualize spots, `[` `]` / ⇧`[` ⇧`]`
brush size/feather, ⌃H / ⌃M merges, F1 help, ⇧6–9 label + advance.
- `⌫` in the Masking panel deletes the active mask; spots have no pin selection yet (⌫ does nothing in Remove).
- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — no pin toggle yet.
⌘, settings, ⌘F focus search, ⌘G / ⇧⌘G stacks, A visualize spots, ⌃H / ⌃M merges, F1 help,
⇧6–9 label + advance.
- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins.
- ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified).
<!-- Sections below hold one row per id. Keep the column order: Id | Feature | Tier | Status | Evidence | Notes. -->
@ -118,7 +105,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-DEVICE | Import from camera/card | P1 | ⬜ | | no device detection |
| LR-IMP-AUTO | Watched-folder auto import | P2 | ⬜ | | |
| 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 | ⬜ | `crates/engine/src/import.rs` | raws get embedded lens corrections on import; no user raw-default setting |
| 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` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | 🟡 | `cmd:library.import` (mode add), `cmd:library.importPreview`, `crates/engine/src/sidecar.rs`, `cmd:library.toggleAutoWriteXmp` | files referenced in place with XMP sidecars; the import review shows a folder's photos before adding (“Add in place” or “Copy into library”); no browse-a-folder-without-adding view in the main grid |
@ -167,14 +154,14 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid |
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | |
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ⬜ | | |
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip` | |
| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar |
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ⬜ | | |
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
| LR-VIEW-BEFOREAFTER | Before / after | P0 | 🟡 | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | all four layouts; "before" is always the original (no before-state from history) |
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle |
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes |
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ⬜ | | |
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) |
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ⬜ | | |
| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | |
| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | |
@ -303,8 +290,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-REM-CLONE | Clone | P0 | ✅ | `cmd:spot.add` (`mode: clone`) | |
| LR-REM-GEN | Generative remove | OOS | 🚫 | | |
| LR-REM-DETECT | Object detection for remove | P2 | ⬜ | | |
| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | 🟡 | `cmd:spot.add` (`size`, `feather`, `opacity`), `crates/ui-egui/src/panels/right.rs` | UI has size only; no `[` `]` keys |
| LR-REM-SPOT-EDIT | Edit existing spots | P0 | 🟡 | `cmd:spot.delete` | no pin selection, no moving target/source, ⌫ deletes the photo instead |
| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | ✅ | `cmd:spot.add` (`size`, `feather`, `opacity`), `cmd:brush.smaller`, `cmd:brush.larger`, `cmd:brush.featherLess`, `cmd:brush.featherMore`, `crates/ui-egui/src/panels/right.rs` | Size / Feather / Opacity sliders (for new spots and the selected one); `[` `]` size, ⇧`[` ⇧`]` feather (also the Masking brush) |
| LR-REM-SPOT-EDIT | Edit existing spots | P0 | ✅ | `cmd:spot.select`, `cmd:spot.update`, `cmd:spot.refreshSource`, `cmd:spot.delete` | click a pin to select, drag target or source, ⌫ deletes the selected spot, `/` picks another source; automatic sources are resolved when the spot is added |
| LR-REM-VISUALIZE | Visualize spots | P1 | ⬜ | | |
| LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | |
| LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | |
@ -329,7 +316,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-MASK-OBJECTS | Object selection | P2 | ⬜ | | shape exists (falls back to the subject heuristic); no UI |
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | |
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` | size/feather/flow/density/erase; Auto Mask flag not applied; no A/B brushes, no pressure |
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | |
| LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`) | renders; sampling UX unverified |
@ -339,9 +326,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | |
| LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | |
| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ⬜ | | |
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | 🟡 | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | noise / moiré / defringe stored but not rendered; no local curve or effect presets |
| LR-MASK-OVERLAY | Mask overlay | P0 | 🟡 | `cmd:view.maskOverlay`, `crates/ui-egui/src/panels/detail.rs` | brush dabs + shape outlines only; no rendered alpha, colour or mode options |
| LR-MASK-PINS | Pins | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | handles drawn; no show/hide-pins option |
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets |
| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; no auto-show on hover |
| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode |
| LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | |
| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | |
| LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | |
@ -424,9 +411,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EXP-TYPE | File types | P0 | 🟡 | `crates/engine/src/export.rs` (`ExportFormat`) | JPEG, PNG, TIFF, WebP, AVIF; no DNG, JXL or original (+XMP) |
| LR-EXP-DIM | Output size | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (long edge / full size) | no short edge / width / height / megapixels, ppi, don't-enlarge |
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | |
| LR-EXP-BITDEPTH | Bit depth | P1 | ⬜ | | 8-bit only |
| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
| LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate |
| LR-EXP-COLORSPACE | Output colour space | P0 | ⬜ | `crates/engine/src/export.rs` | sRGB only |
| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) |
| LR-EXP-HDR | HDR output | P2 | ⬜ | | |
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
@ -459,11 +446,11 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-PREF-GENERAL | General settings | P0 | ⬜ | `cmd:library.xmpPreferences` | no settings dialog; only XMP preferences (by command) |
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | 🟡 | `cmd:library.clearPreviews`, `cmd:library.compact` | bounded thumbnail cache; no UI for size/location |
| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs` | General / Import / Performance / Interface tabs; app settings in ui.json, library settings in prefs.json |
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General |
| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | |
| LR-PREF-INTERFACE | Interface options | P1 | ⬜ | | |
| LR-PREF-PERFORMANCE | GPU / performance | P1 | 🟡 | `cmd:app.gpu` | by command only |
| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings |
| LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | |
| LR-PREF-WATERMARK | Watermark settings | P1 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` | per export in the dialog; not saved as a preference |
| LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra |
@ -512,7 +499,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
|---|---|---|---|---|---|
| MENU-BAR | Menu bar rendering | P1 | ✅ | `crates/ui-egui/src/menubar.rs`, `apps/lightcraft/src/native_menu.rs` | native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux; ⌫ and X stay egui-handled (contextual), so they show no key in the native menu |
| MENU-APP-ABOUT | About | P2 | ✅ | `cmd:app.about` | |
| MENU-APP-SETTINGS | Settings… | P0 | ⬜ | | see LR-PREF-GENERAL |
| MENU-APP-SETTINGS | Settings… | P0 | ✅ | `cmd:app.settings` | app menu on macOS, Edit menu elsewhere |
| MENU-APP-UPDATES | Check for updates | P2 | ⬜ | | |
| MENU-APP-SYNC | Sync status / pause | OOS | 🚫 | | |
| MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | | |
@ -546,8 +533,8 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | |
| MENU-VIEW-DETAIL | Detail | P0 | ✅ | `cmd:view.detail` | |
| MENU-VIEW-EDIT | Edit | P0 | ✅ | `cmd:panel.edit` | |
| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ⬜ | | |
| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ⬜ | | |
| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ✅ | `cmd:view.fullScreenPreview` | |
| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ✅ | `cmd:view.enterFullScreen` | |
| MENU-VIEW-PHOTOSPANEL | Show/Hide photos panel | P0 | ✅ | `cmd:view.leftPanel` | |
| MENU-VIEW-FILMSTRIP | Show/Hide filmstrip | P0 | ✅ | `cmd:view.filmstrip` | |
| MENU-VIEW-INFO | Show/Hide info | P0 | ✅ | `cmd:panel.info` | |
@ -555,7 +542,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-VIEW-ACTIVITY | Show/Hide activity (comments) | OOS | 🚫 | | our History panel is `panel.activity` |
| MENU-VIEW-VERSIONS | Show/Hide versions | P1 | ✅ | `cmd:panel.versions` | |
| MENU-VIEW-HISTOGRAM | Show/Hide histogram | P0 | ✅ | `cmd:view.histogram` | |
| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ⬜ | | |
| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ✅ | `cmd:view.infoOverlay` | cycles |
| MENU-VIEW-SHOWORIGINAL | Show Original | P0 | ✅ | `cmd:view.showOriginal` | |
| MENU-VIEW-BEFOREAFTER | Before/After submenu | P0 | ✅ | `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | |
| MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | |
@ -614,7 +601,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-CROP | Crop & rotate — C | P0 | ✅ | `cmd:panel.crop` | |
| KEY-DETAIL | Detail — D | P0 | ✅ | `cmd:view.detail` | |
| KEY-EDIT | Edit — E | P0 | ✅ | `cmd:panel.edit` | |
| KEY-FULLSCREEN | Full-screen preview — F | P1 | ⬜ | | no command |
| KEY-FULLSCREEN | Full-screen preview — F | P1 | ✅ | `cmd:view.fullScreenPreview` | |
| KEY-GRID | Grid — G | P0 | ✅ | `cmd:view.photoGrid` | |
| KEY-INFO | Info — I | P0 | ✅ | `cmd:panel.info` | |
| KEY-KEYWORDS | Keywords — K | P0 | ✅ | `cmd:panel.keywords` | |
@ -628,10 +615,10 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | |
| KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | `cmd:library.selectNone` | secondary binding (primary ⌘⇧A) |
| KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ⬜ | | no command |
| KEY-PREFS | Settings — ⌘, | P0 | ⬜ | | no command |
| KEY-PREFS | Settings — ⌘, | P0 | ✅ | `cmd:app.settings` | |
| KEY-SEARCH | Search — ⌘F | P0 | ⬜ | | no focus-search command |
| KEY-VISUALIZESPOTS | Visualize spots — A | P1 | ⬜ | | |
| KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | 🟡 | `cmd:view.maskOverlay`, `cmd:view.cropOverlay` | O toggles the mask overlay; crop overlays cycle on ⇧O |
| KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.cropOverlay` | O toggles the mask overlay; ⇧O cycles mask overlay modes while masking, crop overlays elsewhere |
| KEY-PHOTOSPANEL | Photos panel — P | P0 | 🟡 | `cmd:view.leftPanel` | bound to ⌘⇧L; P = pick |
| KEY-LINEAR | Linear gradient — L | P0 | ✅ | `cmd:tool.linear` | |
| KEY-RADIAL | Radial gradient — R | P0 | ✅ | `cmd:tool.radial` | |
@ -643,7 +630,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-MASKCOLOR | Cycle mask colour — ⇧O | P1 | ⬜ | | ⇧O cycles crop overlays |
| KEY-EXPORTPREV | Export with previous — ⌘E | P0 | 🟡 | `cmd:app.exportPrevious` | bound to ⌥⇧⌘E (Classic); ⌘E not bound |
| KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | `cmd:dialog.export` | secondary binding (primary ⌘⇧E) |
| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ⬜ | | |
| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ✅ | `cmd:view.enterFullScreen` | |
| KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup` | also S expand/collapse, ⇧S top of stack |
| KEY-GUIDEDUPRIGHT | Guided Upright — ⇧G | P1 | 🟡 | `cmd:geometry.upright` | button in the crop panel; ⇧G = Square Grid |
| KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | 🟡 | | platform default (unverified) |
@ -656,8 +643,8 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | 🟡 | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | bound to ⌘⌥1–5 |
| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | |
| KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | `cmd:view.histogram` | bound to ⌘⇧H; ⌘0 = zoom to fit |
| KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ⬜ | | brush size is UI state, no command |
| KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ⬜ | | |
| KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ✅ | `cmd:brush.smaller`, `cmd:brush.larger` | Masking brush and Remove tool (and its selected spot) |
| KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ✅ | `cmd:brush.featherLess`, `cmd:brush.featherMore` | |
| KEY-BRUSH | Brush — B | P0 | ✅ | `cmd:tool.brush` | |
| KEY-HEAL | Remove / heal — H | P0 | ✅ | `cmd:panel.remove` | |
| KEY-MERGE | HDR / panorama merges — ⌃H ⇧⌃H ⌃M ⇧⌃M | P2 | ⬜ | | |
@ -690,7 +677,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back` | |
| KEY-COMMIT | Commit tool — Return | P1 | 🟡 | | edits apply live; no explicit commit step |
| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | 🟡 | `cmd:mask.delete` | ⌫ deletes the active mask in the Masking panel and never the photo while retouching; no spot pin selection yet |
| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ✅ | `cmd:mask.delete`, `cmd:spot.delete` | ⌫ deletes the active mask (Masking) or the selected spot (Remove), never the photo while retouching |
| KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel |
## Lightroom Classic extras