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.
- comfy_draftsman/__init__.py +3 -0
- comfy_draftsman/comfy/__init__.py +0 -0
- comfy_draftsman/comfy/catalog.py +254 -0
- comfy_draftsman/comfy/client.py +413 -0
- comfy_draftsman/comfy/progress.py +131 -0
- comfy_draftsman/comfy/registry.py +104 -0
- comfy_draftsman/config.py +53 -0
- comfy_draftsman/graph/__init__.py +0 -0
- comfy_draftsman/graph/annotate.py +769 -0
- comfy_draftsman/graph/knobs.py +172 -0
- comfy_draftsman/graph/layout.py +370 -0
- comfy_draftsman/graph/lint.py +280 -0
- comfy_draftsman/graph/model.py +1274 -0
- comfy_draftsman/graph/port.py +254 -0
- comfy_draftsman/graph/spend.py +54 -0
- comfy_draftsman/graph/subgraph.py +261 -0
- comfy_draftsman/graph/validate.py +757 -0
- comfy_draftsman/graph/widgets.py +651 -0
- comfy_draftsman/imaging.py +57 -0
- comfy_draftsman/knowledge/__init__.py +428 -0
- comfy_draftsman/knowledge/families/chroma.yaml +40 -0
- comfy_draftsman/knowledge/families/flux.yaml +124 -0
- comfy_draftsman/knowledge/families/krea2.yaml +58 -0
- comfy_draftsman/knowledge/families/ltx.yaml +27 -0
- comfy_draftsman/knowledge/families/qwen_image.yaml +37 -0
- comfy_draftsman/knowledge/families/sd15.yaml +58 -0
- comfy_draftsman/knowledge/families/sd35.yaml +33 -0
- comfy_draftsman/knowledge/families/sdxl.yaml +124 -0
- comfy_draftsman/knowledge/families/wan.yaml +47 -0
- comfy_draftsman/knowledge/techniques.yaml +34 -0
- comfy_draftsman/server.py +2862 -0
- comfy_draftsman/session.py +105 -0
- comfy_draftsman-0.15.0.dist-info/METADATA +311 -0
- comfy_draftsman-0.15.0.dist-info/RECORD +37 -0
- comfy_draftsman-0.15.0.dist-info/WHEEL +4 -0
- comfy_draftsman-0.15.0.dist-info/entry_points.txt +2 -0
- comfy_draftsman-0.15.0.dist-info/licenses/LICENSE +21 -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
|