Bladeren bron

feat: one SD.cpp node per kind of generation

SD.cpp Generate carried every setting for txt2img, img2img, txt2vid and upscale
behind an operation dropdown - 23 settings, most of which did not apply to
whatever you had picked, and an upscale that offered a prompt and a sampler it
would never send.

Split into five:

- SD.cpp Text to Image   16 settings
- SD.cpp Image to Image  19  (init image, mask, strength)
- SD.cpp Edit Image      20  (reference images, for Flux Kontext and Qwen Image Edit)
- SD.cpp Text to Video   20  (first frame, frames, fps)
- SD.cpp Upscale          9  (no prompt, no sampler - it never used them)

Each declares its settings in the order it wants them shown, which now survives
to the editor, so every one of them opens with Server URL and Credential first
and its own subject after.

All five are generated from one template, so the shared half - the login, the
architecture-default handling, the extra-options passthrough - is written once
and cannot drift between them. Sending an empty field as 0 would override the
loaded model's preset, so an unfilled setting is still left out of the body
entirely, and the test is emptiness rather than truthiness because seed 0 and
clip_skip 0 are real values.

The old sdcpp-generate is removed and the sdcpp workflow migrated to
SD.cpp Text to Image, which then generated an image end to end - job queued,
waited, 532901 bytes fetched - to prove the split did not change behaviour.

Full suite 55/55.
fszontagh 1 maand geleden
bovenliggende
commit
528079500d
6 gewijzigde bestanden met toevoegingen van 1195 en 355 verwijderingen
  1. 260 0
      nodes/sdcpp/sdcpp-edit.js
  2. 0 355
      nodes/sdcpp/sdcpp-generate.js
  3. 252 0
      nodes/sdcpp/sdcpp-img2img.js
  4. 234 0
      nodes/sdcpp/sdcpp-txt2img.js
  5. 245 0
      nodes/sdcpp/sdcpp-txt2vid.js
  6. 204 0
      nodes/sdcpp/sdcpp-upscale.js

+ 260 - 0
nodes/sdcpp/sdcpp-edit.js

