├── pysssss.default.json ├── web └── js │ ├── assets │ ├── favicon.ico │ ├── notify.mp3 │ ├── no-image.png │ └── favicon-active.ico │ ├── common │ ├── spinner.js │ ├── utils.js │ ├── spinner.css │ ├── autocomplete.css │ ├── lightbox.css │ ├── modelInfoDialog.css │ ├── lightbox.js │ ├── binding.js │ └── modelInfoDialog.js │ ├── swapResolution.js │ ├── useNumberInputPrompt.js │ ├── stringFunction.js │ ├── playSound.js │ ├── middleClickAddDefaultNode.js │ ├── mathExpression.js │ ├── linkRenderMode.js │ ├── systemNotification.js │ ├── kSamplerAdvDenoise.js │ ├── faviconStatus.js │ ├── showImageOnMenu.js │ ├── nodeFinder.js │ ├── snapToGrid.js │ ├── showText.js │ ├── contextMenuHook.js │ ├── graphArrange.js │ ├── customColors.js │ ├── repeater.js │ ├── snapToGridGuide.js │ ├── quickNodes.js │ ├── widgetDefaults.js │ ├── presetText.js │ ├── workflows.js │ ├── modelInfo.js │ ├── reroutePrimitive.js │ └── autocompleter.js ├── user └── text_file_dirs.json ├── .gitignore ├── pysssss.example.json ├── pyproject.toml ├── .github └── workflows │ └── publish.yml ├── py ├── autocomplete.py ├── play_sound.py ├── system_notification.py ├── repeater.py ├── reroute_primitive.py ├── show_text.py ├── string_function.py ├── workflows.py ├── constrain_image.py ├── constrain_image_for_video.py ├── model_info.py ├── better_combos.py ├── text_files.py └── math_expression.py ├── __init__.py ├── LICENSE ├── pysssss.py └── README.md /pysssss.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CustomScripts", 3 | "logging": false 4 | } 5 | -------------------------------------------------------------------------------- /web/js/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pythongosssss/ComfyUI-Custom-Scripts/HEAD/web/js/assets/favicon.ico -------------------------------------------------------------------------------- /web/js/assets/notify.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pythongosssss/ComfyUI-Custom-Scripts/HEAD/web/js/assets/notify.mp3 -------------------------------------------------------------------------------- /web/js/assets/no-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pythongosssss/ComfyUI-Custom-Scripts/HEAD/web/js/assets/no-image.png -------------------------------------------------------------------------------- /user/text_file_dirs.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "$input/**/*.txt", 3 | "output": "$output/**/*.txt", 4 | "temp": "$temp/**/*.txt" 5 | } 6 | -------------------------------------------------------------------------------- /web/js/assets/favicon-active.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pythongosssss/ComfyUI-Custom-Scripts/HEAD/web/js/assets/favicon-active.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | pysssss.json 3 | user/autocomplete.txt 4 | web/js/assets/favicon.user.ico 5 | web/js/assets/favicon-active.user.ico -------------------------------------------------------------------------------- /pysssss.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CustomScripts", 3 | "logging": false, 4 | "workflows": { 5 | "directory": "C:\\ComfyUI-Workflows" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /web/js/common/spinner.js: -------------------------------------------------------------------------------- 1 | import { addStylesheet } from "./utils.js"; 2 | 3 | addStylesheet(import.meta.url); 4 | 5 | export function createSpinner() { 6 | const div = document.createElement("div"); 7 | div.innerHTML = `
`; 8 | return div.firstElementChild; 9 | } 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui-custom-scripts" 3 | description = "Enhancements & experiments for ComfyUI, mostly focusing on UI features" 4 | version = "1.2.5" 5 | license = { file = "LICENSE" } 6 | 7 | [project.urls] 8 | Repository = "https://github.com/pythongosssss/ComfyUI-Custom-Scripts" 9 | 10 | [tool.comfy] 11 | PublisherId = "pythongosssss" 12 | DisplayName = "ComfyUI-Custom-Scripts" 13 | Icon = "" 14 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "pyproject.toml" 9 | 10 | permissions: 11 | issues: write 12 | 13 | jobs: 14 | publish-node: 15 | name: Publish Custom Node to registry 16 | runs-on: ubuntu-latest 17 | if: ${{ github.repository_owner == 'pythongosssss' }} 18 | steps: 19 | - name: Check out code 20 | uses: actions/checkout@v4 21 | - name: Publish Custom Node 22 | uses: Comfy-Org/publish-node-action@v1 23 | with: 24 | ## Add your own personal access token to your Github Repository secrets and reference it here. 25 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} 26 | -------------------------------------------------------------------------------- /web/js/common/utils.js: -------------------------------------------------------------------------------- 1 | import { $el } from "../../../../scripts/ui.js"; 2 | 3 | export function addStylesheet(url) { 4 | if (url.endsWith(".js")) { 5 | url = url.substr(0, url.length - 2) + "css"; 6 | } 7 | $el("link", { 8 | parent: document.head, 9 | rel: "stylesheet", 10 | type: "text/css", 11 | href: url.startsWith("http") ? url : getUrl(url), 12 | }); 13 | } 14 | 15 | export function getUrl(path, baseUrl) { 16 | if (baseUrl) { 17 | return new URL(path, baseUrl).toString(); 18 | } else { 19 | return new URL("../" + path, import.meta.url).toString(); 20 | } 21 | } 22 | 23 | export async function loadImage(url) { 24 | return new Promise((res, rej) => { 25 | const img = new Image(); 26 | img.onload = res; 27 | img.onerror = rej; 28 | img.src = url; 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /web/js/common/spinner.css: -------------------------------------------------------------------------------- 1 | .pysssss-lds-ring { 2 | display: inline-block; 3 | position: absolute; 4 | width: 80px; 5 | height: 80px; 6 | } 7 | .pysssss-lds-ring div { 8 | box-sizing: border-box; 9 | display: block; 10 | position: absolute; 11 | width: 64px; 12 | height: 64px; 13 | margin: 8px; 14 | border: 5px solid #fff; 15 | border-radius: 50%; 16 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 17 | border-color: #fff transparent transparent transparent; 18 | } 19 | .pysssss-lds-ring div:nth-child(1) { 20 | animation-delay: -0.45s; 21 | } 22 | .pysssss-lds-ring div:nth-child(2) { 23 | animation-delay: -0.3s; 24 | } 25 | .pysssss-lds-ring div:nth-child(3) { 26 | animation-delay: -0.15s; 27 | } 28 | @keyframes lds-ring { 29 | 0% { 30 | transform: rotate(0deg); 31 | } 32 | 100% { 33 | transform: rotate(360deg); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /py/autocomplete.py: -------------------------------------------------------------------------------- 1 | from server import PromptServer 2 | from aiohttp import web 3 | import os 4 | import folder_paths 5 | 6 | dir = os.path.abspath(os.path.join(__file__, "../../user")) 7 | if not os.path.exists(dir): 8 | os.mkdir(dir) 9 | file = os.path.join(dir, "autocomplete.txt") 10 | 11 | 12 | @PromptServer.instance.routes.get("/pysssss/autocomplete") 13 | async def get_autocomplete(request): 14 | if os.path.isfile(file): 15 | return web.FileResponse(file) 16 | return web.Response(status=404) 17 | 18 | 19 | @PromptServer.instance.routes.post("/pysssss/autocomplete") 20 | async def update_autocomplete(request): 21 | with open(file, "w", encoding="utf-8") as f: 22 | f.write(await request.text()) 23 | return web.Response(status=200) 24 | 25 | 26 | @PromptServer.instance.routes.get("/pysssss/loras") 27 | async def get_loras(request): 28 | loras = folder_paths.get_filename_list("loras") 29 | return web.json_response(list(map(lambda a: os.path.splitext(a)[0], loras))) 30 | -------------------------------------------------------------------------------- /web/js/swapResolution.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | app.registerExtension({ 3 | name: "pysssss.SwapResolution", 4 | async beforeRegisterNodeDef(nodeType, nodeData) { 5 | const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional }; 6 | if (inputs.width && inputs.height) { 7 | const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; 8 | nodeType.prototype.getExtraMenuOptions = function (_, options) { 9 | const r = origGetExtraMenuOptions?.apply?.(this, arguments); 10 | 11 | options.push( 12 | { 13 | content: "Swap width/height", 14 | callback: () => { 15 | const w = this.widgets.find((w) => w.name === "width"); 16 | const h = this.widgets.find((w) => w.name === "height"); 17 | const a = w.value; 18 | w.value = h.value; 19 | h.value = a; 20 | app.graph.setDirtyCanvas(true); 21 | }, 22 | }, 23 | null 24 | ); 25 | 26 | return r; 27 | }; 28 | } 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /web/js/useNumberInputPrompt.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | 3 | const id = "pysssss.UseNumberInputPrompt"; 4 | const ext = { 5 | name: id, 6 | async setup(app) { 7 | const prompt = LGraphCanvas.prototype.prompt; 8 | 9 | const setting = app.ui.settings.addSetting({ 10 | id, 11 | name: "🐍 Use number input on value entry", 12 | defaultValue: false, 13 | type: "boolean", 14 | }); 15 | 16 | LGraphCanvas.prototype.prompt = function () { 17 | const dialog = prompt.apply(this, arguments); 18 | if (setting.value && typeof arguments[1] === "number") { 19 | // If this should be a number then update the imput 20 | const input = dialog.querySelector("input"); 21 | input.type = "number"; 22 | 23 | // Add constraints 24 | const widget = app.canvas.node_widget?.[1]; 25 | if (widget?.options) { 26 | for (const prop of ["min", "max", "step"]) { 27 | if (widget.options[prop]) input[prop] = widget.options[prop]; 28 | } 29 | } 30 | } 31 | return dialog; 32 | }; 33 | }, 34 | }; 35 | 36 | app.registerExtension(ext); 37 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | import glob 3 | import os 4 | import sys 5 | from .pysssss import init, get_ext_dir 6 | 7 | NODE_CLASS_MAPPINGS = {} 8 | NODE_DISPLAY_NAME_MAPPINGS = {} 9 | 10 | if init(): 11 | py = get_ext_dir("py") 12 | files = glob.glob(os.path.join(py, "*.py"), recursive=False) 13 | for file in files: 14 | name = os.path.splitext(file)[0] 15 | spec = importlib.util.spec_from_file_location(name, file) 16 | module = importlib.util.module_from_spec(spec) 17 | sys.modules[name] = module 18 | spec.loader.exec_module(module) 19 | if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None: 20 | NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS) 21 | if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None: 22 | NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS) 23 | 24 | WEB_DIRECTORY = "./web" 25 | __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] 26 | -------------------------------------------------------------------------------- /web/js/stringFunction.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { ComfyWidgets } from "../../../scripts/widgets.js"; 3 | 4 | // Displays input text on a node 5 | 6 | app.registerExtension({ 7 | name: "pysssss.StringFunction", 8 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 9 | if (nodeData.name === "StringFunction|pysssss") { 10 | const onExecuted = nodeType.prototype.onExecuted; 11 | nodeType.prototype.onExecuted = function (message) { 12 | onExecuted?.apply(this, arguments); 13 | 14 | if (this.widgets) { 15 | const pos = this.widgets.findIndex((w) => w.name === "result"); 16 | if (pos !== -1) { 17 | for (let i = pos; i < this.widgets.length; i++) { 18 | this.widgets[i].onRemove?.(); 19 | } 20 | this.widgets.length = pos; 21 | } 22 | } 23 | 24 | const w = ComfyWidgets["STRING"](this, "result", ["STRING", { multiline: true }], app).widget; 25 | w.inputEl.readOnly = true; 26 | w.inputEl.style.opacity = 0.6; 27 | w.value = message.text; 28 | 29 | this.onResize?.(this.size); 30 | }; 31 | } 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /web/js/playSound.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | 3 | app.registerExtension({ 4 | name: "pysssss.PlaySound", 5 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 6 | if (nodeData.name === "PlaySound|pysssss") { 7 | const onExecuted = nodeType.prototype.onExecuted; 8 | nodeType.prototype.onExecuted = async function () { 9 | onExecuted?.apply(this, arguments); 10 | if (this.widgets[0].value === "on empty queue") { 11 | if (app.ui.lastQueueSize !== 0) { 12 | await new Promise((r) => setTimeout(r, 500)); 13 | } 14 | if (app.ui.lastQueueSize !== 0) { 15 | return; 16 | } 17 | } 18 | let file = this.widgets[2].value; 19 | if (!file) { 20 | file = "notify.mp3"; 21 | } 22 | if (!file.startsWith("http")) { 23 | if (!file.includes("/")) { 24 | file = "assets/" + file; 25 | } 26 | file = new URL(file, import.meta.url) 27 | } 28 | 29 | const url = new URL(file); 30 | const audio = new Audio(url); 31 | audio.volume = this.widgets[1].value; 32 | audio.play(); 33 | }; 34 | } 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 pythongosssss 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /py/play_sound.py: -------------------------------------------------------------------------------- 1 | # Hack: string type that is always equal in not equal comparisons 2 | class AnyType(str): 3 | def __ne__(self, __value: object) -> bool: 4 | return False 5 | 6 | 7 | # Our any instance wants to be a wildcard string 8 | any = AnyType("*") 9 | 10 | 11 | class PlaySound: 12 | @classmethod 13 | def INPUT_TYPES(s): 14 | return {"required": { 15 | "any": (any, {}), 16 | "mode": (["always", "on empty queue"], {}), 17 | "volume": ("FLOAT", {"min": 0, "max": 1, "step": 0.1, "default": 0.5}), 18 | "file": ("STRING", { "default": "notify.mp3" }) 19 | }} 20 | 21 | FUNCTION = "nop" 22 | INPUT_IS_LIST = True 23 | OUTPUT_IS_LIST = (True,) 24 | OUTPUT_NODE = True 25 | RETURN_TYPES = (any,) 26 | 27 | CATEGORY = "utils" 28 | 29 | def IS_CHANGED(self, **kwargs): 30 | return float("NaN") 31 | 32 | def nop(self, any, mode, volume, file): 33 | return {"ui": {"a": []}, "result": (any,)} 34 | 35 | 36 | NODE_CLASS_MAPPINGS = { 37 | "PlaySound|pysssss": PlaySound, 38 | } 39 | 40 | NODE_DISPLAY_NAME_MAPPINGS = { 41 | "PlaySound|pysssss": "PlaySound 🐍", 42 | } 43 | -------------------------------------------------------------------------------- /py/system_notification.py: -------------------------------------------------------------------------------- 1 | # Hack: string type that is always equal in not equal comparisons 2 | class AnyType(str): 3 | def __ne__(self, __value: object) -> bool: 4 | return False 5 | 6 | 7 | # Our any instance wants to be a wildcard string 8 | any = AnyType("*") 9 | 10 | 11 | class SystemNotification: 12 | @classmethod 13 | def INPUT_TYPES(s): 14 | return {"required": { 15 | "message": ("STRING", {"default": "Your notification has triggered."}), 16 | "any": (any, {}), 17 | "mode": (["always", "on empty queue"], {}), 18 | }} 19 | 20 | FUNCTION = "nop" 21 | INPUT_IS_LIST = True 22 | OUTPUT_IS_LIST = (True,) 23 | OUTPUT_NODE = True 24 | RETURN_TYPES = (any,) 25 | 26 | CATEGORY = "utils" 27 | 28 | def IS_CHANGED(self, **kwargs): 29 | return float("NaN") 30 | 31 | def nop(self, any, message, mode): 32 | return {"ui": {"message": message, "mode": mode}, "result": (any,)} 33 | 34 | 35 | NODE_CLASS_MAPPINGS = { 36 | "SystemNotification|pysssss": SystemNotification, 37 | } 38 | 39 | NODE_DISPLAY_NAME_MAPPINGS = { 40 | "SystemNotification|pysssss": "SystemNotification 🐍", 41 | } 42 | -------------------------------------------------------------------------------- /web/js/middleClickAddDefaultNode.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | 3 | const id = "pysssss.MiddleClickAddDefaultNode"; 4 | const ext = { 5 | name: id, 6 | async setup(app) { 7 | app.ui.settings.addSetting({ 8 | id, 9 | name: "🐍 Middle click slot to add", 10 | defaultValue: "Reroute", 11 | type: "combo", 12 | options: (value) => 13 | [ 14 | ...Object.keys(LiteGraph.registered_node_types) 15 | .filter((k) => k.includes("Reroute")) 16 | .sort((a, b) => { 17 | if (a === "Reroute") return -1; 18 | if (b === "Reroute") return 1; 19 | return a.localeCompare(b); 20 | }), 21 | "[None]", 22 | ].map((m) => ({ 23 | value: m, 24 | text: m, 25 | selected: !value ? m === "[None]" : m === value, 26 | })), 27 | onChange(value) { 28 | const enable = value && value !== "[None]"; 29 | if (value === true) { 30 | value = "Reroute"; 31 | } 32 | LiteGraph.middle_click_slot_add_default_node = enable; 33 | if (enable) { 34 | for (const arr of Object.values(LiteGraph.slot_types_default_in).concat( 35 | Object.values(LiteGraph.slot_types_default_out) 36 | )) { 37 | const idx = arr.indexOf(value); 38 | if (idx !== 0) { 39 | arr.splice(idx, 1); 40 | } 41 | arr.unshift(value); 42 | } 43 | } 44 | }, 45 | }); 46 | }, 47 | }; 48 | 49 | app.registerExtension(ext); 50 | -------------------------------------------------------------------------------- /web/js/common/autocomplete.css: -------------------------------------------------------------------------------- 1 | .pysssss-autocomplete { 2 | color: var(--descrip-text); 3 | background-color: var(--comfy-menu-bg); 4 | position: absolute; 5 | font-family: sans-serif; 6 | box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4); 7 | z-index: 9999; 8 | overflow: auto; 9 | } 10 | 11 | .pysssss-autocomplete-item { 12 | cursor: pointer; 13 | padding: 3px 7px; 14 | display: flex; 15 | border-left: 3px solid transparent; 16 | align-items: center; 17 | } 18 | 19 | .pysssss-autocomplete-item--selected { 20 | border-left-color: dodgerblue; 21 | } 22 | 23 | .pysssss-autocomplete-highlight { 24 | font-weight: bold; 25 | text-decoration: underline; 26 | text-decoration-color: dodgerblue; 27 | } 28 | 29 | .pysssss-autocomplete-pill { 30 | margin-left: auto; 31 | font-size: 10px; 32 | color: #fff; 33 | padding: 2px 4px 2px 14px; 34 | position: relative; 35 | } 36 | 37 | .pysssss-autocomplete-pill::after { 38 | content: ""; 39 | display: block; 40 | background: rgba(255, 255, 255, 0.25); 41 | width: calc(100% - 10px); 42 | height: 100%; 43 | position: absolute; 44 | left: 10px; 45 | top: 0; 46 | border-radius: 5px; 47 | } 48 | 49 | .pysssss-autocomplete-pill + .pysssss-autocomplete-pill { 50 | margin-left: 0; 51 | } 52 | 53 | .pysssss-autocomplete-item-info { 54 | margin-left: auto; 55 | transition: filter 0.2s; 56 | will-change: filter; 57 | text-decoration: none; 58 | padding-left: 10px; 59 | } 60 | .pysssss-autocomplete-item-info:hover { 61 | filter: invert(1); 62 | } 63 | -------------------------------------------------------------------------------- /web/js/mathExpression.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { ComfyWidgets } from "../../../scripts/widgets.js"; 3 | 4 | app.registerExtension({ 5 | name: "pysssss.MathExpression", 6 | init() { 7 | const STRING = ComfyWidgets.STRING; 8 | ComfyWidgets.STRING = function (node, inputName, inputData) { 9 | const r = STRING.apply(this, arguments); 10 | r.widget.dynamicPrompts = inputData?.[1].dynamicPrompts; 11 | return r; 12 | }; 13 | }, 14 | beforeRegisterNodeDef(nodeType) { 15 | if (nodeType.comfyClass === "MathExpression|pysssss") { 16 | const onDrawForeground = nodeType.prototype.onDrawForeground; 17 | 18 | nodeType.prototype.onNodeCreated = function() { 19 | // These are typed as any to bypass backend validation 20 | // update frontend to restrict types 21 | for(const input of this.inputs) { 22 | input.type = "INT,FLOAT,IMAGE,LATENT"; 23 | } 24 | } 25 | 26 | nodeType.prototype.onDrawForeground = function (ctx) { 27 | const r = onDrawForeground?.apply?.(this, arguments); 28 | 29 | const v = app.nodeOutputs?.[this.id + ""]; 30 | if (!this.flags.collapsed && v) { 31 | const text = v.value[0] + ""; 32 | ctx.save(); 33 | ctx.font = "bold 12px sans-serif"; 34 | ctx.fillStyle = "dodgerblue"; 35 | const sz = ctx.measureText(text); 36 | ctx.fillText(text, this.size[0] - sz.width - 5, LiteGraph.NODE_SLOT_HEIGHT * 3); 37 | ctx.restore(); 38 | } 39 | 40 | return r; 41 | }; 42 | } 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /py/repeater.py: -------------------------------------------------------------------------------- 1 | # Hack: string type that is always equal in not equal comparisons 2 | class AnyType(str): 3 | def __ne__(self, __value: object) -> bool: 4 | return False 5 | 6 | 7 | # Our any instance wants to be a wildcard string 8 | any = AnyType("*") 9 | 10 | 11 | class Repeater: 12 | @classmethod 13 | def INPUT_TYPES(s): 14 | return {"required": { 15 | "source": (any, {}), 16 | "repeats": ("INT", {"min": 0, "max": 5000, "default": 2}), 17 | "output": (["single", "multi"], {}), 18 | "node_mode": (["reuse", "create"], {}), 19 | }} 20 | 21 | RETURN_TYPES = (any,) 22 | FUNCTION = "repeat" 23 | OUTPUT_NODE = False 24 | OUTPUT_IS_LIST = (True,) 25 | 26 | CATEGORY = "utils" 27 | 28 | def repeat(self, repeats, output, node_mode, **kwargs): 29 | if output == "multi": 30 | # Multi outputs are split to indiviual nodes on the frontend when serializing 31 | return ([kwargs["source"]],) 32 | elif node_mode == "reuse": 33 | # When reusing we have a single input node, repeat that N times 34 | return ([kwargs["source"]] * repeats,) 35 | else: 36 | # When creating new nodes, they'll be added dynamically when the graph is serialized 37 | return ((list(kwargs.values())),) 38 | 39 | 40 | NODE_CLASS_MAPPINGS = { 41 | "Repeater|pysssss": Repeater, 42 | } 43 | 44 | NODE_DISPLAY_NAME_MAPPINGS = { 45 | "Repeater|pysssss": "Repeater 🐍", 46 | } 47 | -------------------------------------------------------------------------------- /py/reroute_primitive.py: -------------------------------------------------------------------------------- 1 | # Hack: string type that is always equal in not equal comparisons 2 | class AnyType(str): 3 | def __ne__(self, __value: object) -> bool: 4 | return False 5 | 6 | 7 | # Our any instance wants to be a wildcard string 8 | any = AnyType("*") 9 | 10 | 11 | class ReroutePrimitive: 12 | @classmethod 13 | def INPUT_TYPES(cls): 14 | return { 15 | "required": {"value": (any, )}, 16 | } 17 | 18 | @classmethod 19 | def VALIDATE_INPUTS(s, **kwargs): 20 | return True 21 | 22 | RETURN_TYPES = (any,) 23 | FUNCTION = "route" 24 | CATEGORY = "__hidden__" 25 | 26 | def route(self, value): 27 | return (value,) 28 | 29 | 30 | class MultiPrimitive: 31 | @classmethod 32 | def INPUT_TYPES(cls): 33 | return { 34 | "required": {}, 35 | "optional": {"value": (any, )}, 36 | } 37 | 38 | @classmethod 39 | def VALIDATE_INPUTS(s, **kwargs): 40 | return True 41 | 42 | RETURN_TYPES = (any,) 43 | FUNCTION = "listify" 44 | CATEGORY = "utils" 45 | OUTPUT_IS_LIST = (True,) 46 | 47 | def listify(self, **kwargs): 48 | return (list(kwargs.values()),) 49 | 50 | 51 | NODE_CLASS_MAPPINGS = { 52 | "ReroutePrimitive|pysssss": ReroutePrimitive, 53 | # "MultiPrimitive|pysssss": MultiPrimitive, 54 | } 55 | 56 | NODE_DISPLAY_NAME_MAPPINGS = { 57 | "ReroutePrimitive|pysssss": "Reroute Primitive 🐍", 58 | # "MultiPrimitive|pysssss": "Multi Primitive 🐍", 59 | } 60 | -------------------------------------------------------------------------------- /web/js/linkRenderMode.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { $el } from "../../../scripts/ui.js"; 3 | 4 | const id = "pysssss.LinkRenderMode"; 5 | const ext = { 6 | name: id, 7 | async setup(app) { 8 | if (app.extensions.find((ext) => ext.name === "Comfy.LinkRenderMode")) { 9 | console.log("%c[🐍 pysssss]", "color: limegreen", "Skipping LinkRenderMode as core extension found"); 10 | return; 11 | } 12 | const setting = app.ui.settings.addSetting({ 13 | id, 14 | name: "🐍 Link Render Mode", 15 | defaultValue: 2, 16 | type: () => { 17 | return $el("tr", [ 18 | $el("td", [ 19 | $el("label", { 20 | for: id.replaceAll(".", "-"), 21 | textContent: "🐍 Link Render Mode:", 22 | }), 23 | ]), 24 | $el("td", [ 25 | $el( 26 | "select", 27 | { 28 | textContent: "Manage", 29 | style: { 30 | fontSize: "14px", 31 | }, 32 | oninput: (e) => { 33 | setting.value = e.target.value; 34 | app.canvas.links_render_mode = +e.target.value; 35 | app.graph.setDirtyCanvas(true, true); 36 | }, 37 | }, 38 | LiteGraph.LINK_RENDER_MODES.map((m, i) => 39 | $el("option", { 40 | value: i, 41 | textContent: m, 42 | selected: i == app.canvas.links_render_mode, 43 | }) 44 | ) 45 | ), 46 | ]), 47 | ]); 48 | }, 49 | onChange(value) { 50 | app.canvas.links_render_mode = +value; 51 | app.graph.setDirtyCanvas(true); 52 | }, 53 | }); 54 | }, 55 | }; 56 | 57 | app.registerExtension(ext); 58 | -------------------------------------------------------------------------------- /py/show_text.py: -------------------------------------------------------------------------------- 1 | class ShowText: 2 | @classmethod 3 | def INPUT_TYPES(s): 4 | return { 5 | "required": { 6 | "text": ("STRING", {"forceInput": True}), 7 | }, 8 | "hidden": { 9 | "unique_id": "UNIQUE_ID", 10 | "extra_pnginfo": "EXTRA_PNGINFO", 11 | }, 12 | } 13 | 14 | INPUT_IS_LIST = True 15 | RETURN_TYPES = ("STRING",) 16 | FUNCTION = "notify" 17 | OUTPUT_NODE = True 18 | OUTPUT_IS_LIST = (True,) 19 | 20 | CATEGORY = "utils" 21 | 22 | def notify(self, text, unique_id=None, extra_pnginfo=None): 23 | if unique_id is not None and extra_pnginfo is not None: 24 | if not isinstance(extra_pnginfo, list): 25 | print("Error: extra_pnginfo is not a list") 26 | elif ( 27 | not isinstance(extra_pnginfo[0], dict) 28 | or "workflow" not in extra_pnginfo[0] 29 | ): 30 | print("Error: extra_pnginfo[0] is not a dict or missing 'workflow' key") 31 | else: 32 | workflow = extra_pnginfo[0]["workflow"] 33 | node = next( 34 | (x for x in workflow["nodes"] if str(x["id"]) == str(unique_id[0])), 35 | None, 36 | ) 37 | if node: 38 | node["widgets_values"] = [text] 39 | 40 | return {"ui": {"text": text}, "result": (text,)} 41 | 42 | 43 | NODE_CLASS_MAPPINGS = { 44 | "ShowText|pysssss": ShowText, 45 | } 46 | 47 | NODE_DISPLAY_NAME_MAPPINGS = { 48 | "ShowText|pysssss": "Show Text 🐍", 49 | } 50 | -------------------------------------------------------------------------------- /web/js/systemNotification.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | 3 | const notificationSetup = () => { 4 | if (!("Notification" in window)) { 5 | console.log("This browser does not support notifications."); 6 | alert("This browser does not support notifications."); 7 | return; 8 | } 9 | if (Notification.permission === "denied") { 10 | console.log("Notifications are blocked. Please enable them in your browser settings."); 11 | alert("Notifications are blocked. Please enable them in your browser settings."); 12 | return; 13 | } 14 | if (Notification.permission !== "granted") { 15 | Notification.requestPermission(); 16 | } 17 | return true; 18 | }; 19 | 20 | app.registerExtension({ 21 | name: "pysssss.SystemNotification", 22 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 23 | if (nodeData.name === "SystemNotification|pysssss") { 24 | const onExecuted = nodeType.prototype.onExecuted; 25 | nodeType.prototype.onExecuted = async function ({ message, mode }) { 26 | onExecuted?.apply(this, arguments); 27 | 28 | if (mode === "on empty queue") { 29 | if (app.ui.lastQueueSize !== 0) { 30 | await new Promise((r) => setTimeout(r, 500)); 31 | } 32 | if (app.ui.lastQueueSize !== 0) { 33 | return; 34 | } 35 | } 36 | if (!notificationSetup()) return; 37 | const notification = new Notification("ComfyUI", { body: message ?? "Your notification has triggered." }); 38 | }; 39 | 40 | const onNodeCreated = nodeType.prototype.onNodeCreated; 41 | nodeType.prototype.onNodeCreated = function () { 42 | onNodeCreated?.apply(this, arguments); 43 | notificationSetup(); 44 | }; 45 | } 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /web/js/kSamplerAdvDenoise.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | app.registerExtension({ 3 | name: "pysssss.KSamplerAdvDenoise", 4 | async beforeRegisterNodeDef(nodeType) { 5 | // Add menu options to conver to/from widgets 6 | const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; 7 | nodeType.prototype.getExtraMenuOptions = function (_, options) { 8 | const r = origGetExtraMenuOptions?.apply?.(this, arguments); 9 | 10 | let stepsWidget = null; 11 | let startAtWidget = null; 12 | let endAtWidget = null; 13 | for (const w of this.widgets || []) { 14 | if (w.name === "steps") { 15 | stepsWidget = w; 16 | } else if (w.name === "start_at_step") { 17 | startAtWidget = w; 18 | } else if (w.name === "end_at_step") { 19 | endAtWidget = w; 20 | } 21 | } 22 | 23 | if (stepsWidget && startAtWidget && endAtWidget) { 24 | options.push( 25 | { 26 | content: "Set Denoise", 27 | callback: () => { 28 | const steps = +prompt("How many steps do you want?", 15); 29 | if (isNaN(steps)) { 30 | return; 31 | } 32 | const denoise = +prompt("How much denoise? (0-1)", 0.5); 33 | if (isNaN(denoise)) { 34 | return; 35 | } 36 | 37 | stepsWidget.value = Math.floor(steps / Math.max(0, Math.min(1, denoise))); 38 | stepsWidget.callback?.(stepsWidget.value); 39 | 40 | startAtWidget.value = stepsWidget.value - steps; 41 | startAtWidget.callback?.(startAtWidget.value); 42 | 43 | endAtWidget.value = stepsWidget.value; 44 | endAtWidget.callback?.(endAtWidget.value); 45 | }, 46 | }, 47 | null 48 | ); 49 | } 50 | 51 | return r; 52 | }; 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /py/string_function.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | class StringFunction: 4 | @classmethod 5 | def INPUT_TYPES(s): 6 | return { 7 | "required": { 8 | "action": (["append", "replace"], {}), 9 | "tidy_tags": (["yes", "no"], {}), 10 | }, 11 | "optional": { 12 | "text_a": ("STRING", {"multiline": True, "dynamicPrompts": False}), 13 | "text_b": ("STRING", {"multiline": True, "dynamicPrompts": False}), 14 | "text_c": ("STRING", {"multiline": True, "dynamicPrompts": False}) 15 | } 16 | } 17 | 18 | RETURN_TYPES = ("STRING",) 19 | FUNCTION = "exec" 20 | CATEGORY = "utils" 21 | OUTPUT_NODE = True 22 | 23 | def exec(self, action, tidy_tags, text_a="", text_b="", text_c=""): 24 | tidy_tags = tidy_tags == "yes" 25 | out = "" 26 | if action == "append": 27 | out = (", " if tidy_tags else "").join(filter(None, [text_a, text_b, text_c])) 28 | else: 29 | if text_c is None: 30 | text_c = "" 31 | if text_b.startswith("/") and text_b.endswith("/"): 32 | regex = text_b[1:-1] 33 | out = re.sub(regex, text_c, text_a) 34 | else: 35 | out = text_a.replace(text_b, text_c) 36 | if tidy_tags: 37 | out = re.sub(r"\s{2,}", " ", out) 38 | out = out.replace(" ,", ",") 39 | out = re.sub(r",{2,}", ",", out) 40 | out = out.strip() 41 | return {"ui": {"text": (out,)}, "result": (out,)} 42 | 43 | NODE_CLASS_MAPPINGS = { 44 | "StringFunction|pysssss": StringFunction, 45 | } 46 | 47 | NODE_DISPLAY_NAME_MAPPINGS = { 48 | "StringFunction|pysssss": "String Function 🐍", 49 | } 50 | -------------------------------------------------------------------------------- /web/js/faviconStatus.js: -------------------------------------------------------------------------------- 1 | import { api } from "../../../scripts/api.js"; 2 | import { app } from "../../../scripts/app.js"; 3 | 4 | // Simple script that adds the current queue size to the window title 5 | // Adds a favicon that changes color while active 6 | 7 | app.registerExtension({ 8 | name: "pysssss.FaviconStatus", 9 | async setup() { 10 | let link = document.querySelector("link[rel~='icon']"); 11 | if (!link) { 12 | link = document.createElement("link"); 13 | link.rel = "icon"; 14 | document.head.appendChild(link); 15 | } 16 | 17 | const getUrl = (active, user) => new URL(`assets/favicon${active ? "-active" : ""}${user ? ".user" : ""}.ico`, import.meta.url); 18 | const testUrl = async (active) => { 19 | const url = getUrl(active, true); 20 | const r = await fetch(url, { 21 | method: "HEAD", 22 | }); 23 | if (r.status === 200) { 24 | return url; 25 | } 26 | return getUrl(active, false); 27 | }; 28 | const activeUrl = await testUrl(true); 29 | const idleUrl = await testUrl(false); 30 | 31 | let executing = false; 32 | const update = () => (link.href = executing ? activeUrl : idleUrl); 33 | 34 | for (const e of ["execution_start", "progress"]) { 35 | api.addEventListener(e, () => { 36 | executing = true; 37 | update(); 38 | }); 39 | } 40 | 41 | api.addEventListener("executing", ({ detail }) => { 42 | // null will be sent when it's finished 43 | executing = !!detail; 44 | update(); 45 | }); 46 | 47 | api.addEventListener("status", ({ detail }) => { 48 | let title = "ComfyUI"; 49 | if (detail && detail.exec_info.queue_remaining) { 50 | title = `(${detail.exec_info.queue_remaining}) ${title}`; 51 | } 52 | document.title = title; 53 | update(); 54 | executing = false; 55 | }); 56 | update(); 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /web/js/showImageOnMenu.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { api } from "../../../scripts/api.js"; 3 | import { $el } from "../../../scripts/ui.js"; 4 | 5 | const id = "pysssss.ShowImageOnMenu"; 6 | const ext = { 7 | name: id, 8 | async setup(app) { 9 | let enabled = true; 10 | let nodeId = null; 11 | const img = $el("img", { 12 | style: { 13 | width: "100%", 14 | height: "150px", 15 | objectFit: "contain", 16 | }, 17 | }); 18 | const link = $el( 19 | "a", 20 | { 21 | style: { 22 | width: "100%", 23 | height: "150px", 24 | marginTop: "10px", 25 | order: 100, // Place this item last (until someone else has a higher order) 26 | display: "none", 27 | }, 28 | href: "#", 29 | onclick: (e) => { 30 | e.stopPropagation(); 31 | e.preventDefault(); 32 | const node = app.graph.getNodeById(nodeId); 33 | if (!node) return; 34 | app.canvas.centerOnNode(node); 35 | app.canvas.setZoom(1); 36 | }, 37 | }, 38 | [img] 39 | ); 40 | 41 | app.ui.menuContainer.append(link); 42 | 43 | const show = (src, node) => { 44 | img.src = src; 45 | nodeId = Number(node); 46 | link.style.display = "unset"; 47 | }; 48 | 49 | api.addEventListener("executed", ({ detail }) => { 50 | if (!enabled) return; 51 | const images = detail?.output?.images; 52 | if (!images || !images.length) return; 53 | const format = app.getPreviewFormatParam(); 54 | const src = [ 55 | `./view?filename=${encodeURIComponent(images[0].filename)}`, 56 | `type=${images[0].type}`, 57 | `subfolder=${encodeURIComponent(images[0].subfolder)}`, 58 | `t=${+new Date()}${format}`,].join('&'); 59 | show(src, detail.node); 60 | }); 61 | 62 | api.addEventListener("b_preview", ({ detail }) => { 63 | if (!enabled) return; 64 | show(URL.createObjectURL(detail), app.runningNodeId); 65 | }); 66 | 67 | app.ui.settings.addSetting({ 68 | id, 69 | name: "🐍 Show Image On Menu", 70 | defaultValue: true, 71 | type: "boolean", 72 | onChange(value) { 73 | enabled = value; 74 | 75 | if (!enabled) link.style.display = "none"; 76 | }, 77 | }); 78 | }, 79 | }; 80 | 81 | app.registerExtension(ext); 82 | -------------------------------------------------------------------------------- /web/js/common/lightbox.css: -------------------------------------------------------------------------------- 1 | .pysssss-lightbox { 2 | width: 100vw; 3 | height: 100vh; 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | z-index: 1001; 8 | background: rgba(0, 0, 0, 0.6); 9 | display: flex; 10 | align-items: center; 11 | transition: opacity 0.2s; 12 | } 13 | 14 | .pysssss-lightbox-prev, 15 | .pysssss-lightbox-next { 16 | height: 60px; 17 | display: flex; 18 | align-items: center; 19 | } 20 | 21 | .pysssss-lightbox-prev:after, 22 | .pysssss-lightbox-next:after { 23 | border-style: solid; 24 | border-width: 0.25em 0.25em 0 0; 25 | display: inline-block; 26 | height: 0.45em; 27 | left: 0.15em; 28 | position: relative; 29 | top: 0.15em; 30 | transform: rotate(-135deg) scale(0.75); 31 | vertical-align: top; 32 | width: 0.45em; 33 | padding: 10px; 34 | font-size: 20px; 35 | margin: 0 10px 0 20px; 36 | transition: color 0.2s; 37 | flex-shrink: 0; 38 | content: ""; 39 | } 40 | 41 | .pysssss-lightbox-next:after { 42 | transform: rotate(45deg) scale(0.75); 43 | margin: 0 20px 0 0px; 44 | } 45 | 46 | .pysssss-lightbox-main { 47 | display: grid; 48 | flex: auto; 49 | place-content: center; 50 | text-align: center; 51 | } 52 | 53 | .pysssss-lightbox-link { 54 | display: flex; 55 | justify-content: center; 56 | align-items: center; 57 | position: relative; 58 | } 59 | 60 | .pysssss-lightbox .lds-ring { 61 | position: absolute; 62 | left: 50%; 63 | top: 50%; 64 | transform: translate(-50%, -50%); 65 | } 66 | 67 | .pysssss-lightbox-img { 68 | max-height: 90vh; 69 | max-width: calc(100vw - 130px); 70 | height: auto; 71 | object-fit: contain; 72 | border: 3px solid white; 73 | border-radius: 4px; 74 | transition: opacity 0.2s; 75 | user-select: none; 76 | } 77 | 78 | .pysssss-lightbox-img:hover { 79 | border-color: dodgerblue; 80 | } 81 | 82 | .pysssss-lightbox-close { 83 | font-size: 80px; 84 | line-height: 1ch; 85 | height: 1ch; 86 | width: 1ch; 87 | position: absolute; 88 | right: 10px; 89 | top: 10px; 90 | padding: 5px; 91 | } 92 | 93 | .pysssss-lightbox-close:after { 94 | content: "\00d7"; 95 | } 96 | 97 | .pysssss-lightbox-close:hover, 98 | .pysssss-lightbox-prev:hover, 99 | .pysssss-lightbox-next:hover { 100 | color: dodgerblue; 101 | cursor: pointer; 102 | } 103 | -------------------------------------------------------------------------------- /py/workflows.py: -------------------------------------------------------------------------------- 1 | from server import PromptServer 2 | from aiohttp import web 3 | import os 4 | import inspect 5 | import json 6 | import importlib 7 | import sys 8 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 9 | import pysssss 10 | 11 | root_directory = os.path.dirname(inspect.getfile(PromptServer)) 12 | workflows_directory = os.path.join(root_directory, "pysssss-workflows") 13 | workflows_directory = pysssss.get_config_value( 14 | "workflows.directory", workflows_directory) 15 | if not os.path.isabs(workflows_directory): 16 | workflows_directory = os.path.abspath(os.path.join(root_directory, workflows_directory)) 17 | 18 | NODE_CLASS_MAPPINGS = {} 19 | NODE_DISPLAY_NAME_MAPPINGS = {} 20 | 21 | 22 | @PromptServer.instance.routes.get("/pysssss/workflows") 23 | async def get_workflows(request): 24 | files = [] 25 | for dirpath, directories, file in os.walk(workflows_directory): 26 | for file in file: 27 | if (file.endswith(".json")): 28 | files.append(os.path.relpath(os.path.join( 29 | dirpath, file), workflows_directory)) 30 | return web.json_response(list(map(lambda f: os.path.splitext(f)[0].replace("\\", "/"), files))) 31 | 32 | 33 | @PromptServer.instance.routes.get("/pysssss/workflows/{name:.+}") 34 | async def get_workflow(request): 35 | file = os.path.abspath(os.path.join( 36 | workflows_directory, request.match_info["name"] + ".json")) 37 | if os.path.commonpath([file, workflows_directory]) != workflows_directory: 38 | return web.Response(status=403) 39 | 40 | return web.FileResponse(file) 41 | 42 | 43 | @PromptServer.instance.routes.post("/pysssss/workflows") 44 | async def save_workflow(request): 45 | json_data = await request.json() 46 | file = os.path.abspath(os.path.join( 47 | workflows_directory, json_data["name"] + ".json")) 48 | if os.path.commonpath([file, workflows_directory]) != workflows_directory: 49 | return web.Response(status=403) 50 | 51 | if os.path.exists(file) and ("overwrite" not in json_data or json_data["overwrite"] == False): 52 | return web.Response(status=409) 53 | 54 | sub_path = os.path.dirname(file) 55 | if not os.path.exists(sub_path): 56 | os.makedirs(sub_path) 57 | 58 | with open(file, "w") as f: 59 | f.write(json.dumps(json_data["workflow"])) 60 | 61 | return web.Response(status=201) 62 | -------------------------------------------------------------------------------- /web/js/nodeFinder.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { api } from "../../../scripts/api.js"; 3 | 4 | // Adds a menu option to toggle follow the executing node 5 | // Adds a menu option to go to the currently executing node 6 | // Adds a menu option to go to a node by type 7 | 8 | app.registerExtension({ 9 | name: "pysssss.NodeFinder", 10 | setup() { 11 | let followExecution = false; 12 | 13 | const centerNode = (id) => { 14 | if (!followExecution || !id) return; 15 | const node = app.graph.getNodeById(id); 16 | if (!node) return; 17 | app.canvas.centerOnNode(node); 18 | }; 19 | 20 | api.addEventListener("executing", ({ detail }) => centerNode(detail)); 21 | 22 | // Add canvas menu options 23 | const orig = LGraphCanvas.prototype.getCanvasMenuOptions; 24 | LGraphCanvas.prototype.getCanvasMenuOptions = function () { 25 | const options = orig.apply(this, arguments); 26 | options.push(null, { 27 | content: followExecution ? "Stop following execution" : "Follow execution", 28 | callback: () => { 29 | if ((followExecution = !followExecution)) { 30 | centerNode(app.runningNodeId); 31 | } 32 | }, 33 | }); 34 | if (app.runningNodeId) { 35 | options.push({ 36 | content: "Show executing node", 37 | callback: () => { 38 | const node = app.graph.getNodeById(app.runningNodeId); 39 | if (!node) return; 40 | app.canvas.centerOnNode(node); 41 | }, 42 | }); 43 | } 44 | 45 | const nodes = app.graph._nodes; 46 | const types = nodes.reduce((p, n) => { 47 | if (n.type in p) { 48 | p[n.type].push(n); 49 | } else { 50 | p[n.type] = [n]; 51 | } 52 | return p; 53 | }, {}); 54 | options.push({ 55 | content: "Go to node", 56 | has_submenu: true, 57 | submenu: { 58 | options: Object.keys(types) 59 | .sort() 60 | .map((t) => ({ 61 | content: t, 62 | has_submenu: true, 63 | submenu: { 64 | options: types[t] 65 | .sort((a, b) => { 66 | return a.pos[0] - b.pos[0]; 67 | }) 68 | .map((n) => ({ 69 | content: `${n.getTitle()} - #${n.id} (${n.pos[0]}, ${n.pos[1]})`, 70 | callback: () => { 71 | app.canvas.centerOnNode(n); 72 | }, 73 | })), 74 | }, 75 | })), 76 | }, 77 | }); 78 | 79 | return options; 80 | }; 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /web/js/snapToGrid.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | 3 | let setting; 4 | const id = "pysssss.SnapToGrid"; 5 | 6 | /** Wraps the provided function call to set/reset shiftDown when setting is enabled. */ 7 | function wrapCallInSettingCheck(fn) { 8 | if (setting?.value) { 9 | const shift = app.shiftDown; 10 | app.shiftDown = true; 11 | const r = fn(); 12 | app.shiftDown = shift; 13 | return r; 14 | } 15 | return fn(); 16 | } 17 | 18 | const ext = { 19 | name: id, 20 | init() { 21 | setting = app.ui.settings.addSetting({ 22 | id, 23 | name: "🐍 Always snap to grid", 24 | defaultValue: false, 25 | type: "boolean", 26 | onChange(value) { 27 | app.canvas.align_to_grid = value; 28 | }, 29 | }); 30 | 31 | // We need to register our hooks after the core snap to grid extension runs 32 | // Do this from the graph configure function so we still get onNodeAdded calls 33 | const configure = LGraph.prototype.configure; 34 | LGraph.prototype.configure = function () { 35 | // Override drawNode to draw the drop position 36 | const drawNode = LGraphCanvas.prototype.drawNode; 37 | LGraphCanvas.prototype.drawNode = function () { 38 | wrapCallInSettingCheck(() => drawNode.apply(this, arguments)); 39 | }; 40 | 41 | // Override node added to add a resize handler to force grid alignment 42 | const onNodeAdded = app.graph.onNodeAdded; 43 | app.graph.onNodeAdded = function (node) { 44 | const r = onNodeAdded?.apply(this, arguments); 45 | const onResize = node.onResize; 46 | node.onResize = function () { 47 | wrapCallInSettingCheck(() => onResize?.apply(this, arguments)); 48 | }; 49 | return r; 50 | }; 51 | 52 | 53 | const groupMove = LGraphGroup.prototype.move; 54 | LGraphGroup.prototype.move = function(deltax, deltay, ignore_nodes) { 55 | wrapCallInSettingCheck(() => groupMove.apply(this, arguments)); 56 | } 57 | 58 | const canvasDrawGroups = LGraphCanvas.prototype.drawGroups; 59 | LGraphCanvas.prototype.drawGroups = function (canvas, ctx) { 60 | wrapCallInSettingCheck(() => canvasDrawGroups.apply(this, arguments)); 61 | } 62 | 63 | const canvasOnGroupAdd = LGraphCanvas.onGroupAdd; 64 | LGraphCanvas.onGroupAdd = function() { 65 | wrapCallInSettingCheck(() => canvasOnGroupAdd.apply(this, arguments)); 66 | } 67 | 68 | return configure.apply(this, arguments); 69 | }; 70 | }, 71 | }; 72 | 73 | app.registerExtension(ext); 74 | -------------------------------------------------------------------------------- /web/js/common/modelInfoDialog.css: -------------------------------------------------------------------------------- 1 | .pysssss-model-info { 2 | color: white; 3 | font-family: sans-serif; 4 | max-width: 90vw; 5 | } 6 | .pysssss-model-content { 7 | display: flex; 8 | flex-direction: column; 9 | overflow: hidden; 10 | } 11 | .pysssss-model-info h2 { 12 | text-align: center; 13 | margin: 0 0 10px 0; 14 | } 15 | .pysssss-model-info p { 16 | margin: 5px 0; 17 | } 18 | .pysssss-model-info a { 19 | color: dodgerblue; 20 | } 21 | .pysssss-model-info a:hover { 22 | text-decoration: underline; 23 | } 24 | .pysssss-model-tags-list { 25 | display: flex; 26 | flex-wrap: wrap; 27 | list-style: none; 28 | gap: 10px; 29 | max-height: 200px; 30 | overflow: auto; 31 | margin: 10px 0; 32 | padding: 0; 33 | } 34 | .pysssss-model-tag { 35 | background-color: rgb(128, 213, 247); 36 | color: #000; 37 | display: flex; 38 | align-items: center; 39 | gap: 5px; 40 | border-radius: 5px; 41 | padding: 2px 5px; 42 | cursor: pointer; 43 | } 44 | .pysssss-model-tag--selected span::before { 45 | content: "✅"; 46 | position: absolute; 47 | background-color: dodgerblue; 48 | left: 0; 49 | top: 0; 50 | right: 0; 51 | bottom: 0; 52 | text-align: center; 53 | } 54 | .pysssss-model-tag:hover { 55 | outline: 2px solid dodgerblue; 56 | } 57 | .pysssss-model-tag p { 58 | margin: 0; 59 | } 60 | .pysssss-model-tag span { 61 | text-align: center; 62 | border-radius: 5px; 63 | background-color: dodgerblue; 64 | color: #fff; 65 | padding: 2px; 66 | position: relative; 67 | min-width: 20px; 68 | overflow: hidden; 69 | } 70 | 71 | .pysssss-model-metadata .comfy-modal-content { 72 | max-width: 100%; 73 | } 74 | .pysssss-model-metadata label { 75 | margin-right: 1ch; 76 | color: #ccc; 77 | } 78 | 79 | .pysssss-model-metadata span { 80 | color: dodgerblue; 81 | } 82 | 83 | .pysssss-preview { 84 | max-width: 50%; 85 | margin-left: 10px; 86 | position: relative; 87 | } 88 | .pysssss-preview img { 89 | max-height: 300px; 90 | } 91 | .pysssss-preview button { 92 | position: absolute; 93 | font-size: 12px; 94 | bottom: 10px; 95 | right: 10px; 96 | } 97 | .pysssss-preview button+button { 98 | bottom: 34px; 99 | } 100 | 101 | .pysssss-preview button.pysssss-preview-nav { 102 | bottom: unset; 103 | right: 30px; 104 | top: 10px; 105 | font-size: 14px; 106 | line-height: 14px; 107 | } 108 | 109 | .pysssss-preview button.pysssss-preview-nav+.pysssss-preview-nav { 110 | right: 10px; 111 | } 112 | .pysssss-model-notes { 113 | background-color: rgba(0, 0, 0, 0.25); 114 | padding: 5px; 115 | margin-top: 5px; 116 | } 117 | .pysssss-model-notes:empty { 118 | display: none; 119 | } 120 | -------------------------------------------------------------------------------- /web/js/showText.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { ComfyWidgets } from "../../../scripts/widgets.js"; 3 | 4 | // Displays input text on a node 5 | 6 | // TODO: This should need to be so complicated. Refactor at some point. 7 | 8 | app.registerExtension({ 9 | name: "pysssss.ShowText", 10 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 11 | if (nodeData.name === "ShowText|pysssss") { 12 | function populate(text) { 13 | if (this.widgets) { 14 | // On older frontend versions there is a hidden converted-widget 15 | const isConvertedWidget = +!!this.inputs?.[0].widget; 16 | for (let i = isConvertedWidget; i < this.widgets.length; i++) { 17 | this.widgets[i].onRemove?.(); 18 | } 19 | this.widgets.length = isConvertedWidget; 20 | } 21 | 22 | const v = [...text]; 23 | if (!v[0]) { 24 | v.shift(); 25 | } 26 | for (let list of v) { 27 | // Force list to be an array, not sure why sometimes it is/isn't 28 | if (!(list instanceof Array)) list = [list]; 29 | for (const l of list) { 30 | const w = ComfyWidgets["STRING"](this, "text_" + this.widgets?.length ?? 0, ["STRING", { multiline: true }], app).widget; 31 | w.inputEl.readOnly = true; 32 | w.inputEl.style.opacity = 0.6; 33 | w.value = l; 34 | } 35 | } 36 | 37 | requestAnimationFrame(() => { 38 | const sz = this.computeSize(); 39 | if (sz[0] < this.size[0]) { 40 | sz[0] = this.size[0]; 41 | } 42 | if (sz[1] < this.size[1]) { 43 | sz[1] = this.size[1]; 44 | } 45 | this.onResize?.(sz); 46 | app.graph.setDirtyCanvas(true, false); 47 | }); 48 | } 49 | 50 | // When the node is executed we will be sent the input text, display this in the widget 51 | const onExecuted = nodeType.prototype.onExecuted; 52 | nodeType.prototype.onExecuted = function (message) { 53 | onExecuted?.apply(this, arguments); 54 | populate.call(this, message.text); 55 | }; 56 | 57 | const VALUES = Symbol(); 58 | const configure = nodeType.prototype.configure; 59 | nodeType.prototype.configure = function () { 60 | // Store unmodified widget values as they get removed on configure by new frontend 61 | this[VALUES] = arguments[0]?.widgets_values; 62 | return configure?.apply(this, arguments); 63 | }; 64 | 65 | const onConfigure = nodeType.prototype.onConfigure; 66 | nodeType.prototype.onConfigure = function () { 67 | onConfigure?.apply(this, arguments); 68 | const widgets_values = this[VALUES]; 69 | if (widgets_values?.length) { 70 | // In newer frontend there seems to be a delay in creating the initial widget 71 | requestAnimationFrame(() => { 72 | populate.call(this, widgets_values.slice(+(widgets_values.length > 1 && this.inputs?.[0].widget))); 73 | }); 74 | } 75 | }; 76 | } 77 | }, 78 | }); 79 | -------------------------------------------------------------------------------- /web/js/contextMenuHook.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | app.registerExtension({ 3 | name: "pysssss.ContextMenuHook", 4 | init() { 5 | const getOrSet = (target, name, create) => { 6 | if (name in target) return target[name]; 7 | return (target[name] = create()); 8 | }; 9 | const symbol = getOrSet(window, "__pysssss__", () => Symbol("__pysssss__")); 10 | const store = getOrSet(window, symbol, () => ({})); 11 | const contextMenuHook = getOrSet(store, "contextMenuHook", () => ({})); 12 | for (const e of ["ctor", "preAddItem", "addItem"]) { 13 | if (!contextMenuHook[e]) { 14 | contextMenuHook[e] = []; 15 | } 16 | } 17 | 18 | // Big ol' hack to get allow customizing the context menu 19 | // Replace the addItem function with our own that wraps the context of "this" with a proxy 20 | // That proxy then replaces the constructor with another proxy 21 | // That proxy then calls the custom ContextMenu that supports filters 22 | const ctorProxy = new Proxy(LiteGraph.ContextMenu, { 23 | construct(target, args) { 24 | return new LiteGraph.ContextMenu(...args); 25 | }, 26 | }); 27 | 28 | function triggerCallbacks(name, getArgs, handler) { 29 | const callbacks = contextMenuHook[name]; 30 | if (callbacks && callbacks instanceof Array) { 31 | for (const cb of callbacks) { 32 | const r = cb(...getArgs()); 33 | handler?.call(this, r); 34 | } 35 | } else { 36 | console.warn("[pysssss 🐍]", `invalid ${name} callbacks`, callbacks, name in contextMenuHook); 37 | } 38 | } 39 | 40 | const addItem = LiteGraph.ContextMenu.prototype.addItem; 41 | LiteGraph.ContextMenu.prototype.addItem = function () { 42 | const proxy = new Proxy(this, { 43 | get(target, prop) { 44 | if (prop === "constructor") { 45 | return ctorProxy; 46 | } 47 | return target[prop]; 48 | }, 49 | }); 50 | proxy.__target__ = this; 51 | 52 | let el; 53 | let args = arguments; 54 | triggerCallbacks( 55 | "preAddItem", 56 | () => [el, this, args], 57 | (r) => { 58 | if (r !== undefined) el = r; 59 | } 60 | ); 61 | 62 | if (el === undefined) { 63 | el = addItem.apply(proxy, arguments); 64 | } 65 | 66 | triggerCallbacks( 67 | "addItem", 68 | () => [el, this, args], 69 | (r) => { 70 | if (r !== undefined) el = r; 71 | } 72 | ); 73 | return el; 74 | }; 75 | 76 | // We also need to patch the ContextMenu constructor to unwrap the parent else it fails a LiteGraph type check 77 | const ctxMenu = LiteGraph.ContextMenu; 78 | LiteGraph.ContextMenu = function (values, options) { 79 | if (options?.parentMenu) { 80 | if (options.parentMenu.__target__) { 81 | options.parentMenu = options.parentMenu.__target__; 82 | } 83 | } 84 | 85 | triggerCallbacks("ctor", () => [values, options]); 86 | return ctxMenu.call(this, values, options); 87 | }; 88 | LiteGraph.ContextMenu.prototype = ctxMenu.prototype; 89 | }, 90 | }); 91 | -------------------------------------------------------------------------------- /py/constrain_image.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import numpy as np 3 | from PIL import Image 4 | 5 | class ConstrainImage: 6 | """ 7 | A node that constrains an image to a maximum and minimum size while maintaining aspect ratio. 8 | """ 9 | 10 | @classmethod 11 | def INPUT_TYPES(cls): 12 | return { 13 | "required": { 14 | "images": ("IMAGE",), 15 | "max_width": ("INT", {"default": 1024, "min": 0}), 16 | "max_height": ("INT", {"default": 1024, "min": 0}), 17 | "min_width": ("INT", {"default": 0, "min": 0}), 18 | "min_height": ("INT", {"default": 0, "min": 0}), 19 | "crop_if_required": (["yes", "no"], {"default": "no"}), 20 | }, 21 | } 22 | 23 | RETURN_TYPES = ("IMAGE",) 24 | FUNCTION = "constrain_image" 25 | CATEGORY = "image" 26 | OUTPUT_IS_LIST = (True,) 27 | 28 | def constrain_image(self, images, max_width, max_height, min_width, min_height, crop_if_required): 29 | crop_if_required = crop_if_required == "yes" 30 | results = [] 31 | for image in images: 32 | i = 255. * image.cpu().numpy() 33 | img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)).convert("RGB") 34 | 35 | current_width, current_height = img.size 36 | aspect_ratio = current_width / current_height 37 | 38 | constrained_width = min(max(current_width, min_width), max_width) 39 | constrained_height = min(max(current_height, min_height), max_height) 40 | 41 | if constrained_width / constrained_height > aspect_ratio: 42 | constrained_width = max(int(constrained_height * aspect_ratio), min_width) 43 | if crop_if_required: 44 | constrained_height = int(current_height / (current_width / constrained_width)) 45 | else: 46 | constrained_height = max(int(constrained_width / aspect_ratio), min_height) 47 | if crop_if_required: 48 | constrained_width = int(current_width / (current_height / constrained_height)) 49 | 50 | resized_image = img.resize((constrained_width, constrained_height), Image.LANCZOS) 51 | 52 | if crop_if_required and (constrained_width > max_width or constrained_height > max_height): 53 | left = max((constrained_width - max_width) // 2, 0) 54 | top = max((constrained_height - max_height) // 2, 0) 55 | right = min(constrained_width, max_width) + left 56 | bottom = min(constrained_height, max_height) + top 57 | resized_image = resized_image.crop((left, top, right, bottom)) 58 | 59 | resized_image = np.array(resized_image).astype(np.float32) / 255.0 60 | resized_image = torch.from_numpy(resized_image)[None,] 61 | results.append(resized_image) 62 | 63 | return (results,) 64 | 65 | NODE_CLASS_MAPPINGS = { 66 | "ConstrainImage|pysssss": ConstrainImage, 67 | } 68 | 69 | NODE_DISPLAY_NAME_MAPPINGS = { 70 | "ConstrainImage|pysssss": "Constrain Image 🐍", 71 | } 72 | -------------------------------------------------------------------------------- /web/js/graphArrange.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | 3 | app.registerExtension({ 4 | name: "pysssss.GraphArrange", 5 | setup(app) { 6 | const orig = LGraphCanvas.prototype.getCanvasMenuOptions; 7 | LGraphCanvas.prototype.getCanvasMenuOptions = function () { 8 | const options = orig.apply(this, arguments); 9 | options.push({ content: "Arrange (float left)", callback: () => graph.arrange() }); 10 | options.push({ 11 | content: "Arrange (float right)", 12 | callback: () => { 13 | (function () { 14 | var margin = 50; 15 | var layout; 16 | 17 | const nodes = this.computeExecutionOrder(false, true); 18 | const columns = []; 19 | 20 | // Find node first use 21 | for (let i = nodes.length - 1; i >= 0; i--) { 22 | const node = nodes[i]; 23 | let max = null; 24 | for (const out of node.outputs || []) { 25 | if (out.links) { 26 | for (const link of out.links) { 27 | const outNode = app.graph.getNodeById(app.graph.links[link].target_id); 28 | if (!outNode) continue; 29 | var l = outNode._level - 1; 30 | if (max === null) max = l; 31 | else if (l < max) max = l; 32 | } 33 | } 34 | } 35 | if (max != null) node._level = max; 36 | } 37 | 38 | for (let i = 0; i < nodes.length; ++i) { 39 | const node = nodes[i]; 40 | const col = node._level || 1; 41 | if (!columns[col]) { 42 | columns[col] = []; 43 | } 44 | columns[col].push(node); 45 | } 46 | 47 | let x = margin; 48 | 49 | for (let i = 0; i < columns.length; ++i) { 50 | const column = columns[i]; 51 | if (!column) { 52 | continue; 53 | } 54 | column.sort((a, b) => { 55 | var as = !(a.type === "SaveImage" || a.type === "PreviewImage"); 56 | var bs = !(b.type === "SaveImage" || b.type === "PreviewImage"); 57 | var r = as - bs; 58 | if (r === 0) r = (a.inputs?.length || 0) - (b.inputs?.length || 0); 59 | if (r === 0) r = (a.outputs?.length || 0) - (b.outputs?.length || 0); 60 | return r; 61 | }); 62 | let max_size = 100; 63 | let y = margin + LiteGraph.NODE_TITLE_HEIGHT; 64 | for (let j = 0; j < column.length; ++j) { 65 | const node = column[j]; 66 | node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x; 67 | node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y; 68 | const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0; 69 | if (node.size[max_size_index] > max_size) { 70 | max_size = node.size[max_size_index]; 71 | } 72 | const node_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 0 : 1; 73 | y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT + j; 74 | } 75 | 76 | // Right align in column 77 | for (let j = 0; j < column.length; ++j) { 78 | const node = column[j]; 79 | node.pos[0] += max_size - node.size[0]; 80 | } 81 | x += max_size + margin; 82 | } 83 | 84 | this.setDirtyCanvas(true, true); 85 | }).apply(app.graph); 86 | }, 87 | }); 88 | return options; 89 | }; 90 | }, 91 | }); 92 | -------------------------------------------------------------------------------- /py/constrain_image_for_video.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import numpy as np 3 | from PIL import Image 4 | 5 | class ConstrainImageforVideo: 6 | """ 7 | A node that constrains an image to a maximum and minimum size while maintaining aspect ratio. 8 | """ 9 | 10 | @classmethod 11 | def INPUT_TYPES(cls): 12 | return { 13 | "required": { 14 | "images": ("IMAGE",), 15 | "max_width": ("INT", {"default": 1024, "min": 0}), 16 | "max_height": ("INT", {"default": 1024, "min": 0}), 17 | "min_width": ("INT", {"default": 0, "min": 0}), 18 | "min_height": ("INT", {"default": 0, "min": 0}), 19 | "crop_if_required": (["yes", "no"], {"default": "no"}), 20 | }, 21 | } 22 | 23 | RETURN_TYPES = ("IMAGE",) 24 | RETURN_NAMES = ("IMAGE",) 25 | FUNCTION = "constrain_image_for_video" 26 | CATEGORY = "image" 27 | 28 | def constrain_image_for_video(self, images, max_width, max_height, min_width, min_height, crop_if_required): 29 | crop_if_required = crop_if_required == "yes" 30 | results = [] 31 | for image in images: 32 | i = 255. * image.cpu().numpy() 33 | img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)).convert("RGB") 34 | 35 | current_width, current_height = img.size 36 | aspect_ratio = current_width / current_height 37 | 38 | constrained_width = max(min(current_width, min_width), max_width) 39 | constrained_height = max(min(current_height, min_height), max_height) 40 | 41 | if constrained_width / constrained_height > aspect_ratio: 42 | constrained_width = max(int(constrained_height * aspect_ratio), min_width) 43 | if crop_if_required: 44 | constrained_height = int(current_height / (current_width / constrained_width)) 45 | else: 46 | constrained_height = max(int(constrained_width / aspect_ratio), min_height) 47 | if crop_if_required: 48 | constrained_width = int(current_width / (current_height / constrained_height)) 49 | 50 | resized_image = img.resize((constrained_width, constrained_height), Image.LANCZOS) 51 | 52 | if crop_if_required and (constrained_width > max_width or constrained_height > max_height): 53 | left = max((constrained_width - max_width) // 2, 0) 54 | top = max((constrained_height - max_height) // 2, 0) 55 | right = min(constrained_width, max_width) + left 56 | bottom = min(constrained_height, max_height) + top 57 | resized_image = resized_image.crop((left, top, right, bottom)) 58 | 59 | resized_image = np.array(resized_image).astype(np.float32) / 255.0 60 | resized_image = torch.from_numpy(resized_image)[None,] 61 | results.append(resized_image) 62 | all_images = torch.cat(results, dim=0) 63 | 64 | return (all_images, all_images.size(0),) 65 | 66 | NODE_CLASS_MAPPINGS = { 67 | "ConstrainImageforVideo|pysssss": ConstrainImageforVideo, 68 | } 69 | 70 | NODE_DISPLAY_NAME_MAPPINGS = { 71 | "ConstrainImageforVideo|pysssss": "Constrain Image for Video 🐍", 72 | } 73 | -------------------------------------------------------------------------------- /web/js/customColors.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { $el } from "../../../scripts/ui.js"; 3 | 4 | const colorShade = (col, amt) => { 5 | col = col.replace(/^#/, ""); 6 | if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2]; 7 | 8 | let [r, g, b] = col.match(/.{2}/g); 9 | [r, g, b] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt]; 10 | 11 | r = Math.max(Math.min(255, r), 0).toString(16); 12 | g = Math.max(Math.min(255, g), 0).toString(16); 13 | b = Math.max(Math.min(255, b), 0).toString(16); 14 | 15 | const rr = (r.length < 2 ? "0" : "") + r; 16 | const gg = (g.length < 2 ? "0" : "") + g; 17 | const bb = (b.length < 2 ? "0" : "") + b; 18 | 19 | return `#${rr}${gg}${bb}`; 20 | }; 21 | 22 | app.registerExtension({ 23 | name: "pysssss.CustomColors", 24 | setup() { 25 | let picker; 26 | let activeNode; 27 | const onMenuNodeColors = LGraphCanvas.onMenuNodeColors; 28 | LGraphCanvas.onMenuNodeColors = function (value, options, e, menu, node) { 29 | const r = onMenuNodeColors.apply(this, arguments); 30 | requestAnimationFrame(() => { 31 | const menus = document.querySelectorAll(".litecontextmenu"); 32 | for (let i = menus.length - 1; i >= 0; i--) { 33 | if (menus[i].firstElementChild.textContent.includes("No color") || menus[i].firstElementChild.value?.content?.includes("No color")) { 34 | $el( 35 | "div.litemenu-entry.submenu", 36 | { 37 | parent: menus[i], 38 | $: (el) => { 39 | el.onclick = () => { 40 | LiteGraph.closeAllContextMenus(); 41 | if (!picker) { 42 | picker = $el("input", { 43 | type: "color", 44 | parent: document.body, 45 | style: { 46 | display: "none", 47 | }, 48 | }); 49 | picker.onchange = () => { 50 | if (activeNode) { 51 | const fApplyColor = function(node){ 52 | if (picker.value) { 53 | if (node.constructor === LiteGraph.LGraphGroup) { 54 | node.color = picker.value; 55 | } else { 56 | node.color = colorShade(picker.value, 20); 57 | node.bgcolor = picker.value; 58 | } 59 | } 60 | } 61 | const graphcanvas = LGraphCanvas.active_canvas; 62 | if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1){ 63 | fApplyColor(activeNode); 64 | } else { 65 | for (let i in graphcanvas.selected_nodes) { 66 | fApplyColor(graphcanvas.selected_nodes[i]); 67 | } 68 | } 69 | 70 | activeNode.setDirtyCanvas(true, true); 71 | } 72 | }; 73 | } 74 | activeNode = null; 75 | picker.value = node.bgcolor; 76 | activeNode = node; 77 | picker.click(); 78 | }; 79 | }, 80 | }, 81 | [ 82 | $el("span", { 83 | style: { 84 | paddingLeft: "4px", 85 | display: "block", 86 | }, 87 | textContent: "🎨 Custom", 88 | }), 89 | ] 90 | ); 91 | break; 92 | } 93 | } 94 | }); 95 | return r; 96 | }; 97 | }, 98 | }); 99 | -------------------------------------------------------------------------------- /py/model_info.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | from aiohttp import web 4 | from server import PromptServer 5 | import folder_paths 6 | import os 7 | 8 | 9 | def get_metadata(filepath): 10 | with open(filepath, "rb") as file: 11 | # https://github.com/huggingface/safetensors#format 12 | # 8 bytes: N, an unsigned little-endian 64-bit integer, containing the size of the header 13 | header_size = int.from_bytes(file.read(8), "little", signed=False) 14 | 15 | if header_size <= 0: 16 | raise BufferError("Invalid header size") 17 | 18 | header = file.read(header_size) 19 | if header_size <= 0: 20 | raise BufferError("Invalid header") 21 | 22 | header_json = json.loads(header) 23 | return header_json["__metadata__"] if "__metadata__" in header_json else None 24 | 25 | 26 | @PromptServer.instance.routes.post("/pysssss/metadata/notes/{name}") 27 | async def save_notes(request): 28 | name = request.match_info["name"] 29 | pos = name.index("/") 30 | type = name[0:pos] 31 | name = name[pos+1:] 32 | 33 | file_path = None 34 | if type == "embeddings" or type == "loras": 35 | name = name.lower() 36 | files = folder_paths.get_filename_list(type) 37 | for f in files: 38 | lower_f = f.lower() 39 | if lower_f == name: 40 | file_path = folder_paths.get_full_path(type, f) 41 | else: 42 | n = os.path.splitext(f)[0].lower() 43 | if n == name: 44 | file_path = folder_paths.get_full_path(type, f) 45 | 46 | if file_path is not None: 47 | break 48 | else: 49 | file_path = folder_paths.get_full_path( 50 | type, name) 51 | if not file_path: 52 | return web.Response(status=404) 53 | 54 | file_no_ext = os.path.splitext(file_path)[0] 55 | info_file = file_no_ext + ".txt" 56 | with open(info_file, "w") as f: 57 | f.write(await request.text()) 58 | 59 | return web.Response(status=200) 60 | 61 | 62 | @PromptServer.instance.routes.get("/pysssss/metadata/{name}") 63 | async def load_metadata(request): 64 | name = request.match_info["name"] 65 | pos = name.index("/") 66 | type = name[0:pos] 67 | name = name[pos+1:] 68 | 69 | file_path = None 70 | if type == "embeddings" or type == "loras": 71 | name = name.lower() 72 | files = folder_paths.get_filename_list(type) 73 | for f in files: 74 | lower_f = f.lower() 75 | if lower_f == name: 76 | file_path = folder_paths.get_full_path(type, f) 77 | else: 78 | n = os.path.splitext(f)[0].lower() 79 | if n == name: 80 | file_path = folder_paths.get_full_path(type, f) 81 | 82 | if file_path is not None: 83 | break 84 | else: 85 | file_path = folder_paths.get_full_path( 86 | type, name) 87 | if not file_path: 88 | return web.Response(status=404) 89 | 90 | try: 91 | meta = get_metadata(file_path) 92 | except: 93 | meta = None 94 | 95 | if meta is None: 96 | meta = {} 97 | 98 | file_no_ext = os.path.splitext(file_path)[0] 99 | 100 | info_file = file_no_ext + ".txt" 101 | if os.path.isfile(info_file): 102 | with open(info_file, "r") as f: 103 | meta["pysssss.notes"] = f.read() 104 | 105 | hash_file = file_no_ext + ".sha256" 106 | if os.path.isfile(hash_file): 107 | with open(hash_file, "rt") as f: 108 | meta["pysssss.sha256"] = f.read() 109 | else: 110 | with open(file_path, "rb") as f: 111 | meta["pysssss.sha256"] = hashlib.sha256(f.read()).hexdigest() 112 | with open(hash_file, "wt") as f: 113 | f.write(meta["pysssss.sha256"]) 114 | 115 | return web.json_response(meta) 116 | -------------------------------------------------------------------------------- /web/js/repeater.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | 3 | const REPEATER = "Repeater|pysssss"; 4 | 5 | app.registerExtension({ 6 | name: "pysssss.Repeater", 7 | init() { 8 | const graphToPrompt = app.graphToPrompt; 9 | app.graphToPrompt = async function () { 10 | const res = await graphToPrompt.apply(this, arguments); 11 | 12 | const id = Date.now() + "_"; 13 | let u = 0; 14 | 15 | let newNodes = {}; 16 | const newRepeaters = {}; 17 | for (const nodeId in res.output) { 18 | let output = res.output[nodeId]; 19 | if (output.class_type === REPEATER) { 20 | const isMulti = output.inputs.output === "multi"; 21 | if (output.inputs.node_mode === "create") { 22 | // We need to clone the input for every repeat 23 | const orig = res.output[output.inputs.source[0]]; 24 | if (isMulti) { 25 | if (!newRepeaters[nodeId]) { 26 | newRepeaters[nodeId] = []; 27 | newRepeaters[nodeId][output.inputs.repeats - 1] = nodeId; 28 | } 29 | } 30 | for (let i = 0; i < output.inputs.repeats - 1; i++) { 31 | const clonedInputId = id + ++u; 32 | 33 | if (isMulti) { 34 | // If multi create we need to clone the repeater too 35 | newNodes[clonedInputId] = structuredClone(orig); 36 | 37 | output = structuredClone(output); 38 | 39 | const clonedRepeaterId = id + ++u; 40 | newNodes[clonedRepeaterId] = output; 41 | output.inputs["source"][0] = clonedInputId; 42 | 43 | newRepeaters[nodeId][i] = clonedRepeaterId; 44 | } else { 45 | newNodes[clonedInputId] = orig; 46 | } 47 | output.inputs[clonedInputId] = [clonedInputId, output.inputs.source[1]]; 48 | } 49 | } else if (isMulti) { 50 | newRepeaters[nodeId] = Array(output.inputs.repeats).fill(nodeId); 51 | } 52 | } 53 | } 54 | 55 | Object.assign(res.output, newNodes); 56 | newNodes = {}; 57 | 58 | for (const nodeId in res.output) { 59 | const output = res.output[nodeId]; 60 | for (const k in output.inputs) { 61 | const v = output.inputs[k]; 62 | if (v instanceof Array) { 63 | const repeaterId = v[0]; 64 | const source = newRepeaters[repeaterId]; 65 | if (source) { 66 | v[0] = source.pop(); 67 | v[1] = 0; 68 | } 69 | } 70 | } 71 | } 72 | 73 | // Object.assign(res.output, newNodes); 74 | 75 | return res; 76 | }; 77 | }, 78 | beforeRegisterNodeDef(nodeType, nodeData, app) { 79 | if (nodeData.name === REPEATER) { 80 | const SETUP_OUTPUTS = Symbol(); 81 | nodeType.prototype[SETUP_OUTPUTS] = function (repeats) { 82 | if (repeats == null) { 83 | repeats = this.widgets[0].value; 84 | } 85 | while (this.outputs.length > repeats) { 86 | this.removeOutput(repeats); 87 | } 88 | const id = Date.now() + "_"; 89 | let u = 0; 90 | while (this.outputs.length < repeats) { 91 | this.addOutput(id + ++u, "*", { label: "*" }); 92 | } 93 | }; 94 | 95 | const onAdded = nodeType.prototype.onAdded; 96 | nodeType.prototype.onAdded = function () { 97 | const self = this; 98 | const repeatsCb = this.widgets[0].callback; 99 | this.widgets[0].callback = async function () { 100 | const v = (await repeatsCb?.apply(this, arguments)) ?? this.value; 101 | if (self.widgets[1].value === "multi") { 102 | self[SETUP_OUTPUTS](v); 103 | } 104 | return v; 105 | }; 106 | 107 | const outputCb = this.widgets[1].callback; 108 | this.widgets[1].callback = async function () { 109 | const v = (await outputCb?.apply(this, arguments)) ?? this.value; 110 | if (v === "single") { 111 | self.outputs[0].shape = 6; 112 | self[SETUP_OUTPUTS](1); 113 | } else { 114 | delete self.outputs[0].shape; 115 | self[SETUP_OUTPUTS](); 116 | } 117 | return v; 118 | }; 119 | return onAdded?.apply(this, arguments); 120 | }; 121 | } 122 | }, 123 | }); 124 | -------------------------------------------------------------------------------- /web/js/common/lightbox.js: -------------------------------------------------------------------------------- 1 | import { $el } from "../../../../scripts/ui.js"; 2 | import { addStylesheet, getUrl, loadImage } from "./utils.js"; 3 | import { createSpinner } from "./spinner.js"; 4 | 5 | addStylesheet(getUrl("lightbox.css", import.meta.url)); 6 | 7 | const $$el = (tag, name, ...args) => { 8 | if (name) name = "-" + name; 9 | return $el(tag + ".pysssss-lightbox" + name, ...args); 10 | }; 11 | 12 | const ani = async (a, t, b) => { 13 | a(); 14 | await new Promise((r) => setTimeout(r, t)); 15 | b(); 16 | }; 17 | 18 | export class Lightbox { 19 | constructor() { 20 | this.el = $$el("div", "", { 21 | parent: document.body, 22 | onclick: (e) => { 23 | e.stopImmediatePropagation(); 24 | this.close(); 25 | }, 26 | style: { 27 | display: "none", 28 | opacity: 0, 29 | }, 30 | }); 31 | this.closeBtn = $$el("div", "close", { 32 | parent: this.el, 33 | }); 34 | this.prev = $$el("div", "prev", { 35 | parent: this.el, 36 | onclick: (e) => { 37 | this.update(-1); 38 | e.stopImmediatePropagation(); 39 | }, 40 | }); 41 | this.main = $$el("div", "main", { 42 | parent: this.el, 43 | }); 44 | this.next = $$el("div", "next", { 45 | parent: this.el, 46 | onclick: (e) => { 47 | this.update(1); 48 | e.stopImmediatePropagation(); 49 | }, 50 | }); 51 | this.link = $$el("a", "link", { 52 | parent: this.main, 53 | target: "_blank", 54 | }); 55 | this.spinner = createSpinner(); 56 | this.link.appendChild(this.spinner); 57 | this.img = $$el("img", "img", { 58 | style: { 59 | opacity: 0, 60 | }, 61 | parent: this.link, 62 | onclick: (e) => { 63 | e.stopImmediatePropagation(); 64 | }, 65 | onwheel: (e) => { 66 | if (!(e instanceof WheelEvent) || e.ctrlKey) { 67 | return; 68 | } 69 | const direction = Math.sign(e.deltaY); 70 | this.update(direction); 71 | }, 72 | }); 73 | } 74 | 75 | close() { 76 | ani( 77 | () => (this.el.style.opacity = 0), 78 | 200, 79 | () => (this.el.style.display = "none") 80 | ); 81 | } 82 | 83 | async show(images, index) { 84 | this.images = images; 85 | this.index = index || 0; 86 | await this.update(0); 87 | } 88 | 89 | async update(shift) { 90 | if (shift < 0 && this.index <= 0) { 91 | return; 92 | } 93 | if (shift > 0 && this.index >= this.images.length - 1) { 94 | return; 95 | } 96 | this.index += shift; 97 | 98 | this.prev.style.visibility = this.index ? "unset" : "hidden"; 99 | this.next.style.visibility = this.index === this.images.length - 1 ? "hidden" : "unset"; 100 | 101 | const img = this.images[this.index]; 102 | this.el.style.display = "flex"; 103 | this.el.clientWidth; // Force a reflow 104 | this.el.style.opacity = 1; 105 | this.img.style.opacity = 0; 106 | this.spinner.style.display = "inline-block"; 107 | try { 108 | await loadImage(img); 109 | } catch (err) { 110 | console.error('failed to load image', img, err); 111 | } 112 | this.spinner.style.display = "none"; 113 | this.link.href = img; 114 | this.img.src = img; 115 | this.img.style.opacity = 1; 116 | } 117 | 118 | async updateWithNewImage(img, feedDirection) { 119 | // No-op if lightbox is not open 120 | if (this.el.style.display === "none" || this.el.style.opacity === "0") return; 121 | 122 | // Ensure currently shown image does not change 123 | const [method, shift] = feedDirection === "newest first" ? ["unshift", 1] : ["push", 0]; 124 | this.images[method](img); 125 | await this.update(shift); 126 | } 127 | } 128 | 129 | export const lightbox = new Lightbox(); 130 | 131 | addEventListener('keydown', (event) => { 132 | if (lightbox.el.style.display === 'none') { 133 | return; 134 | } 135 | const { key } = event; 136 | switch (key) { 137 | case 'ArrowLeft': 138 | case 'a': 139 | lightbox.update(-1); 140 | break; 141 | case 'ArrowRight': 142 | case 'd': 143 | lightbox.update(1); 144 | break; 145 | case 'Escape': 146 | lightbox.close(); 147 | break; 148 | } 149 | }); -------------------------------------------------------------------------------- /web/js/snapToGridGuide.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { $el } from "../../../scripts/ui.js"; 3 | 4 | let guide_config; 5 | const id = "pysssss.SnapToGrid.Guide"; 6 | const guide_config_default = { 7 | lines: { 8 | enabled: false, 9 | fillStyle: "rgba(255, 0, 0, 0.5)", 10 | }, 11 | block: { 12 | enabled: false, 13 | fillStyle: "rgba(0, 0, 255, 0.5)", 14 | }, 15 | } 16 | 17 | const ext = { 18 | name: id, 19 | init() { 20 | if (localStorage.getItem(id) === null) { 21 | localStorage.setItem(id, JSON.stringify(guide_config_default)); 22 | } 23 | guide_config = JSON.parse(localStorage.getItem(id)); 24 | 25 | app.ui.settings.addSetting({ 26 | id, 27 | name: "🐍 Display drag-and-drop guides", 28 | type: (name, setter, value) => { 29 | return $el("tr", [ 30 | $el("td", [ 31 | $el("label", { 32 | for: id.replaceAll(".", "-"), 33 | textContent: name, 34 | }), 35 | ]), 36 | $el("td", [ 37 | $el( 38 | "label", 39 | { 40 | textContent: "Lines: ", 41 | style: { 42 | display: "inline-block", 43 | }, 44 | }, 45 | [ 46 | $el("input", { 47 | id: id.replaceAll(".", "-") + "-line-text", 48 | type: "text", 49 | value: guide_config.lines.fillStyle, 50 | onchange: (event) => { 51 | guide_config.lines.fillStyle = event.target.value; 52 | localStorage.setItem(id, JSON.stringify(guide_config)); 53 | } 54 | }), 55 | $el("input", { 56 | id: id.replaceAll(".", "-") + "-line-checkbox", 57 | type: "checkbox", 58 | checked: guide_config.lines.enabled, 59 | onchange: (event) => { 60 | guide_config.lines.enabled = !!event.target.checked; 61 | localStorage.setItem(id, JSON.stringify(guide_config)); 62 | }, 63 | }), 64 | ] 65 | ), 66 | $el( 67 | "label", 68 | { 69 | textContent: "Block: ", 70 | style: { 71 | display: "inline-block", 72 | }, 73 | }, 74 | [ 75 | $el("input", { 76 | id: id.replaceAll(".", "-") + "-block-text", 77 | type: "text", 78 | value: guide_config.block.fillStyle, 79 | onchange: (event) => { 80 | guide_config.block.fillStyle = event.target.value; 81 | localStorage.setItem(id, JSON.stringify(guide_config)); 82 | } 83 | }), 84 | $el("input", { 85 | id: id.replaceAll(".", "-") + '-block-checkbox', 86 | type: "checkbox", 87 | checked: guide_config.block.enabled, 88 | onchange: (event) => { 89 | guide_config.block.enabled = !!event.target.checked; 90 | localStorage.setItem(id, JSON.stringify(guide_config)); 91 | }, 92 | }), 93 | ] 94 | ), 95 | ]), 96 | ]); 97 | } 98 | }); 99 | 100 | const alwaysSnapToGrid = () => 101 | app.ui.settings.getSettingValue("pysssss.SnapToGrid", /* default=*/ false); 102 | const snapToGridEnabled = () => 103 | app.shiftDown || alwaysSnapToGrid(); 104 | 105 | // Override drag-and-drop behavior to show orthogonal guide lines around selected node(s) and preview of where the node(s) will be placed 106 | const origDrawNode = LGraphCanvas.prototype.drawNode; 107 | LGraphCanvas.prototype.drawNode = function (node, ctx) { 108 | const enabled = guide_config.lines.enabled || guide_config.block.enabled; 109 | if (enabled && this.node_dragged && node.id in this.selected_nodes && snapToGridEnabled()) { 110 | // discretize the canvas into grid 111 | let x = LiteGraph.CANVAS_GRID_SIZE * Math.round(node.pos[0] / LiteGraph.CANVAS_GRID_SIZE); 112 | let y = LiteGraph.CANVAS_GRID_SIZE * Math.round(node.pos[1] / LiteGraph.CANVAS_GRID_SIZE); 113 | 114 | // calculate the width and height of the node 115 | // (also need to shift the y position of the node, depending on whether the title is visible) 116 | x -= node.pos[0]; 117 | y -= node.pos[1]; 118 | let w, h; 119 | if (node.flags.collapsed) { 120 | w = node._collapsed_width; 121 | h = LiteGraph.NODE_TITLE_HEIGHT; 122 | y -= LiteGraph.NODE_TITLE_HEIGHT; 123 | } else { 124 | w = node.size[0]; 125 | h = node.size[1]; 126 | let titleMode = node.constructor.title_mode; 127 | if (titleMode !== LiteGraph.TRANSPARENT_TITLE && titleMode !== LiteGraph.NO_TITLE) { 128 | h += LiteGraph.NODE_TITLE_HEIGHT; 129 | y -= LiteGraph.NODE_TITLE_HEIGHT; 130 | } 131 | } 132 | 133 | // save the original fill style 134 | const f = ctx.fillStyle; 135 | 136 | // draw preview for drag-and-drop (rectangle to show where the node will be placed) 137 | if (guide_config.block.enabled) { 138 | ctx.fillStyle = guide_config.block.fillStyle; 139 | ctx.fillRect(x, y, w, h); 140 | } 141 | 142 | // add guide lines around node (arbitrarily long enough to span most workflows) 143 | if (guide_config.lines.enabled) { 144 | const xd = 10000; 145 | const yd = 10000; 146 | const thickness = 3; 147 | ctx.fillStyle = guide_config.lines.fillStyle; 148 | ctx.fillRect(x - xd, y, 2*xd, thickness); 149 | ctx.fillRect(x, y - yd, thickness, 2*yd); 150 | ctx.fillRect(x - xd, y + h, 2*xd, thickness); 151 | ctx.fillRect(x + w, y - yd, thickness, 2*yd); 152 | } 153 | 154 | // restore the original fill style 155 | ctx.fillStyle = f; 156 | } 157 | 158 | return origDrawNode.apply(this, arguments); 159 | }; 160 | }, 161 | }; 162 | 163 | app.registerExtension(ext); 164 | -------------------------------------------------------------------------------- /py/better_combos.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | from nodes import LoraLoader, CheckpointLoaderSimple 4 | import folder_paths 5 | from server import PromptServer 6 | from folder_paths import get_directory_by_type 7 | from aiohttp import web 8 | import shutil 9 | 10 | 11 | @PromptServer.instance.routes.get("/pysssss/view/{name}") 12 | async def view(request): 13 | name = request.match_info["name"] 14 | pos = name.index("/") 15 | type = name[0:pos] 16 | name = name[pos+1:] 17 | 18 | image_path = folder_paths.get_full_path( 19 | type, name) 20 | if not image_path: 21 | return web.Response(status=404) 22 | 23 | filename = os.path.basename(image_path) 24 | return web.FileResponse(image_path, headers={"Content-Disposition": f"filename=\"{filename}\""}) 25 | 26 | 27 | @PromptServer.instance.routes.post("/pysssss/save/{name}") 28 | async def save_preview(request): 29 | name = request.match_info["name"] 30 | pos = name.index("/") 31 | type = name[0:pos] 32 | name = name[pos+1:] 33 | 34 | body = await request.json() 35 | 36 | dir = get_directory_by_type(body.get("type", "output")) 37 | subfolder = body.get("subfolder", "") 38 | full_output_folder = os.path.join(dir, os.path.normpath(subfolder)) 39 | 40 | filepath = os.path.join(full_output_folder, body.get("filename", "")) 41 | 42 | if os.path.commonpath((dir, os.path.abspath(filepath))) != dir: 43 | return web.Response(status=400) 44 | 45 | image_path = folder_paths.get_full_path(type, name) 46 | image_path = os.path.splitext( 47 | image_path)[0] + os.path.splitext(filepath)[1] 48 | 49 | shutil.copyfile(filepath, image_path) 50 | 51 | return web.json_response({ 52 | "image": type + "/" + os.path.basename(image_path) 53 | }) 54 | 55 | 56 | @PromptServer.instance.routes.get("/pysssss/examples/{name}") 57 | async def get_examples(request): 58 | name = request.match_info["name"] 59 | pos = name.index("/") 60 | type = name[0:pos] 61 | name = name[pos+1:] 62 | 63 | file_path = folder_paths.get_full_path( 64 | type, name) 65 | if not file_path: 66 | return web.Response(status=404) 67 | 68 | file_path_no_ext = os.path.splitext(file_path)[0] 69 | examples = [] 70 | 71 | if os.path.isdir(file_path_no_ext): 72 | examples += sorted(map(lambda t: os.path.relpath(t, file_path_no_ext), 73 | glob.glob(file_path_no_ext + "/*.txt"))) 74 | 75 | if os.path.isfile(file_path_no_ext + ".txt"): 76 | examples += ["notes"] 77 | 78 | return web.json_response(examples) 79 | 80 | 81 | @PromptServer.instance.routes.post("/pysssss/examples/{name}") 82 | async def save_example(request): 83 | name = request.match_info["name"] 84 | pos = name.index("/") 85 | type = name[0:pos] 86 | name = name[pos+1:] 87 | body = await request.json() 88 | example_name = body["name"] 89 | example = body["example"] 90 | 91 | file_path = folder_paths.get_full_path( 92 | type, name) 93 | if not file_path: 94 | return web.Response(status=404) 95 | 96 | if not example_name.endswith(".txt"): 97 | example_name += ".txt" 98 | 99 | file_path_no_ext = os.path.splitext(file_path)[0] 100 | example_file = os.path.join(file_path_no_ext, example_name) 101 | if not os.path.exists(file_path_no_ext): 102 | os.mkdir(file_path_no_ext) 103 | with open(example_file, 'w', encoding='utf8') as f: 104 | f.write(example) 105 | 106 | return web.Response(status=201) 107 | 108 | 109 | @PromptServer.instance.routes.get("/pysssss/images/{type}") 110 | async def get_images(request): 111 | type = request.match_info["type"] 112 | names = folder_paths.get_filename_list(type) 113 | 114 | images = {} 115 | for item_name in names: 116 | file_name = os.path.splitext(item_name)[0] 117 | file_path = folder_paths.get_full_path(type, item_name) 118 | 119 | if file_path is None: 120 | continue 121 | 122 | file_path_no_ext = os.path.splitext(file_path)[0] 123 | 124 | for ext in ["png", "jpg", "jpeg", "preview.png", "preview.jpeg"]: 125 | if os.path.isfile(file_path_no_ext + "." + ext): 126 | images[item_name] = f"{type}/{file_name}.{ext}" 127 | break 128 | 129 | return web.json_response(images) 130 | 131 | 132 | class LoraLoaderWithImages(LoraLoader): 133 | RETURN_TYPES = (*LoraLoader.RETURN_TYPES, "STRING",) 134 | RETURN_NAMES = (*getattr(LoraLoader, "RETURN_NAMES", 135 | LoraLoader.RETURN_TYPES), "example") 136 | 137 | @classmethod 138 | def INPUT_TYPES(s): 139 | types = super().INPUT_TYPES() 140 | types["optional"] = {"prompt": ("STRING", {"hidden": True})} 141 | return types 142 | 143 | def load_lora(self, **kwargs): 144 | prompt = kwargs.pop("prompt", "") 145 | return (*super().load_lora(**kwargs), prompt) 146 | 147 | 148 | class CheckpointLoaderSimpleWithImages(CheckpointLoaderSimple): 149 | RETURN_TYPES = (*CheckpointLoaderSimple.RETURN_TYPES, "STRING",) 150 | RETURN_NAMES = (*getattr(CheckpointLoaderSimple, "RETURN_NAMES", 151 | CheckpointLoaderSimple.RETURN_TYPES), "example") 152 | 153 | @classmethod 154 | def INPUT_TYPES(s): 155 | types = super().INPUT_TYPES() 156 | types["optional"] = {"prompt": ("STRING", {"hidden": True})} 157 | return types 158 | 159 | def load_checkpoint(self, **kwargs): 160 | prompt = kwargs.pop("prompt", "") 161 | return (*super().load_checkpoint(**kwargs), prompt) 162 | 163 | 164 | NODE_CLASS_MAPPINGS = { 165 | "LoraLoader|pysssss": LoraLoaderWithImages, 166 | "CheckpointLoader|pysssss": CheckpointLoaderSimpleWithImages, 167 | } 168 | 169 | NODE_DISPLAY_NAME_MAPPINGS = { 170 | "LoraLoader|pysssss": "Lora Loader 🐍", 171 | "CheckpointLoader|pysssss": "Checkpoint Loader 🐍", 172 | } 173 | -------------------------------------------------------------------------------- /web/js/quickNodes.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | 3 | // Adds a bunch of context menu entries for quickly adding common steps 4 | 5 | function addMenuHandler(nodeType, cb) { 6 | const getOpts = nodeType.prototype.getExtraMenuOptions; 7 | nodeType.prototype.getExtraMenuOptions = function () { 8 | const r = getOpts.apply(this, arguments); 9 | cb.apply(this, arguments); 10 | return r; 11 | }; 12 | } 13 | 14 | function getOrAddVAELoader(node) { 15 | let vaeNode = app.graph._nodes.find((n) => n.type === "VAELoader"); 16 | if (!vaeNode) { 17 | vaeNode = addNode("VAELoader", node); 18 | } 19 | return vaeNode; 20 | } 21 | 22 | function addNode(name, nextTo, options) { 23 | options = { select: true, shiftY: 0, before: false, ...(options || {}) }; 24 | const node = LiteGraph.createNode(name); 25 | app.graph.add(node); 26 | node.pos = [ 27 | options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30, 28 | nextTo.pos[1] + options.shiftY, 29 | ]; 30 | if (options.select) { 31 | app.canvas.selectNode(node, false); 32 | } 33 | return node; 34 | } 35 | 36 | app.registerExtension({ 37 | name: "pysssss.QuickNodes", 38 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 39 | if (nodeData.input && nodeData.input.required) { 40 | const keys = Object.keys(nodeData.input.required); 41 | for (let i = 0; i < keys.length; i++) { 42 | if (nodeData.input.required[keys[i]][0] === "VAE") { 43 | addMenuHandler(nodeType, function (_, options) { 44 | options.unshift({ 45 | content: "Use VAE", 46 | callback: () => { 47 | getOrAddVAELoader(this).connect(0, this, i); 48 | }, 49 | }); 50 | }); 51 | break; 52 | } 53 | } 54 | } 55 | 56 | if (nodeData.name === "KSampler") { 57 | addMenuHandler(nodeType, function (_, options) { 58 | options.unshift( 59 | { 60 | content: "Add Blank Input", 61 | callback: () => { 62 | const imageNode = addNode("EmptyLatentImage", this, { before: true }); 63 | imageNode.connect(0, this, 3); 64 | }, 65 | }, 66 | { 67 | content: "Add Hi-res Fix", 68 | callback: () => { 69 | const upscaleNode = addNode("LatentUpscale", this); 70 | this.connect(0, upscaleNode, 0); 71 | 72 | const sampleNode = addNode("KSampler", upscaleNode); 73 | 74 | for (let i = 0; i < 3; i++) { 75 | const l = this.getInputLink(i); 76 | if (l) { 77 | app.graph.getNodeById(l.origin_id).connect(l.origin_slot, sampleNode, i); 78 | } 79 | } 80 | 81 | upscaleNode.connect(0, sampleNode, 3); 82 | }, 83 | }, 84 | { 85 | content: "Add 2nd Pass", 86 | callback: () => { 87 | const upscaleNode = addNode("LatentUpscale", this); 88 | this.connect(0, upscaleNode, 0); 89 | 90 | const ckptNode = addNode("CheckpointLoaderSimple", this); 91 | const sampleNode = addNode("KSampler", ckptNode); 92 | 93 | const positiveLink = this.getInputLink(1); 94 | const negativeLink = this.getInputLink(2); 95 | const positiveNode = positiveLink 96 | ? app.graph.add(app.graph.getNodeById(positiveLink.origin_id).clone()) 97 | : addNode("CLIPTextEncode"); 98 | const negativeNode = negativeLink 99 | ? app.graph.add(app.graph.getNodeById(negativeLink.origin_id).clone()) 100 | : addNode("CLIPTextEncode"); 101 | 102 | ckptNode.connect(0, sampleNode, 0); 103 | ckptNode.connect(1, positiveNode, 0); 104 | ckptNode.connect(1, negativeNode, 0); 105 | positiveNode.connect(0, sampleNode, 1); 106 | negativeNode.connect(0, sampleNode, 2); 107 | upscaleNode.connect(0, sampleNode, 3); 108 | }, 109 | }, 110 | { 111 | content: "Add Save Image", 112 | callback: () => { 113 | const decodeNode = addNode("VAEDecode", this); 114 | this.connect(0, decodeNode, 0); 115 | 116 | getOrAddVAELoader(decodeNode).connect(0, decodeNode, 1); 117 | 118 | const saveNode = addNode("SaveImage", decodeNode); 119 | decodeNode.connect(0, saveNode, 0); 120 | }, 121 | } 122 | ); 123 | }); 124 | } 125 | 126 | if (nodeData.name === "CheckpointLoaderSimple") { 127 | addMenuHandler(nodeType, function (_, options) { 128 | options.unshift({ 129 | content: "Add Clip Skip", 130 | callback: () => { 131 | const clipSkipNode = addNode("CLIPSetLastLayer", this); 132 | const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : []; 133 | 134 | this.disconnectOutput(1); 135 | this.connect(1, clipSkipNode, 0); 136 | 137 | for (const clipLink of clipLinks) { 138 | clipSkipNode.connect(0, clipLink.target_id, clipLink.target_slot); 139 | } 140 | }, 141 | }); 142 | }); 143 | } 144 | 145 | if ( 146 | nodeData.name === "CheckpointLoaderSimple" || 147 | nodeData.name === "CheckpointLoader" || 148 | nodeData.name === "CheckpointLoader|pysssss" || 149 | nodeData.name === "LoraLoader" || 150 | nodeData.name === "LoraLoader|pysssss" 151 | ) { 152 | addMenuHandler(nodeType, function (_, options) { 153 | function addLora(type) { 154 | const loraNode = addNode(type, this); 155 | 156 | const modelLinks = this.outputs[0].links ? this.outputs[0].links.map((l) => ({ ...graph.links[l] })) : []; 157 | const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : []; 158 | 159 | this.disconnectOutput(0); 160 | this.disconnectOutput(1); 161 | 162 | this.connect(0, loraNode, 0); 163 | this.connect(1, loraNode, 1); 164 | 165 | for (const modelLink of modelLinks) { 166 | loraNode.connect(0, modelLink.target_id, modelLink.target_slot); 167 | } 168 | 169 | for (const clipLink of clipLinks) { 170 | loraNode.connect(1, clipLink.target_id, clipLink.target_slot); 171 | } 172 | } 173 | options.unshift( 174 | { 175 | content: "Add LoRA", 176 | callback: () => addLora.call(this, "LoraLoader"), 177 | }, 178 | { 179 | content: "Add 🐍 LoRA", 180 | callback: () => addLora.call(this, "LoraLoader|pysssss"), 181 | }, 182 | { 183 | content: "Add Prompts", 184 | callback: () => { 185 | const positiveNode = addNode("CLIPTextEncode", this); 186 | const negativeNode = addNode("CLIPTextEncode", this, { shiftY: positiveNode.size[1] + 30 }); 187 | 188 | this.connect(1, positiveNode, 0); 189 | this.connect(1, negativeNode, 0); 190 | }, 191 | } 192 | ); 193 | }); 194 | } 195 | }, 196 | }); 197 | -------------------------------------------------------------------------------- /web/js/common/binding.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // @ts-ignore 3 | import { ComfyWidgets } from "../../../../scripts/widgets.js"; 4 | // @ts-ignore 5 | import { api } from "../../../../scripts/api.js"; 6 | // @ts-ignore 7 | import { app } from "../../../../scripts/app.js"; 8 | 9 | const PathHelper = { 10 | get(obj, path) { 11 | if (typeof path !== "string") { 12 | // Hardcoded value 13 | return path; 14 | } 15 | 16 | if (path[0] === '"' && path[path.length - 1] === '"') { 17 | // Hardcoded string 18 | return JSON.parse(path); 19 | } 20 | 21 | // Evaluate the path 22 | path = path.split(".").filter(Boolean); 23 | for (const p of path) { 24 | const k = isNaN(+p) ? p : +p; 25 | obj = obj[k]; 26 | } 27 | 28 | return obj; 29 | }, 30 | set(obj, path, value) { 31 | // https://stackoverflow.com/a/54733755 32 | if (Object(obj) !== obj) return obj; // When obj is not an object 33 | // If not yet an array, get the keys from the string-path 34 | if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || []; 35 | path.slice(0, -1).reduce( 36 | ( 37 | a, 38 | c, 39 | i // Iterate all of them except the last one 40 | ) => 41 | Object(a[c]) === a[c] // Does the key exist and is its value an object? 42 | ? // Yes: then follow that path 43 | a[c] 44 | : // No: create the key. Is the next key a potential array-index? 45 | (a[c] = 46 | Math.abs(path[i + 1]) >> 0 === +path[i + 1] 47 | ? [] // Yes: assign a new array object 48 | : {}), // No: assign a new plain object 49 | obj 50 | )[path[path.length - 1]] = value; // Finally assign the value to the last key 51 | return obj; // Return the top-level object to allow chaining 52 | }, 53 | }; 54 | 55 | /*** 56 | @typedef { { 57 | left: string; 58 | op: "eq" | "ne", 59 | right: string 60 | } } IfCondition 61 | 62 | @typedef { { 63 | type: "if", 64 | condition: Array, 65 | true?: Array, 66 | false?: Array 67 | } } IfCallback 68 | 69 | @typedef { { 70 | type: "fetch", 71 | url: string, 72 | then: Array 73 | } } FetchCallback 74 | 75 | @typedef { { 76 | type: "set", 77 | target: string, 78 | value: string 79 | } } SetCallback 80 | 81 | @typedef { { 82 | type: "validate-combo", 83 | } } ValidateComboCallback 84 | 85 | @typedef { IfCallback | FetchCallback | SetCallback | ValidateComboCallback } BindingCallback 86 | 87 | @typedef { { 88 | source: string, 89 | callback: Array 90 | } } Binding 91 | ***/ 92 | 93 | /** 94 | * @param {IfCondition} condition 95 | */ 96 | function evaluateCondition(condition, state) { 97 | const left = PathHelper.get(state, condition.left); 98 | const right = PathHelper.get(state, condition.right); 99 | 100 | let r; 101 | if (condition.op === "eq") { 102 | r = left === right; 103 | } else { 104 | r = left !== right; 105 | } 106 | 107 | return r; 108 | } 109 | 110 | /** 111 | * @type { Record) => Promise> } 112 | */ 113 | const callbacks = { 114 | /** 115 | * @param {IfCallback} cb 116 | */ 117 | async if(cb, state) { 118 | // For now only support ANDs 119 | let success = true; 120 | for (const condition of cb.condition) { 121 | const r = evaluateCondition(condition, state); 122 | if (!r) { 123 | success = false; 124 | break; 125 | } 126 | } 127 | 128 | for (const m of cb[success + ""] ?? []) { 129 | await invokeCallback(m, state); 130 | } 131 | }, 132 | /** 133 | * @param {FetchCallback} cb 134 | */ 135 | async fetch(cb, state) { 136 | const url = cb.url.replace(/\{([^\}]+)\}/g, (m, v) => { 137 | return PathHelper.get(state, v); 138 | }); 139 | const res = await (await api.fetchApi(url)).json(); 140 | state["$result"] = res; 141 | for (const m of cb.then) { 142 | await invokeCallback(m, state); 143 | } 144 | }, 145 | /** 146 | * @param {SetCallback} cb 147 | */ 148 | async set(cb, state) { 149 | const value = PathHelper.get(state, cb.value); 150 | PathHelper.set(state, cb.target, value); 151 | }, 152 | async "validate-combo"(cb, state) { 153 | const w = state["$this"]; 154 | const valid = w.options.values.includes(w.value); 155 | if (!valid) { 156 | w.value = w.options.values[0]; 157 | } 158 | }, 159 | }; 160 | 161 | async function invokeCallback(callback, state) { 162 | if (callback.type in callbacks) { 163 | // @ts-ignore 164 | await callbacks[callback.type](callback, state); 165 | } else { 166 | console.warn( 167 | "%c[🐍 pysssss]", 168 | "color: limegreen", 169 | `[binding ${state.$node.comfyClass}.${state.$this.name}]`, 170 | "unsupported binding callback type:", 171 | callback.type 172 | ); 173 | } 174 | } 175 | 176 | app.registerExtension({ 177 | name: "pysssss.Binding", 178 | beforeRegisterNodeDef(node, nodeData) { 179 | const hasBinding = (v) => { 180 | if (!v) return false; 181 | return Object.values(v).find((c) => c[1]?.["pysssss.binding"]); 182 | }; 183 | const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional }; 184 | if (hasBinding(inputs)) { 185 | const onAdded = node.prototype.onAdded; 186 | node.prototype.onAdded = function () { 187 | const r = onAdded?.apply(this, arguments); 188 | 189 | for (const widget of this.widgets || []) { 190 | const bindings = inputs[widget.name][1]?.["pysssss.binding"]; 191 | if (!bindings) continue; 192 | 193 | for (const binding of bindings) { 194 | /** 195 | * @type {import("../../../../../web/types/litegraph.d.ts").IWidget} 196 | */ 197 | const source = this.widgets.find((w) => w.name === binding.source); 198 | if (!source) { 199 | console.warn( 200 | "%c[🐍 pysssss]", 201 | "color: limegreen", 202 | `[binding ${node.comfyClass}.${widget.name}]`, 203 | "unable to find source binding widget:", 204 | binding.source, 205 | binding 206 | ); 207 | continue; 208 | } 209 | 210 | let lastValue; 211 | async function valueChanged() { 212 | const state = { 213 | $this: widget, 214 | $source: source, 215 | $node: node, 216 | }; 217 | 218 | for (const callback of binding.callback) { 219 | await invokeCallback(callback, state); 220 | } 221 | 222 | app.graph.setDirtyCanvas(true, false); 223 | } 224 | 225 | const cb = source.callback; 226 | source.callback = function () { 227 | const v = cb?.apply(this, arguments) ?? source.value; 228 | if (v !== lastValue) { 229 | lastValue = v; 230 | valueChanged(); 231 | } 232 | return v; 233 | }; 234 | 235 | lastValue = source.value; 236 | valueChanged(); 237 | } 238 | } 239 | 240 | return r; 241 | }; 242 | } 243 | }, 244 | }); 245 | -------------------------------------------------------------------------------- /py/text_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import folder_paths 3 | import json 4 | from server import PromptServer 5 | import glob 6 | from aiohttp import web 7 | 8 | 9 | def get_allowed_dirs(): 10 | dir = os.path.abspath(os.path.join(__file__, "../../user")) 11 | file = os.path.join(dir, "text_file_dirs.json") 12 | with open(file, "r") as f: 13 | return json.loads(f.read()) 14 | 15 | 16 | def get_valid_dirs(): 17 | return get_allowed_dirs().keys() 18 | 19 | 20 | def get_dir_from_name(name): 21 | dirs = get_allowed_dirs() 22 | if name not in dirs: 23 | raise KeyError(name + " dir not found") 24 | 25 | path = dirs[name] 26 | path = path.replace("$input", folder_paths.get_input_directory()) 27 | path = path.replace("$output", folder_paths.get_output_directory()) 28 | path = path.replace("$temp", folder_paths.get_temp_directory()) 29 | return path 30 | 31 | 32 | def is_child_dir(parent_path, child_path): 33 | parent_path = os.path.abspath(parent_path) 34 | child_path = os.path.abspath(child_path) 35 | return os.path.commonpath([parent_path]) == os.path.commonpath([parent_path, child_path]) 36 | 37 | 38 | def get_real_path(dir): 39 | dir = dir.replace("/**/", "/") 40 | dir = os.path.abspath(dir) 41 | dir = os.path.split(dir)[0] 42 | return dir 43 | 44 | 45 | @PromptServer.instance.routes.get("/pysssss/text-file/{name}") 46 | async def get_files(request): 47 | name = request.match_info["name"] 48 | dir = get_dir_from_name(name) 49 | recursive = "/**/" in dir 50 | # Ugh cant use root_path on glob... lazy hack.. 51 | pre = get_real_path(dir) 52 | 53 | files = list(map(lambda t: os.path.relpath(t, pre), 54 | glob.glob(dir, recursive=recursive))) 55 | 56 | if len(files) == 0: 57 | files = ["[none]"] 58 | return web.json_response(files) 59 | 60 | 61 | def get_file(root_dir, file): 62 | if file == "[none]" or not file or not file.strip(): 63 | raise ValueError("No file") 64 | 65 | root_dir = get_dir_from_name(root_dir) 66 | root_dir = get_real_path(root_dir) 67 | if not os.path.exists(root_dir): 68 | os.mkdir(root_dir) 69 | full_path = os.path.join(root_dir, file) 70 | 71 | file_dir = os.path.dirname(full_path) 72 | if file_dir and not os.path.exists(file_dir): 73 | os.makedirs(file_dir, exist_ok=True) 74 | 75 | if not is_child_dir(root_dir, full_path): 76 | raise ReferenceError() 77 | 78 | return full_path 79 | 80 | 81 | class TextFileNode: 82 | RETURN_TYPES = ("STRING",) 83 | CATEGORY = "utils" 84 | 85 | @classmethod 86 | def VALIDATE_INPUTS(self, root_dir, file, **kwargs): 87 | if file == "[none]" or not file or not file.strip(): 88 | return True 89 | get_file(root_dir, file) 90 | return True 91 | 92 | def load_text(self, **kwargs): 93 | self.file = get_file(kwargs["root_dir"], kwargs["file"]) 94 | with open(self.file, "r") as f: 95 | return (f.read(), ) 96 | 97 | 98 | class LoadText(TextFileNode): 99 | @classmethod 100 | def IS_CHANGED(self, **kwargs): 101 | return os.path.getmtime(self.file) 102 | 103 | @classmethod 104 | def INPUT_TYPES(s): 105 | return { 106 | "required": { 107 | "root_dir": (list(get_valid_dirs()), {}), 108 | "file": (["[none]"], { 109 | "pysssss.binding": [{ 110 | "source": "root_dir", 111 | "callback": [{ 112 | "type": "set", 113 | "target": "$this.disabled", 114 | "value": True 115 | }, { 116 | "type": "fetch", 117 | "url": "/pysssss/text-file/{$source.value}", 118 | "then": [{ 119 | "type": "set", 120 | "target": "$this.options.values", 121 | "value": "$result" 122 | }, { 123 | "type": "validate-combo" 124 | }, { 125 | "type": "set", 126 | "target": "$this.disabled", 127 | "value": False 128 | }] 129 | }], 130 | }] 131 | }) 132 | }, 133 | } 134 | 135 | FUNCTION = "load_text" 136 | 137 | 138 | class SaveText(TextFileNode): 139 | OUTPUT_NODE = True 140 | 141 | @classmethod 142 | def IS_CHANGED(self, **kwargs): 143 | return float("nan") 144 | 145 | @classmethod 146 | def INPUT_TYPES(s): 147 | return { 148 | "required": { 149 | "root_dir": (list(get_valid_dirs()), {}), 150 | "file": ("STRING", {"default": "file.txt"}), 151 | "append": (["append", "overwrite", "new only"], {}), 152 | "insert": ("BOOLEAN", { 153 | "default": True, "label_on": "new line", "label_off": "none", 154 | "pysssss.binding": [{ 155 | "source": "append", 156 | "callback": [{ 157 | "type": "if", 158 | "condition": [{ 159 | "left": "$source.value", 160 | "op": "eq", 161 | "right": '"append"' 162 | }], 163 | "true": [{ 164 | "type": "set", 165 | "target": "$this.disabled", 166 | "value": False 167 | }], 168 | "false": [{ 169 | "type": "set", 170 | "target": "$this.disabled", 171 | "value": True 172 | }], 173 | }] 174 | }] 175 | }), 176 | "text": ("STRING", {"forceInput": True, "multiline": True}) 177 | }, 178 | } 179 | 180 | FUNCTION = "write_text" 181 | 182 | def write_text(self, **kwargs): 183 | self.file = get_file(kwargs["root_dir"], kwargs["file"]) 184 | if kwargs["append"] == "new only" and os.path.exists(self.file): 185 | raise FileExistsError( 186 | self.file + " already exists and 'new only' is selected.") 187 | with open(self.file, "a+" if kwargs["append"] == "append" else "w") as f: 188 | is_append = f.tell() != 0 189 | if is_append and kwargs["insert"]: 190 | f.write("\n") 191 | f.write(kwargs["text"]) 192 | 193 | return super().load_text(**kwargs) 194 | 195 | 196 | NODE_CLASS_MAPPINGS = { 197 | "LoadText|pysssss": LoadText, 198 | "SaveText|pysssss": SaveText, 199 | } 200 | 201 | NODE_DISPLAY_NAME_MAPPINGS = { 202 | "LoadText|pysssss": "Load Text 🐍", 203 | "SaveText|pysssss": "Save Text 🐍", 204 | } 205 | -------------------------------------------------------------------------------- /web/js/widgetDefaults.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { $el, ComfyDialog } from "../../../scripts/ui.js"; 3 | 4 | // Allows you to specify custom default values for any widget on any node 5 | 6 | const id = "pysssss.WidgetDefaults"; 7 | const nodeDataKey = Symbol(); 8 | 9 | app.registerExtension({ 10 | name: id, 11 | beforeRegisterNodeDef(nodeType, nodeData) { 12 | nodeType[nodeDataKey] = nodeData; 13 | }, 14 | setup() { 15 | let defaults; 16 | let regexDefaults; 17 | let setting; 18 | 19 | const getNodeDefaults = (node, defaults) => { 20 | const nodeDefaults = defaults[node.type] ?? {}; 21 | const propSetBy = {}; 22 | 23 | Object.keys(regexDefaults) 24 | .filter((r) => new RegExp(r).test(node.type)) 25 | .reduce((p, n) => { 26 | const props = regexDefaults[n]; 27 | for (const k in props) { 28 | // Use the longest matching key as its probably the most specific 29 | if (!(k in nodeDefaults) || (k in propSetBy && n.length > propSetBy[k].length)) { 30 | propSetBy[k] = n; 31 | nodeDefaults[k] = props[k]; 32 | } 33 | } 34 | return p; 35 | }, nodeDefaults); 36 | 37 | return nodeDefaults; 38 | }; 39 | 40 | const applyDefaults = (defaults) => { 41 | for (const node of Object.values(LiteGraph.registered_node_types)) { 42 | const nodeData = node[nodeDataKey]; 43 | if (!nodeData) continue; 44 | const nodeDefaults = getNodeDefaults(node, defaults); 45 | if (!nodeDefaults) continue; 46 | const inputs = { ...(nodeData.input?.required || {}), ...(nodeData.input?.optional || {}) }; 47 | 48 | for (const w in nodeDefaults) { 49 | const widgetDef = inputs[w]; 50 | if (widgetDef) { 51 | let v = nodeDefaults[w]; 52 | if (widgetDef[0] === "INT" || widgetDef[0] === "FLOAT") { 53 | v = +v; 54 | } 55 | if (widgetDef[1]) { 56 | widgetDef[1].default = v; 57 | } else { 58 | widgetDef[1] = { default: v }; 59 | } 60 | } 61 | } 62 | } 63 | }; 64 | 65 | const getDefaults = () => { 66 | let items; 67 | regexDefaults = {}; 68 | try { 69 | items = JSON.parse(setting.value); 70 | items = items.reduce((p, n) => { 71 | if (n.node.startsWith("/") && n.node.endsWith("/")) { 72 | const name = n.node.substring(1, n.node.length - 1); 73 | try { 74 | // Validate regex 75 | new RegExp(name); 76 | 77 | if (!regexDefaults[name]) regexDefaults[name] = {}; 78 | regexDefaults[name][n.widget] = n.value; 79 | } catch (error) {} 80 | } 81 | 82 | if (!p[n.node]) p[n.node] = {}; 83 | p[n.node][n.widget] = n.value; 84 | return p; 85 | }, {}); 86 | } catch (error) {} 87 | if (!items) { 88 | items = {}; 89 | } 90 | applyDefaults(items); 91 | return items; 92 | }; 93 | 94 | const onNodeAdded = app.graph.onNodeAdded; 95 | app.graph.onNodeAdded = function (node) { 96 | onNodeAdded?.apply?.(this, arguments); 97 | 98 | // See if we have any defaults for this type of node 99 | const nodeDefaults = getNodeDefaults(node.constructor, defaults); 100 | if (!nodeDefaults) return; 101 | 102 | // Dont run if they are pre-configured nodes from load/pastes 103 | const stack = new Error().stack; 104 | if (stack.includes("pasteFromClipboard") || stack.includes("loadGraphData")) { 105 | return; 106 | } 107 | 108 | for (const k in nodeDefaults) { 109 | if (k.startsWith("property.")) { 110 | const name = k.substring(9); 111 | let v = nodeDefaults[k]; 112 | // Special handling for some built in values 113 | if (name in node || ["color", "bgcolor", "title"].includes(name)) { 114 | node[name] = v; 115 | } else { 116 | // Try using the correct type 117 | if (!node.properties) node.properties = {}; 118 | if (typeof node.properties[name] === "number") v = +v; 119 | else if (typeof node.properties[name] === "boolean") v = v === "true"; 120 | else if (v === "true") v = true; 121 | 122 | node.properties[name] = v; 123 | } 124 | } 125 | } 126 | }; 127 | 128 | class WidgetDefaultsDialog extends ComfyDialog { 129 | constructor() { 130 | super(); 131 | this.element.classList.add("comfy-manage-templates"); 132 | this.grid = $el( 133 | "div", 134 | { 135 | style: { 136 | display: "grid", 137 | gridTemplateColumns: "1fr auto auto auto", 138 | gap: "5px", 139 | }, 140 | className: "pysssss-widget-defaults", 141 | }, 142 | [ 143 | $el("label", { 144 | textContent: "Node Class", 145 | }), 146 | $el("label", { 147 | textContent: "Widget Name", 148 | }), 149 | $el("label", { 150 | textContent: "Default Value", 151 | }), 152 | $el("label"), 153 | (this.rows = $el("div", { 154 | style: { 155 | display: "contents", 156 | }, 157 | })), 158 | ] 159 | ); 160 | } 161 | 162 | createButtons() { 163 | const btns = super.createButtons(); 164 | btns[0].textContent = "Cancel"; 165 | btns.unshift( 166 | $el("button", { 167 | type: "button", 168 | textContent: "Add New", 169 | onclick: () => this.addRow(), 170 | }), 171 | $el("button", { 172 | type: "button", 173 | textContent: "Save", 174 | onclick: () => this.save(), 175 | }) 176 | ); 177 | return btns; 178 | } 179 | 180 | addRow(node = "", widget = "", value = "") { 181 | let nameInput; 182 | this.rows.append( 183 | $el( 184 | "div", 185 | { 186 | style: { 187 | display: "contents", 188 | }, 189 | className: "pysssss-widget-defaults-row", 190 | }, 191 | [ 192 | $el("input", { 193 | placeholder: "e.g. CheckpointLoaderSimple", 194 | value: node, 195 | }), 196 | $el("input", { 197 | placeholder: "e.g. ckpt_name", 198 | value: widget, 199 | $: (el) => (nameInput = el), 200 | }), 201 | $el("input", { 202 | placeholder: "e.g. myBestModel.safetensors", 203 | value, 204 | }), 205 | $el("button", { 206 | textContent: "Delete", 207 | style: { 208 | fontSize: "12px", 209 | color: "red", 210 | fontWeight: "normal", 211 | }, 212 | onclick: (e) => { 213 | nameInput.value = ""; 214 | e.target.parentElement.style.display = "none"; 215 | }, 216 | }), 217 | ] 218 | ) 219 | ); 220 | } 221 | 222 | save() { 223 | const rows = this.rows.children; 224 | const items = []; 225 | 226 | for (const row of rows) { 227 | const inputs = row.querySelectorAll("input"); 228 | const node = inputs[0].value.trim(); 229 | const widget = inputs[1].value.trim(); 230 | const value = inputs[2].value; 231 | if (node && widget) { 232 | items.push({ node, widget, value }); 233 | } 234 | } 235 | 236 | setting.value = JSON.stringify(items); 237 | defaults = getDefaults(); 238 | 239 | this.close(); 240 | } 241 | 242 | show() { 243 | this.rows.replaceChildren(); 244 | for (const nodeName in defaults) { 245 | const node = defaults[nodeName]; 246 | for (const widgetName in node) { 247 | this.addRow(nodeName, widgetName, node[widgetName]); 248 | } 249 | } 250 | 251 | this.addRow(); 252 | super.show(this.grid); 253 | } 254 | } 255 | 256 | setting = app.ui.settings.addSetting({ 257 | id, 258 | name: "🐍 Widget Defaults", 259 | type: () => { 260 | return $el("tr", [ 261 | $el("td", [ 262 | $el("label", { 263 | for: id.replaceAll(".", "-"), 264 | textContent: "🐍 Widget & Property Defaults:", 265 | }), 266 | ]), 267 | $el("td", [ 268 | $el("button", { 269 | textContent: "Manage", 270 | onclick: () => { 271 | try { 272 | // Try closing old settings window 273 | if (typeof app.ui.settings.element?.close === "function") { 274 | app.ui.settings.element.close(); 275 | } 276 | } catch (error) {} 277 | try { 278 | // Try closing new vue dialog 279 | document.querySelector(".p-dialog-close-button").click(); 280 | } catch (error) { 281 | // Fallback to just hiding the element 282 | app.ui.settings.element.style.display = "none"; 283 | } 284 | const dialog = new WidgetDefaultsDialog(); 285 | dialog.show(); 286 | }, 287 | style: { 288 | fontSize: "14px", 289 | }, 290 | }), 291 | ]), 292 | ]); 293 | }, 294 | }); 295 | defaults = getDefaults(); 296 | }, 297 | }); 298 | -------------------------------------------------------------------------------- /web/js/presetText.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | 3 | // Allows you to manage preset tags for e.g. common negative prompt 4 | // Also performs replacements on any text field e.g. allowing you to use preset text in CLIP Text encode fields 5 | 6 | let replaceRegex; 7 | const id = "pysssss.PresetText.Presets"; 8 | const MISSING = Symbol(); 9 | 10 | const getPresets = () => { 11 | let items; 12 | try { 13 | items = JSON.parse(localStorage.getItem(id)); 14 | } catch (error) {} 15 | if (!items || !items.length) { 16 | items = [{ name: "default negative", value: "worst quality" }]; 17 | } 18 | return items; 19 | }; 20 | 21 | let presets = getPresets(); 22 | 23 | app.registerExtension({ 24 | name: "pysssss.PresetText", 25 | setup() { 26 | app.ui.settings.addSetting({ 27 | id: "pysssss.PresetText.ReplacementRegex", 28 | name: "🐍 Preset Text Replacement Regex", 29 | type: "text", 30 | defaultValue: "(?:^|[^\\w])(?@(?[\\w-]+))", 31 | tooltip: 32 | "The regex should return two named capture groups: id (the name of the preset text to use), replace (the matched text to replace)", 33 | attrs: { 34 | style: { 35 | fontFamily: "monospace", 36 | }, 37 | }, 38 | onChange(value) { 39 | if (!value) { 40 | replaceRegex = null; 41 | return; 42 | } 43 | try { 44 | replaceRegex = new RegExp(value, "g"); 45 | } catch (error) { 46 | alert("Error creating regex for preset text replacement, no replacements will be performed."); 47 | replaceRegex = null; 48 | } 49 | }, 50 | }); 51 | 52 | const drawNodeWidgets = LGraphCanvas.prototype.drawNodeWidgets 53 | LGraphCanvas.prototype.drawNodeWidgets = function(node) { 54 | const c = LiteGraph.WIDGET_BGCOLOR; 55 | try { 56 | if(node[MISSING]) { 57 | LiteGraph.WIDGET_BGCOLOR = "red" 58 | } 59 | return drawNodeWidgets.apply(this, arguments); 60 | } finally { 61 | LiteGraph.WIDGET_BGCOLOR = c; 62 | } 63 | } 64 | }, 65 | registerCustomNodes() { 66 | class PresetTextNode extends LiteGraph.LGraphNode { 67 | constructor() { 68 | super(); 69 | this.title = "Preset Text 🐍"; 70 | this.isVirtualNode = true; 71 | this.serialize_widgets = true; 72 | this.addOutput("text", "STRING"); 73 | 74 | const widget = this.addWidget("combo", "value", presets[0].name, () => {}, { 75 | values: presets.map((p) => p.name), 76 | }); 77 | this.addWidget("button", "Manage", "Manage", () => { 78 | const container = document.createElement("div"); 79 | Object.assign(container.style, { 80 | display: "grid", 81 | gridTemplateColumns: "1fr 1fr", 82 | gap: "10px", 83 | }); 84 | 85 | const addNew = document.createElement("button"); 86 | addNew.textContent = "Add New"; 87 | addNew.classList.add("pysssss-presettext-addnew"); 88 | Object.assign(addNew.style, { 89 | fontSize: "13px", 90 | gridColumn: "1 / 3", 91 | color: "dodgerblue", 92 | width: "auto", 93 | textAlign: "center", 94 | }); 95 | addNew.onclick = () => { 96 | addRow({ name: "", value: "" }); 97 | }; 98 | container.append(addNew); 99 | 100 | function addRow(p) { 101 | const name = document.createElement("input"); 102 | const nameLbl = document.createElement("label"); 103 | name.value = p.name; 104 | nameLbl.textContent = "Name:"; 105 | nameLbl.append(name); 106 | 107 | const value = document.createElement("input"); 108 | const valueLbl = document.createElement("label"); 109 | value.value = p.value; 110 | valueLbl.textContent = "Value:"; 111 | valueLbl.append(value); 112 | 113 | addNew.before(nameLbl, valueLbl); 114 | } 115 | for (const p of presets) { 116 | addRow(p); 117 | } 118 | 119 | const help = document.createElement("span"); 120 | help.textContent = "To remove a preset set the name or value to blank"; 121 | help.style.gridColumn = "1 / 3"; 122 | container.append(help); 123 | 124 | dialog.show(""); 125 | dialog.textElement.append(container); 126 | }); 127 | 128 | const dialog = new app.ui.dialog.constructor(); 129 | dialog.element.classList.add("comfy-settings"); 130 | 131 | const closeButton = dialog.element.querySelector("button"); 132 | closeButton.textContent = "CANCEL"; 133 | const saveButton = document.createElement("button"); 134 | saveButton.textContent = "SAVE"; 135 | saveButton.onclick = function () { 136 | const inputs = dialog.element.querySelectorAll("input"); 137 | const p = []; 138 | for (let i = 0; i < inputs.length; i += 2) { 139 | const n = inputs[i]; 140 | const v = inputs[i + 1]; 141 | if (!n.value.trim() || !v.value.trim()) { 142 | continue; 143 | } 144 | p.push({ name: n.value, value: v.value }); 145 | } 146 | 147 | widget.options.values = p.map((p) => p.name); 148 | if (!widget.options.values.includes(widget.value)) { 149 | widget.value = widget.options.values[0]; 150 | } 151 | 152 | presets = p; 153 | localStorage.setItem(id, JSON.stringify(presets)); 154 | 155 | dialog.close(); 156 | }; 157 | 158 | closeButton.before(saveButton); 159 | 160 | this.applyToGraph = function (workflow) { 161 | // For each output link copy our value over the original widget value 162 | if (this.outputs[0].links && this.outputs[0].links.length) { 163 | for (const l of this.outputs[0].links) { 164 | const link_info = app.graph.links[l]; 165 | const outNode = app.graph.getNodeById(link_info.target_id); 166 | const outIn = outNode && outNode.inputs && outNode.inputs[link_info.target_slot]; 167 | if (outIn.widget) { 168 | const w = outNode.widgets.find((w) => w.name === outIn.widget.name); 169 | if (!w) continue; 170 | const preset = presets.find((p) => p.name === widget.value); 171 | if (!preset) { 172 | this[MISSING] = true; 173 | app.graph.setDirtyCanvas(true, true); 174 | const msg = `Preset text '${widget.value}' not found. Please fix this and queue again.`; 175 | throw new Error(msg); 176 | } 177 | delete this[MISSING]; 178 | w.value = preset.value; 179 | } 180 | } 181 | } 182 | }; 183 | } 184 | } 185 | 186 | LiteGraph.registerNodeType( 187 | "PresetText|pysssss", 188 | Object.assign(PresetTextNode, { 189 | title: "Preset Text 🐍", 190 | }) 191 | ); 192 | 193 | PresetTextNode.category = "utils"; 194 | }, 195 | nodeCreated(node) { 196 | if (node.widgets) { 197 | // Locate dynamic prompt text widgets 198 | const widgets = node.widgets.filter((n) => n.type === "customtext" || n.type === "text"); 199 | for (const widget of widgets) { 200 | const callbacks = [ 201 | () => { 202 | let prompt = widget.value; 203 | if (replaceRegex && typeof prompt.replace !== 'undefined') { 204 | prompt = prompt.replace(replaceRegex, (match, p1, p2, index, text, groups) => { 205 | if (!groups.replace || !groups.id) return match; // No match, bad regex? 206 | 207 | const preset = presets.find((p) => p.name.replaceAll(/\s/g, "-") === groups.id); 208 | if (!preset) return match; // Invalid name 209 | 210 | const pos = match.indexOf(groups.replace); 211 | return match.substring(0, pos) + preset.value; 212 | }); 213 | } 214 | return prompt; 215 | }, 216 | ]; 217 | let inheritedSerializeValue = widget.serializeValue || null; 218 | 219 | let called = false; 220 | const serializeValue = async (workflowNode, widgetIndex) => { 221 | const origWidgetValue = widget.value; 222 | if (called) return origWidgetValue; 223 | called = true; 224 | 225 | let allCallbacks = [...callbacks]; 226 | if (inheritedSerializeValue) { 227 | allCallbacks.push(inheritedSerializeValue) 228 | } 229 | let valueIsUndefined = false; 230 | 231 | for (const cb of allCallbacks) { 232 | let value = await cb(workflowNode, widgetIndex); 233 | // Need to check the callback return value before it is set on widget.value as it coerces it to a string (even for undefined) 234 | if (value === undefined) valueIsUndefined = true; 235 | widget.value = value; 236 | } 237 | 238 | const prompt = valueIsUndefined ? undefined : widget.value; 239 | widget.value = origWidgetValue; 240 | 241 | called = false; 242 | 243 | return prompt; 244 | }; 245 | 246 | Object.defineProperty(widget, "serializeValue", { 247 | get() { 248 | return serializeValue; 249 | }, 250 | set(cb) { 251 | inheritedSerializeValue = cb; 252 | }, 253 | }); 254 | } 255 | } 256 | }, 257 | }); 258 | -------------------------------------------------------------------------------- /py/math_expression.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import math 3 | import random 4 | import operator as op 5 | 6 | # Hack: string type that is always equal in not equal comparisons 7 | class AnyType(str): 8 | def __ne__(self, __value: object) -> bool: 9 | return False 10 | 11 | 12 | # Our any instance wants to be a wildcard string 13 | any = AnyType("*") 14 | 15 | operators = { 16 | ast.Add: op.add, 17 | ast.Sub: op.sub, 18 | ast.Mult: op.mul, 19 | ast.Div: op.truediv, 20 | ast.FloorDiv: op.floordiv, 21 | ast.Pow: op.pow, 22 | ast.BitXor: op.xor, 23 | ast.USub: op.neg, 24 | ast.Mod: op.mod, 25 | ast.BitAnd: op.and_, 26 | ast.BitOr: op.or_, 27 | ast.Invert: op.invert, 28 | ast.And: lambda a, b: 1 if a and b else 0, 29 | ast.Or: lambda a, b: 1 if a or b else 0, 30 | ast.Not: lambda a: 0 if a else 1, 31 | ast.RShift: op.rshift, 32 | ast.LShift: op.lshift 33 | } 34 | 35 | # TODO: restructure args to provide more info, generate hint based on args to save duplication 36 | functions = { 37 | "round": { 38 | "args": (1, 2), 39 | "call": lambda a, b = None: round(a, b), 40 | "hint": "number, dp? = 0" 41 | }, 42 | "ceil": { 43 | "args": (1, 1), 44 | "call": lambda a: math.ceil(a), 45 | "hint": "number" 46 | }, 47 | "floor": { 48 | "args": (1, 1), 49 | "call": lambda a: math.floor(a), 50 | "hint": "number" 51 | }, 52 | "min": { 53 | "args": (2, None), 54 | "call": lambda *args: min(*args), 55 | "hint": "...numbers" 56 | }, 57 | "max": { 58 | "args": (2, None), 59 | "call": lambda *args: max(*args), 60 | "hint": "...numbers" 61 | }, 62 | "randomint": { 63 | "args": (2, 2), 64 | "call": lambda a, b: random.randint(a, b), 65 | "hint": "min, max" 66 | }, 67 | "randomchoice": { 68 | "args": (2, None), 69 | "call": lambda *args: random.choice(args), 70 | "hint": "...numbers" 71 | }, 72 | "sqrt": { 73 | "args": (1, 1), 74 | "call": lambda a: math.sqrt(a), 75 | "hint": "number" 76 | }, 77 | "int": { 78 | "args": (1, 1), 79 | "call": lambda a = None: int(a), 80 | "hint": "number" 81 | }, 82 | "iif": { 83 | "args": (3, 3), 84 | "call": lambda a, b, c = None: b if a else c, 85 | "hint": "value, truepart, falsepart" 86 | }, 87 | } 88 | 89 | autocompleteWords = list({ 90 | "text": x, 91 | "value": f"{x}()", 92 | "showValue": False, 93 | "hint": f"{functions[x]['hint']}", 94 | "caretOffset": -1 95 | } for x in functions.keys()) 96 | 97 | 98 | class MathExpression: 99 | 100 | @classmethod 101 | def INPUT_TYPES(cls): 102 | return { 103 | "required": { 104 | "expression": ("STRING", {"multiline": True, "dynamicPrompts": False, "pysssss.autocomplete": { 105 | "words": autocompleteWords, 106 | "separator": "" 107 | }}), 108 | }, 109 | "optional": { 110 | "a": (any, ), 111 | "b": (any,), 112 | "c": (any, ), 113 | }, 114 | "hidden": {"extra_pnginfo": "EXTRA_PNGINFO", 115 | "prompt": "PROMPT"}, 116 | } 117 | 118 | RETURN_TYPES = ("INT", "FLOAT", ) 119 | FUNCTION = "evaluate" 120 | CATEGORY = "utils" 121 | OUTPUT_NODE = True 122 | 123 | @classmethod 124 | def IS_CHANGED(s, expression, **kwargs): 125 | if "random" in expression: 126 | return float("nan") 127 | return expression 128 | 129 | def get_widget_value(self, extra_pnginfo, prompt, node_name, widget_name): 130 | workflow = extra_pnginfo["workflow"] if "workflow" in extra_pnginfo else { "nodes": [] } 131 | node_id = None 132 | for node in workflow["nodes"]: 133 | name = node["type"] 134 | if "properties" in node: 135 | if "Node name for S&R" in node["properties"]: 136 | name = node["properties"]["Node name for S&R"] 137 | if name == node_name: 138 | node_id = node["id"] 139 | break 140 | if "title" in node: 141 | name = node["title"] 142 | if name == node_name: 143 | node_id = node["id"] 144 | break 145 | if node_id is not None: 146 | values = prompt[str(node_id)] 147 | if "inputs" in values: 148 | if widget_name in values["inputs"]: 149 | value = values["inputs"][widget_name] 150 | if isinstance(value, list): 151 | raise ValueError("Converted widgets are not supported via named reference, use the inputs instead.") 152 | return value 153 | raise NameError(f"Widget not found: {node_name}.{widget_name}") 154 | raise NameError(f"Node not found: {node_name}.{widget_name}") 155 | 156 | def get_size(self, target, property): 157 | if isinstance(target, dict) and "samples" in target: 158 | # Latent 159 | if property == "width": 160 | return target["samples"].shape[3] * 8 161 | return target["samples"].shape[2] * 8 162 | else: 163 | # Image 164 | if property == "width": 165 | return target.shape[2] 166 | return target.shape[1] 167 | 168 | def evaluate(self, expression, prompt, extra_pnginfo={}, a=None, b=None, c=None): 169 | expression = expression.replace('\n', ' ').replace('\r', '') 170 | node = ast.parse(expression, mode='eval').body 171 | 172 | lookup = {"a": a, "b": b, "c": c} 173 | 174 | def eval_op(node, l, r): 175 | l = eval_expr(l) 176 | r = eval_expr(r) 177 | l = l if isinstance(l, int) else float(l) 178 | r = r if isinstance(r, int) else float(r) 179 | return operators[type(node.op)](l, r) 180 | 181 | def eval_expr(node): 182 | if isinstance(node, ast.Constant) or isinstance(node, ast.Num): 183 | return node.n 184 | elif isinstance(node, ast.BinOp): 185 | return eval_op(node, node.left, node.right) 186 | elif isinstance(node, ast.BoolOp): 187 | return eval_op(node, node.values[0], node.values[1]) 188 | elif isinstance(node, ast.UnaryOp): 189 | return operators[type(node.op)](eval_expr(node.operand)) 190 | elif isinstance(node, ast.Attribute): 191 | if node.value.id in lookup: 192 | if node.attr == "width" or node.attr == "height": 193 | return self.get_size(lookup[node.value.id], node.attr) 194 | 195 | return self.get_widget_value(extra_pnginfo, prompt, node.value.id, node.attr) 196 | elif isinstance(node, ast.Name): 197 | if node.id in lookup: 198 | val = lookup[node.id] 199 | if isinstance(val, (int, float, complex)): 200 | return val 201 | else: 202 | raise TypeError( 203 | f"Compex types (LATENT/IMAGE) need to reference their width/height, e.g. {node.id}.width") 204 | raise NameError(f"Name not found: {node.id}") 205 | elif isinstance(node, ast.Call): 206 | if node.func.id in functions: 207 | fn = functions[node.func.id] 208 | l = len(node.args) 209 | if l < fn["args"][0] or (fn["args"][1] is not None and l > fn["args"][1]): 210 | if fn["args"][1] is None: 211 | toErr = " or more" 212 | else: 213 | toErr = f" to {fn['args'][1]}" 214 | raise SyntaxError( 215 | f"Invalid function call: {node.func.id} requires {fn['args'][0]}{toErr} arguments") 216 | args = [] 217 | for arg in node.args: 218 | args.append(eval_expr(arg)) 219 | return fn["call"](*args) 220 | raise NameError(f"Invalid function call: {node.func.id}") 221 | elif isinstance(node, ast.Compare): 222 | l = eval_expr(node.left) 223 | r = eval_expr(node.comparators[0]) 224 | if isinstance(node.ops[0], ast.Eq): 225 | return 1 if l == r else 0 226 | if isinstance(node.ops[0], ast.NotEq): 227 | return 1 if l != r else 0 228 | if isinstance(node.ops[0], ast.Gt): 229 | return 1 if l > r else 0 230 | if isinstance(node.ops[0], ast.GtE): 231 | return 1 if l >= r else 0 232 | if isinstance(node.ops[0], ast.Lt): 233 | return 1 if l < r else 0 234 | if isinstance(node.ops[0], ast.LtE): 235 | return 1 if l <= r else 0 236 | raise NotImplementedError( 237 | "Operator " + node.ops[0].__class__.__name__ + " not supported.") 238 | else: 239 | raise TypeError(node) 240 | 241 | r = eval_expr(node) 242 | return {"ui": {"value": [r]}, "result": (int(r), float(r),)} 243 | 244 | 245 | NODE_CLASS_MAPPINGS = { 246 | "MathExpression|pysssss": MathExpression, 247 | } 248 | 249 | NODE_DISPLAY_NAME_MAPPINGS = { 250 | "MathExpression|pysssss": "Math Expression 🐍", 251 | } 252 | 253 | -------------------------------------------------------------------------------- /pysssss.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import json 4 | import shutil 5 | import inspect 6 | import aiohttp 7 | from server import PromptServer 8 | from tqdm import tqdm 9 | 10 | config = None 11 | 12 | 13 | def is_logging_enabled(): 14 | config = get_extension_config() 15 | if "logging" not in config: 16 | return False 17 | return config["logging"] 18 | 19 | 20 | def log(message, type=None, always=False, name=None): 21 | if not always and not is_logging_enabled(): 22 | return 23 | 24 | if type is not None: 25 | message = f"[{type}] {message}" 26 | 27 | if name is None: 28 | name = get_extension_config()["name"] 29 | 30 | print(f"(pysssss:{name}) {message}") 31 | 32 | 33 | def get_ext_dir(subpath=None, mkdir=False): 34 | dir = os.path.dirname(__file__) 35 | if subpath is not None: 36 | dir = os.path.join(dir, subpath) 37 | 38 | dir = os.path.abspath(dir) 39 | 40 | if mkdir and not os.path.exists(dir): 41 | os.makedirs(dir) 42 | return dir 43 | 44 | 45 | def get_comfy_dir(subpath=None, mkdir=False): 46 | dir = os.path.dirname(inspect.getfile(PromptServer)) 47 | if subpath is not None: 48 | dir = os.path.join(dir, subpath) 49 | 50 | dir = os.path.abspath(dir) 51 | 52 | if mkdir and not os.path.exists(dir): 53 | os.makedirs(dir) 54 | return dir 55 | 56 | 57 | def get_web_ext_dir(): 58 | config = get_extension_config() 59 | name = config["name"] 60 | dir = get_comfy_dir("web/extensions/pysssss") 61 | if not os.path.exists(dir): 62 | os.makedirs(dir) 63 | dir = os.path.join(dir, name) 64 | return dir 65 | 66 | 67 | def get_extension_config(reload=False): 68 | global config 69 | if reload == False and config is not None: 70 | return config 71 | 72 | config_path = get_ext_dir("pysssss.json") 73 | default_config_path = get_ext_dir("pysssss.default.json") 74 | if not os.path.exists(config_path): 75 | if os.path.exists(default_config_path): 76 | shutil.copy(default_config_path, config_path) 77 | if not os.path.exists(config_path): 78 | log(f"Failed to create config at {config_path}", type="ERROR", always=True, name="???") 79 | print(f"Extension path: {get_ext_dir()}") 80 | return {"name": "Unknown", "version": -1} 81 | 82 | else: 83 | log("Missing pysssss.default.json, this extension may not work correctly. Please reinstall the extension.", 84 | type="ERROR", always=True, name="???") 85 | print(f"Extension path: {get_ext_dir()}") 86 | return {"name": "Unknown", "version": -1} 87 | 88 | with open(config_path, "r") as f: 89 | config = json.loads(f.read()) 90 | return config 91 | 92 | 93 | def link_js(src, dst): 94 | src = os.path.abspath(src) 95 | dst = os.path.abspath(dst) 96 | if os.name == "nt": 97 | try: 98 | import _winapi 99 | _winapi.CreateJunction(src, dst) 100 | return True 101 | except: 102 | pass 103 | try: 104 | os.symlink(src, dst) 105 | return True 106 | except: 107 | import logging 108 | logging.exception('') 109 | return False 110 | 111 | 112 | def is_junction(path): 113 | if os.name != "nt": 114 | return False 115 | try: 116 | return bool(os.readlink(path)) 117 | except OSError: 118 | return False 119 | 120 | 121 | def install_js(): 122 | src_dir = get_ext_dir("web/js") 123 | if not os.path.exists(src_dir): 124 | log("No JS") 125 | return 126 | 127 | should_install = should_install_js() 128 | if should_install: 129 | log("it looks like you're running an old version of ComfyUI that requires manual setup of web files, it is recommended you update your installation.", "warning", True) 130 | dst_dir = get_web_ext_dir() 131 | linked = os.path.islink(dst_dir) or is_junction(dst_dir) 132 | if linked or os.path.exists(dst_dir): 133 | if linked: 134 | if should_install: 135 | log("JS already linked") 136 | else: 137 | os.unlink(dst_dir) 138 | log("JS unlinked, PromptServer will serve extension") 139 | elif not should_install: 140 | shutil.rmtree(dst_dir) 141 | log("JS deleted, PromptServer will serve extension") 142 | return 143 | 144 | if not should_install: 145 | log("JS skipped, PromptServer will serve extension") 146 | return 147 | 148 | if link_js(src_dir, dst_dir): 149 | log("JS linked") 150 | return 151 | 152 | log("Copying JS files") 153 | shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True) 154 | 155 | 156 | def should_install_js(): 157 | return not hasattr(PromptServer.instance, "supports") or "custom_nodes_from_web" not in PromptServer.instance.supports 158 | 159 | 160 | def init(check_imports=None): 161 | log("Init") 162 | 163 | if check_imports is not None: 164 | import importlib.util 165 | for imp in check_imports: 166 | spec = importlib.util.find_spec(imp) 167 | if spec is None: 168 | log(f"{imp} is required, please check requirements are installed.", 169 | type="ERROR", always=True) 170 | return False 171 | 172 | install_js() 173 | return True 174 | 175 | 176 | def get_async_loop(): 177 | loop = None 178 | try: 179 | loop = asyncio.get_event_loop() 180 | except: 181 | loop = asyncio.new_event_loop() 182 | asyncio.set_event_loop(loop) 183 | return loop 184 | 185 | 186 | def get_http_session(): 187 | loop = get_async_loop() 188 | return aiohttp.ClientSession(loop=loop) 189 | 190 | 191 | async def download(url, stream, update_callback=None, session=None): 192 | close_session = False 193 | if session is None: 194 | close_session = True 195 | session = get_http_session() 196 | try: 197 | async with session.get(url) as response: 198 | size = int(response.headers.get('content-length', 0)) or None 199 | 200 | with tqdm( 201 | unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1], total=size, 202 | ) as progressbar: 203 | perc = 0 204 | async for chunk in response.content.iter_chunked(2048): 205 | stream.write(chunk) 206 | progressbar.update(len(chunk)) 207 | if update_callback is not None and progressbar.total is not None and progressbar.total != 0: 208 | last = perc 209 | perc = round(progressbar.n / progressbar.total, 2) 210 | if perc != last: 211 | last = perc 212 | await update_callback(perc) 213 | finally: 214 | if close_session and session is not None: 215 | await session.close() 216 | 217 | 218 | async def download_to_file(url, destination, update_callback=None, is_ext_subpath=True, session=None): 219 | if is_ext_subpath: 220 | destination = get_ext_dir(destination) 221 | with open(destination, mode='wb') as f: 222 | download(url, f, update_callback, session) 223 | 224 | 225 | def wait_for_async(async_fn, loop=None): 226 | res = [] 227 | 228 | async def run_async(): 229 | r = await async_fn() 230 | res.append(r) 231 | 232 | if loop is None: 233 | try: 234 | loop = asyncio.get_event_loop() 235 | except: 236 | loop = asyncio.new_event_loop() 237 | asyncio.set_event_loop(loop) 238 | 239 | loop.run_until_complete(run_async()) 240 | 241 | return res[0] 242 | 243 | 244 | def update_node_status(client_id, node, text, progress=None): 245 | if client_id is None: 246 | client_id = PromptServer.instance.client_id 247 | 248 | if client_id is None: 249 | return 250 | 251 | PromptServer.instance.send_sync("pysssss/update_status", { 252 | "node": node, 253 | "progress": progress, 254 | "text": text 255 | }, client_id) 256 | 257 | 258 | async def update_node_status_async(client_id, node, text, progress=None): 259 | if client_id is None: 260 | client_id = PromptServer.instance.client_id 261 | 262 | if client_id is None: 263 | return 264 | 265 | await PromptServer.instance.send("pysssss/update_status", { 266 | "node": node, 267 | "progress": progress, 268 | "text": text 269 | }, client_id) 270 | 271 | 272 | def get_config_value(key, default=None, throw=False): 273 | split = key.split(".") 274 | obj = get_extension_config() 275 | for s in split: 276 | if s in obj: 277 | obj = obj[s] 278 | else: 279 | if throw: 280 | raise KeyError("Configuration key missing: " + key) 281 | else: 282 | return default 283 | return obj 284 | 285 | 286 | def is_inside_dir(root_dir, check_path): 287 | root_dir = os.path.abspath(root_dir) 288 | if not os.path.isabs(check_path): 289 | check_path = os.path.abspath(os.path.join(root_dir, check_path)) 290 | return os.path.commonpath([check_path, root_dir]) == root_dir 291 | 292 | 293 | def get_child_dir(root_dir, child_path, throw_if_outside=True): 294 | child_path = os.path.abspath(os.path.join(root_dir, child_path)) 295 | if is_inside_dir(root_dir, child_path): 296 | return child_path 297 | if throw_if_outside: 298 | raise NotADirectoryError( 299 | "Saving outside the target folder is not allowed.") 300 | return None 301 | -------------------------------------------------------------------------------- /web/js/workflows.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { api } from "../../../scripts/api.js"; 3 | import { $el } from "../../../scripts/ui.js"; 4 | 5 | // Adds workflow management 6 | // Original implementation by https://github.com/i-h4x 7 | // Thanks for permission to reimplement as an extension 8 | 9 | const style = ` 10 | #comfy-save-button, #comfy-load-button { 11 | position: relative; 12 | overflow: hidden; 13 | } 14 | .pysssss-workflow-arrow { 15 | position: absolute; 16 | top: 0; 17 | bottom: 0; 18 | right: 0; 19 | font-size: 12px; 20 | display: flex; 21 | align-items: center; 22 | width: 24px; 23 | justify-content: center; 24 | background: rgba(255,255,255,0.1); 25 | } 26 | .pysssss-workflow-arrow:after { 27 | content: "▼"; 28 | } 29 | .pysssss-workflow-arrow:hover { 30 | filter: brightness(1.6); 31 | background-color: var(--comfy-menu-bg); 32 | } 33 | .pysssss-workflow-load .litemenu-entry:not(.has_submenu):before, 34 | .pysssss-workflow-load ~ .litecontextmenu .litemenu-entry:not(.has_submenu):before { 35 | content: "🎛️"; 36 | padding-right: 5px; 37 | } 38 | .pysssss-workflow-load .litemenu-entry.has_submenu:before, 39 | .pysssss-workflow-load ~ .litecontextmenu .litemenu-entry.has_submenu:before { 40 | content: "📂"; 41 | padding-right: 5px; 42 | position: relative; 43 | top: -1px; 44 | } 45 | .pysssss-workflow-popup ~ .litecontextmenu { 46 | transform: scale(1.3); 47 | } 48 | `; 49 | 50 | async function getWorkflows() { 51 | const response = await api.fetchApi("/pysssss/workflows", { cache: "no-store" }); 52 | return await response.json(); 53 | } 54 | 55 | async function getWorkflow(name) { 56 | const response = await api.fetchApi(`/pysssss/workflows/${encodeURIComponent(name)}`, { cache: "no-store" }); 57 | return await response.json(); 58 | } 59 | 60 | async function saveWorkflow(name, workflow, overwrite) { 61 | try { 62 | const response = await api.fetchApi("/pysssss/workflows", { 63 | method: "POST", 64 | headers: { 65 | "Content-Type": "application/json", 66 | }, 67 | body: JSON.stringify({ name, workflow, overwrite }), 68 | }); 69 | if (response.status === 201) { 70 | return true; 71 | } 72 | if (response.status === 409) { 73 | return false; 74 | } 75 | throw new Error(response.statusText); 76 | } catch (error) { 77 | console.error(error); 78 | } 79 | } 80 | 81 | class PysssssWorkflows { 82 | async load() { 83 | this.workflows = await getWorkflows(); 84 | if(this.workflows.length) { 85 | this.workflows.sort(); 86 | } 87 | this.loadMenu.style.display = this.workflows.length ? "flex" : "none"; 88 | } 89 | 90 | getMenuOptions(callback) { 91 | const menu = []; 92 | const directories = new Map(); 93 | for (const workflow of this.workflows || []) { 94 | const path = workflow.split("/"); 95 | let parent = menu; 96 | let currentPath = ""; 97 | for (let i = 0; i < path.length - 1; i++) { 98 | currentPath += "/" + path[i]; 99 | let newParent = directories.get(currentPath); 100 | if (!newParent) { 101 | newParent = { 102 | title: path[i], 103 | has_submenu: true, 104 | submenu: { 105 | options: [], 106 | }, 107 | }; 108 | parent.push(newParent); 109 | newParent = newParent.submenu.options; 110 | directories.set(currentPath, newParent); 111 | } 112 | parent = newParent; 113 | } 114 | parent.push({ 115 | title: path[path.length - 1], 116 | callback: () => callback(workflow), 117 | }); 118 | } 119 | return menu; 120 | } 121 | 122 | constructor() { 123 | function addWorkflowMenu(type, getOptions) { 124 | return $el("div.pysssss-workflow-arrow", { 125 | parent: document.getElementById(`comfy-${type}-button`), 126 | onclick: (e) => { 127 | e.preventDefault(); 128 | e.stopPropagation(); 129 | 130 | LiteGraph.closeAllContextMenus(); 131 | const menu = new LiteGraph.ContextMenu( 132 | getOptions(), 133 | { 134 | event: e, 135 | scale: 1.3, 136 | }, 137 | window 138 | ); 139 | menu.root.classList.add("pysssss-workflow-popup"); 140 | menu.root.classList.add(`pysssss-workflow-${type}`); 141 | }, 142 | }); 143 | } 144 | 145 | this.loadMenu = addWorkflowMenu("load", () => 146 | this.getMenuOptions(async (workflow) => { 147 | const json = await getWorkflow(workflow); 148 | app.loadGraphData(json); 149 | }) 150 | ); 151 | addWorkflowMenu("save", () => { 152 | return [ 153 | { 154 | title: "Save as", 155 | callback: () => { 156 | let filename = prompt("Enter filename", this.workflowName || "workflow"); 157 | if (filename) { 158 | if (!filename.toLowerCase().endsWith(".json")) { 159 | filename += ".json"; 160 | } 161 | 162 | this.workflowName = filename; 163 | 164 | const json = JSON.stringify(app.graph.serialize(), null, 2); // convert the data to a JSON string 165 | const blob = new Blob([json], { type: "application/json" }); 166 | const url = URL.createObjectURL(blob); 167 | const a = $el("a", { 168 | href: url, 169 | download: filename, 170 | style: { display: "none" }, 171 | parent: document.body, 172 | }); 173 | a.click(); 174 | setTimeout(function () { 175 | a.remove(); 176 | window.URL.revokeObjectURL(url); 177 | }, 0); 178 | } 179 | }, 180 | }, 181 | { 182 | title: "Save to workflows", 183 | callback: async () => { 184 | const name = prompt("Enter filename", this.workflowName || "workflow"); 185 | if (name) { 186 | this.workflowName = name; 187 | 188 | const data = app.graph.serialize(); 189 | if (!(await saveWorkflow(name, data))) { 190 | if (confirm("A workspace with this name already exists, do you want to overwrite it?")) { 191 | await saveWorkflow(name, app.graph.serialize(), true); 192 | } else { 193 | return; 194 | } 195 | } 196 | await this.load(); 197 | } 198 | }, 199 | }, 200 | ]; 201 | }); 202 | this.load(); 203 | 204 | const handleFile = app.handleFile; 205 | const self = this; 206 | app.handleFile = function (file) { 207 | if (file?.name?.endsWith(".json")) { 208 | self.workflowName = file.name; 209 | } else { 210 | self.workflowName = null; 211 | } 212 | return handleFile.apply(this, arguments); 213 | }; 214 | } 215 | } 216 | 217 | const refreshComboInNodes = app.refreshComboInNodes; 218 | let workflows; 219 | 220 | async function sendToWorkflow(img, workflow) { 221 | const graph = !workflow ? app.graph.serialize() : await getWorkflow(workflow); 222 | const nodes = graph.nodes.filter((n) => n.type === "LoadImage"); 223 | let targetNode; 224 | if (nodes.length === 0) { 225 | alert("To send the image to another workflow, that workflow must have a LoadImage node."); 226 | return; 227 | } else if (nodes.length > 1) { 228 | targetNode = nodes.find((n) => n.title?.toLowerCase().includes("input")); 229 | if (!targetNode) { 230 | targetNode = nodes[0]; 231 | alert( 232 | "The target workflow has multiple LoadImage nodes, include 'input' in the name of the one you want to use. The first one will be used here." 233 | ); 234 | } 235 | } else { 236 | targetNode = nodes[0]; 237 | } 238 | 239 | const blob = await (await fetch(img.src)).blob(); 240 | const name = 241 | (workflow || "sendtoworkflow").replace(/\//g, "_") + 242 | "-" + 243 | +new Date() + 244 | new URLSearchParams(img.src.split("?")[1]).get("filename"); 245 | const body = new FormData(); 246 | body.append("image", new File([blob], name)); 247 | 248 | const resp = await api.fetchApi("/upload/image", { 249 | method: "POST", 250 | body, 251 | }); 252 | 253 | if (resp.status === 200) { 254 | await refreshComboInNodes.call(app); 255 | targetNode.widgets_values[0] = name; 256 | app.loadGraphData(graph); 257 | app.graph.getNodeById(targetNode.id); 258 | } else { 259 | alert(resp.status + " - " + resp.statusText); 260 | } 261 | } 262 | 263 | app.registerExtension({ 264 | name: "pysssss.Workflows", 265 | init() { 266 | $el("style", { 267 | textContent: style, 268 | parent: document.head, 269 | }); 270 | }, 271 | 272 | async refreshComboInNodes() { 273 | workflows.load() 274 | }, 275 | 276 | async setup() { 277 | workflows = new PysssssWorkflows(); 278 | 279 | const comfyDefault = "[ComfyUI Default]"; 280 | const defaultWorkflow = app.ui.settings.addSetting({ 281 | id: "pysssss.Workflows.Default", 282 | name: "🐍 Default Workflow", 283 | defaultValue: comfyDefault, 284 | type: "combo", 285 | options: (value) => 286 | [comfyDefault, ...workflows.workflows].map((m) => ({ 287 | value: m, 288 | text: m, 289 | selected: m === value, 290 | })), 291 | }); 292 | 293 | document.getElementById("comfy-load-default-button").onclick = async function () { 294 | if ( 295 | localStorage["Comfy.Settings.Comfy.ConfirmClear"] === "false" || 296 | confirm(`Load default workflow (${defaultWorkflow.value})?`) 297 | ) { 298 | if (defaultWorkflow.value === comfyDefault) { 299 | app.loadGraphData(); 300 | } else { 301 | const json = await getWorkflow(defaultWorkflow.value); 302 | app.loadGraphData(json); 303 | } 304 | } 305 | }; 306 | }, 307 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 308 | const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; 309 | nodeType.prototype.getExtraMenuOptions = function (_, options) { 310 | const r = getExtraMenuOptions?.apply?.(this, arguments); 311 | let img; 312 | if (this.imageIndex != null) { 313 | // An image is selected so select that 314 | img = this.imgs[this.imageIndex]; 315 | } else if (this.overIndex != null) { 316 | // No image is selected but one is hovered 317 | img = this.imgs[this.overIndex]; 318 | } 319 | 320 | if (img) { 321 | let pos = options.findIndex((o) => o.content === "Save Image"); 322 | if (pos === -1) { 323 | pos = 0; 324 | } else { 325 | pos++; 326 | } 327 | 328 | options.splice(pos, 0, { 329 | content: "Send to workflow", 330 | has_submenu: true, 331 | submenu: { 332 | options: [ 333 | { callback: () => sendToWorkflow(img), title: "[Current workflow]" }, 334 | ...workflows.getMenuOptions(sendToWorkflow.bind(null, img)), 335 | ], 336 | }, 337 | }); 338 | } 339 | 340 | return r; 341 | }; 342 | }, 343 | }); 344 | -------------------------------------------------------------------------------- /web/js/common/modelInfoDialog.js: -------------------------------------------------------------------------------- 1 | import { $el, ComfyDialog } from "../../../../scripts/ui.js"; 2 | import { api } from "../../../../scripts/api.js"; 3 | import { addStylesheet } from "./utils.js"; 4 | 5 | addStylesheet(import.meta.url); 6 | 7 | class MetadataDialog extends ComfyDialog { 8 | constructor() { 9 | super(); 10 | 11 | this.element.classList.add("pysssss-model-metadata"); 12 | } 13 | show(metadata) { 14 | super.show( 15 | $el( 16 | "div", 17 | Object.keys(metadata).map((k) => 18 | $el("div", [ 19 | $el("label", { textContent: k }), 20 | $el("span", { textContent: typeof metadata[k] === "object" ? JSON.stringify(metadata[k]) : metadata[k] }), 21 | ]) 22 | ) 23 | ) 24 | ); 25 | } 26 | } 27 | 28 | export class ModelInfoDialog extends ComfyDialog { 29 | constructor(name, node) { 30 | super(); 31 | this.name = name; 32 | this.node = node; 33 | this.element.classList.add("pysssss-model-info"); 34 | } 35 | 36 | get customNotes() { 37 | return this.metadata["pysssss.notes"]; 38 | } 39 | 40 | set customNotes(v) { 41 | this.metadata["pysssss.notes"] = v; 42 | } 43 | 44 | get hash() { 45 | return this.metadata["pysssss.sha256"]; 46 | } 47 | 48 | async show(type, value) { 49 | this.type = type; 50 | 51 | const req = api.fetchApi("/pysssss/metadata/" + encodeURIComponent(`${type}/${value}`)); 52 | this.info = $el("div", { style: { flex: "auto" } }); 53 | this.img = $el("img", { style: { display: "none" } }); 54 | this.imgWrapper = $el("div.pysssss-preview", [this.img]); 55 | this.main = $el("main", { style: { display: "flex" } }, [this.info, this.imgWrapper]); 56 | this.content = $el("div.pysssss-model-content", [$el("h2", { textContent: this.name }), this.main]); 57 | 58 | const loading = $el("div", { textContent: "ℹ️ Loading...", parent: this.content }); 59 | 60 | super.show(this.content); 61 | 62 | this.metadata = await (await req).json(); 63 | this.viewMetadata.style.cursor = this.viewMetadata.style.opacity = ""; 64 | this.viewMetadata.removeAttribute("disabled"); 65 | 66 | loading.remove(); 67 | this.addInfo(); 68 | } 69 | 70 | createButtons() { 71 | const btns = super.createButtons(); 72 | this.viewMetadata = $el("button", { 73 | type: "button", 74 | textContent: "View raw metadata", 75 | disabled: "disabled", 76 | style: { 77 | opacity: 0.5, 78 | cursor: "not-allowed", 79 | }, 80 | onclick: (e) => { 81 | if (this.metadata) { 82 | new MetadataDialog().show(this.metadata); 83 | } 84 | }, 85 | }); 86 | 87 | btns.unshift(this.viewMetadata); 88 | return btns; 89 | } 90 | 91 | getNoteInfo() { 92 | function parseNote() { 93 | if (!this.customNotes) return []; 94 | 95 | let notes = []; 96 | // Extract links from notes 97 | const r = new RegExp("(\\bhttps?:\\/\\/[^\\s]+)", "g"); 98 | let end = 0; 99 | let m; 100 | do { 101 | m = r.exec(this.customNotes); 102 | let pos; 103 | let fin = 0; 104 | if (m) { 105 | pos = m.index; 106 | fin = m.index + m[0].length; 107 | } else { 108 | pos = this.customNotes.length; 109 | } 110 | 111 | let pre = this.customNotes.substring(end, pos); 112 | if (pre) { 113 | pre = pre.replaceAll("\n", "
"); 114 | notes.push( 115 | $el("span", { 116 | innerHTML: pre, 117 | }) 118 | ); 119 | } 120 | if (m) { 121 | notes.push( 122 | $el("a", { 123 | href: m[0], 124 | textContent: m[0], 125 | target: "_blank", 126 | }) 127 | ); 128 | } 129 | 130 | end = fin; 131 | } while (m); 132 | return notes; 133 | } 134 | 135 | let textarea; 136 | let notesContainer; 137 | const editText = "✏️ Edit"; 138 | const edit = $el("a", { 139 | textContent: editText, 140 | href: "#", 141 | style: { 142 | float: "right", 143 | color: "greenyellow", 144 | textDecoration: "none", 145 | }, 146 | onclick: async (e) => { 147 | e.preventDefault(); 148 | 149 | if (textarea) { 150 | this.customNotes = textarea.value; 151 | 152 | const resp = await api.fetchApi("/pysssss/metadata/notes/" + encodeURIComponent(`${this.type}/${this.name}`), { 153 | method: "POST", 154 | body: this.customNotes, 155 | }); 156 | 157 | if (resp.status !== 200) { 158 | console.error(resp); 159 | alert(`Error saving notes (${req.status}) ${req.statusText}`); 160 | return; 161 | } 162 | 163 | e.target.textContent = editText; 164 | textarea.remove(); 165 | textarea = null; 166 | 167 | notesContainer.replaceChildren(...parseNote.call(this)); 168 | this.node?.["pysssss.updateExamples"]?.(); 169 | } else { 170 | e.target.textContent = "💾 Save"; 171 | textarea = $el("textarea", { 172 | style: { 173 | width: "100%", 174 | minWidth: "200px", 175 | minHeight: "50px", 176 | }, 177 | textContent: this.customNotes, 178 | }); 179 | e.target.after(textarea); 180 | notesContainer.replaceChildren(); 181 | textarea.style.height = Math.min(textarea.scrollHeight, 300) + "px"; 182 | } 183 | }, 184 | }); 185 | 186 | notesContainer = $el("div.pysssss-model-notes", parseNote.call(this)); 187 | return $el( 188 | "div", 189 | { 190 | style: { display: "contents" }, 191 | }, 192 | [edit, notesContainer] 193 | ); 194 | } 195 | 196 | addInfo() { 197 | const usageHint = this.metadata["modelspec.usage_hint"]; 198 | if (usageHint) { 199 | this.addInfoEntry("Usage Hint", usageHint); 200 | } 201 | this.addInfoEntry("Notes", this.getNoteInfo()); 202 | } 203 | 204 | addInfoEntry(name, value) { 205 | return $el( 206 | "p", 207 | { 208 | parent: this.info, 209 | }, 210 | [ 211 | typeof name === "string" ? $el("label", { textContent: name + ": " }) : name, 212 | typeof value === "string" ? $el("span", { textContent: value }) : value, 213 | ] 214 | ); 215 | } 216 | 217 | async getCivitaiDetails() { 218 | const req = await fetch("https://civitai.com/api/v1/model-versions/by-hash/" + this.hash); 219 | if (req.status === 200) { 220 | return await req.json(); 221 | } else if (req.status === 404) { 222 | throw new Error("Model not found"); 223 | } else { 224 | throw new Error(`Error loading info (${req.status}) ${req.statusText}`); 225 | } 226 | } 227 | 228 | addCivitaiInfo() { 229 | const promise = this.getCivitaiDetails(); 230 | const content = $el("span", { textContent: "ℹ️ Loading..." }); 231 | 232 | this.addInfoEntry( 233 | $el("label", [ 234 | $el("img", { 235 | style: { 236 | width: "18px", 237 | position: "relative", 238 | top: "3px", 239 | margin: "0 5px 0 0", 240 | }, 241 | src: "https://civitai.com/favicon.ico", 242 | }), 243 | $el("span", { textContent: "Civitai: " }), 244 | ]), 245 | content 246 | ); 247 | 248 | return promise 249 | .then((info) => { 250 | content.replaceChildren( 251 | $el("a", { 252 | href: "https://civitai.com/models/" + info.modelId, 253 | textContent: "View " + info.model.name, 254 | target: "_blank", 255 | }) 256 | ); 257 | 258 | const allPreviews = info.images?.filter((i) => i.type === "image"); 259 | const previews = allPreviews?.filter((i) => i.nsfwLevel <= ModelInfoDialog.nsfwLevel); 260 | if (previews?.length) { 261 | let previewIndex = 0; 262 | let preview; 263 | const updatePreview = () => { 264 | preview = previews[previewIndex]; 265 | this.img.src = preview.url; 266 | }; 267 | 268 | updatePreview(); 269 | this.img.style.display = ""; 270 | 271 | this.img.title = `${previews.length} previews.`; 272 | if (allPreviews.length !== previews.length) { 273 | this.img.title += ` ${allPreviews.length - previews.length} images hidden due to NSFW level.`; 274 | } 275 | 276 | this.imgSave = $el("button", { 277 | textContent: "Use as preview", 278 | parent: this.imgWrapper, 279 | onclick: async () => { 280 | // Convert the preview to a blob 281 | const blob = await (await fetch(this.img.src)).blob(); 282 | 283 | // Store it in temp 284 | const name = "temp_preview." + new URL(this.img.src).pathname.split(".")[1]; 285 | const body = new FormData(); 286 | body.append("image", new File([blob], name)); 287 | body.append("overwrite", "true"); 288 | body.append("type", "temp"); 289 | 290 | const resp = await api.fetchApi("/upload/image", { 291 | method: "POST", 292 | body, 293 | }); 294 | 295 | if (resp.status !== 200) { 296 | console.error(resp); 297 | alert(`Error saving preview (${req.status}) ${req.statusText}`); 298 | return; 299 | } 300 | 301 | // Use as preview 302 | await api.fetchApi("/pysssss/save/" + encodeURIComponent(`${this.type}/${this.name}`), { 303 | method: "POST", 304 | body: JSON.stringify({ 305 | filename: name, 306 | type: "temp", 307 | }), 308 | headers: { 309 | "content-type": "application/json", 310 | }, 311 | }); 312 | app.refreshComboInNodes(); 313 | }, 314 | }); 315 | 316 | $el("button", { 317 | textContent: "Show metadata", 318 | parent: this.imgWrapper, 319 | onclick: async () => { 320 | if (preview.meta && Object.keys(preview.meta).length) { 321 | new MetadataDialog().show(preview.meta); 322 | } else { 323 | alert("No image metadata found"); 324 | } 325 | }, 326 | }); 327 | 328 | const addNavButton = (icon, direction) => { 329 | $el("button.pysssss-preview-nav", { 330 | textContent: icon, 331 | parent: this.imgWrapper, 332 | onclick: async () => { 333 | previewIndex += direction; 334 | if (previewIndex < 0) { 335 | previewIndex = previews.length - 1; 336 | } else if (previewIndex >= previews.length) { 337 | previewIndex = 0; 338 | } 339 | updatePreview(); 340 | }, 341 | }); 342 | }; 343 | 344 | if (previews.length > 1) { 345 | addNavButton("‹", -1); 346 | addNavButton("›", 1); 347 | } 348 | } else if (info.images?.length) { 349 | $el("span", { style: { opacity: 0.6 }, textContent: "⚠️ All images hidden due to NSFW level setting.", parent: this.imgWrapper }); 350 | } 351 | 352 | return info; 353 | }) 354 | .catch((err) => { 355 | content.textContent = "⚠️ " + err.message; 356 | }); 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ComfyUI-Custom-Scripts 2 | 3 | ### ⚠️ While these extensions work for the most part, i'm very busy at the moment and so unable to keep on top of everything here, thanks for your patience! 4 | 5 | # Installation 6 | 7 | 1. Clone the repository: 8 | `git clone https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git` 9 | to your ComfyUI `custom_nodes` directory 10 | 11 | The script will then automatically install all custom scripts and nodes. 12 | It will attempt to use symlinks and junctions to prevent having to copy files and keep them up to date. 13 | 14 | - For uninstallation: 15 | - Delete the cloned repo in `custom_nodes` 16 | - Ensure `web/extensions/pysssss/CustomScripts` has also been removed 17 | 18 | # Update 19 | 1. Navigate to the cloned repo e.g. `custom_nodes/ComfyUI-Custom-Scripts` 20 | 2. `git pull` 21 | 22 | # Features 23 | 24 | ## Autocomplete 25 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/b5971135-414f-4f4e-a6cf-2650dc01085f) 26 | Provides embedding and custom word autocomplete. You can view embedding details by clicking on the info icon on the list. 27 | Define your list of custom words via the settings. 28 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/160ef61c-7d7e-49d0-b60f-5a1501b74c9d) 29 | You can quickly default to danbooru tags using the Load button, or load/manage other custom word lists. 30 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/cc180b35-5f45-442f-9285-3ddf3fa320d0) 31 | 32 | ## Auto Arrange Graph 33 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/04b06081-ca6f-4c0f-8584-d0a157c36747) 34 | Adds a menu option to auto arrange the graph in order of execution, this makes very wide graphs! 35 | 36 | ## Always Snap to Grid 37 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/66f36d1f-e579-4959-9880-9a9624922e3a) 38 | Adds a setting to make moving nodes always snap to grid. 39 | 40 | ## [Testing] "Better" Loader Lists 41 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/664caa71-f25f-4a96-a04a-1466d6b2b8b4) 42 | Adds custom Lora and Checkpoint loader nodes, these have the ability to show preview images, just place a png or jpg next to the file and it'll display in the list on hover (e.g. sdxl.safetensors and sdxl.png). 43 | Optionally enable subfolders via the settings: 44 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/e15b5e83-4f9d-4d57-8324-742bedf75439) 45 | Adds an "examples" widget to load sample prompts, triggerwords, etc: 46 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ad1751e4-4c85-42e7-9490-e94fb1cbc8e7) 47 | These should be stored in a folder matching the name of the model, e.g. if it is `loras/add_detail.safetensors` put your files in as `loras/add_detail/*.txt` 48 | To quickly save a generated image as the preview to use for the model, you can right click on an image on a node, and select Save as Preview and choose the model to save the preview for: 49 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/9fa8e9db-27b3-45cb-85c2-0860a238fd3a) 50 | 51 | ## Checkpoint/LoRA/Embedding Info 52 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/6b67bf40-ee17-4fa6-a0c1-7947066bafc2) 53 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/32405df6-b367-404f-a5df-2d4347089a9e) 54 | Adds "View Info" menu option to view details about the selected LoRA or Checkpoint. To view embedding details, click the info button when using embedding autocomplete. 55 | 56 | ## Constrain Image 57 | Adds a node for resizing an image to a max & min size optionally cropping if required. 58 | 59 | ## Custom Colors 60 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/fa7883f3-f81c-49f6-9ab6-9526e4debab6) 61 | Adds a custom color picker to nodes & groups 62 | 63 | ## Favicon Status 64 | ![image](https://user-images.githubusercontent.com/125205205/230171227-31f061a6-6324-4976-bed9-723a87500cf3.png) 65 | ![image](https://user-images.githubusercontent.com/125205205/230171445-c7202a45-b511-4d69-87fa-945ad44c063f.png) 66 | Adds a favicon and title to the window, favicon changes color while generating and the window title includes the number of prompts in the queue 67 | 68 | ## Image Feed 69 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/caea0d48-85b9-4ca9-9771-5c795db35fbc) 70 | Adds a panel showing images that have been generated in the current session, you can control the direction that images are added and the position of the panel via the ComfyUI settings screen and the size of the panel and the images via the sliders at the top of the panel. 71 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ca093d38-41a3-4647-9223-5bd0b9ee4f1e) 72 | 73 | ## KSampler (Advanced) denoise helper 74 | Provides a simple method to set custom denoise on the advanced sampler 75 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/42946bd8-0078-4c7a-bfe9-7adb1382b5e2) 76 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/7cfccb22-f155-4848-934b-a2b2a6efe16f) 77 | 78 | ## Math Expression 79 | Allows for evaluating complex expressions using values from the graph. You can input `INT`, `FLOAT`, `IMAGE` and `LATENT` values. 80 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/1593edde-67b8-45d8-88cb-e75f52dba039) 81 | Other nodes values can be referenced via the `Node name for S&R` via the `Properties` menu item on a node, or the node title. 82 | Supported operators: `+ - * /` (basic ops) `//` (floor division) `**` (power) `^` (xor) `%` (mod) 83 | Supported functions `floor(num, dp?)` `floor(num)` `ceil(num)` `randomint(min,max)` 84 | If using a `LATENT` or `IMAGE` you can get the dimensions using `a.width` or `a.height` where `a` is the input name. 85 | 86 | ## Node Finder 87 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/177d2b67-acbc-4ec3-ab31-7c295a98c194) 88 | Adds a menu item for following/jumping to the executing node, and a menu to quickly go to a node of a specific type. 89 | 90 | ## Preset Text 91 | ![image](https://user-images.githubusercontent.com/125205205/230173939-08459efc-785b-46da-93d1-b02f0300c6f4.png) 92 | Adds a node that lets you save and use text presets (e.g. for your 'normal' negatives) 93 | 94 | ## Quick Nodes 95 | ![image](https://user-images.githubusercontent.com/125205205/230174266-5232831a-a03b-4bf7-bc8b-c45466a0bc64.png) 96 | Adds various menu items to some nodes for quickly setting up common parts of graphs 97 | 98 | ## Play Sound 99 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/9bcf9fb3-5898-4432-a974-fb1e17d3b7e8) 100 | Plays a sound when the node is executed, either after each prompt or only when the queue is empty for queuing multiple prompts. 101 | You can customize the sound by replacing the mp3 file `ComfyUI/custom_nodes/ComfyUI-Custom-Scripts/web/js/assets/notify.mp3` 102 | 103 | ## System Notification 104 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/30354775/993fd783-5cd6-4779-aa97-173bc06cc405) 105 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/30354775/e45227fb-5714-4f45-b96b-6601902ef6e2) 106 | 107 | Sends a system notification via the browser when the node is executed, either after each prompt or only when the queue is empty for queuing multiple prompts. 108 | 109 | ## [WIP] Repeater 110 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ec0dac25-14e4-4d44-b975-52193656709d) 111 | Node allows you to either create a list of N repeats of the input node, or create N outputs from the input node. 112 | You can optionally decide if you want to reuse the input node, or create a new instance each time (e.g. a Checkpoint Loader would want to be re-used, but a random number would want to be unique) 113 | TODO: Type safety on the wildcard outputs to require match with input 114 | 115 | ## Show Text 116 | ![image](https://user-images.githubusercontent.com/125205205/230174888-c004fd48-da78-4de9-81c2-93a866fcfcd1.png) 117 | Takes input from a node that produces a string and displays it, useful for things like interrogator, prompt generators, etc. 118 | 119 | ## Show Image on Menu 120 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/b6ab58f2-583b-448c-bcfc-f93f5cdab0fc) 121 | Shows the current generating image on the menu at the bottom, you can disable this via the settings menu. 122 | 123 | ## String Function 124 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/01107137-8a93-4765-bae0-fcc110a09091) 125 | Supports appending and replacing text 126 | `tidy_tags` will add commas between parts when in `append` mode. 127 | `replace` mode supports regex replace by using `/your regex here/` and you can reference capturing groups using `\number` e.g. `\1` 128 | 129 | ## Touch Support 130 | Provides basic support for touch screen devices, its not perfect but better than nothing 131 | 132 | ## Widget Defaults 133 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/3d675032-2b19-4da8-a7d7-fa2d7c555daa) 134 | Allows you to specify default values for widgets when adding new nodes, the values are configured via the settings menu 135 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/7b57a3d8-98d3-46e9-9b33-6645c0da41e7) 136 | 137 | ## Workflows 138 | Adds options to the menu for saving + loading workflows: 139 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/7b5a3012-4c59-47c6-8eea-85cf534403ea) 140 | 141 | ## Workflow Images 142 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/06453fd2-c020-46ee-a7db-2b8bf5bcba7e) 143 | Adds menu options for importing/exporting the graph as SVG and PNG showing a view of the nodes 144 | 145 | ## (Testing) Reroute Primitive 146 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/8b870eef-d572-43f9-b394-cfa7abbd2f98) Provides a node that allows rerouting primitives. 147 | The node can also be collapsed to a single point that you can drag around. 148 | ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/a9bd0112-cf8f-44f3-af6d-f9a8fed152a7) 149 | Warning: Don't use normal reroutes or primitives with these nodes, it isn't tested and this node replaces their functionality. 150 | 151 |
152 |
153 | 154 | 155 | ## WD14 Tagger 156 | Moved to: https://github.com/pythongosssss/ComfyUI-WD14-Tagger 157 | 158 | ## ~~Lock Nodes & Groups~~ 159 | This is now a standard feature of ComfyUI 160 | ~~Adds a lock option to nodes & groups that prevents you from moving them until unlocked~~ 161 | -------------------------------------------------------------------------------- /web/js/modelInfo.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { api } from "../../../scripts/api.js"; 3 | import { $el } from "../../../scripts/ui.js"; 4 | import { ModelInfoDialog } from "./common/modelInfoDialog.js"; 5 | 6 | const MAX_TAGS = 500; 7 | const NsfwLevel = { 8 | PG: 1, 9 | PG13: 2, 10 | R: 4, 11 | X: 8, 12 | XXX: 16, 13 | Blocked: 32, 14 | }; 15 | 16 | export class LoraInfoDialog extends ModelInfoDialog { 17 | getTagFrequency() { 18 | if (!this.metadata.ss_tag_frequency) return []; 19 | 20 | const datasets = JSON.parse(this.metadata.ss_tag_frequency); 21 | const tags = {}; 22 | for (const setName in datasets) { 23 | const set = datasets[setName]; 24 | for (const t in set) { 25 | if (t in tags) { 26 | tags[t] += set[t]; 27 | } else { 28 | tags[t] = set[t]; 29 | } 30 | } 31 | } 32 | 33 | return Object.entries(tags).sort((a, b) => b[1] - a[1]); 34 | } 35 | 36 | getResolutions() { 37 | let res = []; 38 | if (this.metadata.ss_bucket_info) { 39 | const parsed = JSON.parse(this.metadata.ss_bucket_info); 40 | if (parsed?.buckets) { 41 | for (const { resolution, count } of Object.values(parsed.buckets)) { 42 | res.push([count, `${resolution.join("x")} * ${count}`]); 43 | } 44 | } 45 | } 46 | res = res.sort((a, b) => b[0] - a[0]).map((a) => a[1]); 47 | let r = this.metadata.ss_resolution; 48 | if (r) { 49 | const s = r.split(","); 50 | const w = s[0].replace("(", ""); 51 | const h = s[1].replace(")", ""); 52 | res.push(`${w.trim()}x${h.trim()} (Base res)`); 53 | } else if ((r = this.metadata["modelspec.resolution"])) { 54 | res.push(r + " (Base res"); 55 | } 56 | if (!res.length) { 57 | res.push("⚠️ Unknown"); 58 | } 59 | return res; 60 | } 61 | 62 | getTagList(tags) { 63 | return tags.map((t) => 64 | $el( 65 | "li.pysssss-model-tag", 66 | { 67 | dataset: { 68 | tag: t[0], 69 | }, 70 | $: (el) => { 71 | el.onclick = () => { 72 | el.classList.toggle("pysssss-model-tag--selected"); 73 | }; 74 | }, 75 | }, 76 | [ 77 | $el("p", { 78 | textContent: t[0], 79 | }), 80 | $el("span", { 81 | textContent: t[1], 82 | }), 83 | ] 84 | ) 85 | ); 86 | } 87 | 88 | addTags() { 89 | let tags = this.getTagFrequency(); 90 | if (!tags?.length) { 91 | tags = this.metadata["modelspec.tags"]?.split(",").map((t) => [t.trim(), 1]); 92 | } 93 | let hasMore; 94 | if (tags?.length) { 95 | const c = tags.length; 96 | let list; 97 | if (c > MAX_TAGS) { 98 | tags = tags.slice(0, MAX_TAGS); 99 | hasMore = $el("p", [ 100 | $el("span", { textContent: `⚠️ Only showing first ${MAX_TAGS} tags ` }), 101 | $el("a", { 102 | href: "#", 103 | textContent: `Show all ${c}`, 104 | onclick: () => { 105 | list.replaceChildren(...this.getTagList(this.getTagFrequency())); 106 | hasMore.remove(); 107 | }, 108 | }), 109 | ]); 110 | } 111 | list = $el("ol.pysssss-model-tags-list", this.getTagList(tags)); 112 | this.tags = $el("div", [list]); 113 | } else { 114 | this.tags = $el("p", { textContent: "⚠️ No tag frequency metadata found" }); 115 | } 116 | 117 | this.content.append(this.tags); 118 | 119 | if (hasMore) { 120 | this.content.append(hasMore); 121 | } 122 | } 123 | 124 | addExample(title, value, name) { 125 | const textArea = $el("textarea", { 126 | textContent: value, 127 | style: { 128 | whiteSpace: "pre-wrap", 129 | margin: "10px 0", 130 | color: "#fff", 131 | background: "#222", 132 | padding: "5px", 133 | borderRadius: "5px", 134 | maxHeight: "250px", 135 | overflow: "auto", 136 | display: "block", 137 | border: "none", 138 | width: "calc(100% - 10px)", 139 | }, 140 | }); 141 | $el( 142 | "p", 143 | { 144 | parent: this.content, 145 | textContent: `${title}: `, 146 | }, 147 | [ 148 | textArea, 149 | $el("button", { 150 | onclick: async () => { 151 | await this.saveAsExample(textArea.value, `${name}.txt`); 152 | }, 153 | textContent: "Save as Example", 154 | style: { 155 | fontSize: "14px", 156 | }, 157 | }), 158 | $el("hr"), 159 | ] 160 | ); 161 | } 162 | 163 | async addInfo() { 164 | this.addInfoEntry("Name", this.metadata.ss_output_name || "⚠️ Unknown"); 165 | this.addInfoEntry("Base Model", this.metadata.ss_sd_model_name || "⚠️ Unknown"); 166 | this.addInfoEntry("Clip Skip", this.metadata.ss_clip_skip || "⚠️ Unknown"); 167 | 168 | this.addInfoEntry( 169 | "Resolution", 170 | $el( 171 | "select", 172 | this.getResolutions().map((r) => $el("option", { textContent: r })) 173 | ) 174 | ); 175 | 176 | super.addInfo(); 177 | const p = this.addCivitaiInfo(); 178 | this.addTags(); 179 | 180 | const info = await p; 181 | this.addExample("Trained Words", info?.trainedWords?.join(", ") ?? "", "trainedwords"); 182 | 183 | const triggerPhrase = this.metadata["modelspec.trigger_phrase"]; 184 | if (triggerPhrase) { 185 | this.addExample("Trigger Phrase", triggerPhrase, "triggerphrase"); 186 | } 187 | 188 | $el("div", { 189 | parent: this.content, 190 | innerHTML: info?.description ?? this.metadata["modelspec.description"] ?? "[No description provided]", 191 | style: { 192 | maxHeight: "250px", 193 | overflow: "auto", 194 | }, 195 | }); 196 | } 197 | 198 | async saveAsExample(example, name = "example.txt") { 199 | if (!example.length) { 200 | return; 201 | } 202 | try { 203 | name = prompt("Enter example name", name); 204 | if (!name) return; 205 | 206 | await api.fetchApi("/pysssss/examples/" + encodeURIComponent(`${this.type}/${this.name}`), { 207 | method: "POST", 208 | body: JSON.stringify({ 209 | name, 210 | example, 211 | }), 212 | headers: { 213 | "content-type": "application/json", 214 | }, 215 | }); 216 | this.node?.["pysssss.updateExamples"]?.(); 217 | alert("Saved!"); 218 | } catch (error) { 219 | console.error(error); 220 | alert("Error saving: " + error); 221 | } 222 | } 223 | 224 | createButtons() { 225 | const btns = super.createButtons(); 226 | function tagsToCsv(tags) { 227 | return tags.map((el) => el.dataset.tag).join(", "); 228 | } 229 | function copyTags(e, tags) { 230 | const textarea = $el("textarea", { 231 | parent: document.body, 232 | style: { 233 | position: "fixed", 234 | }, 235 | textContent: tagsToCsv(tags), 236 | }); 237 | textarea.select(); 238 | try { 239 | document.execCommand("copy"); 240 | if (!e.target.dataset.text) { 241 | e.target.dataset.text = e.target.textContent; 242 | } 243 | e.target.textContent = "Copied " + tags.length + " tags"; 244 | setTimeout(() => { 245 | e.target.textContent = e.target.dataset.text; 246 | }, 1000); 247 | } catch (ex) { 248 | prompt("Copy to clipboard: Ctrl+C, Enter", text); 249 | } finally { 250 | document.body.removeChild(textarea); 251 | } 252 | } 253 | 254 | btns.unshift( 255 | $el("button", { 256 | type: "button", 257 | textContent: "Save Selected as Example", 258 | onclick: async (e) => { 259 | const tags = tagsToCsv([...this.tags.querySelectorAll(".pysssss-model-tag--selected")]); 260 | await this.saveAsExample(tags); 261 | }, 262 | }), 263 | $el("button", { 264 | type: "button", 265 | textContent: "Copy Selected", 266 | onclick: (e) => { 267 | copyTags(e, [...this.tags.querySelectorAll(".pysssss-model-tag--selected")]); 268 | }, 269 | }), 270 | $el("button", { 271 | type: "button", 272 | textContent: "Copy All", 273 | onclick: (e) => { 274 | copyTags(e, [...this.tags.querySelectorAll(".pysssss-model-tag")]); 275 | }, 276 | }) 277 | ); 278 | 279 | return btns; 280 | } 281 | } 282 | 283 | class CheckpointInfoDialog extends ModelInfoDialog { 284 | async addInfo() { 285 | super.addInfo(); 286 | const info = await this.addCivitaiInfo(); 287 | if (info) { 288 | this.addInfoEntry("Base Model", info.baseModel || "⚠️ Unknown"); 289 | 290 | $el("div", { 291 | parent: this.content, 292 | innerHTML: info.description, 293 | style: { 294 | maxHeight: "250px", 295 | overflow: "auto", 296 | }, 297 | }); 298 | } 299 | } 300 | } 301 | 302 | const lookups = {}; 303 | 304 | function addInfoOption(node, type, infoClass, widgetNamePattern, opts) { 305 | const widgets = widgetNamePattern 306 | ? node.widgets.filter((w) => w.name === widgetNamePattern || w.name.match(`^${widgetNamePattern}$`)) 307 | : [node.widgets[0]]; 308 | for (const widget of widgets) { 309 | let value = widget.value; 310 | if (value?.content) { 311 | value = value.content; 312 | } 313 | if (!value || value === "None") { 314 | return; 315 | } 316 | let optName; 317 | const split = value.split(/[.\\/]/); 318 | optName = split[split.length - 2]; 319 | opts.push({ 320 | content: optName, 321 | callback: async () => { 322 | new infoClass(value, node).show(type, value); 323 | }, 324 | }); 325 | } 326 | } 327 | 328 | function addTypeOptions(node, typeName, options) { 329 | const type = typeName.toLowerCase() + "s"; 330 | const values = lookups[typeName][node.type]; 331 | if (!values) return; 332 | 333 | const widgets = Object.keys(values); 334 | const cls = type === "loras" ? LoraInfoDialog : CheckpointInfoDialog; 335 | 336 | const opts = []; 337 | for (const w of widgets) { 338 | addInfoOption(node, type, cls, w, opts); 339 | } 340 | 341 | if (!opts.length) return; 342 | 343 | if (opts.length === 1) { 344 | opts[0].content = `View ${typeName} info...`; 345 | options.unshift(opts[0]); 346 | } else { 347 | options.unshift({ 348 | title: `View ${typeName} info...`, 349 | has_submenu: true, 350 | submenu: { 351 | options: opts, 352 | }, 353 | }); 354 | } 355 | } 356 | 357 | app.registerExtension({ 358 | name: "pysssss.ModelInfo", 359 | setup() { 360 | const addSetting = (type, defaultValue) => { 361 | app.ui.settings.addSetting({ 362 | id: `pysssss.ModelInfo.${type}Nodes`, 363 | name: `🐍 Model Info - ${type} Nodes/Widgets`, 364 | type: "text", 365 | defaultValue, 366 | tooltip: `Comma separated list of NodeTypeName or NodeTypeName.WidgetName that contain ${type} node names that should have the View Info option available.\nIf no widget name is specifed the first widget will be used. Regex matches (e.g. NodeName..*lora_\\d+) are supported in the widget name.`, 367 | onChange(value) { 368 | lookups[type] = value.split(",").reduce((p, n) => { 369 | n = n.trim(); 370 | const pos = n.indexOf("."); 371 | const split = pos === -1 ? [n] : [n.substring(0, pos), n.substring(pos + 1)]; 372 | p[split[0]] ??= {}; 373 | p[split[0]][split[1] ?? ""] = true; 374 | return p; 375 | }, {}); 376 | }, 377 | }); 378 | }; 379 | addSetting( 380 | "Lora", 381 | ["LoraLoader.lora_name", "LoraLoader|pysssss", "LoraLoaderModelOnly.lora_name", "LoRA Stacker.lora_name.*"].join(",") 382 | ); 383 | addSetting( 384 | "Checkpoint", 385 | ["CheckpointLoader.ckpt_name", "CheckpointLoaderSimple", "CheckpointLoader|pysssss", "Efficient Loader", "Eff. Loader SDXL"].join(",") 386 | ); 387 | 388 | app.ui.settings.addSetting({ 389 | id: `pysssss.ModelInfo.NsfwLevel`, 390 | name: `🐍 Model Info - Image Preview Max NSFW Level`, 391 | type: "combo", 392 | defaultValue: "PG13", 393 | options: Object.keys(NsfwLevel), 394 | tooltip: `Hides preview images that are tagged as a higher NSFW level`, 395 | onChange(value) { 396 | ModelInfoDialog.nsfwLevel = NsfwLevel[value] ?? NsfwLevel.PG; 397 | }, 398 | }); 399 | }, 400 | beforeRegisterNodeDef(nodeType) { 401 | const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; 402 | nodeType.prototype.getExtraMenuOptions = function (_, options) { 403 | if (this.widgets) { 404 | for (const type in lookups) { 405 | addTypeOptions(this, type, options); 406 | } 407 | } 408 | 409 | return getExtraMenuOptions?.apply(this, arguments); 410 | }; 411 | }, 412 | }); 413 | -------------------------------------------------------------------------------- /web/js/reroutePrimitive.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { ComfyWidgets } from "../../../scripts/widgets.js"; 3 | 4 | const REROUTE_PRIMITIVE = "ReroutePrimitive|pysssss"; 5 | const MULTI_PRIMITIVE = "MultiPrimitive|pysssss"; 6 | const LAST_TYPE = Symbol("LastType"); 7 | 8 | app.registerExtension({ 9 | name: "pysssss.ReroutePrimitive", 10 | init() { 11 | // On graph configure, fire onGraphConfigured to create widgets 12 | const graphConfigure = LGraph.prototype.configure; 13 | LGraph.prototype.configure = function () { 14 | const r = graphConfigure.apply(this, arguments); 15 | for (const n of app.graph._nodes) { 16 | if (n.type === REROUTE_PRIMITIVE) { 17 | n.onGraphConfigured(); 18 | } 19 | } 20 | 21 | return r; 22 | }; 23 | 24 | // Hide this node as it is no longer supported 25 | const getNodeTypesCategories = LiteGraph.getNodeTypesCategories; 26 | LiteGraph.getNodeTypesCategories = function() { 27 | return getNodeTypesCategories.apply(this, arguments).filter(c => !c.startsWith("__hidden__")); 28 | } 29 | 30 | const graphToPrompt = app.graphToPrompt; 31 | app.graphToPrompt = async function () { 32 | const res = await graphToPrompt.apply(this, arguments); 33 | 34 | const multiOutputs = []; 35 | for (const nodeId in res.output) { 36 | const output = res.output[nodeId]; 37 | if (output.class_type === MULTI_PRIMITIVE) { 38 | multiOutputs.push({ id: nodeId, inputs: output.inputs }); 39 | } 40 | } 41 | 42 | function permute(outputs) { 43 | function generatePermutations(inputs, currentIndex, currentPermutation, result) { 44 | if (currentIndex === inputs.length) { 45 | result.push({ ...currentPermutation }); 46 | return; 47 | } 48 | 49 | const input = inputs[currentIndex]; 50 | 51 | for (const k in input) { 52 | currentPermutation[currentIndex] = input[k]; 53 | generatePermutations(inputs, currentIndex + 1, currentPermutation, result); 54 | } 55 | } 56 | 57 | const inputs = outputs.map((output) => output.inputs); 58 | const result = []; 59 | const current = new Array(inputs.length); 60 | 61 | generatePermutations(inputs, 0, current, result); 62 | 63 | return outputs.map((output, index) => ({ 64 | ...output, 65 | inputs: result.reduce((p, permutation) => { 66 | const count = Object.keys(p).length; 67 | p["value" + (count || "")] = permutation[index]; 68 | return p; 69 | }, {}), 70 | })); 71 | } 72 | 73 | const permutations = permute(multiOutputs); 74 | for (let i = 0; i < permutations.length; i++) { 75 | res.output[multiOutputs[i].id].inputs = permutations[i].inputs; 76 | } 77 | 78 | return res; 79 | }; 80 | }, 81 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 82 | function addOutputHandler() { 83 | // Finds the first non reroute output node down the chain 84 | nodeType.prototype.getFirstReroutedOutput = function (slot) { 85 | if (nodeData.name === MULTI_PRIMITIVE) { 86 | slot = 0; 87 | } 88 | const links = this.outputs[slot].links; 89 | if (!links) return null; 90 | 91 | const search = []; 92 | for (const l of links) { 93 | const link = app.graph.links[l]; 94 | if (!link) continue; 95 | 96 | const node = app.graph.getNodeById(link.target_id); 97 | if (node.type !== REROUTE_PRIMITIVE && node.type !== MULTI_PRIMITIVE) { 98 | return { node, link }; 99 | } 100 | search.push({ node, link }); 101 | } 102 | 103 | for (const { link, node } of search) { 104 | const r = node.getFirstReroutedOutput(link.target_slot); 105 | if (r) { 106 | return r; 107 | } 108 | } 109 | }; 110 | } 111 | 112 | if (nodeData.name === REROUTE_PRIMITIVE) { 113 | const configure = nodeType.prototype.configure || LGraphNode.prototype.configure; 114 | const onConnectionsChange = nodeType.prototype.onConnectionsChange; 115 | const onAdded = nodeType.prototype.onAdded; 116 | 117 | nodeType.title_mode = LiteGraph.NO_TITLE; 118 | 119 | function hasAnyInput(node) { 120 | for (const input of node.inputs) { 121 | if (input.link) { 122 | return true; 123 | } 124 | } 125 | return false; 126 | } 127 | 128 | // Remove input text 129 | nodeType.prototype.onAdded = function () { 130 | onAdded?.apply(this, arguments); 131 | this.inputs[0].label = ""; 132 | this.outputs[0].label = "value"; 133 | this.setSize(this.computeSize()); 134 | }; 135 | 136 | // Restore any widgets 137 | nodeType.prototype.onGraphConfigured = function () { 138 | if (hasAnyInput(this)) return; 139 | 140 | const outputNode = this.getFirstReroutedOutput(0); 141 | if (outputNode) { 142 | this.checkPrimitiveWidget(outputNode); 143 | } 144 | }; 145 | 146 | // Check if we need to create (or remove) a widget on the node 147 | nodeType.prototype.checkPrimitiveWidget = function ({ node, link }) { 148 | let widgetType = link.type; 149 | let targetLabel = widgetType; 150 | const input = node.inputs[link.target_slot]; 151 | if (input.widget?.config?.[0] instanceof Array) { 152 | targetLabel = input.widget.name; 153 | widgetType = "COMBO"; 154 | } 155 | 156 | if (widgetType in ComfyWidgets) { 157 | if (!this.widgets?.length) { 158 | let v; 159 | if (this.widgets_values?.length) { 160 | v = this.widgets_values[0]; 161 | } 162 | let config = [link.type, {}]; 163 | if (input.widget?.config) { 164 | config = input.widget.config; 165 | } 166 | const { widget } = ComfyWidgets[widgetType](this, "value", config, app); 167 | if (v !== undefined && (!this[LAST_TYPE] || this[LAST_TYPE] === widgetType)) { 168 | widget.value = v; 169 | } 170 | this[LAST_TYPE] = widgetType; 171 | } 172 | } else if (this.widgets) { 173 | this.widgets.length = 0; 174 | } 175 | 176 | return targetLabel; 177 | }; 178 | 179 | // Finds all input nodes from the current reroute 180 | nodeType.prototype.getReroutedInputs = function (slot) { 181 | let nodes = [{ node: this }]; 182 | let node = this; 183 | while (node?.type === REROUTE_PRIMITIVE) { 184 | const input = node.inputs[slot]; 185 | if (input.link) { 186 | const link = app.graph.links[input.link]; 187 | node = app.graph.getNodeById(link.origin_id); 188 | slot = link.origin_slot; 189 | nodes.push({ 190 | node, 191 | link, 192 | }); 193 | } else { 194 | node = null; 195 | } 196 | } 197 | 198 | return nodes; 199 | }; 200 | 201 | addOutputHandler(); 202 | 203 | // Update the type of all reroutes in a chain 204 | nodeType.prototype.changeRerouteType = function (slot, type, label) { 205 | const color = LGraphCanvas.link_type_colors[type]; 206 | const output = this.outputs[slot]; 207 | this.inputs[slot].label = " "; 208 | output.label = label || (type === "*" ? "value" : type); 209 | output.type = type; 210 | 211 | // Process all linked outputs 212 | for (const linkId of output.links || []) { 213 | const link = app.graph.links[linkId]; 214 | if (!link) continue; 215 | link.color = color; 216 | const node = app.graph.getNodeById(link.target_id); 217 | if (node.changeRerouteType) { 218 | // Recursively update reroutes 219 | node.changeRerouteType(link.target_slot, type, label); 220 | } else { 221 | // Validate links to 'real' nodes 222 | const theirType = node.inputs[link.target_slot].type; 223 | if (theirType !== type && theirType !== "*") { 224 | node.disconnectInput(link.target_slot); 225 | } 226 | } 227 | } 228 | 229 | if (this.inputs[slot].link) { 230 | const link = app.graph.links[this.inputs[slot].link]; 231 | if (link) link.color = color; 232 | } 233 | }; 234 | 235 | // Override configure so we can flag that we are configuring to avoid link validation breaking 236 | let configuring = false; 237 | nodeType.prototype.configure = function () { 238 | configuring = true; 239 | const r = configure?.apply(this, arguments); 240 | configuring = false; 241 | 242 | return r; 243 | }; 244 | 245 | Object.defineProperty(nodeType, "title_mode", { 246 | get() { 247 | return app.canvas.current_node?.widgets?.length ? LiteGraph.NORMAL_TITLE : LiteGraph.NO_TITLE; 248 | }, 249 | }); 250 | 251 | nodeType.prototype.onConnectionsChange = function (type, _, connected, link_info) { 252 | // If configuring treat everything as OK as links may not be set by litegraph yet 253 | if (configuring) return; 254 | 255 | const isInput = type === LiteGraph.INPUT; 256 | const slot = isInput ? link_info.target_slot : link_info.origin_slot; 257 | 258 | let targetLabel = null; 259 | let targetNode = null; 260 | let targetType = "*"; 261 | let targetSlot = slot; 262 | 263 | const inputPath = this.getReroutedInputs(slot); 264 | const rootInput = inputPath[inputPath.length - 1]; 265 | const outputNode = this.getFirstReroutedOutput(slot); 266 | if (rootInput.node.type === REROUTE_PRIMITIVE) { 267 | // Our input node is a reroute, so see if we have an output 268 | if (outputNode) { 269 | targetType = outputNode.link.type; 270 | } else if (rootInput.node.widgets) { 271 | rootInput.node.widgets.length = 0; 272 | } 273 | targetNode = rootInput; 274 | targetSlot = rootInput.link?.target_slot ?? slot; 275 | } else { 276 | // We have a real input, so we want to use that type 277 | targetNode = inputPath[inputPath.length - 2]; 278 | targetType = rootInput.node.outputs[rootInput.link.origin_slot].type; 279 | targetSlot = rootInput.link.target_slot; 280 | } 281 | 282 | if (this.widgets && inputPath.length > 1) { 283 | // We have an input node so remove our widget 284 | this.widgets.length = 0; 285 | } 286 | 287 | if (outputNode && rootInput.node.checkPrimitiveWidget) { 288 | // We have an output, check if we need to create a widget 289 | targetLabel = rootInput.node.checkPrimitiveWidget(outputNode); 290 | } 291 | 292 | // Trigger an update of the type to all child nodes 293 | targetNode.node.changeRerouteType(targetSlot, targetType, targetLabel); 294 | 295 | return onConnectionsChange?.apply(this, arguments); 296 | }; 297 | 298 | // When collapsed fix the size to just the dot 299 | const computeSize = nodeType.prototype.computeSize || LGraphNode.prototype.computeSize; 300 | nodeType.prototype.computeSize = function () { 301 | const r = computeSize.apply(this, arguments); 302 | if (this.flags?.collapsed) { 303 | return [1, 25]; 304 | } else if (this.widgets?.length) { 305 | return r; 306 | } else { 307 | let w = 75; 308 | if (this.outputs?.[0]?.label) { 309 | const t = LiteGraph.NODE_TEXT_SIZE * this.outputs[0].label.length * 0.6 + 30; 310 | if (t > w) { 311 | w = t; 312 | } 313 | } 314 | return [w, r[1]]; 315 | } 316 | }; 317 | 318 | // On collapse shrink the node to just a dot 319 | const collapse = nodeType.prototype.collapse || LGraphNode.prototype.collapse; 320 | nodeType.prototype.collapse = function () { 321 | collapse.apply(this, arguments); 322 | this.setSize(this.computeSize()); 323 | requestAnimationFrame(() => { 324 | this.setDirtyCanvas(true, true); 325 | }); 326 | }; 327 | 328 | // Shift the bounding area up slightly as LiteGraph miscalculates it for collapsed nodes 329 | nodeType.prototype.onBounding = function (area) { 330 | if (this.flags?.collapsed) { 331 | area[1] -= 15; 332 | } 333 | }; 334 | } else if (nodeData.name === MULTI_PRIMITIVE) { 335 | addOutputHandler(); 336 | nodeType.prototype.onConnectionsChange = function (type, _, connected, link_info) { 337 | for (let i = 0; i < this.inputs.length - 1; i++) { 338 | if (!this.inputs[i].link) { 339 | this.removeInput(i--); 340 | } 341 | } 342 | if (this.inputs[this.inputs.length - 1].link) { 343 | this.addInput("v" + +new Date(), this.inputs[0].type).label = "value"; 344 | } 345 | }; 346 | } 347 | }, 348 | }); 349 | -------------------------------------------------------------------------------- /web/js/autocompleter.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../../scripts/app.js"; 2 | import { ComfyWidgets } from "../../../scripts/widgets.js"; 3 | import { api } from "../../../scripts/api.js"; 4 | import { $el, ComfyDialog } from "../../../scripts/ui.js"; 5 | import { TextAreaAutoComplete } from "./common/autocomplete.js"; 6 | import { ModelInfoDialog } from "./common/modelInfoDialog.js"; 7 | import { LoraInfoDialog } from "./modelInfo.js"; 8 | 9 | function parseCSV(csvText) { 10 | const rows = []; 11 | const delimiter = ","; 12 | const quote = '"'; 13 | let currentField = ""; 14 | let inQuotedField = false; 15 | 16 | function pushField() { 17 | rows[rows.length - 1].push(currentField); 18 | currentField = ""; 19 | inQuotedField = false; 20 | } 21 | 22 | rows.push([]); // Initialize the first row 23 | 24 | for (let i = 0; i < csvText.length; i++) { 25 | const char = csvText[i]; 26 | const nextChar = csvText[i + 1]; 27 | 28 | // Special handling for backslash escaped quotes 29 | if (char === "\\" && nextChar === quote) { 30 | currentField += quote; 31 | i++; 32 | } 33 | 34 | if (!inQuotedField) { 35 | if (char === quote) { 36 | inQuotedField = true; 37 | } else if (char === delimiter) { 38 | pushField(); 39 | } else if (char === "\r" || char === "\n" || i === csvText.length - 1) { 40 | pushField(); 41 | if (nextChar === "\n") { 42 | i++; // Handle Windows line endings (\r\n) 43 | } 44 | rows.push([]); // Start a new row 45 | } else { 46 | currentField += char; 47 | } 48 | } else { 49 | if (char === quote && nextChar === quote) { 50 | currentField += quote; 51 | i++; // Skip the next quote 52 | } else if (char === quote) { 53 | inQuotedField = false; 54 | } else if (char === "\r" || char === "\n" || i === csvText.length - 1) { 55 | // Dont allow new lines in quoted text, assume its wrong 56 | const parsed = parseCSV(currentField); 57 | rows.pop(); 58 | rows.push(...parsed); 59 | inQuotedField = false; 60 | currentField = ""; 61 | rows.push([]); 62 | } else { 63 | currentField += char; 64 | } 65 | } 66 | } 67 | 68 | if (currentField || csvText[csvText.length - 1] === ",") { 69 | pushField(); 70 | } 71 | 72 | // Remove the last row if it's empty 73 | if (rows[rows.length - 1].length === 0) { 74 | rows.pop(); 75 | } 76 | 77 | return rows; 78 | } 79 | 80 | async function getCustomWords() { 81 | const resp = await api.fetchApi("/pysssss/autocomplete", { cache: "no-store" }); 82 | if (resp.status === 200) { 83 | return await resp.text(); 84 | } 85 | return undefined; 86 | } 87 | 88 | async function addCustomWords(text) { 89 | if (!text) { 90 | text = await getCustomWords(); 91 | } 92 | if (text) { 93 | TextAreaAutoComplete.updateWords( 94 | "pysssss.customwords", 95 | parseCSV(text).reduce((p, n) => { 96 | let text; 97 | let priority; 98 | let value; 99 | let num; 100 | switch (n.length) { 101 | case 0: 102 | return; 103 | case 1: 104 | // Single word 105 | text = n[0]; 106 | break; 107 | case 2: 108 | // Word,[priority|alias] 109 | num = +n[1]; 110 | if (isNaN(num)) { 111 | text = n[0] + "🔄️" + n[1]; 112 | value = n[0]; 113 | } else { 114 | text = n[0]; 115 | priority = num; 116 | } 117 | break; 118 | case 4: 119 | // a1111 csv format? 120 | value = n[0]; 121 | priority = +n[2]; 122 | const aliases = n[3]?.trim(); 123 | if (aliases && aliases !== "null") { // Weird null in an example csv, maybe they are JSON.parsing the last column? 124 | const split = aliases.split(","); 125 | for (const text of split) { 126 | p[text] = { text, priority, value }; 127 | } 128 | } 129 | text = value; 130 | break; 131 | default: 132 | // Word,alias,priority 133 | text = n[1]; 134 | value = n[0]; 135 | priority = +n[2]; 136 | break; 137 | } 138 | p[text] = { text, priority, value }; 139 | return p; 140 | }, {}) 141 | ); 142 | } 143 | } 144 | 145 | function toggleLoras() { 146 | [TextAreaAutoComplete.globalWords, TextAreaAutoComplete.globalWordsExclLoras] = [ 147 | TextAreaAutoComplete.globalWordsExclLoras, 148 | TextAreaAutoComplete.globalWords, 149 | ]; 150 | } 151 | 152 | class EmbeddingInfoDialog extends ModelInfoDialog { 153 | async addInfo() { 154 | super.addInfo(); 155 | const info = await this.addCivitaiInfo(); 156 | if (info) { 157 | $el("div", { 158 | parent: this.content, 159 | innerHTML: info.description, 160 | style: { 161 | maxHeight: "250px", 162 | overflow: "auto", 163 | }, 164 | }); 165 | } 166 | } 167 | } 168 | 169 | class CustomWordsDialog extends ComfyDialog { 170 | async show() { 171 | const text = await getCustomWords(); 172 | this.words = $el("textarea", { 173 | textContent: text, 174 | style: { 175 | width: "70vw", 176 | height: "70vh", 177 | }, 178 | }); 179 | 180 | const input = $el("input", { 181 | style: { 182 | flex: "auto", 183 | }, 184 | value: 185 | "https://gist.githubusercontent.com/pythongosssss/1d3efa6050356a08cea975183088159a/raw/a18fb2f94f9156cf4476b0c24a09544d6c0baec6/danbooru-tags.txt", 186 | }); 187 | 188 | super.show( 189 | $el( 190 | "div", 191 | { 192 | style: { 193 | display: "flex", 194 | flexDirection: "column", 195 | overflow: "hidden", 196 | maxHeight: "100%", 197 | }, 198 | }, 199 | [ 200 | $el("h2", { 201 | textContent: "Custom Autocomplete Words", 202 | style: { 203 | color: "#fff", 204 | marginTop: 0, 205 | textAlign: "center", 206 | fontFamily: "sans-serif", 207 | }, 208 | }), 209 | $el( 210 | "div", 211 | { 212 | style: { 213 | color: "#fff", 214 | fontFamily: "sans-serif", 215 | display: "flex", 216 | alignItems: "center", 217 | gap: "5px", 218 | }, 219 | }, 220 | [ 221 | $el("label", { textContent: "Load Custom List: " }), 222 | input, 223 | $el("button", { 224 | textContent: "Load", 225 | onclick: async () => { 226 | try { 227 | const res = await fetch(input.value); 228 | if (res.status !== 200) { 229 | throw new Error("Error loading: " + res.status + " " + res.statusText); 230 | } 231 | this.words.value = await res.text(); 232 | } catch (error) { 233 | alert("Error loading custom list, try manually copy + pasting the list"); 234 | } 235 | }, 236 | }), 237 | ] 238 | ), 239 | this.words, 240 | ] 241 | ) 242 | ); 243 | } 244 | 245 | createButtons() { 246 | const btns = super.createButtons(); 247 | const save = $el("button", { 248 | type: "button", 249 | textContent: "Save", 250 | onclick: async (e) => { 251 | try { 252 | const res = await api.fetchApi("/pysssss/autocomplete", { method: "POST", body: this.words.value }); 253 | if (res.status !== 200) { 254 | throw new Error("Error saving: " + res.status + " " + res.statusText); 255 | } 256 | save.textContent = "Saved!"; 257 | addCustomWords(this.words.value); 258 | setTimeout(() => { 259 | save.textContent = "Save"; 260 | }, 500); 261 | } catch (error) { 262 | alert("Error saving word list!"); 263 | console.error(error); 264 | } 265 | }, 266 | }); 267 | 268 | btns.unshift(save); 269 | return btns; 270 | } 271 | } 272 | 273 | const id = "pysssss.AutoCompleter"; 274 | 275 | app.registerExtension({ 276 | name: id, 277 | init() { 278 | const STRING = ComfyWidgets.STRING; 279 | const SKIP_WIDGETS = new Set(["ttN xyPlot.x_values", "ttN xyPlot.y_values"]); 280 | ComfyWidgets.STRING = function (node, inputName, inputData) { 281 | const r = STRING.apply(this, arguments); 282 | 283 | if (inputData[1]?.multiline) { 284 | // Disabled on this input 285 | const config = inputData[1]?.["pysssss.autocomplete"]; 286 | if (config === false) return r; 287 | 288 | // In list of widgets to skip 289 | const id = `${node.comfyClass}.${inputName}`; 290 | if (SKIP_WIDGETS.has(id)) return r; 291 | 292 | let words; 293 | let separator; 294 | if (typeof config === "object") { 295 | separator = config.separator; 296 | words = {}; 297 | if (config.words) { 298 | // Custom wordlist, this will have been registered on setup 299 | Object.assign(words, TextAreaAutoComplete.groups[node.comfyClass + "." + inputName] ?? {}); 300 | } 301 | 302 | for (const item of config.groups ?? []) { 303 | if (item === "*") { 304 | // This widget wants all global words included 305 | Object.assign(words, TextAreaAutoComplete.globalWords); 306 | } else { 307 | // This widget wants a specific group included 308 | Object.assign(words, TextAreaAutoComplete.groups[item] ?? {}); 309 | } 310 | } 311 | } 312 | 313 | new TextAreaAutoComplete(r.widget.inputEl, words, separator); 314 | } 315 | 316 | return r; 317 | }; 318 | 319 | TextAreaAutoComplete.globalSeparator = localStorage.getItem(id + ".AutoSeparate") ?? ", "; 320 | const enabledSetting = app.ui.settings.addSetting({ 321 | id, 322 | name: "🐍 Text Autocomplete", 323 | defaultValue: true, 324 | type: (name, setter, value) => { 325 | return $el("tr", [ 326 | $el("td", [ 327 | $el("label", { 328 | for: id.replaceAll(".", "-"), 329 | textContent: name, 330 | }), 331 | ]), 332 | $el("td", [ 333 | $el( 334 | "label", 335 | { 336 | textContent: "Enabled ", 337 | style: { 338 | display: "block", 339 | }, 340 | }, 341 | [ 342 | $el("input", { 343 | id: id.replaceAll(".", "-"), 344 | type: "checkbox", 345 | checked: value, 346 | onchange: (event) => { 347 | const checked = !!event.target.checked; 348 | TextAreaAutoComplete.enabled = checked; 349 | setter(checked); 350 | }, 351 | }), 352 | ] 353 | ), 354 | $el( 355 | "label.comfy-tooltip-indicator", 356 | { 357 | title: "This requires other ComfyUI nodes/extensions that support using LoRAs in the prompt.", 358 | textContent: "Loras enabled ", 359 | style: { 360 | display: "block", 361 | }, 362 | }, 363 | [ 364 | $el("input", { 365 | type: "checkbox", 366 | checked: !!TextAreaAutoComplete.lorasEnabled, 367 | onchange: (event) => { 368 | const checked = !!event.target.checked; 369 | TextAreaAutoComplete.lorasEnabled = checked; 370 | toggleLoras(); 371 | localStorage.setItem(id + ".ShowLoras", TextAreaAutoComplete.lorasEnabled); 372 | }, 373 | }), 374 | ] 375 | ), 376 | $el( 377 | "label", 378 | { 379 | textContent: "Auto-insert comma ", 380 | style: { 381 | display: "block", 382 | }, 383 | }, 384 | [ 385 | $el("input", { 386 | type: "checkbox", 387 | checked: !!TextAreaAutoComplete.globalSeparator, 388 | onchange: (event) => { 389 | const checked = !!event.target.checked; 390 | TextAreaAutoComplete.globalSeparator = checked ? ", " : ""; 391 | localStorage.setItem(id + ".AutoSeparate", TextAreaAutoComplete.globalSeparator); 392 | }, 393 | }), 394 | ] 395 | ), 396 | $el( 397 | "label", 398 | { 399 | textContent: "Replace _ with space ", 400 | style: { 401 | display: "block", 402 | }, 403 | }, 404 | [ 405 | $el("input", { 406 | type: "checkbox", 407 | checked: !!TextAreaAutoComplete.replacer, 408 | onchange: (event) => { 409 | const checked = !!event.target.checked; 410 | TextAreaAutoComplete.replacer = checked ? (v) => v.replaceAll("_", " ") : undefined; 411 | localStorage.setItem(id + ".ReplaceUnderscore", checked); 412 | }, 413 | }), 414 | ] 415 | ), 416 | $el( 417 | "label", 418 | { 419 | textContent: "Insert suggestion on: ", 420 | style: { 421 | display: "block", 422 | }, 423 | }, 424 | [ 425 | $el( 426 | "label", 427 | { 428 | textContent: "Tab", 429 | style: { 430 | display: "block", 431 | marginLeft: "20px", 432 | }, 433 | }, 434 | [ 435 | $el("input", { 436 | type: "checkbox", 437 | checked: !!TextAreaAutoComplete.insertOnTab, 438 | onchange: (event) => { 439 | const checked = !!event.target.checked; 440 | TextAreaAutoComplete.insertOnTab = checked; 441 | localStorage.setItem(id + ".InsertOnTab", checked); 442 | }, 443 | }), 444 | ] 445 | ), 446 | $el( 447 | "label", 448 | { 449 | textContent: "Enter", 450 | style: { 451 | display: "block", 452 | marginLeft: "20px", 453 | }, 454 | }, 455 | [ 456 | $el("input", { 457 | type: "checkbox", 458 | checked: !!TextAreaAutoComplete.insertOnEnter, 459 | onchange: (event) => { 460 | const checked = !!event.target.checked; 461 | TextAreaAutoComplete.insertOnEnter = checked; 462 | localStorage.setItem(id + ".InsertOnEnter", checked); 463 | }, 464 | }), 465 | ] 466 | ), 467 | ] 468 | ), 469 | $el( 470 | "label", 471 | { 472 | textContent: "Max suggestions: ", 473 | style: { 474 | display: "block", 475 | }, 476 | }, 477 | [ 478 | $el("input", { 479 | type: "number", 480 | value: +TextAreaAutoComplete.suggestionCount, 481 | style: { 482 | width: "80px" 483 | }, 484 | onchange: (event) => { 485 | const value = +event.target.value; 486 | TextAreaAutoComplete.suggestionCount = value;; 487 | localStorage.setItem(id + ".SuggestionCount", TextAreaAutoComplete.suggestionCount); 488 | }, 489 | }), 490 | ] 491 | ), 492 | $el("button", { 493 | textContent: "Manage Custom Words", 494 | onclick: () => { 495 | try { 496 | // Try closing old settings window 497 | if (typeof app.ui.settings.element?.close === "function") { 498 | app.ui.settings.element.close(); 499 | } 500 | } catch (error) { 501 | } 502 | try { 503 | // Try closing new vue dialog 504 | document.querySelector(".p-dialog-close-button").click(); 505 | } catch (error) { 506 | // Fallback to just hiding the element 507 | app.ui.settings.element.style.display = "none"; 508 | } 509 | 510 | new CustomWordsDialog().show(); 511 | }, 512 | style: { 513 | fontSize: "14px", 514 | display: "block", 515 | marginTop: "5px", 516 | }, 517 | }), 518 | ]), 519 | ]); 520 | }, 521 | }); 522 | 523 | TextAreaAutoComplete.enabled = enabledSetting.value; 524 | TextAreaAutoComplete.replacer = localStorage.getItem(id + ".ReplaceUnderscore") === "true" ? (v) => v.replaceAll("_", " ") : undefined; 525 | TextAreaAutoComplete.insertOnTab = localStorage.getItem(id + ".InsertOnTab") !== "false"; 526 | TextAreaAutoComplete.insertOnEnter = localStorage.getItem(id + ".InsertOnEnter") !== "false"; 527 | TextAreaAutoComplete.lorasEnabled = localStorage.getItem(id + ".ShowLoras") === "true"; 528 | TextAreaAutoComplete.suggestionCount = +localStorage.getItem(id + ".SuggestionCount") || 20; 529 | }, 530 | setup() { 531 | async function addEmbeddings() { 532 | const embeddings = await api.getEmbeddings(); 533 | const words = {}; 534 | words["embedding:"] = { text: "embedding:" }; 535 | 536 | for (const emb of embeddings) { 537 | const v = `embedding:${emb}`; 538 | words[v] = { 539 | text: v, 540 | info: () => new EmbeddingInfoDialog(emb).show("embeddings", emb), 541 | use_replacer: false, 542 | }; 543 | } 544 | 545 | TextAreaAutoComplete.updateWords("pysssss.embeddings", words); 546 | } 547 | 548 | async function addLoras() { 549 | let loras; 550 | try { 551 | loras = LiteGraph.registered_node_types["LoraLoader"]?.nodeData.input.required.lora_name[0]; 552 | } catch (error) {} 553 | 554 | if (!loras?.length) { 555 | loras = await api.fetchApi("/pysssss/loras", { cache: "no-store" }).then((res) => res.json()); 556 | } 557 | 558 | const words = {}; 559 | words["lora:"] = { text: "lora:" }; 560 | 561 | for (const lora of loras) { 562 | const v = ``; 563 | words[v] = { 564 | text: v, 565 | info: () => new LoraInfoDialog(lora).show("loras", lora), 566 | use_replacer: false, 567 | }; 568 | } 569 | 570 | TextAreaAutoComplete.updateWords("pysssss.loras", words); 571 | } 572 | 573 | // store global words with/without loras 574 | Promise.all([addEmbeddings(), addCustomWords()]) 575 | .then(() => { 576 | TextAreaAutoComplete.globalWordsExclLoras = Object.assign({}, TextAreaAutoComplete.globalWords); 577 | }) 578 | .then(addLoras) 579 | .then(() => { 580 | if (!TextAreaAutoComplete.lorasEnabled) { 581 | toggleLoras(); // off by default 582 | } 583 | }); 584 | }, 585 | beforeRegisterNodeDef(_, def) { 586 | // Process each input to see if there is a custom word list for 587 | // { input: { required: { something: ["STRING", { "pysssss.autocomplete": ["groupid", ["custom", "words"] ] }] } } } 588 | const inputs = { ...def.input?.required, ...def.input?.optional }; 589 | for (const input in inputs) { 590 | const config = inputs[input][1]?.["pysssss.autocomplete"]; 591 | if (!config) continue; 592 | if (typeof config === "object" && config.words) { 593 | const words = {}; 594 | for (const text of config.words || []) { 595 | const obj = typeof text === "string" ? { text } : text; 596 | words[obj.text] = obj; 597 | } 598 | TextAreaAutoComplete.updateWords(def.name + "." + input, words, false); 599 | } 600 | } 601 | }, 602 | }); 603 | --------------------------------------------------------------------------------