Stable twin of #1194. generateThumbnail, generateHeroImage and generatePreviewImage went straight from sharp(imagePath) to .resize(), so a photo whose Orientation tag is not 1 — routine for portrait shots on bodies that tag rather than rotate the sensor data — was resized from the raw frame and came out sideways. The same pipelines then call .withMetadata(false), stripping the tag from the output, so nothing downstream could correct it either. The download path already had this right, which is why the same photo looked correct on download and rotated in the gallery. watermarkService had it too, and it is the one a guest actually sees: gallery.js serves photos.watermark_path ahead of the original when branding watermarking is on. Two details there — metadata() is read from a separate unrotated handle, because .rotate() does not change what it reports and every use of those numbers is positioning; and the composite offsets are floored, because getPositionCoordinates returns fractional pixels, sharp rejects them, and applyWatermark catches its own error and silently returns the image unwatermarked. The rotate is unconditional in the thumbnail and hero generators — neither passes `animated: true`, so both already flatten a multi-frame source and guarding there would protect an animation that was being discarded anyway. generatePreviewImage keeps the guard, since it genuinely does preserve animation. photos.width/height were stored from sharp's metadata, which reports pixels as STORED, not displayed. For orientation 5-8 those are swapped, so a portrait photo landed in the database as landscape and masonry sized its tile with the wrong aspect ratio on top of the image being unrotated. A shared orientedDimensions() helper now does the conversion at all eight image ingest sites. The video path is deliberately untouched: its dimensions come from ffprobe, where EXIF orientation does not apply. Existing rows keep their pre-rotation dimensions until reprocessed; the backfill for those is #1199 on main and is not ported here yet. Co-authored-by: Paul Nothaft <[email protected]>
356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
const sharp = require('sharp');
|
|
const { orientedDimensions } = require('./imageProcessor');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const { db } = require('../database/db');
|
|
const { getStorage } = require('./storage');
|
|
const logger = require('../utils/logger');
|
|
|
|
class WatermarkService {
|
|
constructor() {
|
|
this.cache = new Map();
|
|
this.cacheMaxAge = 3600000; // 1 hour in milliseconds
|
|
}
|
|
|
|
/**
|
|
* Get watermark settings from database
|
|
*/
|
|
async getWatermarkSettings() {
|
|
try {
|
|
const settings = await db('app_settings')
|
|
.whereIn('setting_key', [
|
|
'branding_watermark_enabled',
|
|
'branding_watermark_logo_path',
|
|
'branding_watermark_position',
|
|
'branding_watermark_opacity',
|
|
'branding_watermark_size',
|
|
'branding_company_name'
|
|
])
|
|
.select('setting_key', 'setting_value');
|
|
|
|
const settingsObj = {};
|
|
settings.forEach(setting => {
|
|
try {
|
|
settingsObj[setting.setting_key] = JSON.parse(setting.setting_value);
|
|
} catch (e) {
|
|
settingsObj[setting.setting_key] = setting.setting_value;
|
|
}
|
|
});
|
|
|
|
return {
|
|
enabled: settingsObj.branding_watermark_enabled || false,
|
|
logoPath: settingsObj.branding_watermark_logo_path || null,
|
|
position: settingsObj.branding_watermark_position || 'bottom-right',
|
|
opacity: parseInt(settingsObj.branding_watermark_opacity || 50),
|
|
size: parseInt(settingsObj.branding_watermark_size || 15),
|
|
companyName: settingsObj.branding_company_name || 'Photo Gallery'
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error fetching watermark settings:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate position coordinates based on position string
|
|
*/
|
|
getPositionCoordinates(imageWidth, imageHeight, watermarkWidth, watermarkHeight, position) {
|
|
const padding = 20;
|
|
let left, top;
|
|
|
|
switch (position) {
|
|
case 'top-left':
|
|
left = padding;
|
|
top = padding;
|
|
break;
|
|
case 'top-right':
|
|
left = imageWidth - watermarkWidth - padding;
|
|
top = padding;
|
|
break;
|
|
case 'bottom-left':
|
|
left = padding;
|
|
top = imageHeight - watermarkHeight - padding;
|
|
break;
|
|
case 'bottom-right':
|
|
left = imageWidth - watermarkWidth - padding;
|
|
top = imageHeight - watermarkHeight - padding;
|
|
break;
|
|
case 'center':
|
|
left = Math.floor((imageWidth - watermarkWidth) / 2);
|
|
top = Math.floor((imageHeight - watermarkHeight) / 2);
|
|
break;
|
|
default:
|
|
// Default to bottom-right
|
|
left = imageWidth - watermarkWidth - padding;
|
|
top = imageHeight - watermarkHeight - padding;
|
|
}
|
|
|
|
return { left: Math.max(0, left), top: Math.max(0, top) };
|
|
}
|
|
|
|
/**
|
|
* Apply watermark to an image
|
|
*/
|
|
async applyWatermark(imagePath, settings) {
|
|
try {
|
|
if (!settings || !settings.enabled) {
|
|
// Return original image if watermarking is disabled
|
|
return await fs.readFile(imagePath);
|
|
}
|
|
|
|
// Check cache first
|
|
const cacheKey = `${imagePath}_${JSON.stringify(settings)}`;
|
|
const cached = this.cache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) {
|
|
return cached.buffer;
|
|
}
|
|
|
|
// Load the main image
|
|
// .rotate() for the same reason as the other generators (#1185): sharp
|
|
// decodes pixels as stored, so an orientation-tagged photo would be
|
|
// composited and re-encoded sideways — and gallery.js serves
|
|
// watermark_path ahead of the original, so this is exactly what a guest
|
|
// sees on a watermarked gallery.
|
|
const image = sharp(imagePath).rotate();
|
|
|
|
// Deliberately NOT `await image.metadata()`: .rotate() does not change
|
|
// what metadata() reports — a 400x200 source tagged orientation 6 still
|
|
// reads 400x200 there, even though the pipeline now emits 200x400. Every
|
|
// use below is positioning, so it has to be the DISPLAYED size or the
|
|
// mark lands against the wrong axis.
|
|
const rawMetadata = await sharp(imagePath).metadata();
|
|
const oriented = orientedDimensions(rawMetadata);
|
|
const metadata = { ...rawMetadata, width: oriented.width, height: oriented.height };
|
|
|
|
let watermarkBuffer;
|
|
let watermarkMetadata;
|
|
|
|
// Try to use logo watermark first
|
|
if (settings.logoPath) {
|
|
try {
|
|
const watermarkImage = sharp(settings.logoPath);
|
|
watermarkMetadata = await watermarkImage.metadata();
|
|
|
|
// Calculate watermark size based on percentage of main image
|
|
const scaleFactor = settings.size / 100;
|
|
const targetWidth = Math.floor(metadata.width * scaleFactor);
|
|
const targetHeight = Math.floor(watermarkMetadata.height * (targetWidth / watermarkMetadata.width));
|
|
|
|
// Resize watermark and apply opacity
|
|
watermarkBuffer = await watermarkImage
|
|
.resize(targetWidth, targetHeight, { fit: 'inside' })
|
|
.composite([{
|
|
input: Buffer.from([255, 255, 255, Math.floor(255 * (settings.opacity / 100))]),
|
|
raw: {
|
|
width: 1,
|
|
height: 1,
|
|
channels: 4
|
|
},
|
|
tile: true,
|
|
blend: 'dest-in'
|
|
}])
|
|
.toBuffer();
|
|
|
|
watermarkMetadata = { width: targetWidth, height: targetHeight };
|
|
} catch (error) {
|
|
logger.error('Error processing watermark logo:', error);
|
|
watermarkBuffer = null;
|
|
}
|
|
}
|
|
|
|
// If no logo or logo failed, create text watermark
|
|
if (!watermarkBuffer) {
|
|
const fontSize = Math.max(16, Math.floor(metadata.width * 0.03));
|
|
const padding = 10;
|
|
|
|
// Create SVG text watermark
|
|
const svg = `
|
|
<svg width="${settings.companyName.length * fontSize * 0.6 + padding * 2}" height="${fontSize + padding * 2}">
|
|
<rect x="0" y="0" width="100%" height="100%" fill="black" opacity="0.5" rx="5"/>
|
|
<text x="${padding}" y="${fontSize + padding/2}"
|
|
font-family="Arial, sans-serif"
|
|
font-size="${fontSize}"
|
|
fill="white"
|
|
opacity="${settings.opacity / 100}">
|
|
${settings.companyName}
|
|
</text>
|
|
</svg>
|
|
`;
|
|
|
|
watermarkBuffer = Buffer.from(svg);
|
|
watermarkMetadata = {
|
|
width: settings.companyName.length * fontSize * 0.6 + padding * 2,
|
|
height: fontSize + padding * 2
|
|
};
|
|
}
|
|
|
|
// Calculate position
|
|
const position = this.getPositionCoordinates(
|
|
metadata.width,
|
|
metadata.height,
|
|
watermarkMetadata.width,
|
|
watermarkMetadata.height,
|
|
settings.position
|
|
);
|
|
|
|
// Apply watermark with high quality output to preserve original image quality
|
|
// Floored: getPositionCoordinates derives from the SVG's estimated text
|
|
// extent, which is fractional, and sharp rejects a non-integer offset
|
|
// outright — applyWatermark then catches its own error and silently
|
|
// returns the unwatermarked original. Whether it landed on a whole pixel
|
|
// was previously luck.
|
|
let watermarkedImage = image.composite([{
|
|
input: watermarkBuffer,
|
|
top: Math.max(0, Math.floor(position.top)),
|
|
left: Math.max(0, Math.floor(position.left))
|
|
}]);
|
|
|
|
// Preserve original format with high quality settings
|
|
const format = metadata.format || 'jpeg';
|
|
let watermarkedBuffer;
|
|
|
|
if (format === 'png') {
|
|
watermarkedBuffer = await watermarkedImage.png({ quality: 100, compressionLevel: 6 }).toBuffer();
|
|
} else if (format === 'webp') {
|
|
watermarkedBuffer = await watermarkedImage.webp({ quality: 95, lossless: false }).toBuffer();
|
|
} else {
|
|
// Default to JPEG with maximum quality (100) to prevent recompression
|
|
watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer();
|
|
}
|
|
|
|
// Cache the result
|
|
this.cache.set(cacheKey, {
|
|
buffer: watermarkedBuffer,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// Clean old cache entries
|
|
this.cleanCache();
|
|
|
|
return watermarkedBuffer;
|
|
} catch (error) {
|
|
logger.error('Error applying watermark:', error);
|
|
// Return original image on error
|
|
return await fs.readFile(imagePath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean old cache entries
|
|
*/
|
|
cleanCache() {
|
|
const now = Date.now();
|
|
for (const [key, value] of this.cache.entries()) {
|
|
if (now - value.timestamp > this.cacheMaxAge) {
|
|
this.cache.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear entire cache
|
|
*/
|
|
clearCache() {
|
|
this.cache.clear();
|
|
}
|
|
|
|
/**
|
|
* Get the file extension from a filename
|
|
*/
|
|
getFileExtension(filename) {
|
|
const ext = path.extname(filename).toLowerCase();
|
|
// Map common extensions
|
|
if (ext === '.jpeg') return '.jpg';
|
|
return ext || '.jpg';
|
|
}
|
|
|
|
/**
|
|
* Generate watermarked version of a photo and persist it through the
|
|
* storage backend. The source must be a local filesystem path because
|
|
* sharp doesn't take streams; callers in S3 mode should materialize a
|
|
* tmp local copy via imageProcessor.withLocalCopy first.
|
|
*
|
|
* @param {Object} photo - Photo object with id, filename, and path info
|
|
* @param {string} originalPath - Local path to the original image file
|
|
* @param {Object} settings - Watermark settings (optional, will fetch if not provided)
|
|
* @returns {Object} { success, watermarkPath, error }
|
|
*/
|
|
async generateAndSaveWatermark(photo, originalPath, settings = null) {
|
|
try {
|
|
if (!settings) {
|
|
settings = await this.getWatermarkSettings();
|
|
}
|
|
|
|
if (!settings || !settings.enabled) {
|
|
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
|
|
}
|
|
|
|
try {
|
|
await fs.access(originalPath);
|
|
} catch {
|
|
return { success: false, watermarkPath: null, error: 'Original file not found' };
|
|
}
|
|
|
|
const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
|
|
|
|
const ext = this.getFileExtension(photo.filename);
|
|
const outputFilename = `${photo.id}_watermarked${ext}`;
|
|
const relativePath = `watermarks/${outputFilename}`;
|
|
|
|
await getStorage().put(relativePath, watermarkedBuffer, {
|
|
contentType: ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg',
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
watermarkPath: relativePath,
|
|
error: null
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Error generating watermark for photo ${photo.id}:`, error);
|
|
return {
|
|
success: false,
|
|
watermarkPath: null,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a pre-generated watermark file from the storage backend.
|
|
* @param {string} watermarkPath - Relative storage key (e.g. "watermarks/123_watermarked.jpg")
|
|
* @returns {boolean} - True if a delete was attempted (no-op if missing)
|
|
*/
|
|
async deleteWatermarkFile(watermarkPath) {
|
|
if (!watermarkPath) return false;
|
|
|
|
try {
|
|
await getStorage().delete(watermarkPath);
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('Error deleting watermark file:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a hash of current watermark settings for change detection
|
|
* @returns {string} - Hash string of settings
|
|
*/
|
|
async getSettingsHash() {
|
|
const settings = await this.getWatermarkSettings();
|
|
if (!settings) return '';
|
|
|
|
const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
|
|
// Simple hash for change detection (not cryptographic)
|
|
let hash = 0;
|
|
for (let i = 0; i < hashData.length; i++) {
|
|
const char = hashData.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
return hash.toString(16);
|
|
}
|
|
}
|
|
|
|
module.exports = new WatermarkService(); |