[Model] Add MiniMax-H3 long-video latent continuation with driving audio (#7838)

Signed-off-by: princepride <wangzhipeng628@gmail.com>
This commit is contained in:
汪志鹏 authored and GitHub committed 2026-09-20 23:44:33 +08:00
1 parent 16734bbdf3
commit 139a47a578
17 files changed
+1089 -50

No files matched your search

+147 -1
View File
@@ -825,7 +825,7 @@ at most 15 seconds combined.
| FL2VA | first image, last image, or ordered first+last images | at most 2 images; `frame_indices` is `[0]`, `[-1]`, or `[0,-1]` |
| Ref2VA | image-only, image+image, image+video, video+audio, and mixed image/video/audio | images ≤9, videos ≤3, audios ≤3, total references ≤12; audio requires a visual reference |
The H3 output contract is 4–15 seconds at 24 FPS, stereo 32 kHz audio, and a
The default H3 output contract is 4–15 seconds at 24 FPS, stereo 32 kHz audio, and a
32-pixel canvas multiple. T2VA requires one named output ratio from `21:9`,
`16:9`, `4:3`, `1:1`, `3:4`, or `9:16`. FL2VA always follows the first input
image's ratio and ignores a generic `aspect_ratio` override. Ref2VA defaults to
@@ -836,6 +836,152 @@ default. `short_edge` controls the 768-pixel canvas and must be `768`.
outputs; the synchronous raw-MP4 endpoint returns the first output when more
than one is requested.
## Long video and driving audio
Set `extra_params.long_video=true` to opt into durations above 15 seconds.
Ref2VA long-video requests now default to latent continuation with global
temporal RoPE positions (A+B), described below. Set `long_video_mode=full`
explicitly to sample the entire target latent at once for comparison. Both modes
retain the native `17n+5` video-frame grid, `5n+2` video-latent grid, and 40 Hz
audio latents. A request for 75 seconds becomes 1,807 frames (75.292 seconds at
24 FPS). In full mode, attention cost and activation memory grow with the full
sequence length. Reference-video and reference-audio limits remain unchanged.
Other task types retain full-mode behavior. Requests are limited to 30 seconds
in full mode and 300 seconds in continuation mode; without `long_video=true`,
the limit remains 15 seconds. The limits cover duration aliases and explicit
`num_frames`, and externally encoded requests are checked again before diffusion.
Native frame-grid alignment can round the output slightly above the requested
limit. These are request sanity bounds, not a guarantee that a given resolution
fits GPU memory. Full mode still allocates the whole sequence, and continuation
retains cumulative latents and reference inputs.
Use `audio_mode=lock_source` with one `audio_reference` to drive generation
with a soundtrack, rather than treating it as a short reference. The encoder
places that waveform in the target audio latent, crops or zero-pads its two
channels independently to the output duration, and keeps those rows clean
(timestep 1) throughout sampling. This mode does not insert an `<Audio 1>`
reference tag. It supports request and step execution; the default `native`
mode continues generating audio normally. Returned audio is the audio VAE's
reconstruction, not a byte-for-byte copy of the input recording.
Against a Ref2VA server, using the same asset-server setup as above:
```bash
curl --fail-with-body -sS -X POST "${API_URL}" \
-F 'prompt=<prompt.txt' \
-F 'width=960' -F 'height=544' -F 'fps=24' \
-F 'num_inference_steps=50' -F 'flow_shift=12' -F 'seed=19960422' \
-F 'extra_params={"task":"ref2va","duration":75,"long_video":true,"audio_mode":"lock_source","audio_flow_shift":3,"preencode_mp4":true,"preencode_batch_frames":17}' \
-F "input_reference=@${REF_IMAGE};type=image/png" \
-F "audio_reference={\"audio_url\":\"${AUDIO_URL}\"}" \
-o long-video.mp4
```
`preencode_mp4` decodes and muxes temporal chunks on the worker, avoiding a
full decoded long video in the API process. It does not reduce the denoiser's
sequence length. For Turbo adapters, use the matching adapter's step count
and flow shift from the Turbo table below.
The [RunningHub workflow](https://www.runninghub.cn/post/2100530217365364737/)
uses T8's `MiniMaxH3AudioConditioningT8`, a full-length latent, and
`MiniMaxH3DualClockSamplerT8`; its `remix_source` strength is zero, equivalent
to source locking. This differs from rolling-overlap continuation nodes.
See [T8 conditioning](https://github.com/T8mars/comfyui-minimax-h3-audio-T8)
and [ComfyUI H3 masking](https://github.com/Comfy-Org/ComfyUI/blob/master/comfy/ldm/minimax/model.py).
The workflow's custom Dasiwa weights, Sol attention, and T8-specific LoRA are
not supplied by this feature.
### Latent-tail continuation with global temporal positions (A+B)
`long_video=true` selects this mode by default for Ref2VA. It can also be selected
explicitly with `long_video_mode=continuation`. This experimental mode supports
Ref2VA request execution with uncached denoising (`quality=lossless`); step
execution is rejected. Use `long_video_mode=full` explicitly for whole-target
sampling, including requests up to 30 seconds that require step execution.
Continuation currently rejects latent-mask editing (`video_noise_mask` or
`audio_noise_mask` with retained source regions). Full mode supports editing;
`audio_mode=lock_source` cannot be combined with audio latent-mask editing.
```json
{
"task": "ref2va",
"duration": 75,
"long_video": true,
"long_video_mode": "continuation",
"continuation_window_frames": 277,
"continuation_overlap_frames": 22,
"audio_mode": "lock_source",
"audio_flow_shift": 3,
"preencode_mp4": true,
"preencode_batch_frames": 17
}
```
These defaults sample seven windows for a 1,807-frame output. Each continuation
uses the preceding AV latent tail as fixed condition rows at the new target's
time origin, samples a fresh unmasked target, discards its hidden overlap, and
appends only the new suffix. The reference image and full prompt remain present
in every window. Both window and overlap use the `17n+5` frame grid; the window
must be 107..345 frames and larger than the overlap. A final window may be shorter.
Audio boundaries are rounded on the cumulative 24 FPS / 40 Hz timeline to avoid
drift; a locked driving track is sliced separately for each stereo channel.
Before RoPE evaluation, every window adds `start_frame * 40 / 24` to the temporal
position IDs of its target audio/video, reference audio/video, and latent-tail
guides. The start includes the overlap (not just the newly appended frames).
Text, static image references, spatial coordinates, and padding remain unchanged.
Offsets retain fractional precision independently of rounded audio slice indices.
For the default windows starting at frames 0, 255, 510, the added positions are
0, 425, 850. This follows the media-only global-offset convention in
[LongMedia temporal positioning](https://github.com/vizart-vj/ComfyUI-MiniMax-H3-LongMedia/blob/main/temporal_positioning.py).
The shifted layout is constructed once per window and then used by all denoising
steps and sequence-parallel ranks; no RoPE-frequency scaling is applied.
For a narrative with different scenes, pass `continuation_prompts` as a JSON
list containing exactly one non-empty prompt string per planned window (seven
strings for the default 75-second request). These prompts are independently
encoded with the same reference assets and selected in window order. Shot times
inside each prompt describe that local window, including its hidden overlap.
This option requires a local text encoder; external-encoder stage execution is
not supported. Media positions use one shared origin after the longest encoded
text prefix, so different prompt lengths do not shift the global AV clock.
Without this list, the request's single prompt is reused as before.
The cumulative latent is decoded once after all windows. Denoising memory is
bounded by the window, while cumulative latent storage still grows with duration.
This follows the [ComfyUI latent-tail continuation algorithm](https://github.com/ttulttul/ComfyUI-Minimax-H3-Continuation).
Global positions can still exceed the model's trained temporal range. This
mode does not guarantee seamless cuts or adherence to absolute shot timestamps in
a repeated prompt. For comparison, keep the model, seed, prompt, source media,
steps, shifts, and output size fixed, changing only `long_video_mode`.
For a reproducible approximately 20-second comparison, use a request-mode
Ref2VA server with caching disabled and the same `prompt.txt` and `REF_IMAGE`
for both requests. Use a single prompt without `continuation_prompts` so that
both modes receive identical text. The commands below generate native audio:
```bash
for mode in full continuation; do
curl --fail-with-body -sS --max-time 14400 "${API_URL}" \
-F 'prompt=<prompt.txt' \
-F "input_reference=@${REF_IMAGE};type=image/png" \
-F 'width=960' -F 'height=544' -F 'fps=24' \
-F 'num_inference_steps=50' -F 'flow_shift=12' -F 'seed=19960422' \
-F 'quality=lossless' \
-F "extra_params={\"task\":\"ref2va\",\"duration\":20,\"long_video\":true,\"long_video_mode\":\"${mode}\",\"audio_flow_shift\":3,\"preencode_mp4\":true}" \
-o "h3-20s-${mode}.mp4" || break
done
```
Both outputs should contain 481 frames (20.042 seconds at 24 FPS).
Continuation uses windows `[0,277)` and `[255,481)`; inspect frames 276/277
(about 11.54 seconds) for visual and audio discontinuities. Compare the complete
clips for motion, identity, prompt adherence, and sound, not just frame similarity:
the trajectories differ by design. Report the tested commit, checkpoint, hardware,
settings, and both videos with any comparison; the command alone is not quality
evidence. A matching seed does not imply matching noise over differently sized
full and windowed latents.
## Request-scoped quality
Add one of these fields to any HTTP request above. No Cache-DiT startup option
@@ -173,3 +173,20 @@ def test_fused_qk_norm_rope_min_tokens_resolution(monkeypatch):
monkeypatch.setenv(env, bad)
with pytest.raises(ValueError, match=env):
fused_qk_norm_rope_min_tokens(2048)
@pytest.mark.skipif(not torch.cuda.is_available() or not HAS_TRITON, reason="CUDA and Triton required")
@pytest.mark.parametrize("interleaved", [False, True])
def test_fused_qk_norm_rope_large_storage_offsets(interleaved):
"""Only three tokens, but the last row lies beyond signed 32-bit indexing."""
from vllm_omni.diffusion.layers.fused_qk_norm_rope import fused_qk_norm_rope
# 4 GiB of backing storage; initialize only the three small visible rows.
q = torch.empty_strided((3, 1, 128), (2**30, 128, 1), device="cuda", dtype=torch.bfloat16)
q.fill_(1)
weight = torch.ones(128, device="cuda", dtype=torch.bfloat16)
table = torch.zeros(3, 96, device="cuda", dtype=torch.bfloat16)
table[:, :48] = 1
actual = fused_qk_norm_rope(q, q, weight, weight, table, _EPS, interleaved=interleaved)
for value in actual:
torch.testing.assert_close(value, torch.ones_like(value))
@@ -87,7 +87,8 @@ def _collective_worker(rank, rendezvous, backend):
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)
text_conditioning, window_text = model._prepare_local_conditioning({"prompt": "test"}, sampling)
assert window_text is None
torch.testing.assert_close(text_conditioning.hidden_states, torch.full_like(text_conditioning.hidden_states, 2))
assert text_conditioning.visual_condition is None
@@ -0,0 +1,231 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
import torch
from vllm_omni.diffusion.models.minimax_h3.continuation import (
diffuse_continuation,
plan_continuation_windows,
resolve_continuation,
)
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import minimax_h3_packed_sequence_ref2va_blocks
from vllm_omni.errors import OmniClientError
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@pytest.mark.parametrize("vary_prompts", [False, True])
@pytest.mark.parametrize("total,window", [(1807, 277), (1807, 243), (124, 277)])
def test_continuation_retains_prefix_and_global_stereo_timeline(total, window, vary_prompts):
audio_t = round(total * 40 / 24)
source = torch.arange(2 * audio_t * 32).reshape(2, audio_t, 32).float()
calls = []
plan = plan_continuation_windows(total, window, 22)
texts = [(torch.full((3 + i, 8), float(i)), torch.ones(3 + i, dtype=torch.long)) for i in range(len(plan))]
def sample(**kw):
calls.append(kw)
t = kw["latent_t"]
if vary_prompts:
assert kw["text_embeddings"] is texts[len(calls) - 1][0]
assert kw["text_tags"] is texts[len(calls) - 1][1]
assert kw["media_time_origin"] == len(texts[-1][0])
# Different constants expose replacement of an old prefix or an
# accidental append of the hidden overlap.
video = torch.full((1, 24, t, 2, 2), float(len(calls)))
audio = kw["locked_audio_rows"].reshape(2, kw["audio_t"], 32).permute(0, 2, 1)
if len(calls) > 1:
assert torch.all(kw["visual_condition"] == len(calls) - 1)
assert kw["ref_blocks"][-1]["kind"] == "latent_guide"
return video, audio
kwargs = dict(
num_frames=total,
latent_t=(total - 5) // 17 * 5 + 2,
latent_h=2,
latent_w=2,
audio_t=audio_t,
locked_audio_rows=source.reshape(-1, 32),
)
video, audio = diffuse_continuation(
sample, kwargs, window_frames=window, overlap_frames=22, text_conditioning=texts if vary_prompts else None
)
torch.testing.assert_close(audio, source.permute(0, 2, 1))
plan = plan_continuation_windows(total, window, 22)
assert [call["temporal_offset"] for call in calls] == pytest.approx([part.start * 40 / 24 for part in plan])
offset = 0
for i, part in enumerate(plan):
new_frames = part.end - part.start - part.overlap
count = (new_frames - 5) // 17 * 5 + 2 if i == 0 else new_frames // 17 * 5
assert torch.all(video[:, :, offset : offset + count] == i + 1)
offset += count
assert offset == video.shape[2] == kwargs["latent_t"]
@pytest.mark.parametrize("temporal_offset", [0.0, 425.0, 221 * 40 / 24])
def test_latent_guide_shares_target_clock_and_remains_condition_only(temporal_offset):
packed = minimax_h3_packed_sequence_ref2va_blocks(
temporal_offset=temporal_offset,
text_len=3,
latent_t=12,
latent_h=4,
latent_w=4,
audio_t=68,
ref_blocks=[
{"kind": "image", "latent_h": 4, "latent_w": 4},
{"kind": "latent_guide", "latent_t": 7, "latent_h": 4, "latent_w": 4, "ref_audio_t": 37},
],
)
pos = packed["img_position_ids"]
visual = packed["img_pos"]
target = visual[packed["update_mask"]]
guide = visual[~packed["update_mask"]][4:]
torch.testing.assert_close(pos[guide], pos[target[:28]])
audio = packed["audio_pos"]
guide_audio = audio[~packed["audio_update_mask"]].reshape(2, 37)
target_audio = audio[packed["audio_update_mask"]].reshape(2, 68)
torch.testing.assert_close(pos[guide_audio], pos[target_audio[:, :37]])
assert pos[target[0], 0] == pytest.approx(4 + temporal_offset)
torch.testing.assert_close(pos[:3, 0], torch.arange(3, dtype=torch.float64))
assert torch.all(pos[3:7, 0] == 3) # static reference image stays fixed
@pytest.mark.parametrize(
"extra,step",
[
({"long_video_mode": "bad"}, False),
({"long_video_mode": "continuation"}, True),
({"long_video_mode": "continuation", "continuation_overlap_frames": 23}, False),
({"long_video_mode": "continuation", "continuation_window_frames": True}, False),
({"long_video_mode": "continuation", "continuation_overlap_frames": 277}, False),
],
)
def test_invalid_continuation_options(extra, step):
with pytest.raises(OmniClientError):
resolve_continuation(extra, task="ref2va", step_execution=step)
def test_global_offset_moves_temporal_references_but_not_images_or_padding():
kwargs = dict(
text_len=3,
latent_t=12,
latent_h=4,
latent_w=4,
audio_t=68,
ref_blocks=[
{"kind": "image", "latent_h": 4, "latent_w": 4},
{"kind": "audio", "ref_audio_t": 3},
{"kind": "video_audio", "ref_audio_t": 2, "latent_t": 2, "latent_h": 4, "latent_w": 4},
{"kind": "image", "latent_h": 4, "latent_w": 4},
{"kind": "latent_guide", "ref_audio_t": 37, "latent_t": 7, "latent_h": 4, "latent_w": 4},
],
)
base = minimax_h3_packed_sequence_ref2va_blocks(**kwargs)
shifted = minimax_h3_packed_sequence_ref2va_blocks(**kwargs, temporal_offset=425.0)
moving = base["image_mask"] | base["audio_mask"]
moving[3:7] = False # first static image
moving[25:29] = False # static image after temporal references
delta = shifted["img_position_ids"] - base["img_position_ids"]
torch.testing.assert_close(delta[:, 0], moving.double() * 425)
assert torch.count_nonzero(delta[:, 1:]) == 0
for key, value in base.items():
if isinstance(value, torch.Tensor) and key != "img_position_ids":
torch.testing.assert_close(shifted[key], value)
def test_long_ref2va_defaults_to_continuation_with_explicit_full_opt_out():
assert resolve_continuation({"long_video": True}, task="ref2va", step_execution=False) == (277, 22)
assert resolve_continuation({}, task="ref2va", step_execution=False) is None
assert (
resolve_continuation({"long_video": True, "long_video_mode": "full"}, task="ref2va", step_execution=False)
is None
)
def test_different_chunk_prompt_lengths_keep_the_same_media_clock():
kwargs = dict(
latent_t=7,
latent_h=4,
latent_w=4,
audio_t=37,
ref_blocks=[{"kind": "image", "latent_h": 4, "latent_w": 4}],
media_time_origin=20,
)
first = minimax_h3_packed_sequence_ref2va_blocks(text_len=3, **kwargs)
second = minimax_h3_packed_sequence_ref2va_blocks(text_len=15, temporal_offset=425, **kwargs)
for key, mask in (("img_pos", "update_mask"), ("audio_pos", "audio_update_mask")):
a = first["img_position_ids"][first[key][first[mask]]]
b = second["img_position_ids"][second[key][second[mask]]]
torch.testing.assert_close(b[:, 0] - a[:, 0], torch.full_like(a[:, 0], 425))
torch.testing.assert_close(b[:, 1:], a[:, 1:])
@pytest.mark.parametrize("prompt_count", [None, 7, 6])
def test_request_encodes_only_window_prompts_and_prepares_references_once(monkeypatch, prompt_count):
from PIL import Image
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3EncoderMediaConditioning
pipeline = object.__new__(module.MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.load_text_encoder = True
pipeline.device = torch.device("cpu")
pipeline.od_config = None
pipeline._active_turbo_spec = lambda sampling: None
pipeline._has_active_native_lora = lambda sampling: False
pipeline._resolve_task = lambda *args, **kwargs: "ref2va"
encoded, prepared_inputs, encoded_media = [], [], []
prepare = module.prepare_encoder_inputs
def prepare_once(*args, **kwargs):
result = prepare(*args, **kwargs)
prepared_inputs.append(result)
return result
def encode_text(prepared):
encoded.append(prepared)
return torch.full((3, 5120), len(encoded), dtype=torch.bfloat16), torch.zeros(3, dtype=torch.long)
def encode_media(media):
encoded_media.append(media)
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,
)
monkeypatch.setattr(module, "prepare_encoder_inputs", prepare_once)
pipeline.encode_prompt = encode_text
pipeline._encode_local_media = encode_media
pipeline._prepare_encoder_conditioning_inputs = lambda conditioning, sampling: {
"text_embeddings": conditioning.hidden_states,
"continuation": (277, 22),
"num_frames": conditioning.num_frames,
"task": conditioning.task,
}
extra = {"task": "ref2va", "long_video": True, "duration": 75}
if prompt_count is not None:
extra["continuation_prompts"] = [f"window {i}" for i in range(prompt_count)]
sampling = OmniDiffusionSamplingParams(width=64, height=64, fps=24, extra_args=extra)
request = {"prompt": "main prompt", "multi_modal_data": {"image": Image.new("RGB", (256, 256))}}
if prompt_count == 6:
with pytest.raises(OmniClientError, match="exactly 7"):
pipeline._prepare_request_inputs(request, sampling)
assert not encoded and not encoded_media
return
context = pipeline._prepare_request_inputs(request, sampling)
assert [item.prompt for item in encoded] == extra.get("continuation_prompts", ["main prompt"])
assert len(prepared_inputs) == len(encoded_media) == 1
assert all(item.media is prepared_inputs[0].media for item in encoded)
if prompt_count is not None:
texts = context["continuation_text_conditioning"]
assert len(texts) == 7
assert texts[0][0] is context["text_embeddings"]
assert [int(hidden[0, 0]) for hidden, _ in texts] == list(range(1, 8))
else:
assert "continuation_text_conditioning" not in context
@@ -282,15 +282,44 @@ def test_encoder_task_validation_and_transformer_routing():
assert pipeline._resolve_task(None, {"image": object()}) == "fl2va"
assert pipeline._resolve_task(None, {"audio": object()}) == "ref2va"
assert pipeline._resolve_task(None, {"video": object()}) == "ref2va"
assert pipeline._resolve_task(None, {"audio": object()}, audio_mode="lock_source") == "t2va"
assert (
pipeline._resolve_task(
None,
{"image": object(), "audio": object()},
audio_mode="lock_source",
)
== "fl2va"
)
assert (
pipeline._resolve_task(
None,
{"video": object(), "audio": object()},
audio_mode="lock_source",
)
== "ref2va"
)
fl2v_spec = _turbo_spec("minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")
ref2v_spec = _turbo_spec("minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors")
assert pipeline._resolve_task("fl2va", {"image": object()}, turbo_spec=fl2v_spec) == "fl2va"
with pytest.raises(OmniClientError, match="serves"):
pipeline._resolve_task("ref2va", {}, turbo_spec=fl2v_spec)
assert pipeline._resolve_task("ref2va", {"image": object()}, turbo_spec=ref2v_spec) == "ref2va"
pipeline.partition = "fl2va"
pipeline.supported_tasks = frozenset({"t2va", "fl2va"})
assert pipeline._resolve_task(None, {"audio": object()}, audio_mode="lock_source") == "t2va"
assert (
pipeline._resolve_task(
None,
{"image": object(), "audio": object()},
audio_mode="lock_source",
)
== "fl2va"
)
pipeline.partition = "ref2va"
pipeline.supported_tasks = frozenset({"ref2va"})
assert pipeline._resolve_task("ref2va") == "ref2va"
assert pipeline._resolve_task(None, {"audio": object()}, audio_mode="lock_source") == "ref2va"
pipeline.partition = "combined"
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
@@ -3139,3 +3168,19 @@ def test_video_codec_options_are_normalized_for_the_encoder():
assert normalize_video_codec_options(None) is None
with pytest.raises(ValueError, match="video_codec_options"):
normalize_video_codec_options("preset=ultrafast")
@pytest.mark.parametrize("duration", [60, 75])
def test_long_video_shape_requires_explicit_opt_in(duration):
from vllm_omni.errors import OmniClientError
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import resolve_minimax_h3_shape
sampling = OmniDiffusionSamplingParams(width=960, height=544, fps=24, extra_args={"duration": duration})
with pytest.raises(OmniClientError, match="15"):
resolve_minimax_h3_shape("ref2va", sampling, None)
sampling.extra_args["long_video"] = True
_, _, frames, video_t, audio_t = resolve_minimax_h3_shape("ref2va", sampling, None)
assert frames >= duration * 24 and frames % 17 == 5
assert video_t == (frames - 5) // 17 * 5 + 2
assert audio_t == round(frames / 24 * 40)
@@ -179,6 +179,7 @@ def test_encoder_preparation_routes_edit_sources_without_qwen_inputs(
pipeline.device = torch.device("cpu")
pipeline.default_video_shift = 12.0
pipeline.default_audio_shift = 3.0
pipeline.od_config = SimpleNamespace(step_execution=False)
pipeline._fasth3 = None
pipeline._active_turbo_spec = lambda _sampling: None
pipeline._has_active_native_lora = lambda _sampling: False
@@ -0,0 +1,143 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Duration limits apply before encoding and to transported encoder payloads."""
from types import SimpleNamespace
from typing import Any
import pytest
import torch
from PIL import Image
from vllm_omni.errors import OmniClientError
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import (
prepare_encoder_inputs,
resolve_minimax_h3_shape,
)
from vllm_omni.model_executor.models.minimax_h3.long_video import validate_encoded_frame_limit
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@pytest.mark.parametrize("mode,limit", [("full", 30), ("continuation", 300), (None, 300)])
@pytest.mark.parametrize("input_kind", ["duration", "duration_seconds", "target", "num_frames"])
def test_long_video_limits_cover_all_duration_inputs(mode, limit, input_kind):
extra: dict[str, Any] = {"long_video": True}
if mode is not None:
extra["long_video_mode"] = mode
sampling = OmniDiffusionSamplingParams(width=64, height=64, fps=24, extra_args=extra)
def set_duration(seconds):
if input_kind == "target":
extra["target"] = {"duration_seconds": seconds}
elif input_kind == "num_frames":
sampling.num_frames = seconds * 24
else:
extra[input_kind] = seconds
set_duration(limit)
_, _, frames, _, _ = resolve_minimax_h3_shape("ref2va", sampling, None)
assert frames >= limit * 24
validate_encoded_frame_limit(extra, "ref2va", frames)
with pytest.raises(OmniClientError, match="request limit"):
validate_encoded_frame_limit(extra, "ref2va", frames + 17)
set_duration(limit + 1)
with pytest.raises(OmniClientError, match="duration"):
resolve_minimax_h3_shape("ref2va", sampling, None)
@pytest.mark.parametrize("task", ["t2va", "fl2va", "ref2va"])
def test_full_mode_rejects_300_seconds_and_accepts_comparison(task):
sampling = OmniDiffusionSamplingParams(
width=64,
height=64,
fps=24,
extra_args={"long_video": True, "long_video_mode": "full", "duration": 300, "aspect_ratio": "1:1"},
)
image = Image.new("RGB", (64, 64)) if task == "fl2va" else None
with pytest.raises(OmniClientError, match="30"):
resolve_minimax_h3_shape(task, sampling, image)
sampling.extra_args["duration"] = 20
assert resolve_minimax_h3_shape(task, sampling, image)[2] == 481
@pytest.mark.parametrize("mode", ["full", "continuation"])
def test_mode_alone_does_not_bypass_long_video_opt_in(mode):
sampling = OmniDiffusionSamplingParams(
width=64,
height=64,
extra_args={"long_video_mode": mode, "duration": 20},
)
with pytest.raises(OmniClientError, match="15"):
resolve_minimax_h3_shape("ref2va", sampling, None)
@pytest.mark.parametrize("kind", ["video", "audio"])
def test_continuation_edit_rejected_before_loading_source(kind):
sampling = OmniDiffusionSamplingParams(
width=64,
height=64,
extra_args={"long_video": True, "task": "ref2va", "duration": 20},
)
with pytest.raises(OmniClientError, match="continuation does not support latent-mask editing"):
prepare_encoder_inputs(
{
"prompt": "test",
"multi_modal_data": {
"image": Image.new("RGB", (64, 64)),
f"source_{kind}": "must-not-be-loaded",
f"{kind}_noise_mask": 0.5,
},
},
sampling,
)
def test_locked_audio_rejects_audio_edit_before_loading_source():
sampling = OmniDiffusionSamplingParams(
width=64,
height=64,
extra_args={"task": "t2va", "aspect_ratio": "1:1", "audio_mode": "lock_source"},
)
with pytest.raises(OmniClientError, match="cannot be combined"):
prepare_encoder_inputs(
{
"prompt": "test",
"multi_modal_data": {
"audio": (torch.zeros(8000), 8000),
"source_audio": "must-not-be-loaded",
"audio_noise_mask": 0.5,
},
},
sampling,
)
@pytest.mark.parametrize("kind", ["video", "audio"])
def test_external_encoder_cannot_bypass_continuation_edit_validation(kind):
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = SimpleNamespace(step_execution=False)
conditioning = SimpleNamespace(
task="ref2va",
num_frames=481,
video_edit_clean_rows=torch.zeros(1) if kind == "video" else None,
audio_edit_clean_rows=torch.zeros(1) if kind == "audio" else None,
)
sampling = OmniDiffusionSamplingParams(extra_args={"long_video": True})
with pytest.raises(OmniClientError, match="continuation does not support latent-mask editing"):
pipeline._prepare_encoder_conditioning_inputs(conditioning, sampling)
def test_external_encoder_cannot_bypass_full_duration_limit():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = SimpleNamespace(step_execution=False)
sampling = OmniDiffusionSamplingParams(extra_args={"long_video": True, "long_video_mode": "full"})
with pytest.raises(OmniClientError, match="30-second request limit"):
pipeline._prepare_encoder_conditioning_inputs(SimpleNamespace(task="ref2va", num_frames=7213), sampling)
@@ -233,7 +233,8 @@ def test_step_execution_matches_request_mode_with_latent_edits():
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], reference_audio)
def test_batched_step_execution_matches_independent_requests():
@pytest.mark.parametrize("lock_audio", [False, True])
def test_batched_step_execution_matches_independent_requests(lock_audio):
"""Two co-batched requests must land where they would have landed alone."""
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
from vllm_omni.diffusion.models.minimax_h3.latent_mask import MiniMaxH3LatentEdit
@@ -268,7 +269,10 @@ def test_batched_step_execution_matches_independent_requests():
audio_mask,
)
state.extra[mod._STEP_VIDEO_EDIT] = video_edit
state.extra[mod._STEP_AUDIO_EDIT] = audio_edit
if lock_audio and index == 0:
branch.locked_audio_rows = audio_rows.clone()
else:
state.extra[mod._STEP_AUDIO_EDIT] = audio_edit
return state
alone: list[tuple[torch.Tensor, torch.Tensor]] = []
@@ -551,3 +555,33 @@ 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_locked_driving_audio_is_clean_and_unchanged_during_denoising():
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
branch, video, audio = _make_branch(text_len=3, latent_t=2, latent_h=2, latent_w=2, audio_t=3, seed=7)
branch.locked_audio_rows = audio.clone()
seen = []
def model(**kwargs):
positions = kwargs["audio_pos_info"]["position_ids"]
times = kwargs["unique_timesteps"][kwargs["inverse_indices"]]
torch.testing.assert_close(times[positions], torch.ones_like(times[positions]))
torch.testing.assert_close(kwargs["audio_x"][0, positions], audio)
seen.append(True)
return torch.ones_like(video), torch.ones_like(audio)
result_video, result_audio = minimax_h3_denoise_loop(
model=model,
positive=branch,
initial_video_rows=video,
initial_audio_rows=audio,
keyframe_cond_rows=None,
sigmas_video=[1.0, 0.5, 0.0],
sigmas_audio=[1.0, 0.25, 0.0],
device=torch.device("cpu"),
)
assert len(seen) == 2
torch.testing.assert_close(result_audio, audio)
assert not torch.equal(result_video, video)
@@ -527,3 +527,65 @@ def test_encoder_releases_workspace_after_cpu_payload(monkeypatch) -> None:
assert empty_cache_calls == 1
assert output.multimodal_outputs is not None
assert output.multimodal_outputs["embed"]["embedding"][0].device.type == "cpu"
def test_driving_audio_is_not_a_short_reference_and_survives_stage_transport():
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.minimax_h3 import encoder_processing as processing
sampling = OmniDiffusionSamplingParams(
width=64,
height=64,
fps=24,
extra_args={
"aspect_ratio": "1:1",
"duration": 20,
"long_video": True,
"audio_mode": "lock_source",
},
)
prepared = processing.prepare_encoder_inputs(
{"prompt": "A singer", "multi_modal_data": {"audio": (torch.zeros(75 * 800), 800)}}, sampling
)
media = MiniMaxH3EncoderMediaInput.from_mm_tensors(prepared.media.to_mm_tensors(), prepared.media.to_metadata())
assert media.task == "t2va"
assert media.audio_mode == "lock_source"
assert prepared.condition_labels == []
class FakeAudioVAE:
def encode_waveform(self, waveform: torch.Tensor, sample_rate: int) -> tuple[torch.Tensor, int]:
return torch.ones(6000, 32), 3000
audio_vae = FakeAudioVAE()
conditioning = processing.encode_media(media, video_vae=None, audio_vae=audio_vae, emit_conditioning=True)
assert conditioning.audio_condition_lengths == (3000,)
assert conditioning.ref_blocks == ()
def test_driving_audio_does_not_override_fl2va_task_inference():
from PIL import Image
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.minimax_h3 import encoder_processing as processing
sampling = OmniDiffusionSamplingParams(
width=64,
height=64,
fps=24,
num_frames=124,
extra_args={"audio_mode": "lock_source"},
)
prepared = processing.prepare_encoder_inputs(
{
"prompt": "A singer",
"multi_modal_data": {
"image": Image.new("RGB", (256, 256)),
"audio": (torch.zeros(5 * 800), 800),
},
},
sampling,
)
assert prepared.media.task == "fl2va"
assert prepared.media.audio_mode == "lock_source"
assert prepared.condition_labels == [("image", 1)]
@@ -124,7 +124,8 @@ if HAS_TRITON:
eps: tl.constexpr,
heads_per_program: tl.constexpr,
):
token = tl.program_id(0)
# Long-video Q/K views can span more than 2**31 elements of fused QKV storage.
token = tl.program_id(0).to(tl.int64)
head_group = tl.program_id(1)
heads = head_group * heads_per_program + tl.arange(0, heads_per_program)
dims = tl.arange(0, head_dim)
@@ -216,7 +217,8 @@ if HAS_TRITON:
(partial rotary or lane padding) the output is the normalized value
unchanged.
"""
token = tl.program_id(0)
# Long-video Q/K views can span more than 2**31 elements of fused QKV storage.
token = tl.program_id(0).to(tl.int64)
head_group = tl.program_id(1)
dims = tl.arange(0, padded_dim)
dim_mask = dims < head_dim
@@ -0,0 +1,162 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Bounded H3 windows with synchronized latent-tail guides and global AV RoPE positions.
The guide/discard/append algorithm follows ComfyUI-Minimax-H3-Continuation:
https://github.com/ttulttul/ComfyUI-Minimax-H3-Continuation
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any
import torch
from vllm.logger import init_logger
from vllm_omni.errors import OmniClientError
from vllm_omni.model_executor.models.minimax_h3.long_video import resolve_long_video_mode
from .packed_tokens import minimax_h3_pack_audio_latent, minimax_h3_patchify_video_latent
logger = init_logger(__name__)
def _video_t(frames: int) -> int:
return (frames - 5) // 17 * 5 + 2
def _audio_t(frame_boundary: int) -> int:
return round(frame_boundary * 5 / 3)
@dataclass(frozen=True)
class ContinuationWindow:
start: int
end: int
overlap: int
@property
def audio_start(self) -> int:
return _audio_t(self.start)
@property
def audio_end(self) -> int:
return _audio_t(self.end)
@property
def overlap_audio_t(self) -> int:
return _audio_t(self.start + self.overlap) - self.audio_start
def resolve_continuation(extra: Mapping[str, Any], *, task: str, step_execution: bool) -> tuple[int, int] | None:
mode = resolve_long_video_mode(extra, task)
if mode == "full":
return None
if task != "ref2va" or step_execution:
raise OmniClientError("MiniMax H3 continuation requires Ref2VA request execution (not step execution)")
window = extra.get("continuation_window_frames", 277)
overlap = extra.get("continuation_overlap_frames", 22)
for name, value in (("window", window), ("overlap", overlap)):
if isinstance(value, bool) or not isinstance(value, int) or value < 5 or value % 17 != 5:
raise OmniClientError(f"MiniMax H3 continuation {name} must be an integer on the 17n+5 frame grid")
if not 107 <= window <= 345 or overlap >= window:
raise OmniClientError("MiniMax H3 continuation window must be 107..345 frames and exceed its overlap")
return window, overlap
def plan_continuation_windows(total_frames: int, window_frames: int, overlap_frames: int) -> list[ContinuationWindow]:
if total_frames < 5 or total_frames % 17 != 5:
raise ValueError("total_frames must satisfy 17n+5")
if not 5 <= overlap_frames < window_frames or window_frames % 17 != 5 or overlap_frames % 17 != 5:
raise ValueError("window and overlap must satisfy 17n+5, with 5 <= overlap < window")
end = min(total_frames, window_frames)
windows = [ContinuationWindow(0, end, 0)]
while end < total_frames:
start = end - overlap_frames
end = min(total_frames, start + window_frames)
windows.append(ContinuationWindow(start, end, overlap_frames))
return windows
def diffuse_continuation(
diffuse: Callable[..., tuple[torch.Tensor, torch.Tensor]],
kwargs: dict[str, Any],
*,
window_frames: int,
overlap_frames: int,
text_conditioning: Sequence[tuple[torch.Tensor, torch.Tensor]] | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Denoise fresh windows; retain old latents and append only new suffixes.
Guides are extra condition rows sharing the new target's temporal origin,
not a masked target prefix. Audio boundaries refer to the cumulative frame
timeline, preventing per-window rounding from accumulating A/V drift.
Each window shifts temporal media positions onto that same global timeline
before RoPE is evaluated; text and static image references remain fixed.
"""
windows = plan_continuation_windows(kwargs["num_frames"], window_frames, overlap_frames)
if text_conditioning is not None and len(text_conditioning) != len(windows):
raise OmniClientError("MiniMax H3 requires one text conditioning per continuation window")
# Different prompts have different prefix lengths. Keep the media clock
# anchored after the longest prefix so text length cannot shift AV time.
media_time_origin = max(item[0].shape[0] for item in text_conditioning) if text_conditioning else None
source_rows = kwargs.get("locked_audio_rows")
source = None if source_rows is None else source_rows.reshape(2, kwargs["audio_t"], 32)
video = audio = None
for index, window in enumerate(windows):
frames = window.end - window.start
args = {
**kwargs,
"num_frames": frames,
"latent_t": _video_t(frames),
"audio_t": window.audio_end - window.audio_start,
# Keep fractional RoPE units; audio slice indices alone are rounded.
"temporal_offset": window.start * (40.0 / 24.0),
}
if text_conditioning is not None:
args["text_embeddings"], args["text_tags"] = text_conditioning[index]
args["media_time_origin"] = media_time_origin
if source is not None:
args["locked_audio_rows"] = source[:, window.audio_start : window.audio_end].reshape(-1, 32)
overlap_v = _video_t(window.overlap) if window.overlap else 0
if video is not None and audio is not None:
guide_video = minimax_h3_patchify_video_latent(video[:, :, -overlap_v:], patch_size=(1, 2, 2))
guide_audio = minimax_h3_pack_audio_latent(audio[..., -window.overlap_audio_t :])
for name, guide in (("visual_condition", guide_video), ("audio_condition", guide_audio)):
existing = kwargs.get(name)
args[name] = guide if existing is None else torch.cat((existing, guide), dim=0)
args["visual_condition_shapes"] = [
*(kwargs.get("visual_condition_shapes") or []),
(overlap_v, kwargs["latent_h"], kwargs["latent_w"]),
]
args["audio_condition_lengths"] = [*(kwargs.get("audio_condition_lengths") or []), window.overlap_audio_t]
args["ref_blocks"] = [
*(kwargs.get("ref_blocks") or []),
{
"kind": "latent_guide",
"latent_t": overlap_v,
"latent_h": kwargs["latent_h"],
"latent_w": kwargs["latent_w"],
"ref_audio_t": window.overlap_audio_t,
},
]
logger.info(
"MiniMax H3 continuation window %d/%d: frames [%d, %d), hidden overlap %d, temporal offset %.6f",
index + 1,
len(windows),
window.start,
window.end,
window.overlap,
args["temporal_offset"],
)
sampled_video, sampled_audio = diffuse(**args)
video = sampled_video if video is None else torch.cat((video, sampled_video[:, :, overlap_v:]), dim=2)
audio = (
sampled_audio if audio is None else torch.cat((audio, sampled_audio[..., window.overlap_audio_t :]), dim=-1)
)
assert video is not None and audio is not None
if video.shape[2] != kwargs["latent_t"] or audio.shape[-1] != kwargs["audio_t"]:
raise RuntimeError("MiniMax H3 continuation produced inconsistent cumulative AV lengths")
return video, audio
@@ -87,6 +87,7 @@ class MiniMaxH3DenoiseBranch:
raise ValueError(f"token_tags length {int(token_tags.view(-1).shape[0])} != seq_len {seq_len}")
cu = packed["cu_seqlens"].to(torch.int32)
self.device = device
self.locked_audio_rows: torch.Tensor | None = None
# ``used_len`` is the real (non-padding) document length; step-mode
# batching concatenates layouts and needs both bounds as Python ints so
# it can rebuild ``cu_seqlens`` without a device sync per step.
@@ -245,7 +246,7 @@ class MiniMaxH3DenoiseBranch:
self.audio_pos_dev,
self.audio_update_mask_dev,
audio_target_timesteps,
t_audio,
t_audio if self.locked_audio_rows is None else 1.0,
audio_ref_cond_timestep,
),
)
@@ -444,7 +445,7 @@ def minimax_h3_denoise_loop(
audio_rows[audio_update], x0_audio, sigma_curr=s_a, sigma_next=s_a_next
)
audio_rows = audio_rows.clone()
audio_rows[audio_update] = new_audio
audio_rows[audio_update] = new_audio if positive.locked_audio_rows is None else positive.locked_audio_rows
if audio_anchor is not None:
audio_rows[~audio_update] = audio_anchor # per-step audio ref reset
if on_step is not None:
@@ -14,6 +14,7 @@ Builder rules:
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
import numpy as np
@@ -345,6 +346,8 @@ def minimax_h3_packed_sequence_ref2va_blocks(
ref_blocks: Sequence[Mapping[str, object]],
audio_channel: int = 2,
seq_len: int | None = None,
temporal_offset: float = 0.0,
media_time_origin: int | None = None,
) -> dict[str, object]:
"""General ref2va-family packed layout.
@@ -353,12 +356,22 @@ def minimax_h3_packed_sequence_ref2va_blocks(
- ``{"kind": "audio", "ref_audio_t": T}``
- ``{"kind": "video"|"video_audio", "ref_audio_t": T,
"latent_t": RT, "latent_h": RH, "latent_w": RW}``
- Internal ``latent_guide``: a final AV condition block on the target's
spatial grid and temporal origin, without advancing the reference clock.
Video-bearing blocks pack their audio rows immediately before their video
rows; both share the same temporal origin and advance by the longer of the
audio and video spans. Standalone audio advances the target origin by its
own T, and image blocks advance it by one integer slot.
``temporal_offset`` is the window origin in 40-Hz RoPE units. It shifts
target AV, reference AV and latent guides together, leaving text, static
images, padding and all spatial coordinates unchanged.
"""
if not math.isfinite(temporal_offset) or temporal_offset < 0:
raise ValueError("temporal_offset must be finite and non-negative")
if media_time_origin is not None and media_time_origin < text_len:
raise ValueError("media_time_origin must follow the text prefix")
if not isinstance(ref_blocks, Sequence) or isinstance(ref_blocks, (str, bytes)):
raise ValueError("ref_blocks must be a sequence")
@@ -383,11 +396,15 @@ def minimax_h3_packed_sequence_ref2va_blocks(
rows = rt * audio_channel
item = {"kind": kind, "ref_audio_t": rt, "audio_rows": rows}
ref_audio_rows += rows
elif kind in ("video", "video_audio"):
elif kind in ("video", "video_audio", "latent_guide"):
rt = _positive_int(raw, "ref_audio_t", path, allow_zero=True)
vt = _positive_int(raw, "latent_t", path)
vh = _positive_int(raw, "latent_h", path)
vw = _positive_int(raw, "latent_w", path)
if kind == "latent_guide" and (
index != len(ref_blocks) - 1 or (vh, vw) != (latent_h, latent_w) or vt > latent_t or rt > audio_t
):
raise ValueError("latent_guide must be last, match the target canvas, and fit its AV lengths")
frame_rows = (vh // _PATCH_H) * (vw // _PATCH_W)
audio_rows = rt * audio_channel
video_rows = vt * frame_rows
@@ -447,7 +464,7 @@ def minimax_h3_packed_sequence_ref2va_blocks(
# device-to-host synchronization.
video_spans: list[dict[str, object]] = []
for item in block_slices:
if item["kind"] in ("video", "video_audio"):
if item["kind"] in ("video", "video_audio", "latent_guide"):
visual_sl = item["visual_sl"]
assert isinstance(visual_sl, slice)
video_spans.append(
@@ -485,7 +502,7 @@ def minimax_h3_packed_sequence_ref2va_blocks(
hh, ww = torch.meshgrid(h_grid, w_grid, indexing="ij")
target_frame = torch.stack([hh.reshape(-1), ww.reshape(-1)], dim=-1)
t_cursor = float(text_len)
t_cursor = float(text_len if media_time_origin is None else media_time_origin)
for item in block_slices:
kind = str(item["kind"])
if kind == "image":
@@ -567,7 +584,8 @@ def minimax_h3_packed_sequence_ref2va_blocks(
rv_g[:, :, 0] = _video_t_grid(vt, t_cursor)[:, None]
rv_g[:, :, 1:] = rv_frame[None]
g[visual_sl] = rv_g.reshape(-1, 3)
t_cursor += max(float(ref_t), _video_t_span(vt))
if kind != "latent_guide":
t_cursor += max(float(ref_t), _video_t_span(vt))
input_ids[audio_sl] = MINIMAX_H3_AUDIO_ID
input_ids[audio_sl.start] = MINIMAX_H3_AUDIO_FIRST_ID
@@ -591,6 +609,13 @@ def minimax_h3_packed_sequence_ref2va_blocks(
video_g[:, :, 1:] = target_frame[None]
g[video_sl] = video_g.reshape(-1, 3)
if temporal_offset:
g[audio_mask, 0] += temporal_offset
g[video_sl, 0] += temporal_offset
for item in block_slices:
if item["kind"] in ("video", "video_audio", "latent_guide"):
g[item["visual_sl"], 0] += temporal_offset
target_img_pos = _range_for_slice(video_sl)
target_audio_pos = _range_for_slice(audio_sl)
img_pos = _cat_ranges(ref_img_pos_parts + [target_img_pos])
@@ -81,6 +81,7 @@ from vllm_omni.model_executor.models.minimax_h3.encoder_processing import (
encode_media,
prepare_encoder_inputs,
)
from vllm_omni.model_executor.models.minimax_h3.long_video import validate_encoded_frame_limit
from vllm_omni.model_executor.models.minimax_h3.preprocessing import build_minimax_h3_presentation
from vllm_omni.model_executor.models.minimax_h3.reference_video import (
MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY,
@@ -99,6 +100,7 @@ from .condition_noise import (
minimax_h3_audio_cond_noise_aug_rows,
minimax_h3_imgvid_cond_noise_aug_rows,
)
from .continuation import diffuse_continuation, plan_continuation_windows, resolve_continuation
from .denoise_loop import (
MiniMaxH3DenoiseBranch,
minimax_h3_denoise_loop,
@@ -267,6 +269,7 @@ _MINIMAX_H3_DENOISE_INPUT_KEYS = (
"audio_condition_lengths",
"keyframe_frame_indices",
"pad_seq_len",
"locked_audio_rows",
"video_edit_clean_rows",
"video_edit_mask_rows",
"video_edit_restore_mask_rows",
@@ -1177,6 +1180,7 @@ class MiniMaxH3Pipeline(
requested: str | None,
multi_modal_data: dict[str, Any] | None = None,
*,
audio_mode: str = "native",
turbo_spec: TurboSpec | None = None,
has_native_lora: bool = False,
) -> str:
@@ -1186,7 +1190,9 @@ class MiniMaxH3Pipeline(
# historical implicit default even for image-only references.
if self.partition == "ref2va":
requested = "ref2va"
elif multi_modal_data.get("video") is not None or multi_modal_data.get("audio") is not None:
elif multi_modal_data.get("video") is not None or (
multi_modal_data.get("audio") is not None and audio_mode != "lock_source"
):
requested = "ref2va"
elif multi_modal_data.get("image") is not None:
requested = "fl2va"
@@ -1567,6 +1573,9 @@ class MiniMaxH3Pipeline(
audio_condition_lengths: list[int] | None = None,
keyframe_frame_indices: list[int] | None = None,
pad_seq_len: int | None = None,
locked_audio_rows: torch.Tensor | None = None,
temporal_offset: float = 0.0,
media_time_origin: int | None = None,
video_edit_clean_rows: torch.Tensor | None = None,
video_edit_mask_rows: torch.Tensor | None = None,
video_edit_restore_mask_rows: torch.Tensor | None = None,
@@ -1603,6 +1612,8 @@ class MiniMaxH3Pipeline(
audio_t=audio_t,
ref_blocks=ref_blocks,
seq_len=pad_seq_len,
temporal_offset=temporal_offset,
media_time_origin=media_time_origin,
)
else:
packed = minimax_h3_packed_sequence(
@@ -1660,6 +1671,13 @@ class MiniMaxH3Pipeline(
full_video[branch.update_mask] = initial_video
initial_video = full_video
if locked_audio_rows is not None:
expected = (2 * audio_t, 32)
if tuple(locked_audio_rows.shape) != expected:
raise OmniClientError(f"MiniMax H3 driving audio rows must have shape {expected}")
branch.locked_audio_rows = locked_audio_rows.to(device=self.device, dtype=torch.float32)
initial_audio = branch.locked_audio_rows.cpu().clone()
audio_anchor = audio_condition
if audio_anchor is not None:
condition_audio_t = audio_condition_lengths
@@ -1800,6 +1818,9 @@ class MiniMaxH3Pipeline(
audio_condition_lengths: list[int] | None = None,
keyframe_frame_indices: list[int] | None = None,
pad_seq_len: int | None = None,
locked_audio_rows: torch.Tensor | None = None,
temporal_offset: float = 0.0,
media_time_origin: int | None = None,
video_edit_clean_rows: torch.Tensor | None = None,
video_edit_mask_rows: torch.Tensor | None = None,
video_edit_restore_mask_rows: torch.Tensor | None = None,
@@ -1830,6 +1851,9 @@ class MiniMaxH3Pipeline(
audio_condition_lengths=audio_condition_lengths,
keyframe_frame_indices=keyframe_frame_indices,
pad_seq_len=pad_seq_len,
locked_audio_rows=locked_audio_rows,
temporal_offset=temporal_offset,
media_time_origin=media_time_origin,
video_edit_clean_rows=video_edit_clean_rows,
video_edit_mask_rows=video_edit_mask_rows,
video_edit_restore_mask_rows=video_edit_restore_mask_rows,
@@ -2160,8 +2184,14 @@ class MiniMaxH3Pipeline(
sampling: Any,
*,
require_external_text: bool = False,
) -> MiniMaxH3EncoderConditioning:
) -> tuple[MiniMaxH3EncoderConditioning, list[tuple[torch.Tensor, torch.Tensor]] | None]:
"""Prepare media once and encode either shared text or each window's text.
Returns initial conditioning and optional request-owned window embeddings.
Invalid continuation prompts are broadcast before any encoder collective.
"""
group, rank, world_size = _dit_rank_world()
prompts = (sampling.extra_args or {}).get("continuation_prompts")
prepared = text_conditioning = None
error = None
if rank == 0:
@@ -2172,6 +2202,7 @@ class MiniMaxH3Pipeline(
task = self._resolve_task(
(sampling.extra_args or {}).get("task"),
multi_modal_data,
audio_mode=(sampling.extra_args or {}).get("audio_mode", "native"),
turbo_spec=turbo_spec,
has_native_lora=has_native_lora,
)
@@ -2190,6 +2221,27 @@ class MiniMaxH3Pipeline(
task=task,
prepared_reference_videos=self._extract_prepared_reference_videos(raw_prompt),
)
if prompts is not None:
continuation = resolve_continuation(
sampling.extra_args or {},
task=task,
step_execution=bool(getattr(self.od_config, "step_execution", False)),
)
if continuation is None or not self.load_text_encoder:
raise OmniClientError(
"continuation_prompts requires continuation mode with a local text encoder"
)
window, overlap = continuation
count = len(plan_continuation_windows(prepared.media.num_frames, window, overlap))
if (
not isinstance(prompts, list)
or len(prompts) != count
or any(not isinstance(prompt, str) or not prompt.strip() for prompt in prompts)
):
raise OmniClientError(f"continuation_prompts must contain exactly {count} non-empty strings")
prepared = replace(prepared, prompt=prompts[0])
# External text belongs to the main prompt, not the first window.
text_conditioning = None
except Exception as exc:
error = exc
_broadcast_rank0_exception(error)
@@ -2207,21 +2259,33 @@ class MiniMaxH3Pipeline(
)
else:
hidden, tags = self.encode_prompt(prepared)
window_text = None
if prompts is not None:
window_text = [(hidden, tags)]
for index, prompt in enumerate(prompts[1:], start=2):
logger.info("MiniMax H3 encoding continuation prompt %d/%d", index, len(prompts))
window_text.append(self.encode_prompt(replace(prepared, prompt=prompt) if rank == 0 else None))
media = self._encode_local_media(prepared.media if prepared is not None else None)
return MiniMaxH3EncoderConditioning.from_components(MiniMaxH3TextConditioning(hidden, tags), media)
return MiniMaxH3EncoderConditioning.from_components(MiniMaxH3TextConditioning(hidden, tags), media), window_text
def _prepare_request_inputs(self, raw_prompt: Any, sampling: Any) -> dict[str, Any]:
if self.load_text_encoder:
conditioning = self._prepare_local_conditioning(raw_prompt, sampling)
elif self.load_vae_encoder:
conditioning = self._prepare_local_conditioning(
if (getattr(sampling, "extra_args", None) or {}).get(
"continuation_prompts"
) is not None and not self.load_text_encoder:
raise OmniClientError("continuation_prompts requires continuation mode with a local text encoder")
window_text = None
if self.load_text_encoder or self.load_vae_encoder:
conditioning, window_text = self._prepare_local_conditioning(
raw_prompt,
sampling,
require_external_text=True,
require_external_text=not self.load_text_encoder,
)
else:
conditioning = self._extract_encoder_conditioning(raw_prompt)
return self._prepare_encoder_conditioning_inputs(conditioning, sampling)
context = self._prepare_encoder_conditioning_inputs(conditioning, sampling)
if window_text is not None:
context["continuation_text_conditioning"] = window_text
return context
@staticmethod
def _extract_encoder_conditioning(prompt: Any) -> MiniMaxH3EncoderConditioning:
@@ -2242,6 +2306,18 @@ class MiniMaxH3Pipeline(
sampling: Any,
) -> dict[str, Any]:
extra = sampling.extra_args or {}
continuation = resolve_continuation(
extra, task=conditioning.task, step_execution=bool(getattr(self.od_config, "step_execution", False))
)
validate_encoded_frame_limit(extra, conditioning.task, conditioning.num_frames)
if continuation is not None and (
conditioning.video_edit_clean_rows is not None or conditioning.audio_edit_clean_rows is not None
):
raise OmniClientError(
"MiniMax H3 continuation does not support latent-mask editing; use long_video_mode=full"
)
if extra.get("audio_mode") == "lock_source" and conditioning.audio_edit_clean_rows is not None:
raise OmniClientError("MiniMax H3 audio_mode=lock_source cannot be combined with audio latent-mask editing")
requested_task = extra.get("task")
if requested_task is not None and str(requested_task).lower() != conditioning.task:
raise OmniClientError(
@@ -2285,6 +2361,22 @@ class MiniMaxH3Pipeline(
visual_shapes = list(conditioning.visual_condition_shapes) or None
audio_lengths = list(conditioning.audio_condition_lengths) or None
audio_condition = conditioning.audio_condition
locked_audio_rows = None
if extra.get("audio_mode", "native") == "lock_source":
if audio_condition is None or not audio_lengths:
raise OmniClientError("MiniMax H3 lock_source requires encoded driving audio")
drive_t = audio_lengths[-1]
drive = audio_condition[-2 * drive_t :].reshape(2, drive_t, 32)
# Pad/crop each stereo channel independently; flattening first would
# shift the right channel when the source and target lengths differ.
fitted = drive.new_zeros((2, conditioning.audio_t, 32))
count = min(drive_t, conditioning.audio_t)
fitted[:, :count] = drive[:, :count]
locked_audio_rows = fitted.reshape(-1, 32).to(device=self.device)
audio_condition = audio_condition[: -2 * drive_t]
audio_condition = audio_condition if audio_condition.numel() else None
audio_lengths = audio_lengths[:-1] or None
latent_edit: dict[str, torch.Tensor | None] = {
"video_edit_clean_rows": None,
@@ -2336,8 +2428,11 @@ class MiniMaxH3Pipeline(
num_inference_steps=num_steps,
extra_args=extra,
)
if continuation is not None and quality_plan.cache_dit is not None:
raise OmniClientError("MiniMax H3 continuation requires uncached denoising; set quality=lossless")
self._cache_dit_runtime.prepare(quality_plan.cache_dit)
return {
"continuation": continuation,
"task": task,
"height": conditioning.height,
"width": conditioning.width,
@@ -2354,15 +2449,12 @@ class MiniMaxH3Pipeline(
else None
),
"visual_condition_shape": visual_shapes[0] if visual_shapes and len(visual_shapes) == 1 else None,
"audio_condition": (
conditioning.audio_condition.to(device=self.device)
if conditioning.audio_condition is not None
else None
),
"audio_condition": (audio_condition.to(device=self.device) if audio_condition is not None else None),
"ref_audio_t": audio_lengths[0] if audio_lengths and len(audio_lengths) == 1 else None,
"ref_blocks": list(conditioning.ref_blocks) or None,
"visual_condition_shapes": visual_shapes,
"audio_condition_lengths": audio_lengths,
"locked_audio_rows": locked_audio_rows,
"keyframe_frame_indices": list(conditioning.keyframe_frame_indices) or None,
"pad_seq_len": _resolve_pad_seq_len(extra.get("pad_seq_len")),
"seed": int(sampling.seed if sampling.seed is not None else 42),
@@ -2403,7 +2495,18 @@ class MiniMaxH3Pipeline(
videos = []
audios = []
for output_seed in _minimax_h3_output_seeds(context["seed"], num_outputs):
video_latent, audio_latent = self.diffuse(**{**denoise_kwargs, "seed": output_seed})
output_kwargs = {**denoise_kwargs, "seed": output_seed}
if context.get("continuation") is None:
video_latent, audio_latent = self.diffuse(**output_kwargs)
else:
window_frames, overlap_frames = context["continuation"]
video_latent, audio_latent = diffuse_continuation(
self.diffuse,
output_kwargs,
window_frames=window_frames,
overlap_frames=overlap_frames,
text_conditioning=context.get("continuation_text_conditioning"),
)
if context["preencode_mp4"]:
videos.append(
self.decode_to_mp4(
@@ -2765,7 +2868,7 @@ class MiniMaxH3Pipeline(
sigma_next=schedule["sigma_audio_next"],
)
audio_rows = audio_rows.clone()
audio_rows[audio_update] = new_audio
audio_rows[audio_update] = new_audio if branch.locked_audio_rows is None else branch.locked_audio_rows
if audio_anchor is not None:
audio_rows[~audio_update] = audio_anchor # per-step audio ref reset
@@ -358,6 +358,7 @@ class MiniMaxH3EncoderMediaInput:
video_audios: tuple[tuple[torch.Tensor, int] | None, ...] = ()
audios: tuple[tuple[torch.Tensor, int], ...] = ()
keyframe_frame_indices: tuple[int, ...] = ()
audio_mode: str = "native"
video_edit: torch.Tensor | None = None
video_edit_mask: torch.Tensor | None = None
audio_edit: tuple[torch.Tensor, int] | None = None
@@ -468,6 +469,7 @@ class MiniMaxH3EncoderMediaInput:
if cursor != len(tensors):
raise ValueError(f"MiniMax H3 encoder media input has {len(tensors) - cursor} trailing tensors")
return cls(
audio_mode=str(metadata.get("audio_mode", "native")),
task=task,
height=height,
width=width,
@@ -541,6 +543,7 @@ class MiniMaxH3EncoderMediaInput:
def to_metadata(self) -> dict[str, Any]:
metadata = {
"audio_mode": self.audio_mode,
"task": self.task,
"height": self.height,
"width": self.width,
@@ -22,6 +22,7 @@ from vllm_omni.model_executor.models.minimax_h3.conditioning import (
MiniMaxH3EncoderMediaConditioning,
MiniMaxH3EncoderMediaInput,
)
from vllm_omni.model_executor.models.minimax_h3.long_video import max_output_seconds, resolve_long_video_mode
from vllm_omni.model_executor.models.minimax_h3.preprocessing import (
MINIMAX_H3_OUTPUT_SHORT_EDGE,
load_minimax_h3_images,
@@ -160,28 +161,25 @@ def resolve_minimax_h3_shape(
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 {}
max_seconds = max_output_seconds(extra, task)
duration_range = f"in [4, {max_seconds:g}] seconds"
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}")
raise OmniClientError(f"MiniMax H3 output duration must be {duration_range}, 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}")
raise OmniClientError(f"MiniMax H3 output duration must be {duration_range}, got {duration!r}") from exc
if not math.isfinite(duration) or not MINIMAX_H3_MIN_OUTPUT_SECONDS <= duration <= max_seconds:
raise OmniClientError(f"MiniMax H3 output duration must be {duration_range}, 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}"
)
if not MINIMAX_H3_MIN_OUTPUT_SECONDS <= requested_frames / fps <= max_seconds:
raise OmniClientError(f"MiniMax H3 output duration must be {duration_range}, got {requested_frames / fps:.3f}")
num_frames = minimax_h3_align_frame_count(requested_frames)
height = getattr(sampling, "height", None)
@@ -220,7 +218,10 @@ def _resolve_task(
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:
has_reference_audio = (
multi_modal_data.get("audio") is not None and extra_args.get("audio_mode", "native") != "lock_source"
)
if multi_modal_data.get("video") is not None or has_reference_audio:
return "ref2va"
if multi_modal_data.get("image") is not None:
return "fl2va"
@@ -428,14 +429,20 @@ def prepare_encoder_inputs(
audio_values = _audio_items(raw_audio)
diffusion_sampling_params = sampling
extra_args = getattr(diffusion_sampling_params, "extra_args", None) or {}
audio_mode = extra_args.get("audio_mode", "native")
if audio_mode not in ("native", "lock_source"):
raise OmniClientError("MiniMax H3 audio_mode must be native or lock_source")
lock_audio = audio_mode == "lock_source"
if lock_audio and len(audio_values) != 1:
raise OmniClientError("MiniMax H3 lock_source requires exactly one driving audio")
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:
if raw_images or videos or (audio_values and not lock_audio):
raise OmniClientError("t2va does not accept image, video, or audio conditions")
elif task == "fl2va":
if not raw_images or videos or audio_values:
if not raw_images or videos or (audio_values and not lock_audio):
raise OmniClientError("fl2va requires image conditions only")
if len(raw_images) > 2:
raise OmniClientError("fl2va accepts at most first and last images")
@@ -479,6 +486,12 @@ def prepare_encoder_inputs(
video_edit_mask = None
if audio_edit_mask is not None and bool(torch.all(audio_edit_mask == 1.0).item()):
audio_edit_mask = None
if resolve_long_video_mode(extra_args, task) == "continuation" and (
video_edit_mask is not None or audio_edit_mask is not None
):
raise OmniClientError("MiniMax H3 continuation does not support latent-mask editing; use long_video_mode=full")
if lock_audio and audio_edit_mask is not None:
raise OmniClientError("MiniMax H3 audio_mode=lock_source cannot be combined with audio latent-mask editing")
if video_edit_mask is not None and source_video is None:
raise OmniClientError("MiniMax H3 video_noise_mask requires source_video")
if audio_edit_mask is not None and source_audio is None and source_video is None:
@@ -555,7 +568,7 @@ def prepare_encoder_inputs(
audio_index += 1
condition_labels.append(("audio", audio_index))
condition_labels.append(("video", video_index))
for _ in audio_values:
for _ in [] if lock_audio else audio_values:
audio_index += 1
condition_labels.append(("audio", audio_index))
@@ -601,10 +614,15 @@ def prepare_encoder_inputs(
raise OmniClientError("MiniMax H3 edit audio must not be empty")
audio_edit = (waveform, int(sample_rate))
if raw_audio is not None:
if raw_audio is not None and not lock_audio:
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)
if lock_audio:
waveform, rate = standalone_audios[0]
if rate <= 0 or waveform.ndim not in (1, 2) or waveform.numel() == 0 or not torch.isfinite(waveform).all():
raise OmniClientError("MiniMax H3 driving audio must be a finite non-empty waveform with a positive rate")
else:
validate_reference_audio_waveforms(standalone_audios)
media_input = MiniMaxH3EncoderMediaInput(
task=task,
height=height,
@@ -617,6 +635,7 @@ def prepare_encoder_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_mode=audio_mode,
video_edit=video_edit,
video_edit_mask=video_edit_mask,
audio_edit=audio_edit,
@@ -630,7 +649,8 @@ def prepare_encoder_inputs(
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:])
if not lock_audio:
validate_reference_audio_waveforms(audio_inputs[embedded_audio_count:])
return PreparedEncoderInputs(
prompt=text,
@@ -723,10 +743,11 @@ def encode_media(
int(source_audio_t),
target_audio_t=media.audio_t,
)
if audio_lengths:
if any(length < 80 or length > 600 for length in audio_lengths):
reference_lengths = audio_lengths[:-1] if media.audio_mode == "lock_source" else audio_lengths
if reference_lengths:
if any(length < 80 or length > 600 for length in reference_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:
if sum(reference_lengths[:embedded_audio_count]) > 600 or sum(reference_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]] = []
@@ -750,7 +771,7 @@ def encode_media(
"kind": "audio",
"ref_audio_t": int(length),
}
for length in audio_lengths[embedded_audio_count:]
for length in reference_lengths[embedded_audio_count:]
)
video_edit_mask = None
if video_edit_clean_rows is not None:
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Shared long-video request limits for encoder and diffusion entry points."""
from collections.abc import Mapping
from typing import Any
from vllm_omni.errors import OmniClientError
MINIMAX_H3_MAX_FULL_SECONDS = 30.0
MINIMAX_H3_MAX_CONTINUATION_SECONDS = 300.0
def resolve_long_video_mode(extra: Mapping[str, Any], task: str) -> str:
long_video = extra.get("long_video", False)
if not isinstance(long_video, bool):
raise OmniClientError("MiniMax H3 long_video must be a boolean")
mode = extra.get("long_video_mode", "continuation" if long_video and task == "ref2va" else "full")
if mode not in ("full", "continuation"):
raise OmniClientError("MiniMax H3 long_video_mode must be full or continuation")
if mode == "continuation" and task != "ref2va":
raise OmniClientError("MiniMax H3 continuation requires Ref2VA request execution (not step execution)")
return mode
def max_output_seconds(extra: Mapping[str, Any], task: str) -> float:
mode = resolve_long_video_mode(extra, task)
if not extra.get("long_video", False):
return 15.0
return MINIMAX_H3_MAX_CONTINUATION_SECONDS if mode == "continuation" else MINIMAX_H3_MAX_FULL_SECONDS
def validate_encoded_frame_limit(extra: Mapping[str, Any], task: str, num_frames: int) -> None:
"""Apply the same cap to externally encoded requests, allowing grid rounding."""
seconds = max_output_seconds(extra, task)
requested_max = int(seconds * 24)
aligned_max = ((requested_max - 5 + 16) // 17) * 17 + 5
if num_frames > aligned_max:
raise OmniClientError(
f"MiniMax H3 output duration exceeds the {seconds:g}-second request limit "
f"({aligned_max} frames after alignment); got {num_frames} frames"
)