diff --git a/server/src/lib/__tests__/image-resize.test.js b/server/src/lib/__tests__/image-resize.test.js index 6e429ef..96d51de 100644 --- a/server/src/lib/__tests__/image-resize.test.js +++ b/server/src/lib/__tests__/image-resize.test.js @@ -97,3 +97,65 @@ describe('image-resize', () => { await fs.promises.unlink(first.path) }) }) + +describe('eager image processing', () => { + it('generateAllSizes creates all width+format combinations', async () => { + const { generateAllSizes } = await import('../image-resize.js') + const sharp = (await import('sharp')).default + const uuid = 'test-eager-uuid-1' + const testImagePath = path.join(UPLOADS_DIR, `${uuid}.png`) + await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } }) + .png() + .toFile(testImagePath) + + await generateAllSizes(uuid, '', testImagePath) + + const cacheDir = path.join(UPLOADS_DIR, '.cache') + for (const width of [320, 640, 1024, 1600]) { + for (const format of ['avif', 'webp']) { + const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`) + const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false) + expect(exists).toBe(true) + } + } + + // Cleanup + await fs.promises.unlink(testImagePath) + for (const width of [320, 640, 1024, 1600]) { + for (const format of ['avif', 'webp']) { + const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`) + try { + await fs.promises.unlink(cachePath) + } catch { + // ignore + } + } + } + }) + + it('convertOriginalToWebp converts and deletes original', async () => { + const { convertOriginalToWebp } = await import('../image-resize.js') + const sharp = (await import('sharp')).default + const uuid = 'test-eager-uuid-2' + const testImagePath = path.join(UPLOADS_DIR, `${uuid}.png`) + await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } }) + .png() + .toFile(testImagePath) + + const result = await convertOriginalToWebp(uuid, '') + + expect(result).toBe(`/uploads/${uuid}.webp`) + const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false) + expect(pngExists).toBe(false) + const webpPath = path.join(UPLOADS_DIR, `${uuid}.webp`) + const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false) + expect(webpExists).toBe(true) + + // Cleanup + try { + await fs.promises.unlink(webpPath) + } catch { + // ignore + } + }) +}) diff --git a/server/src/lib/image-resize.js b/server/src/lib/image-resize.js index e0b5d08..4bd7c0f 100644 --- a/server/src/lib/image-resize.js +++ b/server/src/lib/image-resize.js @@ -64,3 +64,56 @@ export async function getOrCreateResized(uuid, width, format, subdir = '') { } 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 }) + + const sharp = (await import('sharp')).default + + 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) + + const pipeline = sharp(originalPath).resize(width, null, { withoutEnlargement: true }) + const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } + await pipeline[format](options).toFile(cachePath) + } + } +} + +/** + * 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 uploadsDir = path.join(process.cwd(), 'uploads') + const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir + + 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` +}