Bläddra i källkod

feat: every SD.cpp generation option is a real setting, generated from the server

The five generation nodes exposed about ten fields each and hid the other
thirty-odd behind an "Extra Options" JSON box - so using cache acceleration,
skip-layer guidance, VAE tiling or PhotoMaker meant knowing the field name
and hand-writing JSON, with no label, no default and no description.

The server publishes all of it at /options/generation: every field, with a
label, a type, a default, a description, what it is recommended for, and
which endpoints accept it. So the settings are generated from that rather
than typed out again - scripts/gen-sdcpp-generation-options.py rewrites the
schema and the setting-to-field table together, which is what stops the two
drifting the way they already had. Nothing generated runs at execution time;
the nodes stay self-contained JavaScript.

txt2img 51 settings, img2img 50, edit 52, txt2vid 53, upscale 11, each
grouped the way the server groups them - Core, Guidance, Cache Acceleration,
Video, and so on. Every option carries the server's own description and its
recommendation, and every one says it can be left empty for the architecture
default, because that is true of all of them.

The request is now built by walking that table instead of field by field, so
a documented option cannot be quietly missing from what gets sent. Empty
still means absent, never zero - the server fills an absent field from the
loaded model's preset, and seed 0 and clip_skip 0 are real values.

Two things the reference gave away: upscale gains imageIndex and jobId, so it
can upscale a previous job's output rather than only a base64 blob. And the
server documents ref_images and ref_image_args as txt2img fields while the
image-edit node has always posted them to /img2img and had them accepted -
kept, with the discrepancy noted in the generator, rather than a node losing
a working feature to a documentation table.

Verified against the running server: a txt2img job with cache_mode set from
the new setting - previously only reachable through the JSON box - queued,
rendered and came back succeeded, and the request carried exactly the fields
that were filled and nothing else. All twelve groups render in order in the
editor. 60/60.
fszontagh 1 månad sedan
förälder
incheckning
42c2cab7de

+ 233 - 80
nodes/sdcpp/sdcpp-edit.js

