Appearance
Image pipeline: automatic responsive + WebP variants for every image in public/
Status: implementation plan, 2026-09-09. Branch feat/image-pipeline, worktree /Users/jeffjassky/Projects/scenedetect-images.
Goal
Every .jpg/.jpeg/.png under public/ automatically gets a ladder of resized variants plus WebP equivalents, with zero manual steps, and every place that renders an image (marketing components, blog heroes, blog body HTML) serves that ladder. Free (no paid CDN features). Incremental: only regenerate what changed.
Non-goals
- No change to OG image generation (
server/router/og-image.js). Untouched. - No change to serving/caching (
server/main.jsCache-Control is already done). - No AVIF. WebP only.
- No SVG/GIF/MP4 processing.
Design in one paragraph
One script (scripts/images.js) walks public/, hashes each source image, and writes <base>@w<N><ext> + <base>@w<N>.webp beside it for each rung of the ladder, skipping anything whose hash and outputs are already present. It writes public/image-manifest.json describing what exists. Derived files and the manifest are gitignored (regenerated on every yarn build/yarn serve via prebuild/preserve hooks, and in CI with an actions/cache so steady-state runs take seconds). Three consumers read the manifest: ResponsiveImage.vue, Capture.vue, and an isomorphic helper upgradeImages(html, manifest) that rewrites <img> tags in blog body HTML into <picture> — used both server-side (blog-meta-inject.js) and client-side (PageBlogArticle.vue).
Fixed contracts (every agent depends on these — do not deviate)
Derived file naming
For source /blog-media/x/hero.jpg (width W):
- Rungs:
[600, 1000, 1600]filtered to< W, plusmin(W, 1600)appended if not already present. Examples: W=2400 →[600,1000,1600]; W=1600 →[600,1000,1600]; W=900 →[600,900]; W=500 →[500]; W=1437 →[600,1000,1437]. - For each rung N:
/blog-media/x/hero@w<N>.jpg(same format as source; PNG stays PNG) and/blog-media/x/hero@w<N>.webp. - Never upscale. Never modify the source file.
- The existing convention is exactly this naming (
@w600/@w1000/@w1600), and 38 databasefeatureImagevalues point at…@w1600.jpg, so the rung set and the@wnaming must be preserved exactly.
Manifest: public/image-manifest.json
json
{
"version": 1,
"images": {
"/blog-media/x/hero.jpg": {
"width": 2400,
"height": 1260,
"ext": ".jpg",
"widths": [600, 1000, 1600],
"webp": true
}
}
}- Keys are site-absolute URL paths (leading
/, forward slashes, relative topublic/). versionis the script'sSCRIPT_VERSIONconstant. If it changes, everything regenerates.- Keys are sorted so the file diff is stable.
- Source sha1 hashes are NOT in the manifest (it is inlined into the client bundle; 647 hashes cost 18 KB gzipped). They live in a sidecar
public/.image-cache.json({version, hashes: {key: sha1}}) that onlyscripts/images.jsreads. - Consumers must treat a missing key as "no variants, use the plain
src".
Client-side manifest import
Vue components import it as import manifest from '@/../public/image-manifest.json'; (webpack resolves @ to src/). The prebuild/preserve hooks guarantee it exists before webpack runs. Do not add fallbacks for a missing file in components.
Server-side manifest load
server/utils/upgrade-images.js exports loadManifest(distDir) which reads <distDir>/image-manifest.json once, returns {version, images} and returns {version: 0, images: {}} (never throws) if missing or unparsable.
Work packages
Four packages, disjoint files, run in parallel. Agents do not commit. The reviewer commits after review. Agents run yarn test (node --test) freely. Agents must NOT run yarn build, yarn serve or yarn prerender — the reviewer does that once at the end.
WP-A: generator script (owner: agent A)
Files: scripts/images.js (new), scripts/images.test.js (new), package.json (scripts only), .gitignore, scripts/set-feature-images.js (one error-message line), and the git index for public/**/*@w* (see step 5).
scripts/images.js, CommonJS, requires onlysharp,node:fs,node:path,node:crypto,node:os. Exportrun({ publicDir, log, concurrency })returning{ scanned, generated, skipped, removed, manifestPath }. CLI guardif (require.main === module)runsrun({ publicDir: path.join(__dirname, '..', 'public') })and prints one summary line;--verboseprints per-file lines; exits non-zero on any sharp error.- Algorithm:
- Walk
publicDirrecursively. Source = file matching/\.(jpe?g|png)$/iwhose basename does NOT match/@w\d+\.[a-z0-9]+$/i. Skip.DS_Store, skip nothing else. - Load existing manifest if present (tolerate missing/corrupt → empty). If
manifest.version !== SCRIPT_VERSION, treat as empty. - For each source:
hash = sha1(bytes). Compute expected rung list from the source width (read viasharp(file).metadata()— only when needed; if the manifest entry hash matches, reuse its storedwidth/height/widthsand only stat the expected outputs). If hash matches AND every expected output file exists →skipped++, keep entry. Otherwise generate all outputs with sharp (.resize({ width: N, withoutEnlargement: true }); jpeg{ quality: 80, mozjpeg: true }; png{ compressionLevel: 9 }; webp{ quality: 80 }), strip metadata (sharp default), write entry,generated++. - Concurrency: a simple promise pool of
os.cpus().length(min 2). - Orphans: any file in
publicDirmatching/@w\d+\.(jpe?g|png|webp)$/iwhose source (strip@w\d+; for.webptry.jpg,.jpeg,.pngin that order) no longer exists → delete,removed++. Manifest entries whose source is gone → drop. - Write manifest (2-space JSON, sorted keys) only if its content changed.
- Walk
scripts/images.test.js(node:test,.jsso the repo'syarn testglob picks it up):- Uses
fs.mkdtempSyncand sharp to create fixtures: a 2400×1260 jpg, a 900×600 png, a 400×300 jpg, and a stray orphan[email protected]with no source. - Run once: assert outputs exactly
[600,1000,1600]jpg+webp for the first;[600,900]png+webp for the second;[400]for the third; orphan removed; manifest keys/widths correct;hashis 40 hex chars. - Run twice: assert
generated === 0,skipped === 3, and output mtimes unchanged. - Modify the 900px png (rewrite with different pixels), run:
generated === 1. - Delete the 400px source, run: its variants removed, manifest entry gone.
- Delete one output file only, run: that source regenerates (
generated === 1).
- Uses
package.jsonscripts: add"images": "node scripts/images.js","prebuild": "yarn images","preserve": "yarn images". Touch nothing else in package.json..gitignore— append:Then# Derived image variants + manifest. Regenerated by `yarn images` (scripts/images.js), # which also runs before every build/serve. Sources stay committed; these never are. public/**/*@w[0-9]*.* public/image-manifest.jsongit rm -r --cached --quietevery currently tracked file matchingpublic/**/*@w*(291 files: 202 jpg, 89 png). Leave them on disk. Confirm withgit ls-files 'public/**/*@w*' | wc -l→ 0.scripts/set-feature-images.js: its "Not on disk, refusing to wire" error should add one line:Run \yarn images` first — @w variants are generated, not committed.`- Run
node scripts/images.js --verboseonce in the worktree so the manifest and the full ladder exist for the reviewer. Report the summary line and wall time in your final message. Then run it a second time and report that summary line too (expect generated 0).
Completion criteria: yarn test passes including the new test; second run of the script reports generated: 0; git status shows the 291 removals staged and no derived files untracked; public/image-manifest.json exists and is gitignored (git check-ignore confirms).
WP-B: blog body <img> → <picture> (owner: agent B)
Files: server/utils/upgrade-images.js (new), server/utils/upgrade-images.test.js (new), server/middlewares/blog-meta-inject.js (call site), src/pages/PageBlogArticle.vue (call site).
server/utils/upgrade-images.js, CommonJS, no dependencies, no Node built-ins in the exported transform (it is bundled into the browser by webpack). Exports:upgradeImages(html, manifest, { sizes } = {})→ string.srcsetFor(entry, base, ext)andwebpSrcsetFor(entry, base)helpers (exported for tests and for the Vue components to stay consistent).loadManifest(distDir)— the only function that usesfs/path;requirethem inside the function so webpack's browser bundle does not pull them in.DEFAULT_SIZES = '(min-width: 1000px) 940px, 100vw'.
upgradeImagesbehaviour:- Find every
<img …>tag (/<img\b[^>]*>/gi). Parse attributes with a tolerant regex (double-quoted, single-quoted, unquoted, bare). If parsing fails, return the tag unchanged. - Skip if the tag has
data-optimized. - Look up
attrs.srcinmanifest.images. If absent, orwidthsis empty → unchanged. - Otherwise emit:htmlwhere the inner
<picture><source type="image/webp" srcset="<base>@w600.webp 600w, …" sizes="<sizes>"><img … data-optimized="1"></picture><img>keeps every original attribute exceptsrcsetandsizes, which are replaced by the manifest-derived jpg/png srcset and the resolvedsizes(attrs.sizesif present, else the option, elseDEFAULT_SIZES). If the original has nowidth/height, add them from the manifest entry. Attribute order: original order, then the added ones. <base>/<ext>derive fromsrcexactly asResponsiveImage.vuedoes today:/^(.*)(\.[a-zA-Z0-9]+)$/.- Never throws. If
manifestis falsy or has noimages→ returnhtmlunchanged.
- Find every
- Tests (
node:test): empty manifest passthrough; unknown src passthrough; known src → picture with correct webp/jpg srcsets, original alt/class/loading preserved,sizesdefault applied; existing Ghostsrcset/sizesreplaced;width/heightadded when missing and preserved when present; idempotent (running twice yields identical output); PNG source keeps.pngfallback srcset; malformed tag left alone;loadManifeston a missing dir returns{version: 0, images: {}}. server/middlewares/blog-meta-inject.js: load the manifest once at module scope withloadManifest(path.join(__dirname, '..', '..', 'dist'))(samedistresolutionserver/router/og-image.jsline ~99 uses) and wrap the body at the single interpolation site (${article.body}in the article body builder, ~line 185) as${upgradeImages(article.body, manifest)}. Do not touch anything else in that file. Read the file header first; it is dense and deliberate.src/pages/PageBlogArticle.vue:import { upgradeImages } from '../../server/utils/upgrade-images';andimport manifest from '@/../public/image-manifest.json';. At the END of the existingprocessedBody()computed (after all existing regex passes, before thereturn), addhtml = upgradeImages(html, manifest);. Nothing else changes.
Completion criteria: yarn test passes with the new test file; grep -n upgradeImages server/middlewares/blog-meta-inject.js src/pages/PageBlogArticle.vue shows exactly one call site each; the transform module has no top-level require of Node built-ins.
WP-C: components (owner: agent C)
Files: src/components/atoms/ResponsiveImage.vue, src/components/marketing/sections/appframe/Capture.vue.
Both import manifest from '@/../public/image-manifest.json' and { srcsetFor, webpSrcsetFor } from '../../../server/utils/upgrade-images' (adjust relative depth per file; do not add a webpack alias). WP-B owns that helper and its signature is fixed above: srcsetFor(entry, base, ext) returns the jpg/png srcset string, webpSrcsetFor(entry, base) the webp one, both from entry.widths. Until WP-B lands, write against that contract; do not stub it in your files.
ResponsiveImage.vue:- Root becomes
<picture class="responsive-image-picture">containing<source v-if="webpSrcset" type="image/webp" :srcset="webpSrcset" :sizes="sizes">then the existing<img>unchanged in attributes. - New computed
entry=manifest.images[this.src] || null. srcsetAttr: ifentryandentry.widths.length→srcsetFor(entry, base, ext); else keep the current behaviour (derive from thewidthsprop) so any image not yet in the manifest renders exactly as today.webpSrcset:entry && entry.webp ? webpSrcsetFor(entry, base) : ''.resolvedWidth/resolvedHeight: prop if given, elseentry.width/entry.height, else null.- Styles: keep
.responsive-imagerules on the<img>; add.responsive-image-picture { display: block; }. - Keep every existing prop, comment and the
fetchPriorityAttrlogic verbatim.
- Root becomes
Capture.vue:- Add
<source v-if="webpSrcset" type="image/webp" :srcset="webpSrcset" :sizes="media.sizes || null">after the existingmobileSrc<source>and beforeCaptureImage/<img>. Order matters: the browser takes the first matching<source>, and the mobile crop must win below 600px. - Computed
entry=manifest.images[media.src] || null. srcset: ifentryandentry.widths.length→srcsetFor(...); else current logic (frommedia.widths), unchanged.webpSrcsetas above. Also addentry-derivedwidth/heightfallbacks on the<img>only whenmedia.width/media.heightare absent.- Keep the header comment; append one sentence explaining the manifest-driven webp source.
- Add
Completion criteria: yarn lint --no-fix reports no errors for the two files (npx vue-cli-service lint --no-fix src/components/atoms/ResponsiveImage.vue src/components/marketing/sections/appframe/Capture.vue); both files still compile with node -e "require('vue-template-compiler').parseComponent(require('fs').readFileSync(process.argv[1],'utf8'))" without throwing; no other files changed.
WP-D: CI (owner: agent D)
File: .github/workflows/aws.yml only.
- After the
Checkoutstep and beforeMake envfile, add:yamlMirror the exact- name: Use Node 20 uses: actions/setup-node@v4 with: node-version: 20 cache: yarn # Derived image variants (see scripts/images.js and docs/plans/image-pipeline.md) # are gitignored and regenerated here. The cache holds last run's outputs so # only sources that changed since then are re-encoded. The key hashes the # sources and the script; restore-keys falls back to the newest cache so a # miss on the exact key still restores everything except the changed files. - name: Restore derived image variants uses: actions/cache@v4 with: path: | public/**/*@w[0-9]*.* public/image-manifest.json key: images-v1-${{ hashFiles('public/**/*.jpg', 'public/**/*.jpeg', 'public/**/*.png', 'scripts/images.js') }} restore-keys: | images-v1- - name: Install dependencies run: YOUTUBE_DL_SKIP_PYTHON_CHECK=1 yarn install --frozen-lockfile --ignore-engines - name: Generate image variants run: yarn images --verboseyarn installflags the Dockerfile uses (they are quoted above). - Purge step (last step, "Purge changed media from Cloudflare's cache"): derived variants are no longer in git, so
git diff -- public/will never list them. AfterCHANGEDis computed, expand it: for each changed path that is a source image (.jpg/.jpeg/.png, not@w), readpublic/image-manifest.jsonand append each<base>@w<N><ext>and<base>@w<N>.webpfrom that entry'swidths. Do this in the existing Nodefetchblock (it already has the list in memory) rather than in bash. Keep the 30-per-request batching. Keep the secrets-unset early exit. Keep every existing comment. - Validate:
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/aws.yml'))". Also runnode --checkon the extracted Node block if you touched it (extract to a temp file under the scratchpad).
Completion criteria: YAML parses; the diff touches only the four new steps and the purge step's Node block; no secrets.* appears in any if:.
Reviewer checklist (after all four land)
yarn testgreen.node scripts/images.jssecond run →generated: 0, under 15 s.yarn buildsucceeds (runsprebuild→ images → vue-cli build). Then:dist/image-manifest.jsonexists.grep -c 'image/webp' dist/index.htmlis irrelevant (SPA shell); instead compile-check by grepping the built JS forimage/webpand@w.
- Every
featureImagein Mongo that ends in@wN.exthas that file underpublic/after generation (script in reviewer's notes). - Server SSR:
node -erequireblog-meta-inject's body builder with a fake article and a loaded manifest and confirm<picture>appears with both srcsets. git status: only intended files; 291 deletions staged; no derived files tracked.- Commit on
feat/image-pipeline, merge--no-ffintodevelopment, push. - Post-deploy:
curl -sI https://storyfolder.com/blog-media/<slug>/[email protected]→ 200,content-type: image/webp,cf-cache-statusHIT on second request.
WP-E: coverage — heroes, captures, cards, section fallbacks (added after review)
The first four packages covered blog bodies and Capture's fixture branch. An audit of the live pages found the actual LCP elements still bypass the manifest, plus one bug. Files: server/utils/upgrade-images.js (+test), src/components/marketing/CaptureImage.vue, src/components/marketing/sections/appframe/Capture.vue, src/components/atoms/ResponsiveImage.vue, src/pages/PageBlogArticle.vue, src/pages/PageBlog.vue, and the section components listed in step 5.
resolveEntry(manifest, src)exported fromupgrade-images.js: returns{ entry, base, ext } | null. Looks upsrcinmanifest.images; if absent andsrcmatches/^(.*)@w\d+(\.[a-zA-Z0-9]+)$/, looks up<base><ext>instead.base/extare always the source's.upgradeImagesand both Vue components use it, so the derived-path handling lives in one place (37Article.featureImagevalues are…/[email protected]). Tests: direct hit, derived hit, miss, malformed.- Bug fix in
Capture.vue. The webp<source>is keyed onmedia.src, the fallback image; whenmedia.captureis set that source precedesCaptureImage, so the browser shows the fallback's webp instead of the app capture. Fix:webpSrcsetreturns''whenmedia.captureis truthy.CaptureImagehandles its own webp (next step). CaptureImage.vuebecomes manifest-aware. Root becomes<picture>; a webp<source>and an<img srcset>are emitted only whenresolveEntry(manifest, captureSrc)hits and the component is NOT inusingFallback. In fallback state render exactly today's plain<img>so the@error→ fallback flow is untouched. Keep every prop,data-capture*attribute, class,eagerbehaviour and comment. Width/height: prop, else entry.sizesprop (String, default'(min-width: 1000px) 560px, 100vw') added and passed to both.- Blog hero and cards.
PageBlogArticle.vueline ~42: replace the raw<img>with<ResponsiveImage :src="article.featureImage" :alt="article.title" loading="eager" sizes="(min-width: 1200px) 1160px, 100vw" />(register the component).PageBlog.vuelines ~57 and ~83:<ResponsiveImage :src="…featureImage" :alt="…title" sizes="(min-width: 900px) 33vw, 100vw" />(lazy default).ResponsiveImagemust resolve derived srcs viaresolveEntry; when the manifest misses ANDsrcis a derived path, emit no srcset at all (today it would buildhero@[email protected]). Check the.feature-image imgand.card-image-link imgLESS still applies (they targetimg, which is still inside the picture — confirm, don't restyle). - Section fallback branches →
ResponsiveImage. InSplitA,AnnotatedA,AnnotatedB,SeamA,HeroThumbnailWall,WallLifted,ArtifactFan,AutofillBoard: every raw<img>whosesrcis a/marketing-media/…or/blog-media/…path becomes<ResponsiveImage>, passing throughalt,width,height,sizes,loading/fetchprioritysemantics (HeroThumbnailWall's hero isloading="eager"). Where a component computes its ownsrcsetfromwidths, delete that computed;ResponsiveImagederives it. Do NOT touch<img>s whose src is an/api/…URL, an inline SVG, or a data URI. Keep all classes on the<img>by passing them viaimg-class— add that String prop toResponsiveImageand bind it as:class="['responsive-image', imgClass]"on the<img>— because several of these components' LESS rules target a class on the<img>itself. - Verification:
yarn testgreen; lint of every touched.vueclean apart from the pre-existingvue/no-v-htmlwarning; template-compile check on each;yarn buildgreen (reviewer runs it);grep -rn '<img' src/components/marketing/sections src/pages/PageBlog*.vuelists only the exclusions from step 5 and the two atoms.