diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/README.md b/README.md index cfc6c68..902a150 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,25 @@ # ComfyUI-Custom-Scripts +> ⚠️ **If updating from a version prior to 2023-06-18**: Many of the nodes have been renamed to include `pysssss` to prevent conflicts with other nodes. If you get missing nodes, please replace them with the new names. + # Installation -## Usage 1. Clone the repository: -git clone https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git -2. Navigate to the repository directory: -cd ComfyUI-Custom-Scripts -3. Run the `InstallCustomNodes.py` script: -python InstallCustomNodes.py -4. Follow the prompts to choose between installation and uninstallation, as well as test mode. +`git clone https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git` +to your ComfyUI `custom_nodes` directory -- For installation: - - Choose 'i' to install. - - If the destination directory already exists, you will be prompted to remove it. - - Choose whether to run in test mode (dry run) or perform the actual file movements. - - The script will clone the repository, move the files, and create a mapping file. - - If you choose not to run in test mode, the installation will be completed. + The script will then automatically install all custom scripts and nodes. + It will attempt to use symlinks and junctions to prevent having to copy files and keep them up to date. - For uninstallation: - - Choose 'u' to uninstall. - - The script will use the mapping file to undo the file movements. - - Any errors encountered during the uninstallation process will be displayed, but the script will continue. - - The script will remove both the files and empty directories. + - Delete the cloned repo in `custom_nodes` + - Ensure `web/extensions/pysssss/CustomScripts` has also been removed -5. Review the generated `move_mapping.yaml` file for the mapping of moved files. +# Update +1. Navigate to the cloned repo e.g. `custom_nodes/ComfyUI-Custom-Scripts` +2. `git pull` -6. If needed, repeat the steps above to perform installation or uninstallation again - -**If the script has its own readme, that may include additional requirements so be sure to check it!** - -## Anime Segmentation -![image](https://user-images.githubusercontent.com/125205205/230170464-90a60a6e-9dfa-4244-b027-4e13169c71f6.png) -Takes an image/images and uses https://github.com/SkyTNT/anime-segmentation to remove the background or foreground. +# Features ## Auto Arrange Graph ![image](https://user-images.githubusercontent.com/125205205/230170664-acddff3e-f47b-452e-970e-0a7279734b96.png) @@ -51,10 +38,6 @@ Adds a favicon and title to the window, favicon changes color while generating a ![image](https://user-images.githubusercontent.com/125205205/230172436-3fbeb426-a0e8-4a89-9a1d-c7383d11a9db.png) Adds a panel at the bottom of the window showing images that have been generated in the current session -## Latent Upscale By -![image](https://user-images.githubusercontent.com/125205205/230172680-9348b086-5278-472e-91ac-d08433b7b197.png) -Allows upscaling by a factor instead of specific width and height - ## Lock Nodes & Groups ![image](https://user-images.githubusercontent.com/125205205/230172868-5c5a943c-ade1-4799-bf80-cc931da5d4b2.png) Adds a lock option to nodes & groups that prevents you from moving them until unlocked @@ -80,3 +63,19 @@ Provides basic support for touch screen devices, its not perfect but better than ## WD14 Tagger Moved to: https://github.com/pythongosssss/ComfyUI-WD14-Tagger + +--- + +# Changelog + +## 2023-06-18 +- ♻️ Reworked folder structure +- ✨ Added auto installation of scripts and `__init__` with thanks to @TashaSkyUp +- 🚨 Renamed a number of nodes to include `pysssss` to prevent name conflicts +- 🐛 Fix Manage Widget Defaults to work with new ComfyUI settings dialog +- ✨ Add Link Render Mode setting to choose how links are rendered +- ✨ Add Constrain Image node for resizing nodes to a min/max resolution with optional cropping +- 🚨 Remove Latent Upscale By as it is now a built in node in ComfyUI +- ♻️ Include [canvas2svg](https://gliffy.github.io/canvas2svg/) for SVG export in assets to prevent downloading at runtime +- 🎨 Add background color to exported SVG +- 🐛 Increase Image Feed z-index to prevent node text overlapping \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..22ee396 --- /dev/null +++ b/__init__.py @@ -0,0 +1,24 @@ +import importlib.util +import glob +import os +import sys +from .pysssss import init, get_ext_dir + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +if init(): + py = get_ext_dir("py") + files = glob.glob("*.py", root_dir=py, recursive=False) + for file in files: + name = os.path.splitext(file)[0] + spec = importlib.util.spec_from_file_location(name, os.path.join(py, file)) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None: + NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS) + if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None: + NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS) + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/anime-segmentation/README.md b/anime-segmentation/README.md deleted file mode 100644 index b7fb7c9..0000000 --- a/anime-segmentation/README.md +++ /dev/null @@ -1,8 +0,0 @@ -IMPORTANT: If you are on the standalone build of ComfyUI you need to update the ckpt path on the node to ComfyUI\{the path} - -Checkout https://github.com/SkyTNT/anime-segmentation into comfy_extras/anime_segmentation (so you have comfy_extras/anime_segmentation/train.py) -Download model isnetis.ckpt from https://huggingface.co/skytnt/anime-seg/tree/main and put into comfy_extras\anime_segmentation\saved_models -Install requirements -Put anime_segmentation.py in custom_nodes -Node is under image -> AnimeSegmentation - diff --git a/anime-segmentation/anime_segmentation.py b/anime-segmentation/anime_segmentation.py deleted file mode 100644 index 217d9f5..0000000 --- a/anime-segmentation/anime_segmentation.py +++ /dev/null @@ -1,117 +0,0 @@ -import copy -import sys -import os -import torch -import cv2 -import numpy as np -from torch.cuda import amp - -sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "../comfy_extras/anime_segmentation")) # noqa -from comfy_extras.anime_segmentation.train import AnimeSegmentation - - -class Segment: - net = ["isnet_is", "isnet", "u2net", "u2netl", "modnet"] - - @classmethod - def INPUT_TYPES(s): - return {"required": { - "images": ("IMAGE", ), - "net": (s.net, {"default": "isnet_is"}), - "ckpt": ("STRING", {"default": "comfy_extras/anime_segmentation/saved_models/isnetis.ckpt"}), - "device": ("STRING", {"default": "cuda:0"}), - "precision": (["fp16", "fp32"], {"default": "fp16"}), - "image_size": ("INT", {"default": 1024}), - "remove": (["character", "background"], {"default": "background"}), - "alpha": (["yes", "no"], {"default": "yes"}), - }} - - RETURN_TYPES = ("IMAGE", "MASK") - FUNCTION = "segment" - - CATEGORY = "image" - - def get_mask(self, model, input_img, use_amp=True, s=640): - h0, w0 = h, w = input_img.shape[0], input_img.shape[1] - if h > w: - h, w = s, int(s * w / h) - else: - h, w = int(s * h / w), s - ph, pw = s - h, s - w - tmpImg = np.zeros([s, s, 3], dtype=np.float32) - tmpImg[ph // 2:ph // 2 + h, pw // 2:pw // 2 + w] = cv2.resize(input_img, (w, h)) / 255 - tmpImg = tmpImg.transpose((2, 0, 1)) - tmpImg = torch.from_numpy(tmpImg).unsqueeze(0).type(torch.FloatTensor).to(model.device) - with torch.no_grad(): - if use_amp: - with amp.autocast(): - pred = model(tmpImg) - pred = pred.to(dtype=torch.float32) - else: - pred = model(tmpImg) - pred = pred[0, :, ph // 2:ph // 2 + h, pw // 2:pw // 2 + w] - pred = cv2.resize(pred.cpu().numpy().transpose((1, 2, 0)), (w0, h0))[:, :, np.newaxis] - return pred - - def segment(self, images, net, ckpt, device, precision, image_size, remove, alpha): - ckpt = os.path.realpath(ckpt) - - device = torch.device(device) - model = AnimeSegmentation.try_load(net, ckpt, device) - model.eval() - model.to(device) - - outputs = [] - first_mask = None - for image in images: - i = 255. * image.cpu().numpy() - img = cv2.cvtColor(np.clip(i, 0, 255).astype(np.uint8), cv2.COLOR_BGR2RGB) - mask = self.get_mask(model, img, use_amp=precision == "fp16", s=image_size) - - img = np.concatenate( - (mask * img + 1 - mask, mask * 255), axis=2).astype(np.uint8) - - img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGRA) - - if remove == "background": - # Store mask if we are first - if first_mask is None: - mask = img[:, :, 3].astype(np.float32) / 255.0 - mask = torch.from_numpy(mask) - first_mask = 1. - mask - else: - # Copy the source image - i = i.copy() - - # Add alpha channel if it isnt present - if i.shape[2] == 3: - i = np.concatenate([i, np.ones((i.shape[0], i.shape[1], 1), dtype=i.dtype) * 255], axis=2) - - # Remove non transparent pixels in segmented image from source - mask = img[:, :, 3] > 0 - i[mask] = [0, 0, 0, 0] - img = i - - # Store mask if we are first - if first_mask is None: - mask = img[:, :, 3].astype(np.float32, copy=True) / 255.0 - mask = torch.from_numpy(mask) - first_mask = 1. - mask - - if alpha == "no": - # Remove alpha channel - img = img[:, :, :3] - - img = img.astype(np.float32) / 255.0 - img = torch.from_numpy(img)[None,] - outputs.append(img) - - if len(outputs) == 1: - outputs = outputs[0] - - return (outputs, first_mask,) - - -NODE_CLASS_MAPPINGS = { - "AnimeSegmentation": Segment, -} diff --git a/auto-arrange-graph/graphArrange.js b/auto-arrange-graph/graphArrange.js deleted file mode 100644 index 4e5586a..0000000 --- a/auto-arrange-graph/graphArrange.js +++ /dev/null @@ -1,15 +0,0 @@ -import { app } from "/scripts/app.js"; - -// Adds right click -> arrange to the canvas - -app.registerExtension({ - name: "pysssss.GraphArrange", - setup(app) { - const orig = LGraphCanvas.prototype.getCanvasMenuOptions; - LGraphCanvas.prototype.getCanvasMenuOptions = function () { - const options = orig.apply(this, arguments); - options.push({ content: "Arrange", callback: () => graph.arrange() }); - return options; - }; - }, -}); diff --git a/install.py b/install.py deleted file mode 100644 index 873f5bf..0000000 --- a/install.py +++ /dev/null @@ -1,125 +0,0 @@ -import os -import subprocess -import shutil -import yaml - -def clone_repo(repo_url, destination_path): - command = ["git", "clone", repo_url, destination_path] - subprocess.run(command, shell=True) - - -def move_files(source_dir, python_files_destination, all_else_files, test_mode=False): - all_else_files = os.path.normpath(all_else_files) - file_mapping = {} - - for root, dirs, files in os.walk(source_dir): - # Ignore hidden directories - dirs[:] = [d for d in dirs if not d.startswith('.')] - - for file in files: - source_file_path = os.path.join(root, file) - if file.endswith(".py"): - if test_mode: - print(f"[TEST MODE] Moving Python file: {file}") - destination_file_path = os.path.join(python_files_destination, file) - file_mapping[source_file_path] = destination_file_path - else: - destination_file_path = os.path.join(python_files_destination, file) - shutil.move(source_file_path, destination_file_path) - file_mapping[source_file_path] = destination_file_path - else: - relative_path = os.path.relpath(root, source_dir) - destination_subdir = os.path.join(all_else_files, relative_path) - destination_file_path = os.path.join(destination_subdir, file) - if test_mode: - print(f"[TEST MODE] Moving non-Python file: {file} -> {destination_file_path}") - destination_file_path = os.path.normpath(destination_file_path) - file_mapping[source_file_path] = destination_file_path - else: - os.makedirs(destination_subdir, exist_ok=True) - destination_file_path = os.path.normpath(destination_file_path) - shutil.move(source_file_path, destination_file_path) - file_mapping[source_file_path] = destination_file_path - - return file_mapping - - -def write_mapping_file(file_mapping, mapping_file_path): - with open(mapping_file_path, "w") as file: - yaml.dump(file_mapping, file) - - -import os - - -def undo_moves(file_mapping): - directories = set() - - for source_file, destination_file in file_mapping.items(): - try: - destination_dir = os.path.dirname(destination_file) - directories.add(destination_dir) - shutil.move(destination_file, source_file) - except Exception as e: - print(f"Error occurred during file move: {e}") - - # Remove empty directories - for directory in directories: - try: - os.removedirs(directory) - except Exception as e: - print(f"Error occurred during directory removal: {e}") - - -def uninstall(mapping_file_path): - if not os.path.exists(mapping_file_path): - print("Mapping file not found. Cannot perform uninstallation.") - return - - with open(mapping_file_path, "r") as file: - file_mapping = yaml.safe_load(file) - - if not file_mapping: - print("No file mapping found. Nothing to undo.") - return - - undo_moves(file_mapping) - print("Uninstallation complete. Files restored to their original locations.") - - -if __name__ == '__main__': - # Replace the following variables with your desired values - repo_url = "https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git" - destination_path = "RawCustomNodesAndScripts" - python_files_destination = "custom_nodes" - all_else_files = "web/extensions" - mapping_file_path = "move_mapping.yaml" - - # Prompt user for installation or uninstallation - action = input("Do you want to [i]nstall or [u]ninstall? ").lower() - if action == "i": - test_mode = input("Do you want to run in test mode? (y/n): ").lower() == "y" - - # Check if the destination directory already exists - if os.path.exists(destination_path) and os.path.isdir(destination_path): - response = input( - f"The destination directory '{destination_path}' already exists. Do you want to remove it? (y/n): ") - if response.lower() == "y": - # Use system command to force delete the directory - if os.name == 'nt': # Windows - os.system(f'rmdir /s /q "{destination_path}"') - else: # Linux - os.system(f'rm -r -f "{destination_path}"') - else: - print("Operation cancelled. Exiting script.") - exit() - - clone_repo(repo_url, destination_path) - file_mapping = move_files(destination_path, python_files_destination, all_else_files, test_mode) - write_mapping_file(file_mapping, mapping_file_path) - - print("Installation complete.") - elif action == "u": - uninstall(mapping_file_path) - else: - print("Invalid action. Exiting script.") diff --git a/js/assets/canvas2svg.js b/js/assets/canvas2svg.js new file mode 100644 index 0000000..03dc392 --- /dev/null +++ b/js/assets/canvas2svg.js @@ -0,0 +1,1192 @@ +/*!! + * Canvas 2 Svg v1.0.19 + * A low level canvas to SVG converter. Uses a mock canvas context to build an SVG document. + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/mit-license.php + * + * Author: + * Kerry Liu + * + * Copyright (c) 2014 Gliffy Inc. + */ + +;(function() { + "use strict"; + + var STYLES, ctx, CanvasGradient, CanvasPattern, namedEntities; + + //helper function to format a string + function format(str, args) { + var keys = Object.keys(args), i; + for (i=0; i 1) { + options = defaultOptions; + options.width = arguments[0]; + options.height = arguments[1]; + } else if( !o ) { + options = defaultOptions; + } else { + options = o; + } + + if(!(this instanceof ctx)) { + //did someone call this without new? + return new ctx(options); + } + + //setup options + this.width = options.width || defaultOptions.width; + this.height = options.height || defaultOptions.height; + this.enableMirroring = options.enableMirroring !== undefined ? options.enableMirroring : defaultOptions.enableMirroring; + + this.canvas = this; ///point back to this instance! + this.__document = options.document || document; + this.__canvas = this.__document.createElement("canvas"); + this.__ctx = this.__canvas.getContext("2d"); + + this.__setDefaultStyles(); + this.__stack = [this.__getStyleState()]; + this.__groupStack = []; + + //the root svg element + this.__root = this.__document.createElementNS("http://www.w3.org/2000/svg", "svg"); + this.__root.setAttribute("version", 1.1); + this.__root.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + this.__root.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); + this.__root.setAttribute("width", this.width); + this.__root.setAttribute("height", this.height); + + //make sure we don't generate the same ids in defs + this.__ids = {}; + + //defs tag + this.__defs = this.__document.createElementNS("http://www.w3.org/2000/svg", "defs"); + this.__root.appendChild(this.__defs); + + //also add a group child. the svg element can't use the transform attribute + this.__currentElement = this.__document.createElementNS("http://www.w3.org/2000/svg", "g"); + this.__root.appendChild(this.__currentElement); + }; + + + /** + * Creates the specified svg element + * @private + */ + ctx.prototype.__createElement = function (elementName, properties, resetFill) { + if (typeof properties === "undefined") { + properties = {}; + } + + var element = this.__document.createElementNS("http://www.w3.org/2000/svg", elementName), + keys = Object.keys(properties), i, key; + if(resetFill) { + //if fill or stroke is not specified, the svg element should not display. By default SVG's fill is black. + element.setAttribute("fill", "none"); + element.setAttribute("stroke", "none"); + } + for(i=0; i 0) { + var group = this.__createElement("g"); + parent.appendChild(group); + this.__currentElement = group; + } + + var transform = this.__currentElement.getAttribute("transform"); + if(transform) { + transform += " "; + } else { + transform = ""; + } + transform += t; + this.__currentElement.setAttribute("transform", transform); + }; + + /** + * scales the current element + */ + ctx.prototype.scale = function(x, y) { + if(y === undefined) { + y = x; + } + this.__addTransform(format("scale({x},{y})", {x:x, y:y})); + }; + + /** + * rotates the current element + */ + ctx.prototype.rotate = function(angle){ + var degrees = (angle * 180 / Math.PI); + this.__addTransform(format("rotate({angle},{cx},{cy})", {angle:degrees, cx:0, cy:0})); + }; + + /** + * translates the current element + */ + ctx.prototype.translate = function(x, y){ + this.__addTransform(format("translate({x},{y})", {x:x,y:y})); + }; + + /** + * applies a transform to the current element + */ + ctx.prototype.transform = function(a, b, c, d, e, f){ + this.__addTransform(format("matrix({a},{b},{c},{d},{e},{f})", {a:a, b:b, c:c, d:d, e:e, f:f})); + }; + + /** + * Create a new Path Element + */ + ctx.prototype.beginPath = function(){ + var path, parent; + + // Note that there is only one current default path, it is not part of the drawing state. + // See also: https://html.spec.whatwg.org/multipage/scripting.html#current-default-path + this.__currentDefaultPath = ""; + this.__currentPosition = {}; + + path = this.__createElement("path", {}, true); + parent = this.__closestGroupOrSvg(); + parent.appendChild(path); + this.__currentElement = path; + }; + + /** + * Helper function to apply currentDefaultPath to current path element + * @private + */ + ctx.prototype.__applyCurrentDefaultPath = function() { + if(this.__currentElement.nodeName === "path") { + var d = this.__currentDefaultPath; + this.__currentElement.setAttribute("d", d); + } else { + throw new Error("Attempted to apply path command to node " + this.__currentElement.nodeName); + } + }; + + /** + * Helper function to add path command + * @private + */ + ctx.prototype.__addPathCommand = function(command){ + this.__currentDefaultPath += " "; + this.__currentDefaultPath += command; + }; + + /** + * Adds the move command to the current path element, + * if the currentPathElement is not empty create a new path element + */ + ctx.prototype.moveTo = function(x,y){ + if(this.__currentElement.nodeName !== "path") { + this.beginPath(); + } + + // creates a new subpath with the given point + this.__currentPosition = {x: x, y: y}; + this.__addPathCommand(format("M {x} {y}", {x:x, y:y})); + }; + + /** + * Closes the current path + */ + ctx.prototype.closePath = function(){ + this.__addPathCommand("Z"); + }; + + /** + * Adds a line to command + */ + ctx.prototype.lineTo = function(x, y){ + this.__currentPosition = {x: x, y: y}; + if (this.__currentDefaultPath.indexOf('M') > -1) { + this.__addPathCommand(format("L {x} {y}", {x:x, y:y})); + } else { + this.__addPathCommand(format("M {x} {y}", {x:x, y:y})); + } + }; + + /** + * Add a bezier command + */ + ctx.prototype.bezierCurveTo = function(cp1x, cp1y, cp2x, cp2y, x, y) { + this.__currentPosition = {x: x, y: y}; + this.__addPathCommand(format("C {cp1x} {cp1y} {cp2x} {cp2y} {x} {y}", + {cp1x:cp1x, cp1y:cp1y, cp2x:cp2x, cp2y:cp2y, x:x, y:y})); + }; + + /** + * Adds a quadratic curve to command + */ + ctx.prototype.quadraticCurveTo = function(cpx, cpy, x, y){ + this.__currentPosition = {x: x, y: y}; + this.__addPathCommand(format("Q {cpx} {cpy} {x} {y}", {cpx:cpx, cpy:cpy, x:x, y:y})); + }; + + + /** + * Return a new normalized vector of given vector + */ + var normalize = function(vector) { + var len = Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1]); + return [vector[0] / len, vector[1] / len]; + }; + + /** + * Adds the arcTo to the current path + * + * @see http://www.w3.org/TR/2015/WD-2dcontext-20150514/#dom-context-2d-arcto + */ + ctx.prototype.arcTo = function(x1, y1, x2, y2, radius) { + // Let the point (x0, y0) be the last point in the subpath. + var x0 = this.__currentPosition && this.__currentPosition.x; + var y0 = this.__currentPosition && this.__currentPosition.y; + + // First ensure there is a subpath for (x1, y1). + if (typeof x0 == "undefined" || typeof y0 == "undefined") { + return; + } + + // Negative values for radius must cause the implementation to throw an IndexSizeError exception. + if (radius < 0) { + throw new Error("IndexSizeError: The radius provided (" + radius + ") is negative."); + } + + // If the point (x0, y0) is equal to the point (x1, y1), + // or if the point (x1, y1) is equal to the point (x2, y2), + // or if the radius radius is zero, + // then the method must add the point (x1, y1) to the subpath, + // and connect that point to the previous point (x0, y0) by a straight line. + if (((x0 === x1) && (y0 === y1)) + || ((x1 === x2) && (y1 === y2)) + || (radius === 0)) { + this.lineTo(x1, y1); + return; + } + + // Otherwise, if the points (x0, y0), (x1, y1), and (x2, y2) all lie on a single straight line, + // then the method must add the point (x1, y1) to the subpath, + // and connect that point to the previous point (x0, y0) by a straight line. + var unit_vec_p1_p0 = normalize([x0 - x1, y0 - y1]); + var unit_vec_p1_p2 = normalize([x2 - x1, y2 - y1]); + if (unit_vec_p1_p0[0] * unit_vec_p1_p2[1] === unit_vec_p1_p0[1] * unit_vec_p1_p2[0]) { + this.lineTo(x1, y1); + return; + } + + // Otherwise, let The Arc be the shortest arc given by circumference of the circle that has radius radius, + // and that has one point tangent to the half-infinite line that crosses the point (x0, y0) and ends at the point (x1, y1), + // and that has a different point tangent to the half-infinite line that ends at the point (x1, y1), and crosses the point (x2, y2). + // The points at which this circle touches these two lines are called the start and end tangent points respectively. + + // note that both vectors are unit vectors, so the length is 1 + var cos = (unit_vec_p1_p0[0] * unit_vec_p1_p2[0] + unit_vec_p1_p0[1] * unit_vec_p1_p2[1]); + var theta = Math.acos(Math.abs(cos)); + + // Calculate origin + var unit_vec_p1_origin = normalize([ + unit_vec_p1_p0[0] + unit_vec_p1_p2[0], + unit_vec_p1_p0[1] + unit_vec_p1_p2[1] + ]); + var len_p1_origin = radius / Math.sin(theta / 2); + var x = x1 + len_p1_origin * unit_vec_p1_origin[0]; + var y = y1 + len_p1_origin * unit_vec_p1_origin[1]; + + // Calculate start angle and end angle + // rotate 90deg clockwise (note that y axis points to its down) + var unit_vec_origin_start_tangent = [ + -unit_vec_p1_p0[1], + unit_vec_p1_p0[0] + ]; + // rotate 90deg counter clockwise (note that y axis points to its down) + var unit_vec_origin_end_tangent = [ + unit_vec_p1_p2[1], + -unit_vec_p1_p2[0] + ]; + var getAngle = function(vector) { + // get angle (clockwise) between vector and (1, 0) + var x = vector[0]; + var y = vector[1]; + if (y >= 0) { // note that y axis points to its down + return Math.acos(x); + } else { + return -Math.acos(x); + } + }; + var startAngle = getAngle(unit_vec_origin_start_tangent); + var endAngle = getAngle(unit_vec_origin_end_tangent); + + // Connect the point (x0, y0) to the start tangent point by a straight line + this.lineTo(x + unit_vec_origin_start_tangent[0] * radius, + y + unit_vec_origin_start_tangent[1] * radius); + + // Connect the start tangent point to the end tangent point by arc + // and adding the end tangent point to the subpath. + this.arc(x, y, radius, startAngle, endAngle); + }; + + /** + * Sets the stroke property on the current element + */ + ctx.prototype.stroke = function(){ + if(this.__currentElement.nodeName === "path") { + this.__currentElement.setAttribute("paint-order", "fill stroke markers"); + } + this.__applyCurrentDefaultPath(); + this.__applyStyleToCurrentElement("stroke"); + }; + + /** + * Sets fill properties on the current element + */ + ctx.prototype.fill = function(){ + if(this.__currentElement.nodeName === "path") { + this.__currentElement.setAttribute("paint-order", "stroke fill markers"); + } + this.__applyCurrentDefaultPath(); + this.__applyStyleToCurrentElement("fill"); + }; + + /** + * Adds a rectangle to the path. + */ + ctx.prototype.rect = function(x, y, width, height){ + if(this.__currentElement.nodeName !== "path") { + this.beginPath(); + } + this.moveTo(x, y); + this.lineTo(x+width, y); + this.lineTo(x+width, y+height); + this.lineTo(x, y+height); + this.lineTo(x, y); + this.closePath(); + }; + + + /** + * adds a rectangle element + */ + ctx.prototype.fillRect = function(x, y, width, height){ + var rect, parent; + rect = this.__createElement("rect", { + x : x, + y : y, + width : width, + height : height + }, true); + parent = this.__closestGroupOrSvg(); + parent.appendChild(rect); + this.__currentElement = rect; + this.__applyStyleToCurrentElement("fill"); + }; + + /** + * Draws a rectangle with no fill + * @param x + * @param y + * @param width + * @param height + */ + ctx.prototype.strokeRect = function(x, y, width, height){ + var rect, parent; + rect = this.__createElement("rect", { + x : x, + y : y, + width : width, + height : height + }, true); + parent = this.__closestGroupOrSvg(); + parent.appendChild(rect); + this.__currentElement = rect; + this.__applyStyleToCurrentElement("stroke"); + }; + + + /** + * Clear entire canvas: + * 1. save current transforms + * 2. remove all the childNodes of the root g element + */ + ctx.prototype.__clearCanvas = function() { + var current = this.__closestGroupOrSvg(), + transform = current.getAttribute("transform"); + var rootGroup = this.__root.childNodes[1]; + var childNodes = rootGroup.childNodes; + for (var i = childNodes.length - 1; i >= 0; i--) { + if (childNodes[i]) { + rootGroup.removeChild(childNodes[i]); + } + } + this.__currentElement = rootGroup; + //reset __groupStack as all the child group nodes are all removed. + this.__groupStack = []; + if (transform) { + this.__addTransform(transform); + } + }; + + /** + * "Clears" a canvas by just drawing a white rectangle in the current group. + */ + ctx.prototype.clearRect = function(x, y, width, height) { + //clear entire canvas + if (x === 0 && y === 0 && width === this.width && height === this.height) { + this.__clearCanvas(); + return; + } + var rect, parent = this.__closestGroupOrSvg(); + rect = this.__createElement("rect", { + x : x, + y : y, + width : width, + height : height, + fill : "#FFFFFF" + }, true); + parent.appendChild(rect); + }; + + /** + * Adds a linear gradient to a defs tag. + * Returns a canvas gradient object that has a reference to it's parent def + */ + ctx.prototype.createLinearGradient = function(x1, y1, x2, y2){ + var grad = this.__createElement("linearGradient", { + id : randomString(this.__ids), + x1 : x1+"px", + x2 : x2+"px", + y1 : y1+"px", + y2 : y2+"px", + "gradientUnits" : "userSpaceOnUse" + }, false); + this.__defs.appendChild(grad); + return new CanvasGradient(grad, this); + }; + + /** + * Adds a radial gradient to a defs tag. + * Returns a canvas gradient object that has a reference to it's parent def + */ + ctx.prototype.createRadialGradient = function(x0, y0, r0, x1, y1, r1){ + var grad = this.__createElement("radialGradient", { + id : randomString(this.__ids), + cx : x1+"px", + cy : y1+"px", + r : r1+"px", + fx : x0+"px", + fy : y0+"px", + "gradientUnits" : "userSpaceOnUse" + }, false); + this.__defs.appendChild(grad); + return new CanvasGradient(grad, this); + + }; + + /** + * Parses the font string and returns svg mapping + * @private + */ + ctx.prototype.__parseFont = function() { + var regex = /^\s*(?=(?:(?:[-a-z]+\s*){0,2}(italic|oblique))?)(?=(?:(?:[-a-z]+\s*){0,2}(small-caps))?)(?=(?:(?:[-a-z]+\s*){0,2}(bold(?:er)?|lighter|[1-9]00))?)(?:(?:normal|\1|\2|\3)\s*){0,3}((?:xx?-)?(?:small|large)|medium|smaller|larger|[.\d]+(?:\%|in|[cem]m|ex|p[ctx]))(?:\s*\/\s*(normal|[.\d]+(?:\%|in|[cem]m|ex|p[ctx])))?\s*([-,\'\"\sa-z]+?)\s*$/i; + var fontPart = regex.exec( this.font ); + var data = { + style : fontPart[1] || 'normal', + size : fontPart[4] || '10px', + family : fontPart[6] || 'sans-serif', + weight: fontPart[3] || 'normal', + decoration : fontPart[2] || 'normal', + href : null + }; + + //canvas doesn't support underline natively, but we can pass this attribute + if(this.__fontUnderline === "underline") { + data.decoration = "underline"; + } + + //canvas also doesn't support linking, but we can pass this as well + if(this.__fontHref) { + data.href = this.__fontHref; + } + + return data; + }; + + /** + * Helper to link text fragments + * @param font + * @param element + * @return {*} + * @private + */ + ctx.prototype.__wrapTextLink = function(font, element) { + if(font.href) { + var a = this.__createElement("a"); + a.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", font.href); + a.appendChild(element); + return a; + } + return element; + }; + + /** + * Fills or strokes text + * @param text + * @param x + * @param y + * @param action - stroke or fill + * @private + */ + ctx.prototype.__applyText = function(text, x, y, action) { + var font = this.__parseFont(), + parent = this.__closestGroupOrSvg(), + textElement = this.__createElement("text", { + "font-family" : font.family, + "font-size" : font.size, + "font-style" : font.style, + "font-weight" : font.weight, + "text-decoration" : font.decoration, + "x" : x, + "y" : y, + "text-anchor": getTextAnchor(this.textAlign), + "dominant-baseline": getDominantBaseline(this.textBaseline) + }, true); + + textElement.appendChild(this.__document.createTextNode(text)); + this.__currentElement = textElement; + this.__applyStyleToCurrentElement(action); + parent.appendChild(this.__wrapTextLink(font,textElement)); + }; + + /** + * Creates a text element + * @param text + * @param x + * @param y + */ + ctx.prototype.fillText = function(text, x, y){ + this.__applyText(text, x, y, "fill"); + }; + + /** + * Strokes text + * @param text + * @param x + * @param y + */ + ctx.prototype.strokeText = function(text, x, y){ + this.__applyText(text, x, y, "stroke"); + }; + + /** + * No need to implement this for svg. + * @param text + * @return {TextMetrics} + */ + ctx.prototype.measureText = function(text){ + this.__ctx.font = this.font; + return this.__ctx.measureText(text); + }; + + /** + * Arc command! + */ + ctx.prototype.arc = function(x, y, radius, startAngle, endAngle, counterClockwise) { + // in canvas no circle is drawn if no angle is provided. + if (startAngle === endAngle) { + return; + } + startAngle = startAngle % (2*Math.PI); + endAngle = endAngle % (2*Math.PI); + if(startAngle === endAngle) { + //circle time! subtract some of the angle so svg is happy (svg elliptical arc can't draw a full circle) + endAngle = ((endAngle + (2*Math.PI)) - 0.001 * (counterClockwise ? -1 : 1)) % (2*Math.PI); + } + var endX = x+radius*Math.cos(endAngle), + endY = y+radius*Math.sin(endAngle), + startX = x+radius*Math.cos(startAngle), + startY = y+radius*Math.sin(startAngle), + sweepFlag = counterClockwise ? 0 : 1, + largeArcFlag = 0, + diff = endAngle - startAngle; + + // https://github.com/gliffy/canvas2svg/issues/4 + if(diff < 0) { + diff += 2*Math.PI; + } + + if(counterClockwise) { + largeArcFlag = diff > Math.PI ? 0 : 1; + } else { + largeArcFlag = diff > Math.PI ? 1 : 0; + } + + this.lineTo(startX, startY); + this.__addPathCommand(format("A {rx} {ry} {xAxisRotation} {largeArcFlag} {sweepFlag} {endX} {endY}", + {rx:radius, ry:radius, xAxisRotation:0, largeArcFlag:largeArcFlag, sweepFlag:sweepFlag, endX:endX, endY:endY})); + + this.__currentPosition = {x: endX, y: endY}; + }; + + /** + * Generates a ClipPath from the clip command. + */ + ctx.prototype.clip = function(){ + var group = this.__closestGroupOrSvg(), + clipPath = this.__createElement("clipPath"), + id = randomString(this.__ids), + newGroup = this.__createElement("g"); + + this.__applyCurrentDefaultPath(); + group.removeChild(this.__currentElement); + clipPath.setAttribute("id", id); + clipPath.appendChild(this.__currentElement); + + this.__defs.appendChild(clipPath); + + //set the clip path to this group + group.setAttribute("clip-path", format("url(#{id})", {id:id})); + + //clip paths can be scaled and transformed, we need to add another wrapper group to avoid later transformations + // to this path + group.appendChild(newGroup); + + this.__currentElement = newGroup; + + }; + + /** + * Draws a canvas, image or mock context to this canvas. + * Note that all svg dom manipulation uses node.childNodes rather than node.children for IE support. + * http://www.whatwg.org/specs/web-apps/current-work/multipage/the-canvas-element.html#dom-context-2d-drawimage + */ + ctx.prototype.drawImage = function(){ + //convert arguments to a real array + var args = Array.prototype.slice.call(arguments), + image=args[0], + dx, dy, dw, dh, sx=0, sy=0, sw, sh, parent, svg, defs, group, + currentElement, svgImage, canvas, context, id; + + if(args.length === 3) { + dx = args[1]; + dy = args[2]; + sw = image.width; + sh = image.height; + dw = sw; + dh = sh; + } else if(args.length === 5) { + dx = args[1]; + dy = args[2]; + dw = args[3]; + dh = args[4]; + sw = image.width; + sh = image.height; + } else if(args.length === 9) { + sx = args[1]; + sy = args[2]; + sw = args[3]; + sh = args[4]; + dx = args[5]; + dy = args[6]; + dw = args[7]; + dh = args[8]; + } else { + throw new Error("Inavlid number of arguments passed to drawImage: " + arguments.length); + } + + parent = this.__closestGroupOrSvg(); + currentElement = this.__currentElement; + var translateDirective = "translate(" + dx + ", " + dy + ")"; + if(image instanceof ctx) { + //canvas2svg mock canvas context. In the future we may want to clone nodes instead. + //also I'm currently ignoring dw, dh, sw, sh, sx, sy for a mock context. + svg = image.getSvg().cloneNode(true); + if (svg.childNodes && svg.childNodes.length > 1) { + defs = svg.childNodes[0]; + while(defs.childNodes.length) { + id = defs.childNodes[0].getAttribute("id"); + this.__ids[id] = id; + this.__defs.appendChild(defs.childNodes[0]); + } + group = svg.childNodes[1]; + if (group) { + //save original transform + var originTransform = group.getAttribute("transform"); + var transformDirective; + if (originTransform) { + transformDirective = originTransform+" "+translateDirective; + } else { + transformDirective = translateDirective; + } + group.setAttribute("transform", transformDirective); + parent.appendChild(group); + } + } + } else if(image.nodeName === "CANVAS" || image.nodeName === "IMG") { + //canvas or image + svgImage = this.__createElement("image"); + svgImage.setAttribute("width", dw); + svgImage.setAttribute("height", dh); + svgImage.setAttribute("preserveAspectRatio", "none"); + + if(sx || sy || sw !== image.width || sh !== image.height) { + //crop the image using a temporary canvas + canvas = this.__document.createElement("canvas"); + canvas.width = dw; + canvas.height = dh; + context = canvas.getContext("2d"); + context.drawImage(image, sx, sy, sw, sh, 0, 0, dw, dh); + image = canvas; + } + svgImage.setAttribute("transform", translateDirective); + svgImage.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", + image.nodeName === "CANVAS" ? image.toDataURL() : image.getAttribute("src")); + parent.appendChild(svgImage); + } + }; + + /** + * Generates a pattern tag + */ + ctx.prototype.createPattern = function(image, repetition){ + var pattern = this.__document.createElementNS("http://www.w3.org/2000/svg", "pattern"), id = randomString(this.__ids), + img; + pattern.setAttribute("id", id); + pattern.setAttribute("width", image.width); + pattern.setAttribute("height", image.height); + if(image.nodeName === "CANVAS" || image.nodeName === "IMG") { + img = this.__document.createElementNS("http://www.w3.org/2000/svg", "image"); + img.setAttribute("width", image.width); + img.setAttribute("height", image.height); + img.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", + image.nodeName === "CANVAS" ? image.toDataURL() : image.getAttribute("src")); + pattern.appendChild(img); + this.__defs.appendChild(pattern); + } else if(image instanceof ctx) { + pattern.appendChild(image.__root.childNodes[1]); + this.__defs.appendChild(pattern); + } + return new CanvasPattern(pattern, this); + }; + + ctx.prototype.setLineDash = function(dashArray) { + if (dashArray && dashArray.length > 0) { + this.lineDash = dashArray.join(","); + } else { + this.lineDash = null; + } + }; + + /** + * Not yet implemented + */ + ctx.prototype.drawFocusRing = function(){}; + ctx.prototype.createImageData = function(){}; + ctx.prototype.getImageData = function(){}; + ctx.prototype.putImageData = function(){}; + ctx.prototype.globalCompositeOperation = function(){}; + ctx.prototype.setTransform = function(){}; + + //add options for alternative namespace + if (typeof window === "object") { + window.C2S = ctx; + } + + // CommonJS/Browserify + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = ctx; + } + +}()); diff --git a/favicon-status/favicon-active.ico b/js/assets/favicon-active.ico similarity index 100% rename from favicon-status/favicon-active.ico rename to js/assets/favicon-active.ico diff --git a/favicon-status/favicon.ico b/js/assets/favicon.ico similarity index 100% rename from favicon-status/favicon.ico rename to js/assets/favicon.ico diff --git a/export-workflow-svg/exportAsSvg.js b/js/exportAsSvg.js similarity index 94% rename from export-workflow-svg/exportAsSvg.js rename to js/exportAsSvg.js index 76a1ecc..f4800ef 100644 --- a/export-workflow-svg/exportAsSvg.js +++ b/js/exportAsSvg.js @@ -1,247 +1,250 @@ -import { app } from "/scripts/app.js"; -import { ComfyWidgets } from "/scripts/widgets.js"; - -// Adds support for import + export as SVG including input + output images -// Adds two context menu items to the canvas -// Supports drag + drop import - -// https://codepen.io/peterhry/pen/nbMaYg -function wrapText(context, text, x, y, maxWidth, lineHeight) { - var words = text.split(" "), - line = "", - i, - test, - metrics; - - for (i = 0; i < words.length; i++) { - test = words[i]; - metrics = context.measureText(test); - while (metrics.width > maxWidth) { - // Determine how much of the word will fit - test = test.substring(0, test.length - 1); - metrics = context.measureText(test); - } - if (words[i] != test) { - words.splice(i + 1, 0, words[i].substr(test.length)); - words[i] = test; - } - - test = line + words[i] + " "; - metrics = context.measureText(test); - - if (metrics.width > maxWidth && i > 0) { - context.fillText(line, x, y); - line = words[i] + " "; - y += lineHeight; - } else { - line = test; - } - } - - context.fillText(line, x, y); -} - -function escapeXml(unsafe) { - return unsafe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); -} -function unescapeXml(safe) { - return safe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); -} - -let saving = false; -app.registerExtension({ - name: "pysssss.ExportAsSvg", - init() { - const stringWidget = ComfyWidgets.STRING; - // Override multiline string widgets to draw text using canvas while saving as svg - ComfyWidgets.STRING = function () { - const w = stringWidget.apply(this, arguments); - if (w.widget && w.widget.type === "customtext") { - const draw = w.widget.draw; - w.widget.draw = function (ctx) { - draw.apply(this, arguments); - - if (saving) { - const t = ctx.getTransform(); - ctx.save(); - ctx.resetTransform(); - - const style = document.defaultView.getComputedStyle(this.inputEl, null); - const x = parseInt(this.inputEl.style.left) + 10; - const y = parseInt(this.inputEl.style.top) + LiteGraph.NODE_TITLE_HEIGHT; - const w = parseInt(this.inputEl.style.width); - const h = parseInt(this.inputEl.style.height); - ctx.fillStyle = style.getPropertyValue("background-color"); - ctx.fillRect(x, y, w, h); - - ctx.fillStyle = style.getPropertyValue("color"); - ctx.font = style.getPropertyValue("font"); - - wrapText(ctx, this.inputEl.value, x, y + t.d * 12, w, t.d * 12); - - ctx.restore(); - } - }; - } - return w; - }; - }, - setup(app) { - const script = document.createElement("script"); - script.onload = function () { - function exportSvg() { - // Calculate the min max bounds for the nodes on the graph - const bounds = app.graph._nodes.reduce( - (p, n) => { - if (n.pos[0] < p[0]) p[0] = n.pos[0]; - if (n.pos[1] < p[1]) p[1] = n.pos[1]; - const r = n.pos[0] + n.size[0]; - const b = n.pos[1] + n.size[1]; - if (r > p[2]) p[2] = r; - if (b > p[3]) p[3] = b; - return p; - }, - [99999, 99999, -99999, -99999] - ); - - bounds[0] -= 100; - bounds[1] -= 100; - bounds[2] += 100; - bounds[3] += 100; - - // Store current canvas values to reset after drawing - const ctx = app.canvas.ctx; - const scale = app.canvas.ds.scale; - const width = app.canvas.canvas.width; - const height = app.canvas.canvas.height; - const offset = app.canvas.ds.offset; - - const svgCtx = new C2S(bounds[2] - bounds[0], bounds[3] - bounds[1]); - - svgCtx.canvas.getBoundingClientRect = function () { - return { width: svgCtx.width, height: svgCtx.height }; - }; - - // Override the c2s handling of images to draw images as canvases - const drawImage = svgCtx.drawImage; - svgCtx.drawImage = function (...args) { - const image = args[0]; - // If we are an image node and not a datauri then we need to replace with a canvas - // we cant convert to data uri here as it is an async process - if (image.nodeName === "IMG" && !image.src.startsWith("data:image/")) { - const canvas = document.createElement("canvas"); - canvas.width = image.width; - canvas.height = image.height; - const imgCtx = canvas.getContext("2d"); - imgCtx.drawImage(image, 0, 0); - args[0] = canvas; - } - - return drawImage.apply(this, args); - }; - - // Implement missing required functions - svgCtx.getTransform = function () { - return ctx.getTransform(); - }; - svgCtx.resetTransform = function () { - return ctx.resetTransform(); - }; - svgCtx.roundRect = svgCtx.rect; - - // Force the canvas to render the whole graph to the svg context - app.canvas.ds.scale = 1; - app.canvas.canvas.width = bounds[2] - bounds[0]; - app.canvas.canvas.height = bounds[3] - bounds[1]; - app.canvas.ds.offset = [-bounds[0], -bounds[1]]; - app.canvas.ctx = svgCtx; - - // Trigger saving - saving = true; - app.canvas.draw(true, true); - saving = false; - - // Restore original settings - app.canvas.ds.scale = scale; - app.canvas.canvas.width = width; - app.canvas.canvas.height = height; - app.canvas.ds.offset = offset; - app.canvas.ctx = ctx; - - app.canvas.draw(true, true); - - // Convert to SVG, embed graph and save - const json = JSON.stringify(app.graph.serialize()); - const svg = svgCtx.getSerializedSvg(true).replace("", `${escapeXml(json)}`); - const blob = new Blob([svg], { type: "image/svg+xml" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - Object.assign(a, { - href: url, - download: "workflow.svg", - style: "display: none", - }); - document.body.append(a); - a.click(); - setTimeout(function () { - a.remove(); - window.URL.revokeObjectURL(url); - }, 0); - } - - let fileInput; - function importSvg() { - if (!fileInput) { - fileInput = document.createElement("input"); - Object.assign(fileInput, { - type: "file", - accept: ".svg,image/svg+xml", - style: "display: none", - onchange: () => { - app.handleFile(fileInput.files[0]); - }, - }); - document.body.append(fileInput); - } - fileInput.click(); - } - - // Override file handling to allow drag & drop of SVG - const handleFile = app.handleFile; - app.handleFile = function (file) { - if (file && (file.type === "image/svg+xml" || file.name?.endsWith(".svg"))) { - const reader = new FileReader(); - reader.onload = () => { - // Extract embedded workflow from desc tags - const descEnd = reader.result.lastIndexOf(""); - if (descEnd !== -1) { - const descStart = reader.result.lastIndexOf("", descEnd); - if (descStart !== -1) { - const json = reader.result.substring(descStart + 6, descEnd); - this.loadGraphData(JSON.parse(unescapeXml(json))); - } - } - }; - reader.readAsText(file); - } else { - return handleFile.apply(this, arguments); - } - }; - - // Add canvas menu options - const orig = LGraphCanvas.prototype.getCanvasMenuOptions; - LGraphCanvas.prototype.getCanvasMenuOptions = function () { - const options = orig.apply(this, arguments); - options.push( - null, - { content: "SVG -> Import", callback: importSvg }, - { content: "SVG -> Export", callback: exportSvg } - ); - return options; - }; - }; - - script.src = "http://gliffy.github.io/canvas2svg/canvas2svg.js"; - document.body.append(script); - }, -}); +import { app } from "/scripts/app.js"; +import { ComfyWidgets } from "/scripts/widgets.js"; + +// Adds support for import + export as SVG including input + output images +// Adds two context menu items to the canvas +// Supports drag + drop import + +// https://codepen.io/peterhry/pen/nbMaYg +function wrapText(context, text, x, y, maxWidth, lineHeight) { + var words = text.split(" "), + line = "", + i, + test, + metrics; + + for (i = 0; i < words.length; i++) { + test = words[i]; + metrics = context.measureText(test); + while (metrics.width > maxWidth) { + // Determine how much of the word will fit + test = test.substring(0, test.length - 1); + metrics = context.measureText(test); + } + if (words[i] != test) { + words.splice(i + 1, 0, words[i].substr(test.length)); + words[i] = test; + } + + test = line + words[i] + " "; + metrics = context.measureText(test); + + if (metrics.width > maxWidth && i > 0) { + context.fillText(line, x, y); + line = words[i] + " "; + y += lineHeight; + } else { + line = test; + } + } + + context.fillText(line, x, y); +} + +function escapeXml(unsafe) { + return unsafe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} +function unescapeXml(safe) { + return safe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +let saving = false; +app.registerExtension({ + name: "pysssss.ExportAsSvg", + init() { + const stringWidget = ComfyWidgets.STRING; + // Override multiline string widgets to draw text using canvas while saving as svg + ComfyWidgets.STRING = function () { + const w = stringWidget.apply(this, arguments); + if (w.widget && w.widget.type === "customtext") { + const draw = w.widget.draw; + w.widget.draw = function (ctx) { + draw.apply(this, arguments); + + if (saving) { + const t = ctx.getTransform(); + ctx.save(); + ctx.resetTransform(); + + const style = document.defaultView.getComputedStyle(this.inputEl, null); + const x = parseInt(this.inputEl.style.left) + 10; + const y = parseInt(this.inputEl.style.top) + LiteGraph.NODE_TITLE_HEIGHT; + const w = parseInt(this.inputEl.style.width); + const h = parseInt(this.inputEl.style.height); + ctx.fillStyle = style.getPropertyValue("background-color"); + ctx.fillRect(x, y, w, h); + + ctx.fillStyle = style.getPropertyValue("color"); + ctx.font = style.getPropertyValue("font"); + + wrapText(ctx, this.inputEl.value, x, y + t.d * 12, w, t.d * 12); + + ctx.restore(); + } + }; + } + return w; + }; + }, + setup(app) { + const script = document.createElement("script"); + script.onload = function () { + function exportSvg() { + // Calculate the min max bounds for the nodes on the graph + const bounds = app.graph._nodes.reduce( + (p, n) => { + if (n.pos[0] < p[0]) p[0] = n.pos[0]; + if (n.pos[1] < p[1]) p[1] = n.pos[1]; + const r = n.pos[0] + n.size[0]; + const b = n.pos[1] + n.size[1]; + if (r > p[2]) p[2] = r; + if (b > p[3]) p[3] = b; + return p; + }, + [99999, 99999, -99999, -99999] + ); + + bounds[0] -= 100; + bounds[1] -= 100; + bounds[2] += 100; + bounds[3] += 100; + + // Store current canvas values to reset after drawing + const ctx = app.canvas.ctx; + const scale = app.canvas.ds.scale; + const width = app.canvas.canvas.width; + const height = app.canvas.canvas.height; + const offset = app.canvas.ds.offset; + + const svgCtx = new C2S(bounds[2] - bounds[0], bounds[3] - bounds[1]); + + svgCtx.canvas.getBoundingClientRect = function () { + return { width: svgCtx.width, height: svgCtx.height }; + }; + + // Override the c2s handling of images to draw images as canvases + const drawImage = svgCtx.drawImage; + svgCtx.drawImage = function (...args) { + const image = args[0]; + // If we are an image node and not a datauri then we need to replace with a canvas + // we cant convert to data uri here as it is an async process + if (image.nodeName === "IMG" && !image.src.startsWith("data:image/")) { + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const imgCtx = canvas.getContext("2d"); + imgCtx.drawImage(image, 0, 0); + args[0] = canvas; + } + + return drawImage.apply(this, args); + }; + + // Implement missing required functions + svgCtx.getTransform = function () { + return ctx.getTransform(); + }; + svgCtx.resetTransform = function () { + return ctx.resetTransform(); + }; + svgCtx.roundRect = svgCtx.rect; + + // Force the canvas to render the whole graph to the svg context + app.canvas.ds.scale = 1; + app.canvas.canvas.width = bounds[2] - bounds[0]; + app.canvas.canvas.height = bounds[3] - bounds[1]; + app.canvas.ds.offset = [-bounds[0], -bounds[1]]; + app.canvas.ctx = svgCtx; + + // Trigger saving + saving = true; + app.canvas.draw(true, true); + saving = false; + + // Restore original settings + app.canvas.ds.scale = scale; + app.canvas.canvas.width = width; + app.canvas.canvas.height = height; + app.canvas.ds.offset = offset; + app.canvas.ctx = ctx; + + app.canvas.draw(true, true); + + // Convert to SVG, embed graph and save + const json = JSON.stringify(app.graph.serialize()); + const svg = svgCtx + .getSerializedSvg(true) + .replace("", `${escapeXml(json)}`) + .replace(" { + app.handleFile(fileInput.files[0]); + }, + }); + document.body.append(fileInput); + } + fileInput.click(); + } + + // Override file handling to allow drag & drop of SVG + const handleFile = app.handleFile; + app.handleFile = function (file) { + if (file && (file.type === "image/svg+xml" || file.name?.endsWith(".svg"))) { + const reader = new FileReader(); + reader.onload = () => { + // Extract embedded workflow from desc tags + const descEnd = reader.result.lastIndexOf(""); + if (descEnd !== -1) { + const descStart = reader.result.lastIndexOf("", descEnd); + if (descStart !== -1) { + const json = reader.result.substring(descStart + 6, descEnd); + this.loadGraphData(JSON.parse(unescapeXml(json))); + } + } + }; + reader.readAsText(file); + } else { + return handleFile.apply(this, arguments); + } + }; + + // Add canvas menu options + const orig = LGraphCanvas.prototype.getCanvasMenuOptions; + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + const options = orig.apply(this, arguments); + options.push( + null, + { content: "SVG -> Import", callback: importSvg }, + { content: "SVG -> Export", callback: exportSvg } + ); + return options; + }; + }; + + script.src = new URL(`assets/canvas2svg.js`, import.meta.url); + document.body.append(script); + }, +}); diff --git a/favicon-status/faviconStatus.js b/js/faviconStatus.js similarity index 91% rename from favicon-status/faviconStatus.js rename to js/faviconStatus.js index e4465c5..14137ce 100644 --- a/favicon-status/faviconStatus.js +++ b/js/faviconStatus.js @@ -23,7 +23,7 @@ app.registerExtension({ document.head.appendChild(link); } - link.href = new URL(`${favicon}.ico`, import.meta.url); + link.href = new URL(`assets/${favicon}.ico`, import.meta.url); }); }, }); diff --git a/js/graphArrange.js b/js/graphArrange.js new file mode 100644 index 0000000..ce16d23 --- /dev/null +++ b/js/graphArrange.js @@ -0,0 +1,92 @@ +import { app } from "/scripts/app.js"; + +// Adds right click -> arrange to the canvas + +app.registerExtension({ + name: "pysssss.GraphArrange", + setup(app) { + const orig = LGraphCanvas.prototype.getCanvasMenuOptions; + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + const options = orig.apply(this, arguments); + options.push({ content: "Arrange (float left)", callback: () => graph.arrange() }); + options.push({ + content: "Arrange (float right)", + callback: () => { + (function () { + var margin = 50; + var layout; + + const nodes = this.computeExecutionOrder(false, true); + const columns = []; + + // Find node first use + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + let max = null; + for (const out of node.outputs || []) { + if (out.links) { + for (const link of out.links) { + const outNode = app.graph.getNodeById(app.graph.links[link].target_id); + var l = outNode._level - 1; + if (max === null) max = l; + else if (l < max) max = l; + } + } + } + if (max != null) node._level = max; + } + + for (let i = 0; i < nodes.length; ++i) { + const node = nodes[i]; + const col = node._level || 1; + if (!columns[col]) { + columns[col] = []; + } + columns[col].push(node); + } + + let x = margin; + + for (let i = 0; i < columns.length; ++i) { + const column = columns[i]; + if (!column) { + continue; + } + column.sort((a, b) => { + var as = !(a.type === "SaveImage" || a.type === "PreviewImage"); + var bs = !(b.type === "SaveImage" || b.type === "PreviewImage"); + var r = as - bs; + if (r === 0) r = (a.inputs?.length || 0) - (b.inputs?.length || 0); + if (r === 0) r = (a.outputs?.length || 0) - (b.outputs?.length || 0); + return r; + }); + let max_size = 100; + let y = margin + LiteGraph.NODE_TITLE_HEIGHT; + for (let j = 0; j < column.length; ++j) { + const node = column[j]; + node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x; + node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y; + const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0; + if (node.size[max_size_index] > max_size) { + max_size = node.size[max_size_index]; + } + const node_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 0 : 1; + y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT + j; + } + + // Right align in column + for (let j = 0; j < column.length; ++j) { + const node = column[j]; + node.pos[0] += max_size - node.size[0]; + } + x += max_size + margin; + } + + this.setDirtyCanvas(true, true); + }).apply(app.graph); + }, + }); + return options; + }; + }, +}); diff --git a/image-feed/imageFeed.js b/js/imageFeed.js similarity index 98% rename from image-feed/imageFeed.js rename to js/imageFeed.js index 14958f9..bacd602 100644 --- a/image-feed/imageFeed.js +++ b/js/imageFeed.js @@ -15,6 +15,7 @@ app.registerExtension({ bottom: 0, background: "#333", overflow: "auto", + zIndex: 99 }); document.body.append(imageList); @@ -71,7 +72,7 @@ app.registerExtension({ const a = document.createElement("a"); a.href = `/view?filename=${encodeURIComponent(src.filename)}&type=${src.type}&subfolder=${encodeURIComponent( src.subfolder - )}`; + )}&t=${+new Date()}`; a.target = "_blank"; Object.assign(img.style, { height: "120px", diff --git a/js/linkRenderMode.js b/js/linkRenderMode.js new file mode 100644 index 0000000..6bbc511 --- /dev/null +++ b/js/linkRenderMode.js @@ -0,0 +1,53 @@ +import { app } from "/scripts/app.js"; +import { $el } from "/scripts/ui.js"; + +const id = "pysssss.LinkRenderMode"; +const ext = { + name: id, + async setup(app) { + const setting = app.ui.settings.addSetting({ + id, + name: "🐍 Link Render Mode", + defaultValue: 2, + type: () => { + return $el("tr", [ + $el("td", [ + $el("label", { + for: id.replaceAll(".", "-"), + textContent: "🐍 Link Render Mode:", + }), + ]), + $el("td", [ + $el( + "select", + { + textContent: "Manage", + style: { + fontSize: "14px", + }, + oninput: (e) => { + setting.value = e.target.value; + app.canvas.links_render_mode = +e.target.value; + app.graph.setDirtyCanvas(true); + }, + }, + LiteGraph.LINK_RENDER_MODES.map((m, i) => + $el("option", { + value: i, + textContent: m, + selected: i == app.canvas.links_render_mode, + }) + ) + ), + ]), + ]); + }, + onChange(value) { + app.canvas.links_render_mode = +value; + app.graph.setDirtyCanvas(true); + }, + }); + }, +}; + +app.registerExtension(ext); diff --git a/lock-nodes-and-groups/locking.js b/js/locking.js similarity index 100% rename from lock-nodes-and-groups/locking.js rename to js/locking.js diff --git a/lora-subfolders/loraSubfolders.js b/js/loraSubfolders.js similarity index 100% rename from lora-subfolders/loraSubfolders.js rename to js/loraSubfolders.js diff --git a/node-finder/nodeFinder.js b/js/nodeFinder.js similarity index 100% rename from node-finder/nodeFinder.js rename to js/nodeFinder.js diff --git a/preset-text/presetText.js b/js/presetText.js similarity index 98% rename from preset-text/presetText.js rename to js/presetText.js index 112cabc..6a5490a 100644 --- a/preset-text/presetText.js +++ b/js/presetText.js @@ -24,7 +24,7 @@ app.registerExtension({ setup() { app.ui.settings.addSetting({ id: "pysssss.PresetText.ReplacementRegex", - name: "[pysssss] Preset Text Replacement Regex", + name: "🐍 Preset Text Replacement Regex", type: "text", defaultValue: "(?:^|[^\\w])(?@(?[\\w-]+))", tooltip: @@ -166,9 +166,9 @@ app.registerExtension({ } LiteGraph.registerNodeType( - "PresetText", + "PresetText|pysssss", Object.assign(PresetTextNode, { - title: "PresetText", + title: "Preset Text 🐍", }) ); diff --git a/quick-nodes/quickNodes.js b/js/quickNodes.js similarity index 96% rename from quick-nodes/quickNodes.js rename to js/quickNodes.js index 98daf53..cf6fa05 100644 --- a/quick-nodes/quickNodes.js +++ b/js/quickNodes.js @@ -1,198 +1,198 @@ -import { app } from "/scripts/app.js"; - -// Adds a bunch of context menu entries for quickly adding common steps - -// Any node with VAE input: "Use Vae" - Will find or add a VAE Loader and attach it -// KSampler: "Add Blank Input" - Connects an EmptyLatentImage -// KSampler: "Add Hi-res Fix" - Connects a LatentUpscale + second KSampler -// KSampler: "Add 2nd Pass" - Connects a new Ckpt Loader, CLIP Texts, Upscale and Sampler -// KSampler: "Add Save Image" - Connects a VAEDecode + SaveImage -// CheckpointLoaderSimple: "Add Clip Skip" - Connects a CLIPSetLastLayer node -// CheckpointLoaderSimple, CheckpointLoader, LoraLoader - "Add LORA" - Connects a new LoraLoader -// CheckpointLoaderSimple, CheckpointLoader, LoraLoader - "Add Promps" - Connects two new CLIPTextEncodes - -function addMenuHandler(nodeType, cb) { - const getOpts = nodeType.prototype.getExtraMenuOptions; - nodeType.prototype.getExtraMenuOptions = function () { - const r = getOpts.apply(this, arguments); - cb.apply(this, arguments); - return r; - }; -} - -function getOrAddVAELoader(node) { - let vaeNode = app.graph._nodes.find((n) => n.type === "VAELoader"); - if (!vaeNode) { - vaeNode = addNode("VAELoader", node); - } - return vaeNode; -} - -function addNode(name, nextTo, options) { - options = { select: true, shiftY: 0, before: false, ...(options || {}) }; - const node = LiteGraph.createNode(name); - app.graph.add(node); - node.pos = [ - options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30, - nextTo.pos[1] + options.shiftY, - ]; - if (options.select) { - app.canvas.selectNode(node, false); - } - return node; -} - -app.registerExtension({ - name: "pysssss.QuickNodes", - async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.input && nodeData.input.required) { - const keys = Object.keys(nodeData.input.required); - for (let i = 0; i < keys.length; i++) { - if (nodeData.input.required[keys[i]][0] === "VAE") { - addMenuHandler(nodeType, function (_, options) { - options.unshift({ - content: "Use VAE", - callback: () => { - getOrAddVAELoader(this).connect(0, this, i); - }, - }); - }); - break; - } - } - } - - if (nodeData.name === "KSampler") { - addMenuHandler(nodeType, function (_, options) { - options.unshift( - { - content: "Add Blank Input", - callback: () => { - const imageNode = addNode("EmptyLatentImage", this, { before: true }); - imageNode.connect(0, this, 3); - }, - }, - { - content: "Add Hi-res Fix", - callback: () => { - const upscaleNode = addNode("LatentUpscale", this); - this.connect(0, upscaleNode, 0); - - const sampleNode = addNode("KSampler", upscaleNode); - - for (let i = 0; i < 3; i++) { - const l = this.getInputLink(i); - if (l) { - app.graph.getNodeById(l.origin_id).connect(l.origin_slot, sampleNode, i); - } - } - - upscaleNode.connect(0, sampleNode, 3); - }, - }, - { - content: "Add 2nd Pass", - callback: () => { - const upscaleNode = addNode("LatentUpscale", this); - this.connect(0, upscaleNode, 0); - - const ckptNode = addNode("CheckpointLoaderSimple", this); - const sampleNode = addNode("KSampler", ckptNode); - - const positiveLink = this.getInputLink(1); - const negativeLink = this.getInputLink(2); - const positiveNode = positiveLink - ? app.graph.add(app.graph.getNodeById(positiveLink.origin_id).clone()) - : addNode("CLIPTextEncode"); - const negativeNode = negativeLink - ? app.graph.add(app.graph.getNodeById(negativeLink.origin_id).clone()) - : addNode("CLIPTextEncode"); - - ckptNode.connect(0, sampleNode, 0); - ckptNode.connect(1, positiveNode, 0); - ckptNode.connect(1, negativeNode, 0); - positiveNode.connect(0, sampleNode, 1); - negativeNode.connect(0, sampleNode, 2); - upscaleNode.connect(0, sampleNode, 3); - }, - }, - { - content: "Add Save Image", - callback: () => { - const decodeNode = addNode("VAEDecode", this); - this.connect(0, decodeNode, 0); - - getOrAddVAELoader(decodeNode).connect(0, decodeNode, 1); - - const saveNode = addNode("SaveImage", decodeNode); - decodeNode.connect(0, saveNode, 0); - }, - } - ); - }); - } - - if (nodeData.name === "CheckpointLoaderSimple") { - addMenuHandler(nodeType, function (_, options) { - options.unshift({ - content: "Add Clip Skip", - callback: () => { - const clipSkipNode = addNode("CLIPSetLastLayer", this); - const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : []; - - this.disconnectOutput(1); - this.connect(1, clipSkipNode, 0); - - for (const clipLink of clipLinks) { - clipSkipNode.connect(0, clipLink.target_id, clipLink.target_slot); - } - } - }); - }); - } - - if ( - nodeData.name === "CheckpointLoaderSimple" || - nodeData.name === "CheckpointLoader" || - nodeData.name === "LoraLoader" - ) { - addMenuHandler(nodeType, function (_, options) { - options.unshift( - { - content: "Add LORA", - callback: () => { - const loraNode = addNode("LoraLoader", this); - - const modelLinks = this.outputs[0].links ? this.outputs[0].links.map((l) => ({ ...graph.links[l] })) : []; - const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : []; - - this.disconnectOutput(0); - this.disconnectOutput(1); - - this.connect(0, loraNode, 0); - this.connect(1, loraNode, 1); - - for (const modelLink of modelLinks) { - loraNode.connect(0, modelLink.target_id, modelLink.target_slot); - } - - for (const clipLink of clipLinks) { - loraNode.connect(1, clipLink.target_id, clipLink.target_slot); - } - }, - }, - { - content: "Add Prompts", - callback: () => { - const positiveNode = addNode("CLIPTextEncode", this); - const negativeNode = addNode("CLIPTextEncode", this, { shiftY: positiveNode.size[1] + 30 }); - - this.connect(1, positiveNode, 0); - this.connect(1, negativeNode, 0); - }, - } - ); - }); - } - }, -}); +import { app } from "/scripts/app.js"; + +// Adds a bunch of context menu entries for quickly adding common steps + +// Any node with VAE input: "Use Vae" - Will find or add a VAE Loader and attach it +// KSampler: "Add Blank Input" - Connects an EmptyLatentImage +// KSampler: "Add Hi-res Fix" - Connects a LatentUpscale + second KSampler +// KSampler: "Add 2nd Pass" - Connects a new Ckpt Loader, CLIP Texts, Upscale and Sampler +// KSampler: "Add Save Image" - Connects a VAEDecode + SaveImage +// CheckpointLoaderSimple: "Add Clip Skip" - Connects a CLIPSetLastLayer node +// CheckpointLoaderSimple, CheckpointLoader, LoraLoader - "Add LORA" - Connects a new LoraLoader +// CheckpointLoaderSimple, CheckpointLoader, LoraLoader - "Add Promps" - Connects two new CLIPTextEncodes + +function addMenuHandler(nodeType, cb) { + const getOpts = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function () { + const r = getOpts.apply(this, arguments); + cb.apply(this, arguments); + return r; + }; +} + +function getOrAddVAELoader(node) { + let vaeNode = app.graph._nodes.find((n) => n.type === "VAELoader"); + if (!vaeNode) { + vaeNode = addNode("VAELoader", node); + } + return vaeNode; +} + +function addNode(name, nextTo, options) { + options = { select: true, shiftY: 0, before: false, ...(options || {}) }; + const node = LiteGraph.createNode(name); + app.graph.add(node); + node.pos = [ + options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30, + nextTo.pos[1] + options.shiftY, + ]; + if (options.select) { + app.canvas.selectNode(node, false); + } + return node; +} + +app.registerExtension({ + name: "pysssss.QuickNodes", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.input && nodeData.input.required) { + const keys = Object.keys(nodeData.input.required); + for (let i = 0; i < keys.length; i++) { + if (nodeData.input.required[keys[i]][0] === "VAE") { + addMenuHandler(nodeType, function (_, options) { + options.unshift({ + content: "Use VAE", + callback: () => { + getOrAddVAELoader(this).connect(0, this, i); + }, + }); + }); + break; + } + } + } + + if (nodeData.name === "KSampler") { + addMenuHandler(nodeType, function (_, options) { + options.unshift( + { + content: "Add Blank Input", + callback: () => { + const imageNode = addNode("EmptyLatentImage", this, { before: true }); + imageNode.connect(0, this, 3); + }, + }, + { + content: "Add Hi-res Fix", + callback: () => { + const upscaleNode = addNode("LatentUpscale", this); + this.connect(0, upscaleNode, 0); + + const sampleNode = addNode("KSampler", upscaleNode); + + for (let i = 0; i < 3; i++) { + const l = this.getInputLink(i); + if (l) { + app.graph.getNodeById(l.origin_id).connect(l.origin_slot, sampleNode, i); + } + } + + upscaleNode.connect(0, sampleNode, 3); + }, + }, + { + content: "Add 2nd Pass", + callback: () => { + const upscaleNode = addNode("LatentUpscale", this); + this.connect(0, upscaleNode, 0); + + const ckptNode = addNode("CheckpointLoaderSimple", this); + const sampleNode = addNode("KSampler", ckptNode); + + const positiveLink = this.getInputLink(1); + const negativeLink = this.getInputLink(2); + const positiveNode = positiveLink + ? app.graph.add(app.graph.getNodeById(positiveLink.origin_id).clone()) + : addNode("CLIPTextEncode"); + const negativeNode = negativeLink + ? app.graph.add(app.graph.getNodeById(negativeLink.origin_id).clone()) + : addNode("CLIPTextEncode"); + + ckptNode.connect(0, sampleNode, 0); + ckptNode.connect(1, positiveNode, 0); + ckptNode.connect(1, negativeNode, 0); + positiveNode.connect(0, sampleNode, 1); + negativeNode.connect(0, sampleNode, 2); + upscaleNode.connect(0, sampleNode, 3); + }, + }, + { + content: "Add Save Image", + callback: () => { + const decodeNode = addNode("VAEDecode", this); + this.connect(0, decodeNode, 0); + + getOrAddVAELoader(decodeNode).connect(0, decodeNode, 1); + + const saveNode = addNode("SaveImage", decodeNode); + decodeNode.connect(0, saveNode, 0); + }, + } + ); + }); + } + + if (nodeData.name === "CheckpointLoaderSimple") { + addMenuHandler(nodeType, function (_, options) { + options.unshift({ + content: "Add Clip Skip", + callback: () => { + const clipSkipNode = addNode("CLIPSetLastLayer", this); + const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : []; + + this.disconnectOutput(1); + this.connect(1, clipSkipNode, 0); + + for (const clipLink of clipLinks) { + clipSkipNode.connect(0, clipLink.target_id, clipLink.target_slot); + } + } + }); + }); + } + + if ( + nodeData.name === "CheckpointLoaderSimple" || + nodeData.name === "CheckpointLoader" || + nodeData.name === "LoraLoader" + ) { + addMenuHandler(nodeType, function (_, options) { + options.unshift( + { + content: "Add LORA", + callback: () => { + const loraNode = addNode("LoraLoader", this); + + const modelLinks = this.outputs[0].links ? this.outputs[0].links.map((l) => ({ ...graph.links[l] })) : []; + const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : []; + + this.disconnectOutput(0); + this.disconnectOutput(1); + + this.connect(0, loraNode, 0); + this.connect(1, loraNode, 1); + + for (const modelLink of modelLinks) { + loraNode.connect(0, modelLink.target_id, modelLink.target_slot); + } + + for (const clipLink of clipLinks) { + loraNode.connect(1, clipLink.target_id, clipLink.target_slot); + } + }, + }, + { + content: "Add Prompts", + callback: () => { + const positiveNode = addNode("CLIPTextEncode", this); + const negativeNode = addNode("CLIPTextEncode", this, { shiftY: positiveNode.size[1] + 30 }); + + this.connect(1, positiveNode, 0); + this.connect(1, negativeNode, 0); + }, + } + ); + }); + } + }, +}); diff --git a/show-text/showText.js b/js/showText.js similarity index 95% rename from show-text/showText.js rename to js/showText.js index fb1a117..a79ad9f 100644 --- a/show-text/showText.js +++ b/js/showText.js @@ -6,7 +6,7 @@ import { ComfyWidgets } from "/scripts/widgets.js"; app.registerExtension({ name: "pysssss.ShowText", async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.name === "ShowText") { + if (nodeData.name === "ShowText|pysssss") { // When the node is executed we will be sent the input text, display this in the widget const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { diff --git a/touch-support/touchEvents.js b/js/touchEvents.js similarity index 100% rename from touch-support/touchEvents.js rename to js/touchEvents.js diff --git a/widget-defaults/widgetDefaults.js b/js/widgetDefaults.js similarity index 90% rename from widget-defaults/widgetDefaults.js rename to js/widgetDefaults.js index 8991bab..a47084f 100644 --- a/widget-defaults/widgetDefaults.js +++ b/js/widgetDefaults.js @@ -201,19 +201,29 @@ app.registerExtension({ setting = app.ui.settings.addSetting({ id, - name: "[pysss] Widget Defaults", + name: "🐍 Widget Defaults", type: () => { - return $el("button", { - textContent: "[pysss] Manage Widget Defaults", - onclick: () => { - app.ui.settings.close(); - const dialog = new WidgetDefaultsDialog(); - dialog.show(); - }, - style: { - fontSize: "14px", - }, - }); + return $el("tr", [ + $el("td", [ + $el("label", { + for: id.replaceAll(".", "-"), + textContent: "🐍 Widget Defaults:", + }), + ]), + $el("td", [ + $el("button", { + textContent: "Manage", + onclick: () => { + app.ui.settings.element.close(); + const dialog = new WidgetDefaultsDialog(); + dialog.show(); + }, + style: { + fontSize: "14px", + }, + }), + ]), + ]); }, }); defaults = getDefaults(); diff --git a/workflows/index.js b/js/workflows.js similarity index 100% rename from workflows/index.js rename to js/workflows.js diff --git a/latent-upscale-by/latent_upscale_by.py b/latent-upscale-by/latent_upscale_by.py deleted file mode 100644 index 108eeba..0000000 --- a/latent-upscale-by/latent_upscale_by.py +++ /dev/null @@ -1,33 +0,0 @@ -import comfy.utils -import math - -class LatentUpscaleBy: - upscale_methods = ["nearest-exact", "bilinear", "area"] - crop_methods = ["disabled", "center"] - - @classmethod - def INPUT_TYPES(s): - return {"required": {"samples": ("LATENT",), "upscale_method": (s.upscale_methods,), - "scale": ("FLOAT", {"default": 1.5, "min": 0.1, "max": 10, "step": 0.05}), - "crop": (s.crop_methods,)}} - RETURN_TYPES = ("LATENT",) - FUNCTION = "upscale" - - CATEGORY = "latent" - - def upscale(self, samples, upscale_method, scale, crop): - s = samples.copy() - w = round(samples["samples"].shape[3] * 8 * scale) - w = 64 * math.ceil(w / 64) - - h = round(samples["samples"].shape[2] * 8 * scale) - h = 64 * math.ceil(h / 64) - - s["samples"] = comfy.utils.common_upscale( - samples["samples"], w // 8, h // 8, upscale_method, crop) - return (s,) - - -NODE_CLASS_MAPPINGS = { - "LatentUpscaleBy": LatentUpscaleBy, -} diff --git a/lock-nodes-and-groups/README.md b/lock-nodes-and-groups/README.md deleted file mode 100644 index a0d6555..0000000 --- a/lock-nodes-and-groups/README.md +++ /dev/null @@ -1,5 +0,0 @@ -Adds Lock/Unlock menu item to node/edit group to prevent moving/resizing - - - -hacky code in here \ No newline at end of file diff --git a/lora-subfolders/README.md b/lora-subfolders/README.md deleted file mode 100644 index 782c929..0000000 --- a/lora-subfolders/README.md +++ /dev/null @@ -1 +0,0 @@ -Splits the LoraLoader dropdown menu into multiple sub menus based on folders \ No newline at end of file diff --git a/preset-text/README.md b/preset-text/README.md deleted file mode 100644 index f53793c..0000000 --- a/preset-text/README.md +++ /dev/null @@ -1,7 +0,0 @@ -You can right click the CLIPTextEncode node -convert text to input -then feed the results of this node into the text encode - -You can also do search and replace for any preset text value by `@preset-name` -![image](https://user-images.githubusercontent.com/125205205/235372897-e201489f-3b60-498c-875a-aaa785528515.png) -![image](https://user-images.githubusercontent.com/125205205/235372916-9ccd0414-3b94-4702-9620-1847d2318189.png) diff --git a/py/constrain_image.py b/py/constrain_image.py new file mode 100644 index 0000000..d583d03 --- /dev/null +++ b/py/constrain_image.py @@ -0,0 +1,71 @@ +import torch +import numpy as np +from PIL import Image + +class ConstrainImage: + """ + A node that constrains an image to a maximum and minimum size while maintaining aspect ratio. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "max_width": ("INT", {"default": 1024, "min": 0}), + "max_height": ("INT", {"default": 1024, "min": 0}), + "min_width": ("INT", {"default": 0, "min": 0}), + "min_height": ("INT", {"default": 0, "min": 0}), + "crop_if_required": (["yes", "no"], {"default": "no"}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "constrain_image" + CATEGORY = "image" + OUTPUT_IS_LIST = (True,) + + def constrain_image(self, images, max_width, max_height, min_width, min_height, crop_if_required): + crop_if_required = crop_if_required == "yes" + results = [] + for image in images: + i = 255. * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)).convert("RGB") + + current_width, current_height = img.size + aspect_ratio = current_width / current_height + + constrained_width = max(min(current_width, min_width), max_width) + constrained_height = max(min(current_height, min_height), max_height) + + if constrained_width / constrained_height > aspect_ratio: + constrained_width = max(int(constrained_height * aspect_ratio), min_width) + if crop_if_required: + constrained_height = int(current_height / (current_width / constrained_width)) + else: + constrained_height = max(int(constrained_width / aspect_ratio), min_height) + if crop_if_required: + constrained_width = int(current_width / (current_height / constrained_height)) + + resized_image = img.resize((constrained_width, constrained_height), Image.ANTIALIAS) + + if crop_if_required and (constrained_width > max_width or constrained_height > max_height): + left = max((constrained_width - max_width) // 2, 0) + top = max((constrained_height - max_height) // 2, 0) + right = min(constrained_width, max_width) + left + bottom = min(constrained_height, max_height) + top + resized_image = resized_image.crop((left, top, right, bottom)) + + resized_image = np.array(resized_image).astype(np.float32) / 255.0 + resized_image = torch.from_numpy(resized_image)[None,] + results.append(resized_image) + + return (results,) + +NODE_CLASS_MAPPINGS = { + "ConstrainImage|pysssss": ConstrainImage, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ConstrainImage|pysssss": "Constrain Image 🐍", +} diff --git a/show-text/show_text.py b/py/show_text.py similarity index 79% rename from show-text/show_text.py rename to py/show_text.py index 762e0a6..2238d71 100644 --- a/show-text/show_text.py +++ b/py/show_text.py @@ -18,5 +18,9 @@ class ShowText: NODE_CLASS_MAPPINGS = { - "ShowText": ShowText, + "ShowText|pysssss": ShowText, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ShowText|pysssss": "Show Text 🐍", } diff --git a/py/string_function.py b/py/string_function.py new file mode 100644 index 0000000..cc05f66 --- /dev/null +++ b/py/string_function.py @@ -0,0 +1,45 @@ +import re + +class StringFunction: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "action": (["append", "replace"], {}), + "tidy_tags": (["yes", "no"], {}), + "text_a": ("STRING", {"multiline": True}), + "text_b": ("STRING", {"multiline": True}), + }, + "optional": { + "text_c": ("STRING", {"multiline": True}) + } + } + + RETURN_TYPES = ("STRING",) + FUNCTION = "exec" + CATEGORY = "utils" + + def exec(self, action, tidy_tags, text_a, text_b, text_c=""): + tidy_tags = tidy_tags == "yes" + out = "" + if action == "append": + out = (", " if tidy_tags else "").join(filter(None, [text_a, text_b, text_c])) + else: + if text_c is None: + text_c = "" + if text_b.startswith("/") and text_b.endswith("/"): + regex = text_b[1:-1] + out = re.sub(regex, text_c, text_a) + else: + out = text_a.replace(text_b, text_c) + if tidy_tags: + out = out.replace(" ", " ").replace(" ,", ",").replace(",,", ",").replace(",,", ",") + return (out, ) + +NODE_CLASS_MAPPINGS = { + "StringFunction|pysssss": StringFunction, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "StringFunction|pysssss": "String Function 🐍", +} diff --git a/workflows/workflows.py b/py/workflows.py similarity index 80% rename from workflows/workflows.py rename to py/workflows.py index e5ef018..beb256f 100644 --- a/workflows/workflows.py +++ b/py/workflows.py @@ -7,6 +7,7 @@ import json root_directory = os.path.dirname(inspect.getfile(PromptServer)) workflows_directory = os.path.join(root_directory, "pysssss-workflows") NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} @PromptServer.instance.routes.get("/pysssss/workflows") @@ -15,13 +16,15 @@ async def get_workflows(request): for dirpath, directories, file in os.walk(workflows_directory): for file in file: if (file.endswith(".json")): - files.append(os.path.relpath(os.path.join(dirpath, file), workflows_directory)) + files.append(os.path.relpath(os.path.join( + dirpath, file), workflows_directory)) return web.json_response(list(map(lambda f: os.path.splitext(f)[0].replace("\\", "/"), files))) @PromptServer.instance.routes.get("/pysssss/workflows/{name:.+}") async def get_workflow(request): - file = os.path.abspath(os.path.join(workflows_directory, request.match_info["name"] + ".json")) + file = os.path.abspath(os.path.join( + workflows_directory, request.match_info["name"] + ".json")) if os.path.commonpath([file, workflows_directory]) != workflows_directory: return web.Response(status=403) @@ -31,7 +34,8 @@ async def get_workflow(request): @PromptServer.instance.routes.post("/pysssss/workflows") async def save_workflow(request): json_data = await request.json() - file = os.path.abspath(os.path.join(workflows_directory, json_data["name"] + ".json")) + file = os.path.abspath(os.path.join( + workflows_directory, json_data["name"] + ".json")) if os.path.commonpath([file, workflows_directory]) != workflows_directory: return web.Response(status=403) diff --git a/pysssss.json b/pysssss.json new file mode 100644 index 0000000..6ca0642 --- /dev/null +++ b/pysssss.json @@ -0,0 +1,4 @@ +{ + "name": "CustomScripts", + "logging": false +} diff --git a/pysssss.py b/pysssss.py new file mode 100644 index 0000000..ce223b0 --- /dev/null +++ b/pysssss.py @@ -0,0 +1,269 @@ +import asyncio +import os +import json +import shutil +import inspect +import aiohttp +from server import PromptServer +from tqdm import tqdm + +config = None + + +def is_logging_enabled(): + config = get_extension_config() + if "logging" not in config: + return False + return config["logging"] + + +def log(message, type=None, always=False, name=None): + if not always and not is_logging_enabled(): + return + + if type is not None: + message = f"[{type}] {message}" + + if name is None: + name = get_extension_config()["name"] + + print(f"(pysssss:{name}) {message}") + + +def get_ext_dir(subpath=None, mkdir=False): + dir = os.path.dirname(__file__) + if subpath is not None: + dir = os.path.join(dir, subpath) + + dir = os.path.abspath(dir) + + if mkdir and not os.path.exists(dir): + os.makedirs(dir) + return dir + + +def get_comfy_dir(subpath=None, mkdir=False): + dir = os.path.dirname(inspect.getfile(PromptServer)) + if subpath is not None: + dir = os.path.join(dir, subpath) + + dir = os.path.abspath(dir) + + if mkdir and not os.path.exists(dir): + os.makedirs(dir) + return dir + + +def get_web_ext_dir(): + config = get_extension_config() + name = config["name"] + dir = get_comfy_dir("web/extensions/pysssss") + if not os.path.exists(dir): + os.makedirs(dir) + dir = os.path.join(dir, name) + return dir + + +def get_extension_config(reload=False): + global config + if reload == False and config is not None: + return config + + config_path = get_ext_dir("pysssss.json") + if not os.path.exists(config_path): + log("Missing pysssss.json, this extension may not work correctly. Please reinstall the extension.", + type="ERROR", always=True, name="???") + print(f"Extension path: {get_ext_dir()}") + return {"name": "Unknown", "version": -1} + with open(config_path, "r") as f: + config = json.loads(f.read()) + return config + + +def link_js(src, dst): + src = os.path.abspath(src) + dst = os.path.abspath(dst) + if os.name == "nt": + try: + import _winapi + _winapi.CreateJunction(src, dst) + return True + except: + pass + try: + os.symlink(src, dst) + return True + except: + import logging + logging.exception('') + return False + +def is_junction(path): + if os.name != "nt": + return False + try: + return bool(os.readlink(path)) + except OSError: + return False + +def install_js(): + src_dir = get_ext_dir("js") + if not os.path.exists(src_dir): + log("No JS") + return + + dst_dir = get_web_ext_dir() + + if os.path.exists(dst_dir): + if os.path.islink(dst_dir) or is_junction(dst_dir): + log("JS already linked") + return + elif link_js(src_dir, dst_dir): + log("JS linked") + return + + log("Copying JS files") + shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True) + + +def init(check_imports=None): + log("Init") + + if check_imports is not None: + import importlib.util + for imp in check_imports: + spec = importlib.util.find_spec(imp) + if spec is None: + log(f"{imp} is required, please check requirements are installed.", + type="ERROR", always=True) + return False + + install_js() + return True + + +def get_async_loop(): + loop = None + try: + loop = asyncio.get_event_loop() + except: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + +def get_http_session(): + loop = get_async_loop() + return aiohttp.ClientSession(loop=loop) + + +async def download(url, stream, update_callback=None, session=None): + close_session = False + if session is None: + close_session = True + session = get_http_session() + try: + async with session.get(url) as response: + size = int(response.headers.get('content-length', 0)) or None + + with tqdm( + unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1], total=size, + ) as progressbar: + perc = 0 + async for chunk in response.content.iter_chunked(2048): + stream.write(chunk) + progressbar.update(len(chunk)) + if update_callback is not None and progressbar.total is not None and progressbar.total != 0: + last = perc + perc = round(progressbar.n / progressbar.total, 2) + if perc != last: + last = perc + await update_callback(perc) + finally: + if close_session and session is not None: + await session.close() + + +async def download_to_file(url, destination, update_callback=None, is_ext_subpath=True, session=None): + if is_ext_subpath: + destination = get_ext_dir(destination) + with open(destination, mode='wb') as f: + download(url, f, update_callback, session) + + +def wait_for_async(async_fn, loop=None): + res = [] + + async def run_async(): + r = await async_fn() + res.append(r) + + if loop is None: + try: + loop = asyncio.get_event_loop() + except: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + loop.run_until_complete(run_async()) + + return res[0] + + +def update_node_status(client_id, node, text, progress=None): + if client_id is None: + client_id = PromptServer.instance.client_id + + if client_id is None: + return + + PromptServer.instance.send_sync("pysssss/update_status", { + "node": node, + "progress": progress, + "text": text + }, client_id) + + +async def update_node_status_async(client_id, node, text, progress=None): + if client_id is None: + client_id = PromptServer.instance.client_id + + if client_id is None: + return + + await PromptServer.instance.send("pysssss/update_status", { + "node": node, + "progress": progress, + "text": text + }, client_id) + + +def get_config_value(key, default=None, throw=False): + split = key.split(".") + obj = get_extension_config() + for s in split: + if s in split: + obj = obj[s] + else: + if throw: + raise KeyError("Configuration key missing: " + key) + else: + return default + return obj + + +def is_inside_dir(root_dir, check_path): + root_dir = os.path.abspath(root_dir) + if not os.path.isabs(check_path): + check_path = os.path.abspath(os.path.join(root_dir, check_path)) + return os.path.commonpath([check_path, root_dir]) == root_dir + + +def get_child_dir(root_dir, child_path, throw_if_outside=True): + child_path = os.path.abspath(os.path.join(root_dir, child_path)) + if is_inside_dir(root_dir, child_path): + return child_path + if throw_if_outside: + raise NotADirectoryError( + "Saving outside the target folder is not allowed.") + return None