From 6467844750c4542f098d0c2ae3328c46bb3a06fd Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:04:26 +0100 Subject: [PATCH] fix: encode avatars at AVIF quality 75, not sharp's default 50 Avatars, banners and their thumbnails went through sharp's AVIF default of quality 50. On a flat drawing like the owl that softens every edge, and the 128 px chat thumbnail shows it most: a 256 px avatar came out at 1.8 KB. Quality 75 is a few KB more per picture. Only new uploads get it. The thumbnails already stored are rebuilt only when their size changes, not their quality. Co-Authored-By: Claude Opus 5.5 --- src/constants/media.ts | 3 +++ src/services/avatarImage.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/constants/media.ts b/src/constants/media.ts index 9e48a117..783cb21f 100644 --- a/src/constants/media.ts +++ b/src/constants/media.ts @@ -9,6 +9,9 @@ export const AVATAR_MAX_PX = 256; screen, and a 64px source is soft at the top of that. */ export const AVATAR_THUMB_PX = 128; +/** AVIF quality for avatars, banners and their thumbnails. sharp's default of 50 blurs a flat drawing's edges. */ +export const AVATAR_QUALITY = 75; + /** The member card's banner is 320 by 164 CSS px, so 3x of that. Any other shape and the card crops and scales the stored picture again, so it stops matching what the editor showed. */ export const BANNER_BOX = { width: 960, height: 492, thumbWidth: 480, thumbHeight: 246 } as const; diff --git a/src/services/avatarImage.ts b/src/services/avatarImage.ts index 587417a1..e6255096 100644 --- a/src/services/avatarImage.ts +++ b/src/services/avatarImage.ts @@ -2,7 +2,7 @@ import consola from "consola"; import sharp from "sharp"; import { v4 as uuidv4 } from "uuid"; -import { AVATAR_MAX_PX, AVATAR_THUMB_PX } from "../constants/media"; +import { AVATAR_MAX_PX, AVATAR_QUALITY, AVATAR_THUMB_PX } from "../constants/media"; /** The box a picture is cut to, and its thumbnail's. Avatars are square. */ export interface PictureBox { width: number; height: number; thumbWidth: number; thumbHeight: number } @@ -71,7 +71,7 @@ export async function storeAvatarPicture(input: { const thumb = await sharp(bytes, { pages: 1, failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) .resize({ width: box.thumbWidth, height: box.thumbHeight, fit: "cover" }) - .avif({ quality: 50 }) + .avif({ quality: AVATAR_QUALITY }) .toBuffer() .catch(() => null); @@ -88,7 +88,7 @@ export async function storeAvatarPicture(input: { try { storedBody = await sharp(bytes, { pages: 1, failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) .resize({ width: box.width, height: box.height, fit: "cover" }) - .avif() + .avif({ quality: AVATAR_QUALITY }) .toBuffer(); } catch { return { ok: false, status: 400, error: "invalid_file", message: "Could not process image." }; @@ -104,7 +104,7 @@ export async function storeAvatarPicture(input: { try { storedBody = await sharp(bytes, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) .resize({ width: box.width, height: box.height, fit: "cover" }) - .avif() + .avif({ quality: AVATAR_QUALITY }) .toBuffer(); } catch { return { ok: false, status: 400, error: "invalid_file", message: "Could not process image. Please upload a valid image under the size limit." }; @@ -116,7 +116,7 @@ export async function storeAvatarPicture(input: { const thumb = await sharp(bytes, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) .resize({ width: box.thumbWidth, height: box.thumbHeight, fit: "cover" }) - .avif({ quality: 50 }) + .avif({ quality: AVATAR_QUALITY }) .toBuffer() .catch(() => null); @@ -177,7 +177,7 @@ export async function storeAvatarPicture(input: { const thumbBuf = await sharp(resized, { pages: 1, failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) .resize({ width: box.thumbWidth, height: box.thumbHeight, fit: "cover" }) - .avif({ quality: 50 }) + .avif({ quality: AVATAR_QUALITY }) .toBuffer() .catch(() => null); const newThumbKey = thumbBuf ? `${prefix}/thumb_${fileId}.avif` : null;