Add MiniMax-H3 - #14355
Conversation
Self-review reportPre-PR self-review per Design
Fixed during self-review and follow-up validation
Left for maintainer review, deliberately
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 |
|
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. |
61744d5 to
e1b518d
Compare
|
pipe.to("cuda"), out of memory for H100 84G memory |
review & refactor
`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>
`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>
`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>
| ) | ||
|
|
||
| trigger_inputs = self._workflow_map[workflow_name] | ||
| if isinstance(trigger_inputs, tuple): |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
super nice and thorough, thanks!
Co-authored-by: Steven Liu <59462357+stevhliu@users.noreply.github.com>
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
DiffusionPipelinehalf, the way Anima is. Two blocksets sit over one repository:MiniMaxH3Blocks(t2va,fl2va) readstransformer/,MiniMaxH3Ref2VABlocks(ref2va) readstransformer_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_pretrainedfetches 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
Video and audio come out of the one call as
videosandaudio, next to thesampling_rateof 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):Omni reference (image, video, audio references)
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_videogates 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.0and 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_framesmay be omitted and the duration is that soundtrack's, snapped up to the next17n+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:
And speech synchronization from a single image plus a voice recording, where the duration comes from the audio and
num_framesstays unset:Notes
negative_prompt, one forward per step.scheduler/for video at shift 12.0,audio_scheduler/for audio at shift 3.0).MiniMaxH3Scheduleris a new class: the model predicts a data pointing velocity (x0 = x_t + sigma * v), which existing flow match schedulers cannot express.num_inference_stepscounts sigma grid points, the terminal0included, so 30 steps drive 29 model evaluations and the progress bar shows 29.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.ref2vaentry point isMiniMaxH3Ref2VABlocks().init_pipeline(repo)today, and the design is ready for the plannedModularPipeline.from_pretrainedworkflow argument, at which point both halves load throughfrom_pretraineddirectly.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_clipand._encodecarry@apply_forward_hooklike the publicencode/decode: keyframe and reference encoding goes through them rather than throughencode, and without the hook the VAE stays on the CPU under offloading. The conditioner call fires the same hook by hand, because MiniMax-H3 readshidden_states[50]offtext_encoder.modeland 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).