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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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
|