@@ -0,0 +1,260 @@
+/**
+ * @node sdcpp-edit
+ * @name SD.cpp Edit Image
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Edit an image by describing the change, for edit models such as Flux Kontext and Qwen Image Edit
+ * @icon wand-2
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        serverUrl: {
+            type: 'string', title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server',
+            default: 'http://localhost:8077'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password',
+            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+        },
+        prompt: {
+            type: 'string', title: 'Prompt',
+            description: 'What to generate. Supports {{variable}} interpolation, <lora:name:weight>, and {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'
+        },
+        initImageBase64: {
+            type: 'string', title: 'Image (base64)',
+            description: 'The image to edit, base64 without a data: prefix'
+        },
+        refImages: {
+            type: 'array', title: 'Reference Images (base64)',
+            description: 'Extra images the edit model conditions on. Only edit-capable models use these',
+            items: { type: 'string' }
+        },
+        refImageArgs: {
+            type: 'string', title: 'Reference Image Options',
+            description: 'Comma separated k=v options for the reference images, such as resize_before_vae=0,ref_index_mode=increase'
+        },
+        strength: {
+            type: 'number', title: 'Strength',
+            description: 'How far the result may move from the original, 0 to 1'
+        },
+        width: { type: 'number', title: 'Width', description: 'Leave empty to use the loaded architecture default' },
+        height: { type: 'number', title: 'Height', description: 'Leave empty to use the loaded architecture default' },
+        steps: { type: 'number', title: 'Steps', description: 'Leave empty for the architecture default - 4 for Flux Schnell and LCM, 20 for most others' },
+        cfgScale: { type: 'number', title: 'CFG Scale', description: 'Leave empty for 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' },
+        batchCount: { type: 'number', title: 'Batch Count', description: 'How many 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' },
+        extraOptions: {
+            type: 'object', title: 'Extra Options',
+            description: 'Any other generation field passed straight through, such as slg_scale, cache_mode or vae_tiling. See /options/generation on the server for the full list'
+        },
+        timeout: {
+            type: 'number', title: 'Timeout (ms)',
+            description: 'Applies to queueing the job, not to the render. 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 Wait For Job' },
+        status: { type: 'string', description: 'Queue status when the job was accepted, normally pending' },
+        position: { type: 'number', description: 'Place in the queue' },
+        request: { type: 'object', description: 'The body actually sent, useful for seeing 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;
+}
+
+// /health is unauthenticated, and it is the only way to find out what is
+// already loaded without asking for a token first.
+function readHealth(server, timeout) {
+    return call({
+        method: 'GET',
+        url: server + '/health',
+        timeout: timeout,
+        what: 'reading server health'
+    });
+}
+
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    target[key] = value;
+}
+
+// Width, steps, cfg_scale and the rest are architecture defaults: the server
+// fills them from the loaded model's preset when the field is absent. Sending an
+// empty box as 0 would override that preset with nonsense, so a field the user
+// did not fill is left out of the body entirely - and the test is emptiness,
+// never truthiness, because seed 0 and clip_skip 0 are legitimate values.
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout || 30000;
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, timeout);
+
+    const body = {};
+    putIfSet(body, 'prompt', config.prompt);
+    if (!body.prompt) {
+        throw new Error('SD.cpp: an edit needs a prompt describing the change');
+    }
+    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, 'init_image_base64', config.initImageBase64);
+    putIfSet(body, 'strength', config.strength);
+    putIfSet(body, 'ref_image_args', config.refImageArgs);
+    if (Array.isArray(config.refImages) && config.refImages.length > 0) {
+        body.ref_images = config.refImages;
+    }
+    if (!body.init_image_base64 && !body.ref_images) {
+        throw new Error('SD.cpp: an edit needs an image, or at least one reference image');
+    }
+    putIfSet(body, 'title', config.title);
+
+    // Anything else the API accepts, passed 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 + '/img2img',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        body: JSON.stringify(body),
+        timeout: timeout,
+        what: 'queueing the img2img job'
+    });
+
+    if (!queued.job_id) {
+        throw new Error('SD.cpp: the job was accepted but no job id came back');
+    }
+
+    smartbotic.log.info('SD.cpp: queued img2img job ' + queued.job_id);
+
+    return {
+        jobId: queued.job_id,
+        status: queued.status || 'pending',
+        position: queued.position !== undefined ? queued.position : -1,
+        request: body
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 0 - 355
nodes/sdcpp/sdcpp-generate.js

@@ -1,355 +0,0 @@
-/**
- * @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 <lora:name:weight> 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 };

+ 252 - 0
nodes/sdcpp/sdcpp-img2img.js

@@ -0,0 +1,252 @@
+/**
+ * @node sdcpp-img2img
+ * @name SD.cpp Image to Image
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Queue an image generation that starts from an existing image
+ * @icon images
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        serverUrl: {
+            type: 'string', title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server',
+            default: 'http://localhost:8077'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password',
+            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+        },
+        prompt: {
+            type: 'string', title: 'Prompt',
+            description: 'What to generate. Supports {{variable}} interpolation, <lora:name:weight>, and {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'
+        },
+        initImageBase64: {
+            type: 'string', title: 'Init Image (base64)',
+            description: 'The image to start from, base64 without a data: prefix. Usually {{$json.files[0].data}} from a previous node'
+        },
+        maskImageBase64: {
+            type: 'string', title: 'Mask (base64)',
+            description: 'Inpainting mask. White marks the area to regenerate'
+        },
+        strength: {
+            type: 'number', title: 'Strength',
+            description: 'How far the result may move from the init image, 0 to 1. Higher changes more'
+        },
+        width: { type: 'number', title: 'Width', description: 'Leave empty to use the loaded architecture default' },
+        height: { type: 'number', title: 'Height', description: 'Leave empty to use the loaded architecture default' },
+        steps: { type: 'number', title: 'Steps', description: 'Leave empty for the architecture default - 4 for Flux Schnell and LCM, 20 for most others' },
+        cfgScale: { type: 'number', title: 'CFG Scale', description: 'Leave empty for 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' },
+        batchCount: { type: 'number', title: 'Batch Count', description: 'How many 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' },
+        extraOptions: {
+            type: 'object', title: 'Extra Options',
+            description: 'Any other generation field passed straight through, such as slg_scale, cache_mode or vae_tiling. See /options/generation on the server for the full list'
+        },
+        timeout: {
+            type: 'number', title: 'Timeout (ms)',
+            description: 'Applies to queueing the job, not to the render. 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 Wait For Job' },
+        status: { type: 'string', description: 'Queue status when the job was accepted, normally pending' },
+        position: { type: 'number', description: 'Place in the queue' },
+        request: { type: 'object', description: 'The body actually sent, useful for seeing 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;
+}
+
+// /health is unauthenticated, and it is the only way to find out what is
+// already loaded without asking for a token first.
+function readHealth(server, timeout) {
+    return call({
+        method: 'GET',
+        url: server + '/health',
+        timeout: timeout,
+        what: 'reading server health'
+    });
+}
+
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    target[key] = value;
+}
+
+// Width, steps, cfg_scale and the rest are architecture defaults: the server
+// fills them from the loaded model's preset when the field is absent. Sending an
+// empty box as 0 would override that preset with nonsense, so a field the user
+// did not fill is left out of the body entirely - and the test is emptiness,
+// never truthiness, because seed 0 and clip_skip 0 are legitimate values.
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout || 30000;
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, timeout);
+
+    const body = {};
+    putIfSet(body, 'prompt', config.prompt);
+    if (!body.prompt) {
+        throw new Error('SD.cpp: img2img 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, 'init_image_base64', config.initImageBase64);
+    putIfSet(body, 'mask_image_base64', config.maskImageBase64);
+    putIfSet(body, 'strength', config.strength);
+    if (!body.init_image_base64) {
+        throw new Error('SD.cpp: img2img needs an init image');
+    }
+    putIfSet(body, 'title', config.title);
+
+    // Anything else the API accepts, passed 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 + '/img2img',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        body: JSON.stringify(body),
+        timeout: timeout,
+        what: 'queueing the img2img job'
+    });
+
+    if (!queued.job_id) {
+        throw new Error('SD.cpp: the job was accepted but no job id came back');
+    }
+
+    smartbotic.log.info('SD.cpp: queued img2img job ' + queued.job_id);
+
+    return {
+        jobId: queued.job_id,
+        status: queued.status || 'pending',
+        position: queued.position !== undefined ? queued.position : -1,
+        request: body
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 234 - 0
nodes/sdcpp/sdcpp-txt2img.js

@@ -0,0 +1,234 @@
+/**
+ * @node sdcpp-txt2img
+ * @name SD.cpp Text to Image
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Queue an image generation from a prompt
+ * @icon image
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        serverUrl: {
+            type: 'string', title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server',
+            default: 'http://localhost:8077'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password',
+            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+        },
+        prompt: {
+            type: 'string', title: 'Prompt',
+            description: 'What to generate. Supports {{variable}} interpolation, <lora:name:weight>, and {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' },
+        height: { type: 'number', title: 'Height', description: 'Leave empty to use the loaded architecture default' },
+        steps: { type: 'number', title: 'Steps', description: 'Leave empty for the architecture default - 4 for Flux Schnell and LCM, 20 for most others' },
+        cfgScale: { type: 'number', title: 'CFG Scale', description: 'Leave empty for 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' },
+        batchCount: { type: 'number', title: 'Batch Count', description: 'How many 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' },
+        extraOptions: {
+            type: 'object', title: 'Extra Options',
+            description: 'Any other generation field passed straight through, such as slg_scale, cache_mode or vae_tiling. See /options/generation on the server for the full list'
+        },
+        timeout: {
+            type: 'number', title: 'Timeout (ms)',
+            description: 'Applies to queueing the job, not to the render. 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 Wait For Job' },
+        status: { type: 'string', description: 'Queue status when the job was accepted, normally pending' },
+        position: { type: 'number', description: 'Place in the queue' },
+        request: { type: 'object', description: 'The body actually sent, useful for seeing 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;
+}
+
+// /health is unauthenticated, and it is the only way to find out what is
+// already loaded without asking for a token first.
+function readHealth(server, timeout) {
+    return call({
+        method: 'GET',
+        url: server + '/health',
+        timeout: timeout,
+        what: 'reading server health'
+    });
+}
+
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    target[key] = value;
+}
+
+// Width, steps, cfg_scale and the rest are architecture defaults: the server
+// fills them from the loaded model's preset when the field is absent. Sending an
+// empty box as 0 would override that preset with nonsense, so a field the user
+// did not fill is left out of the body entirely - and the test is emptiness,
+// never truthiness, because seed 0 and clip_skip 0 are legitimate values.
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout || 30000;
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, timeout);
+
+    const body = {};
+    putIfSet(body, 'prompt', config.prompt);
+    if (!body.prompt) {
+        throw new Error('SD.cpp: txt2img 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);
+
+    // Anything else the API accepts, passed 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 + '/txt2img',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        body: JSON.stringify(body),
+        timeout: timeout,
+        what: 'queueing the txt2img job'
+    });
+
+    if (!queued.job_id) {
+        throw new Error('SD.cpp: the job was accepted but no job id came back');
+    }
+
+    smartbotic.log.info('SD.cpp: queued txt2img job ' + queued.job_id);
+
+    return {
+        jobId: queued.job_id,
+        status: queued.status || 'pending',
+        position: queued.position !== undefined ? queued.position : -1,
+        request: body
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 245 - 0
nodes/sdcpp/sdcpp-txt2vid.js

@@ -0,0 +1,245 @@
+/**
+ * @node sdcpp-txt2vid
+ * @name SD.cpp Text to Video
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Queue a video generation, for video models such as Wan, LTXAV and HunyuanVideo
+ * @icon clapperboard
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        serverUrl: {
+            type: 'string', title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server',
+            default: 'http://localhost:8077'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password',
+            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+        },
+        prompt: {
+            type: 'string', title: 'Prompt',
+            description: 'What to generate. Supports {{variable}} interpolation, <lora:name:weight>, and {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'
+        },
+        initImageBase64: {
+            type: 'string', title: 'First Frame (base64)',
+            description: 'Optional image to start the video from'
+        },
+        videoFrames: { type: 'number', title: 'Frames', description: 'How many frames to render' },
+        fps: { type: 'number', title: 'FPS', description: 'Frames per second of the output' },
+        strength: { type: 'number', title: 'Strength', description: 'How far the result may move from the first frame, 0 to 1' },
+        width: { type: 'number', title: 'Width', description: 'Leave empty to use the loaded architecture default' },
+        height: { type: 'number', title: 'Height', description: 'Leave empty to use the loaded architecture default' },
+        steps: { type: 'number', title: 'Steps', description: 'Leave empty for the architecture default - 4 for Flux Schnell and LCM, 20 for most others' },
+        cfgScale: { type: 'number', title: 'CFG Scale', description: 'Leave empty for 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' },
+        batchCount: { type: 'number', title: 'Batch Count', description: 'How many 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' },
+        extraOptions: {
+            type: 'object', title: 'Extra Options',
+            description: 'Any other generation field passed straight through, such as slg_scale, cache_mode or vae_tiling. See /options/generation on the server for the full list'
+        },
+        timeout: {
+            type: 'number', title: 'Timeout (ms)',
+            description: 'Applies to queueing the job, not to the render. 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 Wait For Job' },
+        status: { type: 'string', description: 'Queue status when the job was accepted, normally pending' },
+        position: { type: 'number', description: 'Place in the queue' },
+        request: { type: 'object', description: 'The body actually sent, useful for seeing 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;
+}
+
+// /health is unauthenticated, and it is the only way to find out what is
+// already loaded without asking for a token first.
+function readHealth(server, timeout) {
+    return call({
+        method: 'GET',
+        url: server + '/health',
+        timeout: timeout,
+        what: 'reading server health'
+    });
+}
+
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    target[key] = value;
+}
+
+// Width, steps, cfg_scale and the rest are architecture defaults: the server
+// fills them from the loaded model's preset when the field is absent. Sending an
+// empty box as 0 would override that preset with nonsense, so a field the user
+// did not fill is left out of the body entirely - and the test is emptiness,
+// never truthiness, because seed 0 and clip_skip 0 are legitimate values.
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout || 30000;
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, timeout);
+
+    const body = {};
+    putIfSet(body, 'prompt', config.prompt);
+    if (!body.prompt) {
+        throw new Error('SD.cpp: txt2vid 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, 'init_image_base64', config.initImageBase64);
+    putIfSet(body, 'strength', config.strength);
+    putIfSet(body, 'video_frames', config.videoFrames);
+    putIfSet(body, 'fps', config.fps);
+    putIfSet(body, 'title', config.title);
+
+    // Anything else the API accepts, passed 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 + '/txt2vid',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        body: JSON.stringify(body),
+        timeout: timeout,
+        what: 'queueing the txt2vid job'
+    });
+
+    if (!queued.job_id) {
+        throw new Error('SD.cpp: the job was accepted but no job id came back');
+    }
+
+    smartbotic.log.info('SD.cpp: queued txt2vid job ' + queued.job_id);
+
+    return {
+        jobId: queued.job_id,
+        status: queued.status || 'pending',
+        position: queued.position !== undefined ? queued.position : -1,
+        request: body
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 204 - 0
nodes/sdcpp/sdcpp-upscale.js

@@ -0,0 +1,204 @@
+/**
+ * @node sdcpp-upscale
+ * @name SD.cpp Upscale
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Enlarge an image with the loaded upscaler
+ * @icon maximize-2
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        serverUrl: {
+            type: 'string', title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server',
+            default: 'http://localhost:8077'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password',
+            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+        },
+        imageBase64: {
+            type: 'string', title: 'Image (base64)',
+            description: 'The image to enlarge, base64 without a data: prefix'
+        },
+        upscaleFactor: { type: 'number', title: 'Factor', description: 'How much to enlarge by' },
+        repeats: { type: 'number', title: 'Repeats', description: 'Run the upscaler more than once' },
+        tileSize: { type: 'number', title: 'Tile Size', description: 'Tile the work to fit in memory' },
+        title: { type: 'string', title: 'Job Title', description: 'Optional label stored with the job, useful for finding it again in the queue' },
+        extraOptions: {
+            type: 'object', title: 'Extra Options',
+            description: 'Any other generation field passed straight through, such as slg_scale, cache_mode or vae_tiling. See /options/generation on the server for the full list'
+        },
+        timeout: {
+            type: 'number', title: 'Timeout (ms)',
+            description: 'Applies to queueing the job, not to the render. 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 Wait For Job' },
+        status: { type: 'string', description: 'Queue status when the job was accepted, normally pending' },
+        position: { type: 'number', description: 'Place in the queue' },
+        request: { type: 'object', description: 'The body actually sent, useful for seeing 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;
+}
+
+// /health is unauthenticated, and it is the only way to find out what is
+// already loaded without asking for a token first.
+function readHealth(server, timeout) {
+    return call({
+        method: 'GET',
+        url: server + '/health',
+        timeout: timeout,
+        what: 'reading server health'
+    });
+}
+
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    target[key] = value;
+}
+
+// Width, steps, cfg_scale and the rest are architecture defaults: the server
+// fills them from the loaded model's preset when the field is absent. Sending an
+// empty box as 0 would override that preset with nonsense, so a field the user
+// did not fill is left out of the body entirely - and the test is emptiness,
+// never truthiness, because seed 0 and clip_skip 0 are legitimate values.
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout || 30000;
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, timeout);
+
+    const body = {};
+    putIfSet(body, 'image_base64', config.imageBase64);
+    putIfSet(body, 'upscale_factor', config.upscaleFactor);
+    putIfSet(body, 'repeats', config.repeats);
+    putIfSet(body, 'tile_size', config.tileSize);
+    if (!body.image_base64) {
+        throw new Error('SD.cpp: upscale needs an image');
+    }
+    putIfSet(body, 'title', config.title);
+
+    // Anything else the API accepts, passed 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 + '/upscale',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        body: JSON.stringify(body),
+        timeout: timeout,
+        what: 'queueing the upscale job'
+    });
+
+    if (!queued.job_id) {
+        throw new Error('SD.cpp: the job was accepted but no job id came back');
+    }
+
+    smartbotic.log.info('SD.cpp: queued upscale job ' + queued.job_id);
+
+    return {
+        jobId: queued.job_id,
+        status: queued.status || 'pending',
+        position: queued.position !== undefined ? queued.position : -1,
+        request: body
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };