Merge main into the masking branch (output space / deep finish, presets, parity)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 13:23:58 -04:00
commit 4075b42d85
71 changed files with 3524 additions and 325 deletions

81
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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"]

View File

@ -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);
}
}
}

View File

@ -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<String> = 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" {

View File

@ -88,6 +88,9 @@ impl WireJob {
quality: if self.draft { Quality::Draft } else { Quality::Full },
apply_crop: self.apply_crop,
overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1),
// workers render previews (exports run in-process)
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
depth: lightcraft_engine::pipeline::OutputDepth::U8,
}
}

View File

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

View File

@ -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);
}
}
}

View File

@ -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<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut seed_demo = true;

View File

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

View File

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

View File

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

View File

@ -265,10 +265,26 @@ pub fn specs() -> Vec<CommandSpec> {
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();

View File

@ -144,7 +144,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
}
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);

View File

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

View File

@ -36,7 +36,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
/// 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<lightcraft_develop::EmbeddedLens> {
pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop::EmbeddedLens> {
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<ProbeInfo, String> {
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<ProbeInfo, String> {
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<usi
/// instead of each one waking the pool from outside and waiting for it (which costs more than the
/// loops themselves when the machine is busy).
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> 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<u8>, 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)
});

View File

@ -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) {

View File

@ -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<lightcraft_develop::SettingsGroup>,
pub presets: Vec<lightcraft_develop::Preset>,
/// Favourite profile ids (persisted with the library, like preset favourites).
pub profile_favorites: Vec<String>,
/// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]).
pub profile_recent: Vec<String>,
/// 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(),

View File

@ -72,6 +72,9 @@ pub struct LibraryStores {
struct PresetsFile {
user: Vec<Preset>,
favorites: Vec<String>,
/// Favourite profiles, and the recently applied ones (newest first).
profile_favorites: Vec<String>,
profile_recent: Vec<String>,
}
#[derive(Default, Serialize, Deserialize)]
@ -103,10 +106,13 @@ struct PrefsFile {
last_export: Option<serde_json::Value>,
}
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::<PrefsFile>(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}");

View File

@ -94,11 +94,15 @@ impl SourceRef {
pub struct MediaCache {
/// Decoded thumbnail-level sources (LRU by bytes).
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
previews: Vec<(PhotoId, Arc<Rgb32f>)>,
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
previews: Vec<(PhotoId, Arc<Rgb32f>, u64)>,
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
full: Option<(PhotoId, Arc<Rgb32f>)>,
full: Option<(PhotoId, Arc<Rgb32f>, 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<FileLoader>,
pub file_probe: Option<FileProbe>,
pub preview_loader: Option<PreviewLoader>,
@ -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<Arc<Rgb32f>> {
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<Rgb32f>) {
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<Rendered, String>, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick };
let rendered = |image: Rgba8| {
let histogram = Histogram::of_srgb8(&image);
Ok(Rendered { image, histogram })
Ok(Rendered { image, histogram, deep: None })
};
for (cache, k) in &self.cached {
if let Some(img) = cache.get(*k) {
@ -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<RenderJob> {
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<RenderJob> {
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<Rendered, String> {
let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?;
job.request.space = space;
job.request.depth = depth;
job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db);
let r = job.run();
self.accept(&r);
r.rendered
}
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
@ -552,6 +688,54 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + 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);
}
}

295
crates/engine/src/memory.rs Normal file
View File

@ -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::<usize>().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<usize> {
#[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<bool> = 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<R>(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<WorkGate> = 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<HeapUsage>,
}
static HEAP: OnceLock<fn() -> HeapUsage> = OnceLock::new();
static RELEASE: OnceLock<fn()> = 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<HeapUsage> {
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
}
}

View File

@ -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::<Vec<_>>();
json!({
"favorites": list(&self.profile_favorites),
"recent": list(&self.profile_recent),
"groups": profile_groups().into_iter().map(|g| json!({
"name": g,
"profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
}
}
#[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");

View File

@ -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");
}

View File

@ -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);
}

View File

@ -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<wgpu::Buffer>,
buf: Option<Tracked>,
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<Vec<wgpu::Buffer>>);
struct Retired(std::cell::RefCell<Vec<Tracked>>);
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<Tracked> {
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<u32> = 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<Vec<wgpu::Buffer>>,
free: std::sync::Mutex<Vec<Tracked>>,
/// 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<String> = m.iter().map(|r| format!("vec3<f32>({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect();
s += &format!("const {name} = array<vec3<f32>, 3>({});\n", rows.join(", "));
}
@ -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<Item = wgpu::CommandBuffer>) {
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<Item = Tracked>) {
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.

View File

@ -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<String> {
}
}
/// 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::<GpuStages>().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<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
#[cfg(not(target_arch = "wasm32"))]
{
if !enabled() {
// the kernel writes 8-bit output: high-bit-depth exports render on the CPU
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 {
return None;
}
let gpu = device()?;
let ext = stages.map(|c| c.extension::<GpuStages>());
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,

View File

@ -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);

View File

@ -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<Buf>| {
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<'_>)| {

View File

@ -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<u32>) {
let w = pu(F_W);
@ -429,9 +449,14 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
// --- gamut map to sRGB (desaturate towards luminance until in range)
var r = mul3(TO_SRGB, d);
let yy = clamp(0.2126 * r.x + 0.7152 * r.y + 0.0722 * r.z, 0.0, 1.0);
// --- gamut map to the output space (desaturate towards luminance until in range)
let om = array<vec3<f32>, 3>(
vec3<f32>(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)),
vec3<f32>(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)),
vec3<f32>(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)),
);
var r = mul3(om, d);
let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0);
var tg = 1.0;
for (var k = 0u; k < 3u; k++) {
let cc = r[k];
@ -469,5 +494,6 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e + k;
}
e = vec3<f32>(out_encode(e.x), out_encode(e.y), out_encode(e.z));
out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u);
}