@@ -25,6 +25,118 @@ const credentialTypes = [
 
 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: [
+            {
+                    "title": "Server",
+                    "fields": [
+                            "serverUrl",
+                            "credentialId"
+                    ]
+            },
+            {
+                    "title": "Core",
+                    "fields": [
+                            "prompt",
+                            "negativePrompt",
+                            "width",
+                            "height",
+                            "steps",
+                            "cfgScale",
+                            "seed",
+                            "sampler",
+                            "scheduler",
+                            "batchCount"
+                    ]
+            },
+            {
+                    "title": "Cache Acceleration",
+                    "fields": [
+                            "cacheMode",
+                            "easycacheThreshold",
+                            "easycacheStart",
+                            "easycacheEnd",
+                            "spectrumW",
+                            "spectrumM",
+                            "spectrumLam",
+                            "spectrumWindowSize",
+                            "spectrumFlexWindow",
+                            "spectrumWarmupSteps",
+                            "spectrumStopPercent"
+                    ]
+            },
+            {
+                    "title": "ControlNet",
+                    "fields": [
+                            "controlImageBase64",
+                            "controlStrength"
+                    ]
+            },
+            {
+                    "title": "Prompt Expansion",
+                    "fields": [
+                            "expandPrompt"
+                    ]
+            },
+            {
+                    "title": "Guidance",
+                    "fields": [
+                            "distilledGuidance",
+                            "eta",
+                            "shiftedTimestep",
+                            "flowShift",
+                            "clipSkip"
+                    ]
+            },
+            {
+                    "title": "Image Input (img2img / image-edit)",
+                    "fields": [
+                            "initImageBase64",
+                            "maskImageBase64",
+                            "strength",
+                            "imgCfgScale",
+                            "refImages",
+                            "refImageArgs"
+                    ]
+            },
+            {
+                    "title": "Skip Layer Guidance (SLG)",
+                    "fields": [
+                            "slgScale",
+                            "skipLayers",
+                            "slgStart",
+                            "slgEnd",
+                            "customSigmas"
+                    ]
+            },
+            {
+                    "title": "Upscale After Generation",
+                    "fields": [
+                            "upscale",
+                            "upscaleRepeats",
+                            "upscaleAutoUnload"
+                    ]
+            },
+            {
+                    "title": "VAE Tiling (per-generation)",
+                    "fields": [
+                            "vaeTiling",
+                            "vaeTileSizeX",
+                            "vaeTileSizeY",
+                            "vaeTileOverlap"
+                    ]
+            },
+            {
+                    "title": "Job",
+                    "fields": [
+                            "title",
+                            "extraOptions",
+                            "timeout"
+                    ]
+            }
+    ],
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',
@@ -36,63 +148,56 @@ const configSchema = {
             description: 'A basic credential holding the sdcpp-restapi username and password',
             dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', '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
-        }
+        batchCount: { title: "Batch Count", description: "Number of independent images to produce in this single job. Each image gets a different seed (`seed`, `seed+1`, …). Total VRAM doesn't grow with batch_count — sd.cpp serializes. Recommended: 1 for interactive, higher when you want a grid of variations from one prompt. Leave empty for the architecture default.", type: "number" },
+        cacheMode: { title: "Cache Mode", description: "DiT-model intermediate caching strategy. Skips redundant computation across consecutive sampler steps when the model's intermediate state hasn't changed enough to matter. `easycache` is the simplest; `spectrum` is the newest, generally best for Flux/SD3/Z-Image. Recommended: spectrum for Flux/SD3/Z-Image when generation is too slow. Off for SD1.5/SDXL (UNet is too small for caching to win). Leave empty for the architecture default.", type: "string", enum: ["", "easycache", "spectrum"], enumLabels: ["(architecture default)", "EasyCache — single threshold, simple", "Spectrum — frequency-domain analysis (best quality/speed tradeoff)"], default: "" },
+        cfgScale: { title: "CFG Scale", description: "Classifier-Free Guidance scale. Strength of pushing the cond toward the prompt vs the uncond. Higher = follows prompt more aggressively but can over-saturate. Set to 1.0 to disable CFG (skips the uncond pass — twice as fast). Recommended: SD1.5: 7. SDXL: 4–8. Flux Dev: 1 (CFG bypassed in flow models). Z-Image: 1. Schnell/Turbo: 1. Leave empty for the architecture default.", type: "number" },
+        clipSkip: { title: "CLIP Skip", description: "Skip the last N layers of CLIP when encoding the prompt. -1 = use the model's recommended default. 2 is the classic anime-model setting. Recommended: -1 (auto). Set to 2 for anime/cartoon SD1.5 fine-tunes. Leave empty for the architecture default.", type: "number" },
+        controlImageBase64: { title: "ControlNet Image (base64)", description: "Base64-encoded pre-processed control image. Must match the ControlNet model loaded — Canny edges for canny model, depth map for depth model, etc. sd.cpp does not pre-process; you do that client-side. Recommended: Required when using ControlNet. Leave empty for the architecture default.", type: "string" },
+        controlStrength: { title: "ControlNet Strength", description: "How much the ControlNet influences the diffusion process (0.0–1.0). Lower = looser following, higher = tighter following at cost of overall coherence. Recommended: 0.6–0.9 for most uses. Lower for creative prompts where control should be a hint, not a constraint. Leave empty for the architecture default.", type: "number" },
+        customSigmas: { title: "Custom Sigma Schedule", description: "Replace the scheduler's noise sigma sequence with a hand-tuned one. Bypasses `scheduler`. Empty array = use the chosen scheduler. Recommended: Empty unless you're hand-tuning per-step noise levels — niche. Leave empty for the architecture default.", type: "array", items: {"type": "number"} },
+        distilledGuidance: { title: "Distilled Guidance", description: "Distilled-CFG scale for models that bake the CFG behavior into the diffusion model itself (Flux). Replaces the runtime CFG split. Recommended: Flux Dev: 3.5 (sd.cpp default). Other architectures: ignored. Leave empty for the architecture default.", type: "number" },
+        easycacheEnd: { title: "EasyCache End (% of steps)", description: "Fraction of steps after which EasyCache turns off (last few steps fully recomputed for sharpness). Recommended: 0.95. Leave empty for the architecture default.", type: "number" },
+        easycacheStart: { title: "EasyCache Start (% of steps)", description: "Fraction of steps before EasyCache becomes active. Small to skip the early high-noise steps where caching hurts. Recommended: 0.15. Leave empty for the architecture default.", type: "number" },
+        easycacheThreshold: { title: "EasyCache Threshold", description: "Reuse threshold for EasyCache. Higher = more aggressive reuse (faster but more quality loss). Range typically 0.05–0.4. Recommended: 0.2 starting point. Tune up for more speed, down for more quality. Leave empty for the architecture default.", type: "number" },
+        eta: { title: "Eta", description: "Stochasticity parameter for DDIM-family samplers. 0 = deterministic, 1 = max stochasticity (matches DDPM noise schedule). Most other samplers ignore this. Recommended: 0 (deterministic, reproducible). Raise only with DDIM-trailing for some variation. Leave empty for the architecture default.", type: "number" },
+        expandPrompt: { title: "Expand Prompt Template", description: "If true, parse `prompt` for a1111-style dynamic-prompts syntax (`{a|b|c}`, `{N$$a|b|c}`) and create one queue item per variation. The response then carries `group_id` + `variation_count` + `job_ids[]` instead of a single `job_id`. Hard cap: 200 variations per request. Recommended: Enable when your prompt actually contains `{…}` syntax. Leave empty for the architecture default.", type: "boolean" },
+        flowShift: { title: "Flow Shift", description: "Per-generation flow-matching shift parameter for Flux / SD3 / Z-Image models. Higher values shift sampling toward larger noise levels longer. Per-call override; the load-time `flow_shift` is the default if this isn't set. Recommended: Flux Dev: 1.0. Z-Image: 3.0. SD3 Medium: 3.0. Leave empty for the architecture default.", type: "number" },
+        height: { title: "Height (px)", description: "Output image height in pixels. Same divisibility constraint as `width`. Recommended: Match training resolution. For non-square: total pixel count close to native is more important than aspect ratio. Leave empty for the architecture default.", type: "number" },
+        imgCfgScale: { title: "Image CFG Scale", description: "Image-CFG for instruct-pix2pix-style models. -1 = same as `cfg_scale`. Different from `cfg_scale`: balances text-prompt influence against image-prompt influence. Recommended: -1 unless using an instruct-pix2pix variant. Leave empty for the architecture default.", type: "number" },
+        initImageBase64: { title: "Init Image (base64)", description: "Base64-encoded source image for img2img / image-edit. Required for /img2img. Recommended: Required for img2img. Leave empty for the architecture default.", type: "string" },
+        maskImageBase64: { title: "Mask Image (base64)", description: "Base64-encoded inpainting mask. White areas are repainted, black areas preserved. Recommended: Optional. Empty mask = standard img2img (no inpainting). Leave empty for the architecture default.", type: "string" },
+        negativePrompt: { title: "Negative Prompt", description: "What to push the model away from. Has effect only when `cfg_scale > 1` (CFG is what compares cond vs uncond). Ignored on architectures that don't use CFG (most flow-matching models default to `cfg_scale = 1`). Recommended: Optional. Empty is a fine default for Flux / SD3 / Z-Image. Leave empty for the architecture default.", type: "string", format: "textarea" },
+        prompt: { title: "Prompt", description: "Text prompt fed to the model's text encoder (CLIP / T5 / LLM). Supports inline `<lora:name:weight>` tags — these are auto-extracted into the LoRA list before being sent to the encoder. Supports a1111-style dynamic-prompts (`{a|b|c}`) when `expand_prompt: true` is set. Recommended: Required. Leave empty for the architecture default.", type: "string", format: "textarea" },
+        refImageArgs: { title: "Reference Image Args", description: "Comma-separated k=v flags controlling reference-image preprocessing (e.g. `resize_before_vae=0,ref_index_mode=increase`). Replaces the previous auto_resize_ref_image / increase_ref_index bools. See sd.cpp docs. Recommended: Leave empty unless you need to override defaults. Leave empty for the architecture default.", type: "string" },
+        refImages: { title: "Reference Images (base64)", description: "Base64-encoded reference images for Flux Kontext / image-edit. Each image gets encoded into the conditioner alongside the text prompt. Recommended: Use for Flux Kontext or models that accept reference images. Leave empty for the architecture default.", type: "array", items: {"type": "string"} },
+        sampler: { title: "Sampler", description: "Sampling algorithm. Different samplers can produce different images at the same seed; quality and speed differ too. Recommended: euler_a (general), dpmpp2m (SD1.5/SDXL), euler (Flux/SD3/Z-Image), lcm (LCM models). Leave empty for the architecture default.", type: "string", enum: ["", "ddim_trailing", "dpm2", "dpmpp2m", "dpmpp2mv2", "dpmpp2s_a", "er_sde", "euler", "euler_a", "heun", "ipndm", "ipndm_v", "lcm", "res_2s", "res_multistep", "tcd"], enumLabels: ["(architecture default)", "DDIM trailing — required for some fine-tunes", "DPM2 — 2nd-order, balanced", "DPM++ 2M — fast, good quality", "DPM++ 2M v2 — improved schedule", "DPM++ 2S ancestral — popular for SDXL", "ER SDE — SDE sampler (added recently)", "Euler — simple, fast, deterministic", "Euler ancestral — adds noise each step (less reproducible, more variet", "Heun — 2nd-order, slower, sometimes higher quality", "IPNDM", "IPNDM-V", "LCM — for LCM-finetuned models (4–8 step generation)", "RES 2S", "RES multistep — flow-model variant", "TCD — for TCD-finetuned models"], default: "" },
+        scheduler: { title: "Scheduler", description: "Determines the noise schedule (the timesteps the sampler walks through). Pairs with the sampler — some combinations (e.g. karras+dpmpp2m) are well-tested, others may be off. Recommended: discrete or karras for SD1.5/SDXL; simple for Flux/SD3; smoothstep for Z-Image. Leave empty for the architecture default.", type: "string", enum: ["", "ays", "bong_tangent", "discrete", "exponential", "gits", "karras", "kl_optimal", "lcm", "sgm_uniform", "simple", "smoothstep"], enumLabels: ["(architecture default)", "Align Your Steps (AYS) — auto-tuned", "Bong Tangent", "Discrete — uniform across model timesteps (default)", "Exponential", "GITS", "Karras — concentrates more steps near the end, common for SDXL", "KL optimal", "LCM — for LCM samplers", "SGM uniform — for SGM-trained models", "Simple — used by Flux / SD3 flow models", "Smoothstep — Z-Image's recommended scheduler"], default: "" },
+        seed: { title: "Seed", description: "RNG seed for the initial noise tensor (and stochastic samplers). -1 = pick a random one each generation. Same seed + same prompt + same model = same image. Recommended: -1 for variety. Pin a specific number for A/B comparing prompt or sampler changes. Leave empty for the architecture default.", type: "number" },
+        shiftedTimestep: { title: "Shifted Timestep", description: "Start the sampling schedule from a non-final timestep (used by NitroFusion and similar fast-sampling fine-tunes). 0 = standard schedule. 250–500 = NitroFusion's range. Recommended: 0 unless you're explicitly running a NitroFusion-style fine-tune. Leave empty for the architecture default.", type: "number" },
+        skipLayers: { title: "SLG Skip Layers", description: "Which transformer layers to skip during the SLG unconditional pass. SD3.5 Medium uses [7, 8, 9]. Recommended: [7,8,9] for SD3.5 Medium. Other models: leave default, ignored when slg_scale=0. Leave empty for the architecture default.", type: "array", items: {"type": "number"} },
+        slgEnd: { title: "SLG End (% of steps)", description: "Fraction of the sampler schedule at which SLG turns off. Recommended: 0.2 (early-cycle only — late-cycle SLG hurts quality). Leave empty for the architecture default.", type: "number" },
+        slgScale: { title: "SLG Scale", description: "Skip Layer Guidance scale. Selectively zeros out a few diffusion layers when computing the unconditional pass — improves anatomy / coherence on SD3.5-medium and similar. 0 = disabled. Recommended: 0 for most models. 2.5 for SD3.5 Medium with the recommended skip layers. Leave empty for the architecture default.", type: "number" },
+        slgStart: { title: "SLG Start (% of steps)", description: "Fraction of the sampler schedule (0.0–1.0) at which SLG kicks in. Recommended: 0.01 (almost from the start). Leave empty for the architecture default.", type: "number" },
+        spectrumFlexWindow: { title: "Spectrum Cache: flex window", description: "Spectrum-cache flexibility window (0.0–1.0). Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        spectrumLam: { title: "Spectrum Cache: λ", description: "Spectrum-cache regularization λ. Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        spectrumM: { title: "Spectrum Cache: m", description: "Spectrum-cache moving-average length. Recommended: 5. Leave empty for the architecture default.", type: "number" },
+        spectrumStopPercent: { title: "Spectrum Cache: stop percent", description: "Fraction of steps after which spectrum cache disengages (last steps recomputed). Recommended: 0.8. Leave empty for the architecture default.", type: "number" },
+        spectrumW: { title: "Spectrum Cache: w", description: "Spectrum-cache `w` weight (frequency cutoff). Higher = retains more spectrum, less speedup. Recommended: 0.5 default. See sd.cpp PR #1322 for tuning. Leave empty for the architecture default.", type: "number" },
+        spectrumWarmupSteps: { title: "Spectrum Cache: warmup steps", description: "Steps at the start of sampling before spectrum cache becomes active. Recommended: 2. Leave empty for the architecture default.", type: "number" },
+        spectrumWindowSize: { title: "Spectrum Cache: window size", description: "Spectrum-cache analysis window size in steps. Recommended: 3. Leave empty for the architecture default.", type: "number" },
+        steps: { title: "Sampling Steps", description: "Number of denoising steps the sampler runs. More steps = closer to the model's converged output, with diminishing returns. Distilled models (Flux Schnell, SDXL Turbo, Z-Image Turbo) need only 4–8. Recommended: 20–30 for SD1.5/SDXL, 20 for Flux Dev, 4–8 for *-Turbo or Schnell variants. Leave empty for the architecture default.", type: "number" },
+        strength: { title: "Denoising Strength", description: "How much of the init image's noise to keep (0.0 = identical to init, 1.0 = ignore init entirely). Controls how aggressively img2img diverges from the input. Recommended: 0.5–0.75 for natural-looking edits. 0.9+ for radical reinterpretation. Leave empty for the architecture default.", type: "number" },
+        upscale: { title: "Upscale After Generate", description: "Run the loaded ESRGAN upscaler on the output image after generation. Requires an upscaler to be loaded via POST /upscaler/load. Recommended: On for one-shot 'generate then upscale' workflows. Leave empty for the architecture default.", type: "boolean" },
+        upscaleAutoUnload: { title: "Auto-unload Upscaler", description: "Free upscaler VRAM right after the upscale step. Useful when you generated with `upscale: true` and want VRAM back for other work. Recommended: On. Leave empty for the architecture default.", type: "boolean" },
+        upscaleRepeats: { title: "Upscale Repeats", description: "Number of post-generation auto-upscale passes (each with the upscaler's native factor — 4× ESRGAN × 2 passes = 16×). DISTINCT from the `/upscale` endpoint's own `repeats` field, which controls passes inside a single upscale job. `upscale_repeats` only chains additional /upscale calls after txt2img / img2img finish. Recommended: 1. Two passes amplify artifacts, but produces large outputs from small inputs. Leave empty for the architecture default.", type: "number" },
+        vaeTileOverlap: { title: "VAE Tile Overlap", description: "Overlap fraction between VAE tiles for seam blending. Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        vaeTileSizeX: { title: "VAE Tile Width", description: "Width of VAE tiles when tiling is on. 0 = use load-time default. Recommended: 0. Leave empty for the architecture default.", type: "number" },
+        vaeTileSizeY: { title: "VAE Tile Height", description: "Height of VAE tiles. 0 = use load-time default. Recommended: 0. Leave empty for the architecture default.", type: "number" },
+        vaeTiling: { title: "VAE Tiling", description: "Per-generation override of the model-load `vae_tiling`. Process VAE encode/decode in tiles to reduce peak VRAM. Recommended: Enable for ≥2048 px outputs. Otherwise leave to the load-time default. Leave empty for the architecture default.", type: "boolean" },
+        width: { title: "Width (px)", description: "Output image width in pixels. Must be divisible by the model's patch size (typically 8 or 16). Architectures have native resolutions they were trained at — going far off them can degrade quality. Recommended: Match the architecture's training resolution: SD1.5=512, SDXL=1024, Flux/SD3/Z-Image=1024, Wan video=832. Leave empty for the architecture default.", type: "number" },
+        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. Everything the server documents already has a setting above, so this is only needed for a field a newer server has gained" },
+        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']
 };
@@ -201,11 +306,57 @@ function putIfSet(target, key, value) {
     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.
+// Every generation field the server documents, and the setting it comes
+// from. Generated alongside the schema above so the two cannot drift.
+const GENERATION_OPTIONS = [
+    { setting: "batchCount", server: "batch_count" },
+    { setting: "cacheMode", server: "cache_mode" },
+    { setting: "cfgScale", server: "cfg_scale" },
+    { setting: "clipSkip", server: "clip_skip" },
+    { setting: "controlImageBase64", server: "control_image_base64" },
+    { setting: "controlStrength", server: "control_strength" },
+    { setting: "customSigmas", server: "custom_sigmas" },
+    { setting: "distilledGuidance", server: "distilled_guidance" },
+    { setting: "easycacheEnd", server: "easycache_end" },
+    { setting: "easycacheStart", server: "easycache_start" },
+    { setting: "easycacheThreshold", server: "easycache_threshold" },
+    { setting: "eta", server: "eta" },
+    { setting: "expandPrompt", server: "expand_prompt" },
+    { setting: "flowShift", server: "flow_shift" },
+    { setting: "height", server: "height" },
+    { setting: "imgCfgScale", server: "img_cfg_scale" },
+    { setting: "initImageBase64", server: "init_image_base64" },
+    { setting: "maskImageBase64", server: "mask_image_base64" },
+    { setting: "negativePrompt", server: "negative_prompt" },
+    { setting: "prompt", server: "prompt" },
+    { setting: "refImageArgs", server: "ref_image_args" },
+    { setting: "refImages", server: "ref_images" },
+    { setting: "sampler", server: "sampler" },
+    { setting: "scheduler", server: "scheduler" },
+    { setting: "seed", server: "seed" },
+    { setting: "shiftedTimestep", server: "shifted_timestep" },
+    { setting: "skipLayers", server: "skip_layers" },
+    { setting: "slgEnd", server: "slg_end" },
+    { setting: "slgScale", server: "slg_scale" },
+    { setting: "slgStart", server: "slg_start" },
+    { setting: "spectrumFlexWindow", server: "spectrum_flex_window" },
+    { setting: "spectrumLam", server: "spectrum_lam" },
+    { setting: "spectrumM", server: "spectrum_m" },
+    { setting: "spectrumStopPercent", server: "spectrum_stop_percent" },
+    { setting: "spectrumW", server: "spectrum_w" },
+    { setting: "spectrumWarmupSteps", server: "spectrum_warmup_steps" },
+    { setting: "spectrumWindowSize", server: "spectrum_window_size" },
+    { setting: "steps", server: "steps" },
+    { setting: "strength", server: "strength" },
+    { setting: "upscale", server: "upscale" },
+    { setting: "upscaleAutoUnload", server: "upscale_auto_unload" },
+    { setting: "upscaleRepeats", server: "upscale_repeats" },
+    { setting: "vaeTileOverlap", server: "vae_tile_overlap" },
+    { setting: "vaeTileSizeX", server: "vae_tile_size_x" },
+    { setting: "vaeTileSizeY", server: "vae_tile_size_y" },
+    { setting: "vaeTiling", server: "vae_tiling" },
+    { setting: "width", server: "width" }
+];
 
 async function execute(config, input, context) {
     const server = normalizeServer(config.serverUrl);
@@ -215,30 +366,32 @@ async function execute(config, input, context) {
     const token = login(server, credential, timeout);
 
     const body = {};
-    putIfSet(body, 'prompt', config.prompt);
+
+    // Built from the table above rather than field by field, so a setting the
+    // server documents cannot be quietly missing from the request. A setting
+    // left empty is left out of the body entirely: the server fills an absent
+    // field from the loaded model's architecture preset, and sending an empty
+    // box as 0 would override that preset with nonsense. The test is emptiness,
+    // never truthiness - seed 0 and clip_skip 0 are legitimate values.
+    for (let i = 0; i < GENERATION_OPTIONS.length; i++) {
+        const option = GENERATION_OPTIONS[i];
+        const value = config[option.setting];
+        if (Array.isArray(value)) {
+            if (value.length > 0) {
+                body[option.server] = value;
+            }
+        } else {
+            putIfSet(body, option.server, value);
+        }
+    }
+    putIfSet(body, 'title', config.title);
+
     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.

+ 227 - 72
nodes/sdcpp/sdcpp-img2img.js

@@ -25,6 +25,116 @@ const credentialTypes = [
 
 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: [
+            {
+                    "title": "Server",
+                    "fields": [
+                            "serverUrl",
+                            "credentialId"
+                    ]
+            },
+            {
+                    "title": "Core",
+                    "fields": [
+                            "prompt",
+                            "negativePrompt",
+                            "width",
+                            "height",
+                            "steps",
+                            "cfgScale",
+                            "seed",
+                            "sampler",
+                            "scheduler",
+                            "batchCount"
+                    ]
+            },
+            {
+                    "title": "Cache Acceleration",
+                    "fields": [
+                            "cacheMode",
+                            "easycacheThreshold",
+                            "easycacheStart",
+                            "easycacheEnd",
+                            "spectrumW",
+                            "spectrumM",
+                            "spectrumLam",
+                            "spectrumWindowSize",
+                            "spectrumFlexWindow",
+                            "spectrumWarmupSteps",
+                            "spectrumStopPercent"
+                    ]
+            },
+            {
+                    "title": "ControlNet",
+                    "fields": [
+                            "controlImageBase64",
+                            "controlStrength"
+                    ]
+            },
+            {
+                    "title": "Prompt Expansion",
+                    "fields": [
+                            "expandPrompt"
+                    ]
+            },
+            {
+                    "title": "Guidance",
+                    "fields": [
+                            "distilledGuidance",
+                            "eta",
+                            "shiftedTimestep",
+                            "flowShift",
+                            "clipSkip"
+                    ]
+            },
+            {
+                    "title": "Image Input (img2img / image-edit)",
+                    "fields": [
+                            "initImageBase64",
+                            "maskImageBase64",
+                            "strength",
+                            "imgCfgScale"
+                    ]
+            },
+            {
+                    "title": "Skip Layer Guidance (SLG)",
+                    "fields": [
+                            "slgScale",
+                            "skipLayers",
+                            "slgStart",
+                            "slgEnd",
+                            "customSigmas"
+                    ]
+            },
+            {
+                    "title": "Upscale After Generation",
+                    "fields": [
+                            "upscale",
+                            "upscaleRepeats",
+                            "upscaleAutoUnload"
+                    ]
+            },
+            {
+                    "title": "VAE Tiling (per-generation)",
+                    "fields": [
+                            "vaeTiling",
+                            "vaeTileSizeX",
+                            "vaeTileSizeY",
+                            "vaeTileOverlap"
+                    ]
+            },
+            {
+                    "title": "Job",
+                    "fields": [
+                            "title",
+                            "extraOptions",
+                            "timeout"
+                    ]
+            }
+    ],
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',
@@ -36,58 +146,54 @@ const configSchema = {
             description: 'A basic credential holding the sdcpp-restapi username and password',
             dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', '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
-        }
+        batchCount: { title: "Batch Count", description: "Number of independent images to produce in this single job. Each image gets a different seed (`seed`, `seed+1`, …). Total VRAM doesn't grow with batch_count — sd.cpp serializes. Recommended: 1 for interactive, higher when you want a grid of variations from one prompt. Leave empty for the architecture default.", type: "number" },
+        cacheMode: { title: "Cache Mode", description: "DiT-model intermediate caching strategy. Skips redundant computation across consecutive sampler steps when the model's intermediate state hasn't changed enough to matter. `easycache` is the simplest; `spectrum` is the newest, generally best for Flux/SD3/Z-Image. Recommended: spectrum for Flux/SD3/Z-Image when generation is too slow. Off for SD1.5/SDXL (UNet is too small for caching to win). Leave empty for the architecture default.", type: "string", enum: ["", "easycache", "spectrum"], enumLabels: ["(architecture default)", "EasyCache — single threshold, simple", "Spectrum — frequency-domain analysis (best quality/speed tradeoff)"], default: "" },
+        cfgScale: { title: "CFG Scale", description: "Classifier-Free Guidance scale. Strength of pushing the cond toward the prompt vs the uncond. Higher = follows prompt more aggressively but can over-saturate. Set to 1.0 to disable CFG (skips the uncond pass — twice as fast). Recommended: SD1.5: 7. SDXL: 4–8. Flux Dev: 1 (CFG bypassed in flow models). Z-Image: 1. Schnell/Turbo: 1. Leave empty for the architecture default.", type: "number" },
+        clipSkip: { title: "CLIP Skip", description: "Skip the last N layers of CLIP when encoding the prompt. -1 = use the model's recommended default. 2 is the classic anime-model setting. Recommended: -1 (auto). Set to 2 for anime/cartoon SD1.5 fine-tunes. Leave empty for the architecture default.", type: "number" },
+        controlImageBase64: { title: "ControlNet Image (base64)", description: "Base64-encoded pre-processed control image. Must match the ControlNet model loaded — Canny edges for canny model, depth map for depth model, etc. sd.cpp does not pre-process; you do that client-side. Recommended: Required when using ControlNet. Leave empty for the architecture default.", type: "string" },
+        controlStrength: { title: "ControlNet Strength", description: "How much the ControlNet influences the diffusion process (0.0–1.0). Lower = looser following, higher = tighter following at cost of overall coherence. Recommended: 0.6–0.9 for most uses. Lower for creative prompts where control should be a hint, not a constraint. Leave empty for the architecture default.", type: "number" },
+        customSigmas: { title: "Custom Sigma Schedule", description: "Replace the scheduler's noise sigma sequence with a hand-tuned one. Bypasses `scheduler`. Empty array = use the chosen scheduler. Recommended: Empty unless you're hand-tuning per-step noise levels — niche. Leave empty for the architecture default.", type: "array", items: {"type": "number"} },
+        distilledGuidance: { title: "Distilled Guidance", description: "Distilled-CFG scale for models that bake the CFG behavior into the diffusion model itself (Flux). Replaces the runtime CFG split. Recommended: Flux Dev: 3.5 (sd.cpp default). Other architectures: ignored. Leave empty for the architecture default.", type: "number" },
+        easycacheEnd: { title: "EasyCache End (% of steps)", description: "Fraction of steps after which EasyCache turns off (last few steps fully recomputed for sharpness). Recommended: 0.95. Leave empty for the architecture default.", type: "number" },
+        easycacheStart: { title: "EasyCache Start (% of steps)", description: "Fraction of steps before EasyCache becomes active. Small to skip the early high-noise steps where caching hurts. Recommended: 0.15. Leave empty for the architecture default.", type: "number" },
+        easycacheThreshold: { title: "EasyCache Threshold", description: "Reuse threshold for EasyCache. Higher = more aggressive reuse (faster but more quality loss). Range typically 0.05–0.4. Recommended: 0.2 starting point. Tune up for more speed, down for more quality. Leave empty for the architecture default.", type: "number" },
+        eta: { title: "Eta", description: "Stochasticity parameter for DDIM-family samplers. 0 = deterministic, 1 = max stochasticity (matches DDPM noise schedule). Most other samplers ignore this. Recommended: 0 (deterministic, reproducible). Raise only with DDIM-trailing for some variation. Leave empty for the architecture default.", type: "number" },
+        expandPrompt: { title: "Expand Prompt Template", description: "If true, parse `prompt` for a1111-style dynamic-prompts syntax (`{a|b|c}`, `{N$$a|b|c}`) and create one queue item per variation. The response then carries `group_id` + `variation_count` + `job_ids[]` instead of a single `job_id`. Hard cap: 200 variations per request. Recommended: Enable when your prompt actually contains `{…}` syntax. Leave empty for the architecture default.", type: "boolean" },
+        flowShift: { title: "Flow Shift", description: "Per-generation flow-matching shift parameter for Flux / SD3 / Z-Image models. Higher values shift sampling toward larger noise levels longer. Per-call override; the load-time `flow_shift` is the default if this isn't set. Recommended: Flux Dev: 1.0. Z-Image: 3.0. SD3 Medium: 3.0. Leave empty for the architecture default.", type: "number" },
+        height: { title: "Height (px)", description: "Output image height in pixels. Same divisibility constraint as `width`. Recommended: Match training resolution. For non-square: total pixel count close to native is more important than aspect ratio. Leave empty for the architecture default.", type: "number" },
+        imgCfgScale: { title: "Image CFG Scale", description: "Image-CFG for instruct-pix2pix-style models. -1 = same as `cfg_scale`. Different from `cfg_scale`: balances text-prompt influence against image-prompt influence. Recommended: -1 unless using an instruct-pix2pix variant. Leave empty for the architecture default.", type: "number" },
+        initImageBase64: { title: "Init Image (base64)", description: "Base64-encoded source image for img2img / image-edit. Required for /img2img. Recommended: Required for img2img. Leave empty for the architecture default.", type: "string" },
+        maskImageBase64: { title: "Mask Image (base64)", description: "Base64-encoded inpainting mask. White areas are repainted, black areas preserved. Recommended: Optional. Empty mask = standard img2img (no inpainting). Leave empty for the architecture default.", type: "string" },
+        negativePrompt: { title: "Negative Prompt", description: "What to push the model away from. Has effect only when `cfg_scale > 1` (CFG is what compares cond vs uncond). Ignored on architectures that don't use CFG (most flow-matching models default to `cfg_scale = 1`). Recommended: Optional. Empty is a fine default for Flux / SD3 / Z-Image. Leave empty for the architecture default.", type: "string", format: "textarea" },
+        prompt: { title: "Prompt", description: "Text prompt fed to the model's text encoder (CLIP / T5 / LLM). Supports inline `<lora:name:weight>` tags — these are auto-extracted into the LoRA list before being sent to the encoder. Supports a1111-style dynamic-prompts (`{a|b|c}`) when `expand_prompt: true` is set. Recommended: Required. Leave empty for the architecture default.", type: "string", format: "textarea" },
+        sampler: { title: "Sampler", description: "Sampling algorithm. Different samplers can produce different images at the same seed; quality and speed differ too. Recommended: euler_a (general), dpmpp2m (SD1.5/SDXL), euler (Flux/SD3/Z-Image), lcm (LCM models). Leave empty for the architecture default.", type: "string", enum: ["", "ddim_trailing", "dpm2", "dpmpp2m", "dpmpp2mv2", "dpmpp2s_a", "er_sde", "euler", "euler_a", "heun", "ipndm", "ipndm_v", "lcm", "res_2s", "res_multistep", "tcd"], enumLabels: ["(architecture default)", "DDIM trailing — required for some fine-tunes", "DPM2 — 2nd-order, balanced", "DPM++ 2M — fast, good quality", "DPM++ 2M v2 — improved schedule", "DPM++ 2S ancestral — popular for SDXL", "ER SDE — SDE sampler (added recently)", "Euler — simple, fast, deterministic", "Euler ancestral — adds noise each step (less reproducible, more variet", "Heun — 2nd-order, slower, sometimes higher quality", "IPNDM", "IPNDM-V", "LCM — for LCM-finetuned models (4–8 step generation)", "RES 2S", "RES multistep — flow-model variant", "TCD — for TCD-finetuned models"], default: "" },
+        scheduler: { title: "Scheduler", description: "Determines the noise schedule (the timesteps the sampler walks through). Pairs with the sampler — some combinations (e.g. karras+dpmpp2m) are well-tested, others may be off. Recommended: discrete or karras for SD1.5/SDXL; simple for Flux/SD3; smoothstep for Z-Image. Leave empty for the architecture default.", type: "string", enum: ["", "ays", "bong_tangent", "discrete", "exponential", "gits", "karras", "kl_optimal", "lcm", "sgm_uniform", "simple", "smoothstep"], enumLabels: ["(architecture default)", "Align Your Steps (AYS) — auto-tuned", "Bong Tangent", "Discrete — uniform across model timesteps (default)", "Exponential", "GITS", "Karras — concentrates more steps near the end, common for SDXL", "KL optimal", "LCM — for LCM samplers", "SGM uniform — for SGM-trained models", "Simple — used by Flux / SD3 flow models", "Smoothstep — Z-Image's recommended scheduler"], default: "" },
+        seed: { title: "Seed", description: "RNG seed for the initial noise tensor (and stochastic samplers). -1 = pick a random one each generation. Same seed + same prompt + same model = same image. Recommended: -1 for variety. Pin a specific number for A/B comparing prompt or sampler changes. Leave empty for the architecture default.", type: "number" },
+        shiftedTimestep: { title: "Shifted Timestep", description: "Start the sampling schedule from a non-final timestep (used by NitroFusion and similar fast-sampling fine-tunes). 0 = standard schedule. 250–500 = NitroFusion's range. Recommended: 0 unless you're explicitly running a NitroFusion-style fine-tune. Leave empty for the architecture default.", type: "number" },
+        skipLayers: { title: "SLG Skip Layers", description: "Which transformer layers to skip during the SLG unconditional pass. SD3.5 Medium uses [7, 8, 9]. Recommended: [7,8,9] for SD3.5 Medium. Other models: leave default, ignored when slg_scale=0. Leave empty for the architecture default.", type: "array", items: {"type": "number"} },
+        slgEnd: { title: "SLG End (% of steps)", description: "Fraction of the sampler schedule at which SLG turns off. Recommended: 0.2 (early-cycle only — late-cycle SLG hurts quality). Leave empty for the architecture default.", type: "number" },
+        slgScale: { title: "SLG Scale", description: "Skip Layer Guidance scale. Selectively zeros out a few diffusion layers when computing the unconditional pass — improves anatomy / coherence on SD3.5-medium and similar. 0 = disabled. Recommended: 0 for most models. 2.5 for SD3.5 Medium with the recommended skip layers. Leave empty for the architecture default.", type: "number" },
+        slgStart: { title: "SLG Start (% of steps)", description: "Fraction of the sampler schedule (0.0–1.0) at which SLG kicks in. Recommended: 0.01 (almost from the start). Leave empty for the architecture default.", type: "number" },
+        spectrumFlexWindow: { title: "Spectrum Cache: flex window", description: "Spectrum-cache flexibility window (0.0–1.0). Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        spectrumLam: { title: "Spectrum Cache: λ", description: "Spectrum-cache regularization λ. Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        spectrumM: { title: "Spectrum Cache: m", description: "Spectrum-cache moving-average length. Recommended: 5. Leave empty for the architecture default.", type: "number" },
+        spectrumStopPercent: { title: "Spectrum Cache: stop percent", description: "Fraction of steps after which spectrum cache disengages (last steps recomputed). Recommended: 0.8. Leave empty for the architecture default.", type: "number" },
+        spectrumW: { title: "Spectrum Cache: w", description: "Spectrum-cache `w` weight (frequency cutoff). Higher = retains more spectrum, less speedup. Recommended: 0.5 default. See sd.cpp PR #1322 for tuning. Leave empty for the architecture default.", type: "number" },
+        spectrumWarmupSteps: { title: "Spectrum Cache: warmup steps", description: "Steps at the start of sampling before spectrum cache becomes active. Recommended: 2. Leave empty for the architecture default.", type: "number" },
+        spectrumWindowSize: { title: "Spectrum Cache: window size", description: "Spectrum-cache analysis window size in steps. Recommended: 3. Leave empty for the architecture default.", type: "number" },
+        steps: { title: "Sampling Steps", description: "Number of denoising steps the sampler runs. More steps = closer to the model's converged output, with diminishing returns. Distilled models (Flux Schnell, SDXL Turbo, Z-Image Turbo) need only 4–8. Recommended: 20–30 for SD1.5/SDXL, 20 for Flux Dev, 4–8 for *-Turbo or Schnell variants. Leave empty for the architecture default.", type: "number" },
+        strength: { title: "Denoising Strength", description: "How much of the init image's noise to keep (0.0 = identical to init, 1.0 = ignore init entirely). Controls how aggressively img2img diverges from the input. Recommended: 0.5–0.75 for natural-looking edits. 0.9+ for radical reinterpretation. Leave empty for the architecture default.", type: "number" },
+        upscale: { title: "Upscale After Generate", description: "Run the loaded ESRGAN upscaler on the output image after generation. Requires an upscaler to be loaded via POST /upscaler/load. Recommended: On for one-shot 'generate then upscale' workflows. Leave empty for the architecture default.", type: "boolean" },
+        upscaleAutoUnload: { title: "Auto-unload Upscaler", description: "Free upscaler VRAM right after the upscale step. Useful when you generated with `upscale: true` and want VRAM back for other work. Recommended: On. Leave empty for the architecture default.", type: "boolean" },
+        upscaleRepeats: { title: "Upscale Repeats", description: "Number of post-generation auto-upscale passes (each with the upscaler's native factor — 4× ESRGAN × 2 passes = 16×). DISTINCT from the `/upscale` endpoint's own `repeats` field, which controls passes inside a single upscale job. `upscale_repeats` only chains additional /upscale calls after txt2img / img2img finish. Recommended: 1. Two passes amplify artifacts, but produces large outputs from small inputs. Leave empty for the architecture default.", type: "number" },
+        vaeTileOverlap: { title: "VAE Tile Overlap", description: "Overlap fraction between VAE tiles for seam blending. Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        vaeTileSizeX: { title: "VAE Tile Width", description: "Width of VAE tiles when tiling is on. 0 = use load-time default. Recommended: 0. Leave empty for the architecture default.", type: "number" },
+        vaeTileSizeY: { title: "VAE Tile Height", description: "Height of VAE tiles. 0 = use load-time default. Recommended: 0. Leave empty for the architecture default.", type: "number" },
+        vaeTiling: { title: "VAE Tiling", description: "Per-generation override of the model-load `vae_tiling`. Process VAE encode/decode in tiles to reduce peak VRAM. Recommended: Enable for ≥2048 px outputs. Otherwise leave to the load-time default. Leave empty for the architecture default.", type: "boolean" },
+        width: { title: "Width (px)", description: "Output image width in pixels. Must be divisible by the model's patch size (typically 8 or 16). Architectures have native resolutions they were trained at — going far off them can degrade quality. Recommended: Match the architecture's training resolution: SD1.5=512, SDXL=1024, Flux/SD3/Z-Image=1024, Wan video=832. Leave empty for the architecture default.", type: "number" },
+        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. Everything the server documents already has a setting above, so this is only needed for a field a newer server has gained" },
+        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']
 };
@@ -196,11 +302,55 @@ function putIfSet(target, key, value) {
     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.
+// Every generation field the server documents, and the setting it comes
+// from. Generated alongside the schema above so the two cannot drift.
+const GENERATION_OPTIONS = [
+    { setting: "batchCount", server: "batch_count" },
+    { setting: "cacheMode", server: "cache_mode" },
+    { setting: "cfgScale", server: "cfg_scale" },
+    { setting: "clipSkip", server: "clip_skip" },
+    { setting: "controlImageBase64", server: "control_image_base64" },
+    { setting: "controlStrength", server: "control_strength" },
+    { setting: "customSigmas", server: "custom_sigmas" },
+    { setting: "distilledGuidance", server: "distilled_guidance" },
+    { setting: "easycacheEnd", server: "easycache_end" },
+    { setting: "easycacheStart", server: "easycache_start" },
+    { setting: "easycacheThreshold", server: "easycache_threshold" },
+    { setting: "eta", server: "eta" },
+    { setting: "expandPrompt", server: "expand_prompt" },
+    { setting: "flowShift", server: "flow_shift" },
+    { setting: "height", server: "height" },
+    { setting: "imgCfgScale", server: "img_cfg_scale" },
+    { setting: "initImageBase64", server: "init_image_base64" },
+    { setting: "maskImageBase64", server: "mask_image_base64" },
+    { setting: "negativePrompt", server: "negative_prompt" },
+    { setting: "prompt", server: "prompt" },
+    { setting: "sampler", server: "sampler" },
+    { setting: "scheduler", server: "scheduler" },
+    { setting: "seed", server: "seed" },
+    { setting: "shiftedTimestep", server: "shifted_timestep" },
+    { setting: "skipLayers", server: "skip_layers" },
+    { setting: "slgEnd", server: "slg_end" },
+    { setting: "slgScale", server: "slg_scale" },
+    { setting: "slgStart", server: "slg_start" },
+    { setting: "spectrumFlexWindow", server: "spectrum_flex_window" },
+    { setting: "spectrumLam", server: "spectrum_lam" },
+    { setting: "spectrumM", server: "spectrum_m" },
+    { setting: "spectrumStopPercent", server: "spectrum_stop_percent" },
+    { setting: "spectrumW", server: "spectrum_w" },
+    { setting: "spectrumWarmupSteps", server: "spectrum_warmup_steps" },
+    { setting: "spectrumWindowSize", server: "spectrum_window_size" },
+    { setting: "steps", server: "steps" },
+    { setting: "strength", server: "strength" },
+    { setting: "upscale", server: "upscale" },
+    { setting: "upscaleAutoUnload", server: "upscale_auto_unload" },
+    { setting: "upscaleRepeats", server: "upscale_repeats" },
+    { setting: "vaeTileOverlap", server: "vae_tile_overlap" },
+    { setting: "vaeTileSizeX", server: "vae_tile_size_x" },
+    { setting: "vaeTileSizeY", server: "vae_tile_size_y" },
+    { setting: "vaeTiling", server: "vae_tiling" },
+    { setting: "width", server: "width" }
+];
 
 async function execute(config, input, context) {
     const server = normalizeServer(config.serverUrl);
@@ -210,27 +360,32 @@ async function execute(config, input, context) {
     const token = login(server, credential, timeout);
 
     const body = {};
-    putIfSet(body, 'prompt', config.prompt);
+
+    // Built from the table above rather than field by field, so a setting the
+    // server documents cannot be quietly missing from the request. A setting
+    // left empty is left out of the body entirely: the server fills an absent
+    // field from the loaded model's architecture preset, and sending an empty
+    // box as 0 would override that preset with nonsense. The test is emptiness,
+    // never truthiness - seed 0 and clip_skip 0 are legitimate values.
+    for (let i = 0; i < GENERATION_OPTIONS.length; i++) {
+        const option = GENERATION_OPTIONS[i];
+        const value = config[option.setting];
+        if (Array.isArray(value)) {
+            if (value.length > 0) {
+                body[option.server] = value;
+            }
+        } else {
+            putIfSet(body, option.server, value);
+        }
+    }
+    putIfSet(body, 'title', config.title);
+
     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.

+ 235 - 57
nodes/sdcpp/sdcpp-txt2img.js

@@ -25,6 +25,122 @@ const credentialTypes = [
 
 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: [
+            {
+                    "title": "Server",
+                    "fields": [
+                            "serverUrl",
+                            "credentialId"
+                    ]
+            },
+            {
+                    "title": "Core",
+                    "fields": [
+                            "prompt",
+                            "negativePrompt",
+                            "width",
+                            "height",
+                            "steps",
+                            "cfgScale",
+                            "seed",
+                            "sampler",
+                            "scheduler",
+                            "batchCount"
+                    ]
+            },
+            {
+                    "title": "Cache Acceleration",
+                    "fields": [
+                            "cacheMode",
+                            "easycacheThreshold",
+                            "easycacheStart",
+                            "easycacheEnd",
+                            "spectrumW",
+                            "spectrumM",
+                            "spectrumLam",
+                            "spectrumWindowSize",
+                            "spectrumFlexWindow",
+                            "spectrumWarmupSteps",
+                            "spectrumStopPercent"
+                    ]
+            },
+            {
+                    "title": "ControlNet",
+                    "fields": [
+                            "controlImageBase64",
+                            "controlStrength"
+                    ]
+            },
+            {
+                    "title": "Prompt Expansion",
+                    "fields": [
+                            "expandPrompt"
+                    ]
+            },
+            {
+                    "title": "Guidance",
+                    "fields": [
+                            "distilledGuidance",
+                            "eta",
+                            "shiftedTimestep",
+                            "flowShift",
+                            "clipSkip"
+                    ]
+            },
+            {
+                    "title": "Image Input (img2img / image-edit)",
+                    "fields": [
+                            "refImages",
+                            "refImageArgs"
+                    ]
+            },
+            {
+                    "title": "PhotoMaker",
+                    "fields": [
+                            "pmIdImages",
+                            "pmIdEmbedPath",
+                            "pmStyleStrength"
+                    ]
+            },
+            {
+                    "title": "Skip Layer Guidance (SLG)",
+                    "fields": [
+                            "slgScale",
+                            "skipLayers",
+                            "slgStart",
+                            "slgEnd",
+                            "customSigmas"
+                    ]
+            },
+            {
+                    "title": "Upscale After Generation",
+                    "fields": [
+                            "upscale",
+                            "upscaleRepeats",
+                            "upscaleAutoUnload"
+                    ]
+            },
+            {
+                    "title": "VAE Tiling (per-generation)",
+                    "fields": [
+                            "vaeTiling",
+                            "vaeTileSizeX",
+                            "vaeTileSizeY",
+                            "vaeTileOverlap"
+                    ]
+            },
+            {
+                    "title": "Job",
+                    "fields": [
+                            "title",
+                            "extraOptions",
+                            "timeout"
+                    ]
+            }
+    ],
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',
@@ -36,46 +152,55 @@ const configSchema = {
             description: 'A basic credential holding the sdcpp-restapi username and password',
             dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', '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
-        }
+        batchCount: { title: "Batch Count", description: "Number of independent images to produce in this single job. Each image gets a different seed (`seed`, `seed+1`, …). Total VRAM doesn't grow with batch_count — sd.cpp serializes. Recommended: 1 for interactive, higher when you want a grid of variations from one prompt. Leave empty for the architecture default.", type: "number" },
+        cacheMode: { title: "Cache Mode", description: "DiT-model intermediate caching strategy. Skips redundant computation across consecutive sampler steps when the model's intermediate state hasn't changed enough to matter. `easycache` is the simplest; `spectrum` is the newest, generally best for Flux/SD3/Z-Image. Recommended: spectrum for Flux/SD3/Z-Image when generation is too slow. Off for SD1.5/SDXL (UNet is too small for caching to win). Leave empty for the architecture default.", type: "string", enum: ["", "easycache", "spectrum"], enumLabels: ["(architecture default)", "EasyCache — single threshold, simple", "Spectrum — frequency-domain analysis (best quality/speed tradeoff)"], default: "" },
+        cfgScale: { title: "CFG Scale", description: "Classifier-Free Guidance scale. Strength of pushing the cond toward the prompt vs the uncond. Higher = follows prompt more aggressively but can over-saturate. Set to 1.0 to disable CFG (skips the uncond pass — twice as fast). Recommended: SD1.5: 7. SDXL: 4–8. Flux Dev: 1 (CFG bypassed in flow models). Z-Image: 1. Schnell/Turbo: 1. Leave empty for the architecture default.", type: "number" },
+        clipSkip: { title: "CLIP Skip", description: "Skip the last N layers of CLIP when encoding the prompt. -1 = use the model's recommended default. 2 is the classic anime-model setting. Recommended: -1 (auto). Set to 2 for anime/cartoon SD1.5 fine-tunes. Leave empty for the architecture default.", type: "number" },
+        controlImageBase64: { title: "ControlNet Image (base64)", description: "Base64-encoded pre-processed control image. Must match the ControlNet model loaded — Canny edges for canny model, depth map for depth model, etc. sd.cpp does not pre-process; you do that client-side. Recommended: Required when using ControlNet. Leave empty for the architecture default.", type: "string" },
+        controlStrength: { title: "ControlNet Strength", description: "How much the ControlNet influences the diffusion process (0.0–1.0). Lower = looser following, higher = tighter following at cost of overall coherence. Recommended: 0.6–0.9 for most uses. Lower for creative prompts where control should be a hint, not a constraint. Leave empty for the architecture default.", type: "number" },
+        customSigmas: { title: "Custom Sigma Schedule", description: "Replace the scheduler's noise sigma sequence with a hand-tuned one. Bypasses `scheduler`. Empty array = use the chosen scheduler. Recommended: Empty unless you're hand-tuning per-step noise levels — niche. Leave empty for the architecture default.", type: "array", items: {"type": "number"} },
+        distilledGuidance: { title: "Distilled Guidance", description: "Distilled-CFG scale for models that bake the CFG behavior into the diffusion model itself (Flux). Replaces the runtime CFG split. Recommended: Flux Dev: 3.5 (sd.cpp default). Other architectures: ignored. Leave empty for the architecture default.", type: "number" },
+        easycacheEnd: { title: "EasyCache End (% of steps)", description: "Fraction of steps after which EasyCache turns off (last few steps fully recomputed for sharpness). Recommended: 0.95. Leave empty for the architecture default.", type: "number" },
+        easycacheStart: { title: "EasyCache Start (% of steps)", description: "Fraction of steps before EasyCache becomes active. Small to skip the early high-noise steps where caching hurts. Recommended: 0.15. Leave empty for the architecture default.", type: "number" },
+        easycacheThreshold: { title: "EasyCache Threshold", description: "Reuse threshold for EasyCache. Higher = more aggressive reuse (faster but more quality loss). Range typically 0.05–0.4. Recommended: 0.2 starting point. Tune up for more speed, down for more quality. Leave empty for the architecture default.", type: "number" },
+        eta: { title: "Eta", description: "Stochasticity parameter for DDIM-family samplers. 0 = deterministic, 1 = max stochasticity (matches DDPM noise schedule). Most other samplers ignore this. Recommended: 0 (deterministic, reproducible). Raise only with DDIM-trailing for some variation. Leave empty for the architecture default.", type: "number" },
+        expandPrompt: { title: "Expand Prompt Template", description: "If true, parse `prompt` for a1111-style dynamic-prompts syntax (`{a|b|c}`, `{N$$a|b|c}`) and create one queue item per variation. The response then carries `group_id` + `variation_count` + `job_ids[]` instead of a single `job_id`. Hard cap: 200 variations per request. Recommended: Enable when your prompt actually contains `{…}` syntax. Leave empty for the architecture default.", type: "boolean" },
+        flowShift: { title: "Flow Shift", description: "Per-generation flow-matching shift parameter for Flux / SD3 / Z-Image models. Higher values shift sampling toward larger noise levels longer. Per-call override; the load-time `flow_shift` is the default if this isn't set. Recommended: Flux Dev: 1.0. Z-Image: 3.0. SD3 Medium: 3.0. Leave empty for the architecture default.", type: "number" },
+        height: { title: "Height (px)", description: "Output image height in pixels. Same divisibility constraint as `width`. Recommended: Match training resolution. For non-square: total pixel count close to native is more important than aspect ratio. Leave empty for the architecture default.", type: "number" },
+        negativePrompt: { title: "Negative Prompt", description: "What to push the model away from. Has effect only when `cfg_scale > 1` (CFG is what compares cond vs uncond). Ignored on architectures that don't use CFG (most flow-matching models default to `cfg_scale = 1`). Recommended: Optional. Empty is a fine default for Flux / SD3 / Z-Image. Leave empty for the architecture default.", type: "string", format: "textarea" },
+        pmIdEmbedPath: { title: "PhotoMaker ID Embed Path", description: "Server-side path to a pre-computed PhotoMaker ID embedding. Skips per-generation ID encoding. Recommended: Empty unless you've pre-computed an embedding for repeated use. Leave empty for the architecture default.", type: "string" },
+        pmIdImages: { title: "PhotoMaker ID Images (base64)", description: "Reference images for PhotoMaker identity preservation. Multiple faces of the same person. Recommended: 3–5 images of the same person from different angles when using PhotoMaker. Leave empty for the architecture default.", type: "array", items: {"type": "string"} },
+        pmStyleStrength: { title: "PhotoMaker Style Strength", description: "PhotoMaker style-vs-identity tradeoff. Higher = more style influence, less identity preservation. Recommended: 20 (sd.cpp default). Drop to 10 for stronger identity preservation. Leave empty for the architecture default.", type: "number" },
+        prompt: { title: "Prompt", description: "Text prompt fed to the model's text encoder (CLIP / T5 / LLM). Supports inline `<lora:name:weight>` tags — these are auto-extracted into the LoRA list before being sent to the encoder. Supports a1111-style dynamic-prompts (`{a|b|c}`) when `expand_prompt: true` is set. Recommended: Required. Leave empty for the architecture default.", type: "string", format: "textarea" },
+        refImageArgs: { title: "Reference Image Args", description: "Comma-separated k=v flags controlling reference-image preprocessing (e.g. `resize_before_vae=0,ref_index_mode=increase`). Replaces the previous auto_resize_ref_image / increase_ref_index bools. See sd.cpp docs. Recommended: Leave empty unless you need to override defaults. Leave empty for the architecture default.", type: "string" },
+        refImages: { title: "Reference Images (base64)", description: "Base64-encoded reference images for Flux Kontext / image-edit. Each image gets encoded into the conditioner alongside the text prompt. Recommended: Use for Flux Kontext or models that accept reference images. Leave empty for the architecture default.", type: "array", items: {"type": "string"} },
+        sampler: { title: "Sampler", description: "Sampling algorithm. Different samplers can produce different images at the same seed; quality and speed differ too. Recommended: euler_a (general), dpmpp2m (SD1.5/SDXL), euler (Flux/SD3/Z-Image), lcm (LCM models). Leave empty for the architecture default.", type: "string", enum: ["", "ddim_trailing", "dpm2", "dpmpp2m", "dpmpp2mv2", "dpmpp2s_a", "er_sde", "euler", "euler_a", "heun", "ipndm", "ipndm_v", "lcm", "res_2s", "res_multistep", "tcd"], enumLabels: ["(architecture default)", "DDIM trailing — required for some fine-tunes", "DPM2 — 2nd-order, balanced", "DPM++ 2M — fast, good quality", "DPM++ 2M v2 — improved schedule", "DPM++ 2S ancestral — popular for SDXL", "ER SDE — SDE sampler (added recently)", "Euler — simple, fast, deterministic", "Euler ancestral — adds noise each step (less reproducible, more variet", "Heun — 2nd-order, slower, sometimes higher quality", "IPNDM", "IPNDM-V", "LCM — for LCM-finetuned models (4–8 step generation)", "RES 2S", "RES multistep — flow-model variant", "TCD — for TCD-finetuned models"], default: "" },
+        scheduler: { title: "Scheduler", description: "Determines the noise schedule (the timesteps the sampler walks through). Pairs with the sampler — some combinations (e.g. karras+dpmpp2m) are well-tested, others may be off. Recommended: discrete or karras for SD1.5/SDXL; simple for Flux/SD3; smoothstep for Z-Image. Leave empty for the architecture default.", type: "string", enum: ["", "ays", "bong_tangent", "discrete", "exponential", "gits", "karras", "kl_optimal", "lcm", "sgm_uniform", "simple", "smoothstep"], enumLabels: ["(architecture default)", "Align Your Steps (AYS) — auto-tuned", "Bong Tangent", "Discrete — uniform across model timesteps (default)", "Exponential", "GITS", "Karras — concentrates more steps near the end, common for SDXL", "KL optimal", "LCM — for LCM samplers", "SGM uniform — for SGM-trained models", "Simple — used by Flux / SD3 flow models", "Smoothstep — Z-Image's recommended scheduler"], default: "" },
+        seed: { title: "Seed", description: "RNG seed for the initial noise tensor (and stochastic samplers). -1 = pick a random one each generation. Same seed + same prompt + same model = same image. Recommended: -1 for variety. Pin a specific number for A/B comparing prompt or sampler changes. Leave empty for the architecture default.", type: "number" },
+        shiftedTimestep: { title: "Shifted Timestep", description: "Start the sampling schedule from a non-final timestep (used by NitroFusion and similar fast-sampling fine-tunes). 0 = standard schedule. 250–500 = NitroFusion's range. Recommended: 0 unless you're explicitly running a NitroFusion-style fine-tune. Leave empty for the architecture default.", type: "number" },
+        skipLayers: { title: "SLG Skip Layers", description: "Which transformer layers to skip during the SLG unconditional pass. SD3.5 Medium uses [7, 8, 9]. Recommended: [7,8,9] for SD3.5 Medium. Other models: leave default, ignored when slg_scale=0. Leave empty for the architecture default.", type: "array", items: {"type": "number"} },
+        slgEnd: { title: "SLG End (% of steps)", description: "Fraction of the sampler schedule at which SLG turns off. Recommended: 0.2 (early-cycle only — late-cycle SLG hurts quality). Leave empty for the architecture default.", type: "number" },
+        slgScale: { title: "SLG Scale", description: "Skip Layer Guidance scale. Selectively zeros out a few diffusion layers when computing the unconditional pass — improves anatomy / coherence on SD3.5-medium and similar. 0 = disabled. Recommended: 0 for most models. 2.5 for SD3.5 Medium with the recommended skip layers. Leave empty for the architecture default.", type: "number" },
+        slgStart: { title: "SLG Start (% of steps)", description: "Fraction of the sampler schedule (0.0–1.0) at which SLG kicks in. Recommended: 0.01 (almost from the start). Leave empty for the architecture default.", type: "number" },
+        spectrumFlexWindow: { title: "Spectrum Cache: flex window", description: "Spectrum-cache flexibility window (0.0–1.0). Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        spectrumLam: { title: "Spectrum Cache: λ", description: "Spectrum-cache regularization λ. Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        spectrumM: { title: "Spectrum Cache: m", description: "Spectrum-cache moving-average length. Recommended: 5. Leave empty for the architecture default.", type: "number" },
+        spectrumStopPercent: { title: "Spectrum Cache: stop percent", description: "Fraction of steps after which spectrum cache disengages (last steps recomputed). Recommended: 0.8. Leave empty for the architecture default.", type: "number" },
+        spectrumW: { title: "Spectrum Cache: w", description: "Spectrum-cache `w` weight (frequency cutoff). Higher = retains more spectrum, less speedup. Recommended: 0.5 default. See sd.cpp PR #1322 for tuning. Leave empty for the architecture default.", type: "number" },
+        spectrumWarmupSteps: { title: "Spectrum Cache: warmup steps", description: "Steps at the start of sampling before spectrum cache becomes active. Recommended: 2. Leave empty for the architecture default.", type: "number" },
+        spectrumWindowSize: { title: "Spectrum Cache: window size", description: "Spectrum-cache analysis window size in steps. Recommended: 3. Leave empty for the architecture default.", type: "number" },
+        steps: { title: "Sampling Steps", description: "Number of denoising steps the sampler runs. More steps = closer to the model's converged output, with diminishing returns. Distilled models (Flux Schnell, SDXL Turbo, Z-Image Turbo) need only 4–8. Recommended: 20–30 for SD1.5/SDXL, 20 for Flux Dev, 4–8 for *-Turbo or Schnell variants. Leave empty for the architecture default.", type: "number" },
+        upscale: { title: "Upscale After Generate", description: "Run the loaded ESRGAN upscaler on the output image after generation. Requires an upscaler to be loaded via POST /upscaler/load. Recommended: On for one-shot 'generate then upscale' workflows. Leave empty for the architecture default.", type: "boolean" },
+        upscaleAutoUnload: { title: "Auto-unload Upscaler", description: "Free upscaler VRAM right after the upscale step. Useful when you generated with `upscale: true` and want VRAM back for other work. Recommended: On. Leave empty for the architecture default.", type: "boolean" },
+        upscaleRepeats: { title: "Upscale Repeats", description: "Number of post-generation auto-upscale passes (each with the upscaler's native factor — 4× ESRGAN × 2 passes = 16×). DISTINCT from the `/upscale` endpoint's own `repeats` field, which controls passes inside a single upscale job. `upscale_repeats` only chains additional /upscale calls after txt2img / img2img finish. Recommended: 1. Two passes amplify artifacts, but produces large outputs from small inputs. Leave empty for the architecture default.", type: "number" },
+        vaeTileOverlap: { title: "VAE Tile Overlap", description: "Overlap fraction between VAE tiles for seam blending. Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        vaeTileSizeX: { title: "VAE Tile Width", description: "Width of VAE tiles when tiling is on. 0 = use load-time default. Recommended: 0. Leave empty for the architecture default.", type: "number" },
+        vaeTileSizeY: { title: "VAE Tile Height", description: "Height of VAE tiles. 0 = use load-time default. Recommended: 0. Leave empty for the architecture default.", type: "number" },
+        vaeTiling: { title: "VAE Tiling", description: "Per-generation override of the model-load `vae_tiling`. Process VAE encode/decode in tiles to reduce peak VRAM. Recommended: Enable for ≥2048 px outputs. Otherwise leave to the load-time default. Leave empty for the architecture default.", type: "boolean" },
+        width: { title: "Width (px)", description: "Output image width in pixels. Must be divisible by the model's patch size (typically 8 or 16). Architectures have native resolutions they were trained at — going far off them can degrade quality. Recommended: Match the architecture's training resolution: SD1.5=512, SDXL=1024, Flux/SD3/Z-Image=1024, Wan video=832. Leave empty for the architecture default.", type: "number" },
+        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. Everything the server documents already has a setting above, so this is only needed for a field a newer server has gained" },
+        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']
 };
@@ -184,11 +309,56 @@ function putIfSet(target, key, value) {
     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.
+// Every generation field the server documents, and the setting it comes
+// from. Generated alongside the schema above so the two cannot drift.
+const GENERATION_OPTIONS = [
+    { setting: "batchCount", server: "batch_count" },
+    { setting: "cacheMode", server: "cache_mode" },
+    { setting: "cfgScale", server: "cfg_scale" },
+    { setting: "clipSkip", server: "clip_skip" },
+    { setting: "controlImageBase64", server: "control_image_base64" },
+    { setting: "controlStrength", server: "control_strength" },
+    { setting: "customSigmas", server: "custom_sigmas" },
+    { setting: "distilledGuidance", server: "distilled_guidance" },
+    { setting: "easycacheEnd", server: "easycache_end" },
+    { setting: "easycacheStart", server: "easycache_start" },
+    { setting: "easycacheThreshold", server: "easycache_threshold" },
+    { setting: "eta", server: "eta" },
+    { setting: "expandPrompt", server: "expand_prompt" },
+    { setting: "flowShift", server: "flow_shift" },
+    { setting: "height", server: "height" },
+    { setting: "negativePrompt", server: "negative_prompt" },
+    { setting: "pmIdEmbedPath", server: "pm_id_embed_path" },
+    { setting: "pmIdImages", server: "pm_id_images" },
+    { setting: "pmStyleStrength", server: "pm_style_strength" },
+    { setting: "prompt", server: "prompt" },
+    { setting: "refImageArgs", server: "ref_image_args" },
+    { setting: "refImages", server: "ref_images" },
+    { setting: "sampler", server: "sampler" },
+    { setting: "scheduler", server: "scheduler" },
+    { setting: "seed", server: "seed" },
+    { setting: "shiftedTimestep", server: "shifted_timestep" },
+    { setting: "skipLayers", server: "skip_layers" },
+    { setting: "slgEnd", server: "slg_end" },
+    { setting: "slgScale", server: "slg_scale" },
+    { setting: "slgStart", server: "slg_start" },
+    { setting: "spectrumFlexWindow", server: "spectrum_flex_window" },
+    { setting: "spectrumLam", server: "spectrum_lam" },
+    { setting: "spectrumM", server: "spectrum_m" },
+    { setting: "spectrumStopPercent", server: "spectrum_stop_percent" },
+    { setting: "spectrumW", server: "spectrum_w" },
+    { setting: "spectrumWarmupSteps", server: "spectrum_warmup_steps" },
+    { setting: "spectrumWindowSize", server: "spectrum_window_size" },
+    { setting: "steps", server: "steps" },
+    { setting: "upscale", server: "upscale" },
+    { setting: "upscaleAutoUnload", server: "upscale_auto_unload" },
+    { setting: "upscaleRepeats", server: "upscale_repeats" },
+    { setting: "vaeTileOverlap", server: "vae_tile_overlap" },
+    { setting: "vaeTileSizeX", server: "vae_tile_size_x" },
+    { setting: "vaeTileSizeY", server: "vae_tile_size_y" },
+    { setting: "vaeTiling", server: "vae_tiling" },
+    { setting: "width", server: "width" }
+];
 
 async function execute(config, input, context) {
     const server = normalizeServer(config.serverUrl);
@@ -198,21 +368,29 @@ async function execute(config, input, context) {
     const token = login(server, credential, timeout);
 
     const body = {};
-    putIfSet(body, 'prompt', config.prompt);
+
+    // Built from the table above rather than field by field, so a setting the
+    // server documents cannot be quietly missing from the request. A setting
+    // left empty is left out of the body entirely: the server fills an absent
+    // field from the loaded model's architecture preset, and sending an empty
+    // box as 0 would override that preset with nonsense. The test is emptiness,
+    // never truthiness - seed 0 and clip_skip 0 are legitimate values.
+    for (let i = 0; i < GENERATION_OPTIONS.length; i++) {
+        const option = GENERATION_OPTIONS[i];
+        const value = config[option.setting];
+        if (Array.isArray(value)) {
+            if (value.length > 0) {
+                body[option.server] = value;
+            }
+        } else {
+            putIfSet(body, option.server, value);
+        }
+    }
+    putIfSet(body, 'title', config.title);
+
     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.

+ 231 - 68
nodes/sdcpp/sdcpp-txt2vid.js

@@ -25,6 +25,114 @@ const credentialTypes = [
 
 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: [
+            {
+                    "title": "Server",
+                    "fields": [
+                            "serverUrl",
+                            "credentialId"
+                    ]
+            },
+            {
+                    "title": "Core",
+                    "fields": [
+                            "prompt",
+                            "negativePrompt",
+                            "width",
+                            "height",
+                            "steps",
+                            "cfgScale",
+                            "seed",
+                            "sampler",
+                            "scheduler"
+                    ]
+            },
+            {
+                    "title": "Cache Acceleration",
+                    "fields": [
+                            "cacheMode",
+                            "spectrumW",
+                            "spectrumM",
+                            "spectrumLam",
+                            "spectrumWindowSize",
+                            "spectrumFlexWindow",
+                            "spectrumWarmupSteps",
+                            "spectrumStopPercent"
+                    ]
+            },
+            {
+                    "title": "Prompt Expansion",
+                    "fields": [
+                            "expandPrompt"
+                    ]
+            },
+            {
+                    "title": "Guidance",
+                    "fields": [
+                            "distilledGuidance",
+                            "eta",
+                            "shiftedTimestep",
+                            "flowShift",
+                            "clipSkip"
+                    ]
+            },
+            {
+                    "title": "Image Input (img2img / image-edit)",
+                    "fields": [
+                            "initImageBase64",
+                            "strength"
+                    ]
+            },
+            {
+                    "title": "Skip Layer Guidance (SLG)",
+                    "fields": [
+                            "slgScale",
+                            "skipLayers",
+                            "slgStart",
+                            "slgEnd",
+                            "customSigmas"
+                    ]
+            },
+            {
+                    "title": "VAE Tiling (per-generation)",
+                    "fields": [
+                            "vaeTiling",
+                            "vaeTileSizeX",
+                            "vaeTileSizeY",
+                            "vaeTileOverlap"
+                    ]
+            },
+            {
+                    "title": "Video (Wan)",
+                    "fields": [
+                            "videoFrames",
+                            "fps",
+                            "highNoiseSteps",
+                            "highNoiseCfgScale",
+                            "highNoiseDistilledGuidance",
+                            "highNoiseSampler",
+                            "highNoiseSlgScale",
+                            "highNoiseSkipLayers",
+                            "highNoiseSlgStart",
+                            "highNoiseSlgEnd",
+                            "moeBoundary",
+                            "endImageBase64",
+                            "controlFrames",
+                            "vaceStrength"
+                    ]
+            },
+            {
+                    "title": "Job",
+                    "fields": [
+                            "title",
+                            "extraOptions",
+                            "timeout"
+                    ]
+            }
+    ],
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',
@@ -36,53 +144,57 @@ const configSchema = {
             description: 'A basic credential holding the sdcpp-restapi username and password',
             dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', '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
-        }
+        cacheMode: { title: "Cache Mode", description: "DiT-model intermediate caching strategy. Skips redundant computation across consecutive sampler steps when the model's intermediate state hasn't changed enough to matter. `easycache` is the simplest; `spectrum` is the newest, generally best for Flux/SD3/Z-Image. Recommended: spectrum for Flux/SD3/Z-Image when generation is too slow. Off for SD1.5/SDXL (UNet is too small for caching to win). Leave empty for the architecture default.", type: "string", enum: ["", "easycache", "spectrum"], enumLabels: ["(architecture default)", "EasyCache — single threshold, simple", "Spectrum — frequency-domain analysis (best quality/speed tradeoff)"], default: "" },
+        cfgScale: { title: "CFG Scale", description: "Classifier-Free Guidance scale. Strength of pushing the cond toward the prompt vs the uncond. Higher = follows prompt more aggressively but can over-saturate. Set to 1.0 to disable CFG (skips the uncond pass — twice as fast). Recommended: SD1.5: 7. SDXL: 4–8. Flux Dev: 1 (CFG bypassed in flow models). Z-Image: 1. Schnell/Turbo: 1. Leave empty for the architecture default.", type: "number" },
+        clipSkip: { title: "CLIP Skip", description: "Skip the last N layers of CLIP when encoding the prompt. -1 = use the model's recommended default. 2 is the classic anime-model setting. Recommended: -1 (auto). Set to 2 for anime/cartoon SD1.5 fine-tunes. Leave empty for the architecture default.", type: "number" },
+        controlFrames: { title: "ControlNet Frames", description: "Per-frame control images for video generation. Length should match `video_frames` for full coverage. Recommended: Empty unless using video-ControlNet. Leave empty for the architecture default.", type: "array", items: {"type": "string"} },
+        customSigmas: { title: "Custom Sigma Schedule", description: "Replace the scheduler's noise sigma sequence with a hand-tuned one. Bypasses `scheduler`. Empty array = use the chosen scheduler. Recommended: Empty unless you're hand-tuning per-step noise levels — niche. Leave empty for the architecture default.", type: "array", items: {"type": "number"} },
+        distilledGuidance: { title: "Distilled Guidance", description: "Distilled-CFG scale for models that bake the CFG behavior into the diffusion model itself (Flux). Replaces the runtime CFG split. Recommended: Flux Dev: 3.5 (sd.cpp default). Other architectures: ignored. Leave empty for the architecture default.", type: "number" },
+        endImageBase64: { title: "End Image (base64)", description: "Optional final-frame target for Wan 2.2 — forces the video to end on this image. Lets you bridge two stills with generated motion. Recommended: Empty unless doing image-to-image-bridged video. Leave empty for the architecture default.", type: "string" },
+        eta: { title: "Eta", description: "Stochasticity parameter for DDIM-family samplers. 0 = deterministic, 1 = max stochasticity (matches DDPM noise schedule). Most other samplers ignore this. Recommended: 0 (deterministic, reproducible). Raise only with DDIM-trailing for some variation. Leave empty for the architecture default.", type: "number" },
+        expandPrompt: { title: "Expand Prompt Template", description: "If true, parse `prompt` for a1111-style dynamic-prompts syntax (`{a|b|c}`, `{N$$a|b|c}`) and create one queue item per variation. The response then carries `group_id` + `variation_count` + `job_ids[]` instead of a single `job_id`. Hard cap: 200 variations per request. Recommended: Enable when your prompt actually contains `{…}` syntax. Leave empty for the architecture default.", type: "boolean" },
+        flowShift: { title: "Flow Shift", description: "Per-generation flow-matching shift parameter for Flux / SD3 / Z-Image models. Higher values shift sampling toward larger noise levels longer. Per-call override; the load-time `flow_shift` is the default if this isn't set. Recommended: Flux Dev: 1.0. Z-Image: 3.0. SD3 Medium: 3.0. Leave empty for the architecture default.", type: "number" },
+        fps: { title: "Output FPS", description: "Frames per second baked into the resulting MP4. Doesn't affect generation, just the encoded video's playback rate. Recommended: 16 for Wan 2.x. Leave empty for the architecture default.", type: "number" },
+        height: { title: "Height (px)", description: "Output image height in pixels. Same divisibility constraint as `width`. Recommended: Match training resolution. For non-square: total pixel count close to native is more important than aspect ratio. Leave empty for the architecture default.", type: "number" },
+        highNoiseCfgScale: { title: "High-noise CFG (MoE)", description: "CFG scale used during the high-noise expert phase of Wan 2.2 MoE. Recommended: Match `cfg_scale` unless tuning. Leave empty for the architecture default.", type: "number" },
+        highNoiseDistilledGuidance: { title: "High-noise Distilled Guidance", description: "Distilled-guidance during the high-noise expert phase (Wan 2.2 MoE). Recommended: Match `distilled_guidance`. Leave empty for the architecture default.", type: "number" },
+        highNoiseSampler: { title: "High-noise Sampler", description: "Sampler to use during the high-noise expert phase. Recommended: Empty (= same as `sampler`). Leave empty for the architecture default.", type: "string" },
+        highNoiseSkipLayers: { title: "High-noise SLG Skip Layers", description: "SLG skip layers for the high-noise expert phase. Recommended: Empty. Leave empty for the architecture default.", type: "array", items: {"type": "number"} },
+        highNoiseSlgEnd: { title: "High-noise SLG End", description: "SLG end fraction during the high-noise phase. Recommended: 0.2. Leave empty for the architecture default.", type: "number" },
+        highNoiseSlgScale: { title: "High-noise SLG Scale", description: "SLG scale for the high-noise expert phase. Recommended: 0. Leave empty for the architecture default.", type: "number" },
+        highNoiseSlgStart: { title: "High-noise SLG Start", description: "SLG start fraction during the high-noise phase. Recommended: 0.01. Leave empty for the architecture default.", type: "number" },
+        highNoiseSteps: { title: "High-noise Steps (MoE)", description: "For Wan 2.2 MoE: how many steps the high-noise expert runs before handing off to the low-noise expert. 0 = let the model decide via `moe_boundary`. Recommended: 0 (auto). Leave empty for the architecture default.", type: "number" },
+        initImageBase64: { title: "Init Image (base64)", description: "Base64-encoded source image for img2img / image-edit. Required for /img2img. Recommended: Required for img2img. Leave empty for the architecture default.", type: "string" },
+        moeBoundary: { title: "MoE Expert Boundary", description: "For Wan 2.2 MoE: fraction of total steps at which to switch from high-noise to low-noise expert. 0 = use `high_noise_steps` directly. Recommended: 0.875 (Wan 2.2 default). Leave empty for the architecture default.", type: "number" },
+        negativePrompt: { title: "Negative Prompt", description: "What to push the model away from. Has effect only when `cfg_scale > 1` (CFG is what compares cond vs uncond). Ignored on architectures that don't use CFG (most flow-matching models default to `cfg_scale = 1`). Recommended: Optional. Empty is a fine default for Flux / SD3 / Z-Image. Leave empty for the architecture default.", type: "string", format: "textarea" },
+        prompt: { title: "Prompt", description: "Text prompt fed to the model's text encoder (CLIP / T5 / LLM). Supports inline `<lora:name:weight>` tags — these are auto-extracted into the LoRA list before being sent to the encoder. Supports a1111-style dynamic-prompts (`{a|b|c}`) when `expand_prompt: true` is set. Recommended: Required. Leave empty for the architecture default.", type: "string", format: "textarea" },
+        sampler: { title: "Sampler", description: "Sampling algorithm. Different samplers can produce different images at the same seed; quality and speed differ too. Recommended: euler_a (general), dpmpp2m (SD1.5/SDXL), euler (Flux/SD3/Z-Image), lcm (LCM models). Leave empty for the architecture default.", type: "string", enum: ["", "ddim_trailing", "dpm2", "dpmpp2m", "dpmpp2mv2", "dpmpp2s_a", "er_sde", "euler", "euler_a", "heun", "ipndm", "ipndm_v", "lcm", "res_2s", "res_multistep", "tcd"], enumLabels: ["(architecture default)", "DDIM trailing — required for some fine-tunes", "DPM2 — 2nd-order, balanced", "DPM++ 2M — fast, good quality", "DPM++ 2M v2 — improved schedule", "DPM++ 2S ancestral — popular for SDXL", "ER SDE — SDE sampler (added recently)", "Euler — simple, fast, deterministic", "Euler ancestral — adds noise each step (less reproducible, more variet", "Heun — 2nd-order, slower, sometimes higher quality", "IPNDM", "IPNDM-V", "LCM — for LCM-finetuned models (4–8 step generation)", "RES 2S", "RES multistep — flow-model variant", "TCD — for TCD-finetuned models"], default: "" },
+        scheduler: { title: "Scheduler", description: "Determines the noise schedule (the timesteps the sampler walks through). Pairs with the sampler — some combinations (e.g. karras+dpmpp2m) are well-tested, others may be off. Recommended: discrete or karras for SD1.5/SDXL; simple for Flux/SD3; smoothstep for Z-Image. Leave empty for the architecture default.", type: "string", enum: ["", "ays", "bong_tangent", "discrete", "exponential", "gits", "karras", "kl_optimal", "lcm", "sgm_uniform", "simple", "smoothstep"], enumLabels: ["(architecture default)", "Align Your Steps (AYS) — auto-tuned", "Bong Tangent", "Discrete — uniform across model timesteps (default)", "Exponential", "GITS", "Karras — concentrates more steps near the end, common for SDXL", "KL optimal", "LCM — for LCM samplers", "SGM uniform — for SGM-trained models", "Simple — used by Flux / SD3 flow models", "Smoothstep — Z-Image's recommended scheduler"], default: "" },
+        seed: { title: "Seed", description: "RNG seed for the initial noise tensor (and stochastic samplers). -1 = pick a random one each generation. Same seed + same prompt + same model = same image. Recommended: -1 for variety. Pin a specific number for A/B comparing prompt or sampler changes. Leave empty for the architecture default.", type: "number" },
+        shiftedTimestep: { title: "Shifted Timestep", description: "Start the sampling schedule from a non-final timestep (used by NitroFusion and similar fast-sampling fine-tunes). 0 = standard schedule. 250–500 = NitroFusion's range. Recommended: 0 unless you're explicitly running a NitroFusion-style fine-tune. Leave empty for the architecture default.", type: "number" },
+        skipLayers: { title: "SLG Skip Layers", description: "Which transformer layers to skip during the SLG unconditional pass. SD3.5 Medium uses [7, 8, 9]. Recommended: [7,8,9] for SD3.5 Medium. Other models: leave default, ignored when slg_scale=0. Leave empty for the architecture default.", type: "array", items: {"type": "number"} },
+        slgEnd: { title: "SLG End (% of steps)", description: "Fraction of the sampler schedule at which SLG turns off. Recommended: 0.2 (early-cycle only — late-cycle SLG hurts quality). Leave empty for the architecture default.", type: "number" },
+        slgScale: { title: "SLG Scale", description: "Skip Layer Guidance scale. Selectively zeros out a few diffusion layers when computing the unconditional pass — improves anatomy / coherence on SD3.5-medium and similar. 0 = disabled. Recommended: 0 for most models. 2.5 for SD3.5 Medium with the recommended skip layers. Leave empty for the architecture default.", type: "number" },
+        slgStart: { title: "SLG Start (% of steps)", description: "Fraction of the sampler schedule (0.0–1.0) at which SLG kicks in. Recommended: 0.01 (almost from the start). Leave empty for the architecture default.", type: "number" },
+        spectrumFlexWindow: { title: "Spectrum Cache: flex window", description: "Spectrum-cache flexibility window (0.0–1.0). Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        spectrumLam: { title: "Spectrum Cache: λ", description: "Spectrum-cache regularization λ. Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        spectrumM: { title: "Spectrum Cache: m", description: "Spectrum-cache moving-average length. Recommended: 5. Leave empty for the architecture default.", type: "number" },
+        spectrumStopPercent: { title: "Spectrum Cache: stop percent", description: "Fraction of steps after which spectrum cache disengages (last steps recomputed). Recommended: 0.8. Leave empty for the architecture default.", type: "number" },
+        spectrumW: { title: "Spectrum Cache: w", description: "Spectrum-cache `w` weight (frequency cutoff). Higher = retains more spectrum, less speedup. Recommended: 0.5 default. See sd.cpp PR #1322 for tuning. Leave empty for the architecture default.", type: "number" },
+        spectrumWarmupSteps: { title: "Spectrum Cache: warmup steps", description: "Steps at the start of sampling before spectrum cache becomes active. Recommended: 2. Leave empty for the architecture default.", type: "number" },
+        spectrumWindowSize: { title: "Spectrum Cache: window size", description: "Spectrum-cache analysis window size in steps. Recommended: 3. Leave empty for the architecture default.", type: "number" },
+        steps: { title: "Sampling Steps", description: "Number of denoising steps the sampler runs. More steps = closer to the model's converged output, with diminishing returns. Distilled models (Flux Schnell, SDXL Turbo, Z-Image Turbo) need only 4–8. Recommended: 20–30 for SD1.5/SDXL, 20 for Flux Dev, 4–8 for *-Turbo or Schnell variants. Leave empty for the architecture default.", type: "number" },
+        strength: { title: "Denoising Strength", description: "How much of the init image's noise to keep (0.0 = identical to init, 1.0 = ignore init entirely). Controls how aggressively img2img diverges from the input. Recommended: 0.5–0.75 for natural-looking edits. 0.9+ for radical reinterpretation. Leave empty for the architecture default.", type: "number" },
+        vaceStrength: { title: "VACE Strength", description: "Strength of the VACE module (Wan 2.x video editing). 0 = disabled. Recommended: 1.0 when VACE is loaded. Leave empty for the architecture default.", type: "number" },
+        vaeTileOverlap: { title: "VAE Tile Overlap", description: "Overlap fraction between VAE tiles for seam blending. Recommended: 0.5. Leave empty for the architecture default.", type: "number" },
+        vaeTileSizeX: { title: "VAE Tile Width", description: "Width of VAE tiles when tiling is on. 0 = use load-time default. Recommended: 0. Leave empty for the architecture default.", type: "number" },
+        vaeTileSizeY: { title: "VAE Tile Height", description: "Height of VAE tiles. 0 = use load-time default. Recommended: 0. Leave empty for the architecture default.", type: "number" },
+        vaeTiling: { title: "VAE Tiling", description: "Per-generation override of the model-load `vae_tiling`. Process VAE encode/decode in tiles to reduce peak VRAM. Recommended: Enable for ≥2048 px outputs. Otherwise leave to the load-time default. Leave empty for the architecture default.", type: "boolean" },
+        videoFrames: { title: "Video Frames", description: "Number of frames to generate (Wan / video models). Recommended: 33 for Wan 2.x (5-second clip @ 16 fps). Leave empty for the architecture default.", type: "number" },
+        width: { title: "Width (px)", description: "Output image width in pixels. Must be divisible by the model's patch size (typically 8 or 16). Architectures have native resolutions they were trained at — going far off them can degrade quality. Recommended: Match the architecture's training resolution: SD1.5=512, SDXL=1024, Flux/SD3/Z-Image=1024, Wan video=832. Leave empty for the architecture default.", type: "number" },
+        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. Everything the server documents already has a setting above, so this is only needed for a field a newer server has gained" },
+        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']
 };
@@ -191,11 +303,58 @@ function putIfSet(target, key, value) {
     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.
+// Every generation field the server documents, and the setting it comes
+// from. Generated alongside the schema above so the two cannot drift.
+const GENERATION_OPTIONS = [
+    { setting: "cacheMode", server: "cache_mode" },
+    { setting: "cfgScale", server: "cfg_scale" },
+    { setting: "clipSkip", server: "clip_skip" },
+    { setting: "controlFrames", server: "control_frames" },
+    { setting: "customSigmas", server: "custom_sigmas" },
+    { setting: "distilledGuidance", server: "distilled_guidance" },
+    { setting: "endImageBase64", server: "end_image_base64" },
+    { setting: "eta", server: "eta" },
+    { setting: "expandPrompt", server: "expand_prompt" },
+    { setting: "flowShift", server: "flow_shift" },
+    { setting: "fps", server: "fps" },
+    { setting: "height", server: "height" },
+    { setting: "highNoiseCfgScale", server: "high_noise_cfg_scale" },
+    { setting: "highNoiseDistilledGuidance", server: "high_noise_distilled_guidance" },
+    { setting: "highNoiseSampler", server: "high_noise_sampler" },
+    { setting: "highNoiseSkipLayers", server: "high_noise_skip_layers" },
+    { setting: "highNoiseSlgEnd", server: "high_noise_slg_end" },
+    { setting: "highNoiseSlgScale", server: "high_noise_slg_scale" },
+    { setting: "highNoiseSlgStart", server: "high_noise_slg_start" },
+    { setting: "highNoiseSteps", server: "high_noise_steps" },
+    { setting: "initImageBase64", server: "init_image_base64" },
+    { setting: "moeBoundary", server: "moe_boundary" },
+    { setting: "negativePrompt", server: "negative_prompt" },
+    { setting: "prompt", server: "prompt" },
+    { setting: "sampler", server: "sampler" },
+    { setting: "scheduler", server: "scheduler" },
+    { setting: "seed", server: "seed" },
+    { setting: "shiftedTimestep", server: "shifted_timestep" },
+    { setting: "skipLayers", server: "skip_layers" },
+    { setting: "slgEnd", server: "slg_end" },
+    { setting: "slgScale", server: "slg_scale" },
+    { setting: "slgStart", server: "slg_start" },
+    { setting: "spectrumFlexWindow", server: "spectrum_flex_window" },
+    { setting: "spectrumLam", server: "spectrum_lam" },
+    { setting: "spectrumM", server: "spectrum_m" },
+    { setting: "spectrumStopPercent", server: "spectrum_stop_percent" },
+    { setting: "spectrumW", server: "spectrum_w" },
+    { setting: "spectrumWarmupSteps", server: "spectrum_warmup_steps" },
+    { setting: "spectrumWindowSize", server: "spectrum_window_size" },
+    { setting: "steps", server: "steps" },
+    { setting: "strength", server: "strength" },
+    { setting: "vaceStrength", server: "vace_strength" },
+    { setting: "vaeTileOverlap", server: "vae_tile_overlap" },
+    { setting: "vaeTileSizeX", server: "vae_tile_size_x" },
+    { setting: "vaeTileSizeY", server: "vae_tile_size_y" },
+    { setting: "vaeTiling", server: "vae_tiling" },
+    { setting: "videoFrames", server: "video_frames" },
+    { setting: "width", server: "width" }
+];
 
 async function execute(config, input, context) {
     const server = normalizeServer(config.serverUrl);
@@ -205,25 +364,29 @@ async function execute(config, input, context) {
     const token = login(server, credential, timeout);
 
     const body = {};
-    putIfSet(body, 'prompt', config.prompt);
+
+    // Built from the table above rather than field by field, so a setting the
+    // server documents cannot be quietly missing from the request. A setting
+    // left empty is left out of the body entirely: the server fills an absent
+    // field from the loaded model's architecture preset, and sending an empty
+    // box as 0 would override that preset with nonsense. The test is emptiness,
+    // never truthiness - seed 0 and clip_skip 0 are legitimate values.
+    for (let i = 0; i < GENERATION_OPTIONS.length; i++) {
+        const option = GENERATION_OPTIONS[i];
+        const value = config[option.setting];
+        if (Array.isArray(value)) {
+            if (value.length > 0) {
+                body[option.server] = value;
+            }
+        } else {
+            putIfSet(body, option.server, value);
+        }
+    }
+    putIfSet(body, 'title', config.title);
+
     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.

+ 70 - 27
nodes/sdcpp/sdcpp-upscale.js

@@ -25,6 +25,37 @@ const credentialTypes = [
 
 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: [
+            {
+                    "title": "Server",
+                    "fields": [
+                            "serverUrl",
+                            "credentialId"
+                    ]
+            },
+            {
+                    "title": "/upscale Endpoint",
+                    "fields": [
+                            "imageBase64",
+                            "jobId",
+                            "imageIndex",
+                            "upscaleFactor",
+                            "tileSize",
+                            "repeats"
+                    ]
+            },
+            {
+                    "title": "Job",
+                    "fields": [
+                            "title",
+                            "extraOptions",
+                            "timeout"
+                    ]
+            }
+    ],
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',
@@ -36,23 +67,15 @@ const configSchema = {
             description: 'A basic credential holding the sdcpp-restapi username and password',
             dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', '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
-        }
+        imageBase64: { title: "Source Image (base64)", description: "Base64-encoded source image for upscaling. Recommended: Required. Leave empty for the architecture default.", type: "string" },
+        imageIndex: { title: "Source Image Index", description: "Which output of the source job to upscale. Used with `job_id` when the source job produced multiple images (e.g. batch_count > 1). Ignored when `job_id` is absent. 0-based: 0 = first output. Recommended: Leave at 0 unless the source job had batch_count > 1 and you want a specific image. Leave empty for the architecture default.", type: "number" },
+        jobId: { title: "Source Job ID", description: "Convenience: use the output of an existing completed job as the source for upscaling, instead of providing `image_base64`. handle_upscale loads `outputs[image_index]` from disk, base64-encodes it, and substitutes `image_base64` before the worker runs — so to the worker the two paths are indistinguishable. Recommended: Use this when chaining 'generate, then upscale' from a queued job. Leave empty for the architecture default.", type: "string" },
+        repeats: { title: "Upscale Repeats", description: "Run the upscaler N times. With a 4× model: 1 pass = 4×, 2 passes = 16×. Recommended: 1. Leave empty for the architecture default.", type: "number" },
+        tileSize: { title: "Upscale Tile Size", description: "Tile size for streaming the source image through the upscaler. Smaller = less VRAM, more boundary artifacts. 0 = whole-image. Recommended: 128 default. Drop to 64 if hitting VRAM limits. Leave empty for the architecture default.", type: "number" },
+        upscaleFactor: { title: "Upscale Factor", description: "Target upscale factor. 4 = 4× the source image (default for ESRGAN). Recommended: Match the loaded upscaler's native factor (4 for most ESRGAN variants). Leave empty for the architecture default.", type: "number" },
+        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. Everything the server documents already has a setting above, so this is only needed for a field a newer server has gained" },
+        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']
 };
@@ -161,11 +184,16 @@ function putIfSet(target, key, value) {
     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.
+// Every generation field the server documents, and the setting it comes
+// from. Generated alongside the schema above so the two cannot drift.
+const GENERATION_OPTIONS = [
+    { setting: "imageBase64", server: "image_base64" },
+    { setting: "imageIndex", server: "image_index" },
+    { setting: "jobId", server: "job_id" },
+    { setting: "repeats", server: "repeats" },
+    { setting: "tileSize", server: "tile_size" },
+    { setting: "upscaleFactor", server: "upscale_factor" }
+];
 
 async function execute(config, input, context) {
     const server = normalizeServer(config.serverUrl);
@@ -175,14 +203,29 @@ async function execute(config, input, context) {
     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);
+
+    // Built from the table above rather than field by field, so a setting the
+    // server documents cannot be quietly missing from the request. A setting
+    // left empty is left out of the body entirely: the server fills an absent
+    // field from the loaded model's architecture preset, and sending an empty
+    // box as 0 would override that preset with nonsense. The test is emptiness,
+    // never truthiness - seed 0 and clip_skip 0 are legitimate values.
+    for (let i = 0; i < GENERATION_OPTIONS.length; i++) {
+        const option = GENERATION_OPTIONS[i];
+        const value = config[option.setting];
+        if (Array.isArray(value)) {
+            if (value.length > 0) {
+                body[option.server] = value;
+            }
+        } else {
+            putIfSet(body, option.server, value);
+        }
+    }
+    putIfSet(body, 'title', config.title);
+
     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.

+ 238 - 0
scripts/gen-sdcpp-generation-options.py

@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+"""
+Rewrite the settings block of the SD.cpp generation nodes from the server's own
+option reference.
+
+The server publishes every field the generation endpoints accept at
+/options/generation, with a label, a type, a default, a description and which
+endpoints it applies to. Typing that list into five node files by hand is how
+they drifted in the first place - three of them exposed ten fields out of
+forty-six and hid the rest behind an "Extra Options" JSON box.
+
+So the list is generated. Run this against a server, check the diff, commit the
+result. The nodes stay self-contained JavaScript afterwards - nothing here runs
+at execution time.
+
+  python3 scripts/gen-sdcpp-generation-options.py http://mulan:8077
+"""
+
+import json
+import re
+import sys
+import urllib.request
+from pathlib import Path
+
+NODES = {
+    'sdcpp-txt2img': 'txt2img',
+    'sdcpp-img2img': 'img2img',
+    'sdcpp-edit': 'img2img',
+    'sdcpp-txt2vid': 'txt2vid',
+    'sdcpp-upscale': 'upscale',
+}
+
+# Settings that already exist under a name of their own, kept so workflows that
+# were configured before this generator do not lose their values.
+KEEP_NAME = {
+    'negative_prompt': 'negativePrompt',
+    'cfg_scale': 'cfgScale',
+    'batch_count': 'batchCount',
+    'clip_skip': 'clipSkip',
+    'init_image_base64': 'initImageBase64',
+    'mask_image_base64': 'maskImageBase64',
+    'image_base64': 'imageBase64',
+    'upscale_factor': 'upscaleFactor',
+    'tile_size': 'tileSize',
+    'video_frames': 'videoFrames',
+    'ref_images': 'refImages',
+    'ref_image_args': 'refImageArgs',
+}
+
+# Fields the node handles itself rather than passing through, or that make no
+# sense to type into a form.
+SKIP = {'title'}
+
+# Fields a node takes that the reference files under a different endpoint. The
+# server documents ref_images and ref_image_args as txt2img fields, but the
+# image-edit node posts them to /img2img and the server accepts them - so they
+# stay, rather than a node losing a working feature to a documentation table.
+EXTRA_FOR = {
+    'sdcpp-edit': ['ref_images', 'ref_image_args'],
+}
+
+# Which group the connection settings go in, and its position.
+SERVER_GROUP = {'title': 'Server', 'fields': ['serverUrl', 'credentialId']}
+
+
+def camel(name: str) -> str:
+    if name in KEEP_NAME:
+        return KEEP_NAME[name]
+    head, *rest = name.split('_')
+    return head + ''.join(w[:1].upper() + w[1:] for w in rest)
+
+
+def js(value) -> str:
+    """A JS literal. json.dumps is valid JS for everything used here."""
+    return json.dumps(value, ensure_ascii=False)
+
+
+def describe(opt: dict) -> str:
+    text = ' '.join(str(opt.get('description', '')).split())
+    hint = ' '.join(str(opt.get('recommended', '')).split())
+    if hint:
+        text = f'{text} Recommended: {hint}' if text else f'Recommended: {hint}'
+    # Every one of these is optional: the server fills an absent field from the
+    # loaded model's architecture preset, which is almost always the right
+    # answer and is not something the node can know.
+    return f'{text} Leave empty for the architecture default.'
+
+
+def prop_for(name: str, opt: dict) -> dict:
+    kind = opt.get('type')
+    setting = camel(name)
+    prop: dict = {'title': opt.get('label') or setting, 'description': describe(opt)}
+
+    if kind == 'select':
+        values = opt.get('values') or {}
+        # The empty entry is what "leave it to the architecture" looks like in a
+        # dropdown; without it a select cannot express "unset". Some of the
+        # server's own value maps already carry one, so it is not added twice.
+        keys = [k for k in values.keys() if k != '']
+        prop['type'] = 'string'
+        prop['enum'] = [''] + keys
+        prop['enumLabels'] = ['(architecture default)'] + [
+            ' '.join(str(values[k]).split())[:70] or k for k in keys
+        ]
+        prop['default'] = ''
+    elif kind == 'boolean':
+        prop['type'] = 'boolean'
+    elif kind == 'number':
+        prop['type'] = 'number'
+    elif kind == 'array<string>':
+        prop['type'] = 'array'
+        prop['items'] = {'type': 'string'}
+    elif kind == 'array<number>':
+        prop['type'] = 'array'
+        prop['items'] = {'type': 'number'}
+    else:
+        prop['type'] = 'string'
+        if name in ('prompt', 'negative_prompt'):
+            prop['format'] = 'textarea'
+
+    return prop
+
+
+def render_properties(props: dict, indent: str = '        ') -> str:
+    out = []
+    for key, prop in props.items():
+        inner = ', '.join(f'{k}: {js(v)}' for k, v in prop.items())
+        out.append(f'{indent}{key}: {{ {inner} }}')
+    return ',\n'.join(out)
+
+
+def build(reference: dict, endpoint: str, extra: list) -> tuple:
+    options = reference['options']
+    categories = reference['categories']
+
+    applicable = [
+        (name, opt) for name, opt in options.items()
+        if (endpoint in opt.get('applies_to', []) or name in extra) and name not in SKIP
+    ]
+    by_name = dict(applicable)
+
+    props = {}
+    table = []
+    for name, opt in applicable:
+        setting = camel(name)
+        props[setting] = prop_for(name, opt)
+        table.append((setting, name))
+
+    groups = []
+    for _, cat in categories.items():
+        fields = [camel(n) for n in cat['options'] if n in by_name]
+        if fields:
+            groups.append({'title': cat['label'], 'fields': fields})
+    # Core first, then the rest as the server lists them.
+    groups.sort(key=lambda g: 0 if g['title'] == 'Core' else 1)
+
+    return props, table, groups
+
+
+def rewrite(path: Path, endpoint: str, reference: dict) -> str:
+    source = path.read_text()
+
+    start = source.index('const configSchema')
+    end = source.index('const inputSchema')
+
+    props, table, groups = build(reference, endpoint, EXTRA_FOR.get(path.stem, []))
+
+    # Node-owned settings that are not server generation options.
+    tail_props = {
+        '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. Everything the server documents already has a setting above, so this is only needed for a field a newer server has gained'},
+        '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},
+    }
+
+    ui_groups = [SERVER_GROUP] + groups + [{'title': 'Job', 'fields': ['title', 'extraOptions', 'timeout']}]
+
+    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,
+    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'] } }
+        },
+""" % json.dumps(ui_groups, 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"
+
+    source = source[:start] + new_schema + source[end:]
+
+    # The setting-to-field table the request is built from.
+    rows = ',\n'.join(f"    {{ setting: {js(s)}, server: {js(n)} }}" for s, n in table)
+    table_js = (
+        "// Every generation field the server documents, and the setting it comes\n"
+        "// from. Generated alongside the schema above so the two cannot drift.\n"
+        "const GENERATION_OPTIONS = [\n" + rows + "\n];\n"
+    )
+
+    marker = 'async function execute('
+    if 'const GENERATION_OPTIONS' in source:
+        source = re.sub(r'// Every generation field the server documents.*?\n\];\n',
+                        table_js, source, flags=re.S)
+    else:
+        source = source.replace(marker, table_js + '\n' + marker, 1)
+
+    return source
+
+
+def main():
+    server = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:8077'
+    with urllib.request.urlopen(server.rstrip('/') + '/options/generation', timeout=30) as f:
+        reference = json.loads(f.read().decode())
+
+    root = Path(__file__).resolve().parent.parent / 'nodes' / 'sdcpp'
+    for node, endpoint in NODES.items():
+        path = root / f'{node}.js'
+        path.write_text(rewrite(path, endpoint, reference))
+        count = sum(1 for n, o in reference['options'].items()
+                    if endpoint in o.get('applies_to', []) or n in EXTRA_FOR.get(node, []))
+        print(f'{node:18} {endpoint:8} {count} options')
+
+
+if __name__ == '__main__':
+    main()