import sharp from 'sharp'; import { randomUUID } from 'crypto'; import { existsSync, mkdirSync, statSync, unlinkSync } from 'fs'; import { dirname, extname, join, resolve, sep } from 'path'; const SUPPORTED_FORMATS = new Set(['jpeg', 'webp', 'png']); const WATERMARK_POSITIONS = new Set(['bottom-right', 'bottom-left', 'top-right', 'top-left', 'center']); const MAX_PIXELS = 60_000_000; const RESIZE_FITS = new Set(['inside', 'contain', 'cover']); function clamp(value, min, max, fallback = min) { const number = Number(value); return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; } function normalizeRelativePath(value) { return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); } export function resolveEditableImage(uploadRoot, relativePath) { const normalized = normalizeRelativePath(relativePath); if (!normalized || normalized.includes('\0')) throw new Error('Bildpfad fehlt.'); const root = resolve(uploadRoot); const absolute = resolve(root, normalized); if (absolute !== root && !absolute.startsWith(root + sep)) throw new Error('Ungültiger Bildpfad.'); if (!existsSync(absolute) || !statSync(absolute).isFile()) throw new Error('Bilddatei wurde nicht gefunden.'); if (!['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif'].includes(extname(absolute).toLowerCase())) { throw new Error('Dieses Bildformat kann nicht bearbeitet werden.'); } return { normalized, absolute, root }; } export function normalizeEditorOperations(input = {}) { const cropInput = input.crop && typeof input.crop === 'object' ? input.crop : null; const crop = cropInput ? { x: clamp(cropInput.x, 0, 1, 0), y: clamp(cropInput.y, 0, 1, 0), width: clamp(cropInput.width, 0.01, 1, 1), height: clamp(cropInput.height, 0.01, 1, 1), } : null; if (crop) { crop.width = Math.min(crop.width, 1 - crop.x); crop.height = Math.min(crop.height, 1 - crop.y); } const resizeInput = input.resize && typeof input.resize === 'object' ? input.resize : {}; const width = Math.round(clamp(resizeInput.width, 0, 12000, 0)); const height = Math.round(clamp(resizeInput.height, 0, 12000, 0)); const resizeFit = RESIZE_FITS.has(String(resizeInput.fit || '').toLowerCase()) ? String(resizeInput.fit).toLowerCase() : 'inside'; const resizeBackground = /^#[0-9a-f]{6}$/i.test(String(resizeInput.background || '')) ? String(resizeInput.background) : '#ffffff'; const rotation = Math.round(clamp(input.rotation, -1080, 1080, 0) / 90) * 90; const watermarkInput = input.watermark && typeof input.watermark === 'object' ? input.watermark : {}; const watermarkText = String(watermarkInput.text || 'Vendoo').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80); const watermarkPosition = WATERMARK_POSITIONS.has(String(watermarkInput.position || '')) ? String(watermarkInput.position) : 'bottom-right'; return { rotation: ((rotation % 360) + 360) % 360, straighten: clamp(input.straighten, -15, 15, 0), flip_h: Boolean(input.flip_h), flip_v: Boolean(input.flip_v), crop, exposure: clamp(input.exposure, -2, 2, 0), brightness: clamp(input.brightness, -100, 100, 0), contrast: clamp(input.contrast, -100, 100, 0), highlights: clamp(input.highlights, -100, 100, 0), shadows: clamp(input.shadows, -100, 100, 0), temperature: clamp(input.temperature, -100, 100, 0), saturation: clamp(input.saturation, -100, 100, 0), sharpness: clamp(input.sharpness, 0, 100, 0), remove_background: Boolean(input.remove_background), background_sensitivity: Math.round(clamp(input.background_sensitivity, 0, 100, 45)), watermark: { enabled: Boolean(watermarkInput.enabled) && Boolean(watermarkText), text: watermarkText || 'Vendoo', position: watermarkPosition, opacity: Math.round(clamp(watermarkInput.opacity, 10, 100, 40)), }, resize: { width, height, fit: resizeFit, background: resizeBackground }, format: SUPPORTED_FORMATS.has(String(input.format || '').toLowerCase()) ? String(input.format).toLowerCase() : 'jpeg', quality: Math.round(clamp(input.quality, 40, 100, 90)), }; } function applyToneAdjustments(data, channels, operations) { const exposure = 2 ** operations.exposure; const brightness = operations.brightness / 100 * 0.22; const contrastValue = Math.max(-0.95, Math.min(0.95, operations.contrast / 100)); const contrast = (1 + contrastValue) / (1 - contrastValue); const highlights = operations.highlights / 100; const shadows = operations.shadows / 100; const temperature = operations.temperature / 100; const saturation = 1 + operations.saturation / 100; for (let index = 0; index < data.length; index += channels) { let red = data[index] / 255; let green = data[index + 1] / 255; let blue = data[index + 2] / 255; red = red * exposure + brightness; green = green * exposure + brightness; blue = blue * exposure + brightness; red = (red - 0.5) * contrast + 0.5; green = (green - 0.5) * contrast + 0.5; blue = (blue - 0.5) * contrast + 0.5; const luminanceBefore = Math.max(0, Math.min(1, red * 0.2126 + green * 0.7152 + blue * 0.0722)); const shadowWeight = (1 - luminanceBefore) ** 2; const highlightWeight = luminanceBefore ** 2; const shadowDelta = shadows >= 0 ? shadows * shadowWeight * 0.36 : shadows * shadowWeight * 0.28; const highlightDelta = highlights >= 0 ? highlights * highlightWeight * 0.28 : highlights * highlightWeight * 0.38; red += shadowDelta + highlightDelta; green += shadowDelta + highlightDelta; blue += shadowDelta + highlightDelta; red += temperature * 0.12; green += temperature * 0.015; blue -= temperature * 0.12; const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722; red = luminance + (red - luminance) * saturation; green = luminance + (green - luminance) * saturation; blue = luminance + (blue - luminance) * saturation; data[index] = Math.round(Math.max(0, Math.min(1, red)) * 255); data[index + 1] = Math.round(Math.max(0, Math.min(1, green)) * 255); data[index + 2] = Math.round(Math.max(0, Math.min(1, blue)) * 255); } return data; } async function applyGeometry(sourcePath, operations) { // Sharp applies only one rotation per pipeline. Keep EXIF orientation, // flipping and the combined user rotation/straightening in separate stages. let rendered = await sharp(sourcePath, { failOn: 'none', limitInputPixels: MAX_PIXELS }) .rotate() .toBuffer({ resolveWithObject: true }); if (operations.flip_h || operations.flip_v) { let flipStage = sharp(rendered.data, { failOn: 'none', limitInputPixels: MAX_PIXELS }); if (operations.flip_v) flipStage = flipStage.flip(); if (operations.flip_h) flipStage = flipStage.flop(); rendered = await flipStage.toBuffer({ resolveWithObject: true }); } const angle = operations.rotation + operations.straighten; if (Math.abs(angle) >= 0.05) { rendered = await sharp(rendered.data, { failOn: 'none', limitInputPixels: MAX_PIXELS }) .rotate(angle, { background: { r: 0, g: 0, b: 0, alpha: 0 } }) .toBuffer({ resolveWithObject: true }); } let current = sharp(rendered.data, { failOn: 'none', limitInputPixels: MAX_PIXELS }); if (operations.crop) { const { width, height } = rendered.info; const left = Math.max(0, Math.min(width - 1, Math.round(operations.crop.x * width))); const top = Math.max(0, Math.min(height - 1, Math.round(operations.crop.y * height))); const cropWidth = Math.max(1, Math.min(width - left, Math.round(operations.crop.width * width))); const cropHeight = Math.max(1, Math.min(height - top, Math.round(operations.crop.height * height))); current = current.extract({ left, top, width: cropWidth, height: cropHeight }); } if (operations.resize.width || operations.resize.height) { const fit = operations.resize.fit || 'inside'; current = current.resize({ width: operations.resize.width || undefined, height: operations.resize.height || undefined, fit, background: operations.resize.background || '#ffffff', withoutEnlargement: fit === 'inside', position: 'centre', kernel: sharp.kernel.lanczos3, }); } return current; } function removeUniformBackground(data, channels, width, height, sensitivity = 45) { if (channels < 4 || !width || !height) return data; const indexes = [0, (width - 1) * channels, ((height - 1) * width) * channels, (width * height - 1) * channels]; const background = indexes.reduce((sum, index) => ({ r: sum.r + data[index], g: sum.g + data[index + 1], b: sum.b + data[index + 2], }), { r: 0, g: 0, b: 0 }); background.r /= indexes.length; background.g /= indexes.length; background.b /= indexes.length; const level = Math.max(0, Math.min(100, Number(sensitivity) || 0)); const solidThreshold = 24 + level * 2.1; const featherWidth = 45 + level * 1.2; const featherEnd = solidThreshold + featherWidth; for (let index = 0; index < data.length; index += channels) { const distance = Math.abs(data[index] - background.r) + Math.abs(data[index + 1] - background.g) + Math.abs(data[index + 2] - background.b); if (distance <= solidThreshold) data[index + 3] = 0; else if (distance < featherEnd) data[index + 3] = Math.round(data[index + 3] * (distance - solidThreshold) / featherWidth); } return data; } function escapeXmlText(value) { return String(value || '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character])); } function createWatermarkSvg(width, height, watermark) { const margin = Math.max(10, Math.round(Math.min(width, height) * 0.025)); const fontSize = Math.max(16, Math.round(width * 0.04)); const position = watermark.position || 'bottom-right'; let x = width - margin; let y = height - margin; let anchor = 'end'; let baseline = 'auto'; if (position.includes('left')) { x = margin; anchor = 'start'; } if (position.includes('top')) { y = margin + fontSize; baseline = 'auto'; } if (position === 'center') { x = width / 2; y = height / 2; anchor = 'middle'; baseline = 'middle'; } const opacity = Math.max(0.1, Math.min(1, Number(watermark.opacity || 40) / 100)); const text = escapeXmlText(watermark.text); return Buffer.from(`${text}`); } export async function renderImageVersion({ uploadRoot, sourcePath, operations: rawOperations, context = 'generator', userId = null, rootPath = null, recordVersion = null, outputDirectory = null, outputFilename = null }) { const source = resolveEditableImage(uploadRoot, sourcePath); const operations = normalizeEditorOperations(rawOperations); const resolvedRootPath = String(rootPath || source.normalized).replace(/\\/g, '/').replace(/^\/+/, '').trim() || source.normalized; let pipeline = await applyGeometry(source.absolute, operations); const raw = await pipeline.ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const adjusted = applyToneAdjustments(raw.data, raw.info.channels, operations); if (operations.remove_background) removeUniformBackground(adjusted, raw.info.channels, raw.info.width, raw.info.height, operations.background_sensitivity); pipeline = sharp(adjusted, { raw: { width: raw.info.width, height: raw.info.height, channels: raw.info.channels }, limitInputPixels: MAX_PIXELS, }); if (operations.sharpness > 0) { const strength = operations.sharpness / 100; pipeline = pipeline.sharpen({ sigma: 0.6 + strength * 1.8, m1: 0.5 + strength * 1.5, m2: 1.5 + strength * 2.5 }); } if (operations.watermark.enabled && operations.watermark.text) { pipeline = pipeline.composite([{ input: createWatermarkSvg(raw.info.width, raw.info.height, operations.watermark), top: 0, left: 0 }]); } const extension = operations.format === 'jpeg' ? '.jpg' : `.${operations.format}`; const sourceFolder = dirname(source.normalized).replace(/\\/g, '/'); const requestedFolder = normalizeRelativePath(outputDirectory || sourceFolder || '.'); const safeFolder = requestedFolder === '.' ? '' : requestedFolder.split('/').filter(part => part && part !== '.' && part !== '..').join('/'); const requestedBase = String(outputFilename || randomUUID()).replace(/\\/g, '-').replace(/[^a-zA-Z0-9äöüÄÖÜß._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120) || randomUUID(); const cleanBase = requestedBase.replace(/\.(jpe?g|png|webp)$/i, ''); let outputName = `${cleanBase}${extension}`; let outputRelative = safeFolder ? `${safeFolder}/${outputName}` : outputName; let outputAbsolute = resolve(uploadRoot, outputRelative); const resolvedUploadRoot = resolve(uploadRoot); if (outputAbsolute === resolvedUploadRoot || !outputAbsolute.startsWith(resolvedUploadRoot + sep)) throw new Error('Ungültiger Ausgabeordner.'); let duplicateIndex = 2; while (existsSync(outputAbsolute)) { outputName = `${cleanBase}-${duplicateIndex}${extension}`; outputRelative = safeFolder ? `${safeFolder}/${outputName}` : outputName; outputAbsolute = resolve(uploadRoot, outputRelative); duplicateIndex += 1; } mkdirSync(dirname(outputAbsolute), { recursive: true }); if (operations.format === 'png') pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }); else if (operations.format === 'webp') pipeline = pipeline.webp({ quality: operations.quality, smartSubsample: true }); else pipeline = pipeline.flatten({ background: '#ffffff' }).jpeg({ quality: operations.quality, mozjpeg: true, chromaSubsampling: '4:4:4' }); await pipeline.toFile(outputAbsolute); const metadata = await sharp(outputAbsolute, { failOn: 'none' }).metadata(); const stats = statSync(outputAbsolute); const versionData = { root_path: resolvedRootPath, source_path: source.normalized, output_path: outputRelative, context: String(context || 'generator').slice(0, 40), operations, width: metadata.width || null, height: metadata.height || null, format: metadata.format || operations.format, quality: operations.quality, file_size: stats.size, user_id: userId, }; let version; try { version = typeof recordVersion === 'function' ? await recordVersion(versionData) : versionData; } catch (error) { try { unlinkSync(outputAbsolute); } catch {} throw error; } return { filename: outputRelative, root_path: resolvedRootPath, version, metadata: { width: metadata.width || null, height: metadata.height || null, format: metadata.format || operations.format, file_size: stats.size, }, operations, }; }