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.

  • Performance
  • $image-optimization
  • 153 lines
  • 3 files
  • ~1,750 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- image-optimization

Installs to ~/.agents/skills/image-optimization/ for every project on this machine.

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

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.

~/.agents/skills/image-optimization/SKILL.md

--- name: image-optimization description: Cuts image weight on websites without visible quality loss - picks AVIF, WebP or JPEG per image type, compresses with sharp or an image CDN… license: MIT ---

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

ContentDefaultFallback / notes
Photos, complex imageryAVIFWebP, then JPEG (mozjpeg). AVIF is ~20–50% smaller than JPEG at equal quality, slower to encode
Screenshots, UI, text-heavyWebP lossless or PNG via oxipngAVIF can blur fine text at low quality; check
Logos, icons, diagramsSVG optimised with SVGOInline small SVGs; never raster at 4x
Transparency + photo contentWebP or AVIF with alphaPNG photo with alpha is usually the heaviest file on the page
AnimationMP4/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

SituationChoose
Static site, images in repo, fixed setBuild-time (sharp script, Vite/Astro image plugins); commit sources, generate outputs
CMS or user uploads, many sizes neededImage CDN with URL transforms: Cloudinary f_auto,q_auto, imgix auto=format,compress, Cloudflare Images / /cdn-cgi/image/width=800,format=auto/
Next.jsnext/image with images.formats: ['image/avif', 'image/webp']; configure remotePatterns; watch optimizer CPU and cache on self-hosting
Upload endpointResize 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 width and height (or CSS aspect-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+, priority before) only on the LCP image.
  • CSS background images are discovered late and cannot be lazy-loaded natively; for the hero, use an <img> with object-fit: cover instead.
  • 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: Accept on 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 effort 4 or lower in build pipelines with many images, or move encoding off the request path.
  • Retina: 2x assets 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.yamlgenerated from this page

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.py133 lines, 4.5 KB
#!/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.

esc