Files
Maxed-Out-99-ComfyUI-MaxedOut/maxedoutnodes.py
T
Maxed-Out-99 ac82ed7bea Add LTX nodes, VACE 2.2 support & UI tweaks
Add a new ltxnodes module implementing LTX video latent nodes, samplers, and a taeltx-based video previewer (auto-download/load). Update package init to expose ltxnodes and adjust loraloader model list sorting for loras. Extend maxedoutnodes with a BBOX detector batch node and an Image+Mask preview node plus color parsing/helpers. Add VACE 2.2 latent save/load support and related helpers in wan22nodes (trim_latent sidecar, loaders), and enhance WAN 2.2 scaler logic to support 1024×1024 square buckets, aspect_mode options, and auto-square behavior. Add/modify several web UI assets and minor project housekeeping (.gitignore local_notes).
2026-06-24 14:04:07 -07:00

1761 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import torch, math, comfy, os, folder_paths, node_helpers, comfy.model_management, comfy.utils, json, hashlib, re, random
import torch.nn.functional as F
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
import numpy as np
from PIL import Image, ImageOps, ImageSequence, ImageFilter, ImageColor
from nodes import SaveImage
try:
from comfy_api.latest import io
HAVE_COMFY_API = True
except Exception as _e:
io = None
HAVE_COMFY_API = False
print(f"[ComfyUI-MaxedOut] comfy_api not available in maxedoutnodes: {_e}")
########################################################################################################################
# Flux Empty Latent Image (SD3-compatible)
class FluxEmptyLatentImage:
DESCRIPTION = """Select a Flux resolution and create an empty latent batch."""
TITLE = "Flux Empty Latent Image"
CATEGORY = "MXD/Latent"
RESOLUTIONS = {
"— High Resolutions —": None,
"Square (1:1) 1408x1408": (1408, 1408),
"Standard (4:3) 1664x1216": (1664, 1216),
"Landscape (3:2) 1728x1152": (1728, 1152),
"Widescreen (16:9) 1920x1088": (1920, 1088),
"Ultrawide (21:9) 2176x960": (2176, 960),
"— Standard Resolutions —": None,
"Square (1:1) 1024x1024": (1024, 1024),
"Standard (4:3) 1152x896": (1152, 896),
"Landscape (3:2) 1216x832": (1216, 832),
"Widescreen (16:9) 1344x768": (1344, 768),
"Ultrawide (21:9) 1536x640": (1536, 640),
"— Low Resolutions —": None,
"Square (1:1) 320x320": (320, 320),
"Standard (4:3) 448x320": (448, 320),
"Landscape (3:2) 384x256": (384, 256),
"Widescreen (16:9) 448x256": (448, 256),
"Ultrawide (21:9) 576x256": (576, 256),
}
def __init__(self):
self.device = comfy.model_management.intermediate_device()
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"resolution": (
list(cls.RESOLUTIONS.keys()),
{"default": "Square (1:1) 1024x1024"}
),
"vertical": ("BOOLEAN", {"default": False}),
"batch_size": (
"INT",
{
"default": 1,
"min": 1,
"max": 4096,
"tooltip": "The number of latent images in the batch."
}
)
}
}
RETURN_TYPES = ("LATENT",)
OUTPUT_TOOLTIPS = ("The empty latent image batch.",)
FUNCTION = "generate"
def generate(self, resolution, vertical, batch_size=1) -> tuple:
size = self.RESOLUTIONS.get(resolution)
if size is None:
raise ValueError(f"'{resolution}' is a header or invalid option.")
width, height = size
if vertical:
width, height = height, width
latent = torch.zeros([batch_size, 16, height // 8, width // 8], device=self.device)
return ({"samples": latent},)
########################################################################################################################
# Flux 2 Empty Latent Image (Flux2-compatible)
class Flux2EmptyLatentImage:
DESCRIPTION = """Select a Flux resolution and create an empty Flux 2 latent batch."""
TITLE = "Flux 2 Empty Latent Image"
CATEGORY = "MXD/Latent"
RESOLUTIONS = FluxEmptyLatentImage.RESOLUTIONS
def __init__(self):
self.device = comfy.model_management.intermediate_device()
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"resolution": (
list(cls.RESOLUTIONS.keys()),
{"default": "Square (1:1) 1024x1024"}
),
"vertical": ("BOOLEAN", {"default": False}),
"batch_size": (
"INT",
{
"default": 1,
"min": 1,
"max": 4096,
"tooltip": "The number of latent images in the batch."
}
)
}
}
RETURN_TYPES = ("LATENT",)
OUTPUT_TOOLTIPS = ("The empty Flux 2 latent image batch.",)
FUNCTION = "generate"
def generate(self, resolution, vertical, batch_size=1) -> tuple:
size = self.RESOLUTIONS.get(resolution)
if size is None:
raise ValueError(f"'{resolution}' is a header or invalid option.")
width, height = size
if vertical:
width, height = height, width
latent = torch.zeros([batch_size, 128, height // 16, width // 16], device=self.device)
return ({"samples": latent},)
########################################################################################################################
# Flux Resolution Selector (for feeding into FluxEmptyLatentImage)
class FluxResolutionSelector:
DESCRIPTION = """Pick a Flux resolution string for Flux Empty Latent Image."""
TITLE = "Flux Resolution Selector"
CATEGORY = "MXD/Latent"
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"resolution": (
list(FluxEmptyLatentImage.RESOLUTIONS.keys()), # Include ALL keys including headers
{"default": "Square (1:1) 1024x1024"}
),
}
}
RETURN_TYPES = (list(FluxEmptyLatentImage.RESOLUTIONS.keys()),)
RETURN_NAMES = ("resolution",)
OUTPUT_TOOLTIPS = ("The selected resolution string for FluxEmptyLatentImage.",)
FUNCTION = "select_resolution"
def select_resolution(self, resolution) -> tuple:
return (resolution,)
########################################################################################################################
# Sdxl Empty Latent Image
class SdxlEmptyLatentImage:
DESCRIPTION = """Select an SDXL resolution and create an empty latent batch."""
TITLE = "Sdxl Empty Latent Image (With Resolutions)"
CATEGORY = "MXD/Latent"
# SDXL predefined resolutions (width, height)
RESOLUTIONS = {
"Square (1:1) 1024x1024": (1024, 1024),
"Standard (4:3) 1152x896": (1152, 896),
"Landscape (3:2) 1216x832": (1216, 832),
"Widescreen (16:9) 1344x768": (1344, 768),
"Ultra-Wide (21:9) 1536x640": (1536, 640),
}
def __init__(self):
# Retrieve the intermediate device (usually the GPU) from ComfyUI's model management.
self.device = comfy.model_management.intermediate_device()
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
# Dropdown selection for one of the predefined SDXL resolutions.
"resolution": (list(cls.RESOLUTIONS.keys()),),
# Toggle for vertical mode (swaps width and height).
"vertical": ("BOOLEAN", {"default": False}),
# Number of latent images to create in the batch.
"batch_size": (
"INT",
{
"default": 1,
"min": 1,
"max": 4096,
"tooltip": "The number of latent images in the batch."
}
)
}
}
RETURN_TYPES = ("LATENT",)
OUTPUT_TOOLTIPS = ("The empty latent image batch.",)
FUNCTION = "generate"
def generate(self, resolution, vertical, batch_size=1) -> tuple:
# Get the selected resolution tuple (width, height)
width, height = self.RESOLUTIONS[resolution]
# If vertical mode is enabled, swap width and height.
if vertical:
width, height = height, width
# Create an empty latent tensor.
# Typically, the latent space has 4 channels and each spatial dimension is 1/8th of the image.
latent = torch.zeros([batch_size, 4, height // 8, width // 8], device=self.device)
return ({"samples": latent},)
########################################################################################################################
# Z-Image Turbo Empty Latent Image (SD3-compatible) — Flux-style grouping
class ZImageTurboEmptyLatentImage:
DESCRIPTION = """Select a Z-Image Turbo resolution and create an empty latent batch."""
TITLE = "Z-Image Turbo Empty Latent Image"
CATEGORY = "MXD/Latent"
# Tuned for Z-Image Turbo:
# - Rule of 64: every dimension is a multiple of 64
# - 1MP baseline: 1024x1024 in the standard tier
# - Ceiling: keep presets below 6.5MP
MAX_TOTAL_PIXELS = 6_500_000
MIN_BLOCK = 64
RESOLUTIONS = {
"— High Resolutions —": None,
"Square (1:1) 1536x1536": (1536, 1536),
"Photo (4:3) 1792x1344": (1792, 1344),
"Landscape (3:2) 1920x1280": (1920, 1280),
"Widescreen (16:9) 2048x1152": (2048, 1152),
"Ultrawide (21:9) 2304x1024": (2304, 1024),
"— Standard Resolutions —": None,
"Square (1:1) 1024x1024": (1024, 1024),
"Photo (4:3) 1152x896": (1152, 896),
"Landscape (3:2) 1280x832": (1280, 832),
"Widescreen (16:9) 1344x768": (1344, 768),
"Ultrawide (21:9) 1536x640": (1536, 640),
"— Low Resolutions —": None,
"Square (1:1) 512x512": (512, 512),
"Photo (4:3) 576x448": (576, 448),
"Landscape (3:2) 640x448": (640, 448),
"Widescreen (16:9) 704x384": (704, 384),
"Ultrawide (21:9) 768x320": (768, 320),
}
def __init__(self):
self.device = comfy.model_management.intermediate_device()
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"resolution": (
list(cls.RESOLUTIONS.keys()),
{"default": "Square (1:1) 1024x1024"}
),
"vertical": (
"BOOLEAN",
{"default": False, "tooltip": "Swap width and height."}
),
"batch_size": (
"INT",
{"default": 1, "min": 1, "max": 4096, "tooltip": "Number of latent images in the batch."}
)
}
}
RETURN_TYPES = ("LATENT",)
OUTPUT_TOOLTIPS = ("The empty Z-Image Turbo latent batch.",)
FUNCTION = "generate"
def generate(self, resolution, vertical, batch_size=1) -> tuple:
size = self.RESOLUTIONS.get(resolution)
if size is None:
raise ValueError(f"'{resolution}' is a header or invalid option.")
width, height = size
if vertical:
width, height = height, width
if (width % self.MIN_BLOCK) != 0 or (height % self.MIN_BLOCK) != 0:
raise ValueError(
f"Invalid preset {width}x{height}. Z-Image Turbo requires multiples of {self.MIN_BLOCK}."
)
if (width * height) > self.MAX_TOTAL_PIXELS:
raise ValueError(
f"Invalid preset {width}x{height}. Z-Image Turbo presets must stay at or below {self.MAX_TOTAL_PIXELS:,} pixels."
)
latent = torch.zeros([batch_size, 16, height // 8, width // 8], device=self.device)
return ({"samples": latent},)
########################################################################################################################
# Image Scale To Total Pixels (SDXL Safe)
class SDXLImageScaleToTotalPixelsSafe:
DESCRIPTION = """Scale to a target megapixel count and keep aspect ratio. Skips SDXL-safe sizes."""
upscale_methods = ["bilinear", "bicubic", "lanczos", "nearest-exact", "area"]
# SDXL-safe resolutions (width, height) – store one orientation only,
# the code will check both (w, h) and (h, w)
SDXL_SAFE_RESOLUTIONS = [
(1024, 1024),
(1152, 896),
(1216, 832),
(1344, 768),
(1536, 640),
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"upscale_method": (cls.upscale_methods, {"default": "bilinear"}),
"total_megapixels": (
"FLOAT",
{
"default": 1.0,
"min": 0.01,
"max": 128.0,
"step": 0.01,
"tooltip": "Set the total megapixels (e.g., 1.0 = 1 MP)",
},
),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "upscale"
CATEGORY = "MXD/Upscaling"
def upscale(self, image, upscale_method, total_megapixels):
if upscale_method in ["nearest-exact", "area"]:
raise Exception(
f"❌ '{upscale_method}' gives poor results.\n\n"
f"👉 Go to the Scale SDXL Image MXD node and switch to another like 'lanczos'.\n\n"
f"Node may be hidden behind KSampler."
)
b, h, w, c = image.shape
# Skip scaling if the image already matches an SDXL-safe resolution
if (w, h) in self.SDXL_SAFE_RESOLUTIONS or (h, w) in self.SDXL_SAFE_RESOLUTIONS:
return (image,)
# ComfyUI-native megapixel math
samples = image.movedim(-1, 1)
orig_h, orig_w = samples.shape[2], samples.shape[3]
target_pixels = int(round(total_megapixels * 1024 * 1024))
scale_by = math.sqrt(target_pixels / (orig_w * orig_h))
new_w = max(1, round(orig_w * scale_by))
new_h = max(1, round(orig_h * scale_by))
scaled = comfy.utils.common_upscale(samples, new_w, new_h, upscale_method, "disabled")
scaled = scaled.movedim(1, -1)
return (scaled,)
########################################################################################################################
# Flux Image Scale To Total Pixels (Flux Safe)
class FluxImageScaleToTotalPixelsSafe:
DESCRIPTION = """Scale to a target megapixel count and keep aspect ratio. Skips Flux-safe sizes."""
upscale_methods = ["bilinear", "bicubic", "lanczos", "nearest-exact", "area"]
# Flux-safe resolutions (width, height) – stored in one orientation only
FLUX_SAFE_RESOLUTIONS = [
(1408, 1408),
(1728, 1152),
(1664, 1216),
(1920, 1088),
(2176, 960),
(1024, 1024),
(1216, 832),
(1152, 896),
(1344, 768),
(1536, 640),
(320, 320),
(384, 256),
(448, 320),
(448, 256),
(576, 256),
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"upscale_method": (cls.upscale_methods, {"default": "bilinear"}),
"total_megapixels": (
"FLOAT",
{
"default": 1.0,
"min": 0.01,
"max": 128.0,
"step": 0.01,
"tooltip": "Set the total megapixels (e.g., 1.0 = 1 MP)",
},
),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "upscale"
CATEGORY = "MXD/Upscaling"
def upscale(self, image, upscale_method, total_megapixels):
if upscale_method in ["nearest-exact", "area"]:
raise Exception(
f"❌ '{upscale_method}' gives poor results.\n\n"
f"👉 Go to the Scale Flux Image MXD node and switch to another like 'lanczos'.\n\n"
f"Node may be hidden behind KSampler."
)
b, h, w, c = image.shape
# Skip scaling if image matches any Flux-safe resolution
if (w, h) in self.FLUX_SAFE_RESOLUTIONS or (h, w) in self.FLUX_SAFE_RESOLUTIONS:
return (image,)
samples = image.movedim(-1, 1)
orig_h, orig_w = samples.shape[2], samples.shape[3]
target_pixels = int(round(total_megapixels * 1024 * 1024))
scale_by = math.sqrt(target_pixels / (orig_w * orig_h))
new_w = max(1, round(orig_w * scale_by))
new_h = max(1, round(orig_h * scale_by))
scaled = comfy.utils.common_upscale(samples, new_w, new_h, upscale_method, "disabled")
scaled = scaled.movedim(1, -1)
return (scaled,)
########################################################################################################################
# Prompt with Guidance (Flux)
class PromptWithGuidance(ComfyNodeABC):
DESCRIPTION = """Encode text and apply Flux guidance in one node."""
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
return {
"required": {
"text": (IO.STRING, {"multiline": True, "dynamicPrompts": True}),
"clip": (IO.CLIP, {"tooltip": "The CLIP model used for encoding the text."}),
"guidance": ("FLOAT", {"default": 3.5, "min": 0.0, "max": 100.0, "step": 0.1})
}
}
RETURN_TYPES = (IO.CONDITIONING,)
FUNCTION = "encode_and_guide"
CATEGORY = "MXD/conditioning"
def encode_and_guide(self, text, clip, guidance):
if clip is None:
raise RuntimeError("CLIP model is None. Your checkpoint may not contain a text encoder.")
tokens = clip.tokenize(text)
conditioning = clip.encode_from_tokens_scheduled(tokens)
conditioning = node_helpers.conditioning_set_values(conditioning, {"guidance": guidance})
return (conditioning,)
########################################################################################################################
if HAVE_COMFY_API:
class QwenImageEditSingleMXD(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="QwenImageEditSingleMXD",
display_name="Qwen Image Edit + Latent MXD",
category="MXD/conditioning",
description="Encode prompt/image and output a matching empty latent.",
inputs=[
io.Clip.Input("clip"),
io.String.Input("prompt", multiline=True, dynamic_prompts=True),
io.Vae.Input("vae", optional=True),
io.Image.Input("image", optional=True),
io.Int.Input("batch_size", default=1, min=1, max=4096),
],
outputs=[
io.Conditioning.Output(),
io.Latent.Output(), # New Output
],
)
@classmethod
def execute(cls, clip, prompt, vae=None, image=None, batch_size=1) -> io.NodeOutput:
ref_latents = []
images_vl = []
llama_template = "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n"
image_prompt = ""
# Default fallback size if no image is provided (1024x1024)
final_width, final_height = 1024, 1024
if image is not None:
samples = image.movedim(-1, 1)
# --- VISION SCALING (384px area) ---
total_vl = int(384 * 384)
scale_vl = math.sqrt(total_vl / (samples.shape[3] * samples.shape[2]))
width_vl = round(samples.shape[3] * scale_vl)
height_vl = round(samples.shape[2] * scale_vl)
s_vl = comfy.utils.common_upscale(samples, width_vl, height_vl, "area", "disabled")
images_vl.append(s_vl.movedim(1, -1))
# --- LATENT/VAE SCALING (1024px area) ---
total_lat = int(1024 * 1024)
scale_lat = math.sqrt(total_lat / (samples.shape[3] * samples.shape[2]))
# Calculate final dimensions to be multiples of 8
final_width = round(samples.shape[3] * scale_lat / 8.0) * 8
final_height = round(samples.shape[2] * scale_lat / 8.0) * 8
if vae is not None:
s_lat = comfy.utils.common_upscale(samples, final_width, final_height, "area", "disabled")
ref_latents.append(vae.encode(s_lat.movedim(1, -1)[:, :, :, :3]))
image_prompt += "Picture 1: <|vision_start|><|image_pad|><|vision_end|>"
# 1. Generate the Empty Latent (SD3 Style: 16 channels, 1/8th resolution)
# This replaces the need for the separate EmptySD3LatentImage node
latent_tensor = torch.zeros(
[batch_size, 16, final_height // 8, final_width // 8],
device=comfy.model_management.intermediate_device()
)
latent_output = {"samples": latent_tensor}
# 2. Process Conditioning
tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template)
conditioning = clip.encode_from_tokens_scheduled(tokens)
if len(ref_latents) > 0:
conditioning = node_helpers.conditioning_set_values(
conditioning,
{"reference_latents": ref_latents},
append=True,
)
return io.NodeOutput(conditioning, latent_output)
########################################################################################################################
class QwenImageEditTripleMXD(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="QwenImageEditTripleMXD",
display_name="Qwen Image Edit Prompt MXD (Triple)",
category="advanced/conditioning",
inputs=[
io.Clip.Input("clip"),
io.String.Input("prompt", multiline=True, dynamic_prompts=True),
io.Vae.Input("vae", optional=True),
io.Image.Input("image1", optional=True),
io.Image.Input("image2", optional=True),
io.Image.Input("image3", optional=True),
io.Int.Input("batch_size", default=1, min=1, max=4096),
],
outputs=[
io.Conditioning.Output(),
io.Latent.Output(),
],
)
@classmethod
def execute(cls, clip, prompt, vae=None, image1=None, image2=None, image3=None, batch_size=1) -> io.NodeOutput:
ref_latents = []
images = [image1, image2, image3]
images_vl = []
llama_template = "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n"
image_prompt = ""
# Default fallback
latent_width = 1024
latent_height = 1024
for i, image in enumerate(images):
if image is not None:
samples = image.movedim(-1, 1)
# 1. VL Model Scaling (LLM Vision)
total_vl = int(384 * 384)
scale_by_vl = math.sqrt(total_vl / (samples.shape[3] * samples.shape[2]))
width_vl = round(samples.shape[3] * scale_by_vl)
height_vl = round(samples.shape[2] * scale_by_vl)
s_vl = comfy.utils.common_upscale(samples, width_vl, height_vl, "area", "disabled")
images_vl.append(s_vl.movedim(1, -1))
# 2. VAE Scaling (Synchronized to 16-step for SD3 compatibility)
if vae is not None:
total_ref = int(1024 * 1024)
scale_by_ref = math.sqrt(total_ref / (samples.shape[3] * samples.shape[2]))
# Pixels as multiple of 16 ensures Latent (Pixels/8) is always even
width_ref = round(samples.shape[3] * scale_by_ref / 16.0) * 16
height_ref = round(samples.shape[2] * scale_by_ref / 16.0) * 16
if i == 0:
latent_width = width_ref
latent_height = height_ref
s_ref = comfy.utils.common_upscale(samples, width_ref, height_ref, "area", "disabled")
ref_latents.append(vae.encode(s_ref.movedim(1, -1)[:, :, :, :3]))
image_prompt += "Picture {}: <|vision_start|><|image_pad|><|vision_end|>".format(i + 1)
# Process tokens and conditioning
tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template)
conditioning = clip.encode_from_tokens_scheduled(tokens)
if len(ref_latents) > 0:
conditioning = node_helpers.conditioning_set_values(conditioning, {"reference_latents": ref_latents}, append=True)
# Create Output Latent
latent = torch.zeros([batch_size, 16, latent_height // 8, latent_width // 8], device=comfy.model_management.intermediate_device())
# FIXED: Return outputs positionally to match the schema defined above
# Output 1: Conditioning, Output 2: Latent Dictionary
return io.NodeOutput(conditioning, {"samples": latent})
########################################################################################################################
class FluxResolutionMatcher:
DESCRIPTION = """Match the closest Flux resolution and orientation for the input image."""
CATEGORY = "MXD/Latent"
FUNCTION = "match_resolution"
RETURN_NAMES = ("resolution", "vertical")
# Full set kept for compatibility (enum list must match FluxEmptyLatentImage)
RESOLUTIONS = {
"— High Resolutions —": None,
"Square (1:1) 1408x1408": (1408, 1408),
"Standard (4:3) 1664x1216": (1664, 1216),
"Landscape (3:2) 1728x1152": (1728, 1152),
"Widescreen (16:9) 1920x1088": (1920, 1088),
"Ultrawide (21:9) 2176x960": (2176, 960),
"— Standard Resolutions —": None,
"Square (1:1) 1024x1024": (1024, 1024),
"Standard (4:3) 1152x896": (1152, 896),
"Landscape (3:2) 1216x832": (1216, 832),
"Widescreen (16:9) 1344x768": (1344, 768),
"Ultrawide (21:9) 1536x640": (1536, 640),
"— Low Resolutions —": None,
"Square (1:1) 320x320": (320, 320),
"Standard (4:3) 448x320": (448, 320),
"Landscape (3:2) 384x256": (384, 256),
"Widescreen (16:9) 448x256": (448, 256),
"Ultrawide (21:9) 576x256": (576, 256),
}
# Keep same enum type so it connects to FluxEmptyLatentImage
RETURN_TYPES = (list(RESOLUTIONS.keys()), "BOOLEAN")
# Precompute aspect ratio groups (only for standard resolutions)
ASPECT_RATIO_GROUPS = {}
for res_str, dims in RESOLUTIONS.items():
if dims is None:
continue
# ✅ Skip high and low groups for logic
if "High" in res_str or "Low" in res_str:
continue
group_name = " ".join(res_str.split(' ')[:-1])
if group_name not in ASPECT_RATIO_GROUPS:
w, h = dims
ratio = w / h
ASPECT_RATIO_GROUPS[group_name] = {'ratio': ratio, 'resolutions': []}
ASPECT_RATIO_GROUPS[group_name]['resolutions'].append(res_str)
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": ("IMAGE",)}}
def match_resolution(self, image: torch.Tensor):
if image.dim() < 4 or image.shape[1] < 1 or image.shape[2] < 1:
print("Warning: Invalid image tensor received. Falling back to default resolution.")
return ("Square (1:1) 1024x1024", False)
_batch, height, width, _channels = image.shape
is_vertical = height > width
img_aspect_ratio = (height / width) if is_vertical else (width / height)
img_area = height * width
best_ar_group_name = min(
self.ASPECT_RATIO_GROUPS.keys(),
key=lambda name: abs(img_aspect_ratio - self.ASPECT_RATIO_GROUPS[name]['ratio'])
)
candidate_res_strings = self.ASPECT_RATIO_GROUPS[best_ar_group_name]['resolutions']
best_res_string = min(
candidate_res_strings,
key=lambda res_str: abs(img_area - (self.RESOLUTIONS[res_str][0] * self.RESOLUTIONS[res_str][1]))
)
return (best_res_string, is_vertical)
########################################################################################################################
class SDXLResolutionMatcher:
DESCRIPTION = """Match the closest SDXL resolution and orientation for the input image."""
CATEGORY = "MXD/Latent"
FUNCTION = "match_resolution"
RETURN_NAMES = ("resolution", "vertical")
# Use the exact same enum list as SdxlEmptyLatentImage
RESOLUTIONS = SdxlEmptyLatentImage.RESOLUTIONS
RETURN_TYPES = (list(RESOLUTIONS.keys()), "BOOLEAN")
ASPECT_RATIO_GROUPS = {}
for res_str, dims in RESOLUTIONS.items():
if dims is None:
continue
group_name = " ".join(res_str.split(" ")[:-1])
if group_name not in ASPECT_RATIO_GROUPS:
w, h = dims
ratio = w / h
ASPECT_RATIO_GROUPS[group_name] = {"ratio": ratio, "resolutions": []}
ASPECT_RATIO_GROUPS[group_name]["resolutions"].append(res_str)
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": ("IMAGE",)}}
def match_resolution(self, image: torch.Tensor):
if image.dim() < 4 or image.shape[1] < 1 or image.shape[2] < 1:
print("Warning: Invalid image tensor received. Falling back to default resolution.")
return ("Square (1:1) 1024x1024", False)
_batch, height, width, _channels = image.shape
is_vertical = height > width
img_aspect_ratio = (height / width) if is_vertical else (width / height)
img_area = height * width
best_ar_group_name = min(
self.ASPECT_RATIO_GROUPS.keys(),
key=lambda name: abs(img_aspect_ratio - self.ASPECT_RATIO_GROUPS[name]["ratio"])
)
candidate_res_strings = self.ASPECT_RATIO_GROUPS[best_ar_group_name]["resolutions"]
best_res_string = min(
candidate_res_strings,
key=lambda res_str: abs(img_area - (self.RESOLUTIONS[res_str][0] * self.RESOLUTIONS[res_str][1]))
)
return (best_res_string, is_vertical)
########################################################################################################################
class LatentHalfMasks:
DESCRIPTION = """Split a latent into left and right half masks."""
TITLE = "Latent Half Masks"
CATEGORY = "MXD/Latent"
RETURN_TYPES = ("MASK", "MASK")
RETURN_NAMES = ("mask_left", "mask_right")
OUTPUT_TOOLTIPS = (
"Mask covering the left half of the latent.",
"Mask covering the right half of the latent.",
)
FUNCTION = "make_masks"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latent": ("LATENT",),
}
}
RETURN_TYPES = ("MASK", "MASK")
RETURN_NAMES = ("mask_left", "mask_right")
FUNCTION = "make_masks"
CATEGORY = "MXD/latent"
def make_masks(self, latent):
# Infer width/height from latent (assumes 8x scale)
samples = latent.get("samples", None)
if samples is None or not isinstance(samples, torch.Tensor):
raise ValueError("LatentHalfMasks: invalid latent or missing 'samples' tensor.")
h_lat, w_lat = samples.shape[-2], samples.shape[-1]
w, h = int(w_lat * 8), int(h_lat * 8)
# Always vertical, center split, no feather, no swap
split_px = w // 2
left = torch.zeros((h, w), dtype=torch.float32)
right = torch.zeros((h, w), dtype=torch.float32)
left[:, :split_px] = 1.0
right[:, split_px:] = 1.0
return left, right
########################################################################################################################
# Get Latent Size
class GetLatentSizeMXD:
DESCRIPTION = """Get image width/height from a latent."""
TITLE = "Get Latent Size"
CATEGORY = "MXD/Latent"
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
OUTPUT_TOOLTIPS = ("Latent-derived image width in pixels.", "Latent-derived image height in pixels.")
FUNCTION = "get_size"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latent": ("LATENT",),
}
}
def get_size(self, latent):
if isinstance(latent, dict):
width = latent.get("width")
height = latent.get("height")
if width is not None and height is not None:
try:
return (int(width), int(height))
except Exception:
pass
samples = latent.get("samples")
else:
samples = None
if samples is None or not isinstance(samples, torch.Tensor):
raise ValueError("GetLatentSizeMXD: invalid latent or missing 'samples' tensor.")
channels = samples.shape[1] if samples.dim() >= 2 else 0
scale = 16 if channels >= 64 else 8
h_lat, w_lat = samples.shape[-2], samples.shape[-1]
return (int(w_lat * scale), int(h_lat * scale))
########################################################################################################################
# --- Helper function to find the bounding box of a mask ---
def get_bounding_box(mask_tensor):
"""
Finds the bounding box of a non-zero region in a mask tensor.
The mask is expected to be a 2D tensor (H, W).
Returns a tuple (x_min, y_min, x_max, y_max) or None if the mask is empty.
"""
# Get non-zero coordinates from the mask
non_zero_coords = torch.nonzero(mask_tensor, as_tuple=False)
# If the mask is empty, there is no bounding box
if non_zero_coords.numel() == 0:
return None
# Find the min and max coordinates for y (dim 0) and x (dim 1)
min_y = non_zero_coords[:, 0].min().item()
max_y = non_zero_coords[:, 0].max().item()
min_x = non_zero_coords[:, 1].min().item()
max_x = non_zero_coords[:, 1].max().item()
# The bounding box for PIL needs (left, upper, right, lower).
# We add +1 to the max values because the upper bound is exclusive.
return (min_x, min_y, max_x + 1, max_y + 1)
# --- Tensor to PIL and PIL to Tensor conversion helpers ---
def tensor_to_pil(tensor):
"""Converts a torch tensor (B, H, W, C) to a list of PIL Images."""
if tensor is None:
return []
# Handle different tensor dimensions
if tensor.dim() == 4: # Batch of images
images = []
for i in range(tensor.shape[0]):
img_np = 255. * tensor[i].cpu().numpy()
images.append(Image.fromarray(np.clip(img_np, 0, 255).astype(np.uint8)))
return images
elif tensor.dim() == 3: # Single image
img_np = 255. * tensor.cpu().numpy()
return [Image.fromarray(np.clip(img_np, 0, 255).astype(np.uint8))]
else:
raise ValueError(f"Unsupported tensor dimension: {tensor.dim()}")
def pil_to_tensor(pil_images):
"""Converts a list of PIL Images back to a torch tensor (B, H, W, C)."""
if not isinstance(pil_images, list):
pil_images = [pil_images]
tensors = []
for img in pil_images:
# Convert to RGB, then to a numpy array, normalize, and create a tensor
img_np = np.array(img.convert("RGB")).astype(np.float32) / 255.0
tensors.append(torch.from_numpy(img_np).unsqueeze(0))
# Stack all tensors into a single batch tensor
return torch.cat(tensors, dim=0)
# --------------------------------------------------------------------
# ✨ The Main Node Class ✨
# --------------------------------------------------------------------
class PlaceImageByMask:
Description = """Place an overlay image inside the mask bounds on a base image."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_image": ("IMAGE",),
"mask": ("MASK",),
"overlay_image": ("IMAGE",),
},
"optional": {
"maintain_aspect_ratio": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "place_image"
CATEGORY = "MXD/Image"
def place_image(self, base_image, overlay_image, mask, maintain_aspect_ratio=True):
# Convert input tensors to lists of PIL Images
base_pils = tensor_to_pil(base_image)
overlay_pils = tensor_to_pil(overlay_image)
processed_images = []
# Process each image in the batch
for i, base_pil in enumerate(base_pils):
# Work with an RGBA version of the base image for clean pasting
composited_image = base_pil.convert("RGBA")
# Select the corresponding overlay and mask for the current base image
# Clamping the index prevents errors if batch sizes are mismatched
overlay_pil = overlay_pils[min(i, len(overlay_pils) - 1)].convert("RGBA")
current_mask = mask[min(i, mask.shape[0] - 1)]
# Find the bounding box from the mask
bbox = get_bounding_box(current_mask)
# If no mask is found, just use the original base image and skip to the next
if not bbox:
raise ValueError("The base image must be masked where you want the overlay to appear.")
x_min, y_min, x_max, y_max = bbox
box_width = x_max - x_min
box_height = y_max - y_min
# If the bounding box has no area, skip to the next image
if box_width <= 0 or box_height <= 0:
processed_images.append(base_pil)
continue
# --- Resize the overlay image using the specified method ---
if maintain_aspect_ratio:
# Resize to fit *within* the box, preserving aspect ratio (like a thumbnail)
resized_overlay = overlay_pil.copy()
resized_overlay.thumbnail((box_width, box_height), Image.Resampling.LANCZOS)
# Calculate position to center the resized overlay within the bounding box
paste_x = x_min + (box_width - resized_overlay.width) // 2
paste_y = y_min + (box_height - resized_overlay.height) // 2
paste_pos = (paste_x, paste_y)
else:
# As originally requested: stretch to fill the bounding box exactly
resized_overlay = overlay_pil.resize((box_width, box_height), resample=Image.Resampling.LANCZOS)
paste_pos = (x_min, y_min)
# --- Paste the resized overlay onto the base image ---
# The alpha channel of the overlay itself is used as the mask for pasting.
# This ensures transparent areas of the overlay are handled correctly.
composited_image.paste(resized_overlay, paste_pos, resized_overlay)
processed_images.append(composited_image)
# Convert the list of processed PIL images back to a single batch tensor for output
output_tensor = pil_to_tensor(processed_images)
return (output_tensor,)
######################################################################################################################################
class CropImageByMask:
DESCRIPTION = """Crop images to the mask bounds when a mask is provided."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE", ),
},
"optional": {
"mask": ("MASK", ),
}
}
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("image", )
FUNCTION = "crop"
CATEGORY = "MXD/image"
def crop(self, image, mask=None):
# If no mask is provided or the mask is completely empty, return the original image
if mask is None or not torch.any(mask > 0):
return (image, )
B, H, W, C = image.shape
mask = mask.round()
# Find bounding box for each batch
crops = []
for b in range(B):
current_mask = mask[min(b, mask.shape[0]-1)]
# Check if the mask for this specific image is empty.
if not torch.any(current_mask > 0):
# If a specific mask in a batch is empty, we can't crop.
# To prevent errors with torch.cat later due to different sizes,
# we'll skip cropping for the whole batch and return the original.
# This ensures the output is always a valid tensor.
print("Warning: An empty mask was found in a batch. Returning original images.")
return (image, )
# Get coordinates of non-zero elements
rows = torch.any(current_mask > 0, dim=1)
cols = torch.any(current_mask > 0, dim=0)
# Find boundaries
y_min, y_max = torch.where(rows)[0][[0, -1]]
x_min, x_max = torch.where(cols)[0][[0, -1]]
# Crop image
crop = image[b:b+1, y_min:y_max+1, x_min:x_max+1, :]
crops.append(crop)
# Note: This will raise an error if the crops have different sizes.
# The original code had this limitation.
cropped_images = torch.cat(crops, dim=0)
return (cropped_images, )
########################################################################################################################
# ---------- Helpers (copied from latent loader style) ----------
def _safe_json_loads(s):
if s is None:
return None
if isinstance(s, bytes):
try:
s = s.decode("utf-8", "ignore")
except Exception:
return None
if not isinstance(s, str):
return None
try:
return json.loads(s)
except Exception:
try:
return json.loads(json.loads(s))
except Exception:
return None
def _extract_params_from_prompt_json(prompt_json: dict):
"""
Returns (positive, negative) from saved Comfy prompt graph.
"""
pos = ""
neg = ""
if not isinstance(prompt_json, dict):
return pos, neg
# unwrap if saved as {"prompt": {...}}
graph = prompt_json.get("prompt", prompt_json)
if not isinstance(graph, dict):
return pos, neg
# try to find KSampler/KSamplerAdvanced node
ks = None
for _, v in graph.items():
if "KSampler" in v.get("class_type", ""):
ks = v
break
if not ks:
return pos, neg
kin = ks.get("inputs", {})
def _as_node_id(x):
return str(x[0]) if isinstance(x, (list, tuple)) and x else None
def _text_from_clip(node_id):
n = graph.get(str(node_id), {})
if n.get("class_type") == "CLIPTextEncode":
return str(n.get("inputs", {}).get("text", "")).strip()
return ""
pos = _text_from_clip(_as_node_id(kin.get("positive")))
neg = _text_from_clip(_as_node_id(kin.get("negative")))
return pos, neg
def _strip_counter(name: str) -> str:
# Only strip the trailing pattern we generate when saving: "_<5digits>_"
# Preserve numeric-only base names like "96".
stem, _ = os.path.splitext(name)
m = re.match(r"^(.*?)(?:_\d{5}_)$", stem)
return m.group(1) if m else stem
# ---------- Node ----------
class LoadImageBatchMXD:
DESCRIPTION = """Load images from an outputs folder, make masks from alpha, and read prompts."""
TITLE = "Load Image Batch (Outputs + Prompts)"
CATEGORY = "MXD/Image"
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING")
RETURN_NAMES = ("IMAGE", "MASK", "positive", "negative")
OUTPUT_IS_LIST = (True, True, True, True)
FUNCTION = "load_batch"
@classmethod
def INPUT_TYPES(cls):
outputs_root = folder_paths.get_output_directory()
subdirs = [""] + sorted(
[d for d in os.listdir(outputs_root) if os.path.isdir(os.path.join(outputs_root, d))]
)
return {"required": {"folder": (tuple(subdirs), {"default": ""})}}
def _extract_prompts(self, image: Image.Image):
pos, neg = "", ""
try:
raw = image.info.get("prompt")
if raw:
prompt_json = _safe_json_loads(raw)
if prompt_json:
pos, neg = _extract_params_from_prompt_json(prompt_json)
else:
pos = raw
except Exception as e:
print(f"[LoadImageBatchMXD] Prompt parse failed: {e}")
return pos, neg
def load_batch(self, folder: str):
outputs_root = folder_paths.get_output_directory()
folder_path = os.path.join(outputs_root, folder) if folder else outputs_root
if not os.path.isdir(folder_path):
raise FileNotFoundError(f"No such folder: {folder_path}")
valid_exts = (".png", ".jpg", ".jpeg", ".webp")
files = [os.path.join(folder_path, f) for f in sorted(os.listdir(folder_path))
if f.lower().endswith(valid_exts)]
images, masks, positives, negatives, prefixes = [], [], [], [], []
for path in files:
i = Image.open(path)
i = ImageOps.exif_transpose(i)
pos, neg = self._extract_prompts(i)
positives.append(pos)
negatives.append(neg)
rgb = i.convert("RGB")
arr = np.array(rgb).astype(np.float32) / 255.0
img_t = torch.from_numpy(arr)[None, ...]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask_t = 1.0 - torch.from_numpy(mask).unsqueeze(0)
else:
h, w = arr.shape[:2]
mask_t = torch.zeros((1, h, w), dtype=torch.float32)
images.append(img_t)
masks.append(mask_t)
return (images, masks, positives, negatives)
class LoadImageWithPromptsMXD:
DESCRIPTION = """Load one input image, create a mask from alpha, and read prompts if present."""
CATEGORY = "image"
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING")
RETURN_NAMES = ("IMAGE", "MASK", "positive", "negative")
FUNCTION = "load_image"
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
files = folder_paths.filter_files_content_types(files, ["image"])
return {"required": {"image": (sorted(files), {"image_upload": True})}}
def _extract_prompts(self, img: Image.Image):
pos, neg = "", ""
raw = img.info.get("prompt")
if raw:
prompt_json = _safe_json_loads(raw)
if prompt_json:
pos, neg = _extract_params_from_prompt_json(prompt_json)
else:
pos = raw
return pos, neg
def load_image(self, image):
image_path = folder_paths.get_annotated_filepath(image)
img = node_helpers.pillow(Image.open, image_path)
output_images, output_masks = [], []
pos, neg = "", ""
w, h = None, None
excluded_formats = ['MPO']
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
frame = i.convert("RGB")
if len(output_images) == 0:
w, h = frame.size
# extract prompts only once (from first frame)
pos, neg = self._extract_prompts(i)
if frame.size != (w, h):
continue
arr = np.array(frame).astype(np.float32) / 255.0
tensor_img = torch.from_numpy(arr)[None, ...]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
elif i.mode == 'P' and 'transparency' in i.info:
mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((1, 64, 64), dtype=torch.float32, device="cpu")
output_images.append(tensor_img)
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1 and img.format not in excluded_formats:
output_image = torch.cat(output_images, dim=0)
output_mask = torch.cat(output_masks, dim=0)
else:
output_image = output_images[0]
output_mask = output_masks[0]
return (output_image, output_mask, pos, neg)
@classmethod
def IS_CHANGED(s, image):
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
m.update(f.read())
return m.digest().hex()
@classmethod
def VALIDATE_INPUTS(s, image):
if not folder_paths.exists_annotated_filepath(image):
return f"Invalid image file: {image}"
return True
########################################################################################################################
from nodes import PreviewImage, SaveImage
class SaveImage_MXD:
TITLE = "Save Image MXD"
CATEGORY = "MXD/Image"
OUTPUT_NODE = True
FUNCTION = "save"
DESCRIPTION = """Save images to the output folder or preview them."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE", {"tooltip": "Images to preview and/or save."}),
"filename_prefix": ("STRING", {
"default": "ComfyUI",
"tooltip": "File name prefix. Tip: you can use a subfolder like 'tests/my_run'."
}),
"mode": ([
"Save + Preview",
"Save Only",
"Preview only"
], {
"default": "Save + Preview",
"tooltip": "Choose whether to write files to disk, only preview, or save quietly."
}),
},
"optional": {
"embed_workflow": ("BOOLEAN", {
"default": True,
"tooltip": "Embed workflow metadata when saving PNG previews/files."
}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
OUTPUT_TOOLTIPS = ("Saves and/or previews the images.",)
@staticmethod
def _filtered_extra_pnginfo(extra_pnginfo, embed_workflow):
if embed_workflow or not isinstance(extra_pnginfo, dict):
return extra_pnginfo
filtered = {k: v for k, v in extra_pnginfo.items() if str(k).lower() != "workflow"}
return filtered or None
def save(self, images, filename_prefix, mode, embed_workflow=True, prompt=None, extra_pnginfo=None):
if embed_workflow:
save_prompt = prompt
save_extra_pnginfo = self._filtered_extra_pnginfo(extra_pnginfo, True)
else:
# Core SaveImage embeds the hidden `prompt` graph too.
# Drop both to truly disable workflow reconstruction from saved files.
save_prompt = None
save_extra_pnginfo = None
if mode.startswith("Preview"):
return PreviewImage().save_images(images, filename_prefix, save_prompt, save_extra_pnginfo)
result = SaveImage().save_images(images, filename_prefix, save_prompt, save_extra_pnginfo)
if mode == "Save Only" and isinstance(result, dict):
# Strip UI previews so nothing shows up in the ComfyUI viewer.
return {k: v for k, v in result.items() if k != "ui"}
return result
########################################################################################################################
class ExtractWorkflowFromImageMXD:
TITLE = "Extract Workflow From Image MXD"
CATEGORY = "MXD/Image"
OUTPUT_NODE = True
FUNCTION = "extract_and_save"
DESCRIPTION = """Save workflow metadata to a JSON file from a wired image execution context."""
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {"tooltip": "Any connected image. Used to trigger extraction/save."}),
"filename_prefix": ("STRING", {
"default": "workflow/ComfyUI",
"tooltip": "Output JSON prefix. You can include subfolders, e.g. 'workflow/my_run'.",
}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("json_path",)
OUTPUT_TOOLTIPS = ("Relative path to the saved JSON file in outputs.",)
@staticmethod
def _decode_json_candidate(value):
if value is None:
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, bytes):
for enc in ("utf-8", "utf-16", "latin-1"):
try:
value = value.decode(enc)
break
except Exception:
continue
if isinstance(value, bytes):
value = value.decode("utf-8", "ignore")
if not isinstance(value, str):
return None
raw = value.strip()
if not raw:
return None
if raw.lower().startswith("workflow:"):
raw = raw.split(":", 1)[1].strip()
parsed = _safe_json_loads(raw)
if isinstance(parsed, (dict, list)):
return parsed
return None
def _extract_workflow_from_context(self, prompt=None, extra_pnginfo=None):
if isinstance(extra_pnginfo, dict):
for key in ("workflow", "Workflow"):
parsed = self._decode_json_candidate(extra_pnginfo.get(key))
if parsed is not None:
return parsed
parsed_extra = self._decode_json_candidate(extra_pnginfo)
if isinstance(parsed_extra, dict):
for key in ("workflow", "Workflow"):
parsed = self._decode_json_candidate(parsed_extra.get(key))
if parsed is not None:
return parsed
if prompt is not None:
parsed_prompt = self._decode_json_candidate(prompt)
if parsed_prompt is not None:
return {"prompt": parsed_prompt}
if isinstance(prompt, dict):
return {"prompt": prompt}
return None
def extract_and_save(self, image, filename_prefix="workflow/ComfyUI", prompt=None, extra_pnginfo=None):
workflow = self._extract_workflow_from_context(prompt, extra_pnginfo)
if workflow is None:
raise ValueError(
"No workflow metadata is available in this execution context. "
"Connect generated images from the current run, or ensure workflow metadata is present."
)
filename_prefix += self.prefix_append
height = image[0].shape[0]
width = image[0].shape[1]
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
filename_prefix, self.output_dir, width, height
)
os.makedirs(full_output_folder, exist_ok=True)
file = f"{filename}_{counter:05}_.json"
save_path = os.path.join(full_output_folder, file)
with open(save_path, "w", encoding="utf-8", newline="\n") as f:
json.dump(workflow, f, ensure_ascii=False, indent=2)
rel = os.path.join(subfolder, file) if subfolder else file
rel = rel.replace("\\", "/")
return {
"ui": {"text": [f"Saved workflow JSON: {rel}"]},
"result": (rel,),
}
########################################################################################################################
class SmartCropByMaskMXD:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", ),
"mask": ("MASK", ),
},
}
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("image", )
FUNCTION = "crop"
CATEGORY = "image/transform"
DESCRIPTION = "Slides a square crop window horizontally + vertically to center on subject mask."
def crop(self, image, mask):
B, H, W, C = image.shape
mask = mask.round()
crops = []
for b in range(B):
mask_b = mask[min(b, mask.shape[0]-1)]
# Get non-zero rows and columns
rows = torch.any(mask_b > 0, dim=1)
cols = torch.any(mask_b > 0, dim=0)
# Default to center
center_x = W // 2
center_y = H // 2
# Update center_x from mask if possible
if torch.any(cols):
x_min, x_max = torch.where(cols)[0][[0, -1]]
center_x = (x_min + x_max) // 2
# Update center_y from mask if possible
if torch.any(rows):
y_min, y_max = torch.where(rows)[0][[0, -1]]
center_y = (y_min + y_max) // 2
# Compute square crop box
side = min(H, W)
half = side // 2
left = max(0, center_x - half)
right = min(W, left + side)
left = right - side # clamp again
top = max(0, center_y - half)
bottom = min(H, top + side)
top = bottom - side # clamp again
# Final crop: safe slicing
crop = image[b:b+1, top:bottom, left:right, :]
crops.append(crop)
return (torch.cat(crops, dim=0), )
########################################################################################################################
class BboxDetectorCombinedBatchMXD:
DESCRIPTION = "Run an Impact Pack BBOX_DETECTOR combined mask over each image in a batch."
CATEGORY = "MXD/Detector"
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("mask",)
FUNCTION = "detect"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"bbox_detector": ("BBOX_DETECTOR",),
"images": ("IMAGE",),
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"dilation": ("INT", {"default": 4, "min": -512, "max": 512, "step": 1}),
}
}
def detect(self, bbox_detector, images, threshold=0.5, dilation=4):
if images.ndim == 3:
images = images.unsqueeze(0)
if images.ndim != 4:
raise ValueError(f"[BboxDetectorCombinedBatchMXD] Expected IMAGE tensor [B,H,W,C], got shape {tuple(images.shape)}")
masks = []
frame_count, height, width, _ = images.shape
pbar = comfy.utils.ProgressBar(frame_count)
for i in range(frame_count):
frame = images[i:i + 1]
mask = bbox_detector.detect_combined(frame, threshold, dilation)
if mask is None:
mask = torch.zeros((height, width), dtype=torch.float32, device="cpu")
elif torch.is_tensor(mask):
mask = mask.detach().to(dtype=torch.float32, device="cpu")
else:
mask = torch.as_tensor(mask, dtype=torch.float32, device="cpu")
if mask.ndim == 3 and mask.shape[0] == 1:
mask = mask.squeeze(0)
if mask.ndim != 2:
raise ValueError(f"[BboxDetectorCombinedBatchMXD] Detector returned unexpected mask shape {tuple(mask.shape)} for frame {i}.")
masks.append(mask.unsqueeze(0))
pbar.update(1)
return (torch.cat(masks, dim=0),)
########################################################################################################################
def _parse_mxd_mask_color(color_string):
if color_string is None:
return [255, 255, 255]
text = str(color_string).strip()
color = [255, 255, 255]
if "," in text:
try:
values = [float(channel.strip()) for channel in text.split(",")]
if all(0.0 <= value <= 1.0 for value in values):
color = [int(value * 255) for value in values]
else:
color = [int(value) for value in values]
except Exception:
color = [255, 255, 255]
else:
try:
color = list(ImageColor.getrgb(text))
except Exception:
try:
value = float(text)
value = int(value * 255) if 0.0 <= value <= 1.0 else int(value)
color = [value, value, value]
except Exception:
color = [255, 255, 255]
color = np.clip(color, 0, 255).astype(np.int32).tolist()
if len(color) < 3:
color = (color + [color[-1] if color else 255] * 3)[:3]
return color[:4]
def _mxd_image_batch(image):
if image is None:
return None
if image.ndim == 3:
image = image.unsqueeze(0)
if image.ndim != 4:
raise ValueError(f"[ImageAndMaskPreviewMXD] Expected IMAGE tensor [B,H,W,C], got shape {tuple(image.shape)}")
return image.to(dtype=torch.float32)
def _mxd_mask_batch(mask, height=None, width=None, batch_size=None, device=None):
if mask is None:
return None
if mask.ndim == 2:
mask = mask.unsqueeze(0)
elif mask.ndim == 4 and mask.shape[-1] == 1:
mask = mask[..., 0]
elif mask.ndim == 4 and mask.shape[1] == 1:
mask = mask[:, 0]
if mask.ndim != 3:
raise ValueError(f"[ImageAndMaskPreviewMXD] Expected MASK tensor [B,H,W], got shape {tuple(mask.shape)}")
mask = mask.to(dtype=torch.float32, device=device if device is not None else mask.device).clamp(0.0, 1.0)
if height is not None and width is not None and (mask.shape[-2] != height or mask.shape[-1] != width):
mask = F.interpolate(mask.unsqueeze(1), size=(height, width), mode="bilinear", align_corners=False).squeeze(1)
if batch_size is not None:
mask = comfy.utils.repeat_to_batch_size(mask, batch_size)
return mask
class ImageAndMaskPreviewMXD(SaveImage):
DESCRIPTION = """Return an image with a mask composited over it without creating a node preview."""
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("composite",)
FUNCTION = "execute"
CATEGORY = "MXD/Image"
OUTPUT_NODE = False
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
self.type = "temp"
self.prefix_append = "_temp_" + "".join(random.choice("abcdefghijklmnopqrstupvxyz") for _ in range(5))
self.compress_level = 4
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"mask_color": ("STRING", {"default": "255, 255, 255", "tooltip": "RGB/RGBA CSV, hex, or color name."}),
"pass_through": ("BOOLEAN", {"default": True, "tooltip": "Legacy option. This node now always returns the composite without creating a preview."}),
},
"optional": {
"image": ("IMAGE",),
"mask": ("MASK",),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
def _build_composite(self, image=None, mask=None, mask_opacity=1.0, mask_color="255, 255, 255"):
image = _mxd_image_batch(image)
if image is None and mask is None:
raise ValueError("[ImageAndMaskPreviewMXD] Connect an image, a mask, or both.")
if image is None:
mask = _mxd_mask_batch(mask)
return mask.unsqueeze(-1).expand(-1, -1, -1, 3).contiguous()
if image.shape[-1] == 1:
image = image.expand(-1, -1, -1, 3).clone()
elif image.shape[-1] >= 3:
image = image[..., :3].clone()
else:
raise ValueError(f"[ImageAndMaskPreviewMXD] Expected IMAGE tensor with 1 or more channels, got shape {tuple(image.shape)}")
if mask is None:
return image
batch_size, height, width, channels = image.shape
mask = _mxd_mask_batch(mask, height, width, batch_size, image.device)
color = _parse_mxd_mask_color(mask_color)
alpha = mask.mul(float(mask_opacity)).clamp(0.0, 1.0)
if len(color) == 4:
alpha = alpha * (color[3] / 255.0)
rgb = torch.tensor(color[:3], dtype=image.dtype, device=image.device).view(1, 1, 1, channels) / 255.0
alpha = alpha.unsqueeze(-1)
return (image * (1.0 - alpha) + rgb * alpha).clamp(0.0, 1.0)
def execute(self, mask_opacity, mask_color, pass_through, filename_prefix="ComfyUI", image=None, mask=None, prompt=None, extra_pnginfo=None):
composite = self._build_composite(image=image, mask=mask, mask_opacity=mask_opacity, mask_color=mask_color)
return (composite,)
########################################################################################################################
# NODE MAPPING
NODE_CLASS_MAPPINGS = {
"Flux Empty Latent Image": FluxEmptyLatentImage,
"Flux 2 Empty Latent Image": Flux2EmptyLatentImage,
"Sdxl Empty Latent Image": SdxlEmptyLatentImage,
"Flux Resolution Selector": FluxResolutionSelector,
"Image Scale To Total Pixels (SDXL Safe)": SDXLImageScaleToTotalPixelsSafe,
"Flux Image Scale To Total Pixels (Flux Safe)": FluxImageScaleToTotalPixelsSafe,
"Prompt With Guidance (Flux)": PromptWithGuidance,
"FluxResolutionMatcher": FluxResolutionMatcher,
"SDXLResolutionMatcher": SDXLResolutionMatcher,
"LatentHalfMasks": LatentHalfMasks,
"Get Latent Size": GetLatentSizeMXD,
"Place Image By Mask": PlaceImageByMask,
"Crop Image By Mask": CropImageByMask,
"Load Image Batch MXD": LoadImageBatchMXD,
"LoadImageWithPromptsMXD": LoadImageWithPromptsMXD,
"ZImageTurboEmptyLatentImage": ZImageTurboEmptyLatentImage,
"Save Image MXD": SaveImage_MXD,
"Extract Workflow From Image MXD": ExtractWorkflowFromImageMXD,
"SmartCropByMaskMXD": SmartCropByMaskMXD,
"BboxDetectorCombinedBatchMXD": BboxDetectorCombinedBatchMXD,
"ImageAndMaskPreviewMXD": ImageAndMaskPreviewMXD,
}
if HAVE_COMFY_API:
NODE_CLASS_MAPPINGS.update({
"QwenImageEditSingleMXD": QwenImageEditSingleMXD,
"QwenImageEditTripleMXD": QwenImageEditTripleMXD,
})
NODE_DISPLAY_NAME_MAPPINGS = {
"Flux Empty Latent Image": "Flux Empty Latent Image MXD",
"Flux 2 Empty Latent Image": "Flux 2 Empty Latent Image MXD",
"Sdxl Empty Latent Image": "SDXL Empty Latent Image MXD",
"Flux Resolution Selector": "Flux Resolution Selector MXD",
"Image Scale To Total Pixels (SDXL Safe)": "Scale SDXL Image MXD",
"Flux Image Scale To Total Pixels (Flux Safe)": "Scale Flux Image MXD",
"Prompt With Guidance (Flux)": "Prompt with Flux Guidance MXD",
"FluxResolutionMatcher": "Flux Resolution Matcher MXD",
"SDXLResolutionMatcher": "SDXL Resolution Matcher MXD",
"LatentHalfMasks": "Latent to L/R Masks MXD",
"Get Latent Size": "Get Latent Size MXD",
"Place Image By Mask": "Place Image by Mask MXD",
"Crop Image By Mask": "Crop Image by Mask MXD",
"Load Image Batch MXD": "Load Image Batch (From Outputs) MXD",
"LoadImageWithPromptsMXD": "Load Image MXD",
"ZImageTurboEmptyLatentImage": "ZIT Empty Latent Image MXD",
"Save Image MXD": "Save Image MXD",
"Extract Workflow From Image MXD": "Extract Workflow From Image MXD",
"SmartCropByMaskMXD": "Smart Crop by Mask MXD",
"BboxDetectorCombinedBatchMXD": "BBOX Detector Combined Batch MXD",
"ImageAndMaskPreviewMXD": "Image and Mask Preview MXD",
}
if HAVE_COMFY_API:
NODE_DISPLAY_NAME_MAPPINGS.update({
"QwenImageEditSingleMXD": "Qwen Image Edit + Latent MXD",
"QwenImageEditTripleMXD": "Qwen Image Edit Prompt MXD (Triple)",
})