inferencekit-cli 0.1.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 (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
@@ -0,0 +1,749 @@
1
+ """A ComfyUI client for timed video clips, and the ``comfyui`` backend bench clip drives. Standard library only.
2
+
3
+ Per-step dumps (stepdump.py, for quality clip-steps) cannot come from this client: the denoising loop runs inside the
4
+ ComfyUI process. A ComfyUI engine that must pass quality clip-steps writes them from a custom node that wraps its
5
+ sampler's step (x, the guided velocity (x - denoised) / sigma, the next latent), named by the sha256 of the clip it
6
+ saves; not provided here (ComfyUI's sampler code was not read for this).
7
+
8
+ ComfyUI's HTTP API as used here: ``GET /system_stats`` (ready check and version), ``POST /upload/image``
9
+ (the i2v image), ``POST /prompt`` {"prompt": <API-format workflow>, "client_id"} -> {"prompt_id"},
10
+ ``GET /history/<prompt_id>`` (outputs and status messages once the prompt is done), ``GET /view``
11
+ (an output file's bytes), ``GET``/``POST /queue`` and ``POST /interrupt`` (after a timeout), and the
12
+ websocket ``/ws?clientId=...`` (execution_start, execution_cached, executing, progress, executed,
13
+ execution_success / execution_error). With an API key, every request carries ``Authorization: Bearer``,
14
+ the websocket upgrade included.
15
+
16
+ The workflow file (pinned: its sha256 goes into the record) wraps an API-format workflow with the places
17
+ the clip's settings go::
18
+
19
+ {"format": "inferencekit-comfyui-workflow/1",
20
+ "workflow": {"3": {"class_type": "KSampler", "inputs": {...}}, ...}, # Export (API) from ComfyUI
21
+ "bindings": {"prompt": [["6", "text"]], "seed": [["3", "seed"]], "steps": [["3", "steps"]],
22
+ "width": [["5", "width"]], "height": [["5", "height"]], "num_frames": [["5", "length"]],
23
+ "fps": [["8", "frame_rate"]], "guidance_scale": [["3", "cfg"]],
24
+ "image": [["12", "image"]], "noise_seed": [...], "audio": [...]}, # the last three optional
25
+ "output_node": "9", # optional: the node whose file is the clip (else the first video output)
26
+ "allow_cached": ["7"], # optional: the fixed negative prompt's encoder may be reused (see Cache)
27
+ "audio": true, # optional: the graph always makes an audio track (true) or never (false)
28
+ "sampler_nodes": ["3"]} # optional: nodes whose progress counts as denoise steps (default: the
29
+ # KSampler*, SamplerCustom* and *Sampler nodes; not KSamplerSelect)
30
+
31
+ Every field of a clip request must have a binding (``image`` only for i2v, ``noise_seed`` and ``audio`` only
32
+ when the request sets them): a setting the workflow would ignore is refused, never dropped. A graph whose
33
+ audio track cannot be switched (LTX-2's audio-and-video graphs always decode one) declares it with
34
+ ``"audio": true`` (or false) instead of an audio binding: a request asking for that is served, the other
35
+ value is refused. With no
36
+ ``noise_seed`` binding, the seed bindings get ``noise_seed`` when the request has one, since a ComfyUI
37
+ sampler draws its initial noise from its seed.
38
+
39
+ Timing (the same timer as the clip HTTP contract): ``t_send`` just before the image upload or POST
40
+ /prompt, ``t_done`` once the output file's bytes from /view are in hand. The websocket is connected
41
+ before ``t_send`` (it only observes). Without a websocket the client polls /history every ``poll_s``
42
+ (default 0.05 s, so up to that much is added to a clip).
43
+
44
+ Cache: ComfyUI skips a node whose inputs (and upstream nodes) equal an earlier run's and reports it in
45
+ ``execution_cached`` (on the websocket and in the history status messages). Its default cache (RAM pressure,
46
+ ComfyUI 0.40) keeps the outputs of many earlier prompts, so a clip set's prompt, repeated in the next round,
47
+ finds its text encoding cached (observed on an RTX PRO 6000 with LTX-2: rounds 2 and 3 CLAIM INVALID). Time
48
+ ComfyUI launched with ``--cache-classic``, which keeps only the last prompt's outputs. What matters is a cached
49
+ node of a costly stage (COSTLY: text_encode, image_encode, denoise, upscale, vae_decode, audio_decode,
50
+ mux, from the class name, see STAGES): part of the clip was then not computed, so its id goes into
51
+ ``ClipResult.cache`` and bench clip marks the claim invalid. Every other node may be cached: loaders (the
52
+ normal warm state, like weights staying in a server's memory) and constant helpers that cost nothing to
53
+ rebuild and are reused on every clip after the first (empty latents, KSamplerSelect, schedulers and
54
+ sigmas, guiders, noise of an unchanged seed). A negative-prompt encoder whose text never changes is
55
+ cached by ComfyUI after the first clip: list it in ``allow_cached`` to allow that one reuse, which every
56
+ engine may make (a clip HTTP engine reports it as cache ["negative_text_encode"]) and bench clip states as
57
+ a limitation. Only such a node may be listed there among the costly ones: a text encoder that no binding
58
+ reaches (its text is fixed) and that feeds an input named ``negative...``; anything else is refused. A
59
+ reused one is reported as ``ClipResult.cache_allowed`` = ["negative_text_encode"]. A prompt's own encoder
60
+ is cached when two clips in a row share the prompt: bench clip warms up with prompts of its own and orders
61
+ the clips so neighbours differ, gates clip gives each probe pair a prompt and image file of its own, and a
62
+ cached one makes the claim invalid. The record shows the allowed nodes (describe) and per clip what was
63
+ skipped (effective.cached_allowed). The class-name rules were written from ComfyUI's core nodes and the
64
+ LTX-Video ones as remembered, not checked against the LTX-2 template: check describe().allow_cached,
65
+ fixed_encoders and sampler_nodes on the GPU host.
66
+
67
+ Timeout: the client drops its prompt from the queue (``POST /queue {"delete": [id]}``) and interrupts
68
+ it only when ``GET /queue`` shows it running (``POST /interrupt {"prompt_id": id}``), so a prompt left
69
+ behind does not run during the next timed clip and another client's prompt is not interrupted.
70
+
71
+ What is reported (``effective``): steps_run = for each sampler node, the highest progress value seen on
72
+ the websocket, summed; absent when a sampler sent no progress or there was no websocket, and then
73
+ ``steps_unchecked`` says why. ``websocket`` is "connected" or why the client polled /history instead
74
+ (e.g. the upgrade was refused). width, height, frames, fps as submitted (bench clip does not decode the
75
+ file: gates clip does). ``stage_ms`` (websocket only): queue_wait, the node times grouped by stage (the
76
+ costly ones, load and other), upload and fetch.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ import base64
82
+ import copy
83
+ import hashlib
84
+ import http.client
85
+ import json
86
+ import os
87
+ import re
88
+ import socket
89
+ import struct
90
+ import time
91
+ import uuid
92
+ from dataclasses import dataclass, field
93
+ from pathlib import Path
94
+ from urllib.parse import quote, urlencode
95
+
96
+ FORMAT = "inferencekit-comfyui-workflow/1"
97
+ REQUIRED_BINDINGS = ("prompt", "seed", "steps", "width", "height", "num_frames", "fps", "guidance_scale")
98
+ OPTIONAL_BINDINGS = ("image", "noise_seed", "audio")
99
+ VIDEO_EXT = (".mp4", ".webm", ".mov", ".mkv", ".gif", ".webp")
100
+ LOADER = re.compile(r"Loader|^Load")
101
+ # the nodes that run a denoise loop (KSampler, KSamplerAdvanced, SamplerCustom, SamplerCustomAdvanced,
102
+ # LTXVBaseSampler...); not KSamplerSelect or the Sampler<Name> nodes that only build a sampler object
103
+ SAMPLER = re.compile(r"^KSampler(?!Select)|^SamplerCustom|Sampler$")
104
+ # class_type pattern -> stage name for node times (first match wins: a text-encoder loader is a loader,
105
+ # an audio VAE decoder is audio)
106
+ STAGES = ((LOADER, "load"), (re.compile(r"TextEncode|Conditioning"), "text_encode"),
107
+ (re.compile(r"VAEEncode|ImgToVideo|ImageToVideo"), "image_encode"), (SAMPLER, "denoise"),
108
+ (re.compile(r"Audio.*Decode|DecodeAudio|Vocoder"), "audio_decode"),
109
+ (re.compile(r"VAEDecode|VideoDecode"), "vae_decode"),
110
+ (re.compile(r"VideoCombine|SaveVideo|CreateVideo|SaveWEBM|SaveAnimated"), "mux"),
111
+ (re.compile(r"Upscale|Upsampl"), "upscale"))
112
+ # a cached node of these stages means part of the clip was not computed; any other node may be cached
113
+ COSTLY = frozenset({"text_encode", "image_encode", "denoise", "upscale", "vae_decode", "audio_decode", "mux"})
114
+ WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
115
+ MAX_ERROR = 2000
116
+
117
+
118
+ class WorkflowError(ValueError):
119
+ """The workflow file or a request does not fit (bad bindings, a setting without a place to go)."""
120
+
121
+
122
+ @dataclass
123
+ class Workflow:
124
+ """A pinned workflow file: the API-format graph, its bindings and the cache/sampler/output rules."""
125
+
126
+ graph: dict
127
+ bindings: dict
128
+ sha256: str
129
+ output_node: str | None = None
130
+ allow_cached: frozenset = frozenset()
131
+ sampler_nodes: frozenset = frozenset()
132
+ fixed_encoders: frozenset = frozenset() # allowed costly nodes: fixed negative-prompt encoders
133
+ audio: bool | None = None # the graph always (True) or never (False) makes audio; None: bound or unknown
134
+
135
+ @classmethod
136
+ def load(cls, path: str | Path) -> Workflow:
137
+ raw = Path(path).read_bytes()
138
+ return cls.parse(json.loads(raw), hashlib.sha256(raw).hexdigest())
139
+
140
+ @classmethod
141
+ def parse(cls, doc: dict, sha256: str = "") -> Workflow:
142
+ if not isinstance(doc, dict) or doc.get("format") != FORMAT:
143
+ raise WorkflowError(f"the workflow file must be a JSON object with format {FORMAT!r}")
144
+ graph, bindings = doc.get("workflow"), doc.get("bindings")
145
+ if not isinstance(graph, dict) or not graph or not all(
146
+ isinstance(n, dict) and "class_type" in n and isinstance(n.get("inputs"), dict) for n in graph.values()):
147
+ raise WorkflowError("workflow must be ComfyUI's API format: {node_id: {class_type, inputs}}")
148
+ if not isinstance(bindings, dict):
149
+ raise WorkflowError("bindings must map each clip setting to [[node_id, input_name], ...]")
150
+ unknown = sorted(set(bindings) - set(REQUIRED_BINDINGS) - set(OPTIONAL_BINDINGS))
151
+ missing = [k for k in REQUIRED_BINDINGS if not bindings.get(k)]
152
+ if unknown or missing:
153
+ raise WorkflowError(f"bindings: unknown {unknown}, missing {missing}")
154
+ for key, targets in bindings.items():
155
+ if not isinstance(targets, list) or not targets:
156
+ raise WorkflowError(f"bindings.{key} must be a non-empty list of [node_id, input_name]")
157
+ for t in targets:
158
+ if not (isinstance(t, list) and len(t) == 2 and str(t[0]) in graph
159
+ and t[1] in graph[str(t[0])]["inputs"]):
160
+ raise WorkflowError(f"bindings.{key}: {t!r} is not an input of a node in the workflow")
161
+ out = doc.get("output_node")
162
+ if out is not None and str(out) not in graph:
163
+ raise WorkflowError(f"output_node {out!r} is not in the workflow")
164
+
165
+ def ids(key):
166
+ v = doc.get(key)
167
+ if v is None:
168
+ return None
169
+ if not isinstance(v, list) or any(str(i) not in graph for i in v):
170
+ raise WorkflowError(f"{key} must list node ids of the workflow")
171
+ return frozenset(str(i) for i in v)
172
+
173
+ def where(rule):
174
+ return frozenset(i for i, n in graph.items() if rule(str(n["class_type"])))
175
+
176
+ cheap = where(lambda c: stage_of(c) not in COSTLY)
177
+ extra = ids("allow_cached") or frozenset()
178
+ encoders = frozenset(i for i in extra if i not in cheap)
179
+ bound = {str(node) for targets in bindings.values() for node, _ in targets}
180
+ for i in sorted(encoders):
181
+ why = _not_fixed_negative(graph, bound, i)
182
+ if why:
183
+ raise WorkflowError(f"allow_cached: node {i} ({graph[i]['class_type']}) {why}; only the fixed "
184
+ "negative prompt's encoder may be reused (a text encoder no binding reaches, "
185
+ "feeding a 'negative' input)")
186
+ samplers = ids("sampler_nodes") # an explicit [] means none: steps_run is then not reported
187
+ audio = doc.get("audio")
188
+ if audio is not None and not isinstance(audio, bool):
189
+ raise WorkflowError("audio must be true (the graph always makes an audio track) or false")
190
+ if audio is not None and "audio" in bindings:
191
+ raise WorkflowError("give an audio binding or a fixed audio value, not both")
192
+ return cls(graph=graph, bindings={k: [[str(a), b] for a, b in v] for k, v in bindings.items()},
193
+ sha256=sha256, output_node=str(out) if out is not None else None,
194
+ allow_cached=cheap | extra, fixed_encoders=encoders, audio=audio,
195
+ sampler_nodes=samplers if samplers is not None else where(lambda c: bool(SAMPLER.search(c))))
196
+
197
+ def build(self, values: dict) -> dict:
198
+ """A copy of the graph with each bound input set; WorkflowError for a value with no binding."""
199
+ g = copy.deepcopy(self.graph)
200
+ for key, value in values.items():
201
+ if value is None:
202
+ continue
203
+ if key not in self.bindings:
204
+ raise WorkflowError(f"the request sets {key} but the workflow has no binding for it")
205
+ for node, name in self.bindings[key]:
206
+ g[node]["inputs"][name] = value
207
+ return g
208
+
209
+ def class_of(self, node: str) -> str:
210
+ return str(self.graph.get(str(node), {}).get("class_type", ""))
211
+
212
+
213
+ def cache_mode(flags: list) -> str:
214
+ """ComfyUI's node cache from its launch flags: classic keeps only the last prompt's outputs; the default
215
+ (ram, ComfyUI 0.40) and lru keep many earlier prompts' outputs; none recomputes every node, loaders too."""
216
+ for flag, mode in (("--cache-classic", "classic"), ("--cache-lru", "lru"), ("--cache-none", "none")):
217
+ if flag in flags:
218
+ return mode
219
+ return "ram"
220
+
221
+
222
+ def stage_of(class_type: str) -> str:
223
+ for rx, name in STAGES:
224
+ if rx.search(class_type):
225
+ return name
226
+ return "other"
227
+
228
+
229
+ def _links(node: dict):
230
+ """(input name, upstream node id) for each input of an API-format node that is a link [node_id, output]."""
231
+ for name, value in node["inputs"].items():
232
+ if isinstance(value, list) and len(value) == 2 and isinstance(value[1], int) and not isinstance(value[0], list):
233
+ yield name, str(value[0])
234
+
235
+
236
+ def _not_fixed_negative(graph: dict, bound: set, node: str) -> str | None:
237
+ """Why ``node`` is not a fixed negative-prompt encoder (None when it is): a text_encode node whose own
238
+ inputs and upstream nodes no binding sets (so its output is the same on every clip), feeding an input
239
+ whose name starts with 'negative'."""
240
+ if stage_of(str(graph[node]["class_type"])) != "text_encode":
241
+ return f"is a {stage_of(str(graph[node]['class_type']))} node, not a text encoder"
242
+ seen, todo = set(), [node]
243
+ while todo:
244
+ n = todo.pop()
245
+ if n in seen or n not in graph:
246
+ continue
247
+ seen.add(n)
248
+ if n in bound:
249
+ return "depends on a clip setting" if n != node else "has a bound input (it changes per clip)"
250
+ todo += [up for _, up in _links(graph[n])]
251
+ feeds = [f"{i}.{name}" for i, n in graph.items() for name, up in _links(n) if up == node]
252
+ if not any(f.split(".", 1)[1].lower().startswith("negative") for f in feeds):
253
+ return f"feeds no 'negative' input ({feeds or 'nothing'})"
254
+ return None
255
+
256
+
257
+ # --------------------------------------------------------------------------- websocket (RFC 6455, client side)
258
+
259
+
260
+ class WebSocket:
261
+ """Just enough of a websocket client to read ComfyUI's messages: text and binary frames, ping/pong, close."""
262
+
263
+ def __init__(self, host: str, port: int, path: str, timeout: float = 10.0, headers: dict | None = None):
264
+ self.sock = socket.create_connection((host, port), timeout=timeout)
265
+ self.buf = b""
266
+ self.closed = False
267
+ key = base64.b64encode(os.urandom(16)).decode()
268
+ extra = "".join(f"{k}: {v}\r\n" for k, v in (headers or {}).items())
269
+ self.sock.sendall((f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
270
+ f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n"
271
+ f"{extra}\r\n").encode())
272
+ deadline = time.monotonic() + timeout
273
+ while b"\r\n\r\n" not in self.buf:
274
+ self._fill(len(self.buf) + 1, deadline)
275
+ head, self.buf = self.buf.split(b"\r\n\r\n", 1)
276
+ lines = head.decode("latin-1").split("\r\n")
277
+ accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
278
+ headers = {k.strip().lower(): v.strip() for k, _, v in (ln.partition(":") for ln in lines[1:])}
279
+ if " 101 " not in lines[0] + " " or headers.get("sec-websocket-accept") != accept:
280
+ self.sock.close()
281
+ raise ConnectionError(f"websocket upgrade refused: {lines[0][:80]}")
282
+
283
+ def _fill(self, n: int, deadline: float) -> None:
284
+ while len(self.buf) < n:
285
+ left = deadline - time.monotonic()
286
+ if left <= 0:
287
+ raise TimeoutError("websocket read timed out")
288
+ self.sock.settimeout(left)
289
+ chunk = self.sock.recv(65536) # socket.timeout is TimeoutError
290
+ if not chunk:
291
+ self.closed = True
292
+ raise ConnectionError("websocket closed by the server")
293
+ self.buf += chunk
294
+
295
+ def _frame(self, deadline: float) -> tuple[bool, int, bytes]:
296
+ self._fill(2, deadline)
297
+ b0, b1 = self.buf[0], self.buf[1]
298
+ n, pos = b1 & 0x7F, 2
299
+ if n == 126:
300
+ self._fill(4, deadline)
301
+ n, pos = struct.unpack(">H", self.buf[2:4])[0], 4
302
+ elif n == 127:
303
+ self._fill(10, deadline)
304
+ n, pos = struct.unpack(">Q", self.buf[2:10])[0], 10
305
+ mask = b""
306
+ if b1 & 0x80:
307
+ self._fill(pos + 4, deadline)
308
+ mask, pos = self.buf[pos:pos + 4], pos + 4
309
+ self._fill(pos + n, deadline) # the whole frame is buffered before anything is consumed
310
+ payload, self.buf = self.buf[pos:pos + n], self.buf[pos + n:]
311
+ if mask:
312
+ payload = bytes(c ^ mask[i % 4] for i, c in enumerate(payload))
313
+ return bool(b0 & 0x80), b0 & 0x0F, payload
314
+
315
+ def send(self, opcode: int, payload: bytes = b"") -> None:
316
+ mask = os.urandom(4)
317
+ n = len(payload)
318
+ head = bytes([0x80 | opcode]) + (bytes([0x80 | n]) if n < 126 else
319
+ bytes([0x80 | 126]) + struct.pack(">H", n) if n < 65536 else
320
+ bytes([0x80 | 127]) + struct.pack(">Q", n))
321
+ self.sock.sendall(head + mask + bytes(c ^ mask[i % 4] for i, c in enumerate(payload)))
322
+
323
+ def recv(self, timeout: float) -> tuple[int, bytes]:
324
+ """(opcode, payload) of the next whole message: 1 text, 2 binary, 8 close. TimeoutError when none came."""
325
+ deadline = time.monotonic() + timeout
326
+ parts, first = [], None
327
+ while True:
328
+ fin, op, payload = self._frame(deadline)
329
+ if op == 9: # ping
330
+ self.send(10, payload)
331
+ continue
332
+ if op == 10:
333
+ continue
334
+ if op == 8:
335
+ self.closed = True
336
+ return 8, payload
337
+ if op in (1, 2):
338
+ first, parts = op, [payload]
339
+ elif op == 0 and first is not None:
340
+ parts.append(payload)
341
+ if fin and first is not None:
342
+ return first, b"".join(parts)
343
+
344
+ def close(self) -> None:
345
+ try:
346
+ if not self.closed:
347
+ self.send(8, struct.pack(">H", 1000))
348
+ except OSError:
349
+ pass
350
+ self.sock.close()
351
+
352
+
353
+ # --------------------------------------------------------------------------- the client
354
+
355
+
356
+ @dataclass
357
+ class ComfyRun:
358
+ """One clip through ComfyUI: client-clock times (perf_counter_ns) and what was observed."""
359
+
360
+ ok: bool = False
361
+ error: str | None = None
362
+ status: int | None = None
363
+ prompt_id: str | None = None
364
+ t_send_ns: int = 0
365
+ t_connected_ns: int | None = None
366
+ t_done_ns: int | None = None
367
+ t_end_ns: int = 0
368
+ video: bytes | None = field(default=None, repr=False)
369
+ format: str = "mp4"
370
+ cached: list = field(default_factory=list) # every node ComfyUI reported in execution_cached
371
+ progress: dict = field(default_factory=dict) # node -> highest progress value seen
372
+ stage_ms: dict = field(default_factory=dict)
373
+ websocket: bool = False # True when the websocket watched the whole prompt
374
+ ws_note: str = "" # "connected", or why the client polled /history instead
375
+
376
+
377
+ class ComfyUIClient:
378
+ def __init__(self, host: str, port: int, *, scheme: str = "http", prefix: str = "", api_key: str | None = None,
379
+ timeout_s: float = 1800.0, poll_s: float = 0.05, websocket: bool = True):
380
+ self.ws_off = None if websocket else "turned off"
381
+ if scheme != "http" and websocket: # the websocket reader here is plain TCP; https polls /history
382
+ websocket, self.ws_off = False, f"not used over {scheme} (the reader here is plain TCP)"
383
+ self.host, self.port, self.scheme, self.prefix = host, port, scheme, prefix.rstrip("/")
384
+ self.api_key, self.timeout_s, self.poll_s, self.use_ws = api_key, timeout_s, poll_s, websocket
385
+ self.client_id = uuid.uuid4().hex
386
+
387
+ def _conn(self, timeout: float) -> http.client.HTTPConnection:
388
+ cls = http.client.HTTPSConnection if self.scheme == "https" else http.client.HTTPConnection
389
+ return cls(self.host, self.port, timeout=timeout)
390
+
391
+ def _headers(self, extra: dict | None = None) -> dict:
392
+ h = dict(extra or {})
393
+ if self.api_key:
394
+ h["Authorization"] = f"Bearer {self.api_key}"
395
+ return h
396
+
397
+ def request(self, method: str, path: str, body: bytes | None = None, headers: dict | None = None,
398
+ timeout: float = 30.0, conn: http.client.HTTPConnection | None = None) -> tuple[int, bytes]:
399
+ own = conn is None
400
+ conn = conn or self._conn(timeout)
401
+ try:
402
+ conn.request(method, self.prefix + path, body=body, headers=self._headers(headers))
403
+ resp = conn.getresponse()
404
+ return resp.status, resp.read()
405
+ finally:
406
+ if own:
407
+ conn.close()
408
+
409
+ def _json(self, method: str, path: str, doc=None, timeout: float = 30.0):
410
+ body = json.dumps(doc).encode() if doc is not None else None
411
+ status, raw = self.request(method, path, body, {"Content-Type": "application/json"} if body else None, timeout)
412
+ if status != 200:
413
+ raise RuntimeError(f"{method} {path}: HTTP {status}: {raw[:MAX_ERROR].decode(errors='replace').strip()}")
414
+ return json.loads(raw) if raw.strip() else {}
415
+
416
+ def system_stats(self) -> dict:
417
+ return self._json("GET", "/system_stats", timeout=10.0)
418
+
419
+ def history(self, prompt_id: str) -> dict:
420
+ return self._json("GET", f"/history/{quote(prompt_id)}").get(prompt_id) or {}
421
+
422
+ def _post_quiet(self, path: str, doc: dict) -> None:
423
+ try:
424
+ self.request("POST", path, json.dumps(doc).encode(), {"Content-Type": "application/json"}, timeout=5.0)
425
+ except (OSError, http.client.HTTPException):
426
+ pass
427
+
428
+ def cancel(self, prompt_id: str | None) -> None:
429
+ """After a timeout: stop our prompt, and only ours, so it does not run during the next timed clip.
430
+
431
+ It is dropped from the queue if still waiting, and interrupted (by id: ComfyUI versions that take
432
+ prompt_id interrupt only that prompt, older ones whatever runs) only when GET /queue shows it
433
+ running or the queue cannot be read. Without a prompt_id (POST /prompt itself timed out) there is
434
+ nothing to name, so nothing is sent.
435
+ """
436
+ if not prompt_id:
437
+ return
438
+ running = True
439
+ try:
440
+ q = self._json("GET", "/queue", timeout=5.0)
441
+ running = any(isinstance(item, list) and len(item) > 1 and item[1] == prompt_id
442
+ for item in q.get("queue_running") or [])
443
+ except (OSError, http.client.HTTPException, RuntimeError, ValueError, AttributeError):
444
+ pass
445
+ if running:
446
+ self._post_quiet("/interrupt", {"prompt_id": prompt_id})
447
+ self._post_quiet("/queue", {"delete": [prompt_id]})
448
+
449
+ def upload_image(self, data: bytes, name: str) -> str:
450
+ """POST /upload/image (multipart); returns the name to put in a LoadImage node."""
451
+ boundary = uuid.uuid4().hex
452
+ parts = [f'--{boundary}\r\nContent-Disposition: form-data; name="overwrite"\r\n\r\ntrue\r\n'.encode(),
453
+ (f'--{boundary}\r\nContent-Disposition: form-data; name="image"; filename="{name}"\r\n'
454
+ f"Content-Type: application/octet-stream\r\n\r\n").encode() + data + b"\r\n",
455
+ f"--{boundary}--\r\n".encode()]
456
+ status, raw = self.request("POST", "/upload/image", b"".join(parts),
457
+ {"Content-Type": f"multipart/form-data; boundary={boundary}"}, timeout=60.0)
458
+ if status != 200:
459
+ raise RuntimeError(f"image upload: HTTP {status}: {raw[:MAX_ERROR].decode(errors='replace').strip()}")
460
+ doc = json.loads(raw)
461
+ return f"{doc['subfolder']}/{doc['name']}" if doc.get("subfolder") else doc["name"]
462
+
463
+ def _websocket(self) -> tuple[WebSocket | None, str]:
464
+ """(the websocket, "connected"), or (None, why there is none: the client then polls /history)."""
465
+ if not self.use_ws:
466
+ return None, self.ws_off or "turned off"
467
+ try:
468
+ return WebSocket(self.host, self.port, f"{self.prefix}/ws?clientId={self.client_id}", timeout=10.0,
469
+ headers=self._headers()), "connected"
470
+ except (OSError, ConnectionError, TimeoutError) as e:
471
+ return None, f"{type(e).__name__}: {e}"[:200]
472
+
473
+ def run(self, wf: Workflow, values: dict, image: bytes | None = None) -> ComfyRun:
474
+ """Queue one clip, wait for it, fetch its file. Never raises for server-side failures (ok=False)."""
475
+ run = ComfyRun()
476
+ graph = wf.build(values) # before the timer: building the body is client-side preparation
477
+ ws, run.ws_note = self._websocket()
478
+ run.websocket = ws is not None
479
+ run.t_send_ns = time.perf_counter_ns()
480
+ deadline = time.monotonic() + self.timeout_s
481
+ try:
482
+ if image is not None:
483
+ if "image" not in wf.bindings:
484
+ raise WorkflowError("task i2v needs an image binding in the workflow")
485
+ t0 = time.perf_counter_ns()
486
+ name = self.upload_image(image, f"ik-{hashlib.sha256(image).hexdigest()[:16]}.png")
487
+ run.stage_ms["upload"] = (time.perf_counter_ns() - t0) / 1e6
488
+ for node, inp in wf.bindings["image"]:
489
+ graph[node]["inputs"][inp] = name
490
+ conn = self._conn(60.0)
491
+ try:
492
+ conn.connect()
493
+ run.t_connected_ns = time.perf_counter_ns()
494
+ status, raw = self.request("POST", "/prompt", json.dumps(
495
+ {"prompt": graph, "client_id": self.client_id}).encode(), {"Content-Type": "application/json"},
496
+ conn=conn)
497
+ finally:
498
+ conn.close()
499
+ run.status = status
500
+ if status != 200:
501
+ raise RuntimeError(f"POST /prompt: HTTP {status}: {raw[:MAX_ERROR].decode(errors='replace').strip()}")
502
+ run.prompt_id = json.loads(raw)["prompt_id"]
503
+ t_queued = time.perf_counter_ns()
504
+ if ws is not None:
505
+ self._watch(ws, wf, run, t_queued, deadline)
506
+ entry = self._wait_history(run.prompt_id, deadline)
507
+ self._read_status(entry, run)
508
+ if run.error is None:
509
+ t0 = time.perf_counter_ns()
510
+ run.video, run.format = self._fetch(wf, entry)
511
+ run.t_done_ns = time.perf_counter_ns()
512
+ run.stage_ms["fetch"] = (run.t_done_ns - t0) / 1e6
513
+ run.ok = len(run.video) > 0
514
+ if not run.ok:
515
+ run.error = "empty clip"
516
+ except TimeoutError:
517
+ run.error = f"no result within {self.timeout_s:g} s"
518
+ self.cancel(run.prompt_id)
519
+ except (OSError, http.client.HTTPException, RuntimeError, ValueError, KeyError) as e:
520
+ run.error = f"{type(e).__name__}: {e}"
521
+ finally:
522
+ if ws is not None:
523
+ ws.close()
524
+ run.t_end_ns = time.perf_counter_ns()
525
+ if run.error:
526
+ run.ok, run.video = False, None
527
+ run.error = run.error[:MAX_ERROR]
528
+ return run
529
+
530
+ def _watch(self, ws: WebSocket, wf: Workflow, run: ComfyRun, t_queued: int, deadline: float) -> None:
531
+ """Read websocket messages for this prompt until it ends; record cache, progress and node times."""
532
+ current, t_node = None, None
533
+ nodes_ms: dict[str, float] = {}
534
+
535
+ def close_node(t):
536
+ if current is not None:
537
+ stage = stage_of(wf.class_of(current))
538
+ nodes_ms[stage] = nodes_ms.get(stage, 0.0) + (t - t_node) / 1e6
539
+
540
+ while True:
541
+ try:
542
+ op, payload = ws.recv(max(0.0, deadline - time.monotonic()))
543
+ except ConnectionError:
544
+ op, payload = 8, b""
545
+ if op == 8: # closed early: the history poll still finds the result and the cached nodes
546
+ run.websocket, run.ws_note = False, "closed by ComfyUI during the clip"
547
+ return
548
+ if op != 1:
549
+ continue # binary frames are previews
550
+ t = time.perf_counter_ns()
551
+ try:
552
+ msg = json.loads(payload)
553
+ except ValueError:
554
+ continue
555
+ kind, data = msg.get("type"), msg.get("data") or {}
556
+ if data.get("prompt_id") not in (None, run.prompt_id):
557
+ continue
558
+ if kind == "execution_start":
559
+ run.stage_ms["queue_wait"] = (t - t_queued) / 1e6
560
+ elif kind == "execution_cached":
561
+ run.cached.extend(str(n) for n in data.get("nodes") or [])
562
+ elif kind == "progress" and data.get("node") is not None:
563
+ node = str(data["node"])
564
+ run.progress[node] = max(run.progress.get(node, 0), int(data.get("value") or 0))
565
+ elif kind == "executing":
566
+ close_node(t)
567
+ current, t_node = (str(data["node"]), t) if data.get("node") is not None else (None, None)
568
+ if data.get("node") is None and data.get("prompt_id") == run.prompt_id:
569
+ break
570
+ elif kind in ("execution_success", "execution_error", "execution_interrupted"):
571
+ close_node(t)
572
+ break
573
+ run.stage_ms.update({k: round(v, 3) for k, v in nodes_ms.items()})
574
+
575
+ def _wait_history(self, prompt_id: str, deadline: float) -> dict:
576
+ while True:
577
+ entry = self.history(prompt_id)
578
+ status = entry.get("status") or {}
579
+ if entry and (status.get("completed") or status.get("status_str") in ("success", "error")):
580
+ return entry
581
+ if time.monotonic() >= deadline:
582
+ raise TimeoutError
583
+ time.sleep(self.poll_s)
584
+
585
+ @staticmethod
586
+ def _read_status(entry: dict, run: ComfyRun) -> None:
587
+ status = entry.get("status") or {}
588
+ for msg in status.get("messages") or []:
589
+ if not (isinstance(msg, list) and len(msg) == 2 and isinstance(msg[1], dict)):
590
+ continue
591
+ kind, data = msg
592
+ if kind == "execution_cached":
593
+ run.cached.extend(str(n) for n in data.get("nodes") or [] if str(n) not in run.cached)
594
+ elif kind == "execution_error":
595
+ run.error = (f"ComfyUI error in node {data.get('node_id')} ({data.get('node_type')}): "
596
+ f"{data.get('exception_message', '').strip()}")
597
+ elif kind == "execution_interrupted":
598
+ run.error = "ComfyUI interrupted the prompt"
599
+ if run.error is None and status.get("status_str") == "error":
600
+ run.error = "ComfyUI reports status error"
601
+
602
+ def _fetch(self, wf: Workflow, entry: dict) -> tuple[bytes, str]:
603
+ files = []
604
+ for node, out in (entry.get("outputs") or {}).items():
605
+ if wf.output_node is not None and str(node) != wf.output_node:
606
+ continue
607
+ for value in out.values() if isinstance(out, dict) else []:
608
+ for f in value if isinstance(value, list) else []:
609
+ if isinstance(f, dict) and f.get("filename", "").lower().endswith(VIDEO_EXT):
610
+ files.append(f)
611
+ if not files:
612
+ raise RuntimeError("no video file in the prompt's outputs" +
613
+ (f" of node {wf.output_node}" if wf.output_node else ""))
614
+ f = files[0]
615
+ q = urlencode({"filename": f["filename"], "subfolder": f.get("subfolder", ""), "type": f.get("type", "output")})
616
+ status, raw = self.request("GET", f"/view?{q}", timeout=300.0)
617
+ if status != 200:
618
+ raise RuntimeError(f"GET /view: HTTP {status}")
619
+ return raw, Path(f["filename"]).suffix.lstrip(".").lower() or "bin"
620
+
621
+
622
+ # --------------------------------------------------------------------------- the bench clip backend
623
+
624
+
625
+ def request_values(request, wf: Workflow) -> dict:
626
+ """The bound values for a clip request (inferencekit.serve.clip_client.ClipRequest)."""
627
+ seed = request.seed
628
+ values = {"prompt": request.prompt, "steps": request.steps, "width": request.width, "height": request.height,
629
+ "num_frames": request.num_frames, "fps": request.fps, "guidance_scale": request.guidance_scale}
630
+ if request.noise_seed is not None and "noise_seed" in wf.bindings:
631
+ values["noise_seed"] = request.noise_seed
632
+ elif request.noise_seed is not None:
633
+ seed = request.noise_seed # the sampler's seed is its noise seed
634
+ values["seed"] = seed
635
+ if request.audio is not None:
636
+ if "audio" not in wf.bindings and wf.audio is not None:
637
+ if request.audio != wf.audio:
638
+ raise WorkflowError(f"the request asks audio={str(request.audio).lower()} but this workflow "
639
+ f"always makes audio={str(wf.audio).lower()}")
640
+ else:
641
+ values["audio"] = request.audio
642
+ return values
643
+
644
+
645
+ def observed(run: ComfyRun, wf: Workflow) -> dict:
646
+ """What the client saw of one run, for ``effective``: steps_run when every sampler node reported its
647
+ progress (else ``steps_unchecked``: why not), the cached nodes that were allowed, and the websocket."""
648
+ eff = {"cached_allowed": sorted({n for n in run.cached if n in wf.allow_cached}), "websocket": run.ws_note}
649
+ silent = sorted(wf.sampler_nodes - set(run.progress))
650
+ if not run.websocket:
651
+ eff["steps_unchecked"] = f"no websocket ({run.ws_note})"
652
+ elif not wf.sampler_nodes:
653
+ eff["steps_unchecked"] = "the workflow has no sampler node (set sampler_nodes)"
654
+ elif silent: # a count from some samplers only would be a wrong count, not a check
655
+ eff["steps_unchecked"] = f"no progress from sampler node(s) {silent} on the websocket"
656
+ else:
657
+ eff["steps_run"] = sum(run.progress[n] for n in wf.sampler_nodes)
658
+ return eff
659
+
660
+
661
+ class ComfyUIBackend:
662
+ """bench clip's ``comfyui`` endpoint kind (see inferencekit.serve.clip_client.ClipBackend)."""
663
+
664
+ endpoint_kind = "comfyui"
665
+
666
+ def __init__(self, ep, timeout_s: float = 1800.0, workflow: str | None = None, *, websocket: bool = True,
667
+ poll_s: float = 0.05):
668
+ if not workflow:
669
+ raise ValueError("the comfyui backend needs --workflow (an inferencekit ComfyUI workflow file)")
670
+ self.ep = ep
671
+ self.wf = Workflow.load(workflow)
672
+ self.client = ComfyUIClient(ep.host, ep.port, scheme=ep.scheme, prefix=ep.prefix, api_key=ep.api_key,
673
+ timeout_s=timeout_s, poll_s=poll_s, websocket=websocket)
674
+ self._version: dict = {}
675
+
676
+ def health(self) -> tuple[int | None, str]:
677
+ from inferencekit.serve.client import scrub
678
+
679
+ try:
680
+ stats = self.client.system_stats()
681
+ except RuntimeError as e:
682
+ m = re.search(r"HTTP (\d+)", str(e))
683
+ return (int(m.group(1)) if m else None), scrub(str(e), self.ep)[:200]
684
+ except (OSError, http.client.HTTPException, ValueError) as e:
685
+ return None, scrub(f"{type(e).__name__}: {e}", self.ep)
686
+ system = stats.get("system") or {}
687
+ self._version = {k: system[k] for k in ("comfyui_version", "pytorch_version") if system.get(k)}
688
+ self._version["devices"] = [d.get("name") for d in stats.get("devices") or [] if isinstance(d, dict)]
689
+ if isinstance(system.get("argv"), list): # flag names only (values may be paths); the cache mode
690
+ flags = [str(a).split("=", 1)[0] for a in system["argv"] if str(a).startswith("--")]
691
+ self._version["launch_flags"] = flags
692
+ self._version["cache_mode"] = cache_mode(flags)
693
+ return 200, f"comfyui {system.get('comfyui_version', '?')}"
694
+
695
+ def generate(self, request):
696
+ from inferencekit.serve.client import scrub
697
+ from inferencekit.serve.clip_client import NEGATIVE_PROMPT_CACHE, ClipResult
698
+
699
+ res = ClipResult(id=request.id)
700
+ try:
701
+ values = request_values(request, self.wf)
702
+ if request.task == "i2v" and request.image is None:
703
+ raise WorkflowError("task i2v needs an image")
704
+ run = self.client.run(self.wf, values, image=request.image if request.task == "i2v" else None)
705
+ except WorkflowError as e:
706
+ res.error, res.t_send_ns = f"workflow: {e}", time.perf_counter_ns()
707
+ res.t_end_ns = res.t_send_ns
708
+ return res
709
+ res.ok, res.status, res.video, res.format = run.ok, run.status, run.video, run.format
710
+ res.t_send_ns, res.t_connected_ns, res.t_done_ns, res.t_end_ns = (run.t_send_ns, run.t_connected_ns,
711
+ run.t_done_ns, run.t_end_ns)
712
+ res.error = scrub(run.error, self.ep) if run.error else None
713
+ res.via = "view"
714
+ res.cache = sorted({n for n in run.cached if n not in self.wf.allow_cached})
715
+ res.cache_allowed = [NEGATIVE_PROMPT_CACHE] if self.wf.fixed_encoders & set(run.cached) else []
716
+ eff = {"width": request.width, "height": request.height, "frames": request.num_frames, "fps": request.fps,
717
+ "shape_source": "submitted", **observed(run, self.wf)}
718
+ if self.wf.audio is not None:
719
+ eff["audio"] = self.wf.audio # as the workflow file declares it; gates clip decodes the track
720
+ for key in ("websocket", "steps_unchecked"): # error texts go into evidence
721
+ if key in eff:
722
+ eff[key] = scrub(eff[key], self.ep)
723
+ res.effective = eff
724
+ res.stage_ms = run.stage_ms or None
725
+ return res
726
+
727
+ def describe(self) -> dict:
728
+ return {"endpoint_kind": self.endpoint_kind, "workflow_sha256": self.wf.sha256, **self._version,
729
+ "bindings": self.wf.bindings, "allow_cached": sorted(self.wf.allow_cached),
730
+ "fixed_encoders": sorted(self.wf.fixed_encoders),
731
+ "sampler_nodes": sorted(self.wf.sampler_nodes), "output_node": self.wf.output_node,
732
+ "audio": self.wf.audio,
733
+ "websocket": self.client.use_ws}
734
+
735
+ def close(self) -> None:
736
+ return None
737
+
738
+
739
+ def register() -> None:
740
+ """Make the ``comfyui`` endpoint kind available to bench clip (inferencekit.serve.clip_client)."""
741
+ from inferencekit.serve.clip_client import register_backend
742
+
743
+ register_backend("comfyui", ComfyUIBackend)
744
+
745
+
746
+ try: # importing this module is enough for bench clip to find the endpoint kind
747
+ register()
748
+ except ImportError: # a build without bench clip's client
749
+ pass