Krolyxon
Commit e515e5a

Branches

Tags

Refs →

remagee515e5a

Speed up start-up, decoding and quit; release v0.1.1

- Remember the working GPU backend and initialise only that one
- Decode JPEG straight to RGBA through zune-jpeg
- Apply EXIF orientation on the GPU instead of turning pixels
- Skip ICC transforms that are sRGB; thread the real ones
- Request grid thumbnails before the GPU is up
- Drop the window at once on quit and don't join busy workers
Author
krolyxon
Date
2026-11-13 19:15 +0530
Parent
9f6e071
Commit
e515e5ab5c99db9878cf74cff59d5d301b2265ce

+562 −92

14 files changed

  1. M CHANGELOG.md +11 −0
  2. M Cargo.lock +3 −1
  3. M Cargo.toml +3 −1
  4. M src/app.rs +18 −8
  5. M src/decode/color.rs +143 −17
  6. M src/decode/image_rs.rs +94 −9
  7. M src/decode/jxl.rs +1 −0
  8. M src/decode/mod.rs +42 −10
  9. M src/main.rs +2 −0
  10. A src/render/backend.rs +97 −0
  11. M src/render/image.rs +51 −3
  12. M src/render/mod.rs +63 −26
  13. M src/thumbs.rs +23 −3
  14. M src/worker.rs +11 −14
