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()