Architecture teardown · read from the released source
H3 (Hailuo 3.0, open-weighted 31 July 2026) generates up to 15 s of 2K video with its own
synchronised soundtrack from text, images, video and audio references. This page explains how,
starting from zero — no prior knowledge of latents, patchify, or diffusion assumed. Every number
here was read out of the released configs and the diffusers implementation, not from
marketing copy.
Everything below rests on three moves. If these are familiar, skip to §02.
Raw video is enormous and mostly redundant. So a separate small network — an autoencoder, here called a VAE — is trained first, and only to do one thing: squash video into a much smaller grid of numbers (encode) and expand it back to pixels (decode). That small grid is the latent. H3's visual VAE shrinks each frame 16× in width and height, shrinks the frame rate 4×, and describes every surviving cell with 24 numbers ("channels") instead of 3 RGB values.
The big 33 B model never sees a pixel. It works entirely in latent space. Pixels exist only at the very start and the very end.
A transformer does not consume grids; it consumes a flat list of vectors, called tokens. So the
latent grid has to be chopped into a list. Patchify is that chop: H3 takes every
non-overlapping 1 × 2 × 2 block (1 time-step × 2 latent rows × 2 latent columns), and glues
those 4 cells' 24 channels into a single vector of 24 × 1 × 2 × 2 = 96 numbers. That
96-number vector is one token.
Net effect: 16× from the VAE, times another 2× from patchify, means 32× spatial shrink from pixels to tokens. Worked example below.
To generate, H3 fills the target tokens with pure random noise and then runs the transformer N times. Each run predicts "which direction is less noisy?" (a velocity), and a small non-learned routine called the scheduler takes one step in that direction. After N steps the noise has become a video. This is flow matching, a modern form of diffusion.
The transformer is the only learned part of that loop, and it is run from scratch every step — there is no carry-over cache between steps, unlike a chatbot generating words.
Following one concrete clip all the way down. This is the single most useful diagram on the page: almost every later cost and design decision follows from the token count it produces.
These two words get used loosely and they mean different things. A latent frame is one time-slice of the latent grid — a whole two-dimensional sheet of cells, 44 × 80 in the example above. Patchified 1 × 2 × 2, that single sheet becomes 22 × 40 = 880 tokens, every one of which carries the same time coordinate and differs only in height and width.
So the 27,280 figure is 31 latent frames × 880 tokens each. Audio works differently: one audio latent frame is a single 32-number vector, which is exactly one token — two, counting stereo.
| One latent frame | becomes | sharing |
|---|---|---|
| video, 720p | 880 tokens | one time coordinate, 880 distinct (h, w) |
| audio | 2 tokens | one time coordinate, left and right |
Per second of output that is 6 × 880 = 5,280 video tokens against 40 × 2 = 80 audio tokens — about 66 : 1. Whenever a diagram on this page shows a block for a video latent frame, picture 880 tokens stacked behind it.
§01 said "repeatedly clean up noise". Here is what that actually means, with H3's own conventions — which differ from the textbook version in two places, so it is worth being precise.
Take the finished video's latents, call them x₀. Take pure random noise the same shape.
Now blend them on a straight line. H3 writes that blend as
x_t = t·x₀ + (1 − t)·noise.
So t = 1 is the clean video and t = 0 is pure noise.
(Careful: most diffusion papers use the opposite convention, where t = 0 is clean. H3 flips it.)
The noise fraction is σ = 1 − t. Generation starts at σ = 1 and walks down to σ = 0.
That is the entire meaning of "timestep" here — a mixing ratio, nothing more.
Three lines of arithmetic, run once per step. The transformer only supplies the first one.
| Step | Computed | Meaning |
|---|---|---|
| 1 · transformer | v = model(x_t, t) | a velocity: which direction points toward the clean data. (H3's velocity is data-ward — the opposite sign to the usual flow-matching convention.) |
| 2 · implied guess | x₀ = x_t + σ·v | "if I went all the way in that direction, here is the finished video I'd land on" — a rough guess early, sharp later |
| 3 · partial move | x_next = r·x_t + (1−r)·x₀ r = σ_next / σ | don't jump all the way — blend part-way toward the guess. This is the Euler step. |
Repeat N times with σ decreasing, and the guess in line 2 improves each round until it is the video.
Nothing here is learned — the scheduler is fixed arithmetic. Only model() has weights.
H3 has two separate visual paths, and this trips people up. A reference image goes through the VAE (to get its appearance as latents) and through a full vision-language model (to get its meaning as text-like tokens). They do different jobs and both feed the same sequence.
There is no separate branch per modality. Text, reference frames, audio and the video being generated are concatenated into a single list of vectors, all 5376-dimensional, and the transformer attends over the whole thing at once. Three small side-tensors tell it what each row is.
They are easy to confuse and they never touch. One says when in the video a token sits; the other says how noisy it currently is. They enter the transformer as different arguments and travel down completely different paths.
| Quantity | Means | Path through the model | Shape |
|---|---|---|---|
position_ids[:,0]"rotary time" |
when in the video — units of 1/40 s | → rope() → rotates Q and K | (seq_len,) |
timestep_indicesthe diffusion σ of §03 |
how noisy this row is right now | → AdaLN → scales/shifts activations | (seq_len,) |
The noise level never enters RoPE; video time never enters AdaLN. Everything in this section is about the first row. Section 03's σ is the second, and section 08 shows them meeting in one forward pass.
The clock is one number attached to each token — a single float, stored as the first of that token's three coordinates. Nothing more. The rest of this section is about why that number has to exist and what the model does with it.
A transformer's attention is permutation-invariant: shuffle the tokens and it computes the identical answer, just shuffled back. Position in the list means nothing. So on its own the model cannot tell which video token is frame 1 versus frame 30, or which snippet of sound belongs with which picture. Every token is just a vector of 5376 numbers.
And the two streams tick at different rates — video produces 6 latent frames per second, audio produces 40. If each were numbered by its own index, "video 3" and "audio 3" would be entirely different moments: 0.5 s versus 0.075 s. There would be no way to express "these two happen at the same time", which is precisely the thing a video-with-sound model has to know.
Both streams are given a time coordinate on a single shared scale, whose unit is 1/40 of a second — one audio latent. Video is converted onto that same scale. Different streams, different indices, different rates, but the same number when they mean the same instant:
| Token | Its own index | t — the shared coordinate | Real time |
|---|---|---|---|
| audio latent | 0 | 0 | 0.000 s |
| video latent frame | 0 | 0 | 0.000 s |
| audio latent | 30 | 30 | 0.750 s |
| video latent frame | 6 | 30 | 0.750 s |
Audio index 30 and video index 6 are the same moment, and the shared coordinate says so directly. That equality is the entire purpose of the clock.
The uneven block widths above are not a partition anyone chose; they are what the encoder computes. The
4× temporal reduction is two stacked causal convolutions of stride 2
(time_down: [1,2,2,1,1,1] sets the stride, while the kernel is fixed at 3 with a
two-step causal pad on the past side). Stride and kernel then decide two different things, and
it is worth keeping them apart.
(1,4,4,4,4) in the rotary code is the stride
allocation — how much clock time each latent advances — not the receptive field. Each latent actually
sees beyond its nominal slot and shares frames with its neighbours, which is what gives the decoder
enough context to reconstruct smooth motion across latent boundaries.
Because latent 0 advances the clock by 1 frame where its neighbours advance 4, its rotary span is
5/3 units against 20/3 — so the time coordinate stays proportional to real
elapsed time however lopsided the latents are.
A convolution has one stride, not a different one per position. Uneven groupings like 3, 3, 3, 3, 5 cannot be produced by a strided conv stack at all; you would need a different architecture entirely, with learned or variable-width pooling.
And 4 = 2 × 2 is precisely what allows the reduction to be built from two stride-2 stages
(assert time_stride in [1, 2] in the source). 3, 3, 3, 3, 5 factors into nothing usable.
A causal downsampler with ratio r maps T frames to (T−1)/r + 1 latents.
That formula has a property plain T/r does not: T = 1 gives exactly 1 latent.
T/r would give a quarter of a latent, which means nothing.
H3 cannot function without T = 1 working — reference images, first/last-frame conditioning and joint image+video training all feed single stills through this same encoder. The lopsided first latent is the price of that, and it is a price worth paying.
The coordinate is never fed in as a feature. It is not concatenated, not added to the token, not embedded. Its one and only use is to rotate the query and key vectors before attention multiplies them together — the technique called RoPE.
In an LLM, RoPE position is a single integer — the token's index, 0, 1, 2, 3 … — and one axis rotates the whole head. H3 needs three numbers because its data is three-dimensional, so each head's channels are divided between the axes:
| Language model | MiniMax H3 | |
|---|---|---|
| Position is | one scalar — the token index | three floats — (t, h, w) |
| Per token | position_ids: (seq_len,) | position_ids: (seq_len, 3) |
| Channel use | all of head_dim rotated by one axis | 32 by t · 32 by h · 32 by w · 32 untouched |
| Units | token counts (integers) | 1/40 s for time; a fixed [0, 32) canvas for space |
Text tokens get t = 0, 1, 2, … (their index) and h = w = 0. So on the time axis
they behave exactly like ordinary 1-D language-model RoPE, and the two spatial axes contribute nothing.
No separate text-position machinery was needed: the 3-D scheme contains the 1-D one.
| Token | t | h, w |
|---|---|---|
| text | 0 … N_text−1 (its index) | 0, 0 |
| reference image | one slot — a still has no duration | its own frame grid |
| reference video | its own temporal grid, from the block's origin | its own frame grid |
| reference audio | latent index from the block's origin | h = 0, w at the stereo extremes |
| keyframe anchor | coincident with the target frame it pins | frame grid |
| target audio | latent index from the target origin | h = 0, w at the stereo extremes |
| target video | the target's temporal grid | frame grid |
Every token gets all three coordinates. Some simply leave axes at zero.
A natural guess is that "1 unit = 1/40 second" is encoded in RoPE's theta. It is not.
The two are cleanly separated, and keeping them apart is what lets one frequency ladder serve time,
height and width at once.
| Knob | What it sets | Value in H3 |
|---|---|---|
rope_theta |
the frequency ladder — how a single number is fanned out across 16 channels,
inv_freq[j] = θ |
10000 — generic, shared by all three axes |
position_ids |
the unit — what number stands for this token's moment or place | 5/3 per raw frame (time) scale 32 (space) |
The rotation is simply angle = position × inv_freq[j]. Theta answers "how do I spread one
number over 16 channels"; position_ids answers "what is the number". Change the time unit
and you edit the packing code, never the model.
The clock unit is chosen to be one audio latent = 1/40 second. A video frame at 24 fps lasts
1/24 second, so in clock units it is (1/24) ÷ (1/40) = 40/24 = 5/3. That is the whole
derivation, and that constant — _ROPE_FRAME_RESCALE — is the only place the model's sense
of time is set.
Two things confirm the separation. rope_theta and rope_freq_dim live in
transformer/config.json and would be byte-identical for 30 fps video or
48 kHz audio — only the packing constants would move. And rope.inv_freq is
computed, not loaded: it is not a stored tensor, let alone a learned one. Nothing in the 33 B of
weights encodes a time unit.
The chain end to end: _ROPE_FRAME_RESCALE → _temporal_position_grid() → position_ids[:,0] → rope() → rotate Q, K.
Because the same 16 frequencies are reused for t, h and w, and those axes span wildly different ranges, each axis ends up effectively using a different part of the ladder:
| Axis | Range it spans | Fastest channel | Slowest channel |
|---|---|---|---|
| space (h, w) | [0, 32) — aspect-normalised | 32 rad ≈ 5.1 turns across the frame | 0.006 rad — effectively flat |
| time (t) | 0 … ≈600 for a 15 s clip | wraps ~95 times | 0.107 rad — a near-linear absolute ramp |
Space uses only the fast end; time uses the whole ladder, with the slow channels behaving as a coarse "how far into the clip am I" signal. Wavelengths run from 6.28 clock units (0.157 s) down to 35,333 units (883 s) — far longer than any clip, which is exactly what makes the slow end monotonic.
The spatial grid is normalised by √(height × width) and centred on [0, 32),
so a 768p frame and a 2K frame produce the same coordinate range — only the sampling density
between the endpoints changes. Aspect ratio is preserved: a 16:9 frame spans wider in w than in h,
both centred on the same midpoint.
That is how one set of weights generates at several resolutions. The model never learns "position 40 of 80"; it learns positions on a fixed unit canvas. It also explains why In-Context Regeneration can feed a 768p result back in as conditioning for a 2K pass — both live on the same coordinate system.
This is the use that surprises people. A reference frame and a generated frame are identical kinds of object — same VAE, same patchify, same 96 numbers, same input projection, sitting in the same list. No label marks one as "given" and the other as "to be made". There is no learned "am I a reference" vector anywhere in the 33 B of weights.
A familiar way to separate two kinds of token is a segment embedding: a small learned table,
x = x + segment_embed[0 or 1], added once at the input. H3 does nothing of the sort.
| Segment-ID approach | What H3 does | |
|---|---|---|
| Mechanism | learned lookup table | a coordinate value |
| Applied by | addition, once at the input | rotation of Q and K, in every block |
| It says | "you are type A, I am type B" | "you are at time 5, I am at time 200" |
Three signals actually separate them, and none is a type label:
| Signal | Carried by | How |
|---|---|---|
| Time coordinate | RoPE | references occupy coordinates the target does not — or coincide deliberately with the frames they pin |
| Noise level | AdaLN | reference rows sit at a pinned low σ while target rows ride the moving schedule, selecting a different modulation row |
| Content statistics | the latents themselves | a reference is clean structure; a target at high σ is nearly pure noise |
H3 does have one genuinely ID-like mechanism — token_tags (0 video, 1 text, 2 audio)
indexing per-modality AdaLN rows. But it tags modality, never reference-versus-target.
The two conditioning modes then exploit the time coordinate differently:
| Job | What would break without it |
|---|---|
| Order the video frames | no notion of motion or direction; a shuffled clip would look the same to the model |
| Align audio to video | lip-sync would be inexpressible, not merely hard |
| Separate reference from target | the model could not tell what it was given from what it must invent |
This term has been used throughout without being defined. It stands for Adaptive Layer Normalization, the idea is small, and everything distinctive about how H3 handles noise rests on it.
Every transformer normalizes activations before each sublayer, then applies a learned per-channel scale
and shift: y = norm(x) · γ + β. Those two are learned constants — identical for
every input the model will ever see.
AdaLN makes them computed instead of constant:
y = norm(x) · (1 + scale) + shift, where scale and shift come out
of a small network fed some conditioning signal. In diffusion that signal is the noise level.
So the same weights behave one way at σ = 0.9 and another at σ = 0.1 — one network acting like many, with the noise level turning the dial. Which is exactly what the task needs: at high noise a block should be settling coarse global structure, at low noise sharpening fine texture.
In a standard DiT there is one noise level for the whole image, so AdaLN is computed once per
forward pass and applied uniformly. H3 instead builds a table indexed by
(noise level, modality), and every row of the sequence looks up its own entry:
adaln_indices = timestep_indices × 3 + token_tags.
That per-row lookup is precisely what lets a clean reference frame and a fully noised target frame sit in the same sequence and be processed correctly by the same weights — the mechanism §08 depends on.
The block itself is conventional: normalise, attend, normalise, feed-forward, with residual connections. What is unusual is AdaLN — the mechanism that tells each individual row how noisy it is. The timestep is turned into six vectors that stretch, shift and gate the activations, and each row looks up its own set.
This is where H3's design pays off. In a single forward pass, reference rows sit at a fixed low noise level while target rows step down their own schedules — and because references are simply never written back, they stay bit-exact for all N steps. No masking, no re-noising, no paste-back.
In a chatbot, the prompt is encoded once and reused for every generated word. That does not work here. Text rows inherit the video timestep, which moves every step, so their AdaLN modulation — and therefore their contribution to attention — changes every step. And because attention is bidirectional, information flows from the changing target rows back into the reference rows after the very first block.
Net: the only reusable region is block 0's keys and values for the reference rows. Everything else is recomputed N times. That is the structural reason video diffusion is so much more expensive per output than text generation.
MiniMax states that native sparse attention was introduced during the final stage of training to cut the cost of long sequences, and that the open release runs full attention only; the sparse implementation is promised in a future update. Reading the released code confirms it: there is no mask builder, no block selection, no config flag, not even a disabled branch.
One fingerprint of the internal version survives in a source comment — the reference implementation
pads the sequence to a multiple of 64 for FlashAttention and carries cu_seqlens. That is
the variable-length / block-table interface, which means whatever the algorithm is, it is
block-structured at 64-token granularity.
Consequence for anyone using the weights: you pay the full N² attention cost that the sparsity existed to avoid, and the configuration you are running is not the one the final training stage was tuned for. Dense attention is a superset of any sparsity mask, so quality holds — but efficiency benchmarks against the public release are measuring a deliberately handicapped path.
| Cross-attention | zero layers — stated verbatim in the source |
| Attention mask | none; one packed document per request |
| Per-modality blocks | none in attention or feed-forward |
| CFG / guider | distilled away — one forward per step, not two |
| Separate upscaler | 2K via In-Context Regeneration instead |
| Input projections | proj_in 96→5376 · audio_proj_in 32→5376 |
| AdaLN tag | 3 modulation rows per noise level |
| Output heads | proj_out 5376→96 · audio_proj_out 5376→32 |
| Everything else | shared, modality-blind |