change all the things
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__pycache__
|
||||
@@ -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
|
||||

|
||||
Takes an image/images and uses https://github.com/SkyTNT/anime-segmentation to remove the background or foreground.
|
||||
# Features
|
||||
|
||||
## Auto Arrange Graph
|
||||

|
||||
@@ -51,10 +38,6 @@ Adds a favicon and title to the window, favicon changes color while generating a
|
||||

|
||||
Adds a panel at the bottom of the window showing images that have been generated in the current session
|
||||
|
||||
## Latent Upscale By
|
||||

|
||||
Allows upscaling by a factor instead of specific width and height
|
||||
|
||||
## Lock Nodes & Groups
|
||||

|
||||
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
|
||||
+24
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
-125
@@ -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.")
|
||||
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1006 B After Width: | Height: | Size: 1006 B |
@@ -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("</svg>", `<desc>${escapeXml(json)}</desc></svg>`);
|
||||
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("</desc>");
|
||||
if (descEnd !== -1) {
|
||||
const descStart = reader.result.lastIndexOf("<desc>", 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("</svg>", `<desc>${escapeXml(json)}</desc></svg>`)
|
||||
.replace("<svg ", `<svg style="background: ${app.canvas.clear_background_color}" `);
|
||||
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("</desc>");
|
||||
if (descEnd !== -1) {
|
||||
const descStart = reader.result.lastIndexOf("<desc>", 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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
@@ -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);
|
||||
@@ -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])(?<replace>@(?<id>[\\w-]+))",
|
||||
tooltip:
|
||||
@@ -166,9 +166,9 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
LiteGraph.registerNodeType(
|
||||
"PresetText",
|
||||
"PresetText|pysssss",
|
||||
Object.assign(PresetTextNode, {
|
||||
title: "PresetText",
|
||||
title: "Preset Text 🐍",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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) {
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
Adds Lock/Unlock menu item to node/edit group to prevent moving/resizing
|
||||
|
||||
|
||||
|
||||
hacky code in here
|
||||
@@ -1 +0,0 @@
|
||||
Splits the LoraLoader dropdown menu into multiple sub menus based on folders
|
||||
@@ -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`
|
||||

|
||||

|
||||
@@ -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 🐍",
|
||||
}
|
||||
@@ -18,5 +18,9 @@ class ShowText:
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ShowText": ShowText,
|
||||
"ShowText|pysssss": ShowText,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ShowText|pysssss": "Show Text 🐍",
|
||||
}
|
||||
@@ -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 🐍",
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "CustomScripts",
|
||||
"logging": false
|
||||
}
|
||||
+269
@@ -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
|
||||
Reference in New Issue
Block a user