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:
parent
6ee736bcea
commit
0471830e49
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
206
backend/src/services/ImagePreviewService.js
Normal file
206
backend/src/services/ImagePreviewService.js
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user