[Model] Add MiniMax-H3 long-video latent continuation with driving audio (#7838)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
This commit is contained in:
17 files changed
+1089
-50
No files matched your search
@@ -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"
|
||||
)
|
||||
Reference in new issue
Block a user