Преглед изворни кода

feat: a Fill-in button on the generation nodes, from the architecture preset

Load Model has a button that reads what the server has and writes it into
the form. The generation nodes had nothing equivalent, so getting the right
sampler, scheduler, step count and cfg for a model meant knowing them.

The server publishes a preset per architecture at /architectures, each with
the generation defaults it would apply if the fields were left empty. A new
SD.cpp Architecture Preset node reads it, and the five generation nodes now
declare a prefill against it: press the button and the form fills with the
defaults for whichever model the server currently has loaded.

Finding the architecture takes two routes because one is not enough. For a
loaded model /health names it outright; /architectures/detect answers from a
filename and returned "not detected" for the very model that was loaded. A
preset is then matched by id, name or alias - ZImage and Z Image both mean
Z-Image.

The preset's field names are the API's (cfg_scale, cache_mode) and the
settings are the editor's (cfgScale, cacheMode); one rule converts between
them, so a preset that gains a field arrives without the node being touched.

Measured on the running server: Z-Image filled in 8 settings - width, height,
steps, cfg scale, sampler, scheduler, cache mode and distilled guidance - and
said "the server does not report flowShift, negativePrompt, so they were left
as they are", which is the distinction that matters: absent means nothing to
say, and only empty means the server is using its own default. 60/60.
fszontagh пре 1 месец
родитељ
комит
c0303d989f

+ 261 - 0
nodes/sdcpp/sdcpp-architecture.js

@@ -0,0 +1,261 @@
+/**
+ * @node sdcpp-architecture
+ * @name SD.cpp Architecture Preset
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Read the generation defaults SD.cpp uses for a model's architecture
+ * @icon sliders-horizontal
+ */
+
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
+const configSchema = {
+    type: 'object',
+    uiGroups: [
+        { title: 'Server', fields: ['serverUrl', 'credentialId'] },
+        { title: 'Model', fields: ['modelName', 'timeout'] }
+    ],
+    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: ['sdcpp', 'basic'] } }
+        },
+        modelName: {
+            type: 'string', title: 'Model',
+            description: 'Which model to look up. Leave empty to use whatever the server currently has loaded',
+            dynamicOptions: {
+                source: 'node',
+                node: 'sdcpp-model',
+                config: { listOnly: true },
+                itemsPath: 'models',
+                valueKey: 'name',
+                labelKey: 'name',
+                needs: ['serverUrl', 'credentialId']
+            }
+        },
+        timeout: {
+            type: 'number', title: 'Timeout (ms)',
+            description: 'How long to wait for the server to answer',
+            default: 30000
+        }
+    },
+    required: ['credentialId']
+};
+
+const inputSchema = { type: 'object', properties: { data: { type: 'any' } } };
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        found: { type: 'boolean', description: 'False when the server publishes no preset for this model' },
+        architecture: { type: 'string', description: 'Architecture id, such as Z-Image or Flux Schnell' },
+        architectureName: { type: 'string', description: 'Its display name' },
+        modelName: { type: 'string', description: 'The model this describes' },
+        description: { type: 'string', description: 'What the server says about this architecture' },
+        imageEditMode: { type: 'string', description: 'How this architecture takes an image to edit, when it can' },
+        requiredComponents: { type: 'object', description: 'VAE, LLM and so on that this architecture needs loaded' },
+        defaults: { type: 'object', description: 'The generation defaults, named the way the generation nodes name their settings' },
+        preset: { type: 'object', description: 'The preset exactly as the server describes it' }
+    }
+};
+
+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;
+}
+
+// The server names preset fields the way the generation endpoints do -
+// cfg_scale, cache_mode - while the nodes name their settings the way the rest
+// of the editor does. One rule converts between them, so a preset that gains a
+// field arrives without this node being touched.
+function toSetting(name) {
+    const parts = String(name).split('_');
+    let out = parts[0];
+    for (let i = 1; i < parts.length; i++) {
+        out += parts[i].charAt(0).toUpperCase() + parts[i].slice(1);
+    }
+    return out;
+}
+
+// Presets are keyed by their id, but a model can be known by a name or an
+// alias - "ZImage" and "Z Image" both mean Z-Image - so all three are tried.
+function findPreset(architectures, wanted) {
+    const target = String(wanted || '').trim().toLowerCase();
+    if (!target) { return null; }
+
+    const keys = Object.keys(architectures || {});
+    for (let i = 0; i < keys.length; i++) {
+        const preset = architectures[keys[i]] || {};
+        const names = [keys[i], preset.id, preset.name];
+        const aliases = preset.aliases;
+        if (aliases && typeof aliases.length === 'number') {
+            for (let j = 0; j < aliases.length; j++) { names.push(aliases[j]); }
+        }
+        for (let j = 0; j < names.length; j++) {
+            if (String(names[j] || '').trim().toLowerCase() === target) {
+                return preset;
+            }
+        }
+    }
+    return null;
+}
+
+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 authHeader = { 'Authorization': 'Bearer ' + token };
+
+    let modelName = String(config.modelName || '').trim();
+    let architecture = '';
+
+    if (!modelName) {
+        // /health needs no token, and for a model that is already loaded it
+        // names the architecture outright - which detection does not always
+        // manage from a filename alone.
+        const health = call({
+            method: 'GET', url: server + '/health', timeout: timeout,
+            what: 'reading server health'
+        });
+        modelName = String(health.model_name || health.loading_model_name || '').trim();
+        architecture = String(health.model_architecture || '');
+    }
+
+    if (!modelName && !architecture) {
+        throw new Error('SD.cpp: no model given and the server has none loaded, so there is no ' +
+            'architecture to look up');
+    }
+
+    if (!architecture && modelName) {
+        const detection = call({
+            method: 'GET',
+            url: server + '/architectures/detect?model=' + encodeURIComponent(modelName),
+            headers: authHeader,
+            timeout: timeout,
+            what: 'detecting the architecture of "' + modelName + '"'
+        });
+        const detected = detection.architecture || {};
+        architecture = String(detected.id || detected.name || '');
+    }
+
+    const listing = call({
+        method: 'GET', url: server + '/architectures', headers: authHeader,
+        timeout: timeout, what: 'reading the architecture presets'
+    });
+    const preset = findPreset(listing.architectures, architecture) || {};
+
+    // The preset's generation defaults, under the names the generation nodes use
+    // for them. This is what the Fill-in button on those nodes writes into the
+    // form.
+    const defaults = {};
+    const raw = preset.generationDefaults || {};
+    const keys = Object.keys(raw);
+    for (let i = 0; i < keys.length; i++) {
+        defaults[toSetting(keys[i])] = raw[keys[i]];
+    }
+
+    if (!preset.id) {
+        smartbotic.log.warn('SD.cpp: no preset for "' + (architecture || modelName) +
+            '" - nothing to fill in from');
+    } else {
+        smartbotic.log.info('SD.cpp: ' + modelName + ' is ' + preset.id + ', ' +
+            keys.length + ' generation default(s)');
+    }
+
+    return {
+        found: !!preset.id,
+        architecture: String(preset.id || architecture || ''),
+        architectureName: String(preset.name || ''),
+        modelName: modelName,
+        description: String(preset.description || ''),
+        imageEditMode: String(preset.imageEditMode || ''),
+        requiredComponents: preset.requiredComponents || {},
+        defaults: defaults,
+        preset: preset
+    };
+}
+
+module.exports = { credentialTypes, configSchema, inputSchema, outputSchema, execute };

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

