Initial commit: MiniMax H3 Refine (fl2va)

This commit is contained in:
Jordan Caprasse
2026-11-03 10:26:00 +01:00
commit bbb405f279
5 changed files with 344 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.DS_Store
+73
View File
@@ -0,0 +1,73 @@
# ComfyUI-MiniMaxH3-Refine
Nœud **MiniMax H3 Refine (fl2va)** : prend une vidéo brouillon H3 (image + son),
générée avec trop peu de steps, et prépare le latent AV et le conditioning positif
pour la corriger avec un sampler à **faible denoise** sur le checkpoint **fl2va**
(beaucoup plus léger que le ref2va, qui garde la vidéo de référence dans la séquence
à chaque step).
## Installation
Copier le dossier `ComfyUI-MiniMaxH3-Refine` dans `ComfyUI/custom_nodes/`, puis redémarrer.
Aucune dépendance supplémentaire (utilise torch et torchaudio fournis avec ComfyUI).
Prérequis : une version de ComfyUI avec le support natif de MiniMax H3
(PR #15224) et l'API `comfy_api.latest`.
## Nœud
Catégorie : `model/conditioning/minimax`
| Entrée | Rôle |
|---|---|
| `clip` | Encodeur de texte H3 |
| `vae` | VAE vidéo H3 |
| `audio_vae` | VAE audio H3 |
| `prompt` | Même prompt (structuré) que pour le brouillon, soundscape inclus |
| `images` | Frames du brouillon |
| `audio` (opt.) | Audio du brouillon. Absent = silence |
| `source_fps` | FPS du brouillon (rééchantillonné à 24 fps si différent) |
| `width` / `height` | 0 = taille du brouillon arrondie au multiple de 32 |
| `first_frame` / `last_frame` (opt.) | Keyframes **propres**, réinjectées à chaque step |
Sorties : `positive`, `latent` (AV nested), `frames` (nombre de frames utilisé).
Le nœud coupe le brouillon à la plus grande longueur 17k+5 (ex. 130 -> 124 frames).
## Workflow conseillé
```
GetVideoComponents ──images/audio──► MiniMax H3 Refine (fl2va) ──positive──► BasicGuider (cfg 1) ──► SamplerCustomAdvanced ──► séparation AV ──► VAEDecode (vidéo)
└─latent──────────────────────────────────────────────────────► └─► décodage VAE audio
```
- Modèle : checkpoint **fl2va** (idéalement avec `MiniMaxH3SigmaShift`, valeurs par défaut 12 / 3).
- `BasicGuider` avec cfg 1 (le DiT H3 refuse les batchs).
- `BasicScheduler` : 6 à 8 steps, **denoise bas**.
## Réglage du denoise
Avec un shift vidéo de 12, la plage utile est très basse. Valeurs indicatives
(σ vidéo de départ, calcul théorique à confirmer sur ton modèle) :
| denoise | σ vidéo de départ |
|---|---|
| 0.03 | ≈ 0.27 |
| 0.05 | ≈ 0.39 |
| 0.08 | ≈ 0.51 |
| 0.12 | ≈ 0.62 |
| 0.20 | ≈ 0.75 |
Protocole de test :
1. **Identité** : denoise ≈ 0.01, la sortie doit ressembler au brouillon. Une dérive de
couleur ou de saturation indique un souci de normalisation du latent.
2. **Balayage** : même seed, 0.03 / 0.05 / 0.08 / 0.12 / 0.20, comparer image et son séparément.
3. L'audio reçoit moins de bruit que la vidéo à denoise égal. S'il reste trop proche du
brouillon, augmenter le denoise ou `shift_audio`.
## Dépannage
- **"latent vidéo inattendu"** : vérifier que `vae` est bien le VAE vidéo H3.
- **"impossible d'encoder l'audio"** : le nœud essaie les layouts `[B,2,L]` et `[B,L,2]` ;
vérifier la signature de `audio_vae.encode` dans ta version de ComfyUI.
- **Qualité figée** : ne pas mettre de frames du brouillon en `first_frame`/`last_frame`.
+10
View File
@@ -0,0 +1,10 @@
"""ComfyUI-MiniMaxH3-Refine
Nœud de raffinement vidéo+audio pour MiniMax H3 (checkpoint fl2va).
Nécessite une version de ComfyUI avec le support natif de MiniMax H3
(PR Comfy-Org/ComfyUI #15224) et l'API `comfy_api.latest`.
"""
from .minimax_h3_refine import comfy_entrypoint
__all__ = ["comfy_entrypoint"]
+251
View File
@@ -0,0 +1,251 @@
"""MiniMax H3 Refine (fl2va)
Prend une vidéo brouillon (frames + audio) générée par MiniMax H3 avec trop peu
de steps, et prépare tout ce qu'il faut pour la corriger avec un sampler à faible
denoise, sur le checkpoint fl2va :
- un latent AV (NestedTensor : vidéo [1,24,T,H/16,W/16] + audio [1,32,2,T40])
obtenu en encodant le brouillon avec le VAE vidéo et le VAE audio de H3 ;
- un conditioning positif identique à celui de MiniMaxH3ImageToVideo
(prompt Qwen3-VL + keyframes optionnelles propres, minimax_frame_count).
Installation : copier ce fichier dans ComfyUI/custom_nodes/ puis redémarrer.
Chaîne conseillée :
GetVideoComponents -> [ce nœud] -> BasicGuider (cfg 1) -> SamplerCustomAdvanced
(RandomNoise, KSamplerSelect, BasicScheduler avec denoise BAS) -> séparation AV
-> VAEDecode (vidéo) + décodage VAE audio.
Denoise : avec un shift vidéo de 12, le denoise utile est très bas (voir le
README du pack : environ 0.03 à 0.12). Commencer par 0.01 (test
d'identité), puis balayer.
"""
import torch
import torchaudio
import nodes
import comfy.model_management
import comfy.nested_tensor
import comfy.utils
import node_helpers
from comfy_api.latest import ComfyExtension, io
CANVAS_MULTIPLE = 32
FPS = 24
AUDIO_LATENT_FPS = 40
DEFAULT_AUDIO_SR = 32000
# --------------------------------------------------------------------------
# Géométrie temporelle de H3 (identique au nœud officiel nodes_minimax_h3.py)
# --------------------------------------------------------------------------
def snap_frame_count(n):
"""Plus grande longueur valide (17k+5) <= n."""
if n < 5:
raise ValueError(
"MiniMax H3 Refine : il faut au moins 5 frames dans la vidéo brouillon (%d reçues)." % n)
return ((n - 5) // 17) * 17 + 5
def video_latent_t(frame_count):
if frame_count <= 1:
return 1
if frame_count <= 5:
return 2
return ((frame_count - 5) // 17) * 5 + 2
def audio_latent_t(frame_count):
return round(frame_count / FPS * AUDIO_LATENT_FPS)
def _round32(v):
return max(CANVAS_MULTIPLE, round(v / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
def _resize(image, width, height, crop):
# image [B, H, W, C] -> [B, height, width, 3]
samples = image[..., :3].movedim(-1, 1)
samples = comfy.utils.common_upscale(samples, width, height, "lanczos", crop)
return samples.movedim(1, -1)
def _resample_fps(frames, source_fps):
"""Rééchantillonne (plus proche voisin) vers 24 fps si le brouillon a un autre fps."""
if source_fps <= 0 or abs(source_fps - FPS) < 0.01:
return frames
n_src = frames.shape[0]
n_dst = max(1, int(n_src / source_fps * FPS))
idx = torch.clamp((torch.arange(n_dst) * (source_fps / FPS)).long(), max=n_src - 1)
return frames[idx]
# --------------------------------------------------------------------------
# Audio
# --------------------------------------------------------------------------
def _prepare_waveform(audio, vae_sr, audio_t):
"""audio dict -> waveform [1, 2, audio_t * (vae_sr // 40)] au sample rate du VAE."""
wf = audio["waveform"][:1] # [1, C, L]
sr = audio["sample_rate"]
if sr != vae_sr:
wf = torchaudio.functional.resample(wf, sr, vae_sr)
if wf.shape[1] == 1:
wf = wf.repeat(1, 2, 1)
elif wf.shape[1] > 2:
wf = wf[:, :2]
target = audio_t * (vae_sr // AUDIO_LATENT_FPS)
length = wf.shape[-1]
if length >= target:
wf = wf[..., :target]
else:
wf = torch.nn.functional.pad(wf, (0, target - length))
return wf
def _encode_audio(audio_vae, wf, audio_t):
"""Encode en latent [1, 32, 2, audio_t].
La convention d'entrée de audio_vae.encode a varié pendant le développement
du support H3 ([B, 2, L] vs [B, L, 2]) : on essaie les deux et on valide la
forme de sortie.
"""
last_error = None
z = None
for candidate in (wf, wf.movedim(1, -1)):
try:
out = audio_vae.encode(candidate)
except Exception as e: # layout rejeté par cette version du VAE
last_error = e
continue
if out.ndim == 4 and out.shape[1] == 32 and out.shape[2] == 2:
z = out
break
if z is None:
raise RuntimeError(
"MiniMax H3 Refine : impossible d'encoder l'audio avec ce VAE audio "
"(attendu : latent [B, 32, 2, T]). Dernière erreur : %s" % last_error)
t = z.shape[-1]
if abs(t - audio_t) > 2:
print("[MiniMaxH3Refine] attention : latent audio de %d pas, attendu %d" % (t, audio_t))
if t > audio_t:
z = z[..., :audio_t]
elif t < audio_t:
z = torch.cat([z, z[..., -1:].expand(*z.shape[:-1], audio_t - t)], dim=-1)
return z[:1]
# --------------------------------------------------------------------------
# Nœud
# --------------------------------------------------------------------------
class MiniMaxH3RefineFL2VA(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="MiniMaxH3RefineFL2VA",
display_name="MiniMax H3 Refine (fl2va)",
category="model/conditioning/minimax",
description=(
"Encode une vidéo+audio brouillon en latent AV H3 et construit le conditioning "
"fl2va pour la raffiner avec un sampler à faible denoise (≈0.03-0.12)."),
inputs=[
io.Clip.Input("clip"),
io.Vae.Input("vae", tooltip="VAE vidéo H3"),
io.Vae.Input("audio_vae", tooltip="VAE audio H3"),
io.String.Input("prompt", multiline=True, dynamic_prompts=True,
tooltip="Même prompt (structuré) que celui du brouillon, soundscape inclus."),
io.Image.Input("images", tooltip="Frames de la vidéo brouillon (24 fps idéalement)."),
io.Audio.Input("audio", optional=True,
tooltip="Audio du brouillon. Absent = silence (l'audio sera alors à peu près perdu à bas denoise)."),
io.Float.Input("source_fps", default=24.0, min=1.0, max=120.0, step=0.01,
tooltip="FPS de la vidéo brouillon. Rééchantillonné à 24 fps si différent."),
io.Int.Input("width", default=0, min=0, max=nodes.MAX_RESOLUTION, step=32,
tooltip="0 = taille du brouillon arrondie au multiple de 32."),
io.Int.Input("height", default=0, min=0, max=nodes.MAX_RESOLUTION, step=32,
tooltip="0 = taille du brouillon arrondie au multiple de 32."),
io.Image.Input("first_frame", optional=True,
tooltip="Keyframe PROPRE (pas du brouillon) ancrée à la frame 0, réinjectée à chaque step."),
io.Image.Input("last_frame", optional=True,
tooltip="Keyframe PROPRE ancrée à la dernière frame."),
],
outputs=[
io.Conditioning.Output(display_name="positive"),
io.Latent.Output(),
io.Int.Output(display_name="frames"),
],
)
@classmethod
def execute(cls, clip, vae, audio_vae, prompt, images, audio=None, source_fps=24.0,
width=0, height=0, first_frame=None, last_frame=None) -> io.NodeOutput:
# ---- frames : fps -> 24, longueur 17k+5, taille multiple de 32 ----
frames = _resample_fps(images[..., :3], source_fps)
n = snap_frame_count(frames.shape[0])
frames = frames[:n]
w = width if width > 0 else _round32(frames.shape[2])
h = height if height > 0 else _round32(frames.shape[1])
if w % CANVAS_MULTIPLE or h % CANVAS_MULTIPLE:
raise ValueError("MiniMax H3 Refine : width et height doivent être des multiples de 32.")
frames = _resize(frames, w, h, "disabled")
# ---- latent vidéo ----
z_video = vae.encode(frames)[:1]
expected = (24, video_latent_t(n), h // 16, w // 16)
got = (z_video.shape[1], z_video.shape[2], z_video.shape[3], z_video.shape[4])
if got != expected:
raise RuntimeError(
"MiniMax H3 Refine : latent vidéo inattendu %s, attendu %s "
"(n=%d frames, %dx%d). Vérifie que c'est bien le VAE vidéo H3." % (got, expected, n, w, h))
# ---- latent audio ----
audio_t = audio_latent_t(n)
vae_sr = getattr(audio_vae, "audio_sample_rate", DEFAULT_AUDIO_SR)
if audio is None:
wf = torch.zeros([1, 2, audio_t * (vae_sr // AUDIO_LATENT_FPS)])
else:
wf = _prepare_waveform(audio, vae_sr, audio_t)
z_audio = _encode_audio(audio_vae, wf, audio_t)
device = comfy.model_management.intermediate_device()
latent = {"samples": comfy.nested_tensor.NestedTensor((
z_video.to(device=device, dtype=torch.float32),
z_audio.to(device=device, dtype=torch.float32),
))}
# ---- conditioning (même construction que MiniMaxH3ImageToVideo) ----
kf_images = []
keyframes = []
if first_frame is not None:
img = _resize(first_frame[:1], w, h, "disabled")
kf_images.append(img)
keyframes.append({"resolved_frame_index": 0, "image": img})
if last_frame is not None:
img = _resize(last_frame[:1], w, h, "center")
kf_images.append(img)
keyframes.append({"resolved_frame_index": n - 1, "image": img})
tokens = clip.tokenize(prompt, images=kf_images)
cond = clip.encode_from_tokens_scheduled(tokens)
if keyframes:
for kf in keyframes:
kf["latent"] = vae.encode(kf.pop("image"))
cond = node_helpers.conditioning_set_values(cond, {
"minimax_keyframes": keyframes,
"minimax_frame_count": n,
})
return io.NodeOutput(cond, latent, n)
class MiniMaxH3RefineExtension(ComfyExtension):
async def get_node_list(self):
return [MiniMaxH3RefineFL2VA]
async def comfy_entrypoint() -> MiniMaxH3RefineExtension:
return MiniMaxH3RefineExtension()
+7
View File
@@ -0,0 +1,7 @@
[project]
name = "comfyui-minimax-h3-refine"
version = "0.1.0"
description = "Raffinement vidéo+audio MiniMax H3 (fl2va) : encode un brouillon en latent AV et construit le conditioning pour un sampler à faible denoise."
readme = "README.md"
requires-python = ">=3.10"
dependencies = []