Studio: refuse LTX-2.5 instead of loading it as LTX-2 (#13370)

This commit is contained in:
Daniel Han 2026-10-12 04:15:04 -07:00 • committed by GitHub
parent 0464be9dad
commit cfe6d9344d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 597 additions and 3 deletions

View file

@ -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],

View file

@ -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

View file

@ -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"(?<![a-z0-9])ltx(?:v|[-_ ]?video)?[-_ ]?2(?:[._]([0-9])(?![0-9])|([0-9])(?![0-9a-z])|(?![0-9a-z]|[._][0-9]))"
)
def ltx_name_version(name: Optional[str]) -> 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:

View file

@ -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():

View file

@ -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)

View file

@ -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("<Q", len(raw)) + raw)
def test_renamed_local_ltx25_file_is_refused_by_its_header(tmp_path):
from core.inference.video import get_video_backend
from core.inference.video_ltx2 import assert_not_ltx25_checkpoint
shapes, _ = _shapes("Lightricks/LTX-2.3/ltx-2.3-22b-distilled.safetensors")
good = tmp_path / "ltx-2.3-renamed.safetensors"
bad = tmp_path / "ltx-2.3-but-really-2.5.safetensors"
_write_header_only_safetensors(good, shapes)
_write_header_only_safetensors(bad, _as_ltx25(shapes))
assert_not_ltx25_checkpoint(good)
with pytest.raises(ValueError, match = "LTX-2.5"):
assert_not_ltx25_checkpoint(bad)
message = dc.refusal_for(str(bad), "video")
assert message and "LTX-2.5" in message
assert dc.resolve_family_with_content("ltx-2", str(bad), "video") == (None, True)
with pytest.raises(ValueError, match = "LTX-2.5"):
get_video_backend().validate_load_request(
str(tmp_path), gguf_filename = bad.name, model_kind = "single_file"
)
def test_local_ltx25_diffusers_folder_is_refused_by_model_index(tmp_path):
from core.inference.video import _detect_load_family
folder = tmp_path / "my-ltx-video"
folder.mkdir()
index = {
"_class_name": "LTX2Pipeline",
"text_encoder": ["transformers", "Gemma4UnifiedForConditionalGeneration"],
"transformer": ["diffusers", "LTX2VideoTransformer3DModel"],
}
(folder / "model_index.json").write_text(json.dumps(index), encoding = "utf-8")
with pytest.raises(ValueError, match = "model_index.json"):
_detect_load_family(str(folder), None, None)
index["text_encoder"] = ["transformers", "Gemma3ForConditionalGeneration"]
(folder / "model_index.json").write_text(json.dumps(index), encoding = "utf-8")
assert _detect_load_family(str(folder), None, None).name == "ltx-2"
index["duration_head"] = ["diffusers", "LTX2DurationHead"]
(folder / "model_index.json").write_text(json.dumps(index), encoding = "utf-8")
with pytest.raises(ValueError, match = "LTX-2.5"):
_detect_load_family(str(folder), None, None)
index["duration_head"] = [None, None]
(folder / "model_index.json").write_text(json.dumps(index), encoding = "utf-8")
assert _detect_load_family(str(folder), None, None).name == "ltx-2"
@pytest.mark.parametrize(
"filename", ["ltx-transformer.safetensors", "ltxv_dit.safetensors", "LTX.safetensors"]
)
def test_unversioned_ltx_file_name_defers_to_the_repo(filename):
from core.inference.video import get_video_backend
with pytest.raises(ValueError, match = "LTX-2.5"):
assert_supported_ltx_version("Lightricks/LTX-2.5", filename)
with pytest.raises(ValueError, match = "LTX-2.5"):
get_video_backend().validate_load_request(
"Lightricks/LTX-2.5",
gguf_filename = filename,
model_kind = "single_file",
family_override = "ltx-2",
)
# A version-free name is no evidence either way in a 2.0 / 2.3 repo.
assert_supported_ltx_version("Lightricks/LTX-2.3", filename)
@pytest.mark.parametrize(
"filename", ["ltx-2-19b-dev.safetensors", "ltx2_dit.safetensors", "ltx-video-2.3-x.safetensors"]
)
def test_versioned_supported_file_name_still_outranks_the_folder(filename):
assert_supported_ltx_version("/models/ltx-2.5-tests", filename)
def test_training_refuses_renamed_local_ltx25_pipeline(tmp_path):
from core.training.diffusion_train_common import resolve_trainable_family
folder = tmp_path / "my-video-base"
folder.mkdir()
index = {
"_class_name": "LTX2Pipeline",
"text_encoder": ["transformers", "Gemma4UnifiedForConditionalGeneration"],
"transformer": ["diffusers", "LTX2VideoTransformer3DModel"],
}
(folder / "model_index.json").write_text(json.dumps(index), encoding = "utf-8")
for family in (None, "ltx-2"):
with pytest.raises(ValueError, match = "model_index.json"):
resolve_trainable_family(str(folder), family)
index["text_encoder"] = ["transformers", "Gemma3ForConditionalGeneration"]
(folder / "model_index.json").write_text(json.dumps(index), encoding = "utf-8")
assert resolve_trainable_family(str(folder), "ltx-2") == "ltx-2"
def test_explicit_pipeline_load_ignores_the_unused_filename(tmp_path):
from core.inference.video import _detect_load_family, get_video_backend
with pytest.raises(ValueError, match = "LTX-2.5"):
get_video_backend().validate_load_request(
"Lightricks/LTX-2.5",
gguf_filename = "ltx-2.3-22b-dev.safetensors",
model_kind = "pipeline",
family_override = "ltx-2",
)
folder = tmp_path / "opaque"
folder.mkdir()
index = {
"_class_name": "LTX2Pipeline",
"text_encoder": ["transformers", "Gemma4UnifiedForConditionalGeneration"],
}
(folder / "model_index.json").write_text(json.dumps(index), encoding = "utf-8")
with pytest.raises(ValueError, match = "model_index.json"):
_detect_load_family(str(folder), "ltx-2.3.safetensors", "ltx-2", None, "pipeline")
# A real single-file pick still lets its own ltx-2.3 file decide.
assert (
_detect_load_family(str(folder), "ltx-2.3.safetensors", "ltx-2", None, "single_file").name
== "ltx-2"
)
def test_training_name_guard_is_scoped_to_ltx(tmp_path):
from core.training.diffusion_train_common import resolve_trainable_family
folder = tmp_path / "sdxl-ltx-2.5-style"
folder.mkdir()
(folder / "model_index.json").write_text(
json.dumps({"_class_name": "StableDiffusionXLPipeline"}), encoding = "utf-8"
)
assert resolve_trainable_family(str(folder), "sdxl") == "sdxl"
with pytest.raises(ValueError, match = "LTX-2.5"):
resolve_trainable_family(str(folder), "ltx-2")
@pytest.mark.parametrize(
"name", ["ltxv-2.5.safetensors", "ltxv25", "LTXV_2_5-dev.safetensors", "ltxv2.4"]
)
def test_versioned_ltxv_alias_is_refused(name):
assert unsupported_ltx_version(name) is not None
with pytest.raises(ValueError, match = "LTX-2"):
assert_supported_ltx_version("/models/x", name)
@pytest.mark.parametrize(
"filename",
[
"ltx-24fps-clip.safetensors",
"ltx-23.safetensors",
"ltx-2.10-x.safetensors",
"ltx2fast.safetensors",
],
)
def test_numeric_lookalike_file_name_defers_to_the_repo(filename):
with pytest.raises(ValueError, match = "LTX-2.5"):
assert_supported_ltx_version("Lightricks/LTX-2.5", filename)
assert_supported_ltx_version("Lightricks/LTX-2.3", filename)
def test_inference_name_guard_is_scoped_to_ltx():
from core.inference.video import _detect_load_family
fam = _detect_load_family(
"someone/wan-ltx-2.5-style",
"wan-ltx-2.5-style.safetensors",
"wan2.2-ti2v-5b",
None,
"single_file",
)
assert fam is not None and fam.name == "wan2.2-ti2v-5b"
with pytest.raises(ValueError, match = "LTX-2.5"):
_detect_load_family(
"someone/wan-ltx-2.5-style",
"wan-ltx-2.5-style.safetensors",
"ltx-2",
None,
"single_file",
)
def test_ltx25_companion_base_is_refused(tmp_path, monkeypatch):
import core.inference.diffusion as diffusion
from core.inference.video import get_video_backend
# Isolate the LTX-2.5 check from the pipeline-completeness check on the stub folder.
monkeypatch.setattr(diffusion, "_assert_local_base_is_pipeline", lambda *a, **k: None)
backend = get_video_backend()
pick = dict(gguf_filename = "distilled/ltx-2.3-22b-distilled-Q4_K_M.gguf", model_kind = "gguf")
with pytest.raises(ValueError, match = "LTX-2.5"):
backend.validate_load_request(
"unsloth/LTX-2.3-GGUF", base_repo = "unsloth/LTX-2.5-Diffusers", **pick
)
folder = tmp_path / "my-base"
folder.mkdir()
index = {
"_class_name": "LTX2Pipeline",
"text_encoder": ["transformers", "Gemma4UnifiedForConditionalGeneration"],
}
(folder / "model_index.json").write_text(json.dumps(index), encoding = "utf-8")
with pytest.raises(ValueError, match = r"by its model_index\.json\) is LTX-2\.5"):
backend.validate_load_request("unsloth/LTX-2.3-GGUF", base_repo = str(folder), **pick)