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;