View File

@ -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);
}
}
}

View File

@ -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);
}

View File

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

View File

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

View File

@ -1,8 +1,8 @@
//! The per-pixel stage: everything after the spatial planes are ready, in one parallel pass.
use lightcraft_color::luminance_2020;
use lightcraft_color::spline::{Lut1, MonotoneCurve};
use lightcraft_color::transfer::linear_to_srgb;
use lightcraft_color::{REC2020, SRGB, luminance_2020};
use lightcraft_develop::{DevelopSettings, LocalAdjustments, ToneCurve, VignetteStyle};
use lightcraft_geom::Point;
use lightcraft_raster::Rgba8;
@ -10,6 +10,7 @@ use lightcraft_raster::Rgba8;
use crate::colorops::ColorOps;
use crate::geometry::Frame;
use crate::local::log_lum;
use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc};
use crate::tone::ToneMap;
use crate::{Prepared, SourceInfo, for_rows};
@ -159,7 +160,11 @@ pub struct FinishParams {
/// Refine Saturation as 0..1 (1 = the curves' own saturation).
pub refine_sat: f32,
pub vig: Option<Vig>,
pub to_srgb: [[f32; 3]; 3],
/// Linear Rec.2020 → linear output RGB, the output's luminance weights (gamut mapping) and its
/// encoding curve (see [`crate::output`]).
pub to_out: [[f32; 3]; 3],
pub out_luma: [f32; 3],
pub out_trc: OutputTrc,
pub hl: f32,
pub sh: f32,
pub clar: f32,
@ -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<T: Copy + Default + Send>(
p: &Prepared,
fp: &FinishParams,
exact: bool,
store: impl Fn([f32; 3]) -> T + Sync + Send,
) -> Vec<T> {
let (w, h) = (p.img.width, p.img.height);
let FinishParams {
tone,
ops,
curves,
vig,
to_out,
out_luma,
hl,
sh,
clar,
tex,
dehaze,
sharpen,
sharpen_mask,
air,
air_pre,
gain,
ev,
grain,
..
} = fp;
let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) =
(*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev);
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
@ -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

View File

@ -11,7 +11,7 @@
//! clarity, local adjustments (masks)
//! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation
//! 4. colour — vibrance, saturation, colour mixer, colour grading, B&W (OkLCh)
//! 5. display — gamut map to sRGB, encode, tone curves (parametric + point), vignette, grain
//! 5. display — gamut map to the output space (sRGB unless [`RenderRequest::space`] says otherwise), encode, tone curves (parametric + point), vignette, grain
//!
//! Spatial parameters are specified relative to the image's long edge, so a 400 px preview and a
//! 60 MP export look alike.
@ -29,6 +29,7 @@ pub mod geometry;
pub mod local;
pub mod masks;
pub mod optics;
pub mod output;
pub mod profiles;
pub mod redeye;
pub mod spots;
@ -37,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<DeepImage>,
}
/// 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<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
self.ext.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|e| e.clone().downcast::<T>().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<T>(i: &lightcraft_raster::Image<T>) -> usize {
i.data.len() * std::mem::size_of::<T>()
}
let mut seen: Vec<usize> = 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.

View File

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

View File

@ -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<Point>, 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<f64> = 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:?}");
}
}
}

View File

@ -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);

View File

