image.js 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218
  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: 'DejaVu-Sans',
  271. description: 'Font for the watermark text. ImageMagick spells names with hyphens - DejaVu-Sans resolves, "DejaVu Sans" does not - or give a full path to a .ttf. The old default was Arial, which Linux does not have, so every text watermark failed with "unable to read font". Leaving it empty is not a fallback: there is no default font configured, and an empty name fails the same way',
  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 executeInfoOperation(imagePath, includeExif) {
  773. smartbotic.log.info('Reading image metadata: ' + imagePath);
  774. var probe = smartbotic.image.info(imagePath, includeExif === true);
  775. if (!probe.success) {
  776. throw new Error(probe.error);
  777. }
  778. // Returned as the library reports it. This used to be scraped out of
  779. // `identify -verbose` and `exiftool` with regular expressions, so a change
  780. // in either tool's phrasing produced a wrong answer rather than an error -
  781. // and an absent EXIF block was indistinguishable from one that failed to
  782. // parse. The exif key is now simply missing when the file carries none.
  783. return probe.info;
  784. }
  785. function getImageDimensions(imagePath) {
  786. var probe = smartbotic.image.info(imagePath, false);
  787. if (!probe.success) {
  788. throw new Error('Failed to read the image: ' + probe.error);
  789. }
  790. return { width: probe.info.width, height: probe.info.height };
  791. }
  792. function getOutputExtension(format, originalPath) {
  793. if (format === 'auto') {
  794. var ext = originalPath.split('.').pop().toLowerCase();
  795. if (['jpg', 'jpeg', 'png', 'gif', 'webp'].indexOf(ext) >= 0) {
  796. return ext === 'jpg' ? 'jpeg' : ext;
  797. }
  798. return 'jpeg';
  799. }
  800. return format;
  801. }
  802. function getMimeType(format) {
  803. var mimeTypes = {
  804. jpeg: 'image/jpeg',
  805. jpg: 'image/jpeg',
  806. png: 'image/png',
  807. gif: 'image/gif',
  808. webp: 'image/webp'
  809. };
  810. return mimeTypes[format] || 'image/jpeg';
  811. }
  812. // Every transforming operation ends the same way: apply, write, report what
  813. // came out. Quality is passed as a number rather than assembled into command
  814. // flags - Magick++ takes it directly, and the old buildQualityArgs had to know
  815. // that PNG's quality means something different from JPEG's.
  816. function runTransform(imagePath, outputPath, operations, outputFormat, config) {
  817. var quality = config.outputQuality || 85;
  818. var written = smartbotic.image.transform(imagePath, outputPath, operations, {
  819. format: outputFormat,
  820. quality: quality
  821. });
  822. if (!written.success) {
  823. throw new Error(written.error);
  824. }
  825. return {
  826. outputPath: outputPath,
  827. width: written.result.width,
  828. height: written.result.height,
  829. format: written.result.format,
  830. fileSize: written.result.fileSize
  831. };
  832. }
  833. function buildOutputPath(format) {
  834. var uuid = smartbotic.utils.uuid();
  835. return '/tmp/smartbotic-image-out-' + uuid + '.' + format;
  836. }
  837. function readOutputFile(outputPath) {
  838. var readResult = smartbotic.fs.readFile(outputPath, 'base64');
  839. if (!readResult.success) {
  840. throw new Error('Failed to read output file: ' + (readResult.error || 'Unknown error'));
  841. }
  842. return readResult.data;
  843. }
  844. function getOutputFileSize(outputPath) {
  845. var stat = smartbotic.fs.stat(outputPath);
  846. return stat.success ? stat.size : 0;
  847. }
  848. function cleanupOutputFile(outputPath) {
  849. if (outputPath) {
  850. try {
  851. smartbotic.fs.unlink(outputPath);
  852. } catch (e) {
  853. smartbotic.log.warn('Failed to cleanup output file: ' + outputPath);
  854. }
  855. }
  856. }
  857. function executeResizeOperation(imagePath, config) {
  858. var dimensions = getImageDimensions(imagePath);
  859. var originalWidth = dimensions.width;
  860. var originalHeight = dimensions.height;
  861. var resizeMode = config.resizeMode || 'dimensions';
  862. var maintainAspectRatio = config.maintainAspectRatio !== false;
  863. var targetWidth, targetHeight;
  864. if (resizeMode === 'percentage') {
  865. var percentage = config.resizePercentage || 100;
  866. targetWidth = Math.round(originalWidth * percentage / 100);
  867. targetHeight = Math.round(originalHeight * percentage / 100);
  868. smartbotic.log.info('Resizing by ' + percentage + '% to ' + targetWidth + 'x' + targetHeight);
  869. } else {
  870. targetWidth = config.resizeWidth;
  871. targetHeight = config.resizeHeight;
  872. if (!targetWidth && !targetHeight) {
  873. throw new Error('Resize operation requires at least width or height');
  874. }
  875. if (maintainAspectRatio) {
  876. if (targetWidth && !targetHeight) {
  877. targetHeight = Math.round(originalHeight * targetWidth / originalWidth);
  878. } else if (targetHeight && !targetWidth) {
  879. targetWidth = Math.round(originalWidth * targetHeight / originalHeight);
  880. }
  881. } else {
  882. targetWidth = targetWidth || originalWidth;
  883. targetHeight = targetHeight || originalHeight;
  884. }
  885. smartbotic.log.info('Resizing to ' + targetWidth + 'x' + targetHeight +
  886. ' (aspect ratio ' + (maintainAspectRatio ? 'maintained' : 'ignored') + ')');
  887. }
  888. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  889. var outputPath = buildOutputPath(outputFormat);
  890. return runTransform(imagePath, outputPath, [{
  891. op: 'resize',
  892. width: targetWidth,
  893. height: targetHeight,
  894. // Only the explicit-dimensions path can be told to distort; a
  895. // percentage resize is aspect-preserving by definition.
  896. ignoreAspect: !maintainAspectRatio && resizeMode === 'dimensions'
  897. }], outputFormat, config);
  898. }
  899. function executeCropOperation(imagePath, config) {
  900. var dimensions = getImageDimensions(imagePath);
  901. var originalWidth = dimensions.width;
  902. var originalHeight = dimensions.height;
  903. var cropX = config.cropX || 0;
  904. var cropY = config.cropY || 0;
  905. var cropWidth = config.cropWidth;
  906. var cropHeight = config.cropHeight;
  907. if (!cropWidth || !cropHeight) {
  908. throw new Error('Crop operation requires both width and height');
  909. }
  910. if (cropX < 0 || cropY < 0) {
  911. throw new Error('Crop offset cannot be negative');
  912. }
  913. if (cropX + cropWidth > originalWidth || cropY + cropHeight > originalHeight) {
  914. throw new Error('Crop region exceeds image bounds. Image is ' +
  915. originalWidth + 'x' + originalHeight + ', crop would extend to ' +
  916. (cropX + cropWidth) + 'x' + (cropY + cropHeight));
  917. }
  918. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  919. var outputPath = buildOutputPath(outputFormat);
  920. return runTransform(imagePath, outputPath, [
  921. { op: 'crop', x: cropX, y: cropY, width: cropWidth, height: cropHeight }
  922. ], outputFormat, config);
  923. }
  924. function executeRotateOperation(imagePath, config) {
  925. var rotatePreset = config.rotatePreset || 'custom';
  926. var angle = rotatePreset === 'custom' ? config.rotateAngle : Number(rotatePreset);
  927. if (angle === undefined || angle === null || isNaN(Number(angle))) {
  928. throw new Error('Rotate operation requires an angle');
  929. }
  930. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  931. var outputPath = buildOutputPath(outputFormat);
  932. return runTransform(imagePath, outputPath, [
  933. { op: 'rotate', angle: Number(angle), background: config.backgroundColor || 'transparent' }
  934. ], outputFormat, config);
  935. }
  936. function executeFilterOperation(imagePath, config) {
  937. var operations = [];
  938. if (config.filterBlur) {
  939. operations.push({ op: 'blur', radius: config.filterBlurRadius || 5 });
  940. }
  941. if (config.filterSharpen) {
  942. operations.push({ op: 'sharpen', amount: config.filterSharpenAmount || 1 });
  943. }
  944. if (config.filterGrayscale) {
  945. operations.push({ op: 'grayscale' });
  946. }
  947. if (config.filterSepia) {
  948. operations.push({ op: 'sepia', intensity: config.filterSepiaIntensity || 80 });
  949. }
  950. if (config.filterBrightness) {
  951. operations.push({ op: 'brightness', value: config.filterBrightnessValue || 0 });
  952. }
  953. if (config.filterContrast) {
  954. operations.push({ op: 'contrast', value: config.filterContrastValue || 0 });
  955. }
  956. if (operations.length === 0) {
  957. throw new Error('Filter operation requires at least one filter to be enabled');
  958. }
  959. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  960. var outputPath = buildOutputPath(outputFormat);
  961. return runTransform(imagePath, outputPath, operations, outputFormat, config);
  962. }
  963. function prepareWatermarkImage(config, input, timeout) {
  964. var watermarkType = config.watermarkType || 'text';
  965. if (watermarkType !== 'image') {
  966. return null;
  967. }
  968. var source = config.watermarkImageSource || 'filePath';
  969. var tempPath = null;
  970. if (source === 'filePath') {
  971. var filePath = interpolate(config.watermarkImagePath, input);
  972. if (!filePath) {
  973. throw new Error('Watermark image path is required');
  974. }
  975. if (!smartbotic.fs.exists(filePath)) {
  976. throw new Error('Watermark image file not found: ' + filePath);
  977. }
  978. return { path: filePath, tempPath: null };
  979. }
  980. if (source === 'base64') {
  981. var base64Data = config.watermarkImageBase64;
  982. if (!base64Data) {
  983. throw new Error('Watermark image base64 data is required');
  984. }
  985. var ext = detectImageFormat(base64Data) || 'png';
  986. tempPath = generateTempPath('watermark-' + ext);
  987. var writeResult = smartbotic.fs.writeFile(tempPath, base64Data, 'base64');
  988. if (!writeResult.success) {
  989. throw new Error('Failed to write watermark temp file: ' + (writeResult.error || 'Unknown error'));
  990. }
  991. return { path: tempPath, tempPath: tempPath };
  992. }
  993. if (source === 'url') {
  994. var url = interpolate(config.watermarkImageUrl, input);
  995. if (!url) {
  996. throw new Error('Watermark image URL is required');
  997. }
  998. smartbotic.log.info('Downloading watermark image from URL: ' + url);
  999. var response = smartbotic.http.request({
  1000. method: 'GET',
  1001. url: url,
  1002. timeout: timeout
  1003. });
  1004. if (response.status < 200 || response.status >= 300) {
  1005. throw new Error('Failed to download watermark image: HTTP ' + response.status);
  1006. }
  1007. var contentType = response.headers['content-type'] || '';
  1008. var ext = 'png';
  1009. if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
  1010. else if (contentType.includes('gif')) ext = 'gif';
  1011. else if (contentType.includes('webp')) ext = 'webp';
  1012. tempPath = generateTempPath('watermark-' + ext);
  1013. var base64 = smartbotic.utils.base64Encode(response.data);
  1014. var writeResult = smartbotic.fs.writeFile(tempPath, base64, 'base64');
  1015. if (!writeResult.success) {
  1016. throw new Error('Failed to write downloaded watermark: ' + (writeResult.error || 'Unknown error'));
  1017. }
  1018. return { path: tempPath, tempPath: tempPath };
  1019. }
  1020. throw new Error('Invalid watermark image source: ' + source);
  1021. }
  1022. function executeWatermarkOperation(imagePath, config, input, timeout) {
  1023. var dimensions = getImageDimensions(imagePath);
  1024. var originalWidth = dimensions.width;
  1025. var watermarkType = config.watermarkType || 'text';
  1026. var position = config.watermarkPosition || 'center';
  1027. var opacity = config.watermarkOpacity !== undefined ? config.watermarkOpacity : 50;
  1028. var margin = config.watermarkMargin || 10;
  1029. var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
  1030. var outputPath = buildOutputPath(outputFormat);
  1031. var watermarkInfo = { type: watermarkType, position: position, opacity: opacity };
  1032. var watermarkImageInfo = null;
  1033. var operation;
  1034. if (watermarkType === 'text') {
  1035. var text = interpolate(config.watermarkText, input);
  1036. if (!text) {
  1037. throw new Error('Watermark text is required');
  1038. }
  1039. watermarkInfo.text = text;
  1040. // The text goes across as a value, not spliced into a command line.
  1041. // It used to be escaped by hand for quotes, dollars and backticks -
  1042. // which is the kind of thing that is wrong for exactly the input
  1043. // nobody tested, and a watermark is user-supplied text.
  1044. operation = {
  1045. op: 'annotate',
  1046. text: text,
  1047. gravity: position,
  1048. font: config.watermarkFont || '',
  1049. pointSize: config.watermarkFontSize || 48,
  1050. fill: config.watermarkColor || 'white',
  1051. opacity: opacity,
  1052. margin: margin
  1053. };
  1054. smartbotic.log.info('Applying text watermark: "' + text + '" at ' + position +
  1055. ' with opacity ' + opacity + '%');
  1056. } else {
  1057. watermarkImageInfo = prepareWatermarkImage(config, input, timeout);
  1058. watermarkInfo.imagePath = watermarkImageInfo.path;
  1059. var scale = config.watermarkScale || 20;
  1060. var watermarkWidth = Math.round(originalWidth * scale / 100);
  1061. // The overlay is scaled into its own file first, because composite
  1062. // takes it as it finds it.
  1063. var scaledPath = buildOutputPath(outputFormat);
  1064. var scaled = smartbotic.image.transform(watermarkImageInfo.path, scaledPath,
  1065. [{ op: 'resize', width: watermarkWidth, height: watermarkWidth }], {});
  1066. if (!scaled.success) {
  1067. throw new Error('Could not scale the watermark image: ' + scaled.error);
  1068. }
  1069. operation = {
  1070. op: 'composite',
  1071. imagePath: scaledPath,
  1072. gravity: position,
  1073. opacity: opacity,
  1074. margin: margin
  1075. };
  1076. smartbotic.log.info('Applying image watermark at ' + position + ' with opacity ' +
  1077. opacity + '% and scale ' + scale + '%');
  1078. }
  1079. var result = runTransform(imagePath, outputPath, [operation], outputFormat, config);
  1080. result.watermark = watermarkInfo;
  1081. if (watermarkImageInfo && watermarkImageInfo.cleanup) {
  1082. smartbotic.fs.unlink(watermarkImageInfo.path);
  1083. }
  1084. return result;
  1085. }
  1086. async function execute(config, input) {
  1087. var operation = config.operation || 'info';
  1088. var timeout = config.timeout || 30000;
  1089. var supportedOperations = ['info', 'resize', 'crop', 'rotate', 'filter', 'watermark'];
  1090. if (supportedOperations.indexOf(operation) < 0) {
  1091. throw new Error('Operation "' + operation + '" is not yet implemented. Supported: ' + supportedOperations.join(', '));
  1092. }
  1093. var imageInput = resolveImageInput(config, input);
  1094. if (!imageInput.type) {
  1095. throw new Error('No valid image input found. Provide base64 data, file path, URL, or storage reference.');
  1096. }
  1097. smartbotic.log.info('Image input source: ' + imageInput.type);
  1098. var fileInfo = null;
  1099. try {
  1100. fileInfo = prepareImageFile(imageInput, timeout);
  1101. smartbotic.log.info('Processing image: ' + fileInfo.path);
  1102. var result;
  1103. if (operation === 'info') {
  1104. result = executeInfoOperation(fileInfo.path, config.includeExif !== false);
  1105. } else if (operation === 'resize') {
  1106. result = executeResizeOperation(fileInfo.path, config);
  1107. } else if (operation === 'crop') {
  1108. result = executeCropOperation(fileInfo.path, config);
  1109. } else if (operation === 'rotate') {
  1110. result = executeRotateOperation(fileInfo.path, config);
  1111. } else if (operation === 'filter') {
  1112. result = executeFilterOperation(fileInfo.path, config);
  1113. } else if (operation === 'watermark') {
  1114. result = executeWatermarkOperation(fileInfo.path, config, input, timeout);
  1115. }
  1116. result.sourceType = imageInput.type;
  1117. result.sourcePath = fileInfo.path;
  1118. return result;
  1119. } finally {
  1120. if (fileInfo && fileInfo.tempPath) {
  1121. cleanupTempFile(fileInfo.tempPath);
  1122. }
  1123. }
  1124. }
  1125. module.exports = { configSchema, inputSchema, outputSchema, execute };