|
|
@@ -268,8 +268,8 @@ const configSchema = {
|
|
|
watermarkFont: {
|
|
|
type: 'string',
|
|
|
title: 'Font',
|
|
|
- default: 'Arial',
|
|
|
- description: 'Font name for text watermark',
|
|
|
+ default: 'DejaVu-Sans',
|
|
|
+ description: 'Font for the watermark text. ImageMagick spells names with hyphens - DejaVu-Sans resolves, "DejaVu Sans" does not - or give a full path to a .ttf. The old default was Arial, which Linux does not have, so every text watermark failed with "unable to read font". Leaving it empty is not a fallback: there is no default font configured, and an empty name fails the same way',
|
|
|
showWhen: { field: 'operation', value: 'watermark' }
|
|
|
},
|
|
|
watermarkFontSize: {
|
|
|
@@ -802,227 +802,28 @@ function cleanupTempFile(tempPath) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function parseImageMagickInfo(output) {
|
|
|
- var info = {};
|
|
|
+function executeInfoOperation(imagePath, includeExif) {
|
|
|
+ smartbotic.log.info('Reading image metadata: ' + imagePath);
|
|
|
|
|
|
- var lines = output.split('\n');
|
|
|
- for (var i = 0; i < lines.length; i++) {
|
|
|
- var line = lines[i].trim();
|
|
|
- if (!line) continue;
|
|
|
-
|
|
|
- var colonIdx = line.indexOf(':');
|
|
|
- if (colonIdx > 0) {
|
|
|
- var key = line.substring(0, colonIdx).trim().toLowerCase();
|
|
|
- var value = line.substring(colonIdx + 1).trim();
|
|
|
-
|
|
|
- if (key === 'image' || key === 'format') {
|
|
|
- var formatMatch = value.match(/^(\w+)/);
|
|
|
- if (formatMatch) info.format = formatMatch[1].toUpperCase();
|
|
|
- }
|
|
|
- else if (key === 'geometry' || key === 'page geometry') {
|
|
|
- var geomMatch = value.match(/(\d+)x(\d+)/);
|
|
|
- if (geomMatch) {
|
|
|
- info.width = parseInt(geomMatch[1], 10);
|
|
|
- info.height = parseInt(geomMatch[2], 10);
|
|
|
- }
|
|
|
- }
|
|
|
- else if (key === 'colorspace') {
|
|
|
- info.colorSpace = value;
|
|
|
- }
|
|
|
- else if (key === 'depth') {
|
|
|
- var depthMatch = value.match(/(\d+)/);
|
|
|
- if (depthMatch) info.depth = parseInt(depthMatch[1], 10);
|
|
|
- }
|
|
|
- else if (key === 'channel depth' || key === 'channels') {
|
|
|
- if (value.toLowerCase().includes('alpha')) {
|
|
|
- info.hasAlpha = true;
|
|
|
- }
|
|
|
- }
|
|
|
- else if (key === 'alpha') {
|
|
|
- info.hasAlpha = value.toLowerCase() !== 'undefined' && value.toLowerCase() !== 'off';
|
|
|
- }
|
|
|
- else if (key === 'compression') {
|
|
|
- info.compression = value;
|
|
|
- }
|
|
|
- else if (key === 'resolution' || key === 'units') {
|
|
|
- if (!info.dpi) info.dpi = {};
|
|
|
- var resMatch = value.match(/([\d.]+)x([\d.]+)/);
|
|
|
- if (resMatch) {
|
|
|
- info.dpi.x = parseFloat(resMatch[1]);
|
|
|
- info.dpi.y = parseFloat(resMatch[2]);
|
|
|
- }
|
|
|
- if (value.includes('PixelsPerInch')) info.dpi.unit = 'dpi';
|
|
|
- else if (value.includes('PixelsPerCentimeter')) info.dpi.unit = 'dpcm';
|
|
|
- }
|
|
|
- else if (key === 'filesize') {
|
|
|
- var sizeMatch = value.match(/([\d.]+)\s*(\w+)/);
|
|
|
- if (sizeMatch) {
|
|
|
- var size = parseFloat(sizeMatch[1]);
|
|
|
- var unit = sizeMatch[2].toUpperCase();
|
|
|
- if (unit === 'B') info.fileSize = Math.round(size);
|
|
|
- else if (unit === 'KB' || unit === 'KIB') info.fileSize = Math.round(size * 1024);
|
|
|
- else if (unit === 'MB' || unit === 'MIB') info.fileSize = Math.round(size * 1024 * 1024);
|
|
|
- else if (unit === 'GB' || unit === 'GIB') info.fileSize = Math.round(size * 1024 * 1024 * 1024);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ var probe = smartbotic.image.info(imagePath, includeExif === true);
|
|
|
+ if (!probe.success) {
|
|
|
+ throw new Error(probe.error);
|
|
|
}
|
|
|
|
|
|
- if (info.hasAlpha === undefined) info.hasAlpha = false;
|
|
|
-
|
|
|
- return info;
|
|
|
+ // Returned as the library reports it. This used to be scraped out of
|
|
|
+ // `identify -verbose` and `exiftool` with regular expressions, so a change
|
|
|
+ // in either tool's phrasing produced a wrong answer rather than an error -
|
|
|
+ // and an absent EXIF block was indistinguishable from one that failed to
|
|
|
+ // parse. The exif key is now simply missing when the file carries none.
|
|
|
+ return probe.info;
|
|
|
}
|
|
|
|
|
|
-function parseExifToolOutput(output) {
|
|
|
- var exif = {
|
|
|
- camera: {},
|
|
|
- datetime: {},
|
|
|
- gps: {},
|
|
|
- exposure: {},
|
|
|
- lens: {}
|
|
|
- };
|
|
|
-
|
|
|
- var lines = output.split('\n');
|
|
|
- for (var i = 0; i < lines.length; i++) {
|
|
|
- var line = lines[i].trim();
|
|
|
- if (!line) continue;
|
|
|
-
|
|
|
- var colonIdx = line.indexOf(':');
|
|
|
- if (colonIdx <= 0) continue;
|
|
|
-
|
|
|
- var key = line.substring(0, colonIdx).trim().toLowerCase().replace(/\s+/g, '');
|
|
|
- var value = line.substring(colonIdx + 1).trim();
|
|
|
-
|
|
|
- if (!value || value === '-' || value === 'n/a') continue;
|
|
|
-
|
|
|
- if (key === 'make' || key === 'cameramake') exif.camera.make = value;
|
|
|
- else if (key === 'model' || key === 'cameramodel' || key === 'cameramodelname') exif.camera.model = value;
|
|
|
- else if (key === 'software') exif.camera.software = value;
|
|
|
-
|
|
|
- else if (key === 'datetimeoriginal' || key === 'createdate') exif.datetime.original = value;
|
|
|
- else if (key === 'datetimedigitized' || key === 'digitizeddate') exif.datetime.digitized = value;
|
|
|
- else if (key === 'modifydate' || key === 'datetime') exif.datetime.modified = value;
|
|
|
-
|
|
|
- else if (key === 'gpslatitude') {
|
|
|
- var latMatch = value.match(/([\d.]+)/);
|
|
|
- if (latMatch) {
|
|
|
- exif.gps.latitude = parseFloat(latMatch[1]);
|
|
|
- if (value.includes('S')) exif.gps.latitude = -exif.gps.latitude;
|
|
|
- exif.gps.latitudeRef = value.includes('S') ? 'S' : 'N';
|
|
|
- }
|
|
|
- }
|
|
|
- else if (key === 'gpslongitude') {
|
|
|
- var lonMatch = value.match(/([\d.]+)/);
|
|
|
- if (lonMatch) {
|
|
|
- exif.gps.longitude = parseFloat(lonMatch[1]);
|
|
|
- if (value.includes('W')) exif.gps.longitude = -exif.gps.longitude;
|
|
|
- exif.gps.longitudeRef = value.includes('W') ? 'W' : 'E';
|
|
|
- }
|
|
|
- }
|
|
|
- else if (key === 'gpsaltitude') {
|
|
|
- var altMatch = value.match(/([\d.]+)/);
|
|
|
- if (altMatch) exif.gps.altitude = parseFloat(altMatch[1]);
|
|
|
- }
|
|
|
-
|
|
|
- else if (key === 'exposuretime' || key === 'shutterspeed') exif.exposure.time = value;
|
|
|
- else if (key === 'fnumber' || key === 'aperture') {
|
|
|
- var fMatch = value.match(/([\d.]+)/);
|
|
|
- if (fMatch) exif.exposure.fNumber = parseFloat(fMatch[1]);
|
|
|
- }
|
|
|
- else if (key === 'iso' || key === 'isospeedratings') {
|
|
|
- var isoMatch = value.match(/(\d+)/);
|
|
|
- if (isoMatch) exif.exposure.iso = parseInt(isoMatch[1], 10);
|
|
|
- }
|
|
|
- else if (key === 'exposureprogram') exif.exposure.program = value;
|
|
|
- else if (key === 'exposurecompensation' || key === 'exposurebias') exif.exposure.bias = value;
|
|
|
-
|
|
|
- else if (key === 'focallength') exif.lens.focalLength = value;
|
|
|
- else if (key === 'focallengthin35mmformat' || key === 'focallength35efl') {
|
|
|
- var fl35Match = value.match(/(\d+)/);
|
|
|
- if (fl35Match) exif.lens.focalLength35mm = parseInt(fl35Match[1], 10);
|
|
|
- }
|
|
|
- else if (key === 'maxaperturevalue') {
|
|
|
- var apMatch = value.match(/([\d.]+)/);
|
|
|
- if (apMatch) exif.lens.aperture = parseFloat(apMatch[1]);
|
|
|
- }
|
|
|
- else if (key === 'lensmake') exif.lens.make = value;
|
|
|
- else if (key === 'lensmodel' || key === 'lens') exif.lens.model = value;
|
|
|
-
|
|
|
- else if (key === 'orientation') {
|
|
|
- var oriMatch = value.match(/(\d)/);
|
|
|
- if (oriMatch) exif.orientation = parseInt(oriMatch[1], 10);
|
|
|
- }
|
|
|
- else if (key === 'flash') exif.flash = value;
|
|
|
- else if (key === 'whitebalance') exif.whiteBalance = value;
|
|
|
- else if (key === 'meteringmode') exif.meteringMode = value;
|
|
|
- }
|
|
|
-
|
|
|
- var hasData = false;
|
|
|
- for (var section in exif) {
|
|
|
- if (typeof exif[section] === 'object') {
|
|
|
- for (var field in exif[section]) {
|
|
|
- if (exif[section][field] !== undefined) {
|
|
|
- hasData = true;
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
- } else if (exif[section] !== undefined) {
|
|
|
- hasData = true;
|
|
|
- }
|
|
|
- if (hasData) break;
|
|
|
+function getImageDimensions(imagePath) {
|
|
|
+ var probe = smartbotic.image.info(imagePath, false);
|
|
|
+ if (!probe.success) {
|
|
|
+ throw new Error('Failed to read the image: ' + probe.error);
|
|
|
}
|
|
|
-
|
|
|
- return hasData ? exif : null;
|
|
|
-}
|
|
|
-
|
|
|
-function executeInfoOperation(imagePath, includeExif, timeout) {
|
|
|
- smartbotic.log.info('Running ImageMagick identify on: ' + imagePath);
|
|
|
-
|
|
|
- var identifyCmd = 'identify -verbose "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
- var identifyResult = smartbotic.process.exec(identifyCmd, { timeout: timeout });
|
|
|
-
|
|
|
- if (!identifyResult.success) {
|
|
|
- throw new Error('ImageMagick identify failed: ' + (identifyResult.stderr || 'Unknown error'));
|
|
|
- }
|
|
|
-
|
|
|
- var info = parseImageMagickInfo(identifyResult.stdout);
|
|
|
-
|
|
|
- var stat = smartbotic.fs.stat(imagePath);
|
|
|
- if (stat.success && !info.fileSize) {
|
|
|
- info.fileSize = stat.size;
|
|
|
- }
|
|
|
-
|
|
|
- if (includeExif) {
|
|
|
- smartbotic.log.info('Extracting EXIF metadata');
|
|
|
- var exiftoolCmd = 'exiftool "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
- var exifResult = smartbotic.process.exec(exiftoolCmd, { timeout: timeout });
|
|
|
-
|
|
|
- if (exifResult.success && exifResult.stdout) {
|
|
|
- var exifData = parseExifToolOutput(exifResult.stdout);
|
|
|
- if (exifData) {
|
|
|
- info.exif = exifData;
|
|
|
- }
|
|
|
- } else {
|
|
|
- smartbotic.log.debug('No EXIF data available or exiftool not installed');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- return info;
|
|
|
-}
|
|
|
-
|
|
|
-function getImageDimensions(imagePath, timeout) {
|
|
|
- var cmd = 'identify -format "%w %h" "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
- var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
-
|
|
|
- if (!result.success) {
|
|
|
- throw new Error('Failed to get image dimensions: ' + (result.stderr || 'Unknown error'));
|
|
|
- }
|
|
|
-
|
|
|
- var parts = result.stdout.trim().split(' ');
|
|
|
- return {
|
|
|
- width: parseInt(parts[0], 10),
|
|
|
- height: parseInt(parts[1], 10)
|
|
|
- };
|
|
|
+ return { width: probe.info.width, height: probe.info.height };
|
|
|
}
|
|
|
|
|
|
function getOutputExtension(format, originalPath) {
|
|
|
@@ -1047,6 +848,29 @@ function getMimeType(format) {
|
|
|
return mimeTypes[format] || 'image/jpeg';
|
|
|
}
|
|
|
|
|
|
+// Every transforming operation ends the same way: apply, write, report what
|
|
|
+// came out. Quality is passed as a number rather than assembled into command
|
|
|
+// flags - Magick++ takes it directly, and the old buildQualityArgs had to know
|
|
|
+// that PNG's quality means something different from JPEG's.
|
|
|
+function runTransform(imagePath, outputPath, operations, outputFormat, config) {
|
|
|
+ var quality = config.outputQuality || 85;
|
|
|
+ var written = smartbotic.image.transform(imagePath, outputPath, operations, {
|
|
|
+ format: outputFormat,
|
|
|
+ quality: quality
|
|
|
+ });
|
|
|
+ if (!written.success) {
|
|
|
+ throw new Error(written.error);
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ outputPath: outputPath,
|
|
|
+ width: written.result.width,
|
|
|
+ height: written.result.height,
|
|
|
+ format: written.result.format,
|
|
|
+ fileSize: written.result.fileSize
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
function buildOutputPath(format) {
|
|
|
var uuid = smartbotic.utils.uuid();
|
|
|
return '/tmp/smartbotic-image-out-' + uuid + '.' + format;
|
|
|
@@ -1075,19 +899,8 @@ function cleanupOutputFile(outputPath) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function buildQualityArgs(format, quality) {
|
|
|
- if (format === 'jpeg' || format === 'webp') {
|
|
|
- return ' -quality ' + quality;
|
|
|
- }
|
|
|
- if (format === 'png') {
|
|
|
- var pngQuality = Math.round((100 - quality) / 10);
|
|
|
- return ' -quality ' + (pngQuality * 10);
|
|
|
- }
|
|
|
- return '';
|
|
|
-}
|
|
|
-
|
|
|
-function executeResizeOperation(imagePath, config, timeout) {
|
|
|
- var dimensions = getImageDimensions(imagePath, timeout);
|
|
|
+function executeResizeOperation(imagePath, config) {
|
|
|
+ var dimensions = getImageDimensions(imagePath);
|
|
|
var originalWidth = dimensions.width;
|
|
|
var originalHeight = dimensions.height;
|
|
|
|
|
|
@@ -1118,54 +931,25 @@ function executeResizeOperation(imagePath, config, timeout) {
|
|
|
targetWidth = targetWidth || originalWidth;
|
|
|
targetHeight = targetHeight || originalHeight;
|
|
|
}
|
|
|
- smartbotic.log.info('Resizing to ' + targetWidth + 'x' + targetHeight + ' (aspect ratio ' + (maintainAspectRatio ? 'maintained' : 'ignored') + ')');
|
|
|
+ smartbotic.log.info('Resizing to ' + targetWidth + 'x' + targetHeight +
|
|
|
+ ' (aspect ratio ' + (maintainAspectRatio ? 'maintained' : 'ignored') + ')');
|
|
|
}
|
|
|
|
|
|
var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
|
|
|
- var quality = config.outputQuality || 85;
|
|
|
var outputPath = buildOutputPath(outputFormat);
|
|
|
|
|
|
- var resizeSpec = targetWidth + 'x' + targetHeight;
|
|
|
- if (!maintainAspectRatio && resizeMode === 'dimensions') {
|
|
|
- resizeSpec += '!';
|
|
|
- }
|
|
|
-
|
|
|
- var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
- cmd += ' -resize ' + resizeSpec;
|
|
|
- cmd += buildQualityArgs(outputFormat, quality);
|
|
|
- cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
|
|
|
-
|
|
|
- smartbotic.log.info('Executing: ' + cmd);
|
|
|
- var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
-
|
|
|
- if (!result.success) {
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
- throw new Error('Resize operation failed: ' + (result.stderr || 'Unknown error'));
|
|
|
- }
|
|
|
-
|
|
|
- var base64Data = readOutputFile(outputPath);
|
|
|
- var fileSize = getOutputFileSize(outputPath);
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
-
|
|
|
- return {
|
|
|
- operation: 'resize',
|
|
|
- originalWidth: originalWidth,
|
|
|
- originalHeight: originalHeight,
|
|
|
+ return runTransform(imagePath, outputPath, [{
|
|
|
+ op: 'resize',
|
|
|
width: targetWidth,
|
|
|
height: targetHeight,
|
|
|
- format: outputFormat.toUpperCase(),
|
|
|
- file: {
|
|
|
- type: 'binary',
|
|
|
- data: base64Data,
|
|
|
- mimeType: getMimeType(outputFormat),
|
|
|
- filename: 'resized.' + outputFormat,
|
|
|
- size: fileSize
|
|
|
- }
|
|
|
- };
|
|
|
+ // Only the explicit-dimensions path can be told to distort; a
|
|
|
+ // percentage resize is aspect-preserving by definition.
|
|
|
+ ignoreAspect: !maintainAspectRatio && resizeMode === 'dimensions'
|
|
|
+ }], outputFormat, config);
|
|
|
}
|
|
|
|
|
|
-function executeCropOperation(imagePath, config, timeout) {
|
|
|
- var dimensions = getImageDimensions(imagePath, timeout);
|
|
|
+function executeCropOperation(imagePath, config) {
|
|
|
+ var dimensions = getImageDimensions(imagePath);
|
|
|
var originalWidth = dimensions.width;
|
|
|
var originalHeight = dimensions.height;
|
|
|
|
|
|
@@ -1177,253 +961,68 @@ function executeCropOperation(imagePath, config, timeout) {
|
|
|
if (!cropWidth || !cropHeight) {
|
|
|
throw new Error('Crop operation requires both width and height');
|
|
|
}
|
|
|
-
|
|
|
if (cropX < 0 || cropY < 0) {
|
|
|
throw new Error('Crop offset cannot be negative');
|
|
|
}
|
|
|
-
|
|
|
if (cropX + cropWidth > originalWidth || cropY + cropHeight > originalHeight) {
|
|
|
- throw new Error('Crop region exceeds image bounds. Image is ' + originalWidth + 'x' + originalHeight + ', crop would extend to ' + (cropX + cropWidth) + 'x' + (cropY + cropHeight));
|
|
|
+ throw new Error('Crop region exceeds image bounds. Image is ' +
|
|
|
+ originalWidth + 'x' + originalHeight + ', crop would extend to ' +
|
|
|
+ (cropX + cropWidth) + 'x' + (cropY + cropHeight));
|
|
|
}
|
|
|
|
|
|
- smartbotic.log.info('Cropping ' + cropWidth + 'x' + cropHeight + ' from offset ' + cropX + ',' + cropY);
|
|
|
-
|
|
|
var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
|
|
|
- var quality = config.outputQuality || 85;
|
|
|
var outputPath = buildOutputPath(outputFormat);
|
|
|
|
|
|
- var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
- cmd += ' -crop ' + cropWidth + 'x' + cropHeight + '+' + cropX + '+' + cropY;
|
|
|
- cmd += ' +repage';
|
|
|
- cmd += buildQualityArgs(outputFormat, quality);
|
|
|
- cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
|
|
|
-
|
|
|
- smartbotic.log.info('Executing: ' + cmd);
|
|
|
- var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
-
|
|
|
- if (!result.success) {
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
- throw new Error('Crop operation failed: ' + (result.stderr || 'Unknown error'));
|
|
|
- }
|
|
|
-
|
|
|
- var base64Data = readOutputFile(outputPath);
|
|
|
- var fileSize = getOutputFileSize(outputPath);
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
-
|
|
|
- return {
|
|
|
- operation: 'crop',
|
|
|
- originalWidth: originalWidth,
|
|
|
- originalHeight: originalHeight,
|
|
|
- width: cropWidth,
|
|
|
- height: cropHeight,
|
|
|
- cropRegion: {
|
|
|
- x: cropX,
|
|
|
- y: cropY,
|
|
|
- width: cropWidth,
|
|
|
- height: cropHeight
|
|
|
- },
|
|
|
- format: outputFormat.toUpperCase(),
|
|
|
- file: {
|
|
|
- type: 'binary',
|
|
|
- data: base64Data,
|
|
|
- mimeType: getMimeType(outputFormat),
|
|
|
- filename: 'cropped.' + outputFormat,
|
|
|
- size: fileSize
|
|
|
- }
|
|
|
- };
|
|
|
+ return runTransform(imagePath, outputPath, [
|
|
|
+ { op: 'crop', x: cropX, y: cropY, width: cropWidth, height: cropHeight }
|
|
|
+ ], outputFormat, config);
|
|
|
}
|
|
|
|
|
|
-function executeRotateOperation(imagePath, config, timeout) {
|
|
|
- var dimensions = getImageDimensions(imagePath, timeout);
|
|
|
- var originalWidth = dimensions.width;
|
|
|
- var originalHeight = dimensions.height;
|
|
|
-
|
|
|
+function executeRotateOperation(imagePath, config) {
|
|
|
var rotatePreset = config.rotatePreset || 'custom';
|
|
|
- var angle;
|
|
|
-
|
|
|
- if (rotatePreset === 'custom') {
|
|
|
- angle = config.rotateAngle;
|
|
|
- if (angle === undefined || angle === null) {
|
|
|
- angle = 90;
|
|
|
- }
|
|
|
- } else {
|
|
|
- angle = parseInt(rotatePreset, 10);
|
|
|
+ var angle = rotatePreset === 'custom' ? config.rotateAngle : Number(rotatePreset);
|
|
|
+ if (angle === undefined || angle === null || isNaN(Number(angle))) {
|
|
|
+ throw new Error('Rotate operation requires an angle');
|
|
|
}
|
|
|
|
|
|
- angle = angle % 360;
|
|
|
- if (angle < 0) {
|
|
|
- angle += 360;
|
|
|
- }
|
|
|
-
|
|
|
- var backgroundColor = config.backgroundColor || 'transparent';
|
|
|
- smartbotic.log.info('Rotating ' + angle + ' degrees with background: ' + backgroundColor);
|
|
|
-
|
|
|
var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
|
|
|
- var quality = config.outputQuality || 85;
|
|
|
var outputPath = buildOutputPath(outputFormat);
|
|
|
|
|
|
- var supportsTransparency = (outputFormat === 'png' || outputFormat === 'gif' || outputFormat === 'webp');
|
|
|
-
|
|
|
- var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
-
|
|
|
- if (backgroundColor === 'transparent' && supportsTransparency) {
|
|
|
- cmd += ' -background none';
|
|
|
- } else if (backgroundColor === 'transparent') {
|
|
|
- cmd += ' -background white';
|
|
|
- } else {
|
|
|
- cmd += ' -background "' + backgroundColor.replace(/"/g, '\\"') + '"';
|
|
|
- }
|
|
|
-
|
|
|
- cmd += ' -rotate ' + angle;
|
|
|
- cmd += buildQualityArgs(outputFormat, quality);
|
|
|
- cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
|
|
|
-
|
|
|
- smartbotic.log.info('Executing: ' + cmd);
|
|
|
- var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
-
|
|
|
- if (!result.success) {
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
- throw new Error('Rotate operation failed: ' + (result.stderr || 'Unknown error'));
|
|
|
- }
|
|
|
-
|
|
|
- var newDimensions = getImageDimensions(outputPath, timeout);
|
|
|
- var base64Data = readOutputFile(outputPath);
|
|
|
- var fileSize = getOutputFileSize(outputPath);
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
-
|
|
|
- return {
|
|
|
- operation: 'rotate',
|
|
|
- originalWidth: originalWidth,
|
|
|
- originalHeight: originalHeight,
|
|
|
- width: newDimensions.width,
|
|
|
- height: newDimensions.height,
|
|
|
- angle: angle,
|
|
|
- backgroundColor: backgroundColor,
|
|
|
- format: outputFormat.toUpperCase(),
|
|
|
- file: {
|
|
|
- type: 'binary',
|
|
|
- data: base64Data,
|
|
|
- mimeType: getMimeType(outputFormat),
|
|
|
- filename: 'rotated.' + outputFormat,
|
|
|
- size: fileSize
|
|
|
- }
|
|
|
- };
|
|
|
+ return runTransform(imagePath, outputPath, [
|
|
|
+ { op: 'rotate', angle: Number(angle), background: config.backgroundColor || 'transparent' }
|
|
|
+ ], outputFormat, config);
|
|
|
}
|
|
|
|
|
|
-function executeFilterOperation(imagePath, config, input, timeout) {
|
|
|
- var dimensions = getImageDimensions(imagePath, timeout);
|
|
|
- var originalWidth = dimensions.width;
|
|
|
- var originalHeight = dimensions.height;
|
|
|
-
|
|
|
- var filtersApplied = [];
|
|
|
- var filterSettings = {};
|
|
|
- var filterArgs = '';
|
|
|
+function executeFilterOperation(imagePath, config) {
|
|
|
+ var operations = [];
|
|
|
|
|
|
if (config.filterBlur) {
|
|
|
- var blurRadius = config.filterBlurRadius || 5;
|
|
|
- filterArgs += ' -blur 0x' + blurRadius;
|
|
|
- filtersApplied.push('blur');
|
|
|
- filterSettings.blur = { enabled: true, radius: blurRadius };
|
|
|
- smartbotic.log.info('Applying blur with radius: ' + blurRadius);
|
|
|
+ operations.push({ op: 'blur', radius: config.filterBlurRadius || 5 });
|
|
|
}
|
|
|
-
|
|
|
if (config.filterSharpen) {
|
|
|
- var sharpenAmount = config.filterSharpenAmount || 1;
|
|
|
- filterArgs += ' -sharpen 0x' + sharpenAmount;
|
|
|
- filtersApplied.push('sharpen');
|
|
|
- filterSettings.sharpen = { enabled: true, amount: sharpenAmount };
|
|
|
- smartbotic.log.info('Applying sharpen with amount: ' + sharpenAmount);
|
|
|
+ operations.push({ op: 'sharpen', amount: config.filterSharpenAmount || 1 });
|
|
|
}
|
|
|
-
|
|
|
if (config.filterGrayscale) {
|
|
|
- filterArgs += ' -colorspace Gray';
|
|
|
- filtersApplied.push('grayscale');
|
|
|
- filterSettings.grayscale = true;
|
|
|
- smartbotic.log.info('Applying grayscale conversion');
|
|
|
+ operations.push({ op: 'grayscale' });
|
|
|
}
|
|
|
-
|
|
|
if (config.filterSepia) {
|
|
|
- var sepiaIntensity = config.filterSepiaIntensity || 80;
|
|
|
- filterArgs += ' -sepia-tone ' + sepiaIntensity + '%';
|
|
|
- filtersApplied.push('sepia');
|
|
|
- filterSettings.sepia = { enabled: true, intensity: sepiaIntensity };
|
|
|
- smartbotic.log.info('Applying sepia with intensity: ' + sepiaIntensity + '%');
|
|
|
+ operations.push({ op: 'sepia', intensity: config.filterSepiaIntensity || 80 });
|
|
|
}
|
|
|
-
|
|
|
if (config.filterBrightness) {
|
|
|
- var brightnessValue = config.filterBrightnessValue || 0;
|
|
|
- var brightnessPct = 100 + brightnessValue;
|
|
|
- filterArgs += ' -modulate ' + brightnessPct + ',100,100';
|
|
|
- filtersApplied.push('brightness');
|
|
|
- filterSettings.brightness = { enabled: true, value: brightnessValue };
|
|
|
- smartbotic.log.info('Adjusting brightness by: ' + brightnessValue);
|
|
|
+ operations.push({ op: 'brightness', value: config.filterBrightnessValue || 0 });
|
|
|
}
|
|
|
-
|
|
|
if (config.filterContrast) {
|
|
|
- var contrastValue = config.filterContrastValue || 0;
|
|
|
- if (contrastValue > 0) {
|
|
|
- for (var i = 0; i < Math.min(contrastValue, 10); i++) {
|
|
|
- filterArgs += ' -contrast';
|
|
|
- }
|
|
|
- if (contrastValue > 10) {
|
|
|
- filterArgs += ' -sigmoidal-contrast ' + (contrastValue / 10) + ',50%';
|
|
|
- }
|
|
|
- } else if (contrastValue < 0) {
|
|
|
- var absContrast = Math.abs(contrastValue);
|
|
|
- for (var j = 0; j < Math.min(absContrast, 10); j++) {
|
|
|
- filterArgs += ' +contrast';
|
|
|
- }
|
|
|
- if (absContrast > 10) {
|
|
|
- filterArgs += ' +sigmoidal-contrast ' + (absContrast / 10) + ',50%';
|
|
|
- }
|
|
|
- }
|
|
|
- filtersApplied.push('contrast');
|
|
|
- filterSettings.contrast = { enabled: true, value: contrastValue };
|
|
|
- smartbotic.log.info('Adjusting contrast by: ' + contrastValue);
|
|
|
+ operations.push({ op: 'contrast', value: config.filterContrastValue || 0 });
|
|
|
}
|
|
|
|
|
|
- if (filtersApplied.length === 0) {
|
|
|
+ if (operations.length === 0) {
|
|
|
throw new Error('Filter operation requires at least one filter to be enabled');
|
|
|
}
|
|
|
|
|
|
var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
|
|
|
- var quality = config.outputQuality || 85;
|
|
|
var outputPath = buildOutputPath(outputFormat);
|
|
|
|
|
|
- var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
- cmd += filterArgs;
|
|
|
- cmd += buildQualityArgs(outputFormat, quality);
|
|
|
- cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
|
|
|
-
|
|
|
- smartbotic.log.info('Executing: ' + cmd);
|
|
|
- var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
-
|
|
|
- if (!result.success) {
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
- throw new Error('Filter operation failed: ' + (result.stderr || 'Unknown error'));
|
|
|
- }
|
|
|
-
|
|
|
- var base64Data = readOutputFile(outputPath);
|
|
|
- var fileSize = getOutputFileSize(outputPath);
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
-
|
|
|
- return {
|
|
|
- operation: 'filter',
|
|
|
- originalWidth: originalWidth,
|
|
|
- originalHeight: originalHeight,
|
|
|
- width: originalWidth,
|
|
|
- height: originalHeight,
|
|
|
- filtersApplied: filtersApplied,
|
|
|
- filterSettings: filterSettings,
|
|
|
- format: outputFormat.toUpperCase(),
|
|
|
- file: {
|
|
|
- type: 'binary',
|
|
|
- data: base64Data,
|
|
|
- mimeType: getMimeType(outputFormat),
|
|
|
- filename: 'filtered.' + outputFormat,
|
|
|
- size: fileSize
|
|
|
- }
|
|
|
- };
|
|
|
+ return runTransform(imagePath, outputPath, operations, outputFormat, config);
|
|
|
}
|
|
|
|
|
|
function prepareWatermarkImage(config, input, timeout) {
|
|
|
@@ -1494,126 +1093,77 @@ function prepareWatermarkImage(config, input, timeout) {
|
|
|
}
|
|
|
|
|
|
function executeWatermarkOperation(imagePath, config, input, timeout) {
|
|
|
- var dimensions = getImageDimensions(imagePath, timeout);
|
|
|
+ var dimensions = getImageDimensions(imagePath);
|
|
|
var originalWidth = dimensions.width;
|
|
|
- var originalHeight = dimensions.height;
|
|
|
|
|
|
var watermarkType = config.watermarkType || 'text';
|
|
|
var position = config.watermarkPosition || 'center';
|
|
|
var opacity = config.watermarkOpacity !== undefined ? config.watermarkOpacity : 50;
|
|
|
var margin = config.watermarkMargin || 10;
|
|
|
|
|
|
- var gravityMap = {
|
|
|
- center: 'Center',
|
|
|
- northwest: 'NorthWest',
|
|
|
- north: 'North',
|
|
|
- northeast: 'NorthEast',
|
|
|
- west: 'West',
|
|
|
- east: 'East',
|
|
|
- southwest: 'SouthWest',
|
|
|
- south: 'South',
|
|
|
- southeast: 'SouthEast'
|
|
|
- };
|
|
|
- var gravity = gravityMap[position] || 'Center';
|
|
|
-
|
|
|
var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
|
|
|
- var quality = config.outputQuality || 85;
|
|
|
var outputPath = buildOutputPath(outputFormat);
|
|
|
|
|
|
- var watermarkInfo = {
|
|
|
- type: watermarkType,
|
|
|
- position: position,
|
|
|
- opacity: opacity
|
|
|
- };
|
|
|
-
|
|
|
+ var watermarkInfo = { type: watermarkType, position: position, opacity: opacity };
|
|
|
var watermarkImageInfo = null;
|
|
|
- var cmd;
|
|
|
+ var operation;
|
|
|
|
|
|
if (watermarkType === 'text') {
|
|
|
var text = interpolate(config.watermarkText, input);
|
|
|
if (!text) {
|
|
|
throw new Error('Watermark text is required');
|
|
|
}
|
|
|
- var font = config.watermarkFont || 'Arial';
|
|
|
- var fontSize = config.watermarkFontSize || 48;
|
|
|
- var color = config.watermarkColor || 'white';
|
|
|
-
|
|
|
watermarkInfo.text = text;
|
|
|
|
|
|
- var escapedText = text.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
|
|
|
-
|
|
|
- cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
- cmd += ' -gravity ' + gravity;
|
|
|
- cmd += ' -font "' + font.replace(/"/g, '\\"') + '"';
|
|
|
- cmd += ' -pointsize ' + fontSize;
|
|
|
- cmd += ' -fill "' + color.replace(/"/g, '\\"') + '"';
|
|
|
- cmd += ' -stroke none';
|
|
|
- cmd += ' -annotate +' + margin + '+' + margin + ' "' + escapedText + '"';
|
|
|
-
|
|
|
- if (opacity < 100) {
|
|
|
- cmd += ' -channel A -evaluate multiply ' + (opacity / 100);
|
|
|
- }
|
|
|
-
|
|
|
- cmd += buildQualityArgs(outputFormat, quality);
|
|
|
- cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
|
|
|
-
|
|
|
- smartbotic.log.info('Applying text watermark: "' + text + '" at ' + position + ' with opacity ' + opacity + '%');
|
|
|
+ // The text goes across as a value, not spliced into a command line.
|
|
|
+ // It used to be escaped by hand for quotes, dollars and backticks -
|
|
|
+ // which is the kind of thing that is wrong for exactly the input
|
|
|
+ // nobody tested, and a watermark is user-supplied text.
|
|
|
+ operation = {
|
|
|
+ op: 'annotate',
|
|
|
+ text: text,
|
|
|
+ gravity: position,
|
|
|
+ font: config.watermarkFont || '',
|
|
|
+ pointSize: config.watermarkFontSize || 48,
|
|
|
+ fill: config.watermarkColor || 'white',
|
|
|
+ opacity: opacity,
|
|
|
+ margin: margin
|
|
|
+ };
|
|
|
+ smartbotic.log.info('Applying text watermark: "' + text + '" at ' + position +
|
|
|
+ ' with opacity ' + opacity + '%');
|
|
|
} else {
|
|
|
watermarkImageInfo = prepareWatermarkImage(config, input, timeout);
|
|
|
- var watermarkPath = watermarkImageInfo.path;
|
|
|
- watermarkInfo.imagePath = watermarkPath;
|
|
|
+ watermarkInfo.imagePath = watermarkImageInfo.path;
|
|
|
|
|
|
var scale = config.watermarkScale || 20;
|
|
|
var watermarkWidth = Math.round(originalWidth * scale / 100);
|
|
|
|
|
|
- smartbotic.log.info('Applying image watermark at ' + position + ' with opacity ' + opacity + '% and scale ' + scale + '%');
|
|
|
-
|
|
|
- cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
- cmd += ' \\( "' + watermarkPath.replace(/"/g, '\\"') + '"';
|
|
|
- cmd += ' -resize ' + watermarkWidth + 'x';
|
|
|
- if (opacity < 100) {
|
|
|
- cmd += ' -alpha set -channel A -evaluate multiply ' + (opacity / 100) + ' +channel';
|
|
|
+ // The overlay is scaled into its own file first, because composite
|
|
|
+ // takes it as it finds it.
|
|
|
+ var scaledPath = buildOutputPath(outputFormat);
|
|
|
+ var scaled = smartbotic.image.transform(watermarkImageInfo.path, scaledPath,
|
|
|
+ [{ op: 'resize', width: watermarkWidth, height: watermarkWidth }], {});
|
|
|
+ if (!scaled.success) {
|
|
|
+ throw new Error('Could not scale the watermark image: ' + scaled.error);
|
|
|
}
|
|
|
- cmd += ' \\)';
|
|
|
- cmd += ' -gravity ' + gravity;
|
|
|
- cmd += ' -geometry +' + margin + '+' + margin;
|
|
|
- cmd += ' -composite';
|
|
|
- cmd += buildQualityArgs(outputFormat, quality);
|
|
|
- cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
|
|
|
- }
|
|
|
-
|
|
|
- smartbotic.log.info('Executing: ' + cmd);
|
|
|
- var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
|
|
|
- if (watermarkImageInfo && watermarkImageInfo.tempPath) {
|
|
|
- cleanupTempFile(watermarkImageInfo.tempPath);
|
|
|
+ operation = {
|
|
|
+ op: 'composite',
|
|
|
+ imagePath: scaledPath,
|
|
|
+ gravity: position,
|
|
|
+ opacity: opacity,
|
|
|
+ margin: margin
|
|
|
+ };
|
|
|
+ smartbotic.log.info('Applying image watermark at ' + position + ' with opacity ' +
|
|
|
+ opacity + '% and scale ' + scale + '%');
|
|
|
}
|
|
|
|
|
|
- if (!result.success) {
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
- throw new Error('Watermark operation failed: ' + (result.stderr || 'Unknown error'));
|
|
|
+ var result = runTransform(imagePath, outputPath, [operation], outputFormat, config);
|
|
|
+ result.watermark = watermarkInfo;
|
|
|
+ if (watermarkImageInfo && watermarkImageInfo.cleanup) {
|
|
|
+ smartbotic.fs.unlink(watermarkImageInfo.path);
|
|
|
}
|
|
|
-
|
|
|
- var base64Data = readOutputFile(outputPath);
|
|
|
- var fileSize = getOutputFileSize(outputPath);
|
|
|
- cleanupOutputFile(outputPath);
|
|
|
-
|
|
|
- return {
|
|
|
- operation: 'watermark',
|
|
|
- originalWidth: originalWidth,
|
|
|
- originalHeight: originalHeight,
|
|
|
- width: originalWidth,
|
|
|
- height: originalHeight,
|
|
|
- watermark: watermarkInfo,
|
|
|
- format: outputFormat.toUpperCase(),
|
|
|
- file: {
|
|
|
- type: 'binary',
|
|
|
- data: base64Data,
|
|
|
- mimeType: getMimeType(outputFormat),
|
|
|
- filename: 'watermarked.' + outputFormat,
|
|
|
- size: fileSize
|
|
|
- }
|
|
|
- };
|
|
|
+ return result;
|
|
|
}
|
|
|
|
|
|
async function execute(config, input) {
|
|
|
@@ -1641,15 +1191,15 @@ async function execute(config, input) {
|
|
|
var result;
|
|
|
|
|
|
if (operation === 'info') {
|
|
|
- result = executeInfoOperation(fileInfo.path, config.includeExif !== false, timeout);
|
|
|
+ result = executeInfoOperation(fileInfo.path, config.includeExif !== false);
|
|
|
} else if (operation === 'resize') {
|
|
|
- result = executeResizeOperation(fileInfo.path, config, timeout);
|
|
|
+ result = executeResizeOperation(fileInfo.path, config);
|
|
|
} else if (operation === 'crop') {
|
|
|
- result = executeCropOperation(fileInfo.path, config, timeout);
|
|
|
+ result = executeCropOperation(fileInfo.path, config);
|
|
|
} else if (operation === 'rotate') {
|
|
|
- result = executeRotateOperation(fileInfo.path, config, timeout);
|
|
|
+ result = executeRotateOperation(fileInfo.path, config);
|
|
|
} else if (operation === 'filter') {
|
|
|
- result = executeFilterOperation(fileInfo.path, config, input, timeout);
|
|
|
+ result = executeFilterOperation(fileInfo.path, config);
|
|
|
} else if (operation === 'watermark') {
|
|
|
result = executeWatermarkOperation(fileInfo.path, config, input, timeout);
|
|
|
}
|