LR-VIEW-ZOOM: sharp 1:1 in Compare and Reference (zoom windows per pane)

Compare and Reference drew each pane from a whole-frame render of at most
2560 px, so 1:1 was a magnified preview and focus could not be checked.
Each pane now also renders the window on screen from the photo's own
pixels (Slot::CompareRegion), as the loupe does (#323). The media cache
keeps up to two full-size originals while either view is open, so one
pane's window does not evict the other's source.

Docs: gaps.md entry 4 shrinks to overlays and soft proofing; checklist
LR-VIEW-ZOOM / LR-VIEW-COMPARE and ui-parity zoom row updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UyHnpE9573a7nJFQqnGNex
This commit is contained in:
cookingwithsimonandClaude Opus 5.5 committed 2026-10-11 05:55:48 +00:00
1 parent 0ba118de92
commit 5bb43a51f7
9 files changed
+356 -24

No files matched your search

+65 -13
View File
@@ -309,8 +309,11 @@ pub struct MediaCache {
thumbs: Lru<PhotoId, DecodedSource>,
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
previews: Vec<(PhotoId, DecodedSource, u64)>,
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
full: Option<(PhotoId, DecodedSource, u64)>,
/// The last full-resolution originals (exports, zoomed views; ~300 MB each at 24 MP), with
/// their last use. Normally one; see [`Self::set_full_capacity`].
full: Vec<(PhotoId, DecodedSource, u64)>,
/// How many full-resolution originals to keep (LRU, at least one).
full_capacity: usize,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
@@ -386,7 +389,8 @@ impl Default for MediaCache {
settings_hashes: SettingsHashes::default(),
thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)),
previews: Vec::new(),
full: None,
full: Vec::new(),
full_capacity: 1,
preview_capacity: 0,
budget,
file_loader: None,
@@ -431,7 +435,23 @@ impl MediaCache {
self.settings_hashes = SettingsHashes::default();
self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget));
self.previews.clear();
self.full = None;
self.full.clear();
}
/// How many full-resolution originals to keep at once (at least one). Compare and Reference
/// views zoomed to 1:1 cut windows from two photos' originals: with room for one, every window
/// render of one side would evict the other side's original and decode its own again. Lowering
/// it drops the least recently used originals at once.
pub fn set_full_capacity(&mut self, n: usize) {
self.full_capacity = n.max(1);
self.trim_full();
}
fn trim_full(&mut self) {
while self.full.len() > self.full_capacity {
let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0);
self.full.remove(oldest);
}
}
/// Bytes the caches may hold together.
@@ -463,14 +483,14 @@ impl MediaCache {
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.full.iter().map(|e| e.2).min().map(|t| (t, 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| self.previews.remove(i).1.bytes()),
2 => self.full.take().map(|e| e.1.bytes()),
2 => self.full.iter().position(|e| e.2 == tick).map(|i| self.full.remove(i).1.bytes()),
_ => self.rendered.evict_oldest(),
};
match freed {
@@ -491,7 +511,7 @@ impl MediaCache {
e.2 = lightcraft_preview::next_tick();
e.1.clone()
}),
SourceLevel::Full => self.full.as_mut().filter(|e| e.0 == id).map(|e| {
SourceLevel::Full => self.full.iter_mut().find(|e| e.0 == id).map(|e| {
e.2 = lightcraft_preview::next_tick();
e.1.clone()
}),
@@ -534,7 +554,11 @@ impl MediaCache {
self.previews.remove(oldest);
}
}
SourceLevel::Full => self.full = Some((id, img, tick)),
SourceLevel::Full => {
self.full.retain(|e| e.0 != id);
self.full.push((id, img, tick));
self.trim_full();
}
}
self.enforce_budget();
}
@@ -543,9 +567,7 @@ impl MediaCache {
pub fn forget(&mut self, id: PhotoId) {
self.thumbs.remove(&id);
self.previews.retain(|e| e.0 != id);
if self.full.as_ref().is_some_and(|e| e.0 == id) {
self.full = None;
}
self.full.retain(|e| e.0 != id);
}
/// (decoded thumbnail sources, bytes).
@@ -558,7 +580,7 @@ impl MediaCache {
match level {
SourceLevel::Thumb => self.thumbs.contains(&id),
SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id),
SourceLevel::Full => self.full.as_ref().is_some_and(|e| e.0 == id),
SourceLevel::Full => self.full.iter().any(|e| e.0 == id),
}
}
@@ -566,7 +588,7 @@ impl MediaCache {
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| e.1.bytes()).sum());
let full = self.full.as_ref().map(|e| Usage::new(1, e.1.bytes())).unwrap_or_default();
let full = Usage::new(self.full.len(), self.full.iter().map(|e| e.1.bytes()).sum());
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
}
@@ -1529,6 +1551,36 @@ mod tests {
assert_eq!(m.rendered.mem_usage().0, 0);
}
// Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view,
// the least recently used goes, and the budget still evicts them like any source
#[test]
fn full_size_originals_are_kept_up_to_the_capacity() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(100 * mb);
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default");
m.set_full_capacity(2);
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
assert_eq!(m.usage().2.count, 2);
// re-inserting a held photo replaces it instead of taking a second place
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
assert_eq!(m.usage().2.count, 2);
// 1 is the least recently used now
m.set_full_capacity(1);
assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
m.set_full_capacity(0);
assert_eq!(m.usage().2.count, 1, "never less than one");
m.set_full_capacity(2);
m.insert(PhotoId(3), SourceLevel::Full, img(1000));
m.set_budget(15 * mb);
assert_eq!(m.usage().2.count, 1, "the budget evicts the older original");
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
}
#[test]
fn work_gate_waits_for_room() {
use crate::memory::WorkGate;
+1
View File
@@ -457,6 +457,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
} else {
t.grid_bg
};
crate::panels::compare::prepare(app);
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match app.ui.view {
crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid => crate::panels::grid::show(app, ui),
crate::state::ViewMode::Detail => crate::panels::detail::show(app, ui),
+10
View File
@@ -285,6 +285,13 @@ pub struct LightcraftApp {
/// (photo, look, window frame size) a window was refused for: it reads more than one render holds.
pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>,
pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>,
/// The window render each pane of Compare / Reference asked for last (left, right), as
/// [`Self::region_view`] is for the loupe.
pub compare_windows: [Option<region::RegionView>; 2],
/// Compare / Reference window renders by (pane, job key): which texture shows what.
pub(crate) compare_tiles: std::collections::HashMap<(u8, u64), region::RegionView>,
/// Until when (s) a pinch or scroll in Compare / Reference holds the windows there are.
pub(crate) compare_hold_until: f64,
/// Photo Merge dialog previews and background merges.
pub merge: merge::MergeState,
/// An import in progress (the import review dialog's batches).
@@ -372,6 +379,9 @@ impl LightcraftApp {
size_hold: Default::default(),
window_refused: None,
region_tiles: Default::default(),
compare_windows: [None; 2],
compare_tiles: Default::default(),
compare_hold_until: f64::NEG_INFINITY,
merge: merge::MergeState::default(),
import: None,
scan: None,
+135 -2
View File
@@ -164,12 +164,16 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) {
if super::detail::navigate_gesture(app, ui, &resp, img_area, img, native) {
img = super::detail::fit_rect(img_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
// a pinch changes the zoom every frame: no new windows until it is quiet
app.compare_hold_until = ui.input(|i| i.time) + crate::region::HOLD_SECS;
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS));
} else if resp.dragged() {
super::detail::pan_image(app, img_area, img, resp.drag_delta());
}
}
let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp)
.min(2560.0_f32.min(super::detail::texture_side(ui.ctx()) as f32)) as usize;
let texture_side = super::detail::texture_side(ui.ctx());
let want =
(img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0_f32.min(texture_side as f32)) as usize;
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) {
app.renderer.request(slot, job, 60);
@@ -177,6 +181,35 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
if let Some(job) = app.session.thumb_job(id, 256) {
app.renderer.request(Slot::Thumb(id), job, 30);
}
// zoomed past what that render holds (1:1 to check focus): the part on screen as a window, as
// in the loupe. Compare and Reference only; Survey tiles are fit views.
let pane = match slot {
Slot::Compare(i) if i < WINDOW_PANES && matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) => Some(i),
_ => None,
};
let shown_aspect = app.renderer.textures.get(&slot).filter(|x| x.photo == id).map(|t| t.size[0].max(1) as f32 / t.size[1].max(1) as f32);
let window = pane.and_then(|i| {
let tile = TileView {
pane: i,
id,
img,
visible: img_area,
aspect,
native_long: native[0].max(native[1]),
whole_edge: want,
ppp,
texture_side,
now: ui.input(|i| i.time),
};
let plain = shown_aspect.is_none_or(|a| crate::region::same_aspect(a, aspect));
let view = if plain { request_window(app, &tile) } else { None };
app.compare_windows[i as usize] = view;
if view.is_none() {
app.compare_tiles.retain(|(p, _), _| *p != i);
app.renderer.release(Slot::CompareRegion(i));
}
view.map(|_| i)
});
let p = ui.painter_at(img_area);
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
let mut display = img;
@@ -184,6 +217,9 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32;
display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan);
p.image(tex.tex.id(), display, uv, Color32::WHITE);
if let Some(i) = window {
draw_window(&p, app, i, id, photo.develop.hash64(), display);
}
} else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32;
display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan);
@@ -234,6 +270,103 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
(display, resp)
}
/// Panes that get window renders: Compare's Select and Candidate, Reference's two sides.
const WINDOW_PANES: u8 = 2;
/// One pane of Compare or Reference, as [`request_window`] needs it.
struct TileView {
pane: u8,
id: PhotoId,
/// The rect the photo is drawn in (the zoomed frame).
img: Rect,
/// The area whose pixels are on screen.
visible: Rect,
aspect: f32,
/// The photo's own long edge as shown.
native_long: usize,
/// Long edge of the pane's whole-frame render.
whole_edge: usize,
ppp: f32,
texture_side: usize,
/// The frame's time (s).
now: f64,
}
/// Ask for the window of a pane's frame that is on screen, once the view is zoomed past what the
/// pane's whole-frame render holds (issue #323): cut from a frame of at most the photo's own pixels
/// (beyond 100 % the GPU magnifies it) and drawn over the whole-frame render. Remembers it so its
/// texture can be drawn where it belongs. `None`: no window is wanted (or possible): the pane shows
/// its whole-frame render alone, as before windows.
///
/// While a pinch or scroll runs, and while the photo is being edited (a slider drag in Reference
/// view), the window there is stays: a new one per frame would render the photo's own pixels each
/// time. It is drawn magnified or with the old value until the gesture is quiet.
fn request_window(app: &mut LightcraftApp, v: &TileView) -> Option<crate::region::RegionView> {
let drawn_long = v.img.width().max(v.img.height()) * v.ppp;
let frame_edge = app.ui.settings.window_frame_edge(drawn_long, v.native_long);
if frame_edge as f32 <= v.whole_edge as f32 * crate::region::WINDOW_RATIO {
return None;
}
let editing = app.session.interaction.is_some() && app.session.active() == Some(v.id);
if app.compare_hold_until > v.now || editing {
return app.compare_windows[v.pane as usize].filter(|w| w.photo == v.id);
}
let (fw, fh) =
if v.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / v.aspect) } else { (frame_edge as f32 * v.aspect, frame_edge as f32) };
let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize);
// what is on screen, in pixels of the window's frame (the drawn frame, scaled)
let to_px = v.ppp * frame_edge as f32 / drawn_long.max(1.0);
let visible = (
to_px * (v.visible.left() - v.img.left()),
to_px * (v.visible.top() - v.img.top()),
to_px * (v.visible.right() - v.img.left()),
to_px * (v.visible.bottom() - v.img.top()),
);
let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(v.texture_side))?;
// refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one
// render; the pane keeps its whole-frame render
let job = app.session.region_job(v.id, fw, fh, win, true)?;
let look = job.settings.hash64();
let view = crate::region::RegionView { photo: v.id, before: false, key: job.key, full: (fw, fh), window: win, settings: look };
// windows of other photos, looks and zooms of this pane can't be drawn any more
app.compare_tiles.retain(|(p, _), t| *p != v.pane || t.is_current(v.id, (fw, fh), look, false));
app.compare_tiles.insert((v.pane, job.key), view);
// after both panes' whole-frame renders (60): those are what a step to the next photo shows first
app.renderer.request(Slot::CompareRegion(v.pane), job, 58);
Some(view)
}
/// Draw pane `pane`'s window texture over its whole-frame render (`display`), if it shows this
/// photo with its current look (any look of it while it is being edited). A window made at another
/// zoom is drawn at its place in the frame, magnified or shrunk, until the new one arrives.
fn draw_window(p: &egui::Painter, app: &LightcraftApp, pane: u8, id: PhotoId, look: u64, display: Rect) {
let Some(tex) = app.renderer.textures.get(&Slot::CompareRegion(pane)).filter(|t| t.photo == id) else { return };
let editing = app.session.interaction.is_some() && app.session.active() == Some(id);
let Some(tile) = app.compare_tiles.get(&(pane, tex.key)).filter(|t| t.photo == id && (editing || t.settings == look)) else {
return;
};
let (fw, fh) = (tile.full.0.max(1) as f32, tile.full.1.max(1) as f32);
let at = display.min + vec2(tile.window.x as f32 / fw * display.width(), tile.window.y as f32 / fh * display.height());
let dst = Rect::from_min_size(at, vec2(tile.window.w as f32 / fw * display.width(), tile.window.h as f32 / fh * display.height()));
p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
/// Per frame, before the central view is shown: Compare and Reference keep two photos' originals
/// decoded (their windows are cut from both); other views keep one and free the panes' windows.
pub fn prepare(app: &mut LightcraftApp) {
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) {
app.session.media.set_full_capacity(WINDOW_PANES as usize);
return;
}
app.session.media.set_full_capacity(1);
for i in 0..WINDOW_PANES {
if app.compare_windows[i as usize].take().is_some() || app.renderer.textures.contains_key(&Slot::CompareRegion(i)) {
app.renderer.release(Slot::CompareRegion(i));
}
}
app.compare_tiles.clear();
}
fn slot_index(s: Slot) -> u8 {
match s {
Slot::Compare(i) => i,
+8 -2
View File
@@ -39,6 +39,9 @@ pub enum Slot {
Preview,
Before,
Compare(u8),
/// Compare or Reference zoomed past what `Compare(i)` holds: the window of pane `i`'s frame
/// that is on screen, drawn over it (see [`crate::region`] and `panels::compare`).
CompareRegion(u8),
/// Background preparation of a neighbouring photo (no texture: its decoded source and view
/// render are cached by the engine). 0 = next, 1 = previous.
Prefetch(u8),
@@ -111,7 +114,7 @@ const HARD_FACTOR: usize = 4;
pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec<Slot> {
let idle_rank = |s: Slot| match s {
Slot::Hover => 0,
Slot::Compare(_) => 2,
Slot::Compare(_) | Slot::CompareRegion(_) => 2,
Slot::Second => 3,
_ => 5,
};
@@ -310,7 +313,10 @@ impl Renderer {
/// Views that render straight into the display profile's primaries (wide gamut); everything
/// else is rendered for sRGB and converted ([`lightcraft_engine::display::present`]).
fn renders_for_display(slot: Slot) -> bool {
matches!(slot, Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::Second)
matches!(
slot,
Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::CompareRegion(_) | Slot::Second
)
}
/// `job` as [`Self::request`] would run it for `slot` (its key included): views render for
+127
View File
@@ -724,4 +724,131 @@ mod in_the_loupe {
assert_eq!(h.app.renderer.memory()["stageCaches"]["trimmed"], 0);
assert!(held(&h) > before / 3, "and they are still there");
}
/// Compare view of the demo library at `zoom`, settled.
fn compare_at(zoom: crate::state::Zoom) -> Headless {
let (mut h, _) = detail();
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare);
h.app.ui.zoom = zoom;
h.settle(SETTLE);
h
}
fn native_of(h: &Headless, id: lightcraft_catalog::PhotoId) -> usize {
let p = h.app.session.catalog.photo(id).expect("in the catalog");
p.width.max(p.height) as usize
}
// Given two photos in Compare, when I zoom to 1:1 to check focus, then each pane shows a window
// cut from its own photo's pixels, not the 2560 px whole-frame render magnified
#[test]
fn compare_at_one_to_one_gets_a_sharp_window_per_pane() {
let h = compare_at(crate::state::Zoom::Percent(100.0));
let (sel, cand) = h.app.ui.compare.map(|(a, b)| (lightcraft_catalog::PhotoId(a), lightcraft_catalog::PhotoId(b))).expect("a pair");
for (i, id) in [(0u8, sel), (1u8, cand)] {
let whole = h.app.renderer.textures.get(&Slot::Compare(i)).expect("the whole-frame render");
assert!(whole.size[0].max(whole.size[1]) <= 2560, "pane {i}: the whole frame stays small");
let w = h.app.compare_windows[i as usize].unwrap_or_else(|| panic!("pane {i} asked for a window"));
assert_eq!(w.photo, id);
assert_eq!(w.full.0.max(w.full.1), native_of(&h, id), "pane {i}: 1:1 is the photo's own pixels");
let tex = h.app.renderer.textures.get(&Slot::CompareRegion(i)).unwrap_or_else(|| panic!("pane {i}: its texture"));
assert_eq!(tex.photo, id);
assert!(tex.size[0] >= 600 && tex.size[1] >= 600, "pane {i}: it covers the pane: {:?}", tex.size);
assert!(h.app.session.media.has_source(id, lightcraft_engine::SourceLevel::Full), "pane {i}: both originals stay decoded");
}
}
// Given Compare at fit, nothing more is rendered than before windows
#[test]
fn compare_at_fit_makes_no_window() {
let h = compare_at(crate::state::Zoom::Fit);
assert_eq!(h.app.compare_windows, [None, None]);
assert!(!h.app.renderer.textures.contains_key(&Slot::CompareRegion(0)) && !h.app.renderer.textures.contains_key(&Slot::CompareRegion(1)));
}
// Given Compare at 1:1, when I zoom back to fit or go back to Detail, then the windows' textures
// are freed, and back in Detail only one original is kept decoded
#[test]
fn leaving_a_zoomed_compare_frees_its_windows() {
let mut h = compare_at(crate::state::Zoom::Percent(100.0));
assert!(h.app.renderer.textures.contains_key(&Slot::CompareRegion(1)));
h.app.ui.zoom = crate::state::Zoom::Fit;
h.settle(SETTLE);
assert!(!h.app.renderer.textures.contains_key(&Slot::CompareRegion(1)), "fit: no window");
h.app.ui.zoom = crate::state::Zoom::Percent(100.0);
h.settle(SETTLE);
assert!(h.app.renderer.textures.contains_key(&Slot::CompareRegion(0)));
let r = h.request("ui.set", json!({"view": "detail"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.compare_windows, [None, None]);
assert!(!h.app.renderer.textures.contains_key(&Slot::CompareRegion(0)) && !h.app.renderer.textures.contains_key(&Slot::CompareRegion(1)));
assert!(h.app.session.media.usage().2.count <= 1, "one original in Detail");
}
// A pane's window is drawn where it belongs: its pixels agree with the pane's whole-frame render
// at the same place of the frame, and it is painted over that render, inside the pane
#[test]
fn a_compare_window_lines_up_with_its_pane() {
let (mut h, _) = detail();
h.app.renderer.keep_pixels = true;
h.request("engine.execute", json!({"command": "view.compare"}), T);
h.app.ui.zoom = crate::state::Zoom::Percent(100.0);
h.settle(SETTLE);
h.step();
for i in 0..2u8 {
let view = h.app.compare_windows[i as usize].expect("a window");
let tile = h.app.renderer.textures.get(&Slot::CompareRegion(i)).and_then(|t| t.pixels.clone()).expect("tile pixels");
let main = h.app.renderer.textures.get(&Slot::Compare(i)).and_then(|t| t.pixels.clone()).expect("pane pixels");
let (mut sum, mut n) = (0.0f32, 0.0f32);
for j in (8..tile.size[1] - 8).step_by(37) {
for x in (8..tile.size[0] - 8).step_by(37) {
let u = (view.window.x + x) as f32 / view.full.0 as f32;
let v = (view.window.y + j) as f32 / view.full.1 as f32;
let (mx, my) =
(((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1));
let (a, b) = (tile.pixels[j * tile.size[0] + x], main.pixels[my * main.size[0] + mx]);
sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::<f32>() / 3.0;
n += 1.0;
}
}
assert!(sum / n < 6.0, "pane {i}: mean difference {:.2} / 255 between the window and the pane's render", sum / n);
let (whole, window) = (drawn(&h, Slot::Compare(i)), drawn(&h, Slot::CompareRegion(i)));
assert!(!whole.is_empty() && !window.is_empty(), "pane {i}: both are painted");
assert!(window[0].0 > whole[0].0, "pane {i}: the window is painted over the whole-frame render");
assert!(window[0].1.width() < h.size.x * 0.6, "pane {i}: clipped to its pane, {:?}", window[0].1);
}
// the two panes' clips don't overlap
let (a, b) = (drawn(&h, Slot::CompareRegion(0))[0].1, drawn(&h, Slot::CompareRegion(1))[0].1);
assert!(a.right() <= b.left() + 0.5, "{a:?} / {b:?}");
}
// Given Reference view at 1:1, the reference and the active photo both get windows; a slider
// drag on the active photo asks for no new window until it ends
#[test]
fn reference_view_at_one_to_one_gets_windows_and_a_drag_keeps_them() {
let (mut h, _) = detail();
let r = h.request("engine.execute", json!({"command": "photo.setReference"}), T);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("ui.key", json!({"key": "r", "shift": true}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.view, crate::state::ViewMode::Reference);
h.app.ui.zoom = crate::state::Zoom::Percent(100.0);
h.settle(SETTLE);
let before = h.app.compare_windows[1].expect("the active photo's window");
assert!(h.app.compare_windows[0].is_some(), "the reference's window");
h.app.session.begin_interaction("Exposure").unwrap();
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.8}}), T);
for _ in 0..5 {
h.step();
}
assert_eq!(h.app.compare_windows[1], Some(before), "no window per drag tick");
assert!(!h.app.renderer.is_pending(Slot::CompareRegion(1)));
h.app.session.end_interaction().unwrap();
h.settle(SETTLE);
let after = h.app.compare_windows[1].expect("a window for the new look");
assert_ne!(after.settings, before.settings);
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; absorbs the checklist's Top gaps and the ROADMAP's honest assessment, re-ranked after a full re-measure) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Compare and Reference get sharp zoom windows; gap 4 shrinks) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -18,7 +18,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) |
| 2 B | **CR3 beyond three verified bodies** (LR-IMP-FORMATS): CRX variants other than those verified on M50 / R100 / R8 fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 72%; 17 of 22 popular Canon bodies write CR3, 2 verified (raw-parity.md) | +6.5 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) |
| 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) |
| 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): the whole-image preview is capped (2,560 px default); compare, overlays and soft proofing lack sharp zoom windows (#323) | checklist row 🟡 | checking focus, the first thing culling needs, isn't reliable on large files | 8–15 | [ui-parity](ui-parity.md) |
| 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): Detail, Before/After, Compare and Reference are sharp at 1:1–8:1 (zoom windows, #323). Mask overlays and soft proofing still render the whole frame at the drawn size instead of a window: sharp up to the GPU texture side, but a full-size render per edit at 1:1 | checklist row 🟡 | editing with a mask overlay or soft proof on at 1:1 is slow on large files, and blurry past the texture side | 4–8 | [ui-parity](ui-parity.md) |
| 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) |
| 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md) |
| 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) |
@@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h |
| 2026-10-10 | minor | RAW gaps re-measured model by model (raw-parity.md): CR3, compressed ORF and verification entries carry measured shares; HE NEF no longer a beta blocker (+1.1 points for 20–40 h); verification entry absorbs the cheap per-body fixes (#769, #702, A200 series) |
| 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues |
| 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps |
+4 -3
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -128,10 +128,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render |
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) |
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session |
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle |
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle |
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes |
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) |
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends |
@@ -776,5 +776,6 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
| 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` |
| 2026-09-30 – 2026-10-10 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) |
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; shortcut notes moved here from the checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (zoom row: Compare and Reference sharp at 1:1) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier
keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in
@@ -27,7 +27,7 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla
| Crop, Upright, transform handles | handles, aspect lock, angle, overlays (O / ⇧O), guided Upright | ✅ all of them; overlay cycle on ⇧O | 85% | crop opacity of cut-out area (15.5) |
| Masking tools | brush (A / B, flow, density, auto mask, pressure), linear / radial, range masks, AI masks, pins | brush, linear, radial, colour / luminance range, add / subtract / intersect / invert, masks panel, pins | 60% | brush live preview (#517 / #518), no pen pressure, no depth range, AI masks, Feather / Edge controls (15.5) |
| Remove / heal | content-aware remove, heal, clone, visualize spots | heal, clone, auto source, visualize spots, red / pet eye | 65% | content-aware (patch synthesis) remove, generative remove, detect distractions |
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | **original-pixel detail at 100% (preview capped at 2,560 px; P0 gap LR-VIEW-ZOOM)** |
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | **original-pixel detail at 100% with a mask overlay or soft proofing** (no zoom window there; Detail, Before/After, Compare and Reference have them; P0 gap LR-VIEW-ZOOM) |
| Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys |
| Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer |
| Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item |
@@ -78,4 +78,5 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing |
| 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |