image.js 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644
  1. /**
  2. * @node image
  3. * @name Image
  4. * @category media
  5. * @version 1.2.0
  6. * @description Image manipulation, filters, watermarks, and metadata extraction using ImageMagick
  7. * @icon image
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. operation: {
  13. type: 'string',
  14. title: 'Operation',
  15. enum: ['info', 'resize', 'crop', 'rotate', 'filter', 'watermark'],
  16. enumLabels: ['Info', 'Resize', 'Crop', 'Rotate', 'Filter', 'Watermark'],
  17. default: 'info',
  18. description: 'Image operation to perform'
  19. },
  20. inputSource: {
  21. type: 'string',
  22. title: 'Input Source',
  23. enum: ['auto', 'base64', 'filePath', 'url', 'storage'],
  24. enumLabels: ['Auto-detect', 'Base64 Data', 'File Path', 'URL', 'Storage Reference'],
  25. default: 'auto',
  26. description: 'Source type for the input image'
  27. },
  28. base64Data: {
  29. type: 'string',
  30. title: 'Base64 Image Data',
  31. description: 'Base64-encoded image data. Supports {{variable}} interpolation.',
  32. showWhen: { field: 'inputSource', value: 'base64' }
  33. },
  34. filePath: {
  35. type: 'string',
  36. title: 'File Path',
  37. description: 'Path to image file on runner filesystem. Supports {{variable}} interpolation.',
  38. showWhen: { field: 'inputSource', value: 'filePath' }
  39. },
  40. url: {
  41. type: 'string',
  42. title: 'Image URL',
  43. description: 'URL to download image from. Supports {{variable}} interpolation.',
  44. showWhen: { field: 'inputSource', value: 'url' }
  45. },
  46. storageCollection: {
  47. type: 'string',
  48. title: 'Storage Collection',
  49. description: 'Collection name for stored image',
  50. showWhen: { field: 'inputSource', value: 'storage' }
  51. },
  52. storageId: {
  53. type: 'string',
  54. title: 'Storage Document ID',
  55. description: 'Document ID in storage collection. Supports {{variable}} interpolation.',
  56. showWhen: { field: 'inputSource', value: 'storage' }
  57. },
  58. includeExif: {
  59. type: 'boolean',
  60. title: 'Include EXIF Metadata',
  61. default: true,
  62. description: 'Extract EXIF metadata (camera info, GPS, date taken, etc.)',
  63. showWhen: { field: 'operation', value: 'info' }
  64. },
  65. resizeMode: {
  66. type: 'string',
  67. title: 'Resize Mode',
  68. enum: ['dimensions', 'percentage'],
  69. enumLabels: ['Dimensions (pixels)', 'Percentage (%)'],
  70. default: 'dimensions',
  71. description: 'How to specify the new size',
  72. showWhen: { field: 'operation', value: 'resize' }
  73. },
  74. resizeWidth: {
  75. type: 'number',
  76. title: 'Width',
  77. minimum: 1,
  78. maximum: 20000,
  79. description: 'Target width in pixels. Leave empty to auto-calculate from height.',
  80. showWhen: { field: 'operation', value: 'resize' }
  81. },
  82. resizeHeight: {
  83. type: 'number',
  84. title: 'Height',
  85. minimum: 1,
  86. maximum: 20000,
  87. description: 'Target height in pixels. Leave empty to auto-calculate from width.',
  88. showWhen: { field: 'operation', value: 'resize' }
  89. },
  90. resizePercentage: {
  91. type: 'number',
  92. title: 'Scale Percentage',
  93. minimum: 1,
  94. maximum: 1000,
  95. default: 100,
  96. description: 'Scale percentage (100 = original size, 50 = half, 200 = double)',
  97. showWhen: { field: 'operation', value: 'resize' }
  98. },
  99. maintainAspectRatio: {
  100. type: 'boolean',
  101. title: 'Maintain Aspect Ratio',
  102. default: true,
  103. description: 'Keep original proportions when resizing',
  104. showWhen: { field: 'operation', value: 'resize' }
  105. },
  106. cropX: {
  107. type: 'number',
  108. title: 'X Offset',
  109. minimum: 0,
  110. default: 0,
  111. description: 'Horizontal offset from left edge in pixels',
  112. showWhen: { field: 'operation', value: 'crop' }
  113. },
  114. cropY: {
  115. type: 'number',
  116. title: 'Y Offset',
  117. minimum: 0,
  118. default: 0,
  119. description: 'Vertical offset from top edge in pixels',
  120. showWhen: { field: 'operation', value: 'crop' }
  121. },
  122. cropWidth: {
  123. type: 'number',
  124. title: 'Crop Width',
  125. minimum: 1,
  126. description: 'Width of the crop region in pixels',
  127. showWhen: { field: 'operation', value: 'crop' }
  128. },
  129. cropHeight: {
  130. type: 'number',
  131. title: 'Crop Height',
  132. minimum: 1,
  133. description: 'Height of the crop region in pixels',
  134. showWhen: { field: 'operation', value: 'crop' }
  135. },
  136. rotateAngle: {
  137. type: 'number',
  138. title: 'Rotation Angle',
  139. default: 90,
  140. description: 'Rotation angle in degrees (positive = clockwise)',
  141. showWhen: { field: 'operation', value: 'rotate' }
  142. },
  143. rotatePreset: {
  144. type: 'string',
  145. title: 'Quick Rotate',
  146. enum: ['custom', '90', '180', '270'],
  147. enumLabels: ['Custom Angle', '90 (CW)', '180', '270 (CCW)'],
  148. default: 'custom',
  149. description: 'Common rotation presets or use custom angle',
  150. showWhen: { field: 'operation', value: 'rotate' }
  151. },
  152. backgroundColor: {
  153. type: 'string',
  154. title: 'Background Color',
  155. default: 'transparent',
  156. description: 'Fill color for areas exposed by rotation (name, hex, or transparent)',
  157. showWhen: { field: 'operation', value: 'rotate' }
  158. },
  159. filterBlur: {
  160. type: 'boolean',
  161. title: 'Blur',
  162. default: false,
  163. description: 'Apply blur filter',
  164. showWhen: { field: 'operation', value: 'filter' }
  165. },
  166. filterBlurRadius: {
  167. type: 'number',
  168. title: 'Blur Radius',
  169. minimum: 0,
  170. maximum: 100,
  171. default: 5,
  172. description: 'Blur radius in pixels (0-100)',
  173. showWhen: { field: 'operation', value: 'filter' }
  174. },
  175. filterSharpen: {
  176. type: 'boolean',
  177. title: 'Sharpen',
  178. default: false,
  179. description: 'Apply sharpen filter',
  180. showWhen: { field: 'operation', value: 'filter' }
  181. },
  182. filterSharpenAmount: {
  183. type: 'number',
  184. title: 'Sharpen Amount',
  185. minimum: 0,
  186. maximum: 10,
  187. default: 1,
  188. description: 'Sharpen intensity (0-10)',
  189. showWhen: { field: 'operation', value: 'filter' }
  190. },
  191. filterGrayscale: {
  192. type: 'boolean',
  193. title: 'Grayscale',
  194. default: false,
  195. description: 'Convert to grayscale',
  196. showWhen: { field: 'operation', value: 'filter' }
  197. },
  198. filterSepia: {
  199. type: 'boolean',
  200. title: 'Sepia',
  201. default: false,
  202. description: 'Apply sepia tone effect',
  203. showWhen: { field: 'operation', value: 'filter' }
  204. },
  205. filterSepiaIntensity: {
  206. type: 'number',
  207. title: 'Sepia Intensity',
  208. minimum: 0,
  209. maximum: 100,
  210. default: 80,
  211. description: 'Sepia effect intensity (0-100%)',
  212. showWhen: { field: 'operation', value: 'filter' }
  213. },
  214. filterBrightness: {
  215. type: 'boolean',
  216. title: 'Adjust Brightness',
  217. default: false,
  218. description: 'Adjust image brightness',
  219. showWhen: { field: 'operation', value: 'filter' }
  220. },
  221. filterBrightnessValue: {
  222. type: 'number',
  223. title: 'Brightness',
  224. minimum: -100,
  225. maximum: 100,
  226. default: 0,
  227. description: 'Brightness adjustment (-100 to 100, 0 = no change)',
  228. showWhen: { field: 'operation', value: 'filter' }
  229. },
  230. filterContrast: {
  231. type: 'boolean',
  232. title: 'Adjust Contrast',
  233. default: false,
  234. description: 'Adjust image contrast',
  235. showWhen: { field: 'operation', value: 'filter' }
  236. },
  237. filterContrastValue: {
  238. type: 'number',
  239. title: 'Contrast',
  240. minimum: -100,
  241. maximum: 100,
  242. default: 0,
  243. description: 'Contrast adjustment (-100 to 100, 0 = no change)',
  244. showWhen: { field: 'operation', value: 'filter' }
  245. },
  246. watermarkType: {
  247. type: 'string',
  248. title: 'Watermark Type',
  249. enum: ['text', 'image'],
  250. enumLabels: ['Text Watermark', 'Image Watermark'],
  251. default: 'text',
  252. description: 'Type of watermark to apply',
  253. showWhen: { field: 'operation', value: 'watermark' }
  254. },
  255. watermarkText: {
  256. type: 'string',
  257. title: 'Watermark Text',
  258. description: 'Text to use as watermark. Supports {{variable}} interpolation.',
  259. showWhen: { field: 'operation', value: 'watermark' }
  260. },
  261. watermarkFont: {
  262. type: 'string',
  263. title: 'Font',
  264. default: 'Arial',
  265. description: 'Font name for text watermark',
  266. showWhen: { field: 'operation', value: 'watermark' }
  267. },
  268. watermarkFontSize: {
  269. type: 'number',
  270. title: 'Font Size',
  271. minimum: 8,
  272. maximum: 500,
  273. default: 48,
  274. description: 'Font size in points',
  275. showWhen: { field: 'operation', value: 'watermark' }
  276. },
  277. watermarkColor: {
  278. type: 'string',
  279. title: 'Text Color',
  280. default: 'white',
  281. description: 'Text color (name or hex)',
  282. showWhen: { field: 'operation', value: 'watermark' }
  283. },
  284. watermarkImageSource: {
  285. type: 'string',
  286. title: 'Watermark Image Source',
  287. enum: ['base64', 'filePath', 'url'],
  288. enumLabels: ['Base64 Data', 'File Path', 'URL'],
  289. default: 'filePath',
  290. description: 'Source type for watermark image',
  291. showWhen: { field: 'operation', value: 'watermark' }
  292. },
  293. watermarkImageBase64: {
  294. type: 'string',
  295. title: 'Watermark Image Base64',
  296. description: 'Base64-encoded watermark image',
  297. showWhen: { field: 'operation', value: 'watermark' }
  298. },
  299. watermarkImagePath: {
  300. type: 'string',
  301. title: 'Watermark Image Path',
  302. description: 'Path to watermark image file. Supports {{variable}} interpolation.',
  303. showWhen: { field: 'operation', value: 'watermark' }
  304. },
  305. watermarkImageUrl: {
  306. type: 'string',
  307. title: 'Watermark Image URL',
  308. description: 'URL to download watermark image from. Supports {{variable}} interpolation.',
  309. showWhen: { field: 'operation', value: 'watermark' }
  310. },
  311. watermarkPosition: {
  312. type: 'string',
  313. title: 'Position',
  314. enum: ['center', 'northwest', 'north', 'northeast', 'west', 'east', 'southwest', 'south', 'southeast'],
  315. enumLabels: ['Center', 'Top Left', 'Top Center', 'Top Right', 'Left', 'Right', 'Bottom Left', 'Bottom Center', 'Bottom Right'],
  316. default: 'center',
  317. description: 'Position of the watermark on the image',
  318. showWhen: { field: 'operation', value: 'watermark' }
  319. },
  320. watermarkOpacity: {
  321. type: 'number',
  322. title: 'Opacity',
  323. minimum: 0,
  324. maximum: 100,
  325. default: 50,
  326. description: 'Watermark opacity (0-100%)',
  327. showWhen: { field: 'operation', value: 'watermark' }
  328. },
  329. watermarkMargin: {
  330. type: 'number',
  331. title: 'Margin',
  332. minimum: 0,
  333. maximum: 500,
  334. default: 10,
  335. description: 'Margin from edge in pixels',
  336. showWhen: { field: 'operation', value: 'watermark' }
  337. },
  338. watermarkScale: {
  339. type: 'number',
  340. title: 'Scale (%)',
  341. minimum: 1,
  342. maximum: 100,
  343. default: 20,
  344. description: 'Scale watermark image as percentage of main image width',
  345. showWhen: { field: 'operation', value: 'watermark' }
  346. },
  347. outputFormat: {
  348. type: 'string',
  349. title: 'Output Format',
  350. enum: ['auto', 'jpeg', 'png', 'webp', 'gif'],
  351. enumLabels: ['Same as Input', 'JPEG', 'PNG', 'WebP', 'GIF'],
  352. default: 'auto',
  353. description: 'Output image format'
  354. },
  355. outputQuality: {
  356. type: 'number',
  357. title: 'Quality',
  358. minimum: 1,
  359. maximum: 100,
  360. default: 85,
  361. description: 'Output quality for lossy formats (JPEG, WebP). 1-100.'
  362. },
  363. timeout: {
  364. type: 'number',
  365. title: 'Timeout (ms)',
  366. default: 30000,
  367. minimum: 1000,
  368. maximum: 120000,
  369. description: 'Maximum time to wait for image processing'
  370. }
  371. },
  372. required: ['operation']
  373. };
  374. const inputSchema = {
  375. type: 'object',
  376. properties: {
  377. data: {
  378. type: 'any',
  379. description: 'Input data. Can be base64 string, file path, URL, or binary object from another node.'
  380. },
  381. file: {
  382. type: 'object',
  383. description: 'Binary file object with type, data (base64), mimeType, filename'
  384. },
  385. storage: {
  386. type: 'object',
  387. description: 'Storage reference with collection and id'
  388. },
  389. base64: {
  390. type: 'string',
  391. description: 'Base64-encoded image data'
  392. },
  393. filePath: {
  394. type: 'string',
  395. description: 'Override file path from input'
  396. },
  397. url: {
  398. type: 'string',
  399. description: 'Override URL from input'
  400. }
  401. }
  402. };
  403. const outputSchema = {
  404. type: 'object',
  405. properties: {
  406. width: {
  407. type: 'number',
  408. description: 'Image width in pixels'
  409. },
  410. height: {
  411. type: 'number',
  412. description: 'Image height in pixels'
  413. },
  414. format: {
  415. type: 'string',
  416. description: 'Image format (JPEG, PNG, GIF, etc.)'
  417. },
  418. colorSpace: {
  419. type: 'string',
  420. description: 'Color space (sRGB, CMYK, Grayscale, etc.)'
  421. },
  422. depth: {
  423. type: 'number',
  424. description: 'Bit depth per channel'
  425. },
  426. fileSize: {
  427. type: 'number',
  428. description: 'File size in bytes'
  429. },
  430. dpi: {
  431. type: 'object',
  432. description: 'DPI/resolution information',
  433. properties: {
  434. x: { type: 'number' },
  435. y: { type: 'number' },
  436. unit: { type: 'string' }
  437. }
  438. },
  439. hasAlpha: {
  440. type: 'boolean',
  441. description: 'Whether image has alpha/transparency channel'
  442. },
  443. compression: {
  444. type: 'string',
  445. description: 'Compression type used'
  446. },
  447. exif: {
  448. type: 'object',
  449. description: 'EXIF metadata (if available)',
  450. properties: {
  451. camera: {
  452. type: 'object',
  453. properties: {
  454. make: { type: 'string' },
  455. model: { type: 'string' },
  456. software: { type: 'string' }
  457. }
  458. },
  459. datetime: {
  460. type: 'object',
  461. properties: {
  462. original: { type: 'string' },
  463. digitized: { type: 'string' },
  464. modified: { type: 'string' }
  465. }
  466. },
  467. gps: {
  468. type: 'object',
  469. properties: {
  470. latitude: { type: 'number' },
  471. longitude: { type: 'number' },
  472. altitude: { type: 'number' },
  473. latitudeRef: { type: 'string' },
  474. longitudeRef: { type: 'string' }
  475. }
  476. },
  477. exposure: {
  478. type: 'object',
  479. properties: {
  480. time: { type: 'string' },
  481. fNumber: { type: 'number' },
  482. iso: { type: 'number' },
  483. program: { type: 'string' },
  484. bias: { type: 'string' }
  485. }
  486. },
  487. lens: {
  488. type: 'object',
  489. properties: {
  490. focalLength: { type: 'string' },
  491. focalLength35mm: { type: 'number' },
  492. aperture: { type: 'number' },
  493. make: { type: 'string' },
  494. model: { type: 'string' }
  495. }
  496. },
  497. orientation: { type: 'number' },
  498. flash: { type: 'string' },
  499. whiteBalance: { type: 'string' },
  500. meteringMode: { type: 'string' }
  501. }
  502. },
  503. file: {
  504. type: 'object',
  505. description: 'Binary file output (for manipulation operations)',
  506. properties: {
  507. type: { type: 'string', const: 'binary' },
  508. data: { type: 'string', description: 'Base64-encoded image data' },
  509. mimeType: { type: 'string', description: 'MIME type of the image' },
  510. filename: { type: 'string', description: 'Output filename' },
  511. size: { type: 'number', description: 'File size in bytes' }
  512. }
  513. },
  514. operation: {
  515. type: 'string',
  516. description: 'Operation performed'
  517. },
  518. originalWidth: {
  519. type: 'number',
  520. description: 'Original image width before manipulation'
  521. },
  522. originalHeight: {
  523. type: 'number',
  524. description: 'Original image height before manipulation'
  525. },
  526. sourceType: {
  527. type: 'string',
  528. description: 'Source type used (base64, filePath, url, storage)'
  529. },
  530. sourcePath: {
  531. type: 'string',
  532. description: 'Temporary file path used for processing'
  533. },
  534. filtersApplied: {
  535. type: 'array',
  536. description: 'List of filters applied (for filter operation)',
  537. items: { type: 'string' }
  538. },
  539. filterSettings: {
  540. type: 'object',
  541. description: 'Detailed filter settings applied',
  542. properties: {
  543. blur: { type: 'object', properties: { enabled: { type: 'boolean' }, radius: { type: 'number' } } },
  544. sharpen: { type: 'object', properties: { enabled: { type: 'boolean' }, amount: { type: 'number' } } },
  545. grayscale: { type: 'boolean' },
  546. sepia: { type: 'object', properties: { enabled: { type: 'boolean' }, intensity: { type: 'number' } } },
  547. brightness: { type: 'object', properties: { enabled: { type: 'boolean' }, value: { type: 'number' } } },
  548. contrast: { type: 'object', properties: { enabled: { type: 'boolean' }, value: { type: 'number' } } }
  549. }
  550. },
  551. watermark: {
  552. type: 'object',
  553. description: 'Watermark details applied',
  554. properties: {
  555. type: { type: 'string', description: 'text or image' },
  556. position: { type: 'string' },
  557. opacity: { type: 'number' },
  558. text: { type: 'string', description: 'Text used (for text watermark)' },
  559. imagePath: { type: 'string', description: 'Path to watermark image (for image watermark)' }
  560. }
  561. }
  562. }
  563. };
  564. function interpolate(template, data) {
  565. if (typeof template !== 'string') return template;
  566. return template.replace(/\{\{([^}]+)\}\}/g, function(match, key) {
  567. var keys = key.trim().split('.');
  568. var value = data;
  569. for (var i = 0; i < keys.length; i++) {
  570. if (value && typeof value === 'object' && keys[i] in value) {
  571. value = value[keys[i]];
  572. } else {
  573. return match;
  574. }
  575. }
  576. return value !== undefined ? String(value) : match;
  577. });
  578. }
  579. function generateTempPath(extension) {
  580. var uuid = smartbotic.utils.uuid();
  581. return '/tmp/smartbotic-image-' + uuid + (extension ? '.' + extension : '');
  582. }
  583. function detectImageFormat(base64Data) {
  584. if (!base64Data || base64Data.length < 8) return null;
  585. var header = smartbotic.utils.base64Decode(base64Data.substring(0, 16));
  586. if (!header) return null;
  587. if (header.charCodeAt(0) === 0xFF && header.charCodeAt(1) === 0xD8 && header.charCodeAt(2) === 0xFF) {
  588. return 'jpg';
  589. }
  590. if (header.substring(0, 8) === '\x89PNG\r\n\x1a\n') {
  591. return 'png';
  592. }
  593. if (header.substring(0, 6) === 'GIF87a' || header.substring(0, 6) === 'GIF89a') {
  594. return 'gif';
  595. }
  596. if (header.substring(0, 4) === 'RIFF' && header.substring(8, 12) === 'WEBP') {
  597. return 'webp';
  598. }
  599. if (header.substring(0, 2) === 'BM') {
  600. return 'bmp';
  601. }
  602. if (header.substring(0, 4) === 'II*\x00' || header.substring(0, 4) === 'MM\x00*') {
  603. return 'tiff';
  604. }
  605. return null;
  606. }
  607. function resolveImageInput(config, input) {
  608. var inputSource = config.inputSource || 'auto';
  609. var result = { type: null, data: null, path: null, tempPath: null };
  610. if (inputSource === 'filePath' || (inputSource === 'auto' && input.filePath)) {
  611. var filePath = input.filePath || interpolate(config.filePath, input);
  612. if (filePath && smartbotic.fs.exists(filePath)) {
  613. result.type = 'filePath';
  614. result.path = filePath;
  615. return result;
  616. }
  617. }
  618. if (inputSource === 'base64' || (inputSource === 'auto' && (input.base64 || config.base64Data))) {
  619. var base64 = input.base64 || interpolate(config.base64Data, input);
  620. if (base64) {
  621. result.type = 'base64';
  622. result.data = base64;
  623. return result;
  624. }
  625. }
  626. if (inputSource === 'url' || (inputSource === 'auto' && (input.url || config.url))) {
  627. var url = input.url || interpolate(config.url, input);
  628. if (url) {
  629. result.type = 'url';
  630. result.data = url;
  631. return result;
  632. }
  633. }
  634. if (inputSource === 'storage' || (inputSource === 'auto' && input.storage)) {
  635. var collection = input.storage ? input.storage.collection : config.storageCollection;
  636. var id = input.storage ? input.storage.id : interpolate(config.storageId, input);
  637. if (collection && id) {
  638. result.type = 'storage';
  639. result.data = { collection: collection, id: id };
  640. return result;
  641. }
  642. }
  643. if (inputSource === 'auto') {
  644. if (input.file && input.file.type === 'binary' && input.file.data) {
  645. result.type = 'base64';
  646. result.data = input.file.data;
  647. return result;
  648. }
  649. if (typeof input.data === 'string') {
  650. if (input.data.match(/^[A-Za-z0-9+/]+=*$/) && input.data.length > 100) {
  651. result.type = 'base64';
  652. result.data = input.data;
  653. return result;
  654. }
  655. if (input.data.startsWith('http://') || input.data.startsWith('https://')) {
  656. result.type = 'url';
  657. result.data = input.data;
  658. return result;
  659. }
  660. if (smartbotic.fs.exists(input.data)) {
  661. result.type = 'filePath';
  662. result.path = input.data;
  663. return result;
  664. }
  665. }
  666. if (input.data && typeof input.data === 'object') {
  667. if (input.data.type === 'binary' && input.data.data) {
  668. result.type = 'base64';
  669. result.data = input.data.data;
  670. return result;
  671. }
  672. if (input.data.collection && input.data.id) {
  673. result.type = 'storage';
  674. result.data = input.data;
  675. return result;
  676. }
  677. }
  678. }
  679. return result;
  680. }
  681. function prepareImageFile(imageInput, timeout) {
  682. var tempPath = null;
  683. if (imageInput.type === 'filePath') {
  684. return { path: imageInput.path, tempPath: null };
  685. }
  686. if (imageInput.type === 'base64') {
  687. var ext = detectImageFormat(imageInput.data) || 'img';
  688. tempPath = generateTempPath(ext);
  689. var writeResult = smartbotic.fs.writeFile(tempPath, imageInput.data, 'base64');
  690. if (!writeResult.success) {
  691. throw new Error('Failed to write temp file: ' + (writeResult.error || 'Unknown error'));
  692. }
  693. return { path: tempPath, tempPath: tempPath };
  694. }
  695. if (imageInput.type === 'url') {
  696. smartbotic.log.info('Downloading image from URL: ' + imageInput.data);
  697. var response = smartbotic.http.request({
  698. method: 'GET',
  699. url: imageInput.data,
  700. timeout: timeout
  701. });
  702. if (response.status < 200 || response.status >= 300) {
  703. throw new Error('Failed to download image: HTTP ' + response.status);
  704. }
  705. var contentType = response.headers['content-type'] || '';
  706. var ext = 'img';
  707. if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
  708. else if (contentType.includes('png')) ext = 'png';
  709. else if (contentType.includes('gif')) ext = 'gif';
  710. else if (contentType.includes('webp')) ext = 'webp';
  711. tempPath = generateTempPath(ext);
  712. var base64Data = smartbotic.utils.base64Encode(response.data);
  713. var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64');
  714. if (!writeResult.success) {
  715. throw new Error('Failed to write downloaded image: ' + (writeResult.error || 'Unknown error'));
  716. }
  717. return { path: tempPath, tempPath: tempPath };
  718. }
  719. if (imageInput.type === 'storage') {
  720. var storageResult = smartbotic.storage.get(imageInput.data.collection, imageInput.data.id);
  721. if (!storageResult.found) {
  722. throw new Error('Image not found in storage: ' + imageInput.data.collection + '/' + imageInput.data.id);
  723. }
  724. var doc = storageResult.document;
  725. var base64Data = doc.data || doc.content || doc.base64;
  726. if (!base64Data) {
  727. throw new Error('Storage document does not contain image data');
  728. }
  729. var ext = detectImageFormat(base64Data) || 'img';
  730. tempPath = generateTempPath(ext);
  731. var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64');
  732. if (!writeResult.success) {
  733. throw new Error('Failed to write storage image: ' + (writeResult.error || 'Unknown error'));
  734. }
  735. return { path: tempPath, tempPath: tempPath };
  736. }
  737. throw new Error('No valid image input found');
  738. }
  739. function cleanupTempFile(tempPath) {
  740. if (tempPath) {
  741. try {
  742. smartbotic.fs.unlink(tempPath);
  743. } catch (e) {
  744. smartbotic.log.warn('Failed to cleanup temp file: ' + tempPath);
  745. }
  746. }
  747. }
  748. function parseImageMagickInfo(output) {
  749. var info = {};
  750. var lines = output.split('\n');
  751. for (var i = 0; i < lines.length; i++) {
  752. var line = lines[i].trim();
  753. if (!line) continue;
  754. var colonIdx = line.indexOf(':');
  755. if (colonIdx > 0) {
  756. var key = line.substring(0, colonIdx).trim().toLowerCase();
  757. var value = line.substring(colonIdx + 1).trim();
  758. if (key === 'image' || key === 'format') {
  759. var formatMatch = value.match(/^(\w+)/);
  760. if (formatMatch) info.format = formatMatch[1].toUpperCase();
  761. }
  762. else if (key === 'geometry' || key === 'page geometry') {
  763. var geomMatch = value.match(/(\d+)x(\d+)/);
  764. if (geomMatch) {
  765. info.width = parseInt(geomMatch[1], 10);
  766. info.height = parseInt(geomMatch[2], 10);
  767. }
  768. }
  769. else if (key === 'colorspace') {
  770. info.colorSpace = value;
  771. }
  772. else if (key === 'depth') {
  773. var depthMatch = value.match(/(\d+)/);
  774. if (depthMatch) info.depth = parseInt(depthMatch[1], 10);
  775. }
  776. else if (key === 'channel depth' || key === 'channels') {
  777. if (value.toLowerCase().includes('alpha')) {
  778. info.hasAlpha = true;
  779. }
  780. }
  781. else if (key === 'alpha') {
  782. info.hasAlpha = value.toLowerCase() !== 'undefined' && value.toLowerCase() !== 'off';
  783. }
  784. else if (key === 'compression') {
  785. info.compression = value;
  786. }
  787. else if (key === 'resolution' || key === 'units') {
  788. if (!info.dpi) info.dpi = {};
  789. var resMatch = value.match(/([\d.]+)x([\d.]+)/);
  790. if (resMatch) {
  791. info.dpi.x = parseFloat(resMatch[1]);
  792. info.dpi.y = parseFloat(resMatch[2]);
  793. }
  794. if (value.includes('PixelsPerInch')) info.dpi.unit = 'dpi';
  795. else if (value.includes('PixelsPerCentimeter')) info.dpi.unit = 'dpcm';
  796. }
  797. else if (key === 'filesize') {
  798. var sizeMatch = value.match(/([\d.]+)\s*(\w+)/);
  799. if (sizeMatch) {
  800. var size = parseFloat(sizeMatch[1]);
  801. var unit = sizeMatch[2].toUpperCase();
  802. if (unit === 'B') info.fileSize = Math.round(size);
  803. else if (unit === 'KB' || unit === 'KIB') info.fileSize = Math.round(size * 1024);
  804. else if (unit === 'MB' || unit === 'MIB') info.fileSize = Math.round(size * 1024 * 1024);
  805. else if (unit === 'GB' || unit === 'GIB') info.fileSize = Math.round(size * 1024 * 1024 * 1024);
  806. }
  807. }
  808. }
  809. }
  810. if (info.hasAlpha === undefined) info.hasAlpha = false;
  811. return info;
  812. }
  813. function parseExifToolOutput(output) {
  814. var exif = {
  815. camera: {},
  816. datetime: {},
  817. gps: {},
  818. exposure: {},
  819. lens: {}
  820. };
  821. var lines = output.split('\n');
  822. for (var i = 0; i < lines.length; i++) {
  823. var line = lines[i].trim();
  824. if (!line) continue;
  825. var colonIdx = line.indexOf(':');
  826. if (colonIdx <= 0) continue;
  827. var key = line.substring(0, colonIdx).trim().toLowerCase().replace(/\s+/g, '');
  828. var value = line.substring(colonIdx + 1).trim();
  829. if (!value || value === '-' || value === 'n/a') continue;
  830. if (key === 'make' || key === 'cameramake') exif.camera.make = value;
  831. else if (key === 'model' || key === 'cameramodel' || key === 'cameramodelname') exif.camera.model = value;
  832. else if (key === 'software') exif.camera.software = value;
  833. else if (key === 'datetimeoriginal' || key === 'createdate') exif.datetime.original = value;
  834. else if (key === 'datetimedigitized' || key === 'digitizeddate') exif.datetime.digitized = value;
  835. else if (key === 'modifydate' || key === 'datetime') exif.datetime.modified = value;
  836. else if (key === 'gpslatitude') {
  837. var latMatch = value.match(/([\d.]+)/);
  838. if (latMatch) {
  839. exif.gps.latitude = parseFloat(latMatch[1]);
  840. if (value.includes('S')) exif.gps.latitude = -exif.gps.latitude;
  841. exif.gps.latitudeRef = value.includes('S') ? 'S' : 'N';
  842. }
  843. }
  844. else if (key === 'gpslongitude') {
  845. var lonMatch = value.match(/([\d.]+)/);
  846. if (lonMatch) {
  847. exif.gps.longitude = parseFloat(lonMatch[1]);
  848. if (value.includes('W')) exif.gps.longitude = -exif.gps.longitude;
  849. exif.gps.longitudeRef = value.includes('W') ? 'W' : 'E';
  850. }
  851. }
  852. else if (key === 'gpsaltitude') {
  853. var altMatch = value.match(/([\d.]+)/);
  854. if (altMatch) exif.gps.altitude = parseFloat(altMatch[1]);
  855. }
  856. else if (key === 'exposuretime' || key === 'shutterspeed') exif.exposure.time = value;
  857. else if (key === 'fnumber' || key === 'aperture') {
  858. var fMatch = value.match(/([\d.]+)/);
  859. if (fMatch) exif.exposure.fNumber = parseFloat(fMatch[1]);
  860. }
  861. else if (key === 'iso' || key === 'isospeedratings') {
  862. var isoMatch = value.match(/(\d+)/);
  863. if (isoMatch) exif.exposure.iso = parseInt(isoMatch[1], 10);
  864. }
  865. else if (key === 'exposureprogram') exif.exposure.program = value;
  866. else if (key === 'exposurecompensation' || key === 'exposurebias') exif.exposure.bias = value;
  867. else if (key === 'focallength') exif.lens.focalLength = value;
  868. else if (key === 'focallengthin35mmformat' || key === 'focallength35efl') {
  869. var fl35Match = value.match(/(\d+)/);
  870. if (fl35Match) exif.lens.focalLength35mm = parseInt(fl35Match[1], 10);
  871. }
  872. else if (key === 'maxaperturevalue') {
  873. var apMatch = value.match(/([\d.]+)/);
  874. if (apMatch) exif.lens.aperture = parseFloat(apMatch[1]);
  875. }
  876. else if (key === 'lensmake') exif.lens.make = value;
  877. else if (key === 'lensmodel' || key === 'lens') exif.lens.model = value;
  878. else if (key === 'orientation') {
  879. var oriMatch = value.match(/(\d)/);
  880. if (oriMatch) exif.orientation = parseInt(oriMatch[1], 10);
  881. }
  882. else if (key === 'flash') exif.flash = value;
  883. else if (key === 'whitebalance') exif.whiteBalance = value;
  884. else if (key === 'meteringmode') exif.meteringMode = value;
  885. }
  886. var hasData = false;
  887. for (var section in exif) {
  888. if (typeof exif[section] === 'object') {
  889. for (var field in exif[section]) {
  890. if (exif[section][field] !== undefined) {
  891. hasData = true;
  892. break;
  893. }
  894. }
  895. } else if (exif[section] !== undefined) {
  896. hasData = true;
  897. }
  898. if (hasData) break;
  899. }
  900. return hasData ? exif : null;
  901. }
  902. function executeInfoOperation(imagePath, includeExif, timeout) {
  903. smartbotic.log.info('Running ImageMagick identify on: ' + imagePath);
  904. var identifyCmd = 'identify -verbose "' + imagePath.replace(/"/g, '\\"') + '"';
  905. var identifyResult = smartbotic.process.exec(identifyCmd, { timeout: timeout });
  906. if (!identifyResult.success) {
  907. throw new Error('ImageMagick identify failed: ' + (identifyResult.stderr || 'Unknown error'));
  908. }
  909. var info = parseImageMagickInfo(identifyResult.stdout);
  910. var stat = smartbotic.fs.stat(imagePath);
  911. if (stat.success && !info.fileSize) {
  912. info.fileSize = stat.size;
  913. }
  914. if (includeExif) {
  915. smartbotic.log.info('Extracting EXIF metadata');
  916. var exiftoolCmd = 'exiftool "' + imagePath.replace(/"/g, '\\"') + '"';
  917. var exifResult = smartbotic.process.exec(exiftoolCmd, { timeout: timeout });
  918. if (exifResult.success && exifResult.stdout) {
  919. var exifData = parseExifToolOutput(exifResult.stdout);
  920. if (exifData) {
  921. info.exif = exifData;
  922. }
  923. } else {
  924. smartbotic.log.debug('No EXIF data available or exiftool not installed');
  925. }
  926. }
  927. return info;
  928. }
  929. function getImageDimensions(imagePath, timeout) {
  930. var cmd = 'identify -format "%w %h" "' + imagePath.replace(/"/g, '\\"') + '"';
  931. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  932. if (!result.success) {
  933. throw new Error('Failed to get image dimensions: ' + (result.stderr || 'Unknown error'));
  934. }
  935. var parts = result.stdout.trim().split(' ');
  936. return {
  937. width: parseInt(parts[0], 10),
  938. height: parseInt(parts[1], 10)
  939. };
  940. }
  941. function getOutputExtension(format, originalPath) {
  942. if (format === 'auto') {
  943. var ext = originalPath.split('.').pop().toLowerCase();
  944. if (['jpg', 'jpeg', 'png', 'gif', 'webp'].indexOf(ext) >= 0) {
  945. return ext === 'jpg' ? 'jpeg' : ext;
  946. }
  947. return 'jpeg';
  948. }
  949. return format;
  950. }
  951. function getMimeType(format) {
  952. var mimeTypes = {
  953. jpeg: 'image/jpeg',
  954. jpg: 'image/jpeg',
  955. png: 'image/png',
  956. gif: 'image/gif',
  957. webp: 'image/webp'
  958. };
  959. return mimeTypes[format] || 'image/jpeg';
  960. }
  961. function buildOutputPath(format) {
  962. var uuid = smartbotic.utils.uuid();
  963. return '/tmp/smartbotic-image-out-' + uuid + '.' + format;
  964. }
  965. function readOutputFile(outputPath) {
  966. var readResult = smartbotic.fs.readFile(outputPath, 'base64');
  967. if (!readResult.success) {
  968. throw new Error('Failed to read output file: ' + (readResult.error || 'Unknown error'));
  969. }
  970. return readResult.data;
  971. }
  972. function getOutputFileSize(outputPath) {
  973. var stat = smartbotic.fs.stat(outputPath);
  974. return stat.success ? stat.size : 0;
  975. }
  976. function cleanupOutputFile(outputPath) {
  977. if (outputPath) {
  978. try {
  979. smartbotic.fs.unlink(outputPath);
  980. } catch (e) {
  981. smartbotic.log.warn('Failed to cleanup output file: ' + outputPath);
  982. }
  983. }
  984. }
  985. function buildQualityArgs(format, quality) {
  986. if (format === 'jpeg' || format === 'webp') {
  987. return ' -quality ' + quality;
  988. }
  989. if (format === 'png') {
  990. var pngQuality = Math.round((100 - quality) / 10);
  991. return ' -quality ' + (pngQuality * 10);
  992. }
  993. return '';
  994. }
  995. function executeResizeOperation(imagePath, config, timeout) {
  996. var dimensions = getImageDimensions(imagePath, timeout);
  997. var originalWidth = dimensions.width;
  998. var originalHeight = dimensions.height;
  999. var resizeMode = config.resizeMode || 'dimensions';
  1000. var maintainAspectRatio = config.maintainAspectRatio !== false;
  1001. var targetWidth, targetHeight;
  1002. if (resizeMode === 'percentage') {
  1003. var percentage = config.resizePercentage || 100;
  1004. targetWidth = Math.round(originalWidth * percentage / 100);
  1005. targetHeight = Math.round(originalHeight * percentage / 100);
  1006. smartbotic.log.info('Resizing by ' + percentage + '% to ' + targetWidth + 'x' + targetHeight);
  1007. } else {
  1008. targetWidth = config.resizeWidth;
  1009. targetHeight = config.resizeHeight;
  1010. if (!targetWidth && !targetHeight) {
  1011. throw new Error('Resize operation requires at least width or height');
  1012. }
  1013. if (maintainAspectRatio) {
  1014. if (targetWidth && !targetHeight) {
  1015. targetHeight = Math.round(originalHeight * targetWidth / originalWidth);
  1016. } else if (targetHeight && !targetWidth) {
  1017. targetWidth = Math.round(originalWidth * targetHeight / originalHeight);
  1018. }
  1019. } else {
  1020. targetWidth = targetWidth || originalWidth;
  1021. targetHeight = targetHeight || originalHeight;
  1022. }
  1023. smartbotic.log.info('Resizing to ' + targetWidth + 'x' + targetHeight + ' (aspect ratio ' + (maintainAspectRatio ? 'maintained' : 'ignored') + ')');
  1024. }
  1025. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1026. var quality = config.outputQuality || 85;
  1027. var outputPath = buildOutputPath(outputFormat);
  1028. var resizeSpec = targetWidth + 'x' + targetHeight;
  1029. if (!maintainAspectRatio && resizeMode === 'dimensions') {
  1030. resizeSpec += '!';
  1031. }
  1032. var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1033. cmd += ' -resize ' + resizeSpec;
  1034. cmd += buildQualityArgs(outputFormat, quality);
  1035. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1036. smartbotic.log.info('Executing: ' + cmd);
  1037. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1038. if (!result.success) {
  1039. cleanupOutputFile(outputPath);
  1040. throw new Error('Resize operation failed: ' + (result.stderr || 'Unknown error'));
  1041. }
  1042. var base64Data = readOutputFile(outputPath);
  1043. var fileSize = getOutputFileSize(outputPath);
  1044. cleanupOutputFile(outputPath);
  1045. return {
  1046. operation: 'resize',
  1047. originalWidth: originalWidth,
  1048. originalHeight: originalHeight,
  1049. width: targetWidth,
  1050. height: targetHeight,
  1051. format: outputFormat.toUpperCase(),
  1052. file: {
  1053. type: 'binary',
  1054. data: base64Data,
  1055. mimeType: getMimeType(outputFormat),
  1056. filename: 'resized.' + outputFormat,
  1057. size: fileSize
  1058. }
  1059. };
  1060. }
  1061. function executeCropOperation(imagePath, config, timeout) {
  1062. var dimensions = getImageDimensions(imagePath, timeout);
  1063. var originalWidth = dimensions.width;
  1064. var originalHeight = dimensions.height;
  1065. var cropX = config.cropX || 0;
  1066. var cropY = config.cropY || 0;
  1067. var cropWidth = config.cropWidth;
  1068. var cropHeight = config.cropHeight;
  1069. if (!cropWidth || !cropHeight) {
  1070. throw new Error('Crop operation requires both width and height');
  1071. }
  1072. if (cropX < 0 || cropY < 0) {
  1073. throw new Error('Crop offset cannot be negative');
  1074. }
  1075. if (cropX + cropWidth > originalWidth || cropY + cropHeight > originalHeight) {
  1076. throw new Error('Crop region exceeds image bounds. Image is ' + originalWidth + 'x' + originalHeight + ', crop would extend to ' + (cropX + cropWidth) + 'x' + (cropY + cropHeight));
  1077. }
  1078. smartbotic.log.info('Cropping ' + cropWidth + 'x' + cropHeight + ' from offset ' + cropX + ',' + cropY);
  1079. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1080. var quality = config.outputQuality || 85;
  1081. var outputPath = buildOutputPath(outputFormat);
  1082. var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1083. cmd += ' -crop ' + cropWidth + 'x' + cropHeight + '+' + cropX + '+' + cropY;
  1084. cmd += ' +repage';
  1085. cmd += buildQualityArgs(outputFormat, quality);
  1086. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1087. smartbotic.log.info('Executing: ' + cmd);
  1088. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1089. if (!result.success) {
  1090. cleanupOutputFile(outputPath);
  1091. throw new Error('Crop operation failed: ' + (result.stderr || 'Unknown error'));
  1092. }
  1093. var base64Data = readOutputFile(outputPath);
  1094. var fileSize = getOutputFileSize(outputPath);
  1095. cleanupOutputFile(outputPath);
  1096. return {
  1097. operation: 'crop',
  1098. originalWidth: originalWidth,
  1099. originalHeight: originalHeight,
  1100. width: cropWidth,
  1101. height: cropHeight,
  1102. cropRegion: {
  1103. x: cropX,
  1104. y: cropY,
  1105. width: cropWidth,
  1106. height: cropHeight
  1107. },
  1108. format: outputFormat.toUpperCase(),
  1109. file: {
  1110. type: 'binary',
  1111. data: base64Data,
  1112. mimeType: getMimeType(outputFormat),
  1113. filename: 'cropped.' + outputFormat,
  1114. size: fileSize
  1115. }
  1116. };
  1117. }
  1118. function executeRotateOperation(imagePath, config, timeout) {
  1119. var dimensions = getImageDimensions(imagePath, timeout);
  1120. var originalWidth = dimensions.width;
  1121. var originalHeight = dimensions.height;
  1122. var rotatePreset = config.rotatePreset || 'custom';
  1123. var angle;
  1124. if (rotatePreset === 'custom') {
  1125. angle = config.rotateAngle;
  1126. if (angle === undefined || angle === null) {
  1127. angle = 90;
  1128. }
  1129. } else {
  1130. angle = parseInt(rotatePreset, 10);
  1131. }
  1132. angle = angle % 360;
  1133. if (angle < 0) {
  1134. angle += 360;
  1135. }
  1136. var backgroundColor = config.backgroundColor || 'transparent';
  1137. smartbotic.log.info('Rotating ' + angle + ' degrees with background: ' + backgroundColor);
  1138. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1139. var quality = config.outputQuality || 85;
  1140. var outputPath = buildOutputPath(outputFormat);
  1141. var supportsTransparency = (outputFormat === 'png' || outputFormat === 'gif' || outputFormat === 'webp');
  1142. var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1143. if (backgroundColor === 'transparent' && supportsTransparency) {
  1144. cmd += ' -background none';
  1145. } else if (backgroundColor === 'transparent') {
  1146. cmd += ' -background white';
  1147. } else {
  1148. cmd += ' -background "' + backgroundColor.replace(/"/g, '\\"') + '"';
  1149. }
  1150. cmd += ' -rotate ' + angle;
  1151. cmd += buildQualityArgs(outputFormat, quality);
  1152. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1153. smartbotic.log.info('Executing: ' + cmd);
  1154. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1155. if (!result.success) {
  1156. cleanupOutputFile(outputPath);
  1157. throw new Error('Rotate operation failed: ' + (result.stderr || 'Unknown error'));
  1158. }
  1159. var newDimensions = getImageDimensions(outputPath, timeout);
  1160. var base64Data = readOutputFile(outputPath);
  1161. var fileSize = getOutputFileSize(outputPath);
  1162. cleanupOutputFile(outputPath);
  1163. return {
  1164. operation: 'rotate',
  1165. originalWidth: originalWidth,
  1166. originalHeight: originalHeight,
  1167. width: newDimensions.width,
  1168. height: newDimensions.height,
  1169. angle: angle,
  1170. backgroundColor: backgroundColor,
  1171. format: outputFormat.toUpperCase(),
  1172. file: {
  1173. type: 'binary',
  1174. data: base64Data,
  1175. mimeType: getMimeType(outputFormat),
  1176. filename: 'rotated.' + outputFormat,
  1177. size: fileSize
  1178. }
  1179. };
  1180. }
  1181. function executeFilterOperation(imagePath, config, input, timeout) {
  1182. var dimensions = getImageDimensions(imagePath, timeout);
  1183. var originalWidth = dimensions.width;
  1184. var originalHeight = dimensions.height;
  1185. var filtersApplied = [];
  1186. var filterSettings = {};
  1187. var filterArgs = '';
  1188. if (config.filterBlur) {
  1189. var blurRadius = config.filterBlurRadius || 5;
  1190. filterArgs += ' -blur 0x' + blurRadius;
  1191. filtersApplied.push('blur');
  1192. filterSettings.blur = { enabled: true, radius: blurRadius };
  1193. smartbotic.log.info('Applying blur with radius: ' + blurRadius);
  1194. }
  1195. if (config.filterSharpen) {
  1196. var sharpenAmount = config.filterSharpenAmount || 1;
  1197. filterArgs += ' -sharpen 0x' + sharpenAmount;
  1198. filtersApplied.push('sharpen');
  1199. filterSettings.sharpen = { enabled: true, amount: sharpenAmount };
  1200. smartbotic.log.info('Applying sharpen with amount: ' + sharpenAmount);
  1201. }
  1202. if (config.filterGrayscale) {
  1203. filterArgs += ' -colorspace Gray';
  1204. filtersApplied.push('grayscale');
  1205. filterSettings.grayscale = true;
  1206. smartbotic.log.info('Applying grayscale conversion');
  1207. }
  1208. if (config.filterSepia) {
  1209. var sepiaIntensity = config.filterSepiaIntensity || 80;
  1210. filterArgs += ' -sepia-tone ' + sepiaIntensity + '%';
  1211. filtersApplied.push('sepia');
  1212. filterSettings.sepia = { enabled: true, intensity: sepiaIntensity };
  1213. smartbotic.log.info('Applying sepia with intensity: ' + sepiaIntensity + '%');
  1214. }
  1215. if (config.filterBrightness) {
  1216. var brightnessValue = config.filterBrightnessValue || 0;
  1217. var brightnessPct = 100 + brightnessValue;
  1218. filterArgs += ' -modulate ' + brightnessPct + ',100,100';
  1219. filtersApplied.push('brightness');
  1220. filterSettings.brightness = { enabled: true, value: brightnessValue };
  1221. smartbotic.log.info('Adjusting brightness by: ' + brightnessValue);
  1222. }
  1223. if (config.filterContrast) {
  1224. var contrastValue = config.filterContrastValue || 0;
  1225. if (contrastValue > 0) {
  1226. for (var i = 0; i < Math.min(contrastValue, 10); i++) {
  1227. filterArgs += ' -contrast';
  1228. }
  1229. if (contrastValue > 10) {
  1230. filterArgs += ' -sigmoidal-contrast ' + (contrastValue / 10) + ',50%';
  1231. }
  1232. } else if (contrastValue < 0) {
  1233. var absContrast = Math.abs(contrastValue);
  1234. for (var j = 0; j < Math.min(absContrast, 10); j++) {
  1235. filterArgs += ' +contrast';
  1236. }
  1237. if (absContrast > 10) {
  1238. filterArgs += ' +sigmoidal-contrast ' + (absContrast / 10) + ',50%';
  1239. }
  1240. }
  1241. filtersApplied.push('contrast');
  1242. filterSettings.contrast = { enabled: true, value: contrastValue };
  1243. smartbotic.log.info('Adjusting contrast by: ' + contrastValue);
  1244. }
  1245. if (filtersApplied.length === 0) {
  1246. throw new Error('Filter operation requires at least one filter to be enabled');
  1247. }
  1248. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1249. var quality = config.outputQuality || 85;
  1250. var outputPath = buildOutputPath(outputFormat);
  1251. var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1252. cmd += filterArgs;
  1253. cmd += buildQualityArgs(outputFormat, quality);
  1254. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1255. smartbotic.log.info('Executing: ' + cmd);
  1256. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1257. if (!result.success) {
  1258. cleanupOutputFile(outputPath);
  1259. throw new Error('Filter operation failed: ' + (result.stderr || 'Unknown error'));
  1260. }
  1261. var base64Data = readOutputFile(outputPath);
  1262. var fileSize = getOutputFileSize(outputPath);
  1263. cleanupOutputFile(outputPath);
  1264. return {
  1265. operation: 'filter',
  1266. originalWidth: originalWidth,
  1267. originalHeight: originalHeight,
  1268. width: originalWidth,
  1269. height: originalHeight,
  1270. filtersApplied: filtersApplied,
  1271. filterSettings: filterSettings,
  1272. format: outputFormat.toUpperCase(),
  1273. file: {
  1274. type: 'binary',
  1275. data: base64Data,
  1276. mimeType: getMimeType(outputFormat),
  1277. filename: 'filtered.' + outputFormat,
  1278. size: fileSize
  1279. }
  1280. };
  1281. }
  1282. function prepareWatermarkImage(config, input, timeout) {
  1283. var watermarkType = config.watermarkType || 'text';
  1284. if (watermarkType !== 'image') {
  1285. return null;
  1286. }
  1287. var source = config.watermarkImageSource || 'filePath';
  1288. var tempPath = null;
  1289. if (source === 'filePath') {
  1290. var filePath = interpolate(config.watermarkImagePath, input);
  1291. if (!filePath) {
  1292. throw new Error('Watermark image path is required');
  1293. }
  1294. if (!smartbotic.fs.exists(filePath)) {
  1295. throw new Error('Watermark image file not found: ' + filePath);
  1296. }
  1297. return { path: filePath, tempPath: null };
  1298. }
  1299. if (source === 'base64') {
  1300. var base64Data = config.watermarkImageBase64;
  1301. if (!base64Data) {
  1302. throw new Error('Watermark image base64 data is required');
  1303. }
  1304. var ext = detectImageFormat(base64Data) || 'png';
  1305. tempPath = generateTempPath('watermark-' + ext);
  1306. var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64');
  1307. if (!writeResult.success) {
  1308. throw new Error('Failed to write watermark temp file: ' + (writeResult.error || 'Unknown error'));
  1309. }
  1310. return { path: tempPath, tempPath: tempPath };
  1311. }
  1312. if (source === 'url') {
  1313. var url = interpolate(config.watermarkImageUrl, input);
  1314. if (!url) {
  1315. throw new Error('Watermark image URL is required');
  1316. }
  1317. smartbotic.log.info('Downloading watermark image from URL: ' + url);
  1318. var response = smartbotic.http.request({
  1319. method: 'GET',
  1320. url: url,
  1321. timeout: timeout
  1322. });
  1323. if (response.status < 200 || response.status >= 300) {
  1324. throw new Error('Failed to download watermark image: HTTP ' + response.status);
  1325. }
  1326. var contentType = response.headers['content-type'] || '';
  1327. var ext = 'png';
  1328. if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
  1329. else if (contentType.includes('gif')) ext = 'gif';
  1330. else if (contentType.includes('webp')) ext = 'webp';
  1331. tempPath = generateTempPath('watermark-' + ext);
  1332. var base64 = smartbotic.utils.base64Encode(response.data);
  1333. var writeResult = smartbotic.fs.writeFile(tempPath, base64, 'base64');
  1334. if (!writeResult.success) {
  1335. throw new Error('Failed to write downloaded watermark: ' + (writeResult.error || 'Unknown error'));
  1336. }
  1337. return { path: tempPath, tempPath: tempPath };
  1338. }
  1339. throw new Error('Invalid watermark image source: ' + source);
  1340. }
  1341. function executeWatermarkOperation(imagePath, config, input, timeout) {
  1342. var dimensions = getImageDimensions(imagePath, timeout);
  1343. var originalWidth = dimensions.width;
  1344. var originalHeight = dimensions.height;
  1345. var watermarkType = config.watermarkType || 'text';
  1346. var position = config.watermarkPosition || 'center';
  1347. var opacity = config.watermarkOpacity !== undefined ? config.watermarkOpacity : 50;
  1348. var margin = config.watermarkMargin || 10;
  1349. var gravityMap = {
  1350. center: 'Center',
  1351. northwest: 'NorthWest',
  1352. north: 'North',
  1353. northeast: 'NorthEast',
  1354. west: 'West',
  1355. east: 'East',
  1356. southwest: 'SouthWest',
  1357. south: 'South',
  1358. southeast: 'SouthEast'
  1359. };
  1360. var gravity = gravityMap[position] || 'Center';
  1361. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1362. var quality = config.outputQuality || 85;
  1363. var outputPath = buildOutputPath(outputFormat);
  1364. var watermarkInfo = {
  1365. type: watermarkType,
  1366. position: position,
  1367. opacity: opacity
  1368. };
  1369. var watermarkImageInfo = null;
  1370. var cmd;
  1371. if (watermarkType === 'text') {
  1372. var text = interpolate(config.watermarkText, input);
  1373. if (!text) {
  1374. throw new Error('Watermark text is required');
  1375. }
  1376. var font = config.watermarkFont || 'Arial';
  1377. var fontSize = config.watermarkFontSize || 48;
  1378. var color = config.watermarkColor || 'white';
  1379. watermarkInfo.text = text;
  1380. var escapedText = text.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
  1381. cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1382. cmd += ' -gravity ' + gravity;
  1383. cmd += ' -font "' + font.replace(/"/g, '\\"') + '"';
  1384. cmd += ' -pointsize ' + fontSize;
  1385. cmd += ' -fill "' + color.replace(/"/g, '\\"') + '"';
  1386. cmd += ' -stroke none';
  1387. cmd += ' -annotate +' + margin + '+' + margin + ' "' + escapedText + '"';
  1388. if (opacity < 100) {
  1389. cmd += ' -channel A -evaluate multiply ' + (opacity / 100);
  1390. }
  1391. cmd += buildQualityArgs(outputFormat, quality);
  1392. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1393. smartbotic.log.info('Applying text watermark: "' + text + '" at ' + position + ' with opacity ' + opacity + '%');
  1394. } else {
  1395. watermarkImageInfo = prepareWatermarkImage(config, input, timeout);
  1396. var watermarkPath = watermarkImageInfo.path;
  1397. watermarkInfo.imagePath = watermarkPath;
  1398. var scale = config.watermarkScale || 20;
  1399. var watermarkWidth = Math.round(originalWidth * scale / 100);
  1400. smartbotic.log.info('Applying image watermark at ' + position + ' with opacity ' + opacity + '% and scale ' + scale + '%');
  1401. cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1402. cmd += ' \\( "' + watermarkPath.replace(/"/g, '\\"') + '"';
  1403. cmd += ' -resize ' + watermarkWidth + 'x';
  1404. if (opacity < 100) {
  1405. cmd += ' -alpha set -channel A -evaluate multiply ' + (opacity / 100) + ' +channel';
  1406. }
  1407. cmd += ' \\)';
  1408. cmd += ' -gravity ' + gravity;
  1409. cmd += ' -geometry +' + margin + '+' + margin;
  1410. cmd += ' -composite';
  1411. cmd += buildQualityArgs(outputFormat, quality);
  1412. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1413. }
  1414. smartbotic.log.info('Executing: ' + cmd);
  1415. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1416. if (watermarkImageInfo && watermarkImageInfo.tempPath) {
  1417. cleanupTempFile(watermarkImageInfo.tempPath);
  1418. }
  1419. if (!result.success) {
  1420. cleanupOutputFile(outputPath);
  1421. throw new Error('Watermark operation failed: ' + (result.stderr || 'Unknown error'));
  1422. }
  1423. var base64Data = readOutputFile(outputPath);
  1424. var fileSize = getOutputFileSize(outputPath);
  1425. cleanupOutputFile(outputPath);
  1426. return {
  1427. operation: 'watermark',
  1428. originalWidth: originalWidth,
  1429. originalHeight: originalHeight,
  1430. width: originalWidth,
  1431. height: originalHeight,
  1432. watermark: watermarkInfo,
  1433. format: outputFormat.toUpperCase(),
  1434. file: {
  1435. type: 'binary',
  1436. data: base64Data,
  1437. mimeType: getMimeType(outputFormat),
  1438. filename: 'watermarked.' + outputFormat,
  1439. size: fileSize
  1440. }
  1441. };
  1442. }
  1443. async function execute(config, input) {
  1444. var operation = config.operation || 'info';
  1445. var timeout = config.timeout || 30000;
  1446. var supportedOperations = ['info', 'resize', 'crop', 'rotate', 'filter', 'watermark'];
  1447. if (supportedOperations.indexOf(operation) < 0) {
  1448. throw new Error('Operation "' + operation + '" is not yet implemented. Supported: ' + supportedOperations.join(', '));
  1449. }
  1450. var imageInput = resolveImageInput(config, input);
  1451. if (!imageInput.type) {
  1452. throw new Error('No valid image input found. Provide base64 data, file path, URL, or storage reference.');
  1453. }
  1454. smartbotic.log.info('Image input source: ' + imageInput.type);
  1455. var fileInfo = null;
  1456. try {
  1457. fileInfo = prepareImageFile(imageInput, timeout);
  1458. smartbotic.log.info('Processing image: ' + fileInfo.path);
  1459. var result;
  1460. if (operation === 'info') {
  1461. result = executeInfoOperation(fileInfo.path, config.includeExif !== false, timeout);
  1462. } else if (operation === 'resize') {
  1463. result = executeResizeOperation(fileInfo.path, config, timeout);
  1464. } else if (operation === 'crop') {
  1465. result = executeCropOperation(fileInfo.path, config, timeout);
  1466. } else if (operation === 'rotate') {
  1467. result = executeRotateOperation(fileInfo.path, config, timeout);
  1468. } else if (operation === 'filter') {
  1469. result = executeFilterOperation(fileInfo.path, config, input, timeout);
  1470. } else if (operation === 'watermark') {
  1471. result = executeWatermarkOperation(fileInfo.path, config, input, timeout);
  1472. }
  1473. result.sourceType = imageInput.type;
  1474. result.sourcePath = fileInfo.path;
  1475. return result;
  1476. } finally {
  1477. if (fileInfo && fileInfo.tempPath) {
  1478. cleanupTempFile(fileInfo.tempPath);
  1479. }
  1480. }
  1481. }
  1482. module.exports = { configSchema, inputSchema, outputSchema, execute };