remagee3295f9
Add formats and fidelity: JXL, AVIF, HEIF, SVG, RAW, external decoders
ICC/CICP colour to sRGB, HDR tone-mapping, EXIF orientation, animation playback, multi-page TIFF, tiling above the texture limit, and SVG re-rasterising / RAW full develop on zoom. Adds decoder fixtures. Stops tracking CLAUDE.md.
+1929 −708
47 files changed
- D CLAUDE.md +0 −525
- M Cargo.lock +2 −0
- M Cargo.toml +2 −1
- M src/app.rs +134 −37
- M src/cache.rs +1 −6
- M src/decode/color.rs +200 −1
- A src/decode/external.rs +155 −0
- A src/decode/heif.rs +42 −0
- A src/decode/image_rs.rs +297 −0
- A src/decode/jxl.rs +67 −0
- M src/decode/mod.rs +455 −71
- A src/decode/raw.rs +69 −0
- A src/decode/svg.rs +104 −0
- M src/main.rs +1 −0
- M src/render/image.rs +154 −54
- M src/render/mod.rs +35 −6
- M src/state/mod.rs +131 −5
- M src/thumbs.rs +10 −2
- M src/ui/mod.rs +8 −0
- M src/worker.rs +1 −0
- A tests/fixtures/anim.gif
- A tests/fixtures/anim.jxl
- A tests/fixtures/anim.png
- A tests/fixtures/anim.webp
- A tests/fixtures/drawing.svg +6 −0
- A tests/fixtures/drawing.svgz
- A tests/fixtures/make.sh +55 −0
- A tests/fixtures/orient-1.jpg
- A tests/fixtures/orient-2.jpg
- A tests/fixtures/orient-3.jpg
- A tests/fixtures/orient-4.jpg
- A tests/fixtures/orient-5.jpg
- A tests/fixtures/orient-6.jpg
- A tests/fixtures/orient-7.jpg
- A tests/fixtures/orient-8.jpg
- A tests/fixtures/pages.tif
- A tests/fixtures/quadrants.avif
- A tests/fixtures/quadrants.bmp
- A tests/fixtures/quadrants.exr
- A tests/fixtures/quadrants.heic
- A tests/fixtures/quadrants.jpg
- A tests/fixtures/quadrants.jxl
- A tests/fixtures/quadrants.png
- A tests/fixtures/quadrants.ppm
- A tests/fixtures/quadrants.qoi
- A tests/fixtures/quadrants.tga
- A tests/fixtures/quadrants.webp
| @@ -1,525 +0,0 @@ | |||
| 1 | − | # remage | |
| 2 | − | ||
| 3 | − | A keyboard-centric image viewer for Linux, written in Rust. It aims for nsxiv feature parity with | |
| 4 | − | vim-style control and the Nothing OS design language. It is GPU-rendered so it feels smooth, and it | |
| 5 | − | can be themed from the wallpaper (matugen or pywal). Scripting works through declarative commands | |
| 6 | − | plus an IPC socket. The binary and crate are both `remage`, and the Wayland app_id / X11 class is | |
| 7 | − | `remage`. | |
| 8 | − | ||
| 9 | − | ## Principles (in priority order) | |
| 10 | − | ||
| 11 | − | 1. **Fast.** Cold start to the first painted image takes < 200 ms. `n`/`p` to a preloaded image | |
| 12 | − | takes one frame. CPU use is 0% when idle: render on demand only, with no continuous loop. | |
| 13 | − | 2. **Smooth but light.** Draw with wgpu and use short ease-out animations. No GUI toolkit and no | |
| 14 | − | embedded interpreter. Animation runs only while something moves. | |
| 15 | − | 3. **Keyboard first.** Every feature is an *action*. Every action can be bound to a key and is | |
| 16 | − | reachable from the `:` palette and over IPC. The mouse works but is secondary. | |
| 17 | − | 4. **Everything configurable** in TOML, with live reload. | |
| 18 | − | 5. **Never block the UI thread** on I/O, decoding or scripts. A broken image or config never panics. | |
| 19 | − | The error shows in the status bar instead. | |
| 20 | − | ||
| 21 | − | ## Tech stack | |
| 22 | − | ||
| 23 | − | Use the latest stable crate versions at implementation time (`cargo add`) and never guess version | |
| 24 | − | numbers. Check APIs on docs.rs, especially winit, wgpu and glyphon, which break between releases | |
| 25 | − | and must stay mutually compatible. | |
| 26 | − | ||
| 27 | − | | Concern | Choice | | |
| 28 | − | |---|---| | |
| 29 | − | | Window / input | `winit` (ApplicationHandler API), Wayland + X11, no decorations by default | | |
| 30 | − | | GPU | `wgpu`: Vulkan + GL backends, `PowerPreference::LowPower` (an iGPU is plenty, and waking a sleeping laptop dGPU costs ~2 s; `WGPU_POWER_PREF`/`WGPU_BACKEND` override). `PresentMode::Fifo`, `desired_maximum_frame_latency = 1`, call `pre_present_notify()` before present | | |
| 31 | − | | Text | `glyphon` (cosmic-text) with embedded fonts | | |
| 32 | − | | Decoding | `image` (png/apng, jpeg, gif, webp, tiff, bmp, ico, tga, pnm, qoi, dds, hdr, exr), `jxl-oxide`, `image/avif-native` (dav1d), `libheif-rs`, `resvg`, `rawler` | | |
| 33 | − | | Colour | `moxcms` (ICC → sRGB, CICP); EXIF orientation from `image` / `kamadak-exif` | | |
| 34 | − | | Thumbnails | `fast_image_resize` (SIMD), `png` + `md-5` for the freedesktop cache | | |
| 35 | − | | Config | `serde` + `toml` (spans give `file:line:col` errors) | | |
| 36 | − | | Fuzzy matching | `nucleo-matcher` | | |
| 37 | − | | Watching / signals | `notify` (config, includes, directories), `signal-hook` (SIGUSR1 = reload) | | |
| 38 | − | | CLI | `clap` derive + `clap_complete` + `clap_mangen` | | |
| 39 | − | | Concurrency | std threads + `crossbeam-channel`; workers wake the loop via `EventLoopProxy` | | |
| 40 | − | | IPC | std `UnixListener` + `serde_json` | | |
| 41 | − | | Errors / logs | `anyhow`, `thiserror`, `log` + `env_logger` (`RUST_LOG=remage=debug`) | | |
| 42 | − | ||
| 43 | − | **Locked pairings (M0, 2026-11):** glyphon 0.12 ↔ wgpu 30 (cosmic-text 0.19). winit stays on | |
| 44 | − | 0.30 until 0.31 leaves beta. Bump glyphon and wgpu together, never one alone. Crate still to add | |
| 45 | − | when its milestone needs it: `clap_mangen` (M7). | |
| 46 | − | ||
| 47 | − | Cargo features are `default = ["jxl", "svg", "avif", "heif", "raw"]`. The only C dependencies | |
| 48 | − | (dav1d, libheif) sit behind `avif` and `heif`. | |
| 49 | − | ||
| 50 | − | Build profiles: | |
| 51 | − | - Release: `lto = "thin"`, `codegen-units = 1`. | |
| 52 | − | - Dev: `[profile.dev.package."*"] opt-level = 3`. Unoptimised decoders are unusably slow. | |
| 53 | − | ||
| 54 | − | Rust 2024 edition, stable toolchain. | |
| 55 | − | ||
| 56 | − | ## Architecture | |
| 57 | − | ||
| 58 | − | ``` | |
| 59 | − | src/ | |
| 60 | − | main.rs CLI → App::run; `remage msg …` client path | |
| 61 | − | cli.rs clap definitions | |
| 62 | − | app.rs winit ApplicationHandler: routes events, owns State + Renderer | |
| 63 | − | action.rs Action enum + parser/Display (one grammar for keys, palette, IPC, scripts) | |
| 64 | − | scan.rs CLI paths → file list (siblings, recursion, hidden files, unknown extensions to sniff) | |
| 65 | − | state/ PURE logic, no winit/wgpu types → unit-testable | |
| 66 | − | mod.rs AppState: mode, pending input, status line | |
| 67 | − | queue.rs file list, index, marks, sort, filter, refresh | |
| 68 | − | view.rs zoom/pan/fit/rotate/flip maths, animation targets | |
| 69 | − | grid.rs grid layout, cursor, scroll, visual range | |
| 70 | − | input/ | |
| 71 | − | keys.rs vim key-notation parser (<C-x>, <S-Tab>, gg, <Space>w, mouse) | |
| 72 | − | keymap.rs per-mode trie, pending sequences, counts, which-key candidates | |
| 73 | − | config/ | |
| 74 | − | mod.rs schema, compiled-in defaults, include merge, validation, live reload | |
| 75 | − | theme.rs tokens, presets, palette derivation, pywal import | |
| 76 | − | decode/ | |
| 77 | − | mod.rs sniff (magic bytes, then extension) → backend → DecodedImage{frames, icc, orientation, meta} | |
| 78 | − | *.rs image_rs, jxl, svg, heif, raw, external | |
| 79 | − | color.rs ICC/CICP → sRGB, orientation, HDR tone-map | |
| 80 | − | worker.rs priority job pool with epoch cancellation | |
| 81 | − | cache.rs decoded-image LRU (byte budget) + preload policy | |
| 82 | − | thumbs.rs freedesktop thumbnail cache read/write + generation | |
| 83 | − | render/ | |
| 84 | − | mod.rs device/surface setup, frame orchestration | |
| 85 | − | image.rs textures, tiling above max_texture_dimension_2d, mipmaps, filters | |
| 86 | − | prims.rs one instanced SDF shader: rects, rounded rects, borders, dots | |
| 87 | − | text.rs glyphon + embedded fonts | |
| 88 | − | shaders/*.wgsl image (gamma/brightness/contrast, alpha dots), prims | |
| 89 | − | ui/ statusbar, grid view, palette, which-key, help, info panel | |
| 90 | − | anim.rs retargetable ease-out tweens; requests redraws only while active | |
| 91 | − | exec.rs user commands: template expansion, spawn, confirm, output handling | |
| 92 | − | ipc.rs socket server, JSON protocol, event subscriptions | |
| 93 | − | assets/fonts/ SpaceGrotesk, SpaceMono, Doto + OFL licences | |
| 94 | − | contrib/ config.example.toml, matugen/, pywal/, scripts/, remage.desktop, PKGBUILD | |
| 95 | − | tests/fixtures/ tiny images per format, EXIF orientations 1–8, ICC-tagged, animated | |
| 96 | − | ``` | |
| 97 | − | ||
| 98 | − | **Data flow.** Input goes through the keymap, which produces an `Action`. `State::apply` turns the | |
| 99 | − | action into effects: jobs, spawns, redraw. Workers send `UserEvent`s back through the proxy, and | |
| 100 | − | those update the state and renderer. The renderer reads state and never owns logic. | |
| 101 | − | ||
| 102 | − | **Threads.** | |
| 103 | − | - **Main thread:** event loop, state, rendering and GPU uploads. Uploads are budgeted per frame to | |
| 104 | − | about 32 MB so large textures never cause a hitch. | |
| 105 | − | - **Decode pool:** `nproc − 1` workers, ordered by priority: | |
| 106 | − | - P0: current image | |
| 107 | − | - P1: neighbours (preload) | |
| 108 | − | - P2: visible thumbnails | |
| 109 | − | - P3: other thumbnails | |
| 110 | − | ||
| 111 | − | Every job carries an epoch, so mashing `n` never decodes images that were skipped. | |
| 112 | − | - **Filesystem thread:** directory listing, sniffing and the notify watcher. | |
| 113 | − | - **IPC thread:** accepts socket connections and forwards actions through the proxy. | |
| 114 | − | - **Child processes:** run async. Their exit status and stdout come back through the proxy. | |
| 115 | − | ||
| 116 | − | **Startup.** | |
| 117 | − | 1. Parse the CLI and config. | |
| 118 | − | 2. Start decoding the first image right away. | |
| 119 | − | 3. In parallel, create the window hidden and initialise wgpu. | |
| 120 | − | 4. Show the window on the first finished frame, so it never flashes empty. | |
| 121 | − | ||
| 122 | − | The directory listing continues in the background; only the first image is awaited. | |
| 123 | − | ||
| 124 | − | ## Feature spec | |
| 125 | − | ||
| 126 | − | ### Queue | |
| 127 | − | - `remage` with no arguments is the same as `remage .`. A directory argument loads its images, | |
| 128 | − | non-recursively unless `-r` is given. A single file argument loads its siblings and starts on that | |
| 129 | − | file (`general.open_siblings`, or `--no-siblings` to turn it off). | |
| 130 | − | - Known extensions are accepted instantly. Unknown ones are sniffed in the background. Undecodable | |
| 131 | − | files drop out of the queue, and the status line reports `[SKIPPED n]`. | |
| 132 | − | - Sorting is `natural` (the default), `name`, `mtime`, `size` or `random`, plus `reverse`. With | |
| 133 | − | `general.watch_dir`, files that appear or disappear on disk are added or removed live. | |
| 134 | − | - `-i` reads paths from stdin (`-0` for NUL-separated). `-o` prints marked files on exit. | |
| 135 | − | ||
| 136 | − | ### Modes | |
| 137 | − | - **Image:** shows a single image. | |
| 138 | − | - **Grid:** nsxiv's thumbnail mode, the "menu mode" from doc.md. `<CR>` toggles between image and | |
| 139 | − | grid. **Visual** is a sub-mode of grid: press `v`, move to extend a range, then `m` marks it. | |
| 140 | − | - **Prompts:** | |
| 141 | − | - `:` opens the command palette, which fuzzy-matches all actions and user commands and shows their | |
| 142 | − | bound keys. | |
| 143 | − | - `/` opens fuzzy filename search. It filters the grid live, and `<CR>` jumps to the selected file. | |
| 144 | − | - The confirm prompt asks inline (`DELETE 3 FILES? Y / N`). | |
| 145 | − | - **Overlays:** | |
| 146 | − | - Which-key appears after a pending prefix has waited `ui.which_key_delay_ms` (300 ms). | |
| 147 | − | - `?` shows help, generated from the live keymap. | |
| 148 | − | - `i` shows the info panel with EXIF and metadata. | |
| 149 | − | ||
| 150 | − | ### Image view | |
| 151 | − | - **Fit modes:** `down` (the default, as in nsxiv), `fit`, `fill`, `width`, `height` and | |
| 152 | − | `original`. | |
| 153 | − | - **Zoom:** happens around the cursor for the mouse and around the centre for keys. Zoom | |
| 154 | − | interpolates in log space with the anchor held fixed. | |
| 155 | − | - **Panning:** `hjkl` pans and `HJKL` jumps to an edge. A held key pans at constant velocity from | |
| 156 | − | key-down to key-up, never in key-repeat steps. | |
| 157 | − | - **Rotate and flip:** 90° rotation and h/v flips change only the view, never the file. EXIF | |
| 158 | − | orientation is applied automatically. | |
| 159 | − | - **Filtering:** linear with mipmaps when shrinking. Above 300% it switches to nearest automatically | |
| 160 | − | for pixel art, and `a` toggles it by hand. | |
| 161 | − | - **Adjustments:** gamma, brightness and contrast are shader uniforms. | |
| 162 | − | - **Transparency background:** `dots` (the Nothing dot-matrix grid, default), `checker` or `solid`. | |
| 163 | − | - **Animation and pages:** GIF, APNG, WebP and JXL animations play with their own timing. | |
| 164 | − | `<C-Space>` pauses and `<C-n>`/`<C-p>` step frames. Multi-page TIFFs step the same way. | |
| 165 | − | - **Slideshow:** `s` toggles it, with a configurable delay. | |
| 166 | − | - **Huge images and SVG:** images larger than `max_texture_dimension_2d` are tiled. SVGs are | |
| 167 | − | re-rasterised at the current zoom (debounced) so they stay sharp. | |
| 168 | − | - **Preload and cache:** the `image.preload` images on each side are decoded *and* uploaded to the | |
| 169 | − | GPU. Decoded images live in an LRU capped by `image.cache_mb`. | |
| 170 | − | - **Image switching:** a 100 ms crossfade, or none. `[LOADING]` appears only if loading takes more | |
| 171 | − | than 120 ms, so it never flickers. | |
| 172 | − | ||
| 173 | − | ### Grid | |
| 174 | − | - **Thumbnail cache:** uses the freedesktop thumbnail cache in `$XDG_CACHE_HOME/thumbnails/`, with | |
| 175 | − | `normal` 128, `large` 256, `x-large` 512 and `xx-large` 1024. | |
| 176 | − | - Each entry is named by the MD5 of the file URI and validated by `Thumb::URI` + `Thumb::MTime`. | |
| 177 | − | - remage picks the size bucket ≥ thumb size × scale factor. | |
| 178 | − | - Writes are atomic (tmp + rename) with mode 0600, and failures go in `fail/remage/`. | |
| 179 | − | - `-p` disables writing. Because the cache is shared with file managers, thumbnails are often | |
| 180 | − | already there. | |
| 181 | − | - URIs are escaped exactly as GLib's `g_filename_to_uri` escapes them (`!$&'()*+,;=:@` stay | |
| 182 | − | literal, hex is uppercase), or the names won't match other tools' entries. A bare path in | |
| 183 | − | `Thumb::URI` (ffmpegthumbnailer writes those) also counts as valid. | |
| 184 | − | - A `fail/remage/` entry is trusted only if its `Software` is this remage version, so formats a | |
| 185 | − | newer version can decode aren't hidden forever. | |
| 186 | − | - Images no bigger than the bucket aren't written: re-decoding them costs about as much. | |
| 187 | − | - **Fast paths:** JPEG uses a DCT-scaled decode (`jpeg-decoder` `Decoder::scale`). RAW uses the | |
| 188 | − | embedded preview. Then `fast_image_resize` finishes the job (Lanczos, premultiplied alpha). | |
| 189 | − | - **Generation and storage:** | |
| 190 | − | - Visible thumbnails are P2. A page above and two pages below are P3. Requests that scroll out of | |
| 191 | − | that range are cancelled (`Pool::cancel`). | |
| 192 | − | - On the GPU, thumbnails live in 2048² atlas pages at 1:1 pixels, each with a 1 px clear border. | |
| 193 | − | The least recently drawn thumbnail outside the prefetch range is evicted first; if every one is | |
| 194 | − | in range, the atlas grows a page. A new grid size or scale factor rebuilds the atlas. | |
| 195 | − | - Thumbnails fade in over 150 ms. A thumbnail that fails drops the file from the queue (counted in | |
| 196 | − | `[SKIPPED n]`), as in image mode. | |
| 197 | − | - **Cursor:** the grid cursor *is* the queue's current image, so `n`/`p`/`gg`/`G` move it too. The | |
| 198 | − | full image decodes only once the cursor has rested 250 ms (`GRID_REST`). That way `<CR>` opens it | |
| 199 | − | at once without decoding everything the cursor passes over. | |
| 200 | − | - **Navigation:** `hjkl`, `gg`/`G`/`[count]G`, `<C-d>`/`<C-u>`, `<C-f>`/`<C-b>` and `0`/`$`. | |
| 201 | − | Scrolling animates smoothly, and `+`/`-` steps through `grid.sizes`. The wheel scrolls by rows, | |
| 202 | − | pixel-exact on touchpads. | |
| 203 | − | - **Visual mode:** `v` anchors a range and movement extends it. `m` (`mark on`) marks it and returns | |
| 204 | − | to the grid; `mark` toggles, unmarking a range that is all marked. `v` or `<Esc>` leaves. The range | |
| 205 | − | sits on `surface_raised`, and the bar shows `[VISUAL n]`. | |
| 206 | − | ||
| 207 | − | ### Marks | |
| 208 | − | - `m` toggles a mark, `M` inverts all marks and `u` unmarks everything. | |
| 209 | − | - `N`/`P` jump to the next/previous marked image. | |
| 210 | − | - Marks are the default target of user commands, and `-o` prints them on exit. | |
| 211 | − | - Marks are kept by path, so sorting and reversing keep them. | |
| 212 | − | - In the status bar, an accent dot plus the count shows the marks. The dot is filled when the | |
| 213 | − | current image is marked and a ring when it isn't. | |
| 214 | − | ||
| 215 | − | ### Input | |
| 216 | − | - **Key notation** follows vim: `n`, `G`, `<C-x>`, `<S-Tab>`, `<Space>`, `<BS>`, `<Esc>`, `<CR>`, | |
| 217 | − | `<Left>`, `<F5>`. Sequences work too, such as `gg` and `<Space>w`. | |
| 218 | − | - **Mouse notation:** `<ScrollUp>`, `<C-ScrollUp>`, `<LeftMouse>`, `<RightMouse>`, `<MiddleMouse>`. | |
| 219 | − | Drag-to-pan is built in. | |
| 220 | − | - **Tables:** `[keys.global]`, `[keys.image]`, `[keys.grid]` and `[keys.visual]`. A mode's table | |
| 221 | − | overrides `global`. | |
| 222 | − | - **Binding values:** an action string, an array of actions (run in order, with `run` awaited before | |
| 223 | − | the next one), or `"none"` to remove a default binding. | |
| 224 | − | - **Counts:** a digit prefix becomes the count (`5n`, `12G`, `3+`). `0` acts as a motion when no | |
| 225 | − | count is pending. A pending prefix or count shows in the status bar. | |
| 226 | − | ||
| 227 | − | ### Actions | |
| 228 | − | ||
| 229 | − | Actions share one grammar, `name [args]`, used by keys, the palette, IPC and command stdout. Each | |
| 230 | − | action carries a one-line description, which feeds the palette, which-key and help. | |
| 231 | − | ||
| 232 | − | | Group | Actions | | |
| 233 | − | |---|---| | |
| 234 | − | | Navigation | `next [n]`, `prev [n]`, `first`, `last`, `goto N`, `random`, `next-mark`, `prev-mark`, `next-frame`, `prev-frame` | | |
| 235 | − | | Marks | `mark [toggle\|on\|off]`, `mark-all`, `unmark-all`, `invert-marks` | | |
| 236 | − | | Grid | `move <dir> [n]`, `page down\|up\|half-down\|half-up`, `row-start`, `row-end`, `scroll <dir> [n]` (pans a step in image mode) | | |
| 237 | − | | View | `zoom in\|out\|N%`, `fit <mode>`, `pan <dir>`, `pan-edge <dir>`, `center`, `rotate cw\|ccw\|180`, `flip h\|v`, `filter toggle`, `alpha-bg cycle`, `gamma\|brightness\|contrast +\|-\|reset` | | |
| 238 | − | | Playback | `play toggle`, `slideshow toggle`, `slideshow-delay ±N` | | |
| 239 | − | | UI | `mode image\|grid\|toggle`, `visual`, `fullscreen`, `bar toggle`, `info toggle`, `help`, `palette`, `search`, `grid-size +\|-` | | |
| 240 | − | | Queue | `sort <key>`, `reverse`, `filter <pattern>`, `filter-clear`, `remove`, `reload`, `refresh`, `open <path>` | | |
| 241 | − | | App | `run <command>`, `reload-config`, `echo <msg>`, `quit`, `quit-no-output` | | |
| 242 | − | ||
| 243 | − | ### Commands and scripting (replaces nsxiv's key-handler) | |
| 244 | − | ||
| 245 | − | ```toml | |
| 246 | − | [commands.wallpaper] | |
| 247 | − | desc = "Set as wallpaper" | |
| 248 | − | run = ["setwall", "-p", "{file}"] | |
| 249 | − | ||
| 250 | − | [commands.copy] | |
| 251 | − | desc = "Copy image" | |
| 252 | − | run = "wl-copy --type image/png < {file}" # string = sh -c, placeholders shell-quoted | |
| 253 | − | ||
| 254 | − | [commands.delete] | |
| 255 | − | desc = "Delete files" | |
| 256 | − | run = ["rm", "--", "{files}"] # array = exec directly, {files} → N args | |
| 257 | − | confirm = "Delete {count} file(s)?" | |
| 258 | − | ||
| 259 | − | [commands.legacy] # an old nsxiv key-handler works unchanged | |
| 260 | − | run = ["~/.config/nsxiv/exec/key-handler", "w"] | |
| 261 | − | stdin = "files" | |
| 262 | − | ||
| 263 | − | [keys.global] | |
| 264 | − | "<Space>w" = "run wallpaper" | |
| 265 | − | "<Space>c" = "run copy" | |
| 266 | − | "<Space>d" = "run delete" | |
| 267 | − | ``` | |
| 268 | − | ||
| 269 | − | **Command fields:** | |
| 270 | − | - **Placeholders:** `{file} {files} {dir} {name} {stem} {ext} {index} {count} {width} {height}`. | |
| 271 | − | `{file}`, `{dir}`, `{name}`, `{stem}`, `{ext}`, `{index}`, `{width}` and `{height}` describe the | |
| 272 | − | current image; `{files}` and `{count}` (and `REMAGE_COUNT`) describe the targets. Only these | |
| 273 | − | names are substituted, so `awk '{print $1}'` survives. In an exec array, an element containing | |
| 274 | − | `{files}` repeats once per target (`--in={files}` → `--in=a --in=b`), and a leading `~/` in the | |
| 275 | − | template expands. In a shell string every value is shell-quoted, so don't quote placeholders. | |
| 276 | − | A command that mentions file placeholders (or reads stdin) refuses to run with no targets. | |
| 277 | − | - **`targets`:** `auto` (marked files if any, otherwise the current one; the default), `current`, | |
| 278 | − | `marked` or `all`. | |
| 279 | − | - **`stdin`:** `none`, `files` (newline-separated) or `files0` (NUL-separated). | |
| 280 | − | - **Environment:** `REMAGE_FILE`, `REMAGE_INDEX`, `REMAGE_COUNT`, `REMAGE_MODE`, `REMAGE_SOCKET`, | |
| 281 | − | `REMAGE_PID`. | |
| 282 | − | - **`confirm`:** turns the command into an inline y/n prompt. remage has no modal dialogs. `y` | |
| 283 | − | runs it and any other key (or `cancel`) drops it. The prompt shows even with the bar hidden. | |
| 284 | − | - **`output`:** | |
| 285 | − | - `status` (default): the last stdout line goes to the status line. | |
| 286 | − | - `actions`: every stdout line is run as an action. | |
| 287 | − | - `ignore`. | |
| 288 | − | - **Running:** commands always run async. The status bar shows `[RUNNING WALLPAPER]`, then `[DONE]` | |
| 289 | − | or `[ERROR: <code> <first stderr line>]`. | |
| 290 | − | - **`after`:** actions to run on success. The default is `refresh`, which re-stats the targets, | |
| 291 | − | drops missing files and re-decodes changed ones. That way delete and rotate scripts work with no | |
| 292 | − | extra setup. "Changed" means the mtime differs from the one recorded when the file was decoded | |
| 293 | − | or thumbnailed. A bare `refresh` (key, IPC) re-checks the whole queue. | |
| 294 | − | - **Chains:** in a binding array, the actions after a `run` (and the command's `after`) wait for | |
| 295 | − | it to exit successfully; a failure or a declined prompt drops the rest. | |
| 296 | − | - **Unbound commands** still appear in the `:` palette. | |
| 297 | − | ||
| 298 | − | **IPC socket.** It lives at `$XDG_RUNTIME_DIR/remage/<pid>.sock` and speaks newline-delimited JSON. | |
| 299 | − | - **Requests:** | |
| 300 | − | - `{"action":"goto 5"}` | |
| 301 | − | - `{"query":"current"|"marked"|"queue"|"state"}` | |
| 302 | − | - `{"subscribe":["image","marks","mode"]}`, which turns the connection into an event stream. | |
| 303 | − | - **Replies:** `{"ok":true,"data":…}` or `{"ok":false,"error":"…"}`. | |
| 304 | − | - **Client:** `remage msg <action>`, `remage msg --query marked` and `remage msg --subscribe image`. | |
| 305 | − | It targets `$REMAGE_SOCKET`, otherwise the newest instance; `--pid` picks one explicitly. | |
| 306 | − | Strings and string lists print one per line, anything else as JSON; errors go to stderr with | |
| 307 | − | exit 1. Subscriptions print one JSON event per line. | |
| 308 | − | - **Details:** `"action"` also takes an array. Events are `image` (`path`, 1-based `index`, | |
| 309 | − | `count`), `marks` (`count`, `files`) and `mode`. The socket is 0600 in a 0700 directory, | |
| 310 | − | sockets of dead pids are cleaned up at startup, and the socket is removed on exit once any | |
| 311 | − | in-flight reply (to a `quit`) has gone out. | |
| 312 | − | - **Uses:** Hyprland binds, long-running watchers and other tools driving the viewer. | |
| 313 | − | ||
| 314 | − | ### Config | |
| 315 | − | - **Location:** `$XDG_CONFIG_HOME/remage/config.toml`, falling back to `~/.config/remage/`. `-c` | |
| 316 | − | overrides it. | |
| 317 | − | - **Defaults:** every option has a compiled-in default, and the user file deep-merges over them, so a | |
| 318 | − | partial file is fine. `remage --dump-config` prints the full commented default. | |
| 319 | − | `remage --check-config` validates the file and exits non-zero with `file:line:col` errors. | |
| 320 | − | Unknown keys are errors and come with a "did you mean" suggestion. | |
| 321 | − | - **`include`:** | |
| 322 | − | - Format: `include = ["~/.cache/matugen/remage.toml"]`. Paths expand `~` and `$VAR`, and relative | |
| 323 | − | paths resolve against the config dir. | |
| 324 | − | - Semantics follow alacritty's `import`. Includes load in order, and a missing include is a | |
| 325 | − | warning, not an error. The including file loads last and wins. | |
| 326 | − | - **Live reload:** a notify watch on the config and every include, debounced to 100 ms. SIGUSR1 and | |
| 327 | − | the `reload-config` action also reload. An invalid config keeps the last good one and shows | |
| 328 | − | `[CONFIG ERROR: …]`. Theme changes crossfade over about 250 ms, so a wallpaper change recolours | |
| 329 | − | smoothly. | |
| 330 | − | - **Sections:** `general`, `window`, `image`, `grid`, `ui`, `animation`, `theme`, `keys.<mode>`, | |
| 331 | − | `commands.<name>` and `[[decoders]]`. Representative keys: | |
| 332 | − | ||
| 333 | − | ```toml | |
| 334 | − | [general] open_siblings = true; recursive = false; sort = "natural"; start_mode = "image"; wrap = true; watch_dir = true | |
| 335 | − | [window] decorations = false; title = "{name} — remage"; class = "remage"; fullscreen = false | |
| 336 | − | [image] fit = "down"; zoom_step = 1.25; filter = "auto"; alpha_background = "dots"; auto_orient = true | |
| 337 | − | color_management = true; preload = 2; cache_mb = 512; slideshow_delay = 5.0 | |
| 338 | − | [grid] sizes = [96, 128, 160, 224, 320]; size = 160; gap = 16; thumbnail_cache = true; names = false | |
| 339 | − | [ui] statusbar = true; which_key_delay_ms = 300; scale = 1.0 | |
| 340 | − | statusbar_left = "{name}"; statusbar_right = "{format} {width}×{height} {zoom}% {index}/{count}" | |
| 341 | − | info_command = "" # optional script whose output fills a status segment (nsxiv image-info) | |
| 342 | − | font_ui = "Space Grotesk"; font_mono = "Space Mono"; font_display = "Doto" | |
| 343 | − | [animation] enabled = true; duration_ms = 180; transition = "fade" | |
| 344 | − | [theme] preset = "nothing-dark" | |
| 345 | − | ``` | |
| 346 | − | ||
| 347 | − | ### Theming | |
| 348 | − | - **Tokens:** `background`, `foreground`, `surface`, `surface_raised`, `border`, `border_visible`, | |
| 349 | − | `text_disabled`, `text_secondary`, `text_primary`, `text_display`, `accent`, `success`, | |
| 350 | − | `warning` and `image_background` (defaults to `background`). | |
| 351 | − | - **Presets:** `nothing-dark` is the default (`#000000` / `#FFFFFF` / accent `#D71921`). | |
| 352 | − | `nothing-light` is `#F5F5F5` / `#000000` / `#D71921`. | |
| 353 | − | - **Derivation:** only `background`, `foreground` and `accent` are needed. remage derives any token | |
| 354 | − | that is missing by mixing fg over bg in sRGB: | |
| 355 | − | ||
| 356 | − | | Token | fg share | | |
| 357 | − | |---|---| | |
| 358 | − | | `text_display` | 1.0 | | |
| 359 | − | | `text_primary` | 0.91 | | |
| 360 | − | | `text_secondary` | 0.60 | | |
| 361 | − | | `text_disabled` | 0.40 | | |
| 362 | − | | `border_visible` | 0.20 | | |
| 363 | − | | `border` | 0.133 | | |
| 364 | − | | `surface_raised` | 0.10 | | |
| 365 | − | | `surface` | 0.067 | | |
| 366 | − | ||
| 367 | − | These ratios reproduce the Nothing hex tokens exactly, and they give any wallpaper palette a | |
| 368 | − | coherent four-level hierarchy. | |
| 369 | − | - **matugen:** ship `contrib/matugen/remage.toml`: | |
| 370 | − | ```toml | |
| 371 | − | [theme] | |
| 372 | − | background = "{{colors.surface_container_lowest.default.hex}}" | |
| 373 | − | foreground = "{{colors.on_surface.default.hex}}" | |
| 374 | − | accent = "{{colors.primary.default.hex}}" | |
| 375 | − | ``` | |
| 376 | − | Register it in `~/.config/matugen/config.toml` with | |
| 377 | − | `[templates.remage] input_path = '~/.config/matugen/templates/remage.toml'` and | |
| 378 | − | `output_path = '~/.cache/matugen/remage.toml'`. Then set | |
| 379 | − | `include = ["~/.cache/matugen/remage.toml"]` in the remage config. The live reload picks up every | |
| 380 | − | wallpaper change, so no `post_hook` is needed. | |
| 381 | − | - **pywal:** read it natively with `[theme] pywal = "~/.cache/wal/colors.json"`. That maps | |
| 382 | − | `special.background` / `special.foreground` / `colors.color1` to background / foreground / accent. | |
| 383 | − | A template is also provided in `contrib/pywal/`. | |
| 384 | − | ||
| 385 | − | ### UI design language (Nothing) | |
| 386 | − | ||
| 387 | − | **Load the `nothing-design` skill before any UI work.** These are the remage-specific rules: | |
| 388 | − | - **Fonts:** embedded, all under the OFL; copy them from `~/.local/share/fonts/glyph/` along with | |
| 389 | − | the licence. | |
| 390 | − | - Space Grotesk for UI text. | |
| 391 | − | - Space Mono for labels and data, in ALL CAPS with 0.08em tracking. | |
| 392 | − | - Doto (dot-matrix) only at 36 px and above, for hero moments. | |
| 393 | − | ||
| 394 | − | A screen uses at most 2 families, 3 sizes and 2 weights. | |
| 395 | − | - **The image is the hero.** There is no chrome except the optional status bar (`b`), which sits at | |
| 396 | − | the bottom on a solid background with no border. | |
| 397 | − | - **Left side:** the filename in Space Grotesk 14, `text_primary`. | |
| 398 | − | - **Right side:** Space Mono caps 11, `text_secondary`, for example | |
| 399 | − | `JPEG 4032×3024 72% 012/240`. | |
| 400 | − | - **Marks:** an accent dot plus the count, shown only when the count is > 0. | |
| 401 | − | - **Pending keys:** shown in `text_disabled`. | |
| 402 | − | - **Messages:** inline (`[COPIED]`, `[ERROR: …]`). Never toasts. | |
| 403 | − | - **Accent red is a signal only:** marks, errors and the palette's selection bar. Nothing else on | |
| 404 | − | screen is coloured. | |
| 405 | − | - **One surprise per screen.** A count prefix or `goto` shows as large Doto numerals in a corner. | |
| 406 | − | The empty state is Doto `NO IMAGES` plus a single hint line. | |
| 407 | − | - **Overlays** (palette, which-key, info, help) use a `surface` background, a 1 px `border_visible` | |
| 408 | − | border and a radius of 8–16, with no shadow or blur. The selected row is `surface_raised` with a | |
| 409 | − | 2 px accent bar on the left. Overlays anchor to an edge (bottom or right), never a centred modal. | |
| 410 | − | - **Grid:** | |
| 411 | − | - Gaps are 16 px. | |
| 412 | − | - The cursor is a 1 px `text_display` outline with a 4 px radius. | |
| 413 | − | - A marked thumbnail gets an accent dot. | |
| 414 | − | - A pending thumbnail is a single dim dot. No skeletons. | |
| 415 | − | - **Motion:** ease-out `cubic-bezier(0.25, 0.1, 0.25, 1)` over 150–250 ms. Fade rather than slide, | |
| 416 | − | with no spring or bounce. Every animation can be retargeted from its current value, and | |
| 417 | − | `animation.enabled = false` turns them all off. | |
| 418 | − | ||
| 419 | − | ### Formats | |
| 420 | − | ||
| 421 | − | | Formats | Backend | | |
| 422 | − | |---|---| | |
| 423 | − | | JPEG, PNG/APNG, GIF, WebP (animated), BMP, ICO, TIFF (multi-page), TGA, PNM, QOI, DDS, HDR, OpenEXR | `image` (pure Rust) | | |
| 424 | − | | JPEG XL (incl. animation) | `jxl-oxide` (pure Rust) | | |
| 425 | − | | AVIF | `image` `avif-native` (system dav1d) | | |
| 426 | − | | HEIC / HEIF | `libheif-rs` (system libheif) | | |
| 427 | − | | SVG / SVGZ | `resvg`, re-rasterised on zoom | | |
| 428 | − | | Camera RAW (CR2/CR3/NEF/ARW/DNG/RAF/ORF/RW2…) | `rawler`: the embedded preview first (fast), with a full develop on demand | | |
| 429 | − | | Anything else (PSD, XCF, JP2, …) | `[[decoders]]` entries `{ match = ["*.psd"], run = ["magick", "{file}[0]", "png:-"] }`, which emit PNG/PPM/TIFF on stdout | | |
| 430 | − | ||
| 431 | − | Pipeline rules: | |
| 432 | − | - Detect the format by magic bytes first and the extension second. | |
| 433 | − | - Convert ICC and CICP colour to sRGB. | |
| 434 | − | - Tone-map 16-bit and float images (EXR/HDR) to 8-bit sRGB for display. | |
| 435 | − | - Apply EXIF orientation. | |
| 436 | − | ||
| 437 | − | ### CLI | |
| 438 | − | ||
| 439 | − | ``` | |
| 440 | − | remage [OPTIONS] [PATHS]... | |
| 441 | − | -r --recursive -i --stdin -0 --null -o --print-marked -t --grid | |
| 442 | − | -n --start N -f --fullscreen -b --no-bar -S --slideshow [SECS] | |
| 443 | − | -s --fit MODE -z --zoom PCT -g --geometry WxH -c --config PATH | |
| 444 | − | -p --private --class NAME --sort KEY --reverse --no-siblings | |
| 445 | − | --dump-config --check-config | |
| 446 | − | remage msg [--pid PID] [--query Q | --subscribe EV] [ACTION...] | |
| 447 | − | remage completions <SHELL> | |
| 448 | − | ``` | |
| 449 | − | ||
| 450 | − | ### Default keys | |
| 451 | − | ||
| 452 | − | | Mode | Keys | | |
| 453 | − | |---|---| | |
| 454 | − | | global | `q` quit · `<CR>` image⇄grid · `n`/`p`/`<BS>` next/prev · `[`/`]` ±10 · `gg`/`G`/`[N]G` · `m`/`M`/`u` mark/invert/unmark-all · `N`/`P` next/prev mark · `f` fullscreen · `b` bar · `i` info · `r` reload · `D` remove from queue · `s` slideshow · `:` palette · `/` search · `?` help · `<Space>` leader (user commands, which-key) · `<Esc>` cancel | | |
| 455 | − | | image | `hjkl`/arrows pan · `HJKL` edges · `z` centre · `+`/`-`/`=` zoom in/out/100% · `w`/`W`/`e`/`E` fit/fill/width/height · `<`/`>` rotate · `\|`/`_` flip · `a` filter · `A` alpha bg · `{`/`}` gamma · `<C-Space>` play/pause · `<C-n>`/`<C-p>` frame · wheel = zoom at cursor, drag = pan | | |
| 456 | − | | grid | `hjkl` move · `<C-d>`/`<C-u>` half page · `<C-f>`/`<C-b>` page · `0`/`$` row ends · `+`/`-` thumb size · `v` visual · click = select, double-click = open | | |
| 457 | − | ||
| 458 | − | ## Milestones | |
| 459 | − | ||
| 460 | − | Each milestone should end with a usable binary. | |
| 461 | − | ||
| 462 | − | - **M0 Scaffold** | |
| 463 | − | - `git init` and `cargo init`. | |
| 464 | − | - `Cargo.toml` with the features and profiles above. | |
| 465 | − | - Fonts + OFL in `assets/fonts`, and the module skeleton. | |
| 466 | − | - fmt, clippy and test all clean. | |
| 467 | − | - **M1 Viewer core** ✓ (2026-11-04). First frame in ~90 ms on the iGPU, 0% CPU when idle. | |
| 468 | − | - CLI, and a queue with siblings, natural sort and sniffing. | |
| 469 | − | - winit + wgpu window, decoding through the `image` crate. | |
| 470 | − | - Fit/zoom/pan/rotate/flip with animation, and `n`/`p`. | |
| 471 | − | - Preload + cache, a status bar with text, and the `nothing-dark` theme with hard-coded keys. | |
| 472 | − | - **M2 Input + config** ✓ (2026-11-04). Unbuilt actions parse and bind, then say "not available yet". | |
| 473 | − | - Action grammar, and a keymap trie with sequences, counts and mouse bindings. | |
| 474 | − | - TOML defaults, include and live reload, plus `--dump-config` and `--check-config`. | |
| 475 | − | - Theme tokens, presets and derivation; matugen + pywal support. | |
| 476 | − | - **M3 Grid + marks** ✓ (2026-11-04). Cached grid opens in ~85 ms, 0% CPU when idle. | |
| 477 | − | - Freedesktop thumbnails, worker priorities and the GPU atlas. | |
| 478 | − | - Grid navigation, visual mode, marks and smooth scrolling. | |
| 479 | − | - **M4 Commands + IPC** ✓ (2026-11-04). Tested end to end over the socket and with `wtype`. | |
| 480 | − | - `[commands]` with async exec, confirm, output modes and `after`/`refresh`. | |
| 481 | − | - The socket, `remage msg` and subscriptions; `-i`/`-o`/`-0`. | |
| 482 | − | - **M5 Formats + fidelity** | |
| 483 | − | - JXL, AVIF, HEIF, SVG, RAW and external decoders. | |
| 484 | − | - ICC colour, animation, multi-page, tiling + mipmaps, and HDR tone-mapping. | |
| 485 | − | - **M6 Polish** | |
| 486 | − | - Palette, search, which-key, help and the info panel. | |
| 487 | − | - Slideshow, gamma/brightness/contrast and the `nothing-light` preset. | |
| 488 | − | - `watch_dir`, title and status-bar templates. | |
| 489 | − | - A performance pass against the targets. | |
| 490 | − | - **M7 Packaging** | |
| 491 | − | - Man page, zsh completion and `remage.desktop` with the MimeType list. | |
| 492 | − | - README, `contrib/config.example.toml` (with the old key-handler's delete/wallpaper/copy | |
| 493 | − | migrated) and a PKGBUILD. | |
| 494 | − | ||
| 495 | − | ## Testing and verification | |
| 496 | − | - **Unit tests** cover the pure modules: key-notation parsing, the keymap trie and counts, Action | |
| 497 | − | parse ↔ Display round-trips, queue operations (natural sort, marks, filter, refresh), view maths | |
| 498 | − | (fit modes, zoom anchor, rotation), config merge, include precedence and theme derivation, | |
| 499 | − | placeholder expansion and shell quoting, and thumbnail paths (URI → MD5) per the spec. | |
| 500 | − | - **Decoder tests** run against `tests/fixtures/`: dimensions and pixels for each format, EXIF | |
| 501 | − | orientations 1–8, ICC-tagged images and animated frame counts. | |
| 502 | − | - **Visual checks without a display:** `REMAGE_SNAPSHOT=/tmp/x.png remage …` renders each settled | |
| 503 | − | frame offscreen to a PNG as well. Use it to check the UI when the screen is locked or remote. In | |
| 504 | − | the grid, a frame counts as settled once every visible thumbnail is in the atlas. Point | |
| 505 | − | `XDG_CACHE_HOME` at a scratch directory to test cold thumbnail generation without touching the | |
| 506 | − | real cache. | |
| 507 | − | - **Before every commit:** `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and | |
| 508 | − | `cargo test`. | |
| 509 | − | - **Manual smoke test:** run `cargo run --release -- /mnt/media/pix` and check that: | |
| 510 | − | - `n`/`p` are instant. | |
| 511 | − | - `<CR>` opens the grid. | |
| 512 | − | - `m` marks and `<Space>w` runs a command. | |
| 513 | − | - `remage msg next` works from another terminal. | |
| 514 | − | - Changing the wallpaper recolours the window live. | |
| 515 | − | - `RUST_LOG=remage=debug` logs startup and first-frame timings. | |
| 516 | − | - CPU is idle while the view is static. | |
| 517 | − | ||
| 518 | − | ## Conventions | |
| 519 | − | - Never put Claude session links or `Claude-Session:` trailers in commits or PRs. Commit only when | |
| 520 | − | the user asks, with imperative, concise messages. | |
| 521 | − | - No `unwrap()`/`expect()` on paths that touch user input, files or the GPU surface. Errors go to | |
| 522 | − | the status line. | |
| 523 | − | - `state/` must stay free of winit and wgpu types. | |
| 524 | − | - Adding an action takes all of these: an enum variant, a parser case, a description, an optional | |
| 525 | − | default binding, an entry in `--dump-config` and a test. | |
| @@ -1665,6 +1665,7 @@ dependencies = [ | |||
| 1665 | 1665 | "jxl-oxide-common", | |
| 1666 | 1666 | "jxl-render", | |
| 1667 | 1667 | "jxl-threadpool", | |
| 1668 | + | "moxcms 0.8.1", | |
| 1668 | 1669 | "tracing", | |
| 1669 | 1670 | ] | |
| 1670 | 1671 | ||
| @@ -3075,6 +3076,7 @@ dependencies = [ | |||
| 3075 | 3076 | "serde_json", | |
| 3076 | 3077 | "signal-hook", | |
| 3077 | 3078 | "thiserror 2.0.21", | |
| 3079 | + | "tiff", | |
| 3078 | 3080 | "toml 1.2.0+spec-1.1.0", | |
| 3079 | 3081 | "wgpu", | |
| 3080 | 3082 | "winit", | |
| @@ -17,7 +17,7 @@ fast_image_resize = "6.1.0" | |||
| 17 | 17 | glyphon = "0.12.0" | |
| 18 | 18 | image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "tiff", "bmp", "ico", "tga", "pnm", "qoi", "dds", "hdr", "exr"] } | |
| 19 | 19 | jpeg-decoder = "0.3.2" | |
| 20 | − | jxl-oxide = { version = "0.12.6", optional = true } | |
| 20 | + | jxl-oxide = { version = "0.12.6", optional = true, features = ["moxcms"] } | |
| 21 | 21 | kamadak-exif = "0.6.1" | |
| 22 | 22 | libheif-rs = { version = "3.0.0", optional = true } | |
| 23 | 23 | log = "0.4.35" | |
| @@ -33,6 +33,7 @@ serde = { version = "1.0.229", features = ["derive"] } | |||
| 33 | 33 | serde_json = "1.0.154" | |
| 34 | 34 | signal-hook = "0.4.6" | |
| 35 | 35 | thiserror = "2.0.21" | |
| 36 | + | tiff = "0.11.3" | |
| 36 | 37 | toml = "1.2.0" | |
| 37 | 38 | wgpu = "30" | |
| 38 | 39 | winit = "0.30" | |
| @@ -2,7 +2,6 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use std::collections::{HashMap, HashSet, VecDeque}; | |
| 4 | 4 | use std::path::{Path, PathBuf}; | |
| 5 | − | use std::sync::atomic::{AtomicU32, Ordering}; | |
| 6 | 5 | use std::sync::{Arc, Mutex}; | |
| 7 | 6 | use std::time::{Duration, Instant, SystemTime}; | |
| 8 | 7 | ||
| @@ -49,6 +48,8 @@ const CONFIG_ERROR_TIME: Duration = Duration::from_secs(3600); | |||
| 49 | 48 | /// In the grid, the selected image decodes once the cursor has rested this long, so `<CR>` | |
| 50 | 49 | /// opens it at once without decoding everything the cursor passes over. | |
| 51 | 50 | const GRID_REST: Duration = Duration::from_millis(250); | |
| 51 | + | /// A scalable image is re-rendered for the zoom once the view has rested this long. | |
| 52 | + | const REFINE_DELAY: Duration = Duration::from_millis(150); | |
| 52 | 53 | /// Two clicks on the same thumbnail this close together open it. | |
| 53 | 54 | const DOUBLE_CLICK: Duration = Duration::from_millis(400); | |
| 54 | 55 | ||
| @@ -74,6 +75,11 @@ pub enum UserEvent { | |||
| 74 | 75 | mtime: Option<SystemTime>, | |
| 75 | 76 | result: Result<Thumb, ThumbError>, | |
| 76 | 77 | }, | |
| 78 | + | /// A sharper version of an image (SVG at the current zoom, a developed RAW). | |
| 79 | + | Refined { | |
| 80 | + | path: PathBuf, | |
| 81 | + | result: Result<Arc<DecodedImage>, String>, | |
| 82 | + | }, | |
| 77 | 83 | /// Output or the exit of a user command. | |
| 78 | 84 | Command { id: u64, event: exec::Event }, | |
| 79 | 85 | /// `refresh` re-checked these files: their mtime, or `None` if they are gone. | |
| @@ -182,7 +188,6 @@ pub struct App { | |||
| 182 | 188 | pool: Pool, | |
| 183 | 189 | proxy: EventLoopProxy<UserEvent>, | |
| 184 | 190 | cache: ImageCache, | |
| 185 | − | max_dim: Arc<AtomicU32>, | |
| 186 | 191 | /// Decoded images whose result event is still in the loop's queue. | |
| 187 | 192 | arriving: Arc<Mutex<HashSet<PathBuf>>>, | |
| 188 | 193 | gfx: Option<Gfx>, | |
| @@ -211,6 +216,12 @@ pub struct App { | |||
| 211 | 216 | confirm: Option<(Job, exec::Prepared)>, | |
| 212 | 217 | subscribers: Vec<Subscriber>, | |
| 213 | 218 | published: Option<Published>, | |
| 219 | + | /// A refinement waiting for the view to rest: (path, target size, since). | |
| 220 | + | refine_wait: Option<(PathBuf, u32, Instant)>, | |
| 221 | + | /// The image being refined. | |
| 222 | + | refining: Option<PathBuf>, | |
| 223 | + | /// Refinements that failed; not retried. | |
| 224 | + | refine_failed: HashSet<PathBuf>, | |
| 214 | 225 | /// `quit-no-output` ran: `-o` prints nothing. | |
| 215 | 226 | no_output: bool, | |
| 216 | 227 | pub error: Option<anyhow::Error>, | |
| @@ -239,8 +250,6 @@ impl App { | |||
| 239 | 250 | pool: Pool::new(), | |
| 240 | 251 | proxy, | |
| 241 | 252 | cache, | |
| 242 | − | // Until the GPU reports its limit; uploads re-check. | |
| 243 | − | max_dim: Arc::new(AtomicU32::new(16384)), | |
| 244 | 253 | arriving: Arc::default(), | |
| 245 | 254 | gfx: None, | |
| 246 | 255 | uploads: VecDeque::new(), | |
| @@ -262,6 +271,9 @@ impl App { | |||
| 262 | 271 | confirm: None, | |
| 263 | 272 | subscribers: Vec::new(), | |
| 264 | 273 | published: None, | |
| 274 | + | refine_wait: None, | |
| 275 | + | refining: None, | |
| 276 | + | refine_failed: HashSet::new(), | |
| 265 | 277 | no_output: false, | |
| 266 | 278 | error: None, | |
| 267 | 279 | }; | |
| @@ -281,14 +293,14 @@ impl App { | |||
| 281 | 293 | return; | |
| 282 | 294 | } | |
| 283 | 295 | let proxy = self.proxy.clone(); | |
| 284 | − | let max_dim = self.max_dim.clone(); | |
| 296 | + | let opts = self.decode_options(); | |
| 285 | 297 | let arriving = self.arriving.clone(); | |
| 286 | 298 | let path = path.to_path_buf(); | |
| 287 | 299 | self.pool | |
| 288 | 300 | .submit(JobKey::Decode(path.clone()), prio, move || { | |
| 289 | 301 | let t = Instant::now(); | |
| 290 | 302 | let mtime = mtime(&path); | |
| 291 | − | let result = decode::decode(&path, max_dim.load(Ordering::Relaxed)) | |
| 303 | + | let result = decode::decode(&path, &opts) | |
| 292 | 304 | .map(Arc::new) | |
| 293 | 305 | .map_err(|e| e.to_string()); | |
| 294 | 306 | log::debug!("decoded {} in {:.1?}", path.display(), t.elapsed()); | |
| @@ -301,6 +313,94 @@ impl App { | |||
| 301 | 313 | }); | |
| 302 | 314 | } | |
| 303 | 315 | ||
| 316 | + | fn decode_options(&self) -> decode::Options { | |
| 317 | + | let [w, h] = self.state.window(); | |
| 318 | + | let long = w.max(h) as u32; | |
| 319 | + | decode::Options { | |
| 320 | + | // Before the window exists, a typical screen. | |
| 321 | + | target: if long > 1 { long } else { 2048 }, | |
| 322 | + | auto_orient: self.config.image.auto_orient, | |
| 323 | + | color: self.config.image.color_management, | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /// Whether the shown image should be re-rendered sharper for the current zoom. Waits for | |
| 328 | + | /// the view to rest; returns when to check again. | |
| 329 | + | fn check_refine(&mut self, now: Instant) -> Option<Instant> { | |
| 330 | + | if self.state.mode != Mode::Image || self.state.view.is_animating(now) { | |
| 331 | + | return None; | |
| 332 | + | } | |
| 333 | + | let path = self | |
| 334 | + | .state | |
| 335 | + | .shown | |
| 336 | + | .as_ref() | |
| 337 | + | .filter(|s| Some(s.path.as_path()) == self.state.queue.current())? | |
| 338 | + | .path | |
| 339 | + | .clone(); | |
| 340 | + | if self.refining.is_some() || self.refine_failed.contains(&path) { | |
| 341 | + | return None; | |
| 342 | + | } | |
| 343 | + | let img = self.cache.get(&path)?; | |
| 344 | + | let refine = img.refine.clone()?; | |
| 345 | + | let source = img.source_size[0].max(img.source_size[1]) as f32; | |
| 346 | + | let want = (source * self.state.view.scale()).round() as u32; | |
| 347 | + | let Some(target) = refine.wants(img.size[0].max(img.size[1]), want) else { | |
| 348 | + | self.refine_wait = None; | |
| 349 | + | return None; | |
| 350 | + | }; | |
| 351 | + | match &self.refine_wait { | |
| 352 | + | Some((p, t, since)) if *p == path && *t == target => { | |
| 353 | + | let at = *since + REFINE_DELAY; | |
| 354 | + | if now < at { | |
| 355 | + | return Some(at); | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | _ => { | |
| 359 | + | self.refine_wait = Some((path, target, now)); | |
| 360 | + | return Some(now + REFINE_DELAY); | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | self.refine_wait = None; | |
| 364 | + | self.refining = Some(path.clone()); | |
| 365 | + | log::debug!("refining {} to {target} px", path.display()); | |
| 366 | + | let proxy = self.proxy.clone(); | |
| 367 | + | let opts = self.decode_options(); | |
| 368 | + | self.pool | |
| 369 | + | .submit(JobKey::Refine(path.clone()), Priority::Current, move || { | |
| 370 | + | let t = Instant::now(); | |
| 371 | + | let result = refine | |
| 372 | + | .run(&path, target, &opts) | |
| 373 | + | .map(Arc::new) | |
| 374 | + | .map_err(|e| format!("{e:#}")); | |
| 375 | + | log::debug!("refined {} in {:.1?}", path.display(), t.elapsed()); | |
| 376 | + | let _ = proxy.send_event(UserEvent::Refined { path, result }); | |
| 377 | + | }); | |
| 378 | + | None | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | fn on_refined(&mut self, path: PathBuf, result: Result<Arc<DecodedImage>, String>) { | |
| 382 | + | if self.refining.as_ref() == Some(&path) { | |
| 383 | + | self.refining = None; | |
| 384 | + | } | |
| 385 | + | match result { | |
| 386 | + | Ok(img) => { | |
| 387 | + | let wanted = self.window_paths(); | |
| 388 | + | let keep: Vec<&Path> = wanted.iter().map(PathBuf::as_path).collect(); | |
| 389 | + | self.cache.insert(path.clone(), img.clone(), &keep); | |
| 390 | + | let shown = self.state.shown.as_ref().is_some_and(|s| s.path == path); | |
| 391 | + | if shown && let Some(g) = &mut self.gfx { | |
| 392 | + | // Same source size, so the view stays exactly where it is. | |
| 393 | + | g.renderer.upload(&path, &img, usize::MAX); | |
| 394 | + | g.window.request_redraw(); | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | Err(e) => { | |
| 398 | + | log::warn!("{}: {e}", path.display()); | |
| 399 | + | self.refine_failed.insert(path); | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | ||
| 304 | 404 | /// The current file and its preload neighbours, current first. | |
| 305 | 405 | fn window_paths(&self) -> Vec<PathBuf> { | |
| 306 | 406 | let q = &self.state.queue; | |
| @@ -321,6 +421,9 @@ impl App { | |||
| 321 | 421 | let now = Instant::now(); | |
| 322 | 422 | if navigated { | |
| 323 | 423 | self.pool.bump_epoch(); | |
| 424 | + | // A pending refinement was for the image we left (and its job may be cancelled). | |
| 425 | + | self.refine_wait = None; | |
| 426 | + | self.refining = None; | |
| 324 | 427 | } | |
| 325 | 428 | let wanted = self.window_paths(); | |
| 326 | 429 | self.update_title(); | |
| @@ -511,10 +614,10 @@ impl App { | |||
| 511 | 614 | self.state.loading_since.get_or_insert(now); | |
| 512 | 615 | return; | |
| 513 | 616 | }; | |
| 514 | − | let img = fit_gpu(img, g.renderer.max_texture_dim()); | |
| 515 | 617 | g.renderer.upload(path, &img, usize::MAX); | |
| 516 | 618 | self.uploads.retain(|(p, _)| p != path); | |
| 517 | 619 | self.state.show(path, img.source_size, img.format, now); | |
| 620 | + | self.state.set_frames(img.delays(), img.animated, now); | |
| 518 | 621 | self.request_redraw(); | |
| 519 | 622 | } | |
| 520 | 623 | ||
| @@ -556,6 +659,7 @@ impl App { | |||
| 556 | 659 | } | |
| 557 | 660 | let old = std::mem::replace(&mut self.config, loaded.config); | |
| 558 | 661 | let new = &self.config; | |
| 662 | + | decode::set_decoders(&new.decoders); | |
| 559 | 663 | let mut settings = Settings::from_config(new); | |
| 560 | 664 | self.opts.overrides.apply(&mut settings); | |
| 561 | 665 | let preload_changed = settings.preload != self.state.settings.preload; | |
| @@ -1260,6 +1364,21 @@ impl App { | |||
| 1260 | 1364 | } else { | |
| 1261 | 1365 | self.state.loading_since.is_none() | |
| 1262 | 1366 | }; | |
| 1367 | + | // An animation moved on (or a page turned): put that frame in the texture. | |
| 1368 | + | let frame = self.state.frame(); | |
| 1369 | + | if let Some(path) = self | |
| 1370 | + | .state | |
| 1371 | + | .shown | |
| 1372 | + | .as_ref() | |
| 1373 | + | .filter(|_| self.state.mode == Mode::Image) | |
| 1374 | + | .map(|s| s.path.clone()) | |
| 1375 | + | && let Some(g) = &self.gfx | |
| 1376 | + | && g.renderer.frame(&path).is_some_and(|f| f != frame) | |
| 1377 | + | && let Some(img) = self.cache.get(&path) | |
| 1378 | + | && let Some(g) = &mut self.gfx | |
| 1379 | + | { | |
| 1380 | + | g.renderer.show_frame(&path, &img, frame); | |
| 1381 | + | } | |
| 1263 | 1382 | let Some(g) = &mut self.gfx else { return }; | |
| 1264 | 1383 | if !self.shown_window { | |
| 1265 | 1384 | g.window.set_visible(true); | |
| @@ -1316,11 +1435,10 @@ impl App { | |||
| 1316 | 1435 | if !self.thumbs.arrivals.is_empty() { | |
| 1317 | 1436 | return true; | |
| 1318 | 1437 | } | |
| 1319 | − | if let Some((path, img)) = self.uploads.front() { | |
| 1320 | − | let img = fit_gpu(img.clone(), g.renderer.max_texture_dim()); | |
| 1321 | − | if g.renderer.upload(path, &img, UPLOAD_BUDGET) { | |
| 1322 | − | self.uploads.pop_front(); | |
| 1323 | − | } | |
| 1438 | + | if let Some((path, img)) = self.uploads.front() | |
| 1439 | + | && g.renderer.upload(path, img, UPLOAD_BUDGET) | |
| 1440 | + | { | |
| 1441 | + | self.uploads.pop_front(); | |
| 1324 | 1442 | } | |
| 1325 | 1443 | !self.uploads.is_empty() | |
| 1326 | 1444 | } | |
| @@ -1365,29 +1483,6 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> { | |||
| 1365 | 1483 | m.lock().unwrap_or_else(|e| e.into_inner()) | |
| 1366 | 1484 | } | |
| 1367 | 1485 | ||
| 1368 | − | /// Shrink an image that exceeds the GPU's texture limit (decoded before the limit was known). | |
| 1369 | − | fn fit_gpu(img: Arc<DecodedImage>, max_dim: u32) -> Arc<DecodedImage> { | |
| 1370 | − | if img.size[0] <= max_dim && img.size[1] <= max_dim { | |
| 1371 | − | return img; | |
| 1372 | − | } | |
| 1373 | − | let owned = Arc::try_unwrap(img).unwrap_or_else(|a| DecodedImage { | |
| 1374 | − | pixels: a.pixels.clone(), | |
| 1375 | − | ..*a | |
| 1376 | − | }); | |
| 1377 | − | match decode::downscale(owned, max_dim) { | |
| 1378 | − | Ok(small) => Arc::new(small), | |
| 1379 | − | Err(e) => { | |
| 1380 | − | log::error!("downscale: {e:#}"); | |
| 1381 | − | Arc::new(DecodedImage { | |
| 1382 | − | size: [1, 1], | |
| 1383 | − | pixels: vec![0; 4], | |
| 1384 | − | format: "?", | |
| 1385 | − | source_size: [1, 1], | |
| 1386 | − | }) | |
| 1387 | − | } | |
| 1388 | − | } | |
| 1389 | − | } | |
| 1390 | − | ||
| 1391 | 1486 | impl ApplicationHandler<UserEvent> for App { | |
| 1392 | 1487 | fn resumed(&mut self, event_loop: &ActiveEventLoop) { | |
| 1393 | 1488 | if self.gfx.is_some() { | |
| @@ -1430,8 +1525,6 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 1430 | 1525 | } | |
| 1431 | 1526 | }; | |
| 1432 | 1527 | log::debug!("GPU ready after {:.1?}", self.started.elapsed()); | |
| 1433 | − | self.max_dim | |
| 1434 | − | .store(renderer.max_texture_dim(), Ordering::Relaxed); | |
| 1435 | 1528 | let size = window.inner_size(); | |
| 1436 | 1529 | self.state.set_window( | |
| 1437 | 1530 | size.width as f32, | |
| @@ -1481,6 +1574,7 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 1481 | 1574 | mtime, | |
| 1482 | 1575 | result, | |
| 1483 | 1576 | } => self.on_thumb(path, px, mtime, result), | |
| 1577 | + | UserEvent::Refined { path, result } => self.on_refined(path, result), | |
| 1484 | 1578 | UserEvent::Command { id, event } => self.on_command(id, event, event_loop), | |
| 1485 | 1579 | UserEvent::Refreshed(found) => self.on_refreshed(found), | |
| 1486 | 1580 | UserEvent::Ipc(inc) => self.on_ipc(inc, event_loop), | |
| @@ -1608,6 +1702,9 @@ impl ApplicationHandler<UserEvent> for App { | |||
| 1608 | 1702 | let at = t + SEQUENCE_TIMEOUT; | |
| 1609 | 1703 | next = Some(next.map_or(at, |n| n.min(at))); | |
| 1610 | 1704 | } | |
| 1705 | + | if let Some(at) = self.check_refine(now) { | |
| 1706 | + | next = Some(next.map_or(at, |n| n.min(at))); | |
| 1707 | + | } | |
| 1611 | 1708 | if redraw { | |
| 1612 | 1709 | self.request_redraw(); | |
| 1613 | 1710 | } | |
| @@ -82,12 +82,7 @@ mod tests { | |||
| 82 | 82 | use super::*; | |
| 83 | 83 | ||
| 84 | 84 | fn img(bytes: usize) -> Arc<DecodedImage> { | |
| 85 | − | Arc::new(DecodedImage { | |
| 86 | − | size: [1, 1], | |
| 87 | − | pixels: vec![0; bytes], | |
| 88 | − | format: "PNG", | |
| 89 | − | source_size: [1, 1], | |
| 90 | − | }) | |
| 85 | + | Arc::new(DecodedImage::still([1, 1], vec![0; bytes], "PNG")) | |
| 91 | 86 | } | |
| 92 | 87 | ||
| 93 | 88 | #[test] | |
| @@ -1 +1,200 @@ | |||
| 1 | − | //! ICC/CICP to sRGB conversion, EXIF orientation and HDR tone-mapping. | |
| 1 | + | //! Colour to sRGB (ICC and CICP through moxcms), HDR tone-mapping and EXIF orientation. | |
| 2 | + | ||
| 3 | + | use image::DynamicImage; | |
| 4 | + | use image::metadata::Orientation; | |
| 5 | + | use moxcms::{ | |
| 6 | + | CicpColorPrimaries, CicpProfile, ColorProfile, DataColorSpace, Layout, MatrixCoefficients, | |
| 7 | + | TransferCharacteristics, TransformOptions, | |
| 8 | + | }; | |
| 9 | + | ||
| 10 | + | /// An sRGB-bound transform for RGBA8 pixels, or `None` when the source already is sRGB (or | |
| 11 | + | /// the profile can't be used, in which case the pixels are shown as they are). | |
| 12 | + | pub struct ToSrgb(std::sync::Arc<moxcms::Transform8BitExecutor>); | |
| 13 | + | ||
| 14 | + | impl ToSrgb { | |
| 15 | + | pub fn from_icc(icc: &[u8]) -> Option<Self> { | |
| 16 | + | let src = match ColorProfile::new_from_slice(icc) { | |
| 17 | + | Ok(p) => p, | |
| 18 | + | Err(e) => { | |
| 19 | + | log::debug!("ICC profile ignored: {e:?}"); | |
| 20 | + | return None; | |
| 21 | + | } | |
| 22 | + | }; | |
| 23 | + | if src.color_space != DataColorSpace::Rgb { | |
| 24 | + | return None; | |
| 25 | + | } | |
| 26 | + | Self::from_profile(&src) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// From H.273 code points (AVIF/HEIF `nclx`). sRGB/BT.709 and unspecified need nothing. | |
| 30 | + | pub fn from_cicp(primaries: u8, transfer: u8) -> Option<Self> { | |
| 31 | + | let primaries = CicpColorPrimaries::try_from(primaries).ok()?; | |
| 32 | + | if matches!( | |
| 33 | + | primaries, | |
| 34 | + | CicpColorPrimaries::Bt709 | CicpColorPrimaries::Unspecified | |
| 35 | + | ) { | |
| 36 | + | return None; | |
| 37 | + | } | |
| 38 | + | let transfer = TransferCharacteristics::try_from(transfer) | |
| 39 | + | .ok() | |
| 40 | + | .filter(|t| !matches!(t, TransferCharacteristics::Unspecified)) | |
| 41 | + | .unwrap_or(TransferCharacteristics::Srgb); | |
| 42 | + | let profile = ColorProfile::new_from_cicp(CicpProfile { | |
| 43 | + | color_primaries: primaries, | |
| 44 | + | transfer_characteristics: transfer, | |
| 45 | + | matrix_coefficients: MatrixCoefficients::Identity, | |
| 46 | + | full_range: true, | |
| 47 | + | }); | |
| 48 | + | Self::from_profile(&profile) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | fn from_profile(src: &ColorProfile) -> Option<Self> { | |
| 52 | + | let dst = ColorProfile::new_srgb(); | |
| 53 | + | match src.create_transform_8bit( | |
| 54 | + | Layout::Rgba, | |
| 55 | + | &dst, | |
| 56 | + | Layout::Rgba, | |
| 57 | + | TransformOptions::default(), | |
| 58 | + | ) { | |
| 59 | + | Ok(t) => Some(Self(t)), | |
| 60 | + | Err(e) => { | |
| 61 | + | log::debug!("colour transform unavailable: {e:?}"); | |
| 62 | + | None | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Convert RGBA8 pixels in place; alpha is untouched. | |
| 68 | + | pub fn apply(&self, pixels: &mut [u8]) { | |
| 69 | + | let src = pixels.to_vec(); | |
| 70 | + | if let Err(e) = self.0.transform(&src, pixels) { | |
| 71 | + | log::debug!("colour transform failed: {e:?}"); | |
| 72 | + | pixels.copy_from_slice(&src); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | fn srgb_encode(v: f32) -> f32 { | |
| 78 | + | if v <= 0.003_130_8 { | |
| 79 | + | v * 12.92 | |
| 80 | + | } else { | |
| 81 | + | 1.055 * v.powf(1.0 / 2.4) - 0.055 | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Narkowicz's ACES fit: filmic highlights, unchanged blacks. | |
| 86 | + | fn aces(x: f32) -> f32 { | |
| 87 | + | ((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)).clamp(0.0, 1.0) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Linear float RGBA (EXR, Radiance HDR, float TIFF) to sRGB RGBA8. Images whose values stay | |
| 91 | + | /// within 0..1 are only encoded; brighter ones are tone-mapped. | |
| 92 | + | pub fn tonemap(rgba: &[f32]) -> Vec<u8> { | |
| 93 | + | let peak = rgba | |
| 94 | + | .chunks_exact(4) | |
| 95 | + | .flat_map(|p| &p[..3]) | |
| 96 | + | .copied() | |
| 97 | + | .filter(|v| v.is_finite()) | |
| 98 | + | .fold(0.0f32, f32::max); | |
| 99 | + | let hdr = peak > 1.0; | |
| 100 | + | let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; | |
| 101 | + | let mut out = Vec::with_capacity(rgba.len()); | |
| 102 | + | for p in rgba.chunks_exact(4) { | |
| 103 | + | for &c in &p[..3] { | |
| 104 | + | let c = if c.is_finite() { c.max(0.0) } else { 0.0 }; | |
| 105 | + | let c = if hdr { aces(c) } else { c }; | |
| 106 | + | out.push(to8(srgb_encode(c))); | |
| 107 | + | } | |
| 108 | + | out.push(to8(if p[3].is_finite() { p[3] } else { 1.0 })); | |
| 109 | + | } | |
| 110 | + | out | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Whether the image holds float samples that need tone-mapping rather than a plain cast. | |
| 114 | + | pub fn is_float(img: &DynamicImage) -> bool { | |
| 115 | + | matches!( | |
| 116 | + | img, | |
| 117 | + | DynamicImage::ImageRgb32F(_) | DynamicImage::ImageRgba32F(_) | |
| 118 | + | ) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Straight RGBA8 from any decoded image, tone-mapping float data. | |
| 122 | + | pub fn to_rgba8(img: DynamicImage) -> ([u32; 2], Vec<u8>) { | |
| 123 | + | let size = [img.width(), img.height()]; | |
| 124 | + | if is_float(&img) { | |
| 125 | + | let f = img.into_rgba32f(); | |
| 126 | + | (size, tonemap(f.as_raw())) | |
| 127 | + | } else { | |
| 128 | + | (size, img.into_rgba8().into_raw()) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Rotate/flip RGBA8 pixels upright. | |
| 133 | + | pub fn orient(size: [u32; 2], pixels: Vec<u8>, o: Orientation) -> ([u32; 2], Vec<u8>) { | |
| 134 | + | if o == Orientation::NoTransforms { | |
| 135 | + | return (size, pixels); | |
| 136 | + | } | |
| 137 | + | let Some(buf) = image::RgbaImage::from_raw(size[0], size[1], pixels) else { | |
| 138 | + | return (size, Vec::new()); | |
| 139 | + | }; | |
| 140 | + | let mut img = DynamicImage::ImageRgba8(buf); | |
| 141 | + | img.apply_orientation(o); | |
| 142 | + | let out = img.into_rgba8(); | |
| 143 | + | ([out.width(), out.height()], out.into_raw()) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// RGBA8 from interleaved 8-bit samples with 1–4 channels (grey, grey+alpha, RGB, RGBA). | |
| 147 | + | pub fn expand(samples: &[u8], channels: usize) -> Vec<u8> { | |
| 148 | + | match channels { | |
| 149 | + | 4 => samples.to_vec(), | |
| 150 | + | 3 => samples | |
| 151 | + | .chunks_exact(3) | |
| 152 | + | .flat_map(|p| [p[0], p[1], p[2], 255]) | |
| 153 | + | .collect(), | |
| 154 | + | 2 => samples | |
| 155 | + | .chunks_exact(2) | |
| 156 | + | .flat_map(|p| [p[0], p[0], p[0], p[1]]) | |
| 157 | + | .collect(), | |
| 158 | + | _ => samples.iter().flat_map(|&g| [g, g, g, 255]).collect(), | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | #[cfg(test)] | |
| 163 | + | mod tests { | |
| 164 | + | use super::*; | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn tonemapping() { | |
| 168 | + | // In-range linear data is only sRGB-encoded. | |
| 169 | + | let px = tonemap(&[0.0, 0.2158, 1.0, 0.5]); | |
| 170 | + | assert_eq!(px, [0, 128, 255, 128]); | |
| 171 | + | // HDR highlights roll off instead of clipping, and stay ordered. | |
| 172 | + | let px = tonemap(&[1.0, 4.0, 40.0, 1.0, f32::NAN, -1.0, 0.0, f32::INFINITY]); | |
| 173 | + | assert!(px[0] < px[1] && px[1] <= px[2] && px[2] == 255, "{px:?}"); | |
| 174 | + | assert_eq!(&px[4..8], [0, 0, 0, 255]); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[test] | |
| 178 | + | fn orientation_turns_pixels() { | |
| 179 | + | // 2×1: red, green. Rotate 90° clockwise → 1×2: red on top. | |
| 180 | + | let px = vec![255, 0, 0, 255, 0, 255, 0, 255]; | |
| 181 | + | let (size, out) = orient([2, 1], px, Orientation::Rotate90); | |
| 182 | + | assert_eq!(size, [1, 2]); | |
| 183 | + | assert_eq!(&out[..4], [255, 0, 0, 255]); | |
| 184 | + | let (size, out) = orient([2, 1], out.clone(), Orientation::NoTransforms); | |
| 185 | + | assert_eq!((size, out.len()), ([2, 1], 8)); | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | #[test] | |
| 189 | + | fn cicp_and_icc() { | |
| 190 | + | assert!(ToSrgb::from_cicp(1, 13).is_none(), "BT.709 is already sRGB"); | |
| 191 | + | // Display P3 primaries with the sRGB curve: pure P3 red is outside sRGB. | |
| 192 | + | let t = ToSrgb::from_cicp(12, 13).expect("P3 transform"); | |
| 193 | + | let mut px = [255, 0, 0, 200]; | |
| 194 | + | t.apply(&mut px); | |
| 195 | + | assert_eq!(px[0], 255); | |
| 196 | + | assert!(px[3] == 200); | |
| 197 | + | assert!(ToSrgb::from_icc(b"not a profile").is_none()); | |
| 198 | + | assert_eq!(expand(&[7, 9], 2), [7, 7, 7, 9]); | |
| 199 | + | } | |
| 200 | + | } | |
| @@ -0,0 +1,155 @@ | |||
| 1 | + | //! `[[decoders]]`: external programs that turn anything else into PNG, PPM or TIFF on stdout. | |
| 2 | + | ||
| 3 | + | use std::io::Read; | |
| 4 | + | use std::path::Path; | |
| 5 | + | use std::process::{Command, Stdio}; | |
| 6 | + | use std::sync::RwLock; | |
| 7 | + | ||
| 8 | + | use anyhow::{Context, anyhow}; | |
| 9 | + | ||
| 10 | + | use super::{DecodedImage, Options, image_rs}; | |
| 11 | + | use crate::config::Decoder; | |
| 12 | + | ||
| 13 | + | static DECODERS: RwLock<Vec<Decoder>> = RwLock::new(Vec::new()); | |
| 14 | + | ||
| 15 | + | /// Install the configured decoders (at startup and on every config reload). | |
| 16 | + | pub fn set_decoders(decoders: &[Decoder]) { | |
| 17 | + | let mut d = DECODERS.write().unwrap_or_else(|e| e.into_inner()); | |
| 18 | + | *d = decoders.to_vec(); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /// The first decoder whose patterns match `path`. | |
| 22 | + | pub fn find(path: &Path) -> Option<Decoder> { | |
| 23 | + | let d = DECODERS.read().unwrap_or_else(|e| e.into_inner()); | |
| 24 | + | if d.is_empty() { | |
| 25 | + | return None; | |
| 26 | + | } | |
| 27 | + | let name = path.file_name()?.to_string_lossy(); | |
| 28 | + | let full = path.to_string_lossy(); | |
| 29 | + | d.iter() | |
| 30 | + | .find(|dec| { | |
| 31 | + | dec.patterns.iter().any(|p| { | |
| 32 | + | // Patterns with a slash match the whole path, others the file name. | |
| 33 | + | let target = if p.contains('/') { &*full } else { &*name }; | |
| 34 | + | glob(&p.to_lowercase(), &target.to_lowercase()) | |
| 35 | + | }) | |
| 36 | + | }) | |
| 37 | + | .cloned() | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// `*` and `?` wildcards. | |
| 41 | + | fn glob(pattern: &str, text: &str) -> bool { | |
| 42 | + | let (p, t): (Vec<char>, Vec<char>) = (pattern.chars().collect(), text.chars().collect()); | |
| 43 | + | let (mut pi, mut ti) = (0, 0); | |
| 44 | + | let mut star: Option<(usize, usize)> = None; | |
| 45 | + | while ti < t.len() { | |
| 46 | + | if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { | |
| 47 | + | pi += 1; | |
| 48 | + | ti += 1; | |
| 49 | + | } else if pi < p.len() && p[pi] == '*' { | |
| 50 | + | star = Some((pi, ti)); | |
| 51 | + | pi += 1; | |
| 52 | + | } else if let Some((sp, st)) = star { | |
| 53 | + | pi = sp + 1; | |
| 54 | + | ti = st + 1; | |
| 55 | + | star = Some((sp, st + 1)); | |
| 56 | + | } else { | |
| 57 | + | return false; | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | p[pi..].iter().all(|&c| c == '*') | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | pub fn decode(path: &Path, d: &Decoder, opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 64 | + | let file = path.as_os_str(); | |
| 65 | + | let mut args = d.run.iter().map(|a| { | |
| 66 | + | let a = match (a.strip_prefix("~/"), std::env::var_os("HOME")) { | |
| 67 | + | (Some(rest), Some(home)) => format!("{}/{rest}", home.to_string_lossy()), | |
| 68 | + | _ => a.clone(), | |
| 69 | + | }; | |
| 70 | + | // `{file}` stands for the path, also inside a larger argument (`{file}[0]`). | |
| 71 | + | match a.split_once("{file}") { | |
| 72 | + | Some((pre, post)) => { | |
| 73 | + | let mut s = std::ffi::OsString::from(pre); | |
| 74 | + | s.push(file); | |
| 75 | + | s.push(post); | |
| 76 | + | s | |
| 77 | + | } | |
| 78 | + | None => a.into(), | |
| 79 | + | } | |
| 80 | + | }); | |
| 81 | + | let program = args | |
| 82 | + | .next() | |
| 83 | + | .ok_or_else(|| anyhow!("empty decoder command"))?; | |
| 84 | + | let mut child = Command::new(&program) | |
| 85 | + | .args(args) | |
| 86 | + | .stdin(Stdio::null()) | |
| 87 | + | .stdout(Stdio::piped()) | |
| 88 | + | .stderr(Stdio::piped()) | |
| 89 | + | .spawn() | |
| 90 | + | .with_context(|| program.to_string_lossy().into_owned())?; | |
| 91 | + | let mut out = Vec::new(); | |
| 92 | + | if let Some(mut s) = child.stdout.take() { | |
| 93 | + | s.read_to_end(&mut out)?; | |
| 94 | + | } | |
| 95 | + | let mut err = String::new(); | |
| 96 | + | if let Some(mut s) = child.stderr.take() { | |
| 97 | + | let _ = s.read_to_string(&mut err); | |
| 98 | + | } | |
| 99 | + | let status = child.wait()?; | |
| 100 | + | if !status.success() { | |
| 101 | + | let first = err.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); | |
| 102 | + | return Err(anyhow!( | |
| 103 | + | "{} failed: {status} {first}", | |
| 104 | + | program.to_string_lossy() | |
| 105 | + | )); | |
| 106 | + | } | |
| 107 | + | let mut img = image_rs::decode_bytes(&out, opts)?; | |
| 108 | + | img.format = "EXT"; | |
| 109 | + | Ok(img) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | #[cfg(test)] | |
| 113 | + | mod tests { | |
| 114 | + | use super::*; | |
| 115 | + | ||
| 116 | + | #[test] | |
| 117 | + | fn globbing() { | |
| 118 | + | assert!(glob("*.psd", "a.psd")); | |
| 119 | + | assert!(glob("*.psd", ".psd")); | |
| 120 | + | assert!(!glob("*.psd", "a.psd.bak")); | |
| 121 | + | assert!(glob("img-??.x*", "img-01.xcf")); | |
| 122 | + | assert!(glob("*a*b*", "xaxxbx")); | |
| 123 | + | assert!(!glob("a?", "a")); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[test] | |
| 127 | + | fn decodes_through_a_command() { | |
| 128 | + | let dir = std::env::temp_dir().join(format!("remage-ext-{}", std::process::id())); | |
| 129 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 130 | + | let png = dir.join("src.png"); | |
| 131 | + | image::RgbaImage::from_pixel(3, 2, image::Rgba([1, 2, 3, 255])) | |
| 132 | + | .save(&png) | |
| 133 | + | .unwrap(); | |
| 134 | + | let fake = dir.join("pic.fake"); | |
| 135 | + | std::fs::copy(&png, &fake).unwrap(); | |
| 136 | + | let d = Decoder { | |
| 137 | + | patterns: vec!["*.FAKE".into()], | |
| 138 | + | run: vec!["cat".into(), "{file}".into()], | |
| 139 | + | }; | |
| 140 | + | set_decoders(std::slice::from_ref(&d)); | |
| 141 | + | assert!(find(&fake).is_some()); | |
| 142 | + | assert!(find(&png).is_none()); | |
| 143 | + | let img = decode(&fake, &d, &Options::default()).unwrap(); | |
| 144 | + | assert_eq!((img.size, img.format), ([3, 2], "EXT")); | |
| 145 | + | let bad = Decoder { | |
| 146 | + | patterns: vec![], | |
| 147 | + | run: vec!["sh".into(), "-c".into(), "echo nope >&2; exit 2".into()], | |
| 148 | + | }; | |
| 149 | + | let e = decode(&fake, &bad, &Options::default()) | |
| 150 | + | .unwrap_err() | |
| 151 | + | .to_string(); | |
| 152 | + | assert!(e.contains("nope"), "{e}"); | |
| 153 | + | set_decoders(&[]); | |
| 154 | + | } | |
| 155 | + | } | |
| @@ -0,0 +1,42 @@ | |||
| 1 | + | //! HEIC/HEIF through the system libheif. libheif applies the file's rotation and mirroring. | |
| 2 | + | ||
| 3 | + | use std::path::Path; | |
| 4 | + | ||
| 5 | + | use anyhow::anyhow; | |
| 6 | + | use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma}; | |
| 7 | + | ||
| 8 | + | use super::color::ToSrgb; | |
| 9 | + | use super::{DecodedImage, Options}; | |
| 10 | + | ||
| 11 | + | pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 12 | + | let bytes = std::fs::read(path)?; | |
| 13 | + | let ctx = HeifContext::read_from_bytes(&bytes)?; | |
| 14 | + | let handle = ctx.primary_image_handle()?; | |
| 15 | + | let lib = LibHeif::new(); | |
| 16 | + | let image = lib.decode(&handle, ColorSpace::Rgb(RgbChroma::Rgba), None)?; | |
| 17 | + | let planes = image.planes(); | |
| 18 | + | let plane = planes | |
| 19 | + | .interleaved | |
| 20 | + | .ok_or_else(|| anyhow!("no interleaved plane"))?; | |
| 21 | + | let (w, h) = (plane.width, plane.height); | |
| 22 | + | let row = w as usize * 4; | |
| 23 | + | let mut pixels = Vec::with_capacity(row * h as usize); | |
| 24 | + | for y in 0..h as usize { | |
| 25 | + | pixels.extend_from_slice(&plane.data[y * plane.stride..][..row]); | |
| 26 | + | } | |
| 27 | + | if opts.color { | |
| 28 | + | let transform = match handle.color_profile_raw().filter(|p| !p.data.is_empty()) { | |
| 29 | + | Some(icc) => ToSrgb::from_icc(&icc.data), | |
| 30 | + | None => handle.color_profile_nclx().and_then(|n| { | |
| 31 | + | ToSrgb::from_cicp( | |
| 32 | + | n.color_primaries() as u8, | |
| 33 | + | n.transfer_characteristics() as u8, | |
| 34 | + | ) | |
| 35 | + | }), | |
| 36 | + | }; | |
| 37 | + | if let Some(t) = transform { | |
| 38 | + | t.apply(&mut pixels); | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | Ok(DecodedImage::still([w, h], pixels, "HEIF")) | |
| 42 | + | } | |
| @@ -0,0 +1,297 @@ | |||
| 1 | + | //! The `image` crate: stills (with ICC and EXIF orientation), GIF/APNG/WebP animation, and | |
| 2 | + | //! multi-page TIFF. | |
| 3 | + | ||
| 4 | + | use std::fs::File; | |
| 5 | + | use std::io::{BufRead, BufReader, Cursor, Seek}; | |
| 6 | + | use std::path::Path; | |
| 7 | + | use std::time::Duration; | |
| 8 | + | ||
| 9 | + | use anyhow::{Context, anyhow}; | |
| 10 | + | use image::codecs::gif::GifDecoder; | |
| 11 | + | use image::codecs::png::PngDecoder; | |
| 12 | + | use image::codecs::webp::WebPDecoder; | |
| 13 | + | use image::metadata::Orientation; | |
| 14 | + | use image::{AnimationDecoder, DynamicImage, ImageDecoder, ImageFormat, ImageReader}; | |
| 15 | + | ||
| 16 | + | use super::color::{self, ToSrgb}; | |
| 17 | + | use super::{DecodedImage, Frame, MAX_ALLOC, MAX_FRAMES_BYTES, Options, label}; | |
| 18 | + | ||
| 19 | + | /// Browsers show GIF frames with tiny delays at this pace, and so do we. | |
| 20 | + | const MIN_DELAY: Duration = Duration::from_millis(20); | |
| 21 | + | const DEFAULT_DELAY: Duration = Duration::from_millis(100); | |
| 22 | + | ||
| 23 | + | fn limits() -> image::Limits { | |
| 24 | + | let mut l = image::Limits::default(); | |
| 25 | + | l.max_alloc = Some(MAX_ALLOC); | |
| 26 | + | l | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | pub fn decode_file( | |
| 30 | + | path: &Path, | |
| 31 | + | format: ImageFormat, | |
| 32 | + | opts: &Options, | |
| 33 | + | ) -> anyhow::Result<DecodedImage> { | |
| 34 | + | let open = || -> anyhow::Result<BufReader<File>> { Ok(BufReader::new(File::open(path)?)) }; | |
| 35 | + | let icc = |d: &mut dyn ImageDecoder| { | |
| 36 | + | if opts.color { | |
| 37 | + | d.icc_profile().ok().flatten() | |
| 38 | + | } else { | |
| 39 | + | None | |
| 40 | + | } | |
| 41 | + | }; | |
| 42 | + | let animated = match format { | |
| 43 | + | ImageFormat::Gif => { | |
| 44 | + | let mut d = GifDecoder::new(open()?)?; | |
| 45 | + | let icc = icc(&mut d); | |
| 46 | + | animation(d, icc, format)? | |
| 47 | + | } | |
| 48 | + | ImageFormat::Png => { | |
| 49 | + | let mut d = PngDecoder::new(open()?)?; | |
| 50 | + | if d.is_apng()? { | |
| 51 | + | let icc = icc(&mut d); | |
| 52 | + | animation(d.apng()?, icc, format)? | |
| 53 | + | } else { | |
| 54 | + | None | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ImageFormat::WebP => { | |
| 58 | + | let mut d = WebPDecoder::new(open()?)?; | |
| 59 | + | if d.has_animation() { | |
| 60 | + | let icc = icc(&mut d); | |
| 61 | + | animation(d, icc, format)? | |
| 62 | + | } else { | |
| 63 | + | None | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | _ => None, | |
| 67 | + | }; | |
| 68 | + | if let Some(img) = animated { | |
| 69 | + | return Ok(img); | |
| 70 | + | } | |
| 71 | + | let mut img = decode_reader(open()?, format, opts)?; | |
| 72 | + | if format == ImageFormat::Tiff { | |
| 73 | + | add_tiff_pages(path, &mut img, opts); | |
| 74 | + | } | |
| 75 | + | Ok(img) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// A still image from any reader: ICC to sRGB, float tone-mapped, oriented upright. | |
| 79 | + | pub fn decode_reader<R: BufRead + Seek>( | |
| 80 | + | reader: R, | |
| 81 | + | format: ImageFormat, | |
| 82 | + | opts: &Options, | |
| 83 | + | ) -> anyhow::Result<DecodedImage> { | |
| 84 | + | let mut reader = ImageReader::with_format(reader, format); | |
| 85 | + | reader.limits(limits()); | |
| 86 | + | let mut decoder = reader.into_decoder()?; | |
| 87 | + | let orientation = if opts.auto_orient { | |
| 88 | + | decoder.orientation().unwrap_or(Orientation::NoTransforms) | |
| 89 | + | } else { | |
| 90 | + | Orientation::NoTransforms | |
| 91 | + | }; | |
| 92 | + | let icc = if opts.color { | |
| 93 | + | decoder.icc_profile().ok().flatten() | |
| 94 | + | } else { | |
| 95 | + | None | |
| 96 | + | }; | |
| 97 | + | let img = DynamicImage::from_decoder(decoder)?; | |
| 98 | + | let float = color::is_float(&img); | |
| 99 | + | let (size, mut pixels) = color::to_rgba8(img); | |
| 100 | + | // Float data is linear scene light; an embedded profile doesn't describe the 8-bit result. | |
| 101 | + | if !float && let Some(t) = icc.as_deref().and_then(ToSrgb::from_icc) { | |
| 102 | + | t.apply(&mut pixels); | |
| 103 | + | } | |
| 104 | + | let (size, pixels) = color::orient(size, pixels, orientation); | |
| 105 | + | Ok(DecodedImage::still(size, pixels, label(format))) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// Decode from memory, guessing the format from the bytes (external decoders' output). | |
| 109 | + | pub fn decode_bytes(bytes: &[u8], opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 110 | + | let format = image::guess_format(bytes).context("unrecognised output")?; | |
| 111 | + | decode_reader(Cursor::new(bytes), format, opts) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// All frames of an animation, or `None` when it has only one (decode it as a still, which | |
| 115 | + | /// keeps its orientation). | |
| 116 | + | fn animation<'a>( | |
| 117 | + | decoder: impl AnimationDecoder<'a>, | |
| 118 | + | icc: Option<Vec<u8>>, | |
| 119 | + | format: ImageFormat, | |
| 120 | + | ) -> anyhow::Result<Option<DecodedImage>> { | |
| 121 | + | let transform = icc.as_deref().and_then(ToSrgb::from_icc); | |
| 122 | + | let mut frames = Vec::new(); | |
| 123 | + | let mut size = [0, 0]; | |
| 124 | + | let mut bytes = 0; | |
| 125 | + | for frame in decoder.into_frames() { | |
| 126 | + | let frame = frame?; | |
| 127 | + | let (num, den) = frame.delay().numer_denom_ms(); | |
| 128 | + | let ms = num.checked_div(den).unwrap_or(0); | |
| 129 | + | let delay = match Duration::from_millis(ms as u64) { | |
| 130 | + | d if d < MIN_DELAY => DEFAULT_DELAY, | |
| 131 | + | d => d, | |
| 132 | + | }; | |
| 133 | + | let buf = frame.into_buffer(); | |
| 134 | + | size = [buf.width(), buf.height()]; | |
| 135 | + | let mut pixels = buf.into_raw(); | |
| 136 | + | if let Some(t) = &transform { | |
| 137 | + | t.apply(&mut pixels); | |
| 138 | + | } | |
| 139 | + | bytes += pixels.len(); | |
| 140 | + | frames.push(Frame { pixels, delay }); | |
| 141 | + | if bytes > MAX_FRAMES_BYTES { | |
| 142 | + | log::warn!("animation truncated at {} frames", frames.len()); | |
| 143 | + | break; | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | match frames.len() { | |
| 147 | + | 0 => Err(anyhow!("no frames")), | |
| 148 | + | 1 => Ok(None), | |
| 149 | + | n => { | |
| 150 | + | log::debug!("{n} frames"); | |
| 151 | + | Ok(Some(DecodedImage { | |
| 152 | + | size, | |
| 153 | + | frames, | |
| 154 | + | animated: true, | |
| 155 | + | format: label(format), | |
| 156 | + | source_size: size, | |
| 157 | + | refine: None, | |
| 158 | + | })) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Pages after the first, as extra (untimed) frames. Pages of another size are skipped. | |
| 164 | + | fn add_tiff_pages(path: &Path, img: &mut DecodedImage, opts: &Options) { | |
| 165 | + | let Ok(file) = File::open(path) else { return }; | |
| 166 | + | let Ok(mut d) = tiff::decoder::Decoder::new(BufReader::new(file)) else { | |
| 167 | + | return; | |
| 168 | + | }; | |
| 169 | + | let mut bytes = img.bytes(); | |
| 170 | + | while d.more_images() { | |
| 171 | + | if d.next_image().is_err() { | |
| 172 | + | break; | |
| 173 | + | } | |
| 174 | + | match tiff_page(&mut d) { | |
| 175 | + | 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 { | |
| 180 | + | log::debug!("TIFF page of another size skipped"); | |
| 181 | + | continue; | |
| 182 | + | } | |
| 183 | + | bytes += pixels.len(); | |
| 184 | + | img.frames.push(Frame { | |
| 185 | + | pixels, | |
| 186 | + | delay: Duration::ZERO, | |
| 187 | + | }); | |
| 188 | + | } | |
| 189 | + | Err(e) => log::debug!("TIFF page skipped: {e:#}"), | |
| 190 | + | } | |
| 191 | + | if bytes > MAX_FRAMES_BYTES { | |
| 192 | + | log::warn!("multi-page TIFF truncated at {} pages", img.frames.len()); | |
| 193 | + | break; | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | fn orientation_of<R: std::io::Read + Seek>( | |
| 199 | + | d: &mut tiff::decoder::Decoder<R>, | |
| 200 | + | opts: &Options, | |
| 201 | + | ) -> Orientation { | |
| 202 | + | if !opts.auto_orient { | |
| 203 | + | return Orientation::NoTransforms; | |
| 204 | + | } | |
| 205 | + | d.find_tag_unsigned::<u8>(tiff::tags::Tag::Orientation) | |
| 206 | + | .ok() | |
| 207 | + | .flatten() | |
| 208 | + | .and_then(Orientation::from_exif) | |
| 209 | + | .unwrap_or(Orientation::NoTransforms) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | fn tiff_page<R: std::io::Read + Seek>( | |
| 213 | + | d: &mut tiff::decoder::Decoder<R>, | |
| 214 | + | ) -> anyhow::Result<([u32; 2], Vec<u8>)> { | |
| 215 | + | use tiff::ColorType as C; | |
| 216 | + | use tiff::decoder::DecodingResult as D; | |
| 217 | + | let (w, h) = d.dimensions()?; | |
| 218 | + | let channels = match d.colortype()? { | |
| 219 | + | C::Gray(8 | 16) => 1, | |
| 220 | + | C::GrayA(8 | 16) => 2, | |
| 221 | + | C::RGB(8 | 16) => 3, | |
| 222 | + | C::RGBA(8 | 16) => 4, | |
| 223 | + | other => return Err(anyhow!("unsupported colour type {other:?}")), | |
| 224 | + | }; | |
| 225 | + | let samples: Vec<u8> = match d.read_image()? { | |
| 226 | + | D::U8(v) => v, | |
| 227 | + | D::U16(v) => v.into_iter().map(|s| (s >> 8) as u8).collect(), | |
| 228 | + | _ => return Err(anyhow!("unsupported sample format")), | |
| 229 | + | }; | |
| 230 | + | if samples.len() < (w * h) as usize * channels { | |
| 231 | + | return Err(anyhow!("short page")); | |
| 232 | + | } | |
| 233 | + | Ok(([w, h], color::expand(&samples, channels))) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[cfg(test)] | |
| 237 | + | mod tests { | |
| 238 | + | use super::*; | |
| 239 | + | use image::codecs::gif::GifEncoder; | |
| 240 | + | use image::{Delay, Rgba, RgbaImage}; | |
| 241 | + | ||
| 242 | + | fn dir() -> std::path::PathBuf { | |
| 243 | + | let d = std::env::temp_dir().join(format!("remage-imgrs-{}", std::process::id())); | |
| 244 | + | std::fs::create_dir_all(&d).unwrap(); | |
| 245 | + | d | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn gif_animation_keeps_frames_and_delays() { | |
| 250 | + | let path = dir().join("a.gif"); | |
| 251 | + | { | |
| 252 | + | let mut enc = GifEncoder::new(File::create(&path).unwrap()); | |
| 253 | + | for (c, ms) in [(255, 50), (0, 0)] { | |
| 254 | + | let buf = RgbaImage::from_pixel(4, 3, Rgba([c, 0, 0, 255])); | |
| 255 | + | let f = image::Frame::from_parts(buf, 0, 0, Delay::from_numer_denom_ms(ms, 1)); | |
| 256 | + | enc.encode_frame(f).unwrap(); | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | let img = decode_file(&path, ImageFormat::Gif, &Options::default()).unwrap(); | |
| 260 | + | assert!(img.animated); | |
| 261 | + | assert_eq!(img.size, [4, 3]); | |
| 262 | + | assert_eq!( | |
| 263 | + | img.delays(), | |
| 264 | + | [Duration::from_millis(50), DEFAULT_DELAY], | |
| 265 | + | "a zero delay plays at the browser pace" | |
| 266 | + | ); | |
| 267 | + | assert_eq!(img.frame(1)[0], 0); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn multi_page_tiff() { | |
| 272 | + | use tiff::encoder::{TiffEncoder, colortype}; | |
| 273 | + | let path = dir().join("pages.tif"); | |
| 274 | + | { | |
| 275 | + | let mut enc = TiffEncoder::new(File::create(&path).unwrap()).unwrap(); | |
| 276 | + | enc.write_image::<colortype::RGB8>(2, 2, &[10; 12]).unwrap(); | |
| 277 | + | enc.write_image::<colortype::Gray8>(2, 2, &[200; 4]) | |
| 278 | + | .unwrap(); | |
| 279 | + | enc.write_image::<colortype::Gray8>(3, 1, &[0; 3]).unwrap(); | |
| 280 | + | } | |
| 281 | + | let img = decode_file(&path, ImageFormat::Tiff, &Options::default()).unwrap(); | |
| 282 | + | assert!(!img.animated); | |
| 283 | + | assert_eq!(img.frames.len(), 2, "the odd-sized page is skipped"); | |
| 284 | + | assert_eq!(&img.frame(1)[..4], [200, 200, 200, 255]); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[test] | |
| 288 | + | fn float_images_are_tonemapped() { | |
| 289 | + | let path = dir().join("hdr.exr"); | |
| 290 | + | let buf = image::Rgb32FImage::from_pixel(2, 1, image::Rgb([4.0, 0.5, 0.0])); | |
| 291 | + | DynamicImage::ImageRgb32F(buf).save(&path).unwrap(); | |
| 292 | + | let img = decode_file(&path, ImageFormat::OpenExr, &Options::default()).unwrap(); | |
| 293 | + | assert_eq!(img.format, "EXR"); | |
| 294 | + | let px = img.pixels(); | |
| 295 | + | assert!(px[0] > px[1] && px[0] < 255 && px[2] == 0, "{px:?}"); | |
| 296 | + | } | |
| 297 | + | } | |
| @@ -0,0 +1,67 @@ | |||
| 1 | + | //! JPEG XL through `jxl-oxide`, rendered to sRGB (moxcms handles ICC-encoded files), with | |
| 2 | + | //! animation. | |
| 3 | + | ||
| 4 | + | use std::path::Path; | |
| 5 | + | use std::time::Duration; | |
| 6 | + | ||
| 7 | + | use anyhow::anyhow; | |
| 8 | + | use jxl_oxide::{EnumColourEncoding, JxlImage, Moxcms, RenderingIntent}; | |
| 9 | + | ||
| 10 | + | use super::color; | |
| 11 | + | use super::{DecodedImage, Frame, MAX_FRAMES_BYTES, Options}; | |
| 12 | + | ||
| 13 | + | pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 14 | + | let mut image = JxlImage::builder().open(path).map_err(|e| anyhow!("{e}"))?; | |
| 15 | + | if opts.color { | |
| 16 | + | image.set_cms(Moxcms); | |
| 17 | + | image.request_color_encoding(EnumColourEncoding::srgb(RenderingIntent::Relative)); | |
| 18 | + | } | |
| 19 | + | // Seconds per tick of a frame's duration. | |
| 20 | + | let tick = image | |
| 21 | + | .image_header() | |
| 22 | + | .metadata | |
| 23 | + | .animation | |
| 24 | + | .as_ref() | |
| 25 | + | .filter(|a| a.tps_numerator > 0) | |
| 26 | + | .map(|a| a.tps_denominator as f64 / a.tps_numerator as f64); | |
| 27 | + | let count = image.num_loaded_keyframes(); | |
| 28 | + | let mut frames = Vec::new(); | |
| 29 | + | let mut size = [0, 0]; | |
| 30 | + | let mut bytes = 0; | |
| 31 | + | for i in 0..count { | |
| 32 | + | let render = image.render_frame(i).map_err(|e| anyhow!("{e}"))?; | |
| 33 | + | let mut stream = render.stream(); | |
| 34 | + | let channels = stream.channels() as usize; | |
| 35 | + | size = [stream.width(), stream.height()]; | |
| 36 | + | let mut buf = vec![0u8; size[0] as usize * size[1] as usize * channels]; | |
| 37 | + | stream.write_to_buffer(&mut buf); | |
| 38 | + | // Extra channels (CMYK black, spot colours) beyond RGBA are dropped. | |
| 39 | + | let pixels = if channels > 4 { | |
| 40 | + | buf.chunks_exact(channels) | |
| 41 | + | .flat_map(|p| [p[0], p[1], p[2], p[3]]) | |
| 42 | + | .collect() | |
| 43 | + | } else { | |
| 44 | + | color::expand(&buf, channels) | |
| 45 | + | }; | |
| 46 | + | let delay = tick.map_or(Duration::ZERO, |t| { | |
| 47 | + | Duration::from_secs_f64(render.duration() as f64 * t) | |
| 48 | + | }); | |
| 49 | + | bytes += pixels.len(); | |
| 50 | + | frames.push(Frame { pixels, delay }); | |
| 51 | + | if bytes > MAX_FRAMES_BYTES { | |
| 52 | + | log::warn!("animation truncated at {} frames", frames.len()); | |
| 53 | + | break; | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if frames.is_empty() { | |
| 57 | + | return Err(anyhow!("no frames")); | |
| 58 | + | } | |
| 59 | + | Ok(DecodedImage { | |
| 60 | + | size, | |
| 61 | + | animated: tick.is_some() && frames.len() > 1, | |
| 62 | + | frames, | |
| 63 | + | format: "JXL", | |
| 64 | + | source_size: size, | |
| 65 | + | refine: None, | |
| 66 | + | }) | |
| 67 | + | } | |
| @@ -1,31 +1,134 @@ | |||
| 1 | 1 | //! Format sniffing and dispatch to the decoder backends. | |
| 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. | |
| 2 | 6 | ||
| 3 | 7 | pub mod color; | |
| 8 | + | mod external; | |
| 9 | + | #[cfg(feature = "heif")] | |
| 10 | + | mod heif; | |
| 11 | + | mod image_rs; | |
| 12 | + | #[cfg(feature = "jxl")] | |
| 13 | + | mod jxl; | |
| 14 | + | #[cfg(feature = "raw")] | |
| 15 | + | mod raw; | |
| 16 | + | #[cfg(feature = "svg")] | |
| 17 | + | mod svg; | |
| 4 | 18 | ||
| 5 | 19 | use std::fs::File; | |
| 6 | 20 | use std::io::Read; | |
| 7 | 21 | use std::path::Path; | |
| 8 | − | use std::sync::LazyLock; | |
| 22 | + | use std::sync::{Arc, LazyLock}; | |
| 23 | + | use std::time::Duration; | |
| 9 | 24 | ||
| 10 | − | use anyhow::{Context, anyhow}; | |
| 11 | − | use fast_image_resize as fir; | |
| 12 | − | use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, metadata::Orientation}; | |
| 25 | + | use anyhow::anyhow; | |
| 26 | + | use image::ImageFormat; | |
| 27 | + | ||
| 28 | + | pub use external::set_decoders; | |
| 13 | 29 | ||
| 14 | 30 | /// Refuse single images that would need more than this to decode. | |
| 15 | 31 | const MAX_ALLOC: u64 = 4 << 30; | |
| 32 | + | /// Animations and multi-page files stop adding frames past this many bytes. | |
| 33 | + | const MAX_FRAMES_BYTES: usize = 1 << 30; | |
| 34 | + | ||
| 35 | + | /// One frame of an animation or one page of a document, `size` RGBA8 pixels. | |
| 36 | + | pub struct Frame { | |
| 37 | + | pub pixels: Vec<u8>, | |
| 38 | + | /// How long an animation frame shows; zero for pages. | |
| 39 | + | pub delay: Duration, | |
| 40 | + | } | |
| 16 | 41 | ||
| 17 | 42 | /// Straight-alpha RGBA8 in sRGB, oriented upright, ready to upload. | |
| 18 | 43 | pub struct DecodedImage { | |
| 44 | + | /// Pixel size of every frame. | |
| 19 | 45 | pub size: [u32; 2], | |
| 20 | − | pub pixels: Vec<u8>, | |
| 46 | + | /// At least one. | |
| 47 | + | pub frames: Vec<Frame>, | |
| 48 | + | /// Frames are timed animation rather than pages. | |
| 49 | + | pub animated: bool, | |
| 21 | 50 | pub format: &'static str, | |
| 22 | − | /// Size before any downscale to fit the GPU's texture limit. | |
| 51 | + | /// The size the view works in: the image's own pixels. Scalable or previewed sources | |
| 52 | + | /// (SVG, RAW) may hold fewer or more pixels than this. | |
| 23 | 53 | pub source_size: [u32; 2], | |
| 54 | + | /// A sharper version can be made on demand (SVG re-raster, full RAW develop). | |
| 55 | + | pub refine: Option<Refine>, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// How to make a sharper version of an image. | |
| 59 | + | #[derive(Clone)] | |
| 60 | + | pub enum Refine { | |
| 61 | + | #[cfg(feature = "svg")] | |
| 62 | + | Svg(Arc<svg::Svg>), | |
| 63 | + | /// The embedded preview is showing; develop the raw data in full. | |
| 64 | + | #[cfg(feature = "raw")] | |
| 65 | + | Raw, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | impl Refine { | |
| 69 | + | /// Make a version whose longer side is about `long` pixels. | |
| 70 | + | pub fn run(&self, path: &Path, long: u32, opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 71 | + | let _ = (path, long, opts); | |
| 72 | + | match self { | |
| 73 | + | #[cfg(feature = "svg")] | |
| 74 | + | Refine::Svg(s) => Ok(s.rasterize(long)), | |
| 75 | + | #[cfg(feature = "raw")] | |
| 76 | + | Refine::Raw => raw::develop(path, opts), | |
| 77 | + | #[allow(unreachable_patterns)] | |
| 78 | + | _ => unreachable!(), | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Whether a texture of `have` pixels on its longer side should be remade for `want`. | |
| 83 | + | pub fn wants(&self, have: u32, want: u32) -> Option<u32> { | |
| 84 | + | let _ = (have, want); | |
| 85 | + | match self { | |
| 86 | + | #[cfg(feature = "svg")] | |
| 87 | + | Refine::Svg(_) => { | |
| 88 | + | let want = want.clamp(16, svg::MAX_RASTER); | |
| 89 | + | // Sharper when zoomed in; smaller again when zoomed far out. | |
| 90 | + | (want > have + have / 8 || want < have / 3).then_some(want) | |
| 91 | + | } | |
| 92 | + | #[cfg(feature = "raw")] | |
| 93 | + | Refine::Raw => (want > have).then_some(u32::MAX), | |
| 94 | + | #[allow(unreachable_patterns)] | |
| 95 | + | _ => None, | |
| 96 | + | } | |
| 97 | + | } | |
| 24 | 98 | } | |
| 25 | 99 | ||
| 26 | 100 | impl DecodedImage { | |
| 101 | + | pub fn still(size: [u32; 2], pixels: Vec<u8>, format: &'static str) -> Self { | |
| 102 | + | Self { | |
| 103 | + | size, | |
| 104 | + | frames: vec![Frame { | |
| 105 | + | pixels, | |
| 106 | + | delay: Duration::ZERO, | |
| 107 | + | }], | |
| 108 | + | animated: false, | |
| 109 | + | format, | |
| 110 | + | source_size: size, | |
| 111 | + | refine: None, | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 27 | 115 | pub fn bytes(&self) -> usize { | |
| 28 | − | self.pixels.len() | |
| 116 | + | self.frames.iter().map(|f| f.pixels.len()).sum() | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// The first frame's pixels. | |
| 120 | + | #[cfg(test)] | |
| 121 | + | pub fn pixels(&self) -> &[u8] { | |
| 122 | + | &self.frames[0].pixels | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | pub fn frame(&self, i: usize) -> &[u8] { | |
| 126 | + | &self.frames[i.min(self.frames.len() - 1)].pixels | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Frame delays when there is more than one frame. | |
| 130 | + | pub fn delays(&self) -> Vec<Duration> { | |
| 131 | + | self.frames.iter().map(|f| f.delay).collect() | |
| 29 | 132 | } | |
| 30 | 133 | } | |
| 31 | 134 | ||
| @@ -33,36 +136,171 @@ impl std::fmt::Debug for DecodedImage { | |||
| 33 | 136 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 34 | 137 | f.debug_struct("DecodedImage") | |
| 35 | 138 | .field("size", &self.size) | |
| 139 | + | .field("frames", &self.frames.len()) | |
| 36 | 140 | .field("format", &self.format) | |
| 37 | 141 | .finish_non_exhaustive() | |
| 38 | 142 | } | |
| 39 | 143 | } | |
| 40 | 144 | ||
| 145 | + | /// How to decode. | |
| 146 | + | #[derive(Clone, Copy, Debug)] | |
| 147 | + | pub struct Options { | |
| 148 | + | /// About how many pixels the longer side needs, for sources that can be rendered at any | |
| 149 | + | /// size (SVG) or offer a preview (RAW). | |
| 150 | + | pub target: u32, | |
| 151 | + | pub auto_orient: bool, | |
| 152 | + | pub color: bool, | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | impl Default for Options { | |
| 156 | + | fn default() -> Self { | |
| 157 | + | Self { | |
| 158 | + | target: 2048, | |
| 159 | + | auto_orient: true, | |
| 160 | + | color: true, | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[cfg(feature = "raw")] | |
| 166 | + | const RAW_EXTENSIONS: &[&str] = &[ | |
| 167 | + | "3fr", "ari", "arw", "cr2", "cr3", "crw", "dcr", "dcs", "dng", "erf", "iiq", "kdc", "mef", | |
| 168 | + | "mos", "mrw", "nef", "nrw", "orf", "ori", "pef", "raf", "raw", "rw2", "rwl", "sr2", "srf", | |
| 169 | + | "srw", "x3f", | |
| 170 | + | ]; | |
| 171 | + | #[cfg(not(feature = "raw"))] | |
| 172 | + | const RAW_EXTENSIONS: &[&str] = &[]; | |
| 173 | + | ||
| 41 | 174 | static EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| { | |
| 42 | − | ImageFormat::all() | |
| 175 | + | let mut v: Vec<&'static str> = ImageFormat::all() | |
| 43 | 176 | .filter(ImageFormat::reading_enabled) | |
| 44 | 177 | .flat_map(|f| f.extensions_str().iter().copied()) | |
| 45 | 178 | .chain(["apng", "jfif"]) | |
| 46 | − | .collect() | |
| 179 | + | .collect(); | |
| 180 | + | if cfg!(feature = "avif") { | |
| 181 | + | v.push("avif"); | |
| 182 | + | } | |
| 183 | + | if cfg!(feature = "jxl") { | |
| 184 | + | v.push("jxl"); | |
| 185 | + | } | |
| 186 | + | if cfg!(feature = "heif") { | |
| 187 | + | v.extend(["heic", "heif", "hif"]); | |
| 188 | + | } | |
| 189 | + | if cfg!(feature = "svg") { | |
| 190 | + | v.extend(["svg", "svgz"]); | |
| 191 | + | } | |
| 192 | + | v.extend(RAW_EXTENSIONS); | |
| 193 | + | v | |
| 47 | 194 | }); | |
| 48 | 195 | ||
| 49 | − | /// Whether the extension alone says we can decode this file. | |
| 50 | − | pub fn is_known_extension(path: &Path) -> bool { | |
| 196 | + | fn extension(path: &Path) -> Option<String> { | |
| 51 | 197 | path.extension() | |
| 52 | 198 | .and_then(|e| e.to_str()) | |
| 53 | − | .is_some_and(|e| EXTENSIONS.iter().any(|k| k.eq_ignore_ascii_case(e))) | |
| 199 | + | .map(str::to_ascii_lowercase) | |
| 54 | 200 | } | |
| 55 | 201 | ||
| 56 | − | /// Check magic bytes for files whose extension is unknown. | |
| 202 | + | /// Whether the extension alone (or an external decoder) says we can decode this file. | |
| 203 | + | pub fn is_known_extension(path: &Path) -> bool { | |
| 204 | + | extension(path).is_some_and(|e| EXTENSIONS.contains(&e.as_str())) | |
| 205 | + | || external::find(path).is_some() | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// Which backend decodes a file. | |
| 209 | + | #[derive(Clone, Debug, PartialEq)] | |
| 210 | + | enum Kind { | |
| 211 | + | Image(ImageFormat), | |
| 212 | + | Jxl, | |
| 213 | + | Heif, | |
| 214 | + | Svg, | |
| 215 | + | Raw, | |
| 216 | + | External(crate::config::Decoder), | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// The ISO-BMFF `ftyp` brands, major first. | |
| 220 | + | fn ftyp_brands(head: &[u8]) -> Vec<&[u8]> { | |
| 221 | + | if head.len() < 16 || &head[4..8] != b"ftyp" { | |
| 222 | + | return Vec::new(); | |
| 223 | + | } | |
| 224 | + | let len = u32::from_be_bytes([head[0], head[1], head[2], head[3]]) as usize; | |
| 225 | + | let end = len.clamp(16, head.len()); | |
| 226 | + | let mut brands = vec![&head[8..12]]; | |
| 227 | + | brands.extend(head[16..end].chunks_exact(4)); | |
| 228 | + | brands | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | fn looks_like_svg(head: &[u8]) -> bool { | |
| 232 | + | let text = String::from_utf8_lossy(head); | |
| 233 | + | let t = text.trim_start_matches('\u{feff}').trim_start(); | |
| 234 | + | (t.starts_with("<?xml") || t.starts_with("<svg") || t.starts_with("<!--")) | |
| 235 | + | && text.contains("<svg") | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | /// Magic bytes first, the extension second. External decoders configured for a file win. | |
| 239 | + | fn detect(path: &Path, head: &[u8]) -> Option<Kind> { | |
| 240 | + | if let Some(d) = external::find(path) { | |
| 241 | + | return Some(Kind::External(d)); | |
| 242 | + | } | |
| 243 | + | let ext = extension(path).unwrap_or_default(); | |
| 244 | + | let jxl = head.starts_with(&[0xFF, 0x0A]) || head.starts_with(b"\0\0\0\x0cJXL \r\n\x87\n"); | |
| 245 | + | if jxl && cfg!(feature = "jxl") { | |
| 246 | + | return Some(Kind::Jxl); | |
| 247 | + | } | |
| 248 | + | let brands = ftyp_brands(head); | |
| 249 | + | if !brands.is_empty() { | |
| 250 | + | let has = |b: &[u8]| brands.contains(&b); | |
| 251 | + | if has(b"crx ") && cfg!(feature = "raw") { | |
| 252 | + | return Some(Kind::Raw); | |
| 253 | + | } | |
| 254 | + | if has(b"avif") || has(b"avis") { | |
| 255 | + | return Some(Kind::Image(ImageFormat::Avif)); | |
| 256 | + | } | |
| 257 | + | let heif = [ | |
| 258 | + | b"heic", b"heix", b"heim", b"heis", b"hevc", b"hevx", b"mif1", b"msf1", | |
| 259 | + | ]; | |
| 260 | + | if heif.iter().any(|b| has(*b)) && cfg!(feature = "heif") { | |
| 261 | + | return Some(Kind::Heif); | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | // Most raw formats are TIFF inside, so the extension decides before the magic does. | |
| 265 | + | if RAW_EXTENSIONS.contains(&ext.as_str()) { | |
| 266 | + | return Some(Kind::Raw); | |
| 267 | + | } | |
| 268 | + | if cfg!(feature = "svg") | |
| 269 | + | && (looks_like_svg(head) || (ext == "svgz" && head.starts_with(&[0x1F, 0x8B]))) | |
| 270 | + | { | |
| 271 | + | return Some(Kind::Svg); | |
| 272 | + | } | |
| 273 | + | if let Ok(f) = image::guess_format(head) | |
| 274 | + | && f.reading_enabled() | |
| 275 | + | { | |
| 276 | + | return Some(Kind::Image(f)); | |
| 277 | + | } | |
| 278 | + | match ext.as_str() { | |
| 279 | + | "jxl" if cfg!(feature = "jxl") => Some(Kind::Jxl), | |
| 280 | + | "heic" | "heif" | "hif" if cfg!(feature = "heif") => Some(Kind::Heif), | |
| 281 | + | "svg" | "svgz" if cfg!(feature = "svg") => Some(Kind::Svg), | |
| 282 | + | _ => ImageFormat::from_extension(&ext) | |
| 283 | + | .filter(ImageFormat::reading_enabled) | |
| 284 | + | .map(Kind::Image), | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | fn head(path: &Path) -> std::io::Result<Vec<u8>> { | |
| 289 | + | let mut buf = Vec::with_capacity(1024); | |
| 290 | + | File::open(path)?.take(1024).read_to_end(&mut buf)?; | |
| 291 | + | Ok(buf) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// Whether a file with an unknown extension is an image we can decode. | |
| 57 | 295 | pub fn sniff(path: &Path) -> bool { | |
| 58 | − | let mut buf = [0u8; 64]; | |
| 59 | − | let Ok(mut f) = File::open(path) else { | |
| 60 | − | return false; | |
| 61 | − | }; | |
| 62 | − | let Ok(n) = f.read(&mut buf) else { | |
| 63 | − | return false; | |
| 64 | − | }; | |
| 65 | − | image::guess_format(&buf[..n]).is_ok_and(|f| f.reading_enabled()) | |
| 296 | + | let Ok(head) = head(path) else { return false }; | |
| 297 | + | // An extension guess alone is not proof; require the content to say so. | |
| 298 | + | match detect(path, &head) { | |
| 299 | + | Some(Kind::Image(_)) => image::guess_format(&head).is_ok(), | |
| 300 | + | Some(Kind::Raw) => false, | |
| 301 | + | Some(_) => true, | |
| 302 | + | None => false, | |
| 303 | + | } | |
| 66 | 304 | } | |
| 67 | 305 | ||
| 68 | 306 | fn label(format: ImageFormat) -> &'static str { | |
| @@ -86,52 +324,24 @@ fn label(format: ImageFormat) -> &'static str { | |||
| 86 | 324 | } | |
| 87 | 325 | } | |
| 88 | 326 | ||
| 89 | − | /// Decode `path`, downscaling if either side exceeds `max_dim` (the GPU texture limit). | |
| 90 | − | pub fn decode(path: &Path, max_dim: u32) -> anyhow::Result<DecodedImage> { | |
| 91 | − | // `open` guesses from the extension; magic bytes take precedence when recognised. | |
| 92 | − | let mut reader = ImageReader::open(path)?.with_guessed_format()?; | |
| 93 | − | let mut limits = image::Limits::default(); | |
| 94 | − | limits.max_alloc = Some(MAX_ALLOC); | |
| 95 | − | reader.limits(limits); | |
| 96 | − | let format = reader.format().ok_or_else(|| anyhow!("unknown format"))?; | |
| 97 | − | let mut decoder = reader.into_decoder()?; | |
| 98 | − | let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms); | |
| 99 | − | let mut img = DynamicImage::from_decoder(decoder)?; | |
| 100 | − | img.apply_orientation(orientation); | |
| 101 | − | let rgba = img.into_rgba8(); | |
| 102 | − | let source_size = [rgba.width(), rgba.height()]; | |
| 103 | − | let mut out = DecodedImage { | |
| 104 | − | size: source_size, | |
| 105 | − | pixels: rgba.into_raw(), | |
| 106 | − | format: label(format), | |
| 107 | − | source_size, | |
| 108 | − | }; | |
| 109 | − | if source_size[0] > max_dim || source_size[1] > max_dim { | |
| 110 | − | out = downscale(out, max_dim)?; | |
| 111 | − | } | |
| 112 | − | Ok(out) | |
| 113 | − | } | |
| 114 | − | ||
| 115 | − | /// Shrink so neither side exceeds `max_dim`. A stopgap until M5 tiles huge images. | |
| 116 | − | pub fn downscale(img: DecodedImage, max_dim: u32) -> anyhow::Result<DecodedImage> { | |
| 117 | − | let [w, h] = img.size; | |
| 118 | − | let k = max_dim as f64 / w.max(h) as f64; | |
| 119 | − | let (nw, nh) = ( | |
| 120 | − | ((w as f64 * k) as u32).max(1), | |
| 121 | − | ((h as f64 * k) as u32).max(1), | |
| 122 | − | ); | |
| 123 | − | let src = fir::images::Image::from_vec_u8(w, h, img.pixels, fir::PixelType::U8x4) | |
| 124 | − | .context("resize source")?; | |
| 125 | − | let mut dst = fir::images::Image::new(nw, nh, fir::PixelType::U8x4); | |
| 126 | − | fir::Resizer::new() | |
| 127 | − | .resize(&src, &mut dst, None) | |
| 128 | − | .context("resize")?; | |
| 129 | − | log::debug!("downscaled {w}×{h} to {nw}×{nh} for the GPU"); | |
| 130 | − | Ok(DecodedImage { | |
| 131 | − | size: [nw, nh], | |
| 132 | − | pixels: dst.into_vec(), | |
| 133 | − | ..img | |
| 134 | − | }) | |
| 327 | + | /// Decode `path` in full: every frame, colour-converted and upright. | |
| 328 | + | pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 329 | + | let head = head(path)?; | |
| 330 | + | let kind = detect(path, &head).ok_or_else(|| anyhow!("unknown format"))?; | |
| 331 | + | match kind { | |
| 332 | + | Kind::Image(f) => image_rs::decode_file(path, f, opts), | |
| 333 | + | #[cfg(feature = "jxl")] | |
| 334 | + | Kind::Jxl => jxl::decode(path, opts), | |
| 335 | + | #[cfg(feature = "heif")] | |
| 336 | + | Kind::Heif => heif::decode(path, opts), | |
| 337 | + | #[cfg(feature = "svg")] | |
| 338 | + | Kind::Svg => svg::decode(path, opts), | |
| 339 | + | #[cfg(feature = "raw")] | |
| 340 | + | Kind::Raw => raw::decode(path, opts), | |
| 341 | + | Kind::External(d) => external::decode(path, &d, opts), | |
| 342 | + | #[allow(unreachable_patterns)] | |
| 343 | + | _ => Err(anyhow!("support for this format is not compiled in")), | |
| 344 | + | } | |
| 135 | 345 | } | |
| 136 | 346 | ||
| 137 | 347 | #[cfg(test)] | |
| @@ -144,10 +354,44 @@ mod tests { | |||
| 144 | 354 | assert!(is_known_extension(Path::new("b.png"))); | |
| 145 | 355 | assert!(!is_known_extension(Path::new("b.txt"))); | |
| 146 | 356 | assert!(!is_known_extension(Path::new("png"))); | |
| 357 | + | #[cfg(feature = "jxl")] | |
| 358 | + | assert!(is_known_extension(Path::new("a.jxl"))); | |
| 359 | + | #[cfg(feature = "raw")] | |
| 360 | + | assert!(is_known_extension(Path::new("a.NEF"))); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[test] | |
| 364 | + | fn detection_prefers_magic() { | |
| 365 | + | let p = Path::new("x.png"); | |
| 366 | + | assert_eq!( | |
| 367 | + | detect(p, b"\xFF\xD8\xFF\xE0"), | |
| 368 | + | Some(Kind::Image(ImageFormat::Jpeg)) | |
| 369 | + | ); | |
| 370 | + | let avif = b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"; | |
| 371 | + | assert_eq!(detect(p, avif), Some(Kind::Image(ImageFormat::Avif))); | |
| 372 | + | #[cfg(feature = "heif")] | |
| 373 | + | assert_eq!( | |
| 374 | + | detect(p, b"\0\0\0\x18ftypheic\0\0\0\0mif1heic"), | |
| 375 | + | Some(Kind::Heif) | |
| 376 | + | ); | |
| 377 | + | #[cfg(feature = "jxl")] | |
| 378 | + | assert_eq!(detect(p, b"\xFF\x0A\x00"), Some(Kind::Jxl)); | |
| 379 | + | #[cfg(feature = "svg")] | |
| 380 | + | assert_eq!( | |
| 381 | + | detect(p, b"<?xml version=\"1.0\"?>\n<svg xmlns=\"x\">"), | |
| 382 | + | Some(Kind::Svg) | |
| 383 | + | ); | |
| 384 | + | #[cfg(feature = "raw")] | |
| 385 | + | assert_eq!(detect(Path::new("a.nef"), b"MM\0*"), Some(Kind::Raw)); | |
| 386 | + | assert_eq!( | |
| 387 | + | detect(Path::new("a.tif"), b"MM\0*"), | |
| 388 | + | Some(Kind::Image(ImageFormat::Tiff)) | |
| 389 | + | ); | |
| 390 | + | assert_eq!(detect(Path::new("a.txt"), b"hello"), None); | |
| 147 | 391 | } | |
| 148 | 392 | ||
| 149 | 393 | #[test] | |
| 150 | − | fn decodes_and_downscales_png() { | |
| 394 | + | fn decodes_png_without_extension() { | |
| 151 | 395 | let dir = std::env::temp_dir().join(format!("remage-test-{}", std::process::id())); | |
| 152 | 396 | std::fs::create_dir_all(&dir).unwrap(); | |
| 153 | 397 | let path = dir.join("noext"); | |
| @@ -155,11 +399,151 @@ mod tests { | |||
| 155 | 399 | .save_with_format(&path, ImageFormat::Png) | |
| 156 | 400 | .unwrap(); | |
| 157 | 401 | assert!(sniff(&path)); | |
| 158 | − | let img = decode(&path, 16).unwrap(); | |
| 402 | + | let img = decode(&path, &Options::default()).unwrap(); | |
| 159 | 403 | assert_eq!(img.format, "PNG"); | |
| 160 | 404 | assert_eq!(img.source_size, [64, 32]); | |
| 161 | − | assert_eq!(img.size, [16, 8]); | |
| 162 | − | assert_eq!(img.pixels.len(), 16 * 8 * 4); | |
| 405 | + | assert_eq!(img.size, [64, 32]); | |
| 406 | + | assert_eq!(img.pixels().len(), 64 * 32 * 4); | |
| 163 | 407 | std::fs::remove_dir_all(&dir).unwrap(); | |
| 164 | 408 | } | |
| 409 | + | ||
| 410 | + | /// Decoder tests against `tests/fixtures` (see `make.sh` there). | |
| 411 | + | mod fixtures { | |
| 412 | + | use super::*; | |
| 413 | + | ||
| 414 | + | fn fixture(name: &str) -> std::path::PathBuf { | |
| 415 | + | Path::new(env!("CARGO_MANIFEST_DIR")) | |
| 416 | + | .join("tests/fixtures") | |
| 417 | + | .join(name) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | fn load(name: &str) -> DecodedImage { | |
| 421 | + | decode(&fixture(name), &Options::default()).unwrap_or_else(|e| panic!("{name}: {e:#}")) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | fn close(a: &[u8], b: [u8; 3], name: &str, what: &str) { | |
| 425 | + | let ok = a[..3].iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 40); | |
| 426 | + | assert!(ok, "{name}: {what} is {:?}, expected {b:?}", &a[..4]); | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | /// Red, lime, blue and white quadrants, upright. | |
| 430 | + | fn check_quadrants(img: &DecodedImage, frame: usize, name: &str) { | |
| 431 | + | assert_eq!(img.size, [16, 12], "{name}"); | |
| 432 | + | let px = img.frame(frame); | |
| 433 | + | let at = |x: usize, y: usize| &px[(y * 16 + x) * 4..][..4]; | |
| 434 | + | close(at(3, 3), [255, 0, 0], name, "top left"); | |
| 435 | + | close(at(12, 3), [0, 255, 0], name, "top right"); | |
| 436 | + | close(at(3, 9), [0, 0, 255], name, "bottom left"); | |
| 437 | + | close(at(12, 9), [255, 255, 255], name, "bottom right"); | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | #[test] | |
| 441 | + | fn every_format_decodes_upright() { | |
| 442 | + | let mut names = vec![ | |
| 443 | + | "quadrants.png", | |
| 444 | + | "quadrants.jpg", | |
| 445 | + | "quadrants.bmp", | |
| 446 | + | "quadrants.tga", | |
| 447 | + | "quadrants.qoi", | |
| 448 | + | "quadrants.ppm", | |
| 449 | + | "quadrants.webp", | |
| 450 | + | "quadrants.exr", | |
| 451 | + | "quadrants.avif", | |
| 452 | + | ]; | |
| 453 | + | if cfg!(feature = "jxl") { | |
| 454 | + | names.push("quadrants.jxl"); | |
| 455 | + | } | |
| 456 | + | if cfg!(feature = "heif") { | |
| 457 | + | names.push("quadrants.heic"); | |
| 458 | + | } | |
| 459 | + | if cfg!(feature = "svg") { | |
| 460 | + | names.extend(["drawing.svg", "drawing.svgz"]); | |
| 461 | + | } | |
| 462 | + | for name in names { | |
| 463 | + | assert!(is_known_extension(&fixture(name)), "{name}"); | |
| 464 | + | check_quadrants(&load(name), 0, name); | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | #[test] | |
| 469 | + | fn exif_orientations() { | |
| 470 | + | for i in 1..=8 { | |
| 471 | + | let name = format!("orient-{i}.jpg"); | |
| 472 | + | check_quadrants(&load(&name), 0, &name); | |
| 473 | + | } | |
| 474 | + | // With auto_orient off, a rotated file stays as stored (12×16). | |
| 475 | + | let opts = Options { | |
| 476 | + | auto_orient: false, | |
| 477 | + | ..Options::default() | |
| 478 | + | }; | |
| 479 | + | let raw = decode(&fixture("orient-6.jpg"), &opts).unwrap(); | |
| 480 | + | assert_eq!(raw.size, [12, 16]); | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | #[test] | |
| 484 | + | fn animations() { | |
| 485 | + | let mut names = vec!["anim.gif", "anim.webp", "anim.png"]; | |
| 486 | + | if cfg!(feature = "jxl") { | |
| 487 | + | names.push("anim.jxl"); | |
| 488 | + | } | |
| 489 | + | for name in names { | |
| 490 | + | let img = load(name); | |
| 491 | + | assert!(img.animated, "{name}"); | |
| 492 | + | assert_eq!(img.frames.len(), 3, "{name}"); | |
| 493 | + | let ms: Vec<u128> = img.delays().iter().map(Duration::as_millis).collect(); | |
| 494 | + | // ImageMagick writes APNG at a constant frame rate. | |
| 495 | + | let want = if name == "anim.png" { | |
| 496 | + | [50, 50, 50] | |
| 497 | + | } else { | |
| 498 | + | [50, 100, 150] | |
| 499 | + | }; | |
| 500 | + | assert_eq!(ms, want, "{name}"); | |
| 501 | + | for (f, c) in [[255, 0, 0], [0, 255, 0], [0, 0, 255]] | |
| 502 | + | .into_iter() | |
| 503 | + | .enumerate() | |
| 504 | + | { | |
| 505 | + | close(img.frame(f), c, name, &format!("frame {f}")); | |
| 506 | + | } | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | #[test] | |
| 511 | + | fn pages() { | |
| 512 | + | let img = load("pages.tif"); | |
| 513 | + | assert!(!img.animated); | |
| 514 | + | assert_eq!(img.frames.len(), 3); | |
| 515 | + | close(img.frame(2), [0, 0, 255], "pages.tif", "page 3"); | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | #[test] | |
| 519 | + | fn icc_profiles_convert_to_srgb() { | |
| 520 | + | use image::ImageEncoder; | |
| 521 | + | let icc = moxcms::ColorProfile::new_display_p3().encode().unwrap(); | |
| 522 | + | let dir = std::env::temp_dir().join(format!("remage-icc-{}", std::process::id())); | |
| 523 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 524 | + | let path = dir.join("p3.png"); | |
| 525 | + | let px = [40u8, 160, 90, 255].repeat(4); | |
| 526 | + | { | |
| 527 | + | let mut enc = | |
| 528 | + | image::codecs::png::PngEncoder::new(std::fs::File::create(&path).unwrap()); | |
| 529 | + | enc.set_icc_profile(icc).unwrap(); | |
| 530 | + | enc.write_image(&px, 2, 2, image::ExtendedColorType::Rgba8) | |
| 531 | + | .unwrap(); | |
| 532 | + | } | |
| 533 | + | let raw = decode( | |
| 534 | + | &path, | |
| 535 | + | &Options { | |
| 536 | + | color: false, | |
| 537 | + | ..Options::default() | |
| 538 | + | }, | |
| 539 | + | ) | |
| 540 | + | .unwrap(); | |
| 541 | + | assert_eq!(&raw.frame(0)[..4], [40, 160, 90, 255]); | |
| 542 | + | let srgb = decode(&path, &Options::default()).unwrap(); | |
| 543 | + | let p = &srgb.frame(0)[..4]; | |
| 544 | + | // P3 green is more saturated than sRGB can say: red drops, green rises. | |
| 545 | + | assert!(p[0] < 40 && p[1] > 160 && p[3] == 255, "{p:?}"); | |
| 546 | + | std::fs::remove_dir_all(&dir).unwrap(); | |
| 547 | + | } | |
| 548 | + | } | |
| 165 | 549 | } | |
| @@ -0,0 +1,69 @@ | |||
| 1 | + | //! Camera RAW through `rawler`: the embedded preview first (fast), a full develop on demand | |
| 2 | + | //! once the view zooms past the preview's resolution. | |
| 3 | + | ||
| 4 | + | use std::path::Path; | |
| 5 | + | ||
| 6 | + | use anyhow::anyhow; | |
| 7 | + | use image::metadata::Orientation; | |
| 8 | + | use rawler::decoders::RawDecodeParams; | |
| 9 | + | use rawler::imgop::develop::RawDevelop; | |
| 10 | + | use rawler::rawsource::RawSource; | |
| 11 | + | ||
| 12 | + | use super::color; | |
| 13 | + | use super::{DecodedImage, Options, Refine}; | |
| 14 | + | ||
| 15 | + | fn orientation(o: rawler::decoders::Orientation, opts: &Options) -> Orientation { | |
| 16 | + | if !opts.auto_orient { | |
| 17 | + | return Orientation::NoTransforms; | |
| 18 | + | } | |
| 19 | + | Orientation::from_exif(o.to_u16() as u8).unwrap_or(Orientation::NoTransforms) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 23 | + | let src = RawSource::new(path)?; | |
| 24 | + | let decoder = rawler::get_decoder(&src)?; | |
| 25 | + | let params = RawDecodeParams::default(); | |
| 26 | + | let preview = decoder | |
| 27 | + | .preview_image(&src, ¶ms) | |
| 28 | + | .ok() | |
| 29 | + | .flatten() | |
| 30 | + | .or_else(|| decoder.full_image(&src, ¶ms).ok().flatten()); | |
| 31 | + | let Some(preview) = preview else { | |
| 32 | + | return develop(path, opts); | |
| 33 | + | }; | |
| 34 | + | // The raw data's size (cheap: no pixels decoded) is the size the view works in. | |
| 35 | + | let raw = decoder.raw_image(&src, ¶ms, true)?; | |
| 36 | + | let o = orientation(raw.orientation, opts); | |
| 37 | + | let (size, pixels) = color::to_rgba8(preview); | |
| 38 | + | let (size, pixels) = color::orient(size, pixels, o); | |
| 39 | + | let (rw, rh) = match raw.crop_area { | |
| 40 | + | Some(r) => (r.d.w, r.d.h), | |
| 41 | + | None => (raw.width, raw.height), | |
| 42 | + | }; | |
| 43 | + | let raw_long = rw.max(rh) as u32; | |
| 44 | + | let long = size[0].max(size[1]); | |
| 45 | + | let mut img = DecodedImage::still(size, pixels, "RAW"); | |
| 46 | + | if raw_long > long { | |
| 47 | + | // Same shape as the preview, scaled up to the sensor's resolution. | |
| 48 | + | let k = raw_long as f64 / long as f64; | |
| 49 | + | img.source_size = [ | |
| 50 | + | (size[0] as f64 * k).round() as u32, | |
| 51 | + | (size[1] as f64 * k).round() as u32, | |
| 52 | + | ]; | |
| 53 | + | img.refine = Some(Refine::Raw); | |
| 54 | + | } | |
| 55 | + | Ok(img) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Demosaic and develop the raw data in full. | |
| 59 | + | pub fn develop(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 60 | + | let raw = rawler::decode_file(path)?; | |
| 61 | + | let o = orientation(raw.orientation, opts); | |
| 62 | + | let dynamic = RawDevelop::default() | |
| 63 | + | .develop_intermediate(&raw)? | |
| 64 | + | .to_dynamic_image() | |
| 65 | + | .ok_or_else(|| anyhow!("develop produced no image"))?; | |
| 66 | + | let (size, pixels) = color::to_rgba8(dynamic); | |
| 67 | + | let (size, pixels) = color::orient(size, pixels, o); | |
| 68 | + | Ok(DecodedImage::still(size, pixels, "RAW")) | |
| 69 | + | } | |
| @@ -0,0 +1,104 @@ | |||
| 1 | + | //! SVG and SVGZ through resvg. The parsed tree is kept so the image can be re-rasterised at | |
| 2 | + | //! the current zoom and stay sharp. | |
| 3 | + | ||
| 4 | + | use std::path::Path; | |
| 5 | + | use std::sync::{Arc, LazyLock}; | |
| 6 | + | ||
| 7 | + | use resvg::{tiny_skia, usvg}; | |
| 8 | + | ||
| 9 | + | use super::{DecodedImage, Options, Refine}; | |
| 10 | + | ||
| 11 | + | /// Longest side of any raster; zooming further shows this one magnified. | |
| 12 | + | pub const MAX_RASTER: u32 = 8192; | |
| 13 | + | ||
| 14 | + | /// System fonts for `<text>`, loaded once on first use. | |
| 15 | + | static FONTS: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| { | |
| 16 | + | let mut db = usvg::fontdb::Database::new(); | |
| 17 | + | db.load_system_fonts(); | |
| 18 | + | Arc::new(db) | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | pub struct Svg { | |
| 22 | + | tree: usvg::Tree, | |
| 23 | + | /// Intrinsic size, rounded up to whole pixels. | |
| 24 | + | size: [u32; 2], | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> { | |
| 28 | + | let data = std::fs::read(path)?; | |
| 29 | + | let options = usvg::Options { | |
| 30 | + | resources_dir: path.parent().map(Path::to_path_buf), | |
| 31 | + | fontdb: FONTS.clone(), | |
| 32 | + | ..Default::default() | |
| 33 | + | }; | |
| 34 | + | let tree = usvg::Tree::from_data(&data, &options)?; | |
| 35 | + | let s = tree.size(); | |
| 36 | + | let size = [ | |
| 37 | + | (s.width().ceil() as u32).max(1), | |
| 38 | + | (s.height().ceil() as u32).max(1), | |
| 39 | + | ]; | |
| 40 | + | let svg = Arc::new(Svg { tree, size }); | |
| 41 | + | // Never more pixels than the window needs to start with; zooming in re-rasterises. | |
| 42 | + | let long = size[0].max(size[1]).min(opts.target.max(16)); | |
| 43 | + | Ok(svg.rasterize(long)) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl Svg { | |
| 47 | + | /// Render with the longer side `long` pixels. | |
| 48 | + | pub fn rasterize(self: &Arc<Self>, long: u32) -> DecodedImage { | |
| 49 | + | let [w, h] = self.size; | |
| 50 | + | let k = long.clamp(1, MAX_RASTER) as f32 / w.max(h) as f32; | |
| 51 | + | let pw = ((w as f32 * k).round() as u32).max(1); | |
| 52 | + | let ph = ((h as f32 * k).round() as u32).max(1); | |
| 53 | + | let pixels = match tiny_skia::Pixmap::new(pw, ph) { | |
| 54 | + | Some(mut pixmap) => { | |
| 55 | + | let t = | |
| 56 | + | tiny_skia::Transform::from_scale(pw as f32 / w as f32, ph as f32 / h as f32); | |
| 57 | + | resvg::render(&self.tree, t, &mut pixmap.as_mut()); | |
| 58 | + | pixmap.take_demultiplied() | |
| 59 | + | } | |
| 60 | + | None => vec![0; pw as usize * ph as usize * 4], | |
| 61 | + | }; | |
| 62 | + | let mut img = DecodedImage::still([pw, ph], pixels, "SVG"); | |
| 63 | + | img.source_size = self.size; | |
| 64 | + | img.refine = Some(Refine::Svg(self.clone())); | |
| 65 | + | img | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | #[cfg(test)] | |
| 70 | + | mod tests { | |
| 71 | + | use super::*; | |
| 72 | + | ||
| 73 | + | #[test] | |
| 74 | + | fn rasterises_at_any_size() { | |
| 75 | + | let dir = std::env::temp_dir().join(format!("remage-svg-{}", std::process::id())); | |
| 76 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 77 | + | let path = dir.join("a.svg"); | |
| 78 | + | std::fs::write( | |
| 79 | + | &path, | |
| 80 | + | r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"> | |
| 81 | + | <rect width="20" height="20" fill="red"/></svg>"#, | |
| 82 | + | ) | |
| 83 | + | .unwrap(); | |
| 84 | + | let img = decode(&path, &Options::default()).unwrap(); | |
| 85 | + | assert_eq!((img.size, img.source_size), ([40, 20], [40, 20])); | |
| 86 | + | assert_eq!(&img.pixels()[..4], [255, 0, 0, 255]); | |
| 87 | + | // Transparent right half. | |
| 88 | + | assert_eq!(img.pixels()[(30 * 4) + 3], 0); | |
| 89 | + | let Some(Refine::Svg(svg)) = &img.refine else { | |
| 90 | + | panic!("refinable") | |
| 91 | + | }; | |
| 92 | + | let big = svg.rasterize(400); | |
| 93 | + | assert_eq!((big.size, big.source_size), ([400, 200], [40, 20])); | |
| 94 | + | let small = decode( | |
| 95 | + | &path, | |
| 96 | + | &Options { | |
| 97 | + | target: 10, | |
| 98 | + | ..Options::default() | |
| 99 | + | }, | |
| 100 | + | ) | |
| 101 | + | .unwrap(); | |
| 102 | + | assert_eq!(small.size, [16, 8]); | |
| 103 | + | } | |
| 104 | + | } | |
| @@ -71,6 +71,7 @@ fn main() -> anyhow::Result<()> { | |||
| 71 | 71 | } | |
| 72 | 72 | log::debug!("config loaded after {:.1?}", started.elapsed()); | |
| 73 | 73 | let cfg = &loaded.config; | |
| 74 | + | decode::set_decoders(&cfg.decoders); | |
| 74 | 75 | ||
| 75 | 76 | let mut paths = cli.paths.clone(); | |
| 76 | 77 | if cli.stdin { | |
| @@ -1,4 +1,5 @@ | |||
| 1 | − | //! Image textures: incremental row uploads, GPU mipmaps, linear/nearest sampling. | |
| 1 | + | //! Image textures: incremental row uploads, GPU mipmaps, linear/nearest sampling, and tiles | |
| 2 | + | //! for images larger than the GPU's texture limit. | |
| 2 | 3 | ||
| 3 | 4 | use bytemuck::{Pod, Zeroable}; | |
| 4 | 5 | use wgpu::util::DeviceExt; | |
| @@ -30,11 +31,22 @@ pub struct AlphaStyle { | |||
| 30 | 31 | pub radius: f32, | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | − | pub struct GpuImage { | |
| 34 | + | /// One texture of an image: all of it, or a tile of a huge one. | |
| 35 | + | struct Tile { | |
| 34 | 36 | texture: wgpu::Texture, | |
| 35 | 37 | bind_group: wgpu::BindGroup, | |
| 36 | 38 | uniforms: wgpu::Buffer, | |
| 39 | + | origin: [u32; 2], | |
| 40 | + | size: [u32; 2], | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | pub struct GpuImage { | |
| 44 | + | tiles: Vec<Tile>, | |
| 37 | 45 | size: [u32; 2], | |
| 46 | + | /// The frame these textures hold (or are being filled with). | |
| 47 | + | frame: usize, | |
| 48 | + | /// Upload progress: the tile being filled and its rows done. | |
| 49 | + | tile: usize, | |
| 38 | 50 | rows_done: u32, | |
| 39 | 51 | ready: bool, | |
| 40 | 52 | } | |
| @@ -43,6 +55,26 @@ impl GpuImage { | |||
| 43 | 55 | pub fn is_ready(&self) -> bool { | |
| 44 | 56 | self.ready | |
| 45 | 57 | } | |
| 58 | + | ||
| 59 | + | pub fn frame(&self) -> usize { | |
| 60 | + | self.frame | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | pub fn size(&self) -> [u32; 2] { | |
| 64 | + | self.size | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// Split `size` into tiles no larger than `max` on either side. | |
| 69 | + | pub fn tiles(size: [u32; 2], max: u32) -> Vec<([u32; 2], [u32; 2])> { | |
| 70 | + | let max = max.max(1); | |
| 71 | + | let mut out = Vec::new(); | |
| 72 | + | for y in (0..size[1].max(1)).step_by(max as usize) { | |
| 73 | + | for x in (0..size[0].max(1)).step_by(max as usize) { | |
| 74 | + | out.push(([x, y], [(size[0] - x).min(max), (size[1] - y).min(max)])); | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | out | |
| 46 | 78 | } | |
| 47 | 79 | ||
| 48 | 80 | pub struct ImagePipeline { | |
| @@ -202,9 +234,31 @@ impl ImagePipeline { | |||
| 202 | 234 | } | |
| 203 | 235 | } | |
| 204 | 236 | ||
| 205 | − | /// Allocate a texture for `img`; fill it with [`ImagePipeline::upload`]. | |
| 206 | − | pub fn create(&self, device: &wgpu::Device, img: &DecodedImage) -> GpuImage { | |
| 207 | − | let [w, h] = img.size; | |
| 237 | + | /// Allocate textures for `img`, tiled at `max_dim`; fill them with [`ImagePipeline::upload`]. | |
| 238 | + | pub fn create(&self, device: &wgpu::Device, img: &DecodedImage, max_dim: u32) -> GpuImage { | |
| 239 | + | let tiles = tiles(img.size, max_dim) | |
| 240 | + | .into_iter() | |
| 241 | + | .map(|(origin, size)| self.tile(device, origin, size)) | |
| 242 | + | .collect::<Vec<_>>(); | |
| 243 | + | if tiles.len() > 1 { | |
| 244 | + | log::debug!( | |
| 245 | + | "{}×{} image in {} tiles", | |
| 246 | + | img.size[0], | |
| 247 | + | img.size[1], | |
| 248 | + | tiles.len() | |
| 249 | + | ); | |
| 250 | + | } | |
| 251 | + | GpuImage { | |
| 252 | + | tiles, | |
| 253 | + | size: img.size, | |
| 254 | + | frame: 0, | |
| 255 | + | tile: 0, | |
| 256 | + | rows_done: 0, | |
| 257 | + | ready: false, | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | fn tile(&self, device: &wgpu::Device, origin: [u32; 2], [w, h]: [u32; 2]) -> Tile { | |
| 208 | 262 | let mips = 32 - w.max(h).max(1).leading_zeros(); | |
| 209 | 263 | let texture = device.create_texture(&wgpu::TextureDescriptor { | |
| 210 | 264 | label: Some("image"), | |
| @@ -242,16 +296,23 @@ impl ImagePipeline { | |||
| 242 | 296 | }, | |
| 243 | 297 | ], | |
| 244 | 298 | }); | |
| 245 | − | GpuImage { | |
| 299 | + | Tile { | |
| 246 | 300 | texture, | |
| 247 | 301 | bind_group, | |
| 248 | 302 | uniforms, | |
| 303 | + | origin, | |
| 249 | 304 | size: [w, h], | |
| 250 | − | rows_done: 0, | |
| 251 | − | ready: false, | |
| 252 | 305 | } | |
| 253 | 306 | } | |
| 254 | 307 | ||
| 308 | + | /// Start filling the textures with another frame; finish with [`ImagePipeline::upload`]. | |
| 309 | + | pub fn restart(&self, gpu: &mut GpuImage, frame: usize) { | |
| 310 | + | gpu.frame = frame; | |
| 311 | + | gpu.tile = 0; | |
| 312 | + | gpu.rows_done = 0; | |
| 313 | + | gpu.ready = false; | |
| 314 | + | } | |
| 315 | + | ||
| 255 | 316 | /// Upload up to `budget` bytes of rows. Returns true once the image is complete. | |
| 256 | 317 | pub fn upload( | |
| 257 | 318 | &self, | |
| @@ -261,42 +322,53 @@ impl ImagePipeline { | |||
| 261 | 322 | img: &DecodedImage, | |
| 262 | 323 | budget: usize, | |
| 263 | 324 | ) -> bool { | |
| 264 | − | if gpu.ready { | |
| 265 | − | return true; | |
| 266 | − | } | |
| 267 | − | let [w, h] = gpu.size; | |
| 268 | − | let row_bytes = w as usize * 4; | |
| 269 | − | let rows = ((budget / row_bytes.max(1)) as u32).clamp(1, h - gpu.rows_done); | |
| 270 | − | let start = gpu.rows_done as usize * row_bytes; | |
| 271 | − | let end = start + rows as usize * row_bytes; | |
| 272 | − | queue.write_texture( | |
| 273 | − | wgpu::TexelCopyTextureInfo { | |
| 274 | − | texture: &gpu.texture, | |
| 275 | − | mip_level: 0, | |
| 276 | − | origin: wgpu::Origin3d { | |
| 277 | − | x: 0, | |
| 278 | − | y: gpu.rows_done, | |
| 279 | − | z: 0, | |
| 325 | + | let pixels = img.frame(gpu.frame); | |
| 326 | + | let stride = img.size[0] as usize * 4; | |
| 327 | + | let mut left = budget; | |
| 328 | + | while !gpu.ready { | |
| 329 | + | let tile = &gpu.tiles[gpu.tile]; | |
| 330 | + | let [w, h] = tile.size; | |
| 331 | + | let row_bytes = w as usize * 4; | |
| 332 | + | let rows = ((left / row_bytes.max(1)) as u32).clamp(1, h - gpu.rows_done); | |
| 333 | + | let y = tile.origin[1] + gpu.rows_done; | |
| 334 | + | let start = y as usize * stride + tile.origin[0] as usize * 4; | |
| 335 | + | let end = start + (rows as usize - 1) * stride + row_bytes; | |
| 336 | + | queue.write_texture( | |
| 337 | + | wgpu::TexelCopyTextureInfo { | |
| 338 | + | texture: &tile.texture, | |
| 339 | + | mip_level: 0, | |
| 340 | + | origin: wgpu::Origin3d { | |
| 341 | + | x: 0, | |
| 342 | + | y: gpu.rows_done, | |
| 343 | + | z: 0, | |
| 344 | + | }, | |
| 345 | + | aspect: wgpu::TextureAspect::All, | |
| 280 | 346 | }, | |
| 281 | − | aspect: wgpu::TextureAspect::All, | |
| 282 | − | }, | |
| 283 | − | &img.pixels[start..end], | |
| 284 | − | wgpu::TexelCopyBufferLayout { | |
| 285 | − | offset: 0, | |
| 286 | − | bytes_per_row: Some(row_bytes as u32), | |
| 287 | − | rows_per_image: Some(rows), | |
| 288 | − | }, | |
| 289 | − | wgpu::Extent3d { | |
| 290 | − | width: w, | |
| 291 | − | height: rows, | |
| 292 | − | depth_or_array_layers: 1, | |
| 293 | − | }, | |
| 294 | − | ); | |
| 295 | − | gpu.rows_done += rows; | |
| 296 | − | if gpu.rows_done == h { | |
| 297 | − | self.generate_mips(device, queue, &gpu.texture); | |
| 298 | − | gpu.ready = true; | |
| 299 | − | } else { | |
| 347 | + | &pixels[start..end], | |
| 348 | + | wgpu::TexelCopyBufferLayout { | |
| 349 | + | offset: 0, | |
| 350 | + | bytes_per_row: Some(stride as u32), | |
| 351 | + | rows_per_image: Some(rows), | |
| 352 | + | }, | |
| 353 | + | wgpu::Extent3d { | |
| 354 | + | width: w, | |
| 355 | + | height: rows, | |
| 356 | + | depth_or_array_layers: 1, | |
| 357 | + | }, | |
| 358 | + | ); | |
| 359 | + | gpu.rows_done += rows; | |
| 360 | + | left = left.saturating_sub(rows as usize * row_bytes); | |
| 361 | + | if gpu.rows_done == h { | |
| 362 | + | self.generate_mips(device, queue, &gpu.tiles[gpu.tile].texture); | |
| 363 | + | gpu.tile += 1; | |
| 364 | + | gpu.rows_done = 0; | |
| 365 | + | gpu.ready = gpu.tile == gpu.tiles.len(); | |
| 366 | + | } | |
| 367 | + | if left == 0 { | |
| 368 | + | break; | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | if !gpu.ready { | |
| 300 | 372 | queue.submit([]); | |
| 301 | 373 | } | |
| 302 | 374 | gpu.ready | |
| @@ -363,21 +435,49 @@ impl ImagePipeline { | |||
| 363 | 435 | alpha: f32, | |
| 364 | 436 | style: &AlphaStyle, | |
| 365 | 437 | ) { | |
| 366 | − | let u = Uniforms { | |
| 367 | − | m: affine.m, | |
| 368 | − | t: affine.t, | |
| 369 | − | viewport, | |
| 370 | − | bg: style.bg, | |
| 371 | − | dot: style.dot, | |
| 372 | − | params: [alpha, style.mode as f32, style.cell, style.radius], | |
| 373 | − | }; | |
| 374 | − | queue.write_buffer(&gpu.uniforms, 0, bytemuck::bytes_of(&u)); | |
| 438 | + | let [iw, ih] = [gpu.size[0] as f32, gpu.size[1] as f32]; | |
| 439 | + | let m = affine.m; | |
| 440 | + | for tile in &gpu.tiles { | |
| 441 | + | // The tile's corner and extent in the image's 0..1 coordinates. | |
| 442 | + | let (u0, v0) = (tile.origin[0] as f32 / iw, tile.origin[1] as f32 / ih); | |
| 443 | + | let (du, dv) = (tile.size[0] as f32 / iw, tile.size[1] as f32 / ih); | |
| 444 | + | let u = Uniforms { | |
| 445 | + | m: [m[0] * du, m[1] * dv, m[2] * du, m[3] * dv], | |
| 446 | + | t: [ | |
| 447 | + | affine.t[0] + m[0] * u0 + m[1] * v0, | |
| 448 | + | affine.t[1] + m[2] * u0 + m[3] * v0, | |
| 449 | + | ], | |
| 450 | + | viewport, | |
| 451 | + | bg: style.bg, | |
| 452 | + | dot: style.dot, | |
| 453 | + | params: [alpha, style.mode as f32, style.cell, style.radius], | |
| 454 | + | }; | |
| 455 | + | queue.write_buffer(&tile.uniforms, 0, bytemuck::bytes_of(&u)); | |
| 456 | + | } | |
| 375 | 457 | } | |
| 376 | 458 | ||
| 377 | 459 | pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, gpu: &GpuImage, nearest: bool) { | |
| 378 | 460 | pass.set_pipeline(&self.pipeline); | |
| 379 | − | pass.set_bind_group(0, &gpu.bind_group, &[]); | |
| 380 | 461 | pass.set_bind_group(1, if nearest { &self.nearest } else { &self.linear }, &[]); | |
| 381 | − | pass.draw(0..6, 0..1); | |
| 462 | + | for tile in &gpu.tiles { | |
| 463 | + | pass.set_bind_group(0, &tile.bind_group, &[]); | |
| 464 | + | pass.draw(0..6, 0..1); | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | #[cfg(test)] | |
| 470 | + | mod tests { | |
| 471 | + | use super::*; | |
| 472 | + | ||
| 473 | + | #[test] | |
| 474 | + | fn tiling_covers_the_image() { | |
| 475 | + | assert_eq!(tiles([10, 5], 16), [([0, 0], [10, 5])]); | |
| 476 | + | let t = tiles([10, 5], 4); | |
| 477 | + | assert_eq!(t.len(), 6); | |
| 478 | + | assert_eq!(t[2], ([8, 0], [2, 4])); | |
| 479 | + | assert_eq!(t[5], ([8, 4], [2, 1])); | |
| 480 | + | let area: u32 = t.iter().map(|(_, s)| s[0] * s[1]).sum(); | |
| 481 | + | assert_eq!(area, 50); | |
| 382 | 482 | } | |
| 383 | 483 | } | |
| @@ -84,7 +84,14 @@ impl Renderer { | |||
| 84 | 84 | let info = adapter.get_info(); | |
| 85 | 85 | log::debug!("adapter: {} ({:?})", info.name, info.backend); | |
| 86 | 86 | let limits = wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()); | |
| 87 | − | let max_dim = limits.max_texture_dimension_2d; | |
| 87 | + | // `REMAGE_MAX_TEXTURE` lowers the limit, to exercise tiling on any GPU. | |
| 88 | + | let max_dim = std::env::var("REMAGE_MAX_TEXTURE") | |
| 89 | + | .ok() | |
| 90 | + | .and_then(|v| v.parse::<u32>().ok()) | |
| 91 | + | .filter(|v| *v >= 64) | |
| 92 | + | .map_or(limits.max_texture_dimension_2d, |v| { | |
| 93 | + | v.min(limits.max_texture_dimension_2d) | |
| 94 | + | }); | |
| 88 | 95 | let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { | |
| 89 | 96 | label: Some("remage"), | |
| 90 | 97 | required_limits: limits, | |
| @@ -145,10 +152,6 @@ impl Renderer { | |||
| 145 | 152 | }) | |
| 146 | 153 | } | |
| 147 | 154 | ||
| 148 | − | pub fn max_texture_dim(&self) -> u32 { | |
| 149 | − | self.max_dim | |
| 150 | − | } | |
| 151 | − | ||
| 152 | 155 | pub fn resize(&mut self, width: u32, height: u32) { | |
| 153 | 156 | if width == 0 || height == 0 { | |
| 154 | 157 | return; | |
| @@ -175,11 +178,19 @@ impl Renderer { | |||
| 175 | 178 | } | |
| 176 | 179 | ||
| 177 | 180 | /// Upload up to `budget` bytes of `img`. Returns true once it is ready to draw. | |
| 181 | + | /// A texture of another size (a re-rasterised SVG, a developed RAW) is replaced. | |
| 178 | 182 | pub fn upload(&mut self, path: &Path, img: &DecodedImage, budget: usize) -> bool { | |
| 183 | + | if self | |
| 184 | + | .textures | |
| 185 | + | .get(path) | |
| 186 | + | .is_some_and(|g| g.size() != img.size) | |
| 187 | + | { | |
| 188 | + | self.textures.remove(path); | |
| 189 | + | } | |
| 179 | 190 | let gpu = match self.textures.get_mut(path) { | |
| 180 | 191 | Some(g) => g, | |
| 181 | 192 | None => { | |
| 182 | − | let g = self.images.create(&self.device, img); | |
| 193 | + | let g = self.images.create(&self.device, img, self.max_dim); | |
| 183 | 194 | self.textures.entry(path.to_path_buf()).or_insert(g) | |
| 184 | 195 | } | |
| 185 | 196 | }; | |
| @@ -187,6 +198,24 @@ impl Renderer { | |||
| 187 | 198 | .upload(&self.device, &self.queue, gpu, img, budget) | |
| 188 | 199 | } | |
| 189 | 200 | ||
| 201 | + | /// The frame an image's textures hold. | |
| 202 | + | pub fn frame(&self, path: &Path) -> Option<usize> { | |
| 203 | + | self.textures.get(path).map(GpuImage::frame) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// Replace the textures' contents with frame `frame` of `img`, in full. | |
| 207 | + | pub fn show_frame(&mut self, path: &Path, img: &DecodedImage, frame: usize) { | |
| 208 | + | let Some(gpu) = self.textures.get_mut(path) else { | |
| 209 | + | return; | |
| 210 | + | }; | |
| 211 | + | if gpu.size() != img.size { | |
| 212 | + | return; | |
| 213 | + | } | |
| 214 | + | self.images.restart(gpu, frame); | |
| 215 | + | self.images | |
| 216 | + | .upload(&self.device, &self.queue, gpu, img, usize::MAX); | |
| 217 | + | } | |
| 218 | + | ||
| 190 | 219 | /// Start a fresh thumbnail atlas for `cell`-pixel thumbnails. | |
| 191 | 220 | pub fn reset_thumbs(&mut self, cell: u32) { | |
| 192 | 221 | self.atlas.reset(cell); | |
| @@ -163,6 +163,32 @@ pub struct Shown { | |||
| 163 | 163 | pub format: &'static str, | |
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | + | /// Frames of the image on screen: a timed animation, or the pages of a document. | |
| 167 | + | #[derive(Clone, Debug)] | |
| 168 | + | pub struct Playback { | |
| 169 | + | pub delays: Vec<Duration>, | |
| 170 | + | pub animated: bool, | |
| 171 | + | pub index: usize, | |
| 172 | + | pub playing: bool, | |
| 173 | + | /// When the next animation frame is due. | |
| 174 | + | next: Option<Instant>, | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | impl Playback { | |
| 178 | + | pub fn len(&self) -> usize { | |
| 179 | + | self.delays.len() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | fn step(&mut self, delta: isize) { | |
| 183 | + | let n = self.len() as isize; | |
| 184 | + | self.index = (self.index as isize + delta).rem_euclid(n) as usize; | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | fn delay(&self) -> Duration { | |
| 188 | + | self.delays[self.index].max(Duration::from_millis(10)) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 166 | 192 | /// The previous image fading out under the new one. | |
| 167 | 193 | #[derive(Clone, Debug)] | |
| 168 | 194 | pub struct Fade { | |
| @@ -226,6 +252,8 @@ pub struct AppState { | |||
| 226 | 252 | pub nearest: Option<bool>, | |
| 227 | 253 | pub alpha_bg: AlphaBg, | |
| 228 | 254 | pub shown: Option<Shown>, | |
| 255 | + | /// Frames of the shown image, when it has more than one. | |
| 256 | + | pub playback: Option<Playback>, | |
| 229 | 257 | pub fade: Option<Fade>, | |
| 230 | 258 | pub loading_since: Option<Instant>, | |
| 231 | 259 | pub skipped: usize, | |
| @@ -258,6 +286,7 @@ impl AppState { | |||
| 258 | 286 | nearest: None, | |
| 259 | 287 | alpha_bg: AlphaBg::Dots, | |
| 260 | 288 | shown: None, | |
| 289 | + | playback: None, | |
| 261 | 290 | fade: None, | |
| 262 | 291 | loading_since: None, | |
| 263 | 292 | skipped: 0, | |
| @@ -392,6 +421,9 @@ impl AppState { | |||
| 392 | 421 | self.holds.clear(); | |
| 393 | 422 | match mode { | |
| 394 | 423 | Mode::Image => { | |
| 424 | + | if let Some(pb) = self.playback.as_mut().filter(|p| p.playing) { | |
| 425 | + | pb.next = Some(now + pb.delay()); | |
| 426 | + | } | |
| 395 | 427 | // Don't crossfade from whatever was open before the grid. | |
| 396 | 428 | if self.shown.as_ref().map(|s| s.path.as_path()) != self.queue.current() { | |
| 397 | 429 | self.clear_shown(); | |
| @@ -613,6 +645,22 @@ impl AppState { | |||
| 613 | 645 | ); | |
| 614 | 646 | } | |
| 615 | 647 | Action::AlphaBgCycle => self.alpha_bg = self.alpha_bg.next(), | |
| 648 | + | Action::Play(s) if has_image => match &mut self.playback { | |
| 649 | + | Some(pb) if pb.animated => { | |
| 650 | + | pb.playing = switch(s, pb.playing); | |
| 651 | + | pb.next = pb.playing.then(|| now + pb.delay()); | |
| 652 | + | } | |
| 653 | + | _ => self.notify("not animated", MsgKind::Info, now), | |
| 654 | + | }, | |
| 655 | + | Action::NextFrame | Action::PrevFrame if has_image => match &mut self.playback { | |
| 656 | + | Some(pb) => { | |
| 657 | + | pb.step(if action == Action::NextFrame { 1 } else { -1 }); | |
| 658 | + | // Stepping an animation pauses it, so the frame stays put. | |
| 659 | + | pb.playing = false; | |
| 660 | + | pb.next = None; | |
| 661 | + | } | |
| 662 | + | None => self.notify("single frame", MsgKind::Info, now), | |
| 663 | + | }, | |
| 616 | 664 | Action::Fullscreen(s) => fx.fullscreen = Some(s), | |
| 617 | 665 | Action::Bar(s) => { | |
| 618 | 666 | let bar = switch(s, self.bar); | |
| @@ -652,12 +700,12 @@ impl AppState { | |||
| 652 | 700 | | Action::Page(_) | |
| 653 | 701 | | Action::RowStart | |
| 654 | 702 | | Action::RowEnd | |
| 655 | − | | Action::Scroll(..) => fx.redraw = false, | |
| 656 | − | // Parsed and bindable now; they arrive with later milestones. | |
| 657 | − | other @ (Action::NextFrame | |
| 658 | − | | Action::PrevFrame | |
| 659 | − | | Action::Adjust(..) | |
| 703 | + | | Action::Scroll(..) | |
| 660 | 704 | | Action::Play(_) | |
| 705 | + | | Action::NextFrame | |
| 706 | + | | Action::PrevFrame => fx.redraw = false, | |
| 707 | + | // Parsed and bindable now; they arrive with later milestones. | |
| 708 | + | other @ (Action::Adjust(..) | |
| 661 | 709 | | Action::Slideshow(_) | |
| 662 | 710 | | Action::SlideshowDelay { .. } | |
| 663 | 711 | | Action::Info(_) | |
| @@ -759,11 +807,35 @@ impl AppState { | |||
| 759 | 807 | .reset(size, self.settings.fit, self.settings.zoom, now); | |
| 760 | 808 | self.loading_since = None; | |
| 761 | 809 | self.holds.clear(); | |
| 810 | + | self.playback = None; | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | /// The shown image has several frames: play an animation, or page through a document. | |
| 814 | + | pub fn set_frames(&mut self, delays: Vec<Duration>, animated: bool, now: Instant) { | |
| 815 | + | if delays.len() < 2 { | |
| 816 | + | self.playback = None; | |
| 817 | + | return; | |
| 818 | + | } | |
| 819 | + | let mut pb = Playback { | |
| 820 | + | delays, | |
| 821 | + | animated, | |
| 822 | + | index: 0, | |
| 823 | + | playing: animated, | |
| 824 | + | next: None, | |
| 825 | + | }; | |
| 826 | + | pb.next = animated.then(|| now + pb.delay()); | |
| 827 | + | self.playback = Some(pb); | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | /// The frame of the shown image to draw. | |
| 831 | + | pub fn frame(&self) -> usize { | |
| 832 | + | self.playback.as_ref().map_or(0, |p| p.index) | |
| 762 | 833 | } | |
| 763 | 834 | ||
| 764 | 835 | /// The current file can't be shown (or the queue emptied). | |
| 765 | 836 | pub fn clear_shown(&mut self) { | |
| 766 | 837 | self.shown = None; | |
| 838 | + | self.playback = None; | |
| 767 | 839 | self.fade = None; | |
| 768 | 840 | self.loading_since = None; | |
| 769 | 841 | } | |
| @@ -840,6 +912,29 @@ impl AppState { | |||
| 840 | 912 | wake(at); | |
| 841 | 913 | } | |
| 842 | 914 | } | |
| 915 | + | // Animation frames: only while the image is on screen. | |
| 916 | + | if self.mode == Mode::Image | |
| 917 | + | && let Some(pb) = &mut self.playback | |
| 918 | + | && let Some(mut next) = pb.next | |
| 919 | + | { | |
| 920 | + | if now >= next { | |
| 921 | + | let before = pb.index; | |
| 922 | + | // Catch up on missed frames, but after a long stall just carry on from now. | |
| 923 | + | for _ in 0..pb.len() { | |
| 924 | + | if now < next { | |
| 925 | + | break; | |
| 926 | + | } | |
| 927 | + | pb.step(1); | |
| 928 | + | next += pb.delay(); | |
| 929 | + | } | |
| 930 | + | if now >= next { | |
| 931 | + | next = now + pb.delay(); | |
| 932 | + | } | |
| 933 | + | pb.next = Some(next); | |
| 934 | + | redraw |= pb.index != before; | |
| 935 | + | } | |
| 936 | + | wake(next); | |
| 937 | + | } | |
| 843 | 938 | if self.fade.as_ref().is_some_and(|f| !f.alpha.is_active(now)) { | |
| 844 | 939 | self.fade = None; | |
| 845 | 940 | redraw = true; | |
| @@ -1016,6 +1111,37 @@ mod tests { | |||
| 1016 | 1111 | assert_eq!(s.cell_px(), 320); | |
| 1017 | 1112 | } | |
| 1018 | 1113 | ||
| 1114 | + | #[test] | |
| 1115 | + | fn animation_plays_steps_and_pauses() { | |
| 1116 | + | let (mut s, now) = state(1); | |
| 1117 | + | s.show(Path::new("0.png"), [10, 10], "GIF", now); | |
| 1118 | + | let ms = Duration::from_millis; | |
| 1119 | + | s.set_frames(vec![ms(100), ms(50), ms(100)], true, now); | |
| 1120 | + | assert_eq!(s.frame(), 0); | |
| 1121 | + | assert_eq!(s.tick(now + ms(10)).1, Some(now + ms(100))); | |
| 1122 | + | assert!(s.tick(now + ms(100)).0); | |
| 1123 | + | assert_eq!(s.frame(), 1); | |
| 1124 | + | // Late: catch up through frame 1's 50 ms into frame 2. | |
| 1125 | + | s.tick(now + ms(160)); | |
| 1126 | + | assert_eq!(s.frame(), 2); | |
| 1127 | + | s.apply(Action::Play(Switch::Toggle), now); | |
| 1128 | + | assert!(!s.tick(now + ms(5000)).0, "paused"); | |
| 1129 | + | s.apply(Action::NextFrame, now); | |
| 1130 | + | assert_eq!(s.frame(), 0); | |
| 1131 | + | s.apply(Action::PrevFrame, now); | |
| 1132 | + | assert_eq!(s.frame(), 2); | |
| 1133 | + | // Pages step but never play. | |
| 1134 | + | s.set_frames(vec![Duration::ZERO; 3], false, now); | |
| 1135 | + | assert!(!s.playback.as_ref().unwrap().playing); | |
| 1136 | + | s.apply(Action::Play(Switch::On), now); | |
| 1137 | + | assert_eq!(s.message.as_ref().unwrap().text, "not animated"); | |
| 1138 | + | s.apply(Action::NextFrame, now); | |
| 1139 | + | assert_eq!(s.frame(), 1); | |
| 1140 | + | // A new image starts without frames. | |
| 1141 | + | s.show(Path::new("1.png"), [10, 10], "PNG", now); | |
| 1142 | + | assert!(s.playback.is_none()); | |
| 1143 | + | } | |
| 1144 | + | ||
| 1019 | 1145 | #[test] | |
| 1020 | 1146 | fn tapped_pan_moves_a_minimum_distance() { | |
| 1021 | 1147 | let (mut s, now) = state(1); | |
| @@ -399,9 +399,17 @@ fn generate(path: &Path, max: u32) -> anyhow::Result<Generated> { | |||
| 399 | 399 | if let Some(g) = jpeg_scaled(path, max)? { | |
| 400 | 400 | return Ok(g); | |
| 401 | 401 | } | |
| 402 | − | let img = decode::decode(path, u32::MAX)?; | |
| 402 | + | // Scalable and previewed sources (SVG, RAW) only need the bucket's size. | |
| 403 | + | let opts = decode::Options { | |
| 404 | + | target: max, | |
| 405 | + | ..decode::Options::default() | |
| 406 | + | }; | |
| 407 | + | let img = decode::decode(path, &opts)?; | |
| 403 | 408 | let source_size = img.source_size; | |
| 404 | − | let DecodedImage { size, pixels, .. } = img; | |
| 409 | + | let DecodedImage { | |
| 410 | + | size, mut frames, .. | |
| 411 | + | } = img; | |
| 412 | + | let pixels = frames.swap_remove(0).pixels; | |
| 405 | 413 | let t = resize(Thumb { size, pixels }, max)?; | |
| 406 | 414 | Ok(Generated { | |
| 407 | 415 | size: t.size, | |
| @@ -301,6 +301,14 @@ fn status_bar( | |||
| 301 | 301 | .as_ref() | |
| 302 | 302 | .filter(|s| state.mode == Mode::Image && Some(s.path.as_path()) == state.queue.current()) | |
| 303 | 303 | { | |
| 304 | + | if let Some(pb) = &state.playback { | |
| 305 | + | let at = format!("{}/{}", pb.index + 1, pb.len()); | |
| 306 | + | if !pb.animated { | |
| 307 | + | info.push(format!("PAGE {at}")); | |
| 308 | + | } else if !pb.playing { | |
| 309 | + | info.push(format!("FRAME {at}")); | |
| 310 | + | } | |
| 311 | + | } | |
| 304 | 312 | info.push(s.format.to_string()); | |
| 305 | 313 | info.push(format!("{}×{}", s.size[0], s.size[1])); | |
| 306 | 314 | info.push(format!("{}%", (state.view.scale() * 100.0).round())); | |
| @@ -32,6 +32,7 @@ impl Priority { | |||
| 32 | 32 | pub enum JobKey { | |
| 33 | 33 | Decode(PathBuf), | |
| 34 | 34 | Thumb(PathBuf), | |
| 35 | + | Refine(PathBuf), | |
| 35 | 36 | } | |
| 36 | 37 | ||
| 37 | 38 | type Work = Box<dyn FnOnce() + Send>; | |
[Binary file]
[Binary file]
[Binary file]
[Binary file]
| @@ -0,0 +1,6 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="12"> | |
| 2 | + | <rect width="8" height="6" fill="red"/> | |
| 3 | + | <rect x="8" width="8" height="6" fill="lime"/> | |
| 4 | + | <rect y="6" width="8" height="6" fill="blue"/> | |
| 5 | + | <rect x="8" y="6" width="8" height="6" fill="white"/> | |
| 6 | + | </svg> | |
[Binary file]
| @@ -0,0 +1,55 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | # Regenerate the decoder fixtures. Needs ImageMagick 7, cjxl, avifenc, heif-enc and exiftool. | |
| 3 | + | # Every image is the same 16×12 picture: red, green, blue and white quadrants (top-left, | |
| 4 | + | # top-right, bottom-left, bottom-right), so tests can check orientation by sampling corners. | |
| 5 | + | set -eu | |
| 6 | + | cd "$(dirname "$0")" | |
| 7 | + | tmp=$(mktemp -d) | |
| 8 | + | trap 'rm -rf "$tmp"' EXIT | |
| 9 | + | ||
| 10 | + | magick -size 8x6 xc:red xc:lime +append "$tmp/top.png" | |
| 11 | + | magick -size 8x6 xc:blue xc:white +append "$tmp/bottom.png" | |
| 12 | + | magick "$tmp/top.png" "$tmp/bottom.png" -append -strip PNG24:"$tmp/base.png" | |
| 13 | + | ||
| 14 | + | cp "$tmp/base.png" quadrants.png | |
| 15 | + | magick "$tmp/base.png" -quality 95 quadrants.jpg | |
| 16 | + | magick "$tmp/base.png" quadrants.bmp | |
| 17 | + | magick "$tmp/base.png" quadrants.tga | |
| 18 | + | magick "$tmp/base.png" quadrants.qoi | |
| 19 | + | magick "$tmp/base.png" quadrants.ppm | |
| 20 | + | magick "$tmp/base.png" -define webp:lossless=true quadrants.webp | |
| 21 | + | cjxl -q 100 "$tmp/base.png" quadrants.jxl >/dev/null 2>&1 | |
| 22 | + | avifenc -q 100 -y 444 "$tmp/base.png" quadrants.avif >/dev/null | |
| 23 | + | heif-enc -q 100 -p chroma=444 "$tmp/base.png" -o quadrants.heic >/dev/null | |
| 24 | + | # Float data: linear light, values within 0..1, so it is only sRGB-encoded. | |
| 25 | + | magick "$tmp/base.png" -colorspace RGB -define quantum:format=floating-point quadrants.exr | |
| 26 | + | ||
| 27 | + | # EXIF orientations 1–8: pixels stored so that applying the tag gives the base picture. | |
| 28 | + | i=1 | |
| 29 | + | for op in "" "-flop" "-rotate 180" "-flip" "-transpose" "-rotate -90" "-transverse" "-rotate 90"; do | |
| 30 | + | # shellcheck disable=SC2086 | |
| 31 | + | magick "$tmp/base.png" $op -quality 95 "orient-$i.jpg" | |
| 32 | + | exiftool -q -overwrite_original -n -Orientation=$i "orient-$i.jpg" | |
| 33 | + | i=$((i + 1)) | |
| 34 | + | done | |
| 35 | + | ||
| 36 | + | # Animations: three frames, red, lime, blue, 50/100/150 ms (APNG goes through ffmpeg, which | |
| 37 | + | # keeps only the first delay). | |
| 38 | + | magick -size 16x12 -delay 5 xc:red -delay 10 xc:lime -delay 15 xc:blue -loop 0 anim.gif | |
| 39 | + | magick -size 16x12 -delay 5 xc:red -delay 10 xc:lime -delay 15 xc:blue -loop 0 \ | |
| 40 | + | -define webp:lossless=true anim.webp | |
| 41 | + | magick -size 16x12 -delay 5 xc:red -delay 10 xc:lime -delay 15 xc:blue -loop 0 APNG:anim.png | |
| 42 | + | cjxl anim.gif anim.jxl >/dev/null 2>&1 | |
| 43 | + | ||
| 44 | + | # Three pages. | |
| 45 | + | magick -size 16x12 xc:red xc:lime xc:blue pages.tif | |
| 46 | + | ||
| 47 | + | cat > drawing.svg <<'EOF' | |
| 48 | + | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="12"> | |
| 49 | + | <rect width="8" height="6" fill="red"/> | |
| 50 | + | <rect x="8" width="8" height="6" fill="lime"/> | |
| 51 | + | <rect y="6" width="8" height="6" fill="blue"/> | |
| 52 | + | <rect x="8" y="6" width="8" height="6" fill="white"/> | |
| 53 | + | </svg> | |
| 54 | + | EOF | |
| 55 | + | gzip -9 -n -c drawing.svg > drawing.svgz | |
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]