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,601 @@
1
+ """LTX-2.x through Diffusers behind the clip server: text-to-video and image-to-video.
2
+
3
+ python -m inferencekit.engines.clip.server --pipeline diffusers_ltx --model Lightricks/LTX-2.5 \\
4
+ --task t2v --dtype bf16 [--offload model] [--attention native] \\
5
+ [--pipeline-arg reference=true --pipeline-arg default_negative_prompt=true]
6
+
7
+ What was checked, and how (Diffusers 0.41.0.dev0 from the main branch, read on 15 Oct 2026; run on a GPU host on
8
+ LTX-2 (15 Oct) and LTX-2.5 (16 Oct)):
9
+ * Classes: ``diffusers.LTX2Pipeline`` (t2v) and ``diffusers.LTX2ImageToVideoPipeline`` (i2v), both in
10
+ diffusers/pipelines/ltx2. Both take prompt, negative_prompt, height, width, num_frames, frame_rate,
11
+ num_inference_steps, guidance_scale, generator, output_type, return_dict, callback_on_step_end; i2v
12
+ also takes image. With return_dict=False the call returns (video, audio); output_type="np" gives
13
+ float frames in [0, 1], shape [batch, frames, height, width, 3]; audio is a waveform
14
+ [batch, channels, samples] at ``pipe.vocoder.config.output_sampling_rate``.
15
+ * Sizes: height and width must be multiples of 32 (check_inputs); frames are 8k+1 (the VAE's temporal
16
+ ratio); the latent video is [1, transformer.config.in_channels, (frames-1)//8+1, height//32, width//32].
17
+ * Noise: randn_tensor draws on the generator's device, so a CPU generator gives the same numbers on any
18
+ GPU. With ``generator=torch.Generator("cpu").manual_seed(noise_seed)`` the pipeline's first draw is
19
+ the video noise and the second the audio noise, both float32: the recipe in noise.py. The pipeline
20
+ draws the noise itself rather than taking ``latents`` because in i2v a ``latents`` argument
21
+ replaces the image-conditioned latents (the image would be lost), and audio ``latents`` would be
22
+ renormalised. Hooks on prepare_latents / prepare_audio_latents compare what the pipeline drew with
23
+ the server's CPU tensors on every clip: ``effective.noise_check`` is true only when they are equal.
24
+ * Muxing: ``diffusers.utils.encode_video`` (PyAV, libx264 yuv420p, AAC audio). It writes an integer
25
+ frame rate (``rate=int(fps)``), so a fractional fps is refused instead of being silently rounded.
26
+
27
+ BF16 reference mode (``--pipeline-arg reference=true``): every component in BF16, PyTorch
28
+ scaled-dot-product attention (backend ``native``, set on the transformer and the text connectors, so a
29
+ ``DIFFUSERS_ATTN_BACKEND`` in the environment cannot change it), the default pipeline class, no
30
+ ``call.*`` overrides, no VAE tiling (tiles are blended, which changes the frames) and no prompt
31
+ enhancement. It is the pipeline as shipped, the side that quality clip compares the others against.
32
+ Offloading moves weights but changes no numbers, so it is allowed in reference mode. Reference mode also
33
+ needs an explicit negative prompt (``default_negative_prompt=true`` or ``negative_prompt=TEXT``; an empty
34
+ TEXT for none): without one Diffusers encodes an empty one, and nothing would say which the clips used.
35
+
36
+ Other options (``--pipeline-arg``): ``pipeline_class=NAME`` (another diffusers class with the same call
37
+ arguments), ``negative_prompt=TEXT`` or ``default_negative_prompt=true`` (Diffusers' LTX-2 DEFAULT_NEGATIVE_PROMPT,
38
+ the one the model cards use), ``revision=REV``, ``vae_tiling=true``, ``call.NAME=VALUE`` (an extra __call__
39
+ argument, e.g. ``call.decode_timestep=0.05``; never one the request sets), ``transformer_subfolder=NAME`` (load
40
+ the transformer from another folder of the checkpoint) and ``scheduler=JSON`` (scheduler config overrides).
41
+
42
+ LTX-2.5-Diffusers (from its model card, then run on its weights on 16 Oct 2026): ``transformer/`` is the DISTILLED
43
+ DiT and model_index.json loads it by default, with a scheduler set up for distilled sigma schedules. The full
44
+ BF16 model is ``transformer_subfolder=transformer_full`` with ``scheduler={"use_dynamic_shifting": true,
45
+ "shift_terminal": 0.1}``. Reference mode refuses a local folder that has transformer_full/ unless
46
+ transformer_subfolder is given, so a reference never runs the distilled DiT by accident: a hub id is looked up
47
+ in the Hugging Face cache (offline, at ``revision``), and reference mode refuses a hub id it cannot find there.
48
+ In reference mode a transformer_subfolder needs the scheduler overrides that go with it (use_dynamic_shifting
49
+ and shift_terminal: the checkpoint's own scheduler is set up for the distilled sigmas). These options are
50
+ allowed in reference mode (they pick the checkpoint and its schedule; they are recorded in describe(), and
51
+ a re-run of the manifest can compare that with the reference settings it expects).
52
+
53
+ Loading (first run on real LTX-2.5 weights, RTX PRO 6000, 16 Oct 2026): a hub id with ``revision`` set to a full
54
+ 40-character commit sha (or with HF_HUB_OFFLINE set) is loaded from its Hugging Face cache snapshot folder when it
55
+ is there, because Diffusers' sharded-checkpoint loader asks the Hub for the file list even offline and fails
56
+ (OfflineModeIsEnabled). A branch or tag name online is left to from_pretrained: the cache's ref for it may point
57
+ at an older snapshot. describe() records the revision asked for and the snapshot's commit (``snapshot_commit``), so
58
+ a reference manifest's expect_config can pin the commit that was loaded. LTX-2.5-Diffusers' model_index.json also lists optional components a clip never uses:
59
+ the Gemma-4 prompt enhancer (about 10 GB) with its processor, and the duration head (only used when num_frames
60
+ is left out). They are passed as None (``skipped_components`` in describe()): loaded, they would only take GPU
61
+ memory, and on a host with only the folders a clip loads, loading them fails.
62
+
63
+ Per-step dumps (stepdump.py) and replay: the scheduler class's ``step`` is wrapped for the length of each pipeline call
64
+ (the pipeline steps the video latents with ``pipe.scheduler`` and the audio latents with a deep copy of it, so an
65
+ instance attribute would be copied into the audio scheduler still bound to the video one; the class-level wrapper
66
+ tells them apart by identity). Every clip records, per step and stream, the latent the step started from, the
67
+ guided velocity the scheduler took and the latent it returned (unpacked to the noise layout) and the scheduler's
68
+ sigmas. ``replay`` is the checker's side (quality clip-steps): the reference pipeline runs a request with an engine's
69
+ dump forced in: prepare_latents returns the dump's first latents, and every scheduler step returns the dump's next
70
+ latent, so each step's transformer passes see the engine's x_t; it reports the reference's own guided output at
71
+ every step, the reference scheduler's update of the engine's output (the chain check), and the frames its own
72
+ decode path makes from the dump's final latent.
73
+
74
+ Stages (self-reported, see server.py): text_encode (encode_prompt + connectors), prepare_latents (in i2v
75
+ this includes encoding the image), denoise (+ denoise_steps), vae_decode, postprocess (to frames),
76
+ audio_decode (audio VAE + vocoder), mux.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ import dataclasses
82
+ import hashlib
83
+ import io
84
+ import re
85
+ from contextlib import contextmanager
86
+
87
+ from inferencekit.engines.clip import stepdump
88
+ from inferencekit.engines.clip.server import (BadRequest, ClipJob, Generated, PipelineOptions, StageClock,
89
+ hook_method, timed)
90
+
91
+ DTYPES = {"bf16": "bfloat16", "fp16": "float16", "fp32": "float32"}
92
+ CLASSES = {"t2v": "LTX2Pipeline", "i2v": "LTX2ImageToVideoPipeline"}
93
+ # __call__ arguments the request sets: a call.* override may not touch them.
94
+ REQUEST_ARGS = {"prompt", "negative_prompt", "image", "height", "width", "num_frames", "frame_rate",
95
+ "num_inference_steps", "guidance_scale", "generator", "latents", "audio_latents", "output_type",
96
+ "return_dict", "callback_on_step_end", "callback_on_step_end_tensor_inputs"}
97
+ OPTIONS = {"reference", "pipeline_class", "negative_prompt", "default_negative_prompt", "revision", "vae_tiling",
98
+ "transformer_subfolder", "scheduler"}
99
+ # Optional pipeline components a clip never uses (prompt enhancement is off; num_frames is always given), passed as
100
+ # None so from_pretrained does not load them (see the module docstring).
101
+ UNUSED_COMPONENTS = ("prompt_enhancer", "processor", "duration_head")
102
+ COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$") # a Hub revision that names one commit (a branch or tag can move)
103
+
104
+
105
+ def _dtype_name(dtype) -> str:
106
+ s = str(dtype).replace("torch.", "")
107
+ return {v: k for k, v in DTYPES.items()}.get(s, s)
108
+
109
+
110
+ def check_options(opts: PipelineOptions) -> bool:
111
+ """ValueError for options this factory does not take or reference mode forbids; True in reference mode."""
112
+ unknown = sorted(k for k in opts.extra if k not in OPTIONS and not k.startswith("call."))
113
+ if unknown:
114
+ raise ValueError(f"unknown --pipeline-arg {unknown}; known: {sorted(OPTIONS)} and call.NAME")
115
+ if opts.dtype not in DTYPES:
116
+ raise ValueError(f"--dtype must be one of {sorted(DTYPES)}")
117
+ if opts.extra.get("default_negative_prompt") and opts.extra.get("negative_prompt") is not None:
118
+ raise ValueError("give negative_prompt or default_negative_prompt=true, not both")
119
+ sched = opts.extra.get("scheduler")
120
+ if sched is not None and not isinstance(sched, dict):
121
+ raise ValueError("scheduler must be a JSON object of scheduler config overrides, e.g. "
122
+ '{"use_dynamic_shifting": true, "shift_terminal": 0.1}')
123
+ reference = bool(opts.extra.get("reference"))
124
+ if not reference:
125
+ return False
126
+ bad = [k for k in opts.extra if k.startswith("call.") or k in ("vae_tiling", "pipeline_class")]
127
+ if opts.dtype != "bf16" or opts.attention not in (None, "native") or bad:
128
+ raise ValueError("reference mode is BF16 with native attention, the default pipeline class and "
129
+ "no call.* overrides or VAE tiling")
130
+ if opts.extra.get("negative_prompt") is None and not opts.extra.get("default_negative_prompt"):
131
+ raise ValueError("reference mode needs an explicit negative prompt: --pipeline-arg default_negative_prompt=true "
132
+ "(Diffusers' LTX-2 DEFAULT_NEGATIVE_PROMPT) or --pipeline-arg negative_prompt=TEXT (empty "
133
+ "for none)")
134
+ sub = opts.extra.get("transformer_subfolder")
135
+ if sub:
136
+ missing = [k for k in ("use_dynamic_shifting", "shift_terminal") if k not in (sched or {})]
137
+ if missing:
138
+ raise ValueError(f"reference mode with transformer_subfolder={sub} needs the scheduler overrides that go "
139
+ f"with it (missing {', '.join(missing)}): the checkpoint's own scheduler is set up for "
140
+ "its default (distilled) transformer, e.g. --pipeline-arg 'scheduler="
141
+ "{\"use_dynamic_shifting\": true, \"shift_terminal\": 0.1}'")
142
+ return reference
143
+ full = _has_full_transformer(opts.model, opts.extra.get("revision"))
144
+ if full is None:
145
+ raise ValueError(f"reference mode cannot tell whether {opts.model} ships a transformer_full/ folder (its default "
146
+ "transformer/ would then be a distilled one): it is not a local folder and not in the Hugging "
147
+ "Face cache at this revision. Download it first, or give the folder")
148
+ if full:
149
+ raise ValueError(f"{opts.model} has a transformer_full/ folder: its default transformer/ is the distilled "
150
+ "one (LTX-2.5-Diffusers), not the BF16 full model a reference needs. Pass "
151
+ "--pipeline-arg transformer_subfolder=transformer_full and the scheduler the full model "
152
+ "needs, e.g. --pipeline-arg 'scheduler={\"use_dynamic_shifting\": true, "
153
+ "\"shift_terminal\": 0.1}'")
154
+ return reference
155
+
156
+
157
+ def _offline() -> bool:
158
+ import os
159
+
160
+ return os.environ.get("HF_HUB_OFFLINE", "").strip().lower() in ("1", "true", "yes", "on")
161
+
162
+
163
+ def _cached_snapshot(model: str, revision: str | None = None) -> str | None:
164
+ """The folder to load ``model`` from without calling the Hub: ``model`` itself when it is a local folder; for a hub
165
+ id whose ``revision`` is a full commit sha (or any hub id with HF_HUB_OFFLINE set), its Hugging Face cache snapshot
166
+ folder when it is there and holds a model_index.json. None otherwise (from_pretrained then resolves the hub id
167
+ itself, online: a branch or tag name such as ``main`` may have moved since the cache last saw it)."""
168
+ import os
169
+
170
+ if os.path.isdir(str(model)):
171
+ return str(model)
172
+ if not (revision and COMMIT_SHA.match(str(revision))) and not _offline():
173
+ return None # unpinned, or a branch or tag name, online: the cache's ref may point at an older snapshot
174
+ try:
175
+ from huggingface_hub import snapshot_download
176
+
177
+ folder = snapshot_download(str(model), revision=revision, local_files_only=True)
178
+ except Exception: # noqa: BLE001 - not installed, not a valid id, or not in the cache
179
+ return None
180
+ if not os.path.isfile(os.path.join(str(folder), "model_index.json")):
181
+ return None # not a Diffusers pipeline snapshot (e.g. only some files of it were fetched)
182
+ return str(folder)
183
+
184
+
185
+ def _snapshot_commit(folder: str | None) -> str | None:
186
+ """The commit a Hugging Face cache snapshot folder holds (its name, .../snapshots/<sha>), else None."""
187
+ import os
188
+
189
+ if not folder:
190
+ return None
191
+ name = os.path.basename(os.path.normpath(str(folder)))
192
+ parent = os.path.basename(os.path.dirname(os.path.normpath(str(folder))))
193
+ return name if parent == "snapshots" and COMMIT_SHA.match(name) else None
194
+
195
+
196
+ def _has_full_transformer(model: str, revision: str | None = None) -> bool | None:
197
+ """Whether a Diffusers checkpoint ships a second, full transformer beside its default one (LTX-2.5-Diffusers:
198
+ transformer/ is the distilled DiT, transformer_full/ the full one). A local folder is looked at directly; a hub
199
+ id in its Hugging Face cache snapshot at ``revision`` (offline: local_files_only). None when neither is there.
200
+ A snapshot cached without transformer_full/ reads as False: a re-run that checks the expected settings catches that."""
201
+ import os
202
+
203
+ if os.path.isdir(str(model)):
204
+ return os.path.isdir(os.path.join(str(model), "transformer_full"))
205
+ try:
206
+ from huggingface_hub import snapshot_download
207
+
208
+ folder = snapshot_download(str(model), revision=str(revision) if revision else None, local_files_only=True)
209
+ except Exception: # noqa: BLE001 - not installed, not a valid id, or not in the cache
210
+ return None
211
+ return os.path.isdir(os.path.join(str(folder), "transformer_full"))
212
+
213
+
214
+ def configure(pipe, opts: PipelineOptions, reference: bool) -> PipelineOptions:
215
+ """Place a loaded pipeline (device, offload), set its attention backend and VAE tiling.
216
+
217
+ Returns ``opts`` with the attention backend that was set (None: Diffusers' default, which
218
+ ``DIFFUSERS_ATTN_BACKEND`` can change). Reference mode sets ``native`` explicitly.
219
+ """
220
+ if opts.offload == "model":
221
+ pipe.enable_model_cpu_offload(device=opts.device)
222
+ elif opts.offload == "sequential":
223
+ pipe.enable_sequential_cpu_offload(device=opts.device)
224
+ else:
225
+ pipe.to(opts.device)
226
+ attention = opts.attention or ("native" if reference else None)
227
+ if attention:
228
+ pipe.transformer.set_attention_backend(attention)
229
+ connectors = getattr(pipe, "connectors", None) # the text connectors attend too (LTX2Attention)
230
+ if hasattr(connectors, "set_attention_backend"):
231
+ connectors.set_attention_backend(attention)
232
+ if opts.extra.get("vae_tiling"):
233
+ pipe.vae.enable_tiling()
234
+ return dataclasses.replace(opts, attention=attention)
235
+
236
+
237
+ def make_pipeline(opts: PipelineOptions) -> LTXDiffusers:
238
+ reference = check_options(opts) # before any import or load: a refused option costs nothing
239
+ import diffusers
240
+ import torch
241
+
242
+ cls_name = opts.extra.get("pipeline_class") or CLASSES[opts.task]
243
+ cls = getattr(diffusers, cls_name)
244
+ revision = str(opts.extra["revision"]) if opts.extra.get("revision") else None
245
+ source = _cached_snapshot(opts.model, revision) # a local folder: no Hub call while loading
246
+ kw = {"torch_dtype": getattr(torch, DTYPES[opts.dtype])}
247
+ if revision and source is None: # a hub id not in the cache: from_pretrained fetches that revision
248
+ kw["revision"] = revision
249
+ load_from = source or opts.model
250
+ sub = opts.extra.get("transformer_subfolder")
251
+ if sub: # e.g. LTX-2.5-Diffusers' transformer_full/ (its model_index.json names the distilled transformer/)
252
+ tkw = {k: v for k, v in kw.items()}
253
+ kw["transformer"] = diffusers.LTX2VideoTransformer3DModel.from_pretrained(load_from, subfolder=str(sub),
254
+ **tkw)
255
+ skipped = [c for c in UNUSED_COMPONENTS if c in (getattr(cls, "_optional_components", None) or ())]
256
+ pipe = cls.from_pretrained(load_from, **kw, **{c: None for c in skipped})
257
+ if opts.extra.get("scheduler"):
258
+ pipe.scheduler = type(pipe.scheduler).from_config(pipe.scheduler.config, **opts.extra["scheduler"])
259
+ if opts.extra.get("default_negative_prompt"):
260
+ from diffusers.pipelines.ltx2.utils import DEFAULT_NEGATIVE_PROMPT
261
+
262
+ opts = dataclasses.replace(opts, extra={**opts.extra, "negative_prompt": DEFAULT_NEGATIVE_PROMPT})
263
+ opts = configure(pipe, opts, reference)
264
+ facts = {"pipeline": f"diffusers:{cls_name}", "diffusers_version": diffusers.__version__,
265
+ "torch_version": torch.__version__, "reference": reference,
266
+ "transformer_subfolder": sub or "transformer", "skipped_components": skipped,
267
+ "loaded_from": "hf_cache_snapshot" if source and source != opts.model else
268
+ ("folder" if source else "hub"),
269
+ "revision": revision, "snapshot_commit": _snapshot_commit(source) if source != opts.model else None,
270
+ "scheduler": {"class": type(pipe.scheduler).__name__,
271
+ **{k: pipe.scheduler.config.get(k) for k in ("use_dynamic_shifting", "shift_terminal",
272
+ "shift") if k in pipe.scheduler.config}}}
273
+ if str(opts.device).startswith("cuda") and torch.cuda.is_available():
274
+ facts["gpu"] = torch.cuda.get_device_name(torch.device(opts.device))
275
+ return LTXDiffusers(pipe, opts, torch, facts)
276
+
277
+
278
+ def _numel(shape) -> int:
279
+ n = 1
280
+ for d in shape:
281
+ n *= int(d)
282
+ return n
283
+
284
+
285
+ def _torch_dtype(t) -> str:
286
+ return str(t.dtype).replace("torch.", "")
287
+
288
+
289
+ def _prev(res):
290
+ """The new latent in a scheduler step's result (a tuple with return_dict=False, else an output object)."""
291
+ return res[0] if isinstance(res, tuple) else res.prev_sample
292
+
293
+
294
+ def _with_prev(res, latent):
295
+ if isinstance(res, tuple):
296
+ return (latent, *res[1:])
297
+ res.prev_sample = latent
298
+ return res
299
+
300
+
301
+ def _active_attention() -> str | None:
302
+ try: # private in Diffusers: the backend used when a model sets none
303
+ from diffusers.models.attention_dispatch import _AttentionBackendRegistry
304
+
305
+ backend = _AttentionBackendRegistry.get_active_backend()[0]
306
+ return getattr(backend, "value", str(backend))
307
+ except Exception: # noqa: BLE001
308
+ return None
309
+
310
+
311
+ class LTXDiffusers:
312
+ """The clip server's pipeline object for a loaded Diffusers LTX-2.x pipeline (``pipe``)."""
313
+
314
+ def __init__(self, pipe, opts: PipelineOptions, torch_mod, facts: dict | None = None):
315
+ self.pipe, self.opts, self.torch = pipe, opts, torch_mod
316
+ self.facts = dict(facts or {})
317
+ self.negative_prompt = opts.extra.get("negative_prompt")
318
+ self.call_kwargs = {k[5:]: v for k, v in opts.extra.items() if k.startswith("call.")}
319
+ clash = sorted(set(self.call_kwargs) & REQUEST_ARGS)
320
+ if clash:
321
+ raise ValueError(f"call.* may not set what the request sets: {clash}")
322
+ self.clock: StageClock | None = None
323
+ self._noise: dict = {}
324
+ self._noise_seen: dict = {}
325
+ self._install_hooks()
326
+
327
+ # Hooks are installed once; they time on self.clock while a clip runs and call straight through otherwise.
328
+ # Modules get torch forward hooks, not a replaced ``forward``: with --offload model, Diffusers re-applies
329
+ # the offload hooks after every call (maybe_free_model_hooks), and removing them restores ``forward``.
330
+ def _install_hooks(self) -> None:
331
+ p, cur = self.pipe, lambda: self.clock
332
+ hook_method(p, "encode_prompt", "text_encode", cur)
333
+ if getattr(p, "connectors", None) is not None:
334
+ self._hook_module(p.connectors, "text_encode")
335
+ for attr, check in (("prepare_latents", self._check_video), ("prepare_audio_latents", self._check_audio)):
336
+ if hasattr(p, attr):
337
+ setattr(p, attr, self._capturing(timed(getattr(p, attr), "prepare_latents", cur), check))
338
+
339
+ def loop_start(module, args): # returns None: a pre-hook's return value would replace the inputs
340
+ if self.clock is not None:
341
+ self.clock.denoise_start()
342
+
343
+ p.transformer.register_forward_pre_hook(loop_start)
344
+ hook_method(p.vae, "decode", "vae_decode", cur)
345
+ hook_method(p.video_processor, "postprocess_video", "postprocess", cur)
346
+ if getattr(p, "audio_vae", None) is not None:
347
+ hook_method(p.audio_vae, "decode", "audio_decode", cur)
348
+ if getattr(p, "vocoder", None) is not None:
349
+ self._hook_module(p.vocoder, "audio_decode")
350
+
351
+ def _hook_module(self, module, stage: str) -> None:
352
+ def pre(mod, args):
353
+ if self.clock is not None:
354
+ self.clock.begin()
355
+ mod._ik_clock = self.clock # close the stage on the clock it was opened on
356
+
357
+ def post(mod, args, output):
358
+ clock = mod.__dict__.pop("_ik_clock", None)
359
+ if clock is not None:
360
+ clock.end(stage)
361
+
362
+ module.register_forward_pre_hook(pre)
363
+ module.register_forward_hook(post)
364
+
365
+ def _capturing(self, fn, check):
366
+ def wrapper(*args, **kwargs):
367
+ out = fn(*args, **kwargs)
368
+ if self.clock is not None:
369
+ check(out)
370
+ return out
371
+
372
+ return wrapper
373
+
374
+ def _check_video(self, out) -> None:
375
+ latents, mask = out if isinstance(out, tuple) else (out, None)
376
+ ref = self._noise.get("video")
377
+ if ref is None:
378
+ return
379
+ p = self.pipe
380
+ ref = p._pack_latents(ref, p.transformer_spatial_patch_size, p.transformer_temporal_patch_size)
381
+ ref = ref.to(latents.device, latents.dtype)
382
+ if ref.shape != latents.shape:
383
+ self._noise_seen["video"] = False
384
+ return
385
+ if mask is not None: # i2v: the image's tokens (mask 1) are not noise
386
+ free = mask[0] == 0
387
+ latents, ref = latents[:, free], ref[:, free]
388
+ self._noise_seen["video"] = bool(self.torch.equal(latents, ref))
389
+
390
+ def _check_audio(self, out) -> None:
391
+ ref = self._noise.get("audio")
392
+ if ref is None:
393
+ return
394
+ ref = self.pipe._pack_audio_latents(ref).to(out.device, out.dtype)
395
+ self._noise_seen["audio"] = ref.shape == out.shape and bool(self.torch.equal(out, ref))
396
+
397
+ # ------------------------------------------------------------------ the ClipPipeline interface
398
+
399
+ def describe(self) -> dict:
400
+ dtype = _dtype_name(getattr(self.pipe.transformer, "dtype", self.opts.dtype))
401
+ attention = self.opts.attention or _active_attention() or "default"
402
+ neg = self.negative_prompt
403
+ return {**self.facts, "model": self.opts.model, "task": self.opts.task, "dtype": dtype,
404
+ "attention": attention, "device": self.opts.device, "offload": self.opts.offload,
405
+ "negative_prompt_sha256": hashlib.sha256(str(neg).encode()).hexdigest() if neg is not None else None,
406
+ "call_kwargs": self.call_kwargs}
407
+
408
+ def noise_shapes(self, job: ClipJob) -> dict:
409
+ p = self.pipe
410
+ if job.task != self.opts.task:
411
+ raise BadRequest(f"this server loaded the {self.opts.task} pipeline; start another for {job.task}")
412
+ if float(job.fps) != int(job.fps):
413
+ raise BadRequest(f"fps must be a whole number for this muxer, got {job.fps:g}")
414
+ s, t = p.vae_spatial_compression_ratio, p.vae_temporal_compression_ratio
415
+ if job.width % 32 or job.height % 32:
416
+ raise BadRequest(f"width and height must be multiples of 32, got {job.width}x{job.height}")
417
+ if (job.num_frames - 1) % t:
418
+ raise BadRequest(f"num_frames must be {t}k+1, got {job.num_frames}")
419
+ shapes = {"video": (1, p.transformer.config.in_channels, (job.num_frames - 1) // t + 1, job.height // s,
420
+ job.width // s)}
421
+ avae = getattr(p, "audio_vae", None)
422
+ if avae is not None:
423
+ per_s = p.audio_sampling_rate / p.audio_hop_length / float(p.audio_vae_temporal_compression_ratio)
424
+ length = round(job.num_frames / job.fps * per_s)
425
+ shapes["audio"] = (1, avae.config.latent_channels, length,
426
+ avae.config.mel_bins // p.audio_vae_mel_compression_ratio)
427
+ return shapes
428
+
429
+ def _call_kwargs(self, job: ClipJob) -> dict:
430
+ torch = self.torch
431
+ kwargs = dict(prompt=job.prompt, negative_prompt=self.negative_prompt, height=job.height, width=job.width,
432
+ num_frames=job.num_frames, frame_rate=float(job.fps), num_inference_steps=job.steps,
433
+ guidance_scale=job.guidance_scale,
434
+ generator=torch.Generator(device="cpu").manual_seed(job.noise_seed),
435
+ output_type="np", return_dict=False, callback_on_step_end=self._on_step,
436
+ **self.call_kwargs)
437
+ if job.task == "i2v":
438
+ from PIL import Image
439
+
440
+ kwargs["image"] = Image.open(io.BytesIO(job.image)).convert("RGB")
441
+ return kwargs
442
+
443
+ def generate(self, job: ClipJob, noise: dict, clock: StageClock) -> Generated:
444
+ kwargs = self._call_kwargs(job)
445
+ rec, shapes = stepdump.StepRecorder(stepdump.request_of(job)), self.noise_shapes(job)
446
+
447
+ def record(stream, sched, step, model_output, timestep, sample, args, kw):
448
+ res = step(model_output, timestep, sample, *args, **kw)
449
+ i = sched.step_index - 1
450
+ rec.step(stream, sched.sigmas[i], sched.sigmas[i + 1], self._unpack(stream, sample, shapes),
451
+ self._unpack(stream, model_output, shapes), self._unpack(stream, _prev(res), shapes))
452
+ return res
453
+
454
+ self.clock, self._noise, self._noise_seen = clock, noise, {}
455
+ clock.set_steps(0) # the step callback counts from here: a pipeline that never calls it ran 0 steps
456
+ try:
457
+ with self._scheduler_steps(record):
458
+ video, audio = self.pipe(**kwargs)
459
+ finally:
460
+ self.clock = None
461
+ frames = video[0]
462
+ n, h, w = (int(d) for d in frames.shape[:3])
463
+ wave, rate = None, None
464
+ if audio is not None and job.audio is not False:
465
+ wave = audio[0].float().cpu()
466
+ rate = int(self.pipe.vocoder.config.output_sampling_rate)
467
+ expected = set(noise)
468
+ check = all(self._noise_seen.get(k) is True for k in expected) and set(self._noise_seen) >= expected
469
+ return Generated(video=frames, width=w, height=h, frames=n, fps=float(job.fps), audio=wave,
470
+ audio_sample_rate=rate,
471
+ effective={"noise_check": check, "noise_checked": sorted(self._noise_seen),
472
+ "generator": "cpu"}, step_dump=rec if rec.count else None)
473
+
474
+ # ------------------------------------------------------------------ per-step hooks (dumps and replay)
475
+
476
+ @contextmanager
477
+ def _scheduler_steps(self, handler):
478
+ """For one pipeline call, every scheduler.step(model_output, timestep, sample, ...) goes to
479
+ ``handler(stream, scheduler, step, model_output, timestep, sample, args, kwargs)``; ``step`` is the real
480
+ bound method. stream: "video" for pipe.scheduler, "audio" for any other instance (LTX-2's audio copy)."""
481
+ video = getattr(self.pipe, "scheduler", None)
482
+ if video is None: # nothing to hook (a stand-in pipeline without a scheduler)
483
+ yield
484
+ return
485
+ cls = type(video)
486
+ had, orig = "step" in cls.__dict__, cls.step
487
+
488
+ def step(sched, model_output, timestep, sample, *args, **kw):
489
+ bound = orig.__get__(sched, cls)
490
+ return handler("video" if sched is video else "audio", sched, bound, model_output, timestep, sample,
491
+ args, kw)
492
+
493
+ cls.step = step
494
+ try:
495
+ yield
496
+ finally:
497
+ if had:
498
+ cls.step = orig
499
+ else:
500
+ del cls.step
501
+
502
+ def _unpack(self, stream: str, t, shapes: dict):
503
+ """A packed latent in the noise layout of ``shapes``; as it is when its size is not that shape's (a pipeline
504
+ that ran another shape than asked: the dump then shows that shape, which the checker refuses)."""
505
+ p, shape = self.pipe, shapes[stream]
506
+ if t.numel() != _numel(shape):
507
+ return t
508
+ if stream == "video":
509
+ return p._unpack_latents(t, shape[2], shape[3], shape[4], p.transformer_spatial_patch_size,
510
+ p.transformer_temporal_patch_size)
511
+ return p._unpack_audio_latents(t, shape[2], shape[3])
512
+
513
+ def _pack(self, stream: str, t):
514
+ p = self.pipe
515
+ if stream == "video":
516
+ return p._pack_latents(t, p.transformer_spatial_patch_size, p.transformer_temporal_patch_size)
517
+ return p._pack_audio_latents(t)
518
+
519
+ def _set_attention(self, name) -> None:
520
+ self.pipe.transformer.set_attention_backend(name)
521
+ connectors = getattr(self.pipe, "connectors", None)
522
+ if hasattr(connectors, "set_attention_backend"):
523
+ connectors.set_attention_backend(name)
524
+
525
+ def replay(self, job: ClipJob, dump, on_step, on_schedule=None, attention: str | None = None,
526
+ decode: bool = True) -> dict:
527
+ """Run ``job`` through this pipeline with ``dump``'s latents forced in (see the module docstring).
528
+
529
+ ``on_step(i, stream, sigma, sigma_next, ref_out, chain)``: the reference's guided output from the dump's x
530
+ at step i and the reference scheduler's update of the dump's output, both unpacked (on the device).
531
+ ``on_schedule(sigmas)`` gets the reference's sigmas (N + 1 values) before the first update and may raise
532
+ to stop the replay. ``attention``: another backend for this replay only (the floor run); ``decode=False``
533
+ skips the decoders (frames None). Returns
534
+ {"frames": decoded frames [F, H, W, 3] in [0, 1], "initial": {stream: the reference's own initial latent},
535
+ "dtypes": {stream: {x, out, x_next: dtype name}}}."""
536
+ if attention and not self.opts.attention:
537
+ raise ValueError("a replay with another attention backend needs the pipeline's own backend set "
538
+ "(--attention, or reference mode), to switch back to it")
539
+ p, shapes, n = self.pipe, self.noise_shapes(job), dump.n_steps
540
+ initial, dtypes, counts = {}, {}, {}
541
+
542
+ def forced(stream, i, like):
543
+ key = f"step.{i}.{stream}.x" if i < n else f"final.{stream}"
544
+ return self._pack(stream, dump.get(key).to(like.device)).to(like.dtype)
545
+
546
+ def prepare(stream, fn):
547
+ def wrapper(*a, **kw):
548
+ out = fn(*a, **kw)
549
+ lat, rest = (out[0], out[1:]) if isinstance(out, tuple) else (out, None)
550
+ initial[stream] = self._unpack(stream, lat, shapes).float().cpu()
551
+ x = forced(stream, 0, lat)
552
+ return (x, *rest) if rest is not None else x
553
+ return wrapper
554
+
555
+ def step(stream, sched, real_step, model_output, timestep, sample, args, kw):
556
+ i = counts.get(stream, 0)
557
+ counts[stream] = i + 1
558
+ if i == 0 and stream == "video" and on_schedule is not None:
559
+ on_schedule([float(v) for v in sched.sigmas])
560
+ if i >= n: # the dump has fewer steps than the reference (on_schedule normally stopped it)
561
+ return real_step(model_output, timestep, sample, *args, **kw)
562
+ if stream not in dtypes:
563
+ dtypes[stream] = {"x": _torch_dtype(sample), "out": _torch_dtype(model_output)}
564
+ eng = self._pack(stream, dump.get(f"step.{i}.{stream}.out").to(sample.device)).to(model_output.dtype)
565
+ res = real_step(eng, timestep, sample, *args, **kw)
566
+ chain = _prev(res)
567
+ dtypes[stream].setdefault("x_next", _torch_dtype(chain))
568
+ j = sched.step_index - 1
569
+ on_step(i, stream, float(sched.sigmas[j]), float(sched.sigmas[j + 1]),
570
+ self._unpack(stream, model_output, shapes), self._unpack(stream, chain, shapes))
571
+ return _with_prev(res, forced(stream, i + 1, chain))
572
+
573
+ saved = {a: getattr(p, a) for a in ("prepare_latents", "prepare_audio_latents") if hasattr(p, a)}
574
+ for attr, stream in (("prepare_latents", "video"), ("prepare_audio_latents", "audio")):
575
+ if attr in saved and stream in shapes:
576
+ setattr(p, attr, prepare(stream, saved[attr]))
577
+ if attention:
578
+ self._set_attention(attention)
579
+ try:
580
+ kwargs = self._call_kwargs(job)
581
+ if not decode:
582
+ kwargs["output_type"] = "latent"
583
+ with self._scheduler_steps(step):
584
+ video, _audio = p(**kwargs)
585
+ finally:
586
+ for attr, fn in saved.items():
587
+ setattr(p, attr, fn)
588
+ if attention:
589
+ self._set_attention(self.opts.attention)
590
+ return {"frames": video[0] if decode else None, "initial": initial, "dtypes": dtypes}
591
+
592
+ def _on_step(self, pipe, i, t, callback_kwargs):
593
+ if self.clock is not None:
594
+ self.clock.step()
595
+ return {}
596
+
597
+ def write(self, out: Generated, path, clock: StageClock) -> None:
598
+ from diffusers.utils import encode_video
599
+
600
+ encode_video(out.video, fps=int(out.fps), output_path=str(path), audio=out.audio,
601
+ audio_sample_rate=out.audio_sample_rate)
@@ -0,0 +1,46 @@
1
+ """The initial noise a clip starts from: drawn on the CPU from one seed, the same for every engine.
2
+
3
+ The recipe (an engine can follow it without inferencekit)::
4
+
5
+ g = torch.Generator(device="cpu").manual_seed(noise_seed)
6
+ for name, shape in shapes: # in order: "video" first, then "audio" when the model makes audio
7
+ noise[name] = torch.randn(shape, generator=g, dtype=torch.float32)
8
+
9
+ The numbers are drawn on the CPU and only then moved to the GPU. CUDA's generator gives other numbers for
10
+ the same seed, so noise drawn on the GPU would differ between engines (and is not promised to stay the
11
+ same across GPU models or library versions). ``noise_seed`` is the request's ``noise_seed``, or its
12
+ ``seed`` when the request has none.
13
+
14
+ For LTX-2.x the shapes are the unpacked latents (diffusers_ltx.noise_shapes): video
15
+ [1, C, (frames - 1) // 8 + 1, height // 32, width // 32] and audio [1, Ca, L, M]. These are also the first
16
+ two draws a Diffusers LTX-2 pipeline makes when it is given a CPU generator seeded with ``noise_seed``.
17
+
18
+ ``digest`` hashes names, shapes and the float32 bytes in order: equal digests mean identical noise. The
19
+ server reports it as ``effective.noise_sha256``, so two engines' records show whether they started from
20
+ the same tensors.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+
27
+ RECIPE = "cpu-randn-float32/1" # bump if the recipe above ever changes
28
+
29
+
30
+ def initial_noise(noise_seed: int, shapes: dict) -> dict:
31
+ """{name: CPU float32 tensor} drawn in the order of ``shapes`` from one CPU generator."""
32
+ import torch
33
+
34
+ g = torch.Generator(device="cpu").manual_seed(int(noise_seed))
35
+ return {name: torch.randn(tuple(int(d) for d in shape), generator=g, dtype=torch.float32)
36
+ for name, shape in shapes.items()}
37
+
38
+
39
+ def digest(noise: dict) -> str:
40
+ """sha256 over (name, shape, float32 bytes) for each tensor, in order."""
41
+ h = hashlib.sha256(RECIPE.encode())
42
+ for name, t in noise.items():
43
+ t = t.detach().to("cpu").contiguous()
44
+ h.update(f"|{name}:{'x'.join(str(d) for d in t.shape)}|".encode())
45
+ h.update(t.numpy().tobytes())
46
+ return h.hexdigest()