diff --git a/.gitignore b/.gitignore index ed8ebf5..5e9693e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -__pycache__ \ No newline at end of file +__pycache__ +pysssss.json +user/autocomplete.txt \ No newline at end of file diff --git a/py/constrain_image_for_video.py b/py/constrain_image_for_video.py new file mode 100644 index 0000000..91c0c50 --- /dev/null +++ b/py/constrain_image_for_video.py @@ -0,0 +1,72 @@ +import torch +import numpy as np +from PIL import Image + +class ConstrainImageforVideo: + """ + A node that constrains an image to a maximum and minimum size while maintaining aspect ratio. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "max_width": ("INT", {"default": 1024, "min": 0}), + "max_height": ("INT", {"default": 1024, "min": 0}), + "min_width": ("INT", {"default": 0, "min": 0}), + "min_height": ("INT", {"default": 0, "min": 0}), + "crop_if_required": (["yes", "no"], {"default": "no"}), + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("IMAGE",) + FUNCTION = "constrain_image_for_video" + CATEGORY = "image" + + def constrain_image_for_video(self, images, max_width, max_height, min_width, min_height, crop_if_required): + crop_if_required = crop_if_required == "yes" + results = [] + for image in images: + i = 255. * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)).convert("RGB") + + current_width, current_height = img.size + aspect_ratio = current_width / current_height + + constrained_width = max(min(current_width, min_width), max_width) + constrained_height = max(min(current_height, min_height), max_height) + + if constrained_width / constrained_height > aspect_ratio: + constrained_width = max(int(constrained_height * aspect_ratio), min_width) + if crop_if_required: + constrained_height = int(current_height / (current_width / constrained_width)) + else: + constrained_height = max(int(constrained_width / aspect_ratio), min_height) + if crop_if_required: + constrained_width = int(current_width / (current_height / constrained_height)) + + resized_image = img.resize((constrained_width, constrained_height), Image.LANCZOS) + + if crop_if_required and (constrained_width > max_width or constrained_height > max_height): + left = max((constrained_width - max_width) // 2, 0) + top = max((constrained_height - max_height) // 2, 0) + right = min(constrained_width, max_width) + left + bottom = min(constrained_height, max_height) + top + resized_image = resized_image.crop((left, top, right, bottom)) + + resized_image = np.array(resized_image).astype(np.float32) / 255.0 + resized_image = torch.from_numpy(resized_image)[None,] + results.append(resized_image) + all_images = torch.cat(results, dim=0) + + return (all_images, all_images.size(0),) + +NODE_CLASS_MAPPINGS = { + "ConstrainImageforVideo|pysssss": ConstrainImageforVideo, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ConstrainImageforVideo|pysssss": "Constrain Image for Video 🐍", +} diff --git a/py/math_expression.py b/py/math_expression.py index decebdc..aa7a17a 100644 --- a/py/math_expression.py +++ b/py/math_expression.py @@ -95,7 +95,7 @@ class MathExpression: return expression def get_widget_value(self, extra_pnginfo, prompt, node_name, widget_name): - workflow = extra_pnginfo["workflow"] + workflow = extra_pnginfo["workflow"] if "workflow" in extra_pnginfo else { "nodes": [] } node_id = None for node in workflow["nodes"]: name = node["type"] diff --git a/py/play_sound.py b/py/play_sound.py index e2e6cf1..65f599d 100644 --- a/py/play_sound.py +++ b/py/play_sound.py @@ -14,21 +14,23 @@ class PlaySound: return {"required": { "any": (any, {}), "mode": (["always", "on empty queue"], {}), - "volume": ("FLOAT", {"min": 0, "max": 1, "step": 0.1, "default": 0.5}) + "volume": ("FLOAT", {"min": 0, "max": 1, "step": 0.1, "default": 0.5}), + "file": ("STRING", { "default": "notify.mp3" }) }} FUNCTION = "nop" INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True,) OUTPUT_NODE = True - RETURN_TYPES = () + RETURN_TYPES = (any,) CATEGORY = "utils" def IS_CHANGED(self, **kwargs): return float("NaN") - def nop(self, any, mode, volume): - return {"ui": {"a": []}, "result": ()} + def nop(self, any, mode, volume, file): + return {"ui": {"a": []}, "result": (any,)} NODE_CLASS_MAPPINGS = { diff --git a/py/text_files.py b/py/text_files.py index 5b4f917..9a249ca 100644 --- a/py/text_files.py +++ b/py/text_files.py @@ -64,6 +64,8 @@ def get_file(root_dir, file): root_dir = get_dir_from_name(root_dir) root_dir = get_real_path(root_dir) + if not os.path.exists(root_dir): + os.mkdir(root_dir) full_path = os.path.join(root_dir, file) if not is_child_dir(root_dir, full_path): @@ -78,10 +80,13 @@ class TextFileNode: @classmethod def VALIDATE_INPUTS(self, root_dir, file, **kwargs): - self.file = get_file(root_dir, file) + if file == "[none]" or not file or not file.strip(): + return True + get_file(root_dir, file) return True def load_text(self, **kwargs): + self.file = get_file(kwargs["root_dir"], kwargs["file"]) with open(self.file, "r") as f: return (f.read(), ) @@ -168,17 +173,18 @@ class SaveText(TextFileNode): FUNCTION = "write_text" - def write_text(self, root_dir, file, append, insert, text): - if append == "new only" and os.path.exists(self.file): + def write_text(self, **kwargs): + self.file = get_file(kwargs["root_dir"], kwargs["file"]) + if kwargs["append"] == "new only" and os.path.exists(self.file): raise FileExistsError( self.file + " already exists and 'new only' is selected.") - with open(self.file, "a+" if append == "append" else "w") as f: + with open(self.file, "a+" if kwargs["append"] == "append" else "w") as f: is_append = f.tell() != 0 - if is_append and insert: + if is_append and kwargs["insert"]: f.write("\n") - f.write(text) + f.write(kwargs["text"]) - return super().load_text() + return super().load_text(**kwargs) NODE_CLASS_MAPPINGS = { diff --git a/pysssss.json b/pysssss.default.json similarity index 100% rename from pysssss.json rename to pysssss.default.json diff --git a/pysssss.py b/pysssss.py index a61a554..b1024f5 100644 --- a/pysssss.py +++ b/pysssss.py @@ -70,11 +70,21 @@ def get_extension_config(reload=False): return config config_path = get_ext_dir("pysssss.json") + default_config_path = get_ext_dir("pysssss.default.json") if not os.path.exists(config_path): - log("Missing pysssss.json, this extension may not work correctly. Please reinstall the extension.", - type="ERROR", always=True, name="???") - print(f"Extension path: {get_ext_dir()}") - return {"name": "Unknown", "version": -1} + if os.path.exists(default_config_path): + shutil.copy(default_config_path, config_path) + if not os.path.exists(config_path): + log(f"Failed to create config at {config_path}", type="ERROR", always=True, name="???") + print(f"Extension path: {get_ext_dir()}") + return {"name": "Unknown", "version": -1} + + else: + log("Missing pysssss.default.json, this extension may not work correctly. Please reinstall the extension.", + type="ERROR", always=True, name="???") + print(f"Extension path: {get_ext_dir()}") + return {"name": "Unknown", "version": -1} + with open(config_path, "r") as f: config = json.loads(f.read()) return config diff --git a/user/autocomplete.txt b/user/autocomplete.txt deleted file mode 100644 index e69de29..0000000 diff --git a/web/js/autocompleter.js b/web/js/autocompleter.js index 5c5b219..99af50f 100644 --- a/web/js/autocompleter.js +++ b/web/js/autocompleter.js @@ -312,7 +312,7 @@ app.registerExtension({ }; TextAreaAutoComplete.globalSeparator = localStorage.getItem(id + ".AutoSeparate") ?? ", "; - app.ui.settings.addSetting({ + const enabledSetting = app.ui.settings.addSetting({ id, name: "🐍 Text Autocomplete", defaultValue: true, @@ -356,7 +356,6 @@ app.registerExtension({ }, [ $el("input", { - id: id.replaceAll(".", "-"), type: "checkbox", checked: !!TextAreaAutoComplete.globalSeparator, onchange: (event) => { @@ -367,6 +366,26 @@ app.registerExtension({ }), ] ), + $el( + "label", + { + textContent: "Replace _ with space ", + style: { + display: "block", + }, + }, + [ + $el("input", { + type: "checkbox", + checked: !!TextAreaAutoComplete.replacer, + onchange: (event) => { + const checked = !!event.target.checked; + TextAreaAutoComplete.replacer = checked ? (v) => v.replaceAll("_", " ") : undefined; + localStorage.setItem(id + ".ReplaceUnderscore", checked); + }, + }), + ] + ), $el("button", { textContent: "Manage Custom Words", onclick: () => { @@ -383,6 +402,9 @@ app.registerExtension({ ]); }, }); + TextAreaAutoComplete.enabled = enabledSetting.value; + TextAreaAutoComplete.replacer = localStorage.getItem(id + ".ReplaceUnderscore") === "true" ? (v) => v.replaceAll("_", " ") : undefined; + }, beforeRegisterNodeDef(_, def) { // Process each input to see if there is a custom word list for diff --git a/web/js/betterCombos.js b/web/js/betterCombos.js index bf3690d..cdea67a 100644 --- a/web/js/betterCombos.js +++ b/web/js/betterCombos.js @@ -27,10 +27,11 @@ app.registerExtension({ left: 0; top: 0; transform: translate(-100%, 0); - width: 256px; - height: 256px; - background-size: cover; - background-position: center; + width: 384px; + height: 384px; + background-size: contain; + background-position: top right; + background-repeat: no-repeat; filter: brightness(65%); } `, diff --git a/web/js/common/autocomplete.js b/web/js/common/autocomplete.js index a8f232e..862133b 100644 --- a/web/js/common/autocomplete.js +++ b/web/js/common/autocomplete.js @@ -175,8 +175,9 @@ const CHAR_CODE_ZERO = "0".charCodeAt(0); const CHAR_CODE_NINE = "9".charCodeAt(0); class TextAreaCaretHelper { - constructor(el) { + constructor(el, getScale) { this.el = el; + this.getScale = getScale; } #calculateElementOffset() { @@ -253,11 +254,12 @@ class TextAreaCaretHelper { } getCursorOffset() { + const scale = this.getScale(); const elOffset = this.#calculateElementOffset(); const elScroll = this.#getElScroll(); const cursorPosition = this.#getCursorPosition(); const lineHeight = this.#getLineHeightPx(); - const top = elOffset.top - elScroll.top + cursorPosition.top + lineHeight; + const top = elOffset.top - (elScroll.top * scale) + (cursorPosition.top + lineHeight) * scale; const left = elOffset.left - elScroll.left + cursorPosition.left; const clientTop = this.el.getBoundingClientRect().top; if (this.el.dir !== "rtl") { @@ -289,8 +291,7 @@ class TextAreaCaretHelper { const startPos = this.el.selectionStart; const endPos = this.el.selectionEnd; - this.el.value = - this.el.value.substring(0, startPos + offset) + value + this.el.value.substring(endPos, this.el.value.length); + this.el.value = this.el.value.substring(0, startPos + offset) + value + this.el.value.substring(endPos, this.el.value.length); this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + (finalOffset ?? 0); } else { this.el.value += value; @@ -313,6 +314,7 @@ class TextAreaCaretHelper { export class TextAreaAutoComplete { static globalSeparator = ""; static enabled = true; + static replacer = undefined; /** @type {Record>} */ static groups = {}; @@ -341,7 +343,7 @@ export class TextAreaAutoComplete { */ constructor(el, words = null, separator = null) { this.el = el; - this.helper = new TextAreaCaretHelper(el); + this.helper = new TextAreaCaretHelper(el, () => app.canvas.ds.scale); this.dropdown = $el("div.pysssss-autocomplete"); this.overrideWords = words; this.overrideSeparator = separator; @@ -481,10 +483,7 @@ export class TextAreaAutoComplete { ); const top = priorityMatches.length * 0.2; - return priorityMatches - .slice(0, top) - .concat(prefixMatches, priorityMatches.slice(top), includesMatches) - .slice(0, 20); + return priorityMatches.slice(0, top).concat(prefixMatches, priorityMatches.slice(top), includesMatches).slice(0, 20); } #update() { @@ -567,11 +566,11 @@ export class TextAreaAutoComplete { { onclick: () => { this.el.focus(); - this.helper.insertAtCursor( - (wordInfo.value ?? wordInfo.text) + this.separator, - -before.length, - wordInfo.caretOffset - ); + let value = wordInfo.value ?? wordInfo.text; + if(TextAreaAutoComplete.replacer) { + value = TextAreaAutoComplete.replacer(value); + } + this.helper.insertAtCursor(value + this.separator, -before.length, wordInfo.caretOffset); setTimeout(() => { this.#update(); }, 150); diff --git a/web/js/common/lightbox.js b/web/js/common/lightbox.js index 7e07445..4034597 100644 --- a/web/js/common/lightbox.js +++ b/web/js/common/lightbox.js @@ -80,6 +80,12 @@ export class Lightbox { } async update(shift) { + if (shift < 0 && this.index <= 0) { + return; + } + if (shift > 0 && this.index >= this.images.length - 1) { + return; + } this.index += shift; this.prev.style.visibility = this.index ? "unset" : "hidden"; @@ -91,7 +97,11 @@ export class Lightbox { this.el.style.opacity = 1; this.img.style.opacity = 0; this.spinner.style.display = "inline-block"; - await loadImage(img); + try { + await loadImage(img); + } catch (err) { + console.error('failed to load image', img, err); + } this.spinner.style.display = "none"; this.link.href = img; this.img.src = img; @@ -100,3 +110,23 @@ export class Lightbox { } export const lightbox = new Lightbox(); + +addEventListener('keydown', (event) => { + if (lightbox.el.style.display === 'none') { + return; + } + const { key } = event; + switch (key) { + case 'ArrowLeft': + case 'a': + lightbox.update(-1); + break; + case 'ArrowRight': + case 'd': + lightbox.update(1); + break; + case 'Escape': + lightbox.close(); + break; + } +}); \ No newline at end of file diff --git a/web/js/imageFeed.js b/web/js/imageFeed.js index b477912..e214d22 100644 --- a/web/js/imageFeed.js +++ b/web/js/imageFeed.js @@ -410,7 +410,7 @@ app.registerExtension({ api.addEventListener("executed", ({ detail }) => { if (visible && detail?.output?.images) { for (const src of detail.output.images) { - const href = `/view?filename=${encodeURIComponent(src.filename)}&type=${ + const href = `./view?filename=${encodeURIComponent(src.filename)}&type=${ src.type }&subfolder=${encodeURIComponent(src.subfolder)}&t=${+new Date()}`; diff --git a/web/js/modelInfo.js b/web/js/modelInfo.js index 6b198e6..d23f024 100644 --- a/web/js/modelInfo.js +++ b/web/js/modelInfo.js @@ -234,6 +234,8 @@ const infoHandler = { CheckpointLoader: "checkpoints", CheckpointLoaderSimple: "checkpoints", "CheckpointLoader|pysssss": "checkpoints", + "Efficient Loader": "checkpoints", + "Eff. Loader SDXL": "checkpoints", }; app.registerExtension({ diff --git a/web/js/playSound.js b/web/js/playSound.js index e472170..de71058 100644 --- a/web/js/playSound.js +++ b/web/js/playSound.js @@ -15,7 +15,18 @@ app.registerExtension({ return; } } - const url = new URL(`assets/notify.mp3`, import.meta.url); + let file = this.widgets[2].value; + if (!file) { + file = "notify.mp3"; + } + if (!file.startsWith("http")) { + if (!file.includes("/")) { + file = "assets/" + file; + } + file = new URL(file, import.meta.url) + } + + const url = new URL(file); const audio = new Audio(url); audio.volume = this.widgets[1].value; audio.play(); diff --git a/web/js/reroutePrimitive.js b/web/js/reroutePrimitive.js index 1b94894..91c77ad 100644 --- a/web/js/reroutePrimitive.js +++ b/web/js/reroutePrimitive.js @@ -154,7 +154,7 @@ app.registerExtension({ v = this.widgets_values[0]; } let config = [link.type, {}]; - if (input.widget) { + if (input.widget?.config) { config = input.widget.config; } const { widget } = ComfyWidgets[widgetType](this, "value", config, app); diff --git a/web/js/showImageOnMenu.js b/web/js/showImageOnMenu.js index c38b5e7..4fed9de 100644 --- a/web/js/showImageOnMenu.js +++ b/web/js/showImageOnMenu.js @@ -49,11 +49,13 @@ const ext = { api.addEventListener("executed", ({ detail }) => { if (!enabled) return; const images = detail?.output?.images; - if (!images) return; + if (!images || !images.length) return; const format = app.getPreviewFormatParam(); - const src = `/view?filename=${encodeURIComponent(images[0].filename)}&type=${ - images[0].type - }&subfolder=${encodeURIComponent(images[0].subfolder)}&t=${+new Date()}${format}`; + const src = [ + `./view?filename=${encodeURIComponent(images[0].filename)}`, + `type=${images[0].type}`, + `subfolder=${encodeURIComponent(images[0].subfolder)}`, + `t=${+new Date()}${format}`,].join('&'); show(src, detail.node); }); diff --git a/web/js/showText.js b/web/js/showText.js index a16540e..62da82e 100644 --- a/web/js/showText.js +++ b/web/js/showText.js @@ -2,23 +2,23 @@ import { app } from "../../../scripts/app.js"; import { ComfyWidgets } from "../../../scripts/widgets.js"; // Displays input text on a node - app.registerExtension({ name: "pysssss.ShowText", async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === "ShowText|pysssss") { function populate(text) { if (this.widgets) { - const pos = this.widgets.findIndex((w) => w.name === "text"); - if (pos !== -1) { - for (let i = pos; i < this.widgets.length; i++) { - this.widgets[i].onRemove?.(); - } - this.widgets.length = pos; + for (let i = 1; i < this.widgets.length; i++) { + this.widgets[i].onRemove?.(); } + this.widgets.length = 1; } - for (const list of text) { + const v = [...text]; + if (!v[0]) { + v.shift(); + } + for (const list of v) { const w = ComfyWidgets["STRING"](this, "text", ["STRING", { multiline: true }], app).widget; w.inputEl.readOnly = true; w.inputEl.style.opacity = 0.6; diff --git a/web/js/workflowImage.js b/web/js/workflowImage.js index 0158f65..d6b73de 100644 --- a/web/js/workflowImage.js +++ b/web/js/workflowImage.js @@ -386,7 +386,7 @@ class SvgWorkflowImage extends WorkflowImage { return; } else if (file && (file.type === "image/jpeg" || file.name?.endsWith(".jpg") || file.name?.endsWith(".jpeg"))) { if ( - await new Promise((r) => { + await new Promise((resolve) => { try { // This shouldnt go in here but it's easier than refactoring handleFile const reader = new FileReader(); diff --git a/web/js/workflows.js b/web/js/workflows.js index df84c51..b979f6e 100644 --- a/web/js/workflows.js +++ b/web/js/workflows.js @@ -81,6 +81,9 @@ async function saveWorkflow(name, workflow, overwrite) { class PysssssWorkflows { async load() { this.workflows = await getWorkflows(); + if(this.workflows.length) { + this.workflows.sort(); + } this.loadMenu.style.display = this.workflows.length ? "flex" : "none"; }