comfy-draftsman 0.15.0__py3-none-any.whl

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 (37) hide show
  1. comfy_draftsman/__init__.py +3 -0
  2. comfy_draftsman/comfy/__init__.py +0 -0
  3. comfy_draftsman/comfy/catalog.py +254 -0
  4. comfy_draftsman/comfy/client.py +413 -0
  5. comfy_draftsman/comfy/progress.py +131 -0
  6. comfy_draftsman/comfy/registry.py +104 -0
  7. comfy_draftsman/config.py +53 -0
  8. comfy_draftsman/graph/__init__.py +0 -0
  9. comfy_draftsman/graph/annotate.py +769 -0
  10. comfy_draftsman/graph/knobs.py +172 -0
  11. comfy_draftsman/graph/layout.py +370 -0
  12. comfy_draftsman/graph/lint.py +280 -0
  13. comfy_draftsman/graph/model.py +1274 -0
  14. comfy_draftsman/graph/port.py +254 -0
  15. comfy_draftsman/graph/spend.py +54 -0
  16. comfy_draftsman/graph/subgraph.py +261 -0
  17. comfy_draftsman/graph/validate.py +757 -0
  18. comfy_draftsman/graph/widgets.py +651 -0
  19. comfy_draftsman/imaging.py +57 -0
  20. comfy_draftsman/knowledge/__init__.py +428 -0
  21. comfy_draftsman/knowledge/families/chroma.yaml +40 -0
  22. comfy_draftsman/knowledge/families/flux.yaml +124 -0
  23. comfy_draftsman/knowledge/families/krea2.yaml +58 -0
  24. comfy_draftsman/knowledge/families/ltx.yaml +27 -0
  25. comfy_draftsman/knowledge/families/qwen_image.yaml +37 -0
  26. comfy_draftsman/knowledge/families/sd15.yaml +58 -0
  27. comfy_draftsman/knowledge/families/sd35.yaml +33 -0
  28. comfy_draftsman/knowledge/families/sdxl.yaml +124 -0
  29. comfy_draftsman/knowledge/families/wan.yaml +47 -0
  30. comfy_draftsman/knowledge/techniques.yaml +34 -0
  31. comfy_draftsman/server.py +2862 -0
  32. comfy_draftsman/session.py +105 -0
  33. comfy_draftsman-0.15.0.dist-info/METADATA +311 -0
  34. comfy_draftsman-0.15.0.dist-info/RECORD +37 -0
  35. comfy_draftsman-0.15.0.dist-info/WHEEL +4 -0
  36. comfy_draftsman-0.15.0.dist-info/entry_points.txt +2 -0
  37. comfy_draftsman-0.15.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """comfy-draftsman: MCP server that drafts readable, annotated ComfyUI workflows."""