@ -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<u64> {
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<usize> {
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());

View File

@ -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<K, V> {
map: HashMap<K, (V, usize, u64)>,
/// Use tick → key, oldest first.
/// Use tick ([`next_tick`], shared by every LRU) → key, oldest first.
order: BTreeMap<u64, K>,
tick: u64,
cost: usize,
budget: usize,
}
@ -16,12 +24,11 @@ impl<K: Eq + Hash + Clone, V> Lru<K, V> {
/// 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<K: Eq + Hash + Clone, V> Lru<K, V> {
self.map.get(k).map(|e| &e.0)
}
/// Use tick of the least recently used entry.
pub fn oldest_tick(&self) -> Option<u64> {
self.order.first_key_value().map(|(t, _)| *t)
}
/// Remove the least recently used entry; returns its cost.
pub fn pop_oldest(&mut self) -> Option<usize> {
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 };

View File

@ -97,6 +97,19 @@ impl<T: Copy + Default> Image<T> {
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<U: Copy + Default + Send>(&self, f: impl Fn(T) -> U + Sync + Send) -> Image<U>
where
T: Sync,
@ -139,6 +152,19 @@ impl<T: Copy + Default> Image<T> {
}
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<T: Copy + Default> Image<T> {
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::<u32>::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

View File

@ -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

View File

@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
opcodes,
metadata,
};
img.validate()?;
img.validate_for(mode)?;
Ok(img)
}

View File

@ -145,19 +145,75 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
/// Decode a raw file.
pub fn decode(bytes: &[u8]) -> Result<RawImage> {
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<RawInfo> {
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<RawImage> {
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<Cfa>,
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<Rgb32f> {
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))
}
}

View File

@ -23,8 +23,9 @@ enum ChunkPx {
F32(Vec<f32>),
}
/// 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<RawData> {
/// 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<usize> {
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<RawData> {
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<RawData> {
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<Result<(Chunk, ChunkPx)>> =

View File

@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
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<RawImage> {
opcodes: OpcodeLists::default(),
metadata,
};
img.validate()?;
img.validate_for(mode)?;
Ok(img)
}

View File

@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<usize> = match &slices {
Some(s) => {
@ -74,16 +90,20 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
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<RawImage> {
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));

View File

@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
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<RawImage> {
opcodes: OpcodeLists::default(),
metadata,
};
img.validate()?;
img.validate_for(mode)?;
Ok(img)
}

View File

@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
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));

View File

@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
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");

View File

@ -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;

View File

@ -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());
}
}
}

View File

@ -77,12 +77,23 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
"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 {

View File

@ -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() {

View File

@ -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<HoverPreview>,
}
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.")
}

View File

@ -206,6 +206,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
"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),

View File

@ -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<Re
panel(app, &ctx, RightPanel::Edit, "Edit");
Ok(Value::Null)
}
"panel.profiles" => {
// 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<Re
Ok(Value::Null)
}
"dialog.createPreset" => {
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" => {

View File

@ -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" {

View File

@ -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<serde_jso
Dialog::RenameAlbum { id, name } => 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<serde_jso
"format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb,
"sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir,
"metadata": opts.metadata, "removeLocation": opts.remove_location, "watermark": opts.watermark,
"colorSpace": opts.color_space, "bitDepth": opts.bit_depth,
}),
),
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
@ -301,6 +323,38 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
// ------------------------------------------------------------------------------------------ dialog widgets
/// Two columns of settings-group checkboxes (Copy Settings, Create Preset) plus All / None.
fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec<String>) {
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;

View File

@ -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());

View File

@ -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;

View File

@ -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<String, Vec<(String, String, bool)>> = BTreeMap::new();
let mut groups: BTreeMap<String, Vec<Preset>> = 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))
}
}

View File

@ -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}));
}
}
}

View File

@ -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),

View File

@ -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" {

View File

@ -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<Slot, RenderResult>,
/// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet.
@ -90,8 +101,19 @@ pub struct Renderer {
quick_tried: HashMap<Slot, u64>,
/// Prefetch slot → key of the last job submitted (each runs once).
prefetched: HashMap<Slot, u64>,
/// Variant key → frame it was last asked for (LRU of [`Slot::Variant`] textures).
variant_used: HashMap<u64, u64>,
/// 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::<Slot, RenderResult>::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<egui::TextureId, crate::softpaint::CpuTexture> {
self.textures.values().filter_map(|t| Some((t.tex.id(), crate::softpaint::CpuTexture::linear(t.pixels.clone()?)))).collect()

View File

@ -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<Dialog>,
}
/// 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<String> {
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<String>,
},
CopySettings {
groups: Vec<String>,
@ -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),

View File

@ -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 |

View File

@ -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 <files> --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.

View File

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

View File

@ -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%) |
<!-- /parity:summary -->
## Top gaps
Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on.
1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible /
Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort.
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).
<!-- Sections below hold one row per id. Keep the column order: Id | Feature | Tier | Status | Evidence | Notes. -->
@ -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