sdcpp-fetch-output.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. /**
  2. * @node sdcpp-fetch-output
  3. * @name SD.cpp Fetch Output
  4. * @category sdcpp
  5. * @version 1.0.0
  6. * @description Download the images or videos a finished sdcpp-restapi job produced
  7. * @icon download
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. serverUrl: {
  13. type: 'string',
  14. title: 'Server URL',
  15. description: 'Base address of the sdcpp-restapi server',
  16. default: 'http://localhost:8077'
  17. },
  18. credentialId: {
  19. type: 'string',
  20. title: 'Credential',
  21. description: 'A basic credential holding the sdcpp-restapi username and password',
  22. dynamicOptions: {
  23. source: 'credentials',
  24. filter: { type: ['basic'] }
  25. }
  26. },
  27. paths: {
  28. type: 'array',
  29. title: 'Output Paths',
  30. description: 'Relative output paths from a finished job, normally {{$json.outputs}} from an SD.cpp Wait For Job node. A comma separated string works too',
  31. items: { type: 'string' }
  32. },
  33. limit: {
  34. type: 'number',
  35. title: 'Limit',
  36. description: 'Download at most this many of the paths given. 0 means all of them',
  37. default: 0
  38. },
  39. storeInDatabase: {
  40. type: 'boolean',
  41. title: 'Store In Database',
  42. description: 'Also save each file to a storage collection, so a later execution can read it back by id',
  43. default: false
  44. },
  45. collection: {
  46. type: 'string',
  47. title: 'Collection',
  48. description: 'Storage collection to save into. Must not begin with an underscore, those silently lose writes',
  49. default: 'sdcpp_outputs'
  50. },
  51. storageTtlHours: {
  52. type: 'number',
  53. title: 'Storage TTL (hours)',
  54. description: 'How long a stored file is kept before it expires. Set to 0 to keep it forever. Defaults to 24 hours',
  55. default: 24
  56. },
  57. timeout: {
  58. type: 'number',
  59. title: 'Timeout (ms)',
  60. description: 'Per file. A large video can take a while to transfer',
  61. default: 120000
  62. }
  63. },
  64. required: ['credentialId']
  65. };
  66. const inputSchema = {
  67. type: 'object',
  68. properties: {
  69. data: { type: 'any' }
  70. }
  71. };
  72. const outputSchema = {
  73. type: 'object',
  74. properties: {
  75. count: { type: 'number', description: 'How many files were downloaded' },
  76. files: {
  77. type: 'array',
  78. description: 'One entry per file, each a binary file object other nodes accept'
  79. },
  80. file: {
  81. type: 'object',
  82. description: 'The first file, for the common case of a single image feeding a node that takes one'
  83. },
  84. totalBytes: { type: 'number' },
  85. storage: { type: 'array', description: 'Collection and id of each stored file, when storing was enabled' }
  86. }
  87. };
  88. function normalizeServer(url) {
  89. const value = String(url || '').trim();
  90. if (!value) {
  91. throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077');
  92. }
  93. return value.replace(/\/+$/, '');
  94. }
  95. function readCredential(credentialId) {
  96. const auth = smartbotic.credentials.get(credentialId);
  97. if (!auth || auth.success !== true) {
  98. throw new Error('SD.cpp: could not read the credential: ' +
  99. ((auth && auth.error) || 'unknown error'));
  100. }
  101. const value = auth.headerValue || '';
  102. if (value.indexOf('Basic ') !== 0) {
  103. throw new Error('SD.cpp: the credential must be a basic one, holding the sdcpp-restapi ' +
  104. 'username and password');
  105. }
  106. const decoded = smartbotic.utils.base64Decode(value.substring(6));
  107. const separator = decoded.indexOf(':');
  108. if (separator < 1) {
  109. throw new Error('SD.cpp: the credential is malformed, expected a username and a password');
  110. }
  111. return {
  112. username: decoded.substring(0, separator),
  113. password: decoded.substring(separator + 1)
  114. };
  115. }
  116. function call(options) {
  117. const response = smartbotic.http.request(options);
  118. let body = response.data;
  119. if (typeof body === 'string' && body.length > 0) {
  120. try {
  121. body = JSON.parse(body);
  122. } catch (e) {
  123. const snippet = body.substring(0, 200).replace(/\s+/g, ' ');
  124. throw new Error('SD.cpp: ' + options.what + ' returned HTTP ' + response.status +
  125. ' with a body that is not JSON: ' + snippet);
  126. }
  127. }
  128. if (response.status < 200 || response.status >= 300) {
  129. const detail = (body && (body.message || body.error)) || ('HTTP ' + response.status);
  130. throw new Error('SD.cpp: ' + options.what + ' failed: ' + detail);
  131. }
  132. return body || {};
  133. }
  134. function login(server, credential, timeout) {
  135. const session = call({
  136. method: 'POST',
  137. url: server + '/auth/login',
  138. headers: { 'Content-Type': 'application/json' },
  139. body: JSON.stringify({
  140. username: credential.username,
  141. password: credential.password
  142. }),
  143. timeout: timeout,
  144. what: 'signing in'
  145. });
  146. if (!session.token) {
  147. throw new Error('SD.cpp: the server accepted the login but returned no token');
  148. }
  149. return session.token;
  150. }
  151. function toPathList(value) {
  152. if (value === undefined || value === null || value === '') {
  153. return [];
  154. }
  155. if (Array.isArray(value)) {
  156. const list = [];
  157. for (let i = 0; i < value.length; i++) {
  158. const entry = String(value[i] || '').trim();
  159. if (entry) {
  160. list.push(entry);
  161. }
  162. }
  163. return list;
  164. }
  165. return String(value).split(',')
  166. .map(function (entry) { return entry.trim(); })
  167. .filter(function (entry) { return entry.length > 0; });
  168. }
  169. const MIME_BY_EXTENSION = {
  170. png: 'image/png',
  171. jpg: 'image/jpeg',
  172. jpeg: 'image/jpeg',
  173. webp: 'image/webp',
  174. gif: 'image/gif',
  175. mp4: 'video/mp4',
  176. webm: 'video/webm'
  177. };
  178. function guessMimeType(path, headerValue) {
  179. // The server's own Content-Type wins when it sends one that says anything.
  180. // A generic octet-stream tells us nothing an extension cannot tell us better.
  181. const header = String(headerValue || '').split(';')[0].trim().toLowerCase();
  182. if (header && header !== 'application/octet-stream') {
  183. return header;
  184. }
  185. const dot = path.lastIndexOf('.');
  186. if (dot !== -1) {
  187. const extension = path.substring(dot + 1).toLowerCase();
  188. if (MIME_BY_EXTENSION[extension]) {
  189. return MIME_BY_EXTENSION[extension];
  190. }
  191. }
  192. return 'application/octet-stream';
  193. }
  194. function headerLookup(headers, name) {
  195. if (!headers) {
  196. return '';
  197. }
  198. // Header casing is not guaranteed, so match without it rather than hoping
  199. // for a particular spelling of Content-Type.
  200. const wanted = name.toLowerCase();
  201. const keys = Object.keys(headers);
  202. for (let i = 0; i < keys.length; i++) {
  203. if (keys[i].toLowerCase() === wanted) {
  204. return headers[keys[i]];
  205. }
  206. }
  207. return '';
  208. }
  209. async function execute(config, input, context) {
  210. const server = normalizeServer(config.serverUrl);
  211. const timeout = config.timeout || 120000;
  212. let paths = toPathList(config.paths);
  213. if (paths.length === 0) {
  214. throw new Error('SD.cpp: no output paths were given. Pass the outputs array from a ' +
  215. 'finished job, such as {{$json.outputs}}');
  216. }
  217. const limit = config.limit > 0 ? config.limit : 0;
  218. if (limit > 0 && paths.length > limit) {
  219. smartbotic.log.info('SD.cpp: downloading the first ' + limit + ' of ' + paths.length +
  220. ' outputs, the rest were left on the server');
  221. paths = paths.slice(0, limit);
  222. }
  223. const credential = readCredential(config.credentialId);
  224. const token = login(server, credential, timeout);
  225. const files = [];
  226. const storage = [];
  227. let totalBytes = 0;
  228. for (let i = 0; i < paths.length; i++) {
  229. const path = paths[i];
  230. // Each path segment is encoded separately: outputs look like
  231. // "<job-id>/image_0.png" and encoding the whole string would turn the
  232. // separator into %2F and break the lookup.
  233. const encoded = path.split('/').map(function (segment) {
  234. return encodeURIComponent(segment);
  235. }).join('/');
  236. const response = smartbotic.http.request({
  237. method: 'GET',
  238. url: server + '/output/' + encoded,
  239. headers: { 'Authorization': 'Bearer ' + token },
  240. timeout: timeout
  241. });
  242. if (response.status < 200 || response.status >= 300) {
  243. throw new Error('SD.cpp: downloading "' + path + '" failed with HTTP ' +
  244. response.status);
  245. }
  246. // dataBase64, not data: these are PNG and MP4 bytes, and reading them as
  247. // a string would corrupt anything that is not valid UTF-8.
  248. const base64Data = response.dataBase64 || '';
  249. if (!base64Data) {
  250. throw new Error('SD.cpp: the server returned an empty body for "' + path + '"');
  251. }
  252. // Base64 encodes three bytes per four characters, less the padding.
  253. let size = Math.floor(base64Data.length / 4) * 3;
  254. if (base64Data.charAt(base64Data.length - 1) === '=') {
  255. size--;
  256. }
  257. if (base64Data.charAt(base64Data.length - 2) === '=') {
  258. size--;
  259. }
  260. const filename = path.substring(path.lastIndexOf('/') + 1);
  261. const mimeType = guessMimeType(path, headerLookup(response.headers, 'content-type'));
  262. const fileObject = {
  263. type: 'binary',
  264. data: base64Data,
  265. mimeType: mimeType,
  266. filename: filename,
  267. size: size,
  268. path: path,
  269. url: server + '/output/' + encoded
  270. };
  271. files.push(fileObject);
  272. totalBytes += size;
  273. if (config.storeInDatabase) {
  274. const collection = config.collection || 'sdcpp_outputs';
  275. const ttlHours = config.storageTtlHours ?? 24;
  276. const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
  277. const insertResult = smartbotic.storage.insert(collection, {
  278. filename: filename,
  279. mimeType: mimeType,
  280. size: size,
  281. sourcePath: path,
  282. sourceUrl: fileObject.url,
  283. downloadedAt: Date.now(),
  284. data: base64Data
  285. }, null, ttlMs);
  286. if (!insertResult.success) {
  287. throw new Error('SD.cpp: downloaded "' + path + '" but storing it in ' +
  288. collection + ' failed: ' + (insertResult.error || 'unknown error'));
  289. }
  290. storage.push({ collection: collection, id: insertResult.id });
  291. }
  292. }
  293. smartbotic.log.info('SD.cpp: downloaded ' + files.length + ' output file(s), ' +
  294. totalBytes + ' bytes');
  295. return {
  296. count: files.length,
  297. files: files,
  298. file: files[0],
  299. totalBytes: totalBytes,
  300. storage: storage
  301. };
  302. }
  303. module.exports = { configSchema, inputSchema, outputSchema, execute };