collective

Browse / github.com/storytold/lightcraft / patch

perf(catalog): box Photo::embedded_lens (~240 MB less per 1M photos)

perf(catalog): box Photo::embedded_lens (~240 MB less per 1M photos) ## Problem Every photo record carried the file's embedded lens corrections (DNG `WarpRectilinear` / `FixVignetteRadial` coefficients, 248 bytes) inline, even though few photos have them. On a large library that is memory spent on nothing: about 240 MB per million photos once the catalog is open. ## Change `Photo::embedded_lens` is now `Option<Box<EmbeddedLens>>` (record ~1176 → ~936 bytes). Call sites in engine and ui-egui take `.as_deref()` / `.map(Box::new)`; `Op::SetEmbeddedLens` already carried a box, so its apply no longer unboxes and reboxes. Serde treats `Box<T>` as `T`, so catalogs, j

Status
open
Score
0.00
Adopters
0 other operators
Author
emmertex (imported from pull request #3018, open)
Base
387bed199fca44d76af5aa1c24dd7cc3de4c8db2
Size
+37 −22 in 15 files
Review
pending
Shared
2026-10-28
Id
e4e0f93a4df6d9005d15bf1c

How it went

No other operator has reported using it yet.

Diff

raw · crates/catalog/src/lib.rs crates/catalog/src/model.rs crates/catalog/src/tests.rs crates/engine/src/cmd/convert.rs crates/engine/src/files.rs crates/engine/src/import.rs crates/engine/src/lib.rs crates/engine/src/media.rs crates/engine/src/slide.rs crates/engine/src/tests_constrain_crop.rs crates/engine/src/tests_merge.rs crates/ui-egui/examples/thumb_request_bench.rs crates/ui-egui/src/panels/compare.rs crates/ui-egui/src/panels/detail.rs crates/ui-egui/src/panels/slideshow.rs

diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs--- a/crates/catalog/src/lib.rs+++ b/crates/catalog/src/lib.rs@@ -928,7 +928,7 @@ impl Catalog {             }             Op::SetEmbeddedLens { id, lens } => {                 let p = self.photo_mut(id)?;-                Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) }+                Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens) }             }             Op::SetAsShotWb { id, wb } => {                 if wb.is_some_and(|(t, tint)| !(t.is_finite() && tint.is_finite())) {diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs--- a/crates/catalog/src/model.rs+++ b/crates/catalog/src/model.rs@@ -276,7 +276,7 @@ pub struct Photo {     /// Lens corrections embedded in the file (DNG `WarpRectilinear` / `FixVignetteRadial`), applied when     /// "Enable Profile Corrections" is on.     #[serde(default, skip_serializing_if = "Option::is_none")]-    pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,+    pub embedded_lens: Option<Box<lightcraft_develop::EmbeddedLens>>,     /// A virtual copy: the photo it was copied from (it shares that photo's file but has its own     /// settings, metadata and history).     #[serde(default, skip_serializing_if = "Option::is_none")]diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs--- a/crates/catalog/src/tests.rs+++ b/crates/catalog/src/tests.rs@@ -568,7 +568,7 @@ fn set_embedded_lens_is_undoable_and_journaled() {     let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) };     let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();     let inv = c.apply(op).unwrap();-    assert_eq!(c.photo(a).unwrap().embedded_lens, Some(lens));+    assert_eq!(c.photo(a).unwrap().embedded_lens, Some(Box::new(lens)));     let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();     c.apply(inv).unwrap();     assert_eq!(c.photo(a).unwrap().embedded_lens, None);@@ -1046,3 +1046,18 @@ fn a_batch_that_cannot_be_rolled_back_whole_makes_earlier_changes_unknown() {     assert_eq!(c.touched_since(start), None, "so nothing is said of what changed");     assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); }++/// `Photo::embedded_lens` is boxed to keep photo records small; the box doesn't change the serialized form:+/// a photo written with the lens reads back with it, and its JSON is the bare lens, as before the box.+#[test]+fn boxed_embedded_lens_serializes_like_the_bare_lens() {+    let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default() };+    let mut p = Photo::new(PhotoId(1), Source::File { path: "/x.dng".into() }, "x.dng", "DNG", 4000, 3000, "2026-10-06T00:00:00");+    p.embedded_lens = Some(Box::new(lens));+    let json = serde_json::to_value(&p).unwrap();+    assert_eq!(json["embedded_lens"], serde_json::to_value(lens).unwrap());+    let back: Photo = serde_json::from_value(json).unwrap();+    assert_eq!(back.embedded_lens.as_deref(), Some(&lens));+    p.embedded_lens = None;+    assert!(serde_json::to_value(&p).unwrap().get("embedded_lens").is_none(), "no lens, no field");+}diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs--- a/crates/engine/src/cmd/convert.rs+++ b/crates/engine/src/cmd/convert.rs@@ -260,7 +260,7 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P /// what import gave it, the lens correction is turned on as import would; an edited photo keeps /// its settings. pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {-    if info.embedded_lens == ph.embedded_lens {+    if info.embedded_lens.as_ref() == ph.embedded_lens.as_deref() {         return Vec::new();     }     let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }];@@ -486,7 +486,7 @@ mod tests {             c.apply(op).unwrap();         }         let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap();-        assert_eq!(after.embedded_lens, Some(lens()));+        assert_eq!(after.embedded_lens, Some(Box::new(lens())));         assert!(!after.is_edited());         assert_eq!(*after.develop, after.camera_defaults());         assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time");@@ -507,7 +507,7 @@ mod tests {     #[test]     fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() {         let mut ph = old_rw2();-        ph.embedded_lens = Some(lens());+        ph.embedded_lens = Some(Box::new(lens()));         let mut d = ph.camera_defaults();         d.optics.lens_profile = false;         ph.develop = std::sync::Arc::new(d);diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs--- a/crates/engine/src/files.rs+++ b/crates/engine/src/files.rs@@ -2881,7 +2881,7 @@ pub(crate) mod tests {                 let name = format!("x.{format}");                 let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, "");                 p.kind = MediaKind::Raw;-                p.embedded_lens = embedded;+                p.embedded_lens = embedded.map(Box::new);                 crate::import::apply_import_defaults(&s, &mut p);                 assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}");             }diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs--- a/crates/engine/src/import.rs+++ b/crates/engine/src/import.rs@@ -1470,7 +1470,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare                 p.meta = info.meta;                 p.as_shot_wb = info.as_shot_wb;                 p.content_hash = info.content_hash;-                p.embedded_lens = info.embedded_lens;+                p.embedded_lens = info.embedded_lens.map(Box::new);                 p.set_preview_only(info.preview_only.clone());                 apply_import_defaults(s, &mut p);                 if let Some(sc) = &sidecar {@@ -1593,7 +1593,7 @@ impl Session {         p.kind = c.kind;         if let Some(info) = self.import_probes.get(&c.path) {             p.as_shot_wb = info.as_shot_wb;-            p.embedded_lens = info.embedded_lens;+            p.embedded_lens = info.embedded_lens.map(Box::new);             p.preview_only = info.preview_only.clone();         }         p.develop = std::sync::Arc::new(p.import_defaults());diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -960,7 +960,7 @@ impl Session {             photo.height.max(1) as usize,             &d,             true,-            photo.embedded_lens.as_ref(),+            photo.embedded_lens.as_deref(),         );         d.crop.geometry.rect = frame.crop.rect;         ddiff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs--- a/crates/engine/src/media.rs+++ b/crates/engine/src/media.rs@@ -1238,7 +1238,7 @@ fn header_info(p: &Photo) -> SourceInfo {             raw: true,             as_shot_temp: temp,             as_shot_tint: tint,-            lens: p.embedded_lens,+            lens: p.embedded_lens.as_deref().copied(),             relative_wb: relative,             // (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`)             legacy_relative_wb: p.fitted_look() && !relative,@@ -1525,7 +1525,7 @@ impl crate::Session {             p.height.max(1) as usize,             &job.settings,             apply_crop,-            p.embedded_lens.as_ref(),+            p.embedded_lens.as_deref(),         );         let ppl = frame.px_per_long(full_w);         lightcraft_pipeline::spots::window_for_reads_checked(&job.settings, &frame, full_w, full_h, ppl, window.clamped(full_w, full_h))?;diff --git a/crates/engine/src/slide.rs b/crates/engine/src/slide.rs--- a/crates/engine/src/slide.rs+++ b/crates/engine/src/slide.rs@@ -87,7 +87,7 @@ impl crate::Session {             p.height.max(1) as usize,             &p.develop,             true,-            p.embedded_lens.as_ref(),+            p.embedded_lens.as_deref(),         );         let a = frame.aspect();         Some(if a.is_finite() && a > 0.0 { a } else { 1.5 })diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests_constrain_crop.rs--- a/crates/engine/src/tests_constrain_crop.rs+++ b/crates/engine/src/tests_constrain_crop.rs@@ -17,7 +17,7 @@ fn fits(s: &Session) -> bool {     let p = s.catalog.photo(s.active().unwrap()).unwrap();     let mut d = (*p.develop).clone();     d.geometry.constrain_crop = true;-    let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref());+    let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_deref());     let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect);     [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9) }diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs--- a/crates/engine/src/tests_merge.rs+++ b/crates/engine/src/tests_merge.rs@@ -100,8 +100,8 @@ fn hdr_merge_keeps_the_reference_frames_lens_corrections() {         let reference = s.catalog.photo(lightcraft_catalog::PhotoId(ids[1])).unwrap().clone();         let expect = if orientation == Orientation::Normal { (452, 304) } else { (304, 452) };         assert_eq!(((merged.width, merged.height), (reference.width, reference.height)), (expect, expect));-        let a = reference.embedded_lens.expect("the source's correction");-        let b = merged.embedded_lens.expect("the merged DNG carries the reference frame's correction");+        let a = reference.embedded_lens.as_deref().copied().expect("the source's correction");+        let b = merged.embedded_lens.as_deref().copied().expect("the merged DNG carries the reference frame's correction");         let (wa, wb) = (a.warp.unwrap(), b.warp.unwrap());         let (va, vb) = (a.vignette.unwrap(), b.vignette.unwrap());         let (w, h) = (expect.0 as f64, expect.1 as f64);diff --git a/crates/ui-egui/examples/thumb_request_bench.rs b/crates/ui-egui/examples/thumb_request_bench.rs--- a/crates/ui-egui/examples/thumb_request_bench.rs+++ b/crates/ui-egui/examples/thumb_request_bench.rs@@ -195,7 +195,7 @@ fn real() {         p.kind = probe.kind;         p.meta = probe.meta;         p.as_shot_wb = probe.as_shot_wb;-        p.embedded_lens = probe.embedded_lens;+        p.embedded_lens = probe.embedded_lens.map(Box::new);         p.content_hash = probe.content_hash;         p.preview_only = probe.preview_only;         lightcraft_engine::import::apply_import_defaults(&session, &mut p);diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs--- a/crates/ui-egui/src/panels/compare.rs+++ b/crates/ui-egui/src/panels/compare.rs@@ -212,7 +212,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo         photo.height.max(1) as usize,         &photo.develop,         true,-        photo.embedded_lens.as_ref(),+        photo.embedded_lens.as_deref(),     );     let aspect = frame.aspect() as f32;     let native = super::detail::output_px(&frame);diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs--- a/crates/ui-egui/src/panels/detail.rs+++ b/crates/ui-egui/src/panels/detail.rs@@ -296,7 +296,7 @@ pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option<lightcraft_geom::Rect> {     let f = photo.meta.focus.as_ref()?;     let d = &photo.develop;-    let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref());+    let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_deref());     let [w, h] = output_px(&frame);     let to_out = frame.out_to_norm(w, h).inverse()?;     // stored on the upright photo: the user's Rotate / Flip on top, then the crop@@ -527,7 +527,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {     // the full-screen preview shows the photo only: no tool overlays     let right = if fullscreen { RightPanel::None } else { app.ui.right };     let crop_tool = right == RightPanel::Crop;-    let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref());+    let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_deref());     let aspect = frame.aspect() as f32;     let ppp = ui.ctx().pixels_per_point();     let area = canvas.shrink(if fullscreen {@@ -701,7 +701,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {             for (n, nid) in [next, prev].into_iter().enumerate() {                 let Some(nid) = nid else { continue };                 let Some(np) = app.session.catalog.photo(nid).cloned() else { continue };-                let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref());+                let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_deref());                 let na = nf.aspect() as f32;                 let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan);                 let nw =diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs--- a/crates/ui-egui/src/panels/slideshow.rs+++ b/crates/ui-egui/src/panels/slideshow.rs@@ -194,7 +194,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) { /// [`SlideSettings::is_plain`]: lightcraft_engine::slide::SlideSettings::is_plain fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture_side: usize) -> Option<lightcraft_engine::RenderJob> {     let photo = app.session.catalog.photo(id)?.clone();-    let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref());+    let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_deref());     let native = super::detail::output_px(&frame);     if !app.session.slides_plain() {         let (w, h) = slide_px(area, ppp, texture_side);