49c77785e7
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Failing after 1m50s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 3s
215 lines
6.9 KiB
JavaScript
215 lines
6.9 KiB
JavaScript
const sharp = require('sharp');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const logger = require('../utils/logger');
|
|
const { db } = require('../database/db');
|
|
|
|
// Configure sharp for better memory management with large batches
|
|
sharp.cache(false); // Disable cache to prevent memory buildup
|
|
sharp.concurrency(2); // Limit concurrent operations
|
|
|
|
// Default thumbnail settings
|
|
const DEFAULT_THUMBNAIL_WIDTH = 300;
|
|
const DEFAULT_THUMBNAIL_HEIGHT = 300;
|
|
const DEFAULT_THUMBNAIL_FIT = 'cover'; // 'cover' for square crops
|
|
const DEFAULT_THUMBNAIL_QUALITY = 85;
|
|
const DEFAULT_THUMBNAIL_FORMAT = 'jpeg';
|
|
|
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
|
|
|
// Get thumbnail settings from database
|
|
async function getThumbnailSettings() {
|
|
try {
|
|
const settings = await db('app_settings')
|
|
.whereIn('setting_key', [
|
|
'thumbnail_width',
|
|
'thumbnail_height',
|
|
'thumbnail_fit',
|
|
'thumbnail_quality',
|
|
'thumbnail_format'
|
|
])
|
|
.select('setting_key', 'setting_value');
|
|
|
|
const settingsMap = {};
|
|
settings.forEach(s => {
|
|
settingsMap[s.setting_key] = s.setting_value;
|
|
});
|
|
|
|
return {
|
|
width: parseInt(settingsMap.thumbnail_width) || DEFAULT_THUMBNAIL_WIDTH,
|
|
height: parseInt(settingsMap.thumbnail_height) || DEFAULT_THUMBNAIL_HEIGHT,
|
|
fit: settingsMap.thumbnail_fit || DEFAULT_THUMBNAIL_FIT,
|
|
quality: parseInt(settingsMap.thumbnail_quality) || DEFAULT_THUMBNAIL_QUALITY,
|
|
format: settingsMap.thumbnail_format || DEFAULT_THUMBNAIL_FORMAT
|
|
};
|
|
} catch (error) {
|
|
// If database is not ready or settings don't exist, use defaults
|
|
logger.warn('Could not fetch thumbnail settings, using defaults:', error.message);
|
|
return {
|
|
width: DEFAULT_THUMBNAIL_WIDTH,
|
|
height: DEFAULT_THUMBNAIL_HEIGHT,
|
|
fit: DEFAULT_THUMBNAIL_FIT,
|
|
quality: DEFAULT_THUMBNAIL_QUALITY,
|
|
format: DEFAULT_THUMBNAIL_FORMAT
|
|
};
|
|
}
|
|
}
|
|
|
|
async function generateThumbnail(imagePath, options = {}) {
|
|
const filename = path.basename(imagePath);
|
|
const thumbnailFilename = `thumb_${filename}`;
|
|
const thumbnailDir = getThumbnailPath();
|
|
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
|
|
|
|
// Get thumbnail settings
|
|
const settings = await getThumbnailSettings();
|
|
|
|
// Ensure thumbnail directory exists
|
|
await fs.mkdir(thumbnailDir, { recursive: true });
|
|
|
|
// Check if we need to regenerate (for broken thumbnails)
|
|
if (options.regenerate) {
|
|
try {
|
|
await fs.unlink(thumbnailPath);
|
|
logger.info(`Deleted broken thumbnail: ${thumbnailPath}`);
|
|
} catch (err) {
|
|
// File might not exist, that's okay
|
|
}
|
|
}
|
|
|
|
try {
|
|
// First, verify the source image is complete and valid
|
|
const metadata = await sharp(imagePath).metadata();
|
|
|
|
if (!metadata.width || !metadata.height) {
|
|
throw new Error('Invalid image metadata - file may be incomplete');
|
|
}
|
|
|
|
// Create sharp instance with memory-efficient settings
|
|
let sharpInstance = sharp(imagePath, {
|
|
limitInputPixels: 268402689, // ~16k x 16k max
|
|
sequentialRead: true, // More memory efficient for large images
|
|
failOnError: false // Don't fail on minor issues
|
|
});
|
|
|
|
// Apply resize with configured settings
|
|
// For square thumbnails with 'cover' fit, we crop to center
|
|
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
|
withoutEnlargement: true,
|
|
fit: settings.fit, // 'cover' will crop to fill the exact dimensions
|
|
position: 'center' // Center the crop for better composition
|
|
});
|
|
|
|
// Apply format-specific options
|
|
if (settings.format === 'jpeg') {
|
|
sharpInstance = sharpInstance.jpeg({
|
|
quality: settings.quality,
|
|
progressive: true, // Progressive JPEG for better loading
|
|
mozjpeg: true // Better compression
|
|
});
|
|
} else if (settings.format === 'png') {
|
|
sharpInstance = sharpInstance.png({
|
|
quality: settings.quality,
|
|
compressionLevel: 9,
|
|
progressive: true
|
|
});
|
|
} else if (settings.format === 'webp') {
|
|
sharpInstance = sharpInstance.webp({
|
|
quality: settings.quality,
|
|
effort: 4 // Balance between speed and compression
|
|
});
|
|
}
|
|
|
|
// Save the thumbnail
|
|
await sharpInstance.toFile(thumbnailPath);
|
|
|
|
// Verify the thumbnail was created successfully
|
|
const stats = await fs.stat(thumbnailPath);
|
|
if (stats.size === 0) {
|
|
throw new Error('Generated thumbnail is empty');
|
|
}
|
|
|
|
return path.relative(getStoragePath(), thumbnailPath);
|
|
} catch (error) {
|
|
const msg = (error && error.message) ? error.message : String(error);
|
|
logger.error(`Failed to generate thumbnail for ${filename}: ${msg}`);
|
|
|
|
// Clean up any partially created file
|
|
try {
|
|
await fs.unlink(thumbnailPath);
|
|
} catch (unlinkErr) {
|
|
// Ignore unlink errors
|
|
}
|
|
|
|
// Return null if thumbnail generation fails, don't fail the whole upload
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a thumbnail exists and is valid
|
|
*/
|
|
async function isThumbnailValid(thumbnailPath) {
|
|
try {
|
|
const fullPath = path.join(getStoragePath(), thumbnailPath);
|
|
const stats = await fs.stat(fullPath);
|
|
|
|
// Check if file exists and has content
|
|
if (stats.size === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Try to read metadata to ensure it's a valid image
|
|
await sharp(fullPath).metadata();
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Regenerate thumbnail if it's broken or missing
|
|
*/
|
|
async function ensureThumbnail(photo) {
|
|
const { db } = require('../database/db');
|
|
const { resolvePhotoFilePath } = require('./photoResolver');
|
|
let originalPath;
|
|
try {
|
|
const event = await db('events').where('id', photo.event_id).first();
|
|
originalPath = resolvePhotoFilePath(event, photo);
|
|
logger.info(`Ensuring thumbnail for photo ${photo.id} from source: ${originalPath}`);
|
|
} catch (e) {
|
|
const msg = (e && e.message) ? e.message : String(e);
|
|
logger.error(`Failed to resolve original path for thumbnail (photo ${photo.id}): ${msg}`);
|
|
return null;
|
|
}
|
|
|
|
// Check if thumbnail exists and is valid
|
|
if (photo.thumbnail_path) {
|
|
const isValid = await isThumbnailValid(photo.thumbnail_path);
|
|
if (isValid) {
|
|
return photo.thumbnail_path;
|
|
}
|
|
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
|
|
}
|
|
|
|
// Generate new thumbnail
|
|
const newThumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
|
|
|
|
if (newThumbnailPath) {
|
|
// Update database with new thumbnail path
|
|
const { db } = require('../database/db');
|
|
await db('photos')
|
|
.where({ id: photo.id })
|
|
.update({ thumbnail_path: newThumbnailPath });
|
|
|
|
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
|
|
return newThumbnailPath;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
|