linoyts HF Staff commited on
Commit
dca1cd6
·
verified ·
1 Parent(s): b02a286

Masked video+audio inpainting blocks for MiniMax-H3

Browse files
README.md ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: MiniMaxAI/MiniMax-H3
4
+ base_model_relation: adapter
5
+ library_name: diffusers
6
+ tags:
7
+ - modular-diffusers
8
+ - minimax-h3
9
+ - inpainting
10
+ - video-to-video
11
+ - audio-video
12
+ pipeline_tag: video-to-video
13
+ ---
14
+
15
+ # MiniMax-H3 — masked video and audio inpainting
16
+
17
+ Modular Diffusers blocks that repaint part of a clip with MiniMax-H3 and keep the rest, including the soundtrack.
18
+ **No dedicated checkpoint, no adapter, no extra input channel** — the mask becomes a per-row timestep, which the model
19
+ already had.
20
+
21
+ ![source above, repainted below](assets/example.webp)
22
+
23
+ <sub>Above: the plate. Below: the same clip with the animal replaced from one reference photo, in 6 steps. The forest,
24
+ the snow, the camera push and the original soundtrack are untouched.</sub>
25
+
26
+ ## Use it
27
+
28
+ ```python
29
+ import torch
30
+ from diffusers.modular_pipelines import ModularPipelineBlocks
31
+ from diffusers.modular_pipelines.minimax_h3.references import MiniMaxH3ImageReference
32
+
33
+ blocks = ModularPipelineBlocks.from_pretrained(
34
+ "diffusers-modular/minimax-h3-inpainting", trust_remote_code=True
35
+ )
36
+ pipe = blocks.init_pipeline("MiniMaxAI/MiniMax-H3")
37
+ pipe.load_components(dtype=torch.bfloat16)
38
+ pipe.to("cuda")
39
+
40
+ state = pipe(
41
+ prompt="<Picture 1> the man from the picture, walking through deep snow in a pine forest",
42
+ references=[MiniMaxH3ImageReference.from_file("subject.png")],
43
+ source_video=frames, # (num_frames, height, width, 3) uint8
44
+ source_fps=24,
45
+ mask=mask, # (num_frames, height, width) — 1 repaints, 0 preserves
46
+ source_audio=waveform, # optional; preserved whole unless `audio_mask` says otherwise
47
+ source_audio_sample_rate=48000,
48
+ num_inference_steps=28,
49
+ generator=torch.Generator("cpu").manual_seed(0),
50
+ )
51
+ video, audio = state.get("videos")[0], state.get("audio")[0]
52
+ ```
53
+
54
+ `MiniMaxH3Ref2VAInpaintGeneratorBlocks` is the same thing without the text-encoder step, for split deployments where
55
+ the encoder lives elsewhere and `prompt_embeds` / `text_token_tags` are the wire format.
56
+
57
+ ## How it works
58
+
59
+ MiniMax-H3 denoises one packed sequence in which **every row carries its own timestep** — that is how a keyframe
60
+ anchor sits at `t = 0.999`, essentially clean, beside target rows still stepping down the schedule. Nothing says which
61
+ rows may do that, so pointing it at an arbitrary subset of the target rows *is* inpainting.
62
+
63
+ | mask | row timestep | content |
64
+ |---|---|---|
65
+ | `1` — repaint | the schedule's `t` | the model's |
66
+ | `0` — preserve | `max(t, 0.999)` video, `1.0` audio | the source, clean |
67
+ | feathered | `1 − m·σ` | blended to that level |
68
+
69
+ This matters because the usual recipe — re-noise the source to the current sigma and blend — is *off-distribution*
70
+ here: it hands the model a target row claiming timestep `t` while holding content at a level it never saw paired with
71
+ that label. Presenting preserved rows as conditioning is a distribution the checkpoint knows well.
72
+
73
+ Verified: with the mask all ones the blocks reproduce stock `ref2va` **bit for bit**, video and audio. On real
74
+ weights the preserved region comes back at 1.44/255 from the source — the autoencoder round-trip floor — against
75
+ 40.0/255 inside the mask, and the soundtrack at cosine 0.972.
76
+
77
+ ## The mask lands on three grids
78
+
79
+ A generic resize reproduces none of them, and getting any one wrong is a silent quality bug:
80
+
81
+ - **spatially** — the VAE's 16× compression, then the transformer's 2×2 patch. A row is one token: it carries one
82
+ timestep and is written back whole, so a 2×2 latent patch is the finest a mask can be.
83
+ - **temporally** — the VAE's chunked causal grouping, `(1, 4, 4, 4, 4)` repeating every 17 frames. Not uniform.
84
+ - **on the audio clock** — 40 latents per second, *not* 24 frames per second. Aligning an audio mask to the video
85
+ grid is what puts a masked soundtrack out of sync.
86
+
87
+ `pixel_mask_to_row_mask` and `audio_mask_to_row_mask` do this; every reduction is a maximum, so a row regenerates as
88
+ much as the most-masked pixel it covers asks it to.
89
+
90
+ ## Use hard masks
91
+
92
+ ![plate, feathered mask, hard mask](assets/edge.webp)
93
+
94
+ <sub>Plate · feathered mask · hard mask, at the same boundary.</sub>
95
+
96
+ A feathered mask leaves its edge rows at intermediate timesteps holding a *mixture* of source and repaint — lower
97
+ contrast than either. Paste that through an upscale and crossfade it into a sharp plate and you get a visible band
98
+ along the mask, as in the middle panel. Squaring the mask off and generating at the plate's own size removes it. The
99
+ paste's own feather is what should hide the join.
100
+
101
+ ## Give the mask room
102
+
103
+ ![a tracked segmentation, grown](assets/mask.webp)
104
+
105
+ Mask geometry decides what a prompt can do. A box fitted to a walking quadruped is a quadruped-shaped hole: asked for
106
+ a person, the model will put one in it *on all fours* rather than contradict the border it was told to preserve. Only
107
+ a mask with a standing footprint lets it stand up. When you are replacing a subject rather than editing one, grow the
108
+ mask well past the outline.
109
+
110
+ `crop.py` ships the other half of the practical workflow: one stable box around everything the mask ever touches,
111
+ a canvas that never upscales it, and a feathered paste back into the plate. Cost is set by the canvas, not by how
112
+ much of the frame changes, so cropping to the subject is the memory lever.
113
+
114
+ ## Practice notes
115
+
116
+ - **Keep the soundtrack** and the model animates to the words already there. That is the lip-sync recipe.
117
+ - **Per-shot prompting is unavoidable.** Masking makes the prompt less strict, not optional.
118
+ - **`ref2va` needs at least one reference.** Prompt-only object removal is the `t2va` partition's job.
119
+ - **5–15 s per pass.** Longer clips have to be inpainted in segments.
120
+ - **The decoder is not perfectly local.** Preserved latents are preserved exactly, but the video decoder is a
121
+ 36-layer attention stack, so a change inside the mask moves decoded pixels just outside it — 5.5/255 within 8 px,
122
+ 1.2/255 by 32 px, gone by 128 px. Confining the paste to the mask discards that halo.
123
+
124
+ ## Credits
125
+
126
+ The workflow these blocks reproduce was worked out by the ComfyUI community — Ablejones, Nekodificador, and drozbay,
127
+ whose [MaskVidExperiments](https://github.com/drozbay/MaskVidExperiments) covers the same ground. **No code is carried
128
+ over from it** (it is GPLv3); the mask geometry here is re-derived from the checkpoint's own constants. ComfyUI's own
129
+ MiniMax-H3 support arrives at the same "the mask is a timestep" formulation independently.
assets/edge.webp ADDED
assets/example.webp ADDED
assets/mask.webp ADDED
crop.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crop a masked subject out of a clip, inpaint it small, paste it back.
2
+
3
+ MiniMax-H3 generates on a 768-short-edge canvas and attends over the whole packed sequence at once, so the cost of a
4
+ request is set by its canvas rather than by how much of the frame actually changes. Repainting a face in a 4K plate at
5
+ full resolution is mostly spent on pixels the mask preserves — which is why every workflow in the wild crops to the
6
+ subject first, and why "reduce the resolution until it stops running out of memory" is the usual advice.
7
+
8
+ This is the static half of that: one box around everything the mask ever touches, held for the whole clip. A box that
9
+ never moves cannot be read as camera motion, which is the failure mode of cropping each frame to its own subject; the
10
+ cost is that a subject crossing the frame drags the box out to cover its whole travel. Tracking the subject with a box
11
+ that moves as little as possible does better on those clips and is a much larger piece of work.
12
+
13
+ Nothing here touches the model. Crop before the pipeline, paste after.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+ from PIL import Image, ImageFilter
20
+
21
+
22
+ def mask_bounding_box(
23
+ mask: np.ndarray,
24
+ frame_height: int,
25
+ frame_width: int,
26
+ crop_scale: float = 0.5,
27
+ multiple: int = 32,
28
+ threshold: float = 0.02,
29
+ min_aspect_ratio: float = 0.25,
30
+ max_aspect_ratio: float = 4.0,
31
+ ) -> tuple[int, int, int, int]:
32
+ r"""
33
+ One box around everything the mask touches anywhere in the clip.
34
+
35
+ Args:
36
+ mask (`np.ndarray` of shape `(num_frames, height, width)`): The mask, over `[0, 1]`.
37
+ frame_height (`int`), frame_width (`int`): The frame the box is cut from, which it is clamped to.
38
+ crop_scale (`float`, defaults to 0.5):
39
+ Padding around the subject, as a fraction of its size. The model needs context around what it repaints —
40
+ a box cut tight to a head gives it nothing to match lighting or motion against.
41
+ multiple (`int`, defaults to 32): What both axes are rounded up to, i.e. the pipeline's `canvas_multiple`.
42
+ threshold (`float`, defaults to 0.02): Mask values at or below this are treated as unmasked.
43
+ min_aspect_ratio (`float`, defaults to 0.25), max_aspect_ratio (`float`, defaults to 4.0):
44
+ The ratios MiniMax-H3 was trained over. A box outside them is widened on its short axis.
45
+
46
+ Returns:
47
+ `tuple[int, int, int, int]`: the box as `(top, left, height, width)`, in source pixels.
48
+ """
49
+ if mask.ndim != 3:
50
+ raise ValueError(f"A mask must be `(num_frames, height, width)`, got {tuple(mask.shape)}.")
51
+
52
+ covered = mask.max(axis=0) > threshold
53
+ if not covered.any():
54
+ raise ValueError("The mask is empty: there is nothing to inpaint.")
55
+ rows = np.flatnonzero(covered.any(axis=1))
56
+ cols = np.flatnonzero(covered.any(axis=0))
57
+ top, bottom = int(rows[0]), int(rows[-1]) + 1
58
+ left, right = int(cols[0]), int(cols[-1]) + 1
59
+
60
+ # Padding, as a share of the subject rather than a fixed margin, so it scales with the shot.
61
+ pad_y = (bottom - top) * crop_scale / 2.0
62
+ pad_x = (right - left) * crop_scale / 2.0
63
+ top, bottom = top - pad_y, bottom + pad_y
64
+ left, right = left - pad_x, right + pad_x
65
+
66
+ height, width = bottom - top, right - left
67
+ # Back inside the ratios the checkpoint was trained over, by growing the short axis rather than cutting the long
68
+ # one — the subject stays fully inside the box either way.
69
+ ratio = width / height
70
+ if ratio < min_aspect_ratio:
71
+ width = height * min_aspect_ratio
72
+ elif ratio > max_aspect_ratio:
73
+ height = width / max_aspect_ratio
74
+
75
+ return _snap_box(
76
+ (top + bottom) / 2.0, (left + right) / 2.0, height, width, frame_height, frame_width, multiple
77
+ )
78
+
79
+
80
+ def _snap_box(
81
+ center_y: float,
82
+ center_x: float,
83
+ height: float,
84
+ width: float,
85
+ frame_height: int,
86
+ frame_width: int,
87
+ multiple: int,
88
+ ) -> tuple[int, int, int, int]:
89
+ """A centred box onto the `multiple` grid and inside the frame, keeping the subject centred where it can."""
90
+ height = min(int(np.ceil(height / multiple)) * multiple, (frame_height // multiple) * multiple)
91
+ width = min(int(np.ceil(width / multiple)) * multiple, (frame_width // multiple) * multiple)
92
+ height, width = max(height, multiple), max(width, multiple)
93
+
94
+ top = int(round(center_y - height / 2.0))
95
+ left = int(round(center_x - width / 2.0))
96
+ # A box pushed off the edge slides back in rather than being cut down: its size is already on the grid, and
97
+ # shrinking it here would drop part of the subject.
98
+ top = max(0, min(top, frame_height - height))
99
+ left = max(0, min(left, frame_width - width))
100
+ return top, left, height, width
101
+
102
+
103
+ def canvas_for_box(
104
+ box_height: int,
105
+ box_width: int,
106
+ multiple: int = 32,
107
+ short_edge: int = 768,
108
+ max_pixels: int = 768 * 1344,
109
+ ) -> tuple[int, int]:
110
+ r"""
111
+ The canvas to generate a box at.
112
+
113
+ Same rule the pipeline applies to any request — short edge first, area capped, both axes rounded — with one
114
+ addition: a box smaller than the canvas is generated at its own size rather than upscaled. Repainting a 320-pixel
115
+ head at 768 and scaling it back down spends the difference on nothing.
116
+
117
+ Lower `short_edge` and `max_pixels` together to trade quality for memory; the community workflows run at roughly
118
+ 0.4-0.5 MP on 24 GB cards.
119
+
120
+ Args:
121
+ box_height (`int`), box_width (`int`): The box, as [`mask_bounding_box`] returned it.
122
+ multiple (`int`, defaults to 32): What both axes round to.
123
+ short_edge (`int`, defaults to 768), max_pixels (`int`, defaults to `768 * 1344`): The canvas budget.
124
+
125
+ Returns:
126
+ `tuple[int, int]`: the `(height, width)` to generate at.
127
+ """
128
+ scale = short_edge / min(box_height, box_width)
129
+ if box_height * box_width * scale * scale > max_pixels:
130
+ scale = (max_pixels / (box_height * box_width)) ** 0.5
131
+ scale = min(scale, 1.0)
132
+
133
+ height = max(multiple, round(box_height * scale / multiple) * multiple)
134
+ width = max(multiple, round(box_width * scale / multiple) * multiple)
135
+ return height, width
136
+
137
+
138
+ def crop(array: np.ndarray, box: tuple[int, int, int, int]) -> np.ndarray:
139
+ r"""Cut `box` out of a `(num_frames, height, width, ...)` clip or mask. Pixel-exact, no resampling."""
140
+ top, left, height, width = box
141
+ return array[:, top : top + height, left : left + width]
142
+
143
+
144
+ def paste_back(
145
+ frames: np.ndarray,
146
+ generated: np.ndarray,
147
+ box: tuple[int, int, int, int],
148
+ mask: np.ndarray | None = None,
149
+ feather: int = 8,
150
+ ) -> np.ndarray:
151
+ r"""
152
+ Paste an inpainted crop back into the frames it came from.
153
+
154
+ Args:
155
+ frames (`np.ndarray` of shape `(num_frames, height, width, 3)`): The original `uint8` clip.
156
+ generated (`np.ndarray` of shape `(num_frames, box_height, box_width, 3)` or the canvas size):
157
+ The inpainted crop, rescaled to the box if it was generated at another size.
158
+ box (`tuple[int, int, int, int]`): The box, as `(top, left, height, width)`.
159
+ mask (`np.ndarray` of shape `(num_frames, box_height, box_width)`, *optional*):
160
+ Confine the paste to the mask, blurred by `feather`. Without it the whole box is pasted, feathered only
161
+ at its border. Confining is the safer default on a static plate: the video VAE's decode is not local, so
162
+ a repainted region moves its surroundings slightly even where the mask preserved the latents exactly.
163
+ feather (`int`, defaults to 8): Width of the blend ramp in box pixels.
164
+
165
+ Returns:
166
+ `np.ndarray` of shape `(num_frames, height, width, 3)`: the `uint8` clip with the crop pasted in.
167
+ """
168
+ top, left, box_height, box_width = box
169
+ num_frames = frames.shape[0]
170
+ if generated.shape[0] != num_frames:
171
+ raise ValueError(f"The clip has {num_frames} frames and the inpainted crop {generated.shape[0]}.")
172
+
173
+ if generated.shape[1:3] != (box_height, box_width):
174
+ generated = np.stack(
175
+ [
176
+ np.asarray(Image.fromarray(frame).resize((box_width, box_height), Image.Resampling.LANCZOS))
177
+ for frame in generated
178
+ ]
179
+ )
180
+
181
+ weight = _border_ramp(box_height, box_width, feather)[None]
182
+ if mask is not None:
183
+ if mask.shape[1:3] != (box_height, box_width):
184
+ raise ValueError(
185
+ f"The mask is {mask.shape[1:3]} and the box {(box_height, box_width)}; crop the mask with the clip."
186
+ )
187
+ weight = weight * _feather_mask(mask, feather)
188
+
189
+ out = frames.copy()
190
+ region = out[:, top : top + box_height, left : left + box_width].astype(np.float32)
191
+ blended = region + weight[..., None] * (generated.astype(np.float32) - region)
192
+ out[:, top : top + box_height, left : left + box_width] = blended.round().clip(0, 255).astype(np.uint8)
193
+ return out
194
+
195
+
196
+ def _border_ramp(height: int, width: int, feather: int) -> np.ndarray:
197
+ """1 inside the box, ramping to 0 over `feather` pixels at its edges."""
198
+ if feather <= 0:
199
+ return np.ones((height, width), dtype=np.float32)
200
+
201
+ def axis(length):
202
+ edge = np.minimum(np.arange(length), np.arange(length)[::-1]).astype(np.float32)
203
+ return np.clip((edge + 0.5) / feather, 0.0, 1.0)
204
+
205
+ return axis(height)[:, None] * axis(width)[None, :]
206
+
207
+
208
+ def _feather_mask(mask: np.ndarray, feather: int) -> np.ndarray:
209
+ """The mask, blurred outwards so the paste fades into the plate rather than showing its own edge."""
210
+ if feather <= 0:
211
+ return mask.astype(np.float32)
212
+ blurred = []
213
+ for frame in mask:
214
+ image = Image.fromarray((frame * 255.0).clip(0, 255).astype(np.uint8))
215
+ image = image.filter(ImageFilter.MaxFilter(_odd(feather))).filter(ImageFilter.GaussianBlur(feather / 2.0))
216
+ blurred.append(np.asarray(image, dtype=np.float32) / 255.0)
217
+ return np.stack(blurred)
218
+
219
+
220
+ def _odd(value: int) -> int:
221
+ """`MaxFilter` takes an odd kernel size, and 1 is a no-op rather than an error."""
222
+ return max(1, int(value) | 1)
minimax_h3_inpaint_blocks.py ADDED
@@ -0,0 +1,1046 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Masked video+audio inpainting for MiniMax-H3, on the `ref2va` reference workflow.
2
+
3
+ `ref2va` conditions on reference images, clips and soundtracks and then generates a whole new video. This module keeps
4
+ that conditioning and makes the *target* partly given: a source clip is encoded, a mask says which of its rows the
5
+ model repaints, and everything the mask preserves is presented to the transformer the way a keyframe anchor is —
6
+ clean content pinned at the visual-conditioning timestep `0.999`, rather than the source noised down to the step's own
7
+ sigma.
8
+
9
+ That distinction is the whole trick, and it is what ComfyUI's MiniMax-H3 masking does too (`scale_latent_inpaint`
10
+ returns the anchor rather than the schedule's noise level, and the DiT relabels masked rows). MiniMax-H3 was trained
11
+ with clean visual conditioning at `0.999` and has never seen a partially denoised target row claiming to be something
12
+ else, so ordinary "re-noise the original to sigma_t" inpainting is off-distribution for it.
13
+
14
+ Three things follow, and they are the three blocks below:
15
+
16
+ * **the mask lands on rows, not pixels.** A row is one `2 x 2` latent patch of one latent frame, and the latent
17
+ frames are grouped from pixel frames on the VAE's `(1, 4, 4, 4, 4)` cycle. `h3_inpaint_masks` does that reduction;
18
+ a generic resize does not reproduce it, and on the audio side the clock is 40 latents/s rather than 24 fps.
19
+ * **preserved rows are relabelled.** MiniMax-H3 already carries a per-row timestep vector — that is how a keyframe
20
+ anchor rides at `0.999` beside target rows stepping down the schedule — so a mask value `m` simply places its row at
21
+ `1 - m * sigma`, clamped at the conditioning timestep. A fully preserved row lands exactly on `0.999`; a fully
22
+ generated one on the schedule; a feathered one in between, honestly labelled.
23
+ * **preserved rows are written back every step.** The scheduler still steps them, and the write-back overrules it, the
24
+ way `MiniMaxH3AudioConditionStep` imposes a given soundtrack in the audio-to-video Space.
25
+
26
+ The audio stream is masked on the same footing. Preserving the whole soundtrack — the default when a source
27
+ soundtrack is passed — is what makes the model animate a mouth *to the words that are already there* rather than
28
+ inventing new ones, which is the "keep the original spoken text" recipe from the community workflows.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import math
34
+
35
+ import numpy as np
36
+ import torch
37
+ import torch.nn.functional as F
38
+ from PIL import Image
39
+
40
+ from diffusers import attention_backend
41
+ from diffusers.models import AutoencoderKLMiniMaxH3, AutoencoderKLMiniMaxH3Audio
42
+ from diffusers.modular_pipelines.minimax_h3.before_denoise import (
43
+ MiniMaxH3PrepareConditionLatentsStep,
44
+ MiniMaxH3PrepareLatentsStep,
45
+ MiniMaxH3Ref2VAPrepareLatentsStep,
46
+ MiniMaxH3Ref2VAPrepareLayoutStep,
47
+ MiniMaxH3SetTimestepsStep,
48
+ patchify_video_latents,
49
+ )
50
+ from diffusers.modular_pipelines.minimax_h3.before_encoder import MiniMaxH3Ref2VASetupStep
51
+ from diffusers.modular_pipelines.minimax_h3.decoders import MiniMaxH3AfterDenoiseStep
52
+ from diffusers.modular_pipelines.minimax_h3.denoise import (
53
+ MiniMaxH3DenoiseLoopWrapper,
54
+ MiniMaxH3LoopSchedulerStep,
55
+ MiniMaxH3Ref2VALoopDenoiser,
56
+ )
57
+ from diffusers.modular_pipelines.minimax_h3.encoders import (
58
+ MiniMaxH3Ref2VAReferenceEncoderStep,
59
+ MiniMaxH3Ref2VATextEncoderStep,
60
+ encode_vae_condition,
61
+ )
62
+ from diffusers.modular_pipelines.minimax_h3.modular_blocks_minimax_h3 import MiniMaxH3DecodeStep
63
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import (
64
+ MiniMaxH3ModularPipeline,
65
+ align_num_frames,
66
+ audio_latent_num_frames,
67
+ resolve_canvas_size,
68
+ video_latent_num_frames,
69
+ )
70
+ from diffusers.modular_pipelines.modular_pipeline import (
71
+ BlockState,
72
+ ModularPipelineBlocks,
73
+ PipelineState,
74
+ SequentialPipelineBlocks,
75
+ )
76
+ from diffusers.modular_pipelines.modular_pipeline_utils import ComponentSpec, InputParam, OutputParam
77
+ from diffusers.utils import logging
78
+
79
+
80
+
81
+ # ==============================================================================================================
82
+ # Mask geometry — pixel-space masks onto the packed row grid
83
+ #
84
+ # A mask that selects *rows* has to be reduced on three grids, none of which a generic resize reproduces: the video
85
+ # VAE's 16x spatial compression and then the transformer's 2x2 patch, because a row is one token; the VAE's chunked
86
+ # causal frame grouping, which is not uniform; and, for audio, 40 latents per second rather than 24 frames per
87
+ # second. Every reduction is a maximum — a row regenerates as much as the most-masked pixel it covers asks it to.
88
+ # ==============================================================================================================
89
+
90
+
91
+ logger = logging.get_logger(__name__)
92
+
93
+
94
+ # The timesteps a fully preserved row is pinned at. MiniMax-H3 holds visual conditioning just short of clean and
95
+ # audio conditioning exactly clean, and a preserved inpainting row is conditioning in every sense that matters, so it
96
+ # is presented at the levels the checkpoint was trained to read.
97
+ VISUAL_COND_TIMESTEP = 0.999
98
+ AUDIO_COND_TIMESTEP = 1.0
99
+
100
+
101
+ def resample_frame_indices(num_frames: int, fps: float, target_fps: float) -> np.ndarray:
102
+ r"""
103
+ The source frame every target-rate frame is taken from.
104
+
105
+ Whole frames are held and dropped, never blended, reproducing `ffmpeg`'s `fps` filter the way the reference
106
+ implementation resamples a reference clip. Returned as indices rather than frames so a clip and the mask drawn
107
+ over it go through the same resample.
108
+
109
+ Args:
110
+ num_frames (`int`): Frames the source carries.
111
+ fps (`float`): The rate it carries them at.
112
+ target_fps (`float`): The rate to resample onto, i.e. `components.fps`.
113
+
114
+ Returns:
115
+ `np.ndarray`: One source index per resampled frame.
116
+ """
117
+ if fps <= 0:
118
+ raise ValueError(f"A source clip must have a positive frame rate, got {fps}.")
119
+ if fps == target_fps:
120
+ return np.arange(num_frames)
121
+ scale = target_fps / fps
122
+ slots = np.floor(np.arange(num_frames) * scale + 0.5).astype(np.int64)
123
+ repeats = np.diff(slots, append=math.floor(num_frames * scale + 0.5))
124
+ return np.repeat(np.arange(num_frames), repeats)
125
+
126
+
127
+ def normalize_source_frames(frames) -> np.ndarray:
128
+ r"""Any accepted clip layout as `uint8` `(num_frames, height, width, 3)`, matching the reference blocks."""
129
+ if isinstance(frames, list):
130
+ frames = np.stack([np.asarray(frame.convert("RGB")) for frame in frames])
131
+ if isinstance(frames, torch.Tensor):
132
+ frames = frames.movedim(-3, -1).cpu().numpy()
133
+ frames = np.asarray(frames)
134
+ if frames.dtype != np.uint8:
135
+ frames = (frames * 255.0).round().clip(0, 255).astype(np.uint8)
136
+ if frames.ndim != 4 or frames.shape[3] != 3:
137
+ raise ValueError(f"A source clip must be `(num_frames, height, width, 3)` RGB, got {tuple(frames.shape)}.")
138
+ return frames
139
+
140
+
141
+ def normalize_source_mask(mask) -> np.ndarray:
142
+ r"""Any accepted mask layout as float32 `(num_frames, height, width)` over `[0, 1]`."""
143
+ if isinstance(mask, list):
144
+ mask = np.stack([np.asarray(frame.convert("L")) for frame in mask])
145
+ if isinstance(mask, torch.Tensor):
146
+ mask = mask.cpu().numpy()
147
+ mask = np.asarray(mask)
148
+ if mask.ndim == 4:
149
+ # `(num_frames, height, width, 1)` or a 3-channel mask painted as an image.
150
+ mask = mask.mean(axis=-1) if mask.shape[-1] in (3, 4) else mask[..., 0]
151
+ if mask.ndim == 2:
152
+ mask = mask[None]
153
+ if mask.ndim != 3:
154
+ raise ValueError(f"A mask must be `(num_frames, height, width)`, got {tuple(mask.shape)}.")
155
+ mask = mask.astype(np.float32)
156
+ if mask.max() > 1.0:
157
+ mask = mask / 255.0
158
+ return mask.clip(0.0, 1.0)
159
+
160
+
161
+ def normalize_waveform(waveform: torch.Tensor, sample_rate: int, target_sample_rate: int) -> torch.Tensor:
162
+ r"""A `(channels, num_samples)` waveform as MiniMax-H3's stereo, float32, `target_sample_rate` soundtrack."""
163
+ import torchaudio
164
+
165
+ if waveform.ndim == 1:
166
+ waveform = waveform[None]
167
+ waveform = waveform.to(torch.float32)
168
+ if waveform.shape[0] == 1:
169
+ waveform = waveform.repeat(2, 1)
170
+ waveform = waveform[:2]
171
+ if sample_rate != target_sample_rate:
172
+ waveform = torchaudio.functional.resample(waveform, sample_rate, target_sample_rate)
173
+ return waveform
174
+
175
+
176
+ class MiniMaxH3InpaintSourceGeometryStep(ModularPipelineBlocks):
177
+ model_name = "minimax-h3"
178
+
179
+ @property
180
+ def description(self) -> str:
181
+ return (
182
+ "Resolves the request's geometry from the source clip: the canvas its aspect ratio maps onto and the "
183
+ "`17 * n + 5` frame count its length rounds to. `ref2va` otherwise generates on MiniMax-H3's own 16:9 "
184
+ "canvas — references never bind the generated geometry — but an inpainting request has to land back on "
185
+ "top of its own footage, so the source is what settles it."
186
+ )
187
+
188
+ @property
189
+ def expected_configs(self):
190
+ from diffusers.modular_pipelines.modular_pipeline_utils import ConfigSpec
191
+
192
+ return [ConfigSpec("canvas_short_edge", 768), ConfigSpec("canvas_max_pixels", 768 * 1344)]
193
+
194
+ @property
195
+ def inputs(self) -> list[InputParam]:
196
+ return [
197
+ InputParam(
198
+ name="source_video",
199
+ required=True,
200
+ description=(
201
+ "The clip being inpainted: a list of images, a `(num_frames, height, width, 3)` array or a "
202
+ "`(num_frames, 3, height, width)` tensor, `uint8` or floating point over `[0, 1]`."
203
+ ),
204
+ ),
205
+ InputParam(
206
+ name="source_fps",
207
+ type_hint=float,
208
+ description="The rate `source_video` carries its frames at. Left out, MiniMax-H3's own 24 fps.",
209
+ ),
210
+ InputParam.template("height", description="Height of the generated video in pixels, a multiple of 32."),
211
+ InputParam.template("width", description="Width of the generated video in pixels, a multiple of 32."),
212
+ InputParam(
213
+ name="num_frames",
214
+ type_hint=int,
215
+ description="Frames to generate. Left out, as many as the source clip has at 24 fps.",
216
+ ),
217
+ ]
218
+
219
+ @property
220
+ def intermediate_outputs(self) -> list[OutputParam]:
221
+ return [
222
+ OutputParam("height", type_hint=int, description="Resolved height of the generated video in pixels."),
223
+ OutputParam("width", type_hint=int, description="Resolved width of the generated video in pixels."),
224
+ OutputParam("num_frames", type_hint=int, description="Resolved number of frames, of the form 17 * n + 5."),
225
+ OutputParam(
226
+ "source_frame_indices",
227
+ type_hint=np.ndarray,
228
+ description=(
229
+ "The source frame every generated frame is taken from, after the 24 fps resample and the "
230
+ "alignment padding. The clip and the mask drawn over it are both read through it."
231
+ ),
232
+ ),
233
+ ]
234
+
235
+ @torch.no_grad()
236
+ def __call__(self, components: MiniMaxH3ModularPipeline, state: PipelineState) -> PipelineState:
237
+ block_state = self.get_block_state(state)
238
+
239
+ frames = normalize_source_frames(block_state.source_video)
240
+ fps = block_state.source_fps or components.fps
241
+
242
+ indices = resample_frame_indices(frames.shape[0], fps, components.fps)
243
+ num_frames = block_state.num_frames or int(indices.shape[0])
244
+
245
+ # The ceiling is a model limit, not a preference: past it the rotary clock leaves the trained range.
246
+ max_frames = int(components.max_duration * components.fps)
247
+ if num_frames > max_frames:
248
+ logger.warning(
249
+ f"MiniMax-H3 generates at most {components.max_duration:g} seconds; the source clip is "
250
+ f"{indices.shape[0] / components.fps:.2f}s at {components.fps} fps and is truncated to {max_frames} "
251
+ "frames. Inpaint a longer clip in segments."
252
+ )
253
+ num_frames = max_frames
254
+ num_frames = align_num_frames(num_frames, components.vae_frames_per_chunk, components.vae_latents_per_chunk)
255
+ if num_frames < int(components.min_duration * components.fps):
256
+ raise ValueError(
257
+ f"MiniMax-H3 generates at least {components.min_duration:g} seconds, i.e. "
258
+ f"{int(components.min_duration * components.fps)} frames; the source clip resolves to {num_frames}."
259
+ )
260
+
261
+ # Alignment rounds *up*, so a clip that ends mid-chunk is held on its last frame rather than truncated down to
262
+ # the chunk below — the tail is a repeated frame the caller can trim, not lost footage.
263
+ if indices.shape[0] < num_frames:
264
+ indices = np.concatenate([indices, np.full(num_frames - indices.shape[0], indices[-1])])
265
+ block_state.source_frame_indices = indices[:num_frames]
266
+
267
+ if (block_state.height is None) != (block_state.width is None):
268
+ raise ValueError("`height` and `width` have to be passed together, or neither of them.")
269
+ if block_state.height is None:
270
+ block_state.height, block_state.width = resolve_canvas_size(
271
+ frames.shape[2],
272
+ frames.shape[1],
273
+ components.canvas_multiple,
274
+ components.config.canvas_short_edge,
275
+ components.config.canvas_max_pixels,
276
+ )
277
+ block_state.num_frames = num_frames
278
+
279
+ self.set_block_state(state, block_state)
280
+ return components, state
281
+
282
+
283
+ class MiniMaxH3InpaintEncodeStep(ModularPipelineBlocks):
284
+ model_name = "minimax-h3"
285
+
286
+ @property
287
+ def description(self) -> str:
288
+ return (
289
+ "Encodes the source clip and its soundtrack, and reduces the masks onto the packed sequence's row grid. "
290
+ "The clip goes through the same recipe a keyframe anchor does — ImageNet normalization, a posterior "
291
+ "sampled under the fixed encode seed, float16 rounding, latent normalization — because that is what a "
292
+ "preserved row is presented as. The masks are reduced with a maximum on all three grids: the VAE's 16x "
293
+ "spatial compression, its `(1, 4, 4, 4, 4)` frame grouping, and the transformer's `2 x 2` patch."
294
+ )
295
+
296
+ @property
297
+ def expected_components(self) -> list[ComponentSpec]:
298
+ return [
299
+ ComponentSpec("vae", AutoencoderKLMiniMaxH3),
300
+ ComponentSpec("audio_vae", AutoencoderKLMiniMaxH3Audio),
301
+ ]
302
+
303
+ @property
304
+ def inputs(self) -> list[InputParam]:
305
+ return [
306
+ InputParam(name="source_video", required=True, description="The clip being inpainted. See above."),
307
+ InputParam(
308
+ name="source_frame_indices",
309
+ type_hint=np.ndarray,
310
+ required=True,
311
+ description="The source frame every generated frame is taken from.",
312
+ ),
313
+ InputParam(
314
+ name="mask",
315
+ required=True,
316
+ description=(
317
+ "Which of the source clip's pixels the model repaints: `(num_frames, height, width)` over "
318
+ "`[0, 1]`, `1` where the video regenerates and `0` where it is preserved, one frame per *source* "
319
+ "frame. Read at its own resolution — it does not have to match the canvas, only the framing."
320
+ ),
321
+ ),
322
+ InputParam(
323
+ name="source_audio",
324
+ type_hint=torch.Tensor,
325
+ description=(
326
+ "The source soundtrack, a `(channels, num_samples)` waveform. Left out, MiniMax-H3 writes the "
327
+ "whole soundtrack itself."
328
+ ),
329
+ ),
330
+ InputParam(
331
+ name="source_audio_sample_rate",
332
+ type_hint=int,
333
+ description="The rate `source_audio` carries its samples at. Left out, the audio VAE's own.",
334
+ ),
335
+ InputParam(
336
+ name="audio_mask",
337
+ type_hint=torch.Tensor,
338
+ description=(
339
+ "Which of the soundtrack the model rewrites, over the generated duration: `1` regenerates, `0` "
340
+ "preserves. Any length, resampled onto the 40 Hz audio latent clock. Left out, a source "
341
+ "soundtrack is preserved whole — which is what makes the model animate to the words already "
342
+ "there rather than inventing new ones."
343
+ ),
344
+ ),
345
+ InputParam.template("height", required=True),
346
+ InputParam.template("width", required=True),
347
+ InputParam(name="num_frames", type_hint=int, required=True, description="Resolved number of frames."),
348
+ ]
349
+
350
+ @property
351
+ def intermediate_outputs(self) -> list[OutputParam]:
352
+ return [
353
+ OutputParam(
354
+ "source_latents",
355
+ type_hint=torch.Tensor,
356
+ description="The source clip encoded onto the target canvas, `(1, C, F, latent_h, latent_w)`.",
357
+ ),
358
+ OutputParam(
359
+ "source_audio_rows",
360
+ type_hint=torch.Tensor,
361
+ description="The source soundtrack in channel-major row layout, or None.",
362
+ ),
363
+ OutputParam(
364
+ "inpaint_row_mask",
365
+ type_hint=torch.Tensor,
366
+ description="One value per generated video row: `1` regenerates, `0` preserves.",
367
+ ),
368
+ OutputParam(
369
+ "inpaint_audio_row_mask",
370
+ type_hint=torch.Tensor,
371
+ description="One value per generated audio row, channel-major.",
372
+ ),
373
+ ]
374
+
375
+ @torch.no_grad()
376
+ def __call__(self, components: MiniMaxH3ModularPipeline, state: PipelineState) -> PipelineState:
377
+ block_state = self.get_block_state(state)
378
+ device = components._execution_device
379
+
380
+ height, width, num_frames = block_state.height, block_state.width, block_state.num_frames
381
+ ratio = components.vae_spatial_compression_ratio
382
+ num_latent_frames = video_latent_num_frames(
383
+ num_frames, components.vae_frames_per_chunk, components.vae_latents_per_chunk
384
+ )
385
+ num_audio_latents = audio_latent_num_frames(num_frames, components.fps)
386
+ indices = block_state.source_frame_indices
387
+
388
+ # 1. The clip, onto the 24 fps grid and the target canvas. LANCZOS is the reference implementation's rescale.
389
+ frames = normalize_source_frames(block_state.source_video)[indices]
390
+ if frames.shape[1:3] != (height, width):
391
+ frames = np.stack(
392
+ [
393
+ np.asarray(Image.fromarray(frame).resize((width, height), Image.Resampling.LANCZOS))
394
+ for frame in frames
395
+ ]
396
+ )
397
+ pixels = torch.from_numpy(frames.copy()).to(device).permute(3, 0, 1, 2)[None]
398
+ block_state.source_latents = encode_vae_condition(
399
+ components.vae,
400
+ pixels,
401
+ components.pixel_mean,
402
+ components.pixel_std,
403
+ components.keyframe_encode_seed,
404
+ )
405
+
406
+ # 2. The mask, on the same 24 fps grid but at its own resolution — the reduction onto the latent grid is a
407
+ # maximum, so resizing it to the canvas first would only cost detail.
408
+ mask = torch.from_numpy(normalize_source_mask(block_state.mask)[indices].copy())
409
+ block_state.inpaint_row_mask = quantize_mask(
410
+ pixel_mask_to_row_mask(mask, num_latent_frames, height // ratio, width // ratio, components.patch_size)
411
+ ).to(device)
412
+
413
+ # 3. The soundtrack. A request without one generates its audio outright, so every audio row is masked in.
414
+ num_audio_rows = num_audio_latents * components.audio_channels
415
+ if block_state.source_audio is None:
416
+ block_state.source_audio_rows = None
417
+ block_state.inpaint_audio_row_mask = torch.ones(num_audio_rows, device=device)
418
+ else:
419
+ sample_rate = block_state.source_audio_sample_rate or components.audio_sampling_rate
420
+ waveform = normalize_waveform(block_state.source_audio, sample_rate, components.audio_sampling_rate)
421
+ # The audio VAE right-pads to a whole hop, so exactly `num_audio_latents` hops encode to exactly the rows
422
+ # the layout reserves. Silence pads a soundtrack shorter than the video.
423
+ num_samples = num_audio_latents * components.audio_vae.hop_length
424
+ if waveform.shape[-1] < num_samples:
425
+ waveform = F.pad(waveform, (0, num_samples - waveform.shape[-1]))
426
+ waveform = waveform[:, :num_samples]
427
+
428
+ # Under `native` attention explicitly: the audio VAE's encoder carries a causal-attention `pre_block` kept
429
+ # in float32, and cuDNN has no float32 SDPA kernel, so the backend a pipeline selects for its transformer
430
+ # aborts here. Only *encoding* audio reaches this path.
431
+ with attention_backend("native"):
432
+ posterior = components.audio_vae.encode(waveform.to(device)[:, None], return_dict=False)[0]
433
+ latents = posterior.mode().float().transpose(1, 2)
434
+ mean = torch.tensor(components.audio_vae.config.latents_mean, device=latents.device).view(1, 1, -1)
435
+ std = torch.tensor(components.audio_vae.config.latents_std, device=latents.device).view(1, 1, -1)
436
+ rows = ((latents - mean) / std).reshape(-1, components.audio_latent_channels)
437
+ if rows.shape[0] != num_audio_rows:
438
+ raise ValueError(
439
+ f"The source soundtrack encodes to {rows.shape[0]} audio rows but the layout holds "
440
+ f"{num_audio_rows}."
441
+ )
442
+ block_state.source_audio_rows = rows
443
+
444
+ if block_state.audio_mask is None:
445
+ block_state.inpaint_audio_row_mask = torch.zeros(num_audio_rows, device=device)
446
+ else:
447
+ block_state.inpaint_audio_row_mask = quantize_mask(
448
+ audio_mask_to_row_mask(
449
+ block_state.audio_mask, num_audio_latents, components.audio_channels
450
+ )
451
+ ).to(device)
452
+
453
+ self.set_block_state(state, block_state)
454
+ return components, state
455
+
456
+
457
+ class MiniMaxH3InpaintPrepareStep(ModularPipelineBlocks):
458
+ model_name = "minimax-h3"
459
+
460
+ @property
461
+ def description(self) -> str:
462
+ return (
463
+ "Packs the source latents into rows, checks them against the layout, keeps the noise the generated rows "
464
+ "were drawn from, and imposes the source once before the first forward. From here the loop reads a "
465
+ "sequence whose preserved rows already hold the source at the conditioning level."
466
+ )
467
+
468
+ @property
469
+ def inputs(self) -> list[InputParam]:
470
+ return [
471
+ InputParam(
472
+ name="source_latents", type_hint=torch.Tensor, required=True, description="The encoded source clip."
473
+ ),
474
+ InputParam(
475
+ name="source_audio_rows",
476
+ type_hint=torch.Tensor,
477
+ description="The encoded source soundtrack in row layout, or None.",
478
+ ),
479
+ InputParam(
480
+ name="inpaint_row_mask", type_hint=torch.Tensor, required=True, description="Per video row."
481
+ ),
482
+ InputParam(
483
+ name="inpaint_audio_row_mask", type_hint=torch.Tensor, required=True, description="Per audio row."
484
+ ),
485
+ InputParam(
486
+ name="latents",
487
+ type_hint=torch.Tensor,
488
+ required=True,
489
+ description="The video rows of the packed sequence, conditioning rows first.",
490
+ ),
491
+ InputParam(
492
+ name="audio_latents",
493
+ type_hint=torch.Tensor,
494
+ required=True,
495
+ description="The audio rows of the packed sequence, reference rows first.",
496
+ ),
497
+ InputParam(
498
+ name="num_condition_video_rows",
499
+ type_hint=int,
500
+ default=0,
501
+ description="How many leading video rows are conditioning rows.",
502
+ ),
503
+ InputParam(
504
+ name="num_condition_audio_rows",
505
+ type_hint=int,
506
+ default=0,
507
+ description="How many leading audio rows are reference rows.",
508
+ ),
509
+ ]
510
+
511
+ @property
512
+ def intermediate_outputs(self) -> list[OutputParam]:
513
+ return [
514
+ OutputParam("latents", type_hint=torch.Tensor, description="The video rows, source imposed."),
515
+ OutputParam("audio_latents", type_hint=torch.Tensor, description="The audio rows, source imposed."),
516
+ OutputParam("source_rows", type_hint=torch.Tensor, description="The source clip in row layout."),
517
+ OutputParam(
518
+ "inpaint_noise_rows",
519
+ type_hint=torch.Tensor,
520
+ description="The noise the generated video rows were drawn from.",
521
+ ),
522
+ OutputParam(
523
+ "inpaint_audio_noise_rows",
524
+ type_hint=torch.Tensor,
525
+ description="The noise the generated audio rows were drawn from.",
526
+ ),
527
+ ]
528
+
529
+ @torch.no_grad()
530
+ def __call__(self, components: MiniMaxH3ModularPipeline, state: PipelineState) -> PipelineState:
531
+ block_state = self.get_block_state(state)
532
+ device = components._execution_device
533
+
534
+ num_condition_video_rows = block_state.num_condition_video_rows
535
+ num_condition_audio_rows = block_state.num_condition_audio_rows
536
+
537
+ source_rows = patchify_video_latents(
538
+ block_state.source_latents.to(device, torch.float32), components.patch_size
539
+ )
540
+ generated_video_rows = block_state.latents.shape[0] - num_condition_video_rows
541
+ if source_rows.shape[0] != generated_video_rows:
542
+ raise ValueError(
543
+ f"The source clip packs into {source_rows.shape[0]} video rows but the layout reserved "
544
+ f"{generated_video_rows} generated ones. The canvas the layout was built from and the one the source "
545
+ "was encoded at do not agree."
546
+ )
547
+ if block_state.inpaint_row_mask.shape[0] != generated_video_rows:
548
+ raise ValueError(
549
+ f"The mask reduces to {block_state.inpaint_row_mask.shape[0]} rows but the layout reserved "
550
+ f"{generated_video_rows} generated video rows."
551
+ )
552
+ block_state.source_rows = source_rows
553
+
554
+ block_state.inpaint_noise_rows = block_state.latents[num_condition_video_rows:].clone()
555
+ block_state.inpaint_audio_noise_rows = block_state.audio_latents[num_condition_audio_rows:].clone()
556
+
557
+ # The first forward reads the same presentation every later one does.
558
+ block_state.latents[num_condition_video_rows:] = impose_source(
559
+ block_state.latents[num_condition_video_rows:],
560
+ source_rows,
561
+ block_state.inpaint_noise_rows,
562
+ block_state.inpaint_row_mask,
563
+ VISUAL_COND_TIMESTEP,
564
+ components.scheduler,
565
+ )
566
+ if block_state.source_audio_rows is not None:
567
+ block_state.audio_latents[num_condition_audio_rows:] = impose_source(
568
+ block_state.audio_latents[num_condition_audio_rows:],
569
+ block_state.source_audio_rows.to(device, torch.float32),
570
+ block_state.inpaint_audio_noise_rows,
571
+ block_state.inpaint_audio_row_mask,
572
+ AUDIO_COND_TIMESTEP,
573
+ components.audio_scheduler,
574
+ )
575
+
576
+ self.set_block_state(state, block_state)
577
+ return components, state
578
+
579
+
580
+ def impose_source(
581
+ rows: torch.Tensor,
582
+ source_rows: torch.Tensor,
583
+ noise_rows: torch.Tensor,
584
+ row_mask: torch.Tensor,
585
+ condition_timestep: float,
586
+ scheduler,
587
+ ) -> torch.Tensor:
588
+ r"""
589
+ Blend the source into the rows the mask preserves.
590
+
591
+ The preserved content enters at the *conditioning* timestep rather than the step's own — clean for audio, `0.999`
592
+ for video — and the mask does the mixing, so a row with mask `m` ends up carrying content at roughly
593
+ `1 - m * sigma`, which is exactly the timestep the layout labels it with.
594
+
595
+ Args:
596
+ rows (`torch.Tensor` of shape `(num_rows, dim)`): The generated rows as the loop last left them.
597
+ source_rows (`torch.Tensor` of shape `(num_rows, dim)`): The source in the same layout.
598
+ noise_rows (`torch.Tensor` of shape `(num_rows, dim)`): The noise those rows were drawn from.
599
+ row_mask (`torch.Tensor` of shape `(num_rows,)`): `1` regenerates, `0` preserves.
600
+ condition_timestep (`float`): The level the source is presented at.
601
+ scheduler (`MiniMaxH3Scheduler`): The stream's scheduler, for its forward process.
602
+
603
+ Returns:
604
+ `torch.Tensor`: The rows with the source imposed.
605
+ """
606
+ mask = row_mask.to(rows.dtype).unsqueeze(-1)
607
+ source = source_rows.to(rows)
608
+ if condition_timestep < 1.0:
609
+ source = scheduler.scale_noise(source, condition_timestep, noise_rows.to(rows))
610
+ return mask * rows + (1.0 - mask) * source
611
+
612
+
613
+ def _place_rows(row_mask: torch.Tensor, timestep: float, pin: float) -> torch.Tensor:
614
+ r"""
615
+ Place every generated row of one stream on its own timestep: `1 - m * sigma`, pinned at `pin`.
616
+
617
+ The two ends are *selected* rather than computed. Every distinct value here becomes a row of the transformer's
618
+ modulation table, and float32 arithmetic lands `1 - 1 * (1 - t)` a ulp away from `t` — which would split each
619
+ level in two and leave the fully generated rows claiming a timestep the scheduler is not actually stepping them
620
+ on. Computing in float64 and selecting the endpoints keeps a hard mask exactly as cheap as no mask at all.
621
+
622
+ Args:
623
+ row_mask (`torch.Tensor` of shape `(num_rows,)`): `1` regenerates, `0` preserves.
624
+ timestep (`float`): The stream's own timestep this step.
625
+ pin (`float`): Where a fully preserved row sits, i.e. the conditioning timestep or the schedule if it is
626
+ already cleaner.
627
+
628
+ Returns:
629
+ `torch.Tensor` of shape `(num_rows,)`: the float32 timestep of every row.
630
+ """
631
+ mask = row_mask.detach().cpu().to(torch.float64)
632
+ rows = (1.0 - mask * (1.0 - timestep)).clamp(max=pin)
633
+ rows = torch.where(mask >= 1.0, torch.tensor(timestep, dtype=torch.float64), rows)
634
+ rows = torch.where(mask <= 0.0, torch.tensor(pin, dtype=torch.float64), rows)
635
+ return rows.to(torch.float32)
636
+
637
+
638
+ class MiniMaxH3InpaintSetTimestepsStep(MiniMaxH3SetTimestepsStep):
639
+ model_name = "minimax-h3"
640
+
641
+ @property
642
+ def description(self) -> str:
643
+ return (
644
+ "The `ref2va` timestep plan, with every generated row relabelled by the mask. A row masked `m` sits at "
645
+ "`1 - m * sigma` of its own stream's schedule, clamped at the conditioning timestep: fully generated rows "
646
+ "keep the schedule, fully preserved ones land on `0.999` (video) or `1.0` (audio) — where the checkpoint "
647
+ "reads clean conditioning — and feathered ones fall honestly in between. MiniMax-H3 already carries a "
648
+ "per-row timestep vector for its anchors, so nothing about the transformer changes."
649
+ )
650
+
651
+ @property
652
+ def inputs(self) -> list[InputParam]:
653
+ return super().inputs + [
654
+ InputParam(
655
+ name="inpaint_row_mask", type_hint=torch.Tensor, required=True, description="Per video row."
656
+ ),
657
+ InputParam(
658
+ name="inpaint_audio_row_mask", type_hint=torch.Tensor, required=True, description="Per audio row."
659
+ ),
660
+ ]
661
+
662
+ @staticmethod
663
+ def build_masked_row_timesteps(
664
+ video_indices: torch.Tensor,
665
+ audio_indices: torch.Tensor,
666
+ num_condition_video_rows: int,
667
+ num_condition_audio_rows: int,
668
+ num_text_tokens: int,
669
+ video_timestep: float,
670
+ audio_timestep: float,
671
+ condition_video_timestep: float,
672
+ condition_audio_timestep: float,
673
+ video_row_mask: torch.Tensor,
674
+ audio_row_mask: torch.Tensor,
675
+ ) -> tuple[torch.Tensor, torch.Tensor]:
676
+ r"""
677
+ [`MiniMaxH3SetTimestepsStep.build_row_timesteps`] with the generated rows placed by the mask.
678
+
679
+ Returns:
680
+ `tuple[torch.Tensor, torch.Tensor]`: the distinct timesteps, sorted, and the index of every row into them.
681
+ """
682
+ sequence_length = int(video_indices.numel() + audio_indices.numel() + num_text_tokens)
683
+ row_timesteps = torch.full((sequence_length,), video_timestep, dtype=torch.float32)
684
+
685
+ # `1 - t` is the stream's sigma; a row only carries `m` of it, and a fully preserved row is pinned wherever
686
+ # the checkpoint reads conditioning — which the schedule itself overtakes on the last steps.
687
+ video_rows = _place_rows(video_row_mask, video_timestep, max(video_timestep, condition_video_timestep))
688
+ audio_rows = _place_rows(audio_row_mask, audio_timestep, max(audio_timestep, condition_audio_timestep))
689
+
690
+ row_timesteps[video_indices[num_condition_video_rows:]] = video_rows
691
+ row_timesteps[video_indices[:num_condition_video_rows]] = condition_video_timestep
692
+ row_timesteps[audio_indices[num_condition_audio_rows:]] = audio_rows
693
+ row_timesteps[audio_indices[:num_condition_audio_rows]] = condition_audio_timestep
694
+ return torch.unique(row_timesteps, sorted=True, return_inverse=True)
695
+
696
+ @torch.no_grad()
697
+ def __call__(self, components: MiniMaxH3ModularPipeline, state: PipelineState) -> PipelineState:
698
+ block_state = self.get_block_state(state)
699
+ device = components._execution_device
700
+
701
+ components.scheduler.set_timesteps(block_state.num_inference_steps, device=device)
702
+ components.audio_scheduler.set_timesteps(block_state.num_inference_steps, device=device)
703
+ block_state.timesteps = components.scheduler.timesteps
704
+ block_state.audio_timesteps = components.audio_scheduler.timesteps
705
+
706
+ block_state.row_timestep_plan = [
707
+ tuple(
708
+ tensor.to(device)
709
+ for tensor in self.build_masked_row_timesteps(
710
+ block_state.video_indices,
711
+ block_state.audio_indices,
712
+ block_state.num_condition_video_rows,
713
+ block_state.num_condition_audio_rows,
714
+ block_state.text_indices.numel(),
715
+ float(timestep),
716
+ float(audio_timestep),
717
+ max(float(timestep), components.keyframe_noise_aug),
718
+ AUDIO_COND_TIMESTEP,
719
+ block_state.inpaint_row_mask,
720
+ block_state.inpaint_audio_row_mask,
721
+ )
722
+ )
723
+ for timestep, audio_timestep in zip(block_state.timesteps, block_state.audio_timesteps)
724
+ ]
725
+
726
+ self.set_block_state(state, block_state)
727
+ return components, state
728
+
729
+
730
+ class MiniMaxH3InpaintLoopSchedulerStep(MiniMaxH3LoopSchedulerStep):
731
+ model_name = "minimax-h3"
732
+
733
+ @property
734
+ def description(self) -> str:
735
+ return (
736
+ "Steps both streams, then re-imposes the source on every row the mask preserves. The scheduler is still "
737
+ "stepped over those rows so its step index keeps up with the loop; the write-back is what the next "
738
+ "forward reads. The last step imposes the source clean, so what the mask preserved decodes back to the "
739
+ "footage it came from."
740
+ )
741
+
742
+ @property
743
+ def inputs(self) -> list[InputParam]:
744
+ return super().inputs + [
745
+ InputParam(name="source_rows", type_hint=torch.Tensor, required=True, description="The source clip."),
746
+ InputParam(
747
+ name="source_audio_rows", type_hint=torch.Tensor, description="The source soundtrack, or None."
748
+ ),
749
+ InputParam(
750
+ name="inpaint_noise_rows", type_hint=torch.Tensor, required=True, description="Video row noise."
751
+ ),
752
+ InputParam(
753
+ name="inpaint_audio_noise_rows", type_hint=torch.Tensor, required=True, description="Audio row noise."
754
+ ),
755
+ InputParam(
756
+ name="inpaint_row_mask", type_hint=torch.Tensor, required=True, description="Per video row."
757
+ ),
758
+ InputParam(
759
+ name="inpaint_audio_row_mask", type_hint=torch.Tensor, required=True, description="Per audio row."
760
+ ),
761
+ ]
762
+
763
+ @torch.no_grad()
764
+ def __call__(self, components: MiniMaxH3ModularPipeline, block_state: BlockState, i: int, t: torch.Tensor):
765
+ components, block_state = super().__call__(components, block_state, i, t)
766
+
767
+ # On the last step the schedule has reached the clean end, and so does the source: the preserved rows are
768
+ # handed to the decoder as the footage itself rather than as the anchor's 0.999.
769
+ last = i + 1 >= block_state.timesteps.numel()
770
+ video_level = 1.0 if last else VISUAL_COND_TIMESTEP
771
+
772
+ num_condition_video_rows = block_state.num_condition_video_rows
773
+ block_state.latents[num_condition_video_rows:] = impose_source(
774
+ block_state.latents[num_condition_video_rows:],
775
+ block_state.source_rows,
776
+ block_state.inpaint_noise_rows,
777
+ block_state.inpaint_row_mask,
778
+ video_level,
779
+ components.scheduler,
780
+ )
781
+ if block_state.source_audio_rows is not None:
782
+ num_condition_audio_rows = block_state.num_condition_audio_rows
783
+ block_state.audio_latents[num_condition_audio_rows:] = impose_source(
784
+ block_state.audio_latents[num_condition_audio_rows:],
785
+ block_state.source_audio_rows,
786
+ block_state.inpaint_audio_noise_rows,
787
+ block_state.inpaint_audio_row_mask,
788
+ AUDIO_COND_TIMESTEP,
789
+ components.audio_scheduler,
790
+ )
791
+ return components, block_state
792
+
793
+
794
+ class MiniMaxH3InpaintDenoiseStep(MiniMaxH3DenoiseLoopWrapper):
795
+ model_name = "minimax-h3"
796
+ block_classes = [MiniMaxH3Ref2VALoopDenoiser, MiniMaxH3InpaintLoopSchedulerStep]
797
+ block_names = ["denoiser", "update"]
798
+
799
+ @property
800
+ def description(self) -> str:
801
+ return "Runs the `ref2va` denoising loop with the source imposed on every preserved row, one forward per step."
802
+
803
+
804
+ class MiniMaxH3Ref2VAInpaintCoreDenoiseStep(SequentialPipelineBlocks):
805
+ model_name = "minimax-h3"
806
+ block_classes = [
807
+ MiniMaxH3Ref2VAPrepareLayoutStep,
808
+ MiniMaxH3PrepareConditionLatentsStep,
809
+ MiniMaxH3PrepareLatentsStep,
810
+ MiniMaxH3Ref2VAPrepareLatentsStep,
811
+ MiniMaxH3InpaintPrepareStep,
812
+ MiniMaxH3InpaintSetTimestepsStep,
813
+ MiniMaxH3InpaintDenoiseStep,
814
+ MiniMaxH3AfterDenoiseStep,
815
+ ]
816
+ block_names = [
817
+ "prepare_layout",
818
+ "prepare_condition_latents",
819
+ "prepare_latents",
820
+ "prepare_latents_ref2va",
821
+ "prepare_inpaint",
822
+ "set_timesteps",
823
+ "denoise",
824
+ "after_denoise",
825
+ ]
826
+
827
+ @property
828
+ def description(self) -> str:
829
+ return (
830
+ "Core denoising workflow for masked `ref2va` inpainting: `MiniMaxH3Ref2VACoreDenoiseStep` with the source "
831
+ "clip imposed on every row the mask preserves, and those rows relabelled onto the conditioning timestep."
832
+ )
833
+
834
+
835
+ class MiniMaxH3Ref2VAInpaintBlocks(SequentialPipelineBlocks):
836
+ model_name = "minimax-h3"
837
+ block_classes = [
838
+ MiniMaxH3InpaintSourceGeometryStep,
839
+ MiniMaxH3Ref2VASetupStep,
840
+ MiniMaxH3Ref2VATextEncoderStep,
841
+ MiniMaxH3Ref2VAReferenceEncoderStep,
842
+ MiniMaxH3InpaintEncodeStep,
843
+ MiniMaxH3Ref2VAInpaintCoreDenoiseStep,
844
+ MiniMaxH3DecodeStep,
845
+ ]
846
+ block_names = [
847
+ "source_geometry",
848
+ "setup",
849
+ "text_encoder",
850
+ "reference_encoder",
851
+ "inpaint_encoder",
852
+ "denoise",
853
+ "decode",
854
+ ]
855
+
856
+ @property
857
+ def description(self) -> str:
858
+ return (
859
+ "Masked video and audio inpainting with MiniMax-H3, on the `ref2va` reference workflow: the source clip "
860
+ "settles the canvas and the frame count, the references condition the repaint the way they condition any "
861
+ "`ref2va` request, and the mask decides which rows the model writes. Everything the mask preserves rides "
862
+ "through the loop as conditioning, at the levels the checkpoint reads it at."
863
+ )
864
+
865
+ @property
866
+ def outputs(self):
867
+ return [
868
+ OutputParam.template("videos", description="The inpainted video."),
869
+ OutputParam(
870
+ "audio",
871
+ type_hint=torch.Tensor,
872
+ description="The soundtrack of the packed sequence, of shape `(1, 2, num_samples)`.",
873
+ ),
874
+ OutputParam("sampling_rate", type_hint=int, description="Sample rate of the soundtrack in Hz."),
875
+ ]
876
+
877
+
878
+ class MiniMaxH3Ref2VAInpaintGeneratorBlocks(SequentialPipelineBlocks):
879
+ model_name = "minimax-h3"
880
+ # `MiniMaxH3Ref2VAInpaintBlocks` without its text-encoder step, so the 62 GiB Qwen3-VL can live somewhere else and
881
+ # `prompt_embeds` / `text_token_tags` are the whole wire format — the split the reference and audio-to-video
882
+ # Spaces already deploy behind. The geometry step stays: it is also what maps source frames onto the 24 fps grid,
883
+ # and passing the conditioner's resolved `height` / `width` / `num_frames` through it leaves them untouched.
884
+ block_classes = [
885
+ MiniMaxH3InpaintSourceGeometryStep,
886
+ MiniMaxH3Ref2VASetupStep,
887
+ MiniMaxH3Ref2VAReferenceEncoderStep,
888
+ MiniMaxH3InpaintEncodeStep,
889
+ MiniMaxH3Ref2VAInpaintCoreDenoiseStep,
890
+ MiniMaxH3DecodeStep,
891
+ ]
892
+ block_names = ["source_geometry", "setup", "reference_encoder", "inpaint_encoder", "denoise", "decode"]
893
+
894
+ @property
895
+ def description(self) -> str:
896
+ return (
897
+ "The denoising half of a split MiniMax-H3 deployment, masked: `MiniMaxH3Ref2VAInpaintBlocks` without its "
898
+ "text-encoder step. The caller has to pass the `height`, `width` and `num_frames` the conditioner "
899
+ "resolved, because the presentation the embeddings were built from encodes that plan."
900
+ )
901
+
902
+ @property
903
+ def outputs(self):
904
+ return MiniMaxH3Ref2VAInpaintBlocks.outputs.fget(self)
905
+
906
+ # The VAE's chunked causal grouping, as pixel frames per latent frame within one 17-frame chunk. `clip_length = 17`
907
+ # pre-pads to 20 at `vae_ratio_t = 4` and drops the 3 leading tokens, so the chunk's first kept latent frame covers
908
+ # one real pixel frame and the remaining four cover four each.
909
+ MINIMAX_H3_FRAMES_PER_CHUNK = 17
910
+ MINIMAX_H3_FRAMES_PER_LATENT = (1, 4, 4, 4, 4)
911
+
912
+
913
+ def latent_frame_groups(
914
+ num_frames: int,
915
+ num_latent_frames: int,
916
+ frames_per_chunk: int = MINIMAX_H3_FRAMES_PER_CHUNK,
917
+ frames_per_latent: tuple[int, ...] = MINIMAX_H3_FRAMES_PER_LATENT,
918
+ ) -> list[tuple[int, int]]:
919
+ r"""
920
+ The pixel frames each latent frame is encoded from.
921
+
922
+ Args:
923
+ num_frames (`int`): Pixel frames of the clip, of the form `17 * n + 5`.
924
+ num_latent_frames (`int`): Latent frames the VAE produces for them, `5 * n + 2`.
925
+ frames_per_chunk (`int`, defaults to 17): Pixel frames per VAE chunk, its `clip_length`.
926
+ frames_per_latent (`tuple[int, ...]`, defaults to `(1, 4, 4, 4, 4)`):
927
+ Pixel frames each latent frame of a chunk covers, cycling from the first frame.
928
+
929
+ Returns:
930
+ `list[tuple[int, int]]`: one `(start, end)` half-open pixel-frame range per latent frame, covering the clip.
931
+ """
932
+ starts = [0]
933
+ for span in frames_per_latent:
934
+ starts.append(starts[-1] + span)
935
+ cycle = len(frames_per_latent)
936
+
937
+ groups = []
938
+ for index in range(num_latent_frames):
939
+ start = (index // cycle) * frames_per_chunk + starts[index % cycle]
940
+ end = start + frames_per_latent[index % cycle]
941
+ groups.append((min(start, num_frames - 1), min(end, num_frames)))
942
+ # A frame count that ends mid-chunk leaves a tail no latent frame's own span reaches; it is encoded into the last
943
+ # latent frame, so that is where its coverage belongs.
944
+ groups[-1] = (groups[-1][0], num_frames)
945
+ return groups
946
+
947
+
948
+ def pixel_mask_to_row_mask(
949
+ mask: torch.Tensor,
950
+ num_latent_frames: int,
951
+ latent_height: int,
952
+ latent_width: int,
953
+ patch_size: tuple[int, int, int] = (1, 2, 2),
954
+ frames_per_chunk: int = MINIMAX_H3_FRAMES_PER_CHUNK,
955
+ frames_per_latent: tuple[int, ...] = MINIMAX_H3_FRAMES_PER_LATENT,
956
+ ) -> torch.Tensor:
957
+ r"""
958
+ Reduce a pixel-space video mask to one value per video row of the packed sequence.
959
+
960
+ Every reduction is a maximum: a row regenerates as much as the most-masked pixel it covers asks it to. That is the
961
+ safe direction — a token the model must repaint is never accidentally pinned to the source — and it is what makes
962
+ a feathered mask behave, since the softest values survive into the row rather than being averaged away.
963
+
964
+ Args:
965
+ mask (`torch.Tensor` of shape `(num_frames, height, width)`):
966
+ The mask over the source clip, `1` where the video regenerates and `0` where it is preserved. Values in
967
+ between are honoured: they place the row part-way down its own schedule.
968
+ num_latent_frames (`int`), latent_height (`int`), latent_width (`int`):
969
+ The generated video's latent shape, i.e. what the layout step resolved.
970
+ patch_size (`tuple[int, int, int]`, defaults to `(1, 2, 2)`): The transformer's `(t, h, w)` patch.
971
+ frames_per_chunk (`int`), frames_per_latent (`tuple[int, ...]`): See [`latent_frame_groups`].
972
+
973
+ Returns:
974
+ `torch.Tensor` of shape `(num_latent_frames * (latent_height // patch_h) * (latent_width // patch_w),)`:
975
+ one value per video row, in the frame-major then row-major order [`patchify_video_latents`] produces.
976
+ """
977
+ if mask.ndim != 3:
978
+ raise ValueError(f"A video mask must be `(num_frames, height, width)`, got {tuple(mask.shape)}.")
979
+ _, patch_h, patch_w = patch_size
980
+ if latent_height % patch_h or latent_width % patch_w:
981
+ raise ValueError(
982
+ f"A {latent_height}x{latent_width} latent canvas is not divisible by the patch {(patch_h, patch_w)}."
983
+ )
984
+
985
+ num_frames = mask.shape[0]
986
+ mask = mask.to(torch.float32).clamp(0.0, 1.0)
987
+
988
+ # 1. Spatially, onto the latent grid. `adaptive_max_pool2d` divides each axis into `latent_*` near-equal bands,
989
+ # which is the VAE's own 16x split whenever the canvas is a multiple of 16 — and it stays sane when it is not.
990
+ reduced = F.adaptive_max_pool2d(mask[:, None], (latent_height, latent_width))[:, 0]
991
+
992
+ # 2. Temporally, onto the VAE's chunked grouping.
993
+ groups = latent_frame_groups(num_frames, num_latent_frames, frames_per_chunk, frames_per_latent)
994
+ reduced = torch.stack([reduced[start:end].amax(dim=0) for start, end in groups])
995
+
996
+ # 3. Onto the transformer's patch. A row is one token: it carries one timestep and is written back as a whole, so
997
+ # sub-patch detail cannot survive and the strongest value in the patch is what the row acts on.
998
+ rows = reduced.reshape(
999
+ num_latent_frames, latent_height // patch_h, patch_h, latent_width // patch_w, patch_w
1000
+ ).amax(dim=(2, 4))
1001
+ return rows.reshape(-1)
1002
+
1003
+
1004
+ def audio_mask_to_row_mask(
1005
+ mask: torch.Tensor,
1006
+ num_audio_latents: int,
1007
+ audio_channels: int = 2,
1008
+ ) -> torch.Tensor:
1009
+ r"""
1010
+ Reduce a mask over the soundtrack's timeline to one value per audio row of the packed sequence.
1011
+
1012
+ Args:
1013
+ mask (`torch.Tensor` of shape `(num_audio_latents,)` or `(n,)`):
1014
+ The mask over the generated soundtrack, `1` where the audio regenerates and `0` where it is preserved. A
1015
+ mask of another length is resampled onto the audio latent clock with a maximum, so a mask drawn at video
1016
+ frame rate — or as a timeline image — lands on the right latents rather than half a beat off.
1017
+ num_audio_latents (`int`): Audio latents per channel, i.e. what the layout step resolved.
1018
+ audio_channels (`int`, defaults to 2): Channels the soundtrack is packed channel-major over.
1019
+
1020
+ Returns:
1021
+ `torch.Tensor` of shape `(num_audio_latents * audio_channels,)`: one value per audio row, channel-major.
1022
+ """
1023
+ mask = mask.reshape(-1).to(torch.float32).clamp(0.0, 1.0)
1024
+ if mask.shape[0] != num_audio_latents:
1025
+ mask = F.adaptive_max_pool1d(mask[None, None], num_audio_latents)[0, 0]
1026
+ # Channel-major: the layout lays both stereo channels out as two blocks of `num_audio_latents` rows, so one
1027
+ # timeline repeats rather than interleaves.
1028
+ return mask.repeat(audio_channels)
1029
+
1030
+
1031
+ def quantize_mask(mask: torch.Tensor, levels: int = 256) -> torch.Tensor:
1032
+ r"""
1033
+ Snap a mask to a grid of `levels` steps, rounding *up* so a partly-masked row never rounds to fully preserved.
1034
+
1035
+ Each distinct mask value becomes a distinct row timestep, and every distinct row timestep is an extra row in the
1036
+ transformer's modulation table. A feathered mask left at float32 can carry thousands of them; on this grid it
1037
+ carries at most `levels + 1`, and the difference is invisible at the noise levels involved.
1038
+
1039
+ Args:
1040
+ mask (`torch.Tensor`): The mask to snap.
1041
+ levels (`int`, defaults to 256): Steps to snap to.
1042
+
1043
+ Returns:
1044
+ `torch.Tensor`: The snapped mask.
1045
+ """
1046
+ return torch.ceil(mask * levels) / levels
modular_config.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "MiniMaxH3Ref2VAInpaintBlocks",
3
+ "_diffusers_version": "0.40.0.dev0",
4
+ "auto_map": {
5
+ "ModularPipelineBlocks": "minimax_h3_inpaint_blocks.MiniMaxH3Ref2VAInpaintBlocks"
6
+ }
7
+ }