Krolyxon
Commit e3295f9

Branches

Tags

Refs →

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.
Author
krolyxon
Date
2026-11-04 23:21 +0530
Parent
ab1dc3b
Commit
e3295f9a6a62f24c8ae7045938cfd470e2d352ce

+1929 −708

47 files changed

  1. D CLAUDE.md +0 −525
  2. M Cargo.lock +2 −0
  3. M Cargo.toml +2 −1
  4. M src/app.rs +134 −37
  5. M src/cache.rs +1 −6
  6. M src/decode/color.rs +200 −1
  7. A src/decode/external.rs +155 −0
  8. A src/decode/heif.rs +42 −0
  9. A src/decode/image_rs.rs +297 −0
  10. A src/decode/jxl.rs +67 −0
  11. M src/decode/mod.rs +455 −71
  12. A src/decode/raw.rs +69 −0
  13. A src/decode/svg.rs +104 −0
  14. M src/main.rs +1 −0
  15. M src/render/image.rs +154 −54
  16. M src/render/mod.rs +35 −6
  17. M src/state/mod.rs +131 −5
  18. M src/thumbs.rs +10 −2
  19. M src/ui/mod.rs +8 −0
  20. M src/worker.rs +1 −0
  21. A tests/fixtures/anim.gif
  22. A tests/fixtures/anim.jxl
  23. A tests/fixtures/anim.png
  24. A tests/fixtures/anim.webp
  25. A tests/fixtures/drawing.svg +6 −0
  26. A tests/fixtures/drawing.svgz
  27. A tests/fixtures/make.sh +55 −0
  28. A tests/fixtures/orient-1.jpg
  29. A tests/fixtures/orient-2.jpg
  30. A tests/fixtures/orient-3.jpg
  31. A tests/fixtures/orient-4.jpg
  32. A tests/fixtures/orient-5.jpg
  33. A tests/fixtures/orient-6.jpg
  34. A tests/fixtures/orient-7.jpg
  35. A tests/fixtures/orient-8.jpg
  36. A tests/fixtures/pages.tif
  37. A tests/fixtures/quadrants.avif
  38. A tests/fixtures/quadrants.bmp
  39. A tests/fixtures/quadrants.exr
  40. A tests/fixtures/quadrants.heic
  41. A tests/fixtures/quadrants.jpg
  42. A tests/fixtures/quadrants.jxl
  43. A tests/fixtures/quadrants.png
  44. A tests/fixtures/quadrants.ppm
  45. A tests/fixtures/quadrants.qoi
  46. A tests/fixtures/quadrants.tga
  47. A tests/fixtures/quadrants.webp
