We want to talk about why most WordPress image plugins exist as separate, single-purpose tools — and why we think that's the wrong approach.
If you manage WordPress sites, you've probably installed some combination of these: an AI alt text generator, a compression plugin, a WebP converter, a media file renamer, and maybe something to catch broken images. Each one solves a real problem. But together, they create new ones.
They fight over hook priority on wp_get_attachment_image. They store overlapping metadata without knowing about each other. They enqueue separate admin scripts on the same pages. And when one of them breaks on a WordPress update, you're debugging a five-plugin interaction.
ImageCraft is our answer to this. One plugin, one pipeline, every image optimization task in a single codebase. It's free and open source on WordPress.org.
We want to walk through the technical decisions, because we think they're more interesting than a feature list.
Direct AI provider integration
Most AI alt text plugins work on a credit model. You buy credits from the developer, they proxy your request to OpenAI or Claude, and pocket the margin. The markup is significant — GPT-4o-mini costs about $0.15 per 100 images at retail, but credit-based plugins charge $3–6 for the same volume.
ImageCraft connects directly to the AI provider from your server. No middleman, no credit packs. You pay the provider at their published rates.
Three providers are supported: Anthropic (Claude), OpenAI, and Google Gemini. Each extends a BaseAIProvider class that handles image fetching, base64 encoding, MIME detection, and prompt construction. Adding a new provider means implementing one method: generateAltText().
API keys are AES-256-CBC encrypted in a custom database table. The REST API never returns the key — only a boolean has_key field.
Combined meta generation (one call, three outputs)
A naive implementation would make separate API calls for alt text, title, and caption. Three network round trips, three token charges, three latency waits.
ImageCraft makes one call that returns all three in a structured JSON response:
{
"alt_text": "Tan leather crossbody bag with brass buckle and adjustable strap",
"title": "Aria Crossbody Bag - Tan Leather",
"caption": "Handcrafted crossbody bag from the Aria Collection, featuring full-grain tan leather and antique brass hardware."
}
The prompt instructs the model to return this exact shape. A parseMetaJson() helper handles the response — including edge cases like markdown-wrapped JSON, extra fields, and truncated responses. Each field has a configurable max length with a capText() function that truncates at the last word boundary rather than mid-word.
For WooCommerce images, the prompt includes product metadata (name, SKU, categories, price) before the image, so the model writes with product context rather than describing the image generically.
Server-side compression: why not an external service?
Most compression plugins — ShortPixel, Imagify, TinyPNG — upload your images to an external API, compress them there, and download the result. This makes sense if your server doesn't have good image libraries. But most modern hosts have Imagick installed, and it's plenty capable.
ImageCraft compresses directly on the server:
- Detect if Imagick is available; fall back to GD
- Strip EXIF metadata and color profiles (these add kilobytes browsers don't use)
- Apply progressive JPEG encoding (loads top-to-bottom instead of block-by-block)
- Optionally downscale if the longest edge exceeds a threshold (default 2048px)
- Write to a temp file and compare sizes
- Only commit if the compressed file is actually smaller
That last step is important. Some already-optimized images (especially PNGs with good compression) get larger after re-encoding. The never-enlarge guard prevents this silently.
Originals are backed up to wp-content/uploads/icais-originals/ with the same directory structure. One-click restore copies the backup over the compressed version.
// Simplified never-enlarge guard
$tempPath = $this->compressToTemp($sourcePath, $quality);
if (filesize($tempPath) >= filesize($sourcePath)) {
unlink($tempPath);
return; // original is already optimal
}
WebP sidecars: the least-invasive approach
There are broadly three ways to serve WebP on WordPress:
Rewrite rules —
.htaccessor nginx config that serves a.webpfile when the browser sendsAccept: image/webp. Fragile. Breaks on some hosts. Requires server-level config.URL replacement — change the image URL in the HTML from
.jpgto.webp. Breaks caching. Confuses CDNs. Hard to reverse.<picture>wrapping — keep the original<img>intact, wrap it in a<picture>element with<source>entries for WebP/AVIF. The browser picks the best format. The original URL stays as the fallback.
We went with approach 3. The converter generates sidecar files (photo.jpg.webp, photo.jpg.avif) next to the originals — for the full-size image and all registered intermediate sizes. A manifest is stored in post meta (_icais_nextgen) with relative paths and byte savings.
On the frontend, two hooks handle delivery:
-
wp_content_img_tag(WP 6.0+) — wraps content images -
post_thumbnail_html— wraps featured images
Both hooks bail on is_admin() and are wrapped in try/catch (\Throwable) so a conversion bug can never break the frontend rendering. The worst case is: the <picture> wrapper fails silently and the original <img> renders as it always did.
<!-- Before -->
<img src="photo.jpg" srcset="photo-300x200.jpg 300w, photo-768x512.jpg 768w" ...>
<!-- After (automatic, on the frontend only) -->
<picture>
<source type="image/webp"
srcset="photo.jpg.webp 1024w, photo-300x200.jpg.webp 300w, photo-768x512.jpg.webp 768w">
<img src="photo.jpg" srcset="photo-300x200.jpg 300w, photo-768x512.jpg 768w" ...>
</picture>
Cleanup removes all sidecar files and the post meta. The original images and markup are exactly as they were before.
Broken image fallbacks: a get_post_metadata trick
The broken image fallback has two parts: a frontend JavaScript listener and a PHP metadata filter.
The JS part is straightforward — a capture-phase error listener on document that catches <img> load failures and swaps in the fallback URL. It also does an initial sweep for images with naturalWidth === 0.
The PHP part is more interesting. For missing featured images, we needed has_post_thumbnail() to return true even when the real thumbnail is gone. The function checks get_post_meta($postId, '_thumbnail_id', true), and if that returns a valid attachment ID that happens to be deleted, has_post_thumbnail() returns true but the image render fails.
For posts where _thumbnail_id is completely empty (the meta doesn't exist), we hook get_post_metadata with a filter:
add_filter('get_post_metadata', function ($value, $postId, $metaKey) {
if ($metaKey !== '_thumbnail_id') return $value;
// ... bail checks, recursion guard, settings check ...
return $fallbackImageId;
}, 10, 3);
This makes has_post_thumbnail() return true for any post type in the allow-list, and get_the_post_thumbnail() renders the fallback image. The real database is never modified — it's a read-time filter only.
The recursion guard matters because get_post_meta for the fallback image's own _thumbnail_id would trigger the same filter. A static flag prevents the infinite loop.
Usage limiting without being annoying
The free tier allows 50 actions per day across a rolling 24-hour window. Generation and compression share the same pool.
The implementation uses a single wp_options key per user:
// Stored as: icais_daily_usage_{userId}
[
'started_at' => 1691942400, // window start timestamp
'ids' => [41, 42, 43], // attachment IDs (deduped)
'compress_count' => 7, // compression count (NOT deduped)
]
There's an intentional asymmetry here: generation is deduped per attachment ID (re-generating alt text for the same image within the window is free), while every compression counts. The reasoning is that regenerating alt text is a common review workflow (generate, reject, regenerate with a different tone), but re-compressing the same image is an unusual action that probably indicates a settings change.
totalCount() = count(ids) + compress_count. When it hits 50, the API returns a 429 with a human-readable message about when the window resets.
Pro (detected via the ICAIS_PRO constant defined by the separate Pro add-on plugin) bypasses the check entirely.
Things we got wrong and fixed
A few mistakes from earlier versions, in case they're useful to someone building similar tools:
Catching \Exception instead of \Throwable. PHP 7+ throws \Error for things like missing classes and type mismatches. catch (\Exception) doesn't catch those. Any integration point with an external library or optional dependency now catches \Throwable. This one nearly caused a white-screen on a site that had an older PHP version with a missing Imagick extension.
Settings double-prefix. Our settings system prepends icais_ to every key, and the keys in the defaults array already start with icais_. So everything is stored as icais_icais_default_tone. We caught this too late to fix without breaking existing installations. It works. It's ugly. It's documented. We moved on.
The wizard gate. Early versions required an API key to get past the setup wizard. Then we added compression, which doesn't need an API key at all. Users who only wanted compression were blocked by a screen asking for an AI provider key. The wizard is now skippable, and the plugin works fine without any API key configured — you just don't get the AI features.
Try it
ImageCraft is free on WordPress.org. Search "ImageCraft" in your plugin installer, or search for "imagecraft ai alt text" to find it.
Powered By Softminal
Top comments (0)