"""MiniMax H3 nodes: AV latent creation and task conditioning (t2va / fl2va / ref2va). The H3 packed-DiT consumes, via conditioning: - Qwen3-VL-32B hidden states with per-token modality tags (from the minimax CLIP) - keyframe / reference condition latents, re-injected every step (never denoised) Latents are NestedTensor pairs (video [B,24,T,H/16,W/16], audio [B,32,2,T40]); sampling runs on the flat pack with any stock sampler (the model handles the audio stream's shifted schedule internally). """ import math import torch import torchaudio import nodes import comfy.model_management import comfy.model_sampling import comfy.nested_tensor import comfy.utils import node_helpers from comfy_api.latest import ComfyExtension, io CANVAS_MULTIPLE = 32 BASE_SHORT_EDGE = 768 MAX_PIXELS = 768 * 1344 REF_IMAGE_SHORT_EDGE = 2048 FPS = 24 AUDIO_LATENT_FPS = 40 def align_frame_count(n): while n % 17 != 5: n += 1 return n def video_latent_t(frame_count): return 2 if frame_count <= 5 else ((frame_count - 5) // 17) * 5 + 2 def temporal_shape(length): frame_count = align_frame_count(max(5, length)) duration = frame_count / FPS return frame_count, video_latent_t(frame_count), round(duration * AUDIO_LATENT_FPS) def adapt_canvas(width, height): """768-short-edge canvas with 768*1344 area cap, per-axis round to 32.""" ratio = width / height if ratio >= 1.0: nom_w, nom_h = BASE_SHORT_EDGE * ratio, BASE_SHORT_EDGE else: nom_w, nom_h = BASE_SHORT_EDGE, BASE_SHORT_EDGE / ratio if nom_w * nom_h > MAX_PIXELS: s = math.sqrt(MAX_PIXELS / (nom_w * nom_h)) nom_w, nom_h = nom_w * s, nom_h * s return (max(CANVAS_MULTIPLE, round(nom_w / CANVAS_MULTIPLE) * CANVAS_MULTIPLE), max(CANVAS_MULTIPLE, round(nom_h / 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 _empty_av_latent(width, height, length, batch_size=1): frame_count, latent_t, audio_t = temporal_shape(length) video = torch.zeros([batch_size, 24, latent_t, height // 16, width // 16], device=comfy.model_management.intermediate_device()) audio = torch.zeros([batch_size, 32, 2, audio_t], device=comfy.model_management.intermediate_device()) return {"samples": comfy.nested_tensor.NestedTensor((video, audio))}, frame_count class EmptyMiniMaxH3LatentAV(io.ComfyNode): @classmethod def define_schema(cls): return io.Schema( node_id="EmptyMiniMaxH3LatentAV", display_name="Empty MiniMax H3 AV Latent", category="model/latent/minimax", description="Joint video+audio latent for MiniMax H3. Duration snaps to the model's 17k+5 frame grid at 24 fps.", inputs=[ io.Int.Input("width", default=1344, min=32, max=nodes.MAX_RESOLUTION, step=32), io.Int.Input("height", default=768, min=32, max=nodes.MAX_RESOLUTION, step=32), io.Int.Input("length", default=124, min=5, max=3600, step=17, tooltip="Frame count at 24 fps, snapped up to the model's 17k+5 grid (124 = ~5s; trained range is ~124-362, longer is untested)"), ], outputs=[io.Latent.Output()], ) @classmethod def execute(cls, width, height, length) -> io.NodeOutput: latent, _ = _empty_av_latent(width, height, length) return io.NodeOutput(latent) class MiniMaxH3ImageToVideo(io.ComfyNode): """t2va and fl2va: prompt (+ optional first/last keyframes) -> conditioning + AV latent.""" @classmethod def define_schema(cls): return io.Schema( node_id="MiniMaxH3ImageToVideo", display_name="MiniMax H3 Image to Video", category="model/conditioning/minimax", inputs=[ io.Clip.Input("clip"), io.Vae.Input("vae"), io.String.Input("prompt", multiline=True, dynamic_prompts=True), io.Int.Input("width", default=1344, min=32, max=nodes.MAX_RESOLUTION, step=32), io.Int.Input("height", default=768, min=32, max=nodes.MAX_RESOLUTION, step=32), io.Int.Input("length", default=124, min=5, max=3600, step=17, tooltip="Frame count at 24 fps, snapped up to the model's 17k+5 grid (124 = ~5s; trained range is ~124-362, longer is untested)"), io.Image.Input("first_frame", optional=True), io.Image.Input("last_frame", optional=True), ], outputs=[io.Conditioning.Output(display_name="positive"), io.Latent.Output()], ) @classmethod def execute(cls, clip, vae, prompt, width, height, length, first_frame=None, last_frame=None) -> io.NodeOutput: latent, frame_count = _empty_av_latent(width, height, length) images = [] keyframes = [] if first_frame is not None: # geometry anchor: plain stretch to canvas img = _resize(first_frame[:1], width, height, "disabled") images.append(img) keyframes.append({"resolved_frame_index": 0, "image": img}) if last_frame is not None: # follower: aspect-preserving cover-crop img = _resize(last_frame[:1], width, height, "center") images.append(img) keyframes.append({"resolved_frame_index": frame_count - 1, "image": img}) tokens = clip.tokenize(prompt, images=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": frame_count, }) return io.NodeOutput(cond, latent) class MiniMaxH3ReferenceToVideo(io.ComfyNode): """ref2va: prompt + reference images / videos / audio -> conditioning + AV latent. References enter the presentation in fixed order: images, then videos (each soundtrack's