diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..828f300 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,21 @@ +name: Publish to Comfy registry +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "pyproject.toml" + +jobs: + publish-node: + name: Publish Custom Node to registry + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Publish Custom Node + uses: Comfy-Org/publish-node-action@main + with: + ## Add your own personal access token to your Github Repository secrets and reference it here. + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 3d6037c..f154ae3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ComfyUI-Custom-Scripts +### ⚠️ 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! + # Installation 1. Clone the repository: @@ -103,6 +105,12 @@ Adds various menu items to some nodes for quickly setting up common parts of gra Plays a sound when the node is executed, either after each prompt or only when the queue is empty for queuing multiple prompts. You can customize the sound by replacing the mp3 file `web/extensions/pysssss/CustomScripts/assets\notify.mp3` +## System Notification +![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/30354775/993fd783-5cd6-4779-aa97-173bc06cc405) +![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/30354775/e45227fb-5714-4f45-b96b-6601902ef6e2) + +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. + ## [WIP] Repeater ![image](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/assets/125205205/ec0dac25-14e4-4d44-b975-52193656709d) Node allows you to either create a list of N repeats of the input node, or create N outputs from the input node. diff --git a/py/better_combos.py b/py/better_combos.py index a7ed34e..c3f1c36 100644 --- a/py/better_combos.py +++ b/py/better_combos.py @@ -85,7 +85,7 @@ def populate_items(names, type): file_path_no_ext = os.path.splitext(file_path)[0] - for ext in ["png", "jpg", "jpeg", "preview.png"]: + for ext in ["png", "jpg", "jpeg", "preview.png", "preview.jpeg"]: has_image = os.path.isfile(file_path_no_ext + "." + ext) if has_image: item_image = f"{file_name}.{ext}" diff --git a/py/math_expression.py b/py/math_expression.py index 385eed2..eb447a5 100644 --- a/py/math_expression.py +++ b/py/math_expression.py @@ -12,7 +12,13 @@ operators = { ast.Pow: op.pow, ast.BitXor: op.xor, ast.USub: op.neg, - ast.Mod: op.mod + ast.Mod: op.mod, + ast.BitAnd: op.and_, + ast.BitOr: op.or_, + ast.Invert: op.invert, + ast.And: lambda a, b: 1 if a and b else 0, + ast.Or: lambda a, b: 1 if a or b else 0, + ast.Not: lambda a: 0 if a else 1, } # TODO: restructure args to provide more info, generate hint based on args to save duplication @@ -52,6 +58,21 @@ functions = { "call": lambda *args: random.choice(args), "hint": "...numbers" }, + "sqrt": { + "args": (1, 1), + "call": lambda a: math.sqrt(a), + "hint": "number" + }, + "int": { + "args": (1, 1), + "call": lambda a = None: int(a), + "hint": "number" + }, + "iif": { + "args": (3, 3), + "call": lambda a, b, c = None: b if a else c, + "hint": "value, truepart, falsepart" + }, } autocompleteWords = list({ @@ -136,11 +157,20 @@ class MathExpression: lookup = {"a": a, "b": b, "c": c} + def eval_op(node, l, r): + l = eval_expr(l) + r = eval_expr(r) + l = l if isinstance(l, int) else float(l) + r = r if isinstance(r, int) else float(r) + return operators[type(node.op)](l, r) + def eval_expr(node): - if isinstance(node, ast.Num): + if isinstance(node, ast.Constant) or isinstance(node, ast.Num): return node.n elif isinstance(node, ast.BinOp): - return operators[type(node.op)](float(eval_expr(node.left)), float(eval_expr(node.right))) + return eval_op(node, node.left, node.right) + elif isinstance(node, ast.BoolOp): + return eval_op(node, node.values[0], node.values[1]) elif isinstance(node, ast.UnaryOp): return operators[type(node.op)](eval_expr(node.operand)) elif isinstance(node, ast.Attribute): @@ -174,6 +204,23 @@ class MathExpression: args.append(eval_expr(arg)) return fn["call"](*args) raise NameError(f"Invalid function call: {node.func.id}") + elif isinstance(node, ast.Compare): + l = eval_expr(node.left) + r = eval_expr(node.comparators[0]) + if isinstance(node.ops[0], ast.Eq): + return 1 if l == r else 0 + if isinstance(node.ops[0], ast.NotEq): + return 1 if l != r else 0 + if isinstance(node.ops[0], ast.Gt): + return 1 if l > r else 0 + if isinstance(node.ops[0], ast.GtE): + return 1 if l >= r else 0 + if isinstance(node.ops[0], ast.Lt): + return 1 if l < r else 0 + if isinstance(node.ops[0], ast.LtE): + return 1 if l <= r else 0 + raise NotImplementedError( + "Operator " + node.ops[0].__class__.__name__ + " not supported.") else: raise TypeError(node) diff --git a/py/string_function.py b/py/string_function.py index cdf9a00..f502ba3 100644 --- a/py/string_function.py +++ b/py/string_function.py @@ -7,10 +7,10 @@ class StringFunction: "required": { "action": (["append", "replace"], {}), "tidy_tags": (["yes", "no"], {}), - "text_a": ("STRING", {"multiline": True, "dynamicPrompts": False}), - "text_b": ("STRING", {"multiline": True, "dynamicPrompts": False}), }, "optional": { + "text_a": ("STRING", {"multiline": True, "dynamicPrompts": False}), + "text_b": ("STRING", {"multiline": True, "dynamicPrompts": False}), "text_c": ("STRING", {"multiline": True, "dynamicPrompts": False}) } } @@ -20,15 +20,7 @@ class StringFunction: CATEGORY = "utils" OUTPUT_NODE = True - def exec(self, action, tidy_tags, text_a, text_b, text_c=""): - # Converted inputs are sent as the string of 'undefined' if not connected - if text_a == "undefined": - text_a = "" - if text_b == "undefined": - text_b = "" - if text_c == "undefined": - text_c = "" - + def exec(self, action, tidy_tags, text_a="", text_b="", text_c=""): tidy_tags = tidy_tags == "yes" out = "" if action == "append": @@ -42,7 +34,10 @@ class StringFunction: else: out = text_a.replace(text_b, text_c) if tidy_tags: - out = out.replace(" ", " ").replace(" ,", ",").replace(",,", ",").replace(",,", ",") + out = re.sub(r"\s{2,}", " ", out) + out = out.replace(" ,", ",") + out = re.sub(r",{2,}", ",", out) + out = out.strip() return {"ui": {"text": (out,)}, "result": (out,)} NODE_CLASS_MAPPINGS = { diff --git a/py/system_notification.py b/py/system_notification.py new file mode 100644 index 0000000..e9a29c9 --- /dev/null +++ b/py/system_notification.py @@ -0,0 +1,41 @@ +# Hack: string type that is always equal in not equal comparisons +class AnyType(str): + def __ne__(self, __value: object) -> bool: + return False + + +# Our any instance wants to be a wildcard string +any = AnyType("*") + + +class SystemNotification: + @classmethod + def INPUT_TYPES(s): + return {"required": { + "message": ("STRING", {"default": "Your notification has triggered."}), + "any": (any, {}), + "mode": (["always", "on empty queue"], {}), + }} + + FUNCTION = "nop" + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True,) + OUTPUT_NODE = True + RETURN_TYPES = (any,) + + CATEGORY = "utils" + + def IS_CHANGED(self, **kwargs): + return float("NaN") + + def nop(self, any, **kwargs): + return {"ui": {"a": []}, "result": (any,)} + + +NODE_CLASS_MAPPINGS = { + "SystemNotification|pysssss": SystemNotification, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SystemNotification|pysssss": "SystemNotification 🐍", +} diff --git a/py/text_files.py b/py/text_files.py index 9a249ca..253de9b 100644 --- a/py/text_files.py +++ b/py/text_files.py @@ -132,6 +132,8 @@ class LoadText(TextFileNode): class SaveText(TextFileNode): + OUTPUT_NODE = True + @classmethod def IS_CHANGED(self, **kwargs): return float("nan") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5d4ba94 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "comfyui-custom-scripts" +description = "Enhancements & experiments for ComfyUI, mostly focusing on UI features" +version = "1.0.0" +license = "LICENSE" + +[project.urls] +Repository = "https://github.com/pythongosssss/ComfyUI-Custom-Scripts" + +[tool.comfy] +PublisherId = "pythongosssss" +DisplayName = "ComfyUI-Custom-Scripts" +Icon = "" diff --git a/web/js/common/autocomplete.js b/web/js/common/autocomplete.js index cb25b83..d1c17fe 100644 --- a/web/js/common/autocomplete.js +++ b/web/js/common/autocomplete.js @@ -471,7 +471,7 @@ export class TextAreaAutoComplete { this.#hide(); break; } - } else if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "ArrowLeft" || e.key === "ArrowRight") { + } else if (e.key.length > 1 && e.key != "Delete" && e.key != "Backspace") { return; } if (!e.defaultPrevented) { diff --git a/web/js/common/lightbox.js b/web/js/common/lightbox.js index d351acf..5c90790 100644 --- a/web/js/common/lightbox.js +++ b/web/js/common/lightbox.js @@ -62,6 +62,13 @@ export class Lightbox { onclick: (e) => { e.stopImmediatePropagation(); }, + onwheel: (e) => { + if (!(e instanceof WheelEvent) || e.ctrlKey) { + return; + } + const direction = Math.sign(e.deltaY); + this.update(direction); + }, }); } diff --git a/web/js/common/modelInfoDialog.js b/web/js/common/modelInfoDialog.js index 9a8c989..8655867 100644 --- a/web/js/common/modelInfoDialog.js +++ b/web/js/common/modelInfoDialog.js @@ -249,8 +249,9 @@ export class ModelInfoDialog extends ComfyDialog { }) ); - if (info.images?.length) { - this.img.src = info.images[0].url; + const preview = info.images.find((i) => i.type === "image"); + if (preview) { + this.img.src = preview.url; this.img.style.display = ""; this.imgSave = $el("button", { diff --git a/web/js/imageFeed.js b/web/js/imageFeed.js index 22dc323..c13d666 100644 --- a/web/js/imageFeed.js +++ b/web/js/imageFeed.js @@ -37,6 +37,9 @@ $el("style", { min-width: 200px; max-width: calc(var(--max-size, 10) * 1vw); } + .comfyui-body-left .pysssss-image-feed--left, .comfyui-body-right .pysssss-image-feed--right { + height: 100%; + } .pysssss-image-feed--left { left: 0; } @@ -405,6 +408,8 @@ Recommended: "enabled (max performance)" uness images are erroneously deduplicat ), ]) ); + // If lightbox is open, update it with new image + lightbox.updateWithNewImage(href, feedDirection.value); } imageFeed.append( diff --git a/web/js/presetText.js b/web/js/presetText.js index ecb072a..51b9066 100644 --- a/web/js/presetText.js +++ b/web/js/presetText.js @@ -5,6 +5,7 @@ import { app } from "../../../scripts/app.js"; let replaceRegex; const id = "pysssss.PresetText.Presets"; +const MISSING = Symbol(); const getPresets = () => { let items; @@ -47,6 +48,19 @@ app.registerExtension({ } }, }); + + const drawNodeWidgets = LGraphCanvas.prototype.drawNodeWidgets + LGraphCanvas.prototype.drawNodeWidgets = function(node) { + const c = LiteGraph.WIDGET_BGCOLOR; + try { + if(node[MISSING]) { + LiteGraph.WIDGET_BGCOLOR = "red" + } + return drawNodeWidgets.apply(this, arguments); + } finally { + LiteGraph.WIDGET_BGCOLOR = c; + } + } }, registerCustomNodes() { class PresetTextNode { @@ -153,10 +167,12 @@ app.registerExtension({ if (!w) continue; const preset = presets.find((p) => p.name === widget.value); if (!preset) { + this[MISSING] = true; + app.graph.setDirtyCanvas(true, true); const msg = `Preset text '${widget.value}' not found. Please fix this and queue again.`; - alert(msg); throw new Error(msg); } + delete this[MISSING]; w.value = preset.value; } } diff --git a/web/js/snapToGrid.js b/web/js/snapToGrid.js index 4c30686..c8230c8 100644 --- a/web/js/snapToGrid.js +++ b/web/js/snapToGrid.js @@ -1,5 +1,5 @@ import { app } from "../../../scripts/app.js"; -import { $el, ComfyDialog } from "../../../scripts/ui.js"; +import { $el } from "../../../scripts/ui.js"; let setting, guide_setting, guide_config; const id = "pysssss.SnapToGrid"; @@ -13,6 +13,17 @@ const guide_config_default = { enabled: false, fillStyle: "rgba(0, 0, 255, 0.5)", }, + +/** Wraps the provided function call to set/reset shiftDown when setting is enabled. */ +function wrapCallInSettingCheck(fn) { + if (setting?.value) { + const shift = app.shiftDown; + app.shiftDown = true; + const r = fn(); + app.shiftDown = shift; + return r; + } + return fn(); } const ext = { @@ -115,14 +126,7 @@ const ext = { // Override drawNode to draw the drop position const drawNode = LGraphCanvas.prototype.drawNode; LGraphCanvas.prototype.drawNode = function () { - if (setting?.value) { - const shift = app.shiftDown; - app.shiftDown = true; - const r = drawNode.apply(this, arguments); - app.shiftDown = shift; - return r; - } - return drawNode.apply(this, arguments); + wrapCallInSettingCheck(() => drawNode.apply(this, arguments)); }; // Override node added to add a resize handler to force grid alignment @@ -131,18 +135,27 @@ const ext = { const r = onNodeAdded?.apply(this, arguments); const onResize = node.onResize; node.onResize = function () { - if (setting?.value) { - const shift = app.shiftDown; - app.shiftDown = true; - const r = onResize?.apply(this, arguments); - app.shiftDown = shift; - return r; - } - return onResize?.apply(this, arguments); + wrapCallInSettingCheck(() => onResize?.apply(this, arguments)); }; return r; }; + + const groupMove = LGraphGroup.prototype.move; + LGraphGroup.prototype.move = function(deltax, deltay, ignore_nodes) { + wrapCallInSettingCheck(() => groupMove.apply(this, arguments)); + } + + const canvasDrawGroups = LGraphCanvas.prototype.drawGroups; + LGraphCanvas.prototype.drawGroups = function (canvas, ctx) { + wrapCallInSettingCheck(() => canvasDrawGroups.apply(this, arguments)); + } + + const canvasOnGroupAdd = LGraphCanvas.onGroupAdd; + LGraphCanvas.onGroupAdd = function() { + wrapCallInSettingCheck(() => canvasOnGroupAdd.apply(this, arguments)); + } + return configure.apply(this, arguments); }; diff --git a/web/js/systemNotification.js b/web/js/systemNotification.js new file mode 100644 index 0000000..2cc93ba --- /dev/null +++ b/web/js/systemNotification.js @@ -0,0 +1,49 @@ +import { app } from "../../../scripts/app.js"; + +const notificationSetup = () => { + if (!("Notification" in window)) { + console.log("This browser does not support notifications."); + alert("This browser does not support notifications."); + return; + } + if (Notification.permission === "denied") { + console.log("Notifications are blocked. Please enable them in your browser settings."); + alert("Notifications are blocked. Please enable them in your browser settings."); + return; + } + if (Notification.permission !== "granted") { + Notification.requestPermission(); + } + return true; +}; + +app.registerExtension({ + name: "pysssss.SystemNotification", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "SystemNotification|pysssss") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = async function () { + onExecuted?.apply(this, arguments); + const mode = this.widgets.find((w) => w.name === "mode"); + const message = this.widgets.find((w) => w.name === "message"); + + if (mode.value === "on empty queue") { + if (app.ui.lastQueueSize !== 0) { + await new Promise((r) => setTimeout(r, 500)); + } + if (app.ui.lastQueueSize !== 0) { + return; + } + } + if (!notificationSetup()) return; + const notification = new Notification("ComfyUI", { body: message.value ?? "Your notification has triggered." }); + }; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated?.apply(this, arguments); + notificationSetup(); + }; + } + }, +}); diff --git a/web/js/workflowImage.js b/web/js/workflowImage.js index 89b0ef7..e9869e4 100644 --- a/web/js/workflowImage.js +++ b/web/js/workflowImage.js @@ -37,6 +37,7 @@ class WorkflowImage { width: app.canvas.canvas.width, height: app.canvas.canvas.height, offset: app.canvas.ds.offset, + transform: app.canvas.canvas.getContext('2d').getTransform(), // Save the original transformation matrix }; } @@ -45,6 +46,7 @@ class WorkflowImage { app.canvas.canvas.width = this.state.width; app.canvas.canvas.height = this.state.height; app.canvas.ds.offset = this.state.offset; + app.canvas.canvas.getContext('2d').setTransform(this.state.transform); // Reapply the original transformation matrix } updateView(bounds) {