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 }) let sharpModule try { sharpModule = (await import('sharp')).default } catch (err) { const msg = `Failed to load sharp image processing library: ${err.message}` throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' }) } let pipeline try { pipeline = sharpModule(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) } catch (err) { const msg = `Failed to resize image ${originalPath} to ${width}w ${format}: ${err.message}` throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' }) } return { path: cachePath, isNew: true } } export { VALID_WIDTHS, SUPPORTED_FORMATS } /** * Generate all resize widths in AVIF + WebP for eager processing. * @param {string} uuid - UUID without extension * @param {string} subdir - Subdirectory (e.g., 'reviews') or empty * @param {string} originalPath - Full path to the original file */ export async function generateAllSizes(uuid, subdir, originalPath) { const cacheSubdir = subdir ? subdir : '' const cacheDir = path.join(CACHE_DIR, cacheSubdir) await fs.promises.mkdir(cacheDir, { recursive: true }) let sharpModule try { sharpModule = (await import('sharp')).default } catch (err) { const msg = `Failed to load sharp image processing library: ${err.message}` throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' }) } const source = sharpModule(originalPath) for (const width of VALID_WIDTHS) { for (const format of SUPPORTED_FORMATS) { const cacheFileName = `${uuid}_w${width}.${format}` const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName) try { const pipeline = source.clone().resize(width, null, { withoutEnlargement: true }) const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } await pipeline[format](options).toFile(cachePath) } catch (err) { const msg = `Failed to generate ${width}w ${format} for ${originalPath}: ${err.message}` throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' }) } } } } /** * Convert original file to WebP and delete the source file. * @param {string} uuid - UUID without extension * @param {string} subdir - Subdirectory (e.g., 'reviews') or empty * @returns {string} New URL path like `/uploads/.webp` */ export async function convertOriginalToWebp(uuid, subdir) { const targetDir = subdir ? path.join(UPLOADS_DIR, subdir) : UPLOADS_DIR const originalPath = await findOriginalFile(uuid, subdir) if (!originalPath) { throw new Error(`Original file not found for UUID: ${uuid}`) } const originalExt = path.extname(originalPath).toLowerCase() const webpPath = path.join(targetDir, `${uuid}.webp`) const sharp = (await import('sharp')).default await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath) if (originalExt !== '.webp') { await fs.promises.unlink(originalPath) } return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp` }