Optimize image uploads to 2k webp
This commit is contained in:
1 parent
16b3ab2bc0
commit
a433927de7
4 files changed
+130
-40
No files matched your search
+28
-10
@@ -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)
|
||||
})
|
||||
})
|
||||
+7
-17
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+54
-5
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user