ソースを参照

feat: expose the SD.cpp server's new IP-Adapter and video reference fields

The server has been upgraded and now advertises ip_adapter, ip_adapter_plus,
ref_audio and ref_video. Nothing else moved: llms.txt, the endpoint list, the
schema list and /options/generation are all unchanged, and the model-load
option reference has not caught up either. The new fields are only visible in
the OpenAPI request schemas, so they are declared here rather than picked up
by the generator, in the same shape the reference uses - when it does catch
up, deleting them changes nothing else.

Generation gains an IP-Adapter reference image and its strength: a picture
whose style and subject guide the result. txt2vid also gains reference audio
and reference videos. Load Model gains the IP-Adapter component itself, and
the model picker offers ip_adapter as a kind, so choosing one is the same as
choosing a VAE or a ControlNet.

A list setting is now flattened before it is sent. These fields are meant to
be filled with one entry holding an expression for a whole list from an
earlier node, which arrives as a list inside a list - and sending that
untouched is how a batch of two images once became a single file named
"a.png,b.png". Better to fix it before it happens again than after.

The server has no IP-Adapter models installed, so that path could not be run
end to end - the kind lists as empty, which is what the picker is meant to
say. What was verified: ip_adapter_strength set on a real txt2img reached the
server in the request body and the job was accepted.
fszontagh 1 ヶ月 前
親
コミット
dd28021e5d

+ 24 - 4
nodes/sdcpp/sdcpp-edit.js