@@ -137,6 +137,27 @@ const configSchema = {
                     ]
             }
     ],
+    prefill: {
+            "label": "Take the architecture defaults",
+            "description": "Fill these in from the preset for whichever model the server has loaded - the same values it would use if these were left empty",
+            "node": "sdcpp-architecture",
+            "needs": [
+                    "serverUrl",
+                    "credentialId"
+            ],
+            "map": {
+                    "defaults.width": "width",
+                    "defaults.height": "height",
+                    "defaults.steps": "steps",
+                    "defaults.cfgScale": "cfgScale",
+                    "defaults.sampler": "sampler",
+                    "defaults.scheduler": "scheduler",
+                    "defaults.cacheMode": "cacheMode",
+                    "defaults.distilledGuidance": "distilledGuidance",
+                    "defaults.flowShift": "flowShift",
+                    "defaults.negativePrompt": "negativePrompt"
+            }
+    },
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',

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

@@ -135,6 +135,27 @@ const configSchema = {
                     ]
             }
     ],
+    prefill: {
+            "label": "Take the architecture defaults",
+            "description": "Fill these in from the preset for whichever model the server has loaded - the same values it would use if these were left empty",
+            "node": "sdcpp-architecture",
+            "needs": [
+                    "serverUrl",
+                    "credentialId"
+            ],
+            "map": {
+                    "defaults.width": "width",
+                    "defaults.height": "height",
+                    "defaults.steps": "steps",
+                    "defaults.cfgScale": "cfgScale",
+                    "defaults.sampler": "sampler",
+                    "defaults.scheduler": "scheduler",
+                    "defaults.cacheMode": "cacheMode",
+                    "defaults.distilledGuidance": "distilledGuidance",
+                    "defaults.flowShift": "flowShift",
+                    "defaults.negativePrompt": "negativePrompt"
+            }
+    },
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',

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

@@ -141,6 +141,27 @@ const configSchema = {
                     ]
             }
     ],