M CHANGELOG.md View
@@ -1,5 +1,16 @@
11 # Changelog
22
3+## [0.1.1] - 2026-11-13
4+
5+**Faster**
6+- Start-up initialises only the GPU backend that worked last time (remembered in
7+ `$XDG_CACHE_HOME/remage/backend`): the first frame takes about 40 ms instead of 90.
8+- JPEGs decode straight to RGBA, EXIF rotation happens on the GPU, and sRGB profiles are no longer
9+ "converted" to sRGB: a rotated, tagged 24 MP photo decodes in about 80 ms instead of 180.
10+- Other ICC profiles convert on several threads.
11+- The grid requests thumbnails while the GPU starts up: a cached grid opens in about 55 ms.
12+- Quitting no longer waits for decodes in flight.
13+
314 ## [0.1.0] - 2026-11-05
415
516 The first release: a keyboard-centric image viewer for Linux, at nsxiv parity with vim keys,
M Cargo.lock View
@@ -3057,7 +3057,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
30573057
30583058 [[package]]
30593059 name = "remage"
3060−version = "0.1.0"
3060+version = "0.1.1"
30613061 dependencies = [
30623062 "anyhow",
30633063 "bytemuck",
@@ -3091,6 +3091,8 @@ dependencies = [
30913091 "toml 1.2.0+spec-1.1.0",
30923092 "wgpu",
30933093 "winit",
3094+ "zune-core",
3095+ "zune-jpeg",
30943096 ]
30953097
30963098 [[package]]
M Cargo.toml View
@@ -1,6 +1,6 @@
11 [package]
22 name = "remage"
3−version = "0.1.0"
3+version = "0.1.1"
44 edition = "2024"
55 description = "Keyboard-centric image viewer with a Nothing-inspired look"
66 rust-version = "1.96"
@@ -43,6 +43,8 @@ tiff = "0.11.3"
4343 toml = "1.2.0"
4444 wgpu = "30"
4545 winit = "0.30"
46+zune-core = "0.5.3"
47+zune-jpeg = "0.5.15"
4648
4749 [features]
4850 default = ["jxl", "svg", "avif", "heif", "raw"]
M src/app.rs View
@@ -520,7 +520,6 @@ impl App {
520520 if !self.state.in_grid() {
521521 return;
522522 }
523− let Some(g) = &mut self.gfx else { return };
524523 let px = self.state.cell_px();
525524 if px != self.thumbs.cell {
526525 for p in self.thumbs.requested.drain().map(|(p, _)| p) {
@@ -528,7 +527,10 @@ impl App {
528527 }
529528 self.thumbs.arrivals.clear();
530529 self.state.thumbs.clear();
531− g.renderer.reset_thumbs(px);
530+ // Before the GPU exists, thumbnails wait in `arrivals` for an atlas of this size.
531+ if let Some(g) = &mut self.gfx {
532+ g.renderer.reset_thumbs(px);
533+ }
532534 self.thumbs.cell = px;
533535 }
534536 let l = self.state.grid_layout();
@@ -2062,9 +2064,18 @@ impl ApplicationHandler<UserEvent> for App {
20622064 return;
20632065 }
20642066 };
2067+ let size = window.inner_size();
2068+ self.state.set_window(
2069+ size.width as f32,
2070+ size.height as f32,
2071+ window.scale_factor() as f32,
2072+ );
2073+ // The grid's size is known now: thumbnails load while the GPU starts up.
2074+ self.sync_thumbs();
20652075 let renderer = match Renderer::new(window.clone(), event_loop.owned_display_handle()) {
20662076 Ok(mut r) => {
20672077 r.set_font_families(font_families(&self.config));
2078+ r.reset_thumbs(self.thumbs.cell);
20682079 r
20692080 }
20702081 Err(e) => {
@@ -2074,16 +2085,15 @@ impl ApplicationHandler<UserEvent> for App {
20742085 }
20752086 };
20762087 log::debug!("GPU ready after {:.1?}", self.started.elapsed());
2077− let size = window.inner_size();
2078− self.state.set_window(
2079− size.width as f32,
2080− size.height as f32,
2081− window.scale_factor() as f32,
2082− );
20832088 self.gfx = Some(Gfx { renderer, window });
20842089 self.sync(false);
20852090 }
20862091
2092+ fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
2093+ // The window goes at once; whatever else is still winding down does so unseen.
2094+ self.gfx = None;
2095+ }
2096+
20872097 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
20882098 match event {
20892099 UserEvent::Decoded {
M src/decode/color.rs View
@@ -57,7 +57,11 @@ impl ToSrgb {
5757 Layout::Rgba,
5858 TransformOptions::default(),
5959 ) {
60− Ok(t) => Some(Self(t)),
60+ Ok(t) => {
61+ let t = Self(t);
62+ // Most tagged files carry some flavour of the sRGB profile.
63+ (!t.is_identity()).then_some(t)
64+ }
6165 Err(e) => {
6266 log::debug!("colour transform unavailable: {e:?}");
6367 None
@@ -65,13 +69,54 @@ impl ToSrgb {
6569 }
6670 }
6771
68− /// Convert RGBA8 pixels in place; alpha is untouched.
72+ /// Whether the transform leaves colours alone, to within rounding (an sRGB source).
73+ fn is_identity(&self) -> bool {
74+ const STEPS: [u8; 9] = [0, 32, 64, 96, 128, 160, 192, 224, 255];
75+ let mut src = Vec::with_capacity((STEPS.len().pow(3) + 256) * 4);
76+ for r in STEPS {
77+ for g in STEPS {
78+ for b in STEPS {
79+ src.extend([r, g, b, 255]);
80+ }
81+ }
82+ }
83+ for v in 0..=255 {
84+ src.extend([v, v, v, 255]);
85+ }
86+ let mut dst = vec![0; src.len()];
87+ self.0.transform(&src, &mut dst).is_ok()
88+ && src.iter().zip(&dst).all(|(a, b)| a.abs_diff(*b) <= 1)
89+ }
90+
91+ /// Convert RGBA8 pixels in place; alpha is untouched. Large images are split across a
92+ /// few threads.
6993 pub fn apply(&self, pixels: &mut [u8]) {
70− let src = pixels.to_vec();
71− if let Err(e) = self.0.transform(&src, pixels) {
72− log::debug!("colour transform failed: {e:?}");
73− pixels.copy_from_slice(&src);
94+ /// Pixels are converted through a scratch buffer of this many bytes.
95+ const CHUNK: usize = 64 << 10;
96+ /// Bytes that make another thread worth starting.
97+ const PER_THREAD: usize = 4 << 20;
98+ let band = |band: &mut [u8]| {
99+ let mut scratch = [0u8; CHUNK];
100+ for chunk in band.chunks_mut(CHUNK) {
101+ let src = &mut scratch[..chunk.len()];
102+ src.copy_from_slice(chunk);
103+ if let Err(e) = self.0.transform(src, chunk) {
104+ log::debug!("colour transform failed: {e:?}");
105+ chunk.copy_from_slice(src);
106+ }
107+ }
108+ };
109+ let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
110+ let threads = (pixels.len() / PER_THREAD).clamp(1, cores.min(4));
111+ if threads == 1 {
112+ return band(pixels);
74113 }
114+ let len = (pixels.len() / 4).div_ceil(threads) * 4;
115+ std::thread::scope(|s| {
116+ for part in pixels.chunks_mut(len) {
117+ s.spawn(|| band(part));
118+ }
119+ });
75120 }
76121 }
77122
@@ -124,11 +169,33 @@ pub fn is_float(img: &DynamicImage) -> bool {
124169 /// Straight RGBA8 from any decoded image, tone-mapping float data.
125170 pub fn to_rgba8(img: DynamicImage) -> ([u32; 2], Vec<u8>) {
126171 let size = [img.width(), img.height()];
127− if is_float(&img) {
128− let f = img.into_rgba32f();
129− (size, tonemap(f.as_raw()))
130− } else {
131− (size, img.into_rgba8().into_raw())
172+ match img {
173+ DynamicImage::ImageRgb8(rgb) => (size, expand(rgb.as_raw(), 3)),
174+ img if is_float(&img) => (size, tonemap(img.into_rgba32f().as_raw())),
175+ img => (size, img.into_rgba8().into_raw()),
176+ }
177+}
178+
179+/// Whether an orientation turns the image on its side, swapping width and height.
180+pub fn swaps_axes(o: Orientation) -> bool {
181+ use Orientation::*;
182+ matches!(o, Rotate90 | Rotate270 | Rotate90FlipH | Rotate270FlipH)
183+}
184+
185+/// Where stored pixels land in the upright image, as a map between unit squares:
186+/// `upright = m * stored + t`. The renderer folds this into an image's placement, so
187+/// rotated photos are never turned on the CPU.
188+pub fn orient_uv(o: Orientation) -> ([f32; 4], [f32; 2]) {
189+ use Orientation::*;
190+ match o {
191+ NoTransforms => ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0]),
192+ Rotate90 => ([0.0, -1.0, 1.0, 0.0], [1.0, 0.0]),
193+ Rotate180 => ([-1.0, 0.0, 0.0, -1.0], [1.0, 1.0]),
194+ Rotate270 => ([0.0, 1.0, -1.0, 0.0], [0.0, 1.0]),
195+ FlipHorizontal => ([-1.0, 0.0, 0.0, 1.0], [1.0, 0.0]),
196+ FlipVertical => ([1.0, 0.0, 0.0, -1.0], [0.0, 1.0]),
197+ Rotate90FlipH => ([0.0, 1.0, 1.0, 0.0], [0.0, 0.0]),
198+ Rotate270FlipH => ([0.0, -1.0, -1.0, 0.0], [1.0, 1.0]),
132199 }
133200 }
134201
@@ -150,12 +217,18 @@ pub fn orient(size: [u32; 2], pixels: Vec<u8>, o: Orientation) -> ([u32; 2], Vec
150217 pub fn expand(samples: &[u8], channels: usize) -> Vec<u8> {
151218 match channels {
152219 4 => samples.to_vec(),
153− 3 => samples
154− .as_chunks::<3>()
155− .0
156− .iter()
157− .flat_map(|p| [p[0], p[1], p[2], 255])
158− .collect(),
220+ 3 => {
221+ let mut out = vec![255; samples.len() / 3 * 4];
222+ for (o, p) in out
223+ .as_chunks_mut::<4>()
224+ .0
225+ .iter_mut()
226+ .zip(samples.as_chunks::<3>().0)
227+ {
228+ o[..3].copy_from_slice(p);
229+ }
230+ out
231+ }
159232 2 => samples
160233 .as_chunks::<2>()
161234 .0
@@ -192,6 +265,55 @@ mod tests {
192265 assert_eq!((size, out.len()), ([2, 1], 8));
193266 }
194267
268+ #[test]
269+ fn gpu_orientation_matches_the_cpu() {
270+ use Orientation::*;
271+ // 3×2, every pixel distinct.
272+ let (w, h) = (3u32, 2u32);
273+ let px: Vec<u8> = (0..w * h).flat_map(|i| [i as u8, 0, 0, 255]).collect();
274+ for o in [
275+ NoTransforms,
276+ Rotate90,
277+ Rotate180,
278+ Rotate270,
279+ FlipHorizontal,
280+ FlipVertical,
281+ Rotate90FlipH,
282+ Rotate270FlipH,
283+ ] {
284+ let ([uw, uh], upright) = orient([w, h], px.clone(), o);
285+ assert_eq!(swaps_axes(o), uw == h, "{o:?}");
286+ let (m, t) = orient_uv(o);
287+ for y in 0..h {
288+ for x in 0..w {
289+ // The stored pixel's centre, mapped into the upright image.
290+ let (u, v) = ((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32);
291+ let ux = ((m[0] * u + m[1] * v + t[0]) * uw as f32) as u32;
292+ let uy = ((m[2] * u + m[3] * v + t[1]) * uh as f32) as u32;
293+ let got = upright[((uy * uw + ux) * 4) as usize];
294+ assert_eq!(got, (y * w + x) as u8, "{o:?} at {x},{y}");
295+ }
296+ }
297+ }
298+ }
299+
300+ #[test]
301+ fn srgb_profiles_need_no_transform() {
302+ let srgb = ColorProfile::new_srgb().encode().unwrap();
303+ assert!(ToSrgb::from_icc(&srgb).is_none());
304+ let p3 = ColorProfile::new_display_p3().encode().unwrap();
305+ let t = ToSrgb::from_icc(&p3).expect("P3 transform");
306+ // Threaded and single-threaded conversion agree, whatever the length.
307+ let px: Vec<u8> = (0..(9 << 20) + 4).map(|i| (i * 7 % 256) as u8).collect();
308+ let mut big = px.clone();
309+ t.apply(&mut big);
310+ for at in [0, 4 << 20, px.len() - 8] {
311+ let mut small = px[at..at + 8].to_vec();
312+ t.apply(&mut small);
313+ assert_eq!(small, big[at..at + 8], "at {at}");
314+ }
315+ }
316+
195317 #[test]
196318 fn cicp_and_icc() {
197319 assert!(ToSrgb::from_cicp(1, 13).is_none(), "BT.709 is already sRGB");
@@ -203,5 +325,9 @@ mod tests {
203325 assert!(px[3] == 200);
204326 assert!(ToSrgb::from_icc(b"not a profile").is_none());
205327 assert_eq!(expand(&[7, 9], 2), [7, 7, 7, 9]);
328+ assert_eq!(
329+ expand(&[1, 2, 3, 4, 5, 6, 7], 3),
330+ [1, 2, 3, 255, 4, 5, 6, 255]
331+ );
206332 }
207333 }
M src/decode/image_rs.rs View
@@ -68,6 +68,13 @@ pub fn decode_file(
6868 if let Some(img) = animated {
6969 return Ok(img);
7070 }
71+ if format == ImageFormat::Jpeg {
72+ match decode_jpeg(path, opts) {
73+ Ok(Some(img)) => return Ok(img),
74+ Ok(None) => {}
75+ Err(e) => log::debug!("fast JPEG path failed, retrying: {e:#}"),
76+ }
77+ }
7178 let mut img = decode_reader(open()?, format, opts)?;
7279 if format == ImageFormat::Tiff {
7380 add_tiff_pages(path, &mut img, opts);
@@ -75,7 +82,53 @@ pub fn decode_file(
7582 Ok(img)
7683 }
7784
78−/// A still image from any reader: ICC to sRGB, float tone-mapped, oriented upright.
85+/// JPEG straight to RGBA through zune-jpeg. `image` would hand over RGB, and widening 24 MP
86+/// costs a quarter of the decode again. `None` for the colour spaces (CMYK, YCCK, plain RGB)
87+/// that zune-jpeg can't write as RGBA; those go the long way.
88+fn decode_jpeg(path: &Path, opts: &Options) -> anyhow::Result<Option<DecodedImage>> {
89+ use zune_core::colorspace::ColorSpace;
90+ let data = std::fs::read(path)?;
91+ let options = zune_core::options::DecoderOptions::default()
92+ .set_strict_mode(false)
93+ .set_max_width(usize::MAX)
94+ .set_max_height(usize::MAX)
95+ .jpeg_set_out_colorspace(ColorSpace::RGBA);
96+ let mut d = zune_jpeg::JpegDecoder::new_with_options(
97+ zune_core::bytestream::ZCursor::new(data.as_slice()),
98+ options,
99+ );
100+ d.decode_headers()?;
101+ if !matches!(
102+ d.input_colorspace(),
103+ Some(ColorSpace::YCbCr | ColorSpace::Luma)
104+ ) {
105+ return Ok(None);
106+ }
107+ let (w, h) = d.dimensions().context("no JPEG header")?;
108+ if (w as u64) * (h as u64) * 4 > MAX_ALLOC {
109+ return Err(anyhow!("image too large"));
110+ }
111+ let orientation = d
112+ .exif()
113+ .filter(|_| opts.auto_orient)
114+ .and_then(|e| Orientation::from_exif_chunk(e))
115+ .unwrap_or(Orientation::NoTransforms);
116+ let icc = if opts.color { d.icc_profile() } else { None };
117+ let mut pixels = d.decode()?;
118+ if pixels.len() != w * h * 4 {
119+ return Err(anyhow!("JPEG decoded to an unexpected size"));
120+ }
121+ if let Some(t) = icc.as_deref().and_then(ToSrgb::from_icc) {
122+ t.apply(&mut pixels);
123+ }
124+ let size = [w as u32, h as u32];
125+ Ok(Some(
126+ DecodedImage::still(size, pixels, label(ImageFormat::Jpeg)).turned(orientation),
127+ ))
128+}
129+
130+/// A still image from any reader: ICC to sRGB, float tone-mapped. Orientation is left to the
131+/// renderer.
79132 pub fn decode_reader<R: BufRead + Seek>(
80133 reader: R,
81134 format: ImageFormat,
@@ -101,8 +154,7 @@ pub fn decode_reader<R: BufRead + Seek>(
101154 if !float && let Some(t) = icc.as_deref().and_then(ToSrgb::from_icc) {
102155 t.apply(&mut pixels);
103156 }
104− let (size, pixels) = color::orient(size, pixels, orientation);
105− Ok(DecodedImage::still(size, pixels, label(format)))
157+ Ok(DecodedImage::still(size, pixels, label(format)).turned(orientation))
106158 }
107159
108160 /// Decode from memory, guessing the format from the bytes (external decoders' output).
@@ -112,7 +164,7 @@ pub fn decode_bytes(bytes: &[u8], opts: &Options) -> anyhow::Result<DecodedImage
112164 }
113165
114166 /// All frames of an animation, or `None` when it has only one (decode it as a still, which
115−/// keeps its orientation).
167+/// knows its orientation).
116168 fn animation<'a>(
117169 decoder: impl AnimationDecoder<'a>,
118170 icc: Option<Vec<u8>>,
@@ -155,12 +207,14 @@ fn animation<'a>(
155207 format: label(format),
156208 source_size: size,
157209 refine: None,
210+ orientation: Orientation::NoTransforms,
158211 }))
159212 }
160213 }
161214 }
162215
163−/// Pages after the first, as extra (untimed) frames. Pages of another size are skipped.
216+/// Pages after the first, as extra (untimed) frames. Pages of another size or orientation
217+/// are skipped.
164218 fn add_tiff_pages(path: &Path, img: &mut DecodedImage, opts: &Options) {
165219 let Ok(file) = File::open(path) else { return };
166220 let Ok(mut d) = tiff::decoder::Decoder::new(BufReader::new(file)) else {
@@ -173,10 +227,8 @@ fn add_tiff_pages(path: &Path, img: &mut DecodedImage, opts: &Options) {
173227 }
174228 match tiff_page(&mut d) {
175229 Ok((size, pixels)) => {
176− // `image` rotated the first page; rotate the rest the same way.
177− let o = orientation_of(&mut d, opts);
178− let (size, pixels) = color::orient(size, pixels, o);
179− if size != img.size {
230+ // Every frame shares the first page's texture and placement.
231+ if size != img.size || orientation_of(&mut d, opts) != img.orientation {
180232 log::debug!("TIFF page of another size skipped");
181233 continue;
182234 }
@@ -267,6 +319,39 @@ mod tests {
267319 assert_eq!(img.frame(1)[0], 0);
268320 }
269321
322+ #[test]
323+ fn jpeg_fast_path() {
324+ use image::codecs::jpeg::JpegEncoder;
325+ let dir = dir();
326+ // Grey and colour both come out as opaque RGBA.
327+ let grey = dir.join("grey.jpg");
328+ let buf = image::GrayImage::from_pixel(16, 8, image::Luma([200]));
329+ JpegEncoder::new_with_quality(File::create(&grey).unwrap(), 95)
330+ .encode_image(&buf)
331+ .unwrap();
332+ let img = decode_jpeg(&grey, &Options::default()).unwrap().unwrap();
333+ assert_eq!((img.size, img.pixels().len()), ([16, 8], 16 * 8 * 4));
334+ let p = &img.pixels()[..4];
335+ assert!(p[0].abs_diff(200) <= 2 && p[0] == p[1] && p[1] == p[2] && p[3] == 255);
336+
337+ let rgb = dir.join("rgb.jpg");
338+ let buf = image::RgbImage::from_pixel(16, 8, image::Rgb([250, 10, 10]));
339+ JpegEncoder::new_with_quality(File::create(&rgb).unwrap(), 95)
340+ .encode_image(&buf)
341+ .unwrap();
342+ let img = decode_file(&rgb, ImageFormat::Jpeg, &Options::default()).unwrap();
343+ let p = &img.pixels()[..4];
344+ assert!(p[0] > 235 && p[1] < 30 && p[2] < 30 && p[3] == 255, "{p:?}");
345+ // The same pixels as the long way round.
346+ let slow = decode_reader(
347+ BufReader::new(File::open(&rgb).unwrap()),
348+ ImageFormat::Jpeg,
349+ &Options::default(),
350+ )
351+ .unwrap();
352+ assert_eq!(img.pixels(), slow.pixels());
353+ }
354+
270355 #[test]
271356 fn multi_page_tiff() {
272357 use tiff::encoder::{TiffEncoder, colortype};
M src/decode/jxl.rs View
@@ -62,6 +62,7 @@ pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> {
6262 frames,
6363 format: "JXL",
6464 source_size: size,
65+ orientation: image::metadata::Orientation::NoTransforms,
6566 refine: None,
6667 })
6768 }
M src/decode/mod.rs View
@@ -1,8 +1,8 @@
11 //! Format sniffing and dispatch to the decoder backends.
22 //!
3−//! Every backend returns straight-alpha RGBA8 in sRGB, upright: ICC/CICP colour is converted,
4−//! float images are tone-mapped and EXIF orientation is applied here, so nothing downstream
5−//! cares where pixels came from.
3+//! Every backend returns straight-alpha RGBA8 in sRGB: ICC/CICP colour is converted and float
4+//! images are tone-mapped here, so nothing downstream cares where pixels came from. EXIF
5+//! orientation is either applied or handed on in [`DecodedImage::orientation`] for the GPU.
66
77 pub mod color;
88 mod external;
@@ -27,6 +27,7 @@ use std::time::Duration;
2727
2828 use anyhow::anyhow;
2929 use image::ImageFormat;
30+use image::metadata::Orientation;
3031
3132 pub use external::set_decoders;
3233
@@ -42,17 +43,20 @@ pub struct Frame {
4243 pub delay: Duration,
4344 }
4445
45−/// Straight-alpha RGBA8 in sRGB, oriented upright, ready to upload.
46+/// Straight-alpha RGBA8 in sRGB, ready to upload.
4647 pub struct DecodedImage {
47− /// Pixel size of every frame.
48+ /// Pixel size of every frame, as stored.
4849 pub size: [u32; 2],
50+ /// How the stored pixels turn upright. The renderer does it when drawing; turning 24 MP
51+ /// on the CPU takes as long as decoding them.
52+ pub orientation: Orientation,
4953 /// At least one.
5054 pub frames: Vec<Frame>,
5155 /// Frames are timed animation rather than pages.
5256 pub animated: bool,
5357 pub format: &'static str,
54− /// The size the view works in: the image's own pixels. Scalable or previewed sources
55− /// (SVG, RAW) may hold fewer or more pixels than this.
58+ /// The size the view works in: the upright image's own pixels. Scalable or previewed
59+ /// sources (SVG, RAW) may hold fewer or more pixels than this.
5660 pub source_size: [u32; 2],
5761 /// A sharper version can be made on demand (SVG re-raster, full RAW develop).
5862 pub refine: Option<Refine>,
@@ -112,9 +116,31 @@ impl DecodedImage {
112116 format,
113117 source_size: size,
114118 refine: None,
119+ orientation: Orientation::NoTransforms,
115120 }
116121 }
117122
123+ /// Stored pixels that still need turning by `o`.
124+ pub fn turned(mut self, o: Orientation) -> Self {
125+ self.orientation = o;
126+ if color::swaps_axes(o) {
127+ self.source_size.swap(0, 1);
128+ }
129+ self
130+ }
131+
132+ /// Every frame turned upright on the CPU.
133+ #[cfg(test)]
134+ pub fn upright(mut self) -> Self {
135+ for f in &mut self.frames {
136+ let (size, pixels) =
137+ color::orient(self.size, std::mem::take(&mut f.pixels), self.orientation);
138+ (self.size, f.pixels) = (size, pixels);
139+ }
140+ self.orientation = Orientation::NoTransforms;
141+ self
142+ }
143+
118144 pub fn bytes(&self) -> usize {
119145 self.frames.iter().map(|f| f.pixels.len()).sum()
120146 }
@@ -334,7 +360,7 @@ fn label(format: ImageFormat) -> &'static str {
334360 }
335361 }
336362
337−/// Decode `path` in full: every frame, colour-converted and upright.
363+/// Decode `path` in full: every frame, colour-converted.
338364 pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> {
339365 let head = head(path)?;
340366 let kind = detect(path, &head).ok_or_else(|| anyhow!("unknown format"))?;
@@ -428,7 +454,9 @@ mod tests {
428454 }
429455
430456 fn load(name: &str) -> DecodedImage {
431− decode(&fixture(name), &Options::default()).unwrap_or_else(|e| panic!("{name}: {e:#}"))
457+ decode(&fixture(name), &Options::default())
458+ .unwrap_or_else(|e| panic!("{name}: {e:#}"))
459+ .upright()
432460 }
433461
434462 fn close(a: &[u8], b: [u8; 3], name: &str, what: &str) {
@@ -487,7 +515,11 @@ mod tests {
487515 ..Options::default()
488516 };
489517 let raw = decode(&fixture("orient-6.jpg"), &opts).unwrap();
490− assert_eq!(raw.size, [12, 16]);
518+ assert_eq!((raw.size, raw.source_size), ([12, 16], [12, 16]));
519+ // Otherwise the pixels stay as stored and the renderer turns them.
520+ let img = decode(&fixture("orient-6.jpg"), &Options::default()).unwrap();
521+ assert_eq!(img.orientation, Orientation::Rotate90);
522+ assert_eq!((img.size, img.source_size), ([12, 16], [16, 12]));
491523 }
492524
493525 #[test]
M src/main.rs View
@@ -195,6 +195,8 @@ fn main() -> anyhow::Result<()> {
195195 app.config_failed(&e);
196196 }
197197 event_loop.run_app(&mut app)?;
198+ // Decode workers are abandoned mid-job from here on.
199+ thumbs::finish_writes();
198200 if cli.print_marked
199201 && let Some(marked) = app.marked_output()
200202 {
A src/render/backend.rs View
@@ -0,0 +1,97 @@
1+//! Remembers which wgpu backend won the adapter search, so the next start initialises only that
2+//! one. Probing Vulkan and GL together costs tens of milliseconds before the first frame.
3+
4+use std::path::PathBuf;
5+
6+/// The backend and adapter of the last full probe.
7+#[derive(Clone, Debug, PartialEq)]
8+pub struct Choice {
9+ pub backend: wgpu::Backend,
10+ pub adapter: String,
11+}
12+
13+impl Choice {
14+ fn parse(text: &str) -> Option<Self> {
15+ let mut lines = text.lines();
16+ let backend = match lines.next()? {
17+ "vulkan" => wgpu::Backend::Vulkan,
18+ "gl" => wgpu::Backend::Gl,
19+ _ => return None,
20+ };
21+ let adapter = lines.next().filter(|a| !a.is_empty())?.to_string();
22+ Some(Self { backend, adapter })
23+ }
24+
25+ fn encode(&self) -> Option<String> {
26+ let backend = match self.backend {
27+ wgpu::Backend::Vulkan => "vulkan",
28+ wgpu::Backend::Gl => "gl",
29+ _ => return None,
30+ };
31+ Some(format!("{backend}\n{}\n", self.adapter))
32+ }
33+}
34+
35+fn path() -> Option<PathBuf> {
36+ Some(dirs::cache_dir()?.join("remage").join("backend"))
37+}
38+
39+/// Whether the environment steers the adapter search; the choice is then neither used nor kept.
40+pub fn steered() -> bool {
41+ ["WGPU_BACKEND", "WGPU_POWER_PREF", "WGPU_ADAPTER_NAME"]
42+ .iter()
43+ .any(|v| std::env::var_os(v).is_some())
44+}
45+
46+pub fn load() -> Option<Choice> {
47+ if steered() {
48+ return None;
49+ }
50+ Choice::parse(&std::fs::read_to_string(path()?).ok()?)
51+}
52+
53+/// Remember `choice` (or forget the old one, for a backend that can't be asked for alone).
54+pub fn store(choice: &Choice) {
55+ let Some(path) = path() else { return };
56+ let Some(text) = choice.encode() else {
57+ let _ = std::fs::remove_file(&path);
58+ return;
59+ };
60+ let write = || -> std::io::Result<()> {
61+ if let Some(dir) = path.parent() {
62+ std::fs::create_dir_all(dir)?;
63+ }
64+ let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
65+ std::fs::write(&tmp, text)?;
66+ std::fs::rename(&tmp, &path)
67+ };
68+ if let Err(e) = write() {
69+ log::debug!("backend cache not written: {e}");
70+ }
71+}
72+
73+#[cfg(test)]
74+mod tests {
75+ use super::*;
76+
77+ #[test]
78+ fn round_trip() {
79+ let c = Choice {
80+ backend: wgpu::Backend::Gl,
81+ adapter: "Mesa Intel(R) Graphics (RPL-S)".into(),
82+ };
83+ assert_eq!(Choice::parse(&c.encode().unwrap()), Some(c));
84+ assert_eq!(
85+ Choice::parse("vulkan\nNVIDIA\n").map(|c| c.backend),
86+ Some(wgpu::Backend::Vulkan)
87+ );
88+ for bad in ["", "gl", "gl\n", "metal\nApple M1\n", "garbage"] {
89+ assert_eq!(Choice::parse(bad), None, "{bad:?}");
90+ }
91+ let other = Choice {
92+ backend: wgpu::Backend::Metal,
93+ adapter: "x".into(),
94+ };
95+ assert_eq!(other.encode(), None);
96+ }
97+}
M src/render/image.rs View
@@ -4,7 +4,7 @@
44 use bytemuck::{Pod, Zeroable};
55 use wgpu::util::DeviceExt;
66
7−use crate::decode::DecodedImage;
7+use crate::decode::{DecodedImage, color};
88 use crate::state::view::Affine;
99
1010 const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
@@ -47,6 +47,8 @@ struct Tile {
4747 pub struct GpuImage {
4848 tiles: Vec<Tile>,
4949 size: [u32; 2],
50+ /// Where the stored pixels land in the upright image (see [`color::orient_uv`]).
51+ orient: ([f32; 4], [f32; 2]),
5052 /// The frame these textures hold (or are being filled with).
5153 frame: usize,
5254 /// Upload progress: the tile being filled and its rows done.
@@ -64,8 +66,9 @@ impl GpuImage {
6466 self.frame
6567 }
6668
67− pub fn size(&self) -> [u32; 2] {
68− self.size
69+ /// Whether these textures can hold `img`'s frames and draw them the right way up.
70+ pub fn fits(&self, img: &DecodedImage) -> bool {
71+ self.size == img.size && self.orient == color::orient_uv(img.orientation)
6972 }
7073 }
7174
@@ -81,6 +84,23 @@ pub fn tiles(size: [u32; 2], max: u32) -> Vec<([u32; 2], [u32; 2])> {
8184 out
8285 }
8386
87+/// `affine` places the upright image; the result places the pixels as stored.
88+fn turned(affine: Affine, (om, ot): ([f32; 4], [f32; 2])) -> Affine {
89+ let m = affine.m;
90+ Affine {
91+ m: [
92+ m[0] * om[0] + m[1] * om[2],
93+ m[0] * om[1] + m[1] * om[3],
94+ m[2] * om[0] + m[3] * om[2],
95+ m[2] * om[1] + m[3] * om[3],
96+ ],
97+ t: [
98+ affine.t[0] + m[0] * ot[0] + m[1] * ot[1],
99+ affine.t[1] + m[2] * ot[0] + m[3] * ot[1],
100+ ],
101+ }
102+}
103+
84104 pub struct ImagePipeline {
85105 pipeline: wgpu::RenderPipeline,
86106 layout: wgpu::BindGroupLayout,
@@ -255,6 +275,7 @@ impl ImagePipeline {
255275 GpuImage {
256276 tiles,
257277 size: img.size,
278+ orient: color::orient_uv(img.orientation),
258279 frame: 0,
259280 tile: 0,
260281 rows_done: 0,
@@ -442,6 +463,7 @@ impl ImagePipeline {
442463 let adjust = style.adjust;
443464 let on = adjust != [1.0, 0.0, 1.0];
444465 let [iw, ih] = [gpu.size[0] as f32, gpu.size[1] as f32];
466+ let affine = turned(affine, gpu.orient);
445467 let m = affine.m;
446468 for tile in &gpu.tiles {
447469 // The tile's corner and extent in the image's 0..1 coordinates.
@@ -477,6 +499,32 @@ impl ImagePipeline {
477499 mod tests {
478500 use super::*;
479501
502+ #[test]
503+ fn stored_pixels_are_placed_upright() {
504+ use image::metadata::Orientation;
505+ // An upright 200×100 image at (10, 20).
506+ let upright = Affine {
507+ m: [200.0, 0.0, 0.0, 100.0],
508+ t: [10.0, 20.0],
509+ };
510+ let at = |a: Affine, u: f32, v: f32| {
511+ [
512+ a.m[0] * u + a.m[1] * v + a.t[0],
513+ a.m[2] * u + a.m[3] * v + a.t[1],
514+ ]
515+ };
516+ assert_eq!(
517+ turned(upright, color::orient_uv(Orientation::NoTransforms)),
518+ upright
519+ );
520+ // Stored on its side, to be turned clockwise: its top-left corner ends up top right
521+ // and its top-right corner bottom right.
522+ let a = turned(upright, color::orient_uv(Orientation::Rotate90));
523+ assert_eq!(at(a, 0.0, 0.0), [210.0, 20.0]);
524+ assert_eq!(at(a, 1.0, 0.0), [210.0, 120.0]);
525+ assert_eq!(at(a, 0.0, 1.0), [10.0, 20.0]);
526+ }
527+
480528 #[test]
481529 fn tiling_covers_the_image() {
482530 assert_eq!(tiles([10, 5], 16), [([0, 0], [10, 5])]);
M src/render/mod.rs View
@@ -1,6 +1,7 @@
11 //! wgpu device and surface setup, and per-frame orchestration.
22
33 pub mod atlas;
4+mod backend;
45 pub mod image;
56 pub mod prims;
67 pub mod text;
@@ -67,25 +68,7 @@ impl Renderer {
6768 window: Arc<Window>,
6869 display: winit::event_loop::OwnedDisplayHandle,
6970 ) -> anyhow::Result<Self> {
70− let mut desc =
71− wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(display));
72− if wgpu::Backends::from_env().is_none() {
73− desc.backends = wgpu::Backends::VULKAN | wgpu::Backends::GL;
74− }
75− let instance = wgpu::Instance::new(desc);
76− let surface = instance
77− .create_surface(window.clone())
78− .context("create surface")?;
79− let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
80− // The integrated GPU is plenty for images and doesn't wake a sleeping dGPU.
81− power_preference:
82− wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::LowPower),
83− compatible_surface: Some(&surface),
84− ..Default::default()
85− }))
86− .context("no suitable GPU adapter")?;
87− let info = adapter.get_info();
88− log::debug!("adapter: {} ({:?})", info.name, info.backend);
71+ let (instance, surface, adapter) = Self::adapter(&window, &display)?;
8972 let limits = wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits());
9073 // `REMAGE_MAX_TEXTURE` lowers the limit, to exercise tiling on any GPU.
9174 let max_dim = std::env::var("REMAGE_MAX_TEXTURE")
@@ -155,6 +138,64 @@ impl Renderer {
155138 })
156139 }
157140
141+ /// Find the adapter. The backend that won last time is tried alone first: initialising
142+ /// Vulkan and GL together is the slowest part of start-up.
143+ fn adapter(
144+ window: &Arc<Window>,
145+ display: &winit::event_loop::OwnedDisplayHandle,
146+ ) -> anyhow::Result<(wgpu::Instance, wgpu::Surface<'static>, wgpu::Adapter)> {
147+ let probe = |backends: Option<wgpu::Backends>| {
148+ let mut desc = wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(
149+ display.clone(),
150+ ));
151+ if let Some(b) = backends {
152+ desc.backends = b;
153+ }
154+ let instance = wgpu::Instance::new(desc);
155+ let surface = instance
156+ .create_surface(window.clone())
157+ .context("create surface")?;
158+ let adapter = pollster::block_on(
159+ instance.request_adapter(&wgpu::RequestAdapterOptions {
160+ // The integrated GPU is plenty for images and doesn't wake a sleeping dGPU.
161+ power_preference: wgpu::PowerPreference::from_env()
162+ .unwrap_or(wgpu::PowerPreference::LowPower),
163+ compatible_surface: Some(&surface),
164+ ..Default::default()
165+ }),
166+ )
167+ .context("no suitable GPU adapter")?;
168+ anyhow::Ok((instance, surface, adapter))
169+ };
170+ let remembered = backend::load();
171+ if let Some(c) = &remembered {
172+ match probe(Some(c.backend.into())) {
173+ Ok(found) if found.2.get_info().name == c.adapter => {
174+ log::debug!("adapter: {} ({:?}, remembered)", c.adapter, c.backend);
175+ return Ok(found);
176+ }
177+ // Another GPU or driver answers now: search again from scratch.
178+ Ok(_) => log::debug!("remembered adapter {} is gone", c.adapter),
179+ Err(e) => log::debug!("remembered backend {:?} failed: {e:#}", c.backend),
180+ }
181+ }
182+ let env = wgpu::Backends::from_env();
183+ let found = probe(
184+ env.is_none()
185+ .then_some(wgpu::Backends::VULKAN | wgpu::Backends::GL),
186+ )?;
187+ let info = found.2.get_info();
188+ log::debug!("adapter: {} ({:?})", info.name, info.backend);
189+ let choice = backend::Choice {
190+ backend: info.backend,
191+ adapter: info.name,
192+ };
193+ if !backend::steered() && remembered.as_ref() != Some(&choice) {
194+ backend::store(&choice);
195+ }
196+ Ok(found)
197+ }
198+
158199 pub fn resize(&mut self, width: u32, height: u32) {
159200 if width == 0 || height == 0 {
160201 return;
@@ -181,13 +222,9 @@ impl Renderer {
181222 }
182223
183224 /// Upload up to `budget` bytes of `img`. Returns true once it is ready to draw.
184− /// A texture of another size (a re-rasterised SVG, a developed RAW) is replaced.
225+ /// A texture of another shape (a re-rasterised SVG, a developed RAW) is replaced.
185226 pub fn upload(&mut self, path: &Path, img: &DecodedImage, budget: usize) -> bool {
186− if self
187− .textures
188− .get(path)
189− .is_some_and(|g| g.size() != img.size)
190− {
227+ if self.textures.get(path).is_some_and(|g| !g.fits(img)) {
191228 self.textures.remove(path);
192229 }
193230 let gpu = match self.textures.get_mut(path) {
@@ -211,7 +248,7 @@ impl Renderer {
211248 let Some(gpu) = self.textures.get_mut(path) else {
212249 return;
213250 };
214− if gpu.size() != img.size {
251+ if !gpu.fits(img) {
215252 return;
216253 }
217254 self.images.restart(gpu, frame);
M src/thumbs.rs View
@@ -8,6 +8,7 @@ use std::fs::{self, File};
88 use std::io::{BufReader, BufWriter, Write};
99 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
1010 use std::path::{Component, Path, PathBuf};
11+use std::sync::RwLock;
1112 use std::time::UNIX_EPOCH;
1213
1314 use anyhow::{Context, anyhow, bail};
@@ -232,6 +233,15 @@ fn percent_decode(s: &str) -> Vec<u8> {
232233 out
233234 }
234235
236+/// Set once the process is on its way out.
237+static WRITES_CLOSED: RwLock<bool> = RwLock::new(false);
238+
239+/// Wait for thumbnails being written and refuse new ones. Workers aren't joined on exit, so
240+/// this is what keeps temporary files from being left in the shared cache.
241+pub fn finish_writes() {
242+ *WRITES_CLOSED.write().unwrap_or_else(|e| e.into_inner()) = true;
243+}
244+
235245 /// Write a PNG atomically (temporary file, then rename) with mode 0600.
236246 fn write_png(path: &Path, thumb: &Thumb, text: &[(&str, String)]) -> anyhow::Result<()> {
237247 let dir = path.parent().context("cache path has no parent")?;
@@ -245,6 +255,11 @@ fn write_png(path: &Path, thumb: &Thumb, text: &[(&str, String)]) -> anyhow::Res
245255 name.to_string_lossy(),
246256 std::process::id()
247257 ));
258+ // Held to the end, so quitting never kills a write half-way (see `finish_writes`).
259+ let closed = WRITES_CLOSED.read().unwrap_or_else(|e| e.into_inner());
260+ if *closed {
261+ bail!("shutting down");
262+ }
248263 let result = (|| -> anyhow::Result<()> {
249264 let file = fs::OpenOptions::new()
250265 .write(true)
@@ -411,13 +426,18 @@ fn generate(path: &Path, max: u32) -> anyhow::Result<Generated> {
411426 let img = decode::decode(path, &opts)?;
412427 let source_size = img.source_size;
413428 let DecodedImage {
414− size, mut frames, ..
429+ size,
430+ mut frames,
431+ orientation,
432+ ..
415433 } = img;
416434 let pixels = frames.swap_remove(0).pixels;
417435 let t = resize(Thumb { size, pixels }, max)?;
436+ // Turning the thumbnail is much cheaper than turning the image.
437+ let (size, pixels) = decode::color::orient(t.size, t.pixels, orientation);
418438 Ok(Generated {
419− size: t.size,
420− pixels: t.pixels,
439+ size,
440+ pixels,
421441 source_size,
422442 })
423443 }
M src/worker.rs View
@@ -109,9 +109,9 @@ impl Shared {
109109 }
110110 }
111111
112+/// The workers run detached: nothing ever waits for them.
112113 pub struct Pool {
113114 shared: Arc<Shared>,
114− threads: Vec<thread::JoinHandle<()>>,
115115 }
116116
117117 impl Pool {
@@ -129,16 +129,14 @@ impl Pool {
129129 queue: Mutex::new(Queue::default()),
130130 cv: Condvar::new(),
131131 });
132− let threads = (0..n)
133− .map(|i| {
134− let shared = shared.clone();
135− thread::Builder::new()
136− .name(format!("remage-worker-{i}"))
137− .spawn(move || worker(&shared))
138− .expect("spawn worker thread")
139− })
140− .collect();
141− Self { shared, threads }
132+ for i in 0..n {
133+ let shared = shared.clone();
134+ thread::Builder::new()
135+ .name(format!("remage-worker-{i}"))
136+ .spawn(move || worker(&shared))
137+ .expect("spawn worker thread");
138+ }
139+ Self { shared }
142140 }
143141
144142 /// Queue `work` under `key` unless that key is already running.
@@ -173,12 +171,11 @@ impl Pool {
173171 }
174172
175173 impl Drop for Pool {
174+ /// Idle workers stop; busy ones are left to it. This only happens on the way out, and
175+ /// quitting must not wait for a decode nobody will see.
176176 fn drop(&mut self) {
177177 self.shared.lock().shutdown = true;
178178 self.shared.cv.notify_all();
179− for t in self.threads.drain(..) {
180− let _ = t.join();
181− }
182179 }
183180 }
184181