Skip to content

Commit ed6e5ec

Browse files
authored
[LoRA] add LoRA support to LTX-2 (#12933)
* up * fixes * tests * docs. * fix * change loading info. * up * up
1 parent d44b5f8 commit ed6e5ec

9 files changed

Lines changed: 587 additions & 3 deletions

File tree

docs/source/en/api/loaders/lora.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ LoRA is a fast and lightweight training method that inserts and trains a signifi
3333
- [`QwenImageLoraLoaderMixin`] provides similar functions for [Qwen Image](https://huggingface.co/docs/diffusers/main/en/api/pipelines/qwen).
3434
- [`ZImageLoraLoaderMixin`] provides similar functions for [Z-Image](https://huggingface.co/docs/diffusers/main/en/api/pipelines/zimage).
3535
- [`Flux2LoraLoaderMixin`] provides similar functions for [Flux2](https://huggingface.co/docs/diffusers/main/en/api/pipelines/flux2).
36+
- [`LTX2LoraLoaderMixin`] provides similar functions for [Flux2](https://huggingface.co/docs/diffusers/main/en/api/pipelines/ltx2).
3637
- [`LoraBaseMixin`] provides a base class with several utility methods to fuse, unfuse, unload, LoRAs and more.
3738

3839
> [!TIP]
@@ -62,6 +63,10 @@ LoRA is a fast and lightweight training method that inserts and trains a signifi
6263

6364
[[autodoc]] loaders.lora_pipeline.Flux2LoraLoaderMixin
6465

66+
## LTX2LoraLoaderMixin
67+
68+
[[autodoc]] loaders.lora_pipeline.LTX2LoraLoaderMixin
69+
6570
## CogVideoXLoraLoaderMixin
6671

6772
[[autodoc]] loaders.lora_pipeline.CogVideoXLoraLoaderMixin

docs/source/en/api/pipelines/ltx2.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
# LTX-2
1616

17+
<div class="flex flex-wrap space-x-1">
18+
<img alt="LoRA" src="https://img.shields.io/badge/LoRA-d8b4fe?style=flat"/>
19+
</div>
20+
1721
LTX-2 is a DiT-based audio-video foundation model designed to generate synchronized video and audio within a single model. It brings together the core building blocks of modern video generation, with open weights and a focus on practical, local execution.
1822

1923
You can find all the original LTX-Video checkpoints under the [Lightricks](https://huggingface.co/Lightricks) organization.

src/diffusers/loaders/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def text_encoder_attn_modules(text_encoder):
6767
"SD3LoraLoaderMixin",
6868
"AuraFlowLoraLoaderMixin",
6969
"StableDiffusionXLLoraLoaderMixin",
70+
"LTX2LoraLoaderMixin",
7071
"LTXVideoLoraLoaderMixin",
7172
"LoraLoaderMixin",
7273
"FluxLoraLoaderMixin",
@@ -121,6 +122,7 @@ def text_encoder_attn_modules(text_encoder):
121122
HunyuanVideoLoraLoaderMixin,
122123
KandinskyLoraLoaderMixin,
123124
LoraLoaderMixin,
125+
LTX2LoraLoaderMixin,
124126
LTXVideoLoraLoaderMixin,
125127
Lumina2LoraLoaderMixin,
126128
Mochi1LoraLoaderMixin,

src/diffusers/loaders/lora_conversion_utils.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2140,6 +2140,54 @@ def _convert_non_diffusers_ltxv_lora_to_diffusers(state_dict, non_diffusers_pref
21402140
return converted_state_dict
21412141

21422142

2143+
def _convert_non_diffusers_ltx2_lora_to_diffusers(state_dict, non_diffusers_prefix="diffusion_model"):
2144+
# Remove the prefix
2145+
state_dict = {k: v for k, v in state_dict.items() if k.startswith(f"{non_diffusers_prefix}.")}
2146+
converted_state_dict = {k.removeprefix(f"{non_diffusers_prefix}."): v for k, v in state_dict.items()}
2147+
2148+
if non_diffusers_prefix == "diffusion_model":
2149+
rename_dict = {
2150+
"patchify_proj": "proj_in",
2151+
"audio_patchify_proj": "audio_proj_in",
2152+
"av_ca_video_scale_shift_adaln_single": "av_cross_attn_video_scale_shift",
2153+
"av_ca_a2v_gate_adaln_single": "av_cross_attn_video_a2v_gate",
2154+
"av_ca_audio_scale_shift_adaln_single": "av_cross_attn_audio_scale_shift",
2155+
"av_ca_v2a_gate_adaln_single": "av_cross_attn_audio_v2a_gate",
2156+
"scale_shift_table_a2v_ca_video": "video_a2v_cross_attn_scale_shift_table",
2157+
"scale_shift_table_a2v_ca_audio": "audio_a2v_cross_attn_scale_shift_table",
2158+
"q_norm": "norm_q",
2159+
"k_norm": "norm_k",
2160+
}
2161+
else:
2162+
rename_dict = {"aggregate_embed": "text_proj_in"}
2163+
2164+
# Apply renaming
2165+
renamed_state_dict = {}
2166+
for key, value in converted_state_dict.items():
2167+
new_key = key[:]
2168+
for old_pattern, new_pattern in rename_dict.items():
2169+
new_key = new_key.replace(old_pattern, new_pattern)
2170+
renamed_state_dict[new_key] = value
2171+
2172+
# Handle adaln_single -> time_embed and audio_adaln_single -> audio_time_embed
2173+
final_state_dict = {}
2174+
for key, value in renamed_state_dict.items():
2175+
if key.startswith("adaln_single."):
2176+
new_key = key.replace("adaln_single.", "time_embed.")
2177+
final_state_dict[new_key] = value
2178+
elif key.startswith("audio_adaln_single."):
2179+
new_key = key.replace("audio_adaln_single.", "audio_time_embed.")
2180+
final_state_dict[new_key] = value
2181+
else:
2182+
final_state_dict[key] = value
2183+
2184+
# Add transformer prefix
2185+
prefix = "transformer" if non_diffusers_prefix == "diffusion_model" else "connectors"
2186+
final_state_dict = {f"{prefix}.{k}": v for k, v in final_state_dict.items()}
2187+
2188+
return final_state_dict
2189+
2190+
21432191
def _convert_non_diffusers_qwen_lora_to_diffusers(state_dict):
21442192
has_diffusion_model = any(k.startswith("diffusion_model.") for k in state_dict)
21452193
if has_diffusion_model:

src/diffusers/loaders/lora_pipeline.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
_convert_non_diffusers_flux2_lora_to_diffusers,
4949
_convert_non_diffusers_hidream_lora_to_diffusers,
5050
_convert_non_diffusers_lora_to_diffusers,
51+
_convert_non_diffusers_ltx2_lora_to_diffusers,
5152
_convert_non_diffusers_ltxv_lora_to_diffusers,
5253
_convert_non_diffusers_lumina2_lora_to_diffusers,
5354
_convert_non_diffusers_qwen_lora_to_diffusers,
@@ -74,6 +75,7 @@
7475
TEXT_ENCODER_NAME = "text_encoder"
7576
UNET_NAME = "unet"
7677
TRANSFORMER_NAME = "transformer"
78+
LTX2_CONNECTOR_NAME = "connectors"
7779

7880
_MODULE_NAME_TO_ATTRIBUTE_MAP_FLUX = {"x_embedder": "in_channels"}
7981

@@ -3011,6 +3013,233 @@ def unfuse_lora(self, components: List[str] = ["transformer"], **kwargs):
30113013
super().unfuse_lora(components=components, **kwargs)
30123014

30133015

3016+
class LTX2LoraLoaderMixin(LoraBaseMixin):
3017+
r"""
3018+
Load LoRA layers into [`LTX2VideoTransformer3DModel`]. Specific to [`LTX2Pipeline`].
3019+
"""
3020+
3021+
_lora_loadable_modules = ["transformer", "connectors"]
3022+
transformer_name = TRANSFORMER_NAME
3023+
connectors_name = LTX2_CONNECTOR_NAME
3024+
3025+
@classmethod
3026+
@validate_hf_hub_args
3027+
def lora_state_dict(
3028+
cls,
3029+
pretrained_model_name_or_path_or_dict: Union[str, Dict[str, torch.Tensor]],
3030+
**kwargs,
3031+
):
3032+
r"""
3033+
See [`~loaders.StableDiffusionLoraLoaderMixin.lora_state_dict`] for more details.
3034+
"""
3035+
# Load the main state dict first which has the LoRA layers for either of
3036+
# transformer and text encoder or both.
3037+
cache_dir = kwargs.pop("cache_dir", None)
3038+
force_download = kwargs.pop("force_download", False)
3039+
proxies = kwargs.pop("proxies", None)
3040+
local_files_only = kwargs.pop("local_files_only", None)
3041+
token = kwargs.pop("token", None)
3042+
revision = kwargs.pop("revision", None)
3043+
subfolder = kwargs.pop("subfolder", None)
3044+
weight_name = kwargs.pop("weight_name", None)
3045+
use_safetensors = kwargs.pop("use_safetensors", None)
3046+
return_lora_metadata = kwargs.pop("return_lora_metadata", False)
3047+
3048+
allow_pickle = False
3049+
if use_safetensors is None:
3050+
use_safetensors = True
3051+
allow_pickle = True
3052+
3053+
user_agent = {"file_type": "attn_procs_weights", "framework": "pytorch"}
3054+
3055+
state_dict, metadata = _fetch_state_dict(
3056+
pretrained_model_name_or_path_or_dict=pretrained_model_name_or_path_or_dict,
3057+
weight_name=weight_name,
3058+
use_safetensors=use_safetensors,
3059+
local_files_only=local_files_only,
3060+
cache_dir=cache_dir,
3061+
force_download=force_download,
3062+
proxies=proxies,
3063+
token=token,
3064+
revision=revision,
3065+
subfolder=subfolder,
3066+
user_agent=user_agent,
3067+
allow_pickle=allow_pickle,
3068+
)
3069+
3070+
is_dora_scale_present = any("dora_scale" in k for k in state_dict)
3071+
if is_dora_scale_present:
3072+
warn_msg = "It seems like you are using a DoRA checkpoint that is not compatible in Diffusers at the moment. So, we are going to filter out the keys associated to 'dora_scale` from the state dict. If you think this is a mistake please open an issue https://github.com/huggingface/diffusers/issues/new."
3073+
logger.warning(warn_msg)
3074+
state_dict = {k: v for k, v in state_dict.items() if "dora_scale" not in k}
3075+
3076+
final_state_dict = state_dict
3077+
is_non_diffusers_format = any(k.startswith("diffusion_model.") for k in state_dict)
3078+
has_connector = any(k.startswith("text_embedding_projection.") for k in state_dict)
3079+
if is_non_diffusers_format:
3080+
final_state_dict = _convert_non_diffusers_ltx2_lora_to_diffusers(state_dict)
3081+
if has_connector:
3082+
connectors_state_dict = _convert_non_diffusers_ltx2_lora_to_diffusers(
3083+
state_dict, "text_embedding_projection"
3084+
)
3085+
final_state_dict.update(connectors_state_dict)
3086+
out = (final_state_dict, metadata) if return_lora_metadata else final_state_dict
3087+
return out
3088+
3089+
def load_lora_weights(
3090+
self,
3091+
pretrained_model_name_or_path_or_dict: Union[str, Dict[str, torch.Tensor]],
3092+
adapter_name: Optional[str] = None,
3093+
hotswap: bool = False,
3094+
**kwargs,
3095+
):
3096+
"""
3097+
See [`~loaders.StableDiffusionLoraLoaderMixin.load_lora_weights`] for more details.
3098+
"""
3099+
if not USE_PEFT_BACKEND:
3100+
raise ValueError("PEFT backend is required for this method.")
3101+
3102+
low_cpu_mem_usage = kwargs.pop("low_cpu_mem_usage", _LOW_CPU_MEM_USAGE_DEFAULT_LORA)
3103+
if low_cpu_mem_usage and is_peft_version("<", "0.13.0"):
3104+
raise ValueError(
3105+
"`low_cpu_mem_usage=True` is not compatible with this `peft` version. Please update it with `pip install -U peft`."
3106+
)
3107+
3108+
# if a dict is passed, copy it instead of modifying it inplace
3109+
if isinstance(pretrained_model_name_or_path_or_dict, dict):
3110+
pretrained_model_name_or_path_or_dict = pretrained_model_name_or_path_or_dict.copy()
3111+
3112+
# First, ensure that the checkpoint is a compatible one and can be successfully loaded.
3113+
kwargs["return_lora_metadata"] = True
3114+
state_dict, metadata = self.lora_state_dict(pretrained_model_name_or_path_or_dict, **kwargs)
3115+
3116+
is_correct_format = all("lora" in key for key in state_dict.keys())
3117+
if not is_correct_format:
3118+
raise ValueError("Invalid LoRA checkpoint.")
3119+
3120+
transformer_peft_state_dict = {
3121+
k: v for k, v in state_dict.items() if k.startswith(f"{self.transformer_name}.")
3122+
}
3123+
connectors_peft_state_dict = {k: v for k, v in state_dict.items() if k.startswith(f"{self.connectors_name}.")}
3124+
self.load_lora_into_transformer(
3125+
transformer_peft_state_dict,
3126+
transformer=getattr(self, self.transformer_name) if not hasattr(self, "transformer") else self.transformer,
3127+
adapter_name=adapter_name,
3128+
metadata=metadata,
3129+
_pipeline=self,
3130+
low_cpu_mem_usage=low_cpu_mem_usage,
3131+
hotswap=hotswap,
3132+
)
3133+
if connectors_peft_state_dict:
3134+
self.load_lora_into_transformer(
3135+
connectors_peft_state_dict,
3136+
transformer=getattr(self, self.connectors_name)
3137+
if not hasattr(self, "connectors")
3138+
else self.connectors,
3139+
adapter_name=adapter_name,
3140+
metadata=metadata,
3141+
_pipeline=self,
3142+
low_cpu_mem_usage=low_cpu_mem_usage,
3143+
hotswap=hotswap,
3144+
prefix=self.connectors_name,
3145+
)
3146+
3147+
@classmethod
3148+
def load_lora_into_transformer(
3149+
cls,
3150+
state_dict,
3151+
transformer,
3152+
adapter_name=None,
3153+
_pipeline=None,
3154+
low_cpu_mem_usage=False,
3155+
hotswap: bool = False,
3156+
metadata=None,
3157+
prefix: str = "transformer",
3158+
):
3159+
"""
3160+
See [`~loaders.StableDiffusionLoraLoaderMixin.load_lora_into_unet`] for more details.
3161+
"""
3162+
if low_cpu_mem_usage and is_peft_version("<", "0.13.0"):
3163+
raise ValueError(
3164+
"`low_cpu_mem_usage=True` is not compatible with this `peft` version. Please update it with `pip install -U peft`."
3165+
)
3166+
3167+
# Load the layers corresponding to transformer.
3168+
logger.info(f"Loading {prefix}.")
3169+
transformer.load_lora_adapter(
3170+
state_dict,
3171+
network_alphas=None,
3172+
adapter_name=adapter_name,
3173+
metadata=metadata,
3174+
_pipeline=_pipeline,
3175+
low_cpu_mem_usage=low_cpu_mem_usage,
3176+
hotswap=hotswap,
3177+
prefix=prefix,
3178+
)
3179+
3180+
@classmethod
3181+
# Copied from diffusers.loaders.lora_pipeline.CogVideoXLoraLoaderMixin.save_lora_weights
3182+
def save_lora_weights(
3183+
cls,
3184+
save_directory: Union[str, os.PathLike],
3185+
transformer_lora_layers: Dict[str, Union[torch.nn.Module, torch.Tensor]] = None,
3186+
is_main_process: bool = True,
3187+
weight_name: str = None,
3188+
save_function: Callable = None,
3189+
safe_serialization: bool = True,
3190+
transformer_lora_adapter_metadata: Optional[dict] = None,
3191+
):
3192+
r"""
3193+
See [`~loaders.StableDiffusionLoraLoaderMixin.save_lora_weights`] for more information.
3194+
"""
3195+
lora_layers = {}
3196+
lora_metadata = {}
3197+
3198+
if transformer_lora_layers:
3199+
lora_layers[cls.transformer_name] = transformer_lora_layers
3200+
lora_metadata[cls.transformer_name] = transformer_lora_adapter_metadata
3201+
3202+
if not lora_layers:
3203+
raise ValueError("You must pass at least one of `transformer_lora_layers` or `text_encoder_lora_layers`.")
3204+
3205+
cls._save_lora_weights(
3206+
save_directory=save_directory,
3207+
lora_layers=lora_layers,
3208+
lora_metadata=lora_metadata,
3209+
is_main_process=is_main_process,
3210+
weight_name=weight_name,
3211+
save_function=save_function,
3212+
safe_serialization=safe_serialization,
3213+
)
3214+
3215+
# Copied from diffusers.loaders.lora_pipeline.CogVideoXLoraLoaderMixin.fuse_lora
3216+
def fuse_lora(
3217+
self,
3218+
components: List[str] = ["transformer"],
3219+
lora_scale: float = 1.0,
3220+
safe_fusing: bool = False,
3221+
adapter_names: Optional[List[str]] = None,
3222+
**kwargs,
3223+
):
3224+
r"""
3225+
See [`~loaders.StableDiffusionLoraLoaderMixin.fuse_lora`] for more details.
3226+
"""
3227+
super().fuse_lora(
3228+
components=components,
3229+
lora_scale=lora_scale,
3230+
safe_fusing=safe_fusing,
3231+
adapter_names=adapter_names,
3232+
**kwargs,
3233+
)
3234+
3235+
# Copied from diffusers.loaders.lora_pipeline.CogVideoXLoraLoaderMixin.unfuse_lora
3236+
def unfuse_lora(self, components: List[str] = ["transformer"], **kwargs):
3237+
r"""
3238+
See [`~loaders.StableDiffusionLoraLoaderMixin.unfuse_lora`] for more details.
3239+
"""
3240+
super().unfuse_lora(components=components, **kwargs)
3241+
3242+
30143243
class SanaLoraLoaderMixin(LoraBaseMixin):
30153244
r"""
30163245
Load LoRA layers into [`SanaTransformer2DModel`]. Specific to [`SanaPipeline`].

src/diffusers/loaders/peft.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@
6767
"QwenImageTransformer2DModel": lambda model_cls, weights: weights,
6868
"Flux2Transformer2DModel": lambda model_cls, weights: weights,
6969
"ZImageTransformer2DModel": lambda model_cls, weights: weights,
70+
"LTX2VideoTransformer3DModel": lambda model_cls, weights: weights,
71+
"LTX2TextConnectors": lambda model_cls, weights: weights,
7072
}
7173

7274

src/diffusers/pipelines/ltx2/connectors.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import torch.nn.functional as F
66

77
from ...configuration_utils import ConfigMixin, register_to_config
8+
from ...loaders import PeftAdapterMixin
89
from ...models.attention import FeedForward
910
from ...models.modeling_utils import ModelMixin
1011
from ...models.transformers.transformer_ltx2 import LTX2Attention, LTX2AudioVideoAttnProcessor
@@ -252,7 +253,7 @@ def forward(
252253
return hidden_states, attention_mask
253254

254255

255-
class LTX2TextConnectors(ModelMixin, ConfigMixin):
256+
class LTX2TextConnectors(ModelMixin, PeftAdapterMixin, ConfigMixin):
256257
"""
257258
Text connector stack used by LTX 2.0 to process the packed text encoder hidden states for both the video and audio
258259
streams.

src/diffusers/pipelines/ltx2/pipeline_ltx2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from transformers import Gemma3ForConditionalGeneration, GemmaTokenizer, GemmaTokenizerFast
2222

2323
from ...callbacks import MultiPipelineCallbacks, PipelineCallback
24-
from ...loaders import FromSingleFileMixin, LTXVideoLoraLoaderMixin
24+
from ...loaders import FromSingleFileMixin, LTX2LoraLoaderMixin
2525
from ...models.autoencoders import AutoencoderKLLTX2Audio, AutoencoderKLLTX2Video
2626
from ...models.transformers import LTX2VideoTransformer3DModel
2727
from ...schedulers import FlowMatchEulerDiscreteScheduler
@@ -184,7 +184,7 @@ def rescale_noise_cfg(noise_cfg, noise_pred_text, guidance_rescale=0.0):
184184
return noise_cfg
185185

186186

187-
class LTX2Pipeline(DiffusionPipeline, FromSingleFileMixin, LTXVideoLoraLoaderMixin):
187+
class LTX2Pipeline(DiffusionPipeline, FromSingleFileMixin, LTX2LoraLoaderMixin):
188188
r"""
189189
Pipeline for text-to-video generation.
190190

0 commit comments

Comments
 (0)