diff --git a/backend/package.json b/backend/package.json index 4f2c7a6..a320402 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "express-fileupload": "^1.2.1", "find-remove": "^2.0.3", "fs": "^0.0.1-security", + "sharp": "^0.34.4", "shortid": "^2.2.16", "sqlite3": "^5.1.7" }, diff --git a/backend/src/services/ImagePreviewService.js b/backend/src/services/ImagePreviewService.js new file mode 100644 index 0000000..7b00230 --- /dev/null +++ b/backend/src/services/ImagePreviewService.js @@ -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>} + */ + 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} + */ + 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();