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.
Files changed (252) hide show
  1. package/README.md +2 -2
  2. package/dist/comfyui/client.js +46 -6
  3. package/dist/comfyui/client.js.map +1 -1
  4. package/dist/config.js +15 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/orchestrator/error-text.js +29 -1
  7. package/dist/orchestrator/error-text.js.map +1 -1
  8. package/dist/orchestrator/error-text.test.js +28 -0
  9. package/dist/orchestrator/error-text.test.js.map +1 -1
  10. package/dist/orchestrator/index.js +331 -56
  11. package/dist/orchestrator/index.js.map +1 -1
  12. package/dist/orchestrator/panel-agent.js +122 -31
  13. package/dist/orchestrator/panel-agent.js.map +1 -1
  14. package/dist/orchestrator/panel-tools.js +1256 -198
  15. package/dist/orchestrator/panel-tools.js.map +1 -1
  16. package/dist/orchestrator/ready-banner.js +70 -0
  17. package/dist/orchestrator/ready-banner.js.map +1 -0
  18. package/dist/orchestrator/session-store.js +163 -28
  19. package/dist/orchestrator/session-store.js.map +1 -1
  20. package/dist/services/api-nodes.js +5 -7
  21. package/dist/services/api-nodes.js.map +1 -1
  22. package/dist/services/download-cache.js +126 -18
  23. package/dist/services/download-cache.js.map +1 -1
  24. package/dist/services/download-jobs.js +463 -14
  25. package/dist/services/download-jobs.js.map +1 -1
  26. package/dist/services/download-progress.js +252 -3
  27. package/dist/services/download-progress.js.map +1 -1
  28. package/dist/services/env-capabilities.js +80 -9
  29. package/dist/services/env-capabilities.js.map +1 -1
  30. package/dist/services/generate-audio.js +16 -0
  31. package/dist/services/generate-audio.js.map +1 -1
  32. package/dist/services/local-models-fallback.js +7 -3
  33. package/dist/services/local-models-fallback.js.map +1 -1
  34. package/dist/services/manifest.js +180 -65
  35. package/dist/services/manifest.js.map +1 -1
  36. package/dist/services/model-resolver.js +267 -11
  37. package/dist/services/model-resolver.js.map +1 -1
  38. package/dist/services/node-management.js +179 -45
  39. package/dist/services/node-management.js.map +1 -1
  40. package/dist/services/output-dir.js +33 -3
  41. package/dist/services/output-dir.js.map +1 -1
  42. package/dist/services/panel-secrets.js +19 -7
  43. package/dist/services/panel-secrets.js.map +1 -1
  44. package/dist/services/process-control.js +107 -8
  45. package/dist/services/process-control.js.map +1 -1
  46. package/dist/services/queue-monitor.js +45 -2
  47. package/dist/services/queue-monitor.js.map +1 -1
  48. package/dist/services/queue-monitor.stall.test.js +78 -0
  49. package/dist/services/queue-monitor.stall.test.js.map +1 -0
  50. package/dist/services/secure-bridge.js +84 -0
  51. package/dist/services/secure-bridge.js.map +1 -1
  52. package/dist/services/storage/azure-blob.js +8 -3
  53. package/dist/services/storage/azure-blob.js.map +1 -1
  54. package/dist/services/storage/index.js +3 -3
  55. package/dist/services/storage/index.js.map +1 -1
  56. package/dist/services/storage/s3.js +9 -3
  57. package/dist/services/storage/s3.js.map +1 -1
  58. package/dist/services/ui-bridge.js +341 -24
  59. package/dist/services/ui-bridge.js.map +1 -1
  60. package/dist/services/workflow-converter.js +767 -72
  61. package/dist/services/workflow-converter.js.map +1 -1
  62. package/dist/services/workflow-deps.js +20 -1
  63. package/dist/services/workflow-deps.js.map +1 -1
  64. package/dist/services/workflow-lock.js +127 -27
  65. package/dist/services/workflow-lock.js.map +1 -1
  66. package/dist/services/workflow-target-store.js +7 -3
  67. package/dist/services/workflow-target-store.js.map +1 -1
  68. package/dist/services/workspace-env.js +48 -0
  69. package/dist/services/workspace-env.js.map +1 -1
  70. package/dist/tools/diagnostics.js +10 -1
  71. package/dist/tools/diagnostics.js.map +1 -1
  72. package/dist/tools/generate-audio.js +44 -0
  73. package/dist/tools/generate-audio.js.map +1 -1
  74. package/dist/tools/model-explorer.js +85 -5
  75. package/dist/tools/model-explorer.js.map +1 -1
  76. package/dist/tools/model-extras.js +24 -4
  77. package/dist/tools/model-extras.js.map +1 -1
  78. package/dist/tools/model-management.js +124 -14
  79. package/dist/tools/model-management.js.map +1 -1
  80. package/dist/tools/report-issue.js +220 -66
  81. package/dist/tools/report-issue.js.map +1 -1
  82. package/dist/tools/report-issue.test.js +223 -83
  83. package/dist/tools/report-issue.test.js.map +1 -1
  84. package/dist/tools/skills-access.js +7 -2
  85. package/dist/tools/skills-access.js.map +1 -1
  86. package/dist/tools/template-schema.js +5 -5
  87. package/dist/tools/template-schema.js.map +1 -1
  88. package/dist/tools/workflow-compose.js +18 -3
  89. package/dist/tools/workflow-compose.js.map +1 -1
  90. package/dist/tools/workflow-visualize.js +1 -1
  91. package/dist/tools/workflow-visualize.js.map +1 -1
  92. package/package.json +1 -1
  93. package/packs/anima/install-runpod.sh +0 -1
  94. package/packs/anima/install-windows.bat +0 -1
  95. package/packs/anima/manifest.yaml +0 -1
  96. package/packs/anima-img2img/install-runpod.sh +0 -1
  97. package/packs/anima-img2img/install-windows.bat +0 -1
  98. package/packs/anima-img2img/manifest.yaml +0 -1
  99. package/packs/anima-inpaint/install-runpod.sh +0 -1
  100. package/packs/anima-inpaint/install-windows.bat +0 -1
  101. package/packs/anima-inpaint/manifest.yaml +0 -1
  102. package/packs/anima-txt2img/install-runpod.sh +0 -1
  103. package/packs/anima-txt2img/install-windows.bat +0 -1
  104. package/packs/anima-txt2img/manifest.yaml +0 -1
  105. package/packs/artokun-flow/install-runpod.sh +0 -1
  106. package/packs/artokun-flow/install-windows.bat +0 -1
  107. package/packs/artokun-flow/manifest.yaml +0 -1
  108. package/packs/ernie/install-runpod.sh +0 -1
  109. package/packs/ernie/install-windows.bat +0 -1
  110. package/packs/ernie/manifest.yaml +0 -1
  111. package/packs/ernie-combo/install-runpod.sh +0 -1
  112. package/packs/ernie-combo/install-windows.bat +0 -1
  113. package/packs/ernie-combo/manifest.yaml +0 -1
  114. package/packs/ernie-img2img/install-runpod.sh +0 -1
  115. package/packs/ernie-img2img/install-windows.bat +0 -1
  116. package/packs/ernie-img2img/manifest.yaml +0 -1
  117. package/packs/ernie-txt2img/install-runpod.sh +0 -1
  118. package/packs/ernie-txt2img/install-windows.bat +0 -1
  119. package/packs/ernie-txt2img/manifest.yaml +0 -1
  120. package/packs/ideogram/install-runpod.sh +0 -1
  121. package/packs/ideogram/install-windows.bat +0 -1
  122. package/packs/ideogram/manifest.yaml +0 -1
  123. package/packs/ideogram-img2img/install-runpod.sh +0 -1
  124. package/packs/ideogram-img2img/install-windows.bat +0 -1
  125. package/packs/ideogram-img2img/manifest.yaml +0 -1
  126. package/packs/ideogram-txt2img/install-runpod.sh +0 -1
  127. package/packs/ideogram-txt2img/install-windows.bat +0 -1
  128. package/packs/ideogram-txt2img/manifest.yaml +0 -1
  129. package/packs/ltx-2.3/install-runpod.sh +0 -1
  130. package/packs/ltx-2.3/install-windows.bat +0 -1
  131. package/packs/ltx-2.3/manifest.yaml +0 -1
  132. package/packs/ltx-2.3-extender/install-runpod.sh +0 -1
  133. package/packs/ltx-2.3-extender/install-windows.bat +0 -1
  134. package/packs/ltx-2.3-extender/manifest.yaml +0 -1
  135. package/packs/ltx-2.3-extender-no-audio/install-runpod.sh +0 -1
  136. package/packs/ltx-2.3-extender-no-audio/install-windows.bat +0 -1
  137. package/packs/ltx-2.3-extender-no-audio/manifest.yaml +0 -1
  138. package/packs/ltx-2.3-flf/install-runpod.sh +0 -1
  139. package/packs/ltx-2.3-flf/install-windows.bat +0 -1
  140. package/packs/ltx-2.3-flf/manifest.yaml +0 -1
  141. package/packs/ltx-2.3-img2vid/install-runpod.sh +0 -1
  142. package/packs/ltx-2.3-img2vid/install-windows.bat +0 -1
  143. package/packs/ltx-2.3-img2vid/manifest.yaml +3 -4
  144. package/packs/ltx-2.3-txt2vid/install-runpod.sh +0 -1
  145. package/packs/ltx-2.3-txt2vid/install-windows.bat +0 -1
  146. package/packs/ltx-2.3-txt2vid/manifest.yaml +3 -4
  147. package/packs/ltx-2.3-xy-plot/install-runpod.sh +0 -1
  148. package/packs/ltx-2.3-xy-plot/install-windows.bat +0 -1
  149. package/packs/ltx-2.3-xy-plot/manifest.yaml +0 -1
  150. package/packs/ltx23-distill-3stage/install-runpod.sh +0 -1
  151. package/packs/ltx23-distill-3stage/install-windows.bat +0 -1
  152. package/packs/ltx23-distill-3stage/manifest.yaml +0 -1
  153. package/packs/qwen-image/install-runpod.sh +0 -1
  154. package/packs/qwen-image/install-windows.bat +0 -1
  155. package/packs/qwen-image/manifest.yaml +0 -1
  156. package/packs/qwen-image-edit/install-runpod.sh +0 -1
  157. package/packs/qwen-image-edit/install-windows.bat +0 -1
  158. package/packs/qwen-image-edit/manifest.yaml +0 -1
  159. package/packs/qwen-image-edit-edit/install-runpod.sh +0 -1
  160. package/packs/qwen-image-edit-edit/install-windows.bat +0 -1
  161. package/packs/qwen-image-edit-edit/manifest.yaml +0 -1
  162. package/packs/wan-animate/install-runpod.sh +0 -1
  163. package/packs/wan-animate/install-windows.bat +0 -1
  164. package/packs/wan-animate/manifest.yaml +0 -1
  165. package/packs/wan-animate-character/install-runpod.sh +0 -1
  166. package/packs/wan-animate-character/install-windows.bat +0 -1
  167. package/packs/wan-animate-character/manifest.yaml +0 -1
  168. package/packs/wan-animate-ofm/install-runpod.sh +0 -1
  169. package/packs/wan-animate-ofm/install-windows.bat +0 -1
  170. package/packs/wan-animate-ofm/manifest.yaml +0 -1
  171. package/packs/wan-longer-videos/install-runpod.sh +0 -1
  172. package/packs/wan-longer-videos/install-windows.bat +0 -1
  173. package/packs/wan-longer-videos/manifest.yaml +0 -1
  174. package/packs/wan-longer-videos-i2v/install-runpod.sh +0 -1
  175. package/packs/wan-longer-videos-i2v/install-windows.bat +0 -1
  176. package/packs/wan-longer-videos-i2v/manifest.yaml +0 -1
  177. package/packs/wan-longer-videos-i2v-96gb/install-runpod.sh +0 -1
  178. package/packs/wan-longer-videos-i2v-96gb/install-windows.bat +0 -1
  179. package/packs/wan-longer-videos-i2v-96gb/manifest.yaml +0 -1
  180. package/packs/wan-longer-videos-t2v/install-runpod.sh +0 -1
  181. package/packs/wan-longer-videos-t2v/install-windows.bat +0 -1
  182. package/packs/wan-longer-videos-t2v/manifest.yaml +0 -1
  183. package/packs/wan-longer-videos-t2v-96gb/install-runpod.sh +0 -1
  184. package/packs/wan-longer-videos-t2v-96gb/install-windows.bat +0 -1
  185. package/packs/wan-longer-videos-t2v-96gb/manifest.yaml +0 -1
  186. package/packs/wan-longer-videos-v2v/install-runpod.sh +0 -1
  187. package/packs/wan-longer-videos-v2v/install-windows.bat +0 -1
  188. package/packs/wan-longer-videos-v2v/manifest.yaml +0 -1
  189. package/packs/wan-longer-videos-v2v-96gb/install-runpod.sh +0 -1
  190. package/packs/wan-longer-videos-v2v-96gb/install-windows.bat +0 -1
  191. package/packs/wan-longer-videos-v2v-96gb/manifest.yaml +0 -1
  192. package/packs/wan-multitalk/install-runpod.sh +0 -1
  193. package/packs/wan-multitalk/install-windows.bat +0 -1
  194. package/packs/wan-multitalk/manifest.yaml +0 -1
  195. package/packs/wan-pusa-extend/install-runpod.sh +0 -1
  196. package/packs/wan-pusa-extend/install-windows.bat +0 -1
  197. package/packs/wan-pusa-extend/manifest.yaml +0 -1
  198. package/packs/wan-transparent/install-runpod.sh +0 -1
  199. package/packs/wan-transparent/install-windows.bat +0 -1
  200. package/packs/wan-transparent/manifest.yaml +0 -1
  201. package/packs/wan-transparent-img2vid/install-runpod.sh +0 -1
  202. package/packs/wan-transparent-img2vid/install-windows.bat +0 -1
  203. package/packs/wan-transparent-img2vid/manifest.yaml +0 -1
  204. package/packs/wan-transparent-img2vid-96gb/install-runpod.sh +0 -1
  205. package/packs/wan-transparent-img2vid-96gb/install-windows.bat +0 -1
  206. package/packs/wan-transparent-img2vid-96gb/manifest.yaml +0 -1
  207. package/packs/z-image-base/install-runpod.sh +0 -1
  208. package/packs/z-image-base/install-windows.bat +0 -1
  209. package/packs/z-image-base/manifest.yaml +0 -1
  210. package/packs/z-image-base-combo/install-runpod.sh +0 -1
  211. package/packs/z-image-base-combo/install-windows.bat +0 -1
  212. package/packs/z-image-base-combo/manifest.yaml +0 -1
  213. package/packs/z-image-base-controlnet/install-runpod.sh +0 -1
  214. package/packs/z-image-base-controlnet/install-windows.bat +0 -1
  215. package/packs/z-image-base-controlnet/manifest.yaml +0 -1
  216. package/packs/z-image-base-img2img/install-runpod.sh +0 -1
  217. package/packs/z-image-base-img2img/install-windows.bat +0 -1
  218. package/packs/z-image-base-img2img/manifest.yaml +0 -1
  219. package/packs/z-image-base-inpaint/install-runpod.sh +0 -1
  220. package/packs/z-image-base-inpaint/install-windows.bat +0 -1
  221. package/packs/z-image-base-inpaint/manifest.yaml +0 -1
  222. package/packs/z-image-base-txt2img/install-runpod.sh +0 -1
  223. package/packs/z-image-base-txt2img/install-windows.bat +0 -1
  224. package/packs/z-image-base-txt2img/manifest.yaml +0 -1
  225. package/packs/z-image-turbo/install-runpod.sh +0 -1
  226. package/packs/z-image-turbo/install-windows.bat +0 -1
  227. package/packs/z-image-turbo/manifest.yaml +0 -1
  228. package/packs/z-image-turbo-combo/install-runpod.sh +0 -1
  229. package/packs/z-image-turbo-combo/install-windows.bat +0 -1
  230. package/packs/z-image-turbo-combo/manifest.yaml +0 -1
  231. package/packs/z-image-turbo-controlnet/install-runpod.sh +0 -1
  232. package/packs/z-image-turbo-controlnet/install-windows.bat +0 -1
  233. package/packs/z-image-turbo-controlnet/manifest.yaml +0 -1
  234. package/packs/z-image-turbo-detail-daemon/install-runpod.sh +0 -1
  235. package/packs/z-image-turbo-detail-daemon/install-windows.bat +0 -1
  236. package/packs/z-image-turbo-detail-daemon/manifest.yaml +0 -1
  237. package/packs/z-image-turbo-img2img/install-runpod.sh +0 -1
  238. package/packs/z-image-turbo-img2img/install-windows.bat +0 -1
  239. package/packs/z-image-turbo-img2img/manifest.yaml +0 -1
  240. package/packs/z-image-turbo-inpainting/install-runpod.sh +0 -1
  241. package/packs/z-image-turbo-inpainting/install-windows.bat +0 -1
  242. package/packs/z-image-turbo-inpainting/manifest.yaml +0 -1
  243. package/packs/z-image-turbo-txt2img/install-runpod.sh +0 -1
  244. package/packs/z-image-turbo-txt2img/install-windows.bat +0 -1
  245. package/packs/z-image-turbo-txt2img/manifest.yaml +0 -1
  246. package/packs/z-image-xy-plot/install-runpod.sh +0 -1
  247. package/packs/z-image-xy-plot/install-windows.bat +0 -1
  248. package/packs/z-image-xy-plot/manifest.yaml +0 -1
  249. package/plugin/skills/flux-txt2img/SKILL.md +9 -4
  250. package/plugin/skills/wan-scail-replacement/SKILL.md +95 -0
  251. package/scripts/sync-agents.mjs +18 -8
  252. 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 widget types
