File size: 28,345 Bytes
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3be2f8
 
 
 
 
 
 
 
 
 
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44f12a2
 
 
 
 
 
 
 
 
642c64c
 
 
 
44f12a2
 
642c64c
44f12a2
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
"""Render LTX-2.5 clips from conditioning produced outside ComfyUI.

This is the second half of the split that `ltx_conditioning_dump.py` explains:
the encoder ran in another process and wrote a [T, 6144] tensor, and this loads
a GGUF DiT and samples from that tensor. Nothing here knows or cares which
encoder produced it, which is the point - a compressed encoder ComfyUI cannot
load still drives the same DiT, and two builds can be compared frame for frame
because everything downstream of the conditioning is identical.

Why a script instead of a ComfyUI graph: the injection point does not exist as a
node. `CLIPTextEncode` is the only producer of LTX conditioning and it needs a
loadable encoder. Driving ComfyUI's classes directly is a smaller and more
honest change than a custom node that fakes a CLIP object.

Three things are deliberately not the vendor default:

* `Guider_Basic` rather than dual CFG. The published workflow sets `video_cfg`
  and `audio_cfg` to 1.0, and at 1.0 `Guider_DualCFG.predict_noise` drops both
  the negative and middle conds and returns the positive prediction unchanged.
  The two are the same computation; this one does not need a negative prompt.
* no latent upscale pass. The vendor graph samples at base resolution, upscales
  the latent 2x and samples again, and this script stops after the first pass.
  Both builds stop identically, so it never affected a comparison.

  **The reason given here for stopping was wrong.** It claimed the second pass,
  at 4x the token count, is what a 16 GB card cannot afford. Measured: 233
  frames from 512x320 to 1024x640, both passes plus the decode, peaks at
  10.03 GiB and finishes in 312 s on a V100. It fits, and it is the difference
  between soft and sharp - 4.1x the Laplacian variance on the same pixel grid.
  See `reports/ltx25_av_alignment/two_pass/`. `ltx_av_guide.upscale_av_latent`
  performs the pass; this script has not been rewired to use it.
* tiled VAE decode by default. A 97-frame decode at full size peaks well above
  what is left after a 10.6 GB DiT.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
import wave
from fractions import Fraction
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

from pacific.ltx_av import (
    generation_manifest_identity,
    target_sample_count,
    validate_generation_manifest,
)
from pacific.strict_json import strict_json_loads

#: Overridable because a Space, a rented box and this workstation each put
#: ComfyUI somewhere different, and the import below needs the real path.
COMFY = Path(os.environ.get("LTX_COMFY_DIR", "/home/topabaem/ComfyUI"))

#: The distilled schedule from Lightricks' own graph: nine sigmas, eight steps.
#: Not a guess and not tuned here - a different schedule would be a second
#: variable between builds.
DISTILLED_SIGMAS = "1.0,0.99375,0.9875,0.98125,0.975,0.909375,0.725,0.421875,0.0"


def import_comfy(comfy_args: list[str] | None = None):
    """Import ComfyUI with a substituted `sys.argv`.

    `comfy.cli_args` runs argparse at import time against the real `sys.argv`,
    so this script's own flags would be parsed as ComfyUI's and abort. Swapping
    argv is the whole trick, and it is also the only way to set ComfyUI's memory
    policy - there is no API for it, `--reserve-vram` is read once at import.

    Memory policy matters on a card this size. The card is shared: an unrelated
    `llama-server` holds 1.66 GB and is not ours to kill. Left to itself ComfyUI
    loads all 10.6 GB of DiT resident and then has nothing left for the two
    embeddings connectors or the dequantisation buffers, and dies inside the
    Q3_K bit unpack. `--reserve-vram` alone did not change that - it trims the
    budget without changing the loading mode - so `--lowvram` is the flag that
    actually makes it stream module by module.
    """
    saved = sys.argv[:]
    sys.argv = [saved[0], *(comfy_args or [])]
    sys.path.insert(0, str(COMFY))
    try:
        # Without this `cli_args` runs `parse_args([])` and every flag below is
        # silently the default. `main.py` is the only caller that switches it on,
        # so importing ComfyUI as a library gets defaults and no warning - two
        # runs with different flags produce byte-identical OOM messages, which is
        # how this was found.
        import comfy.options
        comfy.options.enable_args_parsing()

        import comfy.model_management
        import comfy.sample
        import comfy.samplers
        import comfy.sd
        import comfy.utils
        import folder_paths  # noqa: F401
    finally:
        sys.argv = saved


def unwrap(node_output):
    """ComfyUI nodes return `io.NodeOutput`; the values live behind it."""
    for attr in ("result", "_result", "values"):
        if hasattr(node_output, attr):
            value = getattr(node_output, attr)
            if value is not None:
                return value
    return node_output


def load_gguf_unet(path: Path):
    """Load the DiT through ComfyUI-GGUF.

    Its dequantisation is written in plain torch, which is the only reason any
    of this runs on a V100: every ComfyUI-native quantised format - fp8, nvfp4,
    mxfp8 - dispatches to tensor-core kernels that need compute capability 8.9.
    """
    import importlib

    sys.path.insert(0, str(COMFY / "custom_nodes"))
    module = importlib.import_module("ComfyUI-GGUF")
    loader = module.NODE_CLASS_MAPPINGS["UnetLoaderGGUF"]()

    import folder_paths
    folder_paths.add_model_folder_path("unet_gguf", str(path.parent))
    return unwrap(loader.load_unet(path.name))[0]


def apply_lora(model, path: Path, strength: float):
    """Patch a LoRA onto the GGUF DiT.

    Lightricks' own `dev` workflow never runs `dev` bare - it loads
    `ltx-2.5-22b-distilled-lora-450` on top at strength 0.5. Run without it, this
    checkpoint produces soft, under-resolved frames at every schedule, step count
    and precision tried, while the distilled transformer is sharp in eight steps.
    ComfyUI-GGUF supports this: `GGUFModelPatcher.patch_weight_to_device` attaches
    patches to the quantized tensor rather than materialising it.
    """
    import comfy.sd
    import comfy.utils
    import folder_paths
    folder_paths.add_model_folder_path("loras", str(path.parent))
    lora = comfy.utils.load_torch_file(str(path), safe_load=True)
    patched, _ = comfy.sd.load_lora_for_models(model, None, lora, strength, 0)
    return patched


def load_video_vae(path: Path):
    """The metadata is not optional here.

    `comfy.sd.VAE` reads the LTX decoder's channel widths out of
    `metadata["config"]` (sd.py:624). Without it the state dict still matches the
    LTX branch on tensor names, so detection succeeds and then loads a
    differently sized LTX VAE - the failure is 200 lines of shape mismatch, not a
    clear "wrong file".
    """
    import comfy.sd
    import comfy.utils
    sd, metadata = comfy.utils.load_torch_file(str(path), return_metadata=True)
    return comfy.sd.VAE(sd=sd, metadata=metadata)


def load_audio_vae(path: Path):
    import comfy.utils
    from comfy.ldm.lightricks.vae.audio_vae import AudioVAE
    sd, metadata = comfy.utils.load_torch_file(str(path), return_metadata=True)
    return AudioVAE(sd, metadata)


def make_latents(width: int, height: int, length: int, fps: Fraction, audio_vae):
    """Empty video and audio latents, joined into the nested pair the DiT wants."""
    import comfy.model_management
    import comfy.nested_tensor
    import torch
    from comfy_extras.nodes_lt_audio import LTXVEmptyLatentAudio

    video = torch.zeros(
        [1, 128, ((length - 1) // 8) + 1, height // 32, width // 32],
        device=comfy.model_management.intermediate_device())
    audio = unwrap(LTXVEmptyLatentAudio.execute(
        frames_number=length, frame_rate=float(fps), batch_size=1, audio_vae=audio_vae))
    if isinstance(audio, (tuple, list)):
        audio = audio[0]

    latent = {}
    latent.update({"samples": video})
    latent.update({k: v for k, v in audio.items() if k != "samples"})
    latent["samples"] = comfy.nested_tensor.NestedTensor((video, audio["samples"]))
    return latent


def wrap_conditioning(tensor, fps: Fraction):
    """A [T, 6144] tensor as a ComfyUI CONDITIONING, with the flag it needs.

    `unprocessed_ltxav_embeds` is not a hint, it selects a different graph. The
    aggregate output is 6144 wide, which is exactly `cross_attention_dim +
    audio_cross_attention_dim`, so without the flag `preprocess_text_embeds`
    matches its early return and hands the raw aggregates straight to
    cross-attention - skipping both embeddings connectors. It renders, it just
    renders mud. `LTXAVTEModel` sets this flag on every dual_linear encode; we
    produce the same tensor, so we owe it too.
    """
    import node_helpers
    cond = [[tensor.unsqueeze(0), {}]]
    return node_helpers.conditioning_set_values(cond, {
        "frame_rate": float(fps),
        "unprocessed_ltxav_embeds": True,
    })


def build_sigmas(model, sigmas_text: str, steps: int, scheduler: str,
                 latent=None, max_shift: float = 2.05, base_shift: float = 0.95,
                 terminal: float = 0.1):
    """The literal vendor schedule, `LTXVScheduler`, or a generic ComfyUI one.

    The nine hardcoded sigmas belong to the distilled transformer and only to
    it - a schedule solved for a model trained to land in eight steps.

    For `dev`, ComfyUI's generic schedulers are the wrong tool and it shows on
    screen. `normal` at 30 steps spends 27 of them between 1.0 and 0.28 and then
    drops 0.28 -> 0.011 -> 0, so the low-sigma range where structure resolves is
    barely sampled at all and the render comes out hazy and washed out.

    `LTXVScheduler` is what Lightricks' own dev workflow uses. It builds a
    linspace, applies a shift interpolated from the *token count* of the latent -
    which is why `latent` is worth passing - and then stretches the tail so the
    last non-zero sigma lands on `terminal` rather than wherever the curve fell.
    Defaults here are that workflow's: 2.05 / 0.95 / stretch / 0.1.
    """
    import comfy.samplers
    import torch
    if steps <= 0:
        return torch.FloatTensor([float(s) for s in sigmas_text.split(",")])
    if scheduler == "ltxv":
        from comfy_extras.nodes_lt import LTXVScheduler
        out = unwrap(LTXVScheduler.execute(
            steps=steps, max_shift=max_shift, base_shift=base_shift,
            stretch=True, terminal=terminal, latent=latent))
        if isinstance(out, (tuple, list)):
            out = out[0]
        return out.cpu()
    return comfy.samplers.calculate_sigmas(
        model.get_model_object("model_sampling"), scheduler, steps).cpu()


def sample(model, conditioning, latent, sigmas, seed: int, fps: Fraction,
           cfg: float = 1.0, negative=None, sampler_name: str = "euler_ancestral"):
    """Sample one clip. `cfg` above 1 needs `negative` and buys prompt adherence.

    The vendor graph sets both CFG scales to 1.0, which makes the guider return
    the conditional prediction unchanged - correct for a distilled model, which
    is trained with the guidance already baked in, and half the compute. On the
    distilled transformer raising CFG changed nothing at all, up to 6.0 with an
    explicit anti-human negative, which is consistent with that: there is no
    guidance direction left to scale. On `dev` it should be the main knob.
    """
    import comfy.model_management
    import comfy.sample
    import comfy.samplers
    from comfy_extras.nodes_custom_sampler import Guider_Basic, Noise_RandomNoise

    cond = wrap_conditioning(conditioning, fps)

    if cfg > 1.0:
        if negative is None:
            raise SystemExit("--cfg above 1 needs a negative conditioning index")
        guider = comfy.samplers.CFGGuider(model)
        guider.set_conds(cond, wrap_conditioning(negative, fps))
        guider.set_cfg(cfg)
    else:
        guider = Guider_Basic(model)
        guider.set_conds(cond)

    sampler = comfy.samplers.sampler_object(sampler_name)
    noise = Noise_RandomNoise(seed)

    samples = latent["samples"]
    samples = comfy.sample.fix_empty_latent_channels(guider.model_patcher, samples)
    out = guider.sample(noise.generate_noise({"samples": samples}), samples,
                        sampler, sigmas, denoise_mask=None, callback=None,
                        disable_pbar=False, seed=seed)
    return out.to(comfy.model_management.intermediate_device())


def decode(samples, video_vae, audio_vae, tile: int, temporal: int):
    """Inference mode is not an optimisation here, it is required.

    `VAE.process_output` finishes with `image.add_(1.0).div_(2.0)`, an in-place
    write. ComfyUI's execution engine runs every node inside `inference_mode`,
    so the tensor it writes to was created there too and the write is legal.
    Called from an ordinary script the tiles come back as inference tensors and
    the same line raises. Wrapping the decode puts us back on ComfyUI's terms.
    """
    import comfy.model_management
    import torch

    video_latent, audio_latent = samples.unbind()
    # The device moves stay OUTSIDE inference mode, and that is not tidiness.
    # `Module.to` rebinds every parameter, and a parameter rebound inside
    # inference mode is an inference tensor for the rest of the process. Later,
    # a `VAE.encode` for an image guide hits `torch.cudnn_convolution` with an
    # inference weight and dies with "Inference tensors cannot be saved for
    # backward" - in a different function, on a different request, with nothing
    # to point back here. Volta hides it, because its fp32 cast skips the branch
    # that reaches that op at all.
    video_vae.first_stage_model.to(comfy.model_management.get_torch_device())
    with torch.inference_mode():
        frames = video_vae.decode_tiled(video_latent, tile_x=tile, tile_y=tile,
                                        overlap=tile // 8, tile_t=temporal,
                                        overlap_t=temporal // 8)
    video_vae.first_stage_model.to("cpu")
    comfy.model_management.soft_empty_cache()

    with torch.inference_mode():
        audio = audio_vae.decode(audio_latent)
    return frames.clone(), audio.clone(), int(audio_vae.output_sample_rate)


def write_clip(
    frames,
    audio,
    sample_rate: int,
    fps: Fraction,
    out: Path,
    raw_video: Path | None = None,
) -> tuple[Path, int]:
    """Pipe raw frames into ffmpeg rather than leaving a PNG sequence behind."""
    import numpy as np
    import torch

    array = frames
    if array.dim() == 5:
        array = array[0]
    array = array.float().clamp(0, 1).mul(255).round().to("cpu", torch.uint8).numpy()
    if array.shape[-1] != 3:
        array = np.moveaxis(array, 1, -1)
    count, height, width, _ = array.shape
    target_samples = target_sample_count(count, fps, sample_rate)
    if raw_video is not None:
        raw_video.parent.mkdir(parents=True, exist_ok=True)
        np.save(raw_video, array)

    wav = out.with_suffix(".wav")
    waveform = audio
    if waveform.dim() == 3:
        waveform = waveform[0]
    pcm = waveform.transpose(0, 1).float().clamp(-1, 1).mul(32767).to("cpu", torch.int16).numpy()
    with wave.open(str(wav), "wb") as f:
        f.setnchannels(pcm.shape[1])
        f.setsampwidth(2)
        f.setframerate(sample_rate)
        f.writeframes(pcm.tobytes())

    command = [
        "ffmpeg", "-y", "-loglevel", "error",
        "-f", "rawvideo", "-pix_fmt", "rgb24",
        "-s", f"{width}x{height}", "-r", str(fps), "-i", "-",
        "-i", str(wav),
        "-c:v", "libx264", "-preset", "medium", "-crf", "17", "-pix_fmt", "yuv420p",
        "-af", f"apad,atrim=end_sample={target_samples}",
        "-c:a", "aac", "-b:a", "192k", str(out),
    ]
    process = subprocess.Popen(command, stdin=subprocess.PIPE)
    process.communicate(array.tobytes())
    if process.returncode != 0:
        raise SystemExit(f"ffmpeg failed for {out}")
    print(f"  wrote {out}  {count} frames {width}x{height}", flush=True)
    return wav, int(pcm.shape[0])


def sha256_path(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def artifact(path: Path) -> dict[str, str]:
    return {"path": str(path.resolve()), "sha256": sha256_path(path)}


def append_manifest(path: Path, record: dict) -> None:
    validate_generation_manifest(record)
    existing: dict[str, dict] = {}
    if path.exists():
        for line in path.read_text(encoding="utf-8").splitlines():
            if line.strip():
                row = strict_json_loads(line)
                existing[row["sample_id"]] = row
    previous = existing.get(record["sample_id"])
    if previous is not None:
        if generation_manifest_identity(previous) != generation_manifest_identity(record):
            raise SystemExit(f"manifest already has a different {record['sample_id']}")
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(record, allow_nan=False, sort_keys=True) + "\n")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--conditioning", type=Path, required=True)
    parser.add_argument("--gguf", type=Path, required=True)
    parser.add_argument("--video-vae", type=Path, required=True)
    parser.add_argument("--audio-vae", type=Path, required=True)
    parser.add_argument("--out-dir", type=Path, required=True)
    parser.add_argument("--width", type=int, default=768)
    parser.add_argument("--height", type=int, default=512)
    parser.add_argument("--lengths", default="73,49,97,49,73",
                        help="frames per clip; LTX needs 8n+1")
    parser.add_argument("--fps", type=Fraction, default=Fraction(24, 1))
    parser.add_argument("--seed", type=int, default=20260813)
    parser.add_argument("--sigmas", default=DISTILLED_SIGMAS,
                        help="literal schedule; ignored when --steps is set")
    parser.add_argument("--steps", type=int, default=0,
                        help="compute the schedule instead; use this for `dev`")
    parser.add_argument("--scheduler", default="ltxv",
                        help="'ltxv' for LTXVScheduler, or a ComfyUI scheduler name")
    parser.add_argument("--max-shift", type=float, default=2.05)
    parser.add_argument("--base-shift", type=float, default=0.95)
    parser.add_argument("--terminal", type=float, default=0.1)
    parser.add_argument("--sampler", default="euler_ancestral",
                        help="euler is the usual choice for a non-distilled model")
    parser.add_argument("--tile", type=int, default=512)
    parser.add_argument("--temporal", type=int, default=32)
    parser.add_argument("--only", type=int, default=None,
                        help="render a single prompt index; for the smoke test")
    parser.add_argument("--limit", type=int,
                        help="render only the first N prompts; for diagnostic subsets")
    parser.add_argument("--cfg", type=float, default=1.0,
                        help="classifier-free guidance; >1 doubles cost per step")
    parser.add_argument("--negative-index", type=int, default=None,
                        help="prompt index in the same file to use as the "
                             "unconditional branch when --cfg is above 1")
    parser.add_argument("--lora", type=Path, default=None,
                        help="LoRA to patch onto the DiT; the vendor dev "
                             "workflow uses the distilled LoRA at 0.5")
    parser.add_argument("--lora-strength", type=float, default=0.5)
    parser.add_argument("--fp32", action="store_true",
                        help="skip --fp16-unet; ComfyUI then casts to fp32. "
                             "Slower and twice the activation memory, but fp16 "
                             "saturation on Volta shows up as hazy low-contrast "
                             "output rather than as an error")
    parser.add_argument("--reserve-vram", type=float, default=1.0,
                        help="GB ComfyUI must leave free")
    parser.add_argument("--vram-mode", choices=("normal", "lowvram", "novram"),
                        default="normal",
                        help="how much of the DiT ComfyUI may keep resident")
    parser.add_argument("--stage-dir", type=Path,
                        help="save generated latents and raw decoder frames")
    parser.add_argument("--manifest", type=Path,
                        help="append a deterministic stage manifest; requires --stage-dir")
    parser.add_argument("--profile-id", default="")
    parser.add_argument("--model-kind", choices=("baseline", "compressed", "ablation"),
                        default="compressed")
    args = parser.parse_args()
    if args.manifest is not None and args.stage_dir is None:
        parser.error("--manifest requires --stage-dir")
    if args.only is not None and args.limit is not None:
        parser.error("--only and --limit are mutually exclusive")
    if args.limit is not None and args.limit <= 0:
        parser.error("--limit must be positive")

    # Volta has fp16 tensor cores and no bf16 ones, so `should_use_bf16` is
    # False - but the checkpoint declares bf16, and ComfyUI resolves that
    # mismatch by setting `manual_cast_dtype` to fp32. Every matmul then runs at
    # fp32: double the activation memory and half the throughput, which is the
    # difference between this fitting and not. Forcing fp16 removes the cast
    # entirely (`manual_cast` becomes None).
    comfy_args = ["--reserve-vram", str(args.reserve_vram)]
    if not args.fp32:
        comfy_args.insert(0, "--fp16-unet")
    if args.vram_mode != "normal":
        comfy_args.append(f"--{args.vram_mode}")
    import_comfy(comfy_args)
    import torch

    bundle = torch.load(args.conditioning, weights_only=False)
    prompts = bundle["prompts"]
    prompt_ids = bundle.get("prompt_ids", [f"prompt-{i:02d}" for i in range(len(prompts))])
    conditioning = bundle["conditioning"]
    label = bundle.get("label", args.conditioning.stem)
    profile_id = args.profile_id or label
    print(f"{len(prompts)} prompts from build '{label}'", flush=True)

    lengths = [int(v) for v in args.lengths.split(",")]
    if len(lengths) < len(prompts):
        lengths = lengths + [lengths[-1]] * (len(prompts) - len(lengths))

    if args.only is not None:
        indices = [args.only]
    else:
        count = len(prompts) if args.limit is None else min(args.limit, len(prompts))
        indices = list(range(count))
    negative = None
    if args.cfg > 1.0:
        if args.negative_index is None:
            raise SystemExit("--cfg above 1 requires --negative-index")
        negative = conditioning[args.negative_index]
        indices = [i for i in indices if i != args.negative_index]
        print(f"cfg {args.cfg} against prompt [{args.negative_index}] as negative",
              flush=True)

    args.out_dir.mkdir(parents=True, exist_ok=True)
    audio_vae = load_audio_vae(args.audio_vae)
    video_vae = load_video_vae(args.video_vae)
    model = load_gguf_unet(args.gguf)
    if args.lora is not None:
        model = apply_lora(model, args.lora, args.lora_strength)
        print(f"lora {args.lora.name} @ {args.lora_strength}", flush=True)
    print(f"models loaded; scheduler {args.scheduler}, sampler {args.sampler}, "
          f"cfg {args.cfg}", flush=True)

    for i in indices:
        clip_started = time.perf_counter()
        if torch.cuda.is_available():
            torch.cuda.reset_peak_memory_stats()
        length = lengths[i]
        seed = args.seed + i
        sample_id = f"{profile_id}__{prompt_ids[i]}__seed-{seed}"
        print(f"[{i}] {length} frames  {prompts[i][:70]}", flush=True)
        latent = make_latents(args.width, args.height, length, args.fps, audio_vae)
        # LTXVScheduler shifts by token count, so the schedule is per clip.
        sigmas = build_sigmas(model, args.sigmas, args.steps, args.scheduler,
                              latent=latent, max_shift=args.max_shift,
                              base_shift=args.base_shift, terminal=args.terminal)
        print(f"    {len(sigmas) - 1} steps  "
              f"{[round(float(x), 3) for x in sigmas[:4]]} .. "
              f"{[round(float(x), 3) for x in sigmas[-3:]]}", flush=True)
        sample_started = time.perf_counter()
        samples = sample(
            model,
            conditioning[i],
            latent,
            sigmas,
            seed,
            args.fps,
            cfg=args.cfg,
            negative=negative,
            sampler_name=args.sampler,
        )
        sample_seconds = time.perf_counter() - sample_started
        latent_path = None
        raw_video_path = None
        if args.stage_dir is not None:
            latent_path = args.stage_dir / "latent" / f"{sample_id}.pt"
            raw_video_path = args.stage_dir / "raw" / f"{sample_id}-frames.npy"
            latent_path.parent.mkdir(parents=True, exist_ok=True)
            video_latent, audio_latent = samples.unbind()
            torch.save(
                {"video": video_latent.cpu(), "audio": audio_latent.cpu()},
                latent_path,
            )
        decode_started = time.perf_counter()
        frames, audio, rate = decode(
            samples, video_vae, audio_vae, args.tile, args.temporal
        )
        decode_seconds = time.perf_counter() - decode_started
        output_name = f"{sample_id}.mp4" if "prompt_ids" in bundle else f"{label}-{i:02d}.mp4"
        output = args.out_dir / output_name
        mux_started = time.perf_counter()
        wav, audio_samples = write_clip(
            frames, audio, rate, args.fps, output, raw_video=raw_video_path
        )
        mux_seconds = time.perf_counter() - mux_started
        total_clip_seconds = time.perf_counter() - clip_started
        peak_allocated = (
            int(torch.cuda.max_memory_allocated()) if torch.cuda.is_available() else 0
        )
        if args.manifest is not None:
            from ltx_av_timing_audit import audit

            timing = audit(output)
            record = {
                "schema_version": 1,
                "sample_id": sample_id,
                "model_kind": args.model_kind,
                "profile": profile_id,
                "checkpoint": bundle.get("checkpoint", ""),
                "checkpoint_sha256": bundle.get("checkpoint_sha256", ""),
                "profile_hash": bundle.get("profile_hash", ""),
                "conditioning_sha256": sha256_path(args.conditioning),
                "prompt_id": prompt_ids[i],
                "prompt": prompts[i],
                "seed": seed,
                "frame_count": timing["video_frames"],
                "fps": timing["fps"],
                "audio_sample_count": audio_samples,
                "audio_sample_rate": rate,
                "first_video_pts": timing["video_first_pts"],
                "last_video_end_pts": timing["video_last_end_pts"],
                "video_time_base": timing["video_time_base"],
                "first_audio_pts": timing["audio_first_packet_pts"],
                "last_audio_end_pts": timing["audio_last_end_pts"],
                "audio_time_base": timing["audio_time_base"],
                "sample_seconds": sample_seconds,
                "decode_seconds": decode_seconds,
                "mux_seconds": mux_seconds,
                "total_clip_seconds": total_clip_seconds,
                "torch_peak_allocated_bytes": peak_allocated,
                "generation_command": [sys.executable, *sys.argv],
                "artifacts": {
                    "latent": artifact(latent_path),
                    "raw_video": artifact(raw_video_path),
                    "raw_audio": artifact(wav),
                    "muxed": artifact(output),
                },
            }
            append_manifest(args.manifest, record)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())