瀏覽代碼

feat: US-012 - Image Node - Filters & Watermark

Adds filter operations for image manipulation including blur (configurable
radius), sharpen, grayscale, sepia, brightness adjustment, and contrast
adjustment. Filters can be chained (multiple applied in sequence).

Adds watermark support for overlaying text or image watermarks with
configurable position (center, corners), opacity, and margin settings.
Image watermarks support configurable scaling.

All operations use ImageMagick and output via existing binary storage.
fszontagh 6 月之前
父節點
當前提交
de05d58774
共有 1 個文件被更改,包括 531 次插入5 次删除
  1. 531 5
      nodes/image/image.js

+ 531 - 5
nodes/image/image.js

@@ -2,8 +2,8 @@
  * @node image
  * @name Image
  * @category media
- * @version 1.1.0
- * @description Image manipulation and metadata extraction using ImageMagick
+ * @version 1.2.0
+ * @description Image manipulation, filters, watermarks, and metadata extraction using ImageMagick
  * @icon image
  */
 
@@ -13,8 +13,8 @@ const configSchema = {
         operation: {
             type: 'string',
             title: 'Operation',
-            enum: ['info', 'resize', 'crop', 'rotate', 'filter', 'convert'],
-            enumLabels: ['Info', 'Resize', 'Crop', 'Rotate', 'Filter', 'Convert Format'],
+            enum: ['info', 'resize', 'crop', 'rotate', 'filter', 'watermark'],
+            enumLabels: ['Info', 'Resize', 'Crop', 'Rotate', 'Filter', 'Watermark'],
             default: 'info',
             description: 'Image operation to perform'
         },
@@ -157,6 +157,194 @@ const configSchema = {
             description: 'Fill color for areas exposed by rotation (name, hex, or transparent)',
             showWhen: { field: 'operation', value: 'rotate' }
         },
+        filterBlur: {
+            type: 'boolean',
+            title: 'Blur',
+            default: false,
+            description: 'Apply blur filter',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterBlurRadius: {
+            type: 'number',
+            title: 'Blur Radius',
+            minimum: 0,
+            maximum: 100,
+            default: 5,
+            description: 'Blur radius in pixels (0-100)',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterSharpen: {
+            type: 'boolean',
+            title: 'Sharpen',
+            default: false,
+            description: 'Apply sharpen filter',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterSharpenAmount: {
+            type: 'number',
+            title: 'Sharpen Amount',
+            minimum: 0,
+            maximum: 10,
+            default: 1,
+            description: 'Sharpen intensity (0-10)',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterGrayscale: {
+            type: 'boolean',
+            title: 'Grayscale',
+            default: false,
+            description: 'Convert to grayscale',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterSepia: {
+            type: 'boolean',
+            title: 'Sepia',
+            default: false,
+            description: 'Apply sepia tone effect',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterSepiaIntensity: {
+            type: 'number',
+            title: 'Sepia Intensity',
+            minimum: 0,
+            maximum: 100,
+            default: 80,
+            description: 'Sepia effect intensity (0-100%)',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterBrightness: {
+            type: 'boolean',
+            title: 'Adjust Brightness',
+            default: false,
+            description: 'Adjust image brightness',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterBrightnessValue: {
+            type: 'number',
+            title: 'Brightness',
+            minimum: -100,
+            maximum: 100,
+            default: 0,
+            description: 'Brightness adjustment (-100 to 100, 0 = no change)',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterContrast: {
+            type: 'boolean',
+            title: 'Adjust Contrast',
+            default: false,
+            description: 'Adjust image contrast',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        filterContrastValue: {
+            type: 'number',
+            title: 'Contrast',
+            minimum: -100,
+            maximum: 100,
+            default: 0,
+            description: 'Contrast adjustment (-100 to 100, 0 = no change)',
+            showWhen: { field: 'operation', value: 'filter' }
+        },
+        watermarkType: {
+            type: 'string',
+            title: 'Watermark Type',
+            enum: ['text', 'image'],
+            enumLabels: ['Text Watermark', 'Image Watermark'],
+            default: 'text',
+            description: 'Type of watermark to apply',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkText: {
+            type: 'string',
+            title: 'Watermark Text',
+            description: 'Text to use as watermark. Supports {{variable}} interpolation.',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkFont: {
+            type: 'string',
+            title: 'Font',
+            default: 'Arial',
+            description: 'Font name for text watermark',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkFontSize: {
+            type: 'number',
+            title: 'Font Size',
+            minimum: 8,
+            maximum: 500,
+            default: 48,
+            description: 'Font size in points',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkColor: {
+            type: 'string',
+            title: 'Text Color',
+            default: 'white',
+            description: 'Text color (name or hex)',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkImageSource: {
+            type: 'string',
+            title: 'Watermark Image Source',
+            enum: ['base64', 'filePath', 'url'],
+            enumLabels: ['Base64 Data', 'File Path', 'URL'],
+            default: 'filePath',
+            description: 'Source type for watermark image',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkImageBase64: {
+            type: 'string',
+            title: 'Watermark Image Base64',
+            description: 'Base64-encoded watermark image',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkImagePath: {
+            type: 'string',
+            title: 'Watermark Image Path',
+            description: 'Path to watermark image file. Supports {{variable}} interpolation.',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkImageUrl: {
+            type: 'string',
+            title: 'Watermark Image URL',
+            description: 'URL to download watermark image from. Supports {{variable}} interpolation.',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkPosition: {
+            type: 'string',
+            title: 'Position',
+            enum: ['center', 'northwest', 'north', 'northeast', 'west', 'east', 'southwest', 'south', 'southeast'],
+            enumLabels: ['Center', 'Top Left', 'Top Center', 'Top Right', 'Left', 'Right', 'Bottom Left', 'Bottom Center', 'Bottom Right'],
+            default: 'center',
+            description: 'Position of the watermark on the image',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkOpacity: {
+            type: 'number',
+            title: 'Opacity',
+            minimum: 0,
+            maximum: 100,
+            default: 50,
+            description: 'Watermark opacity (0-100%)',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkMargin: {
+            type: 'number',
+            title: 'Margin',
+            minimum: 0,
+            maximum: 500,
+            default: 10,
+            description: 'Margin from edge in pixels',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
+        watermarkScale: {
+            type: 'number',
+            title: 'Scale (%)',
+            minimum: 1,
+            maximum: 100,
+            default: 20,
+            description: 'Scale watermark image as percentage of main image width',
+            showWhen: { field: 'operation', value: 'watermark' }
+        },
         outputFormat: {
             type: 'string',
             title: 'Output Format',
@@ -345,6 +533,34 @@ const outputSchema = {
         sourcePath: {
             type: 'string',
             description: 'Temporary file path used for processing'
+        },
+        filtersApplied: {
+            type: 'array',
+            description: 'List of filters applied (for filter operation)',
+            items: { type: 'string' }
+        },
+        filterSettings: {
+            type: 'object',
+            description: 'Detailed filter settings applied',
+            properties: {
+                blur: { type: 'object', properties: { enabled: { type: 'boolean' }, radius: { type: 'number' } } },
+                sharpen: { type: 'object', properties: { enabled: { type: 'boolean' }, amount: { type: 'number' } } },
+                grayscale: { type: 'boolean' },
+                sepia: { type: 'object', properties: { enabled: { type: 'boolean' }, intensity: { type: 'number' } } },
+                brightness: { type: 'object', properties: { enabled: { type: 'boolean' }, value: { type: 'number' } } },
+                contrast: { type: 'object', properties: { enabled: { type: 'boolean' }, value: { type: 'number' } } }
+            }
+        },
+        watermark: {
+            type: 'object',
+            description: 'Watermark details applied',
+            properties: {
+                type: { type: 'string', description: 'text or image' },
+                position: { type: 'string' },
+                opacity: { type: 'number' },
+                text: { type: 'string', description: 'Text used (for text watermark)' },
+                imagePath: { type: 'string', description: 'Path to watermark image (for image watermark)' }
+            }
         }
     }
 };
@@ -1070,11 +1286,317 @@ function executeRotateOperation(imagePath, config, timeout) {
     };
 }
 
+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 = '';
+
+    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);
+    }
+
+    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);
+    }
+
+    if (config.filterGrayscale) {
+        filterArgs += ' -colorspace Gray';
+        filtersApplied.push('grayscale');
+        filterSettings.grayscale = true;
+        smartbotic.log.info('Applying grayscale conversion');
+    }
+
+    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 + '%');
+    }
+
+    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);
+    }
+
+    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);
+    }
+
+    if (filtersApplied.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
+        }
+    };
+}
+
+function prepareWatermarkImage(config, input, timeout) {
+    var watermarkType = config.watermarkType || 'text';
+
+    if (watermarkType !== 'image') {
+        return null;
+    }
+
+    var source = config.watermarkImageSource || 'filePath';
+    var tempPath = null;
+
+    if (source === 'filePath') {
+        var filePath = interpolate(config.watermarkImagePath, input);
+        if (!filePath) {
+            throw new Error('Watermark image path is required');
+        }
+        if (!smartbotic.fs.exists(filePath)) {
+            throw new Error('Watermark image file not found: ' + filePath);
+        }
+        return { path: filePath, tempPath: null };
+    }
+
+    if (source === 'base64') {
+        var base64Data = config.watermarkImageBase64;
+        if (!base64Data) {
+            throw new Error('Watermark image base64 data is required');
+        }
+        var ext = detectImageFormat(base64Data) || 'png';
+        tempPath = generateTempPath('watermark-' + ext);
+        var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64');
+        if (!writeResult.success) {
+            throw new Error('Failed to write watermark temp file: ' + (writeResult.error || 'Unknown error'));
+        }
+        return { path: tempPath, tempPath: tempPath };
+    }
+
+    if (source === 'url') {
+        var url = interpolate(config.watermarkImageUrl, input);
+        if (!url) {
+            throw new Error('Watermark image URL is required');
+        }
+        smartbotic.log.info('Downloading watermark image from URL: ' + url);
+        var response = smartbotic.http.request({
+            method: 'GET',
+            url: url,
+            timeout: timeout
+        });
+        if (response.status < 200 || response.status >= 300) {
+            throw new Error('Failed to download watermark image: HTTP ' + response.status);
+        }
+        var contentType = response.headers['content-type'] || '';
+        var ext = 'png';
+        if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
+        else if (contentType.includes('gif')) ext = 'gif';
+        else if (contentType.includes('webp')) ext = 'webp';
+
+        tempPath = generateTempPath('watermark-' + ext);
+        var base64 = smartbotic.utils.base64Encode(response.data);
+        var writeResult = smartbotic.fs.writeFile(tempPath, base64, 'base64');
+        if (!writeResult.success) {
+            throw new Error('Failed to write downloaded watermark: ' + (writeResult.error || 'Unknown error'));
+        }
+        return { path: tempPath, tempPath: tempPath };
+    }
+
+    throw new Error('Invalid watermark image source: ' + source);
+}
+
+function executeWatermarkOperation(imagePath, config, input, timeout) {
+    var dimensions = getImageDimensions(imagePath, timeout);
+    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 watermarkImageInfo = null;
+    var cmd;
+
+    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 + '%');
+    } else {
+        watermarkImageInfo = prepareWatermarkImage(config, input, timeout);
+        var watermarkPath = watermarkImageInfo.path;
+        watermarkInfo.imagePath = watermarkPath;
+
+        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';
+        }
+        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);
+    }
+
+    if (!result.success) {
+        cleanupOutputFile(outputPath);
+        throw new Error('Watermark operation failed: ' + (result.stderr || 'Unknown error'));
+    }
+
+    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
+        }
+    };
+}
+
 async function execute(config, input) {
     var operation = config.operation || 'info';
     var timeout = config.timeout || 30000;
 
-    var supportedOperations = ['info', 'resize', 'crop', 'rotate'];
+    var supportedOperations = ['info', 'resize', 'crop', 'rotate', 'filter', 'watermark'];
     if (supportedOperations.indexOf(operation) < 0) {
         throw new Error('Operation "' + operation + '" is not yet implemented. Supported: ' + supportedOperations.join(', '));
     }
@@ -1102,6 +1624,10 @@ async function execute(config, input) {
             result = executeCropOperation(fileInfo.path, config, timeout);
         } else if (operation === 'rotate') {
             result = executeRotateOperation(fileInfo.path, config, timeout);
+        } else if (operation === 'filter') {
+            result = executeFilterOperation(fileInfo.path, config, input, timeout);
+        } else if (operation === 'watermark') {
+            result = executeWatermarkOperation(fileInfo.path, config, input, timeout);
         }
 
         result.sourceType = imageInput.type;