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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ for _name in (
|
||||
"latents",
|
||||
"resolution",
|
||||
"prompts",
|
||||
"krea",
|
||||
"masks",
|
||||
"media_io",
|
||||
"comparers",
|
||||
|
||||
+266
@@ -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",
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Vendored
+109
-39
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user