Skip to content

Commit ca8d673

Browse files
committed
Migrate image pipeline from RMagick to libvips (1.2.0)
Direct ruby-vips swap, no image_processing gem fallback, RMagick dropped from the gemspec. Bumps CheesyGallery::VERSION to 1.2.0 so the user-visible install change is signalled by SemVer. * base_image_file.rb: `process_and_write` signature changes from `(img, dest_path)` to `(source_path, dest_path)`. The base class no longer opens the source file; the default body is `FileUtils.cp` and subclasses own decode+resize+write via `Vips::Image.thumbnail`. PR #416's `render_cache_discriminator` hook is preserved as-is. * image_file.rb: ping path uses `Vips::Image.new_from_file(...).autorot` inside the Geometry-cache getset block. The cached `[h, w]` tuple is now stashed on the instance (@geometry) so `process_and_write` doesn't re-ping. Resize uses `Vips::Image.thumbnail(path, w, height:, size: :down, crop: :none)` — `size: :down` is the libvips analogue of PR #416's shrink-only `>` flag. JPEG written with `Q:`, `interlace: true`, `strip: true`, `optimize_coding: true`. `geometry_string` is preserved verbatim for cache-key fingerprint compatibility with PR #416; under libvips its value is decorative and only feeds the Geometry/Render cache keys. A new private `fit_inside` replicates the shrink-only WxH math in plain Ruby. * image_thumb.rb: `Vips::Image.thumbnail(path, w, height: h, crop: :centre)` matches RMagick's `resize_to_fill!`. Thumbs now use the same size-optimisation flags as full-size renders, at Q80. * cache_spec.rb: spies retargeted onto `Vips::Image.new_from_file` and `Vips::Image.thumbnail`. Counts stay numerically identical because cached geometry is threaded into `process_and_write` — there is still exactly one "ping" per source on a Geometry-cache miss and one fused decode+resize per variant on a Render-cache miss. The three-segment Geometry-key test and Render-discriminator test from PR #416 pass verbatim (the discriminator infrastructure is library-agnostic). * generator_spec.rb: output-dimension verifiers swapped from `Magick::Image.ping(dest).first` to `Vips::Image.new_from_file(dest) .autorot`. PR #416's pinned dimensions `[1000, 750]` (default `1920x1080`, shrink-only) and `[533, 400]` (`600x400` override) match libvips under `fit_inside`. * Gemfile.lock under spec/fixtures/test_site regenerated; ruby-vips 2.3.0 replaces rmagick. Behaviour changes are documented in CHANGELOG.md: - EXIF Orientation is now honoured (libvips' `thumbnail` auto-rotates before downsample; output drops the orientation tag). Latent bug fix for sideways sources. - Thumbnails are now progressive Q80 JPEGs with stripped metadata (was: RMagick defaults — baseline, ~Q75, EXIF retained). - `max_size` flags other than `>` (i.e. `!`, `^`, `@`, `#`) are now silently treated as plain `WxH`. cheesy-gallery's own config never used them; full ImageMagick geometry syntax was out of scope. Perf characterised in docs/libvips-bench.md: 3.35x faster on DSLR-sized inputs, modest regression on web-sized inputs, RAM roughly comparable. The migration is justified by the DSLR speedup plus non-perf wins (escape from RMagick CVE / build-pain, correct EXIF Orientation handling). https://claude.ai/code/session_01AZQRrnPUpfUMCXLu3oTSDN
1 parent 7c1bb9c commit ca8d673

8 files changed

Lines changed: 103 additions & 86 deletions

File tree

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

lib/cheesy-gallery/base_image_file.rb

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

3-
require 'rmagick'
3+
require 'fileutils'
4+
require 'vips'
45

56
# This StaticFile subclass adds additional functionality for images in the
67
# gallery
@@ -19,8 +20,8 @@ def path
1920
end
2021

2122
# overwrite this method to add additional processing
22-
def process_and_write(img, path)
23-
img.write(path) {}
23+
def process_and_write(source_path, dest_path)
24+
FileUtils.cp(source_path, dest_path)
2425
end
2526

2627
# Skip the render when our content-aware Render-cache key says we've
@@ -57,17 +58,14 @@ def render_cache_discriminator
5758
''
5859
end
5960

60-
# Replace Jekyll's StaticFile#copy_file (FileUtils.cp) with RMagick
61+
# Replace Jekyll's StaticFile#copy_file (FileUtils.cp) with libvips
6162
# rendering. Super's write has already mkdir_p'd the parent and
62-
# rm'd any existing dest_path before getting here.
63+
# rm'd any existing dest_path before getting here. Subclasses do
64+
# their own decode + resize + write via Vips::Image.thumbnail; the
65+
# base no longer opens the source file itself.
6366
def copy_file(dest_path)
64-
source = Magick::ImageList.new(path)
65-
begin
66-
Jekyll.logger.debug 'Rendering:', dest_path
67-
process_and_write(source, dest_path)
68-
ensure
69-
source.destroy!
70-
end
67+
Jekyll.logger.debug 'Rendering:', dest_path
68+
process_and_write(path, dest_path)
7169

