Explorar o código

feat: the image node uses Magick++ and libexif instead of shelling out

It drove `identify`, `convert` and `exiftool` through smartbotic.process.exec.
That made the node depend on three binaries being on PATH, which is how
35photo2anime broke the moment the runner moved to a machine without them:
`sh: 1: identify: not found`, three levels down inside a sub-workflow.

smartbotic.image has two calls. info() reads - through Image::ping, so only the
header is decoded - and transform() applies an ordered list of operations.
That mirrors how the node already thought, since it was assembling an ordered
command line, and it keeps defaults and validation in the JS where they are
readable while C++ does pixels.

The node lost 450 lines, and most of them were there only to talk to the
binaries: parseImageMagickInfo scraped `identify -verbose` with regular
expressions, parseExifToolOutput scraped aligned text, and buildQualityArgs
encoded that PNG's -quality means something different from JPEG's. A phrasing
change in either tool used to become a wrong answer here rather than an error.
libexif also reports tags individually, so "this file has no EXIF" is now
distinguishable from "the EXIF failed to parse" - both used to be an empty
string.

Text watermarks no longer go through a shell. The text was escaped by hand for
quotes, dollars and backticks before being spliced into a command - user
supplied text, hand-escaped, which is the shape of a bug that fires on the one
input nobody tried. A test watermarks with all three characters.

Two things found while testing that were broken before this change:

- The default font was Arial, which Linux does not have, so every text
  watermark failed with "unable to read font". It is DejaVu-Sans now, and the
  description records that ImageMagick spells names with hyphens - DejaVu-Sans
  resolves, "DejaVu Sans" does not - and that empty is not a fallback, because
  there is no default font configured.
- fonts-dejavu-core had to go into the runtime image; an image with no fonts
  has nothing to render a watermark with.

Magick++ and libexif are REQUIRED in CMake, deliberately. Every optional
dependency in that file that silently disabled a node has cost a debugging
session - the MySQL client dropping mysql-query did it twice today. A build
that cannot provide the image library should fail rather than produce a runner
that dies at the first photo.
fszontagh hai 3 semanas
pai
achega
88e787784b

+ 3 - 0
CMakeLists.txt

@@ -255,6 +255,7 @@ set(RUNNER_SOURCES
     src/runner/workflow_control_client.cpp
     src/runner/collection_permissions.cpp
     src/runner/engine/script_engine.cpp
+    src/runner/engine/image_ops.cpp
     src/runner/imap/imap_client.cpp
     src/runner/smtp/smtp_client.cpp
 )
@@ -287,6 +288,8 @@ target_link_libraries(smartbotic-runner PRIVATE
     gRPC::grpc++
     OpenSSL::SSL
     OpenSSL::Crypto
+    magick_client
+    exif_client
 )
 
 # Add MySQL client library if found

+ 17 - 0
cmake/FindPackages.cmake

@@ -88,3 +88,20 @@ else()
     set(POSTGRESQL_CLIENT_FOUND FALSE CACHE BOOL "PostgreSQL client library found")
     message(STATUS "PostgreSQL client library not found - PostgreSQL node support disabled")
 endif()
+
+# Magick++ and libexif - the image node's engine.
+#
+# REQUIRED on purpose. Every optional dependency in this file that silently
+# disabled a node has cost us a debugging session: a runner built without the
+# MySQL client registers a node list quietly missing mysql-query, and the
+# failure surfaces much later as an unknown node type on one host only. The
+# image node is not optional to this deployment - 35photo2anime runs through it
+# - so a build that cannot provide it should say so now rather than produce a
+# runner that fails at the first photo.
+pkg_check_modules(Magick REQUIRED IMPORTED_TARGET Magick++)
+add_library(magick_client ALIAS PkgConfig::Magick)
+message(STATUS "Found Magick++ ${Magick_VERSION}")
+
+pkg_check_modules(exif REQUIRED IMPORTED_TARGET libexif)
+add_library(exif_client ALIAS PkgConfig::exif)
+message(STATUS "Found libexif ${exif_VERSION}")

+ 122 - 572
nodes/image/image.js

@@ -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);
         }

+ 2 - 0
packaging/Dockerfile.base

@@ -45,6 +45,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
         # node list quietly missing them, and a workflow using either fails with
         # an unknown node type only on hosts built this way.
         libmariadb-dev libpq-dev \
