Skip to main content

hyprforge_image/
budget.rs

1//! How many pixels it is safe to keep, and how that number is reached.
2//!
3//! The one place a cap lives, and pure arithmetic — every test here takes
4//! numbers and touches no file.
5//!
6//! # Why a viewer's rule is the opposite of a thumbnail's
7//!
8//! `hyprforge-clipmenu` refuses any image over 16 megapixels and shows a
9//! text row instead, which is right for a clipboard popup: the picture is
10//! a convenience and the popup has other rows to draw. A viewer may not
11//! refuse. Showing the photograph *is* the app, and "that image is too
12//! big" is not an answer anyone accepts from the program they opened it
13//! with.
14//!
15//! So the rule inverts: decode to fit the **window**, not the file. A
16//! 36-megapixel photograph on a 2560x1600 display has no business
17//! becoming a 144MB buffer, because the screen cannot show more than
18//! four million of those pixels at once. What is retained is bounded by
19//! the viewport and stays bounded no matter what the camera produced.
20//!
21//! # What this does and does not buy
22//!
23//! Honest about its own limits, because a budget that is believed to do
24//! more than it does is worse than none:
25//!
26//! - **Retained** memory is capped. That is the allocation that lives for
27//!   as long as the picture is on screen, and the one a cache multiplies.
28//! - **Transient** memory is not, and cannot be here: `image` 0.25
29//!   exposes no DCT-scaled decode, so a JPEG is decoded whole and then
30//!   scaled down. The peak is a property of the source, not of this
31//!   budget.
32//!
33//! # The measured numbers
34//!
35//! A 36-megapixel JPEG (8001x4501, 2.8MB on disk — a file no larger than
36//! an email attachment) shown in a 2560x1600 viewport, measured through
37//! `/proc/self/status`'s `VmHWM` in a process that had allocated nothing
38//! else:
39//!
40//! | | peak | retained |
41//! |---|---|---|
42//! | decoded and scaled with `resize_exact` | 509MB | 56MB |
43//! | decoded with no scaling at all | 252MB | 137MB |
44//! | decoded and scaled with `thumbnail_exact` | **214MB** | 56MB |
45//!
46//! Two things in that table were worth the measuring. The scaling step
47//! cost more than the decode it followed — `resize_exact` works through
48//! floating-point intermediates, and on a 36-megapixel source those are
49//! larger than the picture. And the cheap integer path is not merely
50//! cheaper than the expensive one, it is cheaper than *not scaling*,
51//! because it never materialises the full-size RGBA buffer on the way.
52//!
53//! `decode` uses the third row. See its note on why that is not a
54//! quality compromise at this ratio.
55//!
56//! What is left is a 214MB peak on a machine with any amount of memory,
57//! and the lock screen's lesson is that a peak is not free just because
58//! it is brief. Bringing it lower needs a decoder that can scale while
59//! decoding, which is a change of dependency rather than of arithmetic.
60
61use crate::measure::SourcePixels;
62
63/// The size something will be decoded to — always at or under the
64/// source, never larger. A viewer showing a 64x64 icon full-screen
65/// scales it up when *drawing*; decoding it big would allocate a buffer
66/// full of invented pixels.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct DecodePixels {
69    pub width: u32,
70    pub height: u32,
71}
72
73impl DecodePixels {
74    /// Bytes an RGBA8 buffer of this size occupies, as `u64` because the
75    /// arithmetic overflows `u32` well before the sizes do.
76    pub fn rgba_bytes(self) -> u64 {
77        u64::from(self.width) * u64::from(self.height) * 4
78    }
79}
80
81/// The size of the area a picture is being shown in, in **physical**
82/// pixels — the buffer's unit, not the widget tree's.
83///
84/// Named rather than a bare `(u32, u32)` because this is the one place
85/// logical and physical coordinates meet, and mixing them is the failure
86/// CLAUDE.md records twice: a 1.6-scale output makes a logical window
87/// 1.6x smaller than the buffer it draws into, so a budget computed from
88/// logical pixels would decode a picture visibly soft on exactly the
89/// displays where it matters most.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct ViewportPixels {
92    pub width: u32,
93    pub height: u32,
94}
95
96impl ViewportPixels {
97    /// From a logical size and the output's scale factor. The conversion
98    /// is here, once, so no caller has to remember which way it goes.
99    pub fn from_logical(width: f32, height: f32, scale: f32) -> ViewportPixels {
100        ViewportPixels {
101            width: (width * scale).ceil().max(1.0) as u32,
102            height: (height * scale).ceil().max(1.0) as u32,
103        }
104    }
105}
106
107/// How much of a picture may be kept in memory at once.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct Budget {
110    /// The longest edge a retained decode may have.
111    max_edge: u32,
112    /// An absolute ceiling on the *source* dimensions, used to refuse a
113    /// file before decoding rather than after.
114    max_source_edge: u32,
115}
116
117/// How much bigger than the viewport a decode is allowed to be.
118///
119/// Not 1.0. Zooming in is the second thing anyone does in a viewer, and
120/// re-decoding on every zoom step would make it stutter. At 2x a picture
121/// can be examined at twice the window's resolution before anything has
122/// to be read again, for four times the bytes of a fit-to-window decode —
123/// on a 2560x1600 display that is 4096x2560 kept, about 42MB, which is a
124/// tenth of what one uncapped 36-megapixel photograph was costing.
125const ZOOM_HEADROOM: f32 = 2.0;
126
127/// The largest source this will decode at all, on any edge.
128///
129/// Not a judgement about photographs — 65536 is past every camera — but a
130/// guard against a file whose header claims a size no allocator can
131/// serve. `image`'s own `Limits` enforces it during decode, before the
132/// allocation, which is the only place it can be enforced usefully.
133const MAX_SOURCE_EDGE: u32 = 65_536;
134
135impl Budget {
136    /// The budget for showing a picture in a given viewport.
137    pub fn for_viewport(viewport: ViewportPixels) -> Budget {
138        let longest = viewport.width.max(viewport.height).max(1);
139        Budget {
140            max_edge: ((longest as f32) * ZOOM_HEADROOM).ceil() as u32,
141            max_source_edge: MAX_SOURCE_EDGE,
142        }
143    }
144
145    /// A budget with an explicit edge, for a thumbnail or a filmstrip
146    /// frame, where the size wanted is known exactly.
147    pub fn for_edge(max_edge: u32) -> Budget {
148        Budget { max_edge: max_edge.max(1), max_source_edge: MAX_SOURCE_EDGE }
149    }
150
151    /// What `source` should be decoded to under this budget.
152    ///
153    /// Aspect ratio is preserved, and a picture already inside the budget
154    /// is decoded at its own size — never scaled up, which would allocate
155    /// a buffer of invented pixels.
156    pub fn fit(&self, source: SourcePixels) -> DecodePixels {
157        let (w, h) = (source.width.max(1), source.height.max(1));
158        let longest = w.max(h);
159        if longest <= self.max_edge {
160            return DecodePixels { width: w, height: h };
161        }
162        let ratio = f64::from(self.max_edge) / f64::from(longest);
163        DecodePixels {
164            // `max(1)`: a panorama 20000x1 pixels scaled by ratio would
165            // round its short edge to zero, and a zero-sized decode is an
166            // error rather than a picture.
167            width: ((f64::from(w) * ratio).round() as u32).max(1),
168            height: ((f64::from(h) * ratio).round() as u32).max(1),
169        }
170    }
171
172    /// Whether a source this size may be decoded at all.
173    pub fn allows_source(&self, source: SourcePixels) -> bool {
174        source.width <= self.max_source_edge && source.height <= self.max_source_edge
175    }
176
177    /// The limits handed to `image`'s decoder, which refuse an absurd
178    /// file *before* it allocates rather than after.
179    ///
180    /// Built on `Limits::default()` rather than `no_limits()`, which
181    /// keeps that default's 512MiB ceiling on decoder allocation.
182    ///
183    /// Worth being precise about what that ceiling does, because it is
184    /// easy to credit it with more: measured on a 36-megapixel JPEG, the
185    /// peak is **the same with it and without it** (509MB either way
186    /// before the fix in `decode`). It is not what bounds the transient
187    /// cost, and the near-match between 509MB and 512MiB is coincidence —
188    /// the sort of suspiciously round number CLAUDE.md warns is usually
189    /// somebody else's limit, which here it was not.
190    ///
191    /// What it does buy is a refusal for a file whose header claims a
192    /// size no allocator can serve, alongside the two edge caps. That is
193    /// worth keeping; it is simply not a defence against a large ordinary
194    /// photograph.
195    pub fn limits(&self) -> image::Limits {
196        let mut limits = image::Limits::default();
197        limits.max_image_width = Some(self.max_source_edge);
198        limits.max_image_height = Some(self.max_source_edge);
199        limits
200    }
201
202    /// The most an image decoded under this budget can retain, in bytes.
203    /// What a cache multiplies, and what a test asserts on.
204    pub fn max_retained_bytes(&self) -> u64 {
205        let edge = u64::from(self.max_edge);
206        edge * edge * 4
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn source(width: u32, height: u32) -> SourcePixels {
215        SourcePixels { width, height }
216    }
217
218    /// The headline claim of this module, in the units people think in:
219    /// a 36-megapixel photograph — the one that peaked at 296MB in the
220    /// lock screen — is never retained at full size.
221    #[test]
222    fn a_thirty_six_megapixel_photograph_is_never_retained_at_full_size() {
223        let budget = Budget::for_viewport(ViewportPixels { width: 2560, height: 1600 });
224        let fitted = budget.fit(source(8001, 4501));
225        assert!(fitted.width < 8001, "{fitted:?}");
226        // 144MB at full size; a small fraction of it kept.
227        assert!(fitted.rgba_bytes() < 60 * 1024 * 1024, "{} bytes", fitted.rgba_bytes());
228        assert!(fitted.rgba_bytes() <= budget.max_retained_bytes());
229    }
230
231    #[test]
232    fn fitting_preserves_the_shape_of_the_picture() {
233        let budget = Budget::for_edge(1000);
234        let fitted = budget.fit(source(4000, 2000));
235        assert_eq!(fitted, DecodePixels { width: 1000, height: 500 });
236    }
237
238    /// Decoding a small picture large would allocate a buffer of pixels
239    /// that were never in the file.
240    #[test]
241    fn a_picture_smaller_than_the_budget_is_never_scaled_up() {
242        let budget = Budget::for_edge(4000);
243        assert_eq!(budget.fit(source(64, 64)), DecodePixels { width: 64, height: 64 });
244    }
245
246    /// A panorama's short edge rounds towards zero, and a zero-sized
247    /// decode is an error rather than a picture.
248    #[test]
249    fn an_extreme_panorama_keeps_at_least_one_pixel_on_its_short_edge() {
250        let budget = Budget::for_edge(100);
251        let fitted = budget.fit(source(20_000, 1));
252        assert_eq!(fitted.width, 100);
253        assert_eq!(fitted.height, 1);
254    }
255
256    /// The budget is a function of the window, not of the file — which is
257    /// the whole design. Two very different photographs shown in the same
258    /// window retain the same amount.
259    #[test]
260    fn what_is_kept_depends_on_the_window_and_not_on_the_file() {
261        let budget = Budget::for_viewport(ViewportPixels { width: 1920, height: 1080 });
262        let huge = budget.fit(source(12_000, 8_000)).rgba_bytes();
263        let large = budget.fit(source(9_000, 6_000)).rgba_bytes();
264        assert_eq!(huge, large);
265    }
266
267    /// A scaled output decodes more, because its buffer really is bigger.
268    /// Computing this from logical pixels is what would make a picture
269    /// soft on exactly the displays where it shows most.
270    #[test]
271    fn a_scaled_display_gets_a_bigger_budget_than_its_logical_size() {
272        let logical = ViewportPixels::from_logical(1600.0, 1000.0, 1.0);
273        let scaled = ViewportPixels::from_logical(1600.0, 1000.0, 1.6);
274        assert_eq!(scaled.width, 2560);
275        assert!(
276            Budget::for_viewport(scaled).max_retained_bytes()
277                > Budget::for_viewport(logical).max_retained_bytes()
278        );
279    }
280
281    #[test]
282    fn a_file_claiming_an_impossible_size_is_refused_before_decoding() {
283        let budget = Budget::for_edge(2000);
284        assert!(budget.allows_source(source(8001, 4501)));
285        assert!(!budget.allows_source(source(200_000, 4)));
286    }
287
288    #[test]
289    fn a_zero_sized_viewport_still_yields_a_usable_budget() {
290        let budget = Budget::for_viewport(ViewportPixels { width: 0, height: 0 });
291        let fitted = budget.fit(source(4000, 3000));
292        assert!(fitted.width >= 1 && fitted.height >= 1);
293    }
294}