Skip to content

fix(core): stop imageCompression and stripExifData flattening animated images - #365

Merged
AminDhouib merged 2 commits into
devfrom
fix/animated-gif-flattening
Sep 2, 2026
Merged

fix(core): stop imageCompression and stripExifData flattening animated images#365
AminDhouib merged 2 commits into
devfrom
fix/animated-gif-flattening

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

The defect

imageCompression and stripExifData both re-encode the file through a canvas:

// steps/image-utils.ts — encodeImageFile
ctx.drawImage(decoded.source, 0, 0, dimensions.width, dimensions.height)
const blob = await canvasToBlob(canvas, type, quality)
return cloneUploadFile(file, new File([blob], file.name, ))

Canvas has no animated encoder. drawImage paints one frame and
toBlob/convertToBlob writes a still, so turning either option on silently
replaces 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 GIF
is in scope. The same is true for animated WebP and APNG: outputTypeFor
preserves image/webp and image/png, so those come back as a still WebP / still
PNG rather than changing container.

Repro

With imageCompression enabled, upload a looping GIF:

in:  loop.gif   image/gif   2 frames, NETSCAPE2.0 loop extension
out: loop.gif   image/jpeg  1 frame

(The type change is a second, pre-existing oddity — outputTypeFor sends
anything that is not PNG or WebP to JPEG while keeping the .gif name. Out of
scope 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.ts sniffs the container, and
compress / exif return the file untouched when it is animated:

// Canvas has no animated encoder — re-encoding an animated image returns
// frame one and silently destroys the animation.
if (await isAnimatedImage(file)) return file

The guard sits at the top of process(), above the worker branch, so both the
web-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 same
in every browser and is deterministic under test:

  • GIF — walks the block structure counting Image Descriptors (0x2C), and
    short-circuits on a NETSCAPE2.0 / ANIMEXTS1.0 Application Extension, which
    every 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.
  • APNG — an acTL chunk before the first IDAT. An acTL after IDAT
    does not animate, so it is correctly ignored.
  • Animated WebP — the VP8X animation flag (0x02), or an ANIM/ANMF
    chunk.

Anything else — JPEG, HEIC, a still image of any of the three formats — returns
false without reading a byte beyond the MIME check, and is processed exactly as
before.

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.

Compatibility

  • No public API change; nothing added to any package's export surface.
    animated-image.ts is internal to steps/.
  • No option change — imageCompression / stripExifData keep their exact
    shapes and defaults.
  • Still images of every format take the identical path they took before; the
    only behaviour that changes is that an animated file survives.
  • Core's mandatory path grows by one small dependency-free module, no dynamic
    import needed (the steps that use it are already lazily imported by
    buildAutoPipeline).

Tests

  • packages/core/tests/steps/animated-image-detection.test.ts — the sniffers
    against 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, still
    WebP vs VP8X alpha-only vs VP8X animated vs ANIM, plus non-image types
    and an unreadable blob.
  • packages/core/tests/steps/image-processing.test.ts — a new block driving the
    real compressStep / exifStep over the stubbed canvas runtime that file
    already uses: an animated GIF comes back as the same object with no
    compressed / exifStripped metadata, 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.ts holds the byte
    builders, deliberately free of @napi-rs/canvas so 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 — the patch changeset only queues an entry for
whenever you decide to run the release cycle.

Targeted at dev rather than master per CLAUDE.md's "Do not merge, PR, or
push to master without an explicit maintainer decision."

…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).
@codesandbox

codesandbox Bot commented Aug 27, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@AminDhouib

Copy link
Copy Markdown
Member Author

Test evidence

Run on Node 20.20.2 (.nvmrc), pnpm 10.11.0, packages/core, vitest 4.1.2.

RED — tests against unmodified steps (git stash push -- packages/core/src/steps/compress.ts packages/core/src/steps/exif.ts). The assertion diff is the flatten repro: an 85-byte image/gif comes back as a 9-byte image/jpeg.

 FAIL  tests/steps/image-processing.test.ts >
       animated images skip the canvas re-encode steps >
       leaves an animated GIF untouched on the web-worker path too

 AssertionError: expected File { size: 9,  type: 'image/jpeg', name: 'loop.gif' }
                      to be File { size: 85, type: 'image/gif',  name: 'loop.gif' }

   - Expected
   + Received
     File {
       "id": "file-1",
       "metadata": {},
       "source": "local",
       "status": "IDLE",
   -   Symbol(kLength): 85,
   -   Symbol(kType): "image/gif",
   +   Symbol(kLength): 9,
   +   Symbol(kType): "image/jpeg",
     }

 Test Files  1 failed | 1 passed (2)
      Tests  7 failed | 27 passed (34)

The 7 failures are the animated GIF / animated WebP / APNG cases across both
compressStep and exifStep, plus the worker-path case. The two that pass in
the RED run are the ones that must not change: the still-GIF re-encode and
the animated-GIF thumbnail.

GREEN — same two files with the guard restored:

 Test Files  2 passed (2)
      Tests  34 passed (34)

Full gates (all via rtk proxy + raw exit codes, per CLAUDE.md's machine notes):

Gate Result
vitest run (packages/core) 143 files, 1722 passed — exit 0
tsc --noEmit exit 0
tsc -p tsconfig.test.json --noEmit exit 0
eslint . --max-warnings 0 (core) exit 0
prettier --check (all 9 packages' src) exit 0
test:quality 392 test files + 5 workflows clean, 0 exceptions
size-limit all 9 budgets pass — @upupjs/core 403.25 kB / 410 kB
@upupjs/server test 32 files, 337 passed, 6 skipped
pre-push (typecheck + turbo lint + knip) exit 0

The size line is the one worth a glance: the module is small and dependency-free,
and it rides the existing lazy import() of steps/compress / steps/exif in
buildAutoPipeline, so core's mandatory path is unchanged.

pnpm run e2e was not run — it needs Docker MinIO plus a six-storybook
webServer boot and this box was heavily loaded. CI's E2E job is the check to
watch. Worth noting that the e2e harness is Chromium-only, so a live check of an
animated GIF surviving imageCompression in Firefox/Safari would be the real
proof if you want one before a release.

One thing I did not fix

outputTypeFor sends anything that is not PNG or WebP to image/jpeg while
keeping the original filename, so a still GIF becomes JPEG bytes still named
.gif. That is pre-existing and orthogonal to this PR, so I left it alone —
flagging it in case you want it tracked separately.

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.
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