Simple Power Lora Loader
This commit is contained in:
+44
-6
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user