@@ -98,7 +98,9 @@ const configSchema = {
                             "strength",
                             "imgCfgScale",
                             "refImages",
-                            "refImageArgs"
+                            "refImageArgs",
+                            "ipAdapterImageBase64",
+                            "ipAdapterStrength"
                     ]
             },
             {
@@ -216,6 +218,8 @@ const configSchema = {
         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" },
+        ipAdapterImageBase64: { title: "IP-Adapter Image (base64)", description: "A reference image whose style and subject guide the result, as base64. Needs an IP-Adapter loaded alongside the model - see the Load Model node. Recommended: Take it from a Download or Fetch Output node rather than pasting one in. Leave empty for the architecture default.", type: "string" },
+        ipAdapterStrength: { title: "IP-Adapter Strength", description: "How strongly the reference image guides the result. Recommended: 1.0 is the upstream default. Lower it when the reference is overwhelming the prompt. 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 }
@@ -376,7 +380,9 @@ const GENERATION_OPTIONS = [
     { setting: "vaeTileSizeX", server: "vae_tile_size_x" },
     { setting: "vaeTileSizeY", server: "vae_tile_size_y" },
     { setting: "vaeTiling", server: "vae_tiling" },
-    { setting: "width", server: "width" }
+    { setting: "width", server: "width" },
+    { setting: "ipAdapterImageBase64", server: "ip_adapter_image_base64" },
+    { setting: "ipAdapterStrength", server: "ip_adapter_strength" }
 ];
 
 async function execute(config, input, context) {
@@ -398,8 +404,22 @@ async function execute(config, input, context) {
         const option = GENERATION_OPTIONS[i];
         const value = config[option.setting];
         if (Array.isArray(value)) {
-            if (value.length > 0) {
-                body[option.server] = value;
+            // A list setting is usually filled with one entry holding an
+            // expression for a whole list from an earlier node, which arrives
+            // as a list inside a list. Left alone, the inner list is sent as a
+            // single item and the server sees one nonsense value instead of
+            // several - the same way a batch of two images once became one file
+            // named "a.png,b.png".
+            let flat = [];
+            for (let j = 0; j < value.length; j++) {
+                if (Array.isArray(value[j])) {
+                    flat = flat.concat(value[j]);
+                } else if (value[j] !== undefined && value[j] !== null && value[j] !== '') {
+                    flat.push(value[j]);
+                }
+            }
+            if (flat.length > 0) {
+                body[option.server] = flat;
             }
         } else {
             putIfSet(body, option.server, value);

+ 24 - 4
nodes/sdcpp/sdcpp-img2img.js

@@ -96,7 +96,9 @@ const configSchema = {
                             "initImageBase64",
                             "maskImageBase64",
                             "strength",
-                            "imgCfgScale"
+                            "imgCfgScale",
+                            "ipAdapterImageBase64",
+                            "ipAdapterStrength"
                     ]
             },
             {
@@ -212,6 +214,8 @@ const configSchema = {
         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" },
+        ipAdapterImageBase64: { title: "IP-Adapter Image (base64)", description: "A reference image whose style and subject guide the result, as base64. Needs an IP-Adapter loaded alongside the model - see the Load Model node. Recommended: Take it from a Download or Fetch Output node rather than pasting one in. Leave empty for the architecture default.", type: "string" },
+        ipAdapterStrength: { title: "IP-Adapter Strength", description: "How strongly the reference image guides the result. Recommended: 1.0 is the upstream default. Lower it when the reference is overwhelming the prompt. 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 }
@@ -370,7 +374,9 @@ const GENERATION_OPTIONS = [
     { setting: "vaeTileSizeX", server: "vae_tile_size_x" },
     { setting: "vaeTileSizeY", server: "vae_tile_size_y" },
     { setting: "vaeTiling", server: "vae_tiling" },
-    { setting: "width", server: "width" }
+    { setting: "width", server: "width" },
+    { setting: "ipAdapterImageBase64", server: "ip_adapter_image_base64" },
+    { setting: "ipAdapterStrength", server: "ip_adapter_strength" }
 ];
 
 async function execute(config, input, context) {
@@ -392,8 +398,22 @@ async function execute(config, input, context) {
         const option = GENERATION_OPTIONS[i];
         const value = config[option.setting];
         if (Array.isArray(value)) {
-            if (value.length > 0) {
-                body[option.server] = value;
+            // A list setting is usually filled with one entry holding an
+            // expression for a whole list from an earlier node, which arrives
+            // as a list inside a list. Left alone, the inner list is sent as a
+            // single item and the server sees one nonsense value instead of
+            // several - the same way a batch of two images once became one file
+            // named "a.png,b.png".
+            let flat = [];
+            for (let j = 0; j < value.length; j++) {
+                if (Array.isArray(value[j])) {
+                    flat = flat.concat(value[j]);
+                } else if (value[j] !== undefined && value[j] !== null && value[j] !== '') {
+                    flat.push(value[j]);
+                }
+            }
+            if (flat.length > 0) {
+                body[option.server] = flat;
             }
         } else {
             putIfSet(body, option.server, value);

+ 16 - 1
nodes/sdcpp/sdcpp-model-load.js

@@ -28,7 +28,7 @@ const configSchema = {
     uiGroups: [
         { title: 'Connection', fields: ['serverUrl', 'credentialId'] },
         { title: 'Model', fields: ['modelName', 'modelType', 'whenDifferent', 'force'] },
-        { title: 'Components', fields: ['vae', 'clipL', 'clipG', 't5xxl', 'llm', 'taesd', 'controlnet'] },
+        { title: 'Components', fields: ['vae', 'clipL', 'clipG', 't5xxl', 'llm', 'taesd', 'controlnet', 'ipAdapter'] },
         { title: 'Loading', fields: ['flashAttn', 'diffusionFlashAttn', 'enableMmap', 'eagerLoad',
                                      'streamLayers', 'maxVram', 'nThreads', 'weightType'] },
         { title: 'Advanced', fields: ['vaeFormat', 'prediction', 'rngType', 'samplerRngType',
@@ -55,6 +55,7 @@ const configSchema = {
             'loadedComponents.llm': 'llm',
             'loadedComponents.taesd': 'taesd',
             'loadedComponents.controlnet': 'controlnet',
+            'loadedComponents.ip_adapter': 'ipAdapter',
             'loadOptions.flash_attn': 'flashAttn',
             'loadOptions.diffusion_flash_attn': 'diffusionFlashAttn',
             'loadOptions.enable_mmap': 'enableMmap',
@@ -201,6 +202,19 @@ const configSchema = {
                 needs: ['serverUrl', 'credentialId']
             }
         },
+        ipAdapter: {
+            type: 'string', title: 'IP-Adapter',
+            description: 'Component file name. An IP-Adapter lets a generation take its style and subject from a reference image - load it here, then set the reference on the generation node. Classic and Plus/Resampler variants both work',
+            dynamicOptions: {
+                source: 'node',
+                node: 'sdcpp-model',
+                config: { listOnly: true, modelType: 'ip_adapter' },
+                itemsPath: 'models',
+                valueKey: 'name',
+                labelKey: 'name',
+                needs: ['serverUrl', 'credentialId']
+            }
+        },
         flashAttn: { type: 'boolean', title: 'Flash Attention', description: 'For CLIP and T5. A large speed and memory win on modern GPUs' },
         diffusionFlashAttn: { type: 'boolean', title: 'Flash Attention (diffusion)', description: 'Flash attention for the diffusion model specifically' },
         enableMmap: { type: 'boolean', title: 'Memory-map Weights', description: 'Recommended for large files' },
@@ -553,6 +567,7 @@ async function execute(config, input, context) {
     putIfSet(body, 'llm', config.llm);
     putIfSet(body, 'taesd', config.taesd);
     putIfSet(body, 'controlnet', config.controlnet);
+    putIfSet(body, 'ip_adapter', config.ipAdapter);
     if (Object.keys(wanted).length > 0) {
         body.options = wanted;
     }

+ 4 - 1
nodes/sdcpp/sdcpp-model.js

@@ -51,8 +51,11 @@ const configSchema = {
         },
         modelType: {
             type: 'string', title: 'Kind',
+            // The kinds the server sorts its models into. Kept in step with the
+            // type filter on GET /models.
             enum: ['checkpoint', 'diffusion', 'vae', 'lora', 'clip', 't5', 'embedding',
-                   'controlnet', 'llm', 'esrgan', 'taesd', 'motion_module', 'adetailer'],
+                   'controlnet', 'ip_adapter', 'llm', 'esrgan', 'taesd', 'motion_module',
+                   'adetailer'],
             default: 'checkpoint',
             description: 'Which kind of model to choose from. esrgan is the upscaler kind'
         },

+ 24 - 4
nodes/sdcpp/sdcpp-txt2img.js

@@ -94,7 +94,9 @@ const configSchema = {
                     "title": "Image Input (img2img / image-edit)",
                     "fields": [
                             "refImages",
-                            "refImageArgs"
+                            "refImageArgs",
+                            "ipAdapterImageBase64",
+                            "ipAdapterStrength"
                     ]
             },
             {
@@ -219,6 +221,8 @@ const configSchema = {
         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" },
+        ipAdapterImageBase64: { title: "IP-Adapter Image (base64)", description: "A reference image whose style and subject guide the result, as base64. Needs an IP-Adapter loaded alongside the model - see the Load Model node. Recommended: Take it from a Download or Fetch Output node rather than pasting one in. Leave empty for the architecture default.", type: "string" },
+        ipAdapterStrength: { title: "IP-Adapter Strength", description: "How strongly the reference image guides the result. Recommended: 1.0 is the upstream default. Lower it when the reference is overwhelming the prompt. 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 }
@@ -378,7 +382,9 @@ const GENERATION_OPTIONS = [
     { setting: "vaeTileSizeX", server: "vae_tile_size_x" },
     { setting: "vaeTileSizeY", server: "vae_tile_size_y" },
     { setting: "vaeTiling", server: "vae_tiling" },
-    { setting: "width", server: "width" }
+    { setting: "width", server: "width" },
+    { setting: "ipAdapterImageBase64", server: "ip_adapter_image_base64" },
+    { setting: "ipAdapterStrength", server: "ip_adapter_strength" }
 ];
 
 async function execute(config, input, context) {
@@ -400,8 +406,22 @@ async function execute(config, input, context) {
         const option = GENERATION_OPTIONS[i];
         const value = config[option.setting];
         if (Array.isArray(value)) {
-            if (value.length > 0) {
-                body[option.server] = value;
+            // A list setting is usually filled with one entry holding an
+            // expression for a whole list from an earlier node, which arrives
+            // as a list inside a list. Left alone, the inner list is sent as a
+            // single item and the server sees one nonsense value instead of
+            // several - the same way a batch of two images once became one file
+            // named "a.png,b.png".
+            let flat = [];
+            for (let j = 0; j < value.length; j++) {
+                if (Array.isArray(value[j])) {
+                    flat = flat.concat(value[j]);
+                } else if (value[j] !== undefined && value[j] !== null && value[j] !== '') {
+                    flat.push(value[j]);
+                }
+            }
+            if (flat.length > 0) {
+                body[option.server] = flat;
             }
         } else {
             putIfSet(body, option.server, value);

+ 31 - 5
nodes/sdcpp/sdcpp-txt2vid.js

@@ -83,7 +83,9 @@ const configSchema = {
                     "title": "Image Input (img2img / image-edit)",
                     "fields": [
                             "initImageBase64",
-                            "strength"
+                            "strength",
+                            "ipAdapterImageBase64",
+                            "ipAdapterStrength"
                     ]
             },
             {
@@ -121,7 +123,9 @@ const configSchema = {
                             "moeBoundary",
                             "endImageBase64",
                             "controlFrames",
-                            "vaceStrength"
+                            "vaceStrength",
+                            "refAudios",
+                            "refVideos"
                     ]
             },
             {
@@ -215,6 +219,10 @@ const configSchema = {
         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" },
+        ipAdapterImageBase64: { title: "IP-Adapter Image (base64)", description: "A reference image whose style and subject guide the result, as base64. Needs an IP-Adapter loaded alongside the model - see the Load Model node. Recommended: Take it from a Download or Fetch Output node rather than pasting one in. Leave empty for the architecture default.", type: "string" },
+        ipAdapterStrength: { title: "IP-Adapter Strength", description: "How strongly the reference image guides the result. Recommended: 1.0 is the upstream default. Lower it when the reference is overwhelming the prompt. Leave empty for the architecture default.", type: "number" },
+        refAudios: { title: "Reference Audios", description: "Reference audio as base64-encoded WAV, mono or stereo PCM (i16/i24/i32/f32). Recommended: For models that take audio guidance. Leave empty for the architecture default.", type: "array", items: {"type": "string"} },
+        refVideos: { title: "Reference Videos", description: "Reference videos. Each entry is an object with frames (base64 images), fps (default 24) and an optional audio_wav_base64. Recommended: Built by an earlier node rather than typed. Leave empty for the architecture default.", type: "array", items: {"type": "object"} },
         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 }
@@ -376,7 +384,11 @@ const GENERATION_OPTIONS = [
     { setting: "vaeTileSizeY", server: "vae_tile_size_y" },
     { setting: "vaeTiling", server: "vae_tiling" },
     { setting: "videoFrames", server: "video_frames" },
-    { setting: "width", server: "width" }
+    { setting: "width", server: "width" },
+    { setting: "ipAdapterImageBase64", server: "ip_adapter_image_base64" },
+    { setting: "ipAdapterStrength", server: "ip_adapter_strength" },
+    { setting: "refAudios", server: "ref_audios" },
+    { setting: "refVideos", server: "ref_videos" }
 ];
 
 async function execute(config, input, context) {
@@ -398,8 +410,22 @@ async function execute(config, input, context) {
         const option = GENERATION_OPTIONS[i];
         const value = config[option.setting];
         if (Array.isArray(value)) {
-            if (value.length > 0) {
-                body[option.server] = value;
+            // A list setting is usually filled with one entry holding an
+            // expression for a whole list from an earlier node, which arrives
+            // as a list inside a list. Left alone, the inner list is sent as a
+            // single item and the server sees one nonsense value instead of
+            // several - the same way a batch of two images once became one file
+            // named "a.png,b.png".
+            let flat = [];
+            for (let j = 0; j < value.length; j++) {
+                if (Array.isArray(value[j])) {
+                    flat = flat.concat(value[j]);
+                } else if (value[j] !== undefined && value[j] !== null && value[j] !== '') {
+                    flat.push(value[j]);
+                }
+            }
+            if (flat.length > 0) {
+                body[option.server] = flat;
             }
         } else {
             putIfSet(body, option.server, value);

+ 16 - 2
nodes/sdcpp/sdcpp-upscale.js

@@ -224,8 +224,22 @@ async function execute(config, input, context) {
         const option = GENERATION_OPTIONS[i];
         const value = config[option.setting];
         if (Array.isArray(value)) {
-            if (value.length > 0) {
-                body[option.server] = value;
+            // A list setting is usually filled with one entry holding an
+            // expression for a whole list from an earlier node, which arrives
+            // as a list inside a list. Left alone, the inner list is sent as a
+            // single item and the server sees one nonsense value instead of
+            // several - the same way a batch of two images once became one file
+            // named "a.png,b.png".
+            let flat = [];
+            for (let j = 0; j < value.length; j++) {
+                if (Array.isArray(value[j])) {
+                    flat = flat.concat(value[j]);
+                } else if (value[j] !== undefined && value[j] !== null && value[j] !== '') {
+                    flat.push(value[j]);
+                }
+            }
+            if (flat.length > 0) {
+                body[option.server] = flat;
             }
         } else {
             putIfSet(body, option.server, value);

+ 55 - 3
scripts/gen-sdcpp-generation-options.py

@@ -51,6 +51,44 @@ KEEP_NAME = {
 # sense to type into a form.
 SKIP = {'title'}
 
+# Fields the endpoints accept that /options/generation does not describe yet.
+# They are in the OpenAPI request schemas, so they are real - the per-field
+# reference simply has not caught up. Declared here in the same shape the
+# reference uses, so that when it does catch up these can be deleted and
+# nothing else changes.
+UNDOCUMENTED = {
+    'ip_adapter_image_base64': {
+        'applies_to': ['txt2img', 'img2img', 'txt2vid'],
+        'label': 'IP-Adapter Image (base64)', 'type': 'string', 'default': '',
+        'description': 'A reference image whose style and subject guide the result, as base64. '
+                       'Needs an IP-Adapter loaded alongside the model - see the Load Model node.',
+        'recommended': 'Take it from a Download or Fetch Output node rather than pasting one in.',
+        'category': 'image_input',
+    },
+    'ip_adapter_strength': {
+        'applies_to': ['txt2img', 'img2img', 'txt2vid'],
+        'label': 'IP-Adapter Strength', 'type': 'number', 'default': 1.0,
+        'description': 'How strongly the reference image guides the result.',
+        'recommended': '1.0 is the upstream default. Lower it when the reference is overwhelming the prompt.',
+        'category': 'image_input',
+    },
+    'ref_audios': {
+        'applies_to': ['txt2vid'],
+        'label': 'Reference Audios', 'type': 'array<string>', 'default': [],
+        'description': 'Reference audio as base64-encoded WAV, mono or stereo PCM (i16/i24/i32/f32).',
+        'recommended': 'For models that take audio guidance.',
+        'category': 'video',
+    },
+    'ref_videos': {
+        'applies_to': ['txt2vid'],
+        'label': 'Reference Videos', 'type': 'array<object>', 'default': [],
+        'description': 'Reference videos. Each entry is an object with frames (base64 images), '
+                       'fps (default 24) and an optional audio_wav_base64.',
+        'recommended': 'Built by an earlier node rather than typed.',
+        'category': 'video',
+    },
+}
+
 # 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
@@ -123,6 +161,9 @@ def prop_for(name: str, opt: dict) -> dict:
     elif kind == 'array<number>':
         prop['type'] = 'array'
         prop['items'] = {'type': 'number'}
+    elif kind == 'array<object>':
+        prop['type'] = 'array'
+        prop['items'] = {'type': 'object'}
     else:
         prop['type'] = 'string'
         if name in ('prompt', 'negative_prompt'):
@@ -140,9 +181,12 @@ def render_properties(props: dict, indent: str = '        ') -> str:
 
 
 def build(reference: dict, endpoint: str, extra: list) -> tuple:
-    options = reference['options']
+    options = dict(reference['options'])
     categories = reference['categories']
 
+    for name, described in UNDOCUMENTED.items():
+        options[name] = {k: v for k, v in described.items() if k != 'category'}
+
     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
@@ -156,9 +200,17 @@ def build(reference: dict, endpoint: str, extra: list) -> tuple:
         props[setting] = prop_for(name, opt)
         table.append((setting, name))
 
+    # The undocumented fields belong in a group too, next to the ones they are
+    # related to rather than dumped in "Other".
+    extra_by_category: dict = {}
+    for name, described in UNDOCUMENTED.items():
+        if name in by_name:
+            extra_by_category.setdefault(described['category'], []).append(name)
+
     groups = []
-    for _, cat in categories.items():
-        fields = [camel(n) for n in cat['options'] if n in by_name]
+    for cat_key, cat in categories.items():
+        fields = [camel(n) for n in list(cat['options']) + extra_by_category.get(cat_key, [])
+                  if n in by_name]
         if fields:
             groups.append({'title': cat['label'], 'fields': fields})
     # Core first, then the rest as the server lists them.