File size: 11,672 Bytes
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257e290
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257e290
 
 
 
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257e290
 
 
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
257e290
 
642c64c
 
 
 
 
 
 
 
 
 
257e290
 
 
 
 
 
 
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257e290
 
 
642c64c
 
257e290
 
 
642c64c
 
 
 
 
257e290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257e290
 
 
 
 
 
 
642c64c
 
 
257e290
 
 
 
 
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257e290
 
 
 
 
 
642c64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257e290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Write the conditioning tensor an LTX-2.5 encoder produces, so a DiT can use it.

The gate (`ltx_prompt_embedding_gate.py`) scores an encoder without rendering.
This is its sibling and shares its entire forward path: same tokenization, same
49-slot hidden stack, same `apply_aggregates`. The difference is the output -
the gate compares two encoders and prints a verdict, this writes the tensor.

Why it has to exist at all: ComfyUI cannot load our compressed encoder. Its GGUF
path gates text encoders on `TXT_ARCH_LIST`, which carries `gemma3` but not
`gemma4`, and its native quantisation kernels need compute capability 8.9. On a
V100 there is no route from a compressed LTX-2.5 encoder into a ComfyUI graph.
So the encoder runs here, out of process, and hands over the one tensor the DiT
actually consumes.

That tensor is `torch.cat((video, audio), dim=-1)`, [T, 6144] - exactly what
`LTXAVTEModel.encode_token_weights` returns, and everything downstream treats it
as one opaque block. `apply_aggregates` already produces the two halves.

Running out of process also solves the memory problem rather than working around
it. The encoder is 26 GB of BF16 and the DiT is 10.6 GB; neither fits beside the
other on a 16 GB card. Here the encoder loads, emits, and exits, and the card is
empty again before the DiT is asked for.
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

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

from ltx_prompt_embedding_gate import (
    apply_aggregates,
    assert_fully_loaded,
    check_aggregates_present,
    hidden_stack,
    hidden_state_artifact,
    register_ltx_renames,
    weights_path,
)

from pacific.ltx_av import load_event_set

#: ComfyUI pads every LTX prompt to this length on the left
#: (`Gemma3_12BTokenizer`, `comfy/text_encoders/lt.py:76`: `min_length=1024`,
#: `pad_left=True`). It is also exactly the encoder's `sliding_window`, so the
#: real tokens sit at the top of a window that is otherwise entirely padding.
COMFY_MIN_LENGTH = 1024

#: `special_tokens={"start": 2, "pad": 0}` in the same constructor. The HF
#: tokenizer for this checkpoint adds neither - verified, its first id for
#: "A humanoid robot..." is 236776, not 2 - so the start token is prepended here
#: or the encoder never sees a sentence begin.
COMFY_BOS = 2
COMFY_PAD = 0


def hidden_stack_comfy(
    model, tokenizer, prompts: list[str], device: str, min_length: int = COMFY_MIN_LENGTH
) -> list:
    """Per prompt, the raw [49, T, 3840] Transformer stack for ComfyUI tokens.

    `hidden_stack` in the gate runs the prompt unpadded, which is the right
    choice for comparing two encoders to each other: both sides get the same
    treatment and the difference between them is the only thing measured. It is
    the wrong choice for feeding a DiT, because the DiT was trained on what the
    vendor pipeline emits, and that differs in two ways:

    * a start token. ComfyUI tokenizes with `add_bos=True`; this checkpoint's HF
      tokenizer does not add one.
    * position. ComfyUI left-pads to 1024 and then takes `position_ids =
      arange(seq_len)` over the *padded* sequence (`llama.py:683`), so a
      68-token prompt occupies RoPE positions 956..1023. Unpadded it occupies
      0..67. Same tokens, different angles in every rotary layer.

    Masking makes the padding itself inert - `enable_attention_masks=True` in
    `Gemma3_12BModel` - so this is not about the pad values. It is about where
    on the rotary circle the real tokens land, and the DiT has an opinion.

    Transformers already final-normalizes only the last returned slot. Preserve
    every raw intermediate slot, then select the real-token suffix.

    The trailing slice reproduces `out[:, :, -sum(attention_mask):]` from
    `LTXAVTEModel.encode_token_weights`.
    """
    import torch

    out = []
    for prompt in prompts:
        ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
        ids = [COMFY_BOS, *ids]
        real = len(ids)
        if real > min_length:
            raise SystemExit(
                f"prompt is {real} tokens, past the {min_length} ComfyUI pads to; "
                "the vendor path would not have padded it either"
            )

        pad = min_length - real
        input_ids = torch.tensor([[COMFY_PAD] * pad + ids], device=device)
        attention_mask = torch.tensor([[0] * pad + [1] * real], device=device)
        # Passed explicitly rather than left to `transformers`, which derives
        # positions from the mask in some versions - that would put the real
        # tokens back at 0..T-1 and undo the entire point of padding here.
        position_ids = torch.arange(min_length, device=device).unsqueeze(0)

        with torch.no_grad():
            states = model(
                input_ids=input_ids,
                attention_mask=attention_mask,
                position_ids=position_ids,
                output_hidden_states=True,
            ).hidden_states
            stacked = torch.stack([state[0, -real:] for state in states])
        out.append(stacked.to(torch.float32).cpu())
    return out


