dzmdd's picture
Upload folder using huggingface_hub
e488ce7 verified
Raw History Blame Contribute Delete
7.46 kB
r"""ComfyUI-H3-ContactSheet — five coordinated views from one reference image.
Contact-Sheet diffusion for MiniMax-H3: five standalone image latents packed
on the model's time axis, jointly denoised, independently decoded. Pair with
a five-view LoRA from https://huggingface.co/matlod/minimax-h3-turnaround
loaded through the STOCK LoraLoaderModelOnly (keys verified compatible).
Graph shape (all other nodes stock):
UNETLoader -> LoraLoaderModelOnly -> BasicGuider ----------------\
CLIPLoader ----------\ v
VAELoader (video) --> H3ContactSheet --(cond, latent)--> SamplerCustomAdvanced
|
VAELoader (video) --> H3ContactSheetDecode <---- (denoised) ------/
-> IMAGE batch of 5 + one strip
The denoise itself is any stock sampler; recommended res_multistep/simple,
28 steps, denoise 1.0. Works at 512/1024/2048 per view.
Tested against ComfyUI master a464ac33 and v0.31.1. The layout/payload
interface this rides (minimax_refs conditioning + latent-shape-driven
packing) is internal to comfy_extras/nodes_minimax_h3.py and may drift;
pin your ComfyUI if the sheet quality changes after an update.
Drag-in graph: examples/contact_sheet.json (API twin alongside). Point the
LoadImage at your reference, set the LoRA file you downloaded, queue.
"""
import math
import torch
import comfy.model_management
import comfy.nested_tensor
import comfy.utils
import node_helpers
from comfy_api.latest import ComfyExtension, io
CANVAS_MULTIPLE = 32
SHEET_SLOTS = 5
# 17-frame bookkeeping at 40 Hz audio latents: silent audio rides along
SHEET_AUDIO_LATENTS = 28
def _resize(image, width, height):
samples = image[..., :3].movedim(-1, 1)
samples = comfy.utils.common_upscale(samples, width, height, "lanczos", "disabled")
return samples.movedim(1, -1)
class H3ContactSheet(io.ComfyNode):
"""Prompt (+ optional reference image) -> conditioning + five-slot AV latent."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="H3ContactSheet",
display_name="H3 Contact Sheet (five views)",
category="model/conditioning/minimax",
description="Five coordinated views of one subject. With a reference "
"image it rides as <Picture 1> — keep that tag in the "
"prompt. Leave ref_image unconnected for text-only sheets "
"(drop the tag). Needs a contact-sheet LoRA on the model.",
inputs=[
io.Clip.Input("clip"),
io.Vae.Input("vae"),
io.String.Input("prompt", multiline=True, dynamic_prompts=True,
default="the camera orbits the subject of <Picture 1> "
"ninety degrees clockwise"),
io.Image.Input("ref_image", optional=True,
tooltip="Optional. Unconnected = text-to-sheet: no "
"reference rows, prompt only."),
io.Int.Input("size", default=1024, min=512, max=2048, step=CANVAS_MULTIPLE,
tooltip="Square size of each generated view. 512 = LoRA "
"training size (fastest), 1024 = sweet spot, 2048 "
"works (rotation arrives in the later views)."),
],
outputs=[io.Conditioning.Output(display_name="positive"), io.Latent.Output()],
)
@classmethod
def execute(cls, clip, vae, prompt, size, ref_image=None) -> io.NodeOutput:
if ref_image is None:
# text-to-sheet: same five-slot latent, plain text conditioning,
# no reference rows in the packed sequence
tokens = clip.tokenize(prompt)
cond = clip.encode_from_tokens_scheduled(tokens)
else:
# aspect-preserving down-only scale of the ref to the generation's
# pixel area, /32 snap — identical to the ref2va "match" policy and
# the training-time match-resize
h, w = ref_image.shape[1], ref_image.shape[2]
scale = min(1.0, math.sqrt((size * size) / (w * h)))
tw = max(CANVAS_MULTIPLE, round(w * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
th = max(CANVAS_MULTIPLE, round(h * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
resized = _resize(ref_image[:1], tw, th)
z = vae.encode(resized)
tokens = clip.tokenize(prompt, minimax_ref_items=[{"type": "image", "data": resized}])
cond = clip.encode_from_tokens_scheduled(tokens)
cond = node_helpers.conditioning_set_values(cond, {
"minimax_refs": [{"kind": "image", "latent_h": th // 16,
"latent_w": tw // 16, "latent": z}],
})
device = comfy.model_management.intermediate_device()
video = torch.zeros([1, 24, SHEET_SLOTS, size // 16, size // 16], device=device)
audio = torch.zeros([1, 32, 2, SHEET_AUDIO_LATENTS], device=device)
latent = {"samples": comfy.nested_tensor.NestedTensor((video, audio))}
return io.NodeOutput(cond, latent)
class H3ContactSheetDecode(io.ComfyNode):
"""Denoised five-slot latent -> five independently-decoded views + strip.
Slots MUST be decoded one at a time (each is a standalone one-frame
image); a whole-latent video decode smears them together.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="H3ContactSheetDecode",
display_name="H3 Contact Sheet Decode",
category="latent/minimax",
inputs=[
io.Vae.Input("vae"),
io.Latent.Input("samples"),
],
outputs=[
io.Image.Output(display_name="views"),
io.Image.Output(display_name="sheet"),
],
)
@classmethod
def execute(cls, vae, samples) -> io.NodeOutput:
latent = samples["samples"]
video = latent.unbind()[0] if hasattr(latent, "unbind") else latent
if video.ndim == 4: # plain image latent (e.g. from stock VAEEncode)
video = video.unsqueeze(2)
views = []
for s in range(video.shape[2]):
# a single latent frame is OFF the VAE's 17k+5 grid (minimum legal
# clip = 2 latent tokens = 5 px frames) and decodes with heavy
# artifacts. Duplicate the slot token to a legal 2-token clip and
# keep pixel frame 0 — measured cleaner than any T=1 path.
slot = video[:, :, s:s + 1]
frames = vae.decode(torch.cat([slot, slot], dim=2)) # [1, T, H, W, C]
if frames.ndim == 5:
frames = frames[:, 0]
elif frames.shape[0] > 1: # some paths return frames on batch dim
frames = frames[:1]
views.append(frames[0])
batch = torch.stack(views) # [5, H, W, C]
sheet = torch.cat(list(batch), dim=1)[None] # [1, H, 5W, C]
return io.NodeOutput(batch, sheet)
class H3ContactSheetExtension(ComfyExtension):
async def get_node_list(self):
return [H3ContactSheet, H3ContactSheetDecode]
def comfy_entrypoint():
return H3ContactSheetExtension()