306 lines
15 KiB
JavaScript
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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,
|
|
};
|
|
}
|