def load_prompt_file(path: Path) -> list[str]:
    """One prompt per line; blank lines and `#` comments ignored.

    Prompts are long enough that a multi-line format would be nicer to read and
    far easier to get wrong - a stray blank line silently splits one prompt into
    two, and the resulting clip is of half a sentence. One line each is the
    format that cannot do that.
    """
    if path.suffix == ".jsonl":
        return [event["prompt"] for event in load_event_set(path)]
    lines = [line.strip() for line in path.read_text().splitlines()]
    return [line for line in lines if line and not line.startswith("#")]


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--model-dir", required=True, help="encoder dir: config, tokenizer and model.safetensors"
    )
    parser.add_argument("--prompts", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument(
        "--label", default="", help="build name recorded in the file, e.g. r45c-gptq"
    )
    parser.add_argument("--checkpoint-sha256", default="")
    parser.add_argument("--profile-hash", default="")
    parser.add_argument("--audio-aggregate-checkpoint", type=Path)
    parser.add_argument("--video-aggregate-checkpoint", type=Path)
    parser.add_argument("--hidden-stacks-output", type=Path)
    parser.add_argument(
        "--device-map",
        default="auto",
        help="26 GB of BF16 does not fit 16 GB of VRAM; 'auto' splits it",
    )
    parser.add_argument(
        "--packed-checkpoint",
        type=Path,
        help="load weights from a ltx_packed_codec artifact "
        "instead of model.safetensors; --model-dir still "
        "supplies config and tokenizer",
    )
    parser.add_argument(
        "--resident",
        action="store_true",
        help="keep the packed weights packed and dequantize in "
        "forward: 8.5 GB resident instead of 26.3 GB",
    )
    parser.add_argument(
        "--fold-pre-scale",
        action="store_true",
        help="with --resident, apply the AWQ scale to the "
        "activation instead of the weight; faster and not "
        "bit-identical",
    )
    parser.add_argument(
        "--comfy-padding",
        action="store_true",
        help="reproduce ComfyUI's BOS and 1024-token left pad; "
        "required for output a DiT will accept",
    )
    args = parser.parse_args()

    import torch
    from transformers import AutoModel, AutoTokenizer

    prompts = load_prompt_file(args.prompts)
    prompt_ids = (
        [event["id"] for event in load_event_set(args.prompts)]
        if args.prompts.suffix == ".jsonl"
        else [f"prompt-{index:02d}" for index in range(len(prompts))]
    )
    if not prompts:
        raise SystemExit(f"{args.prompts} has no prompts")
    print(f"{len(prompts)} prompts from {args.prompts}", flush=True)

    model_dir = Path(args.model_dir)
    checkpoint = args.packed_checkpoint or weights_path(model_dir)
    check_aggregates_present(checkpoint)

    tokenizer = AutoTokenizer.from_pretrained(args.model_dir)
    if args.packed_checkpoint:
        from ltx_packed_codec import load_packed_model

        model = load_packed_model(
            args.model_dir,
            args.packed_checkpoint,
            resident=args.resident,
            fold_pre_scale=args.fold_pre_scale,
        )
    else:
        register_ltx_renames()
        model, info = AutoModel.from_pretrained(
            args.model_dir,
            dtype=torch.bfloat16,
            device_map=args.device_map,
            output_loading_info=True,
        )
        assert_fully_loaded(info)
    model.eval()
    device = str(next(model.parameters()).device)
    print(f"{args.model_dir}: loaded on {device}", flush=True)

    capture = hidden_stack_comfy if args.comfy_padding else hidden_stack
    print(f"forward path: {capture.__name__}", flush=True)
    stacks = capture(model, tokenizer, prompts, device)
    slots, _, hidden = stacks[0].shape
    del model
    torch.cuda.empty_cache()
    print(f"{len(stacks)} stacks, {slots} slots x {hidden}", flush=True)
    if args.hidden_stacks_output is not None:
        args.hidden_stacks_output.parent.mkdir(parents=True, exist_ok=True)
        torch.save(
            hidden_state_artifact(
                prompt_ids=prompt_ids,
                prompts=prompts,
                comfy_padding=args.comfy_padding,
                hidden_stacks=stacks,
            ),
            args.hidden_stacks_output,
        )
        print(f"wrote {args.hidden_stacks_output}", flush=True)

    branch_checkpoints = {
        branch: path
        for branch, path in {
            "audio": args.audio_aggregate_checkpoint,
            "video": args.video_aggregate_checkpoint,
        }.items()
        if path is not None
    }
    for source in branch_checkpoints.values():
        check_aggregates_present(source)
    branches = apply_aggregates(stacks, checkpoint, branch_checkpoints)

    # The DiT sees one [T, 6144] block. Keeping the halves separate here would
    # push the concatenation - and the chance of getting its order backwards -
    # into the render script, where it would be checked by nothing.
    conditioning = []
    for prompt, branch in zip(prompts, branches, strict=True):
        merged = torch.cat((branch["video"], branch["audio"]), dim=-1)
        conditioning.append(merged.to(torch.float32).contiguous())
        print(f"  {tuple(merged.shape)}  {prompt[:60]}", flush=True)

    widths = {tuple(c.shape[1:]) for c in conditioning}
    if widths != {(6144,)}:
        raise SystemExit(f"expected a 6144-wide conditioning, got {widths}")

    args.output.parent.mkdir(parents=True, exist_ok=True)
    torch.save(
        hidden_state_artifact(
            label=args.label or model_dir.name,
            model_dir=str(model_dir),
            checkpoint=str(checkpoint),
            checkpoint_sha256=args.checkpoint_sha256,
            profile_hash=args.profile_hash,
            aggregate_checkpoints={
                branch: str(path) for branch, path in branch_checkpoints.items()
            },
            comfy_padding=args.comfy_padding,
            prompt_ids=prompt_ids,
            prompts=prompts,
            conditioning=conditioning,
        ),
        args.output,
    )
    print(f"wrote {args.output}", flush=True)
    return 0


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