Files
vendoo/lib/image-editor.mjs

306 lines
15 KiB
JavaScript

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' }[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(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"><style>.wm{font-family:Arial,sans-serif;font-size:${fontSize}px;font-weight:700;paint-order:stroke;stroke:rgba(0,0,0,.42);stroke-width:${Math.max(2, fontSize * 0.08)}px;stroke-linejoin:round;fill:#fff;fill-opacity:${opacity};stroke-opacity:${opacity}}</style><text x="${x}" y="${y}" text-anchor="${anchor}" dominant-baseline="${baseline}" class="wm">${text}</text></svg>`);
}
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,
};
}