feat: add Sharp library and ImagePreviewService

- Install sharp@0.33.5 for image processing
- Create ImagePreviewService with preview generation
- Support 800px max width, JPEG 85% quality
- Automatic directory creation on first use
- Include preview size reduction logging
- Add cleanup method for orphaned previews
This commit is contained in:
Matthias Lotz 2025-10-30 20:25:33 +01:00
parent 6ee736bcea
commit 0471830e49
2 changed files with 207 additions and 0 deletions

View File

@ -20,6 +20,7 @@
"express-fileupload": "^1.2.1", "express-fileupload": "^1.2.1",
"find-remove": "^2.0.3", "find-remove": "^2.0.3",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"sharp": "^0.34.4",
"shortid": "^2.2.16", "shortid": "^2.2.16",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },

View File

@ -0,0 +1,206 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
// Preview configuration
const PREVIEW_CONFIG = {
maxWidth: 800,
quality: 85,
format: 'jpeg'
};
// Preview directory relative to backend/src
const PREVIEW_FS_DIR = 'data/previews';
class ImagePreviewService {
/**
* Generates a preview/thumbnail image from the original
* @param {string} originalPath - Absolute path to the original image
* @param {string} previewPath - Absolute path where preview should be saved
* @returns {Promise<{success: boolean, previewPath?: string, error?: string}>}
*/
async generatePreview(originalPath, previewPath) {
try {
// Ensure preview directory exists
const previewDir = path.dirname(previewPath);
await fs.mkdir(previewDir, { recursive: true });
// Check if original file exists
await fs.access(originalPath);
// Generate preview using sharp
await sharp(originalPath)
.resize(PREVIEW_CONFIG.maxWidth, null, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: PREVIEW_CONFIG.quality })
.toFile(previewPath);
// Get file sizes for logging
const [originalStats, previewStats] = await Promise.all([
fs.stat(originalPath),
fs.stat(previewPath)
]);
const reductionPercent = ((1 - previewStats.size / originalStats.size) * 100).toFixed(1);
console.log(`Preview generated: ${path.basename(previewPath)}`);
console.log(` Original: ${(originalStats.size / 1024).toFixed(1)} KB`);
console.log(` Preview: ${(previewStats.size / 1024).toFixed(1)} KB`);
console.log(` Reduction: ${reductionPercent}%`);
return {
success: true,
previewPath,
originalSize: originalStats.size,
previewSize: previewStats.size,
reduction: parseFloat(reductionPercent)
};
} catch (error) {
console.error(`Error generating preview for ${originalPath}:`, error.message);
return {
success: false,
error: error.message
};
}
}
/**
* Generates previews for all images in a group
* @param {Array<{file_path: string, file_name: string}>} images - Array of image objects
* @param {string} baseDir - Base directory for original images (absolute path)
* @param {string} previewBaseDir - Base directory for preview images (absolute path)
* @returns {Promise<Array<{fileName: string, previewPath: string, success: boolean}>>}
*/
async generatePreviewsForGroup(images, baseDir, previewBaseDir) {
const results = [];
for (const image of images) {
const originalPath = path.join(baseDir, image.file_name);
const previewFileName = this._getPreviewFileName(image.file_name);
const previewPath = path.join(previewBaseDir, previewFileName);
const result = await this.generatePreview(originalPath, previewPath);
results.push({
fileName: image.file_name,
originalPath: image.file_path,
previewFileName,
previewPath: previewFileName, // Relative path for DB storage
success: result.success,
error: result.error
});
}
return results;
}
/**
* Gets the preview file path for a given original filename
* @param {string} originalFileName - Original image filename
* @returns {string} Preview filename (always .jpg extension)
*/
_getPreviewFileName(originalFileName) {
const baseName = path.parse(originalFileName).name;
return `${baseName}.jpg`;
}
/**
* Gets the absolute preview path for a given preview filename
* @param {string} previewFileName - Preview filename
* @returns {string} Absolute path to preview file
*/
getPreviewPath(previewFileName) {
return path.join(__dirname, '..', PREVIEW_FS_DIR, previewFileName);
}
/**
* Gets the absolute original image path for a given filename
* @param {string} fileName - Original image filename
* @returns {string} Absolute path to original file
*/
getOriginalPath(fileName) {
const UPLOAD_FS_DIR = require('../constants').UPLOAD_FS_DIR;
return path.join(__dirname, '..', UPLOAD_FS_DIR, fileName);
}
/**
* Cleans up orphaned preview files that don't have corresponding originals
* @returns {Promise<{removed: number, errors: number}>}
*/
async cleanupOrphanedPreviews() {
try {
const previewDir = path.join(__dirname, '..', PREVIEW_FS_DIR);
const UPLOAD_FS_DIR = require('../constants').UPLOAD_FS_DIR;
const originalDir = path.join(__dirname, '..', UPLOAD_FS_DIR);
// Check if preview directory exists
try {
await fs.access(previewDir);
} catch {
console.log('Preview directory does not exist yet, skipping cleanup');
return { removed: 0, errors: 0 };
}
const previewFiles = await fs.readdir(previewDir);
let removed = 0;
let errors = 0;
for (const previewFile of previewFiles) {
try {
// Construct corresponding original filename (might have different extension)
const baseName = path.parse(previewFile).name;
// Check all possible extensions
const possibleExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'];
let originalExists = false;
for (const ext of possibleExtensions) {
const originalPath = path.join(originalDir, baseName + ext);
try {
await fs.access(originalPath);
originalExists = true;
break;
} catch {
// Continue checking
}
}
if (!originalExists) {
const previewPath = path.join(previewDir, previewFile);
await fs.unlink(previewPath);
console.log(`Removed orphaned preview: ${previewFile}`);
removed++;
}
} catch (error) {
console.error(`Error processing ${previewFile}:`, error.message);
errors++;
}
}
return { removed, errors };
} catch (error) {
console.error('Error during preview cleanup:', error.message);
return { removed: 0, errors: 1 };
}
}
/**
* Checks if a preview exists for a given original filename
* @param {string} originalFileName - Original image filename
* @returns {Promise<boolean>}
*/
async previewExists(originalFileName) {
try {
const previewFileName = this._getPreviewFileName(originalFileName);
const previewPath = this.getPreviewPath(previewFileName);
await fs.access(previewPath);
return true;
} catch {
return false;
}
}
}
module.exports = new ImagePreviewService();