/** * @node sdcpp-generate * @name SD.cpp Generate * @category sdcpp * @version 1.0.0 * @description Queue an image or video generation job on an sdcpp-restapi server and return its job id * @icon image-plus */ const configSchema = { type: 'object', properties: { serverUrl: { type: 'string', title: 'Server URL', description: 'Base address of the sdcpp-restapi server, such as http://localhost:8077. No trailing slash needed', default: 'http://localhost:8077' }, credentialId: { type: 'string', title: 'Credential', description: 'A basic credential holding the sdcpp-restapi username and password. Every endpoint except health requires one', dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } } }, operation: { type: 'string', title: 'Operation', enum: ['txt2img', 'img2img', 'txt2vid', 'upscale'], default: 'txt2img', description: 'txt2img generates from a prompt alone, img2img and txt2vid start from an image, upscale enlarges one using the separately loaded upscaler' }, prompt: { type: 'string', title: 'Prompt', description: 'What to generate. Supports {{variable}} interpolation and syntax. Also supports {a|b|c} dynamic prompts, which the server expands into one queue item per variation', format: 'textarea' }, negativePrompt: { type: 'string', title: 'Negative Prompt', description: 'Concepts to exclude. Has no effect on LCM and one-step SDXS models', format: 'textarea' }, width: { type: 'number', title: 'Width', description: 'Leave empty to use the loaded architecture default, such as 1024 for SDXL and Flux' }, height: { type: 'number', title: 'Height', description: 'Leave empty to use the loaded architecture default' }, steps: { type: 'number', title: 'Steps', description: 'Sampling steps. Leave empty to use the architecture default, which is 4 for Flux Schnell and LCM and 20 for most others' }, cfgScale: { type: 'number', title: 'CFG Scale', description: 'Guidance strength. Leave empty to use the architecture default. Distilled models such as Flux Schnell, LCM and Z-Image want 1.0' }, sampler: { type: 'string', title: 'Sampler', enum: ['', 'euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2', 'ipndm', 'ipndm_v', 'lcm', 'ddim_trailing', 'tcd', 'res_multistep', 'res_2s', 'er_sde', 'euler_cfg_pp', 'euler_a_cfg_pp', 'euler_ge'], default: '', description: 'Leave empty to use the architecture default' }, scheduler: { type: 'string', title: 'Scheduler', enum: ['', 'discrete', 'karras', 'exponential', 'ays', 'gits', 'sgm_uniform', 'simple', 'smoothstep', 'kl_optimal', 'lcm', 'bong_tangent', 'ltx2'], default: '', description: 'Leave empty to use the architecture default' }, seed: { type: 'number', title: 'Seed', description: 'Use -1 for a random seed. Any other value reproduces the same image. Leave empty to let the server decide' }, batchCount: { type: 'number', title: 'Batch Count', description: 'How many images to generate in this one job, all sharing the prompt' }, clipSkip: { type: 'number', title: 'CLIP Skip', description: 'Skip the last N CLIP layers. Negative means the model default' }, title: { type: 'string', title: 'Job Title', description: 'Optional label stored with the job, useful for finding it again in the queue' }, initImageBase64: { type: 'string', title: 'Init Image (base64)', description: 'Starting image for img2img and txt2vid, and the image to enlarge for upscale. Base64 without a data: prefix. Usually {{$json.file.data}} from a previous node' }, maskImageBase64: { type: 'string', title: 'Mask Image (base64)', description: 'Inpainting mask for img2img. White marks the area to regenerate' }, strength: { type: 'number', title: 'Strength', description: 'How far img2img and txt2vid may move from the init image, from 0 to 1. Higher changes more' }, upscaleFactor: { type: 'number', title: 'Upscale Factor', description: 'For the upscale operation. The upscaler model must already be loaded' }, videoFrames: { type: 'number', title: 'Video Frames', description: 'For txt2vid, how many frames to render' }, fps: { type: 'number', title: 'FPS', description: 'For txt2vid, frames per second of the output video' }, extraOptions: { type: 'object', title: 'Extra Options', description: 'Any other generation field passed straight through to the server, such as slg_scale, cache_mode, ref_images or vae_tiling. Names must match the API exactly. See /options/generation on the server for the full list with descriptions' }, timeout: { type: 'number', title: 'Timeout (ms)', description: 'Applies to queueing the job, not to the render itself. The call returns as soon as the job is accepted', default: 30000 } }, required: ['credentialId'] }; const inputSchema = { type: 'object', properties: { data: { type: 'any' } } }; const outputSchema = { type: 'object', properties: { jobId: { type: 'string', description: 'Id of the queued job, to pass to SD.cpp Job Status or Wait' }, status: { type: 'string', description: 'Queue status at the moment of accepting, normally pending' }, position: { type: 'number', description: 'Place in the queue when it was accepted' }, operation: { type: 'string', description: 'Which generation endpoint was called' }, request: { type: 'object', description: 'The body actually sent, useful for debugging which defaults were left to the server' } } }; function normalizeServer(url) { const value = String(url || '').trim(); if (!value) { throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077'); } return value.replace(/\/+$/, ''); } function readCredential(credentialId) { const auth = smartbotic.credentials.get(credentialId); if (!auth || auth.success !== true) { throw new Error('SD.cpp: could not read the credential: ' + ((auth && auth.error) || 'unknown error')); } const value = auth.headerValue || ''; if (value.indexOf('Basic ') !== 0) { throw new Error('SD.cpp: the credential must be a basic one, holding the sdcpp-restapi ' + 'username and password'); } const decoded = smartbotic.utils.base64Decode(value.substring(6)); const separator = decoded.indexOf(':'); if (separator < 1) { throw new Error('SD.cpp: the credential is malformed, expected a username and a password'); } return { username: decoded.substring(0, separator), password: decoded.substring(separator + 1) }; } function call(options) { const response = smartbotic.http.request(options); let body = response.data; if (typeof body === 'string' && body.length > 0) { try { body = JSON.parse(body); } catch (e) { const snippet = body.substring(0, 200).replace(/\s+/g, ' '); throw new Error('SD.cpp: ' + options.what + ' returned HTTP ' + response.status + ' with a body that is not JSON: ' + snippet); } } if (response.status < 200 || response.status >= 300) { const detail = (body && (body.message || body.error)) || ('HTTP ' + response.status); throw new Error('SD.cpp: ' + options.what + ' failed: ' + detail); } return body || {}; } function login(server, credential, timeout) { const session = call({ method: 'POST', url: server + '/auth/login', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: credential.username, password: credential.password }), timeout: timeout, what: 'signing in' }); if (!session.token) { throw new Error('SD.cpp: the server accepted the login but returned no token'); } return session.token; } // Width, steps, cfg_scale and friends are architecture defaults: the server // fills them from the loaded model's preset when the field is absent. Sending // an empty box as 0 or "" would override that preset with nonsense, so a field // the user did not fill must not appear in the body at all. // // The test is emptiness, never truthiness. seed 0 and clip_skip 0 are both // legitimate values that a truthy check would silently drop. function putIfSet(target, key, value) { if (value === undefined || value === null || value === '') { return; } if (typeof value === 'number' && !isFinite(value)) { return; } target[key] = value; } async function execute(config, input, context) { const server = normalizeServer(config.serverUrl); const operation = config.operation || 'txt2img'; const timeout = config.timeout || 30000; if (['txt2img', 'img2img', 'txt2vid', 'upscale'].indexOf(operation) === -1) { throw new Error('SD.cpp: unknown operation "' + operation + '"'); } const credential = readCredential(config.credentialId); const token = login(server, credential, timeout); const body = {}; if (operation === 'upscale') { // Upscale is not a generation call - it takes an image and the upscaler // slot, and none of the prompt or sampler fields apply. putIfSet(body, 'image_base64', config.initImageBase64); if (!body.image_base64) { throw new Error('SD.cpp: the upscale operation needs an init image, ' + 'given as base64 in the Init Image field'); } putIfSet(body, 'upscale_factor', config.upscaleFactor); putIfSet(body, 'title', config.title); } else { putIfSet(body, 'prompt', config.prompt); if (!body.prompt) { throw new Error('SD.cpp: ' + operation + ' needs a prompt'); } putIfSet(body, 'negative_prompt', config.negativePrompt); putIfSet(body, 'width', config.width); putIfSet(body, 'height', config.height); putIfSet(body, 'steps', config.steps); putIfSet(body, 'cfg_scale', config.cfgScale); putIfSet(body, 'sampler', config.sampler); putIfSet(body, 'scheduler', config.scheduler); putIfSet(body, 'seed', config.seed); putIfSet(body, 'batch_count', config.batchCount); putIfSet(body, 'clip_skip', config.clipSkip); putIfSet(body, 'title', config.title); if (operation === 'img2img' || operation === 'txt2vid') { putIfSet(body, 'init_image_base64', config.initImageBase64); putIfSet(body, 'strength', config.strength); } if (operation === 'img2img') { putIfSet(body, 'mask_image_base64', config.maskImageBase64); } if (operation === 'txt2vid') { putIfSet(body, 'video_frames', config.videoFrames); putIfSet(body, 'fps', config.fps); } if (operation === 'img2img' && !body.init_image_base64) { throw new Error('SD.cpp: img2img needs an init image, given as base64 in the ' + 'Init Image field'); } } // The API has around seventy generation fields and this node exposes the // common ones. Anything else the user names here goes straight through, so // a new server field does not need a node change to be reachable. const extra = config.extraOptions; if (extra && typeof extra === 'object') { const keys = Object.keys(extra); for (let i = 0; i < keys.length; i++) { putIfSet(body, keys[i], extra[keys[i]]); } } const queued = call({ method: 'POST', url: server + '/' + operation, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify(body), timeout: timeout, what: 'queueing the ' + operation + ' job' }); if (!queued.job_id) { throw new Error('SD.cpp: the ' + operation + ' job was accepted but no job id came back'); } smartbotic.log.info('SD.cpp: queued ' + operation + ' job ' + queued.job_id + ' at position ' + (queued.position !== undefined ? queued.position : 'unknown')); return { jobId: queued.job_id, status: queued.status || 'pending', position: queued.position !== undefined ? queued.position : -1, operation: operation, request: body }; } module.exports = { configSchema, inputSchema, outputSchema, execute };