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,542 @@
1
+ """A clip HTTP server around a video pipeline: POST /v1/clips and GET /health. Standard library only.
2
+
3
+ Run it with the pipeline's own interpreter (inferencekit installed there with ``pip install --no-deps``,
4
+ or on PYTHONPATH), so inferencekit never shares a virtual environment with the pipeline::
5
+
6
+ python -m inferencekit.engines.clip.server --pipeline diffusers_ltx --model DIR --port 8000 \\
7
+ [--task t2v|i2v] [--dtype bf16] [--offload none|model|sequential] [--attention native] \\
8
+ [--out-dir DIR] [--answer path|b64] [--pipeline-arg KEY=VALUE ...]
9
+
10
+ The contract (the client side is inferencekit.serve.clip_client)::
11
+
12
+ GET /health 200 {"status": "ok"} when a clip can start; 503 while the pipeline loads.
13
+ POST /v1/clips {task, prompt, image?, width, height, num_frames, fps, steps, guidance_scale, seed,
14
+ noise_seed?, audio?}; any other field is refused (400), so a client never believes a
15
+ setting was applied when it was not.
16
+ -> {video_path | video_b64, format, video_sha256, video_bytes, stage_ms, effective, cache}; the file is
17
+ complete and closed before the answer is sent (the client checks its size and sha256)
18
+
19
+ What the server does, whatever the pipeline:
20
+ * Initial noise: drawn on the CPU from ``noise_seed`` (``seed`` when absent) with noise.initial_noise
21
+ and handed to the pipeline, so every engine that follows the recipe starts from the same tensors on
22
+ any GPU. ``effective.noise_sha256`` fingerprints them.
23
+ * ``effective.steps_run`` is counted by the pipeline's step callback (StageClock.step), never copied
24
+ from the request, and is null when nothing counted the steps (bench clip then says they were not
25
+ checked, instead of seeing 0); width, height, frames and fps come from the decoded output the
26
+ pipeline returns.
27
+ * ``stage_ms``: disjoint stages timed by hooks on the pipeline's parts (noise, text_encode, denoise,
28
+ vae_decode, audio_decode, mux, ...), each after a device sync when --stage-sync is on (the default),
29
+ plus ``denoise_steps`` (one entry per step; a list, so the client does not add it to the sum). These
30
+ are self-reported: the client's clock is the only published timer.
31
+ * No output cache: every request runs the pipeline, and ``cache`` is always [].
32
+ * Per-step dumps (stepdump.py, the contract quality clip-steps reads): a pipeline that returns a
33
+ ``Generated.step_dump`` (a StepRecorder) gets it written to ``<dump dir>/<video_sha256>.safetensors``
34
+ before the answer (stage ``step_dump``). The dump dir is ``--step-dump-dir``, else $CLIP_STEP_DUMP_DIR,
35
+ else ``<out-dir>/step-dumps``, where only the last ``--keep`` dumps are kept like the clips; a dump dir
36
+ that was given is never pruned (the checker picks the requests it checks after the clips are made).
37
+ * One clip at a time (a lock); /health still answers while a clip runs.
38
+
39
+ A pipeline is any object with ``describe()``, ``noise_shapes(job)``, ``generate(job, noise, clock)`` and
40
+ ``write(output, path, clock)`` (see ClipPipeline); it may bring its own ``make_noise(noise_seed, shapes)``
41
+ -> (tensors, sha256), and a pipeline that draws its own noise returns no shapes (noise_sha256 is then
42
+ null). ``--pipeline`` names a module in this package (diffusers_ltx, official_ltx) or
43
+ ``MODULE:FUNCTION``; the factory gets a PipelineOptions.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import base64
50
+ import binascii
51
+ import functools
52
+ import hashlib
53
+ import importlib
54
+ import json
55
+ import os
56
+ import sys
57
+ import threading
58
+ import time
59
+ import traceback
60
+ from collections.abc import Callable
61
+ from contextlib import contextmanager
62
+ from dataclasses import dataclass, field
63
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
64
+ from pathlib import Path
65
+ from typing import Any, Protocol
66
+
67
+ TASKS = ("t2v", "i2v")
68
+ CLIPS_PATH = "/v1/clips"
69
+ HEALTH_PATH = "/health"
70
+ MAX_BODY = 64 << 20 # an i2v image in base64 fits easily
71
+ FIELDS = {"task", "prompt", "image", "width", "height", "num_frames", "fps", "steps", "guidance_scale", "seed",
72
+ "noise_seed", "audio"}
73
+ REQUIRED = ("task", "prompt", "width", "height", "num_frames", "fps", "steps", "guidance_scale", "seed")
74
+ PIPELINES = {"diffusers_ltx": "inferencekit.engines.clip.diffusers_ltx",
75
+ "official_ltx": "inferencekit.engines.clip.official_ltx"}
76
+
77
+
78
+ class BadRequest(ValueError):
79
+ """The request is malformed or asks for something this pipeline cannot do (HTTP 400)."""
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class ClipJob:
84
+ task: str
85
+ prompt: str
86
+ width: int
87
+ height: int
88
+ num_frames: int
89
+ fps: float
90
+ steps: int
91
+ guidance_scale: float
92
+ seed: int
93
+ noise_seed: int # the request's noise_seed, else its seed
94
+ image: bytes | None = field(default=None, repr=False)
95
+ audio: bool | None = None
96
+
97
+
98
+ def _int(doc: dict, key: str, lo: int, hi: int) -> int:
99
+ v = doc[key]
100
+ if isinstance(v, bool) or not isinstance(v, int) or not lo <= v <= hi:
101
+ raise BadRequest(f"{key} must be an integer in [{lo}, {hi}], got {v!r}")
102
+ return v
103
+
104
+
105
+ def _num(doc: dict, key: str, lo: float, hi: float) -> float:
106
+ v = doc[key]
107
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not lo <= v <= hi:
108
+ raise BadRequest(f"{key} must be a number in [{lo}, {hi}], got {v!r}")
109
+ return float(v)
110
+
111
+
112
+ def parse_job(doc) -> ClipJob:
113
+ """The request body as a ClipJob; BadRequest for anything missing, unknown or out of range."""
114
+ if not isinstance(doc, dict):
115
+ raise BadRequest("the body must be a JSON object")
116
+ unknown = sorted(set(doc) - FIELDS)
117
+ if unknown:
118
+ raise BadRequest(f"unknown field(s) {unknown}; this server takes {sorted(FIELDS)}")
119
+ missing = [k for k in REQUIRED if doc.get(k) is None]
120
+ if missing:
121
+ raise BadRequest(f"missing field(s) {missing}")
122
+ task = doc["task"]
123
+ if task not in TASKS:
124
+ raise BadRequest(f"task must be one of {TASKS}, got {task!r}")
125
+ if not isinstance(doc["prompt"], str) or not doc["prompt"].strip():
126
+ raise BadRequest("prompt must be a non-empty string")
127
+ image = None
128
+ if doc.get("image") is not None:
129
+ if task != "i2v":
130
+ raise BadRequest("image is for task i2v only")
131
+ try:
132
+ image = base64.b64decode(doc["image"], validate=True)
133
+ except (TypeError, ValueError, binascii.Error):
134
+ raise BadRequest("image must be base64 of the image file's bytes") from None
135
+ if task == "i2v" and not image:
136
+ raise BadRequest("task i2v needs an image")
137
+ audio = doc.get("audio")
138
+ if audio is not None and not isinstance(audio, bool):
139
+ raise BadRequest(f"audio must be true or false, got {audio!r}")
140
+ seed = _int(doc, "seed", 0, 2**63 - 1)
141
+ noise_seed = _int(doc, "noise_seed", 0, 2**63 - 1) if doc.get("noise_seed") is not None else seed
142
+ return ClipJob(task=task, prompt=doc["prompt"], width=_int(doc, "width", 1, 16384),
143
+ height=_int(doc, "height", 1, 16384), num_frames=_int(doc, "num_frames", 1, 100000),
144
+ fps=_num(doc, "fps", 1e-3, 1000.0), steps=_int(doc, "steps", 1, 10000),
145
+ guidance_scale=_num(doc, "guidance_scale", 0.0, 1000.0), seed=seed, noise_seed=noise_seed,
146
+ image=image, audio=audio)
147
+
148
+
149
+ # --------------------------------------------------------------------------- stage timing
150
+
151
+
152
+ class StageClock:
153
+ """Stage times of one clip (ms, ``time.perf_counter_ns``), each taken after ``sync()``.
154
+
155
+ ``span(name)`` times a disjoint stage; a span opened inside another is folded into the outer one, so a
156
+ hooked method that calls another hooked method is never counted twice. The denoise loop is timed by
157
+ marks: ``denoise_start()`` (the first transformer call) and ``step()`` (the end of each step, from the
158
+ pipeline's step callback). ``steps`` counts the step() calls: the steps the pipeline really ran.
159
+ ``counted`` says whether anything counted them (step() was called, or the pipeline used set_steps);
160
+ when it is False the server reports steps_run null, not 0.
161
+ """
162
+
163
+ def __init__(self, sync: Callable[[], None] | None = None):
164
+ self.sync = sync or (lambda: None)
165
+ self.stage_ms: dict[str, float] = {}
166
+ self.step_ms: list[float] = []
167
+ self.steps = 0
168
+ self.counted = False
169
+ self._depth = 0
170
+ self._t0 = 0
171
+ self._t_denoise0: int | None = None
172
+ self._t_last: int | None = None # last step mark, or the end of the last span
173
+
174
+ def begin(self) -> None:
175
+ """Open a stage (``end`` closes it); for module pre/post hooks, where a with block does not fit."""
176
+ self._depth += 1
177
+ if self._depth == 1:
178
+ self.sync()
179
+ self._t0 = time.perf_counter_ns()
180
+
181
+ def end(self, name: str) -> None:
182
+ self._depth -= 1
183
+ if self._depth == 0:
184
+ self.sync()
185
+ t1 = time.perf_counter_ns()
186
+ self.stage_ms[name] = self.stage_ms.get(name, 0.0) + (t1 - self._t0) / 1e6
187
+ self._t_last = t1
188
+
189
+ @contextmanager
190
+ def span(self, name: str):
191
+ self.begin()
192
+ try:
193
+ yield
194
+ finally:
195
+ self.end(name)
196
+
197
+ def denoise_start(self) -> None:
198
+ if self._t_denoise0 is None and not self._depth:
199
+ self.sync()
200
+ self._t_denoise0 = self._t_last = time.perf_counter_ns()
201
+
202
+ def step(self) -> None:
203
+ self.sync()
204
+ t = time.perf_counter_ns()
205
+ if self._t_denoise0 is None: # no transformer hook: the loop started when the last span ended
206
+ self._t_denoise0 = self._t_last if self._t_last is not None else t
207
+ self._t_last = self._t_denoise0
208
+ self.step_ms.append((t - self._t_last) / 1e6)
209
+ self._t_last = t
210
+ self.steps += 1
211
+ self.counted = True
212
+
213
+ def set_steps(self, n: int) -> None:
214
+ """The steps run, for a pipeline that counts them another way (e.g. denoiser forward calls)."""
215
+ self.steps, self.counted = int(n), True
216
+
217
+ def result(self) -> dict:
218
+ out = {k: round(v, 3) for k, v in self.stage_ms.items()}
219
+ if self.step_ms:
220
+ out["denoise"] = round(sum(self.step_ms), 3)
221
+ out["denoise_steps"] = [round(v, 3) for v in self.step_ms]
222
+ return out
223
+
224
+
225
+ def timed(fn: Callable, stage: str, current: Callable[[], StageClock | None]) -> Callable:
226
+ """``fn`` timed as ``stage`` on the clock ``current()`` returns (called through when there is none)."""
227
+
228
+ @functools.wraps(fn)
229
+ def wrapper(*args, **kwargs):
230
+ clock = current()
231
+ if clock is None:
232
+ return fn(*args, **kwargs)
233
+ with clock.span(stage):
234
+ return fn(*args, **kwargs)
235
+
236
+ return wrapper
237
+
238
+
239
+ def hook_method(obj, attr: str, stage: str, current: Callable[[], StageClock | None]) -> None:
240
+ """Replace ``obj.attr`` on this instance by a timed wrapper (the class is left alone)."""
241
+ setattr(obj, attr, timed(getattr(obj, attr), stage, current))
242
+
243
+
244
+ # --------------------------------------------------------------------------- pipelines
245
+
246
+
247
+ @dataclass
248
+ class Generated:
249
+ """What a pipeline produced, before muxing. width/height/frames/fps describe the decoded frames."""
250
+
251
+ video: Any
252
+ width: int
253
+ height: int
254
+ frames: int
255
+ fps: float
256
+ audio: Any = None
257
+ audio_sample_rate: int | None = None
258
+ effective: dict = field(default_factory=dict) # extra facts for ``effective`` (e.g. noise checks)
259
+ step_dump: Any = None # a stepdump.StepRecorder: written to the dump dir, named by the clip's sha256
260
+
261
+
262
+ class ClipPipeline(Protocol):
263
+ def describe(self) -> dict:
264
+ """Static facts for the resolved-config line and ``effective``: pipeline, model, dtype, attention..."""
265
+
266
+ def noise_shapes(self, job: ClipJob) -> dict:
267
+ """{name: shape} of the initial noise for ``job``, in draw order. BadRequest if the shape is invalid."""
268
+
269
+ def generate(self, job: ClipJob, noise: dict, clock: StageClock) -> Generated:
270
+ """Run the pipeline; call clock.step() once per denoise step (or clock.set_steps(n) once) and time
271
+ stages on ``clock``. A pipeline that does neither gets steps_run null: not counted."""
272
+
273
+ def write(self, out: Generated, path: Path, clock: StageClock) -> None:
274
+ """Encode ``out`` into the container file at ``path`` (timed by the server as stage ``mux``)."""
275
+
276
+
277
+ @dataclass
278
+ class PipelineOptions:
279
+ model: str
280
+ task: str = "t2v" # the pipeline variant to load; a request for the other task is refused
281
+ device: str = "cuda"
282
+ dtype: str = "bf16"
283
+ offload: str = "none" # none | model | sequential
284
+ attention: str | None = None # Diffusers attention backend name; None keeps the pipeline's default
285
+ extra: dict = field(default_factory=dict) # --pipeline-arg KEY=VALUE
286
+
287
+
288
+ def load_factory(name: str) -> Callable[[PipelineOptions], ClipPipeline]:
289
+ mod_name, _, fn_name = PIPELINES.get(name, name).partition(":")
290
+ return getattr(importlib.import_module(mod_name), fn_name or "make_pipeline")
291
+
292
+
293
+ def cuda_sync(device: str) -> Callable[[], None] | None:
294
+ """torch.cuda.synchronize of ``device`` (imported lazily); None for a device that is not CUDA.
295
+
296
+ The device is passed on: with no argument torch syncs the current device (cuda:0 unless set), so a
297
+ pipeline on cuda:1 would be timed by kernel launches, not by the GPU finishing them.
298
+ """
299
+ if not str(device).startswith("cuda"):
300
+ return None
301
+ import torch
302
+
303
+ return functools.partial(torch.cuda.synchronize, torch.device(device))
304
+
305
+
306
+ def file_sha256(path: Path) -> str:
307
+ h = hashlib.sha256()
308
+ with open(path, "rb") as f:
309
+ for chunk in iter(lambda: f.read(1 << 20), b""):
310
+ h.update(chunk)
311
+ return h.hexdigest()
312
+
313
+
314
+ def _default_noise(noise_seed: int, shapes: dict) -> tuple[dict, str]:
315
+ from inferencekit.engines.clip import noise
316
+
317
+ tensors = noise.initial_noise(noise_seed, shapes)
318
+ return tensors, noise.digest(tensors)
319
+
320
+
321
+ class ClipServer:
322
+ """Binds first (so a port clash fails fast), then loads the pipeline in a thread; /health is 503 until then."""
323
+
324
+ def __init__(self, load: Callable[[], ClipPipeline], *, host: str = "127.0.0.1", port: int = 8000,
325
+ out_dir: str | Path = "clips", answer: str = "path", keep: int = 16, stage_sync: bool = True,
326
+ sync: Callable[[], None] | None = None,
327
+ noise_fn: Callable[[int, dict], tuple[dict, str]] | None = None, log=sys.stderr,
328
+ step_dump_dir: str | Path | None = None):
329
+ if answer not in ("path", "b64"):
330
+ raise ValueError("answer must be path or b64")
331
+ self._load, self.answer, self.keep, self.noise_fn, self.log = load, answer, keep, noise_fn, log
332
+ self.sync = sync if stage_sync else None
333
+ self.stage_sync = stage_sync
334
+ self.out_dir = Path(out_dir).resolve()
335
+ self.out_dir.mkdir(parents=True, exist_ok=True)
336
+ from inferencekit.engines.clip import stepdump
337
+
338
+ given = step_dump_dir or os.environ.get(stepdump.ENV) or None
339
+ self.step_dump_dir = Path(given or self.out_dir / "step-dumps").resolve()
340
+ self._prune_dumps = not given
341
+ self._dumps: list[Path] = []
342
+ self.pipeline: ClipPipeline | None = None
343
+ self.load_error: str | None = None
344
+ self.facts: dict = {}
345
+ self._lock = threading.Lock()
346
+ self._n = 0
347
+ self._written: list[Path] = []
348
+ self.httpd = ThreadingHTTPServer((host, port), _handler(self))
349
+ self.httpd.daemon_threads = True
350
+ self.port = self.httpd.server_address[1]
351
+
352
+ def start_loading(self) -> threading.Thread:
353
+ t = threading.Thread(target=self._load_pipeline, name="clip-pipeline-load", daemon=True)
354
+ t.start()
355
+ return t
356
+
357
+ def _load_pipeline(self) -> None:
358
+ try:
359
+ pipe = self._load()
360
+ self.facts = dict(pipe.describe() or {})
361
+ self.facts["stage_sync"] = self.stage_sync
362
+ print(json.dumps({"inferencekit_resolved_config": self.facts}, sort_keys=True), file=self.log, flush=True)
363
+ self.pipeline = pipe
364
+ except Exception as e: # noqa: BLE001 - reported, then the process stops
365
+ self.load_error = f"{type(e).__name__}: {e}"
366
+ traceback.print_exc(file=self.log)
367
+ print(f"clip server: pipeline load failed: {self.load_error}", file=self.log, flush=True)
368
+ threading.Thread(target=self.httpd.shutdown, daemon=True).start()
369
+
370
+ def health(self) -> tuple[int, dict]:
371
+ if self.pipeline is not None:
372
+ return 200, {"status": "ok", "pipeline": self.facts.get("pipeline"), "busy": self._lock.locked()}
373
+ if self.load_error:
374
+ return 500, {"status": "failed", "error": self.load_error}
375
+ return 503, {"status": "loading"}
376
+
377
+ def clip(self, doc) -> tuple[int, dict]:
378
+ if self.pipeline is None:
379
+ return self.health()
380
+ try:
381
+ job = parse_job(doc)
382
+ except BadRequest as e:
383
+ return 400, {"error": str(e)}
384
+ with self._lock:
385
+ try:
386
+ return 200, self._run(job)
387
+ except BadRequest as e:
388
+ return 400, {"error": str(e)}
389
+ except Exception as e: # noqa: BLE001 - one failed clip must not stop the server
390
+ traceback.print_exc(file=self.log)
391
+ return 500, {"error": f"{type(e).__name__}: {e}"[:2000]}
392
+
393
+ def _run(self, job: ClipJob) -> dict:
394
+ pipe = self.pipeline
395
+ shapes = pipe.noise_shapes(job)
396
+ clock = StageClock(self.sync)
397
+ noise, noise_sha = {}, None # a pipeline that draws its own noise asks for none
398
+ if shapes:
399
+ make = self.noise_fn or getattr(pipe, "make_noise", None) or _default_noise
400
+ with clock.span("noise"):
401
+ noise, noise_sha = make(job.noise_seed, shapes)
402
+ out = pipe.generate(job, noise, clock)
403
+ self._n += 1
404
+ path = self.out_dir / f"clip-{time.strftime('%Y%m%d-%H%M%S')}-{self._n:05d}.mp4"
405
+ with clock.span("mux"):
406
+ pipe.write(out, path, clock)
407
+ sha, size = file_sha256(path), path.stat().st_size
408
+ self._remember(path)
409
+ dump_bytes = None
410
+ if getattr(out, "step_dump", None) is not None:
411
+ from inferencekit.engines.clip import stepdump
412
+
413
+ dump = stepdump.dump_path(self.step_dump_dir, sha)
414
+ with clock.span("step_dump"):
415
+ dump_bytes = out.step_dump.save(dump)
416
+ if self._prune_dumps:
417
+ self._dumps.append(dump)
418
+ while self.keep and len(self._dumps) > self.keep:
419
+ self._dumps.pop(0).unlink(missing_ok=True)
420
+ effective = {"steps_run": clock.steps if clock.counted else None, "width": out.width, "height": out.height,
421
+ "frames": out.frames, "fps": out.fps, "dtype": self.facts.get("dtype"),
422
+ "attention": self.facts.get("attention"), "audio": out.audio is not None, "noise_seed": job.noise_seed, "noise_sha256": noise_sha,
423
+ "step_dump_bytes": dump_bytes, **out.effective}
424
+ answer = {"format": "mp4", "video_sha256": sha, "video_bytes": size, "stage_ms": clock.result(),
425
+ "effective": effective, "cache": []}
426
+ if self.answer == "b64":
427
+ answer["video_b64"] = base64.b64encode(path.read_bytes()).decode("ascii")
428
+ else:
429
+ answer["video_path"] = str(path)
430
+ return answer
431
+
432
+ def _remember(self, path: Path) -> None:
433
+ """Keep the last ``keep`` clips on disk (0 keeps all); the client copies what it needs."""
434
+ self._written.append(path)
435
+ while self.keep and len(self._written) > self.keep:
436
+ old = self._written.pop(0)
437
+ try:
438
+ old.unlink()
439
+ except OSError:
440
+ pass
441
+
442
+ def serve_forever(self) -> None:
443
+ self.httpd.serve_forever(poll_interval=0.2)
444
+
445
+ def close(self) -> None:
446
+ self.httpd.shutdown()
447
+ self.httpd.server_close()
448
+
449
+
450
+ def _handler(server: ClipServer):
451
+ class Handler(BaseHTTPRequestHandler):
452
+ server_version = "inferencekit-clip/1"
453
+
454
+ def log_message(self, fmt, *args): # one short line per request on stderr
455
+ print(f"clip server: {self.command} {self.path} {fmt % args}", file=server.log, flush=True)
456
+
457
+ def _send(self, status: int, doc: dict) -> None:
458
+ body = json.dumps(doc).encode()
459
+ self.send_response(status)
460
+ self.send_header("Content-Type", "application/json")
461
+ self.send_header("Content-Length", str(len(body)))
462
+ self.end_headers()
463
+ self.wfile.write(body)
464
+
465
+ def do_GET(self):
466
+ if self.path.split("?")[0] == HEALTH_PATH:
467
+ self._send(*server.health())
468
+ else:
469
+ self._send(404, {"error": f"unknown path {self.path}"})
470
+
471
+ def do_POST(self):
472
+ if self.path.split("?")[0] != CLIPS_PATH:
473
+ self._send(404, {"error": f"unknown path {self.path}"})
474
+ return
475
+ try:
476
+ n = int(self.headers.get("Content-Length") or 0)
477
+ except ValueError:
478
+ n = -1
479
+ if not 0 < n <= MAX_BODY:
480
+ self._send(400 if n <= 0 else 413, {"error": f"Content-Length must be 1..{MAX_BODY}"})
481
+ return
482
+ try:
483
+ doc = json.loads(self.rfile.read(n))
484
+ except ValueError as e:
485
+ self._send(400, {"error": f"the body is not JSON: {e}"})
486
+ return
487
+ self._send(*server.clip(doc))
488
+
489
+ return Handler
490
+
491
+
492
+ def _pipeline_arg(text: str) -> tuple[str, Any]:
493
+ key, sep, value = text.partition("=")
494
+ if not sep or not key:
495
+ raise argparse.ArgumentTypeError(f"--pipeline-arg must be KEY=VALUE, got {text!r}")
496
+ try:
497
+ return key, json.loads(value)
498
+ except ValueError:
499
+ return key, value
500
+
501
+
502
+ def main(argv: list[str] | None = None) -> int:
503
+ p = argparse.ArgumentParser(prog="python -m inferencekit.engines.clip.server", description=__doc__.split("\n")[0])
504
+ p.add_argument("--pipeline", required=True, help="diffusers_ltx, official_ltx, or MODULE:FUNCTION")
505
+ p.add_argument("--model", required=True, help="model directory or hub id")
506
+ p.add_argument("--task", choices=TASKS, default="t2v", help="the pipeline variant to load")
507
+ p.add_argument("--host", default="127.0.0.1")
508
+ p.add_argument("--port", type=int, default=8000)
509
+ p.add_argument("--device", default="cuda")
510
+ p.add_argument("--dtype", default="bf16", help="bf16 (the reference), fp16 or fp32")
511
+ p.add_argument("--offload", choices=("none", "model", "sequential"), default="none")
512
+ p.add_argument("--attention", default=None, help="attention backend (Diffusers names, e.g. native, flash)")
513
+ p.add_argument("--out-dir", default="clips")
514
+ p.add_argument("--answer", choices=("path", "b64"), default="path",
515
+ help="path: the client reads the file (same machine); b64: the clip in the JSON answer")
516
+ p.add_argument("--keep", type=int, default=16, help="clips kept on disk (0 keeps all)")
517
+ p.add_argument("--step-dump-dir", default=None,
518
+ help="per-step dumps (default $CLIP_STEP_DUMP_DIR, else <out-dir>/step-dumps, pruned like clips)")
519
+ p.add_argument("--stage-sync", choices=("on", "off"), default="on",
520
+ help="sync the device around each timed stage (off: stage times measure launches only)")
521
+ p.add_argument("--pipeline-arg", type=_pipeline_arg, action="append", default=[], metavar="KEY=VALUE")
522
+ a = p.parse_args(argv)
523
+ opts = PipelineOptions(model=a.model, task=a.task, device=a.device, dtype=a.dtype, offload=a.offload,
524
+ attention=a.attention, extra=dict(a.pipeline_arg))
525
+ factory = load_factory(a.pipeline)
526
+ stage_sync = a.stage_sync == "on"
527
+ srv = ClipServer(lambda: factory(opts), host=a.host, port=a.port, out_dir=a.out_dir, answer=a.answer,
528
+ keep=a.keep, stage_sync=stage_sync, sync=cuda_sync(a.device) if stage_sync else None,
529
+ step_dump_dir=a.step_dump_dir)
530
+ print(f"clip server: listening on {a.host}:{srv.port}, loading {a.pipeline}", file=sys.stderr, flush=True)
531
+ srv.start_loading()
532
+ try:
533
+ srv.serve_forever()
534
+ except KeyboardInterrupt:
535
+ pass
536
+ finally:
537
+ srv.httpd.server_close()
538
+ return 1 if srv.load_error else 0
539
+
540
+
541
+ if __name__ == "__main__":
542
+ sys.exit(main())