/** * @node image * @name Image * @category media * @version 1.2.0 * @description Image manipulation, filters, watermarks, and metadata extraction using ImageMagick * @icon image */ const configSchema = { type: 'object', properties: { operation: { type: 'string', title: 'Operation', enum: ['info', 'resize', 'crop', 'rotate', 'filter', 'watermark'], enumLabels: ['Info', 'Resize', 'Crop', 'Rotate', 'Filter', 'Watermark'], default: 'info', description: 'Image operation to perform' }, inputSource: { type: 'string', title: 'Input Source', enum: ['auto', 'base64', 'filePath', 'url', 'storage'], enumLabels: ['Auto-detect', 'Base64 Data', 'File Path', 'URL', 'Storage Reference'], default: 'auto', description: 'Source type for the input image' }, base64Data: { type: 'string', title: 'Base64 Image Data', description: 'Base64-encoded image data. Supports {{variable}} interpolation.', showWhen: { field: 'inputSource', value: 'base64' } }, filePath: { type: 'string', title: 'File Path', description: 'Path to image file on runner filesystem. Supports {{variable}} interpolation.', showWhen: { field: 'inputSource', value: 'filePath' } }, url: { type: 'string', title: 'Image URL', description: 'URL to download image from. Supports {{variable}} interpolation.', showWhen: { field: 'inputSource', value: 'url' } }, storageCollection: { dynamicOptions: { source: 'storage.collections', labelField: 'name', valueField: 'name', filter: {} }, type: 'string', title: 'Storage Collection', description: 'Collection name for stored image', showWhen: { field: 'inputSource', value: 'storage' } }, storageId: { type: 'string', title: 'Storage Document ID', description: 'Document ID in storage collection. Supports {{variable}} interpolation.', showWhen: { field: 'inputSource', value: 'storage' } }, includeExif: { type: 'boolean', title: 'Include EXIF Metadata', default: true, 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' } }, 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: '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: { 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', 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)', default: 30000, minimum: 1000, maximum: 120000, description: 'Maximum time to wait for image processing' } }, required: ['operation'] }; const inputSchema = { type: 'object', properties: { data: { type: 'any', description: 'Input data. Can be base64 string, file path, URL, or binary object from another node.' }, file: { type: 'object', description: 'Binary file object with type, data (base64), mimeType, filename' }, storage: { type: 'object', description: 'Storage reference with collection and id' }, base64: { type: 'string', description: 'Base64-encoded image data' }, filePath: { type: 'string', description: 'Override file path from input' }, url: { type: 'string', description: 'Override URL from input' } } }; const outputSchema = { type: 'object', properties: { width: { type: 'number', description: 'Image width in pixels' }, height: { type: 'number', description: 'Image height in pixels' }, format: { type: 'string', description: 'Image format (JPEG, PNG, GIF, etc.)' }, colorSpace: { type: 'string', description: 'Color space (sRGB, CMYK, Grayscale, etc.)' }, depth: { type: 'number', description: 'Bit depth per channel' }, fileSize: { type: 'number', description: 'File size in bytes' }, dpi: { type: 'object', description: 'DPI/resolution information', properties: { x: { type: 'number' }, y: { type: 'number' }, unit: { type: 'string' } } }, hasAlpha: { type: 'boolean', description: 'Whether image has alpha/transparency channel' }, compression: { type: 'string', description: 'Compression type used' }, exif: { type: 'object', description: 'EXIF metadata (if available)', properties: { camera: { type: 'object', properties: { make: { type: 'string' }, model: { type: 'string' }, software: { type: 'string' } } }, datetime: { type: 'object', properties: { original: { type: 'string' }, digitized: { type: 'string' }, modified: { type: 'string' } } }, gps: { type: 'object', properties: { latitude: { type: 'number' }, longitude: { type: 'number' }, altitude: { type: 'number' }, latitudeRef: { type: 'string' }, longitudeRef: { type: 'string' } } }, exposure: { type: 'object', properties: { time: { type: 'string' }, fNumber: { type: 'number' }, iso: { type: 'number' }, program: { type: 'string' }, bias: { type: 'string' } } }, lens: { type: 'object', properties: { focalLength: { type: 'string' }, focalLength35mm: { type: 'number' }, aperture: { type: 'number' }, make: { type: 'string' }, model: { type: 'string' } } }, orientation: { type: 'number' }, flash: { type: 'string' }, whiteBalance: { type: 'string' }, 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)' }, 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)' } } }, cropRegion: { type: 'object', description: 'The region that was cut out, for the crop operation', properties: { x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' } } }, angle: { type: 'number', description: 'Degrees rotated, for the rotate operation' }, backgroundColor: { type: 'string', description: 'Fill color used behind the rotated image, for the rotate operation' } } }; function interpolate(template, data) { if (typeof template !== 'string') return template; return template.replace(/\{\{([^}]+)\}\}/g, function(match, key) { var keys = key.trim().split('.'); var value = data; for (var i = 0; i < keys.length; i++) { if (value && typeof value === 'object' && keys[i] in value) { value = value[keys[i]]; } else { return match; } } return value !== undefined ? String(value) : match; }); } function generateTempPath(extension) { var uuid = smartbotic.utils.uuid(); return '/tmp/smartbotic-image-' + uuid + (extension ? '.' + extension : ''); } function detectImageFormat(base64Data) { if (!base64Data || base64Data.length < 8) return null; var header = smartbotic.utils.base64Decode(base64Data.substring(0, 16)); if (!header) return null; if (header.charCodeAt(0) === 0xFF && header.charCodeAt(1) === 0xD8 && header.charCodeAt(2) === 0xFF) { return 'jpg'; } if (header.substring(0, 8) === '\x89PNG\r\n\x1a\n') { return 'png'; } if (header.substring(0, 6) === 'GIF87a' || header.substring(0, 6) === 'GIF89a') { return 'gif'; } if (header.substring(0, 4) === 'RIFF' && header.substring(8, 12) === 'WEBP') { return 'webp'; } if (header.substring(0, 2) === 'BM') { return 'bmp'; } if (header.substring(0, 4) === 'II*\x00' || header.substring(0, 4) === 'MM\x00*') { return 'tiff'; } return null; } function resolveImageInput(config, input) { var inputSource = config.inputSource || 'auto'; var result = { type: null, data: null, path: null, tempPath: null }; if (inputSource === 'filePath' || (inputSource === 'auto' && input.filePath)) { var filePath = input.filePath || interpolate(config.filePath, input); if (filePath && smartbotic.fs.exists(filePath)) { result.type = 'filePath'; result.path = filePath; return result; } } if (inputSource === 'base64' || (inputSource === 'auto' && (input.base64 || config.base64Data))) { var base64 = input.base64 || interpolate(config.base64Data, input); if (base64) { result.type = 'base64'; result.data = base64; return result; } } if (inputSource === 'url' || (inputSource === 'auto' && (input.url || config.url))) { var url = input.url || interpolate(config.url, input); if (url) { result.type = 'url'; result.data = url; return result; } } if (inputSource === 'storage' || (inputSource === 'auto' && input.storage)) { var collection = input.storage ? input.storage.collection : config.storageCollection; var id = input.storage ? input.storage.id : interpolate(config.storageId, input); if (collection && id) { result.type = 'storage'; result.data = { collection: collection, id: id }; return result; } } if (inputSource === 'auto') { if (input.file && input.file.type === 'binary' && input.file.data) { result.type = 'base64'; result.data = input.file.data; return result; } if (typeof input.data === 'string') { if (input.data.match(/^[A-Za-z0-9+/]+=*$/) && input.data.length > 100) { result.type = 'base64'; result.data = input.data; return result; } if (input.data.startsWith('http://') || input.data.startsWith('https://')) { result.type = 'url'; result.data = input.data; return result; } if (smartbotic.fs.exists(input.data)) { result.type = 'filePath'; result.path = input.data; return result; } } if (input.data && typeof input.data === 'object') { if (input.data.type === 'binary' && input.data.data) { result.type = 'base64'; result.data = input.data.data; return result; } if (input.data.collection && input.data.id) { result.type = 'storage'; result.data = input.data; return result; } } } return result; } function prepareImageFile(imageInput, timeout) { var tempPath = null; if (imageInput.type === 'filePath') { return { path: imageInput.path, tempPath: null }; } if (imageInput.type === 'base64') { var ext = detectImageFormat(imageInput.data) || 'img'; tempPath = generateTempPath(ext); var writeResult = smartbotic.fs.writeFile(tempPath, imageInput.data, 'base64'); if (!writeResult.success) { throw new Error('Failed to write temp file: ' + (writeResult.error || 'Unknown error')); } return { path: tempPath, tempPath: tempPath }; } if (imageInput.type === 'url') { smartbotic.log.info('Downloading image from URL: ' + imageInput.data); var response = smartbotic.http.request({ method: 'GET', url: imageInput.data, timeout: timeout }); if (response.status < 200 || response.status >= 300) { throw new Error('Failed to download image: HTTP ' + response.status); } var contentType = response.headers['content-type'] || ''; var ext = 'img'; if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg'; else if (contentType.includes('png')) ext = 'png'; else if (contentType.includes('gif')) ext = 'gif'; else if (contentType.includes('webp')) ext = 'webp'; tempPath = generateTempPath(ext); var base64Data = smartbotic.utils.base64Encode(response.data); var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64'); if (!writeResult.success) { throw new Error('Failed to write downloaded image: ' + (writeResult.error || 'Unknown error')); } return { path: tempPath, tempPath: tempPath }; } if (imageInput.type === 'storage') { var storageResult = smartbotic.storage.get(imageInput.data.collection, imageInput.data.id); if (!storageResult.found) { throw new Error('Image not found in storage: ' + imageInput.data.collection + '/' + imageInput.data.id); } var doc = storageResult.document; var base64Data = doc.data || doc.content || doc.base64; if (!base64Data) { throw new Error('Storage document does not contain image data'); } var ext = detectImageFormat(base64Data) || 'img'; tempPath = generateTempPath(ext); var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64'); if (!writeResult.success) { throw new Error('Failed to write storage image: ' + (writeResult.error || 'Unknown error')); } return { path: tempPath, tempPath: tempPath }; } throw new Error('No valid image input found'); } function cleanupTempFile(tempPath) { if (tempPath) { try { smartbotic.fs.unlink(tempPath); } catch (e) { smartbotic.log.warn('Failed to cleanup temp file: ' + tempPath); } } } function executeInfoOperation(imagePath, includeExif) { smartbotic.log.info('Reading image metadata: ' + imagePath); var probe = smartbotic.image.info(imagePath, includeExif === true); if (!probe.success) { throw new Error(probe.error); } // 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 getImageDimensions(imagePath) { var probe = smartbotic.image.info(imagePath, false); if (!probe.success) { throw new Error('Failed to read the image: ' + probe.error); } return { width: probe.info.width, height: probe.info.height }; } 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'; } // 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; } 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 executeResizeOperation(imagePath, config) { var dimensions = getImageDimensions(imagePath); 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 outputPath = buildOutputPath(outputFormat); return runTransform(imagePath, outputPath, [{ op: 'resize', width: targetWidth, height: targetHeight, // 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) { var dimensions = getImageDimensions(imagePath); 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)); } var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath); var outputPath = buildOutputPath(outputFormat); return runTransform(imagePath, outputPath, [ { op: 'crop', x: cropX, y: cropY, width: cropWidth, height: cropHeight } ], outputFormat, config); } function executeRotateOperation(imagePath, config) { var rotatePreset = config.rotatePreset || 'custom'; var angle = rotatePreset === 'custom' ? config.rotateAngle : Number(rotatePreset); if (angle === undefined || angle === null || isNaN(Number(angle))) { throw new Error('Rotate operation requires an angle'); } var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath); var outputPath = buildOutputPath(outputFormat); return runTransform(imagePath, outputPath, [ { op: 'rotate', angle: Number(angle), background: config.backgroundColor || 'transparent' } ], outputFormat, config); } function executeFilterOperation(imagePath, config) { var operations = []; if (config.filterBlur) { operations.push({ op: 'blur', radius: config.filterBlurRadius || 5 }); } if (config.filterSharpen) { operations.push({ op: 'sharpen', amount: config.filterSharpenAmount || 1 }); } if (config.filterGrayscale) { operations.push({ op: 'grayscale' }); } if (config.filterSepia) { operations.push({ op: 'sepia', intensity: config.filterSepiaIntensity || 80 }); } if (config.filterBrightness) { operations.push({ op: 'brightness', value: config.filterBrightnessValue || 0 }); } if (config.filterContrast) { operations.push({ op: 'contrast', value: config.filterContrastValue || 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 outputPath = buildOutputPath(outputFormat); return runTransform(imagePath, outputPath, operations, outputFormat, config); } 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); var originalWidth = dimensions.width; var watermarkType = config.watermarkType || 'text'; var position = config.watermarkPosition || 'center'; var opacity = config.watermarkOpacity !== undefined ? config.watermarkOpacity : 50; var margin = config.watermarkMargin || 10; var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath); var outputPath = buildOutputPath(outputFormat); var watermarkInfo = { type: watermarkType, position: position, opacity: opacity }; var watermarkImageInfo = null; var operation; if (watermarkType === 'text') { var text = interpolate(config.watermarkText, input); if (!text) { throw new Error('Watermark text is required'); } watermarkInfo.text = text; // 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); watermarkInfo.imagePath = watermarkImageInfo.path; var scale = config.watermarkScale || 20; var watermarkWidth = Math.round(originalWidth * scale / 100); // 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); } 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 + '%'); } var result = runTransform(imagePath, outputPath, [operation], outputFormat, config); result.watermark = watermarkInfo; if (watermarkImageInfo && watermarkImageInfo.cleanup) { smartbotic.fs.unlink(watermarkImageInfo.path); } return result; } async function execute(config, input) { var operation = config.operation || 'info'; var timeout = config.timeout || 30000; 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(', ')); } var imageInput = resolveImageInput(config, input); if (!imageInput.type) { throw new Error('No valid image input found. Provide base64 data, file path, URL, or storage reference.'); } smartbotic.log.info('Image input source: ' + imageInput.type); var fileInfo = null; try { fileInfo = prepareImageFile(imageInput, timeout); smartbotic.log.info('Processing image: ' + fileInfo.path); var result; if (operation === 'info') { result = executeInfoOperation(fileInfo.path, config.includeExif !== false); } else if (operation === 'resize') { result = executeResizeOperation(fileInfo.path, config); } else if (operation === 'crop') { result = executeCropOperation(fileInfo.path, config); } else if (operation === 'rotate') { result = executeRotateOperation(fileInfo.path, config); } else if (operation === 'filter') { result = executeFilterOperation(fileInfo.path, config); } else if (operation === 'watermark') { result = executeWatermarkOperation(fileInfo.path, config, input, timeout); } result.sourceType = imageInput.type; result.sourcePath = fileInfo.path; return result; } finally { if (fileInfo && fileInfo.tempPath) { cleanupTempFile(fileInfo.tempPath); } } } module.exports = { configSchema, inputSchema, outputSchema, execute };