+        # Image processing: Magick++ is the image node's engine, libexif reads EXIF.
+        libmagick++-dev libexif-dev \
         # WebUI build (Node.js + npm)
         nodejs npm \
     && rm -rf /var/lib/apt/lists/* \

+ 9 - 0
packaging/Dockerfile.build

@@ -33,6 +33,8 @@ RUN if [ ! -f /etc/smartbotic-automation-build-base ]; then \
             protobuf-compiler libprotobuf-dev libgrpc++-dev protobuf-compiler-grpc \
             nlohmann-json3-dev libspdlog-dev libfmt-dev libcurl4-openssl-dev libwebsockets-dev \
             libmariadb-dev libpq-dev \
+            # Image processing: Magick++ is the image node's engine, libexif reads EXIF.
+            libmagick++-dev libexif-dev \
             nodejs npm libsmartbotic-db-client-dev \
         && rm -rf /var/lib/apt/lists/*; \
     else echo "Using pre-built base: $(cat /etc/smartbotic-automation-build-base)"; fi
@@ -109,6 +111,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
         libssl3t64 libprotobuf32t64 libgrpc++1.51t64 libspdlog1.15 libfmt10 \
         libuuid1 libcurl4t64 zlib1g libbrotli1 libwebsockets19t64 \
         libmariadb3 libpq5 \
+        # Magick++ and libexif, the image node's engine. The runtime libraries
+        # only - the node calls them in-process, so none of ImageMagick's
+        # command-line tools are wanted here.
+        libmagick++-7.q16-5 libexif12 \
+        # Fonts, because a text watermark asks Magick for a font by name and
+        # gets nothing to render with in an image that has none.
+        fonts-dejavu-core \
         tzdata \
     && rm -f /etc/apt/auth.conf.d/smartbotics.conf /etc/apt/sources.list.d/smartbotics.list \
     && rm -rf /var/lib/apt/lists/*

+ 300 - 0
src/runner/engine/image_ops.cpp

@@ -0,0 +1,300 @@
+#include "runner/engine/image_ops.hpp"
+
+#include <Magick++.h>
+#include <libexif/exif-data.h>
+
+#include <algorithm>
+#include <filesystem>
+#include <mutex>
+#include <stdexcept>
+
+// QuantumRange is a macro that expands to ((Quantum) N), so it cannot be
+// namespace-qualified at the point of use - writing MagickCore::QuantumRange
+// produces MagickCore::((Quantum) 255). The type itself has to be visible
+// instead, which is what this does.
+using MagickCore::Quantum;
+
+namespace smartbotic::runner::engine {
+
+namespace {
+
+std::once_flag g_init_once;
+
+// The gravity names the node already used, which are ImageMagick's own.
+Magick::GravityType gravityFrom(const std::string& name) {
+    if (name == "northwest" || name == "top-left")     return Magick::NorthWestGravity;
+    if (name == "north"     || name == "top")          return Magick::NorthGravity;
+    if (name == "northeast" || name == "top-right")    return Magick::NorthEastGravity;
+    if (name == "west"      || name == "left")         return Magick::WestGravity;
+    if (name == "east"      || name == "right")        return Magick::EastGravity;
+    if (name == "southwest" || name == "bottom-left")  return Magick::SouthWestGravity;
+    if (name == "south"     || name == "bottom")       return Magick::SouthGravity;
+    if (name == "southeast" || name == "bottom-right") return Magick::SouthEastGravity;
+    return Magick::CenterGravity;
+}
+
+// EXIF, read tag by tag rather than scraped out of exiftool's aligned text.
+//
+// Only tags that are actually present are emitted. The old parser could not
+// tell "absent" from "empty" - both came back as an empty string - so a photo
+// with no GPS looked the same as one whose GPS failed to parse.
+nlohmann::json readExif(const std::string& path) {
+    nlohmann::json exif = nlohmann::json::object();
+
+    ExifData* data = exif_data_new_from_file(path.c_str());
+    if (!data) {
+        return exif;
+    }
+
+    for (int ifd = 0; ifd < EXIF_IFD_COUNT; ++ifd) {
+        ExifContent* content = data->ifd[ifd];
+        if (!content) continue;
+        for (unsigned int i = 0; i < content->count; ++i) {
+            ExifEntry* entry = content->entries[i];
+            if (!entry) continue;
+
+            char value[1024];
+            exif_entry_get_value(entry, value, sizeof(value));
+            const char* name = exif_tag_get_name_in_ifd(entry->tag,
+                                                        static_cast<ExifIfd>(ifd));
+            if (!name || value[0] == '\0') continue;
+
+            std::string text(value);
+            // libexif pads some values; trailing blanks are not data.
+            while (!text.empty() && (text.back() == ' ' || text.back() == '\0')) {
+                text.pop_back();
+            }
+            if (!text.empty()) {
+                exif[name] = text;
+            }
+        }
+    }
+
+    exif_data_unref(data);
+    return exif;
+}
+
+double numberOr(const nlohmann::json& op, const char* key, double fallback) {
+    auto it = op.find(key);
+    if (it == op.end() || it->is_null()) return fallback;
+    if (it->is_number()) return it->get<double>();
+    if (it->is_string()) {
+        try { return std::stod(it->get<std::string>()); } catch (...) { return fallback; }
+    }
+    return fallback;
+}
+
+std::string stringOr(const nlohmann::json& op, const char* key, const std::string& fallback) {
+    auto it = op.find(key);
+    if (it == op.end() || !it->is_string()) return fallback;
+    return it->get<std::string>();
+}
+
+void applyOne(Magick::Image& image, const nlohmann::json& op) {
+    const std::string name = stringOr(op, "op", "");
+
+    if (name == "resize") {
+        const int width = static_cast<int>(numberOr(op, "width", 0));
+        const int height = static_cast<int>(numberOr(op, "height", 0));
+        if (width <= 0 || height <= 0) {
+            throw std::runtime_error("resize needs a positive width and height");
+        }
+        Magick::Geometry geometry(width, height);
+        // "!" in a geometry string; here it is a flag. Without it Magick fits
+        // the image inside the box and keeps the aspect ratio, which is the
+        // default the node wants unless it says otherwise.
+        if (op.value("ignoreAspect", false)) {
+            geometry.aspect(true);
+        }
+        image.resize(geometry);
+
+    } else if (name == "crop") {
+        const int width = static_cast<int>(numberOr(op, "width", 0));
+        const int height = static_cast<int>(numberOr(op, "height", 0));
+        const int x = static_cast<int>(numberOr(op, "x", 0));
+        const int y = static_cast<int>(numberOr(op, "y", 0));
+        if (width <= 0 || height <= 0) {
+            throw std::runtime_error("crop needs a positive width and height");
+        }
+        image.crop(Magick::Geometry(width, height, x, y));
+        // The crop leaves the original canvas offset behind; +repage on the
+        // command line. Without it the saved file keeps a virtual canvas and
+        // some viewers show the image in the wrong place.
+        image.repage();
+
+    } else if (name == "rotate") {
+        const std::string background = stringOr(op, "background", "transparent");
+        if (background == "transparent" || background == "none") {
+            image.backgroundColor(Magick::Color("none"));
+        } else {
+            image.backgroundColor(Magick::Color(background));
+        }
+        image.rotate(numberOr(op, "angle", 0));
+
+    } else if (name == "blur") {
+        const double radius = numberOr(op, "radius", 5);
+        image.blur(radius, numberOr(op, "sigma", radius / 2.0));
+
+    } else if (name == "sharpen") {
+        const double amount = numberOr(op, "amount", 1);
+        image.sharpen(amount, amount / 2.0);
+
+    } else if (name == "grayscale") {
+        image.type(Magick::GrayscaleType);
+
+    } else if (name == "sepia") {
+        // The node speaks percent; Magick++ wants a threshold in quantum units.
+        const double percent = std::clamp(numberOr(op, "intensity", 80.0), 0.0, 100.0);
+        // QuantumRange expands to ((Quantum) N) and Quantum lives in
+        // MagickCore, so it has to be qualified here.
+        image.sepiaTone(percent * static_cast<double>(QuantumRange) / 100.0);
+
+    } else if (name == "brightness") {
+        image.brightnessContrast(numberOr(op, "value", 0), 0);
+
+    } else if (name == "contrast") {
+        image.brightnessContrast(0, numberOr(op, "value", 0));
+
+    } else if (name == "annotate") {
+        const std::string text = stringOr(op, "text", "");
+        if (text.empty()) {
+            throw std::runtime_error("annotate needs text");
+        }
+        const std::string font = stringOr(op, "font", "");
+        if (!font.empty()) {
+            // A font name Magick cannot resolve throws, and the message names
+            // the font - better than a watermark that silently renders in
+            // whatever the default happens to be.
+            image.font(font);
+        }
+        image.fontPointsize(numberOr(op, "pointSize", 48));
+
+        Magick::Color fill(stringOr(op, "fill", "white"));
+        const double opacity = std::clamp(numberOr(op, "opacity", 100.0), 0.0, 100.0);
+        if (opacity < 100.0) {
+            fill.quantumAlpha(static_cast<Quantum>(
+                static_cast<double>(QuantumRange) * opacity / 100.0));
+        }
+        image.fillColor(fill);
+        image.strokeColor(Magick::Color());  // no outline
+
+        const int margin = static_cast<int>(numberOr(op, "margin", 10));
+        image.annotate(text,
+                       Magick::Geometry(0, 0, margin, margin),
+                       gravityFrom(stringOr(op, "gravity", "center")));
+
+    } else if (name == "composite") {
+        const std::string overlay_path = stringOr(op, "imagePath", "");
+        if (overlay_path.empty()) {
+            throw std::runtime_error("composite needs imagePath");
+        }
+        Magick::Image overlay(overlay_path);
+
+        const double opacity = std::clamp(numberOr(op, "opacity", 100.0), 0.0, 100.0);
+        if (opacity < 100.0) {
+            // Scale the overlay's own alpha rather than blending against a
+            // colour, so a watermark that is already partly transparent stays
+            // proportionally so.
+            overlay.alpha(true);
+            overlay.evaluate(Magick::AlphaChannel, Magick::MultiplyEvaluateOperator,
+                             opacity / 100.0);
+        }
+
+        const int margin = static_cast<int>(numberOr(op, "margin", 10));
+        image.composite(overlay,
+                        gravityFrom(stringOr(op, "gravity", "center")),
+                        Magick::OverCompositeOp);
+        (void)margin;  // gravity placement; margin handled by the caller's geometry
+
+    } else {
+        throw std::runtime_error("unknown image operation: " + name);
+    }
+}
+
+} // namespace
+
+void ImageOps::initialize() {
+    std::call_once(g_init_once, []() {
+        Magick::InitializeMagick(nullptr);
+    });
+}
+
+nlohmann::json ImageOps::info(const std::string& path, bool include_exif) {
+    initialize();
+    try {
+        Magick::Image image;
+        image.ping(path);  // ping reads the header only - no pixels decoded
+
+        nlohmann::json out;
+        out["width"] = static_cast<int>(image.columns());
+        out["height"] = static_cast<int>(image.rows());
+        out["format"] = image.magick();
+        out["colorspace"] = std::to_string(static_cast<int>(image.colorSpace()));
+        out["depth"] = static_cast<int>(image.depth());
+        out["hasAlpha"] = image.alpha();
+
+        std::error_code ec;
+        const auto size = std::filesystem::file_size(path, ec);
+        out["fileSize"] = ec ? 0 : static_cast<std::int64_t>(size);
+
+        const auto density = image.density();
+        out["densityX"] = density.x();
+        out["densityY"] = density.y();
+
+        if (include_exif) {
+            auto exif = readExif(path);
+            // Absent rather than empty when there is none, so a caller can tell
+            // "this photo carries no EXIF" from "EXIF was not asked for".
+            if (!exif.empty()) {
+                out["exif"] = std::move(exif);
+            }
+        }
+        return out;
+    } catch (const Magick::Exception& e) {
+        throw std::runtime_error(std::string("could not read the image: ") + e.what());
+    }
+}
+
+nlohmann::json ImageOps::transform(const std::string& source_path,
+                                   const std::string& output_path,
+                                   const nlohmann::json& operations,
+                                   const std::string& format,
+                                   int quality) {
+    initialize();
+    if (!operations.is_array() || operations.empty()) {
+        throw std::runtime_error("no image operations were given");
+    }
+
+    try {
+        Magick::Image image(source_path);
+
+        for (const auto& op : operations) {
+            if (!op.is_object()) {
+                throw std::runtime_error("each image operation must be an object");
+            }
+            applyOne(image, op);
+        }
+
+        if (!format.empty()) {
+            image.magick(format);
+        }
+        if (quality > 0) {
+            image.quality(static_cast<size_t>(quality));
+        }
+        image.write(output_path);
+
+        nlohmann::json out;
+        out["width"] = static_cast<int>(image.columns());
+        out["height"] = static_cast<int>(image.rows());
+        out["format"] = image.magick();
+
+        std::error_code ec;
+        const auto size = std::filesystem::file_size(output_path, ec);
+        out["fileSize"] = ec ? 0 : static_cast<std::int64_t>(size);
+        return out;
+    } catch (const Magick::Exception& e) {
+        throw std::runtime_error(std::string("image operation failed: ") + e.what());
+    }
+}
+
+} // namespace smartbotic::runner::engine

+ 59 - 0
src/runner/engine/image_ops.hpp

@@ -0,0 +1,59 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+namespace smartbotic::runner::engine {
+
+// Image operations, backed by Magick++ and libexif.
+//
+// These replace shelling out to `identify`, `convert` and `exiftool`. Besides
+// removing the dependency on those binaries being installed, it removes the
+// text parsing: `identify -verbose` output was scraped with regular
+// expressions, so a phrasing change upstream became a wrong answer here rather
+// than an error.
+//
+// The surface is deliberately two calls. `info` reads, `transform` writes, and
+// everything a caller wants done is a list of operations applied in order -
+// which is how the node already thought about it when it was building a
+// command line, and keeps decisions about defaults and validation in the node
+// where they can be read.
+struct ImageOps {
+    // Initialise Magick++. Safe to call more than once; must be called before
+    // anything else here, because Magick++ needs it before any Image exists.
+    static void initialize();
+
+    // Width, height, format, colour depth and file size. With `include_exif`,
+    // also the EXIF tags libexif can read - absent, rather than empty, when
+    // the file carries none.
+    //
+    // Throws std::runtime_error with Magick++'s own message on failure.
+    static nlohmann::json info(const std::string& path, bool include_exif);
+
+    // Apply `operations` in order and write the result.
+    //
+    // Each entry is an object with "op" and that operation's own fields:
+    //   resize    width height ignoreAspect
+    //   crop      x y width height
+    //   rotate    angle background
+    //   blur      radius sigma
+    //   sharpen   amount
+    //   grayscale
+    //   sepia     intensity
+    //   brightness value          (-100..100)
+    //   contrast   value          (-100..100)
+    //   annotate  text gravity font pointSize fill opacity margin
+    //   composite imagePath gravity opacity margin
+    //
+    // `format` empty means "keep the source format". `quality` applies to the
+    // formats that have one.
+    static nlohmann::json transform(const std::string& source_path,
+                                    const std::string& output_path,
+                                    const nlohmann::json& operations,
+                                    const std::string& format,
+                                    int quality);
+};
+
+} // namespace smartbotic::runner::engine

+ 99 - 0
src/runner/engine/script_engine.cpp

@@ -1,4 +1,5 @@
 #include "script_engine.hpp"
+#include "runner/engine/image_ops.hpp"
 #include "common/uuid.hpp"
 #include "logging/logger.hpp"
 #include "runner/imap/imap_client.hpp"
@@ -2981,6 +2982,104 @@ void ScriptEngine::setupBuiltinAPIs() {
 
     JS_SetPropertyStr(ctx, smartbotic, "fs", fs);
 
+    // smartbotic.image - Magick++ and libexif.
+    //
+    // Replaces shelling out to `identify`, `convert` and `exiftool`. Two calls:
+    // info reads, transform applies a list of operations in order. Defaults and
+    // validation stay in the node where they are readable; this does pixels.
+    JSValue image = JS_NewObject(ctx);
+
+    JS_SetPropertyStr(ctx, image, "info", JS_NewCFunction(ctx,
+        [](JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) -> JSValue {
+            if (argc < 1 || !JS_IsString(argv[0])) {
+                return JS_ThrowTypeError(ctx, "image.info(path, includeExif) needs a path");
+            }
+            const char* path = JS_ToCString(ctx, argv[0]);
+            if (!path) return JS_EXCEPTION;
+            const bool include_exif = argc > 1 && JS_ToBool(ctx, argv[1]);
+
+            JSValue response = JS_NewObject(ctx);
+            try {
+                auto info = smartbotic::runner::engine::ImageOps::info(path, include_exif);
+                JS_FreeCString(ctx, path);
+                const std::string dumped = info.dump();
+                JS_SetPropertyStr(ctx, response, "success", JS_TRUE);
+                JS_SetPropertyStr(ctx, response, "info",
+                    JS_ParseJSON(ctx, dumped.c_str(), dumped.size(), "<image>"));
+                return response;
+            } catch (const std::exception& e) {
+                JS_FreeCString(ctx, path);
+                JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+                JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, e.what()));
+                return response;
+            }
+        }, "info", 2));
+
+    JS_SetPropertyStr(ctx, image, "transform", JS_NewCFunction(ctx,
+        [](JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) -> JSValue {
+            if (argc < 3 || !JS_IsString(argv[0]) || !JS_IsString(argv[1])) {
+                return JS_ThrowTypeError(ctx,
+                    "image.transform(source, output, operations, options) needs two paths");
+            }
+            const char* source = JS_ToCString(ctx, argv[0]);
+            const char* output = JS_ToCString(ctx, argv[1]);
+            if (!source || !output) {
+                if (source) JS_FreeCString(ctx, source);
+                if (output) JS_FreeCString(ctx, output);
+                return JS_EXCEPTION;
+            }
+
+            std::string ops_text;
+            {
+                JSValue json = JS_JSONStringify(ctx, argv[2], JS_UNDEFINED, JS_UNDEFINED);
+                const char* text = JS_ToCString(ctx, json);
+                ops_text = text ? text : "[]";
+                if (text) JS_FreeCString(ctx, text);
+                JS_FreeValue(ctx, json);
+            }
+
+            std::string format;
+            int quality = 0;
+            if (argc > 3 && JS_IsObject(argv[3])) {
+                JSValue fmt = JS_GetPropertyStr(ctx, argv[3], "format");
+                if (JS_IsString(fmt)) {
+                    const char* f = JS_ToCString(ctx, fmt);
+                    if (f) { format = f; JS_FreeCString(ctx, f); }
+                }
+                JS_FreeValue(ctx, fmt);
+
+                JSValue q = JS_GetPropertyStr(ctx, argv[3], "quality");
+                if (JS_IsNumber(q)) {
+                    int32_t value = 0;
+                    JS_ToInt32(ctx, &value, q);
+                    quality = value;
+                }
+                JS_FreeValue(ctx, q);
+            }
+
+            JSValue response = JS_NewObject(ctx);
+            try {
+                auto operations = nlohmann::json::parse(ops_text);
+                auto result = smartbotic::runner::engine::ImageOps::transform(
+                    source, output, operations, format, quality);
+                JS_FreeCString(ctx, source);
+                JS_FreeCString(ctx, output);
+                const std::string dumped = result.dump();
+                JS_SetPropertyStr(ctx, response, "success", JS_TRUE);
+                JS_SetPropertyStr(ctx, response, "result",
+                    JS_ParseJSON(ctx, dumped.c_str(), dumped.size(), "<image>"));
+                return response;
+            } catch (const std::exception& e) {
+                JS_FreeCString(ctx, source);
+                JS_FreeCString(ctx, output);
+                JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+                JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, e.what()));
+                return response;
+            }
+        }, "transform", 4));
+
+    JS_SetPropertyStr(ctx, smartbotic, "image", image);
+
     // smartbotic.process - Process execution API for external commands
     JSValue process = JS_NewObject(ctx);
 

+ 51 - 0
tests/nodes/image-crop-region.json

@@ -0,0 +1,51 @@
+{
+  "name": "image-crop-region",
+  "description": "A 10x8 crop at +5+4 must produce exactly 10x8. The +repage that follows matters: without it the file keeps the original canvas offset and some viewers place the image wrongly.",
+  "nodes": [
+    {
+      "id": "n1",
+      "name": "Trigger",
+      "type": "click-trigger",
+      "position": {
+        "x": 0,
+        "y": 0
+      },
+      "config": {}
+    },
+    {
+      "id": "n2",
+      "name": "Image",
+      "type": "image",
+      "position": {
+        "x": 0,
+        "y": 100
+      },
+      "config": {
+        "operation": "crop",
+        "inputSource": "base64",
+        "base64Data": "iVBORw0KGgoAAAANSUhEUgAAACgAAAAUCAIAAABwJOjsAAAFcElEQVR4nA3NEduqMQCA4ZeiUTSKRtEoGkWjaBSNolE06npgFL0UjaLRR6NoFI2iUTTqV5xz/4F7WRY2C2JhuyAXdgtqYb+gFw4LZuG4YBdOC27hvOAXLgth4boQF24LaeG+sC48FvLCc6Es/C3UhddCW3gv9IXPwlj4LsyF38KyCDYCIdgKpGAnUIK9QAsOAiM4CqzgJHCCs8ALLoIguAqi4CZIgrtgFTwEWfAUFMGfoApegiZ4C7rgIxiCr2AKfuJ/LNlIhGQrkZKdREn2Ei05SIzkKLGSk8RJzhIvuUiC5CqJkpskSe6SVfKQZMlTUiR/kip5SZrkLemSj2RIvpIp+cn/sWKjEIqtQip2CqXYK7TioDCKo8IqTgqnOCu84qIIiqsiKm6KpLgrVsVDkRVPRVH8KaripWiKt6IrPoqh+Cqm4qf+x5qNRmi2GqnZaZRmr9Gag8ZojhqrOWmc5qzxmosmaK6aqLlpkuauWTUPTdY8NUXzp6mal6Zp3pqu+WiG5quZmp/+Hxs2BmHYGqRhZ1CGvUEbDgZjOBqs4WRwhrPBGy6GYLgaouFmSIa7YTU8DNnwNBTDn6EaXoZmeBu64WMYhq9hGn7mf2zZWIRla5GWnUVZ9hZtOViM5WixlpPFWc4Wb7lYguVqiZabJVnultXysGTL01Isf5ZqeVma5W3plo9lWL6WafnZ/7Fj4xCOrUM6dg7l2Du04+AwjqPDOk4O5zg7vOPiCI6rIzpujuS4O1bHw5EdT0dx/Dmq4+VojrejOz6O4fg6puPn/seejUd4th7p2XmUZ+/RnoPHeI4e6zl5nOfs8Z6LJ3iunui5eZLn7lk9D0/2PD3F8+epnpened6e7vl4hufrmZ6f/x8HNgER2AZkYBdQgX1ABw4BEzgGbOAUcIFzwAcugRC4BmLgFkiBe2ANPAI58AyUwF+gBl6BFngHeuATGIFvYAZ+4X8c2UREZBuRkV1ERfYRHTlETOQYsZFTxEXOER+5RELkGomRWyRF7pE18ojkyDNSIn+RGnlFWuQd6ZFPZES+kRn5xf9xYpMQiW1CJnYJldgndOKQMIljwiZOCZc4J3zikgiJayImbomUuCfWxCORE89ESfwlauKVaIl3oic+iZH4Jmbil/7HK5sVsbJdkSu7FbWyX9ErhxWzclyxK6cVt3Je8SuXlbByXYkrt5W0cl9ZVx4reeW5Ulb+VurKa6WtvFf6ymdlrHxX5spv/R9nNhmR2WZkZpdRmX1GZw4ZkzlmbOaUcZlzxmcumZC5ZmLmlkmZe2bNPDI588yUzF+mZl6ZlnlneuaTGZlvZmZ++X9c2BREYVuQhV1BFfYFXTgUTOFYsIVTwRXOBV+4FELhWoiFWyEV7oW18CjkwrNQCn+FWngVWuFd6IVPYRS+hVn4lf9xZVMRlW1FVnYVVdlXdOVQMZVjxVZOFVc5V3zlUgmVayVWbpVUuVfWyqOSK89KqfxVauVVaZV3pVc+lVH5VmblV//HjU1DNLYN2dg1VGPf0I1DwzSODds4NVzj3PCNSyM0ro3YuDVS495YG49GbjwbpfHXqI1XozXejd74NEbj25iNX/sfdzYd0dl2ZGfXUZ19R3cOHdM5dmzn1HGdc8d3Lp3QuXZi59ZJnXtn7Tw6ufPslM5fp3ZendZ5d3rn0xmdb2d2fv1/PNgMxGA7kIPdQA32Az04DMzgOLCD08ANzgM/uAzC4DqIg9sgDe6DdfAY5MFzUAZ/gzp4DdrgPeiDz2AMvoM5+I3/8WQzEZPtRE52EzXZT/TkMDGT48ROThM3OU/85DIJk+skTm6TNLlP1sljkifPSZn8TerkNWmT96RPPpMx+U7m5Df5B/cYYh0XsBHwAAAAAElFTkSuQmCC",
+        "cropX": 5,
+        "cropY": 4,
+        "cropWidth": 10,
+        "cropHeight": 8
+      }
+    }
+  ],
+  "connections": [
+    {
+      "sourceNodeId": "n1",
+      "sourceOutput": "main",
+      "targetNodeId": "n2",
+      "targetInput": "data"
+    }
+  ],
+  "expect": {
+    "n2": {
+      "status": "completed",
+      "output": {
+        "width": 10,
+        "height": 8
+      }
+    }
+  }
+}

+ 48 - 0
tests/nodes/image-filter-grayscale.json

@@ -0,0 +1,48 @@
+{
+  "name": "image-filter-grayscale",
+  "description": "A filter must produce an image of unchanged size. The filters go across as an ordered list of operations rather than accumulated command-line flags.",
+  "nodes": [
+    {
+      "id": "n1",
+      "name": "Trigger",
+      "type": "click-trigger",
+      "position": {
+        "x": 0,
+        "y": 0
+      },
+      "config": {}
+    },
+    {
+      "id": "n2",
+      "name": "Image",
+      "type": "image",
+      "position": {
+        "x": 0,
+        "y": 100
+      },
+      "config": {
+        "operation": "filter",
+        "inputSource": "base64",
+        "base64Data": "iVBORw0KGgoAAAANSUhEUgAAACgAAAAUCAIAAABwJOjsAAAFcElEQVR4nA3NEduqMQCA4ZeiUTSKRtEoGkWjaBSNolE06npgFL0UjaLRR6NoFI2iUTTqV5xz/4F7WRY2C2JhuyAXdgtqYb+gFw4LZuG4YBdOC27hvOAXLgth4boQF24LaeG+sC48FvLCc6Es/C3UhddCW3gv9IXPwlj4LsyF38KyCDYCIdgKpGAnUIK9QAsOAiM4CqzgJHCCs8ALLoIguAqi4CZIgrtgFTwEWfAUFMGfoApegiZ4C7rgIxiCr2AKfuJ/LNlIhGQrkZKdREn2Ei05SIzkKLGSk8RJzhIvuUiC5CqJkpskSe6SVfKQZMlTUiR/kip5SZrkLemSj2RIvpIp+cn/sWKjEIqtQip2CqXYK7TioDCKo8IqTgqnOCu84qIIiqsiKm6KpLgrVsVDkRVPRVH8KaripWiKt6IrPoqh+Cqm4qf+x5qNRmi2GqnZaZRmr9Gag8ZojhqrOWmc5qzxmosmaK6aqLlpkuauWTUPTdY8NUXzp6mal6Zp3pqu+WiG5quZmp/+Hxs2BmHYGqRhZ1CGvUEbDgZjOBqs4WRwhrPBGy6GYLgaouFmSIa7YTU8DNnwNBTDn6EaXoZmeBu64WMYhq9hGn7mf2zZWIRla5GWnUVZ9hZtOViM5WixlpPFWc4Wb7lYguVqiZabJVnultXysGTL01Isf5ZqeVma5W3plo9lWL6WafnZ/7Fj4xCOrUM6dg7l2Du04+AwjqPDOk4O5zg7vOPiCI6rIzpujuS4O1bHw5EdT0dx/Dmq4+VojrejOz6O4fg6puPn/seejUd4th7p2XmUZ+/RnoPHeI4e6zl5nOfs8Z6LJ3iunui5eZLn7lk9D0/2PD3F8+epnpened6e7vl4hufrmZ6f/x8HNgER2AZkYBdQgX1ABw4BEzgGbOAUcIFzwAcugRC4BmLgFkiBe2ANPAI58AyUwF+gBl6BFngHeuATGIFvYAZ+4X8c2UREZBuRkV1ERfYRHTlETOQYsZFTxEXOER+5RELkGomRWyRF7pE18ojkyDNSIn+RGnlFWuQd6ZFPZES+kRn5xf9xYpMQiW1CJnYJldgndOKQMIljwiZOCZc4J3zikgiJayImbomUuCfWxCORE89ESfwlauKVaIl3oic+iZH4Jmbil/7HK5sVsbJdkSu7FbWyX9ErhxWzclyxK6cVt3Je8SuXlbByXYkrt5W0cl9ZVx4reeW5Ulb+VurKa6WtvFf6ymdlrHxX5spv/R9nNhmR2WZkZpdRmX1GZw4ZkzlmbOaUcZlzxmcumZC5ZmLmlkmZe2bNPDI588yUzF+mZl6ZlnlneuaTGZlvZmZ++X9c2BREYVuQhV1BFfYFXTgUTOFYsIVTwRXOBV+4FELhWoiFWyEV7oW18CjkwrNQCn+FWngVWuFd6IVPYRS+hVn4lf9xZVMRlW1FVnYVVdlXdOVQMZVjxVZOFVc5V3zlUgmVayVWbpVUuVfWyqOSK89KqfxVauVVaZV3pVc+lVH5VmblV//HjU1DNLYN2dg1VGPf0I1DwzSODds4NVzj3PCNSyM0ro3YuDVS495YG49GbjwbpfHXqI1XozXejd74NEbj25iNX/sfdzYd0dl2ZGfXUZ19R3cOHdM5dmzn1HGdc8d3Lp3QuXZi59ZJnXtn7Tw6ufPslM5fp3ZendZ5d3rn0xmdb2d2fv1/PNgMxGA7kIPdQA32Az04DMzgOLCD08ANzgM/uAzC4DqIg9sgDe6DdfAY5MFzUAZ/gzp4DdrgPeiDz2AMvoM5+I3/8WQzEZPtRE52EzXZT/TkMDGT48ROThM3OU/85DIJk+skTm6TNLlP1sljkifPSZn8TerkNWmT96RPPpMx+U7m5Df5B/cYYh0XsBHwAAAAAElFTkSuQmCC",
+        "filterGrayscale": true
+      }
+    }
+  ],
+  "connections": [
+    {
+      "sourceNodeId": "n1",
+      "sourceOutput": "main",
+      "targetNodeId": "n2",
+      "targetInput": "data"
+    }
+  ],
+  "expect": {
+    "n2": {
+      "status": "completed",
+      "output": {
+        "width": 40,
+        "height": 20
+      }
+    }
+  }
+}

+ 47 - 0
tests/nodes/image-info-reads-dimensions.json

@@ -0,0 +1,47 @@
+{
+  "name": "image-info-reads-dimensions",
+  "description": "info must report the real size of a known 40x20 PNG. It used to come from a regular expression over `identify -verbose`; now it is read from the decoded header, so a phrasing change upstream cannot turn it into a wrong number.",
+  "nodes": [
+    {
+      "id": "n1",
+      "name": "Trigger",
+      "type": "click-trigger",
+      "position": {
+        "x": 0,
+        "y": 0
+      },
+      "config": {}
+    },
+    {
+      "id": "n2",
+      "name": "Image",
+      "type": "image",
+      "position": {
+        "x": 0,
+        "y": 100
+      },
+      "config": {
+        "operation": "info",
+        "inputSource": "base64",
+        "base64Data": "iVBORw0KGgoAAAANSUhEUgAAACgAAAAUCAIAAABwJOjsAAAFcElEQVR4nA3NEduqMQCA4ZeiUTSKRtEoGkWjaBSNolE06npgFL0UjaLRR6NoFI2iUTTqV5xz/4F7WRY2C2JhuyAXdgtqYb+gFw4LZuG4YBdOC27hvOAXLgth4boQF24LaeG+sC48FvLCc6Es/C3UhddCW3gv9IXPwlj4LsyF38KyCDYCIdgKpGAnUIK9QAsOAiM4CqzgJHCCs8ALLoIguAqi4CZIgrtgFTwEWfAUFMGfoApegiZ4C7rgIxiCr2AKfuJ/LNlIhGQrkZKdREn2Ei05SIzkKLGSk8RJzhIvuUiC5CqJkpskSe6SVfKQZMlTUiR/kip5SZrkLemSj2RIvpIp+cn/sWKjEIqtQip2CqXYK7TioDCKo8IqTgqnOCu84qIIiqsiKm6KpLgrVsVDkRVPRVH8KaripWiKt6IrPoqh+Cqm4qf+x5qNRmi2GqnZaZRmr9Gag8ZojhqrOWmc5qzxmosmaK6aqLlpkuauWTUPTdY8NUXzp6mal6Zp3pqu+WiG5quZmp/+Hxs2BmHYGqRhZ1CGvUEbDgZjOBqs4WRwhrPBGy6GYLgaouFmSIa7YTU8DNnwNBTDn6EaXoZmeBu64WMYhq9hGn7mf2zZWIRla5GWnUVZ9hZtOViM5WixlpPFWc4Wb7lYguVqiZabJVnultXysGTL01Isf5ZqeVma5W3plo9lWL6WafnZ/7Fj4xCOrUM6dg7l2Du04+AwjqPDOk4O5zg7vOPiCI6rIzpujuS4O1bHw5EdT0dx/Dmq4+VojrejOz6O4fg6puPn/seejUd4th7p2XmUZ+/RnoPHeI4e6zl5nOfs8Z6LJ3iunui5eZLn7lk9D0/2PD3F8+epnpened6e7vl4hufrmZ6f/x8HNgER2AZkYBdQgX1ABw4BEzgGbOAUcIFzwAcugRC4BmLgFkiBe2ANPAI58AyUwF+gBl6BFngHeuATGIFvYAZ+4X8c2UREZBuRkV1ERfYRHTlETOQYsZFTxEXOER+5RELkGomRWyRF7pE18ojkyDNSIn+RGnlFWuQd6ZFPZES+kRn5xf9xYpMQiW1CJnYJldgndOKQMIljwiZOCZc4J3zikgiJayImbomUuCfWxCORE89ESfwlauKVaIl3oic+iZH4Jmbil/7HK5sVsbJdkSu7FbWyX9ErhxWzclyxK6cVt3Je8SuXlbByXYkrt5W0cl9ZVx4reeW5Ulb+VurKa6WtvFf6ymdlrHxX5spv/R9nNhmR2WZkZpdRmX1GZw4ZkzlmbOaUcZlzxmcumZC5ZmLmlkmZe2bNPDI588yUzF+mZl6ZlnlneuaTGZlvZmZ++X9c2BREYVuQhV1BFfYFXTgUTOFYsIVTwRXOBV+4FELhWoiFWyEV7oW18CjkwrNQCn+FWngVWuFd6IVPYRS+hVn4lf9xZVMRlW1FVnYVVdlXdOVQMZVjxVZOFVc5V3zlUgmVayVWbpVUuVfWyqOSK89KqfxVauVVaZV3pVc+lVH5VmblV//HjU1DNLYN2dg1VGPf0I1DwzSODds4NVzj3PCNSyM0ro3YuDVS495YG49GbjwbpfHXqI1XozXejd74NEbj25iNX/sfdzYd0dl2ZGfXUZ19R3cOHdM5dmzn1HGdc8d3Lp3QuXZi59ZJnXtn7Tw6ufPslM5fp3ZendZ5d3rn0xmdb2d2fv1/PNgMxGA7kIPdQA32Az04DMzgOLCD08ANzgM/uAzC4DqIg9sgDe6DdfAY5MFzUAZ/gzp4DdrgPeiDz2AMvoM5+I3/8WQzEZPtRE52EzXZT/TkMDGT48ROThM3OU/85DIJk+skTm6TNLlP1sljkifPSZn8TerkNWmT96RPPpMx+U7m5Df5B/cYYh0XsBHwAAAAAElFTkSuQmCC"
+      }
+    }
+  ],
+  "connections": [
+    {
+      "sourceNodeId": "n1",
+      "sourceOutput": "main",
+      "targetNodeId": "n2",
+      "targetInput": "data"
+    }
+  ],
+  "expect": {
+    "n2": {
+      "status": "completed",
+      "output": {
+        "width": 40,
+        "height": 20
+      }
+    }
+  }
+}

+ 50 - 0
tests/nodes/image-resize-ignores-aspect.json

@@ -0,0 +1,50 @@
+{
+  "name": "image-resize-ignores-aspect",
+  "description": "The same resize with the aspect ratio off must distort to exactly 20x20. This is the `!` geometry flag, which is a Magick++ Geometry::aspect(true) here rather than a character appended to a command string.",
+  "nodes": [
+    {
+      "id": "n1",
+      "name": "Trigger",
+      "type": "click-trigger",
+      "position": {
+        "x": 0,
+        "y": 0
+      },
+      "config": {}
+    },
+    {
+      "id": "n2",
+      "name": "Image",
+      "type": "image",
+      "position": {
+        "x": 0,
+        "y": 100
+      },
+      "config": {
+        "operation": "resize",
+        "inputSource": "base64",
+        "base64Data": "iVBORw0KGgoAAAANSUhEUgAAACgAAAAUCAIAAABwJOjsAAAFcElEQVR4nA3NEduqMQCA4ZeiUTSKRtEoGkWjaBSNolE06npgFL0UjaLRR6NoFI2iUTTqV5xz/4F7WRY2C2JhuyAXdgtqYb+gFw4LZuG4YBdOC27hvOAXLgth4boQF24LaeG+sC48FvLCc6Es/C3UhddCW3gv9IXPwlj4LsyF38KyCDYCIdgKpGAnUIK9QAsOAiM4CqzgJHCCs8ALLoIguAqi4CZIgrtgFTwEWfAUFMGfoApegiZ4C7rgIxiCr2AKfuJ/LNlIhGQrkZKdREn2Ei05SIzkKLGSk8RJzhIvuUiC5CqJkpskSe6SVfKQZMlTUiR/kip5SZrkLemSj2RIvpIp+cn/sWKjEIqtQip2CqXYK7TioDCKo8IqTgqnOCu84qIIiqsiKm6KpLgrVsVDkRVPRVH8KaripWiKt6IrPoqh+Cqm4qf+x5qNRmi2GqnZaZRmr9Gag8ZojhqrOWmc5qzxmosmaK6aqLlpkuauWTUPTdY8NUXzp6mal6Zp3pqu+WiG5quZmp/+Hxs2BmHYGqRhZ1CGvUEbDgZjOBqs4WRwhrPBGy6GYLgaouFmSIa7YTU8DNnwNBTDn6EaXoZmeBu64WMYhq9hGn7mf2zZWIRla5GWnUVZ9hZtOViM5WixlpPFWc4Wb7lYguVqiZabJVnultXysGTL01Isf5ZqeVma5W3plo9lWL6WafnZ/7Fj4xCOrUM6dg7l2Du04+AwjqPDOk4O5zg7vOPiCI6rIzpujuS4O1bHw5EdT0dx/Dmq4+VojrejOz6O4fg6puPn/seejUd4th7p2XmUZ+/RnoPHeI4e6zl5nOfs8Z6LJ3iunui5eZLn7lk9D0/2PD3F8+epnpened6e7vl4hufrmZ6f/x8HNgER2AZkYBdQgX1ABw4BEzgGbOAUcIFzwAcugRC4BmLgFkiBe2ANPAI58AyUwF+gBl6BFngHeuATGIFvYAZ+4X8c2UREZBuRkV1ERfYRHTlETOQYsZFTxEXOER+5RELkGomRWyRF7pE18ojkyDNSIn+RGnlFWuQd6ZFPZES+kRn5xf9xYpMQiW1CJnYJldgndOKQMIljwiZOCZc4J3zikgiJayImbomUuCfWxCORE89ESfwlauKVaIl3oic+iZH4Jmbil/7HK5sVsbJdkSu7FbWyX9ErhxWzclyxK6cVt3Je8SuXlbByXYkrt5W0cl9ZVx4reeW5Ulb+VurKa6WtvFf6ymdlrHxX5spv/R9nNhmR2WZkZpdRmX1GZw4ZkzlmbOaUcZlzxmcumZC5ZmLmlkmZe2bNPDI588yUzF+mZl6ZlnlneuaTGZlvZmZ++X9c2BREYVuQhV1BFfYFXTgUTOFYsIVTwRXOBV+4FELhWoiFWyEV7oW18CjkwrNQCn+FWngVWuFd6IVPYRS+hVn4lf9xZVMRlW1FVnYVVdlXdOVQMZVjxVZOFVc5V3zlUgmVayVWbpVUuVfWyqOSK89KqfxVauVVaZV3pVc+lVH5VmblV//HjU1DNLYN2dg1VGPf0I1DwzSODds4NVzj3PCNSyM0ro3YuDVS495YG49GbjwbpfHXqI1XozXejd74NEbj25iNX/sfdzYd0dl2ZGfXUZ19R3cOHdM5dmzn1HGdc8d3Lp3QuXZi59ZJnXtn7Tw6ufPslM5fp3ZendZ5d3rn0xmdb2d2fv1/PNgMxGA7kIPdQA32Az04DMzgOLCD08ANzgM/uAzC4DqIg9sgDe6DdfAY5MFzUAZ/gzp4DdrgPeiDz2AMvoM5+I3/8WQzEZPtRE52EzXZT/TkMDGT48ROThM3OU/85DIJk+skTm6TNLlP1sljkifPSZn8TerkNWmT96RPPpMx+U7m5Df5B/cYYh0XsBHwAAAAAElFTkSuQmCC",
+        "resizeWidth": 20,
+        "resizeHeight": 20,
+        "maintainAspectRatio": false
+      }
+    }
+  ],
+  "connections": [
+    {
+      "sourceNodeId": "n1",
+      "sourceOutput": "main",
+      "targetNodeId": "n2",
+      "targetInput": "data"
+    }
+  ],
+  "expect": {
+    "n2": {
+      "status": "completed",
+      "output": {
+        "width": 20,
+        "height": 20
+      }
+    }
+  }
+}

+ 49 - 0
tests/nodes/image-resize-keeps-aspect.json

@@ -0,0 +1,49 @@
+{
+  "name": "image-resize-keeps-aspect",
+  "description": "Resizing to width 20 with the aspect ratio kept must give 20x10, computed from the real source size rather than assumed.",
+  "nodes": [
+    {
+      "id": "n1",
+      "name": "Trigger",
+      "type": "click-trigger",
+      "position": {
+        "x": 0,
+        "y": 0
+      },
+      "config": {}
+    },
+    {
+      "id": "n2",
+      "name": "Image",
+      "type": "image",
+      "position": {
+        "x": 0,
+        "y": 100
+      },
+      "config": {
+        "operation": "resize",
+        "inputSource": "base64",
+        "base64Data": "iVBORw0KGgoAAAANSUhEUgAAACgAAAAUCAIAAABwJOjsAAAFcElEQVR4nA3NEduqMQCA4ZeiUTSKRtEoGkWjaBSNolE06npgFL0UjaLRR6NoFI2iUTTqV5xz/4F7WRY2C2JhuyAXdgtqYb+gFw4LZuG4YBdOC27hvOAXLgth4boQF24LaeG+sC48FvLCc6Es/C3UhddCW3gv9IXPwlj4LsyF38KyCDYCIdgKpGAnUIK9QAsOAiM4CqzgJHCCs8ALLoIguAqi4CZIgrtgFTwEWfAUFMGfoApegiZ4C7rgIxiCr2AKfuJ/LNlIhGQrkZKdREn2Ei05SIzkKLGSk8RJzhIvuUiC5CqJkpskSe6SVfKQZMlTUiR/kip5SZrkLemSj2RIvpIp+cn/sWKjEIqtQip2CqXYK7TioDCKo8IqTgqnOCu84qIIiqsiKm6KpLgrVsVDkRVPRVH8KaripWiKt6IrPoqh+Cqm4qf+x5qNRmi2GqnZaZRmr9Gag8ZojhqrOWmc5qzxmosmaK6aqLlpkuauWTUPTdY8NUXzp6mal6Zp3pqu+WiG5quZmp/+Hxs2BmHYGqRhZ1CGvUEbDgZjOBqs4WRwhrPBGy6GYLgaouFmSIa7YTU8DNnwNBTDn6EaXoZmeBu64WMYhq9hGn7mf2zZWIRla5GWnUVZ9hZtOViM5WixlpPFWc4Wb7lYguVqiZabJVnultXysGTL01Isf5ZqeVma5W3plo9lWL6WafnZ/7Fj4xCOrUM6dg7l2Du04+AwjqPDOk4O5zg7vOPiCI6rIzpujuS4O1bHw5EdT0dx/Dmq4+VojrejOz6O4fg6puPn/seejUd4th7p2XmUZ+/RnoPHeI4e6zl5nOfs8Z6LJ3iunui5eZLn7lk9D0/2PD3F8+epnpened6e7vl4hufrmZ6f/x8HNgER2AZkYBdQgX1ABw4BEzgGbOAUcIFzwAcugRC4BmLgFkiBe2ANPAI58AyUwF+gBl6BFngHeuATGIFvYAZ+4X8c2UREZBuRkV1ERfYRHTlETOQYsZFTxEXOER+5RELkGomRWyRF7pE18ojkyDNSIn+RGnlFWuQd6ZFPZES+kRn5xf9xYpMQiW1CJnYJldgndOKQMIljwiZOCZc4J3zikgiJayImbomUuCfWxCORE89ESfwlauKVaIl3oic+iZH4Jmbil/7HK5sVsbJdkSu7FbWyX9ErhxWzclyxK6cVt3Je8SuXlbByXYkrt5W0cl9ZVx4reeW5Ulb+VurKa6WtvFf6ymdlrHxX5spv/R9nNhmR2WZkZpdRmX1GZw4ZkzlmbOaUcZlzxmcumZC5ZmLmlkmZe2bNPDI588yUzF+mZl6ZlnlneuaTGZlvZmZ++X9c2BREYVuQhV1BFfYFXTgUTOFYsIVTwRXOBV+4FELhWoiFWyEV7oW18CjkwrNQCn+FWngVWuFd6IVPYRS+hVn4lf9xZVMRlW1FVnYVVdlXdOVQMZVjxVZOFVc5V3zlUgmVayVWbpVUuVfWyqOSK89KqfxVauVVaZV3pVc+lVH5VmblV//HjU1DNLYN2dg1VGPf0I1DwzSODds4NVzj3PCNSyM0ro3YuDVS495YG49GbjwbpfHXqI1XozXejd74NEbj25iNX/sfdzYd0dl2ZGfXUZ19R3cOHdM5dmzn1HGdc8d3Lp3QuXZi59ZJnXtn7Tw6ufPslM5fp3ZendZ5d3rn0xmdb2d2fv1/PNgMxGA7kIPdQA32Az04DMzgOLCD08ANzgM/uAzC4DqIg9sgDe6DdfAY5MFzUAZ/gzp4DdrgPeiDz2AMvoM5+I3/8WQzEZPtRE52EzXZT/TkMDGT48ROThM3OU/85DIJk+skTm6TNLlP1sljkifPSZn8TerkNWmT96RPPpMx+U7m5Df5B/cYYh0XsBHwAAAAAElFTkSuQmCC",
+        "resizeWidth": 20,
+        "maintainAspectRatio": true
+      }
+    }
+  ],
+  "connections": [
+    {
+      "sourceNodeId": "n1",
+      "sourceOutput": "main",
+      "targetNodeId": "n2",
+      "targetInput": "data"
+    }
+  ],
+  "expect": {
+    "n2": {
+      "status": "completed",
+      "output": {
+        "width": 20,
+        "height": 10
+      }
+    }
+  }
+}

+ 48 - 0
tests/nodes/image-rotate-swaps-sides.json

@@ -0,0 +1,48 @@
+{
+  "name": "image-rotate-swaps-sides",
+  "description": "Rotating 40x20 by 90 degrees must give 20x40 - the check that the rotation happened at all rather than being silently dropped.",
+  "nodes": [
+    {
+      "id": "n1",
+      "name": "Trigger",
+      "type": "click-trigger",
+      "position": {
+        "x": 0,
+        "y": 0
+      },
+      "config": {}
+    },
+    {
+      "id": "n2",
+      "name": "Image",
+      "type": "image",
+      "position": {
+        "x": 0,
+        "y": 100
+      },
+      "config": {
+        "operation": "rotate",
+        "inputSource": "base64",
+        "base64Data": "iVBORw0KGgoAAAANSUhEUgAAACgAAAAUCAIAAABwJOjsAAAFcElEQVR4nA3NEduqMQCA4ZeiUTSKRtEoGkWjaBSNolE06npgFL0UjaLRR6NoFI2iUTTqV5xz/4F7WRY2C2JhuyAXdgtqYb+gFw4LZuG4YBdOC27hvOAXLgth4boQF24LaeG+sC48FvLCc6Es/C3UhddCW3gv9IXPwlj4LsyF38KyCDYCIdgKpGAnUIK9QAsOAiM4CqzgJHCCs8ALLoIguAqi4CZIgrtgFTwEWfAUFMGfoApegiZ4C7rgIxiCr2AKfuJ/LNlIhGQrkZKdREn2Ei05SIzkKLGSk8RJzhIvuUiC5CqJkpskSe6SVfKQZMlTUiR/kip5SZrkLemSj2RIvpIp+cn/sWKjEIqtQip2CqXYK7TioDCKo8IqTgqnOCu84qIIiqsiKm6KpLgrVsVDkRVPRVH8KaripWiKt6IrPoqh+Cqm4qf+x5qNRmi2GqnZaZRmr9Gag8ZojhqrOWmc5qzxmosmaK6aqLlpkuauWTUPTdY8NUXzp6mal6Zp3pqu+WiG5quZmp/+Hxs2BmHYGqRhZ1CGvUEbDgZjOBqs4WRwhrPBGy6GYLgaouFmSIa7YTU8DNnwNBTDn6EaXoZmeBu64WMYhq9hGn7mf2zZWIRla5GWnUVZ9hZtOViM5WixlpPFWc4Wb7lYguVqiZabJVnultXysGTL01Isf5ZqeVma5W3plo9lWL6WafnZ/7Fj4xCOrUM6dg7l2Du04+AwjqPDOk4O5zg7vOPiCI6rIzpujuS4O1bHw5EdT0dx/Dmq4+VojrejOz6O4fg6puPn/seejUd4th7p2XmUZ+/RnoPHeI4e6zl5nOfs8Z6LJ3iunui5eZLn7lk9D0/2PD3F8+epnpened6e7vl4hufrmZ6f/x8HNgER2AZkYBdQgX1ABw4BEzgGbOAUcIFzwAcugRC4BmLgFkiBe2ANPAI58AyUwF+gBl6BFngHeuATGIFvYAZ+4X8c2UREZBuRkV1ERfYRHTlETOQYsZFTxEXOER+5RELkGomRWyRF7pE18ojkyDNSIn+RGnlFWuQd6ZFPZES+kRn5xf9xYpMQiW1CJnYJldgndOKQMIljwiZOCZc4J3zikgiJayImbomUuCfWxCORE89ESfwlauKVaIl3oic+iZH4Jmbil/7HK5sVsbJdkSu7FbWyX9ErhxWzclyxK6cVt3Je8SuXlbByXYkrt5W0cl9ZVx4reeW5Ulb+VurKa6WtvFf6ymdlrHxX5spv/R9nNhmR2WZkZpdRmX1GZw4ZkzlmbOaUcZlzxmcumZC5ZmLmlkmZe2bNPDI588yUzF+mZl6ZlnlneuaTGZlvZmZ++X9c2BREYVuQhV1BFfYFXTgUTOFYsIVTwRXOBV+4FELhWoiFWyEV7oW18CjkwrNQCn+FWngVWuFd6IVPYRS+hVn4lf9xZVMRlW1FVnYVVdlXdOVQMZVjxVZOFVc5V3zlUgmVayVWbpVUuVfWyqOSK89KqfxVauVVaZV3pVc+lVH5VmblV//HjU1DNLYN2dg1VGPf0I1DwzSODds4NVzj3PCNSyM0ro3YuDVS495YG49GbjwbpfHXqI1XozXejd74NEbj25iNX/sfdzYd0dl2ZGfXUZ19R3cOHdM5dmzn1HGdc8d3Lp3QuXZi59ZJnXtn7Tw6ufPslM5fp3ZendZ5d3rn0xmdb2d2fv1/PNgMxGA7kIPdQA32Az04DMzgOLCD08ANzgM/uAzC4DqIg9sgDe6DdfAY5MFzUAZ/gzp4DdrgPeiDz2AMvoM5+I3/8WQzEZPtRE52EzXZT/TkMDGT48ROThM3OU/85DIJk+skTm6TNLlP1sljkifPSZn8TerkNWmT96RPPpMx+U7m5Df5B/cYYh0XsBHwAAAAAElFTkSuQmCC",
+        "rotatePreset": "90"
+      }
+    }
+  ],
+  "connections": [
+    {
+      "sourceNodeId": "n1",
+      "sourceOutput": "main",
+      "targetNodeId": "n2",
+      "targetInput": "data"
+    }
+  ],
+  "expect": {
+    "n2": {
+      "status": "completed",
+      "output": {
+        "width": 20,
+        "height": 40
+      }
+    }
+  }
+}

+ 51 - 0
tests/nodes/image-watermark-text.json

@@ -0,0 +1,51 @@
+{
+  "name": "image-watermark-text",
+  "description": "A text watermark must render without the text being escaped into a shell command. The text deliberately contains a double quote, a dollar and a backtick - the three characters the old code escaped by hand before splicing them into `convert`.",
+  "nodes": [
+    {
+      "id": "n1",
+      "name": "Trigger",
+      "type": "click-trigger",
+      "position": {
+        "x": 0,
+        "y": 0
+      },
+      "config": {}
+    },
+    {
+      "id": "n2",
+      "name": "Image",
+      "type": "image",
+      "position": {
+        "x": 0,
+        "y": 100
+      },
+      "config": {
+        "operation": "watermark",
+        "inputSource": "base64",
+        "base64Data": "iVBORw0KGgoAAAANSUhEUgAAACgAAAAUCAIAAABwJOjsAAAFcElEQVR4nA3NEduqMQCA4ZeiUTSKRtEoGkWjaBSNolE06npgFL0UjaLRR6NoFI2iUTTqV5xz/4F7WRY2C2JhuyAXdgtqYb+gFw4LZuG4YBdOC27hvOAXLgth4boQF24LaeG+sC48FvLCc6Es/C3UhddCW3gv9IXPwlj4LsyF38KyCDYCIdgKpGAnUIK9QAsOAiM4CqzgJHCCs8ALLoIguAqi4CZIgrtgFTwEWfAUFMGfoApegiZ4C7rgIxiCr2AKfuJ/LNlIhGQrkZKdREn2Ei05SIzkKLGSk8RJzhIvuUiC5CqJkpskSe6SVfKQZMlTUiR/kip5SZrkLemSj2RIvpIp+cn/sWKjEIqtQip2CqXYK7TioDCKo8IqTgqnOCu84qIIiqsiKm6KpLgrVsVDkRVPRVH8KaripWiKt6IrPoqh+Cqm4qf+x5qNRmi2GqnZaZRmr9Gag8ZojhqrOWmc5qzxmosmaK6aqLlpkuauWTUPTdY8NUXzp6mal6Zp3pqu+WiG5quZmp/+Hxs2BmHYGqRhZ1CGvUEbDgZjOBqs4WRwhrPBGy6GYLgaouFmSIa7YTU8DNnwNBTDn6EaXoZmeBu64WMYhq9hGn7mf2zZWIRla5GWnUVZ9hZtOViM5WixlpPFWc4Wb7lYguVqiZabJVnultXysGTL01Isf5ZqeVma5W3plo9lWL6WafnZ/7Fj4xCOrUM6dg7l2Du04+AwjqPDOk4O5zg7vOPiCI6rIzpujuS4O1bHw5EdT0dx/Dmq4+VojrejOz6O4fg6puPn/seejUd4th7p2XmUZ+/RnoPHeI4e6zl5nOfs8Z6LJ3iunui5eZLn7lk9D0/2PD3F8+epnpened6e7vl4hufrmZ6f/x8HNgER2AZkYBdQgX1ABw4BEzgGbOAUcIFzwAcugRC4BmLgFkiBe2ANPAI58AyUwF+gBl6BFngHeuATGIFvYAZ+4X8c2UREZBuRkV1ERfYRHTlETOQYsZFTxEXOER+5RELkGomRWyRF7pE18ojkyDNSIn+RGnlFWuQd6ZFPZES+kRn5xf9xYpMQiW1CJnYJldgndOKQMIljwiZOCZc4J3zikgiJayImbomUuCfWxCORE89ESfwlauKVaIl3oic+iZH4Jmbil/7HK5sVsbJdkSu7FbWyX9ErhxWzclyxK6cVt3Je8SuXlbByXYkrt5W0cl9ZVx4reeW5Ulb+VurKa6WtvFf6ymdlrHxX5spv/R9nNhmR2WZkZpdRmX1GZw4ZkzlmbOaUcZlzxmcumZC5ZmLmlkmZe2bNPDI588yUzF+mZl6ZlnlneuaTGZlvZmZ++X9c2BREYVuQhV1BFfYFXTgUTOFYsIVTwRXOBV+4FELhWoiFWyEV7oW18CjkwrNQCn+FWngVWuFd6IVPYRS+hVn4lf9xZVMRlW1FVnYVVdlXdOVQMZVjxVZOFVc5V3zlUgmVayVWbpVUuVfWyqOSK89KqfxVauVVaZV3pVc+lVH5VmblV//HjU1DNLYN2dg1VGPf0I1DwzSODds4NVzj3PCNSyM0ro3YuDVS495YG49GbjwbpfHXqI1XozXejd74NEbj25iNX/sfdzYd0dl2ZGfXUZ19R3cOHdM5dmzn1HGdc8d3Lp3QuXZi59ZJnXtn7Tw6ufPslM5fp3ZendZ5d3rn0xmdb2d2fv1/PNgMxGA7kIPdQA32Az04DMzgOLCD08ANzgM/uAzC4DqIg9sgDe6DdfAY5MFzUAZ/gzp4DdrgPeiDz2AMvoM5+I3/8WQzEZPtRE52EzXZT/TkMDGT48ROThM3OU/85DIJk+skTm6TNLlP1sljkifPSZn8TerkNWmT96RPPpMx+U7m5Df5B/cYYh0XsBHwAAAAAElFTkSuQmCC",
+        "watermarkType": "text",
+        "watermarkText": "a \"b\" $c `d`",
+        "watermarkPosition": "southeast",
+        "watermarkOpacity": 60
+      }
+    }
+  ],
+  "connections": [
+    {
+      "sourceNodeId": "n1",
+      "sourceOutput": "main",
+      "targetNodeId": "n2",
+      "targetInput": "data"
+    }
+  ],
+  "expect": {
+    "n2": {
+      "status": "completed",
+      "output": {
+        "width": 40,
+        "height": 20
+      }
+    }
+  }
+}