image.js 60 KB

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