Image Optimization skill for Codex
Smaller images, same pixels, faster hero. Codex audits every image for weight, dimensions and bits per pixel with a bundled header-parsing script, then picks a format and quality per content type, resizes to rendered dimensions, and chooses between a build-time sharp pipeline and an image CDN. It fixes loading order too: the hero gets high fetch priority, everything below the fold loads lazily with reserved space, and GIFs become video.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- image-optimization Installs to ~/.agents/skills/image-optimization/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install image-optimization The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project image-optimization Installs to .agents/skills/image-optimization/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/image-optimization.zipunzip -o image-optimization.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) image-optimization PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $image-optimization, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Cuts image weight on websites without visible quality loss - picks AVIF, WebP or JPEG per image type, compresses with sharp or an image CDN (Cloudinary, imgix, Cloudflare Images, Next.js Image), sizes images to their rendered dimensions, lazy-loads below-the-fold media, prioritises the LCP image and converts GIFs to video. Includes a header-parsing audit script that flags heavy and oversized files. Use when pages ship multi-megabyte images, Lighthouse flags image delivery, LCP is an image, or a build or upload pipeline needs automatic image compression.
Use it when
- Pages ship heavy, oversized or legacy-format images
- The LCP element is an image and loads late or slowly
- An upload or build pipeline needs automatic resizing and compression
Use something else when
- You only need srcset, sizes and picture markup for art direction: use responsive-images
- You are batch-editing a photo folder outside any website: use image-batch-processing
How to check the result
Re-run the audit script and the throttled Network panel: total image bytes drop sharply, LCP is no slower, and a side-by-side check at 2x zoom shows no visible artefacts.
The mistake it prevents
Lazy-loading the hero image along with everything else, which saves bytes but makes LCP worse.
Done well means: each image is served at roughly the size it renders, in the most efficient format the browser accepts, at the lowest quality that is visually indistinguishable, and the LCP image starts downloading as early as possible while everything below the fold waits.
Workflow
- [ ] 1. Inventory: where images come from (repo, CMS, user uploads) and how they are served
- [ ] 2. Audit sizes and dimensions (script below + DevTools Network filtered to Img)
- [ ] 3. Choose the pipeline: build-time, image CDN, or framework optimizer
- [ ] 4. Set format + quality defaults; spot-check visually
- [ ] 5. Fix loading behaviour: LCP priority, lazy below the fold, dimensions set
- [ ] 6. Verify bytes saved and LCP unchanged or better
Audit
python3 scripts/audit_images.py public/ --max-kb 200 --max-dim 2560
python3 scripts/audit_images.py public/ --fail # exit 1 if anything is flagged (CI use)
It reads PNG, JPEG, GIF and WebP headers with the standard library and prints size,
dimensions, bits per pixel and flags: heavy, oversized, low-compression (JPEG/WebP
above 2 bpp), legacy-format? (large PNG/GIF, often a photo saved as PNG). AVIF and SVG
are listed without dimensions.
In the browser, compare each image’s intrinsic size with its rendered size (DevTools
Elements hover, or Lighthouse image-delivery-insight). An image downloaded at 2x its
rendered width on the target DPR is waste.
Format decision
| Content | Default | Fallback / notes |
|---|---|---|
| Photos, complex imagery | AVIF | WebP, then JPEG (mozjpeg). AVIF is ~20–50% smaller than JPEG at equal quality, slower to encode |
| Screenshots, UI, text-heavy | WebP lossless or PNG via oxipng | AVIF can blur fine text at low quality; check |
| Logos, icons, diagrams | SVG optimised with SVGO | Inline small SVGs; never raster at 4x |
| Transparency + photo content | WebP or AVIF with alpha | PNG photo with alpha is usually the heaviest file on the page |
| Animation | MP4/WebM <video autoplay muted loop playsinline> | Animated GIF is 5–20x larger than video |
AVIF and WebP are supported in all current major browsers. Serve fallbacks with
<picture> or content negotiation (CDN reads Accept, responds with Vary: Accept).
Markup for srcset, sizes and art direction is covered by $responsive-images.
Compression settings (sharp)
import sharp from 'sharp';
const base = sharp(input).rotate() // apply EXIF orientation
.resize({ width: 1600, withoutEnlargement: true }); // cap at max rendered width x DPR
await base.clone().avif({ quality: 50, effort: 4 }).toFile(`${out}.avif`);
await base.clone().webp({ quality: 75 }).toFile(`${out}.webp`);
await base.clone().jpeg({ quality: 78, mozjpeg: true, progressive: true }).toFile(`${out}.jpg`);
- sharp strips metadata by default, which removes GPS and camera data; keep it that way for user uploads (privacy) and only keep the ICC profile when colour accuracy matters.
- Starting points: AVIF quality 45–60, WebP 70–80, JPEG 72–82. Tune on 5–10 representative images by eye at 1x and 2x zoom; use a perceptual metric (SSIMULACRA2, butteraugli, DSSIM) if the team wants numbers.
- Widths: generate a small set (e.g. 480, 800, 1200, 1600, 2000) rather than every size.
CLI alternatives when Node is not available: cwebp -q 75 in.png -o out.webp,
avifenc from libavif, oxipng -o 4 in.png, svgo in.svg -o out.svg. Installing any of
these needs network or a package manager; ask before installing in a Codex sandbox.
GIF to video:
ffmpeg -i in.gif -movflags +faststart -pix_fmt yuv420p \
-vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" -c:v libx264 -crf 28 out.mp4
Pipeline choice
| Situation | Choose |
|---|---|
| Static site, images in repo, fixed set | Build-time (sharp script, Vite/Astro image plugins); commit sources, generate outputs |
| CMS or user uploads, many sizes needed | Image CDN with URL transforms: Cloudinary f_auto,q_auto, imgix auto=format,compress, Cloudflare Images / /cdn-cgi/image/width=800,format=auto/ |
| Next.js | next/image with images.formats: ['image/avif', 'image/webp']; configure remotePatterns; watch optimizer CPU and cache on self-hosting |
| Upload endpoint | Resize and re-encode on upload (sharp in a background job), store the derivatives, reject absurd dimensions |
Image CDNs cost money per transform or bandwidth; mention the cost model before switching providers, and never change DNS or production CDN config without the user’s go-ahead.
Loading behaviour
<!-- LCP image: in HTML, eager, high priority, explicit size -->
<img src="hero-1600.avif" width="1600" height="900" alt="..." fetchpriority="high">
<!-- Below the fold: lazy, async decode, explicit size -->
<img src="card-800.webp" width="800" height="600" alt="..." loading="lazy" decoding="async">
- Always set
widthandheight(or CSSaspect-ratio) so lazy images do not shift layout. - Never
loading="lazy"on the LCP image or on images in the first viewport. - Next.js:
preload(Next 16+,prioritybefore) only on the LCP image. - CSS background images are discovered late and cannot be lazy-loaded natively; for the
hero, use an
<img>withobject-fit: coverinstead. - Placeholders: a dominant colour or a tiny blurred image (LQIP) improves perceived speed; inline placeholders over ~1 KB each bloat HTML.
Caching
Hashed image URLs get Cache-Control: public, max-age=31536000, immutable. Un-hashed CMS
URLs need revalidation or a version query parameter. Details in $http-caching.
Verification
- Re-run the audit script: flagged count and total MB drop.
- Network panel on a throttled mobile profile: image bytes per page before and after.
- LCP unchanged or better (a smaller but lazily loaded hero is a regression).
- Visual check of 5 images at 1x and 2x on a high-DPR screen; compare against originals.
Gotchas
- Re-encoding an already-compressed JPEG at high quality makes it bigger, not smaller. Always encode from the highest-quality source available.
Vary: Accepton CDN-negotiated formats is required, or a cache can serve AVIF to a client that asked for JPEG.- Very large AVIF encodes are slow; set
effort4 or lower in build pipelines with many images, or move encoding off the request path. - Retina:
2xassets for full-width heroes on mobile are rarely worth it beyond ~1600 px wide; cap DPR at 2. - Animated WebP/AVIF exist, but
<video>is still smaller and gives playback control.
Report template
## Images: <site / page>
Before: 42 images, 6.8 MB (home, mobile). After: 1.1 MB.
| Change | Files | Saved |
|---|---|---|
| Photos -> AVIF q50 with WebP/JPEG fallback | 31 | 4.9 MB |
| Hero moved to <img fetchpriority=high>, LCP 3.4 s -> 2.3 s | 1 | - |
Pipeline: <build-time sharp script / CDN transform URLs>
Follow-ups: <CMS uploads still unprocessed> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Image Optimization"
short_description: "Smaller images, same pixels, faster hero"
default_prompt: "$image-optimization Our homepage loads 7 MB of images, get it under 1.5 MB without making them look worse" scripts/audit_images.py
#!/usr/bin/env python3
"""Audit a folder of web images: size, pixel dimensions, bytes per pixel, format.
Standard library only. Reads image headers (PNG, JPEG, GIF, WebP) to get dimensions
without decoding. Flags files that are heavy, oversized, or use a legacy format.
Usage:
python3 audit_images.py <dir> [--max-kb 200] [--max-dim 2560] [--fail]
Exit code 1 with --fail when any file is flagged; 2 on bad arguments.
"""
from __future__ import annotations
import argparse
import struct
import sys
from pathlib import Path
EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"}
def png_dims(b: bytes):
if b[:8] == b"\x89PNG\r\n\x1a\n" and b[12:16] == b"IHDR":
return struct.unpack(">II", b[16:24])
return None
def gif_dims(b: bytes):
if b[:6] in (b"GIF87a", b"GIF89a"):
return struct.unpack("<HH", b[6:10])
return None
def webp_dims(b: bytes):
if b[:4] != b"RIFF" or b[8:12] != b"WEBP":
return None
chunk = b[12:16]
if chunk == b"VP8X":
w = int.from_bytes(b[24:27], "little") + 1
h = int.from_bytes(b[27:30], "little") + 1
return w, h
if chunk == b"VP8 ":
w, h = struct.unpack("<HH", b[26:30])
return w & 0x3FFF, h & 0x3FFF
if chunk == b"VP8L":
v = int.from_bytes(b[21:25], "little")
return (v & 0x3FFF) + 1, ((v >> 14) & 0x3FFF) + 1
return None
def jpeg_dims(path: Path):
with path.open("rb") as f:
if f.read(2) != b"\xff\xd8":
return None
while True:
byte = f.read(1)
while byte and byte != b"\xff":
byte = f.read(1)
while byte == b"\xff":
byte = f.read(1)
if not byte:
return None
marker = byte[0]
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
continue
seg = f.read(2)
if len(seg) < 2:
return None
length = struct.unpack(">H", seg)[0]
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
data = f.read(5)
h, w = struct.unpack(">HH", data[1:5])
return w, h
f.seek(length - 2, 1)
def dims(path: Path):
ext = path.suffix.lower()
if ext in (".jpg", ".jpeg"):
return jpeg_dims(path)
if ext in (".svg", ".avif"):
return None
head = path.open("rb").read(64)
return png_dims(head) or gif_dims(head) or webp_dims(head)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("root", type=Path)
ap.add_argument("--max-kb", type=float, default=200.0, help="flag files larger than this")
ap.add_argument("--max-dim", type=int, default=2560, help="flag images wider or taller than this")
ap.add_argument("--fail", action="store_true", help="exit 1 when anything is flagged")
a = ap.parse_args()
if not a.root.is_dir():
print(f"not a directory: {a.root}", file=sys.stderr)
return 2
rows = []
for p in a.root.rglob("*"):
if p.is_file() and p.suffix.lower() in EXTS and "node_modules" not in p.parts:
size = p.stat().st_size
try:
d = dims(p)
except (OSError, struct.error):
d = None
flags = []
if size > a.max_kb * 1024:
flags.append("heavy")
if d and max(d) > a.max_dim:
flags.append("oversized")
bpp = None
if d and d[0] * d[1] > 0:
bpp = size * 8 / (d[0] * d[1])
if p.suffix.lower() in (".jpg", ".jpeg", ".webp") and bpp > 2.0 and size > 50 * 1024:
flags.append("low-compression")
if p.suffix.lower() in (".png", ".gif") and size > 100 * 1024:
flags.append("legacy-format?")
rows.append((size, p, d, bpp, flags))
rows.sort(key=lambda r: r[0], reverse=True)
total = sum(r[0] for r in rows)
print(f"{'KB':>9} {'WxH':>11} {'bpp':>5} flags / file")
for size, p, d, bpp, flags in rows:
wh = f"{d[0]}x{d[1]}" if d else "-"
b = f"{bpp:.2f}" if bpp is not None else "-"
print(f"{size / 1024:9.1f} {wh:>11} {b:>5} {','.join(flags) or '.'} {p.relative_to(a.root)}")
flagged = [r for r in rows if r[4]]
print(f"\n{len(rows)} images, {total / 1024 / 1024:.2f} MB total, {len(flagged)} flagged")
return 1 if (a.fail and flagged) else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.