Skip to content

Add MiniMax-H3 - #14355

Merged
yiyixuxu merged 22 commits into
mainfrom
minimax-h3
Aug 5, 2026
Merged

Add MiniMax-H3#14355
yiyixuxu merged 22 commits into
mainfrom
minimax-h3

Conversation

@apolinario

@apolinario apolinario commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Adds the MiniMax-H3 joint video and audio generation model: transformer, video VAE, audio VAE, scheduler, Modular Diffusers blocks for both released tasks, conversion script, docs, and tests.

Checkpoint: MiniMaxAI/MiniMax-H3 (single repo hosting both transformer variants at the root; the shared text encoder, VAEs and processor are stored once)

MiniMax-H3 generates video with synchronized stereo audio in a single denoising pass. One packed token sequence carries text, conditioning, audio and video rows through a shared 33B transformer; modality behavior comes from two input projections, a per row AdaLN modality tag, and two output heads. The released weights ship two transformers over shared components: one serving text to video+audio and first/last frame conditioning, one serving omni reference conditioning.

Modular only

The integration is Modular Diffusers blocks only, with no DiffusionPipeline half, the way Anima is. Two blocksets sit over one repository: MiniMaxH3Blocks (t2va, fl2va) reads transformer/, MiniMaxH3Ref2VABlocks (ref2va) reads transformer_ref/, and everything else is shared.

That shape is what makes one repository work. A modular index declares one loading spec per component, so from_pretrained fetches exactly the subfolders the blockset names and nothing else. It matters here beyond the two DiT partitions: the production repo hosts the original checkpoint folders alongside the converted ones, and a component wise index means loading either half never pulls the rest of the repo down.

Text to video and audio

import torch
from diffusers import ModularPipeline
from diffusers.utils import encode_video

pipe = ModularPipeline.from_pretrained("MiniMaxAI/MiniMax-H3")
pipe.load_components(dtype=torch.bfloat16)
pipe.to("cuda")

state = pipe(
    prompt="A red fox trotting through a snowy pine forest, snow crunching underfoot",
    num_frames=124,  # snapped up to 17n+5, 24 fps
    height=768,
    width=1344,
    num_inference_steps=30,
    generator=torch.Generator("cpu").manual_seed(42),
)
encode_video(
    state.get("videos")[0],
    fps=24,
    audio=state.get("audio")[0],
    audio_sample_rate=state.get("sampling_rate"),
    output_path="fox.mp4",
)

Video and audio come out of the one call as videos and audio, next to the sampling_rate of the soundtrack. Muxing them into one file is the caller's job.

First/last frame conditioning

image= conditions the first frame, last_image= the last, either or both (Wan style):

state = pipe(
    prompt="...",
    image=first_frame,        # PIL image, stretched to the target canvas
    last_image=last_frame,    # optional, cover cropped
    ...
)

Omni reference (image, video, audio references)

import torch
from diffusers.modular_pipelines import MiniMaxH3Ref2VABlocks
from diffusers.modular_pipelines.minimax_h3 import MiniMaxH3Reference

pipe = MiniMaxH3Ref2VABlocks().init_pipeline("MiniMaxAI/MiniMax-H3")
pipe.load_components(dtype=torch.bfloat16)
pipe.to("cuda")

state = pipe(
    prompt="The subject walks toward camera, matching the reference video's shot rhythm",
    references=[
        MiniMaxH3Reference(video="motion_ref.mp4"),  # motion and camera reference, soundtrack included
        MiniMaxH3Reference(image=subject_image),  # identity reference
        MiniMaxH3Reference(audio="voice.wav"),  # audio must be paired with an image or video reference
    ],
    num_frames=124,
    num_inference_steps=30,
)

A reference takes a path or a URL as well as in memory media, and decodes it as it is built, with PyAV for video and audio (an existing optional dependency, gated the way encode_video gates it). The rates come with it: a video reference reads its frame rate off the container and adopts its soundtrack when it has one, and an audio reference its sample rate. No block ever opens a file, and nothing is rebuilt: by the time a reference reaches the blocks, it is pixels and samples.

