From 337f02b4dc6ad9d82f0caf166849ba2975e2d65b Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Tue, 13 Oct 2026 07:13:30 -0500 Subject: [PATCH] Never crash: Fix export size panic for very long strips Apply the 1 px and 65535 px bounds in turn instead of clamping between bounds that can cross. --- crates/engine/src/export.rs | 32 ++++++++++++++++++++++++++++++-- docs/whats-new.md | 4 ++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index e53dfcbc4..e04fe6f44 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -131,8 +131,9 @@ impl Resize { ResizeMode::Percent => v / 100.0, }; let k = if self.dont_enlarge { k.min(1.0) } else { k }; - // at least one pixel, at most the encoders' 65535 limit - k.clamp(1.0 / short, 65_535.0 / long) + // at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more + // than 65535 times as long as it is short (where `clamp` would panic: min above max) + k.max(1.0 / short).min(65_535.0 / long) } /// Output size for a `w × h` (cropped, full-resolution) photo. @@ -2783,6 +2784,33 @@ mod tests { } } + /// A photo more than 65535 times as long as it is short (a 70000 × 1 strip) made `scale` + /// clamp with its lower bound above its upper one, which panics, in the export and in the + /// Export dialog's preview on the UI thread. The encoders' limit wins, the short edge keeps a + /// pixel. + #[test] + fn resize_survives_photos_too_long_for_the_encoders() { + let modes = [ + json!({"longEdge": 2000}), + json!({"shortEdge": 1}), + json!({"width": 500, "height": 500}), + json!({"megapixels": 1}), + json!({"percent": 50}), + json!({"percent": 1000, "dontEnlarge": false}), + ]; + for p in modes { + for (w, h) in [(70_000.0, 1.0), (1.0, 70_000.0), (1e9, 3.0), (65_536.0, 1.0)] { + let r = ExportOptions::from_json(&p).resize.unwrap(); + let (ow, oh) = r.apply(w, h); + assert!((1..=65_535).contains(&ow) && (1..=65_535).contains(&oh), "{p} on {w}×{h}: {ow}×{oh}"); + assert!(r.scale(w, h).is_finite()); + } + } + // (a short edge under a pixel would be scaled up to one, which the limit then caps) + assert_eq!(Resize::long_edge(2000).apply(70_000.0, 1.0), (65_535, 1)); + assert_eq!(Resize::long_edge(2000).apply(1.0, 70_000.0), (1, 65_535)); + } + /// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. fn p3_green(space: OutputSpace) -> Rgba8 { use lightcraft_color::{DISPLAY_P3, REC2020}; diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..2e2dcc451 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,10 @@ ## 2026-10-13 +### Reliability +- Exporting a photo more than 65,535 times as long as it is wide (a one-pixel strip), or opening the Export dialog's + watermark preview for it, no longer crashes LightCraft; the export is capped at the encoders' 65,535 pixels. + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's