comfyui-mcp 0.48.21 → 0.48.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/comfyui/client.js +46 -6
- package/dist/comfyui/client.js.map +1 -1
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/orchestrator/error-text.js +29 -1
- package/dist/orchestrator/error-text.js.map +1 -1
- package/dist/orchestrator/error-text.test.js +28 -0
- package/dist/orchestrator/error-text.test.js.map +1 -1
- package/dist/orchestrator/index.js +331 -56
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +122 -31
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +1256 -198
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/ready-banner.js +70 -0
- package/dist/orchestrator/ready-banner.js.map +1 -0
- package/dist/orchestrator/session-store.js +163 -28
- package/dist/orchestrator/session-store.js.map +1 -1
- package/dist/services/api-nodes.js +5 -7
- package/dist/services/api-nodes.js.map +1 -1
- package/dist/services/download-cache.js +126 -18
- package/dist/services/download-cache.js.map +1 -1
- package/dist/services/download-jobs.js +463 -14
- package/dist/services/download-jobs.js.map +1 -1
- package/dist/services/download-progress.js +252 -3
- package/dist/services/download-progress.js.map +1 -1
- package/dist/services/env-capabilities.js +80 -9
- package/dist/services/env-capabilities.js.map +1 -1
- package/dist/services/generate-audio.js +16 -0
- package/dist/services/generate-audio.js.map +1 -1
- package/dist/services/local-models-fallback.js +7 -3
- package/dist/services/local-models-fallback.js.map +1 -1
- package/dist/services/manifest.js +180 -65
- package/dist/services/manifest.js.map +1 -1
- package/dist/services/model-resolver.js +267 -11
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/services/node-management.js +179 -45
- package/dist/services/node-management.js.map +1 -1
- package/dist/services/output-dir.js +33 -3
- package/dist/services/output-dir.js.map +1 -1
- package/dist/services/panel-secrets.js +19 -7
- package/dist/services/panel-secrets.js.map +1 -1
- package/dist/services/process-control.js +107 -8
- package/dist/services/process-control.js.map +1 -1
- package/dist/services/queue-monitor.js +45 -2
- package/dist/services/queue-monitor.js.map +1 -1
- package/dist/services/queue-monitor.stall.test.js +78 -0
- package/dist/services/queue-monitor.stall.test.js.map +1 -0
- package/dist/services/secure-bridge.js +84 -0
- package/dist/services/secure-bridge.js.map +1 -1
- package/dist/services/storage/azure-blob.js +8 -3
- package/dist/services/storage/azure-blob.js.map +1 -1
- package/dist/services/storage/index.js +3 -3
- package/dist/services/storage/index.js.map +1 -1
- package/dist/services/storage/s3.js +9 -3
- package/dist/services/storage/s3.js.map +1 -1
- package/dist/services/ui-bridge.js +341 -24
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/workflow-converter.js +767 -72
- package/dist/services/workflow-converter.js.map +1 -1
- package/dist/services/workflow-deps.js +20 -1
- package/dist/services/workflow-deps.js.map +1 -1
- package/dist/services/workflow-lock.js +127 -27
- package/dist/services/workflow-lock.js.map +1 -1
- package/dist/services/workflow-target-store.js +7 -3
- package/dist/services/workflow-target-store.js.map +1 -1
- package/dist/services/workspace-env.js +48 -0
- package/dist/services/workspace-env.js.map +1 -1
- package/dist/tools/diagnostics.js +10 -1
- package/dist/tools/diagnostics.js.map +1 -1
- package/dist/tools/generate-audio.js +44 -0
- package/dist/tools/generate-audio.js.map +1 -1
- package/dist/tools/model-explorer.js +85 -5
- package/dist/tools/model-explorer.js.map +1 -1
- package/dist/tools/model-extras.js +24 -4
- package/dist/tools/model-extras.js.map +1 -1
- package/dist/tools/model-management.js +124 -14
- package/dist/tools/model-management.js.map +1 -1
- package/dist/tools/report-issue.js +220 -66
- package/dist/tools/report-issue.js.map +1 -1
- package/dist/tools/report-issue.test.js +223 -83
- package/dist/tools/report-issue.test.js.map +1 -1
- package/dist/tools/skills-access.js +7 -2
- package/dist/tools/skills-access.js.map +1 -1
- package/dist/tools/template-schema.js +5 -5
- package/dist/tools/template-schema.js.map +1 -1
- package/dist/tools/workflow-compose.js +18 -3
- package/dist/tools/workflow-compose.js.map +1 -1
- package/dist/tools/workflow-visualize.js +1 -1
- package/dist/tools/workflow-visualize.js.map +1 -1
- package/package.json +1 -1
- package/packs/anima/install-runpod.sh +0 -1
- package/packs/anima/install-windows.bat +0 -1
- package/packs/anima/manifest.yaml +0 -1
- package/packs/anima-img2img/install-runpod.sh +0 -1
- package/packs/anima-img2img/install-windows.bat +0 -1
- package/packs/anima-img2img/manifest.yaml +0 -1
- package/packs/anima-inpaint/install-runpod.sh +0 -1
- package/packs/anima-inpaint/install-windows.bat +0 -1
- package/packs/anima-inpaint/manifest.yaml +0 -1
- package/packs/anima-txt2img/install-runpod.sh +0 -1
- package/packs/anima-txt2img/install-windows.bat +0 -1
- package/packs/anima-txt2img/manifest.yaml +0 -1
- package/packs/artokun-flow/install-runpod.sh +0 -1
- package/packs/artokun-flow/install-windows.bat +0 -1
- package/packs/artokun-flow/manifest.yaml +0 -1
- package/packs/ernie/install-runpod.sh +0 -1
- package/packs/ernie/install-windows.bat +0 -1
- package/packs/ernie/manifest.yaml +0 -1
- package/packs/ernie-combo/install-runpod.sh +0 -1
- package/packs/ernie-combo/install-windows.bat +0 -1
- package/packs/ernie-combo/manifest.yaml +0 -1
- package/packs/ernie-img2img/install-runpod.sh +0 -1
- package/packs/ernie-img2img/install-windows.bat +0 -1
- package/packs/ernie-img2img/manifest.yaml +0 -1
- package/packs/ernie-txt2img/install-runpod.sh +0 -1
- package/packs/ernie-txt2img/install-windows.bat +0 -1
- package/packs/ernie-txt2img/manifest.yaml +0 -1
- package/packs/ideogram/install-runpod.sh +0 -1
- package/packs/ideogram/install-windows.bat +0 -1
- package/packs/ideogram/manifest.yaml +0 -1
- package/packs/ideogram-img2img/install-runpod.sh +0 -1
- package/packs/ideogram-img2img/install-windows.bat +0 -1
- package/packs/ideogram-img2img/manifest.yaml +0 -1
- package/packs/ideogram-txt2img/install-runpod.sh +0 -1
- package/packs/ideogram-txt2img/install-windows.bat +0 -1
- package/packs/ideogram-txt2img/manifest.yaml +0 -1
- package/packs/ltx-2.3/install-runpod.sh +0 -1
- package/packs/ltx-2.3/install-windows.bat +0 -1
- package/packs/ltx-2.3/manifest.yaml +0 -1
- package/packs/ltx-2.3-extender/install-runpod.sh +0 -1
- package/packs/ltx-2.3-extender/install-windows.bat +0 -1
- package/packs/ltx-2.3-extender/manifest.yaml +0 -1
- package/packs/ltx-2.3-extender-no-audio/install-runpod.sh +0 -1
- package/packs/ltx-2.3-extender-no-audio/install-windows.bat +0 -1
- package/packs/ltx-2.3-extender-no-audio/manifest.yaml +0 -1
- package/packs/ltx-2.3-flf/install-runpod.sh +0 -1
- package/packs/ltx-2.3-flf/install-windows.bat +0 -1
- package/packs/ltx-2.3-flf/manifest.yaml +0 -1
- package/packs/ltx-2.3-img2vid/install-runpod.sh +0 -1
- package/packs/ltx-2.3-img2vid/install-windows.bat +0 -1
- package/packs/ltx-2.3-img2vid/manifest.yaml +3 -4
- package/packs/ltx-2.3-txt2vid/install-runpod.sh +0 -1
- package/packs/ltx-2.3-txt2vid/install-windows.bat +0 -1
- package/packs/ltx-2.3-txt2vid/manifest.yaml +3 -4
- package/packs/ltx-2.3-xy-plot/install-runpod.sh +0 -1
- package/packs/ltx-2.3-xy-plot/install-windows.bat +0 -1
- package/packs/ltx-2.3-xy-plot/manifest.yaml +0 -1
- package/packs/ltx23-distill-3stage/install-runpod.sh +0 -1
- package/packs/ltx23-distill-3stage/install-windows.bat +0 -1
- package/packs/ltx23-distill-3stage/manifest.yaml +0 -1
- package/packs/qwen-image/install-runpod.sh +0 -1
- package/packs/qwen-image/install-windows.bat +0 -1
- package/packs/qwen-image/manifest.yaml +0 -1
- package/packs/qwen-image-edit/install-runpod.sh +0 -1
- package/packs/qwen-image-edit/install-windows.bat +0 -1
- package/packs/qwen-image-edit/manifest.yaml +0 -1
- package/packs/qwen-image-edit-edit/install-runpod.sh +0 -1
- package/packs/qwen-image-edit-edit/install-windows.bat +0 -1
- package/packs/qwen-image-edit-edit/manifest.yaml +0 -1
- package/packs/wan-animate/install-runpod.sh +0 -1
- package/packs/wan-animate/install-windows.bat +0 -1
- package/packs/wan-animate/manifest.yaml +0 -1
- package/packs/wan-animate-character/install-runpod.sh +0 -1
- package/packs/wan-animate-character/install-windows.bat +0 -1
- package/packs/wan-animate-character/manifest.yaml +0 -1
- package/packs/wan-animate-ofm/install-runpod.sh +0 -1
- package/packs/wan-animate-ofm/install-windows.bat +0 -1
- package/packs/wan-animate-ofm/manifest.yaml +0 -1
- package/packs/wan-longer-videos/install-runpod.sh +0 -1
- package/packs/wan-longer-videos/install-windows.bat +0 -1
- package/packs/wan-longer-videos/manifest.yaml +0 -1
- package/packs/wan-longer-videos-i2v/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-i2v/install-windows.bat +0 -1
- package/packs/wan-longer-videos-i2v/manifest.yaml +0 -1
- package/packs/wan-longer-videos-i2v-96gb/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-i2v-96gb/install-windows.bat +0 -1
- package/packs/wan-longer-videos-i2v-96gb/manifest.yaml +0 -1
- package/packs/wan-longer-videos-t2v/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-t2v/install-windows.bat +0 -1
- package/packs/wan-longer-videos-t2v/manifest.yaml +0 -1
- package/packs/wan-longer-videos-t2v-96gb/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-t2v-96gb/install-windows.bat +0 -1
- package/packs/wan-longer-videos-t2v-96gb/manifest.yaml +0 -1
- package/packs/wan-longer-videos-v2v/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-v2v/install-windows.bat +0 -1
- package/packs/wan-longer-videos-v2v/manifest.yaml +0 -1
- package/packs/wan-longer-videos-v2v-96gb/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-v2v-96gb/install-windows.bat +0 -1
- package/packs/wan-longer-videos-v2v-96gb/manifest.yaml +0 -1
- package/packs/wan-multitalk/install-runpod.sh +0 -1
- package/packs/wan-multitalk/install-windows.bat +0 -1
- package/packs/wan-multitalk/manifest.yaml +0 -1
- package/packs/wan-pusa-extend/install-runpod.sh +0 -1
- package/packs/wan-pusa-extend/install-windows.bat +0 -1
- package/packs/wan-pusa-extend/manifest.yaml +0 -1
- package/packs/wan-transparent/install-runpod.sh +0 -1
- package/packs/wan-transparent/install-windows.bat +0 -1
- package/packs/wan-transparent/manifest.yaml +0 -1
- package/packs/wan-transparent-img2vid/install-runpod.sh +0 -1
- package/packs/wan-transparent-img2vid/install-windows.bat +0 -1
- package/packs/wan-transparent-img2vid/manifest.yaml +0 -1
- package/packs/wan-transparent-img2vid-96gb/install-runpod.sh +0 -1
- package/packs/wan-transparent-img2vid-96gb/install-windows.bat +0 -1
- package/packs/wan-transparent-img2vid-96gb/manifest.yaml +0 -1
- package/packs/z-image-base/install-runpod.sh +0 -1
- package/packs/z-image-base/install-windows.bat +0 -1
- package/packs/z-image-base/manifest.yaml +0 -1
- package/packs/z-image-base-combo/install-runpod.sh +0 -1
- package/packs/z-image-base-combo/install-windows.bat +0 -1
- package/packs/z-image-base-combo/manifest.yaml +0 -1
- package/packs/z-image-base-controlnet/install-runpod.sh +0 -1
- package/packs/z-image-base-controlnet/install-windows.bat +0 -1
- package/packs/z-image-base-controlnet/manifest.yaml +0 -1
- package/packs/z-image-base-img2img/install-runpod.sh +0 -1
- package/packs/z-image-base-img2img/install-windows.bat +0 -1
- package/packs/z-image-base-img2img/manifest.yaml +0 -1
- package/packs/z-image-base-inpaint/install-runpod.sh +0 -1
- package/packs/z-image-base-inpaint/install-windows.bat +0 -1
- package/packs/z-image-base-inpaint/manifest.yaml +0 -1
- package/packs/z-image-base-txt2img/install-runpod.sh +0 -1
- package/packs/z-image-base-txt2img/install-windows.bat +0 -1
- package/packs/z-image-base-txt2img/manifest.yaml +0 -1
- package/packs/z-image-turbo/install-runpod.sh +0 -1
- package/packs/z-image-turbo/install-windows.bat +0 -1
- package/packs/z-image-turbo/manifest.yaml +0 -1
- package/packs/z-image-turbo-combo/install-runpod.sh +0 -1
- package/packs/z-image-turbo-combo/install-windows.bat +0 -1
- package/packs/z-image-turbo-combo/manifest.yaml +0 -1
- package/packs/z-image-turbo-controlnet/install-runpod.sh +0 -1
- package/packs/z-image-turbo-controlnet/install-windows.bat +0 -1
- package/packs/z-image-turbo-controlnet/manifest.yaml +0 -1
- package/packs/z-image-turbo-detail-daemon/install-runpod.sh +0 -1
- package/packs/z-image-turbo-detail-daemon/install-windows.bat +0 -1
- package/packs/z-image-turbo-detail-daemon/manifest.yaml +0 -1
- package/packs/z-image-turbo-img2img/install-runpod.sh +0 -1
- package/packs/z-image-turbo-img2img/install-windows.bat +0 -1
- package/packs/z-image-turbo-img2img/manifest.yaml +0 -1
- package/packs/z-image-turbo-inpainting/install-runpod.sh +0 -1
- package/packs/z-image-turbo-inpainting/install-windows.bat +0 -1
- package/packs/z-image-turbo-inpainting/manifest.yaml +0 -1
- package/packs/z-image-turbo-txt2img/install-runpod.sh +0 -1
- package/packs/z-image-turbo-txt2img/install-windows.bat +0 -1
- package/packs/z-image-turbo-txt2img/manifest.yaml +0 -1
- package/packs/z-image-xy-plot/install-runpod.sh +0 -1
- package/packs/z-image-xy-plot/install-windows.bat +0 -1
- package/packs/z-image-xy-plot/manifest.yaml +0 -1
- package/plugin/skills/flux-txt2img/SKILL.md +9 -4
- package/plugin/skills/wan-scail-replacement/SKILL.md +95 -0
- package/scripts/sync-agents.mjs +18 -8
- package/scripts/test-packs.sh +8 -3
|
@@ -9,6 +9,32 @@ export function isUiFormat(obj) {
|
|
|
9
9
|
const record = obj;
|
|
10
10
|
return Array.isArray(record.nodes) && Array.isArray(record.links);
|
|
11
11
|
}
|
|
12
|
+
/** The primitive scalar widget types that occupy a widgets_values slot. */
|
|
13
|
+
const SCALAR_WIDGET_TYPES = new Set(["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"]);
|
|
14
|
+
/**
|
|
15
|
+
* Whether a (type, cfg) pair describes a scalar widget input. Beyond the plain
|
|
16
|
+
* "INT"/"FLOAT"/… types this also recognizes:
|
|
17
|
+
* - a `widgetType` config hint (the frontend's authoritative "render this as a
|
|
18
|
+
* FLOAT/INT widget" flag), and
|
|
19
|
+
* - a COMBINED type string like "FLOAT,INT" (ComfyUI declares widgets whose value
|
|
20
|
+
* may be either — e.g. LTXVEmptyLatentAudio.frame_rate is `["FLOAT,INT", …]`).
|
|
21
|
+
* Without this, a combined-type widget is misread as a LINK input, dropped from
|
|
22
|
+
* the widget list, and every following widget value shifts by one slot (issue
|
|
23
|
+
* #193: frame_rate 24 landed on batch_size and frame_rate fell back to default 25).
|
|
24
|
+
*/
|
|
25
|
+
function isScalarWidgetType(type, cfg) {
|
|
26
|
+
const wt = cfg?.widgetType;
|
|
27
|
+
if (typeof wt === "string" && SCALAR_WIDGET_TYPES.has(wt))
|
|
28
|
+
return true;
|
|
29
|
+
if (typeof type !== "string")
|
|
30
|
+
return false;
|
|
31
|
+
if (SCALAR_WIDGET_TYPES.has(type))
|
|
32
|
+
return true;
|
|
33
|
+
if (!type.includes(","))
|
|
34
|
+
return false;
|
|
35
|
+
const parts = type.split(",").map((s) => s.trim()).filter(Boolean);
|
|
36
|
+
return parts.length > 0 && parts.every((p) => SCALAR_WIDGET_TYPES.has(p));
|
|
37
|
+
}
|
|
12
38
|
/**
|
|
13
39
|
* Check if an input spec represents a widget (value input) vs a link (connection input).
|
|
14
40
|
* Widget inputs have an array type spec like ["INT", {...}] or ["STRING", {...}].
|
|
@@ -27,15 +53,8 @@ function isWidgetInput(inputName, def) {
|
|
|
27
53
|
const cfg = spec[1];
|
|
28
54
|
if (Array.isArray(cfg?.options))
|
|
29
55
|
return true;
|
|
30
|
-
// Standard
|
|
31
|
-
|
|
32
|
-
"INT",
|
|
33
|
-
"FLOAT",
|
|
34
|
-
"STRING",
|
|
35
|
-
"BOOLEAN",
|
|
36
|
-
"COMBO",
|
|
37
|
-
]);
|
|
38
|
-
return WIDGET_TYPES.has(typeSpec);
|
|
56
|
+
// Standard + combined + widgetType-hinted scalar widgets.
|
|
57
|
+
return isScalarWidgetType(typeSpec, cfg);
|
|
39
58
|
}
|
|
40
59
|
/**
|
|
41
60
|
* Whether an input spec is a POSITIONAL widget — one that occupies a slot in the
|
|
@@ -52,7 +71,7 @@ function isPositionalWidgetSpec(spec) {
|
|
|
52
71
|
const cfg = spec[1];
|
|
53
72
|
if (Array.isArray(cfg?.options))
|
|
54
73
|
return true; // dynamic combo (options-carrying)
|
|
55
|
-
return
|
|
74
|
+
return isScalarWidgetType(type, cfg);
|
|
56
75
|
}
|
|
57
76
|
/**
|
|
58
77
|
* Model/asset file extensions that appear in ComfyUI loader combos
|
|
@@ -63,16 +82,23 @@ function isPositionalWidgetSpec(spec) {
|
|
|
63
82
|
* to the first option is harmless.
|
|
64
83
|
*/
|
|
65
84
|
const ASSET_FILE_RE = /\.(safetensors|safetensor|sft|ckpt|pt|pt2|pth|bin|gguf|onnx|vae|pkl|npz|yaml)$/i;
|
|
66
|
-
function looksLikeAssetFilename(value) {
|
|
85
|
+
export function looksLikeAssetFilename(value) {
|
|
67
86
|
return typeof value === "string" && ASSET_FILE_RE.test(value.trim());
|
|
68
87
|
}
|
|
69
88
|
/**
|
|
70
|
-
* Widget names that are ALWAYS server-side asset selectors, even when their
|
|
89
|
+
* Widget names that are ALWAYS server-side MODEL asset selectors, even when their
|
|
71
90
|
* options carry no file extension (e.g. DiffusersLoader.model_path lists
|
|
72
91
|
* extensionless directory names). Substituting a different entry here silently
|
|
73
|
-
* swaps the user's model, so these must never take the comboOpts[0] fallback
|
|
74
|
-
*
|
|
75
|
-
*
|
|
92
|
+
* swaps the user's model, so these must never take the comboOpts[0] fallback
|
|
93
|
+
* (issue #407). These "_name"/"model_path" widget names are unambiguous model
|
|
94
|
+
* selectors on every node that exposes them, so a plain name match is safe.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately EXCLUDES generic media names (image/audio/video) and enum-shaped
|
|
97
|
+
* "_name" widgets (sampler_name, scheduler): those are NOT globally asset
|
|
98
|
+
* selectors — a combo merely *named* `image` on some non-loader node, or a
|
|
99
|
+
* `sampler_name`, is a true enum whose first-option fallback is legitimate.
|
|
100
|
+
* Media/upload selectors are recognized structurally instead (see
|
|
101
|
+
* LOADER_ASSET_INPUTS and isUploadSelectorSpec).
|
|
76
102
|
*/
|
|
77
103
|
const ASSET_WIDGET_NAMES = new Set([
|
|
78
104
|
"ckpt_name",
|
|
@@ -94,13 +120,187 @@ const ASSET_WIDGET_NAMES = new Set([
|
|
|
94
120
|
"diffusion_model",
|
|
95
121
|
]);
|
|
96
122
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
123
|
+
* KNOWN server-side file/upload SELECTORS, keyed by (classType → input name):
|
|
124
|
+
* real loader nodes whose combo lists media files present on the *connected*
|
|
125
|
+
* server (uploads or on-disk inputs). For these — and ONLY these, plus the
|
|
126
|
+
* model-loader widget names in ASSET_WIDGET_NAMES and object_info upload-flag
|
|
127
|
+
* metadata (isUploadSelectorSpec) — a value absent from a STALE combo is
|
|
128
|
+
* PRESERVED with a warning rather than silently rewritten to comboOpts[0]
|
|
129
|
+
* (issue #504). This is deliberately an allowlist, NOT a global name/extension
|
|
130
|
+
* heuristic: a combo merely *named* `image`/`audio`/`video`/`extension` on some
|
|
131
|
+
* OTHER node, or a media-looking value (`.bmp`, `.mp4`, …) on a TRUE enum, is
|
|
132
|
+
* NOT an asset selector and must take the enum fallback (substitute + warn) so
|
|
133
|
+
* ComfyUI does not hard-reject a "Value not in list".
|
|
100
134
|
*/
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
135
|
+
const LOADER_ASSET_INPUTS = new Map([
|
|
136
|
+
["LoadImage", new Set(["image"])],
|
|
137
|
+
["LoadImageMask", new Set(["image"])],
|
|
138
|
+
["LoadImageOutput", new Set(["image"])],
|
|
139
|
+
["VHS_LoadVideo", new Set(["video"])],
|
|
140
|
+
["VHS_LoadVideoPath", new Set(["video"])],
|
|
141
|
+
["VHS_LoadVideoFFmpeg", new Set(["video"])],
|
|
142
|
+
["VHS_LoadVideoFFmpegPath", new Set(["video"])],
|
|
143
|
+
["LoadAudio", new Set(["audio"])],
|
|
144
|
+
["VHS_LoadAudio", new Set(["audio"])],
|
|
145
|
+
["VHS_LoadAudioUpload", new Set(["audio"])],
|
|
146
|
+
]);
|
|
147
|
+
function isKnownLoaderInput(classType, name) {
|
|
148
|
+
return LOADER_ASSET_INPUTS.get(classType)?.has(name) ?? false;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Whether object_info marks this input as an in-browser file/media UPLOAD
|
|
152
|
+
* selector — the authoritative signal that the combo lists server-side input
|
|
153
|
+
* files (LoadImage's `image` carries `{image_upload: true}`, audio/video loaders
|
|
154
|
+
* `{audio_upload|video_upload: true}`). When present this is more reliable than
|
|
155
|
+
* any name allowlist, so a staged value absent from a stale combo is preserved.
|
|
156
|
+
*/
|
|
157
|
+
function isUploadSelectorSpec(spec) {
|
|
158
|
+
if (!Array.isArray(spec))
|
|
159
|
+
return false;
|
|
160
|
+
const cfg = spec[1];
|
|
161
|
+
if (!cfg || typeof cfg !== "object")
|
|
162
|
+
return false;
|
|
163
|
+
return (cfg.image_upload === true ||
|
|
164
|
+
cfg.audio_upload === true ||
|
|
165
|
+
cfg.video_upload === true);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Extract the selectable option list from a widget spec, covering ALL three
|
|
169
|
+
* combo shapes so validation never misses one (issue #504 P1-B):
|
|
170
|
+
* - inline combo: [["a","b"], {..}] -> spec[0]
|
|
171
|
+
* - COMBO w/ options: ["COMBO", {options:["a","b"]}] -> spec[1].options
|
|
172
|
+
* - V3 dynamic combo: ["COMFY_DYNAMICCOMBO_V3", -> the option keys
|
|
173
|
+
* {options:[{key:"K", inputs:{…}}]}]
|
|
174
|
+
* A plain scalar spec (["INT",{…}]) or a link input carries no enumerable list,
|
|
175
|
+
* so this returns undefined and the caller passes the value through untouched.
|
|
176
|
+
* Object-shaped dynamic options are normalized to their `key`.
|
|
177
|
+
*/
|
|
178
|
+
function getComboOptions(spec) {
|
|
179
|
+
if (!Array.isArray(spec))
|
|
180
|
+
return undefined;
|
|
181
|
+
const type = spec[0];
|
|
182
|
+
if (Array.isArray(type))
|
|
183
|
+
return type; // inline combo: spec[0] IS the option list
|
|
184
|
+
const cfg = spec[1];
|
|
185
|
+
const opts = cfg?.options;
|
|
186
|
+
if (Array.isArray(opts)) {
|
|
187
|
+
// Flat value list (["auto","1:1"]) or object-keyed dynamic-combo list
|
|
188
|
+
// ([{key:"Nano Banana 2", …}]). Normalize both to their selectable values.
|
|
189
|
+
return opts.map((o) => o &&
|
|
190
|
+
typeof o === "object" &&
|
|
191
|
+
!Array.isArray(o) &&
|
|
192
|
+
"key" in o
|
|
193
|
+
? o.key
|
|
194
|
+
: o);
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Validate one saved combo value against its object_info options (of ANY combo
|
|
200
|
+
* shape — see getComboOptions) and return the value to store. This is the SINGLE
|
|
201
|
+
* choke-point every combo-value assignment flows through — direct widget, the
|
|
202
|
+
* name→value object form, the has_serialized_properties overwrite, a linked
|
|
203
|
+
* PrimitiveNode literal (P1-A), AND a V3 dynamic-combo nested leaf value (P1-B) —
|
|
204
|
+
* so no path can emit a value ComfyUI would reject with "Value not in list".
|
|
205
|
+
*
|
|
206
|
+
* Non-combo specs and in-list values pass through unchanged. For an out-of-list
|
|
207
|
+
* value the decision mirrors #407/#504:
|
|
208
|
+
*
|
|
209
|
+
* - genuine ASSET/upload selector (model-loader widget name, (classType,input)
|
|
210
|
+
* loader allowlist, or object_info upload flag) → PRESERVE + warn, so a
|
|
211
|
+
* staged/absent file surfaces as an honest missing-asset error rather than a
|
|
212
|
+
* silent wrong-file swap;
|
|
213
|
+
* - true ENUM (sampler_name, scheduler, a stale "Select to add Wildcard"
|
|
214
|
+
* helper, a combo merely NAMED image/extension, a media-looking value on a
|
|
215
|
+
* non-loader enum, an out-of-list dynamic/nested key) → SUBSTITUTE
|
|
216
|
+
* comboOpts[0] + warn, because ComfyUI hard-rejects a "Value not in list".
|
|
217
|
+
*
|
|
218
|
+
* `name` is the LEAF input name (nested leaf for dynamic combos) so asset
|
|
219
|
+
* classification keys off the real selector name, and `spec` is that leaf's
|
|
220
|
+
* spec, never regressing #504/#407.
|
|
221
|
+
*/
|
|
222
|
+
function validateComboValueForSpec(name, value, spec, classType, nodeId, warnings) {
|
|
223
|
+
const comboOpts = getComboOptions(spec);
|
|
224
|
+
// Not an enumerable combo (plain INT/FLOAT/STRING/BOOLEAN or a link input) —
|
|
225
|
+
// nothing to validate against.
|
|
226
|
+
if (!Array.isArray(comboOpts))
|
|
227
|
+
return value;
|
|
228
|
+
// Already a valid option — pass through untouched.
|
|
229
|
+
if (comboOpts.includes(value))
|
|
230
|
+
return value;
|
|
231
|
+
const isAssetCombo = ASSET_WIDGET_NAMES.has(name) ||
|
|
232
|
+
isKnownLoaderInput(classType, name) ||
|
|
233
|
+
isUploadSelectorSpec(spec);
|
|
234
|
+
// Recognized combo whose option list is EMPTY on the connected server (e.g. no
|
|
235
|
+
// models/files installed, or a fully-retired enum). There is no valid option to
|
|
236
|
+
// substitute to, so preserve the declared value and WARN — surfacing an honest
|
|
237
|
+
// missing-option/asset error rather than SILENTLY emitting an out-of-list
|
|
238
|
+
// literal. (Previously the length===0 branch returned the value with no warning.)
|
|
239
|
+
if (comboOpts.length === 0) {
|
|
240
|
+
warnings.push(isAssetCombo
|
|
241
|
+
? `Node ${nodeId} (${classType}): widget "${name}" value "${String(value)}" — the connected server has no installed options for this asset combo; keeping the declared value so it surfaces as a missing-asset error.`
|
|
242
|
+
: `Node ${nodeId} (${classType}): widget "${name}" value "${String(value)}" — the connected server lists no options for this combo, so it cannot be validated; keeping the declared value so it surfaces as an honest error rather than being silently emitted.`);
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
if (isAssetCombo) {
|
|
246
|
+
warnings.push(`Node ${nodeId} (${classType}): widget "${name}" value "${String(value)}" is not installed on the connected server — keeping the declared value so it surfaces as a missing-asset error rather than silently substituting "${String(comboOpts[0])}".`);
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
warnings.push(`Node ${nodeId} (${classType}): widget "${name}" value "${String(value)}" is not a valid option on the connected server — substituting the first available option "${String(comboOpts[0])}" so the graph stays runnable (ComfyUI rejects unknown enum values).`);
|
|
250
|
+
return comboOpts[0];
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Def-keyed wrapper for validateComboValueForSpec: resolves the input's spec from
|
|
254
|
+
* object_info by name (required then optional) and delegates. Used by the direct
|
|
255
|
+
* widget paths and the linked-PrimitiveNode path.
|
|
256
|
+
*/
|
|
257
|
+
function validateComboWidgetValue(name, value, def, classType, nodeId, warnings) {
|
|
258
|
+
const spec = def.input?.required?.[name] ??
|
|
259
|
+
def.input?.optional?.[name];
|
|
260
|
+
return validateComboValueForSpec(name, value, spec, classType, nodeId, warnings);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Resolve an input NAME (possibly a V3 dynamic-combo dotted "<combo>.<leaf>"
|
|
264
|
+
* key) to its LEAF name and object_info spec. A flat name resolves against the
|
|
265
|
+
* node's top-level required/optional inputs. A dotted name resolves the leaf
|
|
266
|
+
* against the parent dynamic combo's SELECTED option (looked up from the already
|
|
267
|
+
* populated `inputs[parent]`), so validation and asset classification key off
|
|
268
|
+
* the real leaf spec + leaf name — never the dotted name (which no top-level
|
|
269
|
+
* lookup would find, letting an unvalidated value through). Used by the linked
|
|
270
|
+
* PrimitiveNode path, where a primitive can feed a nested dotted input directly.
|
|
271
|
+
*/
|
|
272
|
+
function resolveWidgetSpec(def, name, inputs) {
|
|
273
|
+
const dot = name.indexOf(".");
|
|
274
|
+
if (dot < 0) {
|
|
275
|
+
const spec = def.input?.required?.[name] ??
|
|
276
|
+
def.input?.optional?.[name];
|
|
277
|
+
return { leafName: name, spec };
|
|
278
|
+
}
|
|
279
|
+
const parent = name.slice(0, dot);
|
|
280
|
+
const leafName = name.slice(dot + 1);
|
|
281
|
+
const parentSpec = def.input?.required?.[parent] ??
|
|
282
|
+
def.input?.optional?.[parent];
|
|
283
|
+
const opts = (Array.isArray(parentSpec)
|
|
284
|
+
? parentSpec[1]
|
|
285
|
+
: undefined)?.options;
|
|
286
|
+
const selectedKey = inputs[parent];
|
|
287
|
+
// V3 option inputs may live under required OR optional — search both so an
|
|
288
|
+
// optional nested leaf isn't left unresolved (which would skip validation).
|
|
289
|
+
const selected = Array.isArray(opts)
|
|
290
|
+
? opts.find((o) => o?.key === selectedKey)?.inputs
|
|
291
|
+
: undefined;
|
|
292
|
+
const spec = selected?.required?.[leafName] ?? selected?.optional?.[leafName];
|
|
293
|
+
return { leafName, spec };
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Whether a widget SPEC carries a control_after_generate phantom slot — either
|
|
297
|
+
* flagged in its config, or a seed-type INT the ComfyUI frontend auto-augments.
|
|
298
|
+
* Works for both top-level inputs and V3 dynamic-combo NESTED leaves (which have
|
|
299
|
+
* no entry in def.input), so nested controlled seeds skip their phantom
|
|
300
|
+
* "fixed"/"randomize" slot too instead of shifting every following widget.
|
|
301
|
+
*/
|
|
302
|
+
function specHasControlAfterGenerate(name, spec) {
|
|
303
|
+
if (!Array.isArray(spec))
|
|
104
304
|
return false;
|
|
105
305
|
if (spec[1]?.control_after_generate === true)
|
|
106
306
|
return true;
|
|
@@ -108,10 +308,19 @@ function hasControlAfterGenerate(inputName, def) {
|
|
|
108
308
|
// widgets even when object_info doesn't flag it (e.g. UltimateSDUpscale.seed,
|
|
109
309
|
// KSamplerAdvanced.noise_seed). The saved widgets_values then carry the extra
|
|
110
310
|
// "fixed"/"randomize"/… value that must be skipped during mapping.
|
|
111
|
-
|
|
112
|
-
(
|
|
113
|
-
|
|
114
|
-
|
|
311
|
+
return (spec[0] === "INT" &&
|
|
312
|
+
(name === "seed" || name === "noise_seed" || name === "rand_seed"));
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Check if an input has control_after_generate in its spec config.
|
|
316
|
+
* These inputs (like seed, noise_seed) have a phantom "fixed"/"randomize" widget
|
|
317
|
+
* in the UI's widgets_values array that doesn't correspond to any named input.
|
|
318
|
+
*/
|
|
319
|
+
function hasControlAfterGenerate(inputName, def) {
|
|
320
|
+
const spec = def.input.required?.[inputName] ?? def.input.optional?.[inputName];
|
|
321
|
+
if (!spec)
|
|
322
|
+
return false;
|
|
323
|
+
return specHasControlAfterGenerate(inputName, spec);
|
|
115
324
|
}
|
|
116
325
|
/**
|
|
117
326
|
* Get the ordered list of input names from a node definition.
|
|
@@ -142,6 +351,22 @@ const WIRING_VIRTUAL_TYPES = new Set([
|
|
|
142
351
|
const isGetVirtual = (t) => WIRING_VIRTUAL_TYPES.has(t) && /get/i.test(t);
|
|
143
352
|
const isSetVirtual = (t) => WIRING_VIRTUAL_TYPES.has(t) && /set/i.test(t) && !/get/i.test(t);
|
|
144
353
|
const isWiringVirtual = (t) => t != null && (t === "Reroute" || isGetVirtual(t) || isSetVirtual(t));
|
|
354
|
+
/**
|
|
355
|
+
* The bus name (the "Constant" widget) that keys a Set/Get virtual node. Usually
|
|
356
|
+
* widgets_values[0] on a serialized graph, but the live-canvas reconstruction
|
|
357
|
+
* fallback (#384) keys widget values BY NAME, so widgets_values can be an object
|
|
358
|
+
* ({ Constant: "…" }) instead of a positional array. Read both shapes so Set/Get
|
|
359
|
+
* resolution survives the fallback path.
|
|
360
|
+
*/
|
|
361
|
+
function busKey(wv) {
|
|
362
|
+
if (Array.isArray(wv))
|
|
363
|
+
return wv[0];
|
|
364
|
+
if (wv && typeof wv === "object") {
|
|
365
|
+
const o = wv;
|
|
366
|
+
return o.Constant ?? o.constant ?? Object.values(o)[0];
|
|
367
|
+
}
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
145
370
|
/**
|
|
146
371
|
* Pre-pass: strip the pure "wiring" virtual nodes — KJNodes **Get/Set** bus nodes
|
|
147
372
|
* and **Reroute** — by rewriting each consumer's link straight to the real
|
|
@@ -176,7 +401,7 @@ function deVirtualizeGraph(nodes, links) {
|
|
|
176
401
|
const busSet = new Map();
|
|
177
402
|
for (const n of nodes) {
|
|
178
403
|
if (isSetVirtual(n.type)) {
|
|
179
|
-
const b = n.widgets_values
|
|
404
|
+
const b = busKey(n.widgets_values);
|
|
180
405
|
if (b != null)
|
|
181
406
|
busSet.set(String(b), n);
|
|
182
407
|
}
|
|
@@ -197,19 +422,29 @@ function deVirtualizeGraph(nodes, links) {
|
|
|
197
422
|
return s ? resolveReal(s.node, s.slot, depth + 1) : null;
|
|
198
423
|
}
|
|
199
424
|
if (isGetVirtual(node.type)) {
|
|
200
|
-
const b = node.widgets_values
|
|
425
|
+
const b = busKey(node.widgets_values);
|
|
201
426
|
const setN = b != null ? busSet.get(String(b)) : undefined;
|
|
202
427
|
if (!setN)
|
|
203
428
|
return null;
|
|
204
429
|
const s = incoming(setN);
|
|
205
430
|
return s ? resolveReal(s.node, s.slot, depth + 1) : null;
|
|
206
431
|
}
|
|
432
|
+
// SetNode passthrough: rgthree/KJNodes Set nodes expose an output that mirrors
|
|
433
|
+
// their value input, so a consumer can wire straight off the SetNode (not only
|
|
434
|
+
// via a GetNode). Follow the Set's value input to the real source — otherwise
|
|
435
|
+
// the link is left pointing at the (removed) virtual and gets dropped, losing
|
|
436
|
+
// the connection (issue #361: Set/Get-resolved links missing on downstream nodes).
|
|
437
|
+
if (isSetVirtual(node.type)) {
|
|
438
|
+
const s = incoming(node);
|
|
439
|
+
return s ? resolveReal(s.node, s.slot, depth + 1) : null;
|
|
440
|
+
}
|
|
207
441
|
return { node: nodeId, slot };
|
|
208
442
|
};
|
|
209
443
|
const drop = new Set();
|
|
210
444
|
for (const l of links) {
|
|
211
445
|
const srcType = byId.get(lsrc(l))?.type;
|
|
212
|
-
if (srcType === "Reroute" ||
|
|
446
|
+
if (srcType === "Reroute" ||
|
|
447
|
+
(srcType && (isGetVirtual(srcType) || isSetVirtual(srcType)))) {
|
|
213
448
|
const real = resolveReal(lsrc(l), lsrcSlot(l));
|
|
214
449
|
if (real && !isWiringVirtual(byId.get(real.node)?.type)) {
|
|
215
450
|
setLsrc(l, real.node, real.slot);
|
|
@@ -629,7 +864,15 @@ export function isLinkRef(v, nodeKeys) {
|
|
|
629
864
|
/** The UI slot `type` string for a widget input spec. */
|
|
630
865
|
function widgetSlotType(spec) {
|
|
631
866
|
const t = spec[0];
|
|
632
|
-
|
|
867
|
+
if (Array.isArray(t))
|
|
868
|
+
return "COMBO";
|
|
869
|
+
const wt = spec[1]?.widgetType;
|
|
870
|
+
if (typeof wt === "string")
|
|
871
|
+
return wt;
|
|
872
|
+
// Combined type like "FLOAT,INT" → the first member is the concrete slot type.
|
|
873
|
+
if (typeof t === "string" && t.includes(","))
|
|
874
|
+
return t.split(",")[0].trim();
|
|
875
|
+
return t;
|
|
633
876
|
}
|
|
634
877
|
/**
|
|
635
878
|
* A sensible widgets_values entry for a widget the API graph didn't provide:
|
|
@@ -988,7 +1231,7 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
988
1231
|
for (const n of expanded.nodes) {
|
|
989
1232
|
if (!isSetType(n.type))
|
|
990
1233
|
continue;
|
|
991
|
-
const bus = n.widgets_values
|
|
1234
|
+
const bus = busKey(n.widgets_values);
|
|
992
1235
|
const inp = (n.inputs ?? []).find((i) => i.link != null);
|
|
993
1236
|
if (bus != null && inp?.link != null)
|
|
994
1237
|
busSource.set(String(bus), inp.link);
|
|
@@ -1011,7 +1254,7 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
1011
1254
|
}
|
|
1012
1255
|
// Virtual GetNode: follow the bus to the SetNode that wrote it.
|
|
1013
1256
|
if (isGetType(src.type)) {
|
|
1014
|
-
const bus = src.widgets_values
|
|
1257
|
+
const bus = busKey(src.widgets_values);
|
|
1015
1258
|
const setLink = bus != null ? busSource.get(String(bus)) : undefined;
|
|
1016
1259
|
return setLink != null ? resolveSource(setLink, depth + 1) : null;
|
|
1017
1260
|
}
|
|
@@ -1075,25 +1318,166 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
1075
1318
|
widgetNames.push(name);
|
|
1076
1319
|
}
|
|
1077
1320
|
}
|
|
1321
|
+
// Nested widgets that have been "converted to input" (linked) carry NO
|
|
1322
|
+
// positional widgets_values slot — their value arrives via the link, not the
|
|
1323
|
+
// saved array — so consuming a slot for them would steal the next widget's
|
|
1324
|
+
// value and mis-align the whole tail. Track the linked (dotted) input names
|
|
1325
|
+
// to skip their positional consumption below.
|
|
1326
|
+
const linkedInputNames = new Set((node.inputs ?? [])
|
|
1327
|
+
.filter((i) => i.link != null)
|
|
1328
|
+
.map((i) => i.name));
|
|
1078
1329
|
// Map widgets_values to named widget inputs.
|
|
1079
1330
|
// Some INT inputs with "control_after_generate": true (like seed, noise_seed) have
|
|
1080
1331
|
// a phantom widget value in widgets_values ("fixed"/"randomize"/"increment"/"decrement")
|
|
1081
1332
|
// that doesn't correspond to any named input — we must skip those.
|
|
1082
1333
|
const widgetValues = node.widgets_values ?? [];
|
|
1083
1334
|
// Some nodes (e.g. VHS_VideoCombine) store widgets_values as a name->value
|
|
1084
|
-
// object instead of a positional array
|
|
1335
|
+
// object instead of a positional array — as does the #384 live-canvas
|
|
1336
|
+
// fallback (graph_get_state keys every widget by name). Map those by name
|
|
1337
|
+
// directly; no positional drift means the values are authoritative (no
|
|
1338
|
+
// combo-validity re-resolution or phantom control_after_generate slot needed).
|
|
1085
1339
|
if (!Array.isArray(widgetValues)) {
|
|
1086
1340
|
const wv = widgetValues;
|
|
1341
|
+
// Resolve a top-level dynamic combo's SELECTED-option nested inputs (merged
|
|
1342
|
+
// required + optional — an asset selector may live under `optional`, and
|
|
1343
|
+
// reading only `required` would drop it, regressing #504/#407). Mirrors the
|
|
1344
|
+
// positional branch's nested lookup.
|
|
1345
|
+
const nestedFor = (name) => {
|
|
1346
|
+
const spec = def.input?.required?.[name] ??
|
|
1347
|
+
def.input?.optional?.[name];
|
|
1348
|
+
const opts = (Array.isArray(spec)
|
|
1349
|
+
? spec[1]
|
|
1350
|
+
: undefined)?.options;
|
|
1351
|
+
const selInputs = Array.isArray(opts)
|
|
1352
|
+
? opts.find((o) => o?.key === wv[name])?.inputs
|
|
1353
|
+
: undefined;
|
|
1354
|
+
return selInputs && (selInputs.required || selInputs.optional)
|
|
1355
|
+
? { ...selInputs.required, ...selInputs.optional }
|
|
1356
|
+
: undefined;
|
|
1357
|
+
};
|
|
1358
|
+
// A name-keyed capture (the #384 graph_get_state fallback, or a natively
|
|
1359
|
+
// name-keyed widgets_values) stores EVERY widget in ONE FLAT name→value map.
|
|
1360
|
+
// That is only authoritative when names are GLOBALLY UNIQUE. When more than
|
|
1361
|
+
// one control claims the SAME flat slot — two dynamic-combo parents each with
|
|
1362
|
+
// a nested "strength", OR a nested "strength" shadowing a top-level widget
|
|
1363
|
+
// "strength" — the single stored value can't be attributed to ANY of them, so
|
|
1364
|
+
// reading by leaf name would silently push one control's value onto another
|
|
1365
|
+
// (the #504/#499 wrong-value bug class). A control claims the flat slot L when
|
|
1366
|
+
// it needs L and the capture did NOT parent-qualify it ("first.strength"); a
|
|
1367
|
+
// PARENT-QUALIFIED key is always unambiguous and consumes its own slot, never
|
|
1368
|
+
// the flat one. Count the claimants of each flat slot up front so the emit
|
|
1369
|
+
// loop can FAIL CLOSED — refuse EVERY control competing for an over-subscribed
|
|
1370
|
+
// slot (top-level AND nested), leaving each to its node default — rather than
|
|
1371
|
+
// trust a value that might belong to a different control.
|
|
1372
|
+
const nestedByParent = new Map();
|
|
1373
|
+
for (const name of widgetNames) {
|
|
1374
|
+
const nested = nestedFor(name);
|
|
1375
|
+
if (nested)
|
|
1376
|
+
nestedByParent.set(name, nested);
|
|
1377
|
+
}
|
|
1378
|
+
// flat-slot claimant count, keyed by leaf name.
|
|
1379
|
+
const flatClaimants = new Map();
|
|
1380
|
+
const claimFlat = (leaf) => flatClaimants.set(leaf, (flatClaimants.get(leaf) ?? 0) + 1);
|
|
1087
1381
|
for (const name of widgetNames) {
|
|
1088
1382
|
if (name in wv)
|
|
1089
|
-
|
|
1383
|
+
claimFlat(name); // top-level widget claims its own flat slot
|
|
1384
|
+
}
|
|
1385
|
+
for (const [parent, nested] of nestedByParent) {
|
|
1386
|
+
for (const [nName, nSpec] of Object.entries(nested)) {
|
|
1387
|
+
if (!isPositionalWidgetSpec(nSpec))
|
|
1388
|
+
continue;
|
|
1389
|
+
// a nested leaf claims the flat slot only when it FALLS BACK to it
|
|
1390
|
+
// (no parent-qualified key present in the capture).
|
|
1391
|
+
if (`${parent}.${nName}` in wv)
|
|
1392
|
+
continue;
|
|
1393
|
+
if (nName in wv)
|
|
1394
|
+
claimFlat(nName);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
const flatAmbiguous = (leaf) => (flatClaimants.get(leaf) ?? 0) > 1;
|
|
1398
|
+
const ambiguityWarning = (target, leaf) => `Node ${nodeId} (${classType}): widget "${target}" could not be resolved from the name-keyed capture because the flat slot "${leaf}" is claimed by more than one control (multiple dynamic-combo nested leaves of the same name, or a nested leaf shadowing a top-level widget), so the single stored value can't be attributed to the right control. Left to the node default rather than risk assigning another control's value. Re-capture with a panel that emits parent-qualified widget names, or pass pack/path/graph.`;
|
|
1399
|
+
for (const name of widgetNames) {
|
|
1400
|
+
if (name in wv) {
|
|
1401
|
+
// A top-level widget whose flat slot is over-subscribed (a nested leaf of
|
|
1402
|
+
// the same name also falls back to it) is itself ambiguous — the stored
|
|
1403
|
+
// value might be the nested control's — so refuse it too, don't trust it.
|
|
1404
|
+
if (flatAmbiguous(name)) {
|
|
1405
|
+
warnings.push(ambiguityWarning(name, name));
|
|
1406
|
+
}
|
|
1407
|
+
else {
|
|
1408
|
+
inputs[name] = validateComboWidgetValue(name, wv[name], def, classType, nodeId, warnings);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
// V3 dynamic-combo nested inputs: the selected option adds required
|
|
1412
|
+
// sub-inputs whose live values are ALSO keyed in wv. Emit them as
|
|
1413
|
+
// "<combo>.<nested>" so the prompt matches the array-branch shape (ComfyUI
|
|
1414
|
+
// rebuilds the nested dict from these dotted keys). Each nested leaf is
|
|
1415
|
+
// validated against ITS OWN spec/name (mirrors the array branch — don't
|
|
1416
|
+
// regress #504/#407 asset preservation for nested combos).
|
|
1417
|
+
const nested = nestedByParent.get(name);
|
|
1418
|
+
if (nested) {
|
|
1419
|
+
for (const [nName, nSpec] of Object.entries(nested)) {
|
|
1420
|
+
if (!isPositionalWidgetSpec(nSpec))
|
|
1421
|
+
continue;
|
|
1422
|
+
const qualified = `${name}.${nName}`;
|
|
1423
|
+
// Preferred: the capture already parent-qualified the key — unambiguous.
|
|
1424
|
+
if (qualified in wv) {
|
|
1425
|
+
inputs[qualified] = validateComboValueForSpec(nName, wv[qualified], nSpec, classType, nodeId, warnings);
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
if (!(nName in wv))
|
|
1429
|
+
continue;
|
|
1430
|
+
// Flat leaf: safe ONLY when it is the SOLE claimant of that flat slot.
|
|
1431
|
+
if (flatAmbiguous(nName)) {
|
|
1432
|
+
warnings.push(ambiguityWarning(qualified, nName));
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
inputs[qualified] = validateComboValueForSpec(nName, wv[nName], nSpec, classType, nodeId, warnings);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1090
1438
|
}
|
|
1091
1439
|
}
|
|
1092
1440
|
else {
|
|
1093
1441
|
let widgetIdx = 0;
|
|
1094
|
-
|
|
1442
|
+
// A still-valid dynamic-combo option whose NESTED arity DRIFTED between save
|
|
1443
|
+
// and load (the option kept its key but added/removed nested inputs) can't be
|
|
1444
|
+
// detected up front — its saved nested slots were serialized against the OLD
|
|
1445
|
+
// arity, but we map using the CURRENT one, so any drift shifts the trailing
|
|
1446
|
+
// top-level widgets (e.g. a since-removed nested "multiplier" leaves its stale
|
|
1447
|
+
// value to land on the seed). The stale-parent guard below only catches a
|
|
1448
|
+
// REMOVED option (key absent). For a still-present option, a LEFTOVER saved
|
|
1449
|
+
// value after the whole positional mapping is the reliable drift signal — a
|
|
1450
|
+
// self-consistent layout consumes exactly. Track whether a still-valid dynamic
|
|
1451
|
+
// combo was processed and which widgets were mapped AFTER it, so on leftover we
|
|
1452
|
+
// can default those (shifted) widgets + warn instead of trusting the shift.
|
|
1453
|
+
let sawDynamicCombo = false;
|
|
1454
|
+
// Set when ANY combo hits a refusal (stale/empty-options parent, or a linked-
|
|
1455
|
+
// nested leaf). A refusal INTENTIONALLY stops mapping and leaves the tail
|
|
1456
|
+
// unconsumed, so the leftover it creates is EXPECTED — it does NOT prove that an
|
|
1457
|
+
// EARLIER combo drifted. Positionally, a valid layout ([first, first.note,
|
|
1458
|
+
// count, second→refuse]) and a drifted one are IDENTICAL once a refusal is
|
|
1459
|
+
// present, so acting on that leftover would DELETE valid earlier values (a real
|
|
1460
|
+
// regression). Therefore a refusal suppresses the leftover drift guard entirely
|
|
1461
|
+
// for this node; the drift guard fires only on a leftover with NO refusal, where
|
|
1462
|
+
// it unambiguously means the arity shrank.
|
|
1463
|
+
let refusalOccurred = false;
|
|
1464
|
+
const widgetsAfterDynamicCombo = [];
|
|
1465
|
+
// Dotted "<combo>.<leaf>" keys assigned positionally from a dynamic combo's
|
|
1466
|
+
// nested layout. On a proven arity drift (leftover) these are ALSO suspect — a
|
|
1467
|
+
// since-removed nested slot shifts every later nested value onto the wrong leaf
|
|
1468
|
+
// (old A=[obsolete, strength], current A=[strength], saved ["A",4,0.75] maps
|
|
1469
|
+
// strength=4, the obsolete value) — so the drift guard defaults them too, not
|
|
1470
|
+
// just the trailing top-level widgets.
|
|
1471
|
+
const nestedKeysFromDynamic = [];
|
|
1472
|
+
for (let nameIdx = 0; nameIdx < widgetNames.length; nameIdx++) {
|
|
1473
|
+
const name = widgetNames[nameIdx];
|
|
1095
1474
|
if (widgetIdx >= widgetValues.length)
|
|
1096
1475
|
break;
|
|
1476
|
+
// A widget mapped AFTER a still-valid dynamic combo is positionally
|
|
1477
|
+
// downstream of its (unverifiable) nested arity — record it for the
|
|
1478
|
+
// drift-leftover check below.
|
|
1479
|
+
if (sawDynamicCombo)
|
|
1480
|
+
widgetsAfterDynamicCombo.push(name);
|
|
1097
1481
|
const value = widgetValues[widgetIdx];
|
|
1098
1482
|
inputs[name] = value;
|
|
1099
1483
|
widgetIdx++;
|
|
@@ -1108,38 +1492,100 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
1108
1492
|
// Classic combo widget: if the saved value isn't one of the valid options
|
|
1109
1493
|
// (a stale UI-helper combo like Impact's "Select to add Wildcard", or a
|
|
1110
1494
|
// value from a node version with different options), fall back to the
|
|
1111
|
-
// default first option
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
|
|
1116
|
-
// server. When the declared model isn't installed there, comboOpts[0] is a
|
|
1117
|
-
// completely unrelated model — e.g. Krea 2's "krea2_turbo_fp8.safetensors"
|
|
1118
|
-
// silently became "flux-2-klein-9b.safetensors" (the first installed unet).
|
|
1119
|
-
// Silently swapping one model for another produces a misleading graph that
|
|
1120
|
-
// can't render the advertised workflow. For asset combos we KEEP the
|
|
1121
|
-
// declared value and warn, so it surfaces as an honest missing-asset error
|
|
1122
|
-
// instead of a wrong-model success.
|
|
1123
|
-
const comboOpts = Array.isArray(spec) ? spec[0] : undefined;
|
|
1124
|
-
if (Array.isArray(comboOpts) &&
|
|
1125
|
-
comboOpts.length > 0 &&
|
|
1126
|
-
!comboOpts.includes(value)) {
|
|
1127
|
-
const isAssetCombo = ASSET_WIDGET_NAMES.has(name) ||
|
|
1128
|
-
looksLikeAssetFilename(value) ||
|
|
1129
|
-
comboOpts.some((opt) => looksLikeAssetFilename(opt));
|
|
1130
|
-
if (isAssetCombo) {
|
|
1131
|
-
warnings.push(`Node ${nodeId} (${classType}): widget "${name}" value "${String(value)}" is not installed on the connected server — keeping the declared value so it surfaces as a missing-asset error rather than silently substituting "${String(comboOpts[0])}".`);
|
|
1132
|
-
// inputs[name] already holds the declared value; leave it untouched.
|
|
1133
|
-
}
|
|
1134
|
-
else {
|
|
1135
|
-
inputs[name] = comboOpts[0];
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1495
|
+
// default first option — UNLESS it's a genuine asset/upload selector, which
|
|
1496
|
+
// we preserve + warn (#407/#504). See validateComboWidgetValue for the full
|
|
1497
|
+
// classification (model-loader names + (classType,input) loader allowlist +
|
|
1498
|
+
// object_info upload flag — NOT a global name/extension heuristic).
|
|
1499
|
+
inputs[name] = validateComboWidgetValue(name, value, def, classType, nodeId, warnings);
|
|
1138
1500
|
const opts = (Array.isArray(spec)
|
|
1139
1501
|
? spec[1]
|
|
1140
1502
|
: undefined)?.options;
|
|
1141
|
-
|
|
1142
|
-
|
|
1503
|
+
// A V3 option's nested widgets can live under required OR optional; both
|
|
1504
|
+
// occupy positional widgets_values slots in serialization order (required
|
|
1505
|
+
// then optional). Merging them keeps the positional index aligned AND
|
|
1506
|
+
// ensures every nested leaf is validated — reading only `required` would
|
|
1507
|
+
// skip an optional leaf and mis-position every later widget.
|
|
1508
|
+
//
|
|
1509
|
+
// Look up the option by the RAW saved `value` (the selection the array was
|
|
1510
|
+
// serialized against), NOT the post-validation inputs[name]. If the saved
|
|
1511
|
+
// selection is stale (its option was removed/renamed), its ORIGINAL nested
|
|
1512
|
+
// arity is unrecoverable, so consuming zero nested slots while still
|
|
1513
|
+
// advancing the top-level index would silently misassign a leftover nested
|
|
1514
|
+
// value onto the trailing top-level widgets (e.g. a removed option's nested
|
|
1515
|
+
// strength overwriting the user's seed). The stale-parent guard below REFUSES
|
|
1516
|
+
// that case (warn + stop) instead of guessing.
|
|
1517
|
+
// A V3 dynamic combo must be identified by its EXPLICIT type string
|
|
1518
|
+
// (spec[0] === "COMFY_DYNAMICCOMBO_V3"), NOT by whether its current options
|
|
1519
|
+
// list happens to contain keyed objects. An EMPTY current options list
|
|
1520
|
+
// (["COMFY_DYNAMICCOMBO_V3", {options: []}]) has NO keyed entries, so the
|
|
1521
|
+
// object-key heuristic alone reports false and the stale-parent guard below
|
|
1522
|
+
// never fires — re-opening the exact silent-positional-shift hole: with an
|
|
1523
|
+
// empty options list every saved selection is stale, and consuming zero
|
|
1524
|
+
// nested slots while advancing the top-level index misassigns a leftover
|
|
1525
|
+
// nested value (e.g. a removed option's strength=0.75) onto the next widget
|
|
1526
|
+
// (e.g. a seed). Keying off the type covers the empty-options case too.
|
|
1527
|
+
//
|
|
1528
|
+
// The object-key heuristic is retained as a fallback for any V3 dynamic list
|
|
1529
|
+
// that carries keyed {key, inputs} entries without the literal type marker.
|
|
1530
|
+
// A classic string-option COMBO (spec[0] is the ["a","b"] options array, not
|
|
1531
|
+
// the "COMFY_DYNAMICCOMBO_V3" string) matches NEITHER branch, so it must NOT
|
|
1532
|
+
// trip the stale-parent guard below (which would wrongly refuse a perfectly
|
|
1533
|
+
// valid classic-combo value).
|
|
1534
|
+
const specType = Array.isArray(spec) ? spec[0] : undefined;
|
|
1535
|
+
const isDynamicComboType = specType === "COMFY_DYNAMICCOMBO_V3";
|
|
1536
|
+
const isDynamicOptions = isDynamicComboType ||
|
|
1537
|
+
(Array.isArray(opts) &&
|
|
1538
|
+
opts.some((o) => o != null &&
|
|
1539
|
+
typeof o === "object" &&
|
|
1540
|
+
!Array.isArray(o) &&
|
|
1541
|
+
"key" in o));
|
|
1542
|
+
const savedOption = Array.isArray(opts)
|
|
1543
|
+
? opts.find((o) => o?.key === value)
|
|
1544
|
+
: undefined;
|
|
1545
|
+
// P0 (#517): STALE dynamic-combo parent. The saved selection is NOT among
|
|
1546
|
+
// the current dynamic options (the option was removed/renamed, or was
|
|
1547
|
+
// substituted above). The saved array was serialized against the OLD
|
|
1548
|
+
// option's layout: [parent, <OLD option's nested slots>, ...trailing
|
|
1549
|
+
// top-level widgets...]. The OLD nested arity is unknowable from THIS schema,
|
|
1550
|
+
// so the pre-fix code consumed ZERO nested slots while still advancing the
|
|
1551
|
+
// top-level index — silently mapping a leftover stale nested value onto the
|
|
1552
|
+
// next widget (e.g. a removed option's strength=0.75 landing on the user's
|
|
1553
|
+
// seed, discarding the real 42 with no warning).
|
|
1554
|
+
//
|
|
1555
|
+
// The saved array was serialized as
|
|
1556
|
+
// [parent, <removed option's nested slots>, ...trailing top-level widgets...]
|
|
1557
|
+
// and we CANNOT reconstruct where the orphaned-nested block ends: NEITHER the
|
|
1558
|
+
// removed option's nested arity NOR the trailing widgets' saved-slot count is
|
|
1559
|
+
// recoverable. The trailing count is doubly unknown — a nested seed carries a
|
|
1560
|
+
// variable control_after_generate phantom, a trailing dynamic combo adds a
|
|
1561
|
+
// value-dependent number of slots, AND the current schema may have added or
|
|
1562
|
+
// removed trailing widgets since the graph was saved. A surplus arithmetic
|
|
1563
|
+
// (values-left vs current trailing-widget-count) silently mis-attributes
|
|
1564
|
+
// orphaned slots whenever any of those drift (e.g. a schema-added trailing
|
|
1565
|
+
// widget makes surplus cancel to zero while an orphan still sits before the
|
|
1566
|
+
// seed). Because NO positional reconstruction is provably safe, REFUSE: warn
|
|
1567
|
+
// and stop positional mapping here so an orphaned nested value can NEVER be
|
|
1568
|
+
// silently mapped onto a later top-level widget (such as a seed). The
|
|
1569
|
+
// remaining required inputs then default-fill below. The parent itself was
|
|
1570
|
+
// already substituted + warned via validateComboWidgetValue above.
|
|
1571
|
+
if (isDynamicOptions &&
|
|
1572
|
+
savedOption === undefined &&
|
|
1573
|
+
widgetIdx < widgetValues.length) {
|
|
1574
|
+
warnings.push(`Node ${nodeId} (${classType}): widget "${name}" saved selection "${String(value)}" is no longer an available option, so the removed option's nested-input layout can't be reconstructed. The remaining ${widgetValues.length - widgetIdx} saved widget value(s) are left to their defaults rather than risk silently assigning a stale nested value to a later widget (such as a seed).`);
|
|
1575
|
+
// A refusal's leftover is expected and can't be attributed to earlier drift,
|
|
1576
|
+
// so suppress the drift guard for this node (see refusalOccurred note).
|
|
1577
|
+
refusalOccurred = true;
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
// Still-valid dynamic combo (option key present): its nested arity is trusted
|
|
1581
|
+
// from the CURRENT schema but can't be verified against the save — flag it so
|
|
1582
|
+
// the post-loop leftover check can detect a drift-induced tail shift.
|
|
1583
|
+
if (isDynamicOptions && savedOption !== undefined)
|
|
1584
|
+
sawDynamicCombo = true;
|
|
1585
|
+
const selectedInputs = savedOption?.inputs;
|
|
1586
|
+
const nested = selectedInputs &&
|
|
1587
|
+
(selectedInputs.required || selectedInputs.optional)
|
|
1588
|
+
? { ...selectedInputs.required, ...selectedInputs.optional }
|
|
1143
1589
|
: undefined;
|
|
1144
1590
|
if (nested) {
|
|
1145
1591
|
// V3 dynamic-combo nested inputs are keyed with the combo's id as a
|
|
@@ -1149,16 +1595,105 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
1149
1595
|
// non-widget nested inputs (AUTOGROW lists like Nano Banana 2's
|
|
1150
1596
|
// "images", or IMAGE/link types) have no saved widget value, so skipping
|
|
1151
1597
|
// them keeps the positional mapping aligned.
|
|
1598
|
+
let refuseTail = false;
|
|
1152
1599
|
for (const [nName, nSpec] of Object.entries(nested)) {
|
|
1153
|
-
if (widgetIdx >= widgetValues.length)
|
|
1154
|
-
break;
|
|
1155
1600
|
if (!isPositionalWidgetSpec(nSpec))
|
|
1156
1601
|
continue;
|
|
1157
|
-
|
|
1602
|
+
// A linked (converted-to-input) nested leaf's value arrives via the link
|
|
1603
|
+
// loop below, NOT the saved array. Other/older frontend versions, though,
|
|
1604
|
+
// RETAIN a serialized placeholder for a converted widget, so the leaf may
|
|
1605
|
+
// OR may not still occupy a widgets_values slot — and that cannot be told
|
|
1606
|
+
// apart positionally. If a placeholder IS present and we skip it without
|
|
1607
|
+
// consuming, the retained value shifts onto the NEXT top-level widget: a
|
|
1608
|
+
// seed silently becomes the placeholder (e.g. widgets_values
|
|
1609
|
+
// [parent, 4(linked-nested placeholder), 424242(seed), "fixed"] would map
|
|
1610
|
+
// seed=4). When trailing saved values remain, the alignment is
|
|
1611
|
+
// unrecoverable, so REFUSE the tail — the same safe pattern as the
|
|
1612
|
+
// stale-parent guard above: warn + leave the remaining top-level widgets
|
|
1613
|
+
// to their defaults rather than risk a silent shift onto a later widget.
|
|
1614
|
+
// (If NO trailing values remain there is nothing to misassign, so the
|
|
1615
|
+
// link simply supplies the leaf and the loop ends.)
|
|
1616
|
+
if (linkedInputNames.has(`${name}.${nName}`)) {
|
|
1617
|
+
if (widgetIdx < widgetValues.length) {
|
|
1618
|
+
warnings.push(`Node ${nodeId} (${classType}): widget "${name}" has a converted-to-input (linked) nested widget "${name}.${nName}" whose widgets_values slot can't be positionally resolved (frontend versions differ on whether a converted widget keeps a placeholder slot), so the remaining ${widgetValues.length - widgetIdx} saved widget value(s) are left to their defaults rather than risk silently assigning a stale value to a later widget (such as a seed).`);
|
|
1619
|
+
refuseTail = true;
|
|
1620
|
+
break;
|
|
1621
|
+
}
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
if (widgetIdx >= widgetValues.length)
|
|
1625
|
+
break;
|
|
1626
|
+
// Nested leaf values bypass the top-level widget validation, so validate
|
|
1627
|
+
// each against ITS OWN leaf spec/name (P1-B). Asset classification keys
|
|
1628
|
+
// off the leaf name + loader/upload allowlist (don't regress #504/#407);
|
|
1629
|
+
// an out-of-list nested enum (e.g. aspect_ratio "4:3") substitutes+warns.
|
|
1630
|
+
inputs[`${name}.${nName}`] = validateComboValueForSpec(nName, widgetValues[widgetIdx], nSpec, classType, nodeId, warnings);
|
|
1631
|
+
nestedKeysFromDynamic.push(`${name}.${nName}`);
|
|
1158
1632
|
widgetIdx++;
|
|
1633
|
+
// A nested seed-type / control_after_generate leaf carries a phantom
|
|
1634
|
+
// "fixed"/"randomize" value right after it (same as top-level seeds), so
|
|
1635
|
+
// skip that slot or every following widget shifts by one.
|
|
1636
|
+
if (specHasControlAfterGenerate(nName, nSpec) &&
|
|
1637
|
+
widgetIdx < widgetValues.length) {
|
|
1638
|
+
widgetIdx++;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
// A linked nested leaf with trailing saved values made the alignment
|
|
1642
|
+
// ambiguous — stop mapping the remaining top-level widgets (they
|
|
1643
|
+
// default-fill below) so no retained placeholder can shift onto a seed.
|
|
1644
|
+
// This refusal's leftover is expected, so suppress the drift guard for this
|
|
1645
|
+
// node (see refusalOccurred note) — otherwise it would delete valid earlier
|
|
1646
|
+
// widgets/leaves mapped before this refusing combo.
|
|
1647
|
+
if (refuseTail) {
|
|
1648
|
+
refusalOccurred = true;
|
|
1649
|
+
break;
|
|
1159
1650
|
}
|
|
1160
1651
|
}
|
|
1161
1652
|
}
|
|
1653
|
+
// Drift guard: a LEFTOVER saved value after a still-valid dynamic combo means
|
|
1654
|
+
// its nested arity SHRANK since the save (see the sawDynamicCombo note above) —
|
|
1655
|
+
// the current option consumes fewer nested slots than the save held, leaving a
|
|
1656
|
+
// provable leftover. A self-consistent layout consumes exactly; a leftover proves
|
|
1657
|
+
// the positional mapping after (and WITHIN) the drifted combo is shifted: both the
|
|
1658
|
+
// trailing top-level widgets AND the nested dotted leaves may hold a value that
|
|
1659
|
+
// belongs to a since-removed slot (e.g. old A=[obsolete, strength], current
|
|
1660
|
+
// A=[strength], saved ["A",4,0.75] maps strength=4 — the obsolete value). Default
|
|
1661
|
+
// BOTH sets (delete so top-level widgets default-fill below and nested leaves fall
|
|
1662
|
+
// to their runtime option defaults) and warn, rather than trust a silently shifted
|
|
1663
|
+
// value on a later widget (such as a seed) OR a nested leaf. This fires on ANY
|
|
1664
|
+
// leftover — even with no trailing top-level widget — so a shifted nested value
|
|
1665
|
+
// can't survive. (Mirrors the stale-parent + linked-leaf refusals.)
|
|
1666
|
+
//
|
|
1667
|
+
// LIMITATION (accepted, BROAD): only the direction that nets to a LEFTOVER is
|
|
1668
|
+
// catchable. Any nested-arity drift that nets to EXACT consumption is positionally
|
|
1669
|
+
// INDISTINGUISHABLE from a legitimate layout and CANNOT be caught from
|
|
1670
|
+
// widgets_values alone. That covers not just arity-GROWTH (option gained nested
|
|
1671
|
+
// inputs) but ALSO arity-SHRINK masked by a newly-added TOP-LEVEL widget (a
|
|
1672
|
+
// removed nested slot canceled by an added trailing widget → exact), and the
|
|
1673
|
+
// multi-combo variants of both. Catching any of these would require refusing ALL
|
|
1674
|
+
// trailing widgets after every dynamic combo — which regresses the canonical Nano
|
|
1675
|
+
// Banana multi-nested node ([prompt, model, aspect_ratio, resolution,
|
|
1676
|
+
// thinking_level, seed, control, response_modalities], an existing passing test).
|
|
1677
|
+
// So this guard catches only the provable (leftover) direction.
|
|
1678
|
+
//
|
|
1679
|
+
// ALSO ACCEPTED (undecidable when a REFUSAL is present): a refusal (linked-leaf
|
|
1680
|
+
// or stale/empty-options parent) INTENTIONALLY stops mapping, so the leftover it
|
|
1681
|
+
// creates is EXPECTED and cannot be attributed to an earlier combo's drift — a
|
|
1682
|
+
// valid layout ([first, first.note, count, second→refuse]) and a drifted one are
|
|
1683
|
+
// positionally IDENTICAL once a refusal is present. Acting on that leftover would
|
|
1684
|
+
// DELETE valid earlier values, so `refusalOccurred` suppresses this guard for the
|
|
1685
|
+
// whole node. (Any earlier drift then falls to the accepted exact-consumption
|
|
1686
|
+
// class above — a refusal's leftover is not a reliable drift signal.) The guard
|
|
1687
|
+
// therefore fires ONLY on a leftover with NO refusal, where it unambiguously means
|
|
1688
|
+
// the arity shrank.
|
|
1689
|
+
if (sawDynamicCombo && !refusalOccurred && widgetIdx < widgetValues.length) {
|
|
1690
|
+
for (const wn of widgetsAfterDynamicCombo)
|
|
1691
|
+
delete inputs[wn];
|
|
1692
|
+
for (const nk of nestedKeysFromDynamic)
|
|
1693
|
+
delete inputs[nk];
|
|
1694
|
+
const defaulted = [...nestedKeysFromDynamic, ...widgetsAfterDynamicCombo];
|
|
1695
|
+
warnings.push(`Node ${nodeId} (${classType}): a dynamic-combo option's nested-input layout appears to have changed since this workflow was saved (${widgetValues.length - widgetIdx} saved widget value(s) remain unmapped), so its nested leaves and the widget(s) positioned after it${defaulted.length ? ` (${defaulted.join(", ")})` : ""} are left to their defaults rather than risk a silently shifted value on a later widget (such as a seed) or nested leaf.`);
|
|
1696
|
+
}
|
|
1162
1697
|
}
|
|
1163
1698
|
// Nodes with a CUSTOM serialized-widget layout (properties.has_serialized_properties
|
|
1164
1699
|
// — WhatDreamsCost's LTXDirector/LTXSequencer, kijai's PromptRelay) pack extra or
|
|
@@ -1174,7 +1709,7 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
1174
1709
|
if (serializedProps?.has_serialized_properties === true) {
|
|
1175
1710
|
for (const name of widgetNames) {
|
|
1176
1711
|
if (name in serializedProps)
|
|
1177
|
-
inputs[name] = serializedProps[name];
|
|
1712
|
+
inputs[name] = validateComboWidgetValue(name, serializedProps[name], def, classType, nodeId, warnings);
|
|
1178
1713
|
}
|
|
1179
1714
|
}
|
|
1180
1715
|
// Fill any required input not covered by widgets_values or a link with its
|
|
@@ -1200,8 +1735,14 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
1200
1735
|
else {
|
|
1201
1736
|
dflt = config?.default;
|
|
1202
1737
|
}
|
|
1203
|
-
if (dflt !== undefined)
|
|
1204
|
-
|
|
1738
|
+
if (dflt !== undefined) {
|
|
1739
|
+
// A schema `default` can itself be out-of-list (custom-node combo drift,
|
|
1740
|
+
// e.g. sampler_name default "removed_sampler" not in its own options).
|
|
1741
|
+
// Route the derived default through the same choke-point so it can't emit
|
|
1742
|
+
// a value ComfyUI rejects — first-option substitution for a true enum,
|
|
1743
|
+
// leaf-name asset preservation for a loader/upload selector.
|
|
1744
|
+
inputs[name] = validateComboValueForSpec(name, dflt, spec, classType, nodeId, warnings);
|
|
1745
|
+
}
|
|
1205
1746
|
}
|
|
1206
1747
|
// Map linked inputs from node's inputs array (bypass/mute resolved)
|
|
1207
1748
|
if (node.inputs) {
|
|
@@ -1215,8 +1756,18 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
1215
1756
|
const srcNode = link ? nodesById.get(link.sourceNodeId) : undefined;
|
|
1216
1757
|
if (srcNode?.type === "PrimitiveNode") {
|
|
1217
1758
|
const val = srcNode.widgets_values?.[0];
|
|
1218
|
-
|
|
1219
|
-
|
|
1759
|
+
// A linked PrimitiveNode literal bypasses the widget-value validation
|
|
1760
|
+
// above (widgets are processed first, links after), so route it through
|
|
1761
|
+
// the same choke-point (P1-A). A stale LOADER asset is preserved+warned;
|
|
1762
|
+
// a true out-of-list ENUM (e.g. sampler_name "removed_sampler") is
|
|
1763
|
+
// substituted+warned rather than reaching the API and being rejected.
|
|
1764
|
+
// input.name may be a dynamic-combo dotted "<combo>.<leaf>" key, so
|
|
1765
|
+
// resolve the leaf spec/name first — a top-level lookup would miss it
|
|
1766
|
+
// and let the value through unvalidated.
|
|
1767
|
+
if (val !== undefined) {
|
|
1768
|
+
const { leafName, spec } = resolveWidgetSpec(def, input.name, inputs);
|
|
1769
|
+
inputs[input.name] = validateComboValueForSpec(leafName, val, spec, classType, nodeId, warnings);
|
|
1770
|
+
}
|
|
1220
1771
|
continue;
|
|
1221
1772
|
}
|
|
1222
1773
|
const resolved = resolveSource(input.link);
|
|
@@ -1224,6 +1775,145 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
1224
1775
|
inputs[input.name] = [resolved.id, resolved.slot];
|
|
1225
1776
|
}
|
|
1226
1777
|
}
|
|
1778
|
+
// Final dependency-aware pass over V3 dynamic-combo dotted "<parent>.<leaf>"
|
|
1779
|
+
// inputs: a link/PrimitiveNode override above can change the PARENT selection
|
|
1780
|
+
// AFTER its nested leaves were validated against the ORIGINAL option (e.g.
|
|
1781
|
+
// positional emits model="A"+model.choice="a1", then a primitive overrides
|
|
1782
|
+
// model="B"). Re-anchor every dotted leaf to the FINAL parent value — drop a
|
|
1783
|
+
// leaf that the selected option no longer defines, and revalidate the rest
|
|
1784
|
+
// against the leaf's real spec — so no leaf reaches the prompt validated
|
|
1785
|
+
// against the wrong option.
|
|
1786
|
+
for (const key of Object.keys(inputs)) {
|
|
1787
|
+
const dot = key.indexOf(".");
|
|
1788
|
+
if (dot < 0)
|
|
1789
|
+
continue;
|
|
1790
|
+
const parent = key.slice(0, dot);
|
|
1791
|
+
// Orphaned dotted leaf: its dynamic parent isn't in the prompt (e.g. an
|
|
1792
|
+
// optional parent left unset while the leaf was linked). There's no option
|
|
1793
|
+
// context to validate against, and the leaf is meaningless without its
|
|
1794
|
+
// parent — ComfyUI has no selected option so "<parent>.<leaf>" is an unknown
|
|
1795
|
+
// input it hard-rejects. Default-DENY: drop the leaf UNCONDITIONALLY,
|
|
1796
|
+
// whether it's an un-parented literal OR a link ref. Keeping a link ref here
|
|
1797
|
+
// (the previous behavior) still emitted an orphan the server would reject.
|
|
1798
|
+
if (!(parent in inputs)) {
|
|
1799
|
+
delete inputs[key];
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
const leafVal = inputs[key];
|
|
1803
|
+
// A leaf can itself be a LINK REF ([id, slot]) — a nested widget converted to
|
|
1804
|
+
// input and wired to a real node. We can't membership-check its runtime
|
|
1805
|
+
// value, but we CAN check whether the FINAL selected option even DEFINES the
|
|
1806
|
+
// leaf: an array leaf under an option that doesn't define it is just as
|
|
1807
|
+
// orphaned as a literal one, and ComfyUI hard-rejects the unknown input. So
|
|
1808
|
+
// link-ref leaves are re-anchored too (previously they short-circuited here
|
|
1809
|
+
// with an unconditional `continue`, leaking an orphan under an incompatible
|
|
1810
|
+
// parent).
|
|
1811
|
+
const leafIsLink = Array.isArray(leafVal);
|
|
1812
|
+
// Parent fed by a real (non-Primitive) link → the SELECTED option is only
|
|
1813
|
+
// known at runtime, so a leaf saved/linked under one option may be wrong
|
|
1814
|
+
// for the resolved one. Keep it only if it validates under EVERY option
|
|
1815
|
+
// that defines the leaf; otherwise drop it (ComfyUI supplies the runtime
|
|
1816
|
+
// option's default). This prevents a wrong-option literal (e.g. option-A
|
|
1817
|
+
// "a1" left on a parent that resolves to option B) reaching the prompt.
|
|
1818
|
+
if (Array.isArray(inputs[parent])) {
|
|
1819
|
+
const leaf = key.slice(dot + 1);
|
|
1820
|
+
const pSpec = def.input?.required?.[parent] ??
|
|
1821
|
+
def.input?.optional?.[parent];
|
|
1822
|
+
const pOpts = (Array.isArray(pSpec)
|
|
1823
|
+
? pSpec[1]
|
|
1824
|
+
: undefined)?.options;
|
|
1825
|
+
// The resolved option is unknowable at convert time, so KEEP the leaf only
|
|
1826
|
+
// if it stays valid WHICHEVER option resolves — i.e. it must be defined by
|
|
1827
|
+
// EVERY option (definedEverywhere). If ANY option omits the leaf, that
|
|
1828
|
+
// runtime resolution would orphan it (ComfyUI rejects the unknown nested
|
|
1829
|
+
// input), so default-DENY and drop it — the option's default is supplied at
|
|
1830
|
+
// runtime. This holds for a LINK-REF leaf (whose runtime value we can't
|
|
1831
|
+
// membership-check — definition is all we can verify) AND a LITERAL leaf,
|
|
1832
|
+
// which must ALSO be a strict in-list MEMBER of every option. Membership
|
|
1833
|
+
// uses a strict includes() — NOT validateComboValueForSpec, whose
|
|
1834
|
+
// asset/empty-combo PRESERVE path would keep an out-of-list literal and
|
|
1835
|
+
// swallow its warning.
|
|
1836
|
+
let anyOption = false;
|
|
1837
|
+
let definedEverywhere = true;
|
|
1838
|
+
let definedSomewhere = false;
|
|
1839
|
+
// For a LITERAL leaf: it must be an in-list MEMBER of every option that
|
|
1840
|
+
// DEFINES it as a combo. Options that OMIT the leaf are skipped (not failed)
|
|
1841
|
+
// so a leaf defined by only some options is still assessed on the options
|
|
1842
|
+
// that do define it. A scalar/non-combo defining option (getComboOptions
|
|
1843
|
+
// undefined) can't fail membership — it's a normal required input.
|
|
1844
|
+
let memberWhereDefined = true;
|
|
1845
|
+
if (Array.isArray(pOpts)) {
|
|
1846
|
+
for (const o of pOpts) {
|
|
1847
|
+
anyOption = true;
|
|
1848
|
+
const s = o?.inputs?.required?.[leaf] ?? o?.inputs?.optional?.[leaf];
|
|
1849
|
+
if (s === undefined) {
|
|
1850
|
+
// This option omits the leaf entirely (orphan if IT resolves).
|
|
1851
|
+
definedEverywhere = false;
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
definedSomewhere = true;
|
|
1855
|
+
if (leafIsLink)
|
|
1856
|
+
continue; // link ref: definition is all we can check
|
|
1857
|
+
const optList = getComboOptions(s);
|
|
1858
|
+
if (Array.isArray(optList) && !optList.includes(leafVal)) {
|
|
1859
|
+
memberWhereDefined = false;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
// Decision, split by leaf kind:
|
|
1864
|
+
// - LINK-REF leaf: its runtime value is unknowable, so the only defense
|
|
1865
|
+
// against orphaning it under an option that omits the leaf is strict
|
|
1866
|
+
// default-DENY — keep only if EVERY option defines it. Dropping a link
|
|
1867
|
+
// ref discards no user-authored scalar (the connection's value is
|
|
1868
|
+
// runtime), so the strict rule is cheap and safe.
|
|
1869
|
+
// - LITERAL leaf: it is a concrete USER VALUE. Dropping it just because
|
|
1870
|
+
// SOME option omits the leaf silently discards the user's input on a
|
|
1871
|
+
// resolution that may never occur (#517 P0-B: option A defines
|
|
1872
|
+
// strength=0.75, option B omits it — the strict rule deleted 0.75 even
|
|
1873
|
+
// though A is the live selection). A deterministic parent never reaches
|
|
1874
|
+
// this branch (a Primitive/positional parent is a STRING, re-anchored
|
|
1875
|
+
// against its RESOLVED option in the non-array path above), so here the
|
|
1876
|
+
// selection is genuinely unknowable — preserve a literal that at least
|
|
1877
|
+
// one option DEFINES, provided it stays a valid member wherever a
|
|
1878
|
+
// defining option enumerates options. A wrong-option literal (defined by
|
|
1879
|
+
// all options but out-of-list for one — the test-1216 case) still fails
|
|
1880
|
+
// memberWhereDefined and is dropped, so no invalid literal reaches the
|
|
1881
|
+
// prompt.
|
|
1882
|
+
const drop = !anyOption
|
|
1883
|
+
? true
|
|
1884
|
+
: leafIsLink
|
|
1885
|
+
? !definedEverywhere
|
|
1886
|
+
: !definedSomewhere || !memberWhereDefined;
|
|
1887
|
+
if (drop) {
|
|
1888
|
+
// A dropped LINK-REF leaf is a real connection the user wired — deleting it
|
|
1889
|
+
// silently would make the parent's runtime option fall back to its default
|
|
1890
|
+
// with no trace (FIX 2, #517). Keep the strict default-deny (safest against
|
|
1891
|
+
// orphaning under an option that omits the leaf), but WARN so the removed
|
|
1892
|
+
// link + parent are surfaced rather than silently discarded.
|
|
1893
|
+
if (leafIsLink) {
|
|
1894
|
+
warnings.push(`Node ${nodeId} (${classType}): the linked nested input "${key}" was dropped — its dynamic parent "${parent}" is fed by a runtime link (resolved option unknown) and not every option defines "${leaf}", so keeping the connection could orphan it under an option that omits the leaf. "${leaf}" will use the resolved option's default instead of the wired link.`);
|
|
1895
|
+
}
|
|
1896
|
+
delete inputs[key];
|
|
1897
|
+
}
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
const { leafName, spec } = resolveWidgetSpec(def, key, inputs);
|
|
1901
|
+
if (spec === undefined) {
|
|
1902
|
+
// The final selected option does not define this leaf — it belongs to the
|
|
1903
|
+
// superseded option and would be an unknown input on the current one. Drop
|
|
1904
|
+
// it whether it's a literal OR a real-link ref (both orphan under the final
|
|
1905
|
+
// option; a link ref is not exempt — the previous `continue` above leaked
|
|
1906
|
+
// exactly this case).
|
|
1907
|
+
delete inputs[key];
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
// Leaf IS defined by the final selected option. A real-link ref into a defined
|
|
1911
|
+
// leaf is a valid connection whose runtime value ComfyUI validates — keep it
|
|
1912
|
+
// as-is (there's no literal to re-validate here).
|
|
1913
|
+
if (leafIsLink)
|
|
1914
|
+
continue;
|
|
1915
|
+
inputs[key] = validateComboValueForSpec(leafName, leafVal, spec, classType, nodeId, warnings);
|
|
1916
|
+
}
|
|
1227
1917
|
// rgthree "Power Lora Loader" stores its loras in widgets_values as a list of
|
|
1228
1918
|
// {on, lora, strength, strengthTwo} objects, NOT as named inputs — its Python
|
|
1229
1919
|
// reads them from kwargs keyed lora_1, lora_2, …. Translate them here so the
|
|
@@ -1324,6 +2014,11 @@ export function convertUiToApi(ui, objectInfo) {
|
|
|
1324
2014
|
const nodeCount = Object.keys(workflow).length;
|
|
1325
2015
|
const skipped = expanded.nodes.length - nodeCount;
|
|
1326
2016
|
logger.info(`Converted UI workflow: ${nodeCount} nodes (${skipped} skipped)`);
|
|
1327
|
-
|
|
2017
|
+
// De-duplicate identical warnings. A value can legitimately be validated twice
|
|
2018
|
+
// (positional pass, then the final dynamic-parent re-anchor pass) and produce
|
|
2019
|
+
// the same message; identical strings already embed node id + input, so exact
|
|
2020
|
+
// duplicates are pure noise. Distinct nodes/inputs keep their own warnings.
|
|
2021
|
+
const dedupedWarnings = [...new Set(warnings)];
|
|
2022
|
+
return { workflow, warnings: dedupedWarnings };
|
|
1328
2023
|
}
|
|
1329
2024
|
//# sourceMappingURL=workflow-converter.js.map
|