In memory media declares its own rates, defaulting to the model's own: fps=24.0 and the audio VAE sample rate, so only self generated data at another rate has to say so, and an explicit rate also wins over a container whose metadata is wrong. Frames land on the model's 24 fps grid by whole frame drop and duplicate, the same selection ffmpeg's fps filter made in the reference implementation, and a waveform is resampled once onto the audio VAE rate.

References pack in request order (order is load bearing for the model). Up to 9 images, 3 videos of 2 to 15 seconds, 3 audio clips, 12 references total. When exactly one audio bearing reference is present, num_frames may be omitted and the duration is that soundtrack's, snapped up to the next 17n+5; a soundtrack whose snapped duration leaves the 5 to 15 second window is rejected rather than silently stretched.

Two more shapes the same call covers. Multiple image references, where order assigns the roles the prompt names:

state = pipe(
    prompt="Use Image 1 for mood and visual language, use Image 2 as the protagonist reference",
    references=[
        MiniMaxH3Reference(image=style_image),
        MiniMaxH3Reference(image=protagonist_image),
    ],
    num_frames=124,
    num_inference_steps=30,
)

And speech synchronization from a single image plus a voice recording, where the duration comes from the audio and num_frames stays unset:

state = pipe(
    prompt="The character speaks in time with the reference recording, natural lip movement",
    references=[
        MiniMaxH3Reference(image=character_image),
        MiniMaxH3Reference(audio="voice.wav"),
    ],
    num_inference_steps=30,
)

Notes

  • Guidance is distilled into the weights: no CFG, no negative_prompt, one forward per step.
  • Two scheduler instances ride in the repo (scheduler/ for video at shift 12.0, audio_scheduler/ for audio at shift 3.0). MiniMaxH3Scheduler is a new class: the model predicts a data pointing velocity (x0 = x_t + sigma * v), which existing flow match schedulers cannot express.
  • num_inference_steps counts sigma grid points, the terminal 0 included, so 30 steps drive 29 model evaluations and the progress bar shows 29.
  • The video VAE decodes under fp16 autocast over fp32 weights, matching the reference recipe; tiling is on by default because the released model always tiles.
  • 768p generation on a single 80GB card works with ComponentsManager.enable_auto_cpu_offload; the transformer alone is 61.7GB in bf16.
  • pipe.transformer.set_attention_backend("_flash_3_hub") gives roughly 3x faster denoising on Hopper with kernels fetched from the Hub, no flash-attn build required.
  • The ref2va entry point is MiniMaxH3Ref2VABlocks().init_pipeline(repo) today, and the design is ready for the planned ModularPipeline.from_pretrained workflow argument, at which point both halves load through from_pretrained directly.
  • The modular tests read a tiny pipeline repo at hf-internal-testing/tiny-minimax-h3-modular-pipe, which does not exist yet; happy to hand over the builder script or the files for someone with access to create it.
  • AutoencoderKLMiniMaxH3._encode_clip and ._encode carry @apply_forward_hook like the public encode/decode: keyframe and reference encoding goes through them rather than through encode, and without the hook the VAE stays on the CPU under offloading. The conditioner call fires the same hook by hand, because MiniMax-H3 reads hidden_states[50] off text_encoder.model and never uses the language model head.

Numerical parity against the reference implementation, verified per component (CPU float32) and end to end on GPU: the converted transformer reproduces the reference denoising trajectories bit for bit across all 15 documented use case configurations at 30 steps (both transformer variants, all aspect ratios, all conditioning modes).

@github-actions github-actions Bot added size/L PR with diff > 200 LOC documentation Improvements or additions to documentation models tests modular-pipelines utils schedulers and removed size/L PR with diff > 200 LOC labels Aug 2, 2026
@apolinario

Copy link
Copy Markdown
Collaborator Author

Self-review report

