[Model] Add LTX-2.5 support (#6070)
Signed-off-by: lishunyang12 <lishunyang12@163.com>
This commit is contained in:
1 parent
f39b8684eb
commit
ce6ab0ca46
47 files changed
+4558
-218
No files matched your search
@@ -459,6 +459,12 @@ steps:
|
||||
- pytest -s -v tests/e2e/online_serving/test_wan_2_1_vace_expansion.py tests/e2e/online_serving/test_ltx.py -m "full_model and cuda" --run-level "full_model"
|
||||
mirror_hardwares: h100_2
|
||||
|
||||
- label: ":full_moon: Diffusion X2V · LTX-2.5 Online Test"
|
||||
timeout_in_minutes: 60
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/online_serving/test_ltx25.py -m "full_model and cuda" --run-level "full_model"
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: ":full_moon: Diffusion X2V · Wan2.2 Accuracy Test"
|
||||
timeout_in_minutes: 180
|
||||
commands:
|
||||
@@ -487,6 +493,13 @@ steps:
|
||||
- pytest -s -v 'tests/e2e/accuracy/ltx/test_ltx_official_similarity.py::test_ltx_matches_official[ltx23_two_stage_layer_fused_i2v]' -m full_model --run-level full_model
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: ":full_moon: Diffusion X2V · LTX-2.5 Official Parity Test"
|
||||
timeout_in_minutes: 180
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/accuracy/ltx/test_ltx25_official_similarity.py::test_ltx25_distilled_two_stage_matches_official -m full_model --run-level full_model
|
||||
- pytest -s -v tests/e2e/accuracy/ltx/test_ltx25_official_similarity.py::test_ltx25_full_matches_official -m full_model --run-level full_model
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: ":full_moon: Diffusion X2V · Perf Test"
|
||||
key: nightly-diffusion-x2v-performance
|
||||
timeout_in_minutes: 180
|
||||
|
||||
@@ -254,6 +254,7 @@ outputs/
|
||||
results/
|
||||
generated/
|
||||
output_*/
|
||||
/tests/e2e/accuracy/artifacts/
|
||||
|
||||
# Configuration overrides
|
||||
configs/local.yaml
|
||||
|
||||
@@ -43,7 +43,12 @@ th {
|
||||
| `Wan22VACEPipeline` | Wan2.2-VACE | `Pyros13/Wan2.2-VACE-Fun-A14B-Diffusers` | ✅︎ | | | | — |
|
||||
| `LTX2Pipeline` | LTX-2 / LTX-2.3 one-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | | — |
|
||||
| `LTX2TwoStagePipeline` | LTX-2 / LTX-2.3 ordinary two-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` + matching Lightricks LoRA and upsampler | ✅︎ | ✅︎ | | | — |
|
||||
| `LTX2DistilledPipeline` | LTX-2 / LTX-2.3 full-distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled`, `diffusers/LTX-2.3-Distilled-Diffusers` + `Lightricks/LTX-2.3` upsampler | ✅︎ | ✅︎ | | | — |
|
||||
| `LTX2DistilledOneStagePipeline` | LTX-2 / LTX-2.3 merged-distilled one-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled`, `diffusers/LTX-2.3-Distilled-Diffusers` | ✅︎ | ✅︎ | | | — |
|
||||
| `LTX2DistilledTwoStagePipeline` | LTX-2 / LTX-2.3 merged-distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled`, `diffusers/LTX-2.3-Distilled-Diffusers` + matching Lightricks upsampler | ✅︎ | ✅︎ | | | — |
|
||||
| `LTX2Pipeline` | LTX-2.5 Full/SFT one-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
|
||||
| `LTX2TwoStagePipeline` | LTX-2.5 Full/SFT two-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` + `Lightricks/LTX-2.5` LoRA | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
|
||||
| `LTX2DistilledOneStagePipeline` | LTX-2.5 merged-distilled one-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
|
||||
| `LTX2DistilledTwoStagePipeline` | LTX-2.5 merged-distilled two-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
|
||||
| `LingBotVideoPipeline` | LingBot-Video dense and MoE T2I, T2V, TI2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | | — |
|
||||
| `MiniMaxH3Pipeline` | MiniMax H3 T2VA, first/last-keyframe FL2VA, and mixed-reference Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | ✅︎ | | | [Published](https://recipes.vllm.ai/MiniMaxAI/MiniMax-H3) |
|
||||
| `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
|
||||
|
||||
@@ -161,6 +161,7 @@ The following tables show which models support each feature:
|
||||
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **LTX-2.5** | ❌ | ❓ (one-stage) | ❓ (Ulysses only) | ✅ (Full only) | ❓ | ❌ | ✅ | ✅ | ✅ (decode) | ❓ (FP8) | ❌ |
|
||||
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅* |
|
||||
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
|
||||
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
@@ -174,7 +175,7 @@ The following tables show which models support each feature:
|
||||
**Frame Interpolation Support**
|
||||
|
||||
- **Supported**: Wan2.2 text-to-video, image-to-video, and TI2V pipelines
|
||||
- **Not supported**: Wan2.1-VACE, LTX-2, LTX-2.3, Helios, HunyuanVideo-1.5, DreamID-Omni
|
||||
- **Not supported**: Wan2.1-VACE, LTX-2, LTX-2.3, LTX-2.5, Helios, HunyuanVideo-1.5, DreamID-Omni
|
||||
|
||||
### AudioGen
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ from vllm_omni.model_extras import (
|
||||
from vllm_omni.model_extras import (
|
||||
get_extra_body_params,
|
||||
get_model_class_name,
|
||||
should_preserve_reference_image_size,
|
||||
)
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
@@ -454,7 +455,13 @@ def main():
|
||||
|
||||
media_inputs: dict[str, Any] = {}
|
||||
if image is not None:
|
||||
media_inputs["image"] = image.resize((width, height), PIL.Image.Resampling.LANCZOS)
|
||||
preserve_image_size = should_preserve_reference_image_size(
|
||||
model_class_name,
|
||||
model=args.model,
|
||||
)
|
||||
media_inputs["image"] = (
|
||||
image if preserve_image_size else image.resize((width, height), PIL.Image.Resampling.LANCZOS)
|
||||
)
|
||||
if last_image is not None:
|
||||
media_inputs["last_image"] = last_image.resize((width, height), PIL.Image.Resampling.LANCZOS)
|
||||
if mask_image is not None:
|
||||
|
||||
@@ -570,6 +570,8 @@ def main():
|
||||
num_frames=args.num_frames,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
if args.frame_rate is not None:
|
||||
sampling_kwargs["frame_rate"] = args.frame_rate
|
||||
if args.guidance_scale_high is not None:
|
||||
sampling_kwargs["guidance_scale_2"] = args.guidance_scale_high
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# LTX-2.5
|
||||
|
||||
> Text-to-video and first-frame image-to-video generation with synchronized audio
|
||||
|
||||
## Overview
|
||||
|
||||
vLLM-Omni supports the gated
|
||||
[`Lightricks/LTX-2.5-Diffusers`](https://huggingface.co/Lightricks/LTX-2.5-Diffusers)
|
||||
checkpoint for T2V and first-frame I2V. Every output includes synchronized
|
||||
48 kHz stereo audio.
|
||||
|
||||
The raw [`Lightricks/LTX-2.5`](https://huggingface.co/Lightricks/LTX-2.5)
|
||||
repository is not directly loadable with `--model`. It supplies the official
|
||||
upsampler and LoRA sidecars used by the Full/SFT two-stage pipeline, so accept
|
||||
both model licenses and authenticate before first use.
|
||||
|
||||
## Choose a pipeline
|
||||
|
||||
| Pipeline | Mode | Default size | Schedule |
|
||||
|---|---|---:|---:|
|
||||
| `LTX2Pipeline` | Full/SFT one-stage | 960x544 | 30 steps |
|
||||
| `LTX2TwoStagePipeline` | Full/SFT two-stage | 1920x1088 | 30 + 3 steps |
|
||||
| `LTX2DistilledOneStagePipeline` | Distilled one-stage | 960x544 | 8 steps |
|
||||
| `LTX2DistilledTwoStagePipeline` | Distilled two-stage | 1920x1088 | 8 + 3 steps |
|
||||
|
||||
Both two-stage pipelines generate at half resolution, apply the official x2
|
||||
latent upsampler, and run a three-step refinement stage. Select the class with
|
||||
`--model-class-name`; no `--task-type` flag is required. Supplying one initial
|
||||
image selects I2V, while omitting it selects T2V.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
hf auth login
|
||||
export MODEL=Lightricks/LTX-2.5-Diffusers
|
||||
```
|
||||
|
||||
Install matching vLLM and vLLM-Omni versions, and ensure `ffmpeg` and
|
||||
`ffprobe` are on `PATH`. I2V requires PyAV backed by an FFmpeg build with the
|
||||
`libx264` encoder.
|
||||
|
||||
## Hardware
|
||||
|
||||
| GPU | Status | Recommended scope |
|
||||
|---|---|---|
|
||||
| NVIDIA B300 | Verified | All four canonical pipelines |
|
||||
| NVIDIA B200 or H200 | Capacity-based recommendation; not yet verified | All four canonical pipelines |
|
||||
| NVIDIA GB200 or GB300 | Capacity-based recommendation; not yet verified | All four canonical pipelines |
|
||||
| NVIDIA H100 80 GB | FP8 recipe | Distilled one-stage at 960x544 |
|
||||
|
||||
The 1920x1088 two-stage examples require about 114 GB of peak GPU memory.
|
||||
80 GB GPUs do not have enough safety margin for the canonical two-stage
|
||||
configuration; use a larger GPU or reduce the output configuration.
|
||||
|
||||
### H100 80 GB FP8
|
||||
|
||||
Use the distilled one-stage pipeline with FP8 and cuDNN attention:
|
||||
|
||||
```bash
|
||||
vllm serve Lightricks/LTX-2.5-Diffusers \
|
||||
--omni \
|
||||
--model-class-name LTX2DistilledOneStagePipeline \
|
||||
--quantization fp8 \
|
||||
--diffusion-attention-backend CUDNN_ATTN \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000
|
||||
```
|
||||
|
||||
Use the T2V or I2V request below after the server is ready.
|
||||
|
||||
## Offline inference
|
||||
|
||||
Choose values from the pipeline table. For example, the distilled two-stage
|
||||
path uses:
|
||||
|
||||
```bash
|
||||
export PIPELINE=LTX2DistilledTwoStagePipeline
|
||||
export WIDTH=1920
|
||||
export HEIGHT=1088
|
||||
export STEPS=8
|
||||
```
|
||||
|
||||
### Text-to-video
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/text_to_video/text_to_video.py \
|
||||
--model "${MODEL}" \
|
||||
--model-class-name "${PIPELINE}" \
|
||||
--prompt "A cinematic shot of a red fox walking through a snowy forest at dawn, the camera tracking alongside, snow crunching underfoot." \
|
||||
--width "${WIDTH}" \
|
||||
--height "${HEIGHT}" \
|
||||
--num-frames 121 \
|
||||
--num-inference-steps "${STEPS}" \
|
||||
--frame-rate 24 \
|
||||
--fps 24 \
|
||||
--seed 42 \
|
||||
--output ltx25-t2v.mp4
|
||||
```
|
||||
|
||||
### First-frame image-to-video
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/image_to_video/image_to_video.py \
|
||||
--model "${MODEL}" \
|
||||
--model-class-name "${PIPELINE}" \
|
||||
--image /absolute/path/to/first-frame.png \
|
||||
--prompt "The red fox walks forward while the camera tracks alongside." \
|
||||
--width "${WIDTH}" \
|
||||
--height "${HEIGHT}" \
|
||||
--num-frames 121 \
|
||||
--num-inference-steps "${STEPS}" \
|
||||
--frame-rate 24 \
|
||||
--fps 24 \
|
||||
--seed 42 \
|
||||
--output ltx25-i2v.mp4
|
||||
```
|
||||
|
||||
LTX-2.5 uses the official CRF-18 first-frame conditioning path by default.
|
||||
|
||||
## Online serving
|
||||
|
||||
Start one server for the selected pipeline:
|
||||
|
||||
```bash
|
||||
vllm serve "${MODEL}" \
|
||||
--omni \
|
||||
--model-class-name "${PIPELINE}" \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--stage-init-timeout 900
|
||||
```
|
||||
|
||||
T2V request:
|
||||
|
||||
```bash
|
||||
curl -sS --fail-with-body \
|
||||
-X POST http://127.0.0.1:8000/v1/videos/sync \
|
||||
-F 'prompt=A cinematic shot of a red fox walking through a snowy forest at dawn, the camera tracking alongside, snow crunching underfoot.' \
|
||||
-o ltx25-online-t2v.mp4
|
||||
```
|
||||
|
||||
For I2V, add exactly one first frame:
|
||||
|
||||
```bash
|
||||
export FIRST_FRAME=/absolute/path/to/first-frame.png
|
||||
|
||||
curl -sS --fail-with-body \
|
||||
-X POST http://127.0.0.1:8000/v1/videos/sync \
|
||||
-F "input_reference=@${FIRST_FRAME};type=image/png" \
|
||||
-F 'prompt=The red fox walks forward while the camera tracks alongside.' \
|
||||
-o ltx25-online-i2v.mp4
|
||||
```
|
||||
|
||||
Restart the server after changing `PIPELINE` because each class selects a
|
||||
different weight layout and execution topology.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Online `width`, `height`, `num_frames`, `fps`, and `num_inference_steps`
|
||||
are optional and use the selected pipeline defaults when omitted. `seed` is
|
||||
optional and random when omitted.
|
||||
- `num_frames` must be `8k+1` when overridden.
|
||||
- One-stage width and height must be divisible by 32; two-stage final
|
||||
dimensions must be divisible by 64.
|
||||
- Full/SFT pipelines accept negative prompts. Distilled pipelines are
|
||||
positive-only and reject negative prompts.
|
||||
- I2V accepts exactly one initial image per prompt.
|
||||
- The supported `--model` value is `Lightricks/LTX-2.5-Diffusers`.
|
||||
|
||||
## References
|
||||
|
||||
- [LTX-2.5-Diffusers model card](https://huggingface.co/Lightricks/LTX-2.5-Diffusers)
|
||||
- [Official LTX-2 repository](https://github.com/Lightricks/LTX-2)
|
||||
- [Generic T2V example](../../examples/offline_inference/text_to_video/text_to_video.py)
|
||||
- [Generic I2V example](../../examples/offline_inference/image_to_video/image_to_video.py)
|
||||
+14
-9
@@ -8,10 +8,12 @@
|
||||
|---|---|---|
|
||||
| `LTX2Pipeline` | LTX-2 one-stage T2V/I2V | `Lightricks/LTX-2` |
|
||||
| `LTX2TwoStagePipeline` | LTX-2 ordinary two-stage T2V/I2V | `Lightricks/LTX-2` |
|
||||
| `LTX2DistilledPipeline` | LTX-2 full-distilled two-stage T2V/I2V | `rootonchair/LTX-2-19b-distilled` |
|
||||
| `LTX2DistilledOneStagePipeline` | LTX-2 merged-distilled one-stage T2V/I2V | `rootonchair/LTX-2-19b-distilled` |
|
||||
| `LTX2DistilledTwoStagePipeline` | LTX-2 merged-distilled two-stage T2V/I2V | `rootonchair/LTX-2-19b-distilled` |
|
||||
| `LTX2Pipeline` | LTX-2.3 one-stage T2V/I2V | `diffusers/LTX-2.3-Diffusers` |
|
||||
| `LTX2TwoStagePipeline` | LTX-2.3 ordinary two-stage T2V/I2V | `diffusers/LTX-2.3-Diffusers`<br>`Lightricks/LTX-2.3` |
|
||||
| `LTX2DistilledPipeline` | LTX-2.3 full-distilled two-stage T2V/I2V | `diffusers/LTX-2.3-Distilled-Diffusers`<br>`Lightricks/LTX-2.3` |
|
||||
| `LTX2DistilledOneStagePipeline` | LTX-2.3 merged-distilled one-stage T2V/I2V | `diffusers/LTX-2.3-Distilled-Diffusers` |
|
||||
| `LTX2DistilledTwoStagePipeline` | LTX-2.3 merged-distilled two-stage T2V/I2V | `diffusers/LTX-2.3-Distilled-Diffusers`<br>`Lightricks/LTX-2.3` |
|
||||
|
||||
Repositories in the table are download units. A full pipeline repository
|
||||
contains the Transformer, text encoder, connectors, VAEs, vocoder, scheduler,
|
||||
@@ -26,9 +28,12 @@ image selects I2V. Both one-stage repositories declare this class, so
|
||||
the raw `Lightricks/LTX-2.3` safetensors repository is not directly loadable.
|
||||
|
||||
`LTX2TwoStagePipeline` samples the regular model at half resolution, upsamples,
|
||||
then refines with the distilled LoRA. `LTX2DistilledPipeline` uses a fully
|
||||
merged distilled Transformer in both stages. Both entries support T2V and I2V;
|
||||
select their class explicitly.
|
||||
then refines with the distilled LoRA. `LTX2DistilledOneStagePipeline` uses a
|
||||
merged distilled Transformer without upsampling, while
|
||||
`LTX2DistilledTwoStagePipeline` uses it in both stages. All entries support
|
||||
T2V and I2V; select their class explicitly. The deprecated
|
||||
`LTX2DistilledPipeline` name remains an alias for
|
||||
`LTX2DistilledTwoStagePipeline`.
|
||||
|
||||
## API Migration
|
||||
|
||||
@@ -52,8 +57,8 @@ The consolidation also removes these registry names without aliases:
|
||||
| `LTX23Pipeline` | `LTX2Pipeline`; checkpoint metadata selects LTX-2.3 |
|
||||
| `LTX2ImageToVideoPipeline` | `LTX2Pipeline` with `image=` |
|
||||
| `LTX23ImageToVideoPipeline` | `LTX2Pipeline` with `image=`; checkpoint metadata selects LTX-2.3 |
|
||||
| `LTX2TwoStagesPipeline` | `LTX2DistilledPipeline` |
|
||||
| `LTX2ImageToVideoTwoStagesPipeline` | `LTX2DistilledPipeline` with `image=` |
|
||||
| `LTX2TwoStagesPipeline` | `LTX2DistilledTwoStagePipeline` |
|
||||
| `LTX2ImageToVideoTwoStagesPipeline` | `LTX2DistilledTwoStagePipeline` with `image=` |
|
||||
|
||||
Passing any second positional argument now raises `TypeError`. These changes
|
||||
affect direct Python callers and explicit `--model-class-name` overrides;
|
||||
@@ -112,10 +117,10 @@ Start a two-stage checkpoint with its explicit class:
|
||||
|
||||
```bash
|
||||
vllm serve rootonchair/LTX-2-19b-distilled --omni \
|
||||
--model-class-name LTX2DistilledPipeline --stage-init-timeout 600
|
||||
--model-class-name LTX2DistilledTwoStagePipeline --stage-init-timeout 600
|
||||
# LTX-2.3 full-distilled; the v1.1 x2 upsampler is resolved when absent
|
||||
vllm serve diffusers/LTX-2.3-Distilled-Diffusers --omni \
|
||||
--model-class-name LTX2DistilledPipeline --stage-init-timeout 600
|
||||
--model-class-name LTX2DistilledTwoStagePipeline --stage-init-timeout 600
|
||||
# Ordinary LTX-2.3 two-stage
|
||||
vllm serve diffusers/LTX-2.3-Diffusers --omni \
|
||||
--model-class-name LTX2TwoStagePipeline \
|
||||
|
||||
@@ -55,6 +55,7 @@ recipes/
|
||||
| [`MiniMaxAI/MiniMax-H3-MUSA.md`](./MiniMaxAI/MiniMax-H3-MUSA.md) | MiniMax H3 T2VA, FL2VA, and Ref2VA on MUSA | 4x MTT S5000 (TP4 Ref2VA profile) |
|
||||
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB |
|
||||
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU |
|
||||
| [`LTX/LTX-2.5.md`](./LTX/LTX-2.5.md) | LTX-2.5-Diffusers: Full/SFT one-stage and distilled two-stage T2V/I2V with synchronized audio | NVIDIA B300; cuDNN-qualified |
|
||||
| [`MammothModa2/MammothModa2.md`](./MammothModa2/MammothModa2.md) | Preview and Dev text-to-image (AR → DiT); Dev text/image understanding | Preview: 1x L40S 48GB / 1x ≥40GB GPU; Dev: 1x NVIDIA GPU with sufficient cache headroom |
|
||||
| [`meituan-longcat/LongCat-Video-Avatar-1.5.md`](./meituan-longcat/LongCat-Video-Avatar-1.5.md) | Audio-driven avatar video generation (AT2V / AI2V, single- and multi-speaker, AVC continuation) | 1x H100 80GB |
|
||||
| [`mistralai/Voxtral-TTS.md`](./mistralai/Voxtral-TTS.md) | Online serving for TTS | 1x RTX 4090 24GB |
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Common dependencies for all platforms
|
||||
# transformers: kept exactly aligned with the upstream vLLM constraint.
|
||||
# vLLM pins ">= 4.56.0, != 5.0.*, != 5.1.*, != 5.2.*, != 5.3.*, != 5.4.*, != 5.5.0".
|
||||
# The previous extra "< 5.9.0" exclusion was removed when re-aligning with
|
||||
# upstream: transformers 5.9 changed _adjust_missing_and_unexpected_keys from
|
||||
# transformers: required unified multimodal model classes are unavailable
|
||||
# before 5.10.1; 5.10.0 is yanked, and 5.15 has a known construction
|
||||
# regression. transformers 5.9 also changed
|
||||
# _adjust_missing_and_unexpected_keys from
|
||||
# list+list to set|set, breaking models that declare
|
||||
# _keys_to_ignore_on_load_unexpected as a list (e.g. OpenMOSS-Team/MOSS-TTS-Nano)
|
||||
# with "TypeError: unsupported operand type(s) for |: 'list' and 'set'".
|
||||
# That breakage is now handled in code instead of by pinning:
|
||||
# MossTTSNanoForGeneration wraps weight loading in
|
||||
# transformers_keys_to_ignore_compat (vllm_omni/model_executor/models/utils.py).
|
||||
transformers >= 5.5.3
|
||||
transformers >= 5.10.1, < 5.15
|
||||
av>=14.0.0
|
||||
omegaconf>=2.3.0
|
||||
diffusers==0.38.0
|
||||
|
||||
@@ -5,6 +5,7 @@ from contextlib import nullcontext
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.nn.attention import SDPBackend
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.backends.cudnn_attn import CuDNNAttentionImpl
|
||||
@@ -12,6 +13,48 @@ from vllm_omni.diffusion.attention.backends.cudnn_attn import CuDNNAttentionImpl
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query_sequence_length", "key_sequence_length", "expected_backend"),
|
||||
[
|
||||
(1, 1, SDPBackend.MATH),
|
||||
(8, 1, SDPBackend.MATH),
|
||||
(1, 8, SDPBackend.MATH),
|
||||
(8, 8, SDPBackend.CUDNN_ATTENTION),
|
||||
],
|
||||
)
|
||||
def test_cudnn_routes_single_token_attention_to_math(
|
||||
monkeypatch, query_sequence_length, key_sequence_length, expected_backend
|
||||
):
|
||||
selected_backends = []
|
||||
|
||||
def fake_sdpa_kernel(backends):
|
||||
selected_backends.append(backends)
|
||||
return nullcontext()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.attention.backends.cudnn_attn.sdpa_kernel",
|
||||
fake_sdpa_kernel,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
torch.nn.functional,
|
||||
"scaled_dot_product_attention",
|
||||
lambda query, _key, _value, **_kwargs: query,
|
||||
)
|
||||
impl = CuDNNAttentionImpl(
|
||||
num_heads=2,
|
||||
head_size=4,
|
||||
softmax_scale=0.5,
|
||||
)
|
||||
query = torch.randn(1, query_sequence_length, 2, 4)
|
||||
key = torch.randn(1, key_sequence_length, 2, 4)
|
||||
value = torch.randn_like(key)
|
||||
|
||||
output = impl.forward_cuda(query, key, value)
|
||||
|
||||
assert output.shape == query.shape
|
||||
assert selected_backends == [[expected_backend]]
|
||||
|
||||
|
||||
def test_cudnn_slices_valid_kv_prefix_without_padding_mask(monkeypatch):
|
||||
observed = {}
|
||||
|
||||
|
||||
+7
-1
@@ -36,7 +36,6 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
SEPARATE_CFG_TRANSFORMERS = [
|
||||
DreamIdOmniModel,
|
||||
LTX2VideoTransformer3DModel,
|
||||
HeliosTransformer3DModel,
|
||||
LongCatImageTransformer2DModel,
|
||||
Cosmos3VFMTransformer,
|
||||
@@ -124,6 +123,13 @@ def test_cache_dit_configs_have_separate_cfg(transformer_model):
|
||||
assert transformer_model._cache_dit_adapter_config.has_separate_cfg is True
|
||||
|
||||
|
||||
def test_ltx2_cache_dit_uses_one_forward_per_denoise_step():
|
||||
"""LTX batches all guidance passes into one Transformer invocation."""
|
||||
adapter_config = LTX2VideoTransformer3DModel._cache_dit_adapter_config
|
||||
|
||||
assert adapter_config.has_separate_cfg is False
|
||||
|
||||
|
||||
@patch("vllm_omni.diffusion.cache.cachedit.model_specific.BlockAdapter")
|
||||
@patch("vllm_omni.diffusion.cache.cachedit.model_specific.cache_dit")
|
||||
def test_separate_wan22_custom_enabler_has_separate_cfg(mock_cache_dit, mock_block_adapter):
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import contextlib
|
||||
|
||||
import huggingface_hub
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.model_loader import hub_prefetch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_prefetch_subfolders_propagates_revision(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_snapshot_download(**kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
|
||||
monkeypatch.setattr(hub_prefetch, "_repo_prefetch_lock", lambda _model: contextlib.nullcontext())
|
||||
|
||||
hub_prefetch.prefetch_subfolders(
|
||||
"org/model",
|
||||
["transformer"],
|
||||
revision="pinned-revision",
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"repo_id": "org/model",
|
||||
"revision": "pinned-revision",
|
||||
"allow_patterns": ["transformer/*", "transformer/**", "*.json", "*.txt"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_from_pretrained_retry_preserves_revision(monkeypatch):
|
||||
factory_calls = []
|
||||
prefetch_calls = []
|
||||
|
||||
def fake_factory(model, **kwargs):
|
||||
factory_calls.append((model, kwargs))
|
||||
if len(factory_calls) == 1:
|
||||
raise OSError("partial cache")
|
||||
return "loaded"
|
||||
|
||||
def fake_prefetch(model, subfolders, **kwargs):
|
||||
prefetch_calls.append((model, tuple(subfolders), kwargs))
|
||||
|
||||
monkeypatch.setattr(hub_prefetch, "prefetch_subfolders", fake_prefetch)
|
||||
monkeypatch.setattr(hub_prefetch.time, "sleep", lambda _seconds: None)
|
||||
|
||||
result = hub_prefetch.from_pretrained_with_prefetch(
|
||||
fake_factory,
|
||||
"org/model",
|
||||
subfolder="transformer",
|
||||
prefetch_list=["transformer", "vae"],
|
||||
revision="pinned-revision",
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert result == "loaded"
|
||||
assert len(factory_calls) == 2
|
||||
assert all(call[1]["revision"] == "pinned-revision" for call in factory_calls)
|
||||
assert prefetch_calls == [
|
||||
(
|
||||
"org/model",
|
||||
("transformer", "vae"),
|
||||
{"local_files_only": False, "revision": "pinned-revision"},
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,881 @@
|
||||
# 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"]
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
"""Unit tests for LTX image-to-video input and conditioning behavior."""
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -23,7 +24,7 @@ def _make_ltx23_request_pipe(cls):
|
||||
|
||||
|
||||
class TestLTXImageToVideoForwardStages:
|
||||
def test_i2v_pil_preprocessing_matches_official_center_crop(self):
|
||||
def test_legacy_i2v_pil_preprocessing_preserves_aspect_ratio_and_center_crops(self):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_conditioning import _preprocess_i2v_pil_images
|
||||
|
||||
pixels = torch.zeros(4, 8, 3, dtype=torch.uint8)
|
||||
@@ -35,6 +36,136 @@ class TestLTXImageToVideoForwardStages:
|
||||
|
||||
torch.testing.assert_close(actual, expected)
|
||||
|
||||
def test_ltx25_i2v_pil_preprocessing_preserves_aspect_ratio_after_crf(self, monkeypatch):
|
||||
import vllm_omni.diffusion.models.ltx2.ltx2_conditioning as conditioning
|
||||
|
||||
pixels = torch.zeros(4, 8, 3, dtype=torch.uint8)
|
||||
pixels[:, :, 0] = torch.arange(8, dtype=torch.uint8)
|
||||
image = Image.fromarray(pixels.numpy())
|
||||
monkeypatch.setattr(conditioning, "_apply_image_conditioning_crf", lambda image_array, _crf: image_array)
|
||||
|
||||
actual = conditioning._preprocess_i2v_pil_images(image, height=4, width=4, crf=18)
|
||||
expected = pixels[:, 2:6].permute(2, 0, 1).unsqueeze(0).float() / 127.5 - 1.0
|
||||
|
||||
torch.testing.assert_close(actual, expected)
|
||||
|
||||
def test_ltx25_i2v_applies_crf18_before_resize(self, monkeypatch):
|
||||
import vllm_omni.diffusion.models.ltx2.ltx2_conditioning as conditioning
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
captured_crfs = []
|
||||
captured_prepare_kwargs = []
|
||||
|
||||
def fake_preprocess(image, *, height, width, crf, device, dtype):
|
||||
del image, device
|
||||
captured_crfs.append(crf)
|
||||
return torch.zeros(1, 3, height, width, dtype=dtype)
|
||||
|
||||
def fake_prepare_latents(**kwargs):
|
||||
captured_prepare_kwargs.append(kwargs)
|
||||
return kwargs["image"], None
|
||||
|
||||
monkeypatch.setattr(conditioning, "_preprocess_i2v_pil_images", fake_preprocess)
|
||||
pipe = object.__new__(LTX2Pipeline)
|
||||
torch.nn.Module.__init__(pipe)
|
||||
pipe.model_version = "2.5"
|
||||
object.__setattr__(pipe, "transformer", SimpleNamespace(config=SimpleNamespace(in_channels=4)))
|
||||
object.__setattr__(pipe, "prepare_latents", fake_prepare_latents)
|
||||
request_inputs = SimpleNamespace(
|
||||
height=4,
|
||||
width=4,
|
||||
num_frames=3,
|
||||
num_videos_per_prompt=1,
|
||||
generator=None,
|
||||
latents=None,
|
||||
)
|
||||
prompt_context = SimpleNamespace(
|
||||
batch_size=1,
|
||||
positive_connector_prompt_embeds=torch.zeros(1, 1, 1, dtype=torch.bfloat16),
|
||||
)
|
||||
|
||||
pipe._prepare_video_latents_stage(
|
||||
request_inputs,
|
||||
prompt_context,
|
||||
device=torch.device("cpu"),
|
||||
noise_scale=0.0,
|
||||
image=Image.new("RGB", (8, 4)),
|
||||
)
|
||||
|
||||
assert captured_crfs == [18]
|
||||
assert captured_prepare_kwargs[0]["dtype"] is torch.bfloat16
|
||||
assert captured_prepare_kwargs[0]["image"].dtype is torch.bfloat16
|
||||
|
||||
def test_ltx25_i2v_rejects_inputs_that_cannot_receive_crf18(self):
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
pipe = object.__new__(LTX2Pipeline)
|
||||
torch.nn.Module.__init__(pipe)
|
||||
pipe.model_version = "2.5"
|
||||
|
||||
with pytest.raises(ValueError, match="requires PIL images"):
|
||||
pipe._prepare_video_latents_stage(
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
device=torch.device("cpu"),
|
||||
noise_scale=0.0,
|
||||
image=torch.zeros(3, 4, 4),
|
||||
)
|
||||
|
||||
def test_ltx25_i2v_tensor_input_can_opt_out_of_crf18(self):
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
captured_prepare_kwargs = []
|
||||
|
||||
def fake_prepare_latents(**kwargs):
|
||||
captured_prepare_kwargs.append(kwargs)
|
||||
return kwargs["image"], None
|
||||
|
||||
pipe = object.__new__(LTX2Pipeline)
|
||||
torch.nn.Module.__init__(pipe)
|
||||
pipe.model_version = "2.5"
|
||||
object.__setattr__(pipe, "transformer", SimpleNamespace(config=SimpleNamespace(in_channels=4)))
|
||||
object.__setattr__(pipe, "prepare_latents", fake_prepare_latents)
|
||||
request_inputs = SimpleNamespace(
|
||||
height=4,
|
||||
width=4,
|
||||
num_frames=3,
|
||||
num_videos_per_prompt=1,
|
||||
generator=None,
|
||||
latents=None,
|
||||
image_crf=0,
|
||||
)
|
||||
prompt_context = SimpleNamespace(
|
||||
batch_size=1,
|
||||
positive_connector_prompt_embeds=torch.zeros(1, 1, 1, dtype=torch.bfloat16),
|
||||
)
|
||||
|
||||
pipe._prepare_video_latents_stage(
|
||||
request_inputs,
|
||||
prompt_context,
|
||||
device=torch.device("cpu"),
|
||||
noise_scale=0.0,
|
||||
image=torch.zeros(3, 4, 4),
|
||||
)
|
||||
|
||||
assert captured_prepare_kwargs[0]["image"].shape == (1, 3, 4, 4)
|
||||
|
||||
def test_ltx25_i2v_crf_preserves_single_pixel_dimension(self, monkeypatch):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_conditioning import _apply_image_conditioning_crf
|
||||
|
||||
image = torch.zeros(1, 4, 3, dtype=torch.uint8).numpy()
|
||||
monkeypatch.setitem(sys.modules, "av", None)
|
||||
|
||||
assert _apply_image_conditioning_crf(image, 18) is image
|
||||
|
||||
def test_ltx25_i2v_reports_missing_pyav(self, monkeypatch):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_conditioning import _apply_image_conditioning_crf
|
||||
|
||||
monkeypatch.setitem(sys.modules, "av", None)
|
||||
|
||||
with pytest.raises(ImportError, match="PyAV with a libx264 encoder"):
|
||||
_apply_image_conditioning_crf(torch.zeros(4, 4, 3, dtype=torch.uint8).numpy(), 18)
|
||||
|
||||
def test_forward_resolves_request_image_and_delegates_to_shared_recipe_runtime(self):
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
@@ -98,7 +229,7 @@ class TestLTXImageToVideoForwardStages:
|
||||
with pytest.raises(ValueError, match="cannot mix text-to-video and image-to-video"):
|
||||
pipe._resolve_request_image(mixed_req, None, request_inputs)
|
||||
|
||||
def test_denoise_timestep_kwargs_masks_video_only(self):
|
||||
def test_denoise_timestep_kwargs_masks_video_and_keeps_audio_per_token(self):
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
pipe = object.__new__(LTX2Pipeline)
|
||||
@@ -189,13 +320,18 @@ class TestLTXImageToVideoConditioning:
|
||||
torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]]))
|
||||
torch.testing.assert_close(out, torch.tensor([[[40.0, 41.0], [20.0, 21.0], [30.0, 31.0]]]))
|
||||
|
||||
def test_ltx23_i2v_5d_latents_noise_preserves_conditioning_frame(self, monkeypatch):
|
||||
@pytest.mark.parametrize(
|
||||
("model_version", "sampled_shape"),
|
||||
[("2.3", (1, 3, 2)), ("2.5", (1, 3, 2))],
|
||||
)
|
||||
def test_i2v_5d_latents_noise_uses_packed_rng_layout(self, monkeypatch, model_version, sampled_shape):
|
||||
import vllm_omni.diffusion.models.ltx2.ltx2_conditioning as ltx2_conditioning
|
||||
import vllm_omni.diffusion.models.ltx2.ltx2_latents as ltx2_latents
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
pipe = object.__new__(LTX2Pipeline)
|
||||
torch.nn.Module.__init__(pipe)
|
||||
pipe.model_version = model_version
|
||||
pipe.vae_spatial_compression_ratio = 1
|
||||
pipe.vae_temporal_compression_ratio = 1
|
||||
pipe.transformer_spatial_patch_size = 1
|
||||
@@ -237,14 +373,20 @@ class TestLTXImageToVideoConditioning:
|
||||
|
||||
torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]]))
|
||||
torch.testing.assert_close(out, torch.tensor([[[40.0, 41.0], [1.0, 1.0], [1.0, 1.0]]]))
|
||||
assert sampled_shapes == [(1, 3, 2)]
|
||||
assert sampled_shapes == [sampled_shape]
|
||||
|
||||
def test_ltx23_i2v_image_noise_is_sampled_after_packing(self, monkeypatch):
|
||||
@pytest.mark.parametrize(
|
||||
("model_version", "sampled_shape"),
|
||||
[("2.3", (1, 3, 2)), ("2.5", (1, 3, 2))],
|
||||
)
|
||||
def test_i2v_image_noise_uses_packed_rng_layout(self, monkeypatch, model_version, sampled_shape):
|
||||
import vllm_omni.diffusion.models.ltx2.ltx2_conditioning as ltx2_conditioning
|
||||
import vllm_omni.diffusion.models.ltx2.ltx2_latents as ltx2_latents
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
pipe = object.__new__(LTX2Pipeline)
|
||||
torch.nn.Module.__init__(pipe)
|
||||
pipe.model_version = model_version
|
||||
pipe.vae_spatial_compression_ratio = 1
|
||||
pipe.vae_temporal_compression_ratio = 1
|
||||
pipe.transformer_spatial_patch_size = 1
|
||||
@@ -266,6 +408,7 @@ class TestLTXImageToVideoConditioning:
|
||||
return torch.ones(shape, device=device, dtype=dtype)
|
||||
|
||||
monkeypatch.setattr(ltx2_conditioning, "randn_tensor", fake_randn_tensor)
|
||||
monkeypatch.setattr(ltx2_latents, "randn_tensor", fake_randn_tensor)
|
||||
|
||||
out, conditioning_mask = pipe.prepare_latents(
|
||||
image=torch.zeros(1, 3, 1, 1),
|
||||
@@ -279,10 +422,54 @@ class TestLTXImageToVideoConditioning:
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
|
||||
assert sampled_shapes == [(1, 3, 2)]
|
||||
assert sampled_shapes == [sampled_shape]
|
||||
torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]]))
|
||||
torch.testing.assert_close(out, torch.tensor([[[10.0, 11.0], [1.0, 1.0], [1.0, 1.0]]]))
|
||||
|
||||
def test_ltx25_re_noise_uses_official_scalar_noise_scale(self, monkeypatch):
|
||||
import vllm_omni.diffusion.models.ltx2.ltx2_latents as ltx2_latents
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
pipe = object.__new__(LTX2Pipeline)
|
||||
torch.nn.Module.__init__(pipe)
|
||||
pipe.model_version = "2.5"
|
||||
pipe.vae_spatial_compression_ratio = 1
|
||||
pipe.vae_temporal_compression_ratio = 1
|
||||
pipe.transformer_spatial_patch_size = 1
|
||||
pipe.transformer_temporal_patch_size = 1
|
||||
pipe.vae = SimpleNamespace(
|
||||
latents_mean=torch.zeros(2),
|
||||
latents_std=torch.ones(2),
|
||||
config=SimpleNamespace(scaling_factor=1.0),
|
||||
)
|
||||
object.__setattr__(
|
||||
pipe,
|
||||
"_encode_i2v_image_latents",
|
||||
lambda *_args, **_kwargs: torch.zeros(1, 2, 1, 1, 1, dtype=torch.bfloat16),
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_create_noised_state(latents, noise_scale, generator=None):
|
||||
del generator
|
||||
captured["noise_scale"] = noise_scale
|
||||
return latents
|
||||
|
||||
monkeypatch.setattr(ltx2_latents, "create_noised_state", fake_create_noised_state)
|
||||
pipe.prepare_latents(
|
||||
image=torch.zeros(1, 3, 1, 1),
|
||||
batch_size=1,
|
||||
num_channels_latents=2,
|
||||
height=1,
|
||||
width=1,
|
||||
num_frames=3,
|
||||
noise_scale=0.909375,
|
||||
dtype=torch.bfloat16,
|
||||
device=torch.device("cpu"),
|
||||
latents=torch.zeros(1, 2, 3, 1, 1, dtype=torch.bfloat16),
|
||||
)
|
||||
|
||||
assert captured["noise_scale"] == 0.909375
|
||||
|
||||
def test_ltx23_i2v_video_step_preserves_conditioning_frame(self):
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
@@ -385,6 +572,99 @@ class TestLTXImageToVideoConditioning:
|
||||
torch.testing.assert_close(audio, torch.tensor([[[-2.0]]]))
|
||||
assert pipeline.step_indices == [0, 1]
|
||||
|
||||
def test_ltx25_ancestral_adapter_matches_official_seeded_step_and_preserves_i2v_mask(self):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_denoise import LTXVideoAudioStepAdapter
|
||||
|
||||
device = torch.device("cpu")
|
||||
sigmas = torch.tensor([1.0, 0.5, 0.0])
|
||||
pipeline = SimpleNamespace(device=device, scheduler=SimpleNamespace(sigmas=sigmas))
|
||||
mask = torch.tensor([[1.0, 0.0]])
|
||||
adapter = LTXVideoAudioStepAdapter(
|
||||
pipeline,
|
||||
SimpleNamespace(sigmas=sigmas.clone()),
|
||||
latent_num_frames=1,
|
||||
latent_height=1,
|
||||
latent_width=1,
|
||||
image_conditioned=True,
|
||||
sampler="euler_ancestral",
|
||||
generator=torch.Generator(device=device).manual_seed(42),
|
||||
conditioning_mask=mask,
|
||||
)
|
||||
video = torch.tensor([[[3.0], [2.0]]])
|
||||
audio = torch.tensor([[[4.0]]])
|
||||
video_velocity = torch.tensor([[[7.0], [0.25]]])
|
||||
audio_velocity = torch.tensor([[[0.5]]])
|
||||
|
||||
reference_generator = torch.Generator(device=device).manual_seed(10042)
|
||||
video_noise = torch.randn(video.shape, generator=reference_generator)
|
||||
audio_noise = torch.randn(audio.shape, generator=reference_generator)
|
||||
|
||||
def official_step(sample, velocity, noise):
|
||||
sigma = sigmas[0]
|
||||
sigma_next = sigmas[1]
|
||||
denoised = sample.float() - velocity.float() * sigma
|
||||
sigma_down = sigma_next * (sigma_next / sigma)
|
||||
ratio = sigma_down / sigma
|
||||
deterministic = ratio * sample.float() + (1.0 - ratio) * denoised
|
||||
alpha_next = 1.0 - sigma_next
|
||||
alpha_down = 1.0 - sigma_down
|
||||
coeff = (sigma_next**2 - sigma_down**2 * alpha_next**2 / alpha_down**2).sqrt()
|
||||
return (alpha_next / alpha_down) * deterministic + noise * coeff
|
||||
|
||||
expected_video = official_step(video, video_velocity, video_noise)
|
||||
expected_video[:, :1] = video[:, :1]
|
||||
expected_audio = official_step(audio, audio_velocity, audio_noise)
|
||||
|
||||
((actual_video, actual_audio),) = adapter.step(
|
||||
(video_velocity, audio_velocity),
|
||||
(sigmas[0], sigmas[0]),
|
||||
(video, audio),
|
||||
)
|
||||
torch.testing.assert_close(actual_video, expected_video)
|
||||
torch.testing.assert_close(actual_audio, expected_audio)
|
||||
|
||||
terminal_video_velocity = torch.full_like(actual_video, 0.125)
|
||||
terminal_audio_velocity = torch.full_like(actual_audio, 0.25)
|
||||
((terminal_video, terminal_audio),) = adapter.step(
|
||||
(terminal_video_velocity, terminal_audio_velocity),
|
||||
(sigmas[1], sigmas[1]),
|
||||
(actual_video, actual_audio),
|
||||
)
|
||||
expected_terminal_video = actual_video - terminal_video_velocity * sigmas[1]
|
||||
expected_terminal_video[:, :1] = actual_video[:, :1]
|
||||
expected_terminal_audio = actual_audio - terminal_audio_velocity * sigmas[1]
|
||||
torch.testing.assert_close(terminal_video, expected_terminal_video)
|
||||
torch.testing.assert_close(terminal_audio, expected_terminal_audio)
|
||||
|
||||
def test_ltx25_ancestral_quantizes_x0_to_model_dtype_before_fp32_step(self):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_denoise import _ancestral_euler_step_from_velocity
|
||||
|
||||
sample = torch.tensor([[[3.140625, -1.234375]]], dtype=torch.bfloat16)
|
||||
velocity = torch.tensor([[[0.333984375, 2.71875]]], dtype=torch.bfloat16)
|
||||
sigmas = torch.tensor([0.73, 0.41, 0.0], dtype=torch.float32)
|
||||
generator = torch.Generator(device="cpu").manual_seed(10042)
|
||||
reference_generator = torch.Generator(device="cpu").manual_seed(10042)
|
||||
|
||||
sigma, sigma_next = sigmas[:2]
|
||||
denoised = (sample.float() - velocity.float() * sigma).to(sample.dtype).float()
|
||||
sigma_down = sigma_next * (sigma_next / sigma)
|
||||
sigma_down_ratio = sigma_down / sigma
|
||||
expected = sigma_down_ratio * sample.float() + (1.0 - sigma_down_ratio) * denoised
|
||||
alpha_next = 1.0 - sigma_next
|
||||
alpha_down = 1.0 - sigma_down
|
||||
coefficient = (sigma_next**2 - sigma_down**2 * alpha_next**2 / alpha_down**2).sqrt()
|
||||
noise = torch.randn(
|
||||
sample.shape,
|
||||
generator=reference_generator,
|
||||
dtype=sample.dtype,
|
||||
device=sample.device,
|
||||
)
|
||||
expected = ((alpha_next / alpha_down) * expected + noise.float() * coefficient).to(sample.dtype)
|
||||
|
||||
actual = _ancestral_euler_step_from_velocity(sample, velocity, sigmas, 0, generator)
|
||||
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
def test_i2v_guidance_uses_zero_sigma_for_conditioned_tokens(self):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_denoise import LTXDenoiseContext
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
@@ -202,3 +202,68 @@ def test_prepare_audio_latents_rejects_incompatible_provided_length():
|
||||
device=torch.device("cpu"),
|
||||
latents=latents,
|
||||
)
|
||||
|
||||
|
||||
def test_create_noised_state_matches_official_fp32_lerp():
|
||||
latents = torch.linspace(-2, 2, 4096, dtype=torch.bfloat16).reshape(1, 32, 128)
|
||||
expected_generator = torch.Generator().manual_seed(42)
|
||||
noise = torch.randn(latents.shape, generator=expected_generator, dtype=latents.dtype)
|
||||
expected = torch.lerp(latents.float(), noise.float(), torch.tensor(0.15, dtype=torch.bfloat16).float()).to(
|
||||
latents.dtype
|
||||
)
|
||||
|
||||
actual = latent_ops.create_noised_state(
|
||||
latents,
|
||||
torch.tensor(0.15, dtype=torch.bfloat16),
|
||||
torch.Generator().manual_seed(42),
|
||||
)
|
||||
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_create_conditioned_noised_state_matches_official_two_lerps():
|
||||
latents = torch.linspace(-2, 2, 24, dtype=torch.bfloat16).reshape(1, 3, 8)
|
||||
clean_latents = latents.clone()
|
||||
clean_latents[:, 0] = 3.0
|
||||
denoise_mask = torch.tensor([[[0.0], [1.0], [1.0]]])
|
||||
expected_generator = torch.Generator().manual_seed(42)
|
||||
noise = torch.randn(latents.shape, generator=expected_generator, dtype=latents.dtype)
|
||||
noised = torch.lerp(latents.float(), noise.float(), 0.909375)
|
||||
expected = torch.lerp(clean_latents.float(), noised, denoise_mask).to(latents.dtype)
|
||||
|
||||
actual = latent_ops.create_conditioned_noised_state(
|
||||
latents,
|
||||
clean_latents,
|
||||
denoise_mask,
|
||||
0.909375,
|
||||
torch.Generator().manual_seed(42),
|
||||
)
|
||||
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_prepare_supplied_video_latents_uses_official_token_major_layout():
|
||||
pipeline = _make_pipeline(LTX2Pipeline)
|
||||
supplied = torch.arange(512, dtype=torch.float32).reshape(1, 32, 16)
|
||||
actual_video = pipeline.prepare_latents(
|
||||
batch_size=1,
|
||||
num_channels_latents=4,
|
||||
height=64,
|
||||
width=64,
|
||||
num_frames=9,
|
||||
dtype=torch.float32,
|
||||
device=torch.device("cpu"),
|
||||
latents=supplied,
|
||||
)
|
||||
|
||||
torch.testing.assert_close(actual_video, supplied, rtol=0, atol=0)
|
||||
assert actual_video.stride()[1:] == (1, actual_video.shape[1])
|
||||
|
||||
|
||||
def test_clear_audio_padding_keeps_padding_outside_sampler_state():
|
||||
updated = torch.tensor([[[1.0, 2.0], [3.0, 4.0], [99.0, -99.0]]])
|
||||
|
||||
actual = latent_ops.clear_audio_padding(updated, 2)
|
||||
|
||||
torch.testing.assert_close(actual[:, :2], updated[:, :2])
|
||||
torch.testing.assert_close(actual[:, 2:], torch.zeros_like(actual[:, 2:]))
|
||||
@@ -28,6 +28,15 @@ def test_velocity_from_x0_uses_official_host_scalar_sigma(mocker):
|
||||
class TestCFGParallelHelpers:
|
||||
"""Test LTX-2.3 CFG helper math without loading model weights."""
|
||||
|
||||
def test_single_guidance_pass_preserves_official_token_layout(self):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_guidance import _repeat_batch
|
||||
|
||||
tokens = torch.randn(1, 16, 32).transpose(1, 2).contiguous().transpose(1, 2)
|
||||
repeated = _repeat_batch(tokens, 1)
|
||||
|
||||
assert repeated is tokens
|
||||
assert repeated.stride() == tokens.stride()
|
||||
|
||||
def test_combine_cfg_noise_matches_x0_space_formula(self):
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from vllm_omni.diffusion.models.ltx2 import (
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_components import (
|
||||
LTX2_COMPONENT_PROFILE,
|
||||
LTX2_DISTILLED_COMPONENT_PROFILE,
|
||||
LTX2_DISTILLED_ONE_STAGE_COMPONENT_PROFILE,
|
||||
LTX2_TWO_STAGE_COMPONENT_PROFILE,
|
||||
LTX23_COMPONENT_PROFILE,
|
||||
LTX23_DISTILLED_COMPONENT_PROFILE,
|
||||
@@ -47,6 +48,7 @@ from vllm_omni.diffusion.models.ltx2.ltx2_guidance import (
|
||||
)
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_latents import LTXAVState
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_recipes import (
|
||||
LTX2_DISTILLED_ONE_STAGE_RECIPE,
|
||||
LTX2_DISTILLED_TWO_STAGE_RECIPE,
|
||||
LTX2_ONE_STAGE_RECIPE,
|
||||
LTX2_TWO_STAGE_RECIPE,
|
||||
@@ -64,12 +66,14 @@ from vllm_omni.diffusion.models.ltx2.ltx2_request import (
|
||||
)
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_runtime import LTXRuntime
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import (
|
||||
LTX2DistilledOneStagePipeline,
|
||||
LTX2I2VDMD2Pipeline,
|
||||
LTX2Pipeline,
|
||||
LTX2T2VDMD2Pipeline,
|
||||
)
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_two_stage import (
|
||||
LTX2DistilledPipeline,
|
||||
LTX2DistilledTwoStagePipeline,
|
||||
LTX2TwoStagePipeline,
|
||||
)
|
||||
|
||||
@@ -125,15 +129,21 @@ def test_ltx_public_entries_share_runtime_and_keep_recipe_boundaries():
|
||||
|
||||
assert issubclass(LTX2Pipeline, LTXRuntime)
|
||||
assert issubclass(LTX2TwoStagePipeline, LTXRuntime)
|
||||
assert issubclass(LTX2DistilledOneStagePipeline, LTXRuntime)
|
||||
assert issubclass(LTX2DistilledTwoStagePipeline, LTXRuntime)
|
||||
assert issubclass(LTX2DistilledPipeline, LTXRuntime)
|
||||
assert LTX2DistilledPipeline is LTX2DistilledTwoStagePipeline
|
||||
assert LTX2Pipeline.component_profile is LTX2_COMPONENT_PROFILE
|
||||
assert LTX2Pipeline.pipeline_recipe is LTX2_ONE_STAGE_RECIPE
|
||||
assert LTX2TwoStagePipeline.component_profile is LTX2_TWO_STAGE_COMPONENT_PROFILE
|
||||
assert LTX2TwoStagePipeline.pipeline_recipe is LTX2_TWO_STAGE_RECIPE
|
||||
assert LTX2DistilledOneStagePipeline.component_profile is LTX2_DISTILLED_ONE_STAGE_COMPONENT_PROFILE
|
||||
assert LTX2DistilledOneStagePipeline.pipeline_recipe is LTX2_DISTILLED_ONE_STAGE_RECIPE
|
||||
assert LTX2DistilledPipeline.component_profile is LTX2_DISTILLED_COMPONENT_PROFILE
|
||||
for pipeline_cls, profile in (
|
||||
(LTX2Pipeline, LTX2_COMPONENT_PROFILE),
|
||||
(LTX2TwoStagePipeline, LTX2_TWO_STAGE_COMPONENT_PROFILE),
|
||||
(LTX2DistilledOneStagePipeline, LTX2_DISTILLED_ONE_STAGE_COMPONENT_PROFILE),
|
||||
(LTX2DistilledPipeline, LTX2_DISTILLED_COMPONENT_PROFILE),
|
||||
):
|
||||
assert pipeline_cls._dit_modules == list(profile.dit_modules)
|
||||
@@ -254,7 +264,7 @@ def test_ltx_multistage_rejects_cache_dit_before_component_initialization(tmp_pa
|
||||
|
||||
monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", stub_components)
|
||||
|
||||
with pytest.raises(ValueError, match="only one-stage LTX recipes"):
|
||||
with pytest.raises(ValueError, match="not qualified for this LTX recipe"):
|
||||
LTX2TwoStagePipeline(
|
||||
od_config=SimpleNamespace(
|
||||
model=str(tmp_path),
|
||||
@@ -709,15 +719,48 @@ def test_ltx_component_loader_installs_omni_connector_attention(monkeypatch):
|
||||
|
||||
assert len(installed) == 2
|
||||
assert all(processor.__class__.__name__ == "_LTXConnectorAttnProcessor" for processor in installed)
|
||||
assert all(processor.has_learned_registers for processor in installed)
|
||||
assert all(kernel["role"] == "ltx2.connector" for kernel in kernels)
|
||||
assert all(kernel["role_category"] == "self" for kernel in kernels)
|
||||
assert all(kernel["skip_sequence_parallel"] for kernel in kernels)
|
||||
assert all(kernel["disable_kv_quant"] for kernel in kernels)
|
||||
assert all(processor.has_learned_registers for processor in installed)
|
||||
assert not any(processor.preserve_learned_register_mask for processor in installed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_learned_registers", [False, True])
|
||||
def test_ltx_connector_attention_dispatches_through_omni_kernel(has_learned_registers):
|
||||
def test_ltx25_connector_attention_preserves_learned_register_mask(monkeypatch):
|
||||
installed = []
|
||||
|
||||
class OmniAttention:
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
class Attention:
|
||||
heads = 32
|
||||
head_dim = 128
|
||||
inner_kv_dim = 4096
|
||||
|
||||
def set_processor(self, processor):
|
||||
installed.append(processor)
|
||||
|
||||
monkeypatch.setattr(ltx2_components, "OmniAttention", OmniAttention)
|
||||
connectors = SimpleNamespace(
|
||||
video_connector=SimpleNamespace(
|
||||
learnable_registers=object(),
|
||||
transformer_blocks=[SimpleNamespace(attn1=Attention())],
|
||||
),
|
||||
audio_connector=SimpleNamespace(
|
||||
learnable_registers=object(),
|
||||
transformer_blocks=[SimpleNamespace(attn1=Attention())],
|
||||
),
|
||||
)
|
||||
|
||||
_install_connector_attention(connectors, preserve_learned_register_mask=True)
|
||||
|
||||
assert all(processor.has_learned_registers for processor in installed)
|
||||
assert all(processor.preserve_learned_register_mask for processor in installed)
|
||||
|
||||
|
||||
def test_ltx_connector_attention_dispatches_through_omni_kernel():
|
||||
calls = []
|
||||
|
||||
class Backend:
|
||||
@@ -732,12 +775,16 @@ def test_ltx_connector_attention_dispatches_through_omni_kernel(has_learned_regi
|
||||
calls.append((query, key, value, metadata))
|
||||
return query
|
||||
|
||||
class UpcastingNorm(torch.nn.Module):
|
||||
def forward(self, tensor):
|
||||
return tensor.float()
|
||||
|
||||
attention = SimpleNamespace(
|
||||
heads=2,
|
||||
head_dim=4,
|
||||
inner_kv_dim=8,
|
||||
norm_q=torch.nn.Identity(),
|
||||
norm_k=torch.nn.Identity(),
|
||||
norm_q=UpcastingNorm(),
|
||||
norm_k=UpcastingNorm(),
|
||||
to_q=torch.nn.Identity(),
|
||||
to_k=torch.nn.Identity(),
|
||||
to_v=torch.nn.Identity(),
|
||||
@@ -746,24 +793,29 @@ def test_ltx_connector_attention_dispatches_through_omni_kernel(has_learned_regi
|
||||
rope_type="split",
|
||||
omni_attention=OmniAttention(),
|
||||
)
|
||||
hidden_states = torch.randn(2, 3, 8)
|
||||
hidden_states = torch.randn(2, 3, 8, dtype=torch.bfloat16)
|
||||
additive_mask = torch.zeros(2, 1, 3, 3)
|
||||
rotary = (
|
||||
torch.randn(2, 2, 3, 2, dtype=torch.float32),
|
||||
torch.randn(2, 2, 3, 2, dtype=torch.float32),
|
||||
)
|
||||
|
||||
output = ltx2_components._LTXConnectorAttnProcessor(has_learned_registers=has_learned_registers)(
|
||||
output = ltx2_components._LTXConnectorAttnProcessor()(
|
||||
attention,
|
||||
hidden_states,
|
||||
attention_mask=additive_mask,
|
||||
query_rotary_emb=rotary,
|
||||
)
|
||||
|
||||
query, key, value, metadata = calls[0]
|
||||
assert query.shape == key.shape == value.shape == (2, 3, 2, 4)
|
||||
if has_learned_registers:
|
||||
assert metadata is None
|
||||
else:
|
||||
assert metadata.attn_mask.shape == (2, 3)
|
||||
assert metadata.attn_mask.dtype == torch.bool
|
||||
assert metadata.attn_mask.all()
|
||||
torch.testing.assert_close(output, hidden_states)
|
||||
assert query.dtype == key.dtype == value.dtype == torch.bfloat16
|
||||
assert metadata.attn_mask.shape == (2, 3)
|
||||
assert metadata.attn_mask.dtype == torch.bool
|
||||
assert metadata.attn_mask.all()
|
||||
expected_rotary = tuple(component.to(torch.bfloat16) for component in rotary)
|
||||
expected = ltx2_components.apply_split_rotary_emb(hidden_states, expected_rotary, head_dim=4)
|
||||
torch.testing.assert_close(output, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_ltx2_distilled_pipeline_shares_recipe_runtime():
|
||||
@@ -1707,6 +1759,7 @@ class TestLTXForwardStages:
|
||||
assert seen["request_inputs"].output_type == "latent"
|
||||
assert seen["kwargs"] == {
|
||||
"request_sigmas": request_sigmas,
|
||||
"request_phase_sigmas": None,
|
||||
"image": None,
|
||||
}
|
||||
|
||||
@@ -1824,6 +1877,16 @@ class TestRegistryIntegration:
|
||||
"pipeline_ltx2_two_stage",
|
||||
"LTX2TwoStagePipeline",
|
||||
)
|
||||
assert _DIFFUSION_MODELS["LTX2DistilledOneStagePipeline"] == (
|
||||
"ltx2",
|
||||
"pipeline_ltx2",
|
||||
"LTX2DistilledOneStagePipeline",
|
||||
)
|
||||
assert _DIFFUSION_MODELS["LTX2DistilledTwoStagePipeline"] == (
|
||||
"ltx2",
|
||||
"pipeline_ltx2_two_stage",
|
||||
"LTX2DistilledTwoStagePipeline",
|
||||
)
|
||||
assert _DIFFUSION_MODELS["LTX2DistilledPipeline"] == (
|
||||
"ltx2",
|
||||
"pipeline_ltx2_two_stage",
|
||||
@@ -1848,6 +1911,8 @@ class TestRegistryIntegration:
|
||||
expected = [
|
||||
"LTX2Pipeline",
|
||||
"LTX2TwoStagePipeline",
|
||||
"LTX2DistilledOneStagePipeline",
|
||||
"LTX2DistilledTwoStagePipeline",
|
||||
"LTX2DistilledPipeline",
|
||||
"LTX2T2VDMD2Pipeline",
|
||||
"LTX2I2VDMD2Pipeline",
|
||||
@@ -1861,6 +1926,8 @@ class TestRegistryIntegration:
|
||||
[
|
||||
"LTX2Pipeline",
|
||||
"LTX2TwoStagePipeline",
|
||||
"LTX2DistilledOneStagePipeline",
|
||||
"LTX2DistilledTwoStagePipeline",
|
||||
"LTX2DistilledPipeline",
|
||||
"LTX2T2VDMD2Pipeline",
|
||||
"LTX2I2VDMD2Pipeline",
|
||||
|
||||
@@ -7,18 +7,54 @@ import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.cache.cachedit import CacheDiTAdapterConfig
|
||||
from vllm_omni.diffusion.models.ltx2 import ltx2_transformer
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import (
|
||||
LTX2AudioVideoAttnProcessor,
|
||||
LTX2FeedForward,
|
||||
LTX2VideoTransformer3DModel,
|
||||
_LTX2ParallelAttention,
|
||||
LTX2VideoTransformerBlock,
|
||||
_make_rms_norm,
|
||||
apply_interleaved_rotary_emb,
|
||||
apply_keyframes_absolute_embedding,
|
||||
apply_split_rotary_emb,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_ltx_interleaved_rope_matches_official_model_dtype_arithmetic():
|
||||
torch.manual_seed(0)
|
||||
x = torch.randn(2, 3, 8, dtype=torch.bfloat16)
|
||||
cos = torch.randn(2, 3, 8, dtype=x.dtype)
|
||||
sin = torch.randn(2, 3, 8, dtype=x.dtype)
|
||||
x_real, x_imag = x.unflatten(2, (4, 2)).unbind(-1)
|
||||
rotated = torch.stack([-x_imag, x_real], dim=-1).flatten(2)
|
||||
expected = x * cos + rotated * sin
|
||||
|
||||
actual = apply_interleaved_rotary_emb(x, (cos, sin))
|
||||
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_ltx_split_rope_matches_official_model_dtype_arithmetic():
|
||||
torch.manual_seed(1)
|
||||
x = torch.randn(2, 3, 8, dtype=torch.bfloat16)
|
||||
cos = torch.randn(2, 3, 4, dtype=x.dtype)
|
||||
sin = torch.randn(2, 3, 4, dtype=x.dtype)
|
||||
split_x = x.reshape(2, 3, 2, 4)
|
||||
first_x, second_x = split_x.chunk(2, dim=-2)
|
||||
cos_u = cos.unsqueeze(-2)
|
||||
sin_u = sin.unsqueeze(-2)
|
||||
expected = split_x * cos_u
|
||||
expected[..., :1, :].addcmul_(-sin_u, second_x)
|
||||
expected[..., 1:, :].addcmul_(sin_u, first_x)
|
||||
expected = expected.reshape_as(x)
|
||||
|
||||
actual = apply_split_rotary_emb(x, (cos, sin), head_dim=8)
|
||||
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_ltx_rms_norm_no_affine_matches_official_torch_module():
|
||||
norm = _make_rms_norm(8, eps=1e-6, elementwise_affine=False)
|
||||
|
||||
@@ -36,11 +72,53 @@ def test_ltx_rms_norm_affine_weight_remains_parameter():
|
||||
assert "weight" in norm.state_dict()
|
||||
|
||||
|
||||
def test_ltx_transformer_has_separate_cfg_cache_dit_config():
|
||||
def test_ltx_attention_restores_projection_dtype_after_upcasting_qk_norms():
|
||||
calls = []
|
||||
|
||||
class UpcastingNorm(nn.Module):
|
||||
def forward(self, tensor):
|
||||
return tensor.float()
|
||||
|
||||
class RecordingAttention:
|
||||
def __call__(self, query, key, value, metadata):
|
||||
calls.append((query, key, value, metadata))
|
||||
return query
|
||||
|
||||
attention = SimpleNamespace(
|
||||
heads=2,
|
||||
head_dim=4,
|
||||
to_qkv=None,
|
||||
to_q=nn.Identity(),
|
||||
to_k=nn.Identity(),
|
||||
to_v=nn.Identity(),
|
||||
norm_q=UpcastingNorm(),
|
||||
norm_k=UpcastingNorm(),
|
||||
to_gate_logits=None,
|
||||
attn=RecordingAttention(),
|
||||
to_out=(nn.Identity(), nn.Identity()),
|
||||
rope_type="split",
|
||||
)
|
||||
hidden_states = torch.randn(2, 3, 8, dtype=torch.bfloat16)
|
||||
rotary = (
|
||||
torch.ones(2, 2, 3, 2, dtype=torch.float32),
|
||||
torch.zeros(2, 2, 3, 2, dtype=torch.float32),
|
||||
)
|
||||
|
||||
processor = ltx2_transformer.LTX2AudioVideoAttnProcessor()
|
||||
processor._slice_rope_for_tp = lambda rope, _: rope
|
||||
output = processor(attention, hidden_states, query_rotary_emb=rotary)
|
||||
|
||||
query, key, value, metadata = calls[0]
|
||||
assert query.dtype == key.dtype == value.dtype == torch.bfloat16
|
||||
assert metadata is None
|
||||
torch.testing.assert_close(output, hidden_states)
|
||||
|
||||
|
||||
def test_ltx_transformer_has_fused_cfg_cache_dit_config():
|
||||
adapter_config = getattr(LTX2VideoTransformer3DModel, "_cache_dit_adapter_config")
|
||||
|
||||
assert isinstance(adapter_config, CacheDiTAdapterConfig)
|
||||
assert adapter_config.has_separate_cfg
|
||||
assert not adapter_config.has_separate_cfg
|
||||
|
||||
|
||||
def test_ltx_transformer_exposes_hsdp_shard_conditions_for_blocks():
|
||||
@@ -76,8 +154,9 @@ def test_ltx_sp_plan_shards_video_and_audio_timesteps_together(rope_type):
|
||||
|
||||
|
||||
def test_ltx_sp_keeps_cross_attention_key_padding_mask(monkeypatch):
|
||||
monkeypatch.setattr(LTX2AudioVideoAttnProcessor, "_is_sp_enabled", staticmethod(lambda: True))
|
||||
processor = LTX2AudioVideoAttnProcessor()
|
||||
processor_cls = ltx2_transformer.LTX2AudioVideoAttnProcessor
|
||||
monkeypatch.setattr(processor_cls, "_is_sp_enabled", staticmethod(lambda: True))
|
||||
processor = processor_cls()
|
||||
hidden_states = torch.zeros(1, 3, 4)
|
||||
encoder_hidden_states = torch.zeros(1, 4, 4)
|
||||
key_padding_mask = torch.tensor([[True, True, True, False]])
|
||||
@@ -113,7 +192,7 @@ def _fake_ltx_parallel_attention():
|
||||
|
||||
return forward
|
||||
|
||||
layer = object.__new__(_LTX2ParallelAttention)
|
||||
layer = object.__new__(ltx2_transformer._LTX2ParallelAttention)
|
||||
nn.Module.__init__(layer)
|
||||
layer.attn_backend = Backend
|
||||
layer.attention = SimpleNamespace(forward=run("native"))
|
||||
@@ -123,33 +202,281 @@ def _fake_ltx_parallel_attention():
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("query_length", "key_length"), [(4, 4), (3, 4)])
|
||||
def test_ltx_masked_cross_attention_uses_sdpa(query_length, key_length):
|
||||
def test_ltx_masked_attention_uses_sdpa(query_length, key_length):
|
||||
layer, calls = _fake_ltx_parallel_attention()
|
||||
query = torch.zeros(1, query_length, 2, 4, dtype=torch.bfloat16)
|
||||
key = value = torch.zeros(1, key_length, 2, 4, dtype=torch.bfloat16)
|
||||
key_padding_mask = torch.tensor([[True] * (key_length - 1) + [False]])
|
||||
metadata = AttentionMetadata(attn_mask=key_padding_mask[:, None, None, :])
|
||||
metadata = ltx2_transformer.AttentionMetadata(attn_mask=key_padding_mask[:, None, None, :])
|
||||
|
||||
layer._run_local_attention(query, key, value, metadata)
|
||||
|
||||
assert calls == ["sdpa"]
|
||||
|
||||
|
||||
def test_ltx_masked_self_attention_uses_sdpa():
|
||||
layer, calls = _fake_ltx_parallel_attention()
|
||||
query = key = value = torch.zeros(1, 4, 2, 4, dtype=torch.bfloat16)
|
||||
key_padding_mask = torch.tensor([[True, True, True, False]])
|
||||
metadata = AttentionMetadata(attn_mask=key_padding_mask[:, None, None, :])
|
||||
|
||||
layer._run_local_attention(query, key, value, metadata)
|
||||
|
||||
assert calls == ["sdpa"]
|
||||
|
||||
|
||||
def test_ltx_unmasked_cross_attention_keeps_native_backend():
|
||||
def test_ltx_unmasked_attention_keeps_native_backend():
|
||||
layer, calls = _fake_ltx_parallel_attention()
|
||||
query = key = value = torch.zeros(1, 4, 2, 4, dtype=torch.bfloat16)
|
||||
|
||||
layer._run_local_attention(query, key, value, None)
|
||||
|
||||
assert calls == ["native"]
|
||||
|
||||
|
||||
def test_ltx25_feed_forward_bias_flag_controls_both_linears(monkeypatch):
|
||||
class FakeParallelLinear(nn.Linear):
|
||||
def __init__(self, dim_in, dim_out, *, bias=True, **kwargs):
|
||||
super().__init__(dim_in, dim_out, bias=bias)
|
||||
|
||||
monkeypatch.setattr(ltx2_transformer, "ColumnParallelLinear", FakeParallelLinear)
|
||||
monkeypatch.setattr(ltx2_transformer, "RowParallelLinear", FakeParallelLinear)
|
||||
|
||||
without_bias = LTX2FeedForward(8, inner_dim=16, bias=False)
|
||||
with_bias = LTX2FeedForward(8, inner_dim=16, bias=True)
|
||||
|
||||
assert without_bias.net[0].proj.bias is None
|
||||
assert without_bias.net[2].bias is None
|
||||
assert with_bias.net[0].proj.bias is not None
|
||||
assert with_bias.net[2].bias is not None
|
||||
|
||||
|
||||
def _build_tiny_ltx_transformer(monkeypatch, **overrides):
|
||||
class FakeBlock(nn.Module):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__()
|
||||
self.init_kwargs = kwargs
|
||||
|
||||
def forward(self, hidden_states, audio_hidden_states, *_args, **_kwargs):
|
||||
return hidden_states, audio_hidden_states
|
||||
|
||||
monkeypatch.setattr(ltx2_transformer, "LTX2VideoTransformerBlock", FakeBlock)
|
||||
kwargs = {
|
||||
"in_channels": 4,
|
||||
"out_channels": 4,
|
||||
"num_attention_heads": 2,
|
||||
"attention_head_dim": 4,
|
||||
"cross_attention_dim": 8,
|
||||
"vae_scale_factors": (1, 1, 1),
|
||||
"pos_embed_max_pos": 4,
|
||||
"base_height": 8,
|
||||
"base_width": 8,
|
||||
"audio_in_channels": 4,
|
||||
"audio_out_channels": 4,
|
||||
"audio_num_attention_heads": 2,
|
||||
"audio_attention_head_dim": 4,
|
||||
"audio_cross_attention_dim": 8,
|
||||
"audio_scale_factor": 1,
|
||||
"audio_pos_embed_max_pos": 4,
|
||||
"audio_sampling_rate": 16,
|
||||
"audio_hop_length": 4,
|
||||
"num_layers": 1,
|
||||
"caption_channels": 8,
|
||||
"use_prompt_embeddings": False,
|
||||
"cross_attn_mod": True,
|
||||
"audio_cross_attn_mod": True,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return LTX2VideoTransformer3DModel(**kwargs)
|
||||
|
||||
|
||||
def test_ltx25_keyframe_absolute_embedding_matches_official():
|
||||
hidden_states = torch.arange(32, dtype=torch.bfloat16).view(1, 4, 8)
|
||||
keyframes_mask = torch.tensor([[[1.0], [1.0], [0.0], [0.0]]])
|
||||
embedding = torch.linspace(-0.5, 0.5, 8, dtype=torch.bfloat16).view(1, 8)
|
||||
|
||||
actual = apply_keyframes_absolute_embedding(hidden_states, keyframes_mask, embedding)
|
||||
expected = hidden_states + (keyframes_mask > 0).to(torch.bfloat16) * embedding
|
||||
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
assert apply_keyframes_absolute_embedding(hidden_states, None, embedding) is hidden_states
|
||||
|
||||
|
||||
def test_ltx25_constructor_flags_and_native_weight_loader(monkeypatch):
|
||||
legacy_model = _build_tiny_ltx_transformer(monkeypatch)
|
||||
|
||||
assert legacy_model.config.ff_bias is True
|
||||
assert legacy_model.config.audio_ff_bias is True
|
||||
assert legacy_model.config.use_prompt_adaln_single is True
|
||||
assert legacy_model.config.use_keyframes_abs_pos_embedding is False
|
||||
assert hasattr(legacy_model, "prompt_adaln")
|
||||
assert hasattr(legacy_model, "audio_prompt_adaln")
|
||||
assert not hasattr(legacy_model, "keyframes_abs_pos_embedding")
|
||||
assert legacy_model.transformer_blocks[0].init_kwargs["ff_bias"] is True
|
||||
assert legacy_model.transformer_blocks[0].init_kwargs["audio_ff_bias"] is True
|
||||
|
||||
ltx25_model = _build_tiny_ltx_transformer(
|
||||
monkeypatch,
|
||||
ff_bias=False,
|
||||
audio_ff_bias=True,
|
||||
use_prompt_adaln_single=False,
|
||||
use_keyframes_abs_pos_embedding=True,
|
||||
)
|
||||
|
||||
assert ltx25_model.keyframes_abs_pos_embedding.shape == (1, 8)
|
||||
torch.testing.assert_close(ltx25_model.keyframes_abs_pos_embedding, torch.zeros(1, 8))
|
||||
assert not hasattr(ltx25_model, "prompt_adaln")
|
||||
assert not hasattr(ltx25_model, "audio_prompt_adaln")
|
||||
assert ltx25_model.transformer_blocks[0].init_kwargs["ff_bias"] is False
|
||||
assert ltx25_model.transformer_blocks[0].init_kwargs["audio_ff_bias"] is True
|
||||
|
||||
monkeypatch.setattr(ltx2_transformer, "get_tensor_model_parallel_world_size", lambda: 1)
|
||||
expected = torch.arange(8, dtype=torch.float32).view(1, 8)
|
||||
loaded = ltx25_model.load_weights([("keyframes_abs_pos_embedding", expected)])
|
||||
|
||||
assert loaded == {"keyframes_abs_pos_embedding"}
|
||||
torch.testing.assert_close(ltx25_model.keyframes_abs_pos_embedding, expected)
|
||||
|
||||
|
||||
def test_ltx_cross_attention_modulations_use_official_timesteps(monkeypatch):
|
||||
class CaptureAda(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls = []
|
||||
|
||||
def forward(self, timestep, *, batch_size, hidden_dtype):
|
||||
self.calls.append(timestep.detach().clone())
|
||||
embedding = torch.zeros(timestep.numel(), 8, dtype=hidden_dtype)
|
||||
return embedding, embedding
|
||||
|
||||
class CaptureRope(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.output_dtypes = []
|
||||
|
||||
def forward(self, _coords, *, device, out_dtype=None):
|
||||
self.output_dtypes.append(out_dtype)
|
||||
empty = torch.empty(0, device=device, dtype=out_dtype)
|
||||
return empty, empty
|
||||
|
||||
model = _build_tiny_ltx_transformer(monkeypatch)
|
||||
rope = CaptureRope()
|
||||
model.rope = model.audio_rope = model.cross_attn_rope = model.cross_attn_audio_rope = rope
|
||||
model.prompt_modulation = False
|
||||
video_scale_shift = CaptureAda()
|
||||
video_gate = CaptureAda()
|
||||
audio_scale_shift = CaptureAda()
|
||||
audio_gate = CaptureAda()
|
||||
model.av_cross_attn_video_scale_shift = video_scale_shift
|
||||
model.av_cross_attn_video_a2v_gate = video_gate
|
||||
model.av_cross_attn_audio_scale_shift = audio_scale_shift
|
||||
model.av_cross_attn_audio_v2a_gate = audio_gate
|
||||
|
||||
model(
|
||||
hidden_states=torch.zeros(1, 2, 4),
|
||||
audio_hidden_states=torch.zeros(1, 1, 4),
|
||||
encoder_hidden_states=torch.zeros(1, 1, 8),
|
||||
audio_encoder_hidden_states=torch.zeros(1, 1, 8),
|
||||
timestep=torch.tensor([[0.0, 2.0]]),
|
||||
audio_timestep=torch.tensor([[5.0]]),
|
||||
sigma=torch.tensor([2.0]),
|
||||
audio_sigma=torch.tensor([5.0]),
|
||||
video_coords=torch.zeros(1, 3, 2, 2),
|
||||
audio_coords=torch.zeros(1, 1, 1, 2),
|
||||
)
|
||||
|
||||
torch.testing.assert_close(video_scale_shift.calls[0], torch.tensor([0.0, 2.0]))
|
||||
torch.testing.assert_close(video_gate.calls[0], torch.tensor([5.0]))
|
||||
torch.testing.assert_close(audio_scale_shift.calls[0], torch.tensor([5.0]))
|
||||
torch.testing.assert_close(audio_gate.calls[0], torch.tensor([2.0]))
|
||||
assert len(rope.output_dtypes) == 4
|
||||
assert all(dtype is torch.float32 for dtype in rope.output_dtypes)
|
||||
|
||||
|
||||
def test_ltx25_static_prompt_table_is_used_without_prompt_adaln():
|
||||
class CaptureAttention(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.encoder_hidden_states = None
|
||||
|
||||
def forward(self, hidden_states, encoder_hidden_states=None, **kwargs):
|
||||
if encoder_hidden_states is not None:
|
||||
self.encoder_hidden_states = encoder_hidden_states.detach().clone()
|
||||
return torch.zeros_like(hidden_states)
|
||||
|
||||
class ZeroOutput(nn.Module):
|
||||
def forward(self, hidden_states):
|
||||
return torch.zeros_like(hidden_states)
|
||||
|
||||
block = object.__new__(LTX2VideoTransformerBlock)
|
||||
nn.Module.__init__(block)
|
||||
block.video_cross_attn_adaln = True
|
||||
block.audio_cross_attn_adaln = True
|
||||
block.cross_attn_adaln = True
|
||||
block.scale_shift_table = nn.Parameter(torch.zeros(9, 2))
|
||||
block.audio_scale_shift_table = nn.Parameter(torch.zeros(9, 2))
|
||||
block.prompt_scale_shift_table = nn.Parameter(torch.tensor([[1.0, 2.0], [3.0, 4.0]]))
|
||||
block.audio_prompt_scale_shift_table = nn.Parameter(torch.tensor([[0.5, 1.0], [1.5, 2.0]]))
|
||||
block.norm1 = nn.Identity()
|
||||
block.audio_norm1 = nn.Identity()
|
||||
block.norm2 = nn.Identity()
|
||||
block.audio_norm2 = nn.Identity()
|
||||
block.norm3 = nn.Identity()
|
||||
block.audio_norm3 = nn.Identity()
|
||||
block.attn1 = CaptureAttention()
|
||||
block.audio_attn1 = CaptureAttention()
|
||||
block.attn2 = CaptureAttention()
|
||||
block.audio_attn2 = CaptureAttention()
|
||||
block.ff = ZeroOutput()
|
||||
block.audio_ff = ZeroOutput()
|
||||
|
||||
hidden_states = torch.ones(1, 1, 2)
|
||||
audio_hidden_states = torch.ones(1, 1, 2)
|
||||
encoder_hidden_states = torch.ones(1, 1, 2)
|
||||
audio_encoder_hidden_states = torch.ones(1, 1, 2)
|
||||
block(
|
||||
hidden_states,
|
||||
audio_hidden_states,
|
||||
encoder_hidden_states,
|
||||
audio_encoder_hidden_states,
|
||||
temb=torch.zeros(1, 1, 18),
|
||||
temb_audio=torch.zeros(1, 1, 18),
|
||||
temb_ca_scale_shift=torch.empty(0),
|
||||
temb_ca_audio_scale_shift=torch.empty(0),
|
||||
temb_ca_gate=torch.empty(0),
|
||||
temb_ca_audio_gate=torch.empty(0),
|
||||
temb_prompt=None,
|
||||
temb_prompt_audio=None,
|
||||
use_a2v_cross_attention=False,
|
||||
use_v2a_cross_attention=False,
|
||||
)
|
||||
|
||||
torch.testing.assert_close(block.attn2.encoder_hidden_states, torch.tensor([[[5.0, 7.0]]]))
|
||||
torch.testing.assert_close(block.audio_attn2.encoder_hidden_states, torch.tensor([[[3.0, 4.0]]]))
|
||||
|
||||
|
||||
def test_ltx_gated_attention_uses_tp_sharded_projection(monkeypatch):
|
||||
column_calls = []
|
||||
|
||||
class FakeColumnParallelLinear(nn.Linear):
|
||||
def __init__(self, input_size, output_size, *, bias=True, **kwargs):
|
||||
column_calls.append((output_size, kwargs))
|
||||
super().__init__(input_size, output_size, bias=bias)
|
||||
|
||||
class FakeRowParallelLinear(nn.Linear):
|
||||
def __init__(self, input_size, output_size, *, bias=True, **_kwargs):
|
||||
super().__init__(input_size, output_size, bias=bias)
|
||||
|
||||
class FakeParallelAttention(nn.Module):
|
||||
def __init__(self, **_kwargs):
|
||||
super().__init__()
|
||||
|
||||
monkeypatch.setattr(ltx2_transformer, "ColumnParallelLinear", FakeColumnParallelLinear)
|
||||
monkeypatch.setattr(ltx2_transformer, "RowParallelLinear", FakeRowParallelLinear)
|
||||
monkeypatch.setattr(ltx2_transformer, "_LTX2ParallelAttention", FakeParallelAttention)
|
||||
monkeypatch.setattr(ltx2_transformer, "get_tensor_model_parallel_world_size", lambda: 2)
|
||||
|
||||
attention = ltx2_transformer.LTX2Attention(
|
||||
query_dim=8,
|
||||
heads=4,
|
||||
kv_heads=4,
|
||||
dim_head=2,
|
||||
qk_norm="rms_norm",
|
||||
apply_gated_attention=True,
|
||||
pack_qkv=False,
|
||||
)
|
||||
|
||||
gate_output_size, gate_kwargs = column_calls[-1]
|
||||
assert gate_output_size == 4
|
||||
assert gate_kwargs["gather_output"] is False
|
||||
assert gate_kwargs["return_bias"] is False
|
||||
assert attention.query_num_heads == 2
|
||||
@@ -0,0 +1,591 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Generate raw LTX video and audio outputs with the official or Omni runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--backend", choices=("official", "omni"), required=True)
|
||||
parser.add_argument("--request", type=Path, required=True)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--model")
|
||||
parser.add_argument("--model-class-name")
|
||||
parser.add_argument("--official-root", type=Path)
|
||||
parser.add_argument("--checkpoint", type=Path)
|
||||
parser.add_argument("--gemma-root", type=Path)
|
||||
parser.add_argument("--transformer-path", type=Path)
|
||||
parser.add_argument("--text-encoder-path", type=Path)
|
||||
parser.add_argument("--video-vae-path", type=Path)
|
||||
parser.add_argument("--audio-vae-path", type=Path)
|
||||
parser.add_argument("--spatial-upsampler-path", type=Path)
|
||||
parser.add_argument("--distilled-lora-path", type=Path)
|
||||
parser.add_argument("--connector-model", type=Path)
|
||||
parser.add_argument(
|
||||
"--official-pipeline",
|
||||
choices=("distilled_two_stage", "full_one_stage", "full_two_stage"),
|
||||
default="distilled_two_stage",
|
||||
)
|
||||
parser.add_argument("--enable-layerwise-offload", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _save_outputs(
|
||||
output_dir: Path,
|
||||
*,
|
||||
video: torch.Tensor,
|
||||
audio: torch.Tensor,
|
||||
audio_sample_rate: int,
|
||||
fps: float,
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
video = video.detach().float().cpu().clamp(0.0, 1.0)
|
||||
audio = audio.detach().float().cpu()
|
||||
np.save(output_dir / "video.npy", video.numpy())
|
||||
np.save(output_dir / "audio.npy", audio.numpy())
|
||||
|
||||
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
|
||||
|
||||
frames_u8 = video.mul(255.0).round().to(torch.uint8).numpy()
|
||||
audio_array = audio.numpy()
|
||||
while audio_array.ndim > 2 and audio_array.shape[0] == 1:
|
||||
audio_array = audio_array[0]
|
||||
(output_dir / "output.mp4").write_bytes(
|
||||
mux_video_audio_bytes(
|
||||
frames_u8,
|
||||
audio_array,
|
||||
fps=float(fps),
|
||||
audio_sample_rate=int(audio_sample_rate),
|
||||
video_codec_options={"preset": "ultrafast"},
|
||||
)
|
||||
)
|
||||
|
||||
frame_indices = sorted({0, video.shape[0] // 2, video.shape[0] - 1})
|
||||
for index in frame_indices:
|
||||
frame = video[index].mul(255.0).round().to(torch.uint8).numpy()
|
||||
Image.fromarray(frame).save(output_dir / f"frame_{index:04d}.png")
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"video_shape": list(video.shape),
|
||||
"audio_shape": list(audio.shape),
|
||||
"audio_sample_rate": int(audio_sample_rate),
|
||||
"frame_indices": frame_indices,
|
||||
"mp4": "output.mp4",
|
||||
}
|
||||
)
|
||||
(output_dir / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
|
||||
|
||||
def _reset_accelerator_peak_memory_stats() -> None:
|
||||
if torch.accelerator.is_available():
|
||||
torch.accelerator.reset_peak_memory_stats()
|
||||
|
||||
|
||||
def _accelerator_peak_memory_stats() -> dict[str, float]:
|
||||
if not torch.accelerator.is_available():
|
||||
return {}
|
||||
torch.accelerator.synchronize()
|
||||
mib = 1024**2
|
||||
return {
|
||||
"peak_memory_allocated_mb": torch.accelerator.max_memory_allocated() / mib,
|
||||
"peak_memory_reserved_mb": torch.accelerator.max_memory_reserved() / mib,
|
||||
}
|
||||
|
||||
|
||||
def _insert_official_paths(official_root: Path) -> None:
|
||||
for relative_path in ("packages/ltx-core/src", "packages/ltx-pipelines/src"):
|
||||
path = str((official_root / relative_path).resolve())
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
|
||||
def _configure_official_sdpa(pipeline: Any) -> None:
|
||||
"""Pin official connector and denoiser attention to cuDNN, with MATH fallback."""
|
||||
from ltx_core.loader.attention_ops import set_attention_module_op
|
||||
from ltx_core.model.transformer.attention import PytorchAttention
|
||||
from torch.nn.attention import SDPBackend
|
||||
|
||||
attention = PytorchAttention(priority=[SDPBackend.CUDNN_ATTENTION, SDPBackend.MATH])
|
||||
module_op = set_attention_module_op(
|
||||
attention=attention,
|
||||
masked_attention=attention,
|
||||
)
|
||||
transformer_owners = [
|
||||
owner
|
||||
for attribute in ("stage", "stage_1", "stage_2")
|
||||
if (owner := getattr(pipeline, attribute, None)) is not None
|
||||
]
|
||||
owners_and_attributes = [
|
||||
*((owner, "_transformer_builder") for owner in transformer_owners),
|
||||
(pipeline.prompt_encoder, "_embeddings_processor_builder"),
|
||||
]
|
||||
for owner, attribute in owners_and_attributes:
|
||||
builder = getattr(owner, attribute)
|
||||
setattr(
|
||||
owner,
|
||||
attribute,
|
||||
builder.with_module_ops((*builder.module_ops, module_op)),
|
||||
)
|
||||
|
||||
|
||||
def _use_diffusers_connector_weights(prompt_encoder: Any, model: Path) -> None:
|
||||
"""Run official connector code with the checkpoint under test."""
|
||||
from safetensors import safe_open
|
||||
|
||||
connector_root = model / "connectors"
|
||||
shards = sorted(connector_root.glob("*.safetensors"))
|
||||
if not shards:
|
||||
raise ValueError(f"No connector safetensors found under {connector_root}")
|
||||
|
||||
original_build_embeddings_processor = prompt_encoder._build_embeddings_processor
|
||||
|
||||
def build_embeddings_processor():
|
||||
processor = original_build_embeddings_processor()
|
||||
parameters = dict(processor.named_parameters())
|
||||
expected = {name for name in parameters if name.startswith(("video_connector.", "audio_connector."))}
|
||||
loaded: set[str] = set()
|
||||
with torch.no_grad():
|
||||
for shard in shards:
|
||||
with safe_open(str(shard), framework="pt", device="cpu") as weights:
|
||||
for source_name in weights.keys():
|
||||
if not source_name.startswith(("video_connector.", "audio_connector.")):
|
||||
continue
|
||||
target_name = (
|
||||
source_name.replace(".transformer_blocks.", ".transformer_1d_blocks.")
|
||||
.replace(".attn1.norm_q.", ".attn1.q_norm.")
|
||||
.replace(".attn1.norm_k.", ".attn1.k_norm.")
|
||||
)
|
||||
parameter = parameters[target_name]
|
||||
parameter.copy_(weights.get_tensor(source_name).to(parameter.device, parameter.dtype))
|
||||
loaded.add(target_name)
|
||||
if loaded != expected:
|
||||
missing = sorted(expected - loaded)
|
||||
unexpected = sorted(loaded - expected)
|
||||
raise ValueError(f"Connector weight mismatch: missing={missing}, unexpected={unexpected}")
|
||||
return processor
|
||||
|
||||
prompt_encoder._build_embeddings_processor = build_embeddings_processor
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def _run_official(args: argparse.Namespace, request: dict[str, Any]) -> None:
|
||||
if args.official_root is None:
|
||||
raise ValueError("Official backend requires --official-root")
|
||||
_insert_official_paths(args.official_root)
|
||||
# Keep Gemma on the same SDPA path in both subprocesses. DiT attention is
|
||||
# still pinned independently to the explicit cuDNN backend below.
|
||||
torch.backends.cuda.enable_cudnn_sdp(False)
|
||||
_reset_accelerator_peak_memory_stats()
|
||||
|
||||
from ltx_pipelines.utils.args import ImageConditioningInput
|
||||
from ltx_pipelines.utils.model_paths import ModelPaths
|
||||
from ltx_pipelines.utils.types import OffloadMode
|
||||
|
||||
offload_mode = OffloadMode.CPU if args.enable_layerwise_offload else OffloadMode.NONE
|
||||
image_path = request.get("image")
|
||||
images = (
|
||||
[]
|
||||
if image_path is None
|
||||
else [
|
||||
ImageConditioningInput(
|
||||
path=str(image_path),
|
||||
frame_idx=0,
|
||||
strength=1.0,
|
||||
crf=request.get("image_crf", 18),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if args.transformer_path is not None:
|
||||
required = {
|
||||
"--text-encoder-path": args.text_encoder_path,
|
||||
"--video-vae-path": args.video_vae_path,
|
||||
"--audio-vae-path": args.audio_vae_path,
|
||||
}
|
||||
if args.official_pipeline in {"distilled_two_stage", "full_two_stage"}:
|
||||
required["--spatial-upsampler-path"] = args.spatial_upsampler_path
|
||||
if args.official_pipeline == "full_two_stage":
|
||||
required["--distilled-lora-path"] = args.distilled_lora_path
|
||||
missing = [name for name, value in required.items() if value is None]
|
||||
if missing:
|
||||
raise ValueError(f"Official LTX-2.5 split backend is missing: {', '.join(missing)}")
|
||||
|
||||
model_paths = ModelPaths.from_split(
|
||||
transformer_path=str(args.transformer_path),
|
||||
text_encoder_path=str(args.text_encoder_path),
|
||||
video_vae_path=str(args.video_vae_path),
|
||||
audio_vae_path=str(args.audio_vae_path),
|
||||
)
|
||||
if args.official_pipeline == "distilled_two_stage":
|
||||
from ltx_pipelines.distilled import DistilledPipeline
|
||||
|
||||
pipeline = DistilledPipeline(
|
||||
model_paths=model_paths,
|
||||
spatial_upsampler_path=str(args.spatial_upsampler_path),
|
||||
loras=(),
|
||||
offload_mode=offload_mode,
|
||||
)
|
||||
if args.connector_model is not None:
|
||||
_use_diffusers_connector_weights(pipeline.prompt_encoder, args.connector_model)
|
||||
_configure_official_sdpa(pipeline)
|
||||
video, audio, _, _ = pipeline(
|
||||
prompt=request["prompt"],
|
||||
seed=request["seed"],
|
||||
height=request["height"],
|
||||
width=request["width"],
|
||||
num_frames=request["num_frames"],
|
||||
frame_rate=request["fps"],
|
||||
images=images,
|
||||
)
|
||||
pipeline_name = "DistilledPipeline"
|
||||
elif args.official_pipeline == "full_two_stage":
|
||||
from ltx_core.components.guiders import MultiModalGuiderParams
|
||||
from ltx_core.loader import LTXV_LORA_COMFY_RENAMING_MAP, LoraPathStrengthAndSDOps
|
||||
from ltx_pipelines.ti2vid_two_stages import TI2VidTwoStagesPipeline
|
||||
|
||||
pipeline = TI2VidTwoStagesPipeline(
|
||||
model_paths=model_paths,
|
||||
distilled_lora=[
|
||||
LoraPathStrengthAndSDOps(
|
||||
path=str(args.distilled_lora_path),
|
||||
strength=1.0,
|
||||
sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
|
||||
)
|
||||
],
|
||||
spatial_upsampler_path=str(args.spatial_upsampler_path),
|
||||
loras=[],
|
||||
offload_mode=offload_mode,
|
||||
)
|
||||
if args.connector_model is not None:
|
||||
_use_diffusers_connector_weights(pipeline.prompt_encoder, args.connector_model)
|
||||
_configure_official_sdpa(pipeline)
|
||||
video, audio, _, _ = pipeline(
|
||||
prompt=request["prompt"],
|
||||
negative_prompt=request["negative_prompt"],
|
||||
seed=request["seed"],
|
||||
height=request["height"],
|
||||
width=request["width"],
|
||||
num_frames=request["num_frames"],
|
||||
frame_rate=request["fps"],
|
||||
num_inference_steps=request["num_inference_steps"],
|
||||
video_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=request["video_cfg_scale"],
|
||||
stg_scale=request["video_stg_scale"],
|
||||
rescale_scale=request["video_rescale_scale"],
|
||||
modality_scale=request["video_modality_scale"],
|
||||
skip_step=0,
|
||||
stg_blocks=request["video_stg_blocks"],
|
||||
),
|
||||
audio_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=request["audio_cfg_scale"],
|
||||
stg_scale=request["audio_stg_scale"],
|
||||
rescale_scale=request["audio_rescale_scale"],
|
||||
modality_scale=request["audio_modality_scale"],
|
||||
skip_step=0,
|
||||
stg_blocks=request["audio_stg_blocks"],
|
||||
),
|
||||
images=images,
|
||||
max_batch_size=4,
|
||||
stage_1_sigmas=torch.tensor(request["stage_1_sigmas"], dtype=torch.float32),
|
||||
stage_2_sigmas=torch.tensor(request["stage_2_sigmas"], dtype=torch.float32),
|
||||
)
|
||||
pipeline_name = "TI2VidTwoStagesPipeline"
|
||||
else:
|
||||
from ltx_core.components.guiders import MultiModalGuiderParams
|
||||
from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline
|
||||
|
||||
pipeline = TI2VidOneStagePipeline(
|
||||
model_paths=model_paths,
|
||||
loras=(),
|
||||
offload_mode=offload_mode,
|
||||
)
|
||||
if args.connector_model is not None:
|
||||
_use_diffusers_connector_weights(pipeline.prompt_encoder, args.connector_model)
|
||||
_configure_official_sdpa(pipeline)
|
||||
request_sigmas = request.get("sigmas")
|
||||
video, audio, _ = pipeline(
|
||||
prompt=request["prompt"],
|
||||
negative_prompt=request["negative_prompt"],
|
||||
seed=request["seed"],
|
||||
height=request["height"],
|
||||
width=request["width"],
|
||||
num_frames=request["num_frames"],
|
||||
frame_rate=request["fps"],
|
||||
num_inference_steps=request["num_inference_steps"],
|
||||
video_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=request["video_cfg_scale"],
|
||||
stg_scale=request["video_stg_scale"],
|
||||
rescale_scale=request["video_rescale_scale"],
|
||||
modality_scale=request["video_modality_scale"],
|
||||
skip_step=0,
|
||||
stg_blocks=request["video_stg_blocks"],
|
||||
),
|
||||
audio_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=request["audio_cfg_scale"],
|
||||
stg_scale=request["audio_stg_scale"],
|
||||
rescale_scale=request["audio_rescale_scale"],
|
||||
modality_scale=request["audio_modality_scale"],
|
||||
skip_step=0,
|
||||
stg_blocks=request["audio_stg_blocks"],
|
||||
),
|
||||
images=images,
|
||||
max_batch_size=4,
|
||||
sigmas=None if request_sigmas is None else torch.tensor(request_sigmas, dtype=torch.float32),
|
||||
)
|
||||
pipeline_name = "TI2VidOneStagePipeline"
|
||||
checkpoint_metadata = {
|
||||
"transformer_path": str(args.transformer_path),
|
||||
"video_vae_path": str(args.video_vae_path),
|
||||
"connector_model": None if args.connector_model is None else str(args.connector_model),
|
||||
"pipeline": pipeline_name,
|
||||
}
|
||||
else:
|
||||
if args.checkpoint is None or args.gemma_root is None:
|
||||
raise ValueError("Official monolith backend requires --checkpoint and --gemma-root")
|
||||
|
||||
from ltx_core.components.guiders import MultiModalGuiderParams
|
||||
from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline
|
||||
|
||||
pipeline = TI2VidOneStagePipeline(
|
||||
model_paths=ModelPaths.from_monolith(str(args.checkpoint), str(args.gemma_root)),
|
||||
loras=(),
|
||||
offload_mode=offload_mode,
|
||||
)
|
||||
_configure_official_sdpa(pipeline)
|
||||
video, audio, _ = pipeline(
|
||||
prompt=request["prompt"],
|
||||
negative_prompt=request["negative_prompt"],
|
||||
seed=request["seed"],
|
||||
height=request["height"],
|
||||
width=request["width"],
|
||||
num_frames=request["num_frames"],
|
||||
frame_rate=request["fps"],
|
||||
num_inference_steps=request["num_inference_steps"],
|
||||
video_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=request["video_cfg_scale"],
|
||||
stg_scale=request["video_stg_scale"],
|
||||
rescale_scale=request["video_rescale_scale"],
|
||||
modality_scale=request["video_modality_scale"],
|
||||
skip_step=0,
|
||||
stg_blocks=request["video_stg_blocks"],
|
||||
),
|
||||
audio_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=request["audio_cfg_scale"],
|
||||
stg_scale=request["audio_stg_scale"],
|
||||
rescale_scale=request["audio_rescale_scale"],
|
||||
modality_scale=request["audio_modality_scale"],
|
||||
skip_step=0,
|
||||
stg_blocks=request["audio_stg_blocks"],
|
||||
),
|
||||
images=images,
|
||||
max_batch_size=4,
|
||||
)
|
||||
checkpoint_metadata = {
|
||||
"checkpoint": str(args.checkpoint),
|
||||
"pipeline": "TI2VidOneStagePipeline",
|
||||
}
|
||||
|
||||
video_tensor = torch.cat([chunk.detach().cpu() for chunk in video], dim=0)
|
||||
_save_outputs(
|
||||
args.output_dir,
|
||||
video=_canonical_video(video_tensor),
|
||||
audio=audio.waveform,
|
||||
audio_sample_rate=audio.sampling_rate,
|
||||
fps=float(request["fps"]),
|
||||
metadata={
|
||||
"backend": "official",
|
||||
"attention_backend": "torch_sdpa_cudnn_bf16",
|
||||
"official_revision": os.environ.get("VLLM_TEST_LTX_OFFICIAL_REVISION"),
|
||||
"seed": request["seed"],
|
||||
"sigmas": request.get("sigmas"),
|
||||
"stage_1_sigmas": request.get("stage_1_sigmas"),
|
||||
"stage_2_sigmas": request.get("stage_2_sigmas"),
|
||||
**_accelerator_peak_memory_stats(),
|
||||
**checkpoint_metadata,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _omni_worker_peak_memory_mb(output: Any) -> float:
|
||||
result = output[0] if isinstance(output, list) and output else output
|
||||
return float(getattr(result, "peak_memory_mb", 0.0) or 0.0)
|
||||
|
||||
|
||||
def _unwrap_omni_output(output: Any) -> tuple[Any, Any, int]:
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
audio = None
|
||||
audio_sample_rate = None
|
||||
frames = output[0] if isinstance(output, list) and output else output
|
||||
if isinstance(frames, OmniRequestOutput):
|
||||
multimodal_output = frames.multimodal_output or {}
|
||||
audio = multimodal_output.get("audio")
|
||||
audio_sample_rate = multimodal_output.get("audio_sample_rate")
|
||||
nested_output = getattr(frames, "request_output", None)
|
||||
if frames.is_pipeline_output and isinstance(nested_output, OmniRequestOutput):
|
||||
frames = nested_output
|
||||
multimodal_output = frames.multimodal_output or {}
|
||||
audio = multimodal_output.get("audio", audio)
|
||||
audio_sample_rate = multimodal_output.get("audio_sample_rate", audio_sample_rate)
|
||||
if isinstance(frames, OmniRequestOutput):
|
||||
if not frames.images:
|
||||
raise ValueError("No video frames found in OmniRequestOutput")
|
||||
frames = frames.images
|
||||
|
||||
if isinstance(frames, list) and len(frames) == 1:
|
||||
frames = frames[0]
|
||||
if isinstance(frames, tuple) and len(frames) == 2:
|
||||
frames, audio = frames
|
||||
elif isinstance(frames, dict):
|
||||
audio = frames.get("audio", audio)
|
||||
audio_sample_rate = frames.get("audio_sample_rate", audio_sample_rate)
|
||||
frames = frames.get("frames", frames.get("video"))
|
||||
|
||||
if frames is None or audio is None or audio_sample_rate is None:
|
||||
raise ValueError("Omni output did not contain video, audio, and audio_sample_rate")
|
||||
return frames, audio, int(audio_sample_rate)
|
||||
|
||||
|
||||
def _canonical_video(video: Any) -> torch.Tensor:
|
||||
if isinstance(video, list):
|
||||
if len(video) == 1:
|
||||
return _canonical_video(video[0])
|
||||
tensors = [torch.as_tensor(np.asarray(item) if not isinstance(item, torch.Tensor) else item) for item in video]
|
||||
if all(item.ndim == 3 for item in tensors):
|
||||
video = torch.stack(tensors)
|
||||
elif all(item.ndim == 4 for item in tensors):
|
||||
video = torch.cat(tensors)
|
||||
else:
|
||||
raise ValueError(f"Cannot combine video items with dimensions {[item.ndim for item in tensors]}")
|
||||
tensor = torch.as_tensor(np.asarray(video) if not isinstance(video, torch.Tensor) else video).detach().cpu()
|
||||
if tensor.ndim == 5:
|
||||
tensor = tensor[0]
|
||||
if tensor.ndim != 4:
|
||||
raise ValueError(f"Expected a 4D video tensor, got {tuple(tensor.shape)}")
|
||||
if tensor.shape[-1] in (3, 4):
|
||||
tensor = tensor[..., :3]
|
||||
elif tensor.shape[1] in (3, 4):
|
||||
tensor = tensor[:, :3].permute(0, 2, 3, 1)
|
||||
elif tensor.shape[0] in (3, 4):
|
||||
tensor = tensor[:3].permute(1, 2, 3, 0)
|
||||
else:
|
||||
raise ValueError(f"Cannot infer video channel dimension from {tuple(tensor.shape)}")
|
||||
tensor = tensor.float()
|
||||
if tensor.numel() and tensor.max() > 1:
|
||||
tensor = tensor / 255.0
|
||||
elif tensor.numel() and tensor.min() < 0:
|
||||
tensor = tensor.clamp(-1.0, 1.0).add(1.0).mul(0.5)
|
||||
return tensor.clamp(0.0, 1.0)
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def _run_omni(args: argparse.Namespace, request: dict[str, Any]) -> None:
|
||||
if args.model is None or args.model_class_name is None:
|
||||
raise ValueError("Omni backend requires --model and --model-class-name")
|
||||
|
||||
# vLLM imports can otherwise change the process-wide Gemma dispatch.
|
||||
torch.backends.cuda.enable_cudnn_sdp(False)
|
||||
_reset_accelerator_peak_memory_stats()
|
||||
|
||||
attention_config = {"default": {"backend": "CUDNN_ATTN"}}
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_extras import get_extra_body_params
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(request["seed"])
|
||||
omni = Omni(
|
||||
model=args.model,
|
||||
model_class_name=args.model_class_name,
|
||||
enforce_eager=True,
|
||||
enable_layerwise_offload=args.enable_layerwise_offload,
|
||||
diffusion_attention_config=attention_config,
|
||||
parallel_config=DiffusionParallelConfig(),
|
||||
)
|
||||
try:
|
||||
# The explicit public class argument selects the worker pipeline. The
|
||||
# parent Omni proxy can only expose the checkpoint's base class, which
|
||||
# is insufficient to distinguish LTX execution modes.
|
||||
model_class_name = args.model_class_name
|
||||
sampling_params = OmniDiffusionSamplingParams(
|
||||
height=request["height"],
|
||||
width=request["width"],
|
||||
generator=generator,
|
||||
guidance_scale=None,
|
||||
num_inference_steps=request["num_inference_steps"],
|
||||
num_frames=request["num_frames"],
|
||||
fps=request["fps"],
|
||||
frame_rate=float(request["fps"]),
|
||||
output_type="np",
|
||||
)
|
||||
extra_args = {
|
||||
key: value for key, value in request.items() if key.startswith("video_") or key.startswith("audio_")
|
||||
}
|
||||
for key in ("sigmas", "stage_1_sigmas", "stage_2_sigmas", "image_crf"):
|
||||
if key in request:
|
||||
extra_args[key] = request[key]
|
||||
apply_declared_extra_args(sampling_params, get_extra_body_params(model_class_name), extra_args)
|
||||
prompt: dict[str, Any] = {"prompt": request["prompt"]}
|
||||
if "negative_prompt" in request:
|
||||
prompt["negative_prompt"] = request["negative_prompt"]
|
||||
image_path = request.get("image")
|
||||
if image_path is not None:
|
||||
with Image.open(str(image_path)) as source_image:
|
||||
image = source_image.convert("RGB")
|
||||
image.load()
|
||||
prompt["multi_modal_data"] = {"image": image}
|
||||
output = omni.generate(prompt, sampling_params)
|
||||
video, audio, audio_sample_rate = _unwrap_omni_output(output)
|
||||
audio_tensor = torch.as_tensor(np.asarray(audio) if not isinstance(audio, torch.Tensor) else audio)
|
||||
_save_outputs(
|
||||
args.output_dir,
|
||||
video=_canonical_video(video),
|
||||
audio=audio_tensor,
|
||||
audio_sample_rate=audio_sample_rate,
|
||||
fps=float(request["fps"]),
|
||||
metadata={
|
||||
"backend": "omni",
|
||||
"attention_backend": "torch_sdpa_cudnn_bf16",
|
||||
"seed": request["seed"],
|
||||
"sigmas": request.get("sigmas"),
|
||||
"model": args.model,
|
||||
"stage_1_sigmas": request.get("stage_1_sigmas"),
|
||||
"stage_2_sigmas": request.get("stage_2_sigmas"),
|
||||
"model_class_name": model_class_name,
|
||||
"worker_peak_memory_mb": _omni_worker_peak_memory_mb(output),
|
||||
**_accelerator_peak_memory_stats(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
omni.shutdown()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parse_args()
|
||||
request = json.loads(args.request.read_text())
|
||||
if args.backend == "official":
|
||||
_run_official(args, request)
|
||||
else:
|
||||
_run_omni(args, request)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,753 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""E2E accuracy guards against pinned official Lightricks LTX references.
|
||||
|
||||
The LTX-2/2.3 comparison runs both runtimes through PyTorch SDPA and uses
|
||||
``max_batch_size=4`` in the official reference to match Omni's fused guidance
|
||||
batch. Video and audio guidance use the official non-HQ one-stage defaults;
|
||||
only the generation shape and step count are reduced for CI runtime.
|
||||
LTX-2.5 runs the official split-artifact pipelines with connector weights from
|
||||
the same Diffusers checkpoint under test. The distilled case covers the fixed
|
||||
two-stage schedule; the Full/SFT case covers raw dev weights and an explicit
|
||||
shared one-stage schedule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from huggingface_hub import hf_hub_download, snapshot_download
|
||||
from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure
|
||||
|
||||
from tests.e2e.accuracy.helpers import reset_artifact_dir
|
||||
from tests.helpers import skip_if_gated_repo_inaccessible
|
||||
from tests.helpers.mark import hardware_test
|
||||
|
||||
OFFICIAL_REPOSITORY = "https://github.com/Lightricks/LTX-2.git"
|
||||
LTX25_OFFICIAL_REVISION = "7954dcb0d986bdc36ef272564a9789ade07fcc65"
|
||||
LTX25_OMNI_MODEL_ID = "Lightricks/LTX-2.5-Diffusers"
|
||||
LTX25_OMNI_MODEL_REVISION = "a6de4b5354f078db24d9cf4778c14846788aea3d"
|
||||
LTX25_OFFICIAL_MODEL_ID = "Lightricks/LTX-2.5"
|
||||
LTX25_OFFICIAL_MODEL_REVISION = "8a4ff96f581e72bedc1b44367581c49d544a05f1"
|
||||
LTX25_OFFICIAL_COMMON_FILES = {
|
||||
"text_encoder": "text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors",
|
||||
"video_vae": "vae/ltx-2.5-video-vae-conv-bf16.safetensors",
|
||||
"audio_vae": "vae/ltx-2.5-audio-vae-bf16.safetensors",
|
||||
}
|
||||
LTX25_OFFICIAL_VARIANT_FILES = {
|
||||
"distilled": {
|
||||
"transformer": "diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors",
|
||||
"spatial_upsampler": "latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors",
|
||||
},
|
||||
"full": {
|
||||
"transformer": "diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors",
|
||||
},
|
||||
"full_two_stage": {
|
||||
"transformer": "diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors",
|
||||
"spatial_upsampler": "latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors",
|
||||
"distilled_lora": "loras/ltx-2.5-22b-distilled-lora-450-bf16.safetensors",
|
||||
},
|
||||
}
|
||||
# Version selected by the pinned official source's uv.lock. Keep it isolated
|
||||
# from Omni's runtime and development dependencies.
|
||||
OFFICIAL_OPENIMAGEIO_VERSION = "3.1.11.0"
|
||||
LTX25_PROMPT = "A cinematic shot of a red fox walking through a snowy forest at dawn, the camera tracking alongside."
|
||||
LTX25_I2V_IMAGE_REPO = "huggingface/documentation-images"
|
||||
LTX25_I2V_IMAGE_FILENAME = "diffusers/svd/rocket.png"
|
||||
LTX25_I2V_IMAGE_REVISION = "645d8364f0c7a101180b364811b5a11a362e4010"
|
||||
NEGATIVE_PROMPT = (
|
||||
"blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, excessive noise, "
|
||||
"grainy texture, poor lighting, flickering, motion blur, distorted proportions, unnatural skin tones, "
|
||||
"deformed facial features, asymmetrical face, missing facial features, extra limbs, disfigured hands, "
|
||||
"wrong hand count, artifacts around text, inconsistent perspective, camera shake, incorrect depth of field, "
|
||||
"background too sharp, background clutter, distracting reflections, harsh shadows, inconsistent lighting "
|
||||
"direction, color banding, cartoonish rendering, 3D CGI look, unrealistic materials, uncanny valley effect, "
|
||||
"incorrect ethnicity, wrong gender, exaggerated expressions, wrong gaze direction, mismatched lip sync, "
|
||||
"silent or muted audio, distorted voice, robotic voice, echo, background noise, off-sync audio, incorrect "
|
||||
"dialogue, added dialogue, repetitive speech, jittery movement, awkward pauses, incorrect timing, unnatural "
|
||||
"transitions, inconsistent framing, tilted camera, flat lighting, inconsistent tone, cinematic oversaturation, "
|
||||
"stylized filters, or AI artifacts."
|
||||
)
|
||||
|
||||
# Release parity exercises the accelerated BF16 cuDNN path in both runtimes.
|
||||
# Degenerate one-token attention falls back to PyTorch SDPA MATH.
|
||||
ATTENTION_BACKEND = "torch_sdpa_cudnn_bf16"
|
||||
VIDEO_SSIM_MEAN_THRESHOLD = 0.95
|
||||
VIDEO_SSIM_MIN_THRESHOLD = 0.90
|
||||
VIDEO_PSNR_MEAN_THRESHOLD = 30.0
|
||||
AUDIO_RELATIVE_L2_THRESHOLD = 0.2
|
||||
AUDIO_COSINE_THRESHOLD = 0.95
|
||||
|
||||
# Decoded-output gates for the pinned official/Omni BF16 cuDNN comparison.
|
||||
# The metrics artifact preserves the exact observed values.
|
||||
LTX25_VIDEO_SSIM_MEAN_THRESHOLD = 0.99
|
||||
LTX25_I2V_VIDEO_SSIM_MEAN_THRESHOLD = 0.99
|
||||
LTX25_VIDEO_SSIM_MIN_THRESHOLD = 0.99
|
||||
LTX25_VIDEO_PSNR_MEAN_THRESHOLD = 40.0
|
||||
LTX25_VIDEO_LPIPS_MEAN_THRESHOLD = 0.01
|
||||
LTX25_AUDIO_RELATIVE_L2_THRESHOLD = 0.32
|
||||
LTX25_AUDIO_COSINE_THRESHOLD = 0.95
|
||||
|
||||
# Full/SFT uses independently converted dev weights, but the release contract
|
||||
# still requires decoded-video parity at the same 0.99 SSIM floor as the
|
||||
# distilled paths when both runtimes use BF16 cuDNN attention.
|
||||
LTX25_FULL_VIDEO_SSIM_MEAN_THRESHOLD = 0.99
|
||||
LTX25_FULL_VIDEO_SSIM_MIN_THRESHOLD = 0.99
|
||||
LTX25_FULL_VIDEO_PSNR_MEAN_THRESHOLD = 40.0
|
||||
LTX25_FULL_AUDIO_RELATIVE_L2_THRESHOLD = 0.3
|
||||
LTX25_FULL_VIDEO_LPIPS_MEAN_THRESHOLD = 0.01
|
||||
LTX25_FULL_I2V_VIDEO_LPIPS_MEAN_THRESHOLD = 0.02
|
||||
LTX25_FULL_AUDIO_COSINE_THRESHOLD = AUDIO_COSINE_THRESHOLD
|
||||
LTX25_STAGE_2_SIGMAS = [0.909375, 0.725, 0.421875, 0.0]
|
||||
|
||||
|
||||
def test_ltx_reference_runner_unwraps_flattened_pipeline_output() -> None:
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
from .run_ltx25_reference import _omni_worker_peak_memory_mb, _unwrap_omni_output
|
||||
|
||||
frame = object()
|
||||
audio = object()
|
||||
output = OmniRequestOutput(
|
||||
stage_id=0,
|
||||
images=[frame],
|
||||
_multimodal_output={"audio": audio, "audio_sample_rate": 48_000},
|
||||
)
|
||||
|
||||
frames, actual_audio, sample_rate = _unwrap_omni_output(output)
|
||||
|
||||
assert frames is frame
|
||||
assert actual_audio is audio
|
||||
assert sample_rate == 48_000
|
||||
output.peak_memory_mb = 12_345.0
|
||||
assert _omni_worker_peak_memory_mb([output]) == 12_345.0
|
||||
|
||||
|
||||
def _run(command: list[str], *, env: dict[str, str], timeout: int = 1800) -> None:
|
||||
start = time.perf_counter()
|
||||
subprocess.run(command, env=env, timeout=timeout, check=True)
|
||||
print(f"{' '.join(command[:3])} finished in {time.perf_counter() - start:.1f}s")
|
||||
|
||||
|
||||
def _clone_pinned_source(root: Path, repository: str, revision: str) -> None:
|
||||
root.mkdir(parents=True)
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "remote", "add", "origin", repository], check=True)
|
||||
last_error: subprocess.CalledProcessError | None = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(root), "fetch", "--depth", "1", "origin", revision],
|
||||
check=True,
|
||||
)
|
||||
last_error = None
|
||||
break
|
||||
except subprocess.CalledProcessError as error:
|
||||
last_error = error
|
||||
if attempt < 2:
|
||||
time.sleep(5 * (attempt + 1))
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
subprocess.run(["git", "-C", str(root), "checkout", "-q", "--detach", "FETCH_HEAD"], check=True)
|
||||
|
||||
|
||||
def _git_revision(root: Path) -> str | None:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "-C", str(root), "rev-parse", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def _ltx25_official_source(artifact_root: Path) -> tuple[Path, str]:
|
||||
repository = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_REPOSITORY", OFFICIAL_REPOSITORY)
|
||||
revision = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_REVISION", LTX25_OFFICIAL_REVISION)
|
||||
configured_root = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_ROOT")
|
||||
root = Path(configured_root) if configured_root else artifact_root / f"official-source-{revision[:12]}"
|
||||
actual_revision = _git_revision(root) if root.exists() else None
|
||||
if actual_revision != revision and configured_root:
|
||||
raise AssertionError(f"Official LTX-2.5 source revision mismatch: {actual_revision} != {revision}")
|
||||
if actual_revision != revision:
|
||||
if root.exists():
|
||||
shutil.rmtree(root)
|
||||
_clone_pinned_source(root, repository, revision)
|
||||
actual_revision = _git_revision(root)
|
||||
assert actual_revision == revision, f"Official LTX-2.5 source revision mismatch: {actual_revision} != {revision}"
|
||||
return root, revision
|
||||
|
||||
|
||||
def _official_runner_prefix() -> list[str]:
|
||||
"""Run the reference with its missing binary dependency isolated from CI."""
|
||||
uv = shutil.which("uv")
|
||||
assert uv is not None, "uv is required to run the pinned official LTX reference"
|
||||
return [
|
||||
uv,
|
||||
"run",
|
||||
"--no-project",
|
||||
"--with",
|
||||
f"openimageio=={OFFICIAL_OPENIMAGEIO_VERSION}",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"python",
|
||||
]
|
||||
|
||||
|
||||
def _resolve_ltx25_omni_model(*, transformer_subfolder: str = "transformer") -> tuple[Path, str, str | None]:
|
||||
if transformer_subfolder not in {"transformer", "transformer_full"}:
|
||||
raise ValueError(f"Unsupported LTX-2.5 transformer subfolder: {transformer_subfolder!r}")
|
||||
configured_model = os.environ.get("VLLM_TEST_LTX25_MODEL")
|
||||
configured_revision = os.environ.get("VLLM_TEST_LTX25_MODEL_REVISION")
|
||||
if configured_model and Path(configured_model).exists():
|
||||
model = Path(configured_model).resolve()
|
||||
snapshot_revision = model.name if model.parent.name == "snapshots" else None
|
||||
assert (model / transformer_subfolder).is_dir(), f"LTX-2.5 component not found: {model / transformer_subfolder}"
|
||||
return model, configured_model, configured_revision or snapshot_revision
|
||||
model_id = configured_model or LTX25_OMNI_MODEL_ID
|
||||
revision = configured_revision
|
||||
if revision is None and model_id == LTX25_OMNI_MODEL_ID:
|
||||
revision = LTX25_OMNI_MODEL_REVISION
|
||||
model = Path(
|
||||
snapshot_download(
|
||||
repo_id=model_id,
|
||||
revision=revision,
|
||||
allow_patterns=[
|
||||
"model_index.json",
|
||||
"audio_vae/*",
|
||||
"connectors/config.json",
|
||||
"connectors/diffusion_pytorch_model.safetensors.index.json",
|
||||
"connectors/diffusion_pytorch_model-*.safetensors",
|
||||
"scheduler/*",
|
||||
"text_encoder/config.json",
|
||||
"text_encoder/generation_config.json",
|
||||
"text_encoder/model*",
|
||||
"tokenizer/*",
|
||||
f"{transformer_subfolder}/*",
|
||||
"vae/*",
|
||||
"vocoder/*",
|
||||
],
|
||||
)
|
||||
).resolve()
|
||||
snapshot_revision = model.name if model.parent.name == "snapshots" else None
|
||||
return model, model_id, snapshot_revision or revision
|
||||
|
||||
|
||||
def _resolve_ltx25_official_artifacts(
|
||||
*, checkpoint_variant: str = "distilled"
|
||||
) -> tuple[dict[str, Path], str, str | None]:
|
||||
try:
|
||||
variant_files = LTX25_OFFICIAL_VARIANT_FILES[checkpoint_variant]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unsupported official LTX-2.5 checkpoint variant: {checkpoint_variant!r}") from exc
|
||||
files = {**LTX25_OFFICIAL_COMMON_FILES, **variant_files}
|
||||
configured_model = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL")
|
||||
configured_revision = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL_REVISION")
|
||||
if configured_model and Path(configured_model).exists():
|
||||
root = Path(configured_model).resolve()
|
||||
model_source = configured_model
|
||||
snapshot_revision = root.name if root.parent.name == "snapshots" else None
|
||||
resolved_revision = configured_revision or snapshot_revision
|
||||
else:
|
||||
model_source = configured_model or LTX25_OFFICIAL_MODEL_ID
|
||||
revision = configured_revision
|
||||
if revision is None and model_source == LTX25_OFFICIAL_MODEL_ID:
|
||||
revision = LTX25_OFFICIAL_MODEL_REVISION
|
||||
root = Path(
|
||||
snapshot_download(
|
||||
repo_id=model_source,
|
||||
revision=revision,
|
||||
allow_patterns=list(files.values()),
|
||||
)
|
||||
).resolve()
|
||||
snapshot_revision = root.name if root.parent.name == "snapshots" else None
|
||||
resolved_revision = snapshot_revision or revision
|
||||
|
||||
artifacts = {name: root / relative_path for name, relative_path in files.items()}
|
||||
missing = [f"{name}: {path}" for name, path in artifacts.items() if not path.is_file()]
|
||||
assert not missing, f"Official LTX-2.5 artifacts are missing: {', '.join(missing)}"
|
||||
return artifacts, model_source, resolved_revision
|
||||
|
||||
|
||||
def _resolve_gemma_root(model: Path) -> Path:
|
||||
configured_root = os.environ.get("VLLM_TEST_LTX_GEMMA_ROOT")
|
||||
if configured_root:
|
||||
root = Path(configured_root)
|
||||
assert root.is_dir(), f"Gemma root not found: {root}"
|
||||
return root
|
||||
return model
|
||||
|
||||
|
||||
def _resolve_ltx25_image() -> Path:
|
||||
configured_image = os.environ.get("VLLM_TEST_LTX25_I2V_IMAGE")
|
||||
if configured_image:
|
||||
image = Path(configured_image)
|
||||
assert image.is_file(), f"LTX-2.5 I2V conditioning image not found: {image}"
|
||||
return image
|
||||
return Path(
|
||||
hf_hub_download(
|
||||
repo_id=LTX25_I2V_IMAGE_REPO,
|
||||
repo_type="dataset",
|
||||
filename=LTX25_I2V_IMAGE_FILENAME,
|
||||
revision=LTX25_I2V_IMAGE_REVISION,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _require_ltx25_model_access() -> None:
|
||||
configured_omni_model = os.environ.get("VLLM_TEST_LTX25_MODEL")
|
||||
if not (configured_omni_model and Path(configured_omni_model).exists()):
|
||||
omni_model = configured_omni_model or LTX25_OMNI_MODEL_ID
|
||||
omni_revision = os.environ.get("VLLM_TEST_LTX25_MODEL_REVISION")
|
||||
if omni_revision is None and omni_model == LTX25_OMNI_MODEL_ID:
|
||||
omni_revision = LTX25_OMNI_MODEL_REVISION
|
||||
skip_if_gated_repo_inaccessible(
|
||||
omni_model,
|
||||
revision=omni_revision,
|
||||
filename="model_index.json",
|
||||
)
|
||||
|
||||
configured_official_model = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL")
|
||||
if not (configured_official_model and Path(configured_official_model).exists()):
|
||||
official_model = configured_official_model or LTX25_OFFICIAL_MODEL_ID
|
||||
official_revision = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL_REVISION")
|
||||
if official_revision is None and official_model == LTX25_OFFICIAL_MODEL_ID:
|
||||
official_revision = LTX25_OFFICIAL_MODEL_REVISION
|
||||
skip_if_gated_repo_inaccessible(
|
||||
official_model,
|
||||
revision=official_revision,
|
||||
filename=LTX25_OFFICIAL_COMMON_FILES["text_encoder"],
|
||||
)
|
||||
|
||||
|
||||
def _ltx25_request(image: Path | None = None) -> dict[str, object]:
|
||||
request: dict[str, object] = {
|
||||
"prompt": LTX25_PROMPT,
|
||||
# Keep the nightly parity test small enough to run the pinned official
|
||||
# pipeline and Omni sequentially on one H100. The public recipe covers
|
||||
# the release-resolution 1920x1088x121 configuration.
|
||||
"width": 1024,
|
||||
"height": 576,
|
||||
"num_frames": 25,
|
||||
"fps": 24,
|
||||
"num_inference_steps": 8,
|
||||
"seed": 42,
|
||||
}
|
||||
if image is not None:
|
||||
request.update(image=str(image.resolve()), image_crf=18)
|
||||
return request
|
||||
|
||||
|
||||
def _ltx25_full_sigmas(num_inference_steps: int) -> list[float]:
|
||||
"""Materialize the official LTX-2.5 one-stage schedule in FP32."""
|
||||
sigmas = torch.linspace(1.0, 0.0, num_inference_steps + 1)
|
||||
base_anchor = 1024
|
||||
max_anchor = 4096
|
||||
base_shift = 0.95
|
||||
max_shift = 2.05
|
||||
slope = (max_shift - base_shift) / (max_anchor - base_anchor)
|
||||
sigma_shift = max_anchor * slope + (base_shift - slope * base_anchor)
|
||||
exp_shift = math.exp(sigma_shift)
|
||||
sigmas = torch.where(sigmas != 0, exp_shift / (exp_shift + (1 / sigmas - 1)), 0)
|
||||
|
||||
non_zero = sigmas != 0
|
||||
one_minus_sigmas = 1.0 - sigmas[non_zero]
|
||||
scale = one_minus_sigmas[-1] / (1.0 - 0.1)
|
||||
sigmas[non_zero] = 1.0 - one_minus_sigmas / scale
|
||||
return [float(sigma) for sigma in sigmas]
|
||||
|
||||
|
||||
def _ltx25_full_request(image: Path | None = None) -> dict[str, object]:
|
||||
num_inference_steps = 30
|
||||
request: dict[str, object] = {
|
||||
"prompt": LTX25_PROMPT,
|
||||
"negative_prompt": NEGATIVE_PROMPT,
|
||||
# Full guidance expands to a four-way batch. Keep the decoded output
|
||||
# small enough for the pinned official and Omni runs to share one H100.
|
||||
"width": 512,
|
||||
"height": 384,
|
||||
"num_frames": 25,
|
||||
"fps": 24,
|
||||
"num_inference_steps": num_inference_steps,
|
||||
"seed": 42,
|
||||
"video_cfg_scale": 3.0,
|
||||
"audio_cfg_scale": 7.0,
|
||||
"video_stg_scale": 1.0,
|
||||
"audio_stg_scale": 1.0,
|
||||
"video_modality_scale": 3.0,
|
||||
"audio_modality_scale": 3.0,
|
||||
"video_rescale_scale": 0.7,
|
||||
"audio_rescale_scale": 0.7,
|
||||
"video_stg_blocks": [28],
|
||||
"audio_stg_blocks": [28],
|
||||
"sigmas": _ltx25_full_sigmas(num_inference_steps),
|
||||
}
|
||||
if image is not None:
|
||||
request.update(image=str(image.resolve()), image_crf=18)
|
||||
return request
|
||||
|
||||
|
||||
def test_ltx25_full_request_pins_official_schedule() -> None:
|
||||
request = _ltx25_full_request()
|
||||
sigmas = request["sigmas"]
|
||||
|
||||
assert isinstance(sigmas, list)
|
||||
assert isinstance(request["num_inference_steps"], int)
|
||||
assert len(sigmas) == request["num_inference_steps"] + 1
|
||||
assert math.isclose(sigmas[0], 1.0, rel_tol=0.0, abs_tol=1e-6)
|
||||
assert sigmas[-1] == 0.0
|
||||
assert all(sigmas[index] > sigmas[index + 1] for index in range(len(sigmas) - 1))
|
||||
|
||||
|
||||
def test_ltx25_i2v_requests_pin_official_crf() -> None:
|
||||
image = Path("conditioning.png")
|
||||
for request in (_ltx25_request(image), _ltx25_full_request(image)):
|
||||
assert request["image"] == str(image.resolve())
|
||||
assert request["image_crf"] == 18
|
||||
|
||||
|
||||
def _video_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, float]:
|
||||
from benchmarks.diffusion.quantization_quality import compute_lpips_video
|
||||
|
||||
assert reference.shape == prediction.shape
|
||||
assert reference.ndim == 4 and reference.shape[-1] == 3
|
||||
ssim_scores: list[float] = []
|
||||
psnr_scores: list[float] = []
|
||||
for reference_frame, prediction_frame in zip(reference, prediction, strict=True):
|
||||
reference_tensor = torch.from_numpy(reference_frame).permute(2, 0, 1).unsqueeze(0)
|
||||
prediction_tensor = torch.from_numpy(prediction_frame).permute(2, 0, 1).unsqueeze(0)
|
||||
ssim_scores.append(float(StructuralSimilarityIndexMeasure(data_range=1.0)(prediction_tensor, reference_tensor)))
|
||||
psnr_scores.append(float(PeakSignalNoiseRatio(data_range=1.0)(prediction_tensor, reference_tensor)))
|
||||
difference = np.abs(reference.astype(np.float64) - prediction.astype(np.float64))
|
||||
return {
|
||||
"ssim_mean": float(np.mean(ssim_scores)),
|
||||
"ssim_min": float(np.min(ssim_scores)),
|
||||
"psnr_mean_db": float(np.mean(psnr_scores)),
|
||||
"lpips_alex_mean": compute_lpips_video(reference, prediction),
|
||||
"max_abs": float(difference.max()),
|
||||
"mean_abs": float(difference.mean()),
|
||||
}
|
||||
|
||||
|
||||
def _canonical_audio(audio: np.ndarray) -> np.ndarray:
|
||||
while audio.ndim > 2 and audio.shape[0] == 1:
|
||||
audio = audio[0]
|
||||
return audio.astype(np.float64)
|
||||
|
||||
|
||||
def _audio_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, float | bool]:
|
||||
reference = _canonical_audio(reference)
|
||||
prediction = _canonical_audio(prediction)
|
||||
assert reference.shape == prediction.shape
|
||||
difference = reference - prediction
|
||||
reference_norm = max(float(np.linalg.norm(reference)), 1e-12)
|
||||
prediction_norm = max(float(np.linalg.norm(prediction)), 1e-12)
|
||||
return {
|
||||
"bitwise_equal": bool(np.array_equal(reference, prediction)),
|
||||
"max_abs": float(np.abs(difference).max()),
|
||||
"mean_abs": float(np.abs(difference).mean()),
|
||||
"relative_l2": float(np.linalg.norm(difference) / reference_norm),
|
||||
"cosine_similarity": float(np.vdot(reference.ravel(), prediction.ravel()) / (reference_norm * prediction_norm)),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.benchmark
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("task", ["t2v", "i2v"])
|
||||
def test_ltx25_distilled_two_stage_matches_official(accuracy_artifact_root: Path, task: str) -> None:
|
||||
"""Compare Omni with the official runtime using the same checkpoint connector weights."""
|
||||
_require_ltx25_model_access()
|
||||
artifact_parent = accuracy_artifact_root / "ltx_official"
|
||||
output_root = reset_artifact_dir(artifact_parent / f"ltx2_5_distilled_{task}")
|
||||
official_root, official_revision = _ltx25_official_source(artifact_parent)
|
||||
official_artifacts, official_model_source, official_model_revision = _resolve_ltx25_official_artifacts()
|
||||
omni_model, omni_model_source, omni_model_revision = _resolve_ltx25_omni_model()
|
||||
image = _resolve_ltx25_image() if task == "i2v" else None
|
||||
request_path = output_root / "request.json"
|
||||
request_path.write_text(json.dumps(_ltx25_request(image), indent=2) + "\n")
|
||||
|
||||
runner = Path(__file__).with_name("run_ltx25_reference.py")
|
||||
runner_args = [
|
||||
str(runner),
|
||||
"--request",
|
||||
str(request_path),
|
||||
"--enable-layerwise-offload",
|
||||
]
|
||||
env = os.environ.copy()
|
||||
env["VLLM_TEST_LTX_OFFICIAL_REVISION"] = official_revision
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
repository_root = Path(__file__).resolve().parents[4]
|
||||
existing_pythonpath = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = (
|
||||
str(repository_root) if not existing_pythonpath else f"{repository_root}{os.pathsep}{existing_pythonpath}"
|
||||
)
|
||||
|
||||
official_output = output_root / "official"
|
||||
_run(
|
||||
_official_runner_prefix()
|
||||
+ runner_args
|
||||
+ [
|
||||
"--backend",
|
||||
"official",
|
||||
"--output-dir",
|
||||
str(official_output),
|
||||
"--official-root",
|
||||
str(official_root),
|
||||
"--transformer-path",
|
||||
str(official_artifacts["transformer"]),
|
||||
"--text-encoder-path",
|
||||
str(official_artifacts["text_encoder"]),
|
||||
"--video-vae-path",
|
||||
str(official_artifacts["video_vae"]),
|
||||
"--audio-vae-path",
|
||||
str(official_artifacts["audio_vae"]),
|
||||
"--spatial-upsampler-path",
|
||||
str(official_artifacts["spatial_upsampler"]),
|
||||
"--connector-model",
|
||||
str(omni_model),
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
|
||||
omni_output = output_root / "omni"
|
||||
_run(
|
||||
[sys.executable]
|
||||
+ runner_args
|
||||
+ [
|
||||
"--backend",
|
||||
"omni",
|
||||
"--output-dir",
|
||||
str(omni_output),
|
||||
"--model",
|
||||
str(omni_model),
|
||||
"--model-class-name",
|
||||
"LTX2DistilledTwoStagePipeline",
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
|
||||
official_metadata = json.loads((official_output / "metadata.json").read_text())
|
||||
omni_metadata = json.loads((omni_output / "metadata.json").read_text())
|
||||
assert official_metadata["official_revision"] == official_revision
|
||||
assert official_metadata["pipeline"] == "DistilledPipeline"
|
||||
assert official_metadata["attention_backend"] == ATTENTION_BACKEND
|
||||
assert official_metadata["connector_model"] == str(omni_model)
|
||||
assert omni_metadata["model_class_name"] == "LTX2DistilledTwoStagePipeline"
|
||||
assert omni_metadata["attention_backend"] == ATTENTION_BACKEND
|
||||
assert official_metadata["audio_sample_rate"] == omni_metadata["audio_sample_rate"]
|
||||
video_metrics = _video_metrics(
|
||||
np.load(official_output / "video.npy"),
|
||||
np.load(omni_output / "video.npy"),
|
||||
)
|
||||
audio_metrics = _audio_metrics(
|
||||
np.load(official_output / "audio.npy"),
|
||||
np.load(omni_output / "audio.npy"),
|
||||
)
|
||||
result = {
|
||||
"case": "ltx2_5_distilled",
|
||||
"task": task,
|
||||
"attention_backend": ATTENTION_BACKEND,
|
||||
"official_revision": official_revision,
|
||||
"official_model": official_model_source,
|
||||
"official_model_revision": official_model_revision,
|
||||
"official_connector_model": omni_model_source,
|
||||
"official_connector_revision": omni_model_revision,
|
||||
"omni_model": omni_model_source,
|
||||
"omni_model_revision": omni_model_revision,
|
||||
"resolved_omni_model_path": str(omni_model),
|
||||
"peak_memory_mb": {
|
||||
"official_allocated": official_metadata["peak_memory_allocated_mb"],
|
||||
"official_reserved": official_metadata["peak_memory_reserved_mb"],
|
||||
"omni_worker_reported": omni_metadata["worker_peak_memory_mb"],
|
||||
"omni_parent_allocated": omni_metadata["peak_memory_allocated_mb"],
|
||||
"omni_parent_reserved": omni_metadata["peak_memory_reserved_mb"],
|
||||
},
|
||||
"video": video_metrics,
|
||||
"audio": audio_metrics,
|
||||
}
|
||||
(output_root / "metrics.json").write_text(json.dumps(result, indent=2) + "\n")
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
ssim_mean_threshold = LTX25_VIDEO_SSIM_MEAN_THRESHOLD if task == "t2v" else LTX25_I2V_VIDEO_SSIM_MEAN_THRESHOLD
|
||||
assert video_metrics["ssim_mean"] >= ssim_mean_threshold
|
||||
assert video_metrics["ssim_min"] >= LTX25_VIDEO_SSIM_MIN_THRESHOLD
|
||||
assert video_metrics["psnr_mean_db"] >= LTX25_VIDEO_PSNR_MEAN_THRESHOLD
|
||||
assert video_metrics["lpips_alex_mean"] <= LTX25_VIDEO_LPIPS_MEAN_THRESHOLD
|
||||
assert audio_metrics["cosine_similarity"] >= LTX25_AUDIO_COSINE_THRESHOLD
|
||||
assert audio_metrics["relative_l2"] <= LTX25_AUDIO_RELATIVE_L2_THRESHOLD
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.benchmark
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("task", ["t2v", "i2v"])
|
||||
@pytest.mark.parametrize("pipeline_mode", ["full_one_stage", "full_two_stage"])
|
||||
def test_ltx25_full_matches_official(accuracy_artifact_root: Path, task: str, pipeline_mode: str) -> None:
|
||||
"""Compare raw official Full/SFT weights with Omni's converted Full pipeline."""
|
||||
_require_ltx25_model_access()
|
||||
artifact_parent = accuracy_artifact_root / "ltx_official"
|
||||
output_root = reset_artifact_dir(artifact_parent / f"ltx2_5_{pipeline_mode}_{task}")
|
||||
model_class_name = {
|
||||
"full_one_stage": "LTX2Pipeline",
|
||||
"full_two_stage": "LTX2TwoStagePipeline",
|
||||
}[pipeline_mode]
|
||||
official_root, official_revision = _ltx25_official_source(artifact_parent)
|
||||
official_artifacts, official_model_source, official_model_revision = _resolve_ltx25_official_artifacts(
|
||||
checkpoint_variant="full" if pipeline_mode == "full_one_stage" else "full_two_stage"
|
||||
)
|
||||
omni_model, omni_model_source, omni_model_revision = _resolve_ltx25_omni_model(
|
||||
transformer_subfolder="transformer_full"
|
||||
)
|
||||
image = _resolve_ltx25_image() if task == "i2v" else None
|
||||
request = _ltx25_full_request(image)
|
||||
if pipeline_mode == "full_two_stage":
|
||||
request["stage_1_sigmas"] = request.pop("sigmas")
|
||||
request["stage_2_sigmas"] = LTX25_STAGE_2_SIGMAS
|
||||
request_path = output_root / "request.json"
|
||||
request_path.write_text(json.dumps(request, indent=2) + "\n")
|
||||
|
||||
runner = Path(__file__).with_name("run_ltx25_reference.py")
|
||||
runner_args = [
|
||||
str(runner),
|
||||
"--request",
|
||||
str(request_path),
|
||||
"--enable-layerwise-offload",
|
||||
]
|
||||
env = os.environ.copy()
|
||||
env["VLLM_TEST_LTX_OFFICIAL_REVISION"] = official_revision
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
repository_root = Path(__file__).resolve().parents[4]
|
||||
existing_pythonpath = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = (
|
||||
str(repository_root) if not existing_pythonpath else f"{repository_root}{os.pathsep}{existing_pythonpath}"
|
||||
)
|
||||
|
||||
official_output = output_root / "official"
|
||||
official_sidecar_args: list[str] = []
|
||||
if pipeline_mode == "full_two_stage":
|
||||
official_sidecar_args = [
|
||||
"--spatial-upsampler-path",
|
||||
str(official_artifacts["spatial_upsampler"]),
|
||||
"--distilled-lora-path",
|
||||
str(official_artifacts["distilled_lora"]),
|
||||
]
|
||||
|
||||
_run(
|
||||
_official_runner_prefix()
|
||||
+ runner_args
|
||||
+ [
|
||||
"--backend",
|
||||
"official",
|
||||
"--output-dir",
|
||||
str(official_output),
|
||||
"--official-root",
|
||||
str(official_root),
|
||||
"--official-pipeline",
|
||||
pipeline_mode,
|
||||
"--transformer-path",
|
||||
str(official_artifacts["transformer"]),
|
||||
"--text-encoder-path",
|
||||
str(official_artifacts["text_encoder"]),
|
||||
"--video-vae-path",
|
||||
str(official_artifacts["video_vae"]),
|
||||
"--audio-vae-path",
|
||||
str(official_artifacts["audio_vae"]),
|
||||
"--connector-model",
|
||||
str(omni_model),
|
||||
]
|
||||
+ official_sidecar_args,
|
||||
env=env,
|
||||
)
|
||||
|
||||
omni_output = output_root / "omni"
|
||||
_run(
|
||||
[sys.executable]
|
||||
+ runner_args
|
||||
+ [
|
||||
"--backend",
|
||||
"omni",
|
||||
"--output-dir",
|
||||
str(omni_output),
|
||||
"--model",
|
||||
str(omni_model),
|
||||
"--model-class-name",
|
||||
model_class_name,
|
||||
],
|
||||
env=env,
|
||||
)
|
||||
|
||||
official_metadata = json.loads((official_output / "metadata.json").read_text())
|
||||
omni_metadata = json.loads((omni_output / "metadata.json").read_text())
|
||||
assert official_metadata["official_revision"] == official_revision
|
||||
assert (
|
||||
official_metadata["pipeline"]
|
||||
== {
|
||||
"full_one_stage": "TI2VidOneStagePipeline",
|
||||
"full_two_stage": "TI2VidTwoStagesPipeline",
|
||||
}[pipeline_mode]
|
||||
)
|
||||
assert official_metadata["attention_backend"] == ATTENTION_BACKEND
|
||||
assert official_metadata["connector_model"] == str(omni_model)
|
||||
assert omni_metadata["model_class_name"] == model_class_name
|
||||
assert omni_metadata["attention_backend"] == ATTENTION_BACKEND
|
||||
assert official_metadata["seed"] == omni_metadata["seed"]
|
||||
for schedule_name in ("sigmas", "stage_1_sigmas", "stage_2_sigmas"):
|
||||
assert official_metadata[schedule_name] == omni_metadata[schedule_name]
|
||||
assert official_metadata["audio_sample_rate"] == omni_metadata["audio_sample_rate"]
|
||||
video_metrics = _video_metrics(
|
||||
np.load(official_output / "video.npy"),
|
||||
np.load(omni_output / "video.npy"),
|
||||
)
|
||||
audio_metrics = _audio_metrics(
|
||||
np.load(official_output / "audio.npy"),
|
||||
np.load(omni_output / "audio.npy"),
|
||||
)
|
||||
result = {
|
||||
"case": f"ltx2_5_{pipeline_mode}",
|
||||
"task": task,
|
||||
"attention_backend": ATTENTION_BACKEND,
|
||||
"official_revision": official_revision,
|
||||
"official_model": official_model_source,
|
||||
"official_model_revision": official_model_revision,
|
||||
"official_connector_model": omni_model_source,
|
||||
"official_connector_revision": omni_model_revision,
|
||||
"omni_model": omni_model_source,
|
||||
"omni_model_revision": omni_model_revision,
|
||||
"resolved_omni_model_path": str(omni_model),
|
||||
"peak_memory_mb": {
|
||||
"official_allocated": official_metadata["peak_memory_allocated_mb"],
|
||||
"official_reserved": official_metadata["peak_memory_reserved_mb"],
|
||||
"omni_worker_reported": omni_metadata["worker_peak_memory_mb"],
|
||||
"omni_parent_allocated": omni_metadata["peak_memory_allocated_mb"],
|
||||
"omni_parent_reserved": omni_metadata["peak_memory_reserved_mb"],
|
||||
},
|
||||
"video": video_metrics,
|
||||
"audio": audio_metrics,
|
||||
}
|
||||
(output_root / "metrics.json").write_text(json.dumps(result, indent=2) + "\n")
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
assert video_metrics["ssim_mean"] >= LTX25_FULL_VIDEO_SSIM_MEAN_THRESHOLD
|
||||
assert video_metrics["ssim_min"] >= LTX25_FULL_VIDEO_SSIM_MIN_THRESHOLD
|
||||
assert video_metrics["psnr_mean_db"] >= LTX25_FULL_VIDEO_PSNR_MEAN_THRESHOLD
|
||||
lpips_threshold = (
|
||||
LTX25_FULL_VIDEO_LPIPS_MEAN_THRESHOLD if task == "t2v" else LTX25_FULL_I2V_VIDEO_LPIPS_MEAN_THRESHOLD
|
||||
)
|
||||
assert audio_metrics["relative_l2"] <= LTX25_FULL_AUDIO_RELATIVE_L2_THRESHOLD
|
||||
assert video_metrics["lpips_alex_mean"] <= lpips_threshold
|
||||
assert audio_metrics["cosine_similarity"] >= LTX25_FULL_AUDIO_COSINE_THRESHOLD
|
||||
@@ -0,0 +1,89 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Online serving smokes for all four canonical LTX-2.5 pipelines."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers import skip_if_gated_repo_inaccessible
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from tests.helpers.media import generate_synthetic_image
|
||||
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
DEFAULT_MODEL = "Lightricks/LTX-2.5-Diffusers"
|
||||
DEFAULT_REVISION = "a6de4b5354f078db24d9cf4778c14846788aea3d"
|
||||
MODEL = os.getenv("VLLM_TEST_LTX25_MODEL", DEFAULT_MODEL)
|
||||
MODEL_REVISION = os.getenv("VLLM_TEST_LTX25_MODEL_REVISION", DEFAULT_REVISION if MODEL == DEFAULT_MODEL else "")
|
||||
PROMPT = "A red fox walks through a snowy forest while the camera remains fixed."
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
|
||||
SINGLE_CARD_MARKS = hardware_marks(res={"cuda": "H100"})
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def require_ltx25_model_access() -> None:
|
||||
if not os.path.isdir(MODEL):
|
||||
skip_if_gated_repo_inaccessible(
|
||||
MODEL,
|
||||
revision=MODEL_REVISION or None,
|
||||
filename="model_index.json",
|
||||
)
|
||||
|
||||
|
||||
def _server(model_class_name: str) -> OmniServerParams:
|
||||
return OmniServerParams(
|
||||
model=MODEL,
|
||||
server_args=[
|
||||
*(["--revision", MODEL_REVISION] if MODEL_REVISION else []),
|
||||
"--model-class-name",
|
||||
model_class_name,
|
||||
"--enforce-eager",
|
||||
"--enable-layerwise-offload",
|
||||
"--diffusion-attention-backend",
|
||||
"CUDNN_ATTN",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _cases():
|
||||
return [
|
||||
pytest.param(_server("LTX2Pipeline"), 30, id="full_one_stage", marks=SINGLE_CARD_MARKS),
|
||||
pytest.param(_server("LTX2TwoStagePipeline"), 30, id="full_two_stage", marks=SINGLE_CARD_MARKS),
|
||||
pytest.param(_server("LTX2DistilledOneStagePipeline"), 8, id="distilled_one_stage", marks=SINGLE_CARD_MARKS),
|
||||
pytest.param(_server("LTX2DistilledTwoStagePipeline"), 8, id="distilled_two_stage", marks=SINGLE_CARD_MARKS),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("omni_server", "num_inference_steps"), _cases(), indirect=["omni_server"])
|
||||
def test_ltx25_pipeline_entries(
|
||||
omni_server: OmniServer,
|
||||
num_inference_steps: int,
|
||||
openai_client: OpenAIClientHandler,
|
||||
subtests,
|
||||
) -> None:
|
||||
"""Generate T2V and first-frame I2V through the synchronous video API."""
|
||||
for task in ("t2v", "i2v"):
|
||||
with subtests.test(task=task):
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"form_data": {
|
||||
"model": omni_server.model,
|
||||
"prompt": PROMPT,
|
||||
"height": 128,
|
||||
"width": 128,
|
||||
"num_frames": 9,
|
||||
"fps": 24,
|
||||
"num_inference_steps": num_inference_steps,
|
||||
"seed": 42,
|
||||
},
|
||||
}
|
||||
if task == "i2v":
|
||||
request_config["image_reference"] = (
|
||||
f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
|
||||
)
|
||||
|
||||
openai_client.send_video_diffusion_request(request_config)
|
||||
@@ -32,7 +32,7 @@ from vllm_omni.entrypoints.openai.protocol.videos import (
|
||||
VideoParams,
|
||||
VideoResponse,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo
|
||||
from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo, ReferenceImage
|
||||
from vllm_omni.entrypoints.openai.storage import LocalStorageManager
|
||||
from vllm_omni.entrypoints.openai.stores import AsyncDictStore, TaskRegistry
|
||||
from vllm_omni.errors import GuardrailViolationError
|
||||
@@ -461,6 +461,51 @@ def test_i2v_video_generation_resizes_input_to_requested_dimensions(test_client,
|
||||
assert input_image.size == (96, 64)
|
||||
|
||||
|
||||
def test_i2v_resize_policy_can_defer_to_pipeline(monkeypatch):
|
||||
engine = FakeAsyncOmni()
|
||||
engine.get_diffusion_od_config = lambda: SimpleNamespace(
|
||||
model="org/model",
|
||||
model_class_name="ExamplePipeline",
|
||||
revision="pinned-revision",
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_policy(model_class_name, *, model, revision=None):
|
||||
captured.update(
|
||||
model_class_name=model_class_name,
|
||||
model=model,
|
||||
revision=revision,
|
||||
)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.entrypoints.openai.serving_video.should_preserve_reference_image_size",
|
||||
fake_policy,
|
||||
)
|
||||
handler = OmniOpenAIServingVideo.for_diffusion(
|
||||
diffusion_engine=engine,
|
||||
model_name="fallback/model",
|
||||
)
|
||||
image = Image.new("RGB", (48, 32))
|
||||
|
||||
asyncio.run(
|
||||
handler._run_and_extract(
|
||||
VideoGenerationRequest(prompt="A bear playing with yarn.", width=96, height=64),
|
||||
"pipeline-owned-resize",
|
||||
reference_image=ReferenceImage(image),
|
||||
)
|
||||
)
|
||||
|
||||
input_image = engine.captured_prompt["multi_modal_data"]["image"]
|
||||
assert isinstance(input_image, Image.Image)
|
||||
assert input_image.size == (48, 32)
|
||||
assert captured == {
|
||||
"model_class_name": "ExamplePipeline",
|
||||
"model": "org/model",
|
||||
"revision": "pinned-revision",
|
||||
}
|
||||
|
||||
|
||||
def test_i2v_extra_params_dimensions_preserve_input_image_geometry(test_client, mocker: MockerFixture):
|
||||
image_bytes = _make_test_image_bytes((48, 48))
|
||||
mocker.patch(
|
||||
|
||||
@@ -49,7 +49,9 @@ def test_dreamzero_config_sets_model_class_and_policy_config(monkeypatch):
|
||||
def test_dreamzero_enrich_config_preserves_explicit_model_class_name(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"vllm.transformers_utils.config.get_hf_file_to_dict",
|
||||
lambda path, _model: None if path == "model_index.json" else {"model_type": "vla", "architectures": ["VLA"]},
|
||||
lambda path, _model, **_kwargs: (
|
||||
None if path == "model_index.json" else {"model_type": "vla", "architectures": ["VLA"]}
|
||||
),
|
||||
)
|
||||
|
||||
od_config = OmniDiffusionConfig(
|
||||
|
||||
@@ -11,11 +11,16 @@ affect in-process Omni (``OmniRunner`` / offline e2e) vs subprocess-based
|
||||
import pytest
|
||||
|
||||
|
||||
def skip_if_gated_repo_inaccessible(repo_id: str) -> None:
|
||||
def skip_if_gated_repo_inaccessible(
|
||||
repo_id: str,
|
||||
*,
|
||||
revision: str | None = None,
|
||||
filename: str = "config.json",
|
||||
) -> None:
|
||||
"""Skip the test if a gated HuggingFace repo is not accessible.
|
||||
|
||||
Tries to download the model's config.json via ``hf_hub_download``,
|
||||
which performs an actual file-access check (unlike ``HfApi().model_info()``
|
||||
Tries to download ``filename`` via ``hf_hub_download``, which performs an
|
||||
actual file-access check (unlike ``HfApi().model_info()``
|
||||
that only checks metadata). If the token has metadata access but not
|
||||
file-download access, ``hf_hub_download`` will raise ``GatedRepoError``
|
||||
and we skip cleanly.
|
||||
@@ -26,7 +31,7 @@ def skip_if_gated_repo_inaccessible(repo_id: str) -> None:
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
hf_hub_download(repo_id=repo_id, filename="config.json")
|
||||
hf_hub_download(repo_id=repo_id, filename=filename, revision=revision)
|
||||
except GatedRepoError as exc:
|
||||
pytest.skip(
|
||||
f"Skipping: gated HF repo {repo_id!r} inaccessible to the current "
|
||||
|
||||
@@ -20,6 +20,7 @@ from vllm_omni.model_extras import (
|
||||
get_output_tensor_range,
|
||||
get_x_to_text_model_family,
|
||||
should_init_extra_args_for_non_diffusion_stages,
|
||||
should_preserve_reference_image_size,
|
||||
)
|
||||
|
||||
|
||||
@@ -230,18 +231,62 @@ def test_ltx_extra_registry_declares_official_guidance_params() -> None:
|
||||
"audio_rescale_scale",
|
||||
"video_stg_blocks",
|
||||
"audio_stg_blocks",
|
||||
"sigmas",
|
||||
"stage_1_sigmas",
|
||||
"stage_2_sigmas",
|
||||
"image_crf",
|
||||
}
|
||||
)
|
||||
|
||||
for pipeline_name in (
|
||||
"LTX2Pipeline",
|
||||
"LTX2TwoStagePipeline",
|
||||
"LTX2DistilledOneStagePipeline",
|
||||
"LTX2DistilledTwoStagePipeline",
|
||||
"LTX2DistilledPipeline",
|
||||
):
|
||||
assert get_extra_body_params(pipeline_name) == expected
|
||||
assert get_extra_output_params(pipeline_name) == frozenset()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_version", "expected"),
|
||||
[("2", False), ("2.3", False), ("2.5", True)],
|
||||
)
|
||||
def test_ltx_reference_image_size_policy(tmp_path, model_version: str, expected: bool) -> None:
|
||||
(tmp_path / "model_index.json").write_text(
|
||||
'{"_class_name": "LTX2Pipeline", "model_version": "' + model_version + '"}'
|
||||
)
|
||||
|
||||
assert (
|
||||
should_preserve_reference_image_size(
|
||||
None,
|
||||
model=str(tmp_path),
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
def test_reference_image_size_policy_threads_revision(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = {}
|
||||
|
||||
def fake_policy(*, model, revision=None):
|
||||
captured.update(model=model, revision=revision)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.models.ltx2.ltx2_components.preserves_reference_image_size",
|
||||
fake_policy,
|
||||
)
|
||||
|
||||
assert should_preserve_reference_image_size(
|
||||
"LTX2Pipeline",
|
||||
model="org/model",
|
||||
revision="pinned-revision",
|
||||
)
|
||||
assert captured == {"model": "org/model", "revision": "pinned-revision"}
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_ming_flash_omni_extra_registry_declares_request_and_response_params() -> None:
|
||||
@@ -424,6 +469,13 @@ def test_vace_extra_registry_has_no_pipeline_params() -> None:
|
||||
def test_unknown_pipeline_has_empty_extra_registry() -> None:
|
||||
assert get_extra_body_params("UnknownPipeline") == frozenset()
|
||||
assert get_extra_output_params("UnknownPipeline") == frozenset()
|
||||
assert (
|
||||
should_preserve_reference_image_size(
|
||||
"UnknownPipeline",
|
||||
model="org/model",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert should_init_extra_args_for_non_diffusion_stages("UnknownPipeline") is False
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ EXCLUDED_MODELS = [
|
||||
"WanPipeline",
|
||||
"WanVACEPipeline",
|
||||
"LTX2TwoStagePipeline",
|
||||
"LTX2DistilledOneStagePipeline",
|
||||
"LTX2DistilledTwoStagePipeline",
|
||||
"LTX2DistilledPipeline",
|
||||
"LTX2T2VDMD2Pipeline",
|
||||
"LTX2I2VDMD2Pipeline",
|
||||
|
||||
@@ -85,6 +85,24 @@ class CuDNNAttentionImpl(AttentionImpl):
|
||||
|
||||
enable_gqa = query.shape[2] != key.shape[2]
|
||||
query, key, value = (x.permute(0, 2, 1, 3) for x in (query, key, value))
|
||||
# cuDNN has no kernel for degenerate attention shapes when either
|
||||
# sequence has one token. Route those degenerate shapes before entering the
|
||||
# cuDNN-only context: under torch.compile, FakeTensor evaluation raises
|
||||
# before the eager RuntimeError fallback below can run.
|
||||
if query.shape[-2] == 1 or key.shape[-2] == 1:
|
||||
with sdpa_kernel([SDPBackend.MATH]):
|
||||
output = torch.nn.functional.scaled_dot_product_attention(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
attn_mask=attention_mask,
|
||||
dropout_p=0.0,
|
||||
is_causal=self.causal,
|
||||
scale=self.softmax_scale,
|
||||
enable_gqa=enable_gqa,
|
||||
)
|
||||
return output.permute(0, 2, 1, 3)
|
||||
|
||||
# Pin cuDNN exclusively. A priority list like [CUDNN, FLASH, MATH] hits a
|
||||
# PyTorch SDPA dispatch quirk: when FLASH rejects a non-None attn_mask,
|
||||
# cuDNN gets runtime-disabled in the same call and the dispatcher falls
|
||||
@@ -93,7 +111,7 @@ class CuDNNAttentionImpl(AttentionImpl):
|
||||
#
|
||||
# Fall back to the default SDPA dispatcher if cuDNN rejects the shape,
|
||||
# e.g. under torch.compile where Dynamo sees a symbolic head_dim and
|
||||
# cuDNN's kernel selection fails (observed in LTX-2 audio attention).
|
||||
# cuDNN's kernel selection fails for some symbolic attention shapes.
|
||||
# The unpinned dispatcher then picks EFFICIENT/MATH instead of raising.
|
||||
try:
|
||||
with sdpa_kernel([SDPBackend.CUDNN_ATTENTION]):
|
||||
|
||||
@@ -538,7 +538,11 @@ class DiffusionCacheConfig:
|
||||
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{item}'")
|
||||
|
||||
|
||||
def resolve_model_class_name(model: str | None, diffusion_load_format: str = "default") -> str | None:
|
||||
def resolve_model_class_name(
|
||||
model: str | None,
|
||||
diffusion_load_format: str = "default",
|
||||
revision: str | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve the diffusion pipeline class name from the model config.
|
||||
|
||||
Read-only counterpart of ``OmniDiffusionConfig.enrich_config``, safe to call
|
||||
@@ -550,11 +554,10 @@ def resolve_model_class_name(model: str | None, diffusion_load_format: str = "de
|
||||
|
||||
if not model:
|
||||
return None
|
||||
|
||||
is_lance_subfolder = os.path.basename(str(model).rstrip("/")) in {"Lance_3B", "Lance_3B_Video"}
|
||||
|
||||
# Diffusers models: read _class_name from the pipeline index.
|
||||
model_index = get_diffusion_model_index(model)
|
||||
model_index = get_diffusion_model_index(model, revision=revision)
|
||||
if model_index is not None:
|
||||
return model_index.get("_class_name")
|
||||
if diffusion_load_format == "diffusers":
|
||||
@@ -562,7 +565,7 @@ def resolve_model_class_name(model: str | None, diffusion_load_format: str = "de
|
||||
|
||||
# Other models: map model_type / architecture from config.json.
|
||||
try:
|
||||
cfg = get_hf_file_to_dict("config.json", model) or {}
|
||||
cfg = get_hf_file_to_dict("config.json", model, revision=revision) or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
model_type = cfg.get("model_type")
|
||||
@@ -1210,9 +1213,20 @@ class OmniDiffusionConfig:
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
tf_config_dict = get_hf_file_to_dict("transformer/config.json", self.model)
|
||||
from vllm_omni.model_extras import get_transformer_config_subfolder
|
||||
|
||||
transformer_subfolder = get_transformer_config_subfolder(
|
||||
self.model_class_name,
|
||||
model=self.model,
|
||||
revision=self.revision,
|
||||
)
|
||||
tf_config_dict = get_hf_file_to_dict(
|
||||
f"{transformer_subfolder}/config.json",
|
||||
self.model,
|
||||
revision=self.revision,
|
||||
)
|
||||
if tf_config_dict is None:
|
||||
tf_config_dict = get_hf_file_to_dict("unet/config.json", self.model)
|
||||
tf_config_dict = get_hf_file_to_dict("unet/config.json", self.model, revision=self.revision)
|
||||
if tf_config_dict is not None:
|
||||
self.set_tf_model_config(TransformerConfig.from_dict(tf_config_dict))
|
||||
else:
|
||||
@@ -1232,7 +1246,7 @@ class OmniDiffusionConfig:
|
||||
"that require additional inputs."
|
||||
)
|
||||
else:
|
||||
cfg = get_hf_file_to_dict("config.json", self.model)
|
||||
cfg = get_hf_file_to_dict("config.json", self.model, revision=self.revision)
|
||||
if cfg is None:
|
||||
# Lance ships its top-level config.json one directory above
|
||||
# the per-checkpoint subfolders (``Lance_3B/`` or
|
||||
|
||||
@@ -265,6 +265,7 @@ def prefetch_subfolders(
|
||||
subfolders: Iterable[str],
|
||||
*,
|
||||
local_files_only: bool | None = None,
|
||||
revision: str | None = None,
|
||||
include_root_metadata: bool = True,
|
||||
) -> None:
|
||||
"""Materialise ``model``'s ``subfolders`` in the HF cache before loading.
|
||||
@@ -278,6 +279,7 @@ def prefetch_subfolders(
|
||||
local_files_only: When ``True``, skip the prefetch entirely.
|
||||
When ``None`` (default), auto-detect: skip if *model* is a
|
||||
local directory, run otherwise.
|
||||
revision: Optional Hub revision shared with component loaders.
|
||||
include_root_metadata: When True, also pull ``*.json`` at the repo
|
||||
root so ``model_index.json`` / ``config.json`` resolution during
|
||||
``from_pretrained`` also hits a warm cache.
|
||||
@@ -334,6 +336,7 @@ def prefetch_subfolders(
|
||||
with _repo_prefetch_lock(model):
|
||||
snapshot_download(
|
||||
repo_id=model,
|
||||
revision=revision,
|
||||
allow_patterns=allow_patterns,
|
||||
)
|
||||
logger.info("Prefetch complete for %s", model)
|
||||
@@ -450,6 +453,7 @@ def from_pretrained_with_prefetch(
|
||||
on the first failure exactly as before.
|
||||
"""
|
||||
prefetch_list = list(prefetch_list)
|
||||
revision = from_pretrained_kwargs.get("revision")
|
||||
can_heal = not local_files_only and bool(model) and not os.path.isdir(model)
|
||||
last_exc: BaseException | None = None
|
||||
|
||||
@@ -481,7 +485,7 @@ def from_pretrained_with_prefetch(
|
||||
# Force a fresh, verified snapshot of every component this pipeline
|
||||
# needs - not just ``subfolder`` - so a sibling component that was
|
||||
# also half-written gets repaired in the same pass.
|
||||
prefetch_subfolders(model, prefetch_list, local_files_only=False)
|
||||
prefetch_subfolders(model, prefetch_list, local_files_only=False, revision=revision)
|
||||
|
||||
assert last_exc is not None # loop only exits via return or a caught exc
|
||||
raise last_exc
|
||||
@@ -8,21 +8,25 @@ from vllm_omni.diffusion.models.ltx2.ltx2_components import (
|
||||
)
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import (
|
||||
LTX2DistilledOneStagePipeline,
|
||||
LTX2I2VDMD2Pipeline,
|
||||
LTX2Pipeline,
|
||||
LTX2T2VDMD2Pipeline,
|
||||
)
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_two_stage import (
|
||||
LTX2DistilledPipeline,
|
||||
LTX2DistilledTwoStagePipeline,
|
||||
LTX2TwoStagePipeline,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LTX2Pipeline",
|
||||
"LTX2DistilledOneStagePipeline",
|
||||
"LTX2T2VDMD2Pipeline",
|
||||
"LTX2I2VDMD2Pipeline",
|
||||
"LTX2TwoStagePipeline",
|
||||
"LTX2DistilledPipeline",
|
||||
"LTX2DistilledTwoStagePipeline",
|
||||
"get_ltx2_post_process_func",
|
||||
"load_transformer_config",
|
||||
"create_transformer_from_config",
|
||||
|
||||
@@ -22,7 +22,7 @@ from diffusers.video_processor import VideoProcessor
|
||||
from huggingface_hub import hf_hub_download
|
||||
from safetensors import safe_open
|
||||
from safetensors.torch import load_file
|
||||
from transformers import AutoTokenizer, Gemma3ForConditionalGeneration
|
||||
from transformers import AutoModelForImageTextToText, AutoTokenizer, Gemma3ForConditionalGeneration
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.layer import Attention as OmniAttention
|
||||
@@ -48,6 +48,14 @@ try:
|
||||
except ImportError:
|
||||
LTX2VocoderWithBWE = None
|
||||
|
||||
try:
|
||||
from transformers import Gemma4UnifiedForConditionalGeneration as _Gemma4UnifiedForConditionalGeneration
|
||||
except ImportError:
|
||||
_Gemma4UnifiedForConditionalGeneration = None
|
||||
|
||||
|
||||
_LTX25_TEXT_ENCODER_CLS = AutoModelForImageTextToText if _Gemma4UnifiedForConditionalGeneration is not None else None
|
||||
|
||||
|
||||
_LTX_COMPONENT_SUBFOLDERS = (
|
||||
"tokenizer",
|
||||
@@ -73,10 +81,15 @@ class LTXComponentProfile:
|
||||
resident_modules: tuple[str, ...] = ()
|
||||
video_vae_cls: type = AutoencoderKLLTX2Video
|
||||
vocoder_cls: type = LTX2Vocoder
|
||||
text_encoder_cls: type | None = Gemma3ForConditionalGeneration
|
||||
vocoder_fallback_cls: type | None = None
|
||||
artifact_repo_id: str | None = None
|
||||
latent_upsampler_filename: str | None = None
|
||||
distilled_lora_filename: str | None = None
|
||||
transformer_subfolder: str = "transformer"
|
||||
scheduler_use_dynamic_shifting: bool = False
|
||||
scheduler_shift_terminal: float | None = None
|
||||
preserve_connector_attention_mask: bool = False
|
||||
|
||||
|
||||
LTX2_COMPONENT_PROFILE = LTXComponentProfile(
|
||||
@@ -99,6 +112,23 @@ LTX23_COMPONENT_PROFILE = LTXComponentProfile(
|
||||
vocoder_fallback_cls=LTX2Vocoder,
|
||||
)
|
||||
|
||||
LTX25_FULL_COMPONENT_PROFILE = LTXComponentProfile(
|
||||
name="ltx2_5_full",
|
||||
dit_modules=("transformer",),
|
||||
encoder_modules=("text_encoder", "connectors"),
|
||||
vae_modules=("vae", "audio_vae"),
|
||||
resident_modules=("vocoder",),
|
||||
video_vae_cls=DistributedAutoencoderKLLTX2Video,
|
||||
vocoder_cls=LTX2VocoderWithBWE or LTX2Vocoder,
|
||||
vocoder_fallback_cls=LTX2Vocoder,
|
||||
text_encoder_cls=_LTX25_TEXT_ENCODER_CLS,
|
||||
transformer_subfolder="transformer_full",
|
||||
scheduler_use_dynamic_shifting=True,
|
||||
scheduler_shift_terminal=0.1,
|
||||
preserve_connector_attention_mask=True,
|
||||
)
|
||||
|
||||
|
||||
LTX2_DISTILLED_COMPONENT_PROFILE = LTXComponentProfile(
|
||||
name="ltx2_distilled",
|
||||
dit_modules=("transformer",),
|
||||
@@ -108,6 +138,32 @@ LTX2_DISTILLED_COMPONENT_PROFILE = LTXComponentProfile(
|
||||
video_vae_cls=DistributedAutoencoderKLLTX2Video,
|
||||
)
|
||||
|
||||
LTX25_DISTILLED_COMPONENT_PROFILE = LTXComponentProfile(
|
||||
name="ltx2_5_distilled",
|
||||
dit_modules=("transformer",),
|
||||
encoder_modules=("text_encoder", "connectors"),
|
||||
vae_modules=("vae", "audio_vae"),
|
||||
resident_modules=("vocoder", "latent_upsampler"),
|
||||
video_vae_cls=DistributedAutoencoderKLLTX2Video,
|
||||
vocoder_cls=LTX2VocoderWithBWE or LTX2Vocoder,
|
||||
vocoder_fallback_cls=LTX2Vocoder,
|
||||
text_encoder_cls=_LTX25_TEXT_ENCODER_CLS,
|
||||
preserve_connector_attention_mask=True,
|
||||
)
|
||||
|
||||
LTX2_DISTILLED_ONE_STAGE_COMPONENT_PROFILE = replace(
|
||||
LTX2_DISTILLED_COMPONENT_PROFILE,
|
||||
name="ltx2_distilled_one_stage",
|
||||
resident_modules=LTX2_COMPONENT_PROFILE.resident_modules,
|
||||
)
|
||||
|
||||
LTX25_DISTILLED_ONE_STAGE_COMPONENT_PROFILE = replace(
|
||||
LTX25_DISTILLED_COMPONENT_PROFILE,
|
||||
name="ltx2_5_distilled_one_stage",
|
||||
resident_modules=LTX25_FULL_COMPONENT_PROFILE.resident_modules,
|
||||
)
|
||||
|
||||
|
||||
LTX23_DISTILLED_COMPONENT_PROFILE = replace(
|
||||
LTX23_COMPONENT_PROFILE,
|
||||
name="ltx2_3_distilled",
|
||||
@@ -116,6 +172,12 @@ LTX23_DISTILLED_COMPONENT_PROFILE = replace(
|
||||
latent_upsampler_filename="ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
|
||||
)
|
||||
|
||||
LTX23_DISTILLED_ONE_STAGE_COMPONENT_PROFILE = replace(
|
||||
LTX23_DISTILLED_COMPONENT_PROFILE,
|
||||
name="ltx2_3_distilled_one_stage",
|
||||
resident_modules=LTX23_COMPONENT_PROFILE.resident_modules,
|
||||
)
|
||||
|
||||
LTX2_TWO_STAGE_COMPONENT_PROFILE = replace(
|
||||
LTX2_COMPONENT_PROFILE,
|
||||
name="ltx2_two_stage",
|
||||
@@ -135,18 +197,44 @@ LTX23_TWO_STAGE_COMPONENT_PROFILE = replace(
|
||||
)
|
||||
|
||||
|
||||
LTX25_TWO_STAGE_COMPONENT_PROFILE = replace(
|
||||
LTX25_FULL_COMPONENT_PROFILE,
|
||||
name="ltx2_5_two_stage",
|
||||
resident_modules=(*LTX25_FULL_COMPONENT_PROFILE.resident_modules, "latent_upsampler"),
|
||||
artifact_repo_id="Lightricks/LTX-2.5",
|
||||
latent_upsampler_filename=("latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors"),
|
||||
distilled_lora_filename="loras/ltx-2.5-22b-distilled-lora-450-bf16.safetensors",
|
||||
)
|
||||
|
||||
_COMPONENT_PROFILES: dict[tuple[str, str], LTXComponentProfile] = {
|
||||
("one_stage", "2"): LTX2_COMPONENT_PROFILE,
|
||||
("one_stage", "2.3"): LTX23_COMPONENT_PROFILE,
|
||||
("one_stage", "2.5"): LTX25_FULL_COMPONENT_PROFILE,
|
||||
("two_stage", "2"): LTX2_TWO_STAGE_COMPONENT_PROFILE,
|
||||
("two_stage", "2.3"): LTX23_TWO_STAGE_COMPONENT_PROFILE,
|
||||
("two_stage", "2.5"): LTX25_TWO_STAGE_COMPONENT_PROFILE,
|
||||
("distilled_one_stage", "2"): LTX2_DISTILLED_ONE_STAGE_COMPONENT_PROFILE,
|
||||
("distilled_one_stage", "2.3"): LTX23_DISTILLED_ONE_STAGE_COMPONENT_PROFILE,
|
||||
("distilled_one_stage", "2.5"): LTX25_DISTILLED_ONE_STAGE_COMPONENT_PROFILE,
|
||||
("distilled_two_stage", "2"): LTX2_DISTILLED_COMPONENT_PROFILE,
|
||||
("distilled_two_stage", "2.3"): LTX23_DISTILLED_COMPONENT_PROFILE,
|
||||
("distilled_two_stage", "2.5"): LTX25_DISTILLED_COMPONENT_PROFILE,
|
||||
("dmd2", "2"): LTX2_COMPONENT_PROFILE,
|
||||
("dmd2", "2.3"): LTX23_COMPONENT_PROFILE,
|
||||
}
|
||||
|
||||
|
||||
def resolve_ltx_checkpoint_kind(pipeline_kind: str) -> LTXCheckpointKind | None:
|
||||
"""Derive checkpoint requirements from the execution contract."""
|
||||
if pipeline_kind in {"one_stage", "two_stage"}:
|
||||
return "regular"
|
||||
if pipeline_kind in {"distilled_one_stage", "distilled_two_stage"}:
|
||||
return "distilled"
|
||||
if pipeline_kind == "dmd2":
|
||||
return None
|
||||
raise ValueError(f"Unsupported LTX pipeline kind: {pipeline_kind!r}.")
|
||||
|
||||
|
||||
def resolve_ltx_artifact(
|
||||
model: str,
|
||||
repo_id: str,
|
||||
@@ -206,7 +294,7 @@ def resolve_ltx_component_profile(pipeline_kind: str, model_version: str) -> LTX
|
||||
raise ValueError(f"Unsupported LTX component kind/version: {pipeline_kind!r}/{model_version!r}.") from exc
|
||||
|
||||
|
||||
def _load_ltx_metadata_json(model: str, filename: str) -> dict[str, Any]:
|
||||
def _load_ltx_metadata_json(model: str, filename: str, revision: str | None = None) -> dict[str, Any]:
|
||||
"""Load small checkpoint metadata without relying on repository names."""
|
||||
if os.path.isdir(model):
|
||||
path = os.path.join(model, filename)
|
||||
@@ -214,7 +302,7 @@ def _load_ltx_metadata_json(model: str, filename: str) -> dict[str, Any]:
|
||||
return {}
|
||||
else:
|
||||
try:
|
||||
path = hf_hub_download(repo_id=model, filename=filename)
|
||||
path = hf_hub_download(repo_id=model, filename=filename, revision=revision)
|
||||
except Exception:
|
||||
return {}
|
||||
try:
|
||||
@@ -225,17 +313,34 @@ def _load_ltx_metadata_json(model: str, filename: str) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def detect_ltx_model_version(model: str) -> str:
|
||||
"""Detect LTX-2 versus LTX-2.3 from checkpoint component metadata.
|
||||
def detect_ltx_model_version(model: str, revision: str | None = None) -> str:
|
||||
"""Detect the LTX model version from checkpoint component metadata."""
|
||||
model_index = _load_ltx_metadata_json(model, "model_index.json", revision)
|
||||
model_version = str(model_index.get("model_version", ""))
|
||||
if model_version.startswith("2.5"):
|
||||
return "2.5"
|
||||
|
||||
Official checkpoints use ``model_version`` metadata. Diffusers repositories
|
||||
expose the equivalent distinction through the BWE vocoder introduced by
|
||||
LTX-2.3. Unknown conversions retain the official LTX-2 fallback.
|
||||
"""
|
||||
model_index = _load_ltx_metadata_json(model, "model_index.json")
|
||||
if str(model_index.get("model_version", "")).startswith("2.3"):
|
||||
text_encoder_entry = model_index.get("text_encoder")
|
||||
if isinstance(text_encoder_entry, (list, tuple)) and text_encoder_entry:
|
||||
text_encoder_class = str(text_encoder_entry[-1])
|
||||
elif isinstance(text_encoder_entry, dict):
|
||||
text_encoder_class = str(text_encoder_entry.get("_class_name", ""))
|
||||
else:
|
||||
text_encoder_class = ""
|
||||
if text_encoder_class == "Gemma4UnifiedForConditionalGeneration":
|
||||
return "2.5"
|
||||
|
||||
text_encoder_config = _load_ltx_metadata_json(model, "text_encoder/config.json", revision)
|
||||
if text_encoder_config.get("model_type") in ("gemma4_unified", "gemma4"):
|
||||
return "2.5"
|
||||
|
||||
if model_version.startswith("2.3"):
|
||||
return "2.3"
|
||||
|
||||
transformer_config = _load_ltx_metadata_json(model, "transformer/config.json", revision)
|
||||
if transformer_config.get("ff_bias") is False:
|
||||
return "2.5"
|
||||
|
||||
vocoder_entry = model_index.get("vocoder")
|
||||
if isinstance(vocoder_entry, (list, tuple)) and vocoder_entry:
|
||||
vocoder_class = str(vocoder_entry[-1])
|
||||
@@ -246,21 +351,32 @@ def detect_ltx_model_version(model: str) -> str:
|
||||
if vocoder_class == "LTX2VocoderWithBWE":
|
||||
return "2.3"
|
||||
|
||||
vocoder_config = _load_ltx_metadata_json(model, "vocoder/config.json")
|
||||
vocoder_config = _load_ltx_metadata_json(model, "vocoder/config.json", revision)
|
||||
if str(vocoder_config.get("model_version", "")).startswith("2.3"):
|
||||
return "2.3"
|
||||
if vocoder_config.get("_class_name") == "LTX2VocoderWithBWE":
|
||||
return "2.3"
|
||||
|
||||
logger.info("Using LTX-2 defaults for checkpoint %s", model)
|
||||
if "bwe" in vocoder_config or "bwe_config" in vocoder_config:
|
||||
return "2.3"
|
||||
return "2"
|
||||
|
||||
|
||||
def preserves_reference_image_size(*, model: str | None, revision: str | None = None) -> bool:
|
||||
"""Preserve source geometry only for the LTX-2.5 CRF-18 I2V path."""
|
||||
return model is not None and detect_ltx_model_version(model, revision=revision) == "2.5"
|
||||
|
||||
|
||||
class _LTXConnectorAttnProcessor:
|
||||
"""Preserve official connector math around Omni attention dispatch."""
|
||||
|
||||
def __init__(self, *, has_learned_registers: bool) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
has_learned_registers: bool = False,
|
||||
preserve_learned_register_mask: bool = False,
|
||||
) -> None:
|
||||
self.has_learned_registers = has_learned_registers
|
||||
self.preserve_learned_register_mask = preserve_learned_register_mask
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
@@ -274,12 +390,20 @@ class _LTXConnectorAttnProcessor:
|
||||
encoder_hidden_states = encoder_hidden_states if encoder_hidden_states is not None else hidden_states
|
||||
gate_logits = attn.to_gate_logits(hidden_states) if attn.to_gate_logits is not None else None
|
||||
|
||||
query = attn.norm_q(attn.to_q(hidden_states))
|
||||
key = attn.norm_k(attn.to_k(encoder_hidden_states))
|
||||
query = attn.to_q(hidden_states)
|
||||
key = attn.to_k(encoder_hidden_states)
|
||||
value = attn.to_v(encoder_hidden_states)
|
||||
# Offload hooks may execute affine-free Q/K norms in FP32. Restore the
|
||||
# projection dtype before attention, matching the fully resident path.
|
||||
query = attn.norm_q(query).to(dtype=value.dtype)
|
||||
key = attn.norm_k(key).to(dtype=value.dtype)
|
||||
|
||||
if query_rotary_emb is not None:
|
||||
# Diffusers builds connector RoPE in FP32, while the official
|
||||
# connector materializes it in the hidden-state dtype.
|
||||
query_rotary_emb = tuple(component.to(value.dtype) for component in query_rotary_emb)
|
||||
key_rotary_emb = key_rotary_emb if key_rotary_emb is not None else query_rotary_emb
|
||||
key_rotary_emb = tuple(component.to(value.dtype) for component in key_rotary_emb)
|
||||
if attn.rope_type == "interleaved":
|
||||
query = apply_interleaved_rotary_emb(query, query_rotary_emb)
|
||||
key = apply_interleaved_rotary_emb(key, key_rotary_emb)
|
||||
@@ -289,6 +413,10 @@ class _LTXConnectorAttnProcessor:
|
||||
else:
|
||||
raise ValueError(f"Unsupported LTX connector RoPE type: {attn.rope_type}")
|
||||
|
||||
# Keep Q/K in the projection dtype expected by the attention backend.
|
||||
query = query.to(dtype=value.dtype)
|
||||
key = key.to(dtype=value.dtype)
|
||||
|
||||
batch_size, _, inner_dim = query.shape
|
||||
head_dim = inner_dim // attn.heads
|
||||
kv_heads = attn.inner_kv_dim // attn.head_dim
|
||||
@@ -296,10 +424,10 @@ class _LTXConnectorAttnProcessor:
|
||||
key = key.view(batch_size, -1, kv_heads, head_dim)
|
||||
value = value.view(batch_size, -1, kv_heads, head_dim)
|
||||
|
||||
# The connector replaces padding tokens with learned registers before
|
||||
# entering its blocks, so every key is valid and the old padding mask
|
||||
# becomes an all-keep no-op.
|
||||
if self.has_learned_registers:
|
||||
# LTX-2/2.3 replace padding tokens with learned registers, so their
|
||||
# old padding mask becomes a no-op. LTX-2.5 preserves that all-zero
|
||||
# additive mask to match the official masked-SDPA dispatch.
|
||||
if self.has_learned_registers and not self.preserve_learned_register_mask:
|
||||
attention_mask = None
|
||||
elif attention_mask is not None and attn.omni_attention.attn_backend.get_name().upper() == "FLASH_ATTN":
|
||||
attention_mask = to_ltx_padding_mask(attention_mask)
|
||||
@@ -316,7 +444,11 @@ class _LTXConnectorAttnProcessor:
|
||||
return attn.to_out[1](hidden_states)
|
||||
|
||||
|
||||
def _install_connector_attention(connectors: LTX2TextConnectors) -> None:
|
||||
def _install_connector_attention(
|
||||
connectors: LTX2TextConnectors,
|
||||
*,
|
||||
preserve_learned_register_mask: bool = False,
|
||||
) -> None:
|
||||
for connector_name in ("video_connector", "audio_connector"):
|
||||
connector = getattr(connectors, connector_name, None)
|
||||
has_learned_registers = getattr(connector, "learnable_registers", None) is not None
|
||||
@@ -335,15 +467,20 @@ def _install_connector_attention(connectors: LTX2TextConnectors) -> None:
|
||||
skip_sequence_parallel=True,
|
||||
disable_kv_quant=True,
|
||||
)
|
||||
attention.set_processor(_LTXConnectorAttnProcessor(has_learned_registers=has_learned_registers))
|
||||
attention.set_processor(
|
||||
_LTXConnectorAttnProcessor(
|
||||
has_learned_registers=has_learned_registers,
|
||||
preserve_learned_register_mask=preserve_learned_register_mask,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _detect_vocoder_output_sample_rate(model: str) -> int | None:
|
||||
def _detect_vocoder_output_sample_rate(model: str, revision: str | None = None) -> int | None:
|
||||
"""Read the generated waveform sample rate from the vocoder config."""
|
||||
vocoder_config_path = os.path.join(model, "vocoder", "config.json")
|
||||
if not os.path.exists(vocoder_config_path):
|
||||
try:
|
||||
vocoder_config_path = hf_hub_download(model, "vocoder/config.json")
|
||||
vocoder_config_path = hf_hub_download(model, "vocoder/config.json", revision=revision)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
@@ -355,7 +492,10 @@ def _detect_vocoder_output_sample_rate(model: str) -> int | None:
|
||||
|
||||
def get_ltx2_post_process_func(od_config: Any):
|
||||
"""Build the common LTX engine-output adapter."""
|
||||
output_sample_rate = _detect_vocoder_output_sample_rate(od_config.model)
|
||||
output_sample_rate = _detect_vocoder_output_sample_rate(
|
||||
od_config.model,
|
||||
revision=getattr(od_config, "revision", None),
|
||||
)
|
||||
|
||||
def post_process_func(output: tuple[torch.Tensor, torch.Tensor] | torch.Tensor):
|
||||
if not (isinstance(output, tuple) and len(output) == 2):
|
||||
@@ -378,6 +518,7 @@ def _load_component(
|
||||
*,
|
||||
local_files_only: bool,
|
||||
dtype: torch.dtype,
|
||||
revision: str | None,
|
||||
) -> Any:
|
||||
return from_pretrained_with_prefetch(
|
||||
component_cls.from_pretrained,
|
||||
@@ -385,6 +526,7 @@ def _load_component(
|
||||
subfolder=subfolder,
|
||||
prefetch_list=_LTX_COMPONENT_SUBFOLDERS,
|
||||
local_files_only=local_files_only,
|
||||
revision=revision,
|
||||
torch_dtype=dtype,
|
||||
)
|
||||
|
||||
@@ -411,31 +553,36 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
|
||||
pipeline.device = get_local_device()
|
||||
dtype = getattr(od_config, "dtype", torch.bfloat16)
|
||||
model = od_config.model
|
||||
revision = getattr(od_config, "revision", None)
|
||||
local_files_only = os.path.exists(model)
|
||||
|
||||
pipeline.weights_sources = [
|
||||
DiffusersPipelineLoader.ComponentSource(
|
||||
model_or_path=model,
|
||||
subfolder="transformer",
|
||||
revision=None,
|
||||
subfolder=profile.transformer_subfolder,
|
||||
revision=revision,
|
||||
prefix="transformer.",
|
||||
fall_back_to_pt=True,
|
||||
),
|
||||
]
|
||||
prefetch_subfolders(model, _LTX_COMPONENT_SUBFOLDERS, local_files_only=local_files_only)
|
||||
prefetch_subfolders(model, _LTX_COMPONENT_SUBFOLDERS, local_files_only=local_files_only, revision=revision)
|
||||
|
||||
pipeline.tokenizer = AutoTokenizer.from_pretrained(
|
||||
model,
|
||||
subfolder="tokenizer",
|
||||
local_files_only=local_files_only,
|
||||
revision=revision,
|
||||
)
|
||||
if profile.text_encoder_cls is None:
|
||||
raise ImportError("LTX-2.5 requires Gemma4UnifiedForConditionalGeneration; install transformers>=5.10.1,<5.15.")
|
||||
with torch.device("cpu"):
|
||||
pipeline.text_encoder = _load_component(
|
||||
Gemma3ForConditionalGeneration,
|
||||
profile.text_encoder_cls,
|
||||
model,
|
||||
"text_encoder",
|
||||
local_files_only=local_files_only,
|
||||
dtype=dtype,
|
||||
revision=revision,
|
||||
)
|
||||
pipeline.connectors = _load_component(
|
||||
LTX2TextConnectors,
|
||||
@@ -443,14 +590,19 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
|
||||
"connectors",
|
||||
local_files_only=local_files_only,
|
||||
dtype=dtype,
|
||||
revision=revision,
|
||||
)
|
||||
_install_connector_attention(
|
||||
pipeline.connectors,
|
||||
preserve_learned_register_mask=profile.preserve_connector_attention_mask,
|
||||
)
|
||||
_install_connector_attention(pipeline.connectors)
|
||||
pipeline.vae = _load_component(
|
||||
profile.video_vae_cls,
|
||||
model,
|
||||
"vae",
|
||||
local_files_only=local_files_only,
|
||||
dtype=dtype,
|
||||
revision=revision,
|
||||
)
|
||||
pipeline.audio_vae = _load_component(
|
||||
AutoencoderKLLTX2Audio,
|
||||
@@ -458,6 +610,7 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
|
||||
"audio_vae",
|
||||
local_files_only=local_files_only,
|
||||
dtype=dtype,
|
||||
revision=revision,
|
||||
)
|
||||
try:
|
||||
pipeline.vocoder = _load_component(
|
||||
@@ -466,6 +619,7 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
|
||||
"vocoder",
|
||||
local_files_only=local_files_only,
|
||||
dtype=dtype,
|
||||
revision=revision,
|
||||
)
|
||||
except (TypeError, OSError, ValueError):
|
||||
if profile.vocoder_fallback_cls is None or profile.vocoder_fallback_cls is profile.vocoder_cls:
|
||||
@@ -476,6 +630,7 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
|
||||
"vocoder",
|
||||
local_files_only=local_files_only,
|
||||
dtype=dtype,
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
if "latent_upsampler" in profile.resident_modules:
|
||||
@@ -488,6 +643,7 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
|
||||
"latent_upsampler",
|
||||
local_files_only=local_files_only,
|
||||
dtype=dtype,
|
||||
revision=revision,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
if profile.latent_upsampler_filename is None or profile.artifact_repo_id is None:
|
||||
@@ -508,7 +664,9 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
|
||||
)
|
||||
pipeline.latent_upsampler = _load_ltx_latent_upsampler_single_file(upsampler_path, dtype)
|
||||
|
||||
transformer_config = load_transformer_config(model, "transformer", local_files_only)
|
||||
transformer_config = load_transformer_config(
|
||||
model, profile.transformer_subfolder, local_files_only, revision=revision
|
||||
)
|
||||
quant_config = getattr(od_config, "quantization_config", None)
|
||||
pipeline.transformer = create_transformer_from_config(transformer_config, quant_config=quant_config)
|
||||
_place_aux_components(pipeline)
|
||||
@@ -516,15 +674,17 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
|
||||
model,
|
||||
subfolder="scheduler",
|
||||
local_files_only=local_files_only,
|
||||
revision=revision,
|
||||
)
|
||||
expected_checkpoint_kind: LTXCheckpointKind | None = None
|
||||
if pipeline.pipeline_kind == "two_stage":
|
||||
expected_checkpoint_kind = "regular"
|
||||
elif pipeline.pipeline_kind == "distilled_two_stage":
|
||||
expected_checkpoint_kind = "distilled"
|
||||
if profile.scheduler_use_dynamic_shifting:
|
||||
pipeline.scheduler = FlowMatchEulerDiscreteScheduler.from_config(
|
||||
pipeline.scheduler.config,
|
||||
use_dynamic_shifting=True,
|
||||
shift_terminal=profile.scheduler_shift_terminal,
|
||||
)
|
||||
validate_ltx_checkpoint(
|
||||
pipeline.scheduler.config,
|
||||
expected_kind=expected_checkpoint_kind,
|
||||
expected_kind=resolve_ltx_checkpoint_kind(pipeline.pipeline_kind),
|
||||
pipeline_name=type(pipeline).__name__,
|
||||
)
|
||||
|
||||
@@ -553,6 +713,8 @@ def load_transformer_config(
|
||||
model_path: str,
|
||||
subfolder: str = "transformer",
|
||||
local_files_only: bool = True,
|
||||
*,
|
||||
revision: str | None = None,
|
||||
) -> dict:
|
||||
"""Load an LTX transformer config from a local model or the HF Hub."""
|
||||
if local_files_only:
|
||||
@@ -563,6 +725,7 @@ def load_transformer_config(
|
||||
config_path = hf_hub_download(
|
||||
repo_id=model_path,
|
||||
filename=f"{subfolder}/config.json",
|
||||
revision=revision,
|
||||
)
|
||||
with open(config_path) as config_file:
|
||||
return json.load(config_file)
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
@@ -52,19 +53,18 @@ def _preprocess_i2v_pil_images(
|
||||
*,
|
||||
height: int,
|
||||
width: int,
|
||||
crf: int = 0,
|
||||
device: torch.device | None = None,
|
||||
dtype: torch.dtype | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Match official LTX aspect-preserving resize and center crop."""
|
||||
"""Match the version-specific official LTX PIL preprocessing."""
|
||||
image_list = images if isinstance(images, list) else [images]
|
||||
processed = []
|
||||
for image in image_list:
|
||||
pixels = (
|
||||
torch.from_numpy(np.array(image.convert("RGB"), copy=True))
|
||||
.permute(2, 0, 1)
|
||||
.unsqueeze(0)
|
||||
.to(device=device, dtype=torch.float32)
|
||||
)
|
||||
image_array = np.array(image.convert("RGB"), copy=True)
|
||||
if crf:
|
||||
image_array = _apply_image_conditioning_crf(image_array, crf)
|
||||
pixels = torch.from_numpy(image_array).permute(2, 0, 1).unsqueeze(0).to(device=device, dtype=torch.float32)
|
||||
source_height, source_width = pixels.shape[-2:]
|
||||
scale = max(height / source_height, width / source_width)
|
||||
resized_height = math.ceil(source_height * scale)
|
||||
@@ -82,6 +82,47 @@ def _preprocess_i2v_pil_images(
|
||||
return torch.cat(processed, dim=0)
|
||||
|
||||
|
||||
def _apply_image_conditioning_crf(image: np.ndarray, crf: int) -> np.ndarray:
|
||||
"""Round-trip one RGB frame through H.264 to match LTX-2.5 training."""
|
||||
if image.dtype != np.uint8 or image.ndim != 3 or image.shape[-1] != 3:
|
||||
raise ValueError("LTX image-conditioning CRF expects a uint8 RGB image.")
|
||||
if min(image.shape[:2]) < 2:
|
||||
return image
|
||||
try:
|
||||
import av
|
||||
except ImportError as exc:
|
||||
raise ImportError("PyAV with a libx264 encoder is required for LTX-2.5 image conditioning (CRF 18).") from exc
|
||||
|
||||
with BytesIO() as output_file:
|
||||
container = av.open(output_file, "w", format="mp4")
|
||||
try:
|
||||
stream = container.add_stream("libx264", rate=1, options={"crf": str(crf), "preset": "veryfast"})
|
||||
height = image.shape[0] // 2 * 2
|
||||
width = image.shape[1] // 2 * 2
|
||||
stream.height = height
|
||||
stream.width = width
|
||||
frame = av.VideoFrame.from_ndarray(image[:height, :width], format="rgb24").reformat(format="yuv420p")
|
||||
container.mux(stream.encode(frame))
|
||||
container.mux(stream.encode())
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"The installed PyAV/FFmpeg build cannot encode libx264 video required "
|
||||
"for LTX-2.5 image conditioning (CRF 18)."
|
||||
) from exc
|
||||
finally:
|
||||
container.close()
|
||||
video_bytes = output_file.getvalue()
|
||||
|
||||
with BytesIO(video_bytes) as video_file:
|
||||
container = av.open(video_file)
|
||||
try:
|
||||
stream = next(stream for stream in container.streams if stream.type == "video")
|
||||
frame = next(container.decode(stream))
|
||||
finally:
|
||||
container.close()
|
||||
return frame.to_ndarray(format="rgb24")
|
||||
|
||||
|
||||
class LTXTextConditioningMixin:
|
||||
"""Shared Gemma encoding and text connector orchestration."""
|
||||
|
||||
@@ -106,13 +147,30 @@ class LTXTextConditioningMixin:
|
||||
if self.tokenizer.pad_token is None:
|
||||
self.tokenizer.pad_token = self.tokenizer.eos_token
|
||||
|
||||
text_inputs = self.tokenizer(
|
||||
[text.strip() for text in prompt],
|
||||
bos_token_id = self.tokenizer.bos_token_id
|
||||
if bos_token_id is None:
|
||||
raise ValueError("Tokenizer is missing bos_token_id; LTX prompt encoding requires a leading BOS.")
|
||||
|
||||
input_ids = []
|
||||
for text in prompt:
|
||||
encoded = self.tokenizer(
|
||||
text.strip(),
|
||||
padding=False,
|
||||
truncation=True,
|
||||
max_length=max_sequence_length,
|
||||
return_tensors=None,
|
||||
)
|
||||
ids = encoded.input_ids
|
||||
if not ids or ids[0] != bos_token_id:
|
||||
ids = [bos_token_id, *ids][:max_sequence_length]
|
||||
input_ids.append(ids)
|
||||
|
||||
text_inputs = self.tokenizer.pad(
|
||||
{"input_ids": input_ids},
|
||||
padding="max_length",
|
||||
max_length=max_sequence_length,
|
||||
truncation=True,
|
||||
add_special_tokens=True,
|
||||
return_tensors="pt",
|
||||
return_attention_mask=True,
|
||||
)
|
||||
text_input_ids = text_inputs.input_ids.to(device)
|
||||
prompt_attention_mask = text_inputs.attention_mask.to(device)
|
||||
@@ -121,7 +179,6 @@ class LTXTextConditioningMixin:
|
||||
attention_mask=prompt_attention_mask,
|
||||
output_hidden_states=True,
|
||||
).hidden_states
|
||||
|
||||
prompt_embeds = torch.stack(hidden_states, dim=-1).flatten(2, 3).to(dtype=dtype)
|
||||
prompt_embeds = _repeat_prompt_tensor_for_outputs(prompt_embeds, num_videos_per_prompt)
|
||||
prompt_attention_mask = prompt_attention_mask.view(batch_size, -1)
|
||||
@@ -501,6 +558,7 @@ class LTXI2VConditioningMixin:
|
||||
vae_temporal_compression_ratio=self.vae_temporal_compression_ratio,
|
||||
)
|
||||
mask_shape = (batch_size, 1, num_frames, height, width)
|
||||
is_ltx25 = getattr(self, "model_version", None) == "2.5"
|
||||
|
||||
if latents is not None:
|
||||
unpacked_latents = latents.ndim == 5
|
||||
@@ -518,6 +576,7 @@ class LTXI2VConditioningMixin:
|
||||
self.vae.latents_std,
|
||||
self.vae.config.scaling_factor,
|
||||
)
|
||||
clean_latents = latents
|
||||
if image is not None:
|
||||
image_latents = self._encode_i2v_image_latents(
|
||||
image,
|
||||
@@ -525,14 +584,19 @@ class LTXI2VConditioningMixin:
|
||||
generator=generator,
|
||||
dtype=dtype,
|
||||
)
|
||||
# Official distilled Stage 2 starts from the upsampled Stage 1
|
||||
# state, then reapplies the source image at final resolution.
|
||||
latents = self._replace_first_latent_frame(latents, image_latents)
|
||||
# Official Stage 2 starts from the upsampled Stage 1 state,
|
||||
# then reapplies the source image at final resolution.
|
||||
clean_latents = self._replace_first_latent_frame(clean_latents, image_latents)
|
||||
latents = latent_ops.pack_latents(
|
||||
latents,
|
||||
self.transformer_spatial_patch_size,
|
||||
self.transformer_temporal_patch_size,
|
||||
)
|
||||
clean_latents = latent_ops.pack_latents(
|
||||
clean_latents,
|
||||
self.transformer_spatial_patch_size,
|
||||
self.transformer_temporal_patch_size,
|
||||
)
|
||||
else:
|
||||
conditioning_mask = latents.new_zeros(mask_shape)
|
||||
conditioning_mask[:, :, 0] = 1.0
|
||||
@@ -548,14 +612,14 @@ class LTXI2VConditioningMixin:
|
||||
f" {latents.shape}, but the expected shape is {conditioning_mask.shape + (num_channels_latents,)}."
|
||||
)
|
||||
if unpacked_latents:
|
||||
# Official LTX patchifies the latent state before drawing noise.
|
||||
# Sampling the packed shape preserves its seed-to-output contract.
|
||||
latents = latent_ops.create_noised_state(
|
||||
latents = latent_ops.create_conditioned_noised_state(
|
||||
latents,
|
||||
noise_scale * (1 - conditioning_mask).unsqueeze(-1),
|
||||
clean_latents,
|
||||
(1 - conditioning_mask.float()).unsqueeze(-1),
|
||||
noise_scale,
|
||||
generator,
|
||||
)
|
||||
elif image is not None:
|
||||
elif not unpacked_latents and image is not None:
|
||||
image_latents = self._encode_i2v_image_latents(
|
||||
image,
|
||||
batch_size=batch_size,
|
||||
@@ -595,13 +659,27 @@ class LTXI2VConditioningMixin:
|
||||
self.transformer_spatial_patch_size,
|
||||
self.transformer_temporal_patch_size,
|
||||
)
|
||||
conditioning_mask = latent_ops.pack_latents(
|
||||
packed_conditioning_mask = latent_ops.pack_latents(
|
||||
conditioning_mask,
|
||||
self.transformer_spatial_patch_size,
|
||||
self.transformer_temporal_patch_size,
|
||||
).squeeze(-1)
|
||||
noise = randn_tensor(init_latents.shape, generator=generator, device=device, dtype=dtype)
|
||||
latents = init_latents * conditioning_mask.unsqueeze(-1) + noise * (1 - conditioning_mask).unsqueeze(-1)
|
||||
if is_ltx25:
|
||||
clean_latents = init_latents
|
||||
init_latents = torch.zeros_like(init_latents)
|
||||
latents = latent_ops.create_conditioned_noised_state(
|
||||
init_latents,
|
||||
clean_latents,
|
||||
(1 - packed_conditioning_mask.float()).unsqueeze(-1),
|
||||
noise_scale,
|
||||
generator=generator,
|
||||
)
|
||||
else:
|
||||
noise = randn_tensor(init_latents.shape, generator=generator, device=device, dtype=dtype)
|
||||
latents = init_latents * packed_conditioning_mask.unsqueeze(-1) + noise * (
|
||||
1 - packed_conditioning_mask
|
||||
).unsqueeze(-1)
|
||||
conditioning_mask = packed_conditioning_mask
|
||||
return latents, conditioning_mask
|
||||
|
||||
def _step_video_latents_i2v(
|
||||
@@ -665,6 +743,17 @@ class LTXI2VConditioningMixin:
|
||||
)
|
||||
|
||||
if image is not None:
|
||||
default_crf = 18 if getattr(self, "model_version", "2") == "2.5" else 0
|
||||
image_crf = getattr(request_inputs, "image_crf", None)
|
||||
image_crf = default_crf if image_crf is None else int(image_crf)
|
||||
is_pil_input = isinstance(image, PIL.Image.Image) or (
|
||||
isinstance(image, list) and image and all(isinstance(item, PIL.Image.Image) for item in image)
|
||||
)
|
||||
if image_crf != 0 and not is_pil_input:
|
||||
raise ValueError(
|
||||
"`image_crf` re-compression requires PIL images. Pass PIL images, or set `image_crf=0` "
|
||||
"to skip re-compression."
|
||||
)
|
||||
if isinstance(image, torch.Tensor):
|
||||
if image.ndim == 3:
|
||||
image = image.unsqueeze(0)
|
||||
@@ -677,6 +766,7 @@ class LTXI2VConditioningMixin:
|
||||
image,
|
||||
height=request_inputs.height,
|
||||
width=request_inputs.width,
|
||||
crf=image_crf,
|
||||
device=device,
|
||||
dtype=prompt_context.positive_connector_prompt_embeds.dtype,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ class LTXForwardContext:
|
||||
original_audio_num_frames: int
|
||||
padded_audio_num_frames: int
|
||||
timesteps: torch.Tensor
|
||||
sampler: str
|
||||
audio_scheduler: Any
|
||||
video_audio_step_adapter: Any
|
||||
|
||||
@@ -88,6 +89,11 @@ class LTXDenoisePipeline(Protocol):
|
||||
|
||||
LTXDenoiseStep = Callable[[int, torch.Tensor, LTXAVState], LTXAVState]
|
||||
|
||||
LTX_ANCESTRAL_ETA = 1.0
|
||||
LTX_ANCESTRAL_S_NOISE = 1.0
|
||||
LTX_ANCESTRAL_NOISE_SEED_OFFSET = 10000
|
||||
LTX_OFFICIAL_DEFAULT_SEED = 10
|
||||
|
||||
|
||||
class LTXDenoiseExecutor:
|
||||
"""Run the one shared LTX denoise loop.
|
||||
@@ -138,6 +144,9 @@ class LTXVideoAudioStepAdapter:
|
||||
latent_width: int,
|
||||
*,
|
||||
image_conditioned: bool,
|
||||
sampler: str = "euler",
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
conditioning_mask: torch.Tensor | None = None,
|
||||
) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._audio_scheduler = audio_scheduler
|
||||
@@ -145,11 +154,27 @@ class LTXVideoAudioStepAdapter:
|
||||
self._latent_height = latent_height
|
||||
self._latent_width = latent_width
|
||||
self._image_conditioned = image_conditioned
|
||||
self._sampler = sampler
|
||||
self._conditioning_mask = conditioning_mask
|
||||
self._ancestral_generators = (
|
||||
_make_ancestral_generators(generator, pipeline.device) if sampler == "euler_ancestral" else None
|
||||
)
|
||||
self._step_index = 0
|
||||
|
||||
def step(self, noise_pred, t, latents, return_dict=False, generator=None):
|
||||
del t, return_dict, generator
|
||||
if self._image_conditioned:
|
||||
if self._sampler == "euler_ancestral":
|
||||
video_out = _ancestral_euler_step_from_velocity(
|
||||
latents[0],
|
||||
noise_pred[0],
|
||||
self._pipeline.scheduler.sigmas,
|
||||
self._step_index,
|
||||
self._ancestral_generators,
|
||||
)
|
||||
if self._conditioning_mask is not None:
|
||||
mask = self._conditioning_mask.unsqueeze(-1).to(video_out.dtype)
|
||||
video_out = torch.lerp(video_out, latents[0], mask)
|
||||
elif self._image_conditioned:
|
||||
video_out = self._pipeline._step_video_latents_i2v(
|
||||
noise_pred[0],
|
||||
latents[0],
|
||||
@@ -165,17 +190,96 @@ class LTXVideoAudioStepAdapter:
|
||||
self._pipeline.scheduler.sigmas,
|
||||
self._step_index,
|
||||
)
|
||||
audio_out = euler_step_from_velocity(
|
||||
latents[1],
|
||||
noise_pred[1],
|
||||
self._audio_scheduler.sigmas,
|
||||
self._step_index,
|
||||
)
|
||||
if self._sampler == "euler_ancestral":
|
||||
audio_out = _ancestral_euler_step_from_velocity(
|
||||
latents[1],
|
||||
noise_pred[1],
|
||||
self._audio_scheduler.sigmas,
|
||||
self._step_index,
|
||||
self._ancestral_generators,
|
||||
)
|
||||
else:
|
||||
audio_out = euler_step_from_velocity(
|
||||
latents[1],
|
||||
noise_pred[1],
|
||||
self._audio_scheduler.sigmas,
|
||||
self._step_index,
|
||||
)
|
||||
self._step_index += 1
|
||||
return ((video_out, audio_out),)
|
||||
|
||||
|
||||
def _official_ltx_sigmas(scheduler: Any, steps: int, device: torch.device) -> torch.Tensor:
|
||||
def _make_ancestral_generators(
|
||||
generator: torch.Generator | list[torch.Generator] | None,
|
||||
device: torch.device,
|
||||
) -> torch.Generator | list[torch.Generator]:
|
||||
"""Create the official independent ``seed + 10000`` noise stream."""
|
||||
|
||||
def _offset(source: torch.Generator | None) -> torch.Generator:
|
||||
seed = LTX_OFFICIAL_DEFAULT_SEED if source is None else source.initial_seed()
|
||||
return torch.Generator(device=device).manual_seed(seed + LTX_ANCESTRAL_NOISE_SEED_OFFSET)
|
||||
|
||||
if isinstance(generator, list):
|
||||
return [_offset(item) for item in generator]
|
||||
return _offset(generator)
|
||||
|
||||
|
||||
def _randn_like_with_generators(
|
||||
sample: torch.Tensor,
|
||||
generators: torch.Generator | list[torch.Generator],
|
||||
) -> torch.Tensor:
|
||||
if not isinstance(generators, list):
|
||||
return torch.randn(sample.shape, generator=generators, dtype=sample.dtype, device=sample.device)
|
||||
if len(generators) != sample.shape[0]:
|
||||
raise ValueError(
|
||||
f"LTX ancestral sampling received {len(generators)} generators for batch size {sample.shape[0]}."
|
||||
)
|
||||
return torch.cat(
|
||||
[
|
||||
torch.randn((1, *sample.shape[1:]), generator=item, dtype=sample.dtype, device=sample.device)
|
||||
for item in generators
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _ancestral_euler_step_from_velocity(
|
||||
sample: torch.Tensor,
|
||||
velocity: torch.Tensor,
|
||||
sigmas: torch.Tensor,
|
||||
step_index: int,
|
||||
generators: torch.Generator | list[torch.Generator] | None,
|
||||
) -> torch.Tensor:
|
||||
"""Apply the official LTX-2.5 rectified-flow ancestral Euler step."""
|
||||
sigma = sigmas[step_index].to(torch.float32)
|
||||
sigma_next = sigmas[step_index + 1].to(torch.float32)
|
||||
# Official X0Model materializes the denoised prediction in the model
|
||||
# dtype before the ancestral step promotes it back to float32.
|
||||
denoised_model_dtype = (sample.float() - velocity.float() * sigma).to(sample.dtype)
|
||||
denoised = denoised_model_dtype.float()
|
||||
if sigma_next == 0:
|
||||
return denoised.to(sample.dtype)
|
||||
if generators is None:
|
||||
raise ValueError("LTX ancestral Euler sampling requires a noise generator.")
|
||||
|
||||
downstep_ratio = 1.0 + (sigma_next / sigma - 1.0) * LTX_ANCESTRAL_ETA
|
||||
sigma_down = sigma_next * downstep_ratio
|
||||
sigma_down_ratio = sigma_down / sigma
|
||||
x_next = sigma_down_ratio * sample.float() + (1.0 - sigma_down_ratio) * denoised
|
||||
|
||||
alpha_next = 1.0 - sigma_next
|
||||
alpha_down = 1.0 - sigma_down
|
||||
renoise_coeff = (sigma_next**2 - sigma_down**2 * alpha_next**2 / alpha_down**2).clamp(min=0).sqrt()
|
||||
noise = _randn_like_with_generators(sample, generators)
|
||||
x_next = (alpha_next / alpha_down) * x_next + noise.float() * LTX_ANCESTRAL_S_NOISE * renoise_coeff
|
||||
result = x_next.to(sample.dtype)
|
||||
return result
|
||||
|
||||
|
||||
def _official_ltx_sigmas(
|
||||
scheduler: Any,
|
||||
steps: int,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
"""Build the official LTX one-stage sigma schedule in torch fp32."""
|
||||
config = scheduler.config
|
||||
base_anchor = config.get("base_image_seq_len", 1024)
|
||||
@@ -185,7 +289,8 @@ def _official_ltx_sigmas(scheduler: Any, steps: int, device: torch.device) -> to
|
||||
|
||||
sigmas = torch.linspace(1.0, 0.0, steps + 1)
|
||||
slope = (max_shift - base_shift) / (max_anchor - base_anchor)
|
||||
# Official LTX2 one-stage intentionally uses the max sequence anchor, so the shift stays at max_shift.
|
||||
# Official LTX one-stage pipelines omit the latent when constructing
|
||||
# this schedule, so the shift stays at the max sequence anchor.
|
||||
sigma_shift = max_anchor * slope + (base_shift - slope * base_anchor)
|
||||
exp_shift = math.exp(sigma_shift)
|
||||
sigmas = torch.where(sigmas != 0, exp_shift / (exp_shift + (1 / sigmas - 1)), 0)
|
||||
@@ -224,6 +329,9 @@ def prepare_scheduler_stage(
|
||||
latent_width: int,
|
||||
use_official_sigma_schedule: bool,
|
||||
image_conditioned: bool = False,
|
||||
sampler: str = "euler",
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
conditioning_mask: torch.Tensor | None = None,
|
||||
) -> tuple[Any, Any, torch.Tensor]:
|
||||
if sigmas is not None and timesteps is not None:
|
||||
raise ValueError("Only one of `sigmas` or `timesteps` may be provided.")
|
||||
@@ -236,6 +344,9 @@ def prepare_scheduler_stage(
|
||||
latent_height,
|
||||
latent_width,
|
||||
image_conditioned=image_conditioned,
|
||||
sampler=sampler,
|
||||
generator=generator,
|
||||
conditioning_mask=conditioning_mask,
|
||||
)
|
||||
if sigmas is not None:
|
||||
scheduler_sigmas = torch.as_tensor(sigmas, dtype=torch.float32, device=device)
|
||||
@@ -302,6 +413,20 @@ def prepare_rope_coords_stage(
|
||||
return video_coords, audio_coords
|
||||
|
||||
|
||||
def _first_frame_keyframes_mask(reference: torch.Tensor, latent_num_frames: int) -> torch.Tensor:
|
||||
"""Mark the first causal latent frame, matching official LTX-2.5."""
|
||||
if latent_num_frames <= 0:
|
||||
raise ValueError(f"LTX latent frame count must be positive, got {latent_num_frames}.")
|
||||
tokens_per_frame, remainder = divmod(reference.shape[1], latent_num_frames)
|
||||
if remainder:
|
||||
raise ValueError(
|
||||
f"LTX video token count {reference.shape[1]} is not divisible by {latent_num_frames} latent frames."
|
||||
)
|
||||
mask = reference.new_zeros((reference.shape[0], reference.shape[1], 1))
|
||||
mask[:, :tokens_per_frame] = 1
|
||||
return mask
|
||||
|
||||
|
||||
def build_transformer_kwargs(
|
||||
pipeline: Any,
|
||||
forward_ctx: LTXForwardContext,
|
||||
@@ -317,9 +442,13 @@ def build_transformer_kwargs(
|
||||
attention_kwargs: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del encoder_attention_mask, audio_encoder_attention_mask
|
||||
keyframes_mask = None
|
||||
if getattr(pipeline.transformer.config, "use_keyframes_abs_pos_embedding", False):
|
||||
keyframes_mask = _first_frame_keyframes_mask(hidden_states, forward_ctx.latent_num_frames)
|
||||
return {
|
||||
"hidden_states": hidden_states,
|
||||
"audio_hidden_states": audio_hidden_states,
|
||||
"keyframes_mask": keyframes_mask,
|
||||
"encoder_hidden_states": encoder_hidden_states,
|
||||
"audio_encoder_hidden_states": audio_encoder_hidden_states,
|
||||
**pipeline._denoise_timestep_kwargs(
|
||||
@@ -426,6 +555,9 @@ class LTXPhaseExecutor:
|
||||
latent_width=latent_width,
|
||||
use_official_sigma_schedule=phase_recipe.use_official_sigma_schedule,
|
||||
image_conditioned=conditioning_mask is not None,
|
||||
sampler=phase_recipe.sampler,
|
||||
generator=request_inputs.generator,
|
||||
conditioning_mask=conditioning_mask,
|
||||
)
|
||||
forward_ctx = LTXForwardContext(
|
||||
req=req,
|
||||
@@ -441,6 +573,7 @@ class LTXPhaseExecutor:
|
||||
original_audio_num_frames=original_audio_num_frames,
|
||||
padded_audio_num_frames=padded_audio_num_frames,
|
||||
timesteps=timesteps_tensor,
|
||||
sampler=phase_recipe.sampler,
|
||||
audio_scheduler=audio_scheduler,
|
||||
video_audio_step_adapter=video_audio_step_adapter,
|
||||
)
|
||||
|
||||
@@ -126,9 +126,9 @@ def x0_from_velocity(sample: torch.Tensor, velocity: torch.Tensor, sigma: torch.
|
||||
|
||||
def velocity_from_x0(sample: torch.Tensor, x0: torch.Tensor, sigma: torch.Tensor) -> torch.Tensor:
|
||||
"""Convert x0 back to velocity using the official fp32 arithmetic order."""
|
||||
# Official LTX converts the scalar scheduler sigma to a host value before
|
||||
# division. A CUDA scalar tensor selects different kernel arithmetic and
|
||||
# can cross bf16 rounding boundaries late in the denoise trajectory.
|
||||
# Official LTX materializes the scalar schedule value on the host before
|
||||
# dividing. CUDA tensor-scalar division can cross bfloat16 rounding bounds
|
||||
# even when the scalar has the same float32 value.
|
||||
sigma = sigma.to(torch.float32).item()
|
||||
return ((sample.to(torch.float32) - x0.to(torch.float32)) / sigma).to(sample.dtype)
|
||||
|
||||
@@ -305,10 +305,17 @@ class LTXGuidanceExecutor:
|
||||
ts: torch.Tensor,
|
||||
video_token_count: int,
|
||||
audio_token_count: int,
|
||||
*,
|
||||
expand_for_sequence_parallel: bool = False,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
timestep = ts
|
||||
audio_timestep = ts
|
||||
if expand_for_sequence_parallel:
|
||||
timestep = ts.reshape(-1, 1).expand(-1, video_token_count)
|
||||
audio_timestep = ts.reshape(-1, 1).expand(-1, audio_token_count)
|
||||
return {
|
||||
"timestep": ts.reshape(-1, 1).expand(-1, video_token_count),
|
||||
"audio_timestep": ts.reshape(-1, 1).expand(-1, audio_token_count),
|
||||
"timestep": timestep,
|
||||
"audio_timestep": audio_timestep,
|
||||
"sigma": ts,
|
||||
"audio_sigma": ts,
|
||||
}
|
||||
@@ -342,8 +349,11 @@ class LTXGuidanceExecutor:
|
||||
guidance: LTXModalityGuidance,
|
||||
*,
|
||||
model_sigma: torch.Tensor | None = None,
|
||||
preserve_positive_velocity: bool = False,
|
||||
rescale_token_count: int | None = None,
|
||||
) -> torch.Tensor:
|
||||
if preserve_positive_velocity and tuple(splits) == ("cond",):
|
||||
return splits["cond"]
|
||||
model_sigma = sigma if model_sigma is None else model_sigma
|
||||
# Official guidance reduces contiguous BSC tensors. The fused
|
||||
# transformer output may be channel-major after splitting, which
|
||||
@@ -368,6 +378,7 @@ class LTXGuidanceExecutor:
|
||||
state: LTXAVState,
|
||||
forward_ctx: LTXForwardContext,
|
||||
denoise_ctx: LTXDenoiseContext,
|
||||
preserve_positive_velocity: bool = False,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
guidance_world_size = get_guidance_parallel_world_size()
|
||||
self.validate_guidance_world_size(plan, guidance_world_size)
|
||||
@@ -450,12 +461,14 @@ class LTXGuidanceExecutor:
|
||||
video_sigma,
|
||||
plan.spec.video,
|
||||
model_sigma=pipeline._video_guidance_model_sigma(video_sigma, denoise_ctx),
|
||||
preserve_positive_velocity=preserve_positive_velocity,
|
||||
),
|
||||
self._guide_modality(
|
||||
state.audio,
|
||||
audio_splits,
|
||||
forward_ctx.audio_scheduler.sigmas[index],
|
||||
plan.spec.audio,
|
||||
preserve_positive_velocity=preserve_positive_velocity,
|
||||
rescale_token_count=forward_ctx.original_audio_num_frames,
|
||||
),
|
||||
)
|
||||
@@ -469,9 +482,19 @@ class LTXGuidanceExecutor:
|
||||
state: LTXAVState,
|
||||
forward_ctx: LTXForwardContext,
|
||||
denoise_ctx: LTXDenoiseContext,
|
||||
preserve_positive_velocity: bool = False,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
if forward_ctx.guidance_parallel_ready:
|
||||
return self.predict_parallel_guidance(pipeline, plan, index, timestep, state, forward_ctx, denoise_ctx)
|
||||
return self.predict_parallel_guidance(
|
||||
pipeline,
|
||||
plan,
|
||||
index,
|
||||
timestep,
|
||||
state,
|
||||
forward_ctx,
|
||||
denoise_ctx,
|
||||
preserve_positive_velocity=preserve_positive_velocity,
|
||||
)
|
||||
|
||||
prompt = forward_ctx.prompt_context
|
||||
video_contexts: list[torch.Tensor] = []
|
||||
@@ -526,12 +549,14 @@ class LTXGuidanceExecutor:
|
||||
pipeline.scheduler.sigmas[index],
|
||||
denoise_ctx,
|
||||
),
|
||||
preserve_positive_velocity=preserve_positive_velocity,
|
||||
),
|
||||
self._guide_modality(
|
||||
state.audio,
|
||||
audio_splits,
|
||||
forward_ctx.audio_scheduler.sigmas[index],
|
||||
plan.spec.audio,
|
||||
preserve_positive_velocity=preserve_positive_velocity,
|
||||
rescale_token_count=forward_ctx.original_audio_num_frames,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -20,6 +20,11 @@ class LTXAVState:
|
||||
audio: torch.Tensor
|
||||
|
||||
|
||||
def official_video_token_layout(latents: torch.Tensor) -> torch.Tensor:
|
||||
"""Match the token-major view produced by the official LTX patchifier."""
|
||||
return latents.transpose(1, 2).contiguous().transpose(1, 2)
|
||||
|
||||
|
||||
def pack_latents(
|
||||
latents: torch.Tensor,
|
||||
patch_size: int = 1,
|
||||
@@ -117,9 +122,23 @@ def create_noised_state(
|
||||
device=latents.device,
|
||||
dtype=latents.dtype,
|
||||
)
|
||||
if isinstance(noise_scale, torch.Tensor):
|
||||
noise_scale = noise_scale.float()
|
||||
return torch.lerp(latents.float(), noise.float(), noise_scale).to(latents.dtype)
|
||||
# Official GaussianNoisier blends in fp32, then materializes the next
|
||||
# latent state in the model dtype. Batched request weights may arrive in
|
||||
# the model dtype, so promote tensor weights alongside both endpoints.
|
||||
lerp_weight = noise_scale.float() if isinstance(noise_scale, torch.Tensor) else noise_scale
|
||||
return torch.lerp(latents.float(), noise.float(), lerp_weight).to(latents.dtype)
|
||||
|
||||
|
||||
def create_conditioned_noised_state(
|
||||
latents: torch.Tensor,
|
||||
clean_latents: torch.Tensor,
|
||||
denoise_mask: torch.Tensor,
|
||||
noise_scale: float | torch.Tensor,
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Match the official conditioned Gaussian noisier operation order."""
|
||||
noised_latents = create_noised_state(latents, noise_scale, generator)
|
||||
return torch.lerp(clean_latents.float(), noised_latents.float(), denoise_mask.float()).to(latents.dtype)
|
||||
|
||||
|
||||
def pack_audio_latents(latents: torch.Tensor) -> torch.Tensor:
|
||||
@@ -138,7 +157,7 @@ def unpad_audio_latents(latents: torch.Tensor, num_frames: int) -> torch.Tensor:
|
||||
|
||||
|
||||
def clear_audio_padding(latents: torch.Tensor, num_frames: int) -> torch.Tensor:
|
||||
"""Keep Omni's SP-only audio padding outside the sampler state."""
|
||||
"""Keep SP-only audio padding outside the logical sampler state."""
|
||||
if not 0 < num_frames <= latents.shape[1]:
|
||||
raise ValueError(f"Audio frame count must be in [1, {latents.shape[1]}], got {num_frames}.")
|
||||
if num_frames == latents.shape[1]:
|
||||
@@ -196,7 +215,8 @@ def prepare_video_latents(
|
||||
)
|
||||
if latents.ndim != 3:
|
||||
raise ValueError(f"Provided `latents` has shape {latents.shape}, expected [batch, seq, features].")
|
||||
return create_noised_state(latents, noise_scale, generator).to(device=device, dtype=dtype)
|
||||
latents = create_noised_state(latents, noise_scale, generator).to(device=device, dtype=dtype)
|
||||
return official_video_token_layout(latents)
|
||||
|
||||
num_frames, height, width = resolve_video_latent_shape(
|
||||
height,
|
||||
@@ -218,11 +238,7 @@ def prepare_video_latents(
|
||||
f" size of {batch_size}. Make sure the batch size matches the length of the generators."
|
||||
)
|
||||
latents = randn_tensor(shape, generator=generator, device=device, dtype=dtype)
|
||||
# Official LTX samples the 5D video latent first and then patchifies it,
|
||||
# leaving token-major storage behind the [B, tokens, channels] view. The
|
||||
# logical values are unchanged, but preserving that layout is important:
|
||||
# CUDA selects a different bf16 GEMM path for a contiguous token tensor.
|
||||
return latents.transpose(1, 2).contiguous().transpose(1, 2)
|
||||
return official_video_token_layout(latents)
|
||||
|
||||
|
||||
def prepare_audio_latents(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
"""Declarative execution recipes for the LTX model family."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Literal
|
||||
|
||||
from .ltx2_guidance import LTXGuidanceSpec, LTXModalityGuidance
|
||||
@@ -22,6 +22,11 @@ LTX_DEFAULT_NEGATIVE_PROMPT = (
|
||||
"inconsistent tone, cinematic oversaturation, stylized filters, or AI artifacts."
|
||||
)
|
||||
|
||||
LTX25_DEFAULT_NEGATIVE_PROMPT = (
|
||||
"has_subtitles, has_blurbox, transition from black, transition to black, speech_ending_short, "
|
||||
+ LTX_DEFAULT_NEGATIVE_PROMPT
|
||||
)
|
||||
|
||||
LTX_DISTILLED_SIGMAS = (1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0)
|
||||
LTX_STAGE_2_DISTILLED_SIGMAS = (0.909375, 0.725, 0.421875, 0.0)
|
||||
LTX_DISTILLED_ADAPTER_SLOT = "ltx_distilled"
|
||||
@@ -38,6 +43,7 @@ class LTXPhaseRecipe:
|
||||
noise_scale: float = 0.0
|
||||
input_transform: Literal["initial", "spatial_upsample"] = "initial"
|
||||
adapter_slot: str | None = None
|
||||
sampler: Literal["euler", "euler_ancestral"] = "euler"
|
||||
allow_guidance_override: bool = True
|
||||
use_official_sigma_schedule: bool = True
|
||||
|
||||
@@ -46,6 +52,8 @@ class LTXPhaseRecipe:
|
||||
raise ValueError("LTX phase spatial_downscale must be positive.")
|
||||
if self.sigmas is not None and len(self.sigmas) < 2:
|
||||
raise ValueError("An explicit LTX sigma schedule must contain at least two denoise sigmas.")
|
||||
if self.sampler not in ("euler", "euler_ancestral"):
|
||||
raise ValueError(f"Unsupported LTX sampler: {self.sampler!r}.")
|
||||
|
||||
@property
|
||||
def num_inference_steps(self) -> int | None:
|
||||
@@ -66,9 +74,11 @@ class LTXPipelineRecipe:
|
||||
video_output_phase: int = -1
|
||||
audio_output_phase: int = -1
|
||||
allow_request_sigmas: bool = True
|
||||
allow_request_phase_sigmas: bool = False
|
||||
allow_request_latents: bool = True
|
||||
allow_negative_prompt: bool = True
|
||||
fixed_num_inference_steps: bool = False
|
||||
supports_cache_dit: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.phases:
|
||||
@@ -109,13 +119,29 @@ def _official_guidance(stg_block: int) -> LTXGuidanceSpec:
|
||||
|
||||
|
||||
LTX2_ONE_STAGE_RECIPE = LTXPipelineRecipe(
|
||||
supports_cache_dit=True,
|
||||
phases=(LTXPhaseRecipe(name="generate", guidance=_official_guidance(29)),),
|
||||
)
|
||||
LTX23_ONE_STAGE_RECIPE = LTXPipelineRecipe(
|
||||
supports_cache_dit=True,
|
||||
num_inference_steps=30,
|
||||
phases=(LTXPhaseRecipe(name="generate", guidance=_official_guidance(28)),),
|
||||
)
|
||||
LTX25_FULL_RECIPE = LTXPipelineRecipe(
|
||||
height=544,
|
||||
width=960,
|
||||
num_inference_steps=30,
|
||||
negative_prompt=LTX25_DEFAULT_NEGATIVE_PROMPT,
|
||||
phases=(
|
||||
LTXPhaseRecipe(
|
||||
name="generate",
|
||||
guidance=_official_guidance(28),
|
||||
noise_scale=1.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
LTX_POSITIVE_ONLY_RECIPE = LTXPipelineRecipe(
|
||||
supports_cache_dit=True,
|
||||
phases=(
|
||||
LTXPhaseRecipe(
|
||||
name="generate",
|
||||
@@ -159,6 +185,41 @@ LTX2_DISTILLED_TWO_STAGE_RECIPE = LTXPipelineRecipe(
|
||||
fixed_num_inference_steps=True,
|
||||
)
|
||||
|
||||
LTX25_DISTILLED_TWO_STAGE_RECIPE = LTXPipelineRecipe(
|
||||
height=1088,
|
||||
width=1920,
|
||||
num_inference_steps=len(LTX_DISTILLED_SIGMAS) - 1,
|
||||
negative_prompt="",
|
||||
phases=(
|
||||
LTXPhaseRecipe(
|
||||
name="generate_lowres",
|
||||
guidance=LTXGuidanceSpec.positive_only(),
|
||||
spatial_downscale=2,
|
||||
sigmas=LTX_DISTILLED_SIGMAS,
|
||||
noise_scale=1.0,
|
||||
allow_guidance_override=False,
|
||||
use_official_sigma_schedule=False,
|
||||
sampler="euler_ancestral",
|
||||
),
|
||||
LTXPhaseRecipe(
|
||||
name="refine",
|
||||
guidance=LTXGuidanceSpec.positive_only(),
|
||||
sigmas=LTX_STAGE_2_DISTILLED_SIGMAS,
|
||||
noise_scale=LTX_STAGE_2_DISTILLED_SIGMAS[0],
|
||||
input_transform="spatial_upsample",
|
||||
allow_guidance_override=False,
|
||||
use_official_sigma_schedule=False,
|
||||
),
|
||||
),
|
||||
video_output_phase=1,
|
||||
audio_output_phase=1,
|
||||
allow_request_sigmas=False,
|
||||
allow_request_phase_sigmas=True,
|
||||
allow_request_latents=False,
|
||||
allow_negative_prompt=False,
|
||||
fixed_num_inference_steps=True,
|
||||
)
|
||||
|
||||
# LTX-2.3 full-distilled uses the same official fixed 8 + 3 sigma schedules
|
||||
# and request contract. Its distinct component profile selects the 22B merged
|
||||
# distilled Transformer, BWE vocoder, and matching spatial upsampler; no
|
||||
@@ -166,6 +227,37 @@ LTX2_DISTILLED_TWO_STAGE_RECIPE = LTXPipelineRecipe(
|
||||
LTX23_DISTILLED_TWO_STAGE_RECIPE = LTX2_DISTILLED_TWO_STAGE_RECIPE
|
||||
|
||||
|
||||
def _distilled_one_stage_recipe(
|
||||
two_stage_recipe: LTXPipelineRecipe,
|
||||
*,
|
||||
supports_cache_dit: bool = True,
|
||||
) -> LTXPipelineRecipe:
|
||||
"""Run the merged-distilled checkpoint at its native Stage-1 resolution."""
|
||||
generate_phase = replace(
|
||||
two_stage_recipe.phases[0],
|
||||
name="generate",
|
||||
spatial_downscale=1,
|
||||
)
|
||||
return replace(
|
||||
two_stage_recipe,
|
||||
height=two_stage_recipe.height // 2,
|
||||
width=two_stage_recipe.width // 2,
|
||||
phases=(generate_phase,),
|
||||
video_output_phase=0,
|
||||
audio_output_phase=0,
|
||||
allow_request_phase_sigmas=False,
|
||||
supports_cache_dit=supports_cache_dit,
|
||||
)
|
||||
|
||||
|
||||
LTX2_DISTILLED_ONE_STAGE_RECIPE = _distilled_one_stage_recipe(LTX2_DISTILLED_TWO_STAGE_RECIPE)
|
||||
LTX23_DISTILLED_ONE_STAGE_RECIPE = _distilled_one_stage_recipe(LTX23_DISTILLED_TWO_STAGE_RECIPE)
|
||||
LTX25_DISTILLED_ONE_STAGE_RECIPE = _distilled_one_stage_recipe(
|
||||
LTX25_DISTILLED_TWO_STAGE_RECIPE,
|
||||
supports_cache_dit=False,
|
||||
)
|
||||
|
||||
|
||||
def _official_two_stage_recipe(one_stage_recipe: LTXPipelineRecipe) -> LTXPipelineRecipe:
|
||||
return LTXPipelineRecipe(
|
||||
height=one_stage_recipe.height * 2,
|
||||
@@ -197,21 +289,29 @@ def _official_two_stage_recipe(one_stage_recipe: LTXPipelineRecipe) -> LTXPipeli
|
||||
# discards its audio result. Decode the full-context Stage-1 audio.
|
||||
audio_output_phase=0,
|
||||
allow_request_sigmas=False,
|
||||
allow_request_phase_sigmas=True,
|
||||
allow_request_latents=False,
|
||||
)
|
||||
|
||||
|
||||
LTX2_TWO_STAGE_RECIPE = _official_two_stage_recipe(LTX2_ONE_STAGE_RECIPE)
|
||||
LTX23_TWO_STAGE_RECIPE = _official_two_stage_recipe(LTX23_ONE_STAGE_RECIPE)
|
||||
LTX25_TWO_STAGE_RECIPE = _official_two_stage_recipe(LTX25_FULL_RECIPE)
|
||||
|
||||
|
||||
_PIPELINE_RECIPES: dict[tuple[str, str], LTXPipelineRecipe] = {
|
||||
("one_stage", "2"): LTX2_ONE_STAGE_RECIPE,
|
||||
("one_stage", "2.3"): LTX23_ONE_STAGE_RECIPE,
|
||||
("one_stage", "2.5"): LTX25_FULL_RECIPE,
|
||||
("two_stage", "2"): LTX2_TWO_STAGE_RECIPE,
|
||||
("two_stage", "2.3"): LTX23_TWO_STAGE_RECIPE,
|
||||
("two_stage", "2.5"): LTX25_TWO_STAGE_RECIPE,
|
||||
("distilled_one_stage", "2"): LTX2_DISTILLED_ONE_STAGE_RECIPE,
|
||||
("distilled_one_stage", "2.3"): LTX23_DISTILLED_ONE_STAGE_RECIPE,
|
||||
("distilled_one_stage", "2.5"): LTX25_DISTILLED_ONE_STAGE_RECIPE,
|
||||
("distilled_two_stage", "2"): LTX2_DISTILLED_TWO_STAGE_RECIPE,
|
||||
("distilled_two_stage", "2.3"): LTX23_DISTILLED_TWO_STAGE_RECIPE,
|
||||
("distilled_two_stage", "2.5"): LTX25_DISTILLED_TWO_STAGE_RECIPE,
|
||||
("dmd2", "2"): LTX_POSITIVE_ONLY_RECIPE,
|
||||
("dmd2", "2.3"): LTX_POSITIVE_ONLY_RECIPE,
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class LTXRequestInputs:
|
||||
output_type: str
|
||||
max_sequence_length: int
|
||||
audio_latents_normalized: bool = False
|
||||
image_crf: int | None = None
|
||||
|
||||
@property
|
||||
def guidance_scale(self) -> float:
|
||||
@@ -86,6 +87,7 @@ def validate_pipeline_request(
|
||||
vae_temporal_compression_ratio: int,
|
||||
pipeline_name: str,
|
||||
request_sigmas: list[float] | None = None,
|
||||
request_phase_sigmas: tuple[list[float] | None, ...] | None = None,
|
||||
) -> None:
|
||||
"""Validate the request capabilities declared by an execution recipe."""
|
||||
alignment = vae_spatial_compression_ratio * pipeline_recipe.max_spatial_downscale
|
||||
@@ -108,9 +110,28 @@ def validate_pipeline_request(
|
||||
|
||||
if not pipeline_recipe.allow_request_sigmas and request_sigmas is not None:
|
||||
raise ValueError(f"{pipeline_name} uses fixed phase sigma schedules.")
|
||||
if request_sigmas is not None and len(request_sigmas) < 2:
|
||||
raise ValueError("A custom LTX sigma schedule must contain at least two values.")
|
||||
|
||||
if pipeline_recipe.fixed_num_inference_steps and (
|
||||
request_inputs.num_inference_steps != pipeline_recipe.num_inference_steps
|
||||
has_phase_sigmas = request_phase_sigmas is not None and any(item is not None for item in request_phase_sigmas)
|
||||
if request_sigmas is not None and has_phase_sigmas:
|
||||
raise ValueError("Use either `sigmas` or per-stage sigma schedules, not both.")
|
||||
if has_phase_sigmas and not pipeline_recipe.allow_request_phase_sigmas:
|
||||
raise ValueError(f"{pipeline_name} does not accept per-stage sigma schedules.")
|
||||
if request_phase_sigmas is not None and len(request_phase_sigmas) != len(pipeline_recipe.phases):
|
||||
raise ValueError(
|
||||
f"{pipeline_name} has {len(pipeline_recipe.phases)} phases but received "
|
||||
f"{len(request_phase_sigmas)} per-stage sigma schedules."
|
||||
)
|
||||
for index, phase_sigmas in enumerate(request_phase_sigmas or ()):
|
||||
if phase_sigmas is not None and len(phase_sigmas) < 2:
|
||||
raise ValueError(f"`stage_{index + 1}_sigmas` must contain at least two values.")
|
||||
|
||||
if (
|
||||
request_sigmas is None
|
||||
and (request_phase_sigmas is None or request_phase_sigmas[0] is None)
|
||||
and pipeline_recipe.fixed_num_inference_steps
|
||||
and (request_inputs.num_inference_steps != pipeline_recipe.num_inference_steps)
|
||||
):
|
||||
raise ValueError(
|
||||
f"{pipeline_name} uses {pipeline_recipe.num_inference_steps} fixed Stage 1 denoise steps; "
|
||||
@@ -165,6 +186,16 @@ def _get_extra_arg(sampling: Any, *names: str) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_image_crf(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError("`image_crf` must be an integer from 0 through 51.")
|
||||
if not 0 <= value <= 51:
|
||||
raise ValueError("`image_crf` must be an integer from 0 through 51.")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_stg_blocks(value: Any, default: tuple[int, ...]) -> tuple[int, ...]:
|
||||
if value is None:
|
||||
return default
|
||||
@@ -274,6 +305,7 @@ class LTXRequestMixin:
|
||||
req: DiffusionRequestBatch,
|
||||
*,
|
||||
image: Any | None = None,
|
||||
image_crf: int | None = None,
|
||||
prompt: str | list[str] | None = None,
|
||||
negative_prompt: str | list[str] | None = None,
|
||||
height: int | None = None,
|
||||
@@ -282,6 +314,8 @@ class LTXRequestMixin:
|
||||
frame_rate: float | None = None,
|
||||
num_inference_steps: int | None = None,
|
||||
sigmas: list[float] | None = None,
|
||||
stage_1_sigmas: list[float] | None = None,
|
||||
stage_2_sigmas: list[float] | None = None,
|
||||
timesteps: list[int] | None = None,
|
||||
guidance_scale: float | None = None,
|
||||
guidance_rescale: float | None = None,
|
||||
@@ -315,6 +349,7 @@ class LTXRequestMixin:
|
||||
return self._forward_request(
|
||||
req,
|
||||
image=image,
|
||||
image_crf=image_crf,
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
height=height,
|
||||
@@ -323,6 +358,8 @@ class LTXRequestMixin:
|
||||
frame_rate=frame_rate,
|
||||
num_inference_steps=num_inference_steps,
|
||||
sigmas=sigmas,
|
||||
stage_1_sigmas=stage_1_sigmas,
|
||||
stage_2_sigmas=stage_2_sigmas,
|
||||
guidance_scale=guidance_scale,
|
||||
num_videos_per_prompt=num_videos_per_prompt,
|
||||
generator=generator,
|
||||
@@ -343,12 +380,30 @@ class LTXRequestMixin:
|
||||
req: DiffusionRequestBatch,
|
||||
fallback: list[float] | None,
|
||||
) -> list[float] | None:
|
||||
request_sigmas = [sampling.sigmas for sampling in req.sampling_params_list]
|
||||
request_sigmas = [
|
||||
sampling.sigmas if sampling.sigmas is not None else _get_extra_arg(sampling, "sigmas")
|
||||
for sampling in req.sampling_params_list
|
||||
]
|
||||
first = request_sigmas[0] if request_sigmas else None
|
||||
if any(item != first for item in request_sigmas[1:]):
|
||||
raise ValueError("Batched LTX requests must use identical custom sigmas.")
|
||||
return first if first is not None else fallback
|
||||
|
||||
@staticmethod
|
||||
def _resolve_request_phase_sigmas(
|
||||
req: DiffusionRequestBatch,
|
||||
stage_1_fallback: list[float] | None,
|
||||
stage_2_fallback: list[float] | None,
|
||||
) -> tuple[list[float] | None, list[float] | None] | None:
|
||||
resolved: list[list[float] | None] = []
|
||||
for name, fallback in (("stage_1_sigmas", stage_1_fallback), ("stage_2_sigmas", stage_2_fallback)):
|
||||
values = [_get_extra_arg(sampling, name) for sampling in req.sampling_params_list]
|
||||
first = values[0] if values else None
|
||||
if any(item != first for item in values[1:]):
|
||||
raise ValueError(f"Batched LTX requests must use identical `{name}` values.")
|
||||
resolved.append(first if first is not None else fallback)
|
||||
return tuple(resolved) if any(item is not None for item in resolved) else None
|
||||
|
||||
def check_inputs(
|
||||
self,
|
||||
prompt: str | list[str] | None,
|
||||
@@ -408,6 +463,7 @@ class LTXRequestMixin:
|
||||
decode_noise_scale: float | list[float] | None,
|
||||
output_type: str,
|
||||
max_sequence_length: int | None,
|
||||
image_crf: int | None = None,
|
||||
) -> LTXRequestInputs:
|
||||
sampling_params_list = req.sampling_params_list
|
||||
for sampling in sampling_params_list:
|
||||
@@ -421,6 +477,12 @@ class LTXRequestMixin:
|
||||
if _get_extra_arg(sampling, "flow_shift") is not None:
|
||||
raise ValueError("LTX does not support `flow_shift`; use `sigmas` for a custom schedule.")
|
||||
|
||||
request_image_crfs = [_get_extra_arg(item, "image_crf") for item in sampling_params_list]
|
||||
first_image_crf = request_image_crfs[0] if request_image_crfs else None
|
||||
if any(item != first_image_crf for item in request_image_crfs[1:]):
|
||||
raise ValueError("Batched LTX requests must use identical `image_crf` values.")
|
||||
image_crf = _normalize_image_crf(first_image_crf if first_image_crf is not None else image_crf)
|
||||
|
||||
sampling = sampling_params_list[0]
|
||||
is_dummy_run = req.is_dummy_run()
|
||||
prompt = [item if isinstance(item, str) else (item.get("prompt") or "") for item in req.prompts] or prompt
|
||||
@@ -551,4 +613,5 @@ class LTXRequestMixin:
|
||||
decode_noise_scale=decode_noise_scale,
|
||||
output_type=output_type,
|
||||
max_sequence_length=int(max_sequence_length),
|
||||
image_crf=image_crf,
|
||||
)
|
||||
@@ -144,20 +144,19 @@ class LTXRuntime(
|
||||
f"{self.__class__.__name__} does not support ulysses_mode='advanced_uaa'. "
|
||||
"Use the default ulysses_mode='strict' for LTX sequence parallelism."
|
||||
)
|
||||
self.model_version = detect_ltx_model_version(od_config.model)
|
||||
self.model_version = detect_ltx_model_version(od_config.model, revision=getattr(od_config, "revision", None))
|
||||
self.component_profile = resolve_ltx_component_profile(self.pipeline_kind, self.model_version)
|
||||
self.pipeline_recipe = resolve_ltx_pipeline_recipe(self.pipeline_kind, self.model_version)
|
||||
if getattr(od_config, "cache_backend", "none") == "cache_dit" and len(self.pipeline_recipe.phases) > 1:
|
||||
if getattr(od_config, "cache_backend", "none") == "cache_dit" and not self.pipeline_recipe.supports_cache_dit:
|
||||
raise ValueError(
|
||||
f"{self.__class__.__name__} does not support cache_backend='cache_dit'. "
|
||||
"Cache-DiT currently supports only one-stage LTX recipes; multi-stage recipes require "
|
||||
"phase-aware cache enable and refresh."
|
||||
"Cache-DiT is not qualified for this LTX recipe."
|
||||
)
|
||||
self._dit_modules = list(self.component_profile.dit_modules)
|
||||
self._encoder_modules = list(self.component_profile.encoder_modules)
|
||||
self._vae_modules = list(self.component_profile.vae_modules)
|
||||
self._resident_modules = list(self.component_profile.resident_modules)
|
||||
if self.model_version == "2.3":
|
||||
if self.model_version in ("2.3", "2.5"):
|
||||
self.preserve_sp_padded_audio_duration = True
|
||||
self.reports_stage_durations = True
|
||||
super().__init__()
|
||||
@@ -173,6 +172,7 @@ class LTXRuntime(
|
||||
req: DiffusionRequestBatch,
|
||||
*,
|
||||
image: Any | None = None,
|
||||
image_crf: int | None = None,
|
||||
prompt: str | list[str] | None = None,
|
||||
negative_prompt: str | list[str] | None = None,
|
||||
height: int | None = None,
|
||||
@@ -181,6 +181,8 @@ class LTXRuntime(
|
||||
frame_rate: float | None = None,
|
||||
num_inference_steps: int | None = None,
|
||||
sigmas: list[float] | None = None,
|
||||
stage_1_sigmas: list[float] | None = None,
|
||||
stage_2_sigmas: list[float] | None = None,
|
||||
guidance_scale: float | None = None,
|
||||
num_videos_per_prompt: int | None = 1,
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
@@ -217,11 +219,13 @@ class LTXRuntime(
|
||||
decode_noise_scale=decode_noise_scale,
|
||||
output_type=output_type,
|
||||
max_sequence_length=max_sequence_length,
|
||||
image_crf=image_crf,
|
||||
)
|
||||
image = self._resolve_request_image(req, image, request_inputs)
|
||||
if image is not None and not self.support_image_input:
|
||||
raise ValueError(f"{self.__class__.__name__} does not support `image` input.")
|
||||
request_sigmas = self._resolve_request_sigmas(req, sigmas)
|
||||
request_phase_sigmas = self._resolve_request_phase_sigmas(req, stage_1_sigmas, stage_2_sigmas)
|
||||
validate_pipeline_request(
|
||||
request_inputs,
|
||||
pipeline_recipe=self.pipeline_recipe,
|
||||
@@ -229,6 +233,7 @@ class LTXRuntime(
|
||||
vae_temporal_compression_ratio=self.vae_temporal_compression_ratio,
|
||||
pipeline_name=self.__class__.__name__,
|
||||
request_sigmas=request_sigmas,
|
||||
request_phase_sigmas=request_phase_sigmas,
|
||||
)
|
||||
phase_adapter = getattr(self, "_phase_adapter", None)
|
||||
if phase_adapter is not None and any(
|
||||
@@ -237,7 +242,13 @@ class LTXRuntime(
|
||||
raise ValueError(
|
||||
f"{self.__class__.__name__} cannot compose a request LoRA with its internal phase adapter."
|
||||
)
|
||||
return self._run_recipe(req, request_inputs, request_sigmas=request_sigmas, image=image)
|
||||
return self._run_recipe(
|
||||
req,
|
||||
request_inputs,
|
||||
request_sigmas=request_sigmas,
|
||||
request_phase_sigmas=request_phase_sigmas,
|
||||
image=image,
|
||||
)
|
||||
|
||||
def _run_recipe(
|
||||
self,
|
||||
@@ -245,24 +256,39 @@ class LTXRuntime(
|
||||
request_inputs: LTXRequestInputs,
|
||||
*,
|
||||
request_sigmas: list[float] | None,
|
||||
request_phase_sigmas: tuple[list[float] | None, ...] | None = None,
|
||||
image: Any | None = None,
|
||||
) -> DiffusionOutput | list[DiffusionOutput]:
|
||||
"""Execute one- and multi-phase recipes through the same control flow."""
|
||||
phase_results: list[LTXPhaseResult] = []
|
||||
prompt_context = None
|
||||
|
||||
for phase_recipe in self.pipeline_recipe.phases:
|
||||
for phase_index, phase_recipe in enumerate(self.pipeline_recipe.phases):
|
||||
override_sigmas = None if request_phase_sigmas is None else request_phase_sigmas[phase_index]
|
||||
phase_sigmas = (
|
||||
override_sigmas
|
||||
if override_sigmas is not None
|
||||
else (
|
||||
request_sigmas
|
||||
if request_sigmas is not None
|
||||
else (list(phase_recipe.sigmas) if phase_recipe.sigmas is not None else None)
|
||||
)
|
||||
)
|
||||
self._enter_phase(phase_recipe)
|
||||
phase_inputs = self._build_phase_inputs(
|
||||
request_inputs,
|
||||
phase_recipe,
|
||||
phase_results[-1] if phase_results else None,
|
||||
)
|
||||
if phase_sigmas is not None:
|
||||
phase_inputs = replace(phase_inputs, num_inference_steps=len(phase_sigmas) - 1)
|
||||
noise_scale = phase_recipe.noise_scale
|
||||
if override_sigmas is not None and phase_recipe.input_transform == "spatial_upsample":
|
||||
noise_scale = float(override_sigmas[0])
|
||||
phase_result = self.run_phase(
|
||||
req,
|
||||
phase_inputs,
|
||||
noise_scale=phase_recipe.noise_scale,
|
||||
sigmas=list(phase_recipe.sigmas) if phase_recipe.sigmas is not None else request_sigmas,
|
||||
noise_scale=noise_scale,
|
||||
sigmas=phase_sigmas,
|
||||
timesteps=None,
|
||||
attention_kwargs=None,
|
||||
phase_recipe=phase_recipe,
|
||||
@@ -282,6 +308,7 @@ class LTXRuntime(
|
||||
|
||||
def _enter_phase(self, phase: LTXPhaseRecipe) -> None:
|
||||
self._active_phase_name = phase.name
|
||||
self._guidance_plan = LTXGuidancePlan.build(phase.guidance)
|
||||
phase_adapter = getattr(self, "_phase_adapter", None)
|
||||
if phase_adapter is None:
|
||||
if phase.adapter_slot is not None:
|
||||
@@ -645,7 +672,12 @@ class LTXRuntime(
|
||||
audio_token_count: int,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
del forward_ctx, denoise_ctx
|
||||
return self.guidance_executor.timestep_kwargs(ts, video_token_count, audio_token_count)
|
||||
return self.guidance_executor.timestep_kwargs(
|
||||
ts,
|
||||
video_token_count,
|
||||
audio_token_count,
|
||||
expand_for_sequence_parallel=True,
|
||||
)
|
||||
|
||||
def _video_guidance_model_sigma(
|
||||
self,
|
||||
@@ -700,6 +732,7 @@ class LTXRuntime(
|
||||
state,
|
||||
forward_ctx,
|
||||
denoise_ctx,
|
||||
preserve_positive_velocity=forward_ctx.sampler == "euler_ancestral",
|
||||
)
|
||||
|
||||
def _denoise_step(
|
||||
|
||||
@@ -55,6 +55,18 @@ logger = init_logger(__name__)
|
||||
_RMSNORM_INIT_PARAMS = inspect.signature(RMSNorm.__init__).parameters
|
||||
|
||||
|
||||
def apply_keyframes_absolute_embedding(
|
||||
hidden_states: torch.Tensor,
|
||||
keyframes_mask: torch.Tensor | None,
|
||||
embedding: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
"""Add the official learned marker to first-frame/keyframe tokens."""
|
||||
if keyframes_mask is None or embedding is None:
|
||||
return hidden_states
|
||||
mask = (keyframes_mask > 0).to(dtype=hidden_states.dtype)
|
||||
return hidden_states + mask * embedding.to(device=hidden_states.device, dtype=hidden_states.dtype)
|
||||
|
||||
|
||||
def _make_rms_norm(hidden_size: int, *, eps: float, elementwise_affine: bool) -> nn.Module:
|
||||
"""Bridge diffusers' RMSNorm API onto vLLM's `has_weight` variant."""
|
||||
if not elementwise_affine:
|
||||
@@ -82,15 +94,10 @@ def _make_rms_norm(hidden_size: int, *, eps: float, elementwise_affine: bool) ->
|
||||
|
||||
def apply_interleaved_rotary_emb(x: torch.Tensor, freqs: tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor:
|
||||
cos, sin = freqs
|
||||
if cos.dtype != x.dtype:
|
||||
cos = cos.to(dtype=x.dtype)
|
||||
if sin.dtype != x.dtype:
|
||||
sin = sin.to(dtype=x.dtype)
|
||||
# Concrete pair count instead of -1 keeps SDPA shape static under torch.compile.
|
||||
x_real, x_imag = x.unflatten(2, (x.shape[2] // 2, 2)).unbind(-1) # [B, S, C // 2]
|
||||
x_rotated = torch.stack([-x_imag, x_real], dim=-1).flatten(2)
|
||||
out = x * cos + x_rotated * sin
|
||||
return out
|
||||
return x * cos + x_rotated * sin
|
||||
|
||||
|
||||
def apply_split_rotary_emb(
|
||||
@@ -101,12 +108,6 @@ def apply_split_rotary_emb(
|
||||
) -> torch.Tensor:
|
||||
# `head_dim` is plumbed in (not inferred via `-1`) so SDPA shape stays static under torch.compile.
|
||||
cos, sin = freqs
|
||||
if cos.dtype != x.dtype:
|
||||
cos = cos.to(dtype=x.dtype)
|
||||
if sin.dtype != x.dtype:
|
||||
sin = sin.to(dtype=x.dtype)
|
||||
|
||||
x_dtype = x.dtype
|
||||
needs_reshape = False
|
||||
if x.ndim != 4 and cos.ndim == 4:
|
||||
# cos is (#b, h, t, r) -> reshape x to (b, h, t, dim_per_head)
|
||||
@@ -142,7 +143,6 @@ def apply_split_rotary_emb(
|
||||
if needs_reshape:
|
||||
out = out.swapaxes(1, 2).reshape(b, t, h * head_dim)
|
||||
|
||||
out = out.to(dtype=x_dtype)
|
||||
return out
|
||||
|
||||
|
||||
@@ -269,6 +269,7 @@ class LTX2FeedForward(nn.Module):
|
||||
RowParallelLinear(
|
||||
inner_dim,
|
||||
dim_out,
|
||||
bias=bias,
|
||||
input_is_parallel=True,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
@@ -517,8 +518,8 @@ class LTX2AudioVideoAttnProcessor:
|
||||
is_self_attention=is_self_attention,
|
||||
)
|
||||
|
||||
query = attn.norm_q(query)
|
||||
key = attn.norm_k(key)
|
||||
query = attn.norm_q(query).to(dtype=value.dtype)
|
||||
key = attn.norm_k(key).to(dtype=value.dtype)
|
||||
|
||||
if query_rotary_emb is not None:
|
||||
query_rotary_emb = self._slice_rope_for_tp(query_rotary_emb, attn)
|
||||
@@ -537,6 +538,10 @@ class LTX2AudioVideoAttnProcessor:
|
||||
head_dim=attn.head_dim,
|
||||
)
|
||||
|
||||
# Keep FP32 RoPE computation while satisfying attention dtype contracts.
|
||||
query = query.to(dtype=value.dtype)
|
||||
key = key.to(dtype=value.dtype)
|
||||
|
||||
query = query.unflatten(2, (attn.heads, attn.head_dim))
|
||||
key = key.unflatten(2, (attn.heads, attn.head_dim))
|
||||
value_heads = value.unflatten(2, (attn.heads, attn.head_dim))
|
||||
@@ -846,6 +851,8 @@ class LTX2VideoTransformerBlock(nn.Module):
|
||||
elementwise_affine: bool = False,
|
||||
rope_type: str = "interleaved",
|
||||
perturbed_attn: bool = False,
|
||||
ff_bias: bool = True,
|
||||
audio_ff_bias: bool = True,
|
||||
quant_config: "QuantizationConfig | None" = None,
|
||||
prefix: str = "",
|
||||
):
|
||||
@@ -965,6 +972,7 @@ class LTX2VideoTransformerBlock(nn.Module):
|
||||
self.ff = LTX2FeedForward(
|
||||
dim,
|
||||
activation_fn=activation_fn,
|
||||
bias=ff_bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.ff" if prefix else "ff",
|
||||
)
|
||||
@@ -973,6 +981,7 @@ class LTX2VideoTransformerBlock(nn.Module):
|
||||
self.audio_ff = LTX2FeedForward(
|
||||
audio_dim,
|
||||
activation_fn=activation_fn,
|
||||
bias=audio_ff_bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.audio_ff" if prefix else "audio_ff",
|
||||
)
|
||||
@@ -1078,21 +1087,34 @@ class LTX2VideoTransformerBlock(nn.Module):
|
||||
audio_hidden_states = audio_hidden_states + attn_audio_hidden_states * audio_gate_msa
|
||||
|
||||
# 2. Video and Audio Cross-Attention with text embeddings (Q: Video/Audio; K,V: Text)
|
||||
# LTX-2.3: compute prompt modulation params for K/V
|
||||
if self.cross_attn_adaln and temb_prompt is not None:
|
||||
video_prompt_ada_params = self.get_mod_params(self.prompt_scale_shift_table, temb_prompt, batch_size)
|
||||
shift_text_kv, scale_text_kv = video_prompt_ada_params
|
||||
if self.cross_attn_adaln:
|
||||
if temb_prompt is not None:
|
||||
shift_text_kv, scale_text_kv = self.get_mod_params(
|
||||
self.prompt_scale_shift_table, temb_prompt, batch_size
|
||||
)
|
||||
else:
|
||||
shift_text_kv, scale_text_kv = (
|
||||
self.prompt_scale_shift_table[None, None]
|
||||
.to(device=hidden_states.device, dtype=hidden_states.dtype)
|
||||
.unbind(dim=2)
|
||||
)
|
||||
|
||||
audio_prompt_ada_params = self.get_mod_params(
|
||||
self.audio_prompt_scale_shift_table, temb_prompt_audio, batch_size
|
||||
)
|
||||
audio_shift_text_kv, audio_scale_text_kv = audio_prompt_ada_params
|
||||
if temb_prompt_audio is not None:
|
||||
audio_shift_text_kv, audio_scale_text_kv = self.get_mod_params(
|
||||
self.audio_prompt_scale_shift_table, temb_prompt_audio, batch_size
|
||||
)
|
||||
else:
|
||||
audio_shift_text_kv, audio_scale_text_kv = (
|
||||
self.audio_prompt_scale_shift_table[None, None]
|
||||
.to(device=audio_hidden_states.device, dtype=audio_hidden_states.dtype)
|
||||
.unbind(dim=2)
|
||||
)
|
||||
|
||||
# 2.1. Video-Text Cross-Attention
|
||||
norm_hidden_states = self.norm2(hidden_states)
|
||||
if self.video_cross_attn_adaln:
|
||||
norm_hidden_states = norm_hidden_states * (1 + scale_text_q) + shift_text_q
|
||||
if self.cross_attn_adaln and temb_prompt is not None:
|
||||
if self.cross_attn_adaln:
|
||||
encoder_hidden_states = encoder_hidden_states * (1 + scale_text_kv) + shift_text_kv
|
||||
|
||||
attn_hidden_states = self.attn2(
|
||||
@@ -1109,7 +1131,7 @@ class LTX2VideoTransformerBlock(nn.Module):
|
||||
norm_audio_hidden_states = self.audio_norm2(audio_hidden_states)
|
||||
if self.audio_cross_attn_adaln:
|
||||
norm_audio_hidden_states = norm_audio_hidden_states * (1 + audio_scale_text_q) + audio_shift_text_q
|
||||
if self.cross_attn_adaln and temb_prompt is not None:
|
||||
if self.cross_attn_adaln:
|
||||
audio_encoder_hidden_states = audio_encoder_hidden_states * (1 + audio_scale_text_kv) + audio_shift_text_kv
|
||||
|
||||
attn_audio_hidden_states = self.audio_attn2(
|
||||
@@ -1508,7 +1530,10 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
block_forward_patterns={
|
||||
"transformer_blocks": ForwardPattern.Pattern_0,
|
||||
},
|
||||
has_separate_cfg=True,
|
||||
# LTX fuses all guidance passes into the batch dimension and invokes
|
||||
# the transformer once per denoise step. Cache-DiT must therefore
|
||||
# advance its step counter for every transformer forward.
|
||||
has_separate_cfg=False,
|
||||
check_forward_pattern=False,
|
||||
)
|
||||
|
||||
@@ -1546,6 +1571,7 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
# Shard video/audio latents across sequence
|
||||
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False),
|
||||
"audio_hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False),
|
||||
"keyframes_mask": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False),
|
||||
# Shard prompt embeds across sequence
|
||||
"encoder_hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False),
|
||||
"audio_encoder_hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False),
|
||||
@@ -1620,6 +1646,10 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
cross_attn_mod: bool = False,
|
||||
audio_gated_attn: bool = False,
|
||||
audio_cross_attn_mod: bool = False,
|
||||
ff_bias: bool = True,
|
||||
audio_ff_bias: bool = True,
|
||||
use_prompt_adaln_single: bool = True,
|
||||
use_keyframes_abs_pos_embedding: bool = False,
|
||||
quant_config: "QuantizationConfig | None" = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -1666,12 +1696,19 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
timestep_scale_multiplier=timestep_scale_multiplier,
|
||||
cross_attn_timestep_scale_multiplier=cross_attn_timestep_scale_multiplier,
|
||||
rope_type=rope_type,
|
||||
ff_bias=ff_bias,
|
||||
audio_ff_bias=audio_ff_bias,
|
||||
use_prompt_adaln_single=use_prompt_adaln_single,
|
||||
use_keyframes_abs_pos_embedding=use_keyframes_abs_pos_embedding,
|
||||
)
|
||||
|
||||
# 1. Patchification input projections
|
||||
self.proj_in = nn.Linear(in_channels, inner_dim)
|
||||
self.audio_proj_in = nn.Linear(audio_in_channels, audio_inner_dim)
|
||||
|
||||
if use_keyframes_abs_pos_embedding:
|
||||
self.keyframes_abs_pos_embedding = nn.Parameter(torch.zeros(1, inner_dim))
|
||||
|
||||
# 2. Prompt embeddings
|
||||
# LTX-2 (use_prompt_embeddings=True): caption projection in the transformer
|
||||
# LTX-2.3 (use_prompt_embeddings=False): caption projection in the connectors
|
||||
@@ -1695,7 +1732,7 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
|
||||
# 3.3. LTX-2.3: Prompt modulation from sigma
|
||||
self.prompt_modulation = cross_attn_mod or audio_cross_attn_mod
|
||||
if self.prompt_modulation:
|
||||
if self.prompt_modulation and use_prompt_adaln_single:
|
||||
self.prompt_adaln = LTX2AdaLayerNormSingle(inner_dim, num_mod_params=2, use_additional_conditions=False)
|
||||
self.audio_prompt_adaln = LTX2AdaLayerNormSingle(
|
||||
audio_inner_dim, num_mod_params=2, use_additional_conditions=False
|
||||
@@ -1815,6 +1852,8 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
elementwise_affine=norm_elementwise_affine,
|
||||
rope_type=rope_type,
|
||||
perturbed_attn=perturbed_attn,
|
||||
ff_bias=ff_bias,
|
||||
audio_ff_bias=audio_ff_bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"transformer_blocks.{layer_idx}",
|
||||
)
|
||||
@@ -1852,6 +1891,7 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
audio_encoder_hidden_states: torch.Tensor,
|
||||
timestep: torch.LongTensor,
|
||||
audio_timestep: torch.LongTensor | None = None,
|
||||
keyframes_mask: torch.Tensor | None = None,
|
||||
sigma: torch.Tensor | None = None,
|
||||
audio_sigma: torch.Tensor | None = None,
|
||||
encoder_attention_mask: torch.Tensor | None = None,
|
||||
@@ -1952,9 +1992,7 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
|
||||
video_rotary_emb = self.rope(video_coords, device=hidden_states.device, out_dtype=hidden_states.dtype)
|
||||
audio_rotary_emb = self.audio_rope(
|
||||
audio_coords,
|
||||
device=audio_hidden_states.device,
|
||||
out_dtype=audio_hidden_states.dtype,
|
||||
audio_coords, device=audio_hidden_states.device, out_dtype=audio_hidden_states.dtype
|
||||
)
|
||||
|
||||
video_cross_attn_rotary_emb = self.cross_attn_rope(
|
||||
@@ -1970,6 +2008,11 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
|
||||
# 2. Patchify input projections
|
||||
hidden_states = self.proj_in(hidden_states)
|
||||
hidden_states = apply_keyframes_absolute_embedding(
|
||||
hidden_states,
|
||||
keyframes_mask,
|
||||
getattr(self, "keyframes_abs_pos_embedding", None),
|
||||
)
|
||||
audio_hidden_states = self.audio_proj_in(audio_hidden_states)
|
||||
|
||||
# 3. Prepare timestep embeddings and modulation parameters
|
||||
@@ -2028,7 +2071,7 @@ class LTX2VideoTransformer3DModel(nn.Module):
|
||||
audio_cross_attn_v2a_gate = audio_cross_attn_v2a_gate.view(batch_size, -1, audio_cross_attn_v2a_gate.shape[-1])
|
||||
|
||||
# 3.3. LTX-2.3: Compute prompt modulation from sigma
|
||||
if self.prompt_modulation and sigma is not None:
|
||||
if self.prompt_modulation and self.config.use_prompt_adaln_single and sigma is not None:
|
||||
temb_prompt, _ = self.prompt_adaln(sigma.flatten(), batch_size=batch_size, hidden_dtype=hidden_states.dtype)
|
||||
temb_prompt_audio, _ = self.audio_prompt_adaln(
|
||||
audio_sigma.flatten(), batch_size=batch_size, hidden_dtype=audio_hidden_states.dtype
|
||||
|
||||
@@ -10,15 +10,12 @@ from typing import ClassVar
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.dmd2 import DMD2PipelineMixin
|
||||
|
||||
from .ltx2_components import LTX2_COMPONENT_PROFILE
|
||||
from .ltx2_components import LTX2_COMPONENT_PROFILE, LTX2_DISTILLED_ONE_STAGE_COMPONENT_PROFILE
|
||||
from .ltx2_components import (
|
||||
get_ltx2_post_process_func as get_ltx2_post_process_func, # noqa: F401
|
||||
)
|
||||
from .ltx2_conditioning import LTXI2VConditioningMixin
|
||||
from .ltx2_recipes import (
|
||||
LTX2_ONE_STAGE_RECIPE,
|
||||
LTX_POSITIVE_ONLY_RECIPE,
|
||||
)
|
||||
from .ltx2_recipes import LTX2_DISTILLED_ONE_STAGE_RECIPE, LTX2_ONE_STAGE_RECIPE, LTX_POSITIVE_ONLY_RECIPE
|
||||
from .ltx2_runtime import LTXRuntime
|
||||
|
||||
|
||||
@@ -36,6 +33,14 @@ class LTX2Pipeline(LTXI2VConditioningMixin, LTXRuntime):
|
||||
supports_request_batch = True
|
||||
|
||||
|
||||
class LTX2DistilledOneStagePipeline(LTX2Pipeline):
|
||||
"""Merged-distilled checkpoint at its native one-stage resolution."""
|
||||
|
||||
pipeline_kind = "distilled_one_stage"
|
||||
component_profile = LTX2_DISTILLED_ONE_STAGE_COMPONENT_PROFILE
|
||||
pipeline_recipe = LTX2_DISTILLED_ONE_STAGE_RECIPE
|
||||
|
||||
|
||||
class LTX2T2VDMD2Pipeline(DMD2PipelineMixin, LTX2Pipeline):
|
||||
"""LTX2 T2V entry for FastGen DMD2-distilled models."""
|
||||
|
||||
|
||||
@@ -40,9 +40,13 @@ class LTX2TwoStagePipeline(_LTX2TwoStageBase):
|
||||
pipeline_recipe = LTX2_TWO_STAGE_RECIPE
|
||||
|
||||
|
||||
class LTX2DistilledPipeline(_LTX2TwoStageBase):
|
||||
"""Unified LTX-2/LTX-2.3 full-distilled two-stage T2V/I2V entry."""
|
||||
class LTX2DistilledTwoStagePipeline(_LTX2TwoStageBase):
|
||||
"""Merged-distilled checkpoint with latent upsampling and refinement."""
|
||||
|
||||
pipeline_kind = "distilled_two_stage"
|
||||
component_profile = LTX2_DISTILLED_COMPONENT_PROFILE
|
||||
pipeline_recipe = LTX2_DISTILLED_TWO_STAGE_RECIPE
|
||||
|
||||
|
||||
# Backwards-compatible name retained for existing LTX-2/LTX-2.3 recipes.
|
||||
LTX2DistilledPipeline = LTX2DistilledTwoStagePipeline
|
||||
@@ -76,6 +76,16 @@ _DIFFUSION_MODELS = {
|
||||
"pipeline_ltx2_two_stage",
|
||||
"LTX2TwoStagePipeline",
|
||||
),
|
||||
"LTX2DistilledOneStagePipeline": (
|
||||
"ltx2",
|
||||
"pipeline_ltx2",
|
||||
"LTX2DistilledOneStagePipeline",
|
||||
),
|
||||
"LTX2DistilledTwoStagePipeline": (
|
||||
"ltx2",
|
||||
"pipeline_ltx2_two_stage",
|
||||
"LTX2DistilledTwoStagePipeline",
|
||||
),
|
||||
"LTX2DistilledPipeline": (
|
||||
"ltx2",
|
||||
"pipeline_ltx2_two_stage",
|
||||
@@ -522,6 +532,8 @@ _DIFFUSION_POST_PROCESS_FUNCS = {
|
||||
"WanVACEPipeline": "get_wan22_vace_post_process_func",
|
||||
"LTX2Pipeline": "get_ltx2_post_process_func",
|
||||
"LTX2TwoStagePipeline": "get_ltx2_post_process_func",
|
||||
"LTX2DistilledOneStagePipeline": "get_ltx2_post_process_func",
|
||||
"LTX2DistilledTwoStagePipeline": "get_ltx2_post_process_func",
|
||||
"LTX2DistilledPipeline": "get_ltx2_post_process_func",
|
||||
"LTX2T2VDMD2Pipeline": "get_ltx2_post_process_func",
|
||||
"LTX2I2VDMD2Pipeline": "get_ltx2_post_process_func",
|
||||
|
||||
@@ -7,6 +7,7 @@ import copy
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from http import HTTPStatus
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -30,6 +31,7 @@ from vllm_omni.entrypoints.openai.stage_params import (
|
||||
from vllm_omni.entrypoints.openai.utils import get_stage_type, parse_lora_request
|
||||
from vllm_omni.entrypoints.openai.video_api_utils import _encode_video_bytes, encode_video_base64
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
|
||||
from vllm_omni.model_extras import should_preserve_reference_image_size
|
||||
from vllm_omni.outputs.output_metadata import (
|
||||
DiffusionMetadataMapping,
|
||||
DiffusionMultimodalOutput,
|
||||
@@ -100,6 +102,21 @@ class OmniOpenAIServingVideo:
|
||||
if self._stage_configs is None and stage_configs is not None:
|
||||
self._stage_configs = stage_configs
|
||||
|
||||
@cached_property
|
||||
def preserves_reference_image_size(self) -> bool:
|
||||
"""Return whether the active pipeline owns reference-image resizing."""
|
||||
get_od_config = getattr(self._engine_client, "get_diffusion_od_config", None)
|
||||
od_config = get_od_config() if callable(get_od_config) else getattr(self._engine_client, "od_config", None)
|
||||
model_class_name = None if od_config is None else getattr(od_config, "model_class_name", None)
|
||||
model = getattr(od_config, "model", None) if od_config is not None else None
|
||||
model = model or self.model_name
|
||||
revision = getattr(od_config, "revision", None) if od_config is not None else None
|
||||
return should_preserve_reference_image_size(
|
||||
model_class_name,
|
||||
model=None if model is None else str(model),
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_mixed_reference_inputs(self) -> bool:
|
||||
"""Return whether the configured diffusion model accepts mixed refs."""
|
||||
@@ -154,7 +171,12 @@ class OmniOpenAIServingVideo:
|
||||
provided_fields = request.model_fields_set
|
||||
fps_provided = self._request_fps_provided(request)
|
||||
vp = request.resolve_video_params()
|
||||
if input_image is not None and vp.width is not None and vp.height is not None:
|
||||
if (
|
||||
input_image is not None
|
||||
and vp.width is not None
|
||||
and vp.height is not None
|
||||
and not self.preserves_reference_image_size
|
||||
):
|
||||
target_size = (vp.width, vp.height)
|
||||
image_items = input_image if isinstance(input_image, list) else [input_image]
|
||||
resized_images = [
|
||||
|
||||
@@ -10,8 +10,10 @@ from vllm_omni.model_extras.registry import (
|
||||
get_extra_output_params,
|
||||
get_model_class_name,
|
||||
get_output_tensor_range,
|
||||
get_transformer_config_subfolder,
|
||||
get_x_to_text_model_family,
|
||||
should_init_extra_args_for_non_diffusion_stages,
|
||||
should_preserve_reference_image_size,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -23,6 +25,8 @@ __all__ = [
|
||||
"get_extra_output_params",
|
||||
"get_model_class_name",
|
||||
"get_output_tensor_range",
|
||||
"get_transformer_config_subfolder",
|
||||
"get_x_to_text_model_family",
|
||||
"should_init_extra_args_for_non_diffusion_stages",
|
||||
"should_preserve_reference_image_size",
|
||||
]
|
||||
@@ -21,7 +21,35 @@ LTX_EXTRA_BODY_PARAMS = frozenset(
|
||||
"audio_rescale_scale",
|
||||
"video_stg_blocks",
|
||||
"audio_stg_blocks",
|
||||
"sigmas",
|
||||
"stage_1_sigmas",
|
||||
"stage_2_sigmas",
|
||||
"image_crf",
|
||||
}
|
||||
)
|
||||
|
||||
LTX_EXTRA_OUTPUT_PARAMS = frozenset()
|
||||
|
||||
|
||||
def ltx_preserves_reference_image_size(*, model: str | None, revision: str | None = None) -> bool:
|
||||
"""Keep source geometry until LTX-2.5 applies conditioning compression."""
|
||||
if model is None:
|
||||
return False
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_components import (
|
||||
preserves_reference_image_size as ltx_preserves_reference_image_size,
|
||||
)
|
||||
|
||||
return ltx_preserves_reference_image_size(model=model, revision=revision)
|
||||
|
||||
|
||||
def ltx_transformer_config_subfolder(
|
||||
*,
|
||||
model: str | None,
|
||||
revision: str | None = None,
|
||||
) -> str:
|
||||
"""Select the transformer config used by the requested LTX checkpoint."""
|
||||
if model is None:
|
||||
return "transformer"
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_components import detect_ltx_model_version
|
||||
|
||||
return "transformer_full" if detect_ltx_model_version(model, revision=revision) == "2.5" else "transformer"
|
||||
@@ -4,7 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
from PIL import Image
|
||||
|
||||
@@ -34,7 +34,12 @@ from vllm_omni.model_extras.helios import (
|
||||
)
|
||||
from vllm_omni.model_extras.hunyuan_image3 import build_x_to_text_prompt as build_hunyuan_x_to_text_prompt
|
||||
from vllm_omni.model_extras.lingbot_video import LINGBOT_VIDEO_EXTRA_BODY_PARAMS
|
||||
from vllm_omni.model_extras.ltx2 import LTX_EXTRA_BODY_PARAMS, LTX_EXTRA_OUTPUT_PARAMS
|
||||
from vllm_omni.model_extras.ltx2 import (
|
||||
LTX_EXTRA_BODY_PARAMS,
|
||||
LTX_EXTRA_OUTPUT_PARAMS,
|
||||
ltx_preserves_reference_image_size,
|
||||
ltx_transformer_config_subfolder,
|
||||
)
|
||||
from vllm_omni.model_extras.magi_human import (
|
||||
MAGI_HUMAN_EXTRA_BODY_PARAMS,
|
||||
MAGI_HUMAN_EXTRA_OUTPUT_PARAMS,
|
||||
@@ -96,6 +101,24 @@ XToTextPromptBuilder = Callable[[str, str, bool], tuple[dict[str, Any], list[int
|
||||
OutputTensorRange = Literal["negative_one_to_one", "zero_to_one"]
|
||||
|
||||
|
||||
class ReferenceImageSizeResolver(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
model: str | None,
|
||||
revision: str | None = None,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
class TransformerConfigSubfolderResolver(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
model: str | None,
|
||||
revision: str | None = None,
|
||||
) -> str: ...
|
||||
|
||||
|
||||
def default_x_to_text_prompt(
|
||||
model: str,
|
||||
prompt: str,
|
||||
@@ -202,11 +225,14 @@ _EXTRA_SPECS: dict[str, dict[str, Any]] = {
|
||||
model_class_name: {
|
||||
"extra_body_params": LTX_EXTRA_BODY_PARAMS,
|
||||
"extra_output_params": LTX_EXTRA_OUTPUT_PARAMS,
|
||||
"reference_image_size_resolver": ltx_preserves_reference_image_size,
|
||||
}
|
||||
for model_class_name in (
|
||||
"LTX2Pipeline",
|
||||
"LTX2TwoStagePipeline",
|
||||
"LTX2DistilledOneStagePipeline",
|
||||
"LTX2DistilledPipeline",
|
||||
"LTX2DistilledTwoStagePipeline",
|
||||
)
|
||||
},
|
||||
"WanVACEPipeline": {
|
||||
@@ -229,6 +255,10 @@ _EXTRA_SPECS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
}
|
||||
|
||||
for model_class_name in ("LTX2Pipeline", "LTX2TwoStagePipeline"):
|
||||
_EXTRA_SPECS[model_class_name]["transformer_config_subfolder_resolver"] = ltx_transformer_config_subfolder
|
||||
|
||||
|
||||
# Multi-stage discovery reports the top-level wrapper rather than its DiT
|
||||
# submodule, so both names must resolve to the same request builders.
|
||||
_EXTRA_SPECS["MammothModa2ForConditionalGeneration"] = _EXTRA_SPECS["MammothModa2DiTPipeline"]
|
||||
@@ -277,6 +307,36 @@ def get_output_tensor_range(model_class_name: str | None) -> OutputTensorRange:
|
||||
return spec.get("output_tensor_range", "negative_one_to_one")
|
||||
|
||||
|
||||
def get_transformer_config_subfolder(
|
||||
model_class_name: str | None,
|
||||
*,
|
||||
model: str | None,
|
||||
revision: str | None = None,
|
||||
) -> str:
|
||||
"""Return the model-declared DiT config subfolder, or the standard default."""
|
||||
spec = _get_spec(model_class_name)
|
||||
resolver: TransformerConfigSubfolderResolver | None = (
|
||||
spec.get("transformer_config_subfolder_resolver") if spec else None
|
||||
)
|
||||
return resolver(model=model, revision=revision) if resolver else "transformer"
|
||||
|
||||
|
||||
def should_preserve_reference_image_size(
|
||||
model_class_name: str | None,
|
||||
*,
|
||||
model: str | None,
|
||||
revision: str | None = None,
|
||||
) -> bool:
|
||||
"""Return whether the selected pipeline owns reference-image resizing."""
|
||||
if model_class_name is None and model is not None:
|
||||
from vllm_omni.diffusion.data import resolve_model_class_name
|
||||
|
||||
model_class_name = resolve_model_class_name(model, revision=revision)
|
||||
spec = _get_spec(model_class_name)
|
||||
resolver: ReferenceImageSizeResolver | None = spec.get("reference_image_size_resolver") if spec else None
|
||||
return bool(resolver and resolver(model=model, revision=revision))
|
||||
|
||||
|
||||
def should_init_extra_args_for_non_diffusion_stages(model_class_name: str | None) -> bool:
|
||||
spec = _get_spec(model_class_name)
|
||||
return bool(spec and spec.get("init_extra_args_for_non_diffusion_stages", False))
|
||||
|
||||
Reference in new issue
Block a user