31
- const WIDGET_TYPES = new Set([
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 ["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"].includes(String(type));
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
- * Deliberately excludes enum-shaped "_name" widgets (sampler_name, scheduler)
75
- * whose first-option fallback is legitimate.
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
- * Check if an input has control_after_generate in its spec config.
98
- * These inputs (like seed, noise_seed) have a phantom "fixed"/"randomize" widget
99
- * in the UI's widgets_values array that doesn't correspond to any named input.
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
- function hasControlAfterGenerate(inputName, def) {
102
- const spec = def.input.required?.[inputName] ?? def.input.optional?.[inputName];
103
- if (!spec)
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
- if (spec[0] === "INT" &&
112
- (inputName === "seed" || inputName === "noise_seed" || inputName === "rand_seed"))
113
- return true;
114
- return false;
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?.[0];
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?.[0];
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" || (srcType && isGetVirtual(srcType))) {
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
- return Array.isArray(t) ? "COMBO" : t;
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?.[0];
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?.[0];
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. Map those by name directly.
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
- inputs[name] = wv[name];
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
- for (const name of widgetNames) {
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. ComfyUI hard-rejects "Value not in list" otherwise,
1112
- // so defaulting is strictly safer than passing the stale value through.
1113
- //
1114
- // EXCEPTION (issue #407): an ASSET-selection combo (unet_name, ckpt_name,
1115
- // vae_name, lora_name, …) lists the files installed on the *connected*
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
- const nested = Array.isArray(opts)
1142
- ? opts.find((o) => o?.key === value)?.inputs?.required
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
- inputs[`${name}.${nName}`] = widgetValues[widgetIdx];
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
- inputs[name] = dflt;
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
- if (val !== undefined)
1219
- inputs[input.name] = val;
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
- return { workflow, warnings };
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