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,351 @@
|
|
|
1
|
+
"""Clients for video clip engines: the clip HTTP contract, and the interface other backends implement.
|
|
2
|
+
|
|
3
|
+
The clip HTTP contract
|
|
4
|
+
----------------------
|
|
5
|
+
GET /health 200 when the engine can take a clip (the ready check of bench rounds' clip cells).
|
|
6
|
+
POST /v1/clips JSON body:
|
|
7
|
+
task "t2v" | "i2v"
|
|
8
|
+
prompt the text prompt
|
|
9
|
+
image i2v only: the conditioning image, base64 of the image file's bytes
|
|
10
|
+
width, height, num_frames, fps, steps, guidance_scale, seed
|
|
11
|
+
noise_seed optional: seed of the initial noise, when it is given apart from ``seed``
|
|
12
|
+
audio optional: true/false, sent only when the clip set says so
|
|
13
|
+
The answer is JSON:
|
|
14
|
+
video_b64 the encoded clip, base64; or
|
|
15
|
+
video_path a file the server wrote on this machine (accepted only from a local server); the
|
|
16
|
+
answer must then also carry video_sha256 and video_bytes
|
|
17
|
+
video_sha256 sha256 (hex) and size in bytes of the finished clip: required with video_path, checked
|
|
18
|
+
video_bytes whenever given (``sha256`` and ``bytes`` are read as the same two fields)
|
|
19
|
+
format container, e.g. "mp4" (default "mp4"); letters and digits only, else "bin"
|
|
20
|
+
stage_ms {stage: ms}, the engine's own timings (SELF-REPORTED): e.g. text_encode, denoise,
|
|
21
|
+
vae_decode, audio_decode, mux. Disjoint stages; a "total"/"e2e"/"wall" key is kept but
|
|
22
|
+
not summed. Lists (e.g. per-step times) are kept and not summed either.
|
|
23
|
+
effective what the engine says it ran (SELF-REPORTED): steps_run, width, height, frames, fps,
|
|
24
|
+
dtype, attention, audio
|
|
25
|
+
cache optional: names of stages the engine served from a cache (must be empty for a timed clip),
|
|
26
|
+
except "negative_text_encode": the encoding of the engine's fixed negative prompt (a
|
|
27
|
+
launch setting, the same text for every clip) computed once and reused. That one reuse
|
|
28
|
+
is allowed for every engine (ComfyUI makes it through its node cache) and stated in the
|
|
29
|
+
record's limitations; anything else listed makes the claim invalid.
|
|
30
|
+
An answer may instead be the raw clip (Content-Type video/*), with the other fields as JSON in an
|
|
31
|
+
``X-Clip-Meta`` header. The subtype (video/mp4 -> "mp4") is a format like any other: it names the saved
|
|
32
|
+
file, so anything but letters and digits becomes "bin".
|
|
33
|
+
|
|
34
|
+
The timer
|
|
35
|
+
---------
|
|
36
|
+
The client's clock is the only timer for published numbers. ``t_send`` is taken just before the
|
|
37
|
+
connection is opened, ``t_done`` once the client holds the whole clip: the response is read and its
|
|
38
|
+
base64 decoded, or the file at ``video_path`` is read. A server cannot answer before the clip is
|
|
39
|
+
complete and finish it later: the bytes of a raw or base64 answer are the response itself, and the file
|
|
40
|
+
behind ``video_path`` must match the video_sha256 and video_bytes the answer announced, so a server that
|
|
41
|
+
answers while it is still encoding, muxing or writing the file gets a failed clip, not a shorter time (a
|
|
42
|
+
server closes the file before it answers). That check, hashing and saving happen after ``t_done``, so
|
|
43
|
+
they are not timed. Every clock is ``time.perf_counter_ns()`` (monotonic).
|
|
44
|
+
|
|
45
|
+
Other backends
|
|
46
|
+
--------------
|
|
47
|
+
``ClipBackend`` is the interface bench clip drives: ``health()``, ``generate(request)``, ``describe()``,
|
|
48
|
+
``close()``. ``ClipHTTP`` implements the contract above (endpoint kind ``clip_http``). A ComfyUI
|
|
49
|
+
backend (endpoint kind ``comfyui``) lives in ``inferencekit.engines.clip.comfyui`` when installed; that
|
|
50
|
+
module calls ``register_backend("comfyui", factory)`` on import, and ``make_backend`` imports it on
|
|
51
|
+
first use. A ComfyUI backend must keep the same timer (``t_send`` before POST /prompt, ``t_done`` when
|
|
52
|
+
the output bytes are in hand from /view), put the node ids ComfyUI reports in ``execution_cached`` for
|
|
53
|
+
timed nodes into ``ClipResult.cache`` (a reused fixed negative-prompt encoding into ``cache_allowed``),
|
|
54
|
+
and fill ``effective`` from what it submitted and the progress it saw (steps_run = sampler progress
|
|
55
|
+
steps seen). Its ``workflow`` option is the API-format workflow
|
|
56
|
+
file with the node mapping; ``describe()`` returns that file's sha256.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import base64
|
|
62
|
+
import binascii
|
|
63
|
+
import hashlib
|
|
64
|
+
import http.client
|
|
65
|
+
import importlib
|
|
66
|
+
import json
|
|
67
|
+
import time
|
|
68
|
+
from dataclasses import dataclass, field
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
from typing import Callable, Protocol
|
|
71
|
+
|
|
72
|
+
from inferencekit.serve.client import Endpoint, scrub
|
|
73
|
+
|
|
74
|
+
TASKS = ("t2v", "i2v")
|
|
75
|
+
ENDPOINT_KINDS = ("clip_http", "comfyui") # what bench clip can drive; in_process is for ops that run the pipeline
|
|
76
|
+
CLIPS_PATH = "/v1/clips"
|
|
77
|
+
HEALTH_PATH = "/health"
|
|
78
|
+
DEFAULT_TIMEOUT_S = 1800.0
|
|
79
|
+
MAX_ERROR_BODY = 2000
|
|
80
|
+
TOTAL_KEYS = ("total", "total_ms", "e2e", "e2e_ms", "wall", "wall_ms") # in stage_ms, but not stages
|
|
81
|
+
NEGATIVE_PROMPT_CACHE = "negative_text_encode" # the fixed negative prompt's encoding, computed once and reused
|
|
82
|
+
ALLOWED_CACHE = frozenset({NEGATIVE_PROMPT_CACHE}) # reuse every engine may report without invalidating a clip
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ClipRequest:
|
|
87
|
+
"""One clip to generate: a clip-set row plus the round's seed."""
|
|
88
|
+
|
|
89
|
+
id: str
|
|
90
|
+
task: str
|
|
91
|
+
prompt: str
|
|
92
|
+
width: int
|
|
93
|
+
height: int
|
|
94
|
+
num_frames: int
|
|
95
|
+
fps: float
|
|
96
|
+
steps: int
|
|
97
|
+
guidance_scale: float
|
|
98
|
+
seed: int
|
|
99
|
+
image: bytes | None = field(default=None, repr=False) # i2v: the conditioning image file's bytes
|
|
100
|
+
noise_seed: int | None = None
|
|
101
|
+
audio: bool | None = None
|
|
102
|
+
|
|
103
|
+
def body(self) -> dict:
|
|
104
|
+
"""The JSON body of POST /v1/clips."""
|
|
105
|
+
b = {"task": self.task, "prompt": self.prompt, "width": self.width, "height": self.height,
|
|
106
|
+
"num_frames": self.num_frames, "fps": self.fps, "steps": self.steps,
|
|
107
|
+
"guidance_scale": self.guidance_scale, "seed": self.seed}
|
|
108
|
+
if self.image is not None:
|
|
109
|
+
b["image"] = base64.b64encode(self.image).decode("ascii")
|
|
110
|
+
if self.noise_seed is not None:
|
|
111
|
+
b["noise_seed"] = self.noise_seed
|
|
112
|
+
if self.audio is not None:
|
|
113
|
+
b["audio"] = self.audio
|
|
114
|
+
return b
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class ClipResult:
|
|
119
|
+
id: str
|
|
120
|
+
ok: bool = False
|
|
121
|
+
error: str | None = None
|
|
122
|
+
status: int | None = None
|
|
123
|
+
t_send_ns: int = 0
|
|
124
|
+
t_connected_ns: int | None = None
|
|
125
|
+
t_done_ns: int | None = None # the whole clip is in the client's hands
|
|
126
|
+
t_end_ns: int = 0
|
|
127
|
+
video: bytes | None = field(default=None, repr=False)
|
|
128
|
+
format: str = "mp4"
|
|
129
|
+
stage_ms: dict | None = None # self-reported
|
|
130
|
+
effective: dict | None = None # self-reported
|
|
131
|
+
cache: list | None = None # stages served from a cache: any makes the claim invalid
|
|
132
|
+
cache_allowed: list | None = None # reuse that is allowed and stated (ALLOWED_CACHE)
|
|
133
|
+
via: str | None = None # how the clip arrived: video_b64, video_path or body
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def e2e_ms(self) -> float | None:
|
|
137
|
+
return (self.t_done_ns - self.t_send_ns) / 1e6 if self.ok and self.t_done_ns is not None else None
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def connect_ms(self) -> float | None:
|
|
141
|
+
return (self.t_connected_ns - self.t_send_ns) / 1e6 if self.t_connected_ns is not None else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ClipBackend(Protocol):
|
|
145
|
+
"""What bench clip drives. ``generate`` never raises for engine failures: it returns ok=False."""
|
|
146
|
+
|
|
147
|
+
endpoint_kind: str
|
|
148
|
+
|
|
149
|
+
def health(self) -> tuple[int | None, str]:
|
|
150
|
+
"""(HTTP status or None, short detail): 200 means ready."""
|
|
151
|
+
|
|
152
|
+
def generate(self, request: ClipRequest) -> ClipResult:
|
|
153
|
+
"""Generate one clip, client-timed (see the module docstring for the timer)."""
|
|
154
|
+
|
|
155
|
+
def describe(self) -> dict:
|
|
156
|
+
"""Facts for the evidence record, e.g. {"workflow_sha256": ...}; never secrets or host names."""
|
|
157
|
+
|
|
158
|
+
def close(self) -> None:
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def stage_sum_ms(stage_ms: dict | None) -> float | None:
|
|
163
|
+
"""Sum of the numeric stages (totals and per-step lists left out); None when there are none."""
|
|
164
|
+
if not isinstance(stage_ms, dict):
|
|
165
|
+
return None
|
|
166
|
+
vals = [float(v) for k, v in stage_ms.items() if k not in TOTAL_KEYS and isinstance(v, (int, float))
|
|
167
|
+
and not isinstance(v, bool)]
|
|
168
|
+
return sum(vals) if vals else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _clip_format(value) -> str:
|
|
172
|
+
"""The engine's format name as a file extension: up to 8 ASCII letters and digits, else "bin"."""
|
|
173
|
+
f = str(value or "mp4").lower().lstrip(".")
|
|
174
|
+
return f if f.isascii() and f.isalnum() and len(f) <= 8 else "bin"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _announced(doc: dict) -> tuple | None:
|
|
178
|
+
"""(sha256, bytes) of the finished clip as the answer states them, or None when it states neither."""
|
|
179
|
+
sha = doc.get("video_sha256", doc.get("sha256"))
|
|
180
|
+
size = doc.get("video_bytes", doc.get("bytes"))
|
|
181
|
+
return None if sha is None and size is None else (sha, size)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _mismatch(video: bytes, announced: tuple, via: str | None) -> str | None:
|
|
185
|
+
"""Why the clip in hand is not the one the answer announced (None when it is)."""
|
|
186
|
+
sha, size = announced
|
|
187
|
+
bad = []
|
|
188
|
+
if size is not None and (not isinstance(size, int) or isinstance(size, bool) or size != len(video)):
|
|
189
|
+
bad.append(f"{len(video)} bytes in hand, {str(size)[:20]} announced")
|
|
190
|
+
if sha is not None and (not isinstance(sha, str) or hashlib.sha256(video).hexdigest() != sha.strip().lower()):
|
|
191
|
+
bad.append("its sha256 is not the announced one")
|
|
192
|
+
if not bad:
|
|
193
|
+
return None
|
|
194
|
+
late = ": the server answered before the file was complete" if via == "video_path" else ""
|
|
195
|
+
return f"the clip in hand is not the one the answer describes ({'; '.join(bad)}){late}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ClipHTTP:
|
|
199
|
+
"""The clip HTTP contract over plain http.client (one connection per request, like a fresh client)."""
|
|
200
|
+
|
|
201
|
+
endpoint_kind = "clip_http"
|
|
202
|
+
|
|
203
|
+
def __init__(self, ep: Endpoint, timeout_s: float = DEFAULT_TIMEOUT_S, workflow: str | None = None):
|
|
204
|
+
if workflow:
|
|
205
|
+
raise ValueError("--workflow is for the comfyui backend; the clip HTTP contract takes none")
|
|
206
|
+
self.ep, self.timeout_s = ep, timeout_s
|
|
207
|
+
|
|
208
|
+
def _connection(self, timeout: float) -> http.client.HTTPConnection:
|
|
209
|
+
if self.ep.scheme == "https":
|
|
210
|
+
return http.client.HTTPSConnection(self.ep.host, self.ep.port, timeout=timeout)
|
|
211
|
+
return http.client.HTTPConnection(self.ep.host, self.ep.port, timeout=timeout)
|
|
212
|
+
|
|
213
|
+
def _headers(self, extra: dict | None = None) -> dict:
|
|
214
|
+
h = {"Accept": "application/json, video/*", **(extra or {})}
|
|
215
|
+
if self.ep.api_key:
|
|
216
|
+
h["Authorization"] = f"Bearer {self.ep.api_key}"
|
|
217
|
+
return h
|
|
218
|
+
|
|
219
|
+
def health(self) -> tuple[int | None, str]:
|
|
220
|
+
conn = self._connection(10.0)
|
|
221
|
+
try:
|
|
222
|
+
conn.request("GET", self.ep.prefix + HEALTH_PATH, headers=self._headers())
|
|
223
|
+
resp = conn.getresponse()
|
|
224
|
+
body = resp.read(MAX_ERROR_BODY).decode(errors="replace")
|
|
225
|
+
return resp.status, scrub(body.strip(), self.ep)[:200]
|
|
226
|
+
except (OSError, http.client.HTTPException) as e:
|
|
227
|
+
return None, scrub(f"{type(e).__name__}: {e}", self.ep)
|
|
228
|
+
finally:
|
|
229
|
+
conn.close()
|
|
230
|
+
|
|
231
|
+
def generate(self, request: ClipRequest) -> ClipResult:
|
|
232
|
+
res = ClipResult(id=request.id)
|
|
233
|
+
payload = json.dumps(request.body()).encode()
|
|
234
|
+
conn = self._connection(self.timeout_s)
|
|
235
|
+
announced = None
|
|
236
|
+
res.t_send_ns = time.perf_counter_ns()
|
|
237
|
+
try:
|
|
238
|
+
conn.connect()
|
|
239
|
+
res.t_connected_ns = time.perf_counter_ns()
|
|
240
|
+
conn.request("POST", self.ep.prefix + CLIPS_PATH, body=payload,
|
|
241
|
+
headers=self._headers({"Content-Type": "application/json"}))
|
|
242
|
+
resp = conn.getresponse()
|
|
243
|
+
res.status = resp.status
|
|
244
|
+
raw = resp.read()
|
|
245
|
+
ctype = (resp.getheader("Content-Type") or "").split(";")[0].strip().lower()
|
|
246
|
+
meta_header = resp.getheader("X-Clip-Meta")
|
|
247
|
+
if resp.status != 200:
|
|
248
|
+
res.error = f"HTTP {resp.status}: {raw[:MAX_ERROR_BODY].decode(errors='replace').strip()}"
|
|
249
|
+
elif ctype.startswith("video/") or ctype == "application/octet-stream":
|
|
250
|
+
res.video, res.via = raw, "body"
|
|
251
|
+
# The engine picks the subtype and it becomes a file extension: sanitised like "format".
|
|
252
|
+
res.format = _clip_format(ctype.split("/", 1)[1]) if ctype.startswith("video/") else "bin"
|
|
253
|
+
meta = json.loads(meta_header) if meta_header else {}
|
|
254
|
+
self._meta(res, meta)
|
|
255
|
+
announced = _announced(meta)
|
|
256
|
+
else:
|
|
257
|
+
announced = self._json_answer(res, json.loads(raw))
|
|
258
|
+
if res.video is not None and res.error is None:
|
|
259
|
+
res.t_done_ns = time.perf_counter_ns()
|
|
260
|
+
res.ok = len(res.video) > 0
|
|
261
|
+
if not res.ok:
|
|
262
|
+
res.error = "empty clip"
|
|
263
|
+
elif announced is not None: # after t_done: the check is not timed, and a mismatch fails the clip
|
|
264
|
+
res.error = _mismatch(res.video, announced, res.via)
|
|
265
|
+
except (OSError, http.client.HTTPException) as e:
|
|
266
|
+
res.error = f"{type(e).__name__}: {e}"
|
|
267
|
+
except (ValueError, binascii.Error) as e: # JSON, base64 or the meta header did not parse
|
|
268
|
+
res.error = f"bad answer: {type(e).__name__}: {e}"
|
|
269
|
+
finally:
|
|
270
|
+
conn.close()
|
|
271
|
+
res.t_end_ns = time.perf_counter_ns()
|
|
272
|
+
if res.error:
|
|
273
|
+
res.ok, res.video = False, None
|
|
274
|
+
res.error = scrub(res.error, self.ep)[:MAX_ERROR_BODY]
|
|
275
|
+
return res
|
|
276
|
+
|
|
277
|
+
def _json_answer(self, res: ClipResult, doc) -> tuple | None:
|
|
278
|
+
"""Take the clip from a JSON answer; return the announced (sha256, bytes) to check once it is in hand."""
|
|
279
|
+
if not isinstance(doc, dict):
|
|
280
|
+
raise ValueError("the answer is not a JSON object")
|
|
281
|
+
announced = _announced(doc)
|
|
282
|
+
if isinstance(doc.get("video_b64"), str):
|
|
283
|
+
res.video, res.via = base64.b64decode(doc["video_b64"], validate=True), "video_b64"
|
|
284
|
+
elif isinstance(doc.get("video_path"), str):
|
|
285
|
+
if not self.ep.is_local():
|
|
286
|
+
res.error = "video_path from a server on another host cannot be read; send video_b64"
|
|
287
|
+
return None
|
|
288
|
+
if announced is None or None in announced:
|
|
289
|
+
# Without them the client cannot tell a finished file from one the server is still writing.
|
|
290
|
+
res.error = "a video_path answer must carry video_sha256 and video_bytes of the finished file"
|
|
291
|
+
return None
|
|
292
|
+
try:
|
|
293
|
+
res.video, res.via = Path(doc["video_path"]).read_bytes(), "video_path"
|
|
294
|
+
except OSError as e:
|
|
295
|
+
res.error = f"video_path not readable: {type(e).__name__}"
|
|
296
|
+
return None
|
|
297
|
+
else:
|
|
298
|
+
res.error = "the answer has neither video_b64 nor video_path"
|
|
299
|
+
return None
|
|
300
|
+
res.format = _clip_format(doc.get("format"))
|
|
301
|
+
self._meta(res, doc)
|
|
302
|
+
return announced
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _meta(res: ClipResult, doc: dict) -> None:
|
|
306
|
+
if not isinstance(doc, dict):
|
|
307
|
+
raise ValueError("X-Clip-Meta is not a JSON object")
|
|
308
|
+
if doc.get("format"):
|
|
309
|
+
res.format = _clip_format(doc["format"])
|
|
310
|
+
res.stage_ms = doc.get("stage_ms") if isinstance(doc.get("stage_ms"), dict) else None
|
|
311
|
+
res.effective = doc.get("effective") if isinstance(doc.get("effective"), dict) else None
|
|
312
|
+
cache = doc.get("cache")
|
|
313
|
+
if isinstance(cache, list):
|
|
314
|
+
names = [str(c) for c in cache]
|
|
315
|
+
res.cache = [c for c in names if c not in ALLOWED_CACHE]
|
|
316
|
+
res.cache_allowed = [c for c in names if c in ALLOWED_CACHE]
|
|
317
|
+
else:
|
|
318
|
+
res.cache = None
|
|
319
|
+
|
|
320
|
+
def describe(self) -> dict:
|
|
321
|
+
return {"endpoint_kind": self.endpoint_kind, "paths": [HEALTH_PATH, CLIPS_PATH]}
|
|
322
|
+
|
|
323
|
+
def close(self) -> None:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# --------------------------------------------------------------------------- backend registry
|
|
328
|
+
|
|
329
|
+
BackendFactory = Callable[..., ClipBackend]
|
|
330
|
+
_BACKENDS: dict[str, BackendFactory] = {"clip_http": ClipHTTP}
|
|
331
|
+
# Backends that live in their own module; it registers itself when imported.
|
|
332
|
+
_LAZY = {"comfyui": "inferencekit.engines.clip.comfyui"}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def register_backend(kind: str, factory: BackendFactory) -> None:
|
|
336
|
+
"""Make ``kind`` available to bench clip. ``factory(ep, timeout_s=..., workflow=...)`` returns a ClipBackend."""
|
|
337
|
+
_BACKENDS[kind] = factory
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def make_backend(kind: str, ep: Endpoint, timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
341
|
+
workflow: str | None = None) -> ClipBackend:
|
|
342
|
+
"""The backend for an endpoint kind. Raises LookupError when it is not available in this build."""
|
|
343
|
+
if kind not in _BACKENDS and kind in _LAZY:
|
|
344
|
+
try:
|
|
345
|
+
importlib.import_module(_LAZY[kind])
|
|
346
|
+
except ImportError as e:
|
|
347
|
+
raise LookupError(f"endpoint kind {kind!r} needs {_LAZY[kind]}, which is not in this build ({e})") from None
|
|
348
|
+
if kind not in _BACKENDS:
|
|
349
|
+
raise LookupError(f"unknown endpoint kind {kind!r}; known: {sorted(set(_BACKENDS) | set(_LAZY))}")
|
|
350
|
+
return _BACKENDS[kind](ep, timeout_s=timeout_s, workflow=workflow)
|
|
351
|
+
|