Bläddra i källkod

feat: US-011 - Image Node - Manipulation (Resize, Crop, Rotate)

fszontagh 6 månader sedan
förälder
incheckning
50fbca3b22
1 ändrade filer med 450 tillägg och 6 borttagningar
  1. 450 6
      nodes/image/image.js

+ 450 - 6
nodes/image/image.js

@@ -2,8 +2,8 @@
  * @node image
  * @name Image
  * @category media
- * @version 1.0.0
- * @description Extract image information, metadata, and EXIF data using ImageMagick
+ * @version 1.1.0
+ * @description Image manipulation and metadata extraction using ImageMagick
  * @icon image
  */
 
@@ -63,6 +63,116 @@ const configSchema = {
             description: 'Extract EXIF metadata (camera info, GPS, date taken, etc.)',
             showWhen: { field: 'operation', value: 'info' }
         },
+        resizeMode: {
+            type: 'string',
+            title: 'Resize Mode',
+            enum: ['dimensions', 'percentage'],
+            enumLabels: ['Dimensions (pixels)', 'Percentage (%)'],
+            default: 'dimensions',
+            description: 'How to specify the new size',
+            showWhen: { field: 'operation', value: 'resize' }
+        },
+        resizeWidth: {
+            type: 'number',
+            title: 'Width',
+            minimum: 1,
+            maximum: 20000,
+            description: 'Target width in pixels. Leave empty to auto-calculate from height.',
+            showWhen: { field: 'operation', value: 'resize' }
+        },
+        resizeHeight: {
+            type: 'number',
+            title: 'Height',
+            minimum: 1,
+            maximum: 20000,
+            description: 'Target height in pixels. Leave empty to auto-calculate from width.',
+            showWhen: { field: 'operation', value: 'resize' }
+        },
+        resizePercentage: {
+            type: 'number',
+            title: 'Scale Percentage',
+            minimum: 1,
+            maximum: 1000,
+            default: 100,
+            description: 'Scale percentage (100 = original size, 50 = half, 200 = double)',
+            showWhen: { field: 'operation', value: 'resize' }
+        },
+        maintainAspectRatio: {
+            type: 'boolean',
+            title: 'Maintain Aspect Ratio',
+            default: true,
+            description: 'Keep original proportions when resizing',
+            showWhen: { field: 'operation', value: 'resize' }
+        },
+        cropX: {
+            type: 'number',
+            title: 'X Offset',
+            minimum: 0,
+            default: 0,
+            description: 'Horizontal offset from left edge in pixels',
+            showWhen: { field: 'operation', value: 'crop' }
+        },
+        cropY: {
+            type: 'number',
+            title: 'Y Offset',
+            minimum: 0,
+            default: 0,
+            description: 'Vertical offset from top edge in pixels',
+            showWhen: { field: 'operation', value: 'crop' }
+        },
+        cropWidth: {
+            type: 'number',
+            title: 'Crop Width',
+            minimum: 1,
+            description: 'Width of the crop region in pixels',
+            showWhen: { field: 'operation', value: 'crop' }
+        },
+        cropHeight: {
+            type: 'number',
+            title: 'Crop Height',
+            minimum: 1,
+            description: 'Height of the crop region in pixels',
+            showWhen: { field: 'operation', value: 'crop' }
+        },
+        rotateAngle: {
+            type: 'number',
+            title: 'Rotation Angle',
+            default: 90,
+            description: 'Rotation angle in degrees (positive = clockwise)',
+            showWhen: { field: 'operation', value: 'rotate' }
+        },
+        rotatePreset: {
+            type: 'string',
+            title: 'Quick Rotate',
+            enum: ['custom', '90', '180', '270'],
+            enumLabels: ['Custom Angle', '90 (CW)', '180', '270 (CCW)'],
+            default: 'custom',
+            description: 'Common rotation presets or use custom angle',
+            showWhen: { field: 'operation', value: 'rotate' }
+        },
+        backgroundColor: {
+            type: 'string',
+            title: 'Background Color',
+            default: 'transparent',
+            description: 'Fill color for areas exposed by rotation (name, hex, or transparent)',
+            showWhen: { field: 'operation', value: 'rotate' }
+        },
+        outputFormat: {
+            type: 'string',
+            title: 'Output Format',
+            enum: ['auto', 'jpeg', 'png', 'webp', 'gif'],
+            enumLabels: ['Same as Input', 'JPEG', 'PNG', 'WebP', 'GIF'],
+            default: 'auto',
+            description: 'Output image format'
+        },
+        outputQuality: {
+            type: 'number',
+            title: 'Quality',
+            minimum: 1,
+            maximum: 100,
+            default: 85,
+            description: 'Output quality for lossy formats (JPEG, WebP). 1-100.'
+        },
         timeout: {
             type: 'number',
             title: 'Timeout (ms)',
@@ -205,6 +315,29 @@ const outputSchema = {
                 meteringMode: { type: 'string' }
             }
         },
