feat(ltx): add the ltx-2.5 model family

2.5 reuses the LTX-2 pipeline classes, so it is described through the
capability table: Gemma 4 text encoder, cross-timestep conditioning, and the
upsampler and stage 2 LoRA that now ship inside the model repo. The repo
carries the distilled scheduler config, so Dev rows restore the terminal
shift, and the Dev transformer sits in transformer_full.

Distilled rows stop forcing dynamic shifting on, which remapped their sigma
schedule. Auto duration hands the clip length to the duration head.
This commit is contained in:
CalamitousFelicitousness
2026-08-12 02:03:18 +01:00
parent 0d1882eca4
commit 5814d5c4b3
7 changed files with 140 additions and 38 deletions
+31 -9
View File
@@ -9,7 +9,7 @@ class LTXCaps:
name: str
repo_cls_name: str
family: str # '0.9' or '2.x'
variant: str # '0.9', '2.0', '2.3' (finer-grained sub-variant)
variant: str # '0.9', '2.0', '2.3', '2.5' (finer-grained sub-variant)
is_distilled: bool
is_i2v: bool
supports_input_media: bool
@@ -41,6 +41,13 @@ class LTXCaps:
# for refine; same-res refine oversaturates. Condition variants rebuild conditions per stage.
supports_two_stage_refine: bool = False
stage2_dev_lora_repo: Optional[str] = None
# 2.5 keeps the stage 2 LoRA in the model repo; 2.0 and 2.3 each have their own
stage2_dev_lora_weight: Optional[str] = None
# tied to the family VAE: the wrong one drifts per-channel latent statistics
upsample_repo: Optional[str] = None
# 2.5 ships the distilled scheduler config, so its Dev rows restore the terminal shift
scheduler_shift_terminal: Optional[float] = None
supports_auto_duration: bool = False
CONDITION_CLASSES = {'LTXConditionPipeline', 'LTX2ConditionPipeline'}
@@ -84,7 +91,12 @@ def get_caps(model_name: str) -> Optional[LTXCaps]:
family = '2.x' if is_ltx2 else '0.9'
# 2.x sub-variant detection: unknown 2.x mirrors fall through to '2.0' (conservative default).
if is_ltx2:
variant = '2.3' if '2.3' in model_name else '2.0'
if '2.5' in model_name:
variant = '2.5'
elif '2.3' in model_name:
variant = '2.3'
else:
variant = '2.0'
else:
variant = '0.9'
is_distilled = 'Distilled' in model_name
@@ -106,11 +118,12 @@ def get_caps(model_name: str) -> Optional[LTXCaps]:
supports_stg=is_ltx2,
supports_audio=is_ltx2,
supports_frame_rate_kwarg=is_ltx2,
use_cross_timestep=(variant == '2.3'),
use_cross_timestep=variant in ('2.3', '2.5'),
default_cfg=3.0,
default_steps=30 if is_ltx2 else 50,
default_sampler_shift=-1.0,
default_dynamic_shift=is_ltx2,
# distilled ships use_dynamic_shifting=False and runs explicit sigmas, which shifting remaps
default_dynamic_shift=is_ltx2 and not is_distilled,
default_width=768,
default_height=512,
default_frames=121 if is_ltx2 else 161,
@@ -122,7 +135,10 @@ def get_caps(model_name: str) -> Optional[LTXCaps]:
caps.default_steps = 8
if is_ltx2 and not is_distilled:
if variant == '2.3':
if variant == '2.5':
caps.stage2_dev_lora_repo = 'Lightricks/LTX-2.5-Diffusers'
caps.stage2_dev_lora_weight = 'ltx-2.5-22b-distilled-lora-450-bf16.safetensors'
elif variant == '2.3':
caps.stage2_dev_lora_repo = 'CalamitousFelicitousness/LTX-2.3-distilled-lora-384-Diffusers'
elif variant == '2.0':
caps.stage2_dev_lora_repo = 'CalamitousFelicitousness/LTX-2.0-distilled-lora-384-Diffusers'
@@ -130,16 +146,22 @@ def get_caps(model_name: str) -> Optional[LTXCaps]:
caps.supports_two_stage_refine = is_ltx2
if is_ltx2:
if variant == '2.3':
if variant == '2.5':
caps.upsample_repo = 'Lightricks/LTX-2.5-Diffusers'
caps.stg_default_blocks = [28]
caps.supports_auto_duration = True
elif variant == '2.3':
caps.upsample_repo = 'CalamitousFelicitousness/LTX-2.3-Spatial-Upsampler-x2-1.1-Diffusers'
caps.stg_default_blocks = [28]
elif variant == '2.0':
caps.stg_default_blocks = [29]
else:
caps.stg_default_blocks = [28]
caps.upsample_repo = 'Lightricks/LTX-2'
caps.stg_default_blocks = [29]
if not is_distilled:
# canonical T2V composition from huggingface/diffusers#13217
caps.stg_default_scale = 1.0
caps.modality_default_scale = 3.0
caps.guidance_rescale_default = 0.7
if variant == '2.5':
caps.scheduler_shift_terminal = 0.1
return caps
+23 -17
View File
@@ -12,16 +12,12 @@ from modules.ltx.ltx_util import get_bucket, get_frames, load_model, load_upsamp
apply_ltx_diffusers_patch()
from modules.processing_callbacks import diffusers_callback
from modules.video_models.video_vae import set_vae_params
from modules.video_models.video_save import save_video
from modules.video_models.video_save import save_video, get_audio_rate
from modules.video_models.video_utils import check_av
debug = log.trace if os.environ.get('SD_VIDEO_DEBUG', None) is not None else lambda *args, **kwargs: None
upsample_repo_id_09 = 'a-r-r-o-w/LTX-Video-0.9.7-Latent-Spatial-Upsampler-diffusers'
# Upsampler weights are tied to the family VAE; using the wrong one preserves structure
# but drifts per-channel latent statistics (decodes desaturated / crushed contrast).
upsample_repo_id_20 = 'Lightricks/LTX-2'
upsample_repo_id_23 = 'CalamitousFelicitousness/LTX-2.3-Spatial-Upsampler-x2-1.1-Diffusers'
upsample_pipe = None
upsample_pipe_2x = None
@@ -93,7 +89,7 @@ def _latent_pass(caps, prompt_embeds, prompt_attention_mask, negative_prompt_emb
'negative_prompt_attention_mask': negative_prompt_attention_mask,
'width': get_bucket(width),
'height': get_bucket(height),
'num_frames': get_frames(frames),
'num_frames': get_frames(frames) if frames is not None else None, # None defers to the duration head
'num_inference_steps': steps,
'generator': get_generator(seed),
'callback_on_step_end': diffusers_callback,
@@ -131,6 +127,7 @@ def run_ltx(task_id,
width: int,
height: int,
frames: int,
auto_duration: bool,
steps: int,
sampler_index: int,
guidance_scale: float,
@@ -199,6 +196,10 @@ def run_ltx(task_id,
yield from abort(f'Video: cls={shared.sd_model.__class__.__name__} selected model is not LTX', ok=True)
return
auto_frames = bool(auto_duration) and caps.supports_auto_duration
if auto_duration and not auto_frames:
log.warning(f'LTX: model="{model}" auto duration unsupported, using frames={get_frames(frames)}')
# Lightricks TI2VidTwoStagesPipeline: Stage 1 at half-res, 2x upsample, Stage 2 refine at target.
# Auto-couple when the user picks Refine but not Upsample. Both Dev and Distilled refine paths
# expect upsampled latents; same-res refine on Distilled produces oversaturation. Condition
@@ -328,14 +329,15 @@ def run_ltx(task_id,
p.task_args.update(_canonical_ltx2_guidance(caps))
if caps.family == '2.x':
p.task_args['use_cross_timestep'] = caps.use_cross_timestep
if auto_frames:
p.task_args['num_frames'] = None
framewise = caps.family == '0.9'
set_vae_params(p, framewise=framewise)
# Scheduler + shared.opts mutation is wrapped in ltx_scheduler_opts so restore runs on
# every exit path (normal return, abort, interrupt, Stage 2 scheduler swap). See the
# helper's docstring for the five pieces of state it snapshots.
with ltx_scheduler_opts(shared.sd_model, dynamic_shift=dynamic_shift, sampler_shift=sampler_shift):
# every exit path (normal return, abort, interrupt, Stage 2 scheduler swap).
with ltx_scheduler_opts(shared.sd_model, dynamic_shift=dynamic_shift, sampler_shift=sampler_shift, shift_terminal=caps.scheduler_shift_terminal):
if selected is not None:
video_overrides.set_overrides(p, selected)
@@ -382,7 +384,7 @@ def run_ltx(task_id,
negative_prompt_attention_mask=negative_prompt_attention_mask,
width=base_w,
height=base_h,
frames=frames,
frames=None if auto_frames else frames,
steps=steps,
guidance_scale=p.cfg_scale,
mp4_fps=mp4_fps,
@@ -391,6 +393,11 @@ def run_ltx(task_id,
seed=p.seed,
image=p.task_args.get('image'),
)
if auto_frames and torch.is_tensor(latents):
# upsample and refine take the realized length; re-predicting would drift
frames = (latents.shape[-3] - 1) * getattr(shared.sd_model, 'vae_temporal_compression_ratio', 8) + 1
p.frames = frames
log.debug(f'LTX: auto duration frames={frames}')
else:
processed = processing.process_images(p)
if processed is None or processed.images is None or len(processed.images) == 0:
@@ -451,8 +458,7 @@ def run_ltx(task_id,
upsample_pipe = sd_models.apply_balanced_offload(upsample_pipe, exclude=upsample_exclude, silent=True)
else:
global upsample_pipe_2x # pylint: disable=global-statement
upsample_repo = upsample_repo_id_23 if caps.variant == '2.3' else upsample_repo_id_20
upsample_pipe_2x = load_upsample_2x(upsample_pipe_2x, upsample_repo)
upsample_pipe_2x = load_upsample_2x(upsample_pipe_2x, caps.upsample_repo, caps.variant)
upsample_pipe_2x = sd_models.apply_balanced_offload(upsample_pipe_2x, exclude=upsample_exclude, silent=True)
# 2.x base pass returns denormalized latents; latents_normalized=False tells the
# upsampler "already raw, do not denormalize again".
@@ -534,12 +540,15 @@ def run_ltx(task_id,
shift_terminal=None,
)
if caps.supports_canonical_stage2:
log.debug(f'LTX: stage=2 distilled=LoRA repo={caps.stage2_dev_lora_repo}')
log.debug(f'LTX: stage=2 distilled=LoRA repo={caps.stage2_dev_lora_repo} weight={caps.stage2_dev_lora_weight}')
offline_args = {'local_files_only': True} if shared.opts.offline_mode else {}
# 2.5 keeps the LoRA in the model repo, so the file has to be named
lora_args ={'weight_name': caps.stage2_dev_lora_weight} if caps.stage2_dev_lora_weight is not None else {}
shared.sd_model.load_lora_weights(
caps.stage2_dev_lora_repo,
adapter_name=STAGE2_DEV_LORA_ADAPTER,
cache_dir=shared.opts.hfcache_dir,
**lora_args,
**offline_args,
)
shared.sd_model.set_adapters([STAGE2_DEV_LORA_ADAPTER], [1.0])
@@ -628,10 +637,7 @@ def run_ltx(task_id,
if not audio_enable:
audio = None
try:
aac_sample_rate = shared.sd_model.vocoder.config.output_sampling_rate
except Exception:
aac_sample_rate = 24000
aac_sample_rate = get_audio_rate(p)
if mp4_interpolate > 0 and pixels is not None:
p.video_interpolate = mp4_interpolate
+7 -1
View File
@@ -29,6 +29,7 @@ def _model_change(model_name: str):
gr.update(interactive=False), # decode_timestep
gr.update(interactive=False), # image_cond_noise_scale
gr.update(visible=False), # audio_accordion
gr.update(visible=False, value=False), # auto_duration
)
# 2.x refine runs fixed canonical schedules; refine_strength only feeds 0.9.x LTXConditionPipeline.
refine_strength_interactive = caps.family == '0.9'
@@ -36,6 +37,7 @@ def _model_change(model_name: str):
# Distilled T2V/I2V). auto_refine_upsample at ltx_process.py:179 couples the stages once Refine
# is on. Condition variants are excluded by supports_two_stage_refine.
refine_default = caps.supports_two_stage_refine
auto_duration_update =gr.update(visible=True) if caps.supports_auto_duration else gr.update(visible=False, value=False)
return (
gr.update(visible=caps.supports_input_media),
gr.update(visible=caps.supports_multi_condition),
@@ -52,6 +54,7 @@ def _model_change(model_name: str):
gr.update(interactive=caps.supports_decode_timestep),
gr.update(interactive=caps.supports_image_cond_noise_scale),
gr.update(visible=caps.supports_audio),
auto_duration_update,
)
@@ -71,6 +74,8 @@ def create_ui(prompt, negative, styles, overrides, script_inputs, mp4_fps, mp4_i
seed = gr.Number(label='LTX seed', value=-1, elem_id='ltx_seed', container=True)
random_seed = ToolButton(ui_symbols.random, elem_id='ltx_seed_random')
random_seed.click(fn=lambda: -1, show_progress='hidden', inputs=[], outputs=[seed])
with gr.Row():
auto_duration = gr.Checkbox(label='LTX auto duration', value=False, elem_id='ltx_auto_duration', visible=False)
input_media_accordion = gr.Accordion(open=False, label="Input media", elem_id='ltx_input_media_accordion', visible=False)
with input_media_accordion:
ltx_init_image = gr.Image(label='Image', elem_id='ltx_init_image', type='pil', image_mode='RGB', width=256, height=256)
@@ -145,6 +150,7 @@ def create_ui(prompt, negative, styles, overrides, script_inputs, mp4_fps, mp4_i
decode_timestep,
image_cond_noise_scale,
audio_accordion,
auto_duration,
],
)
@@ -155,7 +161,7 @@ def create_ui(prompt, negative, styles, overrides, script_inputs, mp4_fps, mp4_i
video_inputs = [
model,
prompt, negative, styles,
width, height, frames,
width, height, frames, auto_duration,
steps, sampler_index,
guidance_scale, sampler_shift, dynamic_shift,
seed,
+46
View File
@@ -149,6 +149,52 @@ try:
'LTX Video': [
Model(name='None'),
Model(name='─────── LTX-2.5 ───────'),
Model(name='─── Distilled ───'),
Model(name='LTXVideo 2.5 22B T2V Distilled',
url='https://huggingface.co/Lightricks/LTX-2.5',
repo='Lightricks/LTX-2.5-Diffusers',
repo_cls='LTX2Pipeline',
te_cls='Gemma4UnifiedForConditionalGeneration',
dit_cls='LTX2VideoTransformer3DModel'),
Model(name='LTXVideo 2.5 22B I2V Distilled',
url='https://huggingface.co/Lightricks/LTX-2.5',
repo='Lightricks/LTX-2.5-Diffusers',
repo_cls='LTX2ImageToVideoPipeline',
te_cls='Gemma4UnifiedForConditionalGeneration',
dit_cls='LTX2VideoTransformer3DModel'),
Model(name='LTXVideo 2.5 22B Condition Distilled',
url='https://huggingface.co/Lightricks/LTX-2.5',
repo='Lightricks/LTX-2.5-Diffusers',
repo_cls='LTX2ConditionPipeline',
te_cls='Gemma4UnifiedForConditionalGeneration',
dit_cls='LTX2VideoTransformer3DModel'),
Model(name='─── Dev ───'), # transformer_full is the guided model, transformer the distilled one
Model(name='LTXVideo 2.5 22B T2V Dev',
url='https://huggingface.co/Lightricks/LTX-2.5',
repo='Lightricks/LTX-2.5-Diffusers',
repo_cls='LTX2Pipeline',
te_cls='Gemma4UnifiedForConditionalGeneration',
dit_cls='LTX2VideoTransformer3DModel',
dit_folder='transformer_full',
dit_kwarg='transformer'),
Model(name='LTXVideo 2.5 22B I2V Dev',
url='https://huggingface.co/Lightricks/LTX-2.5',
repo='Lightricks/LTX-2.5-Diffusers',
repo_cls='LTX2ImageToVideoPipeline',
te_cls='Gemma4UnifiedForConditionalGeneration',
dit_cls='LTX2VideoTransformer3DModel',
dit_folder='transformer_full',
dit_kwarg='transformer'),
Model(name='LTXVideo 2.5 22B Condition Dev',
url='https://huggingface.co/Lightricks/LTX-2.5',
repo='Lightricks/LTX-2.5-Diffusers',
repo_cls='LTX2ConditionPipeline',
te_cls='Gemma4UnifiedForConditionalGeneration',
dit_cls='LTX2VideoTransformer3DModel',
dit_folder='transformer_full',
dit_kwarg='transformer'),
Model(name='─────── LTX-2.3 v1.1 ───────'),
Model(name='LTXVideo 2.3-1.1 22B T2V Distilled',
url='https://huggingface.co/Lightricks/LTX-2.3',
+14 -7
View File
@@ -17,17 +17,24 @@ def load_override(selected: Model, **load_args):
# LTX
if 'LTXVideo 0.9.5 I2V' in selected.name:
kwargs['vae'] = diffusers.AutoencoderKLLTXVideo.from_pretrained(selected.repo, subfolder="vae", torch_dtype=torch.float32, cache_dir=shared.opts.hfcache_dir, **load_args)
# OzzyGT LTX-2.3 mirrors pack connectors/ twice by design: sharded (*-00001-of-0000N +
# .index.json) and unsharded diffusion_pytorch_model.safetensors of the byte-identical
# weights. snapshot_download faithfully fetches both; diffusers' component loader picks
# sharded when the index is present. ignore_patterns skips the ~6.3 GB unsharded copy
# without reaching for a cleaner upstream mirror.
# OzzyGT LTX-2.3 mirrors and the LTX-2.5 repo pack connectors/ twice by design: sharded
# (*-00001-of-0000N + .index.json) and unsharded diffusion_pytorch_model.safetensors of the
# byte-identical weights. snapshot_download faithfully fetches both; diffusers' component
# loader picks sharded when the index is present. ignore_patterns skips the ~6.3 GB unsharded
# copy without reaching for a cleaner upstream mirror.
ltx2_redundant_connector_repos = {
'OzzyGT/LTX-2.3',
'OzzyGT/LTX-2.3-sdnq-dynamic-int4',
}
if selected.repo in ltx2_redundant_connector_repos:
kwargs['ignore_patterns'] = ['connectors/diffusion_pytorch_model.safetensors']
ltx2_ignore = []
if selected.repo in ltx2_redundant_connector_repos or 'LTXVideo 2.5' in selected.name:
ltx2_ignore.append('connectors/diffusion_pytorch_model.safetensors')
if 'LTXVideo 2.5' in selected.name:
# the pipeline fetch pulls every model-index folder except passed components: transformer_full
# is not one, the diffusion decoder is a separate pipeline, the LoRA is fetched on demand
ltx2_ignore += ['transformer_full/*', 'diffusion_decoder/*', 'ltx-2.5-22b-distilled-lora-450-bf16.safetensors']
if ltx2_ignore:
kwargs['ignore_patterns'] = ltx2_ignore
# LTX2TextConnectors weights are byte-identical across all 2.3 variants (verified by blob
# hash). Pre-load from a canonical repo so per-variant fetches skip connectors/ entirely.
# FP16 variants share OzzyGT/LTX-2.3; SDNQ variants share the pre-quantized mirror.