Image Workflow
Image Workflow
Daily reference for managing product images. For CDN architecture details, see CDN Architecture.
Quick Reference
flowchart LR
A["/imgs/slug.heic"] -->|pnpm convimgs:upload| B["static/images/p/slug/ + R2"]
B -->|Netlify proxy| C["takazudomodular.com/images/p/slug/*"]
| Task | Command |
|---|---|
| Add/update image | pnpm convimgs:upload photo.heic |
| Process all + upload all | pnpm convimgs:upload |
| Upload only (no processing) | pnpm r2:upload |
| Download from R2 (fresh clone) | pnpm r2:download |
| Check for orphans on R2 | pnpm r2:check-orphans |
| Delete orphans from R2 | pnpm r2:check-orphans:delete |
| Verify R2 sync | pnpm r2:verify-sync |
| Full metadata update to main | /l-metadata-update (Claude Code skill) |
Safety Rules
- NEVER put files directly into
static/images/p/— always go through/imgs/→ convimgs pipeline /imgs/(Dropbox symlink) is the single source of truth for originalsstatic/images/p/is derived output, can be regenerated anytime- R2 is the canonical remote store — no auto-delete, cleanup is always manual with confirmation
Adding / Updating Images
# 1. Drop source image into /imgs/ (Dropbox symlink)
# Supports: jpg, png, webp, heic, gif, avif
# 2. Process + upload in one step
pnpm convimgs:upload new-product.heic # single file
pnpm convimgs:upload # all files
# 3. Rebuild metadata database
pnpm build:metadata
For updating, replace the source file in /imgs/ (same filename = same slug), then run the same commands. The upload script uses incremental sync (MD5/ETag comparison), so only changed files are transferred.
Updating metadata-db.json on main
After processing and uploading new images, metadata-db.json must be updated and merged to main so that all branches can use the new image metadata (dimensions, blurhash, etc.). Use the Claude Code skill:
/l-metadata-update
This skill can be invoked from any branch and automates the full flow:
- Cleans
static/images/p/and downloads existing images from R2 (ensures only new images are processed) - Runs
convimgs:upload(process new images + upload to R2) - Runs
build:metadatato regeneratemetadata-db.json - Creates a
metadata-update-YYMMDD-HHMMbranch from main, commits, creates a PR, and merges - Returns to the original branch
The metadata-update-* branch naming convention skips CI checks (pr-checks and security), since these branches only change metadata-db.json.
After merging, merge main into your working branch to pick up the metadata:
git fetch origin main && git merge origin/main --no-edit
Without this step, ResponsiveImage components won’t have blurhash placeholders or responsive srcsets for the new images. The images will still display (R2 serves them), but without the optimized loading experience.
Flags like --cleanup, --skip-ogp, --skip-mercari are passed through to convimgs:
pnpm convimgs:upload --cleanup photo.heic
Deleting Images
There is no auto-delete. R2 cleanup is always manual with confirmation:
- Remove the source from
/imgs/ - Remove local
static/images/p/slug/ - Run orphan detection to clean R2:
# Report orphans (R2 slugs with no original in /imgs/)
pnpm r2:check-orphans
# Delete orphans with interactive confirmation
pnpm r2:check-orphans:delete
--delete requires /imgs/ to be available (Dropbox symlink). On machines without /imgs/, the orphan check reports local-only orphans but cannot determine R2 orphans.
Fresh Clone / New Worktree
On a fresh clone, static/images/p/ is empty. Two options:
Option A: Download from R2 (recommended)
pnpm r2:download # all images
pnpm r2:download -- --slug oxi-one-mk2 # single slug
pnpm r2:download:dry # preview only
Option B: Dev without local images
pnpm buildworks fine (production uses R2 CDN URLs)pnpm devworks but product images will be broken locally (metadata/blurhash still available frommetadata-db.json)
Pre-Push Verification
run-b4push.sh runs verify-r2-sync.mjs automatically — warns (non-blocking) if local slugs are missing from R2. These images would cause 404s in production.
To check manually:
pnpm r2:verify-sync
Image Formats
Each slug produces these files in static/images/p/slug/:
| File | Purpose |
|---|---|
600w.webp, 900w.webp, 1200w.webp, 1600w.webp, 2000w.webp | Responsive images |
ogp.jpg | Social sharing image (1200x630) — only from __og / __ogonly files |
mercari.png | Marketplace listing image (1600px width) |
metadata.json | Dimensions, blurhash, file paths |
original.gif | Animated GIF originals (when applicable) |
Animated GIF handling
GIFs bypass the normal WebP conversion to preserve animation. The pipeline copies the original file as-is:
static/images/p/gif-slug/
├── metadata.json # hasVariants: false, originalFormat: "gif"
└── original.gif # Original file copied directly
Key differences from regular images:
- No WebP variants — no
600w.webpetc. - No Mercari PNG and no OGP generated
hasVariants: falsein metadata — signalsResponsiveImageto serveoriginal.gifinstead of a<picture>srcset- Blurhash is still generated for placeholder display
The Img MDX component extracts the slug from any /images/p/ path and routes to ResponsiveImage regardless of whether __metadata was injected by the remark plugin. ResponsiveImage then loads metadata via getImageMetadata(slug) and constructs the path /images/p/{slug}/original.{originalFormat}.
OGP Image Convention (__og / __ogonly suffixes)
The image pipeline uses __ (double underscore) as a delimiter for conversion rules. Three file types control OGP generation:
| Source file | Slug | Generates |
|---|---|---|
foobar.heic | foobar | WebP + mercari + metadata (no OGP) |
foobar__og.heic | foobar__og | WebP + mercari + metadata + ogp.jpg |
foobar__ogonly.heic | foobar__ogonly | ogp.jpg ONLY |
- No suffix — regular image, OGP skipped for faster processing
__og— full processing plus OGP. Use for product thumbnails that also serve as social sharing images. One source file generates everything needed__ogonly— OGP only, no WebP or mercari variants. Use for dedicated OGP images (e.g., brand OGP for site-wide default)- No slug stripping — the slug IS the filename without extension (e.g.,
foobar__og.heic→ slugfoobar__og) - The
--force-ogpCLI flag can force OGP generation on regular files
Generation methods
The processor automatically chooses between two OGP generation methods based on the source image’s aspect ratio:
Composite method (square/portrait sources, aspect ratio < 1.5):
The source image is centered at 600px on a 1200x630 canvas. The background is filled with a heavily blurred and darkened version of the same image, creating a visually appealing card. A subtle drop shadow is added to the foreground.
Landscape crop method (landscape sources, aspect ratio >= 1.5):
The source image is simply resized and cropped to fit 1200x630, since it already has a wide aspect ratio suitable for OGP.
The threshold is 1.5 (3
Best practices
- The source image for
__og/__ogonlycan be any aspect ratio — the processor handles square, portrait, and landscape sources automatically - Every page that needs social sharing must have an
ogp.jpg— when noogp.jpgexists, noog:imagemeta tag is emitted, and the OGP checker will flag this as an error - Use
__ogwhen you want a single source file to produce both display images and OGP - Use
__ogonlyfor dedicated social sharing images that don’t need display variants
Example
# In /imgs/ directory:
oxi-one-mk2.heic # → slug: oxi-one-mk2 → WebP + mercari + metadata
oxi-one-mk2__og.heic # → slug: oxi-one-mk2__og → WebP + mercari + metadata + ogp.jpg
brand-default__ogonly.heic # → slug: brand-default__ogonly → ogp.jpg ONLY
# Outputs in static/images/p/:
# static/images/p/oxi-one-mk2/ — 600w-2000w.webp, mercari.png, metadata.json
# static/images/p/oxi-one-mk2__og/ — 600w-2000w.webp, mercari.png, metadata.json, ogp.jpg
# static/images/p/brand-default__ogonly/ — ogp.jpg only
OGP image behavior
When a product page is shared on social media:
- If
ogp.jpgexists → use it (1200x630 JPEG, composite or landscape crop) - If
ogp.jpgdoes not exist → noog:imagemeta tag is emitted
The OGP checker (check-ogp-images.mjs) runs during CI and treats any page missing og:image as an error. To ensure every page has OGP coverage, use the __og or __ogonly suffix on source images.
MDX Frontmatter Image Fields
MDX articles use frontmatter fields to specify which image slug to use:
| Field | Purpose | Notes |
|---|---|---|
imgThumb | Display image slug (hero, thumbnails, cards) | Preferred field name |
heroImgUrl | Legacy alias for imgThumb | Still works, imgThumb takes precedence |
imgOgp | Optional OGP override slug | When set, OGP uses this instead of imgThumb |
Resolution order
imgThumbfield (if present)heroImgUrlfield (backward-compatible fallback)- Product image (if
productfield is set) - No image (OGP tag omitted)
OGP resolution
When generating Open Graph meta tags:
- If
imgOgpis set → use that slug’sogp.jpg - If
imgOgpis not set → useimgThumb/heroImgUrlslug’sogp.jpg - If the resolved slug has no
ogp.jpg→ noog:imagemeta tag is emitted
Example
---
title: About Takazudo Modular
imgThumb: bird
imgOgp: bird-ogp__ogonly
---
In this example, the page displays bird as the hero image, but when shared on social media, it uses the dedicated bird-ogp__ogonly OGP image.
R2 Environment Variables
All R2 scripts require these env vars (from .env or environment):
| Variable | Required | Purpose |
|---|---|---|
R2_ACCOUNT_ID | Yes | Cloudflare account identifier |
R2_ACCESS_KEY_ID | Yes | S3-compatible access key |
R2_SECRET_ACCESS_KEY | Yes | S3-compatible secret key |
R2_BUCKET_NAME | No | Bucket name (default: zmodmedia) |