+        file: {
+            type: 'object',
+            description: 'Binary file output (for manipulation operations)',
+            properties: {
+                type: { type: 'string', const: 'binary' },
+                data: { type: 'string', description: 'Base64-encoded image data' },
+                mimeType: { type: 'string', description: 'MIME type of the image' },
+                filename: { type: 'string', description: 'Output filename' },
+                size: { type: 'number', description: 'File size in bytes' }
+            }
+        },
+        operation: {
+            type: 'string',
+            description: 'Operation performed'
+        },
+        originalWidth: {
+            type: 'number',
+            description: 'Original image width before manipulation'
+        },
+        originalHeight: {
+            type: 'number',
+            description: 'Original image height before manipulation'
+        },
         sourceType: {
             type: 'string',
             description: 'Source type used (base64, filePath, url, storage)'
@@ -637,12 +770,313 @@ function executeInfoOperation(imagePath, includeExif, timeout) {
     return info;
 }
 
-async function execute(config, input, context) {
+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)
+    };
+}
+
+function getOutputExtension(format, originalPath) {
+    if (format === 'auto') {
+        var ext = originalPath.split('.').pop().toLowerCase();
+        if (['jpg', 'jpeg', 'png', 'gif', 'webp'].indexOf(ext) >= 0) {
+            return ext === 'jpg' ? 'jpeg' : ext;
+        }
+        return 'jpeg';
+    }
+    return format;
+}
+
+function getMimeType(format) {
+    var mimeTypes = {
+        jpeg: 'image/jpeg',
+        jpg: 'image/jpeg',
+        png: 'image/png',
+        gif: 'image/gif',
+        webp: 'image/webp'
+    };
+    return mimeTypes[format] || 'image/jpeg';
+}
+
+function buildOutputPath(format) {
+    var uuid = smartbotic.utils.uuid();
+    return '/tmp/smartbotic-image-out-' + uuid + '.' + format;
+}
+
+function readOutputFile(outputPath) {
+    var readResult = smartbotic.fs.readFile(outputPath, 'base64');
+    if (!readResult.success) {
+        throw new Error('Failed to read output file: ' + (readResult.error || 'Unknown error'));
+    }
+    return readResult.data;
+}
+
+function getOutputFileSize(outputPath) {
+    var stat = smartbotic.fs.stat(outputPath);
+    return stat.success ? stat.size : 0;
+}
+
+function cleanupOutputFile(outputPath) {
+    if (outputPath) {
+        try {
+            smartbotic.fs.unlink(outputPath);
+        } catch (e) {
+            smartbotic.log.warn('Failed to cleanup output file: ' + 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);
+    var originalWidth = dimensions.width;
+    var originalHeight = dimensions.height;
+
+    var resizeMode = config.resizeMode || 'dimensions';
+    var maintainAspectRatio = config.maintainAspectRatio !== false;
+    var targetWidth, targetHeight;
+
+    if (resizeMode === 'percentage') {
+        var percentage = config.resizePercentage || 100;
+        targetWidth = Math.round(originalWidth * percentage / 100);
+        targetHeight = Math.round(originalHeight * percentage / 100);
+        smartbotic.log.info('Resizing by ' + percentage + '% to ' + targetWidth + 'x' + targetHeight);
+    } else {
+        targetWidth = config.resizeWidth;
+        targetHeight = config.resizeHeight;
+
+        if (!targetWidth && !targetHeight) {
+            throw new Error('Resize operation requires at least width or height');
+        }
+
+        if (maintainAspectRatio) {
+            if (targetWidth && !targetHeight) {
+                targetHeight = Math.round(originalHeight * targetWidth / originalWidth);
+            } else if (targetHeight && !targetWidth) {
+                targetWidth = Math.round(originalWidth * targetHeight / originalHeight);
+            }
+        } else {
+            targetWidth = targetWidth || originalWidth;
+            targetHeight = targetHeight || originalHeight;
+        }
+        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,
+        width: targetWidth,
+        height: targetHeight,
+        format: outputFormat.toUpperCase(),
+        file: {
+            type: 'binary',
+            data: base64Data,
+            mimeType: getMimeType(outputFormat),
+            filename: 'resized.' + outputFormat,
+            size: fileSize
+        }
+    };
+}
+
+function executeCropOperation(imagePath, config, timeout) {
+    var dimensions = getImageDimensions(imagePath, timeout);
+    var originalWidth = dimensions.width;
+    var originalHeight = dimensions.height;
+
+    var cropX = config.cropX || 0;
+    var cropY = config.cropY || 0;
+    var cropWidth = config.cropWidth;
+    var cropHeight = config.cropHeight;
+
+    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));
+    }
+
+    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
+        }
+    };
+}
+
+function executeRotateOperation(imagePath, config, timeout) {
+    var dimensions = getImageDimensions(imagePath, timeout);
+    var originalWidth = dimensions.width;
+    var originalHeight = dimensions.height;
+
+    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);
+    }
+
+    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
+        }
+    };
+}
+
+async function execute(config, input) {
     var operation = config.operation || 'info';
     var timeout = config.timeout || 30000;
 
-    if (operation !== 'info') {
-        throw new Error('Operation "' + operation + '" is not yet implemented. Only "info" is available in this version.');
+    var supportedOperations = ['info', 'resize', 'crop', 'rotate'];
+    if (supportedOperations.indexOf(operation) < 0) {
+        throw new Error('Operation "' + operation + '" is not yet implemented. Supported: ' + supportedOperations.join(', '));
     }
 
     var imageInput = resolveImageInput(config, input);
@@ -658,7 +1092,17 @@ async function execute(config, input, context) {
         fileInfo = prepareImageFile(imageInput, timeout);
         smartbotic.log.info('Processing image: ' + fileInfo.path);
 
-        var result = executeInfoOperation(fileInfo.path, config.includeExif !== false, timeout);
+        var result;
+
+        if (operation === 'info') {
+            result = executeInfoOperation(fileInfo.path, config.includeExif !== false, timeout);
+        } else if (operation === 'resize') {
+            result = executeResizeOperation(fileInfo.path, config, timeout);
+        } else if (operation === 'crop') {
+            result = executeCropOperation(fileInfo.path, config, timeout);
+        } else if (operation === 'rotate') {
+            result = executeRotateOperation(fileInfo.path, config, timeout);
+        }
 
         result.sourceType = imageInput.type;
         result.sourcePath = fileInfo.path;