sdcpp-job-status.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. /**
  2. * @node sdcpp-job-status
  3. * @name SD.cpp Job Status
  4. * @category sdcpp
  5. * @version 1.0.0
  6. * @description Read the current state of one sdcpp-restapi job without waiting for it
  7. * @icon activity
  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. jobId: {
  43. type: 'string',
  44. title: 'Job ID',
  45. description: 'Id of the job to inspect, normally {{$json.jobId}} from an SD.cpp Generate node'
  46. },
  47. failOnJobFailure: {
  48. type: 'boolean',
  49. title: 'Fail If The Job Failed',
  50. description: 'Throw when the job is in the failed state, instead of returning it as data. Leave off to branch on the status yourself',
  51. default: false
  52. },
  53. timeout: {
  54. type: 'number',
  55. title: 'Timeout (ms)',
  56. default: 30000
  57. }
  58. },
  59. required: ['credentialId']
  60. };
  61. const inputSchema = {
  62. type: 'object',
  63. properties: {
  64. data: { type: 'any' }
  65. }
  66. };
  67. const outputSchema = {
  68. type: 'object',
  69. properties: {
  70. jobId: { type: 'string' },
  71. status: { type: 'string', description: 'pending, processing, completed, failed, cancelled or deleted' },
  72. type: { type: 'string', description: 'txt2img, img2img, txt2vid, upscale, convert, model_download or model_hash' },
  73. done: { type: 'boolean', description: 'True once the job reached a state it cannot leave on its own' },
  74. succeeded: { type: 'boolean', description: 'True only for a completed job' },
  75. step: { type: 'number', description: 'Sampling steps finished so far' },
  76. totalSteps: { type: 'number' },
  77. percent: { type: 'number', description: 'Progress from 0 to 100, or -1 when the server reports no total' },
  78. outputs: { type: 'array', description: 'Relative output paths, empty until the job completes' },
  79. urls: { type: 'array', description: 'The same outputs as full URLs on this server' },
  80. error: { type: 'string', description: 'Failure reason when the job failed' },
  81. createdAt: { type: 'string' },
  82. startedAt: { type: 'string' },
  83. completedAt: { type: 'string' },
  84. params: { type: 'object', description: 'Generation parameters the job actually ran with, including the resolved seed' },
  85. modelSettings: { type: 'object', description: 'Which model and components produced it' }
  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. // A job the worker will not move again on its own. "deleted" counts: a job in
  152. // the recycle bin is not coming back without someone restoring it, so a wait
  153. // that treated it as pending would spin until its timeout.
  154. const TERMINAL = ['completed', 'failed', 'cancelled', 'deleted'];
  155. function shapeJob(job, server) {
  156. const status = job.status || 'unknown';
  157. const progress = job.progress || {};
  158. const step = progress.step !== undefined ? progress.step : 0;
  159. const totalSteps = progress.total_steps !== undefined ? progress.total_steps : 0;
  160. const outputs = job.outputs || [];
  161. const urls = [];
  162. for (let i = 0; i < outputs.length; i++) {
  163. urls.push(server + '/output/' + outputs[i]);
  164. }
  165. return {
  166. jobId: job.job_id || '',
  167. status: status,
  168. type: job.type || '',
  169. done: TERMINAL.indexOf(status) !== -1,
  170. succeeded: status === 'completed',
  171. step: step,
  172. totalSteps: totalSteps,
  173. percent: totalSteps > 0 ? Math.round((step / totalSteps) * 100) : -1,
  174. outputs: outputs,
  175. urls: urls,
  176. error: job.error || '',
  177. createdAt: job.created_at || '',
  178. startedAt: job.started_at || '',
  179. completedAt: job.completed_at || '',
  180. params: job.params || {},
  181. modelSettings: job.model_settings || {}
  182. };
  183. }
  184. async function execute(config, input, context) {
  185. const server = normalizeServer(config.serverUrl);
  186. const timeout = config.timeout || 30000;
  187. const jobId = String(config.jobId || '').trim();
  188. if (!jobId) {
  189. throw new Error('SD.cpp: a job id is required');
  190. }
  191. const credential = readCredential(config.credentialId);
  192. const token = login(server, credential, timeout);
  193. const job = call({
  194. method: 'GET',
  195. url: server + '/queue/' + encodeURIComponent(jobId),
  196. headers: { 'Authorization': 'Bearer ' + token },
  197. timeout: timeout,
  198. what: 'reading job ' + jobId
  199. });
  200. const result = shapeJob(job, server);
  201. if (config.failOnJobFailure && result.status === 'failed') {
  202. throw new Error('SD.cpp: job ' + jobId + ' failed: ' + (result.error || 'no reason given'));
  203. }
  204. return result;
  205. }
  206. module.exports = { configSchema, inputSchema, outputSchema, execute };