From 3a74699fef9c9c2bb9a61cc38e1b956469e0bf90 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:57:33 -0400 Subject: [PATCH] fix(media): "Compress image" no longer blackens transparent images or freezes animations (#331) Compression re-encodes through a canvas to JPEG. Only PNG was skipped, so a transparent WebP came out on a black background, and an animated GIF or WebP became one still frame. GIF and APNG are no longer offered; an animated WebP (VP8X animation flag) or any image with a transparent pixel is kept as the original. Fixes #331 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/utils/imageCompression.test.ts | 33 ++++++++++++++++-- src/app/utils/imageCompression.ts | 48 +++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/app/utils/imageCompression.test.ts b/src/app/utils/imageCompression.test.ts index 4bcf11415..a41c1c1a9 100644 --- a/src/app/utils/imageCompression.test.ts +++ b/src/app/utils/imageCompression.test.ts @@ -1,6 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { formatFileSize, isCompressible } from './imageCompression'; +import { + formatFileSize, + hasTransparency, + isAnimatedWebp, + isCompressible, +} from './imageCompression'; test('formatFileSize formats bytes below 1KB as B', () => { assert.equal(formatFileSize(0), '0 B'); @@ -25,14 +30,38 @@ test('formatFileSize formats values in the MB range', () => { test('isCompressible accepts raster image types', () => { assert.equal(isCompressible({ type: 'image/png' } as Blob), true); assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true); - assert.equal(isCompressible({ type: 'image/gif' } as Blob), true); assert.equal(isCompressible({ type: 'image/webp' } as Blob), true); }); test('isCompressible rejects svg, empty type, and non-images', () => { assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false); + // [Gitea #331] usually animated; a canvas keeps one frame + assert.equal(isCompressible({ type: 'image/gif' } as Blob), false); + assert.equal(isCompressible({ type: 'image/apng' } as Blob), false); assert.equal(isCompressible({ type: '' } as Blob), false); assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false); assert.equal(isCompressible({ type: 'text/plain' } as Blob), false); assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false); }); + +const webpHeader = (chunk: string, flags = 0) => { + const b = new Uint8Array(32); + const put = (at: number, str: string) => b.set(new TextEncoder().encode(str), at); + put(0, 'RIFF'); + put(8, 'WEBP'); + put(12, chunk); + b[20] = flags; + return b; +}; + +test('isAnimatedWebp: VP8X with the animation flag (#331)', () => { + assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x02)), true); + assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x10)), false, 'alpha flag only'); + assert.equal(isAnimatedWebp(webpHeader('VP8 ')), false, 'simple lossy'); + assert.equal(isAnimatedWebp(new Uint8Array(8)), false); +}); + +test('hasTransparency (#331)', () => { + assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 4, 5, 6, 255])), false); + assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 0, 0, 0, 0])), true); +}); diff --git a/src/app/utils/imageCompression.ts b/src/app/utils/imageCompression.ts index 63b277036..6eb5e14ad 100644 --- a/src/app/utils/imageCompression.ts +++ b/src/app/utils/imageCompression.ts @@ -8,11 +8,32 @@ export type CompressionResult = { height: number; }; -// Any raster image the browser can render to canvas can be compressed to JPEG. -// SVG is vector and must stay as-is; GIF animation is lost on canvas but static -// frames compress fine. Empty type string (undetected MIME) is excluded. +// Raster images the browser can render to canvas can be compressed to JPEG. +// SVG is vector and must stay as-is. [Gitea #331] GIF and APNG are usually +// animated, and a canvas keeps only the first frame, so they aren't offered. +// Empty type string (undetected MIME) is excluded. +const NOT_COMPRESSIBLE = new Set(['image/svg+xml', 'image/gif', 'image/apng']); const isCompressibleType = (type: string): boolean => - type.startsWith('image/') && type !== 'image/svg+xml' && type !== ''; + type.startsWith('image/') && !NOT_COMPRESSIBLE.has(type); + +/** + * Pure: whether a WebP file is animated. An animated WebP is a VP8X file + * whose header has the animation flag (byte 20, bit 0x02) and an ANIM chunk. + */ +export function isAnimatedWebp(bytes: Uint8Array): boolean { + const ascii = (at: number, len: number) => String.fromCharCode(...bytes.subarray(at, at + len)); + if (bytes.length < 21 || ascii(0, 4) !== 'RIFF' || ascii(8, 4) !== 'WEBP') return false; + // Bit 0x02 of the VP8X flags byte (no-bitwise: test it arithmetically). + return ascii(12, 4) === 'VP8X' && Math.floor(bytes[20] / 2) % 2 === 1; +} + +/** Pure: whether RGBA pixel data has any pixel that isn't fully opaque. */ +export function hasTransparency(rgba: Uint8ClampedArray): boolean { + for (let i = 3; i < rgba.length; i += 4) { + if (rgba[i] !== 255) return true; + } + return false; +} /** Returns true if this file can be compressed via canvas (any raster image type). */ export function isCompressible(file: File | Blob): boolean { @@ -28,7 +49,8 @@ const JPEG_OUTPUT_TYPE = 'image/jpeg'; * * PNG is skipped entirely: it may carry an alpha channel, and re-encoding to * JPEG composites transparency onto an opaque (black) background, corrupting the - * image. Returning null makes callers fall back to uploading the lossless + * image. Any other image with transparent pixels, and animated WebP, are left + * alone for the same reason (#331). Returning null makes callers fall back to uploading the lossless * original. The image is decoded with `imageOrientation: 'from-image'` so any * EXIF orientation is baked into the pixels instead of being silently dropped. */ @@ -39,6 +61,15 @@ export async function compressImage( if (!isCompressibleType(file.type)) return null; // Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency. if (file.type === 'image/png') return null; + // [Gitea #331] An animated WebP would become a single still frame. + if (file.type === 'image/webp') { + try { + const head = new Uint8Array(await file.slice(0, 32).arrayBuffer()); + if (isAnimatedWebp(head)) return null; + } catch { + return null; + } + } let bitmap: ImageBitmap; try { @@ -60,6 +91,13 @@ export async function compressImage( } ctx.drawImage(bitmap, 0, 0); bitmap.close(); + // [Gitea #331] JPEG has no alpha: a transparent WebP (sticker, logo) came out + // with a black background. Keep any image with transparency as the original. + try { + if (hasTransparency(ctx.getImageData(0, 0, width, height).data)) return null; + } catch { + return null; + } return new Promise((resolve) => { canvas.toBlob(