Skip to content

Re-encode scraped images as WebP - #2732

Merged
simon04 merged 5 commits into
mainfrom
webp
Sep 14, 2026
Merged

simon04 merged 5 commits into
mainfrom
webp

Conversation

@simon04

@simon04 simon04 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

What

Convert images to WebP as they're inlined by ImagesFilter:

format encoder mode
PNG cwebp -lossless -z 9 lossless
GIF gif2webp lossless, animation preserved
JPEG cwebp -q 80 -sharp_yuv lossy

Why

Measured over the images in my locally scraped docs:

  • PNG — 188 unique images, 3,216,234 → 2,722,582 bytes (85%)
  • GIF — down to roughly a quarter of their size; GIF's LZW leaves a lot on the table
  • JPEG — 22 images, 541,951 → 395,071 bytes (73%)

Images are base64-inlined into db.json, so the saving carries straight
into what users download.

Choosing the JPEG quality

JPEG is the only lossy conversion here. Quality 80 is the knee of the
size/SSIM curve over those 22 images (cwebp -print_ssim):

quality size mean SSIM worst SSIM
75 67% 20.5 dB 16.1 dB
80 79% 21.9 dB 17.9 dB
85 92% 23.5 dB 19.9 dB
90 111% 25.7 dB 22.6 dB

That's a mean SSIM of 0.9935, worst 0.984. Quality 85 gives up most of
the savings for little in return. It also matches the floor pngquant
already uses in .image_optim.yml, which sets allow_lossy: true — the
pipeline was already lossy for PNGs.

-sharp_yuv is on because documentation images are mostly screenshots
and diagrams, where chroma subsampling artifacts on text edges are the
visible failure mode.

Fallbacks

Conversion is skipped, leaving the original untouched, when:

  • the data isn't a format we convert (sniffed by magic bytes, not the
    Content-Type header, which is often wrong)
  • the PNG is animated — cwebp silently keeps only the first frame, so
    an acTL chunk before IDAT means skip. (gif2webp has no such
    problem and keeps every frame.)
  • the WebP comes out bigger. This is not hypothetical: 4 of 186 PNGs
    and 3 of 22 JPEGs grew, one PNG by 89%.
  • cwebp/gif2webp aren't installed — Errno::ENOENT is rescued, so
    scraping still works, just without conversion.

Breaking

WebP raises the browser requirements to Safari 14+, Edge 18+ and
iOS 14+ (from 11.1+, 17+ and 11.3+). README updated.

libwebp is a new scrape-time dependency; it isn't part of
image_optim_pack. Added to both Dockerfiles, CI and the devcontainer.

Notes

  • Images already scraped are unaffected; this applies on the next scrape.
  • ImagesFilter was missing from both filter reference lists — added.
  • Fixtures: the PNG is generated in-test with chunky_png; the GIF and
    JPEG are small files under test/files/.

@simon04
simon04 requested a review from a team as a code owner September 13, 2026 14:23
@simon04
simon04 requested a balanced review from Copilot September 14, 2026 05:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

APNG detection can misclassify valid animated PNGs and silently flatten them during conversion.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds WebP re-encoding to reduce the size of images embedded in generated documentation.

Changes:

  • Converts PNG, GIF, and JPEG images to WebP with safety fallbacks.
  • Adds encoder dependencies to development, CI, and container environments.
  • Updates tests, filter references, and browser requirements.
File summaries
File Description
lib/docs/filters/core/images.rb Implements WebP conversion and MIME handling.
test/lib/docs/filters/core/images_test.rb Tests conversion and fallback behavior.
test/files/image.gif Adds a GIF conversion fixture.
test/files/image.jpg Adds a JPEG conversion fixture.
README.md Updates browser compatibility requirements.
docs/scraper-reference.md Documents the default image filter.
docs/filter-reference.md Adds the image filter reference.
Dockerfile Installs WebP tools.
Dockerfile-alpine Installs Alpine WebP tools.
.github/workflows/test.yml Installs WebP tools in CI.
.devcontainer/devcontainer.json Installs WebP tools in the dev container.
Review details
  • Files reviewed: 9/11 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +57 to +62
def self.png?(data)
return false unless starts_with?(data, PNG_SIGNATURE)
# cwebp silently keeps the first frame of an animated PNG
idat = data.index('IDAT'.b)
idat.nil? || !data.byteslice(0, idat).include?('acTL'.b)
end
Comment on lines +169 to +173
it "keeps the PNG when the conversion doesn't pay off" do
@body = IMG_BODY
data = png_data
stub_request make_response(body: data, content_length: data.bytesize)
stub(Docs::ImagesFilter).convert_to_webp(data) { nil }
JPEG_SIGNATURE = "\xff\xd8\xff".b

# WebP q=80 is roughly equivalent to JPEG q=90, and -sharp_yuv keeps the
# edges of the screenshots and diagrams documentation is full of crisp.
Pipe PNGs through `cwebp -lossless` after image_optim, before the final
size check. Roughly 15% smaller across the PNGs in the existing docs.

Falls back to the PNG when the data isn't a PNG, when it's animated
(cwebp would silently drop all but the first frame), when the WebP comes
out bigger, or when cwebp isn't installed.

WebP needs Safari 14+, Edge 18+ and iOS 14+, well below the versions the
app already requires.
Route GIFs through gif2webp, sharing the size comparison and fallbacks
with the PNG path. The wins are larger than for PNGs -- the GIFs in the
existing docs drop to roughly a quarter of their size -- since GIF's LZW
leaves a lot on the table.

gif2webp keeps every frame, so animations survive the conversion.
Re-encode JPEGs with `cwebp -q 80 -sharp_yuv`, which takes the JPEGs in
the existing docs down to 73% of their size. Quality 80 is the knee of
the size/SSIM curve measured over those images (mean SSIM 0.9935, worst
0.984); 85 gives up most of the savings for little quality. -sharp_yuv
keeps text edges in screenshots and diagrams crisp.

The pipeline was already lossy for PNGs via pngquant, and jpegoptim
already strips EXIF, so dropping metadata here loses nothing new.

Move the GIF test fixture to test/files/ next to the new JPEG one.
Pipe ChunkyPNG's output straight into `cwebp -lossless` instead of writing
a PNG and running OptiPNG over it. No PNG is produced anymore and the two
sheets go from 462 to 302 kilobytes.

`--disable-optimization` now picks cwebp's fastest setting rather than
skipping OptiPNG, keeping dev boots cheap.

Sprockets 4 resolves `assets_compile` entries as paths, not globs, so the
`*.png` entry never matched the sprites and they were left out of the
manifest. List them explicitly.
The task compiles every scss.erb template, and _environment.scss.erb read
the `App` constant, which only exists once the app is loaded: invoking the
task directly died with a NameError.

Pass the templates a binding carrying `environment`, resolved from APP_ENV
and RACK_ENV the way Sinatra resolves it.
@simon04
simon04 requested a balanced review from Copilot September 14, 2026 11:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@simon04 simon04 self-assigned this Sep 14, 2026
@simon04
simon04 merged commit f6bbe55 into main Sep 14, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants