commit bbb405f2793d448318d872c8e29aebf89ff98c39 Author: Jordan Caprasse Date: Tue Nov 3 10:26:00 2026 +0100 Initial commit: MiniMax H3 Refine (fl2va) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b908d4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b18784 --- /dev/null +++ b/README.md @@ -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`. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..18849d1 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/minimax_h3_refine.py b/minimax_h3_refine.py new file mode 100644 index 0000000..8dd239e --- /dev/null +++ b/minimax_h3_refine.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a7b77a3 --- /dev/null +++ b/pyproject.toml @@ -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 = []