multimodalart HF Staff commited on
Commit
6f29667
·
verified ·
1 Parent(s): ebbee14

MSR reference-to-video demo on LTX-2.5

Browse files
.gitattributes CHANGED
@@ -33,3 +33,12 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/cyberpunk_1.jpg filter=lfs diff=lfs merge=lfs -text
37
+ examples/cyberpunk_2.jpg filter=lfs diff=lfs merge=lfs -text
38
+ examples/cyberpunk_3.jpg filter=lfs diff=lfs merge=lfs -text
39
+ examples/forest_1.jpg filter=lfs diff=lfs merge=lfs -text
40
+ examples/forest_2.jpg filter=lfs diff=lfs merge=lfs -text
41
+ examples/forest_3.jpg filter=lfs diff=lfs merge=lfs -text
42
+ examples/hallway_1.jpg filter=lfs diff=lfs merge=lfs -text
43
+ examples/hallway_2.jpg filter=lfs diff=lfs merge=lfs -text
44
+ examples/hallway_3.jpg filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,92 @@
1
- ---
2
- title: Ltx25 Multi Subject Reference
3
- emoji: 🐠
4
- colorFrom: green
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.12'
9
- app_file: app.py
10
- pinned: false
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LTX-2.5 Multiple Subject Reference
3
+ emoji: 🎭
4
+ colorFrom: indigo
5
+ colorTo: red
6
+ sdk: gradio
7
+ sdk_version: 6.26.0
8
+ app_file: app.py
9
+ python_version: "3.12"
10
+ pinned: false
11
+ short_description: Multi-subject reference images to video with LTX-2.5
12
+ startup_duration_timeout: 1h
13
+ models:
14
+ - LiconStudio/LTX-2.5-Multiple-Subject-Reference
15
+ - Lightricks/LTX-2.5-Diffusers
16
+ tags:
17
+ - ltx-2.5
18
+ - ic-lora
19
+ - multi-subject
20
+ - reference-to-video
21
+ - image-to-video
22
+ ---
23
+
24
+ # 🎭 LTX-2.5 · Licon MSR V1
25
+
26
+ Up to **four subject stills plus one scene still**, named in the prompt (`Image 1:`,
27
+ `Image 2:`, `Image 3: Scene, …`), become a video with native audio that keeps every subject's
28
+ identity, clothing and the scene's look.
29
+
30
+ [`LiconStudio/LTX-2.5-Multiple-Subject-Reference`](https://huggingface.co/LiconStudio/LTX-2.5-Multiple-Subject-Reference)
31
+ (rank 128, apache-2.0) on [`Lightricks/LTX-2.5`](https://huggingface.co/Lightricks/LTX-2.5)
32
+ (22B, distilled), run through upstream `diffusers`.
33
+
34
+ ## How it works
35
+
36
+ Each reference still is repeated to 33 frames, VAE-encoded, given a **learned slot embedding**
37
+ and placed at a **negative temporal position** — slot *i* of *N* sits at pixel-frame
38
+ `-(N - i)`, i.e. just before frame 0. The video tokens then retrieve subject identity through
39
+ the transformer's own self-attention. Reference tokens ride along at `conditioning_mask = 1`
40
+ (fully clean) and are cropped off before decoding.
41
+
42
+ | | |
43
+ |---|---|
44
+ | **Base** | [`Lightricks/LTX-2.5-Diffusers`](https://huggingface.co/Lightricks/LTX-2.5-Diffusers) — distilled 22B DiT + Gemma-4 text encoder + video/audio VAEs. `transformer_full/`, `prompt_enhancer/`, `diffusion_decoder/` and `temporal_latent_upsampler/` are excluded from the snapshot |
45
+ | **Adapter** | `LTX-2.5-Licon-MSR-V1.safetensors`, strength 1.0. The file holds ComfyUI-style LoRA keys (only conversion: `diffusion_model.` → `transformer.`) *and* the non-LoRA `reference_slot_embedding` MLP, which is applied by hand |
46
+ | **Code** | `diffusers` main @ `d57cecde` — `LTX2InContextPipeline`, subclassed in `msr.py` |
47
+ | **Hardware** | ZeroGPU `xlarge` (96 GB). The 22B DiT plus the Gemma-4 encoder packs to ~71 GB, over the 48 GB slice |
48
+ | **Output** | 1280×704 @ 24 fps by default (1664×960 available), with LTX-2.5's native audio |
49
+
50
+ ## Faithfulness to the reference implementation
51
+
52
+ The author's reference is a ComfyUI graph:
53
+ [`ComfyUI-LTX2.5-MSR`](https://github.com/liconstudio/ComfyUI-LTX2.5-MSR) plus
54
+ `LTX2.5-MSR-sample-workflow.json`. Every node is reproduced:
55
+
56
+ | ComfyUI node | Here |
57
+ |---|---|
58
+ | `ComfyUILTX25MSRICLoRALoader` | `split_slot_state` + `load_lora_weights`. The loader's metadata guards (`reference_token_order=prepend`, `reference_slot_time_offsets=pic1_based_negative_time`, `reference_downscale_factor=1`) hold for this checkpoint |
59
+ | `_resize_reference` | `msr.msr_resize` — 1:1 port. Subject slots letterbox onto a **white** canvas unless the source already covers the target in the same aspect family (`landscape` ≥ 1.25, `portrait` ≤ 0.8, else `square`), in which case centre-crop-resize; the background slot always centre-crop-resizes. Bilinear, no antialias, matching `comfy.utils.common_upscale` |
60
+ | `_slot_embedding` | `msr.slot_embedding` — `slot_id/16` → `[scaled, sin(scaled·f), cos(scaled·f)]` → SiLU MLP → 128-dim code, added broadcast over the channel axis of the *normalized* reference latent. ComfyUI's LTX2 VAE normalizes inside `encode` (`per_channel_statistics.normalize`), which is exactly diffusers' `_normalize_latents`, so the two spaces agree |
61
+ | `LTXVAddGuide.append_keyframe(frame_idx=-(N-i))` | `coords[:, 0] += frame_offset / frame_rate` after `prepare_video_coords`. ComfyUI adds the offset in pixel-frames *before* the model's `× 1/frame_rate`; `prepare_video_coords` applies the same causal shift + `clamp(min=0)` and has already divided, so the two are algebraically identical |
62
+ | guide `strength=1.0`, no pixel mask | ComfyUI's `_build_guide_self_attention_mask` returns `None` in that case, and diffusers builds no mask when `conditioning_attention_strength == 1.0` — the stacks agree |
63
+ | `LTXVCropGuides` | `output_type="latent"` already returns only the base tokens |
64
+ | `PromptRelayEncode` (one local segment) | A single prompt textbox. With one segment the relay's token mask covers everything, so it reduces to encoding `global + local` concatenated — which is what the UI's prompt box is |
65
+ | two `ManualSigmas` + two `LTXVBaseSampler` | 8 distilled sigmas at half resolution → ×2 latent upsample → 3 sigmas at full resolution, one `torch.Generator` threaded through both stages, MSR guide re-attached in stage 2 as in the graph |
66
+ | `CFGGuider` cfg=1 / `LTXVDualCFGGuider [1,1]` | every guidance knob explicitly zeroed (`guidance_scale=1.0`, `stg_scale=0.0`, `modality_scale=1.0`, `guidance_rescale=0.0`). The merged pipeline's defaults are SFT values and each adds a blended extra transformer pass that badly degrades distilled output |
67
+ | `LTXVAudioVAEDecode` | audio is decoded and muxed into the mp4 |
68
+
69
+ ## Deviations
70
+
71
+ * **Resolution.** The workflow renders 1664×960 × 361 frames (15 s). The default here is
72
+ 1280×704 × 97 frames (4 s) so a run fits comfortably in a visitor's ZeroGPU quota; both the
73
+ workflow geometry and 8 s are selectable under **Advanced**.
74
+ * **Tiled VAE decode.** The final decode is tiled (untiled it wants >100 GB). Reference
75
+ encoding is *not* tiled, matching the node's `use_tiled_encode=False`, with a tiled fallback
76
+ only if it would OOM.
77
+ * `use_keyframes_abs_pos_embedding` is `True` in the transformer config, and ComfyUI applies
78
+ that marker to first-frame tokens. `LTX2InContextPipeline` never passes
79
+ `video_keyframes_mask`, so it is skipped — but the parameter is zero-initialised in the base
80
+ checkpoint and the LoRA does not train it, so the difference is exactly zero.
81
+
82
+ ## Examples
83
+
84
+ The three examples are the author's own validation references and prompts, taken verbatim from
85
+ [`validition_V1/`](https://huggingface.co/LiconStudio/LTX-2.5-Multiple-Subject-Reference/tree/main/validition_V1)
86
+ in the model repo (`03`, `06`, `07` — the three shown on the model card), downscaled to 1280 px
87
+ and re-encoded as JPEG. The model repo is apache-2.0.
88
+
89
+ ## Licenses
90
+
91
+ Adapter: apache-2.0. Base model: the
92
+ [LTX-2 Community License](https://huggingface.co/Lightricks/LTX-2.5).
app.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LTX-2.5 · Licon MSR V1 — multi-subject reference-to-video on ZeroGPU.
2
+
3
+ Up to five reference stills (four subject slots + one scene slot) plus a prompt that names
4
+ them ("Image 1: …", "Image 2: …") produce a video with native audio, keeping each subject's
5
+ identity, clothing and the scene's look.
6
+
7
+ The author's reference implementation is a ComfyUI graph
8
+ (https://github.com/liconstudio/ComfyUI-LTX2.5-MSR + `LTX2.5-MSR-sample-workflow.json`);
9
+ `msr.py` is a 1:1 port of the custom node's encoder onto `LTX2InContextPipeline`.
10
+ """
11
+
12
+ import os
13
+
14
+ os.environ.setdefault("PYTORCH_ALLOC_CONF", "backend:cudaMallocAsync")
15
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "backend:cudaMallocAsync")
16
+
17
+ import spaces # noqa: E402 — must precede torch / any CUDA-touching import
18
+
19
+ import random # noqa: E402
20
+ import tempfile # noqa: E402
21
+ import time # noqa: E402
22
+
23
+ import gradio as gr # noqa: E402
24
+ import numpy as np # noqa: E402
25
+ import PIL.Image # noqa: E402
26
+ import torch # noqa: E402
27
+ from huggingface_hub import hf_hub_download, snapshot_download # noqa: E402
28
+ from safetensors.torch import load_file # noqa: E402
29
+
30
+ from diffusers import LTX2LatentUpsamplePipeline # noqa: E402
31
+ from diffusers.pipelines.ltx2.latent_upsampler import LTX2LatentUpsamplerModel # noqa: E402
32
+ from diffusers.pipelines.ltx2.utils import ( # noqa: E402
33
+ DISTILLED_SIGMA_VALUES,
34
+ STAGE_2_DISTILLED_SIGMA_VALUES,
35
+ )
36
+ from diffusers.utils import encode_video # noqa: E402
37
+
38
+ from msr import LTX25MSRPipeline, build_references, split_slot_state # noqa: E402
39
+
40
+ HF_TOKEN = os.environ.get("HF_TOKEN")
41
+
42
+ BASE_ID = "Lightricks/LTX-2.5-Diffusers"
43
+ LORA_ID = "LiconStudio/LTX-2.5-Multiple-Subject-Reference"
44
+ LORA_FILE = "LTX-2.5-Licon-MSR-V1.safetensors"
45
+
46
+ FRAME_RATE = 24.0
47
+ MAX_SEED = 2**31 - 1
48
+ MAX_TEXT_TOKENS = 1024
49
+
50
+ # The workflow's two ManualSigmas nodes, which are exactly diffusers' distilled schedules.
51
+ STAGE_1_SIGMAS = DISTILLED_SIGMA_VALUES
52
+ STAGE_2_SIGMAS = STAGE_2_DISTILLED_SIGMA_VALUES
53
+
54
+ # Stage 1 runs at half of each of these, then a x2 latent upsample feeds stage 2.
55
+ RESOLUTIONS = {
56
+ "1280 × 704 · 16:9": (1280, 704),
57
+ "1664 × 960 · 16:9 (workflow, slower)": (1664, 960),
58
+ "704 × 1280 · 9:16 portrait": (704, 1280),
59
+ "960 × 960 · 1:1": (960, 960),
60
+ }
61
+ DEFAULT_RESOLUTION = "1280 × 704 · 16:9"
62
+ DEFAULT_SECONDS = 4.0
63
+ DEFAULT_REFERENCE_FRAMES = 33 # the workflow's `reference_frames`
64
+
65
+ print("[msr] downloading LTX-2.5 (diffusers)...", flush=True)
66
+ # `from_pretrained` derives allow-patterns from every model-like file in the repo, so it would
67
+ # pull both DiTs and both shardings of the distilled one (~114 GB). Snapshot explicitly instead;
68
+ # prompt_enhancer / diffusion_decoder / temporal_latent_upsampler are not components of the
69
+ # in-context pipeline and are pure download + VRAM waste here.
70
+ MODEL_DIR = snapshot_download(
71
+ BASE_ID,
72
+ ignore_patterns=[
73
+ "transformer_full/*",
74
+ "transformer/*-of-00008*",
75
+ "prompt_enhancer/*",
76
+ "diffusion_decoder/*",
77
+ "temporal_latent_upsampler/*",
78
+ ],
79
+ token=HF_TOKEN,
80
+ max_workers=8,
81
+ )
82
+ LORA_PATH = hf_hub_download(LORA_ID, LORA_FILE, token=HF_TOKEN)
83
+
84
+ print("[msr] building pipeline...", flush=True)
85
+ pipe = LTX25MSRPipeline.from_pretrained(
86
+ MODEL_DIR, prompt_enhancer=None, processor=None, dtype=torch.bfloat16
87
+ )
88
+ latent_upsampler = LTX2LatentUpsamplerModel.from_pretrained(
89
+ MODEL_DIR, subfolder="latent_upsampler", dtype=torch.bfloat16
90
+ )
91
+
92
+ # The checkpoint carries two different things under one `diffusion_model.` prefix: rank-128 LoRA
93
+ # tensors (diffusers module paths, so a prefix swap is the whole conversion) and the learned
94
+ # `reference_slot_embedding` MLP, which is not a LoRA and is applied by hand in `msr.py`.
95
+ _raw = load_file(LORA_PATH)
96
+ _slot_state, _lora_raw = split_slot_state(_raw)
97
+ _lora_sd = {k.replace("diffusion_model.", "transformer.", 1): v for k, v in _lora_raw.items()}
98
+ del _raw, _lora_raw
99
+ pipe.load_lora_weights(_lora_sd, adapter_name="msr")
100
+ pipe.set_adapters(["msr"], [1.0])
101
+ del _lora_sd
102
+ pipe.msr_slot_state = _slot_state
103
+ print(
104
+ f"[msr] LoRA applied (strength 1.0); slot-embedding keys: {sorted(_slot_state)}",
105
+ flush=True,
106
+ )
107
+
108
+ # ZeroGPU: module scope + eager .to("cuda"). The backend packs the weights to disk at startup
109
+ # and streams them into VRAM on the first @spaces.GPU entry.
110
+ pipe.to("cuda")
111
+ latent_upsampler.to("cuda")
112
+ pipe.vae.enable_tiling() # stage-2 decode alone would want >100 GB untiled
113
+
114
+ upsample_pipe = LTX2LatentUpsamplePipeline(vae=pipe.vae, latent_upsampler=latent_upsampler)
115
+ AUDIO_SR = pipe.vocoder.config.output_sampling_rate
116
+ print("[msr] ready", flush=True)
117
+
118
+
119
+ # ------------------------------------------------------------------------------------------
120
+ # helpers
121
+ # ------------------------------------------------------------------------------------------
122
+ def _plan(resolution: str, seconds: float, reference_frames: int, num_refs: int):
123
+ """Resolve UI values into geometry plus a stage-2 token count (the cost driver)."""
124
+ width, height = RESOLUTIONS.get(resolution, RESOLUTIONS[DEFAULT_RESOLUTION])
125
+ num_frames = max(25, int(round(float(seconds) * FRAME_RATE)) // 8 * 8 + 1)
126
+ ref_keep = max(1, ((int(reference_frames) - 1) // 8) * 8 + 1)
127
+ latent_frames = (num_frames - 1) // 8 + 1
128
+ ref_latent_frames = (ref_keep - 1) // 8 + 1
129
+ tokens = (latent_frames + num_refs * ref_latent_frames) * (height // 32) * (width // 32)
130
+ return width, height, num_frames, ref_keep, tokens
131
+
132
+
133
+ def _count_refs(*images) -> int:
134
+ return max(1, sum(1 for image in images if image is not None))
135
+
136
+
137
+ def gpu_duration(
138
+ prompt=None,
139
+ subject_1=None,
140
+ subject_2=None,
141
+ scene_image=None,
142
+ subject_3=None,
143
+ subject_4=None,
144
+ resolution=DEFAULT_RESOLUTION,
145
+ seconds=DEFAULT_SECONDS,
146
+ reference_frames=DEFAULT_REFERENCE_FRAMES,
147
+ *_args,
148
+ **_kwargs,
149
+ ):
150
+ """Scale the ZeroGPU reservation with the stage-2 sequence length.
151
+
152
+ Sized from the measured 1280x704 / 97-frame / 3-reference run and extrapolated with an
153
+ n^1.4 attention term. Kept as tight as the measurement allows: `duration` is charged
154
+ against every visitor's daily quota.
155
+ """
156
+ num_refs = _count_refs(subject_1, subject_2, subject_3, subject_4, scene_image)
157
+ *_geom, tokens = _plan(resolution, seconds, reference_frames, num_refs)
158
+ return int(min(400, 60 + 130 * (tokens / 24640.0) ** 1.4))
159
+
160
+
161
+ def _write_video(frames, path: str, audio=None) -> None:
162
+ frames = np.asarray(frames)
163
+ if frames.dtype == np.uint8:
164
+ frames = frames.astype(np.float32) / 255.0
165
+ kwargs = {}
166
+ if audio is not None:
167
+ kwargs = dict(audio=audio, audio_sample_rate=AUDIO_SR)
168
+ encode_video(frames, fps=FRAME_RATE, output_path=path, **kwargs)
169
+
170
+
171
+ def _as_pil(image):
172
+ if image is None:
173
+ return None
174
+ if isinstance(image, PIL.Image.Image):
175
+ return image.convert("RGB")
176
+ if isinstance(image, np.ndarray):
177
+ return PIL.Image.fromarray(image.astype(np.uint8)).convert("RGB")
178
+ return PIL.Image.open(image).convert("RGB")
179
+
180
+
181
+ # ------------------------------------------------------------------------------------------
182
+ # generation
183
+ # ------------------------------------------------------------------------------------------
184
+ @spaces.GPU(duration=gpu_duration, size="xlarge")
185
+ def generate(
186
+ prompt: str,
187
+ subject_1,
188
+ subject_2,
189
+ scene_image,
190
+ subject_3=None,
191
+ subject_4=None,
192
+ resolution: str = DEFAULT_RESOLUTION,
193
+ seconds: float = DEFAULT_SECONDS,
194
+ reference_frames: int = DEFAULT_REFERENCE_FRAMES,
195
+ seed: int = 42,
196
+ randomize_seed: bool = True,
197
+ ):
198
+ """Generate a video with audio whose subjects and scene come from the reference stills.
199
+
200
+ Args:
201
+ prompt: describe each reference on its own line ("Image 1: …", "Image 2: …",
202
+ "Image 3: Scene, …"), then the shot itself — action, camera moves, dialogue.
203
+ subject_1: reference still for subject slot 1. Required.
204
+ subject_2: reference still for subject slot 2, or empty.
205
+ scene_image: reference still for the scene / background slot, or empty. Unlike the
206
+ subject slots it is centre-cropped rather than letterboxed.
207
+ subject_3: reference still for subject slot 3, or empty.
208
+ subject_4: reference still for subject slot 4, or empty.
209
+ resolution: final output resolution; stage 1 samples at half of it.
210
+ seconds: video length in seconds at 24 fps.
211
+ reference_frames: how many frames each still is repeated to before VAE encoding
212
+ (the ComfyUI node's `reference_frames`; 33 is the workflow value).
213
+ seed: RNG seed.
214
+ randomize_seed: pick a fresh random seed instead of using `seed`.
215
+
216
+ Returns:
217
+ The mp4 path and a one-line run summary.
218
+ """
219
+ started = time.perf_counter()
220
+
221
+ if not (prompt or "").strip():
222
+ raise gr.Error("A prompt is required.")
223
+ if subject_1 is None:
224
+ raise gr.Error("At least one reference image is required (Subject 1).")
225
+
226
+ # The node collects pic1..pic4 then background, skipping empties, so the scene slot always
227
+ # takes the last slot id and each slot lands at pixel-frame -(num_slots - index).
228
+ subjects = [_as_pil(x) for x in (subject_1, subject_2, subject_3, subject_4)]
229
+ subjects = [x for x in subjects if x is not None]
230
+ scene = _as_pil(scene_image)
231
+ images = subjects + ([scene] if scene is not None else [])
232
+ flags = [False] * len(subjects) + ([True] if scene is not None else [])
233
+ references = build_references(images, flags)
234
+
235
+ width, height, num_frames, ref_keep, tokens = _plan(
236
+ resolution, seconds, reference_frames, len(references)
237
+ )
238
+ pipe.msr_reference_frames = ref_keep
239
+
240
+ if randomize_seed:
241
+ seed = random.randint(0, MAX_SEED)
242
+ generator = torch.Generator("cuda").manual_seed(int(seed))
243
+
244
+ # One Gemma pass, reused by both stages (the 12B encoder is far too expensive to run twice).
245
+ prompt_embeds, prompt_mask, _, _ = pipe.encode_prompt(
246
+ prompt=[prompt],
247
+ negative_prompt=None,
248
+ do_classifier_free_guidance=False,
249
+ num_videos_per_prompt=1,
250
+ max_sequence_length=MAX_TEXT_TOKENS,
251
+ device=pipe._execution_device,
252
+ )
253
+
254
+ shared = dict(
255
+ prompt=None,
256
+ prompt_embeds=prompt_embeds,
257
+ prompt_attention_mask=prompt_mask,
258
+ negative_prompt=None,
259
+ frame_rate=FRAME_RATE,
260
+ # Distilled checkpoint (SimpleDenoiser): every guidance knob is off, matching the
261
+ # workflow's cfg=1 CFGGuider / DualCFGGuider [1, 1]. The merged pipeline's defaults are
262
+ # SFT values and each one adds a blended extra transformer pass that wrecks the output.
263
+ guidance_scale=1.0,
264
+ audio_guidance_scale=1.0,
265
+ stg_scale=0.0,
266
+ audio_stg_scale=0.0,
267
+ modality_scale=1.0,
268
+ audio_modality_scale=1.0,
269
+ guidance_rescale=0.0,
270
+ audio_guidance_rescale=0.0,
271
+ spatio_temporal_guidance_blocks=None,
272
+ reference_downscale_factor=1,
273
+ conditioning_attention_strength=1.0,
274
+ generator=generator,
275
+ return_dict=False,
276
+ )
277
+
278
+ print(
279
+ f"[gen] {len(references)} slot(s) · {width}x{height} · {num_frames}f · "
280
+ f"ref {ref_keep}f · ~{tokens} stage-2 tokens · seed {seed}",
281
+ flush=True,
282
+ )
283
+
284
+ # ---- stage 1: half resolution, 8 distilled sigmas, MSR reference tokens attached ----
285
+ stage_1 = time.perf_counter()
286
+ s1_latents, s1_audio = pipe(
287
+ reference_conditions=references,
288
+ height=height // 2,
289
+ width=width // 2,
290
+ num_frames=num_frames,
291
+ sigmas=STAGE_1_SIGMAS,
292
+ output_type="latent",
293
+ **shared,
294
+ )
295
+ print(f"[gen] stage 1 in {time.perf_counter() - stage_1:.1f}s", flush=True)
296
+
297
+ # ---- x2 spatial latent upsample. `output_type="latent"` has already cropped the
298
+ # reference tokens off, which is what the graph's LTXVCropGuides does. ----
299
+ stage_up = time.perf_counter()
300
+ up_latents = upsample_pipe(latents=s1_latents, output_type="latent", return_dict=False)[0]
301
+ print(f"[gen] upsample in {time.perf_counter() - stage_up:.1f}s", flush=True)
302
+
303
+ # ---- stage 2: full resolution, 3 sigmas. The workflow re-attaches the MSR guide here,
304
+ # so the reference tokens are encoded again at the larger size. ----
305
+ stage_2 = time.perf_counter()
306
+ video, audio = pipe(
307
+ reference_conditions=references,
308
+ height=height,
309
+ width=width,
310
+ num_frames=num_frames,
311
+ sigmas=STAGE_2_SIGMAS,
312
+ latents=up_latents,
313
+ audio_latents=s1_audio,
314
+ noise_scale=STAGE_2_SIGMAS[0],
315
+ output_type="np",
316
+ **shared,
317
+ )
318
+ print(f"[gen] stage 2 in {time.perf_counter() - stage_2:.1f}s", flush=True)
319
+
320
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle:
321
+ out_path = handle.name
322
+ _write_video(video[0], out_path, audio=audio[0].float().cpu() if audio is not None else None)
323
+
324
+ elapsed = time.perf_counter() - started
325
+ print(f"[gen] done in {elapsed:.1f}s", flush=True)
326
+ return out_path, (
327
+ f"{len(references)} reference slot(s) · {width}×{height} · "
328
+ f"{num_frames} frames ({num_frames / FRAME_RATE:.1f}s) · seed {seed} · {elapsed:.0f}s"
329
+ )
330
+
331
+
332
+ # ------------------------------------------------------------------------------------------
333
+ # examples (the author's own validation set, apache-2.0, from the model repo)
334
+ # ------------------------------------------------------------------------------------------
335
+ EXAMPLES = [
336
+ [
337
+ "Image 1: Beast-girl, fluffy orange cat ears and a long tail, big amber eyes, fluffy "
338
+ "orange short hair, cream-white puffy dress with a bow, bare legs and small boots, "
339
+ "Pixar-style 3D cartoon rendering, photorealistic render quality.\n"
340
+ "Image 2: Elf girl, long pointed ears, silver-white long hair, big emerald eyes, "
341
+ "leaf-green robe with a vine belt, barefoot, Pixar-style 3D cartoon rendering, "
342
+ "photorealistic render quality.\n"
343
+ "Image 3: Scene, enchanted forest clearing, foreground glowing mushrooms and "
344
+ "wildflowers, midground the two figures, background ancient trees with light shafts "
345
+ "through the leaves, warm dappled golden light, fireflies drifting, Pixar-style 3D "
346
+ "cartoon rendering, photorealistic render quality.\n\n"
347
+ "A clearing in an enchanted forest, warm dappled golden light falling through the "
348
+ "ancient canopy, fireflies drifting in the light shafts, glowing mushrooms and "
349
+ "wildflowers dotting the foreground. Figure 1 is the beast-girl, Figure 2 is the elf "
350
+ "girl. The two stand in the clearing, Pixar-style 3D cartoon rendering. The shot "
351
+ "starts on a medium two-shot, slow dolly push-in about 40%. Figure 1 curiously leans "
352
+ "in toward Figure 2, looking at her pointed ears, and asks: \"How are your ears so "
353
+ "pointy?\" Figure 2 smiles and answers: \"These are elf ears.\" Figure 1 reaches up to "
354
+ "touch her own fluffy orange cat ears and says happily: \"Then mine are fluffy!\" No "
355
+ "cut throughout, with birdsong and wind rustling through leaves underneath, their hair "
356
+ "and hems swaying lightly in the breeze, the beast-girl's tail flicking.",
357
+ "examples/forest_1.jpg",
358
+ "examples/forest_2.jpg",
359
+ "examples/forest_3.jpg",
360
+ ],
361
+ [
362
+ "Image 1: Man, black buzz cut with shaved lines on both sides, hard jawline, black "
363
+ "techwear parka with reflective strips, dark grey cargo pants, black tactical boots, "
364
+ "photorealistic natural texture.\n"
365
+ "Image 2: Woman, neck-length silver-grey gradient bob, cold-blue contact lenses and "
366
+ "ear-clip cuffs, translucent PVC long coat over a liquid-silver bodysuit, black "
367
+ "wide-leg pants, silver platform boots, photorealistic natural texture.\n"
368
+ "Image 3: Scene, late-night cyberpunk club booth, foreground bottles and neon, "
369
+ "midground leather booth, background dancefloor strobe light, cyan-blue key light, "
370
+ "magenta fill light, low-hanging smoke, low-frequency beat, photorealistic natural "
371
+ "texture.\n\n"
372
+ "Inside a late-night cyberpunk club booth, cyan-blue key light pressed low, magenta "
373
+ "fill light tracing the leather booth's silhouette, the dancefloor strobe flashing in "
374
+ "the distance, smoke hanging low, a low-frequency beat running continuously. Image 1 "
375
+ "in the black techwear parka sits in the booth, a bottle beside him, head down and "
376
+ "spaced out. Image 2 in the translucent PVC long coat walks to Image 1's side and sits "
377
+ "down, naturally leaning on his shoulder, her silver-grey bob brushing against his "
378
+ "parka. Image 1 turns his head to look at her, she lifts her eyes back to him, the two "
379
+ "holding still for one second. The shot starts on a side medium of Image 1, slow dolly "
380
+ "push-in about 40% as Image 2 approaches, then a small slow orbit around the two. "
381
+ "Photorealistic natural texture.",
382
+ "examples/cyberpunk_1.jpg",
383
+ "examples/cyberpunk_2.jpg",
384
+ "examples/cyberpunk_3.jpg",
385
+ ],
386
+ [
387
+ "Image 1: East Asian, 30 years old, man, very short buzz cut, thick dark straight "
388
+ "eyebrows, hard jawline, sharp gaze, black high-collar technical windbreaker with "
389
+ "silver-grey reflective strips and zippered pockets across the chest and upper arms.\n"
390
+ "Image 2: East Asian, 20 years old, woman, neck-length wavy bob with wispy bangs, "
391
+ "bright red satin headband, pearl stud earrings.\n"
392
+ "Image 3: Scene, late-night apartment entryway narrow hallway, foreground a black "
393
+ "metal coat rack and black curved-handle umbrellas leaning against the wall, midground "
394
+ "the two facing off, background a half-open white door leading into a brighter room, "
395
+ "light-grey walls and matte grey square-tile floor, cool-white overhead light with "
396
+ "cold-blue light seeping through the door, sharp high contrast, oppressive standoff "
397
+ "mood, photorealistic natural texture.\n\n"
398
+ "Late-night apartment entryway narrow hallway, cool-white overhead light, a half-open "
399
+ "white door leading into a brighter room, cold-blue light seeping through the door, "
400
+ "sharp high contrast no bloom; camera axis slightly diagonal, fixed from the door "
401
+ "toward the interior. Figure 1 stands upper-right near the door facing lower-left, "
402
+ "Figure 2 stands lower-left in midground facing upper-right, one step apart. Figure "
403
+ "1's shoulders are tense, half-turned toward the door; Figure 2 blocks the hallway "
404
+ "with reddened eyes under her red headband. Diagonal composition, locked camera, slow "
405
+ "push-in about 25%, Figure 2's lashes trembling, Figure 1's jaw muscles tightening, "
406
+ "the two's gazes locked, the overhead light humming low, the brighter room beyond in "
407
+ "silence. Photorealistic natural texture.",
408
+ "examples/hallway_1.jpg",
409
+ "examples/hallway_2.jpg",
410
+ "examples/hallway_3.jpg",
411
+ ],
412
+ ]
413
+
414
+
415
+ CSS = """
416
+ .main.fillable { max-width: 1280px !important; }
417
+ .dark .gradio-container { color: var(--body-text-color); }
418
+ """
419
+
420
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="LTX-2.5 Multi-Subject Reference") as demo:
421
+ gr.Markdown(
422
+ "# 🎭 LTX-2.5 · Multiple Subject Reference\n"
423
+ "Give up to **four subject stills plus one scene still**, name them in the prompt "
424
+ "(`Image 1:`, `Image 2:`, `Image 3: Scene, …`), and get a video with native audio that "
425
+ "keeps every subject's identity, clothing and the scene's look.\n\n"
426
+ "[`LiconStudio/LTX-2.5-Multiple-Subject-Reference`](https://huggingface.co/LiconStudio/LTX-2.5-Multiple-Subject-Reference)"
427
+ " on [`Lightricks/LTX-2.5`](https://huggingface.co/Lightricks/LTX-2.5) (22B, distilled)."
428
+ )
429
+
430
+ with gr.Row():
431
+ with gr.Column(scale=1):
432
+ prompt = gr.Textbox(
433
+ label="Prompt",
434
+ lines=10,
435
+ max_lines=22,
436
+ placeholder=(
437
+ "Image 1: <who / what slot 1 is — build, hair, clothes>\n"
438
+ "Image 2: <who / what slot 2 is>\n"
439
+ "Image 3: Scene, <where it happens, lighting, mood>\n\n"
440
+ "<the shot itself: what they do, camera moves, dialogue, sound>"
441
+ ),
442
+ )
443
+ with gr.Row():
444
+ subject_1 = gr.Image(label="Subject 1", type="pil", height=190)
445
+ subject_2 = gr.Image(label="Subject 2 (optional)", type="pil", height=190)
446
+ scene_image = gr.Image(label="Scene (optional)", type="pil", height=190)
447
+
448
+ with gr.Accordion("More subject slots", open=False):
449
+ with gr.Row():
450
+ subject_3 = gr.Image(label="Subject 3 (optional)", type="pil", height=190)
451
+ subject_4 = gr.Image(label="Subject 4 (optional)", type="pil", height=190)
452
+
453
+ with gr.Accordion("Advanced", open=False):
454
+ resolution = gr.Dropdown(
455
+ list(RESOLUTIONS),
456
+ value=DEFAULT_RESOLUTION,
457
+ label="Resolution",
458
+ info="Stage 1 samples at half of this, then a ×2 latent upsample.",
459
+ )
460
+ seconds = gr.Slider(
461
+ 1.0, 8.0, value=DEFAULT_SECONDS, step=0.5, label="Length (seconds @ 24 fps)"
462
+ )
463
+ reference_frames = gr.Slider(
464
+ 9,
465
+ 33,
466
+ value=DEFAULT_REFERENCE_FRAMES,
467
+ step=8,
468
+ label="Reference frames per slot",
469
+ info="How many frames each still is repeated to before VAE encoding. "
470
+ "33 is the author's workflow value; lower is faster and weaker.",
471
+ )
472
+ with gr.Row():
473
+ seed = gr.Slider(0, MAX_SEED, value=42, step=1, label="Seed")
474
+ randomize_seed = gr.Checkbox(value=True, label="Randomize seed")
475
+
476
+ run_button = gr.Button("Generate", variant="primary")
477
+
478
+ with gr.Column(scale=1):
479
+ video_out = gr.Video(label="Result", autoplay=True, height=430)
480
+ info_out = gr.Textbox(label="Run", lines=2, interactive=False)
481
+
482
+ inputs = [
483
+ prompt,
484
+ subject_1,
485
+ subject_2,
486
+ scene_image,
487
+ subject_3,
488
+ subject_4,
489
+ resolution,
490
+ seconds,
491
+ reference_frames,
492
+ seed,
493
+ randomize_seed,
494
+ ]
495
+ outputs = [video_out, info_out]
496
+
497
+ run_button.click(fn=generate, inputs=inputs, outputs=outputs)
498
+
499
+ gr.Examples(
500
+ examples=EXAMPLES,
501
+ inputs=[prompt, subject_1, subject_2, scene_image],
502
+ outputs=outputs,
503
+ fn=generate,
504
+ cache_examples=True,
505
+ cache_mode="lazy",
506
+ label="The author's own validation references and prompts",
507
+ )
508
+
509
+ gr.Markdown(
510
+ "### Prompting\n"
511
+ "* Describe **every** reference on its own line, in slot order, using the same labels "
512
+ "(`Image 1`, `Image 2`, …). The scene still is always the last slot.\n"
513
+ "* Then state who does what, where they stand relative to each other, camera moves and "
514
+ "any dialogue — LTX-2.5 generates the audio too.\n"
515
+ "* Subject stills are letterboxed onto white; the scene still is centre-cropped. "
516
+ "Character-sheet style references (front / three-quarter / back on a plain background) "
517
+ "work best — that is what the LoRA was validated on.\n\n"
518
+ "Reference images and prompts are the author's own validation set from the model repo "
519
+ "(apache-2.0)."
520
+ )
521
+
522
+ demo.queue().launch(mcp_server=True, show_error=True)
examples/cyberpunk_1.jpg ADDED

Git LFS Details

  • SHA256: 9ae97615f41b88d0cc651220193427e92426ba88ff6e8bcbd40ee0ad4916b7aa
  • Pointer size: 131 Bytes
  • Size of remote file: 153 kB
examples/cyberpunk_2.jpg ADDED

Git LFS Details

  • SHA256: 182e20ff60019db1c46c21ca164b490d3103035df618bd048bfa795d5e62ed13
  • Pointer size: 131 Bytes
  • Size of remote file: 179 kB
examples/cyberpunk_3.jpg ADDED

Git LFS Details

  • SHA256: fe9c17e7dbe445e62fb69ce671cd7482f8371bbaa557126654aa460b6fed6136
  • Pointer size: 131 Bytes
  • Size of remote file: 200 kB
examples/forest_1.jpg ADDED

Git LFS Details

  • SHA256: bf5880803b16886762327afc3c43d7780010ebc4062c67de646b3649ed782847
  • Pointer size: 131 Bytes
  • Size of remote file: 170 kB
examples/forest_2.jpg ADDED

Git LFS Details

  • SHA256: eb80509eb2c633524e6a47e34b5ebe142d1bdd1b5dad3c0066c07137af47dba4
  • Pointer size: 131 Bytes
  • Size of remote file: 164 kB
examples/forest_3.jpg ADDED

Git LFS Details

  • SHA256: 8ad8d3b657d1a3609490a4d049ad3ea82d2e2e497f25e364b7f34f625c902c39
  • Pointer size: 131 Bytes
  • Size of remote file: 379 kB
examples/hallway_1.jpg ADDED

Git LFS Details

  • SHA256: 9ae97615f41b88d0cc651220193427e92426ba88ff6e8bcbd40ee0ad4916b7aa
  • Pointer size: 131 Bytes
  • Size of remote file: 153 kB
examples/hallway_2.jpg ADDED

Git LFS Details

  • SHA256: 9405ddfe86c1936f7a7bb5e85d78156cb9059767f747121b658f3ede02af0b88
  • Pointer size: 131 Bytes
  • Size of remote file: 168 kB
examples/hallway_3.jpg ADDED

Git LFS Details

  • SHA256: 3fb8c74289db3ad1e51f788863aef5b54c93a125be2de6128395076e39db5d92
  • Pointer size: 131 Bytes
  • Size of remote file: 109 kB
msr.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LTX-2.5 Multiple-Subject-Reference (MSR) — a diffusers port of the author's ComfyUI nodes.
2
+
3
+ Reference implementation: https://github.com/liconstudio/ComfyUI-LTX2.5-MSR (`nodes.py`).
4
+ Everything the custom node does that plain IC-LoRA conditioning does *not* lives here:
5
+
6
+ 1. `msr_resize` — the node's own reference-image fitting rule. Subject slots are letterboxed
7
+ onto a **white** canvas unless the source already covers the target in the same aspect
8
+ family (then it is centre-crop-resized); the background slot is always centre-crop-resized.
9
+ 2. `slot_embedding` — the LoRA's learned Fourier-MLP slot code, added broadcast over the
10
+ channel axis of the *normalized* reference latent. This is what tells the transformer
11
+ "these tokens are subject #2".
12
+ 3. Negative temporal offsets — reference tokens are placed *before* frame 0 on the RoPE time
13
+ axis at `-(num_slots - slot_index)` pixel-frames, i.e. each slot gets its own one-frame
14
+ slice of negative time. ComfyUI does `pixel_coords[:, 0] += frame_idx` before the model
15
+ divides the temporal axis by `frame_rate`; diffusers' `prepare_video_coords` already
16
+ divides, so the equivalent here is `+= frame_offset / frame_rate` afterwards.
17
+
18
+ Everything else the node relies on is already what `LTX2InContextPipeline` does: reference
19
+ tokens are appended at the END of the packed sequence with `conditioning_mask = strength`,
20
+ and `output_type="latent"` crops them back off (ComfyUI's `LTXVCropGuides`). With
21
+ `strength=1.0` and no pixel mask, ComfyUI's `_build_guide_self_attention_mask` returns
22
+ `None` and diffusers builds no mask either — the two stacks agree.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+
29
+ import numpy as np
30
+ import PIL.Image
31
+ import torch
32
+ import torch.nn.functional as F
33
+
34
+ from diffusers.pipelines.ltx2.pipeline_ltx2_ic_lora import (
35
+ LTX2InContextPipeline,
36
+ LTX2ReferenceCondition,
37
+ retrieve_latents,
38
+ )
39
+
40
+ SLOT_PREFIXES = ("diffusion_model.reference_slot_embedding.", "reference_slot_embedding.")
41
+
42
+ # The node hard-requires these two metadata values; anything else means a different token
43
+ # layout than the one implemented below.
44
+ EXPECTED_METADATA = {
45
+ "reference_token_order": "prepend",
46
+ "reference_slot_time_offsets": "pic1_based_negative_time",
47
+ }
48
+
49
+
50
+ # ------------------------------------------------------------------------------------------
51
+ # LoRA state-dict splitting
52
+ # ------------------------------------------------------------------------------------------
53
+ def split_slot_state(state_dict: dict) -> tuple[dict, dict]:
54
+ """Separate the `reference_slot_embedding.*` weights (not a LoRA) from the LoRA tensors."""
55
+ slot: dict[str, torch.Tensor] = {}
56
+ rest: dict[str, torch.Tensor] = {}
57
+ for key, value in state_dict.items():
58
+ for prefix in SLOT_PREFIXES:
59
+ if key.startswith(prefix):
60
+ slot[key[len(prefix) :]] = value
61
+ break
62
+ else:
63
+ rest[key] = value
64
+ return slot, rest
65
+
66
+
67
+ def slot_embedding(
68
+ slot_id: int, state: dict, device: torch.device, dtype: torch.dtype
69
+ ) -> torch.Tensor:
70
+ """1:1 port of `ComfyUI-LTX2.5-MSR/nodes.py::_slot_embedding`.
71
+
72
+ `slot_id / 16` is expanded into `[scaled, sin(scaled * f), cos(scaled * f)]` and pushed
73
+ through the LoRA's 2-layer SiLU MLP, giving a 128-dim per-slot code.
74
+ """
75
+ frequencies = state["frequencies"].to(device=device, dtype=torch.float32)
76
+ slot_value = torch.tensor(float(slot_id), device=device, dtype=torch.float32)
77
+ scaled = slot_value / 16.0
78
+ phases = scaled * frequencies
79
+ features = torch.cat((scaled.reshape(1), torch.sin(phases), torch.cos(phases)))
80
+ hidden = F.silu(
81
+ F.linear(
82
+ features,
83
+ state["net.0.weight"].to(device=device, dtype=torch.float32),
84
+ state["net.0.bias"].to(device=device, dtype=torch.float32),
85
+ )
86
+ )
87
+ embedding = F.linear(
88
+ hidden,
89
+ state["net.2.weight"].to(device=device, dtype=torch.float32),
90
+ state["net.2.bias"].to(device=device, dtype=torch.float32),
91
+ )
92
+ return embedding.to(dtype=dtype)
93
+
94
+
95
+ # ------------------------------------------------------------------------------------------
96
+ # reference-image fitting (ComfyUI `comfy.utils.common_upscale` semantics)
97
+ # ------------------------------------------------------------------------------------------
98
+ def _bilinear(x: torch.Tensor, height: int, width: int) -> torch.Tensor:
99
+ # ComfyUI uses plain `F.interpolate(..., mode="bilinear")` — no antialias, align_corners=False.
100
+ return F.interpolate(x, size=(height, width), mode="bilinear")
101
+
102
+
103
+ def _center_crop_resize(x: torch.Tensor, target_width: int, target_height: int) -> torch.Tensor:
104
+ """`common_upscale(..., crop="center")`: crop to the target aspect, then bilinear resize."""
105
+ old_height, old_width = x.shape[-2:]
106
+ old_aspect = old_width / old_height
107
+ new_aspect = target_width / target_height
108
+ x_off = y_off = 0
109
+ if old_aspect > new_aspect:
110
+ x_off = round((old_width - old_width * (new_aspect / old_aspect)) / 2)
111
+ elif old_aspect < new_aspect:
112
+ y_off = round((old_height - old_height * (old_aspect / new_aspect)) / 2)
113
+ cropped = x.narrow(-2, y_off, old_height - y_off * 2).narrow(-1, x_off, old_width - x_off * 2)
114
+ return _bilinear(cropped, target_height, target_width)
115
+
116
+
117
+ def _aspect_family(width: int, height: int) -> str:
118
+ ratio = width / height
119
+ if ratio >= 1.25:
120
+ return "landscape"
121
+ if ratio <= 0.8:
122
+ return "portrait"
123
+ return "square"
124
+
125
+
126
+ def msr_resize(
127
+ x: torch.Tensor, target_width: int, target_height: int, is_background: bool
128
+ ) -> torch.Tensor:
129
+ """1:1 port of `nodes.py::_resize_reference`. `x` is `[F, C, H, W]`, float in `[0, 1]`."""
130
+ if is_background:
131
+ return _center_crop_resize(x, target_width, target_height)
132
+
133
+ source_height, source_width = x.shape[-2:]
134
+ if source_width == target_width and source_height == target_height:
135
+ return x
136
+
137
+ same_family = _aspect_family(source_width, source_height) == _aspect_family(
138
+ target_width, target_height
139
+ )
140
+ source_is_smaller = source_width <= target_width and source_height <= target_height
141
+ if same_family and not source_is_smaller:
142
+ return _center_crop_resize(x, target_width, target_height)
143
+
144
+ # Letterbox onto a white canvas, preserving the source aspect ratio.
145
+ scale = min(target_width / source_width, target_height / source_height)
146
+ resized_width = max(1, min(target_width, round(source_width * scale)))
147
+ resized_height = max(1, min(target_height, round(source_height * scale)))
148
+ resized = _bilinear(x, resized_height, resized_width)
149
+ canvas = torch.ones(
150
+ (x.shape[0], x.shape[1], target_height, target_width), dtype=x.dtype, device=x.device
151
+ )
152
+ left = (target_width - resized_width) // 2
153
+ top = (target_height - resized_height) // 2
154
+ canvas[:, :, top : top + resized_height, left : left + resized_width] = resized
155
+ return canvas
156
+
157
+
158
+ def to_frames_tensor(image) -> torch.Tensor:
159
+ """Any PIL / ndarray / tensor still -> `[1, 3, H, W]` float32 in `[0, 1]`."""
160
+ if isinstance(image, PIL.Image.Image):
161
+ array = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
162
+ tensor = torch.from_numpy(array).permute(2, 0, 1)
163
+ elif isinstance(image, np.ndarray):
164
+ array = image.astype(np.float32)
165
+ if array.max() > 1.5:
166
+ array = array / 255.0
167
+ tensor = torch.from_numpy(array[..., :3]).permute(2, 0, 1)
168
+ elif isinstance(image, torch.Tensor):
169
+ tensor = image.float()
170
+ if tensor.ndim == 4:
171
+ tensor = tensor[0]
172
+ if tensor.shape[0] not in (1, 3): # HWC
173
+ tensor = tensor.permute(2, 0, 1)
174
+ tensor = tensor[:3]
175
+ else:
176
+ raise TypeError(f"Unsupported reference image type: {type(image)}")
177
+ return tensor.unsqueeze(0)
178
+
179
+
180
+ # ------------------------------------------------------------------------------------------
181
+ # pipeline
182
+ # ------------------------------------------------------------------------------------------
183
+ @dataclass
184
+ class MSRReference(LTX2ReferenceCondition):
185
+ """An MSR reference slot. `frames` holds a single still image (any PIL/np/tensor)."""
186
+
187
+ slot_id: int = 1
188
+ frame_offset: int = 0
189
+ is_background: bool = False
190
+
191
+
192
+ class LTX25MSRPipeline(LTX2InContextPipeline):
193
+ """`LTX2InContextPipeline` with the MSR reference encoder swapped in.
194
+
195
+ `_encode_reference_conditions` is the single override point: `prepare_latents` and
196
+ `__call__` already append the returned tokens/coords in the layout the LoRA expects.
197
+ """
198
+
199
+ msr_slot_state: dict | None = None
200
+ msr_reference_frames: int = 33
201
+ # The workflow runs the guide node with `use_tiled_encode=False`, so reference stills are
202
+ # encoded whole. Falls back to tiled encoding if that ever runs out of memory.
203
+ msr_tiled_encode: bool = False
204
+
205
+ def _encode_reference_pixels(self, pixels, generator):
206
+ previous = getattr(self.vae, "use_tiling", False)
207
+ self.vae.use_tiling = bool(self.msr_tiled_encode)
208
+ try:
209
+ return retrieve_latents(
210
+ self.vae.encode(pixels), generator=generator, sample_mode="argmax"
211
+ )
212
+ except torch.cuda.OutOfMemoryError:
213
+ torch.cuda.empty_cache()
214
+ self.vae.use_tiling = True
215
+ return retrieve_latents(
216
+ self.vae.encode(pixels), generator=generator, sample_mode="argmax"
217
+ )
218
+ finally:
219
+ self.vae.use_tiling = previous
220
+
221
+ def _encode_reference_conditions(
222
+ self,
223
+ reference_conditions,
224
+ height: int,
225
+ width: int,
226
+ num_frames: int,
227
+ reference_downscale_factor: int = 1,
228
+ frame_rate: float = 24.0,
229
+ conditioning_attention_strength: float = 1.0,
230
+ conditioning_attention_mask=None,
231
+ dtype=None,
232
+ device=None,
233
+ generator=None,
234
+ ):
235
+ ref_height = height // reference_downscale_factor
236
+ ref_width = width // reference_downscale_factor
237
+ mask_needed = conditioning_attention_strength < 1.0 or conditioning_attention_mask is not None
238
+
239
+ temporal_ratio = self.vae_temporal_compression_ratio
240
+ n_frames = int(self.msr_reference_frames)
241
+ # `nodes.py::_encode_reference`: trim to a VAE-legal frame count.
242
+ keep = ((n_frames - 1) // temporal_ratio) * temporal_ratio + 1
243
+
244
+ all_latents, all_coords, all_cross, token_counts = [], [], [], []
245
+
246
+ for ref in reference_conditions:
247
+ still = to_frames_tensor(ref.frames) # [1, 3, H, W] in [0, 1]
248
+ is_background = bool(getattr(ref, "is_background", False))
249
+ fitted = msr_resize(still, ref_width, ref_height, is_background)
250
+
251
+ # A still repeated to `keep` frames. Resizing once and repeating is identical to
252
+ # (and far cheaper than) the node's `image.repeat(reference_frames, ...)` first.
253
+ pixels = fitted.repeat(keep, 1, 1, 1) # [keep, 3, h, w]
254
+ pixels = pixels.permute(1, 0, 2, 3).unsqueeze(0) # [1, 3, keep, h, w]
255
+ # ComfyUI's VAE wrapper applies `image * 2 - 1` before `encode`.
256
+ pixels = (pixels * 2.0 - 1.0).to(device=device, dtype=self.vae.dtype)
257
+
258
+ latent = self._encode_reference_pixels(pixels, generator)
259
+ # ComfyUI's LTX2 VAE normalizes inside `encode` (`per_channel_statistics.normalize`),
260
+ # so the slot embedding must be added in this normalized space.
261
+ latent = self._normalize_latents(
262
+ latent, self.vae.latents_mean, self.vae.latents_std
263
+ ).to(device=device, dtype=dtype)
264
+
265
+ if self.msr_slot_state:
266
+ embedding = slot_embedding(
267
+ int(getattr(ref, "slot_id", 1)), self.msr_slot_state, latent.device, latent.dtype
268
+ )
269
+ latent = latent + embedding.view(1, -1, 1, 1, 1)
270
+
271
+ _, _, latent_frames, latent_height, latent_width = latent.shape
272
+ packed = self._pack_latents(
273
+ latent, self.transformer_spatial_patch_size, self.transformer_temporal_patch_size
274
+ )
275
+
276
+ coords = self.transformer.rope.prepare_video_coords(
277
+ batch_size=1,
278
+ num_frames=latent_frames,
279
+ height=latent_height,
280
+ width=latent_width,
281
+ device=device,
282
+ fps=frame_rate,
283
+ )
284
+ if reference_downscale_factor != 1:
285
+ coords[:, 1, :, :] = coords[:, 1, :, :] * reference_downscale_factor
286
+ coords[:, 2, :, :] = coords[:, 2, :, :] * reference_downscale_factor
287
+ # The MSR slot offset. ComfyUI adds it in pixel-frames before the model's `/ fps`;
288
+ # `prepare_video_coords` has already divided, hence `/ frame_rate` here.
289
+ offset = float(getattr(ref, "frame_offset", 0)) / float(frame_rate)
290
+ if offset:
291
+ coords[:, 0, :, :] = coords[:, 0, :, :] + offset
292
+
293
+ num_tokens = packed.shape[1]
294
+ all_latents.append(packed)
295
+ all_coords.append(coords)
296
+ token_counts.append(num_tokens)
297
+
298
+ if mask_needed:
299
+ if conditioning_attention_mask is not None:
300
+ cross = self._downsample_mask_to_latent(
301
+ mask=conditioning_attention_mask,
302
+ latent_num_frames=latent_frames,
303
+ latent_height=latent_height,
304
+ latent_width=latent_width,
305
+ ).to(device=device, dtype=torch.float32)
306
+ else:
307
+ cross = torch.ones((1, num_tokens), device=device, dtype=torch.float32)
308
+ all_cross.append(cross * conditioning_attention_strength)
309
+
310
+ reference_latents = torch.cat(all_latents, dim=1)
311
+ reference_coords = torch.cat(all_coords, dim=2)
312
+ reference_cross_mask = torch.cat(all_cross, dim=1) if mask_needed else None
313
+ return reference_latents, reference_coords, reference_cross_mask, token_counts
314
+
315
+
316
+ def build_references(images: list, backgrounds: list[bool]) -> list[MSRReference]:
317
+ """Assign slot ids and negative time offsets exactly as `nodes.py` does.
318
+
319
+ The node collects `pic1..pic4` then `background`, skipping empties, so the background —
320
+ when present — always takes the last slot. Slot `i` (0-based) is placed at pixel-frame
321
+ `-(num_slots - i)`, i.e. the slots occupy `[-N, -1]` in order.
322
+ """
323
+ num_slots = len(images)
324
+ return [
325
+ MSRReference(
326
+ frames=image,
327
+ strength=1.0,
328
+ slot_id=index + 1,
329
+ frame_offset=-(num_slots - index),
330
+ is_background=is_background,
331
+ )
332
+ for index, (image, is_background) in enumerate(zip(images, backgrounds))
333
+ ]
requirements.txt ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LTX-2.5 and `LTX2InContextPipeline` (the in-context reference conditioning this Space is
2
+ # built on) are not in a PyPI diffusers release yet. Pinned to a known-good main commit.
3
+ diffusers @ git+https://github.com/huggingface/diffusers@d57cecde92a6d396845ab35425aa27469dff8173
4
+
5
+ # gradio and spaces are deliberately absent: the platform preinstalls and pins them.
6
+ #
7
+ # huggingface_hub IS pinned, which is the exception rather than an oversight: diffusers main
8
+ # imports `get_cached_repo_tree` from it and the base image ships an older hub without it.
9
+ huggingface_hub>=1.23.0,<2.0
10
+ gradio_client
11
+
12
+ # 5.12.1 breaks at import against current `kernels`; 5.14.1 declares kernels>=0.15.2,<0.16 and
13
+ # imports cleanly. The Gemma4* classes LTX-2.5 needs live here.
14
+ transformers==5.14.1
15
+ kernels
16
+
17
+ accelerate
18
+ peft
19
+ safetensors
20
+ sentencepiece
21
+ protobuf
22
+ av
23
+ imageio
24
+ imageio-ffmpeg
25
+ Pillow
26
+ numpy
27
+ scipy
28
+
29
+ # Not preinstalled on the ZeroGPU image; torch itself is left unpinned so the runtime's build wins.
30
+ torchvision
31
+ torchaudio