252 lines
10 KiB
Python
252 lines
10 KiB
Python
"""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()
|