Corrige la compression d'image sur Safari/iPhone (repli PNG au lieu de JPEG)
Build and deploy / deploy (push) Successful in 37s

canvas.toBlob demandait du WebP en dur pour compresser avant l'envoi. Safari ne sait pas l'encoder et retombe silencieusement sur du PNG (imposé par la spec) — un format quasi sans perte, bien plus lourd qu'un JPEG pour une photo, au point de dépasser la limite de taille du bucket malgré une photo source raisonnable (vécu en prod : 4 Mo sur iPhone). Le support WebP est maintenant détecté une fois, avec repli explicite sur JPEG plutôt que de laisser le navigateur choisir. S'applique au Mur (déjà corrigé pour l'extension/Content-Type) et maintenant aussi aux avatars (/profile, /signup).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-26 15:04:08 +02:00
parent 358bed0c2e
commit e70f0c85bb
3 changed files with 45 additions and 12 deletions
+7 -3
View File
@@ -64,14 +64,18 @@ export function ProfileForm({
let nextAvatarUrl = avatarUrl; let nextAvatarUrl = avatarUrl;
if (avatarBlob) { if (avatarBlob) {
const path = `${userId}.webp`; // Extension alignée sur le vrai type du blob (pas ".webp" en dur) :
// certains navigateurs (Safari) retombent sur du PNG si le WebP
// n'est pas supporté à l'encodage — voir cropImageToBlob.
const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp";
const path = `${userId}.${extension}`;
const { error: uploadError } = await supabase.storage const { error: uploadError } = await supabase.storage
.from("avatars") .from("avatars")
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" }); .upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
if (uploadError) { if (uploadError) {
setLoading(false); setLoading(false);
setError("Impossible d'envoyer la photo, réessaie."); setError(`Impossible d'envoyer la photo (${uploadError.message}). Réessaie.`);
return; return;
} }
+11 -2
View File
@@ -120,14 +120,23 @@ export default function SignupPage() {
let avatarUrl: string | null = null; let avatarUrl: string | null = null;
if (avatarBlob) { if (avatarBlob) {
const path = `${userId}.webp`; // Extension alignée sur le vrai type du blob (pas ".webp" en dur) :
// certains navigateurs (Safari) retombent sur du PNG si le WebP
// n'est pas supporté à l'encodage — voir cropImageToBlob.
const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp";
const path = `${userId}.${extension}`;
const { error: uploadError } = await supabase.storage const { error: uploadError } = await supabase.storage
.from("avatars") .from("avatars")
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" }); .upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
if (!uploadError) { if (!uploadError) {
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path); const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
avatarUrl = publicUrlData.publicUrl; avatarUrl = publicUrlData.publicUrl;
} else {
// Ne bloque pas la finalisation du compte pour une photo optionnelle
// — juste de quoi diagnostiquer si quelqu'un signale l'absence
// d'avatar après inscription.
console.error("Échec de l'envoi de l'avatar à l'inscription :", uploadError);
} }
} }
+27 -7
View File
@@ -9,8 +9,28 @@ function loadImage(src: string): Promise<HTMLImageElement> {
}); });
} }
// Certains navigateurs (Safari, historiquement) ne savent pas encoder du
// WebP via canvas.toBlob/toDataURL : la spec impose alors un repli
// silencieux sur du PNG, un format quasi sans perte — beaucoup plus lourd
// qu'un JPEG pour une photo, au point de dépasser la limite de taille du
// bucket malgré la "compression" (vécu en prod avec une photo iPhone de
// 4 Mo). Détecter le support réel et demander explicitement du JPEG dans
// ce cas — plutôt que de laisser le navigateur retomber sur du PNG —
// garantit une vraie compression avec perte dans tous les cas.
let cachedMimeType: string | null = null;
function preferredMimeType(): string {
if (cachedMimeType) return cachedMimeType;
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
cachedMimeType = canvas.toDataURL("image/webp").startsWith("data:image/webp") ? "image/webp" : "image/jpeg";
return cachedMimeType;
}
// Découpe la zone sélectionnée, redimensionne (sans jamais agrandir) à // Découpe la zone sélectionnée, redimensionne (sans jamais agrandir) à
// maxSize et compresse en WebP côté client avant l'envoi vers le bucket. // maxSize et compresse (WebP si le navigateur sait l'encoder, sinon JPEG)
// côté client avant l'envoi vers le bucket.
export async function cropImageToBlob( export async function cropImageToBlob(
imageSrc: string, imageSrc: string,
crop: PixelCrop, crop: PixelCrop,
@@ -33,7 +53,7 @@ export async function cropImageToBlob(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))), (blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
"image/webp", preferredMimeType(),
quality, quality,
); );
}); });
@@ -41,10 +61,10 @@ export async function cropImageToBlob(
// Redimensionne (sans jamais agrandir) au format d'origine — contrairement // Redimensionne (sans jamais agrandir) au format d'origine — contrairement
// à cropImageToBlob qui force un carré pour les avatars — à maxDimension // à cropImageToBlob qui force un carré pour les avatars — à maxDimension
// sur le plus grand côté, et compresse en WebP avant l'envoi vers le // sur le plus grand côté, et compresse (WebP si supporté, sinon JPEG —
// bucket. Le bucket a aussi sa propre limite de taille côté serveur // voir preferredMimeType) avant l'envoi vers le bucket. Le bucket a aussi
// (défense en profondeur) : cette compression n'est qu'un premier filtre, // sa propre limite de taille côté serveur (défense en profondeur) : cette
// pas la seule garantie. // compression n'est qu'un premier filtre, pas la seule garantie.
export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise<Blob> { export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise<Blob> {
const src = URL.createObjectURL(file); const src = URL.createObjectURL(file);
try { try {
@@ -65,7 +85,7 @@ export async function resizeImageToBlob(file: File, maxDimension = 1600, quality
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))), (blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
"image/webp", preferredMimeType(),
quality, quality,
); );
}); });