[new-model] Add LTX-2.5 inference support (#1704)

Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aryan Kumar
2026-10-07 03:34:18 -07:00
committed by GitHub
co-authored by Aryan Kumar coderabbitai[bot] CodeRabbit SolitaryThinker Claude Opus 5.5
parent 4b3f99b224
commit 2452022f15
32 changed files with 2612 additions and 35 deletions
+1
View File
@@ -143,5 +143,6 @@ fastvideo/tests/ssim/.reference_videos_download.lock
*.nvimlog
.nvimlog
.python-version
/LTX-2-Reference/
scripts/benchmarks/minimax_h3_pro6000/headline_results/
fastvideo/tests/ssim/.reference_videos_download.lock
@@ -584,6 +584,7 @@ surfaces:
ltx2_stg_scale_audio: request.extensions.ltx2.stg_scale_audio
ltx2_stg_blocks_video: request.extensions.ltx2.stg_blocks_video
ltx2_stg_blocks_audio: request.extensions.ltx2.stg_blocks_audio
ltx2_use_ancestral_sampler: request.extensions.ltx2.use_ancestral_sampler
ltx2_images: request.extensions.ltx2.images
ltx2_image_crf: request.stage_overrides.refine.image_crf
ltx2_conditioning_latent_stage1: request.extensions.ltx2.conditioning_latent_stage1
+64
View File
@@ -0,0 +1,64 @@
# LTX-2.5 inference
FastVideo supports BF16 inference for the LTX-2.5 dev and distilled
transformers. Both text-to-video and image-to-video generate synchronized
video and audio. The native path supports sequence parallelism, component
offload, and `torch.compile`.
## Convert the official checkpoint
The gated [`Lightricks/LTX-2.5`](https://huggingface.co/Lightricks/LTX-2.5)
repository publishes separate transformer, packed Gemma 4, convolutional video
VAE, audio VAE/vocoder, and spatial upscaler files. Accept its license and
download those files, then convert them into one FastVideo model directory:
```bash
python scripts/checkpoint_conversion/convert_ltx2_weights.py \
--variant distilled \
--transformer-source /weights/ltx-2.5-22b-distilled-transformer-bf16.safetensors \
--text-encoder-source /weights/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors \
--vae-source /weights/ltx-2.5-video-vae-conv-bf16.safetensors \
--audio-vae-source /weights/ltx-2.5-audio-vae-bf16.safetensors \
--spatial-upscaler-source /weights/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors \
--output /models/LTX-2.5-Distilled-Diffusers
```
Use `--variant dev` and the dev transformer to convert the development model.
The converter emits a standard component directory and preserves LTX-2.5's
architecture metadata, packed tokenizer, joint audio components, and refine
configuration. The convolutional video VAE is the supported launch path.
## Generate video and audio
Run distilled text-to-audio-video across four GPUs with sequence parallelism:
```bash
python examples/inference/basic/basic_ltx2_5_t2av.py \
--model-path /models/LTX-2.5-Distilled-Diffusers \
--prompt "A jazz trio performs in a candlelit club, synchronized live sound" \
--num-gpus 4 \
--torch-compile
```
Condition on a first frame with the I2AV example:
```bash
python examples/inference/basic/basic_ltx2_5_i2av.py \
--model-path /models/LTX-2.5-Distilled-Diffusers \
--image /images/first-frame.png \
--prompt "The train pulls away from the platform as its horn sounds" \
--num-gpus 4 \
--torch-compile
```
Pass `--variant dev` with a converted dev directory to use the 30-step dev
guidance preset. Distilled inference uses the official eight-step ancestral
schedule and a three-step spatial refinement pass by default.
## Current scope
The initial inference path includes the native transformer, packed Gemma 4
text stack, convolutional video VAE, audio VAE/vocoder, dev guidance, and the
distilled ancestral sampler. DiffVAE/NATTEN, generated keyframes, temporal
upsampling, automatic duration selection, HDR, training, fine-tuning, and
quantized deployment are separate follow-up work.
+7
View File
@@ -66,6 +66,8 @@ column links a runnable script in `examples/inference/basic/` where one exists.
| longcat | `FastVideo/LongCat-Video-I2V-Diffusers` | I2V | [basic_longcat_i2v.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_longcat_i2v.py) |
| longcat | `FastVideo/LongCat-Video-VC-Diffusers` | — | [basic_longcat_vc.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_longcat_vc.py) |
| ltx2 | `FastVideo/LTX2-Distilled-Diffusers`<br>`FastVideo/LTX2.3-Distilled-Diffusers`<br>`FastVideo/LTX-2.3-Distilled-Diffusers` | T2V | [basic_ltx2_distilled.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2_distilled.py) |
| ltx2 | `FastVideo/LTX-2.5-Distilled-Diffusers` | T2V, I2V | [T2AV](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2_5_t2av.py)<br>[I2AV](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2_5_i2av.py) |
| ltx2 | `FastVideo/LTX-2.5-Dev-Diffusers` | T2V, I2V | [T2AV](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2_5_t2av.py)<br>[I2AV](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2_5_i2av.py) |
| ltx2 | `Lightricks/LTX-2.3`<br>`FastVideo/LTX2.3-base`<br>`FastVideo/LTX2.3-Diffusers` | T2V | [basic_ltx2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2.py) |
| ltx2 | `Lightricks/LTX-2`<br>`FastVideo/LTX2-base`<br>`FastVideo/LTX2-Diffusers` | T2V | [basic_ltx2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2.py) |
| mmaudio | `FastVideo/MMAudio-large-44k-v2-Diffusers` | V2A, T2A | [basic_mmaudio.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_mmaudio.py) |
@@ -116,6 +118,11 @@ and distilled) generate video with audio from text, optionally plus an image (se
`kandinsky6_sr` IDs upscale an existing video (see the
[Video SR guide](kandinsky6_sr.md)).
**Note (LTX-2.5)**: T2V and I2V produce synchronized video and audio. The
official `Lightricks/LTX-2.5` repository uses gated, split component files, so
it must first be converted to FastVideo's component layout. See the
[LTX-2.5 inference guide](ltx2_5.md).
**Note (Wan-VACE)**: not currently supported — no VACE pipeline or registered
model ID exists on `main`
([#1435](https://github.com/hao-ai-lab/FastVideo/issues/1435)). The closest
@@ -0,0 +1,108 @@
# SPDX-License-Identifier: Apache-2.0
"""Generate synchronized video and audio from an image and text with LTX-2.5."""
from __future__ import annotations
import argparse
from pathlib import Path
from fastvideo import VideoGenerator
from fastvideo.api import (
CompileConfig,
EngineConfig,
GenerationRequest,
GeneratorConfig,
OffloadConfig,
OutputConfig,
ParallelismConfig,
PipelineSelection,
SamplingConfig,
)
MODEL_IDS = {
"distilled": "FastVideo/LTX-2.5-Distilled-Diffusers",
"dev": "FastVideo/LTX-2.5-Dev-Diffusers",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--variant", choices=tuple(MODEL_IDS), default="distilled")
parser.add_argument(
"--model-path",
help="FastVideo-converted model directory or Hub ID; the raw split Lightricks repo is not directly loadable.",
)
parser.add_argument("--image", required=True)
parser.add_argument("--prompt", required=True)
parser.add_argument("--output", default="outputs/ltx2_5_i2av.mp4")
parser.add_argument("--height", type=int)
parser.add_argument("--width", type=int)
parser.add_argument("--num-frames", type=int, default=121)
parser.add_argument("--steps", type=int)
parser.add_argument("--seed", type=int, default=10)
parser.add_argument("--num-gpus", type=int, default=4)
parser.add_argument("--torch-compile", action="store_true", help="Compile the transformer denoising path")
return parser.parse_args()
def main() -> None:
args = parse_args()
image_path = Path(args.image)
if not image_path.is_file():
raise SystemExit(f"Conditioning image not found: {image_path}")
is_distilled = args.variant == "distilled"
model_path = args.model_path or MODEL_IDS[args.variant]
height = args.height or (1024 if is_distilled else 512)
width = args.width or (1536 if is_distilled else 768)
steps = args.steps or (8 if is_distilled else 30)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
generator = VideoGenerator.from_config(
GeneratorConfig(
model_path=model_path,
engine=EngineConfig(
num_gpus=args.num_gpus,
use_fsdp_inference=args.num_gpus > 1,
parallelism=ParallelismConfig(tp_size=1, sp_size=args.num_gpus),
offload=OffloadConfig(
dit=False,
dit_layerwise=False,
text_encoder=True,
vae=True,
pin_cpu_memory=False,
),
compile=CompileConfig(enabled=args.torch_compile),
),
pipeline=PipelineSelection(workload_type="i2v"),
))
try:
result = generator.generate(
GenerationRequest(
prompt=args.prompt,
sampling=SamplingConfig(
seed=args.seed,
height=height,
width=width,
num_frames=args.num_frames,
fps=24,
num_inference_steps=steps,
guidance_scale=1.0 if is_distilled else 3.0,
),
output=OutputConfig(
output_path=str(output_path),
save_video=True,
return_frames=False,
),
extensions={
"ltx2_images": [(str(image_path), 0, 1.0)],
"ltx2_image_crf": 18.0,
},
))
print(f"Synchronized video and audio written to: {result.video_path}")
finally:
generator.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: Apache-2.0
"""Generate synchronized video and audio from text with LTX-2.5."""
from __future__ import annotations
import argparse
from pathlib import Path
from fastvideo import VideoGenerator
from fastvideo.api import (
CompileConfig,
EngineConfig,
GenerationRequest,
GeneratorConfig,
OffloadConfig,
OutputConfig,
ParallelismConfig,
PipelineSelection,
SamplingConfig,
)
MODEL_IDS = {
"distilled": "FastVideo/LTX-2.5-Distilled-Diffusers",
"dev": "FastVideo/LTX-2.5-Dev-Diffusers",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--variant", choices=tuple(MODEL_IDS), default="distilled")
parser.add_argument(
"--model-path",
help="FastVideo-converted model directory or Hub ID; the raw split Lightricks repo is not directly loadable.",
)
parser.add_argument("--prompt", required=True)
parser.add_argument("--output", default="outputs/ltx2_5_t2av.mp4")
parser.add_argument("--height", type=int)
parser.add_argument("--width", type=int)
parser.add_argument("--num-frames", type=int, default=121)
parser.add_argument("--steps", type=int)
parser.add_argument("--seed", type=int, default=10)
parser.add_argument("--num-gpus", type=int, default=4)
parser.add_argument("--torch-compile", action="store_true", help="Compile the transformer denoising path")
return parser.parse_args()
def main() -> None:
args = parse_args()
is_distilled = args.variant == "distilled"
model_path = args.model_path or MODEL_IDS[args.variant]
height = args.height or (1024 if is_distilled else 512)
width = args.width or (1536 if is_distilled else 768)
steps = args.steps or (8 if is_distilled else 30)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
generator = VideoGenerator.from_config(
GeneratorConfig(
model_path=model_path,
engine=EngineConfig(
num_gpus=args.num_gpus,
use_fsdp_inference=args.num_gpus > 1,
parallelism=ParallelismConfig(tp_size=1, sp_size=args.num_gpus),
offload=OffloadConfig(
dit=False,
dit_layerwise=False,
text_encoder=True,
vae=True,
pin_cpu_memory=False,
),
compile=CompileConfig(enabled=args.torch_compile),
),
pipeline=PipelineSelection(workload_type="t2v"),
))
try:
result = generator.generate(
GenerationRequest(
prompt=args.prompt,
sampling=SamplingConfig(
seed=args.seed,
height=height,
width=width,
num_frames=args.num_frames,
fps=24,
num_inference_steps=steps,
guidance_scale=1.0 if is_distilled else 3.0,
),
output=OutputConfig(
output_path=str(output_path),
save_video=True,
return_frames=False,
),
))
print(f"Synchronized video and audio written to: {result.video_path}")
finally:
generator.shutdown()
if __name__ == "__main__":
main()
+3
View File
@@ -152,6 +152,9 @@ class SamplingParam:
ltx2_stg_scale_audio: float = 0.0
ltx2_stg_blocks_video: list[int] = field(default_factory=lambda: [29])
ltx2_stg_blocks_audio: list[int] = field(default_factory=lambda: [29])
# LTX-2.5 distilled stage 1 uses the official variance-preserving
# ancestral Euler update. Older LTX generations remain deterministic.
ltx2_use_ancestral_sampler: bool = False
# LTX-2 image / video / continuation conditioning. These flow from
# generate_video(...) kwargs through ``sampling_param.update(kwargs)``
+7
View File
@@ -55,6 +55,13 @@ class LTX2VideoArchConfig(DiTArchConfig):
# LTX-2.3 gated extensions. All default OFF == LTX-2.0 behavior.
cross_attention_adaln: bool = False
caption_proj_before_connector: bool = False
# LTX-2.5 architecture gates. Defaults preserve all pre-2.5 checkpoints:
# prompt cross-attention remains timestep-dependent, FFNs retain their
# biases, and no keyframe-only parameter is added to the state dict.
use_prompt_adaln_single: bool = True
ff_bias: bool = True
audio_ff_bias: bool = True
use_keyframes_abs_pos_embedding: bool = False
positional_embedding_theta: float = 10000.0
positional_embedding_max_pos: list[int] = field(default_factory=lambda: [20, 2048, 2048])
@@ -30,6 +30,7 @@ class LTX2GemmaArchConfig(TextEncoderArchConfig):
num_attention_heads: int = 30
text_len: int = 1024
pad_token_id: int = 0
bos_token_id: int = 2
eos_token_id: int = 2
gemma_model_path: str = ""
@@ -58,6 +59,7 @@ class LTX2GemmaArchConfig(TextEncoderArchConfig):
connector_rope_type: str = "split"
connector_double_precision_rope: bool = False
connector_apply_gated_attention: bool = False
connector_ff_bias: bool = True
connector_num_learnable_registers: int | None = 128
_fsdp_shard_conditions: list = field(
+1
View File
@@ -26,6 +26,7 @@ class LTX2VAEArchConfig(VAEArchConfig):
decoder_spatial_padding_mode: str = "reflect"
causal_decoder: bool = False
timestep_conditioning: bool = True
decoder_base_channels: int = 128
use_quant_conv: bool = False
scaling_factor: float = 1.0
normalize_latent_channels: bool = False
+92 -14
View File
@@ -332,6 +332,7 @@ class GELUApprox(nn.Module):
self,
in_features: int,
out_features: int,
bias: bool = True,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
):
@@ -339,6 +340,7 @@ class GELUApprox(nn.Module):
self.proj = ReplicatedLinear(
in_features,
out_features,
bias=bias,
quant_config=quant_config,
prefix=f"{prefix}.fc_in",
)
@@ -356,6 +358,7 @@ class FeedForward(nn.Module):
dim: int,
dim_out: int,
mult: int = 4,
bias: bool = True,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
@@ -364,12 +367,14 @@ class FeedForward(nn.Module):
project_in = GELUApprox(
dim,
inner_dim,
bias=bias,
quant_config=quant_config,
prefix=f"{prefix}.ffn",
)
project_out = ReplicatedLinear(
inner_dim,
dim_out,
bias=bias,
quant_config=quant_config,
prefix=f"{prefix}.ffn.fc_out",
)
@@ -918,6 +923,26 @@ class Modality:
context_mask: torch.Tensor | None = None
# LTX-2.3 cross-attention AdaLN sigma timestep (None for 2.0).
sigma: torch.Tensor | None = None
# LTX-2.5 per-token keyframe marker, shaped (B, T, 1).
keyframes_mask: torch.Tensor | None = None
KeyframesEmbeddingProvider = Callable[[], torch.Tensor | None]
def apply_keyframes_absolute_embedding(
hidden_states: torch.Tensor,
keyframes_mask: torch.Tensor | None,
embedding_provider: KeyframesEmbeddingProvider | None,
) -> torch.Tensor:
"""Apply the learned LTX-2.5 marker to projected keyframe tokens."""
if embedding_provider is None or keyframes_mask is None:
return hidden_states
embedding = embedding_provider()
if embedding is None:
return hidden_states
mask = (keyframes_mask > 0).to(dtype=hidden_states.dtype)
return hidden_states + mask * embedding.to(dtype=hidden_states.dtype)
class TransformerArgsPreprocessor:
@@ -937,6 +962,7 @@ class TransformerArgsPreprocessor:
positional_embedding_theta: float,
rope_type: LTXRopeType,
prompt_adaln: AdaLayerNormSingle | None = None,
keyframes_embedding_provider: KeyframesEmbeddingProvider | None = None,
) -> None:
self.patchify_proj = patchify_proj
self.adaln = adaln
@@ -951,6 +977,7 @@ class TransformerArgsPreprocessor:
self.rope_type = rope_type
# LTX-2.3 cross-attention AdaLN prompt timestep embedder (None for 2.0).
self.prompt_adaln = prompt_adaln
self.keyframes_embedding_provider = keyframes_embedding_provider
def _prepare_timestep(
self,
@@ -1025,6 +1052,11 @@ class TransformerArgsPreprocessor:
if not latent.is_contiguous():
latent = latent.contiguous()
x = self.patchify_proj(latent)
x = apply_keyframes_absolute_embedding(
x,
modality.keyframes_mask,
self.keyframes_embedding_provider,
)
timestep, embedded_timestep = self._prepare_timestep(modality.timesteps, x.shape[0], modality.latent.dtype,
self.adaln)
prompt_timestep = None
@@ -1082,6 +1114,7 @@ class MultiModalTransformerArgsPreprocessor:
rope_type: LTXRopeType,
av_ca_timestep_scale_multiplier: int,
prompt_adaln: AdaLayerNormSingle | None = None,
keyframes_embedding_provider: KeyframesEmbeddingProvider | None = None,
) -> None:
self.simple_preprocessor = TransformerArgsPreprocessor(
patchify_proj=patchify_proj,
@@ -1096,6 +1129,7 @@ class MultiModalTransformerArgsPreprocessor:
positional_embedding_theta=positional_embedding_theta,
rope_type=rope_type,
prompt_adaln=prompt_adaln,
keyframes_embedding_provider=keyframes_embedding_provider,
)
self.cross_scale_shift_adaln = cross_scale_shift_adaln
self.cross_gate_adaln = cross_gate_adaln
@@ -1163,6 +1197,7 @@ class TransformerConfig:
# LTX-2.3 gated extensions (default OFF == LTX-2.0 behavior).
apply_gated_attention: bool = False
cross_attention_adaln: bool = False
ff_bias: bool = True
class LTXDistributedAttention(DistributedAttention):
@@ -1803,6 +1838,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
self.ff = FeedForward(
video.dim,
dim_out=video.dim,
bias=video.ff_bias,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}",
)
@@ -1841,6 +1877,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
self.audio_ff = FeedForward(
audio.dim,
dim_out=audio.dim,
bias=audio.ff_bias,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.audio",
)
@@ -2247,11 +2284,16 @@ def apply_cross_attention_adaln(
Only reached when ``cross_attention_adaln`` is enabled, so it never
affects the LTX-2.0 path.
"""
if prompt_scale_shift_table is None or prompt_timestep is None:
raise ValueError("cross_attention_adaln requires prompt scale/shift tables and prompt_timestep.")
if prompt_scale_shift_table is None:
raise ValueError("cross_attention_adaln requires a prompt scale/shift table.")
batch_size = x.shape[0]
shift_kv, scale_kv = (prompt_scale_shift_table[None, None].to(device=x.device, dtype=x.dtype) +
prompt_timestep.reshape(batch_size, prompt_timestep.shape[1], 2, -1)).unbind(dim=2)
kv_modulation = prompt_scale_shift_table[None, None].to(device=x.device, dtype=x.dtype)
# LTX-2.5 can disable the prompt-side AdaLN MLP. In that mode the static
# per-block table is the entire K/V modulation, making it timestep
# independent and cacheable. Older checkpoints add the dynamic term.
if prompt_timestep is not None:
kv_modulation = kv_modulation + prompt_timestep.reshape(batch_size, prompt_timestep.shape[1], 2, -1)
shift_kv, scale_kv = kv_modulation.unbind(dim=2)
attn_input = _rms_norm_dispatch(x, eps=norm_eps) * (1 + q_scale) + q_shift
encoder_hidden_states = context * (1 + scale_kv) + shift_kv
return attn(attn_input, context=encoder_hidden_states, mask=context_mask) * q_gate
@@ -2299,6 +2341,10 @@ class LTXModel(torch.nn.Module):
rope_type: LTXRopeType = LTXRopeType.INTERLEAVED,
double_precision_rope: bool = False,
cross_attention_adaln: bool = False,
use_prompt_adaln_single: bool = True,
ff_bias: bool = True,
audio_ff_bias: bool = True,
use_keyframes_abs_pos_embedding: bool = False,
caption_proj_before_connector: bool = False,
apply_gated_attention: bool = False,
stg_block_idx: int = 29,
@@ -2310,6 +2356,10 @@ class LTXModel(torch.nn.Module):
self._enable_gradient_checkpointing = False
# LTX-2.3 gated extensions (all default OFF == LTX-2.0 behavior).
self.cross_attention_adaln = cross_attention_adaln
self.use_prompt_adaln_single = use_prompt_adaln_single
self.ff_bias = ff_bias
self.audio_ff_bias = audio_ff_bias
self.use_keyframes_abs_pos_embedding = use_keyframes_abs_pos_embedding
self.caption_proj_before_connector = caption_proj_before_connector
self.apply_gated_attention = apply_gated_attention
self.stg_block_idx = stg_block_idx
@@ -2368,6 +2418,10 @@ class LTXModel(torch.nn.Module):
prefix=prefix,
)
def _keyframes_embedding(self) -> torch.Tensor | None:
"""Resolve the optional parameter at call time, matching upstream."""
return getattr(self, "keyframes_abs_pos_embedding", None)
def _init_video(
self,
in_channels: int,
@@ -2377,6 +2431,9 @@ class LTXModel(torch.nn.Module):
create_caption_projection: bool = True,
) -> None:
self.patchify_proj = torch.nn.Linear(in_channels, self.inner_dim, bias=True)
self.keyframes_abs_pos_embedding = (
torch.nn.Parameter(torch.zeros(1, self.inner_dim)) if self.use_keyframes_abs_pos_embedding else None
)
self.adaln_single = AdaLayerNormSingle(
self.inner_dim,
embedding_coefficient=adaln_embedding_coefficient(self.cross_attention_adaln),
@@ -2388,11 +2445,10 @@ class LTXModel(torch.nn.Module):
in_features=caption_channels,
hidden_size=self.inner_dim,
)
if self.cross_attention_adaln:
self.prompt_adaln_single = AdaLayerNormSingle(
self.inner_dim,
embedding_coefficient=2,
)
self.prompt_adaln_single = (
AdaLayerNormSingle(self.inner_dim, embedding_coefficient=2)
if self.cross_attention_adaln and self.use_prompt_adaln_single else None
)
self.scale_shift_table = torch.nn.Parameter(torch.empty(2, self.inner_dim))
self.norm_out = torch.nn.LayerNorm(self.inner_dim, elementwise_affine=False, eps=norm_eps)
self.proj_out = torch.nn.Linear(self.inner_dim, out_channels)
@@ -2415,11 +2471,10 @@ class LTXModel(torch.nn.Module):
in_features=caption_channels,
hidden_size=self.audio_inner_dim,
)
if self.cross_attention_adaln:
self.audio_prompt_adaln_single = AdaLayerNormSingle(
self.audio_inner_dim,
embedding_coefficient=2,
)
self.audio_prompt_adaln_single = (
AdaLayerNormSingle(self.audio_inner_dim, embedding_coefficient=2)
if self.cross_attention_adaln and self.use_prompt_adaln_single else None
)
self.audio_scale_shift_table = torch.nn.Parameter(torch.empty(2, self.audio_inner_dim))
self.audio_norm_out = torch.nn.LayerNorm(self.audio_inner_dim, elementwise_affine=False, eps=norm_eps)
self.audio_proj_out = torch.nn.Linear(self.audio_inner_dim, out_channels)
@@ -2462,6 +2517,7 @@ class LTXModel(torch.nn.Module):
rope_type=self.rope_type,
av_ca_timestep_scale_multiplier=self.av_ca_timestep_scale_multiplier,
prompt_adaln=getattr(self, "prompt_adaln_single", None),
keyframes_embedding_provider=self._keyframes_embedding,
)
self.audio_args_preprocessor = MultiModalTransformerArgsPreprocessor(
patchify_proj=self.audio_patchify_proj,
@@ -2496,6 +2552,7 @@ class LTXModel(torch.nn.Module):
positional_embedding_theta=self.positional_embedding_theta,
rope_type=self.rope_type,
prompt_adaln=getattr(self, "prompt_adaln_single", None),
keyframes_embedding_provider=self._keyframes_embedding,
)
elif self.model_type.is_audio_enabled():
self.audio_args_preprocessor = TransformerArgsPreprocessor(
@@ -2532,6 +2589,7 @@ class LTXModel(torch.nn.Module):
context_dim=cross_attention_dim,
apply_gated_attention=self.apply_gated_attention,
cross_attention_adaln=self.cross_attention_adaln,
ff_bias=self.ff_bias,
) if self.model_type.is_video_enabled() else None)
audio_config = (TransformerConfig(
dim=self.audio_inner_dim,
@@ -2540,6 +2598,7 @@ class LTXModel(torch.nn.Module):
context_dim=audio_cross_attention_dim,
apply_gated_attention=self.apply_gated_attention,
cross_attention_adaln=self.cross_attention_adaln,
ff_bias=self.audio_ff_bias,
) if self.model_type.is_audio_enabled() else None)
self.use_distributed_attention = use_distributed_attention
self.transformer_blocks = torch.nn.ModuleList([
@@ -2716,6 +2775,10 @@ class LTX2Transformer3DModel(BaseDiT):
audio_positional_embedding_max_pos=arch.audio_positional_embedding_max_pos,
av_ca_timestep_scale_multiplier=arch.av_ca_timestep_scale_multiplier,
cross_attention_adaln=arch.cross_attention_adaln,
use_prompt_adaln_single=arch.use_prompt_adaln_single,
ff_bias=arch.ff_bias,
audio_ff_bias=arch.audio_ff_bias,
use_keyframes_abs_pos_embedding=arch.use_keyframes_abs_pos_embedding,
caption_proj_before_connector=arch.caption_proj_before_connector,
apply_gated_attention=arch.apply_gated_attention,
stg_block_idx=arch.stg_block_idx,
@@ -2752,6 +2815,7 @@ class LTX2Transformer3DModel(BaseDiT):
audio_encoder_attention_mask: torch.Tensor | None = None,
video_sigma: torch.Tensor | None = None,
audio_sigma: torch.Tensor | None = None,
keyframes_mask: torch.Tensor | None = None,
skip_cross_modal_attn: bool = False,
skip_video_self_attn_blocks: list[int] | None = None,
skip_audio_self_attn_blocks: list[int] | None = None,
@@ -2790,10 +2854,23 @@ class LTX2Transformer3DModel(BaseDiT):
video_original_seq_len = latents.shape[1]
video_padded_seq_len = video_original_seq_len
video_timestep = timestep
if keyframes_mask is not None:
if keyframes_mask.ndim != 3 or keyframes_mask.shape[-1] != 1:
raise ValueError(
"keyframes_mask must contain patchified per-token markers with shape (B, T, 1)"
)
if keyframes_mask.shape[:2] != latents.shape[:2]:
raise ValueError(
"keyframes_mask batch/sequence dimensions must match the patchified video latents: "
f"mask={tuple(keyframes_mask.shape)}, latents={tuple(latents.shape)}"
)
keyframes_mask = keyframes_mask.to(device=latents.device)
if sp_world_size > 1:
latents, video_original_seq_len = sequence_model_parallel_shard(latents, dim=1)
# Shard timestep along sequence dimension (timestep has shape [batch, seq_len])
video_timestep, _ = sequence_model_parallel_shard(timestep, dim=1)
if keyframes_mask is not None:
keyframes_mask, _ = sequence_model_parallel_shard(keyframes_mask, dim=1)
current_seq_len = latents.shape[1]
video_padded_seq_len = current_seq_len * sp_world_size
# Compute RoPE positions for the FULL sequence (before sharding)
@@ -2825,6 +2902,7 @@ class LTX2Transformer3DModel(BaseDiT):
context=encoder_hidden_states,
context_mask=encoder_attention_mask,
sigma=video_sigma,
keyframes_mask=keyframes_mask,
)
# Process audio modality if provided
+81 -6
View File
@@ -7,7 +7,7 @@ from typing import Any, Iterable
import torch
from torch import nn
from transformers import AutoTokenizer, Gemma3ForConditionalGeneration
from transformers import AutoModelForImageTextToText, AutoTokenizer, PreTrainedModel
import fastvideo.envs as envs
from fastvideo.configs.models.encoders import BaseEncoderOutput, TextEncoderConfig
@@ -50,6 +50,7 @@ class GemmaConnectorConfig:
num_learnable_registers: int | None
# LTX-2.3 connector gated attention (default False == LTX-2.0 behavior).
apply_gated_attention: bool = False
ff_bias: bool = True
class GemmaFeaturesExtractorProjLinear(nn.Module):
@@ -93,6 +94,7 @@ class _BasicTransformerBlock1D(nn.Module):
dim_head: int,
rope_type: LTXRopeType,
apply_gated_attention: bool = False,
ff_bias: bool = True,
norm_eps: float = 1e-6,
) -> None:
super().__init__()
@@ -105,7 +107,7 @@ class _BasicTransformerBlock1D(nn.Module):
rope_type=rope_type,
apply_gated_attention=apply_gated_attention,
)
self.ff = FeedForward(dim, dim_out=dim)
self.ff = FeedForward(dim, dim_out=dim, bias=ff_bias)
self.norm_eps = norm_eps
def forward(
@@ -237,6 +239,7 @@ class Embeddings1DConnector(nn.Module):
dim_head=config.attention_head_dim,
rope_type=config.rope_type,
apply_gated_attention=config.apply_gated_attention,
ff_bias=config.ff_bias,
) for _ in range(config.num_layers)
])
self.num_learnable_registers = config.num_learnable_registers
@@ -357,6 +360,7 @@ class LTX2GemmaTextEncoderModel(TextEncoder):
)
connector_apply_gated_attention = bool(getattr(arch, "connector_apply_gated_attention", False))
connector_ff_bias = bool(getattr(arch, "connector_ff_bias", True))
video_connector_config = GemmaConnectorConfig(
num_attention_heads=arch.connector_num_attention_heads,
attention_head_dim=arch.connector_attention_head_dim,
@@ -367,6 +371,7 @@ class LTX2GemmaTextEncoderModel(TextEncoder):
double_precision_rope=arch.connector_double_precision_rope,
num_learnable_registers=arch.connector_num_learnable_registers,
apply_gated_attention=connector_apply_gated_attention,
ff_bias=connector_ff_bias,
)
audio_connector_config = GemmaConnectorConfig(
num_attention_heads=getattr(arch, "audio_connector_num_attention_heads", None)
@@ -380,6 +385,7 @@ class LTX2GemmaTextEncoderModel(TextEncoder):
double_precision_rope=arch.connector_double_precision_rope,
num_learnable_registers=arch.connector_num_learnable_registers,
apply_gated_attention=connector_apply_gated_attention,
ff_bias=connector_ff_bias,
)
self.embeddings_connector = Embeddings1DConnector(video_connector_config)
self.audio_embeddings_connector = Embeddings1DConnector(audio_connector_config)
@@ -387,11 +393,11 @@ class LTX2GemmaTextEncoderModel(TextEncoder):
self.gemma_model_path = arch.gemma_model_path
self.gemma_dtype = arch.gemma_dtype
self.padding_side = arch.padding_side
self._gemma_model: Gemma3ForConditionalGeneration | None = None
self._gemma_model: PreTrainedModel | None = None
def named_parameters(self, prefix: str = "", recurse: bool = True):
for name, param in super().named_parameters(prefix=prefix, recurse=recurse):
if name.startswith("gemma_model."):
if name.startswith("_gemma_model."):
continue
yield name, param
@@ -425,13 +431,13 @@ class LTX2GemmaTextEncoderModel(TextEncoder):
)
@property
def gemma_model(self) -> Gemma3ForConditionalGeneration:
def gemma_model(self) -> PreTrainedModel:
if self._gemma_model is None:
gemma_path = self.gemma_model_path
if not gemma_path:
raise ValueError("gemma_model_path must be set (expected text_encoder/gemma).")
dtype = getattr(torch, self.gemma_dtype, torch.bfloat16)
self._gemma_model = Gemma3ForConditionalGeneration.from_pretrained(
self._gemma_model = AutoModelForImageTextToText.from_pretrained(
gemma_path,
local_files_only=True,
torch_dtype=dtype,
@@ -554,6 +560,11 @@ class LTX2GemmaTextEncoderModel(TextEncoder):
input_ids = text_inputs["input_ids"].to(device=model.device)
attention_mask = text_inputs["attention_mask"].to(device=model.device)
input_ids, attention_mask = _ensure_leading_bos(
input_ids,
attention_mask,
bos_token_id=_get_bos_token_id(model.config),
)
outputs = model(
input_ids=input_ids,
attention_mask=attention_mask,
@@ -590,6 +601,11 @@ class LTX2GemmaTextEncoderModel(TextEncoder):
attention_mask = torch.ones_like(input_ids)
model = self.gemma_model
input_ids, attention_mask = _ensure_leading_bos(
input_ids,
attention_mask,
bos_token_id=_get_bos_token_id(model.config),
)
target_device = get_local_torch_device()
# Do not invoke model.to() inside the compiled forward path.
# _parse_to returns a non-Tensor torch.device, which Dynamo cannot
@@ -695,5 +711,64 @@ def _norm_and_concat_padded_batch(
return normed
def _get_bos_token_id(config: Any) -> int | None:
bos_token_id = getattr(config, "bos_token_id", None)
if bos_token_id is None:
bos_token_id = getattr(getattr(config, "text_config", None), "bos_token_id", None)
return int(bos_token_id) if bos_token_id is not None else None
def _ensure_leading_bos(
input_ids: torch.Tensor,
attention_mask: torch.Tensor,
*,
bos_token_id: int | None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Ensure each left-padded Gemma sequence starts with a valid BOS token.
Gemma 3 tokenizers generally add BOS themselves, while the packed Gemma 4
tokenizer used by LTX-2.5 does not. This keeps the legacy path unchanged
and adds BOS only to rows that need it, matching the official LTX wrapper.
"""
if bos_token_id is None:
raise ValueError("LTX-2 Gemma tokenizer/config must define bos_token_id.")
if input_ids.ndim != 2 or attention_mask.shape != input_ids.shape:
raise ValueError("LTX-2 Gemma input_ids and attention_mask must have matching rank-2 shapes.")
valid = attention_mask.bool()
has_valid = valid.any(dim=1)
first_valid = valid.to(torch.int64).argmax(dim=1)
first_tokens = input_ids.gather(1, first_valid.unsqueeze(1)).squeeze(1)
needs_bos = ~has_valid | first_tokens.ne(bos_token_id)
adjusted_ids = input_ids.clone()
adjusted_mask = attention_mask.clone()
# Rows with left padding can consume one padding slot without moving any
# text tokens. Empty rows place BOS in the final slot.
target_positions = torch.where(has_valid, first_valid - 1, torch.full_like(first_valid, input_ids.shape[1] - 1))
consume_padding = needs_bos & (~has_valid | first_valid.gt(0))
safe_targets = target_positions.clamp(min=0)
adjusted_ids.scatter_(1, safe_targets.unsqueeze(1), torch.where(
consume_padding,
torch.full_like(safe_targets, bos_token_id),
adjusted_ids.gather(1, safe_targets.unsqueeze(1)).squeeze(1),
).unsqueeze(1))
adjusted_mask.scatter_(1, safe_targets.unsqueeze(1), torch.where(
consume_padding,
torch.ones_like(safe_targets, dtype=adjusted_mask.dtype),
adjusted_mask.gather(1, safe_targets.unsqueeze(1)).squeeze(1),
).unsqueeze(1))
# A completely full row has no padding slot; prepend BOS and truncate its
# last token exactly as the official max-length tokenizer wrapper does.
shift_rows = needs_bos & has_valid & first_valid.eq(0)
shifted_ids = torch.cat((torch.full_like(input_ids[:, :1], bos_token_id), input_ids[:, :-1]), dim=1)
shifted_mask = torch.cat((torch.ones_like(attention_mask[:, :1]), attention_mask[:, :-1]), dim=1)
adjusted_ids = torch.where(shift_rows.unsqueeze(1), shifted_ids, adjusted_ids)
adjusted_mask = torch.where(shift_rows.unsqueeze(1), shifted_mask, adjusted_mask)
return adjusted_ids, adjusted_mask
# Entry point for model registry
EntryClass = LTX2GemmaTextEncoderModel
@@ -290,6 +290,32 @@ class TextEncoderLoader(ComponentLoader):
if gemma_path and not gemma_path_from_candidate:
if not os.path.isabs(gemma_path):
model_config["gemma_model_path"] = os.path.normpath(os.path.join(repo_root, gemma_path))
resolved_gemma_path = model_config.get("gemma_model_path", gemma_path)
gemma_config_path = os.path.join(resolved_gemma_path, "config.json") if resolved_gemma_path else ""
if os.path.isfile(gemma_config_path):
try:
with open(gemma_config_path, encoding="utf-8") as f:
gemma_config = json.load(f)
gemma_text_config = gemma_config.get("text_config", gemma_config)
gemma_hidden_size = gemma_text_config.get("hidden_size")
gemma_num_hidden_layers = gemma_text_config.get("num_hidden_layers")
if gemma_hidden_size is not None and gemma_num_hidden_layers is not None:
# LTX feature extraction stacks the embedding output plus
# every Gemma transformer layer. Derive this from the
# packed Gemma config so Gemma 4 never inherits Gemma 3's
# hard-coded 3840x49 geometry.
model_config["hidden_size"] = int(gemma_hidden_size)
model_config["num_hidden_layers"] = int(gemma_num_hidden_layers)
model_config["feature_extractor_in_features"] = (int(gemma_hidden_size) *
(int(gemma_num_hidden_layers) + 1))
for field_name in ("num_attention_heads", "pad_token_id", "eos_token_id"):
value = gemma_text_config.get(field_name, gemma_config.get(field_name))
if value is not None:
if field_name == "eos_token_id" and isinstance(value, list):
value = value[0] if value else 2
model_config[field_name] = value
except (json.JSONDecodeError, OSError, TypeError, ValueError) as exc:
logger.warning("Unable to derive LTX Gemma geometry from %s: %s", gemma_config_path, exc)
transformer_config_path = os.path.join(repo_root, "transformer", "config.json")
if os.path.isfile(transformer_config_path):
try:
+4 -1
View File
@@ -1327,6 +1327,7 @@ class VideoDecoder(nn.Module):
causal: bool = False,
timestep_conditioning: bool = False,
decoder_spatial_padding_mode: PaddingModeType = PaddingModeType.REFLECT,
base_channels: int = 128,
):
super().__init__()
@@ -1341,7 +1342,7 @@ class VideoDecoder(nn.Module):
self.decode_noise_scale = 0.025
self.decode_timestep = 0.05
feature_channels = in_channels
feature_channels = base_channels
for block_name, block_params in list(reversed(decoder_blocks)):
block_config = block_params if isinstance(block_params, dict) else {}
if block_name == "res_x_y":
@@ -1521,6 +1522,7 @@ class VideoDecoderConfigurator:
norm_layer_str = config.get("norm_layer", "pixel_norm")
causal = config.get("causal_decoder", False)
timestep_conditioning = config.get("timestep_conditioning", True)
base_channels = config.get("decoder_base_channels", 128)
return VideoDecoder(
convolution_dimensions=convolution_dimensions,
@@ -1532,6 +1534,7 @@ class VideoDecoderConfigurator:
causal=causal,
timestep_conditioning=timestep_conditioning,
decoder_spatial_padding_mode=decoder_spatial_padding_mode,
base_channels=base_channels,
)
+88 -1
View File
@@ -141,4 +141,91 @@ LTX2_TWO_STAGE = InferencePreset(
},
)
ALL_PRESETS = (LTX2_BASE, LTX2_3_BASE, LTX2_DISTILLED, LTX2_TWO_STAGE)
LTX2_5_DEV = InferencePreset(
name="ltx2_5_dev",
version=1,
model_family="ltx2",
description="LTX-2.5 dev joint video/audio generation at 512x768",
workload_type="t2v",
stage_schemas=(_DENOISE_STAGE, ),
defaults={
"seed": 10,
"height": 512,
"width": 768,
"num_frames": 121,
"fps": 24,
"guidance_scale": 3.0,
"num_inference_steps": 30,
"negative_prompt": _LTX2_NEGATIVE_PROMPT,
"ltx2_cfg_scale_video": 3.0,
"ltx2_cfg_scale_audio": 7.0,
"ltx2_modality_scale_video": 3.0,
"ltx2_modality_scale_audio": 3.0,
"ltx2_rescale_scale": 0.7,
"ltx2_stg_scale_video": 1.0,
"ltx2_stg_scale_audio": 1.0,
"ltx2_stg_blocks_video": [28],
"ltx2_stg_blocks_audio": [28],
"ltx2_image_crf": 18.0,
"ltx2_use_ancestral_sampler": False,
},
)
LTX2_5_DISTILLED = InferencePreset(
name="ltx2_5_distilled",
version=1,
model_family="ltx2",
description="LTX-2.5 distilled joint video/audio generation at 1024x1536",
workload_type="t2v",
stage_schemas=(_DENOISE_STAGE, ),
defaults={
"seed": 10,
"height": 1024,
"width": 1536,
"num_frames": 121,
"fps": 24,
"guidance_scale": 1.0,
"num_inference_steps": 8,
"negative_prompt": "",
"ltx2_image_crf": 18.0,
"ltx2_use_ancestral_sampler": True,
},
)
LTX2_5_DISTILLED_TWO_STAGE = InferencePreset(
name="ltx2_5_distilled_two_stage",
version=1,
model_family="ltx2",
description="LTX-2.5 distilled joint video/audio generation with 2x spatial refinement",
workload_type="t2v",
stage_schemas=(_DENOISE_STAGE, _REFINE_STAGE),
defaults=dict(LTX2_5_DISTILLED.defaults),
stage_defaults={
"refine": {
"num_inference_steps": 3,
"guidance_scale": 1.0,
"image_crf": 18,
},
},
)
ALL_PRESETS = (
LTX2_BASE,
LTX2_3_BASE,
LTX2_DISTILLED,
LTX2_TWO_STAGE,
LTX2_5_DEV,
LTX2_5_DISTILLED,
LTX2_5_DISTILLED_TWO_STAGE,
)
__all__ = [
"ALL_PRESETS",
"LTX2_BASE",
"LTX2_3_BASE",
"LTX2_DISTILLED",
"LTX2_TWO_STAGE",
"LTX2_5_DEV",
"LTX2_5_DISTILLED",
"LTX2_5_DISTILLED_TWO_STAGE",
]
@@ -25,6 +25,7 @@ from fastvideo.pipelines.basic.ltx2.stages.ltx2_image_conditioning import (LTX2_
LTX2_VIDEO_DENOISE_MASK_KEY,
apply_ltx2_gaussian_noiser,
post_process_ltx2_denoised)
from fastvideo.pipelines.basic.ltx2.stages.ltx2_latent_preparation import _randn_ltx2_video_latents
from fastvideo.pipelines.stages.validators import StageValidators as V
from fastvideo.pipelines.stages.validators import VerificationResult
from fastvideo.logger import init_logger
@@ -43,6 +44,7 @@ MAX_SHIFT_ANCHOR = 4096
# Official distilled sigma schedule (8 denoising steps)
# From LTX-2/packages/ltx-pipelines/src/ltx_pipelines/utils/constants.py
DISTILLED_SIGMA_VALUES = [1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0]
ANCESTRAL_NOISE_SEED_OFFSET = 10000
logger = init_logger(__name__)
@@ -100,6 +102,24 @@ def _ltx2_sigmas(
return sigmas
def _ltx2_first_frame_keyframes_mask(
*,
batch_size: int,
token_count: int,
latent_frames: int,
device: torch.device,
) -> torch.Tensor:
"""Mark the target's first causal latent frame, matching LTX-2.5."""
if latent_frames < 1 or token_count % latent_frames != 0:
raise ValueError(
"LTX-2 keyframe mask requires a positive latent frame count that divides the video token count: "
f"tokens={token_count}, frames={latent_frames}")
tokens_per_frame = token_count // latent_frames
mask = torch.zeros((batch_size, token_count, 1), device=device, dtype=torch.float32)
mask[:, :tokens_per_frame] = 1.0
return mask
def _distilled_subset_sigmas(
steps: int,
device: torch.device,
@@ -151,6 +171,45 @@ def _distilled_subset_sigmas(
return subset_sigmas, list(best_indices)
def _ltx2_euler_ancestral_step(
sample: torch.Tensor,
denoised_sample: torch.Tensor,
sigma: torch.Tensor,
sigma_next: torch.Tensor,
noise: torch.Tensor | None,
*,
eta: float = 1.0,
s_noise: float = 1.0,
) -> torch.Tensor:
"""Apply the official rectified-flow ancestral Euler update.
LTX-2.5 distilled generation advances to an intermediate
``sigma_down`` and then re-noises to ``sigma_next`` while preserving the
signal/noise variance. This is intentionally not the VE/DDIM ancestral
formula used by conventional Euler schedulers.
"""
sigma = sigma.to(device=sample.device, dtype=torch.float32)
sigma_next = sigma_next.to(device=sample.device, dtype=torch.float32)
if bool(sigma_next == 0):
return denoised_sample.to(sample.dtype)
if eta > 0 and noise is None:
raise ValueError("LTX-2 ancestral Euler sampling requires noise when eta > 0.")
sample_float = sample.float()
denoised_float = denoised_sample.float()
downstep_ratio = 1.0 + (sigma_next / sigma - 1.0) * eta
sigma_down = sigma_next * downstep_ratio
sigma_down_ratio = sigma_down / sigma
sample_next = sigma_down_ratio * sample_float + (1.0 - sigma_down_ratio) * denoised_float
if eta > 0:
alpha_next = 1.0 - sigma_next
alpha_down = 1.0 - sigma_down
renoise_coeff = (sigma_next**2 - sigma_down**2 * alpha_next**2 / alpha_down**2).clamp(min=0).sqrt()
sample_next = ((alpha_next / alpha_down) * sample_next + noise.float() * s_noise * renoise_coeff)
return sample_next.to(sample.dtype)
class LTX2DenoisingStage(PipelineStage):
"""Run the LTX-2 denoising loop over the sigma schedule."""
@@ -272,8 +331,8 @@ class LTX2DenoisingStage(PipelineStage):
)
logger.info("[LTX2] Using computed sigma schedule, "
"num_inference_steps=%s", num_inference_steps)
video_shape = VideoLatentShape.from_torch_shape(latents.shape)
if hasattr(self.transformer, "patchifier"):
video_shape = VideoLatentShape.from_torch_shape(latents.shape)
token_count = self.transformer.patchifier.get_token_count(video_shape)
else:
token_count = 1
@@ -283,6 +342,12 @@ class LTX2DenoisingStage(PipelineStage):
device=latents.device,
dtype=torch.float32,
)
keyframes_mask = _ltx2_first_frame_keyframes_mask(
batch_size=latents.shape[0],
token_count=token_count,
latent_frames=video_shape.frames,
device=latents.device,
)
if video_denoise_mask is not None:
patchifier = getattr(self.transformer, "patchifier", None)
if patchifier is not None:
@@ -457,6 +522,18 @@ class LTX2DenoisingStage(PipelineStage):
tuple(sigmas.shape),
tuple(latents.shape),
)
use_ancestral_sampler = bool(batch.ltx2_use_ancestral_sampler and self.sigmas_override is None)
ancestral_generator = None
if use_ancestral_sampler:
if batch.seed is None:
raise ValueError("LTX-2 ancestral sampling requires an explicit seed.")
ancestral_generator = torch.Generator(
device=latents.device).manual_seed(int(batch.seed) + ANCESTRAL_NOISE_SEED_OFFSET)
logger.info(
"[LTX2] Using official ancestral Euler stage-1 sampler "
"(eta=1, s_noise=1, seed_offset=%d)",
ANCESTRAL_NOISE_SEED_OFFSET,
)
# Hint runtime FP4 layer gating (single shared transformer path):
# stage-1 denoising uses "base", stage-2 refine uses "refine".
batch.extra["ltx2_fp4_stage_profile"] = ("refine" if self.sigmas_override is not None else "base")
@@ -516,6 +593,7 @@ class LTX2DenoisingStage(PipelineStage):
audio_timestep=audio_timestep,
video_sigma=sigma_batch,
audio_sigma=sigma_batch,
keyframes_mask=keyframes_mask,
video_position_offset_sec=video_position_offset_sec,
)
if isinstance(pos_outputs, tuple):
@@ -545,6 +623,7 @@ class LTX2DenoisingStage(PipelineStage):
audio_timestep=audio_timestep,
video_sigma=sigma_batch,
audio_sigma=sigma_batch,
keyframes_mask=keyframes_mask,
video_position_offset_sec=video_position_offset_sec,
)
if isinstance(neg_outputs, tuple):
@@ -566,6 +645,7 @@ class LTX2DenoisingStage(PipelineStage):
audio_timestep=audio_timestep,
video_sigma=sigma_batch,
audio_sigma=sigma_batch,
keyframes_mask=keyframes_mask,
skip_cross_modal_attn=True,
video_position_offset_sec=video_position_offset_sec,
)
@@ -588,6 +668,7 @@ class LTX2DenoisingStage(PipelineStage):
audio_timestep=audio_timestep,
video_sigma=sigma_batch,
audio_sigma=sigma_batch,
keyframes_mask=keyframes_mask,
skip_video_self_attn_blocks=(stg_blocks_video if do_stg_video else None),
skip_audio_self_attn_blocks=(stg_blocks_audio if do_stg_audio else None),
video_position_offset_sec=video_position_offset_sec,
@@ -641,11 +722,86 @@ class LTX2DenoisingStage(PipelineStage):
)
dt = sigma_next - sigma
with _nvtx_range("ltx2.denoise.scheduler_update"):
velocity = ((latents.float() - pos_denoised.float()) / sigma_value).to(latents.dtype)
latents = (latents.float() + velocity.float() * dt).to(latents.dtype)
if pos_audio is not None and audio_latents is not None:
audio_velocity = ((audio_latents.float() - pos_audio.float()) / sigma_value).to(audio_latents.dtype)
audio_latents = (audio_latents.float() + audio_velocity.float() * dt).to(audio_latents.dtype)
if use_ancestral_sampler:
assert ancestral_generator is not None
# Match the official joint-AV random stream: video draws
# first, then audio, from one seed+10000 generator. The
# official loop draws on the patchified latent state, so
# sample in token order and unpatchify back to the native
# layout (same order as the initial latent draw).
video_noise = None
audio_noise = None
if bool(sigma_next != 0):
video_noise = _randn_ltx2_video_latents(
shape=tuple(latents.shape),
transformer=self.transformer,
generator=ancestral_generator,
device=latents.device,
dtype=latents.dtype,
)
if pos_audio is not None and audio_latents is not None:
audio_patchifier = getattr(self.transformer, "audio_patchifier", None)
if audio_patchifier is not None:
audio_noise_shape = AudioLatentShape.from_torch_shape(audio_latents.shape)
audio_patch_shape = (
audio_noise_shape.batch,
audio_noise_shape.frames,
audio_noise_shape.channels * audio_noise_shape.mel_bins,
)
audio_noise_patch = torch.randn(
audio_patch_shape,
generator=ancestral_generator,
device=audio_latents.device,
dtype=audio_latents.dtype,
)
audio_noise = audio_patchifier.unpatchify(audio_noise_patch, audio_noise_shape)
else:
audio_noise = torch.randn(
audio_latents.shape,
generator=ancestral_generator,
device=audio_latents.device,
dtype=audio_latents.dtype,
)
latents = _ltx2_euler_ancestral_step(
latents,
pos_denoised,
sigma,
sigma_next,
video_noise,
)
if pos_audio is not None and audio_latents is not None:
audio_latents = _ltx2_euler_ancestral_step(
audio_latents,
pos_audio,
sigma,
sigma_next,
audio_noise,
)
# Re-apply clean conditioning after stochastic noise.
# Skipped on the terminal step (sigma_next == 0): the
# denoised output is already mask-corrected above and the
# official loop does not re-apply the blend there.
if bool(sigma_next != 0):
if video_clean_latent is not None and video_denoise_mask is not None:
latents = post_process_ltx2_denoised(
denoised=latents,
denoise_mask=video_denoise_mask,
clean_latent=video_clean_latent,
)
if (audio_clean_latent is not None and audio_denoise_mask is not None
and audio_latents is not None):
audio_latents = post_process_ltx2_denoised(
denoised=audio_latents,
denoise_mask=audio_denoise_mask,
clean_latent=audio_clean_latent,
)
else:
velocity = ((latents.float() - pos_denoised.float()) / sigma_value).to(latents.dtype)
latents = (latents.float() + velocity.float() * dt).to(latents.dtype)
if pos_audio is not None and audio_latents is not None:
audio_velocity = ((audio_latents.float() - pos_audio.float()) / sigma_value).to(
audio_latents.dtype)
audio_latents = (audio_latents.float() + audio_velocity.float() * dt).to(audio_latents.dtype)
batch.latents = latents
if (batch.return_continuation_state and self.sigmas_override is not None):
@@ -222,6 +222,9 @@ class ForwardBatch:
ltx2_stg_scale_audio: float = 0.0
ltx2_stg_blocks_video: list[int] = field(default_factory=list)
ltx2_stg_blocks_audio: list[int] = field(default_factory=list)
# Enabled only by LTX-2.5 distilled presets. The refine stage supplies an
# explicit sigma override and therefore remains deterministic.
ltx2_use_ancestral_sampler: bool = False
# LTX-2 image / video / continuation conditioning
ltx2_images: list[tuple[str, int, float]] | None = None
+37 -3
View File
@@ -254,10 +254,11 @@ def _get_config_info(
if helios_model_id is not None:
logger.debug("Resolved Helios-Distilled from authoritative model index metadata.")
return _CONFIG_REGISTRY.get(helios_model_id)
variant = config.get("fastvideo_ltx2_variant", "").lower()
matched_model_names: list[str] = []
for model_id, detector in _MODEL_NAME_DETECTORS:
if detector(model_path.lower()) or detector(pipeline_name):
if detector(model_path.lower()) or detector(pipeline_name) or detector(variant):
logger.debug("Matched model name '%s' using a registered detector.", model_id)
matched_model_names.append(model_id)
@@ -331,8 +332,41 @@ def _register_configs() -> None:
pipeline_cls_name="MMAudioPipeline",
)
# LTX-2 (distilled) — registered FIRST so its detector wins over
# the base detector when both fire. The detector loop in
# LTX-2.5 distilled — registered before every older/generic LTX entry so
# its ancestral-sampling preset and architecture flags cannot be shadowed.
register_configs(
sampling_param_cls=None,
pipeline_config_cls=LTX2T2VConfig,
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
hf_model_paths=[
"FastVideo/LTX-2.5-Distilled-Diffusers",
"FastVideo/LTX2.5-Distilled-Diffusers",
],
model_detectors=[
lambda path: ("ltx-2.5" in path.lower() or "ltx2.5" in path.lower()) and "distilled" in path.lower(),
],
model_family="ltx2",
default_preset="ltx2_5_distilled_two_stage",
)
# LTX-2.5 dev — raw Lightricks split checkpoints must first be converted
# into the standard component layout consumed by FastVideo loaders.
register_configs(
sampling_param_cls=None,
pipeline_config_cls=LTX2T2VConfig,
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
hf_model_paths=[
"FastVideo/LTX-2.5-Dev-Diffusers",
"FastVideo/LTX2.5-Dev-Diffusers",
],
model_detectors=[
lambda path: ("ltx-2.5" in path.lower() or "ltx2.5" in path.lower()) and "distilled" not in path.lower(),
],
model_family="ltx2",
default_preset="ltx2_5_dev",
)
# LTX-2 (distilled) — registered before the generic base detector so its
# detector wins over the base detector when both fire. The detector loop in
# ``get_model_name_for_path`` ORs the path-based check with a
# pipeline-name check (``ltx2pipeline``) which the base detector's
# "distilled not in path" predicate matches as True (the
+9 -1
View File
@@ -378,7 +378,15 @@ class TestLtx2Presets:
import fastvideo.registry # noqa: F401
presets = get_presets_for_family("ltx2")
names = {p.name for p in presets}
assert names == {"ltx2_base", "ltx2_3_base", "ltx2_distilled", "ltx2_two_stage"}
assert names == {
"ltx2_base",
"ltx2_3_base",
"ltx2_distilled",
"ltx2_two_stage",
"ltx2_5_dev",
"ltx2_5_distilled",
"ltx2_5_distilled_two_stage",
}
def test_ltx2_base_lookup(self) -> None:
import fastvideo.registry # noqa: F401
+1
View File
@@ -187,6 +187,7 @@ nav:
- FastH3 on RTX PRO 6000: inference/fasth3_rtx_pro_6000.md
- ComfyUI: inference/comfyui.md
- Support Matrix: inference/support_matrix.md
- LTX-2.5: inference/ltx2_5.md
- Kandinsky 6 T2IVA: inference/kandinsky6.md
- Kandinsky 6 Video SR: inference/kandinsky6_sr.md
- CLI: inference/cli.md
+2
View File
@@ -25,6 +25,8 @@ dependencies = [
# transformers 5.0.0. Floor raised from >=5.0.0 to >=5.15.0 for the public
# vision interpolation helper (get_vision_interpolation_indices_and_weights)
# that the MiniMax-H3 Qwen3-VL encoder cross-checks against.
# LTX-2.5's packed Gemma 4 unified encoder also requires transformers 5.15.0+
# for correct mapping and loading of the Gemma 4 architecture.
"transformers>=5.15.0",
# <0.23: tokenizers 0.23 renamed RobertaProcessing's binding args, so
# transformers' CLIP-style tokenizer loading dies with
+3 -1
View File
@@ -23,7 +23,9 @@ dependencies = [
# Machine Learning & Transformers
# GLM-Image's AR encoder (GlmImageForConditionalGeneration) first ships in
# transformers 5.0.0; floor bumped from >=4.57.3 to >=5.0.0 (stable, not rc).
"transformers>=5.0.0",
# Kept in lockstep with pyproject.toml: >=5.15.0 for the MiniMax-H3 vision
# interpolation helper and LTX-2.5's packed Gemma 4 unified encoder mapping.
"transformers>=5.15.0",
"tokenizers>=0.20.1",
"sentencepiece>=0.2.0",
"timm>=1.0.11",
@@ -14,6 +14,18 @@ Example usage:
--pipeline-class-name "LTX2Pipeline" \\
--diffusers-version "0.33.0.dev0" \\
--gemma-path "<PATH_TO_LOCAL_REPO>/google/gemma-3-12b-it"
LTX-2.5 uses separate official component files. Convert that layout with:
python scripts/checkpoint_conversion/convert_ltx2_weights.py \
--transformer-source diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors \
--text-encoder-source text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors \
--vae-source vae/ltx-2.5-video-vae-conv-bf16.safetensors \
--audio-vae-source vae/ltx-2.5-audio-vae-bf16.safetensors \
--spatial-upscaler-source latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors \
--distilled-lora-source loras/ltx-2.5-22b-distilled-lora-450-bf16.safetensors \
--variant dev \
--output converted_weights/ltx2-5-dev
"""
from __future__ import annotations
@@ -26,6 +38,7 @@ import re
import shutil
from collections import OrderedDict
from pathlib import Path
from typing import Any
import torch
from safetensors import safe_open
@@ -48,6 +61,35 @@ COMPONENT_PREFIXES: dict[str, tuple[str, ...]] = {
"text_embedding_projection": ("text_embedding_projection.", "model.text_embedding_projection."),
}
PACKED_GEMMA_CONFIG_METADATA_KEY = "gemma_config"
PACKED_GEMMA_TOKENIZER_KEY = "tokenizer_json"
PACKED_GEMMA_ASSET_PREFIX = "hf_asset__"
SPLIT_SOURCE_ARGUMENTS = (
"transformer_source",
"text_encoder_source",
"vae_source",
"audio_vae_source",
"spatial_upscaler_source",
)
TEXT_PROJECTION_PREFIXES: dict[str, str] = {
"text_embedding_projection.aggregate_embed.": "feature_extractor_linear.aggregate_embed.",
"text_embedding_projection.video_aggregate_embed.": "video_feature_extractor_linear.",
"text_embedding_projection.audio_aggregate_embed.": "audio_feature_extractor_linear.",
"model.text_embedding_projection.aggregate_embed.": "feature_extractor_linear.aggregate_embed.",
"model.text_embedding_projection.video_aggregate_embed.": "video_feature_extractor_linear.",
"model.text_embedding_projection.audio_aggregate_embed.": "audio_feature_extractor_linear.",
}
TEXT_CONNECTOR_PREFIXES: dict[str, str] = {
"model.diffusion_model.video_embeddings_connector.": "embeddings_connector.",
"model.diffusion_model.audio_embeddings_connector.": "audio_embeddings_connector.",
"model.diffusion_model.embeddings_connector.": "embeddings_connector.",
"video_embeddings_connector.": "embeddings_connector.",
"audio_embeddings_connector.": "audio_embeddings_connector.",
}
def _find_shards(model_path: Path) -> list[Path]:
if model_path.is_file():
@@ -83,6 +125,18 @@ def _read_metadata_config(path: Path) -> dict:
return json.loads(metadata["config"])
def _read_safetensors_metadata(path: Path) -> dict[str, str]:
with safe_open(str(path), framework="pt") as f:
return dict(f.metadata() or {})
def _metadata_config_for_component(metadata_config: dict, component_name: str) -> dict:
component_config = metadata_config.get(component_name)
if isinstance(component_config, dict):
return component_config
return metadata_config
def _filter_transformer_config(config: dict) -> dict:
transformer = config.get("transformer", {})
allowed = {
@@ -108,6 +162,28 @@ def _filter_transformer_config(config: dict) -> dict:
"audio_cross_attention_dim",
"audio_positional_embedding_max_pos",
"av_ca_timestep_scale_multiplier",
# LTX-2.3 architecture extensions.
"cross_attention_adaln",
"caption_proj_before_connector",
"apply_gated_attention",
"caption_projection_first_linear",
"caption_proj_input_norm",
"caption_projection_second_linear",
"connector_num_attention_heads",
"connector_attention_head_dim",
"connector_num_layers",
"audio_connector_num_attention_heads",
"audio_connector_attention_head_dim",
"audio_connector_num_layers",
"connector_positional_embedding_max_pos",
"connector_apply_gated_attention",
"connector_ff_bias",
# LTX-2.5 architecture extensions. Defaults live in the native model
# config, so preserving an explicit False is important.
"use_prompt_adaln_single",
"ff_bias",
"audio_ff_bias",
"use_keyframes_abs_pos_embedding",
}
filtered = {k: v for k, v in transformer.items() if k in allowed}
if "frequencies_precision" in filtered:
@@ -141,6 +217,170 @@ def _build_text_embedding_projection_config(gemma_model_path: str = "", ) -> dic
}
def _gemma_text_config(gemma_config: dict) -> dict:
text_config = gemma_config.get("text_config")
if isinstance(text_config, dict):
return text_config
return gemma_config
def _build_split_text_encoder_config(transformer_config: dict, gemma_config: dict) -> dict:
text_config = _gemma_text_config(gemma_config)
hidden_size = int(text_config.get("hidden_size", 3840))
num_hidden_layers = int(text_config.get("num_hidden_layers", 48))
video_inner_dim = int(transformer_config.get("num_attention_heads", 30)) * int(
transformer_config.get("attention_head_dim", 128))
audio_inner_dim = int(transformer_config.get("audio_num_attention_heads", 30)) * int(
transformer_config.get("audio_attention_head_dim", 128))
eos_token_id = gemma_config.get("eos_token_id", text_config.get("eos_token_id", 2))
if isinstance(eos_token_id, list):
eos_token_id = eos_token_id[0] if eos_token_id else 2
config = _build_text_embedding_projection_config(gemma_model_path="gemma")
config.update({
"hidden_size": hidden_size,
"num_hidden_layers": num_hidden_layers,
"num_attention_heads": int(text_config.get("num_attention_heads", 30)),
# The packed LTX tokenizer always pads/truncates to 1024 regardless of
# the Gemma backbone's much larger context window.
"text_len": 1024,
"pad_token_id": int(gemma_config.get("pad_token_id", text_config.get("pad_token_id", 0)) or 0),
"bos_token_id": int(gemma_config.get("bos_token_id", text_config.get("bos_token_id", 2)) or 2),
"eos_token_id": int(eos_token_id),
"feature_extractor_in_features": hidden_size * (num_hidden_layers + 1),
"feature_extractor_out_features": video_inner_dim,
"video_feature_extractor_out_features": video_inner_dim,
"audio_feature_extractor_out_features": audio_inner_dim,
"caption_proj_before_connector": bool(transformer_config.get("caption_proj_before_connector", False)),
"caption_projection_first_linear": bool(transformer_config.get("caption_projection_first_linear", True)),
"caption_proj_input_norm": bool(transformer_config.get("caption_proj_input_norm", True)),
"caption_projection_second_linear": bool(transformer_config.get("caption_projection_second_linear", True)),
"connector_num_attention_heads": int(transformer_config.get("connector_num_attention_heads", 30)),
"connector_attention_head_dim": int(transformer_config.get("connector_attention_head_dim", 128)),
"connector_num_layers": int(transformer_config.get("connector_num_layers", 2)),
"audio_connector_num_attention_heads": int(
transformer_config.get(
"audio_connector_num_attention_heads",
transformer_config.get("connector_num_attention_heads", 30),
)),
"audio_connector_attention_head_dim": int(
transformer_config.get(
"audio_connector_attention_head_dim",
transformer_config.get("connector_attention_head_dim", 128),
)),
"audio_connector_num_layers": int(
transformer_config.get(
"audio_connector_num_layers",
transformer_config.get("connector_num_layers", 2),
)),
"connector_positional_embedding_max_pos": transformer_config.get(
"connector_positional_embedding_max_pos", [4096]),
"connector_rope_type": transformer_config.get("rope_type", "split"),
"connector_apply_gated_attention": bool(transformer_config.get("connector_apply_gated_attention", False)),
"connector_ff_bias": bool(transformer_config.get("connector_ff_bias", True)),
"connector_double_precision_rope": transformer_config.get("frequencies_precision") == "float64",
})
return config
def _tensor_to_bytes(tensor: torch.Tensor, key: str) -> bytes:
if tensor.dtype != torch.uint8 or tensor.ndim != 1:
raise ValueError(f"Packed Gemma asset {key!r} must be a 1-D uint8 tensor, got {tensor.dtype} {tensor.shape}.")
return bytes(tensor.cpu().tolist())
def _map_packed_gemma_weight_key(key: str) -> str:
"""Map the official Comfy-flat Gemma 4 pack back to HF key names.
The official packer flattens the language tower and renames vision/audio
components. Already-HF keys pass through unchanged so repacked checkpoints
are also accepted.
"""
if key.startswith("model.language_model.") or key.startswith("model.vision_embedder."):
return key
if key.startswith(("model.layers.", "model.embed_tokens.", "model.norm.")):
return "model.language_model." + key.removeprefix("model.")
if key.startswith("vision_model."):
return "model.vision_embedder." + key.removeprefix("vision_model.")
if key.startswith("multi_modal_projector."):
return "model.embed_vision." + key.removeprefix("multi_modal_projector.")
if key.startswith("audio_projector."):
return "model.embed_audio." + key.removeprefix("audio_projector.")
return key
def _route_text_projection_key(key: str) -> str | None:
for prefix, replacement in TEXT_PROJECTION_PREFIXES.items():
if key.startswith(prefix):
return replacement + key[len(prefix):]
for prefix, replacement in TEXT_CONNECTOR_PREFIXES.items():
if key.startswith(prefix):
return replacement + key[len(prefix):]
return None
def _write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(value, f, indent=2)
f.write("\n")
def _write_bytes(path: Path, value: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(value)
def _unpack_packed_gemma(
source_path: Path,
output_dir: Path,
) -> tuple[OrderedDict, dict]:
shards = _find_shards(source_path)
if len(shards) != 1:
raise ValueError("Packed LTX-2.5 text encoder must resolve to exactly one safetensors file.")
source_file = shards[0]
metadata = _read_safetensors_metadata(source_file)
raw_gemma_config = metadata.get(PACKED_GEMMA_CONFIG_METADATA_KEY)
if raw_gemma_config is None:
raise ValueError(
f"Packed text encoder {source_file} is missing {PACKED_GEMMA_CONFIG_METADATA_KEY!r} metadata.")
gemma_config = json.loads(raw_gemma_config)
gemma_weights: OrderedDict[str, torch.Tensor] = OrderedDict()
projection_weights: OrderedDict[str, torch.Tensor] = OrderedDict()
tokenizer_assets: dict[str, bytes] = {}
for key, tensor in _load_weights(shards).items():
projection_key = _route_text_projection_key(key)
if projection_key is not None:
projection_weights[projection_key] = tensor
elif key == PACKED_GEMMA_TOKENIZER_KEY:
tokenizer_assets["tokenizer.json"] = _tensor_to_bytes(tensor, key)
elif key.startswith(PACKED_GEMMA_ASSET_PREFIX):
asset_name = key.removeprefix(PACKED_GEMMA_ASSET_PREFIX)
# Untrusted safetensors keys must not escape the output tree:
# reject separators and dot-segments so the joined path stays a
# single file name inside the target tokenizer directories.
if not asset_name or asset_name in {".", ".."} or "/" in asset_name or "\\" in asset_name:
raise ValueError(f"Unsafe packed Gemma asset name {asset_name!r} from tensor key {key!r}.")
tokenizer_assets[asset_name] = _tensor_to_bytes(tensor, key)
else:
gemma_weights[_map_packed_gemma_weight_key(key)] = tensor
if not gemma_weights:
raise ValueError(f"Packed text encoder {source_file} did not contain Gemma model weights.")
if "tokenizer.json" not in tokenizer_assets:
raise ValueError(f"Packed text encoder {source_file} did not contain tokenizer_json.")
gemma_dir = output_dir / "text_encoder" / "gemma"
gemma_dir.mkdir(parents=True, exist_ok=True)
save_file(gemma_weights, str(gemma_dir / "model.safetensors"))
_write_json(gemma_dir / "config.json", gemma_config)
for asset_name, asset_bytes in tokenizer_assets.items():
_write_bytes(gemma_dir / asset_name, asset_bytes)
_write_bytes(output_dir / "tokenizer" / asset_name, asset_bytes)
return projection_weights, gemma_config
def _wrap_component_config(
component_name: str,
component_config: dict | None,
@@ -179,6 +419,59 @@ def _split_component_weights(weights: dict[str, torch.Tensor]) -> dict[str, Orde
return {name: weights for name, weights in components.items() if weights}
def _strip_first_matching_prefix(key: str, prefixes: tuple[str, ...]) -> str:
for prefix in prefixes:
if key.startswith(prefix):
return key[len(prefix):]
return key
def _split_transformer_and_connectors(
weights: dict[str, torch.Tensor],
) -> tuple[OrderedDict, OrderedDict]:
transformer: OrderedDict[str, torch.Tensor] = OrderedDict()
text_encoder: OrderedDict[str, torch.Tensor] = OrderedDict()
for key, tensor in weights.items():
text_key = _route_text_projection_key(key)
if text_key is not None:
text_encoder[text_key] = tensor
continue
transformer_key = _strip_first_matching_prefix(
key,
("model.diffusion_model.", "diffusion_model."),
)
transformer[transformer_key] = tensor
return transformer, text_encoder
def _split_audio_vae_source(weights: dict[str, torch.Tensor]) -> tuple[OrderedDict, OrderedDict]:
audio_vae: OrderedDict[str, torch.Tensor] = OrderedDict()
vocoder: OrderedDict[str, torch.Tensor] = OrderedDict()
for key, tensor in weights.items():
if key.startswith("audio_vae."):
audio_vae[key.removeprefix("audio_vae.")] = tensor
elif key.startswith("vocoder."):
# Strip exactly one prefix. LTX-2.3+ BWE keys intentionally keep
# the second "vocoder." segment.
vocoder[key.removeprefix("vocoder.")] = tensor
if not audio_vae:
raise ValueError("Audio VAE source contains no keys with the 'audio_vae.' prefix.")
if not vocoder:
raise ValueError("Audio VAE source contains no keys with the 'vocoder.' prefix.")
return audio_vae, vocoder
def _component_weights(
source_path: Path,
prefixes: tuple[str, ...],
) -> OrderedDict:
shards = _find_shards(source_path)
if not shards:
raise FileNotFoundError(f"No safetensors found in {source_path}")
return OrderedDict(
(_strip_first_matching_prefix(key, prefixes), tensor) for key, tensor in _load_weights(shards).items())
def _write_component(
output_dir: Path,
name: str,
@@ -218,6 +511,33 @@ def _build_model_index(
}
def _build_split_model_index(
transformer_class_name: str,
pipeline_class_name: str,
diffusers_version: str,
variant: str,
distilled_lora: bool,
) -> dict:
model_index = _build_model_index(
transformer_class_name=transformer_class_name,
vae_class_name="CausalVideoAutoencoder",
pipeline_class_name=pipeline_class_name,
diffusers_version=diffusers_version,
)
model_index.update({
"spatial_upsampler": ["diffusers", "LTX2LatentUpsampler"],
"fastvideo_ltx2_variant": f"ltx2.5-{variant}",
"fastvideo_refine_enabled": variant == "distilled" or distilled_lora,
"fastvideo_refine_upsampler_path": "spatial_upsampler",
"fastvideo_refine_num_inference_steps": 3,
"fastvideo_refine_guidance_scale": 1.0,
"fastvideo_refine_add_noise": True,
})
if distilled_lora:
model_index["fastvideo_refine_lora_path"] = "distilled_lora/model.safetensors"
return model_index
def _write_model_index(output_dir: Path, model_index: dict) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
model_index_path = output_dir / "model_index.json"
@@ -313,6 +633,119 @@ def convert_components(
_write_model_index(output_dir, model_index)
def _source_metadata_config(source_path: Path) -> dict:
shards = _find_shards(source_path)
if not shards:
raise FileNotFoundError(f"No safetensors found in {source_path}")
return _read_metadata_config(shards[0])
def convert_split_components(
*,
transformer_source: Path,
text_encoder_source: Path,
vae_source: Path,
audio_vae_source: Path,
spatial_upscaler_source: Path,
output_dir: Path,
transformer_class_name: str,
variant: str,
distilled_lora_source: Path | None = None,
emit_diffusers_repo: bool = True,
pipeline_class_name: str = "LTX2Pipeline",
diffusers_version: str = "0.33.0.dev0",
) -> None:
"""Convert the official LTX-2.5 separate-component checkpoint layout.
The public LTX-2.5 repository describes these files as independent
safetensors. Exact real-weight strict loading remains a post-conversion
verification step because the repository is gated; this function keeps all
routing explicit so key drift fails visibly rather than being dropped.
"""
transformer_metadata = _source_metadata_config(transformer_source)
full_transformer_config = _metadata_config_for_component(transformer_metadata, "transformer")
transformer_config = _filter_transformer_config(transformer_metadata)
if not transformer_config:
raise ValueError("Transformer source is missing config.transformer metadata.")
transformer_config["_class_name"] = transformer_class_name
transformer_shards = _find_shards(transformer_source)
transformer_weights, connector_weights = _split_transformer_and_connectors(_load_weights(transformer_shards))
if not transformer_weights:
raise ValueError("Transformer source did not contain transformer weights.")
output_dir.mkdir(parents=True, exist_ok=True)
projection_weights, gemma_config = _unpack_packed_gemma(text_encoder_source, output_dir)
projection_weights.update(connector_weights)
if not projection_weights:
raise ValueError("Split sources did not contain LTX text projections or connectors.")
text_encoder_config = _build_split_text_encoder_config(full_transformer_config, gemma_config)
vae_metadata = _source_metadata_config(vae_source)
vae_config = _metadata_config_for_component(vae_metadata, "vae")
vae_class_name = vae_config.get("_class_name", "CausalVideoAutoencoder")
if vae_class_name != "CausalVideoAutoencoder":
raise ValueError(
"--vae-source must be the LTX-2.5 convolutional VAE; "
f"metadata declares {vae_class_name!r}.")
vae_weights = _component_weights(vae_source, ("vae.", "model.vae."))
audio_metadata = _source_metadata_config(audio_vae_source)
audio_weights, vocoder_weights = _split_audio_vae_source(
_load_weights(_find_shards(audio_vae_source)))
audio_vae_config = _metadata_config_for_component(audio_metadata, "audio_vae")
vocoder_config = _metadata_config_for_component(audio_metadata, "vocoder")
if audio_vae_config is audio_metadata or vocoder_config is audio_metadata:
raise ValueError("Audio VAE source metadata must contain both config.audio_vae and config.vocoder.")
upscaler_metadata = _source_metadata_config(spatial_upscaler_source)
upscaler_config = dict(upscaler_metadata)
upscaler_config["_class_name"] = "LTX2LatentUpsampler"
upscaler_weights = _component_weights(
spatial_upscaler_source,
("spatial_upscaler.", "spatial_upsampler.", "upsampler.", "model."),
)
upscaler_weights = OrderedDict((f"upsampler.{key}" if key.split(".", 1)[0].isdigit() else key, value)
for key, value in upscaler_weights.items())
_write_component(output_dir, "transformer", transformer_weights, transformer_config)
_write_component(output_dir, "text_encoder", projection_weights, text_encoder_config)
_write_component(
output_dir,
"vae",
vae_weights,
_wrap_component_config("vae", vae_config, class_name="CausalVideoAutoencoder"),
)
_write_component(
output_dir,
"audio_vae",
audio_weights,
_wrap_component_config("audio_vae", audio_vae_config, class_name="LTX2AudioDecoder"),
)
_write_component(
output_dir,
"vocoder",
vocoder_weights,
_wrap_component_config("vocoder", vocoder_config, class_name="LTX2Vocoder"),
)
_write_component(output_dir, "spatial_upsampler", upscaler_weights, upscaler_config)
has_distilled_lora = distilled_lora_source is not None
if distilled_lora_source is not None:
lora_weights = _component_weights(distilled_lora_source, ())
_write_component(output_dir, "distilled_lora", lora_weights, config=None)
if emit_diffusers_repo:
model_index = _build_split_model_index(
transformer_class_name=transformer_class_name,
pipeline_class_name=pipeline_class_name,
diffusers_version=diffusers_version,
variant=variant,
distilled_lora=has_distilled_lora,
)
_write_model_index(output_dir, model_index)
def update_transformer_config(config_path: Path, class_name: str) -> None:
if not config_path.exists():
print(f"Config file not found: {config_path}")
@@ -411,9 +844,84 @@ def main() -> None:
default="",
help="Optional local Gemma model path to copy into the output repo.",
)
parser.add_argument(
"--transformer-source",
type=str,
help="LTX-2.5 split transformer safetensors file or sharded directory.",
)
parser.add_argument(
"--text-encoder-source",
type=str,
help="LTX-2.5 packed Gemma 4 text-encoder safetensors file.",
)
parser.add_argument(
"--vae-source",
type=str,
help="LTX-2.5 convolutional video VAE safetensors file or sharded directory.",
)
parser.add_argument(
"--audio-vae-source",
type=str,
help="LTX-2.5 audio VAE safetensors containing audio_vae.* and vocoder.* keys.",
)
parser.add_argument(
"--spatial-upscaler-source",
type=str,
help="LTX-2.5 spatial latent upscaler safetensors file or sharded directory.",
)
parser.add_argument(
"--distilled-lora-source",
type=str,
help="Optional LTX-2.5 distilled LoRA safetensors used for dev stage-2 refinement.",
)
parser.add_argument(
"--variant",
choices=("dev", "distilled"),
default="dev",
help="LTX-2.5 split transformer variant; controls bundled refine defaults.",
)
args = parser.parse_args()
split_values = {name: getattr(args, name) for name in SPLIT_SOURCE_ARGUMENTS}
split_mode = any(value is not None for value in split_values.values())
if args.distilled_lora_source is not None and not split_mode:
raise ValueError("--distilled-lora-source is only valid with the LTX-2.5 split source arguments.")
if split_mode:
missing = [f"--{name.replace('_', '-')}" for name, value in split_values.items() if value is None]
if missing:
raise ValueError("LTX-2.5 split conversion requires: " + ", ".join(missing))
incompatible = []
if args.source:
incompatible.append("--source")
if args.download:
incompatible.append("--download")
if args.gemma_path:
incompatible.append("--gemma-path")
if args.transformer_only:
incompatible.append("--transformer-only")
if args.components:
incompatible.append("--components")
if args.update_config:
incompatible.append("--update-config")
if incompatible:
raise ValueError("LTX-2.5 split source arguments cannot be combined with " + ", ".join(incompatible))
convert_split_components(
transformer_source=Path(args.transformer_source),
text_encoder_source=Path(args.text_encoder_source),
vae_source=Path(args.vae_source),
audio_vae_source=Path(args.audio_vae_source),
spatial_upscaler_source=Path(args.spatial_upscaler_source),
distilled_lora_source=(Path(args.distilled_lora_source) if args.distilled_lora_source else None),
output_dir=Path(args.output),
transformer_class_name=args.class_name,
variant=args.variant,
emit_diffusers_repo=args.diffusers_repo,
pipeline_class_name=args.pipeline_class_name,
diffusers_version=args.diffusers_version,
)
return
if args.download:
if args.source:
raise ValueError("Use either --download or --source, not both.")
@@ -306,9 +306,17 @@ def test_ltx2_typed_surface_preflight() -> None:
LTX2AudioDecodingStage, LTX2DenoisingStage, LTX2LatentPreparationStage, LTX2TextEncodingStage,
)
# All four LTX-2 presets registered.
# All LTX-2/2.3/2.5 presets registered.
names = {p.name for p in get_presets_for_family("ltx2")}
assert names == {"ltx2_base", "ltx2_3_base", "ltx2_distilled", "ltx2_two_stage"}
assert names == {
"ltx2_base",
"ltx2_3_base",
"ltx2_distilled",
"ltx2_two_stage",
"ltx2_5_dev",
"ltx2_5_distilled",
"ltx2_5_distilled_two_stage",
}
# Two-stage preset has the denoise + refine topology and pulls its
# refine allowed_overrides from the typed dataclass.
+99
View File
@@ -0,0 +1,99 @@
# LTX-2.5 Port Status
## Summary
- model_family: `ltx2_5`
- workload_types: `T2AV, I2AV` through the existing T2V/I2V compatibility surface
- official_ref: `https://github.com/Lightricks/LTX-2`
- official_ref_dir: `LTX-2-Reference/`
- hf_weights_path: `Lightricks/LTX-2.5`
- local_weights_dir: `Modal-backed Hugging Face cache`
- source_layout: `separate_components`
- local_tests_readme: `tests/local_tests/ltx2_5/README.md`
## Current Phase
- phase: `launch_validation`
- status: `complete`
- owner: `orchestrator`
- last_updated: `2026-08-12`
## Component Matrix
| Component | Type | Reuse/Port | Official Definition | Official Instantiation | FastVideo Target | Prototype | Conversion | Parity | Open Issues |
|---|---|---|---|---|---|---|---|---|---|
| LTX-2.5 transformer | dit | extend | `packages/ltx-core/src/ltx_core/model/transformer/` | `LTXModelConfigurator.from_metadata` | `fastvideo/models/dits/ltx2.py`, `fastvideo/configs/models/dits/ltx2.py` | implemented | real_split_pass | real_strict_pass | — |
| Gemma 4 unified encoder/connector | encoder | port | `packages/ltx-core/src/ltx_core/text_encoders/gemma/` | Gemma encoder configurator + prompt encoder block | `fastvideo/models/encoders/gemma.py`, `fastvideo/configs/models/encoders/gemma.py` | implemented | real_split_pass | real_pipeline_pass | — |
| Convolutional video VAE | vae | extend | `packages/ltx-core/src/ltx_core/model/video_vae/` | `VideoEncoderConfigurator` / `VideoDecoderConfigurator` from checkpoint metadata | `fastvideo/models/vaes/ltx2vae.py`, `fastvideo/configs/models/vaes/ltx2vae.py` | implemented | real_split_pass | real_pipeline_pass | — |
| Audio VAE/vocoder | vae | reuse | `packages/ltx-core/src/ltx_core/model/audio_vae/` | audio VAE/vocoder configurators from checkpoint metadata | existing LTX-2 audio decoder/vocoder | reused | real_split_pass | real_pipeline_pass | — |
| Euler ancestral sampling | generic | port | official diffusion step and distilled pipeline selection | distilled preset | `fastvideo/pipelines/basic/ltx2/stages/ltx2_denoising.py` | implemented | stateless | unit_pass | — |
| T2AV/I2AV pipeline | pipeline | extend | official one-stage and distilled pipelines | split component paths + official defaults | `fastvideo/pipelines/basic/ltx2/` | implemented | real_split_pass | real_t2av_pass | — |
## Conversion State
- conversion_script: `scripts/checkpoint_conversion/convert_ltx2_weights.py`
- converted_weights_dir: `converted_weights/ltx2_5`
- source_layout: `separate_components`
- strict_load_status: `dev_and_distilled_pass`
- passthrough_components: `packed Gemma tokenizer/assets are unpacked into text_encoder/gemma and tokenizer`
- retry_history: `I002 resolved before conversion: emitted config allowlist now preserves the 2.3/2.5 gates`
## Parity Commands
| Scope | Command | Last Result | Notes |
|---|---|---|---|
| lightweight launch suite | `pytest -q tests/local_tests/ltx2_5 tests/local_tests/transformers/test_ltx2_5_transformer_parity.py -k 'not real_weights'` | 14 passed, 7 skipped, 2 deselected | Modal L40S before official source clone; skips were reference-dependent |
| transformer source parity | `pytest -q -rs tests/local_tests/transformers/test_ltx2_5_transformer_parity.py -k 'not real_weights'` | 8 passed, 2 deselected | Same live Modal L40S after cloning official v1.2.0 source |
| expanded launch/legacy suite | `pytest -q tests/local_tests/ltx2_5 tests/local_tests/transformers/test_ltx2_5_transformer_parity.py fastvideo/tests/api/test_presets.py::TestLtx2Presets::test_ltx2_presets_registered tests/local_tests/ltx2/test_ltx2_pipeline_smoke.py::test_ltx2_typed_surface_preflight -k 'not real_weights'` | 24 passed, 2 deselected | Same live Modal L40S; includes first-frame keyframe-mask wiring and legacy preset regression checks |
| real dev strict load | `pytest -q -rs 'tests/local_tests/transformers/test_ltx2_5_transformer_parity.py::test_real_weights_load_strictly_through_production_loader[dev]'` | 1 passed | Modal L40S; official builder and production `TransformerLoader`; 28.76s |
| real distilled strict load | `pytest -q -rs 'tests/local_tests/transformers/test_ltx2_5_transformer_parity.py::test_real_weights_load_strictly_through_production_loader[distilled]'` | 1 passed | Same live Modal L40S; official builder and production `TransformerLoader`; 27.06s |
| split conversion | `pytest -q tests/local_tests/ltx2_5/test_ltx2_5_conversion.py` | pass in launch suite | Synthetic files validate component routing, packed assets, and metadata |
| Gemma/VAE/sampler/registry | `pytest -q tests/local_tests/ltx2_5 -k 'not conversion'` | pass in launch suite | Focused structural and stateless behavior coverage |
| real distilled one-stage T2AV | `basic_ltx2_5_t2av.py --variant distilled ... --steps 1 --num-gpus 2` | pass | 2xH100; joint H.264/AAC output, 9 video frames and 17 audio frames; generation 11.77s |
| real distilled two-stage T2AV | `basic_ltx2_5_t2av.py --variant distilled ... --steps 1 --num-gpus 2` | pass | 2xB200; base ancestral denoise, 2x spatial upsample, 1,632-layer LoRA, three-step refine, joint H.264/AAC output; generation 76.17s |
## Open Questions
| ID | Question | Owner | Needed By Phase | Status | Resolution |
|---|---|---|---|---|---|
| Q001 | Which exact 2.5 transformer metadata fields and state-dict deltas are required beyond 2.3? | component:transformer | prototype | resolved | `use_prompt_adaln_single`, `ff_bias`, `audio_ff_bias`, and `use_keyframes_abs_pos_embedding`; defaults are True, True, True, False. |
| Q002 | Does the published convolutional video VAE instantiate the existing native VAE without architecture changes? | component:video_vae | prototype | resolved | Existing graph is reused with checkpoint-derived `decoder_base_channels=128`. |
| Q003 | Does the published audio VAE/BWE stack instantiate the existing native audio path unchanged? | component:audio_vae | prototype | resolved | Official v1.2.0 retains the LTX-2.3 audio VAE/vocoder/BWE topology; real joint video-audio pipeline generation passes. |
| Q004 | Can ancestral sampling reuse an existing FastVideo scheduler while preserving official per-step RNG? | component:scheduler | parity | resolved | Implemented the official rectified-flow Euler ancestral step in the LTX denoising stage, including seed offset and video-before-audio RNG order. |
| Q005 | Which official dev/distilled T2AV and I2AV defaults should become public FastVideo presets? | pipeline | pipeline | resolved | Dev uses 30-step CFG/STG guidance at 512x768; distilled uses eight-step ancestral denoising and optional three-step 2x refine. |
## Issues And Blockers
| ID | Phase | Component | Severity | Issue | Evidence | Owner | Status | Resolution |
|---|---|---|---|---|---|---|---|---|
| I001 | parity | all | high | Real LTX-2.5 artifact loading and end-to-end generation were blocked by gated Hugging Face access. | Pinned revision `ce298b1259d61ce6c87e05154b9ad339b16f32a0` now downloads successfully in the HAO AI Lab workspace. | repository owners | resolved | Repository access was approved; both BF16 transformer variants now pass official and FastVideo strict loading. |
| I002 | conversion | transformer | high | Transformer config filtering dropped the 2.3 and 2.5 architecture gates, so converted metadata could instantiate the legacy parameter surface. | `scripts/checkpoint_conversion/convert_ltx2_weights.py::_filter_transformer_config` allowlist review. | component:conversion | resolved | Allowlist now preserves `cross_attention_adaln`, `caption_proj_before_connector`, `apply_gated_attention`, `use_prompt_adaln_single`, `ff_bias`, `audio_ff_bias`, and `use_keyframes_abs_pos_embedding`. |
## Escape Hatches
| ID | Phase | Decision Type | Question | Recommended Option | Status | Resolution |
|---|---|---|---|---|---|---|
## Decisions
| Date | Decision | Rationale | Impact |
|---|---|---|---|
| 2026-08-12 | Build a complete launch inference port even if the PR is H3-sized. | Users should receive meaningful FastVideo acceleration immediately after model launch. | Dev/distilled T2AV/I2AV, native components, examples, quality coverage, and multi-GPU verification belong in the first PR. |
| 2026-08-12 | Use the official convolutional video VAE in the launch PR. | It provides authentic 2.5 inference without coupling launch support to the separate iterative DiffVAE subsystem. | DiffVAE/NATTEN/CuTe work follows independently. |
| 2026-08-12 | Keep work local until a coherent tested checkpoint, then open a non-draft PR on the fork. | CodeRabbit should review real progress while incomplete experimental fragments stay private. | Every later push is preceded by review-feedback triage. |
| 2026-08-12 | Extend the native LTX-2 transformer behind metadata-compatible gates. | Official 2.5 preserves the LTX-2.3 graph and changes only prompt AdaLN, FFN biases, and keyframe token embedding surfaces. | Existing LTX-2/2.3 configs retain byte-compatible parameter surfaces by default. |
## Handoff Notes
- Branch: `aryan/ltx-2-5-initial-support`, based on upstream `8208536cd`.
- Official source: `v1.2.0` at `d151147788a9284cca791edc6ce898007e727fe6`.
- Active Modal profile: `ai-lab`; workspace verified as `hao-ai-lab`.
- Modal policy: reuse one warm GPU-backed shell for iterative checks; do not
create a fresh app per test command.
- Transformer prototype targets the pinned official `ltx-core` v1.2.0 graph and
preserves FastVideo sequence-parallel and compile block boundaries.
- Official source/random transformer parity and both real BF16 transformer
strict loads pass in Modal. Real one-stage generation passes on 2xH100, and
the full distilled upsample/refine path passes on 2xB200 with verified H.264
video and AAC audio streams. The tracked launch run is
`https://wandb.ai/aryan5v-san-jose-state-university/fastvideo-ltx2-5/runs/o9ixwewk`.
+151
View File
@@ -0,0 +1,151 @@
# LTX-2.5 Local Tests
Local parity, conversion, and smoke tests for the native LTX-2.5 FastVideo
port. The launch scope is BF16 dev and distilled text/image-conditioned joint
video-audio inference. Training, QAD/NVFP4, DiffVAE, DFR, HDR, temporal
upsampling, and automatic duration prediction are follow-up work.
Port progress and unresolved questions live in
`tests/local_tests/ltx2_5/PORT_STATUS.md`.
## Reference Assets
| Field | Value |
|---|---|
| Model family | `ltx2_5` (config-gated extension of native `ltx2`) |
| Workload types | T2AV and I2AV through the existing T2V/I2V compatibility surface; output is joint video and audio |
| Official reference | `https://github.com/Lightricks/LTX-2` |
| Local reference dir | `LTX-2-Reference/` (gitignored) |
| Official commit/version | `v1.2.0`, `d151147788a9284cca791edc6ce898007e727fe6` |
| HF weights | `Lightricks/LTX-2.5` |
| HF revision | `ce298b1259d61ce6c87e05154b9ad339b16f32a0` |
| Local weights dir | Modal-backed Hugging Face cache; no local weight download |
| Source layout | Separate component safetensors with checkpoint metadata; no root `model_index.json` |
| Needs conversion | Yes |
Never place token values in this file. GPU validation resolves `HF_TOKEN` from
an approved secret in the HAO AI Lab Modal workspace.
## Shared Environment Setup
The source clone is reproducible from the repository root:
```bash
python3 .agents/skills/add-model-01-prep/scripts/clone_reference_repo.py \
https://github.com/Lightricks/LTX-2.git \
LTX-2-Reference \
--branch v1.2.0 \
--commit d151147788a9284cca791edc6ce898007e727fe6 \
--update-gitignore
```
The local FastVideo `.venv` does not currently contain PyTorch. Do not change
FastVideo's core pins on this Mac solely for parity. Numeric checks reuse one
warm HAO AI Lab Modal L40S shell with FastVideo and the pinned official source
installed together.
```text
dependency_changes: transformers>=5.15.0 for Gemma 4
official_env_status: source parity passing in one shared Modal environment
private_dep_stubs: none planned
gated_access_status: approved and verified at the pinned revision
```
## Weight Layout
The published gated repository exposes these inference components:
```text
diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors
diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors
text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors
vae/ltx-2.5-video-vae-conv-bf16.safetensors
vae/ltx-2.5-audio-vae-bf16.safetensors
```
The first PR deliberately uses the official convolutional video VAE. The
diffusion VAE is a separate iterative neighborhood-attention decoder and is not
required for authentic LTX-2.5 launch inference.
## Prototype And Conversion Artifacts
```text
official_key_dumps:
transformer: converted_weights/ltx2_5/_mapping/transformer_official_keys.json
text_encoder: converted_weights/ltx2_5/_mapping/text_encoder_official_keys.json
video_vae: converted_weights/ltx2_5/_mapping/video_vae_official_keys.json
audio_vae: converted_weights/ltx2_5/_mapping/audio_vae_official_keys.json
fastvideo_key_dumps:
transformer: converted_weights/ltx2_5/_mapping/transformer_fastvideo_keys.json
text_encoder: converted_weights/ltx2_5/_mapping/text_encoder_fastvideo_keys.json
video_vae: converted_weights/ltx2_5/_mapping/video_vae_fastvideo_keys.json
audio_vae: converted_weights/ltx2_5/_mapping/audio_vae_fastvideo_keys.json
conversion_script: scripts/checkpoint_conversion/convert_ltx2_weights.py
conversion_source_layout: separate_components
converted_weights_dir: converted_weights/ltx2_5
strict_load_status: dev_and_distilled_pass
```
## Expected Parity Tests
| Component | Official files / args | Test | Concerns | Status |
|---|---|---|---|---|
| Transformer | `ltx_core.model.transformer.model_configurator.LTXModelConfigurator`; checkpoint metadata | `tests/local_tests/transformers/test_ltx2_5_transformer_parity.py` | Official random FFN/keyframe/prompt-AdaLN parity plus strict dev/distilled production loading | real strict-load pass |
| Gemma 4 encoder and connector | `ltx_core.text_encoders.gemma`; unified Gemma 4 checkpoint | `tests/local_tests/ltx2_5/test_ltx2_5_gemma.py` | BOS behavior, connector bias surface, packed conversion | focused pass |
| Convolutional video VAE | `ltx_core.model.video_vae` configurator; conv checkpoint | `tests/local_tests/ltx2_5/test_ltx2_5_vae.py` | checkpoint-derived decoder width | config pass |
| Audio VAE/vocoder | `ltx_core.model.audio_vae`; audio VAE checkpoint | `tests/local_tests/ltx2_5/test_ltx2_5_conversion.py` | split routing and BWE topology reuse | real pipeline pass |
| Ancestral sampler | `ltx_core.components.diffusion_steps` and distilled pipeline selection | `tests/local_tests/ltx2_5/test_ltx2_5_sampler.py` | terminal x0, eta=0, reproducibility, seed offset, video-before-audio RNG | unit pass |
| T2AV/I2AV pipeline | official one-stage and distilled pipeline call paths | `tests/local_tests/ltx2_5/test_ltx2_5_registry.py` | dev/distilled presets, guidance, conditioning CRF, workloads | structural and real T2AV pass |
Planned verification order:
```bash
pytest -q tests/local_tests/ltx2_5 \
tests/local_tests/transformers/test_ltx2_5_transformer_parity.py \
-k 'not real_weights'
pytest -q -rs \
tests/local_tests/transformers/test_ltx2_5_transformer_parity.py \
-k 'not real_weights'
```
The transformer prototype extends the existing native LTX-2 graph rather than
forking it. It adds the exact upstream v1.2.0 gates
`use_prompt_adaln_single=True`, `ff_bias=True`, `audio_ff_bias=True`, and
`use_keyframes_abs_pos_embedding=False`; these defaults preserve LTX-2/2.3.
Both published 2.5 BF16 variants disable video FFN bias and enable keyframe
absolute embeddings while retaining the prompt-AdaLN and audio-FFN-bias
defaults. The keyframe marker mask is sharded with video tokens on FastVideo's
SP path.
The same warm Modal L40S shell ran the complete lightweight launch suite plus
legacy LTX preset preflight after the official source clone: 24 passed with the
two real-weight cases deselected. It then strict-loaded both 42 GB BF16
transformers through the official builder and FastVideo's production loader:
development passed in 28.76s and distilled passed in 27.06s.
Real pipeline validation then passed both distilled execution modes. The
one-stage smoke ran on 2xH100 and produced a joint H.264/AAC MP4 with 9 video
frames and 17 audio frames in 11.77s after load. The full two-stage smoke ran
on 2xB200: base ancestral denoising, spatial upsampling, the published
1,632-layer refinement LoRA, all three refinement steps, and joint decode/save
completed in 76.17s. The verified output contains 9 H.264 frames and 17 AAC
frames. W&B records the launch run at
`https://wandb.ai/aryan5v-san-jose-state-university/fastvideo-ltx2-5/runs/o9ixwewk`.
CPU/reference parity coverage includes random-weight FFN output parity, exact
keyframe-marker functional parity, and official-versus-FastVideo parameter
surface checks for both legacy defaults and the 2.5 gates. The real-weight test
strict-loads dev and distilled checkpoints through the official builder and
FastVideo's production `TransformerLoader` when the gated assets are present.
## Review Notes
- Existing LTX-2 and LTX-2.3 checkpoints must remain behavior-identical when
all 2.5 configuration flags retain their backward-compatible defaults.
- Native FastVideo loading, compilation, offload, and sequence-parallel paths
are production requirements, not follow-up wrappers.
- A scaffold skip is not a parity pass. Real-weight claims require non-skipped
Modal results with exact commands and hardware recorded here.
- Before every push after the first PR publication, inspect new CodeRabbit
feedback and address actionable findings before updating the branch.
@@ -0,0 +1,399 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import torch
from safetensors.torch import load_file, save_file
from fastvideo.configs.models.encoders.gemma import LTX2GemmaConfig
from fastvideo.models.encoders.gemma import LTX2GemmaTextEncoderModel
REPO_ROOT = Path(__file__).resolve().parents[3]
CONVERTER_PATH = REPO_ROOT / "scripts" / "checkpoint_conversion" / "convert_ltx2_weights.py"
SPEC = importlib.util.spec_from_file_location("convert_ltx2_weights", CONVERTER_PATH)
assert SPEC is not None and SPEC.loader is not None
converter = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(converter)
def _uint8(value: str) -> torch.Tensor:
return torch.tensor(list(value.encode()), dtype=torch.uint8)
def _save(
path: Path,
tensors: dict[str, torch.Tensor],
*,
config: dict | None = None,
metadata: dict[str, str] | None = None,
) -> None:
safetensors_metadata = dict(metadata or {})
if config is not None:
safetensors_metadata["config"] = json.dumps(config)
save_file(tensors, str(path), metadata=safetensors_metadata)
def _split_sources(tmp_path: Path) -> dict[str, Path]:
transformer_config = {
"transformer": {
"num_attention_heads": 2,
"attention_head_dim": 4,
"num_layers": 1,
"cross_attention_dim": 8,
"caption_channels": 6,
"audio_num_attention_heads": 1,
"audio_attention_head_dim": 3,
"audio_in_channels": 4,
"audio_out_channels": 4,
"audio_cross_attention_dim": 3,
"cross_attention_adaln": True,
"caption_proj_before_connector": True,
"apply_gated_attention": True,
"use_prompt_adaln_single": False,
"ff_bias": False,
"audio_ff_bias": False,
"use_keyframes_abs_pos_embedding": False,
"caption_projection_first_linear": False,
"caption_proj_input_norm": True,
"caption_projection_second_linear": False,
"connector_num_attention_heads": 2,
"connector_attention_head_dim": 4,
"connector_num_layers": 1,
"audio_connector_num_attention_heads": 1,
"audio_connector_attention_head_dim": 3,
"audio_connector_num_layers": 1,
"connector_positional_embedding_max_pos": [2048],
"connector_apply_gated_attention": True,
"connector_ff_bias": False,
"frequencies_precision": "float64",
}
}
transformer = tmp_path / "transformer.safetensors"
_save(
transformer,
{
"model.diffusion_model.transformer_blocks.0.ff.net.0.proj.weight": torch.ones(2, 2),
"model.diffusion_model.video_embeddings_connector.transformer_1d_blocks.0.weight": torch.ones(1),
"model.diffusion_model.audio_embeddings_connector.transformer_1d_blocks.0.weight": torch.ones(1),
},
config=transformer_config,
)
gemma_config = {
"model_type": "gemma4_unified",
"pad_token_id": 0,
"eos_token_id": 2,
"text_config": {
"hidden_size": 6,
"num_hidden_layers": 2,
"num_attention_heads": 2,
"max_position_embeddings": 1024,
"bos_token_id": 2,
},
}
text_encoder = tmp_path / "text_encoder.safetensors"
_save(
text_encoder,
{
"model.layers.0.self_attn.q_proj.weight": torch.ones(2, 2),
"vision_model.embeddings.patch_embedding.weight": torch.ones(1),
"multi_modal_projector.embedding_projection.weight": torch.ones(1),
"audio_projector.proj.weight": torch.ones(1),
"text_embedding_projection.video_aggregate_embed.weight": torch.ones(8, 18),
"text_embedding_projection.video_aggregate_embed.bias": torch.ones(8),
"text_embedding_projection.audio_aggregate_embed.weight": torch.ones(3, 18),
"text_embedding_projection.audio_aggregate_embed.bias": torch.ones(3),
"tokenizer_json": _uint8("{}"),
"hf_asset__tokenizer_config.json": _uint8('{"tokenizer_class":"PreTrainedTokenizerFast"}'),
"hf_asset__processor_config.json": _uint8("{}"),
},
metadata={"gemma_config": json.dumps(gemma_config)},
)
vae = tmp_path / "vae.safetensors"
_save(
vae,
{"vae.encoder.conv.weight": torch.ones(1)},
config={"vae": {"_class_name": "CausalVideoAutoencoder", "dims": 3}},
)
audio_vae = tmp_path / "audio_vae.safetensors"
_save(
audio_vae,
{
"audio_vae.decoder.conv_in.weight": torch.ones(1),
"audio_vae.per_channel_statistics.mean-of-means": torch.ones(1),
"vocoder.vocoder.conv_pre.weight": torch.ones(1),
"vocoder.bwe_generator.conv_pre.weight": torch.ones(1),
},
config={
"audio_vae": {"model": {"params": {"ddconfig": {}}}},
"vocoder": {"vocoder": {"resblock": "AMP1"}, "bwe": {"resblock": "AMP1"}},
},
)
spatial_upscaler = tmp_path / "spatial_upscaler.safetensors"
_save(
spatial_upscaler,
{
"model.initial_conv.weight": torch.ones(1),
"model.0.weight": torch.ones(1),
},
config={
"in_channels": 128,
"mid_channels": 512,
"num_blocks_per_stage": 4,
"dims": 3,
"spatial_upsample": True,
"temporal_upsample": False,
},
)
distilled_lora = tmp_path / "distilled_lora.safetensors"
_save(
distilled_lora,
{"diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight": torch.ones(1)},
)
return {
"transformer": transformer,
"text_encoder": text_encoder,
"vae": vae,
"audio_vae": audio_vae,
"spatial_upscaler": spatial_upscaler,
"distilled_lora": distilled_lora,
}
def test_split_ltx2_5_conversion_routes_components_and_metadata(tmp_path: Path) -> None:
sources = _split_sources(tmp_path)
output = tmp_path / "converted"
converter.convert_split_components(
transformer_source=sources["transformer"],
text_encoder_source=sources["text_encoder"],
vae_source=sources["vae"],
audio_vae_source=sources["audio_vae"],
spatial_upscaler_source=sources["spatial_upscaler"],
distilled_lora_source=sources["distilled_lora"],
output_dir=output,
transformer_class_name="LTX2Transformer3DModel",
variant="dev",
)
transformer_weights = load_file(str(output / "transformer" / "model.safetensors"))
assert set(transformer_weights) == {"transformer_blocks.0.ff.net.0.proj.weight"}
transformer_config = json.loads((output / "transformer" / "config.json").read_text())
assert transformer_config["cross_attention_adaln"] is True
assert transformer_config["use_prompt_adaln_single"] is False
assert transformer_config["ff_bias"] is False
assert transformer_config["audio_ff_bias"] is False
assert transformer_config["use_keyframes_abs_pos_embedding"] is False
assert transformer_config["connector_ff_bias"] is False
assert transformer_config["double_precision_rope"] is True
text_weights = load_file(str(output / "text_encoder" / "model.safetensors"))
assert set(text_weights) == {
"video_feature_extractor_linear.weight",
"video_feature_extractor_linear.bias",
"audio_feature_extractor_linear.weight",
"audio_feature_extractor_linear.bias",
"embeddings_connector.transformer_1d_blocks.0.weight",
"audio_embeddings_connector.transformer_1d_blocks.0.weight",
}
text_config = json.loads((output / "text_encoder" / "config.json").read_text())
assert text_config["gemma_model_path"] == "gemma"
assert text_config["hidden_size"] == 6
assert text_config["num_hidden_layers"] == 2
assert text_config["feature_extractor_in_features"] == 18
assert text_config["text_len"] == 1024
assert text_config["bos_token_id"] == 2
assert text_config["eos_token_id"] == 2
assert text_config["video_feature_extractor_out_features"] == 8
assert text_config["audio_feature_extractor_out_features"] == 3
assert text_config["caption_proj_before_connector"] is True
assert text_config["connector_rope_type"] == "split"
assert text_config["connector_ff_bias"] is False
# Validate the emitted config through the production FastVideo model
# constructor. The Gemma backbone remains lazy, so this is lightweight.
encoder_config = LTX2GemmaConfig()
encoder_config.update_model_arch(text_config)
encoder = LTX2GemmaTextEncoderModel(encoder_config)
assert encoder.video_feature_extractor_linear.in_features == 18
assert encoder.video_feature_extractor_linear.out_features == 8
assert encoder.audio_feature_extractor_linear.out_features == 3
assert encoder.embeddings_connector.transformer_1d_blocks[0].ff.net[0].proj.bias is None
gemma_weights = load_file(str(output / "text_encoder" / "gemma" / "model.safetensors"))
assert "model.language_model.layers.0.self_attn.q_proj.weight" in gemma_weights
assert "model.vision_embedder.embeddings.patch_embedding.weight" in gemma_weights
assert "model.embed_vision.embedding_projection.weight" in gemma_weights
assert "model.embed_audio.proj.weight" in gemma_weights
assert (output / "text_encoder" / "gemma" / "tokenizer.json").read_text() == "{}"
assert (output / "tokenizer" / "tokenizer_config.json").is_file()
assert set(load_file(str(output / "vae" / "model.safetensors"))) == {"encoder.conv.weight"}
assert set(load_file(str(output / "audio_vae" / "model.safetensors"))) == {
"decoder.conv_in.weight",
"per_channel_statistics.mean-of-means",
}
assert set(load_file(str(output / "vocoder" / "model.safetensors"))) == {
"vocoder.conv_pre.weight",
"bwe_generator.conv_pre.weight",
}
assert set(load_file(str(output / "spatial_upsampler" / "model.safetensors"))) == {
"initial_conv.weight",
"upsampler.0.weight",
}
assert set(load_file(str(output / "distilled_lora" / "model.safetensors"))) == {
"diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight"
}
model_index = json.loads((output / "model_index.json").read_text())
assert model_index["transformer"] == ["diffusers", "LTX2Transformer3DModel"]
assert model_index["spatial_upsampler"] == ["diffusers", "LTX2LatentUpsampler"]
assert model_index["fastvideo_ltx2_variant"] == "ltx2.5-dev"
assert model_index["fastvideo_refine_enabled"] is True
assert model_index["fastvideo_refine_upsampler_path"] == "spatial_upsampler"
assert model_index["fastvideo_refine_lora_path"] == "distilled_lora/model.safetensors"
def test_split_model_index_enables_distilled_refine_without_lora() -> None:
model_index = converter._build_split_model_index(
transformer_class_name="LTX2Transformer3DModel",
pipeline_class_name="LTX2Pipeline",
diffusers_version="0.33.0.dev0",
variant="distilled",
distilled_lora=False,
)
assert model_index["fastvideo_refine_enabled"] is True
assert "fastvideo_refine_lora_path" not in model_index
def test_split_ltx2_5_conversion_preserves_enabled_transformer_gates(tmp_path: Path) -> None:
"""Transformer config filtering retains True-valued 2.5 architecture gates."""
# Create a fresh fixture with all 2.5 gates enabled
transformer_config = {
"transformer": {
"num_attention_heads": 2,
"attention_head_dim": 4,
"num_layers": 1,
"cross_attention_dim": 8,
"caption_channels": 6,
"audio_num_attention_heads": 1,
"audio_attention_head_dim": 3,
"audio_in_channels": 4,
"audio_out_channels": 4,
"audio_cross_attention_dim": 3,
"cross_attention_adaln": True,
"caption_proj_before_connector": True,
"apply_gated_attention": True,
"use_prompt_adaln_single": True,
"ff_bias": True,
"audio_ff_bias": True,
"use_keyframes_abs_pos_embedding": True,
"caption_projection_first_linear": False,
"caption_proj_input_norm": True,
"caption_projection_second_linear": False,
"connector_num_attention_heads": 2,
"connector_attention_head_dim": 4,
"connector_num_layers": 1,
"audio_connector_num_attention_heads": 1,
"audio_connector_attention_head_dim": 3,
"audio_connector_num_layers": 1,
"connector_positional_embedding_max_pos": [2048],
"connector_apply_gated_attention": True,
"connector_ff_bias": False,
"frequencies_precision": "float64",
}
}
transformer = tmp_path / "transformer_gates_enabled.safetensors"
_save(
transformer,
{
"model.diffusion_model.transformer_blocks.0.ff.net.0.proj.weight": torch.ones(2, 2),
"model.diffusion_model.video_embeddings_connector.transformer_1d_blocks.0.weight": torch.ones(1),
"model.diffusion_model.audio_embeddings_connector.transformer_1d_blocks.0.weight": torch.ones(1),
},
config=transformer_config,
)
# Reuse other fixture sources
sources = _split_sources(tmp_path)
output = tmp_path / "converted_gates_enabled"
converter.convert_split_components(
transformer_source=transformer,
text_encoder_source=sources["text_encoder"],
vae_source=sources["vae"],
audio_vae_source=sources["audio_vae"],
spatial_upscaler_source=sources["spatial_upscaler"],
distilled_lora_source=sources["distilled_lora"],
output_dir=output,
transformer_class_name="LTX2Transformer3DModel",
variant="dev",
)
transformer_config_out = json.loads((output / "transformer" / "config.json").read_text())
assert transformer_config_out["use_prompt_adaln_single"] is True
assert transformer_config_out["ff_bias"] is True
assert transformer_config_out["audio_ff_bias"] is True
assert transformer_config_out["use_keyframes_abs_pos_embedding"] is True
def test_legacy_monolithic_conversion_behavior_is_preserved(tmp_path: Path) -> None:
source = tmp_path / "legacy.safetensors"
metadata_config = {
"transformer": {
"num_attention_heads": 2,
"frequencies_precision": "float64",
"cross_attention_adaln": True,
"caption_proj_before_connector": True,
"apply_gated_attention": True,
"use_prompt_adaln_single": False,
"ff_bias": False,
"audio_ff_bias": False,
"use_keyframes_abs_pos_embedding": False,
},
"vae": {},
"audio_vae": {},
"vocoder": {},
}
_save(
source,
{
"model.diffusion_model.transformer_blocks.0.weight": torch.ones(1),
"vae.encoder.weight": torch.ones(1),
"audio_vae.decoder.weight": torch.ones(1),
"vocoder.conv_pre.weight": torch.ones(1),
"text_embedding_projection.video_aggregate_embed.weight": torch.ones(1),
},
config=metadata_config,
)
output = tmp_path / "legacy-converted"
converter.convert_components(
source,
output,
metadata_config,
"LTX2Transformer3DModel",
)
assert set(load_file(str(output / "transformer" / "model.safetensors"))) == {
"transformer_blocks.0.weight"
}
# Legacy monolith keeps its historical projection key surface.
assert set(load_file(str(output / "text_encoder" / "model.safetensors"))) == {
"video_aggregate_embed.weight"
}
config = json.loads((output / "transformer" / "config.json").read_text())
assert config["cross_attention_adaln"] is True
assert config["use_prompt_adaln_single"] is False
assert config["ff_bias"] is False
assert config["audio_ff_bias"] is False
assert config["use_keyframes_abs_pos_embedding"] is False
assert config["double_precision_rope"] is True
assert (output / "model_index.json").is_file()
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: Apache-2.0
"""Focused compatibility tests for the LTX-2.5 packed Gemma 4 path."""
from types import SimpleNamespace
import torch
from fastvideo.models.dits.ltx2 import LTXRopeType
from fastvideo.models.encoders.gemma import (
Embeddings1DConnector,
GemmaConnectorConfig,
_ensure_leading_bos,
_get_bos_token_id,
)
def test_gemma4_bos_token_id_comes_from_nested_text_config() -> None:
config = SimpleNamespace(text_config=SimpleNamespace(bos_token_id=2))
assert _get_bos_token_id(config) == 2
def test_ensure_leading_bos_preserves_gemma3_and_fixes_gemma4() -> None:
input_ids = torch.tensor([
[0, 0, 2, 10, 11],
[0, 0, 0, 10, 11],
[10, 11, 12, 13, 14],
])
attention_mask = torch.tensor([
[0, 0, 1, 1, 1],
[0, 0, 0, 1, 1],
[1, 1, 1, 1, 1],
])
actual_ids, actual_mask = _ensure_leading_bos(
input_ids,
attention_mask,
bos_token_id=2,
)
torch.testing.assert_close(actual_ids[0], input_ids[0])
torch.testing.assert_close(actual_mask[0], attention_mask[0])
torch.testing.assert_close(actual_ids[1], torch.tensor([0, 0, 2, 10, 11]))
torch.testing.assert_close(actual_mask[1], torch.tensor([0, 0, 1, 1, 1]))
torch.testing.assert_close(actual_ids[2], torch.tensor([2, 10, 11, 12, 13]))
torch.testing.assert_close(actual_mask[2], torch.ones(5, dtype=torch.long))
def test_ltx2_5_connector_ffn_can_omit_bias() -> None:
connector = Embeddings1DConnector(
GemmaConnectorConfig(
num_attention_heads=2,
attention_head_dim=4,
num_layers=1,
positional_embedding_theta=10000.0,
positional_embedding_max_pos=[4096],
rope_type=LTXRopeType.SPLIT,
double_precision_rope=False,
num_learnable_registers=None,
apply_gated_attention=False,
ff_bias=False,
))
feed_forward = connector.transformer_1d_blocks[0].ff
assert feed_forward.net[0].proj.bias is None
assert feed_forward.net[2].bias is None
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: Apache-2.0
"""Registry and preset contracts for LTX-2.5 launch inference."""
import pytest
from fastvideo.api.sampling_param import SamplingParam
from fastvideo.fastvideo_args import WorkloadType
from fastvideo.registry import get_preset_selection, get_registered_models_with_workloads
@pytest.mark.parametrize(
("model_path", "preset_name", "ancestral"),
(
("FastVideo/LTX-2.5-Dev-Diffusers", "ltx2_5_dev", False),
("FastVideo/LTX-2.5-Distilled-Diffusers", "ltx2_5_distilled_two_stage", True),
),
)
def test_ltx2_5_registry_precedence_and_presets(
model_path: str,
preset_name: str,
ancestral: bool,
) -> None:
assert get_preset_selection(model_path) == (preset_name, "ltx2")
registered = {entry["id"]: entry for entry in get_registered_models_with_workloads()}
assert set(registered[model_path]["workload_types"]) == {
WorkloadType.T2V.value,
WorkloadType.I2V.value,
}
sampling = SamplingParam.from_pretrained(model_path)
assert sampling.ltx2_image_crf == 18.0
assert sampling.ltx2_use_ancestral_sampler is ancestral
def test_legacy_ltx_distilled_remains_deterministic() -> None:
sampling = SamplingParam.from_pretrained("FastVideo/LTX2-Distilled-Diffusers")
assert sampling.ltx2_use_ancestral_sampler is False
assert sampling.ltx2_image_crf == 33.0
def test_ltx2_5_metadata_variant_selects_preset(tmp_path) -> None:
"""Local directories with fastvideo_ltx2_variant metadata route to the correct preset."""
import json
from pathlib import Path
# Create a local directory with LTX2Pipeline _class_name and distilled variant metadata
distilled_dir = tmp_path / "ltx2_5_distilled_local"
distilled_dir.mkdir()
model_index = {
"_class_name": "LTX2Pipeline",
"fastvideo_ltx2_variant": "ltx2.5-distilled",
}
(distilled_dir / "model_index.json").write_text(json.dumps(model_index))
preset, family = get_preset_selection(str(distilled_dir))
assert preset == "ltx2_5_distilled_two_stage"
assert family == "ltx2"
# Create a local directory with LTX2Pipeline _class_name and dev variant metadata
dev_dir = tmp_path / "ltx2_5_dev_local"
dev_dir.mkdir()
model_index = {
"_class_name": "LTX2Pipeline",
"fastvideo_ltx2_variant": "ltx2.5-dev",
}
(dev_dir / "model_index.json").write_text(json.dumps(model_index))
preset, family = get_preset_selection(str(dev_dir))
assert preset == "ltx2_5_dev"
assert family == "ltx2"
def test_ltx2_5_neutral_path_with_class_name_routes_correctly(tmp_path) -> None:
"""Neutral local paths with _class_name=LTX2Pipeline and variant metadata route correctly."""
import json
# A path with no ltx-2.5 tokens but LTX2Pipeline class and distilled variant
neutral_dir = tmp_path / "my_converted_checkpoint"
neutral_dir.mkdir()
model_index = {
"_class_name": "LTX2Pipeline",
"fastvideo_ltx2_variant": "ltx2.5-distilled",
}
(neutral_dir / "model_index.json").write_text(json.dumps(model_index))
preset, family = get_preset_selection(str(neutral_dir))
assert preset == "ltx2_5_distilled_two_stage"
assert family == "ltx2"
@@ -0,0 +1,151 @@
# SPDX-License-Identifier: Apache-2.0
"""Unit coverage for the LTX-2.5 distilled ancestral sampler."""
import torch
from fastvideo.api.sampling_param import SamplingParam
from fastvideo.pipelines.basic.ltx2.stages.ltx2_denoising import (
ANCESTRAL_NOISE_SEED_OFFSET,
_ltx2_first_frame_keyframes_mask,
_ltx2_euler_ancestral_step,
)
def test_ltx2_5_preset_enables_ancestral_sampling() -> None:
params = SamplingParam.from_pretrained("FastVideo/LTX-2.5-Distilled-Diffusers")
assert params.ltx2_use_ancestral_sampler is True
assert params.num_inference_steps == 8
assert params.ltx2_image_crf == 18.0
def test_ancestral_terminal_step_returns_denoised_prediction() -> None:
sample = torch.tensor([3.0], dtype=torch.bfloat16)
denoised = torch.tensor([1.25], dtype=torch.bfloat16)
actual = _ltx2_euler_ancestral_step(
sample,
denoised,
torch.tensor(0.421875),
torch.tensor(0.0),
noise=None,
)
torch.testing.assert_close(actual, denoised)
def test_ancestral_eta_zero_matches_deterministic_euler() -> None:
sample = torch.tensor([2.0, -1.0], dtype=torch.float32)
denoised = torch.tensor([0.5, 0.25], dtype=torch.float32)
sigma = torch.tensor(0.8)
sigma_next = torch.tensor(0.3)
expected = sample + ((sample - denoised) / sigma) * (sigma_next - sigma)
actual = _ltx2_euler_ancestral_step(
sample,
denoised,
sigma,
sigma_next,
noise=None,
eta=0.0,
)
torch.testing.assert_close(actual, expected)
def test_ancestral_noise_stream_is_reproducible_and_video_first() -> None:
"""Verify ancestral noise uses the seeded offset and video-first ordering."""
seed = 17 + ANCESTRAL_NOISE_SEED_OFFSET
generator = torch.Generator(device="cpu").manual_seed(seed)
video_noise = torch.randn((1, 2), generator=generator)
audio_noise = torch.randn((1, 3), generator=generator)
replay = torch.Generator(device="cpu").manual_seed(seed)
torch.testing.assert_close(video_noise, torch.randn((1, 2), generator=replay))
torch.testing.assert_close(audio_noise, torch.randn((1, 3), generator=replay))
def test_ancestral_step_captures_noise_from_stage_forward(monkeypatch) -> None:
"""LTX2DenoisingStage.forward generates ancestral noise with the expected seed, shapes, and ordering."""
from unittest.mock import MagicMock
from fastvideo.pipelines.basic.ltx2.stages.ltx2_denoising import LTX2DenoisingStage
from fastvideo.pipelines.composed_pipeline_base import ForwardBatch
from fastvideo.fastvideo_args import FastVideoArgs
# Track noise tensors passed to _ltx2_euler_ancestral_step
captured_noises = []
original_step = _ltx2_euler_ancestral_step
def mock_step(sample, denoised, sigma, sigma_next, noise=None, eta=1.0):
if noise is not None:
captured_noises.append(noise.clone())
return original_step(sample, denoised, sigma, sigma_next, noise=noise, eta=eta)
monkeypatch.setattr(
"fastvideo.pipelines.basic.ltx2.stages.ltx2_denoising._ltx2_euler_ancestral_step",
mock_step,
)
# Create a minimal stage with a mock transformer
mock_transformer = MagicMock()
mock_transformer.return_value = (torch.zeros(1, 12, 4), torch.zeros(1, 18, 4))
stage = LTX2DenoisingStage(
mock_transformer,
sigmas_override=[0.8, 0.3],
num_inference_steps_override=1,
)
# Create a batch with video and audio latents matching expected shapes
batch = ForwardBatch(
latents=torch.randn(1, 4, 3, 2, 2), # video: (B, C, T, H, W)
extra={
"ltx2_audio_latents": torch.randn(1, 4, 6), # audio: (B, C, T)
"ltx2_conditioning_video_latents": None,
"ltx2_conditioning_audio_latents": None,
},
)
batch.text_embeddings = torch.randn(1, 10, 8)
batch.timestep_embeddings = torch.randn(1)
args = FastVideoArgs(
model_path="dummy",
seed=17,
ltx2_use_ancestral_sampler=True,
)
# Run the stage forward to generate ancestral noise
stage.forward(batch, args)
# Verify we captured noise tensors (one per ancestral step)
assert len(captured_noises) == 1
noise = captured_noises[0]
# Verify the noise tensor contains both video and audio with video-first ordering
# Video latents are (1, 4, 3, 2, 2) = 48 elements; audio latents are (1, 4, 6) = 24 elements
# Combined flattened noise should have 48 + 24 = 72 elements
assert noise.numel() == 48 + 24
# Verify reproducibility with the seeded offset
expected_seed = 17 + ANCESTRAL_NOISE_SEED_OFFSET
generator = torch.Generator(device="cpu").manual_seed(expected_seed)
video_noise_expected = torch.randn((1, 4, 3, 2, 2), generator=generator)
audio_noise_expected = torch.randn((1, 4, 6), generator=generator)
# The noise tensor is concatenated [video, audio] in flattened form
noise_video = noise[:48].view(1, 4, 3, 2, 2)
noise_audio = noise[48:].view(1, 4, 6)
torch.testing.assert_close(noise_video, video_noise_expected)
torch.testing.assert_close(noise_audio, audio_noise_expected)
def test_keyframe_mask_marks_every_token_in_first_causal_latent_frame() -> None:
mask = _ltx2_first_frame_keyframes_mask(
batch_size=2,
token_count=12,
latent_frames=3,
device=torch.device("cpu"),
)
expected = torch.zeros((2, 12, 1))
expected[:, :4] = 1.0
torch.testing.assert_close(mask, expected)
@@ -0,0 +1,23 @@
# SPDX-License-Identifier: Apache-2.0
"""Configuration compatibility for the LTX-2.5 convolutional video VAE."""
from fastvideo.models.vaes.ltx2vae import VideoDecoderConfigurator
def test_conv_vae_honors_checkpoint_decoder_base_channels() -> None:
decoder = VideoDecoderConfigurator.from_config({
"vae": {
"dims": 3,
"latent_channels": 8,
"out_channels": 3,
"decoder_blocks": [],
"patch_size": 1,
"norm_layer": "pixel_norm",
"causal_decoder": False,
"timestep_conditioning": False,
"decoder_base_channels": 12,
}
})
assert decoder.conv_in.in_channels == 8
assert decoder.conv_in.out_channels == 12
@@ -0,0 +1,316 @@
# SPDX-License-Identifier: Apache-2.0
"""Focused parity coverage for the config-gated LTX-2.5 DiT extension.
The CPU tests use random weights to compare the new stateful/functional delta
against pinned ``ltx-core`` v1.2.0. The real-weight test exercises the official
builder and FastVideo's production ``TransformerLoader`` when the gated assets
have been provisioned.
"""
from __future__ import annotations
import importlib
import os
from pathlib import Path
import sys
import pytest
import torch
from torch.testing import assert_close
REPO_ROOT = Path(__file__).resolve().parents[3]
OFFICIAL_REF_DIR = Path(os.environ.get("LTX2_5_OFFICIAL_REF_DIR", REPO_ROOT / "LTX-2-Reference"))
OFFICIAL_SRC = OFFICIAL_REF_DIR / "packages" / "ltx-core" / "src"
OFFICIAL_WEIGHTS_DIR = Path(
os.environ.get(
"LTX2_5_OFFICIAL_WEIGHTS_DIR",
REPO_ROOT / "official_weights" / "LTX-2.5" / "diffusion_models",
)
)
CONVERTED_WEIGHTS_DIR = Path(
os.environ.get(
"LTX2_5_CONVERTED_WEIGHTS_DIR",
REPO_ROOT / "converted_weights" / "ltx2_5",
)
)
PARITY_SCOPE = "both"
@pytest.fixture(autouse=True)
def _setup_test_environment(monkeypatch: pytest.MonkeyPatch) -> None:
"""Set default environment variables for all tests in this module."""
monkeypatch.setenv("MASTER_ADDR", "localhost")
monkeypatch.setenv("MASTER_PORT", "29525")
monkeypatch.setenv("DISABLE_SP", "1")
monkeypatch.setenv("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
def _import_official(module_name: str):
if not OFFICIAL_SRC.is_dir():
pytest.skip(f"Pinned LTX-2 v1.2.0 reference is missing: {OFFICIAL_REF_DIR}")
if str(OFFICIAL_SRC) not in sys.path:
sys.path.insert(0, str(OFFICIAL_SRC))
try:
return importlib.import_module(module_name)
except Exception as exc: # noqa: BLE001 - report the exact missing reference dependency.
pytest.skip(f"Cannot import pinned official module {module_name}: {exc}")
def _tiny_model_kwargs() -> dict:
return {
"num_attention_heads": 2,
"attention_head_dim": 4,
"in_channels": 4,
"out_channels": 4,
"num_layers": 1,
"cross_attention_dim": 8,
"audio_num_attention_heads": 2,
"audio_attention_head_dim": 4,
"audio_in_channels": 4,
"audio_out_channels": 4,
"audio_cross_attention_dim": 8,
"cross_attention_adaln": True,
}
@pytest.mark.parametrize("bias", (False, True))
def test_feed_forward_random_weight_parity(bias: bool) -> None:
"""The 2.5 bias gate must change both FFN linears, not their math/layout."""
official_module = _import_official("ltx_core.model.transformer.feed_forward")
from fastvideo.models.dits.ltx2 import FeedForward as FastVideoFeedForward
torch.manual_seed(20260812)
official = official_module.FeedForward(dim=8, dim_out=8, bias=bias).eval()
fastvideo = FastVideoFeedForward(dim=8, dim_out=8, bias=bias).eval()
official_state = official.state_dict()
fastvideo_state = fastvideo.state_dict()
assert official_state.keys() == fastvideo_state.keys()
fastvideo.load_state_dict(official_state, strict=True)
inputs = torch.randn(2, 5, 8)
with torch.inference_mode():
expected = official(inputs)
actual = fastvideo(inputs)
assert_close(actual, expected, atol=1e-6, rtol=1e-6)
def test_keyframe_absolute_embedding_random_weight_parity() -> None:
"""Match upstream marker semantics for positive, zero, and negative mask values."""
official_module = _import_official("ltx_core.model.transformer.transformer_args")
from fastvideo.models.dits.ltx2 import apply_keyframes_absolute_embedding
torch.manual_seed(20260812)
hidden_states = torch.randn(2, 4, 8)
embedding = torch.randn(1, 8)
keyframes_mask = torch.tensor(
[[[1.0], [0.0], [-1.0], [2.0]], [[0.0], [1.0], [0.0], [0.5]]]
)
provider = lambda: embedding
expected = official_module.apply_keyframes_absolute_embedding(hidden_states, keyframes_mask, provider)
actual = apply_keyframes_absolute_embedding(hidden_states, keyframes_mask, provider)
assert_close(actual, expected, atol=0.0, rtol=0.0)
assert apply_keyframes_absolute_embedding(hidden_states, None, provider) is hidden_states
@pytest.mark.parametrize("dynamic_prompt", (False, True))
def test_cross_attention_adaln_static_and_dynamic_prompt_parity(dynamic_prompt: bool) -> None:
"""Exercise the static 2.5 K/V table and legacy dynamic prompt AdaLN paths."""
official_module = _import_official("ltx_core.model.transformer.transformer")
from fastvideo.models.dits.ltx2 import _rms_norm_dispatch
from fastvideo.models.dits.ltx2 import apply_cross_attention_adaln
torch.manual_seed(20260812)
batch_size, sequence_length, dim = 2, 3, 8
x = torch.randn(batch_size, sequence_length, dim)
context = torch.randn_like(x)
q_shift = torch.randn_like(x)
q_scale = torch.randn_like(x)
q_gate = torch.randn_like(x)
prompt_scale_shift_table = torch.randn(2, dim)
prompt_timestep = torch.randn(batch_size, sequence_length, 2 * dim) if dynamic_prompt else None
def attention(inputs, *, context, mask):
assert mask is None
return inputs + context
expected = official_module.apply_cross_attention_adaln(
_rms_norm_dispatch(x, eps=1e-6),
context,
attention,
q_shift,
q_scale,
q_gate,
prompt_scale_shift_table,
prompt_timestep,
)
actual = apply_cross_attention_adaln(
x,
context,
attention,
q_shift,
q_scale,
q_gate,
prompt_scale_shift_table,
prompt_timestep,
)
assert_close(actual, expected, atol=0.0, rtol=0.0)
@pytest.mark.parametrize(
("use_prompt_adaln_single", "ff_bias", "audio_ff_bias", "use_keyframes_abs_pos_embedding"),
(
(True, True, True, False), # LTX-2/2.3-compatible defaults.
(False, False, False, True), # LTX-2.5-style architecture delta.
),
)
def test_variant_structure_matches_official(
use_prompt_adaln_single: bool,
ff_bias: bool,
audio_ff_bias: bool,
use_keyframes_abs_pos_embedding: bool,
) -> None:
"""Compare the exact parameter surface controlled by the four new flags."""
official_model_module = _import_official("ltx_core.model.transformer.model")
from fastvideo.models.dits.ltx2 import LTXModel as FastVideoLTXModel
from fastvideo.models.dits.ltx2 import LTXModelType as FastVideoLTXModelType
common = _tiny_model_kwargs()
variant = {
"use_prompt_adaln_single": use_prompt_adaln_single,
"ff_bias": ff_bias,
"audio_ff_bias": audio_ff_bias,
"use_keyframes_abs_pos_embedding": use_keyframes_abs_pos_embedding,
}
official = official_model_module.LTXModel(
model_type=official_model_module.LTXModelType.AudioVideo,
**common,
**variant,
)
fastvideo = FastVideoLTXModel(
model_type=FastVideoLTXModelType.AudioVideo,
caption_proj_before_connector=True,
**common,
**variant,
)
assert (official.prompt_adaln_single is not None) == (fastvideo.prompt_adaln_single is not None)
assert (official.audio_prompt_adaln_single is not None) == (fastvideo.audio_prompt_adaln_single is not None)
official_state = official.state_dict()
fastvideo_state = fastvideo.state_dict()
for prefix in ("transformer_blocks.0.ff.", "transformer_blocks.0.audio_ff."):
official_keys = {name for name in official_state if name.startswith(prefix)}
fastvideo_keys = {name for name in fastvideo_state if name.startswith(prefix)}
assert official_keys == fastvideo_keys
keyframe_name = "keyframes_abs_pos_embedding"
assert (keyframe_name in official_state) == use_keyframes_abs_pos_embedding
assert (keyframe_name in fastvideo_state) == use_keyframes_abs_pos_embedding
if use_keyframes_abs_pos_embedding:
assert official_state[keyframe_name].shape == fastvideo_state[keyframe_name].shape == (1, 8)
def test_arch_config_defaults_preserve_pre_2_5_checkpoints() -> None:
from fastvideo.configs.models.dits.ltx2 import LTX2VideoArchConfig
config = LTX2VideoArchConfig()
assert config.use_prompt_adaln_single is True
assert config.ff_bias is True
assert config.audio_ff_bias is True
assert config.use_keyframes_abs_pos_embedding is False
def _read_transformer_metadata(official_path: Path) -> dict:
loader_module = _import_official("ltx_core.loader.sft_loader")
metadata = loader_module.SafetensorsModelStateDictLoader().metadata(str(official_path))
transformer = metadata.get("config", {}).get("transformer", {})
if not transformer:
pytest.fail(f"LTX-2.5 checkpoint has no config.transformer metadata: {official_path}", pytrace=False)
return transformer
def _fastvideo_config_from_metadata(metadata: dict):
from fastvideo.configs.models.dits import LTX2VideoConfig
config = LTX2VideoConfig()
arch = config.arch_config
for name in (
"num_attention_heads",
"attention_head_dim",
"num_layers",
"cross_attention_dim",
"caption_channels",
"norm_eps",
"positional_embedding_theta",
"positional_embedding_max_pos",
"timestep_scale_multiplier",
"use_middle_indices_grid",
"rope_type",
"audio_num_attention_heads",
"audio_attention_head_dim",
"audio_in_channels",
"audio_out_channels",
"audio_cross_attention_dim",
"audio_positional_embedding_max_pos",
"av_ca_timestep_scale_multiplier",
"in_channels",
"out_channels",
"cross_attention_adaln",
"caption_proj_before_connector",
"apply_gated_attention",
"use_prompt_adaln_single",
"ff_bias",
"audio_ff_bias",
"use_keyframes_abs_pos_embedding",
):
if name in metadata:
setattr(arch, name, metadata[name])
arch.double_precision_rope = metadata.get("frequencies_precision", "") == "float64"
return config
@pytest.mark.parametrize("variant", ("dev", "distilled"))
def test_real_weights_load_strictly_through_production_loader(variant: str, monkeypatch: pytest.MonkeyPatch) -> None:
"""Strict-load both gated 2.5 transformer variants when provisioned."""
official_path = OFFICIAL_WEIGHTS_DIR / f"ltx-2.5-22b-{variant}-transformer-bf16.safetensors"
converted_dir = CONVERTED_WEIGHTS_DIR / variant / "transformer"
missing = [str(path) for path in (official_path, converted_dir) if not path.exists()]
if missing:
pytest.skip(f"Gated LTX-2.5 transformer assets are not provisioned: {missing}")
if not torch.cuda.is_available() or not torch.cuda.is_bf16_supported():
pytest.fail("Provisioned LTX-2.5 weights require a bf16-capable CUDA GPU", pytrace=False)
official_loader = _import_official("ltx_core.loader.single_gpu_model_builder")
official_transformer = _import_official("ltx_core.model.transformer")
metadata = _read_transformer_metadata(official_path)
official = official_loader.SingleGPUModelBuilder(
model_class_configurator=official_transformer.LTXModelConfigurator,
model_path=str(official_path),
model_sd_ops=official_transformer.LTXV_MODEL_COMFY_RENAMING_MAP,
).build(device=torch.device("cuda:0"), dtype=torch.bfloat16)
assert official.use_prompt_adaln_single == metadata.get("use_prompt_adaln_single", True)
assert official.use_keyframes_abs_pos_embedding == metadata.get("use_keyframes_abs_pos_embedding", False)
del official
torch.cuda.empty_cache()
from fastvideo.configs.pipelines import PipelineConfig
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.models.dits import ltx2 as fastvideo_ltx2
from fastvideo.models.loader.component_loader import TransformerLoader
monkeypatch.setattr(fastvideo_ltx2, "get_sp_world_size", lambda: 1)
config = _fastvideo_config_from_metadata(metadata)
args = FastVideoArgs(
model_path=str(converted_dir),
dit_cpu_offload=True,
use_fsdp_inference=False,
pipeline_config=PipelineConfig(dit_config=config, dit_precision="bf16"),
)
args.device = torch.device("cuda:0")
model = TransformerLoader().load(str(converted_dir), args)
assert model.model.use_prompt_adaln_single == metadata.get("use_prompt_adaln_single", True)
assert model.model.use_keyframes_abs_pos_embedding == metadata.get("use_keyframes_abs_pos_embedding", False)