Skip to content

fix: probe images with a real decode before optimizing - #394

Merged
oleeskild merged 1 commit into
mainfrom
fix/decode-probe-before-optimize
Jul 27, 2026
Merged

fix: probe images with a real decode before optimizing#394
oleeskild merged 1 commit into
mainfrom
fix/decode-probe-before-optimize

Conversation

@oleeskild

Copy link
Copy Markdown
Owner

Problem

Same garden as #393, new failure: a truncated AVIF renamed to .jpg (EyetotheEar.jpg, 34,033 bytes with the decoder seeking to 34,041). The #393 magic-byte guard passes it — the ftypavif header is perfectly valid — but sharp fails mid-decode, and the build dies again.

Two findings from reproducing this locally with the actual file:

  1. A valid header cannot prove a decodable bitstream. Truncated or corrupt files sail through any magic-byte allowlist.
  2. The .catch net cannot save the build. On decode failure, eleventy-img leaves internal per-format output promises permanently unhandled; Eleventy's unhandledRejection handler goes fatal no matter what the caller catches. (rejectionHandled is only a warning — the fatal comes from the inner promises.)

Fix

The picture transform (now async) awaits isDecodableImage(path) before letting a file anywhere near eleventy-img:

  • header sniff as the cheap first filter (unchanged behavior for HEIC/junk),
  • then a real decode probesharp(path).stats() — memoized per file mtime+size, so each unique image decodes once per build,
  • sharp is resolved through eleventy-img's own module resolution, so "can we decode this?" always agrees with what the pipeline can actually do,
  • undecodable files keep their original <img> tag and log [image] <path> cannot be decoded and will not be optimized: <reason>.

Testing

  • 4 new tests incl. a committed fixture: a tiny AVIF truncated to 60%, reproducing the exact bad seek failure; 265 total pass.
  • E2E with the reporting garden's real file and a fake HEIC: before — Unhandled rejection in promise ×2 and exit 1 despite the .catch; after — build succeeds, both bad files keep plain <img> tags, healthy images still get <picture>/optimized variants.

No manifest change needed: imageFormat.js is already in plugin-info.json from #393; test + fixture files are intentionally not synced.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TFUucReuWzHi8VCKMovneB

The header-sniffing guard from #393 was not enough: the same garden
failed again on a *truncated AVIF* renamed to .jpg. Its ftypavif header
passes the magic-byte check, but sharp fails mid-decode ("bad seek"),
and eleventy-img leaves internal per-format promise rejections
permanently unhandled on decode failure — Eleventy's unhandledRejection
handler then fails the build, regardless of the .catch we attach to the
returned promise (verified empirically with the reporting garden's
actual file).

A valid header can never prove a decodable bitstream, so the picture
transform now awaits isDecodableImage: header sniff as a cheap first
filter, then an actual sharp decode (stats()), memoized per file
mtime+size so each unique image is probed once per build. The probe
resolves sharp through eleventy-img's own module resolution, so the
answer always agrees with what the pipeline can do. Undecodable files
keep their original <img> tag and log a warning naming the file.

Fixture: a tiny AVIF truncated to 60%, which reproduces the exact
"bad seek" failure in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFUucReuWzHi8VCKMovneB
@oleeskild
oleeskild merged commit 7746893 into main Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant