[feat] Extend disaggregated MiniMax-H3 stage with VAE encoders (#6939)
Signed-off-by: asukaqaq-s <1311722138@qq.com> Signed-off-by: Jader <yjader@foxmail.com> Co-authored-by: Jader <yjader@foxmail.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
27 files changed
+3663
-2399
No files matched your search
@@ -131,13 +131,13 @@ for the validated batching, attention, and quantization combinations.
|
||||
|
||||
### MiniMax-H3: conditioning, denoising, and decode
|
||||
|
||||
MiniMax-H3 illustrates logical component and phase boundaries between text
|
||||
encoding, task-specific denoising, and media decoding:
|
||||
`Text Encoder → DiT → VAE Decoder`. The current registered topology co-locates
|
||||
these components in one diffusion stage. These boundaries are useful for
|
||||
profiling and offload; independent placement would require a future
|
||||
multi-stage `PipelineConfig`. The stage mapping follows the implementation and
|
||||
serving recipe; the [release overview](https://minimaxi.com/blog/minimax-h3)
|
||||
MiniMax-H3 combines text and reference-media encoding, task-specific denoising,
|
||||
and media decoding. Standard serving co-locates these components in one diffusion
|
||||
stage. The opt-in
|
||||
[disaggregated topology](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3-Disaggregated.md)
|
||||
runs Qwen and the video/audio VAE encoders in Stage 0, then sends their conditioning
|
||||
to a Stage 1 containing the DiTs and VAE decoders. The stage mapping follows the
|
||||
implementation and serving recipe; the [release overview](https://minimaxi.com/blog/minimax-h3)
|
||||
notes that a detailed technical report is forthcoming.
|
||||
|
||||
#### Model architecture by component
|
||||
@@ -149,16 +149,15 @@ notes that a detailed technical report is forthcoming.
|
||||
| **VAE Decoder** | Video VAE and audio VAE decode the generated latents and assemble the synchronized output | Decoded video frames and stereo waveform, with FPS and audio sample rate metadata |
|
||||
|
||||
Both DiT partitions remain behind one task-selection point; `task` chooses
|
||||
which partition runs. In the current implementation, the three logical
|
||||
components execute inside one diffusion stage. This is different from
|
||||
Qwen3-Omni's three independently scheduled stages, even though both models
|
||||
produce synchronized audio and video-like outputs.
|
||||
which partition runs.
|
||||
|
||||
#### Representative component execution
|
||||
|
||||
The following table describes the standard single-stage deployment.
|
||||
|
||||
| Logical component | Batching | Attention and execution | Parallelism | Quantization |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Text Encoder** | The current co-located pipeline follows the diffusion request scheduler; independent prompt/reference batching would require a separate multi-stage topology | Qwen3-VL attention and multimodal preprocessing | `--text-encoder-tp-size N` shards the encoder across the first `N` DiT ranks; independent placement is not part of the current topology | BF16/FP32 baseline; the H3 DiT FP8 path does not quantize the text encoder |
|
||||
| **Text Encoder** | Follows the diffusion request scheduler | Qwen3-VL attention and multimodal preprocessing | `--text-encoder-tp-size N` shards the encoder across the first `N` DiT ranks | BF16/FP32 baseline; the H3 DiT FP8 path does not quantize the text encoder |
|
||||
| **DiT** | The current H3 implementation executes one generation request per diffusion batch; use concurrency for service-level throughput | cuDNN attention for the validated two-GPU consumer profile; TRTLLM attention or FlashAttention-4 on supported Blackwell profiles | TP2 plus text-encoder TP and Ulysses/VAE parallel groups on multi-GPU profiles; DLO/CPU offload trade memory for transfer time | Online FP8 applies to eligible DiT linears and is incompatible with layerwise offload |
|
||||
| **VAE Decoder** | Decode follows each generation request; tile/patch work can be distributed even when denoising is not batched | VAE decode kernels rather than DiT attention | VAE patch parallelism and native tiled decode within the diffusion stage | BF16/FP32 baseline; the documented H3 FP8 path leaves both VAEs unchanged |
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# MiniMax H3 disaggregated text encoder
|
||||
# MiniMax H3 disaggregated encoder stage
|
||||
|
||||
This opt-in topology runs the Qwen3-VL text encoder as a vLLM stage and sends
|
||||
its hidden states and token-role metadata to an encoder-free diffusion stage.
|
||||
The standard MiniMax H3 recipes remain single-stage and continue to load the
|
||||
text encoder inside the diffusion pipeline.
|
||||
This opt-in topology runs the Qwen3-VL text encoder, video VAE encoder, and
|
||||
audio WVAE encoder together in a vLLM stage. It sends their conditioning
|
||||
payload to an encoder-free diffusion stage. The standard MiniMax H3 recipes
|
||||
remain single-stage and continue to run all components inside the diffusion
|
||||
pipeline.
|
||||
|
||||
## Start the server
|
||||
|
||||
@@ -23,6 +24,14 @@ deployment override for the available hardware. Diffusion quantization,
|
||||
layerwise offload, distributed layerwise offload, VAE parallelism, and USP
|
||||
settings use the same stage 1 options documented in [MiniMax-H3.md](MiniMax-H3.md).
|
||||
|
||||
Stage 0's `tensor_parallel_size` defines one shared rank group for all three
|
||||
encoder roles; it does not choose each role's execution strategy. The required
|
||||
`hf_overrides.minimax_h3_encoder_components` mapping declares those strategies
|
||||
explicitly: `text_encoder` uses tensor parallelism, `video_vae` uses patch
|
||||
parallelism, and `audio_vae` runs on the group leader. All three role entries
|
||||
are mandatory. They share the stage's `max_num_seqs` scheduler and batch limit;
|
||||
the configuration does not create independent role schedulers or world sizes.
|
||||
|
||||
For example, this five-GPU topology assigns one GPU to the encoder and four
|
||||
to the diffusion stage. `--stage-overrides` keeps placement and parallelism
|
||||
scoped to the owning stage rather than broadcasting an override to both:
|
||||
@@ -57,8 +66,12 @@ The Stage 1 VAE patch-parallel options remain independent of offload and
|
||||
quantization. See [MiniMax-H3.md](MiniMax-H3.md) for memory requirements and
|
||||
hardware-qualified profiles before combining these options.
|
||||
|
||||
Stage 1 sets `model_loaded.text_encoder: false`; it must not load or download
|
||||
text-encoder weights. This H3 topology explicitly keeps its single-replica
|
||||
Stage 1 sets `model_loaded.text_encoder: false` and
|
||||
`model_loaded.vae_encoder: false`. It loads only the VAE decoders and skips
|
||||
tokenizer, processor, and text-encoder downloads. It requires complete text and
|
||||
media conditioning from Stage 0, with no fallback to local encoding.
|
||||
|
||||
This H3 topology explicitly keeps its single-replica
|
||||
diffusion stage inline, avoiding serialization of decoded video through a
|
||||
subprocess. It expects both stages to run in one deployment; cross-node payload
|
||||
transport is outside this configuration.
|
||||
|
||||
@@ -23,6 +23,12 @@ One vLLM-Omni diffusion stage can load both DiTs while instantiating the
|
||||
tokenizer, processor, Qwen3-VL text encoder, video VAE, and audio VAE only
|
||||
once. Requests select the DiT with `extra_params.task`.
|
||||
|
||||
This single-stage recipe uses the default `model_loaded.text_encoder: true` and
|
||||
`model_loaded.vae_encoder: true`. Precomputed text conditioning can replace the
|
||||
local Qwen call. Setting `model_loaded.text_encoder: false` while leaving
|
||||
`model_loaded.vae_encoder: true` requires precomputed text conditioning;
|
||||
reference media is still encoded locally.
|
||||
|
||||
The generated MP4 contains H.264 video and synchronized stereo audio.
|
||||
|
||||
## Prerequisites
|
||||
@@ -35,7 +41,7 @@ hf auth login
|
||||
export MODEL=MiniMaxAI/MiniMax-H3
|
||||
```
|
||||
|
||||
The vLLM-Omni pipeline downloads `FL2VA/**`, `Ref2VA/model_index.json`, and
|
||||
By default, the pipeline downloads `FL2VA/**`, `Ref2VA/model_index.json`, and
|
||||
`Ref2VA/transformer/**`. It does not download or load the diffusers-format
|
||||
`transformer`, `transformer_ref`, or `vae` weights at the repository root, nor
|
||||
duplicate Ref2VA copies of shared components.
|
||||
|
||||
@@ -1562,7 +1562,10 @@ stages:
|
||||
monkeypatch.setattr(stage_init_utils, "resolve_worker_cls", lambda _engine_args: None)
|
||||
|
||||
deploy = load_deploy_config(get_deploy_config_path("minimax_h3_disaggregated.yaml"))
|
||||
assert deploy.stages[1].engine_extras["model_loaded"] == {"text_encoder": False}
|
||||
assert deploy.stages[1].engine_extras["model_loaded"] == {
|
||||
"text_encoder": False,
|
||||
"vae_encoder": False,
|
||||
}
|
||||
stages = merge_pipeline_deploy(OMNI_PIPELINES["minimax_h3_disaggregated"], deploy)
|
||||
resolved = [stage_init_utils.build_engine_args_dict(stage.to_omegaconf(), str(model_root)) for stage in stages]
|
||||
|
||||
@@ -1587,12 +1590,20 @@ stages:
|
||||
deploy = load_deploy_config(Path(get_deploy_config_path("minimax_h3_disaggregated.yaml")))
|
||||
stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
|
||||
assert stages[0].yaml_engine_args["model_arch"] == "MiniMaxH3TextEncoder"
|
||||
assert stages[0].yaml_engine_args["model_arch"] == "MiniMaxH3Encoder"
|
||||
assert stages[1].yaml_engine_args["model_arch"] == "MiniMaxH3Pipeline"
|
||||
assert stages[0].yaml_runtime["num_replicas"] == 1
|
||||
assert stages[1].yaml_runtime["num_replicas"] == 1
|
||||
assert stages[1].yaml_engine_args["model_loaded"] == {"text_encoder": False}
|
||||
assert stages[1].yaml_engine_args["model_loaded"] == {
|
||||
"text_encoder": False,
|
||||
"vae_encoder": False,
|
||||
}
|
||||
assert stages[0].yaml_engine_args["max_num_seqs"] == 1
|
||||
assert stages[0].yaml_engine_args["hf_overrides"]["minimax_h3_encoder_components"] == {
|
||||
"text_encoder": {"parallel_mode": "tp"},
|
||||
"video_vae": {"parallel_mode": "patch"},
|
||||
"audio_vae": {"parallel_mode": "leader"},
|
||||
}
|
||||
assert stages[0].yaml_engine_args["model_path_resolver"].endswith(".resolve_minimax_h3_model_root")
|
||||
assert stages[1].yaml_engine_args["model_path_resolver"].endswith(".resolve_minimax_h3_diffusion_model_path")
|
||||
parallel = stages[1].yaml_engine_args["parallel_config"]
|
||||
@@ -1604,6 +1615,9 @@ stages:
|
||||
|
||||
turbo = load_deploy_config(Path(get_deploy_config_path("minimax_h3_disaggregated_turbo.yaml")))
|
||||
turbo_stages = merge_pipeline_deploy(pipeline, turbo)
|
||||
assert turbo_stages[0].yaml_engine_args["model_arch"] == stages[0].yaml_engine_args["model_arch"]
|
||||
assert turbo_stages[1].yaml_engine_args["model_loaded"] == stages[1].yaml_engine_args["model_loaded"]
|
||||
assert turbo_stages[0].yaml_engine_args["hf_overrides"] == stages[0].yaml_engine_args["hf_overrides"]
|
||||
turbo_sampling = turbo_stages[1].yaml_extras["default_sampling_params"]
|
||||
assert turbo_sampling["num_inference_steps"] == 5
|
||||
assert turbo_sampling["extra_args"] == {"flow_shift": 6.0, "audio_flow_shift": 3.0}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.multiprocessing as mp
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def _collective_worker(rank, rendezvous, backend):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3EncoderMediaInput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
torch.set_num_threads(1)
|
||||
device = torch.device("cuda", rank) if backend == "nccl" else torch.device("cpu")
|
||||
if backend == "nccl":
|
||||
current_omni_platform.set_device(device)
|
||||
dist.init_process_group(
|
||||
backend, init_method=f"file://{rendezvous}", rank=rank, world_size=2, timeout=timedelta(seconds=45)
|
||||
)
|
||||
try:
|
||||
module._dit_rank_world = lambda: (dist.group.WORLD, rank, 2)
|
||||
|
||||
class VisualCodec:
|
||||
fail = False
|
||||
|
||||
def is_distributed_enabled(self):
|
||||
return True
|
||||
|
||||
def collective(self):
|
||||
value = torch.ones(1, device=device)
|
||||
dist.all_reduce(value)
|
||||
assert value.item() == 2
|
||||
|
||||
def encode_image(self, image):
|
||||
self.collective()
|
||||
assert image.size == (32, 32)
|
||||
return torch.ones(1, 96)
|
||||
|
||||
def encode_video(self, frames):
|
||||
self.collective()
|
||||
assert frames.shape == (4, 32, 32, 3)
|
||||
if self.fail and rank == 1:
|
||||
raise ValueError("codec failed on rank 1")
|
||||
return torch.full((1, 96), 2.0), (1, 2, 2)
|
||||
|
||||
class AudioCodec:
|
||||
def encode_waveform(self, waveform, sample_rate):
|
||||
assert rank == 0
|
||||
assert sample_rate == 32000
|
||||
assert waveform.shape == (64000,)
|
||||
return torch.full((160, 32), 3.0), 80
|
||||
|
||||
model = object.__new__(module.MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.device = device
|
||||
model.od_config = SimpleNamespace(enable_layerwise_offload=False)
|
||||
model.video_vae = VisualCodec()
|
||||
model.audio_vae = AudioCodec()
|
||||
model.partition = "combined"
|
||||
model.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
model.text_encoder_tp_size = 2
|
||||
model.text_encoder_group = SimpleNamespace(
|
||||
world_size=2, ranks=[0, 1], rank_in_group=rank, device_group=dist.group.WORLD
|
||||
)
|
||||
model.processor = SimpleNamespace(image_processor=SimpleNamespace(merge_size=2))
|
||||
model.tokenizer = lambda text, **kwargs: {"input_ids": [1, 2, 3]}
|
||||
|
||||
def encode_text(ids, kwargs):
|
||||
assert ids.tolist() == [1, 2, 3]
|
||||
assert not kwargs
|
||||
hidden = torch.ones(3, 5120, device=device, dtype=torch.bfloat16)
|
||||
dist.all_reduce(hidden)
|
||||
return hidden
|
||||
|
||||
model._encode_text_hidden = encode_text
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=32, width=32, num_frames=96, extra_args={"task": "t2va", "aspect_ratio": "1:1"}
|
||||
)
|
||||
text_conditioning = model._prepare_local_conditioning({"prompt": "test"}, sampling)
|
||||
torch.testing.assert_close(text_conditioning.hidden_states, torch.full_like(text_conditioning.hidden_states, 2))
|
||||
assert text_conditioning.visual_condition is None
|
||||
|
||||
media = (
|
||||
MiniMaxH3EncoderMediaInput(
|
||||
task="ref2va",
|
||||
height=32,
|
||||
width=32,
|
||||
num_frames=107,
|
||||
latent_t=32,
|
||||
audio_t=178,
|
||||
images=(torch.zeros(32, 32, 3, dtype=torch.uint8),),
|
||||
videos=(torch.zeros(4, 32, 32, 3, dtype=torch.uint8),),
|
||||
video_audios=(None,),
|
||||
audios=((torch.zeros(64000), 32000),),
|
||||
)
|
||||
if rank == 0
|
||||
else None
|
||||
)
|
||||
conditioning = model._encode_local_media(media)
|
||||
assert [block["kind"] for block in conditioning.ref_blocks] == ["image", "video", "audio"]
|
||||
torch.testing.assert_close(
|
||||
conditioning.visual_condition.cpu(), torch.cat([torch.ones(1, 96), torch.full((1, 96), 2.0)])
|
||||
)
|
||||
torch.testing.assert_close(conditioning.audio_condition.cpu(), torch.full((160, 32), 3.0))
|
||||
assert conditioning.audio_condition_lengths == (80,)
|
||||
|
||||
model.video_vae.fail = True
|
||||
with pytest.raises(OmniClientError, match="codec failed on rank 1"):
|
||||
model._encode_local_media(media)
|
||||
with pytest.raises(OmniClientError, match="non-empty prompt"):
|
||||
model._prepare_local_conditioning({"prompt": ""}, sampling)
|
||||
model.video_vae.fail = False
|
||||
assert model._encode_local_media(media).visual_condition_shapes == ((1, 2, 2), (1, 2, 2))
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
def _run_collectives(tmp_path, backend):
|
||||
context = mp.spawn(_collective_worker, args=(str(tmp_path / "rendezvous"), backend), nprocs=2, join=False)
|
||||
deadline = time.monotonic() + 150
|
||||
try:
|
||||
while not context.join(timeout=1):
|
||||
if time.monotonic() > deadline:
|
||||
pytest.fail("MiniMax H3 collectives did not finish within 150 seconds")
|
||||
finally:
|
||||
for process in context.processes:
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
process.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_two_rank_conditioning_and_errors_gloo(tmp_path):
|
||||
_run_collectives(tmp_path, "gloo")
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.parallel
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("VLLM_TEST_MINIMAX_H3_COLLECTIVES") != "1",
|
||||
reason="set VLLM_TEST_MINIMAX_H3_COLLECTIVES=1 with two reserved GPUs",
|
||||
)
|
||||
def test_two_rank_conditioning_and_errors_nccl(tmp_path):
|
||||
if torch.accelerator.device_count() < 2:
|
||||
pytest.skip("requires two GPUs")
|
||||
_run_collectives(tmp_path, "nccl")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline():
|
||||
model = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.load_text_encoder = True
|
||||
model.load_vae_encoder = True
|
||||
model.device = torch.device("cpu")
|
||||
model.partition = "combined"
|
||||
model.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
model.default_video_shift = 12.0
|
||||
model.default_audio_shift = 3.0
|
||||
model.od_config = SimpleNamespace(enable_layerwise_offload=False)
|
||||
model._quality_policy = SimpleNamespace(resolve=Mock(return_value=SimpleNamespace(cache_dit=None)))
|
||||
model._cache_dit_runtime = SimpleNamespace(prepare=Mock())
|
||||
model.encode_prompt = Mock(
|
||||
return_value=(torch.ones(2, 5120, dtype=torch.bfloat16), torch.ones(2, dtype=torch.long))
|
||||
)
|
||||
model.video_vae = Mock()
|
||||
model.video_vae.is_distributed_enabled.return_value = False
|
||||
model.video_vae.encode_image.side_effect = lambda image: torch.ones((image.height // 32) * (image.width // 32), 96)
|
||||
model.audio_vae = Mock()
|
||||
model.transformer = torch.nn.Identity()
|
||||
model.diffuse = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
|
||||
model.decode = Mock(return_value=(torch.zeros(1, 3, 1, 32, 32), torch.zeros(1)))
|
||||
return model
|
||||
|
||||
|
||||
def _request(task):
|
||||
prompt: dict[str, Any] = {"prompt": "A person waves."}
|
||||
if task != "t2va":
|
||||
prompt["multi_modal_data"] = {"image": Image.new("RGB", (256, 256))}
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=32, width=32, num_frames=96, num_inference_steps=2, extra_args={"task": task, "aspect_ratio": "1:1"}
|
||||
)
|
||||
return SimpleNamespace(prompts=[prompt], sampling_params=sampling)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", ["t2va", "fl2va", "ref2va"])
|
||||
def test_single_stage_encodes_without_stage_zero(pipeline, task):
|
||||
output = pipeline.forward(_request(task))
|
||||
pipeline.encode_prompt.assert_called_once()
|
||||
assert pipeline.video_vae.encode_image.call_count == int(task != "t2va")
|
||||
pipeline.diffuse.assert_called_once()
|
||||
assert pipeline.diffuse.call_args.kwargs["task"] == task
|
||||
video, audio = output.output
|
||||
torch.testing.assert_close(video, torch.zeros(1, 1, 32, 32, 3, dtype=torch.uint8))
|
||||
torch.testing.assert_close(audio, pipeline.decode.return_value[1])
|
||||
|
||||
|
||||
def test_mixed_reference_conditioning_matches_stage_zero_handoff(pipeline, monkeypatch):
|
||||
import numpy as np
|
||||
|
||||
from vllm_omni.data_entry_keys import flatten_payload
|
||||
from vllm_omni.model_executor.models.minimax_h3 import encoder_processing as processing
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MiniMaxH3EncoderConditioning,
|
||||
MiniMaxH3TextConditioning,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder import MiniMaxH3Encoder
|
||||
from vllm_omni.model_executor.stage_input_processors.minimax_h3 import encoder2diffusion, prepare_encoder_prompt
|
||||
|
||||
frames = np.zeros((48, 32, 32, 3), dtype=np.uint8)
|
||||
decode_frames = Mock(return_value=frames)
|
||||
sampler = Mock(wraps=processing.sample_reference_video_frames)
|
||||
monkeypatch.setattr(processing, "load_video_frames", decode_frames)
|
||||
monkeypatch.setattr(processing, "sample_reference_video_frames", sampler)
|
||||
monkeypatch.setattr(
|
||||
processing,
|
||||
"prepare_reference_videos",
|
||||
Mock(
|
||||
return_value=[{"prepared_path": "prepared.mp4", "original_path": "original.mp4", "input_has_audio": True}]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(processing, "load_video_audio", Mock(return_value=(torch.full((64000,), 3.0), 32000)))
|
||||
pipeline.video_vae.encode_video.return_value = (torch.full((1, 96), 2.0), (1, 2, 2))
|
||||
|
||||
def encode_audio(waveform, sample_rate):
|
||||
length = round(waveform.shape[-1] / sample_rate * 40)
|
||||
return torch.full((length * 2, 32), float(waveform[0])), length
|
||||
|
||||
pipeline.audio_vae.encode_waveform.side_effect = encode_audio
|
||||
request = _request("ref2va")
|
||||
request.prompts[0]["multi_modal_data"].update(video="original.mp4", audio=(torch.full((192000,), 4.0), 32000))
|
||||
pipeline.forward(request)
|
||||
local_kwargs = pipeline.diffuse.call_args.kwargs
|
||||
|
||||
transformed = prepare_encoder_prompt(request.prompts[0], [request.sampling_params])
|
||||
stage_zero = object.__new__(MiniMaxH3Encoder)
|
||||
torch.nn.Module.__init__(stage_zero)
|
||||
stage_zero._component_leader = True
|
||||
stage_zero.video_vae = pipeline.video_vae
|
||||
stage_zero.audio_vae = pipeline.audio_vae
|
||||
media = stage_zero._encode_media(stage_zero._media_input(transformed["additional_information"]))
|
||||
text = MiniMaxH3TextConditioning(*pipeline.encode_prompt.return_value)
|
||||
payload = MiniMaxH3EncoderConditioning.from_components(text, media).to_omni_payload()
|
||||
source = SimpleNamespace(finished=True, outputs=[SimpleNamespace(multimodal_output=flatten_payload(payload))])
|
||||
bridged = encoder2diffusion([source], request.prompts[0])
|
||||
pipeline.load_text_encoder = False
|
||||
pipeline.load_vae_encoder = False
|
||||
pipeline.encode_prompt.reset_mock()
|
||||
pipeline.forward(SimpleNamespace(prompts=[bridged], sampling_params=request.sampling_params))
|
||||
external_kwargs = pipeline.diffuse.call_args.kwargs
|
||||
for key, value in local_kwargs.items():
|
||||
if isinstance(value, torch.Tensor):
|
||||
torch.testing.assert_close(value, external_kwargs[key])
|
||||
else:
|
||||
assert value == external_kwargs[key]
|
||||
pipeline.encode_prompt.assert_not_called()
|
||||
assert decode_frames.call_count == sampler.call_count == 2
|
||||
assert all(call.kwargs["decoded_frames"] is frames for call in sampler.call_args_list)
|
||||
assert [block["kind"] for block in local_kwargs["ref_blocks"]] == ["image", "video_audio", "audio"]
|
||||
assert local_kwargs["audio_condition_lengths"] == [80, round(107 / 24 * 40)]
|
||||
assert all(
|
||||
call.args[0].shape[-1] == round(107 / 24 * 32000)
|
||||
for call in pipeline.audio_vae.encode_waveform.call_args_list[1::2]
|
||||
)
|
||||
@@ -9,72 +9,25 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_h3_profiler_targets_cover_reference_encoders_and_vae_decoders():
|
||||
def test_h3_full_pipeline_profiler_includes_local_encoding():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
targets = MiniMaxH3Pipeline._PROFILER_TARGETS
|
||||
assert "_encode_visual_conditions" in targets
|
||||
assert "_encode_reference_audio_conditions" in targets
|
||||
assert "video_vae.decode_latent" in targets
|
||||
assert "audio_vae.decode_latent" in targets
|
||||
assert "_encode_video_conditions" not in targets
|
||||
assert "_encode_video_audio_conditions" not in targets
|
||||
assert "_encode_audio_conditions" not in targets
|
||||
|
||||
|
||||
@pytest.mark.parametrize("distributed", [False, True])
|
||||
def test_manual_component_offload_wins_over_requested_model_offload(distributed):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=not distributed,
|
||||
enable_distributed_layerwise_offload=distributed,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
component = Mock()
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
with pipeline._component_on_device(component):
|
||||
component.load_to_device.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_not_called()
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_called_once_with()
|
||||
|
||||
|
||||
def test_requested_model_offload_without_backend_uses_resident_components():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.encode_ids.assert_called_once()
|
||||
assert targets == [
|
||||
"encode_prompt",
|
||||
"_encode_local_media",
|
||||
"diffuse",
|
||||
"decode",
|
||||
"video_vae.decode_latent",
|
||||
"audio_vae.decode_latent",
|
||||
"prepare_encode",
|
||||
"denoise_step",
|
||||
"post_decode",
|
||||
]
|
||||
|
||||
|
||||
def test_decoded_output_is_unchanged_without_active_model_offload():
|
||||
@@ -194,7 +147,7 @@ def test_forward_releases_video_after_quantization_and_before_next_seed(monkeypa
|
||||
_extract_prompt=lambda raw: ("a prompt", {}),
|
||||
_extract_text_conditioning=lambda raw: None,
|
||||
_extract_prepared_reference_videos=lambda raw: None,
|
||||
_prepare_request_inputs=lambda **kwargs: context,
|
||||
_prepare_request_inputs=lambda *args, **kwargs: context,
|
||||
_denoise_kwargs=lambda ctx: {},
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
@@ -243,7 +196,7 @@ def test_forward_drops_the_decoded_video_before_the_next_seed_diffuses(monkeypat
|
||||
_extract_prompt=lambda raw: ("a prompt", {}),
|
||||
_extract_text_conditioning=lambda raw: None,
|
||||
_extract_prepared_reference_videos=lambda raw: None,
|
||||
_prepare_request_inputs=lambda **kwargs: context,
|
||||
_prepare_request_inputs=lambda *args, **kwargs: context,
|
||||
_denoise_kwargs=lambda ctx: {},
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
@@ -318,7 +271,8 @@ def test_output_owner_rank_matches_executor_contract(monkeypatch, available, ini
|
||||
assert MiniMaxH3Pipeline._is_output_owner_rank() is expected
|
||||
|
||||
|
||||
def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
@pytest.mark.parametrize("load_text_encoder", [True, False])
|
||||
def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch, load_text_encoder):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
@@ -326,9 +280,11 @@ def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.transformer = torch.nn.Linear(2, 2)
|
||||
pipeline.transformers_ref = torch.nn.Linear(2, 2)
|
||||
pipeline.text_encoder = torch.nn.Linear(2, 2)
|
||||
pipeline.video_vae = torch.nn.Linear(2, 2)
|
||||
pipeline.audio_vae = torch.nn.Linear(2, 2)
|
||||
pipeline._encoder_modules = ["text_encoder"] if load_text_encoder else []
|
||||
if load_text_encoder:
|
||||
pipeline.text_encoder = torch.nn.Linear(2, 2)
|
||||
apply_offload = Mock()
|
||||
remove_offload = Mock()
|
||||
monkeypatch.setattr(module, "apply_sequential_offload", apply_offload)
|
||||
@@ -341,7 +297,7 @@ def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
)
|
||||
|
||||
dits = [pipeline.transformer, pipeline.transformers_ref]
|
||||
stages = [pipeline.text_encoder, pipeline.video_vae, pipeline.audio_vae]
|
||||
stages = [*([pipeline.text_encoder] if load_text_encoder else []), pipeline.video_vae, pipeline.audio_vae]
|
||||
apply_offload.assert_called_once_with(
|
||||
dit_modules=dits,
|
||||
encoder_modules=stages,
|
||||
@@ -356,6 +312,46 @@ def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
remove_offload.assert_called_once_with([*dits, *stages])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decode_fails", [False, True])
|
||||
def test_h3_model_cpu_offload_scopes_direct_vae_call(monkeypatch, decode_fails):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
component = torch.nn.Linear(2, 2)
|
||||
events: list[tuple[Any, ...]] = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
events.append(("activate", value))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append(("offload", value))
|
||||
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
pipeline._model_cpu_offload_modules = [component]
|
||||
|
||||
def decode():
|
||||
with pipeline._component_on_device(component):
|
||||
events.append(("decode", component))
|
||||
if decode_fails:
|
||||
raise RuntimeError("decode failed")
|
||||
|
||||
if decode_fails:
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
decode()
|
||||
else:
|
||||
decode()
|
||||
|
||||
assert events == [
|
||||
("activate", component),
|
||||
("decode", component),
|
||||
("offload", component),
|
||||
]
|
||||
|
||||
|
||||
def test_h3_model_cpu_offload_keeps_unselected_vaes_resident(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
@@ -416,44 +412,20 @@ def test_h3_model_cpu_offload_rejects_unloaded_selected_encoder(monkeypatch):
|
||||
apply_offload.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decode_fails", [False, True])
|
||||
def test_h3_model_cpu_offload_scopes_direct_vae_call(monkeypatch, decode_fails):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
def _encode_media(pipeline, **kwargs):
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3EncoderMediaInput
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import encode_media
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
component = torch.nn.Linear(2, 2)
|
||||
events: list[tuple[Any, ...]] = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
events.append(("activate", value))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append(("offload", value))
|
||||
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
pipeline._model_cpu_offload_modules = [component]
|
||||
|
||||
def decode():
|
||||
with pipeline._component_on_device(component):
|
||||
events.append(("decode", component))
|
||||
if decode_fails:
|
||||
raise RuntimeError("decode failed")
|
||||
|
||||
if decode_fails:
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
decode()
|
||||
else:
|
||||
decode()
|
||||
|
||||
assert events == [
|
||||
("activate", component),
|
||||
("decode", component),
|
||||
("offload", component),
|
||||
]
|
||||
media = MiniMaxH3EncoderMediaInput(
|
||||
task="ref2va", height=32, width=32, num_frames=124, latent_t=31, audio_t=208, **kwargs
|
||||
)
|
||||
return encode_media(
|
||||
media,
|
||||
video_vae=getattr(pipeline, "video_vae", None),
|
||||
audio_vae=getattr(pipeline, "audio_vae", None),
|
||||
emit_conditioning=True,
|
||||
component_scope=pipeline._component_on_device,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encode_fails", [False, True])
|
||||
@@ -463,7 +435,6 @@ def test_h3_model_cpu_offload_batches_visual_reference_scope(monkeypatch, encode
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.video_vae = Mock()
|
||||
pipeline._model_cpu_offload_modules = [pipeline.video_vae]
|
||||
events = []
|
||||
@@ -484,17 +455,15 @@ def test_h3_model_cpu_offload_batches_visual_reference_scope(monkeypatch, encode
|
||||
|
||||
pipeline.video_vae.encode_image.side_effect = encode_image
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
monkeypatch.setattr(module, "_dit_rank_world", lambda: (None, 0, 1))
|
||||
monkeypatch.setattr(module, "_broadcast_tensor", lambda value, **kwargs: value)
|
||||
images = [Image.new("RGB", (16, 16)), Image.new("RGB", (32, 16))]
|
||||
images = (torch.zeros(16, 16, 3, dtype=torch.uint8), torch.zeros(16, 32, 3, dtype=torch.uint8))
|
||||
|
||||
if encode_fails:
|
||||
with pytest.raises(RuntimeError, match="encode failed"):
|
||||
pipeline._encode_visual_conditions(images, None, video_count=0)
|
||||
_encode_media(pipeline, images=images)
|
||||
else:
|
||||
rows, shapes = pipeline._encode_visual_conditions(images, None, video_count=0)
|
||||
assert rows.shape == (3, 4)
|
||||
assert shapes == [(1, 1, 1), (1, 1, 2)]
|
||||
result = _encode_media(pipeline, images=images)
|
||||
assert result.visual_condition.shape == (3, 4)
|
||||
assert result.visual_condition_shapes == ((1, 1, 1), (1, 1, 2))
|
||||
|
||||
assert events == [
|
||||
("activate", pipeline.video_vae),
|
||||
@@ -510,9 +479,7 @@ def test_h3_model_cpu_offload_shares_image_and_video_scope(monkeypatch):
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.video_vae = Mock()
|
||||
pipeline.video_vae.is_distributed_enabled.return_value = False
|
||||
pipeline._model_cpu_offload_modules = [pipeline.video_vae]
|
||||
events = []
|
||||
|
||||
@@ -529,31 +496,26 @@ def test_h3_model_cpu_offload_shares_image_and_video_scope(monkeypatch):
|
||||
return torch.ones(1, 4)
|
||||
|
||||
def encode_video(frames):
|
||||
events.append(("video", frames))
|
||||
events.append(("video", frames.shape))
|
||||
return torch.ones(3, 4), (3, 2, 2)
|
||||
|
||||
pipeline.video_vae.encode_image.side_effect = encode_image
|
||||
pipeline.video_vae.encode_video.side_effect = encode_video
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
monkeypatch.setattr(module, "_dit_rank_world", lambda: (None, 0, 1))
|
||||
monkeypatch.setattr(module, "_broadcast_tensor", lambda value, **kwargs: value)
|
||||
monkeypatch.setattr(module, "load_video_frames", lambda path: f"frames:{path}")
|
||||
images = [Image.new("RGB", (16, 16)), Image.new("RGB", (32, 16))]
|
||||
prepared_videos = [{"prepared_path": "reference.mp4"}]
|
||||
|
||||
rows, shapes = pipeline._encode_visual_conditions(
|
||||
images,
|
||||
prepared_videos,
|
||||
video_count=1,
|
||||
result = _encode_media(
|
||||
pipeline,
|
||||
images=(torch.zeros(16, 16, 3, dtype=torch.uint8), torch.zeros(16, 32, 3, dtype=torch.uint8)),
|
||||
videos=(torch.zeros(12, 32, 32, 3, dtype=torch.uint8),),
|
||||
video_audios=(None,),
|
||||
)
|
||||
|
||||
assert rows.shape == (5, 4)
|
||||
assert shapes == [(1, 1, 1), (1, 1, 2), (3, 2, 2)]
|
||||
assert result.visual_condition.shape == (5, 4)
|
||||
assert result.visual_condition_shapes == ((1, 1, 1), (1, 1, 2), (3, 2, 2))
|
||||
assert events == [
|
||||
("activate", pipeline.video_vae),
|
||||
("image", (16, 16)),
|
||||
("image", (32, 16)),
|
||||
("video", "frames:reference.mp4"),
|
||||
("video", (12, 32, 32, 3)),
|
||||
("offload", pipeline.video_vae),
|
||||
]
|
||||
|
||||
@@ -567,7 +529,7 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.audio_vae = Mock()
|
||||
pipeline._model_cpu_offload_modules = [pipeline.audio_vae]
|
||||
events: list[tuple[Any, ...]] = []
|
||||
events = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
@@ -577,26 +539,20 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
finally:
|
||||
events.append(("offload", value))
|
||||
|
||||
def encode_embedded(*args, **kwargs):
|
||||
events.append(("embedded",))
|
||||
def encode_waveform(waveform, sample_rate):
|
||||
events.append(("audio", int(waveform[0]), sample_rate))
|
||||
if encode_fails:
|
||||
raise RuntimeError("audio encode failed")
|
||||
return torch.ones(1, 2), [1]
|
||||
return torch.ones(80, 32), 80
|
||||
|
||||
def encode_standalone(*args, **kwargs):
|
||||
events.append(("standalone",))
|
||||
return torch.ones(1, 2), [1]
|
||||
|
||||
pipeline._encode_video_audio_conditions_resident = encode_embedded
|
||||
pipeline._encode_audio_conditions_resident = encode_standalone
|
||||
pipeline.audio_vae.encode_waveform.side_effect = encode_waveform
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
|
||||
def call():
|
||||
return pipeline._encode_reference_audio_conditions(
|
||||
[{"input_has_audio": True}],
|
||||
has_audio=[True],
|
||||
standalone_audios=[(torch.ones(1), 16_000)],
|
||||
max_duration_seconds=1.0,
|
||||
return _encode_media(
|
||||
pipeline,
|
||||
video_audios=(),
|
||||
audios=((torch.ones(32_000), 16_000),),
|
||||
)
|
||||
|
||||
if encode_fails:
|
||||
@@ -604,10 +560,61 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
call()
|
||||
else:
|
||||
result = call()
|
||||
assert result[0].shape == (1, 2)
|
||||
assert result[2].shape == (1, 2)
|
||||
assert result.audio_condition.shape == (80, 32)
|
||||
assert result.audio_condition_lengths == (80,)
|
||||
assert events[1:-1] == [("audio", 1, 16_000)]
|
||||
|
||||
assert events[0] == ("activate", pipeline.audio_vae)
|
||||
assert events[-1] == ("offload", pipeline.audio_vae)
|
||||
assert events.count(("activate", pipeline.audio_vae)) == 1
|
||||
assert events.count(("offload", pipeline.audio_vae)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("distributed", [False, True])
|
||||
def test_manual_component_offload_wins_over_requested_model_offload(distributed):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=not distributed,
|
||||
enable_distributed_layerwise_offload=distributed,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
component = Mock()
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
with pipeline._component_on_device(component):
|
||||
component.load_to_device.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_not_called()
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_called_once_with()
|
||||
|
||||
|
||||
def test_requested_model_offload_without_backend_uses_resident_components():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.encode_ids.assert_called_once()
|
||||
@@ -186,9 +186,8 @@ def test_minimax_h3_quantization_quality(config: QualityTestConfig):
|
||||
common_kwargs = {
|
||||
"model": model_ref,
|
||||
"enforce_eager": True,
|
||||
"tensor_parallel_size": 2,
|
||||
# The fused BF16 baseline only fits on H100-80GB with encoder TP.
|
||||
"text_encoder_tp_size": 2,
|
||||
"stage_0_tensor_parallel_size": 2,
|
||||
"vae_use_tiling": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ def _step_pipeline(model, *, packed_batch_supported: bool = True):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
pipeline.load_text_encoder = False
|
||||
pipeline.load_vae_encoder = False
|
||||
pipeline.transformer = model
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline._transformer_for_task = lambda task: model
|
||||
@@ -304,8 +306,17 @@ def test_prepare_encode_seeds_runner_visible_state(monkeypatch, batch_frames):
|
||||
}
|
||||
|
||||
pipeline = _step_pipeline(_SegmentMeanModel())
|
||||
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_extract_prompt", staticmethod(lambda _: ("a prompt", {})))
|
||||
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_prepare_request_inputs", lambda self, **_: context)
|
||||
conditioning = object()
|
||||
monkeypatch.setattr(
|
||||
mod.MiniMaxH3Pipeline,
|
||||
"_extract_encoder_conditioning",
|
||||
staticmethod(lambda _: conditioning),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod.MiniMaxH3Pipeline,
|
||||
"_prepare_encoder_conditioning_inputs",
|
||||
lambda self, value, sampling: context,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod.MiniMaxH3Pipeline,
|
||||
"_build_denoise_inputs",
|
||||
@@ -439,50 +450,3 @@ def test_packed_batch_rejects_backends_that_cannot_isolate_requests(attention):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
assert MiniMaxH3Pipeline._packed_batch_supported(_FakeTransformer([attention])) is False
|
||||
|
||||
|
||||
def test_broadcast_rank0_exception_single_rank_reraises():
|
||||
"""Single-rank execution has no group; the helper just reraises."""
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_broadcast_rank0_exception,
|
||||
)
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
_broadcast_rank0_exception(None)
|
||||
with pytest.raises(OmniClientError, match="bad ref"):
|
||||
_broadcast_rank0_exception(OmniClientError("bad ref"))
|
||||
|
||||
|
||||
def test_broadcast_rank0_exception_propagates_to_non_zero_ranks(monkeypatch):
|
||||
"""A rank-0 error becomes a matching client error on every other DiT rank."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
def fake_rank_world(rank):
|
||||
return lambda: (object(), rank, 4)
|
||||
|
||||
def make_broadcast(rank0_payload):
|
||||
def fake_broadcast(payload_list, *, src, group):
|
||||
payload_list[0] = rank0_payload
|
||||
|
||||
return fake_broadcast
|
||||
|
||||
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(0))
|
||||
err = OmniClientError("invalid reference-video file", status_code=422, error_type="UnprocessableEntityError")
|
||||
rank0_payload = {
|
||||
"type": type(err).__name__,
|
||||
"message": str(err),
|
||||
"status_code": err.status_code,
|
||||
"error_type": err.error_type,
|
||||
}
|
||||
monkeypatch.setattr(mod.dist, "broadcast_object_list", make_broadcast(rank0_payload))
|
||||
with pytest.raises(OmniClientError) as rank0_info:
|
||||
mod._broadcast_rank0_exception(err)
|
||||
assert rank0_info.value is err
|
||||
|
||||
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(2))
|
||||
with pytest.raises(OmniClientError) as rank2_info:
|
||||
mod._broadcast_rank0_exception(None)
|
||||
assert rank2_info.value.status_code == 422
|
||||
assert rank2_info.value.error_type == "UnprocessableEntityError"
|
||||
assert "invalid reference-video file" in str(rank2_info.value)
|
||||
@@ -0,0 +1,406 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.data_entry_keys import flatten_payload
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MiniMaxH3EncoderConditioning,
|
||||
MiniMaxH3EncoderMediaConditioning,
|
||||
MiniMaxH3EncoderMediaInput,
|
||||
)
|
||||
from vllm_omni.model_executor.stage_input_processors.minimax_h3 import encoder2diffusion
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_pipeline_owns_three_encoders_then_dit_and_decoders() -> None:
|
||||
from vllm_omni.model_executor.models.minimax_h3.pipeline import MINIMAX_H3_PIPELINE
|
||||
|
||||
encoder, diffusion = MINIMAX_H3_PIPELINE.stages
|
||||
assert encoder.model_arch == "MiniMaxH3Encoder"
|
||||
assert encoder.custom_process_next_stage_input_func is None
|
||||
assert diffusion.model_arch == "MiniMaxH3Pipeline"
|
||||
assert diffusion.requires_multimodal_data is False
|
||||
assert diffusion.requires_full_payload_input is False
|
||||
|
||||
|
||||
def _patch_encoder_constructors(monkeypatch, *, rank: int):
|
||||
from vllm_omni.model_executor.models.minimax_h3 import encoder as encoder_module
|
||||
|
||||
group = SimpleNamespace(rank_in_group=rank, world_size=2, device_group=object())
|
||||
|
||||
class FakeVideoVAE(torch.nn.Module):
|
||||
def __init__(self, path, *, device, encode_only, trust_remote_code):
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.parallel_args = None
|
||||
self.trust_remote_code = trust_remote_code
|
||||
|
||||
def set_parallel_size(self, size, *, process_group):
|
||||
self.parallel_args = (size, process_group)
|
||||
|
||||
class FakeAudioVAE(torch.nn.Module):
|
||||
def __init__(self, path, *, device, encode_only, trust_remote_code):
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.trust_remote_code = trust_remote_code
|
||||
|
||||
def init_backbone(self, *, vllm_config, prefix):
|
||||
torch.nn.Module.__init__(self)
|
||||
|
||||
monkeypatch.setattr(encoder_module.MiniMaxH3TextEncoderBackbone, "__init__", init_backbone)
|
||||
monkeypatch.setattr(encoder_module, "get_tp_group", lambda: group)
|
||||
monkeypatch.setattr(encoder_module, "get_local_device", lambda: torch.device("cpu"))
|
||||
monkeypatch.setattr(encoder_module, "MiniMaxH3VideoVAE", FakeVideoVAE)
|
||||
monkeypatch.setattr(encoder_module, "MiniMaxH3AudioVAE", FakeAudioVAE)
|
||||
return encoder_module, group
|
||||
|
||||
|
||||
def _component_root(tmp_path):
|
||||
root = tmp_path / "FL2VA"
|
||||
for name in ("text_encoder", "video_vae", "audio_vae"):
|
||||
(root / name).mkdir(parents=True)
|
||||
return root
|
||||
|
||||
|
||||
def _encoder_config(root, *, video_mode="patch", roles=None, trust_remote_code=True):
|
||||
components = (
|
||||
{
|
||||
"text_encoder": {"parallel_mode": "tp"},
|
||||
"video_vae": {"parallel_mode": video_mode},
|
||||
"audio_vae": {"parallel_mode": "leader"},
|
||||
}
|
||||
if roles is None
|
||||
else roles
|
||||
)
|
||||
return SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
model=str(root / "text_encoder"),
|
||||
hf_config=SimpleNamespace(minimax_h3_encoder_components=components),
|
||||
trust_remote_code=trust_remote_code,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_encoder_requires_exactly_three_components(monkeypatch, tmp_path) -> None:
|
||||
encoder_module, _ = _patch_encoder_constructors(monkeypatch, rank=0)
|
||||
root = tmp_path / "FL2VA"
|
||||
(root / "text_encoder").mkdir(parents=True)
|
||||
(root / "video_vae").mkdir()
|
||||
config = _encoder_config(root)
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"requires all three encoders.*audio_vae"):
|
||||
encoder_module.MiniMaxH3Encoder(vllm_config=config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("rank", "has_audio_vae"), [(0, True), (1, False)])
|
||||
@pytest.mark.parametrize("trust_remote_code", [False, True])
|
||||
def test_encoder_uses_tp_patch_and_leader_on_the_same_rank_set(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
rank,
|
||||
has_audio_vae,
|
||||
trust_remote_code,
|
||||
) -> None:
|
||||
encoder_module, group = _patch_encoder_constructors(monkeypatch, rank=rank)
|
||||
root = _component_root(tmp_path)
|
||||
config = _encoder_config(root, trust_remote_code=trust_remote_code)
|
||||
|
||||
model = encoder_module.MiniMaxH3Encoder(vllm_config=config)
|
||||
|
||||
assert model.component_config.text_parallel_mode == "tp"
|
||||
assert model.component_config.video_parallel_mode == "patch"
|
||||
assert model.component_config.audio_parallel_mode == "leader"
|
||||
assert model.video_vae.parallel_args == (2, group.device_group)
|
||||
assert model.video_vae.trust_remote_code is trust_remote_code
|
||||
assert (model.audio_vae is not None) is has_audio_vae
|
||||
if has_audio_vae:
|
||||
assert model.audio_vae.trust_remote_code is trust_remote_code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("component", ["video", "audio"])
|
||||
@pytest.mark.parametrize("trusted", [False, True])
|
||||
def test_encoder_only_vae_requires_remote_code_trust(monkeypatch, component, trusted) -> None:
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
|
||||
config = {"auto_map": {"AutoModel": "modeling.RemoteVAE"}, "sample_rate": 32000}
|
||||
monkeypatch.setattr(vae_module, "_load_component_config", lambda _path: config)
|
||||
remote = torch.nn.Module()
|
||||
remote.model = torch.nn.Module()
|
||||
calls = []
|
||||
|
||||
def load_encoder(path, config_dict):
|
||||
calls.append((path, config_dict))
|
||||
return remote
|
||||
|
||||
monkeypatch.setattr(vae_module, f"_load_{component}_vae_encoder", load_encoder)
|
||||
vae_cls = vae_module.MiniMaxH3VideoVAE if component == "video" else vae_module.MiniMaxH3AudioVAE
|
||||
if trusted:
|
||||
model = vae_cls("unused", device=torch.device("cpu"), encode_only=True, trust_remote_code=True)
|
||||
assert model.remote is remote
|
||||
assert calls == [("unused", config)]
|
||||
else:
|
||||
with pytest.raises(ValueError, match="trust-remote-code"):
|
||||
vae_cls("unused", device=torch.device("cpu"), encode_only=True)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_encoder_requires_all_role_policies(monkeypatch, tmp_path) -> None:
|
||||
encoder_module, _ = _patch_encoder_constructors(monkeypatch, rank=0)
|
||||
root = _component_root(tmp_path)
|
||||
config = _encoder_config(
|
||||
root,
|
||||
roles={
|
||||
"text_encoder": {"parallel_mode": "tp"},
|
||||
"video_vae": {"parallel_mode": "patch"},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"requires exactly.*audio_vae"):
|
||||
encoder_module.MiniMaxH3Encoder(vllm_config=config)
|
||||
|
||||
|
||||
def test_encoder_does_not_infer_role_policies(monkeypatch, tmp_path) -> None:
|
||||
encoder_module, _ = _patch_encoder_constructors(monkeypatch, rank=0)
|
||||
root = _component_root(tmp_path)
|
||||
config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
model=str(root / "text_encoder"),
|
||||
hf_config=SimpleNamespace(),
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="requires explicit minimax_h3_encoder_components"):
|
||||
encoder_module.MiniMaxH3Encoder(vllm_config=config)
|
||||
|
||||
|
||||
def test_video_leader_policy_skips_nonleader(monkeypatch, tmp_path) -> None:
|
||||
encoder_module, _ = _patch_encoder_constructors(monkeypatch, rank=1)
|
||||
root = _component_root(tmp_path)
|
||||
model = encoder_module.MiniMaxH3Encoder(vllm_config=_encoder_config(root, video_mode="leader"))
|
||||
|
||||
assert model.video_vae is None
|
||||
assert model.audio_vae is None
|
||||
assert (
|
||||
model._encode_media(
|
||||
MiniMaxH3EncoderMediaInput(
|
||||
task="ref2va",
|
||||
height=32,
|
||||
width=32,
|
||||
num_frames=48,
|
||||
latent_t=1,
|
||||
audio_t=80,
|
||||
images=(torch.zeros(32, 32, 3, dtype=torch.uint8),),
|
||||
)
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_encoder_runs_video_and_audio_components_on_ar_model() -> None:
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder import MiniMaxH3Encoder
|
||||
|
||||
class VideoVAE:
|
||||
def encode_image(self, _image):
|
||||
return torch.ones(1, 96)
|
||||
|
||||
def encode_video(self, _frames):
|
||||
return torch.full((1, 96), 2.0), (1, 2, 2)
|
||||
|
||||
class AudioVAE:
|
||||
def encode_waveform(self, waveform, _sample_rate):
|
||||
return torch.ones(160, 32), waveform.shape[-1] // 800
|
||||
|
||||
model = MiniMaxH3Encoder.__new__(MiniMaxH3Encoder)
|
||||
torch.nn.Module.__init__(model)
|
||||
model._component_leader = True
|
||||
model.video_vae = VideoVAE()
|
||||
model.audio_vae = AudioVAE()
|
||||
media = MiniMaxH3EncoderMediaInput(
|
||||
task="ref2va",
|
||||
height=32,
|
||||
width=32,
|
||||
num_frames=48,
|
||||
latent_t=1,
|
||||
audio_t=80,
|
||||
images=(torch.zeros(32, 32, 3, dtype=torch.uint8),),
|
||||
videos=(torch.zeros(1, 32, 32, 3, dtype=torch.uint8),),
|
||||
video_audios=((torch.zeros(64_000), 32_000),),
|
||||
)
|
||||
|
||||
conditioning = model._encode_media(media)
|
||||
|
||||
assert conditioning is not None
|
||||
assert conditioning.visual_condition_shapes == ((1, 2, 2), (1, 2, 2))
|
||||
assert conditioning.audio_condition_lengths == (80,)
|
||||
assert conditioning.ref_blocks[1]["kind"] == "video_audio"
|
||||
|
||||
|
||||
def test_prepare_encoder_inputs_keeps_reference_audio_budgets_separate(monkeypatch) -> None:
|
||||
from vllm_omni.model_executor.models.minimax_h3 import encoder_processing as processing
|
||||
|
||||
frames = torch.zeros(1, 32, 32, 3, dtype=torch.uint8).numpy()
|
||||
waveform = torch.zeros(320_000)
|
||||
monkeypatch.setattr(processing, "resolve_minimax_h3_shape", lambda *_args: (32, 32, 240, 60, 400))
|
||||
monkeypatch.setattr(
|
||||
processing,
|
||||
"prepare_reference_videos",
|
||||
lambda *_args, **_kwargs: [
|
||||
{"prepared_path": "prepared.mp4", "original_path": "reference.mp4", "input_has_audio": True}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(processing, "load_video_frames", lambda _path: frames)
|
||||
monkeypatch.setattr(
|
||||
processing,
|
||||
"sample_reference_video_frames",
|
||||
lambda *_args, **_kwargs: {"frames": [frames[0]], "block_timestamps": [[0.0]]},
|
||||
)
|
||||
monkeypatch.setattr(processing, "load_video_audio", lambda *_args, **_kwargs: (waveform, 32_000))
|
||||
|
||||
prepared = processing.prepare_encoder_inputs(
|
||||
{
|
||||
"prompt": "reference",
|
||||
"multi_modal_data": {"video": "reference.mp4", "audio": (waveform, 32_000)},
|
||||
},
|
||||
SimpleNamespace(extra_args={"task": "ref2va"}),
|
||||
)
|
||||
|
||||
assert prepared.media.video_audios[0][0].shape[-1] == 320_000
|
||||
assert prepared.media.audios[0][0].shape[-1] == 320_000
|
||||
assert prepared.condition_labels == [("audio", 1), ("video", 1), ("audio", 2)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("embedded_lengths", "standalone_lengths", "valid"),
|
||||
[((400,), (400,), True), ((320, 320), (400,), False), ((400,), (320, 320), False)],
|
||||
)
|
||||
def test_encode_media_keeps_audio_budgets_and_component_residency_separate(
|
||||
embedded_lengths, standalone_lengths, valid
|
||||
) -> None:
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import encode_media
|
||||
|
||||
active = None
|
||||
scopes = []
|
||||
|
||||
class VideoVAE:
|
||||
def encode_image(self, _image):
|
||||
assert active is self
|
||||
return torch.ones(1, 96)
|
||||
|
||||
def encode_video(self, _frames):
|
||||
assert active is self
|
||||
return torch.ones(1, 96), (1, 2, 2)
|
||||
|
||||
class AudioVAE:
|
||||
def encode_waveform(self, waveform, _sample_rate):
|
||||
assert active is self
|
||||
length = waveform.shape[-1] // 800
|
||||
return torch.ones(2 * length, 32), length
|
||||
|
||||
@contextmanager
|
||||
def component_scope(component):
|
||||
nonlocal active
|
||||
assert active is None
|
||||
active = component
|
||||
scopes.append(component)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
active = None
|
||||
|
||||
video_vae = VideoVAE()
|
||||
audio_vae = AudioVAE()
|
||||
media = MiniMaxH3EncoderMediaInput(
|
||||
task="ref2va",
|
||||
height=32,
|
||||
width=32,
|
||||
num_frames=360,
|
||||
latent_t=90,
|
||||
audio_t=600,
|
||||
images=(torch.zeros(32, 32, 3, dtype=torch.uint8),),
|
||||
videos=tuple(torch.zeros(1, 32, 32, 3, dtype=torch.uint8) for _ in embedded_lengths),
|
||||
video_audios=tuple((torch.zeros(length * 800), 32_000) for length in embedded_lengths),
|
||||
audios=tuple((torch.zeros(length * 800), 32_000) for length in standalone_lengths),
|
||||
)
|
||||
kwargs = dict(video_vae=video_vae, audio_vae=audio_vae, emit_conditioning=True, component_scope=component_scope)
|
||||
if valid:
|
||||
conditioning = encode_media(media, **kwargs)
|
||||
assert conditioning.audio_condition_lengths == embedded_lengths + standalone_lengths
|
||||
assert [block["kind"] for block in conditioning.ref_blocks] == ["image", "video_audio", "audio"]
|
||||
conditioning.to_omni_components()
|
||||
else:
|
||||
with pytest.raises(ValueError, match="at most 15 seconds"):
|
||||
encode_media(media, **kwargs)
|
||||
assert scopes == [video_vae, audio_vae]
|
||||
|
||||
|
||||
def test_encoder_output_reuses_encoder_handoff_and_round_trips() -> None:
|
||||
expected = MiniMaxH3EncoderConditioning(
|
||||
hidden_states=torch.randn(3, 5120, dtype=torch.bfloat16),
|
||||
token_tags=torch.tensor([1, 0, 1], dtype=torch.int64),
|
||||
task="t2va",
|
||||
height=256,
|
||||
width=448,
|
||||
num_frames=17,
|
||||
latent_t=5,
|
||||
audio_t=10,
|
||||
)
|
||||
|
||||
source = SimpleNamespace(
|
||||
finished=True,
|
||||
request_id="request-1",
|
||||
outputs=[SimpleNamespace(multimodal_output=flatten_payload(expected.to_omni_payload()))],
|
||||
)
|
||||
prompt = {
|
||||
"prompt": "hello",
|
||||
"additional_information": {"global_request_id": ["request-1"]},
|
||||
}
|
||||
|
||||
result = encoder2diffusion([source], prompt)
|
||||
|
||||
assert result is not None
|
||||
payload = result["additional_information"]["encoder_output"]
|
||||
actual = MiniMaxH3EncoderConditioning.from_omni_payload(payload)
|
||||
torch.testing.assert_close(actual.hidden_states, expected.hidden_states)
|
||||
torch.testing.assert_close(actual.token_tags, expected.token_tags)
|
||||
|
||||
|
||||
def test_encoder_releases_workspace_after_cpu_payload(monkeypatch) -> None:
|
||||
from vllm_omni.model_executor.models.minimax_h3 import encoder as encoder_module
|
||||
|
||||
model = encoder_module.MiniMaxH3Encoder.__new__(encoder_module.MiniMaxH3Encoder)
|
||||
torch.nn.Module.__init__(model)
|
||||
model._component_leader = True
|
||||
model._token_tags = torch.tensor([1])
|
||||
info = {
|
||||
"meta": {"minimax_h3_encoder_request": {}},
|
||||
"_minimax_h3_encoder_media_conditioning": MiniMaxH3EncoderMediaConditioning(
|
||||
task="t2va",
|
||||
height=256,
|
||||
width=448,
|
||||
num_frames=17,
|
||||
latent_t=5,
|
||||
audio_t=10,
|
||||
),
|
||||
}
|
||||
empty_cache_calls = 0
|
||||
|
||||
def empty_cache() -> None:
|
||||
nonlocal empty_cache_calls
|
||||
empty_cache_calls += 1
|
||||
|
||||
monkeypatch.setattr(torch.accelerator, "empty_cache", empty_cache)
|
||||
output = model.make_omni_output(
|
||||
torch.zeros(1, 5120),
|
||||
model_intermediate_buffer=[info],
|
||||
request_sample_eligible=[True],
|
||||
)
|
||||
|
||||
assert empty_cache_calls == 1
|
||||
assert output.multimodal_outputs is not None
|
||||
assert output.multimodal_outputs["embed"]["embedding"][0].device.type == "cpu"
|
||||
@@ -2,8 +2,6 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Regression tests for MiniMax H3's disaggregated text-encoder contract."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -12,39 +10,37 @@ import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.data_entry_keys import OmniPayload, deserialize_payload, serialize_payload
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_load_audio,
|
||||
resolve_minimax_h3_diffusion_model_path,
|
||||
from vllm_omni.data_entry_keys import flatten_payload
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import resolve_minimax_h3_diffusion_model_path
|
||||
from vllm_omni.engine.serialization import (
|
||||
deserialize_additional_information,
|
||||
serialize_additional_information,
|
||||
)
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_executor.models.minimax_h3.checkpoint import (
|
||||
resolve_minimax_h3_model_root,
|
||||
resolve_minimax_h3_encoder_model_root,
|
||||
resolve_minimax_h3_partition,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MINIMAX_H3_CONDITION_LABELS_KEY,
|
||||
MINIMAX_H3_ENCODER_REQUEST_KEY,
|
||||
MINIMAX_H3_PRESENTATION_TASK_KEY,
|
||||
MINIMAX_H3_TEXT_CONDITIONING_SCHEMA,
|
||||
MiniMaxH3TextConditioning,
|
||||
MiniMaxH3EncoderConditioning,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import _audio_items, _load_audio
|
||||
from vllm_omni.model_executor.models.minimax_h3.preprocessing import (
|
||||
minimax_h3_ref2va_presentation,
|
||||
minimax_h3_ref2va_video_presentation,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.reference_video import (
|
||||
MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY,
|
||||
deserialize_prepared_reference_videos,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.text_encoder import (
|
||||
MiniMaxH3MultiModalProcessor,
|
||||
_build_minimax_h3_presentation,
|
||||
)
|
||||
from vllm_omni.model_executor.stage_input_processors.minimax_h3 import (
|
||||
_audio_items,
|
||||
_diffusion_sampling_params,
|
||||
prepare_text_encoder_prompt,
|
||||
text_encoder2diffusion,
|
||||
encoder2diffusion,
|
||||
prepare_encoder_prompt,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
@@ -66,13 +62,14 @@ class _SegmentTokenizer:
|
||||
return self._special_ids[token]
|
||||
|
||||
|
||||
def test_h3_processor_reprocesses_media_instead_of_using_partial_sender_cache(mocker):
|
||||
def test_h3_processor_reprocesses_media_instead_of_using_partial_sender_cache(monkeypatch):
|
||||
processor = object.__new__(MiniMaxH3MultiModalProcessor)
|
||||
sentinel = ([1, 2, 3], object(), True)
|
||||
apply_processor = mocker.patch.object(
|
||||
apply_processor = Mock(return_value=sentinel)
|
||||
monkeypatch.setattr(
|
||||
MiniMaxH3MultiModalProcessor,
|
||||
"_apply_hf_processor",
|
||||
return_value=sentinel,
|
||||
apply_processor,
|
||||
)
|
||||
inputs = object()
|
||||
timing_ctx = object()
|
||||
@@ -119,9 +116,11 @@ def test_fused_audio_loader_accepts_list_waveform_pair():
|
||||
def test_prepare_ref2va_keeps_original_text_and_exact_condition_order():
|
||||
prompt = {
|
||||
"prompt": "hello",
|
||||
"additional_information": {"global_request_id": ["request-1"]},
|
||||
"model_intermediate_buffer": {"private": "preserved"},
|
||||
"multi_modal_data": {
|
||||
"image": Image.new("RGB", (256, 256)),
|
||||
"audio": [np.zeros(16), 16_000],
|
||||
"audio": [np.zeros(32_000), 16_000],
|
||||
},
|
||||
}
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
@@ -130,7 +129,7 @@ def test_prepare_ref2va_keeps_original_text_and_exact_condition_order():
|
||||
extra_args={"task": "ref2va"},
|
||||
)
|
||||
|
||||
transformed = prepare_text_encoder_prompt(prompt, [sampling])
|
||||
transformed = prepare_encoder_prompt(prompt, [sampling])
|
||||
|
||||
assert transformed["prompt"] == "hello"
|
||||
assert len(transformed["multi_modal_data"]["image"]) == 1
|
||||
@@ -140,223 +139,148 @@ def test_prepare_ref2va_keeps_original_text_and_exact_condition_order():
|
||||
("image", 1),
|
||||
("audio", 1),
|
||||
]
|
||||
assert transformed["model_intermediate_buffer"] == {"private": "preserved"}
|
||||
runner_info = transformed["additional_information"]
|
||||
for _ in range(2):
|
||||
wire = serialize_additional_information(runner_info)
|
||||
runner_info = deserialize_additional_information(wire)
|
||||
assert runner_info["global_request_id"] == ["request-1"]
|
||||
request_metadata = runner_info["meta"][MINIMAX_H3_ENCODER_REQUEST_KEY]
|
||||
assert request_metadata["task"] == "ref2va"
|
||||
assert isinstance(
|
||||
runner_info["hidden_states"]["layers"][0],
|
||||
torch.Tensor,
|
||||
)
|
||||
assert isinstance(
|
||||
runner_info["hidden_states"]["layers"][1],
|
||||
torch.Tensor,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder import MiniMaxH3Encoder
|
||||
|
||||
media = MiniMaxH3Encoder._media_input(runner_info)
|
||||
assert media.task == "ref2va"
|
||||
assert len(media.images) == 1
|
||||
assert len(media.audios) == 1
|
||||
|
||||
|
||||
def test_text_encoder_prompt_rejects_injected_prepared_video_descriptor():
|
||||
prompt = {
|
||||
"prompt": "hello",
|
||||
"additional_information": {"meta": {MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY: '{"artifact_dir":"/tmp/user"}'}},
|
||||
}
|
||||
sampling = OmniDiffusionSamplingParams(height=256, width=448, extra_args={"task": "t2va"})
|
||||
def _mock_ref2va_video_with_audio(monkeypatch, *, duration_seconds: float) -> None:
|
||||
from vllm_omni.model_executor.models.minimax_h3 import encoder_processing
|
||||
|
||||
transformed = prepare_text_encoder_prompt(prompt, [sampling])
|
||||
|
||||
assert MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY not in transformed["additional_information"]["meta"]
|
||||
|
||||
|
||||
def test_prepare_ref2va_video_uses_shared_frame_sampler_once(monkeypatch):
|
||||
prepare_videos = Mock()
|
||||
|
||||
def prepare(_videos, *, target_frame_count, workdir, start_time_seconds):
|
||||
prepare_videos(_videos, target_frame_count, workdir, start_time_seconds)
|
||||
prepared_path = os.path.join(workdir, "prepared.mp4")
|
||||
open(prepared_path, "wb").close()
|
||||
return [
|
||||
{
|
||||
"original_path": "/tmp/input.mp4",
|
||||
"prepared_path": prepared_path,
|
||||
"input_has_audio": True,
|
||||
"width": 448,
|
||||
"height": 256,
|
||||
"start_time_seconds": 0.0,
|
||||
"duration_seconds": 5.2,
|
||||
"audio_duration_seconds": 5.2,
|
||||
}
|
||||
]
|
||||
|
||||
sample_frames = Mock(return_value={"frames": [np.zeros((4, 4, 3), dtype=np.uint8)]})
|
||||
monkeypatch.setattr("vllm_omni.model_executor.stage_input_processors.minimax_h3.prepare_reference_videos", prepare)
|
||||
sample_rate = 16_000
|
||||
frames = np.zeros((4, 32, 32, 3), dtype=np.uint8)
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.model_executor.stage_input_processors.minimax_h3.sample_reference_video_frames",
|
||||
sample_frames,
|
||||
encoder_processing,
|
||||
"prepare_reference_videos",
|
||||
lambda *args, **kwargs: [
|
||||
{
|
||||
"prepared_path": "prepared.mp4",
|
||||
"original_path": "original.mp4",
|
||||
"input_has_audio": True,
|
||||
"duration_seconds": duration_seconds,
|
||||
"audio_duration_seconds": duration_seconds,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(encoder_processing, "load_video_frames", lambda path: frames)
|
||||
monkeypatch.setattr(
|
||||
encoder_processing,
|
||||
"sample_reference_video_frames",
|
||||
lambda *args, **kwargs: {"block_timestamps": [0.0], "frames": frames[:1]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
encoder_processing,
|
||||
"load_video_audio",
|
||||
lambda *args, **kwargs: (torch.zeros(round(duration_seconds * sample_rate)), sample_rate),
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_ref2va_rejects_short_embedded_video_audio(monkeypatch):
|
||||
_mock_ref2va_video_with_audio(monkeypatch, duration_seconds=1.0)
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=256,
|
||||
width=448,
|
||||
num_frames=96,
|
||||
extra_args={"task": "ref2va"},
|
||||
)
|
||||
prompt = {"prompt": "hello", "multi_modal_data": {"video": "original.mp4"}}
|
||||
|
||||
with pytest.raises(OmniClientError, match=r"duration must be in \[2, 15\] seconds, got 1\.000"):
|
||||
prepare_encoder_prompt(prompt, [sampling])
|
||||
|
||||
|
||||
def test_prepare_ref2va_keeps_embedded_and_standalone_audio_budgets_separate(monkeypatch):
|
||||
duration_seconds = 8.0
|
||||
sample_rate = 16_000
|
||||
_mock_ref2va_video_with_audio(monkeypatch, duration_seconds=duration_seconds)
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=256,
|
||||
width=448,
|
||||
num_frames=240,
|
||||
extra_args={"task": "ref2va"},
|
||||
)
|
||||
prompt = {
|
||||
"prompt": "hello",
|
||||
"multi_modal_data": {"video": "/tmp/input.mp4"},
|
||||
"multi_modal_data": {
|
||||
"video": "original.mp4",
|
||||
"audio": (torch.zeros(int(duration_seconds * sample_rate)), sample_rate),
|
||||
},
|
||||
}
|
||||
sampling = OmniDiffusionSamplingParams(height=256, width=448, num_frames=124, extra_args={"task": "ref2va"})
|
||||
|
||||
transformed = prepare_text_encoder_prompt(prompt, [sampling])
|
||||
|
||||
prepare_videos.assert_called_once()
|
||||
_, target_frame_count, artifact_dir, start_time_seconds = prepare_videos.call_args.args
|
||||
assert target_frame_count == 124
|
||||
assert start_time_seconds is None
|
||||
descriptor = transformed["additional_information"]["meta"][MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY]
|
||||
described_dir, described_videos = deserialize_prepared_reference_videos(descriptor)
|
||||
assert described_dir == artifact_dir
|
||||
assert os.path.isfile(described_videos[0]["prepared_path"])
|
||||
sample_frames.assert_called_once_with(described_videos[0]["prepared_path"])
|
||||
assert prompt["multi_modal_data"] == {"video": "/tmp/input.mp4"}
|
||||
transformed = prepare_encoder_prompt(prompt, [sampling])
|
||||
assert transformed["mm_processor_kwargs"][MINIMAX_H3_CONDITION_LABELS_KEY] == [
|
||||
("audio", 1),
|
||||
("video", 1),
|
||||
("audio", 2),
|
||||
]
|
||||
shutil.rmtree(artifact_dir)
|
||||
|
||||
|
||||
def test_stage_wire_rejects_multiple_text_encoder_sources():
|
||||
with pytest.raises(RuntimeError, match="exactly one text-encoder source"):
|
||||
text_encoder2diffusion([SimpleNamespace(), SimpleNamespace()], prompt={"prompt": "hello"})
|
||||
def _encoder_output() -> dict:
|
||||
return MiniMaxH3EncoderConditioning(
|
||||
hidden_states=torch.randn(3, 5120, dtype=torch.bfloat16),
|
||||
token_tags=torch.tensor([1, 0, 1], dtype=torch.int64),
|
||||
task="t2va",
|
||||
height=256,
|
||||
width=448,
|
||||
num_frames=17,
|
||||
latent_t=5,
|
||||
audio_t=10,
|
||||
).to_omni_payload()
|
||||
|
||||
|
||||
def test_stage_wire_rejects_request_id_mismatch():
|
||||
source = SimpleNamespace(request_id="other", outputs=[SimpleNamespace()])
|
||||
def test_encoder2diffusion_reuses_encoder_handoff() -> None:
|
||||
prompt = {
|
||||
"prompt": "hello",
|
||||
"additional_information": {"global_request_id": ["req-1"]},
|
||||
}
|
||||
|
||||
with pytest.raises(RuntimeError, match="request ID does not match"):
|
||||
text_encoder2diffusion([source], prompt=prompt)
|
||||
|
||||
|
||||
def test_stage_wire_rejects_multiple_completions():
|
||||
source = SimpleNamespace(request_id="req-1", outputs=[SimpleNamespace(), SimpleNamespace()])
|
||||
|
||||
with pytest.raises(RuntimeError, match="exactly one completion"):
|
||||
text_encoder2diffusion([source], prompt={"prompt": "hello"})
|
||||
|
||||
|
||||
def _source_output(payload: dict) -> SimpleNamespace:
|
||||
completion = SimpleNamespace(multimodal_output=payload)
|
||||
return SimpleNamespace(request_id="request-1", outputs=[completion])
|
||||
|
||||
|
||||
def _diffusion_prompt() -> dict:
|
||||
return {
|
||||
"request_id": "request-1",
|
||||
"prompt": "test prompt",
|
||||
"additional_information": {},
|
||||
}
|
||||
|
||||
|
||||
def _valid_text_conditioning_payload() -> OmniPayload:
|
||||
return {
|
||||
"hidden_states": {
|
||||
"output": torch.zeros(4, 5120, dtype=torch.bfloat16),
|
||||
},
|
||||
"meta": {
|
||||
"token_role_ids": torch.tensor([[1], [1], [0], [0]], dtype=torch.int64),
|
||||
"multi_modal_data": {"image": object()},
|
||||
"additional_information": {
|
||||
"private": "preserved",
|
||||
"meta": {MINIMAX_H3_ENCODER_REQUEST_KEY: {"task": "t2va"}},
|
||||
"hidden_states": {"layers": {0: torch.zeros(1)}},
|
||||
},
|
||||
"model_intermediate_buffer": {"private": "encoder-only"},
|
||||
}
|
||||
|
||||
|
||||
def test_text_encoder2diffusion_reads_hidden_states_output_and_token_role_ids() -> None:
|
||||
payload = _valid_text_conditioning_payload()
|
||||
hidden = payload["hidden_states"]["output"]
|
||||
token_role_ids = payload["meta"]["token_role_ids"]
|
||||
|
||||
result = text_encoder2diffusion(
|
||||
[_source_output(payload)],
|
||||
_diffusion_prompt(),
|
||||
source = SimpleNamespace(
|
||||
finished=True,
|
||||
request_id="request-1",
|
||||
outputs=[SimpleNamespace(multimodal_output=flatten_payload(_encoder_output()))],
|
||||
)
|
||||
|
||||
conditioning = result["additional_information"]["text_encoder_output"]
|
||||
torch.testing.assert_close(conditioning["hidden_states"], hidden)
|
||||
torch.testing.assert_close(conditioning["token_tags"], token_role_ids.squeeze(-1))
|
||||
result = encoder2diffusion([source], prompt)
|
||||
|
||||
assert result["prompt"] == "test prompt"
|
||||
assert result["multi_modal_data"] is None
|
||||
assert "model_intermediate_buffer" not in result
|
||||
additional_information = result["additional_information"]
|
||||
assert additional_information["private"] == "preserved"
|
||||
assert "hidden_states" not in additional_information
|
||||
assert "meta" not in additional_information
|
||||
parsed = MiniMaxH3EncoderConditioning.from_omni_payload(additional_information["encoder_output"])
|
||||
assert parsed.task == "t2va"
|
||||
|
||||
|
||||
def test_text_conditioning_artifact_round_trips_through_omni_payload_wire_format() -> None:
|
||||
payload = _valid_text_conditioning_payload()
|
||||
|
||||
wire = serialize_payload(payload)
|
||||
|
||||
assert wire is not None
|
||||
restored = deserialize_payload(wire)
|
||||
conditioning = MiniMaxH3TextConditioning.from_omni_payload(restored)
|
||||
assert MINIMAX_H3_TEXT_CONDITIONING_SCHEMA == "minimax_h3.text_conditioning/v1"
|
||||
torch.testing.assert_close(
|
||||
conditioning.hidden_states,
|
||||
payload["hidden_states"]["output"],
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
conditioning.token_tags,
|
||||
payload["meta"]["token_role_ids"].squeeze(-1),
|
||||
)
|
||||
assert conditioning.hidden_states.dtype == torch.bfloat16
|
||||
assert conditioning.hidden_states.is_contiguous()
|
||||
assert conditioning.token_tags.dtype == torch.int64
|
||||
assert conditioning.token_tags.is_contiguous()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "message"),
|
||||
[
|
||||
({"meta": {"token_role_ids": torch.ones(4, 1)}}, "no hidden_states payload"),
|
||||
({"hidden_states": {}, "meta": {"token_role_ids": torch.ones(4, 1)}}, "no hidden_states.output tensor"),
|
||||
({"hidden_states": {"output": torch.randn(4, 5120)}}, "no conditioning metadata"),
|
||||
({"hidden_states": {"output": torch.randn(4, 5120)}, "meta": {}}, "no token_role_ids tensor"),
|
||||
(
|
||||
{
|
||||
"hidden_states": {"output": torch.randn(4, 5120)},
|
||||
"meta": {"token_role_ids": torch.ones(4)},
|
||||
},
|
||||
r"must have shape \[tokens, 1\]",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_text_encoder2diffusion_rejects_invalid_conditioning_payload(payload: dict, message: str) -> None:
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
text_encoder2diffusion([_source_output(payload)], _diffusion_prompt())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "message"),
|
||||
[
|
||||
("hidden_width", r"hidden_states must have shape \[tokens, 5120\]"),
|
||||
("hidden_dtype", "hidden_states must have dtype torch.bfloat16"),
|
||||
("hidden_noncontiguous", "hidden_states must use contiguous strided layout"),
|
||||
("hidden_non_strided", "hidden_states must use contiguous strided layout"),
|
||||
("token_dtype", "stage-wire token_role_ids must have dtype torch.int64"),
|
||||
("token_layout", "stage-wire token_role_ids must use contiguous strided layout"),
|
||||
("token_count", "token_tags must align with hidden_states"),
|
||||
("token_value", "token_tags must contain only 0 and 1"),
|
||||
],
|
||||
)
|
||||
def test_text_encoder2diffusion_rejects_h3_v1_contract_mismatch(case: str, message: str) -> None:
|
||||
payload = _valid_text_conditioning_payload()
|
||||
|
||||
if case == "hidden_width":
|
||||
payload["hidden_states"]["output"] = torch.zeros(4, 5119, dtype=torch.bfloat16)
|
||||
elif case == "hidden_dtype":
|
||||
payload["hidden_states"]["output"] = torch.zeros(4, 5120, dtype=torch.float32)
|
||||
elif case == "hidden_noncontiguous":
|
||||
payload["hidden_states"]["output"] = torch.empty(5120, 4, dtype=torch.bfloat16).t()
|
||||
elif case == "hidden_non_strided":
|
||||
payload["hidden_states"]["output"] = torch.empty(
|
||||
(4, 5120),
|
||||
dtype=torch.bfloat16,
|
||||
layout=torch.sparse_coo,
|
||||
)
|
||||
elif case == "token_dtype":
|
||||
payload["meta"]["token_role_ids"] = torch.tensor([[1], [1], [0], [0]], dtype=torch.int32)
|
||||
elif case == "token_layout":
|
||||
payload["meta"]["token_role_ids"] = torch.tensor(
|
||||
[[1], [0], [1], [0], [1], [0], [1], [0]],
|
||||
dtype=torch.int64,
|
||||
)[::2]
|
||||
elif case == "token_count":
|
||||
payload["meta"]["token_role_ids"] = torch.tensor([[1], [0], [0]], dtype=torch.int64)
|
||||
elif case == "token_value":
|
||||
payload["meta"]["token_role_ids"] = torch.tensor([[1], [1], [2], [0]], dtype=torch.int64)
|
||||
else: # pragma: no cover - parameterization is exhaustive
|
||||
raise AssertionError(f"unknown test case: {case}")
|
||||
|
||||
with pytest.raises(RuntimeError, match=message) as exc_info:
|
||||
text_encoder2diffusion([_source_output(payload)], _diffusion_prompt())
|
||||
|
||||
assert MINIMAX_H3_TEXT_CONDITIONING_SCHEMA in str(exc_info.value)
|
||||
def test_encoder2diffusion_waits_for_one_finished_source() -> None:
|
||||
assert encoder2diffusion([SimpleNamespace(finished=False)], {"prompt": "hello"}) is None
|
||||
with pytest.raises(RuntimeError, match="exactly one encoder source"):
|
||||
encoder2diffusion([SimpleNamespace(), SimpleNamespace()], {"prompt": "hello"})
|
||||
|
||||
|
||||
def test_ref2va_one_image_tokens_and_tags_match_fused_presentation():
|
||||
@@ -421,15 +345,20 @@ def test_checkpoint_resolver_selects_local_partition(tmp_path):
|
||||
(root / "FL2VA" / "text_encoder").mkdir(parents=True)
|
||||
(root / "Ref2VA" / "text_encoder").mkdir(parents=True)
|
||||
|
||||
assert resolve_minimax_h3_model_root(str(root), None, "fl2va") == str(root / "FL2VA" / "text_encoder")
|
||||
assert resolve_minimax_h3_model_root(str(root), None, "ref2va") == str(root / "Ref2VA" / "text_encoder")
|
||||
assert resolve_minimax_h3_model_root(str(root / "Ref2VA"), None, None) == str(root / "Ref2VA" / "text_encoder")
|
||||
assert resolve_minimax_h3_model_root(str(root / "FL2VA"), None, "ref2va") == str(root / "Ref2VA" / "text_encoder")
|
||||
assert resolve_minimax_h3_encoder_model_root(str(root), None, "fl2va") == str(root / "FL2VA" / "text_encoder")
|
||||
assert resolve_minimax_h3_encoder_model_root(str(root), None, "ref2va") == str(root / "Ref2VA" / "text_encoder")
|
||||
assert resolve_minimax_h3_encoder_model_root(str(root), None, "combined") == str(root / "FL2VA" / "text_encoder")
|
||||
assert resolve_minimax_h3_encoder_model_root(str(root / "Ref2VA"), None, None) == str(
|
||||
root / "Ref2VA" / "text_encoder"
|
||||
)
|
||||
assert resolve_minimax_h3_encoder_model_root(str(root / "FL2VA"), None, "ref2va") == str(
|
||||
root / "Ref2VA" / "text_encoder"
|
||||
)
|
||||
|
||||
|
||||
def test_checkpoint_resolver_rejects_unknown_task(tmp_path):
|
||||
with pytest.raises(ValueError, match="task_type must be one of"):
|
||||
resolve_minimax_h3_model_root(str(tmp_path), None, "unknown")
|
||||
resolve_minimax_h3_encoder_model_root(str(tmp_path), None, "unknown")
|
||||
|
||||
|
||||
def test_partition_resolver_preserves_consumer_auto_default(tmp_path):
|
||||
|
||||
@@ -804,6 +804,7 @@ class _DiffusionConfigProjection:
|
||||
"transformer": True,
|
||||
"vae": True,
|
||||
"text_encoder": True,
|
||||
"vae_encoder": True,
|
||||
}
|
||||
)
|
||||
override_transformer_cls_name: str | None = None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# MiniMax H3: vLLM Qwen3-VL text encoder + diffusion video/audio generation.
|
||||
# MiniMax H3: unified text/video/audio encoder stage + diffusion generation stage.
|
||||
pipeline: minimax_h3_disaggregated
|
||||
async_chunk: false
|
||||
trust_remote_code: true
|
||||
@@ -8,7 +8,16 @@ stages:
|
||||
- stage_id: 0
|
||||
devices: "0,1"
|
||||
max_num_seqs: 1
|
||||
gpu_memory_utilization: 0.6
|
||||
tensor_parallel_size: 2
|
||||
hf_overrides:
|
||||
minimax_h3_encoder_components:
|
||||
text_encoder:
|
||||
parallel_mode: tp
|
||||
video_vae:
|
||||
parallel_mode: patch
|
||||
audio_vae:
|
||||
parallel_mode: leader
|
||||
default_sampling_params:
|
||||
max_tokens: 1
|
||||
temperature: 0.0
|
||||
@@ -19,6 +28,7 @@ stages:
|
||||
max_num_seqs: 1
|
||||
model_loaded:
|
||||
text_encoder: false
|
||||
vae_encoder: false
|
||||
parallel_config:
|
||||
tensor_parallel_size: 1
|
||||
ulysses_degree: 4
|
||||
|
||||
@@ -8,7 +8,16 @@ stages:
|
||||
- stage_id: 0
|
||||
devices: "0,1"
|
||||
max_num_seqs: 1
|
||||
gpu_memory_utilization: 0.6
|
||||
tensor_parallel_size: 2
|
||||
hf_overrides:
|
||||
minimax_h3_encoder_components:
|
||||
text_encoder:
|
||||
parallel_mode: tp
|
||||
video_vae:
|
||||
parallel_mode: patch
|
||||
audio_vae:
|
||||
parallel_mode: leader
|
||||
default_sampling_params:
|
||||
max_tokens: 1
|
||||
temperature: 0.0
|
||||
@@ -19,6 +28,7 @@ stages:
|
||||
max_num_seqs: 1
|
||||
model_loaded:
|
||||
text_encoder: false
|
||||
vae_encoder: false
|
||||
parallel_config:
|
||||
tensor_parallel_size: 1
|
||||
ulysses_degree: 4
|
||||
|
||||
@@ -984,6 +984,7 @@ class OmniDiffusionConfig:
|
||||
"transformer": True,
|
||||
"vae": True,
|
||||
"text_encoder": True,
|
||||
"vae_encoder": True,
|
||||
}
|
||||
)
|
||||
override_transformer_cls_name: str | None = None
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,8 +15,10 @@ import torch
|
||||
import torch.distributed as dist
|
||||
import torch.nn as nn
|
||||
from PIL import Image
|
||||
from safetensors import safe_open
|
||||
from transformers.dynamic_module_utils import get_class_from_dynamic_module
|
||||
from vllm.logger import init_logger
|
||||
from vllm.utils.torch_utils import set_default_torch_dtype
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import (
|
||||
DistributedVaeMixin,
|
||||
@@ -106,6 +108,121 @@ def _load_remote_component(
|
||||
return component_cls.from_pretrained(component_path)
|
||||
|
||||
|
||||
def _check_trust_remote_code(
|
||||
component_path: str,
|
||||
config: dict[str, Any],
|
||||
trust_remote_code: bool,
|
||||
) -> None:
|
||||
"""Apply the remote-code gate before either full or encoder-only loading."""
|
||||
|
||||
class_reference = (config.get("auto_map") or {}).get("AutoModel")
|
||||
if isinstance(class_reference, str) and not trust_remote_code:
|
||||
raise ValueError(
|
||||
f"Loading {component_path} executes the modeling code shipped with "
|
||||
f"the checkpoint (auto_map.AutoModel = {class_reference}). Pass "
|
||||
"--trust-remote-code (or trust_remote_code=True) to allow it."
|
||||
)
|
||||
|
||||
|
||||
def _load_selected_state_dict(
|
||||
model: nn.Module,
|
||||
weights_path: Path,
|
||||
prefixes: tuple[str, ...],
|
||||
) -> None:
|
||||
with safe_open(str(weights_path), framework="pt", device="cpu") as checkpoint:
|
||||
selected = sorted(name for name in checkpoint.keys() if name.startswith(prefixes))
|
||||
if not selected:
|
||||
raise ValueError(f"{weights_path}: no VAE weights match prefixes {prefixes!r}")
|
||||
state_dict = {name: checkpoint.get_tensor(name) for name in selected}
|
||||
model.load_state_dict(state_dict, strict=True)
|
||||
|
||||
|
||||
def _remove_modules(model: nn.Module, names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if not isinstance(getattr(model, name, None), nn.Module):
|
||||
raise ValueError(f"MiniMax H3 VAE model has no module {name!r}")
|
||||
delattr(model, name)
|
||||
|
||||
|
||||
def _load_video_vae_encoder(
|
||||
component_path: str,
|
||||
config: dict[str, Any],
|
||||
) -> nn.Module:
|
||||
class_reference = (config.get("auto_map") or {}).get("AutoModel")
|
||||
if not isinstance(class_reference, str):
|
||||
raise ValueError(f"{component_path}/config.json must define auto_map.AutoModel")
|
||||
component_cls = get_class_from_dynamic_module(class_reference, component_path)
|
||||
component_module = importlib.import_module(component_cls.__module__)
|
||||
source_classes = getattr(component_module, "_SOURCE_CLASSES", None)
|
||||
source_class_name = config.get("source_class_name")
|
||||
if not isinstance(source_classes, dict) or source_class_name not in source_classes:
|
||||
raise ValueError(f"unsupported MiniMax H3 video VAE source class {source_class_name!r}")
|
||||
if bool(config["vae_parallel_tiling"]):
|
||||
ensure_parallel_state = getattr(component_module, "_ensure_vae_parallel_state", None)
|
||||
if not callable(ensure_parallel_state):
|
||||
raise ValueError("MiniMax H3 video VAE remote module does not expose its parallel-state initializer")
|
||||
ensure_parallel_state()
|
||||
|
||||
source_path = Path(component_path) / str(config["source_path"])
|
||||
weights_path = source_path / str(config["source_safetensors_path"])
|
||||
load_kwargs = {
|
||||
"clip_length": int(config["vae_clip_length"]),
|
||||
"token_drop": int(config["vae_token_drop"]),
|
||||
"encoder_tiling": int(config["vae_encoder_tiling"]),
|
||||
"decoder_tiling": int(config["vae_decoder_tiling"]),
|
||||
"parallel_tiling": int(config["vae_parallel_tiling"]),
|
||||
"tile_size": int(config["vae_tile_size"]),
|
||||
"tile_overlap_min": int(config["vae_tile_overlap_min"]),
|
||||
"encoder_parallel": int(config["vae_encoder_parallel"]),
|
||||
"decoder_parallel": int(config["vae_decoder_parallel"]),
|
||||
"chunk_dim": int(config["vae_chunk_dim"]),
|
||||
}
|
||||
source_cls = source_classes[source_class_name]
|
||||
source_config = source_cls.load_config(str(source_path))
|
||||
with set_default_torch_dtype(torch.float32), torch.device("cpu"):
|
||||
model, _unused = source_cls.from_config(source_config, return_unused_kwargs=True, **load_kwargs)
|
||||
_remove_modules(model, ("decoder", "post_quant_conv"))
|
||||
_load_selected_state_dict(model, weights_path, ("encoder.", "quant_conv."))
|
||||
model.eval()
|
||||
return component_cls(model)
|
||||
|
||||
|
||||
def _load_audio_vae_encoder(
|
||||
component_path: str,
|
||||
config: dict[str, Any],
|
||||
) -> nn.Module:
|
||||
class_reference = (config.get("auto_map") or {}).get("AutoModel")
|
||||
if not isinstance(class_reference, str):
|
||||
raise ValueError(f"{component_path}/config.json must define auto_map.AutoModel")
|
||||
component_cls = get_class_from_dynamic_module(class_reference, component_path)
|
||||
component_module = importlib.import_module(component_cls.__module__)
|
||||
load_yaml = getattr(component_module, "_load_yaml", None)
|
||||
model_cls = getattr(component_module, "DacAudioVAE", None)
|
||||
if not callable(load_yaml) or not isinstance(model_cls, type):
|
||||
raise ValueError("MiniMax H3 audio VAE remote module does not expose its source loader")
|
||||
|
||||
component_dir = Path(component_path)
|
||||
audio_config = load_yaml(component_dir / str(config["source_config_path"]))
|
||||
metadata_path = component_dir / str(config["source_metadata_path"])
|
||||
metadata_doc = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
metadata = metadata_doc["metadata"]["kwargs"]
|
||||
with set_default_torch_dtype(torch.float32), torch.device("cpu"):
|
||||
model = model_cls(
|
||||
encoder_rates=metadata["encoder_rates"],
|
||||
decoder_rates=metadata["decoder_rates"],
|
||||
attn_proj=metadata["attn_proj"],
|
||||
decoder_type=metadata["decoder_type"],
|
||||
decoder_dim=audio_config["model_config"]["decoder_dim"],
|
||||
vae_latent_channels=audio_config["model_config"]["vae_latent_channels"],
|
||||
sample_rate=metadata["sample_rate"],
|
||||
)
|
||||
_remove_modules(model, ("decoder", "dec_in_proj", "logs_proj"))
|
||||
weights_path = component_dir / str(config["source_safetensors_path"])
|
||||
_load_selected_state_dict(model, weights_path, ("encoder.", "pre_block.", "mean_proj."))
|
||||
model.eval()
|
||||
return component_cls(model)
|
||||
|
||||
|
||||
class _AudioVAEDeterminismContext(AbstractContextManager):
|
||||
def __enter__(self):
|
||||
backends = torch.backends
|
||||
@@ -156,16 +273,24 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
*,
|
||||
device: torch.device,
|
||||
load_device: torch.device | None = None,
|
||||
encode_only: bool = False,
|
||||
decode_only: bool = False,
|
||||
trust_remote_code: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._device_target = device
|
||||
self.encode_only = bool(encode_only)
|
||||
self.decode_only = bool(decode_only)
|
||||
if self.encode_only and self.decode_only:
|
||||
raise ValueError("MiniMax H3 video VAE cannot be both encode-only and decode-only")
|
||||
self.config_dict = _load_component_config(component_path)
|
||||
self.remote = _load_remote_component(
|
||||
component_path,
|
||||
self.config_dict,
|
||||
trust_remote_code=trust_remote_code,
|
||||
)
|
||||
_check_trust_remote_code(component_path, self.config_dict, trust_remote_code)
|
||||
if self.encode_only:
|
||||
self.remote = _load_video_vae_encoder(component_path, self.config_dict)
|
||||
else:
|
||||
self.remote = _load_remote_component(component_path, self.config_dict, trust_remote_code=trust_remote_code)
|
||||
if self.decode_only:
|
||||
_remove_modules(self.remote.model, ("encoder", "quant_conv"))
|
||||
# Match the reference loader contract before installing inference-only
|
||||
# decoder fast paths. Keyframe encoding remains FP32; decoder Linear
|
||||
# weights may be materialized in FP16 because reference decode casts
|
||||
@@ -222,10 +347,11 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
self,
|
||||
parallel_size: int,
|
||||
mode: str = "tile",
|
||||
process_group: dist.ProcessGroup | None = None,
|
||||
) -> None:
|
||||
if mode != "tile":
|
||||
raise ValueError(f"MiniMax H3 VAE supports its native tile parallel mode only, got {mode!r}")
|
||||
group = get_world_group().device_group
|
||||
group = process_group if process_group is not None else get_world_group().device_group
|
||||
world_size = dist.get_world_size(group)
|
||||
rank = dist.get_rank(group)
|
||||
parallel_size = int(parallel_size)
|
||||
@@ -412,15 +538,40 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
cols, _, _ = self.model.split_tiles(int(latent.shape[-1]) * ratio, True)
|
||||
return len(rows) * len(cols)
|
||||
|
||||
def _encoder_tile_count(self, height: int, width: int) -> int:
|
||||
processor = getattr(self.model, "processor", None)
|
||||
align = getattr(processor, "_align_to_total_patch_size", None)
|
||||
if callable(align):
|
||||
height, width = align(int(height), int(width))
|
||||
rows, _, _ = self.model.split_tiles(int(height), False)
|
||||
cols, _, _ = self.model.split_tiles(int(width), False)
|
||||
return len(rows) * len(cols)
|
||||
|
||||
def _encoder_tiling_context(
|
||||
self,
|
||||
height: int,
|
||||
width: int,
|
||||
) -> AbstractContextManager:
|
||||
parallel_size = int(getattr(self, "parallel_size", 1))
|
||||
if parallel_size <= 1:
|
||||
return nullcontext()
|
||||
num_tiles = self._encoder_tile_count(height, width)
|
||||
if num_tiles >= parallel_size:
|
||||
return nullcontext()
|
||||
logger.warning_once(
|
||||
"MiniMax-H3 VAE encode splits a %dx%d input into %d tile(s) but "
|
||||
"the tile group has %d ranks; encoding rank-locally for this "
|
||||
"shape instead, which avoids ranks without tiles hanging the "
|
||||
"collective.",
|
||||
width,
|
||||
height,
|
||||
num_tiles,
|
||||
parallel_size,
|
||||
)
|
||||
return self._rank_local_tiling()
|
||||
|
||||
@contextmanager
|
||||
def _rank_local_tiling(self) -> Iterator[None]:
|
||||
"""Run one decode with tiling kept on this rank, then restore the group.
|
||||
|
||||
Used only when there are fewer tiles than ranks. Every rank then decodes
|
||||
every tile, which is slower than sharing the work but is correct and
|
||||
involves no collective.
|
||||
"""
|
||||
|
||||
state = self._native_parallel_state()
|
||||
saved_state = dict(state)
|
||||
saved_tiling = self.model.parallel_tiling
|
||||
@@ -448,15 +599,24 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
|
||||
@torch.inference_mode()
|
||||
def encode_image(self, image: Image.Image) -> torch.Tensor:
|
||||
if getattr(self, "decode_only", False):
|
||||
raise RuntimeError("MiniMax H3 decode-only video VAE cannot encode images")
|
||||
previous_parallel = self.model.parallel_tiling
|
||||
self.model.parallel_tiling = False
|
||||
if int(getattr(self, "parallel_size", 1)) <= 1:
|
||||
self.model.parallel_tiling = False
|
||||
parameter = next(self.parameters())
|
||||
previous_dtype = parameter.dtype
|
||||
if previous_dtype != torch.float32:
|
||||
self.to(torch.float32)
|
||||
devices = [parameter.device] if parameter.device.type != "cpu" else []
|
||||
try:
|
||||
with torch.random.fork_rng(devices=devices, device_type=parameter.device.type):
|
||||
with (
|
||||
self._encoder_tiling_context(image.height, image.width),
|
||||
torch.random.fork_rng(
|
||||
devices=devices,
|
||||
device_type=parameter.device.type,
|
||||
),
|
||||
):
|
||||
torch.default_generator.manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED)
|
||||
for device in devices:
|
||||
with self.device_module.device(device):
|
||||
@@ -495,13 +655,26 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
self,
|
||||
frames: Any,
|
||||
) -> tuple[torch.Tensor, tuple[int, int, int]]:
|
||||
if getattr(self, "decode_only", False):
|
||||
raise RuntimeError("MiniMax H3 decode-only video VAE cannot encode videos")
|
||||
parameter = next(self.parameters())
|
||||
previous_dtype = parameter.dtype
|
||||
if previous_dtype != torch.float32:
|
||||
self.to(torch.float32)
|
||||
devices = [parameter.device] if parameter.device.type != "cpu" else []
|
||||
shape = getattr(frames, "shape", None)
|
||||
if int(getattr(self, "parallel_size", 1)) > 1:
|
||||
if shape is None or len(shape) != 4:
|
||||
raise ValueError("parallel MiniMax H3 video encode requires a rank-4 frame array")
|
||||
if int(shape[-1]) in {1, 3, 4}:
|
||||
frame_height, frame_width = int(shape[-3]), int(shape[-2])
|
||||
else:
|
||||
frame_height, frame_width = int(shape[-2]), int(shape[-1])
|
||||
tiling_context = self._encoder_tiling_context(frame_height, frame_width)
|
||||
else:
|
||||
tiling_context = nullcontext()
|
||||
try:
|
||||
with torch.random.fork_rng(devices=devices, device_type=parameter.device.type):
|
||||
with tiling_context, torch.random.fork_rng(devices=devices, device_type=parameter.device.type):
|
||||
torch.default_generator.manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED)
|
||||
for device in devices:
|
||||
with self.device_module.device(device):
|
||||
@@ -561,6 +734,8 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
|
||||
@torch.inference_mode()
|
||||
def decode_latent(self, latent: torch.Tensor) -> torch.Tensor:
|
||||
if getattr(self, "encode_only", False):
|
||||
raise RuntimeError("MiniMax H3 encode-only video VAE cannot decode latents")
|
||||
with self._decode_tiling_context(latent):
|
||||
decoded = self.model.decode_base(self._denormalize_latent(latent))
|
||||
return self._normalize_decoded_frames(self.model.processor.revert_tensor(decoded))
|
||||
@@ -598,6 +773,8 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
the complete decode path. After a callback failure, the remaining
|
||||
chunks are decoded and discarded before the exception is re-raised.
|
||||
"""
|
||||
if getattr(self, "encode_only", False):
|
||||
raise RuntimeError("MiniMax H3 encode-only video VAE cannot decode latents")
|
||||
if not callable(on_chunk):
|
||||
raise TypeError("on_chunk must be callable")
|
||||
if not callable(getattr(self.model, "_adaptive_decode", None)):
|
||||
@@ -622,16 +799,24 @@ class MiniMaxH3AudioVAE(nn.Module):
|
||||
*,
|
||||
device: torch.device,
|
||||
load_device: torch.device | None = None,
|
||||
encode_only: bool = False,
|
||||
decode_only: bool = False,
|
||||
trust_remote_code: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._device_target = device
|
||||
self.encode_only = bool(encode_only)
|
||||
self.decode_only = bool(decode_only)
|
||||
if self.encode_only and self.decode_only:
|
||||
raise ValueError("MiniMax H3 audio VAE cannot be both encode-only and decode-only")
|
||||
self.config_dict = _load_component_config(component_path)
|
||||
self.remote = _load_remote_component(
|
||||
component_path,
|
||||
self.config_dict,
|
||||
trust_remote_code=trust_remote_code,
|
||||
)
|
||||
_check_trust_remote_code(component_path, self.config_dict, trust_remote_code)
|
||||
if self.encode_only:
|
||||
self.remote = _load_audio_vae_encoder(component_path, self.config_dict)
|
||||
else:
|
||||
self.remote = _load_remote_component(component_path, self.config_dict, trust_remote_code=trust_remote_code)
|
||||
if self.decode_only:
|
||||
_remove_modules(self.remote.model, ("encoder", "pre_block", "mean_proj"))
|
||||
# The checkpoint's audio VAE contract is FP32 for both reference
|
||||
# encoding and waveform decoding.
|
||||
initial_device = load_device or device
|
||||
@@ -674,6 +859,8 @@ class MiniMaxH3AudioVAE(nn.Module):
|
||||
waveform: torch.Tensor,
|
||||
sample_rate: int,
|
||||
) -> tuple[torch.Tensor, int]:
|
||||
if getattr(self, "decode_only", False):
|
||||
raise RuntimeError("MiniMax H3 decode-only audio VAE cannot encode waveforms")
|
||||
import torchaudio
|
||||
|
||||
waveform = waveform.float()
|
||||
@@ -719,6 +906,8 @@ class MiniMaxH3AudioVAE(nn.Module):
|
||||
|
||||
@torch.inference_mode()
|
||||
def decode_latent(self, latent: torch.Tensor) -> torch.Tensor:
|
||||
if getattr(self, "encode_only", False):
|
||||
raise RuntimeError("MiniMax H3 encode-only audio VAE cannot decode latents")
|
||||
channels = int(self.config_dict["latent_channels"])
|
||||
mean = torch.tensor(
|
||||
self.config_dict["latents_mean"],
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 checkpoint resolution for the AR text-encoder stage."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vllm_omni.model_executor.model_loader.weight_utils import (
|
||||
download_weights_from_hf_specific,
|
||||
)
|
||||
|
||||
MINIMAX_H3_TEXT_ENCODER_DOWNLOAD_PATTERNS = {
|
||||
"fl2va": ["FL2VA/text_encoder/**"],
|
||||
"ref2va": ["Ref2VA/text_encoder/**"],
|
||||
MINIMAX_H3_ENCODER_DOWNLOAD_PATTERNS = {
|
||||
partition: [
|
||||
f"{subdir}/text_encoder/**",
|
||||
f"{subdir}/video_vae/**",
|
||||
f"{subdir}/audio_vae/**",
|
||||
]
|
||||
for partition, subdir in {"fl2va": "FL2VA", "ref2va": "Ref2VA"}.items()
|
||||
}
|
||||
MINIMAX_H3_ENCODER_DOWNLOAD_PATTERNS["combined"] = MINIMAX_H3_ENCODER_DOWNLOAD_PATTERNS["fl2va"]
|
||||
|
||||
|
||||
def resolve_minimax_h3_partition(
|
||||
@@ -32,7 +35,7 @@ def resolve_minimax_h3_partition(
|
||||
return "ref2va" if task == "ref2va" else "fl2va"
|
||||
|
||||
|
||||
def resolve_minimax_h3_model_root(
|
||||
def resolve_minimax_h3_encoder_model_root(
|
||||
model: str,
|
||||
revision: str | None,
|
||||
task_type: str | None,
|
||||
@@ -50,7 +53,7 @@ def resolve_minimax_h3_model_root(
|
||||
snapshot = download_weights_from_hf_specific(
|
||||
model_name_or_path=model,
|
||||
cache_dir=None,
|
||||
allow_patterns=MINIMAX_H3_TEXT_ENCODER_DOWNLOAD_PATTERNS[partition],
|
||||
allow_patterns=MINIMAX_H3_ENCODER_DOWNLOAD_PATTERNS[partition],
|
||||
revision=revision,
|
||||
require_all=True,
|
||||
)
|
||||
@@ -59,4 +62,11 @@ def resolve_minimax_h3_model_root(
|
||||
return str(Path(snapshot) / subdir / "text_encoder")
|
||||
|
||||
|
||||
__all__ = ["resolve_minimax_h3_model_root", "resolve_minimax_h3_partition"]
|
||||
resolve_minimax_h3_model_root = resolve_minimax_h3_encoder_model_root
|
||||
|
||||
|
||||
__all__ = [
|
||||
"resolve_minimax_h3_encoder_model_root",
|
||||
"resolve_minimax_h3_model_root",
|
||||
"resolve_minimax_h3_partition",
|
||||
]
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, fields
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -119,3 +119,502 @@ class MiniMaxH3TextConditioning:
|
||||
"hidden_states": self.hidden_states,
|
||||
"token_tags": self.token_tags,
|
||||
}
|
||||
|
||||
|
||||
MINIMAX_H3_ENCODER_REQUEST_KEY = "minimax_h3_encoder_request"
|
||||
MINIMAX_H3_ENCODER_LAYOUT_KEY = "minimax_h3_encoder_layout"
|
||||
STAGE_SCHEMA_VERSION = 1
|
||||
_ENCODER_WIRE_SCHEMA_ID = 1
|
||||
|
||||
_TASK_TO_CODE = {"t2va": 1, "fl2va": 2, "ref2va": 3}
|
||||
_CODE_TO_TASK = {value: key for key, value in _TASK_TO_CODE.items()}
|
||||
_REF_KIND_TO_CODE = {"image": 1, "video": 2, "video_audio": 3, "audio": 4}
|
||||
_CODE_TO_REF_KIND = {value: key for key, value in _REF_KIND_TO_CODE.items()}
|
||||
_INTEGER_DTYPES = frozenset(
|
||||
{
|
||||
torch.int8,
|
||||
torch.int16,
|
||||
torch.int32,
|
||||
torch.int64,
|
||||
torch.uint8,
|
||||
}
|
||||
)
|
||||
_VIDEO_CONDITION_WIDTH = 96
|
||||
_AUDIO_CONDITION_WIDTH = 32
|
||||
_AUDIO_CONDITION_CHANNELS = 2
|
||||
|
||||
|
||||
def _wire_layout(payload: Mapping[str, Any]) -> torch.Tensor:
|
||||
private_metadata = payload.get("kv_metadata")
|
||||
if not isinstance(private_metadata, Mapping):
|
||||
raise ValueError("MiniMax H3 wire payload requires private layout metadata")
|
||||
layout = private_metadata.get(MINIMAX_H3_ENCODER_LAYOUT_KEY)
|
||||
if isinstance(layout, torch.Tensor):
|
||||
return layout
|
||||
raise ValueError("MiniMax H3 wire payload requires one packed encoder layout tensor")
|
||||
|
||||
|
||||
def _validate_condition_tensors(
|
||||
visual: torch.Tensor | None,
|
||||
visual_shapes: Sequence[Sequence[int]],
|
||||
audio: torch.Tensor | None,
|
||||
audio_lengths: Sequence[int],
|
||||
) -> None:
|
||||
if (visual is None) != (not visual_shapes):
|
||||
raise ValueError("MiniMax H3 visual condition and shapes must be present together")
|
||||
if (audio is None) != (not audio_lengths):
|
||||
raise ValueError("MiniMax H3 audio condition and lengths must be present together")
|
||||
|
||||
parsed_shapes = [tuple(int(item) for item in shape) for shape in visual_shapes]
|
||||
if any(
|
||||
len(shape) != 3 or any(item <= 0 for item in shape) or shape[1] % 2 or shape[2] % 2 for shape in parsed_shapes
|
||||
):
|
||||
raise ValueError("MiniMax H3 visual condition shapes must be positive [T, H, W] triplets with even H/W")
|
||||
if visual is not None:
|
||||
expected_rows = sum(t * (h // 2) * (w // 2) for t, h, w in parsed_shapes)
|
||||
if (
|
||||
visual.dtype != torch.float32
|
||||
or visual.ndim != 2
|
||||
or tuple(visual.shape)
|
||||
!= (
|
||||
expected_rows,
|
||||
_VIDEO_CONDITION_WIDTH,
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"MiniMax H3 visual condition must be FP32 with shape "
|
||||
f"[{expected_rows}, {_VIDEO_CONDITION_WIDTH}], got {visual.dtype} {tuple(visual.shape)}"
|
||||
)
|
||||
|
||||
parsed_lengths = [int(length) for length in audio_lengths]
|
||||
if any(length <= 0 for length in parsed_lengths):
|
||||
raise ValueError("MiniMax H3 audio condition lengths must be positive")
|
||||
if audio is not None:
|
||||
expected_rows = _AUDIO_CONDITION_CHANNELS * sum(parsed_lengths)
|
||||
if (
|
||||
audio.dtype != torch.float32
|
||||
or audio.ndim != 2
|
||||
or tuple(audio.shape)
|
||||
!= (
|
||||
expected_rows,
|
||||
_AUDIO_CONDITION_WIDTH,
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"MiniMax H3 audio condition must be FP32 with shape "
|
||||
f"[{expected_rows}, {_AUDIO_CONDITION_WIDTH}], got {audio.dtype} {tuple(audio.shape)}"
|
||||
)
|
||||
|
||||
|
||||
def _ref_block_rows(ref_blocks: Sequence[Mapping[str, Any]]) -> list[list[int]]:
|
||||
rows: list[list[int]] = []
|
||||
for block in ref_blocks:
|
||||
kind = str(block.get("kind") or "")
|
||||
kind_code = _REF_KIND_TO_CODE.get(kind)
|
||||
if kind_code is None:
|
||||
raise ValueError(f"unsupported MiniMax H3 ref kind {kind!r}")
|
||||
rows.append(
|
||||
[
|
||||
kind_code,
|
||||
int(block.get("ref_audio_t", 0)),
|
||||
int(block.get("latent_t", 0)),
|
||||
int(block.get("latent_h", 0)),
|
||||
int(block.get("latent_w", 0)),
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _decode_ref_blocks(value: torch.Tensor) -> tuple[dict[str, Any], ...]:
|
||||
if value.ndim != 2 or value.shape[1] != 5:
|
||||
raise ValueError("MiniMax H3 ref_blocks metadata must have shape [N, 5]")
|
||||
blocks: list[dict[str, Any]] = []
|
||||
for kind_code, ref_audio_t, ref_t, ref_h, ref_w in value.detach().cpu().tolist():
|
||||
kind = _CODE_TO_REF_KIND.get(int(kind_code))
|
||||
if kind is None:
|
||||
raise ValueError(f"unsupported MiniMax H3 ref kind code {kind_code!r}")
|
||||
block: dict[str, Any] = {"kind": kind}
|
||||
for key, item in (
|
||||
("ref_audio_t", ref_audio_t),
|
||||
("latent_t", ref_t),
|
||||
("latent_h", ref_h),
|
||||
("latent_w", ref_w),
|
||||
):
|
||||
if item:
|
||||
block[key] = int(item)
|
||||
blocks.append(block)
|
||||
return tuple(blocks)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MiniMaxH3EncoderMediaInput:
|
||||
task: str
|
||||
height: int
|
||||
width: int
|
||||
num_frames: int
|
||||
latent_t: int
|
||||
audio_t: int
|
||||
images: tuple[torch.Tensor, ...] = ()
|
||||
videos: tuple[torch.Tensor, ...] = ()
|
||||
video_audios: tuple[tuple[torch.Tensor, int] | None, ...] = ()
|
||||
audios: tuple[tuple[torch.Tensor, int], ...] = ()
|
||||
keyframe_frame_indices: tuple[int, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_mm_tensors(
|
||||
cls,
|
||||
values: Sequence[torch.Tensor],
|
||||
metadata: Mapping[str, Any],
|
||||
) -> MiniMaxH3EncoderMediaInput:
|
||||
if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
|
||||
raise ValueError("MiniMax H3 encoder media must be a tensor sequence")
|
||||
tensors = list(values)
|
||||
if any(not isinstance(item, torch.Tensor) for item in tensors):
|
||||
raise ValueError("MiniMax H3 encoder media input must contain only tensors")
|
||||
task = str(metadata["task"])
|
||||
if task not in _TASK_TO_CODE:
|
||||
raise ValueError(f"unsupported MiniMax H3 task {task!r}")
|
||||
height = int(metadata["height"])
|
||||
width = int(metadata["width"])
|
||||
num_frames = int(metadata["num_frames"])
|
||||
latent_t = int(metadata["latent_t"])
|
||||
audio_t = int(metadata["audio_t"])
|
||||
image_count = int(metadata.get("image_count", 0))
|
||||
video_count = int(metadata.get("video_count", 0))
|
||||
audio_count = int(metadata.get("audio_count", 0))
|
||||
if min(height, width, num_frames, latent_t, audio_t) <= 0:
|
||||
raise ValueError("MiniMax H3 encoder media dimensions must be positive")
|
||||
if min(image_count, video_count, audio_count) < 0:
|
||||
raise ValueError("MiniMax H3 encoder media counts must be non-negative")
|
||||
|
||||
audio_flags = tuple(bool(value) for value in metadata.get("video_audio_flags", ()))
|
||||
video_audio_sample_rates = tuple(int(value) for value in metadata.get("video_audio_sample_rates", ()))
|
||||
audio_sample_rates = tuple(int(value) for value in metadata.get("audio_sample_rates", ()))
|
||||
if len(audio_flags) != video_count or len(video_audio_sample_rates) != video_count:
|
||||
raise ValueError("MiniMax H3 video audio flags must align with videos")
|
||||
if len(audio_sample_rates) != audio_count:
|
||||
raise ValueError("MiniMax H3 audio sample rates must align with audio inputs")
|
||||
if any((rate > 0) != has_audio for rate, has_audio in zip(video_audio_sample_rates, audio_flags, strict=True)):
|
||||
raise ValueError("MiniMax H3 embedded audio sample rates must align with audio flags")
|
||||
if any(rate <= 0 for rate in audio_sample_rates):
|
||||
raise ValueError("MiniMax H3 audio sample rates must be positive")
|
||||
|
||||
cursor = 0
|
||||
images = tuple(tensors[cursor : cursor + image_count])
|
||||
cursor += image_count
|
||||
videos = tuple(tensors[cursor : cursor + video_count])
|
||||
cursor += video_count
|
||||
if len(images) != image_count or any(image.ndim != 3 or image.shape[-1] != 3 for image in images):
|
||||
raise ValueError("MiniMax H3 encoder images must have shape [H, W, 3]")
|
||||
if len(videos) != video_count or any(video.ndim != 4 or video.shape[-1] != 3 for video in videos):
|
||||
raise ValueError("MiniMax H3 encoder videos must have shape [T, H, W, 3]")
|
||||
|
||||
video_audios: list[tuple[torch.Tensor, int] | None] = []
|
||||
for has_audio, sample_rate in zip(audio_flags, video_audio_sample_rates, strict=True):
|
||||
if not has_audio:
|
||||
video_audios.append(None)
|
||||
continue
|
||||
if cursor >= len(tensors):
|
||||
raise ValueError("MiniMax H3 encoder media input is truncated")
|
||||
waveform = tensors[cursor]
|
||||
cursor += 1
|
||||
if waveform.ndim not in (1, 2):
|
||||
raise ValueError("MiniMax H3 embedded audio must have shape [samples] or [channels, samples]")
|
||||
video_audios.append((waveform, sample_rate))
|
||||
|
||||
audios: list[tuple[torch.Tensor, int]] = []
|
||||
for sample_rate in audio_sample_rates:
|
||||
if cursor >= len(tensors):
|
||||
raise ValueError("MiniMax H3 encoder media input is truncated")
|
||||
waveform = tensors[cursor]
|
||||
cursor += 1
|
||||
if waveform.ndim not in (1, 2):
|
||||
raise ValueError("MiniMax H3 audio must have shape [samples] or [channels, samples]")
|
||||
audios.append((waveform, sample_rate))
|
||||
if cursor != len(tensors):
|
||||
raise ValueError(f"MiniMax H3 encoder media input has {len(tensors) - cursor} trailing tensors")
|
||||
return cls(
|
||||
task=task,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
latent_t=latent_t,
|
||||
audio_t=audio_t,
|
||||
images=images,
|
||||
videos=videos,
|
||||
video_audios=tuple(video_audios),
|
||||
audios=tuple(audios),
|
||||
keyframe_frame_indices=tuple(int(value) for value in metadata.get("keyframe_frame_indices", ())),
|
||||
)
|
||||
|
||||
def to_mm_tensors(self) -> list[torch.Tensor]:
|
||||
task_code = _TASK_TO_CODE.get(self.task)
|
||||
if task_code is None:
|
||||
raise ValueError(f"unsupported MiniMax H3 task {self.task!r}")
|
||||
if min(self.height, self.width, self.num_frames, self.latent_t, self.audio_t) <= 0:
|
||||
raise ValueError("MiniMax H3 encoder media dimensions must be positive")
|
||||
if len(self.video_audios) != len(self.videos):
|
||||
raise ValueError("MiniMax H3 video audio slots must align with videos")
|
||||
if any(image.ndim != 3 or image.shape[-1] != 3 for image in self.images):
|
||||
raise ValueError("MiniMax H3 encoder images must have shape [H, W, 3]")
|
||||
if any(video.ndim != 4 or video.shape[-1] != 3 for video in self.videos):
|
||||
raise ValueError("MiniMax H3 encoder videos must have shape [T, H, W, 3]")
|
||||
audio_items = [item for item in self.video_audios if item is not None] + list(self.audios)
|
||||
if any(waveform.ndim not in (1, 2) or int(sample_rate) <= 0 for waveform, sample_rate in audio_items):
|
||||
raise ValueError("MiniMax H3 encoder audio must have a waveform and positive sample rate")
|
||||
tensors = [*self.images, *self.videos]
|
||||
for item in self.video_audios:
|
||||
if item is None:
|
||||
continue
|
||||
waveform, _sample_rate = item
|
||||
tensors.append(waveform)
|
||||
tensors.extend(waveform for waveform, _sample_rate in self.audios)
|
||||
return tensors
|
||||
|
||||
def to_metadata(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task": self.task,
|
||||
"height": self.height,
|
||||
"width": self.width,
|
||||
"num_frames": self.num_frames,
|
||||
"latent_t": self.latent_t,
|
||||
"audio_t": self.audio_t,
|
||||
"image_count": len(self.images),
|
||||
"video_count": len(self.videos),
|
||||
"audio_count": len(self.audios),
|
||||
"video_audio_flags": [item is not None for item in self.video_audios],
|
||||
"video_audio_sample_rates": [item[1] if item is not None else 0 for item in self.video_audios],
|
||||
"audio_sample_rates": [sample_rate for _waveform, sample_rate in self.audios],
|
||||
"keyframe_frame_indices": list(self.keyframe_frame_indices),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MiniMaxH3EncoderMediaConditioning:
|
||||
task: str
|
||||
height: int
|
||||
width: int
|
||||
num_frames: int
|
||||
latent_t: int
|
||||
audio_t: int
|
||||
visual_condition: torch.Tensor | None = None
|
||||
visual_condition_shapes: tuple[tuple[int, int, int], ...] = ()
|
||||
audio_condition: torch.Tensor | None = None
|
||||
audio_condition_lengths: tuple[int, ...] = ()
|
||||
ref_blocks: tuple[dict[str, Any], ...] = ()
|
||||
keyframe_frame_indices: tuple[int, ...] = ()
|
||||
|
||||
def to_omni_components(
|
||||
self,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
task_code = _TASK_TO_CODE.get(self.task)
|
||||
if task_code is None:
|
||||
raise ValueError(f"unsupported MiniMax H3 task {self.task!r}")
|
||||
if min(self.height, self.width, self.num_frames, self.latent_t, self.audio_t) <= 0:
|
||||
raise ValueError("MiniMax H3 encoder media dimensions must be positive")
|
||||
_validate_condition_tensors(
|
||||
self.visual_condition,
|
||||
self.visual_condition_shapes,
|
||||
self.audio_condition,
|
||||
self.audio_condition_lengths,
|
||||
)
|
||||
empty = torch.empty((0,), dtype=torch.float32)
|
||||
ref_rows = _ref_block_rows(self.ref_blocks)
|
||||
layout = [
|
||||
_ENCODER_WIRE_SCHEMA_ID,
|
||||
STAGE_SCHEMA_VERSION,
|
||||
task_code,
|
||||
self.height,
|
||||
self.width,
|
||||
self.num_frames,
|
||||
self.latent_t,
|
||||
self.audio_t,
|
||||
len(self.visual_condition_shapes),
|
||||
len(self.audio_condition_lengths),
|
||||
len(ref_rows),
|
||||
len(self.keyframe_frame_indices),
|
||||
]
|
||||
layout.extend(item for shape in self.visual_condition_shapes for item in shape)
|
||||
layout.extend(self.audio_condition_lengths)
|
||||
layout.extend(item for row in ref_rows for item in row)
|
||||
layout.extend(self.keyframe_frame_indices)
|
||||
return (
|
||||
self.visual_condition if self.visual_condition is not None else empty,
|
||||
self.audio_condition if self.audio_condition is not None else empty,
|
||||
torch.tensor(layout, dtype=torch.int64),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MiniMaxH3EncoderConditioning:
|
||||
hidden_states: torch.Tensor
|
||||
token_tags: torch.Tensor
|
||||
task: str
|
||||
height: int
|
||||
width: int
|
||||
num_frames: int
|
||||
latent_t: int
|
||||
audio_t: int
|
||||
visual_condition: torch.Tensor | None = None
|
||||
visual_condition_shapes: tuple[tuple[int, int, int], ...] = ()
|
||||
audio_condition: torch.Tensor | None = None
|
||||
audio_condition_lengths: tuple[int, ...] = ()
|
||||
ref_blocks: tuple[dict[str, Any], ...] = ()
|
||||
keyframe_frame_indices: tuple[int, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_components(
|
||||
cls,
|
||||
text: MiniMaxH3TextConditioning,
|
||||
media: MiniMaxH3EncoderMediaConditioning,
|
||||
) -> MiniMaxH3EncoderConditioning:
|
||||
"""Combine local encoder results without a stage-wire round trip."""
|
||||
text = MiniMaxH3TextConditioning.from_payload(text.to_payload())
|
||||
_validate_condition_tensors(
|
||||
media.visual_condition,
|
||||
media.visual_condition_shapes,
|
||||
media.audio_condition,
|
||||
media.audio_condition_lengths,
|
||||
)
|
||||
return cls(
|
||||
hidden_states=text.hidden_states,
|
||||
token_tags=text.token_tags,
|
||||
**{item.name: getattr(media, item.name) for item in fields(media)},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_omni_payload(cls, payload: Mapping[str, Any]) -> MiniMaxH3EncoderConditioning:
|
||||
hidden_payload = payload.get("hidden_states")
|
||||
embed_payload = payload.get("embed")
|
||||
meta_payload = payload.get("meta")
|
||||
hidden_states = hidden_payload.get("output") if isinstance(hidden_payload, Mapping) else None
|
||||
token_tags = meta_payload.get("token_role_ids") if isinstance(meta_payload, Mapping) else None
|
||||
visual = embed_payload.get("embedding") if isinstance(embed_payload, Mapping) else None
|
||||
audio = embed_payload.get("speech_feat") if isinstance(embed_payload, Mapping) else None
|
||||
if not all(isinstance(item, torch.Tensor) for item in (hidden_states, token_tags, visual, audio)):
|
||||
raise ValueError("MiniMax H3 encoder wire requires text, visual and audio tensors")
|
||||
assert isinstance(hidden_states, torch.Tensor)
|
||||
assert isinstance(token_tags, torch.Tensor)
|
||||
assert isinstance(visual, torch.Tensor)
|
||||
assert isinstance(audio, torch.Tensor)
|
||||
if token_tags.ndim == 2 and token_tags.shape[-1] == 1:
|
||||
token_tags = token_tags.squeeze(-1)
|
||||
text = MiniMaxH3TextConditioning.from_payload({"hidden_states": hidden_states, "token_tags": token_tags})
|
||||
for name, value in (("visual", visual), ("audio", audio)):
|
||||
if value.numel() == 0 and (value.dtype != torch.float32 or tuple(value.shape) != (0,)):
|
||||
raise ValueError(f"MiniMax H3 empty {name} condition slot must be FP32 with shape [0]")
|
||||
if not isinstance(meta_payload, Mapping):
|
||||
raise ValueError("MiniMax H3 wire payload requires a meta mapping")
|
||||
layout = _wire_layout(payload)
|
||||
if layout.dtype not in _INTEGER_DTYPES or layout.ndim != 1:
|
||||
raise ValueError("MiniMax H3 encoder layout must be a one-dimensional integer tensor")
|
||||
values = [int(item) for item in layout.detach().cpu().tolist()]
|
||||
if len(values) < 12:
|
||||
raise ValueError("MiniMax H3 encoder layout header is truncated")
|
||||
(
|
||||
schema_id,
|
||||
version,
|
||||
task_code,
|
||||
height,
|
||||
width,
|
||||
num_frames,
|
||||
latent_t,
|
||||
audio_t,
|
||||
visual_count,
|
||||
audio_count,
|
||||
ref_count,
|
||||
keyframe_count,
|
||||
) = values[:12]
|
||||
if schema_id != _ENCODER_WIRE_SCHEMA_ID or version != STAGE_SCHEMA_VERSION:
|
||||
raise ValueError(f"unsupported MiniMax H3 encoder wire schema {schema_id}:{version}")
|
||||
task = _CODE_TO_TASK.get(task_code)
|
||||
if task is None:
|
||||
raise ValueError(f"unsupported MiniMax H3 wire task code {task_code!r}")
|
||||
if min(height, width, num_frames, latent_t, audio_t) <= 0:
|
||||
raise ValueError("MiniMax H3 encoder wire dimensions must be positive")
|
||||
if min(visual_count, audio_count, ref_count, keyframe_count) < 0:
|
||||
raise ValueError("MiniMax H3 encoder layout counts must be non-negative")
|
||||
expected_size = 12 + 3 * visual_count + audio_count + 5 * ref_count + keyframe_count
|
||||
if len(values) != expected_size:
|
||||
raise ValueError(f"MiniMax H3 encoder layout has {len(values)} integers, expected {expected_size}")
|
||||
cursor = 12
|
||||
visual_shapes = tuple(
|
||||
tuple(values[cursor + 3 * index : cursor + 3 * (index + 1)]) for index in range(visual_count)
|
||||
)
|
||||
cursor += 3 * visual_count
|
||||
audio_lengths = tuple(values[cursor : cursor + audio_count])
|
||||
cursor += audio_count
|
||||
ref_rows = values[cursor : cursor + 5 * ref_count]
|
||||
cursor += 5 * ref_count
|
||||
ref_blocks_tensor = torch.tensor(ref_rows, dtype=torch.int64).reshape(-1, 5)
|
||||
keyframe_frame_indices = tuple(values[cursor : cursor + keyframe_count])
|
||||
visual_condition = visual if visual.numel() else None
|
||||
audio_condition = audio if audio.numel() else None
|
||||
_validate_condition_tensors(
|
||||
visual_condition,
|
||||
visual_shapes,
|
||||
audio_condition,
|
||||
audio_lengths,
|
||||
)
|
||||
return cls(
|
||||
hidden_states=text.hidden_states,
|
||||
token_tags=text.token_tags,
|
||||
task=task,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
latent_t=latent_t,
|
||||
audio_t=audio_t,
|
||||
visual_condition=visual_condition,
|
||||
visual_condition_shapes=visual_shapes,
|
||||
audio_condition=audio_condition,
|
||||
audio_condition_lengths=audio_lengths,
|
||||
ref_blocks=_decode_ref_blocks(ref_blocks_tensor),
|
||||
keyframe_frame_indices=keyframe_frame_indices,
|
||||
)
|
||||
|
||||
def to_omni_payload(self) -> dict[str, Any]:
|
||||
text = MiniMaxH3TextConditioning.from_payload(
|
||||
{
|
||||
"hidden_states": self.hidden_states,
|
||||
"token_tags": self.token_tags,
|
||||
}
|
||||
)
|
||||
visual, audio, layout = MiniMaxH3EncoderMediaConditioning(
|
||||
task=self.task,
|
||||
height=self.height,
|
||||
width=self.width,
|
||||
num_frames=self.num_frames,
|
||||
latent_t=self.latent_t,
|
||||
audio_t=self.audio_t,
|
||||
visual_condition=self.visual_condition,
|
||||
visual_condition_shapes=self.visual_condition_shapes,
|
||||
audio_condition=self.audio_condition,
|
||||
audio_condition_lengths=self.audio_condition_lengths,
|
||||
ref_blocks=self.ref_blocks,
|
||||
keyframe_frame_indices=self.keyframe_frame_indices,
|
||||
).to_omni_components()
|
||||
return {
|
||||
"hidden_states": {"output": text.hidden_states},
|
||||
"embed": {
|
||||
"embedding": visual,
|
||||
"speech_feat": audio,
|
||||
},
|
||||
"meta": {"token_role_ids": text.token_tags},
|
||||
"kv_metadata": {MINIMAX_H3_ENCODER_LAYOUT_KEY: layout},
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_CONDITION_LABELS_KEY",
|
||||
"MINIMAX_H3_ENCODER_LAYOUT_KEY",
|
||||
"MINIMAX_H3_ENCODER_REQUEST_KEY",
|
||||
"MINIMAX_H3_PRESENTATION_TASK_KEY",
|
||||
"MINIMAX_H3_TEXT_CONDITIONING_SCHEMA",
|
||||
"MINIMAX_H3_TEXT_HIDDEN_SIZE",
|
||||
"STAGE_SCHEMA_VERSION",
|
||||
"MiniMaxH3EncoderConditioning",
|
||||
"MiniMaxH3EncoderMediaConditioning",
|
||||
"MiniMaxH3EncoderMediaInput",
|
||||
"MiniMaxH3TextConditioning",
|
||||
]
|
||||
@@ -0,0 +1,252 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.distributed.parallel_state import get_tp_group
|
||||
|
||||
from vllm_omni.diffusion.distributed.utils import get_local_device
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import (
|
||||
MiniMaxH3AudioVAE,
|
||||
MiniMaxH3VideoVAE,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MINIMAX_H3_ENCODER_LAYOUT_KEY,
|
||||
MINIMAX_H3_ENCODER_REQUEST_KEY,
|
||||
MiniMaxH3EncoderMediaConditioning,
|
||||
MiniMaxH3EncoderMediaInput,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import encode_media
|
||||
from vllm_omni.model_executor.models.minimax_h3.text_encoder import (
|
||||
MiniMaxH3TextEncoderBackbone,
|
||||
)
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
|
||||
_MEDIA_CONDITIONING_CACHE_KEY = "_minimax_h3_encoder_media_conditioning"
|
||||
_MEDIA_CACHE_KEY = "_minimax_h3_encoder_media"
|
||||
_COMPONENT_CONFIG_KEY = "minimax_h3_encoder_components"
|
||||
_COMPONENT_ROLES = frozenset({"text_encoder", "video_vae", "audio_vae"})
|
||||
|
||||
|
||||
def _partition_root(model_path: str) -> Path:
|
||||
path = Path(model_path)
|
||||
if path.name in {"text_encoder", "video_vae", "audio_vae"}:
|
||||
return path.parent
|
||||
return path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MiniMaxH3EncoderComponentConfig:
|
||||
text_parallel_mode: str
|
||||
video_parallel_mode: str
|
||||
audio_parallel_mode: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.text_parallel_mode != "tp":
|
||||
raise ValueError("MiniMax H3 text encoder currently supports parallel_mode='tp' only")
|
||||
if self.video_parallel_mode not in {"leader", "patch"}:
|
||||
raise ValueError("MiniMax H3 video VAE parallel_mode must be 'leader' or 'patch'")
|
||||
if self.audio_parallel_mode != "leader":
|
||||
raise ValueError("MiniMax H3 audio VAE currently supports parallel_mode='leader' only")
|
||||
|
||||
@staticmethod
|
||||
def _parallel_mode(raw: Mapping[str, Any], role: str) -> str:
|
||||
value = raw[role]
|
||||
if not isinstance(value, Mapping):
|
||||
raise TypeError(f"MiniMax H3 encoder role {role!r} must be a mapping")
|
||||
if set(value) != {"parallel_mode"}:
|
||||
raise ValueError(f"MiniMax H3 encoder role {role!r} requires only parallel_mode")
|
||||
return str(value["parallel_mode"])
|
||||
|
||||
@classmethod
|
||||
def from_vllm_config(cls, vllm_config: VllmConfig) -> MiniMaxH3EncoderComponentConfig:
|
||||
hf_config = vllm_config.model_config.hf_config
|
||||
raw = getattr(hf_config, _COMPONENT_CONFIG_KEY, None)
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ValueError(f"MiniMax H3 Encoder Stage requires explicit {_COMPONENT_CONFIG_KEY}")
|
||||
roles = set(raw)
|
||||
if roles != _COMPONENT_ROLES:
|
||||
missing = sorted(_COMPONENT_ROLES - roles)
|
||||
unknown = sorted(roles - _COMPONENT_ROLES)
|
||||
raise ValueError(
|
||||
f"{_COMPONENT_CONFIG_KEY} requires exactly {sorted(_COMPONENT_ROLES)}; "
|
||||
f"missing={missing}, unknown={unknown}"
|
||||
)
|
||||
return cls(
|
||||
text_parallel_mode=cls._parallel_mode(raw, "text_encoder"),
|
||||
video_parallel_mode=cls._parallel_mode(raw, "video_vae"),
|
||||
audio_parallel_mode=cls._parallel_mode(raw, "audio_vae"),
|
||||
)
|
||||
|
||||
|
||||
class MiniMaxH3Encoder(MiniMaxH3TextEncoderBackbone):
|
||||
requires_request_sample_eligibility = True
|
||||
enable_update_additional_information = True
|
||||
omni_payload_at_request_end = True
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "model") -> None:
|
||||
super().__init__(vllm_config=vllm_config, prefix=prefix)
|
||||
try:
|
||||
tp_group = get_tp_group()
|
||||
tp_rank = int(tp_group.rank_in_group)
|
||||
except (AssertionError, RuntimeError):
|
||||
tp_group = None
|
||||
tp_rank = 0
|
||||
self._component_leader = tp_rank == 0
|
||||
self.component_config = MiniMaxH3EncoderComponentConfig.from_vllm_config(vllm_config)
|
||||
root = _partition_root(vllm_config.model_config.model)
|
||||
missing = [name for name in ("text_encoder", "video_vae", "audio_vae") if not (root / name).is_dir()]
|
||||
if missing:
|
||||
raise RuntimeError(f"MiniMax H3 Encoder Stage requires all three encoders; missing {missing}")
|
||||
device = get_local_device()
|
||||
self.video_vae: MiniMaxH3VideoVAE | None = None
|
||||
self.audio_vae: MiniMaxH3AudioVAE | None = None
|
||||
if self._component_leader or self.component_config.video_parallel_mode == "patch":
|
||||
self.video_vae = MiniMaxH3VideoVAE(
|
||||
str(root / "video_vae"),
|
||||
device=device,
|
||||
encode_only=True,
|
||||
trust_remote_code=vllm_config.model_config.trust_remote_code,
|
||||
)
|
||||
if self.component_config.video_parallel_mode == "patch":
|
||||
if tp_group is None:
|
||||
raise RuntimeError("MiniMax H3 video VAE patch parallelism requires an initialized TP group")
|
||||
self.video_vae.set_parallel_size(
|
||||
tp_group.world_size,
|
||||
process_group=tp_group.device_group,
|
||||
)
|
||||
if self._component_leader:
|
||||
self.audio_vae = MiniMaxH3AudioVAE(
|
||||
str(root / "audio_vae"),
|
||||
device=device,
|
||||
encode_only=True,
|
||||
trust_remote_code=vllm_config.model_config.trust_remote_code,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _media_input(info: dict[str, Any]) -> MiniMaxH3EncoderMediaInput:
|
||||
cached = info.get(_MEDIA_CACHE_KEY)
|
||||
if isinstance(cached, MiniMaxH3EncoderMediaInput):
|
||||
return cached
|
||||
meta = info.get("meta")
|
||||
metadata = meta.get(MINIMAX_H3_ENCODER_REQUEST_KEY) if isinstance(meta, Mapping) else None
|
||||
if not isinstance(metadata, Mapping):
|
||||
raise RuntimeError("MiniMax H3 unified encoder requires request metadata")
|
||||
|
||||
hidden_states = info.get("hidden_states")
|
||||
raw_media = hidden_states.get("layers") if isinstance(hidden_states, Mapping) else None
|
||||
if raw_media is None:
|
||||
media_tensors: list[torch.Tensor] = []
|
||||
elif isinstance(raw_media, Mapping):
|
||||
try:
|
||||
ordered_items = sorted(raw_media.items(), key=lambda item: int(item[0]))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("MiniMax H3 encoder media keys must be integer indices") from exc
|
||||
if [int(key) for key, _ in ordered_items] != list(range(len(ordered_items))):
|
||||
raise RuntimeError("MiniMax H3 encoder media indices must be contiguous from zero")
|
||||
media_tensors = [value for _, value in ordered_items]
|
||||
else:
|
||||
raise RuntimeError("MiniMax H3 encoder media must be a namespaced tensor mapping")
|
||||
if any(not isinstance(value, torch.Tensor) for value in media_tensors):
|
||||
raise RuntimeError("MiniMax H3 encoder media must contain only tensors")
|
||||
media = MiniMaxH3EncoderMediaInput.from_mm_tensors(media_tensors, metadata)
|
||||
if media.to_metadata() != dict(metadata):
|
||||
raise RuntimeError("MiniMax H3 encoder media does not match request metadata")
|
||||
info[_MEDIA_CACHE_KEY] = media
|
||||
return media
|
||||
|
||||
def _encode_media(self, media: MiniMaxH3EncoderMediaInput) -> MiniMaxH3EncoderMediaConditioning | None:
|
||||
if not self._component_leader and self.component_config.video_parallel_mode == "leader":
|
||||
return None
|
||||
return encode_media(
|
||||
media,
|
||||
video_vae=self.video_vae,
|
||||
audio_vae=self.audio_vae,
|
||||
emit_conditioning=self._component_leader,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _has_encoder_input(info: object) -> bool:
|
||||
if not isinstance(info, Mapping):
|
||||
return False
|
||||
meta = info.get("meta")
|
||||
return isinstance(meta, Mapping) and isinstance(meta.get(MINIMAX_H3_ENCODER_REQUEST_KEY), Mapping)
|
||||
|
||||
def make_omni_output(
|
||||
self,
|
||||
model_outputs: torch.Tensor | OmniOutput,
|
||||
**kwargs: Any,
|
||||
) -> OmniOutput:
|
||||
output = super().make_omni_output(model_outputs, **kwargs)
|
||||
infos = kwargs.get("model_intermediate_buffer") or []
|
||||
if not isinstance(infos, Sequence) or not infos:
|
||||
return output
|
||||
sample_eligible = kwargs.get("request_sample_eligible")
|
||||
if sample_eligible is None:
|
||||
sample_eligible = [True] * len(infos)
|
||||
if len(sample_eligible) != len(infos):
|
||||
raise RuntimeError(
|
||||
f"MiniMax H3 unified encoder received {len(sample_eligible)} sampling flags for {len(infos)} requests"
|
||||
)
|
||||
per_request: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None] = []
|
||||
release_workspace = False
|
||||
for eligible, info in zip(sample_eligible, infos, strict=True):
|
||||
if not eligible:
|
||||
per_request.append(None)
|
||||
continue
|
||||
if not isinstance(info, dict) or not self._has_encoder_input(info):
|
||||
raise RuntimeError("MiniMax H3 unified encoder request metadata is missing from the runner")
|
||||
release_workspace = True
|
||||
if _MEDIA_CONDITIONING_CACHE_KEY not in info:
|
||||
info[_MEDIA_CONDITIONING_CACHE_KEY] = self._encode_media(self._media_input(info))
|
||||
if not self._component_leader:
|
||||
continue
|
||||
cached = info.get(_MEDIA_CONDITIONING_CACHE_KEY)
|
||||
if not isinstance(cached, MiniMaxH3EncoderMediaConditioning):
|
||||
raise RuntimeError("MiniMax H3 encoder components did not produce conditioning")
|
||||
per_request.append(cached.to_omni_components())
|
||||
|
||||
if release_workspace:
|
||||
torch.accelerator.empty_cache()
|
||||
if not self._component_leader:
|
||||
return output
|
||||
|
||||
if not any(item is not None for item in per_request):
|
||||
return output
|
||||
empty = torch.empty((0,), dtype=torch.float32)
|
||||
multimodal_outputs = dict(output.multimodal_outputs or {})
|
||||
dotted_key = f"kv_metadata.{MINIMAX_H3_ENCODER_LAYOUT_KEY}"
|
||||
multimodal_outputs[dotted_key] = [
|
||||
item[2] if item is not None else torch.empty((0,), dtype=torch.int64) for item in per_request
|
||||
]
|
||||
|
||||
embed = dict(multimodal_outputs.get("embed") or {})
|
||||
embed["embedding"] = [item[0] if item is not None else empty for item in per_request]
|
||||
embed["speech_feat"] = [item[1] if item is not None else empty for item in per_request]
|
||||
multimodal_outputs["embed"] = embed
|
||||
return OmniOutput(
|
||||
text_hidden_states=output.text_hidden_states,
|
||||
multimodal_outputs=multimodal_outputs,
|
||||
intermediate_tensors=output.intermediate_tensors,
|
||||
next_token_id=output.next_token_id,
|
||||
)
|
||||
|
||||
def load_weights(
|
||||
self,
|
||||
weights: Iterable[tuple[str, torch.Tensor]],
|
||||
) -> set[str]:
|
||||
loaded = super().load_weights(weights)
|
||||
for component_name in ("video_vae", "audio_vae"):
|
||||
component = getattr(self, component_name)
|
||||
if component is not None:
|
||||
loaded.update(f"{component_name}.{name}" for name, _ in component.named_parameters())
|
||||
return loaded
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3Encoder"]
|
||||
@@ -0,0 +1,569 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Shared request preparation and condition codec execution for MiniMax H3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MiniMaxH3EncoderMediaConditioning,
|
||||
MiniMaxH3EncoderMediaInput,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.preprocessing import (
|
||||
MINIMAX_H3_OUTPUT_SHORT_EDGE,
|
||||
load_minimax_h3_images,
|
||||
resolve_minimax_h3_aspect_ratio,
|
||||
resolve_minimax_h3_output_canvas,
|
||||
resolve_minimax_h3_reference_image_shape,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.reference_video import (
|
||||
MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS,
|
||||
load_audio_file,
|
||||
load_video_audio,
|
||||
load_video_frames,
|
||||
prepare_reference_videos,
|
||||
sample_reference_video_frames,
|
||||
validate_reference_audio_files,
|
||||
validate_reference_audio_waveforms,
|
||||
)
|
||||
|
||||
MINIMAX_H3_FPS = 24
|
||||
MINIMAX_H3_MIN_OUTPUT_SECONDS = 4.0
|
||||
MINIMAX_H3_MAX_OUTPUT_SECONDS = 15.0
|
||||
|
||||
|
||||
def _items(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, tuple) and not (len(value) == 2 and isinstance(value[1], Mapping)):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
|
||||
def _audio_items(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[1], (int, np.integer)):
|
||||
return [value]
|
||||
return list(value) if isinstance(value, (list, tuple)) else [value]
|
||||
|
||||
|
||||
def _load_audio(value: Any) -> tuple[torch.Tensor, int]:
|
||||
if isinstance(value, (list, tuple)) and not (len(value) == 2 and isinstance(value[1], (int, np.integer))):
|
||||
audios = _load_audios(value)
|
||||
if len(audios) != 1:
|
||||
raise OmniClientError(f"MiniMax H3 expected one audio, got {len(audios)}")
|
||||
return audios[0]
|
||||
if isinstance(value, (str, os.PathLike)):
|
||||
return load_audio_file(str(value))
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
waveform, sample_rate = value
|
||||
return torch.as_tensor(waveform).float(), int(sample_rate)
|
||||
if isinstance(value, dict):
|
||||
waveform = value.get("waveform", value.get("array"))
|
||||
sample_rate = value.get("sample_rate", value.get("sampling_rate"))
|
||||
if waveform is not None and sample_rate is not None:
|
||||
return torch.as_tensor(waveform).float(), int(sample_rate)
|
||||
raise OmniClientError("MiniMax H3 audio input must be a path, (waveform, sample_rate), or a waveform mapping")
|
||||
|
||||
|
||||
def _load_audios(value: Any) -> list[tuple[torch.Tensor, int]]:
|
||||
if isinstance(value, (list, tuple)) and not (len(value) == 2 and isinstance(value[1], (int, np.integer))):
|
||||
if not value:
|
||||
raise OmniClientError("MiniMax H3 audio input must not be empty")
|
||||
return [_load_audio(item) for item in value]
|
||||
return [_load_audio(value)]
|
||||
|
||||
|
||||
def _as_int_list(value: Any, *, name: str) -> list[int]:
|
||||
if isinstance(value, bool):
|
||||
raise OmniClientError(f"{name} must be an integer or a list of integers")
|
||||
if isinstance(value, (int, np.integer)):
|
||||
return [int(value)]
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
result = list(value)
|
||||
if not result:
|
||||
raise OmniClientError(f"{name} must not be empty")
|
||||
if any(isinstance(item, bool) or not isinstance(item, (int, np.integer)) for item in result):
|
||||
raise OmniClientError(f"{name} must contain only integers")
|
||||
return [int(item) for item in result]
|
||||
raise OmniClientError(f"{name} must be an integer or a list of integers")
|
||||
|
||||
|
||||
def _resolve_fl2va_keyframe_indices(extra: Mapping[str, Any], image_count: int) -> list[int]:
|
||||
target = extra.get("target")
|
||||
target = target if isinstance(target, Mapping) else {}
|
||||
raw = extra.get("frame_indices", extra.get("frame_index"))
|
||||
if raw is None:
|
||||
raw = target.get("frame_indices", target.get("frame_index"))
|
||||
raw_indices = ([0] if image_count == 1 else [0, -1]) if raw is None else _as_int_list(raw, name="frame_indices")
|
||||
if len(raw_indices) != image_count:
|
||||
raise OmniClientError(
|
||||
f"MiniMax H3 FL2VA requires one frame index per image: got {raw_indices!r} for {image_count} image(s)"
|
||||
)
|
||||
if tuple(raw_indices) not in ((0,), (-1,), (0, -1)):
|
||||
raise OmniClientError("MiniMax H3 FL2VA frame_indices must be [0], [-1], or [0, -1]")
|
||||
return raw_indices
|
||||
|
||||
|
||||
def _validate_ref2va_reference_counts(image_count: int, video_count: int, audio_count: int) -> None:
|
||||
if min(image_count, video_count, audio_count) < 0:
|
||||
raise OmniClientError("MiniMax H3 reference counts must be non-negative")
|
||||
if image_count + video_count == 0:
|
||||
raise OmniClientError("ref2va requires at least one image or video reference")
|
||||
if image_count > 9:
|
||||
raise OmniClientError("ref2va accepts at most 9 image references")
|
||||
if video_count > 3:
|
||||
raise OmniClientError("ref2va accepts at most 3 video references")
|
||||
if audio_count > 3:
|
||||
raise OmniClientError("ref2va accepts at most 3 standalone audio references")
|
||||
if image_count + video_count + audio_count > 12:
|
||||
raise OmniClientError("ref2va accepts at most 12 total references")
|
||||
|
||||
|
||||
def _validate_reference_image(image: Image.Image) -> None:
|
||||
resolve_minimax_h3_reference_image_shape(image)
|
||||
|
||||
|
||||
def resolve_minimax_h3_shape(
|
||||
task: str,
|
||||
sampling: Any,
|
||||
image: Image.Image | None,
|
||||
) -> tuple[int, int, int, int, int]:
|
||||
# The diffusion package exports a pipeline that imports this shared module.
|
||||
from vllm_omni.diffusion.models.minimax_h3.time_request import (
|
||||
MINIMAX_H3_SHAPE_PLANNER,
|
||||
minimax_h3_align_frame_count,
|
||||
)
|
||||
|
||||
fps = int(getattr(sampling, "fps", None) or MINIMAX_H3_FPS)
|
||||
if fps != MINIMAX_H3_FPS:
|
||||
raise OmniClientError(f"MiniMax H3 output fps is fixed at {MINIMAX_H3_FPS}")
|
||||
extra = sampling.extra_args or {}
|
||||
target = extra.get("target")
|
||||
if target is not None and not isinstance(target, Mapping):
|
||||
raise OmniClientError("MiniMax H3 extra_args['target'] must be an object")
|
||||
target = target if isinstance(target, Mapping) else {}
|
||||
duration = target.get("duration_seconds", extra.get("duration_seconds", extra.get("duration")))
|
||||
if duration is not None:
|
||||
if isinstance(duration, bool):
|
||||
raise OmniClientError(f"MiniMax H3 output duration must be in [4, 15] seconds, got {duration!r}")
|
||||
try:
|
||||
duration = float(duration)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise OmniClientError(f"MiniMax H3 output duration must be in [4, 15] seconds, got {duration!r}") from exc
|
||||
if (
|
||||
not math.isfinite(duration)
|
||||
or not MINIMAX_H3_MIN_OUTPUT_SECONDS <= duration <= MINIMAX_H3_MAX_OUTPUT_SECONDS
|
||||
):
|
||||
raise OmniClientError(f"MiniMax H3 output duration must be in [4, 15] seconds, got {duration}")
|
||||
requested_frames = int(round(duration * fps))
|
||||
elif int(getattr(sampling, "num_frames", None) or 1) > 1:
|
||||
requested_frames = int(sampling.num_frames)
|
||||
else:
|
||||
requested_frames = 124 if task == "ref2va" else 209
|
||||
if not MINIMAX_H3_MIN_OUTPUT_SECONDS <= requested_frames / fps <= MINIMAX_H3_MAX_OUTPUT_SECONDS:
|
||||
raise OmniClientError(
|
||||
f"MiniMax H3 output duration must be in [4, 15] seconds, got {requested_frames / fps:.3f}"
|
||||
)
|
||||
num_frames = minimax_h3_align_frame_count(requested_frames)
|
||||
|
||||
height = getattr(sampling, "height", None)
|
||||
width = getattr(sampling, "width", None)
|
||||
aspect_ratio = resolve_minimax_h3_aspect_ratio(
|
||||
task,
|
||||
target.get("aspect_ratio", extra.get("aspect_ratio")),
|
||||
image,
|
||||
)
|
||||
raw_short_edge = target.get("short_edge", extra.get("short_edge", MINIMAX_H3_OUTPUT_SHORT_EDGE))
|
||||
if isinstance(raw_short_edge, bool) or not isinstance(raw_short_edge, (int, np.integer)):
|
||||
raise OmniClientError(
|
||||
f"MiniMax H3 target.short_edge must be {MINIMAX_H3_OUTPUT_SHORT_EDGE}, got {raw_short_edge!r}"
|
||||
)
|
||||
if height is None or width is None:
|
||||
height, width = resolve_minimax_h3_output_canvas(aspect_ratio, int(raw_short_edge))
|
||||
height = int(height) // 32 * 32
|
||||
width = int(width) // 32 * 32
|
||||
if min(height, width) <= 0:
|
||||
raise OmniClientError(f"invalid MiniMax H3 canvas {width}x{height}")
|
||||
if width > 4 * height or height > 4 * width:
|
||||
raise OmniClientError("MiniMax H3 canvas aspect ratio must be in [1:4, 4:1]")
|
||||
return (
|
||||
height,
|
||||
width,
|
||||
num_frames,
|
||||
MINIMAX_H3_SHAPE_PLANNER.video_latent_t(num_frames),
|
||||
MINIMAX_H3_SHAPE_PLANNER.audio_latent_t(num_frames / fps),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_task(
|
||||
extra_args: Mapping[str, Any],
|
||||
multi_modal_data: Mapping[str, Any],
|
||||
) -> str:
|
||||
requested = extra_args.get("task")
|
||||
if requested is not None:
|
||||
return str(requested).lower()
|
||||
if multi_modal_data.get("video") is not None or multi_modal_data.get("audio") is not None:
|
||||
return "ref2va"
|
||||
if multi_modal_data.get("image") is not None:
|
||||
return "fl2va"
|
||||
return "t2va"
|
||||
|
||||
|
||||
def _prepare_encoder_images(
|
||||
task: str,
|
||||
images: list[Image.Image],
|
||||
*,
|
||||
height: int,
|
||||
width: int,
|
||||
) -> list[Any]:
|
||||
if not images:
|
||||
return []
|
||||
if task == "ref2va":
|
||||
return [
|
||||
image.resize(
|
||||
resolve_minimax_h3_reference_image_shape(image),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
for image in images
|
||||
]
|
||||
if task != "fl2va":
|
||||
return images
|
||||
return [image.resize((width, height), Image.Resampling.LANCZOS) for image in images]
|
||||
|
||||
|
||||
def _image_to_tensor(image: Image.Image) -> torch.Tensor:
|
||||
array = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
return torch.from_numpy(np.array(array, copy=True)).contiguous()
|
||||
|
||||
|
||||
def _frames_to_tensor(frames: Sequence[Any]) -> torch.Tensor:
|
||||
if len(frames) == 0:
|
||||
raise ValueError("MiniMax H3 reference video must contain frames")
|
||||
return torch.stack(
|
||||
[
|
||||
_image_to_tensor(frame if isinstance(frame, Image.Image) else Image.fromarray(np.asarray(frame)))
|
||||
for frame in frames
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _reuse_prepared_reference_videos(
|
||||
prepared: list[dict[str, Any]] | None,
|
||||
*,
|
||||
expected_count: int,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
if prepared is None:
|
||||
return None
|
||||
if len(prepared) != expected_count:
|
||||
raise OmniClientError("MiniMax H3 prepared-reference-video count does not match the request")
|
||||
for item in prepared:
|
||||
if not os.path.isfile(item["prepared_path"]):
|
||||
raise OmniClientError(f"MiniMax H3 prepared reference video is unavailable: {item['prepared_path']}")
|
||||
return prepared
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedEncoderInputs:
|
||||
"""CPU-owned media and Qwen presentation inputs, independent of a runner."""
|
||||
|
||||
prompt: str
|
||||
media: MiniMaxH3EncoderMediaInput
|
||||
images: list[Image.Image]
|
||||
qwen_videos: list[tuple[np.ndarray, dict[str, Any]]]
|
||||
video_timestamps: list[list[float]]
|
||||
condition_labels: list[tuple[str, int]]
|
||||
|
||||
|
||||
def _effective_audio_inputs(
|
||||
video_audios: Sequence[tuple[torch.Tensor, int] | None],
|
||||
standalone_audios: Sequence[tuple[torch.Tensor, int]],
|
||||
*,
|
||||
max_standalone_seconds: float,
|
||||
) -> list[tuple[torch.Tensor, int]]:
|
||||
"""Return the waveforms exactly as the audio WVAE will consume them."""
|
||||
audio_inputs = [item for item in video_audios if item is not None]
|
||||
audio_inputs.extend(
|
||||
(waveform[..., : int(round(max_standalone_seconds * sample_rate))], sample_rate)
|
||||
for waveform, sample_rate in standalone_audios
|
||||
)
|
||||
return audio_inputs
|
||||
|
||||
|
||||
def prepare_encoder_inputs(
|
||||
prompt: Any,
|
||||
sampling: Any,
|
||||
*,
|
||||
task: str | None = None,
|
||||
prepared_reference_videos: list[dict[str, Any]] | None = None,
|
||||
) -> PreparedEncoderInputs:
|
||||
"""Decode references once and prepare the shared text/codec presentation."""
|
||||
if isinstance(prompt, str):
|
||||
prompt = {"prompt": prompt}
|
||||
if not isinstance(prompt, dict):
|
||||
raise TypeError(f"MiniMax H3 expects a string or dict prompt, got {type(prompt)!r}")
|
||||
|
||||
text = str(prompt.get("prompt") or "")
|
||||
if not text:
|
||||
raise OmniClientError("MiniMax H3 requires a non-empty prompt")
|
||||
multi_modal_data = prompt.get("multi_modal_data") or {}
|
||||
if not isinstance(multi_modal_data, Mapping):
|
||||
raise TypeError("multi_modal_data must be a mapping")
|
||||
|
||||
image_values = _items(multi_modal_data.get("image"))
|
||||
videos = _items(multi_modal_data.get("video"))
|
||||
raw_audio = multi_modal_data.get("audio")
|
||||
audio_values = _audio_items(raw_audio)
|
||||
diffusion_sampling_params = sampling
|
||||
extra_args = getattr(diffusion_sampling_params, "extra_args", None) or {}
|
||||
task = task if task is not None else _resolve_task(extra_args, multi_modal_data)
|
||||
raw_images = load_minimax_h3_images(image_values) if image_values else []
|
||||
|
||||
if task == "t2va":
|
||||
if raw_images or videos or audio_values:
|
||||
raise OmniClientError("t2va does not accept image, video, or audio conditions")
|
||||
elif task == "fl2va":
|
||||
if not raw_images or videos or audio_values:
|
||||
raise OmniClientError("fl2va requires image conditions only")
|
||||
if len(raw_images) > 2:
|
||||
raise OmniClientError("fl2va accepts at most first and last images")
|
||||
for image in raw_images:
|
||||
_validate_reference_image(image)
|
||||
elif task == "ref2va":
|
||||
_validate_ref2va_reference_counts(len(raw_images), len(videos), len(audio_values))
|
||||
if not raw_images and not videos:
|
||||
raise OmniClientError("ref2va requires an image or video condition")
|
||||
else:
|
||||
raise OmniClientError(f"unsupported MiniMax H3 task {task!r}")
|
||||
|
||||
height, width, num_frames, latent_t, audio_t = resolve_minimax_h3_shape(
|
||||
task,
|
||||
diffusion_sampling_params,
|
||||
raw_images[0] if raw_images else None,
|
||||
)
|
||||
images = _prepare_encoder_images(
|
||||
task,
|
||||
raw_images,
|
||||
height=height,
|
||||
width=width,
|
||||
)
|
||||
keyframe_indices = _resolve_fl2va_keyframe_indices(extra_args, len(images)) if task == "fl2va" else []
|
||||
video_timestamps: list[list[float]] = []
|
||||
qwen_video_inputs: list[tuple[np.ndarray, dict[str, Any]]] = []
|
||||
condition_labels: list[tuple[str, int]] = []
|
||||
encoded_video_inputs: list[torch.Tensor] = []
|
||||
video_audio_inputs: list[tuple[torch.Tensor, int] | None] = []
|
||||
|
||||
if task == "fl2va":
|
||||
condition_labels.extend(("image", index) for index in range(1, len(images) + 1))
|
||||
elif task == "ref2va":
|
||||
condition_labels.extend(("image", index) for index in range(1, len(images) + 1))
|
||||
prepared_videos: list[dict[str, Any]] = []
|
||||
if videos:
|
||||
with tempfile.TemporaryDirectory(prefix="minimax_h3_encoder_") as workdir:
|
||||
prepared_videos = _reuse_prepared_reference_videos(
|
||||
prepared_reference_videos, expected_count=len(videos)
|
||||
)
|
||||
if prepared_videos is None:
|
||||
prepared_videos = prepare_reference_videos(
|
||||
videos,
|
||||
target_frame_count=num_frames,
|
||||
workdir=workdir,
|
||||
start_time_seconds=extra_args.get("start_time_seconds"),
|
||||
)
|
||||
for item in prepared_videos:
|
||||
full_frames = load_video_frames(item["prepared_path"])
|
||||
encoded_video_inputs.append(_frames_to_tensor(full_frames))
|
||||
sampled = sample_reference_video_frames(
|
||||
item["prepared_path"],
|
||||
decoded_frames=full_frames,
|
||||
)
|
||||
video_timestamps.append(sampled["block_timestamps"])
|
||||
frames = np.stack(sampled["frames"])
|
||||
frame_count = int(frames.shape[0])
|
||||
qwen_video_inputs.append(
|
||||
(
|
||||
frames,
|
||||
{
|
||||
"total_num_frames": frame_count,
|
||||
"fps": MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS,
|
||||
"duration": frame_count / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS,
|
||||
"video_backend": "minimax_h3",
|
||||
"frames_indices": list(range(frame_count)),
|
||||
"do_sample_frames": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
if item["input_has_audio"]:
|
||||
waveform, sample_rate = load_video_audio(
|
||||
item["original_path"],
|
||||
start_time_seconds=float(item.get("start_time_seconds", 0.0)),
|
||||
duration_seconds=item.get(
|
||||
"audio_duration_seconds",
|
||||
item.get("duration_seconds"),
|
||||
),
|
||||
)
|
||||
video_audio_inputs.append((waveform.float().contiguous(), int(sample_rate)))
|
||||
else:
|
||||
video_audio_inputs.append(None)
|
||||
audio_index = 0
|
||||
for video_index, item in enumerate(prepared_videos, start=1):
|
||||
if item["input_has_audio"]:
|
||||
audio_index += 1
|
||||
condition_labels.append(("audio", audio_index))
|
||||
condition_labels.append(("video", video_index))
|
||||
for _ in audio_values:
|
||||
audio_index += 1
|
||||
condition_labels.append(("audio", audio_index))
|
||||
|
||||
if raw_audio is not None:
|
||||
validate_reference_audio_files(raw_audio)
|
||||
standalone_audios = _load_audios(raw_audio) if raw_audio is not None else []
|
||||
validate_reference_audio_waveforms(standalone_audios)
|
||||
media_input = MiniMaxH3EncoderMediaInput(
|
||||
task=task,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
latent_t=latent_t,
|
||||
audio_t=audio_t,
|
||||
images=tuple(_image_to_tensor(image) for image in images),
|
||||
videos=tuple(encoded_video_inputs),
|
||||
video_audios=tuple(video_audio_inputs),
|
||||
audios=tuple((waveform.float().contiguous(), int(sample_rate)) for waveform, sample_rate in standalone_audios),
|
||||
keyframe_frame_indices=tuple(keyframe_indices),
|
||||
)
|
||||
audio_inputs = _effective_audio_inputs(
|
||||
media_input.video_audios,
|
||||
media_input.audios,
|
||||
max_standalone_seconds=float(media_input.num_frames) / MINIMAX_H3_FPS,
|
||||
)
|
||||
embedded_audio_count = sum(item is not None for item in media_input.video_audios)
|
||||
# Video soundtracks and standalone references have separate 15-second budgets.
|
||||
validate_reference_audio_waveforms(audio_inputs[:embedded_audio_count])
|
||||
validate_reference_audio_waveforms(audio_inputs[embedded_audio_count:])
|
||||
|
||||
return PreparedEncoderInputs(
|
||||
prompt=text,
|
||||
media=media_input,
|
||||
images=images,
|
||||
qwen_videos=qwen_video_inputs,
|
||||
video_timestamps=video_timestamps,
|
||||
condition_labels=condition_labels,
|
||||
)
|
||||
|
||||
|
||||
def _image_from_tensor(value: torch.Tensor) -> Image.Image:
|
||||
if value.ndim != 3 or value.shape[-1] != 3:
|
||||
raise ValueError(f"MiniMax H3 image tensor must have shape [H, W, 3], got {tuple(value.shape)}")
|
||||
array = value.detach().cpu().to(torch.uint8).contiguous().numpy()
|
||||
return Image.fromarray(array, mode="RGB")
|
||||
|
||||
|
||||
def encode_media(
|
||||
media: MiniMaxH3EncoderMediaInput,
|
||||
*,
|
||||
video_vae: Any,
|
||||
audio_vae: Any,
|
||||
emit_conditioning: bool,
|
||||
component_scope: Callable[[Any], AbstractContextManager[Any]] = nullcontext,
|
||||
) -> MiniMaxH3EncoderMediaConditioning | None:
|
||||
"""Run codecs on participating ranks; only the output rank runs audio.
|
||||
|
||||
The caller selects participating ranks and configures the VAE process group.
|
||||
Every visual participant must call this function with the same references.
|
||||
Component scopes allow the diffusion runner to stage one codec at a time.
|
||||
"""
|
||||
visual_rows: list[torch.Tensor] = []
|
||||
visual_shapes: list[tuple[int, int, int]] = []
|
||||
if media.images or media.videos:
|
||||
if video_vae is None:
|
||||
raise RuntimeError("MiniMax H3 video VAE is not resident on this rank")
|
||||
with component_scope(video_vae):
|
||||
for value in media.images:
|
||||
image = _image_from_tensor(value)
|
||||
visual_rows.append(video_vae.encode_image(image))
|
||||
visual_shapes.append((1, image.height // 16, image.width // 16))
|
||||
for value in media.videos:
|
||||
frames = np.asarray(value.detach().cpu().to(torch.uint8).contiguous().numpy())
|
||||
rows, shape = video_vae.encode_video(frames)
|
||||
visual_rows.append(rows)
|
||||
visual_shapes.append(tuple(int(item) for item in shape))
|
||||
|
||||
if not emit_conditioning:
|
||||
return None
|
||||
|
||||
audio_rows: list[torch.Tensor] = []
|
||||
audio_lengths: list[int] = []
|
||||
embedded_audio_count = sum(item is not None for item in media.video_audios)
|
||||
audio_inputs = _effective_audio_inputs(
|
||||
media.video_audios,
|
||||
media.audios,
|
||||
max_standalone_seconds=float(media.num_frames) / MINIMAX_H3_FPS,
|
||||
)
|
||||
if audio_inputs:
|
||||
if audio_vae is None:
|
||||
raise RuntimeError("MiniMax H3 audio WVAE is not resident on the encoder leader")
|
||||
with component_scope(audio_vae):
|
||||
for waveform, sample_rate in audio_inputs:
|
||||
rows, length = audio_vae.encode_waveform(waveform, sample_rate)
|
||||
audio_rows.append(rows)
|
||||
audio_lengths.append(int(length))
|
||||
if audio_lengths:
|
||||
if any(length < 80 or length > 600 for length in audio_lengths):
|
||||
raise ValueError("MiniMax H3 audio references must each be between 2 and 15 seconds")
|
||||
if sum(audio_lengths[:embedded_audio_count]) > 600 or sum(audio_lengths[embedded_audio_count:]) > 600:
|
||||
raise ValueError("MiniMax H3 audio references must be at most 15 seconds in total")
|
||||
|
||||
ref_blocks: list[dict[str, Any]] = []
|
||||
image_shapes = visual_shapes[: len(media.images)]
|
||||
video_shapes = visual_shapes[len(media.images) :]
|
||||
ref_blocks.extend({"kind": "image", "latent_h": shape[1], "latent_w": shape[2]} for shape in image_shapes)
|
||||
embedded_lengths = iter(audio_lengths[:embedded_audio_count])
|
||||
for shape, embedded in zip(video_shapes, media.video_audios, strict=True):
|
||||
ref_audio_t = int(next(embedded_lengths)) if embedded is not None else 0
|
||||
ref_blocks.append(
|
||||
{
|
||||
"kind": "video_audio" if ref_audio_t else "video",
|
||||
"ref_audio_t": ref_audio_t,
|
||||
"latent_t": shape[0],
|
||||
"latent_h": shape[1],
|
||||
"latent_w": shape[2],
|
||||
}
|
||||
)
|
||||
ref_blocks.extend(
|
||||
{
|
||||
"kind": "audio",
|
||||
"ref_audio_t": int(length),
|
||||
}
|
||||
for length in audio_lengths[embedded_audio_count:]
|
||||
)
|
||||
return MiniMaxH3EncoderMediaConditioning(
|
||||
task=media.task,
|
||||
height=media.height,
|
||||
width=media.width,
|
||||
num_frames=media.num_frames,
|
||||
latent_t=media.latent_t,
|
||||
audio_t=media.audio_t,
|
||||
visual_condition=torch.cat(visual_rows) if visual_rows else None,
|
||||
visual_condition_shapes=tuple(visual_shapes),
|
||||
audio_condition=torch.cat(audio_rows) if audio_rows else None,
|
||||
audio_condition_lengths=tuple(audio_lengths),
|
||||
ref_blocks=tuple(ref_blocks),
|
||||
keyframe_frame_indices=media.keyframe_frame_indices,
|
||||
)
|
||||
@@ -16,18 +16,18 @@ MINIMAX_H3_PIPELINE = PipelineConfig(
|
||||
model_type="minimax_h3_disaggregated",
|
||||
default_deploy_config_name="minimax_h3_disaggregated.yaml",
|
||||
stage_cli_aliases={"text_encoder_tp_size": (0, "tensor_parallel_size")},
|
||||
model_arch="MiniMaxH3TextEncoder",
|
||||
model_arch="MiniMaxH3Encoder",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="text_encoder",
|
||||
model_stage="encoder",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
input_sources=(),
|
||||
owns_tokenizer=True,
|
||||
requires_multimodal_data=True,
|
||||
model_arch="MiniMaxH3TextEncoder",
|
||||
model_arch="MiniMaxH3Encoder",
|
||||
engine_output_type="latent",
|
||||
prompt_transform_func=f"{_PROCESSOR}.prepare_text_encoder_prompt",
|
||||
prompt_transform_func=f"{_PROCESSOR}.prepare_encoder_prompt",
|
||||
sampling_constraints={
|
||||
"max_tokens": 1,
|
||||
"temperature": 0.0,
|
||||
@@ -42,9 +42,9 @@ MINIMAX_H3_PIPELINE = PipelineConfig(
|
||||
input_sources=(0,),
|
||||
final_output=True,
|
||||
final_output_type="video",
|
||||
requires_multimodal_data=True,
|
||||
requires_multimodal_data=False,
|
||||
model_arch="MiniMaxH3Pipeline",
|
||||
custom_process_input_func=f"{_PROCESSOR}.text_encoder2diffusion",
|
||||
custom_process_input_func=f"{_PROCESSOR}.encoder2diffusion",
|
||||
omni_kv_config={"need_recv_cache": False},
|
||||
model_path_resolver=f"{_DIFFUSION_PIPELINE}.resolve_minimax_h3_diffusion_model_path",
|
||||
inline_diffusion=True,
|
||||
|
||||
@@ -531,14 +531,27 @@ def _decode_video_frames_ffmpeg(
|
||||
)
|
||||
|
||||
|
||||
def sample_reference_video_frames(prepared_path: str) -> dict[str, Any]:
|
||||
meta = _probe_video(prepared_path)
|
||||
def sample_reference_video_frames(
|
||||
prepared_path: str,
|
||||
*,
|
||||
decoded_frames: np.ndarray | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if decoded_frames is None:
|
||||
meta = _probe_video(prepared_path)
|
||||
frame_count = int(meta["frame_count"])
|
||||
else:
|
||||
decoded_frames = np.asarray(decoded_frames)
|
||||
if decoded_frames.ndim != 4 or decoded_frames.shape[-1] != 3 or len(decoded_frames) <= 0:
|
||||
raise OmniClientError(
|
||||
f"decoded reference video frames must have shape [T, H, W, 3], got {decoded_frames.shape}"
|
||||
)
|
||||
frame_count = len(decoded_frames)
|
||||
ratio = MINIMAX_H3_FPS / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS
|
||||
indices: list[int] = []
|
||||
cursor = 0.0
|
||||
while True:
|
||||
frame_index = int(round(cursor))
|
||||
if frame_index >= meta["frame_count"]:
|
||||
if frame_index >= frame_count:
|
||||
break
|
||||
if not indices or frame_index > indices[-1]:
|
||||
indices.append(frame_index)
|
||||
@@ -546,16 +559,16 @@ def sample_reference_video_frames(prepared_path: str) -> dict[str, Any]:
|
||||
if not indices:
|
||||
raise OmniClientError(f"no frames sampled from {prepared_path}")
|
||||
|
||||
# The preparation step emits a lossless RGB stream. Decode only the sampled
|
||||
# frames in one ffmpeg process so Qwen3VL sees the exact prepared pixels
|
||||
# without decoding the entire high-bitrate stream into host memory.
|
||||
decoded_frames = _decode_video_frames_ffmpeg(
|
||||
prepared_path,
|
||||
frame_count=int(meta["frame_count"]),
|
||||
indices=indices,
|
||||
width=meta["width"],
|
||||
height=meta["height"],
|
||||
)
|
||||
if decoded_frames is None:
|
||||
decoded_frames = _decode_video_frames_ffmpeg(
|
||||
prepared_path,
|
||||
frame_count=frame_count,
|
||||
indices=indices,
|
||||
width=meta["width"],
|
||||
height=meta["height"],
|
||||
)
|
||||
else:
|
||||
decoded_frames = decoded_frames[indices]
|
||||
frames = [np.asarray(frame) for frame in decoded_frames]
|
||||
|
||||
timestamps = [index / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS for index in range(len(indices))]
|
||||
|
||||
@@ -176,7 +176,7 @@ class _ResidualMerge(nn.Module):
|
||||
info=Qwen3VLProcessingInfo,
|
||||
dummy_inputs=Qwen3VLDummyInputsBuilder,
|
||||
)
|
||||
class MiniMaxH3TextEncoder(Qwen3VLForConditionalGeneration):
|
||||
class MiniMaxH3TextEncoderBackbone(Qwen3VLForConditionalGeneration):
|
||||
"""Qwen3-VL encoder used by MiniMax H3.
|
||||
|
||||
MiniMax H3 consumes the residual stream after decoder layer 50. The
|
||||
@@ -305,3 +305,6 @@ class MiniMaxH3TextEncoder(Qwen3VLForConditionalGeneration):
|
||||
encoder_weights(),
|
||||
mapper=self.hf_to_vllm_mapper,
|
||||
)
|
||||
|
||||
|
||||
MiniMaxH3TextEncoder = MiniMaxH3TextEncoderBackbone
|
||||
@@ -476,10 +476,10 @@ _OMNI_MODELS = {
|
||||
"qwen3_vl",
|
||||
"AuraQwen3VLForConditionalGeneration",
|
||||
),
|
||||
"MiniMaxH3TextEncoder": (
|
||||
"MiniMaxH3Encoder": (
|
||||
"minimax_h3",
|
||||
"text_encoder",
|
||||
"MiniMaxH3TextEncoder",
|
||||
"encoder",
|
||||
"MiniMaxH3Encoder",
|
||||
),
|
||||
## MiniMax-Music3 (text-to-music; AR talker -> flow-matching acoustic decoder)
|
||||
"MiniMaxMusic3TalkerForConditionalGeneration": (
|
||||
|
||||
@@ -1,73 +1,22 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""MiniMax H3 text-encoder stage input and output adapters."""
|
||||
"""Adapters between MiniMax H3 conditioning and the Omni stage runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.data_entry_keys import REQUEST_ARTIFACT_DIRS_KEY
|
||||
from vllm_omni.diffusion.models.minimax_h3.time_request import minimax_h3_align_frame_count
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.data_entry_keys import unflatten_payload
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MINIMAX_H3_CONDITION_LABELS_KEY,
|
||||
MINIMAX_H3_ENCODER_REQUEST_KEY,
|
||||
MINIMAX_H3_PRESENTATION_TASK_KEY,
|
||||
MiniMaxH3TextConditioning,
|
||||
MiniMaxH3EncoderConditioning,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.preprocessing import (
|
||||
MINIMAX_H3_OUTPUT_SHORT_EDGE,
|
||||
load_minimax_h3_images,
|
||||
resolve_minimax_h3_aspect_ratio,
|
||||
resolve_minimax_h3_output_canvas,
|
||||
resolve_minimax_h3_reference_image_shape,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.reference_video import (
|
||||
MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY,
|
||||
MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS,
|
||||
prepare_reference_videos,
|
||||
sample_reference_video_frames,
|
||||
serialize_prepared_reference_videos,
|
||||
)
|
||||
|
||||
|
||||
def _items(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, tuple) and not (len(value) == 2 and isinstance(value[1], Mapping)):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
|
||||
def _audio_items(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[1], (int, np.integer)):
|
||||
return [value]
|
||||
return list(value) if isinstance(value, (list, tuple)) else [value]
|
||||
|
||||
|
||||
def _resolve_task(
|
||||
extra_args: Mapping[str, Any],
|
||||
multi_modal_data: Mapping[str, Any],
|
||||
) -> str:
|
||||
requested = extra_args.get("task")
|
||||
if requested is not None:
|
||||
return str(requested).lower()
|
||||
if multi_modal_data.get("video") is not None or multi_modal_data.get("audio") is not None:
|
||||
return "ref2va"
|
||||
if multi_modal_data.get("image") is not None:
|
||||
return "fl2va"
|
||||
return "t2va"
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import prepare_encoder_inputs
|
||||
|
||||
|
||||
def _diffusion_sampling_params(sampling_params_list: Sequence[Any]) -> Any:
|
||||
@@ -78,190 +27,29 @@ def _diffusion_sampling_params(sampling_params_list: Sequence[Any]) -> Any:
|
||||
]
|
||||
if len(diffusion_params) != 1:
|
||||
raise RuntimeError(
|
||||
"MiniMax H3 text encoding requires exactly one OmniDiffusionSamplingParams stage parameter, "
|
||||
"MiniMax H3 encoding requires exactly one OmniDiffusionSamplingParams stage parameter, "
|
||||
f"got {len(diffusion_params)}"
|
||||
)
|
||||
return diffusion_params[0]
|
||||
|
||||
|
||||
def _ref2va_target_frame_count(sampling_params_list: Sequence[Any]) -> int:
|
||||
sampling = _diffusion_sampling_params(sampling_params_list)
|
||||
extra_args = sampling.extra_args or {}
|
||||
target = extra_args.get("target")
|
||||
target = target if isinstance(target, Mapping) else {}
|
||||
duration = target.get("duration_seconds", extra_args.get("duration_seconds", extra_args.get("duration")))
|
||||
if duration is not None:
|
||||
requested = int(round(float(duration) * 24))
|
||||
elif int(getattr(sampling, "num_frames", None) or 1) > 1:
|
||||
requested = int(sampling.num_frames)
|
||||
else:
|
||||
requested = 124
|
||||
return minimax_h3_align_frame_count(requested)
|
||||
|
||||
|
||||
def _prepare_qwen_images(
|
||||
task: str,
|
||||
values: list[Any],
|
||||
sampling_params_list: Sequence[Any],
|
||||
) -> list[Any]:
|
||||
if not values:
|
||||
return []
|
||||
images = load_minimax_h3_images(values)
|
||||
if task == "ref2va":
|
||||
return [
|
||||
image.resize(
|
||||
resolve_minimax_h3_reference_image_shape(image),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
for image in images
|
||||
]
|
||||
if task != "fl2va":
|
||||
return images
|
||||
|
||||
sampling = _diffusion_sampling_params(sampling_params_list)
|
||||
extra_args = sampling.extra_args or {}
|
||||
target = extra_args.get("target")
|
||||
if target is not None and not isinstance(target, Mapping):
|
||||
raise OmniClientError("MiniMax H3 extra_args['target'] must be an object")
|
||||
target = target if isinstance(target, Mapping) else {}
|
||||
aspect_ratio = resolve_minimax_h3_aspect_ratio(
|
||||
task,
|
||||
target.get("aspect_ratio", extra_args.get("aspect_ratio")),
|
||||
images[0],
|
||||
)
|
||||
if not 0.25 <= aspect_ratio <= 4.0:
|
||||
raise OmniClientError(f"MiniMax H3 canvas aspect ratio must be in [1:4, 4:1], got {aspect_ratio}")
|
||||
height = sampling.height
|
||||
width = sampling.width
|
||||
if height is None or width is None:
|
||||
short_edge = target.get(
|
||||
"short_edge",
|
||||
extra_args.get("short_edge", MINIMAX_H3_OUTPUT_SHORT_EDGE),
|
||||
)
|
||||
if isinstance(short_edge, bool) or not isinstance(short_edge, (int, np.integer)):
|
||||
raise OmniClientError(
|
||||
f"MiniMax H3 target.short_edge must be {MINIMAX_H3_OUTPUT_SHORT_EDGE}, got {short_edge!r}"
|
||||
)
|
||||
height, width = resolve_minimax_h3_output_canvas(aspect_ratio, int(short_edge))
|
||||
height = int(height) // 32 * 32
|
||||
width = int(width) // 32 * 32
|
||||
if min(height, width) <= 0:
|
||||
raise OmniClientError(f"invalid MiniMax H3 canvas {width}x{height}")
|
||||
if width > 4 * height or height > 4 * width:
|
||||
raise OmniClientError("MiniMax H3 canvas aspect ratio must be in [1:4, 4:1]")
|
||||
return [image.resize((width, height), Image.Resampling.LANCZOS) for image in images]
|
||||
|
||||
|
||||
def prepare_text_encoder_prompt(
|
||||
def prepare_encoder_prompt(
|
||||
prompt: Any,
|
||||
sampling_params_list: Sequence[Any],
|
||||
) -> Any:
|
||||
"""Build H3's labeled Qwen3-VL presentation for Stage 0.
|
||||
|
||||
The upstream Qwen3-VL multimodal processor expands each image/video
|
||||
placeholder into the exact number of vision tokens and adds timestamped
|
||||
video blocks. Audio is represented only by its H3 text label and is not
|
||||
sent to Qwen3-VL.
|
||||
"""
|
||||
prepared = prepare_encoder_inputs(prompt, _diffusion_sampling_params(sampling_params_list))
|
||||
if isinstance(prompt, str):
|
||||
return prompt
|
||||
if not isinstance(prompt, dict):
|
||||
raise TypeError(f"MiniMax H3 expects a string or dict prompt, got {type(prompt)!r}")
|
||||
|
||||
prompt = copy.copy(prompt)
|
||||
additional_information = dict(prompt.get("additional_information") or {})
|
||||
meta = dict(additional_information.get("meta") or {})
|
||||
meta.pop(MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY, None)
|
||||
additional_information["meta"] = meta
|
||||
prompt["additional_information"] = additional_information
|
||||
|
||||
text = str(prompt.get("prompt") or "")
|
||||
if not text:
|
||||
raise OmniClientError("MiniMax H3 requires a non-empty prompt")
|
||||
multi_modal_data = prompt.get("multi_modal_data") or {}
|
||||
if not isinstance(multi_modal_data, Mapping):
|
||||
raise TypeError("multi_modal_data must be a mapping")
|
||||
|
||||
image_values = _items(multi_modal_data.get("image"))
|
||||
videos = _items(multi_modal_data.get("video"))
|
||||
audios = _audio_items(multi_modal_data.get("audio"))
|
||||
diffusion_sampling = _diffusion_sampling_params(sampling_params_list)
|
||||
extra_args = diffusion_sampling.extra_args or {}
|
||||
task = _resolve_task(extra_args, multi_modal_data)
|
||||
images = _prepare_qwen_images(task, image_values, sampling_params_list)
|
||||
qwen_video_inputs: list[tuple[np.ndarray, dict[str, Any]]] = []
|
||||
condition_labels: list[tuple[str, int]] = []
|
||||
|
||||
if task == "t2va":
|
||||
if images or videos or audios:
|
||||
raise OmniClientError("t2va does not accept image, video, or audio conditions")
|
||||
elif task == "fl2va":
|
||||
if not images or videos or audios:
|
||||
raise OmniClientError("fl2va requires image conditions only")
|
||||
condition_labels.extend(("image", index) for index in range(1, len(images) + 1))
|
||||
elif task == "ref2va":
|
||||
if not images and not videos:
|
||||
raise OmniClientError("ref2va requires an image or video condition")
|
||||
condition_labels.extend(("image", index) for index in range(1, len(images) + 1))
|
||||
prepared_videos: list[dict[str, Any]] = []
|
||||
artifact_dir: str | None = None
|
||||
if videos:
|
||||
artifact_dir = tempfile.mkdtemp(prefix="minimax_h3_ref2va_")
|
||||
try:
|
||||
prepared_videos = prepare_reference_videos(
|
||||
videos,
|
||||
target_frame_count=_ref2va_target_frame_count(sampling_params_list),
|
||||
workdir=artifact_dir,
|
||||
start_time_seconds=extra_args.get("start_time_seconds"),
|
||||
)
|
||||
for item in prepared_videos:
|
||||
sampled = sample_reference_video_frames(item["prepared_path"])
|
||||
frames = np.stack(sampled["frames"])
|
||||
frame_count = int(frames.shape[0])
|
||||
qwen_video_inputs.append(
|
||||
(
|
||||
frames,
|
||||
{
|
||||
"total_num_frames": frame_count,
|
||||
"fps": MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS,
|
||||
"duration": frame_count / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS,
|
||||
"video_backend": "minimax_h3",
|
||||
"frames_indices": list(range(frame_count)),
|
||||
"do_sample_frames": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
except BaseException:
|
||||
shutil.rmtree(artifact_dir, ignore_errors=True)
|
||||
raise
|
||||
audio_index = 0
|
||||
for video_index, item in enumerate(prepared_videos, start=1):
|
||||
if item["input_has_audio"]:
|
||||
audio_index += 1
|
||||
condition_labels.append(("audio", audio_index))
|
||||
condition_labels.append(("video", video_index))
|
||||
for _ in audios:
|
||||
audio_index += 1
|
||||
condition_labels.append(("audio", audio_index))
|
||||
else:
|
||||
raise OmniClientError(f"unsupported MiniMax H3 task {task!r}")
|
||||
|
||||
prompt = {"prompt": prompt}
|
||||
text = prepared.prompt
|
||||
images = prepared.images
|
||||
qwen_video_inputs = prepared.qwen_videos
|
||||
condition_labels = prepared.condition_labels
|
||||
media_input = prepared.media
|
||||
task = media_input.task
|
||||
transformed = copy.copy(prompt)
|
||||
if isinstance(prompt.get("additional_information"), Mapping):
|
||||
transformed["additional_information"] = dict(prompt["additional_information"])
|
||||
additional_information = dict(prompt.get("additional_information") or {})
|
||||
transformed["prompt"] = text
|
||||
if task == "ref2va" and prepared_videos and artifact_dir is not None:
|
||||
additional_information = dict(transformed.get("additional_information") or {})
|
||||
meta = dict(additional_information.get("meta") or {})
|
||||
meta[MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY] = serialize_prepared_reference_videos(
|
||||
prepared_videos,
|
||||
artifact_dir,
|
||||
)
|
||||
additional_information["meta"] = meta
|
||||
transformed["additional_information"] = additional_information
|
||||
transformed[REQUEST_ARTIFACT_DIRS_KEY] = [artifact_dir]
|
||||
qwen_mm_data = dict(multi_modal_data)
|
||||
qwen_mm_data.pop("audio", None)
|
||||
qwen_mm_data: dict[str, Any] = {}
|
||||
if images:
|
||||
qwen_mm_data["image"] = images
|
||||
if qwen_video_inputs:
|
||||
@@ -271,7 +59,16 @@ def prepare_text_encoder_prompt(
|
||||
mm_processor_kwargs = dict(prompt.get("mm_processor_kwargs") or {})
|
||||
mm_processor_kwargs[MINIMAX_H3_PRESENTATION_TASK_KEY] = task
|
||||
mm_processor_kwargs[MINIMAX_H3_CONDITION_LABELS_KEY] = condition_labels
|
||||
media_tensors = media_input.to_mm_tensors()
|
||||
transformed["mm_processor_kwargs"] = mm_processor_kwargs
|
||||
|
||||
hidden_states = dict(additional_information.get("hidden_states") or {})
|
||||
hidden_states["layers"] = dict(enumerate(media_tensors))
|
||||
additional_information["hidden_states"] = hidden_states
|
||||
meta = dict(additional_information.get("meta") or {})
|
||||
meta[MINIMAX_H3_ENCODER_REQUEST_KEY] = media_input.to_metadata()
|
||||
additional_information["meta"] = meta
|
||||
transformed["additional_information"] = additional_information
|
||||
return transformed
|
||||
|
||||
|
||||
@@ -295,18 +92,20 @@ def _global_request_id(prompt: Mapping[str, Any]) -> str | None:
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def text_encoder2diffusion(
|
||||
def encoder2diffusion(
|
||||
source_outputs: list[Any],
|
||||
prompt: Any = None,
|
||||
requires_multimodal_data: bool = False,
|
||||
streaming_context: Any | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Attach Stage 0 hidden states and token tags to the original request."""
|
||||
"""Reuse the encoder handoff for all three H3 encoder components."""
|
||||
del requires_multimodal_data, streaming_context
|
||||
if not source_outputs:
|
||||
return None
|
||||
if len(source_outputs) != 1:
|
||||
raise RuntimeError(f"MiniMax H3 diffusion requires exactly one text-encoder source, got {len(source_outputs)}")
|
||||
raise RuntimeError(f"MiniMax H3 DiT requires exactly one encoder source, got {len(source_outputs)}")
|
||||
if not getattr(source_outputs[0], "finished", True):
|
||||
return None
|
||||
|
||||
diffusion_prompt = _original_prompt(prompt)
|
||||
source_output = source_outputs[0]
|
||||
@@ -318,25 +117,32 @@ def text_encoder2diffusion(
|
||||
and str(source_request_id) != expected_request_id
|
||||
):
|
||||
raise RuntimeError(
|
||||
"MiniMax H3 text-encoder request ID does not match the diffusion request: "
|
||||
"MiniMax H3 encoder request ID does not match the diffusion request: "
|
||||
f"source={source_request_id!r}, expected={expected_request_id!r}"
|
||||
)
|
||||
|
||||
outputs = getattr(source_output, "outputs", None)
|
||||
if not isinstance(outputs, list) or len(outputs) != 1:
|
||||
output_count = len(outputs) if isinstance(outputs, list) else 0
|
||||
raise RuntimeError(f"MiniMax H3 text encoder must return exactly one completion, got {output_count}")
|
||||
|
||||
completion = outputs[0]
|
||||
payload = completion.multimodal_output
|
||||
raise RuntimeError(f"MiniMax H3 encoder must return exactly one completion, got {output_count}")
|
||||
payload = getattr(outputs[0], "multimodal_output", None)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise RuntimeError("MiniMax H3 text encoder returned no conditioning payload")
|
||||
raise RuntimeError("MiniMax H3 encoder returned no conditioning payload")
|
||||
try:
|
||||
conditioning = MiniMaxH3TextConditioning.from_omni_payload(payload)
|
||||
conditioning = MiniMaxH3EncoderConditioning.from_omni_payload(unflatten_payload(dict(payload)))
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
additional_information = dict(diffusion_prompt.get("additional_information") or {})
|
||||
additional_information["text_encoder_output"] = conditioning.to_payload()
|
||||
additional_information.pop("hidden_states", None)
|
||||
meta = dict(additional_information.get("meta") or {})
|
||||
meta.pop(MINIMAX_H3_ENCODER_REQUEST_KEY, None)
|
||||
if meta:
|
||||
additional_information["meta"] = meta
|
||||
else:
|
||||
additional_information.pop("meta", None)
|
||||
additional_information["encoder_output"] = conditioning.to_omni_payload()
|
||||
diffusion_prompt["additional_information"] = additional_information
|
||||
diffusion_prompt["multi_modal_data"] = None
|
||||
diffusion_prompt.pop("model_intermediate_buffer", None)
|
||||
return diffusion_prompt
|
||||
Reference in new issue
Block a user