Instructions to use diffusers-modular/minimax-h3-inpainting with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use diffusers-modular/minimax-h3-inpainting with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("diffusers-modular/minimax-h3-inpainting", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
Masked video+audio inpainting blocks for MiniMax-H3
Browse files- README.md +129 -0
- assets/edge.webp +0 -0
- assets/example.webp +0 -0
- assets/mask.webp +0 -0
- crop.py +222 -0
- minimax_h3_inpaint_blocks.py +1046 -0
- modular_config.json +7 -0
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 |
+

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

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

|
| 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 |
+
}
|