diff --git a/server/src/lib/image-resize.js b/server/src/lib/image-resize.js new file mode 100644 index 0000000..e0b5d08 --- /dev/null +++ b/server/src/lib/image-resize.js @@ -0,0 +1,66 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads') +const CACHE_DIR = path.join(UPLOADS_DIR, '.cache') +const VALID_WIDTHS = [320, 640, 1024, 1600] +const SUPPORTED_FORMATS = new Set(['avif', 'webp']) + +/** + * Find the original file by UUID (without extension) in the uploads directory tree. + * Searches both /uploads/ and /uploads/reviews/. + * Returns full path or null. + */ +export async function findOriginalFile(uuid, subdir = '') { + const searchDirs = subdir ? [subdir] : ['', 'reviews'] + for (const dir of searchDirs) { + for (const ext of ['.png', '.jpg', '.jpeg', '.webp']) { + const fullPath = path.join(UPLOADS_DIR, dir, `${uuid}${ext}`) + try { + await fs.promises.access(fullPath) + return fullPath + } catch { + // file not found with this extension, try next + } + } + } + return null +} + +/** + * Get or generate a resized image. Returns { path: string, isNew: boolean }. + */ +export async function getOrCreateResized(uuid, width, format, subdir = '') { + const cacheSubdir = subdir ? subdir : '' + const cacheFileName = `${uuid}_w${width}.${format}` + const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName) + + try { + await fs.promises.access(cachePath) + return { path: cachePath, isNew: false } + } catch { + // cache miss, need to generate + } + + const originalPath = await findOriginalFile(uuid, subdir) + if (!originalPath) { + return null + } + + await fs.promises.mkdir(path.dirname(cachePath), { recursive: true }) + + const sharp = (await import('sharp')).default + let pipeline = sharp(originalPath) + + if (width) { + pipeline = pipeline.resize(width, null, { withoutEnlargement: true }) + } + + const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } + await pipeline[format](options).toFile(cachePath) + + return { path: cachePath, isNew: true } +} + +export { VALID_WIDTHS, SUPPORTED_FORMATS }