"""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.ldm.minimax.model import FRAME_PER_TOKEN, FRAME_RESCALE 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 _encode_ref_audio(audio_vae, audio): waveform = audio["waveform"] # [B, C, L] sr = audio["sample_rate"] vae_sr = getattr(audio_vae, "audio_sample_rate", 32000) if sr != vae_sr: waveform = torchaudio.functional.resample(waveform, sr, vae_sr) z = audio_vae.encode(waveform[:1].movedim(1, -1)) # [1, 32, 2, T] return z, z.shape[-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}) return io.NodeOutput(cond, latent) class MiniMaxH3AddGuide(io.ComfyNode): """Anchor image and/or audio guides at an arbitrary pixel frame of the target video.""" @classmethod def define_schema(cls): return io.Schema( node_id="MiniMaxH3AddGuide", display_name="Add Guide for MiniMax H3", category="model/conditioning/minimax", description="Anchor an image, a short clip, audio, or a clip with its soundtrack at any frame of a MiniMax H3 video. Chain several nodes to anchor several frames.", inputs=[ io.Conditioning.Input("positive"), io.Vae.Input("vae", optional=True, tooltip="Video VAE, needed when an image is connected."), io.Vae.Input("audio_vae", optional=True, tooltip="Audio VAE, needed when an audio is connected."), io.Latent.Input("latent"), io.Image.Input("image", optional=True, tooltip="Image or video frames to anchor. Multi-frame batches are anchored as a clip and cropped down to the model's valid clip lengths: 5, 22, 39... (17k + 5) frames. Batches shorter than 5 frames use only the first image."), io.Audio.Input("audio", optional=True, tooltip="Soundtrack to anchor starting at the same frame index, cropped to the video's remaining duration."), io.Int.Input("frame_idx", default=0, min=-9999, max=9999, tooltip="Frame index to anchor the image or the clip's first frame at. Negative values are counted from the end of the video."), ], outputs=[io.Conditioning.Output(display_name="positive")], ) @classmethod def execute(cls, positive, latent, frame_idx, vae=None, audio_vae=None, image=None, audio=None) -> io.NodeOutput: samples = latent["samples"] if not samples.is_nested or len(samples.tensors) != 2 or samples.tensors[0].ndim != 5 or samples.tensors[0].shape[1] != 24: raise ValueError("MiniMaxH3AddGuide expects a MiniMax H3 AV latent") if image is None and audio is None: raise ValueError("MiniMaxH3AddGuide needs an image or an audio to anchor") video = samples.tensors[0] height = video.shape[3] * 16 width = video.shape[4] * 16 frame_count = sum(FRAME_PER_TOKEN[k % 5] for k in range(video.shape[2])) guide_frames = 1 if image is not None: if vae is None: raise ValueError("anchoring guide frames needs the vae input") guide_frames = image.shape[0] if guide_frames < 5: guide_frames = 1 else: while guide_frames % 17 != 5: guide_frames -= 1 resolved_frame_index = frame_idx if frame_idx >= 0 else frame_count + frame_idx if resolved_frame_index < 0 or resolved_frame_index + guide_frames > frame_count: if guide_frames == 1: raise ValueError("frame_idx {} is outside the video's {} frames".format(frame_idx, frame_count)) raise ValueError("a {} frame guide clip at frame_idx {} does not fit in the video's {} frames".format( guide_frames, frame_idx, frame_count)) keyframe = {"resolved_frame_index": resolved_frame_index} if image is not None: frames = _resize(image[:guide_frames], width, height, "center") keyframe["latent"] = vae.encode(frames) if audio is not None: if audio_vae is None: raise ValueError("anchoring guide audio needs the audio_vae input") audio_latent, audio_rt = _encode_ref_audio(audio_vae, audio) # the streams share one time axis: FRAME_RESCALE per pixel frame, 1.0 per audio latent frame max_rt = math.floor(samples.tensors[1].shape[-1] - FRAME_RESCALE * resolved_frame_index) if max_rt < 1: raise ValueError("frame_idx {} is past the end of the video's audio track".format(frame_idx)) if audio_rt > max_rt: audio_latent = audio_latent[..., :max_rt].clone() keyframe["audio_latent"] = audio_latent keyframes = list(positive[0][1].get("minimax_keyframes", [])) keyframes.append(keyframe) positive = node_helpers.conditioning_set_values(positive, {"minimax_keyframes": keyframes}) return io.NodeOutput(positive) 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