diff --git a/.changeset/still-frames-only.md b/.changeset/still-frames-only.md new file mode 100644 index 000000000..a730dfe13 --- /dev/null +++ b/.changeset/still-frames-only.md @@ -0,0 +1,19 @@ +--- +'@upupjs/core': patch +--- + +`imageCompression` and `stripExifData` no longer flatten animated 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, +so enabling either option silently replaced an uploaded animated GIF with a +single frame — the upload succeeded and the user got a frozen image back. + +Both steps now sniff the file first and pass animated GIF, animated WebP and +APNG through untouched. Detection is byte-level (GIF image descriptors plus the +NETSCAPE2.0/ANIMEXTS1.0 looping extension; the APNG `acTL` chunk; the WebP +`VP8X` animation flag and `ANIM`/`ANMF` chunks) rather than `ImageDecoder`-based, +so it behaves identically in every browser. Still images of the same formats are +processed exactly as before, and the upload itself is untouched either way. + +`thumbnailGenerator` is deliberately unchanged — a thumbnail is a still by +definition, and it is stored alongside the file rather than replacing it. diff --git a/apps/landing/content/docs/guides/file-processing.mdx b/apps/landing/content/docs/guides/file-processing.mdx index 6c44708e7..ba93e7747 100644 --- a/apps/landing/content/docs/guides/file-processing.mdx +++ b/apps/landing/content/docs/guides/file-processing.mdx @@ -29,8 +29,8 @@ The pipeline is assembled from boolean options on the uploader. The order is | Order | Step | Option | Applies to | | ----- | ----------- | ---------------------- | ---------------- | | 1 | `heic` | `heicConversion` | HEIC/HEIF images | -| 2 | `exif` | `stripExifData` | any `image/*` | -| 3 | `compress` | `imageCompression` | any `image/*` | +| 2 | `exif` | `stripExifData` | still `image/*` | +| 3 | `compress` | `imageCompression` | still `image/*` | | 4 | `thumbnail` | `thumbnailGenerator` | any `image/*` | | 5 | `hash` | `checksumVerification` | every file | @@ -120,6 +120,11 @@ Two things to know about the mechanism: empty type falls back to `image/jpeg`). It does not resize. - **It is a re-encode, not a surgical metadata edit.** Everything outside the pixel data goes, including ICC color profiles. +- **Animated images are skipped.** A canvas cannot write an animation, so + re-encoding an animated GIF, animated WebP or APNG would keep frame one and + discard the rest. Those files are left untouched — which also means their EXIF + is left untouched. `imageCompression` skips them for the same reason. If you + need metadata stripped from animated uploads, do it server-side. Because EXIF runs _before_ compression, turning both on means two encodes. If you want compression anyway, that is the cost of the privacy guarantee; if you diff --git a/apps/landing/content/docs/guides/processing/compression.mdx b/apps/landing/content/docs/guides/processing/compression.mdx index 78035d147..a2c340a55 100644 --- a/apps/landing/content/docs/guides/processing/compression.mdx +++ b/apps/landing/content/docs/guides/processing/compression.mdx @@ -76,14 +76,22 @@ does. ## Output format The encoder preserves `image/png` and `image/webp`; **every other input type -comes out as `image/jpeg`**. The filename is unchanged, so a `.gif` compressed -to JPEG bytes keeps its original name — set the storage key or rename the file -yourself if the extension matters to you. +comes out as `image/jpeg`**. The filename is unchanged, so a still `.gif` +compressed to JPEG bytes keeps its original name — set the storage key or +rename the file yourself if the extension matters to you. (An _animated_ GIF is +not compressed at all — see below.) ## When compression does nothing -Compression can decide to keep the original file. Two cases: +Compression can decide to keep the original file. Three cases: +- **The image is animated.** A canvas has no animated encoder, so re-encoding an + animated GIF, animated WebP or APNG would upload its first frame and throw the + rest away. upup detects those from the file's bytes and skips the step + entirely — the original uploads untouched, with no `compressed` metadata. Still + images of the same formats are compressed normally. `stripExifData` skips them + for the same reason, so an animated image reaches your storage exactly as the + user picked it, EXIF included. - **No size benefit.** If the re-encoded result is not smaller than the original, upup keeps the original — but only when you passed neither `maxSizeMB` nor an explicit `maxWidthOrHeight`. Setting either of those is read diff --git a/packages/core/src/steps/animated-image.ts b/packages/core/src/steps/animated-image.ts new file mode 100644 index 000000000..fead6aea3 --- /dev/null +++ b/packages/core/src/steps/animated-image.ts @@ -0,0 +1,196 @@ +/** + * Animation detection for the image pipeline. + * + * `compress` and `exif` both re-encode through a canvas, and canvas has no + * animated encoder — `drawImage` paints one frame and `toBlob`/`convertToBlob` + * writes a still. Running either step over an animated GIF, WebP or APNG + * therefore destroys the animation silently: the upload succeeds and the user + * gets a frozen first frame. These sniffers let those steps opt the file out + * instead. + * + * Detection is byte-level rather than `ImageDecoder`-based so it behaves the + * same in every browser and is deterministic under test. Reading the whole + * blob is cheaper than what the step does next either way — the worker path + * already calls `file.arrayBuffer()`, and the main-thread path hands the file + * to `createImageBitmap`, which decodes it to raw RGBA. + */ + +function ascii(bytes: Uint8Array, start: number, length: number): string { + let out = '' + for (let i = start; i < start + length; i += 1) { + const byte = bytes[i] + if (byte === undefined) return out + out += String.fromCharCode(byte) + } + return out +} + +function byteAt(bytes: Uint8Array, index: number): number { + return bytes[index] ?? 0 +} + +/** + * Walk GIF data sub-blocks (a length byte, that many bytes, repeated until a + * zero-length terminator) and return the offset just past the terminator. + */ +function skipSubBlocks(bytes: Uint8Array, start: number): number { + let offset = start + while (offset < bytes.length) { + const size = byteAt(bytes, offset) + offset += 1 + if (size === 0) return offset + offset += size + } + return bytes.length +} + +/** + * A GIF is animated when it carries more than one Image Descriptor. The + * NETSCAPE2.0 / ANIMEXTS1.0 looping Application Extension is an earlier, and + * for looping GIFs universal, tell — it lets an animated file be recognised + * from its header instead of a full walk. + */ +function isAnimatedGif(bytes: Uint8Array): boolean { + // 'GIF' + version (6) + logical screen descriptor (7) + if (bytes.length < 13) return false + if (ascii(bytes, 0, 3) !== 'GIF') return false + + const screenPacked = byteAt(bytes, 10) + let offset = 13 + // Global colour table: 3 bytes per entry, 2^(N+1) entries. + if ((screenPacked & 0x80) !== 0) { + offset += 3 * (1 << ((screenPacked & 0x07) + 1)) + } + + let frames = 0 + while (offset < bytes.length) { + const block = byteAt(bytes, offset) + + // Trailer — the stream ended with a single frame. + if (block === 0x3b) return false + + if (block === 0x21) { + const label = byteAt(bytes, offset + 1) + offset += 2 + if (label === 0xff) { + // Application Extension: an 11-byte identifier sub-block. + const identifier = ascii( + bytes, + offset + 1, + byteAt(bytes, offset), + ) + if ( + identifier === 'NETSCAPE2.0' || + identifier === 'ANIMEXTS1.0' + ) { + return true + } + } + offset = skipSubBlocks(bytes, offset) + continue + } + + if (block === 0x2c) { + frames += 1 + if (frames > 1) return true + // Image descriptor is 10 bytes; its packed field is the last one. + const imagePacked = byteAt(bytes, offset + 9) + offset += 10 + if ((imagePacked & 0x80) !== 0) { + offset += 3 * (1 << ((imagePacked & 0x07) + 1)) + } + offset += 1 // LZW minimum code size + offset = skipSubBlocks(bytes, offset) + continue + } + + // Unrecognised block — stop rather than guess past it. + return false + } + + return false +} + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +/** + * An APNG is a PNG carrying an `acTL` (animation control) chunk, which the + * spec requires to appear before the first `IDAT`. + */ +function isAnimatedPng(bytes: Uint8Array): boolean { + if (bytes.length < PNG_SIGNATURE.length) return false + for (const [index, expected] of PNG_SIGNATURE.entries()) { + if (byteAt(bytes, index) !== expected) return false + } + + let offset = PNG_SIGNATURE.length + while (offset + 8 <= bytes.length) { + const length = + byteAt(bytes, offset) * 0x1000000 + + byteAt(bytes, offset + 1) * 0x10000 + + byteAt(bytes, offset + 2) * 0x100 + + byteAt(bytes, offset + 3) + const type = ascii(bytes, offset + 4, 4) + if (type === 'acTL') return true + if (type === 'IDAT') return false + offset += 12 + length // length + type + data + CRC + } + + return false +} + +/** + * An animated WebP declares the ANIMATION flag in its `VP8X` chunk and carries + * `ANIM`/`ANMF` chunks for the frames. + */ +function isAnimatedWebp(bytes: Uint8Array): boolean { + if (bytes.length < 16) return false + if (ascii(bytes, 0, 4) !== 'RIFF' || ascii(bytes, 8, 4) !== 'WEBP') { + return false + } + + let offset = 12 + while (offset + 8 <= bytes.length) { + const fourCC = ascii(bytes, offset, 4) + const size = + byteAt(bytes, offset + 4) + + byteAt(bytes, offset + 5) * 0x100 + + byteAt(bytes, offset + 6) * 0x10000 + + byteAt(bytes, offset + 7) * 0x1000000 + if (fourCC === 'ANIM' || fourCC === 'ANMF') return true + if (fourCC === 'VP8X' && (byteAt(bytes, offset + 8) & 0x02) !== 0) { + return true + } + offset += 8 + size + (size % 2) // chunk payloads are padded to even + } + + return false +} + +const SNIFFERS: Record boolean> = { + 'image/gif': isAnimatedGif, + 'image/png': isAnimatedPng, + 'image/apng': isAnimatedPng, + 'image/webp': isAnimatedWebp, +} + +/** Strip any `; parameters` and casing so `IMAGE/GIF; charset=…` still matches. */ +function baseMimeType(type: string): string { + return type.split(';')[0]?.trim().toLowerCase() ?? '' +} + +/** + * True when the blob is an animated image in one of the three formats a canvas + * re-encode would flatten. Anything else — including formats with no animated + * variant — is false, so callers process it as before. + */ +export async function isAnimatedImage(file: Blob): Promise { + const sniff = SNIFFERS[baseMimeType(file.type)] + if (!sniff) return false + try { + return sniff(new Uint8Array(await file.arrayBuffer())) + } catch { + // upup-catch: unreadable blob — let the step's own decode surface it + return false + } +} diff --git a/packages/core/src/steps/compress.ts b/packages/core/src/steps/compress.ts index edcf79871..70179d5ee 100644 --- a/packages/core/src/steps/compress.ts +++ b/packages/core/src/steps/compress.ts @@ -1,5 +1,6 @@ import type { PipelineStep, PipelineContext, UploadFile } from '../contracts' import { encodeImageFile, uploadFileFromImageResult } from './image-utils' +import { isAnimatedImage } from './animated-image' import type { WorkerResult } from '../worker/protocol' export interface ImageCompressionOptions { @@ -16,6 +17,11 @@ export function compressStep(_options?: ImageCompressionOptions): PipelineStep { file: UploadFile, context: PipelineContext, ): Promise { + // Compression re-encodes through a canvas, and canvas has no + // animated encoder — an animated GIF/WebP/APNG would come back as + // its first frame. Leave those alone; the upload is unaffected. + if (await isAnimatedImage(file)) return file + if (context.worker) { try { const result = await context.worker.execute({ diff --git a/packages/core/src/steps/exif.ts b/packages/core/src/steps/exif.ts index 00c34aa3b..670fe08a9 100644 --- a/packages/core/src/steps/exif.ts +++ b/packages/core/src/steps/exif.ts @@ -1,5 +1,6 @@ import type { PipelineStep, PipelineContext, UploadFile } from '../contracts' import { encodeImageFile, uploadFileFromImageResult } from './image-utils' +import { isAnimatedImage } from './animated-image' import type { WorkerResult } from '../worker/protocol' export function exifStep(): PipelineStep { @@ -10,6 +11,11 @@ export function exifStep(): PipelineStep { file: UploadFile, context: PipelineContext, ): Promise { + // Stripping EXIF re-encodes through a canvas, and canvas has no + // animated encoder — an animated GIF/WebP/APNG would come back as + // its first frame. Leave those alone; the upload is unaffected. + if (await isAnimatedImage(file)) return file + if (context.worker) { try { const result = await context.worker.execute({ diff --git a/packages/core/tests/helpers/animated-image-fixtures.ts b/packages/core/tests/helpers/animated-image-fixtures.ts new file mode 100644 index 000000000..6745c1e74 --- /dev/null +++ b/packages/core/tests/helpers/animated-image-fixtures.ts @@ -0,0 +1,179 @@ +// Hand-assembled image containers for the animation guard. These are real, +// spec-shaped bytes — a decoder can open them — but deliberately tiny (1x1, +// two-colour) so the interesting structure is the only thing in the file. +// +// Kept free of @napi-rs/canvas so the unit test tree can use them too; +// helpers/fixtures.ts is the Skia-rendered counterpart for real photos. + +export type BytePart = number[] | string | Uint8Array + +export function concatBytes(...parts: BytePart[]): Uint8Array { + const out: number[] = [] + for (const part of parts) { + if (typeof part === 'string') { + for (const char of part) out.push(char.charCodeAt(0)) + } else { + for (const byte of part) out.push(byte) + } + } + return new Uint8Array(out) +} + +// ── GIF ───────────────────────────────────────────────────────── +// 'GIF89a' + logical screen descriptor. `globalColourTable` sets the packed +// field's GCT flag and appends a two-entry (red/blue) table. +export function gifHeader({ + globalColourTable = true, +}: { globalColourTable?: boolean } = {}): Uint8Array { + if (!globalColourTable) { + return concatBytes('GIF89a', [0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]) + } + return concatBytes( + 'GIF89a', + // width 1, height 1, packed 0x80 (GCT present, 2^(0+1) entries), bg, aspect + [0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00], + [0xff, 0x00, 0x00, 0x00, 0x00, 0xff], + ) +} + +/** + * One 1x1 image descriptor plus its LZW payload. The three data bytes are a + * real minimal LZW stream: clear code, one pixel, end-of-information. + */ +export function gifFrame(colourIndex: 0 | 1 = 0): number[] { + return [ + 0x2c, // image descriptor + 0x00, + 0x00, + 0x00, + 0x00, // left, top + 0x01, + 0x00, + 0x01, + 0x00, // width, height + 0x00, // packed: no local colour table + 0x02, // LZW minimum code size + 0x02, // one 2-byte data sub-block + colourIndex === 0 ? 0x44 : 0x4c, + 0x01, + 0x00, // sub-block terminator + ] +} + +/** The Application Extension every looping animated GIF carries. */ +export const GIF_NETSCAPE_LOOP = concatBytes( + [0x21, 0xff, 0x0b], + 'NETSCAPE2.0', + [0x03, 0x01, 0x00, 0x00, 0x00], +) + +/** Graphic Control Extension — legal on a single-frame GIF too. */ +export const GIF_GRAPHIC_CONTROL = [ + 0x21, 0xf9, 0x04, 0x00, 0x0a, 0x00, 0x00, 0x00, +] + +export const GIF_TRAILER = [0x3b] + +export function stillGifBytes(): Uint8Array { + return concatBytes(gifHeader(), gifFrame(0), GIF_TRAILER) +} + +export function animatedGifBytes(): Uint8Array { + return concatBytes( + gifHeader(), + GIF_NETSCAPE_LOOP, + GIF_GRAPHIC_CONTROL, + gifFrame(0), + GIF_GRAPHIC_CONTROL, + gifFrame(1), + GIF_TRAILER, + ) +} + +// ── PNG / APNG ────────────────────────────────────────────────── +export const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +/** A length-prefixed, CRC-suffixed PNG chunk with zero-filled data. */ +export function pngChunk(type: string, dataLength = 0): Uint8Array { + return concatBytes( + [ + (dataLength >>> 24) & 0xff, + (dataLength >>> 16) & 0xff, + (dataLength >>> 8) & 0xff, + dataLength & 0xff, + ], + type, + new Uint8Array(dataLength), + [0x00, 0x00, 0x00, 0x00], + ) +} + +export function stillPngBytes(): Uint8Array { + return concatBytes( + PNG_SIGNATURE, + pngChunk('IHDR', 13), + pngChunk('IDAT', 4), + pngChunk('IEND'), + ) +} + +export function apngBytes(): Uint8Array { + return concatBytes( + PNG_SIGNATURE, + pngChunk('IHDR', 13), + pngChunk('acTL', 8), + pngChunk('IDAT', 4), + pngChunk('IEND'), + ) +} + +// ── WebP ──────────────────────────────────────────────────────── +/** A RIFF chunk: FourCC, little-endian size, payload padded to an even length. */ +export function riffChunk(fourCC: string, data: number[]): Uint8Array { + const size = data.length + return concatBytes( + fourCC, + [ + size & 0xff, + (size >>> 8) & 0xff, + (size >>> 16) & 0xff, + (size >>> 24) & 0xff, + ], + data, + size % 2 === 1 ? [0x00] : [], + ) +} + +export function webpContainer(...chunks: BytePart[]): Uint8Array { + return concatBytes('RIFF', [0x00, 0x00, 0x00, 0x00], 'WEBP', ...chunks) +} + +/** VP8X payload: flags byte then a 9-byte canvas description. */ +export function vp8xChunk(flags: number): Uint8Array { + return riffChunk('VP8X', [ + flags, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ]) +} + +const VP8X_ANIMATION_FLAG = 0x02 +export const VP8X_ALPHA_FLAG = 0x10 + +export function stillWebpBytes(): Uint8Array { + return webpContainer(riffChunk('VP8 ', [0x00, 0x00, 0x00, 0x00])) +} + +export function animatedWebpBytes(): Uint8Array { + return webpContainer( + vp8xChunk(VP8X_ANIMATION_FLAG), + riffChunk('ANIM', [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + ) +} diff --git a/packages/core/tests/steps/animated-image-detection.test.ts b/packages/core/tests/steps/animated-image-detection.test.ts new file mode 100644 index 000000000..8013168b5 --- /dev/null +++ b/packages/core/tests/steps/animated-image-detection.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from 'vitest' +import { isAnimatedImage } from '../../src/steps/animated-image' +import { + concatBytes, + gifHeader, + gifFrame, + riffChunk, + pngChunk, + vp8xChunk, + webpContainer, + stillGifBytes, + animatedGifBytes, + stillPngBytes, + apngBytes, + stillWebpBytes, + animatedWebpBytes, + GIF_GRAPHIC_CONTROL, + GIF_NETSCAPE_LOOP, + GIF_TRAILER, + PNG_SIGNATURE, + VP8X_ALPHA_FLAG, + type BytePart, +} from '../helpers/animated-image-fixtures' + +function image(type: string, ...parts: BytePart[]): Blob { + return new Blob([concatBytes(...parts)], { type }) +} + +/** A blob stand-in whose `type` and read behaviour are fully controlled. */ +function fakeBlob(type: string, read: () => Promise): Blob { + return { type, arrayBuffer: read } as unknown as Blob +} + +describe('isAnimatedImage — GIF', () => { + it('reports a single-frame GIF as still', async () => { + await expect( + isAnimatedImage(image('image/gif', stillGifBytes())), + ).resolves.toBe(false) + }) + + it('reports a single-frame GIF with no global colour table as still', async () => { + const file = image( + 'image/gif', + gifHeader({ globalColourTable: false }), + gifFrame(0), + GIF_TRAILER, + ) + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) + + it('reports a single frame behind a graphic control extension as still', async () => { + const file = image( + 'image/gif', + gifHeader(), + GIF_GRAPHIC_CONTROL, + gifFrame(0), + GIF_TRAILER, + ) + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) + + it('reports a GIF carrying the NETSCAPE looping extension as animated', async () => { + await expect( + isAnimatedImage(image('image/gif', animatedGifBytes())), + ).resolves.toBe(true) + }) + + it('reports two image descriptors with no loop extension as animated', async () => { + const file = image( + 'image/gif', + gifHeader(), + gifFrame(0), + gifFrame(1), + GIF_TRAILER, + ) + await expect(isAnimatedImage(file)).resolves.toBe(true) + }) + + it('steps over the global colour table to reach the second frame', async () => { + // gifHeader() emits a two-entry table; finding frame two proves the + // walk used the packed field's size rather than a fixed offset. + const file = image( + 'image/gif', + gifHeader(), + GIF_GRAPHIC_CONTROL, + gifFrame(0), + GIF_GRAPHIC_CONTROL, + gifFrame(1), + GIF_TRAILER, + ) + await expect(isAnimatedImage(file)).resolves.toBe(true) + }) + + it('reports a truncated GIF as still instead of looping on it', async () => { + const file = image('image/gif', gifHeader(), [0x2c, 0x00, 0x00]) + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) + + it('reports bytes that are not a GIF at all as still', async () => { + const file = image('image/gif', 'this is not a GIF, it is prose') + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) +}) + +describe('isAnimatedImage — PNG', () => { + it('reports a still PNG as still', async () => { + await expect( + isAnimatedImage(image('image/png', stillPngBytes())), + ).resolves.toBe(false) + }) + + it('reports a PNG carrying an acTL chunk as animated', async () => { + await expect( + isAnimatedImage(image('image/png', apngBytes())), + ).resolves.toBe(true) + }) + + it('reports an APNG served as image/apng as animated', async () => { + await expect( + isAnimatedImage(image('image/apng', apngBytes())), + ).resolves.toBe(true) + }) + + it('ignores an acTL chunk that follows the first IDAT', async () => { + // The APNG spec requires acTL before IDAT; a later one does not + // animate, so re-encoding such a file loses nothing. + const file = image( + 'image/png', + PNG_SIGNATURE, + pngChunk('IHDR', 13), + pngChunk('IDAT', 4), + pngChunk('acTL', 8), + ) + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) +}) + +describe('isAnimatedImage — WebP', () => { + it('reports a simple lossy WebP as still', async () => { + await expect( + isAnimatedImage(image('image/webp', stillWebpBytes())), + ).resolves.toBe(false) + }) + + it('reports an extended WebP without the animation flag as still', async () => { + const file = image( + 'image/webp', + webpContainer(vp8xChunk(VP8X_ALPHA_FLAG)), + ) + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) + + it('reports the VP8X animation flag as animated', async () => { + await expect( + isAnimatedImage(image('image/webp', animatedWebpBytes())), + ).resolves.toBe(true) + }) + + it('reports a bare ANIM chunk as animated', async () => { + const file = image( + 'image/webp', + webpContainer( + riffChunk('ANIM', [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + ), + ) + await expect(isAnimatedImage(file)).resolves.toBe(true) + }) +}) + +describe('isAnimatedImage — everything else', () => { + it('reports a JPEG as still without reading its bytes', async () => { + const file = fakeBlob('image/jpeg', () => + Promise.reject(new Error('a JPEG must not be read')), + ) + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) + + it('reports a video as still', async () => { + const file = image('video/mp4', [0x00, 0x00, 0x00, 0x18]) + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) + + it('reports a blob with no type as still', async () => { + const file = image('', stillGifBytes()) + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) + + it('handles an uppercase MIME type carrying parameters', async () => { + const bytes = concatBytes( + gifHeader(), + GIF_NETSCAPE_LOOP, + gifFrame(0), + GIF_TRAILER, + ) + const file = fakeBlob('IMAGE/GIF; charset=binary', () => + Promise.resolve(bytes.buffer as ArrayBuffer), + ) + await expect(isAnimatedImage(file)).resolves.toBe(true) + }) + + it('reports an unreadable blob as still rather than throwing', async () => { + const file = fakeBlob('image/gif', () => + Promise.reject(new Error('stream errored')), + ) + await expect(isAnimatedImage(file)).resolves.toBe(false) + }) +}) diff --git a/packages/core/tests/steps/image-processing.test.ts b/packages/core/tests/steps/image-processing.test.ts index 8d2032d08..f0ba5c6cf 100644 --- a/packages/core/tests/steps/image-processing.test.ts +++ b/packages/core/tests/steps/image-processing.test.ts @@ -9,6 +9,12 @@ import { type PipelineContext, type UploadFile, } from '@upupjs/core' +import { + animatedGifBytes, + animatedWebpBytes, + apngBytes, + stillGifBytes, +} from '../helpers/animated-image-fixtures' vi.mock('libheif-js/libheif-wasm/libheif-bundle.mjs', () => ({ default: () => @@ -43,7 +49,7 @@ const ctx: PipelineContext = { function makeUploadFile( name = 'photo.jpg', type = 'image/jpeg', - content = 'original image payload', + content: BlobPart = 'original image payload', ): UploadFile { const file = new File([content], name, { type, lastModified: 123 }) return Object.assign(file, { @@ -157,6 +163,23 @@ describe('browser image processing steps', () => { expect(result.thumbnail?.file.name).toContain('.thumbnail.jpg') }) + it('re-encodes a still GIF, so the animation guard is not a blanket opt-out', async () => { + installImageRuntime('compressed') + const original = makeUploadFile( + 'static.gif', + 'image/gif', + stillGifBytes(), + ) + + const result = await compressStep({ quality: 0.7 }).process( + original, + ctx, + ) + + expect(result).not.toBe(original) + expect(result.metadata.compressed).toBe(true) + }) + it('converts HEIC files to JPEG via libheif through canvas', async () => { installImageRuntime('heic-jpeg') const original = makeUploadFile( @@ -177,3 +200,102 @@ describe('browser image processing steps', () => { }) }) }) + +// ───────────────────────────────────────────── +// Animated images +// +// Both re-encode steps go through a canvas, which has no animated encoder: +// drawImage paints frame one and toBlob writes a still. The guard must keep +// these files out of both steps entirely — the canvas runtime is installed +// here precisely so a re-encode WOULD happen if the guard were missing. +// ───────────────────────────────────────────── +describe('animated images skip the canvas re-encode steps', () => { + const animated: [string, string, Uint8Array][] = [ + ['an animated GIF', 'loop.gif', animatedGifBytes()], + ['an animated WebP', 'loop.webp', animatedWebpBytes()], + ['an APNG', 'loop.png', apngBytes()], + ] + + for (const [label, name, bytes] of animated) { + const type = name.endsWith('.gif') + ? 'image/gif' + : name.endsWith('.webp') + ? 'image/webp' + : 'image/png' + + it(`leaves ${label} untouched in the compress step`, async () => { + installImageRuntime('compressed') + const original = makeUploadFile(name, type, bytes) + + const result = await compressStep({ + maxWidthOrHeight: 1000, + quality: 0.7, + }).process(original, ctx) + + expect(result).toBe(original) + expect(result.type).toBe(type) + expect(result.metadata.compressed).toBeUndefined() + }) + + it(`leaves ${label} untouched in the exif step`, async () => { + installImageRuntime('reencoded') + const original = makeUploadFile(name, type, bytes) + + const result = await exifStep().process(original, ctx) + + expect(result).toBe(original) + expect(result.type).toBe(type) + expect(result.metadata.exifStripped).toBeUndefined() + }) + } + + it('leaves an animated GIF untouched on the web-worker path too', async () => { + installImageRuntime('compressed') + const execute = vi.fn(() => + Promise.resolve({ + kind: 'image' as const, + bytes: new TextEncoder().encode('flattened').buffer, + type: 'image/jpeg', + name: 'loop.gif', + }), + ) + // The worker contract is generic in its result; cast at this + // mock-introspection boundary rather than widening the contract. + const worker = { execute } as unknown as NonNullable< + PipelineContext['worker'] + > + const original = makeUploadFile( + 'loop.gif', + 'image/gif', + animatedGifBytes(), + ) + + const result = await compressStep().process(original, { + ...ctx, + worker, + }) + + expect(result).toBe(original) + expect(execute).not.toHaveBeenCalled() + }) + + it('still generates a thumbnail for an animated GIF', async () => { + // A thumbnail is a still by definition and is stored alongside the + // file rather than replacing it, so it is deliberately not guarded. + installImageRuntime('thumb') + const original = makeUploadFile( + 'loop.gif', + 'image/gif', + animatedGifBytes(), + ) + + const result = await thumbnailStep({ width: 200 }).process( + original, + ctx, + ) + + expect(result.metadata.thumbnailUrl).toMatch( + /^data:image\/jpeg;base64,/, + ) + }) +})