Files
vllm-omni/tests/diffusion/models/ltx2/test_ltx25_pipeline.py
T
2026-08-15 18:27:23 +08:00

882 lines
32 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Focused LTX-2.5 pipeline correctness tests."""
import json
from dataclasses import replace
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.models.ltx2 import ltx2_components, ltx2_latents, ltx2_runtime
from vllm_omni.diffusion.models.ltx2.ltx2_components import (
LTX25_DISTILLED_COMPONENT_PROFILE,
LTX25_DISTILLED_ONE_STAGE_COMPONENT_PROFILE,
LTX25_FULL_COMPONENT_PROFILE,
LTX25_TWO_STAGE_COMPONENT_PROFILE,
detect_ltx_model_version,
resolve_ltx_checkpoint_kind,
resolve_ltx_component_profile,
)
from vllm_omni.diffusion.models.ltx2.ltx2_denoise import (
LTXPhaseResult,
_first_frame_keyframes_mask,
_official_ltx_sigmas,
)
from vllm_omni.diffusion.models.ltx2.ltx2_guidance import (
LTX_GUIDANCE_EXECUTOR,
LTXGuidanceSpec,
LTXModalityGuidance,
velocity_from_x0,
x0_from_velocity,
)
from vllm_omni.diffusion.models.ltx2.ltx2_latents import LTXAVState
from vllm_omni.diffusion.models.ltx2.ltx2_recipes import (
LTX2_DISTILLED_TWO_STAGE_RECIPE,
LTX2_ONE_STAGE_RECIPE,
LTX23_ONE_STAGE_RECIPE,
LTX25_DISTILLED_ONE_STAGE_RECIPE,
LTX25_DISTILLED_TWO_STAGE_RECIPE,
LTX25_FULL_RECIPE,
LTX25_TWO_STAGE_RECIPE,
LTX_DISTILLED_SIGMAS,
LTX_POSITIVE_ONLY_RECIPE,
resolve_ltx_pipeline_recipe,
)
from vllm_omni.diffusion.models.ltx2.ltx2_request import LTXRequestInputs
from vllm_omni.diffusion.models.ltx2.ltx2_runtime import LTXRuntime
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import (
LTX2DistilledOneStagePipeline,
LTX2Pipeline,
)
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_two_stage import (
LTX2DistilledTwoStagePipeline,
LTX2TwoStagePipeline,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.parametrize("model_version", ["2", "2.3", "2.5"])
def test_ltx_timestep_adaln_expands_per_token_like_official_without_sp(model_version):
pipe = object.__new__(LTX2Pipeline)
pipe.model_version = model_version
pipe.od_config = SimpleNamespace(parallel_config=SimpleNamespace(sequence_parallel_size=1))
ts = torch.tensor([750.0, 500.0])
kwargs = LTXRuntime._denoise_timestep_kwargs(
pipe,
ts,
SimpleNamespace(),
SimpleNamespace(),
video_token_count=4,
audio_token_count=3,
)
torch.testing.assert_close(kwargs["timestep"], ts[:, None].expand(-1, 4))
torch.testing.assert_close(kwargs["audio_timestep"], ts[:, None].expand(-1, 3))
assert kwargs["sigma"] is ts
assert kwargs["audio_sigma"] is ts
def test_ltx_ancestral_step_clears_sp_audio_padding_after_scheduler_update(monkeypatch):
state = LTXAVState(
video=torch.zeros(1, 2, 2),
audio=torch.zeros(1, 4, 2),
)
forward_ctx = SimpleNamespace(
original_audio_num_frames=3,
sampler="euler_ancestral",
)
denoise_ctx = SimpleNamespace(latents=None, audio_latents=None)
pipe = SimpleNamespace(
_predict_noise_for_step=lambda *_args: (torch.zeros_like(state.video), torch.zeros_like(state.audio))
)
def fake_step(_pipeline, actual_forward_ctx, _denoise_ctx, *_args):
assert actual_forward_ctx.sampler == "euler_ancestral"
updated_audio = torch.ones_like(state.audio)
updated_audio[:, 3:] = 99.0
return torch.ones_like(state.video), updated_audio
monkeypatch.setattr(ltx2_runtime, "step_denoised_latents", fake_step)
actual = LTXRuntime._denoise_step(
pipe,
0,
torch.tensor(1.0),
state,
forward_ctx,
denoise_ctx,
)
torch.testing.assert_close(actual.audio[:, :3], torch.ones_like(actual.audio[:, :3]))
torch.testing.assert_close(actual.audio[:, 3:], torch.zeros_like(actual.audio[:, 3:]))
def test_ltx25_first_causal_latent_frame_is_marked_for_keyframe_embedding():
reference = torch.zeros(2, 12, 8)
mask = _first_frame_keyframes_mask(reference, latent_num_frames=3)
assert mask.shape == (2, 12, 1)
torch.testing.assert_close(mask[:, :4], torch.ones(2, 4, 1))
torch.testing.assert_close(mask[:, 4:], torch.zeros(2, 8, 1))
def _make_ltx_request_pipe(cls):
pipe = object.__new__(cls)
torch.nn.Module.__init__(pipe)
pipe.device = torch.device("cpu")
pipe.tokenizer_max_length = 99
pipe.vae_spatial_compression_ratio = 32
pipe.vae_temporal_compression_ratio = 8
return pipe
def _resolve_request_inputs_for_test(
pipe,
req,
*,
guidance_scale=4.0,
negative_prompt=None,
):
return pipe._resolve_request_inputs(
req,
prompt=None,
negative_prompt=negative_prompt,
height=None,
width=None,
num_frames=None,
frame_rate=None,
num_inference_steps=None,
guidance_scale=guidance_scale,
num_videos_per_prompt=1,
generator=None,
latents=None,
audio_latents=None,
prompt_embeds=None,
negative_prompt_embeds=None,
prompt_attention_mask=None,
negative_prompt_attention_mask=None,
decode_timestep=0.0,
decode_noise_scale=None,
output_type="np",
max_sequence_length=None,
)
def test_ltx25_missing_gemma4_recommends_supported_transformers_range(monkeypatch):
pipe = SimpleNamespace(component_profile=replace(LTX25_FULL_COMPONENT_PROFILE, text_encoder_cls=None))
od_config = SimpleNamespace(model="Lightricks/LTX-2.5-Diffusers", dtype=torch.bfloat16)
monkeypatch.setattr(ltx2_components, "get_local_device", lambda: torch.device("cpu"))
monkeypatch.setattr(ltx2_components, "prefetch_subfolders", lambda *_args, **_kwargs: None)
monkeypatch.setattr(ltx2_components.AutoTokenizer, "from_pretrained", lambda *_args, **_kwargs: object())
with pytest.raises(ImportError) as exc_info:
ltx2_components.initialize_pipeline_components(pipe, od_config)
assert str(exc_info.value) == (
"LTX-2.5 requires Gemma4UnifiedForConditionalGeneration; install transformers>=5.10.1,<5.15."
)
def test_ltx_converted_component_loading_propagates_revision(monkeypatch):
revision = "pinned-revision"
calls = {"components": []}
profile = replace(
LTX25_DISTILLED_COMPONENT_PROFILE,
text_encoder_cls=object,
video_vae_cls=object,
vocoder_cls=object,
vocoder_fallback_cls=None,
scheduler_use_dynamic_shifting=False,
)
pipeline = SimpleNamespace(component_profile=profile, pipeline_kind="distilled_two_stage")
od_config = SimpleNamespace(
model="org/converted-ltx25",
revision=revision,
dtype=torch.bfloat16,
quantization_config=None,
)
monkeypatch.setattr(ltx2_components, "get_local_device", lambda: torch.device("cpu"))
monkeypatch.setattr(ltx2_components, "_install_connector_attention", lambda _connectors, **_kwargs: None)
monkeypatch.setattr(ltx2_components, "_place_aux_components", lambda _pipeline: None)
def fake_prefetch(model, subfolders, **kwargs):
calls["prefetch"] = (model, tuple(subfolders), kwargs)
def fake_tokenizer(*args, **kwargs):
calls["tokenizer"] = (args, kwargs)
return SimpleNamespace(model_max_length=1024)
def fake_component(component_cls, model, subfolder, **kwargs):
calls["components"].append((component_cls, model, subfolder, kwargs))
if subfolder == "vae":
return SimpleNamespace(spatial_compression_ratio=32, temporal_compression_ratio=8)
if subfolder == "audio_vae":
return SimpleNamespace(
mel_compression_ratio=4,
temporal_compression_ratio=4,
config=SimpleNamespace(sample_rate=16_000, mel_hop_length=160),
)
return object()
def fake_transformer_config(model, subfolder, local_files_only, *, revision):
calls["transformer_config"] = (model, subfolder, local_files_only, revision)
return {"component": "transformer"}
monkeypatch.setattr(ltx2_components, "prefetch_subfolders", fake_prefetch)
monkeypatch.setattr(ltx2_components.AutoTokenizer, "from_pretrained", fake_tokenizer)
monkeypatch.setattr(ltx2_components, "_load_component", fake_component)
monkeypatch.setattr(ltx2_components, "load_transformer_config", fake_transformer_config)
monkeypatch.setattr(
ltx2_components,
"create_transformer_from_config",
lambda *_args, **_kwargs: SimpleNamespace(config=SimpleNamespace(patch_size=1, patch_size_t=1)),
)
def fake_scheduler(*args, **kwargs):
calls["scheduler"] = (args, kwargs)
return SimpleNamespace(config={"use_dynamic_shifting": False, "shift_terminal": None})
monkeypatch.setattr(ltx2_components.FlowMatchEulerDiscreteScheduler, "from_pretrained", fake_scheduler)
ltx2_components.initialize_pipeline_components(pipeline, od_config)
assert pipeline.weights_sources[0].revision == revision
assert calls["prefetch"][2]["revision"] == revision
assert calls["tokenizer"][1]["revision"] == revision
assert {call[2] for call in calls["components"]} == {
"text_encoder",
"connectors",
"vae",
"audio_vae",
"vocoder",
"latent_upsampler",
}
assert all(call[3]["revision"] == revision for call in calls["components"])
assert calls["transformer_config"] == (
od_config.model,
profile.transformer_subfolder,
False,
revision,
)
assert calls["scheduler"][1]["revision"] == revision
def test_ltx_checkpoint_explicit_version_precedes_structural_heuristics(tmp_path):
(tmp_path / "model_index.json").write_text(json.dumps({"model_version": "2.3.1"}))
(tmp_path / "transformer").mkdir()
(tmp_path / "transformer" / "config.json").write_text(json.dumps({"ff_bias": False}))
assert detect_ltx_model_version(str(tmp_path)) == "2.3"
def test_ltx25_checkpoint_selects_full_one_stage_profile(tmp_path, monkeypatch):
from vllm_omni.diffusion.models.ltx2 import ltx2_runtime
(tmp_path / "model_index.json").write_text(
json.dumps({"text_encoder": ["transformers", "Gemma4UnifiedForConditionalGeneration"]})
)
def stub_components(pipe, od_config):
pipe.od_config = od_config
pipe.vae_spatial_compression_ratio = 32
monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", stub_components)
monkeypatch.setattr(LTXRuntime, "setup_diffusion_pipeline_profiler", lambda *_args, **_kwargs: None)
pipe = LTX2Pipeline(od_config=SimpleNamespace(model=str(tmp_path), enable_diffusion_pipeline_profiler=False))
assert pipe.model_version == "2.5"
assert pipe.component_profile is LTX25_FULL_COMPONENT_PROFILE
assert pipe.pipeline_recipe is LTX25_FULL_RECIPE
assert pipe.preserve_sp_padded_audio_duration
assert pipe.reports_stage_durations
def test_ltx25_full_recipe_matches_official_sft_defaults():
(phase,) = LTX25_FULL_RECIPE.phases
assert (LTX25_FULL_RECIPE.height, LTX25_FULL_RECIPE.width) == (544, 960)
assert LTX25_FULL_RECIPE.num_frames == 121
assert LTX25_FULL_RECIPE.frame_rate == 24.0
assert LTX25_FULL_RECIPE.num_inference_steps == 30
assert phase.guidance.video == LTXModalityGuidance(
cfg_scale=3.0,
stg_scale=1.0,
modality_scale=3.0,
rescale_scale=0.7,
stg_blocks=(28,),
)
assert phase.guidance.audio == LTXModalityGuidance(
cfg_scale=7.0,
stg_scale=1.0,
modality_scale=3.0,
rescale_scale=0.7,
stg_blocks=(28,),
)
assert phase.sigmas is None
assert phase.noise_scale == 1.0
assert phase.use_official_sigma_schedule
assert LTX25_FULL_COMPONENT_PROFILE.transformer_subfolder == "transformer_full"
assert LTX25_FULL_COMPONENT_PROFILE.scheduler_use_dynamic_shifting
assert LTX25_FULL_COMPONENT_PROFILE.scheduler_shift_terminal == 0.1
def test_ltx25_full_sigma_schedule_uses_official_default_sequence_anchor():
scheduler = SimpleNamespace(
config={
"base_image_seq_len": 1024,
"max_image_seq_len": 4096,
"base_shift": 0.95,
"max_shift": 2.05,
"shift_terminal": 0.1,
}
)
sigmas = _official_ltx_sigmas(
scheduler,
steps=30,
device=torch.device("cpu"),
)
torch.testing.assert_close(
sigmas[[0, 1, -2, -1]],
torch.tensor([1.0, 0.9949570, 0.1, 0.0]),
rtol=1e-5,
atol=1e-6,
)
def test_ltx25_checkpoint_selects_distilled_two_stage_profile(tmp_path, monkeypatch):
from vllm_omni.diffusion.models.ltx2 import ltx2_runtime
(tmp_path / "model_index.json").write_text(
json.dumps({"text_encoder": ["transformers", "Gemma4UnifiedForConditionalGeneration"]})
)
def stub_components(pipe, od_config):
pipe.od_config = od_config
pipe.vae_spatial_compression_ratio = 32
monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", stub_components)
monkeypatch.setattr(LTXRuntime, "setup_diffusion_pipeline_profiler", lambda *_args, **_kwargs: None)
pipe = LTX2DistilledTwoStagePipeline(
od_config=SimpleNamespace(
model=str(tmp_path),
enable_diffusion_pipeline_profiler=False,
)
)
assert pipe.component_profile is LTX25_DISTILLED_COMPONENT_PROFILE
assert pipe.pipeline_recipe is LTX25_DISTILLED_TWO_STAGE_RECIPE
def test_ltx25_full_config_enrichment_uses_selected_transformer_subfolder(tmp_path):
from vllm_omni.diffusion.data import OmniDiffusionConfig
(tmp_path / "model_index.json").write_text(json.dumps({"_class_name": "LTX2Pipeline", "model_version": "2.5"}))
for subfolder, marker in (("transformer", "distilled"), ("transformer_full", "full")):
path = tmp_path / subfolder
path.mkdir()
(path / "config.json").write_text(json.dumps({"marker": marker}))
config = OmniDiffusionConfig(model=str(tmp_path), model_class_name="LTX2Pipeline")
config.enrich_config()
assert config.tf_model_config.get("marker") == "full"
def test_ltx25_four_public_pipeline_semantics_are_disjoint():
assert resolve_ltx_component_profile("one_stage", "2.5") is LTX25_FULL_COMPONENT_PROFILE
assert resolve_ltx_pipeline_recipe("one_stage", "2.5") is LTX25_FULL_RECIPE
assert resolve_ltx_component_profile("two_stage", "2.5") is LTX25_TWO_STAGE_COMPONENT_PROFILE
assert resolve_ltx_pipeline_recipe("two_stage", "2.5") is LTX25_TWO_STAGE_RECIPE
assert resolve_ltx_component_profile("distilled_one_stage", "2.5") is LTX25_DISTILLED_ONE_STAGE_COMPONENT_PROFILE
assert resolve_ltx_pipeline_recipe("distilled_one_stage", "2.5") is LTX25_DISTILLED_ONE_STAGE_RECIPE
assert resolve_ltx_component_profile("distilled_two_stage", "2.5") is LTX25_DISTILLED_COMPONENT_PROFILE
assert resolve_ltx_pipeline_recipe("distilled_two_stage", "2.5") is LTX25_DISTILLED_TWO_STAGE_RECIPE
assert LTX2Pipeline.pipeline_kind == "one_stage"
assert LTX2TwoStagePipeline.pipeline_kind == "two_stage"
assert LTX2DistilledOneStagePipeline.pipeline_kind == "distilled_one_stage"
assert LTX2DistilledTwoStagePipeline.pipeline_kind == "distilled_two_stage"
@pytest.mark.parametrize(
("pipeline_kind", "expected"),
[
("one_stage", "regular"),
("two_stage", "regular"),
("distilled_one_stage", "distilled"),
("distilled_two_stage", "distilled"),
("dmd2", None),
],
)
def test_ltx_checkpoint_kind_is_derived_from_pipeline_kind(pipeline_kind, expected):
assert resolve_ltx_checkpoint_kind(pipeline_kind) == expected
def test_ltx_checkpoint_kind_rejects_unknown_pipeline():
with pytest.raises(ValueError, match="Unsupported LTX pipeline kind"):
resolve_ltx_checkpoint_kind("unknown")
def test_ltx25_distilled_two_stage_recipe_matches_model_card_resolution():
stage1, stage2 = LTX25_DISTILLED_TWO_STAGE_RECIPE.phases
assert (LTX25_DISTILLED_TWO_STAGE_RECIPE.height, LTX25_DISTILLED_TWO_STAGE_RECIPE.width) == (1088, 1920)
assert stage1.spatial_downscale == 2
assert stage1.sigmas == LTX_DISTILLED_SIGMAS
assert stage1.sampler == "euler_ancestral"
assert stage2.sampler == "euler"
assert stage2.sigmas == (0.909375, 0.725, 0.421875, 0.0)
assert stage2.input_transform == "spatial_upsample"
assert LTX25_DISTILLED_TWO_STAGE_RECIPE.negative_prompt == ""
assert not LTX25_DISTILLED_TWO_STAGE_RECIPE.allow_negative_prompt
def test_ltx25_distilled_one_stage_uses_native_low_resolution():
(phase,) = LTX25_DISTILLED_ONE_STAGE_RECIPE.phases
assert (LTX25_DISTILLED_ONE_STAGE_RECIPE.height, LTX25_DISTILLED_ONE_STAGE_RECIPE.width) == (544, 960)
assert phase.spatial_downscale == 1
assert phase.sigmas == LTX_DISTILLED_SIGMAS
assert phase.sampler == "euler_ancestral"
assert not LTX25_DISTILLED_ONE_STAGE_RECIPE.supports_cache_dit
def test_ltx25_regular_two_stage_uses_full_transformer_then_distilled_lora():
stage1, stage2 = LTX25_TWO_STAGE_RECIPE.phases
assert (LTX25_TWO_STAGE_RECIPE.height, LTX25_TWO_STAGE_RECIPE.width) == (1088, 1920)
assert LTX25_TWO_STAGE_RECIPE.num_inference_steps == 30
assert stage1.spatial_downscale == 2
assert stage1.guidance == LTX25_FULL_RECIPE.request_guidance
assert stage2.adapter_slot == "ltx_distilled"
assert LTX25_TWO_STAGE_RECIPE.allow_request_phase_sigmas
assert LTX25_TWO_STAGE_COMPONENT_PROFILE.transformer_subfolder == "transformer_full"
assert LTX25_TWO_STAGE_COMPONENT_PROFILE.distilled_lora_filename is not None
@pytest.mark.parametrize(
"recipe",
[
LTX2_ONE_STAGE_RECIPE,
LTX23_ONE_STAGE_RECIPE,
LTX_POSITIVE_ONLY_RECIPE,
],
)
def test_ltx_one_stage_recipes_declare_cache_dit_supported(recipe):
assert recipe.supports_cache_dit
@pytest.mark.parametrize(
"recipe",
[
LTX25_FULL_RECIPE,
LTX25_DISTILLED_ONE_STAGE_RECIPE,
LTX2_DISTILLED_TWO_STAGE_RECIPE,
LTX25_DISTILLED_TWO_STAGE_RECIPE,
],
)
def test_ltx25_and_multistage_recipes_declare_cache_dit_unsupported(recipe):
assert not recipe.supports_cache_dit
def test_ltx_positive_only_guidance_preserves_official_x0_roundtrip():
sample = torch.full((1, 1), 1e8, dtype=torch.float32)
velocity = torch.ones((1, 1), dtype=torch.bfloat16)
sigma = torch.tensor(0.5)
actual = LTX_GUIDANCE_EXECUTOR._guide_modality(
sample,
{"cond": velocity},
sigma,
LTXModalityGuidance(),
)
expected = velocity_from_x0(sample, x0_from_velocity(sample, velocity, sigma), sigma)
assert actual.dtype == torch.float32
torch.testing.assert_close(actual, expected, rtol=0.0, atol=0.0)
def test_ltx_velocity_from_x0_materializes_official_scalar_sigma():
item_calls = []
class TrackingTensor(torch.Tensor):
@staticmethod
def __new__(cls):
return torch.Tensor._make_subclass(cls, torch.tensor(0.725), False)
def item(self):
item_calls.append(True)
return super().item()
sample = torch.tensor([[1.0]], dtype=torch.bfloat16)
x0 = torch.tensor([[0.5]], dtype=torch.bfloat16)
velocity_from_x0(sample, x0, TrackingTensor())
assert item_calls == [True]
def test_ltx_ancestral_positive_only_guidance_preserves_raw_velocity():
sample = torch.full((1, 1), 1e8, dtype=torch.float32)
velocity = torch.ones((1, 1), dtype=torch.bfloat16)
actual = LTX_GUIDANCE_EXECUTOR._guide_modality(
sample,
{"cond": velocity},
torch.tensor(0.5),
LTXModalityGuidance(),
preserve_positive_velocity=True,
)
assert actual is velocity
@pytest.mark.parametrize("model_version", ["2.5", "2.3"])
def test_ltx_t2v_denoise_state_uses_official_bfloat16_dtype(model_version):
expected_dtype = torch.bfloat16
captured = {}
def prepare_latents(**kwargs):
captured["video_dtype"] = kwargs["dtype"]
return torch.empty(1, 1, 1, dtype=kwargs["dtype"])
def prepare_audio_latents(*_args, **kwargs):
captured["audio_dtype"] = kwargs["dtype"]
return torch.empty(1, 1, 1, dtype=kwargs["dtype"]), 1, 1
pipeline = SimpleNamespace(
model_version=model_version,
transformer=SimpleNamespace(config=SimpleNamespace(in_channels=128)),
prepare_latents=prepare_latents,
prepare_audio_latents=prepare_audio_latents,
audio_sampling_rate=48_000,
audio_hop_length=160,
audio_vae_temporal_compression_ratio=4,
audio_vae_mel_compression_ratio=4,
audio_vae=SimpleNamespace(config=SimpleNamespace(mel_bins=64, latent_channels=8)),
_resolve_audio_latent_length=lambda length, _latents: length,
)
request = SimpleNamespace(
num_videos_per_prompt=1,
height=64,
width=64,
num_frames=1,
frame_rate=24.0,
generator=None,
latents=None,
audio_latents=None,
audio_latents_normalized=False,
)
prompt = SimpleNamespace(
batch_size=1,
positive_connector_prompt_embeds=torch.empty(1, dtype=torch.bfloat16),
positive_connector_audio_prompt_embeds=torch.empty(1, dtype=torch.bfloat16),
)
LTXRuntime._prepare_video_latents_stage(
pipeline,
request,
prompt,
device=torch.device("cpu"),
noise_scale=1.0,
)
LTXRuntime._prepare_audio_latents_stage(
pipeline,
request,
prompt,
device=torch.device("cpu"),
noise_scale=1.0,
)
assert captured == {"video_dtype": expected_dtype, "audio_dtype": expected_dtype}
def test_ltx_seeded_latents_match_official_packed_rng_layout():
pipeline = SimpleNamespace(
model_version="2.5",
vae_spatial_compression_ratio=2,
vae_temporal_compression_ratio=2,
transformer_spatial_patch_size=1,
transformer_temporal_patch_size=1,
audio_vae_mel_compression_ratio=2,
od_config=SimpleNamespace(parallel_config=SimpleNamespace(sequence_parallel_size=1)),
)
device = torch.device("cpu")
actual_generator = torch.Generator(device=device).manual_seed(123)
expected_generator = torch.Generator(device=device).manual_seed(123)
actual_video = ltx2_latents.prepare_video_latents(
pipeline,
batch_size=1,
num_channels_latents=2,
height=4,
width=6,
num_frames=3,
dtype=torch.float32,
device=device,
generator=actual_generator,
)
actual_audio, original_length, padded_length = ltx2_latents.prepare_audio_latents(
pipeline,
batch_size=1,
num_channels_latents=2,
audio_latent_length=3,
num_mel_bins=4,
dtype=torch.float32,
device=device,
generator=actual_generator,
)
expected_video = torch.randn((1, 12, 2), generator=expected_generator)
expected_audio = torch.randn((1, 3, 4), generator=expected_generator)
torch.testing.assert_close(actual_video, expected_video, rtol=0.0, atol=0.0)
torch.testing.assert_close(actual_audio, expected_audio, rtol=0.0, atol=0.0)
assert (original_length, padded_length) == (3, 3)
def test_ltx25_distilled_two_stage_executes_custom_phase_schedules():
request_inputs = LTXRequestInputs(
prompt="prompt",
negative_prompt="",
height=64,
width=64,
num_frames=1,
frame_rate=24.0,
num_inference_steps=8,
guidance=LTXGuidanceSpec.positive_only(),
num_videos_per_prompt=1,
generator=None,
latents=None,
audio_latents=None,
prompt_embeds=None,
negative_prompt_embeds=None,
prompt_attention_mask=None,
negative_prompt_attention_mask=None,
decode_timestep=0.25,
decode_noise_scale=0.5,
output_type="np",
max_sequence_length=16,
)
prompt_context = object()
phase_calls = []
def resolve_request_inputs(req, **kwargs):
return request_inputs
def run_phase(req, inputs, *, prompt_context=None, **kwargs):
phase_recipe = kwargs["phase_recipe"]
assert kwargs["image"] is source_image
phase_calls.append((phase_recipe, inputs, prompt_context))
if len(phase_calls) == 1:
assert prompt_context is None
assert inputs.decode_timestep == 0.25
assert inputs.decode_noise_scale == 0.5
context = prompt_context_sentinel
assert phase_recipe.name == "generate_lowres"
assert (inputs.height, inputs.width) == (32, 32)
assert inputs.num_inference_steps == 2
assert kwargs["sigmas"] == stage_1_sigmas
assert kwargs["noise_scale"] == 1.0
video = torch.ones(1, 128, 1, 1, 1)
audio = torch.full((1, 8, 1, 2), 2.0)
audio_for_next_phase = torch.full((1, 8, 1, 2), 5.0)
else:
assert prompt_context is prompt_context_sentinel
assert phase_recipe.name == "refine"
assert (inputs.height, inputs.width) == (64, 64)
assert inputs.latents.shape == (1, 128, 1, 2, 2)
torch.testing.assert_close(inputs.audio_latents, torch.full((1, 8, 1, 2), 5.0))
assert inputs.audio_latents_normalized
assert inputs.guidance_scale == 1.0
assert inputs.num_inference_steps == 2
assert kwargs["sigmas"] == stage_2_sigmas
assert kwargs["noise_scale"] == stage_2_sigmas[0]
assert inputs.decode_timestep == 0.0
assert inputs.decode_noise_scale is None
context = prompt_context
video = torch.full((1, 128, 1, 2, 2), 3.0)
audio = torch.full((1, 8, 1, 2), 4.0)
audio_for_next_phase = None
return LTXPhaseResult(
forward_context=SimpleNamespace(prompt_context=context),
video=video,
audio=audio,
audio_for_next_phase=audio_for_next_phase,
)
def decode_phase(phase):
return DiffusionOutput(output=(phase.video, phase.audio))
class FakeUpsampler(torch.nn.Module):
dtype = torch.float32
def forward(self, latents):
return latents.repeat_interleave(2, dim=-2).repeat_interleave(2, dim=-1)
prompt_context_sentinel = prompt_context
stage_1_sigmas = [1.0, 0.5, 0.0]
stage_2_sigmas = [0.8, 0.2, 0.0]
pipeline = object.__new__(LTX2TwoStagePipeline)
torch.nn.Module.__init__(pipeline)
pipeline.pipeline_recipe = LTX25_DISTILLED_TWO_STAGE_RECIPE
pipeline.device = torch.device("cpu")
pipeline.vae_spatial_compression_ratio = 32
pipeline.vae_temporal_compression_ratio = 8
pipeline.latent_upsampler = FakeUpsampler()
object.__setattr__(pipeline, "_resolve_request_inputs", resolve_request_inputs)
object.__setattr__(pipeline, "run_phase", run_phase)
object.__setattr__(pipeline, "decode_phase", decode_phase)
source_image = object()
output = pipeline.forward(
SimpleNamespace(sampling_params_list=[], prompts=[]),
image=source_image,
stage_1_sigmas=stage_1_sigmas,
stage_2_sigmas=stage_2_sigmas,
)
assert len(phase_calls) == 2
assert phase_calls[1][2] is prompt_context_sentinel
torch.testing.assert_close(output.output[0], torch.full((1, 128, 1, 2, 2), 3.0))
torch.testing.assert_close(output.output[1], torch.full((1, 8, 1, 2), 4.0))
class TestLTXRequestParsing:
def test_request_resolves_custom_sigmas_from_extra_args(self):
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
sigmas = [1.0, 0.5, 0.0]
req = DiffusionRequestBatch(
[
OmniDiffusionRequest(
prompt="prompt",
sampling_params=OmniDiffusionSamplingParams(extra_args={"sigmas": sigmas}),
request_id="ltx-custom-sigmas",
)
]
)
assert _make_ltx_request_pipe(LTX2Pipeline)._resolve_request_sigmas(req, None) == sigmas
def test_request_resolves_per_stage_sigmas_from_extra_args(self):
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
stage_1_sigmas = [1.0, 0.4, 0.0]
req = DiffusionRequestBatch(
[
OmniDiffusionRequest(
prompt="prompt",
sampling_params=OmniDiffusionSamplingParams(extra_args={"stage_1_sigmas": stage_1_sigmas}),
request_id="ltx-custom-phase-sigmas",
)
]
)
resolved = _make_ltx_request_pipe(LTX2TwoStagePipeline)._resolve_request_phase_sigmas(
req, [1.0, 0.0], [0.8, 0.0]
)
assert resolved == (stage_1_sigmas, [0.8, 0.0])
@pytest.mark.parametrize("value", [-1, 52, 1.5, True])
def test_request_rejects_invalid_image_crf(self, value):
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
req = DiffusionRequestBatch(
[
OmniDiffusionRequest(
prompt="prompt",
sampling_params=OmniDiffusionSamplingParams(extra_args={"image_crf": value}),
request_id="ltx-invalid-image-crf",
)
]
)
with pytest.raises(ValueError, match="image_crf"):
_resolve_request_inputs_for_test(_make_ltx_request_pipe(LTX2Pipeline), req)
class TestLTXForwardStages:
def test_gemma_tokenization_matches_official_bos_and_left_padding(self):
pipe = _make_ltx_request_pipe(LTX2Pipeline)
class FakeTokenizer:
bos_token_id = 2
eos_token_id = 1
eos_token = "<eos>"
pad_token_id = 0
pad_token = "<pad>"
padding_side = "right"
def __init__(self):
self.calls = []
def __call__(self, text, **kwargs):
self.calls.append((text, kwargs))
ids = [7, 8, 9, self.eos_token_id] if text == "long" else [6, self.eos_token_id]
return SimpleNamespace(input_ids=ids)
def pad(self, encoded, *, padding, max_length, return_tensors, return_attention_mask):
assert padding == "max_length"
assert return_tensors == "pt"
assert return_attention_mask is True
rows = []
masks = []
for ids in encoded["input_ids"]:
pad = max_length - len(ids)
rows.append([self.pad_token_id] * pad + ids)
masks.append([0] * pad + [1] * len(ids))
return SimpleNamespace(input_ids=torch.tensor(rows), attention_mask=torch.tensor(masks))
class FakeTextEncoder(torch.nn.Module):
dtype = torch.float32
def __init__(self):
super().__init__()
self.seen_input_ids = None
self.seen_attention_mask = None
def forward(self, *, input_ids, attention_mask, output_hidden_states):
assert output_hidden_states is True
self.seen_input_ids = input_ids.clone()
self.seen_attention_mask = attention_mask.clone()
return SimpleNamespace(hidden_states=(input_ids.unsqueeze(-1).float(),))
tokenizer = FakeTokenizer()
text_encoder = FakeTextEncoder()
object.__setattr__(pipe, "tokenizer", tokenizer)
object.__setattr__(pipe, "text_encoder", text_encoder)
pipe._get_gemma_prompt_embeds(
[" long ", "short"],
max_sequence_length=4,
device=torch.device("cpu"),
dtype=torch.float32,
)
torch.testing.assert_close(text_encoder.seen_input_ids, torch.tensor([[2, 7, 8, 9], [0, 2, 6, 1]]))
torch.testing.assert_close(text_encoder.seen_attention_mask, torch.tensor([[1, 1, 1, 1], [0, 1, 1, 1]]))
assert tokenizer.padding_side == "left"
assert [call[0] for call in tokenizer.calls] == ["long", "short"]