|
@@ -2,8 +2,8 @@
|
|
|
* @node image
|
|
* @node image
|
|
|
* @name Image
|
|
* @name Image
|
|
|
* @category media
|
|
* @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
|
|
* @icon image
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
@@ -13,8 +13,8 @@ const configSchema = {
|
|
|
operation: {
|
|
operation: {
|
|
|
type: 'string',
|
|
type: 'string',
|
|
|
title: 'Operation',
|
|
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',
|
|
default: 'info',
|
|
|
description: 'Image operation to perform'
|
|
description: 'Image operation to perform'
|
|
|
},
|
|
},
|
|
@@ -157,6 +157,194 @@ const configSchema = {
|
|
|
description: 'Fill color for areas exposed by rotation (name, hex, or transparent)',
|
|
description: 'Fill color for areas exposed by rotation (name, hex, or transparent)',
|
|
|
showWhen: { field: 'operation', value: 'rotate' }
|
|
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: {
|
|
outputFormat: {
|
|
|
type: 'string',
|
|
type: 'string',
|
|
|
title: 'Output Format',
|
|
title: 'Output Format',
|
|
@@ -345,6 +533,34 @@ const outputSchema = {
|
|
|
sourcePath: {
|
|
sourcePath: {
|
|
|
type: 'string',
|
|
type: 'string',
|
|
|
description: 'Temporary file path used for processing'
|
|
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) {
|
|
async function execute(config, input) {
|
|
|
var operation = config.operation || 'info';
|
|
var operation = config.operation || 'info';
|
|
|
var timeout = config.timeout || 30000;
|
|
var timeout = config.timeout || 30000;
|
|
|
|
|
|
|
|
- var supportedOperations = ['info', 'resize', 'crop', 'rotate'];
|
|
|
|
|
|
|
+ var supportedOperations = ['info', 'resize', 'crop', 'rotate', 'filter', 'watermark'];
|
|
|
if (supportedOperations.indexOf(operation) < 0) {
|
|
if (supportedOperations.indexOf(operation) < 0) {
|
|
|
throw new Error('Operation "' + operation + '" is not yet implemented. Supported: ' + supportedOperations.join(', '));
|
|
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);
|
|
result = executeCropOperation(fileInfo.path, config, timeout);
|
|
|
} else if (operation === 'rotate') {
|
|
} else if (operation === 'rotate') {
|
|
|
result = executeRotateOperation(fileInfo.path, config, timeout);
|
|
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;
|
|
result.sourceType = imageInput.type;
|