feat(profile): preserve animation on GIF/APNG/animated-WebP avatar uploads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-17 17:38:07 +02:00
parent 11869be443
commit e19a71e892
+68 -1
View File
@@ -43,19 +43,86 @@ async function resizeToSquare(file: File): Promise<Blob> {
} }
} }
const MAX_ANIMATED_BYTES = 2 * 1024 * 1024; // 2 MB hard cap on animated uploads
const ANIMATED_MIMES = new Set(['image/gif', 'image/apng', 'image/webp', 'image/png']);
export async function uploadAvatar(userId: string, file: File): Promise<string> { export async function uploadAvatar(userId: string, file: File): Promise<string> {
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
throw new Error('only image files are accepted'); throw new Error('only image files are accepted');
} }
// Animated formats bypass the canvas re-encode (which would strip
// animation by sampling the first frame). We still validate dimensions
// and size so a 40-MB animated WebP can't slip through.
if (ANIMATED_MIMES.has(file.type) && (await isAnimated(file))) {
if (file.size > MAX_ANIMATED_BYTES) {
throw new Error('animated avatar too large (max 2 MB)');
}
const dims = await readDimensions(file);
if (dims.width > MAX_DIM || dims.height > MAX_DIM) {
throw new Error('animated avatar exceeds ' + MAX_DIM + 'px (got ' + dims.width + 'x' + dims.height + ')');
}
return uploadAvatarBlob(userId, file);
}
const blob = await resizeToSquare(file); const blob = await resizeToSquare(file);
return uploadAvatarBlob(userId, blob); return uploadAvatarBlob(userId, blob);
} }
async function readDimensions(file: File): Promise<{ width: number; height: number }> {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('image load failed'));
i.src = url;
});
return { width: img.naturalWidth, height: img.naturalHeight };
} finally {
URL.revokeObjectURL(url);
}
}
async function isAnimated(file: File): Promise<boolean> {
// GIF: any GIF89a/GIF87a header is treated as potentially animated. The
// static-GIF case (one image-descriptor block) is rare enough that
// re-encoding wouldn't save much, so we accept the false-positives.
if (file.type === 'image/gif') return true;
// APNG: presence of an 'acTL' chunk inside the PNG stream. Scan the
// first 64 KB — APNGs put acTL near the front, before IDAT.
if (file.type === 'image/apng' || file.type === 'image/png') {
const head = await file.slice(0, 65536).arrayBuffer();
return containsBytes(head, [0x61, 0x63, 0x54, 0x4c]); // 'acTL'
}
// Animated WebP: 'ANIM' chunk in the RIFF container.
if (file.type === 'image/webp') {
const head = await file.slice(0, 65536).arrayBuffer();
return containsBytes(head, [0x41, 0x4e, 0x49, 0x4d]); // 'ANIM'
}
return false;
}
function containsBytes(buf: ArrayBuffer, needle: number[]): boolean {
const view = new Uint8Array(buf);
const len = view.length;
const nlen = needle.length;
outer: for (let i = 0; i + nlen <= len; i++) {
for (let j = 0; j < nlen; j++) {
if (view[i + j] !== needle[j]) continue outer;
}
return true;
}
return false;
}
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going // Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
// through the legacy center-crop. Caller is responsible for sizing — the // through the legacy center-crop. Caller is responsible for sizing — the
// dialog already clamps to MAX_DIM via its outputWidth. // dialog already clamps to MAX_DIM via its outputWidth.
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> { export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg'; const ext =
blob.type === 'image/webp' ? 'webp' :
blob.type === 'image/gif' ? 'gif' :
blob.type === 'image/apng' || blob.type === 'image/png' ? 'png' :
'jpg';
// Random filename so old uploads don't get overwritten before we update // Random filename so old uploads don't get overwritten before we update
// the profile row — Supabase Storage CDN caches by URL, so a fresh path // the profile row — Supabase Storage CDN caches by URL, so a fresh path
// also forces clients to fetch the new image. // also forces clients to fetch the new image.