change all the things

This commit is contained in:
pythongosssss
2023-06-18 12:45:25 +01:00
parent 5deac16327
commit ac6a031cde
34 changed files with 2268 additions and 807 deletions
+1
View File
@@ -0,0 +1 @@
__pycache__
+28 -29
View File
@@ -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
+24
View File
@@ -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"]
-8
View File
@@ -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
-117
View File
@@ -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,
}
-15
View File
@@ -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
View File
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function unescapeXml(safe) {
return safe.replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
}
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function unescapeXml(safe) {
return safe.replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
}
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);
});
},
});
+92
View File
@@ -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;
};
},
});
+2 -1
View File
@@ -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",
+53
View File
@@ -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 🐍",
})
);
+198 -198
View File
@@ -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);
},
}
);
});
}
},
});
+1 -1
View File
@@ -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();
-33
View File
@@ -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,
}
-5
View File
@@ -1,5 +0,0 @@
Adds Lock/Unlock menu item to node/edit group to prevent moving/resizing
hacky code in here
-1
View File
@@ -1 +0,0 @@
Splits the LoraLoader dropdown menu into multiple sub menus based on folders
-7
View File
@@ -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)
+71
View File
@@ -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 🐍",
}
+5 -1
View File
@@ -18,5 +18,9 @@ class ShowText:
NODE_CLASS_MAPPINGS = {
"ShowText": ShowText,
"ShowText|pysssss": ShowText,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ShowText|pysssss": "Show Text 🐍",
}
+45
View File
@@ -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 -3
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
{
"name": "CustomScripts",
"logging": false
}
+269
View File
@@ -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