Skip to content

Add MiniMax-H3 - #14355

Open
apolinario wants to merge 18 commits into
mainfrom
minimax-h3
Open

Add MiniMax-H3#14355
apolinario wants to merge 18 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

# rows and padding rows with padding rows reproduces that split exactly. Padless sequences keep `None` so the
# unmasked fast paths (flash & co.) stay available.
attention_mask = None
is_pad = token_tags < 0

@yiyixuxu yiyixuxu Aug 4, 2026

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.

@apolinario is it ok we remove the code path related to padded-layout? our pipeline does not pad but there is a test suit for this so not sure if we can remove

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>
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.

4 participants