sdcpp-fetch-output.js 12 KB

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