[Model] Add MiniMax-H3 latent super-resolution and hi-res refinement (#8322)

Signed-off-by: princepride <wangzhipeng628@gmail.com>
This commit is contained in:
汪志鹏 authored and GitHub committed 2026-10-01 14:24:40 +08:00
1 parent 27a6321a63
commit a038b38179
10 files changed
+1436 -34

No files matched your search

@@ -422,6 +422,7 @@ source_file_dependencies:
diffusion_minimax_h3_function:
- *minimax_h3
- tests/helpers/assertions.py
- tests/e2e/online_serving/test_minimax_h3_dlo_dp2_t2va.py
- tests/e2e/online_serving/minimax_h3/
- tests/diffusion/models/minimax_h3/
+11
View File
@@ -208,3 +208,14 @@ steps:
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_turbo_lora.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3_fasth3_lora.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
mirror_hardwares: h100_2
- label: "Diffusion · MiniMax-H3 Latent Upscale and Refine"
source_file_dependencies: diffusion_minimax_h3_function
timeout_in_minutes: 40
commands:
- |
set -eu
VLLM_TEST_MINIMAX_H3_LATENT_UPSCALER=$(python3 -c 'from huggingface_hub import hf_hub_download; print(hf_hub_download(repo_id="LBH-123-AI/Minimax_h3_latent_Upscaler", filename="minimax_h3_latent_upscaler_3d_conv_v1/minimax_h3_latent_upscaler_3d_conv_v1_bf16.safetensors"))')
export VLLM_TEST_MINIMAX_H3_LATENT_UPSCALER
timeout 35m pytest -s -v tests/e2e/online_serving/minimax_h3/test_minimax_h3.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
mirror_hardwares: h100_2
+175
View File
@@ -1515,9 +1515,184 @@ vllm serve "${MODEL_ROOT}/FL2VA" \
--cache-config '{"rel_l1_thresh":0.17}'
```
## Latent super-resolution
H3's video VAE is a ~5B-parameter model, so the usual way to raise output
resolution -- decode, upscale in pixel space, re-encode -- pays for that VAE
twice. The community
[Minimax H3 latent upscaler](https://huggingface.co/LBH-123-AI/Minimax_h3_latent_Upscaler)
is a learned 3D-convolutional resizer trained on H3's 24-channel latents: it
runs between the denoise loop and the VAE, so the clip is decoded once, at the
larger size. Unlike bilinear or bicubic interpolation of a latent, it does not
introduce ghosting or double-image artifacts.
The network runs one normalization below the pipeline latent: an H3 latent in
vLLM-Omni is already normalized, and the upscaler was trained on that tensor
normalized again by the VAE's `latents_mean` / `latents_std`. The stage applies
and undoes that itself, so nothing about a request changes -- but a port that
skips it inflates the latent about 5x and decodes as a magenta grid at one tile
per latent cell.
The checkpoint is optional and is not part of the H3 release. Download one file
from that repository (`minimax_h3_latent_upscaler_3d_bf16.safetensors`, 691 MB)
and name it at startup:
```bash
vllm serve "${MODEL_ROOT}/FL2VA" \
--omni \
--trust-remote-code \
--additional-config '{"latent_upscaler_path":"/models/minimax_h3_latent_upscaler_3d_bf16.safetensors"}'
```
A request then asks for a size. The three modes are mutually exclusive and all
resolve to the same 16-pixel latent grid, so the three requests below are the
same 960x544 -> 1920x1088 upscale:
```bash
curl -sS -X POST "${API_URL}" \
-F 'prompt=...' \
-F 'width=960' -F 'height=544' -F 'fps=24' \
-F 'extra_params={"task":"t2va","duration":8.7,"latent_upscale":2.0}' \
-o t2va_1080p.mp4
# or name the output size in pixels
# "latent_upscale":{"width":1920,"height":1088}
# or name a pixel budget and keep the aspect ratio
# "latent_upscale":{"megapixels":2.0}
```
Over HTTP the field is `extra_params`: the videos request model declares that
name and nothing else, so an `extra_args` form field is dropped without an
error and the request silently returns the un-upscaled size. Offline, the same
value goes in `extra_args`:
```python
omni = Omni(
model=os.path.join(os.environ["MODEL_ROOT"], "FL2VA"),
trust_remote_code=True,
additional_config={"latent_upscaler_path": "/models/minimax_h3_latent_upscaler_3d_bf16.safetensors"},
)
outputs = omni.generate(
"A quiet cinematic night scene with matching ambient sound.",
OmniDiffusionSamplingParams(
height=544,
width=960,
fps=24,
num_inference_steps=50,
seed=42,
extra_args={"task": "t2va", "duration": 8.7, "latent_upscale": 2.0},
),
)
```
### The hi-res route: generate small, refine large
Upscaling alone decodes the enlarged latent directly, which is fast but can
only interpolate detail the first pass never generated. Adding `latent_refine`
resumes the denoise schedule at the new size, so the DiT puts real detail back:
1. generate at a cheap size -- far fewer DiT tokens,
2. upscale the latent,
3. re-noise it and run the tail of the schedule at the target size.
```bash
curl -sS -X POST "${API_URL}" \
-F 'prompt=...' \
-F 'width=960' -F 'height=544' -F 'fps=24' \
-F 'num_inference_steps=50' \
-F 'extra_params={"task":"t2va","duration":8.7,"latent_upscale":2.0,"latent_refine":0.4}' \
-o t2va_1080p.mp4
```
`latent_refine` is the fraction of the request's steps the second pass re-runs,
the img2img convention, so it sets both how much the pass may change and what
it costs: `0.4` of `num_inference_steps=50` is 50 cheap steps followed by 20
expensive ones. Low values stay close to the first pass and mostly sharpen;
high values re-generate and may drift from it. `1.0` re-runs the whole schedule
from noise, which discards the first pass entirely.
Detail does not keep climbing with strength. Measured on a 15s 2688x1536 clip,
six frames apart, against the same seed and prompt: 0.5 returned 0.98x the
high-frequency energy of 0.35 and 0.65 returned 0.92x, while the mean per-pixel
drift from the 0.35 result rose from 6.3 to 12.6 (0-255). What caps detail is
what the DiT can resolve at the target size, not how noisy a start it is given;
past that, a higher strength only erases more of the first pass and re-imagines
it from less. On this checkpoint 0.35 was the best of the three -- treat higher
values as "re-generate", not as "sharpen".
Why this is faster than generating at the target size: the DiT cost follows the
video token count, which is quadratic in the latent area for attention. The
example above is 31,620 video tokens at 960x544 against 126,480 at 1920x1088 --
4x the tokens, so roughly 4x the linear cost and up to 16x the attention cost
per step. Paying that on 20 steps instead of 50 is where the saving comes from.
The gain therefore depends on the resolutions, the strength, and how
attention-bound the shape is; measure it on your own workload.
Audio is re-noised and refined alongside the video at the same schedule
position. The two modalities are shifted apart (`flow_shift` 12.0 against
`audio_flow_shift` 3.0), so each resumes at its own sigma for that position --
the state the first pass actually passed through, which is what keeps the pass
in distribution.
`latent_refine` works without `latent_upscale` too, as a plain detail pass at
the generated size. For FL2VA, the keyframe condition is pinned to the output
latent size, so a refine after an upscale re-encodes the keyframes from the
originals at the new size; REF2VA references carry their own shapes and are
reused as they are.
### Options
| `--additional-config` key | Default | Meaning |
| --- | --- | --- |
| `latent_upscaler_path` | unset | Checkpoint file, or a directory holding exactly one. Unset disables the stage and loads no extra weights. |
| `latent_upscaler_dtype` | the engine dtype | `bf16`, `fp16` or `fp32`. |
| `latent_upscaler_chunk_frames` | `0` | `0` runs the whole clip in one pass; a positive value bounds activation memory with approximate temporal chunking. |
| `latent_upscaler_resident` | `false` | Keep the ~700 MB of weights on the GPU between requests instead of in host memory. |
| `latent_refine_max_tokens_per_rank` | `65536` | Reject larger refine layouts before denoising. Set `0` only after validating a larger deployment. |
| `latent_upscale` | unset | A default target applied to every request, in the request's own format. A request overrides it, and `false` opts out. |
| `latent_refine` | unset | A default refine strength applied to every request. A request overrides it, and `false` opts out. |
The `latent_upscale` request value is a multiplier (`2.0`), `false`, or an
object naming one mode: `{"scale": 2.0}`, `{"width": …, "height": …}` or
`{"megapixels": …}`, each accepting an `align` (default `32` pixels). The
checkpoint was trained between 1.0x and 4.0x and only upscales; a smaller
target is rejected, and `scale` 1.0 is a no-op that skips the stage. The
`latent_refine` value is a strength in `(0, 1]`, `false`, or
`{"strength": 0.4}`; it needs no checkpoint of its own.
When `latent_refine` is enabled, both resolved output dimensions must be
divisible by 32 pixels for the DiT's spatial patches. A custom `align` that
produces an incompatible target is rejected before denoising; upscale-only
requests can still use the VAE's 16-pixel grid. Numeric upscale fields must be
positive and finite, and `width`, `height`, and `align` must be integers.
Temporal chunking can bound activation memory on long clips -- a 15-second clip is
102 latent frames, more than three 32-frame chunks. It is an approximation rather than a
partition: the network's GroupNorms pool statistics over whatever clip they are
handed, so a chunked pass can differ from a single pass across the whole output
and not only at the chunk seams. How much that costs in practice was not
measured here; the default single pass buys the exact result for the
memory a full-length activation volume takes (10.1 GiB against 5.6 GiB at
2688x1536 for 15 seconds, measured on one B300). Set
`latent_upscaler_chunk_frames: 32` explicitly if that memory cost is too high.
## Known limitations
- TeaCache is calibrated for FL2VA only; Ref2VA requests run uncached.
- The latent upscaler is a community checkpoint, not part of the H3 release,
and is unavailable unless `latent_upscaler_path` names one. It runs
replicated on every DiT rank rather than sharded.
- `latent_refine` is request-mode only. It is a second denoise loop with its
own resolution and its own truncated schedule, and the step contract carries
one schedule per request, so step execution rejects it. `latent_upscale`
without `latent_refine` works in both modes.
- Large refine layouts can abort workers with `CUDA error: an illegal memory
access was encountered`. On B300, observed video token counts of 54,144 per
rank complete while 108,288 and 109,360 abort. The preflight limit of 65,536
counts the full padded layout, including text, audio, and visual references,
per Ulysses rank. It rejects the observed failing sizes before the base pass.
This is an empirical B300 guard, not a proven kernel limit on every device;
deployments with different hardware can adjust the limit after validation.
- Combined serving requires sibling `FL2VA` and `Ref2VA` directories, loads
both task-specific DiTs, and loads shared components once from `FL2VA`.
- Request mode executes one generation request per diffusion batch. Use
+21 -3
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
@@ -45,7 +45,7 @@ def _recording_backend(events):
def test_startup_cache_follows_omit_lossless_high_omit_state_machine(monkeypatch):
events = []
events: list[tuple] = []
FakeBackend = _recording_backend(events)
monkeypatch.setattr(runtime_module, "CacheDiTBackend", FakeBackend)
pipeline = object()
@@ -83,7 +83,7 @@ def test_startup_cache_follows_omit_lossless_high_omit_state_machine(monkeypatch
def test_high_installs_without_startup_cache_and_lossless_uninstalls(monkeypatch):
events = []
events: list[tuple] = []
monkeypatch.setattr(runtime_module, "CacheDiTBackend", _recording_backend(events))
pipeline = object()
runtime = RequestScopedCacheDiTRuntime(pipeline)
@@ -99,3 +99,21 @@ def test_high_installs_without_startup_cache_and_lossless_uninstalls(monkeypatch
]
assert events[2][-1] == 40
assert not runtime.is_enabled
def test_denoise_pass_refresh_preserves_profile_and_uses_actual_steps(monkeypatch):
events: list[tuple] = []
monkeypatch.setattr(runtime_module, "CacheDiTBackend", _recording_backend(events))
pipeline = object()
runtime = RequestScopedCacheDiTRuntime(pipeline)
runtime.refresh(10)
assert events == []
runtime.prepare(_spec("high", 50))
events.clear()
runtime.refresh(20)
runtime.refresh(50)
assert events == [("refresh", pipeline, 20), ("refresh", pipeline, 50)]
runtime.disable()
events.clear()
runtime.refresh(20)
assert events == []
@@ -3551,7 +3551,7 @@ def test_request_cancellation_at_prepare_and_decode_boundaries(preencode, cancel
def prepare(*args):
if cancel_phase == "prepare":
registry.cancel(["request"])
return {"num_outputs": 1, "seed": 1101, "preencode_mp4": preencode}
return {"num_outputs": 1, "seed": 1101, "preencode_mp4": preencode, "height": 2, "width": 2}
def diffuse(**kwargs):
registry.cancel(["request"])
@@ -0,0 +1,126 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Real-weight L3 coverage for optional latent upscaling and second-pass refinement.
Set VLLM_TEST_MINIMAX_H3_LATENT_UPSCALER to the LBH BF16 upscaler checkpoint.
VLLM_TEST_MINIMAX_H3_MODEL may point to a local FL2VA model directory.
"""
import json
import os
from pathlib import Path
import pytest
from tests.helpers.assertions import assert_video_first_frames_differ, assert_video_valid
from tests.helpers.mark import hardware_marks
from tests.helpers.media import generate_synthetic_image
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient
MODEL = os.environ.get("VLLM_TEST_MINIMAX_H3_MODEL", "MiniMaxAI/MiniMax-H3")
UPSCALER = os.environ.get("VLLM_TEST_MINIMAX_H3_LATENT_UPSCALER")
pytestmark = [pytest.mark.advanced_model, pytest.mark.diffusion]
@pytest.mark.skipif(not UPSCALER, reason="set VLLM_TEST_MINIMAX_H3_LATENT_UPSCALER to the real upscaler checkpoint")
@pytest.mark.parametrize(
"omni_server",
[
pytest.param(
OmniServerParams(
model=MODEL,
server_args=[
"--trust-remote-code",
"--task-type",
"fl2va",
"--num-gpus",
"2",
"--tensor-parallel-size",
"2",
"--usp",
"1",
"--ring",
"1",
"--text-encoder-tp-size",
"2",
"--vae-patch-parallel-size",
"2",
"--vae-parallel-mode",
"tile",
"--vae-use-tiling",
"--enable-cpu-offload",
"--enforce-eager",
"--diffusion-attention-backend",
"FLASH_ATTN",
"--additional-config",
json.dumps({"latent_upscaler_path": UPSCALER, "latent_upscaler_dtype": "bf16"}),
],
env_dict={"VLLM_WORKER_MULTIPROC_METHOD": "spawn"},
),
id="minimax_h3_latent_upscale_refine_tp2",
marks=hardware_marks(res={"cuda": "H100"}, num_cards=2),
),
],
indirect=True,
)
@pytest.mark.parametrize("task", ["t2va", "fl2va"])
def test_latent_upscale_and_refine(
omni_server: OmniServer,
online_client: OnlineOmniClient,
tmp_path: Path,
task: str,
) -> None:
"""Exercise upscale, refinement, and request-local opt-out on one live server."""
videos = []
frame_counts = []
image_reference = None
if task == "fl2va":
image = generate_synthetic_image(512, 288, seed=42)
image_reference = f"data:image/jpeg;base64,{image['base64']}"
# The 1024x576 refine keyframe spans multiple 256px VAE encoder tiles:
# both VAE ranks must encode it before broadcasting the condition latents.
# Four base steps and the minimum four-second clip keep real-weight CI affordable.
# Use size rather than width/height: upscale intentionally changes output dimensions.
for name, upscale, refine, width, height in [
("original", False, False, 512, 288),
("upscaled", 2.0, False, 1024, 576),
("refined", 2.0, 0.5, 1024, 576),
("disabled_again", False, False, 512, 288),
]:
responses = online_client.send_video_diffusion_request(
{
"model": omni_server.model,
"image_reference": image_reference,
"form_data": {
"model": omni_server.model,
"prompt": "A woman in a sunlit room turns toward the camera, cinematic lighting.",
"size": "512x288",
"fps": 24,
"num_inference_steps": 4,
"flow_shift": 12,
"seed": 1101,
"extra_params": json.dumps(
{
"task": task,
"duration": 4.0,
"aspect_ratio": "16:9",
"audio_flow_shift": 3.0,
"latent_upscale": upscale,
"latent_refine": refine,
}
),
},
"expected_audio": {"sample_rate": 32000, "channels": 2},
}
)
video = responses[0].videos[0]
(tmp_path / f"{name}.mp4").write_bytes(video)
metadata = assert_video_valid(video, width=width, height=height, fps=24)
frame_counts.append(metadata["num_frames"])
videos.append(video)
assert frame_counts[0] > 0
assert len(set(frame_counts)) == 1, f"Upscale/refinement changed the frame count: {frame_counts}"
# Equal output resolution alone would miss a silently ignored second denoise pass.
assert_video_first_frames_differ(videos[2], videos[1], min_mean_absolute_error=1 / 255)
+19
View File
@@ -367,6 +367,25 @@ def assert_video_first_frame_matches(
)
def assert_video_first_frames_differ(
video: bytes,
reference: bytes,
*,
min_mean_absolute_error: float,
) -> None:
"""Compare decoded pixels, ignoring differences in MP4 encoding metadata."""
with av.open(BytesIO(video)) as container:
actual = next(container.decode(video=0)).to_ndarray(format="rgb24")
with av.open(BytesIO(reference)) as container:
expected = next(container.decode(video=0)).to_ndarray(format="rgb24")
assert actual.shape == expected.shape
mean_absolute_error = float(np.abs(actual.astype(np.float32) - expected).mean() / 255.0)
assert mean_absolute_error > min_mean_absolute_error, (
f"Expected first-frame MAE > {min_mean_absolute_error}, got {mean_absolute_error:.6f}."
)
def assert_audio_diffusion_response(
response: "DiffusionResponse",
request_config: dict[str, Any],
+7 -1
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Declarative request-scoped Cache-DiT lifecycle."""
@@ -87,6 +87,12 @@ class RequestScopedCacheDiTRuntime:
self._backend = None
self._installation_key = None
def refresh(self, num_inference_steps: int) -> None:
"""Start an independent denoise pass with the installed profile."""
if self.is_enabled:
assert self._backend is not None
self._backend.refresh(self._pipeline, num_inference_steps=num_inference_steps)
__all__ = [
"CacheDiTRequestSpec",
@@ -0,0 +1,672 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Learned latent super-resolution for MiniMax-H3 video latents.
The released upscaler is a pure-3D convolutional resizer trained on H3's
24-channel VAE latents: it lifts a latent to a target spatial size without a
decode/encode round trip through the ~5B-parameter H3 VAE. Weights come from
``LBH-123-AI/Minimax_h3_latent_Upscaler`` and are a plain ``state_dict``, so the
module layout below reproduces the checkpoint's parameter names exactly.
The network works in a space one normalization *below* the pipeline latent.
A vLLM-Omni H3 latent is already normalized -- :class:`MiniMaxH3VideoVAE`
denormalizes at decode time -- and the reference ComfyUI node applies the VAE's
``latents_mean`` / ``latents_std`` on top of a latent in that same convention,
so the upscaler was trained on the doubly-normalized tensor. Feeding it the
pipeline latent directly inflates the output by roughly 5x (measured: output
std 5.4 against an input std of 1.0), which decodes as a magenta grid at one
tile per latent cell. :meth:`MiniMaxH3LatentUpscaler.upscale` therefore
normalizes in and denormalizes out, exactly as the reference node does.
"""
from __future__ import annotations
import math
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
import regex as re
import torch
import torch.nn as nn
import torch.nn.functional as F
from safetensors import safe_open
from vllm.logger import init_logger
logger = init_logger(__name__)
# H3's video VAE downsamples 16x spatially, so one latent cell is a 16px tile.
MINIMAX_H3_VAE_SPATIAL_DOWNSAMPLE = 16
# The published checkpoints were trained between 1x and 4x.
MINIMAX_H3_LATENT_UPSCALE_MAX_SCALE = 4.0
_EMBED_DIM = 64
_GROUP_NORM_GROUPS = 32
class MiniMaxH3LatentUpscalerError(ValueError):
"""A latent upscaler artifact that cannot be served."""
@dataclass(frozen=True)
class MiniMaxH3LatentUpscalerArch:
"""Shape of a released latent upscaler, recovered from its ``state_dict``."""
in_channels: int = 24
channels: int = 512
in_blocks: int = 12
out_blocks: int = 12
temporal_every: int = 2
temporal_kernel: int = 5
def _normalization(channels: int) -> nn.GroupNorm:
return nn.GroupNorm(_GROUP_NORM_GROUPS, channels)
class _ResBlockEmb3D(nn.Module):
"""3D residual block whose output norm is modulated by the scale embedding."""
def __init__(self, channels: int, emb_channels: int) -> None:
super().__init__()
self.in_layers = nn.Sequential(
_normalization(channels),
nn.SiLU(),
nn.Conv3d(channels, channels, 3, padding=1),
)
self.emb_layers = nn.Sequential(
nn.SiLU(),
nn.Linear(emb_channels, 2 * channels),
)
self.out_norm = _normalization(channels)
# Index 1 is the training-time dropout. It is a no-op at inference but
# has to keep its slot so ``out_layers.2`` still names the convolution.
self.out_layers = nn.Sequential(
nn.SiLU(),
nn.Identity(),
nn.Conv3d(channels, channels, 3, padding=1),
)
self.skip = nn.Identity()
def forward(self, x: torch.Tensor, emb: torch.Tensor) -> torch.Tensor:
h = self.in_layers(x)
scale, shift = torch.chunk(self.emb_layers(emb).to(h.dtype)[..., None, None, None], 2, dim=1)
h = self.out_norm(h) * (1 + scale) + shift
return self.skip(x) + self.out_layers(h)
class _TemporalConv3D(nn.Module):
"""Zero-initialized depthwise temporal residual, for cross-frame coherence."""
def __init__(self, channels: int, kernel_size: int) -> None:
super().__init__()
self.norm = _normalization(channels)
self.dwconv = nn.Conv3d(
channels,
channels,
kernel_size=(kernel_size, 1, 1),
padding=(kernel_size // 2, 0, 0),
groups=channels,
)
self.pwconv = nn.Conv3d(channels, channels, kernel_size=1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return x + self.pwconv(self.dwconv(F.silu(self.norm(x))))
class MiniMaxH3LatentResizer3D(nn.Module):
"""The released pure-3D latent resizer.
``in_blocks`` run at the source resolution, a trilinear interpolation moves
the feature volume to the target size, and ``out_blocks`` refine it there.
The requested scale enters every residual block through a two-layer
embedding of ``scale - 1``.
"""
def __init__(self, arch: MiniMaxH3LatentUpscalerArch) -> None:
super().__init__()
self.arch = arch
self.conv_in = nn.Conv3d(arch.in_channels, arch.channels, 3, padding=1)
self.embed = nn.Sequential(
nn.Linear(1, _EMBED_DIM),
nn.SiLU(),
nn.Linear(_EMBED_DIM, _EMBED_DIM),
)
self.in_blocks = self._build_stage(arch, arch.in_blocks)
self.out_blocks = self._build_stage(arch, arch.out_blocks)
self.norm_out = _normalization(arch.channels)
self.conv_out = nn.Conv3d(arch.channels, arch.in_channels, 3, padding=1)
@staticmethod
def _build_stage(arch: MiniMaxH3LatentUpscalerArch, num_blocks: int) -> nn.ModuleList:
# Residual and temporal blocks share one flat ModuleList in the
# checkpoint, so their interleaving fixes the parameter names.
stage = nn.ModuleList()
for index in range(num_blocks):
stage.append(_ResBlockEmb3D(arch.channels, _EMBED_DIM))
if arch.temporal_every > 0 and index % arch.temporal_every == 0:
stage.append(_TemporalConv3D(arch.channels, arch.temporal_kernel))
return stage
@property
def temporal_kernel(self) -> int:
return self.arch.temporal_kernel if self.arch.temporal_every > 0 else 0
def forward(self, latent: torch.Tensor, *, scale: float, target_size: tuple[int, int, int]) -> torch.Tensor:
"""Resize ``[B, C, T, H, W]`` to ``target_size`` under a scale hint."""
emb = self.embed(
torch.tensor([[scale - 1.0]], dtype=latent.dtype, device=latent.device),
).expand(latent.shape[0], -1)
hidden = self.conv_in(latent)
for block in self.in_blocks:
hidden = block(hidden, emb) if isinstance(block, _ResBlockEmb3D) else block(hidden)
hidden = F.interpolate(hidden, size=target_size, mode="trilinear", align_corners=False)
for block in self.out_blocks:
hidden = block(hidden, emb) if isinstance(block, _ResBlockEmb3D) else block(hidden)
return self.conv_out(F.silu(self.norm_out(hidden)))
def detect_minimax_h3_upscaler_arch(state_dict: Mapping[str, torch.Tensor]) -> MiniMaxH3LatentUpscalerArch:
"""Recover the block layout of a released upscaler from its tensors."""
conv_in = state_dict.get("conv_in.weight")
if conv_in is None or conv_in.ndim != 5:
raise MiniMaxH3LatentUpscalerError("not a MiniMax-H3 3D latent upscaler: expected a rank-5 'conv_in.weight'")
if any("attn" in key for key in state_dict):
raise MiniMaxH3LatentUpscalerError(
"this checkpoint carries attention blocks, which no released MiniMax-H3 upscaler uses"
)
residual_blocks: dict[str, set[int]] = {"in_blocks": set(), "out_blocks": set()}
temporal_blocks: dict[str, set[int]] = {"in_blocks": set(), "out_blocks": set()}
temporal_kernel = 5
for key, tensor in state_dict.items():
match = re.match(r"(in_blocks|out_blocks)\.(\d+)\.(in_layers|dwconv)\b", key)
if match is None:
continue
stage, index, kind = match.group(1), int(match.group(2)), match.group(3)
if kind == "in_layers":
residual_blocks[stage].add(index)
else:
temporal_blocks[stage].add(index)
if key.endswith("dwconv.weight"):
temporal_kernel = int(tensor.shape[2])
has_temporal = bool(temporal_blocks["in_blocks"] or temporal_blocks["out_blocks"])
return MiniMaxH3LatentUpscalerArch(
in_channels=int(conv_in.shape[1]),
channels=int(conv_in.shape[0]),
in_blocks=len(residual_blocks["in_blocks"]),
out_blocks=len(residual_blocks["out_blocks"]),
temporal_every=2 if has_temporal else 0,
temporal_kernel=temporal_kernel,
)
@dataclass(frozen=True)
class MiniMaxH3LatentUpscaleTarget:
"""A resolved upscale target in latent cells, plus the scale hint to feed the network."""
latent_height: int
latent_width: int
scale: float
@property
def height(self) -> int:
return self.latent_height * MINIMAX_H3_VAE_SPATIAL_DOWNSAMPLE
@property
def width(self) -> int:
return self.latent_width * MINIMAX_H3_VAE_SPATIAL_DOWNSAMPLE
def resolve_minimax_h3_latent_upscale_target(
*,
latent_height: int,
latent_width: int,
scale: float | None = None,
height: int | None = None,
width: int | None = None,
megapixels: float | None = None,
align: int = 32,
) -> MiniMaxH3LatentUpscaleTarget:
"""Resolve one of the three sizing modes into latent dimensions.
``scale`` multiplies the source size; ``height``/``width`` name the target
in pixels; ``megapixels`` names a total pixel budget and keeps the aspect
ratio. For the two size-based modes the network still needs a single scale
hint, which is the mean of the per-axis ratios -- what the reference node
feeds the same weights. Targets snap to ``align`` pixels and then to the
VAE's 16px cell.
"""
source_height = latent_height * MINIMAX_H3_VAE_SPATIAL_DOWNSAMPLE
source_width = latent_width * MINIMAX_H3_VAE_SPATIAL_DOWNSAMPLE
modes = [
name
for name, value in (("scale", scale), ("dimensions", height or width), ("megapixels", megapixels))
if value is not None
]
if len(modes) != 1:
raise MiniMaxH3LatentUpscalerError(
f"latent upscaling takes exactly one of scale, height/width, or megapixels, got {modes or ['nothing']}"
)
if align < 1:
raise MiniMaxH3LatentUpscalerError(f"latent upscale align must be positive, got {align}")
if scale is not None:
target_height = source_height * float(scale)
target_width = source_width * float(scale)
elif megapixels is not None:
if megapixels <= 0:
raise MiniMaxH3LatentUpscalerError(f"latent upscale megapixels must be positive, got {megapixels}")
aspect = source_width / source_height
target_height = math.sqrt(float(megapixels) * 1024 * 1024 / aspect)
target_width = target_height * aspect
else:
if height is None or width is None:
raise MiniMaxH3LatentUpscalerError("latent upscaling by dimensions needs both height and width")
target_height = float(height)
target_width = float(width)
cell = MINIMAX_H3_VAE_SPATIAL_DOWNSAMPLE
if not math.isfinite(target_height) or not math.isfinite(target_width):
raise MiniMaxH3LatentUpscalerError("latent upscale target dimensions must be finite")
out_height = max(1, int(round(round(target_height / align) * align / cell)))
out_width = max(1, int(round(round(target_width / align) * align / cell)))
effective = (out_height / latent_height + out_width / latent_width) / 2.0
if out_height < latent_height or out_width < latent_width:
raise MiniMaxH3LatentUpscalerError(
f"the MiniMax-H3 latent upscaler only upscales: {latent_width}x{latent_height} -> "
f"{out_width}x{out_height} latent cells"
)
if effective > MINIMAX_H3_LATENT_UPSCALE_MAX_SCALE:
logger.warning(
"MiniMax-H3 latent upscale factor %.3f exceeds the %.1fx the checkpoint was trained for",
effective,
MINIMAX_H3_LATENT_UPSCALE_MAX_SCALE,
)
return MiniMaxH3LatentUpscaleTarget(latent_height=out_height, latent_width=out_width, scale=effective)
class MiniMaxH3LatentUpscaler(nn.Module):
"""Serving wrapper around :class:`MiniMaxH3LatentResizer3D`.
Owns the temporal chunking that keeps long clips inside VRAM and the
residency policy that keeps the extra ~700MB off the device between
requests.
"""
def __init__(
self,
resizer: MiniMaxH3LatentResizer3D,
*,
device: torch.device,
dtype: torch.dtype,
latents_mean: Sequence[float],
latents_std: Sequence[float],
chunk_frames: int = 0,
chunk_overlap: int | None = None,
resident: bool = False,
) -> None:
super().__init__()
if chunk_frames < 0:
raise MiniMaxH3LatentUpscalerError(f"latent upscale chunk_frames must be >= 0, got {chunk_frames}")
# Plain tuples rather than buffers: this module manages its own device
# placement, and a buffer would also show up in the pipeline state dict.
self.latents_mean = tuple(float(value) for value in latents_mean)
self.latents_std = tuple(float(value) for value in latents_std)
if len(self.latents_mean) != len(self.latents_std):
raise MiniMaxH3LatentUpscalerError(
f"latents_mean/latents_std length mismatch: {len(self.latents_mean)} vs {len(self.latents_std)}"
)
if any(value == 0.0 for value in self.latents_std):
raise MiniMaxH3LatentUpscalerError("latents_std must not contain zeros")
self.resizer = resizer.to(dtype=dtype).eval().requires_grad_(False)
self.device = device
self.dtype = dtype
self.chunk_frames = chunk_frames
self.chunk_overlap = resizer.temporal_kernel if chunk_overlap is None else chunk_overlap
self.resident = resident
self.resizer.to(device if resident else torch.device("cpu"))
def load_to_device(self) -> None:
self.resizer.to(self.device)
def offload_to_cpu(self) -> None:
self.resizer.to(torch.device("cpu"))
@torch.inference_mode()
def upscale(self, latent: torch.Tensor, target: MiniMaxH3LatentUpscaleTarget) -> torch.Tensor:
"""Upscale a normalized ``[B, 24, T, H, W]`` H3 latent to ``target``."""
if latent.ndim != 5:
raise MiniMaxH3LatentUpscalerError(f"expected a rank-5 H3 latent, got shape {tuple(latent.shape)}")
size = (int(latent.shape[2]), target.latent_height, target.latent_width)
if size[1:] == tuple(latent.shape[-2:]):
return latent
if int(latent.shape[1]) != len(self.latents_mean):
raise MiniMaxH3LatentUpscalerError(
f"expected {len(self.latents_mean)} latent channels, got {int(latent.shape[1])}"
)
source_dtype = latent.dtype
work = latent.to(device=self.device, dtype=self.dtype)
mean, std = self._norm_tensors(work.device, work.dtype)
try:
if not self.resident:
self.resizer.to(self.device)
upscaled = self._run((work - mean) / std, target.scale, size) * std + mean
finally:
if not self.resident:
self.resizer.to(torch.device("cpu"))
return upscaled.to(dtype=source_dtype)
def _norm_tensors(self, device: torch.device, dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor]:
shape = (1, -1, 1, 1, 1)
return (
torch.tensor(self.latents_mean, device=device, dtype=dtype).view(shape),
torch.tensor(self.latents_std, device=device, dtype=dtype).view(shape),
)
def _run(self, latent: torch.Tensor, scale: float, size: tuple[int, int, int]) -> torch.Tensor:
"""Resize the clip, in temporal chunks when it does not fit in one pass.
Chunking is not free: every GroupNorm in the network pools its
statistics over the whole clip it is given, so a chunked pass is an
approximation of the single pass everywhere, not only at the seams.
``chunk_frames=0`` buys the exact result for the memory a full-length
activation volume costs.
"""
frames = int(latent.shape[2])
overlap = self.chunk_overlap
if self.chunk_frames == 0 or frames <= self.chunk_frames or overlap == 0:
return self.resizer(latent, scale=scale, target_size=size)
logger.debug(
"MiniMax-H3 latent upscale: %d frames in chunks of %d with %d-frame overlap",
frames,
self.chunk_frames,
overlap,
)
# Replicate padding gives the first and last chunk the same amount of
# temporal context as the interior ones, so the clip does not flicker at
# its ends. Unlike the reference node the context is symmetric: every
# chunk sees `overlap` frames on both sides rather than only before.
padded = F.pad(latent, (0, 0, 0, 0, overlap, overlap), mode="replicate")
batch, channels = latent.shape[0], latent.shape[1]
accumulated = torch.zeros(batch, channels, frames, size[1], size[2], device=latent.device, dtype=latent.dtype)
weights = torch.zeros(1, 1, frames, 1, 1, device=latent.device, dtype=latent.dtype)
for start in range(0, frames, self.chunk_frames):
core_start, core_end = start, min(frames, start + self.chunk_frames)
blend_start, blend_end = max(0, core_start - overlap), min(frames, core_end + overlap)
# Padded coordinates: original frame `i` sits at `i + overlap`.
segment = padded[:, :, blend_start : blend_end + 2 * overlap].contiguous()
decoded = self.resizer(
segment,
scale=scale,
target_size=(int(segment.shape[2]), size[1], size[2]),
)[:, :, overlap : overlap + (blend_end - blend_start)]
weight = self._blend_weights(
blend_start,
blend_end,
core_start,
core_end,
device=latent.device,
dtype=latent.dtype,
)
accumulated[:, :, blend_start:blend_end] += decoded * weight
weights[:, :, blend_start:blend_end] += weight
del segment, decoded
return accumulated / weights.clamp(min=1e-8)
@staticmethod
def _blend_weights(
blend_start: int,
blend_end: int,
core_start: int,
core_end: int,
*,
device: torch.device,
dtype: torch.dtype,
) -> torch.Tensor:
"""Linear ramps over the frames a chunk shares with its neighbours."""
weight = torch.ones(blend_end - blend_start, device=device, dtype=dtype)
lead = core_start - blend_start
if lead > 0:
weight[:lead] = torch.arange(1, lead + 1, device=device, dtype=dtype) / (lead + 1)
trail = blend_end - core_end
if trail > 0:
weight[-trail:] = torch.arange(trail, 0, -1, device=device, dtype=dtype) / (trail + 1)
return weight.view(1, 1, -1, 1, 1)
_UPSCALER_DTYPES = {
"fp32": torch.float32,
"float32": torch.float32,
"fp16": torch.float16,
"float16": torch.float16,
"bf16": torch.bfloat16,
"bfloat16": torch.bfloat16,
}
def _resolve_checkpoint_file(path: str | Path) -> Path:
"""Find the single upscaler checkpoint at ``path``."""
candidate = Path(path).expanduser()
if candidate.is_file():
return candidate
if not candidate.is_dir():
raise MiniMaxH3LatentUpscalerError(f"MiniMax-H3 latent upscaler not found at {candidate}")
files = sorted(file for suffix in (".safetensors", ".pth") for file in candidate.glob(f"*{suffix}"))
if len(files) != 1:
raise MiniMaxH3LatentUpscalerError(
f"{candidate} holds {len(files)} latent upscaler checkpoints; point the path at one file"
)
return files[0]
def _read_state_dict(file: Path) -> dict[str, torch.Tensor]:
if file.suffix == ".safetensors":
with safe_open(file, framework="pt", device="cpu") as checkpoint:
state_dict = {key: checkpoint.get_tensor(key) for key in checkpoint.keys()}
elif file.suffix == ".pth":
state_dict = torch.load(file, map_location="cpu", weights_only=True)
if isinstance(state_dict, Mapping) and "model" in state_dict:
state_dict = state_dict["model"]
else:
raise MiniMaxH3LatentUpscalerError(f"unsupported latent upscaler checkpoint {file}")
# Some releases nest the resizer under a training-time wrapper.
if any(key.startswith("upscaler.") for key in state_dict):
return {
key.removeprefix("upscaler."): value for key, value in state_dict.items() if key.startswith("upscaler.")
}
return dict(state_dict)
def load_minimax_h3_latent_upscaler(
path: str | Path,
*,
device: torch.device,
dtype: torch.dtype,
latents_mean: Sequence[float],
latents_std: Sequence[float],
chunk_frames: int = 0,
chunk_overlap: int | None = None,
resident: bool = False,
) -> MiniMaxH3LatentUpscaler:
"""Build a serving-ready upscaler from a released checkpoint."""
file = _resolve_checkpoint_file(path)
state_dict = _read_state_dict(file)
arch = detect_minimax_h3_upscaler_arch(state_dict)
# Built on meta and assigned from the checkpoint: the pipeline is
# constructed under a CUDA default-device context, and a real
# initialization would spend ~2.7GB of device memory on values the next
# line overwrites anyway.
with torch.device("meta"):
resizer = MiniMaxH3LatentResizer3D(arch)
resizer.load_state_dict(state_dict, strict=True, assign=True)
logger.info(
"Loaded MiniMax-H3 latent upscaler %s: %d channels, %d+%d blocks, temporal kernel %d, %s",
file.name,
arch.channels,
arch.in_blocks,
arch.out_blocks,
arch.temporal_kernel,
dtype,
)
return MiniMaxH3LatentUpscaler(
resizer,
device=device,
dtype=dtype,
latents_mean=latents_mean,
latents_std=latents_std,
chunk_frames=chunk_frames,
chunk_overlap=chunk_overlap,
resident=resident,
)
def resolve_minimax_h3_latent_upscaler(
od_config,
*,
device: torch.device,
latent_stats: Callable[[], tuple[Sequence[float], Sequence[float]]],
) -> MiniMaxH3LatentUpscaler | None:
"""Build the upscaler declared by ``--additional-config``, if any.
``latent_stats`` yields the VAE's per-channel ``latents_mean`` /
``latents_std`` and is called only once a checkpoint is configured, so a
deployment without this stage never reaches for them.
Recognized keys: ``latent_upscaler_path`` (required to enable the stage),
``latent_upscaler_dtype``, ``latent_upscaler_chunk_frames`` (0 disables
temporal chunking) and ``latent_upscaler_resident`` (keep the weights on
the device between requests instead of parking them in host memory).
"""
additional = getattr(od_config, "additional_config", None) or {}
path = additional.get("latent_upscaler_path")
if not path:
return None
raw_dtype = additional.get("latent_upscaler_dtype")
if raw_dtype is None:
dtype = getattr(od_config, "dtype", torch.bfloat16)
elif str(raw_dtype) not in _UPSCALER_DTYPES:
raise MiniMaxH3LatentUpscalerError(
f"latent_upscaler_dtype must be one of {sorted(_UPSCALER_DTYPES)}, got {raw_dtype!r}"
)
else:
dtype = _UPSCALER_DTYPES[str(raw_dtype)]
latents_mean, latents_std = latent_stats()
return load_minimax_h3_latent_upscaler(
path,
device=device,
dtype=dtype,
latents_mean=latents_mean,
latents_std=latents_std,
chunk_frames=int(additional.get("latent_upscaler_chunk_frames", 0)),
resident=bool(additional.get("latent_upscaler_resident", False)),
)
def _parse_upscale_number(value, *, key: str) -> float | int:
integer = key in {"width", "height", "align"}
try:
number = float(value)
if isinstance(value, bool) or not math.isfinite(number) or number <= 0:
raise ValueError
if integer and not number.is_integer():
raise ValueError
return int(value) if integer else number
except (TypeError, ValueError, OverflowError) as exc:
kind = "positive integer" if integer else "positive finite number"
raise MiniMaxH3LatentUpscalerError(f"latent_upscale {key} must be a {kind}, got {value!r}") from exc
def parse_minimax_h3_latent_upscale_request(value) -> dict[str, float | int] | None:
"""Normalize ``extra_args['latent_upscale']`` into resolver keywords.
Accepts a bare multiplier (``2.0``), ``false``/``null`` to opt out of a
server-side default, or an object naming one sizing mode:
``{"scale": 2}``, ``{"width": 2688, "height": 1536}`` or
``{"megapixels": 4}``, each optionally with ``align``.
"""
if value is None or value is False:
return None
if isinstance(value, bool):
raise MiniMaxH3LatentUpscalerError("latent_upscale must be a number or an object, not true")
if isinstance(value, (int, float)):
return {"scale": _parse_upscale_number(value, key="scale")}
if not isinstance(value, Mapping):
raise MiniMaxH3LatentUpscalerError(f"latent_upscale must be a number or an object, got {type(value).__name__}")
known = {"scale", "width", "height", "megapixels", "align"}
unknown = set(value) - known
if unknown:
raise MiniMaxH3LatentUpscalerError(f"unknown latent_upscale keys {sorted(unknown)}; expected {sorted(known)}")
resolved: dict[str, float | int] = {}
for key in ("scale", "megapixels", "width", "height", "align"):
if value.get(key) is not None:
resolved[key] = _parse_upscale_number(value[key], key=key)
return resolved or None
@dataclass(frozen=True)
class MiniMaxH3LatentRefineSpec:
"""How much of the sigma schedule a second denoise pass re-runs."""
strength: float
def start_index(self, num_sigma_points: int) -> int:
"""The schedule position the refine pass starts from.
``strength`` is the fraction of the request's steps to re-run, the
img2img convention, so the cost of the pass is predictable from the
request. At least one step always runs.
"""
total_steps = num_sigma_points - 1
if total_steps < 1:
raise MiniMaxH3LatentUpscalerError("a refine pass needs a schedule with at least two sigma points")
return total_steps - max(1, round(self.strength * total_steps))
def parse_minimax_h3_latent_refine_request(value) -> MiniMaxH3LatentRefineSpec | None:
"""Normalize ``extra_args['latent_refine']`` into a refine spec.
Accepts a bare strength (``0.4``), ``false``/``null`` to opt out of a
server-side default, or ``{"strength": 0.4}``.
"""
if value is None or value is False:
return None
if isinstance(value, bool):
raise MiniMaxH3LatentUpscalerError("latent_refine must be a number or an object, not true")
if isinstance(value, Mapping):
unknown = set(value) - {"strength"}
if unknown:
raise MiniMaxH3LatentUpscalerError(f"unknown latent_refine keys {sorted(unknown)}; expected ['strength']")
if value.get("strength") is None:
return None
value = value["strength"]
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise MiniMaxH3LatentUpscalerError(f"latent_refine strength must be a number, got {type(value).__name__}")
strength = float(value)
if not 0.0 < strength <= 1.0:
raise MiniMaxH3LatentUpscalerError(f"latent_refine strength must be in (0, 1], got {strength}")
return MiniMaxH3LatentRefineSpec(strength=strength)
__all__ = [
"MINIMAX_H3_LATENT_UPSCALE_MAX_SCALE",
"MINIMAX_H3_VAE_SPATIAL_DOWNSAMPLE",
"MiniMaxH3LatentRefineSpec",
"MiniMaxH3LatentResizer3D",
"MiniMaxH3LatentUpscaleTarget",
"MiniMaxH3LatentUpscaler",
"MiniMaxH3LatentUpscalerArch",
"MiniMaxH3LatentUpscalerError",
"detect_minimax_h3_upscaler_arch",
"load_minimax_h3_latent_upscaler",
"parse_minimax_h3_latent_refine_request",
"parse_minimax_h3_latent_upscale_request",
"resolve_minimax_h3_latent_upscale_target",
"resolve_minimax_h3_latent_upscaler",
]
@@ -18,6 +18,7 @@ import numpy as np
import torch
import torch.distributed as dist
import torch.nn as nn
from PIL import Image
from transformers import Qwen2TokenizerFast, Qwen3VLProcessor
from vllm.logger import init_logger
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
@@ -27,6 +28,7 @@ from vllm_omni.diffusion.cache.cachedit import (
CacheDiTBackend,
RequestScopedCacheDiTRuntime,
)
from vllm_omni.diffusion.cache.teacache.hook import TeaCacheHook
from vllm_omni.diffusion.cancellation import check_request_cancellation
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.parallel_state import get_world_group, init_world_group
@@ -86,7 +88,10 @@ from vllm_omni.model_executor.models.minimax_h3.encoder_processing import (
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.preprocessing import (
build_minimax_h3_presentation,
load_minimax_h3_images,
)
from vllm_omni.model_executor.models.minimax_h3.reference_video import (
MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY,
deserialize_prepared_reference_videos,
@@ -120,6 +125,15 @@ from .latent_mask import (
minimax_h3_prepare_edit_rows,
minimax_h3_video_edit_masks,
)
from .latent_upscaler import (
MiniMaxH3LatentRefineSpec,
MiniMaxH3LatentUpscalerError,
MiniMaxH3LatentUpscaleTarget,
parse_minimax_h3_latent_refine_request,
parse_minimax_h3_latent_upscale_request,
resolve_minimax_h3_latent_upscale_target,
resolve_minimax_h3_latent_upscaler,
)
from .lora import TurboSpec, load_minimax_h3_turbo_lora
from .minimax_h3_transformer import (
MiniMaxH3Attention,
@@ -137,6 +151,7 @@ from .packed_sequence import (
minimax_h3_packed_sequence_ref2va_blocks,
)
from .packed_tokens import (
minimax_h3_pack_audio_latent,
minimax_h3_patchify_video_latent,
minimax_h3_unpack_audio_tokens,
minimax_h3_unpatchify_video_tokens,
@@ -153,8 +168,6 @@ from .time_request import (
from .vae import MiniMaxH3AudioVAE, MiniMaxH3VideoVAE, _VideoVAEPartProxy
if TYPE_CHECKING:
from PIL import Image
from vllm_omni.diffusion.worker.input_batch import InputBatch
from vllm_omni.diffusion.worker.utils import StepRequestState
@@ -282,6 +295,9 @@ _MINIMAX_H3_DENOISE_INPUT_KEYS = (
"audio_edit_restore_mask_rows",
)
# Request-context key for the FL2VA keyframes re-encoded at the refine size.
_REFINE_KEYFRAME_CONDITION = "minimax_h3_refine_keyframe_condition"
# ``StepRequestState.extra`` keys owned by the step-execution path.
_STEP_BRANCH = "minimax_h3_branch"
_STEP_AUDIO_ROWS = "minimax_h3_audio_rows"
@@ -351,6 +367,16 @@ def resolve_minimax_h3_diffusion_model_path(
return str(model_root / subdir)
def _minimax_h3_output_canvas(
shape: Mapping[str, Any],
target: MiniMaxH3LatentUpscaleTarget | None,
) -> tuple[int, int]:
"""The decoded frame size, which the upscaler moves when it runs."""
if target is None:
return int(shape["height"]), int(shape["width"])
return target.height, target.width
def _minimax_h3_post_process(output, output_type: str = "np"):
"""Convert the joint video/audio output without capturing worker state.
@@ -1076,6 +1102,19 @@ class MiniMaxH3Pipeline(
)
# Registry-side VAE patch-parallel discovery uses ``pipeline.vae``.
self.vae = self.video_vae
# Optional learned latent super-resolution, run between the denoise
# loop and the VAE. Absent unless --additional-config names a
# checkpoint, so a plain H3 deployment carries none of its weights.
# The upscaler works one normalization below the pipeline latent, so it
# needs the same per-channel statistics the VAE denormalizes with.
self.latent_upscaler = resolve_minimax_h3_latent_upscaler(
od_config,
device=self.device,
latent_stats=lambda: (
self.video_vae.config_dict["latents_mean"],
self.video_vae.config_dict["latents_std"],
),
)
self._dlo_component_cache = None
offloads_text_encoder = should_offload_component(od_config, TEXT_ENCODER_COMPONENT)
@@ -1138,8 +1177,8 @@ class MiniMaxH3Pipeline(
# ``weights_sources``. The text encoder uses the shared component
# loader so online quantization and offload processing follow the same
# path as the DiT.
for component_name in ("video_vae", "audio_vae"):
component = getattr(self, component_name)
for component_name in ("video_vae", "audio_vae", "latent_upscaler"):
component = getattr(self, component_name, None)
if component is None:
continue
loaded_with_prefix.update(f"{component_name}.{name}" for name, _ in component.named_parameters())
@@ -1458,20 +1497,33 @@ class MiniMaxH3Pipeline(
components = ModuleDiscovery.discover(self)
dits = components.dits
# This optional stage owns its own weight placement. Register it only
# with model-level sequential offload; generic VAE discovery would
# otherwise move it onto the GPU during unrelated layerwise setup.
upscaler = getattr(self, "latent_upscaler", None)
stages = [*components.encoders, *components.vaes]
if upscaler is not None:
stages.append(upscaler)
modules = [*dits, *stages]
# The upscaler normally parks its own weights on the host. Keep it as
# an execution stage so activating it evicts the DiT, but do not scan
# its parameters on every DiT step unless residency was requested.
selected_stages = [stage for stage in stages if stage is not upscaler or upscaler.resident]
selection_options: dict[str, Any] = {}
if offload_components is not None:
if DIT_COMPONENT in offload_components and not dits:
raise ValueError("MiniMax-H3 has no loaded DiT for selected module offload")
if TEXT_ENCODER_COMPONENT in offload_components and not components.encoders:
raise ValueError("MiniMax-H3 has no loaded text encoder for selected module offload")
selected_explicit_stages = [*components.encoders] if TEXT_ENCODER_COMPONENT in offload_components else []
if upscaler is not None and upscaler.resident:
selected_explicit_stages.append(upscaler)
selection_options = {
"offload_dit_modules": dits if DIT_COMPONENT in offload_components else (),
"offload_encoder_modules": (
components.encoders if TEXT_ENCODER_COMPONENT in offload_components else ()
),
"offload_encoder_modules": selected_explicit_stages,
}
elif upscaler is not None:
selection_options["offload_encoder_modules"] = selected_stages
apply_sequential_offload(
dit_modules=dits,
encoder_modules=stages,
@@ -1556,22 +1608,26 @@ class MiniMaxH3Pipeline(
rows: list[torch.Tensor] = []
shapes: list[tuple[int, int, int]] = []
_, rank, _ = _dit_rank_world()
# encode_image retains parallel tiling when there are enough tiles.
# Every VAE rank must finish its codec collectives before the latent
# broadcast, including when refinement enlarges the keyframe images.
encode_images = bool(images) and (rank == 0 or self.video_vae.is_distributed_enabled())
# Keep image and video references in one residency window when both
# appear in a request; otherwise the video branch would reload the VAE.
# Encoding touches only the CNN encoder half, so the 9GB ViT decoder
# stays off the device for the whole window.
needs_video_vae = video_count > 0 or (rank == 0 and bool(images))
needs_video_vae = video_count > 0 or encode_images
video_vae_context = (
self._component_on_device(self.video_vae.encoder_component) if needs_video_vae else nullcontext()
)
with video_vae_context:
if images:
image_rows = None
if rank == 0:
if encode_images:
image_rows = torch.cat([self.video_vae.encode_image(image) for image in images])
rows.append(
_broadcast_tensor(
image_rows,
image_rows if rank == 0 else None,
dtype=torch.float32,
device=self.device,
)
@@ -1642,6 +1698,43 @@ class MiniMaxH3Pipeline(
)
return video_rows, audio_rows
@staticmethod
def _renoise_rows(
init_latents: tuple[torch.Tensor, torch.Tensor],
*,
noise_video: torch.Tensor,
noise_audio: torch.Tensor,
sigma_video: float,
sigma_audio: float,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Put finished latents back on the flow at the given sigmas.
The rectified-flow forward process is ``x_s = (1 - s) * x0 + s * noise``
-- the interpolation :func:`minimax_h3_euler_eta0_step` walks back down
-- so this lands the latents exactly where a full pass would have been
at that sigma, which is what makes resuming mid-schedule in-distribution.
"""
video_latent, audio_latent = init_latents
video_prior = minimax_h3_patchify_video_latent(
video_latent.detach().to(device="cpu", dtype=torch.float32),
patch_size=(1, 2, 2),
)
audio_prior = minimax_h3_pack_audio_latent(audio_latent.detach().to(device="cpu", dtype=torch.float32))
if video_prior.shape != noise_video.shape:
raise ValueError(
f"refine video rows {tuple(video_prior.shape)} do not match the "
f"target layout {tuple(noise_video.shape)}"
)
if audio_prior.shape != noise_audio.shape:
raise ValueError(
f"refine audio rows {tuple(audio_prior.shape)} do not match the "
f"target layout {tuple(noise_audio.shape)}"
)
return (
(1.0 - sigma_video) * video_prior + sigma_video * noise_video,
(1.0 - sigma_audio) * audio_prior + sigma_audio * noise_audio,
)
@contextmanager
def _resident_dit_layers_on_device(self, *, enabled: bool = True):
controller = getattr(self, "_dlo_residency_controller", None)
@@ -1687,12 +1780,28 @@ class MiniMaxH3Pipeline(
audio_edit_clean_rows: torch.Tensor | None = None,
audio_edit_mask_rows: torch.Tensor | None = None,
audio_edit_restore_mask_rows: torch.Tensor | None = None,
init_latents: tuple[torch.Tensor, torch.Tensor] | None = None,
refine: MiniMaxH3LatentRefineSpec | None = None,
) -> dict[str, Any]:
"""Build the packed layout, initial rows, anchors, and sigma schedules.
Shared by request-mode :meth:`diffuse` and step-mode
:meth:`prepare_encode` so both paths start from identical state.
``init_latents`` and ``refine`` turn the pass into a second, partial
one: the rows start from those latents re-noised to the schedule
position ``refine`` selects, and the returned schedules begin there.
"""
video_sigmas = minimax_h3_time_shift_sigmas(
num_steps=num_steps,
shift_scale=video_shift,
base_schedule=base_schedule,
)
audio_sigmas = minimax_h3_time_shift_sigmas(
num_steps=num_steps,
shift_scale=audio_shift,
base_schedule=base_schedule,
)
initial_video, initial_audio = self._initial_noise(
seed=seed,
latent_t=latent_t,
@@ -1700,6 +1809,23 @@ class MiniMaxH3Pipeline(
latent_w=latent_w,
audio_t=audio_t,
)
if init_latents is not None:
if refine is None:
raise ValueError("init_latents needs a refine spec to place them on the schedule")
# Video and audio are shifted apart (12.0 against 3.0 by default),
# so the two schedules hold different sigmas at the same position.
# The loop steps them by index, so the pass has to resume at one
# index and re-noise each modality to its own sigma there.
start = refine.start_index(len(video_sigmas))
video_sigmas = video_sigmas[start:]
audio_sigmas = audio_sigmas[start:]
initial_video, initial_audio = self._renoise_rows(
init_latents,
noise_video=initial_video,
noise_audio=initial_audio,
sigma_video=video_sigmas[0],
sigma_audio=audio_sigmas[0],
)
if task == "ref2va":
if ref_blocks is None:
if visual_condition_shape is None or ref_audio_t is None:
@@ -1839,16 +1965,6 @@ class MiniMaxH3Pipeline(
audio_edit_restore_mask_rows,
)
video_sigmas = minimax_h3_time_shift_sigmas(
num_steps=num_steps,
shift_scale=video_shift,
base_schedule=base_schedule,
)
audio_sigmas = minimax_h3_time_shift_sigmas(
num_steps=num_steps,
shift_scale=audio_shift,
base_schedule=base_schedule,
)
return {
"branch": branch,
# The request-mode loop moves these onto the device itself; step mode
@@ -1932,6 +2048,8 @@ class MiniMaxH3Pipeline(
audio_edit_clean_rows: torch.Tensor | None = None,
audio_edit_mask_rows: torch.Tensor | None = None,
audio_edit_restore_mask_rows: torch.Tensor | None = None,
init_latents: tuple[torch.Tensor, torch.Tensor] | None = None,
refine: MiniMaxH3LatentRefineSpec | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
inputs = self._build_denoise_inputs(
task=task,
@@ -1965,9 +2083,19 @@ class MiniMaxH3Pipeline(
audio_edit_clean_rows=audio_edit_clean_rows,
audio_edit_mask_rows=audio_edit_mask_rows,
audio_edit_restore_mask_rows=audio_edit_restore_mask_rows,
init_latents=init_latents,
refine=refine,
)
branch = inputs["branch"]
transformer = self._transformer_for_task(task)
# Each pass (including each output and hi-res refine) owns its cache.
# Refine can change both the packed shape and the schedule length.
registry = getattr(transformer, "_hook_registry", None)
if registry is not None:
registry.reset_hook(TeaCacheHook._HOOK_NAME)
cache_runtime = getattr(self, "_cache_dit_runtime", None)
if cache_runtime is not None:
cache_runtime.refresh(len(inputs["sigmas_video"]) - 1)
with self._resident_dit_layers_on_device(enabled=transformer is self.transformer):
with self.progress_bar(total=len(inputs["sigmas_video"]) - 1) as progress:
video_rows, audio_rows = minimax_h3_denoise_loop(
@@ -2097,6 +2225,188 @@ class MiniMaxH3Pipeline(
audio = self._offload_model_cpu_stage_output(audio)
return video, audio
def _resolve_latent_upscale(
self,
extra: Mapping[str, Any],
*,
latent_h: int,
latent_w: int,
) -> MiniMaxH3LatentUpscaleTarget | None:
"""Resolve the requested latent super-resolution target, if any.
A request opts in with ``extra_args['latent_upscale']``; a deployment
can set the same value under ``--additional-config`` to upscale every
request, which a request then overrides (``false`` opts back out).
Resolving here means an unserviceable size is rejected before the
denoise loop rather than after it.
"""
additional = getattr(getattr(self, "od_config", None), "additional_config", None) or {}
raw = extra["latent_upscale"] if "latent_upscale" in extra else additional.get("latent_upscale")
try:
spec = parse_minimax_h3_latent_upscale_request(raw)
if spec is None:
return None
if self.latent_upscaler is None:
raise MiniMaxH3LatentUpscalerError(
"latent_upscale needs a checkpoint: serve with "
'--additional-config \'{"latent_upscaler_path": "<path>"}\''
)
target = resolve_minimax_h3_latent_upscale_target(
latent_height=latent_h,
latent_width=latent_w,
**spec,
)
if self._resolve_latent_refine(extra) is not None and (target.latent_height % 2 or target.latent_width % 2):
raise MiniMaxH3LatentUpscalerError(
"latent_refine requires upscale latent height and width divisible by 2 "
"(32 pixels); use align=32 or a compatible target size"
)
except MiniMaxH3LatentUpscalerError as exc:
raise OmniClientError(str(exc)) from exc
if (target.latent_height, target.latent_width) == (latent_h, latent_w):
return None
logger.info(
"MiniMax H3 latent upscale %dx%d -> %dx%d (scale %.3f)",
latent_w * 16,
latent_h * 16,
target.width,
target.height,
target.scale,
)
return target
def _resolve_latent_refine(self, extra: Mapping[str, Any]) -> MiniMaxH3LatentRefineSpec | None:
"""Resolve the requested second denoise pass, if any.
Read like ``latent_upscale``: ``extra_args['latent_refine']`` wins over
an ``--additional-config`` default, and ``false`` opts back out.
"""
additional = getattr(getattr(self, "od_config", None), "additional_config", None) or {}
raw = extra["latent_refine"] if "latent_refine" in extra else additional.get("latent_refine")
try:
return parse_minimax_h3_latent_refine_request(raw)
except MiniMaxH3LatentUpscalerError as exc:
raise OmniClientError(str(exc)) from exc
def _validate_refine_token_budget(
self,
*,
task: str,
target: MiniMaxH3LatentUpscaleTarget | None,
latent_t: int,
latent_h: int,
latent_w: int,
audio_t: int,
text_len: int,
keyframe_count: int,
ref_blocks: list[dict[str, Any]] | None,
) -> None:
"""Reject refine layouts known to exceed the tested per-rank limit.
Count the same rows as the packed layout without materializing its
large position tensors before the first denoise pass.
"""
additional = getattr(getattr(self, "od_config", None), "additional_config", None) or {}
limit = additional.get("latent_refine_max_tokens_per_rank", 65_536)
if type(limit) is not int or limit < 0:
raise OmniClientError("latent_refine_max_tokens_per_rank must be a non-negative integer")
if limit == 0:
return
height = target.latent_height if target is not None else latent_h
width = target.latent_width if target is not None else latent_w
frame_rows = (height // 2) * (width // 2)
used = text_len + 2 * audio_t + latent_t * frame_rows
if task == "fl2va":
used += keyframe_count * frame_rows
elif task == "ref2va":
for block in ref_blocks or ():
kind = block["kind"]
if kind == "image":
used += (block["latent_h"] // 2) * (block["latent_w"] // 2)
elif kind == "audio":
used += 2 * block["ref_audio_t"]
else:
used += 2 * block["ref_audio_t"]
used += block["latent_t"] * (block["latent_h"] // 2) * (block["latent_w"] // 2)
padded = ((used + MINIMAX_H3_SEQ_ALIGN - 1) // MINIMAX_H3_SEQ_ALIGN) * MINIMAX_H3_SEQ_ALIGN
parallel = getattr(self, "parallel_config", None)
degree = int(getattr(parallel, "ulysses_degree", 1))
per_rank = (padded + degree - 1) // degree
if per_rank > limit:
raise OmniClientError(
f"MiniMax H3 latent_refine needs about {per_rank:,} packed tokens per Ulysses rank "
f"(limit {limit:,}); reduce the target size or duration, increase --usp, "
"or adjust latent_refine_max_tokens_per_rank for a validated deployment"
)
def _refine_keyframe_condition(
self,
context: dict[str, Any],
) -> tuple[torch.Tensor | None, list[tuple[int, int, int]]]:
"""Encode the FL2VA keyframes at the refine size, once per request.
The encode broadcasts across the DiT group, so every rank has to reach
it the same number of times; caching on the request context keeps that
true while sparing the repeat for each additional output.
"""
cached = context.get(_REFINE_KEYFRAME_CONDITION)
if cached is None:
target = context["latent_upscale"]
cached = self._encode_visual_conditions(
[
image.resize((target.width, target.height), Image.Resampling.LANCZOS)
for image in context["keyframe_images"]
],
None,
video_count=0,
)
context[_REFINE_KEYFRAME_CONDITION] = cached
return cached
def _refined_latents(
self,
video_latent: torch.Tensor,
audio_latent: torch.Tensor,
*,
context: dict[str, Any],
seed: int,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Re-denoise finished latents at their current size.
This is the second half of the hi-res route: the first pass generates
at a cheap size, the upscaler moves the latent, and this pass resumes
the schedule at the target size to put back the detail the upscaler can
only approximate. Without an upscale it is an ordinary detail pass.
"""
refine = context["latent_refine"]
target = context["latent_upscale"]
kwargs = dict(self._denoise_kwargs(context))
kwargs["seed"] = seed
if target is not None:
kwargs["latent_h"] = target.latent_height
kwargs["latent_w"] = target.latent_width
# A pinned pad_seq_len sizes the first pass's layout. This pass packs
# several times as many video rows, so carrying the pin over would
# fail the seq_len >= used check with an error that names neither
# the refine pass nor the size that outgrew it.
kwargs["pad_seq_len"] = None
if context["task"] == "fl2va":
condition, shapes = self._refine_keyframe_condition(context)
kwargs["visual_condition"] = condition
kwargs["visual_condition_shapes"] = shapes
kwargs["visual_condition_shape"] = shapes[0] if len(shapes) == 1 else None
return self.diffuse(**kwargs, init_latents=(video_latent, audio_latent), refine=refine)
def _upscaled_latent(
self,
video_latent: torch.Tensor,
target: MiniMaxH3LatentUpscaleTarget | None,
) -> torch.Tensor:
if target is None:
return video_latent
with self._component_on_device(self.latent_upscaler):
return self.latent_upscaler.upscale(video_latent, target)
@staticmethod
def _extract_prompt(raw_prompt: Any) -> tuple[str, dict[str, Any]]:
"""Split a request prompt into its text and multimodal parts."""
@@ -2398,6 +2708,19 @@ class MiniMaxH3Pipeline(
else:
conditioning = self._extract_encoder_conditioning(raw_prompt)
context = self._prepare_encoder_conditioning_inputs(conditioning, sampling)
if (
context.get("latent_refine") is not None
and context.get("latent_upscale") is not None
and context["task"] == "fl2va"
):
if not self.load_vae_encoder:
raise OmniClientError("MiniMax H3 FL2VA latent_refine with upscale requires a local VAE encoder")
_, media = self._extract_prompt(raw_prompt)
images = media.get("image")
images = list(images) if isinstance(images, (list, tuple)) else [images] if images is not None else []
context["keyframe_images"] = load_minimax_h3_images(images)
if len(context["keyframe_images"]) != len(context["keyframe_frame_indices"] or ()):
raise OmniClientError("MiniMax H3 latent_refine requires the original FL2VA keyframe images")
if window_text is not None:
context["continuation_text_conditioning"] = window_text
return context
@@ -2566,9 +2889,32 @@ class MiniMaxH3Pipeline(
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)
upscale_target = self._resolve_latent_upscale(
extra, latent_h=conditioning.height // 16, latent_w=conditioning.width // 16
)
latent_refine = self._resolve_latent_refine(extra)
if latent_refine is not None:
if continuation is not None:
raise OmniClientError("MiniMax H3 latent_refine does not support latent-tail continuation")
if conditioning.video_edit_clean_rows is not None or conditioning.audio_edit_clean_rows is not None:
raise OmniClientError("MiniMax H3 latent_refine does not support latent-mask editing")
self._validate_refine_token_budget(
task=task,
target=upscale_target,
latent_t=conditioning.latent_t,
latent_h=conditioning.height // 16,
latent_w=conditioning.width // 16,
audio_t=conditioning.audio_t,
text_len=int(conditioning.hidden_states.shape[0]),
keyframe_count=len(conditioning.keyframe_frame_indices),
ref_blocks=list(conditioning.ref_blocks) or None,
)
return {
"continuation": continuation,
"task": task,
"latent_upscale": upscale_target,
"latent_refine": latent_refine,
"keyframe_images": [],
"height": conditioning.height,
"width": conditioning.width,
"num_frames": conditioning.num_frames,
@@ -2629,6 +2975,9 @@ class MiniMaxH3Pipeline(
check_request_cancellation()
denoise_kwargs = self._denoise_kwargs(context)
num_outputs = context["num_outputs"]
upscale_target = context.get("latent_upscale")
latent_refine = context.get("latent_refine")
height, width = _minimax_h3_output_canvas(context, upscale_target)
videos = []
audios = []
for output_seed in _minimax_h3_output_seeds(context["seed"], num_outputs):
@@ -2646,13 +2995,22 @@ class MiniMaxH3Pipeline(
text_conditioning=context.get("continuation_text_conditioning"),
)
check_request_cancellation()
if upscale_target is not None:
video_latent = self._upscaled_latent(video_latent, upscale_target)
if latent_refine is not None:
video_latent, audio_latent = self._refined_latents(
video_latent,
audio_latent,
context=context,
seed=output_seed,
)
if context["preencode_mp4"]:
videos.append(
self.decode_to_mp4(
video_latent,
audio_latent,
height=context["height"],
width=context["width"],
height=height,
width=width,
video_codec_options=context["video_codec_options"],
batch_frames=context["preencode_batch_frames"],
)
@@ -2662,8 +3020,8 @@ class MiniMaxH3Pipeline(
video, audio = self.decode(
video_latent,
audio_latent,
height=context["height"],
width=context["width"],
height=height,
width=width,
)
# Rebind rather than append the expression: the local would
# otherwise keep the decoded frames' device storage alive for the
@@ -2751,6 +3109,17 @@ class MiniMaxH3Pipeline(
"co-batched requests would reuse incompatible cache state. Drop --step-execution "
"or omit quality=high."
)
# A refine pass is a second denoise loop, at its own resolution and over
# its own truncated schedule. The step contract gives the scheduler one
# latent and one schedule per request, so there is nowhere to put it.
# Latent upscaling alone has no such problem and stays supported: it
# runs once in ``post_decode``.
if self._resolve_latent_refine(getattr(state.sampling, "extra_args", None) or {}) is not None:
raise OmniClientError(
"MiniMax H3 step execution does not support latent_refine; it is a second denoise "
"loop and the step contract carries one schedule per request. Drop --step-execution, "
"or keep latent_upscale without latent_refine."
)
context = self._prepare_request_inputs(
state.prompt,
state.sampling,
@@ -2810,6 +3179,7 @@ class MiniMaxH3Pipeline(
"preencode_mp4": context.get("preencode_mp4", False),
"preencode_batch_frames": context.get("preencode_batch_frames", 17),
"video_codec_options": context.get("video_codec_options"),
"latent_upscale": context.get("latent_upscale"),
},
}
)
@@ -3029,12 +3399,16 @@ class MiniMaxH3Pipeline(
latent_w=shape["latent_w"],
audio_t=shape["audio_t"],
)
upscale_target = shape.get("latent_upscale")
if upscale_target is not None:
video_latent = self._upscaled_latent(video_latent, upscale_target)
height, width = _minimax_h3_output_canvas(shape, upscale_target)
if shape.get("preencode_mp4", False):
video = self.decode_to_mp4(
video_latent,
audio_latent,
height=shape["height"],
width=shape["width"],
height=height,
width=width,
video_codec_options=shape.get("video_codec_options"),
batch_frames=shape.get("preencode_batch_frames", 17),
)
@@ -3043,8 +3417,8 @@ class MiniMaxH3Pipeline(
video, audio = self.decode(
video_latent,
audio_latent,
height=shape["height"],
width=shape["width"],
height=height,
width=width,
)
video = self._offload_model_cpu_stage_output(_prepare_minimax_h3_video_output(video))
self._release_stage_cache()