Pre-PR self-review per .ai/review-rules.md, updated for the modular-only design (this comment supersedes its earlier version, which reviewed the since-removed standard pipelines).

Design

  • MiniMax-H3 ships as a modular-only integration (anima precedent): one repo, one modular_model_index.json, two blocksets. MiniMaxH3Blocks declares transformer, MiniMaxH3Ref2VABlocks declares transformer_ref; each half loads exactly its declared components. Verified empirically: a fresh-cache component load against the 210 GB repo fetched 578 MB, only the named subfolders, and the ref2va blockset never touches transformer/. This is what makes hosting the original checkpoint folders alongside the diffusers layout in one repo safe by construction.
  • The ref2va entry point is MiniMaxH3Ref2VABlocks().init_pipeline(repo) today; both blocksets carry a _workflow_map so the planned ModularPipeline.from_pretrained(workflow=...) argument slots in without changes here.

Fixed during self-review and follow-up validation

  • Offload-hook bypass class: submodule and private-method calls route around accelerate's CpuOffload forward wrapper (component silently stays on CPU). Three sites fixed: @apply_forward_hook on AutoencoderKLMiniMaxH3._encode_clip / ._encode, and both encode_prompts fire the hook manually before text_encoder.model(...) (routing through the top-level forward would run the 151936-way LM head purely to trigger a hook). The modular ComponentsManager offloading has the same semantics and is covered by the same fixes.
  • Both VAEs pin themselves float32 under torch_dtype casts (_keep_in_fp32_modules, matching the transformer's mixed-precision contract): the released checkpoint is fp32 and a bf16 audio VAE decodes roughly 20 dB too quiet. The pin is asserted by positive tests rather than skips.
  • Audio VAE attention refactored to the diffusers attention pattern (processor + AttentionModuleMixin + dispatch_attention_fn), verified bitwise against the reference on the real checkpoint. is_causal is honored by every registered backend except _native_npu (documented).
  • Generator semantics are the standard convention: one generator, all draws via randn_tensor in a documented order, latents= injection, reproducibility pinned by tests on frames and audio (same seed twice identical, across t2va, keyframed and referenced runs).
  • Duration ceiling validated on the aligned (17n+5) frame count everywhere, including the audio-derived-duration path.
  • Docstring, dead-code and callback findings from the original pass: all applied, each gated by the corresponding bitwise parity suite.

Left for maintainer review, deliberately

  • Weight-dtype-derived casts in the transformer and audio VAE: necessary under the mixed-precision checkpoint plus _keep_in_fp32_modules (where self.dtype is the wrong target for exactly the fp32 modules); interacts with quantized loading, so it needs an explicit ack rather than a silent pattern violation.
  • _no_split_modules and the video VAE ViT decoder (register_tokens concat in forward): only a multi-GPU device_map="auto" run settles whether the decoder needs listing or repacking.
  • Transformer attention_mask path is reachable only for hand-built padded layouts (both packers emit padless sequences); kept with test coverage, and the model docs note that padded layouts require a masked attention backend. Same mask-in-forward class as HunyuanVideo, but dormant in the shipped path.
  • No MiniMaxH3LoraLoaderMixin in this PR, consistent with the no-LoRA-tests-initially convention.
  • Full-graph torch.compile / torch.export are blocked by the data-dependent pad-mask branch; regional compilation (compile_repeated_blocks) and non-fullgraph compile work and are tested.

Verification summary: the transformer reproduces the reference implementation's denoising trajectories bit for bit across all 15 documented use-case configurations at 30 steps (both transformer partitions; verified against pinned reference commit 232f31f8b); VAEs, scheduler and packing verified bitwise (packing suites 68 + 211 checks); text-encoder tokens, tags and RoPE positions identical with embeddings at the attention-kernel noise floor; modular tests 69 passed (plus model tests, self-consistency and CPU smoke gates); ruff, check_copies, check_dummies, check_forward_call_docstrings, check_doc_toc, custom_init_isort, modular_auto_docstring all clean.

@apolinario
apolinario requested a review from yiyixuxu August 2, 2026 01:52
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@github-actions github-actions Bot added the size/L PR with diff > 200 LOC label Aug 2, 2026
@apolinario
apolinario force-pushed the minimax-h3 branch 2 times, most recently from 61744d5 to e1b518d Compare August 2, 2026 02:06
@perryyang-hue

Copy link
Copy Markdown

pipe.to("cuda"), out of memory for H100 84G memory

Comment thread src/diffusers/models/transformers/transformer_minimax_h3.py Outdated
yiyixuxu and others added 6 commits August 4, 2026 14:54
`make modular-autodoctrings` and `make quality` both failed on CI. The nine
`# auto_docstring` blocks in `modular_blocks_minimax_h3.py` were stale, and the
docstrings of eleven files had not been through `doc-builder style`.

Nothing but docstrings and comments changes here.

One trap worth recording: `utils/modular_auto_docstring.py` shells out to
`ruff` and degrades silently when it is not on `PATH` ("Warning: tool not
found ... Skipping formatting"), which makes it rewrite 42 unrelated pipelines
instead of the one that is stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`condition_latents` is the VAE encoder block's own output — a list of one latent
per condition — and the after-denoise step unpacks `latents` and drops the
conditioning rows, so neither is meaningful once a request has come back. The
end-to-end tests no longer reach for them; a new standalone test runs
`MiniMaxH3KeyframeVaeEncoderStep` on its own, which is where the list is real.

Three other tests were failing for reasons of their own:

- `test_check_inputs_references` was missing its `@parametrize` and errored at
  collection. Added four cases that hit real validation.
- The duration ceiling had drifted between the two blocks that check it.
  `before_denoise` warned and reassigned before validating, so it reported `got
  362` — a count the caller never passed, right after warning it had rounded
  their 346. It now validates first, like `before_encoder` already did, and both
  messages read `got 346 (rounded up to 362)`.
- `height` without `width` raised `TypeError: unsupported operand type(s) for %:
  'NoneType' and 'int'` on `t2va`, and died inside the resize on `fl2va`. Both
  blocks now raise the same error `MiniMaxH3Ref2VASetupStep` already raised.

58 passed -> 71 passed, 5 failed, no errors. What is left: `output_type="latent"`
has no opt-out in the video decode block, `reference_image_short_edge` is a
constructor argument so it does not survive save/reload, an image reference is
PIL-only where the test expects three layouts, and `test_float16_inference`,
which fails on the base branch too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The block declares it `InputParam.template("num_inference_steps", required=True)`
and `expected_workflow_defaults` lists it under `required_inputs` for all three
workflows, so `optional_params` said the opposite in the same file. It was
inherited from the mixin's default set and never examined.

Nothing enforces the requirement today: `_check_inputs` substitutes the declared
default before it tests `required`, and the template carries `default=50`, so
omitting the input runs 50 steps rather than raising. That is not specific to
H3 — 14 call sites on main are in the same position — and it is tracked in #14388.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two-card section did not run, on this branch or on the base one. A pipeline
resolves a single execution device for all of its components, so the documented
shape — one pipeline whose conditioner is `device_map`ped to the second card and
whose denoiser is moved to the first — built the rotary positions on `cuda:1` and
handed them to a transformer on `cuda:0`:

    transformer_minimax_h3.py, in Rope.forward
    RuntimeError: Expected all tensors to be on the same device, but found at
    least two devices, cuda:1 and cuda:0!

Reproduced against `abc5e9bf7` too, so it is not something the refactor broke;
the section went in as a plausible recipe that was never executed.

The split is therefore between pipelines rather than inside one: pop the text
encoder block into a conditioner of its own, give each pipeline a
`ComponentsManager` pinned to a card, and pass the state from one call to the
other. The managers' hooks are what align `prompt_embeds` onto the denoiser's
card; placing the components by hand instead works too, but then that one tensor
has to be moved explicitly. Verified end to end against the tiny fixture.

`output_type="latent"` is separate. It is not an output format — a request that
wants latents runs a pipeline without the decode blocks — and it used to run the
whole VAE decode before dying inside `postprocess_video` with "latent does not
exist", which does not say what to do instead. The video decode block now rejects
anything it cannot postprocess, before decoding. `test_output_type` covers the
three formats it does accept, and the rejection is a `test_check_inputs` case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_from_pretrained_workflow`, `test_load_components_workflow` and
`test_unload_components` build a pipeline from `pretrained_model_name_or_path`
and compare it against the test class's own blocks. Three fixtures cannot answer
that, in three different ways, and none of them is about the pipelines' code:

- `tiny-anima-modular-pipe` has no `modular_model_index.json`. Anima assembles
  its dummy components in `get_pipeline` and never loads from a repository, so
  the path it declares had never been exercised.
- `tiny-flux2-klein-modular` names `Flux2KleinBaseAutoBlocks` while its
  `_class_name` and `is_distilled` both say distilled. `from_pretrained` honours
  `_blocks_class_name`, so it builds base blocks, which declare a `guider` the
  distilled ones do not.
- `tiny-qwenimage-edit-modular` names `QwenImageModularPipeline`, so
  `from_pretrained` falls back to `QwenImageAutoBlocks`, which declares no
  `image_conditioned` workflow.

Skipped with a TODO each, since the fixes are Hub-side. The klein and qwenimage
ones are a single key in `modular_model_index.json`; Anima needs a repository
published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
yiyixuxu and others added 2 commits August 5, 2026 06:02
`MiniMaxH3Ref2VASetupStep` resolves three pieces of released-checkpoint geometry
— the canvas short edge, the canvas area cap, and the separate short edge an
image reference is encoded at — and declared only the first two as `ConfigSpec`s.
The third was a constructor argument, so it did not survive a save and reload:
the reloaded pipeline rebuilt the block from the repository, took the 2048
default instead of the 64 the tests configure, resized reference images at a
different resolution, and landed 0.398 away from the original output.

All three now sit together, which is also what lets the test fixture stop
reaching into the block tree to swap in a configured copy — the shrunken
geometry is one `update_components` call next to the canvas rule it belongs
with, and `test_reference_image_geometry` exercises the released default rather
than constructing the block with an explicit 2048.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`MiniMaxH3VideoReference.frames` takes a list of images, a channels-last array
or a channels-first tensor; `MiniMaxH3ImageReference.image` took a PIL image
only, and the two array layouts failed on `entry.image.size` — an element count
on numpy, a bound method on torch — with `TypeError: 'int' object is not
subscriptable`.

Passing an array through as-is would have been worse than the crash. The resize
would silently drop to `F.interpolate`'s nearest-neighbour where a PIL image
gets LANCZOS, and the VAE encode does `np.array(image).permute(2, 0, 1)`, which
fixes the axes of an image and scrambles those of a channels-first tensor. So
the layouts are normalized onto a PIL image before any of that, through the
processor component the block already declares: `pt_to_numpy` for the channel
axis and `numpy_to_pil` for the array. Neither converts dtype, and
`numpy_to_pil` scales by 255, so `uint8` is normalized on the original object
first — after `pt_to_numpy` a `uint8` tensor is floats over `[0, 255]` and a
later dtype check would miss it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
yiyixuxu and others added 3 commits August 5, 2026 08:19
`before_denoise.py` imported `MINIMAX_H3_AUDIO_CHANNELS`, `MINIMAX_H3_AUDIO_TAG`
and `MINIMAX_H3_VIDEO_TAG` and read them inside its layout builders, even though
two of the three were already exposed as pipeline properties and used that way
everywhere else. They are arguments now, passed from `components.*` at the two
`__call__` sites; `audio_tag` needed the property `text_tag` and `video_tag`
already had, which is presumably why it was the one still reaching for the
global.

Same treatment where a helper cannot see a pipeline: `resolve_canvas_size` and
`audio_latent_num_frames` take the constants as default arguments instead of
closing over them, and `_normalize_video_condition` takes the rate it resamples
onto, so `before_encoder.py` imports none of them at all. What is left of the
constants is their definitions, the properties that expose them, and default
argument values.

Two helpers with a single caller each are inlined at it: the two rotary-time
sums, which are a parity contract — the reference sums the same series pairwise
in one place and sequentially in the other, and the orders differ in the last
ulp from 16 latent frames onwards. Both inlined sums are bit-identical to the
functions they replace across 1..399 latent frames, and the two still disagree
on 373 of those counts. `_frame_position_grid` stays, and `build_packed_sequence`
now calls it rather than repeating its five lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The conditioner reads a reference video at 2 fps and Qwen3-VL merges the sampled
frames in groups of two, so anything under 13 frames at 24 fps samples down to a
single frame and dies inside transformers:

    image_processing_glm4v.py
    ValueError: t:1 must be larger than temporal_factor:2

which names neither the reference nor the rate that produced it, and only fires
on the versions carrying that check — it passed locally and failed on CI.
`_sample_video_condition_frames` now rejects it where the sampling happens, with
the bound derived from the rates rather than hardcoded: "must run at least 13
frames at 24 fps (0.54 seconds), got 4".

`test_reference_media_layouts` was building exactly such a reference, four
frames, which is what surfaced this. It uses a second of video now, like
`test_reference_combinations` already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`forward` built a boolean attention mask whenever a row carried a negative
modality tag, reproducing the reference implementation's `cu_seqlens = [0, used,
S]` split of the padding tail it adds for FlashAttention. Nothing in this port
ever produces such a row: both packers partition `[0, sequence_length)` and write
only the text, audio and video tags, so the branch was unreachable and the mask
was never built. The `clamp(min=0)` that kept a `-1` from indexing the AdaLN
table backwards goes with it.

Removing it is not only dead-code removal. `if bool(is_pad.any())` is a
data-dependent branch, which has no fullgraph representation, and it was the
reason whole-model `fullgraph=True` compilation and `torch.export` were skipped:

    4 passed, 1 skipped     tests -k "compile or export or aot"

Both now run. The model tests go from 40 passed / 4 skipped to 41 passed /
2 skipped, against the same 8 pre-existing memory-offload failures.

`attention_mask` stays on the processor, attention and block signatures — it is
the signature every other processor in diffusers has, and a custom one may want
it — but the model itself never passes anything but `None`, so attention runs
unmasked over the one document and every backend stays available.

Two of the four documentation examples have been re-run against this and come
back bit-identical, frames and audio: `t2va` at the full 768x1344 canvas and
`fl2va`. The two `ref2va` ones are still to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@yiyixuxu yiyixuxu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

)

trigger_inputs = self._workflow_map[workflow_name]
if isinstance(trigger_inputs, tuple):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is to support H3's workflow map, where more than one possibility of inputs combination can trigger the same workflow (because they are literally same set of blocks)

   _workflow_map = {
         ...
        "fl2va": ({"prompt": True, "image": True}, {"prompt": True, "last_image": True}),
         ...
    }

@stevhliu stevhliu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nice and thorough, thanks!

Comment thread docs/source/en/api/models/autoencoderkl_minimax_h3.md Outdated
Comment thread docs/source/en/api/models/minimax_h3_transformer3d.md Outdated
Comment thread docs/source/en/api/pipelines/minimax_h3.md Outdated
Co-authored-by: Steven Liu <59462357+stevhliu@users.noreply.github.com>
@yiyixuxu
yiyixuxu merged commit f53d552 into main Aug 5, 2026
19 of 20 checks passed
@yiyixuxu
yiyixuxu deleted the minimax-h3 branch August 5, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation models modular-pipelines schedulers size/L PR with diff > 200 LOC tests utils

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants