image.js 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650
  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. }
  569. };
  570. function interpolate(template, data) {
  571. if (typeof template !== 'string') return template;
  572. return template.replace(/\{\{([^}]+)\}\}/g, function(match, key) {
  573. var keys = key.trim().split('.');
  574. var value = data;
  575. for (var i = 0; i < keys.length; i++) {
  576. if (value && typeof value === 'object' && keys[i] in value) {
  577. value = value[keys[i]];
  578. } else {
  579. return match;
  580. }
  581. }
  582. return value !== undefined ? String(value) : match;
  583. });
  584. }
  585. function generateTempPath(extension) {
  586. var uuid = smartbotic.utils.uuid();
  587. return '/tmp/smartbotic-image-' + uuid + (extension ? '.' + extension : '');
  588. }
  589. function detectImageFormat(base64Data) {
  590. if (!base64Data || base64Data.length < 8) return null;
  591. var header = smartbotic.utils.base64Decode(base64Data.substring(0, 16));
  592. if (!header) return null;
  593. if (header.charCodeAt(0) === 0xFF && header.charCodeAt(1) === 0xD8 && header.charCodeAt(2) === 0xFF) {
  594. return 'jpg';
  595. }
  596. if (header.substring(0, 8) === '\x89PNG\r\n\x1a\n') {
  597. return 'png';
  598. }
  599. if (header.substring(0, 6) === 'GIF87a' || header.substring(0, 6) === 'GIF89a') {
  600. return 'gif';
  601. }
  602. if (header.substring(0, 4) === 'RIFF' && header.substring(8, 12) === 'WEBP') {
  603. return 'webp';
  604. }
  605. if (header.substring(0, 2) === 'BM') {
  606. return 'bmp';
  607. }
  608. if (header.substring(0, 4) === 'II*\x00' || header.substring(0, 4) === 'MM\x00*') {
  609. return 'tiff';
  610. }
  611. return null;
  612. }
  613. function resolveImageInput(config, input) {
  614. var inputSource = config.inputSource || 'auto';
  615. var result = { type: null, data: null, path: null, tempPath: null };
  616. if (inputSource === 'filePath' || (inputSource === 'auto' && input.filePath)) {
  617. var filePath = input.filePath || interpolate(config.filePath, input);
  618. if (filePath && smartbotic.fs.exists(filePath)) {
  619. result.type = 'filePath';
  620. result.path = filePath;
  621. return result;
  622. }
  623. }
  624. if (inputSource === 'base64' || (inputSource === 'auto' && (input.base64 || config.base64Data))) {
  625. var base64 = input.base64 || interpolate(config.base64Data, input);
  626. if (base64) {
  627. result.type = 'base64';
  628. result.data = base64;
  629. return result;
  630. }
  631. }
  632. if (inputSource === 'url' || (inputSource === 'auto' && (input.url || config.url))) {
  633. var url = input.url || interpolate(config.url, input);
  634. if (url) {
  635. result.type = 'url';
  636. result.data = url;
  637. return result;
  638. }
  639. }
  640. if (inputSource === 'storage' || (inputSource === 'auto' && input.storage)) {
  641. var collection = input.storage ? input.storage.collection : config.storageCollection;
  642. var id = input.storage ? input.storage.id : interpolate(config.storageId, input);
  643. if (collection && id) {
  644. result.type = 'storage';
  645. result.data = { collection: collection, id: id };
  646. return result;
  647. }
  648. }
  649. if (inputSource === 'auto') {
  650. if (input.file && input.file.type === 'binary' && input.file.data) {
  651. result.type = 'base64';
  652. result.data = input.file.data;
  653. return result;
  654. }
  655. if (typeof input.data === 'string') {
  656. if (input.data.match(/^[A-Za-z0-9+/]+=*$/) && input.data.length > 100) {
  657. result.type = 'base64';
  658. result.data = input.data;
  659. return result;
  660. }
  661. if (input.data.startsWith('http://') || input.data.startsWith('https://')) {
  662. result.type = 'url';
  663. result.data = input.data;
  664. return result;
  665. }
  666. if (smartbotic.fs.exists(input.data)) {
  667. result.type = 'filePath';
  668. result.path = input.data;
  669. return result;
  670. }
  671. }
  672. if (input.data && typeof input.data === 'object') {
  673. if (input.data.type === 'binary' && input.data.data) {
  674. result.type = 'base64';
  675. result.data = input.data.data;
  676. return result;
  677. }
  678. if (input.data.collection && input.data.id) {
  679. result.type = 'storage';
  680. result.data = input.data;
  681. return result;
  682. }
  683. }
  684. }
  685. return result;
  686. }
  687. function prepareImageFile(imageInput, timeout) {
  688. var tempPath = null;
  689. if (imageInput.type === 'filePath') {
  690. return { path: imageInput.path, tempPath: null };
  691. }
  692. if (imageInput.type === 'base64') {
  693. var ext = detectImageFormat(imageInput.data) || 'img';
  694. tempPath = generateTempPath(ext);
  695. var writeResult = smartbotic.fs.writeFile(tempPath, imageInput.data, 'base64');
  696. if (!writeResult.success) {
  697. throw new Error('Failed to write temp file: ' + (writeResult.error || 'Unknown error'));
  698. }
  699. return { path: tempPath, tempPath: tempPath };
  700. }
  701. if (imageInput.type === 'url') {
  702. smartbotic.log.info('Downloading image from URL: ' + imageInput.data);
  703. var response = smartbotic.http.request({
  704. method: 'GET',
  705. url: imageInput.data,
  706. timeout: timeout
  707. });
  708. if (response.status < 200 || response.status >= 300) {
  709. throw new Error('Failed to download image: HTTP ' + response.status);
  710. }
  711. var contentType = response.headers['content-type'] || '';
  712. var ext = 'img';
  713. if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
  714. else if (contentType.includes('png')) ext = 'png';
  715. else if (contentType.includes('gif')) ext = 'gif';
  716. else if (contentType.includes('webp')) ext = 'webp';
  717. tempPath = generateTempPath(ext);
  718. var base64Data = smartbotic.utils.base64Encode(response.data);
  719. var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64');
  720. if (!writeResult.success) {
  721. throw new Error('Failed to write downloaded image: ' + (writeResult.error || 'Unknown error'));
  722. }
  723. return { path: tempPath, tempPath: tempPath };
  724. }
  725. if (imageInput.type === 'storage') {
  726. var storageResult = smartbotic.storage.get(imageInput.data.collection, imageInput.data.id);
  727. if (!storageResult.found) {
  728. throw new Error('Image not found in storage: ' + imageInput.data.collection + '/' + imageInput.data.id);
  729. }
  730. var doc = storageResult.document;
  731. var base64Data = doc.data || doc.content || doc.base64;
  732. if (!base64Data) {
  733. throw new Error('Storage document does not contain image data');
  734. }
  735. var ext = detectImageFormat(base64Data) || 'img';
  736. tempPath = generateTempPath(ext);
  737. var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64');
  738. if (!writeResult.success) {
  739. throw new Error('Failed to write storage image: ' + (writeResult.error || 'Unknown error'));
  740. }
  741. return { path: tempPath, tempPath: tempPath };
  742. }
  743. throw new Error('No valid image input found');
  744. }
  745. function cleanupTempFile(tempPath) {
  746. if (tempPath) {
  747. try {
  748. smartbotic.fs.unlink(tempPath);
  749. } catch (e) {
  750. smartbotic.log.warn('Failed to cleanup temp file: ' + tempPath);
  751. }
  752. }
  753. }
  754. function parseImageMagickInfo(output) {
  755. var info = {};
  756. var lines = output.split('\n');
  757. for (var i = 0; i < lines.length; i++) {
  758. var line = lines[i].trim();
  759. if (!line) continue;
  760. var colonIdx = line.indexOf(':');
  761. if (colonIdx > 0) {
  762. var key = line.substring(0, colonIdx).trim().toLowerCase();
  763. var value = line.substring(colonIdx + 1).trim();
  764. if (key === 'image' || key === 'format') {
  765. var formatMatch = value.match(/^(\w+)/);
  766. if (formatMatch) info.format = formatMatch[1].toUpperCase();
  767. }
  768. else if (key === 'geometry' || key === 'page geometry') {
  769. var geomMatch = value.match(/(\d+)x(\d+)/);
  770. if (geomMatch) {
  771. info.width = parseInt(geomMatch[1], 10);
  772. info.height = parseInt(geomMatch[2], 10);
  773. }
  774. }
  775. else if (key === 'colorspace') {
  776. info.colorSpace = value;
  777. }
  778. else if (key === 'depth') {
  779. var depthMatch = value.match(/(\d+)/);
  780. if (depthMatch) info.depth = parseInt(depthMatch[1], 10);
  781. }
  782. else if (key === 'channel depth' || key === 'channels') {
  783. if (value.toLowerCase().includes('alpha')) {
  784. info.hasAlpha = true;
  785. }
  786. }
  787. else if (key === 'alpha') {
  788. info.hasAlpha = value.toLowerCase() !== 'undefined' && value.toLowerCase() !== 'off';
  789. }
  790. else if (key === 'compression') {
  791. info.compression = value;
  792. }
  793. else if (key === 'resolution' || key === 'units') {
  794. if (!info.dpi) info.dpi = {};
  795. var resMatch = value.match(/([\d.]+)x([\d.]+)/);
  796. if (resMatch) {
  797. info.dpi.x = parseFloat(resMatch[1]);
  798. info.dpi.y = parseFloat(resMatch[2]);
  799. }
  800. if (value.includes('PixelsPerInch')) info.dpi.unit = 'dpi';
  801. else if (value.includes('PixelsPerCentimeter')) info.dpi.unit = 'dpcm';
  802. }
  803. else if (key === 'filesize') {
  804. var sizeMatch = value.match(/([\d.]+)\s*(\w+)/);
  805. if (sizeMatch) {
  806. var size = parseFloat(sizeMatch[1]);
  807. var unit = sizeMatch[2].toUpperCase();
  808. if (unit === 'B') info.fileSize = Math.round(size);
  809. else if (unit === 'KB' || unit === 'KIB') info.fileSize = Math.round(size * 1024);
  810. else if (unit === 'MB' || unit === 'MIB') info.fileSize = Math.round(size * 1024 * 1024);
  811. else if (unit === 'GB' || unit === 'GIB') info.fileSize = Math.round(size * 1024 * 1024 * 1024);
  812. }
  813. }
  814. }
  815. }
  816. if (info.hasAlpha === undefined) info.hasAlpha = false;
  817. return info;
  818. }
  819. function parseExifToolOutput(output) {
  820. var exif = {
  821. camera: {},
  822. datetime: {},
  823. gps: {},
  824. exposure: {},
  825. lens: {}
  826. };
  827. var lines = output.split('\n');
  828. for (var i = 0; i < lines.length; i++) {
  829. var line = lines[i].trim();
  830. if (!line) continue;
  831. var colonIdx = line.indexOf(':');
  832. if (colonIdx <= 0) continue;
  833. var key = line.substring(0, colonIdx).trim().toLowerCase().replace(/\s+/g, '');
  834. var value = line.substring(colonIdx + 1).trim();
  835. if (!value || value === '-' || value === 'n/a') continue;
  836. if (key === 'make' || key === 'cameramake') exif.camera.make = value;
  837. else if (key === 'model' || key === 'cameramodel' || key === 'cameramodelname') exif.camera.model = value;
  838. else if (key === 'software') exif.camera.software = value;
  839. else if (key === 'datetimeoriginal' || key === 'createdate') exif.datetime.original = value;
  840. else if (key === 'datetimedigitized' || key === 'digitizeddate') exif.datetime.digitized = value;
  841. else if (key === 'modifydate' || key === 'datetime') exif.datetime.modified = value;
  842. else if (key === 'gpslatitude') {
  843. var latMatch = value.match(/([\d.]+)/);
  844. if (latMatch) {
  845. exif.gps.latitude = parseFloat(latMatch[1]);
  846. if (value.includes('S')) exif.gps.latitude = -exif.gps.latitude;
  847. exif.gps.latitudeRef = value.includes('S') ? 'S' : 'N';
  848. }
  849. }
  850. else if (key === 'gpslongitude') {
  851. var lonMatch = value.match(/([\d.]+)/);
  852. if (lonMatch) {
  853. exif.gps.longitude = parseFloat(lonMatch[1]);
  854. if (value.includes('W')) exif.gps.longitude = -exif.gps.longitude;
  855. exif.gps.longitudeRef = value.includes('W') ? 'W' : 'E';
  856. }
  857. }
  858. else if (key === 'gpsaltitude') {
  859. var altMatch = value.match(/([\d.]+)/);
  860. if (altMatch) exif.gps.altitude = parseFloat(altMatch[1]);
  861. }
  862. else if (key === 'exposuretime' || key === 'shutterspeed') exif.exposure.time = value;
  863. else if (key === 'fnumber' || key === 'aperture') {
  864. var fMatch = value.match(/([\d.]+)/);
  865. if (fMatch) exif.exposure.fNumber = parseFloat(fMatch[1]);
  866. }
  867. else if (key === 'iso' || key === 'isospeedratings') {
  868. var isoMatch = value.match(/(\d+)/);
  869. if (isoMatch) exif.exposure.iso = parseInt(isoMatch[1], 10);
  870. }
  871. else if (key === 'exposureprogram') exif.exposure.program = value;
  872. else if (key === 'exposurecompensation' || key === 'exposurebias') exif.exposure.bias = value;
  873. else if (key === 'focallength') exif.lens.focalLength = value;
  874. else if (key === 'focallengthin35mmformat' || key === 'focallength35efl') {
  875. var fl35Match = value.match(/(\d+)/);
  876. if (fl35Match) exif.lens.focalLength35mm = parseInt(fl35Match[1], 10);
  877. }
  878. else if (key === 'maxaperturevalue') {
  879. var apMatch = value.match(/([\d.]+)/);
  880. if (apMatch) exif.lens.aperture = parseFloat(apMatch[1]);
  881. }
  882. else if (key === 'lensmake') exif.lens.make = value;
  883. else if (key === 'lensmodel' || key === 'lens') exif.lens.model = value;
  884. else if (key === 'orientation') {
  885. var oriMatch = value.match(/(\d)/);
  886. if (oriMatch) exif.orientation = parseInt(oriMatch[1], 10);
  887. }
  888. else if (key === 'flash') exif.flash = value;
  889. else if (key === 'whitebalance') exif.whiteBalance = value;
  890. else if (key === 'meteringmode') exif.meteringMode = value;
  891. }
  892. var hasData = false;
  893. for (var section in exif) {
  894. if (typeof exif[section] === 'object') {
  895. for (var field in exif[section]) {
  896. if (exif[section][field] !== undefined) {
  897. hasData = true;
  898. break;
  899. }
  900. }
  901. } else if (exif[section] !== undefined) {
  902. hasData = true;
  903. }
  904. if (hasData) break;
  905. }
  906. return hasData ? exif : null;
  907. }
  908. function executeInfoOperation(imagePath, includeExif, timeout) {
  909. smartbotic.log.info('Running ImageMagick identify on: ' + imagePath);
  910. var identifyCmd = 'identify -verbose "' + imagePath.replace(/"/g, '\\"') + '"';
  911. var identifyResult = smartbotic.process.exec(identifyCmd, { timeout: timeout });
  912. if (!identifyResult.success) {
  913. throw new Error('ImageMagick identify failed: ' + (identifyResult.stderr || 'Unknown error'));
  914. }
  915. var info = parseImageMagickInfo(identifyResult.stdout);
  916. var stat = smartbotic.fs.stat(imagePath);
  917. if (stat.success && !info.fileSize) {
  918. info.fileSize = stat.size;
  919. }
  920. if (includeExif) {
  921. smartbotic.log.info('Extracting EXIF metadata');
  922. var exiftoolCmd = 'exiftool "' + imagePath.replace(/"/g, '\\"') + '"';
  923. var exifResult = smartbotic.process.exec(exiftoolCmd, { timeout: timeout });
  924. if (exifResult.success && exifResult.stdout) {
  925. var exifData = parseExifToolOutput(exifResult.stdout);
  926. if (exifData) {
  927. info.exif = exifData;
  928. }
  929. } else {
  930. smartbotic.log.debug('No EXIF data available or exiftool not installed');
  931. }
  932. }
  933. return info;
  934. }
  935. function getImageDimensions(imagePath, timeout) {
  936. var cmd = 'identify -format "%w %h" "' + imagePath.replace(/"/g, '\\"') + '"';
  937. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  938. if (!result.success) {
  939. throw new Error('Failed to get image dimensions: ' + (result.stderr || 'Unknown error'));
  940. }
  941. var parts = result.stdout.trim().split(' ');
  942. return {
  943. width: parseInt(parts[0], 10),
  944. height: parseInt(parts[1], 10)
  945. };
  946. }
  947. function getOutputExtension(format, originalPath) {
  948. if (format === 'auto') {
  949. var ext = originalPath.split('.').pop().toLowerCase();
  950. if (['jpg', 'jpeg', 'png', 'gif', 'webp'].indexOf(ext) >= 0) {
  951. return ext === 'jpg' ? 'jpeg' : ext;
  952. }
  953. return 'jpeg';
  954. }
  955. return format;
  956. }
  957. function getMimeType(format) {
  958. var mimeTypes = {
  959. jpeg: 'image/jpeg',
  960. jpg: 'image/jpeg',
  961. png: 'image/png',
  962. gif: 'image/gif',
  963. webp: 'image/webp'
  964. };
  965. return mimeTypes[format] || 'image/jpeg';
  966. }
  967. function buildOutputPath(format) {
  968. var uuid = smartbotic.utils.uuid();
  969. return '/tmp/smartbotic-image-out-' + uuid + '.' + format;
  970. }
  971. function readOutputFile(outputPath) {
  972. var readResult = smartbotic.fs.readFile(outputPath, 'base64');
  973. if (!readResult.success) {
  974. throw new Error('Failed to read output file: ' + (readResult.error || 'Unknown error'));
  975. }
  976. return readResult.data;
  977. }
  978. function getOutputFileSize(outputPath) {
  979. var stat = smartbotic.fs.stat(outputPath);
  980. return stat.success ? stat.size : 0;
  981. }
  982. function cleanupOutputFile(outputPath) {
  983. if (outputPath) {
  984. try {
  985. smartbotic.fs.unlink(outputPath);
  986. } catch (e) {
  987. smartbotic.log.warn('Failed to cleanup output file: ' + outputPath);
  988. }
  989. }
  990. }
  991. function buildQualityArgs(format, quality) {
  992. if (format === 'jpeg' || format === 'webp') {
  993. return ' -quality ' + quality;
  994. }
  995. if (format === 'png') {
  996. var pngQuality = Math.round((100 - quality) / 10);
  997. return ' -quality ' + (pngQuality * 10);
  998. }
  999. return '';
  1000. }
  1001. function executeResizeOperation(imagePath, config, timeout) {
  1002. var dimensions = getImageDimensions(imagePath, timeout);
  1003. var originalWidth = dimensions.width;
  1004. var originalHeight = dimensions.height;
  1005. var resizeMode = config.resizeMode || 'dimensions';
  1006. var maintainAspectRatio = config.maintainAspectRatio !== false;
  1007. var targetWidth, targetHeight;
  1008. if (resizeMode === 'percentage') {
  1009. var percentage = config.resizePercentage || 100;
  1010. targetWidth = Math.round(originalWidth * percentage / 100);
  1011. targetHeight = Math.round(originalHeight * percentage / 100);
  1012. smartbotic.log.info('Resizing by ' + percentage + '% to ' + targetWidth + 'x' + targetHeight);
  1013. } else {
  1014. targetWidth = config.resizeWidth;
  1015. targetHeight = config.resizeHeight;
  1016. if (!targetWidth && !targetHeight) {
  1017. throw new Error('Resize operation requires at least width or height');
  1018. }
  1019. if (maintainAspectRatio) {
  1020. if (targetWidth && !targetHeight) {
  1021. targetHeight = Math.round(originalHeight * targetWidth / originalWidth);
  1022. } else if (targetHeight && !targetWidth) {
  1023. targetWidth = Math.round(originalWidth * targetHeight / originalHeight);
  1024. }
  1025. } else {
  1026. targetWidth = targetWidth || originalWidth;
  1027. targetHeight = targetHeight || originalHeight;
  1028. }
  1029. smartbotic.log.info('Resizing to ' + targetWidth + 'x' + targetHeight + ' (aspect ratio ' + (maintainAspectRatio ? 'maintained' : 'ignored') + ')');
  1030. }
  1031. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1032. var quality = config.outputQuality || 85;
  1033. var outputPath = buildOutputPath(outputFormat);
  1034. var resizeSpec = targetWidth + 'x' + targetHeight;
  1035. if (!maintainAspectRatio && resizeMode === 'dimensions') {
  1036. resizeSpec += '!';
  1037. }
  1038. var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1039. cmd += ' -resize ' + resizeSpec;
  1040. cmd += buildQualityArgs(outputFormat, quality);
  1041. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1042. smartbotic.log.info('Executing: ' + cmd);
  1043. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1044. if (!result.success) {
  1045. cleanupOutputFile(outputPath);
  1046. throw new Error('Resize operation failed: ' + (result.stderr || 'Unknown error'));
  1047. }
  1048. var base64Data = readOutputFile(outputPath);
  1049. var fileSize = getOutputFileSize(outputPath);
  1050. cleanupOutputFile(outputPath);
  1051. return {
  1052. operation: 'resize',
  1053. originalWidth: originalWidth,
  1054. originalHeight: originalHeight,
  1055. width: targetWidth,
  1056. height: targetHeight,
  1057. format: outputFormat.toUpperCase(),
  1058. file: {
  1059. type: 'binary',
  1060. data: base64Data,
  1061. mimeType: getMimeType(outputFormat),
  1062. filename: 'resized.' + outputFormat,
  1063. size: fileSize
  1064. }
  1065. };
  1066. }
  1067. function executeCropOperation(imagePath, config, timeout) {
  1068. var dimensions = getImageDimensions(imagePath, timeout);
  1069. var originalWidth = dimensions.width;
  1070. var originalHeight = dimensions.height;
  1071. var cropX = config.cropX || 0;
  1072. var cropY = config.cropY || 0;
  1073. var cropWidth = config.cropWidth;
  1074. var cropHeight = config.cropHeight;
  1075. if (!cropWidth || !cropHeight) {
  1076. throw new Error('Crop operation requires both width and height');
  1077. }
  1078. if (cropX < 0 || cropY < 0) {
  1079. throw new Error('Crop offset cannot be negative');
  1080. }
  1081. if (cropX + cropWidth > originalWidth || cropY + cropHeight > originalHeight) {
  1082. throw new Error('Crop region exceeds image bounds. Image is ' + originalWidth + 'x' + originalHeight + ', crop would extend to ' + (cropX + cropWidth) + 'x' + (cropY + cropHeight));
  1083. }
  1084. smartbotic.log.info('Cropping ' + cropWidth + 'x' + cropHeight + ' from offset ' + cropX + ',' + cropY);
  1085. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1086. var quality = config.outputQuality || 85;
  1087. var outputPath = buildOutputPath(outputFormat);
  1088. var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1089. cmd += ' -crop ' + cropWidth + 'x' + cropHeight + '+' + cropX + '+' + cropY;
  1090. cmd += ' +repage';
  1091. cmd += buildQualityArgs(outputFormat, quality);
  1092. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1093. smartbotic.log.info('Executing: ' + cmd);
  1094. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1095. if (!result.success) {
  1096. cleanupOutputFile(outputPath);
  1097. throw new Error('Crop operation failed: ' + (result.stderr || 'Unknown error'));
  1098. }
  1099. var base64Data = readOutputFile(outputPath);
  1100. var fileSize = getOutputFileSize(outputPath);
  1101. cleanupOutputFile(outputPath);
  1102. return {
  1103. operation: 'crop',
  1104. originalWidth: originalWidth,
  1105. originalHeight: originalHeight,
  1106. width: cropWidth,
  1107. height: cropHeight,
  1108. cropRegion: {
  1109. x: cropX,
  1110. y: cropY,
  1111. width: cropWidth,
  1112. height: cropHeight
  1113. },
  1114. format: outputFormat.toUpperCase(),
  1115. file: {
  1116. type: 'binary',
  1117. data: base64Data,
  1118. mimeType: getMimeType(outputFormat),
  1119. filename: 'cropped.' + outputFormat,
  1120. size: fileSize
  1121. }
  1122. };
  1123. }
  1124. function executeRotateOperation(imagePath, config, timeout) {
  1125. var dimensions = getImageDimensions(imagePath, timeout);
  1126. var originalWidth = dimensions.width;
  1127. var originalHeight = dimensions.height;
  1128. var rotatePreset = config.rotatePreset || 'custom';
  1129. var angle;
  1130. if (rotatePreset === 'custom') {
  1131. angle = config.rotateAngle;
  1132. if (angle === undefined || angle === null) {
  1133. angle = 90;
  1134. }
  1135. } else {
  1136. angle = parseInt(rotatePreset, 10);
  1137. }
  1138. angle = angle % 360;
  1139. if (angle < 0) {
  1140. angle += 360;
  1141. }
  1142. var backgroundColor = config.backgroundColor || 'transparent';
  1143. smartbotic.log.info('Rotating ' + angle + ' degrees with background: ' + backgroundColor);
  1144. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1145. var quality = config.outputQuality || 85;
  1146. var outputPath = buildOutputPath(outputFormat);
  1147. var supportsTransparency = (outputFormat === 'png' || outputFormat === 'gif' || outputFormat === 'webp');
  1148. var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1149. if (backgroundColor === 'transparent' && supportsTransparency) {
  1150. cmd += ' -background none';
  1151. } else if (backgroundColor === 'transparent') {
  1152. cmd += ' -background white';
  1153. } else {
  1154. cmd += ' -background "' + backgroundColor.replace(/"/g, '\\"') + '"';
  1155. }
  1156. cmd += ' -rotate ' + angle;
  1157. cmd += buildQualityArgs(outputFormat, quality);
  1158. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1159. smartbotic.log.info('Executing: ' + cmd);
  1160. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1161. if (!result.success) {
  1162. cleanupOutputFile(outputPath);
  1163. throw new Error('Rotate operation failed: ' + (result.stderr || 'Unknown error'));
  1164. }
  1165. var newDimensions = getImageDimensions(outputPath, timeout);
  1166. var base64Data = readOutputFile(outputPath);
  1167. var fileSize = getOutputFileSize(outputPath);
  1168. cleanupOutputFile(outputPath);
  1169. return {
  1170. operation: 'rotate',
  1171. originalWidth: originalWidth,
  1172. originalHeight: originalHeight,
  1173. width: newDimensions.width,
  1174. height: newDimensions.height,
  1175. angle: angle,
  1176. backgroundColor: backgroundColor,
  1177. format: outputFormat.toUpperCase(),
  1178. file: {
  1179. type: 'binary',
  1180. data: base64Data,
  1181. mimeType: getMimeType(outputFormat),
  1182. filename: 'rotated.' + outputFormat,
  1183. size: fileSize
  1184. }
  1185. };
  1186. }
  1187. function executeFilterOperation(imagePath, config, input, timeout) {
  1188. var dimensions = getImageDimensions(imagePath, timeout);
  1189. var originalWidth = dimensions.width;
  1190. var originalHeight = dimensions.height;
  1191. var filtersApplied = [];
  1192. var filterSettings = {};
  1193. var filterArgs = '';
  1194. if (config.filterBlur) {
  1195. var blurRadius = config.filterBlurRadius || 5;
  1196. filterArgs += ' -blur 0x' + blurRadius;
  1197. filtersApplied.push('blur');
  1198. filterSettings.blur = { enabled: true, radius: blurRadius };
  1199. smartbotic.log.info('Applying blur with radius: ' + blurRadius);
  1200. }
  1201. if (config.filterSharpen) {
  1202. var sharpenAmount = config.filterSharpenAmount || 1;
  1203. filterArgs += ' -sharpen 0x' + sharpenAmount;
  1204. filtersApplied.push('sharpen');
  1205. filterSettings.sharpen = { enabled: true, amount: sharpenAmount };
  1206. smartbotic.log.info('Applying sharpen with amount: ' + sharpenAmount);
  1207. }
  1208. if (config.filterGrayscale) {
  1209. filterArgs += ' -colorspace Gray';
  1210. filtersApplied.push('grayscale');
  1211. filterSettings.grayscale = true;
  1212. smartbotic.log.info('Applying grayscale conversion');
  1213. }
  1214. if (config.filterSepia) {
  1215. var sepiaIntensity = config.filterSepiaIntensity || 80;
  1216. filterArgs += ' -sepia-tone ' + sepiaIntensity + '%';
  1217. filtersApplied.push('sepia');
  1218. filterSettings.sepia = { enabled: true, intensity: sepiaIntensity };
  1219. smartbotic.log.info('Applying sepia with intensity: ' + sepiaIntensity + '%');
  1220. }
  1221. if (config.filterBrightness) {
  1222. var brightnessValue = config.filterBrightnessValue || 0;
  1223. var brightnessPct = 100 + brightnessValue;
  1224. filterArgs += ' -modulate ' + brightnessPct + ',100,100';
  1225. filtersApplied.push('brightness');
  1226. filterSettings.brightness = { enabled: true, value: brightnessValue };
  1227. smartbotic.log.info('Adjusting brightness by: ' + brightnessValue);
  1228. }
  1229. if (config.filterContrast) {
  1230. var contrastValue = config.filterContrastValue || 0;
  1231. if (contrastValue > 0) {
  1232. for (var i = 0; i < Math.min(contrastValue, 10); i++) {
  1233. filterArgs += ' -contrast';
  1234. }
  1235. if (contrastValue > 10) {
  1236. filterArgs += ' -sigmoidal-contrast ' + (contrastValue / 10) + ',50%';
  1237. }
  1238. } else if (contrastValue < 0) {
  1239. var absContrast = Math.abs(contrastValue);
  1240. for (var j = 0; j < Math.min(absContrast, 10); j++) {
  1241. filterArgs += ' +contrast';
  1242. }
  1243. if (absContrast > 10) {
  1244. filterArgs += ' +sigmoidal-contrast ' + (absContrast / 10) + ',50%';
  1245. }
  1246. }
  1247. filtersApplied.push('contrast');
  1248. filterSettings.contrast = { enabled: true, value: contrastValue };
  1249. smartbotic.log.info('Adjusting contrast by: ' + contrastValue);
  1250. }
  1251. if (filtersApplied.length === 0) {
  1252. throw new Error('Filter operation requires at least one filter to be enabled');
  1253. }
  1254. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1255. var quality = config.outputQuality || 85;
  1256. var outputPath = buildOutputPath(outputFormat);
  1257. var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1258. cmd += filterArgs;
  1259. cmd += buildQualityArgs(outputFormat, quality);
  1260. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1261. smartbotic.log.info('Executing: ' + cmd);
  1262. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1263. if (!result.success) {
  1264. cleanupOutputFile(outputPath);
  1265. throw new Error('Filter operation failed: ' + (result.stderr || 'Unknown error'));
  1266. }
  1267. var base64Data = readOutputFile(outputPath);
  1268. var fileSize = getOutputFileSize(outputPath);
  1269. cleanupOutputFile(outputPath);
  1270. return {
  1271. operation: 'filter',
  1272. originalWidth: originalWidth,
  1273. originalHeight: originalHeight,
  1274. width: originalWidth,
  1275. height: originalHeight,
  1276. filtersApplied: filtersApplied,
  1277. filterSettings: filterSettings,
  1278. format: outputFormat.toUpperCase(),
  1279. file: {
  1280. type: 'binary',
  1281. data: base64Data,
  1282. mimeType: getMimeType(outputFormat),
  1283. filename: 'filtered.' + outputFormat,
  1284. size: fileSize
  1285. }
  1286. };
  1287. }
  1288. function prepareWatermarkImage(config, input, timeout) {
  1289. var watermarkType = config.watermarkType || 'text';
  1290. if (watermarkType !== 'image') {
  1291. return null;
  1292. }
  1293. var source = config.watermarkImageSource || 'filePath';
  1294. var tempPath = null;
  1295. if (source === 'filePath') {
  1296. var filePath = interpolate(config.watermarkImagePath, input);
  1297. if (!filePath) {
  1298. throw new Error('Watermark image path is required');
  1299. }
  1300. if (!smartbotic.fs.exists(filePath)) {
  1301. throw new Error('Watermark image file not found: ' + filePath);
  1302. }
  1303. return { path: filePath, tempPath: null };
  1304. }
  1305. if (source === 'base64') {
  1306. var base64Data = config.watermarkImageBase64;
  1307. if (!base64Data) {
  1308. throw new Error('Watermark image base64 data is required');
  1309. }
  1310. var ext = detectImageFormat(base64Data) || 'png';
  1311. tempPath = generateTempPath('watermark-' + ext);
  1312. var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64');
  1313. if (!writeResult.success) {
  1314. throw new Error('Failed to write watermark temp file: ' + (writeResult.error || 'Unknown error'));
  1315. }
  1316. return { path: tempPath, tempPath: tempPath };
  1317. }
  1318. if (source === 'url') {
  1319. var url = interpolate(config.watermarkImageUrl, input);
  1320. if (!url) {
  1321. throw new Error('Watermark image URL is required');
  1322. }
  1323. smartbotic.log.info('Downloading watermark image from URL: ' + url);
  1324. var response = smartbotic.http.request({
  1325. method: 'GET',
  1326. url: url,
  1327. timeout: timeout
  1328. });
  1329. if (response.status < 200 || response.status >= 300) {
  1330. throw new Error('Failed to download watermark image: HTTP ' + response.status);
  1331. }
  1332. var contentType = response.headers['content-type'] || '';
  1333. var ext = 'png';
  1334. if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
  1335. else if (contentType.includes('gif')) ext = 'gif';
  1336. else if (contentType.includes('webp')) ext = 'webp';
  1337. tempPath = generateTempPath('watermark-' + ext);
  1338. var base64 = smartbotic.utils.base64Encode(response.data);
  1339. var writeResult = smartbotic.fs.writeFile(tempPath, base64, 'base64');
  1340. if (!writeResult.success) {
  1341. throw new Error('Failed to write downloaded watermark: ' + (writeResult.error || 'Unknown error'));
  1342. }
  1343. return { path: tempPath, tempPath: tempPath };
  1344. }
  1345. throw new Error('Invalid watermark image source: ' + source);
  1346. }
  1347. function executeWatermarkOperation(imagePath, config, input, timeout) {
  1348. var dimensions = getImageDimensions(imagePath, timeout);
  1349. var originalWidth = dimensions.width;
  1350. var originalHeight = dimensions.height;
  1351. var watermarkType = config.watermarkType || 'text';
  1352. var position = config.watermarkPosition || 'center';
  1353. var opacity = config.watermarkOpacity !== undefined ? config.watermarkOpacity : 50;
  1354. var margin = config.watermarkMargin || 10;
  1355. var gravityMap = {
  1356. center: 'Center',
  1357. northwest: 'NorthWest',
  1358. north: 'North',
  1359. northeast: 'NorthEast',
  1360. west: 'West',
  1361. east: 'East',
  1362. southwest: 'SouthWest',
  1363. south: 'South',
  1364. southeast: 'SouthEast'
  1365. };
  1366. var gravity = gravityMap[position] || 'Center';
  1367. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1368. var quality = config.outputQuality || 85;
  1369. var outputPath = buildOutputPath(outputFormat);
  1370. var watermarkInfo = {
  1371. type: watermarkType,
  1372. position: position,
  1373. opacity: opacity
  1374. };
  1375. var watermarkImageInfo = null;
  1376. var cmd;
  1377. if (watermarkType === 'text') {
  1378. var text = interpolate(config.watermarkText, input);
  1379. if (!text) {
  1380. throw new Error('Watermark text is required');
  1381. }
  1382. var font = config.watermarkFont || 'Arial';
  1383. var fontSize = config.watermarkFontSize || 48;
  1384. var color = config.watermarkColor || 'white';
  1385. watermarkInfo.text = text;
  1386. var escapedText = text.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
  1387. cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1388. cmd += ' -gravity ' + gravity;
  1389. cmd += ' -font "' + font.replace(/"/g, '\\"') + '"';
  1390. cmd += ' -pointsize ' + fontSize;
  1391. cmd += ' -fill "' + color.replace(/"/g, '\\"') + '"';
  1392. cmd += ' -stroke none';
  1393. cmd += ' -annotate +' + margin + '+' + margin + ' "' + escapedText + '"';
  1394. if (opacity < 100) {
  1395. cmd += ' -channel A -evaluate multiply ' + (opacity / 100);
  1396. }
  1397. cmd += buildQualityArgs(outputFormat, quality);
  1398. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1399. smartbotic.log.info('Applying text watermark: "' + text + '" at ' + position + ' with opacity ' + opacity + '%');
  1400. } else {
  1401. watermarkImageInfo = prepareWatermarkImage(config, input, timeout);
  1402. var watermarkPath = watermarkImageInfo.path;
  1403. watermarkInfo.imagePath = watermarkPath;
  1404. var scale = config.watermarkScale || 20;
  1405. var watermarkWidth = Math.round(originalWidth * scale / 100);
  1406. smartbotic.log.info('Applying image watermark at ' + position + ' with opacity ' + opacity + '% and scale ' + scale + '%');
  1407. cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
  1408. cmd += ' \\( "' + watermarkPath.replace(/"/g, '\\"') + '"';
  1409. cmd += ' -resize ' + watermarkWidth + 'x';
  1410. if (opacity < 100) {
  1411. cmd += ' -alpha set -channel A -evaluate multiply ' + (opacity / 100) + ' +channel';
  1412. }
  1413. cmd += ' \\)';
  1414. cmd += ' -gravity ' + gravity;
  1415. cmd += ' -geometry +' + margin + '+' + margin;
  1416. cmd += ' -composite';
  1417. cmd += buildQualityArgs(outputFormat, quality);
  1418. cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
  1419. }
  1420. smartbotic.log.info('Executing: ' + cmd);
  1421. var result = smartbotic.process.exec(cmd, { timeout: timeout });
  1422. if (watermarkImageInfo && watermarkImageInfo.tempPath) {
  1423. cleanupTempFile(watermarkImageInfo.tempPath);
  1424. }
  1425. if (!result.success) {
  1426. cleanupOutputFile(outputPath);
  1427. throw new Error('Watermark operation failed: ' + (result.stderr || 'Unknown error'));
  1428. }
  1429. var base64Data = readOutputFile(outputPath);
  1430. var fileSize = getOutputFileSize(outputPath);
  1431. cleanupOutputFile(outputPath);
  1432. return {
  1433. operation: 'watermark',
  1434. originalWidth: originalWidth,
  1435. originalHeight: originalHeight,
  1436. width: originalWidth,
  1437. height: originalHeight,
  1438. watermark: watermarkInfo,
  1439. format: outputFormat.toUpperCase(),
  1440. file: {
  1441. type: 'binary',
  1442. data: base64Data,
  1443. mimeType: getMimeType(outputFormat),
  1444. filename: 'watermarked.' + outputFormat,
  1445. size: fileSize
  1446. }
  1447. };
  1448. }
  1449. async function execute(config, input) {
  1450. var operation = config.operation || 'info';
  1451. var timeout = config.timeout || 30000;
  1452. var supportedOperations = ['info', 'resize', 'crop', 'rotate', 'filter', 'watermark'];
  1453. if (supportedOperations.indexOf(operation) < 0) {
  1454. throw new Error('Operation "' + operation + '" is not yet implemented. Supported: ' + supportedOperations.join(', '));
  1455. }
  1456. var imageInput = resolveImageInput(config, input);
  1457. if (!imageInput.type) {
  1458. throw new Error('No valid image input found. Provide base64 data, file path, URL, or storage reference.');
  1459. }
  1460. smartbotic.log.info('Image input source: ' + imageInput.type);
  1461. var fileInfo = null;
  1462. try {
  1463. fileInfo = prepareImageFile(imageInput, timeout);
  1464. smartbotic.log.info('Processing image: ' + fileInfo.path);
  1465. var result;
  1466. if (operation === 'info') {
  1467. result = executeInfoOperation(fileInfo.path, config.includeExif !== false, timeout);
  1468. } else if (operation === 'resize') {
  1469. result = executeResizeOperation(fileInfo.path, config, timeout);
  1470. } else if (operation === 'crop') {
  1471. result = executeCropOperation(fileInfo.path, config, timeout);
  1472. } else if (operation === 'rotate') {
  1473. result = executeRotateOperation(fileInfo.path, config, timeout);
  1474. } else if (operation === 'filter') {
  1475. result = executeFilterOperation(fileInfo.path, config, input, timeout);
  1476. } else if (operation === 'watermark') {
  1477. result = executeWatermarkOperation(fileInfo.path, config, input, timeout);
  1478. }
  1479. result.sourceType = imageInput.type;
  1480. result.sourcePath = fileInfo.path;
  1481. return result;
  1482. } finally {
  1483. if (fileInfo && fileInfo.tempPath) {
  1484. cleanupTempFile(fileInfo.tempPath);
  1485. }
  1486. }
  1487. }
  1488. module.exports = { configSchema, inputSchema, outputSchema, execute };