+    prefill: {
+            "label": "Take the architecture defaults",
+            "description": "Fill these in from the preset for whichever model the server has loaded - the same values it would use if these were left empty",
+            "node": "sdcpp-architecture",
+            "needs": [
+                    "serverUrl",
+                    "credentialId"
+            ],
+            "map": {
+                    "defaults.width": "width",
+                    "defaults.height": "height",
+                    "defaults.steps": "steps",
+                    "defaults.cfgScale": "cfgScale",
+                    "defaults.sampler": "sampler",
+                    "defaults.scheduler": "scheduler",
+                    "defaults.cacheMode": "cacheMode",
+                    "defaults.distilledGuidance": "distilledGuidance",
+                    "defaults.flowShift": "flowShift",
+                    "defaults.negativePrompt": "negativePrompt"
+            }
+    },
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',

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

@@ -133,6 +133,29 @@ const configSchema = {
                     ]
             }
     ],
+    prefill: {
+            "label": "Take the architecture defaults",
+            "description": "Fill these in from the preset for whichever model the server has loaded - the same values it would use if these were left empty",
+            "node": "sdcpp-architecture",
+            "needs": [
+                    "serverUrl",
+                    "credentialId"
+            ],
+            "map": {
+                    "defaults.width": "width",
+                    "defaults.height": "height",
+                    "defaults.steps": "steps",
+                    "defaults.cfgScale": "cfgScale",
+                    "defaults.sampler": "sampler",
+                    "defaults.scheduler": "scheduler",
+                    "defaults.cacheMode": "cacheMode",
+                    "defaults.distilledGuidance": "distilledGuidance",
+                    "defaults.flowShift": "flowShift",
+                    "defaults.negativePrompt": "negativePrompt",
+                    "defaults.videoFrames": "videoFrames",
+                    "defaults.fps": "fps"
+            }
+    },
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',

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

@@ -56,6 +56,16 @@ const configSchema = {
                     ]
             }
     ],
+    prefill: {
+            "label": "Take the architecture defaults",
+            "description": "Fill these in from the preset for whichever model the server has loaded - the same values it would use if these were left empty",
+            "node": "sdcpp-architecture",
+            "needs": [
+                    "serverUrl",
+                    "credentialId"
+            ],
+            "map": {}
+    },
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',

+ 21 - 1
scripts/gen-sdcpp-generation-options.py

@@ -62,6 +62,16 @@ EXTRA_FOR = {
 # Which group the connection settings go in, and its position.
 SERVER_GROUP = {'title': 'Server', 'fields': ['serverUrl', 'credentialId']}
 
+# Pressing the button asks the server which architecture the loaded model is and
+# writes that architecture's generation defaults into the form. Every preset
+# carries width, height, steps, cfg_scale, sampler and scheduler; a few carry
+# more. A field a preset does not mention is left alone rather than blanked.
+PREFILL_FIELDS = [
+    'width', 'height', 'steps', 'cfgScale', 'sampler', 'scheduler',
+    'cacheMode', 'distilledGuidance', 'flowShift', 'negativePrompt',
+    'videoFrames', 'fps',
+]
+
 
 def camel(name: str) -> str:
     if name in KEEP_NAME:
@@ -178,12 +188,21 @@ def rewrite(path: Path, endpoint: str, reference: dict) -> str:
 
     ui_groups = [SERVER_GROUP] + groups + [{'title': 'Job', 'fields': ['title', 'extraOptions', 'timeout']}]
 
+    prefill = {
+        'label': 'Take the architecture defaults',
+        'description': "Fill these in from the preset for whichever model the server has loaded - the same values it would use if these were left empty",
+        'node': 'sdcpp-architecture',
+        'needs': ['serverUrl', 'credentialId'],
+        'map': {f'defaults.{f}': f for f in PREFILL_FIELDS if f in props},
+    }
+
     head = """const configSchema = {
     type: 'object',
     // Generated from the server's own reference at /options/generation - see
     // scripts/gen-sdcpp-generation-options.py. Every field the endpoint accepts
     // has a setting here, grouped the way the server groups them.
     uiGroups: %s,
+    prefill: %s,
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',
@@ -195,7 +214,8 @@ def rewrite(path: Path, endpoint: str, reference: dict) -> str:
             description: 'A basic credential holding the sdcpp-restapi username and password',
             dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
-""" % json.dumps(ui_groups, ensure_ascii=False, indent=8).replace('\n', '\n    ')
+""" % (json.dumps(ui_groups, ensure_ascii=False, indent=8).replace('\n', '\n    '),
+        json.dumps(prefill, ensure_ascii=False, indent=8).replace('\n', '\n    '))
 
     body = render_properties(props) + ',\n' + render_properties(tail_props)
     new_schema = head + body + "\n    },\n    required: ['credentialId']\n};\n\n"