mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
@@ -76,7 +76,8 @@ impl eframe::App for App {
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0);
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
|
||||
+25
-7
@@ -437,6 +437,14 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
|
||||
try_render(src, info, s, req, stages).ok()
|
||||
}
|
||||
|
||||
/// Whether the GPU renders requests of this kind at all (a given render can still fall back to
|
||||
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on
|
||||
/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it,
|
||||
/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart.
|
||||
pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
|
||||
depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange
|
||||
}
|
||||
|
||||
/// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU
|
||||
/// was not used (callers can ask [`unavailable_reason`]), [`Fallback::Failed`] with the reason
|
||||
/// when the GPU render failed and the CPU must render it. The reason belongs to this call, unlike
|
||||
@@ -453,13 +461,7 @@ pub fn try_render(
|
||||
// sections switched off with their eye render as if at their defaults (issue #316)
|
||||
let effective = s.effective();
|
||||
let s: &DevelopSettings = &effective;
|
||||
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
|
||||
// Visualize HDR needs the float HDR render: CPU too
|
||||
if !enabled()
|
||||
|| req.depth != lightcraft_pipeline::OutputDepth::U8
|
||||
|| req.proof.is_some()
|
||||
|| req.overlay == lightcraft_pipeline::Overlay::HdrRange
|
||||
{
|
||||
if !enabled() || !handles(req.depth, req.proof.is_some(), req.overlay) {
|
||||
return Err(Fallback::NotTried);
|
||||
}
|
||||
let s = &*lightcraft_pipeline::settings_for(s, req);
|
||||
@@ -533,3 +535,19 @@ pub fn try_render(
|
||||
Err(Fallback::NotTried)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lightcraft_pipeline::{OutputDepth, Overlay};
|
||||
|
||||
// Given a request the 8-bit kernel can't draw (high bit depth, a soft proof, Visualize HDR),
|
||||
// the GPU doesn't take it; the loupe and diagnostic overlays it can draw stay on the GPU
|
||||
#[test]
|
||||
fn the_gpu_handles_8_bit_unproofed_renders_without_the_hdr_range() {
|
||||
assert!(super::handles(OutputDepth::U8, false, Overlay::None));
|
||||
assert!(super::handles(OutputDepth::U8, false, Overlay::Spots(50)));
|
||||
assert!(!super::handles(OutputDepth::U16, false, Overlay::None));
|
||||
assert!(!super::handles(OutputDepth::U8, true, Overlay::None));
|
||||
assert!(!super::handles(OutputDepth::U8, false, Overlay::HdrRange));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,9 @@ pub struct Perf {
|
||||
pub update_ms: f64,
|
||||
/// The slowest whole update since start.
|
||||
pub max_update_ms: f64,
|
||||
/// The previous frame's whole time on this thread, painting (texture uploads) included, as
|
||||
/// the host reports it (eframe's `cpu_usage`); `None` without a host report (headless).
|
||||
pub host_frame_ms: Option<f64>,
|
||||
pub fps: f64,
|
||||
}
|
||||
|
||||
@@ -367,6 +370,8 @@ pub struct LightcraftApp {
|
||||
pub region_before_view: Option<region::RegionView>,
|
||||
/// The loupe's render sizes while a pinch or two-finger scroll runs.
|
||||
pub(crate) size_hold: region::SizeHold,
|
||||
/// The size of a slider drag's whole-frame drafts (full size where they keep up, issue #330).
|
||||
pub(crate) drag_scale: region::DragScale,
|
||||
/// (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>,
|
||||
@@ -477,6 +482,7 @@ impl LightcraftApp {
|
||||
region_view: None,
|
||||
region_before_view: None,
|
||||
size_hold: Default::default(),
|
||||
drag_scale: Default::default(),
|
||||
window_refused: None,
|
||||
region_tiles: Default::default(),
|
||||
compare_windows: [None; 2],
|
||||
|
||||
@@ -439,13 +439,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
app.image_rect = Some(img_rect);
|
||||
// request renders: the loupe at display resolution (drafts during drags)
|
||||
let interacting = app.session.interaction.is_some();
|
||||
let scale = if interacting { 0.6 } else { 1.0 };
|
||||
let overlay = view_overlay(app, &d);
|
||||
// a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`;
|
||||
// `ready` first: `available` would wait for a device still being created)
|
||||
let gpu = interacting
|
||||
&& lightcraft_engine::gpu::handles(lightcraft_pipeline::OutputDepth::U8, app.ui.soft_proof, overlay)
|
||||
&& lightcraft_engine::gpu::ready()
|
||||
&& lightcraft_engine::gpu::available();
|
||||
let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms));
|
||||
let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str()));
|
||||
let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key));
|
||||
// render at the final size: a click-zoom animation only changes how the result is drawn
|
||||
let native_long = native[0].max(native[1]);
|
||||
let drawn_long = target_rect.width().max(target_rect.height()) * ppp;
|
||||
// the whole frame at about canvas size; the zoomed part of it is a window render (below)
|
||||
let look = d.hash64();
|
||||
let overlay = view_overlay(app, &d);
|
||||
// Diagnostic views that need whole-frame context, soft proofing, or reads too far for one
|
||||
// window still render the whole frame at the size it is drawn. A mask overlay is window-safe.
|
||||
let sizes = crate::region::ViewSizes {
|
||||
@@ -453,13 +461,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
canvas_long: canvas.width().max(canvas.height()) * ppp,
|
||||
native_long,
|
||||
texture_side,
|
||||
draft_scale: scale,
|
||||
draft,
|
||||
windows: !app.ui.soft_proof && overlay_supports_window(overlay),
|
||||
};
|
||||
let mut plan = crate::region::plan(&app.ui.settings, sizes);
|
||||
if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) {
|
||||
plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes });
|
||||
}
|
||||
// time the full-size drafts where they are what shows: under a window the whole-frame draft
|
||||
// is small anyway, and the frame's time is the window's upload
|
||||
if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) {
|
||||
app.drag_scale.observe(key, ms, app.perf.host_frame_ms);
|
||||
}
|
||||
// a pinch changes the zoom every frame: keep the sizes of before it until it is quiet
|
||||
let now = ui.input(|i| i.time);
|
||||
let plan = app.size_hold.apply(id, now, navigating, plan);
|
||||
|
||||
+295
-14
@@ -72,8 +72,10 @@ pub struct ViewSizes {
|
||||
pub native_long: usize,
|
||||
/// The largest texture the GPU takes.
|
||||
pub texture_side: usize,
|
||||
/// 1.0, or less while a slider is dragged (a draft of the whole-frame render).
|
||||
pub draft_scale: f32,
|
||||
/// While a slider is dragged, the scale of the whole-frame draft against the render at rest
|
||||
/// (1.0 or [`CPU_DRAFT_SCALE`], from [`DragScale`]); a draft is never above the canvas size.
|
||||
/// `None`: no drag.
|
||||
pub draft: Option<f32>,
|
||||
/// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay
|
||||
/// that needs whole-frame context, soft proofing, or a read too far for one window). Mask
|
||||
/// overlays are window-safe.
|
||||
@@ -92,17 +94,141 @@ pub struct LoupePlan {
|
||||
pub window_edge: Option<usize>,
|
||||
}
|
||||
|
||||
/// Size of a slider drag's whole-frame draft when full size would be too slow (the CPU renders
|
||||
/// it, or full-size drafts of this drag went over [`DRAFT_BUDGET_MS`]), against the render at rest.
|
||||
pub const CPU_DRAFT_SCALE: f32 = 0.6;
|
||||
|
||||
/// The slider budget (ms): a drag whose full-size drafts take longer gets smaller drafts next time.
|
||||
/// A draft's time is its render plus the frame that shows it (the texture upload is part of it).
|
||||
pub const DRAFT_BUDGET_MS: f64 = 16.0;
|
||||
|
||||
/// Full-size drafts this many times over budget give up on full size in the middle of the drag:
|
||||
/// a drag at 200 ms a step is worse than one jump in resolution.
|
||||
const GIVE_UP_FACTOR: f64 = 2.0;
|
||||
|
||||
/// The full-size drafts (after the first) a drag times before it may give up on full size.
|
||||
const GIVE_UP_AFTER: usize = 3;
|
||||
|
||||
/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts.
|
||||
const SLOW_DRAGS_KEPT: usize = 256;
|
||||
|
||||
/// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it
|
||||
/// ends (a change in the middle would make the resolution jump), unless its full-size drafts are
|
||||
/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of
|
||||
/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`]
|
||||
/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT
|
||||
/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large
|
||||
/// draft costs a texture upload too, which only the time a draft took to show tells.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DragScale {
|
||||
/// The drag running now: (photo, control) and its scale.
|
||||
current: Option<((lightcraft_catalog::PhotoId, String), f32)>,
|
||||
/// How long the current drag's full-size drafts took to show (ms).
|
||||
samples: Vec<f64>,
|
||||
/// Key of the whole-frame render last timed (each render is timed once).
|
||||
seen: Option<u64>,
|
||||
/// The render time (ms) of a draft that reached the screen this frame: the time of the frame
|
||||
/// that uploads it is only known on the next one.
|
||||
pending: Option<f64>,
|
||||
/// Drags whose full-size drafts went over budget.
|
||||
slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>,
|
||||
}
|
||||
|
||||
impl DragScale {
|
||||
/// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control
|
||||
/// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame
|
||||
/// render on screen (a render from before the drag is not one of its drafts).
|
||||
pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option<u64>) -> Option<f32> {
|
||||
let Some((photo, control)) = drag else {
|
||||
self.finish();
|
||||
return None;
|
||||
};
|
||||
match &self.current {
|
||||
Some(((p, c), s)) if *p == photo && c == control => return Some(*s),
|
||||
Some(_) => self.finish(),
|
||||
None => {}
|
||||
}
|
||||
let key = (photo, control.to_string());
|
||||
let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE };
|
||||
self.current = Some((key, s));
|
||||
self.seen = shown;
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Once per frame: `key` identifies the whole-frame render on screen and `render_ms` is how
|
||||
/// long it took; `last_frame_ms` is how long the previous frame took, painting included (the
|
||||
/// host's report; `None` without one, e.g. headless). Times the current drag's full-size
|
||||
/// drafts, each as its render plus the frame that uploaded and showed it, and gives up on full
|
||||
/// size when they are far over budget.
|
||||
pub fn observe(&mut self, key: u64, render_ms: f64, last_frame_ms: Option<f64>) {
|
||||
let full = matches!(self.current, Some((_, s)) if s >= 1.0);
|
||||
if !full {
|
||||
self.pending = None;
|
||||
return;
|
||||
}
|
||||
if let Some(render) = self.pending.take() {
|
||||
let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0);
|
||||
if self.samples.len() < 1024 {
|
||||
self.samples.push(render + frame);
|
||||
}
|
||||
if self.samples.len() > GIVE_UP_AFTER
|
||||
&& median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS * GIVE_UP_FACTOR)
|
||||
&& let Some((_, s)) = self.current.as_mut()
|
||||
{
|
||||
*s = CPU_DRAFT_SCALE;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.seen == Some(key) {
|
||||
return;
|
||||
}
|
||||
self.seen = Some(key);
|
||||
if render_ms.is_finite() && render_ms >= 0.0 {
|
||||
self.pending = Some(render_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember drags of `control` on `photo` as too slow for full-size drafts (tests).
|
||||
#[cfg(test)]
|
||||
pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) {
|
||||
self.slow.insert((photo, control.to_string()));
|
||||
}
|
||||
|
||||
/// The drag ended: remember it if its full-size drafts were over budget.
|
||||
fn finish(&mut self) {
|
||||
if let Some((key, _)) = self.current.take()
|
||||
&& median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS)
|
||||
{
|
||||
if self.slow.len() >= SLOW_DRAGS_KEPT {
|
||||
self.slow.clear();
|
||||
}
|
||||
self.slow.insert(key);
|
||||
}
|
||||
self.samples.clear();
|
||||
self.seen = None;
|
||||
self.pending = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The median of a drag's draft times (ms), leaving out the first draft (it rebuilds the stage
|
||||
/// caches the others reuse); `None` before a second draft.
|
||||
fn median_after_first(samples: &[f64]) -> Option<f64> {
|
||||
let mut rest: Vec<f64> = samples.iter().skip(1).copied().collect();
|
||||
rest.sort_by(f64::total_cmp);
|
||||
rest.get(rest.len() / 2).copied()
|
||||
}
|
||||
|
||||
/// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame
|
||||
/// render is canvas-sized, and the window holds only the pixels on screen at no more than 100 %.
|
||||
pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan {
|
||||
let canvas_long = if v.canvas_long.is_finite() { v.canvas_long.max(8.0) } else { 8.0 };
|
||||
let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long };
|
||||
let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 };
|
||||
let scale = v.draft.map_or(1.0, |s| if s.is_finite() { s.clamp(0.1, 1.0) } else { 1.0 });
|
||||
if !v.windows {
|
||||
// the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no
|
||||
// window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must
|
||||
// not render the photo's every pixel each frame
|
||||
let long = if scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long };
|
||||
let long = if v.draft.is_some() { drawn_long.min(canvas_long) } else { drawn_long };
|
||||
let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side);
|
||||
return LoupePlan { main_edge, window_edge: None };
|
||||
}
|
||||
@@ -110,13 +236,15 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan {
|
||||
// original is not decoded for the whole-frame render
|
||||
let source_cap = if settings.preview_limit == 0 { lightcraft_engine::SourceLevel::Preview.max_edge() } else { settings.preview_limit as usize };
|
||||
let edge_at = |scale: f32| settings.loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side).min(source_cap);
|
||||
let main_edge = edge_at(scale);
|
||||
let frame_edge = settings.window_frame_edge(drawn_long, v.native_long);
|
||||
// a user who capped the preview size has chosen speed over sharpness at fit
|
||||
let fit = drawn_long <= canvas_long * 1.05;
|
||||
// (against the undrafted whole-frame render: a drag must not switch the window off and on)
|
||||
let window_edge = (frame_edge as f32 > edge_at(1.0) as f32 * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge);
|
||||
LoupePlan { main_edge, window_edge }
|
||||
// with a window over it, the whole-frame draft is hidden (it feeds the histogram and stands in
|
||||
// where the window has no pixels yet): the small draft does, and the window keeps the frame time
|
||||
let scale = if window_edge.is_some() && v.draft.is_some() { scale.min(CPU_DRAFT_SCALE) } else { scale };
|
||||
LoupePlan { main_edge: edge_at(scale), window_edge }
|
||||
}
|
||||
|
||||
/// How long the render sizes stay as they were after a pinch or two-finger scroll stops (s).
|
||||
@@ -238,7 +366,7 @@ mod tests {
|
||||
const BIG: usize = 16384;
|
||||
|
||||
fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes {
|
||||
ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, windows: true }
|
||||
ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true }
|
||||
}
|
||||
|
||||
fn auto() -> AppSettings {
|
||||
@@ -289,7 +417,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_drag_shrinks_the_whole_frame_draft_not_the_window() {
|
||||
let still = plan(&auto(), sizes(24000.0, 2800.0, 6000));
|
||||
let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(24000.0, 2800.0, 6000) });
|
||||
let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) });
|
||||
assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}");
|
||||
assert_eq!(drag.window_edge, still.window_edge);
|
||||
}
|
||||
@@ -299,14 +427,167 @@ mod tests {
|
||||
// windowed path
|
||||
#[test]
|
||||
fn without_a_window_a_drag_drafts_at_the_canvas_size() {
|
||||
let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, ..sizes(24000.0, 2800.0, 6000) };
|
||||
let still = plan(&auto(), no_window(1.0));
|
||||
let drag = plan(&auto(), no_window(0.6));
|
||||
let no_window = |draft: Option<f32>| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) };
|
||||
let still = plan(&auto(), no_window(None));
|
||||
let drag = plan(&auto(), no_window(Some(0.6)));
|
||||
assert_eq!(still.window_edge, None);
|
||||
assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}");
|
||||
assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}");
|
||||
}
|
||||
|
||||
// Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330)
|
||||
#[test]
|
||||
fn a_drag_shrinks_the_draft_only_on_the_cpu() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut gpu = DragScale::default();
|
||||
assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft");
|
||||
assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0));
|
||||
let mut cpu = DragScale::default();
|
||||
assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE));
|
||||
}
|
||||
|
||||
// Given a drag that started before the GPU was ready, it keeps its size until it ends: the
|
||||
// resolution never jumps in the middle of a drag
|
||||
#[test]
|
||||
fn a_drag_keeps_its_draft_size_until_it_ends() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut d = DragScale::default();
|
||||
let drag = Some((PhotoId(1), "Exposure"));
|
||||
assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE));
|
||||
assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag");
|
||||
assert_eq!(d.scale(None, true, None), None);
|
||||
assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size");
|
||||
// and the other way: the GPU lost mid-drag keeps the drag at full size until it ends
|
||||
assert_eq!(d.scale(drag, false, None), Some(1.0));
|
||||
}
|
||||
|
||||
// Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders,
|
||||
// a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts
|
||||
// small; other controls and photos keep full size
|
||||
#[test]
|
||||
fn a_drag_over_budget_drafts_small_the_next_time() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut d = DragScale::default();
|
||||
let exposure = Some((PhotoId(1), "Exposure"));
|
||||
assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0));
|
||||
d.observe(7, 400.0, None); // the render from before the drag: not a draft of it
|
||||
for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] {
|
||||
d.observe(key, ms, None);
|
||||
d.observe(key, ms, None); // a render is timed once, however many frames show it
|
||||
}
|
||||
assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag");
|
||||
assert_eq!(d.scale(None, true, None), None);
|
||||
assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE));
|
||||
assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0));
|
||||
assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0));
|
||||
}
|
||||
|
||||
// Given full-size drafts within budget (the first one, rebuilding the stage caches, may be
|
||||
// slow), the next drag stays at full size
|
||||
#[test]
|
||||
fn a_drag_within_budget_stays_full_size() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut d = DragScale::default();
|
||||
let drag = Some((PhotoId(1), "Exposure"));
|
||||
d.scale(drag, true, None);
|
||||
for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] {
|
||||
d.observe(key, ms, None);
|
||||
}
|
||||
d.scale(None, true, None);
|
||||
assert_eq!(d.scale(drag, true, None), Some(1.0));
|
||||
// hostile times are ignored, never a panic
|
||||
for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] {
|
||||
d.observe(key, ms, None);
|
||||
}
|
||||
d.scale(None, true, None);
|
||||
assert_eq!(d.scale(drag, true, None), Some(1.0));
|
||||
}
|
||||
|
||||
// Given renders within budget whose frames (the texture upload) take it over, the drag counts
|
||||
// as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms
|
||||
#[test]
|
||||
fn a_drafts_time_includes_the_frame_that_shows_it() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut d = DragScale::default();
|
||||
let drag = Some((PhotoId(1), "Exposure"));
|
||||
d.scale(drag, true, None);
|
||||
for key in 1..=5 {
|
||||
d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft
|
||||
}
|
||||
d.observe(5, 8.0, Some(12.0));
|
||||
d.scale(None, true, None);
|
||||
assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms");
|
||||
// the same renders without the host's frame times (headless) stay within budget
|
||||
let mut headless = DragScale::default();
|
||||
headless.scale(drag, true, None);
|
||||
for key in 1..=6 {
|
||||
headless.observe(key, 8.0, None);
|
||||
}
|
||||
headless.scale(None, true, None);
|
||||
assert_eq!(headless.scale(drag, true, None), Some(1.0));
|
||||
// a hostile frame time counts as nothing, never a panic
|
||||
let mut hostile = DragScale::default();
|
||||
hostile.scale(drag, true, None);
|
||||
for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] {
|
||||
hostile.observe(key, 8.0, Some(frame));
|
||||
}
|
||||
hostile.scale(None, true, None);
|
||||
assert_eq!(hostile.scale(drag, true, None), Some(1.0));
|
||||
}
|
||||
|
||||
// Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step),
|
||||
// the drag drops to the small draft after a few of them instead of at its end, and the next
|
||||
// drag starts small
|
||||
#[test]
|
||||
fn a_drag_far_over_budget_drafts_small_before_it_ends() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut d = DragScale::default();
|
||||
let drag = Some((PhotoId(1), "Exposure"));
|
||||
assert_eq!(d.scale(drag, true, None), Some(1.0));
|
||||
let mut scales = Vec::new();
|
||||
for key in 1..=8 {
|
||||
d.observe(key, 190.0, None);
|
||||
scales.push(d.scale(drag, true, None));
|
||||
}
|
||||
// drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame
|
||||
assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}");
|
||||
assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}");
|
||||
d.scale(None, true, None);
|
||||
assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE));
|
||||
}
|
||||
|
||||
// Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is
|
||||
// the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees
|
||||
#[test]
|
||||
fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() {
|
||||
let zoomed = sizes(24000.0, 2800.0, 6000);
|
||||
let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed });
|
||||
let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed });
|
||||
assert!(full.window_edge.is_some(), "{full:?}");
|
||||
assert_eq!(full, small);
|
||||
// at fit without a window, full size stays full size
|
||||
let fit = sizes(2800.0, 2800.0, 6000);
|
||||
assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit));
|
||||
}
|
||||
|
||||
// Given a drag at fit at full size, the draft is as sharp as the render at rest
|
||||
#[test]
|
||||
fn a_full_size_drag_at_fit_keeps_the_resolution() {
|
||||
for drawn in [1400.0, 2800.0] {
|
||||
let still = plan(&auto(), sizes(drawn, drawn, 6000));
|
||||
let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) });
|
||||
assert_eq!(drag, still, "{drawn}");
|
||||
}
|
||||
}
|
||||
|
||||
// Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1
|
||||
// must not render the photo's every pixel each frame
|
||||
#[test]
|
||||
fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() {
|
||||
let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) });
|
||||
assert!(drag.main_edge <= 2816, "{drag:?}");
|
||||
}
|
||||
|
||||
// Given the user capped the preview size, the whole-frame render obeys it, and a fit view
|
||||
// stays on it (no original decoded); a zoomed view still gets its sharp window
|
||||
#[test]
|
||||
@@ -339,7 +620,7 @@ mod tests {
|
||||
fn a_drag_at_fit_on_a_large_display_keeps_the_window() {
|
||||
for drawn in [3300.0, 3840.0, 4096.0, 5120.0] {
|
||||
let still = plan(&auto(), sizes(drawn, drawn, 6000));
|
||||
let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(drawn, drawn, 6000) });
|
||||
let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) });
|
||||
assert!(still.window_edge.is_some(), "{drawn}: {still:?}");
|
||||
assert_eq!(drag.window_edge, still.window_edge, "{drawn}");
|
||||
}
|
||||
@@ -383,7 +664,7 @@ mod tests {
|
||||
// Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag)
|
||||
#[test]
|
||||
fn a_drag_at_fit_does_not_switch_the_window_on() {
|
||||
let p = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(2800.0, 2800.0, 6000) });
|
||||
let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) });
|
||||
assert_eq!(p.window_edge, None);
|
||||
}
|
||||
|
||||
@@ -457,7 +738,7 @@ mod tests {
|
||||
#[test]
|
||||
fn hostile_sizes_give_a_usable_plan() {
|
||||
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] {
|
||||
let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft_scale: v, ..sizes(1.0, 1.0, 6000) });
|
||||
let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) });
|
||||
assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}");
|
||||
}
|
||||
let p = plan(&auto(), sizes(1000.0, 1000.0, 0));
|
||||
|
||||
@@ -89,6 +89,14 @@ pub trait RenderOffload {
|
||||
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)>;
|
||||
}
|
||||
|
||||
/// A job's result as the pool hands it back: the render and its texture pixels, converted on the
|
||||
/// worker (`None` for prefetches and failures). A 10 MP zoom window's conversion took a few ms of
|
||||
/// the UI thread on every slider step.
|
||||
struct Finished {
|
||||
result: RenderResult,
|
||||
color: Option<Arc<egui::ColorImage>>,
|
||||
}
|
||||
|
||||
struct Queued {
|
||||
slot: Slot,
|
||||
priority: u32,
|
||||
@@ -188,7 +196,7 @@ pub struct Renderer {
|
||||
thumb_inputs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64, bool)>,
|
||||
/// Photos whose grid thumbnail is the camera's embedded preview itself (no render behind it).
|
||||
embedded_thumbs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64)>,
|
||||
pool: JobPool<Slot, RenderResult>,
|
||||
pool: JobPool<Slot, Finished>,
|
||||
/// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet.
|
||||
offload: Option<Box<dyn RenderOffload>>,
|
||||
queue: Vec<Queued>,
|
||||
@@ -251,7 +259,7 @@ impl Slot {
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, RenderResult>::default_threads().min(6) };
|
||||
let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, Finished>::default_threads().min(6) };
|
||||
Self::with_worker_threads(threads)
|
||||
}
|
||||
}
|
||||
@@ -396,12 +404,13 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Run `job` and make its image ready for `display` (on the worker thread).
|
||||
fn run_for(job: impl FnOnce() -> RenderResult, display: &Option<Arc<lightcraft_engine::display::Display>>) -> RenderResult {
|
||||
fn run_for(job: impl FnOnce() -> RenderResult, display: &Option<Arc<lightcraft_engine::display::Display>>) -> Finished {
|
||||
let mut r = job();
|
||||
if let Some(d) = display {
|
||||
lightcraft_engine::display::present(&mut r, d);
|
||||
}
|
||||
r
|
||||
let color = r.rendered.as_ref().ok().map(|x| Arc::new(color_image(&x.image)));
|
||||
Finished { result: r, color }
|
||||
}
|
||||
|
||||
fn request_needed(&self, slot: Slot, key: u64, priority: u32) -> bool {
|
||||
@@ -517,7 +526,12 @@ impl Renderer {
|
||||
job.request_id = lightcraft_preview::next_tick();
|
||||
self.request_ids.insert(slot, job.request_id);
|
||||
let key = job.key;
|
||||
self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run())));
|
||||
self.pool.submit(
|
||||
slot,
|
||||
key,
|
||||
priority,
|
||||
Box::new(move || Finished { result: lightcraft_engine::memory::in_background(|| job.run()), color: None }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Drop the slideshow slides other than `keep`: their textures and their requests that have
|
||||
@@ -756,14 +770,14 @@ impl Renderer {
|
||||
fn collect(&mut self, ctx: &egui::Context, session: &mut Session, inline_ms: f64) -> bool {
|
||||
let mut finished = Vec::new();
|
||||
while let Some(done) = self.pool.try_recv() {
|
||||
finished.push((done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms }));
|
||||
finished.push((done.slot, done.result.result, done.result.color, if done.ms > 0.0 { done.ms } else { inline_ms }));
|
||||
}
|
||||
if let Some(off) = self.offload.as_mut() {
|
||||
finished.extend(off.finished());
|
||||
finished.extend(off.finished().into_iter().map(|(slot, r, ms)| (slot, r, None, ms)));
|
||||
}
|
||||
self.dispatch();
|
||||
let mut changed = false;
|
||||
for (mut slot, mut r, ms) in finished {
|
||||
for (mut slot, mut r, mut color, ms) in finished {
|
||||
self.completed += 1;
|
||||
// made for another display profile than the one in use: obsolete
|
||||
if r.display.is_some_and(|id| Some(id) != self.display_id()) {
|
||||
@@ -792,7 +806,11 @@ impl Renderer {
|
||||
}
|
||||
// (results from an offload, which doesn't know the display profile: converted here)
|
||||
if let Some(d) = &self.display {
|
||||
let before = r.display;
|
||||
lightcraft_engine::display::present(&mut r, d);
|
||||
if r.display != before {
|
||||
color = None; // converted after the worker made its texture pixels
|
||||
}
|
||||
}
|
||||
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
|
||||
self.pending.remove(&slot);
|
||||
@@ -833,7 +851,7 @@ impl Renderer {
|
||||
self.textures.remove(&Slot::ThumbQuick(id));
|
||||
}
|
||||
let img = &rendered.image;
|
||||
let color = std::sync::Arc::new(color_image(img));
|
||||
let color = color.unwrap_or_else(|| Arc::new(color_image(img)));
|
||||
let pixels = self.keep_pixels.then(|| color.clone());
|
||||
let name = format!("{slot:?}");
|
||||
match self.textures.get_mut(&slot) {
|
||||
@@ -1627,6 +1645,30 @@ mod color_image_tests {
|
||||
assert_eq!(got.pixels, want.pixels);
|
||||
}
|
||||
|
||||
// Given a finished render, the worker hands back its texture pixels with it (the UI thread no
|
||||
// longer converts a 10 MP window on every slider step); a failed render has none
|
||||
#[test]
|
||||
fn the_worker_converts_the_texture_pixels() {
|
||||
let image = lightcraft_raster::Rgba8::from_fn(5, 3, |x, y| [(x * 40) as u8, (y * 80) as u8, 7, 255]);
|
||||
let result = |rendered| RenderResult {
|
||||
request_id: 1,
|
||||
source_key: None,
|
||||
photo: PhotoId(1),
|
||||
level: lightcraft_engine::SourceLevel::Preview,
|
||||
key: 1,
|
||||
rendered,
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
};
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
let ok = Renderer::run_for(|| result(Ok(lightcraft_pipeline::Rendered { image: image.clone(), histogram, deep: None })), &None);
|
||||
assert_eq!(ok.color.map(|c| c.pixels.clone()), Some(color_image(&image).pixels));
|
||||
let failed = Renderer::run_for(|| result(Err("unreadable".into())), &None);
|
||||
assert!(failed.color.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_image_is_an_empty_texture() {
|
||||
let img = lightcraft_raster::Rgba8::new(0, 0);
|
||||
|
||||
@@ -438,13 +438,54 @@ mod in_the_loupe {
|
||||
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T);
|
||||
h.settle(SETTLE);
|
||||
let main = rendered_long_edge(&h);
|
||||
assert!(main <= 1000 && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo");
|
||||
// canvas-sized: the whole canvas on the GPU, a 0.6 draft of it on the CPU (#330)
|
||||
let canvas = h.app.canvas_rect.expect("the loupe was drawn");
|
||||
let canvas_long = canvas.width().max(canvas.height()) as usize + crate::state::LOUPE_EDGE_STEP;
|
||||
assert!(main <= canvas_long && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo");
|
||||
let region = h.app.region_view.expect("the window is drafted too");
|
||||
assert_eq!(region.full.0.max(region.full.1), native, "at 100 %, magnified by the GPU");
|
||||
let (w, hh) = region_tile(&h).expect("a window draft");
|
||||
assert!(w * hh <= 3_000_000, "{w}×{hh}: the window holds what is on screen, not the frame");
|
||||
}
|
||||
|
||||
/// Drag Exposure in the loupe and return the long edge of the whole-frame draft shown, with
|
||||
/// the largest a small (CPU-sized) draft of the photo as drawn can be.
|
||||
fn drag_exposure(h: &mut Headless) -> (usize, usize) {
|
||||
h.app.session.begin_interaction("Exposure").unwrap();
|
||||
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T);
|
||||
h.settle(SETTLE);
|
||||
let image = h.app.image_rect.expect("the photo was drawn");
|
||||
let small = (image.width().max(image.height()) * crate::region::CPU_DRAFT_SCALE) as usize + crate::state::LOUPE_EDGE_STEP;
|
||||
(rendered_long_edge(h), small)
|
||||
}
|
||||
|
||||
// Given a drag whose full-size drafts were too slow before (the CPU, a LUT profile, a slow
|
||||
// GPU), when the same slider is dragged again, then it drafts small (#330)
|
||||
#[test]
|
||||
fn a_slow_drag_drafts_small() {
|
||||
// the GPU device up front (the loupe never waits for it): with one, it is the earlier
|
||||
// drag's time that makes this drag small
|
||||
let _ = lightcraft_engine::gpu::available();
|
||||
let (mut h, _) = detail();
|
||||
let id = h.app.session.active().expect("a photo is open");
|
||||
h.app.drag_scale.remember_slow(id, "Exposure");
|
||||
let (main, small) = drag_exposure(&mut h);
|
||||
assert!(main <= small, "the draft is {main} px, a small one at most {small}");
|
||||
}
|
||||
|
||||
// Given soft proofing (rendered on the CPU even with a GPU), a drag drafts small (#330)
|
||||
#[test]
|
||||
fn a_soft_proofed_drag_drafts_small() {
|
||||
// the GPU device up front (the loupe never waits for it): with one, this is the case where
|
||||
// the GPU is there and the render still goes to the CPU
|
||||
let _ = lightcraft_engine::gpu::available();
|
||||
let (mut h, _) = detail();
|
||||
h.app.ui.soft_proof = true;
|
||||
h.settle(SETTLE);
|
||||
let (main, small) = drag_exposure(&mut h);
|
||||
assert!(main <= small, "the draft is {main} px, a small one at most {small}");
|
||||
}
|
||||
|
||||
fn tile(h: &Headless, slot: Slot) -> Option<(usize, usize)> {
|
||||
h.app.renderer.textures.get(&slot).map(|t| (t.size[0], t.size[1]))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import: persistent result, offline Local promotion and library-switch dismissal; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry; LR-IMP-FORMATS: preview-only raws rechecked after an update, #662) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-VIEW-ZOOM: full-size slider-drag drafts on the GPU, timed with their frame, issue #330; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; 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
|
||||
|
||||
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
|
||||
@@ -130,7 +131,7 @@ 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. 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-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 slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. 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; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it |
|
||||
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does |
|
||||
@@ -816,6 +817,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged |
|
||||
| 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) |
|
||||
| 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). |
|
||||
| 2026-10-12 | minor | LR-VIEW-ZOOM: slider-drag drafts are timed with the frame that shows them, drop to 0.6 mid-drag when far over budget, stay 0.6 under a zoom window; workers convert texture pixels (issue #330); status unchanged |
|
||||
| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution where the GPU draws the view and keeps up (0.6 drafts on the CPU, with soft proofing or the HDR range, or after over-budget drags; issue #330); status unchanged |
|
||||
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
|
||||
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user