2
+
3
+ __version__ = "0.15.0"
File without changes
@@ -0,0 +1,254 @@
1
+ """Compact search and summaries over ComfyUI's object_info document.
2
+
3
+ object_info is multi-megabyte (combo inputs embed every model filename on the
4
+ instance), so agents must never receive it raw. Search returns lightweight
5
+ descriptors; node_summary returns one node's schema with giant combo lists
6
+ truncated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ from ..graph import widgets as w
15
+
16
+ MAX_COMBO_CHOICES = 24
17
+ MAX_TOOLTIP_CHARS = 160 # tooltips are rarely needed to wire a node correctly
18
+ MAX_TRIGGER_TAGS = 15
19
+
20
+ # An autogrow container can declare up to 50 synthesized slot names. The names
21
+ # are a visible arithmetic pattern ("image0".."image49"), so a few plus the
22
+ # count and the last name convey it - listing all 50 is pure repetition.
23
+ _AUTOGROW_NAMES_SHOWN = 6
24
+
25
+
26
+ def metadata_digest(meta: dict[str, Any], max_tags: int = MAX_TRIGGER_TAGS) -> dict[str, Any]:
27
+ """Trim a safetensors __metadata__ dict to what matters for USING the model:
28
+ base-model/architecture keys and the top training tags by frequency (a
29
+ LoRA's trigger words live in ss_tag_frequency). Raw headers can be tens of
30
+ KB of per-image tag counts - never return them whole."""
31
+ out: dict[str, Any] = {}
32
+ for key in (
33
+ "ss_base_model_version",
34
+ "ss_sd_model_name",
35
+ "modelspec.architecture",
36
+ "modelspec.title",
37
+ "ss_network_module",
38
+ "ss_network_dim",
39
+ "ss_network_alpha",
40
+ "ss_output_name",
41
+ "ss_resolution",
42
+ ):
43
+ if meta.get(key):
44
+ out[key] = meta[key]
45
+ freq_raw = meta.get("ss_tag_frequency")
46
+ if freq_raw:
47
+ try:
48
+ data = json.loads(freq_raw) if isinstance(freq_raw, str) else freq_raw
49
+ counts: dict[str, int] = {}
50
+ for tags in data.values(): # {dataset_name: {tag: count}}
51
+ if isinstance(tags, dict):
52
+ for tag, n in tags.items():
53
+ counts[tag.strip()] = counts.get(tag.strip(), 0) + int(n)
54
+ top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:max_tags]
55
+ out["top_training_tags"] = [f"{tag} ({n})" for tag, n in top]
56
+ except (ValueError, TypeError, AttributeError):
57
+ pass
58
+ if not out:
59
+ out["note"] = (
60
+ "no recognizable training metadata; raw keys: "
61
+ + ", ".join(sorted(meta)[:20])
62
+ )
63
+ return out
64
+
65
+
66
+ def search_nodes(
67
+ object_info: dict[str, Any],
68
+ query: str,
69
+ category: str | None = None,
70
+ limit: int = 25,
71
+ ) -> list[dict[str, Any]]:
72
+ """Case-insensitive substring search over class name, display name, description."""
73
+ q = query.lower().strip()
74
+ hits: list[tuple[int, dict[str, Any]]] = []
75
+ for class_type, schema in object_info.items():
76
+ cat = schema.get("category", "") or ""
77
+ if category and category.lower() not in cat.lower():
78
+ continue
79
+ display = schema.get("display_name", "") or ""
80
+ description = schema.get("description", "") or ""
81
+ haystacks = (class_type.lower(), display.lower(), description.lower())
82
+ if q:
83
+ if q in haystacks[0]:
84
+ rank = 0
85
+ elif q in haystacks[1]:
86
+ rank = 1
87
+ elif q in haystacks[2]:
88
+ rank = 2
89
+ else:
90
+ continue
91
+ else:
92
+ rank = 3
93
+ hits.append(
94
+ (
95
+ rank,
96
+ {
97
+ "class_type": class_type,
98
+ "display_name": display,
99
+ "description": description[:120],
100
+ "category": cat,
101
+ "output_node": bool(schema.get("output_node")),
102
+ },
103
+ )
104
+ )
105
+ hits.sort(key=lambda pair: (pair[0], pair[1]["class_type"]))
106
+ return [h for _, h in hits[:limit]]
107
+
108
+
109
+ def _sub_widget_descriptors(parent: str, option: dict[str, Any]) -> list[dict[str, Any]]:
110
+ """Describe the conditional sub-widgets a dynamic-combo option reveals, with
111
+ their dotted set_widget names (e.g. 'output.normalization')."""
112
+ descriptors: list[dict[str, Any]] = []
113
+ inputs = (option or {}).get("inputs", {}) or {}
114
+ for section in ("required", "optional"):
115
+ for name, spec in (inputs.get(section) or {}).items():
116
+ if not w.is_widget_input(spec):
117
+ continue
118
+ opts = spec[1] if isinstance(spec, list | tuple) and len(spec) > 1 and isinstance(spec[1], dict) else {}
119
+ kind = spec[0]
120
+ desc: dict[str, Any] = {"name": f"{parent}.{name}"}
121
+ if w.is_dynamic_combo(spec):
122
+ desc["type"] = "COMBO"
123
+ desc["dynamic_combo"] = True
124
+ desc["choices"] = [o.get("key") for o in w.dynamic_options(spec)]
125
+ desc["default"] = w.dynamic_default_key(spec)
126
+ elif isinstance(kind, list):
127
+ desc["type"] = "COMBO"
128
+ desc["choices"] = kind[:MAX_COMBO_CHOICES]
129
+ elif kind == "COMBO":
130
+ desc["type"] = "COMBO"
131
+ desc["choices"] = opts.get("options", [])[:MAX_COMBO_CHOICES]
132
+ else:
133
+ desc["type"] = str(kind)
134
+ if "default" in opts:
135
+ desc["default"] = opts["default"]
136
+ descriptors.append(desc)
137
+ return descriptors
138
+
139
+
140
+ def _apply_choices(
141
+ entry: dict[str, Any],
142
+ options: list[Any],
143
+ choices_filter: str,
144
+ max_choices: int,
145
+ ) -> None:
146
+ """Attach a combo's choices to an input entry, filtered and capped. When the
147
+ list is cut, say how to see the rest (agents can't guess hidden entries)."""
148
+ total = len(options)
149
+ if choices_filter:
150
+ needle = choices_filter.lower()
151
+ options = [o for o in options if needle in str(o).lower()]
152
+ cap = max_choices if max_choices > 0 else MAX_COMBO_CHOICES
153
+ entry["choices"] = options[:cap]
154
+ if choices_filter:
155
+ entry["choices_matched"] = len(options)
156
+ entry["choices_total"] = total
157
+ if len(options) > cap:
158
+ entry["choices_truncated"] = len(options)
159
+ entry["choices_hint"] = (
160
+ f"showing {cap} of {len(options)}; re-call get_node_info with "
161
+ "choices_filter='substring' or a larger max_choices"
162
+ )
163
+
164
+
165
+ def node_summary(
166
+ object_info: dict[str, Any],
167
+ class_type: str,
168
+ choices_filter: str = "",
169
+ max_choices: int = 0,
170
+ ) -> dict[str, Any]:
171
+ """One node's full slot schema, sized for an agent to wire it correctly.
172
+
173
+ choices_filter / max_choices control combo-choice listing: filter is a
174
+ case-insensitive substring over every combo input's choices; max_choices
175
+ raises the per-combo cap (default MAX_COMBO_CHOICES)."""
176
+ schema = object_info[class_type]
177
+ inputs = []
178
+ for section in ("required", "optional"):
179
+ for name, spec in schema.get("input", {}).get(section, {}).items():
180
+ entry: dict[str, Any] = {
181
+ "name": name,
182
+ "required": section == "required",
183
+ "widget": w.is_widget_input(spec),
184
+ }
185
+ kind = spec[0] if isinstance(spec, list | tuple) and spec else "*"
186
+ opts = spec[1] if isinstance(spec, list | tuple) and len(spec) > 1 and isinstance(spec[1], dict) else {}
187
+ if isinstance(kind, list):
188
+ entry["type"] = "COMBO"
189
+ _apply_choices(entry, kind, choices_filter, max_choices)
190
+ elif kind == "COMBO":
191
+ entry["type"] = "COMBO"
192
+ _apply_choices(entry, opts.get("options", []), choices_filter, max_choices)
193
+ elif (autogrow := w.autogrow_template(spec)) is not None:
194
+ # A growing socket list, not a socket. `/object_info` names only
195
+ # this container, so without expanding it here the real slot
196
+ # names are undiscoverable and `connect` has nothing to aim at -
197
+ # and the prefix isn't derivable from the marker ("images" ->
198
+ # "image0"). Names are capped like every other list this server
199
+ # returns; `max` is up to 50 and repeating it per node is cost
200
+ # for nothing once the pattern is visible.
201
+ item = autogrow["item_spec"]
202
+ entry["type"] = str(
203
+ item[0] if isinstance(item, list | tuple) and item else "*"
204
+ )
205
+ names = autogrow["names"]
206
+ entry["autogrow"] = True
207
+ entry["connect_to"] = [
208
+ f"{name}.{n}" for n in names[:_AUTOGROW_NAMES_SHOWN]
209
+ ]
210
+ entry["slot_count"] = len(names)
211
+ entry["min_connected"] = autogrow["min"]
212
+ entry["hint"] = (
213
+ f"a growing socket list, not a socket: connect to "
214
+ f"'{name}.{names[0]}'..'{name}.{names[-1]}' "
215
+ f"({len(names)} slots, at least {autogrow['min']} required, "
216
+ "gaps allowed) - never to "
217
+ f"'{name}' itself"
218
+ )
219
+ elif w.is_dynamic_combo(spec):
220
+ # a V3 dynamic combo: the main value is one of the option keys,
221
+ # and the selected key reveals dotted sub-widgets. Surface both
222
+ # so the agent can set_widget them (main first, then subs).
223
+ entry["type"] = "COMBO"
224
+ entry["dynamic_combo"] = True
225
+ entry["choices"] = [o.get("key") for o in w.dynamic_options(spec)]
226
+ entry["default"] = w.dynamic_default_key(spec)
227
+ entry["options"] = {
228
+ o.get("key"): _sub_widget_descriptors(name, o)
229
+ for o in w.dynamic_options(spec)
230
+ }
231
+ else:
232
+ entry["type"] = str(kind)
233
+ for key in ("default", "min", "max", "tooltip", "control_after_generate", "step"):
234
+ if key in opts:
235
+ entry[key] = opts[key]
236
+ if isinstance(entry.get("tooltip"), str) and len(entry["tooltip"]) > MAX_TOOLTIP_CHARS:
237
+ entry["tooltip"] = entry["tooltip"][:MAX_TOOLTIP_CHARS] + "…"
238
+ if entry["widget"] and w.has_control_slot(name, spec):
239
+ entry["control_slot"] = f"{name}__control_after_generate"
240
+ inputs.append(entry)
241
+ out_names = schema.get("output_name") or []
242
+ outputs = [
243
+ {"name": str(out_names[i]) if i < len(out_names) else str(t), "type": str(t)}
244
+ for i, t in enumerate(schema.get("output") or [])
245
+ ]
246
+ return {
247
+ "class_type": class_type,
248
+ "display_name": schema.get("display_name", class_type),
249
+ "description": (schema.get("description") or "")[:300],
250
+ "category": schema.get("category", ""),
251
+ "output_node": bool(schema.get("output_node")),
252
+ "inputs": inputs,
253
+ "outputs": outputs,
254
+ }
@@ -0,0 +1,413 @@
1
+ """Async HTTP client for a local ComfyUI instance.
2
+
3
+ Endpoint shapes verified against ComfyUI 0.27.0 (July 2026):
4
+ - GET /system_stats -> {"system": {...}, "devices": [...]}
5
+ - GET /object_info -> {class_type: schema, ...} (~2.5 MB; cached here)
6
+ - GET /models -> [folder, ...]
7
+ - GET /models/{folder} -> [filename, ...]
8
+ - GET /templates/index.json -> [{"moduleName", "templates": [...]}, ...]
9
+ - GET /templates/{name}.json -> UI-format workflow JSON (schema 0.4)
10
+ - POST /prompt -> {"prompt_id", "number"} | 400 {"error", "node_errors"}
11
+ - GET /history/{prompt_id} -> {prompt_id: {...}}
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import uuid
19
+ from typing import Any
20
+ from urllib.parse import quote
21
+
22
+ import httpx
23
+ import websockets
24
+
25
+ from ..config import Config
26
+
27
+
28
+ class ComfyValidationError(Exception):
29
+ """ComfyUI rejected a prompt at validation time."""
30
+
31
+ def __init__(self, error: dict[str, Any], node_errors: dict[str, Any]):
32
+ self.error = error
33
+ self.node_errors = node_errors
34
+ message = error.get("message", "prompt validation failed")
35
+ super().__init__(f"{message}; node_errors={list(node_errors)}")
36
+
37
+
38
+ class ComfyConnectionError(Exception):
39
+ """Couldn't reach the ComfyUI instance at all - down, wrong URL, or blocked.
40
+
41
+ Distinct from ComfyValidationError (reached it, prompt rejected): a raw httpx
42
+ ConnectError/timeout is opaque to an agent, so we turn it into an actionable
43
+ message naming the URL and the likely fixes."""
44
+
45
+
46
+ class ComfyClient:
47
+ def __init__(self, config: Config):
48
+ self._config = config
49
+ self.base_url = config.comfyui_url
50
+ self.client_id = uuid.uuid4().hex
51
+ self._http = httpx.AsyncClient(base_url=self.base_url, timeout=config.request_timeout)
52
+ self._object_info: dict[str, Any] | None = None
53
+
54
+ async def close(self) -> None:
55
+ await self._http.aclose()
56
+
57
+ async def _send(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
58
+ """Issue a request, converting a failure-to-connect into a clear error.
59
+
60
+ Only transport-level failures (can't connect, DNS, connect timeout) become
61
+ ComfyConnectionError; an HTTP response - even a 4xx/5xx - is returned for
62
+ the caller's own raise_for_status/status handling."""
63
+ try:
64
+ return await self._http.request(method, path, **kwargs)
65
+ except httpx.RequestError as e:
66
+ raise ComfyConnectionError(
67
+ f"can't reach ComfyUI at {self.base_url}: {type(e).__name__}. "
68
+ "Is ComfyUI running and is COMFYUI_URL correct? A remote instance "
69
+ "must be started with --listen and reachable from this host."
70
+ ) from e
71
+
72
+ async def _get_json(self, path: str) -> Any:
73
+ response = await self._send("GET", path)
74
+ response.raise_for_status()
75
+ return response.json()
76
+
77
+ async def get_system_stats(self) -> dict[str, Any]:
78
+ return await self._get_json("/system_stats")
79
+
80
+ async def get_object_info(self, refresh: bool = False) -> dict[str, Any]:
81
+ if self._object_info is None or refresh:
82
+ self._object_info = await self._get_json("/object_info")
83
+ return self._object_info
84
+
85
+ async def list_model_folders(self) -> list[str]:
86
+ return await self._get_json("/models")
87
+
88
+ async def list_models(self, folder: str) -> list[str]:
89
+ return await self._get_json(f"/models/{quote(folder, safe='')}")
90
+
91
+ async def get_model_metadata(self, folder: str, filename: str) -> dict[str, Any]:
92
+ """Embedded safetensors __metadata__ via /view_metadata. ComfyUI 404s
93
+ for missing files, non-.safetensors, and headers without metadata."""
94
+ clean = filename.replace("\\", "/")
95
+ if clean.startswith("/") or ".." in clean.split("/"):
96
+ raise ValueError(f"invalid filename: {filename!r}")
97
+ response = await self._send(
98
+ "GET", f"/view_metadata/{quote(folder, safe='')}", params={"filename": filename}
99
+ )
100
+ if response.status_code == 404:
101
+ raise FileNotFoundError(filename)
102
+ response.raise_for_status()
103
+ return response.json()
104
+
105
+ async def get_template_index(self) -> list[dict[str, Any]]:
106
+ return await self._get_json("/templates/index.json")
107
+
108
+ async def get_template_workflow(self, name: str) -> dict[str, Any]:
109
+ clean = name.replace("\\", "/")
110
+ if clean.startswith("/") or ".." in clean.split("/"):
111
+ raise ValueError(f"invalid template name: {name!r}")
112
+ return await self._get_json(f"/templates/{name}.json")
113
+
114
+ async def queue_prompt(
115
+ self,
116
+ api_prompt: dict[str, Any],
117
+ extra_data: dict[str, Any] | None = None,
118
+ client_id: str | None = None,
119
+ front: bool = False,
120
+ ) -> dict[str, Any]:
121
+ # client_id override: non-blocking runs queue under the ProgressTracker's
122
+ # id so ITS socket receives the per-prompt events (ComfyUI routes them
123
+ # to the queuing client's socket only).
124
+ payload: dict[str, Any] = {"prompt": api_prompt, "client_id": client_id or self.client_id}
125
+ if front:
126
+ # run next after the current job finishes; pending jobs stay queued
127
+ payload["front"] = True
128
+ if extra_data:
129
+ payload["extra_data"] = extra_data
130
+ response = await self._send("POST", "/prompt", json=payload)
131
+ if response.status_code == 400:
132
+ body = response.json()
133
+ raise ComfyValidationError(body.get("error", {}), body.get("node_errors", {}))
134
+ response.raise_for_status()
135
+ return response.json()
136
+
137
+ async def get_history(self, prompt_id: str) -> dict[str, Any]:
138
+ history = await self._get_json(f"/history/{prompt_id}")
139
+ return history.get(prompt_id, {})
140
+
141
+ async def get_queue(self) -> dict[str, Any]:
142
+ return await self._get_json("/queue")
143
+
144
+ async def interrupt(self) -> None:
145
+ response = await self._send("POST", "/interrupt")
146
+ response.raise_for_status()
147
+
148
+ async def clear_queue(self) -> None:
149
+ response = await self._send("POST", "/queue", json={"clear": True})
150
+ response.raise_for_status()
151
+
152
+ async def delete_queue_items(self, prompt_ids: list[str]) -> None:
153
+ response = await self._send("POST", "/queue", json={"delete": prompt_ids})
154
+ response.raise_for_status()
155
+
156
+ async def free(self, unload_models: bool = False, free_memory: bool = True) -> None:
157
+ response = await self._send(
158
+ "POST", "/free", json={"unload_models": unload_models, "free_memory": free_memory}
159
+ )
160
+ response.raise_for_status()
161
+
162
+ async def upload_image(
163
+ self,
164
+ data: bytes,
165
+ name: str,
166
+ subfolder: str = "",
167
+ overwrite: bool = False,
168
+ image_type: str = "input",
169
+ ) -> dict[str, Any]:
170
+ """POST /upload/image -> {"name", "subfolder", "type"}."""
171
+ form: dict[str, str] = {"type": image_type, "overwrite": "true" if overwrite else "false"}
172
+ if subfolder:
173
+ form["subfolder"] = subfolder
174
+ response = await self._send(
175
+ "POST", "/upload/image", files={"image": (name, data)}, data=form
176
+ )
177
+ response.raise_for_status()
178
+ return response.json()
179
+
180
+ async def upload_mask(
181
+ self,
182
+ data: bytes,
183
+ name: str,
184
+ original_ref: dict[str, Any],
185
+ subfolder: str = "",
186
+ overwrite: bool = False,
187
+ ) -> dict[str, Any]:
188
+ """POST /upload/mask; original_ref names the image the mask belongs to."""
189
+ form: dict[str, str] = {
190
+ "type": "input",
191
+ "overwrite": "true" if overwrite else "false",
192
+ "original_ref": json.dumps(original_ref),
193
+ }
194
+ if subfolder:
195
+ form["subfolder"] = subfolder
196
+ response = await self._send(
197
+ "POST", "/upload/mask", files={"image": (name, data)}, data=form
198
+ )
199
+ response.raise_for_status()
200
+ return response.json()
201
+
202
+ @staticmethod
203
+ def _workflow_userdata_path(name: str) -> str:
204
+ """Relative userdata path for a workflow browser file, refusing traversal."""
205
+ clean = name.replace("\\", "/").strip("/")
206
+ if not clean or ".." in clean.split("/"):
207
+ raise ValueError(f"invalid workflow name: {name!r}")
208
+ filename = clean if clean.endswith(".json") else f"{clean}.json"
209
+ return f"workflows/{filename}"
210
+
211
+ async def list_userdata_workflows(self) -> list[str]:
212
+ """Workflow files in ComfyUI's workflow browser (userdata), incl. subdirs."""
213
+ response = await self._send(
214
+ "GET", "/api/userdata", params={"dir": "workflows", "recurse": "true", "split": "false"}
215
+ )
216
+ if response.status_code == 404: # no workflows dir yet on a fresh instance
217
+ return []
218
+ response.raise_for_status()
219
+ return [p.replace("\\", "/") for p in response.json()]
220
+
221
+ async def get_userdata_workflow(self, name: str) -> dict[str, Any]:
222
+ """Load one workflow browser file by name (with or without .json).
223
+
224
+ Raises FileNotFoundError if it doesn't exist, ValueError on a name that
225
+ would escape the workflows directory.
226
+ """
227
+ path = self._workflow_userdata_path(name)
228
+ response = await self._send("GET", f"/api/userdata/{quote(path, safe='')}")
229
+ if response.status_code == 404:
230
+ raise FileNotFoundError(name)
231
+ response.raise_for_status()
232
+ return response.json()
233
+
234
+ async def save_userdata_workflow(
235
+ self, name: str, document: dict[str, Any], overwrite: bool = False
236
+ ) -> str:
237
+ """Save a UI-format workflow into ComfyUI's workflow browser (userdata API).
238
+
239
+ With overwrite=False (default) ComfyUI answers 409 if the file exists,
240
+ surfaced here as FileExistsError so callers can pick another name.
241
+ """
242
+ filename = name if name.endswith(".json") else f"{name}.json"
243
+ response = await self._send(
244
+ "POST",
245
+ f"/api/userdata/{quote(f'workflows/{filename}', safe='')}",
246
+ params={"overwrite": "true" if overwrite else "false"},
247
+ json=document,
248
+ )
249
+ if response.status_code == 409:
250
+ raise FileExistsError(filename)
251
+ response.raise_for_status()
252
+ return filename
253
+
254
+ #: output keys holding {filename, subfolder, type} FILE refs - the only ones
255
+ #: /view can fetch and _relocate_outputs can move.
256
+ FILE_OUTPUT_KEYS = ("images", "gifs", "videos", "audio")
257
+
258
+ #: non-file keys that carry no information for the caller: `animated` is a
259
+ #: per-image bool list the frontend uses to pick a player.
260
+ _DATA_KEYS_IGNORED = frozenset({"animated"})
261
+
262
+ # A node can return anything JSON-serializable, and some return a lot (an
263
+ # entire generated prompt, a batch's worth of paths). These are explicitly
264
+ # requested run results rather than a recurring summary, so the budget is
265
+ # generous - but it is still a budget.
266
+ _DATA_VALUE_CHARS = 1200
267
+ _DATA_TOTAL_CHARS = 5000
268
+
269
+ @staticmethod
270
+ def _collect_outputs(history: dict[str, Any]) -> list[dict[str, Any]]:
271
+ """FILE outputs only, flattened to relocatable {filename, subfolder, type}
272
+ refs. Non-file return values go through _collect_data_outputs."""
273
+ outputs: list[dict[str, Any]] = []
274
+ for node_id, node_output in (history.get("outputs") or {}).items():
275
+ for key in ComfyClient.FILE_OUTPUT_KEYS:
276
+ for item in node_output.get(key, []) or []:
277
+ outputs.append({**item, "node_id": node_id, "kind": key})
278
+ return outputs
279
+
280
+ @classmethod
281
+ def _collect_data_outputs(cls, history: dict[str, Any]) -> dict[str, Any]:
282
+ """Every NON-file value an output node returned, keyed by node id.
283
+
284
+ Output nodes are free to return anything in their `ui` dict, and plenty
285
+ do: ShowText-style nodes return `text`, and save nodes commonly return the
286
+ paths they wrote (`filenames`, `path`, `saved_count`). Harvesting only the
287
+ four file keys meant those results were silently dropped - a workflow whose
288
+ product was a generated string, or whose save node reported where it wrote,
289
+ looked like it produced nothing at all.
290
+
291
+ Values are truncated per item and the whole payload is budgeted; a `note`
292
+ records anything cut so a short value is never mistaken for the full one.
293
+ """
294
+ collected: dict[str, Any] = {}
295
+ used = 0
296
+ truncated: list[str] = []
297
+ for node_id, node_output in (history.get("outputs") or {}).items():
298
+ if not isinstance(node_output, dict):
299
+ continue
300
+ for key, value in node_output.items():
301
+ if key in cls.FILE_OUTPUT_KEYS or key in cls._DATA_KEYS_IGNORED:
302
+ continue
303
+ rendered = value if isinstance(value, str) else json.dumps(value, default=str)
304
+ # over-long values degrade to a clipped STRING; anything within
305
+ # budget keeps its original shape (list/dict/number)
306
+ kept: Any = value
307
+ if len(rendered) > cls._DATA_VALUE_CHARS:
308
+ kept = rendered[: cls._DATA_VALUE_CHARS] + "…"
309
+ truncated.append(f"{node_id}.{key}")
310
+ cost = len(kept) if isinstance(kept, str) else len(rendered)
311
+ if used + cost > cls._DATA_TOTAL_CHARS:
312
+ truncated.append(f"{node_id}.{key} (omitted)")
313
+ continue
314
+ used += cost
315
+ collected.setdefault(str(node_id), {})[key] = kept
316
+ if truncated:
317
+ collected["note"] = (
318
+ "clipped to keep the response bounded: " + ", ".join(truncated[:10])
319
+ )
320
+ return collected
321
+
322
+ def _ws_url(self, client_id: str | None = None) -> str:
323
+ scheme = "wss" if self.base_url.startswith("https") else "ws"
324
+ host = self.base_url.split("://", 1)[1]
325
+ return f"{scheme}://{host}/ws?clientId={client_id or self.client_id}"
326
+
327
+ async def run_and_wait(
328
+ self,
329
+ api_prompt: dict[str, Any],
330
+ timeout: float = 600.0,
331
+ extra_data: dict[str, Any] | None = None,
332
+ front: bool = False,
333
+ ) -> dict[str, Any]:
334
+ """Queue a prompt and wait for completion via the /ws event stream.
335
+
336
+ Returns {"status", "prompt_id", "outputs" (image/video/audio files from
337
+ history), "error"?}.
338
+ """
339
+ async with websockets.connect(self._ws_url(), max_size=32 * 1024 * 1024) as ws:
340
+ queued = await self.queue_prompt(api_prompt, extra_data=extra_data, front=front)
341
+ prompt_id = queued["prompt_id"]
342
+ # ComfyUI accepts a prompt (HTTP 200 + prompt_id) yet still returns
343
+ # node_errors for nodes it rejected at validation, executing only the
344
+ # rest of the graph. Capture them so a partial run isn't reported as a
345
+ # clean success with mysteriously-empty outputs.
346
+ partial_node_errors = queued.get("node_errors") or {}
347
+ error: dict[str, Any] | None = None
348
+ async with asyncio.timeout(timeout):
349
+ while True:
350
+ frame = await ws.recv()
351
+ if isinstance(frame, bytes): # preview image frames
352
+ continue
353
+ event = json.loads(frame)
354
+ data = event.get("data", {})
355
+ if data.get("prompt_id") not in (None, prompt_id):
356
+ continue
357
+ kind = event.get("type")
358
+ if kind == "execution_error":
359
+ error = data
360
+ break
361
+ if kind == "execution_interrupted":
362
+ error = {"exception_message": "interrupted"}
363
+ break
364
+ finished = kind == "execution_success" or (
365
+ kind == "executing" and data.get("node") is None
366
+ )
367
+ if finished and data.get("prompt_id") == prompt_id:
368
+ break
369
+ # /history can lag the execution_success event by a beat; on a clean run
370
+ # with no outputs yet, re-poll briefly before trusting an empty list.
371
+ outputs: list[dict[str, Any]] = []
372
+ data_outputs: dict[str, Any] = {}
373
+ for attempt in range(5):
374
+ history = await self.get_history(prompt_id)
375
+ outputs = self._collect_outputs(history)
376
+ data_outputs = self._collect_data_outputs(history)
377
+ if (
378
+ outputs
379
+ or data_outputs
380
+ or error
381
+ or history.get("status", {}).get("completed") is False
382
+ ):
383
+ break
384
+ if attempt < 4:
385
+ await asyncio.sleep(0.2 * (attempt + 1))
386
+ result: dict[str, Any] = {
387
+ "status": "error" if error else "success",
388
+ "prompt_id": prompt_id,
389
+ "outputs": outputs,
390
+ }
391
+ if data_outputs:
392
+ # non-file return values (generated text, written paths, counts) -
393
+ # omitted entirely when empty so the common case costs nothing
394
+ result["data_outputs"] = data_outputs
395
+ if error:
396
+ result["error"] = {
397
+ "node_id": error.get("node_id"),
398
+ "node_type": error.get("node_type"),
399
+ "message": error.get("exception_message"),
400
+ "type": error.get("exception_type"),
401
+ }
402
+ if partial_node_errors:
403
+ result["node_errors"] = partial_node_errors
404
+ return result
405
+
406
+ async def fetch_output(self, item: dict[str, Any]) -> bytes:
407
+ response = await self._send("GET", "/view", params={
408
+ "filename": item.get("filename", ""),
409
+ "subfolder": item.get("subfolder", ""),
410
+ "type": item.get("type", "output"),
411
+ })
412
+ response.raise_for_status()
413
+ return response.content