From fe6cb329564558c69f1f2f0606512485288f61a9 Mon Sep 17 00:00:00 2001 From: Maxed-Out-99 Date: Sat, 19 Sep 2026 00:39:23 -0700 Subject: [PATCH] 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. --- README.md | 9 +- THIRD_PARTY_LICENSES.md | 24 ++ loraloader_mxd/power_lora_loader_mxd.py | 95 +++++-- nodes/__init__.py | 1 + nodes/krea.py | 266 ++++++++++++++++++ nodes/krea2_edit_core.py | 346 +++++++++++++++++++++++ nodes/wan22/video_ops.py | 318 ++++++++++++++++----- pyproject.toml | 2 +- scripts/node_schema_baseline.json | 350 +++++++++++++++++++++++- third_party/ComfyUI-Krea2Edit/LICENSE | 203 ++++++++++++++ web/WEB.md | 9 +- web/nodes/power_lora_base.js | 18 ++ web/nodes/run_folder.js | 115 ++++++-- web/vendor/zip_loader/zip_loader.js | 148 +++++++--- 14 files changed, 1737 insertions(+), 167 deletions(-) create mode 100644 THIRD_PARTY_LICENSES.md create mode 100644 nodes/krea.py create mode 100644 nodes/krea2_edit_core.py create mode 100644 third_party/ComfyUI-Krea2Edit/LICENSE diff --git a/README.md b/README.md index 01c98c9..df72220 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..61054fe --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -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 + + + +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. diff --git a/loraloader_mxd/power_lora_loader_mxd.py b/loraloader_mxd/power_lora_loader_mxd.py index 277fe2c..fa2c807 100644 --- a/loraloader_mxd/power_lora_loader_mxd.py +++ b/loraloader_mxd/power_lora_loader_mxd.py @@ -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) diff --git a/nodes/__init__.py b/nodes/__init__.py index cbc98d2..21ca9ef 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -21,6 +21,7 @@ for _name in ( "latents", "resolution", "prompts", + "krea", "masks", "media_io", "comparers", diff --git a/nodes/krea.py b/nodes/krea.py new file mode 100644 index 0000000..00cab90 --- /dev/null +++ b/nodes/krea.py @@ -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", +} diff --git a/nodes/krea2_edit_core.py b/nodes/krea2_edit_core.py new file mode 100644 index 0000000..5b6c30a --- /dev/null +++ b/nodes/krea2_edit_core.py @@ -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 diff --git a/nodes/wan22/video_ops.py b/nodes/wan22/video_ops.py index 0145fab..c829112 100644 --- a/nodes/wan22/video_ops.py +++ b/nodes/wan22/video_ops.py @@ -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", }) diff --git a/pyproject.toml b/pyproject.toml index 68339f2..faa531a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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) diff --git a/scripts/node_schema_baseline.json b/scripts/node_schema_baseline.json index 6d73ca3..209b3d2 100644 --- a/scripts/node_schema_baseline.json +++ b/scripts/node_schema_baseline.json @@ -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", diff --git a/third_party/ComfyUI-Krea2Edit/LICENSE b/third_party/ComfyUI-Krea2Edit/LICENSE new file mode 100644 index 0000000..6b0b127 --- /dev/null +++ b/third_party/ComfyUI-Krea2Edit/LICENSE @@ -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. + diff --git a/web/WEB.md b/web/WEB.md index 943c112..97a65e1 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -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 diff --git a/web/nodes/power_lora_base.js b/web/nodes/power_lora_base.js index a9fbb70..d59f5c2 100644 --- a/web/nodes/power_lora_base.js +++ b/web/nodes/power_lora_base.js @@ -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) { diff --git a/web/nodes/run_folder.js b/web/nodes/run_folder.js index a762e55..82646d2 100644 --- a/web/nodes/run_folder.js +++ b/web/nodes/run_folder.js @@ -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); } }; diff --git a/web/vendor/zip_loader/zip_loader.js b/web/vendor/zip_loader/zip_loader.js index 5f82c56..08900f8 100644 --- a/web/vendor/zip_loader/zip_loader.js +++ b/web/vendor/zip_loader/zip_loader.js @@ -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 Workflows (press W). Look in folder ${commonFolder}.` + : `Open Workflows (press W) after reload. Look in folder ${commonFolder}.`; + dialog.innerHTML = `

Workflows Imported

Folder: ${commonFolder}

@@ -190,34 +243,51 @@ app.registerExtension({

- These will appear in the Workflows side panel (Press W) after a reload. + ${whereHint}

- - + +
`; 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); } -}); +}); \ No newline at end of file