Lensman
Auto-generates responsive WebP/AVIF variants of WordPress uploads and serves them via <picture> + srcset. Stops Lighthouse from shaming you for 4MB hero images. MIT.
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/rennerdo30/wp-lensman/archive/refs/heads/main.zipReadme
Lensman
Stop shipping 4 MB hero images. Lensman auto-generates WebP and AVIF variants of every uploaded JPEG/PNG, caches them on disk, and rewrites front-end
<img>tags into<picture>elements with a proper responsivesrcset. Lighthouse "Properly size images" / "Serve images in next-gen formats" warnings, gone.
What it does
-
On upload — hooks
wp_handle_uploadandadd_attachment:- Downscales the original to a sane maximum width (default 2400 px) so the WordPress media library never holds a 6000 px source.
- Pre-generates the standard responsive widths (
320, 480, 768, 1024, 1440, 1920) plus a WebP variant for each. - Optionally pre-generates AVIF variants where the runtime supports it.
add_attachmentcatcheswp media import, REST insertions, and theme seeders that bypasswp_handle_upload.
-
On render — hooks
wp_get_attachment_image_attributes,wp_get_attachment_image,the_content, andpost_thumbnail_html:- Picks the smallest cached variant that's still ≥ the requested width as the
srcfallback. - Emits a
srcsetlisting every cached variant withwdescriptors. - Adds a configurable default
sizesattribute ((max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw). - Wraps the resulting
<img>in a<picture>element with<source type="image/avif">and<source type="image/webp">ahead of it, so modern browsers pick the smaller format and old browsers keep working. This works for bothwp_get_attachment_image()-rendered images (hero carousels, post thumbnails, ACF image fields) and content<img>tags. - Adds
loading="lazy"anddecoding="async"when missing.
- Picks the smallest cached variant that's still ≥ the requested width as the
-
LCP preload hints — when
wp_get_attachment_image()is called withfetchpriority="high"in its attrs, Lensman captures the WebPsrcset+sizesand:- Emits
<link rel="preload" as="image" imagesrcset="…" imagesizes="…" fetchpriority="high">atwp_headpriority 1 for images rendered beforewp_head()ran. - Emits an HTTP
Link: <…>; rel=preload; as=image; imagesrcset=…; imagesizes=…header as a fallback so images rendered later in the body still get a (best-effort) preload signal — only when headers have not been flushed yet. - Bounded at 2 preloads per request (browsers throttle beyond that). First writer wins.
- Emits
-
Cache directory —
wp-content/uploads/lensman/cache/<hash>/<width>.<ext>, keyed by the source path + mtime. When a source is replaced in place, the hash changes and the old bucket becomes orphaned; a daily cron sweeps anything stale.- Created with
0775perms and best-effortchgrp www-data(orapache/nginx/http) so the webserver process can write into a cache that was originally created by a wp-cli--allow-rootinvocation. - On boot, if the cache root is not writable by the current PHP process, Lensman registers an admin notice with the exact
chownto run and disables<picture>emission for the rest of the request so no broken variant URLs ship in the HTML. - Pre-created on activation, idempotent.
- Created with
-
Concurrency-safe — every variant write goes through a
flock-guarded tempfile + atomicrename, so two simultaneous requests for the same uncached size don't corrupt each other.
Install
- Drop the plugin into
wp-content/plugins/lensman/and activate (or zip-install). - Visit Lensman in the wp-admin sidebar (camera icon).
- Defaults are sane. Tune quality sliders + srcset widths to taste.
Composer — no version has been tagged yet, so track the default branch:
"repositories": [ { "type": "vcs", "url": "https://github.com/rennerdo30/wp-lensman" } ],
"require": { "rennerdo30/lensman": "dev-main" }
Lensman has no runtime Composer dependencies (PHP 8.1+ and either GD or Imagick is
all it needs), so a plain checkout into wp-content/plugins/ works just as well.
Lint
composer lint # php -l over every file outside vendor/
Settings
The plugin lives under a top-level Lensman menu in wp-admin (camera dashicon).
| Setting | Default | Purpose |
|---|---|---|
| Generate WebP | on | Emit a WebP <source> for every image |
| Generate AVIF | off | Emit an AVIF <source> (requires runtime support) |
| On-the-fly resize | on | Downscale fresh uploads that exceed max master width |
| Max master width | 2400 px | Cap for the original file kept in the media library |
| JPEG quality | 82 | Quality for cached JPEG variants |
| WebP quality | 80 | Quality for cached WebP variants |
| AVIF quality | 60 | Quality for cached AVIF variants |
| Srcset widths | 320,480,768,1024,1440,1920 |
Comma-separated list of w descriptors |
Default sizes |
(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw |
Fallback sizes for images without one |
Regenerate all cached images flushes the cache and schedules a background job (via wp_schedule_single_event) that re-primes every JPEG/PNG attachment in the media library. The admin request returns immediately; the job runs in WP-Cron context so it doesn't time out.
Lighthouse impact
On a representative WordPress site with un-optimised hero photography:
| Metric | Before | After |
|---|---|---|
| Largest image (4 MB JPEG, 5184 × 3456) | served as-is | 1024w WebP, ≈ 90 % smaller |
| "Properly size images" savings | ≈ 3.5 MB | 0 |
| "Serve images in next-gen formats" savings | ≈ 2.8 MB | 0 |
| LCP on slow 4G | 8–12 s | 1.5–3 s |
Numbers will vary with content; the dominant cost on most WordPress sites is the hero image, and that's exactly what Lensman targets.
Architecture
wp upload
│
▼
┌──────────────────────┐ ┌──────────────────────────┐
│ wp_handle_upload │────────▶│ Resize\Engine::on_upload │
└──────────────────────┘ │ • downscale master │
│ • prime srcset widths │
│ • prime WebP / AVIF │
└──────────────┬───────────┘
│
▼
wp-content/uploads/lensman/cache/
<sha256(path|mtime)[0..16]>/
320.jpeg / 320.webp / …
front-end render
│
▼
┌─────────────────────────────────────────┐
│ wp_get_attachment_image_attributes │
│ + the_content / post_thumbnail_html │
└────────────────┬────────────────────────┘
▼
┌─────────────────────────────────────────┐
│ Filters\Content::rewrite_tag() │
│ • resolve src → uploads path │
│ • build srcset (JPEG/PNG) │
│ • build srcset (WebP) │
│ • build srcset (AVIF, optional) │
│ • wrap <img> in <picture><source> │
└─────────────────────────────────────────┘
Roadmap (v0.3 deferred)
- Photo-PNG → JPEG conversion at upload, behind an opt-in setting. When a PNG > 500 KB and > 800 px wide has no alpha channel, it is almost certainly a photograph that was saved as PNG by mistake — recompressing it as JPEG saves 70-90 % bandwidth. The risk is breaking attachment URLs that other code has cached; v0.2 deliberately keeps the master untouched and lets the
<picture>WebP source carry the win instead. - Output-buffer safety net for
<picture>rewrite that catches images injected by template-rendered (not filter-rendered) code paths. lensman_picture($attachment_id, $args)theme helper for themes that want WebP-first markup outside of thewp_get_attachment_image()/the_contentpaths.- WP-CLI command (
wp lensman regenerate,wp lensman flush,wp lensman stats).
Known limitations
- No SVG, no GIF. Vector and animated formats fall through untouched. (You don't want either of them as a
<picture>source anyway.) - AVIF support depends on the runtime. PHP 8.1+ with the GD
aviffunctions, or ImageMagick built againstlibheif. The settings page reports availability and disables the AVIF checkbox when it's unsupported. - External URLs are ignored. Lensman only rewrites images served from
wp_upload_dir(). Images on a remote CDN keep their original markup. - No EXIF orientation rewrite. We strip metadata in cached variants for size, but we honour the source's EXIF orientation by passing it through Imagick's
getImageOrientation()(GD has no equivalent, so GD-only servers may rotate landscape→portrait incorrectly; Imagick fixes this). - No retina-density (
2x/3x) markup. We usewdescriptors instead, which is the modern best practice and lets the browser pick based on viewport + DPR together. If your theme hardcodes2xsrcsets, those will pass through untouched. - Cache is not garbage-collected aggressively. The daily cron deletes buckets untouched for 30 days. If you regenerate often, watch
wp-content/uploads/lensman/size — or use the Flush cache button. - LCP preload only fires for images rendered before
wp_head()runs. The HTTPLink:header fallback covers in-body renders, but only whenheaders_sent() === falseat the time the<picture>is built — i.e., when no output has been flushed. Themes that flush output buffer early may miss the preload signal; in that case, render the hero image inside awp_headcallback (priority< 1) to guarantee it.
Author
License
MIT.