diff --git a/Cargo.lock b/Cargo.lock index e6d6f3f3..eab75aa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,15 @@ dependencies = [ "winit", ] +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -519,6 +528,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -899,6 +923,22 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dhat" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827" +dependencies = [ + "backtrace", + "lazy_static", + "mintex", + "parking_lot", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "thousands", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1402,6 +1442,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gl_generator" version = "0.14.0" @@ -1975,6 +2021,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.189" @@ -2052,6 +2104,7 @@ dependencies = [ name = "lightcraft-cli" version = "0.1.0" dependencies = [ + "dhat", "lightcraft-codecs", "lightcraft-engine", "lightcraft-mcp", @@ -2208,6 +2261,7 @@ dependencies = [ "proptest", "rayon", "serde", + "serde_json", ] [[package]] @@ -2411,6 +2465,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mintex" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" + [[package]] name = "moxcms" version = "0.8.1" @@ -2938,6 +2998,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3540,6 +3609,12 @@ version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3953,6 +4028,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "tiff" version = "0.11.3" diff --git a/Cargo.toml b/Cargo.toml index 03888394..bdadda7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" siphasher = "1" libc = "0.2" # dev-only: process CPU time in benches +dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds # formats quick-xml = "0.38" diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml index 8bb6e114..36c37c32 100644 --- a/apps/lightcraft-cli/Cargo.toml +++ b/apps/lightcraft-cli/Cargo.toml @@ -21,3 +21,10 @@ lightcraft-codecs = { workspace = true } lightcraft-raster = { workspace = true } lightcraft-merge = { workspace = true } serde_json = { workspace = true } +dhat = { workspace = true, optional = true } + +[features] +# Heap profiling: `cargo run --release -p lightcraft-cli --features dhat-heap -- snapshot …` counts +# every allocation (live/peak bytes in `library.memory` / `ui.inspect`) and writes dhat-heap.json +# (open it in DHAT's viewer: allocation sites by bytes at the peak). Slows allocation-heavy work. +dhat-heap = ["dep:dhat"] diff --git a/apps/lightcraft-cli/src/alloc_release.rs b/apps/lightcraft-cli/src/alloc_release.rs new file mode 100644 index 00000000..0d0775ba --- /dev/null +++ b/apps/lightcraft-cli/src/alloc_release.rs @@ -0,0 +1,32 @@ +//! Give memory the system allocator keeps after frees back to the system +//! ([`lightcraft_engine::memory::set_release_hook`]). +//! +//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the +//! process's memory footprint but stay in its resident size until the system runs short. After a +//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of +//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now. +//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do. + +/// Install the hook for this platform. +pub fn install() { + #[cfg(target_os = "macos")] + lightcraft_engine::memory::set_release_hook(macos::release); +} + +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod macos { + unsafe extern "C" { + /// libSystem malloc: release free memory of `zone` (all zones when null) back to the + /// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released. + fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize; + } + + pub fn release() { + // SAFETY: a documented libSystem function taking a null zone (= every zone) and a size; + // it only returns free pages to the system and touches no memory we own. + unsafe { + malloc_zone_pressure_relief(std::ptr::null_mut(), 0); + } + } +} diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 0df0a917..28c88911 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -10,6 +10,8 @@ //! lightcraft-cli controls [--json] //! ``` +mod alloc_release; + use std::io::{BufReader, Write}; use std::path::Path; use std::process::ExitCode; @@ -62,7 +64,23 @@ USAGE: lightcraft-cli --version | --help "; +#[cfg(feature = "dhat-heap")] +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + fn main() -> ExitCode { + // `--features dhat-heap`: count allocations; the profile is written when `_heap` drops + // (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json). + #[cfg(feature = "dhat-heap")] + let _heap = { + let file = std::env::var("LIGHTCRAFT_DHAT_FILE").unwrap_or_else(|_| "dhat-heap.json".into()); + lightcraft_engine::memory::set_heap_stats(|| { + let s = dhat::HeapStats::get(); + lightcraft_engine::memory::HeapUsage { current: s.curr_bytes as u64, peak: s.max_bytes as u64 } + }); + dhat::Profiler::builder().file_name(file).build() + }; + alloc_release::install(); let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { Some("mcp") => mcp(&args[1..]), @@ -374,7 +392,10 @@ fn snapshot(args: &[String]) -> Result<(), String> { None => Session::new().with_fs(), }; if !files.is_empty() { - session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?; + let ti = Instant::now(); + let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?; + let n = r["imported"].as_array().map_or(0, Vec::len); + eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3); } let services = lightcraft_ui_egui::Services { write: Some(Box::new(|p: &str, b: &[u8]| { @@ -438,6 +459,9 @@ fn snapshot(args: &[String]) -> Result<(), String> { }; if let Some(o) = reply.as_object_mut() { o.insert("id".into(), id); + // wall time of the request (incl. the frames it ran), and since the start + o.insert("ms".into(), json!((ts.elapsed().as_secs_f64() * 1e4).round() / 10.0)); + o.insert("t".into(), json!((t0.elapsed().as_secs_f64() * 1e4).round() / 10.0)); } writeln!(out, "{reply}").map_err(|e| e.to_string())?; if method == "ui.screenshot" { diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 835721c6..84017432 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -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, } } diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 9f282c16..50ae0f72 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -97,7 +97,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option { 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, }; diff --git a/apps/lightcraft/src/alloc_release.rs b/apps/lightcraft/src/alloc_release.rs new file mode 100644 index 00000000..0d0775ba --- /dev/null +++ b/apps/lightcraft/src/alloc_release.rs @@ -0,0 +1,32 @@ +//! Give memory the system allocator keeps after frees back to the system +//! ([`lightcraft_engine::memory::set_release_hook`]). +//! +//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the +//! process's memory footprint but stay in its resident size until the system runs short. After a +//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of +//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now. +//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do. + +/// Install the hook for this platform. +pub fn install() { + #[cfg(target_os = "macos")] + lightcraft_engine::memory::set_release_hook(macos::release); +} + +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod macos { + unsafe extern "C" { + /// libSystem malloc: release free memory of `zone` (all zones when null) back to the + /// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released. + fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize; + } + + pub fn release() { + // SAFETY: a documented libSystem function taking a null zone (= every zone) and a size; + // it only returns free pages to the system and touches no memory we own. + unsafe { + malloc_zone_pressure_relief(std::ptr::null_mut(), 0); + } + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index b2ad7236..6ac150ec 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -12,6 +12,7 @@ //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. //! See `lightcraft_ui_egui::control` for the methods. +mod alloc_release; mod control_server; #[cfg(target_os = "macos")] mod native_menu; @@ -186,6 +187,7 @@ OPTIONS: "; fn main() -> eframe::Result { + alloc_release::install(); let mut control_port: Option = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut seed_demo = true; diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 9bfc1f78..6dcb44ad 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -290,15 +290,26 @@ pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result Result> { 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> { + 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) +} diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs index c6ccff7c..7850e61b 100644 --- a/crates/codecs/src/icc.rs +++ b/crates/codecs/src/icc.rs @@ -146,7 +146,46 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec { }; 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 { + 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> = (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); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 77d06185..da9ea700 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -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; diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 7b805ae2..32ce552f 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -265,10 +265,26 @@ pub fn specs() -> Vec { return Err(bad("develop.profile", format!("unknown profile `{id}`"))); } edit(s, "develop.profile", "Profile", |d| { - d.profile.id = id; + d.profile.id = id.clone(); d.profile.amount = f64_or(p, "amount", d.profile.amount).clamp(0.0, 200.0); Ok(()) - }) + })?; + s.note_profile_used(&id); + ok() + }), + cmd!("profile.favorite", "Favorite Profile", [], None, "{id, favorite?: bool} (toggles when omitted)", always, |s, p| { + let id = str_param(p, "id").ok_or_else(|| bad("profile.favorite", "missing id"))?.to_string(); + if crate::presets::profile(&id).is_none() { + return Err(bad("profile.favorite", format!("unknown profile `{id}`"))); + } + let on = s.profile_favorites.contains(&id); + let want = bool_or(p, "favorite", !on); + if want && !on { + s.profile_favorites.push(id); + } else if !want { + s.profile_favorites.retain(|f| *f != id); + } + Ok(json!({"favorite": want})) }), cmd!("develop.curve", "Set Point Curve", [], None, "{channel: master|red|green|blue, points: [[x,y],...] in 0..1}", has_active, |s, p| { let ch = str_param(p, "channel").unwrap_or("master").to_string(); diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index bbaf0dce..74eec8d5 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -144,7 +144,15 @@ pub fn specs() -> Vec { cmd!(query "presets.list", "List Presets", [], None, "{}", always, |s, _| { Ok(Value::Array(s.presets.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": p.favorite, "builtin": p.builtin})).collect())) }), - cmd!(query "profiles.list", "List Profiles", [], None, "{}", always, |_, _| Ok(serde_json::to_value(crate::presets::PROFILES).unwrap_or_default())), + cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile with its group and favourite flag", always, |s, _| { + Ok(Value::Array( + crate::presets::PROFILES + .iter() + .map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": s.profile_favorites.iter().any(|f| f == p.id)})) + .collect(), + )) + }), + cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())), cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| { let id = photo_arg(s, p, "history.list")?; let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?; @@ -159,6 +167,18 @@ pub fn specs() -> Vec { } Ok(json!({"enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), "adapter": lightcraft_gpu::adapter_name()})) }), + cmd!(query "library.memory", "Memory Usage", [], None, "{} — bytes held by each cache (decoded sources, rendered previews, GPU buffers; heap when instrumented)", always, |s, _| { + Ok(serde_json::to_value(s.memory_report()).unwrap_or_default()) + }), + cmd!(query "app.memoryBudget", "Memory Budget", [], None, "{mb?: number} — set the memory budget shared by the caches (default: a quarter of RAM, at most 1536 MB); returns the report", always, |s, p| { + if let Some(mb) = p.get("mb").and_then(Value::as_f64) { + if !(64.0..=1_048_576.0).contains(&mb) { + return Err(bad("app.memoryBudget", "mb must be 64..1048576")); + } + s.set_memory_budget((mb * 1048576.0) as usize); + } + Ok(serde_json::to_value(s.memory_report()).unwrap_or_default()) + }), cmd!(query "journal.list", "Command Journal", [], None, "{limit?}", always, |s, p| { let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(100) as usize; let start = s.journal.len().saturating_sub(lim); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 107a501b..cbb3269b 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -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, + /// 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, } 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> { 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, String> { encode_with_metadata(img, o, None) @@ -314,10 +414,12 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result, 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, 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, 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 { 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) { + 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 = (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}"), + } + } } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 88423501..a857f6cb 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -36,7 +36,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { /// Lens corrections embedded in a DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), re-expressed for /// the default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. -pub fn embedded_lens(raw: &lightcraft_raw::RawImage) -> Option { +pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; use lightcraft_geom::Point; let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64); @@ -86,13 +86,14 @@ fn ext_upper(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default() } -/// Probe a file's bytes: kind, dimensions (oriented), metadata. +/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their +/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed. pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string()); let m = lightcraft_meta::extract(bytes); let (meta, captured) = meta_of(&m); if lightcraft_raw::probe(bytes).is_some() { - let raw = match lightcraft_raw::decode(bytes) { + let raw = match lightcraft_raw::probe_info(bytes) { Ok(r) => r, Err(lightcraft_raw::RawError::Unsupported(why)) => { // a raw variant we can't decode yet: describe it from its embedded preview @@ -120,7 +121,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { if raw.orientation.swaps_axes() { std::mem::swap(&mut w, &mut h); } - let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy(&raw)); + let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw)); let as_shot_wb = Some((t.round(), tint.round())); let embedded_lens = embedded_lens(&raw); return Ok(ProbeInfo { @@ -190,21 +191,28 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option Result<(Rgb32f, SourceInfo), String> { - rayon::scope(|_| load_bytes_now(bytes, max_edge)) + rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge)) } -fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { - if lightcraft_raw::probe(bytes).is_some() { - let mut raw = match lightcraft_raw::decode(bytes) { +/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded +/// (less memory held while it is developed). +pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge)) +} + +fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { + if lightcraft_raw::probe(&bytes).is_some() { + let mut raw = match lightcraft_raw::decode(&bytes) { Ok(r) => r, Err(lightcraft_raw::RawError::Unsupported(why)) => { // show the camera's embedded JPEG (rendered, not raw) until the variant is supported - return load_embedded_preview(bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview")); + return load_embedded_preview(&bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview")); } Err(e) => return Err(e.to_string()), }; + drop(bytes); // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in. - let lens = embedded_lens(&raw); + let lens = embedded_lens(&raw.info()); raw.opcodes.list3.retain(|op| !is_lens_opcode(op)); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -220,6 +228,8 @@ fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), raw.develop(method).map_err(|e| e.to_string())? } }; + // the samples aren't needed any more (the colour model below reads only the tags) + raw.data = lightcraft_raw::RawData::U16(Vec::new()); let mut stages = vec![("develop", t0.elapsed())]; let xy = lightcraft_raw::color::as_shot_white_xy(&raw); let t = lightcraft_raw::color::camera_transform(&raw, xy); @@ -229,7 +239,7 @@ fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), let m = t.matrix.to_f32(); let gain = 2f32.powf(t.baseline_exposure as f32); let wb = t.wb; - let img = img.map(|p| { + img.map_in_place(|p| { let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; [ ((m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]) * gain).max(0.0), @@ -240,7 +250,7 @@ fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), stages.push(("colour", t0.elapsed())); let img = fit(&img, max_edge, max_edge, Filter::Box); stages.push(("fit", t0.elapsed())); - let img = img.oriented(raw.orientation); + let img = img.into_oriented(raw.orientation); stages.push(("orient", t0.elapsed())); if lightcraft_pipeline::profiling() { let mut prev = std::time::Duration::ZERO; @@ -257,10 +267,11 @@ fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), let (temp, tint) = xy_to_temp_tint(xy); return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens })); } - let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?; + let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?; + drop(bytes); let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; - Ok((img.oriented(Orientation::from_exif(d.orientation)), SourceInfo::default())) + Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default())) } /// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. @@ -310,12 +321,27 @@ pub fn fs_preview_loader() -> PreviewLoader { } /// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead. +/// +/// Their working memory is bounded by [`crate::memory::work_gate`]: background loads (grid +/// thumbnails, neighbour prefetch: [`crate::memory::in_background`]) and import probes wait while +/// too much is in flight, each counting its file's size times the expansion of decoding it; +/// interactive loads (the loupe, exports) never wait — they are counted, so background work +/// yields to them. pub fn fs_hooks() -> (FileLoader, FileProbe) { let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| { + let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0); + let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }; + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - load_bytes(&bytes, max_edge) + let r = load_vec(bytes, max_edge); + // the file, the samples and the intermediate images are gone: give their pages back + crate::memory::release(); + r }); let probe: FileProbe = Arc::new(|path: &str| { + let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0); + let _permit = crate::memory::work_gate().acquire(len); let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; probe_bytes(path, &bytes) }); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index e07199d6..2be62172 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -194,6 +194,7 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res } let probed = probe_all(s, &todo); + crate::memory::release(); let now = (s.clock)(); let mut ops = Vec::new(); for (path, info) in todo.into_iter().zip(probed) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 789a7b45..a3e57e0d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod files; pub mod import; pub mod library; pub mod media; +pub mod memory; pub mod merge; pub mod presets; pub mod sidecar; @@ -81,6 +82,10 @@ pub struct Session { /// Groups last used for Copy (Lightroom remembers them). pub copy_groups: Vec, pub presets: Vec, + /// Favourite profile ids (persisted with the library, like preset favourites). + pub profile_favorites: Vec, + /// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]). + pub profile_recent: Vec, /// Executed commands (actions / debugging / replay). pub journal: Vec<(String, Value)>, /// Ops applied since the last `drain_log` (for persistence). @@ -123,6 +128,8 @@ impl Session { clipboard: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), presets: presets::builtin(), + profile_favorites: Vec::new(), + profile_recent: Vec::new(), journal: Vec::new(), pending_log: Vec::new(), media: media::MediaCache::default(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 5225a00a..8b8a9982 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -72,6 +72,9 @@ pub struct LibraryStores { struct PresetsFile { user: Vec, favorites: Vec, + /// Favourite profiles, and the recently applied ones (newest first). + profile_favorites: Vec, + profile_recent: Vec, } #[derive(Default, Serialize, Deserialize)] @@ -103,10 +106,13 @@ struct PrefsFile { last_export: Option, } -fn presets_json(presets: &[Preset]) -> String { +fn presets_json(s: &Session) -> String { + let presets = &s.presets; let f = PresetsFile { user: presets.iter().filter(|p| !p.builtin).cloned().collect(), favorites: presets.iter().filter(|p| p.builtin && p.favorite).map(|p| p.id.clone()).collect(), + profile_favorites: s.profile_favorites.clone(), + profile_recent: s.profile_recent.clone(), }; serde_json::to_string_pretty(&f).unwrap_or_default() } @@ -151,6 +157,9 @@ impl Session { self.presets.push(u); } } + let known = |id: &String| crate::presets::profile(id).is_some(); + self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect(); + self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect(); } // preferences let prefs = read_json::(files.as_mut(), "prefs.json").unwrap_or_default(); @@ -170,7 +179,7 @@ impl Session { { self.selection = Selection::single(*first); } - let presets_written = presets_json(&self.presets); + let presets_written = presets_json(self); if on_disk { self.media.attach_disk_cache(&dir.join("thumbs")); } @@ -196,7 +205,8 @@ impl Session { if lib.journal.wants_snapshot() && self.interaction.is_none() { lib.journal.snapshot(&self.catalog)?; } - let presets = presets_json(&self.presets); + let presets = presets_json(self); + let Some(lib) = self.library.as_mut() else { return Ok(()) }; if presets != lib.presets_written { if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { log::error!("library: presets: {e}"); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e10b31a1..946cbc27 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -94,11 +94,15 @@ impl SourceRef { pub struct MediaCache { /// Decoded thumbnail-level sources (LRU by bytes). thumbs: Lru>, - previews: Vec<(PhotoId, Arc)>, + /// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]). + previews: Vec<(PhotoId, Arc, u64)>, /// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP). - full: Option<(PhotoId, Arc)>, + full: Option<(PhotoId, Arc, u64)>, /// How many previews to keep (LRU). pub preview_capacity: usize, + /// Bytes all decoded sources and rendered previews in memory may take together (the cache + /// share of [`crate::memory::budget`]); the least recently used entry of any of them goes first. + budget: usize, pub file_loader: Option, pub file_probe: Option, pub preview_loader: Option, @@ -111,58 +115,124 @@ pub struct MediaCache { impl Default for MediaCache { fn default() -> Self { + let budget = crate::memory::cache_share(crate::memory::budget()); MediaCache { - thumbs: Lru::new(THUMB_SOURCE_BYTES), + thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)), previews: Vec::new(), full: None, preview_capacity: 0, + budget, file_loader: None, file_probe: None, preview_loader: None, file_bytes: None, scenes: Vec::new(), - rendered: Arc::new(PreviewCache::memory(RENDERED_MEM_BYTES)), + rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))), } } } +/// Memory for rendered previews out of the cache share. +fn rendered_budget(share: usize) -> usize { + RENDERED_MEM_BYTES.min(share / 4) +} + +fn source_bytes(img: &Rgb32f) -> usize { + img.data.len() * 12 + 64 +} + 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_MEM_BYTES, dir, DISK_CACHE_BYTES)); + self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, DISK_CACHE_BYTES)); + } + + /// Bytes the caches may hold together. + pub fn budget(&self) -> usize { + self.budget + } + + /// Change the bytes the caches may hold together, evicting at once if needed. + pub fn set_budget(&mut self, bytes: usize) { + self.budget = bytes; + self.thumbs.set_budget(THUMB_SOURCE_BYTES.min(bytes)); + self.rendered.set_mem_budget(rendered_budget(bytes)); + self.enforce_budget(); + } + + /// Bytes held by decoded sources and rendered previews in memory. + pub fn held(&self) -> usize { + let (t, p, f) = self.usage(); + t.bytes + p.bytes + f.bytes + self.rendered.mem_usage().1 + } + + /// Evict least recently used entries across the source caches and the rendered previews + /// until they fit the budget. The newest preview-level source (the photo on screen, or the + /// one just decoded) is never evicted here. + pub fn enforce_budget(&mut self) { + let mut held = self.held(); + while held > self.budget { + let newest_preview = self.previews.iter().map(|e| e.2).max(); + let candidates = [ + self.thumbs.oldest_tick().map(|t| (t, 0)), + self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), + self.full.as_ref().map(|e| (e.2, 2)), + self.rendered.oldest_tick().map(|t| (t, 3)), + ]; + let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; + let freed = match which { + 0 => self.thumbs.pop_oldest(), + 1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1)), + 2 => self.full.take().map(|e| source_bytes(&e.1)), + _ => self.rendered.evict_oldest(), + }; + match freed { + Some(b) if b > 0 => held = held.saturating_sub(b), + _ => break, + } + } } pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option> { match level { SourceLevel::Thumb => self.thumbs.get(&id).cloned(), - SourceLevel::Preview => self.previews.iter().find(|(p, _)| *p == id).map(|(_, a)| a.clone()), - SourceLevel::Full => self.full.as_ref().filter(|(p, _)| *p == id).map(|(_, a)| a.clone()), + SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| { + e.2 = lightcraft_preview::next_tick(); + e.1.clone() + }), + SourceLevel::Full => self.full.as_mut().filter(|e| e.0 == id).map(|e| { + e.2 = lightcraft_preview::next_tick(); + e.1.clone() + }), } } pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc) { + let tick = lightcraft_preview::next_tick(); match level { SourceLevel::Thumb => { - let cost = img.width * img.height * 12 + 64; + let cost = source_bytes(&img); self.thumbs.insert(id, img, cost); } SourceLevel::Preview => { - self.previews.retain(|(p, _)| *p != id); - self.previews.push((id, img)); + self.previews.retain(|e| e.0 != id); + self.previews.push((id, img, tick)); let cap = if self.preview_capacity == 0 { 4 } else { self.preview_capacity }; while self.previews.len() > cap { - self.previews.remove(0); + let oldest = self.previews.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); + self.previews.remove(oldest); } } - SourceLevel::Full => self.full = Some((id, img)), + SourceLevel::Full => self.full = Some((id, img, tick)), } + self.enforce_budget(); } /// Forget a photo's decoded sources (e.g. after its file changed). pub fn forget(&mut self, id: PhotoId) { self.thumbs.remove(&id); - self.previews.retain(|(p, _)| *p != id); - if self.full.as_ref().is_some_and(|(p, _)| *p == id) { + self.previews.retain(|e| e.0 != id); + if self.full.as_ref().is_some_and(|e| e.0 == id) { self.full = None; } } @@ -172,6 +242,14 @@ impl MediaCache { (self.thumbs.len(), self.thumbs.cost()) } + /// Decoded sources held: (thumbnail level, preview level, full size). + pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { + use crate::memory::Usage; + let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1)).sum()); + let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default(); + (Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full) + } + pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { if let Some(a) = self.get(p.id, level) { return SourceRef::Loaded(a); @@ -277,7 +355,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, }; @@ -356,7 +434,7 @@ impl QuickJob { let done = |rendered: Result, 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) { @@ -451,6 +529,46 @@ impl crate::Session { }) } + /// A render of `id` with `settings` in place of its own, fitting `max_w × max_h` (the loupe's + /// temporary preview while hovering a preset or profile; nothing is committed or cached). + pub fn preview_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool, settings: &DevelopSettings) -> Option { + let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; + job.settings = Arc::new(settings.clone()); + job.key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (job.level as u64) << 60; + Some(job) + } + + /// Cache key of a variant thumbnail ([`Self::variant_job`]). + pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 { + Hasher128::new().str(&content_key(p)).str("variant").u64(settings.hash64()).u64(edge as u64).u64(RENDER_CACHE_VERSION).finish() + } + + /// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset + /// browsers): from the thumbnail-level source, long edge `edge` (≤ 512), cropped. The result + /// is cached (memory + the library's disk cache) under the photo's content and the settings + /// hash, so a variant renders once; `key` is derived from the same hash, so a frontend can + /// keep one texture per variant. + pub fn variant_job(&mut self, id: PhotoId, settings: &DevelopSettings, edge: usize) -> Option { + let p = self.catalog.photo(id)?.clone(); + let edge = edge.clamp(16, SourceLevel::Thumb.max_edge()); + let level = SourceLevel::Thumb; + let source = self.media.source_ref(&p, level); + let ck = Self::variant_key(&p, settings, edge); + Some(RenderJob { + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings: Arc::new(settings.clone()), + request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, + key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), + cache: Some((self.media.rendered.clone(), ck)), + stages: None, + view_cache: None, + }) + } + /// Size-independent cache key of a photo's view render (loupe) for its current settings. fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish() @@ -518,6 +636,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 { + 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, String> { let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); @@ -552,6 +688,54 @@ pub type FileProbe = Arc Result + Send + Sync mod tests { use super::*; + #[test] + fn budget_evicts_least_recently_used_across_caches() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(30 * mb); + // 3 previews of 12 MB (1000² × 12 B) don't fit 30 MB: the least recently used goes + m.insert(PhotoId(1), SourceLevel::Preview, img(1000)); + m.insert(PhotoId(2), SourceLevel::Preview, img(1000)); + assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some()); // 1 is now more recent than 2 + m.insert(PhotoId(3), SourceLevel::Preview, img(1000)); + assert!(m.get(PhotoId(2), SourceLevel::Preview).is_none()); + assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some() && m.get(PhotoId(3), SourceLevel::Preview).is_some()); + // a thumbnail source and a rendered preview compete in the same budget + m.insert(PhotoId(4), SourceLevel::Thumb, img(500)); + m.rendered.put(Hash128(7), Arc::new(lightcraft_raster::Rgba8::new(1000, 1000))); + assert!(m.held() <= 30 * mb, "{}", m.held()); + // the newest preview (the one on screen) is never evicted, even alone over budget + m.set_budget(mb); + assert!(m.get(PhotoId(3), SourceLevel::Preview).is_some()); + assert_eq!(m.usage().1.count, 1); + assert_eq!(m.usage().0.count, 0); + assert_eq!(m.rendered.mem_usage().0, 0); + } + + #[test] + fn work_gate_waits_for_room() { + use crate::memory::WorkGate; + let g = Arc::new(WorkGate::new(100)); + let a = g.acquire(80); + // more than fits waits until `a` is released; urgent work never waits + let urgent = g.acquire_urgent(500); + assert_eq!(g.usage().0, 580); + drop(urgent); + let g2 = g.clone(); + let t = std::thread::spawn(move || { + let _b = g2.acquire(50); + g2.usage().0 + }); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert_eq!(g.usage().0, 80, "the second holder is still waiting"); + drop(a); + assert_eq!(t.join().unwrap(), 50); + assert_eq!(g.usage().0, 0); + // a single holder may exceed the limit + drop(g.acquire(1000)); + } + #[test] fn levels_cover_sizes_and_large_exports_use_the_original() { assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb); @@ -576,4 +760,44 @@ mod tests { let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); assert_ne!(a.key, b.key); } + + #[test] + fn variant_jobs_have_their_own_keys_and_hit_the_cache() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let mine = (*s.develop_of(id).unwrap()).clone(); + let with = |pid: &str| { + let mut d = mine.clone(); + d.profile.id = pid.into(); + d + }; + let (a, b) = (with("lc.vivid"), with("lc.bw.sepia")); + let ja = s.variant_job(id, &a, 128).unwrap(); + let jb = s.variant_job(id, &b, 128).unwrap(); + assert_ne!(ja.key, jb.key, "keys differ per settings"); + assert_eq!(ja.key, s.variant_job(id, &a, 128).unwrap().key, "stable"); + assert_ne!(ja.key, s.variant_job(id, &a, 256).unwrap().key, "and per size"); + assert_ne!(ja.key, s.thumb_job(id, 128).unwrap().key, "not the photo's own thumbnail"); + assert_eq!(ja.level, SourceLevel::Thumb); + let (cache, ck) = ja.cache.clone().unwrap(); + assert!(cache.get(ck).is_none()); + let r = ja.run(); + s.accept(&r); + let first = r.rendered.unwrap().image; + assert!(first.width.max(first.height) <= 128); + assert!(cache.get(ck).is_some(), "rendered variants are cached"); + assert!(cache.get(jb.cache.as_ref().unwrap().1).is_none()); + // a second job for the same variant is served from the cache, pixel for pixel + let again = s.variant_job(id, &a, 128).unwrap().run(); + assert!(again.loaded.is_none()); + assert_eq!(again.rendered.unwrap().image.as_bytes(), first.as_bytes()); + let other = jb.run().rendered.unwrap().image; + assert_ne!(other.as_bytes(), first.as_bytes()); + // the photo's own settings never changed + assert_eq!(*s.develop_of(id).unwrap(), mine); + // hover previews: a loupe-sized job with other settings, its own key + let pv = s.preview_job(id, 800, 600, true, &a).unwrap(); + assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key); + assert_eq!(*pv.settings, a); + } } diff --git a/crates/engine/src/memory.rs b/crates/engine/src/memory.rs new file mode 100644 index 00000000..fa7cb878 --- /dev/null +++ b/crates/engine/src/memory.rs @@ -0,0 +1,295 @@ +//! Memory accounting: what each cache holds (`library.memory`, and `ui.inspect` → `memory` with the +//! frontend's own caches added). +//! +//! The numbers are the caches' own bookkeeping (pixels held), not the process's resident size: the +//! allocator keeps freed pages for reuse and the GPU driver maps device buffers, so `ps`/`time -l` +//! report more. A binary built with a heap profiler installs [`set_heap_stats`] (e.g. +//! `lightcraft-cli` with `--features dhat-heap`) to add live/peak heap bytes. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Condvar, Mutex, OnceLock}; + +use serde::Serialize; + +/// The memory budget: one number from which every cache's share is derived. +/// +/// - half for the engine's caches (decoded sources and rendered previews), evicted least recently +/// used *across* them ([`crate::media::MediaCache`]); +/// - a quarter for the working memory of decodes in flight ([`work_gate`]); +/// - an eighth for the GPU renderer's pool of recycled buffers (trimmed when the app is idle). +/// +/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). Default: +/// a quarter of the machine's RAM, at most 1.5 GiB (`LIGHTCRAFT_MEMORY_MB` overrides it). +pub fn default_budget() -> usize { + if let Some(mb) = std::env::var("LIGHTCRAFT_MEMORY_MB").ok().and_then(|v| v.trim().parse::().ok()).filter(|m| *m >= 64) { + return mb << 20; + } + let cap = 3usize << 29; // 1.5 GiB + match total_ram() { + Some(ram) => (ram / 4).clamp(256 << 20, cap), + None => cap, + } +} + +/// Physical memory (bytes), when the platform tells us without native calls. +fn total_ram() -> Option { + #[cfg(target_os = "linux")] + { + let s = std::fs::read_to_string("/proc/meminfo").ok()?; + let kb: usize = s.lines().find(|l| l.starts_with("MemTotal:"))?.split_whitespace().nth(1)?.parse().ok()?; + Some(kb * 1024) + } + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?; + String::from_utf8_lossy(&out.stdout).trim().parse().ok() + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + None + } +} + +static BUDGET: AtomicUsize = AtomicUsize::new(0); + +/// The current budget (bytes). +pub fn budget() -> usize { + match BUDGET.load(Ordering::Relaxed) { + 0 => { + let b = default_budget(); + BUDGET.store(b, Ordering::Relaxed); + apply(b); + b + } + b => b, + } +} + +/// Change the budget (bytes, at least 64 MiB) for the process: the decode gate and the GPU pool +/// follow at once; sessions apply their cache share with [`crate::Session::set_memory_budget`]. +pub fn set_budget(bytes: usize) -> usize { + let b = bytes.max(64 << 20); + BUDGET.store(b, Ordering::Relaxed); + apply(b); + b +} + +fn apply(b: usize) { + if let Some(g) = GATE.get() { + g.set_limit(b / 4); + } + lightcraft_gpu::set_pool_limit((b / 8) as u64); +} + +/// Share of the budget for the engine's caches. +pub fn cache_share(budget: usize) -> usize { + budget / 2 +} + +/// A weighted semaphore over bytes of working memory: [`WorkGate::acquire`] waits while other +/// holders already use the limit (one holder may always proceed, whatever its size). +pub struct WorkGate { + state: Mutex<(usize, usize)>, + cv: Condvar, +} + +/// Bytes held from a [`WorkGate`] until dropped. +pub struct Permit<'a> { + gate: &'a WorkGate, + bytes: usize, +} + +impl WorkGate { + pub fn new(limit: usize) -> WorkGate { + WorkGate { state: Mutex::new((0, limit)), cv: Condvar::new() } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, (usize, usize)> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub fn set_limit(&self, limit: usize) { + self.lock().1 = limit; + self.cv.notify_all(); + } + + /// Bytes held now and the limit. + pub fn usage(&self) -> (usize, usize) { + *self.lock() + } + + /// Hold `bytes`, waiting until they fit (or nothing else is held). + pub fn acquire(&self, bytes: usize) -> Permit<'_> { + let mut g = self.lock(); + while g.0 > 0 && g.0 + bytes > g.1 { + g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner()); + } + g.0 += bytes; + Permit { gate: self, bytes } + } + + /// Hold `bytes` without waiting (interactive work): counted, so background work waits for it. + pub fn acquire_urgent(&self, bytes: usize) -> Permit<'_> { + self.lock().0 += bytes; + Permit { gate: self, bytes } + } +} + +impl Drop for Permit<'_> { + fn drop(&mut self) { + let mut g = self.gate.lock(); + g.0 = g.0.saturating_sub(self.bytes); + drop(g); + self.gate.cv.notify_all(); + } +} + +thread_local! { + static BACKGROUND: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Run `f` as background work (grid thumbnails, neighbour prefetch): its decodes wait at the +/// [`work_gate`] while interactive work holds the memory. +pub fn in_background(f: impl FnOnce() -> R) -> R { + let was = BACKGROUND.with(|b| b.replace(true)); + let r = f(); + BACKGROUND.with(|b| b.set(was)); + r +} + +/// Is this thread running background work ([`in_background`])? +pub fn is_background() -> bool { + BACKGROUND.with(|b| b.get()) +} + +/// The process-wide gate for decodes and import probes (limit: a quarter of the budget). +pub fn work_gate() -> &'static WorkGate { + GATE.get_or_init(|| WorkGate::new(budget() / 4)) +} + +static GATE: OnceLock = OnceLock::new(); + +/// Entries and bytes held by one cache. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Usage { + pub count: usize, + pub bytes: usize, +} + +impl Usage { + pub fn new(count: usize, bytes: usize) -> Usage { + Usage { count, bytes } + } +} + +/// Heap bytes as counted by an instrumented allocator. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HeapUsage { + /// Live heap bytes now. + pub current: u64, + /// Highest live heap bytes so far. + pub peak: u64, +} + +/// Device buffers of the GPU renderer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GpuUsage { + /// Every device buffer that exists. + pub allocated: u64, + /// Of which recycled buffers waiting in the free pool. + pub pooled: u64, + /// Of which buffers released since their thread's last submit. + pub retired: u64, +} + +/// What the engine's caches hold. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryReport { + /// Decoded thumbnail-level sources (≤ 512 px, linear float). + pub thumb_sources: Usage, + /// Decoded preview-level sources (≤ 2560 px, linear float) of the current and prefetched photos. + pub preview_sources: Usage, + /// The last full-resolution original (exports, 1:1). + pub full_source: Usage, + /// Rendered thumbnails and view renders (8-bit) in memory. + pub rendered: Usage, + /// Sum of the above. + pub engine_bytes: usize, + pub gpu: GpuUsage, + /// The memory budget (see [`budget`]) and the engine caches' share of it. + pub budget: usize, + pub cache_budget: usize, + /// Working memory of decodes / probes in flight and its limit ([`work_gate`]). + pub work_bytes: usize, + pub work_limit: usize, + /// Live/peak heap when the binary counts allocations. + #[serde(skip_serializing_if = "Option::is_none")] + pub heap: Option, +} + +static HEAP: OnceLock HeapUsage> = OnceLock::new(); +static RELEASE: OnceLock = OnceLock::new(); + +/// Install a hook that hands memory the allocator keeps after frees back to the system (first +/// call wins). The macOS allocator keeps freed large blocks resident (reusable, but counted in +/// the resident size) until the system runs short; the apps install +/// `malloc_zone_pressure_relief` so the resident size follows what is actually held. +pub fn set_release_hook(f: fn()) { + let _ = RELEASE.set(f); +} + +/// Return freed memory to the system now (after large temporaries were dropped: a decode, an +/// import). No-op without a hook. +pub fn release() { + if let Some(f) = RELEASE.get() { + f(); + } +} + +/// Install the heap counter of an instrumented binary (first call wins). +pub fn set_heap_stats(f: fn() -> HeapUsage) { + let _ = HEAP.set(f); +} + +/// Live/peak heap bytes, when the binary installed a counter. +pub fn heap_stats() -> Option { + HEAP.get().map(|f| f()) +} + +/// The GPU renderer's device buffers. +pub fn gpu_usage() -> GpuUsage { + let g = lightcraft_gpu::memory(); + GpuUsage { allocated: g.allocated, pooled: g.pooled, retired: g.retired } +} + +impl crate::Session { + /// What the engine's caches hold now. + pub fn memory_report(&self) -> MemoryReport { + let (thumb_sources, preview_sources, full_source) = self.media.usage(); + let (n, b) = self.media.rendered.mem_usage(); + let rendered = Usage::new(n, b); + MemoryReport { + thumb_sources, + preview_sources, + full_source, + rendered, + engine_bytes: thumb_sources.bytes + preview_sources.bytes + full_source.bytes + rendered.bytes, + gpu: gpu_usage(), + budget: budget(), + cache_budget: self.media.budget(), + work_bytes: work_gate().usage().0, + work_limit: work_gate().usage().1, + heap: heap_stats(), + } + } + + /// Set the memory budget (bytes; process-wide) and apply this session's cache share. + pub fn set_memory_budget(&mut self, bytes: usize) -> usize { + let b = set_budget(bytes); + self.media.set_budget(cache_share(b)); + b + } +} diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index f7994f06..c5136153 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -263,8 +263,67 @@ pub const PROFILES: &[ProfileInfo] = &[ ProfileInfo { id: "lc.landscape", name: "Landscape", group: "Basic" }, ProfileInfo { id: "lc.portrait", name: "Portrait", group: "Basic" }, ProfileInfo { id: "lc.mono", name: "Monochrome", group: "Basic" }, + ProfileInfo { id: "lc.film.warm-print", name: "Warm Print", group: "Film" }, + ProfileInfo { id: "lc.film.cool-fade", name: "Cool Fade", group: "Film" }, + ProfileInfo { id: "lc.film.golden-hour", name: "Golden Hour", group: "Film" }, + ProfileInfo { id: "lc.film.faded-slide", name: "Faded Slide", group: "Film" }, + ProfileInfo { id: "lc.cine.teal-amber", name: "Teal & Amber", group: "Cinematic" }, + ProfileInfo { id: "lc.cine.night-blue", name: "Night Blue", group: "Cinematic" }, + ProfileInfo { id: "lc.cine.desert-heat", name: "Desert Heat", group: "Cinematic" }, + ProfileInfo { id: "lc.cine.neon-dusk", name: "Neon Dusk", group: "Cinematic" }, + ProfileInfo { id: "lc.muted.matte-soft", name: "Matte Soft", group: "Muted" }, + ProfileInfo { id: "lc.muted.bleached", name: "Bleached", group: "Muted" }, + ProfileInfo { id: "lc.muted.pastel-haze", name: "Pastel Haze", group: "Muted" }, + ProfileInfo { id: "lc.muted.quiet-green", name: "Quiet Green", group: "Muted" }, + ProfileInfo { id: "lc.bw.mono-rich", name: "Mono Rich", group: "B&W" }, + ProfileInfo { id: "lc.bw.red-filter", name: "Mono Red Filter", group: "B&W" }, + ProfileInfo { id: "lc.bw.soft", name: "Mono Soft", group: "B&W" }, + ProfileInfo { id: "lc.bw.sepia", name: "Sepia Tone", group: "B&W" }, ]; +/// Profile groups in menu/browser order. +pub fn profile_groups() -> Vec<&'static str> { + let mut out: Vec<&'static str> = Vec::new(); + for p in PROFILES { + if !out.contains(&p.group) { + out.push(p.group); + } + } + out +} + +/// A profile by id. +pub fn profile(id: &str) -> Option<&'static ProfileInfo> { + PROFILES.iter().find(|p| p.id == id) +} + +/// How many recently used profiles the profile menu lists. +pub const RECENT_PROFILES: usize = 5; + +impl Session { + /// Remember `id` as the most recently applied profile. + pub fn note_profile_used(&mut self, id: &str) { + self.profile_recent.retain(|p| p != id); + self.profile_recent.insert(0, id.to_string()); + self.profile_recent.truncate(RECENT_PROFILES); + } + + /// The profile menu: favourites, recent, then every group (`profiles.menu`). + pub fn profile_menu(&self) -> Value { + let item = + |p: &ProfileInfo| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.iter().any(|f| f == p.id)}); + let list = |ids: &[String]| ids.iter().filter_map(|id| profile(id)).map(item).collect::>(); + json!({ + "favorites": list(&self.profile_favorites), + "recent": list(&self.profile_recent), + "groups": profile_groups().into_iter().map(|g| json!({ + "name": g, + "profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::>(), + })).collect::>(), + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -280,6 +339,35 @@ mod tests { } } + #[test] + fn every_profile_has_a_look_and_ids_are_unique() { + let looks = crate::pipeline::profiles::LOOK_IDS; + let mut ids = std::collections::HashSet::new(); + for p in PROFILES { + assert!(ids.insert(p.id), "{}", p.id); + assert!(p.id == "lc.color" || looks.contains(&p.id), "{} has no look", p.id); + } + assert!(PROFILES.len() >= 22); + assert_eq!(profile_groups(), ["Basic", "Film", "Cinematic", "Muted", "B&W"]); + } + + #[test] + fn creative_profiles_render_without_moving_the_sliders() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap(); + let before = s.develop_of(id).unwrap(); + let plain = s.render_now(id, 96, 96).unwrap().image; + s.execute("develop.profile", &json!({"id": "lc.cine.teal-amber", "amount": 150})).unwrap(); + let after = s.develop_of(id).unwrap(); + assert_eq!((after.profile.id.as_str(), after.profile.amount), ("lc.cine.teal-amber", 150.0)); + let mut same = (*after).clone(); + same.profile = before.profile.clone(); + assert_eq!(same, *before, "only the profile changed"); + let looked = s.render_now(id, 96, 96).unwrap().image; + assert_ne!(plain.as_bytes(), looked.as_bytes()); + } + #[test] fn slugs() { assert_eq!(slug("Warm & Soft!"), "warm-soft"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a6623710..9b6e8bd6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -189,3 +189,23 @@ fn upright_and_guides_commands() { assert!(s.render_now(id, 160, 160).is_ok()); assert!(s.execute("geometry.upright", &json!({"mode": "sideways"})).is_err()); } + +#[test] +fn memory_report_counts_decoded_sources_and_renders() { + let mut s = demo(); + let r = s.execute("library.memory", &json!({})).unwrap(); + assert_eq!(r["previewSources"]["count"], 0); + assert!(r["gpu"]["allocated"].is_u64() && r.get("engineBytes").is_some()); + let id = s.active().unwrap(); + s.render_now(id, 800, 600).unwrap(); + let m = s.memory_report(); + assert_eq!(m.preview_sources.count, 1); + // a 2560 px linear float source: 12 bytes per pixel + assert!(m.preview_sources.bytes >= 12 * 2560 * 1000, "{:?}", m.preview_sources); + assert_eq!(m.engine_bytes, m.thumb_sources.bytes + m.preview_sources.bytes + m.full_source.bytes + m.rendered.bytes); + let r = s.thumb_job(id, 200).unwrap().run(); + s.accept(&r); + let m = s.memory_report(); + assert_eq!(m.thumb_sources.count, 1); + assert!(m.rendered.count >= 1, "the thumbnail render is cached"); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index e3e75753..bd3397ca 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -126,3 +126,29 @@ fn new_library_without_seed_is_empty_and_compacts() { assert_eq!(s2.catalog.albums().count(), 1); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn profile_favorites_and_recent_survive_reopen() { + let dir = temp_dir("profiles"); + let mut s = open(&dir, true); + s.execute("profile.favorite", &json!({"id": "lc.bw.sepia"})).unwrap(); + s.execute("profile.favorite", &json!({"id": "lc.vivid"})).unwrap(); + s.execute("profile.favorite", &json!({"id": "lc.vivid"})).unwrap(); // toggled off again + assert!(s.execute("profile.favorite", &json!({"id": "nope"})).is_err()); + for id in ["lc.neutral", "lc.vivid", "lc.film.cool-fade", "lc.mono", "lc.cine.teal-amber", "lc.muted.bleached", "lc.vivid"] { + s.execute("develop.profile", &json!({"id": id})).unwrap(); + } + let want_recent = ["lc.vivid", "lc.muted.bleached", "lc.cine.teal-amber", "lc.mono", "lc.film.cool-fade"]; + assert_eq!(s.profile_recent, want_recent, "newest first, deduplicated, at most 5"); + drop(s); // no clean close: every command persisted already + let mut s = open(&dir, false); + assert_eq!(s.profile_favorites, ["lc.bw.sepia"]); + assert_eq!(s.profile_recent, want_recent); + let menu = s.execute("profiles.menu", &json!({})).unwrap(); + assert_eq!(menu["favorites"][0]["name"], "Sepia Tone"); + assert_eq!(menu["recent"].as_array().unwrap().len(), 5); + assert_eq!(menu["groups"][0]["name"], "Basic"); + let list = s.execute("profiles.list", &json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|p| p["id"] == "lc.bw.sepia" && p["favorite"] == true)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index b415d312..ad40d680 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -1,6 +1,7 @@ //! The wgpu device, the compiled kernels, buffers, dispatch and readback. use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use wgpu::util::DeviceExt; @@ -79,48 +80,140 @@ struct Kernel { /// as the queue then runs every earlier use before any later one. Reuse matters: allocating (and /// zero-filling) a fresh 100–300 MB buffer per pass costs as much as the pass itself. pub struct Buf { - buf: Option, + buf: Option, pub len: usize, } impl Buf { pub fn raw(&self) -> &wgpu::Buffer { - self.buf.as_ref().expect("live buffer") + &self.buf.as_ref().expect("live buffer").0 } } impl Drop for Buf { fn drop(&mut self) { - if let Some(b) = self.buf.take() { - // during thread teardown the list may already be gone: then the buffer leaks - let mut b = Some(b); - let _ = RETIRED.try_with(|r| r.0.borrow_mut().extend(b.take())); - if let Some(b) = b { - std::mem::forget(b); + let Some(b) = self.buf.take() else { return }; + // Outside a render this thread has no commands recorded (only renders record), so the + // buffer is reusable at once; without this, buffers dropped on threads that rarely + // submit (the UI thread clearing a view's stages) would wait indefinitely. + let Ok(in_render) = IN_RENDER.try_with(|c| c.get() > 0) else { + // thread teardown: wgpu's thread-locals may be gone too, and dropping (or trimming + // the pool, which drops) could abort — leak it (the device owns its memory) + std::mem::forget(b); + return; + }; + if !in_render && let Some(g) = crate::existing_device() { + g.pool([b]); + return; + } + // during thread teardown the list may already be gone: then the buffer leaks + let size = b.0.size(); + let mut b = Some(b); + let _ = RETIRED.try_with(|r| r.0.borrow_mut().extend(b.take())); + match b { + Some(b) => std::mem::forget(b), + None => { + RETIRED_BYTES.fetch_add(size, Ordering::Relaxed); } } } } /// Buffers dropped on a thread since its last submit. -struct Retired(std::cell::RefCell>); +struct Retired(std::cell::RefCell>); impl Drop for Retired { /// At thread exit wgpu's own thread-locals may already be destroyed, and dropping a buffer /// then aborts the process: leak the few buffers left instead (the device owns their memory). fn drop(&mut self) { for b in self.0.get_mut().drain(..) { + RETIRED_BYTES.fetch_sub(b.0.size(), Ordering::Relaxed); std::mem::forget(b); } } } +/// This thread's retired buffers (empty during thread teardown). +fn take_retired() -> Vec { + let retired = RETIRED.try_with(|r| std::mem::take(&mut *r.0.borrow_mut())).unwrap_or_default(); + RETIRED_BYTES.fetch_sub(retired.iter().map(|b| b.0.size()).sum(), Ordering::Relaxed); + retired +} + +thread_local! { + /// Renders running on this thread (they may hold recorded, unsubmitted commands). + static IN_RENDER: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Marks a render on this thread; when it ends, the buffers it released join the pool (everything +/// it recorded has been submitted by then). +pub(crate) struct RenderScope<'a>(&'a Gpu); + +impl<'a> RenderScope<'a> { + pub fn new(g: &'a Gpu) -> RenderScope<'a> { + IN_RENDER.with(|c| c.set(c.get() + 1)); + RenderScope(g) + } +} + +impl Drop for RenderScope<'_> { + fn drop(&mut self) { + let outer = IN_RENDER.with(|c| { + c.set(c.get().saturating_sub(1)); + c.get() == 0 + }); + if outer { + self.0.pool(take_retired()); + } + } +} + +/// A device buffer, counted in [`ALLOCATED`] while it exists. +pub(crate) struct Tracked(wgpu::Buffer); + +impl Tracked { + fn new(b: wgpu::Buffer) -> Tracked { + ALLOCATED.fetch_add(b.size(), Ordering::Relaxed); + Tracked(b) + } +} + +impl Drop for Tracked { + fn drop(&mut self) { + ALLOCATED.fetch_sub(self.0.size(), Ordering::Relaxed); + } +} + thread_local! { static RETIRED: Retired = const { Retired(std::cell::RefCell::new(Vec::new())) }; } -/// Most bytes kept in the free pool. -const POOL_BYTES: u64 = 2 << 30; +/// Bytes of device buffers that exist (in use, retired or pooled). +static ALLOCATED: AtomicU64 = AtomicU64::new(0); +/// Bytes waiting in per-thread retired lists. +static RETIRED_BYTES: AtomicU64 = AtomicU64::new(0); +/// Bytes in the free pool. +static POOLED: AtomicU64 = AtomicU64::new(0); +/// Most bytes kept in the free pool (see [`crate::set_pool_limit`]). +pub(crate) static POOL_LIMIT: AtomicU64 = AtomicU64::new(DEFAULT_POOL_BYTES); + +/// Default for the most bytes kept in the free pool (apps derive it from their memory budget). +pub(crate) const DEFAULT_POOL_BYTES: u64 = 256 << 20; + +/// Device buffers held by the renderer (bytes). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GpuMemory { + /// Every buffer that exists (in use + retired + pooled). + pub allocated: u64, + /// Of which recycled buffers waiting in the free pool. + pub pooled: u64, + /// Of which buffers dropped since their thread's last submit (pooled at its next submit). + pub retired: u64, +} + +pub(crate) fn memory() -> GpuMemory { + GpuMemory { allocated: ALLOCATED.load(Ordering::Relaxed), pooled: POOLED.load(Ordering::Relaxed), retired: RETIRED_BYTES.load(Ordering::Relaxed) } +} pub struct Gpu { pub device: wgpu::Device, @@ -129,7 +222,7 @@ pub struct Gpu { kernels: HashMap<&'static str, Kernel>, dummy: wgpu::Buffer, /// Recycled buffers (see [`Buf`]). - free: std::sync::Mutex>, + free: std::sync::Mutex>, /// Largest storage buffer the device accepts (bytes). pub max_buffer: u64, } @@ -140,8 +233,7 @@ fn constants() -> String { 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 = m.iter().map(|r| format!("vec3({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect(); s += &format!("const {name} = array, 3>({});\n", rows.join(", ")); } @@ -257,15 +349,27 @@ impl Gpu { let size = (len.max(1) * 4) as u64; let recycled = { let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner()); - f.iter().position(|b| b.size() == size).map(|i| f.swap_remove(i)) + // the smallest pooled buffer that holds `size` with at most 25 % to spare: photos of + // slightly different sizes reuse each other's buffers instead of piling up new ones + let b = f + .iter() + .enumerate() + .filter(|(_, b)| b.0.size() >= size && b.0.size() <= size + size / 4) + .min_by_key(|(_, b)| b.0.size()) + .map(|(i, _)| i) + .map(|i| f.swap_remove(i)); + if let Some(b) = &b { + POOLED.fetch_sub(b.0.size(), Ordering::Relaxed); + } + b }; let buf = recycled.unwrap_or_else(|| { - self.device.create_buffer(&wgpu::BufferDescriptor { + Tracked::new(self.device.create_buffer(&wgpu::BufferDescriptor { label: None, size, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, - }) + })) }); Buf { buf: Some(buf), len } } @@ -273,12 +377,42 @@ impl Gpu { /// Submit command buffers, then make the buffers this thread dropped before reusable. pub fn submit(&self, cmds: impl IntoIterator) { self.queue.submit(cmds); - let retired = RETIRED.with(|r| std::mem::take(&mut *r.0.borrow_mut())); + let retired = take_retired(); + self.pool(retired); + } + + /// Put reusable buffers into the free pool (trimmed to its limit). + fn pool(&self, bufs: impl IntoIterator) { + let mut added = false; + { + let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner()); + for b in bufs { + POOLED.fetch_add(b.0.size(), Ordering::Relaxed); + f.push(b); + added = true; + } + } + if added { + self.trim(POOL_LIMIT.load(Ordering::Relaxed)); + } + } + + /// Free pooled buffers (oldest first) until at most `keep` bytes stay pooled. + pub fn trim(&self, keep: u64) { let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner()); - f.extend(retired); - let mut total: u64 = f.iter().map(|b| b.size()).sum(); - while total > POOL_BYTES && !f.is_empty() { - total -= f.remove(0).size(); + let mut total: u64 = f.iter().map(|b| b.0.size()).sum(); + let mut dropped = Vec::new(); + while total > keep && !f.is_empty() { + let b = f.remove(0); + total -= b.0.size(); + POOLED.fetch_sub(b.0.size(), Ordering::Relaxed); + dropped.push(b); + } + drop(f); + if !dropped.is_empty() { + drop(dropped); + // let wgpu release the memory now rather than at its next maintenance + let _ = self.device.poll(wgpu::PollType::Poll); } } @@ -294,7 +428,7 @@ impl Gpu { contents: bytes, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, }); - Buf { buf: Some(buf), len } + Buf { buf: Some(Tracked::new(buf)), len } } /// Whether a buffer of `len` 32-bit values fits the device limits. diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ee88430b..7b07603d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -66,6 +66,12 @@ pub(crate) fn device() -> Option<&'static ctx::Gpu> { GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).ok().flatten()).as_ref() } +/// The device if it has been created (never creates it). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> { + GPU.get().and_then(|g| g.as_ref()) +} + /// Create the device and compile the kernels on a background thread now (app start), so the /// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. pub fn warm_up() { @@ -114,17 +120,80 @@ pub fn adapter_name() -> Option { } } +/// Device buffers held by the renderer. +#[cfg(not(target_arch = "wasm32"))] +pub use ctx::GpuMemory; + +/// Device buffers held by the renderer. +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GpuMemory { + pub allocated: u64, + pub pooled: u64, + pub retired: u64, +} + +/// Device memory held by the renderer's buffers (zero without a device). +pub fn memory() -> GpuMemory { + #[cfg(not(target_arch = "wasm32"))] + { + ctx::memory() + } + #[cfg(target_arch = "wasm32")] + { + GpuMemory::default() + } +} + +/// Keep at most `bytes` of recycled buffers in the free pool from now on (trimming it now). +pub fn set_pool_limit(bytes: u64) { + #[cfg(not(target_arch = "wasm32"))] + { + ctx::POOL_LIMIT.store(bytes, Ordering::Relaxed); + trim_pool(bytes); + } + #[cfg(target_arch = "wasm32")] + let _ = bytes; +} + +/// Free recycled buffers until at most `keep` bytes stay pooled (e.g. when the app goes idle). +pub fn trim_pool(keep: u64) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(g) = GPU.get().and_then(|g| g.as_ref()) { + g.trim(keep); + } + #[cfg(target_arch = "wasm32")] + let _ = keep; +} + +/// Device bytes held by a view's GPU stages (kept with its [`StageCache`]); 0 when it has none. +pub fn stage_bytes(stages: &StageCache) -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + stages.peek_extension::().map(|g| g.bytes()).unwrap_or(0) + } + #[cfg(target_arch = "wasm32")] + { + let _ = stages; + 0 + } +} + /// Render `src` with `s` on the GPU, reusing the device-resident stages kept with `stages` (the /// view's CPU stage cache). `None`: render on the CPU instead. pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option { #[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()?; let ext = stages.map(|c| c.extension::()); - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref()))); + let r = { + let _scope = ctx::RenderScope::new(gpu); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref()))) + }; match r { // a device error during the render: its result is not trustworthy Ok(_) if BROKEN.load(Ordering::Relaxed) => None, diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 00ae96ba..e35c6d8d 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -67,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). @@ -216,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); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 492cb38c..ff983a0b 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -83,6 +83,41 @@ impl GpuStages { self.lock().len() } + /// Device bytes held (each buffer counted once). + pub fn bytes(&self) -> usize { + let mut seen: Vec<*const Buf> = Vec::new(); + let mut total = 0; + let mut add = |b: &Arc| { + let p = Arc::as_ptr(b); + if !seen.contains(&p) { + seen.push(p); + total += b.len * 4; + } + }; + for e in self.lock().iter() { + add(&e.sampled); + if let Some((_, b)) = &e.lin { + add(b); + } + let p = &e.planes; + p.log_l.iter().for_each(&mut add); + p.base.iter().for_each(|(_, b)| add(b)); + p.clarity.iter().for_each(|(_, b)| add(b)); + p.texture.iter().for_each(|(_, b)| add(b)); + p.dark.iter().for_each(|(_, b, _)| add(b)); + } + if let Some((_, b)) = &*self.source.lock().unwrap_or_else(|e| e.into_inner()) { + add(b); + } + total + } + + /// Drop everything (e.g. when memory is needed). + pub fn clear(&self) { + self.lock().clear(); + *self.source.lock().unwrap_or_else(|e| e.into_inner()) = None; + } + pub fn is_empty(&self) -> bool { self.len() == 0 } @@ -475,7 +510,7 @@ 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 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(), @@ -525,7 +560,7 @@ pub fn render( } }); lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); - Some(Rendered { image, histogram }) + Some(Rendered { image, histogram, deep: None }) } /// The white-balanced, retouched, denoised image. @@ -844,6 +879,7 @@ mod tests { #[ignore] fn bench_kernels() { let Some(gpu) = crate::device() else { return }; + let _scope = crate::ctx::RenderScope::new(gpu); let (w, h) = (6000, 4000); let src = gpu.upload(&vec![0.5f32; w * h * 3]); let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| { diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 2ddda1b6..30c2d25c 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -219,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) { let w = pu(F_W); @@ -429,9 +449,14 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } } - // --- 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, 3>( + vec3(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)), + vec3(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)), + vec3(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]; @@ -469,5 +494,6 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e + k; } + e = vec3(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); } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 349c82e1..d56de054 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -530,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); + } + } +} diff --git a/crates/gpu/tests/memory.rs b/crates/gpu/tests/memory.rs new file mode 100644 index 00000000..2ef958cb --- /dev/null +++ b/crates/gpu/tests/memory.rs @@ -0,0 +1,41 @@ +//! Device-memory bookkeeping: a view's stages are counted, released buffers are pooled within +//! the pool limit, and trimming frees them. Skips when no GPU adapter exists. + +use std::sync::Arc; + +use lightcraft_develop::DevelopSettings; +use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache}; + +#[test] +fn stages_are_counted_and_the_pool_is_bounded_and_trimmed() { + if !lightcraft_gpu::available() { + eprintln!("skipped: no GPU adapter"); + return; + } + let limit = 64u64 << 20; + lightcraft_gpu::set_pool_limit(limit); + let info = SourceInfo { raw: true, ..Default::default() }; + let mut s = DevelopSettings::default(); + s.effects.clarity = 30.0; + let stages = StageCache::default(); + // photos of slightly different sizes, like stepping through a library + for (i, (w, h)) in [(1200usize, 800usize), (1190, 810), (1210, 790), (800, 1200)].into_iter().enumerate() { + let src = Arc::new(lightcraft_scenes::demo_library()[i].render(w, h)); + let r = lightcraft_gpu::render(&src, &info, &s, &RenderRequest::fit(w, h), Some(&stages)).expect("gpu render"); + assert_eq!((r.image.width, r.image.height), (w, h)); + let m = lightcraft_gpu::memory(); + assert!(m.pooled <= limit, "pool {} over its limit", m.pooled); + assert_eq!(m.retired, 0, "a finished render leaves nothing retired"); + } + let held = lightcraft_gpu::stage_bytes(&stages); + assert!(held >= 1200 * 800 * 3 * 4, "the view's stages hold at least its resampled source: {held}"); + let before = lightcraft_gpu::memory().allocated; + stages.clear(); + let m = lightcraft_gpu::memory(); + assert_eq!(lightcraft_gpu::stage_bytes(&stages), 0); + assert!(m.pooled <= limit && m.allocated <= before); + lightcraft_gpu::trim_pool(0); + let m = lightcraft_gpu::memory(); + assert_eq!(m.pooled, 0); + assert!(m.allocated < before, "trimming frees the pooled buffers: {} → {}", before, m.allocated); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 663c8f08..2f441eb9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -188,6 +188,8 @@ pub fn helper_tools(has_ui: bool) -> Vec { "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", ], ), ) diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml index ff997b5c..27d77816 100644 --- a/crates/pipeline/Cargo.toml +++ b/crates/pipeline/Cargo.toml @@ -20,3 +20,4 @@ serde = { workspace = true } lightcraft-scenes = { workspace = true } proptest = { workspace = true } image = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 8eb6a637..022448ae 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -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}; @@ -159,7 +160,11 @@ pub struct FinishParams { /// Refine Saturation as 0..1 (1 = the curves' own saturation). pub refine_sat: f32, pub vig: Option, - 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, @@ -184,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) @@ -211,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, @@ -234,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( + p: &Prepared, + fp: &FinishParams, + exact: bool, + store: impl Fn([f32; 3]) -> T + Sync + Send, +) -> Vec { + 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(); @@ -246,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]; @@ -422,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 { @@ -443,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])]; @@ -461,7 +542,7 @@ 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 diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 20fcbb86..7ffd65f1 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -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,6 +38,7 @@ pub mod transform; pub mod upright; pub mod visualize; +pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc}; pub use visualize::{MaskView, Overlay}; use lightcraft_develop::{DevelopSettings, Treatment}; @@ -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, } /// Everything the per-pixel stage needs, precomputed at output resolution. @@ -175,6 +184,39 @@ impl StageCache { t } + /// The per-view state of type `T` if there is one (unlike [`Self::extension`], never creates it). + pub fn peek_extension(&self) -> Option> { + self.ext.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|e| e.clone().downcast::().ok()) + } + + /// Bytes of the intermediate images held (each buffer counted once; the sources they were + /// made from belong to their owner and are not counted). + pub fn bytes(&self) -> usize { + fn size(i: &lightcraft_raster::Image) -> usize { + i.data.len() * std::mem::size_of::() + } + let mut seen: Vec = Vec::new(); + let mut total = 0; + let mut add = |p: usize, n: usize| { + if !seen.contains(&p) { + seen.push(p); + total += n; + } + }; + for e in self.lock().iter() { + add(Arc::as_ptr(&e.sampled) as usize, size(&e.sampled)); + if let Some((_, l)) = &e.lin { + add(Arc::as_ptr(l) as usize, size(l)); + } + let pl = &e.planes; + let planes = pl.log_l.iter().chain(pl.base.iter().map(|x| &x.1)).chain(pl.clarity.iter().map(|x| &x.1)); + for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) { + add(Arc::as_ptr(p) as usize, size(p)); + } + } + total + } + /// Number of cached output sizes. pub fn len(&self) -> usize { self.lock().len() @@ -346,14 +388,21 @@ 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; let mask = overlay_alpha(req.overlay, &plan, &prep); visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); - Rendered { image, histogram } + Rendered { image, histogram, deep: None } } /// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a @@ -376,7 +425,7 @@ pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { } pub(crate) fn is_bw(s: &DevelopSettings) -> bool { - s.treatment == Treatment::Bw || s.profile.id == "lc.mono" + s.treatment == Treatment::Bw || s.profile.id == "lc.mono" || s.profile.id.starts_with("lc.bw.") } /// `LIGHTCRAFT_PROFILE` is set: print per-stage timings to stderr. diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs new file mode 100644 index 00000000..644fc866 --- /dev/null +++ b/crates/pipeline/src/output.rs @@ -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 { + let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().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), + F32(Vec), +} + +/// 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::() - 1.0).abs() < 1e-4, "{o:?}"); + } + assert!((o.luma().iter().sum::() - 1.0).abs() < 1e-4); + } + } +} diff --git a/crates/pipeline/src/profiles.rs b/crates/pipeline/src/profiles.rs index ff95eed6..c519b2a3 100644 --- a/crates/pipeline/src/profiles.rs +++ b/crates/pipeline/src/profiles.rs @@ -1,9 +1,45 @@ //! Base rendering profiles. A profile is a look applied *underneath* the user's sliders (the sliders //! stay at their displayed values); `profile.amount` (0–200 %) scales it. All looks are our own. +//! +//! Every look is a set of *deltas* on the user's settings, each multiplied by the amount: slider +//! offsets (contrast, saturation, colour mixer, B&W mix…), colour-grading wheels (added as colour +//! vectors to the user's wheels) and point-curve fades (black/white output levels composed after +//! the user's point curves). The CPU pipeline and the GPU path both render [`effective`] +//! settings, so a profile needs no kernel of its own. No look adds grain. use std::borrow::Cow; -use lightcraft_develop::{DevelopSettings, Treatment}; +use lightcraft_develop::{DevelopSettings, Treatment, Wheel}; +use lightcraft_geom::Point; + +/// Add a colour-grading wheel (`hue` °, `sat` 0..100, `lum` −100..100) scaled by `k` to `w`, as a +/// colour vector (so a profile's tint combines with the user's instead of replacing it). +fn wheel(w: &mut Wheel, hue: f64, sat: f64, lum: f64, k: f64) { + let (ua, ub) = (w.sat * w.hue.to_radians().cos(), w.sat * w.hue.to_radians().sin()); + let (pa, pb) = (sat * k * hue.to_radians().cos(), sat * k * hue.to_radians().sin()); + let (a, b) = (ua + pa, ub + pb); + let s = a.hypot(b); + if s > 1e-9 { + w.hue = b.atan2(a).to_degrees().rem_euclid(360.0); + } else if w.sat == 0.0 { + w.hue = hue; + } + w.sat = s.clamp(0.0, 100.0); + w.lum = (w.lum + lum * k).clamp(-100.0, 100.0); +} + +/// Fade a point curve: output black level `black` and white level `white` (0..1, moved from the +/// identity by `k`), composed after the user's curve (`y → b + y·(w − b)`). +fn fade(pts: &mut Vec, black: f64, white: f64, k: f64) { + let b = (black * k).clamp(0.0, 0.45); + let w = (1.0 - (1.0 - white) * k).clamp(0.55, 1.0); + if pts.len() < 2 { + *pts = vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)]; + } + for p in pts.iter_mut() { + p.y = b + p.y.clamp(0.0, 1.0) * (w - b); + } +} /// The settings the pipeline actually renders: user settings plus the profile's look. pub fn effective(s: &DevelopSettings) -> Cow<'_, DevelopSettings> { @@ -13,45 +49,199 @@ pub fn effective(s: &DevelopSettings) -> Cow<'_, DevelopSettings> { return Cow::Borrowed(s); } let mut e = s.clone(); - let add = |v: &mut f64, d: f64, lo: f64, hi: f64| *v = (*v + d * k).clamp(lo, hi); + let add = |v: &mut f64, d: f64| *v = (*v + d * k).clamp(-100.0, 100.0); match id { + // ---- Basic "lc.neutral" => { - add(&mut e.light.contrast, -18.0, -100.0, 100.0); - add(&mut e.color.saturation, -10.0, -100.0, 100.0); - add(&mut e.light.highlights, -10.0, -100.0, 100.0); - add(&mut e.light.shadows, 8.0, -100.0, 100.0); + add(&mut e.light.contrast, -18.0); + add(&mut e.color.saturation, -10.0); + add(&mut e.light.highlights, -10.0); + add(&mut e.light.shadows, 8.0); } "lc.vivid" => { - add(&mut e.light.contrast, 16.0, -100.0, 100.0); - add(&mut e.color.saturation, 14.0, -100.0, 100.0); - add(&mut e.color.vibrance, 14.0, -100.0, 100.0); - add(&mut e.light.blacks, -6.0, -100.0, 100.0); + add(&mut e.light.contrast, 16.0); + add(&mut e.color.saturation, 14.0); + add(&mut e.color.vibrance, 14.0); + add(&mut e.light.blacks, -6.0); } "lc.landscape" => { - add(&mut e.light.contrast, 10.0, -100.0, 100.0); - add(&mut e.color.vibrance, 10.0, -100.0, 100.0); - add(&mut e.mixer.green.sat, 14.0, -100.0, 100.0); - add(&mut e.mixer.aqua.sat, 10.0, -100.0, 100.0); - add(&mut e.mixer.blue.sat, 14.0, -100.0, 100.0); - add(&mut e.mixer.blue.lum, -6.0, -100.0, 100.0); - add(&mut e.effects.clarity, 6.0, -100.0, 100.0); + add(&mut e.light.contrast, 10.0); + add(&mut e.color.vibrance, 10.0); + add(&mut e.mixer.green.sat, 14.0); + add(&mut e.mixer.aqua.sat, 10.0); + add(&mut e.mixer.blue.sat, 14.0); + add(&mut e.mixer.blue.lum, -6.0); + add(&mut e.effects.clarity, 6.0); } "lc.portrait" => { - add(&mut e.light.contrast, -6.0, -100.0, 100.0); - add(&mut e.mixer.orange.sat, -8.0, -100.0, 100.0); - add(&mut e.mixer.orange.lum, 6.0, -100.0, 100.0); - add(&mut e.mixer.red.sat, -5.0, -100.0, 100.0); - add(&mut e.effects.texture, -6.0, -100.0, 100.0); + add(&mut e.light.contrast, -6.0); + add(&mut e.mixer.orange.sat, -8.0); + add(&mut e.mixer.orange.lum, 6.0); + add(&mut e.mixer.red.sat, -5.0); + add(&mut e.effects.texture, -6.0); } "lc.mono" => { e.treatment = Treatment::Bw; - add(&mut e.light.contrast, 8.0, -100.0, 100.0); + add(&mut e.light.contrast, 8.0); + } + // ---- Film + "lc.film.warm-print" => { + add(&mut e.light.contrast, 6.0); + add(&mut e.color.saturation, -4.0); + add(&mut e.mixer.orange.lum, 4.0); + fade(&mut e.curve.master, 0.04, 0.97, k); + wheel(&mut e.grading.highlights, 40.0, 18.0, 0.0, k); + wheel(&mut e.grading.shadows, 25.0, 8.0, 0.0, k); + } + "lc.film.cool-fade" => { + add(&mut e.light.contrast, -8.0); + add(&mut e.color.saturation, -10.0); + fade(&mut e.curve.master, 0.07, 0.95, k); + wheel(&mut e.grading.shadows, 210.0, 16.0, 0.0, k); + wheel(&mut e.grading.highlights, 190.0, 6.0, 0.0, k); + } + "lc.film.golden-hour" => { + add(&mut e.color.vibrance, 10.0); + add(&mut e.curve.highlights, -8.0); + add(&mut e.mixer.orange.sat, 8.0); + add(&mut e.mixer.yellow.hue, -6.0); + wheel(&mut e.grading.global, 45.0, 10.0, 0.0, k); + wheel(&mut e.grading.highlights, 35.0, 20.0, 0.0, k); + } + "lc.film.faded-slide" => { + add(&mut e.light.contrast, 14.0); + add(&mut e.color.vibrance, 8.0); + add(&mut e.color.saturation, 6.0); + add(&mut e.curve.shadows, 10.0); + fade(&mut e.curve.blue, 0.06, 1.0, k); + fade(&mut e.curve.red, 0.0, 0.97, k); + } + // ---- Cinematic + "lc.cine.teal-amber" => { + add(&mut e.light.contrast, 10.0); + add(&mut e.mixer.orange.sat, 6.0); + add(&mut e.mixer.green.hue, 20.0); + add(&mut e.mixer.green.sat, -20.0); + add(&mut e.mixer.blue.hue, -8.0); + wheel(&mut e.grading.shadows, 195.0, 30.0, 0.0, k); + wheel(&mut e.grading.highlights, 38.0, 24.0, 0.0, k); + } + "lc.cine.night-blue" => { + add(&mut e.light.contrast, 12.0); + add(&mut e.color.saturation, -12.0); + add(&mut e.curve.darks, -8.0); + wheel(&mut e.grading.shadows, 220.0, 28.0, -4.0, k); + wheel(&mut e.grading.midtones, 215.0, 10.0, 0.0, k); + } + "lc.cine.desert-heat" => { + add(&mut e.light.contrast, 8.0); + add(&mut e.curve.lights, 6.0); + add(&mut e.mixer.blue.sat, -25.0); + add(&mut e.mixer.aqua.sat, -20.0); + wheel(&mut e.grading.global, 32.0, 16.0, 0.0, k); + wheel(&mut e.grading.highlights, 48.0, 12.0, 0.0, k); + } + "lc.cine.neon-dusk" => { + add(&mut e.light.contrast, 10.0); + add(&mut e.color.vibrance, 18.0); + add(&mut e.mixer.magenta.sat, 12.0); + add(&mut e.mixer.purple.sat, 12.0); + wheel(&mut e.grading.shadows, 265.0, 22.0, 0.0, k); + wheel(&mut e.grading.highlights, 330.0, 14.0, 0.0, k); + } + // ---- Muted + "lc.muted.matte-soft" => { + add(&mut e.light.contrast, -12.0); + add(&mut e.color.saturation, -14.0); + add(&mut e.curve.highlights, -6.0); + fade(&mut e.curve.master, 0.08, 0.96, k); + } + "lc.muted.bleached" => { + add(&mut e.light.contrast, 22.0); + add(&mut e.color.saturation, -38.0); + add(&mut e.curve.lights, 6.0); + add(&mut e.effects.clarity, 8.0); + } + "lc.muted.pastel-haze" => { + add(&mut e.light.contrast, -18.0); + add(&mut e.color.saturation, -20.0); + add(&mut e.color.vibrance, 12.0); + fade(&mut e.curve.master, 0.1, 1.0, k); + wheel(&mut e.grading.highlights, 330.0, 6.0, 0.0, k); + wheel(&mut e.grading.shadows, 200.0, 6.0, 0.0, k); + } + "lc.muted.quiet-green" => { + add(&mut e.light.contrast, -4.0); + add(&mut e.color.saturation, -8.0); + add(&mut e.mixer.green.sat, -30.0); + add(&mut e.mixer.green.hue, -10.0); + add(&mut e.mixer.yellow.sat, -18.0); + add(&mut e.mixer.aqua.sat, -15.0); + fade(&mut e.curve.master, 0.04, 1.0, k); + } + // ---- B&W + "lc.bw.mono-rich" => { + e.treatment = Treatment::Bw; + add(&mut e.light.contrast, 18.0); + add(&mut e.curve.darks, -8.0); + add(&mut e.curve.lights, 6.0); + add(&mut e.bw_mix.red, 8.0); + add(&mut e.bw_mix.orange, 10.0); + add(&mut e.bw_mix.blue, -12.0); + } + "lc.bw.red-filter" => { + e.treatment = Treatment::Bw; + add(&mut e.light.contrast, 12.0); + add(&mut e.bw_mix.red, 40.0); + add(&mut e.bw_mix.orange, 30.0); + add(&mut e.bw_mix.yellow, 10.0); + add(&mut e.bw_mix.green, -20.0); + add(&mut e.bw_mix.aqua, -30.0); + add(&mut e.bw_mix.blue, -45.0); + } + "lc.bw.soft" => { + e.treatment = Treatment::Bw; + add(&mut e.light.contrast, -14.0); + add(&mut e.bw_mix.orange, 12.0); + fade(&mut e.curve.master, 0.06, 0.97, k); + } + "lc.bw.sepia" => { + e.treatment = Treatment::Bw; + add(&mut e.light.contrast, 6.0); + fade(&mut e.curve.master, 0.04, 0.98, k); + wheel(&mut e.grading.shadows, 28.0, 18.0, 0.0, k); + wheel(&mut e.grading.highlights, 45.0, 14.0, 0.0, k); } _ => return Cow::Borrowed(s), } Cow::Owned(e) } +/// Ids of the looks [`effective`] implements (besides the identity `lc.color`). +pub const LOOK_IDS: &[&str] = &[ + "lc.neutral", + "lc.vivid", + "lc.landscape", + "lc.portrait", + "lc.mono", + "lc.film.warm-print", + "lc.film.cool-fade", + "lc.film.golden-hour", + "lc.film.faded-slide", + "lc.cine.teal-amber", + "lc.cine.night-blue", + "lc.cine.desert-heat", + "lc.cine.neon-dusk", + "lc.muted.matte-soft", + "lc.muted.bleached", + "lc.muted.pastel-haze", + "lc.muted.quiet-green", + "lc.bw.mono-rich", + "lc.bw.red-filter", + "lc.bw.soft", + "lc.bw.sepia", +]; + #[cfg(test)] mod tests { use super::*; @@ -60,12 +250,61 @@ mod tests { fn default_profile_is_borrowed_and_others_change_things() { let s = DevelopSettings::default(); assert!(matches!(effective(&s), Cow::Borrowed(_))); - for id in ["lc.neutral", "lc.vivid", "lc.landscape", "lc.portrait", "lc.mono"] { + for id in LOOK_IDS { let mut t = s.clone(); - t.profile.id = id.into(); - assert_ne!(*effective(&t), s, "{id}"); + t.profile.id = (*id).into(); + assert_ne!(*effective(&t), t, "{id}"); t.profile.amount = 0.0; assert_eq!(*effective(&t), t, "{id} at 0%"); } } + + #[test] + fn looks_scale_with_amount_and_never_add_grain() { + let mut s = DevelopSettings::default(); + s.light.contrast = 5.0; + for id in LOOK_IDS { + s.profile.id = (*id).into(); + let at = |a: f64| { + let mut t = s.clone(); + t.profile.amount = a; + effective(&t).into_owned() + }; + let (half, full, double) = (at(50.0), at(100.0), at(200.0)); + // slider deltas are linear in the amount + let d = |e: &DevelopSettings| e.light.contrast - s.light.contrast; + assert!((d(&full) - 2.0 * d(&half)).abs() < 1e-9, "{id}"); + assert!((d(&double) - 2.0 * d(&full)).abs() < 1e-9, "{id}"); + // wheels and point-curve fades grow with the amount + let lift = |e: &DevelopSettings| e.curve.master.first().map_or(0.0, |p| p.y); + for (a, b) in [(&half, &full), (&full, &double)] { + assert!(a.grading.shadows.sat <= b.grading.shadows.sat + 1e-9, "{id}"); + assert!(a.grading.highlights.sat <= b.grading.highlights.sat + 1e-9, "{id}"); + assert!(lift(a) <= lift(b) + 1e-9, "{id}"); + } + if lift(&full) > 0.0 { + assert!((lift(&full) - 2.0 * lift(&half)).abs() < 1e-9, "{id}"); + } + assert_eq!(full.grain, s.grain, "{id} adds no grain"); + assert_eq!(full.profile, s.profile, "{id}"); + } + } + + #[test] + fn wheels_and_fades_compose_with_user_settings() { + let mut s = DevelopSettings::default(); + s.grading.shadows = Wheel { hue: 195.0, sat: 10.0, lum: 0.0 }; + s.curve.master = vec![Point::new(0.0, 0.0), Point::new(0.5, 0.6), Point::new(1.0, 1.0)]; + s.profile.id = "lc.cine.teal-amber".into(); + let e = effective(&s); + assert!((e.grading.shadows.hue - 195.0).abs() < 1e-6 && (e.grading.shadows.sat - 40.0).abs() < 1e-6, "{:?}", e.grading.shadows); + assert_eq!(e.curve.master, s.curve.master, "no fade in this look: the user's curve stays"); + s.profile.id = "lc.muted.matte-soft".into(); + let e = effective(&s); + assert_eq!(e.curve.master.len(), 3, "user points kept"); + let ys: Vec = e.curve.master.iter().map(|p| p.y).collect(); + for (y, want) in ys.iter().zip([0.08, 0.08 + 0.6 * 0.88, 0.96]) { + assert!((y - want).abs() < 1e-9, "{ys:?}"); + } + } } diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 9aa5a93b..bfdfae37 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -209,6 +209,11 @@ fn stage_cache_matches_uncached_render_through_a_slider_session() { } } assert_eq!(cache.len(), 2); + // both sizes' sampled + linear images at least (12 bytes per pixel each), nothing after clear() + let b = cache.bytes(); + assert!(b >= 2 * 12 * (200 * 133 + 120 * 80), "{b}"); + cache.clear(); + assert_eq!(cache.bytes(), 0); // a different source buffer never hits another source's entries let other = Arc::new(Rgb32f::filled(src.width, src.height, [0.3, 0.3, 0.3])); assert_eq!(render_cached(&other, &info, &s, &full, &cache).image, render(&other, &info, &s, &full).image); diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index 5490194e..e36f1b41 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, Mutex}; pub use disk::{DiskCache, decode_jpeg, encode_jpeg}; pub use hash::{Hash128, Hasher128, hash_bytes}; use lightcraft_raster::Rgba8; -pub use lru::Lru; +pub use lru::{Lru, next_tick}; pub use pool::JobPool; /// Rendered-thumbnail cache: memory LRU over an optional disk cache. @@ -93,6 +93,25 @@ impl PreviewCache { } } + /// Use tick ([`next_tick`]) of the least recently used image in memory. + pub fn oldest_tick(&self) -> Option { + self.mem.lock().unwrap_or_else(|e| e.into_inner()).oldest_tick() + } + + /// Drop the least recently used image from memory (it stays on disk); returns its bytes. + pub fn evict_oldest(&self) -> Option { + self.mem.lock().unwrap_or_else(|e| e.into_inner()).pop_oldest() + } + + /// Change the memory budget (bytes), evicting at once if needed. + pub fn set_mem_budget(&self, bytes: usize) { + let mut m = self.mem.lock().unwrap_or_else(|e| e.into_inner()); + m.set_budget(bytes); + while m.cost() > bytes && m.len() > 1 { + m.pop_oldest(); + } + } + /// (entries, bytes) held in memory. pub fn mem_usage(&self) -> (usize, usize) { let m = self.mem.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/preview/src/lru.rs b/crates/preview/src/lru.rs index 1be8bca4..a15d4eec 100644 --- a/crates/preview/src/lru.rs +++ b/crates/preview/src/lru.rs @@ -2,12 +2,20 @@ use std::collections::{BTreeMap, HashMap}; use std::hash::Hash; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TICK: AtomicU64 = AtomicU64::new(1); + +/// A process-wide use counter: entries of different caches (LRUs or not) compare by recency, so +/// a memory budget can evict the least recently used entry across all of them. +pub fn next_tick() -> u64 { + TICK.fetch_add(1, Ordering::Relaxed) +} pub struct Lru { map: HashMap, - /// Use tick → key, oldest first. + /// Use tick ([`next_tick`], shared by every LRU) → key, oldest first. order: BTreeMap, - tick: u64, cost: usize, budget: usize, } @@ -16,12 +24,11 @@ impl Lru { /// An LRU holding at most `budget` cost units (e.g. bytes). The most recent entry is always /// kept, even if it alone exceeds the budget. pub fn new(budget: usize) -> Self { - Lru { map: HashMap::new(), order: BTreeMap::new(), tick: 0, cost: 0, budget } + Lru { map: HashMap::new(), order: BTreeMap::new(), cost: 0, budget } } fn touch(&mut self, k: &K) { - self.tick += 1; - let t = self.tick; + let t = next_tick(); if let Some(e) = self.map.get_mut(k) { self.order.remove(&e.2); e.2 = t; @@ -41,15 +48,28 @@ impl Lru { self.map.get(k).map(|e| &e.0) } + /// Use tick of the least recently used entry. + pub fn oldest_tick(&self) -> Option { + self.order.first_key_value().map(|(t, _)| *t) + } + + /// Remove the least recently used entry; returns its cost. + pub fn pop_oldest(&mut self) -> Option { + let (_, k) = self.order.pop_first()?; + let (_, c, _) = self.map.remove(&k)?; + self.cost -= c; + Some(c) + } + pub fn contains(&self, k: &K) -> bool { self.map.contains_key(k) } pub fn insert(&mut self, k: K, v: V, cost: usize) { self.remove(&k); - self.tick += 1; - self.order.insert(self.tick, k.clone()); - self.map.insert(k, (v, cost, self.tick)); + let t = next_tick(); + self.order.insert(t, k.clone()); + self.map.insert(k, (v, cost, t)); self.cost += cost; while self.cost > self.budget && self.map.len() > 1 { let Some((_, old)) = self.order.pop_first() else { break }; diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 15f3e940..50593f29 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -97,6 +97,19 @@ impl Image { pub fn is_empty(&self) -> bool { self.data.is_empty() } + /// [`Self::map`] into the same buffer (no second image in memory). + pub fn map_in_place(&mut self, f: impl Fn(T) -> T + Sync + Send) + where + T: Send, + { + let w = self.width; + par_rows(&mut self.data, w, |_, row| { + for p in row.iter_mut() { + *p = f(*p); + } + }); + } + pub fn map(&self, f: impl Fn(T) -> U + Sync + Send) -> Image where T: Sync, @@ -139,6 +152,19 @@ impl Image { } Self { width: w, height: h, data } } + /// [`Self::crop`] in place: rows move to the front of the same buffer (no second image). + pub fn into_crop(mut self, x0: usize, y0: usize, w: usize, h: usize) -> Self { + let x0 = x0.min(self.width); + let y0 = y0.min(self.height); + let w = w.min(self.width - x0); + let h = h.min(self.height - y0); + for (i, y) in (y0..y0 + h).enumerate() { + let src = y * self.width + x0; + self.data.copy_within(src..src + w, i * w); + } + self.data.truncate(w * h); + Self { width: w, height: h, data: self.data } + } /// Rotate 90° clockwise. pub fn rotate_cw(&self) -> Self { let (w, h) = (self.width, self.height); @@ -157,6 +183,11 @@ impl Image { out } /// Apply an EXIF orientation (stored → displayed). + /// [`Self::oriented`] without a copy when `o` is the identity. + pub fn into_oriented(self, o: lightcraft_geom::Orientation) -> Self { + if o == lightcraft_geom::Orientation::Normal { self } else { self.oriented(o) } + } + pub fn oriented(&self, o: lightcraft_geom::Orientation) -> Self { let (flip, turns) = o.to_parts(); let mut img = if flip { self.flip_h() } else { self.clone() }; @@ -231,6 +262,17 @@ mod tests { let img = Image::::from_fn(4, 3, |x, y| (y * 10 + x) as u32); let c = img.crop(1, 1, 2, 2); assert_eq!(c.data, vec![11, 12, 21, 22]); + // the in-place variants equal the copying ones + for (x, y, w, h) in [(1, 1, 2, 2), (0, 0, 4, 3), (2, 0, 9, 9), (0, 2, 4, 1), (3, 2, 1, 1)] { + assert_eq!(img.clone().into_crop(x, y, w, h), img.crop(x, y, w, h)); + } + for v in 1..=8 { + let o = Orientation::from_exif(v); + assert_eq!(img.clone().into_oriented(o), img.oriented(o)); + } + let mut m = img.clone(); + m.map_in_place(|p| p * 2 + 1); + assert_eq!(m, img.map(|p| p * 2 + 1)); let r = img.rotate_cw(); assert_eq!((r.width, r.height), (3, 4)); // top-left of rotated = bottom-left of original diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index 5180745f..050d21ee 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -126,14 +126,22 @@ pub fn neutral_to_xy(color: &ColorData, neutral: [f64; 3]) -> Xy { /// The as-shot white point: `AsShotWhiteXY`, `AsShotNeutral`, vendor multipliers, else D65. pub fn as_shot_white_xy(raw: &RawImage) -> Xy { - let c = &raw.color; + as_shot_white(&raw.color, raw.wb_multipliers) +} + +/// [`as_shot_white_xy`] from a header-only [`crate::RawInfo`]. +pub fn as_shot_white_xy_of(info: &crate::RawInfo) -> Xy { + as_shot_white(&info.color, info.wb_multipliers) +} + +fn as_shot_white(c: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Xy { if let Some(xy) = c.as_shot_white_xy { return xy; } if let Some(n) = c.as_shot_neutral { return neutral_to_xy(c, n); } - if let Some(m) = raw.wb_multipliers.filter(|m| m.iter().all(|v| *v > 0.0 && v.is_finite())) { + if let Some(m) = wb_multipliers.filter(|m| m.iter().all(|v| *v > 0.0 && v.is_finite())) { return neutral_to_xy(c, [1.0 / m[0] as f64, 1.0 / m[1] as f64, 1.0 / m[2] as f64]); } D65 diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 4ffb0666..2b0db2bb 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -1,8 +1,8 @@ //! DNG (Adobe Digital Negative Specification 1.7): raw IFD selection, pixel data (via [`crate::tiffraw`]), //! linearization, black/white levels, active area, default crop, CFA description, colour tags, opcode lists. -use crate::tiffraw::{Packing, read_image}; -use crate::{BlackLevel, Cfa, ColorData, Mat3, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes}; +use crate::tiffraw::{Packing, read_image_in}; +use crate::{BlackLevel, Cfa, ColorData, Mat3, Mode, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes}; use lightcraft_color::Xy; use lightcraft_geom::Orientation; use lightcraft_tiff::tags::{self as t, photometric}; @@ -48,7 +48,7 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData { } } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("DNG without a raw image IFD".into()))?; @@ -63,7 +63,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { if info.compression == t::compression::JPEG_XL { return Err(RawError::Unsupported("JPEG XL DNG".into())); } - let mut data = read_image(bytes, &info, tiff.order, Packing::Msb)?; + let mut data = read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?; let bits = info.bits() as u32; // linearization table (integer data only) @@ -162,6 +162,6 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes, metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 453c7523..01c9fd2c 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -145,19 +145,75 @@ pub fn probe(bytes: &[u8]) -> Option { /// Decode a raw file. pub fn decode(bytes: &[u8]) -> Result { + decode_with(bytes, Mode::Full) +} + +/// Everything [`decode`] learns about a raw file except its samples: geometry, orientation, CFA, +/// colour data, as-shot white balance, opcode lists (embedded lens corrections), metadata — read +/// from the headers without decompressing the pixel data, for imports. Equal to +/// [`RawImage::info`] of the decoded image. Black and white levels are not included (some formats +/// measure them from the samples). +/// +/// A few uncompressed vendor formats derive part of this from the samples themselves (Nikon +/// NEF: optically masked trailing columns; Olympus ORF: the CFA phase and bit depth; Pentax PEF +/// without crop tags: dark borders); for those the samples are read (unpacked, nothing to +/// decompress) and dropped. +pub fn probe_info(bytes: &[u8]) -> Result { + decode_with(bytes, Mode::Header).map(RawImage::into_info) +} + +/// How much of a file a decoder reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mode { + /// Headers and samples. + Full, + /// Headers only: the returned image has no samples, and black/white levels measured from + /// the samples are placeholders. Only [`RawImage::into_info`] may look at it. + Header, +} + +fn decode_with(bytes: &[u8], mode: Mode) -> Result { match probe(bytes).ok_or(RawError::NotRaw)? { - RawFormat::Dng => dng::decode(bytes), - RawFormat::Cr2 => vendor::cr2::decode(bytes), + RawFormat::Dng => dng::decode(bytes, mode), + RawFormat::Cr2 => vendor::cr2::decode(bytes, mode), RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes), - RawFormat::Arw => vendor::arw::decode(bytes), - RawFormat::Raf => vendor::raf::decode(bytes), - RawFormat::Rw2 => vendor::rw2::decode(bytes), - RawFormat::Pef => vendor::pef::decode(bytes), + RawFormat::Arw => vendor::arw::decode(bytes, mode), + RawFormat::Raf => vendor::raf::decode(bytes, mode), + RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), + RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } } +/// A raw file's description without its samples (see [`probe_info`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RawInfo { + pub format: RawFormat, + /// Full sensor data dimensions (including masked borders). + pub width: usize, + pub height: usize, + pub cpp: usize, + pub cfa: Option, + pub bits: u32, + pub active_area: Rect, + /// Default crop relative to the active area. + pub crop: Rect, + pub orientation: Orientation, + pub color: ColorData, + pub wb_multipliers: Option<[f32; 3]>, + pub opcodes: OpcodeLists, + pub metadata: Metadata, +} + +impl RawInfo { + /// Size of the developed image before orientation: the default crop, else the active area. + pub fn developed_size(&self) -> (usize, usize) { + let c = self.crop.clipped(self.active_area.width, self.active_area.height); + if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) } + } +} + /// Sensor samples. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum RawData { @@ -377,12 +433,59 @@ pub struct Normalized { } impl RawImage { + /// The description without the samples (what [`probe_info`] returns for the same file). + pub fn info(&self) -> RawInfo { + RawInfo { + format: self.format, + width: self.width, + height: self.height, + cpp: self.cpp, + cfa: self.cfa.clone(), + bits: self.bits, + active_area: self.active_area, + crop: self.crop, + orientation: self.orientation, + color: self.color.clone(), + wb_multipliers: self.wb_multipliers, + opcodes: self.opcodes.clone(), + metadata: self.metadata.clone(), + } + } + + /// [`Self::info`], dropping the samples. + pub fn into_info(self) -> RawInfo { + RawInfo { + format: self.format, + width: self.width, + height: self.height, + cpp: self.cpp, + cfa: self.cfa, + bits: self.bits, + active_area: self.active_area, + crop: self.crop, + orientation: self.orientation, + color: self.color, + wb_multipliers: self.wb_multipliers, + opcodes: self.opcodes, + metadata: self.metadata, + } + } + + /// Validate what a decoder read in `mode` ([`Mode::Header`]: everything but the samples). + pub(crate) fn validate_for(&self, mode: Mode) -> Result<()> { + self.check(mode == Mode::Full) + } + /// Validate internal consistency (dimensions vs data length, CFA shape, rectangles). pub fn validate(&self) -> Result<()> { + self.check(true) + } + + fn check(&self, samples: bool) -> Result<()> { if self.width == 0 || self.height == 0 || !(1..=4).contains(&self.cpp) { return Err(RawError::Corrupt("bad dimensions".into())); } - if self.data.len() != self.width * self.height * self.cpp { + if samples && self.data.len() != self.width * self.height * self.cpp { return Err(RawError::Corrupt("data length mismatch".into())); } if let Some(c) = &self.cfa @@ -449,12 +552,13 @@ impl RawImage { pub fn develop(&self, method: Method) -> Result { let n = self.normalized()?; let mut rgb = demosaic(&n, method); + drop(n); opcodes::apply_list3(&self.opcodes.list3, &mut rgb); let c = self.crop.clipped(rgb.width, rgb.height); if c.width == 0 || c.height == 0 || (c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height) { return Ok(rgb); } - Ok(rgb.crop(c.x, c.y, c.width, c.height)) + Ok(rgb.into_crop(c.x, c.y, c.width, c.height)) } } diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index fbf7e922..7da80403 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -23,8 +23,9 @@ enum ChunkPx { F32(Vec), } -/// Decode all chunks of `info` into one buffer of `width × height × cpp` samples. -pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Packing) -> Result { +/// The checks [`read_image`] makes before decoding anything (sample format, size limits, data +/// present); returns the number of samples. +pub fn check_image(data: &[u8], info: &ImageInfo) -> Result { let (w, h) = (info.width as usize, info.height as usize); let cpp = info.samples_per_pixel as usize; let total = w.checked_mul(h).and_then(|v| v.checked_mul(cpp)).ok_or(RawError::Limit("image too large"))?; @@ -49,6 +50,29 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack if available.saturating_mul(2048) < total as u64 { return Err(RawError::Corrupt(format!("{available} bytes of image data cannot hold {total} samples"))); } + Ok(total) +} + +/// Read the samples of `info` in `mode`: [`Mode::Header`] only checks them ([`check_image`]) and +/// returns no samples (floating-point data as an empty `F32`). +pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Packing) -> Result { + match mode { + crate::Mode::Full => read_image(data, info, order, packing), + crate::Mode::Header => { + check_image(data, info)?; + Ok(if info.sample_format == 3 { RawData::F32(Vec::new()) } else { RawData::U16(Vec::new()) }) + } + } +} + +/// Decode all chunks of `info` into one buffer of `width × height × cpp` samples. +pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Packing) -> Result { + let (w, h) = (info.width as usize, info.height as usize); + let total = check_image(data, info)?; + let cpp = info.samples_per_pixel as usize; + let bits = info.bits() as u32; + let float = info.sample_format == 3; + let chunks = info.chunks(data.len() as u64); let planar = info.planar == 2 && cpp > 1; let ccpp = if planar { 1 } else { cpp }; let decoded: Vec> = diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 89020038..0e82a381 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -16,8 +16,8 @@ //! //! Also: uncompressed 16-bit ARW, and lossless-JPEG tiled ARW through the generic TIFF path. -use crate::tiffraw::{Packing, read_image}; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::tiffraw::{Packing, read_image_in}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; use lightcraft_tiff::tags::{self as t, photometric}; @@ -94,7 +94,7 @@ fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0))) } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("ARW without a CFA image IFD (old ARW or SR2)".into()))?; @@ -107,6 +107,10 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let chunks = info.chunks(bytes.len() as u64); let strip_len: u64 = chunks.iter().map(|c| c.len).sum(); let (data, out_bits) = match info.compression { + 32767 if chunks.len() == 1 && strip_len >= (w * h) as u64 && strip_len < (w * h) as u64 * 5 / 4 && mode == Mode::Header => { + chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + (RawData::U16(Vec::new()), 14) + } 32767 if chunks.len() == 1 && strip_len >= (w * h) as u64 && strip_len < (w * h) as u64 * 5 / 4 => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100])); @@ -123,9 +127,9 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { 32767 => return Err(RawError::Unsupported("Sony ARW version 1 / packed compressed variant".into())), 1 => { let packing = if strip_len >= (w * h * 2) as u64 { Packing::Word16 } else { Packing::Msb }; - (read_image(bytes, &info, tiff.order, packing)?, bits) + (read_image_in(mode, bytes, &info, tiff.order, packing)?, bits) } - _ => (read_image(bytes, &info, tiff.order, Packing::Msb)?, bits), + _ => (read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?, bits), }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float ARW".into())) }; @@ -171,7 +175,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 601666d6..8629f75c 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -14,7 +14,7 @@ //! - sRAW / mRAW (YCbCr, subsampled) are not supported yet. use super::{black_from_columns, white_from_data}; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; use lightcraft_tiff::{Ifd, Tiff, makernote, tags as t}; @@ -45,7 +45,7 @@ fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { None } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("CR2 without raw IFD".into()))?; @@ -57,8 +57,24 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let chunk = lightcraft_tiff::image::Chunk { index: 0, x: 0, y: 0, width: 0, height: 0, plane: 0, offset: off, len }; let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let (fw, fh, nc, prec) = ljpeg::frame_info(src)?; - let frame = ljpeg::decode(src, (fw * fh * nc).min(crate::MAX_SAMPLES))?; - let total = frame.data.len(); + let frame = match mode { + Mode::Full => Some(ljpeg::decode(src, (fw * fh * nc).min(crate::MAX_SAMPLES))?), + Mode::Header => None, + }; + let total = match &frame { + Some(f) => f.data.len(), + None => { + let total = fw + .checked_mul(fh) + .and_then(|v| v.checked_mul(nc)) + .filter(|t| *t > 0) + .ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + if total > crate::MAX_SAMPLES { + return Err(RawError::Limit("lossless JPEG frame larger than expected")); + } + total + } + }; let slices = raw.u64s(CR2_SLICE).filter(|s| s.len() == 3 && s[1] > 0 && s[2] > 0); let widths: Vec = match &slices { Some(s) => { @@ -74,16 +90,20 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { return Err(RawError::Corrupt(format!("CR2 slices ({width}) do not divide the frame ({total} samples)"))); } let height = total / width; - let mut data = vec![0u16; total]; - let mut i = 0; - let mut x0 = 0; - for &sw in &widths { - for y in 0..height { - data[y * width + x0..y * width + x0 + sw].copy_from_slice(&frame.data[i..i + sw]); - i += sw; + let mut data = Vec::new(); + if let Some(frame) = &frame { + data = vec![0u16; total]; + let mut i = 0; + let mut x0 = 0; + for &sw in &widths { + for y in 0..height { + data[y * width + x0..y * width + x0 + sw].copy_from_slice(&frame.data[i..i + sw]); + i += sw; + } + x0 += sw; } - x0 += sw; } + drop(frame); // maker note: sensor borders and white balance let make = ifd0.string(t::MAKE).unwrap_or_default(); @@ -126,7 +146,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } @@ -183,6 +203,7 @@ pub(crate) mod tests { assert_eq!(crate::probe(&bytes), Some(RawFormat::Cr2)); let r = crate::decode(&bytes).unwrap(); assert_eq!((r.width, r.height), (64, 10)); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); assert_eq!(r.data, RawData::U16(img)); assert_eq!(r.orientation, Orientation::Rotate270); assert_eq!(r.metadata.iso, Some(400)); diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 57034d77..a6fb8c57 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -17,8 +17,8 @@ use super::{black_from_columns, white_from_data}; -use crate::tiffraw::{Packing, read_image}; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::tiffraw::{Packing, read_image_in}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; use lightcraft_tiff::{ByteOrder, Tiff, makernote, tags as t}; @@ -184,7 +184,7 @@ fn dark_trimmed(d: &[u16], w: usize, h: usize) -> Rect { Rect::new(left, 0, (right - left) & !1, h) } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let info = ifd0.image()?; @@ -196,6 +196,13 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let make = ifd0.string(t::MAKE).unwrap_or_default(); let mn = tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)); + let pair = |tag: u16| mn.as_ref().and_then(|m| m.ifd.u64s(tag)).filter(|v| v.len() == 2).map(|v| (v[0] as usize, v[1] as usize)); + let tagged = match (pair(CROP_ORIGIN), pair(CROP_SIZE)) { + (Some((x, y)), Some((cw, ch))) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= hgt => Some(Rect::new(x, y, cw, ch)), + _ => None, + }; + // without crop tags the image area is found from the samples (dark borders) + let read = if tagged.is_some() { mode } else { Mode::Full }; let data = match info.compression { 65535 => { let mn = mn.as_ref().ok_or_else(|| RawError::Corrupt("compressed PEF without maker note".into()))?; @@ -208,21 +215,20 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let src = chunk_bytes(bytes, &c) .or_else(|| bytes.get(first.offset as usize..)) .ok_or_else(|| RawError::Corrupt("PEF data outside file".into()))?; - RawData::U16(decode_huffman(src, &huff, w, hgt, bits)?) + if read == Mode::Full { RawData::U16(decode_huffman(src, &huff, w, hgt, bits)?) } else { RawData::U16(Vec::new()) } } 1 => { let strip: u64 = info.chunks(bytes.len() as u64).iter().map(|c| c.len).sum(); let packing = if strip >= (w * hgt * 2) as u64 { Packing::Word16 } else { Packing::Msb }; - read_image(bytes, &info, tiff.order, packing)? + read_image_in(read, bytes, &info, tiff.order, packing)? } c => return Err(RawError::Unsupported(format!("PEF compression {c}"))), }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float PEF".into())) }; - let pair = |tag: u16| mn.as_ref().and_then(|m| m.ifd.u64s(tag)).filter(|v| v.len() == 2).map(|v| (v[0] as usize, v[1] as usize)); - let active = match (pair(CROP_ORIGIN), pair(CROP_SIZE)) { - (Some((x, y)), Some((cw, ch))) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= hgt => Rect::new(x, y, cw, ch), - _ => dark_trimmed(samples, w, hgt), + let active = match tagged { + Some(r) => r, + None => dark_trimmed(samples, w, hgt), }; let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, @@ -265,7 +271,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 45dd3e93..e8ee126e 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -21,7 +21,7 @@ use super::white_from_data; use crate::unpack::{unpack_lsb, unpack_msb}; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::{ByteOrder, Ifd, Tiff}; use rayon::prelude::*; @@ -114,7 +114,7 @@ pub(crate) fn unpack_row(src: &[u8], bits: u32, out: &mut [u16]) { } } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let h = header(bytes)?; let raw = h.raw.ok_or_else(|| RawError::Corrupt("RAF without raw data".into()))?; let ifd = raw_ifd(raw)?; @@ -137,11 +137,14 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { return Err(RawError::Unsupported(format!("Fujifilm compressed RAF ({len} bytes for {w}x{hgt} {bits}-bit)"))); } let src = raw.get(off..off.saturating_add(len).min(raw.len())).ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?; - let mut data = vec![0u16; n]; - data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - let s = src.get(y * stride..(y * stride + packed_row).min(src.len())).unwrap_or(&[]); - unpack_row(s, store, row); - }); + let mut data = Vec::new(); + if mode == Mode::Full { + data = vec![0u16; n]; + data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let s = src.get(y * stride..(y * stride + packed_row).min(src.len())).unwrap_or(&[]); + unpack_row(s, store, row); + }); + } let cfa = cfa(&h); let active = match (pair(&h, CROP_TOP_LEFT), pair(&h, CROPPED_SIZE)) { @@ -186,7 +189,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } @@ -298,6 +301,7 @@ mod tests { let bytes = raf(w, h, bits, pack(&px, store), Some(layout), b"\xff\xd8\xff\xd9"); assert_eq!(crate::probe(&bytes), Some(RawFormat::Raf)); let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("{bits}: {e}")); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); assert_eq!(r.data, RawData::U16(px), "{bits}-bit"); assert_eq!(r.cfa, Some(Cfa::xtrans())); assert_eq!(r.active_area, Rect::new(4, 2, 12, 4)); diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 47b9e543..d996d5b2 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -20,7 +20,7 @@ //! reconstruction rule for every scale, and no permissively licensed description exists. Those files report //! [`RawError::Unsupported`]; their embedded preview still works. -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::{Tiff, tags as t}; use rayon::prelude::*; @@ -97,7 +97,7 @@ fn cfa(code: u16) -> Cfa { Cfa::bayer(s).expect("static") } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let get = |tag: u16| ifd0.u64(tag).map(|v| v as usize); @@ -118,9 +118,12 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { if src.len() < need { return Err(RawError::Corrupt(format!("RW2 raw data truncated ({} of {need} bytes)", src.len()))); } - let stream = unrotate(&src[..(need.div_ceil(CHUNK) * CHUNK).min(src.len())]); - let mut data = vec![0u16; n]; - unpack(&stream, bits, &mut data)?; + let mut data = Vec::new(); + if mode == Mode::Full { + let stream = unrotate(&src[..(need.div_ceil(CHUNK) * CHUNK).min(src.len())]); + data = vec![0u16; n]; + unpack(&stream, bits, &mut data)?; + } let active = match (get(BORDER_TOP), get(BORDER_LEFT), get(BORDER_BOTTOM), get(BORDER_RIGHT)) { (Some(tp), Some(l), Some(b), Some(r)) if b > tp && r > l => Rect::new(l, tp, r - l, b - tp).clipped(w, h), @@ -167,7 +170,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } @@ -235,6 +238,7 @@ mod tests { let bytes = rw2(w as u16, h as u16, bits as u16, 5, &encode(&px, bits)); assert_eq!(crate::probe(&bytes), Some(RawFormat::Rw2)); let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("{bits}: {e}")); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); assert_eq!(r.data, RawData::U16(px), "{bits}-bit"); assert_eq!(r.active_area, Rect::new(4, 2, w - 4, h - 2)); assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index e8f055cf..af47b84a 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -5,7 +5,7 @@ //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times //! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`). -use lightcraft_raw::{RawError, RawFormat, decode, embedded_preview, probe}; +use lightcraft_raw::{RawError, RawFormat, decode, embedded_preview, probe, probe_info}; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -55,9 +55,20 @@ fn corpus_raw_decodes() { } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); - match decode(&bytes) { + let decoded = decode(&bytes); + let dt = t1.elapsed().as_secs_f64() * 1e3; + // the header-only probe agrees with the full decode, faster + let t2 = Instant::now(); + let info = probe_info(&bytes); + let di = t2.elapsed().as_secs_f64() * 1e3; + match (&decoded, &info) { + (Ok(img), Ok(info)) => assert_eq!(&img.info(), info, "{name}: probe_info differs from decode"), + (Err(RawError::Unsupported(_)), Err(RawError::Unsupported(_))) => {} + (d, i) => panic!("{name}: decode {:?} but probe_info {:?}", d.as_ref().err(), i.as_ref().err()), + } + eprintln!("{name:44} probe_info {di:.1} ms"); + match decoded { Ok(img) => { - let dt = t1.elapsed().as_secs_f64() * 1e3; img.validate().unwrap(); assert!(img.white_at(0) > img.black.mean(), "{name}: white {} <= black {}", img.white_at(0), img.black.mean()); let mp = (img.width * img.height) as f64 / 1e6; diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 04f59507..c7e77ba0 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -100,6 +100,8 @@ fn writer_roundtrip_all_layouts() { assert_eq!(probe(&bytes), Some(RawFormat::Dng)); let back = decode(&bytes).unwrap(); assert_same(&raw, &back); + // the header-only probe describes the same image + assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap(), back.info()); } } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 913cdab8..4ea36eb7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -77,12 +77,23 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, + "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), + "hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()), "status": app.ui.status, + "memory": memory(app), }) } +/// Bytes held by the engine's caches and the renderer's (`ui.inspect` → `memory`). +pub fn memory(app: &LightcraftApp) -> Value { + let mut v = serde_json::to_value(app.session.memory_report()).unwrap_or_default(); + if let (Some(o), Value::Object(r)) = (v.as_object_mut(), app.renderer.memory()) { + o.extend(r); + } + v +} + fn modifiers(p: &Value) -> egui::Modifiers { let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } @@ -145,13 +156,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest app.widgets.iter().filter(|(id, _)| id.contains(filter)).map(|(id, r)| json!({"id": id, "rect": rect_json(*r)})).collect(), )) } - "ui.clickWidget" | "ui.dragWidget" => { + "ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" => { let Some(id) = s("id") else { return err("missing `id`") }; let Some(r) = widget_rect(app, id) else { return err(format!("no widget `{id}` on screen (see ui.widgets)")) }; let m = modifiers(p); // optional relative position inside the widget (0..1) let at = egui::pos2(r.left() + r.width() * f("fx").unwrap_or(0.5) as f32, r.top() + r.height() * f("fy").unwrap_or(0.5) as f32); - if req.method == "ui.clickWidget" { + if req.method == "ui.hoverWidget" { + app.synthetic.push(egui::Event::PointerMoved(at)); + } else if req.method == "ui.clickWidget" { let n = p.get("count").and_then(Value::as_u64).unwrap_or(1); app.synthetic.push(egui::Event::PointerMoved(at)); for _ in 0..n { diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f4c70cec..c7e3c05a 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -345,6 +345,96 @@ mod tests { h.settle(SETTLE); } + /// Profile browser: live variant thumbnails, hover previews in the loupe without touching the + /// photo or its history, click applies, the star toggles the favourite. + #[test] + fn profile_browser_previews_on_hover_and_applies_on_click() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + let r = h.request("engine.execute", json!({"command": "panel.profiles"}), t); + assert_eq!(r["result"]["open"], true, "{r}"); + h.settle(SETTLE); + let id = h.app.session.active().unwrap(); + let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); + let before = photo(&h); + assert!(h.app.renderer.variant_textures() >= 6, "variant thumbnails rendered: {}", h.app.renderer.variant_textures()); + // hover: the loupe shows the look, nothing is committed + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Profile: Vivid")); + assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); + let hover = h.app.renderer.textures.get(&crate::render::Slot::Hover).expect("hover render"); + assert_eq!(hover.photo, id); + let after = photo(&h); + assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len()), "hover leaves the photo alone"); + assert_eq!(h.app.session.undo.len(), 0); + // moving away ends the preview + h.request("ui.move", json!({"x": 5, "y": 500}), t); + h.step(); + assert!(h.app.hover_preview.is_none()); + // click applies (one history step); the star toggles the favourite + h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(photo(&h).develop.profile.id, "lc.vivid"); + assert_eq!(photo(&h).history.len(), before.history.len() + 1); + h.request("ui.clickWidget", json!({"id": "profileStar:lc.portrait"}), t); + assert_eq!(h.app.session.profile_favorites, ["lc.portrait"]); + assert_eq!(photo(&h).develop.profile.id, "lc.vivid", "the star doesn't apply"); + h.settle(SETTLE); + } + + /// Presets column: resting on a preset previews it in the loupe without a history entry; + /// thumbnails are variant renders; Create Preset includes only the checked groups. + #[test] + fn preset_hover_previews_and_create_preset_groups() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let id = h.app.session.active().unwrap(); + let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); + let before = photo(&h); + h.request("ui.hoverWidget", json!({"id": "preset:lc.bw-high-contrast"}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); + assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); + let after = photo(&h); + assert_eq!(after.develop, before.develop); + assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering"); + assert!(h.app.session.undo.is_empty()); + // thumbnails: variant textures for the visible presets + h.app.ui.preset_thumbs = true; + h.request("ui.move", json!({"x": 5, "y": 500}), t); + h.settle(SETTLE); + h.step(); + assert!(h.app.hover_preview.is_none()); + assert!(h.app.renderer.variant_textures() >= 5, "{}", h.app.renderer.variant_textures()); + // Create Preset with a group checklist: untick Light, keep Effects + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "effects.clarity", "value": 25}}), t); + h.app.ui.dialog = Some(crate::state::Dialog::create_preset()); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "presetInclude:light"}), t); + assert_eq!(r["ok"], true, "{r}"); + match &h.app.ui.dialog { + Some(crate::state::Dialog::CreatePreset { groups, .. }) => { + assert!(!groups.iter().any(|g| g == "light") && groups.iter().any(|g| g == "effects"), "{groups:?}"); + assert!(!groups.iter().any(|g| g == "crop" || g == "masks"), "crop and masks off by default"); + } + d => panic!("dialog: {d:?}"), + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let p = h.app.session.presets.iter().find(|p| !p.builtin).expect("created").clone(); + assert!(p.settings.get("light").is_none() && p.settings["effects"]["clarity"] == 25.0, "{}", p.settings); + h.settle(SETTLE); + } + /// The filter bar drives `library.filter` and saves the view as a smart album. #[test] fn filter_bar_filters_and_saves_a_smart_album() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1371a880..a08da91c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -97,6 +97,9 @@ pub struct LightcraftApp { pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, + /// 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, } impl LightcraftApp { @@ -127,6 +130,7 @@ impl LightcraftApp { gesture: None, loupe_shown: None, merge: merge::MergeState::default(), + hover_preview: None, } } @@ -343,6 +347,8 @@ impl LightcraftApp { return; } let t0 = now_ms(); + // panels set it again this frame while the pointer rests on a preset or profile + self.hover_preview = None; // 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); @@ -398,6 +404,15 @@ pub fn now_ms() -> f64 { } /// Whether the settings render in black & white. -pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool { - d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" +/// A temporary look for the loupe (hovering a preset or profile). +#[derive(Clone, Debug, PartialEq)] +pub struct HoverPreview { + /// What is previewed (e.g. "Preset: Warm Glow"). + pub label: String, + /// The photo's settings with the look applied. + pub settings: lightcraft_develop::DevelopSettings, +} + +pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool { + d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.") } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 0026ebe4..53320d3f 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -206,6 +206,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), "panel.edit" => panel(RightPanel::Edit), + "panel.profiles" => panel(RightPanel::Profiles), "panel.crop" => panel(RightPanel::Crop), "panel.remove" => panel(RightPanel::Remove), "panel.masking" => panel(RightPanel::Masking), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index df5d4d5f..0ba0c274 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -43,6 +43,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.back", "Back to Grid", Some("Escape"), ""), ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), ("panel.edit", "Edit", Some("E"), "Window"), + ("panel.profiles", "Profile Browser", None, "Window"), ("panel.crop", "Crop & Rotate", Some("C"), "Window"), ("panel.remove", "Remove", Some("H"), "Window"), ("panel.masking", "Masking", Some("M"), "Window"), @@ -326,6 +327,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // toggles between the profile browser and the Edit panel it belongs to + app.ui.right = if app.ui.right == RightPanel::Profiles { RightPanel::Edit } else { RightPanel::Profiles }; + if !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + Ok(json!({"open": app.ui.right == RightPanel::Profiles})) + } "panel.crop" => { panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); Ok(Value::Null) @@ -422,7 +431,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); + app.ui.dialog = Some(Dialog::create_preset()); Ok(Value::Null) } "dialog.copySettings" => { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 9c77ebc6..ec43e17d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -178,6 +178,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let job = job.with_overlay(view_overlay(app, &d)); app.renderer.request(Slot::Main, job, 100); } + // hovering a preset or profile: the photo with that look, shown instead of the loupe render + // once it is ready (nothing is committed) + let hover_key = match app.hover_preview.clone() { + Some(h) if !interacting => app.session.preview_job(id, rw.max(8), rh.max(8), !crop_tool, &h.settings).map(|job| { + let key = job.key; + app.renderer.request(Slot::Hover, job, 105); + (key, h.label) + }), + _ => None, + }; // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and // kept, view render cached) so stepping to them is instant if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { @@ -238,6 +248,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else if show_before { shown = draw(Slot::Before, img_rect); p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text); + } else if let Some((key, label)) = &hover_key + && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id && t.key == *key) + { + p.image(tex.tex.id(), img_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE); + let bg = Rect::from_min_size(img_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0)); + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); + shown = "hover"; } else { shown = draw(Slot::Main, img_rect); if shown == "none" { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0ac33308..498a81e1 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -77,31 +77,19 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } } - Dialog::CreatePreset { name, group } => { + Dialog::CreatePreset { name, group, groups } => { ui.add(egui::TextEdit::singleline(name).hint_text("Preset name").desired_width(f32::INFINITY)); ui.add(egui::TextEdit::singleline(group).hint_text("Group").desired_width(f32::INFINITY)); - ui.label(egui::RichText::new("Includes the current settings except crop, masks and remove.").color(t.text_dim)); - } - Dialog::CopySettings { groups } => { - ui.columns(2, |cols| { - for (i, g) in SettingsGroup::ALL.iter().enumerate() { - let key = serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); - let mut on = groups.contains(&key); - if cols[i % 2].checkbox(&mut on, g.label()).changed() { - if on { - groups.push(key); - } else { - groups.retain(|x| *x != key); - } - } - } - }); + ui.label(egui::RichText::new("Settings to include").color(t.text_dim)); + group_checklist(ui, "presetInclude", groups); } + Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), Dialog::Export { opts, long_edge, limit_kb, dir } => { use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; 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", @@ -109,6 +97,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) { @@ -282,9 +298,14 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.rename", json!({"id": id, "name": name})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})), - Dialog::CreatePreset { name, group } => { - app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group})) - } + Dialog::CreatePreset { name, group, groups } => app.run( + "preset.create", + json!({ + "name": if name.trim().is_empty() { "My Preset" } else { name }, + "group": if group.trim().is_empty() { "User Presets" } else { group }, + "groups": groups, + }), + ), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), Dialog::Export { opts, long_edge, limit_kb, dir } => app.run( "app.export", @@ -292,6 +313,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), @@ -301,6 +323,38 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result) { + let key_of = |g: &SettingsGroup| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + ui.columns(2, |cols| { + for (i, g) in SettingsGroup::ALL.iter().enumerate() { + let key = key_of(g); + let mut on = groups.contains(&key); + let r = cols[i % 2].checkbox(&mut on, g.label()); + crate::widgets::register(&r.ctx, format!("{tag}:{key}"), r.rect); + if r.changed() { + if on { + groups.push(key); + } else { + groups.retain(|x| *x != key); + } + } + } + }); + ui.horizontal(|ui| { + let all = ui.small_button("All"); + crate::widgets::register(ui.ctx(), format!("{tag}:all"), all.rect); + if all.clicked() { + *groups = SettingsGroup::ALL.iter().map(key_of).collect(); + } + let none = ui.small_button("None"); + crate::widgets::register(ui.ctx(), format!("{tag}:none"), none.rect); + if none.clicked() { + groups.clear(); + } + }); +} + /// Width of the label column in dialogs. const LABEL_W: f32 = 78.0; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 239316a6..5fa492f3 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -32,7 +32,7 @@ pub fn apply_slider_out( } } -fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) { +pub(crate) fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) { let Some(spec) = controls::find(id) else { return }; let v = controls::get(d, id).unwrap_or(spec.default); let out = slider(ui, spec, v, enabled, None); @@ -104,17 +104,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 14, bottom: 14 }).show(ui, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new("Profile").font(t.font(13.0)).color(t.text_dim)); - let name = lightcraft_engine::presets::PROFILES.iter().find(|p| p.id == d.profile.id).map(|p| p.name).unwrap_or("Color"); + let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); let r = crate::widgets::dropdown(ui, "profile", name, t.font(15.0), t.text_label); - egui::Popup::menu(&r).show(|ui| { - for p in lightcraft_engine::presets::PROFILES { - if ui.selectable_label(p.id == d.profile.id, p.name).clicked() { - let _ = app.run("develop.profile", json!({"id": p.id})); - } + egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() { + let _ = app.run("panel.profiles", json!({})); } }); }); }); + if d.profile.id != "lc.color" { + control(app, ui, &d, "profile.amount", true); + ui.add_space(6.0); + } divider(ui); section(app, ui, &d, "light", "Light", |app, ui, d| { @@ -304,6 +307,80 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(40.0); } +/// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and +/// Browse…. +fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + use lightcraft_engine::presets::{PROFILES, profile, profile_groups}; + let t = Tokens::get(ui.ctx()); + ui.set_min_width(200.0); + let cur = d.profile.id.as_str(); + // (clicked, hovered) + let mut pick: (Option<&'static str>, Option<&'static str>) = (None, None); + let item = |ui: &mut egui::Ui, + key: &str, + p: &'static lightcraft_engine::presets::ProfileInfo, + pick: &mut (Option<&'static str>, Option<&'static str>)| { + let r = ui.selectable_label(p.id == cur, p.name); + register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect); + if r.clicked() { + pick.0 = Some(p.id); + } + if r.hovered() { + pick.1 = Some(p.id); + } + }; + let heading = |ui: &mut egui::Ui, s: &str| { + ui.label(egui::RichText::new(s).font(t.semibold(11.5)).color(t.text_dim)); + }; + for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] { + let list: Vec<_> = ids.iter().filter_map(|id| profile(id)).collect(); + if list.is_empty() { + continue; + } + heading(ui, title); + for p in list { + item(ui, key, p, &mut pick); + } + ui.separator(); + } + for g in profile_groups() { + let r = ui.menu_button(g, |ui| { + ui.set_min_width(170.0); + for p in PROFILES.iter().filter(|p| p.group == g) { + item(ui, "group", p, &mut pick); + } + }); + register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); + } + ui.separator(); + if let Some(p) = profile(cur) { + let fav = app.session.profile_favorites.iter().any(|f| f == p.id); + let label = if fav { format!("Remove “{}” from Favorites", p.name) } else { format!("Add “{}” to Favorites", p.name) }; + let r = ui.button(label); + register(ui.ctx(), "profileMenu:toggleFavorite", r.rect); + if r.clicked() { + let _ = app.run("profile.favorite", json!({"id": p.id})); + } + } + let r = ui.button("Browse…"); + register(ui.ctx(), "profileMenu:browse", r.rect); + if r.clicked() { + let _ = app.run("panel.profiles", json!({})); + } + // resting on a profile previews it in the loupe + if let Some(p) = pick.1.and_then(profile) + && p.id != cur + { + let mut s = d.clone(); + s.profile.id = p.id.to_string(); + s.profile.amount = 100.0; + app.hover_preview = Some(crate::HoverPreview { label: format!("Profile: {}", p.name), settings: s }); + } + if let Some(id) = pick.0 { + let _ = app.run("develop.profile", json!({"id": id})); + } +} + pub fn sub_title(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 76cbb011..67787028 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -10,6 +10,7 @@ pub mod grid; pub mod left; pub mod masking; pub mod presets; +pub mod profiles; pub mod right; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 7e1abd36..035e5574 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -1,16 +1,21 @@ -//! The Presets column (opens to the left of the Edit panel): grouped presets with hover preview, -//! amount slider, create/favourite, import/export of preset files. +//! The Presets column (opens to the left of the Edit panel): grouped presets with hover preview +//! (the loupe shows the photo with the preset while the pointer rests on it; nothing is +//! committed), optional live thumbnails, amount slider, create/favourite, import/export of preset +//! files. use std::collections::BTreeMap; -use egui::{Align2, Rect, Sense, pos2, vec2}; -use lightcraft_develop::{ControlSpec, Section, Track}; +use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; +use lightcraft_develop::{ControlSpec, Preset, Section, Track}; use serde_json::json; -use crate::LightcraftApp; use crate::icons::{Icon, paint}; use crate::theme::Tokens; use crate::widgets::{divider, icon_button, register, slider}; +use crate::{HoverPreview, LightcraftApp}; + +/// Long edge of preset thumbnails (px). +const THUMB_EDGE: usize = 128; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -29,10 +34,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); + app.ui.dialog = Some(crate::state::Dialog::create_preset()); } let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options"); egui::Popup::menu(&more).show(|ui| { + let r = ui.checkbox(&mut app.ui.preset_thumbs, "Show Thumbnails"); + register(ui.ctx(), "presetMenu:thumbnails", r.rect); + ui.separator(); if ui.button("Import Presets…").clicked() { let _ = app.run("file.importPresets", json!({})); } @@ -66,10 +74,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } divider(ui); } + let active = app.session.active(); + let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone()); + let thumbs = app.ui.preset_thumbs && active.is_some(); + let row_h = if thumbs { 54.0 } else { 26.0 }; egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { - let mut groups: BTreeMap> = BTreeMap::new(); + let mut groups: BTreeMap> = BTreeMap::new(); for p in &app.session.presets { - groups.entry(p.group.clone()).or_default().push((p.id.clone(), p.name.clone(), p.favorite)); + groups.entry(p.group.clone()).or_default().push(p.clone()); } for (g, items) in groups { let open_id = egui::Id::new(("preset-group", &g)); @@ -94,16 +106,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !open { continue; } - for (pid, name, fav) in items { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + for pr in items { + let (pid, name, fav) = (pr.id.clone(), pr.name.clone(), pr.favorite); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); register(ui.ctx(), format!("preset:{pid}"), r); - let active = last.as_ref().is_some_and(|(l, _)| *l == pid); - if active { + let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); + if is_last { ui.painter().rect_filled(r, 0.0, t.tool_active); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7)); } - ui.painter().text(pos2(r.left() + 40.0, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); + let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None }; + let mut text_x = r.left() + 40.0; + if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) { + let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0)); + ui.painter().rect_filled(tr, 2.0, t.canvas); + if let Some(job) = app.session.variant_job(id, s, THUMB_EDGE) + && let Some(tex) = app.renderer.variant(job) + { + ui.painter().image(tex.tex.id(), tr, cover_uv(tr, tex.size), Color32::WHITE); + } + text_x = tr.right() + 10.0; + } + ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); if fav { paint( ui.painter(), @@ -112,6 +137,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { t.star, ); } + // resting on a preset previews it in the loupe (no history entry) + if resp.hovered() + && let Some(s) = look + { + app.hover_preview = Some(HoverPreview { label: format!("Preset: {name}"), settings: s }); + } if resp.clicked() { let _ = app.run("preset.apply", json!({"id": pid, "amount": 100})); ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0))); @@ -131,3 +162,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); }); } + +/// The part of a `size` image that covers `r` (centre crop), as texture coordinates. +pub fn cover_uv(r: Rect, size: [usize; 2]) -> Rect { + let a = size[0] as f32 / size[1].max(1) as f32; + let ra = r.width() / r.height().max(1.0); + if a > ra { + let f = ra / a; + Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0)) + } else { + let f = a / ra; + Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0)) + } +} diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs new file mode 100644 index 00000000..9e32c52a --- /dev/null +++ b/crates/ui-egui/src/panels/profiles.rs @@ -0,0 +1,139 @@ +//! The profile browser (the profile menu's "Browse…", or the grid button next to it): every +//! profile as a live thumbnail of the current photo, grouped (Favorites first), with a favourite +//! star, the applied profile's amount, a temporary loupe preview while hovering and apply on click. + +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_develop::DevelopSettings; +use lightcraft_engine::presets::{PROFILES, ProfileInfo, profile, profile_groups}; +use serde_json::json; + +use super::presets::cover_uv; +use crate::icons::{Icon, paint}; +use crate::state::RightPanel; +use crate::theme::Tokens; +use crate::widgets::{divider, icon_button, register}; +use crate::{HoverPreview, LightcraftApp}; + +/// Long edge of the variant thumbnails (px). +const THUMB_EDGE: usize = 256; +/// Thumbnail columns. +const COLUMNS: usize = 2; + +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let t = Tokens::get(ui.ctx()); + let d = (*app.session.develop_of(id).unwrap_or_default()).clone(); + // header: back + title + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); + let mut back = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(hr.min + vec2(12.0, 9.0), vec2(28.0, 28.0)))); + if icon_button(&mut back, "profilesBack", Icon::ChevronLeft, vec2(28.0, 28.0), false, true, "Back to Edit").clicked() { + app.ui.right = RightPanel::Edit; + } + ui.painter().text(pos2(hr.left() + 46.0, hr.center().y + 2.0), Align2::LEFT_CENTER, "Profiles", t.semibold(15.0), t.text); + divider(ui); + // the applied profile and its amount + let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); + let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); + ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, "Profile", t.font(13.0), t.text_dim); + ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, name, t.font(14.0), t.text_label); + if d.profile.id != "lc.color" { + super::edit::control(app, ui, &d, "profile.amount", true); + ui.add_space(6.0); + } + divider(ui); + let mut sections: Vec<(&str, &str, Vec<&'static ProfileInfo>)> = Vec::new(); + let favs: Vec<&'static ProfileInfo> = app.session.profile_favorites.iter().filter_map(|f| profile(f)).collect(); + if !favs.is_empty() { + sections.push(("Favorites", "fav", favs)); + } + for g in profile_groups() { + sections.push((g, "", PROFILES.iter().filter(|p| p.group == g).collect())); + } + for (title, tag, list) in sections { + let open_id = egui::Id::new(("profile-group", title)); + let open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(true); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + register(ui.ctx(), format!("profileGroup:{title}"), r); + paint( + ui.painter(), + Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), + if open { Icon::ChevronDown } else { Icon::ChevronRight }, + t.text_label, + ); + ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); + ui.painter().text(pos2(r.right() - 22.0, r.center().y), Align2::RIGHT_CENTER, list.len().to_string(), t.font(12.0), t.text_dim); + if resp.clicked() { + ui.data_mut(|m| m.insert_temp(open_id, !open)); + } + if !open { + continue; + } + let gap = 8.0; + let cw = ((ui.available_width() - 44.0 - gap * (COLUMNS as f32 - 1.0)) / COLUMNS as f32).max(40.0); + let th = (cw * 0.75).round(); + for row in list.chunks(COLUMNS) { + let (rr, _) = ui.allocate_exact_size(vec2(ui.available_width(), th + 30.0), Sense::hover()); + for (i, p) in row.iter().enumerate() { + let cell = Rect::from_min_size(pos2(rr.left() + 22.0 + i as f32 * (cw + gap), rr.top()), vec2(cw, th + 24.0)); + cell_ui(app, ui, &d, id, tag, p, cell, th); + } + } + ui.add_space(6.0); + } + ui.add_space(30.0); +} + +/// `d` with profile `p` (at 100 % unless it is the applied one). +fn with_profile(d: &DevelopSettings, p: &ProfileInfo) -> DevelopSettings { + let mut s = d.clone(); + if s.profile.id != p.id { + s.profile.id = p.id.to_string(); + s.profile.amount = 100.0; + } + s +} + +#[allow(clippy::too_many_arguments)] +fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: PhotoId, tag: &str, p: &'static ProfileInfo, cell: Rect, th: f32) { + let t = Tokens::get(ui.ctx()); + let key = if tag.is_empty() { format!("profileCell:{}", p.id) } else { format!("profileCell:{tag}:{}", p.id) }; + let resp = ui.interact(cell, egui::Id::new(&key), Sense::click()); + register(ui.ctx(), key, cell); + let thumb = Rect::from_min_size(cell.min, vec2(cell.width(), th)); + let s = with_profile(d, p); + let painter = ui.painter(); + painter.rect_filled(thumb, 3.0, t.canvas); + if let Some(job) = app.session.variant_job(id, &s, THUMB_EDGE) + && let Some(tex) = app.renderer.variant(job) + { + painter.image(tex.tex.id(), thumb, cover_uv(thumb, tex.size), Color32::WHITE); + } + let applied = d.profile.id == p.id; + if applied { + painter.rect_stroke(thumb.expand(1.0), 4.0, Stroke::new(2.0, t.pick), egui::StrokeKind::Outside); + } else if resp.hovered() { + painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } + let name_color = if applied { t.text } else { t.text_label }; + painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, p.name, t.font(12.0), name_color); + // favourite star (always shown when set, on hover otherwise) + let fav = app.session.profile_favorites.iter().any(|f| f == p.id); + let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0)); + register(ui.ctx(), format!("profileStar:{}{}", if tag.is_empty() { "" } else { "fav:" }, p.id), star); + let over_star = resp.hover_pos().is_some_and(|q| star.contains(q)); + if fav || resp.hovered() { + painter.circle_filled(star.center(), 10.0, Color32::from_black_alpha(110)); + paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE }); + } + if resp.hovered() && !applied && !over_star { + app.hover_preview = Some(HoverPreview { label: format!("Profile: {}", p.name), settings: s.clone() }); + } + let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group)); + if resp.clicked() { + if resp.interact_pointer_pos().is_some_and(|q| star.contains(q)) { + let _ = app.run("profile.favorite", json!({"id": p.id})); + } else if !applied { + let _ = app.run("develop.profile", json!({"id": p.id, "amount": 100})); + } + } +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index d271ffd0..6231c573 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -27,6 +27,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.y = 0.0; match app.ui.right { RightPanel::Edit => super::edit::show(app, ui, id), + RightPanel::Profiles => super::profiles::show(app, ui, id), RightPanel::Crop => crop(app, ui, id), RightPanel::Remove => remove(app, ui, id), RightPanel::Masking => super::masking::show(app, ui, id), diff --git a/crates/ui-egui/src/panels/strip.rs b/crates/ui-egui/src/panels/strip.rs index 9b7a1dde..ae1d6e55 100644 --- a/crates/ui-egui/src/panels/strip.rs +++ b/crates/ui-egui/src/panels/strip.rs @@ -35,7 +35,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("masking", Icon::Mask, RightPanel::Masking, "Masking (M)"), ("redeye", Icon::Eye, RightPanel::RedEye, "Red Eye"), ] { - if icon_button(ui, id, icon, sz, app.ui.right == panel, has_photo, tip).clicked() { + let on = app.ui.right == panel || (panel == RightPanel::Edit && app.ui.right == RightPanel::Profiles); + if icon_button(ui, id, icon, sz, on, has_photo, tip).clicked() { let _ = app.run(&format!("panel.{id}"), json!({})); } if id == "edit" { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 3703dcb8..5aceea13 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -37,6 +37,12 @@ pub enum Slot { /// Background preparation of a neighbouring photo (no texture: its decoded source and view /// render are cached by the engine). 0 = next, 1 = previous. Prefetch(u8), + /// A variant thumbnail (profile / preset browsers), by its job key ([`Session::variant_job`]). + /// Kept in a bounded LRU ([`VARIANT_TEXTURES`]). + Variant(u64), + /// The loupe while hovering a preset or profile: the photo with the hovered look (nothing is + /// committed). + Hover, } pub struct Tex { @@ -69,6 +75,11 @@ struct Queued { job: RenderJob, } +/// Variant thumbnails kept as textures (LRU). +pub const VARIANT_TEXTURES: usize = 96; +/// Priority of variant thumbnail jobs: below on-screen grid thumbnails and the loupe. +const VARIANT_PRIORITY: u32 = 6; + pub struct Renderer { pool: JobPool, /// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet. @@ -90,8 +101,19 @@ pub struct Renderer { quick_tried: HashMap, /// Prefetch slot → key of the last job submitted (each runs once). prefetched: HashMap, + /// Variant key → frame it was last asked for (LRU of [`Slot::Variant`] textures). + variant_used: HashMap, + /// Frames polled so far. + frame: u64, + /// Since when nothing has been pending, and whether the GPU pool was trimmed since. + #[cfg(not(target_arch = "wasm32"))] + idle: Option<(std::time::Instant, bool)>, } +/// After this long without renders the GPU renderer's pool of recycled buffers is freed. +#[cfg(not(target_arch = "wasm32"))] +const IDLE_TRIM: std::time::Duration = std::time::Duration::from_secs(3); + impl Default for Renderer { fn default() -> Self { let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; @@ -108,6 +130,10 @@ impl Default for Renderer { stages: HashMap::new(), quick_tried: HashMap::new(), prefetched: HashMap::new(), + variant_used: HashMap::new(), + frame: 0, + #[cfg(not(target_arch = "wasm32"))] + idle: None, } } } @@ -140,7 +166,8 @@ impl Renderer { } self.pending.insert(slot, (job.key, priority)); // (an offload keeps its own per-view stage caches; the flag tells it to) - let job = if matches!(slot, Slot::Main | Slot::Before) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; + let job = + if matches!(slot, Slot::Main | Slot::Before | Slot::Hover) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; if self.offload.is_some() { self.seq += 1; self.queue.retain(|q| q.slot != slot); @@ -149,7 +176,13 @@ impl Renderer { return; } let key = job.key; - self.pool.submit(slot, key, priority, Box::new(move || job.run())); + let background = matches!(slot, Slot::Thumb(_) | Slot::ThumbQuick(_) | Slot::Prefetch(_)); + self.pool.submit( + slot, + key, + priority, + Box::new(move || if background { lightcraft_engine::memory::in_background(|| job.run()) } else { job.run() }), + ); } /// Request a stand-in for `slot` (once per job key). @@ -172,7 +205,7 @@ impl Renderer { self.prefetched.insert(slot, job.key); self.pending.insert(slot, (job.key, priority)); let key = job.key; - self.pool.submit(slot, key, priority, Box::new(move || job.run())); + self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run()))); } /// Is a request for `slot` queued or running? @@ -207,12 +240,73 @@ impl Renderer { } /// Thumbnail textures currently loaded. + /// A variant thumbnail ([`Slot::Variant`]): its texture once rendered; until then the job is + /// queued at low priority (below on-screen grid thumbnails). Call it every frame the variant + /// is visible: variants not asked for during the last frames leave the queue, and textures + /// beyond [`VARIANT_TEXTURES`] are evicted least recently used first. + pub fn variant(&mut self, job: RenderJob) -> Option<&Tex> { + let key = job.key; + let slot = Slot::Variant(key); + self.variant_used.insert(key, self.frame); + if !self.textures.contains_key(&slot) { + self.request(slot, job, VARIANT_PRIORITY); + } + self.textures.get(&slot) + } + + /// Variant thumbnail textures currently loaded. + pub fn variant_textures(&self) -> usize { + self.textures.keys().filter(|s| matches!(s, Slot::Variant(_))).count() + } + + /// Drop variant jobs nobody asked for in the last frames, and the least recently used + /// variant textures beyond the budget. + fn evict_variants(&mut self) { + let now = self.frame; + let stale = |used: Option<&u64>| used.is_none_or(|f| now.saturating_sub(*f) > 2); + let used = &self.variant_used; + let dropped = self.pool.reprioritize(|s, p| match s { + Slot::Variant(k) if stale(used.get(k)) => None, + _ => Some(p), + }); + for s in dropped { + self.pending.remove(&s); + } + let pending = &mut self.pending; + self.queue.retain(|q| match q.slot { + Slot::Variant(k) if stale(used.get(&k)) => { + pending.remove(&q.slot); + false + } + _ => true, + }); + let n = self.variant_textures(); + if n > VARIANT_TEXTURES { + let mut have: Vec<(u64, u64)> = self + .textures + .keys() + .filter_map(|s| if let Slot::Variant(k) = s { Some((self.variant_used.get(k).copied().unwrap_or(0), *k)) } else { None }) + .collect(); + have.sort_unstable(); + for (_, k) in have.into_iter().take(n - VARIANT_TEXTURES) { + self.textures.remove(&Slot::Variant(k)); + self.variant_used.remove(&k); + } + } + if self.variant_used.len() > 4 * VARIANT_TEXTURES { + let textures = &self.textures; + let pending = &self.pending; + self.variant_used.retain(|k, _| textures.contains_key(&Slot::Variant(*k)) || pending.contains_key(&Slot::Variant(*k))); + } + } + pub fn thumb_textures(&self) -> usize { self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_) | Slot::ThumbQuick(_))).count() } /// Collect finished jobs into textures. Returns true if anything changed. pub fn poll(&mut self, ctx: &egui::Context, session: &mut Session) -> bool { + self.frame += 1; // wasm: run one job per frame on this thread, timed with the host clock #[cfg(target_arch = "wasm32")] let inline_ms = { @@ -292,12 +386,50 @@ impl Renderer { } changed = true; } + self.evict_variants(); + #[cfg(not(target_arch = "wasm32"))] + self.trim_when_idle(ctx); if !self.pending.is_empty() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } changed } + /// Free the GPU renderer's recycled buffers once nothing has rendered for [`IDLE_TRIM`] (they + /// are reallocated by the next render; while working, renders reuse them). + #[cfg(not(target_arch = "wasm32"))] + fn trim_when_idle(&mut self, ctx: &egui::Context) { + if !self.pending.is_empty() { + self.idle = None; + return; + } + let now = std::time::Instant::now(); + let (since, trimmed) = *self.idle.get_or_insert((now, false)); + if trimmed { + return; + } + let waited = now.duration_since(since); + if waited >= IDLE_TRIM { + lightcraft_engine::gpu::trim_pool(0); + lightcraft_engine::memory::release(); + self.idle = Some((since, true)); + } else { + ctx.request_repaint_after(IDLE_TRIM - waited); + } + } + + /// What the renderer holds: per-view stage caches (CPU images and GPU buffers) and textures. + pub fn memory(&self) -> serde_json::Value { + let cpu: usize = self.stages.values().map(|s| s.bytes()).sum(); + let gpu: usize = self.stages.values().map(|s| lightcraft_engine::gpu::stage_bytes(s)).sum(); + let tex: usize = self.textures.values().map(|t| t.size[0] * t.size[1] * 4).sum(); + let copies: usize = self.textures.values().filter_map(|t| t.pixels.as_ref()).map(|p| p.pixels.len() * 4).sum(); + serde_json::json!({ + "stageCaches": {"count": self.stages.len(), "cpuBytes": cpu, "gpuBytes": gpu}, + "textures": {"count": self.textures.len(), "bytes": tex, "cpuCopyBytes": copies}, + }) + } + /// CPU copies of the current textures by id (see [`Self::keep_pixels`]). pub fn cpu_textures(&self) -> HashMap { self.textures.values().filter_map(|t| Some((t.tex.id(), crate::softpaint::CpuTexture::linear(t.pixels.clone()?)))).collect() diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cdca7f19..b649a029 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -31,11 +31,13 @@ pub enum RightPanel { Activity, Keywords, Info, + /// The profile browser (part of Edit). + Profiles, } impl RightPanel { pub fn is_edit_tool(self) -> bool { - matches!(self, RightPanel::Edit | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye) + matches!(self, RightPanel::Edit | RightPanel::Profiles | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye) } } @@ -83,6 +85,8 @@ pub struct UiState { pub right: RightPanel, /// Presets column open (opens to the left of the Edit panel). pub presets: bool, + /// Presets column: show a live thumbnail of the photo with each preset. + pub preset_thumbs: bool, pub filmstrip: bool, pub zoom: Zoom, /// Pan offset of the loupe when zoomed (image-normalized centre). @@ -151,6 +155,19 @@ pub struct UiState { pub dialog: Option, } +/// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything +/// but crop, masks, spots and red eye. +pub fn default_preset_groups() -> Vec { + lightcraft_develop::SettingsGroup::default_copy().iter().filter_map(|g| serde_json::to_value(g).ok()?.as_str().map(str::to_string)).collect() +} + +impl Dialog { + /// A fresh Create Preset dialog. + pub fn create_preset() -> Dialog { + Dialog::CreatePreset { name: String::new(), group: "User Presets".into(), groups: default_preset_groups() } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { @@ -170,9 +187,12 @@ pub enum Dialog { NewSmartAlbum { name: String, }, + /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). CreatePreset { name: String, group: String, + #[serde(default = "default_preset_groups")] + groups: Vec, }, CopySettings { groups: Vec, @@ -199,6 +219,7 @@ impl Default for UiState { left_panel: false, right: RightPanel::Edit, presets: false, + preset_thumbs: false, filmstrip: true, zoom: Zoom::Fit, pan: (0.5, 0.5), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 71d5ee05..15ee1fd8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -21,9 +21,9 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status | +| `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status, memory (bytes per cache, see `library.memory`; plus stage caches and textures) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | -| `ui.clickWidget` / `ui.dragWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` | Real egui input on a widget | +| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | | `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) | | `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 88f19924..b8db4585 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -88,6 +88,33 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo one is rendered (decoded source kept, view render cached): stepping through photos shows the developed image in ~50 ms. +## Memory (M5.6) +- `library.memory` reports what the engine's caches hold (decoded thumbnail / preview / full-size + sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled + and retired); `ui.inspect` → `memory` adds the loupe's stage caches (CPU images, GPU buffers) + and the textures. +- Heap profile: build `lightcraft-cli` with `--features dhat-heap`; `library.memory` then also + reports live/peak heap bytes and the run writes `dhat-heap.json` (`LIGHTCRAFT_DHAT_FILE`), whose + allocation sites at the peak (`t-gmax`) show who holds the memory. +- Measuring the scenario (import 14 raws from `corpus/raw`, open the loupe, step 12 times): + `/usr/bin/time -l lightcraft-cli snapshot --script steps.jsonl -o out.png` → "maximum + resident set size" and "peak memory footprint". Run it several times: the high-water mark is + noisy (allocator caching, scheduling). On Apple silicon GPU buffers count in the footprint. +- Imports read raw headers only (`lightcraft_raw::probe_info`): no pixel data is decompressed. +- One budget (`memory::budget`, default min(25 % of RAM, 1.5 GiB); `LIGHTCRAFT_MEMORY_MB` or + `app.memoryBudget {mb}`): half for the engine caches (decoded thumbnail / preview / full + sources and rendered previews, evicted least recently used *across* them; the photo on screen + stays), a quarter for decodes in flight (`memory::work_gate`: grid thumbnails, neighbour + prefetch and import probes wait for room, loupe and export loads never wait), an eighth for the + GPU buffer pool (reuse by best fit within 25 %; freed after 3 s idle). +- Decodes free the file's bytes and the raw samples as soon as they are consumed and colour / + crop / orient in place. macOS keeps freed large blocks resident ("reusable") until the system + is short of memory, so the resident size climbed with every photo decoded; the apps install + `malloc_zone_pressure_relief` as `memory::set_release_hook`, called after each decode, import + and idle trim. +- Result (M4 Pro, 14 raws, loupe, 12 steps): peak RSS 1.9–2.3 GB → 0.83–0.99 GB, peak footprint + 2.5–2.6 GB → 0.91–0.96 GB, heap peak 930 → ~600 MB; stepping median ~13 ms (unchanged). + ## Open items - Keep the loupe texture on the GPU (render into an `egui-wgpu` texture on eframe's device instead of reading back); share the device with eframe. diff --git a/docs/mcp.md b/docs/mcp.md index 02bdcb09..f343d88a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. diff --git a/docs/parity.md b/docs/parity.md index 9d2fb880..291cd8fd 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -27,53 +27,44 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | 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) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | +| 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) | 5 | 1 | 4 | 2 | 4/4 (100%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | | K. Masking (MASK) | 10 | 6 | 7 | 0 | 7/8 (88%) | 3/5 (60%) | -| L. Presets (PRE) | 1 | 4 | 2 | 1 | 0/2 (0%) | 1/2 (50%) | +| 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%) | | O. Merge (MERGE) | 3 | 0 | 1 | 0 | — | — | | 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%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 10 | 2 | 5 | 1 | 7/8 (88%) | 3/5 (60%) | +| 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%) | +| Z. Keyboard shortcuts (desktop) | 50 | 14 | 16 | 1 | 43/52 (83%) | 7/23 (30%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 214 | 97 | 189 | 37 | 157/198 (79%) | 49/144 (34%) | +| **Total** | 225 | 93 | 182 | 37 | 165/198 (83%) | 51/144 (35%) | ## 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. -5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. -6. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; - live preview while hovering presets/profiles/versions. Medium effort. -7. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). -8. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. -9. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), - XMP prefs, cache, GPU. Medium effort. -10. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination +1. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. +2. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +3. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination album; browse folders before adding. Medium effort. -11. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in +4. **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. -12. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no +5. **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). -13. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. -14. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. -15. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. -16. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). +6. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +7. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -102,10 +93,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). @@ -270,12 +260,12 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PROF-DROPDOWN | Profile menu | P0 | 🟡 | `cmd:develop.profile`, `cmd:profiles.list`, `crates/ui-egui/src/panels/edit.rs` | no favourites / recent / browse entry | -| LR-PROF-BROWSER | Profile browser | P1 | ⬜ | | | +| LR-PROF-DROPDOWN | Profile menu | P0 | ✅ | `cmd:develop.profile`, `cmd:profiles.list`, `cmd:profiles.menu`, `cmd:profile.favorite`, `crates/ui-egui/src/panels/edit.rs` | Favorites, Recent (last 5), group submenus, favourite toggle; favourites/recent persist with the library; Amount slider under the menu for non-default profiles; Browse… opens the profile browser; resting on an entry previews it in the loupe | +| LR-PROF-BROWSER | Profile browser | P1 | ✅ | `cmd:panel.profiles`, `crates/ui-egui/src/panels/profiles.rs`, `crates/engine/src/media.rs` | grid of live variant thumbnails of the current photo per group (Favorites first), star toggles favourites, amount slider, hover = loupe preview (no history), click applies | | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ⬜ | | | +| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | | LR-PROF-AMOUNT | Profile amount | P1 | ✅ | `ctl:profile.amount` | | @@ -352,8 +342,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PRE-PANEL | Presets panel | P0 | 🟡 | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; no hover preview | -| LR-PRE-CREATE | Create preset | P0 | 🟡 | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | dialog has no per-group checkboxes (fixed subset) | +| LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | +| LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) | | LR-PRE-MANAGE | Manage presets | P1 | 🟡 | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export` | no rename, update-with-current, move group, hide groups | | LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | | LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | @@ -426,9 +416,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`) | | @@ -490,7 +480,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`) | covers ratings, albums, deletes, edits | | LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | | | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | -| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ⬜ | | presets, profiles, versions | +| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/detail.rs` | presets, profile menu and profile browser preview in the loupe; versions do not | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | | LR-BEHAV-BG-TASKS | Background tasks | P0 | 🟡 | `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; no progress popover for import/export | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | @@ -633,7 +623,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-PREFS | Settings — ⌘, | P0 | ⬜ | | no command | | 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` | | @@ -658,8 +648,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 | ⬜ | | | @@ -692,7 +682,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