From 230d58872d0fa44279f6358cd9c5116bcc2cd3fe Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:39:48 +0100 Subject: [PATCH] Studio: Preserve transparency in uploaded profile avatars (#5200) * fix: preserve transparency in uploaded profile avatars * fix: guard unsupported canvas mime fallback --- .../profile/utils/resize-image-file.ts | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/studio/frontend/src/features/profile/utils/resize-image-file.ts b/studio/frontend/src/features/profile/utils/resize-image-file.ts index 3f829ba975..c420497395 100644 --- a/studio/frontend/src/features/profile/utils/resize-image-file.ts +++ b/studio/frontend/src/features/profile/utils/resize-image-file.ts @@ -3,6 +3,11 @@ const MAX_EDGE = 256; const MAX_BYTES = 380_000; +const MAX_DATA_URL_LENGTH = Math.floor(MAX_BYTES * 1.35); +const JPEG_QUALITY_START = 0.88; +const WEBP_QUALITY_START = 0.9; +const MIN_QUALITY = 0.45; +const QUALITY_STEP = 0.08; function loadImage(file: File): Promise { return new Promise((resolve, reject) => { @@ -20,8 +25,33 @@ function loadImage(file: File): Promise { }); } +function canvasHasTransparency(ctx: CanvasRenderingContext2D, width: number, height: number): boolean { + const { data } = ctx.getImageData(0, 0, width, height); + for (let i = 3; i < data.length; i += 4) { + if (data[i] < 255) return true; + } + return false; +} + +function encodeCanvasWithinLimit( + canvas: HTMLCanvasElement, + mimeType: string, + startQuality: number, +): string | null { + let quality = startQuality; + let dataUrl = canvas.toDataURL(mimeType, quality); + if (!dataUrl.startsWith(`data:${mimeType}`)) return null; + + while (dataUrl.length > MAX_DATA_URL_LENGTH && quality > MIN_QUALITY) { + quality -= QUALITY_STEP; + dataUrl = canvas.toDataURL(mimeType, quality); + } + + return dataUrl.length <= MAX_DATA_URL_LENGTH ? dataUrl : null; +} + /** - * Downscale and re-encode as JPEG so localStorage stays within reasonable size. + * Downscale the image and keep transparency when present while staying localStorage-friendly. */ export async function resizeImageFileToDataUrl(file: File): Promise { const img = await loadImage(file); @@ -40,14 +70,14 @@ export async function resizeImageFileToDataUrl(file: File): Promise { if (!ctx) throw new Error("Canvas not available"); ctx.drawImage(img, 0, 0, cw, ch); - let quality = 0.88; - let dataUrl = canvas.toDataURL("image/jpeg", quality); - while (dataUrl.length > MAX_BYTES * 1.35 && quality > 0.45) { - quality -= 0.08; - dataUrl = canvas.toDataURL("image/jpeg", quality); + const hasTransparency = canvasHasTransparency(ctx, cw, ch); + if (hasTransparency) { + const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START); + if (webpDataUrl) return webpDataUrl; } - if (dataUrl.length > MAX_BYTES * 1.35) { - throw new Error("Image is still too large after compression. Try a smaller file."); - } - return dataUrl; + + const jpegDataUrl = encodeCanvasWithinLimit(canvas, "image/jpeg", JPEG_QUALITY_START); + if (jpegDataUrl) return jpegDataUrl; + + throw new Error("Image is still too large after compression. Try a smaller file."); }