Add Krea2 Edit nodes & improve video/web tooling

Add Krea 2 Edit support (nodes/krea.py, nodes/krea2_edit_core.py) and vendor Apache-2.0 notice (third_party/ComfyUI-Krea2Edit/LICENSE + THIRD_PARTY_LICENSES.md). Harden Power LoRA loader validation and error behavior (loraloader_mxd/power_lora_loader_mxd.py + web power_lora_base UI error styling). Extend WAN 2.2 video features: flexible frame removal, LoadVideoComponents, CreateAndSaveVideo node, and keep backward-compatible LoadVideoMXD (nodes/wan22/video_ops.py). Improve web UX: smarter run_folder loop, robust zip workflow importer (web/nodes/run_folder.js, web/vendor/zip_loader/zip_loader.js, WEB.md). Bump package version and refresh node schema baseline.
This commit is contained in:
Maxed-Out-99
2026-09-19 00:39:23 -07:00
parent 48981e19d3
commit fe6cb32956
14 changed files with 1737 additions and 167 deletions
+7 -2
View File
@@ -58,6 +58,7 @@ Leave all three unset and nothing changes -- this is entirely opt-in.
| `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. |
| Prompt spellcheck | Right-click a misspelled word in any prompt box for suggestions. Works offline, no node to add. |
## Companion Packs
@@ -92,12 +93,16 @@ Huge thanks to these projects. I have learned a lot from them and built on many
Inspiration for LoRA Loader, Image/Video Comparer, and more.
- https://github.com/kijai/ComfyUI-KJNodes
Major reference and inspiration for my own nodes.
- https://github.com/lbouaraba/comfyui-krea2edit
The Krea 2 Edit MXD implementation is adapted from Conrad Locke's
Apache-2.0-licensed ComfyUI-Krea2Edit project.
If you star this repo, definitely consider starring theirs too.
## License
MIT — see [LICENSE](LICENSE). Vendored code under `web/vendor/` keeps its own
licenses, carried alongside it in that folder.
MIT — see [LICENSE](LICENSE). Third-party code keeps its original license; see
[THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and the licenses carried
alongside vendored web code.
This pack has no pip dependencies — everything it needs ships with ComfyUI.
+24
View File
@@ -0,0 +1,24 @@
# Third-party licenses
## ComfyUI-Krea2Edit
`nodes/krea2_edit_core.py` is adapted from
[ComfyUI-Krea2Edit](https://github.com/lbouaraba/comfyui-krea2edit) by Conrad
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.
The complete license text is included at
`third_party/ComfyUI-Krea2Edit/LICENSE`.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
<http://www.apache.org/licenses/LICENSE-2.0>
Unless required by applicable law or agreed to in writing, software
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.
+79 -16
View File
@@ -60,10 +60,59 @@ class MxdPowerLoraLoader:
except (TypeError, ValueError):
return float(default)
@classmethod
def VALIDATE_INPUTS(cls, input_types, **kwargs): # pylint: disable=invalid-name
"""Reject missing enabled LoRAs during prompt validation, before execution."""
clip_connected = "clip" in input_types
model_connected = "model" in input_types
lora_paths = None
for key, value in kwargs.items():
if not key.upper().startswith("LORA_"):
continue
if not isinstance(value, dict):
return f'{NODE_NAME}: malformed LoRA input "{key}" (expected object).'
if not all(field in value for field in ("on", "lora", "strength")):
if cls._coerce_bool(value.get("on"), default=False):
return f'{NODE_NAME}: malformed LoRA input "{key}" (missing fields).'
continue
strength_model = cls._coerce_float(value.get("strength"), default=0.0)
strength_clip = (
cls._coerce_float(value.get("strengthTwo"), default=strength_model)
if clip_connected
else 0.0
)
if not cls._coerce_bool(value.get("on"), default=False):
continue
if strength_model == 0.0 and strength_clip == 0.0:
continue
lora_name = str(value.get("lora") or "").strip()
if not lora_name:
return f'{NODE_NAME}: enabled LoRA slot "{key}" has an empty filename.'
if not model_connected:
return f'{NODE_NAME}: LoRA "{lora_name}" is enabled but no MODEL is connected.'
if lora_paths is None:
lora_paths = folder_paths.get_filename_list("loras")
if get_lora_by_filename(lora_name, lora_paths=lora_paths, log_node=None) is None:
return (
f'{NODE_NAME}: LoRA not found: "{lora_name}". '
"Choose an installed LoRA or turn this row off."
)
return True
def _apply_lora_without_clip(self, model, lora, strength_model, strength_clip):
lora_path = folder_paths.get_full_path("loras", lora)
if not lora_path:
return model
# Match stock ComfyUI: missing file must hard-fail, not silently no-op.
get_path = getattr(folder_paths, "get_full_path_or_raise", None)
if get_path is not None:
lora_path = get_path("loras", lora)
else:
lora_path = folder_paths.get_full_path("loras", lora)
if not lora_path:
raise FileNotFoundError(f'LoRA not found: "{lora}"')
loaded_lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
model, _ = comfy.sd.load_lora_for_models(model, None, loaded_lora, strength_model, strength_clip)
return model
@@ -74,10 +123,12 @@ class MxdPowerLoraLoader:
if not key.startswith("LORA_"):
continue
if not isinstance(value, dict):
log_node_warn(NODE_NAME, f'Skipping malformed LoRA input "{key}" (expected object).')
continue
# Disabled/empty UI slots can arrive weirdly — only soft-skip junk that is off/empty.
# Anything clearly toggled on must hard-fail like stock Loaders.
raise ValueError(f'{NODE_NAME}: malformed LoRA input "{key}" (expected object).')
if not all(k in value for k in ("on", "lora", "strength")):
log_node_warn(NODE_NAME, f'Skipping malformed LoRA input "{key}" (missing fields).')
if self._coerce_bool(value.get("on"), default=False):
raise ValueError(f'{NODE_NAME}: malformed LoRA input "{key}" (missing fields).')
continue
strength_model = self._coerce_float(value.get("strength"), default=0.0)
@@ -90,22 +141,34 @@ class MxdPowerLoraLoader:
else:
strength_clip = self._coerce_float(strength_clip_raw, default=strength_model)
# Off / zero strength = intentionally unused slot (same as leaving a stock loader unused)
if not self._coerce_bool(value.get("on"), default=False):
continue
if strength_model == 0.0 and strength_clip == 0.0:
continue
lora = get_lora_by_filename(value["lora"], log_node=self.NAME)
if model is None or lora is None:
continue
lora_name = value.get("lora") or ""
if not str(lora_name).strip():
raise FileNotFoundError(f'{NODE_NAME}: enabled LoRA slot has empty filename.')
try:
if clip is None:
model = self._apply_lora_without_clip(model, lora, strength_model, strength_clip)
else:
model, clip = LoraLoader().load_lora(model, clip, lora, strength_model, strength_clip)
except Exception as exc:
log_node_warn(NODE_NAME, f'Failed to apply LoRA "{value.get("lora")}" ({exc}). Skipping.')
if model is None:
raise RuntimeError(
f'{NODE_NAME}: LoRA "{lora_name}" is enabled but no MODEL is connected.'
)
lora = get_lora_by_filename(lora_name, log_node=self.NAME)
if lora is None:
# Stock Load LoRA / Checkpoint behavior: missing file aborts the prompt.
raise FileNotFoundError(
f'{NODE_NAME}: LoRA not found: "{lora_name}". '
f'Fix the slot or turn it off — refusing to continue silently.'
)
# Do not swallow apply errors — same as stock LoraLoader.
if clip is None:
model = self._apply_lora_without_clip(model, lora, strength_model, strength_clip)
else:
model, clip = LoraLoader().load_lora(model, clip, lora, strength_model, strength_clip)
return (model, clip)
+1
View File
@@ -21,6 +21,7 @@ for _name in (
"latents",
"resolution",
"prompts",
"krea",
"masks",
"media_io",
"comparers",
+266
View File
@@ -0,0 +1,266 @@
"""Krea 2 editing nodes.
Registered nodes:
Krea2EditModelPatchMXD Krea 2 Edit MXD
Krea2EditGroundedEncodeMXD Krea2 Edit MXD
"""
import comfy.patcher_extension
import comfy.utils
from .krea2_edit_core import fit_encode_image, krea2_edit_forward
class Krea2EditModelPatchMXD:
TITLE = "Krea 2 Edit MXD"
CATEGORY = "MXD/Krea"
DESCRIPTION = (
"Adds the Krea 2 edit source-preservation path with independent boost "
"and mask controls for each reference image."
)
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."},
),
},
}
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,
):
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]
if image_2 is not None:
images.append(image_2)
boosts.append(image_2_boost)
boost_masks.append(image_2_boost_mask)
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=boost_masks,
ref_native=True,
pos_mode="stride1",
)
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)
class Krea2EditGroundedEncodeMXD:
"""Encode a Krea 2 edit instruction together with its reference image."""
TITLE = "Krea2 Edit MXD"
CATEGORY = "MXD/Krea"
DESCRIPTION = (
"Encodes the edit instruction grounded on the source image using the "
"training-matched Krea 2 semantic path."
)
DEFAULT_SYSTEM = (
"Describe the image by detailing the color, shape, size, texture, "
"quantity, text, spatial relationships of the objects and background:"
)
RETURN_TYPES = ("CONDITIONING",)
FUNCTION = "encode"
@classmethod
def _template(cls, image_count):
vision_tokens = "<|vision_start|><|image_pad|><|vision_end|>" * image_count
return (
"<|im_start|>system\n"
+ cls.DEFAULT_SYSTEM
+ "<|im_end|>\n<|im_start|>user\n"
+ vision_tokens
+ "{}<|im_end|>\n<|im_start|>assistant\n"
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clip": ("CLIP",),
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"image": ("IMAGE",),
"image_b": (
"IMAGE",
{
"tooltip": (
"Optional second reference (subject) for multi-reference "
"LoRAs; the first image is the scene."
)
},
),
"grounding_px": (
"INT",
{
"default": 768,
"min": 0,
"max": 4096,
"step": 64,
"tooltip": "Maximum longest side fed to Qwen3-VL; 0 uses native resolution.",
},
),
},
}
@staticmethod
def _prep(image, grounding_px):
samples = image.movedim(-1, 1) # B,H,W,C -> B,C,H,W
height, width = samples.shape[2], samples.shape[3]
if grounding_px and max(height, width) > grounding_px:
scale = grounding_px / max(height, width)
samples = comfy.utils.common_upscale(
samples,
round(width * scale),
round(height * scale),
"area",
"disabled",
)
return samples.movedim(1, -1)[:, :, :, :3]
def encode(self, clip, prompt, image=None, image_b=None, grounding_px=768):
if image is None:
tokens = clip.tokenize(prompt)
return (clip.encode_from_tokens_scheduled(tokens),)
images = [self._prep(image, grounding_px)]
if image_b is not None:
images.append(self._prep(image_b, grounding_px))
tokens = clip.tokenize(
prompt,
images=images,
llama_template=self._template(len(images)),
)
return (clip.encode_from_tokens_scheduled(tokens),)
NODE_CLASS_MAPPINGS = {
"Krea2EditModelPatchMXD": Krea2EditModelPatchMXD,
"Krea2EditGroundedEncodeMXD": Krea2EditGroundedEncodeMXD,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Krea2EditModelPatchMXD": "Krea 2 Edit MXD",
"Krea2EditGroundedEncodeMXD": "Krea2 Edit MXD",
}
+346
View File
@@ -0,0 +1,346 @@
"""Internal Krea 2 Edit implementation used by the MXD wrapper nodes.
Adapted from ComfyUI-Krea2Edit by Conrad Locke:
https://github.com/lbouaraba/comfyui-krea2edit
Upstream revision: 86f886dac23013d88996e3a2e99093ba44d322fb
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
nodes, workflow, and packaging code are intentionally not duplicated.
"""
import math
import torch
import torch.nn.functional as F
from einops import rearrange
import comfy.ldm.common_dit
from comfy.ldm.flux.layers import timestep_embedding
def _imgids(bs, frame, height, width, device):
ids = torch.zeros(height, width, 3, device=device, dtype=torch.float32)
ids[..., 0] = frame
ids[..., 1] = torch.arange(height, device=device, dtype=torch.float32)[:, None]
ids[..., 2] = torch.arange(width, device=device, dtype=torch.float32)[None, :]
return ids.reshape(1, height * width, 3).repeat(bs, 1, 1)
def _imgids_offset(bs, frame, grid_h, grid_w, target_h, target_w, device):
"""Build stride-1 positions centered within the target token grid."""
off_h = max(0.0, (target_h - grid_h) / 2)
off_w = max(0.0, (target_w - grid_w) / 2)
ids = torch.zeros(grid_h, grid_w, 3, device=device, dtype=torch.float32)
ids[..., 0] = frame
ids[..., 1] = (
torch.arange(grid_h, device=device, dtype=torch.float32) + off_h
)[:, None]
ids[..., 2] = (
torch.arange(grid_w, device=device, dtype=torch.float32) + off_w
)[None, :]
return ids.reshape(1, grid_h * grid_w, 3).repeat(bs, 1, 1)
def _to_4d(value):
"""Convert (B,C,T,H,W) to (B*T,C,H,W); pass 4D tensors through."""
if value.ndim == 5:
batch, channels, frames, height, width = value.shape
return value.reshape(batch * frames, channels, height, width)
return value
def _fit_src(source, height, width):
"""Center-crop a source latent to the target aspect ratio, then resize."""
source_h, source_w = source.shape[-2:]
if (source_h, source_w) == (height, width):
return source
scale = max(height / source_h, width / source_w)
crop_h = min(source_h, int(round(height / scale)))
crop_w = min(source_w, int(round(width / scale)))
top = (source_h - crop_h) // 2
left = (source_w - crop_w) // 2
source = source[..., top : top + crop_h, left : left + crop_w]
return F.interpolate(source.float(), size=(height, width), mode="bilinear")
def fit_encode_image(image, vae, height, width, cache, key, fit_mode="crop"):
"""Fit an image in pixel space and VAE-encode it at the target grid."""
key = key + (fit_mode,)
if key in cache:
return cache[key]
print(
f"[Krea 2 Edit MXD] source mode={fit_mode} "
f"input={tuple(image.shape)} target_latent={height}x{width}",
flush=True,
)
pixel_h, pixel_w = height * 8, width * 8
source = image.movedim(-1, 1)
image_h, image_w = source.shape[-2:]
if fit_mode == "fit":
scale = min(pixel_h / image_h, pixel_w / image_w)
crop_tolerance = 0.08
if (
image_h * scale >= pixel_h * (1 - crop_tolerance)
and image_w * scale >= pixel_w * (1 - crop_tolerance)
):
fill_scale = max(pixel_h / image_h, pixel_w / image_w)
crop_h = min(image_h, int(round(pixel_h / fill_scale)))
crop_w = min(image_w, int(round(pixel_w / fill_scale)))
top = (image_h - crop_h) // 2
left = (image_w - crop_w) // 2
source = source[..., top : top + crop_h, left : left + crop_w]
new_h, new_w = pixel_h, pixel_w
else:
new_h = min(
max(16, int(image_h * scale) // 16 * 16),
max(16, pixel_h // 16 * 16),
)
new_w = min(
max(16, int(image_w * scale) // 16 * 16),
max(16, pixel_w // 16 * 16),
)
crop_h = min(image_h, max(1, int(round(new_h / scale))))
crop_w = min(image_w, max(1, int(round(new_w / scale))))
top = (image_h - crop_h) // 2
left = (image_w - crop_w) // 2
source = source[..., top : top + crop_h, left : left + crop_w]
source = F.interpolate(
source.float(), size=(new_h, new_w), mode="bicubic", antialias=True
)
latent = vae.encode(source.movedim(1, -1)[..., :3].clamp(0, 1))
cache[key] = latent
return latent
scale = max(pixel_h / image_h, pixel_w / image_w)
crop_h = min(image_h, int(round(pixel_h / scale)))
crop_w = min(image_w, int(round(pixel_w / scale)))
top = (image_h - crop_h) // 2
left = (image_w - crop_w) // 2
source = source[..., top : top + crop_h, left : left + crop_w]
source = F.interpolate(
source.float(), size=(pixel_h, pixel_w), mode="bicubic", antialias=True
)
latent = vae.encode(source.movedim(1, -1)[..., :3].clamp(0, 1))
cache[key] = latent
return latent
def _ref_attn_bias(
boosts,
boost_masks,
text_length,
source_lengths,
target_length,
mask_sizes,
device,
dtype,
):
"""Build the reference-fidelity attention bias."""
offsets = [text_length]
for source_length in source_lengths:
offsets.append(offsets[-1] + source_length)
target_start = offsets[-1]
total_length = target_start + target_length
bias = torch.zeros(
1, 1, total_length, total_length, device=device, dtype=dtype
)
for index, boost in enumerate(boosts):
if boost == 1.0:
continue
offset = offsets[index]
source_length = source_lengths[index]
boost_mask = boost_masks[index] if boost_masks is not None else None
if (
boost_mask is not None
and mask_sizes is not None
):
mask = boost_mask[:1]
if mask.ndim == 2:
mask = mask[None]
mask = F.interpolate(
mask[None].float(), size=mask_sizes[index], mode="area"
)[0, 0]
columns = offset + torch.nonzero(
mask.reshape(-1) > 0.5, as_tuple=True
)[0].to(device)
else:
columns = torch.arange(offset, offset + source_length, device=device)
bias[:, :, target_start:, columns] = math.log(max(boost, 1e-4))
return bias
def krea2_edit_forward(
model,
x,
timesteps,
context,
source_latent,
transformer_options,
image_boosts=None,
image_boost_masks=None,
ref_native=False,
pos_mode="anchor",
):
"""Run Krea 2 with clean source blocks prepended to the noisy target."""
patch = model.patch
temporal = x.ndim == 5
if temporal:
batch_5d, _channels_5d, frames_5d, height_5d, width_5d = x.shape
x = _to_4d(x)
batch_size, _channels, original_h, original_w = x.shape
x = comfy.ldm.common_dit.pad_to_patch_size(
x, (patch, patch), padding_mode="replicate"
)
height, width = x.shape[-2], x.shape[-1]
grid_h, grid_w = height // patch, width // patch
source_list = (
source_latent
if isinstance(source_latent, (list, tuple))
else [source_latent]
)
sources = []
for latent in source_list:
source = _to_4d(latent).to(x.device, x.dtype)
if source.shape[0] != batch_size:
source = source[:1].expand(batch_size, *source.shape[1:])
if not ref_native and source.shape[-2:] != (height, width):
source = _fit_src(source, height, width).to(x.dtype)
sources.append(
comfy.ldm.common_dit.pad_to_patch_size(
source, (patch, patch), padding_mode="replicate"
)
)
source_grids = [
(source.shape[-2] // patch, source.shape[-1] // patch)
for source in sources
]
context = model._unpack_context(context)
target_image = model.first(
rearrange(
x,
"b c (h ph) (w pw) -> b (h w) (c ph pw)",
ph=patch,
pw=patch,
)
)
source_images = [
model.first(
rearrange(
source,
"b c (h ph) (w pw) -> b (h w) (c ph pw)",
ph=patch,
pw=patch,
)
)
for source in sources
]
timestep = model.tmlp(
timestep_embedding(timesteps, model.tdim)
.unsqueeze(1)
.to(target_image.dtype)
)
timestep_vector = model.tproj(timestep)
context = model.txtfusion(
context, mask=None, transformer_options=transformer_options
)
context = model.txtmlp(context)
text_length = context.shape[1]
target_length = target_image.shape[1]
source_length = sum(image.shape[1] for image in source_images)
combined = torch.cat([context] + source_images + [target_image], dim=1)
if pos_mode == "stride1" and ref_native:
reference_ids = [
_imgids_offset(
batch_size,
index + 1,
source_h,
source_w,
grid_h,
grid_w,
combined.device,
)
for index, (source_h, source_w) in enumerate(source_grids)
]
else:
reference_ids = [
_imgids(
batch_size,
index + 1,
source_h,
source_w,
combined.device,
)
for index, (source_h, source_w) in enumerate(source_grids)
]
positions = torch.cat(
[
torch.zeros(
batch_size,
text_length,
3,
device=combined.device,
dtype=torch.float32,
)
]
+ reference_ids
+ [_imgids(batch_size, 0, grid_h, grid_w, combined.device)],
dim=1,
)
frequencies = model.pe_embedder(positions)
attention_bias = None
boosts = image_boosts or [1.0] * len(source_images)
if any(boost != 1.0 for boost in boosts):
attention_bias = _ref_attn_bias(
boosts,
image_boost_masks,
text_length,
[image.shape[1] for image in source_images],
target_length,
source_grids,
combined.device,
combined.dtype,
)
for block in model.blocks:
combined = block(
combined,
timestep_vector,
frequencies,
attention_bias,
transformer_options=transformer_options,
)
final = model.last(combined, timestep)
output = final[
:, text_length + source_length : text_length + source_length + target_length
]
output = rearrange(
output,
"b (h w) (c ph pw) -> b c (h ph) (w pw)",
h=grid_h,
w=grid_w,
ph=patch,
pw=patch,
c=model.channels,
)
output = output[:, :, :original_h, :original_w]
if temporal:
output = output.reshape(
batch_5d, frames_5d, model.channels, height_5d, width_5d
).movedim(1, 2)
return output
+248 -70
View File
@@ -2,20 +2,22 @@
Registered nodes (always):
Frames_Select_StartEnd_MXD Select Frames MXD
Frames_Remove_From_Start_MXD Remove Frames From Start MXD
Frames_Remove_From_Start_MXD Remove Frames MXD
GroupVideoFramesMXD Group Video Frames MXD
Registered nodes (only when HAVE_COMFY_API):
CombineVideos_MXD Combine Videos MXD
LoadVideoMXD Load Video MXD
SaveVideoMXD Save Video MXD (merges a prior stage's workflow
CreateAndSaveVideoMXD Save Video MXD (creates and saves in one node)
LoadVideoMXD Load Video MXD (also outputs images/audio/fps/
bit_depth, like Get Video Components, in one node)
SaveVideoMXD Save Wan22 Video MXD (merges a prior stage's workflow
into the embedded metadata via latent_io helpers)
PreviewVideoMXD Preview Video MXD
Route: GET /mxd/videos/input (video-only file list for LoadVideoMXD's combo).
"""
from __future__ import annotations
import os
from fractions import Fraction
import torch
@@ -42,43 +44,25 @@ except Exception as _e:
HAVE_COMFY_API = False
print(f"[ComfyUI-MaxedOut] comfy_api not available in wan22.video_ops: {_e}")
from aiohttp import web
from .latent_io import _merge_prior_workflow_into_current
from ..shared.routes import register_get_route
VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
async def mxd_list_input_videos(request):
"""
Return a JSON list of *video* files under the input folder (relative paths),
sorted by last modified time (newest first) so the combo's 'first' entry
is always the latest render.
"""
input_dir = folder_paths.get_input_directory()
entries = []
def _frame_window(total, count, offset, mode):
offset = max(1, min(offset, total))
count = max(1, min(count, total - offset + 1))
for root, _, filenames in os.walk(input_dir):
for name in filenames:
ext = os.path.splitext(name)[1].lower()
if ext in VIDEO_EXTS:
full = os.path.join(root, name)
rel = os.path.relpath(full, input_dir).replace("\\", "/")
try:
mtime = os.path.getmtime(full)
except OSError:
mtime = 0
entries.append((mtime, rel))
if mode == "start":
start_idx = offset - 1
end_idx = start_idx + count
elif mode == "end":
start_idx = max(0, total - offset - count + 1)
end_idx = start_idx + count
else:
raise ValueError(f"Invalid mode '{mode}'. Expected 'start' or 'end'.")
# Sort newest -> oldest, to match Comfy's internal behavior
entries.sort(key=lambda x: x[0], reverse=True)
files = [rel for _, rel in entries]
return web.json_response(files)
register_get_route("/mxd/videos/input", mxd_list_input_videos)
return start_idx, end_idx
def _select_frames_start_end(frames, count=1, offset=1, mode="end"):
@@ -86,22 +70,21 @@ def _select_frames_start_end(frames, count=1, offset=1, mode="end"):
if total <= 0:
raise ValueError("No frames available for selection.")
# Clamp offset and count
offset = max(1, min(offset, total))
count = max(1, min(count, total - offset + 1))
start_idx, end_idx = _frame_window(total, count, offset, mode)
return frames[start_idx:end_idx].clone()
if mode == "start":
start_idx = offset - 1
end_idx = start_idx + count
selected = frames[start_idx:end_idx].clone()
elif mode == "end":
start_idx = max(0, total - offset - count + 1)
end_idx = start_idx + count
selected = frames[start_idx:end_idx].clone()
else:
raise ValueError(f"Invalid mode '{mode}'. Expected 'start' or 'end'.")
return selected
def _remove_frames_start_end(frames, count=1, offset=1, mode="start"):
total = int(frames.shape[0])
if total <= 0:
raise ValueError("No frames available for removal.")
start_idx, end_idx = _frame_window(total, count, offset, mode)
remaining = torch.cat([frames[:start_idx], frames[end_idx:]], dim=0).clone()
if remaining.shape[0] == 0:
raise ValueError("Removing this window would leave no frames.")
return remaining
# ---------- MXD Frames Select Start/End (from start or end of sequence) ----------
@@ -143,11 +126,47 @@ class Frames_Select_StartEnd_MXD:
return (selected,)
# ---------- MXD Frames Remove From Start ----------
class Frames_Remove_From_Start_MXD:
# ---------- MXD Frames Remove (from start or end of sequence) ----------
class FramesRemoveMXD:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"frames": ("IMAGE",),
"count": ("INT", {
"default": 10,
"min": 1,
"max": 10000,
"tooltip": "Number of frames to remove"
}),
"offset": ("INT", {
"default": 1,
"min": 1,
"max": 10000,
"tooltip": "How far into the video to start removal (from start or end)"
}),
"mode": (["start", "end"], {
"default": "start",
"tooltip": "Remove frames from the start or end of the sequence"
}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "main"
CATEGORY = "MXD/images"
def main(self, frames=None, count=10, offset=1, mode="start"):
remaining = _remove_frames_start_end(frames, count=count, offset=offset, mode=mode)
return (remaining,)
# Keep this published node's schema frozen for existing workflows.
class Frames_Remove_From_Start_MXD:
@classmethod
def INPUT_TYPES(cls):
return {
@@ -164,13 +183,11 @@ class Frames_Remove_From_Start_MXD:
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "main"
CATEGORY = "MXD/images"
FUNCTION = "main"
CATEGORY = "MXD/images"
def main(self, frames=None, count=10):
# Skip the first `count` frames instead of keeping them
frames_after = frames[count:].clone()
return (frames_after,)
return (frames[count:].clone(),)
class GroupVideoFramesMXD:
@@ -355,14 +372,19 @@ if HAVE_COMFY_API:
return (combined_video,)
# ---------- Load Video MXD (video-only picker with refresh) ----------
class LoadVideoMXD:
"""Load a video from /input with a refresh button (videos only)."""
# ---------- Load Video MXD ----------
class LoadVideoComponentsMXD:
"""Load a video from /input (videos only).
Also extracts components (images/audio/fps/bit_depth) inline so this
node covers what LoadVideo + GetVideoComponents would otherwise take two
nodes to do.
"""
CATEGORY = "image/video"
FUNCTION = "load"
RETURN_TYPES = ("VIDEO", "STRING")
RETURN_NAMES = ("video", "video_path")
RETURN_TYPES = ("VIDEO", "IMAGE", "AUDIO", "FLOAT", "INT")
RETURN_NAMES = ("video", "images", "audio", "fps", "bit_depth")
TITLE = "Load Video MXD"
@classmethod
@@ -370,14 +392,7 @@ if HAVE_COMFY_API:
return {
"required": {
"file": ("COMBO", {
# Only allow video uploads in the picker
"video_upload": True,
# Custom route that returns ONLY videos in /input
"remote": {
"route": "/mxd/videos/input",
"refresh_button": True,
"control_after_refresh": "first",
},
}),
}
}
@@ -423,7 +438,10 @@ if HAVE_COMFY_API:
raise ValueError(f"[LoadVideoMXD] Not a video file: {video_path}")
print(f"[LoadVideoMXD] Loaded exactly: {video_path}")
return (VideoFromFile(video_path), video_path)
video = VideoFromFile(video_path)
components = video.get_components()
bit_depth = video.get_bit_depth()
return (video, components.images, components.audio, float(components.frame_rate), bit_depth)
# --- nice-to-haves --------------------------------------------------------
@@ -454,6 +472,36 @@ if HAVE_COMFY_API:
return f"Invalid video file: {file}"
# Keep this published node's inputs and outputs frozen for existing workflows.
class LoadVideoMXD(LoadVideoComponentsMXD):
RETURN_TYPES = ("VIDEO", "STRING")
RETURN_NAMES = ("video", "video_path")
TITLE = "Load Video MXD"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file": ("COMBO", {
"video_upload": True,
"remote": {
"route": "/mxd/videos/input",
"refresh_button": True,
"control_after_refresh": "first",
},
}),
}
}
def load(self, file: str):
video_path = self._resolve_video_path(file)
if not os.path.isfile(video_path):
raise FileNotFoundError(f"[LoadVideoMXD] File not found: {video_path}")
if not self._is_video_file(video_path):
raise ValueError(f"[LoadVideoMXD] Not a video file: {video_path}")
print(f"[LoadVideoMXD] Loaded exactly: {video_path}")
return (VideoFromFile(video_path), video_path)
# ---------- Save Video MXD ----------
class SaveVideoMXD(io.ComfyNode):
@classmethod
@@ -466,8 +514,8 @@ if HAVE_COMFY_API:
inputs=[
io.Video.Input("video", tooltip="The video to save."),
io.String.Input("filename_prefix", default="video/ComfyUI", tooltip="The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."),
io.Combo.Input("format", options=VideoContainer.as_input(), default="auto", tooltip="The format to save the video as."),
io.Combo.Input("codec", options=VideoCodec.as_input(), default="auto", tooltip="The codec to use for the video."),
io.Combo.Input("format", options=["auto", "mp4"], default="auto", tooltip="The format to save the video as."),
io.Combo.Input("codec", options=["auto", "h264"], default="auto", tooltip="The codec to use for the video."),
io.Boolean.Input(
"embed_workflow",
default=True,
@@ -525,6 +573,130 @@ if HAVE_COMFY_API:
return io.NodeOutput(ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))
# ---------- Create + Save Video MXD ----------
class CreateAndSaveVideoMXD(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="CreateAndSaveVideoMXD",
display_name="Create and Save Video MXD",
search_aliases=["create video", "images to video", "export video"],
category="video",
description="Creates a video from images and saves it to the ComfyUI output directory.",
inputs=[
io.Image.Input("images", tooltip="The images to create a video from."),
io.Float.Input("fps", default=30.0, min=1.0, max=120.0, step=1.0),
io.String.Input(
"filename_prefix",
default="video/ComfyUI",
tooltip="The prefix for the saved file. This may include formatting information.",
),
io.Combo.Input(
"format",
options=VideoContainer.as_input(),
default="auto",
tooltip="The format to save the video as.",
),
io.DynamicCombo.Input(
"codec",
options=[
io.DynamicCombo.Option("auto", []),
io.DynamicCombo.Option(
"h264",
[
io.DynamicCombo.Input(
"encoding",
display_name="encoding mode",
options=[
io.DynamicCombo.Option("auto", []),
io.DynamicCombo.Option(
"re-encode",
[
io.Float.Input(
"crf",
default=23.0,
min=0.0,
max=51.0,
step=1.0,
tooltip="Lower values produce higher quality and larger files.",
)
],
),
],
optional=True,
tooltip="Automatic preserves compatible H.264 streams. Re-encode applies a custom CRF.",
)
],
),
],
tooltip="The codec to use for the video.",
),
io.Audio.Input("audio", optional=True, tooltip="The audio to add to the video."),
io.Int.Input(
"bit_depth",
min=8,
max=10,
default=8,
step=2,
optional=True,
display_mode=io.NumberDisplay.number,
tooltip="10-bit keeps smoother gradients, but some players and nodes may not support it.",
),
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
outputs=[io.Video.Output("video")],
is_output_node=True,
)
@classmethod
def execute(
cls,
images,
fps: float,
filename_prefix: str,
format: str,
codec: io.DynamicCombo.Type,
audio=None,
bit_depth: int = 8,
) -> io.NodeOutput:
video = VideoFromComponents(
VideoComponents(images=images, audio=audio, frame_rate=Fraction(fps)),
bit_depth=bit_depth,
)
codec_name = codec["codec"]
encoding = codec.get("encoding") or {}
width, height = video.get_dimensions()
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix,
folder_paths.get_output_directory(),
width,
height,
)
saved_metadata = None
if not args.disable_metadata:
metadata = {}
if cls.hidden.extra_pnginfo is not None:
metadata.update(cls.hidden.extra_pnginfo)
if cls.hidden.prompt is not None:
metadata["prompt"] = cls.hidden.prompt
if metadata:
saved_metadata = metadata
file = f"{filename}_{counter:05}_.{VideoContainer.get_extension(format)}"
video.save_to(
os.path.join(full_output_folder, file),
format=VideoContainer(format),
codec=codec_name,
metadata=saved_metadata,
crf=encoding.get("crf"),
)
return io.NodeOutput(
video,
ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]),
)
class PreviewVideoMXD(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -563,12 +735,14 @@ if HAVE_COMFY_API:
NODE_CLASS_MAPPINGS = {
"Frames_Remove_From_Start_MXD": Frames_Remove_From_Start_MXD,
"FramesRemoveMXD": FramesRemoveMXD,
"GroupVideoFramesMXD": GroupVideoFramesMXD,
"Frames_Select_StartEnd_MXD": Frames_Select_StartEnd_MXD,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Frames_Remove_From_Start_MXD": "Remove Frames From Start MXD",
"FramesRemoveMXD": "Remove Frames MXD",
"GroupVideoFramesMXD": "Group Video Frames MXD",
"Frames_Select_StartEnd_MXD": "Select Frames MXD",
}
@@ -576,13 +750,17 @@ NODE_DISPLAY_NAME_MAPPINGS = {
if HAVE_COMFY_API:
NODE_CLASS_MAPPINGS.update({
"CombineVideos_MXD": CombineVideos_MXD,
"CreateAndSaveVideoMXD": CreateAndSaveVideoMXD,
"LoadVideoMXD": LoadVideoMXD,
"LoadVideoComponentsMXD": LoadVideoComponentsMXD,
"SaveVideoMXD": SaveVideoMXD,
"PreviewVideoMXD": PreviewVideoMXD,
})
NODE_DISPLAY_NAME_MAPPINGS.update({
"CombineVideos_MXD": "Combine Videos MXD",
"CreateAndSaveVideoMXD": "Create and Save Video MXD",
"LoadVideoMXD": "Load Video MXD",
"LoadVideoComponentsMXD": "Load Video + Components MXD",
"SaveVideoMXD": "Save Video MXD",
"PreviewVideoMXD": "Preview Video MXD",
})
+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.0.0"
version = "3.1.0"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
+344 -6
View File
@@ -1,5 +1,5 @@
{
"node_count": 65,
"node_count": 70,
"nodes": {
"BboxDetectorCombinedBatchMXD": {
"category": "MXD/Detector",
@@ -71,6 +71,141 @@
"VIDEO"
]
},
"CreateAndSaveVideoMXD": {
"category": "video",
"class": "CreateAndSaveVideoMXD",
"display_name": "Create and Save Video MXD",
"function": "EXECUTE_NORMALIZED",
"input_types": {
"hidden": {
"extra_pnginfo": {
"type": "EXTRA_PNGINFO"
},
"prompt": {
"type": "PROMPT"
}
},
"optional": {
"audio": {
"config": {
"tooltip": "The audio to add to the video."
},
"type": "AUDIO"
},
"bit_depth": {
"config": {
"default": 8,
"display": "number",
"max": 10,
"min": 8,
"step": 2,
"tooltip": "10-bit keeps smoother gradients, but some players and nodes may not support it."
},
"type": "INT"
}
},
"required": {
"codec": {
"config": {
"options": [
{
"inputs": {
"required": {}
},
"key": "auto"
},
{
"inputs": {
"optional": {
"encoding": [
"COMFY_DYNAMICCOMBO_V3",
{
"display_name": "encoding mode",
"options": [
{
"inputs": {
"required": {}
},
"key": "auto"
},
{
"inputs": {
"required": {
"crf": [
"FLOAT",
{
"default": 23.0,
"max": 51.0,
"min": 0.0,
"step": 1.0,
"tooltip": "Lower values produce higher quality and larger files."
}
]
}
},
"key": "re-encode"
}
],
"tooltip": "Automatic preserves compatible H.264 streams. Re-encode applies a custom CRF."
}
]
},
"required": {}
},
"key": "h264"
}
],
"tooltip": "The codec to use for the video."
},
"type": "COMFY_DYNAMICCOMBO_V3"
},
"filename_prefix": {
"config": {
"default": "video/ComfyUI",
"multiline": false,
"tooltip": "The prefix for the saved file. This may include formatting information."
},
"type": "STRING"
},
"format": {
"config": {
"default": "auto",
"multiselect": false,
"options": [
"auto",
"mp4",
"mkv",
"webm"
],
"tooltip": "The format to save the video as."
},
"type": "COMBO"
},
"fps": {
"config": {
"default": 30.0,
"max": 120.0,
"min": 1.0,
"step": 1.0
},
"type": "FLOAT"
},
"images": {
"config": {
"tooltip": "The images to create a video from."
},
"type": "IMAGE"
}
}
},
"output_node": true,
"return_names": [
"video"
],
"return_types": [
"VIDEO"
]
},
"Crop Image By Mask": {
"category": "MXD/image",
"class": "CropImageByMask",
@@ -356,6 +491,56 @@
"BOOLEAN"
]
},
"FramesRemoveMXD": {
"category": "MXD/images",
"class": "FramesRemoveMXD",
"display_name": "Remove Frames MXD",
"function": "main",
"input_types": {
"required": {
"count": {
"config": {
"default": 10,
"max": 10000,
"min": 1,
"tooltip": "Number of frames to remove"
},
"type": "INT"
},
"frames": {
"type": "IMAGE"
},
"mode": {
"config": {
"default": "start",
"tooltip": "Remove frames from the start or end of the sequence"
},
"type": {
"combo": [
"start",
"end"
]
}
},
"offset": {
"config": {
"default": 1,
"max": 10000,
"min": 1,
"tooltip": "How far into the video to start removal (from start or end)"
},
"type": "INT"
}
}
},
"output_node": false,
"return_names": [
"image"
],
"return_types": [
"IMAGE"
]
},
"Frames_Remove_From_Start_MXD": {
"category": "MXD/images",
"class": "Frames_Remove_From_Start_MXD",
@@ -642,6 +827,128 @@
"IMAGE"
]
},
"Krea2EditGroundedEncodeMXD": {
"category": "MXD/Krea",
"class": "Krea2EditGroundedEncodeMXD",
"display_name": "Krea2 Edit MXD",
"function": "encode",
"input_types": {
"optional": {
"grounding_px": {
"config": {
"default": 768,
"max": 4096,
"min": 0,
"step": 64,
"tooltip": "Maximum longest side fed to Qwen3-VL; 0 uses native resolution."
},
"type": "INT"
},
"image": {
"type": "IMAGE"
},
"image_b": {
"config": {
"tooltip": "Optional second reference (subject) for multi-reference LoRAs; the first image is the scene."
},
"type": "IMAGE"
}
},
"required": {
"clip": {
"type": "CLIP"
},
"prompt": {
"config": {
"default": "",
"multiline": true
},
"type": "STRING"
}
}
},
"output_node": false,
"return_names": null,
"return_types": [
"CONDITIONING"
]
},
"Krea2EditModelPatchMXD": {
"category": "MXD/Krea",
"class": "Krea2EditModelPatchMXD",
"display_name": "Krea 2 Edit 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": "Optional mask limiting Image 1 Boost to a region such as a face. White areas are boosted."
},
"type": "MASK"
},
"image_2": {
"config": {
"tooltip": "Optional second reference, usually a subject to place into Image 1."
},
"type": "IMAGE"
},
"image_2_boost": {
"config": {
"default": 1.0,
"max": 1000.0,
"min": 0.0,
"round": 0.001,
"step": 0.01,
"tooltip": "Attention strength for Image 2. 1.0 = unchanged; no effect when Image 2 is disconnected."
},
"type": "FLOAT"
},
"image_2_boost_mask": {
"config": {
"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."
},
"type": "MASK"
},
"vae": {
"config": {
"tooltip": "Required. VAE used to encode the reference images."
},
"type": "VAE"
}
},
"required": {
"image_1": {
"config": {
"tooltip": "Primary reference image, usually the scene or image to edit."
},
"type": "IMAGE"
},
"model": {
"type": "MODEL"
}
}
},
"output_node": false,
"return_names": [
"model",
"source_latent"
],
"return_types": [
"MODEL",
"LATENT"
]
},
"KreaLayerVarianceMXD": {
"category": "MXD/conditioning",
"class": "KreaLayerVarianceMXD",
@@ -1038,7 +1345,7 @@
"LATENT",
"INT",
"FLOAT",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'cfgpp_ud10_ab', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['simple', 'sgm_uniform', 'karras', 'exponential', 'ddim_uniform', 'beta', 'normal', 'linear_quadratic', 'kl_optimal']",
"INT",
"STRING",
@@ -1282,7 +1589,7 @@
"LATENT",
"INT",
"FLOAT",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'cfgpp_ud10_ab', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['simple', 'sgm_uniform', 'karras', 'exponential', 'ddim_uniform', 'beta', 'normal', 'linear_quadratic', 'kl_optimal']",
"INT",
"STRING",
@@ -1378,7 +1685,7 @@
"LATENT",
"INT",
"FLOAT",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'cfgpp_ud10_ab', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['simple', 'sgm_uniform', 'karras', 'exponential', 'ddim_uniform', 'beta', 'normal', 'linear_quadratic', 'kl_optimal']",
"INT",
"STRING",
@@ -1422,7 +1729,7 @@
"LATENT",
"INT",
"FLOAT",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'cfgpp_ud10_ab', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['simple', 'sgm_uniform', 'karras', 'exponential', 'ddim_uniform', 'beta', 'normal', 'linear_quadratic', 'kl_optimal']",
"INT",
"STRING",
@@ -1488,13 +1795,44 @@
"LATENT",
"INT",
"FLOAT",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['euler', 'euler_cfg_pp', 'euler_ancestral', 'euler_ancestral_cfg_pp', 'heun', 'heunpp2', 'exp_heun_2_x0', 'exp_heun_2_x0_sde', 'dpm_2', 'dpm_2_ancestral', 'lms', 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_2s_ancestral_cfg_pp', 'dpmpp_sde', 'dpmpp_sde_gpu', 'dpmpp_2m', 'dpmpp_2m_cfg_pp', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_2m_sde_heun', 'dpmpp_2m_sde_heun_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', 'ddpm', 'lcm', 'ipndm', 'ipndm_v', 'deis', 'cfgpp_ud10_ab', 'res_multistep', 'res_multistep_cfg_pp', 'res_multistep_ancestral', 'res_multistep_ancestral_cfg_pp', 'gradient_estimation', 'gradient_estimation_cfg_pp', 'er_sde', 'seeds_2', 'seeds_3', 'sa_solver', 'sa_solver_pece', 'ddim', 'uni_pc', 'uni_pc_bh2']",
"['simple', 'sgm_uniform', 'karras', 'exponential', 'ddim_uniform', 'beta', 'normal', 'linear_quadratic', 'kl_optimal']",
"INT",
"STRING",
"INT"
]
},
"LoadVideoComponentsMXD": {
"category": "image/video",
"class": "LoadVideoComponentsMXD",
"display_name": "Load Video + Components MXD",
"function": "load",
"input_types": {
"required": {
"file": {
"config": {
"video_upload": true
},
"type": "COMBO"
}
}
},
"output_node": false,
"return_names": [
"video",
"images",
"audio",
"fps",
"bit_depth"
],
"return_types": [
"VIDEO",
"IMAGE",
"AUDIO",
"FLOAT",
"INT"
]
},
"LoadVideoFromFolderMXD": {
"category": "MXD/Video",
"class": "LoadVideoFromFolderMXD",
+203
View File
@@ -0,0 +1,203 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
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.
+6 -3
View File
@@ -30,7 +30,6 @@ enforces this.
`mxd_dialog_info.js` (paths derive from its `import.meta.url`).
- `mxd_api.js` / `mxd_model_info_service.js` / `mxd_model_row_widget.js` /
`mxd_smart_search.js` / `mxd_menu.js` / `mxd_svgs.js`.
- **`nodes/`** — one extension file per node/feature. Each registers via
`app.registerExtension` and targets Python node names in
`beforeRegisterNodeDef` (names must match `NODE_CLASS_MAPPINGS` keys).
@@ -41,8 +40,12 @@ enforces this.
that work. Serialization shapes are frozen (see the CLAUDE.md contract).
- `better_combos.js` — folder-tree/grid combo display for the MXD latent
loaders (adapted from pysssss; scoped to MXD nodes only; keeps its BOM).
- `run_folder.js` — wraps `app.queuePrompt` for batch folder runs; uses
`/mxd/latents/files`.
- `run_folder.js` — wraps `app.queuePrompt` for multi-run loops driven by
the `run_folder` toggle on folder loaders (uses `/mxd/latents/files`).
All active nodes advance together in one pass.
A toggled-on node only drives the loop if it would actually execute:
not muted/bypassed, and wired forward into an output node. Otherwise a
stray disconnected loader would multiply the queue for nothing.
- `prompt_spellcheck.js` — right-click spelling suggestions in any prompt
textarea, using the vendored dictionary in `vendor/spellcheck/`. Also
ships standalone as the Spell-Check-MXD pack; both copies claim the shared
+18
View File
@@ -53,6 +53,24 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
this.loraWidgetsCounter = 0;
this.widgetButtonSpacer = null;
// This class replaces ComfyUI's generated server-node class. If a frontend
// version misses syncing `has_errors` onto the replacement, feed its
// recorded validation error through LiteGraph's own native error style.
const nativeErrorStroke = this.strokeStyles?.error;
this.strokeStyles = this.strokeStyles || {};
this.strokeStyles.mxdRecordedValidationError = function () {
if (this.has_errors || !app.lastNodeErrors?.[String(this.id)]?.errors?.length) return;
if (typeof nativeErrorStroke === "function") {
this.has_errors = true;
try {
return nativeErrorStroke.call(this);
} finally {
this.has_errors = false;
}
}
return { padding: 12, lineWidth: 10, color: LiteGraph.NODE_ERROR_COLOUR };
};
mxdApi.getLoras();
if (mxdRuntime.loadingApiJson) {
+85 -30
View File
@@ -46,31 +46,93 @@ async function refreshPickerOptions(pickerWidget, kind) {
}
}
// Find every node with run_folder=true and expand it to the list of files
// that live in the same folder as its currently selected file.
async function collectActiveRunFolderNodes() {
// LiteGraph node modes. Bypassed nodes still pass data down the chain, so they
// stay traversable; muted ones cut the branch dead.
const MODE_ALWAYS = 0;
const MODE_NEVER = 2;
const MODE_BYPASS = 4;
function isOutputNode(node) {
return !!node?.constructor?.nodeData?.output_node;
}
// Walk forward from this node's output slots looking for an output node
// (save/preview/etc). A node that reaches none of them is dropped by the
// backend before execution, so looping it would queue runs that do nothing
// but repeat the rest of the graph.
function feedsAnOutputNode(node) {
const graph = node.graph || app.graph;
const seen = new Set([node.id]);
const queue = [node];
while (queue.length) {
const current = queue.shift();
for (const output of current.outputs || []) {
for (const linkId of output.links || []) {
const link = graph?.links?.[linkId] ?? graph?.links?.get?.(linkId);
if (!link) continue;
const target = graph.getNodeById?.(link.target_id);
if (!target || seen.has(target.id)) continue;
if (target.mode === MODE_NEVER) continue;
if (isOutputNode(target) && target.mode === MODE_ALWAYS) return true;
seen.add(target.id);
queue.push(target);
}
}
}
return false;
}
// A node only drives the loop if it will actually execute: not muted or
// bypassed itself, and wired into something that produces a result.
function participatesInRun(node) {
if (node.mode === MODE_NEVER || node.mode === MODE_BYPASS) return false;
return feedsAnOutputNode(node);
}
// Build the per-step driver for one run_folder node: walk its picker widget
// across every file sitting in the same folder as the current selection.
async function runFolderDriver(node, pickerName) {
const runWidget = getWidget(node, "run_folder");
const pickerWidget = getWidget(node, pickerName);
if (!runWidget || !pickerWidget || !runWidget.value) return null;
const refreshWidget = getWidget(node, "refresh_before_run");
if (refreshWidget?.value) {
await refreshPickerOptions(pickerWidget, pickerName);
}
const originalValue = pickerWidget.value;
const dir = dirOf(originalValue);
const files = (pickerWidget.options?.values || []).filter((v) => dirOf(v) === dir);
if (files.length <= 1) return null;
const set = (v) => {
pickerWidget.value = v;
pickerWidget.callback?.(v);
};
return {
steps: files.length,
apply: (i) => set(files[Math.min(i, files.length - 1)]),
restore: () => set(originalValue),
};
}
// Collect every node currently asking for a multi-run loop. All of them
// advance together, so a mixed graph runs max(steps) times with each node
// clamping to its own last entry.
async function collectActiveDrivers() {
const nodes = app.graph?._nodes || [];
const active = [];
for (const node of nodes) {
const pickerName = NODE_TYPES.get(node.comfyClass);
if (!pickerName) continue;
if (!pickerName || !participatesInRun(node)) continue;
const runWidget = getWidget(node, "run_folder");
const pickerWidget = getWidget(node, pickerName);
if (!runWidget || !pickerWidget || !runWidget.value) continue;
const refreshWidget = getWidget(node, "refresh_before_run");
if (refreshWidget?.value) {
await refreshPickerOptions(pickerWidget, pickerName);
}
const originalValue = pickerWidget.value;
const dir = dirOf(originalValue);
const files = (pickerWidget.options?.values || []).filter((v) => dirOf(v) === dir);
if (files.length <= 1) continue;
active.push({ node, widget: pickerWidget, files, originalValue });
const driver = await runFolderDriver(node, pickerName);
if (driver) active.push(driver);
}
return active;
@@ -83,27 +145,20 @@ app.registerExtension({
const originalQueuePrompt = app.queuePrompt.bind(app);
app.queuePrompt = async function (...args) {
const active = await collectActiveRunFolderNodes();
const active = await collectActiveDrivers();
if (!active.length) {
return originalQueuePrompt(...args);
}
const steps = Math.max(...active.map((a) => a.files.length));
const steps = Math.max(...active.map((a) => a.steps));
try {
for (let i = 0; i < steps; i++) {
for (const a of active) {
const file = a.files[Math.min(i, a.files.length - 1)];
a.widget.value = file;
a.widget.callback?.(file);
}
for (const a of active) a.apply(i);
app.canvas?.setDirty(true, true);
await originalQueuePrompt(...args);
}
} finally {
for (const a of active) {
a.widget.value = a.originalValue;
a.widget.callback?.(a.originalValue);
}
for (const a of active) a.restore();
app.canvas?.setDirty(true, true);
}
};
+109 -39
View File
@@ -1,4 +1,5 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
function get_ext(filename) {
const ext = filename.split(".").pop();
@@ -8,6 +9,15 @@ function get_ext(filename) {
return ext.toLowerCase();
}
/** Normalize zip entry paths: forward slashes, no leading ./ */
function normalize_zip_path(relativePath) {
let p = String(relativePath || "").replace(/\\/g, "/");
while (p.startsWith("./")) {
p = p.slice(2);
}
return p.replace(/\/+/g, "/");
}
function get_common_top_folder(paths) {
let common = null;
for (const p of paths) {
@@ -25,10 +35,57 @@ function get_common_top_folder(paths) {
return common || "";
}
function should_skip_entry(relativePath) {
if (!relativePath || relativePath.endsWith("/")) return true;
if (relativePath.startsWith("__MACOSX/") || relativePath.includes("/__MACOSX/")) return true;
const parts = relativePath.split("/");
if (parts.some((part) => part.startsWith("."))) return true;
return false;
}
async function upload_userdata(targetPath, blob) {
if (api && typeof api.storeUserData === "function") {
const res = await api.storeUserData(targetPath, blob, {
overwrite: true,
stringify: false,
throwOnError: false,
full_info: false
});
return res;
}
return api.fetchApi(`/userdata/${encodeURIComponent(targetPath)}?overwrite=true`, {
method: "POST",
body: blob
});
}
async function refresh_workflows_sidebar() {
try {
const pinia = app?.vueApp?.config?.globalProperties?.$pinia;
const store = pinia?._s?.get?.("workflow");
if (store && typeof store.syncWorkflows === "function") {
await store.syncWorkflows();
return true;
}
} catch (err) {
console.warn("zip_loader: syncWorkflows via vueApp failed", err);
}
try {
const stores = window.__PINIA__?._s;
const store = stores?.get?.("workflow");
if (store && typeof store.syncWorkflows === "function") {
await store.syncWorkflows();
return true;
}
} catch (err) {
console.warn("zip_loader: syncWorkflows via __PINIA__ failed", err);
}
return false;
}
app.registerExtension({
name: "Comfy.ZipLoader",
init() {
// Use capture phase to intercept the event before ComfyUI's default handler (which likely listens on bubbling phase on document/body)
document.addEventListener("drop", async (event) => {
if (!event.dataTransfer || !event.dataTransfer.files || event.dataTransfer.files.length === 0) {
return;
@@ -38,12 +95,10 @@ app.registerExtension({
let zipFiles = files.filter(f => get_ext(f.name) === "zip");
let nonZipFiles = files.filter(f => get_ext(f.name) !== "zip");
// If all dropped files are zips, we handle it and stop others
if (zipFiles.length > 0 && nonZipFiles.length === 0) {
event.preventDefault();
event.stopPropagation();
// Load JSZip if not already loaded
if (!window.JSZip) {
try {
await import("./jszip.min.js");
@@ -69,43 +124,37 @@ app.registerExtension({
let workflowCount = 0;
const workflowPaths = [];
const workflowBlobs = [];
const failedUploads = [];
for (const file of zipFiles) {
console.log("Processing zip file:", file.name);
try {
const zip = await JSZip.loadAsync(file);
let count = 0;
const promises = [];
zip.forEach((relativePath, zipEntry) => {
if (zipEntry.dir) return;
if (relativePath.startsWith("__MACOSX")) return;
if (relativePath.includes("/.")) return;
const ext = get_ext(relativePath);
const normPath = normalize_zip_path(relativePath);
if (should_skip_entry(normPath)) return;
const ext = get_ext(normPath);
const promise = zipEntry.async("blob").then(async (blob) => {
if (ext === "json") {
workflowCount++;
workflowPaths.push(relativePath);
workflowPaths.push(normPath);
workflowBlobs.push(blob);
return;
}
const targetPath = "workflows/" + relativePath;
const url = `/api/userdata/${encodeURIComponent(targetPath)}?overwrite=true`;
const res = await fetch(url, {
method: "POST",
body: blob
});
const targetPath = "workflows/" + normPath;
const res = await upload_userdata(targetPath, blob);
if (res.ok) {
count++;
totalCount++;
} else {
console.error("Failed to upload:", relativePath, res.statusText);
console.error("Failed to upload:", normPath, res.status, res.statusText);
failedUploads.push(normPath);
}
});
promises.push(promise);
@@ -121,8 +170,6 @@ app.registerExtension({
let loadedSingleInMemory = false;
if (workflowCount === 1 && typeof app.handleFile === "function") {
// Mirror vanilla ComfyUI's own json-drop behavior: load straight into the
// graph via app.handleFile instead of writing to disk and forcing a reload.
try {
const relativePath = workflowPaths[0];
const blob = workflowBlobs[0];
@@ -131,30 +178,32 @@ app.registerExtension({
await app.handleFile(jsonFile);
loadedSingleInMemory = true;
} catch (err) {
// app.handleFile is an internal API; fall back to the disk-upload
// path below if a future ComfyUI build changes/removes it.
console.error("app.handleFile failed, falling back to workflow upload:", err);
}
}
if (!loadedSingleInMemory && workflowCount > 0) {
const importedWorkflowPaths = [];
await Promise.all(workflowPaths.map(async (relativePath, i) => {
const targetPath = "workflows/" + relativePath;
const url = `/api/userdata/${encodeURIComponent(targetPath)}?overwrite=true`;
const res = await fetch(url, {
method: "POST",
body: workflowBlobs[i]
});
const res = await upload_userdata(targetPath, workflowBlobs[i]);
if (res.ok) {
totalCount++;
importedWorkflowPaths.push(relativePath);
} else {
console.error("Failed to upload:", relativePath, res.statusText);
console.error("Failed to upload:", relativePath, res.status, res.statusText);
failedUploads.push(relativePath);
}
}));
const sortedWorkflows = workflowPaths.slice().sort((a, b) => a.localeCompare(b));
if (importedWorkflowPaths.length === 0) {
alert("Workflow import failed. No files were saved. Please check the browser console for details.");
return;
}
const synced = await refresh_workflows_sidebar();
const sortedWorkflows = importedWorkflowPaths.slice().sort((a, b) => a.localeCompare(b));
const commonFolder = get_common_top_folder(sortedWorkflows) || "(Root)";
const modal = document.createElement("div");
@@ -183,6 +232,10 @@ app.registerExtension({
boxShadow: "0 0 20px rgba(0,0,0,0.5)"
});
const whereHint = synced
? `They should already be under <strong>Workflows</strong> (press <strong>W</strong>). Look in folder <span style="color:#00bdff;">${commonFolder}</span>.`
: `Open <strong>Workflows</strong> (press <strong>W</strong>) after reload. Look in folder <span style="color:#00bdff;">${commonFolder}</span>.`;
dialog.innerHTML = `
<h2 style="margin-top:0; color:#44cf7e;">Workflows Imported</h2>
<p><strong>Folder:</strong> <span style="color:#00bdff;">${commonFolder}</span></p>
@@ -190,34 +243,51 @@ app.registerExtension({
<ul id="workflowList" style="margin:0; padding-left:20px; font-size:14px; line-height:1.6;"></ul>
</div>
<p style="font-size:13px; color:#aaa;">
These will appear in the <strong>Workflows</strong> side panel (Press <strong>W</strong>) after a reload.
${whereHint}
</p>
<div style="display:flex; gap:10px; margin-top:20px;">
<button id="reloadBtn" style="flex:1; padding:10px; background:#44cf7e; border:none; color:black; font-weight:bold; border-radius:4px; cursor:pointer;">Reload Now</button>
<button id="closeBtn" style="flex:1; padding:10px; background:#444; border:none; color:white; border-radius:4px; cursor:pointer;">Later</button>
<button id="reloadBtn" style="flex:1; padding:10px; background:#44cf7e; border:none; color:black; font-weight:bold; border-radius:4px; cursor:pointer;">${synced ? "Done" : "Reload Now"}</button>
<button id="closeBtn" style="flex:1; padding:10px; background:#444; border:none; color:white; border-radius:4px; cursor:pointer;">${synced ? "Close" : "Later"}</button>
</div>
`;
const workflowList = dialog.querySelector("#workflowList");
for (const p of sortedWorkflows) {
const li = document.createElement("li");
li.textContent = p.split("/").pop();
li.textContent = p;
workflowList.appendChild(li);
}
modal.appendChild(dialog);
document.body.appendChild(modal);
dialog.querySelector("#reloadBtn").onclick = () => window.location.reload();
if (failedUploads.length > 0) {
const warning = document.createElement("p");
warning.style.color = "#ffb84d";
warning.textContent = `${failedUploads.length} file(s) failed to import. Check the browser console for details.`;
dialog.insertBefore(warning, dialog.querySelector("div:last-child"));
}
dialog.querySelector("#reloadBtn").onclick = async () => {
if (synced) {
document.body.removeChild(modal);
return;
}
const ok = await refresh_workflows_sidebar();
if (ok) {
document.body.removeChild(modal);
return;
}
window.location.reload();
};
dialog.querySelector("#closeBtn").onclick = () => document.body.removeChild(modal);
}
}
}, true); // Capture = true
}, true);
// We also need to prevent default dragover to allow drop
document.addEventListener("dragover", (event) => {
if (event.dataTransfer && event.dataTransfer.types && event.dataTransfer.types.includes("Files")) {
// event.preventDefault(); // This is needed to allow drop
event.preventDefault();
}
}, true);
}
});
});