Run the library from local data when the server is unreachable
Also carries in-flight work that shared these files: the zoom structure-key fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the timeline scrub marker correction, the 423-Locked retry in the metadata sweep, and the thumbnail size-class migration. # Offline mode (FR-CAT-9) The app previously assumed the server was reachable and treated its absence as a series of unrelated per-operation failures. A launch without a connection produced an empty grid, even with a complete catalog on disk and every thumbnail already in the shards. Reachability is now inferred from traffic the app was already making, rather than probed for. `RemoteError::indicates_offline` draws the line that makes this possible: a dead connection is offline, a 403 or a 500 is not — the server answered, so blanking the library over one forbidden file would be a worse error than the one being reported. `Reachability` turns those outcomes into a state, so a library browsing happily never issues a probe at all. Going offline takes one failure, because the user is already experiencing it. Coming back requires evidence — a completed scan or a fetched thumbnail — with a capped exponential backoff behind the manual retry, so twelve sweep lanes failing together do not schedule twelve immediate probes. What keeps working: the catalog opens even when the scan that normally provides it failed, so the grid fills from the last successful scan. Thumbnails come from the shards. Rating, flagging and collecting are catalog writes that never touched the network. What stops is opening an original that was never stored locally, and it now says so in those words instead of reporting "network error: connection refused" over a photograph. Work that is pure network is refused rather than left to fail slowly: the metadata sweep, derived sync, and sidecar writes. The sweep would otherwise spend a timeout per image across the whole library while the progress bar implied something was happening. Deferring sidecars is a real gap rather than a hidden one — a rating made offline reaches its sidecar only when that image is judged again while connected — and it is recorded as such at the call site. # The "On this device" filter A chip beside the rating filters, narrowing the grid to images whose original is held locally. It composes with the rating terms rather than replacing them, so "five-star frames I can actually edit on this train" is one filter. The predicate is SQL, like the rating terms and for the same reason: the count in the header has to agree with the cells drawn. It reads `image_cache.tier_actual`, which nothing writes yet — the next commit fills it. Until then the chip honestly reports zero. `Tier` gains an explicit on-disk encoding. The variants are ordered by generosity and the derived `Ord` invites reordering them, which would silently reinterpret every cached row; the round-trip test is what holds the two in agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+401
-1
@@ -70,6 +70,19 @@ impl DevelopSession {
|
||||
pub fn rows(&self) -> Vec<ParamRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (op_index, op) in self.graph.capabilities().iter().enumerate() {
|
||||
// Framing has a panel of its own.
|
||||
//
|
||||
// The one place this side names a stage, and the exception proves
|
||||
// the rule: every *other* operation is rendered from its
|
||||
// descriptor alone. Framing is skipped because its parameters are
|
||||
// not sliders in any useful sense — four crop edges are dragged on
|
||||
// the photograph and a quarter turn is a button — so it is
|
||||
// presented by `GeometryPanel` instead of generated here. Emitting
|
||||
// both would show the same eight values twice, in one good control
|
||||
// surface and one bad one.
|
||||
if op.id == dr_pipeline::framing::ID {
|
||||
continue;
|
||||
}
|
||||
// Where this operation's rows begin. The panel groups by walking
|
||||
// back to it, so it has to be taken before any row is pushed.
|
||||
let group_head = rows.len();
|
||||
@@ -406,6 +419,37 @@ impl DevelopSession {
|
||||
self.demosaiced.size()
|
||||
}
|
||||
|
||||
/// Whether one source pixel now covers more than one screen pixel.
|
||||
///
|
||||
/// The question the interface asks to decide how the canvas is *filtered*,
|
||||
/// not how it is rendered. Below 1:1 there are more source pixels than
|
||||
/// screen pixels and smoothing is what stops the image aliasing; past it
|
||||
/// there is no more detail to show, and smoothing only invents values
|
||||
/// between real ones — at which point a photographer inspecting focus or
|
||||
/// noise wants to see the pixels, not a blur of them.
|
||||
///
|
||||
/// Measured against the visible region rather than the zoom factor alone,
|
||||
/// because the two differ: a 24 MP file in a 1200px viewport is still
|
||||
/// showing five sensor pixels per screen pixel at 4×, while a small JPEG is
|
||||
/// already magnified at 1×.
|
||||
pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||||
let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
|
||||
|
||||
// How many source pixels lie behind the render target: the framed
|
||||
// image narrowed to the region the view selects. The target keeps its
|
||||
// size while that region shrinks, which is what raises the ratio.
|
||||
let view = self.graph.framing().view();
|
||||
let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON));
|
||||
let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON));
|
||||
|
||||
// Strictly greater, with a margin: at exactly 1:1 either filter gives
|
||||
// the same answer, and flipping mode on a rounding error would make the
|
||||
// canvas visibly change character mid-scroll.
|
||||
f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001
|
||||
}
|
||||
|
||||
/// Set the crop rectangle, in fractions of the source.
|
||||
pub fn set_crop(&mut self, rect: CropRect) {
|
||||
self.graph.set_crop(rect);
|
||||
@@ -416,10 +460,75 @@ impl DevelopSession {
|
||||
}
|
||||
|
||||
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
||||
///
|
||||
/// The crop travels with the frame rather than staying where it was on
|
||||
/// screen. A crop is a decision about *this part of the photograph*, and
|
||||
/// leaving the rect in place while the image turns under it would move the
|
||||
/// selection onto a different part of the picture — so the rect is turned
|
||||
/// by the same quarter and the composition survives the rotation.
|
||||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||||
let crop = self.graph.crop();
|
||||
if !crop.is_full() {
|
||||
self.graph.set_crop(rotate_crop(crop, turns));
|
||||
}
|
||||
self.graph.rotate_quarters(turns);
|
||||
}
|
||||
|
||||
/// Straightening, in degrees. Positive turns the image clockwise.
|
||||
pub fn angle(&self) -> f32 {
|
||||
self.graph.framing().angle()
|
||||
}
|
||||
|
||||
/// Quarter turns clockwise, 0..=3 — for the panel's readout.
|
||||
pub fn quarter_turns(&self) -> u8 {
|
||||
self.graph.framing().quarter_turns()
|
||||
}
|
||||
|
||||
pub fn flips(&self) -> (bool, bool) {
|
||||
self.graph.framing().flips()
|
||||
}
|
||||
|
||||
/// Mirror horizontally, about the frame's vertical centre line.
|
||||
pub fn toggle_flip_h(&mut self) {
|
||||
let (h, _) = self.graph.framing().flips();
|
||||
self.graph
|
||||
.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, f32::from(u8::from(!h)));
|
||||
}
|
||||
|
||||
pub fn toggle_flip_v(&mut self) {
|
||||
let (_, v) = self.graph.framing().flips();
|
||||
self.graph
|
||||
.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_V, f32::from(u8::from(!v)));
|
||||
}
|
||||
|
||||
/// Set the straightening angle, in degrees.
|
||||
pub fn set_angle(&mut self, degrees: f32) {
|
||||
self.graph
|
||||
.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, degrees);
|
||||
}
|
||||
|
||||
/// Whether the framing currently changes the image — what lights the
|
||||
/// section's modified dot and enables its reset.
|
||||
///
|
||||
/// Asks whether it *edits*, not whether it is active: a zoomed view makes
|
||||
/// the framing active without changing the photograph, and a section that
|
||||
/// claimed an edit because the user scrolled would be lying.
|
||||
pub fn framing_edits_image(&self) -> bool {
|
||||
self.graph.framing().edits_image()
|
||||
}
|
||||
|
||||
/// Return crop, straightening, rotation and flips to neutral, leaving
|
||||
/// every colour adjustment alone.
|
||||
///
|
||||
/// The zoom is deliberately preserved: it is a viewing state, and resetting
|
||||
/// the framing is an edit, so throwing away where the user was looking
|
||||
/// would be an unrelated second effect.
|
||||
pub fn reset_framing(&mut self) {
|
||||
let view = self.graph.framing().view();
|
||||
self.graph.framing_mut().reset();
|
||||
self.graph.framing_mut().set_view(view);
|
||||
}
|
||||
|
||||
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
||||
pub fn zoom(&self) -> f32 {
|
||||
let v = self.graph.framing().view();
|
||||
@@ -520,6 +629,31 @@ impl DevelopSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-express a crop rect after the frame it is measured against turns.
|
||||
///
|
||||
/// The crop lives in fractions of the *framed* image — the one the quarter
|
||||
/// turns have already produced — so turning the frame another quarter leaves
|
||||
/// the rect describing the wrong region unless it turns with it. Without this,
|
||||
/// rotating a portrait crop on a landscape photograph slides the selection
|
||||
/// onto a different part of the picture, which reads as the rotation having
|
||||
/// moved the image rather than the frame.
|
||||
///
|
||||
/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the
|
||||
/// extents; anticlockwise is the same map run the other way. Applied
|
||||
/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree.
|
||||
fn rotate_crop(rect: CropRect, turns: i32) -> CropRect {
|
||||
let mut r = rect;
|
||||
for _ in 0..turns.rem_euclid(4) {
|
||||
r = CropRect {
|
||||
x: 1.0 - r.y - r.height,
|
||||
y: r.x,
|
||||
width: r.height,
|
||||
height: r.width,
|
||||
};
|
||||
}
|
||||
r.normalised()
|
||||
}
|
||||
|
||||
/// Sort ascending and force a minimum separation.
|
||||
///
|
||||
/// Mirrors what the curve operation does before handing points to the
|
||||
@@ -577,6 +711,103 @@ mod tests {
|
||||
use super::*;
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
/// The whole scroll-to-zoom path, end to end, in the order the user drives
|
||||
/// it: show the image fitted, *then* turn the wheel.
|
||||
///
|
||||
/// The lower layers each had zoom tests and each passed while this was
|
||||
/// broken, because every one of them set a view before its first render.
|
||||
/// That ordering hid the bug — a neutral framing compiles a prologue that
|
||||
/// never reads the crop rect, and while zoom was absent from the structure
|
||||
/// hash that pipeline stayed cached once zoomed. The session reported the
|
||||
/// new zoom, the uniforms carried the new view, and the pixels never moved.
|
||||
///
|
||||
/// So this asserts on the rendered pixels rather than on `zoom()`: the
|
||||
/// symptom was precisely that the state was right and the image was not.
|
||||
#[test]
|
||||
fn zooming_after_a_fitted_render_changes_the_pixels() {
|
||||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||||
log::warn!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// A gradient, so any change in the sampled region moves the pixels.
|
||||
let (w, h) = (64u32, 64u32);
|
||||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]);
|
||||
}
|
||||
}
|
||||
let mut session = DevelopSession::open_rgb(&ctx, &rgba, w, h).expect("session");
|
||||
|
||||
let fitted = session.render(64, 64).expect("fitted render");
|
||||
session.zoom_about(4.0, 0.5, 0.5);
|
||||
assert!(session.is_zoomed(), "the session did not register the zoom");
|
||||
let zoomed = session.render(64, 64).expect("zoomed render");
|
||||
|
||||
let before = fitted.to_rgba8().expect("fitted pixels");
|
||||
let after = zoomed.to_rgba8().expect("zoomed pixels");
|
||||
let differing = before
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.zip(after.as_bytes().iter())
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
|
||||
assert!(
|
||||
differing > 0,
|
||||
"zooming 4x after a fitted render produced identical pixels — the \
|
||||
view reached the session but not the shader"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() {
|
||||
// What decides whether the canvas is filtered. The distinction this
|
||||
// guards is the reason the interface cannot answer it from `zoom()`
|
||||
// alone: the same 4x on a large source is still showing more source
|
||||
// pixels than screen pixels, while on a small one it is already
|
||||
// inventing values between them.
|
||||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||||
log::warn!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// Bigger than the viewport it is shown in: `fit` scales it down, so
|
||||
// every screen pixel still has several source pixels behind it.
|
||||
let big = vec![128u8; (800 * 800 * 4) as usize];
|
||||
let mut session = DevelopSession::open_rgb(&ctx, &big, 800, 800).expect("session");
|
||||
assert!(
|
||||
!session.magnifies_source(200, 200),
|
||||
"a downscaled image is not magnified"
|
||||
);
|
||||
session.zoom_about(2.0, 0.5, 0.5);
|
||||
assert!(
|
||||
!session.magnifies_source(200, 200),
|
||||
"2x on a 4x-downscaled source is still below 1:1"
|
||||
);
|
||||
session.zoom_about(8.0, 0.5, 0.5);
|
||||
assert!(
|
||||
session.magnifies_source(200, 200),
|
||||
"16x on a 4x-downscaled source magnifies and must not be filtered"
|
||||
);
|
||||
|
||||
// Smaller than the viewport: `fit` refuses to upscale, so the render is
|
||||
// 1:1 and unzoomed is exactly the boundary — not past it.
|
||||
let small = vec![128u8; (100 * 100 * 4) as usize];
|
||||
let mut session = DevelopSession::open_rgb(&ctx, &small, 100, 100).expect("session");
|
||||
assert!(
|
||||
!session.magnifies_source(800, 800),
|
||||
"1:1 is the boundary, not past it — filtering must not flip on a \
|
||||
rounding error"
|
||||
);
|
||||
session.zoom_about(2.0, 0.5, 0.5);
|
||||
assert!(
|
||||
session.magnifies_source(800, 800),
|
||||
"any zoom past a 1:1 render magnifies"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_capability_becomes_exactly_one_row() {
|
||||
// The UI shows what the pipeline offers — no more, and nothing
|
||||
@@ -616,7 +847,62 @@ mod tests {
|
||||
heads += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(heads, caps.len());
|
||||
// Every operation but framing, which has its own panel.
|
||||
let generated = caps
|
||||
.iter()
|
||||
.filter(|c| c.id != dr_pipeline::framing::ID)
|
||||
.count();
|
||||
assert_eq!(heads, generated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framing_is_not_generated_as_sliders() {
|
||||
// The geometry panel presents crop, rotation, flips and straightening
|
||||
// as the gestures they are. If the generic path emitted them too, the
|
||||
// sidebar would carry both — including four "Crop Left/Top/Width/
|
||||
// Height" sliders that no one can compose a photograph with.
|
||||
let graph = EditGraph::default_chain();
|
||||
let caps = graph.capabilities();
|
||||
assert!(
|
||||
caps.iter().any(|c| c.id == dr_pipeline::framing::ID),
|
||||
"the chain must still expose framing — the panel reads it"
|
||||
);
|
||||
|
||||
// Asserted through the row count rather than by inspecting labels: a
|
||||
// leaked framing group would add its eight parameters as eight rows,
|
||||
// and the difference is exactly what `rows_of` must not contain.
|
||||
let framing_params = caps
|
||||
.iter()
|
||||
.find(|c| c.id == dr_pipeline::framing::ID)
|
||||
.map(|c| c.params.len())
|
||||
.expect("framing is in the chain");
|
||||
assert!(framing_params > 0);
|
||||
|
||||
let generated = rows_of(&caps).len();
|
||||
let with_framing = rows_of_unfiltered(&caps).len();
|
||||
assert_eq!(
|
||||
with_framing - generated,
|
||||
framing_params,
|
||||
"framing parameters leaked into the generated panel"
|
||||
);
|
||||
}
|
||||
|
||||
/// `rows_of` without the framing skip — the shape the panel would have if
|
||||
/// framing were generated, which is what the test above measures against.
|
||||
fn rows_of_unfiltered(caps: &[OpCapability]) -> Vec<(usize, usize)> {
|
||||
let mut rows = Vec::new();
|
||||
for op in caps {
|
||||
let head = rows.len();
|
||||
let collapses = op
|
||||
.presentation
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.params.len() == op.params.len());
|
||||
let len = if collapses { 1 } else { op.params.len() };
|
||||
for _ in 0..len {
|
||||
rows.push((head, len));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -702,6 +988,12 @@ mod tests {
|
||||
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
|
||||
let mut rows = Vec::new();
|
||||
for op in caps {
|
||||
// Framing is presented by `GeometryPanel`, not generated — mirror
|
||||
// the skip, or these tests assert against a panel that is not the
|
||||
// one the interface builds.
|
||||
if op.id == dr_pipeline::framing::ID {
|
||||
continue;
|
||||
}
|
||||
let head = rows.len();
|
||||
let collapses = op
|
||||
.presentation
|
||||
@@ -715,6 +1007,114 @@ mod tests {
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_quarter_turns_return_a_crop_where_it_started() {
|
||||
// The property that makes rotation safe to repeat: a user who turns
|
||||
// past the orientation they wanted and keeps going must arrive back at
|
||||
// the crop they had, not at a slowly drifting one.
|
||||
let start = CropRect {
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
width: 0.3,
|
||||
height: 0.4,
|
||||
};
|
||||
let mut r = start;
|
||||
for _ in 0..4 {
|
||||
r = rotate_crop(r, 1);
|
||||
}
|
||||
assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x);
|
||||
assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y);
|
||||
assert!((r.width - start.width).abs() < 1e-5);
|
||||
assert!((r.height - start.height).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_exchanges_a_crops_extents() {
|
||||
// A portrait selection on a landscape frame must come out landscape.
|
||||
// Were the extents left alone, the rect would keep its old shape while
|
||||
// the frame changed to the other one, and the crop would spill off the
|
||||
// photograph.
|
||||
let r = rotate_crop(
|
||||
CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.25,
|
||||
height: 1.0,
|
||||
},
|
||||
1,
|
||||
);
|
||||
assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width);
|
||||
assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_a_crop_keeps_it_inside_the_frame() {
|
||||
// Whatever the angle and wherever the rect, the result must still be a
|
||||
// rect the pipeline can render: outside the unit square it would
|
||||
// sample undefined area, and degenerate it is a zero-sized texture.
|
||||
for turns in -5..=5 {
|
||||
for rect in [
|
||||
CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
CropRect {
|
||||
x: 0.7,
|
||||
y: 0.8,
|
||||
width: 0.3,
|
||||
height: 0.2,
|
||||
},
|
||||
CropRect {
|
||||
x: 0.0,
|
||||
y: 0.45,
|
||||
width: 0.02,
|
||||
height: 0.02,
|
||||
},
|
||||
] {
|
||||
let r = rotate_crop(rect, turns);
|
||||
assert!(
|
||||
r.x >= 0.0 && r.y >= 0.0,
|
||||
"{turns} turns of {rect:?} gave {r:?}"
|
||||
);
|
||||
assert!(
|
||||
r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5,
|
||||
"{turns} turns of {rect:?} left the frame: {r:?}"
|
||||
);
|
||||
assert!(
|
||||
r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT,
|
||||
"{turns} turns of {rect:?} went degenerate: {r:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opposite_quarter_turns_cancel() {
|
||||
// The rotate-left and rotate-right buttons must undo one another, or
|
||||
// correcting an over-rotation would land somewhere new each time.
|
||||
let start = CropRect {
|
||||
x: 0.15,
|
||||
y: 0.05,
|
||||
width: 0.5,
|
||||
height: 0.25,
|
||||
};
|
||||
let there_and_back = rotate_crop(rotate_crop(start, 1), -1);
|
||||
assert!((there_and_back.x - start.x).abs() < 1e-5);
|
||||
assert!((there_and_back.y - start.y).abs() < 1e-5);
|
||||
assert!((there_and_back.width - start.width).abs() < 1e-5);
|
||||
assert!((there_and_back.height - start.height).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_crop_survives_rotation_as_a_full_crop() {
|
||||
// The common case: rotating an uncropped photograph must not quietly
|
||||
// introduce a crop, which would shrink the exported image.
|
||||
assert!(rotate_crop(CropRect::default(), 1).is_full());
|
||||
assert!(rotate_crop(CropRect::default(), -3).is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_suffixes_come_from_the_descriptor() {
|
||||
assert_eq!(unit_suffix(Unit::Stops), " EV");
|
||||
|
||||
Reference in New Issue
Block a user