fix(core): stop imageCompression and stripExifData flattening animated images - #365
Conversation
…d images
Both steps re-encode through a canvas, and canvas has no animated encoder:
`drawImage` paints the first frame and `toBlob`/`convertToBlob` writes a still.
Enabling either option therefore replaced an uploaded animated GIF with a
frozen frame — the upload succeeded, nothing errored, and the user got a dead
image back. Both steps gated only on `file.type.startsWith('image/')`, so every
animated GIF was in scope; animated WebP and APNG were destroyed the same way
(`outputTypeFor` preserves those two types, so they came back as stills of the
same format).
`steps/animated-image.ts` sniffs the container and both steps now return the
file untouched when it is animated. The guard sits above the worker branch, so
one check covers the web-worker and main-thread encode paths, and the upload
itself is unaffected — an animated file simply skips these two steps.
Detection is byte-level rather than `ImageDecoder`-based, so it behaves the
same in every browser and is deterministic under test: GIF image descriptors
plus the NETSCAPE2.0/ANIMEXTS1.0 looping extension, the APNG `acTL` chunk
before the first `IDAT`, and the WebP `VP8X` animation flag or `ANIM`/`ANMF`
chunks. Everything else returns false without reading a byte past the MIME
check. Reading the blob is not a new cost class — the worker path already
calls `file.arrayBuffer()` and the main-thread path hands the file to
`createImageBitmap`, which decodes it to raw RGBA.
`thumbnailGenerator` is deliberately not guarded: a thumbnail is a still by
definition and is stored alongside the file rather than replacing it.
RED before (vitest, packages/core, guard removed, canvas runtime installed):
FAIL tests/steps/image-processing.test.ts >
animated images skip the canvas re-encode steps
expected File { size: 9, type: 'image/jpeg', name: 'loop.gif' }
to be File { size: 85, type: 'image/gif', name: 'loop.gif' }
Test Files 1 failed | 1 passed (2)
Tests 7 failed | 27 passed (34)
GREEN after: 34 passed (34); full core suite 1722 passed (143 files);
typecheck (src + test trees), eslint, prettier, test:quality and size-limit
all clean (core 403.25 kB against a 410 kB budget).
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
Test evidenceRun on Node 20.20.2 ( RED — tests against unmodified steps ( The 7 failures are the animated GIF / animated WebP / APNG cases across both GREEN — same two files with the guard restored: Full gates (all via
The size line is the one worth a glance: the module is small and dependency-free,
One thing I did not fix
|
The compression guide's "When compression does nothing" list and the file-processing guide's stripExifData notes now cover the animation bypass, and the pipeline table's "any image/*" rows read "still image/*". Also qualifies the output-format line: only a *still* .gif turns into JPEG bytes under its original name. Calls out the consequence worth knowing — an animated image keeps its EXIF, because the step that would have stripped it is the same re-encode that would have flattened it.
The defect
imageCompressionandstripExifDataboth re-encode the file through a canvas:Canvas has no animated encoder.
drawImagepaints one frame andtoBlob/convertToBlobwrites a still, so turning either option on silentlyreplaces an uploaded animated GIF with its first frame. The upload succeeds,
nothing errors, and the user gets a frozen image back — the worst shape a bug
can take.
Both steps gate only on
file.type.startsWith('image/'), so every animated GIFis in scope. The same is true for animated WebP and APNG:
outputTypeForpreserves
image/webpandimage/png, so those come back as a still WebP / stillPNG rather than changing container.
Repro
With
imageCompressionenabled, upload a looping GIF:(The type change is a second, pre-existing oddity —
outputTypeForsendsanything that is not PNG or WebP to JPEG while keeping the
.gifname. Out ofscope here; flagged for a separate look.)
Postify hit this enabling the three client-side pipeline options from its upup
audit — the options are otherwise a clear win (HEIC conversion, pre-empting X's
5 MB image cap before the transfer rather than after, GPS stripping), and
animated GIFs are ordinary content for a social scheduler.
The fix
A new
packages/core/src/steps/animated-image.tssniffs the container, andcompress/exifreturn the file untouched when it is animated:The guard sits at the top of
process(), above the worker branch, so both theweb-worker and main-thread encode paths are covered by one check. The upload
itself is untouched — an animated file simply skips these two steps and
uploads as-is.
Detection is byte-level rather than
ImageDecoder-based, so it behaves the samein every browser and is deterministic under test:
0x2C), andshort-circuits on a
NETSCAPE2.0/ANIMEXTS1.0Application Extension, whichevery looping GIF carries near the header. Global and local colour tables are
stepped over by their packed-field size; an unrecognised block stops the walk
rather than guessing past it.
acTLchunk before the firstIDAT. AnacTLafterIDATdoes not animate, so it is correctly ignored.
VP8Xanimation flag (0x02), or anANIM/ANMFchunk.
Anything else — JPEG, HEIC, a still image of any of the three formats — returns
falsewithout reading a byte beyond the MIME check, and is processed exactly asbefore.
Reading the blob is not a new cost class: the worker path already calls
file.arrayBuffer(), and the main-thread path hands the file tocreateImageBitmap, which decodes it to raw RGBA.thumbnailGeneratoris deliberately not guarded — a thumbnail is a still bydefinition and is stored alongside the file rather than replacing it.
Compatibility
animated-image.tsis internal tosteps/.imageCompression/stripExifDatakeep their exactshapes and defaults.
only behaviour that changes is that an animated file survives.
import needed (the steps that use it are already lazily imported by
buildAutoPipeline).Tests
packages/core/tests/steps/animated-image-detection.test.ts— the sniffersagainst hand-assembled, spec-shaped fixtures: still vs looping vs two-frame
GIF (with and without a global colour table), a GIF behind a Graphic Control
Extension, a truncated GIF, still PNG vs APNG vs
acTL-after-IDAT, stillWebP vs
VP8Xalpha-only vsVP8Xanimated vsANIM, plus non-image typesand an unreadable blob.
packages/core/tests/steps/image-processing.test.ts— a new block driving thereal
compressStep/exifStepover the stubbed canvas runtime that filealready uses: an animated GIF comes back as the same object with no
compressed/exifStrippedmetadata, while a still GIF is still re-encoded(so the guard is proven narrow, not a blanket GIF opt-out).
packages/core/tests/helpers/animated-image-fixtures.tsholds the bytebuilders, deliberately free of
@napi-rs/canvasso the unit tree can use them.RED-before / GREEN-after evidence is in a comment below.
Not a release
No release is cut by this PR and none should be inferred from it. No version
bump, no tag, no
npm publish— thepatchchangeset only queues an entry forwhenever you decide to run the release cycle.
Targeted at
devrather thanmasterper CLAUDE.md's "Do not merge, PR, orpush to
masterwithout an explicit maintainer decision."