Skip to content

Commit 5e90db8

Browse files
authored
Merge pull request #417 from DavidS/claude/libvips-migration-plan-5CjpP
Migrate image pipeline from RMagick to libvips (1.2.0)
2 parents 1e90303 + 8f41392 commit 5e90db8

17 files changed

Lines changed: 636 additions & 133 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ jobs:
1414
runs-on: ubuntu-latest
1515
steps:
1616
- uses: actions/checkout@v4
17-
- name: Install ImageMagick headers for rmagick
18-
run: sudo apt-get update && sudo apt-get install -y libmagickwand-dev
17+
- name: Install libvips for ruby-vips
18+
run: sudo apt-get update && sudo apt-get install -y libvips42 libvips-dev libvips-tools
1919
- uses: ruby/setup-ruby@v1
2020
with:
2121
ruby-version: ${{ matrix.ruby }}
@@ -31,8 +31,8 @@ jobs:
3131
runs-on: ubuntu-latest
3232
steps:
3333
- uses: actions/checkout@v4
34-
- name: Install ImageMagick headers for rmagick
35-
run: sudo apt-get update && sudo apt-get install -y libmagickwand-dev
34+
- name: Install libvips for ruby-vips
35+
run: sudo apt-get update && sudo apt-get install -y libvips42 libvips-dev libvips-tools
3636
- uses: ruby/setup-ruby@v1
3737
with:
3838
ruby-version: ${{ matrix.ruby }}
@@ -42,7 +42,22 @@ jobs:
4242
working-directory: spec/fixtures/test_site
4343
run: |
4444
bundle exec jekyll build --strict --trace --verbose
45-
find _site -type f
45+
find _site -type f
4646
echo -e '\n===\n'
47-
cat _site/gallery_one/index.html
47+
cat _site/gallery_one/index.html
4848
file _site/gallery_two/third/Morgenspaziergang-3.jpg_thumb.jpg
49+
- name: Verify rendered output (dimensions + EXIF strip)
50+
working-directory: spec/fixtures/test_site
51+
run: |
52+
set -euo pipefail
53+
# gallery_two: max_size 600x400; source 1000x750 → 533x400
54+
vipsheader _site/gallery_two/Frostig-001.jpg
55+
test "$(vipsheader -f width _site/gallery_two/Frostig-001.jpg)" = 533
56+
test "$(vipsheader -f height _site/gallery_two/Frostig-001.jpg)" = 400
57+
# 150x150 centre-cropped per-image thumb
58+
test "$(vipsheader -f width _site/gallery_two/Frostig-001.jpg_thumb.jpg)" = 150
59+
test "$(vipsheader -f height _site/gallery_two/Frostig-001.jpg_thumb.jpg)" = 150
60+
# EXIF must be stripped from every rendered output
61+
! vipsheader -a _site/gallery_two/Frostig-001.jpg | grep -i '^exif-'
62+
! vipsheader -a _site/gallery_two/Frostig-001.jpg_thumb.jpg | grep -i '^exif-'
63+
! vipsheader -a _site/gallery_one/2012-07-29-Eingeschlafen.jpg | grep -i '^exif-'

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,31 @@
1+
# 1.2.0
2+
3+
* **Breaking install change:** image processing switched from RMagick
4+
to libvips (via [ruby-vips](https://github.com/libvips/ruby-vips)).
5+
Install `libvips` (e.g. `apt install libvips42 libvips-dev` or
6+
`brew install vips`) instead of ImageMagick / libmagickwand-dev. See
7+
`docs/libvips-bench.md` for the perf comparison that motivated the
8+
swap.
9+
* **Behaviour change:** EXIF Orientation is now honoured. Sources that
10+
previously rendered sideways (Orientation 5–8) will now render
11+
upright. Output JPEGs no longer carry the orientation tag.
12+
* **Behaviour change:** every rendered JPEG (full-size + thumbs) now
13+
ships as a progressive JPEG with optimised Huffman tables, explicit
14+
4:2:0 chroma subsampling, and all metadata stripped (`keep: :none`).
15+
Thumbnails are encoded at Q80 and full-size renders honour the
16+
collection-metadata `quality` value (default 85). Previously
17+
thumbnails used RMagick's default encoder (baseline JPEG, ~Q75, EXIF
18+
retained). The explicit `subsample_mode: :on` guards against a
19+
silent ~25% file-size jump if a user bumps `quality` to ≥ 90 (where
20+
libvips' `:auto` mode would otherwise switch to 4:4:4).
21+
* **Robustness:** image header reads now use `fail_on: :error`, so a
22+
truncated or corrupt source JPEG aborts the build with a clear
23+
error rather than silently rendering a half-grey output.
24+
* **Behaviour narrowing:** `max_size` values with ImageMagick trailing
25+
flags other than `>` (i.e. `!`, `^`, `@`, `#`) are silently treated
26+
as the plain `WxH` form. cheesy-gallery's own config never used
27+
these flags; if you depended on them, file an issue.
28+
129
# 1.1.1
230

331
* Address some deprecations warnings thanks to @pdxmph

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## Project
44

55
`cheesy-gallery` — a Ruby gem providing a Jekyll plugin that turns
6-
directory trees of JPGs into navigable, RMagick-rendered photo galleries.
6+
directory trees of JPGs into navigable, libvips-rendered photo galleries.
77
Powers <https://www.cheesy.at/fotos/>.
88

99
## Structure

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
This is a jekyll photo gallery to manage large amounts of galleries and pictures. You can see the results at https://www.cheesy.at/fotos/.
44

5+
## Requirements
6+
7+
Image rendering is done by [libvips](https://www.libvips.org/) via the
8+
[ruby-vips](https://github.com/libvips/ruby-vips) gem. Install libvips
9+
before installing the gem:
10+
11+
- Debian / Ubuntu: `sudo apt install libvips42 libvips-dev`
12+
- macOS: `brew install vips`
13+
- Other distros: see the [libvips install docs](https://www.libvips.org/install.html)
14+
515
## Installation
616

717
Follow Jekyll's documentation on [how to install plugins](https://jekyllrb.com/docs/plugins/installation/) using "cheesy-gallery" as name for the gem and plugin.

cheesy-gallery.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,5 @@ Gem::Specification.new do |spec|
3939
spec.add_development_dependency 'rubocop'
4040

4141
spec.add_dependency 'jekyll', '~> 4.4'
42-
spec.add_dependency 'rmagick', '>= 4', '< 6'
42+
spec.add_dependency 'ruby-vips', '~> 2.2'
4343
end

docs/cache-analysis.md

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
# Cache Mechanism Analysis (2026-05-11)
22

3-
How `cheesy-gallery` 1.1.1 caches work, how they sit on top of (and
3+
How `cheesy-gallery` 1.2.0 caches work, how they sit on top of (and
44
sometimes around) Jekyll 4.4.1's own caching, where invalidation
55
happens, and what happens once you put a `git-annex` worktree
66
underneath it all.
77

8+
> **Note for readers of v1.2+:** this doc was originally written for the
9+
> RMagick-backed v1.1.x. Layers A–C and the invalidation model are
10+
> unchanged under the libvips backend. **Layer D** has been rewritten
11+
> below to describe libvips' operation cache instead of RMagick's
12+
> decoded pixel cache. Code snippets that mention `Magick::Image.ping`
13+
> or `Magick::ImageList.new` reflect the historical implementation;
14+
> the current call sites are `Vips::Image.new_from_file` (replacing
15+
> the ping) and `Vips::Image.thumbnail` (replacing the decode+resize
16+
> pair, now fused). The cache-spec spy targets are updated to match —
17+
> see `spec/cheesy/cache_spec.rb`.
18+
819
## TL;DR
920

1021
- The plugin maintains **two named `Jekyll::Cache` instances** that
@@ -209,25 +220,47 @@ re-read at write time — geometry is only used to populate
209220
width=… height=…>` attributes. So Layer C does not gate I/O the way
210221
Layer B does; it gates the per-image `Magick::Image.ping` call.
211222

212-
### 1.4 Layer D — RMagick's internal cache
223+
### 1.4 Layer D — libvips operation cache
213224

214-
When we *do* render (Layer A and B both miss), `copy_file` in
215-
`base_image_file.rb:49-62` runs:
225+
When we *do* render (Layer A and B both miss), the subclass's
226+
`process_and_write` runs `Vips::Image.thumbnail(source_path, ...)`
227+
directly — libvips fuses decode + shrink-on-load + resize + (for
228+
thumbnails) centre-crop into a single operation. The base class no
229+
longer opens the source file itself; it just hands the source path
230+
to the subclass:
216231

217232
```ruby
218-
source = Magick::ImageList.new(path)
219-
begin
220-
process_and_write(source, dest_path)
221-
ensure
222-
source.destroy!
233+
# base_image_file.rb
234+
def copy_file(dest_path)
235+
Jekyll.logger.debug 'Rendering:', dest_path
236+
process_and_write(path, dest_path)
237+
unless File.symlink?(dest_path)
238+
File.utime(self.class.mtimes[path], self.class.mtimes[path], dest_path)
239+
end
240+
@@render_cache[render_cache_key(dest_path)] = true
223241
end
242+
243+
# image_file.rb#process_and_write (full-size)
244+
img = Vips::Image.thumbnail(source_path, target_w, height: target_h,
245+
size: :down, crop: :none)
246+
img.write_to_file(dest_path, Q: @quality, interlace: true,
247+
strip: true, optimize_coding: true)
224248
```
225249

226-
`Magick::ImageList.new` decodes the JPEG into a pixel cache. RMagick
227-
keeps that decoded image in memory (and, depending on
228-
`MAGICK_TEMPORARY_PATH` and `MAGICK_DISK_LIMIT`, on disk too) until
229-
`destroy!` is called. The generator's *only* concession to this layer
230-
is the `collection.files.sort!` on `generator.rb:117`:
250+
What used to be "decode the entire JPEG into a pixel buffer and then
251+
resize" is now one library call that streams only the rows it needs
252+
(JPEG shrink-on-load at 1/2, 1/4, or 1/8 inside the codec, plus
253+
in-memory resize). There is **no separate decoded-pixel cache** to
254+
size, and no `destroy!` lifecycle — the `Vips::Image` is freed by GC.
255+
256+
libvips does keep a small **operation cache** of recently-compiled
257+
operation graphs (default ~100 entries; tunable via
258+
`Vips.cache_set_max`). That's a process-local performance optimisation,
259+
not a correctness-affecting cache; it's transparent to our code and
260+
to the four-layer model above.
261+
262+
The generator's `collection.files.sort!` on `generator.rb:117` still
263+
helps Layer D, but for a different reason now:
231264

232265
```ruby
233266
# sort files by source path, so that we have better cache hits when
@@ -238,12 +271,14 @@ is the `collection.files.sort!` on `generator.rb:117`:
238271
collection.files.sort! { |a, b| a.path <=> b.path }
239272
```
240273

241-
Sorting by source path keeps the full-size variant, the
242-
`*_thumb.jpg`, and (if applicable) the `*_index.jpg` for the same
243-
source next to each other in iteration order, so the underlying file
244-
data is more likely to be hot in the OS page cache when each variant
245-
opens it. There's no shared `Magick::ImageList` instance — each
246-
subclass opens, decodes, processes, and destroys independently.
274+
Sorting by source path keeps the full-size variant and its thumb(s)
275+
adjacent in iteration order. Under libvips this benefits **OS page
276+
cache locality** (each `Vips::Image.thumbnail` re-opens the file; the
277+
JPEG bytes are warm from the previous variant's open) and gives the
278+
libvips operation cache a better chance of reusing a previously-
279+
compiled graph. The TODO in the comment is now obsolete: there's no
280+
`ImageList` instance to share, because there's no separate decode
281+
step.
247282

248283
This is the cache layer most affected by the choice of source-image
249284
storage (local FS vs. `git-annex`-resolved symlink vs. networked

docs/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ Internal documentation and reports for `cheesy-gallery`.
1313
- [Cache Mechanism Analysis](cache-analysis.md) — the four cache layers
1414
the plugin sits on top of, how they invalidate, and how `git-annex`
1515
source storage interacts with each layer (generated 2026-05-11).
16+
- [libvips Bench](libvips-bench.md) — perf comparison that motivated
17+
the 1.2.0 RMagick → libvips migration; methodology, numbers across
18+
small / DSLR workloads, and the gate decision (generated 2026-05-12).
1619

1720
## Working lists
1821

docs/jekyll-api-review.md

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -222,28 +222,31 @@ is moot (Jekyll already covers it); one is still open.
222222

223223
Estimated effort: ~half a day, all behind existing tests.
224224

225-
### 3.2 Same architecture, swap RMagick for libvips
225+
### 3.2 Same architecture, swap RMagick for libvips _(landed in 1.2.0)_
226226

227-
The numbers from `jekyll_picture_tag`'s migration and OpsLevel's blog
228-
suggest **5–10× build-time speedup** and **roughly an order of magnitude
229-
less RAM**. RMagick has also had recurring CVE / build-pain issues, while
230-
`ruby-vips` ships as a thin FFI wrapper.
231-
232-
Two ways to land this:
233-
234-
- **Drop-in via the [`image_processing`](https://github.com/janko/image_processing)
235-
gem.** That gem exposes a single API over both libvips and
236-
ImageMagick/MiniMagick, so we could keep RMagick as a fallback and
237-
default to vips. Smallest behavioural risk.
238-
- **Direct `ruby-vips`.** Fewer dependencies, but a chunkier diff in
239-
`image_file.rb` / `image_thumb.rb`.
227+
Originally a forward-looking option; landed in `cheesy-gallery 1.2.0`
228+
on top of PR #416 via the `claude/libvips-migration-plan-5CjpP` branch.
240229

241-
Either way, the plugin's external interface (the collection metadata
242-
keys, the generated layout data) does not change. CI matrix needs to
243-
ensure libvips is installed (`apt install libvips`).
244-
245-
Estimated effort: 1–2 days; bulk of the work is parity testing against
246-
existing fixtures.
230+
The numbers from `jekyll_picture_tag`'s migration and OpsLevel's blog
231+
suggested **5–10× build-time speedup** and **roughly an order of magnitude
232+
less RAM**. cheesy-gallery's empirical numbers turned out workload-
233+
dependent (see `docs/libvips-bench.md`): a **3.35× speedup at DSLR
234+
source sizes** (the production workload), and a slight regression for
235+
already-web-sized sources. RAM is roughly comparable to RMagick across
236+
both workloads, not the 10× public claim. The migration was justified
237+
on the DSLR speedup plus two non-perf wins: escape from RMagick's CVE /
238+
build-pain churn, and correct EXIF Orientation handling (latent bug
239+
under RMagick).
240+
241+
The chosen path was **direct `ruby-vips`** (no `image_processing` gem,
242+
no RMagick fallback). The plugin's external interface (collection
243+
metadata keys, generated layout data) did not change. Subclass shape:
244+
`process_and_write` now takes `(source_path, dest_path)` and calls
245+
`Vips::Image.thumbnail` directly; the base class no longer opens the
246+
source file. CI installs `libvips42 libvips-dev libvips-tools` in
247+
place of `libmagickwand-dev`. See `CHANGELOG.md` for the user-facing
248+
notes (EXIF Orientation, thumb encoder settings, geometry-flag
249+
narrowing).
247250

248251
### 3.3 Split into two gems
249252

0 commit comments

Comments
 (0)