initial: server + shared
This commit is contained in:
Executable
+143
@@ -0,0 +1,143 @@
|
||||
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/<uuid>.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`
|
||||
}
|
||||
Reference in New Issue
Block a user