D CLAUDE.md View
@@ -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.
M Cargo.lock View
@@ -1665,6 +1665,7 @@ dependencies = [
16651665 "jxl-oxide-common",
16661666 "jxl-render",
16671667 "jxl-threadpool",
1668+ "moxcms 0.8.1",
16681669 "tracing",
16691670 ]
16701671
@@ -3075,6 +3076,7 @@ dependencies = [
30753076 "serde_json",
30763077 "signal-hook",
30773078 "thiserror 2.0.21",
3079+ "tiff",
30783080 "toml 1.2.0+spec-1.1.0",
30793081 "wgpu",
30803082 "winit",
M Cargo.toml View
@@ -17,7 +17,7 @@ fast_image_resize = "6.1.0"
1717 glyphon = "0.12.0"
1818 image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "tiff", "bmp", "ico", "tga", "pnm", "qoi", "dds", "hdr", "exr"] }
1919 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"] }
2121 kamadak-exif = "0.6.1"
2222 libheif-rs = { version = "3.0.0", optional = true }
2323 log = "0.4.35"
@@ -33,6 +33,7 @@ serde = { version = "1.0.229", features = ["derive"] }
3333 serde_json = "1.0.154"
3434 signal-hook = "0.4.6"
3535 thiserror = "2.0.21"
36+tiff = "0.11.3"
3637 toml = "1.2.0"
3738 wgpu = "30"
3839 winit = "0.30"
M src/app.rs View
@@ -2,7 +2,6 @@
22
33 use std::collections::{HashMap, HashSet, VecDeque};
44 use std::path::{Path, PathBuf};
5−use std::sync::atomic::{AtomicU32, Ordering};
65 use std::sync::{Arc, Mutex};
76 use std::time::{Duration, Instant, SystemTime};
87
@@ -49,6 +48,8 @@ const CONFIG_ERROR_TIME: Duration = Duration::from_secs(3600);
4948 /// In the grid, the selected image decodes once the cursor has rested this long, so `<CR>`
5049 /// opens it at once without decoding everything the cursor passes over.
5150 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);
5253 /// Two clicks on the same thumbnail this close together open it.
5354 const DOUBLE_CLICK: Duration = Duration::from_millis(400);
5455
@@ -74,6 +75,11 @@ pub enum UserEvent {
7475 mtime: Option<SystemTime>,
7576 result: Result<Thumb, ThumbError>,
7677 },
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+ },
7783 /// Output or the exit of a user command.
7884 Command { id: u64, event: exec::Event },
7985 /// `refresh` re-checked these files: their mtime, or `None` if they are gone.
@@ -182,7 +188,6 @@ pub struct App {
182188 pool: Pool,
183189 proxy: EventLoopProxy<UserEvent>,
184190 cache: ImageCache,
185− max_dim: Arc<AtomicU32>,
186191 /// Decoded images whose result event is still in the loop's queue.
187192 arriving: Arc<Mutex<HashSet<PathBuf>>>,
188193 gfx: Option<Gfx>,
@@ -211,6 +216,12 @@ pub struct App {
211216 confirm: Option<(Job, exec::Prepared)>,
212217 subscribers: Vec<Subscriber>,
213218 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>,
214225 /// `quit-no-output` ran: `-o` prints nothing.
215226 no_output: bool,
216227 pub error: Option<anyhow::Error>,
@@ -239,8 +250,6 @@ impl App {
239250 pool: Pool::new(),
240251 proxy,
241252 cache,
242− // Until the GPU reports its limit; uploads re-check.
243− max_dim: Arc::new(AtomicU32::new(16384)),
244253 arriving: Arc::default(),
245254 gfx: None,
246255 uploads: VecDeque::new(),
@@ -262,6 +271,9 @@ impl App {
262271 confirm: None,
263272 subscribers: Vec::new(),
264273 published: None,
274+ refine_wait: None,
275+ refining: None,
276+ refine_failed: HashSet::new(),
265277 no_output: false,
266278 error: None,
267279 };
@@ -281,14 +293,14 @@ impl App {
281293 return;
282294 }
283295 let proxy = self.proxy.clone();
284− let max_dim = self.max_dim.clone();
296+ let opts = self.decode_options();
285297 let arriving = self.arriving.clone();
286298 let path = path.to_path_buf();
287299 self.pool
288300 .submit(JobKey::Decode(path.clone()), prio, move || {
289301 let t = Instant::now();
290302 let mtime = mtime(&path);
291− let result = decode::decode(&path, max_dim.load(Ordering::Relaxed))
303+ let result = decode::decode(&path, &opts)
292304 .map(Arc::new)
293305 .map_err(|e| e.to_string());
294306 log::debug!("decoded {} in {:.1?}", path.display(), t.elapsed());
@@ -301,6 +313,94 @@ impl App {
301313 });
302314 }
303315
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+
304404 /// The current file and its preload neighbours, current first.
305405 fn window_paths(&self) -> Vec<PathBuf> {
306406 let q = &self.state.queue;
@@ -321,6 +421,9 @@ impl App {
321421 let now = Instant::now();
322422 if navigated {
323423 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;
324427 }
325428 let wanted = self.window_paths();
326429 self.update_title();
@@ -511,10 +614,10 @@ impl App {
511614 self.state.loading_since.get_or_insert(now);
512615 return;
513616 };
514− let img = fit_gpu(img, g.renderer.max_texture_dim());
515617 g.renderer.upload(path, &img, usize::MAX);
516618 self.uploads.retain(|(p, _)| p != path);
517619 self.state.show(path, img.source_size, img.format, now);
620+ self.state.set_frames(img.delays(), img.animated, now);
518621 self.request_redraw();
519622 }
520623
@@ -556,6 +659,7 @@ impl App {
556659 }
557660 let old = std::mem::replace(&mut self.config, loaded.config);
558661 let new = &self.config;
662+ decode::set_decoders(&new.decoders);
559663 let mut settings = Settings::from_config(new);
560664 self.opts.overrides.apply(&mut settings);
561665 let preload_changed = settings.preload != self.state.settings.preload;
@@ -1260,6 +1364,21 @@ impl App {
12601364 } else {
12611365 self.state.loading_since.is_none()
12621366 };
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+ }
12631382 let Some(g) = &mut self.gfx else { return };
12641383 if !self.shown_window {
12651384 g.window.set_visible(true);
@@ -1316,11 +1435,10 @@ impl App {
13161435 if !self.thumbs.arrivals.is_empty() {
13171436 return true;
13181437 }
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();
13241442 }
13251443 !self.uploads.is_empty()
13261444 }
@@ -1365,29 +1483,6 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
13651483 m.lock().unwrap_or_else(|e| e.into_inner())
13661484 }
13671485
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−
13911486 impl ApplicationHandler<UserEvent> for App {
13921487 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
13931488 if self.gfx.is_some() {
@@ -1430,8 +1525,6 @@ impl ApplicationHandler<UserEvent> for App {
14301525 }
14311526 };
14321527 log::debug!("GPU ready after {:.1?}", self.started.elapsed());
1433− self.max_dim
1434− .store(renderer.max_texture_dim(), Ordering::Relaxed);
14351528 let size = window.inner_size();
14361529 self.state.set_window(
14371530 size.width as f32,
@@ -1481,6 +1574,7 @@ impl ApplicationHandler<UserEvent> for App {
14811574 mtime,
14821575 result,
14831576 } => self.on_thumb(path, px, mtime, result),
1577+ UserEvent::Refined { path, result } => self.on_refined(path, result),
14841578 UserEvent::Command { id, event } => self.on_command(id, event, event_loop),
14851579 UserEvent::Refreshed(found) => self.on_refreshed(found),
14861580 UserEvent::Ipc(inc) => self.on_ipc(inc, event_loop),
@@ -1608,6 +1702,9 @@ impl ApplicationHandler<UserEvent> for App {
16081702 let at = t + SEQUENCE_TIMEOUT;
16091703 next = Some(next.map_or(at, |n| n.min(at)));
16101704 }
1705+ if let Some(at) = self.check_refine(now) {
1706+ next = Some(next.map_or(at, |n| n.min(at)));
1707+ }
16111708 if redraw {
16121709 self.request_redraw();
16131710 }
M src/cache.rs View
@@ -82,12 +82,7 @@ mod tests {
8282 use super::*;
8383
8484 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"))
9186 }
9287
9388 #[test]
M src/decode/color.rs View
@@ -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+}
A src/decode/external.rs View
@@ -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+}
A src/decode/heif.rs View
@@ -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+}
A src/decode/image_rs.rs View
@@ -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+}
A src/decode/jxl.rs View
@@ -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+}
M src/decode/mod.rs View
@@ -1,31 +1,134 @@
11 //! 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.
26
37 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;
418
519 use std::fs::File;
620 use std::io::Read;
721 use std::path::Path;
8−use std::sync::LazyLock;
22+use std::sync::{Arc, LazyLock};
23+use std::time::Duration;
924
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;
1329
1430 /// Refuse single images that would need more than this to decode.
1531 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+}
1641
1742 /// Straight-alpha RGBA8 in sRGB, oriented upright, ready to upload.
1843 pub struct DecodedImage {
44+ /// Pixel size of every frame.
1945 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,
2150 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.
2353 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+ }
2498 }
2599
26100 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+
27115 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()
29132 }
30133 }
31134
@@ -33,36 +136,171 @@ impl std::fmt::Debug for DecodedImage {
33136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34137 f.debug_struct("DecodedImage")
35138 .field("size", &self.size)
139+ .field("frames", &self.frames.len())
36140 .field("format", &self.format)
37141 .finish_non_exhaustive()
38142 }
39143 }
40144
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+
41174 static EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
42− ImageFormat::all()
175+ let mut v: Vec<&'static str> = ImageFormat::all()
43176 .filter(ImageFormat::reading_enabled)
44177 .flat_map(|f| f.extensions_str().iter().copied())
45178 .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
47194 });
48195
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> {
51197 path.extension()
52198 .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)
54200 }
55201
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.
57295 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+ }
66304 }
67305
68306 fn label(format: ImageFormat) -> &'static str {
@@ -86,52 +324,24 @@ fn label(format: ImageFormat) -> &'static str {
86324 }
87325 }
88326
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+ }
135345 }
136346
137347 #[cfg(test)]
@@ -144,10 +354,44 @@ mod tests {
144354 assert!(is_known_extension(Path::new("b.png")));
145355 assert!(!is_known_extension(Path::new("b.txt")));
146356 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);
147391 }
148392
149393 #[test]
150− fn decodes_and_downscales_png() {
394+ fn decodes_png_without_extension() {
151395 let dir = std::env::temp_dir().join(format!("remage-test-{}", std::process::id()));
152396 std::fs::create_dir_all(&dir).unwrap();
153397 let path = dir.join("noext");
@@ -155,11 +399,151 @@ mod tests {
155399 .save_with_format(&path, ImageFormat::Png)
156400 .unwrap();
157401 assert!(sniff(&path));
158− let img = decode(&path, 16).unwrap();
402+ let img = decode(&path, &Options::default()).unwrap();
159403 assert_eq!(img.format, "PNG");
160404 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);
163407 std::fs::remove_dir_all(&dir).unwrap();
164408 }
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+ }
165549 }
A src/decode/raw.rs View
@@ -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, &params)
28+ .ok()
29+ .flatten()
30+ .or_else(|| decoder.full_image(&src, &params).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, &params, 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+}
A src/decode/svg.rs View
@@ -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+}
M src/main.rs View
@@ -71,6 +71,7 @@ fn main() -> anyhow::Result<()> {
7171 }
7272 log::debug!("config loaded after {:.1?}", started.elapsed());
7373 let cfg = &loaded.config;
74+ decode::set_decoders(&cfg.decoders);
7475
7576 let mut paths = cli.paths.clone();
7677 if cli.stdin {
M src/render/image.rs View
@@ -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.
23
34 use bytemuck::{Pod, Zeroable};
45 use wgpu::util::DeviceExt;
@@ -30,11 +31,22 @@ pub struct AlphaStyle {
3031 pub radius: f32,
3132 }
3233
33−pub struct GpuImage {
34+/// One texture of an image: all of it, or a tile of a huge one.
35+struct Tile {
3436 texture: wgpu::Texture,
3537 bind_group: wgpu::BindGroup,
3638 uniforms: wgpu::Buffer,
39+ origin: [u32; 2],
40+ size: [u32; 2],
41+}
42+
43+pub struct GpuImage {
44+ tiles: Vec<Tile>,
3745 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,
3850 rows_done: u32,
3951 ready: bool,
4052 }
@@ -43,6 +55,26 @@ impl GpuImage {
4355 pub fn is_ready(&self) -> bool {
4456 self.ready
4557 }
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
4678 }
4779
4880 pub struct ImagePipeline {
@@ -202,9 +234,31 @@ impl ImagePipeline {
202234 }
203235 }
204236
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 {
208262 let mips = 32 - w.max(h).max(1).leading_zeros();
209263 let texture = device.create_texture(&wgpu::TextureDescriptor {
210264 label: Some("image"),
@@ -242,16 +296,23 @@ impl ImagePipeline {
242296 },
243297 ],
244298 });
245− GpuImage {
299+ Tile {
246300 texture,
247301 bind_group,
248302 uniforms,
303+ origin,
249304 size: [w, h],
250− rows_done: 0,
251− ready: false,
252305 }
253306 }
254307
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+
255316 /// Upload up to `budget` bytes of rows. Returns true once the image is complete.
256317 pub fn upload(
257318 &self,
@@ -261,42 +322,53 @@ impl ImagePipeline {
261322 img: &DecodedImage,
262323 budget: usize,
263324 ) -> 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,
280346 },
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 {
300372 queue.submit([]);
301373 }
302374 gpu.ready
@@ -363,21 +435,49 @@ impl ImagePipeline {
363435 alpha: f32,
364436 style: &AlphaStyle,
365437 ) {
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+ }
375457 }
376458
377459 pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, gpu: &GpuImage, nearest: bool) {
378460 pass.set_pipeline(&self.pipeline);
379− pass.set_bind_group(0, &gpu.bind_group, &[]);
380461 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);
382482 }
383483 }
M src/render/mod.rs View
@@ -84,7 +84,14 @@ impl Renderer {
8484 let info = adapter.get_info();
8585 log::debug!("adapter: {} ({:?})", info.name, info.backend);
8686 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+ });
8895 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
8996 label: Some("remage"),
9097 required_limits: limits,
@@ -145,10 +152,6 @@ impl Renderer {
145152 })
146153 }
147154
148− pub fn max_texture_dim(&self) -> u32 {
149− self.max_dim
150− }
151−
152155 pub fn resize(&mut self, width: u32, height: u32) {
153156 if width == 0 || height == 0 {
154157 return;
@@ -175,11 +178,19 @@ impl Renderer {
175178 }
176179
177180 /// 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.
178182 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+ }
179190 let gpu = match self.textures.get_mut(path) {
180191 Some(g) => g,
181192 None => {
182− let g = self.images.create(&self.device, img);
193+ let g = self.images.create(&self.device, img, self.max_dim);
183194 self.textures.entry(path.to_path_buf()).or_insert(g)
184195 }
185196 };
@@ -187,6 +198,24 @@ impl Renderer {
187198 .upload(&self.device, &self.queue, gpu, img, budget)
188199 }
189200
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+
190219 /// Start a fresh thumbnail atlas for `cell`-pixel thumbnails.
191220 pub fn reset_thumbs(&mut self, cell: u32) {
192221 self.atlas.reset(cell);
M src/state/mod.rs View
@@ -163,6 +163,32 @@ pub struct Shown {
163163 pub format: &'static str,
164164 }
165165
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+
166192 /// The previous image fading out under the new one.
167193 #[derive(Clone, Debug)]
168194 pub struct Fade {
@@ -226,6 +252,8 @@ pub struct AppState {
226252 pub nearest: Option<bool>,
227253 pub alpha_bg: AlphaBg,
228254 pub shown: Option<Shown>,
255+ /// Frames of the shown image, when it has more than one.
256+ pub playback: Option<Playback>,
229257 pub fade: Option<Fade>,
230258 pub loading_since: Option<Instant>,
231259 pub skipped: usize,
@@ -258,6 +286,7 @@ impl AppState {
258286 nearest: None,
259287 alpha_bg: AlphaBg::Dots,
260288 shown: None,
289+ playback: None,
261290 fade: None,
262291 loading_since: None,
263292 skipped: 0,
@@ -392,6 +421,9 @@ impl AppState {
392421 self.holds.clear();
393422 match mode {
394423 Mode::Image => {
424+ if let Some(pb) = self.playback.as_mut().filter(|p| p.playing) {
425+ pb.next = Some(now + pb.delay());
426+ }
395427 // Don't crossfade from whatever was open before the grid.
396428 if self.shown.as_ref().map(|s| s.path.as_path()) != self.queue.current() {
397429 self.clear_shown();
@@ -613,6 +645,22 @@ impl AppState {
613645 );
614646 }
615647 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+ },
616664 Action::Fullscreen(s) => fx.fullscreen = Some(s),
617665 Action::Bar(s) => {
618666 let bar = switch(s, self.bar);
@@ -652,12 +700,12 @@ impl AppState {
652700 | Action::Page(_)
653701 | Action::RowStart
654702 | 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(..)
660704 | 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(..)
661709 | Action::Slideshow(_)
662710 | Action::SlideshowDelay { .. }
663711 | Action::Info(_)
@@ -759,11 +807,35 @@ impl AppState {
759807 .reset(size, self.settings.fit, self.settings.zoom, now);
760808 self.loading_since = None;
761809 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)
762833 }
763834
764835 /// The current file can't be shown (or the queue emptied).
765836 pub fn clear_shown(&mut self) {
766837 self.shown = None;
838+ self.playback = None;
767839 self.fade = None;
768840 self.loading_since = None;
769841 }
@@ -840,6 +912,29 @@ impl AppState {
840912 wake(at);
841913 }
842914 }
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+ }
843938 if self.fade.as_ref().is_some_and(|f| !f.alpha.is_active(now)) {
844939 self.fade = None;
845940 redraw = true;
@@ -1016,6 +1111,37 @@ mod tests {
10161111 assert_eq!(s.cell_px(), 320);
10171112 }
10181113
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+
10191145 #[test]
10201146 fn tapped_pan_moves_a_minimum_distance() {
10211147 let (mut s, now) = state(1);
M src/thumbs.rs View
@@ -399,9 +399,17 @@ fn generate(path: &Path, max: u32) -> anyhow::Result<Generated> {
399399 if let Some(g) = jpeg_scaled(path, max)? {
400400 return Ok(g);
401401 }
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)?;
403408 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;
405413 let t = resize(Thumb { size, pixels }, max)?;
406414 Ok(Generated {
407415 size: t.size,
M src/ui/mod.rs View
@@ -301,6 +301,14 @@ fn status_bar(
301301 .as_ref()
302302 .filter(|s| state.mode == Mode::Image && Some(s.path.as_path()) == state.queue.current())
303303 {
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+ }
304312 info.push(s.format.to_string());
305313 info.push(format!("{}×{}", s.size[0], s.size[1]));
306314 info.push(format!("{}%", (state.view.scale() * 100.0).round()));
M src/worker.rs View
@@ -32,6 +32,7 @@ impl Priority {
3232 pub enum JobKey {
3333 Decode(PathBuf),
3434 Thumb(PathBuf),
35+ Refine(PathBuf),
3536 }
3637
3738 type Work = Box<dyn FnOnce() + Send>;
A tests/fixtures/anim.gif View

[Binary file]

A tests/fixtures/anim.jxl View

[Binary file]

A tests/fixtures/anim.png View

[Binary file]

A tests/fixtures/anim.webp View

[Binary file]

A tests/fixtures/drawing.svg View
@@ -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>
A tests/fixtures/drawing.svgz View

[Binary file]

A tests/fixtures/make.sh View
@@ -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
A tests/fixtures/orient-1.jpg View

[Binary file]

A tests/fixtures/orient-2.jpg View

[Binary file]

A tests/fixtures/orient-3.jpg View

[Binary file]

A tests/fixtures/orient-4.jpg View

[Binary file]

A tests/fixtures/orient-5.jpg View

[Binary file]

A tests/fixtures/orient-6.jpg View

[Binary file]

A tests/fixtures/orient-7.jpg View

[Binary file]

A tests/fixtures/orient-8.jpg View

[Binary file]

A tests/fixtures/pages.tif View

[Binary file]

A tests/fixtures/quadrants.avif View

[Binary file]

A tests/fixtures/quadrants.bmp View

[Binary file]

A tests/fixtures/quadrants.exr View

[Binary file]

A tests/fixtures/quadrants.heic View

[Binary file]

A tests/fixtures/quadrants.jpg View

[Binary file]

A tests/fixtures/quadrants.jxl View

[Binary file]

A tests/fixtures/quadrants.png View

[Binary file]

A tests/fixtures/quadrants.ppm View

[Binary file]

A tests/fixtures/quadrants.qoi View

[Binary file]

A tests/fixtures/quadrants.tga View

[Binary file]

A tests/fixtures/quadrants.webp View

[Binary file]