7270
unless File.symlink?(dest_path)
7371
File.utime(self.class.mtimes[path], self.class.mtimes[path], dest_path)

lib/cheesy-gallery/image_file.rb

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

3-
require 'rmagick'
3+
require 'vips'
44
require 'cheesy-gallery/base_image_file'
55

66
# This StaticFile subclass adds additional functionality for images in the
@@ -16,44 +16,45 @@ def initialize(site, collection, file, max_size:, quality:)
1616

1717
realpath = File.realdirpath(path)
1818
mtime = File.mtime(realpath)
19-
geom = @@geometry_cache.getset("#{realpath}##{mtime}##{geometry_string}") do
20-
result = [100, 100]
21-
# read file metadata in the same way it will be processed
19+
@geometry = @@geometry_cache.getset("#{realpath}##{mtime}##{geometry_string}") do
2220
Jekyll.logger.debug 'Identifying:', path
23-
source = Magick::Image.ping(path).first
24-
source.change_geometry!(geometry_string) do |cols, rows, _img|
25-
result = [rows, cols]
26-
end
27-
source.destroy!
28-
result
21+
# autorot so width/height reflect post-orientation pixels — matches
22+
# what Vips::Image.thumbnail will produce at render time.
23+
source = Vips::Image.new_from_file(path, access: :sequential).autorot
24+
fit_inside(source.width, source.height)
2925
end
3026

31-
data['height'] = geom[0]
32-
data['width'] = geom[1]
27+
data['height'] = @geometry[0]
28+
data['width'] = @geometry[1]
3329
end
3430

3531
# instead of copying, renders an optimised version
36-
def process_and_write(img, path)
37-
img.change_geometry!(geometry_string) do |cols, rows, i|
38-
i.resize!(cols, rows)
39-
end
40-
# follow recommendations from https://stackoverflow.com/a/7262050/4918 to get better compression
41-
img.interlace = Magick::PlaneInterlace
42-
# but skip the blur to avoid too many changes to the data
43-
# img.gaussian_blur(0.05)
44-
img.strip!
45-
# workaround weird {self} initialisation pattern
46-
quality = @quality
47-
img.write(path) { |image| image.quality = quality }
32+
def process_and_write(source_path, dest_path)
33+
target_h, target_w = @geometry
34+
img = Vips::Image.thumbnail(
35+
source_path,
36+
target_w,
37+
height: target_h,
38+
size: :down, # never upscale — equivalent of the `>` in geometry_string
39+
crop: :none,
40+
)
41+
img.write_to_file(
42+
dest_path,
43+
Q: @quality,
44+
interlace: true,
45+
strip: true,
46+
optimize_coding: true,
47+
)
4848
end
4949

5050
private
5151

