Corrige la compression d'image sur Safari/iPhone (repli PNG au lieu de JPEG)
Build and deploy / deploy (push) Successful in 37s
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:
@@ -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
@@ -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
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user