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}