[feat] Add MiniMax H3 (#1674)
This commit is contained in:
@@ -445,7 +445,11 @@ surfaces:
|
||||
moved:
|
||||
image_path: request.inputs.image_path
|
||||
pil_image: request.inputs.pil_image
|
||||
last_image: request.inputs.last_image
|
||||
references: request.inputs.references
|
||||
video_path: request.inputs.video_path
|
||||
latents: request.inputs.latents
|
||||
audio_latents: request.inputs.audio_latents
|
||||
mouse_cond: request.inputs.mouse_cond
|
||||
keyboard_cond: request.inputs.keyboard_cond
|
||||
grid_sizes: request.inputs.grid_sizes
|
||||
@@ -525,7 +529,6 @@ surfaces:
|
||||
inpaint_mask: request.extensions.stable_audio.inpaint_mask
|
||||
internal_only:
|
||||
data_type: "Derived from the request shape and not a public input."
|
||||
latents: "Pre-generated diffusion latents supplied by parity/debug harnesses; not a public input."
|
||||
|
||||
sampling_param_extensions: {}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ column links a runnable script in `examples/inference/basic/` where one exists.
|
||||
| ltx2 | `Lightricks/LTX-2`<br>`FastVideo/LTX2-base`<br>`FastVideo/LTX2-Diffusers` | T2V | [basic_ltx2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_ltx2.py) |
|
||||
| matrixgame | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers`<br>`FastVideo/Matrix-Game-2.0-Base-Diffusers`<br>`FastVideo/Matrix-Game-2.0-GTA-Diffusers`<br>`FastVideo/Matrix-Game-2.0-TempleRun-Diffusers`<br>`mignonjia/mg_longtuning_distilled_zelda`<br>`mignonjia/mg_sf_distilled_zelda_1k_steps`<br>`mignonjia/mg_sf_distilled_zelda`<br>`mignonjia/mg_causal_zelda`<br>`mignonjia/mg_bidirectional_zelda` | I2V | [basic_matrixgame2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame2.py) |
|
||||
| matrixgame | `FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers` | I2V | [basic_matrixgame3.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame3.py) |
|
||||
| minimax_h3 | `MiniMaxAI/MiniMax-H3` | T2V, I2V | [T2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_t2v.py)<br>[FL2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_fl2va.py)<br>[Ref2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_ref2va.py) |
|
||||
| sd35 | `stabilityai/stable-diffusion-3.5-medium` | T2I | [basic_sd35_t2i.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_sd35_t2i.py) |
|
||||
| stable_audio | `FastVideo/stable-audio-open-1.0-Diffusers` | T2V | [basic_stable_audio.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio.py) |
|
||||
| stable_audio | `FastVideo/stable-audio-open-small-Diffusers` | T2V | [basic_stable_audio_small.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio_small.py) |
|
||||
@@ -93,6 +94,10 @@ column links a runnable script in `examples/inference/basic/` where one exists.
|
||||
(`StableAudioT2AConfig` / `StableAudioOpenSmallConfig`); they are registered
|
||||
under the generic T2V workload option in the registry.
|
||||
|
||||
**Note (MiniMax H3)**: T2VA, FL2VA, and Ref2VA all generate video with stereo
|
||||
audio. Use the Ref2VA example when passing ordered image, video, or audio
|
||||
references.
|
||||
|
||||
**Note (Wan-VACE)**: not currently supported — no VACE pipeline or registered
|
||||
model ID exists on `main`
|
||||
([#1435](https://github.com/hao-ai-lab/FastVideo/issues/1435)). The closest
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Generate synchronized video/audio from a first frame with MiniMax H3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from fastvideo import VideoGenerator
|
||||
from fastvideo.api import (
|
||||
EngineConfig,
|
||||
GenerationRequest,
|
||||
GeneratorConfig,
|
||||
InputConfig,
|
||||
OffloadConfig,
|
||||
OutputConfig,
|
||||
ParallelismConfig,
|
||||
SamplingConfig,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import resolve_canvas_size
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--model-path", default="MiniMaxAI/MiniMax-H3")
|
||||
parser.add_argument("--image", required=True, help="First-frame image path.")
|
||||
parser.add_argument("--last-image", help="Optional last-frame image path.")
|
||||
parser.add_argument("--output", default="outputs/minimax_h3_fl2va")
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--num-frames", type=int, default=192, help="192 frames is exactly 8 seconds at 24 fps.")
|
||||
parser.add_argument("--steps", type=int, default=50)
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
parser.add_argument("--num-gpus", type=int, default=4)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
first_image = Image.open(args.image).convert("RGB")
|
||||
last_image = Image.open(args.last_image).convert("RGB") if args.last_image else None
|
||||
height, width = resolve_canvas_size(*first_image.size)
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
generator = VideoGenerator.from_config(
|
||||
GeneratorConfig(
|
||||
model_path=args.model_path,
|
||||
engine=EngineConfig(
|
||||
num_gpus=args.num_gpus,
|
||||
use_fsdp_inference=args.num_gpus > 1,
|
||||
parallelism=ParallelismConfig(tp_size=1, sp_size=args.num_gpus),
|
||||
offload=OffloadConfig(
|
||||
dit=False,
|
||||
dit_layerwise=False,
|
||||
text_encoder=True,
|
||||
vae=True,
|
||||
pin_cpu_memory=False,
|
||||
),
|
||||
),
|
||||
))
|
||||
try:
|
||||
result = generator.generate(
|
||||
GenerationRequest(
|
||||
prompt=args.prompt,
|
||||
negative_prompt="",
|
||||
inputs=InputConfig(pil_image=first_image, last_image=last_image),
|
||||
sampling=SamplingConfig(
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=args.num_frames,
|
||||
fps=24,
|
||||
num_inference_steps=args.steps,
|
||||
guidance_scale=1.0,
|
||||
batch_cfg=False,
|
||||
seed=args.seed,
|
||||
),
|
||||
output=OutputConfig(
|
||||
output_path=str(output_dir / "minimax_h3_fl2va.mp4"),
|
||||
save_video=True,
|
||||
return_frames=False,
|
||||
),
|
||||
))
|
||||
print(f"Output written to: {result.video_path}")
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Generate synchronized video/audio from ordered references with MiniMax H3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from fastvideo import VideoGenerator
|
||||
from fastvideo.api import (
|
||||
ComponentConfig,
|
||||
EngineConfig,
|
||||
GenerationRequest,
|
||||
GeneratorConfig,
|
||||
InputConfig,
|
||||
OffloadConfig,
|
||||
OutputConfig,
|
||||
ParallelismConfig,
|
||||
PipelineSelection,
|
||||
SamplingConfig,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--model-path", default="MiniMaxAI/MiniMax-H3")
|
||||
parser.add_argument("--reference-video", required=True)
|
||||
parser.add_argument("--reference-audio", help="Optional additional audio reference.")
|
||||
parser.add_argument("--output", default="outputs/minimax_h3_ref2va")
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--height", type=int, default=768)
|
||||
parser.add_argument("--width", type=int, default=1344)
|
||||
parser.add_argument("--num-frames", type=int, default=124)
|
||||
parser.add_argument("--steps", type=int, default=50)
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
parser.add_argument("--num-gpus", type=int, default=4)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
references = [MiniMaxH3Reference(source=args.reference_video, media_type="video")]
|
||||
if args.reference_audio:
|
||||
references.append(MiniMaxH3Reference(source=args.reference_audio, media_type="audio"))
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
generator = VideoGenerator.from_config(
|
||||
GeneratorConfig(
|
||||
model_path=args.model_path,
|
||||
engine=EngineConfig(
|
||||
num_gpus=args.num_gpus,
|
||||
use_fsdp_inference=args.num_gpus > 1,
|
||||
parallelism=ParallelismConfig(tp_size=1, sp_size=args.num_gpus),
|
||||
offload=OffloadConfig(
|
||||
dit=False,
|
||||
dit_layerwise=False,
|
||||
text_encoder=True,
|
||||
vae=True,
|
||||
pin_cpu_memory=False,
|
||||
),
|
||||
),
|
||||
pipeline=PipelineSelection(
|
||||
workload_type="i2v",
|
||||
components=ComponentConfig(override_pipeline_cls_name="MiniMaxH3Ref2VAModularPipeline"),
|
||||
),
|
||||
))
|
||||
try:
|
||||
result = generator.generate(
|
||||
GenerationRequest(
|
||||
prompt=args.prompt,
|
||||
negative_prompt="",
|
||||
inputs=InputConfig(references=references),
|
||||
sampling=SamplingConfig(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
fps=24,
|
||||
num_inference_steps=args.steps,
|
||||
guidance_scale=1.0,
|
||||
batch_cfg=False,
|
||||
seed=args.seed,
|
||||
),
|
||||
output=OutputConfig(
|
||||
output_path=str(output_dir / "minimax_h3_ref2va.mp4"),
|
||||
save_video=True,
|
||||
return_frames=False,
|
||||
),
|
||||
))
|
||||
print(f"Output written to: {result.video_path}")
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,83 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Generate video and audio from text with MiniMax H3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from fastvideo import VideoGenerator
|
||||
from fastvideo.api import (
|
||||
EngineConfig,
|
||||
GenerationRequest,
|
||||
GeneratorConfig,
|
||||
OffloadConfig,
|
||||
OutputConfig,
|
||||
ParallelismConfig,
|
||||
SamplingConfig,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--model-path", default="MiniMaxAI/MiniMax-H3")
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--output", default="outputs/minimax_h3_t2v")
|
||||
parser.add_argument("--height", type=int, default=768)
|
||||
parser.add_argument("--width", type=int, default=1344)
|
||||
parser.add_argument("--num-frames", type=int, default=124)
|
||||
parser.add_argument("--steps", type=int, default=50)
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
parser.add_argument("--num-gpus", type=int, default=4)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
generator = VideoGenerator.from_config(
|
||||
GeneratorConfig(
|
||||
model_path=args.model_path,
|
||||
engine=EngineConfig(
|
||||
num_gpus=args.num_gpus,
|
||||
use_fsdp_inference=args.num_gpus > 1,
|
||||
parallelism=ParallelismConfig(tp_size=1, sp_size=args.num_gpus),
|
||||
offload=OffloadConfig(
|
||||
dit=False,
|
||||
dit_layerwise=False,
|
||||
text_encoder=True,
|
||||
vae=True,
|
||||
pin_cpu_memory=False,
|
||||
),
|
||||
),
|
||||
))
|
||||
try:
|
||||
result = generator.generate(
|
||||
GenerationRequest(
|
||||
prompt=args.prompt,
|
||||
negative_prompt="",
|
||||
sampling=SamplingConfig(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
fps=24,
|
||||
num_inference_steps=args.steps,
|
||||
guidance_scale=1.0,
|
||||
batch_cfg=False,
|
||||
seed=args.seed,
|
||||
),
|
||||
output=OutputConfig(
|
||||
output_path=str(output_dir / "minimax_h3_t2v.mp4"),
|
||||
save_video=True,
|
||||
return_frames=False,
|
||||
),
|
||||
))
|
||||
print(f"Output written to: {result.video_path}")
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -25,6 +25,8 @@ class SamplingParam:
|
||||
# Image inputs
|
||||
image_path: str | None = None
|
||||
pil_image: Any | None = None
|
||||
last_image: Any | None = None
|
||||
references: list[Any] | None = None
|
||||
|
||||
# Video inputs
|
||||
video_path: str | None = None
|
||||
@@ -32,6 +34,7 @@ class SamplingParam:
|
||||
# Optional pre-generated diffusion latents. Used by parity/debug harnesses
|
||||
# and advanced callers that need deterministic latent reuse.
|
||||
latents: Any | None = None
|
||||
audio_latents: Any | None = None
|
||||
|
||||
# Action control inputs (Matrix-Game)
|
||||
mouse_cond: Any | None = None # Shape: (B, T, 2)
|
||||
|
||||
@@ -125,6 +125,10 @@ class InputConfig:
|
||||
image_path: str | list[str] | None = None
|
||||
video_path: str | list[str] | None = None
|
||||
pil_image: Any | None = None
|
||||
last_image: Any | None = None
|
||||
references: list[Any] | None = None
|
||||
latents: Any | None = None
|
||||
audio_latents: Any | None = None
|
||||
pose: str | None = None
|
||||
mouse_cond: Any | None = None
|
||||
keyboard_cond: Any | None = None
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastvideo.configs.models.dits.hunyuanvideo15 import HunyuanVideo15Config
|
||||
from fastvideo.configs.models.dits.longcat import LongCatVideoConfig
|
||||
from fastvideo.configs.models.dits.ltx2 import LTX2VideoConfig
|
||||
from fastvideo.configs.models.dits.magi_human import MagiHumanVideoConfig
|
||||
from fastvideo.configs.models.dits.minimax_h3 import MiniMaxH3Config
|
||||
from fastvideo.configs.models.dits.stable_audio import StableAudioConfig
|
||||
from fastvideo.configs.models.dits.wanvideo import WanVideoConfig
|
||||
from fastvideo.configs.models.dits.zimage import ZImageDiTConfig
|
||||
@@ -23,5 +24,5 @@ __all__ = [
|
||||
"DreamXWorldARConfig", "CosmosVideoConfig", "Cosmos25VideoConfig", "FluxDiTConfig", "Flux2Config",
|
||||
"LongCatVideoConfig", "LTX2VideoConfig", "HYWorldConfig", "Kandinsky5VideoConfig", "MagiHumanVideoConfig",
|
||||
"StableAudioConfig", "GlmImageDiTConfig", "LingBotWorld2CausalFastVideoConfig", "LingBotVideoConfig",
|
||||
"ZImageDiTConfig"
|
||||
"MiniMaxH3Config", "ZImageDiTConfig"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Architecture configuration for the MiniMax H3 joint audio-video DiT."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastvideo.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from fastvideo.platforms import AttentionBackendEnum
|
||||
|
||||
|
||||
def _is_minimax_h3_block(name: str, module: object) -> bool:
|
||||
"""Select the main and text-refiner transformer blocks for FSDP."""
|
||||
del module
|
||||
parts = name.split(".")
|
||||
return ((len(parts) == 2 and parts[0] == "transformer_blocks" and parts[1].isdigit())
|
||||
or (len(parts) == 3 and parts[:2] == ["token_refiner", "refiner_blocks"] and parts[2].isdigit()))
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3ArchConfig(DiTArchConfig):
|
||||
"""One-to-one representation of the released transformer config."""
|
||||
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [_is_minimax_h3_block])
|
||||
_supported_attention_backends: tuple[AttentionBackendEnum, ...] = (
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
AttentionBackendEnum.FLASH_ATTN,
|
||||
)
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^time_embedder\.linear_1\.(.*)$": r"time_embedder.fc_in.\1",
|
||||
r"^time_embedder\.linear_2\.(.*)$": r"time_embedder.fc_out.\1",
|
||||
r"^(.*)\.attn\.to_out\.0\.(.*)$": r"\1.attn.to_out.\2",
|
||||
r"^(.*)\.ff\.net\.0\.proj\.(.*)$": r"\1.ff.fc_in.\2",
|
||||
r"^(.*)\.ff\.net\.2\.(.*)$": r"\1.ff.fc_out.\2",
|
||||
})
|
||||
num_attention_heads: int = 56
|
||||
attention_head_dim: int = 128
|
||||
hidden_size: int = 5376
|
||||
num_layers: int = 50
|
||||
num_refiner_layers: int = 2
|
||||
ffn_dim: int = 14336
|
||||
in_channels: int = 24
|
||||
audio_in_channels: int = 32
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
text_dim: int = 5120
|
||||
freq_dim: int = 256
|
||||
time_embed_hidden_dim: int = 5376
|
||||
time_embed_dim: int = 2688
|
||||
rope_freq_dim: int = 16
|
||||
rope_theta: float = 10000.0
|
||||
norm_eps: float = 1e-5
|
||||
qk_norm_eps: float = 1e-5
|
||||
final_norm_eps: float = 1e-5
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if len(self.patch_size) != 3:
|
||||
raise ValueError(f"MiniMax H3 patch_size must have three axes, got {self.patch_size}.")
|
||||
self.patch_size = (self.patch_size[0], self.patch_size[1], self.patch_size[2])
|
||||
self.num_channels_latents = self.in_channels
|
||||
self.out_channels = self.in_channels
|
||||
rotary_dim = 2 * 3 * self.rope_freq_dim
|
||||
if rotary_dim > self.attention_head_dim or rotary_dim % 2:
|
||||
raise ValueError(f"MiniMax H3 rotary width must be even and no larger than the head width; got "
|
||||
f"rotary_dim={rotary_dim}, attention_head_dim={self.attention_head_dim}.")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3Config(DiTConfig):
|
||||
"""FastVideo component configuration for MiniMax H3 transformers."""
|
||||
|
||||
arch_config: MiniMaxH3ArchConfig = field(default_factory=MiniMaxH3ArchConfig)
|
||||
prefix: str = "minimax_h3"
|
||||
@@ -9,6 +9,7 @@ from fastvideo.configs.models.encoders.siglip import SiglipVisionConfig
|
||||
from fastvideo.configs.models.encoders.reason1 import Reason1ArchConfig, Reason1Config
|
||||
from fastvideo.configs.models.encoders.gemma import LTX2GemmaConfig
|
||||
from fastvideo.configs.models.encoders.mistral3 import Mistral3TextConfig
|
||||
from fastvideo.configs.models.encoders.minimax_h3_qwen3_vl import (MiniMaxH3Qwen3VLArchConfig, MiniMaxH3Qwen3VLConfig)
|
||||
from fastvideo.configs.models.encoders.qwen3 import Qwen3TextConfig
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLTextConfig
|
||||
from fastvideo.configs.models.encoders.stable_audio_conditioner import (StableAudioConditionerArchConfig,
|
||||
@@ -20,5 +21,6 @@ __all__ = [
|
||||
"CLIPVisionConfig", "WAN2_1ControlCLIPVisionConfig", "LlamaConfig", "T5Config", "T5LargeConfig", "Qwen2_5_VLConfig",
|
||||
"Reason1ArchConfig", "Reason1Config", "LTX2GemmaConfig", "SiglipVisionConfig", "StableAudioConditionerArchConfig",
|
||||
"StableAudioConditionerConfig", "T5GemmaEncoderConfig", "Qwen3TextConfig", "Mistral3TextConfig",
|
||||
"LingBotWorld2UMT5ArchConfig", "LingBotWorld2UMT5Config", "LingBotVideoQwen3VLTextConfig"
|
||||
"LingBotWorld2UMT5ArchConfig", "LingBotWorld2UMT5Config", "LingBotVideoQwen3VLTextConfig",
|
||||
"MiniMaxH3Qwen3VLArchConfig", "MiniMaxH3Qwen3VLConfig"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Qwen3-VL conditioner configuration for MiniMax H3."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastvideo.configs.models.encoders.base import TextEncoderArchConfig, TextEncoderConfig
|
||||
|
||||
|
||||
def _is_language_transformer_layer(name: str, module: Any) -> bool:
|
||||
parts = name.split(".")
|
||||
return len(parts) >= 2 and parts[-2] == "layers" and parts[-1].isdigit()
|
||||
|
||||
|
||||
def _is_vision_transformer_layer(name: str, module: Any) -> bool:
|
||||
parts = name.split(".")
|
||||
return len(parts) >= 2 and parts[-2] == "blocks" and parts[-1].isdigit()
|
||||
|
||||
|
||||
def _is_embeddings(name: str, module: Any) -> bool:
|
||||
return name.endswith(("embed_tokens", "patch_embed"))
|
||||
|
||||
|
||||
def _is_vision_merger(name: str, module: Any) -> bool:
|
||||
parts = name.split(".")
|
||||
return name.endswith("visual.merger") or (len(parts) >= 2 and parts[-2] == "deepstack_merger_list"
|
||||
and parts[-1].isdigit())
|
||||
|
||||
|
||||
def _is_final_norm(name: str, module: Any) -> bool:
|
||||
return name == "norm" or name.endswith("language_model.norm")
|
||||
|
||||
|
||||
_VISION_CONFIG_MAPPING = {
|
||||
"depth": "vision_depth",
|
||||
"hidden_size": "vision_hidden_size",
|
||||
"hidden_act": "vision_hidden_act",
|
||||
"intermediate_size": "vision_intermediate_size",
|
||||
"num_heads": "vision_num_heads",
|
||||
"in_channels": "vision_in_channels",
|
||||
"patch_size": "vision_patch_size",
|
||||
"spatial_merge_size": "vision_spatial_merge_size",
|
||||
"temporal_patch_size": "vision_temporal_patch_size",
|
||||
"out_hidden_size": "vision_out_hidden_size",
|
||||
"num_position_embeddings": "vision_num_position_embeddings",
|
||||
"initializer_range": "vision_initializer_range",
|
||||
"deepstack_visual_indexes": "vision_deepstack_visual_indexes",
|
||||
}
|
||||
|
||||
_OFFICIAL_ARCHITECTURES = {
|
||||
"Qwen3VLForConditionalGeneration",
|
||||
"Qwen3VLModel",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3Qwen3VLArchConfig(TextEncoderArchConfig):
|
||||
"""Architecture of the Qwen3-VL encoder used by MiniMax H3."""
|
||||
|
||||
architectures: list[str] = field(default_factory=lambda: ["MiniMaxH3Qwen3VLConditioner"])
|
||||
vocab_size: int = 151936
|
||||
hidden_size: int = 5120
|
||||
intermediate_size: int = 25600
|
||||
num_hidden_layers: int = 64
|
||||
num_attention_heads: int = 64
|
||||
num_key_value_heads: int = 8
|
||||
head_dim: int = 128
|
||||
hidden_act: str = "silu"
|
||||
max_position_embeddings: int = 262144
|
||||
initializer_range: float = 0.02
|
||||
rms_norm_eps: float = 1e-6
|
||||
use_cache: bool = True
|
||||
attention_bias: bool = False
|
||||
attention_dropout: float = 0.0
|
||||
rope_theta: float = 5000000.0
|
||||
rope_scaling: dict[str, Any] | None = field(default_factory=lambda: {
|
||||
"mrope_interleaved": True,
|
||||
"mrope_section": [24, 20, 20],
|
||||
"rope_type": "default",
|
||||
})
|
||||
mrope_interleaved: bool = True
|
||||
mrope_section: tuple[int, int, int] = (24, 20, 20)
|
||||
|
||||
bos_token_id: int = 151643
|
||||
eos_token_id: int = 151645
|
||||
pad_token_id: int | None = None
|
||||
vision_start_token_id: int = 151652
|
||||
vision_end_token_id: int = 151653
|
||||
image_token_id: int = 151655
|
||||
video_token_id: int = 151656
|
||||
tie_word_embeddings: bool = False
|
||||
|
||||
vision_depth: int = 27
|
||||
vision_hidden_size: int = 1152
|
||||
vision_hidden_act: str = "gelu_pytorch_tanh"
|
||||
vision_intermediate_size: int = 4304
|
||||
vision_num_heads: int = 16
|
||||
vision_in_channels: int = 3
|
||||
vision_patch_size: int = 16
|
||||
vision_spatial_merge_size: int = 2
|
||||
vision_temporal_patch_size: int = 2
|
||||
vision_out_hidden_size: int = 5120
|
||||
vision_num_position_embeddings: int = 2304
|
||||
vision_initializer_range: float = 0.02
|
||||
vision_deepstack_visual_indexes: tuple[int, ...] = (8, 16, 24)
|
||||
|
||||
output_hidden_states: bool = True
|
||||
stacked_params_mapping: list[tuple[str, str, str | int]] = field(default_factory=list)
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [
|
||||
_is_language_transformer_layer,
|
||||
_is_vision_transformer_layer,
|
||||
_is_embeddings,
|
||||
_is_vision_merger,
|
||||
_is_final_norm,
|
||||
])
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
rope_scaling = dict(self.rope_scaling or {})
|
||||
self.mrope_interleaved = bool(rope_scaling.get("mrope_interleaved", self.mrope_interleaved))
|
||||
if not self.mrope_interleaved:
|
||||
raise ValueError("MiniMax H3 Qwen3-VL only supports interleaved multimodal RoPE.")
|
||||
section = rope_scaling.get("mrope_section", self.mrope_section)
|
||||
if not isinstance(section, list | tuple) or len(section) != 3:
|
||||
raise ValueError("MiniMax H3 Qwen3-VL mRoPE requires exactly three section sizes.")
|
||||
self.mrope_section = (int(section[0]), int(section[1]), int(section[2]))
|
||||
if sum(self.mrope_section) * 2 != self.head_dim:
|
||||
raise ValueError("MiniMax H3 Qwen3-VL mRoPE sections must cover exactly half of each attention head.")
|
||||
rope_scaling["mrope_interleaved"] = self.mrope_interleaved
|
||||
rope_scaling["mrope_section"] = list(self.mrope_section)
|
||||
rope_scaling.setdefault("rope_type", "default")
|
||||
self.rope_scaling = rope_scaling
|
||||
|
||||
if self.vision_out_hidden_size != self.hidden_size:
|
||||
raise ValueError("MiniMax H3 Qwen3-VL vision_out_hidden_size must match the language hidden_size "
|
||||
"for visual-token and DeepStack injection.")
|
||||
|
||||
# H3 builds its presentation in the pipeline stage and tokenizes each
|
||||
# segment verbatim without adding tokenizer-owned presentation tokens.
|
||||
self.tokenizer_kwargs = {"add_special_tokens": False}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3Qwen3VLConfig(TextEncoderConfig):
|
||||
"""FastVideo loader config for H3's base Qwen3-VL encoder."""
|
||||
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=MiniMaxH3Qwen3VLArchConfig)
|
||||
prefix: str = "minimax_h3_qwen3_vl"
|
||||
is_chat_model: bool = False
|
||||
|
||||
def update_model_arch(self, source_model_dict: dict[str, Any]) -> None:
|
||||
flattened = dict(source_model_dict)
|
||||
|
||||
text_config = flattened.pop("text_config", {})
|
||||
flattened.update(dict(text_config))
|
||||
|
||||
vision_config = flattened.pop("vision_config", {})
|
||||
for source_name, target_name in _VISION_CONFIG_MAPPING.items():
|
||||
if source_name in vision_config:
|
||||
flattened[target_name] = vision_config[source_name]
|
||||
|
||||
architectures = flattened.get("architectures")
|
||||
if isinstance(architectures, str):
|
||||
architectures = [architectures]
|
||||
if isinstance(architectures, list | tuple) and any(architecture in _OFFICIAL_ARCHITECTURES
|
||||
for architecture in architectures):
|
||||
flattened["architectures"] = ["MiniMaxH3Qwen3VLConditioner"]
|
||||
|
||||
section = flattened.get("mrope_section")
|
||||
if isinstance(section, list):
|
||||
flattened["mrope_section"] = tuple(section)
|
||||
deepstack_indexes = flattened.get("vision_deepstack_visual_indexes")
|
||||
if isinstance(deepstack_indexes, list):
|
||||
flattened["vision_deepstack_visual_indexes"] = tuple(deepstack_indexes)
|
||||
|
||||
super().update_model_arch(flattened)
|
||||
@@ -6,6 +6,11 @@ from fastvideo.configs.models.vaes.glm_image import GlmImageVAEConfig
|
||||
from fastvideo.configs.models.vaes.hunyuanvae import HunyuanVAEConfig
|
||||
from fastvideo.configs.models.vaes.hunyuan15vae import Hunyuan15VAEConfig
|
||||
from fastvideo.configs.models.vaes.ltx2vae import LTX2VAEConfig
|
||||
from fastvideo.configs.models.vaes.minimax_h3_audio import MiniMaxH3AudioVAEArchConfig, MiniMaxH3AudioVAEConfig
|
||||
from fastvideo.configs.models.vaes.minimax_h3_video import (
|
||||
MiniMaxH3VideoVAEArchConfig,
|
||||
MiniMaxH3VideoVAEConfig,
|
||||
)
|
||||
from fastvideo.configs.models.vaes.oobleck import OobleckVAEArchConfig, OobleckVAEConfig
|
||||
from fastvideo.configs.models.vaes.flux2vae import Flux2VAEConfig
|
||||
from fastvideo.configs.models.vaes.wanvae import WanVAEConfig
|
||||
@@ -19,6 +24,10 @@ __all__ = [
|
||||
"Gen3CVAEConfig",
|
||||
"Hunyuan15VAEConfig",
|
||||
"LTX2VAEConfig",
|
||||
"MiniMaxH3AudioVAEArchConfig",
|
||||
"MiniMaxH3AudioVAEConfig",
|
||||
"MiniMaxH3VideoVAEArchConfig",
|
||||
"MiniMaxH3VideoVAEConfig",
|
||||
"OobleckVAEArchConfig",
|
||||
"OobleckVAEConfig",
|
||||
"Flux2VAEConfig",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Copyright 2025 MiniMax authors and HuggingFace Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastvideo.configs.models.vaes.base import VAEArchConfig, VAEConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3AudioVAEArchConfig(VAEArchConfig):
|
||||
"""Architecture of the MiniMax H3 waveform autoencoder."""
|
||||
|
||||
_class_name: str = "AutoencoderKLMiniMaxH3Audio"
|
||||
architectures: list[str] = field(default_factory=lambda: ["AutoencoderKLMiniMaxH3Audio"])
|
||||
|
||||
encoder_dim: int = 64
|
||||
encoder_rates: tuple[int, ...] | list[int] = (2, 4, 4, 5, 5)
|
||||
latent_dim: int = 2048
|
||||
latent_channels: int = 32
|
||||
num_attention_heads: int = 8
|
||||
|
||||
decoder_dim: int = 1024
|
||||
decoder_rates: tuple[int, ...] | list[int] = (5, 5, 2, 2, 2, 2, 2)
|
||||
decoder_kernel_sizes: tuple[int, ...] | list[int] = (9, 9, 4, 4, 4, 4, 4)
|
||||
resblock_kernel_sizes: tuple[int, ...] | list[int] = (3, 7, 11)
|
||||
resblock_dilation_sizes: tuple[tuple[int, ...], ...] | list[list[int]] = (
|
||||
(1, 3, 5),
|
||||
(1, 3, 5),
|
||||
(1, 3, 5),
|
||||
)
|
||||
|
||||
sampling_rate: int = 32000
|
||||
latents_mean: tuple[float, ...] | list[float] | None = None
|
||||
latents_std: tuple[float, ...] | list[float] | None = None
|
||||
|
||||
scaling_factor: float = 1.0
|
||||
temporal_compression_ratio: int = 1
|
||||
spatial_compression_ratio: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3AudioVAEConfig(VAEConfig):
|
||||
"""FastVideo loader configuration for the full H3 audio VAE."""
|
||||
|
||||
arch_config: MiniMaxH3AudioVAEArchConfig = field(default_factory=MiniMaxH3AudioVAEArchConfig)
|
||||
|
||||
use_tiling: bool = False
|
||||
use_temporal_tiling: bool = False
|
||||
use_parallel_tiling: bool = False
|
||||
@@ -0,0 +1,71 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax-H3 video VAE configuration."""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastvideo.configs.models.vaes.base import VAEArchConfig, VAEConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3VideoVAEArchConfig(VAEArchConfig):
|
||||
"""Architecture fields from ``AutoencoderKLMiniMaxH3``."""
|
||||
|
||||
_class_name: str = "AutoencoderKLMiniMaxH3"
|
||||
in_channels: int = 3
|
||||
out_channels: int = 3
|
||||
latent_channels: int = 24
|
||||
block_out_channels: tuple[int, ...] = (128, 256, 256, 512, 512, 1024)
|
||||
layers_per_block: int = 2
|
||||
spatial_downsample_factors: tuple[int, ...] = (2, 2, 2, 2, 1, 1)
|
||||
temporal_downsample_factors: tuple[int, ...] = (1, 2, 2, 1, 1, 1)
|
||||
norm_num_groups: int = 32
|
||||
norm_eps: float = 1e-6
|
||||
spatial_padding_mode: str = "reflect"
|
||||
|
||||
decoder_num_layers: int = 36
|
||||
decoder_num_attention_heads: int = 32
|
||||
decoder_attention_head_dim: int = 64
|
||||
decoder_num_register_tokens: int = 4
|
||||
decoder_ffn_mult: int = 4
|
||||
decoder_rope_theta: float = 100.0
|
||||
decoder_rope_dim_ratio: float = 0.75
|
||||
decoder_norm_eps: float = 1e-5
|
||||
|
||||
clip_length: int = 17
|
||||
token_drop: int = 3
|
||||
latents_mean: tuple[float, ...] = (0.0, ) * 24
|
||||
latents_std: tuple[float, ...] = (1.0, ) * 24
|
||||
|
||||
# MiniMax-H3 uses per-channel mean/std instead of a scalar latent scale.
|
||||
scaling_factor: float = 1.0
|
||||
temporal_compression_ratio: int = 4
|
||||
spatial_compression_ratio: int = 16
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
num_blocks = len(self.block_out_channels)
|
||||
if len(self.spatial_downsample_factors) != num_blocks:
|
||||
raise ValueError("`spatial_downsample_factors` must have one entry per encoder block.")
|
||||
if len(self.temporal_downsample_factors) != num_blocks:
|
||||
raise ValueError("`temporal_downsample_factors` must have one entry per encoder block.")
|
||||
if len(self.latents_mean) != self.latent_channels:
|
||||
raise ValueError("`latents_mean` must have one entry per latent channel.")
|
||||
if len(self.latents_std) != self.latent_channels:
|
||||
raise ValueError("`latents_std` must have one entry per latent channel.")
|
||||
|
||||
self.spatial_compression_ratio = math.prod(self.spatial_downsample_factors)
|
||||
self.temporal_compression_ratio = math.prod(self.temporal_downsample_factors)
|
||||
|
||||
rotary_dim = int(self.decoder_attention_head_dim * self.decoder_rope_dim_ratio)
|
||||
if rotary_dim % 6 != 0:
|
||||
raise ValueError("MiniMax-H3 decoder rotary dimensions must be divisible by 6.")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3VideoVAEConfig(VAEConfig):
|
||||
"""FastVideo runtime controls for the MiniMax-H3 video VAE."""
|
||||
|
||||
arch_config: MiniMaxH3VideoVAEArchConfig = field(default_factory=MiniMaxH3VideoVAEArchConfig)
|
||||
|
||||
tile_sample_min_overlap_height: int = 64
|
||||
tile_sample_min_overlap_width: int = 64
|
||||
@@ -0,0 +1,38 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pipeline configuration for MiniMax H3 joint video/audio generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastvideo.configs.models import EncoderConfig, VAEConfig
|
||||
from fastvideo.configs.models.dits.minimax_h3 import MiniMaxH3Config
|
||||
from fastvideo.configs.models.encoders.minimax_h3_qwen3_vl import MiniMaxH3Qwen3VLConfig
|
||||
from fastvideo.configs.models.vaes.minimax_h3_audio import MiniMaxH3AudioVAEConfig
|
||||
from fastvideo.configs.models.vaes.minimax_h3_video import MiniMaxH3VideoVAEConfig
|
||||
from fastvideo.configs.pipelines.base import PipelineConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3PipelineConfig(PipelineConfig):
|
||||
"""Component and precision policy shared by T2VA, FL2VA, and Ref2VA."""
|
||||
|
||||
dit_config: MiniMaxH3Config = field(default_factory=MiniMaxH3Config)
|
||||
vae_config: VAEConfig = field(default_factory=MiniMaxH3VideoVAEConfig)
|
||||
audio_vae_config: VAEConfig = field(default_factory=MiniMaxH3AudioVAEConfig)
|
||||
text_encoder_configs: tuple[EncoderConfig, ...] = field(default_factory=lambda: (MiniMaxH3Qwen3VLConfig(), ))
|
||||
|
||||
flow_shift: float | None = None
|
||||
embedded_cfg_scale: float | None = None
|
||||
dit_precision: str = "bf16"
|
||||
vae_precision: str = "fp32"
|
||||
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16", ))
|
||||
vae_sp: bool = False
|
||||
|
||||
def check_pipeline_config(self) -> None:
|
||||
super().check_pipeline_config()
|
||||
if self.flow_shift is not None:
|
||||
raise ValueError("MiniMax-H3 uses fixed video/audio scheduler shifts; flow_shift must remain unset.")
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3PipelineConfig"]
|
||||
@@ -0,0 +1,661 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""FastVideo-native MiniMax H3 joint audio-video diffusion transformer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
from fastvideo.attention import DistributedAttention
|
||||
from fastvideo.configs.models.dits.minimax_h3 import MiniMaxH3Config
|
||||
from fastvideo.distributed.communication_op import (
|
||||
sequence_model_parallel_all_gather_with_unpad,
|
||||
sequence_model_parallel_shard,
|
||||
)
|
||||
from fastvideo.distributed.parallel_state import get_sp_world_size, model_parallel_is_initialized
|
||||
from fastvideo.layers.linear import ReplicatedLinear
|
||||
from fastvideo.layers.quantization import QuantizationConfig
|
||||
from fastvideo.layers.visual_embedding import Timesteps
|
||||
from fastvideo.models.dits.base import BaseDiT
|
||||
from fastvideo.platforms import AttentionBackendEnum
|
||||
|
||||
|
||||
MINIMAX_H3_MODALITY_NUM = 3
|
||||
_CFG = MiniMaxH3Config()
|
||||
|
||||
|
||||
class MiniMaxH3RotaryPosEmbed(nn.Module):
|
||||
"""Three-axis rotary frequencies over packed `(t, h, w)` coordinates."""
|
||||
|
||||
def __init__(self, rope_freq_dim: int, rope_theta: float) -> None:
|
||||
super().__init__()
|
||||
inv_freq = 1.0 / (
|
||||
rope_theta ** (torch.arange(0, 2 * rope_freq_dim, 2, dtype=torch.float32) / (2 * rope_freq_dim))
|
||||
)
|
||||
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
||||
|
||||
def forward(self, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
position_ids = position_ids.to(torch.float32)
|
||||
freqs = position_ids.unsqueeze(-1) * self.inv_freq.view(1, 1, -1)
|
||||
freqs_t, freqs_h, freqs_w = freqs.unbind(dim=1)
|
||||
freqs = torch.cat((freqs_t, freqs_h, freqs_w), dim=-1)
|
||||
freqs = torch.cat((freqs, freqs), dim=-1)
|
||||
return freqs.cos(), freqs.sin()
|
||||
|
||||
|
||||
class MiniMaxH3TimestepEmbedding(nn.Module):
|
||||
"""Linear-SiLU-linear timestep MLP."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_dim: int,
|
||||
hidden_dim: int,
|
||||
output_dim: int,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.fc_in = ReplicatedLinear(
|
||||
input_dim,
|
||||
hidden_dim,
|
||||
bias=True,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.fc_in",
|
||||
)
|
||||
self.fc_out = ReplicatedLinear(
|
||||
hidden_dim,
|
||||
output_dim,
|
||||
bias=True,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.fc_out",
|
||||
)
|
||||
|
||||
def forward(self, sample: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states, _ = self.fc_in(sample)
|
||||
hidden_states, _ = self.fc_out(F.silu(hidden_states))
|
||||
return hidden_states
|
||||
|
||||
|
||||
class MiniMaxH3FeedForward(nn.Module):
|
||||
"""Bias-free H3 SwiGLU with value-first packed halves."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
ffn_dim: int,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.fc_in = ReplicatedLinear(
|
||||
hidden_size,
|
||||
2 * ffn_dim,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.fc_in",
|
||||
)
|
||||
self.fc_out = ReplicatedLinear(
|
||||
ffn_dim,
|
||||
hidden_size,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.fc_out",
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states, _ = self.fc_in(hidden_states)
|
||||
hidden_states, gate = hidden_states.chunk(2, dim=-1)
|
||||
hidden_states = hidden_states * F.silu(gate)
|
||||
hidden_states, _ = self.fc_out(hidden_states)
|
||||
return hidden_states
|
||||
|
||||
|
||||
class MiniMaxH3Attention(nn.Module):
|
||||
"""Full self-attention over one sequence-parallel packed document."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
num_attention_heads: int,
|
||||
attention_head_dim: int,
|
||||
qk_norm_eps: float,
|
||||
supported_attention_backends: tuple[AttentionBackendEnum, ...],
|
||||
quant_config: QuantizationConfig | None,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.num_attention_heads = num_attention_heads
|
||||
self.attention_head_dim = attention_head_dim
|
||||
inner_dim = num_attention_heads * attention_head_dim
|
||||
self.to_q = ReplicatedLinear(
|
||||
hidden_size,
|
||||
inner_dim,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.to_q",
|
||||
)
|
||||
self.to_k = ReplicatedLinear(
|
||||
hidden_size,
|
||||
inner_dim,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.to_k",
|
||||
)
|
||||
self.to_v = ReplicatedLinear(
|
||||
hidden_size,
|
||||
inner_dim,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.to_v",
|
||||
)
|
||||
self.norm_q = nn.RMSNorm(attention_head_dim, eps=qk_norm_eps)
|
||||
self.norm_k = nn.RMSNorm(attention_head_dim, eps=qk_norm_eps)
|
||||
self.to_out = ReplicatedLinear(
|
||||
inner_dim,
|
||||
hidden_size,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.to_out",
|
||||
)
|
||||
self.distributed_attention = DistributedAttention(
|
||||
num_heads=num_attention_heads,
|
||||
head_size=attention_head_dim,
|
||||
causal=False,
|
||||
supported_attention_backends=supported_attention_backends,
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_rotary_emb(
|
||||
hidden_states: torch.Tensor,
|
||||
rotary_emb: tuple[torch.Tensor, torch.Tensor],
|
||||
) -> torch.Tensor:
|
||||
"""Rotate the RoPE prefix while preserving the rest of each attention head."""
|
||||
cos, sin = rotary_emb
|
||||
rotary_dim = cos.shape[-1]
|
||||
hidden_states_rotary = hidden_states[..., :rotary_dim]
|
||||
hidden_states_pass = hidden_states[..., rotary_dim:]
|
||||
cos = cos.to(hidden_states.dtype)[None, :, None, :]
|
||||
sin = sin.to(hidden_states.dtype)[None, :, None, :]
|
||||
first_half, second_half = hidden_states_rotary.chunk(2, dim=-1)
|
||||
hidden_states_rotated = torch.cat((-second_half, first_half), dim=-1)
|
||||
hidden_states_rotary = hidden_states_rotary * cos + hidden_states_rotated * sin
|
||||
return torch.cat((hidden_states_rotary, hidden_states_pass), dim=-1).contiguous()
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
rotary_emb: tuple[torch.Tensor, torch.Tensor] | None,
|
||||
original_seq_len: int,
|
||||
) -> torch.Tensor:
|
||||
query, _ = self.to_q(hidden_states)
|
||||
key, _ = self.to_k(hidden_states)
|
||||
value, _ = self.to_v(hidden_states)
|
||||
query = query.unflatten(-1, (self.num_attention_heads, self.attention_head_dim))
|
||||
key = key.unflatten(-1, (self.num_attention_heads, self.attention_head_dim))
|
||||
value = value.unflatten(-1, (self.num_attention_heads, self.attention_head_dim))
|
||||
query = self.norm_q(query)
|
||||
key = self.norm_k(key)
|
||||
if rotary_emb is not None:
|
||||
query = self._apply_rotary_emb(query, rotary_emb)
|
||||
key = self._apply_rotary_emb(key, rotary_emb)
|
||||
|
||||
# H3 rotates only 96/128 channels, which the generic `freqs_cis`
|
||||
# branch cannot express. Apply it above, then pass no RoPE here.
|
||||
hidden_states, _ = self.distributed_attention(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
original_seq_len=original_seq_len,
|
||||
freqs_cis=None,
|
||||
)
|
||||
hidden_states = hidden_states.flatten(2, 3).type_as(query)
|
||||
hidden_states, _ = self.to_out(hidden_states)
|
||||
return hidden_states
|
||||
|
||||
|
||||
class MiniMaxH3TokenRefinerBlock(nn.Module):
|
||||
"""Plain pre-norm Transformer block for the projected text stream."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
num_attention_heads: int,
|
||||
attention_head_dim: int,
|
||||
ffn_dim: int,
|
||||
norm_eps: float,
|
||||
qk_norm_eps: float,
|
||||
supported_attention_backends: tuple[AttentionBackendEnum, ...],
|
||||
quant_config: QuantizationConfig | None,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.norm1 = nn.RMSNorm(hidden_size, eps=norm_eps)
|
||||
self.attn = MiniMaxH3Attention(
|
||||
hidden_size,
|
||||
num_attention_heads,
|
||||
attention_head_dim,
|
||||
qk_norm_eps,
|
||||
supported_attention_backends,
|
||||
quant_config,
|
||||
prefix=f"{prefix}.attn",
|
||||
)
|
||||
self.norm2 = nn.RMSNorm(hidden_size, eps=norm_eps)
|
||||
self.ff = MiniMaxH3FeedForward(
|
||||
hidden_size,
|
||||
ffn_dim,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.ff",
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor, original_seq_len: int) -> torch.Tensor:
|
||||
hidden_states = hidden_states + self.attn(self.norm1(hidden_states), None, original_seq_len)
|
||||
return hidden_states + self.ff(self.norm2(hidden_states))
|
||||
|
||||
|
||||
class MiniMaxH3TokenRefiner(nn.Module):
|
||||
"""Two-block text refiner used before packing the modalities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
num_attention_heads: int,
|
||||
attention_head_dim: int,
|
||||
ffn_dim: int,
|
||||
num_layers: int,
|
||||
norm_eps: float,
|
||||
qk_norm_eps: float,
|
||||
final_norm_eps: float,
|
||||
supported_attention_backends: tuple[AttentionBackendEnum, ...],
|
||||
quant_config: QuantizationConfig | None,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.refiner_blocks = nn.ModuleList([
|
||||
MiniMaxH3TokenRefinerBlock(
|
||||
hidden_size,
|
||||
num_attention_heads,
|
||||
attention_head_dim,
|
||||
ffn_dim,
|
||||
norm_eps,
|
||||
qk_norm_eps,
|
||||
supported_attention_backends,
|
||||
quant_config,
|
||||
prefix=f"{prefix}.refiner_blocks.{index}",
|
||||
) for index in range(num_layers)
|
||||
])
|
||||
self.final_norm = nn.RMSNorm(hidden_size, eps=final_norm_eps)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor, original_seq_len: int) -> torch.Tensor:
|
||||
for block in self.refiner_blocks:
|
||||
hidden_states = block(hidden_states, original_seq_len)
|
||||
return self.final_norm(hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3AdaLayerNormModulation(nn.Module):
|
||||
"""Produce six modulation tables for every `(timestep, modality)` pair."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
time_embed_dim: int,
|
||||
hidden_size: int,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.hidden_size = hidden_size
|
||||
self.linear = ReplicatedLinear(
|
||||
time_embed_dim,
|
||||
6 * hidden_size * MINIMAX_H3_MODALITY_NUM,
|
||||
bias=True,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.linear",
|
||||
)
|
||||
|
||||
def forward(self, temb: torch.Tensor) -> tuple[torch.Tensor, ...]:
|
||||
temb, _ = self.linear(F.silu(temb).to(self.linear.weight.dtype))
|
||||
return temb.view(-1, 6 * self.hidden_size).chunk(6, dim=-1)
|
||||
|
||||
|
||||
class MiniMaxH3AdaLayerNormOut(nn.Module):
|
||||
"""Final RMSNorm with per-timestep row modulation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
time_embed_dim: int,
|
||||
eps: float,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.norm = nn.RMSNorm(hidden_size, eps=eps)
|
||||
self.linear = ReplicatedLinear(
|
||||
time_embed_dim,
|
||||
2 * hidden_size,
|
||||
bias=True,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.linear",
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
temb: torch.Tensor,
|
||||
timestep_indices: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
shift_scale, _ = self.linear(F.silu(temb).to(self.linear.weight.dtype))
|
||||
shift, scale = shift_scale.chunk(2, dim=-1)
|
||||
hidden_states = self.norm(hidden_states)
|
||||
return hidden_states * (1.0 + scale.index_select(0, timestep_indices)) + shift.index_select(
|
||||
0, timestep_indices
|
||||
)
|
||||
|
||||
|
||||
class MiniMaxH3TransformerBlock(nn.Module):
|
||||
"""Packed self-attention and feed-forward branches with row-indexed AdaLN."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
num_attention_heads: int,
|
||||
attention_head_dim: int,
|
||||
ffn_dim: int,
|
||||
time_embed_dim: int,
|
||||
norm_eps: float,
|
||||
qk_norm_eps: float,
|
||||
supported_attention_backends: tuple[AttentionBackendEnum, ...],
|
||||
quant_config: QuantizationConfig | None,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.norm1 = nn.RMSNorm(hidden_size, eps=norm_eps)
|
||||
self.attn = MiniMaxH3Attention(
|
||||
hidden_size,
|
||||
num_attention_heads,
|
||||
attention_head_dim,
|
||||
qk_norm_eps,
|
||||
supported_attention_backends,
|
||||
quant_config,
|
||||
prefix=f"{prefix}.attn",
|
||||
)
|
||||
self.norm2 = nn.RMSNorm(hidden_size, eps=norm_eps)
|
||||
self.ff = MiniMaxH3FeedForward(
|
||||
hidden_size,
|
||||
ffn_dim,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.ff",
|
||||
)
|
||||
self.adaln_proj = MiniMaxH3AdaLayerNormModulation(
|
||||
time_embed_dim,
|
||||
hidden_size,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.adaln_proj",
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
temb: torch.Tensor,
|
||||
adaln_indices: torch.Tensor,
|
||||
rotary_emb: tuple[torch.Tensor, torch.Tensor],
|
||||
original_seq_len: int,
|
||||
) -> torch.Tensor:
|
||||
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.adaln_proj(temb)
|
||||
|
||||
residual = hidden_states
|
||||
norm_hidden_states = self.norm1(hidden_states)
|
||||
norm_hidden_states = norm_hidden_states * (
|
||||
1.0 + scale_msa.index_select(0, adaln_indices)
|
||||
) + shift_msa.index_select(0, adaln_indices)
|
||||
attention_output = self.attn(norm_hidden_states, rotary_emb, original_seq_len)
|
||||
hidden_states = residual + gate_msa.index_select(0, adaln_indices) * attention_output
|
||||
|
||||
residual = hidden_states
|
||||
norm_hidden_states = self.norm2(hidden_states)
|
||||
norm_hidden_states = norm_hidden_states * (
|
||||
1.0 + scale_mlp.index_select(0, adaln_indices)
|
||||
) + shift_mlp.index_select(0, adaln_indices)
|
||||
feed_forward_output = self.ff(norm_hidden_states)
|
||||
return residual + gate_mlp.index_select(0, adaln_indices) * feed_forward_output
|
||||
|
||||
|
||||
class MiniMaxH3Transformer3DModel(BaseDiT):
|
||||
"""Joint H3 Transformer over one padless text/audio/video document.
|
||||
|
||||
The layout builder validates semantic rows before denoising. Sequence-
|
||||
parallel padding is transport-only and `DistributedAttention` trims it
|
||||
before attention.
|
||||
"""
|
||||
|
||||
_fsdp_shard_conditions = _CFG._fsdp_shard_conditions
|
||||
_compile_conditions = _CFG._compile_conditions
|
||||
_supported_attention_backends = _CFG._supported_attention_backends
|
||||
param_names_mapping = _CFG.param_names_mapping
|
||||
reverse_param_names_mapping = _CFG.reverse_param_names_mapping
|
||||
lora_param_names_mapping = _CFG.lora_param_names_mapping
|
||||
_keep_in_fp32_modules = frozenset({
|
||||
"proj_in",
|
||||
"audio_proj_in",
|
||||
"time_embedder",
|
||||
"proj_out",
|
||||
"audio_proj_out",
|
||||
"rope",
|
||||
})
|
||||
|
||||
def _get_parameter_dtype(self, name: str, default_dtype: torch.dtype) -> torch.dtype:
|
||||
"""Keep the released input, timestep, and output projections in FP32."""
|
||||
return torch.float32 if name.split(".", 1)[0] in self._keep_in_fp32_modules else default_dtype
|
||||
|
||||
def __init__(self, config: MiniMaxH3Config, hf_config: dict[str, Any]) -> None:
|
||||
super().__init__(config, hf_config)
|
||||
arch = config.arch_config
|
||||
sp_world_size = get_sp_world_size() if model_parallel_is_initialized() else 1
|
||||
if arch.num_attention_heads % sp_world_size:
|
||||
raise ValueError(
|
||||
f"MiniMax H3 attention heads ({arch.num_attention_heads}) must be divisible by "
|
||||
f"sequence parallel size ({sp_world_size})."
|
||||
)
|
||||
|
||||
self.hidden_size = arch.hidden_size
|
||||
self.num_attention_heads = arch.num_attention_heads
|
||||
self.num_channels_latents = arch.in_channels
|
||||
self.patch_size = tuple(int(value) for value in arch.patch_size)
|
||||
video_patch_dim = arch.in_channels * math.prod(arch.patch_size)
|
||||
|
||||
self.proj_in = ReplicatedLinear(
|
||||
video_patch_dim,
|
||||
arch.hidden_size,
|
||||
bias=True,
|
||||
quant_config=config.quant_config,
|
||||
prefix=f"{config.prefix}.proj_in",
|
||||
)
|
||||
self.audio_proj_in = ReplicatedLinear(
|
||||
arch.audio_in_channels,
|
||||
arch.hidden_size,
|
||||
bias=True,
|
||||
quant_config=config.quant_config,
|
||||
prefix=f"{config.prefix}.audio_proj_in",
|
||||
)
|
||||
self.context_embedder = ReplicatedLinear(
|
||||
arch.text_dim,
|
||||
arch.hidden_size,
|
||||
bias=True,
|
||||
quant_config=config.quant_config,
|
||||
prefix=f"{config.prefix}.context_embedder",
|
||||
)
|
||||
self.time_proj = Timesteps(
|
||||
num_channels=arch.freq_dim,
|
||||
flip_sin_to_cos=True,
|
||||
downscale_freq_shift=0,
|
||||
)
|
||||
self.time_embedder = MiniMaxH3TimestepEmbedding(
|
||||
arch.freq_dim,
|
||||
arch.time_embed_hidden_dim,
|
||||
arch.time_embed_dim,
|
||||
quant_config=config.quant_config,
|
||||
prefix=f"{config.prefix}.time_embedder",
|
||||
)
|
||||
self.rope = MiniMaxH3RotaryPosEmbed(arch.rope_freq_dim, arch.rope_theta)
|
||||
self.token_refiner = MiniMaxH3TokenRefiner(
|
||||
arch.hidden_size,
|
||||
arch.num_attention_heads,
|
||||
arch.attention_head_dim,
|
||||
arch.ffn_dim,
|
||||
arch.num_refiner_layers,
|
||||
arch.norm_eps,
|
||||
arch.qk_norm_eps,
|
||||
arch.final_norm_eps,
|
||||
self.supported_attention_backends,
|
||||
config.quant_config,
|
||||
prefix=f"{config.prefix}.token_refiner",
|
||||
)
|
||||
self.transformer_blocks = nn.ModuleList([
|
||||
MiniMaxH3TransformerBlock(
|
||||
arch.hidden_size,
|
||||
arch.num_attention_heads,
|
||||
arch.attention_head_dim,
|
||||
arch.ffn_dim,
|
||||
arch.time_embed_dim,
|
||||
arch.norm_eps,
|
||||
arch.qk_norm_eps,
|
||||
self.supported_attention_backends,
|
||||
config.quant_config,
|
||||
prefix=f"{config.prefix}.transformer_blocks.{index}",
|
||||
) for index in range(arch.num_layers)
|
||||
])
|
||||
self.norm_out = MiniMaxH3AdaLayerNormOut(
|
||||
arch.hidden_size,
|
||||
arch.time_embed_dim,
|
||||
arch.final_norm_eps,
|
||||
quant_config=config.quant_config,
|
||||
prefix=f"{config.prefix}.norm_out",
|
||||
)
|
||||
self.proj_out = ReplicatedLinear(
|
||||
arch.hidden_size,
|
||||
video_patch_dim,
|
||||
bias=True,
|
||||
quant_config=config.quant_config,
|
||||
prefix=f"{config.prefix}.proj_out",
|
||||
)
|
||||
self.audio_proj_out = ReplicatedLinear(
|
||||
arch.hidden_size,
|
||||
arch.audio_in_channels,
|
||||
bias=True,
|
||||
quant_config=config.quant_config,
|
||||
prefix=f"{config.prefix}.audio_proj_out",
|
||||
)
|
||||
self.__post_init__()
|
||||
|
||||
def materialize_non_persistent_buffers(
|
||||
self,
|
||||
device: torch.device,
|
||||
dtype: torch.dtype | None = None,
|
||||
) -> None:
|
||||
"""Rebuild analytic RoPE state after meta-device checkpoint loading."""
|
||||
del dtype
|
||||
if self.rope.inv_freq.is_meta:
|
||||
arch = self.config.arch_config
|
||||
inv_freq = 1.0 / (
|
||||
arch.rope_theta
|
||||
** (
|
||||
torch.arange(0, 2 * arch.rope_freq_dim, 2, device=device, dtype=torch.float32)
|
||||
/ (2 * arch.rope_freq_dim)
|
||||
)
|
||||
)
|
||||
self.rope._buffers["inv_freq"] = inv_freq
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
audio_hidden_states: torch.Tensor,
|
||||
encoder_hidden_states: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
timestep_indices: torch.Tensor,
|
||||
token_tags: torch.Tensor,
|
||||
position_ids: torch.Tensor,
|
||||
video_indices: torch.Tensor,
|
||||
audio_indices: torch.Tensor,
|
||||
text_indices: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Predict video and audio velocities from one caller-defined packed layout."""
|
||||
if position_ids.ndim != 2 or position_ids.shape[-1] != 3:
|
||||
raise ValueError(f"position_ids must have shape (seq_len, 3), got {tuple(position_ids.shape)}.")
|
||||
sequence_length = position_ids.shape[0]
|
||||
if token_tags.shape != (sequence_length,) or timestep_indices.shape != (sequence_length,):
|
||||
raise ValueError("token_tags and timestep_indices must both match the packed sequence length.")
|
||||
if hidden_states.shape[1] != video_indices.numel():
|
||||
raise ValueError("hidden_states row count must match video_indices.")
|
||||
if audio_hidden_states.shape[1] != audio_indices.numel():
|
||||
raise ValueError("audio_hidden_states row count must match audio_indices.")
|
||||
if encoder_hidden_states.shape[1] != text_indices.numel():
|
||||
raise ValueError("encoder_hidden_states row count must match text_indices.")
|
||||
|
||||
rotary_emb = self.rope(position_ids)
|
||||
video_embeds, _ = self.proj_in(hidden_states.to(self.proj_in.weight.dtype))
|
||||
audio_embeds, _ = self.audio_proj_in(audio_hidden_states.to(self.audio_proj_in.weight.dtype))
|
||||
text_embeds, _ = self.context_embedder(encoder_hidden_states.to(self.context_embedder.weight.dtype))
|
||||
|
||||
text_original_seq_len = text_embeds.shape[1]
|
||||
sp_world_size = get_sp_world_size() if model_parallel_is_initialized() else 1
|
||||
if sp_world_size > 1:
|
||||
text_embeds, _ = sequence_model_parallel_shard(text_embeds, dim=1)
|
||||
text_embeds = self.token_refiner(text_embeds, text_original_seq_len)
|
||||
if sp_world_size > 1:
|
||||
text_embeds = sequence_model_parallel_all_gather_with_unpad(
|
||||
text_embeds,
|
||||
text_original_seq_len,
|
||||
dim=1,
|
||||
)
|
||||
|
||||
packed_hidden_states = text_embeds.new_zeros(
|
||||
(text_embeds.shape[0], sequence_length, text_embeds.shape[-1])
|
||||
)
|
||||
packed_hidden_states = packed_hidden_states.index_copy(1, text_indices, text_embeds)
|
||||
packed_hidden_states = packed_hidden_states.index_copy(1, video_indices, video_embeds.to(text_embeds.dtype))
|
||||
packed_hidden_states = packed_hidden_states.index_copy(1, audio_indices, audio_embeds.to(text_embeds.dtype))
|
||||
|
||||
temb = self.time_proj(timestep)
|
||||
temb = self.time_embedder(temb.to(self.time_embedder.fc_in.weight.dtype))
|
||||
adaln_indices = timestep_indices * MINIMAX_H3_MODALITY_NUM + token_tags
|
||||
local_timestep_indices = timestep_indices
|
||||
original_seq_len = sequence_length
|
||||
|
||||
if sp_world_size > 1:
|
||||
packed_hidden_states, _ = sequence_model_parallel_shard(packed_hidden_states, dim=1)
|
||||
rotary_cos, _ = sequence_model_parallel_shard(rotary_emb[0], dim=0)
|
||||
rotary_sin, _ = sequence_model_parallel_shard(rotary_emb[1], dim=0)
|
||||
adaln_indices, _ = sequence_model_parallel_shard(adaln_indices, dim=0)
|
||||
local_timestep_indices, _ = sequence_model_parallel_shard(local_timestep_indices, dim=0)
|
||||
rotary_emb = (rotary_cos, rotary_sin)
|
||||
|
||||
for block in self.transformer_blocks:
|
||||
packed_hidden_states = block(
|
||||
packed_hidden_states,
|
||||
temb,
|
||||
adaln_indices,
|
||||
rotary_emb,
|
||||
original_seq_len,
|
||||
)
|
||||
|
||||
packed_hidden_states = self.norm_out(
|
||||
packed_hidden_states,
|
||||
temb,
|
||||
local_timestep_indices,
|
||||
).to(self.proj_out.weight.dtype)
|
||||
video_output, _ = self.proj_out(packed_hidden_states)
|
||||
audio_output, _ = self.audio_proj_out(packed_hidden_states)
|
||||
if sp_world_size > 1:
|
||||
video_output = sequence_model_parallel_all_gather_with_unpad(video_output, original_seq_len, dim=1)
|
||||
audio_output = sequence_model_parallel_all_gather_with_unpad(audio_output, original_seq_len, dim=1)
|
||||
video_output = video_output.index_select(1, video_indices)
|
||||
audio_output = audio_output.index_select(1, audio_indices)
|
||||
|
||||
return video_output, audio_output
|
||||
|
||||
|
||||
EntryClass = MiniMaxH3Transformer3DModel
|
||||
@@ -0,0 +1,704 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Native Qwen3-VL conditioner used by MiniMax H3."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from torch import nn
|
||||
|
||||
from fastvideo.configs.models.encoders import BaseEncoderOutput
|
||||
from fastvideo.configs.models.encoders.minimax_h3_qwen3_vl import MiniMaxH3Qwen3VLConfig
|
||||
from fastvideo.distributed import get_tp_world_size
|
||||
from fastvideo.layers.layernorm import RMSNorm
|
||||
from fastvideo.layers.linear import ColumnParallelLinear, RowParallelLinear
|
||||
from fastvideo.layers.vocab_parallel_embedding import VocabParallelEmbedding
|
||||
from fastvideo.models.encoders.base import TextEncoder
|
||||
from fastvideo.models.loader.weight_utils import default_weight_loader
|
||||
|
||||
|
||||
def _rotate_half(tensor: torch.Tensor) -> torch.Tensor:
|
||||
first, second = tensor.chunk(2, dim=-1)
|
||||
return torch.cat((-second, first), dim=-1)
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLTextRotaryEmbedding(nn.Module):
|
||||
"""Shared Qwen3-VL interleaved temporal/height/width rotary embedding."""
|
||||
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig) -> None:
|
||||
super().__init__()
|
||||
head_dim = config.head_dim
|
||||
inv_freq = 1.0 / (config.rope_theta**(torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
|
||||
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
||||
self.mrope_section = tuple(config.mrope_section)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
if position_ids.ndim == 2:
|
||||
position_ids = position_ids[None].expand(3, -1, -1)
|
||||
inv_freq = self.inv_freq[None, None, :, None].expand(3, position_ids.shape[1], -1, 1)
|
||||
positions = position_ids[:, :, None, :].float()
|
||||
device_type = hidden_states.device.type if hidden_states.device.type != "mps" else "cpu"
|
||||
with torch.autocast(device_type=device_type, enabled=False):
|
||||
frequencies = (inv_freq.float() @ positions).transpose(2, 3)
|
||||
interleaved = frequencies[0]
|
||||
for dimension, offset in enumerate((1, 2), start=1):
|
||||
stop = self.mrope_section[dimension] * 3
|
||||
interleaved[..., offset:stop:3] = frequencies[dimension, ..., offset:stop:3]
|
||||
embedding = torch.cat((interleaved, interleaved), dim=-1)
|
||||
cos = embedding.cos()
|
||||
sin = embedding.sin()
|
||||
return cos.to(hidden_states.dtype), sin.to(hidden_states.dtype)
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLTextAttention(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig, prefix: str) -> None:
|
||||
super().__init__()
|
||||
tp_size = get_tp_world_size()
|
||||
self.total_num_heads = config.num_attention_heads
|
||||
self.total_num_kv_heads = config.num_key_value_heads
|
||||
if self.total_num_heads % tp_size:
|
||||
raise ValueError(f"Qwen3-VL attention heads {self.total_num_heads} are not divisible by TP={tp_size}")
|
||||
if tp_size > self.total_num_kv_heads:
|
||||
raise ValueError(
|
||||
f"Qwen3-VL native K/V projections require TP={tp_size} to be no larger than "
|
||||
f"the {self.total_num_kv_heads} KV heads"
|
||||
)
|
||||
if self.total_num_kv_heads % tp_size:
|
||||
raise ValueError(f"Qwen3-VL KV heads {self.total_num_kv_heads} are not divisible by TP={tp_size}")
|
||||
|
||||
self.num_heads = self.total_num_heads // tp_size
|
||||
self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
|
||||
self.head_dim = config.head_dim
|
||||
self.scaling = self.head_dim**-0.5
|
||||
quant_config = getattr(config, "quant_config", None)
|
||||
self.q_proj = ColumnParallelLinear(
|
||||
input_size=config.hidden_size,
|
||||
output_size=self.total_num_heads * self.head_dim,
|
||||
bias=config.attention_bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.q_proj",
|
||||
)
|
||||
self.k_proj = ColumnParallelLinear(
|
||||
input_size=config.hidden_size,
|
||||
output_size=self.total_num_kv_heads * self.head_dim,
|
||||
bias=config.attention_bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.k_proj",
|
||||
)
|
||||
self.v_proj = ColumnParallelLinear(
|
||||
input_size=config.hidden_size,
|
||||
output_size=self.total_num_kv_heads * self.head_dim,
|
||||
bias=config.attention_bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.v_proj",
|
||||
)
|
||||
self.o_proj = RowParallelLinear(
|
||||
input_size=self.total_num_heads * self.head_dim,
|
||||
output_size=config.hidden_size,
|
||||
bias=config.attention_bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.o_proj",
|
||||
)
|
||||
self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
|
||||
self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
position_embeddings: tuple[torch.Tensor, torch.Tensor],
|
||||
attention_mask: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
query, _ = self.q_proj(hidden_states)
|
||||
key, _ = self.k_proj(hidden_states)
|
||||
value, _ = self.v_proj(hidden_states)
|
||||
batch_size, sequence_length = hidden_states.shape[:2]
|
||||
query = self.q_norm(query.view(batch_size, sequence_length, self.num_heads, self.head_dim)).transpose(1, 2)
|
||||
key = self.k_norm(key.view(batch_size, sequence_length, self.num_kv_heads, self.head_dim)).transpose(1, 2)
|
||||
value = value.view(batch_size, sequence_length, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
||||
|
||||
cos, sin = position_embeddings
|
||||
cos = cos.unsqueeze(1)
|
||||
sin = sin.unsqueeze(1)
|
||||
query = query * cos + _rotate_half(query) * sin
|
||||
key = key * cos + _rotate_half(key) * sin
|
||||
|
||||
if attention_mask is None:
|
||||
output = F.scaled_dot_product_attention(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
dropout_p=0.0,
|
||||
is_causal=sequence_length > 1,
|
||||
scale=self.scaling,
|
||||
enable_gqa=self.num_heads != self.num_kv_heads,
|
||||
)
|
||||
else:
|
||||
if self.num_heads != self.num_kv_heads:
|
||||
groups = self.num_heads // self.num_kv_heads
|
||||
key = key[:, :, None].expand(-1, -1, groups, -1, -1).reshape(
|
||||
batch_size, self.num_heads, sequence_length, self.head_dim)
|
||||
value = value[:, :, None].expand(-1, -1, groups, -1, -1).reshape(
|
||||
batch_size, self.num_heads, sequence_length, self.head_dim)
|
||||
causal = torch.ones(sequence_length, sequence_length, device=query.device, dtype=torch.bool).tril()
|
||||
key_mask = attention_mask.to(device=query.device, dtype=torch.bool)
|
||||
mask = causal[None, None] & key_mask[:, None, None]
|
||||
output = F.scaled_dot_product_attention(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
attn_mask=mask,
|
||||
dropout_p=0.0,
|
||||
is_causal=False,
|
||||
scale=self.scaling,
|
||||
)
|
||||
output = output.transpose(1, 2).reshape(batch_size, sequence_length, -1)
|
||||
output, _ = self.o_proj(output)
|
||||
return output
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLTextMLP(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig, prefix: str) -> None:
|
||||
super().__init__()
|
||||
quant_config = getattr(config, "quant_config", None)
|
||||
self.gate_proj = ColumnParallelLinear(
|
||||
input_size=config.hidden_size,
|
||||
output_size=config.intermediate_size,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.gate_proj",
|
||||
)
|
||||
self.up_proj = ColumnParallelLinear(
|
||||
input_size=config.hidden_size,
|
||||
output_size=config.intermediate_size,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.up_proj",
|
||||
)
|
||||
self.down_proj = RowParallelLinear(
|
||||
input_size=config.intermediate_size,
|
||||
output_size=config.hidden_size,
|
||||
bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.down_proj",
|
||||
)
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
gate, _ = self.gate_proj(hidden_states)
|
||||
up, _ = self.up_proj(hidden_states)
|
||||
hidden_states = F.silu(gate) * up
|
||||
hidden_states, _ = self.down_proj(hidden_states)
|
||||
return hidden_states
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLTextDecoderLayer(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig, prefix: str) -> None:
|
||||
super().__init__()
|
||||
self.self_attn = MiniMaxH3Qwen3VLTextAttention(config, prefix=f"{prefix}.self_attn")
|
||||
self.mlp = MiniMaxH3Qwen3VLTextMLP(config, prefix=f"{prefix}.mlp")
|
||||
self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
position_embeddings: tuple[torch.Tensor, torch.Tensor],
|
||||
attention_mask: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
residual = hidden_states
|
||||
hidden_states = self.input_layernorm(hidden_states)
|
||||
hidden_states = self.self_attn(hidden_states, position_embeddings, attention_mask)
|
||||
hidden_states = residual + hidden_states
|
||||
residual = hidden_states
|
||||
hidden_states = self.post_attention_layernorm(hidden_states)
|
||||
hidden_states = self.mlp(hidden_states)
|
||||
return residual + hidden_states
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLLanguageModel(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig) -> None:
|
||||
super().__init__()
|
||||
quant_config = getattr(config, "quant_config", None)
|
||||
self.embed_tokens = VocabParallelEmbedding(
|
||||
config.vocab_size,
|
||||
config.hidden_size,
|
||||
org_num_embeddings=config.vocab_size,
|
||||
quant_config=quant_config,
|
||||
)
|
||||
self.layers = nn.ModuleList(
|
||||
MiniMaxH3Qwen3VLTextDecoderLayer(config, prefix=f"{config.prefix}.language_model.layers.{index}")
|
||||
for index in range(config.num_hidden_layers)
|
||||
)
|
||||
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
self.rotary_emb = MiniMaxH3Qwen3VLTextRotaryEmbedding(config)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
inputs_embeds: torch.Tensor,
|
||||
position_ids: torch.Tensor,
|
||||
attention_mask: torch.Tensor | None,
|
||||
output_hidden_states: bool,
|
||||
visual_pos_masks: torch.Tensor | None,
|
||||
deepstack_visual_embeds: list[torch.Tensor] | None,
|
||||
) -> BaseEncoderOutput:
|
||||
if attention_mask is not None and bool(attention_mask.to(torch.bool).all()):
|
||||
attention_mask = None
|
||||
position_embeddings = self.rotary_emb(inputs_embeds, position_ids)
|
||||
hidden_states = inputs_embeds
|
||||
all_hidden_states: tuple[torch.Tensor, ...] | None = () if output_hidden_states else None
|
||||
for layer_index, layer in enumerate(self.layers):
|
||||
if all_hidden_states is not None:
|
||||
all_hidden_states += (hidden_states,)
|
||||
hidden_states = layer(hidden_states, position_embeddings, attention_mask)
|
||||
if deepstack_visual_embeds is not None and layer_index < len(deepstack_visual_embeds):
|
||||
if visual_pos_masks is None:
|
||||
raise ValueError("Qwen3-VL DeepStack features require visual token positions")
|
||||
mask = visual_pos_masks.to(hidden_states.device)
|
||||
visual = deepstack_visual_embeds[layer_index].to(hidden_states.device, hidden_states.dtype)
|
||||
updated = hidden_states[mask].clone() + visual
|
||||
hidden_states[mask] = updated
|
||||
hidden_states = self.norm(hidden_states)
|
||||
if all_hidden_states is not None:
|
||||
all_hidden_states += (hidden_states,)
|
||||
return BaseEncoderOutput(last_hidden_state=hidden_states, hidden_states=all_hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLVisionPatchEmbed(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig) -> None:
|
||||
super().__init__()
|
||||
self.in_channels = config.vision_in_channels
|
||||
self.temporal_patch_size = config.vision_temporal_patch_size
|
||||
self.patch_size = config.vision_patch_size
|
||||
self.hidden_size = config.vision_hidden_size
|
||||
kernel = (self.temporal_patch_size, self.patch_size, self.patch_size)
|
||||
self.proj = nn.Conv3d(self.in_channels, self.hidden_size, kernel_size=kernel, stride=kernel, bias=True)
|
||||
|
||||
def forward(self, pixels: torch.Tensor) -> torch.Tensor:
|
||||
pixels = pixels.view(-1, self.in_channels, self.temporal_patch_size, self.patch_size, self.patch_size)
|
||||
return self.proj(pixels.to(self.proj.weight.dtype)).view(-1, self.hidden_size)
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLVisionRotaryEmbedding(nn.Module):
|
||||
def __init__(self, dimension: int) -> None:
|
||||
super().__init__()
|
||||
# Transformers initializes this non-persistent constant on CPU before
|
||||
# moving the model. FastVideo constructs native encoders inside a CUDA
|
||||
# device context, where evaluating the power directly differs by a few
|
||||
# fp32 ULPs and is amplified by the 64-layer language model.
|
||||
target_device = torch.empty(0).device
|
||||
exponents = torch.arange(0, dimension, 2, dtype=torch.float32, device="cpu") / dimension
|
||||
inv_freq = (1.0 / (10000.0**exponents)).to(target_device)
|
||||
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
||||
|
||||
def forward(self, sequence_length: int) -> torch.Tensor:
|
||||
positions = torch.arange(sequence_length, device=self.inv_freq.device, dtype=self.inv_freq.dtype)
|
||||
return torch.outer(positions, self.inv_freq)
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLVisionPatchMerger(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig, use_postshuffle_norm: bool) -> None:
|
||||
super().__init__()
|
||||
self.hidden_size = config.vision_hidden_size * config.vision_spatial_merge_size**2
|
||||
self.use_postshuffle_norm = use_postshuffle_norm
|
||||
norm_size = self.hidden_size if use_postshuffle_norm else config.vision_hidden_size
|
||||
self.norm = nn.LayerNorm(norm_size, eps=1e-6)
|
||||
self.linear_fc1 = nn.Linear(self.hidden_size, self.hidden_size)
|
||||
self.linear_fc2 = nn.Linear(self.hidden_size, config.vision_out_hidden_size)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
if self.use_postshuffle_norm:
|
||||
hidden_states = self.norm(hidden_states.view(-1, self.hidden_size))
|
||||
else:
|
||||
hidden_states = self.norm(hidden_states)
|
||||
hidden_states = hidden_states.view(-1, self.hidden_size)
|
||||
hidden_states = F.gelu(self.linear_fc1(hidden_states))
|
||||
return self.linear_fc2(hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLVisionMLP(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig) -> None:
|
||||
super().__init__()
|
||||
self.linear_fc1 = nn.Linear(config.vision_hidden_size, config.vision_intermediate_size, bias=True)
|
||||
self.linear_fc2 = nn.Linear(config.vision_intermediate_size, config.vision_hidden_size, bias=True)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
return self.linear_fc2(F.gelu(self.linear_fc1(hidden_states), approximate="tanh"))
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLVisionAttention(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig) -> None:
|
||||
super().__init__()
|
||||
self.num_heads = config.vision_num_heads
|
||||
self.head_dim = config.vision_hidden_size // self.num_heads
|
||||
self.scaling = self.head_dim**-0.5
|
||||
self.qkv = nn.Linear(config.vision_hidden_size, config.vision_hidden_size * 3, bias=True)
|
||||
self.proj = nn.Linear(config.vision_hidden_size, config.vision_hidden_size, bias=True)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
sequence_lengths: list[int],
|
||||
position_embeddings: tuple[torch.Tensor, torch.Tensor],
|
||||
) -> torch.Tensor:
|
||||
sequence_length = hidden_states.shape[0]
|
||||
query, key, value = self.qkv(hidden_states).view(sequence_length, 3, self.num_heads, self.head_dim).unbind(1)
|
||||
cos, sin = position_embeddings
|
||||
query_dtype, key_dtype = query.dtype, key.dtype
|
||||
cos = cos.unsqueeze(-2).float()
|
||||
sin = sin.unsqueeze(-2).float()
|
||||
query = (query.float() * cos + _rotate_half(query.float()) * sin).to(query_dtype)
|
||||
key = (key.float() * cos + _rotate_half(key.float()) * sin).to(key_dtype)
|
||||
|
||||
query_chunks = query.split(sequence_lengths, dim=0)
|
||||
key_chunks = key.split(sequence_lengths, dim=0)
|
||||
value_chunks = value.split(sequence_lengths, dim=0)
|
||||
outputs = []
|
||||
for query_chunk, key_chunk, value_chunk in zip(query_chunks, key_chunks, value_chunks, strict=True):
|
||||
output = F.scaled_dot_product_attention(
|
||||
query_chunk.transpose(0, 1).unsqueeze(0),
|
||||
key_chunk.transpose(0, 1).unsqueeze(0),
|
||||
value_chunk.transpose(0, 1).unsqueeze(0),
|
||||
dropout_p=0.0,
|
||||
is_causal=False,
|
||||
scale=self.scaling,
|
||||
)
|
||||
outputs.append(output.transpose(1, 2))
|
||||
hidden_states = torch.cat(outputs, dim=1).reshape(sequence_length, -1)
|
||||
return self.proj(hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLVisionBlock(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig) -> None:
|
||||
super().__init__()
|
||||
self.norm1 = nn.LayerNorm(config.vision_hidden_size, eps=1e-6)
|
||||
self.norm2 = nn.LayerNorm(config.vision_hidden_size, eps=1e-6)
|
||||
self.attn = MiniMaxH3Qwen3VLVisionAttention(config)
|
||||
self.mlp = MiniMaxH3Qwen3VLVisionMLP(config)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
sequence_lengths: list[int],
|
||||
position_embeddings: tuple[torch.Tensor, torch.Tensor],
|
||||
) -> torch.Tensor:
|
||||
hidden_states = hidden_states + self.attn(self.norm1(hidden_states), sequence_lengths, position_embeddings)
|
||||
return hidden_states + self.mlp(self.norm2(hidden_states))
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLVisionModel(nn.Module):
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.spatial_merge_size = config.vision_spatial_merge_size
|
||||
self.patch_embed = MiniMaxH3Qwen3VLVisionPatchEmbed(config)
|
||||
self.pos_embed = nn.Embedding(config.vision_num_position_embeddings, config.vision_hidden_size)
|
||||
self.num_grid_per_side = int(config.vision_num_position_embeddings**0.5)
|
||||
head_dim = config.vision_hidden_size // config.vision_num_heads
|
||||
self.rotary_pos_emb = MiniMaxH3Qwen3VLVisionRotaryEmbedding(head_dim // 2)
|
||||
self.blocks = nn.ModuleList(MiniMaxH3Qwen3VLVisionBlock(config) for _ in range(config.vision_depth))
|
||||
self.merger = MiniMaxH3Qwen3VLVisionPatchMerger(config, use_postshuffle_norm=False)
|
||||
self.deepstack_visual_indexes = tuple(config.vision_deepstack_visual_indexes)
|
||||
self.deepstack_merger_list = nn.ModuleList(
|
||||
MiniMaxH3Qwen3VLVisionPatchMerger(config, use_postshuffle_norm=True)
|
||||
for _ in self.deepstack_visual_indexes
|
||||
)
|
||||
|
||||
def _rotary_positions(self, grid_thw: torch.Tensor) -> torch.Tensor:
|
||||
max_height_width = int(grid_thw[:, 1:].max().item())
|
||||
frequency_table = self.rotary_pos_emb(max_height_width)
|
||||
total_tokens = int(torch.prod(grid_thw, dim=1).sum().item())
|
||||
position_ids = torch.empty((total_tokens, 2), dtype=torch.long, device=frequency_table.device)
|
||||
offset = 0
|
||||
merge = self.spatial_merge_size
|
||||
for frames_tensor, height_tensor, width_tensor in grid_thw:
|
||||
frames, height, width = int(frames_tensor), int(height_tensor), int(width_tensor)
|
||||
merged_height, merged_width = height // merge, width // merge
|
||||
block_rows = torch.arange(merged_height, device=frequency_table.device)
|
||||
block_cols = torch.arange(merged_width, device=frequency_table.device)
|
||||
intra_rows = torch.arange(merge, device=frequency_table.device)
|
||||
intra_cols = torch.arange(merge, device=frequency_table.device)
|
||||
rows = block_rows[:, None, None, None] * merge + intra_rows[None, None, :, None]
|
||||
cols = block_cols[None, :, None, None] * merge + intra_cols[None, None, None, :]
|
||||
rows = rows.expand(merged_height, merged_width, merge, merge).reshape(-1)
|
||||
cols = cols.expand(merged_height, merged_width, merge, merge).reshape(-1)
|
||||
coordinates = torch.stack((rows, cols), dim=-1).repeat(frames, 1)
|
||||
position_ids[offset:offset + coordinates.shape[0]] = coordinates
|
||||
offset += coordinates.shape[0]
|
||||
return frequency_table[position_ids].flatten(1)
|
||||
|
||||
def _interpolate_position_embeddings(self, grid_thw: torch.Tensor) -> torch.Tensor:
|
||||
index_lists: list[list[int]] = [[] for _ in range(4)]
|
||||
weight_lists: list[list[float]] = [[] for _ in range(4)]
|
||||
merge = self.spatial_merge_size
|
||||
patch_counts: list[int] = []
|
||||
grids: list[tuple[int, int, int]] = []
|
||||
for frames_tensor, height_tensor, width_tensor in grid_thw:
|
||||
frames, height, width = int(frames_tensor), int(height_tensor), int(width_tensor)
|
||||
grids.append((frames, height, width))
|
||||
patch_counts.append(height * width)
|
||||
height_positions = torch.linspace(0, self.num_grid_per_side - 1, height)
|
||||
width_positions = torch.linspace(0, self.num_grid_per_side - 1, width)
|
||||
height_floor = height_positions.int()
|
||||
width_floor = width_positions.int()
|
||||
height_ceil = (height_floor + 1).clip(max=self.num_grid_per_side - 1)
|
||||
width_ceil = (width_floor + 1).clip(max=self.num_grid_per_side - 1)
|
||||
delta_height = height_positions - height_floor
|
||||
delta_width = width_positions - width_floor
|
||||
base_height = height_floor * self.num_grid_per_side
|
||||
base_height_ceil = height_ceil * self.num_grid_per_side
|
||||
indices = (
|
||||
(base_height[:, None] + width_floor[None]).flatten(),
|
||||
(base_height[:, None] + width_ceil[None]).flatten(),
|
||||
(base_height_ceil[:, None] + width_floor[None]).flatten(),
|
||||
(base_height_ceil[:, None] + width_ceil[None]).flatten(),
|
||||
)
|
||||
weights = (
|
||||
((1 - delta_height)[:, None] * (1 - delta_width)[None]).flatten(),
|
||||
((1 - delta_height)[:, None] * delta_width[None]).flatten(),
|
||||
(delta_height[:, None] * (1 - delta_width)[None]).flatten(),
|
||||
(delta_height[:, None] * delta_width[None]).flatten(),
|
||||
)
|
||||
for index in range(4):
|
||||
index_lists[index].extend(indices[index].tolist())
|
||||
weight_lists[index].extend(weights[index].tolist())
|
||||
|
||||
index_tensor = torch.tensor(index_lists, dtype=torch.long, device=self.pos_embed.weight.device)
|
||||
weight_tensor = torch.tensor(weight_lists, dtype=self.pos_embed.weight.dtype, device=self.pos_embed.weight.device)
|
||||
embeddings = self.pos_embed(index_tensor) * weight_tensor[:, :, None]
|
||||
embeddings = (embeddings[0] + embeddings[1] + embeddings[2] + embeddings[3]).split(patch_counts)
|
||||
permuted = []
|
||||
for embedding, (frames, height, width) in zip(embeddings, grids, strict=True):
|
||||
embedding = embedding.repeat(frames, 1)
|
||||
embedding = embedding.view(frames, height // merge, merge, width // merge, merge, -1)
|
||||
permuted.append(embedding.permute(0, 1, 3, 2, 4, 5).flatten(0, 4))
|
||||
return torch.cat(permuted)
|
||||
|
||||
def forward(self, pixels: torch.Tensor, grid_thw: torch.Tensor) -> tuple[torch.Tensor, list[torch.Tensor]]:
|
||||
hidden_states = self.patch_embed(pixels)
|
||||
hidden_states = hidden_states + self._interpolate_position_embeddings(grid_thw)
|
||||
rotary = self._rotary_positions(grid_thw).reshape(hidden_states.shape[0], -1)
|
||||
embedding = torch.cat((rotary, rotary), dim=-1)
|
||||
position_embeddings = (embedding.cos(), embedding.sin())
|
||||
sequence_lengths = [int(value) for value in torch.repeat_interleave(
|
||||
grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]
|
||||
)]
|
||||
deepstack_features = []
|
||||
for layer_index, block in enumerate(self.blocks):
|
||||
hidden_states = block(hidden_states, sequence_lengths, position_embeddings)
|
||||
if layer_index in self.deepstack_visual_indexes:
|
||||
merger_index = self.deepstack_visual_indexes.index(layer_index)
|
||||
deepstack_features.append(self.deepstack_merger_list[merger_index](hidden_states))
|
||||
return self.merger(hidden_states), deepstack_features
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLConditioner(TextEncoder):
|
||||
"""FastVideo-native Qwen3-VL body without the unused language-model head."""
|
||||
|
||||
supports_hf_from_pretrained = False
|
||||
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig) -> None:
|
||||
super().__init__(config)
|
||||
self.visual = MiniMaxH3Qwen3VLVisionModel(config)
|
||||
self.language_model = MiniMaxH3Qwen3VLLanguageModel(config)
|
||||
|
||||
@property
|
||||
def dtype(self) -> torch.dtype:
|
||||
parameter = next(self.parameters(), None)
|
||||
return torch.float32 if parameter is None else parameter.dtype
|
||||
|
||||
@property
|
||||
def num_hidden_layers(self) -> int:
|
||||
return self.config.num_hidden_layers
|
||||
|
||||
def _get_rope_index(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
image_grid_thw: torch.Tensor | None,
|
||||
video_grid_thw: torch.Tensor | None,
|
||||
attention_mask: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
if image_grid_thw is None and video_grid_thw is None:
|
||||
if attention_mask is None:
|
||||
return torch.arange(input_ids.shape[1], device=input_ids.device).view(1, 1, -1).expand(
|
||||
3, input_ids.shape[0], -1)
|
||||
positions = attention_mask.long().cumsum(-1) - 1
|
||||
positions.masked_fill_(attention_mask == 0, 1)
|
||||
return positions.unsqueeze(0).expand(3, -1, -1)
|
||||
|
||||
if video_grid_thw is not None:
|
||||
video_grid_thw = torch.repeat_interleave(video_grid_thw, video_grid_thw[:, 0], dim=0).clone()
|
||||
video_grid_thw[:, 0] = 1
|
||||
mask = torch.ones_like(input_ids) if attention_mask is None else attention_mask
|
||||
position_ids = torch.ones(3, input_ids.shape[0], input_ids.shape[1], dtype=input_ids.dtype, device=input_ids.device)
|
||||
image_index = 0
|
||||
video_index = 0
|
||||
merge = self.config.vision_spatial_merge_size
|
||||
for batch_index, sequence in enumerate(input_ids):
|
||||
sequence = sequence[mask[batch_index] == 1]
|
||||
starts = torch.argwhere(sequence == self.config.vision_start_token_id).squeeze(1)
|
||||
vision_tokens = sequence[starts + 1]
|
||||
image_count = int((vision_tokens == self.config.image_token_id).sum())
|
||||
video_count = int((vision_tokens == self.config.video_token_id).sum())
|
||||
tokens = sequence.tolist()
|
||||
pieces = []
|
||||
start = 0
|
||||
remaining_images, remaining_videos = image_count, video_count
|
||||
for _ in range(image_count + video_count):
|
||||
image_end = tokens.index(self.config.image_token_id, start) if remaining_images else len(tokens) + 1
|
||||
video_end = tokens.index(self.config.video_token_id, start) if remaining_videos else len(tokens) + 1
|
||||
if image_end < video_end:
|
||||
if image_grid_thw is None:
|
||||
raise ValueError("Qwen3-VL image tokens require image_grid_thw")
|
||||
frames, height, width = (int(value) for value in image_grid_thw[image_index])
|
||||
image_index += 1
|
||||
remaining_images -= 1
|
||||
end = image_end
|
||||
else:
|
||||
if video_grid_thw is None:
|
||||
raise ValueError("Qwen3-VL video tokens require video_grid_thw")
|
||||
frames, height, width = (int(value) for value in video_grid_thw[video_index])
|
||||
video_index += 1
|
||||
remaining_videos -= 1
|
||||
end = video_end
|
||||
grid_frames, grid_height, grid_width = frames, height // merge, width // merge
|
||||
text_length = end - start
|
||||
offset = int(pieces[-1].max()) + 1 if pieces else 0
|
||||
pieces.append(torch.arange(text_length).view(1, -1).expand(3, -1) + offset)
|
||||
temporal = torch.arange(grid_frames).view(-1, 1).expand(-1, grid_height * grid_width).flatten()
|
||||
rows = torch.arange(grid_height).view(1, -1, 1).expand(grid_frames, -1, grid_width).flatten()
|
||||
columns = torch.arange(grid_width).view(1, 1, -1).expand(grid_frames, grid_height, -1).flatten()
|
||||
pieces.append(torch.stack((temporal, rows, columns)) + text_length + offset)
|
||||
start = end + grid_frames * grid_height * grid_width
|
||||
if start < len(tokens):
|
||||
offset = int(pieces[-1].max()) + 1 if pieces else 0
|
||||
pieces.append(torch.arange(len(tokens) - start).view(1, -1).expand(3, -1) + offset)
|
||||
positions = torch.cat(pieces, dim=1).to(position_ids.device)
|
||||
position_ids[:, batch_index, mask[batch_index] == 1] = positions
|
||||
return position_ids
|
||||
|
||||
def _visual_features(
|
||||
self,
|
||||
pixels: torch.Tensor,
|
||||
grid_thw: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, list[torch.Tensor]]:
|
||||
return self.visual(pixels.to(self.visual.patch_embed.proj.weight.dtype), grid_thw)
|
||||
|
||||
@staticmethod
|
||||
def _placeholder_mask(
|
||||
input_ids: torch.Tensor,
|
||||
inputs_embeds: torch.Tensor,
|
||||
token_id: int,
|
||||
features: torch.Tensor,
|
||||
label: str,
|
||||
) -> torch.Tensor:
|
||||
mask = input_ids == token_id
|
||||
expanded = mask.unsqueeze(-1).expand_as(inputs_embeds)
|
||||
if inputs_embeds[expanded].numel() != features.numel():
|
||||
raise ValueError(
|
||||
f"Qwen3-VL {label} features and placeholder tokens do not match: "
|
||||
f"tokens={int(mask.sum())}, features={features.shape[0]}"
|
||||
)
|
||||
return mask
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor | None,
|
||||
position_ids: torch.Tensor | None = None,
|
||||
attention_mask: torch.Tensor | None = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
output_hidden_states: bool | None = None,
|
||||
pixel_values: torch.Tensor | None = None,
|
||||
pixel_values_videos: torch.Tensor | None = None,
|
||||
image_grid_thw: torch.Tensor | None = None,
|
||||
video_grid_thw: torch.Tensor | None = None,
|
||||
mm_token_type_ids: torch.Tensor | None = None,
|
||||
**kwargs: Any,
|
||||
) -> BaseEncoderOutput:
|
||||
del mm_token_type_ids, kwargs
|
||||
if (input_ids is None) == (inputs_embeds is None):
|
||||
raise ValueError("Exactly one of input_ids or inputs_embeds is required")
|
||||
if inputs_embeds is None:
|
||||
assert input_ids is not None
|
||||
inputs_embeds = self.language_model.embed_tokens(input_ids)
|
||||
if input_ids is None and (pixel_values is not None or pixel_values_videos is not None):
|
||||
raise ValueError("Multimodal Qwen3-VL inputs require input_ids for placeholder matching")
|
||||
|
||||
image_mask = None
|
||||
video_mask = None
|
||||
image_deepstack = None
|
||||
video_deepstack = None
|
||||
if pixel_values is not None:
|
||||
if input_ids is None or image_grid_thw is None:
|
||||
raise ValueError("pixel_values require input_ids and image_grid_thw")
|
||||
image_features, image_deepstack = self._visual_features(pixel_values, image_grid_thw)
|
||||
image_features = image_features.to(inputs_embeds.device, inputs_embeds.dtype)
|
||||
image_mask = self._placeholder_mask(
|
||||
input_ids, inputs_embeds, self.config.image_token_id, image_features, "image")
|
||||
inputs_embeds = inputs_embeds.masked_scatter(image_mask.unsqueeze(-1), image_features)
|
||||
if pixel_values_videos is not None:
|
||||
if input_ids is None or video_grid_thw is None:
|
||||
raise ValueError("pixel_values_videos require input_ids and video_grid_thw")
|
||||
video_features, video_deepstack = self._visual_features(pixel_values_videos, video_grid_thw)
|
||||
video_features = video_features.to(inputs_embeds.device, inputs_embeds.dtype)
|
||||
video_mask = self._placeholder_mask(
|
||||
input_ids, inputs_embeds, self.config.video_token_id, video_features, "video")
|
||||
inputs_embeds = inputs_embeds.masked_scatter(video_mask.unsqueeze(-1), video_features)
|
||||
|
||||
visual_mask = None
|
||||
deepstack_features = None
|
||||
if image_mask is not None and video_mask is not None:
|
||||
visual_mask = image_mask | video_mask
|
||||
deepstack_features = []
|
||||
image_joint = image_mask[visual_mask]
|
||||
video_joint = video_mask[visual_mask]
|
||||
assert image_deepstack is not None and video_deepstack is not None
|
||||
for image_features, video_features in zip(image_deepstack, video_deepstack, strict=True):
|
||||
combined = image_features.new_zeros((int(visual_mask.sum()), image_features.shape[-1]))
|
||||
combined[image_joint] = image_features
|
||||
combined[video_joint] = video_features
|
||||
deepstack_features.append(combined)
|
||||
elif image_mask is not None:
|
||||
visual_mask = image_mask
|
||||
deepstack_features = image_deepstack
|
||||
elif video_mask is not None:
|
||||
visual_mask = video_mask
|
||||
deepstack_features = video_deepstack
|
||||
|
||||
if position_ids is None:
|
||||
if input_ids is None:
|
||||
sequence_length = inputs_embeds.shape[1]
|
||||
position_ids = torch.arange(sequence_length, device=inputs_embeds.device).view(1, 1, -1).expand(
|
||||
3, inputs_embeds.shape[0], -1)
|
||||
else:
|
||||
position_ids = self._get_rope_index(input_ids, image_grid_thw, video_grid_thw, attention_mask)
|
||||
output_hidden_states = self.config.output_hidden_states if output_hidden_states is None else output_hidden_states
|
||||
outputs = self.language_model(
|
||||
inputs_embeds,
|
||||
position_ids,
|
||||
attention_mask,
|
||||
output_hidden_states,
|
||||
visual_mask,
|
||||
deepstack_features,
|
||||
)
|
||||
outputs.attention_mask = attention_mask
|
||||
return outputs
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
parameters = dict(self.named_parameters())
|
||||
loaded: set[str] = set()
|
||||
|
||||
for source_name, tensor in weights:
|
||||
if source_name == "lm_head.weight":
|
||||
continue
|
||||
name = source_name[6:] if source_name.startswith("model.") else source_name
|
||||
if name not in parameters:
|
||||
raise ValueError(f"Unexpected MiniMax-H3 Qwen3-VL checkpoint key: {source_name}")
|
||||
parameter = parameters[name]
|
||||
loader = getattr(parameter, "weight_loader", default_weight_loader)
|
||||
loader(parameter, tensor)
|
||||
loaded.add(name)
|
||||
return loaded
|
||||
|
||||
|
||||
EntryClass = MiniMaxH3Qwen3VLConditioner
|
||||
|
||||
__all__ = ["MiniMaxH3Qwen3VLConditioner"]
|
||||
@@ -84,7 +84,9 @@ class ComponentLoader(ABC):
|
||||
# Map of module types to their loader classes and expected library
|
||||
module_loaders = {
|
||||
"scheduler": (SchedulerLoader, "diffusers"),
|
||||
"audio_scheduler": (SchedulerLoader, "diffusers"),
|
||||
"transformer": (TransformerLoader, "diffusers"),
|
||||
"transformer_ref": (TransformerLoader, "diffusers"),
|
||||
"sr_transformer": (TransformerLoader, "diffusers"),
|
||||
"transformer_2": (TransformerLoader, "diffusers"),
|
||||
"transformer_3": (TransformerLoader, "diffusers"),
|
||||
@@ -911,7 +913,7 @@ class VAELoader(ComponentLoader):
|
||||
|
||||
# Diffusers-format AutoencoderKL checkpoints should match exactly; load
|
||||
# strictly so missing/unexpected keys are surfaced early.
|
||||
strict_load = class_name == "AutoencoderKL"
|
||||
strict_load = class_name in {"AutoencoderKL", "AutoencoderKLMiniMaxH3"}
|
||||
vae.load_state_dict(loaded, strict=strict_load)
|
||||
if (class_name == "AutoencoderKLWan"
|
||||
and getattr(vae.config, "use_light_vae", False)
|
||||
@@ -923,15 +925,36 @@ class VAELoader(ComponentLoader):
|
||||
|
||||
|
||||
class AudioDecoderLoader(ComponentLoader):
|
||||
"""Loader for LTX-2 audio decoder (audio_vae component)."""
|
||||
"""Loader for full audio VAEs and legacy decode-only audio components."""
|
||||
|
||||
def load(self, model_path: str, fastvideo_args: FastVideoArgs):
|
||||
config = get_diffusers_config(model=model_path)
|
||||
class_name = config.pop("_class_name", None) or "LTX2AudioDecoder"
|
||||
|
||||
model_cls, _ = ModelRegistry.resolve_model_cls(class_name)
|
||||
target_device = get_local_torch_device()
|
||||
|
||||
if class_name == "AutoencoderKLMiniMaxH3Audio":
|
||||
from fastvideo.platforms import current_platform
|
||||
|
||||
configured_audio_vae = getattr(fastvideo_args.pipeline_config, "audio_vae_config", None)
|
||||
if configured_audio_vae is None:
|
||||
raise ValueError("MiniMax H3 requires audio_vae_config.")
|
||||
config.pop("_name_or_path", None)
|
||||
audio_vae_config = deepcopy(configured_audio_vae)
|
||||
audio_vae_config.update_model_arch(config)
|
||||
if getattr(fastvideo_args, "vae_cpu_offload", False):
|
||||
target_device = torch.device("mps") if current_platform.is_mps() else torch.device("cpu")
|
||||
with set_default_torch_dtype(torch.float32):
|
||||
audio_vae = model_cls(audio_vae_config).to(device=target_device, dtype=torch.float32)
|
||||
safetensors_list = glob.glob(os.path.join(str(model_path), "*.safetensors"))
|
||||
if not safetensors_list:
|
||||
raise ValueError(f"No safetensors files found in {model_path}")
|
||||
loaded: dict[str, torch.Tensor] = {}
|
||||
for sf_file in safetensors_list:
|
||||
loaded.update(safetensors_load_file(sf_file))
|
||||
audio_vae.load_state_dict(loaded, strict=True)
|
||||
return audio_vae.eval()
|
||||
|
||||
precision = getattr(
|
||||
fastvideo_args.pipeline_config, "audio_decoder_precision", "bf16"
|
||||
)
|
||||
@@ -1132,9 +1155,10 @@ class TransformerLoader(ComponentLoader):
|
||||
total_params = sum(p.numel() for p in model.parameters())
|
||||
logger.info("Loaded model with %.2fB parameters", total_params / 1e9)
|
||||
|
||||
assert next(model.parameters()).dtype == default_dtype, (
|
||||
"Model dtype does not match default dtype"
|
||||
)
|
||||
first_name, first_parameter = next(model.named_parameters())
|
||||
dtype_selector = getattr(model, "_get_parameter_dtype", None)
|
||||
expected_dtype = dtype_selector(first_name, default_dtype) if callable(dtype_selector) else default_dtype
|
||||
assert first_parameter.dtype == expected_dtype, "Model dtype does not match the configured parameter dtype"
|
||||
|
||||
model = model.eval()
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025 The MiniMax authors and The HuggingFace Team.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
from diffusers.configuration_utils import ConfigMixin, register_to_config
|
||||
from diffusers.schedulers.scheduling_utils import SchedulerMixin
|
||||
from diffusers.utils import BaseOutput
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3SchedulerOutput(BaseOutput):
|
||||
prev_sample: torch.FloatTensor
|
||||
|
||||
|
||||
class MiniMaxH3Scheduler(SchedulerMixin, ConfigMixin):
|
||||
"""Rectified-flow scheduler using MiniMax-H3's clean-time convention."""
|
||||
|
||||
_compatibles: list[str] = []
|
||||
order = 1
|
||||
|
||||
@register_to_config
|
||||
def __init__(self, shift: float = 12.0) -> None:
|
||||
if shift <= 0:
|
||||
raise ValueError(f"`shift` must be positive, got {shift}.")
|
||||
self.num_inference_steps: int | None = None
|
||||
self.sigmas: torch.Tensor | None = None
|
||||
self.timesteps: torch.Tensor | None = None
|
||||
self._shift = float(shift)
|
||||
self._step_index: int | None = None
|
||||
self._begin_index: int | None = None
|
||||
|
||||
@property
|
||||
def shift(self) -> float:
|
||||
return self._shift
|
||||
|
||||
@property
|
||||
def step_index(self) -> int | None:
|
||||
return self._step_index
|
||||
|
||||
@property
|
||||
def begin_index(self) -> int | None:
|
||||
return self._begin_index
|
||||
|
||||
def set_begin_index(self, begin_index: int = 0) -> None:
|
||||
self._begin_index = begin_index
|
||||
|
||||
def set_shift(self, shift: float) -> None:
|
||||
if shift <= 0:
|
||||
raise ValueError(f"`shift` must be positive, got {shift}.")
|
||||
self._shift = float(shift)
|
||||
|
||||
def set_timesteps(
|
||||
self,
|
||||
num_inference_steps: int | None = None,
|
||||
device: str | torch.device | None = None,
|
||||
sigmas: list[float] | torch.Tensor | None = None,
|
||||
) -> None:
|
||||
if sigmas is None:
|
||||
if num_inference_steps is None or num_inference_steps < 2:
|
||||
raise ValueError(
|
||||
"`set_timesteps` requires explicit `sigmas` or "
|
||||
f"`num_inference_steps` >= 2, got {num_inference_steps}."
|
||||
)
|
||||
base = torch.linspace(1.0, 0.0, int(num_inference_steps), dtype=torch.float32)
|
||||
sigma_tensor = self._shift * base / (1 + (self._shift - 1) * base)
|
||||
sigma_tensor = torch.unique_consecutive(sigma_tensor)
|
||||
else:
|
||||
sigma_tensor = torch.as_tensor(sigmas, dtype=torch.float32).flatten().cpu()
|
||||
is_valid = (
|
||||
sigma_tensor.numel() >= 2
|
||||
and bool((sigma_tensor[1:] < sigma_tensor[:-1]).all())
|
||||
and sigma_tensor[-1].item() == 0.0
|
||||
)
|
||||
if not is_valid:
|
||||
raise ValueError("`sigmas` must hold at least two strictly decreasing values ending at 0.0.")
|
||||
|
||||
self.sigmas = sigma_tensor.to(device=device)
|
||||
self.timesteps = (1.0 - sigma_tensor[:-1]).to(device=device)
|
||||
self.num_inference_steps = int(self.timesteps.numel())
|
||||
self._step_index = None
|
||||
self._begin_index = None
|
||||
|
||||
def index_for_timestep(self, timestep: float | torch.Tensor) -> int:
|
||||
if self.timesteps is None:
|
||||
raise ValueError("Call `set_timesteps` before looking up a timestep.")
|
||||
if isinstance(timestep, torch.Tensor):
|
||||
timestep = timestep.to(self.timesteps.device)
|
||||
indices = (self.timesteps == timestep).nonzero()
|
||||
if len(indices) == 0:
|
||||
raise ValueError("Passed `timestep` is not in `self.timesteps`.")
|
||||
return indices[0].item()
|
||||
|
||||
def scale_model_input(self, sample: torch.Tensor, timestep: float | torch.Tensor | None = None) -> torch.Tensor:
|
||||
return sample
|
||||
|
||||
def scale_noise(
|
||||
self,
|
||||
sample: torch.FloatTensor,
|
||||
timestep: float | torch.FloatTensor,
|
||||
noise: torch.FloatTensor,
|
||||
) -> torch.FloatTensor:
|
||||
if not isinstance(timestep, torch.Tensor):
|
||||
timestep = torch.tensor(timestep, dtype=sample.dtype, device=sample.device)
|
||||
timestep = timestep.to(device=sample.device, dtype=sample.dtype)
|
||||
while timestep.ndim < sample.ndim:
|
||||
timestep = timestep.unsqueeze(-1)
|
||||
return timestep * sample + (1.0 - timestep) * noise
|
||||
|
||||
def step(
|
||||
self,
|
||||
model_output: torch.FloatTensor,
|
||||
timestep: float | torch.FloatTensor,
|
||||
sample: torch.FloatTensor,
|
||||
return_dict: bool = True,
|
||||
) -> MiniMaxH3SchedulerOutput | tuple[torch.FloatTensor]:
|
||||
if isinstance(timestep, int) or (isinstance(timestep, torch.Tensor) and not timestep.is_floating_point()):
|
||||
raise ValueError("Pass a floating-point value from `scheduler.timesteps`, not an integer index.")
|
||||
if self.sigmas is None or self.timesteps is None:
|
||||
raise ValueError("Call `set_timesteps` before `step`.")
|
||||
if self._step_index is None:
|
||||
self._step_index = self.index_for_timestep(timestep) if self._begin_index is None else self._begin_index
|
||||
|
||||
if not isinstance(timestep, torch.Tensor):
|
||||
timestep = torch.tensor(timestep, dtype=sample.dtype)
|
||||
# H3 deliberately derives x0's sigma from the transformer timestep, while
|
||||
# the Euler ratio below uses the stored grid. Keep the two float32 paths separate.
|
||||
sigma_from_timestep = 1 - timestep.to(device=sample.device, dtype=sample.dtype)
|
||||
while sigma_from_timestep.ndim < sample.ndim:
|
||||
sigma_from_timestep = sigma_from_timestep.unsqueeze(-1)
|
||||
denoised = sample + sigma_from_timestep * model_output
|
||||
|
||||
compute_dtype = torch.float32 if sample.dtype in (torch.float16, torch.bfloat16) else sample.dtype
|
||||
sigma = self.sigmas[self._step_index].to(device=sample.device, dtype=compute_dtype)
|
||||
sigma_next = self.sigmas[self._step_index + 1].to(device=sample.device, dtype=compute_dtype)
|
||||
ratio = sigma_next / sigma
|
||||
prev_sample = ratio * sample.to(compute_dtype) + (1.0 - ratio) * denoised.to(compute_dtype)
|
||||
prev_sample = prev_sample.to(sample.dtype)
|
||||
self._step_index += 1
|
||||
|
||||
if not return_dict:
|
||||
return (prev_sample,)
|
||||
return MiniMaxH3SchedulerOutput(prev_sample=prev_sample)
|
||||
|
||||
|
||||
EntryClass = MiniMaxH3Scheduler
|
||||
@@ -0,0 +1,530 @@
|
||||
# Copyright 2025 MiniMax authors and HuggingFace Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Native MiniMax H3 waveform autoencoder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch.nn.utils import weight_norm
|
||||
|
||||
from fastvideo.configs.models.vaes.minimax_h3_audio import MiniMaxH3AudioVAEConfig
|
||||
from fastvideo.layers.linear import ReplicatedLinear
|
||||
from fastvideo.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class MiniMaxH3AudioDiagonalGaussianDistribution:
|
||||
"""Diagonal Gaussian parameterized by mean and log standard deviation."""
|
||||
|
||||
def __init__(self, mean: torch.Tensor, logs: torch.Tensor):
|
||||
self.mean = mean
|
||||
self.logs = logs
|
||||
self.std = torch.exp(logs)
|
||||
|
||||
def mode(self) -> torch.Tensor:
|
||||
return self.mean
|
||||
|
||||
def sample(self, generator: torch.Generator | None = None) -> torch.Tensor:
|
||||
noise_device = self.mean.device
|
||||
if generator is not None and generator.device.type == "cpu":
|
||||
noise_device = torch.device("cpu")
|
||||
noise = torch.randn(
|
||||
self.mean.shape,
|
||||
generator=generator,
|
||||
device=noise_device,
|
||||
dtype=self.mean.dtype,
|
||||
).to(self.mean.device)
|
||||
return self.mean + self.std * noise
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3AudioEncoderOutput:
|
||||
latent_dist: MiniMaxH3AudioDiagonalGaussianDistribution
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3AudioDecoderOutput:
|
||||
sample: torch.Tensor
|
||||
|
||||
|
||||
def _wn_conv1d(*args, **kwargs) -> nn.Module:
|
||||
return weight_norm(nn.Conv1d(*args, **kwargs))
|
||||
|
||||
|
||||
def _linear(layer: ReplicatedLinear, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
return layer(hidden_states)[0]
|
||||
|
||||
|
||||
def _module_dtype(module: nn.Module) -> torch.dtype:
|
||||
return next(module.parameters()).dtype
|
||||
|
||||
|
||||
def kaiser_sinc_filter1d(cutoff: float, half_width: float, kernel_size: int) -> torch.Tensor:
|
||||
"""Build the persistent low-pass filter used by alias-free activations."""
|
||||
|
||||
half_size = kernel_size // 2
|
||||
attenuation = 2.285 * (half_size - 1) * math.pi * (4 * half_width) + 7.95
|
||||
if attenuation > 50.0:
|
||||
beta = 0.1102 * (attenuation - 8.7)
|
||||
elif attenuation >= 21.0:
|
||||
beta = 0.5842 * (attenuation - 21) ** 0.4 + 0.07886 * (attenuation - 21.0)
|
||||
else:
|
||||
beta = 0.0
|
||||
|
||||
window = torch.kaiser_window(kernel_size, beta=beta, periodic=False, dtype=torch.float32)
|
||||
if kernel_size % 2 == 0:
|
||||
time = torch.arange(-half_size, half_size, dtype=torch.float32) + 0.5
|
||||
else:
|
||||
time = torch.arange(kernel_size, dtype=torch.float32) - half_size
|
||||
|
||||
filter_ = 2 * cutoff * window * torch.sinc(2 * cutoff * time)
|
||||
filter_ /= filter_.sum()
|
||||
return filter_.view(1, 1, kernel_size)
|
||||
|
||||
|
||||
class MiniMaxH3AudioSnake1d(nn.Module):
|
||||
|
||||
def __init__(self, channels: int):
|
||||
super().__init__()
|
||||
self.alpha = nn.Parameter(torch.ones(1, channels, 1))
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
return hidden_states + (self.alpha + 1e-9).reciprocal() * torch.sin(self.alpha * hidden_states).pow(2)
|
||||
|
||||
|
||||
class MiniMaxH3AudioSnakeBeta(nn.Module):
|
||||
|
||||
def __init__(self, channels: int):
|
||||
super().__init__()
|
||||
self.alpha = nn.Parameter(torch.zeros(channels))
|
||||
self.beta = nn.Parameter(torch.zeros(channels))
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
alpha = torch.exp(self.alpha.unsqueeze(0).unsqueeze(-1))
|
||||
beta = torch.exp(self.beta.unsqueeze(0).unsqueeze(-1))
|
||||
return hidden_states + (beta + 1e-9).reciprocal() * torch.sin(alpha * hidden_states).pow(2)
|
||||
|
||||
|
||||
class MiniMaxH3AudioLowPassFilter1d(nn.Module):
|
||||
|
||||
def __init__(self, cutoff: float, half_width: float, stride: int, kernel_size: int):
|
||||
super().__init__()
|
||||
even = kernel_size % 2 == 0
|
||||
self.pad_left = kernel_size // 2 - int(even)
|
||||
self.pad_right = kernel_size // 2
|
||||
self.stride = stride
|
||||
self.register_buffer("filter", kaiser_sinc_filter1d(cutoff, half_width, kernel_size))
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
num_channels = hidden_states.shape[1]
|
||||
hidden_states = F.pad(hidden_states, (self.pad_left, self.pad_right), mode="replicate")
|
||||
return F.conv1d(
|
||||
hidden_states,
|
||||
self.filter.expand(num_channels, -1, -1),
|
||||
stride=self.stride,
|
||||
groups=num_channels,
|
||||
)
|
||||
|
||||
|
||||
class MiniMaxH3AudioUpSample1d(nn.Module):
|
||||
|
||||
def __init__(self, ratio: int, kernel_size: int):
|
||||
super().__init__()
|
||||
self.ratio = ratio
|
||||
self.stride = ratio
|
||||
self.pad = kernel_size // ratio - 1
|
||||
self.pad_left = self.pad * self.stride + (kernel_size - self.stride) // 2
|
||||
self.pad_right = self.pad * self.stride + (kernel_size - self.stride + 1) // 2
|
||||
self.register_buffer(
|
||||
"filter",
|
||||
kaiser_sinc_filter1d(cutoff=0.5 / ratio, half_width=0.6 / ratio, kernel_size=kernel_size),
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
num_channels = hidden_states.shape[1]
|
||||
hidden_states = F.pad(hidden_states, (self.pad, self.pad), mode="replicate")
|
||||
hidden_states = self.ratio * F.conv_transpose1d(
|
||||
hidden_states,
|
||||
self.filter.expand(num_channels, -1, -1),
|
||||
stride=self.stride,
|
||||
groups=num_channels,
|
||||
)
|
||||
return hidden_states[..., self.pad_left:-self.pad_right]
|
||||
|
||||
|
||||
class MiniMaxH3AudioDownSample1d(nn.Module):
|
||||
|
||||
def __init__(self, ratio: int, kernel_size: int):
|
||||
super().__init__()
|
||||
self.lowpass = MiniMaxH3AudioLowPassFilter1d(
|
||||
cutoff=0.5 / ratio,
|
||||
half_width=0.6 / ratio,
|
||||
stride=ratio,
|
||||
kernel_size=kernel_size,
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
return self.lowpass(hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3AudioActivation1d(nn.Module):
|
||||
|
||||
def __init__(self, activation: nn.Module, ratio: int = 2, kernel_size: int = 12):
|
||||
super().__init__()
|
||||
self.act = activation
|
||||
self.upsample = MiniMaxH3AudioUpSample1d(ratio, kernel_size)
|
||||
self.downsample = MiniMaxH3AudioDownSample1d(ratio, kernel_size)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states = self.upsample(hidden_states)
|
||||
hidden_states = self.act(hidden_states)
|
||||
return self.downsample(hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3AudioResidualUnit(nn.Module):
|
||||
|
||||
def __init__(self, dim: int, dilation: int):
|
||||
super().__init__()
|
||||
self.block = nn.Sequential(
|
||||
MiniMaxH3AudioSnake1d(dim),
|
||||
_wn_conv1d(dim, dim, kernel_size=7, dilation=dilation, padding=((7 - 1) * dilation) // 2),
|
||||
MiniMaxH3AudioSnake1d(dim),
|
||||
_wn_conv1d(dim, dim, kernel_size=1),
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
residual = self.block(hidden_states)
|
||||
pad = (hidden_states.shape[-1] - residual.shape[-1]) // 2
|
||||
if pad > 0:
|
||||
hidden_states = hidden_states[..., pad:-pad]
|
||||
return hidden_states + residual
|
||||
|
||||
|
||||
class MiniMaxH3AudioEncoderBlock(nn.Module):
|
||||
|
||||
def __init__(self, dim: int, stride: int):
|
||||
super().__init__()
|
||||
self.block = nn.Sequential(
|
||||
MiniMaxH3AudioResidualUnit(dim // 2, dilation=1),
|
||||
MiniMaxH3AudioResidualUnit(dim // 2, dilation=3),
|
||||
MiniMaxH3AudioResidualUnit(dim // 2, dilation=9),
|
||||
MiniMaxH3AudioSnake1d(dim // 2),
|
||||
_wn_conv1d(
|
||||
dim // 2,
|
||||
dim,
|
||||
kernel_size=2 * stride,
|
||||
stride=stride,
|
||||
padding=math.ceil(stride / 2),
|
||||
),
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
return self.block(hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3AudioEncoder(nn.Module):
|
||||
|
||||
def __init__(self, d_model: int, strides: tuple[int, ...], d_latent: int):
|
||||
super().__init__()
|
||||
block: list[nn.Module] = [_wn_conv1d(1, d_model, kernel_size=7, padding=3)]
|
||||
for stride in strides:
|
||||
d_model *= 2
|
||||
block.append(MiniMaxH3AudioEncoderBlock(d_model, stride=stride))
|
||||
block += [
|
||||
MiniMaxH3AudioSnake1d(d_model),
|
||||
_wn_conv1d(d_model, d_latent, kernel_size=3, padding=1),
|
||||
]
|
||||
self.block = nn.Sequential(*block)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
return self.block(hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3AudioGeGluMlp(nn.Module):
|
||||
|
||||
def __init__(self, in_features: int, hidden_features: int):
|
||||
super().__init__()
|
||||
self.norm = nn.LayerNorm(in_features)
|
||||
self.act = nn.GELU(approximate="tanh")
|
||||
self.w0 = ReplicatedLinear(in_features, hidden_features, params_dtype=torch.float32)
|
||||
self.w1 = ReplicatedLinear(in_features, hidden_features, params_dtype=torch.float32)
|
||||
self.w2 = ReplicatedLinear(hidden_features, in_features, params_dtype=torch.float32)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states = self.norm(hidden_states)
|
||||
hidden_states = self.act(_linear(self.w0, hidden_states)) * _linear(self.w1, hidden_states)
|
||||
return _linear(self.w2, hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3AudioCausalAttention(nn.Module):
|
||||
"""Causal projection from the encoder trunk width to the latent width."""
|
||||
|
||||
def __init__(self, in_dim: int, out_dim: int, num_heads: int):
|
||||
super().__init__()
|
||||
self.out_dim = out_dim
|
||||
self.num_heads = num_heads
|
||||
self.head_dim = in_dim // num_heads
|
||||
self.qkv = ReplicatedLinear(in_dim, in_dim * 3, bias=False, params_dtype=torch.float32)
|
||||
self.q_bias = nn.Parameter(torch.zeros(in_dim))
|
||||
self.v_bias = nn.Parameter(torch.zeros(in_dim))
|
||||
self.register_buffer("zero_k_bias", torch.zeros(in_dim))
|
||||
self.proj = ReplicatedLinear(out_dim, out_dim, params_dtype=torch.float32)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
batch_size, seq_len, _ = hidden_states.shape
|
||||
qkv = F.linear(
|
||||
hidden_states,
|
||||
self.qkv.weight,
|
||||
torch.cat((self.q_bias, self.zero_k_bias, self.v_bias)),
|
||||
)
|
||||
query, key, value = (
|
||||
qkv.reshape(batch_size, seq_len, 3, self.num_heads, self.head_dim).permute(2, 0, 1, 3, 4).unbind(0)
|
||||
)
|
||||
|
||||
# H3's projection is an unusual causal flat stream, not a VAE spatial-attention block.
|
||||
hidden_states = F.scaled_dot_product_attention(
|
||||
query.transpose(1, 2),
|
||||
key.transpose(1, 2),
|
||||
value.transpose(1, 2),
|
||||
dropout_p=0.0,
|
||||
is_causal=True,
|
||||
).transpose(1, 2)
|
||||
hidden_states = torch.mean(hidden_states, dim=2)
|
||||
hidden_states = F.adaptive_avg_pool1d(hidden_states, self.out_dim)
|
||||
return _linear(self.proj, hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3AudioAttnProjection(nn.Module):
|
||||
|
||||
def __init__(self, in_dim: int, out_dim: int, num_heads: int, mlp_ratio: int = 2):
|
||||
super().__init__()
|
||||
self.norm1 = nn.LayerNorm(in_dim)
|
||||
self.attn = MiniMaxH3AudioCausalAttention(in_dim, out_dim, num_heads)
|
||||
self.proj = ReplicatedLinear(in_dim, out_dim, params_dtype=torch.float32)
|
||||
self.norm3 = nn.LayerNorm(in_dim)
|
||||
self.norm2 = nn.LayerNorm(out_dim)
|
||||
self.mlp = MiniMaxH3AudioGeGluMlp(in_features=out_dim, hidden_features=out_dim * mlp_ratio)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states = _linear(self.proj, self.norm3(hidden_states)) + self.attn(self.norm1(hidden_states))
|
||||
return hidden_states + self.mlp(self.norm2(hidden_states))
|
||||
|
||||
|
||||
class MiniMaxH3AudioAMPBlock(nn.Module):
|
||||
|
||||
def __init__(self, channels: int, kernel_size: int, dilation: tuple[int, ...]):
|
||||
super().__init__()
|
||||
self.convs1 = nn.ModuleList([
|
||||
_wn_conv1d(channels, channels, kernel_size, dilation=d, padding=(kernel_size * d - d) // 2)
|
||||
for d in dilation
|
||||
])
|
||||
self.convs2 = nn.ModuleList([
|
||||
_wn_conv1d(channels, channels, kernel_size, dilation=1, padding=(kernel_size - 1) // 2)
|
||||
for _ in dilation
|
||||
])
|
||||
self.activations = nn.ModuleList([
|
||||
MiniMaxH3AudioActivation1d(activation=MiniMaxH3AudioSnakeBeta(channels))
|
||||
for _ in range(2 * len(dilation))
|
||||
])
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
acts1, acts2 = self.activations[::2], self.activations[1::2]
|
||||
for conv1, conv2, act1, act2 in zip(self.convs1, self.convs2, acts1, acts2):
|
||||
residual = conv1(act1(hidden_states))
|
||||
residual = conv2(act2(residual))
|
||||
hidden_states = residual + hidden_states
|
||||
return hidden_states
|
||||
|
||||
|
||||
class MiniMaxH3AudioBigVGANDecoder(nn.Module):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
upsample_initial_channel: int,
|
||||
upsample_rates: tuple[int, ...],
|
||||
upsample_kernel_sizes: tuple[int, ...],
|
||||
resblock_kernel_sizes: tuple[int, ...],
|
||||
resblock_dilation_sizes: tuple[tuple[int, ...], ...],
|
||||
):
|
||||
super().__init__()
|
||||
self.num_kernels = len(resblock_kernel_sizes)
|
||||
self.num_upsamples = len(upsample_rates)
|
||||
self.conv_pre = _wn_conv1d(in_channels, upsample_initial_channel, 7, 1, padding=3)
|
||||
|
||||
self.ups = nn.ModuleList()
|
||||
for i, (rate, kernel) in enumerate(zip(upsample_rates, upsample_kernel_sizes)):
|
||||
self.ups.append(nn.ModuleList([
|
||||
weight_norm(nn.ConvTranspose1d(
|
||||
upsample_initial_channel // (2**i),
|
||||
upsample_initial_channel // (2 ** (i + 1)),
|
||||
kernel,
|
||||
rate,
|
||||
padding=(kernel - rate) // 2,
|
||||
))
|
||||
]))
|
||||
|
||||
self.resblocks = nn.ModuleList()
|
||||
for i in range(self.num_upsamples):
|
||||
channels = upsample_initial_channel // (2 ** (i + 1))
|
||||
for kernel, dilation in zip(resblock_kernel_sizes, resblock_dilation_sizes):
|
||||
self.resblocks.append(MiniMaxH3AudioAMPBlock(channels, kernel, tuple(dilation)))
|
||||
|
||||
self.activation_post = MiniMaxH3AudioActivation1d(activation=MiniMaxH3AudioSnakeBeta(channels))
|
||||
self.conv_post = _wn_conv1d(channels, 1, 7, 1, padding=3, bias=False)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states = self.conv_pre(hidden_states)
|
||||
for i in range(self.num_upsamples):
|
||||
hidden_states = self.ups[i][0](hidden_states)
|
||||
residual = None
|
||||
for j in range(self.num_kernels):
|
||||
block = self.resblocks[i * self.num_kernels + j](hidden_states)
|
||||
residual = block if residual is None else residual + block
|
||||
hidden_states = residual / self.num_kernels
|
||||
|
||||
hidden_states = self.activation_post(hidden_states)
|
||||
hidden_states = self.conv_post(hidden_states)
|
||||
return torch.clamp(hidden_states, min=-1.0, max=1.0)
|
||||
|
||||
|
||||
class MiniMaxH3AudioVAE(nn.Module):
|
||||
"""DAC encoder plus BigVGAN decoder for mono 32 kHz waveforms."""
|
||||
|
||||
def __init__(self, config: MiniMaxH3AudioVAEConfig):
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.fastvideo_config = config
|
||||
arch = config.arch_config
|
||||
|
||||
encoder_rates = tuple(int(rate) for rate in arch.encoder_rates)
|
||||
decoder_rates = tuple(int(rate) for rate in arch.decoder_rates)
|
||||
self.hop_length = math.prod(encoder_rates)
|
||||
self.sampling_rate = int(arch.sampling_rate)
|
||||
self.latent_channels = int(arch.latent_channels)
|
||||
self.audio_channels = 1
|
||||
latents_mean = arch.latents_mean if arch.latents_mean is not None else [0.0] * self.latent_channels
|
||||
latents_std = arch.latents_std if arch.latents_std is not None else [1.0] * self.latent_channels
|
||||
self.register_buffer(
|
||||
"latents_mean",
|
||||
torch.tensor(latents_mean, dtype=torch.float32).view(1, -1, 1),
|
||||
persistent=False,
|
||||
)
|
||||
self.register_buffer(
|
||||
"latents_std",
|
||||
torch.tensor(latents_std, dtype=torch.float32).view(1, -1, 1),
|
||||
persistent=False,
|
||||
)
|
||||
|
||||
if math.prod(decoder_rates) != self.hop_length:
|
||||
raise ValueError(
|
||||
f"`decoder_rates` must upsample by the encoder hop length {self.hop_length}, got "
|
||||
f"{math.prod(decoder_rates)}."
|
||||
)
|
||||
if arch.latent_dim % arch.latent_channels != 0:
|
||||
raise ValueError(
|
||||
f"`latent_dim` ({arch.latent_dim}) must be a multiple of `latent_channels` "
|
||||
f"({arch.latent_channels})."
|
||||
)
|
||||
|
||||
self.encoder = MiniMaxH3AudioEncoder(
|
||||
d_model=arch.encoder_dim,
|
||||
strides=encoder_rates,
|
||||
d_latent=arch.latent_dim,
|
||||
)
|
||||
self.pre_block = MiniMaxH3AudioAttnProjection(
|
||||
arch.latent_dim,
|
||||
arch.latent_channels,
|
||||
num_heads=arch.num_attention_heads,
|
||||
)
|
||||
self.mean_proj = nn.Conv1d(arch.latent_channels, arch.latent_channels, 1)
|
||||
self.logs_proj = nn.Conv1d(arch.latent_channels, arch.latent_channels, 1)
|
||||
|
||||
self.dec_in_proj = nn.Conv1d(arch.latent_channels, arch.latent_dim, 1)
|
||||
self.decoder = MiniMaxH3AudioBigVGANDecoder(
|
||||
in_channels=arch.latent_dim,
|
||||
upsample_initial_channel=arch.decoder_dim,
|
||||
upsample_rates=decoder_rates,
|
||||
upsample_kernel_sizes=tuple(int(kernel) for kernel in arch.decoder_kernel_sizes),
|
||||
resblock_kernel_sizes=tuple(int(kernel) for kernel in arch.resblock_kernel_sizes),
|
||||
resblock_dilation_sizes=tuple(
|
||||
tuple(int(dilation) for dilation in group) for group in arch.resblock_dilation_sizes
|
||||
),
|
||||
)
|
||||
|
||||
# The H3 checkpoint and waveform numerics require this component to stay FP32.
|
||||
self.float()
|
||||
|
||||
def encode(
|
||||
self,
|
||||
sample: torch.Tensor,
|
||||
return_dict: bool = True,
|
||||
) -> MiniMaxH3AudioEncoderOutput | tuple[MiniMaxH3AudioDiagonalGaussianDistribution]:
|
||||
if sample.ndim != 3 or sample.shape[1] != 1:
|
||||
raise ValueError(f"`sample` must have shape [batch_size, 1, samples], got {tuple(sample.shape)}.")
|
||||
|
||||
right_pad = math.ceil(sample.shape[-1] / self.hop_length) * self.hop_length - sample.shape[-1]
|
||||
if right_pad > 0:
|
||||
sample = F.pad(sample, (0, right_pad))
|
||||
|
||||
encoder_dtype = _module_dtype(self.encoder)
|
||||
hidden_states = self.encoder(sample.to(encoder_dtype))
|
||||
hidden_states = self.pre_block(hidden_states.transpose(1, 2)).transpose(1, 2)
|
||||
mean = self.mean_proj(hidden_states)
|
||||
logs = self.logs_proj(hidden_states)
|
||||
if encoder_dtype != torch.float32:
|
||||
mean = mean.float()
|
||||
logs = logs.float()
|
||||
|
||||
posterior = MiniMaxH3AudioDiagonalGaussianDistribution(mean, logs)
|
||||
if not return_dict:
|
||||
return (posterior,)
|
||||
return MiniMaxH3AudioEncoderOutput(latent_dist=posterior)
|
||||
|
||||
def normalize_latents(self, latents: torch.Tensor) -> torch.Tensor:
|
||||
return (latents - self.latents_mean) / self.latents_std
|
||||
|
||||
def denormalize_latents(self, latents: torch.Tensor) -> torch.Tensor:
|
||||
return latents * self.latents_std + self.latents_mean
|
||||
|
||||
def decode(
|
||||
self,
|
||||
latents: torch.Tensor,
|
||||
return_dict: bool = True,
|
||||
) -> MiniMaxH3AudioDecoderOutput | tuple[torch.Tensor]:
|
||||
if latents.ndim != 3:
|
||||
raise ValueError(
|
||||
f"`latents` must have shape [batch_size, latent_channels, num_frames], got {tuple(latents.shape)}."
|
||||
)
|
||||
|
||||
decoder_dtype = _module_dtype(self.decoder)
|
||||
decoded = self.decoder(self.dec_in_proj(latents.to(decoder_dtype)))
|
||||
if decoder_dtype != torch.float32:
|
||||
decoded = decoded.float()
|
||||
|
||||
if not return_dict:
|
||||
return (decoded,)
|
||||
return MiniMaxH3AudioDecoderOutput(sample=decoded)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
sample: torch.Tensor,
|
||||
sample_posterior: bool = False,
|
||||
return_dict: bool = True,
|
||||
generator: torch.Generator | None = None,
|
||||
) -> MiniMaxH3AudioDecoderOutput | tuple[torch.Tensor]:
|
||||
posterior = self.encode(sample).latent_dist
|
||||
latents = posterior.sample(generator=generator) if sample_posterior else posterior.mode()
|
||||
return self.decode(latents, return_dict=return_dict)
|
||||
|
||||
|
||||
# Keep the Diffusers checkpoint class name loadable through FastVideo's registry.
|
||||
AutoencoderKLMiniMaxH3Audio = MiniMaxH3AudioVAE
|
||||
EntryClass = [MiniMaxH3AudioVAE, AutoencoderKLMiniMaxH3Audio]
|
||||
@@ -0,0 +1,847 @@
|
||||
# Copyright 2026 The MiniMax and HuggingFace Teams. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Native MiniMax-H3 video VAE.
|
||||
|
||||
The encoder is a causal 3D CNN while the decoder is a full-attention ViT.
|
||||
This module intentionally uses only PyTorch and FastVideo configuration types.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch.utils.checkpoint import checkpoint
|
||||
|
||||
from fastvideo.configs.models.vaes.minimax_h3_video import MiniMaxH3VideoVAEConfig
|
||||
|
||||
|
||||
class DiagonalGaussianDistribution:
|
||||
"""Diagonal Gaussian posterior used by the KL encoder."""
|
||||
|
||||
def __init__(self, parameters: torch.Tensor, deterministic: bool = False) -> None:
|
||||
self.parameters = parameters
|
||||
self.mean, self.logvar = torch.chunk(parameters, 2, dim=1)
|
||||
self.logvar = torch.clamp(self.logvar, -30.0, 20.0)
|
||||
self.deterministic = deterministic
|
||||
self.std = torch.exp(0.5 * self.logvar)
|
||||
self.var = torch.exp(self.logvar)
|
||||
if deterministic:
|
||||
self.std = torch.zeros_like(self.mean)
|
||||
self.var = torch.zeros_like(self.mean)
|
||||
|
||||
def sample(self, generator: torch.Generator | None = None) -> torch.Tensor:
|
||||
noise_device = self.mean.device
|
||||
if generator is not None and generator.device.type == "cpu":
|
||||
noise_device = torch.device("cpu")
|
||||
noise = torch.randn(
|
||||
self.mean.shape,
|
||||
generator=generator,
|
||||
device=noise_device,
|
||||
dtype=self.mean.dtype,
|
||||
).to(self.mean.device)
|
||||
return self.mean + self.std * noise
|
||||
|
||||
def mode(self) -> torch.Tensor:
|
||||
return self.mean
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutoencoderKLOutput:
|
||||
latent_dist: DiagonalGaussianDistribution
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecoderOutput:
|
||||
sample: torch.Tensor
|
||||
|
||||
|
||||
class MiniMaxH3VideoCausalConv3d(nn.Conv3d):
|
||||
"""3D convolution with reflect spatial padding and causal temporal padding."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
out_channels: int,
|
||||
kernel_size: int | tuple[int, int, int],
|
||||
stride: int | tuple[int, int, int] = 1,
|
||||
spatial_padding: int = 0,
|
||||
temporal_padding: int = 0,
|
||||
spatial_padding_mode: str = "reflect",
|
||||
) -> None:
|
||||
super().__init__(in_channels, out_channels, kernel_size=kernel_size, stride=stride, padding=0)
|
||||
self.spatial_padding = spatial_padding
|
||||
self.temporal_padding = temporal_padding
|
||||
self.spatial_padding_mode = spatial_padding_mode
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
if self.spatial_padding > 0:
|
||||
padding = self.spatial_padding
|
||||
hidden_states = F.pad(
|
||||
hidden_states,
|
||||
(padding, padding, padding, padding, 0, 0),
|
||||
mode=self.spatial_padding_mode,
|
||||
)
|
||||
if self.temporal_padding > 0:
|
||||
hidden_states = F.pad(hidden_states, (0, 0, 0, 0, self.temporal_padding, 0), mode="constant")
|
||||
return F.conv3d(hidden_states, self.weight, self.bias, stride=self.stride, padding=0, dilation=self.dilation)
|
||||
|
||||
|
||||
class MiniMaxH3VideoGroupNorm(nn.GroupNorm):
|
||||
"""GroupNorm with each temporal frame normalized independently."""
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
batch_size, num_channels, num_frames, height, width = hidden_states.shape
|
||||
hidden_states = hidden_states.permute(0, 2, 1, 3, 4).contiguous()
|
||||
hidden_states = hidden_states.view(batch_size * num_frames, num_channels, 1, height, width)
|
||||
hidden_states = super().forward(hidden_states)
|
||||
hidden_states = hidden_states.view(batch_size, num_frames, num_channels, height, width)
|
||||
return hidden_states.permute(0, 2, 1, 3, 4).contiguous()
|
||||
|
||||
|
||||
class MiniMaxH3VideoResnetBlock3d(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
out_channels: int,
|
||||
norm_num_groups: int = 32,
|
||||
norm_eps: float = 1e-6,
|
||||
spatial_padding_mode: str = "reflect",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.in_channels = in_channels
|
||||
self.out_channels = out_channels
|
||||
self.norm1 = MiniMaxH3VideoGroupNorm(norm_num_groups, in_channels, eps=norm_eps, affine=True)
|
||||
self.conv1 = MiniMaxH3VideoCausalConv3d(
|
||||
in_channels,
|
||||
out_channels,
|
||||
kernel_size=3,
|
||||
spatial_padding=1,
|
||||
temporal_padding=2,
|
||||
spatial_padding_mode=spatial_padding_mode,
|
||||
)
|
||||
self.norm2 = MiniMaxH3VideoGroupNorm(norm_num_groups, out_channels, eps=norm_eps, affine=True)
|
||||
self.conv2 = MiniMaxH3VideoCausalConv3d(
|
||||
out_channels,
|
||||
out_channels,
|
||||
kernel_size=3,
|
||||
spatial_padding=1,
|
||||
temporal_padding=2,
|
||||
spatial_padding_mode=spatial_padding_mode,
|
||||
)
|
||||
self.conv_shortcut = None
|
||||
if in_channels != out_channels:
|
||||
self.conv_shortcut = MiniMaxH3VideoCausalConv3d(in_channels, out_channels, kernel_size=1)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
residual = hidden_states
|
||||
hidden_states = self.conv1(F.silu(self.norm1(hidden_states)))
|
||||
hidden_states = self.conv2(F.silu(self.norm2(hidden_states)))
|
||||
if self.conv_shortcut is not None:
|
||||
residual = self.conv_shortcut(residual)
|
||||
return residual + hidden_states
|
||||
|
||||
|
||||
class MiniMaxH3VideoDownsample3d(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
out_channels: int,
|
||||
temporal_stride: int = 1,
|
||||
spatial_stride: int = 2,
|
||||
spatial_padding_mode: str = "reflect",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.spatial_stride = spatial_stride
|
||||
self.spatial_padding_mode = spatial_padding_mode
|
||||
self.conv = MiniMaxH3VideoCausalConv3d(
|
||||
in_channels,
|
||||
out_channels,
|
||||
kernel_size=3,
|
||||
stride=(temporal_stride, spatial_stride, spatial_stride),
|
||||
temporal_padding=2,
|
||||
spatial_padding_mode=spatial_padding_mode,
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
if self.spatial_stride == 2:
|
||||
hidden_states = F.pad(hidden_states, (0, 1, 0, 1, 0, 0), mode=self.spatial_padding_mode)
|
||||
return self.conv(hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3VideoDownBlock3d(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
out_channels: int,
|
||||
num_layers: int,
|
||||
temporal_downsample_factor: int,
|
||||
spatial_downsample_factor: int,
|
||||
norm_num_groups: int = 32,
|
||||
norm_eps: float = 1e-6,
|
||||
spatial_padding_mode: str = "reflect",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.resnets = nn.ModuleList(
|
||||
[
|
||||
MiniMaxH3VideoResnetBlock3d(
|
||||
in_channels=in_channels if index == 0 else out_channels,
|
||||
out_channels=out_channels,
|
||||
norm_num_groups=norm_num_groups,
|
||||
norm_eps=norm_eps,
|
||||
spatial_padding_mode=spatial_padding_mode,
|
||||
)
|
||||
for index in range(num_layers)
|
||||
]
|
||||
)
|
||||
self.downsamplers = None
|
||||
if temporal_downsample_factor * spatial_downsample_factor > 1:
|
||||
self.downsamplers = nn.ModuleList(
|
||||
[
|
||||
MiniMaxH3VideoDownsample3d(
|
||||
out_channels,
|
||||
out_channels,
|
||||
temporal_stride=temporal_downsample_factor,
|
||||
spatial_stride=spatial_downsample_factor,
|
||||
spatial_padding_mode=spatial_padding_mode,
|
||||
)
|
||||
]
|
||||
)
|
||||
self.gradient_checkpointing = False
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
for resnet in self.resnets:
|
||||
if torch.is_grad_enabled() and self.gradient_checkpointing:
|
||||
hidden_states = checkpoint(resnet, hidden_states, use_reentrant=False)
|
||||
else:
|
||||
hidden_states = resnet(hidden_states)
|
||||
if self.downsamplers is not None:
|
||||
for downsampler in self.downsamplers:
|
||||
hidden_states = downsampler(hidden_states)
|
||||
return hidden_states
|
||||
|
||||
|
||||
class MiniMaxH3VideoEncoder3d(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
out_channels: int,
|
||||
block_out_channels: tuple[int, ...],
|
||||
layers_per_block: int,
|
||||
spatial_downsample_factors: tuple[int, ...],
|
||||
temporal_downsample_factors: tuple[int, ...],
|
||||
norm_num_groups: int,
|
||||
norm_eps: float,
|
||||
spatial_padding_mode: str,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.conv_in = MiniMaxH3VideoCausalConv3d(
|
||||
in_channels,
|
||||
block_out_channels[0],
|
||||
kernel_size=3,
|
||||
spatial_padding=1,
|
||||
temporal_padding=2,
|
||||
spatial_padding_mode=spatial_padding_mode,
|
||||
)
|
||||
block_in_channels = (block_out_channels[0],) + tuple(block_out_channels[:-1])
|
||||
self.down_blocks = nn.ModuleList(
|
||||
[
|
||||
MiniMaxH3VideoDownBlock3d(
|
||||
in_channels=block_in_channels[index],
|
||||
out_channels=block_out_channels[index],
|
||||
num_layers=layers_per_block,
|
||||
temporal_downsample_factor=temporal_downsample_factors[index],
|
||||
spatial_downsample_factor=spatial_downsample_factors[index],
|
||||
norm_num_groups=norm_num_groups,
|
||||
norm_eps=norm_eps,
|
||||
spatial_padding_mode=spatial_padding_mode,
|
||||
)
|
||||
for index in range(len(block_out_channels))
|
||||
]
|
||||
)
|
||||
self.norm_out = MiniMaxH3VideoGroupNorm(norm_num_groups, block_out_channels[-1], eps=norm_eps, affine=True)
|
||||
self.conv_out = MiniMaxH3VideoCausalConv3d(
|
||||
block_out_channels[-1],
|
||||
out_channels,
|
||||
kernel_size=3,
|
||||
spatial_padding=1,
|
||||
temporal_padding=2,
|
||||
spatial_padding_mode=spatial_padding_mode,
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states = self.conv_in(hidden_states)
|
||||
for down_block in self.down_blocks:
|
||||
hidden_states = down_block(hidden_states)
|
||||
return self.conv_out(F.silu(self.norm_out(hidden_states)))
|
||||
|
||||
|
||||
class MiniMaxH3VideoRotaryPosEmbed(nn.Module):
|
||||
def __init__(self, dim: int, theta: float = 100.0, num_axes: int = 3) -> None:
|
||||
super().__init__()
|
||||
if dim % (2 * num_axes) != 0:
|
||||
raise ValueError(f"`dim` {dim} must be divisible by `2 * num_axes` {2 * num_axes}.")
|
||||
inv_freq = 1.0 / theta ** torch.arange(0, 1, 2 * num_axes / dim, dtype=torch.float32)
|
||||
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
||||
|
||||
def forward(self, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
angles = 2.0 * math.pi * position_ids[:, :, :, None] * self.inv_freq[None, None, None, :]
|
||||
angles = angles.flatten(2, 3).tile(2).unsqueeze(2)
|
||||
return angles.cos(), angles.sin()
|
||||
|
||||
|
||||
class MiniMaxH3VideoAttention(nn.Module):
|
||||
def __init__(self, dim: int, heads: int, dim_head: int, eps: float = 1e-5, bias: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.heads = heads
|
||||
self.dim_head = dim_head
|
||||
self.use_bias = bias
|
||||
inner_dim = heads * dim_head
|
||||
self.norm_q = nn.RMSNorm(dim_head, eps=eps, elementwise_affine=False)
|
||||
self.norm_k = nn.RMSNorm(dim_head, eps=eps, elementwise_affine=False)
|
||||
self.to_q = nn.Linear(dim, inner_dim, bias=bias)
|
||||
self.to_k = nn.Linear(dim, inner_dim, bias=bias)
|
||||
self.to_v = nn.Linear(dim, inner_dim, bias=bias)
|
||||
self.to_out = nn.ModuleList([nn.Linear(inner_dim, dim, bias=bias), nn.Dropout(0.0)])
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None,
|
||||
) -> torch.Tensor:
|
||||
query = self.to_q(hidden_states).unflatten(2, (self.heads, -1))
|
||||
key = self.to_k(hidden_states).unflatten(2, (self.heads, -1))
|
||||
value = self.to_v(hidden_states).unflatten(2, (self.heads, -1))
|
||||
|
||||
query = self.norm_q(query.float()).to(query.dtype)
|
||||
key = self.norm_k(key.float()).to(key.dtype)
|
||||
if rotary_emb is not None:
|
||||
cos, sin = rotary_emb
|
||||
cos = cos.to(query.dtype)
|
||||
sin = sin.to(query.dtype)
|
||||
rotary_dim = cos.shape[-1]
|
||||
query_rotary, query_pass = query[..., :rotary_dim], query[..., rotary_dim:]
|
||||
key_rotary, key_pass = key[..., :rotary_dim], key[..., rotary_dim:]
|
||||
query_first, query_second = query_rotary.chunk(2, dim=-1)
|
||||
key_first, key_second = key_rotary.chunk(2, dim=-1)
|
||||
query_rotated = torch.cat([-query_second, query_first], dim=-1)
|
||||
key_rotated = torch.cat([-key_second, key_first], dim=-1)
|
||||
query = torch.cat([query_rotary * cos + query_rotated * sin, query_pass], dim=-1)
|
||||
key = torch.cat([key_rotary * cos + key_rotated * sin, key_pass], dim=-1)
|
||||
|
||||
query, key, value = (tensor.permute(0, 2, 1, 3) for tensor in (query, key, value))
|
||||
hidden_states = F.scaled_dot_product_attention(query, key, value)
|
||||
hidden_states = hidden_states.permute(0, 2, 1, 3).flatten(2, 3)
|
||||
return self.to_out[0](hidden_states)
|
||||
|
||||
|
||||
class MiniMaxH3VideoSwiGLU(nn.Module):
|
||||
def __init__(self, dim_in: int, dim_out: int, bias: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.proj = nn.Linear(dim_in, dim_out * 2, bias=bias)
|
||||
self.activation = nn.SiLU()
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states, gate = self.proj(hidden_states).chunk(2, dim=-1)
|
||||
return hidden_states * self.activation(gate)
|
||||
|
||||
|
||||
class MiniMaxH3VideoFeedForward(nn.Module):
|
||||
def __init__(self, dim: int, mult: int = 4, bias: bool = True) -> None:
|
||||
super().__init__()
|
||||
inner_dim = int(dim * mult)
|
||||
self.net = nn.ModuleList(
|
||||
[
|
||||
MiniMaxH3VideoSwiGLU(dim, inner_dim, bias=bias),
|
||||
nn.Dropout(0.0),
|
||||
nn.Linear(inner_dim, dim, bias=bias),
|
||||
]
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
for module in self.net:
|
||||
hidden_states = module(hidden_states)
|
||||
return hidden_states
|
||||
|
||||
|
||||
class MiniMaxH3VideoTransformerBlock(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
dim: int,
|
||||
heads: int,
|
||||
dim_head: int,
|
||||
ffn_mult: int = 4,
|
||||
eps: float = 1e-5,
|
||||
bias: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.norm1 = nn.RMSNorm(dim, eps=eps, elementwise_affine=True)
|
||||
self.attn = MiniMaxH3VideoAttention(dim=dim, heads=heads, dim_head=dim_head, eps=eps, bias=bias)
|
||||
self.scale1 = nn.Parameter(torch.zeros(dim))
|
||||
self.norm2 = nn.RMSNorm(dim, eps=eps, elementwise_affine=True)
|
||||
self.ff = MiniMaxH3VideoFeedForward(dim, mult=ffn_mult, bias=bias)
|
||||
self.scale2 = nn.Parameter(torch.zeros(dim))
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None,
|
||||
) -> torch.Tensor:
|
||||
norm_hidden_states = self.norm1(hidden_states.float()).to(hidden_states.dtype)
|
||||
hidden_states = hidden_states + self.attn(norm_hidden_states, rotary_emb) * self.scale1
|
||||
norm_hidden_states = self.norm2(hidden_states.float()).to(hidden_states.dtype)
|
||||
return hidden_states + self.ff(norm_hidden_states) * self.scale2
|
||||
|
||||
|
||||
class MiniMaxH3VideoViTDecoder3d(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
out_channels: int,
|
||||
patch_size: int,
|
||||
patch_size_t: int,
|
||||
num_layers: int,
|
||||
num_attention_heads: int,
|
||||
attention_head_dim: int,
|
||||
num_register_tokens: int,
|
||||
ffn_mult: int,
|
||||
rope_theta: float,
|
||||
rope_dim_ratio: float,
|
||||
norm_eps: float,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
dim = num_attention_heads * attention_head_dim
|
||||
self.patch_size = patch_size
|
||||
self.patch_size_t = patch_size_t
|
||||
self.out_channels = out_channels
|
||||
self.num_register_tokens = num_register_tokens
|
||||
self.rope = MiniMaxH3VideoRotaryPosEmbed(int(attention_head_dim * rope_dim_ratio), theta=rope_theta)
|
||||
self.proj_in = nn.Linear(in_channels, dim)
|
||||
self.register_tokens = nn.Parameter(torch.zeros(1, num_register_tokens, dim))
|
||||
self.transformer_blocks = nn.ModuleList(
|
||||
[
|
||||
MiniMaxH3VideoTransformerBlock(
|
||||
dim=dim,
|
||||
heads=num_attention_heads,
|
||||
dim_head=attention_head_dim,
|
||||
ffn_mult=ffn_mult,
|
||||
eps=norm_eps,
|
||||
)
|
||||
for _ in range(num_layers)
|
||||
]
|
||||
)
|
||||
self.norm_out = nn.LayerNorm(dim, elementwise_affine=True, eps=norm_eps)
|
||||
self.proj_out = nn.Linear(dim, out_channels * patch_size_t * patch_size * patch_size)
|
||||
self.gradient_checkpointing = False
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
batch_size, num_channels, num_frames, height, width = hidden_states.shape
|
||||
hidden_states = hidden_states.permute(0, 2, 3, 4, 1).reshape(
|
||||
batch_size,
|
||||
num_frames * height * width,
|
||||
num_channels,
|
||||
)
|
||||
hidden_states = self.proj_in(hidden_states)
|
||||
num_patches = hidden_states.shape[1]
|
||||
register_tokens = self.register_tokens.expand(batch_size, -1, -1)
|
||||
cls_token = torch.zeros_like(hidden_states[:, :1, :])
|
||||
hidden_states = torch.cat([hidden_states, register_tokens, cls_token], dim=1)
|
||||
|
||||
grids = [
|
||||
2.0 * (torch.arange(0.5, size, dtype=torch.float32, device=hidden_states.device) / size) - 1.0
|
||||
for size in (num_frames, height, width)
|
||||
]
|
||||
position_ids = torch.stack(torch.meshgrid(*grids, indexing="ij"), dim=-1).flatten(0, 2)
|
||||
position_ids = position_ids.unsqueeze(0).expand(batch_size, -1, -1)
|
||||
suffix_ids = position_ids.new_zeros((batch_size, self.num_register_tokens + 1, 3))
|
||||
rotary_emb = self.rope(torch.cat([position_ids, suffix_ids], dim=1))
|
||||
|
||||
for block in self.transformer_blocks:
|
||||
if torch.is_grad_enabled() and self.gradient_checkpointing:
|
||||
hidden_states = checkpoint(block, hidden_states, rotary_emb, use_reentrant=False)
|
||||
else:
|
||||
hidden_states = block(hidden_states, rotary_emb)
|
||||
|
||||
hidden_states = self.proj_out(self.norm_out(hidden_states))[:, :num_patches, :]
|
||||
patch_size, patch_size_t = self.patch_size, self.patch_size_t
|
||||
hidden_states = hidden_states.view(
|
||||
batch_size,
|
||||
num_frames,
|
||||
height,
|
||||
width,
|
||||
self.out_channels,
|
||||
patch_size_t,
|
||||
patch_size,
|
||||
patch_size,
|
||||
)
|
||||
hidden_states = hidden_states.permute(0, 4, 1, 5, 2, 6, 3, 7).contiguous()
|
||||
return hidden_states.reshape(
|
||||
batch_size,
|
||||
self.out_channels,
|
||||
num_frames * patch_size_t,
|
||||
height * patch_size,
|
||||
width * patch_size,
|
||||
)
|
||||
|
||||
|
||||
class AutoencoderKLMiniMaxH3(nn.Module):
|
||||
"""MiniMax-H3 causal encoder and ViT decoder with exact release geometry."""
|
||||
|
||||
_supports_gradient_checkpointing = True
|
||||
_no_split_modules = ["MiniMaxH3VideoResnetBlock3d", "MiniMaxH3VideoTransformerBlock"]
|
||||
_repeated_blocks = ["MiniMaxH3VideoTransformerBlock"]
|
||||
_keep_in_fp32_modules = ["encoder", "decoder", "quant_conv", "post_quant_conv"]
|
||||
|
||||
def __init__(self, config: MiniMaxH3VideoVAEConfig) -> None:
|
||||
super().__init__()
|
||||
self.model_config = config
|
||||
self.config = config.arch_config
|
||||
arch = config.arch_config
|
||||
self.latent_channels = int(arch.latent_channels)
|
||||
self.spatial_compression_ratio = math.prod(arch.spatial_downsample_factors)
|
||||
self.temporal_compression_ratio = math.prod(arch.temporal_downsample_factors)
|
||||
|
||||
self.encoder = MiniMaxH3VideoEncoder3d(
|
||||
in_channels=arch.in_channels,
|
||||
out_channels=2 * arch.latent_channels,
|
||||
block_out_channels=tuple(arch.block_out_channels),
|
||||
layers_per_block=arch.layers_per_block,
|
||||
spatial_downsample_factors=tuple(arch.spatial_downsample_factors),
|
||||
temporal_downsample_factors=tuple(arch.temporal_downsample_factors),
|
||||
norm_num_groups=arch.norm_num_groups,
|
||||
norm_eps=arch.norm_eps,
|
||||
spatial_padding_mode=arch.spatial_padding_mode,
|
||||
)
|
||||
self.quant_conv = nn.Conv3d(2 * arch.latent_channels, 2 * arch.latent_channels, kernel_size=1)
|
||||
self.post_quant_conv = nn.Conv3d(arch.latent_channels, arch.latent_channels, kernel_size=1)
|
||||
self.decoder = MiniMaxH3VideoViTDecoder3d(
|
||||
in_channels=arch.latent_channels,
|
||||
out_channels=arch.out_channels,
|
||||
patch_size=self.spatial_compression_ratio,
|
||||
patch_size_t=self.temporal_compression_ratio,
|
||||
num_layers=arch.decoder_num_layers,
|
||||
num_attention_heads=arch.decoder_num_attention_heads,
|
||||
attention_head_dim=arch.decoder_attention_head_dim,
|
||||
num_register_tokens=arch.decoder_num_register_tokens,
|
||||
ffn_mult=arch.decoder_ffn_mult,
|
||||
rope_theta=arch.decoder_rope_theta,
|
||||
rope_dim_ratio=arch.decoder_rope_dim_ratio,
|
||||
norm_eps=arch.decoder_norm_eps,
|
||||
)
|
||||
|
||||
self.frame_pre_padding = (-arch.clip_length) % self.temporal_compression_ratio
|
||||
self.tokens_chunk_size = math.ceil(arch.clip_length / self.temporal_compression_ratio)
|
||||
self.token_overlap = (-arch.token_drop) % self.tokens_chunk_size
|
||||
self.frame_overlap = max(
|
||||
self.token_overlap * self.temporal_compression_ratio - self.frame_pre_padding,
|
||||
0,
|
||||
)
|
||||
self.use_slicing = False
|
||||
self.use_tiling = config.use_tiling
|
||||
self.tile_sample_min_height = config.tile_sample_min_height
|
||||
self.tile_sample_min_width = config.tile_sample_min_width
|
||||
self.tile_sample_min_overlap_height = config.tile_sample_min_overlap_height
|
||||
self.tile_sample_min_overlap_width = config.tile_sample_min_overlap_width
|
||||
|
||||
self.register_buffer(
|
||||
"latents_mean",
|
||||
torch.tensor(arch.latents_mean, dtype=torch.float32).view(1, -1, 1, 1, 1),
|
||||
persistent=False,
|
||||
)
|
||||
self.register_buffer(
|
||||
"latents_std",
|
||||
torch.tensor(arch.latents_std, dtype=torch.float32).view(1, -1, 1, 1, 1),
|
||||
persistent=False,
|
||||
)
|
||||
self.register_buffer(
|
||||
"pixel_mean",
|
||||
torch.tensor((0.485, 0.456, 0.406), dtype=torch.float32).view(1, -1, 1, 1, 1),
|
||||
persistent=False,
|
||||
)
|
||||
self.register_buffer(
|
||||
"pixel_std",
|
||||
torch.tensor((0.229, 0.224, 0.225), dtype=torch.float32).view(1, -1, 1, 1, 1),
|
||||
persistent=False,
|
||||
)
|
||||
# The released encoder and decoder stay in FP32 for both weights and compute.
|
||||
self.float()
|
||||
|
||||
def to(self, *args, **kwargs) -> "AutoencoderKLMiniMaxH3":
|
||||
device, dtype, non_blocking, memory_format = torch._C._nn._parse_to(*args, **kwargs)
|
||||
if dtype is None or dtype == torch.float32:
|
||||
return super().to(*args, **kwargs)
|
||||
if not torch.is_floating_point(torch.empty((), dtype=dtype)):
|
||||
return super().to(*args, **kwargs)
|
||||
|
||||
to_kwargs = {
|
||||
"device": device,
|
||||
"dtype": torch.float32,
|
||||
"non_blocking": non_blocking,
|
||||
}
|
||||
if memory_format is not None:
|
||||
to_kwargs["memory_format"] = memory_format
|
||||
return super().to(**to_kwargs)
|
||||
|
||||
def half(self) -> "AutoencoderKLMiniMaxH3":
|
||||
return self
|
||||
|
||||
def bfloat16(self) -> "AutoencoderKLMiniMaxH3":
|
||||
return self
|
||||
|
||||
def normalize_latents(self, latents: torch.Tensor) -> torch.Tensor:
|
||||
return (latents - self.latents_mean) / self.latents_std
|
||||
|
||||
def denormalize_latents(self, latents: torch.Tensor) -> torch.Tensor:
|
||||
return latents * self.latents_std + self.latents_mean
|
||||
|
||||
def normalize_pixels(self, pixels: torch.Tensor) -> torch.Tensor:
|
||||
return (pixels - self.pixel_mean) / self.pixel_std
|
||||
|
||||
def denormalize_pixels(self, sample: torch.Tensor) -> torch.Tensor:
|
||||
return sample * self.pixel_std + self.pixel_mean
|
||||
|
||||
def enable_slicing(self) -> None:
|
||||
self.use_slicing = True
|
||||
|
||||
def disable_slicing(self) -> None:
|
||||
self.use_slicing = False
|
||||
|
||||
def enable_tiling(
|
||||
self,
|
||||
tile_sample_min_height: int | None = None,
|
||||
tile_sample_min_width: int | None = None,
|
||||
tile_sample_min_overlap_height: int | None = None,
|
||||
tile_sample_min_overlap_width: int | None = None,
|
||||
) -> None:
|
||||
self.use_tiling = True
|
||||
self.tile_sample_min_height = tile_sample_min_height or self.tile_sample_min_height
|
||||
self.tile_sample_min_width = tile_sample_min_width or self.tile_sample_min_width
|
||||
self.tile_sample_min_overlap_height = tile_sample_min_overlap_height or self.tile_sample_min_overlap_height
|
||||
self.tile_sample_min_overlap_width = tile_sample_min_overlap_width or self.tile_sample_min_overlap_width
|
||||
|
||||
def disable_tiling(self) -> None:
|
||||
self.use_tiling = False
|
||||
|
||||
def _split_tiles(self, length: int, tile_size: int, min_overlap: int) -> tuple[list[int], list[int], list[int]]:
|
||||
if tile_size >= length:
|
||||
return [0], [length], []
|
||||
num_tiles = math.ceil(length / tile_size)
|
||||
while tile_size * num_tiles - min_overlap * (num_tiles - 1) - length < 0:
|
||||
num_tiles += 1
|
||||
overlaps = [min_overlap] * (num_tiles - 1)
|
||||
remaining = tile_size * num_tiles - sum(overlaps) - length
|
||||
for index in range(remaining // self.spatial_compression_ratio):
|
||||
overlaps[index % (num_tiles - 1)] += self.spatial_compression_ratio
|
||||
tile_start_indices = [0]
|
||||
for index in range(num_tiles - 1):
|
||||
tile_start_indices.append(tile_start_indices[-1] + tile_size - overlaps[index])
|
||||
return tile_start_indices, [tile_size] * num_tiles, overlaps
|
||||
|
||||
@staticmethod
|
||||
def _blend(a: torch.Tensor, b: torch.Tensor, blend_extent: int, dim: int) -> torch.Tensor:
|
||||
blend_extent = min(a.shape[dim], b.shape[dim], blend_extent)
|
||||
positions = torch.arange(blend_extent, device=b.device, dtype=b.dtype)
|
||||
shape = [1] * a.ndim
|
||||
shape[dim] = blend_extent
|
||||
weight_a = (1 - positions / blend_extent).view(shape)
|
||||
weight_b = (positions / blend_extent).view(shape)
|
||||
slice_a = [slice(None)] * a.ndim
|
||||
slice_a[dim] = slice(-blend_extent, None)
|
||||
slice_b = [slice(None)] * b.ndim
|
||||
slice_b[dim] = slice(0, blend_extent)
|
||||
blended = a[tuple(slice_a)] * weight_a + b[tuple(slice_b)] * weight_b
|
||||
if blend_extent == b.shape[dim]:
|
||||
return blended
|
||||
slice_rest = [slice(None)] * b.ndim
|
||||
slice_rest[dim] = slice(blend_extent, None)
|
||||
return torch.cat([blended, b[tuple(slice_rest)]], dim=dim)
|
||||
|
||||
def _stitch_tiles(
|
||||
self,
|
||||
tiles: list[list[torch.Tensor]],
|
||||
height_overlaps: list[int],
|
||||
width_overlaps: list[int],
|
||||
) -> torch.Tensor:
|
||||
result_rows = []
|
||||
for row_index, row in enumerate(tiles):
|
||||
result_row = []
|
||||
for column_index, tile in enumerate(row):
|
||||
if row_index > 0:
|
||||
tile = self._blend(tiles[row_index - 1][column_index], tile, height_overlaps[row_index - 1], -2)
|
||||
if column_index > 0:
|
||||
tile = self._blend(row[column_index - 1], tile, width_overlaps[column_index - 1], -1)
|
||||
if row_index < len(tiles) - 1:
|
||||
tile = tile[..., : -height_overlaps[row_index], :]
|
||||
if column_index < len(row) - 1:
|
||||
tile = tile[..., :, : -width_overlaps[column_index]]
|
||||
result_row.append(tile)
|
||||
result_rows.append(torch.cat(result_row, dim=-1))
|
||||
return torch.cat(result_rows, dim=-2)
|
||||
|
||||
def _encode_clip(self, x: torch.Tensor) -> torch.Tensor:
|
||||
if not self.use_tiling:
|
||||
return self.quant_conv(self.encoder(x))
|
||||
height, width = x.shape[-2:]
|
||||
y_indices, y_lengths, y_overlaps = self._split_tiles(
|
||||
height,
|
||||
self.tile_sample_min_height,
|
||||
self.tile_sample_min_overlap_height,
|
||||
)
|
||||
x_indices, x_lengths, x_overlaps = self._split_tiles(
|
||||
width,
|
||||
self.tile_sample_min_width,
|
||||
self.tile_sample_min_overlap_width,
|
||||
)
|
||||
rows = []
|
||||
for y_position, y_length in zip(y_indices, y_lengths):
|
||||
row = []
|
||||
for x_position, x_length in zip(x_indices, x_lengths):
|
||||
tile = x[..., y_position:y_position + y_length, x_position:x_position + x_length]
|
||||
row.append(self.quant_conv(self.encoder(tile)))
|
||||
rows.append(row)
|
||||
latent_y_overlaps = [overlap // self.spatial_compression_ratio for overlap in y_overlaps]
|
||||
latent_x_overlaps = [overlap // self.spatial_compression_ratio for overlap in x_overlaps]
|
||||
return self._stitch_tiles(rows, latent_y_overlaps, latent_x_overlaps)
|
||||
|
||||
def _decode_clip(self, z: torch.Tensor) -> torch.Tensor:
|
||||
if not self.use_tiling:
|
||||
return self.decoder(self.post_quant_conv(z))
|
||||
height = z.shape[-2] * self.spatial_compression_ratio
|
||||
width = z.shape[-1] * self.spatial_compression_ratio
|
||||
y_indices, y_lengths, y_overlaps = self._split_tiles(
|
||||
height,
|
||||
self.tile_sample_min_height,
|
||||
self.tile_sample_min_overlap_height,
|
||||
)
|
||||
x_indices, x_lengths, x_overlaps = self._split_tiles(
|
||||
width,
|
||||
self.tile_sample_min_width,
|
||||
self.tile_sample_min_overlap_width,
|
||||
)
|
||||
ratio = self.spatial_compression_ratio
|
||||
rows = []
|
||||
for y_position, y_length in zip(y_indices, y_lengths):
|
||||
row = []
|
||||
for x_position, x_length in zip(x_indices, x_lengths):
|
||||
tile = z[
|
||||
...,
|
||||
y_position // ratio:y_position // ratio + y_length // ratio,
|
||||
x_position // ratio:x_position // ratio + x_length // ratio,
|
||||
]
|
||||
row.append(self.decoder(self.post_quant_conv(tile)))
|
||||
rows.append(row)
|
||||
return self._stitch_tiles(rows, y_overlaps, x_overlaps)
|
||||
|
||||
def _encode(self, x: torch.Tensor) -> torch.Tensor:
|
||||
clip_length = self.config.clip_length
|
||||
num_frames = x.shape[2]
|
||||
if num_frames % clip_length != 0:
|
||||
pad_frames = x[:, :, -1:].repeat(1, 1, (-num_frames) % clip_length, 1, 1)
|
||||
x = torch.cat([x, pad_frames], dim=2)
|
||||
moments = torch.cat(
|
||||
[
|
||||
self._encode_clip(x[:, :, index * clip_length:(index + 1) * clip_length])
|
||||
for index in range(x.shape[2] // clip_length)
|
||||
],
|
||||
dim=2,
|
||||
)
|
||||
if self.config.token_drop > 0:
|
||||
moments = moments[:, :, :-self.config.token_drop]
|
||||
return moments
|
||||
|
||||
def _decode(self, z: torch.Tensor) -> torch.Tensor:
|
||||
tokens_chunk_size = self.tokens_chunk_size
|
||||
token_drop = self.config.token_drop
|
||||
temporal_ratio = self.temporal_compression_ratio
|
||||
chunk_num_frames = tokens_chunk_size * temporal_ratio
|
||||
num_tokens = z.shape[2] + token_drop
|
||||
pad_tokens = (-num_tokens) % tokens_chunk_size
|
||||
num_chunks = (num_tokens + pad_tokens) // tokens_chunk_size - int(token_drop > 0)
|
||||
if pad_tokens > 0:
|
||||
z = torch.cat([z, z[:, :, -1:].repeat(1, 1, pad_tokens, 1, 1)], dim=2)
|
||||
|
||||
decoded_chunks = []
|
||||
overlap = None
|
||||
for index in range(num_chunks):
|
||||
start = index * tokens_chunk_size
|
||||
clip = self._decode_clip(z[:, :, start:start + tokens_chunk_size + self.token_overlap])
|
||||
for overlap_index in range(int(token_drop > 0) + 1):
|
||||
frame_start = overlap_index * chunk_num_frames
|
||||
chunk = clip[:, :, frame_start:frame_start + chunk_num_frames]
|
||||
chunk = chunk[:, :, self.frame_pre_padding:]
|
||||
if overlap_index == 0:
|
||||
if overlap is not None:
|
||||
chunk = self._blend(overlap, chunk, self.frame_overlap, dim=-3)
|
||||
decoded_chunks.append(chunk)
|
||||
else:
|
||||
overlap = chunk
|
||||
if overlap is not None:
|
||||
decoded_chunks.append(overlap)
|
||||
decoded = torch.cat(decoded_chunks, dim=2)
|
||||
|
||||
if pad_tokens > 0:
|
||||
intra_tail = self.config.clip_length % temporal_ratio
|
||||
num_tokens_before_pad = z.shape[2] - pad_tokens
|
||||
pad_frames = sum(
|
||||
intra_tail
|
||||
if intra_tail and (num_tokens_before_pad + offset) % tokens_chunk_size == 0
|
||||
else temporal_ratio
|
||||
for offset in range(pad_tokens)
|
||||
)
|
||||
decoded = decoded[:, :, :-pad_frames]
|
||||
return decoded
|
||||
|
||||
def encode(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
return_dict: bool = True,
|
||||
) -> AutoencoderKLOutput | tuple[DiagonalGaussianDistribution]:
|
||||
if self.use_slicing and x.shape[0] > 1:
|
||||
moments = torch.cat([self._encode(x_slice) for x_slice in x.split(1)])
|
||||
else:
|
||||
moments = self._encode(x)
|
||||
posterior = DiagonalGaussianDistribution(moments)
|
||||
if not return_dict:
|
||||
return (posterior,)
|
||||
return AutoencoderKLOutput(latent_dist=posterior)
|
||||
|
||||
def encode_keyframe(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
return_dict: bool = True,
|
||||
) -> AutoencoderKLOutput | tuple[DiagonalGaussianDistribution]:
|
||||
"""Encode one-frame conditioning inputs without video chunk padding."""
|
||||
if x.ndim != 5 or x.shape[2] != 1:
|
||||
raise ValueError(f"`x` must contain exactly one video frame, got shape {tuple(x.shape)}.")
|
||||
if self.use_slicing and x.shape[0] > 1:
|
||||
moments = torch.cat([self._encode_clip(x_slice) for x_slice in x.split(1)])
|
||||
else:
|
||||
moments = self._encode_clip(x)
|
||||
posterior = DiagonalGaussianDistribution(moments)
|
||||
if not return_dict:
|
||||
return (posterior,)
|
||||
return AutoencoderKLOutput(latent_dist=posterior)
|
||||
|
||||
def decode(self, z: torch.Tensor, return_dict: bool = True) -> DecoderOutput | tuple[torch.Tensor]:
|
||||
if self.use_slicing and z.shape[0] > 1:
|
||||
decoded = torch.cat([self._decode(z_slice) for z_slice in z.split(1)])
|
||||
else:
|
||||
decoded = self._decode(z)
|
||||
if not return_dict:
|
||||
return (decoded,)
|
||||
return DecoderOutput(sample=decoded)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
sample: torch.Tensor,
|
||||
sample_posterior: bool = False,
|
||||
generator: torch.Generator | None = None,
|
||||
return_dict: bool = True,
|
||||
) -> DecoderOutput | tuple[torch.Tensor]:
|
||||
posterior = self.encode(sample).latent_dist
|
||||
latents = posterior.sample(generator=generator) if sample_posterior else posterior.mode()
|
||||
return self.decode(latents, return_dict=return_dict)
|
||||
|
||||
|
||||
EntryClass = AutoencoderKLMiniMaxH3
|
||||
@@ -0,0 +1,15 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from fastvideo.pipelines.basic.minimax_h3.minimax_h3_pipeline import (
|
||||
EntryClass,
|
||||
MiniMaxH3ModularPipeline,
|
||||
MiniMaxH3Ref2VAModularPipeline,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.reference import MiniMaxH3Reference
|
||||
|
||||
__all__ = [
|
||||
"EntryClass",
|
||||
"MiniMaxH3ModularPipeline",
|
||||
"MiniMaxH3Ref2VAModularPipeline",
|
||||
"MiniMaxH3Reference",
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""FastVideo composed pipelines for MiniMax H3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages import (
|
||||
MiniMaxH3AudioDecodingStage,
|
||||
MiniMaxH3ConditioningStage,
|
||||
MiniMaxH3DenoisingStage,
|
||||
MiniMaxH3InputPreparationStage,
|
||||
MiniMaxH3LatentPreparationStage,
|
||||
MiniMaxH3VideoDecodingStage,
|
||||
)
|
||||
from fastvideo.pipelines.composed_pipeline_base import ComposedPipelineBase
|
||||
|
||||
|
||||
class MiniMaxH3BasePipeline(ComposedPipelineBase):
|
||||
"""Shared loading and target-generation path for MiniMax H3."""
|
||||
|
||||
pipeline_config_cls: type[MiniMaxH3PipelineConfig] = MiniMaxH3PipelineConfig
|
||||
_required_config_modules = [
|
||||
"text_encoder",
|
||||
"tokenizer",
|
||||
"processor",
|
||||
"vae",
|
||||
"audio_vae",
|
||||
"transformer",
|
||||
"scheduler",
|
||||
"audio_scheduler",
|
||||
]
|
||||
|
||||
def initialize_pipeline(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
del fastvideo_args
|
||||
for module_name, modality, expected_shift in (
|
||||
("scheduler", "video", 12.0),
|
||||
("audio_scheduler", "audio", 3.0),
|
||||
):
|
||||
shift = getattr(self.get_module(module_name), "shift", None)
|
||||
if shift is None or float(shift) != expected_shift:
|
||||
raise ValueError(f"MiniMax-H3 {modality} scheduler must expose shift={expected_shift:g}, got {shift}.")
|
||||
|
||||
def _add_stages(self, *, ref2va: bool) -> None:
|
||||
transformer = self.get_module("transformer")
|
||||
vae = self.get_module("vae")
|
||||
audio_vae = self.get_module("audio_vae")
|
||||
scheduler = self.get_module("scheduler")
|
||||
audio_scheduler = self.get_module("audio_scheduler")
|
||||
|
||||
self.add_stage(
|
||||
"input_preparation_stage",
|
||||
MiniMaxH3InputPreparationStage(
|
||||
vae=vae,
|
||||
audio_vae=audio_vae if ref2va else None,
|
||||
ref2va=ref2va,
|
||||
),
|
||||
)
|
||||
self.add_stage(
|
||||
"conditioning_stage",
|
||||
MiniMaxH3ConditioningStage(
|
||||
conditioner=self.get_module("text_encoder"),
|
||||
tokenizer=self.get_module("tokenizer"),
|
||||
processor=self.get_module("processor"),
|
||||
ref2va=ref2va,
|
||||
),
|
||||
)
|
||||
self.add_stage(
|
||||
"latent_preparation_stage",
|
||||
MiniMaxH3LatentPreparationStage(
|
||||
transformer=transformer,
|
||||
vae=vae,
|
||||
audio_vae=audio_vae,
|
||||
scheduler=scheduler,
|
||||
ref2va=ref2va,
|
||||
),
|
||||
)
|
||||
self.add_stage(
|
||||
"denoising_stage",
|
||||
MiniMaxH3DenoisingStage(
|
||||
transformer=transformer,
|
||||
scheduler=scheduler,
|
||||
audio_scheduler=audio_scheduler,
|
||||
),
|
||||
)
|
||||
self.add_stage("video_decoding_stage", MiniMaxH3VideoDecodingStage(vae=vae, transformer=transformer))
|
||||
self.add_stage("audio_decoding_stage", MiniMaxH3AudioDecodingStage(audio_vae=audio_vae))
|
||||
|
||||
|
||||
class MiniMaxH3Pipeline(MiniMaxH3BasePipeline):
|
||||
"""One-request joint video/stereo-audio pipeline for T2VA and FL2VA."""
|
||||
|
||||
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
del fastvideo_args
|
||||
self._add_stages(ref2va=False)
|
||||
|
||||
|
||||
class MiniMaxH3RefPipeline(MiniMaxH3BasePipeline):
|
||||
"""Ordered-reference joint video/stereo-audio pipeline for Ref2VA."""
|
||||
|
||||
_extra_config_module_map = {"transformer": "transformer_ref"}
|
||||
|
||||
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
del fastvideo_args
|
||||
self._add_stages(ref2va=True)
|
||||
|
||||
|
||||
class MiniMaxH3ModularPipeline(MiniMaxH3Pipeline):
|
||||
"""Public T2VA/FL2VA entry matching the official manifest class name."""
|
||||
|
||||
|
||||
class MiniMaxH3Ref2VAModularPipeline(MiniMaxH3RefPipeline):
|
||||
"""Public Ref2VA entry using the checkpoint's ``transformer_ref`` partition."""
|
||||
|
||||
|
||||
EntryClass = [MiniMaxH3ModularPipeline, MiniMaxH3Ref2VAModularPipeline]
|
||||
|
||||
__all__ = [
|
||||
"EntryClass",
|
||||
"MiniMaxH3BasePipeline",
|
||||
"MiniMaxH3ModularPipeline",
|
||||
"MiniMaxH3Pipeline",
|
||||
"MiniMaxH3Ref2VAModularPipeline",
|
||||
"MiniMaxH3RefPipeline",
|
||||
]
|
||||
@@ -0,0 +1,492 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2026 The MiniMax and HuggingFace Teams.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from diffusers.utils.torch_utils import randn_tensor
|
||||
from PIL import Image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastvideo.pipelines.basic.minimax_h3.reference import MiniMaxH3PreparedReference
|
||||
|
||||
MINIMAX_H3_VIDEO_TAG = 0
|
||||
MINIMAX_H3_TEXT_TAG = 1
|
||||
MINIMAX_H3_AUDIO_TAG = 2
|
||||
|
||||
MINIMAX_H3_FPS = 24
|
||||
MINIMAX_H3_SHORT_EDGE = 768
|
||||
MINIMAX_H3_MAX_PIXELS = 768 * 1344
|
||||
MINIMAX_H3_CANVAS_MULTIPLE = 32
|
||||
MINIMAX_H3_MIN_ASPECT_RATIO = 1 / 4
|
||||
MINIMAX_H3_MAX_ASPECT_RATIO = 4
|
||||
MINIMAX_H3_MIN_DURATION = 5.0
|
||||
MINIMAX_H3_MAX_DURATION = 15.0
|
||||
MINIMAX_H3_FRAMES_PER_CHUNK = 17
|
||||
MINIMAX_H3_LATENTS_PER_CHUNK = 5
|
||||
MINIMAX_H3_TEXT_ENCODER_LAYER = 50
|
||||
MINIMAX_H3_VISION_START_TOKEN = "<|vision_start|>"
|
||||
MINIMAX_H3_IMAGE_PAD_TOKEN = "<|image_pad|>"
|
||||
MINIMAX_H3_VIDEO_PAD_TOKEN = "<|video_pad|>"
|
||||
MINIMAX_H3_VISION_END_TOKEN = "<|vision_end|>"
|
||||
MINIMAX_H3_AUDIO_LATENTS_PER_SECOND = 40
|
||||
MINIMAX_H3_AUDIO_CHANNELS = 2
|
||||
MINIMAX_H3_KEYFRAME_NOISE_AUG = 0.999
|
||||
MINIMAX_H3_KEYFRAME_ENCODE_SEED = 42
|
||||
|
||||
MINIMAX_H3_ROPE_FRAME_RESCALE = 5.0 / 3.0
|
||||
MINIMAX_H3_ROPE_FRAMES_PER_LATENT = (1, 4, 4, 4, 4)
|
||||
_ROPE_SPATIAL_SCALE = 32
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MiniMaxH3PackedLayout:
|
||||
"""One packed joint sequence and the geometry needed to interpret it."""
|
||||
|
||||
sequence_length: int
|
||||
position_ids: torch.Tensor
|
||||
token_tags: torch.Tensor
|
||||
video_indices: torch.Tensor
|
||||
audio_indices: torch.Tensor
|
||||
text_indices: torch.Tensor
|
||||
num_condition_video_rows: int
|
||||
num_condition_audio_rows: int
|
||||
num_video_latent_frames: int
|
||||
latent_height: int
|
||||
latent_width: int
|
||||
num_audio_latents: int
|
||||
|
||||
|
||||
def resolve_canvas_size(aspect_width: float, aspect_height: float) -> tuple[int, int]:
|
||||
if aspect_width <= 0 or aspect_height <= 0:
|
||||
raise ValueError(f"The aspect ratio must be positive, got {aspect_width}:{aspect_height}.")
|
||||
ratio = aspect_width / aspect_height
|
||||
if not MINIMAX_H3_MIN_ASPECT_RATIO <= ratio <= MINIMAX_H3_MAX_ASPECT_RATIO:
|
||||
raise ValueError(f"MiniMax-H3 supports aspect ratios from 1:4 to 4:1, got {aspect_width}:{aspect_height}.")
|
||||
|
||||
if ratio >= 1:
|
||||
width, height = MINIMAX_H3_SHORT_EDGE * ratio, float(MINIMAX_H3_SHORT_EDGE)
|
||||
else:
|
||||
width, height = float(MINIMAX_H3_SHORT_EDGE), MINIMAX_H3_SHORT_EDGE / ratio
|
||||
area = width * height
|
||||
if area > MINIMAX_H3_MAX_PIXELS:
|
||||
scale = (MINIMAX_H3_MAX_PIXELS / area)**0.5
|
||||
width, height = width * scale, height * scale
|
||||
multiple = MINIMAX_H3_CANVAS_MULTIPLE
|
||||
return max(multiple, round(height / multiple) * multiple), max(multiple, round(width / multiple) * multiple)
|
||||
|
||||
|
||||
def align_num_frames(num_frames: int) -> int:
|
||||
if num_frames < 1:
|
||||
raise ValueError(f"`num_frames` must be positive, got {num_frames}.")
|
||||
while num_frames % MINIMAX_H3_FRAMES_PER_CHUNK != MINIMAX_H3_LATENTS_PER_CHUNK:
|
||||
num_frames += 1
|
||||
return num_frames
|
||||
|
||||
|
||||
def video_latent_num_frames(num_frames: int) -> int:
|
||||
if num_frames % MINIMAX_H3_FRAMES_PER_CHUNK != MINIMAX_H3_LATENTS_PER_CHUNK:
|
||||
raise ValueError(f"`num_frames` must be of the form 17 * n + 5, got {num_frames}.")
|
||||
return (num_frames - MINIMAX_H3_LATENTS_PER_CHUNK) // MINIMAX_H3_FRAMES_PER_CHUNK * MINIMAX_H3_LATENTS_PER_CHUNK + 2
|
||||
|
||||
|
||||
def audio_latent_num_frames(num_frames: int) -> int:
|
||||
return int(round(num_frames / MINIMAX_H3_FPS * MINIMAX_H3_AUDIO_LATENTS_PER_SECOND))
|
||||
|
||||
|
||||
def prepare_keyframe_image(image: Image.Image, height: int, width: int, stretch: bool) -> Image.Image:
|
||||
if image.size == (width, height):
|
||||
return image
|
||||
if stretch:
|
||||
return image.resize((width, height), Image.Resampling.LANCZOS)
|
||||
scale = max(width / image.size[0], height / image.size[1])
|
||||
resized_size = (max(width, round(image.size[0] * scale)), max(height, round(image.size[1] * scale)))
|
||||
left = max(0, (resized_size[0] - width) // 2)
|
||||
top = max(0, (resized_size[1] - height) // 2)
|
||||
resized = image.resize(resized_size, Image.Resampling.LANCZOS)
|
||||
return resized.crop((left, top, left + width, top + height))
|
||||
|
||||
|
||||
def patchify_video_latents(latents: torch.Tensor, patch_size: tuple[int, int, int]) -> torch.Tensor:
|
||||
patch_t, patch_h, patch_w = patch_size
|
||||
batch_size, channels, num_frames, height, width = latents.shape
|
||||
if num_frames % patch_t or height % patch_h or width % patch_w:
|
||||
raise ValueError(f"Latents of shape {tuple(latents.shape)} are not divisible by the patch {patch_size}.")
|
||||
latents = latents.reshape(
|
||||
batch_size,
|
||||
channels,
|
||||
num_frames // patch_t,
|
||||
patch_t,
|
||||
height // patch_h,
|
||||
patch_h,
|
||||
width // patch_w,
|
||||
patch_w,
|
||||
)
|
||||
# Channel-major patch features are a checkpoint contract; do not use einops's common patch-major order.
|
||||
latents = latents.permute(0, 2, 4, 6, 1, 3, 5, 7)
|
||||
return latents.reshape(-1, channels * patch_t * patch_h * patch_w).contiguous()
|
||||
|
||||
|
||||
def unpatchify_video_tokens(
|
||||
rows: torch.Tensor,
|
||||
num_latent_frames: int,
|
||||
latent_height: int,
|
||||
latent_width: int,
|
||||
channels: int,
|
||||
patch_size: tuple[int, int, int],
|
||||
) -> torch.Tensor:
|
||||
patch_t, patch_h, patch_w = patch_size
|
||||
rows = rows.reshape(
|
||||
-1,
|
||||
num_latent_frames // patch_t,
|
||||
latent_height // patch_h,
|
||||
latent_width // patch_w,
|
||||
channels,
|
||||
patch_t,
|
||||
patch_h,
|
||||
patch_w,
|
||||
)
|
||||
rows = rows.permute(0, 4, 1, 5, 2, 6, 3, 7)
|
||||
return rows.reshape(-1, channels, num_latent_frames, latent_height, latent_width).contiguous()
|
||||
|
||||
|
||||
def unpack_audio_tokens(rows: torch.Tensor, num_audio_latents: int) -> torch.Tensor:
|
||||
rows = rows.reshape(MINIMAX_H3_AUDIO_CHANNELS, num_audio_latents, rows.shape[-1])
|
||||
return rows.permute(0, 2, 1).contiguous()
|
||||
|
||||
|
||||
def spatial_position_grid(dim: int, patch: int, sqrt_area: float) -> torch.Tensor:
|
||||
ratio = dim / sqrt_area
|
||||
left = (1.0 - ratio) / 2.0
|
||||
# NumPy endpoint=False and float64 are part of the reference RoPE coordinate arithmetic.
|
||||
grid = np.linspace(left, left + ratio, dim // patch, endpoint=False) * _ROPE_SPATIAL_SCALE
|
||||
return torch.from_numpy(grid).to(torch.float64)
|
||||
|
||||
|
||||
def temporal_position_grid(num_latent_frames: int, origin: float) -> torch.Tensor:
|
||||
spans = torch.tensor(
|
||||
[
|
||||
MINIMAX_H3_ROPE_FRAME_RESCALE *
|
||||
MINIMAX_H3_ROPE_FRAMES_PER_LATENT[index % len(MINIMAX_H3_ROPE_FRAMES_PER_LATENT)]
|
||||
for index in range(num_latent_frames)
|
||||
],
|
||||
dtype=torch.float64,
|
||||
)
|
||||
return origin + torch.cat([torch.zeros(1, dtype=torch.float64), spans[:-1].cumsum(0)])
|
||||
|
||||
|
||||
def _temporal_position_span(num_latent_frames: int) -> float:
|
||||
# Preserve NumPy's pairwise summation: sequential torch summation diverges in the final ulp for longer clips.
|
||||
spans = np.ones(num_latent_frames, dtype=np.float64) * MINIMAX_H3_ROPE_FRAME_RESCALE
|
||||
for index, frames in enumerate(MINIMAX_H3_ROPE_FRAMES_PER_LATENT):
|
||||
spans[index::len(MINIMAX_H3_ROPE_FRAMES_PER_LATENT)] *= frames
|
||||
return float(spans.sum())
|
||||
|
||||
|
||||
def build_packed_sequence(
|
||||
text_token_tags: torch.Tensor,
|
||||
num_latent_frames: int,
|
||||
latent_height: int,
|
||||
latent_width: int,
|
||||
num_audio_latents: int,
|
||||
patch_size: tuple[int, int, int],
|
||||
keyframe_anchors: tuple[str, ...] = (),
|
||||
) -> MiniMaxH3PackedLayout:
|
||||
if text_token_tags.ndim != 1:
|
||||
raise ValueError(f"text_token_tags must be one-dimensional, got {tuple(text_token_tags.shape)}.")
|
||||
valid_text_tags = (text_token_tags == MINIMAX_H3_TEXT_TAG) | (text_token_tags == MINIMAX_H3_VIDEO_TAG)
|
||||
if not bool(valid_text_tags.all()):
|
||||
raise ValueError("text_token_tags may contain only text and vision tags; semantic padding is not allowed.")
|
||||
|
||||
_, patch_h, patch_w = patch_size
|
||||
rows_per_frame = (latent_height // patch_h) * (latent_width // patch_w)
|
||||
num_text_tokens = text_token_tags.shape[0]
|
||||
num_condition_rows = len(keyframe_anchors) * rows_per_frame
|
||||
num_audio_rows = num_audio_latents * MINIMAX_H3_AUDIO_CHANNELS
|
||||
num_video_rows = num_latent_frames * rows_per_frame
|
||||
sequence_length = num_text_tokens + num_condition_rows + num_audio_rows + num_video_rows
|
||||
|
||||
condition_start = num_text_tokens
|
||||
audio_start = condition_start + num_condition_rows
|
||||
video_start = audio_start + num_audio_rows
|
||||
position_ids = torch.zeros(sequence_length, 3, dtype=torch.float64)
|
||||
position_ids[:num_text_tokens, 0] = torch.arange(num_text_tokens, dtype=torch.float64)
|
||||
|
||||
sqrt_area = np.sqrt(latent_height * latent_width)
|
||||
height_grid = spatial_position_grid(latent_height, patch_h, sqrt_area)
|
||||
width_grid = spatial_position_grid(latent_width, patch_w, sqrt_area)
|
||||
frame_grid = torch.stack([axis.reshape(-1) for axis in torch.meshgrid(height_grid, width_grid, indexing="ij")], -1)
|
||||
|
||||
for index, anchor in enumerate(keyframe_anchors):
|
||||
if anchor == "first":
|
||||
anchor_time = float(num_text_tokens)
|
||||
elif anchor == "last":
|
||||
anchor_time = (float(num_text_tokens) + _temporal_position_span(num_latent_frames) -
|
||||
MINIMAX_H3_ROPE_FRAME_RESCALE)
|
||||
else:
|
||||
raise ValueError(f"A keyframe anchor must be 'first' or 'last', got {anchor!r}.")
|
||||
rows = slice(condition_start + index * rows_per_frame, condition_start + (index + 1) * rows_per_frame)
|
||||
position_ids[rows, 0] = anchor_time
|
||||
position_ids[rows, 1:] = frame_grid
|
||||
|
||||
audio_time = float(num_text_tokens) + torch.arange(num_audio_latents, dtype=torch.float64)
|
||||
position_ids[audio_start:video_start, 0] = audio_time.repeat(MINIMAX_H3_AUDIO_CHANNELS)
|
||||
position_ids[audio_start:video_start, 2] = torch.cat([
|
||||
torch.full((num_audio_latents, ), float(width_grid[0]), dtype=torch.float64),
|
||||
torch.full((num_audio_rows - num_audio_latents, ), float(width_grid[-1]), dtype=torch.float64),
|
||||
])
|
||||
|
||||
video_positions = torch.empty(num_latent_frames, rows_per_frame, 3, dtype=torch.float64)
|
||||
video_positions[:, :, 0] = temporal_position_grid(num_latent_frames, float(num_text_tokens))[:, None]
|
||||
video_positions[:, :, 1:] = frame_grid[None]
|
||||
position_ids[video_start:] = video_positions.reshape(-1, 3)
|
||||
|
||||
video_indices = torch.cat([torch.arange(condition_start, audio_start), torch.arange(video_start, sequence_length)])
|
||||
audio_indices = torch.arange(audio_start, video_start)
|
||||
text_indices = torch.arange(num_text_tokens)
|
||||
token_tags = torch.empty(sequence_length, dtype=torch.long)
|
||||
token_tags[text_indices] = text_token_tags.to(torch.long)
|
||||
token_tags[audio_indices] = MINIMAX_H3_AUDIO_TAG
|
||||
token_tags[video_indices] = MINIMAX_H3_VIDEO_TAG
|
||||
return MiniMaxH3PackedLayout(
|
||||
sequence_length=sequence_length,
|
||||
position_ids=position_ids,
|
||||
token_tags=token_tags,
|
||||
video_indices=video_indices,
|
||||
audio_indices=audio_indices,
|
||||
text_indices=text_indices,
|
||||
num_condition_video_rows=num_condition_rows,
|
||||
num_condition_audio_rows=0,
|
||||
num_video_latent_frames=num_latent_frames,
|
||||
latent_height=latent_height,
|
||||
latent_width=latent_width,
|
||||
num_audio_latents=num_audio_latents,
|
||||
)
|
||||
|
||||
|
||||
def _reference_temporal_span(num_latent_frames: int) -> float:
|
||||
"""Advance the Ref2VA clock using its required sequential summation order."""
|
||||
return sum(MINIMAX_H3_ROPE_FRAME_RESCALE *
|
||||
MINIMAX_H3_ROPE_FRAMES_PER_LATENT[index % len(MINIMAX_H3_ROPE_FRAMES_PER_LATENT)]
|
||||
for index in range(num_latent_frames))
|
||||
|
||||
|
||||
def _frame_position_grid(
|
||||
latent_height: int,
|
||||
latent_width: int,
|
||||
patch_h: int,
|
||||
patch_w: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
sqrt_area = np.sqrt(latent_height * latent_width)
|
||||
height_grid = spatial_position_grid(latent_height, patch_h, sqrt_area)
|
||||
width_grid = spatial_position_grid(latent_width, patch_w, sqrt_area)
|
||||
grids = torch.meshgrid(height_grid, width_grid, indexing="ij")
|
||||
return torch.stack([grid.reshape(-1) for grid in grids], dim=-1), width_grid
|
||||
|
||||
|
||||
def _fill_audio_positions(
|
||||
position_ids: torch.Tensor,
|
||||
rows: slice,
|
||||
num_audio_latents: int,
|
||||
rotary_time: float,
|
||||
width_grid: torch.Tensor,
|
||||
) -> None:
|
||||
time = rotary_time + torch.arange(num_audio_latents, dtype=torch.float64)
|
||||
position_ids[rows, 0] = time.repeat(MINIMAX_H3_AUDIO_CHANNELS)
|
||||
position_ids[rows, 2] = torch.cat([
|
||||
torch.full((num_audio_latents, ), float(width_grid[0]), dtype=torch.float64),
|
||||
torch.full((num_audio_latents, ), float(width_grid[-1]), dtype=torch.float64),
|
||||
])
|
||||
|
||||
|
||||
def _num_video_rows(reference: MiniMaxH3PreparedReference, patch_size: tuple[int, int, int]) -> int:
|
||||
patch_t, patch_h, patch_w = patch_size
|
||||
geometry = (reference.num_latent_frames, reference.latent_height, reference.latent_width)
|
||||
if any(value <= 0 for value in geometry):
|
||||
raise ValueError(f"Incomplete visual reference geometry: {geometry}.")
|
||||
if any(value % patch for value, patch in zip(geometry, patch_size, strict=True)):
|
||||
raise ValueError(f"Visual reference geometry {geometry} is not divisible by patch {patch_size}.")
|
||||
return (reference.num_latent_frames // patch_t) * (reference.latent_height // patch_h) * (reference.latent_width //
|
||||
patch_w)
|
||||
|
||||
|
||||
def build_ref2va_packed_sequence(
|
||||
text_token_tags: torch.Tensor,
|
||||
references: list[MiniMaxH3PreparedReference],
|
||||
num_latent_frames: int,
|
||||
latent_height: int,
|
||||
latent_width: int,
|
||||
num_audio_latents: int,
|
||||
patch_size: tuple[int, int, int],
|
||||
) -> MiniMaxH3PackedLayout:
|
||||
"""Build `[text | ordered references | target audio | target video]`."""
|
||||
if patch_size != (1, 2, 2):
|
||||
raise ValueError(f"MiniMax-H3 Ref2VA requires patch_size=(1, 2, 2), got {patch_size}.")
|
||||
if text_token_tags.ndim != 1:
|
||||
raise ValueError(f"text_token_tags must be one-dimensional, got {tuple(text_token_tags.shape)}.")
|
||||
valid_text_tags = (text_token_tags == MINIMAX_H3_TEXT_TAG) | (text_token_tags == MINIMAX_H3_VIDEO_TAG)
|
||||
if not bool(valid_text_tags.all()):
|
||||
raise ValueError("text_token_tags may contain only text and vision tags.")
|
||||
if not references:
|
||||
raise ValueError("Ref2VA requires at least one prepared reference.")
|
||||
|
||||
patch_t, patch_h, patch_w = patch_size
|
||||
target_geometry = (num_latent_frames, latent_height, latent_width)
|
||||
if any(value <= 0 for value in target_geometry) or num_audio_latents <= 0:
|
||||
raise ValueError("Ref2VA target latent geometry must be positive.")
|
||||
if any(value % patch for value, patch in zip(target_geometry, patch_size, strict=True)):
|
||||
raise ValueError(f"Target geometry {target_geometry} is not divisible by patch {patch_size}.")
|
||||
|
||||
visual_row_counts = [
|
||||
0 if reference.media_type == "audio" else _num_video_rows(reference, patch_size) for reference in references
|
||||
]
|
||||
for reference in references:
|
||||
if reference.media_type == "audio" and not reference.has_audio:
|
||||
raise ValueError("An audio reference must carry decoded waveform latents.")
|
||||
if reference.has_audio and reference.num_audio_latents <= 0:
|
||||
raise ValueError("An audio-bearing reference has no resolved audio latents.")
|
||||
|
||||
num_text_tokens = int(text_token_tags.shape[0])
|
||||
num_target_video_rows = (num_latent_frames // patch_t) * (latent_height // patch_h) * (latent_width // patch_w)
|
||||
num_target_audio_rows = num_audio_latents * MINIMAX_H3_AUDIO_CHANNELS
|
||||
num_reference_video_rows = sum(visual_row_counts)
|
||||
num_reference_audio_rows = sum(reference.num_audio_latents * MINIMAX_H3_AUDIO_CHANNELS for reference in references
|
||||
if reference.has_audio)
|
||||
sequence_length = (num_text_tokens + num_reference_video_rows + num_reference_audio_rows + num_target_audio_rows +
|
||||
num_target_video_rows)
|
||||
|
||||
position_ids = torch.zeros(sequence_length, 3, dtype=torch.float64)
|
||||
position_ids[:num_text_tokens, 0] = torch.arange(num_text_tokens, dtype=torch.float64)
|
||||
target_frame_grid, target_width_grid = _frame_position_grid(latent_height, latent_width, patch_h, patch_w)
|
||||
|
||||
video_indices: list[torch.Tensor] = []
|
||||
audio_indices: list[torch.Tensor] = []
|
||||
cursor = num_text_tokens
|
||||
rotary_time = float(num_text_tokens)
|
||||
for reference, visual_row_count in zip(references, visual_row_counts, strict=True):
|
||||
if reference.media_type == "image":
|
||||
rows = slice(cursor, cursor + visual_row_count)
|
||||
cursor = rows.stop
|
||||
video_indices.append(torch.arange(rows.start, rows.stop))
|
||||
frame_grid, _ = _frame_position_grid(
|
||||
reference.latent_height,
|
||||
reference.latent_width,
|
||||
patch_h,
|
||||
patch_w,
|
||||
)
|
||||
position_ids[rows, 0] = rotary_time
|
||||
position_ids[rows, 1:] = frame_grid
|
||||
rotary_time += 1.0
|
||||
elif reference.media_type == "audio":
|
||||
count = reference.num_audio_latents * MINIMAX_H3_AUDIO_CHANNELS
|
||||
rows = slice(cursor, cursor + count)
|
||||
cursor = rows.stop
|
||||
audio_indices.append(torch.arange(rows.start, rows.stop))
|
||||
_fill_audio_positions(position_ids, rows, reference.num_audio_latents, rotary_time, target_width_grid)
|
||||
rotary_time += float(reference.num_audio_latents)
|
||||
elif reference.media_type == "video":
|
||||
audio_count = reference.num_audio_latents * MINIMAX_H3_AUDIO_CHANNELS if reference.has_audio else 0
|
||||
audio_rows = slice(cursor, cursor + audio_count)
|
||||
video_rows = slice(audio_rows.stop, audio_rows.stop + visual_row_count)
|
||||
cursor = video_rows.stop
|
||||
if audio_count:
|
||||
audio_indices.append(torch.arange(audio_rows.start, audio_rows.stop))
|
||||
video_indices.append(torch.arange(video_rows.start, video_rows.stop))
|
||||
|
||||
frame_grid, width_grid = _frame_position_grid(
|
||||
reference.latent_height,
|
||||
reference.latent_width,
|
||||
patch_h,
|
||||
patch_w,
|
||||
)
|
||||
if audio_count:
|
||||
_fill_audio_positions(
|
||||
position_ids,
|
||||
audio_rows,
|
||||
reference.num_audio_latents,
|
||||
rotary_time,
|
||||
width_grid,
|
||||
)
|
||||
frame_time = temporal_position_grid(reference.num_latent_frames, rotary_time)
|
||||
rows_per_frame = frame_grid.shape[0]
|
||||
position_ids[video_rows, 0] = frame_time.repeat_interleave(rows_per_frame)
|
||||
position_ids[video_rows, 1:] = frame_grid.repeat(reference.num_latent_frames // patch_t, 1)
|
||||
rotary_time += max(
|
||||
float(reference.num_audio_latents if reference.has_audio else 0),
|
||||
_reference_temporal_span(reference.num_latent_frames),
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported prepared reference type: {reference.media_type!r}.")
|
||||
|
||||
audio_start = cursor
|
||||
video_start = audio_start + num_target_audio_rows
|
||||
_fill_audio_positions(position_ids, slice(audio_start, video_start), num_audio_latents, rotary_time,
|
||||
target_width_grid)
|
||||
frame_time = temporal_position_grid(num_latent_frames, rotary_time)
|
||||
position_ids[video_start:, 0] = frame_time.repeat_interleave(target_frame_grid.shape[0])
|
||||
position_ids[video_start:, 1:] = target_frame_grid.repeat(num_latent_frames // patch_t, 1)
|
||||
|
||||
video_indices.append(torch.arange(video_start, sequence_length))
|
||||
audio_indices.append(torch.arange(audio_start, video_start))
|
||||
packed_video_indices = torch.cat(video_indices)
|
||||
packed_audio_indices = torch.cat(audio_indices)
|
||||
text_indices = torch.arange(num_text_tokens)
|
||||
token_tags = torch.empty(sequence_length, dtype=torch.long)
|
||||
token_tags[text_indices] = text_token_tags.to(torch.long)
|
||||
token_tags[packed_audio_indices] = MINIMAX_H3_AUDIO_TAG
|
||||
token_tags[packed_video_indices] = MINIMAX_H3_VIDEO_TAG
|
||||
|
||||
return MiniMaxH3PackedLayout(
|
||||
sequence_length=sequence_length,
|
||||
position_ids=position_ids,
|
||||
token_tags=token_tags,
|
||||
video_indices=packed_video_indices,
|
||||
audio_indices=packed_audio_indices,
|
||||
text_indices=text_indices,
|
||||
num_condition_video_rows=num_reference_video_rows,
|
||||
num_condition_audio_rows=num_reference_audio_rows,
|
||||
num_video_latent_frames=num_latent_frames,
|
||||
latent_height=latent_height,
|
||||
latent_width=latent_width,
|
||||
num_audio_latents=num_audio_latents,
|
||||
)
|
||||
|
||||
|
||||
def build_row_timesteps(
|
||||
layout: MiniMaxH3PackedLayout,
|
||||
video_timestep: float,
|
||||
audio_timestep: float,
|
||||
condition_video_timestep: float,
|
||||
condition_audio_timestep: float,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
row_timesteps = torch.full((layout.sequence_length, ), video_timestep, dtype=torch.float32)
|
||||
row_timesteps[layout.video_indices[:layout.num_condition_video_rows]] = condition_video_timestep
|
||||
row_timesteps[layout.audio_indices[layout.num_condition_audio_rows:]] = audio_timestep
|
||||
row_timesteps[layout.audio_indices[:layout.num_condition_audio_rows]] = condition_audio_timestep
|
||||
return torch.unique(row_timesteps, sorted=True, return_inverse=True)
|
||||
|
||||
|
||||
def keyframe_condition_noise(
|
||||
condition_latent_shapes: tuple[tuple[int, int, int], ...],
|
||||
patch_size: tuple[int, int, int],
|
||||
latent_channels: int,
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
device: torch.device | None = None,
|
||||
dtype: torch.dtype = torch.float32,
|
||||
) -> torch.Tensor:
|
||||
rows = []
|
||||
for num_latent_frames, latent_height, latent_width in condition_latent_shapes:
|
||||
noise = randn_tensor(
|
||||
(1, latent_channels, num_latent_frames, latent_height, latent_width),
|
||||
generator=generator,
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
)
|
||||
rows.append(patchify_video_latents(noise, patch_size))
|
||||
if not rows:
|
||||
return torch.empty((0, latent_channels * int(np.prod(patch_size))), device=device, dtype=dtype)
|
||||
return torch.cat(rows)
|
||||
@@ -0,0 +1,74 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Inference presets for MiniMax H3 joint video/audio generation."""
|
||||
|
||||
from fastvideo.api.presets import InferencePreset, PresetStageSpec
|
||||
|
||||
_DENOISE_STAGE = PresetStageSpec(
|
||||
name="denoise",
|
||||
kind="denoising",
|
||||
description="Joint video/audio flow-matching denoising",
|
||||
allowed_overrides=frozenset({"num_inference_steps"}),
|
||||
)
|
||||
|
||||
_SHARED_DEFAULTS = {
|
||||
"fps": 24,
|
||||
"guidance_scale": 1.0,
|
||||
"batch_cfg": False,
|
||||
"negative_prompt": "",
|
||||
"num_inference_steps": 50,
|
||||
"seed": 0,
|
||||
}
|
||||
|
||||
MINIMAX_H3_T2VA = InferencePreset(
|
||||
name="minimax_h3_t2va",
|
||||
version=1,
|
||||
model_family="minimax_h3",
|
||||
description="MiniMax H3 text-to-video with synchronized stereo audio at 768p",
|
||||
workload_type="t2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
**_SHARED_DEFAULTS,
|
||||
"height": 768,
|
||||
"width": 1344,
|
||||
"num_frames": 124,
|
||||
},
|
||||
)
|
||||
|
||||
MINIMAX_H3_FL2VA = InferencePreset(
|
||||
name="minimax_h3_fl2va",
|
||||
version=1,
|
||||
model_family="minimax_h3",
|
||||
description="MiniMax H3 first/last-frame-to-video with synchronized stereo audio",
|
||||
workload_type="i2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
**_SHARED_DEFAULTS,
|
||||
"height": 768,
|
||||
"width": 1344,
|
||||
"num_frames": 192,
|
||||
},
|
||||
)
|
||||
|
||||
MINIMAX_H3_REF2VA = InferencePreset(
|
||||
name="minimax_h3_ref2va",
|
||||
version=1,
|
||||
model_family="minimax_h3",
|
||||
description="MiniMax H3 ordered image/video/audio references to joint video and stereo audio",
|
||||
workload_type="i2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
**_SHARED_DEFAULTS,
|
||||
"height": 768,
|
||||
"width": 1344,
|
||||
"num_frames": 124,
|
||||
},
|
||||
)
|
||||
|
||||
ALL_PRESETS = (MINIMAX_H3_T2VA, MINIMAX_H3_FL2VA, MINIMAX_H3_REF2VA)
|
||||
|
||||
__all__ = [
|
||||
"ALL_PRESETS",
|
||||
"MINIMAX_H3_FL2VA",
|
||||
"MINIMAX_H3_REF2VA",
|
||||
"MINIMAX_H3_T2VA",
|
||||
]
|
||||
@@ -0,0 +1,381 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 reference inputs and media preparation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import math
|
||||
from numbers import Integral, Real
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
import torch
|
||||
|
||||
from fastvideo.models.vision_utils import load_image
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import (
|
||||
MINIMAX_H3_AUDIO_CHANNELS,
|
||||
MINIMAX_H3_CANVAS_MULTIPLE,
|
||||
MINIMAX_H3_FPS,
|
||||
MINIMAX_H3_FRAMES_PER_CHUNK,
|
||||
MINIMAX_H3_LATENTS_PER_CHUNK,
|
||||
resolve_canvas_size,
|
||||
)
|
||||
|
||||
MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE = 2048
|
||||
MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS = 2.0
|
||||
MINIMAX_H3_QWEN_TEMPORAL_PATCH = 2
|
||||
MINIMAX_H3_MAX_REFERENCE_IMAGES = 9
|
||||
MINIMAX_H3_MAX_REFERENCE_VIDEOS = 3
|
||||
MINIMAX_H3_MAX_REFERENCE_AUDIOS = 3
|
||||
MINIMAX_H3_MAX_REFERENCES = 12
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MiniMaxH3Reference:
|
||||
"""One ordered Ref2VA input; decoding is deferred until request preparation."""
|
||||
|
||||
source: Any
|
||||
media_type: Literal["image", "video", "audio"] = "image"
|
||||
soundtrack: Any | None = None
|
||||
fps: float | None = None
|
||||
sample_rate: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.source is None:
|
||||
raise ValueError("A MiniMax-H3 reference requires a media source.")
|
||||
if self.media_type not in ("image", "video", "audio"):
|
||||
raise ValueError(f"Unsupported MiniMax-H3 reference type: {self.media_type!r}.")
|
||||
if self.soundtrack is not None and self.media_type != "video":
|
||||
raise ValueError("Only a video reference may carry a separate soundtrack.")
|
||||
if self.fps is not None and (self.media_type != "video" or isinstance(self.fps, bool)
|
||||
or not isinstance(self.fps, Real) or self.fps <= 0):
|
||||
raise ValueError("Reference `fps` must be positive and is only valid for video.")
|
||||
if self.sample_rate is not None and (self.media_type == "image" or isinstance(self.sample_rate, bool)
|
||||
or not isinstance(self.sample_rate, Integral) or self.sample_rate <= 0):
|
||||
raise ValueError("Reference `sample_rate` must be positive and is only valid for audio-bearing media.")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3PreparedReference:
|
||||
"""Decoded Ref2VA media and the latent geometry resolved by condition encoding."""
|
||||
|
||||
media_type: Literal["image", "video", "audio"]
|
||||
has_audio: bool = False
|
||||
image: Image.Image | None = None
|
||||
frames: np.ndarray | None = None
|
||||
waveform: torch.Tensor | None = None
|
||||
block_timestamps: list[float] = field(default_factory=list)
|
||||
num_latent_frames: int = 1
|
||||
latent_height: int = 0
|
||||
latent_width: int = 0
|
||||
num_audio_latents: int = 0
|
||||
|
||||
|
||||
def validate_references(references: list[Any]) -> list[MiniMaxH3Reference]:
|
||||
"""Validate the ordered Ref2VA media list and its per-modality limits."""
|
||||
if not references:
|
||||
raise ValueError("MiniMax-H3 Ref2VA requires at least one reference.")
|
||||
if not all(isinstance(reference, MiniMaxH3Reference) for reference in references):
|
||||
raise TypeError("Every Ref2VA entry must be a MiniMaxH3Reference.")
|
||||
|
||||
typed = list(references)
|
||||
counts = {
|
||||
media_type: sum(reference.media_type == media_type for reference in typed)
|
||||
for media_type in ("image", "video", "audio")
|
||||
}
|
||||
limits = {
|
||||
"image": MINIMAX_H3_MAX_REFERENCE_IMAGES,
|
||||
"video": MINIMAX_H3_MAX_REFERENCE_VIDEOS,
|
||||
"audio": MINIMAX_H3_MAX_REFERENCE_AUDIOS,
|
||||
}
|
||||
for media_type, limit in limits.items():
|
||||
if counts[media_type] > limit:
|
||||
raise ValueError(f"MiniMax-H3 accepts at most {limit} {media_type} references.")
|
||||
if len(typed) > MINIMAX_H3_MAX_REFERENCES:
|
||||
raise ValueError(f"MiniMax-H3 accepts at most {MINIMAX_H3_MAX_REFERENCES} references.")
|
||||
if counts["audio"] == len(typed):
|
||||
raise ValueError("Audio references must be paired with at least one image or video reference.")
|
||||
return typed
|
||||
|
||||
|
||||
def _import_av() -> Any:
|
||||
try:
|
||||
import av
|
||||
except ImportError as error:
|
||||
raise ImportError("Decoding MiniMax-H3 video or audio references requires PyAV.") from error
|
||||
return av
|
||||
|
||||
|
||||
def _decode_audio_stream(av_module: Any, container: Any, stream: Any) -> tuple[torch.Tensor, int]:
|
||||
sample_rate = int(stream.codec_context.sample_rate)
|
||||
resampler = av_module.audio.resampler.AudioResampler(format="fltp", layout=stream.layout, rate=sample_rate)
|
||||
chunks: list[torch.Tensor] = []
|
||||
for frame in container.decode(stream):
|
||||
chunks.extend(torch.from_numpy(item.to_ndarray()) for item in resampler.resample(frame))
|
||||
chunks.extend(torch.from_numpy(item.to_ndarray()) for item in resampler.resample(None))
|
||||
if not chunks:
|
||||
raise ValueError("The reference audio stream contains no samples.")
|
||||
return torch.cat(chunks, dim=-1).to(torch.float32), sample_rate
|
||||
|
||||
|
||||
def decode_reference_audio(source: str | os.PathLike[str]) -> tuple[torch.Tensor, int]:
|
||||
"""Decode the first audio stream without imposing H3's target sample rate."""
|
||||
av_module = _import_av()
|
||||
with av_module.open(str(source)) as container:
|
||||
if not container.streams.audio:
|
||||
raise ValueError(f"Reference media {source!s} contains no audio stream.")
|
||||
return _decode_audio_stream(av_module, container, container.streams.audio[0])
|
||||
|
||||
|
||||
def decode_reference_video(source: str | os.PathLike[str]) -> tuple[np.ndarray, float, tuple[torch.Tensor, int] | None]:
|
||||
"""Decode RGB frames, display rotation, frame rate, and an optional soundtrack."""
|
||||
av_module = _import_av()
|
||||
with av_module.open(str(source)) as container:
|
||||
if not container.streams.video:
|
||||
raise ValueError(f"Reference media {source!s} contains no video stream.")
|
||||
stream = container.streams.video[0]
|
||||
frames = []
|
||||
rotation = 0.0
|
||||
for frame in container.decode(stream):
|
||||
rotation = float(getattr(frame, "rotation", 0.0) or 0.0)
|
||||
frames.append(frame.to_ndarray(format="rgb24"))
|
||||
if not frames:
|
||||
raise ValueError(f"Reference media {source!s} contains no video frames.")
|
||||
rate = stream.average_rate or getattr(stream, "guessed_rate", None)
|
||||
if rate is None:
|
||||
raise ValueError(f"Reference media {source!s} does not expose a frame rate.")
|
||||
|
||||
soundtrack = None
|
||||
if container.streams.audio:
|
||||
container.seek(0)
|
||||
soundtrack = _decode_audio_stream(av_module, container, container.streams.audio[0])
|
||||
|
||||
result = np.stack(frames)
|
||||
turns = round(rotation / 90.0) % 4
|
||||
if turns:
|
||||
result = np.ascontiguousarray(np.rot90(result, k=-turns, axes=(1, 2)))
|
||||
return result, float(rate), soundtrack
|
||||
|
||||
|
||||
def resolve_reference_image_size(width: int, height: int) -> tuple[int, int]:
|
||||
"""Resolve the released 2048-short-edge reference-image canvas."""
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(f"A reference image must have a positive size, got {width}x{height}.")
|
||||
if width > 4 * height or height > 4 * width:
|
||||
raise ValueError(f"A reference image must be within 1:4 and 4:1, got {width}x{height}.")
|
||||
scale = MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE / min(width, height)
|
||||
multiple = MINIMAX_H3_CANVAS_MULTIPLE
|
||||
return (
|
||||
max(multiple,
|
||||
round(height * scale / multiple) * multiple),
|
||||
max(multiple,
|
||||
round(width * scale / multiple) * multiple),
|
||||
)
|
||||
|
||||
|
||||
def reference_media_to_uint8(media: Any) -> np.ndarray:
|
||||
"""Normalize PIL, tensor, NumPy, or frame-list media to channel-last uint8."""
|
||||
if isinstance(media, list):
|
||||
if not media:
|
||||
raise ValueError("A reference video must contain at least one frame.")
|
||||
return np.stack([reference_media_to_uint8(item) for item in media])
|
||||
if isinstance(media, Image.Image):
|
||||
return np.asarray(media.convert("RGB"))
|
||||
if isinstance(media, torch.Tensor):
|
||||
media = media.movedim(-3, -1).detach().cpu().numpy()
|
||||
media = np.asarray(media)
|
||||
if media.dtype != np.uint8:
|
||||
media = (media * 255.0).round().clip(0, 255).astype(np.uint8)
|
||||
return media
|
||||
|
||||
|
||||
def prepare_reference_image(image: Image.Image, height: int, width: int) -> Image.Image:
|
||||
"""Resize an RGB reference image to its resolved condition canvas."""
|
||||
return image if image.size == (width, height) else image.resize((width, height), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def resample_reference_frames(frames: np.ndarray, fps: float) -> np.ndarray:
|
||||
"""Nearest-resample reference frames onto H3's fixed 24-fps timeline."""
|
||||
if frames.ndim != 4 or frames.shape[-1] != 3 or frames.shape[0] == 0:
|
||||
raise ValueError(f"A reference video must be non-empty RGB frames, got {tuple(frames.shape)}.")
|
||||
if fps <= 0:
|
||||
raise ValueError(f"A reference video must have a positive frame rate, got {fps}.")
|
||||
if fps == MINIMAX_H3_FPS:
|
||||
return frames
|
||||
scale = MINIMAX_H3_FPS / fps
|
||||
slots = np.floor(np.arange(frames.shape[0]) * scale + 0.5).astype(np.int64)
|
||||
repeats = np.diff(slots, append=math.floor(frames.shape[0] * scale + 0.5))
|
||||
return np.repeat(frames, repeats, axis=0)
|
||||
|
||||
|
||||
def prepare_reference_frames(frames: np.ndarray, num_frames: int) -> np.ndarray:
|
||||
"""Trim a reference video to the request and resize every RGB frame."""
|
||||
if frames.ndim != 4 or frames.shape[-1] != 3 or frames.shape[0] == 0:
|
||||
raise ValueError(f"A reference video must be non-empty RGB frames, got {tuple(frames.shape)}.")
|
||||
frames = frames[:num_frames]
|
||||
height, width = resolve_canvas_size(frames.shape[2], frames.shape[1])
|
||||
if frames.shape[1:3] == (height, width):
|
||||
return frames
|
||||
return np.stack(
|
||||
[np.asarray(Image.fromarray(frame).resize((width, height), Image.Resampling.LANCZOS)) for frame in frames])
|
||||
|
||||
|
||||
def sample_reference_video_frames(frames: np.ndarray) -> tuple[list[np.ndarray], list[float]]:
|
||||
"""Sample the released 2-fps Qwen presentation and its block timestamps."""
|
||||
if frames.ndim != 4 or frames.shape[0] == 0:
|
||||
raise ValueError("A prepared reference video must contain frames.")
|
||||
stride = MINIMAX_H3_FPS / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS
|
||||
indices: list[int] = []
|
||||
cursor = 0.0
|
||||
while round(cursor) < frames.shape[0]:
|
||||
if not indices or round(cursor) > indices[-1]:
|
||||
indices.append(round(cursor))
|
||||
cursor += stride
|
||||
timestamps = [index / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS for index in range(len(indices))]
|
||||
timestamps += [timestamps[-1]] * (-len(timestamps) % MINIMAX_H3_QWEN_TEMPORAL_PATCH)
|
||||
block_timestamps = [(timestamps[index] + timestamps[index + MINIMAX_H3_QWEN_TEMPORAL_PATCH - 1]) / 2
|
||||
for index in range(0, len(timestamps), MINIMAX_H3_QWEN_TEMPORAL_PATCH)]
|
||||
return [frames[index] for index in indices], block_timestamps
|
||||
|
||||
|
||||
def prepare_reference_waveform(
|
||||
waveform: torch.Tensor,
|
||||
sample_rate: int,
|
||||
target_sample_rate: int,
|
||||
max_duration: float,
|
||||
) -> torch.Tensor:
|
||||
"""Normalize reference audio to stereo float32 at the audio-VAE rate."""
|
||||
waveform = torch.as_tensor(waveform).detach().cpu()
|
||||
if waveform.ndim != 2 or waveform.shape[0] not in (1, MINIMAX_H3_AUDIO_CHANNELS):
|
||||
raise ValueError(
|
||||
f"A reference soundtrack must be mono or stereo [channels, samples], got {tuple(waveform.shape)}.")
|
||||
if waveform.shape[-1] == 0:
|
||||
raise ValueError("A reference soundtrack must contain samples.")
|
||||
if sample_rate <= 0 or target_sample_rate <= 0:
|
||||
raise ValueError("Reference audio sample rates must be positive.")
|
||||
waveform = waveform.to(torch.float32)[:, :int(max_duration * sample_rate)]
|
||||
if waveform.shape[0] == 1:
|
||||
waveform = waveform.expand(MINIMAX_H3_AUDIO_CHANNELS, -1).contiguous()
|
||||
if sample_rate == target_sample_rate:
|
||||
return waveform
|
||||
try:
|
||||
import torchaudio
|
||||
except (ImportError, OSError):
|
||||
from math import gcd
|
||||
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
divisor = gcd(sample_rate, target_sample_rate)
|
||||
resampled = resample_poly(
|
||||
waveform.numpy(),
|
||||
up=target_sample_rate // divisor,
|
||||
down=sample_rate // divisor,
|
||||
axis=-1,
|
||||
)
|
||||
return torch.from_numpy(np.asarray(resampled, dtype=np.float32))
|
||||
return torchaudio.transforms.Resample(sample_rate, target_sample_rate)(waveform)
|
||||
|
||||
|
||||
def trim_reference_num_frames(num_frames: int) -> int:
|
||||
"""Trim video references to the causal VAE's complete chunk geometry."""
|
||||
if num_frames < 1:
|
||||
raise ValueError(f"A reference video must have at least one frame, got {num_frames}.")
|
||||
return (
|
||||
max(1,
|
||||
(num_frames - MINIMAX_H3_LATENTS_PER_CHUNK) // MINIMAX_H3_FRAMES_PER_CHUNK) * MINIMAX_H3_FRAMES_PER_CHUNK +
|
||||
MINIMAX_H3_LATENTS_PER_CHUNK)
|
||||
|
||||
|
||||
def _resolve_audio_source(source: Any) -> tuple[torch.Tensor, int | None]:
|
||||
if isinstance(source, str | os.PathLike):
|
||||
return decode_reference_audio(source)
|
||||
return torch.as_tensor(source), None
|
||||
|
||||
|
||||
def prepare_reference(
|
||||
reference: MiniMaxH3Reference,
|
||||
num_frames: int,
|
||||
target_sample_rate: int,
|
||||
) -> MiniMaxH3PreparedReference:
|
||||
"""Decode and normalize one deferred Ref2VA medium."""
|
||||
prepared = MiniMaxH3PreparedReference(media_type=reference.media_type)
|
||||
if reference.media_type == "image":
|
||||
source = reference.source
|
||||
image = load_image(str(source)) if isinstance(source, str | os.PathLike) else source
|
||||
if not isinstance(image, Image.Image):
|
||||
pixels = reference_media_to_uint8(image)
|
||||
if pixels.ndim != 3 or pixels.shape[-1] != 3:
|
||||
raise ValueError(f"An image reference must be RGB, got {tuple(pixels.shape)}.")
|
||||
image = Image.fromarray(pixels)
|
||||
image = ImageOps.exif_transpose(image).convert("RGB")
|
||||
height, width = resolve_reference_image_size(*image.size)
|
||||
prepared.image = prepare_reference_image(image, height, width)
|
||||
return prepared
|
||||
|
||||
if reference.media_type == "video":
|
||||
decoded_soundtrack = None
|
||||
decoded_sample_rate = None
|
||||
if isinstance(reference.source, str | os.PathLike):
|
||||
frames, decoded_fps, soundtrack = decode_reference_video(reference.source)
|
||||
if soundtrack is not None:
|
||||
decoded_soundtrack, decoded_sample_rate = soundtrack
|
||||
else:
|
||||
frames = reference_media_to_uint8(reference.source)
|
||||
decoded_fps = float(MINIMAX_H3_FPS)
|
||||
fps = float(reference.fps if reference.fps is not None else decoded_fps)
|
||||
prepared.frames = prepare_reference_frames(resample_reference_frames(frames, fps), num_frames)
|
||||
|
||||
if reference.soundtrack is not None:
|
||||
waveform, source_rate = _resolve_audio_source(reference.soundtrack)
|
||||
decoded_soundtrack = waveform
|
||||
decoded_sample_rate = source_rate
|
||||
if decoded_soundtrack is not None:
|
||||
sample_rate = reference.sample_rate or decoded_sample_rate or target_sample_rate
|
||||
prepared.waveform = prepare_reference_waveform(
|
||||
decoded_soundtrack,
|
||||
int(sample_rate),
|
||||
target_sample_rate,
|
||||
max_duration=num_frames / MINIMAX_H3_FPS,
|
||||
)
|
||||
prepared.has_audio = True
|
||||
elif reference.sample_rate is not None:
|
||||
raise ValueError("A silent video reference cannot specify `sample_rate`.")
|
||||
return prepared
|
||||
|
||||
waveform, decoded_sample_rate = _resolve_audio_source(reference.source)
|
||||
sample_rate = reference.sample_rate or decoded_sample_rate or target_sample_rate
|
||||
prepared.waveform = prepare_reference_waveform(
|
||||
waveform,
|
||||
int(sample_rate),
|
||||
target_sample_rate,
|
||||
max_duration=num_frames / MINIMAX_H3_FPS,
|
||||
)
|
||||
prepared.has_audio = True
|
||||
return prepared
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_MAX_REFERENCES",
|
||||
"MINIMAX_H3_MAX_REFERENCE_AUDIOS",
|
||||
"MINIMAX_H3_MAX_REFERENCE_IMAGES",
|
||||
"MINIMAX_H3_MAX_REFERENCE_VIDEOS",
|
||||
"MINIMAX_H3_QWEN_TEMPORAL_PATCH",
|
||||
"MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS",
|
||||
"MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE",
|
||||
"MiniMaxH3PreparedReference",
|
||||
"MiniMaxH3Reference",
|
||||
"decode_reference_audio",
|
||||
"decode_reference_video",
|
||||
"prepare_reference",
|
||||
"prepare_reference_frames",
|
||||
"prepare_reference_image",
|
||||
"prepare_reference_waveform",
|
||||
"reference_media_to_uint8",
|
||||
"resample_reference_frames",
|
||||
"resolve_reference_image_size",
|
||||
"sample_reference_video_frames",
|
||||
"trim_reference_num_frames",
|
||||
"validate_references",
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_conditioning import MiniMaxH3ConditioningStage
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_decoding import (
|
||||
MiniMaxH3AudioDecodingStage,
|
||||
MiniMaxH3VideoDecodingStage,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_denoising import MiniMaxH3DenoisingStage
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_input_preparation import MiniMaxH3InputPreparationStage
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import MiniMaxH3LatentPreparationStage
|
||||
|
||||
__all__ = [
|
||||
"MiniMaxH3AudioDecodingStage",
|
||||
"MiniMaxH3ConditioningStage",
|
||||
"MiniMaxH3DenoisingStage",
|
||||
"MiniMaxH3InputPreparationStage",
|
||||
"MiniMaxH3LatentPreparationStage",
|
||||
"MiniMaxH3VideoDecodingStage",
|
||||
]
|
||||
@@ -0,0 +1,329 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Qwen3-VL conditioning for MiniMax H3 FL2VA and Ref2VA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch.distributed.tensor import DTensor
|
||||
|
||||
from fastvideo.distributed import get_local_torch_device
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.models.encoders.minimax_h3_qwen3_vl import MiniMaxH3Qwen3VLConditioner
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import (
|
||||
MINIMAX_H3_IMAGE_PAD_TOKEN,
|
||||
MINIMAX_H3_TEXT_ENCODER_LAYER,
|
||||
MINIMAX_H3_TEXT_TAG,
|
||||
MINIMAX_H3_VIDEO_PAD_TOKEN,
|
||||
MINIMAX_H3_VIDEO_TAG,
|
||||
MINIMAX_H3_VISION_END_TOKEN,
|
||||
MINIMAX_H3_VISION_START_TOKEN,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.reference import MiniMaxH3PreparedReference, sample_reference_video_frames
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_input_preparation import MINIMAX_H3_KEYFRAMES_KEY
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.pipelines.stages.base import PipelineStage
|
||||
from fastvideo.pipelines.stages.validators import StageValidators as V
|
||||
from fastvideo.pipelines.stages.validators import VerificationResult
|
||||
|
||||
MINIMAX_H3_TEXT_TOKEN_TAGS_KEY = "minimax_h3_text_token_tags"
|
||||
|
||||
|
||||
def _token_ids(tokenized: Any) -> list[int]:
|
||||
input_ids = tokenized["input_ids"] if isinstance(tokenized, dict) else tokenized.input_ids
|
||||
if isinstance(input_ids, torch.Tensor):
|
||||
input_ids = input_ids.tolist()
|
||||
if input_ids and isinstance(input_ids[0], list):
|
||||
if len(input_ids) != 1:
|
||||
raise ValueError("MiniMax H3 tokenization must produce exactly one sequence.")
|
||||
input_ids = input_ids[0]
|
||||
return [int(token_id) for token_id in input_ids]
|
||||
|
||||
|
||||
def _module_dtype(module: Any) -> torch.dtype:
|
||||
dtype = getattr(module, "dtype", None)
|
||||
if isinstance(dtype, torch.dtype):
|
||||
return dtype
|
||||
parameter = next(module.parameters(), None)
|
||||
return torch.float32 if parameter is None else parameter.dtype
|
||||
|
||||
|
||||
def _create_mm_token_type_ids(processor: Any, token_ids: list[int]) -> list[list[int]]:
|
||||
"""Build Qwen3-VL modality IDs across old and new Transformers releases."""
|
||||
create_ids = getattr(processor, "create_mm_token_type_ids", None)
|
||||
if callable(create_ids):
|
||||
return create_ids([token_ids])
|
||||
|
||||
modality_ids = [0] * len(token_ids)
|
||||
for modality, modality_type in (("image", 1), ("video", 2), ("audio", 3)):
|
||||
special_ids = getattr(processor, f"{modality}_token_ids", None)
|
||||
if special_ids is None:
|
||||
special_id = getattr(processor, f"{modality}_token_id", None)
|
||||
special_ids = [] if special_id is None else [special_id]
|
||||
resolved_ids = {int(special_id) for special_id in special_ids if special_id is not None}
|
||||
for index, token_id in enumerate(token_ids):
|
||||
if token_id in resolved_ids:
|
||||
modality_ids[index] = modality_type
|
||||
return [modality_ids]
|
||||
|
||||
|
||||
def build_ref2va_presentation(
|
||||
tokenizer: Any,
|
||||
prompt: str,
|
||||
references: list[MiniMaxH3PreparedReference],
|
||||
image_token_counts: list[int],
|
||||
video_block_token_counts: list[int],
|
||||
) -> tuple[list[int], list[int]]:
|
||||
"""Tokenize ordered reference labels, vision blocks, then the prompt."""
|
||||
|
||||
def text(value: str) -> tuple[list[int], list[int]]:
|
||||
ids = _token_ids(tokenizer(value, add_special_tokens=False))
|
||||
return ids, [MINIMAX_H3_TEXT_TAG] * len(ids)
|
||||
|
||||
def vision(pad_token: str, count: int) -> tuple[list[int], list[int]]:
|
||||
ids = ([int(tokenizer.convert_tokens_to_ids(MINIMAX_H3_VISION_START_TOKEN))] +
|
||||
[int(tokenizer.convert_tokens_to_ids(pad_token))] * count +
|
||||
[int(tokenizer.convert_tokens_to_ids(MINIMAX_H3_VISION_END_TOKEN))])
|
||||
return ids, [MINIMAX_H3_VIDEO_TAG] * len(ids)
|
||||
|
||||
token_ids: list[int] = []
|
||||
token_tags: list[int] = []
|
||||
|
||||
def emit(segment: tuple[list[int], list[int]]) -> None:
|
||||
token_ids.extend(segment[0])
|
||||
token_tags.extend(segment[1])
|
||||
|
||||
counts = {"image": 0, "video": 0, "audio": 0}
|
||||
for reference in references:
|
||||
if reference.has_audio:
|
||||
counts["audio"] += 1
|
||||
emit(text(f"<Audio {counts['audio']}>: "))
|
||||
if reference.media_type == "image":
|
||||
counts["image"] += 1
|
||||
if counts["image"] > len(image_token_counts):
|
||||
raise ValueError("Missing Qwen token count for a reference image.")
|
||||
emit(text(f"<Picture {counts['image']}>: "))
|
||||
emit(vision(MINIMAX_H3_IMAGE_PAD_TOKEN, image_token_counts[counts["image"] - 1]))
|
||||
elif reference.media_type == "video":
|
||||
counts["video"] += 1
|
||||
if counts["video"] > len(video_block_token_counts):
|
||||
raise ValueError("Missing Qwen token count for a reference video.")
|
||||
emit(text(f"<Video {counts['video']}>: "))
|
||||
for timestamp in reference.block_timestamps:
|
||||
emit(text(f"<{timestamp:.1f} seconds>"))
|
||||
emit(vision(MINIMAX_H3_VIDEO_PAD_TOKEN, video_block_token_counts[counts["video"] - 1]))
|
||||
elif reference.media_type != "audio":
|
||||
raise ValueError(f"Unsupported prepared reference type: {reference.media_type!r}.")
|
||||
if counts["image"] != len(image_token_counts) or counts["video"] != len(video_block_token_counts):
|
||||
raise ValueError("Qwen vision token counts do not match the ordered references.")
|
||||
emit(text(prompt))
|
||||
return token_ids, token_tags
|
||||
|
||||
|
||||
class MiniMaxH3ConditioningStage(PipelineStage):
|
||||
"""Encode the prompt and ordered visual presentation with Qwen3-VL."""
|
||||
|
||||
performance_component_metric = "text_encoder_time_s"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conditioner: MiniMaxH3Qwen3VLConditioner,
|
||||
tokenizer: Any,
|
||||
processor: Any,
|
||||
*,
|
||||
ref2va: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.conditioner = conditioner
|
||||
self.tokenizer = tokenizer
|
||||
self.processor = processor
|
||||
self.ref2va = ref2va
|
||||
|
||||
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("prompt", batch.prompt, lambda value: isinstance(value, str))
|
||||
if self.ref2va:
|
||||
result.add_check("references", batch.references, V.list_not_empty)
|
||||
else:
|
||||
result.add_check("keyframes", batch.extra.get(MINIMAX_H3_KEYFRAMES_KEY), V.is_list)
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("prompt_embeds", batch.prompt_embeds, V.list_of_tensors_dims(3))
|
||||
result.add_check("text_token_tags", batch.extra.get(MINIMAX_H3_TEXT_TOKEN_TAGS_KEY), V.with_dims(1))
|
||||
return result
|
||||
|
||||
def _encode_tokens(
|
||||
self,
|
||||
token_ids: list[int],
|
||||
token_tags: list[int],
|
||||
device: torch.device,
|
||||
**vision_inputs: torch.Tensor | None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
hidden_state_index = MINIMAX_H3_TEXT_ENCODER_LAYER
|
||||
num_hidden_layers = getattr(self.conditioner, "num_hidden_layers", None)
|
||||
if num_hidden_layers is None:
|
||||
config = getattr(self.conditioner, "config", None)
|
||||
arch = getattr(config, "arch_config", config)
|
||||
num_hidden_layers = getattr(arch, "num_hidden_layers", None)
|
||||
if num_hidden_layers is None or num_hidden_layers <= hidden_state_index:
|
||||
raise ValueError(f"MiniMax H3 requires more than {hidden_state_index} Qwen3-VL decoder layers to read "
|
||||
f"`hidden_states[{hidden_state_index}]`, got {num_hidden_layers}.")
|
||||
|
||||
input_ids = torch.tensor([token_ids], dtype=torch.long, device=device)
|
||||
mm_token_type_ids = torch.as_tensor(
|
||||
_create_mm_token_type_ids(self.processor, token_ids),
|
||||
dtype=torch.long,
|
||||
device=device,
|
||||
)
|
||||
dtype = _module_dtype(self.conditioner)
|
||||
outputs = self.conditioner(
|
||||
input_ids=input_ids,
|
||||
attention_mask=torch.ones_like(input_ids),
|
||||
mm_token_type_ids=mm_token_type_ids,
|
||||
use_cache=False,
|
||||
output_hidden_states=True,
|
||||
**{
|
||||
name:
|
||||
None if value is None else value.to(
|
||||
device=device,
|
||||
dtype=dtype if name in {"pixel_values", "pixel_values_videos"} else None,
|
||||
)
|
||||
for name, value in vision_inputs.items()
|
||||
},
|
||||
)
|
||||
if outputs.hidden_states is None or len(outputs.hidden_states) <= hidden_state_index:
|
||||
raise ValueError(f"Qwen3-VL did not return `hidden_states[{hidden_state_index}]`.")
|
||||
return (
|
||||
outputs.hidden_states[hidden_state_index].to(device=device, dtype=dtype),
|
||||
torch.tensor(token_tags, dtype=torch.long),
|
||||
)
|
||||
|
||||
def _encode_fl2va(self, batch: ForwardBatch, device: torch.device) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
if not isinstance(batch.prompt, str):
|
||||
raise ValueError("MiniMax H3 requires `prompt` to be a single string.")
|
||||
images = batch.extra.get(MINIMAX_H3_KEYFRAMES_KEY, [])
|
||||
if not isinstance(images, list):
|
||||
raise TypeError("MiniMax-H3 keyframes must be a list.")
|
||||
|
||||
token_ids: list[int] = []
|
||||
token_tags: list[int] = []
|
||||
pixel_values = None
|
||||
image_grid_thw = None
|
||||
if images:
|
||||
vision_inputs = self.processor.image_processor(images=images, return_tensors="pt")
|
||||
pixel_values = vision_inputs["pixel_values"]
|
||||
image_grid_thw = vision_inputs["image_grid_thw"]
|
||||
merge_area = int(self.processor.image_processor.merge_size)**2
|
||||
vision_start_id = int(self.tokenizer.convert_tokens_to_ids(MINIMAX_H3_VISION_START_TOKEN))
|
||||
image_pad_id = int(self.tokenizer.convert_tokens_to_ids(MINIMAX_H3_IMAGE_PAD_TOKEN))
|
||||
vision_end_id = int(self.tokenizer.convert_tokens_to_ids(MINIMAX_H3_VISION_END_TOKEN))
|
||||
for index in range(len(images)):
|
||||
num_image_tokens = int(image_grid_thw[index].prod().item()) // merge_area
|
||||
label_ids = _token_ids(self.tokenizer(f"<Picture {index + 1}>: ", add_special_tokens=False))
|
||||
vision_ids = [vision_start_id] + [image_pad_id] * num_image_tokens + [vision_end_id]
|
||||
token_ids.extend(label_ids)
|
||||
token_ids.extend(vision_ids)
|
||||
token_tags.extend([MINIMAX_H3_TEXT_TAG] * len(label_ids))
|
||||
token_tags.extend([MINIMAX_H3_VIDEO_TAG] * len(vision_ids))
|
||||
|
||||
prompt_ids = _token_ids(self.tokenizer(batch.prompt, add_special_tokens=False))
|
||||
token_ids.extend(prompt_ids)
|
||||
token_tags.extend([MINIMAX_H3_TEXT_TAG] * len(prompt_ids))
|
||||
return self._encode_tokens(
|
||||
token_ids,
|
||||
token_tags,
|
||||
device,
|
||||
pixel_values=pixel_values,
|
||||
image_grid_thw=image_grid_thw,
|
||||
)
|
||||
|
||||
def _encode_ref2va(self, batch: ForwardBatch, device: torch.device) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
if not isinstance(batch.prompt, str):
|
||||
raise ValueError("MiniMax H3 requires `prompt` to be a single string.")
|
||||
references = list(batch.references or [])
|
||||
if not references or not all(isinstance(item, MiniMaxH3PreparedReference) for item in references):
|
||||
raise TypeError("MiniMax-H3 Ref2VA conditioning requires prepared references.")
|
||||
|
||||
merge_area = int(self.processor.image_processor.merge_size)**2
|
||||
pixel_values = None
|
||||
image_grid_thw = None
|
||||
image_token_counts: list[int] = []
|
||||
images = [reference.image for reference in references if reference.media_type == "image"]
|
||||
if any(image is None for image in images):
|
||||
raise ValueError("MiniMax-H3 reference images must be prepared before conditioning.")
|
||||
if images:
|
||||
vision = self.processor.image_processor(images=images, return_tensors="pt")
|
||||
pixel_values = vision["pixel_values"]
|
||||
image_grid_thw = vision["image_grid_thw"]
|
||||
image_token_counts = [int(grid.prod().item()) // merge_area for grid in image_grid_thw]
|
||||
|
||||
pixel_values_videos = None
|
||||
video_grid_thw = None
|
||||
video_block_token_counts: list[int] = []
|
||||
videos = [reference for reference in references if reference.media_type == "video"]
|
||||
if videos:
|
||||
if any(reference.frames is None for reference in videos):
|
||||
raise ValueError("MiniMax-H3 reference videos must be prepared before conditioning.")
|
||||
sampled = [sample_reference_video_frames(reference.frames) for reference in videos]
|
||||
for reference, (_, timestamps) in zip(videos, sampled, strict=True):
|
||||
reference.block_timestamps = timestamps
|
||||
vision = self.processor.video_processor(
|
||||
videos=[np.stack(frames) for frames, _ in sampled],
|
||||
do_sample_frames=False,
|
||||
return_tensors="pt",
|
||||
)
|
||||
pixel_values_videos = vision["pixel_values_videos"]
|
||||
video_grid_thw = vision["video_grid_thw"]
|
||||
video_block_token_counts = [int(grid[1]) * int(grid[2]) // merge_area for grid in video_grid_thw]
|
||||
for reference, grid in zip(videos, video_grid_thw, strict=True):
|
||||
if int(grid[0]) != len(reference.block_timestamps):
|
||||
raise ValueError(f"Qwen3-VL produced {int(grid[0])} blocks for a reference video, but "
|
||||
f"MiniMax-H3 labels {len(reference.block_timestamps)}.")
|
||||
|
||||
token_ids, token_tags = build_ref2va_presentation(
|
||||
self.tokenizer,
|
||||
batch.prompt,
|
||||
references,
|
||||
image_token_counts,
|
||||
video_block_token_counts,
|
||||
)
|
||||
return self._encode_tokens(
|
||||
token_ids,
|
||||
token_tags,
|
||||
device,
|
||||
pixel_values=pixel_values,
|
||||
image_grid_thw=image_grid_thw,
|
||||
pixel_values_videos=pixel_values_videos,
|
||||
video_grid_thw=video_grid_thw,
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
device = get_local_torch_device()
|
||||
first_param = next(self.conditioner.parameters(), None)
|
||||
moved_for_forward = (fastvideo_args.text_encoder_cpu_offload and first_param is not None
|
||||
and not isinstance(first_param, DTensor))
|
||||
if moved_for_forward:
|
||||
self.conditioner.to(device)
|
||||
try:
|
||||
if self.ref2va:
|
||||
prompt_embeds, text_token_tags = self._encode_ref2va(batch, device)
|
||||
else:
|
||||
prompt_embeds, text_token_tags = self._encode_fl2va(batch, device)
|
||||
finally:
|
||||
if moved_for_forward:
|
||||
self.conditioner.to("cpu")
|
||||
batch.prompt_embeds = [prompt_embeds]
|
||||
batch.extra[MINIMAX_H3_TEXT_TOKEN_TAGS_KEY] = text_token_tags
|
||||
return batch
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_TEXT_TOKEN_TAGS_KEY",
|
||||
"MiniMaxH3ConditioningStage",
|
||||
"build_ref2va_presentation",
|
||||
]
|
||||
@@ -0,0 +1,150 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 video and stereo-audio decoding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from fastvideo.distributed import get_local_torch_device
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.models.vaes.minimax_h3_audio import MiniMaxH3AudioVAE
|
||||
from fastvideo.models.vaes.minimax_h3_video import AutoencoderKLMiniMaxH3
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import (
|
||||
MiniMaxH3PackedLayout,
|
||||
unpack_audio_tokens,
|
||||
unpatchify_video_tokens,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import MINIMAX_H3_LAYOUT_KEY
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.pipelines.stages.base import PipelineStage
|
||||
from fastvideo.pipelines.stages.validators import StageValidators as V
|
||||
from fastvideo.pipelines.stages.validators import VerificationResult
|
||||
|
||||
|
||||
def _layout(batch: ForwardBatch) -> MiniMaxH3PackedLayout:
|
||||
layout = batch.extra.get(MINIMAX_H3_LAYOUT_KEY)
|
||||
if not isinstance(layout, MiniMaxH3PackedLayout):
|
||||
raise ValueError("MiniMax-H3 packed layout is missing at decode.")
|
||||
return layout
|
||||
|
||||
|
||||
class MiniMaxH3VideoDecodingStage(PipelineStage):
|
||||
"""Drop visual condition rows, unpatchify, and decode the target video."""
|
||||
|
||||
performance_component_metric = "vae_decode_time_s"
|
||||
|
||||
def __init__(self, vae: AutoencoderKLMiniMaxH3, transformer: Any) -> None:
|
||||
super().__init__()
|
||||
self.vae = vae
|
||||
self.transformer = transformer
|
||||
|
||||
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("layout", batch.extra.get(MINIMAX_H3_LAYOUT_KEY), V.not_none)
|
||||
result.add_check("latents", batch.latents, V.with_dims(2))
|
||||
result.add_check("raw_latent_shape", batch.raw_latent_shape, V.not_none)
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("output", batch.output, V.with_dims(5))
|
||||
return result
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
layout = _layout(batch)
|
||||
if batch.latents is None or batch.raw_latent_shape is None or len(batch.raw_latent_shape) != 5:
|
||||
raise ValueError("MiniMax-H3 video latents or raw geometry are missing at decode.")
|
||||
_, channels, num_frames, latent_height, latent_width = batch.raw_latent_shape
|
||||
latents = unpatchify_video_tokens(
|
||||
batch.latents[layout.num_condition_video_rows:],
|
||||
num_frames,
|
||||
latent_height,
|
||||
latent_width,
|
||||
channels,
|
||||
self.transformer.patch_size,
|
||||
)
|
||||
device = get_local_torch_device()
|
||||
self.vae.to(device)
|
||||
try:
|
||||
latents = self.vae.denormalize_latents(latents.to(device=device, dtype=torch.float32))
|
||||
if fastvideo_args.output_type == "latent":
|
||||
batch.output = latents.detach().float().cpu()
|
||||
return batch
|
||||
|
||||
# The published decode recipe uses FP16 autocast over FP32 weights.
|
||||
with torch.autocast(device_type=device.type, dtype=torch.float16, enabled=device.type == "cuda"):
|
||||
video = self.vae.decode(latents).sample
|
||||
batch.output = self.vae.denormalize_pixels(video.float()).clamp_(0, 1).cpu()
|
||||
return batch
|
||||
finally:
|
||||
if fastvideo_args.vae_cpu_offload:
|
||||
self.vae.to("cpu")
|
||||
|
||||
|
||||
class MiniMaxH3AudioDecodingStage(PipelineStage):
|
||||
"""Drop audio condition rows and decode the target stereo waveform."""
|
||||
|
||||
performance_component_metric = "audio_decode_time_s"
|
||||
|
||||
def __init__(self, audio_vae: MiniMaxH3AudioVAE) -> None:
|
||||
super().__init__()
|
||||
self.audio_vae = audio_vae
|
||||
|
||||
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("layout", batch.extra.get(MINIMAX_H3_LAYOUT_KEY), V.not_none)
|
||||
result.add_check("audio_latents", batch.audio_latents, V.with_dims(2))
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("audio", batch.extra.get("audio"), V.is_tensor)
|
||||
result.add_check("audio_sample_rate", batch.extra.get("audio_sample_rate"), V.positive_int)
|
||||
return result
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
layout = _layout(batch)
|
||||
if batch.audio_latents is None:
|
||||
raise ValueError("MiniMax-H3 audio latents are missing at decode.")
|
||||
latents = unpack_audio_tokens(
|
||||
batch.audio_latents[layout.num_condition_audio_rows:],
|
||||
layout.num_audio_latents,
|
||||
)
|
||||
device = get_local_torch_device()
|
||||
self.audio_vae.to(device)
|
||||
try:
|
||||
latents = self.audio_vae.denormalize_latents(latents.to(device=device, dtype=torch.float32))
|
||||
if fastvideo_args.output_type == "latent":
|
||||
batch.extra["audio"] = latents.detach().float().cpu()
|
||||
batch.extra["audio_sample_rate"] = self.audio_vae.sampling_rate
|
||||
self._clear_runtime(batch)
|
||||
return batch
|
||||
|
||||
decoded = self.audio_vae.decode(latents).sample.float()
|
||||
if decoded.ndim != 3 or decoded.shape[0] != 2 or decoded.shape[1] != 1:
|
||||
raise ValueError("MiniMax-H3 audio VAE must decode stereo channels as two mono batch items; "
|
||||
f"got {tuple(decoded.shape)}.")
|
||||
batch.extra["audio"] = decoded[:, 0].transpose(0, 1).contiguous().cpu()
|
||||
batch.extra["audio_sample_rate"] = self.audio_vae.sampling_rate
|
||||
self._clear_runtime(batch)
|
||||
return batch
|
||||
finally:
|
||||
if fastvideo_args.vae_cpu_offload:
|
||||
self.audio_vae.to("cpu")
|
||||
|
||||
@staticmethod
|
||||
def _clear_runtime(batch: ForwardBatch) -> None:
|
||||
batch.prompt_embeds = []
|
||||
batch.latents = None
|
||||
batch.audio_latents = None
|
||||
batch.references = None
|
||||
for key in tuple(batch.extra):
|
||||
if key.startswith("minimax_h3_"):
|
||||
batch.extra.pop(key)
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3AudioDecodingStage", "MiniMaxH3VideoDecodingStage"]
|
||||
@@ -0,0 +1,149 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""One-forward joint audio/video denoising for MiniMax H3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from fastvideo.distributed import get_local_torch_device
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.forward_context import set_forward_context
|
||||
from fastvideo.hooks.activation_trace import trace_step
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import (
|
||||
MINIMAX_H3_KEYFRAME_NOISE_AUG,
|
||||
MiniMaxH3PackedLayout,
|
||||
build_row_timesteps,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import MINIMAX_H3_LAYOUT_KEY
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.pipelines.stages.base import PipelineStage
|
||||
from fastvideo.pipelines.stages.validators import StageValidators as V
|
||||
from fastvideo.pipelines.stages.validators import VerificationResult
|
||||
|
||||
|
||||
class MiniMaxH3DenoisingStage(PipelineStage):
|
||||
"""Build both schedules and denoise both modalities in one transformer call."""
|
||||
|
||||
performance_component_metric = "dit_time_s"
|
||||
|
||||
def __init__(self, transformer: Any, scheduler: Any, audio_scheduler: Any) -> None:
|
||||
super().__init__()
|
||||
self.transformer = transformer
|
||||
self.scheduler = scheduler
|
||||
self.audio_scheduler = audio_scheduler
|
||||
|
||||
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("layout", batch.extra.get(MINIMAX_H3_LAYOUT_KEY), V.not_none)
|
||||
result.add_check("prompt_embeds", batch.prompt_embeds, V.list_of_tensors_dims(3))
|
||||
result.add_check("latents", batch.latents, V.with_dims(2))
|
||||
result.add_check("audio_latents", batch.audio_latents, V.with_dims(2))
|
||||
result.add_check("num_inference_steps", batch.num_inference_steps, V.positive_int)
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("latents", batch.latents, V.with_dims(2))
|
||||
result.add_check("audio_latents", batch.audio_latents, V.with_dims(2))
|
||||
result.add_check("timesteps", batch.timesteps, V.with_dims(1))
|
||||
result.add_check("step_index", batch.step_index, V.non_negative_int)
|
||||
return result
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
layout = batch.extra.get(MINIMAX_H3_LAYOUT_KEY)
|
||||
if not isinstance(layout, MiniMaxH3PackedLayout):
|
||||
raise ValueError("MiniMax-H3 packed layout is missing before denoising.")
|
||||
if not batch.prompt_embeds or batch.latents is None or batch.audio_latents is None:
|
||||
raise ValueError("MiniMax-H3 conditioning and packed latents must precede denoising.")
|
||||
|
||||
full_cpu_offload = (bool(getattr(fastvideo_args, "dit_cpu_offload", False))
|
||||
and not bool(getattr(fastvideo_args, "dit_layerwise_offload", False))
|
||||
and not bool(getattr(fastvideo_args, "use_fsdp_inference", False)))
|
||||
device = get_local_torch_device()
|
||||
if full_cpu_offload:
|
||||
self.transformer.to(device)
|
||||
batch.latents = batch.latents.to(device)
|
||||
batch.audio_latents = batch.audio_latents.to(device)
|
||||
|
||||
self.scheduler.set_timesteps(batch.num_inference_steps, device=device)
|
||||
self.audio_scheduler.set_timesteps(batch.num_inference_steps, device=device)
|
||||
video_timesteps = self.scheduler.timesteps
|
||||
audio_timesteps = self.audio_scheduler.timesteps
|
||||
if video_timesteps is None or audio_timesteps is None:
|
||||
raise ValueError("MiniMax-H3 schedulers did not produce timesteps.")
|
||||
if len(video_timesteps) != len(audio_timesteps):
|
||||
raise ValueError("MiniMax-H3 video and audio schedules must have the same number of intervals.")
|
||||
|
||||
row_timestep_plan = []
|
||||
for video_timestep, audio_timestep in zip(video_timesteps, audio_timesteps, strict=True):
|
||||
video_value = float(video_timestep.item())
|
||||
audio_value = float(audio_timestep.item())
|
||||
unique, inverse = build_row_timesteps(
|
||||
layout,
|
||||
video_timestep=video_value,
|
||||
audio_timestep=audio_value,
|
||||
condition_video_timestep=max(video_value, MINIMAX_H3_KEYFRAME_NOISE_AUG),
|
||||
condition_audio_timestep=1.0,
|
||||
)
|
||||
row_timestep_plan.append((unique.to(device), inverse.to(device)))
|
||||
batch.timesteps = video_timesteps
|
||||
|
||||
position_ids = layout.position_ids.to(device)
|
||||
token_tags = layout.token_tags.to(device)
|
||||
video_indices = layout.video_indices.to(device)
|
||||
audio_indices = layout.audio_indices.to(device)
|
||||
text_indices = layout.text_indices.to(device)
|
||||
prompt_embeds = batch.prompt_embeds[0].to(device)
|
||||
|
||||
try:
|
||||
for index, (video_timestep, audio_timestep) in enumerate(zip(video_timesteps, audio_timesteps,
|
||||
strict=True)):
|
||||
unique_timesteps, timestep_indices = row_timestep_plan[index]
|
||||
with trace_step(index), set_forward_context(
|
||||
current_timestep=index,
|
||||
attn_metadata=None,
|
||||
forward_batch=batch,
|
||||
):
|
||||
video_velocity, audio_velocity = self.transformer(
|
||||
hidden_states=batch.latents[None],
|
||||
audio_hidden_states=batch.audio_latents[None],
|
||||
encoder_hidden_states=prompt_embeds,
|
||||
timestep=unique_timesteps,
|
||||
timestep_indices=timestep_indices,
|
||||
token_tags=token_tags,
|
||||
position_ids=position_ids,
|
||||
video_indices=video_indices,
|
||||
audio_indices=audio_indices,
|
||||
text_indices=text_indices,
|
||||
)
|
||||
|
||||
video_start = layout.num_condition_video_rows
|
||||
audio_start = layout.num_condition_audio_rows
|
||||
batch.latents[video_start:] = self.scheduler.step(
|
||||
video_velocity[0, video_start:].float(),
|
||||
video_timestep,
|
||||
batch.latents[video_start:],
|
||||
return_dict=False,
|
||||
)[0]
|
||||
batch.audio_latents[audio_start:] = self.audio_scheduler.step(
|
||||
audio_velocity[0, audio_start:].float(),
|
||||
audio_timestep,
|
||||
batch.audio_latents[audio_start:],
|
||||
return_dict=False,
|
||||
)[0]
|
||||
batch.step_index = index
|
||||
batch.timestep = video_timestep
|
||||
finally:
|
||||
if bool(getattr(fastvideo_args, "dit_layerwise_offload", False)):
|
||||
manager = getattr(self.transformer, "_layerwise_offload_manager", None)
|
||||
if manager is not None and getattr(manager, "enabled", False):
|
||||
manager.release_all()
|
||||
if full_cpu_offload:
|
||||
self.transformer.to("cpu")
|
||||
return batch
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3DenoisingStage"]
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Unified request and target-geometry preparation for MiniMax H3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
import torch
|
||||
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.models.vision_utils import load_image
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import (
|
||||
MINIMAX_H3_CANVAS_MULTIPLE,
|
||||
MINIMAX_H3_FPS,
|
||||
MINIMAX_H3_MAX_DURATION,
|
||||
MINIMAX_H3_MIN_DURATION,
|
||||
align_num_frames,
|
||||
prepare_keyframe_image,
|
||||
resolve_canvas_size,
|
||||
video_latent_num_frames,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.reference import (
|
||||
MiniMaxH3PreparedReference,
|
||||
prepare_reference,
|
||||
validate_references,
|
||||
)
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.pipelines.stages.base import PipelineStage
|
||||
from fastvideo.pipelines.stages.validators import StageValidators as V
|
||||
from fastvideo.pipelines.stages.validators import VerificationResult
|
||||
|
||||
MINIMAX_H3_KEYFRAMES_KEY = "minimax_h3_keyframes"
|
||||
MINIMAX_H3_KEYFRAME_ANCHORS_KEY = "minimax_h3_keyframe_anchors"
|
||||
|
||||
|
||||
def _component_value(module: Any, name: str) -> Any:
|
||||
value = getattr(module, name, None)
|
||||
if value is not None:
|
||||
return value
|
||||
config = getattr(module, "config", None)
|
||||
for owner in (getattr(config, "arch_config", None), config):
|
||||
value = getattr(owner, name, None)
|
||||
if value is not None:
|
||||
return value
|
||||
raise ValueError(f"MiniMax-H3 component {type(module).__name__} does not expose `{name}`.")
|
||||
|
||||
|
||||
def _has_negative_prompt(value: str | list[str] | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return bool(value.strip())
|
||||
return any(bool(item.strip()) for item in value)
|
||||
|
||||
|
||||
def prepare_common_request(batch: ForwardBatch) -> None:
|
||||
"""Validate H3's one-request, no-CFG contract and initialize its RNG."""
|
||||
if not isinstance(batch.prompt, str):
|
||||
raise ValueError("MiniMax-H3 packs one request, so `prompt` must be a single string.")
|
||||
if batch.prompt_embeds:
|
||||
raise ValueError("MiniMax-H3 requires conditioner token tags and does not accept standalone prompt_embeds.")
|
||||
if batch.num_videos_per_prompt != 1:
|
||||
raise ValueError("MiniMax-H3 generates one packed video/audio request at a time.")
|
||||
if _has_negative_prompt(batch.negative_prompt):
|
||||
raise ValueError("MiniMax-H3 is guidance-distilled and does not accept a negative prompt.")
|
||||
if batch.guidance_scale != 1.0 or batch.batch_cfg or batch.do_classifier_free_guidance:
|
||||
raise ValueError("MiniMax-H3 does not support classifier-free guidance; guidance_scale must be 1.0.")
|
||||
if batch.num_inference_steps < 2:
|
||||
raise ValueError("MiniMax-H3 needs at least two sigma grid points, including terminal zero.")
|
||||
|
||||
if batch.generator is None:
|
||||
seed = 0 if batch.seed is None else int(batch.seed)
|
||||
batch.generator = torch.Generator("cpu").manual_seed(seed)
|
||||
elif isinstance(batch.generator, list) and len(batch.generator) != 1:
|
||||
raise ValueError("MiniMax-H3 accepts exactly one request generator.")
|
||||
|
||||
fps = MINIMAX_H3_FPS if batch.fps is None else batch.fps
|
||||
if not isinstance(fps, int) or fps != MINIMAX_H3_FPS:
|
||||
raise ValueError(f"MiniMax-H3 uses a fixed {MINIMAX_H3_FPS} fps, got {batch.fps!r}.")
|
||||
batch.fps = MINIMAX_H3_FPS
|
||||
|
||||
|
||||
def resolve_target_canvas(batch: ForwardBatch, vae: object, default_aspect: tuple[int, int]) -> tuple[int, int, int]:
|
||||
"""Resolve target geometry independently of the selected condition mode."""
|
||||
if (batch.height is None) != (batch.width is None):
|
||||
raise ValueError("MiniMax-H3 `height` and `width` must be passed together, or neither.")
|
||||
if batch.height is None:
|
||||
height, width = resolve_canvas_size(*default_aspect)
|
||||
else:
|
||||
if not isinstance(batch.height, int) or not isinstance(batch.width, int):
|
||||
raise TypeError("MiniMax-H3 `height` and `width` must be integers.")
|
||||
height, width = batch.height, batch.width
|
||||
if height <= 0 or width <= 0 or height % MINIMAX_H3_CANVAS_MULTIPLE or width % MINIMAX_H3_CANVAS_MULTIPLE:
|
||||
raise ValueError(f"MiniMax-H3 `height` and `width` must be positive multiples of "
|
||||
f"{MINIMAX_H3_CANVAS_MULTIPLE}, got {height}x{width}.")
|
||||
|
||||
ratio = int(_component_value(vae, "spatial_compression_ratio"))
|
||||
if height % ratio or width % ratio:
|
||||
raise ValueError(f"MiniMax-H3 canvas {height}x{width} is not divisible by VAE ratio {ratio}.")
|
||||
return height, width, ratio
|
||||
|
||||
|
||||
def resolve_target_num_frames(num_frames: object) -> int:
|
||||
"""Align target pixels to the released causal-VAE chunk geometry."""
|
||||
if not isinstance(num_frames, int):
|
||||
raise TypeError("MiniMax-H3 `num_frames` must be an integer.")
|
||||
aligned = align_num_frames(num_frames)
|
||||
duration = aligned / MINIMAX_H3_FPS
|
||||
if not MINIMAX_H3_MIN_DURATION <= duration <= MINIMAX_H3_MAX_DURATION:
|
||||
raise ValueError(f"MiniMax-H3 generates {MINIMAX_H3_MIN_DURATION:g}-{MINIMAX_H3_MAX_DURATION:g} seconds at "
|
||||
f"{MINIMAX_H3_FPS} fps; aligned num_frames={aligned}.")
|
||||
return aligned
|
||||
|
||||
|
||||
class MiniMaxH3InputPreparationStage(PipelineStage):
|
||||
"""Prepare FL2VA/T2VA or Ref2VA inputs without a parallel family state object."""
|
||||
|
||||
def __init__(self, vae: object, audio_vae: object | None = None, *, ref2va: bool = False) -> None:
|
||||
super().__init__()
|
||||
if ref2va and audio_vae is None:
|
||||
raise ValueError("MiniMax-H3 Ref2VA input preparation requires an audio VAE.")
|
||||
self.vae = vae
|
||||
self.audio_vae = audio_vae
|
||||
self.ref2va = ref2va
|
||||
|
||||
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("prompt", batch.prompt, lambda value: isinstance(value, str))
|
||||
result.add_check("num_frames", batch.num_frames, V.positive_int)
|
||||
result.add_check("num_inference_steps", batch.num_inference_steps, V.positive_int)
|
||||
result.add_check("num_videos_per_prompt", batch.num_videos_per_prompt, lambda value: value == 1)
|
||||
result.add_check("latents", batch.latents, V.none_or_tensor)
|
||||
result.add_check("audio_latents", batch.audio_latents, V.none_or_tensor)
|
||||
if self.ref2va:
|
||||
result.add_check("references", batch.references, V.list_not_empty)
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("generator", batch.generator, V.generator_or_list_generators)
|
||||
result.add_check("height", batch.height, V.positive_int)
|
||||
result.add_check("width", batch.width, V.positive_int)
|
||||
result.add_check("num_frames", batch.num_frames, V.positive_int)
|
||||
result.add_check("height_latents", batch.height_latents, V.positive_int)
|
||||
result.add_check("width_latents", batch.width_latents, V.positive_int)
|
||||
result.add_check(
|
||||
"raw_latent_shape",
|
||||
batch.raw_latent_shape,
|
||||
lambda value: isinstance(value, tuple) and len(value) == 5 and all(
|
||||
isinstance(item, int) and item > 0 for item in value),
|
||||
)
|
||||
result.add_check("keyframes", batch.extra.get(MINIMAX_H3_KEYFRAMES_KEY), V.is_list)
|
||||
result.add_check(
|
||||
"keyframe_anchors",
|
||||
batch.extra.get(MINIMAX_H3_KEYFRAME_ANCHORS_KEY),
|
||||
lambda value: isinstance(value, tuple),
|
||||
)
|
||||
if self.ref2va:
|
||||
result.add_check(
|
||||
"prepared_references",
|
||||
batch.references,
|
||||
lambda value: isinstance(value, list) and bool(value) and all(
|
||||
isinstance(reference, MiniMaxH3PreparedReference) for reference in value),
|
||||
)
|
||||
return result
|
||||
|
||||
def _write_target_geometry(self, batch: ForwardBatch, height: int, width: int, ratio: int, num_frames: int) -> None:
|
||||
latent_height = height // ratio
|
||||
latent_width = width // ratio
|
||||
num_latent_frames = video_latent_num_frames(num_frames)
|
||||
latent_channels = int(_component_value(self.vae, "latent_channels"))
|
||||
|
||||
batch.height = height
|
||||
batch.width = width
|
||||
batch.num_frames = num_frames
|
||||
batch.height_latents = latent_height
|
||||
batch.width_latents = latent_width
|
||||
batch.raw_latent_shape = (1, latent_channels, num_latent_frames, latent_height, latent_width)
|
||||
|
||||
def _prepare_fl2va(self, batch: ForwardBatch) -> None:
|
||||
if batch.references:
|
||||
raise ValueError("MiniMax-H3 references belong to the Ref2VA pipeline.")
|
||||
|
||||
if batch.image_path is not None:
|
||||
batch.pil_image = load_image(batch.image_path)
|
||||
for name, image in (("pil_image", batch.pil_image), ("last_image", batch.last_image)):
|
||||
if image is not None and not isinstance(image, Image.Image):
|
||||
raise TypeError(f"MiniMax-H3 `{name}` must be a PIL image, got {type(image).__name__}.")
|
||||
|
||||
raw_keyframes = [(anchor, ImageOps.exif_transpose(image).convert("RGB"))
|
||||
for anchor, image in (("first", batch.pil_image), ("last", batch.last_image))
|
||||
if image is not None]
|
||||
default_aspect = raw_keyframes[0][1].size if raw_keyframes else (16, 9)
|
||||
height, width, ratio = resolve_target_canvas(batch, self.vae, default_aspect)
|
||||
num_frames = resolve_target_num_frames(batch.num_frames)
|
||||
self._write_target_geometry(batch, height, width, ratio, num_frames)
|
||||
|
||||
batch.extra[MINIMAX_H3_KEYFRAME_ANCHORS_KEY] = tuple(anchor for anchor, _ in raw_keyframes)
|
||||
batch.extra[MINIMAX_H3_KEYFRAMES_KEY] = [
|
||||
prepare_keyframe_image(image, height, width, stretch=index == 0)
|
||||
for index, (_, image) in enumerate(raw_keyframes)
|
||||
]
|
||||
|
||||
def _prepare_ref2va(self, batch: ForwardBatch) -> None:
|
||||
if batch.image_path is not None or batch.pil_image is not None or batch.last_image is not None:
|
||||
raise ValueError("MiniMax-H3 Ref2VA accepts media through `references`, not FL2VA keyframe fields.")
|
||||
references = validate_references(list(batch.references or []))
|
||||
if self.audio_vae is None:
|
||||
raise RuntimeError("MiniMax-H3 Ref2VA input preparation has no audio VAE.")
|
||||
|
||||
height, width, ratio = resolve_target_canvas(batch, self.vae, (16, 9))
|
||||
num_frames = resolve_target_num_frames(batch.num_frames)
|
||||
target_sample_rate = int(_component_value(self.audio_vae, "sampling_rate"))
|
||||
batch.references = [prepare_reference(reference, num_frames, target_sample_rate) for reference in references]
|
||||
self._write_target_geometry(batch, height, width, ratio, num_frames)
|
||||
batch.extra[MINIMAX_H3_KEYFRAMES_KEY] = []
|
||||
batch.extra[MINIMAX_H3_KEYFRAME_ANCHORS_KEY] = ()
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
del fastvideo_args
|
||||
prepare_common_request(batch)
|
||||
if self.ref2va:
|
||||
self._prepare_ref2va(batch)
|
||||
else:
|
||||
self._prepare_fl2va(batch)
|
||||
return batch
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_KEYFRAME_ANCHORS_KEY",
|
||||
"MINIMAX_H3_KEYFRAMES_KEY",
|
||||
"MiniMaxH3InputPreparationStage",
|
||||
"prepare_common_request",
|
||||
"resolve_target_canvas",
|
||||
"resolve_target_num_frames",
|
||||
]
|
||||
@@ -0,0 +1,340 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Condition encoding, packed layout, and target noise for MiniMax H3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from diffusers.utils.torch_utils import randn_tensor
|
||||
|
||||
from fastvideo.distributed import get_local_torch_device
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import (
|
||||
MINIMAX_H3_AUDIO_CHANNELS,
|
||||
MINIMAX_H3_KEYFRAME_ENCODE_SEED,
|
||||
MINIMAX_H3_KEYFRAME_NOISE_AUG,
|
||||
MiniMaxH3PackedLayout,
|
||||
audio_latent_num_frames,
|
||||
build_packed_sequence,
|
||||
build_ref2va_packed_sequence,
|
||||
keyframe_condition_noise,
|
||||
patchify_video_latents,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.reference import (
|
||||
MiniMaxH3PreparedReference,
|
||||
trim_reference_num_frames,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_conditioning import MINIMAX_H3_TEXT_TOKEN_TAGS_KEY
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_input_preparation import (
|
||||
MINIMAX_H3_KEYFRAME_ANCHORS_KEY,
|
||||
MINIMAX_H3_KEYFRAMES_KEY,
|
||||
)
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.pipelines.stages.base import PipelineStage
|
||||
from fastvideo.pipelines.stages.validators import StageValidators as V
|
||||
from fastvideo.pipelines.stages.validators import VerificationResult
|
||||
|
||||
MINIMAX_H3_LAYOUT_KEY = "minimax_h3_layout"
|
||||
|
||||
|
||||
def _video_geometry(batch: ForwardBatch) -> tuple[int, int, int, int]:
|
||||
shape = batch.raw_latent_shape
|
||||
if shape is None or len(shape) != 5:
|
||||
raise ValueError("MiniMax-H3 input preparation must set a five-dimensional raw latent shape.")
|
||||
_, channels, num_frames, height, width = shape
|
||||
if min(channels, num_frames, height, width) <= 0:
|
||||
raise ValueError(f"MiniMax-H3 raw latent geometry must be positive, got {shape}.")
|
||||
return channels, num_frames, height, width
|
||||
|
||||
|
||||
def _sample_visual_posterior(posterior: Any) -> torch.Tensor:
|
||||
generator = torch.Generator("cpu").manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED)
|
||||
return posterior.sample(generator=generator)
|
||||
|
||||
|
||||
class MiniMaxH3LatentPreparationStage(PipelineStage):
|
||||
"""Encode fixed conditions, build the row layout, then draw target noise."""
|
||||
|
||||
performance_component_metric = "vae_encode_time_s"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transformer: Any,
|
||||
vae: Any,
|
||||
audio_vae: Any,
|
||||
scheduler: Any,
|
||||
*,
|
||||
ref2va: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.transformer = transformer
|
||||
self.vae = vae
|
||||
self.audio_vae = audio_vae
|
||||
self.scheduler = scheduler
|
||||
self.ref2va = ref2va
|
||||
|
||||
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("prompt_embeds", batch.prompt_embeds, V.list_of_tensors_dims(3))
|
||||
result.add_check("text_token_tags", batch.extra.get(MINIMAX_H3_TEXT_TOKEN_TAGS_KEY), V.with_dims(1))
|
||||
result.add_check("raw_latent_shape", batch.raw_latent_shape, V.not_none)
|
||||
result.add_check("latents", batch.latents, V.none_or_tensor)
|
||||
result.add_check("audio_latents", batch.audio_latents, V.none_or_tensor)
|
||||
if self.ref2va:
|
||||
result.add_check("references", batch.references, V.list_not_empty)
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("layout", batch.extra.get(MINIMAX_H3_LAYOUT_KEY), V.not_none)
|
||||
result.add_check("latents", batch.latents, V.with_dims(2))
|
||||
result.add_check("audio_latents", batch.audio_latents, V.with_dims(2))
|
||||
return result
|
||||
|
||||
def _encode_visual_rows(
|
||||
self,
|
||||
references: list[MiniMaxH3PreparedReference],
|
||||
device: torch.device,
|
||||
) -> list[torch.Tensor]:
|
||||
patch_size = self.transformer.patch_size
|
||||
mean = self.vae.latents_mean.detach().float().cpu()
|
||||
std = self.vae.latents_std.detach().float().cpu()
|
||||
rows: list[torch.Tensor] = []
|
||||
for reference in references:
|
||||
if reference.media_type == "audio":
|
||||
continue
|
||||
if reference.media_type == "image":
|
||||
if reference.image is None:
|
||||
raise ValueError("MiniMax-H3 reference image pixels are missing.")
|
||||
pixels = torch.from_numpy(np.asarray(reference.image).copy()).permute(2, 0, 1)[None, :, None]
|
||||
encode = self.vae.encode_keyframe
|
||||
else:
|
||||
if reference.frames is None:
|
||||
raise ValueError("MiniMax-H3 reference video frames are missing.")
|
||||
frames = reference.frames[:trim_reference_num_frames(reference.frames.shape[0])]
|
||||
pixels = torch.from_numpy(frames.copy()).permute(3, 0, 1, 2)[None]
|
||||
encode = self.vae.encode
|
||||
|
||||
pixels = pixels.to(device=device, dtype=torch.float32).div_(255.0)
|
||||
posterior = encode(self.vae.normalize_pixels(pixels)).latent_dist
|
||||
latents = _sample_visual_posterior(posterior).to(torch.float16).float().cpu()
|
||||
reference.num_latent_frames = int(latents.shape[2])
|
||||
reference.latent_height = int(latents.shape[3])
|
||||
reference.latent_width = int(latents.shape[4])
|
||||
rows.append(patchify_video_latents((latents - mean) / std, patch_size))
|
||||
return rows
|
||||
|
||||
def _encode_audio_rows(
|
||||
self,
|
||||
references: list[MiniMaxH3PreparedReference],
|
||||
device: torch.device,
|
||||
) -> list[torch.Tensor]:
|
||||
mean = self.audio_vae.latents_mean.detach().float().cpu().transpose(1, 2)
|
||||
std = self.audio_vae.latents_std.detach().float().cpu().transpose(1, 2)
|
||||
rows: list[torch.Tensor] = []
|
||||
for reference in references:
|
||||
if not reference.has_audio:
|
||||
continue
|
||||
if reference.waveform is None:
|
||||
raise ValueError("MiniMax-H3 reference waveform is missing.")
|
||||
posterior = self.audio_vae.encode(reference.waveform.to(device)[:, None]).latent_dist
|
||||
latents = posterior.mode().float().cpu().transpose(1, 2)
|
||||
reference.num_audio_latents = int(latents.shape[1])
|
||||
rows.append(((latents - mean) / std).reshape(-1, self.audio_vae.latent_channels))
|
||||
return rows
|
||||
|
||||
def _encode_fl2va_conditions(
|
||||
self,
|
||||
batch: ForwardBatch,
|
||||
fastvideo_args: FastVideoArgs,
|
||||
device: torch.device,
|
||||
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
|
||||
keyframes = batch.extra.get(MINIMAX_H3_KEYFRAMES_KEY, [])
|
||||
if not isinstance(keyframes, list):
|
||||
raise TypeError("MiniMax-H3 keyframes must be a list.")
|
||||
if not keyframes:
|
||||
return None, None
|
||||
|
||||
vae_device = get_local_torch_device()
|
||||
self.vae.to(vae_device)
|
||||
try:
|
||||
mean = self.vae.latents_mean.detach().float().cpu()
|
||||
std = self.vae.latents_std.detach().float().cpu()
|
||||
clean_rows: list[torch.Tensor] = []
|
||||
for image in keyframes:
|
||||
pixels = torch.from_numpy(np.asarray(image).copy()).permute(2, 0, 1)[None, :, None]
|
||||
pixels = pixels.to(device=vae_device, dtype=torch.float32).div_(255.0)
|
||||
posterior = self.vae.encode_keyframe(self.vae.normalize_pixels(pixels)).latent_dist
|
||||
latents = _sample_visual_posterior(posterior).to(torch.float16).float().cpu()
|
||||
clean_rows.append(patchify_video_latents((latents - mean) / std, self.transformer.patch_size))
|
||||
finally:
|
||||
if fastvideo_args.vae_cpu_offload:
|
||||
self.vae.to("cpu")
|
||||
|
||||
_, _, latent_height, latent_width = _video_geometry(batch)
|
||||
shapes = ((1, latent_height, latent_width), ) * len(keyframes)
|
||||
noise = keyframe_condition_noise(
|
||||
shapes,
|
||||
self.transformer.patch_size,
|
||||
self.vae.latent_channels,
|
||||
generator=batch.generator,
|
||||
device=device,
|
||||
)
|
||||
return (
|
||||
self.scheduler.scale_noise(
|
||||
torch.cat(clean_rows).to(device),
|
||||
MINIMAX_H3_KEYFRAME_NOISE_AUG,
|
||||
noise,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _encode_ref2va_conditions(
|
||||
self,
|
||||
batch: ForwardBatch,
|
||||
fastvideo_args: FastVideoArgs,
|
||||
device: torch.device,
|
||||
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
||||
references = list(batch.references or [])
|
||||
if not references or not all(isinstance(item, MiniMaxH3PreparedReference) for item in references):
|
||||
raise TypeError("MiniMax-H3 Ref2VA latent preparation requires prepared references.")
|
||||
|
||||
vae_device = get_local_torch_device()
|
||||
self.vae.to(vae_device)
|
||||
try:
|
||||
video_rows = self._encode_visual_rows(references, vae_device)
|
||||
finally:
|
||||
if fastvideo_args.vae_cpu_offload:
|
||||
self.vae.to("cpu")
|
||||
|
||||
audio_rows: list[torch.Tensor] = []
|
||||
if any(reference.has_audio for reference in references):
|
||||
audio_device = get_local_torch_device()
|
||||
self.audio_vae.to(audio_device)
|
||||
try:
|
||||
audio_rows = self._encode_audio_rows(references, audio_device)
|
||||
finally:
|
||||
if fastvideo_args.vae_cpu_offload:
|
||||
self.audio_vae.to("cpu")
|
||||
|
||||
if not video_rows:
|
||||
raise ValueError("MiniMax-H3 Ref2VA requires at least one visual reference.")
|
||||
shapes = tuple((reference.num_latent_frames, reference.latent_height, reference.latent_width)
|
||||
for reference in references if reference.media_type != "audio")
|
||||
noise = keyframe_condition_noise(
|
||||
shapes,
|
||||
self.transformer.patch_size,
|
||||
self.vae.latent_channels,
|
||||
generator=batch.generator,
|
||||
device=device,
|
||||
)
|
||||
video_conditions = self.scheduler.scale_noise(
|
||||
torch.cat(video_rows).to(device),
|
||||
MINIMAX_H3_KEYFRAME_NOISE_AUG,
|
||||
noise,
|
||||
)
|
||||
audio_conditions = torch.cat(audio_rows).to(device) if audio_rows else None
|
||||
|
||||
for reference in references:
|
||||
reference.image = None
|
||||
reference.frames = None
|
||||
reference.waveform = None
|
||||
return video_conditions, audio_conditions
|
||||
|
||||
def _build_layout(self, batch: ForwardBatch) -> MiniMaxH3PackedLayout:
|
||||
text_token_tags = batch.extra.get(MINIMAX_H3_TEXT_TOKEN_TAGS_KEY)
|
||||
if not isinstance(text_token_tags, torch.Tensor):
|
||||
raise ValueError("MiniMax-H3 conditioning must produce text token tags.")
|
||||
_, num_frames, height, width = _video_geometry(batch)
|
||||
if not isinstance(batch.num_frames, int):
|
||||
raise TypeError("MiniMax-H3 num_frames must be an integer.")
|
||||
num_audio_latents = audio_latent_num_frames(batch.num_frames)
|
||||
if self.ref2va:
|
||||
references = list(batch.references or [])
|
||||
return build_ref2va_packed_sequence(
|
||||
text_token_tags,
|
||||
references,
|
||||
num_frames,
|
||||
height,
|
||||
width,
|
||||
num_audio_latents,
|
||||
self.transformer.patch_size,
|
||||
)
|
||||
anchors = batch.extra.get(MINIMAX_H3_KEYFRAME_ANCHORS_KEY, ())
|
||||
if not isinstance(anchors, tuple):
|
||||
raise TypeError("MiniMax-H3 keyframe anchors must be a tuple.")
|
||||
return build_packed_sequence(
|
||||
text_token_tags,
|
||||
num_frames,
|
||||
height,
|
||||
width,
|
||||
num_audio_latents,
|
||||
self.transformer.patch_size,
|
||||
anchors,
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
video_noise = batch.latents
|
||||
audio_noise = batch.audio_latents
|
||||
device = get_local_torch_device()
|
||||
if self.ref2va:
|
||||
condition_video, condition_audio = self._encode_ref2va_conditions(batch, fastvideo_args, device)
|
||||
else:
|
||||
condition_video, condition_audio = self._encode_fl2va_conditions(batch, fastvideo_args, device)
|
||||
|
||||
layout = self._build_layout(batch)
|
||||
video_channels, num_frames, height, width = _video_geometry(batch)
|
||||
expected_video_shape = (1, video_channels, num_frames, height, width)
|
||||
if video_noise is None:
|
||||
video_noise = randn_tensor(
|
||||
expected_video_shape,
|
||||
generator=batch.generator,
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
elif tuple(video_noise.shape) != expected_video_shape:
|
||||
raise ValueError(f"MiniMax-H3 injected video latents must have shape {expected_video_shape}, "
|
||||
f"got {tuple(video_noise.shape)}.")
|
||||
video_rows = patchify_video_latents(video_noise.to(device=device, dtype=torch.float32),
|
||||
self.transformer.patch_size)
|
||||
|
||||
num_audio_latents = layout.num_audio_latents
|
||||
expected_audio_shape = (MINIMAX_H3_AUDIO_CHANNELS, self.audio_vae.latent_channels, num_audio_latents)
|
||||
if audio_noise is None:
|
||||
audio_rows = randn_tensor(
|
||||
(num_audio_latents * MINIMAX_H3_AUDIO_CHANNELS, self.audio_vae.latent_channels),
|
||||
generator=batch.generator,
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
else:
|
||||
if tuple(audio_noise.shape) != expected_audio_shape:
|
||||
raise ValueError(f"MiniMax-H3 injected audio latents must have shape {expected_audio_shape}, "
|
||||
f"got {tuple(audio_noise.shape)}.")
|
||||
audio_rows = audio_noise.to(device=device,
|
||||
dtype=torch.float32).permute(0, 2,
|
||||
1).reshape(-1, self.audio_vae.latent_channels)
|
||||
|
||||
if condition_video is not None:
|
||||
video_rows = torch.cat((condition_video.to(device), video_rows))
|
||||
if condition_audio is not None:
|
||||
audio_rows = torch.cat((condition_audio.to(device), audio_rows))
|
||||
if video_rows.shape[0] != layout.video_indices.numel():
|
||||
raise ValueError("MiniMax-H3 packed video row count does not match its layout.")
|
||||
if audio_rows.shape[0] != layout.audio_indices.numel():
|
||||
raise ValueError("MiniMax-H3 packed audio row count does not match its layout.")
|
||||
|
||||
batch.latents = video_rows
|
||||
batch.audio_latents = audio_rows
|
||||
batch.extra[MINIMAX_H3_LAYOUT_KEY] = layout
|
||||
batch.extra.pop(MINIMAX_H3_TEXT_TOKEN_TAGS_KEY, None)
|
||||
batch.extra.pop(MINIMAX_H3_KEYFRAMES_KEY, None)
|
||||
batch.extra.pop(MINIMAX_H3_KEYFRAME_ANCHORS_KEY, None)
|
||||
batch.references = None
|
||||
return batch
|
||||
|
||||
|
||||
__all__ = ["MINIMAX_H3_LAYOUT_KEY", "MiniMaxH3LatentPreparationStage"]
|
||||
@@ -78,6 +78,8 @@ class ForwardBatch:
|
||||
image_path: str | None = None
|
||||
image_embeds: list[torch.Tensor] = field(default_factory=list)
|
||||
pil_image: torch.Tensor | PIL.Image.Image | None = None
|
||||
last_image: torch.Tensor | PIL.Image.Image | None = None
|
||||
references: list[Any] | None = None
|
||||
preprocessed_image: torch.Tensor | None = None
|
||||
# Text inputs
|
||||
prompt: str | list[str] | None = None
|
||||
@@ -125,6 +127,7 @@ class ForwardBatch:
|
||||
|
||||
# Latent tensors
|
||||
latents: torch.Tensor | None = None
|
||||
audio_latents: torch.Tensor | None = None
|
||||
lq_latents: torch.Tensor | None = None
|
||||
raw_latent_shape: tuple[int, ...] | None = None
|
||||
noise_pred: torch.Tensor | None = None
|
||||
|
||||
@@ -40,6 +40,7 @@ from fastvideo.configs.pipelines.flux_2 import (
|
||||
)
|
||||
from fastvideo.configs.pipelines.matrixgame2 import MatrixGame2I2V480PConfig
|
||||
from fastvideo.configs.pipelines.matrixgame3 import MatrixGame3I2V720PConfig
|
||||
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
|
||||
from fastvideo.configs.pipelines.turbodiffusion import (
|
||||
TurboDiffusionI2V_A14B_Config,
|
||||
TurboDiffusionT2V_14B_Config,
|
||||
@@ -1102,6 +1103,24 @@ def _register_configs() -> None:
|
||||
default_preset="sf_wan_2_2_i2v_a14b",
|
||||
)
|
||||
|
||||
# MiniMax H3
|
||||
register_configs(
|
||||
sampling_param_cls=None,
|
||||
pipeline_config_cls=MiniMaxH3PipelineConfig,
|
||||
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
|
||||
hf_model_paths=["MiniMaxAI/MiniMax-H3"],
|
||||
model_detectors=[
|
||||
lambda path: any(token in path.lower() for token in (
|
||||
"minimax-h3",
|
||||
"minimax_h3",
|
||||
"minimaxh3modularpipeline",
|
||||
"minimaxh3ref2vamodularpipeline",
|
||||
)),
|
||||
],
|
||||
model_family="minimax_h3",
|
||||
default_preset="minimax_h3_t2va",
|
||||
)
|
||||
|
||||
# SD3.5
|
||||
register_configs(
|
||||
sampling_param_cls=None,
|
||||
@@ -1279,6 +1298,8 @@ def _register_presets() -> None:
|
||||
ALL_PRESETS as MATRIXGAME2_PRESETS, )
|
||||
from fastvideo.pipelines.basic.matrixgame3.presets import (
|
||||
ALL_PRESETS as MATRIXGAME3_PRESETS, )
|
||||
from fastvideo.pipelines.basic.minimax_h3.presets import (
|
||||
ALL_PRESETS as MINIMAX_H3_PRESETS, )
|
||||
from fastvideo.pipelines.basic.sd35.presets import (
|
||||
ALL_PRESETS as SD35_PRESETS, )
|
||||
from fastvideo.pipelines.basic.stable_audio.presets import (
|
||||
@@ -1309,6 +1330,7 @@ def _register_presets() -> None:
|
||||
LTX2_PRESETS,
|
||||
MATRIXGAME2_PRESETS,
|
||||
MATRIXGAME3_PRESETS,
|
||||
MINIMAX_H3_PRESETS,
|
||||
SD35_PRESETS,
|
||||
STABLE_AUDIO_PRESETS,
|
||||
TURBODIFFUSION_PRESETS,
|
||||
|
||||
@@ -164,6 +164,10 @@ def test_load_run_config_supports_yaml_roundtrip(tmp_path) -> None:
|
||||
"image_path": None,
|
||||
"video_path": None,
|
||||
"pil_image": None,
|
||||
"last_image": None,
|
||||
"references": None,
|
||||
"latents": None,
|
||||
"audio_latents": None,
|
||||
"pose": None,
|
||||
"mouse_cond": None,
|
||||
"keyboard_cond": None,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from fastvideo.logger import init_logger
|
||||
from fastvideo.tests.ssim.inference_similarity_utils import (
|
||||
resolve_inference_device_reference_folder,
|
||||
run_text_to_video_similarity_test,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
REQUIRED_GPUS = 4
|
||||
|
||||
device_reference_folder = resolve_inference_device_reference_folder(logger)
|
||||
|
||||
MINIMAX_H3_PARAMS = {
|
||||
"num_gpus": 4,
|
||||
"model_path": "MiniMaxAI/MiniMax-H3",
|
||||
"height": 768,
|
||||
"width": 1344,
|
||||
"num_frames": 124,
|
||||
"num_inference_steps": 4,
|
||||
"guidance_scale": 1.0,
|
||||
"seed": 0,
|
||||
"sp_size": 4,
|
||||
"tp_size": 1,
|
||||
"fps": 24,
|
||||
"neg_prompt": "",
|
||||
}
|
||||
MINIMAX_H3_FULL_QUALITY_PARAMS = {
|
||||
**MINIMAX_H3_PARAMS,
|
||||
"num_inference_steps": 50,
|
||||
}
|
||||
|
||||
MINIMAX_H3_MODEL_TO_PARAMS = {
|
||||
"MiniMax-H3": MINIMAX_H3_PARAMS,
|
||||
}
|
||||
FULL_QUALITY_MINIMAX_H3_MODEL_TO_PARAMS = {
|
||||
"MiniMax-H3": MINIMAX_H3_FULL_QUALITY_PARAMS,
|
||||
}
|
||||
|
||||
MINIMAX_H3_TEST_PROMPTS = [
|
||||
"A blue-white FastVideo logo forms from glowing particles in a black futuristic digital city",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt", MINIMAX_H3_TEST_PROMPTS)
|
||||
@pytest.mark.parametrize("attention_backend_name", ["FLASH_ATTN"])
|
||||
@pytest.mark.parametrize("model_id", list(MINIMAX_H3_MODEL_TO_PARAMS))
|
||||
def test_minimax_h3_inference_similarity(
|
||||
prompt: str,
|
||||
attention_backend_name: str,
|
||||
model_id: str,
|
||||
) -> None:
|
||||
run_text_to_video_similarity_test(
|
||||
logger=logger,
|
||||
script_dir=os.path.dirname(os.path.abspath(__file__)),
|
||||
device_reference_folder=device_reference_folder,
|
||||
prompt=prompt,
|
||||
attention_backend_name=attention_backend_name,
|
||||
model_id=model_id,
|
||||
default_params_map=MINIMAX_H3_MODEL_TO_PARAMS,
|
||||
full_quality_params_map=FULL_QUALITY_MINIMAX_H3_MODEL_TO_PARAMS,
|
||||
min_acceptable_ssim=0.95,
|
||||
)
|
||||
+41
-24
@@ -612,7 +612,7 @@ def maybe_download_lora(model_name_or_path: str, local_dir: str | None = None, d
|
||||
|
||||
def verify_model_config_and_directory(model_path: str) -> dict[str, Any]:
|
||||
"""
|
||||
Verify that the model directory contains a valid diffusers configuration.
|
||||
Verify that the model directory contains a valid Diffusers configuration.
|
||||
|
||||
Args:
|
||||
model_path: Path to the model directory
|
||||
@@ -621,14 +621,19 @@ def verify_model_config_and_directory(model_path: str) -> dict[str, Any]:
|
||||
The loaded model configuration as a dictionary
|
||||
"""
|
||||
|
||||
# Check for model_index.json which is required for diffusers models
|
||||
config_path = os.path.join(model_path, "model_index.json")
|
||||
if not os.path.exists(config_path):
|
||||
raise ValueError(f"Model directory {model_path} does not contain model_index.json. "
|
||||
"Only Hugging Face diffusers format is supported.")
|
||||
# Some Diffusers checkpoints publish a modular manifest instead of model_index.json.
|
||||
config_filename = next(
|
||||
(name for name in ("model_index.json", "modular_model_index.json")
|
||||
if os.path.isfile(os.path.join(model_path, name))),
|
||||
None,
|
||||
)
|
||||
if config_filename is None:
|
||||
raise ValueError(f"Model directory {model_path} does not contain model_index.json or "
|
||||
"modular_model_index.json. Only Hugging Face Diffusers format is supported.")
|
||||
config_path = os.path.join(model_path, config_filename)
|
||||
|
||||
# Load the config first so directory checks below can be conditional
|
||||
# on what model_index.json actually declares.
|
||||
# Load the config first so directory checks below can be conditional on
|
||||
# what the manifest actually declares.
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
|
||||
@@ -638,8 +643,10 @@ def verify_model_config_and_directory(model_path: str) -> dict[str, Any]:
|
||||
if not os.path.exists(transformer_dir):
|
||||
raise ValueError(f"Model directory {model_path} does not contain a transformer/ directory.")
|
||||
|
||||
# Diffusers convention: model_index.json entries are [library, class]
|
||||
# pairs for on-disk components. Non-list entries are scalar metadata
|
||||
# Diffusers convention: component entries start with [library, class].
|
||||
# Modular manifests may append loading metadata, which FastVideo does not
|
||||
# need because published component subfolders match their manifest keys.
|
||||
# Non-list entries are scalar metadata
|
||||
# (e.g. boundary_ratio); a None first element marks a disabled
|
||||
# component (matches composed_pipeline_base.py). Pipelines that
|
||||
# lazy-load shared components from upstream HF repos simply omit the
|
||||
@@ -656,11 +663,11 @@ def verify_model_config_and_directory(model_path: str) -> dict[str, Any]:
|
||||
subdir = os.path.join(model_path, key)
|
||||
if not os.path.exists(subdir):
|
||||
raise ValueError(f"Model directory {model_path} declares `{key}` in "
|
||||
f"model_index.json but is missing the {key}/ subfolder.")
|
||||
f"{config_filename} but is missing the {key}/ subfolder.")
|
||||
|
||||
# Verify diffusers version exists
|
||||
if "_diffusers_version" not in config:
|
||||
raise ValueError("model_index.json does not contain _diffusers_version")
|
||||
raise ValueError(f"{config_filename} does not contain _diffusers_version")
|
||||
|
||||
logger.info("Diffusers version: %s", config["_diffusers_version"])
|
||||
return cast(dict[str, Any], config)
|
||||
@@ -668,13 +675,13 @@ def verify_model_config_and_directory(model_path: str) -> dict[str, Any]:
|
||||
|
||||
def maybe_download_model_index(model_name_or_path: str) -> dict[str, Any]:
|
||||
"""
|
||||
Download and extract just the model_index.json for a Hugging Face model.
|
||||
Download and extract a Diffusers model manifest for a Hugging Face model.
|
||||
|
||||
Args:
|
||||
model_name_or_path: Path or HF Hub model ID
|
||||
|
||||
Returns:
|
||||
The parsed model_index.json as a dictionary
|
||||
The parsed model_index.json or modular_model_index.json dictionary
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
@@ -684,33 +691,43 @@ def maybe_download_model_index(model_name_or_path: str) -> dict[str, Any]:
|
||||
if os.path.exists(model_name_or_path):
|
||||
return verify_model_config_and_directory(model_name_or_path)
|
||||
|
||||
# For remote models, download just the model_index.json
|
||||
# For remote models, download only the small manifest.
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Download just the model_index.json file
|
||||
model_index_path = hf_hub_download(repo_id=model_name_or_path,
|
||||
filename="model_index.json",
|
||||
local_dir=tmp_dir)
|
||||
from huggingface_hub.utils import EntryNotFoundError
|
||||
|
||||
# Load the model_index.json
|
||||
config_filename = "model_index.json"
|
||||
try:
|
||||
model_index_path = hf_hub_download(repo_id=model_name_or_path,
|
||||
filename=config_filename,
|
||||
local_dir=tmp_dir)
|
||||
except EntryNotFoundError:
|
||||
config_filename = "modular_model_index.json"
|
||||
model_index_path = hf_hub_download(repo_id=model_name_or_path,
|
||||
filename=config_filename,
|
||||
local_dir=tmp_dir)
|
||||
|
||||
# Load the selected manifest.
|
||||
with open(model_index_path) as f:
|
||||
config: dict[str, Any] = json.load(f)
|
||||
|
||||
# Verify it has the required fields
|
||||
if "_class_name" not in config:
|
||||
raise ValueError(f"model_index.json for {model_name_or_path} does not contain _class_name field")
|
||||
raise ValueError(f"{config_filename} for {model_name_or_path} does not contain _class_name field")
|
||||
|
||||
if "_diffusers_version" not in config:
|
||||
raise ValueError(f"model_index.json for {model_name_or_path} does not contain _diffusers_version field")
|
||||
raise ValueError(
|
||||
f"{config_filename} for {model_name_or_path} does not contain _diffusers_version field")
|
||||
|
||||
# Add the pipeline name for downstream use
|
||||
config["pipeline_name"] = config["_class_name"]
|
||||
|
||||
logger.info("Downloaded model_index.json for %s, pipeline: %s", model_name_or_path, config["_class_name"])
|
||||
logger.info("Downloaded %s for %s, pipeline: %s", config_filename, model_name_or_path,
|
||||
config["_class_name"])
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to download or parse model_index.json for {model_name_or_path}: {e}") from e
|
||||
raise ValueError(f"Failed to download or parse a Diffusers manifest for {model_name_or_path}: {e}") from e
|
||||
|
||||
|
||||
_HF_TOKEN_ENV_VARS = ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_API_KEY")
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Production-loader parity for MiniMax-H3's Qwen3-VL conditioner.
|
||||
|
||||
The test compares the exact Transformers base model used by the official
|
||||
pipeline with FastVideo's production ``TextEncoderLoader`` path. It covers
|
||||
the three numerical branches the H3 pipelines exercise: text-only tokens,
|
||||
image features, and video features.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torch.testing import assert_close
|
||||
|
||||
from fastvideo.configs.models.encoders.minimax_h3_qwen3_vl import MiniMaxH3Qwen3VLConfig
|
||||
from fastvideo.distributed import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
|
||||
from fastvideo.models.loader.component_loader import TextEncoderLoader
|
||||
|
||||
|
||||
PARITY_SCOPE = "production_loader"
|
||||
MINIMAX_H3_TEXT_ENCODER_LAYER = 50
|
||||
|
||||
|
||||
def _require_assets() -> tuple[torch.device, Path]:
|
||||
if os.environ.get("MINIMAX_H3_RUN_ENCODER_PARITY") != "1":
|
||||
pytest.skip("set MINIMAX_H3_RUN_ENCODER_PARITY=1 on an allocated GPU node")
|
||||
if not torch.cuda.is_available() or not torch.cuda.is_bf16_supported():
|
||||
pytest.fail("MiniMax-H3 Qwen3-VL parity requires a bf16-capable CUDA GPU", pytrace=False)
|
||||
|
||||
model_root = os.environ.get("MINIMAX_H3_MODEL_ROOT")
|
||||
if not model_root:
|
||||
pytest.fail("set MINIMAX_H3_MODEL_ROOT to the downloaded MiniMax-H3 checkpoint", pytrace=False)
|
||||
root = Path(model_root)
|
||||
required = (root / "text_encoder", root / "tokenizer", root / "processor")
|
||||
missing = [str(path) for path in required if not path.is_dir()]
|
||||
if missing:
|
||||
pytest.fail(f"MiniMax-H3 component directories are missing: {missing}", pytrace=False)
|
||||
return torch.device("cuda"), root
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _distributed_runtime():
|
||||
if os.environ.get("MINIMAX_H3_RUN_ENCODER_PARITY") != "1":
|
||||
yield
|
||||
return
|
||||
|
||||
os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
|
||||
os.environ.setdefault("MASTER_PORT", "29623")
|
||||
os.environ.setdefault("RANK", "0")
|
||||
os.environ.setdefault("WORLD_SIZE", "1")
|
||||
os.environ.setdefault("LOCAL_RANK", "0")
|
||||
maybe_init_distributed_environment_and_model_parallel(1, 1)
|
||||
yield
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
|
||||
def _reclaim_vram() -> None:
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def _token_id(tokenizer: Any, token: str) -> int:
|
||||
token_id = tokenizer.convert_tokens_to_ids(token)
|
||||
if token_id is None or token_id == tokenizer.unk_token_id:
|
||||
raise ValueError(f"MiniMax-H3 tokenizer does not define {token!r}")
|
||||
return int(token_id)
|
||||
|
||||
|
||||
def _mm_token_type_ids(processor: Any, token_ids: list[int]) -> torch.Tensor:
|
||||
create_ids = getattr(processor, "create_mm_token_type_ids", None)
|
||||
if callable(create_ids):
|
||||
return torch.tensor(create_ids([token_ids]), dtype=torch.long)
|
||||
|
||||
image_token_id = int(getattr(processor, "image_token_id", -1))
|
||||
video_token_id = int(getattr(processor, "video_token_id", -1))
|
||||
return torch.tensor(
|
||||
[[1 if token == image_token_id else 2 if token == video_token_id else 0 for token in token_ids]],
|
||||
dtype=torch.long,
|
||||
)
|
||||
|
||||
|
||||
def _make_cases(root: Path) -> dict[str, dict[str, torch.Tensor]]:
|
||||
from transformers import AutoProcessor, AutoTokenizer
|
||||
|
||||
processor = AutoProcessor.from_pretrained(root / "processor", local_files_only=True)
|
||||
tokenizer = AutoTokenizer.from_pretrained(root / "tokenizer", local_files_only=True)
|
||||
prompt_ids = tokenizer("A red fox crosses fresh snow at sunrise.", add_special_tokens=False)["input_ids"]
|
||||
|
||||
text_ids = list(prompt_ids)
|
||||
cases: dict[str, dict[str, torch.Tensor]] = {
|
||||
"text": {
|
||||
"input_ids": torch.tensor([text_ids], dtype=torch.long),
|
||||
"attention_mask": torch.ones(1, len(text_ids), dtype=torch.long),
|
||||
"mm_token_type_ids": _mm_token_type_ids(processor, text_ids),
|
||||
}
|
||||
}
|
||||
|
||||
height, width = 64, 96
|
||||
rows = np.arange(height, dtype=np.uint16)[:, None, None]
|
||||
cols = np.arange(width, dtype=np.uint16)[None, :, None]
|
||||
channels = np.arange(3, dtype=np.uint16)[None, None, :]
|
||||
pixels = ((rows * 7 + cols * 11 + channels * 53) % 256).astype(np.uint8)
|
||||
image = Image.fromarray(pixels)
|
||||
image_inputs = processor.image_processor(images=[image], return_tensors="pt")
|
||||
image_grid_thw = image_inputs["image_grid_thw"]
|
||||
merge_area = int(processor.image_processor.merge_size)**2
|
||||
image_token_count = int(image_grid_thw[0].prod().item()) // merge_area
|
||||
image_ids = (
|
||||
tokenizer("<Picture 1>: ", add_special_tokens=False)["input_ids"]
|
||||
+ [_token_id(tokenizer, "<|vision_start|>")]
|
||||
+ [_token_id(tokenizer, "<|image_pad|>")] * image_token_count
|
||||
+ [_token_id(tokenizer, "<|vision_end|>")]
|
||||
+ prompt_ids
|
||||
)
|
||||
cases["image"] = {
|
||||
"input_ids": torch.tensor([image_ids], dtype=torch.long),
|
||||
"attention_mask": torch.ones(1, len(image_ids), dtype=torch.long),
|
||||
"mm_token_type_ids": _mm_token_type_ids(processor, image_ids),
|
||||
"pixel_values": image_inputs["pixel_values"],
|
||||
"image_grid_thw": image_grid_thw,
|
||||
}
|
||||
|
||||
frames = np.stack([np.roll(pixels, shift=index * 3, axis=1) for index in range(4)])
|
||||
video_inputs = processor.video_processor(videos=[frames], do_sample_frames=False, return_tensors="pt")
|
||||
video_grid_thw = video_inputs["video_grid_thw"]
|
||||
temporal_blocks, grid_height, grid_width = (int(value) for value in video_grid_thw[0])
|
||||
video_tokens_per_block = grid_height * grid_width // int(processor.video_processor.merge_size)**2
|
||||
video_ids = tokenizer("<Video 1>: ", add_special_tokens=False)["input_ids"]
|
||||
for _ in range(temporal_blocks):
|
||||
video_ids += (
|
||||
[_token_id(tokenizer, "<|vision_start|>")]
|
||||
+ [_token_id(tokenizer, "<|video_pad|>")] * video_tokens_per_block
|
||||
+ [_token_id(tokenizer, "<|vision_end|>")]
|
||||
)
|
||||
video_ids += prompt_ids
|
||||
cases["video"] = {
|
||||
"input_ids": torch.tensor([video_ids], dtype=torch.long),
|
||||
"attention_mask": torch.ones(1, len(video_ids), dtype=torch.long),
|
||||
"mm_token_type_ids": _mm_token_type_ids(processor, video_ids),
|
||||
"pixel_values_videos": video_inputs["pixel_values_videos"],
|
||||
"video_grid_thw": video_grid_thw,
|
||||
}
|
||||
return cases
|
||||
|
||||
|
||||
def _run_cases(
|
||||
model: torch.nn.Module,
|
||||
cases: dict[str, dict[str, torch.Tensor]],
|
||||
device: torch.device,
|
||||
) -> dict[str, tuple[torch.Tensor, ...]]:
|
||||
dtype = next(model.parameters()).dtype
|
||||
outputs: dict[str, tuple[torch.Tensor, ...]] = {}
|
||||
for name, case in cases.items():
|
||||
inputs = {
|
||||
key: value.to(device=device, dtype=dtype if key.startswith("pixel_values") else value.dtype)
|
||||
for key, value in case.items()
|
||||
}
|
||||
with torch.inference_mode():
|
||||
result = model(
|
||||
**inputs,
|
||||
use_cache=False,
|
||||
output_hidden_states=True,
|
||||
)
|
||||
assert result.hidden_states is not None
|
||||
assert len(result.hidden_states) > MINIMAX_H3_TEXT_ENCODER_LAYER
|
||||
outputs[name] = tuple(hidden_state.detach().cpu() for hidden_state in result.hidden_states)
|
||||
return outputs
|
||||
|
||||
|
||||
def _production_loader_args() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
pipeline_config=SimpleNamespace(
|
||||
text_encoder_configs=(MiniMaxH3Qwen3VLConfig(),),
|
||||
text_encoder_precisions=("bf16",),
|
||||
),
|
||||
text_encoder_cpu_offload=False,
|
||||
override_text_encoder_quant=None,
|
||||
override_text_encoder_safetensors=None,
|
||||
pin_cpu_memory=False,
|
||||
)
|
||||
|
||||
|
||||
def test_minimax_h3_qwen3_vl_parity() -> None:
|
||||
"""Match official layer-50 states for text, image, and video inputs."""
|
||||
from transformers import Qwen3VLForConditionalGeneration
|
||||
|
||||
device, root = _require_assets()
|
||||
cases = _make_cases(root)
|
||||
requested_cases = os.environ.get("MINIMAX_H3_ENCODER_PARITY_CASES")
|
||||
if requested_cases:
|
||||
names = {name.strip() for name in requested_cases.split(",") if name.strip()}
|
||||
unknown = names - cases.keys()
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown MiniMax-H3 encoder parity cases: {sorted(unknown)}")
|
||||
cases = {name: case for name, case in cases.items() if name in names}
|
||||
|
||||
official_full, loading_info = Qwen3VLForConditionalGeneration.from_pretrained(
|
||||
root / "text_encoder",
|
||||
local_files_only=True,
|
||||
dtype=torch.bfloat16,
|
||||
low_cpu_mem_usage=True,
|
||||
output_loading_info=True,
|
||||
)
|
||||
load_errors = {
|
||||
name: loading_info.get(name, [])
|
||||
for name in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs")
|
||||
if loading_info.get(name)
|
||||
}
|
||||
assert not load_errors, f"Official Qwen3-VL checkpoint did not load strictly: {load_errors}"
|
||||
official = official_full.model.eval().to(device)
|
||||
del official_full
|
||||
expected = _run_cases(official, cases, device)
|
||||
del official
|
||||
_reclaim_vram()
|
||||
|
||||
production = TextEncoderLoader().load(str(root / "text_encoder"), _production_loader_args())
|
||||
assert getattr(production, "_fastvideo_input_device", device) == device
|
||||
actual = _run_cases(production, cases, device)
|
||||
|
||||
assert actual.keys() == expected.keys()
|
||||
for name in expected:
|
||||
assert len(actual[name]) == len(expected[name])
|
||||
for layer, (result, reference) in enumerate(zip(actual[name], expected[name], strict=True)):
|
||||
assert_close(result, reference, atol=0.0, rtol=0.0, msg=lambda message: f"{name} layer {layer}: {message}")
|
||||
result = actual[name][MINIMAX_H3_TEXT_ENCODER_LAYER]
|
||||
reference = expected[name][MINIMAX_H3_TEXT_ENCODER_LAYER]
|
||||
drift = (result.float() - reference.float()).abs()
|
||||
print(
|
||||
f"{name}: max_abs={drift.max().item():.8f} mean_abs={drift.mean().item():.8f}",
|
||||
flush=True,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
# MiniMax H3 port status
|
||||
|
||||
## Status
|
||||
|
||||
- workloads: T2VA, FL2VA, Ref2VA joint video/audio generation
|
||||
- component parity: complete
|
||||
- FastVideo runtime acceptance: complete
|
||||
- official end-to-end pipeline parity: complete
|
||||
|
||||
## Coverage
|
||||
|
||||
| Scope | Evidence | State |
|
||||
|---|---|---|
|
||||
| Qwen3-VL encoder | exact text/image/video hidden states through the production loader | complete |
|
||||
| FL2VA and Ref2VA DiTs | exact video/audio heads for both model partitions | complete |
|
||||
| Video VAE | exact encode, normalization, and decode through the production loader | complete |
|
||||
| Audio VAE | exact encode and normalization; decode maximum absolute drift `2.4e-7` | complete |
|
||||
| Video/audio schedulers | pinned `12/3` schedule parity | complete |
|
||||
| FL2VA packing | pinned row, position, tag, timestep, and RNG parity | complete |
|
||||
| Ref2VA media and packing | pinned media and packing parity | complete |
|
||||
| Public surface | manifest resolution, pipeline registration, and three presets | complete |
|
||||
| FastVideo distributed runtime | valid joint AV outputs; SP=1/SP=4 latent consistency | complete |
|
||||
| Official end-to-end pipeline | exact T2VA, FL2VA, and Ref2VA video/audio latents | complete |
|
||||
|
||||
## Current validation
|
||||
|
||||
T2VA, FL2VA, and Ref2VA match the official video/audio latents exactly.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Preserve each H3 scheduler's configured shift; global `flow_shift` is invalid.
|
||||
- Do not wrap the H3 DiT in global autocast; its FP32 projections must stay FP32.
|
||||
- Let FSDP move CPU-offloaded Qwen parameters; do not move the wrapped conditioner as a whole.
|
||||
- Load `transformer/` for T2VA/FL2VA and `transformer_ref/` for Ref2VA.
|
||||
- Keep `last_image`, `references`, and `audio_latents` on the typed request path.
|
||||
- Treat the published component folders as the loading boundary.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
Completed rows summarize recorded component and FastVideo runtime runs. Registry smoke, generated media, and
|
||||
FastVideo SP consistency are supporting checks, not substitutes for the recorded official comparisons.
|
||||
@@ -0,0 +1,55 @@
|
||||
# MiniMax H3 validation
|
||||
|
||||
Local tests keep checks that need the pinned Diffusers source, published weights, or the public registry surface.
|
||||
FastVideo-owned unit contracts belong under `fastvideo/tests/`.
|
||||
|
||||
## Reference
|
||||
|
||||
- Diffusers implementation: `https://github.com/huggingface/diffusers/pull/14355`
|
||||
- Source checkout: `${MINIMAX_H3_OFFICIAL_REF_DIR:-$PWD/DiffusersMiniMaxH3}`
|
||||
- Checkpoint: `MiniMaxAI/MiniMax-H3`
|
||||
|
||||
The reference helper verifies the pinned source and import origin. A missing checkout may skip a source-parity module;
|
||||
that skip is not parity evidence.
|
||||
|
||||
## Registry smoke
|
||||
|
||||
```bash
|
||||
pytest tests/local_tests/pipelines/test_minimax_h3_pipeline_smoke.py -q
|
||||
```
|
||||
|
||||
## Pinned implementation parity
|
||||
|
||||
```bash
|
||||
PYTHONPATH="${MINIMAX_H3_OFFICIAL_REF_DIR:-$PWD/DiffusersMiniMaxH3}/src:$PWD" pytest \
|
||||
tests/local_tests/minimax_h3/test_minimax_h3_scheduler_parity.py \
|
||||
tests/local_tests/minimax_h3/test_minimax_h3_packing.py \
|
||||
tests/local_tests/minimax_h3/test_minimax_h3_ref2va_packing.py \
|
||||
tests/local_tests/minimax_h3/test_minimax_h3_ref2va_media.py -v -s
|
||||
```
|
||||
|
||||
## Checkpoint component parity
|
||||
|
||||
```bash
|
||||
export MINIMAX_H3_MODEL_ROOT=/path/to/MiniMax-H3
|
||||
export MINIMAX_H3_OFFICIAL_REF_DIR=/path/to/DiffusersMiniMaxH3
|
||||
|
||||
PYTHONPATH="$MINIMAX_H3_OFFICIAL_REF_DIR/src:$PWD" \
|
||||
MINIMAX_H3_RUN_ENCODER_PARITY=1 \
|
||||
pytest tests/local_tests/encoders/test_minimax_h3_qwen3_vl_parity.py -v -s
|
||||
|
||||
PYTHONPATH="$MINIMAX_H3_OFFICIAL_REF_DIR/src:$PWD" \
|
||||
MINIMAX_H3_RUN_DIT_PARITY=1 \
|
||||
MINIMAX_H3_RUN_VIDEO_VAE_PARITY=1 \
|
||||
MINIMAX_H3_RUN_AUDIO_VAE_PARITY=1 \
|
||||
pytest \
|
||||
tests/local_tests/transformers/test_minimax_h3_transformer_parity.py \
|
||||
tests/local_tests/vaes/test_minimax_h3_video_vae_parity.py \
|
||||
tests/local_tests/vaes/test_minimax_h3_audio_vae_parity.py -v -s
|
||||
```
|
||||
|
||||
With a gate enabled, missing CUDA, source, or weights is a failure. Recorded component evidence is exact for both DiT
|
||||
partitions, the video VAE, and all Qwen3-VL hidden states; audio decode has maximum absolute drift `2.4e-7`.
|
||||
|
||||
FastVideo joint audio/video generation and SP=1/SP=4 latent consistency have been validated. T2VA, FL2VA, and
|
||||
Ref2VA video/audio latents match the pinned Diffusers pipeline exactly.
|
||||
@@ -0,0 +1,42 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import hashlib
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
REFERENCE_ROOT = Path(
|
||||
os.environ.get("MINIMAX_H3_OFFICIAL_REF_DIR") or REPO_ROOT / "DiffusersMiniMaxH3"
|
||||
).resolve()
|
||||
REFERENCE_SRC = REFERENCE_ROOT / "src"
|
||||
PINNED_COMMIT = "abc5e9bf71fd38f53cd471bc3acaa84bc5ecbfdc"
|
||||
|
||||
|
||||
def assert_pinned_reference(relative_path: str, sha256: str) -> Path:
|
||||
path = REFERENCE_ROOT / relative_path
|
||||
if not path.is_file():
|
||||
pytest.skip(
|
||||
f"pinned MiniMax-H3 Diffusers reference is missing: {path}",
|
||||
allow_module_level=True,
|
||||
)
|
||||
actual_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if actual_sha256 != sha256:
|
||||
raise RuntimeError(
|
||||
f"MiniMax-H3 reference file changed from {PINNED_COMMIT}: {relative_path} "
|
||||
f"has SHA-256 {actual_sha256}, expected {sha256}."
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def assert_reference_source(symbol: object, relative_path: str) -> None:
|
||||
expected = (REFERENCE_ROOT / relative_path).resolve()
|
||||
source = inspect.getsourcefile(symbol)
|
||||
actual = Path(source).resolve() if source is not None else None
|
||||
if actual != expected:
|
||||
raise RuntimeError(
|
||||
f"MiniMax-H3 parity imported {actual}, expected {expected}. "
|
||||
f"Prepend {REFERENCE_SRC} to PYTHONPATH before starting pytest."
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
from tests.local_tests.minimax_h3._reference import REFERENCE_SRC, assert_pinned_reference, assert_reference_source
|
||||
|
||||
assert_pinned_reference(
|
||||
"src/diffusers/modular_pipelines/minimax_h3/packing.py",
|
||||
"969d667d1c0316ed931d1675d474220045390e9a566cc885e3bd9cc6147b3e5b",
|
||||
)
|
||||
sys.path.insert(0, str(REFERENCE_SRC))
|
||||
|
||||
from diffusers.modular_pipelines.minimax_h3 import packing as reference # noqa: E402
|
||||
|
||||
assert_reference_source(reference, "src/diffusers/modular_pipelines/minimax_h3/packing.py")
|
||||
|
||||
from fastvideo.pipelines.basic.minimax_h3 import packing as actual # noqa: E402
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import MiniMaxH3PackedLayout # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ratio", [(16, 9), (9, 16), (1, 1), (4, 1), (1, 4)])
|
||||
def test_canvas_and_frame_geometry_match_reference(ratio: tuple[int, int]) -> None:
|
||||
assert actual.resolve_canvas_size(*ratio) == reference.resolve_canvas_size(*ratio)
|
||||
for requested_frames in (1, 5, 6, 22, 120, 360):
|
||||
aligned = actual.align_num_frames(requested_frames)
|
||||
assert aligned == reference.align_num_frames(requested_frames)
|
||||
assert actual.video_latent_num_frames(aligned) == reference.video_latent_num_frames(aligned)
|
||||
assert actual.audio_latent_num_frames(aligned) == reference.audio_latent_num_frames(aligned)
|
||||
|
||||
|
||||
def test_channel_major_patch_order_and_round_trip_match_reference() -> None:
|
||||
latents = torch.arange(2 * 3 * 4 * 6 * 8, dtype=torch.float32).reshape(2, 3, 4, 6, 8)
|
||||
patch_size = (2, 2, 4)
|
||||
expected = reference.patchify_video_latents(latents, patch_size)
|
||||
result = actual.patchify_video_latents(latents, patch_size)
|
||||
assert_close(result, expected, rtol=0, atol=0)
|
||||
assert result[0].tolist() == expected[0].tolist()
|
||||
restored = actual.unpatchify_video_tokens(result, 4, 6, 8, 3, patch_size)
|
||||
assert_close(restored, latents, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("anchors", [(), ("first",), ("first", "last")])
|
||||
def test_layout_position_tags_indices_and_timesteps_match_reference(anchors: tuple[str, ...]) -> None:
|
||||
kwargs = {
|
||||
"text_token_tags": torch.tensor([1, 1, 0, 0, 1]),
|
||||
"num_latent_frames": 7,
|
||||
"latent_height": 6,
|
||||
"latent_width": 8,
|
||||
"num_audio_latents": 11,
|
||||
"patch_size": (1, 2, 2),
|
||||
"keyframe_anchors": anchors,
|
||||
}
|
||||
expected = reference.build_packed_sequence(**kwargs)
|
||||
result = actual.build_packed_sequence(**kwargs)
|
||||
assert isinstance(result, MiniMaxH3PackedLayout)
|
||||
assert result.sequence_length == expected.sequence_length
|
||||
for field in ("position_ids", "token_tags", "video_indices", "audio_indices", "text_indices"):
|
||||
assert_close(getattr(result, field), getattr(expected, field), rtol=0, atol=0)
|
||||
assert result.num_condition_video_rows == expected.num_condition_video_rows
|
||||
assert result.num_condition_audio_rows == expected.num_condition_audio_rows
|
||||
|
||||
expected_steps = reference.build_row_timesteps(expected, 0.25, 0.5, 0.999, 0.75)
|
||||
result_steps = actual.build_row_timesteps(result, 0.25, 0.5, 0.999, 0.75)
|
||||
assert_close(result_steps[0], expected_steps[0], rtol=0, atol=0)
|
||||
assert_close(result_steps[1], expected_steps[1], rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_condition_noise_preserves_draw_order() -> None:
|
||||
shapes = ((1, 4, 6), (2, 4, 6))
|
||||
kwargs = {"condition_latent_shapes": shapes, "patch_size": (1, 2, 2), "latent_channels": 3}
|
||||
reference_generator = torch.Generator().manual_seed(77)
|
||||
actual_generator = torch.Generator().manual_seed(77)
|
||||
expected = reference.keyframe_condition_noise(**kwargs, generator=reference_generator)
|
||||
result = actual.keyframe_condition_noise(**kwargs, generator=actual_generator)
|
||||
assert_close(result, expected, rtol=0, atol=0)
|
||||
assert_close(torch.randn(5, generator=actual_generator), torch.randn(5, generator=reference_generator), rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_stereo_audio_rows_unpack_channel_major() -> None:
|
||||
rows = torch.arange(2 * 5 * 3).reshape(10, 3)
|
||||
assert_close(actual.unpack_audio_tokens(rows, 5), reference.unpack_audio_tokens(rows, 5), rtol=0, atol=0)
|
||||
@@ -0,0 +1,237 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Implementation-subcomponent parity for MiniMax H3 Ref2VA media preparation."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torch.testing import assert_close
|
||||
|
||||
from tests.local_tests.minimax_h3._reference import REFERENCE_SRC, assert_pinned_reference, assert_reference_source
|
||||
|
||||
PARITY_SCOPE = "implementation_subcomponent"
|
||||
|
||||
assert_pinned_reference(
|
||||
"src/diffusers/modular_pipelines/minimax_h3/packing.py",
|
||||
"969d667d1c0316ed931d1675d474220045390e9a566cc885e3bd9cc6147b3e5b",
|
||||
)
|
||||
assert_pinned_reference(
|
||||
"src/diffusers/modular_pipelines/minimax_h3/packing_ref2va.py",
|
||||
"1f025b68af3f5c4316e2b89b026d4976c5b85286dafcc8b5c1d8ceb186c9bc01",
|
||||
)
|
||||
sys.path.insert(0, str(REFERENCE_SRC))
|
||||
|
||||
from diffusers.modular_pipelines.minimax_h3 import packing as reference_packing # noqa: E402
|
||||
from diffusers.modular_pipelines.minimax_h3 import packing_ref2va as reference # noqa: E402
|
||||
|
||||
assert_reference_source(reference_packing, "src/diffusers/modular_pipelines/minimax_h3/packing.py")
|
||||
assert_reference_source(reference, "src/diffusers/modular_pipelines/minimax_h3/packing_ref2va.py")
|
||||
|
||||
from fastvideo.pipelines.basic.minimax_h3 import packing as base_packing # noqa: E402
|
||||
from fastvideo.pipelines.basic.minimax_h3 import reference as actual # noqa: E402
|
||||
from fastvideo.pipelines.basic.minimax_h3.reference import ( # noqa: E402
|
||||
MiniMaxH3Reference,
|
||||
decode_reference_audio,
|
||||
decode_reference_video,
|
||||
prepare_reference,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(80, 48), (48, 80), (64, 64), (128, 32)])
|
||||
def test_reference_image_size_matches_pinned_diffusers(size: tuple[int, int]) -> None:
|
||||
assert actual.resolve_reference_image_size(*size) == reference.resolve_reference_image_size(*size)
|
||||
|
||||
|
||||
def test_reference_media_to_uint8_matches_pinned_diffusers() -> None:
|
||||
float_pixels = np.array(
|
||||
[
|
||||
[[0.0, 0.25, 0.5], [0.75, 1.0, 0.125]],
|
||||
[[1.0, 0.5, 0.0], [0.1, 0.2, 0.3]],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
cases = [
|
||||
float_pixels,
|
||||
torch.from_numpy(float_pixels).movedim(-1, 0),
|
||||
Image.fromarray((float_pixels * 255).round().astype(np.uint8)),
|
||||
[
|
||||
Image.fromarray((float_pixels * 255).round().astype(np.uint8)),
|
||||
Image.fromarray(np.flip((float_pixels * 255).round().astype(np.uint8), axis=1)),
|
||||
],
|
||||
]
|
||||
|
||||
for media in cases:
|
||||
np.testing.assert_array_equal(actual.reference_media_to_uint8(media), reference.reference_media_to_uint8(media))
|
||||
|
||||
|
||||
def test_reference_video_24_and_30_fps_resampling_matches_pinned_diffusers() -> None:
|
||||
frames = np.arange(30, dtype=np.uint8).reshape(-1, 1, 1, 1) * np.ones((1, 2, 2, 3), dtype=np.uint8)
|
||||
|
||||
assert actual.resample_reference_frames(frames, 24.0) is frames
|
||||
assert reference.resample_reference_frames(frames, 24.0) is frames
|
||||
|
||||
result = actual.resample_reference_frames(frames, 30.0)
|
||||
expected = reference.resample_reference_frames(frames, 30.0)
|
||||
np.testing.assert_array_equal(result, expected)
|
||||
assert [int(frame[0, 0, 0]) for frame in result] == [
|
||||
index for index in range(30) if index not in (2, 7, 12, 17, 22, 27)
|
||||
]
|
||||
|
||||
|
||||
def test_reference_video_resize_and_truncate_matches_pinned_diffusers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for module in (base_packing, reference_packing):
|
||||
monkeypatch.setattr(module, "MINIMAX_H3_SHORT_EDGE", 32)
|
||||
monkeypatch.setattr(module, "MINIMAX_H3_MAX_PIXELS", 32 * 64)
|
||||
|
||||
frames = np.arange(5 * 16 * 32 * 3, dtype=np.uint32).reshape(5, 16, 32, 3).astype(np.uint8)
|
||||
result = actual.prepare_reference_frames(frames, num_frames=3)
|
||||
expected = reference.prepare_reference_frames(frames, num_frames=3)
|
||||
np.testing.assert_array_equal(result, expected)
|
||||
assert result.shape == (3, 32, 64, 3)
|
||||
|
||||
canvas_frames = np.zeros((5, 32, 64, 3), dtype=np.uint8)
|
||||
result = actual.prepare_reference_frames(canvas_frames, num_frames=3)
|
||||
expected = reference.prepare_reference_frames(canvas_frames, num_frames=3)
|
||||
np.testing.assert_array_equal(result, expected)
|
||||
assert np.shares_memory(result, canvas_frames)
|
||||
assert np.shares_memory(expected, canvas_frames)
|
||||
|
||||
|
||||
def test_qwen_two_fps_sampling_and_timestamps_match_pinned_diffusers() -> None:
|
||||
frames = np.arange(25, dtype=np.uint8).reshape(-1, 1, 1, 1) * np.ones((1, 2, 2, 3), dtype=np.uint8)
|
||||
|
||||
sampled, timestamps = actual.sample_reference_video_frames(frames)
|
||||
expected_sampled, expected_timestamps = reference.sample_reference_video_frames(frames)
|
||||
|
||||
np.testing.assert_array_equal(np.stack(sampled), np.stack(expected_sampled))
|
||||
assert [int(frame[0, 0, 0]) for frame in sampled] == [0, 12, 24]
|
||||
assert timestamps == expected_timestamps == [0.25, 1.0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channels", [1, 2], ids=["mono", "stereo"])
|
||||
def test_same_rate_reference_waveform_matches_pinned_diffusers(channels: int) -> None:
|
||||
waveform = torch.arange(channels * 10, dtype=torch.float64).reshape(channels, 10) / 10
|
||||
kwargs = {
|
||||
"waveform": waveform,
|
||||
"sample_rate": 10,
|
||||
"target_sample_rate": 10,
|
||||
"max_duration": 0.7,
|
||||
}
|
||||
|
||||
result = actual.prepare_reference_waveform(**kwargs)
|
||||
expected = reference.prepare_reference_waveform(**kwargs)
|
||||
|
||||
assert_close(result, expected, rtol=0, atol=0)
|
||||
assert result.dtype == torch.float32
|
||||
assert result.shape == (2, 7)
|
||||
if channels == 1:
|
||||
assert_close(result[0], result[1], rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("num_frames", "expected"),
|
||||
[(1, 22), (5, 22), (21, 22), (22, 22), (23, 22), (39, 39), (56, 56), (120, 107)],
|
||||
)
|
||||
def test_reference_frame_trimming_matches_pinned_diffusers(num_frames: int, expected: int) -> None:
|
||||
assert actual.trim_reference_num_frames(num_frames) == expected
|
||||
assert actual.trim_reference_num_frames(num_frames) == reference.trim_reference_num_frames(num_frames)
|
||||
|
||||
|
||||
_FIXTURE_FPS = 8
|
||||
_FIXTURE_NUM_FRAMES = 8
|
||||
_FIXTURE_SAMPLE_RATE = 8000
|
||||
|
||||
|
||||
def _write_video_with_audio(path: Path) -> None:
|
||||
av = pytest.importorskip("av")
|
||||
with av.open(str(path), "w") as container:
|
||||
video_stream = container.add_stream("libx264", rate=_FIXTURE_FPS)
|
||||
video_stream.width = 64
|
||||
video_stream.height = 32
|
||||
video_stream.pix_fmt = "yuv420p"
|
||||
audio_stream = container.add_stream("aac", rate=_FIXTURE_SAMPLE_RATE)
|
||||
audio_stream.codec_context.layout = "stereo"
|
||||
|
||||
for index in range(_FIXTURE_NUM_FRAMES):
|
||||
pixels = np.full((32, 64, 3), index * 16, dtype=np.uint8)
|
||||
frame = av.VideoFrame.from_ndarray(pixels, format="rgb24")
|
||||
frame.pts = index
|
||||
container.mux(video_stream.encode(frame))
|
||||
container.mux(video_stream.encode())
|
||||
|
||||
num_samples = _FIXTURE_SAMPLE_RATE
|
||||
samples = np.zeros((1, 2 * num_samples), dtype=np.int16)
|
||||
frame = av.AudioFrame.from_ndarray(samples, format="s16", layout="stereo")
|
||||
frame.sample_rate = _FIXTURE_SAMPLE_RATE
|
||||
resampler = av.audio.resampler.AudioResampler(
|
||||
format="fltp",
|
||||
layout="stereo",
|
||||
rate=_FIXTURE_SAMPLE_RATE,
|
||||
)
|
||||
pts = 0
|
||||
for resampled in resampler.resample(frame):
|
||||
resampled.pts = pts
|
||||
pts += resampled.samples
|
||||
container.mux(audio_stream.encode(resampled))
|
||||
container.mux(audio_stream.encode())
|
||||
|
||||
|
||||
def _write_audio(path: Path) -> None:
|
||||
av = pytest.importorskip("av")
|
||||
with av.open(str(path), "w") as container:
|
||||
stream = container.add_stream("pcm_s16le", rate=_FIXTURE_SAMPLE_RATE)
|
||||
stream.codec_context.layout = "stereo"
|
||||
frame = av.AudioFrame.from_ndarray(
|
||||
np.zeros((1, 2 * _FIXTURE_SAMPLE_RATE), dtype=np.int16),
|
||||
format="s16",
|
||||
layout="stereo",
|
||||
)
|
||||
frame.sample_rate = _FIXTURE_SAMPLE_RATE
|
||||
frame.pts = 0
|
||||
container.mux(stream.encode(frame))
|
||||
container.mux(stream.encode())
|
||||
|
||||
|
||||
def test_deferred_stage_decodes_local_video_soundtrack_and_audio(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pytest.importorskip("av")
|
||||
video_path = tmp_path / "reference.mp4"
|
||||
audio_path = tmp_path / "reference.wav"
|
||||
_write_video_with_audio(video_path)
|
||||
_write_audio(audio_path)
|
||||
|
||||
actual_video = decode_reference_video(video_path)
|
||||
reference_video = reference.decode_reference_video(video_path)
|
||||
np.testing.assert_array_equal(actual_video[0], reference_video[0])
|
||||
assert actual_video[1] == reference_video[1] == float(_FIXTURE_FPS)
|
||||
assert actual_video[2] is not None and reference_video[2] is not None
|
||||
assert_close(actual_video[2][0], reference_video[2][0], rtol=0, atol=0)
|
||||
assert actual_video[2][1] == reference_video[2][1] == _FIXTURE_SAMPLE_RATE
|
||||
|
||||
actual_audio = decode_reference_audio(audio_path)
|
||||
reference_audio = reference.decode_reference_audio(audio_path)
|
||||
assert_close(actual_audio[0], reference_audio[0], rtol=0, atol=0)
|
||||
assert actual_audio[1] == reference_audio[1] == _FIXTURE_SAMPLE_RATE
|
||||
|
||||
video_reference = MiniMaxH3Reference(source=str(video_path), media_type="video")
|
||||
audio_reference = MiniMaxH3Reference(source=str(audio_path), media_type="audio")
|
||||
assert isinstance(video_reference.source, str)
|
||||
assert isinstance(audio_reference.source, str)
|
||||
|
||||
monkeypatch.setattr(base_packing, "MINIMAX_H3_SHORT_EDGE", 32)
|
||||
monkeypatch.setattr(base_packing, "MINIMAX_H3_MAX_PIXELS", 32 * 64)
|
||||
prepared_video = prepare_reference(video_reference, num_frames=22, target_sample_rate=_FIXTURE_SAMPLE_RATE)
|
||||
prepared_audio = prepare_reference(audio_reference, num_frames=22, target_sample_rate=_FIXTURE_SAMPLE_RATE)
|
||||
|
||||
expected_samples = int(22 / 24 * _FIXTURE_SAMPLE_RATE)
|
||||
assert prepared_video.frames.shape == (22, 32, 64, 3)
|
||||
assert prepared_video.frames.dtype == np.uint8
|
||||
assert prepared_video.has_audio
|
||||
assert prepared_video.waveform.shape == (2, expected_samples)
|
||||
assert prepared_audio.has_audio
|
||||
assert prepared_audio.waveform.shape == (2, expected_samples)
|
||||
@@ -0,0 +1,181 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pinned Diffusers parity for MiniMax H3 Ref2VA packing."""
|
||||
|
||||
import sys
|
||||
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
from tests.local_tests.minimax_h3._reference import REFERENCE_SRC, assert_pinned_reference, assert_reference_source
|
||||
|
||||
PARITY_SCOPE = "implementation_subcomponent"
|
||||
|
||||
assert_pinned_reference(
|
||||
"src/diffusers/modular_pipelines/minimax_h3/packing.py",
|
||||
"969d667d1c0316ed931d1675d474220045390e9a566cc885e3bd9cc6147b3e5b",
|
||||
)
|
||||
assert_pinned_reference(
|
||||
"src/diffusers/modular_pipelines/minimax_h3/packing_ref2va.py",
|
||||
"1f025b68af3f5c4316e2b89b026d4976c5b85286dafcc8b5c1d8ceb186c9bc01",
|
||||
)
|
||||
sys.path.insert(0, str(REFERENCE_SRC))
|
||||
|
||||
from diffusers.modular_pipelines.minimax_h3 import packing_ref2va as reference # noqa: E402
|
||||
|
||||
assert_reference_source(reference, "src/diffusers/modular_pipelines/minimax_h3/packing_ref2va.py")
|
||||
|
||||
from fastvideo.pipelines.basic.minimax_h3 import packing as actual # noqa: E402
|
||||
from fastvideo.pipelines.basic.minimax_h3.reference import MiniMaxH3PreparedReference # noqa: E402
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_conditioning import ( # noqa: E402
|
||||
build_ref2va_presentation,
|
||||
)
|
||||
|
||||
|
||||
def _actual_mixed_references() -> list[MiniMaxH3PreparedReference]:
|
||||
return [
|
||||
MiniMaxH3PreparedReference(
|
||||
media_type="image",
|
||||
num_latent_frames=1,
|
||||
latent_height=4,
|
||||
latent_width=2,
|
||||
),
|
||||
MiniMaxH3PreparedReference(
|
||||
media_type="video",
|
||||
has_audio=True,
|
||||
num_latent_frames=2,
|
||||
latent_height=2,
|
||||
latent_width=4,
|
||||
num_audio_latents=2,
|
||||
),
|
||||
MiniMaxH3PreparedReference(media_type="audio", has_audio=True, num_audio_latents=1),
|
||||
]
|
||||
|
||||
|
||||
def _reference_mixed_references() -> list[reference.MiniMaxH3PreparedReference]:
|
||||
return [
|
||||
reference.MiniMaxH3PreparedReference(
|
||||
kind="image",
|
||||
num_latent_frames=1,
|
||||
latent_height=4,
|
||||
latent_width=2,
|
||||
),
|
||||
reference.MiniMaxH3PreparedReference(
|
||||
kind="video",
|
||||
has_audio=True,
|
||||
num_latent_frames=2,
|
||||
latent_height=2,
|
||||
latent_width=4,
|
||||
num_audio_latents=2,
|
||||
),
|
||||
reference.MiniMaxH3PreparedReference(kind="audio", has_audio=True, num_audio_latents=1),
|
||||
]
|
||||
|
||||
|
||||
def _mixed_layout_kwargs() -> dict:
|
||||
return {
|
||||
"text_token_tags": torch.tensor([1, 0, 1]),
|
||||
"num_latent_frames": 2,
|
||||
"latent_height": 4,
|
||||
"latent_width": 4,
|
||||
"num_audio_latents": 2,
|
||||
"patch_size": (1, 2, 2),
|
||||
}
|
||||
|
||||
|
||||
def test_ref2va_layout_matches_pinned_diffusers_exactly() -> None:
|
||||
kwargs = _mixed_layout_kwargs()
|
||||
expected = reference.build_ref2va_packed_sequence(
|
||||
references=_reference_mixed_references(),
|
||||
**kwargs,
|
||||
)
|
||||
result = actual.build_ref2va_packed_sequence(
|
||||
references=_actual_mixed_references(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
assert result.sequence_length == expected.sequence_length
|
||||
for field in ("position_ids", "token_tags", "video_indices", "audio_indices", "text_indices"):
|
||||
assert_close(getattr(result, field), getattr(expected, field), rtol=0, atol=0)
|
||||
assert result.num_condition_video_rows == expected.num_condition_video_rows
|
||||
assert result.num_condition_audio_rows == expected.num_condition_audio_rows
|
||||
|
||||
|
||||
class _PresentationTokenizer:
|
||||
text_ids = {
|
||||
"<Picture 1>: ": 10,
|
||||
"<Audio 1>: ": 11,
|
||||
"<Video 1>: ": 12,
|
||||
"<0.2 seconds>": 13,
|
||||
"<1.0 seconds>": 14,
|
||||
"<Audio 2>: ": 15,
|
||||
"dance": 16,
|
||||
}
|
||||
special_ids = {
|
||||
"<|vision_start|>": 100,
|
||||
"<|image_pad|>": 101,
|
||||
"<|video_pad|>": 102,
|
||||
"<|vision_end|>": 103,
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
|
||||
def __call__(self, value: str, add_special_tokens: bool) -> dict[str, list[int]]:
|
||||
assert not add_special_tokens
|
||||
self.calls.append(value)
|
||||
return {"input_ids": [self.text_ids[value]]}
|
||||
|
||||
def convert_tokens_to_ids(self, token: str) -> int:
|
||||
return self.special_ids[token]
|
||||
|
||||
|
||||
def test_presentation_preserves_reference_and_soundtrack_order() -> None:
|
||||
actual_references = [
|
||||
MiniMaxH3PreparedReference(media_type="image"),
|
||||
MiniMaxH3PreparedReference(
|
||||
media_type="video",
|
||||
has_audio=True,
|
||||
block_timestamps=[0.25, 1.0],
|
||||
),
|
||||
MiniMaxH3PreparedReference(media_type="audio", has_audio=True),
|
||||
]
|
||||
reference_references = [
|
||||
reference.MiniMaxH3PreparedReference(kind="image"),
|
||||
reference.MiniMaxH3PreparedReference(
|
||||
kind="video",
|
||||
has_audio=True,
|
||||
block_timestamps=[0.25, 1.0],
|
||||
),
|
||||
reference.MiniMaxH3PreparedReference(kind="audio", has_audio=True),
|
||||
]
|
||||
actual_tokenizer = _PresentationTokenizer()
|
||||
reference_tokenizer = _PresentationTokenizer()
|
||||
|
||||
result = build_ref2va_presentation(
|
||||
actual_tokenizer,
|
||||
"dance",
|
||||
actual_references,
|
||||
image_token_counts=[2],
|
||||
video_block_token_counts=[1],
|
||||
)
|
||||
expected_from_reference = reference.build_ref2va_presentation(
|
||||
reference_tokenizer,
|
||||
"dance",
|
||||
reference_references,
|
||||
image_token_counts=[2],
|
||||
video_block_token_counts=[1],
|
||||
)
|
||||
expected_calls = [
|
||||
"<Picture 1>: ",
|
||||
"<Audio 1>: ",
|
||||
"<Video 1>: ",
|
||||
"<0.2 seconds>",
|
||||
"<1.0 seconds>",
|
||||
"<Audio 2>: ",
|
||||
"dance",
|
||||
]
|
||||
expected_ids = [10, 100, 101, 101, 103, 11, 12, 13, 100, 102, 103, 14, 100, 102, 103, 15, 16]
|
||||
expected_tags = [1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1]
|
||||
|
||||
assert actual_tokenizer.calls == reference_tokenizer.calls == expected_calls
|
||||
assert result == expected_from_reference == (expected_ids, expected_tags)
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
from tests.local_tests.minimax_h3._reference import REFERENCE_SRC, assert_pinned_reference, assert_reference_source
|
||||
|
||||
assert_pinned_reference(
|
||||
"src/diffusers/schedulers/scheduling_minimax_h3.py",
|
||||
"b6fe1b29d5bc7f134213c175817803f569db77de433480e13f4c71c50e4b7758",
|
||||
)
|
||||
sys.path.insert(0, str(REFERENCE_SRC))
|
||||
|
||||
from diffusers import MiniMaxH3Scheduler as ReferenceScheduler # noqa: E402
|
||||
|
||||
assert_reference_source(ReferenceScheduler, "src/diffusers/schedulers/scheduling_minimax_h3.py")
|
||||
|
||||
from fastvideo.models.schedulers.scheduling_minimax_h3 import MiniMaxH3Scheduler # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shift", [3.0, 12.0])
|
||||
def test_generated_schedule_and_steps_match_reference(shift: float) -> None:
|
||||
reference = ReferenceScheduler(shift=shift)
|
||||
actual = MiniMaxH3Scheduler(shift=shift)
|
||||
reference.set_timesteps(30)
|
||||
actual.set_timesteps(30)
|
||||
assert_close(actual.sigmas, reference.sigmas, rtol=0, atol=0)
|
||||
assert_close(actual.timesteps, reference.timesteps, rtol=0, atol=0)
|
||||
assert actual.num_inference_steps == reference.num_inference_steps == 29
|
||||
|
||||
generator = torch.Generator().manual_seed(123)
|
||||
sample = torch.randn((2, 3, 4), generator=generator, dtype=torch.float32)
|
||||
for reference_timestep, actual_timestep in zip(reference.timesteps, actual.timesteps, strict=True):
|
||||
velocity = torch.randn(sample.shape, generator=generator, dtype=sample.dtype)
|
||||
reference_sample = reference.step(velocity, reference_timestep, sample).prev_sample
|
||||
actual_sample = actual.step(velocity, actual_timestep, sample).prev_sample
|
||||
assert_close(actual_sample, reference_sample, rtol=0, atol=0)
|
||||
sample = reference_sample
|
||||
|
||||
|
||||
def test_explicit_schedule_noise_and_half_precision_match_reference() -> None:
|
||||
sigmas = [1.0, 0.5, 0.125, 0.0]
|
||||
reference = ReferenceScheduler(shift=12.0)
|
||||
actual = MiniMaxH3Scheduler(shift=12.0)
|
||||
reference.set_timesteps(sigmas=sigmas)
|
||||
actual.set_timesteps(sigmas=sigmas)
|
||||
|
||||
sample = torch.tensor([[1.0, -2.0]], dtype=torch.bfloat16)
|
||||
noise = torch.tensor([[0.5, 3.0]], dtype=torch.bfloat16)
|
||||
assert_close(actual.scale_noise(sample, 0.999, noise), reference.scale_noise(sample, 0.999, noise), rtol=0, atol=0)
|
||||
|
||||
velocity = torch.tensor([[0.25, -0.75]], dtype=torch.bfloat16)
|
||||
expected = reference.step(velocity, reference.timesteps[1], sample).prev_sample
|
||||
result = actual.step(velocity, actual.timesteps[1], sample).prev_sample
|
||||
assert result.dtype == torch.bfloat16
|
||||
assert_close(result, expected, rtol=0, atol=0)
|
||||
@@ -0,0 +1,95 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 public registration, modular manifest, and preset smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_modular_checkpoint(model_dir: Path) -> None:
|
||||
component_types = {
|
||||
"text_encoder": ("transformers", "Qwen3VLForConditionalGeneration"),
|
||||
"tokenizer": ("transformers", "Qwen2TokenizerFast"),
|
||||
"processor": ("transformers", "Qwen3VLProcessor"),
|
||||
"vae": ("diffusers", "AutoencoderKLMiniMaxH3"),
|
||||
"audio_vae": ("diffusers", "AutoencoderKLMiniMaxH3Audio"),
|
||||
"transformer": ("diffusers", "MiniMaxH3Transformer3DModel"),
|
||||
"transformer_ref": ("diffusers", "MiniMaxH3Transformer3DModel"),
|
||||
"scheduler": ("diffusers", "MiniMaxH3Scheduler"),
|
||||
"audio_scheduler": ("diffusers", "MiniMaxH3Scheduler"),
|
||||
}
|
||||
components = {
|
||||
name: [*component_type, {"type_hint": list(component_type), "subfolder": name}]
|
||||
for name, component_type in component_types.items()
|
||||
}
|
||||
model_dir.mkdir()
|
||||
for component in components:
|
||||
(model_dir / component).mkdir()
|
||||
manifest = {
|
||||
"_class_name": "MiniMaxH3ModularPipeline",
|
||||
"_diffusers_version": "0.36.0.dev0",
|
||||
"_blocks_class_name": "MiniMaxH3Blocks",
|
||||
**components,
|
||||
}
|
||||
(model_dir / "modular_model_index.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
|
||||
|
||||
def test_minimax_h3_registry_resolves_both_public_pipelines(tmp_path: Path) -> None:
|
||||
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
|
||||
from fastvideo.fastvideo_args import WorkloadType
|
||||
from fastvideo.pipelines.basic.minimax_h3.minimax_h3_pipeline import (
|
||||
MiniMaxH3ModularPipeline,
|
||||
MiniMaxH3Ref2VAModularPipeline,
|
||||
)
|
||||
from fastvideo.registry import get_model_info
|
||||
|
||||
model_dir = tmp_path / "checkpoint-with-modular-manifest"
|
||||
_write_modular_checkpoint(model_dir)
|
||||
|
||||
default_info = get_model_info(str(model_dir), workload_type=WorkloadType.I2V)
|
||||
assert default_info.pipeline_cls is MiniMaxH3ModularPipeline
|
||||
assert default_info.pipeline_config_cls is MiniMaxH3PipelineConfig
|
||||
|
||||
ref_info = get_model_info(
|
||||
str(model_dir),
|
||||
workload_type=WorkloadType.I2V,
|
||||
override_pipeline_cls_name="MiniMaxH3Ref2VAModularPipeline",
|
||||
)
|
||||
assert ref_info.pipeline_cls is MiniMaxH3Ref2VAModularPipeline
|
||||
assert ref_info.pipeline_config_cls is MiniMaxH3PipelineConfig
|
||||
assert MiniMaxH3Ref2VAModularPipeline._extra_config_module_map == {"transformer": "transformer_ref"}
|
||||
|
||||
|
||||
def test_minimax_h3_presets_and_guidance_distilled_defaults(tmp_path: Path) -> None:
|
||||
from fastvideo.api.presets import get_preset
|
||||
from fastvideo.api.sampling_param import SamplingParam
|
||||
from fastvideo.registry import get_preset_selection
|
||||
|
||||
model_dir = tmp_path / "minimax_h3-local"
|
||||
_write_modular_checkpoint(model_dir)
|
||||
|
||||
preset_name, family = get_preset_selection(str(model_dir))
|
||||
assert (preset_name, family) == ("minimax_h3_t2va", "minimax_h3")
|
||||
preset = get_preset(preset_name, family)
|
||||
assert preset.defaults == {
|
||||
"fps": 24,
|
||||
"guidance_scale": 1.0,
|
||||
"batch_cfg": False,
|
||||
"negative_prompt": "",
|
||||
"num_inference_steps": 50,
|
||||
"seed": 0,
|
||||
"height": 768,
|
||||
"width": 1344,
|
||||
"num_frames": 124,
|
||||
}
|
||||
|
||||
sampling = SamplingParam.from_pretrained(str(model_dir))
|
||||
assert sampling.negative_prompt == ""
|
||||
assert sampling.guidance_scale == 1.0
|
||||
assert sampling.batch_cfg is False
|
||||
|
||||
fl2va = get_preset("minimax_h3_fl2va", family)
|
||||
ref2va = get_preset("minimax_h3_ref2va", family)
|
||||
assert fl2va.defaults["num_frames"] == 192
|
||||
assert ref2va.defaults["num_frames"] == 124
|
||||
@@ -0,0 +1,244 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Production-loader parity for both MiniMax H3 DiT partitions.
|
||||
|
||||
This gated CUDA test loads the published ``transformer/`` and
|
||||
``transformer_ref/`` checkpoints through FastVideo's production
|
||||
``TransformerLoader`` and compares both output heads against the pinned
|
||||
Diffusers implementation. It is intentionally excluded from routine local
|
||||
and CI runs because each partition contains roughly 33B parameters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
OFFICIAL_REF_DIR = Path(os.environ.get("MINIMAX_H3_OFFICIAL_REF_DIR", REPO_ROOT / "DiffusersMiniMaxH3"))
|
||||
OFFICIAL_SRC = OFFICIAL_REF_DIR / "src"
|
||||
MODEL_ROOT = Path(os.environ.get("MINIMAX_H3_MODEL_ROOT", REPO_ROOT / "official_weights" / "MiniMax-H3"))
|
||||
RUN_ENV = "MINIMAX_H3_RUN_DIT_PARITY"
|
||||
PARITY_SCOPE = "production_loader"
|
||||
|
||||
if os.environ.get(RUN_ENV) == "1":
|
||||
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
|
||||
os.environ.setdefault("DIFFUSERS_ATTN_BACKEND", "native")
|
||||
os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
|
||||
os.environ.setdefault("MASTER_PORT", "29659")
|
||||
os.environ.setdefault("RANK", "0")
|
||||
os.environ.setdefault("WORLD_SIZE", "1")
|
||||
os.environ.setdefault("LOCAL_RANK", "0")
|
||||
# Put the pinned checkout ahead of the environment's Diffusers installation
|
||||
# before any FastVideo import can populate ``sys.modules``.
|
||||
if OFFICIAL_SRC.is_dir():
|
||||
sys.path.insert(0, str(OFFICIAL_SRC))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _distributed_runtime():
|
||||
if os.environ.get(RUN_ENV) != "1":
|
||||
yield
|
||||
return
|
||||
|
||||
from fastvideo.distributed import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
|
||||
|
||||
maybe_init_distributed_environment_and_model_parallel(1, 1)
|
||||
yield
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
|
||||
def _require_assets(partition: str) -> tuple[torch.device, Path]:
|
||||
if os.environ.get(RUN_ENV) != "1":
|
||||
pytest.skip(f"set {RUN_ENV}=1 on an allocated CUDA node")
|
||||
if not torch.cuda.is_available() or not torch.cuda.is_bf16_supported():
|
||||
pytest.fail("MiniMax H3 DiT parity requires a bf16-capable CUDA GPU", pytrace=False)
|
||||
if not OFFICIAL_SRC.is_dir():
|
||||
pytest.fail(f"pinned Diffusers MiniMax H3 checkout is missing: {OFFICIAL_REF_DIR}", pytrace=False)
|
||||
|
||||
component_dir = MODEL_ROOT / partition
|
||||
required = (component_dir / "config.json", component_dir / "diffusion_pytorch_model.safetensors.index.json")
|
||||
missing = [str(path) for path in required if not path.is_file()]
|
||||
if missing:
|
||||
pytest.fail(f"MiniMax H3 {partition} assets are missing: {missing}", pytrace=False)
|
||||
return torch.device("cuda:0"), component_dir
|
||||
|
||||
|
||||
def _make_inputs() -> dict[str, torch.Tensor]:
|
||||
"""Create a small padless packed layout that exercises all three modalities."""
|
||||
generator = torch.Generator(device="cpu").manual_seed(20260803)
|
||||
num_text_tokens = 2
|
||||
num_audio_tokens = 2
|
||||
num_video_tokens = 4
|
||||
sequence_length = num_text_tokens + num_audio_tokens + num_video_tokens
|
||||
|
||||
text_indices = torch.arange(num_text_tokens)
|
||||
audio_indices = torch.arange(num_text_tokens, num_text_tokens + num_audio_tokens)
|
||||
video_indices = torch.arange(num_text_tokens + num_audio_tokens, sequence_length)
|
||||
|
||||
token_tags = torch.empty(sequence_length, dtype=torch.long)
|
||||
token_tags[text_indices] = 1
|
||||
token_tags[audio_indices] = 2
|
||||
token_tags[video_indices] = 0
|
||||
|
||||
timestep_indices = torch.zeros(sequence_length, dtype=torch.long)
|
||||
timestep_indices[audio_indices] = 1
|
||||
timestep_indices[video_indices[:2]] = 2
|
||||
|
||||
position_ids = torch.zeros(sequence_length, 3, dtype=torch.float32)
|
||||
position_ids[:, 0] = torch.arange(sequence_length, dtype=torch.float32)
|
||||
position_ids[video_indices, 1] = torch.arange(num_video_tokens, dtype=torch.float32) % 2
|
||||
position_ids[video_indices, 2] = torch.arange(num_video_tokens, dtype=torch.float32) // 2
|
||||
|
||||
return {
|
||||
"hidden_states": torch.randn(1, num_video_tokens, 96, generator=generator),
|
||||
"audio_hidden_states": torch.randn(1, num_audio_tokens, 32, generator=generator),
|
||||
"encoder_hidden_states": torch.randn(1, num_text_tokens, 5120, generator=generator),
|
||||
"timestep": torch.tensor([0.7, 0.3, 0.999], dtype=torch.float32),
|
||||
"timestep_indices": timestep_indices,
|
||||
"token_tags": token_tags,
|
||||
"position_ids": position_ids,
|
||||
"video_indices": video_indices,
|
||||
"audio_indices": audio_indices,
|
||||
"text_indices": text_indices,
|
||||
}
|
||||
|
||||
|
||||
def _to_device(inputs: dict[str, torch.Tensor], device: torch.device) -> dict[str, torch.Tensor]:
|
||||
return {name: tensor.to(device) for name, tensor in inputs.items()}
|
||||
|
||||
|
||||
def _assert_mixed_dtype_contract(model: torch.nn.Module) -> None:
|
||||
parameters = dict(model.named_parameters())
|
||||
assert parameters["proj_in.weight"].dtype == torch.float32
|
||||
assert parameters["audio_proj_in.weight"].dtype == torch.float32
|
||||
assert parameters["time_embedder.fc_in.weight" if "time_embedder.fc_in.weight" in parameters
|
||||
else "time_embedder.linear_1.weight"].dtype == torch.float32
|
||||
assert parameters["proj_out.weight"].dtype == torch.float32
|
||||
assert parameters["audio_proj_out.weight"].dtype == torch.float32
|
||||
assert parameters["context_embedder.weight"].dtype == torch.bfloat16
|
||||
assert parameters["transformer_blocks.0.attn.to_q.weight"].dtype == torch.bfloat16
|
||||
|
||||
|
||||
def _load_official(component_dir: Path, device: torch.device) -> torch.nn.Module:
|
||||
from diffusers.models.transformers.transformer_minimax_h3 import MiniMaxH3Transformer3DModel
|
||||
from tests.local_tests.minimax_h3._reference import assert_reference_source
|
||||
|
||||
assert_reference_source(
|
||||
MiniMaxH3Transformer3DModel,
|
||||
"src/diffusers/models/transformers/transformer_minimax_h3.py",
|
||||
)
|
||||
|
||||
model, loading_info = MiniMaxH3Transformer3DModel.from_pretrained(
|
||||
component_dir,
|
||||
local_files_only=True,
|
||||
low_cpu_mem_usage=True,
|
||||
torch_dtype=torch.bfloat16,
|
||||
device_map=device,
|
||||
output_loading_info=True,
|
||||
)
|
||||
load_errors = {
|
||||
name: loading_info.get(name, [])
|
||||
for name in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs")
|
||||
if loading_info.get(name)
|
||||
}
|
||||
assert not load_errors, f"official MiniMax H3 checkpoint did not load strictly: {load_errors}"
|
||||
model = model.eval()
|
||||
_assert_mixed_dtype_contract(model)
|
||||
return model
|
||||
|
||||
|
||||
def _production_loader_args() -> SimpleNamespace:
|
||||
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
|
||||
|
||||
return SimpleNamespace(
|
||||
pipeline_config=MiniMaxH3PipelineConfig(),
|
||||
model_paths={},
|
||||
override_transformer_cls_name=None,
|
||||
init_weights_from_safetensors=None,
|
||||
hsdp_replicate_dim=1,
|
||||
hsdp_shard_dim=1,
|
||||
dit_cpu_offload=False,
|
||||
pin_cpu_memory=False,
|
||||
use_fsdp_inference=True,
|
||||
training_mode=False,
|
||||
enable_torch_compile=False,
|
||||
torch_compile_kwargs={},
|
||||
inference_mode=True,
|
||||
dit_layerwise_offload=False,
|
||||
)
|
||||
|
||||
|
||||
def _load_fastvideo(component_dir: Path) -> torch.nn.Module:
|
||||
from fastvideo.models.loader.component_loader import TransformerLoader
|
||||
|
||||
model = TransformerLoader().load(str(component_dir), _production_loader_args())
|
||||
_assert_mixed_dtype_contract(model)
|
||||
return model
|
||||
|
||||
|
||||
def _run_official(
|
||||
model: torch.nn.Module,
|
||||
inputs: dict[str, torch.Tensor],
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
with torch.inference_mode():
|
||||
video, audio = model(**inputs, return_dict=False)
|
||||
return video.detach().float().cpu(), audio.detach().float().cpu()
|
||||
|
||||
|
||||
def _run_fastvideo(
|
||||
model: torch.nn.Module,
|
||||
inputs: dict[str, torch.Tensor],
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
from fastvideo.forward_context import set_forward_context
|
||||
|
||||
with torch.inference_mode(), set_forward_context(current_timestep=0, attn_metadata=None):
|
||||
video, audio = model(**inputs)
|
||||
return video.detach().float().cpu(), audio.detach().float().cpu()
|
||||
|
||||
|
||||
def _reclaim_vram() -> None:
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def _assert_head_parity(name: str, actual: torch.Tensor, expected: torch.Tensor) -> None:
|
||||
assert actual.shape == expected.shape
|
||||
drift = (actual - expected).abs()
|
||||
reference_abs_mean = expected.abs().mean().item()
|
||||
relative_mean_drift = drift.mean().item() / max(reference_abs_mean, 1e-8)
|
||||
print(
|
||||
f"{name}: reference_abs_mean={reference_abs_mean:.8f} "
|
||||
f"max_abs={drift.max().item():.8f} mean_abs={drift.mean().item():.8f} "
|
||||
f"relative_mean={relative_mean_drift:.8f}",
|
||||
flush=True,
|
||||
)
|
||||
assert relative_mean_drift == 0.0
|
||||
assert_close(actual, expected, atol=0.0, rtol=0.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("partition", ("transformer", "transformer_ref"))
|
||||
def test_minimax_h3_transformer_parity(partition: str) -> None:
|
||||
"""Match both DiT partitions against the pinned official implementation."""
|
||||
device, component_dir = _require_assets(partition)
|
||||
cpu_inputs = _make_inputs()
|
||||
|
||||
official = _load_official(component_dir, device)
|
||||
expected_video, expected_audio = _run_official(official, _to_device(cpu_inputs, device))
|
||||
del official
|
||||
_reclaim_vram()
|
||||
|
||||
fastvideo = _load_fastvideo(component_dir)
|
||||
actual_video, actual_audio = _run_fastvideo(fastvideo, _to_device(cpu_inputs, device))
|
||||
del fastvideo
|
||||
_reclaim_vram()
|
||||
|
||||
_assert_head_parity(f"{partition}.video", actual_video, expected_video)
|
||||
_assert_head_parity(f"{partition}.audio", actual_audio, expected_audio)
|
||||
@@ -0,0 +1,174 @@
|
||||
# Copyright 2025 MiniMax authors and HuggingFace Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Production-loader parity for the MiniMax H3 audio VAE."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
OFFICIAL_REF_DIR = Path(os.environ.get("MINIMAX_H3_OFFICIAL_REF_DIR", REPO_ROOT / "DiffusersMiniMaxH3"))
|
||||
OFFICIAL_SRC = OFFICIAL_REF_DIR / "src"
|
||||
MODEL_ROOT = Path(os.environ.get("MINIMAX_H3_MODEL_ROOT", REPO_ROOT / "official_weights" / "MiniMax-H3"))
|
||||
RUN_ENV = "MINIMAX_H3_RUN_AUDIO_VAE_PARITY"
|
||||
PARITY_SCOPE = "production_loader"
|
||||
|
||||
if os.environ.get(RUN_ENV) == "1":
|
||||
os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
|
||||
os.environ.setdefault("MASTER_PORT", "29663")
|
||||
os.environ.setdefault("RANK", "0")
|
||||
os.environ.setdefault("WORLD_SIZE", "1")
|
||||
os.environ.setdefault("LOCAL_RANK", "0")
|
||||
if OFFICIAL_SRC.is_dir():
|
||||
sys.path.insert(0, str(OFFICIAL_SRC))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _distributed_runtime():
|
||||
if os.environ.get(RUN_ENV) != "1":
|
||||
yield
|
||||
return
|
||||
|
||||
from fastvideo.distributed import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
|
||||
|
||||
maybe_init_distributed_environment_and_model_parallel(1, 1)
|
||||
yield
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
|
||||
def _require_assets() -> tuple[torch.device, Path]:
|
||||
if os.environ.get(RUN_ENV) != "1":
|
||||
pytest.skip(f"set {RUN_ENV}=1 on an allocated CUDA node")
|
||||
if not torch.cuda.is_available():
|
||||
pytest.fail("MiniMax H3 audio VAE parity requires CUDA", pytrace=False)
|
||||
if not OFFICIAL_SRC.is_dir():
|
||||
pytest.fail(f"pinned Diffusers MiniMax H3 checkout is missing: {OFFICIAL_REF_DIR}", pytrace=False)
|
||||
|
||||
component_dir = MODEL_ROOT / "audio_vae"
|
||||
required = (component_dir / "config.json", component_dir / "diffusion_pytorch_model.safetensors")
|
||||
missing = [str(path) for path in required if not path.is_file()]
|
||||
if missing:
|
||||
pytest.fail(f"MiniMax H3 audio VAE assets are missing: {missing}", pytrace=False)
|
||||
return torch.device("cuda:0"), component_dir
|
||||
|
||||
|
||||
def _load_official(component_dir: Path, device: torch.device) -> torch.nn.Module:
|
||||
from diffusers.models.autoencoders.autoencoder_kl_minimax_h3_audio import AutoencoderKLMiniMaxH3Audio
|
||||
from tests.local_tests.minimax_h3._reference import assert_reference_source
|
||||
|
||||
assert_reference_source(
|
||||
AutoencoderKLMiniMaxH3Audio,
|
||||
"src/diffusers/models/autoencoders/autoencoder_kl_minimax_h3_audio.py",
|
||||
)
|
||||
|
||||
model, loading_info = AutoencoderKLMiniMaxH3Audio.from_pretrained(
|
||||
component_dir,
|
||||
local_files_only=True,
|
||||
low_cpu_mem_usage=True,
|
||||
torch_dtype=torch.float32,
|
||||
device_map=device,
|
||||
output_loading_info=True,
|
||||
)
|
||||
load_errors = {
|
||||
name: loading_info.get(name, [])
|
||||
for name in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs")
|
||||
if loading_info.get(name)
|
||||
}
|
||||
assert not load_errors, f"official MiniMax H3 audio VAE did not load strictly: {load_errors}"
|
||||
model = model.eval()
|
||||
assert all(parameter.dtype == torch.float32 for parameter in model.parameters())
|
||||
return model
|
||||
|
||||
|
||||
def _load_fastvideo(component_dir: Path) -> torch.nn.Module:
|
||||
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
|
||||
from fastvideo.models.loader.component_loader import AudioDecoderLoader
|
||||
|
||||
args = SimpleNamespace(
|
||||
pipeline_config=MiniMaxH3PipelineConfig(),
|
||||
vae_cpu_offload=False,
|
||||
)
|
||||
model = AudioDecoderLoader().load(str(component_dir), args)
|
||||
assert all(parameter.dtype == torch.float32 for parameter in model.parameters())
|
||||
return model
|
||||
|
||||
|
||||
def _make_waveform() -> torch.Tensor:
|
||||
generator = torch.Generator(device="cpu").manual_seed(20260803)
|
||||
# An intentionally non-divisible length exercises the production right-pad
|
||||
# path for the model's 800-sample hop length.
|
||||
return torch.randn(1, 1, 1601, generator=generator, dtype=torch.float32)
|
||||
|
||||
|
||||
def _run(model: torch.nn.Module, waveform: torch.Tensor) -> dict[str, torch.Tensor]:
|
||||
with torch.inference_mode():
|
||||
posterior = model.encode(waveform, return_dict=False)[0]
|
||||
latents = posterior.mode()
|
||||
decoded = model.decode(latents, return_dict=False)[0]
|
||||
return {
|
||||
"mean": posterior.mean.detach().cpu(),
|
||||
"logs": posterior.logs.detach().cpu(),
|
||||
"decoded": decoded.detach().cpu(),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_with_config(model: torch.nn.Module, latents: torch.Tensor) -> torch.Tensor:
|
||||
mean = torch.tensor(model.config.latents_mean, dtype=latents.dtype).view(1, -1, 1)
|
||||
std = torch.tensor(model.config.latents_std, dtype=latents.dtype).view(1, -1, 1)
|
||||
return (latents - mean) / std
|
||||
|
||||
|
||||
def _reclaim_vram() -> None:
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def _assert_tensor_parity(name: str, actual: torch.Tensor, expected: torch.Tensor, tolerance: float) -> None:
|
||||
assert actual.shape == expected.shape
|
||||
drift = (actual.float() - expected.float()).abs()
|
||||
reference_abs_mean = expected.float().abs().mean().item()
|
||||
relative_mean_drift = drift.mean().item() / max(reference_abs_mean, 1e-8)
|
||||
print(
|
||||
f"{name}: reference_abs_mean={reference_abs_mean:.8f} "
|
||||
f"max_abs={drift.max().item():.8f} mean_abs={drift.mean().item():.8f} "
|
||||
f"relative_mean={relative_mean_drift:.8f}",
|
||||
flush=True,
|
||||
)
|
||||
assert_close(actual, expected, atol=tolerance, rtol=tolerance)
|
||||
|
||||
|
||||
def test_minimax_h3_audio_vae_parity() -> None:
|
||||
"""Match posterior, normalization, hop geometry, and deterministic decode."""
|
||||
device, component_dir = _require_assets()
|
||||
waveform = _make_waveform()
|
||||
|
||||
official = _load_official(component_dir, device)
|
||||
expected = _run(official, waveform.to(device))
|
||||
expected_normalized = _normalize_with_config(official, expected["mean"])
|
||||
del official
|
||||
_reclaim_vram()
|
||||
|
||||
fastvideo = _load_fastvideo(component_dir)
|
||||
actual = _run(fastvideo, waveform.to(device))
|
||||
actual_normalized = _normalize_with_config(fastvideo, actual["mean"])
|
||||
assert fastvideo.hop_length == 800
|
||||
assert fastvideo.sampling_rate == 32000
|
||||
assert fastvideo.latent_channels == 32
|
||||
del fastvideo
|
||||
_reclaim_vram()
|
||||
|
||||
_assert_tensor_parity("audio_vae.mean", actual["mean"], expected["mean"], 0.0)
|
||||
_assert_tensor_parity("audio_vae.logs", actual["logs"], expected["logs"], 0.0)
|
||||
_assert_tensor_parity("audio_vae.normalized", actual_normalized, expected_normalized, 0.0)
|
||||
# The official and native FP32 BigVGAN paths differ only in the final
|
||||
# weight-norm rounding order (observed max_abs=2.4e-7 on GB200).
|
||||
_assert_tensor_parity("audio_vae.decode", actual["decoded"], expected["decoded"], 5e-7)
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright 2026 The MiniMax and HuggingFace Teams. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Production-loader parity for the MiniMax H3 video VAE."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
OFFICIAL_REF_DIR = Path(os.environ.get("MINIMAX_H3_OFFICIAL_REF_DIR", REPO_ROOT / "DiffusersMiniMaxH3"))
|
||||
OFFICIAL_SRC = OFFICIAL_REF_DIR / "src"
|
||||
MODEL_ROOT = Path(os.environ.get("MINIMAX_H3_MODEL_ROOT", REPO_ROOT / "official_weights" / "MiniMax-H3"))
|
||||
RUN_ENV = "MINIMAX_H3_RUN_VIDEO_VAE_PARITY"
|
||||
PARITY_SCOPE = "production_loader"
|
||||
|
||||
if os.environ.get(RUN_ENV) == "1":
|
||||
os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
|
||||
os.environ.setdefault("MASTER_PORT", "29663")
|
||||
os.environ.setdefault("RANK", "0")
|
||||
os.environ.setdefault("WORLD_SIZE", "1")
|
||||
os.environ.setdefault("LOCAL_RANK", "0")
|
||||
if OFFICIAL_SRC.is_dir():
|
||||
sys.path.insert(0, str(OFFICIAL_SRC))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _distributed_runtime():
|
||||
if os.environ.get(RUN_ENV) != "1":
|
||||
yield
|
||||
return
|
||||
|
||||
from fastvideo.distributed import cleanup_dist_env_and_memory, maybe_init_distributed_environment_and_model_parallel
|
||||
|
||||
maybe_init_distributed_environment_and_model_parallel(1, 1)
|
||||
yield
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
|
||||
def _require_assets() -> tuple[torch.device, Path]:
|
||||
if os.environ.get(RUN_ENV) != "1":
|
||||
pytest.skip(f"set {RUN_ENV}=1 on an allocated CUDA node")
|
||||
if not torch.cuda.is_available():
|
||||
pytest.fail("MiniMax H3 video VAE parity requires CUDA", pytrace=False)
|
||||
if not OFFICIAL_SRC.is_dir():
|
||||
pytest.fail(f"pinned Diffusers MiniMax H3 checkout is missing: {OFFICIAL_REF_DIR}", pytrace=False)
|
||||
|
||||
component_dir = MODEL_ROOT / "vae"
|
||||
required = (component_dir / "config.json", component_dir / "diffusion_pytorch_model.safetensors.index.json")
|
||||
missing = [str(path) for path in required if not path.is_file()]
|
||||
if missing:
|
||||
pytest.fail(f"MiniMax H3 video VAE assets are missing: {missing}", pytrace=False)
|
||||
return torch.device("cuda:0"), component_dir
|
||||
|
||||
|
||||
def _load_official(component_dir: Path, device: torch.device) -> torch.nn.Module:
|
||||
from diffusers.models.autoencoders.autoencoder_kl_minimax_h3 import AutoencoderKLMiniMaxH3
|
||||
from tests.local_tests.minimax_h3._reference import assert_reference_source
|
||||
|
||||
assert_reference_source(
|
||||
AutoencoderKLMiniMaxH3,
|
||||
"src/diffusers/models/autoencoders/autoencoder_kl_minimax_h3.py",
|
||||
)
|
||||
|
||||
model, loading_info = AutoencoderKLMiniMaxH3.from_pretrained(
|
||||
component_dir,
|
||||
local_files_only=True,
|
||||
low_cpu_mem_usage=True,
|
||||
torch_dtype=torch.float32,
|
||||
device_map=device,
|
||||
output_loading_info=True,
|
||||
)
|
||||
load_errors = {
|
||||
name: loading_info.get(name, [])
|
||||
for name in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs")
|
||||
if loading_info.get(name)
|
||||
}
|
||||
assert not load_errors, f"official MiniMax H3 video VAE did not load strictly: {load_errors}"
|
||||
model = model.eval()
|
||||
assert all(parameter.dtype == torch.float32 for parameter in model.parameters())
|
||||
return model
|
||||
|
||||
|
||||
def _load_fastvideo(component_dir: Path) -> torch.nn.Module:
|
||||
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
|
||||
from fastvideo.models.loader.component_loader import VAELoader
|
||||
|
||||
args = SimpleNamespace(
|
||||
pipeline_config=MiniMaxH3PipelineConfig(),
|
||||
model_paths={},
|
||||
vae_cpu_offload=False,
|
||||
)
|
||||
model = VAELoader().load(str(component_dir), args)
|
||||
assert all(parameter.dtype == torch.float32 for parameter in model.parameters())
|
||||
return model
|
||||
|
||||
|
||||
def _make_video() -> torch.Tensor:
|
||||
generator = torch.Generator(device="cpu").manual_seed(20260803)
|
||||
# Two logical 17-frame clips after padding are required for H3's three-token
|
||||
# temporal overlap contract; 32 px remains safely above reflect-pad minima.
|
||||
return torch.randn(1, 3, 22, 32, 32, generator=generator, dtype=torch.float32)
|
||||
|
||||
|
||||
def _run(model: torch.nn.Module, video: torch.Tensor) -> dict[str, torch.Tensor]:
|
||||
with torch.inference_mode():
|
||||
posterior = model.encode(video, return_dict=False)[0]
|
||||
latents = posterior.mode()
|
||||
if hasattr(model, "normalize_latents"):
|
||||
normalized = model.normalize_latents(latents)
|
||||
else:
|
||||
mean = torch.tensor(model.config.latents_mean, device=latents.device, dtype=latents.dtype).view(
|
||||
1, -1, 1, 1, 1
|
||||
)
|
||||
std = torch.tensor(model.config.latents_std, device=latents.device, dtype=latents.dtype).view(
|
||||
1, -1, 1, 1, 1
|
||||
)
|
||||
normalized = (latents - mean) / std
|
||||
decoded = model.decode(latents, return_dict=False)[0]
|
||||
return {
|
||||
"mean": posterior.mean.detach().cpu(),
|
||||
"logvar": posterior.logvar.detach().cpu(),
|
||||
"normalized": normalized.detach().cpu(),
|
||||
"decoded": decoded.detach().cpu(),
|
||||
}
|
||||
|
||||
|
||||
def _reclaim_vram() -> None:
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def _assert_tensor_parity(name: str, actual: torch.Tensor, expected: torch.Tensor, tolerance: float) -> None:
|
||||
assert actual.shape == expected.shape
|
||||
drift = (actual.float() - expected.float()).abs()
|
||||
reference_abs_mean = expected.float().abs().mean().item()
|
||||
relative_mean_drift = drift.mean().item() / max(reference_abs_mean, 1e-8)
|
||||
print(
|
||||
f"{name}: reference_abs_mean={reference_abs_mean:.8f} "
|
||||
f"max_abs={drift.max().item():.8f} mean_abs={drift.mean().item():.8f} "
|
||||
f"relative_mean={relative_mean_drift:.8f}",
|
||||
flush=True,
|
||||
)
|
||||
assert_close(actual, expected, atol=tolerance, rtol=tolerance)
|
||||
|
||||
|
||||
def test_minimax_h3_video_vae_parity() -> None:
|
||||
"""Match posterior, normalization, geometry, and deterministic decode."""
|
||||
device, component_dir = _require_assets()
|
||||
video = _make_video()
|
||||
|
||||
official = _load_official(component_dir, device)
|
||||
expected = _run(official, video.to(device))
|
||||
del official
|
||||
_reclaim_vram()
|
||||
|
||||
fastvideo = _load_fastvideo(component_dir)
|
||||
actual = _run(fastvideo, video.to(device))
|
||||
assert fastvideo.temporal_compression_ratio == 4
|
||||
assert fastvideo.spatial_compression_ratio == 16
|
||||
del fastvideo
|
||||
_reclaim_vram()
|
||||
|
||||
_assert_tensor_parity("video_vae.mean", actual["mean"], expected["mean"], 0.0)
|
||||
_assert_tensor_parity("video_vae.logvar", actual["logvar"], expected["logvar"], 0.0)
|
||||
_assert_tensor_parity("video_vae.normalized", actual["normalized"], expected["normalized"], 0.0)
|
||||
_assert_tensor_parity("video_vae.decode", actual["decoded"], expected["decoded"], 0.0)
|
||||
Reference in New Issue
Block a user