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
@@ -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())) {@@ -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")]@@ -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");+}@@ -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);@@ -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:?}"); }@@ -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());@@ -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; d@@ -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))?;@@ -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 })@@ -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) }@@ -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);@@ -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);@@ -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);@@ -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 =@@ -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);