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
+562 −92
14 files changed
- M CHANGELOG.md +11 −0
- M Cargo.lock +3 −1
- M Cargo.toml +3 −1
- M src/app.rs +18 −8
- M src/decode/color.rs +143 −17
- M src/decode/image_rs.rs +94 −9
- M src/decode/jxl.rs +1 −0
- M src/decode/mod.rs +42 −10
- M src/main.rs +2 −0
- A src/render/backend.rs +97 −0
- M src/render/image.rs +51 −3
- M src/render/mod.rs +63 −26
- M src/thumbs.rs +23 −3
- M src/worker.rs +11 −14
| @@ -1,5 +1,16 @@ | |||
| 1 | 1 | # Changelog | |
| 2 | 2 | ||
| 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 | + | ||
| 3 | 14 | ## [0.1.0] - 2026-11-05 | |
| 4 | 15 | ||
| 5 | 16 | The first release: a keyboard-centric image viewer for Linux, at nsxiv parity with vim keys, | |
| @@ -3057,7 +3057,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | |||
| 3057 | 3057 | ||
| 3058 | 3058 | [[package]] | |
| 3059 | 3059 | name = "remage" | |
| 3060 | − | version = "0.1.0" | |
| 3060 | + | version = "0.1.1" | |
| 3061 | 3061 | dependencies = [ | |
| 3062 | 3062 | "anyhow", | |
| 3063 | 3063 | "bytemuck", | |
| @@ -3091,6 +3091,8 @@ dependencies = [ | |||
| 3091 | 3091 | "toml 1.2.0+spec-1.1.0", | |
| 3092 | 3092 | "wgpu", | |
| 3093 | 3093 | "winit", | |
| 3094 | + | "zune-core", | |
| 3095 | + | "zune-jpeg", | |
| 3094 | 3096 | ] | |
| 3095 | 3097 | ||
| 3096 | 3098 | [[package]] | |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "remage" | |
| 3 | − | version = "0.1.0" | |
| 3 | + | version = "0.1.1" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | description = "Keyboard-centric image viewer with a Nothing-inspired look" | |
| 6 | 6 | rust-version = "1.96" | |
| @@ -43,6 +43,8 @@ tiff = "0.11.3" | |||
| 43 | 43 | toml = "1.2.0" | |
| 44 | 44 | wgpu = "30" | |
| 45 | 45 | winit = "0.30" | |
| 46 | + | zune-core = "0.5.3" | |
| 47 | + | zune-jpeg = "0.5.15" | |
| 46 | 48 | ||
| 47 | 49 | [features] | |
| 48 | 50 | default = ["jxl", "svg", "avif", "heif", "raw"] | |
| @@ -520,7 +520,6 @@ impl App { | |||
| 520 | 520 | if !self.state.in_grid() { | |
| 521 | 521 | return; | |
| 522 | 522 | } | |
| 523 | − | let Some(g) = &mut self.gfx else { return }; | |
| 524 | 523 | let px = self.state.cell_px(); | |
| 525 | 524 | if px != self.thumbs.cell { | |
| 526 | 525 | for p in self.thumbs.requested.drain().map(|(p, _)| p) { | |
| @@ -528,7 +527,10 @@ impl App { | |||
| 528 | 527 | } | |
| 529 | 528 | self.thumbs.arrivals.clear(); | |
| 530 | 529 | 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 | + | } | |
| 532 | 534 | self.thumbs.cell = px; | |
| 533 | 535 | } | |
| 534 | 536 | let l = self.state.grid_layout(); | |
| @@ -2062,9 +2064,18 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 2062 | 2064 | return; | |
| 2063 | 2065 | } | |
| 2064 | 2066 | }; | |
| 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(); | |
| 2065 | 2075 | let renderer = match Renderer::new(window.clone(), event_loop.owned_display_handle()) { | |
| 2066 | 2076 | Ok(mut r) => { | |
| 2067 | 2077 | r.set_font_families(font_families(&self.config)); | |
| 2078 | + | r.reset_thumbs(self.thumbs.cell); | |
| 2068 | 2079 | r | |
| 2069 | 2080 | } | |
| 2070 | 2081 | Err(e) => { | |
| @@ -2074,16 +2085,15 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 2074 | 2085 | } | |
| 2075 | 2086 | }; | |
| 2076 | 2087 | 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 | − | ); | |
| 2083 | 2088 | self.gfx = Some(Gfx { renderer, window }); | |
| 2084 | 2089 | self.sync(false); | |
| 2085 | 2090 | } | |
| 2086 | 2091 | ||
| 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 | + | ||
| 2087 | 2097 | fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { | |
| 2088 | 2098 | match event { | |
| 2089 | 2099 | UserEvent::Decoded { | |
| @@ -57,7 +57,11 @@ impl ToSrgb { | |||
| 57 | 57 | Layout::Rgba, | |
| 58 | 58 | TransformOptions::default(), | |
| 59 | 59 | ) { | |
| 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 | + | } | |
| 61 | 65 | Err(e) => { | |
| 62 | 66 | log::debug!("colour transform unavailable: {e:?}"); | |
| 63 | 67 | None | |
| @@ -65,13 +69,54 @@ impl ToSrgb { | |||
| 65 | 69 | } | |
| 66 | 70 | } | |
| 67 | 71 | ||
| 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. | |
| 69 | 93 | 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); | |
| 74 | 113 | } | |
| 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 | + | }); | |
| 75 | 120 | } | |
| 76 | 121 | } | |
| 77 | 122 | ||
| @@ -124,11 +169,33 @@ pub fn is_float(img: &DynamicImage) -> bool { | |||
| 124 | 169 | /// Straight RGBA8 from any decoded image, tone-mapping float data. | |
| 125 | 170 | pub fn to_rgba8(img: DynamicImage) -> ([u32; 2], Vec<u8>) { | |
| 126 | 171 | 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]), | |
| 132 | 199 | } | |
| 133 | 200 | } | |
| 134 | 201 | ||
| @@ -150,12 +217,18 @@ pub fn orient(size: [u32; 2], pixels: Vec<u8>, o: Orientation) -> ([u32; 2], Vec | |||
| 150 | 217 | pub fn expand(samples: &[u8], channels: usize) -> Vec<u8> { | |
| 151 | 218 | match channels { | |
| 152 | 219 | 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 | + | } | |
| 159 | 232 | 2 => samples | |
| 160 | 233 | .as_chunks::<2>() | |
| 161 | 234 | .0 | |
| @@ -192,6 +265,55 @@ mod tests { | |||
| 192 | 265 | assert_eq!((size, out.len()), ([2, 1], 8)); | |
| 193 | 266 | } | |
| 194 | 267 | ||
| 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 | + | ||
| 195 | 317 | #[test] | |
| 196 | 318 | fn cicp_and_icc() { | |
| 197 | 319 | assert!(ToSrgb::from_cicp(1, 13).is_none(), "BT.709 is already sRGB"); | |
| @@ -203,5 +325,9 @@ mod tests { | |||
| 203 | 325 | assert!(px[3] == 200); | |
| 204 | 326 | assert!(ToSrgb::from_icc(b"not a profile").is_none()); | |
| 205 | 327 | 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 | + | ); | |
| 206 | 332 | } | |
| 207 | 333 | } | |
| @@ -68,6 +68,13 @@ pub fn decode_file( | |||
| 68 | 68 | if let Some(img) = animated { | |
| 69 | 69 | return Ok(img); | |
| 70 | 70 | } | |
| 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 | + | } | |
| 71 | 78 | let mut img = decode_reader(open()?, format, opts)?; | |
| 72 | 79 | if format == ImageFormat::Tiff { | |
| 73 | 80 | add_tiff_pages(path, &mut img, opts); | |
| @@ -75,7 +82,53 @@ pub fn decode_file( | |||
| 75 | 82 | Ok(img) | |
| 76 | 83 | } | |
| 77 | 84 | ||
| 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. | |
| 79 | 132 | pub fn decode_reader<R: BufRead + Seek>( | |
| 80 | 133 | reader: R, | |
| 81 | 134 | format: ImageFormat, | |
| @@ -101,8 +154,7 @@ pub fn decode_reader<R: BufRead + Seek>( | |||
| 101 | 154 | if !float && let Some(t) = icc.as_deref().and_then(ToSrgb::from_icc) { | |
| 102 | 155 | t.apply(&mut pixels); | |
| 103 | 156 | } | |
| 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)) | |
| 106 | 158 | } | |
| 107 | 159 | ||
| 108 | 160 | /// 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 | |||
| 112 | 164 | } | |
| 113 | 165 | ||
| 114 | 166 | /// 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). | |
| 116 | 168 | fn animation<'a>( | |
| 117 | 169 | decoder: impl AnimationDecoder<'a>, | |
| 118 | 170 | icc: Option<Vec<u8>>, | |
| @@ -155,12 +207,14 @@ fn animation<'a>( | |||
| 155 | 207 | format: label(format), | |
| 156 | 208 | source_size: size, | |
| 157 | 209 | refine: None, | |
| 210 | + | orientation: Orientation::NoTransforms, | |
| 158 | 211 | })) | |
| 159 | 212 | } | |
| 160 | 213 | } | |
| 161 | 214 | } | |
| 162 | 215 | ||
| 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. | |
| 164 | 218 | fn add_tiff_pages(path: &Path, img: &mut DecodedImage, opts: &Options) { | |
| 165 | 219 | let Ok(file) = File::open(path) else { return }; | |
| 166 | 220 | 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) { | |||
| 173 | 227 | } | |
| 174 | 228 | match tiff_page(&mut d) { | |
| 175 | 229 | 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 { | |
| 180 | 232 | log::debug!("TIFF page of another size skipped"); | |
| 181 | 233 | continue; | |
| 182 | 234 | } | |
| @@ -267,6 +319,39 @@ mod tests { | |||
| 267 | 319 | assert_eq!(img.frame(1)[0], 0); | |
| 268 | 320 | } | |
| 269 | 321 | ||
| 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 | + | ||
| 270 | 355 | #[test] | |
| 271 | 356 | fn multi_page_tiff() { | |
| 272 | 357 | use tiff::encoder::{TiffEncoder, colortype}; | |
| @@ -62,6 +62,7 @@ pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> { | |||
| 62 | 62 | frames, | |
| 63 | 63 | format: "JXL", | |
| 64 | 64 | source_size: size, | |
| 65 | + | orientation: image::metadata::Orientation::NoTransforms, | |
| 65 | 66 | refine: None, | |
| 66 | 67 | }) | |
| 67 | 68 | } | |
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | //! Format sniffing and dispatch to the decoder backends. | |
| 2 | 2 | //! | |
| 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. | |
| 6 | 6 | ||
| 7 | 7 | pub mod color; | |
| 8 | 8 | mod external; | |
| @@ -27,6 +27,7 @@ use std::time::Duration; | |||
| 27 | 27 | ||
| 28 | 28 | use anyhow::anyhow; | |
| 29 | 29 | use image::ImageFormat; | |
| 30 | + | use image::metadata::Orientation; | |
| 30 | 31 | ||
| 31 | 32 | pub use external::set_decoders; | |
| 32 | 33 | ||
| @@ -42,17 +43,20 @@ pub struct Frame { | |||
| 42 | 43 | pub delay: Duration, | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | − | /// Straight-alpha RGBA8 in sRGB, oriented upright, ready to upload. | |
| 46 | + | /// Straight-alpha RGBA8 in sRGB, ready to upload. | |
| 46 | 47 | pub struct DecodedImage { | |
| 47 | − | /// Pixel size of every frame. | |
| 48 | + | /// Pixel size of every frame, as stored. | |
| 48 | 49 | 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, | |
| 49 | 53 | /// At least one. | |
| 50 | 54 | pub frames: Vec<Frame>, | |
| 51 | 55 | /// Frames are timed animation rather than pages. | |
| 52 | 56 | pub animated: bool, | |
| 53 | 57 | 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. | |
| 56 | 60 | pub source_size: [u32; 2], | |
| 57 | 61 | /// A sharper version can be made on demand (SVG re-raster, full RAW develop). | |
| 58 | 62 | pub refine: Option<Refine>, | |
| @@ -112,9 +116,31 @@ impl DecodedImage { | |||
| 112 | 116 | format, | |
| 113 | 117 | source_size: size, | |
| 114 | 118 | refine: None, | |
| 119 | + | orientation: Orientation::NoTransforms, | |
| 115 | 120 | } | |
| 116 | 121 | } | |
| 117 | 122 | ||
| 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 | + | ||
| 118 | 144 | pub fn bytes(&self) -> usize { | |
| 119 | 145 | self.frames.iter().map(|f| f.pixels.len()).sum() | |
| 120 | 146 | } | |
| @@ -334,7 +360,7 @@ fn label(format: ImageFormat) -> &'static str { | |||
| 334 | 360 | } | |
| 335 | 361 | } | |
| 336 | 362 | ||
| 337 | − | /// Decode `path` in full: every frame, colour-converted and upright. | |
| 363 | + | /// Decode `path` in full: every frame, colour-converted. | |
| 338 | 364 | pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 339 | 365 | let head = head(path)?; | |
| 340 | 366 | let kind = detect(path, &head).ok_or_else(|| anyhow!("unknown format"))?; | |
| @@ -428,7 +454,9 @@ mod tests { | |||
| 428 | 454 | } | |
| 429 | 455 | ||
| 430 | 456 | 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() | |
| 432 | 460 | } | |
| 433 | 461 | ||
| 434 | 462 | fn close(a: &[u8], b: [u8; 3], name: &str, what: &str) { | |
| @@ -487,7 +515,11 @@ mod tests { | |||
| 487 | 515 | ..Options::default() | |
| 488 | 516 | }; | |
| 489 | 517 | 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])); | |
| 491 | 523 | } | |
| 492 | 524 | ||
| 493 | 525 | #[test] | |
| @@ -195,6 +195,8 @@ fn main() -> anyhow::Result<()> { | |||
| 195 | 195 | app.config_failed(&e); | |
| 196 | 196 | } | |
| 197 | 197 | event_loop.run_app(&mut app)?; | |
| 198 | + | // Decode workers are abandoned mid-job from here on. | |
| 199 | + | thumbs::finish_writes(); | |
| 198 | 200 | if cli.print_marked | |
| 199 | 201 | && let Some(marked) = app.marked_output() | |
| 200 | 202 | { | |
| @@ -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 | + | } | |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | use bytemuck::{Pod, Zeroable}; | |
| 5 | 5 | use wgpu::util::DeviceExt; | |
| 6 | 6 | ||
| 7 | − | use crate::decode::DecodedImage; | |
| 7 | + | use crate::decode::{DecodedImage, color}; | |
| 8 | 8 | use crate::state::view::Affine; | |
| 9 | 9 | ||
| 10 | 10 | const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb; | |
| @@ -47,6 +47,8 @@ struct Tile { | |||
| 47 | 47 | pub struct GpuImage { | |
| 48 | 48 | tiles: Vec<Tile>, | |
| 49 | 49 | size: [u32; 2], | |
| 50 | + | /// Where the stored pixels land in the upright image (see [`color::orient_uv`]). | |
| 51 | + | orient: ([f32; 4], [f32; 2]), | |
| 50 | 52 | /// The frame these textures hold (or are being filled with). | |
| 51 | 53 | frame: usize, | |
| 52 | 54 | /// Upload progress: the tile being filled and its rows done. | |
| @@ -64,8 +66,9 @@ impl GpuImage { | |||
| 64 | 66 | self.frame | |
| 65 | 67 | } | |
| 66 | 68 | ||
| 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) | |
| 69 | 72 | } | |
| 70 | 73 | } | |
| 71 | 74 | ||
| @@ -81,6 +84,23 @@ pub fn tiles(size: [u32; 2], max: u32) -> Vec<([u32; 2], [u32; 2])> { | |||
| 81 | 84 | out | |
| 82 | 85 | } | |
| 83 | 86 | ||
| 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 | + | ||
| 84 | 104 | pub struct ImagePipeline { | |
| 85 | 105 | pipeline: wgpu::RenderPipeline, | |
| 86 | 106 | layout: wgpu::BindGroupLayout, | |
| @@ -255,6 +275,7 @@ impl ImagePipeline { | |||
| 255 | 275 | GpuImage { | |
| 256 | 276 | tiles, | |
| 257 | 277 | size: img.size, | |
| 278 | + | orient: color::orient_uv(img.orientation), | |
| 258 | 279 | frame: 0, | |
| 259 | 280 | tile: 0, | |
| 260 | 281 | rows_done: 0, | |
| @@ -442,6 +463,7 @@ impl ImagePipeline { | |||
| 442 | 463 | let adjust = style.adjust; | |
| 443 | 464 | let on = adjust != [1.0, 0.0, 1.0]; | |
| 444 | 465 | let [iw, ih] = [gpu.size[0] as f32, gpu.size[1] as f32]; | |
| 466 | + | let affine = turned(affine, gpu.orient); | |
| 445 | 467 | let m = affine.m; | |
| 446 | 468 | for tile in &gpu.tiles { | |
| 447 | 469 | // The tile's corner and extent in the image's 0..1 coordinates. | |
| @@ -477,6 +499,32 @@ impl ImagePipeline { | |||
| 477 | 499 | mod tests { | |
| 478 | 500 | use super::*; | |
| 479 | 501 | ||
| 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 | + | ||
| 480 | 528 | #[test] | |
| 481 | 529 | fn tiling_covers_the_image() { | |
| 482 | 530 | assert_eq!(tiles([10, 5], 16), [([0, 0], [10, 5])]); | |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! wgpu device and surface setup, and per-frame orchestration. | |
| 2 | 2 | ||
| 3 | 3 | pub mod atlas; | |
| 4 | + | mod backend; | |
| 4 | 5 | pub mod image; | |
| 5 | 6 | pub mod prims; | |
| 6 | 7 | pub mod text; | |
| @@ -67,25 +68,7 @@ impl Renderer { | |||
| 67 | 68 | window: Arc<Window>, | |
| 68 | 69 | display: winit::event_loop::OwnedDisplayHandle, | |
| 69 | 70 | ) -> 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)?; | |
| 89 | 72 | let limits = wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()); | |
| 90 | 73 | // `REMAGE_MAX_TEXTURE` lowers the limit, to exercise tiling on any GPU. | |
| 91 | 74 | let max_dim = std::env::var("REMAGE_MAX_TEXTURE") | |
| @@ -155,6 +138,64 @@ impl Renderer { | |||
| 155 | 138 | }) | |
| 156 | 139 | } | |
| 157 | 140 | ||
| 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 | + | ||
| 158 | 199 | pub fn resize(&mut self, width: u32, height: u32) { | |
| 159 | 200 | if width == 0 || height == 0 { | |
| 160 | 201 | return; | |
| @@ -181,13 +222,9 @@ impl Renderer { | |||
| 181 | 222 | } | |
| 182 | 223 | ||
| 183 | 224 | /// 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. | |
| 185 | 226 | 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)) { | |
| 191 | 228 | self.textures.remove(path); | |
| 192 | 229 | } | |
| 193 | 230 | let gpu = match self.textures.get_mut(path) { | |
| @@ -211,7 +248,7 @@ impl Renderer { | |||
| 211 | 248 | let Some(gpu) = self.textures.get_mut(path) else { | |
| 212 | 249 | return; | |
| 213 | 250 | }; | |
| 214 | − | if gpu.size() != img.size { | |
| 251 | + | if !gpu.fits(img) { | |
| 215 | 252 | return; | |
| 216 | 253 | } | |
| 217 | 254 | self.images.restart(gpu, frame); | |
| @@ -8,6 +8,7 @@ use std::fs::{self, File}; | |||
| 8 | 8 | use std::io::{BufReader, BufWriter, Write}; | |
| 9 | 9 | use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; | |
| 10 | 10 | use std::path::{Component, Path, PathBuf}; | |
| 11 | + | use std::sync::RwLock; | |
| 11 | 12 | use std::time::UNIX_EPOCH; | |
| 12 | 13 | ||
| 13 | 14 | use anyhow::{Context, anyhow, bail}; | |
| @@ -232,6 +233,15 @@ fn percent_decode(s: &str) -> Vec<u8> { | |||
| 232 | 233 | out | |
| 233 | 234 | } | |
| 234 | 235 | ||
| 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 | + | ||
| 235 | 245 | /// Write a PNG atomically (temporary file, then rename) with mode 0600. | |
| 236 | 246 | fn write_png(path: &Path, thumb: &Thumb, text: &[(&str, String)]) -> anyhow::Result<()> { | |
| 237 | 247 | 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 | |||
| 245 | 255 | name.to_string_lossy(), | |
| 246 | 256 | std::process::id() | |
| 247 | 257 | )); | |
| 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 | + | } | |
| 248 | 263 | let result = (|| -> anyhow::Result<()> { | |
| 249 | 264 | let file = fs::OpenOptions::new() | |
| 250 | 265 | .write(true) | |
| @@ -411,13 +426,18 @@ fn generate(path: &Path, max: u32) -> anyhow::Result<Generated> { | |||
| 411 | 426 | let img = decode::decode(path, &opts)?; | |
| 412 | 427 | let source_size = img.source_size; | |
| 413 | 428 | let DecodedImage { | |
| 414 | − | size, mut frames, .. | |
| 429 | + | size, | |
| 430 | + | mut frames, | |
| 431 | + | orientation, | |
| 432 | + | .. | |
| 415 | 433 | } = img; | |
| 416 | 434 | let pixels = frames.swap_remove(0).pixels; | |
| 417 | 435 | 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); | |
| 418 | 438 | Ok(Generated { | |
| 419 | − | size: t.size, | |
| 420 | − | pixels: t.pixels, | |
| 439 | + | size, | |
| 440 | + | pixels, | |
| 421 | 441 | source_size, | |
| 422 | 442 | }) | |
| 423 | 443 | } | |
| @@ -109,9 +109,9 @@ impl Shared { | |||
| 109 | 109 | } | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | + | /// The workers run detached: nothing ever waits for them. | |
| 112 | 113 | pub struct Pool { | |
| 113 | 114 | shared: Arc<Shared>, | |
| 114 | − | threads: Vec<thread::JoinHandle<()>>, | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 117 | 117 | impl Pool { | |
| @@ -129,16 +129,14 @@ impl Pool { | |||
| 129 | 129 | queue: Mutex::new(Queue::default()), | |
| 130 | 130 | cv: Condvar::new(), | |
| 131 | 131 | }); | |
| 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 } | |
| 142 | 140 | } | |
| 143 | 141 | ||
| 144 | 142 | /// Queue `work` under `key` unless that key is already running. | |
| @@ -173,12 +171,11 @@ impl Pool { | |||
| 173 | 171 | } | |
| 174 | 172 | ||
| 175 | 173 | 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. | |
| 176 | 176 | fn drop(&mut self) { | |
| 177 | 177 | self.shared.lock().shutdown = true; | |
| 178 | 178 | self.shared.cv.notify_all(); | |
| 179 | − | for t in self.threads.drain(..) { | |
| 180 | − | let _ = t.join(); | |
| 181 | − | } | |
| 182 | 179 | } | |
| 183 | 180 | } | |
| 184 | 181 | ||