feat: image compression w/ sharp

This commit is contained in:
Face 2025-06-14 19:06:22 +06:00
parent 7b11266f72
commit 861bb01a31
7 changed files with 1626 additions and 52 deletions

1600
website/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -58,6 +58,7 @@
"openai": "^4.103.0", "openai": "^4.103.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"redis": "^5.1.0", "redis": "^5.1.0",
"sharp": "^0.34.2",
"svelte-apexcharts": "^1.0.2", "svelte-apexcharts": "^1.0.2",
"svelte-confetti": "^2.3.1", "svelte-confetti": "^2.3.1",
"svelte-lightweight-charts": "^2.2.0" "svelte-lightweight-charts": "^2.2.0"

View file

@ -42,8 +42,7 @@ export const auth = betterAuth({
s3ImageKey = await uploadProfilePicture( s3ImageKey = await uploadProfilePicture(
profile.sub, profile.sub,
new Uint8Array(arrayBuffer), new Uint8Array(arrayBuffer),
blob.type, blob.type || 'image/jpeg'
blob.size
); );
} }
} catch (error) { } catch (error) {

View file

@ -0,0 +1,39 @@
import sharp from 'sharp';
const MAX_SIZE = 128;
const WEBP_QUALITY = 50;
export interface ProcessedImage {
buffer: Buffer;
contentType: string;
size: number;
}
export async function processImage(
inputBuffer: Buffer,
): Promise<ProcessedImage> {
try {
const image = sharp(inputBuffer, { animated: true });
const processedBuffer = await image
.resize(MAX_SIZE, MAX_SIZE, {
fit: 'inside',
withoutEnlargement: true
})
.webp({
quality: WEBP_QUALITY,
effort: 6
})
.toBuffer();
return {
buffer: processedBuffer,
contentType: 'image/webp',
size: processedBuffer.length
};
} catch (error) {
console.error('Image processing failed:', error);
throw new Error('Failed to process image');
}
}

View file

@ -2,6 +2,7 @@ import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } fro
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PRIVATE_B2_KEY_ID, PRIVATE_B2_APP_KEY } from '$env/static/private'; import { PRIVATE_B2_KEY_ID, PRIVATE_B2_APP_KEY } from '$env/static/private';
import { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT, PUBLIC_B2_REGION } from '$env/static/public'; import { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT, PUBLIC_B2_REGION } from '$env/static/public';
import { processImage } from './image.js';
const s3Client = new S3Client({ const s3Client = new S3Client({
endpoint: PUBLIC_B2_ENDPOINT, endpoint: PUBLIC_B2_ENDPOINT,
@ -47,7 +48,6 @@ export async function uploadProfilePicture(
identifier: string, // Can be user ID or a unique ID from social provider identifier: string, // Can be user ID or a unique ID from social provider
body: Uint8Array, body: Uint8Array,
contentType: string, contentType: string,
contentLength?: number
): Promise<string> { ): Promise<string> {
if (!contentType || !contentType.startsWith('image/')) { if (!contentType || !contentType.startsWith('image/')) {
throw new Error('Invalid file type. Only images are allowed.'); throw new Error('Invalid file type. Only images are allowed.');
@ -58,17 +58,16 @@ export async function uploadProfilePicture(
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.'); throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
} }
let fileExtension = contentType.split('/')[1]; const processedImage = await processImage(Buffer.from(body));
if (fileExtension === 'jpeg') fileExtension = 'jpg';
const key = `avatars/${identifier}.${fileExtension}`; const key = `avatars/${identifier}.webp`;
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET, Bucket: PUBLIC_B2_BUCKET,
Key: key, Key: key,
Body: body, Body: processedImage.buffer,
ContentType: contentType, ContentType: processedImage.contentType,
...(contentLength && { ContentLength: contentLength }), ContentLength: processedImage.size,
}); });
await s3Client.send(command); await s3Client.send(command);
@ -79,7 +78,6 @@ export async function uploadCoinIcon(
coinSymbol: string, coinSymbol: string,
body: Uint8Array, body: Uint8Array,
contentType: string, contentType: string,
contentLength?: number
): Promise<string> { ): Promise<string> {
if (!contentType || !contentType.startsWith('image/')) { if (!contentType || !contentType.startsWith('image/')) {
throw new Error('Invalid file type. Only images are allowed.'); throw new Error('Invalid file type. Only images are allowed.');
@ -90,17 +88,16 @@ export async function uploadCoinIcon(
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.'); throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
} }
let fileExtension = contentType.split('/')[1]; const processedImage = await processImage(Buffer.from(body));
if (fileExtension === 'jpeg') fileExtension = 'jpg';
const key = `coins/${coinSymbol.toLowerCase()}.${fileExtension}`; const key = `coins/${coinSymbol.toLowerCase()}.webp`;
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET, Bucket: PUBLIC_B2_BUCKET,
Key: key, Key: key,
Body: body, Body: processedImage.buffer,
ContentType: contentType, ContentType: processedImage.contentType,
...(contentLength && { ContentLength: contentLength }), ContentLength: processedImage.size,
}); });
await s3Client.send(command); await s3Client.send(command);

View file

@ -49,8 +49,7 @@ async function handleIconUpload(iconFile: File | null, symbol: string): Promise<
return await uploadCoinIcon( return await uploadCoinIcon(
symbol, symbol,
new Uint8Array(arrayBuffer), new Uint8Array(arrayBuffer),
iconFile.type, iconFile.type
iconFile.size
); );
} }

View file

@ -91,8 +91,7 @@ export async function POST({ request }) {
const key = await uploadProfilePicture( const key = await uploadProfilePicture(
session.user.id, session.user.id,
new Uint8Array(arrayBuffer), new Uint8Array(arrayBuffer),
avatarFile.type, avatarFile.type
avatarFile.size
); );
updates.image = key; updates.image = key;
} catch (e) { } catch (e) {