File size: 7,457 Bytes
e488ce7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | 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()
|