/** * @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: { 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: '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', 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)' } } } } }; 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 parseImageMagickInfo(output) { var info = {}; var lines = output.split('\n'); for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line) continue; var colonIdx = line.indexOf(':'); if (colonIdx > 0) { var key = line.substring(0, colonIdx).trim().toLowerCase(); var value = line.substring(colonIdx + 1).trim(); if (key === 'image' || key === 'format') { var formatMatch = value.match(/^(\w+)/); if (formatMatch) info.format = formatMatch[1].toUpperCase(); } else if (key === 'geometry' || key === 'page geometry') { var geomMatch = value.match(/(\d+)x(\d+)/); if (geomMatch) { info.width = parseInt(geomMatch[1], 10); info.height = parseInt(geomMatch[2], 10); } } else if (key === 'colorspace') { info.colorSpace = value; } else if (key === 'depth') { var depthMatch = value.match(/(\d+)/); if (depthMatch) info.depth = parseInt(depthMatch[1], 10); } else if (key === 'channel depth' || key === 'channels') { if (value.toLowerCase().includes('alpha')) { info.hasAlpha = true; } } else if (key === 'alpha') { info.hasAlpha = value.toLowerCase() !== 'undefined' && value.toLowerCase() !== 'off'; } else if (key === 'compression') { info.compression = value; } else if (key === 'resolution' || key === 'units') { if (!info.dpi) info.dpi = {}; var resMatch = value.match(/([\d.]+)x([\d.]+)/); if (resMatch) { info.dpi.x = parseFloat(resMatch[1]); info.dpi.y = parseFloat(resMatch[2]); } if (value.includes('PixelsPerInch')) info.dpi.unit = 'dpi'; else if (value.includes('PixelsPerCentimeter')) info.dpi.unit = 'dpcm'; } else if (key === 'filesize') { var sizeMatch = value.match(/([\d.]+)\s*(\w+)/); if (sizeMatch) { var size = parseFloat(sizeMatch[1]); var unit = sizeMatch[2].toUpperCase(); if (unit === 'B') info.fileSize = Math.round(size); else if (unit === 'KB' || unit === 'KIB') info.fileSize = Math.round(size * 1024); else if (unit === 'MB' || unit === 'MIB') info.fileSize = Math.round(size * 1024 * 1024); else if (unit === 'GB' || unit === 'GIB') info.fileSize = Math.round(size * 1024 * 1024 * 1024); } } } } if (info.hasAlpha === undefined) info.hasAlpha = false; return info; } function parseExifToolOutput(output) { var exif = { camera: {}, datetime: {}, gps: {}, exposure: {}, lens: {} }; var lines = output.split('\n'); for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line) continue; var colonIdx = line.indexOf(':'); if (colonIdx <= 0) continue; var key = line.substring(0, colonIdx).trim().toLowerCase().replace(/\s+/g, ''); var value = line.substring(colonIdx + 1).trim(); if (!value || value === '-' || value === 'n/a') continue; if (key === 'make' || key === 'cameramake') exif.camera.make = value; else if (key === 'model' || key === 'cameramodel' || key === 'cameramodelname') exif.camera.model = value; else if (key === 'software') exif.camera.software = value; else if (key === 'datetimeoriginal' || key === 'createdate') exif.datetime.original = value; else if (key === 'datetimedigitized' || key === 'digitizeddate') exif.datetime.digitized = value; else if (key === 'modifydate' || key === 'datetime') exif.datetime.modified = value; else if (key === 'gpslatitude') { var latMatch = value.match(/([\d.]+)/); if (latMatch) { exif.gps.latitude = parseFloat(latMatch[1]); if (value.includes('S')) exif.gps.latitude = -exif.gps.latitude; exif.gps.latitudeRef = value.includes('S') ? 'S' : 'N'; } } else if (key === 'gpslongitude') { var lonMatch = value.match(/([\d.]+)/); if (lonMatch) { exif.gps.longitude = parseFloat(lonMatch[1]); if (value.includes('W')) exif.gps.longitude = -exif.gps.longitude; exif.gps.longitudeRef = value.includes('W') ? 'W' : 'E'; } } else if (key === 'gpsaltitude') { var altMatch = value.match(/([\d.]+)/); if (altMatch) exif.gps.altitude = parseFloat(altMatch[1]); } else if (key === 'exposuretime' || key === 'shutterspeed') exif.exposure.time = value; else if (key === 'fnumber' || key === 'aperture') { var fMatch = value.match(/([\d.]+)/); if (fMatch) exif.exposure.fNumber = parseFloat(fMatch[1]); } else if (key === 'iso' || key === 'isospeedratings') { var isoMatch = value.match(/(\d+)/); if (isoMatch) exif.exposure.iso = parseInt(isoMatch[1], 10); } else if (key === 'exposureprogram') exif.exposure.program = value; else if (key === 'exposurecompensation' || key === 'exposurebias') exif.exposure.bias = value; else if (key === 'focallength') exif.lens.focalLength = value; else if (key === 'focallengthin35mmformat' || key === 'focallength35efl') { var fl35Match = value.match(/(\d+)/); if (fl35Match) exif.lens.focalLength35mm = parseInt(fl35Match[1], 10); } else if (key === 'maxaperturevalue') { var apMatch = value.match(/([\d.]+)/); if (apMatch) exif.lens.aperture = parseFloat(apMatch[1]); } else if (key === 'lensmake') exif.lens.make = value; else if (key === 'lensmodel' || key === 'lens') exif.lens.model = value; else if (key === 'orientation') { var oriMatch = value.match(/(\d)/); if (oriMatch) exif.orientation = parseInt(oriMatch[1], 10); } else if (key === 'flash') exif.flash = value; else if (key === 'whitebalance') exif.whiteBalance = value; else if (key === 'meteringmode') exif.meteringMode = value; } var hasData = false; for (var section in exif) { if (typeof exif[section] === 'object') { for (var field in exif[section]) { if (exif[section][field] !== undefined) { hasData = true; break; } } } else if (exif[section] !== undefined) { hasData = true; } if (hasData) break; } return hasData ? exif : null; } function executeInfoOperation(imagePath, includeExif, timeout) { smartbotic.log.info('Running ImageMagick identify on: ' + imagePath); var identifyCmd = 'identify -verbose "' + imagePath.replace(/"/g, '\\"') + '"'; var identifyResult = smartbotic.process.exec(identifyCmd, { timeout: timeout }); if (!identifyResult.success) { throw new Error('ImageMagick identify failed: ' + (identifyResult.stderr || 'Unknown error')); } var info = parseImageMagickInfo(identifyResult.stdout); var stat = smartbotic.fs.stat(imagePath); if (stat.success && !info.fileSize) { info.fileSize = stat.size; } if (includeExif) { smartbotic.log.info('Extracting EXIF metadata'); var exiftoolCmd = 'exiftool "' + imagePath.replace(/"/g, '\\"') + '"'; var exifResult = smartbotic.process.exec(exiftoolCmd, { timeout: timeout }); if (exifResult.success && exifResult.stdout) { var exifData = parseExifToolOutput(exifResult.stdout); if (exifData) { info.exif = exifData; } } else { smartbotic.log.debug('No EXIF data available or exiftool not installed'); } } return info; } function getImageDimensions(imagePath, timeout) { var cmd = 'identify -format "%w %h" "' + imagePath.replace(/"/g, '\\"') + '"'; var result = smartbotic.process.exec(cmd, { timeout: timeout }); if (!result.success) { throw new Error('Failed to get image dimensions: ' + (result.stderr || 'Unknown error')); } var parts = result.stdout.trim().split(' '); return { width: parseInt(parts[0], 10), height: parseInt(parts[1], 10) }; } 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 } }; } 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', '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, 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); } 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.sourcePath = fileInfo.path; return result; } finally { if (fileInfo && fileInfo.tempPath) { cleanupTempFile(fileInfo.tempPath); } } } module.exports = { configSchema, inputSchema, outputSchema, execute };