Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)
* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
This commit was merged in pull request #18.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user