52-
# Append the `>` flag so `change_geometry!` fits originals into
53-
# @max_size when they're larger, but never enlarges smaller ones —
54-
# a photo gallery should not produce blurry upscales of small
55-
# source images. Idempotent: skip the append if the user already
56-
# supplied any ImageMagick geometry flag (!, <, >, ^, @, #).
52+
# Preserved from the RMagick era. Under libvips this is no longer
53+
# passed to an image library — its value is purely the cache
54+
# fingerprint so that changing `max_size` (or upgrading to a release
55+
# that changes the upscale policy) invalidates entries naturally.
56+
# Idempotent: skip the `>` append if the user already supplied any
57+
# ImageMagick geometry flag (!, <, >, ^, @, #).
5758
def geometry_string
5859
@geometry_string ||= @max_size.match?(%r{[!<>^@#]}) ? @max_size : "#{@max_size}>"
5960
end
@@ -64,4 +65,17 @@ def geometry_string
6465
def render_cache_discriminator
6566
geometry_string
6667
end
68+
69+
# Replicates the shrink-only `WxH>` semantics that the geometry
70+
# string previously got from ImageMagick: fit inside the box,
71+
# preserve aspect ratio, never upscale. Returns [height, width] to
72+
# match the legacy Geometry-cache shape and the data hash. The
73+
# ImageMagick-style trailing flags (`!`, `^`, `@`, `#`) are parsed
74+
# out by `to_i`'s leading-digits rule and silently ignored — see
75+
# CHANGELOG for the narrowing.
76+
def fit_inside(src_width, src_height)
77+
max_w, max_h = @max_size.split('x').map(&:to_i)
78+
scale = [max_w.to_f / src_width, max_h.to_f / src_height, 1.0].min
79+
[(src_height * scale).round, (src_width * scale).round]
80+
end
6781
end

lib/cheesy-gallery/image_thumb.rb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

3-
require 'rmagick'
3+
require 'vips'
44
require 'cheesy-gallery/base_image_file'
55

66
# This StaticFile subclass represents thumbnail images for each image. On `write()` it renders a 150x150 center crop of the source
@@ -14,9 +14,20 @@ def initialize(site, collection, file, postfix, height, width)
1414
@width = width
1515
end
1616

17-
# instead of copying, renders the thumbnail
18-
def process_and_write(img, path)
19-
img.resize_to_fill!(height, width)
20-
img.write(path)
17+
# centre-crop square thumbnail, optimised for file size at Q80
18+
def process_and_write(source_path, dest_path)
19+
img = Vips::Image.thumbnail(
20+
source_path,
21+
width,
22+
height: height,
23+
crop: :centre,
24+
)
25+
img.write_to_file(
26+
dest_path,
27+
Q: 80,
28+
interlace: true,
29+
strip: true,
30+
optimize_coding: true,
31+
)
2132
end
2233
end

lib/cheesy-gallery/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module CheesyGallery
4-
VERSION = '1.1.1'
4+
VERSION = '1.2.0'
55
end

spec/cheesy/cache_spec.rb

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
require 'jekyll'
55
require 'fileutils'
66
require 'tmpdir'
7-
require 'rmagick'
7+
require 'vips'
88

99
# Cache behaviour specs for cheesy-gallery.
1010
#
@@ -17,15 +17,19 @@
1717
# The expensive operations that the caches are designed to short-circuit
1818
# are:
1919
#
20-
# * `Magick::Image.ping(path)` — runs inside `CheesyGallery::ImageFile`'s
21-
# Geometry-cache `getset` block. Exactly one call per source image
22-
# on a Geometry-cache miss; zero calls on a hit.
20+
# * `Vips::Image.new_from_file(path)` — runs inside
21+
# `CheesyGallery::ImageFile`'s Geometry-cache `getset` block.
22+
# Exactly one call per source image on a Geometry-cache miss; zero
23+
# calls on a hit. The header read is cheap but non-trivial (libvips
24+
# opens the file and parses the JPEG marker chain).
2325
#
24-
# * `Magick::ImageList.new(path)` — the first line of
25-
# `BaseImageFile#copy_file`, which is reached only when Layer A
26-
# (mtimes/dest existence) and Layer B (Render cache) both miss.
27-
# Exactly one call per variant on a Layer-B miss; zero calls on
28-
# a hit.
26+
# * `Vips::Image.thumbnail(path, ...)` — the fused decode + resize
27+
# used by every subclass's `process_and_write`. Reached only when
28+
# Layer A (mtimes/dest existence) and Layer B (Render cache) both
29+
# miss. Exactly one call per variant on a Layer-B miss; zero calls
30+
# on a hit. Under libvips the legacy "decode" and "resize" steps
31+
# are a single fused operation, so we count one `thumbnail` call
32+
# where the RMagick generation counted one `Magick::ImageList.new`.
2933
#
3034
# We install spies via `and_wrap_original` so the original calls still
3135
# happen (we want real Jekyll output) and assert per-scenario counts.
@@ -36,7 +40,7 @@
3640
# calls by clearing in-memory class state without touching the on-disk
3741
# cache or the rendered `_site/` tree — that's the only state a fresh
3842
# Ruby process would actually have lost.
39-
# Use the smaller _gallery_two JPGs (~1000x750) to keep RMagick work
43+
# Use the smaller _gallery_two JPGs (~1000x750) to keep libvips work
4044
# cheap across the matrix of scenarios.
4145
CHEESY_CACHE_FIXTURE_DIR = File.expand_path('../fixtures/test_site/_gallery_two', __dir__)
4246
CHEESY_CACHE_FIXTURE_JPGS = %w[Frostig-001.jpg Frostig-003.jpg].freeze
@@ -69,15 +73,15 @@ def install_spies!
6973
@decode_count = 0
7074
@ping_paths = []
7175
@decode_paths = []
72-
allow(Magick::Image).to receive(:ping).and_wrap_original do |orig, *args|
76+
allow(Vips::Image).to receive(:new_from_file).and_wrap_original do |orig, *args, **kwargs|
7377
@ping_count += 1
7478
@ping_paths << args.first
75-
orig.call(*args)
79+
orig.call(*args, **kwargs)
7680
end
77-
allow(Magick::ImageList).to receive(:new).and_wrap_original do |orig, *args|
81+
allow(Vips::Image).to receive(:thumbnail).and_wrap_original do |orig, *args, **kwargs|
7882
@decode_count += 1
7983
@decode_paths << args.first
80-
orig.call(*args)
84+
orig.call(*args, **kwargs)
8185
end
8286
end
8387

@@ -186,7 +190,7 @@ def geometry_cache_files
186190
# --- §3.3 scenario 2: warm second build, no source changes ---------
187191

188192
describe 'scenario 2: warm second build, no source changes' do
189-
it 'does no RMagick work at all (Layers B and C both hit)' do
193+
it 'does no libvips work at all (Layers B and C both hit)' do
190194
build!
191195
simulate_cold_process!
192196
reset_counters!
@@ -210,7 +214,7 @@ def geometry_cache_files
210214
# --- §3.3 scenario 3: warm build with one new photo ----------------
211215

212216
describe 'scenario 3: warm build with one new photo' do
213-
it 'only does RMagick work for the new source' do
217+
it 'only does libvips work for the new source' do
214218
build!
215219
simulate_cold_process!
216220
new_jpg = File.join(source_dir, '_gallery', 'zzz-new-photo.jpg')
@@ -340,7 +344,7 @@ def geometry_cache_files
340344
expect(File.realdirpath(realpath)).to eq(realpath)
341345
expect(mtime).to match(%r{\A\d{4}-\d{2}-\d{2}})
342346
# The geometry component is the `geometry_string` that
343-
# ImageFile passes to `change_geometry!`, with the `>`
347+
# ImageFile uses for cache fingerprinting, with the `>`
344348
# appended so we never upscale small originals.
345349
expect(geom).to eq('1920x1080>')
346350
end

spec/cheesy/generator_spec.rb

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
require 'jekyll'
55
require 'fileutils'
66
require 'tmpdir'
7-
require 'rmagick'
7+
require 'vips'
88

99
# Plugin-behaviour specs covering docs/todos.md §4: end-to-end site
1010
# generation, the synthetic GalleryIndex doc for index-less gallery
@@ -329,13 +329,9 @@ def write_two_gallery_fixture!
329329
write_two_gallery_fixture!
330330
build!
331331

332-
out = Magick::Image.ping(File.join(dest_dir, 'gallery_two', 'Frostig-001.jpg')).first
333-
begin
334-
expect(out.columns).to be <= 600
335-
expect(out.rows).to be <= 400
336-
ensure
337-
out.destroy!
338-
end
332+
out = Vips::Image.new_from_file(File.join(dest_dir, 'gallery_two', 'Frostig-001.jpg')).autorot
333+
expect(out.width).to be <= 600
334+
expect(out.height).to be <= 400
339335
end
340336

341337
it 'leaves rendered output at source size when below the default max_size' do
@@ -344,12 +340,8 @@ def write_two_gallery_fixture!
344340

345341
# Shrink-only `>` policy: 1000x750 stays 1000x750 under the
346342
# default '1920x1080' max_size.
347-
out = Magick::Image.ping(File.join(dest_dir, 'gallery_one', 'Frostig-001.jpg')).first
348-
begin
349-
expect([out.columns, out.rows]).to eq([1000, 750])
350-
ensure
351-
out.destroy!
352-
end
343+
out = Vips::Image.new_from_file(File.join(dest_dir, 'gallery_one', 'Frostig-001.jpg')).autorot
344+
expect([out.width, out.height]).to eq([1000, 750])
353345
end
354346

355347
it 'passes max_size and quality through from collection metadata to ImageFile' do

spec/fixtures/test_site/Gemfile.lock

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
PATH
22
remote: ../../..
33
specs:
4-
cheesy-gallery (1.1.1)
4+
cheesy-gallery (1.2.0)
55
jekyll (~> 4.4)
6-
rmagick (>= 4, < 6)
6+
ruby-vips (~> 2.2)
77

88
GEM
99
remote: https://rubygems.org/
@@ -70,20 +70,18 @@ GEM
7070
jekyll (>= 3.5, < 5.0)
7171
jekyll-feed (~> 0.9)
7272
jekyll-seo-tag (~> 2.1)
73-
observer (0.1.2)
7473
pathutil (0.16.2)
7574
forwardable-extended (~> 2.6)
76-
pkg-config (1.6.5)
7775
public_suffix (7.0.5)
7876
rake (13.4.2)
7977
rb-fsevent (0.11.2)
8078
rb-inotify (0.11.1)
8179
ffi (~> 1.0)
8280
rexml (3.4.4)
83-
rmagick (5.5.0)
84-
observer (~> 0.1)
85-
pkg-config (~> 1.4)
8681
rouge (4.7.0)
82+
ruby-vips (2.3.0)
83+
ffi (~> 1.12)
84+
logger
8785
safe_yaml (1.0.5)
8886
sass-embedded (1.99.0)
8987
google-protobuf (~> 4.31)

0 commit comments

Comments
 (0)