From 494afbcca9989264f0ca09d8a07008db935e3334 Mon Sep 17 00:00:00 2001 From: JJ Date: Tue, 27 Oct 2026 22:17:26 +0000 Subject: [PATCH] Simple Power Lora Loader --- nodes/image_nodes.py | 50 +- nodes/image_nodes_extra.py | 563 ++++++++++++++++ nodes/power_loaders.py | 104 +++ web/power_image_grid/power_image_grid.js | 476 ++++++++++++++ web/power_lora_loader_js/dialog_info.js | 24 +- .../power_lora_loader_common.js | 622 ++++++++++++++++++ .../power_lora_loader_simple.js | 18 + .../power_lora_loader_v2.js | 483 +------------- web/power_lora_loader_js/widgets.js | 54 +- 9 files changed, 1896 insertions(+), 498 deletions(-) create mode 100644 web/power_image_grid/power_image_grid.js create mode 100644 web/power_lora_loader_js/power_lora_loader_common.js create mode 100644 web/power_lora_loader_js/power_lora_loader_simple.js diff --git a/nodes/image_nodes.py b/nodes/image_nodes.py index 8c2fc7e..4cace6a 100644 --- a/nodes/image_nodes.py +++ b/nodes/image_nodes.py @@ -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 diff --git a/nodes/image_nodes_extra.py b/nodes/image_nodes_extra.py index 351ffaf..b0c5c79 100644 --- a/nodes/image_nodes_extra.py +++ b/nodes/image_nodes_extra.py @@ -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 ".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 '.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 '.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 .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", } diff --git a/nodes/power_loaders.py b/nodes/power_loaders.py index b0a2e16..bdd551f 100644 --- a/nodes/power_loaders.py +++ b/nodes/power_loaders.py @@ -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.""" diff --git a/web/power_image_grid/power_image_grid.js b/web/power_image_grid/power_image_grid.js new file mode 100644 index 0000000..4e9315b --- /dev/null +++ b/web/power_image_grid/power_image_grid.js @@ -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 + * 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?.(); + }; + }, +}); diff --git a/web/power_lora_loader_js/dialog_info.js b/web/power_lora_loader_js/dialog_info.js index 267c11e..16670f9 100644 --- a/web/power_lora_loader_js/dialog_info.js +++ b/web/power_lora_loader_js/dialog_info.js @@ -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 ? `${label ? `` : ""}${value}` : ""; + if (value == null) return ""; + const isLong = label === "positive" || label === "negative"; + const cls = isLong ? ` class="-info-field-long"` : ""; + return `${label ? `` : ""}${value}`; } \ No newline at end of file diff --git a/web/power_lora_loader_js/power_lora_loader_common.js b/web/power_lora_loader_js/power_lora_loader_common.js new file mode 100644 index 0000000..c6deb23 --- /dev/null +++ b/web/power_lora_loader_js/power_lora_loader_common.js @@ -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); + }); + }; + } + }); +} \ No newline at end of file diff --git a/web/power_lora_loader_js/power_lora_loader_simple.js b/web/power_lora_loader_js/power_lora_loader_simple.js new file mode 100644 index 0000000..720fa89 --- /dev/null +++ b/web/power_lora_loader_js/power_lora_loader_simple.js @@ -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", +}); \ No newline at end of file diff --git a/web/power_lora_loader_js/power_lora_loader_v2.js b/web/power_lora_loader_js/power_lora_loader_v2.js index 8ac16bf..5e5030e 100644 --- a/web/power_lora_loader_js/power_lora_loader_v2.js +++ b/web/power_lora_loader_js/power_lora_loader_v2.js @@ -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", }); \ No newline at end of file diff --git a/web/power_lora_loader_js/widgets.js b/web/power_lora_loader_js/widgets.js index 94fdc14..d75feb8 100644 --- a/web/power_lora_loader_js/widgets.js +++ b/web/power_lora_loader_js/widgets.js @@ -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(); }