219 lines
5.3 KiB
JavaScript
219 lines
5.3 KiB
JavaScript
const fs = require('fs/promises')
|
|
const path = require('path')
|
|
const crypto = require('crypto')
|
|
const sharp = require('sharp')
|
|
const { nanoid } = require('nanoid')
|
|
const {
|
|
findImageAssetByHash,
|
|
createImageAsset,
|
|
createImageOptimizationJob,
|
|
updateImageOptimizationJobStatus,
|
|
} = require('../db')
|
|
|
|
const UPLOAD_ROOT = path.join(__dirname, '..', '..', 'uploads')
|
|
const OPTIMIZED_DIR = 'assets'
|
|
const OPTIMIZATION_CONCURRENCY = Math.max(1, Number(process.env.IMAGE_OPTIMIZATION_CONCURRENCY || 1))
|
|
|
|
let activeCount = 0
|
|
const pendingJobs = []
|
|
|
|
function ensureImageMimeType(file) {
|
|
return typeof file?.mimetype === 'string' && file.mimetype.startsWith('image/')
|
|
}
|
|
|
|
function createMemoryUpload(multer, { fileSize = 6 * 1024 * 1024, maxCount } = {}) {
|
|
return multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: {
|
|
fileSize,
|
|
...(typeof maxCount === 'number' ? { files: maxCount } : {}),
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
if (ensureImageMimeType(file)) return cb(null, true)
|
|
cb(new Error('image_file_required'))
|
|
},
|
|
})
|
|
}
|
|
|
|
function scheduleQueue() {
|
|
while (activeCount < OPTIMIZATION_CONCURRENCY && pendingJobs.length) {
|
|
const job = pendingJobs.shift()
|
|
activeCount += 1
|
|
processQueuedJob(job)
|
|
.then(job.resolve)
|
|
.catch(job.reject)
|
|
.finally(() => {
|
|
activeCount = Math.max(0, activeCount - 1)
|
|
scheduleQueue()
|
|
})
|
|
}
|
|
}
|
|
|
|
async function optimizeAndPersist({ file, width, height, fit, quality }) {
|
|
const { data, info } = await sharp(file.buffer, { failOn: 'none' })
|
|
.rotate()
|
|
.resize({
|
|
width,
|
|
height,
|
|
fit,
|
|
withoutEnlargement: true,
|
|
})
|
|
.webp({ quality })
|
|
.toBuffer({ resolveWithObject: true })
|
|
|
|
const contentHash = crypto.createHash('sha256').update(data).digest('hex')
|
|
const existing = await findImageAssetByHash(contentHash)
|
|
if (existing) {
|
|
return {
|
|
src: existing.src,
|
|
size: existing.byteSize,
|
|
originalSize: existing.originalByteSize,
|
|
width: existing.width,
|
|
height: existing.height,
|
|
contentHash: existing.contentHash,
|
|
reused: true,
|
|
}
|
|
}
|
|
|
|
const filename = String(Date.now()) + '-' + nanoid() + '.webp'
|
|
const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR)
|
|
const absolutePath = path.join(absoluteDir, filename)
|
|
const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename
|
|
|
|
await fs.mkdir(absoluteDir, { recursive: true })
|
|
await fs.writeFile(absolutePath, data)
|
|
|
|
try {
|
|
const asset = await createImageAsset({
|
|
id: nanoid(),
|
|
contentHash,
|
|
src,
|
|
mimeType: 'image/webp',
|
|
byteSize: data.length,
|
|
originalByteSize: file.size || file.buffer.length,
|
|
width: info.width || 0,
|
|
height: info.height || 0,
|
|
})
|
|
|
|
return {
|
|
src: asset.src,
|
|
size: asset.byteSize,
|
|
originalSize: asset.originalByteSize,
|
|
width: asset.width,
|
|
height: asset.height,
|
|
contentHash: asset.contentHash,
|
|
reused: false,
|
|
}
|
|
} catch (error) {
|
|
try {
|
|
await fs.unlink(absolutePath)
|
|
} catch (unlinkError) {
|
|
if (unlinkError?.code !== 'ENOENT') throw unlinkError
|
|
}
|
|
|
|
if (error?.code === 'ER_DUP_ENTRY') {
|
|
const asset = await findImageAssetByHash(contentHash)
|
|
if (asset) {
|
|
return {
|
|
src: asset.src,
|
|
size: asset.byteSize,
|
|
originalSize: asset.originalByteSize,
|
|
width: asset.width,
|
|
height: asset.height,
|
|
contentHash: asset.contentHash,
|
|
reused: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function processQueuedJob(job) {
|
|
await updateImageOptimizationJobStatus({
|
|
id: job.jobId,
|
|
status: 'processing',
|
|
startedAt: Date.now(),
|
|
})
|
|
|
|
try {
|
|
const result = await optimizeAndPersist(job)
|
|
await updateImageOptimizationJobStatus({
|
|
id: job.jobId,
|
|
status: 'completed',
|
|
optimizedByteSize: result.size,
|
|
reusedAsset: result.reused,
|
|
finishedAt: Date.now(),
|
|
})
|
|
return result
|
|
} catch (error) {
|
|
await updateImageOptimizationJobStatus({
|
|
id: job.jobId,
|
|
status: 'failed',
|
|
errorMessage: error?.message || 'optimization_failed',
|
|
finishedAt: Date.now(),
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function writeOptimizedImage({
|
|
file,
|
|
directory,
|
|
width,
|
|
height,
|
|
fit = 'inside',
|
|
quality = 82,
|
|
}) {
|
|
if (!file?.buffer?.length) {
|
|
const error = new Error('file_required')
|
|
error.code = 'file_required'
|
|
throw error
|
|
}
|
|
|
|
if (!ensureImageMimeType(file)) {
|
|
const error = new Error('image_file_required')
|
|
error.code = 'image_file_required'
|
|
throw error
|
|
}
|
|
|
|
const jobId = nanoid()
|
|
await createImageOptimizationJob({
|
|
id: jobId,
|
|
sourceCategory: directory,
|
|
targetDirectory: OPTIMIZED_DIR,
|
|
originalByteSize: file.size || file.buffer.length,
|
|
})
|
|
|
|
return new Promise((resolve, reject) => {
|
|
pendingJobs.push({
|
|
jobId,
|
|
file,
|
|
directory,
|
|
width,
|
|
height,
|
|
fit,
|
|
quality,
|
|
resolve: (result) => resolve({ ...result, directory }),
|
|
reject,
|
|
})
|
|
scheduleQueue()
|
|
})
|
|
}
|
|
|
|
function getImageOptimizationQueueState() {
|
|
return {
|
|
concurrency: OPTIMIZATION_CONCURRENCY,
|
|
activeCount,
|
|
pendingCount: pendingJobs.length,
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
createMemoryUpload,
|
|
ensureImageMimeType,
|
|
writeOptimizedImage,
|
|
getImageOptimizationQueueState,
|
|
}
|