Add support for MiniMax H3 T2V and I2V training

This commit is contained in:
Jaret Burkett 2026-08-03 10:17:39 -06:00
parent 73cab2acf5
commit 8502a845b1
28 changed files with 4095 additions and 41 deletions

View File

@ -57,6 +57,7 @@ AI Toolkit is an easy to use all in one training suite for diffusion models. I t
- [Wan-AI/Wan2.2-TI2V-5B-Diffusers](https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B-Diffusers) (Wan 2.2 TI2V 5B)
- [Lightricks/LTX-2](https://huggingface.co/Lightricks/LTX-2) (LTX-2)
- [Lightricks/LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3) (LTX-2.3)
- [MiniMaxAI/MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) (MiniMaxAI/MiniMax-H3)
### Audio
- [ACE-Step/Ace-Step1.5](https://huggingface.co/ACE-Step/Ace-Step1.5) (Ace Step 1.5)

View File

@ -19,6 +19,7 @@ from .prx_pixel_t2i import PRXPixelT2IModel
from .krea2 import Krea2Model
from .boogu_image import BooguImageModel, BooguImageEditModel
from .mageflow import MageFlowModel, MageFlowEditModel
from .minimax_h3 import MinimaxH3Model
AI_TOOLKIT_MODELS = [
# put a list of models here
@ -54,4 +55,5 @@ AI_TOOLKIT_MODELS = [
BooguImageEditModel,
MageFlowModel,
MageFlowEditModel,
MinimaxH3Model,
]

View File

@ -0,0 +1 @@
from .minimax_h3 import MinimaxH3Model

View File

@ -0,0 +1,947 @@
"""MiniMax-H3 (33B joint video+audio DiT) for ai-toolkit.
Supports t2v (t2va) and first-frame i2v (fl2va) training and sampling, with
joint audio when the dataset provides it. The architecture lives in ./src/:
- transformer.py: packed-sequence DiT, weight-compatible with the original
``MiniMaxAI/MiniMax-H3`` checkpoint keys
- vae.py / audio_vae.py: the video VAE (causal CNN encoder + ViT decoder,
16x/17n+5->5n+2) and the waveform audio VAE (DAC/BigVGAN, 32 kHz, 40 Hz)
- packing.py: packed-sequence geometry, rotary grids, sigma-shift math
- text_encoder.py: Qwen3-VL-32B conditioning (unnormalized hidden_states[50],
"<Picture i>: " + vision block presentation for keyframes)
- pipeline.py: the released sampler (no CFG the model is guidance-distilled)
Weights load from the Comfy-Org repack (``Comfy-Org/MiniMax-H3``) by default:
the pruned int8-ConvRot transformer, the nvfp4 AWQ Qwen3-VL text encoder (kept
quantized through the toolkit's Ostris quantization backends — convrot8 and
nvfp4 with dequantized-matmul fallbacks for GPUs without the fast kernels),
and the fp16/fp32 single-file VAEs. Files are resolved under ``MODELS_PATH``
(checked first, both at the repo-relative location and flat at the root) and
downloaded from the hub into ``MODELS_PATH`` when missing. Individual files
can be overridden via ``model_kwargs``: ``dit_path``, ``text_encoder_path``,
``video_vae_path``, ``audio_vae_path``; ``model_kwargs.partition`` picks
``fl2va`` (default) or ``ref2va``.
Conventions bridged to ai-toolkit:
- the model consumes t = 1 - sigma in [0, 1] (t=1 clean) and predicts the
data-ward velocity ``clean - noise``; ai-toolkit targets ``noise - clean``
on a 0..1000 timestep scale, so timesteps are flipped and the prediction
negated in get_noise_prediction
- the audio stream runs on its own flow shift (3 vs video's 12): its sigma
is derived per step from the video sigma via the closed-form remap, in
training and sampling alike
"""
import os
from functools import partial
from typing import TYPE_CHECKING, List, Optional
import torch
import yaml
from PIL import Image
from safetensors.torch import load_file, save_file
from toolkit.accelerator import unwrap_model
from toolkit.advanced_prompt_embeds import AdvancedPromptEmbeds
from toolkit.basic import flush
from toolkit.config_modules import GenerateImageConfig, ModelConfig
from toolkit.memory_management import MemoryManager
from toolkit.metadata import get_meta_for_safetensors
from toolkit.models.base_model import BaseModel
from toolkit.paths import MODELS_PATH
from toolkit.util.comfy_quant_import import import_comfy_quantized_layers
from toolkit.util.ostris_quant import OstrisLinear
from toolkit.samplers.custom_flowmatch_sampler import (
CustomFlowMatchEulerDiscreteScheduler,
)
from toolkit.util.quantize import get_qtype, quantize, quantize_model
from optimum.quanto import freeze
from .src import packing
from .src.audio_vae import MiniMaxH3AudioVAE, fold_audio_vae_weight_norm
from .src.packing import (
KEYFRAME_ENCODE_SEED,
KEYFRAME_NOISE_AUG_T,
build_packed_sequence,
pack_audio_latents,
pad_layouts_to_batch,
patchify_video_latents,
remap_sigma,
unpatchify_video_tokens,
)
from .src.pipeline import MiniMaxH3Pipeline
from .src.text_encoder import TEXT_ENCODER_LAYER, encode_minimax_h3_prompt
from .src.transformer import MiniMaxH3Transformer, MiniMaxH3TransformerParams
from .src.vae import MiniMaxH3VideoVAE
if TYPE_CHECKING:
from toolkit.data_transfer_object.data_loader import DataLoaderBatchDTO
scheduler_config = {
"num_train_timesteps": 1000,
"shift": packing.VIDEO_SIGMA_SHIFT,
"use_dynamic_shifting": False,
}
# Comfy-Org repack of the released weights, at ComfyUI's repo-relative paths
# under MODELS_PATH (diffusion_models/, text_encoders/, vae/). Files are used
# in place when present and downloaded to exactly these locations only when
# missing.
COMFY_REPO = "Comfy-Org/MiniMax-H3"
COMFY_FILES = {
"dit_fl2va": "diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors",
"dit_ref2va": "diffusion_models/minimax_h3_ref2va_pruned_int8_convrot.safetensors",
"text_encoder": "text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"video_vae": "vae/minimax_h3_video_vae_fp16.safetensors",
"audio_vae": "vae/minimax_h3_audio_vae_fp32.safetensors",
}
# tokenizer/processor/text-encoder config come from the original repo (tiny files)
ORIGINAL_REPO = "MiniMaxAI/MiniMax-H3"
def new_save_image_function(
self: GenerateImageConfig, image, count=0, max_count=0, **kwargs
):
# video (+ audio) previews save as mp4
try:
from diffusers.utils import encode_video
except ImportError:
from diffusers.pipelines.ltx2.export_utils import encode_video
image["output_path"] = self.get_image_path(count, max_count)
os.makedirs(os.path.dirname(image["output_path"]), exist_ok=True)
if image.get("audio", None) is None:
image.pop("audio", None)
image.pop("audio_sample_rate", None)
encode_video(**image)
flush()
def blank_log_image_function(self, *args, **kwargs):
# todo handle wandb logging of videos with audio
return
class MiniMaxH3VaeBundle(torch.nn.Module):
"""Holds both frozen autoencoders behind the single ``self.vae`` handle."""
def __init__(self, video_vae: MiniMaxH3VideoVAE, audio_vae: MiniMaxH3AudioVAE):
super().__init__()
self.video_vae = video_vae
self.audio_vae = audio_vae
@property
def device(self):
return self.video_vae.device
@property
def dtype(self):
return self.video_vae.dtype
class MinimaxH3Model(BaseModel):
arch = "minimax_h3"
use_old_lokr_format = False
def __init__(
self,
device,
model_config: ModelConfig,
dtype="bf16",
custom_pipeline=None,
noise_scheduler=None,
**kwargs,
):
super().__init__(
device, model_config, dtype, custom_pipeline, noise_scheduler, **kwargs
)
self.is_flow_matching = True
self.is_transformer = True
self.target_lora_modules = ["MiniMaxH3Transformer"]
self.supports_model_paths = True
# keyframes ride into the Qwen3-VL conditioning as vision blocks, so
# sampling (and control_path datasets) pass control images to
# get_prompt_embeds
self.encode_control_in_text_embeddings = True
self.processor = None # Qwen3VLProcessor
self._warned_frame_trim = False
self.latent_space_version = "minimax_h3_v1"
@staticmethod
def get_train_scheduler():
return CustomFlowMatchEulerDiscreteScheduler(**scheduler_config)
def get_bucket_divisibility(self):
# 16x VAE spatial compression * 2x2 transformer patch
return 32
def get_frame_count_snapper(self):
# auto_frame_count: snap dataset clips down to the VAE's 17n+5 grid
return packing.align_num_frames_down
@property
def video_vae(self) -> MiniMaxH3VideoVAE:
return self.vae.video_vae
@property
def audio_vae(self) -> MiniMaxH3AudioVAE:
return self.vae.audio_vae
# ------------------------------------------------------------------
# Loading
# ------------------------------------------------------------------
def _resolve_comfy_file(self, component: str) -> str:
"""Find a weight file at its local location, or download it there
when (and only when) it is missing.
Search order: model_kwargs override, the repo-relative path under
MODELS_PATH (diffusion_models/, text_encoders/, vae/), the bare
filename at the root as a fallback, the same spots under name_or_path
when it is a local folder, then the hub downloaded to the
repo-relative path under MODELS_PATH.
"""
override = self.model_config.model_kwargs.get(f"{component}_path", None)
if override is not None:
if not os.path.exists(override):
raise FileNotFoundError(
f"model_kwargs.{component}_path does not exist: {override}"
)
return override
rel_path = COMFY_FILES[component]
roots = [MODELS_PATH]
name_or_path = self.model_config.name_or_path
if name_or_path and os.path.isdir(name_or_path):
roots.append(name_or_path)
for root in roots:
for rel in (rel_path, os.path.basename(rel_path)):
candidate = os.path.join(root, rel)
if os.path.exists(candidate):
return candidate
import huggingface_hub
repo_id = COMFY_REPO
if name_or_path and not os.path.exists(name_or_path) and "/" in name_or_path:
repo_id = name_or_path
self.print_and_status_update(
f"Downloading {rel_path} from {repo_id} into {MODELS_PATH}"
)
return huggingface_hub.hf_hub_download(
repo_id=repo_id, filename=rel_path, local_dir=MODELS_PATH
)
def _dit_component(self) -> str:
partition = str(
self.model_config.model_kwargs.get("partition", "fl2va")
).lower()
if partition not in ("fl2va", "ref2va"):
raise ValueError(
f"model_kwargs.partition must be fl2va or ref2va, got {partition}"
)
return f"dit_{partition}"
def _load_transformer(self) -> MiniMaxH3Transformer:
dtype = self.torch_dtype
dit_path = self._resolve_comfy_file(self._dit_component())
self.print_and_status_update(f"Loading transformer from {dit_path}")
state_dict = load_file(dit_path)
params = MiniMaxH3TransformerParams()
table = state_dict.get("adaln_t_table", None)
if table is not None:
# pruned checkpoint: factored timestep table instead of the MLP
params.adaln_t_table_size = table.shape[0]
params.time_embed_dim = table.shape[1]
with torch.device("meta"):
transformer = MiniMaxH3Transformer(params)
# attach the pre-quantized (int8 ConvRot) linears onto the toolkit's
# quantization backends; the rest loads at its stored precision (the
# checkpoint's bf16/fp16/fp32 mix is deliberate)
state_dict, num_quantized = import_comfy_quantized_layers(
transformer, state_dict, orig_dtype=dtype
)
if num_quantized:
self.print_and_status_update(
f" - attached {num_quantized} pre-quantized ConvRot layers"
)
result = transformer.load_state_dict(state_dict, assign=True, strict=False)
quantized_weight_keys = {
f"{name}.weight"
for name, m in transformer.named_modules()
if isinstance(m, OstrisLinear)
}
bad_missing = [k for k in result.missing_keys if k not in quantized_weight_keys]
if bad_missing or result.unexpected_keys:
raise ValueError(
f"MiniMax-H3 transformer load mismatch: missing {bad_missing[:8]}, "
f"unexpected {result.unexpected_keys[:8]}"
)
del state_dict
flush()
return transformer
def _load_text_encoder(self):
from accelerate import init_empty_weights
from transformers import (
AutoConfig,
AutoProcessor,
AutoTokenizer,
Qwen3VLForConditionalGeneration,
)
tokenizer = AutoTokenizer.from_pretrained(
ORIGINAL_REPO, subfolder="FL2VA/tokenizer"
)
processor = AutoProcessor.from_pretrained(
ORIGINAL_REPO, subfolder="FL2VA/processor"
)
te_path = self.model_config.te_name_or_path
if te_path is not None and os.path.isdir(te_path):
# transformers-format folder (e.g. the original repo's text_encoder)
self.print_and_status_update(
f"Loading Qwen3-VL text encoder from {te_path}"
)
config = AutoConfig.from_pretrained(te_path)
config.text_config.num_hidden_layers = TEXT_ENCODER_LAYER
text_encoder = Qwen3VLForConditionalGeneration.from_pretrained(
te_path, config=config, torch_dtype=self.te_torch_dtype
)
else:
if te_path is not None:
te_file = te_path
else:
te_file = self._resolve_comfy_file("text_encoder")
self.print_and_status_update(
f"Loading Qwen3-VL text encoder from {te_file}"
)
# single-file ComfyUI checkpoint: 50 decoder layers, no final norm,
# no lm_head; LM linears nvfp4 (AWQ), embeddings int8, vision bf16
config = AutoConfig.from_pretrained(
ORIGINAL_REPO, subfolder="FL2VA/text_encoder"
)
# only hidden_states[50] is consumed: truncate the decoder stack to
# 50 layers; the final norm is neutralized below so
# hidden_states[-1] stays the unnormalized layer-49 output
config.text_config.num_hidden_layers = TEXT_ENCODER_LAYER
config.tie_word_embeddings = False
with init_empty_weights():
text_encoder = Qwen3VLForConditionalGeneration(config)
text_encoder.lm_head = None
state_dict = load_file(te_file)
def key_map(prefix: str) -> str:
if prefix.startswith("model."):
return "model.language_model." + prefix[len("model.") :]
if prefix.startswith("visual."):
return "model." + prefix
return prefix
state_dict, num_quantized = import_comfy_quantized_layers(
text_encoder,
state_dict,
orig_dtype=self.te_torch_dtype,
key_map=key_map,
)
self.print_and_status_update(
f" - attached {num_quantized} pre-quantized nvfp4/int8 layers"
)
state_dict = {
key_map(k[: k.rfind(".")]) + k[k.rfind(".") :]: v
for k, v in state_dict.items()
}
result = text_encoder.load_state_dict(state_dict, assign=True, strict=False)
quantized_keys = set()
for name, m in text_encoder.named_modules():
if isinstance(m, OstrisLinear):
quantized_keys.add(f"{name}.weight")
allowed_missing_prefixes = (
"lm_head",
"model.language_model.norm",
"model.language_model.embed_tokens",
)
bad_missing = [
k
for k in result.missing_keys
if k not in quantized_keys
and not k.startswith(allowed_missing_prefixes)
]
if bad_missing or result.unexpected_keys:
raise ValueError(
f"MiniMax-H3 text encoder load mismatch: missing {bad_missing[:8]}, "
f"unexpected {result.unexpected_keys[:8]}"
)
del state_dict
text_encoder.model.language_model.norm = torch.nn.Identity()
text_encoder.eval()
text_encoder.requires_grad_(False)
flush()
return tokenizer, processor, text_encoder
def _load_vaes(self) -> MiniMaxH3VaeBundle:
self.print_and_status_update("Loading video VAE")
video_sd = load_file(self._resolve_comfy_file("video_vae"))
# normalization stats ride along in the comfy file; the module holds
# them as non-persistent buffers, keep them float32
video_stats = {
k: video_sd.pop(k).float()
for k in ("latents_mean", "latents_std")
if k in video_sd
}
video_vae = MiniMaxH3VideoVAE()
video_vae.load_state_dict(video_sd, strict=True, assign=True)
for k, v in video_stats.items():
getattr(video_vae, k).copy_(v)
video_vae.eval().requires_grad_(False)
del video_sd
self.print_and_status_update("Loading audio VAE")
audio_sd = load_file(self._resolve_comfy_file("audio_vae"))
audio_stats = {
k: audio_sd.pop(k).float()
for k in ("latents_mean", "latents_std")
if k in audio_sd
}
# comfy repack ships the weight norm already folded; fold only if the
# raw parametrization is present (original-repo file)
if any(k.endswith("weight_g") for k in audio_sd.keys()):
audio_sd = fold_audio_vae_weight_norm(audio_sd)
audio_vae = MiniMaxH3AudioVAE()
audio_vae.load_state_dict(audio_sd, strict=True, assign=True)
for k, v in audio_stats.items():
getattr(audio_vae, k).copy_(v)
audio_vae.to(torch.float32).eval().requires_grad_(False)
del audio_sd
flush()
return MiniMaxH3VaeBundle(video_vae, audio_vae)
def load_model(self):
dtype = self.torch_dtype
self.print_and_status_update("Loading MiniMax-H3 model")
transformer = self._load_transformer()
if self.model_config.quantize:
self.print_and_status_update("Quantizing transformer")
quantize_model(self, transformer)
flush()
if (
self.model_config.layer_offloading
and self.model_config.layer_offloading_transformer_percent > 0
):
MemoryManager.attach(
transformer,
self.device_torch,
offload_percent=self.model_config.layer_offloading_transformer_percent,
)
if self.model_config.low_vram:
self.print_and_status_update("Keeping transformer on CPU")
transformer.to("cpu")
else:
transformer.to(self.device_torch)
flush()
tokenizer, processor, text_encoder = self._load_text_encoder()
te_prequantized = any(
isinstance(m, OstrisLinear) for m in text_encoder.modules()
)
if self.model_config.quantize_te and not te_prequantized:
self.print_and_status_update("Quantizing text encoder")
quantize(text_encoder, weights=get_qtype(self.model_config.qtype_te))
freeze(text_encoder)
flush()
elif self.model_config.quantize_te:
self.print_and_status_update(
"Text encoder is already nvfp4/int8 quantized; skipping quantize_te"
)
if (
self.model_config.layer_offloading
and self.model_config.layer_offloading_text_encoder_percent > 0
):
MemoryManager.attach(
text_encoder,
self.device_torch,
offload_percent=self.model_config.layer_offloading_text_encoder_percent,
)
if self.model_config.low_vram:
text_encoder.to("cpu")
else:
text_encoder.to(self.device_torch)
flush()
vae_bundle = self._load_vaes()
vae_bundle.to(self.vae_device_torch)
self.noise_scheduler = MinimaxH3Model.get_train_scheduler()
self.vae = vae_bundle
self.text_encoder = text_encoder
self.tokenizer = tokenizer
self.processor = processor
self.model = transformer
self.pipeline = MiniMaxH3Pipeline(self)
self.print_and_status_update("Model Loaded")
# ------------------------------------------------------------------
# Text conditioning
# ------------------------------------------------------------------
def get_prompt_embeds(self, prompt, control_images=None) -> AdvancedPromptEmbeds:
if isinstance(prompt, str):
prompt = [prompt]
if self.text_encoder.device == torch.device("cpu"):
self.text_encoder.to(self.device_torch)
# control tensors arrive in [0, 1]; the Qwen3-VL processor wants PIL
keyframes_per_prompt = [None] * len(prompt)
if control_images is not None:
if isinstance(control_images, torch.Tensor):
images = [control_images[i] for i in range(control_images.shape[0])]
elif isinstance(control_images, list):
images = [
c[0] if isinstance(c, torch.Tensor) and c.ndim == 4 else c
for c in control_images
]
else:
images = [control_images]
pil_images = []
for img in images:
if isinstance(img, torch.Tensor):
if img.ndim == 4:
img = img[0]
arr = (img.float().clamp(0, 1) * 255).round().to(torch.uint8)
pil_images.append(
Image.fromarray(arr.permute(1, 2, 0).cpu().numpy())
)
else:
pil_images.append(img)
if len(pil_images) == 1:
keyframes_per_prompt = [pil_images] * len(prompt)
elif len(pil_images) == len(prompt):
keyframes_per_prompt = [[img] for img in pil_images]
else:
keyframes_per_prompt = [pil_images] * len(prompt)
embeds_list, tags_list = [], []
for p, keyframes in zip(prompt, keyframes_per_prompt):
embeds, tags = encode_minimax_h3_prompt(
self.text_encoder,
self.tokenizer,
self.processor,
p.strip(),
keyframes=keyframes,
device=self.device_torch,
dtype=self.torch_dtype,
)
embeds_list.append(embeds)
tags_list.append(tags)
pe = AdvancedPromptEmbeds(text_embeds=embeds_list, text_token_tags=tags_list)
pe.frozen_dtype_keys = ["text_token_tags"]
return pe
# ------------------------------------------------------------------
# VAE encode / decode
# ------------------------------------------------------------------
@torch.no_grad()
def encode_images(self, image_list, device=None, dtype=None):
"""Images (C, H, W) or videos (T, C, H, W) in [-1, 1] -> normalized
video latents (B, 24, t, h, w). Video frame counts are trimmed down to
the VAE's 17n+5 grid when needed."""
if device is None:
device = self.vae_device_torch
if dtype is None:
dtype = self.vae_torch_dtype
if self.vae.device == torch.device("cpu"):
self.vae.to(self.vae_device_torch)
items = []
for image in image_list:
if image.ndim == 3:
items.append(image.unsqueeze(1)) # (C, 1, H, W)
elif image.ndim == 4:
items.append(image.permute(1, 0, 2, 3)) # (C, T, H, W)
else:
raise ValueError(f"Invalid image shape: {image.shape}")
num_frames = items[0].shape[1]
if num_frames > 1:
aligned = packing.align_num_frames_down(num_frames)
if aligned != num_frames and not self._warned_frame_trim:
print(
f"MiniMax-H3: trimming {num_frames}-frame clips to {aligned} "
f"frames (the video VAE needs 17n+5: 5, 22, 39, 56, ...). Set "
f"the dataset num_frames accordingly to avoid wasted decode."
)
self._warned_frame_trim = True
items = [it[:, :aligned] for it in items]
batch = torch.stack(items).to(self.vae_device_torch, self.video_vae.dtype)
latents = self.video_vae.encode(batch, sample=True)
return latents.to(device, dtype=dtype)
@torch.no_grad()
def encode_keyframe_latents(self, frames: torch.Tensor) -> torch.Tensor:
"""(B, 3, 1, H, W) in [-1, 1] -> normalized latents (B, 24, 1, h, w),
with the released conditioning recipe: seeded posterior sample (seed
42, independent of the request seed) rounded to fp16 before
normalization."""
if self.vae.device == torch.device("cpu"):
self.vae.to(self.vae_device_torch)
generator = torch.Generator(device="cpu").manual_seed(KEYFRAME_ENCODE_SEED)
latents = self.video_vae.encode(
frames.to(self.vae_device_torch, self.video_vae.dtype),
sample=True,
generator=generator,
fp16_round=True,
)
return latents.float()
def decode_latents(self, latents: torch.Tensor, device=None, dtype=None):
# differentiable: pixel-space losses backprop through the video VAE
if self.vae.device == torch.device("cpu"):
self.vae.to(self.vae_device_torch)
video = self.video_vae.decode(latents.to(self.vae.device, self.video_vae.dtype))
if device is not None:
video = video.to(device, dtype=dtype)
return video
def decode_audio_latents(self, latents: torch.Tensor):
# differentiable, like decode_latents
"""(B, 32, T) normalized -> waveform (B, 1, T*800) at 32 kHz."""
if self.vae.device == torch.device("cpu"):
self.vae.to(self.vae_device_torch)
return self.audio_vae.decode(latents.to(self.audio_vae.device, torch.float32))
@torch.no_grad()
def encode_audio(self, audio_data_list):
"""[{"waveform": (C, L), "sample_rate": int}, ...] -> packed audio
rows (B, 2*T, 32), normalized, channel-major stereo."""
import torchaudio
if self.vae.device == torch.device("cpu"):
self.vae.to(self.device_torch)
packed = []
for audio_data in audio_data_list:
waveform = audio_data["waveform"].to(self.audio_vae.device, torch.float32)
sample_rate = int(audio_data["sample_rate"])
if waveform.dim() == 1:
waveform = waveform.unsqueeze(0)
if waveform.shape[0] == 1:
waveform = waveform.repeat(2, 1) # mono -> stereo
elif waveform.shape[0] > 2:
waveform = waveform[:2]
if sample_rate != packing.AUDIO_SAMPLE_RATE:
waveform = torchaudio.functional.resample(
waveform, sample_rate, packing.AUDIO_SAMPLE_RATE
)
# the mono VAE sees each stereo channel as its own batch item
z = self.audio_vae.encode(waveform.unsqueeze(1)) # (2, 32, T)
packed.append(pack_audio_latents(z.unsqueeze(0))) # (1, 2*T, 32)
max_len = max(p.shape[1] for p in packed)
packed = [
torch.nn.functional.pad(p, (0, 0, 0, max_len - p.shape[1])) for p in packed
]
return torch.cat(packed, dim=0).to(self.device_torch, self.torch_dtype)
# ------------------------------------------------------------------
# Training forward
# ------------------------------------------------------------------
def get_noise_prediction(
self,
latent_model_input: torch.Tensor, # (B, 24, t, h, w) noisy latents
timestep: torch.Tensor, # (B,) on the 0..1000 scale, 1000 = pure noise
text_embeddings: AdvancedPromptEmbeds,
batch: "DataLoaderBatchDTO" = None,
**kwargs,
):
device = self.device_torch
dtype = self.torch_dtype
if self.model.device == torch.device("cpu"):
self.model.to(device)
batch_size, _, t_lat, h_lat, w_lat = latent_model_input.shape
with torch.no_grad():
sigma_v = (timestep.to(device, torch.float32) / 1000.0).clamp(1e-6, 1.0)
if sigma_v.dim() == 0:
sigma_v = sigma_v.unsqueeze(0)
if sigma_v.shape[0] != batch_size:
sigma_v = sigma_v.expand(batch_size)
sigma_a = remap_sigma(sigma_v)
t_v = 1.0 - sigma_v
t_a = 1.0 - sigma_a
# --- i2v first-frame conditioning rows -------------------------
do_i2v = (
batch is not None
and batch.dataset_config.do_i2v
and getattr(batch, "num_frames", 1) > 1
)
cond_rows = None
if do_i2v:
if batch.first_frame_latents is not None:
first_latents = batch.first_frame_latents.to(device, torch.float32)
else:
frames = batch.tensor
if frames is None:
raise ValueError(
"do_i2v needs the first frame; no cached "
"first_frame_latents or raw tensors in batch"
)
first_frames = frames[:, 0] if frames.ndim == 5 else frames
first_latents = self.encode_keyframe_latents(
first_frames.unsqueeze(2).to(device)
)
if first_latents.ndim == 4:
first_latents = first_latents.unsqueeze(2)
cond_noise = torch.randn_like(first_latents)
first_latents = (
KEYFRAME_NOISE_AUG_T * first_latents
+ (1.0 - KEYFRAME_NOISE_AUG_T) * cond_noise
)
cond_rows = patchify_video_latents(first_latents).to(dtype)
# --- audio rows -------------------------------------------------
if batch is not None and getattr(batch, "num_frames", None):
num_frames = batch.num_frames
else:
# invert 17n+5 -> 5n+2 from the latent frame count
num_frames = (t_lat - 2) // 5 * 17 + 5 if t_lat > 1 else 1
a_lat = packing.audio_latent_num_frames(num_frames)
raw_audio = None
if batch is not None and batch.audio_latents is not None:
raw_audio = batch.audio_latents.to(device, torch.float32)
elif batch is not None and getattr(batch, "audio_data", None) is not None:
raw_audio = self.encode_audio(batch.audio_data).to(
device, torch.float32
)
sa = sigma_a.view(-1, 1, 1)
if raw_audio is not None:
expected_rows = a_lat * packing.AUDIO_CHANNELS
if raw_audio.shape[1] > expected_rows:
raw_audio = raw_audio[:, :expected_rows]
elif raw_audio.shape[1] < expected_rows:
raw_audio = torch.nn.functional.pad(
raw_audio, (0, 0, 0, expected_rows - raw_audio.shape[1])
)
audio_noise = torch.randn_like(raw_audio)
# model predicts clean - noise; audio_pred is negated below so
# the stored target follows ai-toolkit's noise - clean
batch.audio_target = (audio_noise - raw_audio).detach()
audio_rows = (1.0 - sa) * raw_audio + sa * audio_noise
else:
# no soundtrack: silence (zeros) noised at the audio sigma
# rides along without contributing to the loss
audio_rows = sa * torch.randn(
batch_size,
a_lat * packing.AUDIO_CHANNELS,
32,
device=device,
dtype=torch.float32,
)
# --- packed layout (per item: text lengths differ) --------------
layouts = []
anchors = ("first",) if cond_rows is not None else ()
for i in range(batch_size):
layouts.append(
build_packed_sequence(
text_token_tags=text_embeddings.text_token_tags[i].to("cpu"),
num_latent_frames=t_lat,
latent_height=h_lat,
latent_width=w_lat,
num_audio_latents=a_lat,
keyframe_anchors=anchors,
)
)
(
position_ids,
token_tags,
video_indices,
audio_indices,
text_indices,
_,
) = pad_layouts_to_batch(layouts)
num_cond = layouts[0].num_condition_video_rows
# per-row timesteps: text/video rows at t_v, audio rows at t_a,
# condition rows pinned at max(t_v, 0.999)
row_t = t_v.view(-1, 1).expand(-1, token_tags.shape[1]).clone()
row_t[:, audio_indices] = t_a.view(-1, 1)
if num_cond > 0:
cond_t = torch.maximum(t_v, torch.full_like(t_v, KEYFRAME_NOISE_AUG_T))
row_t[:, video_indices[:num_cond]] = cond_t.view(-1, 1)
# pad text embeds to the batch max length
max_text = int(text_indices.shape[0])
text_batch = torch.zeros(
batch_size,
max_text,
text_embeddings.text_embeds[0].shape[-1],
device=device,
dtype=dtype,
)
for i, emb in enumerate(text_embeddings.text_embeds):
text_batch[i, : emb.shape[0]] = emb.to(device, dtype)
video_rows = patchify_video_latents(
latent_model_input.to(device, torch.float32)
).to(dtype)
if cond_rows is not None:
video_rows = torch.cat([cond_rows, video_rows], dim=1)
video_pred, audio_pred = self.model(
hidden_states=video_rows,
audio_hidden_states=audio_rows.to(dtype),
encoder_hidden_states=text_batch,
row_timesteps=row_t.to(device),
token_tags=token_tags.to(device),
position_ids=position_ids.to(device),
video_indices=video_indices.to(device),
audio_indices=audio_indices.to(device),
text_indices=text_indices.to(device),
)
if batch is not None and batch.audio_target is not None:
# flip to ai-toolkit's noise - clean convention
batch.audio_pred = -audio_pred
video_pred = video_pred[:, num_cond:]
noise_pred = unpatchify_video_tokens(video_pred, t_lat, h_lat, w_lat)
return -noise_pred
def get_loss_target(self, *args, **kwargs):
noise = kwargs.get("noise")
batch = kwargs.get("batch")
return (noise - batch.latents).detach()
# ------------------------------------------------------------------
# Sampling (training previews)
# ------------------------------------------------------------------
def get_generation_pipeline(self):
return MiniMaxH3Pipeline(self)
def generate_single_image(
self,
pipeline: MiniMaxH3Pipeline,
gen_config: GenerateImageConfig,
conditional_embeds: AdvancedPromptEmbeds,
unconditional_embeds: AdvancedPromptEmbeds,
generator: torch.Generator,
extra: dict,
):
if self.model.device == torch.device("cpu"):
self.model.to(self.device_torch)
sc = self.get_bucket_divisibility()
gen_config.width = max(sc, int(gen_config.width // sc * sc))
gen_config.height = max(sc, int(gen_config.height // sc * sc))
is_video = gen_config.num_frames > 1
if is_video:
gen_config.num_frames = packing.align_num_frames_down(gen_config.num_frames)
gen_config.fps = packing.FPS
gen_config.save_image = partial(new_save_image_function, gen_config)
gen_config.log_image = partial(blank_log_image_function, gen_config)
gen_config.output_ext = "mp4"
ctrl_img = None
if gen_config.ctrl_img is not None:
ctrl_img = Image.open(gen_config.ctrl_img).convert("RGB")
ctrl_img = packing.prepare_keyframe_image(
ctrl_img, gen_config.height, gen_config.width, stretch=True
)
with_audio = bool(self.model_config.model_kwargs.get("sample_audio", True))
result = pipeline(
conditional_embeds=conditional_embeds,
unconditional_embeds=unconditional_embeds,
height=gen_config.height,
width=gen_config.width,
num_frames=gen_config.num_frames,
num_inference_steps=gen_config.num_inference_steps,
guidance_scale=gen_config.guidance_scale,
latents=gen_config.latents,
generator=generator,
ctrl_img=ctrl_img,
with_audio=with_audio and is_video,
)
if is_video:
return result # dict consumed by new_save_image_function
return result[0]
# ------------------------------------------------------------------
# Saving / bookkeeping
# ------------------------------------------------------------------
def get_model_has_grad(self):
return False
def get_te_has_grad(self):
return False
def save_model(self, output_path, meta, save_dtype):
from toolkit.util.quantize import dequantize_if_quantized
transformer: MiniMaxH3Transformer = unwrap_model(self.model)
os.makedirs(os.path.join(output_path, "transformer"), exist_ok=True)
state_dict = transformer.state_dict()
save_dict = {}
for k, v in state_dict.items():
v = dequantize_if_quantized(v)
if v.is_floating_point() and not k.startswith(
MiniMaxH3Transformer.FP32_KEY_PREFIXES
):
v = v.to(save_dtype)
save_dict[k] = v.clone().to("cpu")
meta_st = get_meta_for_safetensors(meta, name="minimax_h3")
save_file(
save_dict,
os.path.join(output_path, "transformer", "model.safetensors"),
metadata=meta_st,
)
with open(os.path.join(output_path, "aitk_meta.yaml"), "w") as f:
yaml.dump(meta, f)
def get_base_model_version(self):
return "minimax_h3"
def get_transformer_block_names(self) -> Optional[List[str]]:
return ["blocks"]
def get_quantization_exclude_modules(self) -> Optional[List[str]]:
# float32 islands, the conditioning projection, and the token refiner
# (shipped bf16 in the pre-quantized checkpoints — excluding it makes
# quantize with the checkpoint's own qtype an exact no-op). The
# per-block adaln_proj stays quantizable (half the parameter count).
return [
"video_patch_proj*",
"audio_patch_proj*",
"time_embedder*",
"final_layer*",
"condition_proj*",
"token_refiner*",
]
def convert_lora_weights_before_save(self, state_dict):
# ComfyUI's MiniMax-H3 keys are the original checkpoint keys, so the
# standard diffusion_model prefix maps directly
return {
k.replace("transformer.", "diffusion_model."): v
for k, v in state_dict.items()
}
def convert_lora_weights_before_load(self, state_dict):
return {
k.replace("diffusion_model.", "transformer."): v
for k, v in state_dict.items()
}

View File

@ -0,0 +1,504 @@
"""MiniMax-H3 audio VAE -- frozen DAC/BigVGAN waveform autoencoder.
32 kHz mono waveform <-> 32-channel latents at 40 latents/second (hop 800,
no mel front-end and no separate vocoder). Stereo is carried by the CALLER
as two batch items. Runs in fp32 (the BigVGAN decoder degrades audibly
under bf16).
Weight-compatible with the ``MiniMaxAI/MiniMax-H3`` checkpoint
``FL2VA/audio_vae/model.safetensors`` once ``fold_audio_vae_weight_norm``
collapses its ``weight_g``/``weight_v`` weight-norm pairs into plain conv
weights::
vae = MiniMaxH3AudioVAE()
vae.load_state_dict(fold_audio_vae_weight_norm(load_file(path)), strict=True)
"""
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor
# fmt: off
# Per-channel latent statistics from the released FL2VA/audio_vae/config.json.
_LATENTS_MEAN = [
-0.020211687488382354, 0.3876466479950502, -0.04398279799186767,
-0.28591514936373, 0.08179686214561671, -0.35782641352446604,
0.040623809960919084, -0.01552534501956604, -0.223362481667332,
0.1821006842509091, 0.2941778783780663, -0.07901167601970885,
-0.056815072777201, -0.3699028221860095, -0.31616315591624855,
0.5905951377425391, -0.052139568068853864, 0.013673160263486295,
-0.03691647864630577, 0.09732660653298163, -0.3394662328788498,
-0.30685677538541667, -0.24504598907458763, -0.034698524462007344,
0.02868032184767538, -0.21217779266454084, -0.1678263169941987,
0.3221287889040614, -0.1223055851554907, 0.4356604928128464,
-0.0502599202236253, 0.3979258376211797,
]
_LATENTS_STD = [
1.6895524230479284, 2.76263727217653, 1.7945344281264435,
1.6801681847309828, 1.6390226546605453, 2.7788298348882177,
1.7659090095747236, 1.6199757612137327, 2.6336525640336896,
1.8539356672817833, 2.5056497896915633, 1.811019237886178,
1.9579657790720237, 1.6685498243529284, 1.4922469314453364,
3.298670198067373, 1.9491804496832168, 1.8720003270431442,
1.8334080103291832, 1.6488070416529093, 1.6176957696319716,
1.9131449234774398, 1.5695245398428617, 1.6943659940415912,
1.8318420762504692, 1.5540637421583379, 1.9344930328968526,
1.599198216109855, 1.718045989838149, 1.6307219190837705,
1.8661226051202384, 1.5613768203168363,
]
# fmt: on
def fold_audio_vae_weight_norm(state_dict: dict) -> dict:
"""Fold weight-norm ``weight_g``/``weight_v`` pairs into plain ``.weight``
tensors, passing every other key through untouched.
The checkpoint uses ``nn.utils.weight_norm`` with the default ``dim=0``,
so ``w = g * v / ||v||`` with the norm taken over every dim except 0.
That also holds for the decoder's ConvTranspose1d layers: their
``weight_v`` is laid out ``[in, out, k]`` and ``weight_g`` is
``[in, 1, 1]``, i.e. the reduction still runs over dims (1, 2).
"""
folded = {}
for name, tensor in state_dict.items():
if name.endswith(".weight_g"):
continue
if name.endswith(".weight_v"):
g = state_dict[name[: -len("weight_v")] + "weight_g"]
norm = tensor.norm(p=2, dim=tuple(range(1, tensor.ndim)), keepdim=True)
folded[name[: -len("weight_v")] + "weight"] = g * tensor / norm
else:
folded[name] = tensor
return folded
def _kaiser_sinc_filter1d(cutoff: float, half_width: float, kernel_size: int) -> Tensor:
# Kaiser-windowed sinc low-pass, arithmetically identical to
# alias-free-torch. The result is also stored in the checkpoint as a
# buffer, so this init only matters for randomly initialized models.
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) ** 0.4 + 0.07886 * (attenuation - 21.0)
else:
beta = 0.0
window = torch.kaiser_window(kernel_size, beta=beta, periodic=False)
if kernel_size % 2 == 0:
time = torch.arange(-half_size, half_size) + 0.5
else:
time = torch.arange(kernel_size) - half_size
kernel = 2 * cutoff * window * torch.sinc(2 * cutoff * time)
kernel = kernel / kernel.sum()
return kernel.view(1, 1, kernel_size)
class Snake1d(nn.Module):
"""``x + (alpha + 1e-9)^-1 * sin(alpha * x)^2`` with per-channel alpha
stored as ``[1, C, 1]``. The DAC encoder's activation."""
def __init__(self, channels: int):
super().__init__()
self.alpha = nn.Parameter(torch.ones(1, channels, 1))
def forward(self, x: Tensor) -> Tensor:
return x + (self.alpha + 1e-9).reciprocal() * torch.sin(self.alpha * x).pow(2)
class SnakeBeta(nn.Module):
"""BigVGAN activation with separate frequency/magnitude parameters, both
stored log-scale as ``[C]`` vectors:
``x + (exp(beta) + 1e-9)^-1 * sin(exp(alpha) * x)^2``."""
def __init__(self, channels: int):
super().__init__()
self.alpha = nn.Parameter(torch.zeros(channels))
self.beta = nn.Parameter(torch.zeros(channels))
def forward(self, x: Tensor) -> Tensor:
alpha = torch.exp(self.alpha)[None, :, None]
beta = torch.exp(self.beta)[None, :, None]
return x + (beta + 1e-9).reciprocal() * torch.sin(alpha * x).pow(2)
class UpSample1d(nn.Module):
"""Anti-aliased ratio-x upsampler (depthwise transposed Kaiser-sinc conv)."""
def __init__(self, ratio: int = 2, kernel_size: int = 12):
super().__init__()
self.ratio = ratio
self.pad = kernel_size // ratio - 1
self.pad_left = self.pad * ratio + (kernel_size - ratio) // 2
self.pad_right = self.pad * ratio + (kernel_size - ratio + 1) // 2
self.register_buffer(
"filter",
_kaiser_sinc_filter1d(0.5 / ratio, 0.6 / ratio, kernel_size),
)
def forward(self, x: Tensor) -> Tensor:
channels = x.shape[1]
x = F.pad(x, (self.pad, self.pad), mode="replicate")
x = self.ratio * F.conv_transpose1d(
x, self.filter.expand(channels, -1, -1), stride=self.ratio, groups=channels
)
return x[..., self.pad_left : -self.pad_right]
class LowPassFilter1d(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, x: Tensor) -> Tensor:
channels = x.shape[1]
x = F.pad(x, (self.pad_left, self.pad_right), mode="replicate")
return F.conv1d(
x, self.filter.expand(channels, -1, -1), stride=self.stride, groups=channels
)
class DownSample1d(nn.Module):
"""Anti-aliased ratio-x downsampler."""
def __init__(self, ratio: int = 2, kernel_size: int = 12):
super().__init__()
self.lowpass = LowPassFilter1d(0.5 / ratio, 0.6 / ratio, ratio, kernel_size)
def forward(self, x: Tensor) -> Tensor:
return self.lowpass(x)
class AliasFreeActivation1d(nn.Module):
"""2x upsample -> activation -> 2x downsample (BigVGAN's alias-free wrapper)."""
def __init__(self, activation: nn.Module, ratio: int = 2, kernel_size: int = 12):
super().__init__()
self.act = activation
self.upsample = UpSample1d(ratio, kernel_size)
self.downsample = DownSample1d(ratio, kernel_size)
def forward(self, x: Tensor) -> Tensor:
return self.downsample(self.act(self.upsample(x)))
class ResidualUnit(nn.Module):
"""DAC residual unit: Snake -> dilated conv k7 -> Snake -> conv k1."""
def __init__(self, dim: int, dilation: int):
super().__init__()
self.block = nn.Sequential(
Snake1d(dim),
nn.Conv1d(
dim, dim, 7, dilation=dilation, padding=((7 - 1) * dilation) // 2
),
Snake1d(dim),
nn.Conv1d(dim, dim, 1),
)
def forward(self, x: Tensor) -> Tensor:
y = self.block(x)
crop = (x.shape[-1] - y.shape[-1]) // 2
if crop > 0:
x = x[..., crop:-crop]
return x + y
class EncoderBlock(nn.Module):
"""Three residual units (dilations 1/3/9), Snake, then a strided
channel-doubling conv (kernel 2*stride, padding ceil(stride/2))."""
def __init__(self, out_dim: int, stride: int):
super().__init__()
in_dim = out_dim // 2
self.block = nn.Sequential(
ResidualUnit(in_dim, dilation=1),
ResidualUnit(in_dim, dilation=3),
ResidualUnit(in_dim, dilation=9),
Snake1d(in_dim),
nn.Conv1d(
in_dim,
out_dim,
kernel_size=2 * stride,
stride=stride,
padding=math.ceil(stride / 2),
),
)
def forward(self, x: Tensor) -> Tensor:
return self.block(x)
class DacEncoder(nn.Module):
"""``[B, 1, samples] -> [B, d_latent, samples / prod(strides)]``."""
def __init__(self, d_model: int, strides: tuple, d_latent: int):
super().__init__()
layers = [nn.Conv1d(1, d_model, 7, padding=3)]
for stride in strides:
d_model *= 2
layers.append(EncoderBlock(d_model, stride))
layers += [Snake1d(d_model), nn.Conv1d(d_model, d_latent, 3, padding=1)]
self.block = nn.Sequential(*layers)
def forward(self, x: Tensor) -> Tensor:
return self.block(x)
class GeGluMlp(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 = nn.Linear(in_features, hidden_features)
self.w1 = nn.Linear(in_features, hidden_features)
self.w2 = nn.Linear(hidden_features, in_features)
def forward(self, x: Tensor) -> Tensor:
x = self.norm(x)
return self.w2(self.act(self.w0(x)) * self.w1(x))
class CausalAttention(nn.Module):
"""Causal self-attention that narrows the width from in_dim to out_dim.
QKV is one bias-less linear; the checkpoint stores separate q/v bias
parameters and a frozen all-zero key bias buffer. The heads are
MEAN-pooled away (not concatenated) and the surviving head dim (256) is
adaptively average-pooled down to out_dim."""
def __init__(self, in_dim: int, out_dim: int, num_heads: int):
super().__init__()
self.num_heads = num_heads
self.head_dim = in_dim // num_heads
self.out_dim = out_dim
self.qkv = nn.Linear(in_dim, in_dim * 3, bias=False)
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 = nn.Linear(out_dim, out_dim)
def forward(self, x: Tensor) -> Tensor:
b, t, _ = x.shape
bias = torch.cat((self.q_bias, self.zero_k_bias, self.v_bias))
qkv = F.linear(x, self.qkv.weight, bias)
q, k, v = (
qkv.view(b, t, 3, self.num_heads, self.head_dim)
.permute(2, 0, 3, 1, 4)
.unbind(0)
)
x = F.scaled_dot_product_attention(q, k, v, is_causal=True)
x = x.mean(dim=1)
x = F.adaptive_avg_pool1d(x, self.out_dim)
return self.proj(x)
class AttnProjection(nn.Module):
"""``pre_block``: rewires the 2048-wide encoder trunk to the 32-channel
latent width. Operates on ``[B, T, C]``."""
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 = CausalAttention(in_dim, out_dim, num_heads)
self.proj = nn.Linear(in_dim, out_dim)
self.norm3 = nn.LayerNorm(in_dim)
self.norm2 = nn.LayerNorm(out_dim)
self.mlp = GeGluMlp(out_dim, out_dim * mlp_ratio)
def forward(self, x: Tensor) -> Tensor:
x = self.proj(self.norm3(x)) + self.attn(self.norm1(x))
return x + self.mlp(self.norm2(x))
class AMPBlock(nn.Module):
"""BigVGAN AMPBlock1: per dilation a (dilated conv, dilation-1 conv)
pair, each conv preceded by its own alias-free SnakeBeta."""
def __init__(self, channels: int, kernel_size: int, dilations: tuple):
super().__init__()
self.convs1 = nn.ModuleList(
nn.Conv1d(
channels,
channels,
kernel_size,
dilation=d,
padding=(kernel_size * d - d) // 2,
)
for d in dilations
)
self.convs2 = nn.ModuleList(
nn.Conv1d(channels, channels, kernel_size, padding=(kernel_size - 1) // 2)
for _ in dilations
)
self.activations = nn.ModuleList(
AliasFreeActivation1d(SnakeBeta(channels))
for _ in range(2 * len(dilations))
)
def forward(self, x: Tensor) -> Tensor:
for i, (conv1, conv2) in enumerate(zip(self.convs1, self.convs2)):
y = conv1(self.activations[2 * i](x))
y = conv2(self.activations[2 * i + 1](y))
x = x + y
return x
class BigVGANDecoder(nn.Module):
"""``[B, in_channels, T] -> [B, 1, T * prod(rates)]``, clamped to [-1, 1]."""
def __init__(
self,
in_channels: int = 2048,
initial_channels: int = 1024,
rates: tuple = (5, 5, 2, 2, 2, 2, 2),
kernel_sizes: tuple = (9, 9, 4, 4, 4, 4, 4),
resblock_kernel_sizes: tuple = (3, 7, 11),
resblock_dilations: tuple = ((1, 3, 5), (1, 3, 5), (1, 3, 5)),
):
super().__init__()
self.num_kernels = len(resblock_kernel_sizes)
self.conv_pre = nn.Conv1d(in_channels, initial_channels, 7, padding=3)
# each upsampler is nested in a one-element ModuleList so the state
# dict keys stay `ups.<i>.0.*` like the checkpoint
self.ups = nn.ModuleList()
self.resblocks = nn.ModuleList()
channels = initial_channels
for rate, kernel in zip(rates, kernel_sizes):
self.ups.append(
nn.ModuleList(
[
nn.ConvTranspose1d(
channels,
channels // 2,
kernel,
stride=rate,
padding=(kernel - rate) // 2,
)
]
)
)
channels //= 2
for k, dils in zip(resblock_kernel_sizes, resblock_dilations):
self.resblocks.append(AMPBlock(channels, k, dils))
self.activation_post = AliasFreeActivation1d(SnakeBeta(channels))
self.conv_post = nn.Conv1d(channels, 1, 7, padding=3, bias=False)
def forward(self, x: Tensor) -> Tensor:
x = self.conv_pre(x)
for i, up in enumerate(self.ups):
x = up[0](x)
acc = None
for j in range(self.num_kernels):
y = self.resblocks[i * self.num_kernels + j](x)
acc = y if acc is None else acc + y
x = acc / self.num_kernels
x = self.activation_post(x)
x = self.conv_post(x)
return torch.clamp(x, min=-1.0, max=1.0)
class MiniMaxH3AudioVAE(nn.Module):
"""Frozen MiniMax-H3 audio autoencoder with diagonal latent normalization.
- ``encode``: waveform ``(B, 1, samples)`` at 32 kHz -> latents ``(B, 32, T)``
- ``decode``: latents ``(B, 32, T)`` -> waveform ``(B, 1, T * 800)``
"""
SAMPLE_RATE = 32000
HOP_LENGTH = 800
LATENT_CHANNELS = 32
LATENTS_PER_SECOND = 40.0
def __init__(self):
super().__init__()
self.encoder = DacEncoder(d_model=64, strides=(2, 4, 4, 5, 5), d_latent=2048)
self.pre_block = AttnProjection(2048, 32, num_heads=8)
self.mean_proj = nn.Conv1d(32, 32, 1)
# log-STD head (not log-var); unused by encode(), kept for weight parity
self.logs_proj = nn.Conv1d(32, 32, 1)
self.dec_in_proj = nn.Conv1d(32, 2048, 1)
self.decoder = BigVGANDecoder()
self.register_buffer(
"latents_mean",
torch.tensor(_LATENTS_MEAN, dtype=torch.float32),
persistent=False,
)
self.register_buffer(
"latents_std",
torch.tensor(_LATENTS_STD, dtype=torch.float32),
persistent=False,
)
for p in self.parameters():
p.requires_grad = False
self.eval()
@property
def device(self):
return next(self.parameters()).device
@property
def dtype(self):
return next(self.parameters()).dtype
@property
def sample_rate(self) -> int:
return self.SAMPLE_RATE
@property
def downsampling_ratio(self) -> int:
return self.HOP_LENGTH
def _apply(self, fn, recurse=True):
# This VAE is pinned to fp32 (bf16 decodes are audibly degraded).
# Device moves pass through, but any cast that would land a float
# tensor in a non-fp32 dtype is rewritten to a device-move of the
# ORIGINAL tensor kept at fp32 -- the weights never round-trip
# through a lower precision.
def guarded(t):
out = fn(t)
if (
torch.is_tensor(out)
and out.is_floating_point()
and out.dtype != torch.float32
):
return t.to(device=out.device, dtype=torch.float32)
return out
return super()._apply(guarded, recurse)
def encode(self, waveform: Tensor) -> Tensor:
"""Mono waveform ``(B, 1, samples)`` at 32 kHz -> normalized latents
``(B, 32, T)``. Uses the posterior mean (mode), like the released
pipeline; the tail is zero-padded to a multiple of 800 samples."""
waveform = waveform.float()
remainder = waveform.shape[-1] % self.HOP_LENGTH
if remainder:
waveform = F.pad(waveform, (0, self.HOP_LENGTH - remainder))
h = self.encoder(waveform)
h = self.pre_block(h.transpose(1, 2)).transpose(1, 2)
z = self.mean_proj(h)
return (z - self.latents_mean[:, None]) / self.latents_std[:, None]
def decode(self, latents: Tensor) -> Tensor:
"""Normalized latents ``(B, 32, T)`` -> waveform ``(B, 1, T * 800)``
in [-1, 1], fp32."""
latents = latents.float()
z = latents * self.latents_std[:, None] + self.latents_mean[:, None]
return self.decoder(self.dec_in_proj(z))
def forward(self, waveform: Tensor) -> Tensor:
return self.decode(self.encode(waveform))

View File

@ -0,0 +1,439 @@
"""Packed-sequence geometry for MiniMax-H3.
One transformer forward runs over a single packed 1-D sequence:
[ text (L) | keyframe conditions (C) | target audio (A) | target video (V) ]
This module owns everything needed to place a row in that sequence and give it
its (t, h, w) rotary coordinate, plus the sigma-shift math that couples the
video (shift 12) and audio (shift 3) flow schedules.
Rotary coordinates are built in float64 and with numpy's ``linspace`` because
video and audio share one 40-units-per-second rotary clock (video advances
5/3 units per pixel frame at 24 fps, audio one unit per latent at 40/s) and
that shared clock is the released checkpoint's audio/video alignment.
"""
import math
from dataclasses import dataclass
from typing import List, Optional, Tuple
import numpy as np
import torch
from PIL import Image
# Per-row modality tags — these index the transformer's AdaLN table, so the
# values are a checkpoint contract.
VIDEO_TAG = 0
TEXT_TAG = 1
AUDIO_TAG = 2
PAD_TAG = -1
FPS = 24
SHORT_EDGE = 768
MAX_PIXELS = 768 * 1344
CANVAS_MULTIPLE = 32
# video VAE: 17 pixel frames per chunk -> 5 latent frames, 3 trailing latents
# dropped overall, so 17n+5 pixel frames <-> 5n+2 latent frames
FRAMES_PER_CHUNK = 17
LATENTS_PER_CHUNK = 5
AUDIO_LATENTS_PER_SECOND = 40
AUDIO_CHANNELS = 2
AUDIO_SAMPLE_RATE = 32000
# released flow shifts (exponential): video 12, audio 3
VIDEO_SIGMA_SHIFT = 12.0
AUDIO_SIGMA_SHIFT = 3.0
# keyframe conditioning rows are noised to t = 0.999 and pinned there; the
# posterior sample of the keyframe VAE encode uses a fixed seed of 42
KEYFRAME_NOISE_AUG_T = 0.999
KEYFRAME_ENCODE_SEED = 42
# rotary-time constants: one latent frame spans 5/3 * frames_per_latent units,
# the (1, 4, 4, 4, 4) pattern mirroring the VAE's 17 -> 5 frame grouping
_ROPE_FRAME_RESCALE = 5.0 / 3.0
_ROPE_FRAMES_PER_LATENT = (1, 4, 4, 4, 4)
_ROPE_SPATIAL_SCALE = 32
# ---------------------------------------------------------------------------
# Frame / canvas arithmetic
# ---------------------------------------------------------------------------
def align_num_frames(num_frames: int) -> int:
"""Snap a frame count UP to the next 17n+5 the video VAE can encode."""
if num_frames < 1:
raise ValueError(f"num_frames must be positive, got {num_frames}")
while num_frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
num_frames += 1
return num_frames
def align_num_frames_down(num_frames: int) -> int:
"""Snap a frame count DOWN to the previous 17n+5 (minimum 5)."""
num_frames = max(num_frames, LATENTS_PER_CHUNK)
while num_frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
num_frames -= 1
return num_frames
def video_latent_num_frames(num_frames: int) -> int:
"""17n+5 pixel frames -> 5n+2 latent frames."""
if num_frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
raise ValueError(f"num_frames must be of the form 17n+5, got {num_frames}")
return (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
def audio_latent_num_frames(num_frames: int) -> int:
"""Audio latents covering `num_frames` video frames at 24 fps / 40 Hz."""
return int(round(num_frames / FPS * AUDIO_LATENTS_PER_SECOND))
def resolve_canvas_size(aspect_width: float, aspect_height: float) -> Tuple[int, int]:
"""Aspect ratio -> (height, width): short edge 768, area capped at 768*1344,
both axes rounded to the nearest multiple of 32."""
ratio = aspect_width / aspect_height
if ratio >= 1.0:
width, height = SHORT_EDGE * ratio, float(SHORT_EDGE)
else:
width, height = float(SHORT_EDGE), SHORT_EDGE / ratio
area = width * height
if area > MAX_PIXELS:
scale = (MAX_PIXELS / area) ** 0.5
width, height = width * scale, height * scale
m = CANVAS_MULTIPLE
return max(m, round(height / m) * m), max(m, round(width / m) * m)
def prepare_keyframe_image(
image: Image.Image, height: int, width: int, stretch: bool = True
):
"""Put a keyframe onto the target canvas: the geometry anchor is stretched,
a follower keyframe is cover-cropped."""
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))
# ---------------------------------------------------------------------------
# Row packing
# ---------------------------------------------------------------------------
def patchify_video_latents(latents: torch.Tensor, patch_size=(1, 2, 2)) -> torch.Tensor:
"""(B, C, T, H, W) -> (B, N, C * prod(patch)) rows, frame-major then
row-major, feature order [c, pt, ph, pw]."""
pt, ph, pw = patch_size
b, c, t, h, w = latents.shape
latents = latents.reshape(b, c, t // pt, pt, h // ph, ph, w // pw, pw)
latents = latents.permute(0, 2, 4, 6, 1, 3, 5, 7)
return latents.reshape(b, -1, c * pt * ph * pw).contiguous()
def unpatchify_video_tokens(
rows: torch.Tensor,
num_latent_frames: int,
latent_height: int,
latent_width: int,
channels: int = 24,
patch_size=(1, 2, 2),
) -> torch.Tensor:
"""(B, N, C * prod(patch)) -> (B, C, T, H, W). Inverse of patchify."""
pt, ph, pw = patch_size
b = rows.shape[0]
rows = rows.reshape(
b,
num_latent_frames // pt,
latent_height // ph,
latent_width // pw,
channels,
pt,
ph,
pw,
)
rows = rows.permute(0, 4, 1, 5, 2, 6, 3, 7)
return rows.reshape(
b, channels, num_latent_frames, latent_height, latent_width
).contiguous()
def pack_audio_latents(latents: torch.Tensor) -> torch.Tensor:
"""(B, 2, C, T) stereo audio latents -> (B, 2*T, C) channel-major rows
(all T frames of channel 0, then channel 1)."""
return (
latents.permute(0, 1, 3, 2)
.reshape(latents.shape[0], -1, latents.shape[2])
.contiguous()
)
def unpack_audio_tokens(rows: torch.Tensor, num_audio_latents: int) -> torch.Tensor:
"""(B, 2*T, C) channel-major rows -> (B, 2, C, T)."""
b, _, c = rows.shape
rows = rows.reshape(b, AUDIO_CHANNELS, num_audio_latents, c)
return rows.permute(0, 1, 3, 2).contiguous()
# ---------------------------------------------------------------------------
# Rotary grids (float64, numpy linspace — the released grid must reproduce)
# ---------------------------------------------------------------------------
def _spatial_position_grid(dim: int, patch: int, sqrt_area: float) -> torch.Tensor:
ratio = dim / sqrt_area
left = (1.0 - ratio) / 2.0
# numpy linspace(endpoint=False) is start + arange(n) * (stop-start)/n,
# which is not bit-identical to torch.linspace
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(
[
_ROPE_FRAME_RESCALE
* _ROPE_FRAMES_PER_LATENT[i % len(_ROPE_FRAMES_PER_LATENT)]
for i 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:
# numpy pairwise sum on purpose: the reference computes the "last" keyframe
# anchor this way and the summation orders differ in the last ulp
spans = np.ones(num_latent_frames, dtype=np.float64) * _ROPE_FRAME_RESCALE
for i in range(len(_ROPE_FRAMES_PER_LATENT)):
spans[i :: len(_ROPE_FRAMES_PER_LATENT)] *= _ROPE_FRAMES_PER_LATENT[i]
return float(spans.sum())
# ---------------------------------------------------------------------------
# Sequence layout
# ---------------------------------------------------------------------------
@dataclass
class PackedLayout:
"""Structural description of one packed sequence (one batch item)."""
sequence_length: int
position_ids: torch.Tensor # (S, 3) float64
token_tags: torch.Tensor # (S,) long
video_indices: torch.Tensor # condition rows first, then target rows
audio_indices: torch.Tensor
text_indices: torch.Tensor
num_condition_video_rows: int
def build_packed_sequence(
text_token_tags: torch.Tensor, # (L,) long: 1 text, 0 for vision-block rows
num_latent_frames: int,
latent_height: int,
latent_width: int,
num_audio_latents: int,
patch_size=(1, 2, 2),
keyframe_anchors: Tuple[str, ...] = (),
) -> PackedLayout:
"""Build the [text | keyframe conditions | target audio | target video]
layout used by t2va and fl2va."""
_, ph, pw = patch_size
rows_per_frame = (latent_height // ph) * (latent_width // pw)
num_text = int(text_token_tags.shape[0])
num_cond = len(keyframe_anchors) * rows_per_frame
num_audio_rows = num_audio_latents * AUDIO_CHANNELS
num_video_rows = num_latent_frames * rows_per_frame
seq_len = num_text + num_cond + num_audio_rows + num_video_rows
cond_start = num_text
audio_start = cond_start + num_cond
video_start = audio_start + num_audio_rows
# text rows sit on the time axis at their row index; the media clock
# continues from there, so prompt length shifts the whole media clock
position_ids = torch.zeros(seq_len, 3, dtype=torch.float64)
position_ids[:num_text, 0] = torch.arange(num_text, dtype=torch.float64)
sqrt_area = math.sqrt(latent_height * latent_width)
height_grid = _spatial_position_grid(latent_height, ph, sqrt_area)
width_grid = _spatial_position_grid(latent_width, pw, sqrt_area)
frame_grid = torch.stack(
[g.reshape(-1) for g in torch.meshgrid(height_grid, width_grid, indexing="ij")],
dim=-1,
)
for i, anchor in enumerate(keyframe_anchors):
if anchor == "first":
anchor_time = float(num_text)
elif anchor == "last":
anchor_time = (
float(num_text)
+ _temporal_position_span(num_latent_frames)
- _ROPE_FRAME_RESCALE
)
else:
raise ValueError(
f"keyframe anchor must be 'first' or 'last', got {anchor!r}"
)
rows = slice(
cond_start + i * rows_per_frame, cond_start + (i + 1) * rows_per_frame
)
position_ids[rows, 0] = anchor_time
position_ids[rows, 1:] = frame_grid
# audio rows: channel-major, one rotary unit per latent (40/s = 24fps*5/3),
# no height coordinate, width pinned to the grid extremes per channel
audio_time = float(num_text) + torch.arange(num_audio_latents, dtype=torch.float64)
position_ids[audio_start:video_start, 0] = audio_time.repeat(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_latents,), float(width_grid[-1]), dtype=torch.float64
),
]
)
video_pos = torch.empty(num_latent_frames, rows_per_frame, 3, dtype=torch.float64)
video_pos[:, :, 0] = _temporal_position_grid(num_latent_frames, float(num_text))[
:, None
]
video_pos[:, :, 1:] = frame_grid[None]
position_ids[video_start:] = video_pos.reshape(-1, 3)
video_indices = torch.cat(
[torch.arange(cond_start, audio_start), torch.arange(video_start, seq_len)]
)
audio_indices = torch.arange(audio_start, video_start)
text_indices = torch.arange(num_text)
token_tags = torch.empty(seq_len, dtype=torch.long)
token_tags[text_indices] = text_token_tags.to(torch.long)
token_tags[audio_indices] = AUDIO_TAG
token_tags[video_indices] = VIDEO_TAG
return PackedLayout(
sequence_length=seq_len,
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_cond,
)
def build_row_timesteps(
layout: PackedLayout,
video_timestep: float,
audio_timestep: float,
condition_video_timestep: Optional[float] = None,
) -> torch.Tensor:
"""Per-row timestep values (S,) float32. Text rows inherit the video
timestep; condition video rows stay pinned at their noise-aug level."""
if condition_video_timestep is None:
condition_video_timestep = max(video_timestep, KEYFRAME_NOISE_AUG_T)
row_t = torch.full(
(layout.sequence_length,), float(video_timestep), dtype=torch.float32
)
row_t[layout.video_indices[: layout.num_condition_video_rows]] = float(
condition_video_timestep
)
row_t[layout.audio_indices] = float(audio_timestep)
return row_t
def pad_layouts_to_batch(layouts: List[PackedLayout]):
"""Stack per-item layouts that share the same media geometry but may have
different text lengths into batched transformer inputs.
Items are right-padded in the TEXT segment to the batch's max text length
(pad rows tagged -1, masked out of attention as keys, positions zero).
Returns (position_ids (B, S, 3) f64, token_tags (B, S) long,
video_indices, audio_indices, text_indices, pad_counts) where the index
tensors describe the shared padded layout: [text_max | cond | audio | video].
"""
max_text = max(int(l.text_indices.shape[0]) for l in layouts)
ref = layouts[0]
media_len = ref.sequence_length - int(ref.text_indices.shape[0])
for l in layouts:
if l.sequence_length - int(l.text_indices.shape[0]) != media_len:
raise ValueError(
"all layouts in a batch must share the same media geometry"
)
seq_len = max_text + media_len
b = len(layouts)
position_ids = torch.zeros(b, seq_len, 3, dtype=torch.float64)
token_tags = torch.full((b, seq_len), PAD_TAG, dtype=torch.long)
pad_counts = []
for i, l in enumerate(layouts):
lt = int(l.text_indices.shape[0])
position_ids[i, :lt] = l.position_ids[:lt]
position_ids[i, max_text:] = l.position_ids[lt:]
token_tags[i, :lt] = l.token_tags[:lt]
token_tags[i, max_text:] = l.token_tags[lt:]
pad_counts.append(max_text - lt)
offset = max_text - int(ref.text_indices.shape[0])
video_indices = ref.video_indices + offset
audio_indices = ref.audio_indices + offset
text_indices = torch.arange(max_text)
return (
position_ids,
token_tags,
video_indices,
audio_indices,
text_indices,
pad_counts,
)
# ---------------------------------------------------------------------------
# Sigma-shift math (video shift 12, audio shift 3, exponential)
# ---------------------------------------------------------------------------
def shift_sigma(sigma, shift: float):
"""Exponential timeshift: shift * sigma / (1 + (shift - 1) * sigma)."""
return shift * sigma / (1.0 + (shift - 1.0) * sigma)
def remap_sigma(
sigma, from_shift: float = VIDEO_SIGMA_SHIFT, to_shift: float = AUDIO_SIGMA_SHIFT
):
"""Map a sigma on the `from_shift` schedule onto the `to_shift` schedule
at the same underlying schedule position (the video/audio coupling)."""
base = sigma / (from_shift + sigma * (1.0 - from_shift))
return shift_sigma(base, to_shift)
def build_sigma_schedule(
num_inference_steps: int, shift: float = VIDEO_SIGMA_SHIFT
) -> torch.Tensor:
"""The released sampling grid: linspace(1, 0, steps) through the
exponential shift, consecutive duplicates collapsed the terminal 0 is
part of the count, so `steps` yields `steps - 1` model evaluations."""
base = torch.linspace(1.0, 0.0, num_inference_steps, dtype=torch.float32)
sigmas = shift_sigma(base, shift)
return torch.unique_consecutive(sigmas)

View File

@ -0,0 +1,215 @@
"""MiniMax-H3 sampling pipeline for ai-toolkit training previews.
Covers t2v (t2va) and first-frame i2v (fl2va), always denoising the joint
audio stream alongside the video (the packed sequence contains audio rows by
construction; decoding the audio track is optional).
MiniMax-H3 is guidance-distilled: there is no negative prompt, no CFG and
exactly one transformer forward per step. ``unconditional_embeds`` and
``guidance_scale`` are accepted for harness compatibility and ignored.
Scheduler (the released math, not diffusers'):
- sigma grid: ``linspace(1, 0, steps)`` through the exponential shift
(video 12, audio 3), consecutive duplicates collapsed; the terminal 0 is
part of the count so ``steps`` yields ``steps - 1`` model evaluations
- the model consumes ``t = 1 - sigma`` (t = 1 means clean) and predicts the
data-ward velocity ``clean - noise``: ``denoised = x + sigma * v``
- Euler update ``x_next = r * x + (1 - r) * denoised`` with
``r = sigma_next / sigma``, evaluated in float32
"""
from typing import Optional
import numpy as np
import torch
from PIL import Image
from diffusers.utils.torch_utils import randn_tensor
from . import packing
from .packing import (
AUDIO_CHANNELS,
AUDIO_SIGMA_SHIFT,
FPS,
KEYFRAME_NOISE_AUG_T,
VIDEO_SIGMA_SHIFT,
build_packed_sequence,
build_row_timesteps,
build_sigma_schedule,
pack_audio_latents,
patchify_video_latents,
remap_sigma,
unpack_audio_tokens,
unpatchify_video_tokens,
)
class MiniMaxH3Pipeline:
"""Lightweight sampler; receives the MinimaxH3Model (BaseModel subclass)
and reuses its VAEs / transformer / device bookkeeping."""
def __init__(self, model):
self.model = model
@property
def device(self):
return self.model.device_torch
def to(self, *args, **kwargs):
return self
def set_progress_bar_config(self, **kwargs):
pass
@torch.no_grad()
def __call__(
self,
conditional_embeds, # AdvancedPromptEmbeds: text_embeds [(L, 5120)], text_token_tags [(L,)]
unconditional_embeds=None, # ignored: MiniMax-H3 is guidance-distilled
height: int = 768,
width: int = 768,
num_frames: int = 124,
num_inference_steps: int = 28,
guidance_scale: float = 1.0, # ignored
latents: Optional[torch.Tensor] = None,
generator: Optional[torch.Generator] = None,
ctrl_img: Optional[
Image.Image
] = None, # first-frame keyframe, already canvas-sized
with_audio: bool = True,
**kwargs,
):
model = self.model
device = model.device_torch
dtype = model.torch_dtype
transformer = model.transformer
is_video = num_frames > 1
if is_video:
num_frames = packing.align_num_frames_down(num_frames)
else:
# a single image still runs through the video path with the
# minimum 5-frame clip; the first frame is returned
num_frames = packing.LATENTS_PER_CHUNK
t_lat = packing.video_latent_num_frames(num_frames)
h_lat = height // 16
w_lat = width // 16
a_lat = packing.audio_latent_num_frames(num_frames)
text_embeds = conditional_embeds.text_embeds[0].to(device, dtype)
token_tags = conditional_embeds.text_token_tags[0].to("cpu", torch.long)
# --- packed layout -------------------------------------------------
anchors = ("first",) if ctrl_img is not None else ()
layout = build_packed_sequence(
text_token_tags=token_tags,
num_latent_frames=t_lat,
latent_height=h_lat,
latent_width=w_lat,
num_audio_latents=a_lat,
keyframe_anchors=anchors,
)
num_cond = layout.num_condition_video_rows
# --- conditioning rows (draw order: condition noise, video, audio) --
cond_rows = None
if ctrl_img is not None:
cond_noise = randn_tensor(
(1, 24, 1, h_lat, w_lat), generator=generator, dtype=torch.float32
).to(device)
frame = torch.from_numpy(np.array(ctrl_img)).float()
frame = (frame / 255.0) * 2.0 - 1.0 # (H, W, 3) -> [-1, 1]
frame = frame.permute(2, 0, 1)[None, :, None] # (1, 3, 1, H, W)
cond_latents = model.encode_keyframe_latents(frame) # (1, 24, 1, h, w) fp32
# released noise-aug recipe: x = t * clean + (1 - t) * noise at t = 0.999
cond_latents = (
KEYFRAME_NOISE_AUG_T * cond_latents.to(device)
+ (1.0 - KEYFRAME_NOISE_AUG_T) * cond_noise
)
cond_rows = patchify_video_latents(cond_latents) # (1, rows, 96)
# --- initial noise -------------------------------------------------
if latents is None:
latents = randn_tensor(
(1, 24, t_lat, h_lat, w_lat), generator=generator, dtype=torch.float32
)
video_rows = patchify_video_latents(latents.to(device).float()) # (1, V, 96)
audio_noise = randn_tensor(
(1, AUDIO_CHANNELS, 32, a_lat), generator=generator, dtype=torch.float32
).to(device)
audio_rows = pack_audio_latents(audio_noise) # (1, 2*A, 32)
# --- schedules -----------------------------------------------------
sigmas_v = build_sigma_schedule(num_inference_steps, VIDEO_SIGMA_SHIFT).to(
device
)
# the audio schedule follows the video grid through the closed-form
# shift remap so both streams sit at the same underlying position
sigmas_a = remap_sigma(sigmas_v, VIDEO_SIGMA_SHIFT, AUDIO_SIGMA_SHIFT)
position_ids = layout.position_ids[None].to(device)
tags = layout.token_tags[None].to(device)
video_indices = layout.video_indices.to(device)
audio_indices = layout.audio_indices.to(device)
text_indices = layout.text_indices.to(device)
# --- denoise loop --------------------------------------------------
num_steps = sigmas_v.shape[0] - 1
for i in range(num_steps):
sv, sv_next = sigmas_v[i], sigmas_v[i + 1]
sa, sa_next = sigmas_a[i], sigmas_a[i + 1]
t_v = 1.0 - float(sv)
t_a = 1.0 - float(sa)
row_t = build_row_timesteps(layout, t_v, t_a)[None].to(device)
video_in = video_rows
if cond_rows is not None:
video_in = torch.cat([cond_rows, video_rows], dim=1)
video_pred, audio_pred = transformer(
hidden_states=video_in.to(dtype),
audio_hidden_states=audio_rows.to(dtype),
encoder_hidden_states=text_embeds[None],
row_timesteps=row_t,
token_tags=tags,
position_ids=position_ids,
video_indices=video_indices,
audio_indices=audio_indices,
text_indices=text_indices,
)
v_video = video_pred[:, num_cond:].float()
v_audio = audio_pred.float()
denoised_v = video_rows + sv * v_video
ratio_v = sv_next / sv
video_rows = ratio_v * video_rows + (1.0 - ratio_v) * denoised_v
denoised_a = audio_rows + sa * v_audio
ratio_a = sa_next / sa if float(sa) != 0.0 else 0.0
audio_rows = ratio_a * audio_rows + (1.0 - ratio_a) * denoised_a
# --- decode --------------------------------------------------------
video_latents = unpatchify_video_tokens(video_rows, t_lat, h_lat, w_lat)
video = model.decode_latents(video_latents) # (1, 3, T, H, W) in [-1, 1]
video = ((video.float().clamp(-1, 1) + 1.0) * 127.5).round().to(torch.uint8)
video = video[0].permute(1, 2, 3, 0).cpu() # (T, H, W, C)
if not is_video:
return [Image.fromarray(video[0].numpy())]
audio_out = None
if with_audio:
audio_latents = unpack_audio_tokens(audio_rows, a_lat)[0] # (2, 32, A)
waveform = model.decode_audio_latents(
audio_latents.float()
) # (2, 1, samples)
audio_out = waveform[:, 0].cpu() # (2, samples) stereo
return {
"video": video,
"fps": FPS,
"audio": audio_out,
"audio_sample_rate": packing.AUDIO_SAMPLE_RATE,
"output_path": None,
}

View File

@ -0,0 +1,106 @@
"""Qwen3-VL conditioning for MiniMax-H3.
MiniMax-H3 conditions on the **unnormalized** ``hidden_states[50]`` of its
Qwen3-VL-32B conditioner (``hidden_states[0]`` is the embedding output, so
this is the output of decoder layer 49, before the final norm). The LM head
and layers 50..63 are never used, which lets the loader truncate the stack.
The presentation is raw tokens no chat template, no special tokens:
- t2va: the verbatim prompt.
- fl2va: per keyframe, a ``"<Picture i>: "`` label plus a vision block
(``<|vision_start|>`` + one ``<|image_pad|>`` per merged vision patch +
``<|vision_end|>``), then the verbatim prompt. Vision-block rows are
tagged as *video* (0) rather than text (1) the transformer's AdaLN
modality selection keys off these tags.
"""
from typing import List, Optional
import torch
from .packing import TEXT_TAG, VIDEO_TAG
TEXT_ENCODER_LAYER = 50
@torch.no_grad()
def encode_minimax_h3_prompt(
text_encoder, # transformers Qwen3VLForConditionalGeneration
tokenizer, # Qwen2TokenizerFast
processor, # Qwen3VLProcessor (needed only when keyframes are present)
prompt: str,
keyframes: Optional[List] = None, # PIL images already on the target canvas
device: Optional[torch.device] = None,
dtype: Optional[torch.dtype] = None,
):
"""Encode ONE prompt (with optional keyframes) into MiniMax-H3 conditioning.
Returns (embeds (L, 5120), token_tags (L,) long). The embeds come from
``hidden_states[50]`` unnormalized. A stack truncated to exactly 50 layers
also works ONLY if the final ``model.norm`` has been replaced with an
Identity (transformers applies the final norm to the last entry of
``hidden_states``); the loader in minimax_h3.py does exactly that.
"""
num_layers = text_encoder.config.text_config.num_hidden_layers
if num_layers < TEXT_ENCODER_LAYER:
raise ValueError(
f"MiniMax-H3 needs at least {TEXT_ENCODER_LAYER} Qwen3-VL decoder "
f"layers to read hidden_states[{TEXT_ENCODER_LAYER}], got {num_layers}"
)
if device is None:
device = text_encoder.device
pixel_values, image_grid_thw = None, None
token_ids: List[int] = []
token_tags: List[int] = []
if keyframes:
vision = processor.image_processor(images=keyframes, return_tensors="pt")
pixel_values = vision["pixel_values"]
image_grid_thw = vision["image_grid_thw"]
merge = processor.image_processor.merge_size**2
vision_start = tokenizer.convert_tokens_to_ids("<|vision_start|>")
vision_end = tokenizer.convert_tokens_to_ids("<|vision_end|>")
image_pad = tokenizer.convert_tokens_to_ids("<|image_pad|>")
for i in range(len(keyframes)):
num_image_tokens = int(image_grid_thw[i].prod()) // merge
label_ids = tokenizer(f"<Picture {i + 1}>: ", add_special_tokens=False)[
"input_ids"
]
vision_ids = [vision_start] + [image_pad] * num_image_tokens + [vision_end]
token_ids += label_ids + vision_ids
token_tags += [TEXT_TAG] * len(label_ids) + [VIDEO_TAG] * len(vision_ids)
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
token_ids += prompt_ids
token_tags += [TEXT_TAG] * len(prompt_ids)
if len(token_ids) == 0:
# empty (unconditional) prompt: a single pad token keeps the sequence
# non-degenerate; the model was not trained with CFG so this is only
# ever a fallback
token_ids = [tokenizer.pad_token_id or 0]
token_tags = [TEXT_TAG]
input_ids = torch.tensor([token_ids], dtype=torch.long, device=device)
mm_token_type_ids = torch.tensor(
processor.create_mm_token_type_ids([token_ids]), dtype=torch.long, device=device
)
# call the inner .model directly: the LM head's vocab projection is dead
# weight here and hidden_states[50] is all that is consumed
outputs = text_encoder.model(
input_ids=input_ids,
attention_mask=torch.ones_like(input_ids),
mm_token_type_ids=mm_token_type_ids,
pixel_values=None
if pixel_values is None
else pixel_values.to(device, text_encoder.dtype),
image_grid_thw=None if image_grid_thw is None else image_grid_thw.to(device),
use_cache=False,
output_hidden_states=True,
)
layer = min(TEXT_ENCODER_LAYER, len(outputs.hidden_states) - 1)
embeds = outputs.hidden_states[layer][0]
if dtype is not None:
embeds = embeds.to(dtype)
return embeds, torch.tensor(token_tags, dtype=torch.long)

View File

@ -0,0 +1,480 @@
"""MiniMax-H3 diffusion transformer (33B), weight-compatible with the original
``MiniMaxAI/MiniMax-H3`` checkpoint keys (``video_patch_proj``, ``blocks.N.*``,
``final_layer.*``, ...).
MiniMax-H3 runs one stack of blocks over a single packed 1-D sequence holding
``[text | keyframe-condition video rows | audio rows | target video rows]``.
Full self-attention, no cross-attention, no per-modality weights: modality-
specific behavior comes only from the two input patch projections, the per-row
AdaLN modality tag (0 = video, 1 = text, 2 = audio) and the two output heads.
Deviations from the reference implementations (both are pure generalizations
with batch size 1 and no padding the math is identical):
- every structural input (``position_ids``, ``token_tags``, ``row_timesteps``)
carries a batch axis, so items with different prompt lengths can share a
training batch (pad rows are tagged ``-1`` and masked out of attention)
- timesteps are passed per row and deduplicated internally instead of the
caller supplying ``timestep`` + ``timestep_indices``
Mixed precision follows the shipped checkpoint: ``video_patch_proj``,
``audio_patch_proj``, ``time_embedder``, ``final_layer.video_out/audio_out``
and ``rope.inv_freq`` are float32 islands, everything else runs bf16. Inputs
are aligned to each projection's own parameter dtype at the call site.
Timesteps are consumed unscaled in [0, 1] with t = 1 - sigma (t=1 means clean),
and both heads predict the data-ward velocity ``clean - noise``.
"""
import math
from dataclasses import dataclass
from typing import Optional, Tuple
import torch
import torch.nn.functional as F
from torch import nn
from torch.utils.checkpoint import checkpoint
MODALITY_NUM = 3 # 0 = video, 1 = text, 2 = audio; -1 marks padding rows
@dataclass
class MiniMaxH3TransformerParams:
hidden_size: int = 5376
num_layers: int = 50
token_refiner_num_layers: int = 2
num_attention_heads: int = 56
attention_head_dim: int = 128 # heads * head_dim = 7168 > hidden_size
ffn_hidden_size: int = 14336
latents_dim: int = 24
audio_latents_dim: int = 32
patch_size: Tuple[int, int, int] = (1, 2, 2)
text_dim: int = 5120
timestep_input_dim: int = 256
time_embed_hidden_size: int = 5376
time_embed_dim: int = 2688
rope_inv_freq_len: int = 16
rope_theta: float = 10000.0
norm_eps: float = 1e-5
qk_norm_eps: float = 1e-5
final_norm_eps: float = 1e-5
# "pruned" checkpoints (e.g. Comfy-Org *_pruned_*) replace the timestep
# MLP with a small lookup table: ``adaln_t_table`` of shape
# (adaln_t_table_size, time_embed_dim) sampled by linear interpolation at
# t * (size - 1), consumed by the AdaLN projections WITHOUT the SiLU.
# These checkpoints also shrink time_embed_dim (8 in the released files).
adaln_t_table_size: Optional[int] = None
@property
def adaln_apply_silu(self) -> bool:
return self.adaln_t_table_size is None
class MiniMaxH3Rope(nn.Module):
"""3-axis rotary embedding over the packed (t, h, w) coordinates.
One shared ``inv_freq`` of 16 frequencies per axis; the three 16-angle
blocks concatenate to 48 and duplicate to 96, so the first 96 of the 128
head channels rotate (rotate-half convention) and the last 32 pass through.
"""
def __init__(self, inv_freq_len: int = 16, theta: float = 10000.0):
super().__init__()
dim = 2 * inv_freq_len
inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
# present in the checkpoint, so persistent
self.register_buffer("inv_freq", inv_freq, persistent=True)
def forward(self, position_ids: torch.Tensor):
"""position_ids (B, S, 3) -> cos, sin each (B, S, 96), float32."""
position_ids = position_ids.to(device=self.inv_freq.device, dtype=torch.float32)
freqs = position_ids.unsqueeze(-1) * self.inv_freq.view(1, 1, 1, -1)
# (B, S, 3, 16) -> (B, S, 48) in (t, h, w) axis order -> duplicate to 96
freqs = freqs.flatten(2, 3)
freqs = torch.cat([freqs, freqs], dim=-1)
return freqs.cos(), freqs.sin()
def apply_rotary_emb(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor):
"""x (B, S, H, D); cos/sin (B, S, rot) rotate the leading ``rot`` channels."""
rot = cos.shape[-1]
x_rot, x_pass = x[..., :rot], x[..., rot:]
cos = cos.to(x.dtype).unsqueeze(2)
sin = sin.to(x.dtype).unsqueeze(2)
x1, x2 = x_rot.chunk(2, dim=-1)
rotated = torch.cat([-x2, x1], dim=-1)
return torch.cat([x_rot * cos + rotated * sin, x_pass], dim=-1)
class MiniMaxH3TimeEmbedder(nn.Module):
"""Sinusoidal embedding (cos before sin, unscaled t in [0, 1]) -> MLP.
A float32 island in the checkpoint; the output stays float32 so every
block's AdaLN applies its SiLU at full precision before casting down.
"""
def __init__(self, freq_dim: int, hidden: int, out_dim: int):
super().__init__()
self.freq_dim = freq_dim
self.proj_in = nn.Linear(freq_dim, hidden, bias=True)
self.proj_out = nn.Linear(hidden, out_dim, bias=True)
def forward(self, t: torch.Tensor) -> torch.Tensor:
half = self.freq_dim // 2
freqs = torch.exp(
-math.log(10000.0)
* torch.arange(half, dtype=torch.float32, device=t.device)
/ half
)
args = t.to(torch.float32)[:, None] * freqs[None]
emb = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
emb = emb.to(self.proj_in.weight.dtype)
return self.proj_out(F.silu(self.proj_in(emb)))
class MiniMaxH3Attention(nn.Module):
"""Fused-QKV self-attention with per-head RMSNorm on q/k and partial RoPE."""
def __init__(self, hidden: int, heads: int, head_dim: int, qk_norm_eps: float):
super().__init__()
self.heads = heads
self.head_dim = head_dim
inner = heads * head_dim
self.qkv_proj = nn.Linear(hidden, inner * 3, bias=False)
self.q_norm = nn.RMSNorm(head_dim, eps=qk_norm_eps)
self.k_norm = nn.RMSNorm(head_dim, eps=qk_norm_eps)
self.out_proj = nn.Linear(inner, hidden, bias=False)
def forward(
self,
x: torch.Tensor, # (B, S, hidden)
rotary_emb=None, # (cos, sin) each (B, S, rot) or None
attn_mask: Optional[torch.Tensor] = None, # (B, 1, 1, S) bool, True = attend
) -> torch.Tensor:
b, s, _ = x.shape
q, k, v = self.qkv_proj(x).chunk(3, dim=-1)
q = q.view(b, s, self.heads, self.head_dim)
k = k.view(b, s, self.heads, self.head_dim)
v = v.view(b, s, self.heads, self.head_dim)
q = self.q_norm(q)
k = self.k_norm(k)
if rotary_emb is not None:
q = apply_rotary_emb(q, *rotary_emb)
k = apply_rotary_emb(k, *rotary_emb)
q = q.transpose(1, 2)
k = k.transpose(1, 2)
v = v.transpose(1, 2)
out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask)
out = out.transpose(1, 2).reshape(b, s, -1)
return self.out_proj(out)
class MiniMaxH3Mlp(nn.Module):
"""SwiGLU: fc1 packs [gate | up]; silu(gate) * up -> fc2. No biases."""
def __init__(self, hidden: int, ffn: int):
super().__init__()
self.fc1 = nn.Linear(hidden, ffn * 2, bias=False)
self.fc2 = nn.Linear(ffn, hidden, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
gate, up = self.fc1(x).chunk(2, dim=-1)
return self.fc2(F.silu(gate) * up)
class MiniMaxH3AdalnProj(nn.Module):
"""Timestep embedding -> per-(timestep, modality) modulation parameters.
(M, time_embed_dim) -> ``expand`` tensors of (M * modalities, hidden); row
layout ``[t0_mod0, t0_mod1, t0_mod2, t1_mod0, ...]``, addressed by
``timestep_index * MODALITY_NUM + tag``. The SiLU runs at temb's own
(float32) precision; only its result is cast to the projection dtype.
"""
def __init__(
self,
t_dim: int,
hidden: int,
expand: int,
modalities: int,
apply_silu: bool = True,
):
super().__init__()
self.expand = expand
self.modalities = modalities
self.hidden = hidden
self.apply_silu = apply_silu
self.linear = nn.Linear(t_dim, expand * hidden * modalities, bias=True)
def forward(self, temb: torch.Tensor):
if self.apply_silu:
temb = F.silu(temb)
x = self.linear(temb.to(self.linear.weight.dtype))
x = x.view(x.shape[0] * self.modalities, self.expand * self.hidden)
return x.chunk(self.expand, dim=-1)
class MiniMaxH3RefinerBlock(nn.Module):
"""Plain pre-norm block over the projected text stream. No AdaLN, no RoPE."""
def __init__(self, p: MiniMaxH3TransformerParams):
super().__init__()
self.norm1 = nn.RMSNorm(p.hidden_size, eps=p.norm_eps)
self.norm2 = nn.RMSNorm(p.hidden_size, eps=p.norm_eps)
self.attn = MiniMaxH3Attention(
p.hidden_size, p.num_attention_heads, p.attention_head_dim, p.qk_norm_eps
)
self.mlp = MiniMaxH3Mlp(p.hidden_size, p.ffn_hidden_size)
def forward(self, x: torch.Tensor, attn_mask=None) -> torch.Tensor:
x = x + self.attn(self.norm1(x), attn_mask=attn_mask)
x = x + self.mlp(self.norm2(x))
return x
class MiniMaxH3TokenRefiner(nn.Module):
def __init__(self, p: MiniMaxH3TransformerParams):
super().__init__()
self.blocks = nn.ModuleList(
[MiniMaxH3RefinerBlock(p) for _ in range(p.token_refiner_num_layers)]
)
self.final_norm = nn.RMSNorm(p.hidden_size, eps=p.final_norm_eps)
self.gradient_checkpointing = False
def forward(self, x: torch.Tensor, attn_mask=None) -> torch.Tensor:
for block in self.blocks:
if torch.is_grad_enabled() and self.gradient_checkpointing:
x = checkpoint(block, x, attn_mask, use_reentrant=False)
else:
x = block(x, attn_mask)
return self.final_norm(x)
class MiniMaxH3Block(nn.Module):
"""Pre-norm attention + SwiGLU MLP, each modulated by AdaLN parameters
gathered per row from the (timestep, modality) table."""
def __init__(self, p: MiniMaxH3TransformerParams):
super().__init__()
self.norm1 = nn.RMSNorm(p.hidden_size, eps=p.norm_eps)
self.norm2 = nn.RMSNorm(p.hidden_size, eps=p.norm_eps)
self.attn = MiniMaxH3Attention(
p.hidden_size, p.num_attention_heads, p.attention_head_dim, p.qk_norm_eps
)
self.mlp = MiniMaxH3Mlp(p.hidden_size, p.ffn_hidden_size)
self.adaln_proj = MiniMaxH3AdalnProj(
p.time_embed_dim,
p.hidden_size,
expand=6,
modalities=MODALITY_NUM,
apply_silu=p.adaln_apply_silu,
)
def forward(
self,
x: torch.Tensor, # (B, S, hidden)
temb: torch.Tensor, # (M, time_embed_dim) float32
adaln_indices: torch.Tensor, # (B, S) long into the (M * 3) table
rotary_emb, # (cos, sin)
attn_mask: Optional[torch.Tensor] = None,
) -> torch.Tensor:
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
self.adaln_proj(temb)
)
dt = x.dtype # pruned checkpoints store the adaln projections fp16
h = self.norm1(x) * (1.0 + scale_msa[adaln_indices].to(dt)) + shift_msa[
adaln_indices
].to(dt)
x = x + gate_msa[adaln_indices].to(dt) * self.attn(h, rotary_emb, attn_mask)
h = self.norm2(x) * (1.0 + scale_mlp[adaln_indices].to(dt)) + shift_mlp[
adaln_indices
].to(dt)
x = x + gate_mlp[adaln_indices].to(dt) * self.mlp(h)
return x
class MiniMaxH3FinalLayer(nn.Module):
"""Shared shift/scale-modulated RMSNorm + the two per-modality heads.
Both heads run over every row (matching the reference); the caller selects
each modality's rows from the results. Heads are float32 islands.
"""
def __init__(self, p: MiniMaxH3TransformerParams):
super().__init__()
video_patch_dim = (
p.latents_dim * p.patch_size[0] * p.patch_size[1] * p.patch_size[2]
)
self.norm = nn.RMSNorm(p.hidden_size, eps=p.final_norm_eps)
self.adaln_proj = MiniMaxH3AdalnProj(
p.time_embed_dim,
p.hidden_size,
expand=2,
modalities=1,
apply_silu=p.adaln_apply_silu,
)
self.video_out = nn.Linear(p.hidden_size, video_patch_dim, bias=True)
self.audio_out = nn.Linear(p.hidden_size, p.audio_latents_dim, bias=True)
def forward(
self,
x: torch.Tensor, # (B, S, hidden)
temb: torch.Tensor, # (M, time_embed_dim)
timestep_indices: torch.Tensor, # (B, S) long into the M rows
):
shift, scale = self.adaln_proj(temb)
dt = x.dtype
h = self.norm(x) * (1.0 + scale[timestep_indices].to(dt)) + shift[
timestep_indices
].to(dt)
h = h.to(self.video_out.weight.dtype)
return self.video_out(h), self.audio_out(h)
class MiniMaxH3Transformer(nn.Module):
def __init__(self, params: Optional[MiniMaxH3TransformerParams] = None):
super().__init__()
if params is None:
params = MiniMaxH3TransformerParams()
self.params = params
p = params
video_patch_dim = (
p.latents_dim * p.patch_size[0] * p.patch_size[1] * p.patch_size[2]
)
self.video_patch_proj = nn.Linear(video_patch_dim, p.hidden_size, bias=True)
self.audio_patch_proj = nn.Linear(p.audio_latents_dim, p.hidden_size, bias=True)
self.condition_proj = nn.Linear(p.text_dim, p.hidden_size, bias=True)
if p.adaln_t_table_size is not None:
# pruned checkpoints: the timestep MLP is replaced by a lookup
# table sampled with linear interpolation at t * (size - 1)
self.time_embedder = None
self.register_buffer(
"adaln_t_table",
torch.zeros(p.adaln_t_table_size, p.time_embed_dim),
persistent=True,
)
else:
self.time_embedder = MiniMaxH3TimeEmbedder(
p.timestep_input_dim, p.time_embed_hidden_size, p.time_embed_dim
)
self.rope = MiniMaxH3Rope(p.rope_inv_freq_len, p.rope_theta)
self.token_refiner = MiniMaxH3TokenRefiner(p)
self.blocks = nn.ModuleList([MiniMaxH3Block(p) for _ in range(p.num_layers)])
self.final_layer = MiniMaxH3FinalLayer(p)
self.gradient_checkpointing = False
# float32 islands of the shipped checkpoint; used by the loader to keep
# these keys at full precision when the rest is cast to bf16
FP32_KEY_PREFIXES = (
"video_patch_proj",
"audio_patch_proj",
"time_embedder",
"final_layer.video_out",
"final_layer.audio_out",
"rope",
)
def enable_gradient_checkpointing(self, enable: bool = True):
self.gradient_checkpointing = enable
self.token_refiner.gradient_checkpointing = enable
def disable_gradient_checkpointing(self):
self.enable_gradient_checkpointing(False)
@property
def device(self):
return self.condition_proj.weight.device
@property
def dtype(self):
# working dtype of the block stack (norm weights are never quantized)
return self.token_refiner.final_norm.weight.dtype
def _time_embedding(self, t: torch.Tensor) -> torch.Tensor:
"""(M,) float32 in [0, 1] -> (M, time_embed_dim) float32."""
if self.time_embedder is not None:
return self.time_embedder(t)
table = self.adaln_t_table.float()
pos = t.clamp(0.0, 1.0) * (table.shape[0] - 1)
lo = pos.floor().long()
hi = (lo + 1).clamp(max=table.shape[0] - 1)
frac = (pos - lo.float()).unsqueeze(1)
return table[lo] * (1.0 - frac) + table[hi] * frac
def forward(
self,
hidden_states: torch.Tensor, # (B, Nv, 96) patchified video rows (cond + target)
audio_hidden_states: torch.Tensor, # (B, Na, 32) audio rows
encoder_hidden_states: torch.Tensor, # (B, L, 5120) text conditioning
row_timesteps: torch.Tensor, # (B, S) float in [0, 1], t = 1 - sigma
token_tags: torch.Tensor, # (B, S) long: 0 video, 1 text, 2 audio, -1 pad
position_ids: torch.Tensor, # (B, S, 3) float (t, h, w) rotary coords
video_indices: torch.Tensor, # (Nv,) long positions of video rows in the pack
audio_indices: torch.Tensor, # (Na,) long
text_indices: torch.Tensor, # (L,) long
):
"""Returns (video_out (B, Nv, 96), audio_out (B, Na, 32)) — the
data-ward velocity ``clean - noise`` for every row, in input order.
Conditioning rows come back unmasked; discarding them is the caller's
job."""
batch_size, seq_len = token_tags.shape
rotary_emb = self.rope(position_ids)
video_embeds = self.video_patch_proj(
hidden_states.to(self.video_patch_proj.weight.dtype)
)
audio_embeds = self.audio_patch_proj(
audio_hidden_states.to(self.audio_patch_proj.weight.dtype)
)
text_embeds = self.condition_proj(encoder_hidden_states.to(self.dtype))
# pad rows never act as attention keys; as queries they see everything
# (their outputs are discarded), which keeps SDPA rows finite
attn_mask = None
text_attn_mask = None
is_pad = token_tags < 0
if bool(is_pad.any()):
live = ~is_pad
attn_mask = live[:, None, None, :]
text_attn_mask = live[:, text_indices][:, None, None, :]
text_embeds = self.token_refiner(text_embeds, text_attn_mask)
x = text_embeds.new_zeros((batch_size, seq_len, text_embeds.shape[-1]))
x = x.index_copy(1, text_indices, text_embeds)
x = x.index_copy(1, video_indices, video_embeds.to(x.dtype))
x = x.index_copy(1, audio_indices, audio_embeds.to(x.dtype))
# one timestep embedding per distinct noise level across the batch
unique_t, inverse = torch.unique(
row_timesteps.to(torch.float32), sorted=True, return_inverse=True
)
temb = self._time_embedding(unique_t)
adaln_indices = inverse * MODALITY_NUM + token_tags.clamp(min=0)
for block in self.blocks:
if torch.is_grad_enabled() and self.gradient_checkpointing:
x = checkpoint(
block,
x,
temb,
adaln_indices,
rotary_emb,
attn_mask,
use_reentrant=False,
)
else:
x = block(x, temb, adaln_indices, rotary_emb, attn_mask)
video_all, audio_all = self.final_layer(x, temb, inverse)
video_out = video_all.index_select(1, video_indices)
audio_out = audio_all.index_select(1, audio_indices)
return video_out, audio_out

View File

@ -0,0 +1,786 @@
"""MiniMax-H3 video VAE -- causal 3D-CNN encoder + non-causal 36-layer ViT decoder.
16x spatial / 4x temporal compression, 24 latent channels, per-channel
latents_mean/latents_std normalization. The temporal geometry is fixed by
``clip_length`` (17 pixel frames per encoder chunk) and ``token_drop`` (3
trailing latent frames dropped after encoding): ``17n + 5`` pixel frames
map to ``5n + 2`` latent frames. ``decode`` mirrors the chunking -- 5-token
chunks with a 2-token overlap, cross-faded over 5 pixel frames. A single
frame (image / i2v keyframe) skips the temporal chunking entirely and maps
to a single latent frame.
The reference pixel convention is ImageNet-normalized RGB over [0, 1];
``encode``/``decode`` here speak ai-toolkit's [-1, 1] and convert inside.
Spatial tiling (256 px tiles, >= 64 px overlap, linearly blended) is ON by
default for both encode and decode -- the model was released that way and
the released frames are the blended-tile ones. The checkpoint is float32
and the released decode recipe is fp16 autocast over the fp32 weights, with
the token embedder, output projection, and every norm kept in fp32.
"""
import math
from contextlib import nullcontext
import torch
import torch.nn as nn
import torch.nn.functional as F
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
LATENTS_MEAN = [
0.858090341091156,
-0.9606591463088989,
1.0661640167236328,
-0.5090325474739075,
-0.2727581858634949,
-1.3675414323806763,
-0.2553254961967468,
-0.26907554268836975,
-0.5376840829849243,
-0.0464097298681736,
0.6657370328903198,
0.19690127670764923,
-0.5460608005523682,
-0.4035342037677765,
-0.23683024942874908,
0.25928452610969543,
-0.30133944749832153,
0.211341992020607,
-1.1206848621368408,
0.3581933379173279,
-0.04225143790245056,
0.2604829967021942,
0.22864092886447906,
0.7056031823158264,
]
LATENTS_STD = [
1.2223774194717407,
1.2767263650894165,
1.68317747116088865,
1.7549455165863037,
1.5636216402053833,
2.194143533706665,
0.96531379222869875,
1.05698859691619875,
0.841948926448822,
0.7729952931404114,
1.8955937623977661,
0.946841835975647,
0.7996809482574463,
0.44988900423049925,
0.7197399735450745,
0.69362932443618775,
2.961095094680786,
2.7694199085235595,
3.0496184825897215,
2.1088054180145265,
3.276226282119751,
3.1627357006073,
2.28168129920959475,
2.6127843856811525,
]
# ---------------------------------------------------------------------------
# causal 3D-CNN encoder
# ---------------------------------------------------------------------------
class CausalConv3d(nn.Conv3d):
"""Conv3d with symmetric reflect spatial padding and causal (front-only,
zeros) temporal padding of ``kernel_t - 1`` frames."""
def __init__(self, in_channels, out_channels, kernel_size, stride=1, spatial_pad=0):
super().__init__(
in_channels, out_channels, kernel_size=kernel_size, stride=stride
)
self.spatial_pad = spatial_pad
def forward(self, x):
if self.spatial_pad > 0:
p = self.spatial_pad
x = F.pad(x, (p, p, p, p, 0, 0), mode="reflect")
t_pad = self.kernel_size[0] - 1
if t_pad > 0:
x = F.pad(x, (0, 0, 0, 0, t_pad, 0))
return F.conv3d(x, self.weight, self.bias, stride=self.stride)
class FrameGroupNorm(nn.GroupNorm):
"""GroupNorm with statistics computed per frame (``use_t_isolated_gn``):
the temporal axis is folded into the batch axis."""
def forward(self, x):
b, c, t, h, w = x.shape
x = x.permute(0, 2, 1, 3, 4).reshape(b * t, c, h, w)
x = super().forward(x)
return x.view(b, t, c, h, w).permute(0, 2, 1, 3, 4)
class ResnetBlock3d(nn.Module):
def __init__(self, in_channels, out_channels, norm_num_groups=32):
super().__init__()
self.norm1 = FrameGroupNorm(norm_num_groups, in_channels, eps=1e-6)
self.conv1 = CausalConv3d(in_channels, out_channels, 3, spatial_pad=1)
self.norm2 = FrameGroupNorm(norm_num_groups, out_channels, eps=1e-6)
self.conv2 = CausalConv3d(out_channels, out_channels, 3, spatial_pad=1)
if in_channels != out_channels:
self.nin_shortcut = CausalConv3d(in_channels, out_channels, 1)
else:
self.nin_shortcut = None
def forward(self, x):
h = self.conv1(F.silu(self.norm1(x)))
h = self.conv2(F.silu(self.norm2(h)))
if self.nin_shortcut is not None:
x = self.nin_shortcut(x)
return x + h
class Downsample3d(nn.Module):
"""Strided 3x3x3 conv; a spatial stride of 2 is preceded by an asymmetric
bottom/right reflect pad of 1 so the output is exactly ceil(size / 2)."""
def __init__(self, channels, time_stride, space_stride):
super().__init__()
self.space_stride = space_stride
self.conv = CausalConv3d(
channels, channels, 3, stride=(time_stride, space_stride, space_stride)
)
def forward(self, x):
if self.space_stride == 2:
x = F.pad(x, (0, 1, 0, 1, 0, 0), mode="reflect")
return self.conv(x)
class Encoder3d(nn.Module):
def __init__(
self,
in_channels,
out_channels,
block_out_channels,
layers_per_block,
space_down,
time_down,
norm_num_groups,
):
super().__init__()
block_in = (block_out_channels[0],) + tuple(block_out_channels[:-1])
self.conv_in = CausalConv3d(in_channels, block_in[0], 3, spatial_pad=1)
self.down = nn.ModuleList()
for i in range(len(block_out_channels)):
level = nn.Module()
level.block = nn.ModuleList(
ResnetBlock3d(
block_in[i] if j == 0 else block_out_channels[i],
block_out_channels[i],
norm_num_groups=norm_num_groups,
)
for j in range(layers_per_block)
)
if space_down[i] * time_down[i] > 1:
level.downsample = Downsample3d(
block_out_channels[i], time_down[i], space_down[i]
)
self.down.append(level)
self.norm_out = FrameGroupNorm(
norm_num_groups, block_out_channels[-1], eps=1e-6
)
self.conv_out = CausalConv3d(
block_out_channels[-1], out_channels, 3, spatial_pad=1
)
def forward(self, x):
h = self.conv_in(x)
for level in self.down:
for block in level.block:
h = block(h)
if hasattr(level, "downsample"):
h = level.downsample(h)
return self.conv_out(F.silu(self.norm_out(h)))
# ---------------------------------------------------------------------------
# non-causal ViT decoder
# ---------------------------------------------------------------------------
class RMSNormFP32(nn.RMSNorm):
"""The reference normalizes in float32 regardless of the compute dtype
(or the storage dtype the Comfy repack ships this VAE in fp16)."""
def forward(self, x):
weight = None if self.weight is None else self.weight.float()
out = F.rms_norm(x.float(), self.normalized_shape, weight, self.eps)
return out.to(x.dtype)
class RotaryEmbedding3d(nn.Module):
"""3-axis rotary table. Coordinates are length-normalized to [-1, 1) per
axis and scaled by 2*pi; the per-axis angles are concatenated and then
duplicated, so the first ``rope_dim_ratio * head_dim`` channels of every
head are rotated (rotate-half convention)."""
def __init__(self, dim, theta=100.0, num_axes=3):
super().__init__()
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):
angles = (
2.0
* math.pi
* position_ids[:, :, :, None].float()
* self.inv_freq[None, None, None, :]
)
angles = angles.flatten(2, 3).tile(2).unsqueeze(2) # (B, S, 1, rot_dim)
return angles.cos(), angles.sin()
def _rotate_half(x):
x1, x2 = x.chunk(2, dim=-1)
return torch.cat([-x2, x1], dim=-1)
def _apply_rope(x, cos, sin):
rot = cos.shape[-1]
x_rot, x_pass = x[..., :rot], x[..., rot:]
x_rot = x_rot * cos + _rotate_half(x_rot) * sin
return torch.cat([x_rot, x_pass], dim=-1)
class Attention(nn.Module):
def __init__(self, heads, head_dim, eps=1e-5):
super().__init__()
self.heads = heads
self.head_dim = head_dim
inner = heads * head_dim
self.norm_q = RMSNormFP32(head_dim, eps=eps, elementwise_affine=False)
self.norm_k = RMSNormFP32(head_dim, eps=eps, elementwise_affine=False)
self.to_qkv = nn.Linear(inner, inner * 3, bias=True)
self.to_out = nn.Linear(inner, inner, bias=True)
def forward(self, x, rotary_emb):
b, s, _ = x.shape
# per-head-interleaved qkv layout: each head's 3 * head_dim slab is
# split into q | k | v, NOT [Q_all | K_all | V_all]
qkv = self.to_qkv(x).view(b, s, self.heads, 3 * self.head_dim)
q, k, v = qkv.chunk(3, dim=-1)
q = self.norm_q(q)
k = self.norm_k(k)
cos, sin = rotary_emb
cos = cos.to(q.dtype)
sin = sin.to(q.dtype)
q = _apply_rope(q, cos, sin)
k = _apply_rope(k, cos, sin)
out = F.scaled_dot_product_attention(
q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2)
)
return self.to_out(out.transpose(1, 2).reshape(b, s, -1))
class FeedForward(nn.Module):
"""Gated SiLU: w1 output chunks into gate | value, silu(gate) * value."""
def __init__(self, dim, mult=4):
super().__init__()
inner = dim * mult
self.w1 = nn.Linear(dim, inner * 2, bias=True)
self.w2 = nn.Linear(inner, dim, bias=True)
def forward(self, x):
gate, x = self.w1(x).chunk(2, dim=-1)
return self.w2(F.silu(gate) * x)
class TransformerBlock(nn.Module):
def __init__(self, heads, head_dim, ffn_mult=4, eps=1e-5):
super().__init__()
dim = heads * head_dim
self.norm1 = RMSNormFP32(dim, eps=eps, elementwise_affine=True)
self.attn = Attention(heads, head_dim, eps=eps)
self.scale1 = nn.Parameter(torch.zeros(dim))
self.norm2 = RMSNormFP32(dim, eps=eps, elementwise_affine=True)
self.ff = FeedForward(dim, mult=ffn_mult)
self.scale2 = nn.Parameter(torch.zeros(dim))
def forward(self, x, rotary_emb):
x = x + self.attn(self.norm1(x), rotary_emb) * self.scale1
return x + self.ff(self.norm2(x)) * self.scale2
class ViTDecoder3d(nn.Module):
"""Every latent voxel is one token; 4 learned register tokens plus one
all-zero token are appended (all at rope coordinate 0), attended over with
full self-attention, and dropped before the patch projection expands each
token into a patch_size_t x patch_size x patch_size pixel block."""
def __init__(
self,
in_channels,
out_channels,
patch_size,
patch_size_t,
num_layers,
heads,
head_dim,
num_register_tokens=4,
ffn_mult=4,
rope_theta=100.0,
rope_dim_ratio=0.75,
eps=1e-5,
):
super().__init__()
dim = heads * 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 = RotaryEmbedding3d(int(head_dim * rope_dim_ratio), theta=rope_theta)
self.x_embedder = nn.Linear(in_channels, dim)
self.register_tokens = nn.Parameter(torch.zeros(1, num_register_tokens, dim))
# unused at inference; kept so the checkpoint loads strict
self.register_buffer("mask_token", torch.zeros(1, 1, dim))
self.transformer_blocks = nn.ModuleList(
TransformerBlock(heads, head_dim, ffn_mult=ffn_mult, eps=eps)
for _ in range(num_layers)
)
self.norm_out = nn.LayerNorm(dim, eps=eps)
self.proj_out = nn.Linear(
dim, out_channels * patch_size_t * patch_size * patch_size
)
def forward(self, z):
b, c, t, h, w = z.shape
tokens = z.permute(0, 2, 3, 4, 1).reshape(b, t * h * w, c)
# released recipe: token embedder / output projection / norms compute
# fp32 even when the matmuls run fp16 (autocast or fp16 storage); the
# block stack then runs at the storage dtype
with torch.autocast(device_type=z.device.type, enabled=False):
x = F.linear(
tokens.float(),
self.x_embedder.weight.float(),
self.x_embedder.bias.float(),
)
x = x.to(self.x_embedder.weight.dtype)
num_patches = x.shape[1]
x = torch.cat(
[
x,
self.register_tokens.expand(b, -1, -1).to(x.dtype),
torch.zeros_like(x[:, :1]),
],
dim=1,
)
grids = [
2.0 * (torch.arange(0.5, size, dtype=torch.float32, device=z.device) / size)
- 1.0
for size in (t, h, w)
]
position_ids = torch.stack(
torch.meshgrid(*grids, indexing="ij"), dim=-1
).flatten(0, 2)
position_ids = position_ids.unsqueeze(0).expand(b, -1, -1)
suffix_ids = position_ids.new_zeros((b, self.num_register_tokens + 1, 3))
with torch.autocast(device_type=z.device.type, enabled=False):
rotary_emb = self.rope(torch.cat([position_ids, suffix_ids], dim=1))
for block in self.transformer_blocks:
x = block(x, rotary_emb)
with torch.autocast(device_type=z.device.type, enabled=False):
x = F.layer_norm(
x.float(),
self.norm_out.normalized_shape,
self.norm_out.weight.float(),
self.norm_out.bias.float(),
self.norm_out.eps,
)
x = F.linear(x, self.proj_out.weight.float(), self.proj_out.bias.float())
x = x[:, :num_patches]
pt, ps = self.patch_size_t, self.patch_size
x = x.view(b, t, h, w, self.out_channels, pt, ps, ps)
x = x.permute(0, 4, 1, 5, 2, 6, 3, 7)
return x.reshape(b, self.out_channels, t * pt, h * ps, w * ps)
# ---------------------------------------------------------------------------
# full VAE
# ---------------------------------------------------------------------------
class MiniMaxH3VideoVAE(nn.Module):
def __init__(
self,
in_channels=3,
out_channels=3,
latent_channels=24,
block_out_channels=(128, 256, 256, 512, 512, 1024),
layers_per_block=2,
space_down=(2, 2, 2, 2, 1, 1),
time_down=(1, 2, 2, 1, 1, 1),
norm_num_groups=32,
decoder_num_layers=36,
decoder_heads=32,
decoder_head_dim=64,
decoder_num_register_tokens=4,
decoder_ffn_mult=4,
rope_theta=100.0,
rope_dim_ratio=0.75,
clip_length=17,
token_drop=3,
latents_mean=None,
latents_std=None,
tile_size=256,
tile_overlap_min=64,
tiling=True,
):
super().__init__()
self.latent_channels = latent_channels
self.spatial_compression = int(math.prod(space_down))
self.temporal_compression = int(math.prod(time_down))
self.clip_length = clip_length
self.token_drop = token_drop
# clip_length is not a multiple of the temporal ratio, so each decoded
# chunk carries an implicit leading pad; token_drop leaves an overlap
# between consecutive chunks that gets cross-faded in pixel space
self.frame_pre_padding = (-clip_length) % self.temporal_compression
self.tokens_chunk_size = math.ceil(clip_length / self.temporal_compression)
self.token_overlap = (-token_drop) % self.tokens_chunk_size
self.frame_overlap = max(
self.token_overlap * self.temporal_compression - self.frame_pre_padding, 0
)
self.use_tiling = tiling
self.tile_size = tile_size
self.tile_overlap_min = tile_overlap_min
self.encoder = Encoder3d(
in_channels=in_channels,
out_channels=2 * latent_channels,
block_out_channels=block_out_channels,
layers_per_block=layers_per_block,
space_down=space_down,
time_down=time_down,
norm_num_groups=norm_num_groups,
)
self.quant_conv = nn.Conv3d(2 * latent_channels, 2 * latent_channels, 1)
self.post_quant_conv = nn.Conv3d(latent_channels, latent_channels, 1)
self.decoder = ViTDecoder3d(
in_channels=latent_channels,
out_channels=out_channels,
patch_size=self.spatial_compression,
patch_size_t=self.temporal_compression,
num_layers=decoder_num_layers,
heads=decoder_heads,
head_dim=decoder_head_dim,
num_register_tokens=decoder_num_register_tokens,
ffn_mult=decoder_ffn_mult,
rope_theta=rope_theta,
rope_dim_ratio=rope_dim_ratio,
)
if latents_mean is None:
latents_mean = (
LATENTS_MEAN
if latent_channels == len(LATENTS_MEAN)
else [0.0] * latent_channels
)
if latents_std is None:
latents_std = (
LATENTS_STD
if latent_channels == len(LATENTS_STD)
else [1.0] * latent_channels
)
self.register_buffer(
"latents_mean",
torch.tensor(latents_mean, dtype=torch.float32),
persistent=False,
)
self.register_buffer(
"latents_std",
torch.tensor(latents_std, dtype=torch.float32),
persistent=False,
)
self.register_buffer(
"pixel_mean",
torch.tensor(IMAGENET_MEAN, dtype=torch.float32).view(1, 3, 1, 1, 1),
persistent=False,
)
self.register_buffer(
"pixel_std",
torch.tensor(IMAGENET_STD, dtype=torch.float32).view(1, 3, 1, 1, 1),
persistent=False,
)
@property
def device(self):
return next(self.parameters()).device
@property
def dtype(self):
return next(self.parameters()).dtype
@staticmethod
def latent_frames(num_pixel_frames: int) -> int:
"""17n + 5 pixel frames -> 5n + 2 latent frames; 1 -> 1 (keyframe)."""
if num_pixel_frames == 1:
return 1
return 5 * math.ceil(num_pixel_frames / 17) - 3
@staticmethod
def pixel_frames(num_latent_frames: int) -> int:
"""5n + 2 latent frames -> 17n + 5 pixel frames; 1 -> 1 (keyframe)."""
if num_latent_frames == 1:
return 1
n, rem = divmod(num_latent_frames - 2, 5)
if rem != 0 or n < 0:
raise ValueError(
f"latent frame count must be 5n + 2 or 1, got {num_latent_frames}"
)
return 17 * n + 5
# -- spatial tiling ------------------------------------------------------
def _split_tiles(self, length):
"""Lay tile_size-wide tiles over length pixels; the slack beyond the
minimum overlap is spread round-robin in whole spatial_compression
steps so every tile boundary stays latent-aligned."""
tile_size = self.tile_size
if tile_size >= length:
return [0], [length], []
num_tiles = math.ceil(length / tile_size)
while tile_size * num_tiles - self.tile_overlap_min * (num_tiles - 1) < length:
num_tiles += 1
overlaps = [self.tile_overlap_min] * (num_tiles - 1)
remaining = tile_size * num_tiles - sum(overlaps) - length
for i in range(remaining // self.spatial_compression):
overlaps[i % (num_tiles - 1)] += self.spatial_compression
starts = [0]
for i in range(num_tiles - 1):
starts.append(starts[-1] + tile_size - overlaps[i])
return starts, [tile_size] * num_tiles, overlaps
@staticmethod
def _blend(a, b, blend_extent, dim):
blend_extent = min(a.shape[dim], b.shape[dim], blend_extent)
positions = torch.arange(blend_extent, device=b.device, dtype=b.dtype)
shape = [1] * b.ndim
shape[dim] = blend_extent
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)] * (1 - weight_b) + 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, rows, y_overlaps, x_overlaps):
result_rows = []
for i, row in enumerate(rows):
result_row = []
for j, tile in enumerate(row):
if i > 0:
tile = self._blend(rows[i - 1][j], tile, y_overlaps[i - 1], dim=-2)
if j > 0:
tile = self._blend(row[j - 1], tile, x_overlaps[j - 1], dim=-1)
if i < len(rows) - 1:
tile = tile[..., : -y_overlaps[i], :]
if j < len(row) - 1:
tile = tile[..., :, : -x_overlaps[j]]
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):
"""Encode one temporal clip to moments, spatially tiled when enabled."""
if not self.use_tiling:
return self.quant_conv(self.encoder(x))
y_starts, y_lens, y_overlaps = self._split_tiles(x.shape[-2])
x_starts, x_lens, x_overlaps = self._split_tiles(x.shape[-1])
rows = []
for i_pos, i_len in zip(y_starts, y_lens):
row = []
for j_pos, j_len in zip(x_starts, x_lens):
tile = x[..., i_pos : i_pos + i_len, j_pos : j_pos + j_len]
row.append(self.quant_conv(self.encoder(tile)))
rows.append(row)
ratio = self.spatial_compression
return self._stitch_tiles(
rows, [o // ratio for o in y_overlaps], [o // ratio for o in x_overlaps]
)
def _decode_clip(self, z):
"""Decode one temporal clip; tiles are laid out in pixel space and
mapped back onto the latent grid, blended in pixel space."""
if not self.use_tiling:
return self.decoder(self.post_quant_conv(z))
ratio = self.spatial_compression
y_starts, y_lens, y_overlaps = self._split_tiles(z.shape[-2] * ratio)
x_starts, x_lens, x_overlaps = self._split_tiles(z.shape[-1] * ratio)
rows = []
for i_pos, i_len in zip(y_starts, y_lens):
row = []
for j_pos, j_len in zip(x_starts, x_lens):
tile = z[
...,
i_pos // ratio : (i_pos + i_len) // ratio,
j_pos // ratio : (j_pos + j_len) // ratio,
]
row.append(self.decoder(self.post_quant_conv(tile)))
rows.append(row)
return self._stitch_tiles(rows, y_overlaps, x_overlaps)
# -- temporal chunking ---------------------------------------------------
def _encode_video(self, x):
if x.shape[2] % self.clip_length != 0:
pad = x[:, :, -1:].repeat(1, 1, (-x.shape[2]) % self.clip_length, 1, 1)
x = torch.cat([x, pad], dim=2)
moments = torch.cat(
[
self._encode_clip(
x[:, :, i * self.clip_length : (i + 1) * self.clip_length]
)
for i in range(x.shape[2] // self.clip_length)
],
dim=2,
)
if self.token_drop > 0:
moments = moments[:, :, : -self.token_drop]
return moments
def _decode_video(self, z):
tcs = self.tokens_chunk_size
ratio_t = self.temporal_compression
chunk_frames = tcs * ratio_t
split_count = 2 if self.token_drop > 0 else 1
num_tokens = z.shape[2] + self.token_drop
pad_tokens = (-num_tokens) % tcs
num_chunks = (num_tokens + pad_tokens) // tcs - (split_count - 1)
if num_chunks < 1:
# too few tokens for one chunk (e.g. the 2-latent minimum clip)
pad_tokens += tcs
num_chunks += 1
if pad_tokens > 0:
z = torch.cat([z, z[:, :, -1:].repeat(1, 1, pad_tokens, 1, 1)], dim=2)
decoded = []
overlap = None
for i in range(num_chunks):
start = i * tcs
clip = self._decode_clip(z[:, :, start : start + tcs + self.token_overlap])
for j in range(split_count):
part = clip[:, :, j * chunk_frames : (j + 1) * chunk_frames]
part = part[:, :, self.frame_pre_padding :]
if j == 0:
if overlap is not None:
part = self._blend(overlap, part, self.frame_overlap, dim=-3)
decoded.append(part)
else:
overlap = part
if overlap is not None:
decoded.append(overlap)
dec = torch.cat(decoded, dim=2)
if pad_tokens > 0:
# repeated latent frames produced trailing pixel frames that were
# never requested; a chunk's last token only covers
# clip_length % ratio_t frames, the others cover ratio_t
intra_tail = self.clip_length % ratio_t
before_pad = z.shape[2] - pad_tokens
pad_frames = sum(
intra_tail if intra_tail and (before_pad + k) % tcs == 0 else ratio_t
for k in range(pad_tokens)
)
dec = dec[:, :, :-pad_frames]
return dec
# -- public interface ----------------------------------------------------
def encode(self, pixels, sample=True, generator=None, fp16_round=False):
"""pixels (B, 3, T, H, W) in [-1, 1], T == 17n + 5, or T == 1 (single
keyframe: spatial encode only, no temporal chunking). Returns
normalized latents (B, latent_channels, t, h, w): posterior sampled
(or the mean when sample=False), optionally rounded through fp16
BEFORE the (z - latents_mean) / latents_std normalization
(fp16_round=True is the released first-frame-conditioning recipe)."""
x = pixels
if x.ndim == 4:
x = x.unsqueeze(2)
x = (x.float() + 1.0) * 0.5
x = (x - self.pixel_mean) / self.pixel_std
x = x.to(self.dtype)
if x.shape[2] == 1:
moments = self._encode_clip(x)[:, :, -1:]
else:
moments = self._encode_video(x)
mean, logvar = moments.float().chunk(2, dim=1)
if sample:
std = torch.exp(0.5 * logvar.clamp(-30.0, 20.0))
noise = torch.randn(
mean.shape,
generator=generator,
device=generator.device if generator is not None else mean.device,
dtype=mean.dtype,
).to(mean.device)
z = mean + std * noise
else:
z = mean
if fp16_round:
z = z.to(torch.float16).float()
shape = (1, -1, 1, 1, 1)
return (z - self.latents_mean.view(shape)) / self.latents_std.view(shape)
def decode(self, latents, autocast_fp16=True):
"""Normalized latents (B, latent_channels, t, h, w) -> pixels
(B, 3, T, H, W) in [-1, 1]. Runs under fp16 autocast over the fp32
weights on cuda (the released recipe) unless autocast_fp16=False."""
shape = (1, -1, 1, 1, 1)
z = latents.float() * self.latents_std.view(shape) + self.latents_mean.view(
shape
)
ctx = (
torch.autocast(device_type="cuda", dtype=torch.float16)
if autocast_fp16 and z.is_cuda
else nullcontext()
)
with ctx:
if z.shape[2] == 1:
dec = self._decode_clip(z)[:, :, -1:]
else:
dec = self._decode_video(z)
# out-of-place so gradients can flow through decode (pixel-space losses)
dec = dec.float() * self.pixel_std + self.pixel_mean
return dec.clamp(0.0, 1.0) * 2.0 - 1.0

View File

@ -1988,7 +1988,12 @@ class BaseSDTrainProcess(BaseTrainProcess):
# we cannot merge in if quantized or offloading. note: torchao quantized weights can
# still be force merged at save time for the merge-and-reset method (see save logic),
# but we keep can_merge_in False here so sampling never merges in/out.
if self.model_config.quantize or self.model_config.layer_offloading:
# models loaded from pre-quantized checkpoints (e.g. comfy convrot/nvfp4
# imports) never set model_config.quantize, so detect their layers too
model_is_prequantized = any(
getattr(m, 'is_ostris_quantized', False) for m in unet.modules()
) if unet is not None else False
if self.model_config.quantize or self.model_config.layer_offloading or model_is_prequantized:
# todo find a way around this
self.network.can_merge_in = False

View File

@ -60,6 +60,14 @@ class FileItemDTO(
self.sample_rate = kwargs.get("sample_rate", 48000)
self.num_frames = self.dataset_config.num_frames
self.temporal_compression = kwargs.get("temporal_compression", 8)
# module-level function (picklable) for models whose valid frame
# counts are not temporal_compression * n + 1; None = default math
_sd = kwargs.get("sd", None)
self.frame_count_snapper = (
_sd.get_frame_count_snapper()
if _sd is not None and hasattr(_sd, "get_frame_count_snapper")
else None
)
size_database = kwargs.get("size_database", {})
dataset_root = kwargs.get("dataset_root", None)
self.encode_control_in_text_embeddings = kwargs.get(

View File

@ -508,15 +508,19 @@ class ImageProcessingDTOMixin:
if self.dataset_config.auto_frame_count:
# allow for any length video here but make sure it is temporally compressable.
vid_length_seconds = total_frames / video_fps
desired_num_frames = int(vid_length_seconds * self.dataset_config.fps)
# make sure it is divisible by temporal_compression
desired_num_frames = desired_num_frames // self.temporal_compression * self.temporal_compression
# TODO, all models currently add a key frame, but future models may not, update here if this changes.
desired_num_frames += 1 # add one for the key frame that is always added
if getattr(self, 'frame_count_snapper', None) is not None:
# model-specific valid-frame-count grid (e.g. minimax_h3's 17n+5)
desired_num_frames = self.frame_count_snapper(desired_num_frames)
else:
# make sure it is divisible by temporal_compression
desired_num_frames = desired_num_frames // self.temporal_compression * self.temporal_compression
# TODO, all models currently add a key frame, but future models may not, update here if this changes.
desired_num_frames += 1 # add one for the key frame that is always added
self.num_frames = desired_num_frames
@ -705,31 +709,39 @@ class ImageProcessingDTOMixin:
else:
target_duration = source_duration
waveform, sample_rate = torchaudio.load(self.path) # [channels, samples]
waveform = waveform_to_stereo(waveform) # Convert to stereo if not already
if self.dataset_config.audio_normalize:
peak = waveform.abs().amax() # global peak across channels
eps = 1e-9
target_peak = 0.999 # ~ -0.01 dBFS
gain = target_peak / (peak + eps)
waveform = waveform * gain
# torchcodec's AudioDecoder raises when a video has no audio
# track, so probe for a stream before decoding.
import av
with av.open(self.path) as container:
has_audio_stream = len(container.streams.audio) > 0
# Slice to the selected clip region (when we have a meaningful time range)
if source_duration > 0.0:
start_sample = int(round(clip_start_time * sample_rate))
end_sample = int(round(clip_end_time * sample_rate))
start_sample = max(0, min(start_sample, waveform.shape[-1]))
end_sample = max(0, min(end_sample, waveform.shape[-1]))
if end_sample > start_sample:
waveform = waveform[..., start_sample:end_sample]
waveform = None
if has_audio_stream:
waveform, sample_rate = torchaudio.load(self.path) # [channels, samples]
waveform = waveform_to_stereo(waveform) # Convert to stereo if not already
if self.dataset_config.audio_normalize:
peak = waveform.abs().amax() # global peak across channels
eps = 1e-9
target_peak = 0.999 # ~ -0.01 dBFS
gain = target_peak / (peak + eps)
waveform = waveform * gain
# Slice to the selected clip region (when we have a meaningful time range)
if source_duration > 0.0:
start_sample = int(round(clip_start_time * sample_rate))
end_sample = int(round(clip_end_time * sample_rate))
start_sample = max(0, min(start_sample, waveform.shape[-1]))
end_sample = max(0, min(end_sample, waveform.shape[-1]))
if end_sample > start_sample:
waveform = waveform[..., start_sample:end_sample]
else:
# No valid audio segment
waveform = None
else:
# No valid audio segment
# If we can't compute a meaningful time range, treat as no-audio
waveform = None
else:
# If we can't compute a meaningful time range, treat as no-audio
waveform = None
if waveform is not None and waveform.numel() > 0:
target_samples = int(round(target_duration * sample_rate))

View File

@ -274,12 +274,24 @@ class BaseModel:
except:
# if we have a custom vae, it might not have this
divisibility = 8
# flux packs this again,
if self.is_flux:
divisibility = divisibility * 2
return divisibility
def get_frame_count_snapper(self):
"""Optional hook for video models whose VAE accepts frame counts on a
grid other than the default ``temporal_compression * n + 1``.
Return a MODULE-LEVEL function ``(num_frames: int) -> int`` that snaps
an arbitrary frame count DOWN to the nearest count the video VAE can
encode (it must be picklable by reference file items travel into
dataloader workers, so no lambdas or bound methods). Returning None
keeps the default auto_frame_count behavior.
"""
return None
# these must be implemented in child classes
def load_model(self):
# override this in child classes

View File

@ -8,7 +8,7 @@ DIFFUSERS_CONFIGS_ROOT = os.path.join(TOOLKIT_ROOT, "toolkit", "diffusers_config
COMFY_MODELS_PATH = None
# check if ENV variable is set
if 'MODELS_PATH' in os.environ:
if 'MODELS_PATH' in os.environ and os.environ['MODELS_PATH'].strip() != "":
MODELS_PATH = os.environ['MODELS_PATH']
else:
MODELS_PATH = os.path.join(TOOLKIT_ROOT, "models")

View File

@ -0,0 +1,175 @@
"""Import ComfyUI pre-quantized checkpoints onto toolkit modules.
ComfyUI quantized checkpoints mark each quantized submodule with a
``<prefix>.comfy_quant`` uint8 tensor holding a JSON config, alongside the
quantized ``weight`` and its scale tensors. This module walks those markers
and converts the matching submodules in place:
- ``{"format": "int8_tensorwise", "convrot": true, "convrot_groupsize": G}``
per-output-row symmetric int8 on regular-Hadamard-rotated weights the
exact storage of the toolkit's convrot8 backend
(toolkit/util/convrot_quant.py:ConvRotInt8Quantizer), so the tensors are
attached to its buffers directly (no requantization). Without the
``convrot`` flag the rotation block is 1, i.e. plain per-row int8, which
the same backend also decodes (rotate is the identity at rot_size 1).
- ``{"format": "nvfp4"}`` block-16 fp4 with e4m3 block scales, an fp32
per-tensor scale and an optional AWQ ``pre_quant_scale`` attached to
the nvfp4 backend (toolkit/util/nvfp4_quant.py).
- an int8 marker on an ``nn.Embedding`` swaps in :class:`Int8Embedding`
(per-row scales, dequantized per lookup).
Linears become OstrisLinear (class swap in place, like
convert_linear_to_ostris), so LoRA attachment, memory management and the
quantized save paths all work unchanged.
"""
import json
from typing import Dict, Tuple
import torch
from toolkit.util.nvfp4_quant import (
Nvfp4Quantizer,
swap_nvfp4_nibbles,
unswizzle_nvfp4_scales,
)
from toolkit.util.ostris_quant import OstrisLinear, get_ostris_quantizer
def parse_comfy_quant_blob(blob: torch.Tensor) -> dict:
return json.loads(bytes(blob.cpu().tolist()).decode("utf-8"))
class Int8Embedding(torch.nn.Module):
"""An embedding table stored as per-row symmetric int8. Rows are
dequantized per lookup, so the full-precision table never materializes."""
def __init__(self, qweight: torch.Tensor, scales: torch.Tensor, dtype: torch.dtype):
super().__init__()
self.num_embeddings, self.embedding_dim = qweight.shape
self.output_dtype = dtype
self.register_buffer("qweight", qweight.contiguous(), persistent=False)
self.register_buffer(
"scales",
scales.detach().float().reshape(-1).contiguous().view(torch.uint8),
persistent=False,
)
@property
def weight(self):
# full dequantized table, for code that inspects it
scales = self.scales.view(torch.float32)
return (self.qweight.float() * scales.unsqueeze(1)).to(self.output_dtype)
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
flat = input_ids.reshape(-1)
rows = self.qweight.index_select(0, flat).float()
scales = self.scales.view(torch.float32).index_select(0, flat)
out = rows * scales.unsqueeze(1)
return out.to(self.output_dtype).reshape(*input_ids.shape, self.embedding_dim)
def _to_ostris(module: torch.nn.Linear, quantizer, orig_dtype: torch.dtype) -> OstrisLinear:
if "weight" in module._parameters:
del module._parameters["weight"]
module.ostris_quantizer = quantizer
module.ostris_orig_dtype = orig_dtype
if module.bias is not None:
module.bias.requires_grad_(False)
module.__class__ = OstrisLinear
return module
@torch.no_grad()
def import_comfy_quantized_layers(
root: torch.nn.Module,
state_dict: Dict[str, torch.Tensor],
orig_dtype: torch.dtype = torch.bfloat16,
key_map=None,
) -> Tuple[Dict[str, torch.Tensor], int]:
"""Convert every module a ``comfy_quant`` marker points at and attach its
quantized tensors. Consumes the quantized entries from ``state_dict`` and
returns ``(remaining_state_dict, num_converted)`` load the remainder
with the regular load_state_dict.
``key_map`` optionally maps a checkpoint prefix to the module path in
``root`` (e.g. comfy text encoder keys onto transformers module paths).
"""
state_dict = dict(state_dict)
converted = 0
marker_keys = [k for k in state_dict.keys() if k.endswith(".comfy_quant")]
for marker_key in marker_keys:
prefix = marker_key[: -len(".comfy_quant")]
conf = parse_comfy_quant_blob(state_dict.pop(marker_key))
fmt = conf.get("format")
module_path = key_map(prefix) if key_map is not None else prefix
module = root.get_submodule(module_path)
weight = state_dict.pop(f"{prefix}.weight")
weight_scale = state_dict.pop(f"{prefix}.weight_scale", None)
if isinstance(module, torch.nn.Embedding):
if fmt != "int8_tensorwise":
raise ValueError(
f"Unsupported comfy quant format {fmt!r} on embedding {prefix}"
)
parent_path, _, attr = module_path.rpartition(".")
parent = root.get_submodule(parent_path) if parent_path else root
setattr(parent, attr, Int8Embedding(weight, weight_scale, orig_dtype))
converted += 1
continue
if not isinstance(module, torch.nn.Linear):
raise ValueError(
f"comfy_quant marker {prefix} points at {type(module).__name__}, "
"expected nn.Linear or nn.Embedding"
)
if fmt == "int8_tensorwise":
rot = int(conf.get("convrot_groupsize", 256)) if conf.get("convrot") else 1
quantizer = get_ostris_quantizer("convrot8")
module.register_buffer("cr8_qdata", weight.contiguous(), persistent=False)
module.register_buffer(
"cr8_scales",
weight_scale.detach().float().reshape(-1).contiguous().view(torch.uint8),
persistent=False,
)
module.cr8_rot_size = rot
elif fmt == "nvfp4":
quantizer = get_ostris_quantizer("nvfp4")
# normalize comfy_kitchen's storage to the toolkit's conventions:
# fp4 pairs are packed high-nibble-first and the e4m3 block scales
# are stored in the swizzled cuBLAS 128x4 tile layout
scales = unswizzle_nvfp4_scales(
weight_scale.view(torch.float8_e4m3fn),
module.out_features,
module.in_features // 16,
)
Nvfp4Quantizer.attach_(
module,
packed=swap_nvfp4_nibbles(weight),
scales=scales,
pts=state_dict.pop(f"{prefix}.weight_scale_2"),
pre_scale=state_dict.pop(f"{prefix}.pre_quant_scale", None),
)
else:
raise ValueError(
f"Unsupported comfy quant format {fmt!r} on {prefix} "
"(supported: int8_tensorwise, nvfp4)"
)
# drop unused calibration extras if present
state_dict.pop(f"{prefix}.input_scale", None)
_to_ostris(module, quantizer, orig_dtype)
bias = state_dict.pop(f"{prefix}.bias", None)
if bias is not None and module.bias is not None:
# bias may still be a meta parameter when the model was built under
# a meta device context
module._parameters["bias"] = torch.nn.Parameter(
bias.detach().clone(), requires_grad=False
)
converted += 1
return state_dict, converted

155
toolkit/util/nvfp4_quant.py Normal file
View File

@ -0,0 +1,155 @@
"""NVFP4 (ModelOpt/ComfyUI-style) OstrisQuantizer backend — qtype "nvfp4".
Plain block-16 nvfp4 weight storage without ConvRot's rotation: fp4 e2m1
codes packed two per byte, one fp8 e4m3 scale per 16 elements, one fp32
per-tensor scale, plus an optional AWQ ``pre_quant_scale`` applied
elementwise to the input activation before the matmul (ModelOpt convention,
matching ComfyUI's quantized ops).
This is the layout ComfyUI checkpoints tagged ``{"format": "nvfp4"}`` carry
(e.g. the Comfy-Org MiniMax-H3 text encoder). Those exports set
``full_precision_matrix_mult`` the activations are NOT fp4-quantized so
the forward here is always the dequantized matmul in the activation's dtype:
weights stay at ~4.25 bits in memory and the math runs on any GPU (or CPU),
no Blackwell fp4 tensor cores required. The triton dequant kernel in
convrot_quant is used when available; pure torch otherwise.
Quantized state attached to each module (uint8 byte views, like the convrot
backends, so nn.Module._apply dtype casts can't corrupt them):
nv4_qdata packed e2m1 codes (uint8, out x in/2; low nibble = even column)
nv4_scales e4m3 block scales (out x in/16)
nv4_pts fp32 per-tensor scale (1 element)
nv4_pre_scale optional fp32 AWQ input scale (in,)
"""
from typing import Optional
import torch
import torch.nn.functional as F
from toolkit.util.convrot_quant import BLOCK, dequantize_nvfp4, quantize_nvfp4
from toolkit.util.ostris_quant import OstrisQuantizer
from toolkit.print import print_acc
NVFP4_QTYPES = ("nvfp4",)
_skip_warned = set()
def unswizzle_nvfp4_scales(scales: torch.Tensor, rows: int, cols: int) -> torch.Tensor:
"""Undo the cuBLAS 128x4-tile block-scale layout (comfy_kitchen's
``to_blocked``) back to a row-major (rows, cols) matrix. ComfyUI nvfp4
checkpoints store ``weight_scale`` swizzled; when the dims are already
tile-aligned the shape is unchanged and only the element order differs."""
n_row_blocks = (rows + 127) // 128
n_col_blocks = (cols + 3) // 4
padded_rows = n_row_blocks * 128
padded_cols = n_col_blocks * 4
x = scales.reshape(-1, 32, 16)
x = x.reshape(-1, 32, 4, 4).transpose(1, 2)
x = x.reshape(n_row_blocks, n_col_blocks, 4, 32, 4)
x = x.reshape(n_row_blocks, n_col_blocks, 128, 4)
x = x.permute(0, 2, 1, 3).reshape(padded_rows, padded_cols)
return x[:rows, :cols].contiguous()
def swap_nvfp4_nibbles(packed: torch.Tensor) -> torch.Tensor:
"""ComfyUI packs fp4 pairs high-nibble-first; the toolkit's decode is
low-nibble-first. Swapping nibbles converts between the two."""
return ((packed << 4) | (packed >> 4)).contiguous()
class Nvfp4Quantizer(OstrisQuantizer):
"""Block-16 nvfp4 weights, full-precision activations. One instance is
shareable across modules."""
def can_quantize(self, module: torch.nn.Linear) -> bool:
if module.in_features % BLOCK != 0:
if module.in_features not in _skip_warned:
_skip_warned.add(module.in_features)
print_acc(
f"nvfp4: skipping linears with in_features={module.in_features} "
f"(needs in divisible by {BLOCK})"
)
return False
return True
def quantize_(self, module: torch.nn.Linear, weight_fp32: torch.Tensor) -> None:
packed, scales, pts = quantize_nvfp4(weight_fp32, optimize_scales=True)
self.attach_(module, packed, scales, pts, pre_scale=None)
@staticmethod
def attach_(
module: torch.nn.Module,
packed: torch.Tensor, # uint8 (out, in/2)
scales: torch.Tensor, # float8_e4m3fn (out, in/16)
pts: torch.Tensor, # fp32 scalar per-tensor scale
pre_scale: Optional[torch.Tensor] = None, # (in,) AWQ input scale
) -> None:
"""Register the quantized representation on the module. Used both by
quantize_ and by importers of pre-quantized checkpoints."""
module.register_buffer("nv4_qdata", packed.contiguous(), persistent=False)
module.register_buffer(
"nv4_scales", scales.contiguous().view(torch.uint8), persistent=False
)
module.register_buffer(
"nv4_pts",
pts.detach().float().clone().reshape(1).view(torch.uint8),
persistent=False,
)
if pre_scale is not None:
module.register_buffer(
"nv4_pre_scale",
pre_scale.detach().float().clone().contiguous().view(torch.uint8),
persistent=False,
)
@staticmethod
def _pts(module) -> torch.Tensor:
return module.nv4_pts.view(torch.float32).reshape(())
@staticmethod
def _pre_scale(module) -> Optional[torch.Tensor]:
buf = getattr(module, "nv4_pre_scale", None)
return None if buf is None else buf.view(torch.float32)
def _dequantize_weight(self, module, dtype: torch.dtype) -> torch.Tensor:
return dequantize_nvfp4(
module.nv4_qdata,
module.nv4_scales.view(torch.float8_e4m3fn),
self._pts(module),
module.out_features,
module.in_features,
dtype,
)
def dequantize(self, module) -> torch.Tensor:
"""The weight as stored. NOTE: with an AWQ pre_quant_scale present the
stored weight expects pre-scaled activations; folding the scale back
(w * pre_scale per column) would reconstruct the original-basis weight
but is deliberately not done here forward() owns that contract."""
return self._dequantize_weight(module, torch.float32)
def dequantize_folded(self, module) -> torch.Tensor:
"""Weight for raw (un-pre-scaled) activations: the AWQ pre_quant_scale
multiplies the input elementwise, which folds into the weight columns."""
w = self._dequantize_weight(module, torch.float32)
pre_scale = self._pre_scale(module)
if pre_scale is not None:
w = w * pre_scale.unsqueeze(0)
return w
def requantize_(self, module, fp_weight: torch.Tensor) -> None:
w = fp_weight.to(device=module.nv4_qdata.device, dtype=torch.float32)
packed, scales, pts = quantize_nvfp4(w, optimize_scales=True)
module.nv4_qdata = packed
module.nv4_scales = scales.view(torch.uint8)
module.nv4_pts = pts.detach().clone().reshape(1).view(torch.uint8)
def forward(self, module, x: torch.Tensor) -> torch.Tensor:
pre_scale = self._pre_scale(module)
if pre_scale is not None:
x = x * pre_scale.to(dtype=x.dtype)
with torch.no_grad():
w = self._dequantize_weight(module, x.dtype)
return F.linear(x, w, module.bias)

View File

@ -51,6 +51,13 @@ class OstrisQuantizer:
"""Reconstruct the full weight in the original basis, in float32."""
raise NotImplementedError
def dequantize_folded(self, module: "OstrisLinear") -> torch.Tensor:
"""The full weight with any ACTIVATION-side transform folded in, i.e. a
weight that computes the same output on raw activations. Used when
re-quantizing into a different backend (which won't know about this
backend's activation transforms). Default: same as dequantize."""
return self.dequantize(module)
def requantize_(self, module: "OstrisLinear", fp_weight: torch.Tensor) -> None:
"""Re-quantize in place from a full precision weight in the original basis
(used by the continuous merge/reset method)."""
@ -179,6 +186,7 @@ def get_ostris_quantizer(qtype: str) -> Optional[OstrisQuantizer]:
"""Resolve a qtype string to a quantizer backend instance, or None if the qtype
does not belong to a custom backend. Add new backends here."""
from toolkit.util.convrot_quant import CONVROT_QTYPES, get_convrot_quantizer
from toolkit.util.nvfp4_quant import NVFP4_QTYPES, Nvfp4Quantizer
from toolkit.util.orbit_quant import ORBIT_QTYPES, OrbitQuantizer
from toolkit.util.orbit_vq_quant import ORBIT_VQ_QTYPES, OrbitVQQuantizer
from toolkit.util.uintx_quant import UINTX_QTYPES, UIntXQuantizer
@ -190,6 +198,8 @@ def get_ostris_quantizer(qtype: str) -> Optional[OstrisQuantizer]:
quantizer = OrbitVQQuantizer(**ORBIT_VQ_QTYPES[qtype])
elif qtype in CONVROT_QTYPES:
quantizer = get_convrot_quantizer(qtype)
elif qtype in NVFP4_QTYPES:
quantizer = Nvfp4Quantizer()
elif qtype in UINTX_QTYPES:
quantizer = UIntXQuantizer(UINTX_QTYPES[qtype])
if quantizer is not None:
@ -324,8 +334,33 @@ def convert_linear_to_ostris(
module: torch.nn.Linear, quantizer: OstrisQuantizer
) -> bool:
"""Quantize an nn.Linear in place (class swap). Returns True if the module was
converted (or already was), False if it is not a candidate."""
converted (or already was), False if it is not a candidate.
A module that is ALREADY quantized (e.g. loaded from a pre-quantized
checkpoint) is re-quantized into the requested backend when the qtypes
differ: the weight is dequantized and re-quantized layer by layer, so the
full-precision transient never exceeds one layer's weight. Same qtype is
a no-op (the shipped quantization is kept)."""
if isinstance(module, OstrisLinear):
current_qtype = getattr(module.ostris_quantizer, "qtype", None)
if quantizer.qtype is None or current_qtype == quantizer.qtype:
return True
if not quantizer.can_quantize(module):
return True # keep the existing quantization rather than dropping it
# fold any activation-side transform (e.g. an AWQ pre_quant_scale) into
# the weight so the new backend computes the same function on raw inputs
weight = module.ostris_quantizer.dequantize_folded(module).to(
module.ostris_orig_dtype
)
# backend state lives exclusively in buffers; leftover scalar attrs from
# the old backend are inert
module._buffers.clear()
if quantizer.wants_fp32_weight:
quantizer.quantize_(module, weight.to(torch.float32))
else:
quantizer.quantize_(module, weight)
del weight
module.ostris_quantizer = quantizer
return True
weight = getattr(module, "weight", None)
if not isinstance(weight, torch.nn.Parameter) or not weight.dtype.is_floating_point:

View File

@ -176,7 +176,16 @@ def quantize(
try:
# check if m is QLinear or QConv2d
if m.__class__.__name__ in Q_MODULES:
continue
# OstrisLinear may still be RE-quantized into a different
# ostris qtype (same qtype is a per-layer no-op); every other
# already-quantized module type is always left alone, which
# also keeps quanto/torchao from double-quantizing
# pre-quantized checkpoints
if not (
isinstance(weights, ostristype)
and m.__class__.__name__ == "OstrisLinear"
):
continue
if (
isinstance(weights, aotype)
and not isinstance(m, torch.nn.Linear)
@ -193,7 +202,10 @@ def quantize(
continue
orig_device = None
if quantize_device is not None and next(m.children(), None) is None:
# OstrisLinear layers being re-quantized hold buffers, not params
param = next(m.parameters(recurse=False), None)
if param is None:
param = next(m.buffers(recurse=False), None)
if param is not None:
orig_device = param.device
m.to(quantize_device)

View File

@ -3,7 +3,7 @@ import { Job } from '@prisma/client';
import { spawn, ChildProcess } from 'child_process';
import path from 'path';
import fs from 'fs';
import { TOOLKIT_ROOT, getTrainingFolder, getHFToken } from '../paths';
import { TOOLKIT_ROOT, getTrainingFolder, getHFToken, getModelsPath } from '../paths';
import { resolveDetachedPythonPath } from '../pythonPath';
const isWindows = process.platform === 'win32';
@ -250,6 +250,15 @@ const startAndWatchJob = (job: Job) => {
additionalEnv.HF_TOKEN = hfToken;
}
// MODELS_PATH - one set in the env always takes precedence (it passes
// through via process.env); only fall back to the setting if it is not set
if (!process.env.MODELS_PATH || process.env.MODELS_PATH.trim() === '') {
const modelsPath = await getModelsPath();
if (modelsPath && modelsPath.trim() !== '') {
additionalEnv.MODELS_PATH = modelsPath;
}
}
const args = [runFilePath, configPath];
// Where the Windows relay reports the job's real pid back to us.

View File

@ -5,6 +5,7 @@ export const TOOLKIT_ROOT = path.resolve('@', '..', '..');
export const defaultTrainFolder = path.join(TOOLKIT_ROOT, 'output');
export const defaultDatasetsFolder = path.join(TOOLKIT_ROOT, 'datasets');
export const defaultDataRoot = path.join(TOOLKIT_ROOT, 'data');
export const defaultModelsFolder = path.join(TOOLKIT_ROOT, 'models');
// Forked file-server workers set AI_TOOLKIT_QUIET_PATHS so this line prints
// once per launched process group, not once per worker.
@ -39,3 +40,17 @@ export const getHFToken = async () => {
}
return token;
};
export const getModelsPath = async () => {
const key = 'MODELS_PATH';
let row = await prisma.settings.findFirst({
where: {
key: key,
},
});
let modelsPath = '';
if (row?.value && row.value !== '' && row.value !== defaultModelsFolder) {
modelsPath = row.value;
}
return modelsPath;
};

View File

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import prisma from '@/server/prisma';
import { defaultTrainFolder, defaultDatasetsFolder } from '@/paths';
import { defaultTrainFolder, defaultDatasetsFolder, defaultModelsFolder } from '@/paths';
import { flushCache } from '@/server/settings';
export async function GET() {
@ -18,6 +18,13 @@ export async function GET() {
if (!settingsObject.DATASETS_FOLDER || settingsObject.DATASETS_FOLDER === '') {
settingsObject.DATASETS_FOLDER = defaultDatasetsFolder;
}
// MODELS_PATH from the env file always takes precedence over the setting
if (process.env.MODELS_PATH && process.env.MODELS_PATH.trim() !== '') {
settingsObject.MODELS_PATH = process.env.MODELS_PATH;
} else if (!settingsObject.MODELS_PATH || settingsObject.MODELS_PATH === '') {
// if MODELS_PATH is not set, use default
settingsObject.MODELS_PATH = defaultModelsFolder;
}
return NextResponse.json(settingsObject);
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
@ -27,7 +34,7 @@ export async function GET() {
export async function POST(request: Request) {
try {
const body = await request.json();
const { HF_TOKEN, TRAINING_FOLDER, DATASETS_FOLDER } = body;
const { HF_TOKEN, TRAINING_FOLDER, DATASETS_FOLDER, MODELS_PATH } = body;
// Upsert both settings
await Promise.all([
@ -46,6 +53,11 @@ export async function POST(request: Request) {
update: { value: DATASETS_FOLDER },
create: { key: 'DATASETS_FOLDER', value: DATASETS_FOLDER },
}),
prisma.settings.upsert({
where: { key: 'MODELS_PATH' },
update: { value: MODELS_PATH },
create: { key: 'MODELS_PATH', value: MODELS_PATH },
}),
]);
flushCache();

View File

@ -324,6 +324,30 @@ export default function SimpleJob({
placeholder=""
/>
)}
{modelArch?.modelNotes && (
<div className="pt-2">
<button
type="button"
onClick={() => {
const gateUrl = modelArch.gateUrl as string;
openDoc({
title: `Notes - ${modelArch.label}`,
description: (
<div className="space-y-3">
{modelArch.modelNotes}
</div>
),
});
}}
className="w-full flex items-center gap-2 rounded-md bg-blue-950/60 border border-blue-800 px-3 py-2 text-sm text-blue-200 hover:bg-blue-900/60 text-left"
>
<Info className="w-4 h-4 shrink-0 text-blue-400" />
<span>
Model notes
</span>
</button>
</div>
)}
{modelArch?.gateUrl && (
<div className="pt-2">
<button
@ -368,9 +392,9 @@ export default function SimpleJob({
),
});
}}
className="w-full flex items-center gap-2 rounded-md bg-blue-950/60 border border-blue-800 px-3 py-2 text-sm text-blue-200 hover:bg-blue-900/60 text-left"
className="w-full flex items-center gap-2 rounded-md bg-yellow-950/60 border border-yellow-800 px-3 py-2 text-sm text-yellow-200 hover:bg-yellow-900/60 text-left"
>
<Info className="w-4 h-4 shrink-0 text-blue-400" />
<Info className="w-4 h-4 shrink-0 text-yellow-400" />
<span>
Gated model. <span className="underline">Learn more.</span>
</span>

View File

@ -1,3 +1,5 @@
import React from 'react';
import Link from 'next/link';
import { GroupedSelectOption, SelectOption, JobConfig } from '@/types';
import { defaultSliderConfig } from './jobConfig';
import { defaultAudioSampleConfig, defaultSampleConfig, defaultIdeogramSamplesConfig } from '@/helpers/defaultSamples';
@ -61,6 +63,7 @@ export interface ModelArch {
accuracyRecoveryAdapters?: { [key: string]: string };
sampleTags?: SampleTags;
gateUrl?: string;
modelNotes?: React.ReactNode;
}
const defaultNameOrPath = '';
@ -695,6 +698,77 @@ export const modelArchs: ModelArch[] = [
disableSections: ['network.conv'],
additionalSections: ['model.low_vram', 'model.layer_offloading'],
},
{
name: 'minimax_h3',
label: 'MiniMax-H3',
group: 'video',
isVideoModel: true,
defaults: {
// default updates when [selected, unselected] in the UI
'config.process[0].model.name_or_path': ['Comfy-Org/MiniMax-H3', defaultNameOrPath],
// the Comfy-Org weights are pre-quantized (int8 convrot DiT, nvfp4 TE); these
// qtypes match the checkpoints exactly, so the load is unchanged. Picking a
// different qtype re-quantizes layer by layer into that format.
'config.process[0].model.quantize': [true, false],
'config.process[0].model.qtype': ['convrot8', 'qfloat8'],
'config.process[0].model.quantize_te': [true, false],
'config.process[0].model.qtype_te': ['nvfp4', 'qfloat8'],
'config.process[0].model.low_vram': [true, false],
'config.process[0].sample.sampler': ['flowmatch', 'flowmatch'],
'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
'config.process[0].train.cache_text_embeddings': [true, false],
'config.process[0].network.linear': [16, defaultLinearRank],
'config.process[0].sample.num_frames': [107, 1],
'config.process[0].sample.fps': [24, 1],
'config.process[0].sample.width': [768, 1024],
'config.process[0].sample.height': [768, 1024],
'config.process[0].sample.guidance_scale': [1, 4],
'config.process[0].sample.sample_steps': [28, 25],
'config.process[0].train.audio_loss_multiplier': [1.0, undefined],
'config.process[0].train.timestep_type': ['shift', 'sigmoid'],
'config.process[0].datasets[x].do_i2v': [false, undefined],
'config.process[0].datasets[x].do_audio': [true, undefined],
'config.process[0].datasets[x].cache_latents_to_disk': [true, false],
'config.process[0].datasets[x].fps': [24, undefined],
'config.process[0].datasets[x].num_frames': [39, undefined],
'config.process[0].datasets[x].auto_frame_count': [true, undefined],
},
disableSections: ['network.conv'],
additionalSections: ['sample.ctrl_img', 'datasets.num_frames', 'model.layer_offloading', 'model.low_vram', 'datasets.do_audio', 'datasets.audio_normalize', 'datasets.audio_preserve_pitch', 'datasets.do_i2v', 'train.audio_loss_multiplier', 'datasets.auto_frame_count'],
modelNotes: (
<div className="space-y-2">
<p>
Weights load from the{' '}
<Link href="/settings" className="text-blue-400 hover:underline">
Models Folder Path
</Link>{' '}
set in settings. Anything missing is downloaded there from <code>Comfy-Org/MiniMax-H3</code> on first load
(~43GB total). Files used:
</p>
<pre className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs overflow-x-auto">
<code>{`<MODELS_PATH>/
diffusion_models/
minimax_h3_fl2va_pruned_int8_convrot.safetensors
text_encoders/
qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors
vae/
minimax_h3_video_vae_fp16.safetensors
minimax_h3_audio_vae_fp32.safetensors`}</code>
</pre>
<p>
The checkpoints are pre-quantized and load directly: int8 ConvRot DiT (~21GB) and nvfp4 Qwen3-VL text encoder
(~16GB). The default qtypes (<code>convrot8</code> / <code>nvfp4</code>) match the files exactly, so nothing
is re-quantized on load. Picking a different quantization re-quantizes the pre-quantized layers into that
format, one layer at a time.
</p>
<p>
Supports t2v and first-frame i2v (ctrl img / i2v datasets) with joint audio. The model is guidance-distilled
keep guidance scale at 1. Video is fixed 24 fps and frame counts snap down to the 17n+5 grid (5, 22, 39, 56,
..., 107, 124 5s).
</p>
</div>
),
},
{
name: 'ltx2',
label: 'LTX-2',
@ -1321,6 +1395,7 @@ export const quantizationOptions: SelectOption[] = [
{ value: 'float8', label: 'float8' },
{ value: 'convrot8', label: '8bit convrot' },
{ value: 'convrot4', label: '4bit convrot (nvfp4)' },
{ value: 'nvfp4', label: 'nvfp4 (4bit weight only)' },
{ value: 'convrotint7', label: '7bit convrot' },
{ value: 'convrotint6', label: '6bit convrot' },
{ value: 'convrotint5', label: '5bit convrot' },

View File

@ -108,6 +108,26 @@ export default function Settings() {
placeholder="Enter datasets folder path"
/>
</div>
<div>
<label htmlFor="MODELS_PATH" className="block text-sm font-medium mb-2">
Models Folder Path
<div className="text-gray-500 text-sm ml-1">
Some models support loading ComfyUI model weights directly. Models that do will be loaded
from/downloaded to this path. Must be an absolute path. If blank, it will default to the models
folder in the project root.
</div>
</label>
<input
type="text"
id="MODELS_PATH"
name="MODELS_PATH"
value={settings.MODELS_PATH}
onChange={handleChange}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-gray-600 focus:border-transparent"
placeholder="Enter models folder path"
/>
</div>
</div>
</div>
</div>

View File

@ -7,6 +7,7 @@ export interface Settings {
HF_TOKEN: string;
TRAINING_FOLDER: string;
DATASETS_FOLDER: string;
MODELS_PATH: string;
}
export default function useSettings() {
@ -14,6 +15,7 @@ export default function useSettings() {
HF_TOKEN: '',
TRAINING_FOLDER: '',
DATASETS_FOLDER: '',
MODELS_PATH: '',
});
const [isSettingsLoaded, setIsLoaded] = useState(false);
useEffect(() => {
@ -26,6 +28,7 @@ export default function useSettings() {
HF_TOKEN: data.HF_TOKEN || '',
TRAINING_FOLDER: data.TRAINING_FOLDER || '',
DATASETS_FOLDER: data.DATASETS_FOLDER || '',
MODELS_PATH: data.MODELS_PATH || '',
});
setIsLoaded(true);
})

View File

@ -3,3 +3,4 @@ export const TOOLKIT_ROOT = path.resolve('@', '..', '..');
export const defaultTrainFolder = path.join(TOOLKIT_ROOT, 'output');
export const defaultDatasetsFolder = path.join(TOOLKIT_ROOT, 'datasets');
export const defaultDataRoot = path.join(TOOLKIT_ROOT, 'data');
export const defaultModelsFolder = path.join(TOOLKIT_ROOT, 'models');

View File

@ -1 +1 @@
VERSION = "0.12.1"
VERSION = "0.12.2"