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. totalBytes: { type: 'number' },
  81. storage: { type: 'array', description: 'Collection and id of each stored file, when storing was enabled' }
  82. }
  83. };
  84. function normalizeServer(url) {
  85. const value = String(url || '').trim();
  86. if (!value) {
  87. throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077');
  88. }
  89. return value.replace(/\/+$/, '');
  90. }
  91. function readCredential(credentialId) {
  92. const auth = smartbotic.credentials.get(credentialId);
  93. if (!auth || auth.success !== true) {
  94. throw new Error('SD.cpp: could not read the credential: ' +
  95. ((auth && auth.error) || 'unknown error'));
  96. }
  97. const value = auth.headerValue || '';
  98. if (value.indexOf('Basic ') !== 0) {
  99. throw new Error('SD.cpp: the credential must be a basic one, holding the sdcpp-restapi ' +
  100. 'username and password');
  101. }
  102. const decoded = smartbotic.utils.base64Decode(value.substring(6));
  103. const separator = decoded.indexOf(':');
  104. if (separator < 1) {
  105. throw new Error('SD.cpp: the credential is malformed, expected a username and a password');
  106. }
  107. return {
  108. username: decoded.substring(0, separator),
  109. password: decoded.substring(separator + 1)
  110. };
  111. }
  112. function call(options) {
  113. const response = smartbotic.http.request(options);
  114. let body = response.data;
  115. if (typeof body === 'string' && body.length > 0) {
  116. try {
  117. body = JSON.parse(body);
  118. } catch (e) {
  119. const snippet = body.substring(0, 200).replace(/\s+/g, ' ');
  120. throw new Error('SD.cpp: ' + options.what + ' returned HTTP ' + response.status +
  121. ' with a body that is not JSON: ' + snippet);
  122. }
  123. }
  124. if (response.status < 200 || response.status >= 300) {
  125. const detail = (body && (body.message || body.error)) || ('HTTP ' + response.status);
  126. throw new Error('SD.cpp: ' + options.what + ' failed: ' + detail);
  127. }
  128. return body || {};
  129. }
  130. function login(server, credential, timeout) {
  131. const session = call({
  132. method: 'POST',
  133. url: server + '/auth/login',
  134. headers: { 'Content-Type': 'application/json' },
  135. body: JSON.stringify({
  136. username: credential.username,
  137. password: credential.password
  138. }),
  139. timeout: timeout,
  140. what: 'signing in'
  141. });
  142. if (!session.token) {
  143. throw new Error('SD.cpp: the server accepted the login but returned no token');
  144. }
  145. return session.token;
  146. }
  147. function toPathList(value) {
  148. if (value === undefined || value === null || value === '') {
  149. return [];
  150. }
  151. if (Array.isArray(value)) {
  152. const list = [];
  153. for (let i = 0; i < value.length; i++) {
  154. const entry = String(value[i] || '').trim();
  155. if (entry) {
  156. list.push(entry);
  157. }
  158. }
  159. return list;
  160. }
  161. return String(value).split(',')
  162. .map(function (entry) { return entry.trim(); })
  163. .filter(function (entry) { return entry.length > 0; });
  164. }
  165. const MIME_BY_EXTENSION = {
  166. png: 'image/png',
  167. jpg: 'image/jpeg',
  168. jpeg: 'image/jpeg',
  169. webp: 'image/webp',
  170. gif: 'image/gif',
  171. mp4: 'video/mp4',
  172. webm: 'video/webm'
  173. };
  174. function guessMimeType(path, headerValue) {
  175. // The server's own Content-Type wins when it sends one that says anything.
  176. // A generic octet-stream tells us nothing an extension cannot tell us better.
  177. const header = String(headerValue || '').split(';')[0].trim().toLowerCase();
  178. if (header && header !== 'application/octet-stream') {
  179. return header;
  180. }
  181. const dot = path.lastIndexOf('.');
  182. if (dot !== -1) {
  183. const extension = path.substring(dot + 1).toLowerCase();
  184. if (MIME_BY_EXTENSION[extension]) {
  185. return MIME_BY_EXTENSION[extension];
  186. }
  187. }
  188. return 'application/octet-stream';
  189. }
  190. function headerLookup(headers, name) {
  191. if (!headers) {
  192. return '';
  193. }
  194. // Header casing is not guaranteed, so match without it rather than hoping
  195. // for a particular spelling of Content-Type.
  196. const wanted = name.toLowerCase();
  197. const keys = Object.keys(headers);
  198. for (let i = 0; i < keys.length; i++) {
  199. if (keys[i].toLowerCase() === wanted) {
  200. return headers[keys[i]];
  201. }
  202. }
  203. return '';
  204. }
  205. async function execute(config, input, context) {
  206. const server = normalizeServer(config.serverUrl);
  207. const timeout = config.timeout || 120000;
  208. let paths = toPathList(config.paths);
  209. if (paths.length === 0) {
  210. throw new Error('SD.cpp: no output paths were given. Pass the outputs array from a ' +
  211. 'finished job, such as {{$json.outputs}}');
  212. }
  213. const limit = config.limit > 0 ? config.limit : 0;
  214. if (limit > 0 && paths.length > limit) {
  215. smartbotic.log.info('SD.cpp: downloading the first ' + limit + ' of ' + paths.length +
  216. ' outputs, the rest were left on the server');
  217. paths = paths.slice(0, limit);
  218. }
  219. const credential = readCredential(config.credentialId);
  220. const token = login(server, credential, timeout);
  221. const files = [];
  222. const storage = [];
  223. let totalBytes = 0;
  224. for (let i = 0; i < paths.length; i++) {
  225. const path = paths[i];
  226. // Each path segment is encoded separately: outputs look like
  227. // "<job-id>/image_0.png" and encoding the whole string would turn the
  228. // separator into %2F and break the lookup.
  229. const encoded = path.split('/').map(function (segment) {
  230. return encodeURIComponent(segment);
  231. }).join('/');
  232. const response = smartbotic.http.request({
  233. method: 'GET',
  234. url: server + '/output/' + encoded,
  235. headers: { 'Authorization': 'Bearer ' + token },
  236. timeout: timeout
  237. });
  238. if (response.status < 200 || response.status >= 300) {
  239. throw new Error('SD.cpp: downloading "' + path + '" failed with HTTP ' +
  240. response.status);
  241. }
  242. // dataBase64, not data: these are PNG and MP4 bytes, and reading them as
  243. // a string would corrupt anything that is not valid UTF-8.
  244. const base64Data = response.dataBase64 || '';
  245. if (!base64Data) {
  246. throw new Error('SD.cpp: the server returned an empty body for "' + path + '"');
  247. }
  248. // Base64 encodes three bytes per four characters, less the padding.
  249. let size = Math.floor(base64Data.length / 4) * 3;
  250. if (base64Data.charAt(base64Data.length - 1) === '=') {
  251. size--;
  252. }
  253. if (base64Data.charAt(base64Data.length - 2) === '=') {
  254. size--;
  255. }
  256. const filename = path.substring(path.lastIndexOf('/') + 1);
  257. const mimeType = guessMimeType(path, headerLookup(response.headers, 'content-type'));
  258. const fileObject = {
  259. type: 'binary',
  260. data: base64Data,
  261. mimeType: mimeType,
  262. filename: filename,
  263. size: size,
  264. path: path,
  265. url: server + '/output/' + encoded
  266. };
  267. files.push(fileObject);
  268. totalBytes += size;
  269. if (config.storeInDatabase) {
  270. const collection = config.collection || 'sdcpp_outputs';
  271. const ttlHours = config.storageTtlHours ?? 24;
  272. const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
  273. const insertResult = smartbotic.storage.insert(collection, {
  274. filename: filename,
  275. mimeType: mimeType,
  276. size: size,
  277. sourcePath: path,
  278. sourceUrl: fileObject.url,
  279. downloadedAt: Date.now(),
  280. data: base64Data
  281. }, null, ttlMs);
  282. if (!insertResult.success) {
  283. throw new Error('SD.cpp: downloaded "' + path + '" but storing it in ' +
  284. collection + ' failed: ' + (insertResult.error || 'unknown error'));
  285. }
  286. storage.push({ collection: collection, id: insertResult.id });
  287. }
  288. }
  289. smartbotic.log.info('SD.cpp: downloaded ' + files.length + ' output file(s), ' +
  290. totalBytes + ' bytes');
  291. // Only "files". There used to be a "file" alongside it holding the first
  292. // entry, meant as a convenience for the single-image case - but JSON has no
  293. // references, so it was a second copy of the same base64. The execution
  294. // record carried every image twice, and anything that walks the output
  295. // looking for images found and displayed each one twice. Use files[0].
  296. return {
  297. count: files.length,
  298. files: files,
  299. totalBytes: totalBytes,
  300. storage: storage
  301. };
  302. }
  303. module.exports = { configSchema, inputSchema, outputSchema, execute };