From 2603ca8e5c621daaa9e781ceba539e94a2f0e770 Mon Sep 17 00:00:00 2001 From: litruv Date: Sat, 25 Jul 2026 20:09:22 +1000 Subject: [PATCH] Fix avatar upload failing on JPEG/WebP/GIF without a profile color. embedMetadataInImage returned null for non-PNG when there was nothing to embed, which blocked plain avatar saves after format/library churn. --- src/app/features/settings/account/Profile.tsx | 61 +++++++++++++---- src/app/hooks/useUserBanner.ts | 40 +++++++---- src/app/hooks/useUserColor.ts | 3 +- src/app/hooks/useUserProfileStyle.ts | 22 ++++-- src/app/utils/imageMetadata.ts | 68 ++++++++++++++++++- 5 files changed, 158 insertions(+), 36 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 349a6f1..17451d3 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -39,12 +39,14 @@ import { AvatarPresence, PresenceBadge } from '../../../components/presence'; import { BreakWord, LineClamp3 } from '../../../styles/Text.css'; import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID'; import { - extractMetadataFromImage, embedMetadataInImage, detectImageFormat, getMimeType, getExtension, ImageMetadata, + needsPngForMetadata, + convertImageDataToPng, + uint8ArrayToBlob, } from '../../../utils/imageMetadata'; import { getCurrentAccessToken } from '../../../utils/auth'; @@ -473,7 +475,35 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject { diff --git a/src/app/hooks/useUserBanner.ts b/src/app/hooks/useUserBanner.ts index e0e9fc3..ddf5662 100644 --- a/src/app/hooks/useUserBanner.ts +++ b/src/app/hooks/useUserBanner.ts @@ -12,6 +12,8 @@ import { getMimeType, getExtension, ImageMetadata, + convertImageDataToPng, + uint8ArrayToBlob, } from '../utils/imageMetadata'; /** @@ -137,27 +139,39 @@ export function useUserBanner(): [ throw new Error('Failed to fetch current avatar'); } - // Detect image format - const format = detectImageFormat(avatarData); - + // Detect image format — banner lives in PNG tEXt, so convert other formats first + let workingData: ArrayBuffer | Uint8Array = avatarData; + let format = detectImageFormat(workingData); + if (format === 'unknown') { throw new Error('Unsupported avatar image format'); } + if (format !== 'png') { + console.log('[updateBanner] Converting', format, 'avatar to PNG for banner metadata'); + const pngData = await convertImageDataToPng(workingData); + if (!pngData) { + throw new Error('Failed to convert avatar to PNG for banner metadata'); + } + workingData = pngData; + format = 'png'; + } + // Get existing metadata to preserve - const existingMetadata = extractMetadataFromImage(avatarData); + const existingMetadata = extractMetadataFromImage(workingData); console.log('[updateBanner] Existing metadata:', existingMetadata); - - // Modify image metadata with new banner, preserving color + + // Modify image metadata with new banner, preserving color. + // Empty string clears banner (PNG embed drops falsy fields after stripping old chunks). const newMetadata: ImageMetadata = { color: existingMetadata.color, - banner: newBanner, + banner: newBanner ?? '', avatarBorderColor: existingMetadata.avatarBorderColor, gradient: existingMetadata.gradient, }; console.log('[updateBanner] New metadata to embed:', newMetadata); - - const newAvatarData = embedMetadataInImage(avatarData, newMetadata); + + const newAvatarData = embedMetadataInImage(workingData, newMetadata); if (!newAvatarData) { throw new Error('Failed to embed banner in avatar metadata'); @@ -166,8 +180,8 @@ export function useUserBanner(): [ // Verify the banner was embedded correctly before uploading const verifyMetadata = extractMetadataFromImage(newAvatarData); console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata); - - if (newBanner && verifyMetadata.banner !== newBanner) { + + if ((newBanner || undefined) !== verifyMetadata.banner) { console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner); throw new Error('Banner verification failed'); } @@ -176,8 +190,8 @@ export function useUserBanner(): [ // Upload modified avatar with correct MIME type const mimeType = getMimeType(format); const extension = getExtension(format); - const blob = new Blob([newAvatarData], { type: mimeType }); - + const blob = uint8ArrayToBlob(newAvatarData, mimeType); + const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType, diff --git a/src/app/hooks/useUserColor.ts b/src/app/hooks/useUserColor.ts index 083c515..48c53dc 100644 --- a/src/app/hooks/useUserColor.ts +++ b/src/app/hooks/useUserColor.ts @@ -12,6 +12,7 @@ import { detectImageFormat, getMimeType, getExtension, + uint8ArrayToBlob, } from '../utils/imageMetadata'; /** @@ -176,7 +177,7 @@ export function useUserColor(): [ // Upload modified avatar with correct MIME type const mimeType = getMimeType(format); const extension = getExtension(format); - const blob = new Blob([newAvatarData], { type: mimeType }); + const blob = uint8ArrayToBlob(newAvatarData, mimeType); const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType, diff --git a/src/app/hooks/useUserProfileStyle.ts b/src/app/hooks/useUserProfileStyle.ts index e82249e..727c21c 100644 --- a/src/app/hooks/useUserProfileStyle.ts +++ b/src/app/hooks/useUserProfileStyle.ts @@ -11,6 +11,8 @@ import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken } getExtension, ImageMetadata, ProfileGradient, + convertImageDataToPng, + uint8ArrayToBlob, } from '../utils/imageMetadata'; /** @@ -142,14 +144,24 @@ export function useUserProfileStyle(): [ throw new Error('Failed to fetch current avatar'); } - // Detect image format - const format = detectImageFormat(avatarData); + // Border/gradient live in PNG tEXt — convert other formats first + let workingData: ArrayBuffer | Uint8Array = avatarData; + let format = detectImageFormat(workingData); if (format === 'unknown') { throw new Error('Unsupported avatar image format'); } + if (format !== 'png') { + const pngData = await convertImageDataToPng(workingData); + if (!pngData) { + throw new Error('Failed to convert avatar to PNG for style metadata'); + } + workingData = pngData; + format = 'png'; + } + // Get existing metadata to preserve - const existingMetadata = extractMetadataFromImage(avatarData); + const existingMetadata = extractMetadataFromImage(workingData); // Merge with new style const newMetadata: ImageMetadata = { @@ -159,7 +171,7 @@ export function useUserProfileStyle(): [ }; // Embed updated metadata - const newAvatarData = embedMetadataInImage(avatarData, newMetadata); + const newAvatarData = embedMetadataInImage(workingData, newMetadata); if (!newAvatarData) { throw new Error('Failed to embed style in avatar metadata'); } @@ -167,7 +179,7 @@ export function useUserProfileStyle(): [ // Upload modified avatar with correct MIME type const mimeType = getMimeType(format); const extension = getExtension(format); - const blob = new Blob([newAvatarData], { type: mimeType }); + const blob = uint8ArrayToBlob(newAvatarData, mimeType); const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, diff --git a/src/app/utils/imageMetadata.ts b/src/app/utils/imageMetadata.ts index 9611c7f..01cf7eb 100644 --- a/src/app/utils/imageMetadata.ts +++ b/src/app/utils/imageMetadata.ts @@ -258,6 +258,59 @@ export function embedColorInImage(imageData: ArrayBuffer | Uint8Array, color: st } } +/** + * True when metadata includes fields that only PNG tEXt chunks can store. + */ +export function needsPngForMetadata(metadata: ImageMetadata): boolean { + return Boolean(metadata.banner || metadata.avatarBorderColor || metadata.gradient); +} + +/** + * Convert image data to PNG via canvas (needed for banner/border/gradient metadata). + */ +export async function convertImageDataToPng( + imageData: ArrayBuffer | Uint8Array +): Promise { + const bytes = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + const format = detectImageFormat(bytes); + if (format === 'png') return bytes; + if (format === 'unknown') return null; + + try { + const blob = uint8ArrayToBlob(bytes, getMimeType(format)); + const bitmap = await createImageBitmap(blob); + try { + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.drawImage(bitmap, 0, 0); + + const pngBlob = await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/png'); + }); + if (!pngBlob) return null; + return new Uint8Array(await pngBlob.arrayBuffer()); + } finally { + bitmap.close(); + } + } catch (error) { + console.error('[convertImageDataToPng] Conversion failed:', error); + return null; + } +} + +/** + * Build a Blob from Uint8Array without relying on BufferSource/BlobPart quirks + * across TS DOM lib / Electron versions (avoids SharedArrayBuffer typing issues). + */ +export function uint8ArrayToBlob(data: Uint8Array, type: string): Blob { + const copy = new ArrayBuffer(data.byteLength); + new Uint8Array(copy).set(data); + return new Blob([copy], { type }); +} + /** * Embed paarrot metadata (color and/or banner) into image data * Preserves existing metadata that is not being updated @@ -270,18 +323,27 @@ export function embedMetadataInImage( metadata: ImageMetadata ): Uint8Array | null { const format = detectImageFormat(imageData); - + const bytes = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + switch (format) { case 'png': return embedMetadataInPNG(imageData, metadata); - // For other formats, only embed color for now + // Non-PNG: color via format-specific chunks; banner/border/gradient need PNG (convert first) case 'webp': case 'jpeg': case 'gif': + if (needsPngForMetadata(metadata)) { + console.warn( + '[embedMetadataInImage] Banner/border/gradient require PNG; convert with convertImageDataToPng first. Got:', + format + ); + return null; + } if (metadata.color) { return embedColorInImage(imageData, metadata.color); } - return null; + // Supported format with nothing to embed — succeed as a no-op so avatar upload isn't blocked + return bytes; default: console.warn('[embedMetadataInImage] Unknown image format, cannot embed metadata'); return null;