Simple Power Lora Loader

This commit is contained in:
JJ
2026-10-27 22:44:59 +00:00
parent 6cbd142500
commit 494afbcca9
9 changed files with 1896 additions and 498 deletions
+44 -6
View File
@@ -353,6 +353,11 @@ class ImageBlend_GPU_advanced(ImageBlend_GPU):
cells are never sampled or resized (skipped for speed). image_low (and
its mask) inherit this automatically since they are derived from the
main output.
- image_source_high: the top-left cell cropped out of the finished "image"
output (single frame, not the whole grid canvas). Since the grid canvas
is already sized to high_MP, the cell comes out at high_MP quality
without any extra resampling. Without a grid it is just the first frame
of the output.
"""
NODE_NAME = "Image Blend GPU Advanced"
@@ -397,8 +402,8 @@ class ImageBlend_GPU_advanced(ImageBlend_GPU):
}
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "MASK")
RETURN_NAMES = ("image", "mask", "image_low", "mask_low")
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "MASK", "IMAGE")
RETURN_NAMES = ("image", "mask", "image_low", "mask_low", "image_source_high")
FUNCTION = 'image_blend_gpu_advanced'
CATEGORY = 'WanVideoWrapper_QQ/image'
@@ -682,6 +687,19 @@ class ImageBlend_GPU_advanced(ImageBlend_GPU):
"""Apply the optional grid (from the Image Blend Grid node), then
run the shared high_MP / resize_to_32 / mask_inner_erode / low_MP
post-processing."""
# --- image_source_high: the top-left cell of the finished "image"
# output, cropped out after all processing (single frame). When a
# grid is built, figure out the cell size from the layout so the
# crop can be taken from the final canvas. ---
source_cell = None # None -> take the whole first frame
if enable_grid is not None and str(enable_grid.get("enable", "false")).lower() != "false":
try:
cols, rows = (int(v) for v in str(enable_grid.get("enable", "false")).lower().split("x"))
if cols >= 1 and rows >= 1:
source_cell = (cols, rows) # (cols, rows) - resolved after the grid is built
except ValueError:
pass
if enable_grid is not None and str(enable_grid.get("enable", "false")).lower() != "false":
# _build_grid sizes its canvas to hit high_MP directly (single-pass
# cell resize), so pass high_MP=0.0 to _finalize_output to avoid a
@@ -697,7 +715,8 @@ class ImageBlend_GPU_advanced(ImageBlend_GPU):
return self._finalize_output(output_bhwc, output_mask,
resize_to_32, scale_method,
mask_inner_erode,
high_MP, low_MP, pbar=pbar)
high_MP, low_MP, pbar=pbar,
source_cell=source_cell)
def _build_grid(self, output_bhwc, output_mask, grid_cfg, scale_method="lanczos",
high_MP=0.0, fill_black=False):
@@ -837,10 +856,14 @@ class ImageBlend_GPU_advanced(ImageBlend_GPU):
def _finalize_output(self, output_bhwc, output_mask,
resize_to_32, scale_method, mask_inner_erode,
high_MP, low_MP, pbar=None):
high_MP, low_MP, pbar=None, source_cell=None):
"""Apply high_MP scaling, resize_to_32 cropping, mask_inner_erode mask
erosion and the low_MP copy to a composited (image, mask) pair and
build the return tuple. All resizes use the selected scale_method."""
build the return tuple. All resizes use the selected scale_method.
source_cell is an optional (cols, rows) grid layout: the image_source_high
output is then the top-left cell cropped from the FINAL (fully
processed) output, as a single frame. Without it, image_source_high is
just the first frame of the output."""
# --- Optional high-megapixel scale of the main output (before resize_to_32) ---
if high_MP > 0.0:
src_h, src_w = output_bhwc.shape[1], output_bhwc.shape[2]
@@ -868,6 +891,20 @@ class ImageBlend_GPU_advanced(ImageBlend_GPU):
f"{output_bhwc.shape[2]}x{output_bhwc.shape[1]}.")
output_mask = output_mask_4d.squeeze(-1)
# --- image_source_high: crop the top-left cell out of the FINAL
# output (a single frame). The canvas is already at high_MP, so
# this is a pure crop - no extra resampling. ---
if source_cell is not None:
cols, rows = source_cell
canvas_h, canvas_w = output_bhwc.shape[1], output_bhwc.shape[2]
cell_h = max(1, canvas_h // rows)
cell_w = max(1, canvas_w // cols)
source_high = output_bhwc[:1, :cell_h, :cell_w, :]
print(f"[INFO] image_source_high: cropped top-left cell "
f"{cell_w}x{cell_h} from the {canvas_w}x{canvas_h} output.")
else:
source_high = output_bhwc[:1]
# --- Optional: erode the mask inwards (inner feather), but only from
# the right and bottom sides of the image; the top/left borders
# are kept. The image is not affected. ---
@@ -929,7 +966,8 @@ class ImageBlend_GPU_advanced(ImageBlend_GPU):
# CPU download phase: last unit of the bar
comfy.model_management.throw_exception_if_processing_interrupted()
result = (output_bhwc.cpu(), output_mask.cpu(),
image_low.cpu(), mask_low.squeeze(-1).cpu())
image_low.cpu(), mask_low.squeeze(-1).cpu(),
source_high.contiguous().cpu())
if pbar is not None:
pbar.update(1)
return result
+563
View File
@@ -1,7 +1,19 @@
import os
import json
import hashlib
import torch
import torch.nn.functional as F
import numpy as np
from PIL import Image, ImageOps, ImageSequence
import comfy.model_management
from comfy.comfy_types import IO
from comfy_api.latest import InputImpl
import folder_paths
import node_helpers
class WanVideoMotionToFlowmap:
"""
Analyzes video motion and generates flowmap (displacement map).
@@ -514,13 +526,564 @@ class WanVideoFlowmapDistortion:
return distorted_bhwc
class LoadImageExtra:
"""
Same as core Load Image, but also outputs the selected image's
filename without extension as a STRING (output "name").
"""
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
files = folder_paths.filter_files_content_types(files, ["image"])
return {"required":
{"image": (sorted(files), {"image_upload": True})},
}
CATEGORY = "image"
RETURN_TYPES = ("IMAGE", "MASK", IO.STRING)
RETURN_NAMES = ("IMAGE", "MASK", "name")
FUNCTION = "load_image"
def load_image(self, image):
image_path = folder_paths.get_annotated_filepath(image)
dtype = comfy.model_management.intermediate_dtype()
device = comfy.model_management.intermediate_device()
components = InputImpl.VideoFromFile(image_path).get_components()
if components.images.shape[0] > 0:
out_image = components.images.to(device=device, dtype=dtype)
out_mask = (1.0 - components.alpha[..., -1]).to(device=device, dtype=dtype) if components.alpha is not None else torch.zeros((components.images.shape[0], 64, 64), dtype=dtype, device=device)
else:
# This code is left here to handle animated webp which pyav does not support loading
img = node_helpers.pillow(Image.open, image_path)
output_images = []
output_masks = []
w, h = None, None
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
im = i.convert("RGB")
if len(output_images) == 0:
w = im.size[0]
h = im.size[1]
if im.size[0] != w or im.size[1] != h:
continue
im = np.array(im).astype(np.float32) / 255.0
im = torch.from_numpy(im)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(im.to(dtype=dtype))
output_masks.append(mask.unsqueeze(0).to(dtype=dtype))
out_image = torch.cat(output_images, dim=0)
out_mask = torch.cat(output_masks, dim=0)
out_image = out_image.to(device=device, dtype=dtype)
out_mask = out_mask.to(device=device, dtype=dtype)
name = os.path.splitext(os.path.basename(image))[0]
return (out_image, out_mask, name)
@classmethod
def IS_CHANGED(s, image):
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
m.update(f.read())
return m.digest().hex()
@classmethod
def VALIDATE_INPUTS(s, image):
if not folder_paths.exists_annotated_filepath(image):
return "Invalid image file: {}".format(image)
return True
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tif", ".tiff", ".avif"}
def _list_image_files(directory):
"""Image files located directly in `directory`, sorted case-insensitively."""
try:
names = os.listdir(directory)
except OSError:
return []
files = [n for n in names
if os.path.splitext(n)[1].lower() in _IMAGE_EXTENSIONS
and os.path.isfile(os.path.join(directory, n))]
return sorted(files, key=lambda n: (n.lower(), n))
def _list_nonempty_txt_stems(directory):
"""Lower-cased stems of non-empty .txt files located directly in `directory`."""
try:
names = os.listdir(directory)
except OSError:
return set()
stems = set()
for name in names:
stem, ext = os.path.splitext(name)
if ext.lower() != ".txt":
continue
file_path = os.path.join(directory, name)
try:
if os.path.isfile(file_path) and os.path.getsize(file_path) > 0:
stems.add(stem.lower())
except OSError:
continue
return stems
def _load_pair_image(image_path):
"""Load an image file as a [1, H, W, 3] float32 CPU tensor (first frame)."""
img = node_helpers.pillow(Image.open, image_path)
img = node_helpers.pillow(ImageOps.exif_transpose, img)
img = img.convert("RGB")
arr = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None,]
class LoadImagePair_FromPath:
"""
Loads image pairs from a folder.
Scans the given folder for images. Every image whose file name (extension
stripped, case-insensitive) also exists in the folder's "control"
sub-folder forms a pair - the extensions may differ
(e.g. "a.png" pairs with "control/a.jpg"). Only complete pairs are output,
in alphabetical order of the main folder image name.
Outputs (parallel lists, one entry per pair):
control - IMAGE list: the images from the "control" sub-folder.
result - IMAGE list: the images from the folder itself.
path - STRING list: absolute file paths of the result images with
the file extension stripped (never the ones inside
"control"), e.g. "C:/images/foo" for "C:/images/foo.png".
With skip_existing enabled, pairs whose result image already has a
non-empty "<name>.txt" next to it (in the folder itself, not in "control")
are left out of the output - useful for resuming captioning runs, since
already captioned pairs are not processed again. Empty (0-byte) .txt files
count as missing, so emptying a caption re-includes that pair.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"path": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "Folder with the images. Each image needs a name-matching counterpart in the 'control' sub-folder (extension may differ). Quoted paths are accepted.",
}),
},
"optional": {
"skip_existing": ("BOOLEAN", {
"default": False,
"tooltip": "When enabled, pairs whose result image already has a non-empty '<name>.txt' next to it (in the folder itself, never in 'control') are not output. Useful to resume captioning runs; empty (0-byte) .txt files count as missing.",
}),
},
}
RETURN_TYPES = ("IMAGE", "IMAGE", IO.STRING)
RETURN_NAMES = ("control", "result", "path")
FUNCTION = "load_pairs"
# All three outputs are real Python lists (one entry per pair). Without
# this flag, execution.py's merge_result_data would wrap each returned
# list into a 1-element list, so a downstream node (e.g. SaveImage) would
# receive the whole list as a single "batch" and iterate over 4D tensors.
# With it, list-aware nodes get the full list and plain nodes are called
# once per element - the standard ComfyUI list-output behaviour.
OUTPUT_IS_LIST = (True, True, True)
CATEGORY = "WanVideoWrapper_QQ/image"
DESCRIPTION = ("Scans a folder for image pairs: the image in the folder itself and its counterpart "
"in the 'control' sub-folder with the same name (extension may differ, e.g. a.png + "
"control/a.jpg). Outputs parallel lists: control (from 'control'), result (from the "
"folder itself) and path (full file paths of the result images, file extension "
"stripped). Only complete pairs are output. With skip_existing enabled, pairs that "
"already have a non-empty '<name>.txt' next to the result image are left out, so "
"captioning runs can be resumed.")
@staticmethod
def _resolve_dirs(path):
p = (path or "").strip().strip('"').strip("'")
if not p:
raise ValueError("Load Image Pair From Path: 'path' is empty.")
p = os.path.normpath(os.path.abspath(os.path.expanduser(p)))
main_dir = os.path.dirname(p) if os.path.isfile(p) else p
if not os.path.isdir(main_dir):
raise ValueError(f"Load Image Pair From Path: folder does not exist: {main_dir}")
control_dir = os.path.join(main_dir, "control")
if not os.path.isdir(control_dir):
raise ValueError(f"Load Image Pair From Path: no 'control' sub-folder found in: {main_dir}")
return main_dir, control_dir
def load_pairs(self, path, skip_existing=False):
skip_existing = skip_existing if isinstance(skip_existing, bool) else str(skip_existing).lower() == "true"
main_dir, control_dir = self._resolve_dirs(path)
main_files = _list_image_files(main_dir)
control_files = _list_image_files(control_dir)
if not main_files:
raise ValueError(f"Load Image Pair From Path: no images found in: {main_dir}")
if not control_files:
raise ValueError(f"Load Image Pair From Path: no images found in: {control_dir}")
# stems of non-empty <name>.txt files next to the result images (never 'control')
existing_caption_stems = _list_nonempty_txt_stems(main_dir) if skip_existing else set()
# stem (lower-case, extension stripped) -> [(filename, extension), ...]
control_by_stem = {}
for name in control_files:
stem, ext = os.path.splitext(name)
control_by_stem.setdefault(stem.lower(), []).append((name, ext.lower()))
control_images, result_images, result_paths = [], [], []
skipped = []
already_captioned = []
for name in main_files:
stem, ext = os.path.splitext(name)
candidates = control_by_stem.get(stem.lower())
if not candidates:
skipped.append(name)
continue
# Pair already has a non-empty caption .txt next to the result image
if skip_existing and stem.lower() in existing_caption_stems:
already_captioned.append(name)
continue
# Prefer the control file with the same extension, else alphabetical
ext = ext.lower()
control_name = next((c[0] for c in candidates if c[1] == ext), candidates[0][0])
result_path = os.path.join(main_dir, name)
control_images.append(_load_pair_image(os.path.join(control_dir, control_name)))
result_images.append(_load_pair_image(result_path))
# Full path with the file extension stripped (e.g. ".../foo.png" -> ".../foo")
result_paths.append(os.path.splitext(result_path)[0])
if not result_images:
if already_captioned:
details = []
if skipped:
details.append(f"{len(skipped)} image(s) without a matching control file")
details.append(f"{len(already_captioned)} pair(s) already have a non-empty caption "
f".txt next to the result image (skip_existing=True)")
reason = "; ".join(details)
else:
reason = f"{len(main_files)} image(s), none has a name-matching file in 'control'"
raise ValueError(
f"Load Image Pair From Path: no pairs left to output in {main_dir} ({reason})."
)
parts = []
if skipped:
preview = ", ".join(skipped[:10]) + (" ..." if len(skipped) > 10 else "")
parts.append(f"skipped {len(skipped)} image(s) without a matching control file: {preview}")
if already_captioned:
preview = ", ".join(already_captioned[:10]) + (" ..." if len(already_captioned) > 10 else "")
parts.append(f"skipped {len(already_captioned)} pair(s) with an existing non-empty caption .txt: {preview}")
tail = ("; " + "; ".join(parts)) if parts else ""
print(f"[LoadImagePair_FromPath] Loaded {len(result_images)} pair(s) from {main_dir}{tail}.")
return (control_images, result_images, result_paths)
@classmethod
def IS_CHANGED(cls, path, skip_existing=False):
skip_existing = skip_existing if isinstance(skip_existing, bool) else str(skip_existing).lower() == "true"
try:
main_dir, control_dir = cls._resolve_dirs(path)
except ValueError:
return float("NaN")
sha = hashlib.sha256()
sha.update(f"skip_existing={skip_existing}".encode("utf-8"))
for directory in (main_dir, control_dir):
for name in _list_image_files(directory):
fp = os.path.join(directory, name)
try:
st = os.stat(fp)
sha.update(f"{fp}|{st.st_mtime_ns}|{st.st_size}".encode("utf-8"))
except OSError:
sha.update(fp.encode("utf-8"))
# With skip_existing the loaded pairs also depend on the caption .txt
# files next to the result images, so newly written (or emptied)
# captions must invalidate the cache and re-run the loader.
if skip_existing:
for stem in sorted(_list_nonempty_txt_stems(main_dir)):
sha.update(f"|caption_txt:{stem}".encode("utf-8"))
return sha.hexdigest()
@classmethod
def VALIDATE_INPUTS(cls, path):
try:
cls._resolve_dirs(path)
except ValueError as e:
return str(e)
return True
def _grid_snap(v, div):
"""Snap a dimension to the closest multiple of div (>= div when div > 1)."""
if div and div > 1:
return max(div, int(round(v / div)) * div)
return max(1, int(round(v)))
def _grid_resize_cover(t, target_w, target_h):
"""LANCZOS-scale a HWC float tensor to COVER the target size (aspect ratio
preserved, no stretching), then center-crop to the exact target dims."""
h, w = t.shape[0], t.shape[1]
if w == target_w and h == target_h:
return t
scale = max(target_w / w, target_h / h)
new_w = max(1, int(round(w * scale)))
new_h = max(1, int(round(h * scale)))
x = t.permute(2, 0, 1).unsqueeze(0)
try:
x = F.interpolate(x, size=(new_h, new_w), mode="lanczos", antialias=True)
except (ValueError, NotImplementedError):
x = F.interpolate(x, size=(new_h, new_w), mode="bicubic", antialias=True)
x = x.clamp(0.0, 1.0)[0].permute(1, 2, 0)
left = (new_w - target_w) // 2
top = (new_h - target_h) // 2
return x[top:top + target_h, left:left + target_w, :].contiguous()
class PowerImageGrid:
"""
Displays a batch of images in a resizable grid (one cell per image) and
outputs the images after optional per-image cropping and MP-normalisation.
The frontend (web/power_image_grid) draws the grid: node size + image count
determine rows/columns, resizing the node re-arranges cells so all images
fit. Each cell gets a scissors button in its top-right corner that toggles
an interactive crop box (drag to move, corner handles to resize) - each
image's crop can be activated/deactivated independently.
Inputs:
images - IMAGE batch tensor [N, H, W, C] or a LIST of IMAGE
tensors (comfy "Create List" etc.); every image is
shown as one grid cell.
resize_to_mp - 0.0 = inactive. Any other value: all images are first
cropped (where crop is active), then resized to the
FIRST image's aspect ratio at `resize_to_mp`
megapixels total area (LANCZOS cover + center-crop).
divisible_by - Output dimensions are snapped to the closest multiple
of this (crop sizes too when resize_to_mp is 0).
crop_json - Hidden widget, written by the frontend. JSON list of
per-image crops: {enabled, x, y, w, h} (normalized
center + size, like Power Load Video's crop).
Output:
images - IMAGE tensor of the processed (cropped/resized) batch.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"resize_to_mp": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 64.0, "step": 0.1}),
"divisible_by": ("INT", {"default": 32, "min": 1, "max": 256, "step": 1}),
"crop_json": ("STRING", {"default": "[]"}),
},
"hidden": {"unique_id": "UNIQUE_ID", "prompt": "PROMPT"},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "process"
OUTPUT_NODE = True
# Accept true LISTs (e.g. comfy "Create List"): without this, a list input
# would execute this node once PER ITEM and the grid would only ever see
# the first image. With it, ALL list items arrive in a single execution
# (widget values arrive wrapped in 1-element lists too).
INPUT_IS_LIST = True
CATEGORY = "Power/Image"
DESCRIPTION = "Resizable image grid with per-image crop boxes, MP normalisation (resize_to_mp) and divisible_by snapping."
@staticmethod
def _unwrap_scalar(v, default, cast):
"""Pull a scalar out of a possibly list-wrapped widget value."""
if isinstance(v, (list, tuple)):
v = v[0] if len(v) > 0 else default
try:
return cast(v)
except (TypeError, ValueError):
return default
@staticmethod
def _flatten_images(images):
"""Normalize any accepted input form into a flat list of HWC tensors.
Handles: a [N,H,W,C] batch, a single [H,W,C] tensor, a LIST of either
(comfy "Create List"), and mixed lists. Items may have different H/W -
they are only stacked at the very end, after resizing."""
flat = []
items = images if isinstance(images, (list, tuple)) else [images]
for im in items:
if im is None:
continue
if im.dim() == 4:
flat.extend(im[i] for i in range(im.shape[0]))
elif im.dim() == 3:
flat.append(im)
return flat
def process(self, images, resize_to_mp=0.0, divisible_by=32, crop_json="[]",
unique_id=None, prompt=None):
# INPUT_IS_LIST: flatten lists / batches into individual HWC images
# WITHOUT concatenating - list items may have different sizes.
imgs = self._flatten_images(images)
if not imgs:
raise ValueError("Power Image Grid: no images received on the images input.")
# Coerce widget values (lists from INPUT_IS_LIST, or odd optionals)
resize_to_mp = self._unwrap_scalar(resize_to_mp, 0.0, float)
divisible_by = self._unwrap_scalar(divisible_by, 32, int)
if divisible_by < 1:
divisible_by = 1
if isinstance(crop_json, (list, tuple)):
crop_json = crop_json[0] if len(crop_json) > 0 else "[]"
# Parse per-image crops written by the frontend
crops = []
if isinstance(crop_json, str) and crop_json.strip():
try:
parsed = json.loads(crop_json)
if isinstance(parsed, list):
crops = parsed
except (ValueError, TypeError):
crops = []
n = len(imgs)
processed = []
for i in range(n):
img = imgs[i] # HWC
h, w = int(img.shape[0]), int(img.shape[1])
c = crops[i] if i < len(crops) and isinstance(crops[i], dict) else None
if c is not None and c.get("enabled"):
try:
cx = min(1.0, max(0.0, float(c.get("x", 0.5))))
cy = min(1.0, max(0.0, float(c.get("y", 0.5))))
cw = min(1.0, max(0.05, float(c.get("w", 1.0))))
ch = min(1.0, max(0.05, float(c.get("h", 1.0))))
except (TypeError, ValueError):
cx, cy, cw, ch = 0.5, 0.5, 1.0, 1.0
left = max(0, int(round((cx - cw / 2) * w)))
top = max(0, int(round((cy - ch / 2) * h)))
right = min(w, int(round((cx + cw / 2) * w)))
bottom = min(h, int(round((cy + ch / 2) * h)))
if right > left and bottom > top:
# Snap crop dimensions to divisible_by and re-center,
# so outputs stay usable even with resize_to_mp = 0
raw_w = right - left
raw_h = bottom - top
snap_w = _grid_snap(raw_w, divisible_by)
snap_h = _grid_snap(raw_h, divisible_by)
if snap_w < raw_w or snap_h < raw_h:
left = left + (raw_w - snap_w) // 2
top = top + (raw_h - snap_h) // 2
left = max(0, min(left, w - snap_w))
top = max(0, min(top, h - snap_h))
right = left + snap_w
bottom = top + snap_h
img = img[top:bottom, left:right, :].contiguous()
processed.append(img)
if resize_to_mp > 0.0:
# Target = FIRST image's (post-crop) aspect ratio at resize_to_mp
# megapixels of total area, both sides snapped to divisible_by.
h0, w0 = int(processed[0].shape[0]), int(processed[0].shape[1])
aspect = w0 / h0
area = resize_to_mp * 1_000_000.0
target_h = (area / aspect) ** 0.5
target_w = aspect * target_h
target_w = _grid_snap(target_w, divisible_by)
target_h = _grid_snap(target_h, divisible_by)
# COVER: images with a different AR are scaled to cover the target
# (aspect ratio preserved) and center-cropped to the exact target
# dimensions.
processed = [_grid_resize_cover(p, target_w, target_h) for p in processed]
else:
# Inactive: make the batch stackable by cover-fitting any image
# whose size differs from the first image's size.
h0, w0 = int(processed[0].shape[0]), int(processed[0].shape[1])
if any(int(p.shape[0]) != h0 or int(p.shape[1]) != w0 for p in processed):
processed = [_grid_resize_cover(p, w0, h0) for p in processed]
out = torch.stack(processed, dim=0)
# --- UI previews (UNCROPPED originals, so crop boxes stay editable) --
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
node_key = str(unique_id) if unique_id is not None else "shared"
prefix = f"power_image_grid_{node_key}_"
try:
for name in os.listdir(temp_dir):
if name.startswith(prefix):
try:
os.remove(os.path.join(temp_dir, name))
except OSError:
pass
except OSError:
pass
results = []
for i in range(n):
arr = np.clip(imgs[i].float().cpu().numpy() * 255.0, 0, 255).astype(np.uint8)
pil_img = Image.fromarray(arr)
# Cap preview resolution - only needed for crop-box editing
max_side = 1536
if max(pil_img.size) > max_side:
s = max_side / max(pil_img.size)
pil_img = pil_img.resize(
(max(1, int(pil_img.size[0] * s)), max(1, int(pil_img.size[1] * s))),
Image.LANCZOS,
)
name = f"{prefix}{i:05d}.jpg"
pil_img.save(os.path.join(temp_dir, name), format="JPEG", quality=92)
results.append({"filename": name, "subfolder": "", "type": "temp"})
# NOTE: every ui value must be a list - the server iterates over each
# value when merging ui outputs. Custom key "grid_images" (not
# "images") so the frontend's native output-node preview does not
# ALSO render these images under the node.
return {
"ui": {"grid_images": results},
"result": (out,),
}
# Node registration
NODE_CLASS_MAPPINGS = {
"WanVideoMotionToFlowmap": WanVideoMotionToFlowmap,
"WanVideoFlowmapDistortion": WanVideoFlowmapDistortion,
"LoadImageExtra": LoadImageExtra,
"LoadImagePair_FromPath": LoadImagePair_FromPath,
"PowerImageGrid": PowerImageGrid,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WanVideoMotionToFlowmap": "Wan Video Motion to Flowmap",
"WanVideoFlowmapDistortion": "Wan Video Flowmap Distortion",
"LoadImageExtra": "Load Image Extra",
"LoadImagePair_FromPath": "Load Image Pair From Path",
"PowerImageGrid": "Power Image Grid",
}
+104
View File
@@ -642,6 +642,110 @@ class PowerLoraLoaderV2:
# Clip connected, return model, model_low, and clip
return (model, model_low, clip)
class SimplePowerLoraLoader:
"""Simplified Power Lora Loader V2: one strength per LoRA, single model output (no model_low)."""
def __init__(self):
self.properties = {}
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
},
"optional": FlexibleOptionalInputType(type=any_type, data={
"clip": ("CLIP",),
}),
"hidden": {
"ui_state": ("STRING", {"default": "{}"}),
},
}
RETURN_TYPES = ("MODEL", "CLIP")
RETURN_NAMES = ("model", "clip")
FUNCTION = "load_loras"
CATEGORY = "WanVideoWrapper_QQ/utils"
DESCRIPTION = "Simple Power Lora Loader - apply multiple loras with a single strength each (no model_low support)"
OUTPUT_NODE = False
def load_loras(self, model=None, clip=None, **kwargs):
"""Applies each enabled lora with a single strength to model (and clip)."""
# Check if clip is actually connected (from JavaScript hasClip property)
has_clip = getattr(self, 'hasClip', clip is not None)
from nodes import LoraLoader
# Collect LoRA information for summary printing
loras_summary = []
for key, value in kwargs.items():
if key.startswith('lora_') and isinstance(value, dict) and 'on' in value and 'lora' in value and 'strength' in value:
# Check if lora is enabled and has valid data
if not value['on']:
continue
lora_name = value['lora']
if not lora_name or lora_name.lower() == "none":
continue
# Use the sophisticated LoRA path resolution from rgthree-comfy
try:
from rgthree_comfy.py.power_prompt_utils import get_lora_by_filename
lora_filename = get_lora_by_filename(lora_name, log_node=self.__class__.__name__)
except ImportError:
# Fallback to basic path resolution if rgthree-comfy is not available
lora_filename = get_lora_by_filename_basic(lora_name, log_node=self.__class__.__name__)
if lora_filename is None:
# Try to get the full path directly from ComfyUI as a fallback
try:
lora_path = folder_paths.get_full_path("loras", lora_name)
if lora_path and os.path.exists(lora_path):
lora_filename = os.path.basename(lora_name.replace('\\', '/'))
else:
print(f"[SimplePowerLoraLoader] LoRA file '{lora_name}' not found in loras folder.")
continue
except Exception as e:
print(f"[SimplePowerLoraLoader] Error getting full path for '{lora_name}': {e}")
continue
strength = value['strength']
strength = round(strength, 4) if not isinstance(strength, list) else strength
# Skip if strength is zero or None
if strength == 0.0 or strength is None:
continue
# Apply the lora using ComfyUI's LoraLoader
try:
if not has_clip:
# No clip connected - act like LoraLoaderModelOnly
if model is not None:
model, _ = LoraLoader().load_lora(model, None, lora_filename, strength, 0)
else:
if model is not None:
model, clip = LoraLoader().load_lora(model, clip, lora_filename, strength, strength)
except Exception as e:
print(f"[SimplePowerLoraLoader] Error loading lora '{lora_name}': {e}")
continue
loras_summary.append((strength, lora_filename))
# Print the summary
if loras_summary:
print("------------------------------")
print(" == LORAS :")
for strength, filename in loras_summary:
print(f" {strength:.2f} - {filename}")
print("------------------------------")
# Return different tuples based on whether clip was connected
if not has_clip:
return (model,)
else:
return (model, clip)
class CheckpointLoader_v2:
"""A power checkpoint loader v2 that provides PowerLoraLoaderV2-style UI for model checkpoints."""
+476
View File
@@ -0,0 +1,476 @@
/**
* Power Image Grid - frontend
*
* Displays the images piped into its input almost exactly like the default
* "Preview Image" node does for multiple images: a plain DOM grid of <img>
* elements, nothing else. No toolbar, no top row, no buttons beyond the
* per-image crop toggle.
*
* Layout: node size + image count determine rows/columns (largest uniform
* cell that fits wins, aspect from the FIRST image); resizing the node
* re-wraps the grid.
*
* Each image gets a small scissors button in its top-right corner that
* toggles an interactive crop box for THAT image only (drag body to move,
* corner handles to resize - same interaction as Power Load Video's box
* crop). Crop values are stored as normalized {enabled, x, y, w, h} (center
* + size) in the hidden `crop_json` STRING widget the backend parses on
* execute. The backend returns UNCROPPED previews, so crop boxes stay live.
*/
import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js';
const GAP = 4; // px gap between grid cells
const BTN_W = 24; // crop button size (per cell, top-right corner)
const BTN_H = 18;
const HANDLE = 10; // crop corner-handle hit radius
const MIN_CELL = 320; // minimum cell size - cells scale up with the node
const WIDGET_AREA = 110; // fallback offset above the grid before first draw
const GRID_MIN_H = 160; // widget's DECLARED height (fixed, never derived
// from node size: litegraph enforces node min
// height = sum of widget heights, so a size-derived
// value would lock the node and block shrinking)
function defaultCrop() {
return { enabled: false, x: 0.5, y: 0.5, w: 1.0, h: 1.0 };
}
app.registerExtension({
name: 'PowerImageGrid',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== 'PowerImageGrid') return;
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = originalOnNodeCreated?.apply(this, arguments);
const node = this;
// Guard against double initialization - a second (empty) container
// would stack on top and paint a black sheet over the images
if (node.gridWidget) return r;
node.gridMeta = []; // { w, h } natural image sizes
node.gridCrops = []; // per-image crop state (serialized via crop_json)
node.cellEls = []; // per-image cell DOM elements
if (node.size[0] < 320) node.size[0] = 320;
if (node.size[1] < 240) node.size[1] = 240;
// === DOM widget: plain image grid (Preview Image style) ===
const container = document.createElement('div');
container.style.cssText =
'width:100%;height:' + GRID_MIN_H + 'px;background:transparent;' +
'position:relative;overflow:hidden;box-sizing:border-box;';
const grid = document.createElement('div');
grid.style.cssText = [
'width:100%', 'height:100%',
'display:grid',
`gap:${GAP}px`,
'overflow:hidden',
'box-sizing:border-box',
'padding:2px',
].join(';');
container.appendChild(grid);
node.gridEl = grid;
node.gridWidget = node.addDOMWidget('grid', 'GridDisplay', container, {
serialize: false,
hideOnZoom: false,
});
// FIXED declared height: keeps litegraph's node-min-size constant
// so the node can always be freely resized in both directions.
node.gridWidget.computeSize = () => [node.size[0], GRID_MIN_H];
// Keep the element's real height synced to the actual node height
// (called from onResize AND onDrawBackground, so it converges no
// matter what changed the size). The grid always fills the node.
node.syncGridSize = () => {
const offset = node.gridWidget.last_y || WIDGET_AREA;
const h = Math.max(GRID_MIN_H, Math.min(4096, node.size[1] - offset - 2));
if (Math.abs((node._lastGridH || 0) - h) > 0.5) {
node._lastGridH = h;
container.style.height = h + 'px';
requestAnimationFrame(() => node.layoutGrid());
}
};
node.syncGridSize();
// Hide the crop_json widget (backend serialization only)
setTimeout(() => {
const w = node.widgets?.find((w) => w.name === 'crop_json');
if (w) {
w.computeSize = () => [0, 0];
w.hidden = true;
if (typeof w.value !== 'string' || !w.value.trim()) w.value = '[]';
}
}, 0);
// === crop state helpers ===
node.ensureCrops = (n) => {
while (node.gridCrops.length < n) node.gridCrops.push(defaultCrop());
};
node.syncCropsToWidget = () => {
const w = node.widgets?.find((w) => w.name === 'crop_json');
if (w) {
w.value = JSON.stringify(
node.gridCrops.map((c) => ({
enabled: !!c.enabled, x: c.x, y: c.y, w: c.w, h: c.h,
}))
);
}
};
// Fit an image fully inside a cell rect preserving aspect
// (CSS object-fit: contain) - nothing gets cropped away, so wide
// (2:1) and tall (1:2) images both stay fully visible. Crop-box
// coordinates map onto this rect.
node.coverRect = (cw, ch, meta) => {
if (!meta || !meta.w || !meta.h) return { x: 0, y: 0, w: cw, h: ch };
const s = Math.min(cw / meta.w, ch / meta.h);
const dw = meta.w * s;
const dh = meta.h * s;
return { x: (cw - dw) / 2, y: (ch - dh) / 2, w: dw, h: dh };
};
// === CELL CONSTRUCTION ===
node.buildCells = (n) => {
grid.innerHTML = '';
node.cellEls = [];
node.ensureCrops(n);
for (let i = 0; i < n; i++) {
const cell = document.createElement('div');
cell.style.cssText =
'position:relative;background:#161616;border:1px solid #333;' +
'overflow:hidden;box-sizing:border-box;';
const img = document.createElement('img');
img.style.cssText =
'width:100%;height:100%;display:block;object-fit:contain;' +
'pointer-events:none;background:#111;';
cell.appendChild(img);
// Crop overlay canvas (only visible while crop is active)
const cv = document.createElement('canvas');
cv.style.cssText =
'position:absolute;left:0;top:0;width:100%;height:100%;display:none;';
cell.appendChild(cv);
// Crop toggle button (top-right corner)
const btn = document.createElement('button');
btn.textContent = '✂';
btn.title = 'Toggle crop for this image';
btn.style.cssText = [
'position:absolute', `right:4px`, 'top:4px',
`width:${BTN_W}px`, `height:${BTN_H}px`,
'padding:0', 'line-height:1', 'font-size:12px',
'background:rgba(30,30,30,0.85)', 'color:#ccc',
'border:1px solid #555', 'border-radius:3px',
'cursor:pointer', 'z-index:3',
].join(';');
btn.addEventListener('mousedown', (e) => e.stopPropagation());
btn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
node.gridCrops[i].enabled = !node.gridCrops[i].enabled;
btn.style.background = node.gridCrops[i].enabled
? 'rgba(0,255,100,0.2)' : 'rgba(30,30,30,0.85)';
btn.style.color = node.gridCrops[i].enabled ? '#00ff64' : '#ccc';
btn.style.borderColor = node.gridCrops[i].enabled ? '#00ff64' : '#555';
cv.style.display = node.gridCrops[i].enabled ? 'block' : 'none';
if (node.gridCrops[i].enabled) node.drawCropOverlay(i);
node.syncCropsToWidget();
});
cell.appendChild(btn);
// Crop box drag interaction on the overlay canvas
cv.addEventListener('mousedown', (e) => node.cropMouseDown(e, i));
// Cursor feedback over crop handles / box
cv.addEventListener('mousemove', (e) => {
const c = node.gridCrops[i];
if (!c || !c.enabled) { cv.style.cursor = 'default'; return; }
const r = cv.getBoundingClientRect();
const mx = e.clientX - r.left, my = e.clientY - r.top;
const ir = node.coverRect(cell.clientWidth, cell.clientHeight, node.gridMeta[i]);
const cx = ir.x + c.x * ir.w;
const cy = ir.y + c.y * ir.h;
const hw = (c.w * ir.w) / 2;
const hh = (c.h * ir.h) / 2;
const near = (x, y) => Math.hypot(mx - x, my - y) <= HANDLE;
let cursor = 'default';
if (near(cx - hw, cy - hh)) cursor = 'nw-resize';
else if (near(cx + hw, cy - hh)) cursor = 'ne-resize';
else if (near(cx + hw, cy + hh)) cursor = 'se-resize';
else if (near(cx - hw, cy + hh)) cursor = 'sw-resize';
else if (mx >= cx - hw && mx <= cx + hw && my >= cy - hh && my <= cy + hh) cursor = 'move';
cv.style.cursor = cursor;
});
cv.addEventListener('mouseleave', () => { cv.style.cursor = 'default'; });
grid.appendChild(cell);
node.cellEls.push({ cell, img, cv, btn });
}
};
// === CROP OVERLAY (same math as Power Load Video's BoxCropWidget) ===
node.drawCropOverlay = (i) => {
const entry = node.cellEls[i];
if (!entry) return;
const cv = entry.cv;
const cw = entry.cell.clientWidth;
const chh = entry.cell.clientHeight;
if (cv.width !== Math.round(cw) || cv.height !== Math.round(chh)) {
cv.width = Math.round(cw);
cv.height = Math.round(chh);
}
const ctx = cv.getContext('2d');
ctx.clearRect(0, 0, cw, chh);
const c = node.gridCrops[i];
if (!c || !c.enabled) return;
const ir = node.coverRect(cw, chh, node.gridMeta[i]);
const cx = ir.x + c.x * ir.w;
const cy = ir.y + c.y * ir.h;
const hw = (c.w * ir.w) / 2;
const hh = (c.h * ir.h) / 2;
ctx.fillStyle = 'rgba(0, 255, 100, 0.25)';
ctx.strokeStyle = '#00ff64';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.rect(cx - hw, cy - hh, hw * 2, hh * 2);
ctx.fill();
ctx.stroke();
const handleSize = 8;
const corners = [
[cx - hw, cy - hh],
[cx + hw, cy - hh],
[cx + hw, cy + hh],
[cx - hw, cy + hh],
];
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
corners.forEach(([hx, hy]) => {
ctx.beginPath();
ctx.rect(hx - handleSize / 2, hy - handleSize / 2, handleSize, handleSize);
ctx.fill();
ctx.stroke();
});
ctx.beginPath();
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = '#000';
ctx.stroke();
};
node.cropMouseDown = (e, i) => {
const c = node.gridCrops[i];
if (!c || !c.enabled) return;
e.preventDefault();
e.stopPropagation();
const entry = node.cellEls[i];
const rect = entry.cv.getBoundingClientRect();
const toLocal = (ev) => [
ev.clientX - rect.left,
ev.clientY - rect.top,
];
const [sx, sy] = toLocal(e);
const ir = node.coverRect(entry.cell.clientWidth, entry.cell.clientHeight, node.gridMeta[i]);
const cx = ir.x + c.x * ir.w;
const cy = ir.y + c.y * ir.h;
const hw = (c.w * ir.w) / 2;
const hh = (c.h * ir.h) / 2;
// Corner hit first, then body
const corners = [
{ name: 'tl', x: cx - hw, y: cy - hh },
{ name: 'tr', x: cx + hw, y: cy - hh },
{ name: 'br', x: cx + hw, y: cy + hh },
{ name: 'bl', x: cx - hw, y: cy + hh },
];
let hitCorner = null;
for (const cor of corners) {
if (Math.hypot(sx - cor.x, sy - cor.y) <= HANDLE) { hitCorner = cor.name; break; }
}
const inside = sx >= cx - hw && sx <= cx + hw && sy >= cy - hh && sy <= cy + hh;
if (!hitCorner && !inside) return;
const drag = { corner: hitCorner, start: { x: sx, y: sy }, init: { ...c } };
const onMove = (ev) => {
const [mx, my] = toLocal(ev);
if (ir.w <= 0 || ir.h <= 0) return;
const toNorm = (px, py) => [(px - ir.x) / ir.w, (py - ir.y) / ir.h];
if (!hitCorner) {
// move
const [nx0, ny0] = toNorm(drag.start.x, drag.start.y);
const [nx1, ny1] = toNorm(mx, my);
c.x = Math.max(c.w / 2, Math.min(1 - c.w / 2, drag.init.x + (nx1 - nx0)));
c.y = Math.max(c.h / 2, Math.min(1 - c.h / 2, drag.init.y + (ny1 - ny0)));
} else {
// resize, opposite corner fixed
const iv = drag.init;
const ocx = ir.x + iv.x * ir.w;
const ocy = ir.y + iv.y * ir.h;
const ohw = (iv.w * ir.w) / 2;
const ohh = (iv.h * ir.h) / 2;
let ox, oy;
switch (hitCorner) {
case 'tl': ox = ocx + ohw; oy = ocy + ohh; break;
case 'tr': ox = ocx - ohw; oy = ocy + ohh; break;
case 'br': ox = ocx - ohw; oy = ocy - ohh; break;
case 'bl': ox = ocx + ohw; oy = ocy - ohh; break;
}
const px = Math.max(ir.x, Math.min(ir.x + ir.w, mx));
const py = Math.max(ir.y, Math.min(ir.y + ir.h, my));
c.x = ((ox + px) / 2 - ir.x) / ir.w;
c.y = ((oy + py) / 2 - ir.y) / ir.h;
c.w = Math.max(0.05, Math.min(1, Math.abs(px - ox) / ir.w));
c.h = Math.max(0.05, Math.min(1, Math.abs(py - oy) / ir.h));
}
node.drawCropOverlay(i);
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
node.syncCropsToWidget();
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
// === LAYOUT (node size + image count -> rows/columns) ===
// Explicit CSS grid template (no flex re-wrapping -> no jitter):
// cells fill the node and scale smoothly with it, never smaller
// than MIN_CELL. Node is 640+ tall -> second row appears, etc.
node.layoutGrid = () => {
const n = node.cellEls.length;
if (n === 0) {
grid.style.gridTemplateColumns = '';
grid.style.gridTemplateRows = '';
return;
}
const availW = Math.max(MIN_CELL, grid.clientWidth - 4);
const availH = Math.max(MIN_CELL, grid.clientHeight - 4);
// How many 320px cells fit per axis (at least 1 each)
let cols = Math.max(1, Math.floor(availW / MIN_CELL));
let rows = Math.max(1, Math.floor(availH / MIN_CELL));
// More images than cells: add columns/rows on whichever axis
// still has more space, until every image has a cell
while (cols * rows < n) {
if (availW / cols >= availH / rows) cols++;
else rows++;
}
grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
grid.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
node.cellEls.forEach((entry, i) => {
if (node.gridCrops[i]?.enabled) node.drawCropOverlay(i);
});
};
// Node resized -> just re-sync the element height (syncGridSize
// only re-lays-out when the height actually changed, so auto-fit
// echoes are harmless). The declared widget height is FIXED, so
// litegraph's node minimum size never blocks shrinking.
const originalOnResize = node.onResize;
node.onResize = function (size) {
originalOnResize?.apply(this, arguments);
node.syncGridSize();
};
// === Receive previews from the backend ===
// Uses the custom "grid_images" ui key: the frontend natively
// renders any "images" key for output nodes (drawn under the node),
// which would duplicate our grid.
const originalOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
originalOnExecuted?.apply(this, arguments);
const imgs = message?.grid_images;
if (!Array.isArray(imgs) || imgs.length === 0) return;
const total = imgs.length;
if (node.cellEls.length !== total) node.buildCells(total);
node.ensureCrops(total);
let loaded = 0;
// Cache-buster: the backend reuses the same preview filenames
// every run, so without this the browser would keep showing
// the cached (previous-run) images and never fire onload.
const bust = Date.now();
imgs.forEach((info, i) => {
const url = api.apiURL(
`/view?filename=${encodeURIComponent(info.filename)}` +
`&subfolder=${encodeURIComponent(info.subfolder || '')}` +
`&type=${encodeURIComponent(info.type || 'temp')}` +
`&pigrid_run=${bust}_${i}`
);
const entry = node.cellEls[i];
const im = entry.img;
im.onload = () => {
node.gridMeta[i] = { w: im.naturalWidth, h: im.naturalHeight };
// Restore per-cell crop button state
const active = node.gridCrops[i]?.enabled;
entry.cv.style.display = active ? 'block' : 'none';
entry.btn.style.background = active ? 'rgba(0,255,100,0.2)' : 'rgba(30,30,30,0.85)';
entry.btn.style.color = active ? '#00ff64' : '#ccc';
entry.btn.style.borderColor = active ? '#00ff64' : '#555';
if (active) node.drawCropOverlay(i);
if (++loaded === total) node.layoutGrid();
};
im.onerror = () => {
if (++loaded === total) node.layoutGrid();
};
im.src = url;
});
};
// === Restore crops from the saved workflow ===
const originalOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
originalOnConfigure?.apply(this, arguments);
const w = node.widgets?.find((w) => w.name === 'crop_json');
if (w && typeof w.value === 'string') {
try {
const arr = JSON.parse(w.value);
if (Array.isArray(arr)) {
node.gridCrops = arr.map((c) => ({
enabled: !!(c && c.enabled),
x: Math.min(1, Math.max(0, +(c?.x ?? 0.5) || 0.5)),
y: Math.min(1, Math.max(0, +(c?.y ?? 0.5) || 0.5)),
w: Math.min(1, Math.max(0.05, +(c?.w ?? 1) || 1)),
h: Math.min(1, Math.max(0.05, +(c?.h ?? 1) || 1)),
}));
}
} catch (err) { /* keep defaults */ }
}
};
// Keep the element height in sync on every redraw as well (covers
// size changes that don't fire onResize, e.g. workflow load).
// Done once at prototype level in beforeRegisterNodeDef (below).
return r;
};
// Keep the element height in sync on every redraw (covers size changes
// that don't fire onResize, e.g. workflow load). Wrapped ONCE at
// prototype level; syncGridSize is per-instance and change-guarded.
const originalOnDrawBackground = nodeType.prototype.onDrawBackground;
nodeType.prototype.onDrawBackground = function () {
originalOnDrawBackground?.apply(this, arguments);
this.syncGridSize?.();
};
},
});
+23 -1
View File
@@ -427,6 +427,25 @@ class WanInfoDialog extends WanDialog {
opacity: 1;
transform: translateY(0px);
}
/* Constrain long prompt text (positive/negative) so the caption
doesn't grow past the image and clip off the Copy+ button. */
.rgthree-info-dialog .rgthree-info-images figure figcaption > span.-info-field-long {
display: block;
max-height: 8.8em; /* ~8 lines at 11px font */
overflow-y: auto;
overscroll-behavior: contain;
}
.rgthree-info-dialog .rgthree-info-images figure figcaption > span.-info-field-long > label {
position: sticky;
left: 0;
}
.rgthree-info-dialog .rgthree-info-images figure figcaption > span.-info-field-long::-webkit-scrollbar {
width: 6px;
}
.rgthree-info-dialog .rgthree-info-images figure figcaption > span.-info-field-long::-webkit-scrollbar-thumb {
background: #444;
}
`;
const style = document.createElement('style');
@@ -766,5 +785,8 @@ function saveEditableRow(info, tr, saving = true) {
}
function imgInfoField(label, value) {
return value != null ? `<span>${label ? `<label>${label} </label>` : ""}${value}</span>` : "";
if (value == null) return "";
const isLong = label === "positive" || label === "negative";
const cls = isLong ? ` class="-info-field-long"` : "";
return `<span${cls}>${label ? `<label>${label} </label>` : ""}${value}</span>`;
}
@@ -0,0 +1,622 @@
/**
* Shared factory for the Power Lora Loader family of nodes
* (power_lora_loader_v2.js / power_lora_loader_simple.js).
*
* Eliminates code duplication across the loaders: the extension logic, LoRA
* picker wiring, UI-state persistence and the row/header widgets live here and
* are configured per node via `createPowerLoraLoaderExtension(config)`.
*
* Config options:
* extensionName - unique extension name for app.registerExtension
* nodeNames - array of node class names this extension hooks
* logTag - prefix used in console messages
* strengthSplit - true: Low + High strength columns (v2 style)
* false: single united Strength column (simple style)
* copyButtons - draw the "<" / ">" strength copy buttons
* outputBoth - draw the "Output both" toggle + hidden output_both widget
* localStoragePrefix - prefix for UI state localStorage keys
*/
import { app } from "../../../scripts/app.js";
import {
RgthreeBaseWidget, RgthreeBetterButtonWidget, RgthreeDividerWidget,
StrengthCopyWidget, PowerLoraLoaderHeaderWidget, PowerLoraLoaderWidget,
fitString, drawTogglePart, drawRoundedRectangle,
} from "./widgets.js";
import { LoraPickerDialog } from "./lora_picker_dialog.js";
import { getLoraSlotInPosition, getLoraSlotMenuOptions } from "./lora_context_menu.js";
import { modelDataManager, UIManager } from "./pull_info.js";
const MINIMUM_NODE_WIDTH = 480;
const STRENGTH_WIDTH = 60;
const ICON_WIDTH = 20;
const MARGIN = 10;
// Unified data fetching using pull_info.js
async function getLoras(forceRefresh = false) {
return modelDataManager.getItems('loras', forceRefresh);
}
// Functions to persist favorites using pull_info.js
function saveFavorites(favorites) {
UIManager.saveFavorites(favorites, 'loras');
}
function loadFavorites() {
return UIManager.loadFavorites('loras');
}
// Functions to persist UI state using pull_info.js
function saveFoldersVisible(foldersVisible) {
UIManager.saveUIProperty('foldersVisible', foldersVisible, 'loras');
}
function loadFoldersVisible() {
return UIManager.loadUIProperty('foldersVisible', 'loras', false);
}
function saveSelectedFolder(selectedFolder) {
UIManager.saveUIProperty('selectedFolder', selectedFolder, 'loras');
}
function loadSelectedFolder() {
return UIManager.loadUIProperty('selectedFolder', 'loras', null);
}
function saveEyeRefreshState(eyeRefreshState) {
UIManager.saveUIProperty('eyeRefreshState', eyeRefreshState, 'loras');
}
function loadEyeRefreshState() {
return UIManager.loadUIProperty('eyeRefreshState', 'loras', false);
}
function showLoraPicker(event, callback, node = null, logTag = "PowerLoraLoader") {
getLoras().then(loras => {
const savedFavorites = loadFavorites();
// Load UI state from localStorage or node properties
let foldersVisible = loadFoldersVisible();
let eyeRefreshState = loadEyeRefreshState();
let selectedFolder = loadSelectedFolder();
if (node && node.widgets) {
const uiStateWidget = node.widgets.find(w => w.name === "ui_state");
if (uiStateWidget && uiStateWidget.value) {
try {
const uiState = JSON.parse(uiStateWidget.value);
if (uiState.foldersVisible !== undefined) foldersVisible = uiState.foldersVisible;
if (uiState.eyeRefreshState !== undefined) eyeRefreshState = uiState.eyeRefreshState;
if (uiState.selectedFolder !== undefined) selectedFolder = uiState.selectedFolder;
} catch (e) {
console.warn(`[${logTag}] Failed to parse UI state from widget:`, e);
}
}
}
const dialog = new LoraPickerDialog(loras, {
parentNode: node,
callback: callback,
sort: "Latest",
favorites: savedFavorites,
favoritesOnly: false,
itemType: 'loras', // Explicitly set item type for unified system
onFavoriteToggle: (loraName) => {
const index = savedFavorites.indexOf(loraName);
if (index !== -1) {
// Remove from favorites
savedFavorites.splice(index, 1);
} else {
// Add to favorites
savedFavorites.push(loraName);
}
// Save to localStorage using unified system
saveFavorites(savedFavorites);
// Update dialog's favorites
dialog.options.favorites = savedFavorites;
// Re-render the list to update star colors
dialog.renderList();
},
foldersVisible: foldersVisible,
refreshCallback: async () => {
// Perform actual refresh of the LoRA list using unified system
try {
const freshLoras = await getLoras(true);
dialog.updateLoras(freshLoras);
// Also refresh preview availability for blue dots using unified system
await modelDataManager.getPreviewAvailability('loras');
// Re-render the list to show updated blue dots
dialog.renderList();
} catch (error) {
console.error(`[${logTag}] Error refreshing LoRA list:`, error);
}
}
});
// Initialize dialog's UI state
dialog.eyeRefreshState = eyeRefreshState;
dialog.selectedFolder = selectedFolder;
dialog.show();
});
}
// Header: "Toggle All" + either "Low"/"High" column labels (strengthSplit)
// or a single "Strength" column label.
export class PowerLoraLoaderHeaderWidgetEx extends PowerLoraLoaderHeaderWidget {
constructor(strengthSplit) {
super();
this.strengthSplit = strengthSplit;
}
draw(ctx, node, w, y, h) {
if (this.strengthSplit) {
super.draw(ctx, node, w, y, h);
return;
}
const allLoraState = node.allLorasState();
let posX = MARGIN;
this.hitAreas.toggle.bounds = drawTogglePart(ctx, [posX, y], h, allLoraState);
posX += this.hitAreas.toggle.bounds[1] + MARGIN;
ctx.textAlign = "left";
ctx.fillStyle = "white";
ctx.textBaseline = "middle";
ctx.fillText("Toggle All", posX, y + h / 2);
ctx.textAlign = "right";
let rposX = w - MARGIN;
ctx.fillText("Strength", rposX, y + h / 2);
}
}
// One row per LoRA. With strengthSplit = true it behaves exactly like the v2
// widget (Low + High strengths). With strengthSplit = false the Low column is
// hidden and the row shows a single united Strength value.
export class PowerLoraLoaderWidgetEx extends PowerLoraLoaderWidget {
constructor(name, showLoraChooser, strengthSplit) {
super(name, showLoraChooser);
this.strengthSplit = strengthSplit;
if (!strengthSplit) {
// Single-strength mode: no Low strength hit area / interactions
delete this.hitAreas.low_strength;
}
}
draw(ctx, node, w, y, h) {
if (this.strengthSplit) {
super.draw(ctx, node, w, y, h);
return;
}
drawRoundedRectangle(ctx, [MARGIN, y], [w - MARGIN * 2, h], h * 0.5);
if (!this.value.on) {
ctx.globalAlpha = 0.5;
}
let posX = MARGIN;
this.hitAreas.toggle.bounds = drawTogglePart(ctx, [posX, y], h, this.value.on);
posX += this.hitAreas.toggle.bounds[1] + MARGIN;
let rposX = w - MARGIN;
if (this.value.use_for_both) {
ctx.fillStyle = "#9370DB"; // Purple for "both" state
} else if (this.value.low_only) {
ctx.fillStyle = "#4169E1"; // Blue for "low only" state
} else if (this.value.high_only) {
ctx.fillStyle = "#DC143C"; // Red for "high only" state
} else if (this.value.is_low) {
ctx.fillStyle = this.value.low_active ? "lime" : "orange";
} else {
ctx.fillStyle = "#555";
}
ctx.beginPath();
ctx.arc(rposX - ICON_WIDTH / 2, y + h / 2, h * 0.2, 0, Math.PI * 2);
ctx.fill();
// Store the bounds for the low variant icon
this.hitAreas.low_variant_icon.bounds = [
rposX - ICON_WIDTH,
y,
ICON_WIDTH,
h
];
rposX -= ICON_WIDTH + MARGIN;
this.hitAreas.strength.bounds = [rposX - STRENGTH_WIDTH, STRENGTH_WIDTH];
ctx.textAlign = "right";
ctx.fillStyle = "white";
ctx.textBaseline = "middle";
ctx.fillText(this.value.strength.toFixed(2), rposX, y + h / 2);
rposX -= STRENGTH_WIDTH + MARGIN;
const loraWidth = rposX - posX;
this.hitAreas.lora.bounds = [posX, loraWidth];
ctx.textAlign = "left";
ctx.fillText(fitString(ctx, this.value.lora, loraWidth), posX, y + h / 2);
ctx.globalAlpha = 1;
}
}
/**
* Builds and registers a full extension for one (or more) power lora loader
* node types. See the module docblock for the available config options.
*/
export function createPowerLoraLoaderExtension(config) {
const {
extensionName,
nodeNames = [],
logTag = "PowerLoraLoader",
strengthSplit = true,
copyButtons = true,
outputBoth = true,
localStoragePrefix = "powerLoraLoader",
} = config;
const nodeSet = new Set(nodeNames);
function showPicker(event, callback, node = null) {
return showLoraPicker(event, callback, node, logTag);
}
app.registerExtension({
name: extensionName,
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (!nodeSet.has(nodeData.name)) {
return;
}
nodeType.prototype.lorasCache = [];
nodeType.prototype.addCustomWidget = function(widget) {
if (!this.widgets) {
this.widgets = [];
}
widget.parent = this;
this.widgets.push(widget);
return widget;
};
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
this.serialize_widgets = true;
this.loraWidgetsCounter = 0;
this.hasClip = false; // Initialize clip connection state
this.properties = this.properties || {};
if (outputBoth) {
this.properties['Show Strengths'] = "Single Strength";
this.properties['output_both'] = false;
}
this.addNonLoraWidgets();
// Ensure minimum size
const computed = this.computeSize();
this.size = this.size || [0, 0];
this.size[0] = Math.max(MINIMUM_NODE_WIDTH, this.size[0], computed[0]);
this.size[1] = Math.max(this.size[1], computed[1]);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
onConfigure?.apply(this, arguments);
this.loraWidgetsCounter = 0;
// Restore hasClip state if it exists
if (info.hasClip !== undefined) {
this.hasClip = info.hasClip;
}
if (this.widgets) {
const widgets_to_remove = [...this.widgets];
for (const widget of widgets_to_remove) {
this.removeWidget(widget);
}
}
this.addNonLoraWidgets();
if (info.widgets_values) {
for (const widgetValue of info.widgets_values) {
if (widgetValue && widgetValue.lora !== undefined) {
const widget = this.addNewLoraWidget();
widget.value = { ...widgetValue };
}
}
}
getLoras().then((loras) => {
this.lorasCache = loras;
for (const widget of this.widgets) {
if (widget.name?.startsWith("lora_")) {
widget.checkLowLoraVariant(widget.value.lora);
}
}
});
// Restore UI state from widget if it exists
if (this.properties.uiState) {
const uiStateWidget = this.widgets.find(w => w.name === "ui_state");
if (uiStateWidget) {
uiStateWidget.value = this.properties.uiState;
}
}
// Also check if there is a ui_state value directly in the widget_values
if (info.widgets_values) {
for (let i = 0; i < info.widgets_values.length; i++) {
const widgetValue = info.widgets_values[i];
if (widgetValue && typeof widgetValue === 'string') {
try {
const parsed = JSON.parse(widgetValue);
if (parsed && typeof parsed === 'object' && !parsed.lora) {
// This looks like UI state data, update the widget
const uiStateWidget = this.widgets.find(w => w.name === "ui_state");
if (uiStateWidget) {
uiStateWidget.value = widgetValue;
}
break;
}
} catch (e) {
// Not valid JSON, skip
}
}
}
}
// Sync output_both property to widget
if (outputBoth) {
const outputBothWidget = this.widgets.find(w => w.name === "output_both");
if (outputBothWidget && this.properties.output_both !== undefined) {
outputBothWidget.value = this.properties.output_both;
}
}
};
nodeType.prototype.addNewLoraWidget = function () {
this.loraWidgetsCounter++;
const widget = new PowerLoraLoaderWidgetEx("lora_" + this.loraWidgetsCounter, showPicker, strengthSplit);
this.addCustomWidget(widget);
if (this.widgetButtonSpacer) {
const index = this.widgets.indexOf(this.widgetButtonSpacer);
this.widgets.splice(index, 0, this.widgets.pop());
}
return widget;
};
nodeType.prototype.addNonLoraWidgets = function () {
// Optional strength-copy row: "Output both" toggle + "<"/">" buttons
if (outputBoth || copyButtons) {
this.addCustomWidget(new StrengthCopyWidget(outputBoth, copyButtons));
}
this.addCustomWidget(new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 }));
this.addCustomWidget(new PowerLoraLoaderHeaderWidgetEx(strengthSplit));
this.widgetButtonSpacer = this.addCustomWidget(new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 }));
this.addCustomWidget(new RgthreeBetterButtonWidget("➕ Add Lora", (event, pos, node) => {
showPicker(event, (lora) => {
if (lora) {
this.addNewLoraWidget().setLora(lora);
}
}, node);
}));
// Add hidden widget for UI state persistence
const uiStateWidget = this.addWidget("string", "ui_state", "{}", () => {},
{ serialize: true });
uiStateWidget.type = "hidden";
// Keep serialize: true to ensure UI state is saved in workflow JSON
// Add hidden widget for output_both toggle state
if (outputBoth) {
const outputBothValue = this.properties.output_both || false;
const outputBothWidget = this.addWidget("boolean", "output_both", outputBothValue, () => {},
{ serialize: true });
outputBothWidget.type = "hidden";
}
};
nodeType.prototype.allLorasState = function() {
let allOn = true;
let allOff = true;
for (const widget of this.widgets) {
if (widget.name?.startsWith("lora_")) {
const on = widget.value?.on;
allOn = allOn && on === true;
allOff = allOff && on === false;
if (!allOn && !allOff) {
return null;
}
}
}
return allOn ? true : (allOff ? false : null);
};
nodeType.prototype.toggleAllLoras = function() {
const allOn = this.allLorasState();
const toggledTo = !allOn;
for (const widget of this.widgets) {
if (widget.name?.startsWith("lora_") && widget.value?.on != null) {
widget.value.on = toggledTo;
}
}
};
// Add width locking mechanism to prevent shrinking below minimum width
nodeType.prototype.onResize = function(size) {
// Enforce minimum width constraint
if (size && size[0] < MINIMUM_NODE_WIDTH) {
size[0] = MINIMUM_NODE_WIDTH;
}
// Call the original onResize if it exists
if (LGraphNode.prototype.onResize) {
return LGraphNode.prototype.onResize.call(this, size);
}
return size;
};
// Add method to compute size properly
nodeType.prototype.computeSize = function(width) {
const minWidth = MINIMUM_NODE_WIDTH;
const minHeight = 100; // Base height
let computedHeight = minHeight;
// Calculate height based on number of lora widgets
if (this.widgets) {
const loraWidgets = this.widgets.filter(w => w.name?.startsWith("lora_"));
if (loraWidgets.length > 0) {
computedHeight += loraWidgets.length * LiteGraph.NODE_WIDGET_HEIGHT;
}
}
// Account for other widgets too
if (this.widgets) {
// Count non-lora widgets that contribute to height
const nonLoraWidgets = this.widgets.filter(w => !w.name?.startsWith("lora_") &&
!(w.computeSize === undefined));
if (nonLoraWidgets.length > 0) {
for (const widget of nonLoraWidgets) {
if (widget.computeSize) {
const widgetSize = widget.computeSize(width || this.size[0]);
if (widgetSize && widgetSize[1]) {
computedHeight += widgetSize[1];
} else {
computedHeight += LiteGraph.NODE_WIDGET_HEIGHT;
}
} else {
computedHeight += LiteGraph.NODE_WIDGET_HEIGHT;
}
}
} else {
// If there are no special computeSize widgets, just add base amount for non-lora widgets
const otherWidgetsCount = this.widgets.length - (this.widgets.filter(w => w.name?.startsWith("lora_")).length);
computedHeight += otherWidgetsCount * LiteGraph.NODE_WIDGET_HEIGHT;
}
}
return [minWidth, computedHeight];
};
nodeType.prototype.getSlotInPosition = getLoraSlotInPosition;
nodeType.prototype.getSlotMenuOptions = getLoraSlotMenuOptions;
// Store clip connection state for Python code to check
const originalOnConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function(type, slotIndex, isConnected, link_info, ioSlot) {
originalOnConnectionsChange?.apply(this, arguments);
// Store the clip connection state (by input name, not slot index)
if (type === LiteGraph.INPUT && ioSlot && ioSlot.name === "clip") {
this.hasClip = isConnected;
this.setDirtyCanvas(true, true);
}
};
// Override serialize to include hasClip state
const originalOnSerialize = nodeType.prototype.serialize;
nodeType.prototype.serialize = function() {
const data = originalOnSerialize?.apply(this, arguments) || {};
data.hasClip = this.hasClip;
return data;
};
// Add onSerialize method to persist UI state
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(o) {
onSerialize?.apply(this, arguments);
let uiState = null;
const uiStateWidget = this.widgets.find(w => w.name === "ui_state");
if (uiStateWidget && uiStateWidget.value) {
try {
uiState = JSON.parse(uiStateWidget.value);
} catch (e) {
console.warn(`[${logTag}] Failed to parse existing widget value:`, e);
}
}
if (!uiState) {
// Try to get from localStorage with per-loader keys
const foldersVisible = localStorage.getItem(`${localStoragePrefix}.foldersVisible`);
const eyeRefreshState = localStorage.getItem(`${localStoragePrefix}.eyeRefreshState`);
const selectedFolder = localStorage.getItem(`${localStoragePrefix}.selectedFolder`);
uiState = {
foldersVisible: foldersVisible ? JSON.parse(foldersVisible) : false,
eyeRefreshState: eyeRefreshState ? JSON.parse(eyeRefreshState) : false,
selectedFolder: selectedFolder ? JSON.parse(selectedFolder) : null,
};
}
this.properties.uiState = JSON.stringify(uiState);
if (uiStateWidget) {
uiStateWidget.value = JSON.stringify(uiState);
}
};
// Pre-load loras
getLoras().then(loras => {
nodeType.prototype.lorasCache = loras;
});
// Add refreshComboInNode method to handle R key press for reloading definitions
nodeType.prototype.refreshComboInNode = function(defs) {
// Use unified system to refresh the LoRA list
modelDataManager.clearCaches('loras');
// Fetch fresh loras and update cache
getLoras(true).then((lorasDetails) => {
nodeType.prototype.lorasCache = lorasDetails;
// Update all nodes of this type
app.graph._nodes.forEach(node => {
if (nodeSet.has(node.type)) {
// Update any existing widgets that might need the fresh data
for (const widget of node.widgets || []) {
if (widget.name?.startsWith("lora_")) {
// Update the widget's parent reference to this node
widget.parent = node;
// Refresh low variant detection for existing LoRAs with new cache
if (widget.value?.lora && widget.value.lora !== "None") {
const oldIsLow = widget.value.is_low;
const oldVariantName = widget.value.low_variant_name;
// Re-run the low variant check with updated cache
widget.checkLowLoraVariant(widget.value.lora);
// Log changes in low variant detection
if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) {
console.log(`[${logTag}] Low variant status changed for '${widget.value.lora}': ` +
`${oldIsLow} -> ${widget.value.is_low}, ` +
`variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`);
}
}
// Trigger a value change to update the UI
if (widget.callback && typeof widget.callback === 'function') {
widget.callback(widget.value);
}
}
}
// Trigger a redraw to update the green low icons
node.setDirtyCanvas(true, true);
}
});
console.log(`[${logTag}] LoRA definitions refreshed - individual LoRA info available in dialogs`);
}).catch(error => {
console.error(`[${logTag}] Error refreshing LoRA list:`, error);
});
};
}
});
}
@@ -0,0 +1,18 @@
/**
* Simple Power Lora Loader UI.
* All logic lives in the shared factory (power_lora_loader_common.js); this
* file only provides the simple configuration: one united Strength per LoRA,
* no Low/High split, no "<" / ">" copy buttons and no "Output both" toggle.
*/
import { createPowerLoraLoaderExtension } from "./power_lora_loader_common.js";
createPowerLoraLoaderExtension({
extensionName: "WanVideo.SimplePowerLoraLoader",
nodeNames: ["SimplePowerLoraLoader"],
logTag: "SimplePowerLoraLoader",
strengthSplit: false, // Single united Strength column
copyButtons: false, // No "<" / ">" strength copy buttons
outputBoth: false, // No "Output both" toggle
localStoragePrefix: "powerLoraLoaderSimple",
});
+15 -468
View File
@@ -1,471 +1,18 @@
import { app } from "../../../scripts/app.js";
import { rgthree } from "../rgthree/common/rgthree.js";
import { RgthreeBetterButtonWidget, RgthreeDividerWidget, StrengthCopyWidget, PowerLoraLoaderHeaderWidget, PowerLoraLoaderWidget } from "./widgets.js";
import { LoraPickerDialog } from "./lora_picker_dialog.js";
import { getLoraSlotInPosition, getLoraSlotMenuOptions } from "./lora_context_menu.js";
import { modelDataManager, UIManager } from "./pull_info.js";
/**
* Power Lora Loader V2 UI.
* All logic lives in the shared factory (power_lora_loader_common.js); this
* file only provides the v2 configuration: Low/High strength split, "<" / ">"
* strength copy buttons and the "Output both" toggle (model_low support).
*/
const MINIMUM_NODE_WIDTH = 480;
import { createPowerLoraLoaderExtension } from "./power_lora_loader_common.js";
// Unified data fetching using pull_info.js
async function getLoras(forceRefresh = false) {
return modelDataManager.getItems('loras', forceRefresh);
}
// Functions to persist favorites using pull_info.js
function saveFavorites(favorites) {
UIManager.saveFavorites(favorites, 'loras');
}
function loadFavorites() {
return UIManager.loadFavorites('loras');
}
// Functions to persist UI state using pull_info.js
function saveFoldersVisible(foldersVisible) {
UIManager.saveUIProperty('foldersVisible', foldersVisible, 'loras');
}
function loadFoldersVisible() {
return UIManager.loadUIProperty('foldersVisible', 'loras', false);
}
function saveSelectedFolder(selectedFolder) {
UIManager.saveUIProperty('selectedFolder', selectedFolder, 'loras');
}
function loadSelectedFolder() {
return UIManager.loadUIProperty('selectedFolder', 'loras', null);
}
function saveEyeRefreshState(eyeRefreshState) {
UIManager.saveUIProperty('eyeRefreshState', eyeRefreshState, 'loras');
}
function loadEyeRefreshState() {
return UIManager.loadUIProperty('eyeRefreshState', 'loras', false);
}
function showLoraPicker(event, callback, node = null) {
getLoras().then(loras => {
const savedFavorites = loadFavorites();
// Load UI state from localStorage or node properties
let foldersVisible = loadFoldersVisible();
let eyeRefreshState = loadEyeRefreshState();
let selectedFolder = loadSelectedFolder();
if (node && node.widgets) {
const uiStateWidget = node.widgets.find(w => w.name === "ui_state");
if (uiStateWidget && uiStateWidget.value) {
try {
const uiState = JSON.parse(uiStateWidget.value);
if (uiState.foldersVisible !== undefined) foldersVisible = uiState.foldersVisible;
if (uiState.eyeRefreshState !== undefined) eyeRefreshState = uiState.eyeRefreshState;
if (uiState.selectedFolder !== undefined) selectedFolder = uiState.selectedFolder;
} catch (e) {
console.warn("[PowerLoraLoaderV2] Failed to parse UI state from widget:", e);
}
}
}
const dialog = new LoraPickerDialog(loras, {
parentNode: node,
callback: callback,
sort: "Latest",
favorites: savedFavorites,
favoritesOnly: false,
itemType: 'loras', // Explicitly set item type for unified system
onFavoriteToggle: (loraName) => {
const index = savedFavorites.indexOf(loraName);
if (index !== -1) {
// Remove from favorites
savedFavorites.splice(index, 1);
} else {
// Add to favorites
savedFavorites.push(loraName);
}
// Save to localStorage using unified system
saveFavorites(savedFavorites);
// Update dialog's favorites
dialog.options.favorites = savedFavorites;
// Re-render the list to update star colors
dialog.renderList();
},
foldersVisible: foldersVisible,
refreshCallback: async () => {
// Perform actual refresh of the LoRA list using unified system
try {
console.log('[PowerLoraLoaderV2] Refreshing LoRA list...');
const freshLoras = await getLoras(true);
dialog.updateLoras(freshLoras);
// Also refresh preview availability for blue dots using unified system
console.log('[PowerLoraLoaderV2] Refreshing preview availability...');
await modelDataManager.getPreviewAvailability('loras');
// Re-render the list to show updated blue dots
console.log('[PowerLoraLoaderV2] Re-rendering list to show updated blue dots...');
dialog.renderList();
console.log('[PowerLoraLoaderV2] LoRA list refreshed successfully');
} catch (error) {
console.error('[PowerLoraLoaderV2] Error refreshing LoRA list:', error);
}
}
});
// Initialize dialog's UI state
dialog.eyeRefreshState = eyeRefreshState;
dialog.selectedFolder = selectedFolder;
dialog.show();
});
}
app.registerExtension({
name: "WanVideo.PowerLoraLoaderV2",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "PowerLoraLoaderV2") {
nodeType.prototype.lorasCache = [];
nodeType.prototype.addCustomWidget = function(widget) {
if (!this.widgets) {
this.widgets = [];
}
widget.parent = this;
this.widgets.push(widget);
return widget;
};
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
this.serialize_widgets = true;
this.loraWidgetsCounter = 0;
this.hasClip = false; // Initialize clip connection state
this.properties = this.properties || {};
this.properties['Show Strengths'] = "Single Strength";
this.properties['output_both'] = false;
this.addNonLoraWidgets();
// Ensure minimum size
const computed = this.computeSize();
this.size = this.size || [0, 0];
this.size[0] = Math.max(MINIMUM_NODE_WIDTH, this.size[0], computed[0]);
this.size[1] = Math.max(this.size[1], computed[1]);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
onConfigure?.apply(this, arguments);
this.loraWidgetsCounter = 0;
// Restore hasClip state if it exists
if (info.hasClip !== undefined) {
this.hasClip = info.hasClip;
}
if (this.widgets) {
const widgets_to_remove = [...this.widgets];
for (const widget of widgets_to_remove) {
this.removeWidget(widget);
}
}
this.addNonLoraWidgets();
if (info.widgets_values) {
for (const widgetValue of info.widgets_values) {
if (widgetValue && widgetValue.lora !== undefined) {
const widget = this.addNewLoraWidget();
widget.value = { ...widgetValue };
}
}
}
getLoras().then((loras) => {
this.lorasCache = loras;
for (const widget of this.widgets) {
if (widget.name?.startsWith("lora_")) {
widget.checkLowLoraVariant(widget.value.lora);
}
}
});
// Restore UI state from widget if it exists
if (this.properties.uiState) {
const uiStateWidget = this.widgets.find(w => w.name === "ui_state");
if (uiStateWidget) {
uiStateWidget.value = this.properties.uiState;
}
}
// Also check if there's a ui_state value directly in the widget_values
if (info.widgets_values) {
// Look for the ui_state value in the widgets_values
for (let i = 0; i < info.widgets_values.length; i++) {
const widgetValue = info.widgets_values[i];
if (widgetValue && typeof widgetValue === 'string') {
try {
const parsed = JSON.parse(widgetValue);
if (parsed && typeof parsed === 'object' && !parsed.lora) {
// This looks like UI state data, update the widget
const uiStateWidget = this.widgets.find(w => w.name === "ui_state");
if (uiStateWidget) {
uiStateWidget.value = widgetValue;
}
break;
}
} catch (e) {
// Not valid JSON, skip
}
}
}
}
// Sync output_both property to widget
const outputBothWidget = this.widgets.find(w => w.name === "output_both");
if (outputBothWidget && this.properties.output_both !== undefined) {
outputBothWidget.value = this.properties.output_both;
}
};
nodeType.prototype.addNewLoraWidget = function () {
this.loraWidgetsCounter++;
const widget = new PowerLoraLoaderWidget("lora_" + this.loraWidgetsCounter, showLoraPicker);
this.addCustomWidget(widget);
if (this.widgetButtonSpacer) {
const index = this.widgets.indexOf(this.widgetButtonSpacer);
this.widgets.splice(index, 0, this.widgets.pop());
}
return widget;
};
nodeType.prototype.addNonLoraWidgets = function () {
// Only use StrengthCopyWidget for PowerLoraLoaderV2
this.addCustomWidget(new StrengthCopyWidget());
this.addCustomWidget(new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 }));
this.addCustomWidget(new PowerLoraLoaderHeaderWidget());
this.widgetButtonSpacer = this.addCustomWidget(new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 }));
this.addCustomWidget(new RgthreeBetterButtonWidget("➕ Add Lora", (event, pos, node) => {
showLoraPicker(event, (lora) => {
if (lora) {
this.addNewLoraWidget().setLora(lora);
}
}, node);
}));
// Add hidden widget for UI state persistence
const uiStateWidget = this.addWidget("string", "ui_state", "{}", () => {},
{ serialize: true });
uiStateWidget.type = "hidden";
// Keep serialize: true to ensure UI state is saved in workflow JSON
// Add hidden widget for output_both toggle state
const outputBothValue = this.properties.output_both || false;
const outputBothWidget = this.addWidget("boolean", "output_both", outputBothValue, () => {},
{ serialize: true });
outputBothWidget.type = "hidden";
};
nodeType.prototype.allLorasState = function() {
let allOn = true;
let allOff = true;
for (const widget of this.widgets) {
if (widget.name?.startsWith("lora_")) {
const on = widget.value?.on;
allOn = allOn && on === true;
allOff = allOff && on === false;
if (!allOn && !allOff) {
return null;
}
}
}
return allOn ? true : (allOff ? false : null);
};
nodeType.prototype.toggleAllLoras = function() {
const allOn = this.allLorasState();
const toggledTo = !allOn;
for (const widget of this.widgets) {
if (widget.name?.startsWith("lora_") && widget.value?.on != null) {
widget.value.on = toggledTo;
}
}
};
// Add width locking mechanism to prevent shrinking below minimum width
nodeType.prototype.onResize = function(size) {
// Enforce minimum width constraint
if (size && size[0] < MINIMUM_NODE_WIDTH) {
size[0] = MINIMUM_NODE_WIDTH;
}
// Call the original onResize if it exists
if (LGraphNode.prototype.onResize) {
return LGraphNode.prototype.onResize.call(this, size);
}
return size;
};
// Add method to compute size properly
nodeType.prototype.computeSize = function(width) {
const minWidth = MINIMUM_NODE_WIDTH;
const minHeight = 100; // Base height
let computedHeight = minHeight;
// Calculate height based on number of lora widgets
if (this.widgets) {
const loraWidgets = this.widgets.filter(w => w.name?.startsWith("lora_"));
if (loraWidgets.length > 0) {
computedHeight += loraWidgets.length * LiteGraph.NODE_WIDGET_HEIGHT;
}
}
// Account for other widgets too
if (this.widgets) {
// Count non-lora widgets that contribute to height
const nonLoraWidgets = this.widgets.filter(w => !w.name?.startsWith("lora_") &&
!(w.computeSize === undefined));
if (nonLoraWidgets.length > 0) {
for (const widget of nonLoraWidgets) {
if (widget.computeSize) {
const widgetSize = widget.computeSize(width || this.size[0]);
if (widgetSize && widgetSize[1]) {
computedHeight += widgetSize[1];
} else {
computedHeight += LiteGraph.NODE_WIDGET_HEIGHT;
}
} else {
computedHeight += LiteGraph.NODE_WIDGET_HEIGHT;
}
}
} else {
// If there are no special computeSize widgets, just add base amount for non-lora widgets
const otherWidgetsCount = this.widgets.length - (this.widgets.filter(w => w.name?.startsWith("lora_")).length);
computedHeight += otherWidgetsCount * LiteGraph.NODE_WIDGET_HEIGHT;
}
}
return [minWidth, computedHeight];
};
nodeType.prototype.getSlotInPosition = getLoraSlotInPosition;
nodeType.prototype.getSlotMenuOptions = getLoraSlotMenuOptions;
// Store clip connection state for Python code to check
const originalOnConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function(type, slotIndex, isConnected, link_info, ioSlot) {
originalOnConnectionsChange?.apply(this, arguments);
// Store the clip connection state
if (type === LiteGraph.INPUT && slotIndex === 2) { // clip is at index 2
this.hasClip = isConnected;
this.setDirtyCanvas(true, true);
}
};
// Override serialize to include hasClip state
const originalOnSerialize = nodeType.prototype.serialize;
nodeType.prototype.serialize = function() {
const data = originalOnSerialize?.apply(this, arguments) || {};
data.hasClip = this.hasClip;
return data;
};
// Add onSerialize method to persist UI state
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(o) {
onSerialize?.apply(this, arguments);
let uiState = null;
const uiStateWidget = this.widgets.find(w => w.name === "ui_state");
if (uiStateWidget && uiStateWidget.value) {
try {
uiState = JSON.parse(uiStateWidget.value);
} catch (e) {
console.warn("[PowerLoraLoaderV2] Failed to parse existing widget value:", e);
}
}
if (!uiState) {
// Try to get from localStorage with correct keys for V2
const foldersVisible = localStorage.getItem("powerLoraLoaderV2.foldersVisible");
const eyeRefreshState = localStorage.getItem("powerLoraLoaderV2.eyeRefreshState");
const selectedFolder = localStorage.getItem("powerLoraLoaderV2.selectedFolder");
uiState = {
foldersVisible: foldersVisible ? JSON.parse(foldersVisible) : false,
eyeRefreshState: eyeRefreshState ? JSON.parse(eyeRefreshState) : false,
selectedFolder: selectedFolder ? JSON.parse(selectedFolder) : null,
};
}
this.properties.uiState = JSON.stringify(uiState);
if (uiStateWidget) {
uiStateWidget.value = JSON.stringify(uiState);
}
};
// Pre-load loras
getLoras().then(loras => {
nodeType.prototype.lorasCache = loras;
});
// Add refreshComboInNode method to handle R key press for reloading definitions
nodeType.prototype.refreshComboInNode = function(defs) {
// Use unified system to refresh the LoRA list
modelDataManager.clearCaches('loras');
// Fetch fresh loras and update cache
getLoras(true).then((lorasDetails) => {
nodeType.prototype.lorasCache = lorasDetails;
// Update all nodes of this type
app.graph._nodes.forEach(node => {
if (node.type === "PowerLoraLoaderV2") {
// Update any existing widgets that might need the fresh data
for (const widget of node.widgets || []) {
if (widget.name?.startsWith("lora_")) {
// Update the widget's parent reference to this node
widget.parent = node;
// Refresh low variant detection for existing LoRAs with new cache
if (widget.value?.lora && widget.value.lora !== "None") {
const oldIsLow = widget.value.is_low;
const oldVariantName = widget.value.low_variant_name;
// Re-run the low variant check with updated cache
widget.checkLowLoraVariant(widget.value.lora);
// Log changes in low variant detection
if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) {
console.log(`[PowerLoraLoaderV2] Low variant status changed for '${widget.value.lora}': ` +
`${oldIsLow} -> ${widget.value.is_low}, ` +
`variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`);
}
}
// Trigger a value change to update the UI
if (widget.callback && typeof widget.callback === 'function') {
widget.callback(widget.value);
}
}
}
// Trigger a redraw to update the green low icons
node.setDirtyCanvas(true, true);
}
});
console.log('[PowerLoraLoaderV2] LoRA definitions refreshed - individual LoRA info available in dialogs');
}).catch(error => {
console.error('[PowerLoraLoaderV2] Error refreshing LoRA list:', error);
});
};
}
},
createPowerLoraLoaderExtension({
extensionName: "WanVideo.PowerLoraLoaderV2",
nodeNames: ["PowerLoraLoaderV2"],
logTag: "PowerLoraLoaderV2",
strengthSplit: true, // Low + High strength columns
copyButtons: true, // "<" / ">" strength copy buttons
outputBoth: true, // "Output both" toggle + hidden output_both widget
localStoragePrefix: "powerLoraLoaderV2",
});
+31 -23
View File
@@ -248,8 +248,10 @@ const ICON_WIDTH = 20;
const MARGIN = 10;
export class StrengthCopyWidget extends RgthreeBaseWidget {
constructor() {
constructor(showOutputBoth = true, showCopyButtons = true) {
super("StrengthCopyWidget");
this.showOutputBoth = showOutputBoth;
this.showCopyButtons = showCopyButtons;
this.value = {};
this.hitAreas = {
output_both_toggle: { bounds: [0, 0] },
@@ -272,38 +274,44 @@ export class StrengthCopyWidget extends RgthreeBaseWidget {
const buttonSpacing = 36;
const rightOffset = 25;
const innerMargin = 8;
const toggleSpacing = 15; // Extra spacing between toggle and buttons
// Calculated values
const midY = posY + height * 0.5;
const totalButtonsWidth = (buttonWidth * 2) + buttonSpacing;
const totalButtonsWidth = this.showCopyButtons ? (buttonWidth * 2) + buttonSpacing : 0;
ctx.save();
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
// Draw ">" button (high to low) and "<" button (low to high)
if (this.showCopyButtons) {
const rightX = w - margin - totalButtonsWidth - rightOffset;
const highToLowX = rightX;
drawWidgetButton(ctx, [highToLowX, posY], [buttonWidth, buttonHeight], ">", this.highToLowPressed);
this.hitAreas['high_to_low_button'].bounds = [highToLowX, buttonWidth];
const lowToHighX = rightX + buttonWidth + buttonSpacing;
drawWidgetButton(ctx, [lowToHighX, posY], [buttonWidth, buttonHeight], "<", this.lowToHighPressed);
this.hitAreas['low_to_high_button'].bounds = [lowToHighX, buttonWidth];
} else {
this.hitAreas['high_to_low_button'].bounds = [0, 0];
this.hitAreas['low_to_high_button'].bounds = [0, 0];
}
// Draw "Output both" toggle
const toggleSpacing = 15; // Extra spacing between toggle and buttons
let posX = w - margin - totalButtonsWidth - rightOffset - toggleSpacing;
const outputBothValue = node.properties['output_both'] || false;
// Draw label and toggle from right to left
const toggleBounds = drawTogglePart(ctx, [posX - height * 1.5, posY], height, outputBothValue);
this.hitAreas['output_both_toggle'].bounds = toggleBounds;
const labelText = "Output both";
ctx.fillText(labelText, posX - height * 1.5 - ctx.measureText(labelText).width - innerMargin, midY);
// Adjust rightX for buttons (they stay in same position)
const rightX = w - margin - totalButtonsWidth - rightOffset;
// Draw ">" button (high to low)
const highToLowX = rightX;
drawWidgetButton(ctx, [highToLowX, posY], [buttonWidth, buttonHeight], ">", this.highToLowPressed);
this.hitAreas['high_to_low_button'].bounds = [highToLowX, buttonWidth];
// Draw "<" button (low to high)
const lowToHighX = rightX + buttonWidth + buttonSpacing;
drawWidgetButton(ctx, [lowToHighX, posY], [buttonWidth, buttonHeight], "<", this.lowToHighPressed);
this.hitAreas['low_to_high_button'].bounds = [lowToHighX, buttonWidth];
if (this.showOutputBoth) {
let posX = w - margin - totalButtonsWidth - rightOffset - toggleSpacing;
const outputBothValue = node.properties['output_both'] || false;
// Draw label and toggle from right to left
const toggleBounds = drawTogglePart(ctx, [posX - height * 1.5, posY], height, outputBothValue);
this.hitAreas['output_both_toggle'].bounds = toggleBounds;
const labelText = "Output both";
ctx.fillText(labelText, posX - height * 1.5 - ctx.measureText(labelText).width - innerMargin, midY);
} else {
this.hitAreas['output_both_toggle'].bounds = [0, 0];
}
ctx.restore();
}