From a433927de78328ad59124a243d5e3b140033ae09 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:10:07 +0000 Subject: [PATCH] Optimize image uploads to 2k webp --- src/service/asset.test.ts | 38 ++++++++++++++++++------- src/service/asset.ts | 24 +++++----------- src/service/file.test.ts | 49 ++++++++++++++++++++++++++------ src/service/file.ts | 59 +++++++++++++++++++++++++++++++++++---- 4 files changed, 130 insertions(+), 40 deletions(-) diff --git a/src/service/asset.test.ts b/src/service/asset.test.ts index 10359c3..965ac21 100644 --- a/src/service/asset.test.ts +++ b/src/service/asset.test.ts @@ -7,21 +7,29 @@ const mockPrisma = { }, } +mock.module('ulidx', () => ({ + ulid: () => 'mocked-ulid', +})) + mock.module('../db', () => ({ prisma: mockPrisma, })) -const mockBunWrite = mock((_path: string, content: unknown) => - Promise.resolve((content as Blob | string).toString().length), -) -// @ts-ignore -Bun.write = mockBunWrite +const mockFileService = { + saveFile: mock(() => Promise.resolve({ path: 'data/files/mock.webp', filename: 'mock.webp', mimeType: 'image/webp' })), + saveBuffer: mock(() => Promise.resolve({ path: 'data/files/mock.webp', filename: 'mock.webp', mimeType: 'image/webp' })), +} + +mock.module('./file', () => ({ + fileService: mockFileService, +})) describe('AssetService', () => { afterEach(() => { mockPrisma.asset.create.mockClear() mockPrisma.asset.findUnique.mockClear() - mockBunWrite.mockClear() + mockFileService.saveFile.mockClear() + mockFileService.saveBuffer.mockClear() }) it('createAsset should save file and create db record', async () => { @@ -31,18 +39,28 @@ describe('AssetService', () => { const meta = { name: 'Test Asset', type: 'image/png' } mockPrisma.asset.create.mockResolvedValue({ - id: '01HRKV...', + id: 'mocked-ulid', name: meta.name, - type: meta.type, - path: 'data/files/01HRKV....png', + type: 'image/webp', + path: 'mock.webp', createdAt: new Date(), updatedAt: new Date(), }) const result = await assetService.createAsset(file, meta) - expect(mockBunWrite).toHaveBeenCalled() + expect(mockFileService.saveFile).toHaveBeenCalled() expect(mockPrisma.asset.create).toHaveBeenCalled() + + // Check call arguments + // Use expect.objectContaining for partial match + const createCall = mockPrisma.asset.create.mock.calls[0][0] + expect(createCall.data).toEqual(expect.objectContaining({ + type: 'image/webp', + path: 'mock.webp', + id: 'mocked-ulid' + })) + expect(result.name).toBe(meta.name) }) }) diff --git a/src/service/asset.ts b/src/service/asset.ts index e3a3f63..378be40 100644 --- a/src/service/asset.ts +++ b/src/service/asset.ts @@ -1,24 +1,18 @@ import { ulid } from 'ulidx' import { prisma } from '../db' -import { join, extname } from 'path' -// import { writeFile } from '../lib/fileSystem' // Using Bun.write +import { fileService } from './file' export class AssetService { async createAsset(file: File, meta: { name: string; type: string }) { + const saved = await fileService.saveFile(file, meta.name) const id = ulid() - const ext = extname(file.name) || '.bin' - const filename = `${id}${ext}` - const path = join('data/files', filename) - - // await writeFile(path, file) - await Bun.write(path, await file.arrayBuffer()) const asset = await prisma.asset.create({ data: { id, name: meta.name, - type: meta.type, - path: filename, // Save only filename or relative path + type: saved.mimeType, + path: saved.filename, }, }) @@ -29,19 +23,15 @@ export class AssetService { buffer: Buffer | ArrayBuffer, meta: { name: string; type: string; ext?: string }, ) { + const saved = await fileService.saveBuffer(buffer, meta.name, meta.type) const id = ulid() - const ext = meta.ext || '.bin' - const filename = `${id}${ext}` - const path = join('data/files', filename) - - await Bun.write(path, buffer) const asset = await prisma.asset.create({ data: { id, name: meta.name, - type: meta.type, - path: filename, + type: saved.mimeType, + path: saved.filename, }, }) diff --git a/src/service/file.test.ts b/src/service/file.test.ts index 7779d19..df34d5d 100644 --- a/src/service/file.test.ts +++ b/src/service/file.test.ts @@ -1,14 +1,20 @@ import { describe, expect, test, mock, beforeAll } from 'bun:test' +// Mock ulidx +mock.module('ulidx', () => ({ + ulid: () => 'test-ulid', +})) + // Mock sharp +const sharpChain: any = { + resize: mock(() => sharpChain), + webp: mock(() => sharpChain), + toBuffer: mock(() => Promise.resolve(Buffer.from([1, 2, 3]))), +} + mock.module('sharp', () => { - const sharpInstance = { - webp: () => ({ - toBuffer: () => Promise.resolve(Buffer.from([1, 2, 3])), - }), - } return { - default: () => sharpInstance, + default: () => sharpChain, } }) @@ -38,7 +44,7 @@ describe('FileService', () => { const file = new File([VALID_PNG_BUFFER], 'test.png', { type: 'image/png' }) const result = await fileService.saveFile(file) - expect(result.filename.endsWith('.webp')).toBe(true) + expect(result.filename).toBe('test-ulid.webp') expect(result.mimeType).toBe('image/webp') // Check if write was called expect(mockWrite).toHaveBeenCalled() @@ -46,13 +52,40 @@ describe('FileService', () => { const writeCalls = mockWrite.mock.calls const lastCall = writeCalls[writeCalls.length - 1] expect(lastCall[0]).toContain('data/files') + + // Check if sharp resize was called + expect(sharpChain.resize).toHaveBeenCalled() + expect(sharpChain.resize).toHaveBeenCalledWith({ + width: 2048, + height: 2048, + fit: 'outside', + withoutEnlargement: true, + }) }) test('saveBase64Image saves a base64 string as webp', async () => { // Pass valid base64 const result = await fileService.saveBase64Image(VALID_PNG_BASE64) - expect(result.filename.endsWith('.webp')).toBe(true) + expect(result.filename).toBe('test-ulid.webp') expect(mockWrite).toHaveBeenCalled() + expect(sharpChain.resize).toHaveBeenCalled() + }) + + test('saveBuffer saves a buffer and converts to webp if mimeType is image', async () => { + const result = await fileService.saveBuffer(VALID_PNG_BUFFER, 'test.png', 'image/png') + + expect(result.filename).toBe('test-ulid.webp') + expect(result.mimeType).toBe('image/webp') + expect(mockWrite).toHaveBeenCalled() + expect(sharpChain.resize).toHaveBeenCalled() + }) + + test('saveBuffer saves original if not image', async () => { + const result = await fileService.saveBuffer(VALID_PNG_BUFFER, 'test.bin', 'application/octet-stream') + + expect(result.filename).toBe('test-ulid.bin') + expect(result.mimeType).toBe('application/octet-stream') + expect(mockWrite).toHaveBeenCalled() }) }) diff --git a/src/service/file.ts b/src/service/file.ts index 587c958..96232c6 100644 --- a/src/service/file.ts +++ b/src/service/file.ts @@ -37,10 +37,16 @@ export class FileService { } else { // Blob case buffer = await file.arrayBuffer() - const result = await this.optimizeImage(buffer) - buffer = result.buffer - filename = `${id}.webp` - mimeType = 'image/webp' + if (file.type.startsWith('image/')) { + const result = await this.optimizeImage(buffer) + buffer = result.buffer + filename = `${id}.webp` + mimeType = 'image/webp' + } else { + const ext = name ? name.split('.').pop() : 'bin' + filename = `${id}.${ext}` + mimeType = file.type || 'application/octet-stream' + } } const path = join('data/files', filename) @@ -50,12 +56,55 @@ export class FileService { return { path, filename, mimeType } } + /** + * Saves a buffer directly as a file. Optimizes if it's an image. + * @param buffer The buffer to save. + * @param name Optional name for the file (used for extension detection). + * @param mimeType Optional mimeType. + */ + async saveBuffer( + buffer: Buffer | ArrayBuffer, + name?: string, + mimeType?: string, + ): Promise<{ path: string; filename: string; mimeType: string }> { + const id = ulid() + let finalBuffer: Buffer | ArrayBuffer = buffer + let finalFilename: string + let finalMimeType: string = mimeType || 'application/octet-stream' + + if (finalMimeType.startsWith('image/')) { + try { + const result = await this.optimizeImage(finalBuffer) + finalBuffer = result.buffer + finalFilename = `${id}.webp` + finalMimeType = 'image/webp' + } catch (error) { + console.warn('Failed to optimize image buffer, saving as is.', error) + const ext = name ? name.split('.').pop() : 'bin' + finalFilename = `${id}.${ext}` + } + } else { + const ext = name ? name.split('.').pop() : 'bin' + finalFilename = `${id}.${ext}` + } + + const path = join('data/files', finalFilename) + await Bun.write(path, finalBuffer) + return { path, filename: finalFilename, mimeType: finalMimeType } + } + /** * Optimizes an image buffer by converting it to WebP using sharp. */ private async optimizeImage(buffer: ArrayBuffer | Buffer): Promise<{ buffer: Buffer }> { const optimizedBuffer = await sharp(buffer) - .webp({ quality: 80 }) // Good balance between size and quality + .resize({ + width: 2048, + height: 2048, + fit: 'outside', + withoutEnlargement: true, + }) + .webp({ quality: 85 }) .toBuffer() return { buffer: optimizedBuffer }