Merge branch 'main' into worktree-agent-aecd35c3421bad4ae
# Conflicts: # crates/ui-egui/src/render.rs
This commit is contained in:
commit
3c644f7faa
|
|
@ -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",
|
||||
|
|
@ -2411,6 +2464,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 +2997,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 +3608,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 +4027,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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -166,6 +166,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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -592,6 +670,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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -64,48 +65,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,
|
||||
|
|
@ -114,7 +207,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,
|
||||
}
|
||||
|
|
@ -237,15 +330,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 }
|
||||
}
|
||||
|
|
@ -253,12 +358,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +409,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.
|
||||
|
|
|
|||
|
|
@ -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,6 +120,65 @@ 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> {
|
||||
|
|
@ -124,7 +189,10 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
|
|||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,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
|
||||
}
|
||||
|
|
@ -740,6 +775,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<'_>)| {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -173,6 +173,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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)>> =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,9 +81,19 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
|||
"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") }
|
||||
|
|
|
|||
|
|
@ -105,8 +105,15 @@ pub struct Renderer {
|
|||
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) };
|
||||
|
|
@ -125,6 +132,8 @@ impl Default for Renderer {
|
|||
prefetched: HashMap::new(),
|
||||
variant_used: HashMap::new(),
|
||||
frame: 0,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
idle: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -167,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).
|
||||
|
|
@ -190,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?
|
||||
|
|
@ -372,12 +387,49 @@ 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()
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ 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` / `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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) |
|
||||
| J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) |
|
||||
| K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) |
|
||||
| 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 | — | — |
|
||||
|
|
@ -44,11 +44,11 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| 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%) |
|
||||
| Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) |
|
||||
| **Total** | 211 | 102 | 187 | 37 | 153/198 (77%) | 49/144 (34%) |
|
||||
| **Total** | 213 | 101 | 186 | 37 | 155/198 (78%) | 49/144 (34%) |
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
|
|
|||
Loading…
Reference in New Issue