Add GIF, Krea, and Set/Get workflow tools

Add timed GIF load, stitch, and save nodes; modular Krea 2 editors and prompt encoders; a downscale node; and compatible Set/Get frontend nodes. Also improve video-save progress and chooser positioning, update schema and release checks, and bump the package version to 3.3.1.
This commit is contained in:
Maxed-Out-99
2026-11-12 17:50:23 -08:00
parent a5db511a70
commit fc3aba2e38
16 changed files with 3138 additions and 30 deletions
+27 -1
View File
@@ -71,9 +71,27 @@ preview actually needs it.
| `Flux Empty Latent Image MXD` / `ZIT Empty Latent Image MXD` / `SDXL Empty Latent Image MXD` | Resolution presets plus vertical toggle to avoid retyping the same sizes repeatedly. |
| `Save Image MXD` | Simple save modes (`Save + Preview`, `Save Only`, `Preview Only`). |
| `WAN 2.2 MXD` nodes | Helpers for WAN 2.2 latent/video prep, frame tools, and I2V-focused workflows. |
| `Krea 2 Edit MXD` / `Krea2 Edit MXD` | Krea 2 source-preservation and grounded prompt encoding. No separate custom-node pack is required; model weights are still downloaded separately. |
| `Krea2 Img Edit MXD` / `Krea2 Img Edit Adv MXD` | Single-image editing with one boost. Adv adds a Base/Ref choice and a boost mask. |
| `Krea2 2 Img Edit MXD` / `Krea2 2 Img Edit Adv MXD` | Two-image editing. Simple uses fixed Base/Ref roles; Adv allows independent roles, boosts, and boost masks. |
| `Krea2 Img Edit Prompt MXD` / `Krea2 2 Img Edit Prompt MXD` | Grounded prompt encoding with one image or Base/Ref images, matching the edit nodes. Both use a fixed 768px grounding limit. |
| `Krea 2 Edit MXD` | The original model patch node is retained for published workflows. The separate grounded prompt encoder is named `Krea2 Edit Prompt MXD`. No separate custom-node pack is required; download model weights separately. |
| Prompt spellcheck | Right-click a misspelled word in any prompt box for suggestions. Works offline, no node to add. |
Krea image editors output a model and `source_latent`; prompt encoding, sampling,
and VAE decoding remain separate. `source_latent` always comes from the first
image (the base in the simple two-image editor). Advanced Base/Ref choices affect
reference layout influence, not which image supplies that latent. To put two
characters into a new scene, use the two-image Adv editor, set both images to
Ref, and describe the new scene in the prompt. Boost masks restrict attention
boosts to white areas; they are not inpainting masks.
The original `Krea2EditModelPatchMXD` key retains its published inputs, widget
order, defaults, and aligned placement for both images so existing V1.2 workflows
can load unchanged. The new editors also accept V1.2 and V2 edit LoRAs; the simple
two-image editor uses freer placement for its Ref input. The prompt encoder keeps its class key and image/prompt inputs. Its displayed
title is `Krea2 Edit Prompt MXD`, and its legacy grounding control is retained for existing workflows. The new
one/two-image prompt nodes use a fixed 768px limit; this does not set output size.
## Companion Packs
Some things that used to live in here now ship separately, so you can install
@@ -118,3 +136,11 @@ and license details are recorded in
[THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and alongside vendored code.
This pack has no pip dependencies — everything it needs ships with ComfyUI.
### Get / Set connections
Maxed Out includes compatible `GetNode` / `SetNode` frontend nodes under
**MXD/Utilities**. They connect parts of a graph by a shared name. If KJNodes'
Set/Get extension is enabled, Maxed Out uses that provider and registers no
second copy, menus or settings. Existing workflows use the same node keys
with either pack. Refresh the browser after adding or removing a provider.
+19
View File
@@ -22,6 +22,10 @@ Locke, revision `86f886dac23013d88996e3a2e99093ba44d322fb`. It was modified to
extract only the implementation used by the Maxed Out nodes. The upstream
project is licensed under the Apache License, Version 2.0.
V2 reference placement and reference pre-encoding were adapted from revision
`13ef60f20828949becada815d3cbc371eb5ccaab`, retaining the MXD per-image controls
and latent output.
The complete license text is included at
`third_party/ComfyUI-Krea2Edit/LICENSE`.
@@ -36,3 +40,18 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
## Set/Get frontend nodes
`web/nodes/mxd_set_get.js` adapts `web/js/setgetnodes.js` from
[ComfyUI-KJNodes](https://github.com/kijai/ComfyUI-KJNodes), licensed under
GPL-3.0 (full text in the repository-root `LICENSE`). Upstream credits
[diffus3's SetGet](https://github.com/diffus3/ComfyUI-extensions) as its origin.
The installed source snapshot used on 2026-11-08 has SHA-256
`82d4dbcf522c23679e28132a59d907a33bc4c580e1c787afa713e3cae1e0c59d`.
MXD adds provider detection and a singleton guard, places fallback nodes in
MXD/Utilities, updates package metadata, and makes the add-node commands
independent of KJ's other modules. KJ's enabled Set/Get script takes precedence;
the fallback does not register nodes, settings, menus or hooks alongside it.
The original SetNode/GetNode keys and KJ settings IDs are retained for compatibility.
+1
View File
@@ -24,6 +24,7 @@ for _name in (
"krea",
"masks",
"media_io",
"gifs",
"comparers",
"checkpoints",
"wan22",
+191
View File
@@ -0,0 +1,191 @@
"""Timed GIF loading, spatial stitching, and saving.
Registered nodes: Load GIF MXD, GIF Stitch MXD, Save GIF MXD.
MXD_GIF carries RGB IMAGE frames and their individual durations in milliseconds.
"""
from __future__ import annotations
import bisect
import hashlib
import os
import numpy as np
import torch
from PIL import Image, ImageSequence
import comfy.utils
import folder_paths
def _animation(frames, durations):
if frames.ndim != 4 or frames.shape[0] == 0 or frames.shape[-1] != 3:
raise ValueError("GIF frames must be a nonempty RGB image batch.")
durations = [int(d) for d in durations]
if len(durations) != frames.shape[0] or any(d < 10 or d % 10 for d in durations):
raise ValueError("Each GIF frame needs a positive duration in multiples of 10 ms.")
return {"frames": frames, "durations": durations}
def _timeline(animation, total):
ends = np.cumsum(animation["durations"]).tolist()
period = ends[-1]
boundaries = {0, total}
for offset in range(0, total, period):
boundaries.update(offset + end for end in ends if offset + end < total)
return ends, period, boundaries
def _stitch_frames(image1, image2, direction, match_image_size, spacing_width, spacing_color):
"""Use the same layout rules as core Image Stitch, on synchronized batches."""
horizontal = direction in ("left", "right")
if match_image_size:
h1, w1 = image1.shape[1:3]
h2, w2 = image2.shape[1:3]
width, height = (max(1, int(h1 * w2 / h2)), h1) if horizontal else (w1, max(1, int(w1 * h2 / w2)))
image2 = comfy.utils.common_upscale(
image2.movedim(-1, 1), width, height, "lanczos", "disabled"
).movedim(1, -1)
colors = {"white": (1, 1, 1), "black": (0, 0, 0), "red": (1, 0, 0),
"green": (0, 1, 0), "blue": (0, 0, 1)}
color = colors[spacing_color]
# Core pads unmatched sizes with white/black, or black for RGB spacing colors.
pad_value = 1.0 if spacing_color == "white" else 0.0
dim = 1 if horizontal else 2
target = max(image1.shape[dim], image2.shape[dim])
def pad(image):
delta = target - image.shape[dim]
before, after = delta // 2, delta - delta // 2
padding = (0, 0, 0, 0, before, after) if horizontal else (0, 0, before, after)
return torch.nn.functional.pad(image, padding, value=pad_value) if delta else image
image1, image2 = pad(image1), pad(image2)
pieces = [image2, image1] if direction in ("left", "up") else [image1, image2]
if spacing_width:
spacing_width += spacing_width % 2
shape = list(image1.shape)
shape[2 if horizontal else 1] = spacing_width
spacing = image1.new_empty(shape)
spacing[:] = image1.new_tensor(color)
pieces.insert(1, spacing)
return torch.cat(pieces, dim=2 if horizontal else 1)
class LoadGifMXD:
@classmethod
def INPUT_TYPES(cls):
root = folder_paths.get_input_directory()
files = sorted(f for f in os.listdir(root) if f.lower().endswith(".gif")
and os.path.isfile(os.path.join(root, f)))
return {"required": {"gif": (files, {"image_upload": True, "animated_image_upload": True})}}
RETURN_TYPES = ("MXD_GIF", "IMAGE")
RETURN_NAMES = ("gif", "images")
FUNCTION = "load"
CATEGORY = "Maxed Out/GIF"
DESCRIPTION = "Load all GIF frames with their original timing. Transparency is composited onto black."
def load(self, gif):
path = folder_paths.get_annotated_filepath(gif)
frames, durations = [], []
with Image.open(path) as source:
if source.format != "GIF":
raise ValueError("Load GIF MXD requires a GIF file.")
for frame in ImageSequence.Iterator(source):
rgba = frame.convert("RGBA")
background = Image.new("RGBA", rgba.size, (0, 0, 0, 255))
rgb = Image.alpha_composite(background, rgba).convert("RGB")
frames.append(torch.from_numpy(np.array(rgb).astype(np.float32) / 255.0))
duration = int(frame.info.get("duration", 100))
durations.append(max(10, duration) if duration > 0 else 100)
frames = torch.stack(frames)
return (_animation(frames, durations), frames)
@classmethod
def IS_CHANGED(cls, gif):
digest = hashlib.sha256()
with open(folder_paths.get_annotated_filepath(gif), "rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@classmethod
def VALIDATE_INPUTS(cls, gif):
if not folder_paths.exists_annotated_filepath(gif):
return f"Invalid GIF file: {gif}"
return True
class GifStitchMXD:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"gif1": ("MXD_GIF",),
"direction": (["right", "down", "left", "up"], {"default": "right"}),
"match_image_size": ("BOOLEAN", {"default": True}),
"spacing_width": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 2}),
"spacing_color": (["white", "black", "red", "green", "blue"], {"default": "white"}),
}, "optional": {"gif2": ("MXD_GIF",)}}
RETURN_TYPES = ("MXD_GIF", "IMAGE")
RETURN_NAMES = ("gif", "images")
FUNCTION = "stitch"
CATEGORY = "Maxed Out/GIF"
DESCRIPTION = "Stitch GIFs spatially with original timing. The shorter GIF loops to the longer GIF's duration."
def stitch(self, gif1, direction, match_image_size, spacing_width, spacing_color, gif2=None):
gif1 = _animation(gif1["frames"], gif1["durations"])
if gif2 is None:
return (gif1, gif1["frames"])
gif2 = _animation(gif2["frames"], gif2["durations"])
total = max(sum(gif1["durations"]), sum(gif2["durations"]))
ends1, period1, bounds1 = _timeline(gif1, total)
ends2, period2, bounds2 = _timeline(gif2, total)
bounds = sorted(bounds1 | bounds2)
# Merge consecutive identical frame pairs to avoid needless image copies.
pairs, durations = [], []
for start, end in zip(bounds, bounds[1:]):
pair = (bisect.bisect_right(ends1, start % period1),
bisect.bisect_right(ends2, start % period2))
if pairs and pairs[-1] == pair:
durations[-1] += end - start
else:
pairs.append(pair)
durations.append(end - start)
image1 = gif1["frames"][[p[0] for p in pairs]]
image2 = gif2["frames"][[p[1] for p in pairs]].to(image1)
frames = _stitch_frames(image1, image2, direction, match_image_size, spacing_width, spacing_color)
return (_animation(frames, durations), frames)
class SaveGifMXD:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"gif": ("MXD_GIF",),
"filename_prefix": ("STRING", {"default": "MaxedOut/GIF"})}}
RETURN_TYPES = ("MXD_GIF",)
RETURN_NAMES = ("gif",)
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "Maxed Out/GIF"
DESCRIPTION = "Save an infinitely looping GIF with its frame timing and an animated preview."
def save(self, gif, filename_prefix="MaxedOut/GIF"):
gif = _animation(gif["frames"], gif["durations"])
images = gif["frames"]
root, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory(), images.shape[2], images.shape[1])
filename = filename.replace("%batch_num%", "0")
filename = f"{filename}_{counter:05}_.gif"
frames = [Image.fromarray((frame.detach().cpu().numpy().clip(0, 1) * 255).astype(np.uint8))
for frame in images]
frames[0].save(os.path.join(root, filename), save_all=True, append_images=frames[1:],
duration=gif["durations"], loop=0, disposal=2, optimize=False)
return {"ui": {"images": [{"filename": filename, "subfolder": subfolder, "type": "output"}],
"animated": (True,)}, "result": (gif,)}
NODE_CLASS_MAPPINGS = {"Load GIF MXD": LoadGifMXD, "GIF Stitch MXD": GifStitchMXD, "Save GIF MXD": SaveGifMXD}
NODE_DISPLAY_NAME_MAPPINGS = {key: key for key in NODE_CLASS_MAPPINGS}
+338 -9
View File
@@ -1,14 +1,28 @@
"""Krea 2 editing nodes.
"""Modular Krea 2 editing nodes.
Registered nodes:
Published compatibility nodes (original mapping keys and input schemas):
Krea2EditModelPatchMXD Krea 2 Edit MXD
Krea2EditGroundedEncodeMXD Krea2 Edit MXD
Krea2EditGroundedEncodeMXD Krea2 Edit Prompt MXD
Focused editing nodes:
Krea2ImgEditMXD Krea2 Img Edit MXD
Krea2ImgEditAdvMXD Krea2 Img Edit Adv MXD
Krea2TwoImgEditMXD Krea2 2 Img Edit MXD
Krea2EditAdvancedMXD Krea2 2 Img Edit Adv MXD
Krea2ImgPromptMXD Krea2 Img Edit Prompt MXD
Krea2TwoImgPromptMXD Krea2 2 Img Edit Prompt MXD
The advanced two-image key retains the advanced node introduced locally.
All new editors share the same edit path; conditioning and sampling stay separate.
"""
import comfy.patcher_extension
import comfy.utils
from .krea2_edit_core import fit_encode_image, krea2_edit_forward
from .krea2_edit_core import REF_POSITIONS, fit_encode_image, fit_boost_mask, krea2_edit_forward
IMAGE_ROLES = {"Base": REF_POSITIONS[0], "Ref": REF_POSITIONS[1]}
class Krea2EditModelPatchMXD:
@@ -166,10 +180,288 @@ class Krea2EditModelPatchMXD:
return (patched, source_latent)
class _KreaEditImplementation:
TITLE = "Krea 2 Edit Advanced MXD"
CATEGORY = "MXD/Krea"
DESCRIPTION = (
"Krea 2 editing with independent Base/Ref roles, attention boosts, and "
"boost masks for each image. Masks limit attention boosts; they are not edit masks."
)
RETURN_TYPES = ("MODEL", "LATENT")
RETURN_NAMES = ("model", "source_latent")
FUNCTION = "patch"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"image_1": (
"IMAGE",
{"tooltip": "Primary reference image, usually the scene or image to edit."},
),
},
"optional": {
"image_2": (
"IMAGE",
{
"tooltip": (
"Optional second reference, usually a subject to place into Image 1."
)
},
),
"image_1_boost": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1000.0,
"step": 0.01,
"round": 0.001,
"tooltip": "Attention strength for Image 1. 1.0 = unchanged.",
},
),
"image_1_boost_mask": (
"MASK",
{
"tooltip": (
"Optional mask limiting Image 1 Boost to a region such as a face. "
"White areas are boosted."
)
},
),
"image_2_boost": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1000.0,
"step": 0.01,
"round": 0.001,
"tooltip": (
"Attention strength for Image 2. 1.0 = unchanged; no effect when "
"Image 2 is disconnected."
),
},
),
"image_2_boost_mask": (
"MASK",
{
"tooltip": (
"Optional mask limiting Image 2 Boost to a region such as a face. "
"White areas are boosted; no effect when Image 2 is disconnected."
)
},
),
"vae": (
"VAE",
{"tooltip": "Required. VAE used to encode the reference images."},
),
# Keep each image role independent.
"image_1_position": (
list(IMAGE_ROLES),
{"default": "Base", "tooltip": "Base: follow this image's layout and framing. Ref: use this image's appearance with more freedom to reposition and reframe its contents. Base keeps existing V1.2 behavior. This does not change source_latent, which always uses Image 1."},
),
"image_2_position": (
list(IMAGE_ROLES),
{"default": "Base", "tooltip": "Base: follow this image's layout and framing. Ref: use this image's appearance with more freedom to reposition and reframe its contents. Base keeps existing V1.2 behavior. No effect when Image 2 is disconnected. Set both images to Ref to restage two subjects in a new scene."},
),
},
}
def patch(
self,
model,
image_1,
image_2=None,
image_1_boost=1.0,
image_1_boost_mask=None,
image_2_boost=1.0,
image_2_boost_mask=None,
vae=None,
image_1_position="Base",
image_2_position="Base",
):
if vae is None:
raise ValueError("Connect a VAE to Krea 2 Edit MXD.")
images = [image_1]
boosts = [image_1_boost]
boost_masks = [image_1_boost_mask]
positions = [IMAGE_ROLES.get(image_1_position, image_1_position)]
if image_2 is not None:
images.append(image_2)
boosts.append(image_2_boost)
boost_masks.append(image_2_boost_mask)
positions.append(IMAGE_ROLES.get(image_2_position, image_2_position))
cache = {}
# Cache each source at the actual sampled resolution.
def encode_sources(height, width):
return [
model.model.process_latent_in(
fit_encode_image(
image,
vae,
height,
width,
cache,
(index, height, width),
"fit",
)
)
for index, image in enumerate(images)
]
def wrapper(executor, x, timesteps, context, *args, **kwargs):
transformer_options = kwargs.get("transformer_options")
if transformer_options is None:
transformer_options = next(
(arg for arg in reversed(args) if isinstance(arg, dict)), {}
)
height, width = x.shape[-2:]
refs = encode_sources(height, width)
return krea2_edit_forward(
executor.class_obj,
x,
timesteps,
context,
refs,
transformer_options,
image_boosts=boosts,
image_boost_masks=[fit_boost_mask(mask, image, height * 8, width * 8)
for mask, image in zip(boost_masks, images)],
ref_native=True,
pos_mode="stride1",
image_positions=positions,
)
patched = model.clone()
options = patched.model_options.setdefault("transformer_options", {})
comfy.patcher_extension.add_wrapper_with_key(
comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL,
"krea2_edit",
wrapper,
options,
)
# This is deliberately only the first image's ordinary VAE latent.
# The optional second reference remains internal to the edit wrapper.
source_latent = {"samples": vae.encode(image_1[..., :3])}
return (patched, source_latent)
def _edit_inputs(*, two=False, advanced=False):
"""Build explicit one/two-image schemas without exposing unused image controls."""
source = _KreaEditImplementation.INPUT_TYPES()
options = source["optional"]
required = {"model": ("MODEL",)}
if two:
if advanced:
required.update({"image_1": source["required"]["image_1"],
"image_2": options["image_2"]})
else:
required.update({
"img_base": ("IMAGE", {"tooltip": "Image to edit. Guides layout and framing; supplies source_latent."}),
"img_ref": ("IMAGE", {"tooltip": "Appearance reference with freer placement than the base image."}),
})
else:
required["image"] = ("IMAGE", {"tooltip": "The image to edit; supplies source_latent."})
required["vae"] = options["vae"]
controls = {}
for i in range(1, 3 if two else 2):
if not two:
prefix, label = "", "the image"
elif advanced:
prefix, label = f"image_{i}_", f"Image {i}"
elif i == 1:
prefix, label = "img_base_", "the base image"
else:
prefix, label = "img_ref_", "the reference image"
boost = options[f"image_{i}_boost"]
if two and i == 2:
boost[1]["default"] = 2.0
boost[1]["tooltip"] = f"Attention strength for {label}. 1.0 = unchanged."
controls[prefix + "boost"] = boost
if advanced:
controls[prefix + "boost_mask"] = (
"MASK", {"tooltip": f"Limit the boost for {label} to white areas. This is not an edit or inpainting mask."}
)
controls[prefix + "position"] = (
list(IMAGE_ROLES), {
"default": "Ref" if two and i == 2 else "Base",
"tooltip": (
"Base: follow this image's layout and framing. Ref: use its appearance "
"with freer positioning. "
"This does not change which image supplies source_latent."
),
}
)
return {"required": required, "optional": controls}
class Krea2ImgEditMXD(_KreaEditImplementation):
TITLE = "Krea2 Img Edit MXD"
DESCRIPTION = "Edit one image with one attention boost. Connect conditioning and a sampler separately."
@classmethod
def INPUT_TYPES(cls):
return _edit_inputs()
def patch(self, model, image, vae, boost=1.0):
return super().patch(model, image, image_1_boost=boost, vae=vae)
class Krea2ImgEditAdvMXD(_KreaEditImplementation):
TITLE = "Krea2 Img Edit Adv MXD"
DESCRIPTION = "Edit or restage one image with a Base/Ref role, attention boost, and optional boost mask."
@classmethod
def INPUT_TYPES(cls):
return _edit_inputs(advanced=True)
def patch(self, model, image, vae, boost=1.0, boost_mask=None, position="Base"):
return super().patch(model, image, image_1_boost=boost,
image_1_boost_mask=boost_mask, vae=vae,
image_1_position=position)
class Krea2TwoImgEditMXD(_KreaEditImplementation):
TITLE = "Krea2 2 Img Edit MXD"
DESCRIPTION = "Edit a base image using a second appearance reference. Base follows layout; Ref allows freer placement."
@classmethod
def INPUT_TYPES(cls):
return _edit_inputs(two=True)
def patch(self, model, img_base, img_ref, vae, img_base_boost=1.0, img_ref_boost=2.0):
return super().patch(model, img_base, image_2=img_ref,
image_1_boost=img_base_boost, image_2_boost=img_ref_boost,
vae=vae, image_1_position="Base", image_2_position="Ref")
class Krea2EditAdvancedMXD(_KreaEditImplementation):
TITLE = "Krea2 2 Img Edit Adv MXD"
DESCRIPTION = "Two images with independent Base/Ref roles, attention boosts, and boost masks. Use Ref for both to restage two subjects."
@classmethod
def INPUT_TYPES(cls):
return _edit_inputs(two=True, advanced=True)
def patch(self, model, image_1, image_2=None, image_1_boost=1.0,
image_1_boost_mask=None, image_2_boost=2.0, image_2_boost_mask=None,
vae=None, image_1_position="Base", image_2_position="Ref"):
return super().patch(
model, image_1, image_2=image_2,
image_1_boost=image_1_boost, image_1_boost_mask=image_1_boost_mask,
image_2_boost=image_2_boost, image_2_boost_mask=image_2_boost_mask,
vae=vae, image_1_position=image_1_position, image_2_position=image_2_position,
)
class Krea2EditGroundedEncodeMXD:
"""Encode a Krea 2 edit instruction together with its reference image."""
TITLE = "Krea2 Edit MXD"
TITLE = "Krea2 Edit Prompt MXD"
CATEGORY = "MXD/Krea"
DESCRIPTION = (
"Encodes the edit instruction grounded on the source image using the "
@@ -240,6 +532,7 @@ class Krea2EditGroundedEncodeMXD:
return samples.movedim(1, -1)[:, :, :, :3]
def encode(self, clip, prompt, image=None, image_b=None, grounding_px=768):
# Legacy workflows retain their grounding control. New prompt nodes use 768px.
if image is None:
tokens = clip.tokenize(prompt)
return (clip.encode_from_tokens_scheduled(tokens),)
@@ -255,12 +548,48 @@ class Krea2EditGroundedEncodeMXD:
return (clip.encode_from_tokens_scheduled(tokens),)
class Krea2ImgPromptMXD(Krea2EditGroundedEncodeMXD):
TITLE = "Krea2 Img Edit Prompt MXD"
DESCRIPTION = "Encode an edit instruction with the same image used by the single-image editor. Image grounding is fixed at 768px."
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"clip": ("CLIP",),
"image": ("IMAGE", {"tooltip": "Connect the same image as the single-image edit node."}),
"prompt": ("STRING", {"multiline": True, "default": ""}),
}}
def encode(self, clip, image, prompt):
return super().encode(clip, prompt, image=image, grounding_px=768)
class Krea2TwoImgPromptMXD(Krea2EditGroundedEncodeMXD):
TITLE = "Krea2 2 Img Edit Prompt MXD"
DESCRIPTION = "Encode an edit instruction with both images in the same order as the two-image editor. Image grounding is fixed at 768px."
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"clip": ("CLIP",),
"img_base": ("IMAGE", {"tooltip": "The base image, or Image 1 when using the advanced editor."}),
"img_ref": ("IMAGE", {"tooltip": "The reference image, or Image 2 when using the advanced editor."}),
"prompt": ("STRING", {"multiline": True, "default": ""}),
}}
def encode(self, clip, img_base, img_ref, prompt):
return super().encode(clip, prompt, image=img_base, image_b=img_ref, grounding_px=768)
NODE_CLASS_MAPPINGS = {
"Krea2EditModelPatchMXD": Krea2EditModelPatchMXD,
"Krea2EditGroundedEncodeMXD": Krea2EditGroundedEncodeMXD,
"Krea2ImgEditMXD": Krea2ImgEditMXD,
"Krea2ImgEditAdvMXD": Krea2ImgEditAdvMXD,
"Krea2TwoImgEditMXD": Krea2TwoImgEditMXD,
"Krea2EditAdvancedMXD": Krea2EditAdvancedMXD,
"Krea2ImgPromptMXD": Krea2ImgPromptMXD,
"Krea2TwoImgPromptMXD": Krea2TwoImgPromptMXD,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Krea2EditModelPatchMXD": "Krea 2 Edit MXD",
"Krea2EditGroundedEncodeMXD": "Krea2 Edit MXD",
}
NODE_DISPLAY_NAME_MAPPINGS = {key: cls.TITLE for key, cls in NODE_CLASS_MAPPINGS.items()}
+54 -1
View File
@@ -4,10 +4,12 @@ Adapted from ComfyUI-Krea2Edit by Conrad Locke:
https://github.com/lbouaraba/comfyui-krea2edit
Upstream revision: 86f886dac23013d88996e3a2e99093ba44d322fb
V2 reference placement: 13ef60f20828949becada815d3cbc371eb5ccaab
Upstream license: Apache License 2.0 (see THIRD_PARTY_LICENSES.md).
This file was modified for ComfyUI-MaxedOut by extracting only the image-fit
and diffusion-forward helpers needed by the MXD nodes. The upstream public
and diffusion-forward helpers needed by the MXD nodes, with per-image V2
reference placement. The upstream public
nodes, workflow, and packaging code are intentionally not duplicated.
"""
@@ -21,6 +23,30 @@ import comfy.ldm.common_dit
from comfy.ldm.flux.layers import timestep_embedding
REF_POSITIONS = ["aligned (trained)", "beside (right of target)", "bag of features"]
def _place_refs(reference_ids, modes, height, width):
"""Place references in RoPE space, preserving their frame indices."""
placed = []
next_column = float(width)
for ids, mode in zip(reference_ids, modes):
if mode == REF_POSITIONS[0]:
placed.append(ids)
continue
ids = ids.clone()
if mode == REF_POSITIONS[1]:
ids[..., 2] = ids[..., 2] - ids[..., 2].min() + next_column
next_column = float(ids[..., 2].max()) + 1.0
elif mode == REF_POSITIONS[2]:
ids[..., 1] = (height - 1) / 2.0
ids[..., 2] = (width - 1) / 2.0
else:
raise ValueError(f"Unknown reference placement: {mode!r}")
placed.append(ids)
return placed
def _imgids(bs, frame, height, width, device):
ids = torch.zeros(height, width, 3, device=device, dtype=torch.float32)
ids[..., 0] = frame
@@ -131,6 +157,26 @@ def fit_encode_image(image, vae, height, width, cache, key, fit_mode="crop"):
return latent
def fit_boost_mask(mask, image, height, width):
"""Apply the same crop as fit_encode_image before its token-grid resize."""
if mask is None:
return None
if mask.ndim == 2:
mask = mask.unsqueeze(0)
ih, iw = image.shape[1:3]
mask = F.interpolate(mask[:1, None].float(), size=(ih, iw), mode="nearest")
scale = min(height / ih, width / iw)
if ih * scale >= height * 0.92 and iw * scale >= width * 0.92:
fill = max(height / ih, width / iw)
ch, cw = min(ih, round(height / fill)), min(iw, round(width / fill))
else:
nh = min(max(16, int(ih * scale) // 16 * 16), max(16, height // 16 * 16))
nw = min(max(16, int(iw * scale) // 16 * 16), max(16, width // 16 * 16))
ch, cw = min(ih, max(1, round(nh / scale))), min(iw, max(1, round(nw / scale)))
top, left = (ih - ch) // 2, (iw - cw) // 2
return mask[:, 0, top:top + ch, left:left + cw]
def _ref_attn_bias(
boosts,
boost_masks,
@@ -187,6 +233,7 @@ def krea2_edit_forward(
image_boost_masks=None,
ref_native=False,
pos_mode="anchor",
image_positions=None,
):
"""Run Krea 2 with clean source blocks prepended to the noisy target."""
patch = model.patch
@@ -286,6 +333,12 @@ def krea2_edit_forward(
)
for index, (source_h, source_w) in enumerate(source_grids)
]
reference_ids = _place_refs(
reference_ids,
image_positions or [REF_POSITIONS[0]] * len(reference_ids),
grid_h,
grid_w,
)
positions = torch.cat(
[
torch.zeros(
+47
View File
@@ -1,6 +1,7 @@
"""Image scaling to model-safe megapixel targets + resolution matchers.
Registered nodes:
DownscaleImageToTotalPixelsMXD Downscale Image MXD
Image Scale To Total Pixels (SDXL Safe) Scale SDXL Image MXD
Flux Image Scale To Total Pixels (Flux Safe) Scale Flux Image MXD
FluxResolutionMatcher Flux Resolution Matcher MXD
@@ -11,6 +12,50 @@ from __future__ import annotations
import math, comfy, comfy.utils, torch
from .latents import SdxlEmptyLatentImage, ResolutionSelectorEmptyLatentImage
########################################################################################################################
class DownscaleImageToTotalPixelsMXD:
DESCRIPTION = """Reduce images toward a megapixel target without ever increasing either dimension. Aligns width and height to resolution_steps, including images already below the target. Images with either dimension smaller than one step pass through unchanged."""
upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"]
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"image": ("IMAGE",),
"upscale_method": (cls.upscale_methods, {"default": "lanczos"}),
"megapixels": ("FLOAT", {
"default": 1.0, "min": 0.01, "max": 16.0, "step": 0.01,
"tooltip": "Downscale toward this target; smaller images are only aligned to resolution_steps. Uses 1024 × 1024 pixels per MP, like core.",
}),
"resolution_steps": ("INT", {
"default": 1, "min": 1, "max": 256, "advanced": True,
"tooltip": "Align both dimensions to this multiple without enlarging. Rounding may put the result slightly above the megapixel target.",
}),
}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "downscale"
CATEGORY = "MXD/Upscaling"
def downscale(self, image, upscale_method, megapixels, resolution_steps=1):
_, height, width, _ = image.shape
# No positive aligned size fits when a source dimension is below a step.
if min(height, width) < resolution_steps:
return (image,)
scale = min(1.0, math.sqrt(megapixels * 1024 * 1024 / (width * height)))
def aligned_size(size):
nearest = max(1, round(size * scale / resolution_steps))
return min(nearest, size // resolution_steps) * resolution_steps
new_width, new_height = aligned_size(width), aligned_size(height)
if (new_width, new_height) == (width, height):
return (image,)
samples = comfy.utils.common_upscale(
image.movedim(-1, 1), new_width, new_height, upscale_method, "disabled"
)
return (samples.movedim(1, -1),)
########################################################################################################################
# Image Scale To Total Pixels (SDXL Safe)
class SDXLImageScaleToTotalPixelsSafe:
@@ -334,6 +379,7 @@ class ResolutionSelectorMXD:
########################################################################################################################
NODE_CLASS_MAPPINGS = {
"DownscaleImageToTotalPixelsMXD": DownscaleImageToTotalPixelsMXD,
"Image Scale To Total Pixels (SDXL Safe)": SDXLImageScaleToTotalPixelsSafe,
"Flux Image Scale To Total Pixels (Flux Safe)": FluxImageScaleToTotalPixelsSafe,
"FluxResolutionMatcher": FluxResolutionMatcher,
@@ -342,6 +388,7 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DownscaleImageToTotalPixelsMXD": "Downscale Image MXD",
"Image Scale To Total Pixels (SDXL Safe)": "Scale SDXL Image MXD",
"Flux Image Scale To Total Pixels (Flux Safe)": "Scale Flux Image MXD",
"FluxResolutionMatcher": "Flux Resolution Matcher MXD",
+38 -1
View File
@@ -23,6 +23,7 @@ import torch
import folder_paths
import comfy.model_management
from comfy.utils import ProgressBar
from comfy.cli_args import args
# Comfy API
@@ -49,6 +50,29 @@ from .latent_io import _merge_prior_workflow_into_current
VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
class _VideoEncodingFrames:
"""Report frames consumed by core's encoder without copying the image batch.
Used only for saving: the returned VIDEO retains its original image tensor.
Core's VideoFromComponents encoder reads shape and iterates these frames.
"""
def __init__(self, images, progress):
self.images = images
self.shape = images.shape
self.progress = progress
def __len__(self):
return len(self.images)
def __iter__(self):
for frame in self.images:
comfy.model_management.throw_exception_if_processing_interrupted()
yield frame
# Resuming the iterator means core has encoded/muxed this frame.
self.progress.update(1)
def _frame_window(total, count, offset, mode):
offset = max(1, min(offset, total))
count = max(1, min(count, total - offset + 1))
@@ -684,13 +708,26 @@ if HAVE_COMFY_API:
saved_metadata = metadata
file = f"{filename}_{counter:05}_.{VideoContainer.get_extension(format)}"
video.save_to(
# Reserve the last step for encoder flush, audio and container close.
# Keep the progress adapter out of the VIDEO output and downstream nodes.
progress = ProgressBar(len(images) + 1)
progress.update_absolute(0)
saving_video = VideoFromComponents(
VideoComponents(
images=_VideoEncodingFrames(images, progress),
audio=audio,
frame_rate=Fraction(fps),
),
bit_depth=bit_depth,
)
saving_video.save_to(
os.path.join(full_output_folder, file),
format=VideoContainer(format),
codec=codec_name,
metadata=saved_metadata,
crf=encoding.get("crf"),
)
progress.update_absolute(progress.total)
return io.NodeOutput(
video,
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI-MaxedOut"
description = "Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, Wan 2.2, etc.)"
version = "3.3.0"
version = "3.3.1"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
+33
View File
@@ -0,0 +1,33 @@
"""Release checks using synthetic images only; no model weights or personal media."""
import ast
from pathlib import Path
import torch
import torch.nn.functional as F
def functions(path,names):
tree=ast.parse(Path(path).read_text(encoding='utf-8'))
return compile(ast.Module(body=[n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name in names],type_ignores=[]),path,'exec')
ns={'F':F,'torch':torch}
exec(functions('nodes/krea2_edit_core.py',{'fit_encode_image','fit_boost_mask'}),ns)
class VAE:
def encode(self,image):
self.image=image
return image.movedim(-1,1)
for h,w,th,tw in [(100,100,96,88),(120,240,96,96),(240,120,96,96),(96,96,96,96)]:
image=torch.zeros(1,h,w,3);image[:,h//4:3*h//4,w//4:3*w//4]=1
mask=image[...,0].clone();vae=VAE()
ns['fit_encode_image'](image,vae,th//8,tw//8,{},(0,),'fit')
cropped=ns['fit_boost_mask'](mask,image,th,tw)
aligned=F.interpolate(cropped[:,None],size=vae.image.shape[1:3],mode='bicubic',antialias=True).clamp(0,1)[:,0]
torch.testing.assert_close(aligned,vae.image[...,0])
assert ns['fit_boost_mask'](None,image,th,tw) is None
print('PASS: boost-mask crop matches encoded image for square, near-square, portrait and landscape references')
# The original published model class remains unchanged.
import subprocess
old=ast.parse(subprocess.check_output(['git','show','HEAD:nodes/krea.py']).decode());new=ast.parse(Path('nodes/krea.py').read_text())
get=lambda tree,name:next(n for n in tree.body if isinstance(n,ast.ClassDef) and n.name==name)
assert ast.dump(get(old,'Krea2EditModelPatchMXD'))==ast.dump(get(new,'Krea2EditModelPatchMXD'))
for tree in [old,new]:
c=get(tree,'Krea2EditGroundedEncodeMXD');tree.schema=ast.dump(next(n for n in c.body if isinstance(n,ast.FunctionDef) and n.name=='INPUT_TYPES'))
assert old.schema==new.schema
print('PASS: published legacy model and legacy prompt input schema preserved')
+87
View File
@@ -0,0 +1,87 @@
// Run with node scripts/check_set_get.mjs. No server or personal files needed.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync(new URL('../web/nodes/mxd_set_get.js', import.meta.url), 'utf8')
.replace(/^import .*;\r?\n/gm, '');
const run = context => vm.runInContext(`(async () => {${source}\n})()`, context);
function environment(extensions = [], existing = {}) {
const registered = [];
class Node {
constructor() { this.properties = {}; this.inputs = []; this.outputs = []; this.widgets = []; }
addInput(name, type) { this.inputs.push({ name, type, link: null }); }
addOutput(name, type) { this.outputs.push({ name, type, links: [] }); }
addWidget(type, name, value, callback, options) {
const widget = { type, name, value, callback, options };
this.widgets.push(widget); return widget;
}
serialize() { return { type: this.type, widgets_values: this.widgets.map(w => w.value) }; }
}
const LiteGraph = {
LGraphNode: Node, registered_node_types: { ...existing },
registerNodeType(key, cls) {
assert.ok(!this.registered_node_types[key], `Duplicate ${key}`);
cls.prototype.type = key; this.registered_node_types[key] = cls;
}
};
const context = vm.createContext({
LiteGraph, window: {}, console, setTimeout, clearTimeout,
api: { getExtensions: async () => extensions },
app: {
registerExtension: ext => registered.push(ext),
canvas: { setDirty() {} },
ui: { settings: { getSettingValue() { return false; } } }
}
});
return { context, registered, LiteGraph };
}
// Detection is based on the served file list, even before KJ runs its code.
for (const url of [
'/extensions/comfyui-kjnodes/js/setgetnodes.js',
'/extensions/renamed%20KJ%20pack/js/setgetnodes.js?version=2'
]) {
const env = environment([url]);
await run(env.context);
assert.equal(env.registered.length, 0);
assert.equal(env.context.window.kjNodes, undefined);
}
const existing = environment([], { SetNode: class {}, GetNode: class {} });
await run(existing.context);
assert.equal(existing.registered.length, 0);
const failed = environment();
failed.context.api.getExtensions = async () => { throw new Error('simulated offline'); };
failed.context.console = { warn() {} };
await run(failed.context);
assert.equal(failed.registered.length, 0);
// Two MXD installations racing must install only one provider.
const env = environment(['/extensions/ComfyUI-MaxedOut/nodes/mxd_set_get.js']);
await Promise.all([run(env.context), run(env.context)]);
assert.equal(new Set(env.registered.map(e => e.name)).size, env.registered.length);
assert.equal(env.registered.filter(e => e.name === 'KJNodes.SetNode').length, 1);
for (const ext of env.registered) ext.registerCustomNodes?.();
assert.deepEqual(Object.keys(env.LiteGraph.registered_node_types).sort(), ['GetNode', 'SetNode']);
const { SetNode, GetNode } = env.LiteGraph.registered_node_types;
const setter = new SetNode();
const getter = new GetNode();
setter.widgets[0].value = getter.widgets[0].value = 'VAE';
setter.inputs[0] = { name: 'VAE', type: 'VAE', link: 7 };
const upstream = { id: 1, type: 'VAELoader' };
const link = { id: 7, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0, type: 'VAE' };
const graph = { _nodes: [upstream, setter, getter], _links: { 7: link }, getNodeById: id => id === 1 ? upstream : undefined };
setter.graph = getter.graph = graph;
getter.onRename();
assert.equal(getter.findSetter(graph), setter);
assert.equal(getter.outputs[0].type, 'VAE');
assert.equal(getter.getInputLink(0), link);
assert.equal(getter.serialize().widgets_values[0], 'VAE');
// Renaming a setter must update the paired getter's saved name.
setter.properties.previousName = 'VAE';
setter.widgets[0].value = 'Shared VAE';
setter.update();
assert.equal(getter.widgets[0].value, 'Shared VAE');
assert.equal(getter.getInputLink(0), link);
console.log('OK: Set/Get provider coexistence, singleton, routing, serialization and rename checks');
+2 -2
View File
@@ -49,7 +49,7 @@ def _norm_combo(values, force_dynamic=False):
and not any(_PATHY.search(v) for v in vals)
):
return {"combo": vals}
return {"combo": "nonempty" if vals else "empty", "dynamic": True}
return {"combo": "dynamic", "dynamic": True}
def _norm(obj, in_type_position=False):
@@ -197,7 +197,7 @@ def dump(comfy_root, out_path):
errors = [k for k, v in result["nodes"].items() if "__error__" in v.get("input_types", {})]
if errors:
print(f"[dump_node_schema] WARNING: INPUT_TYPES failed for: {errors}")
return 0
return 1 if errors or not class_map else 0
def compare(a_path, b_path):
+537 -15
View File
@@ -1,5 +1,5 @@
{
"node_count": 70,
"node_count": 80,
"nodes": {
"BboxDetectorCombinedBatchMXD": {
"category": "MXD/Detector",
@@ -175,7 +175,8 @@
"auto",
"mp4",
"mkv",
"webm"
"webm",
"mov"
],
"tooltip": "The format to save the video as."
},
@@ -231,6 +232,58 @@
"IMAGE"
]
},
"DownscaleImageToTotalPixelsMXD": {
"category": "MXD/Upscaling",
"class": "DownscaleImageToTotalPixelsMXD",
"display_name": "Downscale Image MXD",
"function": "downscale",
"input_types": {
"required": {
"image": {
"type": "IMAGE"
},
"megapixels": {
"config": {
"default": 1.0,
"max": 16.0,
"min": 0.01,
"step": 0.01,
"tooltip": "Downscale toward this target; smaller images are only aligned to resolution_steps. Uses 1024 × 1024 pixels per MP, like core."
},
"type": "FLOAT"
},
"resolution_steps": {
"config": {
"advanced": true,
"default": 1,
"max": 256,
"min": 1,
"tooltip": "Align both dimensions to this multiple without enlarging. Rounding may put the result slightly above the megapixel target."
},
"type": "INT"
},
"upscale_method": {
"config": {
"default": "lanczos"
},
"type": {
"combo": [
"nearest-exact",
"bilinear",
"area",
"bicubic",
"lanczos"
]
}
}
}
},
"output_node": false,
"return_names": null,
"return_types": [
"IMAGE"
]
},
"Extract Workflow From Image MXD": {
"category": "MXD/Image",
"class": "ExtractWorkflowFromImageMXD",
@@ -620,6 +673,75 @@
"IMAGE"
]
},
"GIF Stitch MXD": {
"category": "Maxed Out/GIF",
"class": "GifStitchMXD",
"display_name": "GIF Stitch MXD",
"function": "stitch",
"input_types": {
"optional": {
"gif2": {
"type": "MXD_GIF"
}
},
"required": {
"direction": {
"config": {
"default": "right"
},
"type": {
"combo": [
"right",
"down",
"left",
"up"
]
}
},
"gif1": {
"type": "MXD_GIF"
},
"match_image_size": {
"config": {
"default": true
},
"type": "BOOLEAN"
},
"spacing_color": {
"config": {
"default": "white"
},
"type": {
"combo": [
"white",
"black",
"red",
"green",
"blue"
]
}
},
"spacing_width": {
"config": {
"default": 0,
"max": 1024,
"min": 0,
"step": 2
},
"type": "INT"
}
}
},
"output_node": false,
"return_names": [
"gif",
"images"
],
"return_types": [
"MXD_GIF",
"IMAGE"
]
},
"Get Latent Size": {
"category": "MXD/Latent",
"class": "GetLatentSizeMXD",
@@ -820,10 +942,110 @@
"IMAGE"
]
},
"Krea2EditAdvancedMXD": {
"category": "MXD/Krea",
"class": "Krea2EditAdvancedMXD",
"display_name": "Krea2 2 Img Edit Adv MXD",
"function": "patch",
"input_types": {
"optional": {
"image_1_boost": {
"config": {
"default": 1.0,
"max": 1000.0,
"min": 0.0,
"round": 0.001,
"step": 0.01,
"tooltip": "Attention strength for Image 1. 1.0 = unchanged."
},
"type": "FLOAT"
},
"image_1_boost_mask": {
"config": {
"tooltip": "Limit the boost for Image 1 to white areas. This is not an edit or inpainting mask."
},
"type": "MASK"
},
"image_1_position": {
"config": {
"default": "Base",
"tooltip": "Base: follow this image's layout and framing. Ref: use its appearance with freer positioning. This does not change which image supplies source_latent."
},
"type": {
"combo": [
"Base",
"Ref"
]
}
},
"image_2_boost": {
"config": {
"default": 2.0,
"max": 1000.0,
"min": 0.0,
"round": 0.001,
"step": 0.01,
"tooltip": "Attention strength for Image 2. 1.0 = unchanged."
},
"type": "FLOAT"
},
"image_2_boost_mask": {
"config": {
"tooltip": "Limit the boost for Image 2 to white areas. This is not an edit or inpainting mask."
},
"type": "MASK"
},
"image_2_position": {
"config": {
"default": "Ref",
"tooltip": "Base: follow this image's layout and framing. Ref: use its appearance with freer positioning. This does not change which image supplies source_latent."
},
"type": {
"combo": [
"Base",
"Ref"
]
}
}
},
"required": {
"image_1": {
"config": {
"tooltip": "Primary reference image, usually the scene or image to edit."
},
"type": "IMAGE"
},
"image_2": {
"config": {
"tooltip": "Optional second reference, usually a subject to place into Image 1."
},
"type": "IMAGE"
},
"model": {
"type": "MODEL"
},
"vae": {
"config": {
"tooltip": "Required. VAE used to encode the reference images."
},
"type": "VAE"
}
}
},
"output_node": false,
"return_names": [
"model",
"source_latent"
],
"return_types": [
"MODEL",
"LATENT"
]
},
"Krea2EditGroundedEncodeMXD": {
"category": "MXD/Krea",
"class": "Krea2EditGroundedEncodeMXD",
"display_name": "Krea2 Edit MXD",
"display_name": "Krea2 Edit Prompt MXD",
"function": "encode",
"input_types": {
"optional": {
@@ -942,6 +1164,250 @@
"LATENT"
]
},
"Krea2ImgEditAdvMXD": {
"category": "MXD/Krea",
"class": "Krea2ImgEditAdvMXD",
"display_name": "Krea2 Img Edit Adv MXD",
"function": "patch",
"input_types": {
"optional": {
"boost": {
"config": {
"default": 1.0,
"max": 1000.0,
"min": 0.0,
"round": 0.001,
"step": 0.01,
"tooltip": "Attention strength for the image. 1.0 = unchanged."
},
"type": "FLOAT"
},
"boost_mask": {
"config": {
"tooltip": "Limit the boost for the image to white areas. This is not an edit or inpainting mask."
},
"type": "MASK"
},
"position": {
"config": {
"default": "Base",
"tooltip": "Base: follow this image's layout and framing. Ref: use its appearance with freer positioning. This does not change which image supplies source_latent."
},
"type": {
"combo": [
"Base",
"Ref"
]
}
}
},
"required": {
"image": {
"config": {
"tooltip": "The image to edit; supplies source_latent."
},
"type": "IMAGE"
},
"model": {
"type": "MODEL"
},
"vae": {
"config": {
"tooltip": "Required. VAE used to encode the reference images."
},
"type": "VAE"
}
}
},
"output_node": false,
"return_names": [
"model",
"source_latent"
],
"return_types": [
"MODEL",
"LATENT"
]
},
"Krea2ImgEditMXD": {
"category": "MXD/Krea",
"class": "Krea2ImgEditMXD",
"display_name": "Krea2 Img Edit MXD",
"function": "patch",
"input_types": {
"optional": {
"boost": {
"config": {
"default": 1.0,
"max": 1000.0,
"min": 0.0,
"round": 0.001,
"step": 0.01,
"tooltip": "Attention strength for the image. 1.0 = unchanged."
},
"type": "FLOAT"
}
},
"required": {
"image": {
"config": {
"tooltip": "The image to edit; supplies source_latent."
},
"type": "IMAGE"
},
"model": {
"type": "MODEL"
},
"vae": {
"config": {
"tooltip": "Required. VAE used to encode the reference images."
},
"type": "VAE"
}
}
},
"output_node": false,
"return_names": [
"model",
"source_latent"
],
"return_types": [
"MODEL",
"LATENT"
]
},
"Krea2ImgPromptMXD": {
"category": "MXD/Krea",
"class": "Krea2ImgPromptMXD",
"display_name": "Krea2 Img Edit Prompt MXD",
"function": "encode",
"input_types": {
"required": {
"clip": {
"type": "CLIP"
},
"image": {
"config": {
"tooltip": "Connect the same image as the single-image edit node."
},
"type": "IMAGE"
},
"prompt": {
"config": {
"default": "",
"multiline": true
},
"type": "STRING"
}
}
},
"output_node": false,
"return_names": null,
"return_types": [
"CONDITIONING"
]
},
"Krea2TwoImgEditMXD": {
"category": "MXD/Krea",
"class": "Krea2TwoImgEditMXD",
"display_name": "Krea2 2 Img Edit MXD",
"function": "patch",
"input_types": {
"optional": {
"img_base_boost": {
"config": {
"default": 1.0,
"max": 1000.0,
"min": 0.0,
"round": 0.001,
"step": 0.01,
"tooltip": "Attention strength for the base image. 1.0 = unchanged."
},
"type": "FLOAT"
},
"img_ref_boost": {
"config": {
"default": 2.0,
"max": 1000.0,
"min": 0.0,
"round": 0.001,
"step": 0.01,
"tooltip": "Attention strength for the reference image. 1.0 = unchanged."
},
"type": "FLOAT"
}
},
"required": {
"img_base": {
"config": {
"tooltip": "Image to edit. Guides layout and framing; supplies source_latent."
},
"type": "IMAGE"
},
"img_ref": {
"config": {
"tooltip": "Appearance reference with freer placement than the base image."
},
"type": "IMAGE"
},
"model": {
"type": "MODEL"
},
"vae": {
"config": {
"tooltip": "Required. VAE used to encode the reference images."
},
"type": "VAE"
}
}
},
"output_node": false,
"return_names": [
"model",
"source_latent"
],
"return_types": [
"MODEL",
"LATENT"
]
},
"Krea2TwoImgPromptMXD": {
"category": "MXD/Krea",
"class": "Krea2TwoImgPromptMXD",
"display_name": "Krea2 2 Img Edit Prompt MXD",
"function": "encode",
"input_types": {
"required": {
"clip": {
"type": "CLIP"
},
"img_base": {
"config": {
"tooltip": "The base image, or Image 1 when using the advanced editor."
},
"type": "IMAGE"
},
"img_ref": {
"config": {
"tooltip": "The reference image, or Image 2 when using the advanced editor."
},
"type": "IMAGE"
},
"prompt": {
"config": {
"default": "",
"multiline": true
},
"type": "STRING"
}
}
},
"output_node": false,
"return_names": null,
"return_types": [
"CONDITIONING"
]
},
"KreaLayerVarianceMXD": {
"category": "MXD/conditioning",
"class": "KreaLayerVarianceMXD",
@@ -1346,6 +1812,36 @@
"STRING"
]
},
"Load GIF MXD": {
"category": "Maxed Out/GIF",
"class": "LoadGifMXD",
"display_name": "Load GIF MXD",
"function": "load",
"input_types": {
"required": {
"gif": {
"config": {
"animated_image_upload": true,
"default": "<dynamic-combo-default>",
"image_upload": true
},
"type": {
"combo": "dynamic",
"dynamic": true
}
}
}
},
"output_node": false,
"return_names": [
"gif",
"images"
],
"return_types": [
"MXD_GIF",
"IMAGE"
]
},
"Load Image Batch MXD": {
"category": "MXD/Image",
"class": "LoadImageBatchMXD",
@@ -1358,7 +1854,7 @@
"default": "<dynamic-combo-default>"
},
"type": {
"combo": "nonempty",
"combo": "dynamic",
"dynamic": true
}
},
@@ -1401,7 +1897,7 @@
"default": "<dynamic-combo-default>"
},
"type": {
"combo": "nonempty",
"combo": "dynamic",
"dynamic": true
}
},
@@ -1435,7 +1931,7 @@
"required": {
"ckpt_name": {
"type": {
"combo": "nonempty",
"combo": "dynamic",
"dynamic": true
}
}
@@ -1458,7 +1954,7 @@
"required": {
"image": {
"type": {
"combo": "nonempty",
"combo": "dynamic",
"dynamic": true
}
},
@@ -1511,7 +2007,7 @@
"image_upload": true
},
"type": {
"combo": "nonempty",
"combo": "dynamic",
"dynamic": true
}
}
@@ -1540,7 +2036,7 @@
"required": {
"latent": {
"type": {
"combo": "empty",
"combo": "dynamic",
"dynamic": true
}
},
@@ -1599,7 +2095,7 @@
"required": {
"latent": {
"type": {
"combo": "empty",
"combo": "dynamic",
"dynamic": true
}
},
@@ -1636,7 +2132,7 @@
"required": {
"latent": {
"type": {
"combo": "empty",
"combo": "dynamic",
"dynamic": true
}
},
@@ -1695,7 +2191,7 @@
"required": {
"subfolder": {
"type": {
"combo": "nonempty",
"combo": "dynamic",
"dynamic": true
}
}
@@ -1738,7 +2234,7 @@
"required": {
"subfolder": {
"type": {
"combo": "nonempty",
"combo": "dynamic",
"dynamic": true
}
}
@@ -1761,7 +2257,7 @@
"required": {
"subfolder": {
"type": {
"combo": "nonempty",
"combo": "dynamic",
"dynamic": true
}
}
@@ -1853,7 +2349,7 @@
},
"video": {
"type": {
"combo": "nonempty",
"combo": "dynamic",
"dynamic": true
}
}
@@ -2351,6 +2847,32 @@
"BOOLEAN"
]
},
"Save GIF MXD": {
"category": "Maxed Out/GIF",
"class": "SaveGifMXD",
"display_name": "Save GIF MXD",
"function": "save",
"input_types": {
"required": {
"filename_prefix": {
"config": {
"default": "MaxedOut/GIF"
},
"type": "STRING"
},
"gif": {
"type": "MXD_GIF"
}
}
},
"output_node": true,
"return_names": [
"gif"
],
"return_types": [
"MXD_GIF"
]
},
"Save Image MXD": {
"category": "MXD/Image",
"class": "SaveImage_MXD",
+1
View File
@@ -31,3 +31,4 @@ import './nodes/checkpoint_loader.js';
import './nodes/prompt_spellcheck.js';
import './nodes/video_preview.js';
import './nodes/live_preview_panel.js';
import './nodes/mxd_set_get.js';
+15
View File
@@ -183,6 +183,20 @@ function normalizeMenuCallbackValue(value) {
return value;
}
function positionChooserAfterLayout(menuEl, event) {
if (!menuEl?.isConnected || !Number.isFinite(event?.clientY)) return;
// ContextMenu positions the full, flat file list before we collapse folders.
// Re-anchor the finished tree now, rather than waiting for the first keystroke
// to make ComfyUI's filter correct the stale (often far too high) position.
const rect = menuEl.getBoundingClientRect();
const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
const top = Math.max(10, Math.min(event.clientY - 10, viewportHeight - rect.height - 10));
// Account for ContextMenu's zoom transform, including its transform origin.
const transformOffset = rect.top - (parseFloat(menuEl.style.top) || 0);
menuEl.style.top = `${top - transformOffset}px`;
}
/** Generic tree/search-enabled item chooser context menu, used for LoRAs, checkpoints, and
* the smart UNET/CLIP model lists. */
export async function showModelChooser(event, callback, parentMenu, items, title = "Choose an Item") {
@@ -203,6 +217,7 @@ export async function showModelChooser(event, callback, parentMenu, items, title
const menuEl = getContextMenuRootElement(menu);
decorateChooserAsTree(menuEl);
attachSmartChooserFilter(menuEl);
positionChooserAfterLayout(menuEl, event);
});
}
File diff suppressed because it is too large Load Diff