From cfe6d9344dbbf442d4e8fdf0521d10ff7bba68f3 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 12 Oct 2026 04:15:04 -0700 Subject: [PATCH] Studio: refuse LTX-2.5 instead of loading it as LTX-2 (#13370) --- .../core/inference/diffusion_content.py | 28 ++ studio/backend/core/inference/video.py | 45 +- .../backend/core/inference/video_families.py | 93 ++++ studio/backend/core/inference/video_ltx2.py | 11 + .../core/training/diffusion_train_common.py | 12 + .../backend/tests/test_video_ltx25_refusal.py | 411 ++++++++++++++++++ 6 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 studio/backend/tests/test_video_ltx25_refusal.py diff --git a/studio/backend/core/inference/diffusion_content.py b/studio/backend/core/inference/diffusion_content.py index 4eb55324f5..93f584c20c 100644 --- a/studio/backend/core/inference/diffusion_content.py +++ b/studio/backend/core/inference/diffusion_content.py @@ -239,6 +239,19 @@ def _any(keys, pattern: str) -> bool: return any(rx.search(k) for k in keys) +LTX25_VARIANT = "ltx-2.5" + + +def is_ltx25_dit_keys(keys) -> bool: + """LTX-2.5 DiT: keyframe position embedding, or block 0 video FFN without bias (every 2.0 / 2.3 file has it).""" + names = set(keys) + if "keyframes_abs_pos_embedding" in names: + return True + ff = "transformer_blocks.0.ff.net.0.proj" + has_weight = any(k == f"{ff}.weight" or k.startswith(f"{ff}.weight") for k in names) + return has_weight and f"{ff}.bias" not in names + + def _dit(family: Optional[str], what: str, layout: str) -> CheckpointInfo: return CheckpointInfo(ROLE_DIT, family, family_page(family), what, layout) @@ -313,6 +326,17 @@ def _match_dit(s: dict[str, list[int]]) -> Optional[CheckpointInfo]: or "av_cross_attn_audio_scale_shift" in tops or "audio_caption_projection" in tops ): + if is_ltx25_dit_keys(keys): + # No family: the ltx-2 name must not revive it. + return CheckpointInfo( + ROLE_DIT, + None, + None, + "an LTX-2.5 audio-video diffusion transformer, which Studio does not support yet " + "(use LTX-2.3 for now)", + "diffusers" if "proj_in" in tops else "comfy", + LTX25_VARIANT, + ) return _dit( "ltx-2", "an LTX-2 audio-video diffusion transformer", @@ -630,6 +654,10 @@ def refusal_for(path: str, page: str) -> Optional[str]: return None +def is_ltx25_checkpoint(path: str) -> bool: + return inspect_checkpoint(path).variant == LTX25_VARIANT + + def resolve_family_with_content( name_family: Optional[str], path: Optional[str], diff --git a/studio/backend/core/inference/video.py b/studio/backend/core/inference/video.py index 820c7abdb4..e5db106354 100644 --- a/studio/backend/core/inference/video.py +++ b/studio/backend/core/inference/video.py @@ -200,6 +200,8 @@ from .video_families import ( VIDEO_GENERATION_BUSY_MSG, VIDEO_MODEL_CHANGED_MSG, VIDEO_NOT_LOADED_MSG, + assert_local_pipeline_not_ltx25, + assert_ltx_pick_supported, pipeline_available_video_families, VideoFamily, default_video_generation_params, @@ -1045,11 +1047,32 @@ def _refuse_non_dit_pick(repo_id: str, gguf_filename: Optional[str], page: str) assert_local_pick_is_dit(repo_id, gguf_filename, page) +def _refuse_local_ltx25_pipeline(repo_id: str, gguf_filename: Optional[str]) -> None: + if not gguf_filename: + assert_local_pipeline_not_ltx25(repo_id) + + def _detect_load_family( repo_id: str, gguf_filename: Optional[str], family_override: Optional[str], display_repo_id: Optional[str] = None, + model_kind: Optional[str] = None, +) -> Optional[VideoFamily]: + # An explicit pipeline load never opens the filename, so it must not vouch for the repo. + ltx_file = None if str(model_kind or "").strip().lower() == "pipeline" else gguf_filename + _refuse_local_ltx25_pipeline(repo_id, ltx_file) + fam = _resolve_load_family(repo_id, gguf_filename, family_override, display_repo_id) + # After resolution: family_override="ltx-2" must not load LTX-2.5, and a non-LTX pick never trips on the name. + assert_ltx_pick_supported(fam.name if fam else None, repo_id, ltx_file, display_repo_id) + return fam + + +def _resolve_load_family( + repo_id: str, + gguf_filename: Optional[str], + family_override: Optional[str], + display_repo_id: Optional[str] = None, ) -> Optional[VideoFamily]: """Family detection shared by validate_load_request and the load worker: the repo id first, then the picked filename -- a local directory or generically @@ -2681,7 +2704,9 @@ class VideoBackend: "(gguf_filename) instead of loading it as a diffusers pipeline." ) _refuse_non_dit_pick(repo_id, gguf_filename, "video") - fam = _detect_load_family(repo_id, gguf_filename, family_override, display_repo_id) + fam = _detect_load_family( + repo_id, gguf_filename, family_override, display_repo_id, model_kind + ) if fam is None: raise ValueError( f"'{repo_id}' is not a supported text-to-video model. Supported families: " @@ -2826,6 +2851,10 @@ class VideoBackend: f"base_repo is limited to unsloth/* repos, the official family base " f"repos, and local paths; '{base_repo}' is neither." ) + if base_repo and base_repo.strip(): + # The companion supplies the text encoder / VAE, so an LTX-2.5 base is refused like an LTX-2.5 pick. + assert_ltx_pick_supported(fam.name, base_repo) + assert_local_pipeline_not_ltx25(base_repo) # A local base_repo loads as a full pipeline (needs model_index.json); reject a non-pipeline one here, before # the load. from core.inference.diffusion import _assert_local_base_is_pipeline @@ -3070,6 +3099,7 @@ class VideoBackend: kwargs.get("gguf_filename"), kwargs.get("family_override"), kwargs.get("display_repo_id"), + kwargs.get("model_kind"), ) # Also on the worker, which a direct begin_load reaches without a plan. Here rather than in # validate_load_request, which is network-free by contract. @@ -4990,7 +5020,9 @@ class VideoBackend: download manager.""" from huggingface_hub import HfApi - fam = _detect_load_family(repo_id, gguf_filename, family_override, display_repo_id) + fam = _detect_load_family( + repo_id, gguf_filename, family_override, display_repo_id, model_kind + ) # _detect_load_family resolves from the REPO id first, so a mixed repo answers its media family for every file # in it, a csm quant included. Refuse before the plan stages a byte. _assert_pick_is_not_speech(repo_id, gguf_filename, hf_token) @@ -6414,8 +6446,15 @@ class VideoBackend: sf_kwargs["quantization_config"] = diffusers.GGUFQuantizationConfig( compute_dtype = dtype ) - from .video_ltx2 import direct_load_device, is_ltx23_checkpoint, load_ltx23_pipeline + from .video_ltx2 import ( + assert_not_ltx25_checkpoint, + direct_load_device, + is_ltx23_checkpoint, + load_ltx23_pipeline, + ) + if fam.name == "ltx-2": + assert_not_ltx25_checkpoint(checkpoint_path) if fam.name == "ltx-2" and is_ltx23_checkpoint(checkpoint_path): # Explicit fp8 takes the hosted DiT, resident only: offload hooks' Module.to() rejects torchao tensors. ltx23_override = None diff --git a/studio/backend/core/inference/video_families.py b/studio/backend/core/inference/video_families.py index a7939b3665..eb92ced4e5 100644 --- a/studio/backend/core/inference/video_families.py +++ b/studio/backend/core/inference/video_families.py @@ -36,6 +36,99 @@ VIDEO_CANCELLED_MSG = "Video generation was cancelled." VIDEO_GENERATION_BUSY_MSG = "A video generation is already in progress." VIDEO_MODEL_CHANGED_MSG = "The requested video model changed before generation was reserved." +# One LTX version token: ltx / ltxv / ltx-video, optional separator, major 2, then ".N" / "_N" (minor), "N" ending the +# token ("ltx25"), or nothing ("ltx-2-19b" = 2.0). Anything else ("ltx-24fps", "ltx-transformer") is no name evidence, +# as is a bare "ltx23" / "ltx-23" (no release uses that spelling, so it cannot outrank the repo). +_LTX_VERSION = re.compile( + r"(? Optional[str]: + for found in _LTX_VERSION.finditer((name or "").lower()): + if found.group(2) is None or int(found.group(2)) >= 4: + return f"2.{found.group(1) or found.group(2) or 0}" + return None + + +def unsupported_ltx_version(*names: Optional[str]) -> Optional[str]: + # LTX > 2.3 (diffusers calls the 2.5 pre-release "2.4") is not drop-in LTX-2. + for name in names: + version = ltx_name_version(name) + if version is not None and int(version[2:]) >= 4: + return version + return None + + +def unsupported_ltx_message(version: str, subject: str) -> str: + return ( + f"{subject} is LTX-{version}, which Studio does not support yet. LTX-{version} is not a drop-in LTX-2.3: " + "it uses a new Gemma 4 text encoder, a new video decoder and its own sampling schedule, so loading it as " + "LTX-2 would fail or produce a broken video. Use LTX-2.3 (Lightricks/LTX-2.3 or unsloth/LTX-2.3-GGUF) " + "for now." + ) + + +def assert_supported_ltx_version( + repo_id: Optional[str], + filename: Optional[str] = None, + display_repo_id: Optional[str] = None, +) -> None: + """A file whose own name has a versioned LTX token decides alone (an ltx-2.3 file in an ``ltx-2.5-tests`` folder + loads); any other file name (``model``, ``ltx-transformer``) defers to the repo / display id.""" + + def leaf(name: Optional[str]) -> str: + # Last segment only: a local parent folder must not decide. + return os.path.basename((name or "").replace("\\", "/").rstrip("/")) + + base = leaf(filename) + if ltx_name_version(base) is not None: + picks: tuple[tuple[str, Optional[str]], ...] = ((base, filename),) + else: + picks = ( + (base, filename), + (leaf(display_repo_id), display_repo_id), + (leaf(repo_id), repo_id), + ) + for name, shown in picks: + version = unsupported_ltx_version(name) + if version is not None: + raise ValueError(unsupported_ltx_message(version, f"'{shown}'")) + + +def assert_ltx_pick_supported( + family: Optional[str], + repo_id: Optional[str], + filename: Optional[str] = None, + display_repo_id: Optional[str] = None, +) -> None: + """Name guard for a pick already resolved to ``family``; a non-LTX family never trips on an ltx-2.5 token.""" + if family == "ltx-2": + assert_supported_ltx_version(repo_id, filename, display_repo_id) + + +def assert_local_pipeline_not_ltx25(path: Optional[str]) -> None: + """Refuse a local diffusers LTX-2.5 folder whose name does not say so; unreadable passes.""" + import json + from pathlib import Path + + try: + index = Path(str(path or "")).expanduser() / "model_index.json" + if not path or not index.is_file(): + return + spec = json.loads(index.read_text(encoding = "utf-8")) + except (OSError, ValueError, TypeError): + return + if not isinstance(spec, dict) or not str(spec.get("_class_name", "")).startswith("LTX2"): + return + encoder = spec.get("text_encoder") + encoder_class = encoder[1] if isinstance(encoder, list) and len(encoder) == 2 else "" + if str(encoder_class).startswith("Gemma4") or any( + isinstance(spec.get(k), list) and spec.get(k)[0] + for k in ("diffusion_decoder", "duration_head") + ): + raise ValueError(unsupported_ltx_message("2.5", f"'{path}' (by its model_index.json)")) + @dataclass(frozen = True) class VideoFamily: diff --git a/studio/backend/core/inference/video_ltx2.py b/studio/backend/core/inference/video_ltx2.py index d5c0ea21cb..47bf8682e1 100644 --- a/studio/backend/core/inference/video_ltx2.py +++ b/studio/backend/core/inference/video_ltx2.py @@ -283,6 +283,17 @@ def is_ltx23_checkpoint(checkpoint_path: Path | str) -> bool: return False +def assert_not_ltx25_checkpoint(checkpoint_path: Path | str) -> None: + """Catch a renamed LTX-2.5 file by header: ``is_ltx23_checkpoint`` counts modulation rows, and 2.5 keeps 2.3's nine.""" + from .diffusion_content import is_ltx25_checkpoint + from .video_families import unsupported_ltx_message + + if is_ltx25_checkpoint(str(checkpoint_path)): + raise ValueError( + unsupported_ltx_message("2.5", f"'{Path(str(checkpoint_path)).name}' (by its tensors)") + ) + + def _apply_rename(state: dict[str, Any], rename: dict[str, str]) -> dict[str, Any]: out: dict[str, Any] = {} for key, value in state.items(): diff --git a/studio/backend/core/training/diffusion_train_common.py b/studio/backend/core/training/diffusion_train_common.py index d147764d5f..0614566386 100644 --- a/studio/backend/core/training/diffusion_train_common.py +++ b/studio/backend/core/training/diffusion_train_common.py @@ -280,6 +280,14 @@ def training_pipeline_import_error(resolved_family: str) -> Optional[str]: def resolve_trainable_family(base_model: str, model_family: Optional[str] = None) -> str: + family = _resolve_trainable_family(base_model, model_family) + from core.inference.video_families import assert_ltx_pick_supported + + assert_ltx_pick_supported(family, base_model) + return family + + +def _resolve_trainable_family(base_model: str, model_family: Optional[str] = None) -> str: """Resolve the trainer family for a base model, or raise ValueError with a clear reason. Positive resolution, before anything is downloaded: a ``.gguf`` name can never be a training base; an explicit ``model_family`` must name a registry family, and that family must be @@ -313,6 +321,10 @@ def resolve_trainable_family(base_model: str, model_family: Optional[str] = None # Same shape as the component-only refusal: the name resolves to a real, trainable family, but the repo is not # something the training loader can open. _refuse_ltx23_training_base(base_model) + # Before either branch: model_family="ltx-2" must not train a local LTX-2.5 pipeline. + from core.inference.video_families import assert_local_pipeline_not_ltx25 + + assert_local_pipeline_not_ltx25(base_model) if model_family and str(model_family).strip(): key = str(model_family).strip().lower() fam = detect_family("", override = key) diff --git a/studio/backend/tests/test_video_ltx25_refusal.py b/studio/backend/tests/test_video_ltx25_refusal.py new file mode 100644 index 0000000000..6b6dfee241 --- /dev/null +++ b/studio/backend/tests/test_video_ltx25_refusal.py @@ -0,0 +1,411 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""LTX-2.5 is refused instead of being loaded as LTX-2; LTX-2.0 / 2.3 still resolve to ltx-2.""" + +from __future__ import annotations + +import json +import struct +from pathlib import Path + +import pytest + +from core.inference import diffusion_content as dc +from core.inference.video_families import ( + assert_supported_ltx_version, + detect_video_family, + unsupported_ltx_version, +) + +_FIXTURE = json.loads( + (Path(__file__).parent / "fixtures" / "comfy_checkpoint_headers.json").read_text( + encoding = "utf-8" + ) +) + +LTX25_NAMES = [ + "Lightricks/LTX-2.5-Diffusers", + "Lightricks/LTX-2.5", + "lightricks/ltx-2.5", + "ltx-2.5-22b-distilled-transformer-bf16.safetensors", + "diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors", + "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "ltx-2.5-22b-distilled-transformer-nvfp4.safetensors", + "someone/LTX-2.5-GGUF", + "ltx2.5", + "ltx2_5", + "LTX25", + "LTX-Video-2.5", +] + +LTX2X_NAMES = [ + "Lightricks/LTX-2", + "Lightricks/LTX-2.3", + "Lightricks/LTX-2.3-fp8", + "unsloth/LTX-2-FP8", + "unsloth/LTX-2.3-FP8", + "unsloth/LTX-2.3-GGUF", + "ltx-2-19b-dev.safetensors", + "ltx-2-19b-distilled-fp8.safetensors", + "ltx-2.3-22b-distilled.safetensors", + "ltx-2.3-22b-distilled-1.1-Q4_K_M.gguf", + "LTX-2.3-22B-distilled-1.1-Q4_K_M.gguf", + "ltx-2.3-22b-distilled-1.1_transformer_only_int8_convrot.safetensors", + "ltx-2.3-spatial-upscaler-x1.5-1.0.safetensors", + "ltxv", + "ltx2", +] + + +@pytest.mark.parametrize("name", LTX25_NAMES) +def test_ltx25_names_are_recognised(name): + assert unsupported_ltx_version(name) == "2.5" + + +@pytest.mark.parametrize("name", LTX2X_NAMES) +def test_ltx2_and_ltx23_names_are_untouched(name): + assert unsupported_ltx_version(name) is None + assert detect_video_family(name).name == "ltx-2" + + +@pytest.mark.parametrize("name", ["ltx-24fps-clip", "voltx-2.5", "ltx-23", "Lightricks/LTX-Video"]) +def test_lookalikes_are_not_ltx25(name): + assert unsupported_ltx_version(name) is None + + +def test_prerelease_name_is_named_as_such(): + assert unsupported_ltx_version("ltx-2.4-test") == "2.4" + + +def test_refusal_names_the_pick_and_the_alternative(): + with pytest.raises(ValueError) as exc: + assert_supported_ltx_version("Lightricks/LTX-2.5-Diffusers") + text = str(exc.value) + assert "'Lightricks/LTX-2.5-Diffusers' is LTX-2.5" in text + assert "Gemma 4" in text + assert "LTX-2.3" in text + + +def test_picked_file_name_outranks_the_folder(): + # An ltx-2.3 file in a folder someone named after 2.5 still loads... + assert_supported_ltx_version("/models/ltx-2.5-tests", "ltx-2.3-22b-distilled.safetensors") + # ... while a 2.5 file is refused whatever the folder is called... + with pytest.raises(ValueError, match = "ltx-2.5-22b-distilled-transformer-bf16"): + assert_supported_ltx_version( + "Lightricks/LTX-2.5", + "diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors", + ) + # ... and a generically named file defers to the repo / display id. + with pytest.raises(ValueError, match = "LTX-2.5"): + assert_supported_ltx_version("Lightricks/LTX-2.5", "model.safetensors") + with pytest.raises(ValueError, match = "LTX-2.5"): + assert_supported_ltx_version("/tmp/x", "model.safetensors", "Lightricks/LTX-2.5") + + +def test_detect_load_family_refuses_ltx25_even_with_an_override(): + from core.inference.video import _detect_load_family + + for override in (None, "ltx-2", "ltx-2.3"): + with pytest.raises(ValueError, match = "LTX-2.5"): + _detect_load_family("Lightricks/LTX-2.5-Diffusers", None, override) + with pytest.raises(ValueError, match = "LTX-2.5"): + _detect_load_family( + "Lightricks/LTX-2.5", + "diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors", + None, + ) + assert ( + _detect_load_family("Lightricks/LTX-2.3", "ltx-2.3-22b-distilled.safetensors", None).name + == "ltx-2" + ) + assert _detect_load_family("Lightricks/LTX-2", None, None).name == "ltx-2" + assert ( + _detect_load_family( + "unsloth/LTX-2.3-GGUF", "distilled/ltx-2.3-22b-distilled-Q4_K_M.gguf", None + ).name + == "ltx-2" + ) + + +def test_validate_load_request_refuses_before_any_download(): + from core.inference.video import get_video_backend + + backend = get_video_backend() + with pytest.raises(ValueError, match = "LTX-2.5"): + backend.validate_load_request("Lightricks/LTX-2.5-Diffusers", model_kind = "pipeline") + with pytest.raises(ValueError, match = "LTX-2.5"): + backend.validate_load_request( + "Lightricks/LTX-2.5", + gguf_filename = "diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors", + model_kind = "single_file", + family_override = "ltx-2", + ) + + +def test_training_refuses_ltx25_base(): + from core.training.diffusion_train_common import resolve_trainable_family + for family in (None, "ltx-2"): + with pytest.raises(ValueError, match = "LTX-2.5"): + resolve_trainable_family("Lightricks/LTX-2.5-Diffusers", family) + + +_LTX23_KEYS = [ + "Lightricks/LTX-2.3/ltx-2.3-22b-distilled.safetensors", + "Lightricks/LTX-2.3/ltx-2.3-22b-dev.safetensors", + "Lightricks/LTX-2.3-fp8/ltx-2.3-22b-distilled-fp8.safetensors", + "Kijai/LTX2.3_comfy/diffusion_models/ltx-2.3-22b-distilled-1.1_transformer_only_int8_convrot.safetensors", + "Kijai/LTX2.3_comfy/diffusion_models/ltx-2.3-22b-distilled-1.1_transformer_only_mxfp8_block32.safetensors", + "Lightricks/LTX-2/ltx-2-19b-dev-fp4.safetensors", + "QuantStack/LTX-2.3-GGUF/LTX-2.3-distilled-1.1/LTX-2.3-22B-distilled-1.1-Q4_K_M.gguf", + "diffusers/ltx-2/LTX2VideoTransformer3DModel", +] + + +def _shapes(key): + entry = _FIXTURE[key] + return (entry["shapes"], entry.get("meta")) if "shapes" in entry else (entry, None) + + +def _as_ltx25(shapes): + """The 2.3 header with diffusers' 2.5 deltas applied: no video FFN bias, a keyframe position embedding.""" + prefix = ( + "model.diffusion_model." + if any(k.startswith("model.diffusion_model.") for k in shapes) + else "" + ) + out = { + k: v + for k, v in shapes.items() + if not (".ff.net." in k and k.endswith(".bias") and ".audio_ff." not in k) + } + out[f"{prefix}keyframes_abs_pos_embedding"] = [1, 4096] + return out + + +@pytest.mark.parametrize("key", _LTX23_KEYS) +def test_every_ltx2x_header_still_classifies_as_ltx2(key): + shapes, meta = _shapes(key) + assert dc.classify_tensors(shapes, meta).family == "ltx-2" + + +@pytest.mark.parametrize("key", _LTX23_KEYS) +def test_ltx25_header_is_recognised_but_unsupported(key): + shapes, meta = _shapes(key) + info = dc.classify_tensors(_as_ltx25(shapes), meta) + assert info.role == dc.ROLE_DIT and info.family is None + assert "LTX-2.5" in info.what + + +def test_ltx25_header_without_the_embedding_is_still_caught(): + # Fixture is trimmed past the FFN: re-add block 0's video FFN. + shapes, meta = _shapes("Lightricks/LTX-2.3/ltx-2.3-22b-distilled.safetensors") + ff = "model.diffusion_model.transformer_blocks.0.ff.net.0.proj" + no_bias = {**shapes, f"{ff}.weight": [16384, 4096]} + assert dc.classify_tensors(no_bias, meta).family is None + assert dc.classify_tensors({**no_bias, f"{ff}.bias": [16384]}, meta).family == "ltx-2" + + +def _write_header_only_safetensors(path: Path, shapes: dict) -> None: + header = {k: {"dtype": "BF16", "shape": v, "data_offsets": [0, 0]} for k, v in shapes.items()} + raw = json.dumps(header).encode() + path.write_bytes(struct.pack("