From b409b782c96b545a4b749a50f6f2459763f741d7 Mon Sep 17 00:00:00 2001 From: Aaron Burton Date: Fri, 13 Mar 2026 18:48:59 -0700 Subject: [PATCH] initial release - Augment node pack v1.0.0 --- .gitignore | 11 + LICENSE.MD | 9 + README.md | 213 ++++++++ __init__.py | 186 +++++++ install.py | 32 ++ py/blur_average.py | 96 ++++ py/circle_draw.py | 196 +++++++ py/construction_lines.py | 135 +++++ py/crypto.py | 109 ++++ py/draw_line.py | 197 +++++++ py/edge_detect.py | 46 ++ py/executor.py | 149 ++++++ py/face_crop.py | 102 ++++ py/fib.py | 231 ++++++++ py/grid.py | 107 ++++ py/grid_coords.py | 199 +++++++ py/image_crop.py | 50 ++ py/image_flip.py | 40 ++ py/image_place.py | 134 +++++ py/image_rotate.py | 48 ++ py/invert_image.py | 51 ++ py/json_extract.py | 61 +++ py/json_viewer.py | 40 ++ py/load_image.py | 114 ++++ py/logo_mask.py | 140 +++++ py/mask_bounding_box.py | 69 +++ py/preview.py | 197 +++++++ py/prim_node.py | 132 +++++ py/recolor.py | 115 ++++ py/svg_tools.py | 256 +++++++++ py/switch.py | 31 ++ py/trigger.py | 99 ++++ py/utils.py | 256 +++++++++ py/variables.py | 330 ++++++++++++ pyproject.toml | 20 + requirements.txt | 5 + web/js/augment_style.js | 28 + web/js/json_viewer.js | 89 ++++ web/js/preview_any.js | 34 ++ workflow_examples/augment_logo_crop.json | 649 +++++++++++++++++++++++ 40 files changed, 5006 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.MD create mode 100644 README.md create mode 100644 __init__.py create mode 100644 install.py create mode 100644 py/blur_average.py create mode 100644 py/circle_draw.py create mode 100644 py/construction_lines.py create mode 100644 py/crypto.py create mode 100644 py/draw_line.py create mode 100644 py/edge_detect.py create mode 100644 py/executor.py create mode 100644 py/face_crop.py create mode 100644 py/fib.py create mode 100644 py/grid.py create mode 100644 py/grid_coords.py create mode 100644 py/image_crop.py create mode 100644 py/image_flip.py create mode 100644 py/image_place.py create mode 100644 py/image_rotate.py create mode 100644 py/invert_image.py create mode 100644 py/json_extract.py create mode 100644 py/json_viewer.py create mode 100644 py/load_image.py create mode 100644 py/logo_mask.py create mode 100644 py/mask_bounding_box.py create mode 100644 py/preview.py create mode 100644 py/prim_node.py create mode 100644 py/recolor.py create mode 100644 py/svg_tools.py create mode 100644 py/switch.py create mode 100644 py/trigger.py create mode 100644 py/utils.py create mode 100644 py/variables.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 web/js/augment_style.js create mode 100644 web/js/json_viewer.js create mode 100644 web/js/preview_any.js create mode 100644 workflow_examples/augment_logo_crop.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1aa533 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +.env +.venv/ +*.log +.DS_Store +Thumbs.db diff --git a/LICENSE.MD b/LICENSE.MD new file mode 100644 index 0000000..fb5ec80 --- /dev/null +++ b/LICENSE.MD @@ -0,0 +1,9 @@ + + +Copyright 2026 Augment Studio + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..771cd56 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# Augment — ComfyUI Node Pack + +A design-focused custom node suite for [ComfyUI](https://github.com/comfyanonymous/ComfyUI), built for digital artists and technical designers who need precision, clean outputs, and scalable workflows. + +Augment brings grid systems, construction lines, vector conversion, masking, image transforms, and flow control utilities directly into your ComfyUI graph — so you can build production-ready pipelines without leaving the node canvas. + +--- + +## Node Categories + +### Vector + +Stop exporting pixel-locked assets. The vector nodes bring true scalable output into your generative workflow. Convert any raster image into a clean, editable SVG, or render an SVG back to a high-quality PNG with full alpha, all without leaving ComfyUI. + +**PNG to SVG** is built for logo and brand asset pipelines. Run a generated image straight into vectorization and get a production-ready SVG out the other end. Scalable to any size, ready for Illustrator, Figma, or print. **SVG to PNG** works the other way: load any SVG from your input directory and render it to a crisp PNG with alpha channel intact, feeding directly into the rest of your node graph. + +Both nodes are powered by the Augment Studio API. An API key is required and available at [augmentstudio.app/pricing](https://augmentstudio.app/pricing). All other Augment nodes work fully offline. + + +| Node | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------ | +| **PNG to SVG** | Vectorizes a raster image (PNG, JPG, WebP) into a clean, scalable SVG via the Augment API. Outputs SVG and JSON metadata | +| **SVG to PNG** | Renders an uploaded SVG to a high-quality PNG with full alpha channel. Outputs image and mask tensors | + + +--- + +### Image Processing + +Precision image manipulation nodes built around the objects and assets that matter most in design workflows. **Logo Mask** makes isolating logos and graphic elements dead simple: it auto-detects the background, thresholds the foreground, fills internal holes, and smooths contours — giving you a clean usable mask from just an image input, no manual selection required. **Mask Bounding Box** rounds out the masking toolkit by reading any mask and outputting the tight bounding box around its content as pixel coordinates, which can then feed directly into crop, placement, or construction line nodes. The crop nodes make it easy to isolate key subjects. **Face Crop** automatically detects and crops to the largest face in an image with configurable padding and square-crop options, while **Image Crop** handles manual region cropping. + +Every node in this section is trigger-aware. Wire them together and your image pipeline runs in exactly the order you designed it, allowing for crazy control. + + +| Node | Description | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Logo Mask** | Generates a clean filled mask from a logo or graphic element. Auto-detects background, fills holes, smooths contours. Outputs mask, mask image, and masked RGB | +| **Mask Bounding Box** | Reads any mask and outputs the tight bounding box around its content as pixel coordinates, with optional padding and threshold control | +| **Face Crop** | Auto-detects the largest face in an image and crops to it, with configurable padding and optional square crop. Outputs crop coordinates and a trigger | +| **Image Crop** | Crops an image to a defined region by coordinate and size | +| **Image Place** | Composites one image onto another at a specified position | +| **Invert Image** | Inverts pixel values across RGB channels | +| **Image Rotate** | Rotates an image by a given angle | +| **Image Flip** | Flips an image horizontally or vertically | + + +--- + +### Grid & Geometric Drawing + +The composition toolkit. These nodes are built for designers who think in systems grids, ratios, axes, and geometry. The **Grid Generator** is the centrepiece: a fully configurable grid engine that outputs a grid image, a mask, and all the dimension data you need to wire into other nodes. Everything else in this section is designed to work alongside it. + +**Grid Coords** is the companion node. A layout engine that takes a canvas size and a layout preset (golden ratio, rule of thirds, halves, quadrants) and a named region (top-left, center, bottom-right, etc.), then outputs exact pixel coordinates, dimensions, center point, and a mask for that region. Feed those coordinates directly into placement, crop, or construction line nodes to build compositionally precise workflows without guessing. + +The drawing nodes (Line Draw, Circle Draw, Fibonacci Spiral, Construction Line Overlay) all output both an image overlay and a mask, and are rendered at 2x supersampling for clean, anti-aliased results at any resolution. + + +| Node | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Grid Generator** | Generates a grid image with configurable rows, columns, line weight, color, and optional dotted lines. Outputs image, mask, and all dimension values | +| **Grid Coords** | Layout engine. Pick a layout preset (golden ratio, thirds, halves, quadrants) and a region name, get back pixel coordinates, dimensions, center point, and a region mask | +| **Line Draw** | Draws an anti-aliased line with presets (diagonals, center axes, outline) or fully custom coordinates. Supports extending lines to canvas edges | +| **Circle Draw** | Draws an anti-aliased circle or ellipse onto a canvas | +| **Fibonacci Spiral** | Renders a golden ratio spiral with optional subdivision rectangles, configurable iterations and line weight | +| **Construction Line Overlay** | Overlays dotted construction lines (bounding box, center axes, diagonals) onto an existing image from a bounding box input | +| **Shape Edge Detect** | Detects shape edges for contour and silhouette extraction | + + +--- + +### Color & Effects + + +| Node | Description | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| **Recolor (Mask)** | Recolors a masked region to a target color | +| **Blur Average (Dominant Color)** | Blurs or averages a region to its dominant color, great for when you need to grab a spefic color from an image, photo, or logo. | + + +--- + +### Primitives + +Typed value nodes with trigger in and trigger out, unlike ComfyUI's built-in primitives which are evaluated at graph compile time, these are execution-aware and can be wired into a specific point in your workflow's run order. + + +| Node | Description | +| ---------- | ------------------------------------------ | +| **Int** | Integer primitive with trigger passthrough | +| **Float** | Float primitive with trigger passthrough | +| **String** | String primitive with trigger passthrough | +| **Bool** | Boolean primitive with trigger passthrough | + + +--- + +### Variables + +Primitives on steroids. Variables are named, persistent values that live across nodes in your workflow. Set them once, read them anywhere, and mutate them through increment, decrement, or reset operations. Built for stateful and iterative workflows where a static primitive isn't enough. + + +| Node | Description | +| ------------------------------- | ------------------------------------------------------- | +| **Variable Set (Number)** | Assigns a value to a named number variable | +| **Variable Get (Number)** | Reads the current value of a named number variable | +| **Variable Increment (Number)** | Increments a named number variable by a given amount | +| **Variable Decrement (Number)** | Decrements a named number variable by a given amount | +| **Variable Reset (Number)** | Resets a named number variable back to its default | +| **Variable Set (String)** | Assigns a value to a named string variable | +| **Variable Get (String)** | Reads the current value of a named string variable | +| **Variable Delete (String)** | Removes a named string variable from the workflow scope | + + +--- + +### Flow Control + +By default, ComfyUI evaluates your entire graph at once, everything fires, everything runs. Flow Control breaks that open. These nodes let you sequence, gate, and branch your workflow so that things happen when you decide, not all at the same time. The result is less of a static graph and more of a pipeline you actually control. + +**Improved Type Nodes** are the backbone, trigger-aware versions of every major ComfyUI data type that hold their value and only pass it through once a trigger arrives. Chain them together and you get a workflow that moves step by step, each stage waiting on the one before it. **Switch** adds conditional logic: feed it a boolean and it fires down one of two paths, blocking the other entirely, true branching inside a ComfyUI graph. + + +| Node | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Improved Type Nodes** | Trigger-aware nodes for every major ComfyUI data type (Image, Mask, Latent, Model, Clip, VAE, Conditioning, Int, Float, String, Bool) — holds a value and only passes it through once a trigger arrives | +| **Switch (Improved)** | Takes a boolean and fires down either the true or false path, blocking the other — conditional branching inside your graph | + + +--- + +### Data & Intelligence + +Every Augment node that does meaningful work outputs structured JSON, coordinates, dimensions, colors, contour counts, layout data, whatever that node knows about what it just did. This section is how you use that data. + +**JSON Viewer** lets you inspect the output of any node inline in your graph, no exporting, no guessing. **JSON Extract** lets you pull specific values out by field name and wire them directly into other nodes, prompts, or variables. Today that means tighter, data-driven workflows where one node's output genuinely informs the next. The longer-term direction is feeding this structured data to an LLM so it can make decisions mid-workflow and write values back, closing the loop between generation and design logic in a way ComfyUI doesn't support out of the box. + + +| Node | Description | +| ---------------- | ------------------------------------------------------------------------------------------------------------- | +| **JSON Viewer** | Inspects and pretty-prints any Augment JSON output inline in the node canvas | +| **JSON Extract** | Pulls a specific value out of any Augment JSON by field name and outputs it as a string ready to wire forward | + + +--- + +### Preview & Save + +Improved versions of ComfyUI's core I/O nodes, all trigger-aware and all designed to stay out of your way while giving you more. Every node here passes its value through, so you can drop them anywhere in a chain without breaking it. + +**Load Image (Improved)** goes well beyond a standard image loader. Alongside the image it outputs dimensions, alpha mask, and individual R/G/B channel masks, all ready to wire into other nodes without any extra processing. **Preview Any** is particularly useful when building or debugging complex graphs: connect it to any text or numeric output — a JSON string, a variable, a coordinate — and it displays the value inline without interrupting the flow. + + +| Node | Description | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Load Image (Improved)** | Loads an image and outputs width, height, alpha mask, and individual R/G/B channel masks alongside the image tensor. Supports image override input and trigger passthrough | +| **Preview Image** | Previews an image inline in the node canvas without writing to the output folder. Passes the image through so the chain keeps running | +| **Save Image (Improved)** | Saves an image to the output folder with a custom filename prefix. Passes the image through with trigger out | +| **Preview Any** | Displays any text or numeric value — strings, numbers, JSON — as readable text in the node UI. Useful for inspecting outputs mid-graph without interrupting the workflow | + + +--- + +## Installation + +Augment is not yet listed on the ComfyUI registry. Install it manually by cloning the repository directly into your `custom_nodes` folder. + +### Step 1: Clone the repository + +```bash +git clone https://github.com/augmentstudio/augment-ComfyUI augment +cd augment +``` + +### Step 2: Install dependencies + +```bash +cd augment +pip install -r requirements.txt +``` + +### Step 3: Restart ComfyUI + +Restart ComfyUI. Augment nodes will appear under the **Augment** category in the node search. + +--- + +## Vector Nodes: API Key + +The **PNG to SVG** and **SVG to PNG** nodes connect to the Augment Studio API for high-quality vectorization. You will need an API key: + +1. Visit [augmentstudio.app/pricing](https://augmentstudio.app/pricing) +2. Create an account and obtain your API key +3. Paste the key into the `api_key` field on the vector nodes + +All other nodes work fully offline without an API key. + +--- + +## Requirements + +- ComfyUI (latest) +- Python 3.10+ +- `opencv-python >= 4.5.0` +- `numpy >= 1.19.0` +- `torch >= 1.9.0` +- `Pillow >= 8.0.0` +- `requests >= 2.25.0` + +--- + +## License + +MIT License: Copyright 2026 Augment Studio. See [LICENSE.MD](LICENSE.MD) for details. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..db3e177 --- /dev/null +++ b/__init__.py @@ -0,0 +1,186 @@ +""" +Augment - ComfyUI Custom Node Pack +Resilient loader: each node imports independently so one failure won't block the rest. +""" + +import traceback + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} +_failed = [] + + +def _register(module_path, imports, mappings): + """ + Try to import `imports` from `module_path` and register each into the global dicts. + + imports: list of (ClassName,) — names to import from the module + mappings: dict of { "NodeID": ("ClassName", "Display Name") } + """ + try: + mod = __import__(module_path, fromlist=[c for c in imports]) + except Exception as e: + for node_id, (cls_name, display) in mappings.items(): + _failed.append((node_id, e)) + print(f"[augment] ⚠ {node_id} unavailable: {e}") + return + + for node_id, (cls_name, display) in mappings.items(): + cls = getattr(mod, cls_name, None) + if cls is None: + _failed.append((node_id, f"{cls_name} not found in {module_path}")) + print(f"[augment] ⚠ {node_id} unavailable: {cls_name} not found in {module_path}") + continue + NODE_CLASS_MAPPINGS[node_id] = cls + NODE_DISPLAY_NAME_MAPPINGS[node_id] = display + + +# ── Executor Override (must import first to patch ComfyUI) ── +try: + __import__("custom_nodes.augment.py.executor") +except Exception as e: + print(f"[augment] ⚠ Executor patch unavailable: {e}") + +# ── Image Processing & Cropping ── +_register("custom_nodes.augment.py.face_crop", ["FaceCrop"], { + "AugmentFaceCrop": ("FaceCrop", "Face Crop"), +}) + +_register("custom_nodes.augment.py.image_crop", ["ImageCrop"], { + "AugmentImageCrop": ("ImageCrop", "Image Crop"), +}) +_register("custom_nodes.augment.py.image_place", ["ImagePlaceNode"], { + "AugmentImagePlace": ("ImagePlaceNode", "Image Place"), +}) +_register("custom_nodes.augment.py.invert_image", ["InvertImageNode"], { + "AugmentInvertImage": ("InvertImageNode", "Invert Image"), +}) +_register("custom_nodes.augment.py.image_rotate", ["ImageRotateNode"], { + "AugmentImageRotate": ("ImageRotateNode", "Image Rotate"), +}) +_register("custom_nodes.augment.py.image_flip", ["ImageFlipNode"], { + "AugmentImageFlip": ("ImageFlipNode", "Image Flip"), +}) +_register("custom_nodes.augment.py.logo_mask", ["LogoMask"], { + "AugmentLogoMask": ("LogoMask", "Logo Mask"), +}) +_register("custom_nodes.augment.py.mask_bounding_box", ["MaskBoundingBox"], { + "AugmentMaskBoundingBox": ("MaskBoundingBox", "Mask Bounding Box"), +}) +# ── Grid & Construction Lines ── +_register("custom_nodes.augment.py.grid", ["GridGenerator"], { + "AugmentGridGenerator": ("GridGenerator", "Grid Generator"), +}) +_register("custom_nodes.augment.py.grid_coords", ["GridCoordsNode"], { + "AugmentGridCoords": ("GridCoordsNode", "Grid Coords"), +}) +_register("custom_nodes.augment.py.draw_line", ["LineDrawNode"], { + "AugmentLineDraw": ("LineDrawNode", "Line Draw"), +}) +_register("custom_nodes.augment.py.circle_draw", ["CircleDrawNode"], { + "AugmentCircleDraw": ("CircleDrawNode", "Circle Draw"), +}) +_register("custom_nodes.augment.py.fib", ["FibonacciSpiralNode"], { + "AugmentFibonacciSpiral": ("FibonacciSpiralNode", "Fibonacci Spiral"), +}) +_register("custom_nodes.augment.py.construction_lines", ["ConstructionLineOverlay"], { + "AugmentConstructionLineOverlay": ("ConstructionLineOverlay", "Construction Line Overlay"), +}) +_register("custom_nodes.augment.py.edge_detect", ["ShapeEdgeDetect"], { + "AugmentShapeEdgeDetect": ("ShapeEdgeDetect", "Shape Edge Detect"), +}) + +# ── Color & Effects ── +_register("custom_nodes.augment.py.recolor", ["RecolorNode"], { + "AugmentRecolor": ("RecolorNode", "Recolor (Mask)"), +}) +_register("custom_nodes.augment.py.blur_average", ["BlurAverageNode"], { + "AugmentBlurAverage": ("BlurAverageNode", "Blur Average (Dominant Color)"), +}) + +# ── Primitives ── +_register("custom_nodes.augment.py.prim_node", ["IntNode", "FloatNode", "StringNode", "BoolNode"], { + "AugmentIntPrim": ("IntNode", "Int"), + "AugmentFloatPrim": ("FloatNode", "Float"), + "AugmentStringPrim": ("StringNode", "String"), + "AugmentBoolPrim": ("BoolNode", "Bool"), +}) + +# ── Variables ── +_register("custom_nodes.augment.py.variables", [ + "NumberVarSetNode", "NumberVarGetNode", "NumberVarIncrementNode", + "NumberVarDecrementNode", "NumberVarResetNode", + "StringVarSetNode", "StringVarGetNode", "StringVarDeleteNode", +], { + "AugmentNumberVarSet": ("NumberVarSetNode", "Variable Set (Number)"), + "AugmentNumberVarGet": ("NumberVarGetNode", "Variable Get (Number)"), + "AugmentNumberVarIncrement": ("NumberVarIncrementNode", "Variable Increment (Number)"), + "AugmentNumberVarDecrement": ("NumberVarDecrementNode", "Variable Decrement (Number)"), + "AugmentNumberVarReset": ("NumberVarResetNode", "Variable Reset (Number)"), + "AugmentStringVarSet": ("StringVarSetNode", "Variable Set (String)"), + "AugmentStringVarGet": ("StringVarGetNode", "Variable Get (String)"), + "AugmentStringVarDelete": ("StringVarDeleteNode", "Variable Delete (String)"), +}) + +# ── Flow Control ── +try: + from .py.trigger import make_wait_node, WAIT_TYPES + for _name, _type_str in WAIT_TYPES.items(): + _cls_name = f"AugmentWait{_name}" + try: + NODE_CLASS_MAPPINGS[_cls_name] = make_wait_node(_name, _type_str) + NODE_DISPLAY_NAME_MAPPINGS[_cls_name] = f"{_name} (Improved)" + except Exception as e: + _failed.append((_cls_name, e)) + print(f"[augment] ⚠ {_cls_name} unavailable: {e}") +except Exception as e: + _failed.append(("Wait nodes", e)) + print(f"[augment] ⚠ Flow control nodes unavailable: {e}") + + +# PAID + +_register("custom_nodes.augment.py.svg_tools", ["AugmentSVGToPNG", "AugmentPNGToSVG"], { + "AugmentSVGToPNG": ("AugmentSVGToPNG", "Augment SVG to PNG"), + "AugmentPNGToSVG": ("AugmentPNGToSVG", "Augment PNG to SVG"), +}) + +# ── Crypto & Utilities ── +_register("custom_nodes.augment.py.crypto", ["SHA256Node", "RandomNumberNode", "UUIDNode"], { + "AugmentSHA256": ("SHA256Node", "SHA-256 Hash"), + "AugmentRandomNumber": ("RandomNumberNode", "Random Number"), + "AugmentUUID": ("UUIDNode", "UUID Generator"), +}) +_register("custom_nodes.augment.py.json_viewer", ["JsonViewerNode"], { + "AugmentJsonViewer": ("JsonViewerNode", "JSON Viewer"), +}) + +_register("custom_nodes.augment.py.json_extract", ["JsonExtractNode"], { + "AugmentJsonExtract": ("JsonExtractNode", "JSON Extract"), +}) + +# ── Preview & Save ── +_register("custom_nodes.augment.py.preview", ["AugmentPreviewImage", "AugmentSaveImage", "AugmentPreviewAny"], { + "AugmentPreviewImage": ("AugmentPreviewImage", "Preview Image"), + "AugmentSaveImage": ("AugmentSaveImage", "Save Image (Improved)"), + "AugmentPreviewAny": ("AugmentPreviewAny", "Preview Any"), +}) + +_register("custom_nodes.augment.py.load_image", ["AugmentLoadImage"], { + "AugmentLoadImage": ("AugmentLoadImage", "Load Image (Improved)"), +}) + +_register("custom_nodes.augment.py.switch", ["SwitchNode"], { + "AugmentSwitchNode": ("SwitchNode", "Switch Improved"), +}) + +# ── Summary ── +WEB_DIRECTORY = "./web/js" +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] + +if _failed: + print(f"[augment] ✓ Registered {len(NODE_CLASS_MAPPINGS)} nodes ({len(_failed)} failed)") + for node_id, err in _failed: + print(f"[augment] ✗ {node_id}: {err}") +else: + print(f"[augment] ✓ Registered {len(NODE_CLASS_MAPPINGS)} nodes") diff --git a/install.py b/install.py new file mode 100644 index 0000000..f1c3b18 --- /dev/null +++ b/install.py @@ -0,0 +1,32 @@ +""" +Auto-install dependencies for Augment nodes. +ComfyUI will run this automatically when the node pack is loaded. +""" + +import subprocess +import sys +import os + +def install(): + """Install required packages.""" + requirements_path = os.path.join(os.path.dirname(__file__), "requirements.txt") + + if not os.path.exists(requirements_path): + print("[augment] No requirements.txt found, skipping dependency install") + return + + print("[augment] Checking dependencies...") + + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "-r", requirements_path, "--quiet"], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE + ) + print("[augment] ✓ Dependencies installed/verified") + except subprocess.CalledProcessError as e: + print(f"[augment] ⚠ Warning: Could not install dependencies: {e}") + print("[augment] Please manually run: pip install -r requirements.txt") + +if __name__ == "__main__": + install() diff --git a/py/blur_average.py b/py/blur_average.py new file mode 100644 index 0000000..3bf3a78 --- /dev/null +++ b/py/blur_average.py @@ -0,0 +1,96 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json +import torch +import numpy as np +from PIL import Image +import folder_paths +import os + + +class BlurAverageNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "FLOAT", "FLOAT", "FLOAT", "JSON", "TRIGGER") + RETURN_NAMES = ("color_swatch","R", "G", "B", "json", "trigger") + OUTPUT_NODE = True + FUNCTION = "execute" + CATEGORY = "Augment/Image" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, image, trigger=None): + # image tensor shape: (batch, height, width, channels) with values 0-1 + img = image[0] + + # Mean across all pixels + r_avg = img[:, :, 0].mean().item() + g_avg = img[:, :, 1].mean().item() + b_avg = img[:, :, 2].mean().item() + + # Convert to 0-255 + r_255 = round(r_avg * 255) + g_255 = round(g_avg * 255) + b_255 = round(b_avg * 255) + + hex_color = "#{:02X}{:02X}{:02X}".format(r_255, g_255, b_255) + + # --- Generate swatch as IMAGE output (64x64) --- + swatch = torch.zeros(1, 64, 64, 3) + swatch[:, :, :, 0] = r_avg + swatch[:, :, :, 1] = g_avg + swatch[:, :, :, 2] = b_avg + + # --- Save temp image for in-node preview --- + preview_img = Image.new("RGB", (64, 64), (r_255, g_255, b_255)) + temp_dir = folder_paths.get_temp_directory() + os.makedirs(temp_dir, exist_ok=True) + preview_filename = f"blur_avg_{hex_color.lstrip('#')}.png" + preview_path = os.path.join(temp_dir, preview_filename) + preview_img.save(preview_path) + + results = { + "r": r_255, + "g": g_255, + "b": b_255, + "hex": hex_color, + "r_float": round(r_avg, 6), + "g_float": round(g_avg, 6), + "b_float": round(b_avg, 6), + } + + return { + "ui": { + "images": [ + { + "filename": preview_filename, + "subfolder": "", + "type": "temp", + } + ] + }, + "result": (json.dumps(results, indent=2), float(r_255), float(g_255), float(b_255), swatch, "done"), + } + + +NODE_CLASS_MAPPINGS = { + "BlurAverageNode": BlurAverageNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "BlurAverageNode": "Blur Average (Dominant Color)", +} + diff --git a/py/circle_draw.py b/py/circle_draw.py new file mode 100644 index 0000000..16b59af --- /dev/null +++ b/py/circle_draw.py @@ -0,0 +1,196 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import torch +import numpy as np +from PIL import Image, ImageDraw +import json +import math + +PHI = 1.6180339887 + + +class CircleDrawNode: + """ + Draw construction circles. Three modes: + - fibonacci: reads spiral JSON and draws circles at each subdivision step + - single: one circle from manual center_x/center_y/radius + - concentric: multiple circles from center, scaled by golden ratio + """ + + MODES = ["fibonacci", "single", "concentric"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 1}), + "height": ("INT", {"default": 632, "min": 64, "max": 8192, "step": 1}), + "mode": (cls.MODES, {"default": "fibonacci"}), + "center_x": ("INT", {"default": 512, "min": -8192, "max": 8192, "step": 1}), + "center_y": ("INT", {"default": 316, "min": -8192, "max": 8192, "step": 1}), + "radius": ("INT", {"default": 200, "min": 1, "max": 8192, "step": 1}), + "rings": ("INT", {"default": 5, "min": 1, "max": 12, "step": 1}), + "line_weight": ("INT", {"default": 1, "min": 1, "max": 20, "step": 1}), + "line_color": ("STRING", {"default": "#FFFFFF"}), + "dotted": ("BOOLEAN", {"default": False}), + "invert": ("BOOLEAN", {"default": False}), + }, + "optional": { + "spiral_json": ("AUGMENT_JSON",), + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "mask", "json_result", "trigger") + FUNCTION = "draw" + CATEGORY = "Augment/Grid" + + @staticmethod + def _hex_to_rgb(hex_str): + hex_str = hex_str.lstrip("#") + if len(hex_str) == 3: + hex_str = "".join(c * 2 for c in hex_str) + return tuple(int(hex_str[i : i + 2], 16) for i in (0, 2, 4)) + + @staticmethod + def _draw_circle(draw, cx, cy, r, color, weight, dotted=False): + """Draw a single circle, optionally dotted.""" + if r < 1: + return + + bbox = [cx - r, cy - r, cx + r, cy + r] + + if not dotted: + draw.ellipse(bbox, outline=color, width=weight) + else: + # Draw dotted circle using small arcs + steps = max(36, int(r * 0.5)) + gap = 360 / steps + for i in range(0, steps, 2): + start = i * gap + end = start + gap + draw.arc(bbox, start=start, end=end, fill=color, width=weight) + + def draw( + self, + width, + height, + mode, + center_x, + center_y, + radius, + rings, + line_weight, + line_color, + dotted, + invert, + spiral_json=None, + trigger=None, + ): + color = self._hex_to_rgb(line_color) + + # Supersampling + ss = 2 + sw, sh = width * ss, height * ss + slw = line_weight * ss + + img = Image.new("RGBA", (sw, sh), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + circles_data = [] + + if mode == "fibonacci" and spiral_json: + # Parse spiral JSON and draw circles at each step + if isinstance(spiral_json, str): + spiral = json.loads(spiral_json) + else: + spiral = spiral_json + + steps = spiral.get("steps", []) + for step in steps: + cx = step["arc_center"]["x"] * ss + cy = step["arc_center"]["y"] * ss + r = step["radius"] * ss + + self._draw_circle(draw, cx, cy, r, color + (255,), slw, dotted) + + # Also draw small crosshair at center + ch_size = max(4, slw * 2) + draw.line([(cx - ch_size, cy), (cx + ch_size, cy)], fill=color + (120,), width=max(1, slw // 2)) + draw.line([(cx, cy - ch_size), (cx, cy + ch_size)], fill=color + (120,), width=max(1, slw // 2)) + + circles_data.append({ + "center": {"x": step["arc_center"]["x"], "y": step["arc_center"]["y"]}, + "radius": step["radius"], + "iteration": step["iteration"], + }) + + elif mode == "concentric": + # Draw rings scaling by golden ratio from center + cx = center_x * ss + cy = center_y * ss + r = radius * ss + + for i in range(rings): + current_r = int(r) + self._draw_circle(draw, cx, cy, current_r, color + (255,), slw, dotted) + + # Crosshair at center (only on first) + if i == 0: + ch_size = max(6, slw * 3) + draw.line([(cx - ch_size, cy), (cx + ch_size, cy)], fill=color + (120,), width=max(1, slw // 2)) + draw.line([(cx, cy - ch_size), (cx, cy + ch_size)], fill=color + (120,), width=max(1, slw // 2)) + + circles_data.append({ + "center": {"x": center_x, "y": center_y}, + "radius": int(current_r // ss), + "ring": i, + "scale": round(1 / (PHI ** i), 4), + }) + + r = r / PHI + + else: + # Single circle + cx = center_x * ss + cy = center_y * ss + r = radius * ss + + self._draw_circle(draw, cx, cy, int(r), color + (255,), slw, dotted) + + # Crosshair + ch_size = max(6, slw * 3) + draw.line([(cx - ch_size, cy), (cx + ch_size, cy)], fill=color + (120,), width=max(1, slw // 2)) + draw.line([(cx, cy - ch_size), (cx, cy + ch_size)], fill=color + (120,), width=max(1, slw // 2)) + + circles_data.append({ + "center": {"x": center_x, "y": center_y}, + "radius": radius, + }) + + # Downsample + img = img.resize((width, height), Image.LANCZOS) + + # Build output + result = { + "mode": mode, + "canvas": [width, height], + "circles": circles_data, + } + json_result = json.dumps(result, indent=2) + + # Convert + img_np = np.array(img) + alpha = img_np[:, :, 3].astype(np.float32) / 255.0 + if invert: + alpha = 1.0 - alpha + + rgb = img_np[:, :, :3].astype(np.float32) / 255.0 + image_tensor = torch.from_numpy(rgb).unsqueeze(0) + mask_tensor = torch.from_numpy(alpha).unsqueeze(0) + + return (image_tensor, mask_tensor, json_result, trigger) + diff --git a/py/construction_lines.py b/py/construction_lines.py new file mode 100644 index 0000000..aa8ac4e --- /dev/null +++ b/py/construction_lines.py @@ -0,0 +1,135 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import numpy as np +import torch +import json +from PIL import Image, ImageDraw + + +LINE_COLOR = (180, 180, 180) + + +class ConstructionLineOverlay: + """Draws construction lines on an image from bounding box.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "x": ("INT", {"default": 0}), + "y": ("INT", {"default": 0}), + "width": ("INT", {"default": 100}), + "height": ("INT", {"default": 100}), + }, + "optional": { + "show_bbox": ("BOOLEAN", {"default": True}), + "show_centers": ("BOOLEAN", {"default": True}), + "show_diagonals": ("BOOLEAN", {"default": True}), + "dot_weight": ("INT", {"default": 3, "min": 1, "max": 20, "step": 1}), + "dot_spacing": ("INT", {"default": 10, "min": 1, "max": 60, "step": 1}), + "show_coords": ("BOOLEAN", {"default": False}), + }, + } + + RETURN_TYPES = ("IMAGE", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "json_result", "trigger") + FUNCTION = "draw" + CATEGORY = "Augment/Grid" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def draw(self, image, x, y, width, height, + show_bbox=True, show_centers=True, show_diagonals=True, + dot_weight=3, dot_spacing=10, show_coords=False): + + dot_weight = max(1, dot_weight) + dot_spacing = max(3, dot_spacing) + + img_np = (image[0].cpu().numpy() * 255).astype(np.uint8) + img_h, img_w = img_np.shape[:2] + pil_img = Image.fromarray(img_np, "RGB") + draw = ImageDraw.Draw(pil_img) + + x0, y0 = x, y + x1, y1 = x + width - 1, y + height - 1 + cx, cy = x + width // 2, y + height // 2 + r = dot_weight / 2.0 + + if show_diagonals: + _draw_dotted_line_any(draw, (x0, y0), (x1, y1), LINE_COLOR, r, dot_spacing) + _draw_dotted_line_any(draw, (x1, y0), (x0, y1), LINE_COLOR, r, dot_spacing) + + if show_centers: + _draw_dotted_line_h(draw, 0, img_w, cy, LINE_COLOR, r, dot_spacing) + _draw_dotted_line_v(draw, 0, img_h, cx, LINE_COLOR, r, dot_spacing) + + if show_bbox: + _draw_dotted_line_h(draw, x0, x1, y0, LINE_COLOR, r, dot_spacing) + _draw_dotted_line_h(draw, x0, x1, y1, LINE_COLOR, r, dot_spacing) + _draw_dotted_line_v(draw, y0, y1, x0, LINE_COLOR, r, dot_spacing) + _draw_dotted_line_v(draw, y0, y1, x1, LINE_COLOR, r, dot_spacing) + + if show_coords: + _draw_label(draw, f"{width}px", (cx, y0 - 10), LINE_COLOR, align="center") + _draw_label(draw, f"{height}px", (x1 + 8, cy), LINE_COLOR, align="left") + + result_np = np.array(pil_img).astype(np.float32) / 255.0 + result = torch.from_numpy(result_np).unsqueeze(0) + + json_result = json.dumps({ + "node": "ConstructionLineOverlay", + "x": x, "y": y, "width": width, "height": height, + "center": [cx, cy], + }) + + return (result, json_result, "done") + + +def _draw_dotted_line_h(draw, x_start, x_end, y_pos, color, r, spacing): + """Horizontal dotted line using evenly spaced circles (matches grid.py).""" + x = float(x_start) + while x <= x_end: + draw.ellipse([x - r, y_pos - r, x + r, y_pos + r], fill=color) + x += spacing + + +def _draw_dotted_line_v(draw, y_start, y_end, x_pos, color, r, spacing): + """Vertical dotted line using evenly spaced circles (matches grid.py).""" + y = float(y_start) + while y <= y_end: + draw.ellipse([x_pos - r, y - r, x_pos + r, y + r], fill=color) + y += spacing + + +def _draw_dotted_line_any(draw, p1, p2, color, r, spacing): + """Diagonal dotted line using evenly spaced circles.""" + x0, y0 = p1 + x1, y1 = p2 + dx, dy = x1 - x0, y1 - y0 + length = (dx * dx + dy * dy) ** 0.5 + if length < 1: + return + ux, uy = dx / length, dy / length + pos = 0.0 + while pos <= length: + px = x0 + ux * pos + py = y0 + uy * pos + draw.ellipse([px - r, py - r, px + r, py + r], fill=color) + pos += spacing + + +def _draw_label(draw, text, position, color, align="left"): + """Draw a small coordinate label.""" + x, y = position + try: + from PIL import ImageFont + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 11) + except Exception: + font = ImageFont.load_default() + anchor = "rm" if align == "right" else ("mm" if align == "center" else "lm") + draw.text((x, y), text, fill=color, font=font, anchor=anchor) \ No newline at end of file diff --git a/py/crypto.py b/py/crypto.py new file mode 100644 index 0000000..ef9dc03 --- /dev/null +++ b/py/crypto.py @@ -0,0 +1,109 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import hashlib +import secrets +import time +import json + + +class SHA256Node: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "encode": ("STRING", {"forceInput": True}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("STRING", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("hash", "json", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, encode, trigger=None): + h = hashlib.sha256(encode.encode("utf-8")).hexdigest() + json_result = json.dumps({"node": "SHA-256 Hash", "input": encode, "hash": h, "algorithm": "sha256"}) + return {"ui": {"text": [h]}, "result": (h, json_result, "done")} + + +class RandomNumberNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("INT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("number", "json", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + @classmethod + def IS_CHANGED(cls, **kwargs): + return time.time() + + def execute(self, trigger=None): + n = secrets.randbelow(1000000000) + json_result = json.dumps({"node": "Random Number", "number": n, "max": 1000000000}) + return {"ui": {"text": [str(n)]}, "result": (n, json_result, "done")} + + +class UUIDNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("STRING", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("uuid", "json", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + @classmethod + def IS_CHANGED(cls, **kwargs): + return time.time() + + def execute(self, trigger=None): + u = secrets.token_hex(16) + json_result = json.dumps({"node": "UUID Generator", "uuid": u, "length": 32}) + return {"ui": {"text": [u]}, "result": (u, json_result, "done")} + + +NODE_CLASS_MAPPINGS = { + "SHA256Node": SHA256Node, + "RandomNumberNode": RandomNumberNode, + "UUIDNode": UUIDNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SHA256Node": "SHA-256 Hash", + "RandomNumberNode": "Random Number", + "UUIDNode": "UUID Generator", +} diff --git a/py/draw_line.py b/py/draw_line.py new file mode 100644 index 0000000..7445d1e --- /dev/null +++ b/py/draw_line.py @@ -0,0 +1,197 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import torch +import numpy as np +from PIL import Image, ImageDraw +import json +import math + + +class LineDrawNode: + """ + Draw a line from point A to point B. + Outputs image overlay and mask. Supersampled 2x for clean anti-aliased lines. + """ + + PRESETS = [ + "custom", + "vertical_center", + "horizontal_center", + "diagonal_left", + "diagonal_right", + "outline", + ] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 1}), + "height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 1}), + "preset": (cls.PRESETS, {"default": "diagonal_left"}), + "x1": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "y1": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "x2": ("INT", {"default": 1024, "min": -8192, "max": 8192, "step": 1}), + "y2": ("INT", {"default": 1024, "min": -8192, "max": 8192, "step": 1}), + "line_weight": ("INT", {"default": 2, "min": 1, "max": 20, "step": 1}), + "line_color": ("STRING", {"default": "#FFFFFF"}), + "extend": ("BOOLEAN", {"default": False}), + "invert": ("BOOLEAN", {"default": False}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "mask", "json_result", "trigger") + FUNCTION = "draw" + CATEGORY = "Augment/Grid" + + @staticmethod + def _hex_to_rgb(hex_str): + hex_str = hex_str.lstrip("#") + if len(hex_str) == 3: + hex_str = "".join(c * 2 for c in hex_str) + return tuple(int(hex_str[i : i + 2], 16) for i in (0, 2, 4)) + + @staticmethod + def _extend_line(x1, y1, x2, y2, w, h): + """Extend line to canvas edges.""" + dx = x2 - x1 + dy = y2 - y1 + + if dx == 0 and dy == 0: + return x1, y1, x2, y2 + + # Find parameter t where line hits each edge + # line: (x1 + t*dx, y1 + t*dy) + t_values = [] + + if dx != 0: + # Left edge: x = 0 + t = -x1 / dx + y_at = y1 + t * dy + if 0 <= y_at <= h: + t_values.append(t) + # Right edge: x = w + t = (w - x1) / dx + y_at = y1 + t * dy + if 0 <= y_at <= h: + t_values.append(t) + + if dy != 0: + # Top edge: y = 0 + t = -y1 / dy + x_at = x1 + t * dx + if 0 <= x_at <= w: + t_values.append(t) + # Bottom edge: y = h + t = (h - y1) / dy + x_at = x1 + t * dx + if 0 <= x_at <= w: + t_values.append(t) + + if len(t_values) < 2: + return x1, y1, x2, y2 + + t_min = min(t_values) + t_max = max(t_values) + + ex1 = int(x1 + t_min * dx) + ey1 = int(y1 + t_min * dy) + ex2 = int(x1 + t_max * dx) + ey2 = int(y1 + t_max * dy) + + return ex1, ey1, ex2, ey2 + + def draw(self, width, height, preset, x1, y1, x2, y2, line_weight, line_color, extend, invert, trigger=None): + color = self._hex_to_rgb(line_color) + + # Resolve preset into coordinates + lines = [] # list of (x1, y1, x2, y2) — outline draws 4 lines + + if preset == "vertical_center": + mx = width // 2 + lines = [(mx, 0, mx, height)] + elif preset == "horizontal_center": + my = height // 2 + lines = [(0, my, width, my)] + elif preset == "diagonal_left": + lines = [(0, 0, width, height)] + elif preset == "diagonal_right": + lines = [(width, 0, 0, height)] + elif preset == "outline": + lines = [ + (0, 0, width - 1, 0), + (width - 1, 0, width - 1, height - 1), + (width - 1, height - 1, 0, height - 1), + (0, height - 1, 0, 0), + ] + else: + # custom — use raw x1/y1/x2/y2 + lines = [(x1, y1, x2, y2)] + + # Supersampling + ss = 2 + sw, sh = width * ss, height * ss + slw = line_weight * ss + + img = Image.new("RGBA", (sw, sh), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + drawn_lines = [] + for lx1, ly1, lx2, ly2 in lines: + sx1, sy1, sx2, sy2 = lx1 * ss, ly1 * ss, lx2 * ss, ly2 * ss + + if extend and preset == "custom": + sx1, sy1, sx2, sy2 = self._extend_line(sx1, sy1, sx2, sy2, sw, sh) + + draw.line([(sx1, sy1), (sx2, sy2)], fill=color + (255,), width=slw) + + # Track actual coords for JSON + ax1, ay1, ax2, ay2 = sx1 // ss, sy1 // ss, sx2 // ss, sy2 // ss + dx = ax2 - ax1 + dy = ay2 - ay1 + length = math.sqrt(dx * dx + dy * dy) + angle = math.degrees(math.atan2(dy, dx)) + + drawn_lines.append({ + "a": {"x": ax1, "y": ay1}, + "b": {"x": ax2, "y": ay2}, + "midpoint": {"x": (ax1 + ax2) // 2, "y": (ay1 + ay2) // 2}, + "length": round(length, 2), + "angle": round(angle, 2), + }) + + # Downsample + img = img.resize((width, height), Image.LANCZOS) + + result = { + "preset": preset, + "canvas": [width, height], + "lines": drawn_lines, + } + + if extend and preset == "custom": + ex1, ey1, ex2, ey2 = self._extend_line(x1, y1, x2, y2, width, height) + result["extended"] = { + "a": {"x": ex1, "y": ey1}, + "b": {"x": ex2, "y": ey2}, + } + + json_result = json.dumps(result, indent=2) + + # Convert + img_np = np.array(img) + alpha = img_np[:, :, 3].astype(np.float32) / 255.0 + if invert: + alpha = 1.0 - alpha + + rgb = img_np[:, :, :3].astype(np.float32) / 255.0 + image_tensor = torch.from_numpy(rgb).unsqueeze(0) + mask_tensor = torch.from_numpy(alpha).unsqueeze(0) + + return (image_tensor, mask_tensor, json_result, trigger) \ No newline at end of file diff --git a/py/edge_detect.py b/py/edge_detect.py new file mode 100644 index 0000000..c24a40c --- /dev/null +++ b/py/edge_detect.py @@ -0,0 +1,46 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +from .utils import compute_smart_edges +import json + + +class ShapeEdgeDetect: + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mask": ("MASK",), + }, + "optional": { + "symmetry_threshold": ("FLOAT", {"default": 0.85, "min": 0.5, "max": 0.99, "step": 0.05}), + }, + } + + RETURN_TYPES = ("EDGE_DATA", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("edges", "json_result", "trigger") + FUNCTION = "detect" + CATEGORY = "Augment/Grid" + + def detect(self, mask, symmetry_threshold=0.85): + m = mask[0].cpu().numpy() + edges = compute_smart_edges(m, symmetry_threshold) + + h_count = len(edges["h_lines"]) + v_count = len(edges["v_lines"]) + d45_count = len(edges["d45_lines"]) + d135_count = len(edges["d135_lines"]) + + print(f"[augment] edges: {h_count}H / {v_count}V / {d45_count}d45 / {d135_count}d135") + + json_result = json.dumps({ + "node": "ShapeEdgeDetect", + "h_lines": h_count, + "v_lines": v_count, + "d45_lines": d45_count, + "d135_lines": d135_count, + }) + + return (edges, json_result, "done") \ No newline at end of file diff --git a/py/executor.py b/py/executor.py new file mode 100644 index 0000000..a8a8fc0 --- /dev/null +++ b/py/executor.py @@ -0,0 +1,149 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +""" +Augment Executor Override +───────────────────────────── +Bypasses ComfyUI's caching for trigger-driven workflows. + +What it does: +1. Monkeypatches the execute() function in ComfyUI's execution module +2. Any node class with ALWAYS_EXECUTE = True skips the output cache — always re-runs +3. Any node that accepts/outputs TRIGGER type is auto-detected and also skips cache +4. Everything else executes normally — zero impact on other workflows + +No fork needed. No CLI flags. Just drop and go. +""" + +import logging +import execution as comfy_execution +from comfy_execution.caching import BasicCache, CacheKeySetInputSignature, HierarchicalCache + +logger = logging.getLogger("Augment.Executor") + +# ── Store reference to the original execute function ───────────── +_original_execute = comfy_execution.execute + + +def _should_skip_cache(class_def): + """ + Determine if a node class should bypass the output cache. + + Returns True if: + - Node has ALWAYS_EXECUTE = True + - Node accepts or returns TRIGGER type + - Node has IS_CHANGED that returns float("NaN") (legacy force-run pattern) + """ + # Explicit opt-in + if getattr(class_def, "ALWAYS_EXECUTE", False): + return True + + # Check if node deals with TRIGGER types (input or output) + try: + input_types = class_def.INPUT_TYPES() + for category in ("required", "optional"): + if category in input_types: + for key, val in input_types[category].items(): + input_type = val[0] if isinstance(val, tuple) else val + if input_type == "TRIGGER": + return True + except Exception: + pass + + # Check return types for TRIGGER + if hasattr(class_def, "RETURN_TYPES"): + for rt in class_def.RETURN_TYPES: + if rt == "TRIGGER": + return True + + return False + + +async def patched_execute(server, dynprompt, caches, current_item, extra_data, + executed, prompt_id, execution_list, + pending_subgraph_results, pending_async_nodes, ui_outputs): + """ + Drop-in replacement for execution.execute(). + + For nodes that should skip cache: + - Evict any cached output BEFORE the original execute() runs + - This forces the original code past the cache-hit early return (line 419-428) + - The node executes fresh every time + - After execution, evict again so next run also re-executes + + For all other nodes: passthrough to original, zero overhead. + """ + import nodes + + unique_id = current_item + node_info = dynprompt.get_node(unique_id) + class_type = node_info["class_type"] + class_def = nodes.NODE_CLASS_MAPPINGS[class_type] + + skip_cache = _should_skip_cache(class_def) + + if skip_cache: + # Evict cached output so the original execute() can't short-circuit + try: + caches.outputs.delete(unique_id) + except (AttributeError, KeyError): + # NullCache or key doesn't exist — fine + pass + + # Run the original execute + result = await _original_execute( + server, dynprompt, caches, current_item, extra_data, + executed, prompt_id, execution_list, + pending_subgraph_results, pending_async_nodes, ui_outputs + ) + + if skip_cache: + # Evict AFTER execution too, so next prompt also re-runs this node + try: + caches.outputs.delete(unique_id) + except (AttributeError, KeyError): + pass + + return result + + +# ── Also patch the cache's set_prompt to skip signature checks for our nodes ── + +_original_cache_set_prompt = None + +def _patch_cache_set_prompt(): + """ + Patch CacheSet's set_prompt behavior so IsChangedCache + never marks our trigger nodes as 'unchanged'. + """ + global _original_cache_set_prompt + + original_is_changed_get = comfy_execution.IsChangedCache.get + + async def patched_is_changed_get(self, node_id): + """Force trigger/always-execute nodes to always report as changed.""" + import nodes + node = self.dynprompt.get_node(node_id) + class_type = node["class_type"] + class_def = nodes.NODE_CLASS_MAPPINGS[class_type] + + if _should_skip_cache(class_def): + # Return NaN — ComfyUI treats this as "always changed" + self.is_changed[node_id] = float("NaN") + return float("NaN") + + return await original_is_changed_get(self, node_id) + + comfy_execution.IsChangedCache.get = patched_is_changed_get + + +# ── Apply patches on import ───────────────────────────────────── + +def _install(): + """Monkeypatch ComfyUI's execution module. Called once on import.""" + comfy_execution.execute = patched_execute + _patch_cache_set_prompt() + logger.info("Augment executor override installed — trigger nodes will always re-execute") + +_install() diff --git a/py/face_crop.py b/py/face_crop.py new file mode 100644 index 0000000..d703fec --- /dev/null +++ b/py/face_crop.py @@ -0,0 +1,102 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json +import numpy as np +import torch +import cv2 +import os + +class FaceCrop: + """Fast face crop using OpenCV. Image in, cropped image + coordinates out.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "padding": ("INT", {"default": 20, "min": 0, "max": 512, "step": 5}), + "square": ("BOOLEAN", {"default": True}), + }, + } + + RETURN_TYPES = ("IMAGE", "INT", "INT", "INT", "INT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("cropped", "x", "y", "width", "height", "json", "trigger") + OUTPUT_NODE = False + FUNCTION = "detect_and_crop" + CATEGORY = "Augment/Image" + + def detect_and_crop(self, image, padding=20, square=True): + img_np = (image[0].cpu().numpy() * 255).astype(np.uint8) + img_h, img_w = img_np.shape[:2] + + gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) + + # Try multiple paths to find the Haar cascade file + cascade_file = None + + # Try cv2.data path first (if available) + if hasattr(cv2, 'data'): + try: + cascade_file = cv2.data.haarcascades + "haarcascade_frontalface_alt2.xml" + if os.path.exists(cascade_file): + pass # Found it + else: + cascade_file = None + except: + cascade_file = None + + # Try other common paths + if not cascade_file: + cascade_paths = [ + os.path.join(cv2.__path__[0], "data", "haarcascade_frontalface_alt2.xml"), + "/usr/share/opencv4/haarcascades/haarcascade_frontalface_alt2.xml", + "/usr/local/share/opencv4/haarcascades/haarcascade_frontalface_alt2.xml", + "haarcascade_frontalface_alt2.xml", # System path fallback + ] + + for path in cascade_paths: + if os.path.exists(path): + cascade_file = path + break + + if not cascade_file: + cascade_file = "haarcascade_frontalface_alt2.xml" + + cascade = cv2.CascadeClassifier(cascade_file) + faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=6, minSize=(40, 40)) + + face_count = len(faces) if isinstance(faces, np.ndarray) else 0 + + if face_count == 0: + print("[augment] FaceCrop: no faces detected") + result_json = json.dumps({"node": "FaceCrop", "x": 0, "y": 0, "width": img_w, "height": img_h}) + return (image, 0, 0, img_w, img_h, result_json, "done") + + faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True) + fx, fy, fw, fh = [int(v) for v in faces[0]] + + cx, cy = fx + fw // 2, fy + fh // 2 + fw += padding * 2 + fh += padding * 2 + + if square: + side = max(fw, fh) + fw, fh = side, side + + x0 = max(0, cx - fw // 2) + y0 = max(0, cy - fh // 2) + x1 = min(img_w, x0 + fw) + y1 = min(img_h, y0 + fh) + + out_x, out_y = int(x0), int(y0) + out_w, out_h = int(x1 - x0), int(y1 - y0) + + print(f"[augment] FaceCrop: {face_count} face(s), crop x={out_x} y={out_y} w={out_w} h={out_h}") + + cropped = image[:, out_y:out_y + out_h, out_x:out_x + out_w, :] + result_json = json.dumps({"node": "FaceCrop", "x": out_x, "y": out_y, "width": out_w, "height": out_h}) + return (cropped, out_x, out_y, out_w, out_h, result_json, "done") diff --git a/py/fib.py b/py/fib.py new file mode 100644 index 0000000..ebf1225 --- /dev/null +++ b/py/fib.py @@ -0,0 +1,231 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import torch +import numpy as np +from PIL import Image, ImageDraw +import json +import math + +PHI = 1.6180339887 + + +class FibonacciSpiralNode: + """ + Draw a Fibonacci/Golden Ratio spiral with optional subdivision rectangles. + Each iteration subdivides the remaining golden rectangle and draws a quarter arc. + Companion to Grid Generator and Grid Coords. + """ + + DIRECTIONS = ["top_right", "bottom_right", "bottom_left", "top_left"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 1}), + "height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 1}), + "iterations": ("INT", {"default": 8, "min": 2, "max": 16, "step": 1}), + "line_weight": ("INT", {"default": 2, "min": 1, "max": 20, "step": 1}), + "line_color": ("STRING", {"default": "#FFFFFF"}), + "draw_rectangles": ("BOOLEAN", {"default": False}), + "draw_spiral": ("BOOLEAN", {"default": True}), + "invert": ("BOOLEAN", {"default": False}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "mask", "json_result", "trigger") + FUNCTION = "generate" + CATEGORY = "Augment/Grid" + + @staticmethod + def _hex_to_rgb(hex_str): + hex_str = hex_str.lstrip("#") + if len(hex_str) == 3: + hex_str = "".join(c * 2 for c in hex_str) + return tuple(int(hex_str[i : i + 2], 16) for i in (0, 2, 4)) + + def generate( + self, + width, + height, + iterations, + line_weight, + line_color, + draw_rectangles, + draw_spiral, + invert, + trigger=None, + ): + color = self._hex_to_rgb(line_color) + + # --- Work at 2x resolution for anti-aliased arcs, then downscale --- + ss = 2 # supersampling factor + sw, sh = width * ss, height * ss + slw = line_weight * ss + + img = Image.new("RGBA", (sw, sh), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Rectangle colors for subdivision boxes + rect_colors = [ + (255, 80, 80, 160), # red + (80, 255, 80, 160), # green + (80, 80, 255, 160), # blue + (255, 200, 60, 160), # yellow + (200, 80, 255, 160), # purple + (80, 220, 220, 160), # cyan + ] + + # --- Subdivision logic --- + # We track the remaining rectangle and which corner the spiral starts from. + # Direction cycle: the square is carved from a rotating corner each step. + # 0 = carve from RIGHT (square on right side) + # 1 = carve from BOTTOM (square on bottom) + # 2 = carve from LEFT (square on left side) + # 3 = carve from TOP (square on top) + + rx, ry, rw, rh = 0.0, 0.0, float(sw), float(sh) + + spiral_data = [] + arc_points = [] + + for i in range(iterations): + direction = i % 4 + + if direction == 0: + # Carve square from RIGHT + sq_size = rh + sq_x = rx + rw - sq_size + sq_y = ry + # Arc: bottom-left quarter (from bottom to left of square) + # Center at bottom-left of square + cx, cy = sq_x, sq_y + sq_size + start_angle = 270 + end_angle = 360 + # Remaining rect + new_rx, new_ry = rx, ry + new_rw, new_rh = rw - sq_size, rh + + elif direction == 1: + # Carve square from BOTTOM + sq_size = rw + sq_x = rx + sq_y = ry + rh - sq_size + # Center at top-left of square + cx, cy = sq_x, sq_y + start_angle = 0 + end_angle = 90 + # Remaining rect + new_rx, new_ry = rx, ry + new_rw, new_rh = rw, rh - sq_size + + elif direction == 2: + # Carve square from LEFT + sq_size = rh + sq_x = rx + sq_y = ry + # Center at top-right of square + cx, cy = sq_x + sq_size, sq_y + start_angle = 90 + end_angle = 180 + # Remaining rect + new_rx, new_ry = rx + sq_size, ry + new_rw, new_rh = rw - sq_size, rh + + else: + # Carve square from TOP + sq_size = rw + sq_x = rx + sq_y = ry + # Center at bottom-right of square + cx, cy = sq_x + sq_size, sq_y + sq_size + start_angle = 180 + end_angle = 270 + # Remaining rect + new_rx, new_ry = rx, ry + sq_size + new_rw, new_rh = rw, rh - sq_size + + radius = sq_size + + # Store data + step_data = { + "iteration": i, + "square": { + "x": round(sq_x / ss), + "y": round(sq_y / ss), + "size": round(sq_size / ss), + }, + "arc_center": { + "x": round(cx / ss), + "y": round(cy / ss), + }, + "radius": round(radius / ss), + "direction": self.DIRECTIONS[direction], + } + spiral_data.append(step_data) + + # Draw subdivision rectangle + if draw_rectangles: + rc = rect_colors[i % len(rect_colors)] + draw.rectangle( + [sq_x, sq_y, sq_x + sq_size, sq_y + sq_size], + outline=rc, + width=slw, + ) + + # Draw arc + if draw_spiral: + bbox = [ + cx - radius, + cy - radius, + cx + radius, + cy + radius, + ] + draw.arc( + bbox, + start=start_angle, + end=end_angle, + fill=color + (255,), + width=slw, + ) + + # Move to remaining rectangle + rx, ry, rw, rh = new_rx, new_ry, new_rw, new_rh + + if rw < 2 or rh < 2: + break + + # --- Downsample to target resolution --- + img = img.resize((width, height), Image.LANCZOS) + + # --- Build mask from alpha --- + img_np = np.array(img) + alpha = img_np[:, :, 3].astype(np.float32) / 255.0 + + if invert: + alpha = 1.0 - alpha + + # Convert image to RGB tensor [1, H, W, 3] + rgb = img_np[:, :, :3].astype(np.float32) / 255.0 + image_tensor = torch.from_numpy(rgb).unsqueeze(0) + + # Mask tensor [1, H, W] + mask_tensor = torch.from_numpy(alpha).unsqueeze(0) + + # JSON output + result = { + "canvas": [width, height], + "iterations": len(spiral_data), + "phi": round(PHI, 6), + "steps": spiral_data, + } + json_result = json.dumps(result, indent=2) + + return (image_tensor, mask_tensor, json_result, trigger) + diff --git a/py/grid.py b/py/grid.py new file mode 100644 index 0000000..2b66720 --- /dev/null +++ b/py/grid.py @@ -0,0 +1,107 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import numpy as np +import torch +import json +from PIL import Image, ImageDraw + +from .utils import compute_all_edges + +class GridGenerator: + """Generates a grid image with configurable rows, columns, and line weight.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "rows": ("INT", {"default": 8, "min": 1, "max": 128, "step": 1}), + "cols": ("INT", {"default": 8, "min": 1, "max": 128, "step": 1}), + "line_weight": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}), + "width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}), + "height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}), + }, + "optional": { + "invert": ("BOOLEAN", {"default": False}), + "dotted": ("BOOLEAN", {"default": False}), + "dotted_line_weight": ("INT", {"default": 3, "min": 1, "max": 20, "step": 1}), + "dotted_spacing": ("INT", {"default": 10, "min": 3, "max": 60, "step": 1}), + "line_color": ("STRING", {"default": "#000000"}), + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT", "INT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "mask", "rows", "cols", "width", "height", "json_result", "trigger") + FUNCTION = "generate" + CATEGORY = "Augment/Grid" + + def generate(self, rows, cols, line_weight, width, height, invert=False, dotted=False, dotted_line_weight=3, dotted_spacing=10, line_color="#000000", trigger=None): + fg = _hex_to_rgb(line_color) + if invert: + bg = fg + fg = (255 - bg[0], 255 - bg[1], 255 - bg[2]) + else: + bg = (255, 255, 255) + + pil_img = Image.new("RGB", (width, height), bg) + draw = ImageDraw.Draw(pil_img) + + if dotted: + r = dotted_line_weight / 2.0 + cell_w = width / cols + cell_h = height / rows + dots_per_cell_x = max(1, round(cell_w / dotted_spacing)) + dots_per_cell_y = max(1, round(cell_h / dotted_spacing)) + sx = cell_w / dots_per_cell_x + sy = cell_h / dots_per_cell_y + + for row in range(1, rows): + y_pos = int(round(row * height / rows)) + x = 0.0 + while x < width: + draw.ellipse([x - r, y_pos - r, x + r, y_pos + r], fill=fg) + x += sx + for col in range(1, cols): + x_pos = int(round(col * width / cols)) + y = 0.0 + while y < height: + draw.ellipse([x_pos - r, y - r, x_pos + r, y + r], fill=fg) + y += sy + else: + for row in range(1, rows): + y_pos = int(round(row * height / rows)) + draw.line([(0, y_pos), (width - 1, y_pos)], fill=fg, width=line_weight) + for col in range(1, cols): + x_pos = int(round(col * width / cols)) + draw.line([(x_pos, 0), (x_pos, height - 1)], fill=fg, width=line_weight) + + img_np = np.array(pil_img).astype(np.float32) / 255.0 + result_image = torch.from_numpy(img_np).unsqueeze(0) + + mask_val = img_np[:, :, 0] + result_mask = torch.from_numpy(mask_val).unsqueeze(0) + + json_result = json.dumps({ + "node": "GridGenerator", + "rows": rows, + "cols": cols, + "width": width, + "height": height, + "line_weight": line_weight, + "dotted": dotted, + "line_color": line_color + }) + + return (result_image, result_mask, rows, cols, width, height, json_result, "done") + + +def _hex_to_rgb(hex_str): + """Convert a hex color string like '#ff0000' to an (R, G, B) tuple.""" + h = hex_str.lstrip("#") + if len(h) == 3: + h = h[0]*2 + h[1]*2 + h[2]*2 + if len(h) != 6: + return (0, 0, 0) + return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) \ No newline at end of file diff --git a/py/grid_coords.py b/py/grid_coords.py new file mode 100644 index 0000000..e1d357c --- /dev/null +++ b/py/grid_coords.py @@ -0,0 +1,199 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import torch +import numpy as np +import json + +PHI = 1.6180339887 # Golden ratio + + +class GridCoordsNode: + """ + Design layout engine. Pick a layout preset (golden_ratio, thirds, halves, quadrants) + and a region name (top_left, center, etc.) — get back pixel coordinates. + + Companion node to Grid Generator. + """ + + LAYOUTS = ["golden_ratio", "thirds", "halves", "quadrants"] + REGIONS = [ + "top_left", "top_center", "top_right", + "center_left", "center", "center_right", + "bottom_left", "bottom_center", "bottom_right", + ] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "canvas_w": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}), + "canvas_h": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}), + "layout": (cls.LAYOUTS, {"default": "golden_ratio"}), + "region": (cls.REGIONS, {"default": "center"}), + "padding": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("INT", "INT", "INT", "INT", "INT", "INT", "AUGMENT_JSON", "MASK", "TRIGGER") + RETURN_NAMES = ("x", "y", "width", "height", "center_x", "center_y", "json_result", "mask", "trigger") + FUNCTION = "calculate" + CATEGORY = "Augment/Grid" + + # ------------------------------------------------------------------ # + # Split helpers — return the two split points for each axis + # ------------------------------------------------------------------ # + + @staticmethod + def _splits(size, layout, padding): + """Return (split1, split2) pixel positions for a given axis length.""" + inner = size - 2 * padding + p = padding + + if layout == "golden_ratio": + s1 = p + int(inner / PHI) # ~61.8 % + s2 = p + int(inner - inner / PHI) # ~38.2 % (mirrored) + # Ensure s1 < s2 + if s1 > s2: + s1, s2 = s2, s1 + return (s1, s2) + + if layout == "thirds": + third = inner / 3 + return (p + int(third), p + int(third * 2)) + + if layout == "halves": + half = inner // 2 + mid = p + half + return (mid, mid) # single split + + if layout == "quadrants": + half = inner // 2 + mid = p + half + return (mid, mid) + + return (p, size - p) + + # ------------------------------------------------------------------ # + # Region → bounding box + # ------------------------------------------------------------------ # + + def _region_box(self, canvas_w, canvas_h, layout, region, padding): + """ + Returns (x, y, w, h) for the named region. + """ + sx1, sx2 = self._splits(canvas_w, layout, padding) + sy1, sy2 = self._splits(canvas_h, layout, padding) + p = padding + cw = canvas_w - padding + ch = canvas_h - padding + + # Build column boundaries [left_edge, split1, split2, right_edge] + # For halves/quadrants split1 == split2, giving us 2 zones per axis + cols = sorted(set([p, sx1, sx2, cw])) + rows = sorted(set([p, sy1, sy2, ch])) + + # Map region name → (col_index, row_index) + region_map_3x3 = { + "top_left": (0, 0), + "top_center": (1, 0), + "top_right": (2, 0), + "center_left": (0, 1), + "center": (1, 1), + "center_right": (2, 1), + "bottom_left": (0, 2), + "bottom_center": (1, 2), + "bottom_right": (2, 2), + } + + # For 2-zone layouts (halves, quadrants) we collapse to 2x2 + region_map_2x2 = { + "top_left": (0, 0), + "top_center": (0, 0), # collapse to top_left + "top_right": (1, 0), + "center_left": (0, 0), # collapse to top_left + "center": (0, 0), # collapse to top_left + "center_right": (1, 0), # collapse to top_right + "bottom_left": (0, 1), + "bottom_center": (0, 1), # collapse to bottom_left + "bottom_right": (1, 1), + } + + is_2zone = len(cols) <= 3 and len(rows) <= 3 + + if is_2zone and len(cols) == 2: + # True 2-col layout + ci, ri = region_map_2x2[region] + elif is_2zone and len(cols) == 3: + # 3 boundaries but middle is duplicate — still pick properly + ci, ri = region_map_2x2[region] + else: + ci, ri = region_map_3x3[region] + + # Clamp indices + ci = min(ci, len(cols) - 2) + ri = min(ri, len(rows) - 2) + + x = int(cols[ci]) + y = int(rows[ri]) + w = int(cols[ci + 1] - cols[ci]) + h = int(rows[ri + 1] - rows[ri]) + + return x, y, w, h + + # ------------------------------------------------------------------ # + # Main + # ------------------------------------------------------------------ # + + def calculate(self, canvas_w, canvas_h, layout, region, padding, trigger=None): + x, y, w, h = self._region_box(canvas_w, canvas_h, layout, region, padding) + + center_x = x + w // 2 + center_y = y + h // 2 + + # -- Normalized values -- + norm_x = round(x / canvas_w, 4) if canvas_w else 0 + norm_y = round(y / canvas_h, 4) if canvas_h else 0 + norm_w = round(w / canvas_w, 4) if canvas_w else 0 + norm_h = round(h / canvas_h, 4) if canvas_h else 0 + norm_cx = round(center_x / canvas_w, 4) if canvas_w else 0 + norm_cy = round(center_y / canvas_h, 4) if canvas_h else 0 + + # -- All split lines for this layout (useful for LLM to understand full grid) -- + sx1, sx2 = self._splits(canvas_w, layout, padding) + sy1, sy2 = self._splits(canvas_h, layout, padding) + + result = { + "layout": layout, + "region": region, + "canvas": [canvas_w, canvas_h], + "padding": padding, + "box": { + "x": x, "y": y, "w": w, "h": h, + "right": x + w, "bottom": y + h, + "center_x": center_x, "center_y": center_y, + }, + "normalized": { + "x": norm_x, "y": norm_y, + "w": norm_w, "h": norm_h, + "center_x": norm_cx, "center_y": norm_cy, + }, + "splits": { + "x": [sx1, sx2], + "y": [sy1, sy2], + }, + } + + json_result = json.dumps(result, indent=2) + + # -- Mask output: white rectangle at region on black canvas -- + mask = np.zeros((canvas_h, canvas_w), dtype=np.float32) + mask[y : y + h, x : x + w] = 1.0 + mask_tensor = torch.from_numpy(mask).unsqueeze(0) + + return (int(x), int(y), int(w), int(h), int(center_x), int(center_y), json_result, mask_tensor, trigger) + diff --git a/py/image_crop.py b/py/image_crop.py new file mode 100644 index 0000000..7dc706d --- /dev/null +++ b/py/image_crop.py @@ -0,0 +1,50 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json +import numpy as np +import torch +import cv2 + +class ImageCrop: + """Simple image crop. Accepts x, y, width, height — wires to any node outputting INT.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "x": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}), + "y": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}), + "width": ("INT", {"default": 256, "min": 1, "max": 8192, "step": 1}), + "height": ("INT", {"default": 256, "min": 1, "max": 8192, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE", "INT", "INT", "INT", "INT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "x", "y", "width", "height", "json_result", "trigger") + OUTPUT_NODE = False + FUNCTION = "crop" + CATEGORY = "Augment/Image" + + def crop(self, image, x, y, width, height): + img_h, img_w = image.shape[1], image.shape[2] + + # Clamp to image bounds + x0 = max(0, min(x, img_w - 1)) + y0 = max(0, min(y, img_h - 1)) + x1 = min(img_w, x0 + width) + y1 = min(img_h, y0 + height) + + out_w = int(x1 - x0) + out_h = int(y1 - y0) + out_x = int(x0) + out_y = int(y0) + + cropped = image[:, out_y:out_y + out_h, out_x:out_x + out_w, :] + + print(f"[augment] ImageCrop: x={out_x} y={out_y} w={out_w} h={out_h}") + + result_json = json.dumps({"node": "ImageCrop", "x": out_x, "y": out_y, "width": out_w, "height": out_h}) + return (cropped, out_x, out_y, out_w, out_h, result_json, "done") \ No newline at end of file diff --git a/py/image_flip.py b/py/image_flip.py new file mode 100644 index 0000000..5dcfc8b --- /dev/null +++ b/py/image_flip.py @@ -0,0 +1,40 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import torch +import json + + +class ImageFlipNode: + """Flip an image horizontally, vertically, or both.""" + + MODES = ["horizontal", "vertical", "both"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "direction": (cls.MODES, {"default": "horizontal"}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Image" + + def execute(self, image, direction, trigger=None): + if direction == "horizontal": + flipped = torch.flip(image, [2]) + elif direction == "vertical": + flipped = torch.flip(image, [1]) + else: + flipped = torch.flip(image, [1, 2]) + + json_result = json.dumps({"node": "ImageFlipNode", "direction": direction, "shape": list(image.shape)}) + return (flipped, json_result, "done") diff --git a/py/image_place.py b/py/image_place.py new file mode 100644 index 0000000..57572aa --- /dev/null +++ b/py/image_place.py @@ -0,0 +1,134 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import torch +import numpy as np +import json +from PIL import Image + + +class ImagePlaceNode: + """ + Place an image onto a canvas at any x/y/w/h coordinates. + Handles resizing, alpha compositing, and fit modes. + Wire from Grid Coords, manual INTs, or any coordinate source. + """ + + FIT_MODES = ["fit", "fill", "stretch"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "canvas": ("IMAGE",), + "image": ("IMAGE",), + "x": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "y": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "width": ("INT", {"default": 512, "min": 1, "max": 8192, "step": 1}), + "height": ("INT", {"default": 512, "min": 1, "max": 8192, "step": 1}), + "fit": (cls.FIT_MODES, {"default": "fit"}), + }, + "optional": { + "mask": ("MASK",), + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "mask", "json_result", "trigger") + FUNCTION = "place" + CATEGORY = "Augment/Image" + + @staticmethod + def _tensor_to_pil(tensor): + """Convert ComfyUI IMAGE tensor [B,H,W,C] to PIL RGBA.""" + img_np = tensor[0].cpu().numpy() + img_np = np.clip(img_np * 255, 0, 255).astype(np.uint8) + + if img_np.shape[2] == 4: + return Image.fromarray(img_np, "RGBA") + else: + return Image.fromarray(img_np, "RGB").convert("RGBA") + + @staticmethod + def _pil_to_tensor(pil_img): + """Convert PIL RGB image to ComfyUI IMAGE tensor [1,H,W,3].""" + img_np = np.array(pil_img.convert("RGB")).astype(np.float32) / 255.0 + return torch.from_numpy(img_np).unsqueeze(0) + + @staticmethod + def _resize_fit(pil_img, target_w, target_h): + """Resize to fit inside target, preserving aspect ratio (letterbox).""" + src_w, src_h = pil_img.size + scale = min(target_w / src_w, target_h / src_h) + new_w = int(src_w * scale) + new_h = int(src_h * scale) + resized = pil_img.resize((new_w, new_h), Image.LANCZOS) + + # Center on transparent canvas + result = Image.new("RGBA", (target_w, target_h), (0, 0, 0, 0)) + offset_x = (target_w - new_w) // 2 + offset_y = (target_h - new_h) // 2 + result.paste(resized, (offset_x, offset_y), resized) + return result + + @staticmethod + def _resize_fill(pil_img, target_w, target_h): + """Resize to fill target, cropping excess (cover).""" + src_w, src_h = pil_img.size + scale = max(target_w / src_w, target_h / src_h) + new_w = int(src_w * scale) + new_h = int(src_h * scale) + resized = pil_img.resize((new_w, new_h), Image.LANCZOS) + + # Center crop + left = (new_w - target_w) // 2 + top = (new_h - target_h) // 2 + return resized.crop((left, top, left + target_w, top + target_h)) + + @staticmethod + def _resize_stretch(pil_img, target_w, target_h): + """Stretch to exact target size.""" + return pil_img.resize((target_w, target_h), Image.LANCZOS) + + def place(self, canvas, image, x, y, width, height, fit, mask=None, trigger=None): + canvas_pil = self._tensor_to_pil(canvas) + image_pil = self._tensor_to_pil(image) + + canvas_w, canvas_h = canvas_pil.size + + # Resize image to target region + if fit == "fit": + placed = self._resize_fit(image_pil, width, height) + elif fit == "fill": + placed = self._resize_fill(image_pil, width, height) + else: + placed = self._resize_stretch(image_pil, width, height) + + # Apply external mask to placed image's alpha if provided + if mask is not None: + mask_pil = Image.fromarray((mask[0].cpu().numpy() * 255).astype(np.uint8), "L") + mask_resized = mask_pil.resize((placed.size[0], placed.size[1]), Image.LANCZOS) + placed_alpha = placed.split()[3] + combined_alpha = Image.fromarray( + np.minimum(np.array(placed_alpha), np.array(mask_resized)).astype(np.uint8), "L" + ) + placed.putalpha(combined_alpha) + + # Composite onto canvas + result = canvas_pil.copy() + result.paste(placed, (x, y), placed) + + # Build placement mask (white where image was placed, including alpha) + mask_img = Image.new("L", (canvas_w, canvas_h), 0) + placed_alpha = placed.split()[3] + mask_img.paste(placed_alpha, (x, y)) + + # Convert outputs + result_tensor = self._pil_to_tensor(result) + mask_np = np.array(mask_img).astype(np.float32) / 255.0 + mask_tensor = torch.from_numpy(mask_np).unsqueeze(0) + + json_result = json.dumps({"node": "ImagePlaceNode", "x": x, "y": y, "width": width, "height": height, "fit": fit, "canvas_size": [canvas_w, canvas_h]}) + return (result_tensor, mask_tensor, json_result, trigger) \ No newline at end of file diff --git a/py/image_rotate.py b/py/image_rotate.py new file mode 100644 index 0000000..b8fa16a --- /dev/null +++ b/py/image_rotate.py @@ -0,0 +1,48 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import torch +import numpy as np +import json +from PIL import Image + + +class ImageRotateNode: + """Rotate an image by preset or custom angle.""" + + PRESETS = ["0", "90", "180", "270", "custom"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "angle": (cls.PRESETS, {"default": "90"}), + "custom_angle": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0, "step": 0.5}), + "expand": ("BOOLEAN", {"default": True}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Image" + + def execute(self, image, angle, custom_angle, expand, trigger=None): + deg = custom_angle if angle == "custom" else float(angle) + + img_np = (image[0].cpu().numpy() * 255).astype(np.uint8) + pil_img = Image.fromarray(img_np) + + rotated = pil_img.rotate(-deg, resample=Image.BICUBIC, expand=expand, fillcolor=(0, 0, 0)) + + result_np = np.array(rotated).astype(np.float32) / 255.0 + result_tensor = torch.from_numpy(result_np).unsqueeze(0) + + h, w = result_np.shape[:2] + json_result = json.dumps({"node": "ImageRotateNode", "angle": deg, "expand": expand, "size": [w, h]}) + return (result_tensor, json_result, "done") diff --git a/py/invert_image.py b/py/invert_image.py new file mode 100644 index 0000000..a5756c5 --- /dev/null +++ b/py/invert_image.py @@ -0,0 +1,51 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import torch +import json + + +class InvertImageNode: + """Invert an image's colors (1 - pixel value). Alpha passes through untouched.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "alpha": ("MASK",), + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK", "MASK", "MASK", "MASK", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "alpha", "red", "green", "blue", "json", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Image" + + def execute(self, image, alpha=None, trigger=None): + inverted = 1.0 - image + h, w = image.shape[1], image.shape[2] + + if alpha is not None and alpha.shape[-2:] == (h, w) and alpha.any(): + has_alpha = True + alpha_out = alpha + else: + has_alpha = False + alpha_out = torch.zeros((1, h, w), dtype=torch.float32) + + img_np = inverted[0].cpu().numpy() + red = torch.from_numpy(img_np[:, :, 0]).unsqueeze(0) + green = torch.from_numpy(img_np[:, :, 1]).unsqueeze(0) + blue = torch.from_numpy(img_np[:, :, 2]).unsqueeze(0) + + json_result = json.dumps({ + "node": "InvertImageNode", + "width": w, + "height": h, + "has_alpha": has_alpha, + }) + return (inverted, alpha_out, red, green, blue, json_result, "done") diff --git a/py/json_extract.py b/py/json_extract.py new file mode 100644 index 0000000..4643b45 --- /dev/null +++ b/py/json_extract.py @@ -0,0 +1,61 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json as json_module + + +class JsonExtractNode: + """Extract a specific value from JSON data by name.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "json": ("AUGMENT_JSON",), + "field_name": ("STRING", {"default": "value"}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("STRING", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("extracted_value", "json", "trigger") + OUTPUT_NODE = True + FUNCTION = "extract" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def extract(self, json, field_name, trigger=None): + try: + data = json_module.loads(json) + + if "value" in data and field_name not in data: + inner = data["value"] + if isinstance(inner, str): + try: + inner = json_module.loads(inner) + except (json_module.JSONDecodeError, TypeError): + pass + if isinstance(inner, dict) and field_name in inner: + data = inner + + fields = field_name.split(".") + value = data + for field in fields: + value = value[field] + + result = str(value) if not isinstance(value, str) else value + ui_text = f"Field: {field_name}\nValue: {result}" + except (json_module.JSONDecodeError, KeyError, TypeError): + result = "" + ui_text = f"Field: {field_name}\nError: Could not find field or invalid JSON" + + return { + "ui": {"text": [ui_text]}, + "result": (result, json, "done") + } diff --git a/py/json_viewer.py b/py/json_viewer.py new file mode 100644 index 0000000..ad2cce9 --- /dev/null +++ b/py/json_viewer.py @@ -0,0 +1,40 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json as json_module + + +class JsonViewerNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "json": ("AUGMENT_JSON", {"forceInput": True}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("json", "trigger") + OUTPUT_NODE = True + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, json, trigger=None): + try: + parsed = json_module.loads(json) + pretty = json_module.dumps(parsed, indent=2) + except (json_module.JSONDecodeError, TypeError): + pretty = str(json) + + return { + "ui": {"text": [pretty]}, + "result": (json, "done") + } diff --git a/py/load_image.py b/py/load_image.py new file mode 100644 index 0000000..323f2a4 --- /dev/null +++ b/py/load_image.py @@ -0,0 +1,114 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import hashlib +import json +import os +import numpy as np +import torch +from PIL import Image, ImageOps, ImageSequence + +import folder_paths +import node_helpers + + +class AugmentLoadImage: + @classmethod + def INPUT_TYPES(cls): + input_dir = folder_paths.get_input_directory() + files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] + files = folder_paths.filter_files_content_types(files, ["image"]) + return { + "required": { + "image": (sorted(files), {"image_upload": True}), + }, + "optional": { + "image_override": ("IMAGE",), + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "INT", "INT", "MASK", "MASK", "MASK", "MASK", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "width", "height", "alpha", "red", "green", "blue", "json", "trigger") + OUTPUT_NODE = True + FUNCTION = "load_image" + CATEGORY = "Augment/Image" + + def load_image(self, image, image_override=None, trigger=None): + if image_override is not None: + output_image = image_override + h, w = output_image.shape[1], output_image.shape[2] + has_alpha = False + output_mask = torch.zeros((1, h, w), dtype=torch.float32) + source_name = "override" + else: + image_path = folder_paths.get_annotated_filepath(image) + img = node_helpers.pillow(Image.open, image_path) + + output_images = [] + output_masks = [] + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + if i.mode == 'I': + i = i.point(lambda i: i * (1 / 255)) + has_alpha = 'A' in i.getbands() + rgba = i.convert("RGBA") + rgb = i.convert("RGB") + + image_tensor = torch.from_numpy(np.array(rgb).astype(np.float32) / 255.0)[None,] + output_images.append(image_tensor) + + if has_alpha: + mask = 1.0 - torch.from_numpy(np.array(rgba.getchannel('A')).astype(np.float32) / 255.0) + else: + mask = torch.zeros((rgb.size[1], rgb.size[0]), dtype=torch.float32) + output_masks.append(mask.unsqueeze(0)) + + if img.format == "MPO": + break + + if len(output_images) > 1: + output_image = torch.cat(output_images, dim=0) + output_mask = torch.cat(output_masks, dim=0) + else: + output_image = output_images[0] + output_mask = output_masks[0] + + w = output_image.shape[2] + h = output_image.shape[1] + source_name = image + + img_np = output_image[0].numpy() + red = torch.from_numpy(img_np[:, :, 0]).unsqueeze(0) + green = torch.from_numpy(img_np[:, :, 1]).unsqueeze(0) + blue = torch.from_numpy(img_np[:, :, 2]).unsqueeze(0) + + w = output_image.shape[2] + h = output_image.shape[1] + json_result = json.dumps({ + "node": "Load Image", + "file": source_name, + "width": w, + "height": h, + "has_alpha": has_alpha, + }) + + return { + "ui": {"text": [f"{source_name}\n{w}x{h}"]}, + "result": (output_image, w, h, output_mask, red, green, blue, json_result, "done"), + } + + @classmethod + def IS_CHANGED(cls, image, image_override=None, trigger=None): + image_path = folder_paths.get_annotated_filepath(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(cls, image, image_override=None, trigger=None): + if not folder_paths.exists_annotated_filepath(image): + return "Invalid image file: {}".format(image) + return True diff --git a/py/logo_mask.py b/py/logo_mask.py new file mode 100644 index 0000000..c9e4205 --- /dev/null +++ b/py/logo_mask.py @@ -0,0 +1,140 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json +import numpy as np +import torch +import cv2 + +class LogoMask: + """Generates a filled mask from edge detection — ideal for logos, icons, and text.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "threshold_low": ("INT", {"default": 50, "min": 0, "max": 255, "step": 5}), + "threshold_high": ("INT", {"default": 150, "min": 0, "max": 255, "step": 5}), + "fill_holes": ("BOOLEAN", {"default": True}), + "smooth": ("INT", {"default": 3, "min": 0, "max": 20, "step": 1}), + "invert": ("BOOLEAN", {"default": False}), + }, + } + + RETURN_TYPES = ("MASK", "IMAGE", "IMAGE", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("mask", "mask_image", "masked_rgb", "json_result", "trigger") + OUTPUT_NODE = False + FUNCTION = "generate_mask" + CATEGORY = "Augment/Image" + + def generate_mask(self, image, threshold_low=50, threshold_high=150, + fill_holes=True, smooth=3, invert=False): + img_np = (image[0].cpu().numpy() * 255).astype(np.uint8) + gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) + h, w = gray.shape + + # Detect background from corner pixels (10px sample from each corner) + s = 10 + corners = np.concatenate([ + gray[:s, :s].flatten(), + gray[:s, -s:].flatten(), + gray[-s:, :s].flatten(), + gray[-s:, -s:].flatten(), + ]) + bg_mean = np.mean(corners) + + # threshold_low = tolerance (how far from bg a pixel must be to count as foreground) + # Higher = stricter, lower = catches more + if bg_mean > 127: + # Light background — mask anything darker than (bg - tolerance) + cutoff = max(0, bg_mean - threshold_low) + _, mask = cv2.threshold(gray, int(cutoff), 255, cv2.THRESH_BINARY_INV) + else: + # Dark background — mask anything lighter than (bg + tolerance) + cutoff = min(255, bg_mean + threshold_low) + _, mask = cv2.threshold(gray, int(cutoff), 255, cv2.THRESH_BINARY) + + # Clean up noise + kernel_clean = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_clean, iterations=1) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_clean, iterations=1) + + if fill_holes: + contours, hierarchy = cv2.findContours(mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE) + if hierarchy is not None: + for i, cnt in enumerate(contours): + cv2.drawContours(mask, [cnt], 0, 255, cv2.FILLED) + else: + # Preserve negative space + contours, hierarchy = cv2.findContours(mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE) + filled = np.zeros_like(mask) + if hierarchy is not None: + for i, cnt in enumerate(contours): + if hierarchy[0][i][3] == -1: + cv2.drawContours(filled, [cnt], 0, 255, cv2.FILLED) + for i, cnt in enumerate(contours): + if hierarchy[0][i][3] >= 0: + cv2.drawContours(filled, [cnt], 0, 0, cv2.FILLED) + mask = filled + + kernel_clean = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_clean, iterations=1) + + if smooth > 0: + # Smooth contour geometry only — no pixel blurring + smooth_mask = np.zeros_like(mask) + contours_s, hierarchy_s = cv2.findContours(mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE) + if hierarchy_s is not None: + for i, cnt in enumerate(contours_s): + epsilon = smooth * 0.5 + approx = cv2.approxPolyDP(cnt, epsilon, True) + # Outer contours filled white, inner (holes) filled black + if hierarchy_s[0][i][3] == -1: + cv2.drawContours(smooth_mask, [approx], 0, 255, cv2.FILLED) + if not fill_holes: + for i, cnt in enumerate(contours_s): + if hierarchy_s[0][i][3] >= 0: + epsilon = smooth * 0.5 + approx = cv2.approxPolyDP(cnt, epsilon, True) + cv2.drawContours(smooth_mask, [approx], 0, 0, cv2.FILLED) + mask = smooth_mask + + if invert: + mask = cv2.bitwise_not(mask) + + # Bounding box of the mask content + rows = np.any(mask > 0, axis=1) + cols = np.any(mask > 0, axis=0) + if rows.any(): + r = np.where(rows)[0] + c = np.where(cols)[0] + bx, by = int(c[0]), int(r[0]) + bw, bh = int(c[-1] - c[0] + 1), int(r[-1] - r[0] + 1) + else: + bx, by, bw, bh = 0, 0, mask.shape[1], mask.shape[0] + + total_contours = len(contours) if contours else 0 + coverage = float(np.sum(mask > 0) / mask.size * 100) + print(f"[augment] LogoMask: {total_contours} contours, {coverage:.1f}% coverage") + + result = torch.from_numpy(mask.astype(np.float32) / 255.0).unsqueeze(0) + + # mask_image: mask as RGB (white on black) + mask_rgb = np.stack([mask, mask, mask], axis=-1) # (H, W, 3) 0-255 + mask_image = torch.from_numpy(mask_rgb.astype(np.float32) / 255.0).unsqueeze(0) # (1, H, W, 3) + + # masked_rgb: original image with mask applied (black where mask is empty) + mask_f = mask.astype(np.float32) / 255.0 # (H, W) 0-1 + masked = img_np.astype(np.float32) * mask_f[:, :, np.newaxis] # (H, W, 3) + masked_rgb = torch.from_numpy(masked / 255.0).unsqueeze(0) # (1, H, W, 3) + + result_json = json.dumps({ + "node": "LogoMask", + "x": bx, "y": by, "width": bw, "height": bh, + "contours": total_contours, "coverage_pct": round(coverage, 1) + }) + return (result, mask_image, masked_rgb, result_json, "done") diff --git a/py/mask_bounding_box.py b/py/mask_bounding_box.py new file mode 100644 index 0000000..e969635 --- /dev/null +++ b/py/mask_bounding_box.py @@ -0,0 +1,69 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import numpy as np +import torch +import json + + +class MaskBoundingBox: + """Finds the bounding box of non-zero mask content and outputs the cropped mask + coordinates.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mask": ("MASK",), + "padding": ("INT", {"default": 0, "min": 0, "max": 512, "step": 1}), + "invert": ("BOOLEAN", {"default": False}), + "threshold": ("FLOAT", {"default": 0.10, "min": 0.01, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("MASK", "INT", "INT", "INT", "INT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("mask", "x", "y", "width", "height", "json_result", "trigger") + FUNCTION = "find_bounds" + CATEGORY = "Augment/Image" + + def find_bounds(self, mask, padding, invert, threshold): + m = mask[0].cpu().numpy() + img_h, img_w = m.shape + + if invert: + m = 1.0 - m + + rows = np.any(m > threshold, axis=1) + cols = np.any(m > threshold, axis=0) + + if not rows.any(): + x_min, y_min, out_w, out_h = 0, 0, img_w, img_h + else: + row_indices = np.where(rows)[0] + col_indices = np.where(cols)[0] + y_min, y_max = int(row_indices[0]), int(row_indices[-1]) + x_min, x_max = int(col_indices[0]), int(col_indices[-1]) + + x_min = max(x_min - padding, 0) + y_min = max(y_min - padding, 0) + x_max = min(x_max + padding, img_w - 1) + y_max = min(y_max + padding, img_h - 1) + + out_w = x_max - x_min + 1 + out_h = y_max - y_min + 1 + + cropped_mask = mask[:, y_min:y_min + out_h, x_min:x_min + out_w] + + print(f"[augment] bounds: x={x_min} y={y_min} w={out_w} h={out_h}") + + json_result = json.dumps({ + "node": "MaskBoundingBox", + "x": x_min, + "y": y_min, + "width": out_w, + "height": out_h, + "padding": padding, + "invert": invert, + }) + + return (cropped_mask, x_min, y_min, out_w, out_h, json_result, "done") diff --git a/py/preview.py b/py/preview.py new file mode 100644 index 0000000..2159bca --- /dev/null +++ b/py/preview.py @@ -0,0 +1,197 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json +import os +import numpy as np +from PIL import Image +from PIL.PngImagePlugin import PngInfo + +import folder_paths + + +class AugmentPreviewImage: + """Previews images in the ComfyUI UI without saving to the output folder.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "json", "trigger") + OUTPUT_NODE = True + FUNCTION = "preview" + CATEGORY = "Augment/Image" + + def preview(self, images, trigger=None): + results = [] + for batch_idx in range(images.shape[0]): + i = 255.0 * images[batch_idx].cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + filename = f"augment_preview_{batch_idx:05d}.png" + subfolder = "augment_temp" + temp_dir = folder_paths.get_temp_directory() + full_dir = os.path.join(temp_dir, subfolder) + os.makedirs(full_dir, exist_ok=True) + img.save(os.path.join(full_dir, filename), compress_level=1) + + results.append({ + "filename": filename, + "subfolder": subfolder, + "type": "temp", + }) + + json_result = json.dumps({"node": "AugmentPreviewImage", "images": results, "count": len(results)}) + return {"ui": {"images": results}, "result": (images, json_result, "done")} + + +class AugmentSaveImage: + """Saves images to the output folder with a customizable filename prefix.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "filename_prefix": ("STRING", {"default": "Augment"}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("image", "json", "trigger") + OUTPUT_NODE = True + FUNCTION = "save" + CATEGORY = "Augment/Image" + + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + self.type = "output" + self.compress_level = 4 + + def save(self, images, filename_prefix="Augment", trigger=None): + full_output_folder, filename, counter, subfolder, filename_prefix = ( + folder_paths.get_save_image_path( + filename_prefix, self.output_dir, images.shape[2], images.shape[1] + ) + ) + + results = [] + for batch_idx in range(images.shape[0]): + i = 255.0 * images[batch_idx].cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + metadata = PngInfo() + file = f"{filename}_{counter + batch_idx:05d}.png" + img.save( + os.path.join(full_output_folder, file), + pnginfo=metadata, + compress_level=self.compress_level, + ) + + results.append({ + "filename": file, + "subfolder": subfolder, + "type": self.type, + }) + + json_result = json.dumps({"node": "AugmentSaveImage", "images": results, "count": len(results), "prefix": filename_prefix}) + return {"ui": {"images": results}, "result": (images, json_result, "done")} + + +class AugmentPreviewMask: + """Previews a mask in the ComfyUI UI without saving to the output folder.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mask": ("MASK",), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("MASK", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("mask", "json", "trigger") + OUTPUT_NODE = True + FUNCTION = "preview" + CATEGORY = "Augment/Image" + + def preview(self, mask, trigger=None): + results = [] + for batch_idx in range(mask.shape[0]): + m = 255.0 * mask[batch_idx].cpu().numpy() + img = Image.fromarray(np.clip(m, 0, 255).astype(np.uint8), mode="L") + + filename = f"augment_mask_preview_{batch_idx:05d}.png" + subfolder = "augment_temp" + temp_dir = folder_paths.get_temp_directory() + full_dir = os.path.join(temp_dir, subfolder) + os.makedirs(full_dir, exist_ok=True) + img.save(os.path.join(full_dir, filename), compress_level=1) + + results.append({ + "filename": filename, + "subfolder": subfolder, + "type": "temp", + }) + + json_result = json.dumps({"node": "Preview Mask", "images": results, "count": len(results)}) + return {"ui": {"images": results}, "result": (mask, json_result, "done")} + + +class AugmentPreviewAny: + """Displays the string representation of any value in the ComfyUI UI.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "source": ("*", {"forceInput": True}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("*", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("value", "json", "trigger") + OUTPUT_NODE = True + FUNCTION = "preview" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def preview(self, source=None, trigger=None): + if isinstance(source, str): + text = source + elif isinstance(source, (int, float, bool)): + text = str(source) + elif source is None: + text = "None" + else: + try: + text = json.dumps(source, indent=2, default=str) + except Exception: + try: + text = str(source) + except Exception: + text = "Value exists but could not be serialized" + + json_result = json.dumps({"node": "AugmentPreviewAny", "value": text, "type": type(source).__name__}) + return {"ui": {"text": [text]}, "result": (source, json_result, "done")} diff --git a/py/prim_node.py b/py/prim_node.py new file mode 100644 index 0000000..3d5b362 --- /dev/null +++ b/py/prim_node.py @@ -0,0 +1,132 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json + + +class IntNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": ("INT", {"default": 0}), + }, + "optional": { + "trigger": ("TRIGGER",), + "input": ("INT", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("INT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("value", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, value, trigger=None, input=None): + result = input if input is not None else value + json_result = json.dumps({"node": "IntNode", "value": result, "type": "int"}) + return (result, json_result, "done") + + +class FloatNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": ("FLOAT", {"default": 0.0, "step": 0.01}), + }, + "optional": { + "trigger": ("TRIGGER",), + "input": ("FLOAT", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("FLOAT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("value", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, value, trigger=None, input=None): + result = input if input is not None else value + json_result = json.dumps({"node": "FloatNode", "value": result, "type": "float"}) + return (result, json_result, "done") + + +class StringNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": ("STRING", {"default": ""}), + }, + "optional": { + "trigger": ("TRIGGER",), + "input": ("STRING", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("STRING", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("value", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, value, trigger=None, input=None): + result = input if input is not None else value + json_result = json.dumps({"node": "StringNode", "value": result, "type": "string"}) + return (result, json_result, "done") + + +class BoolNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": ("BOOLEAN", {"default": False}), + }, + "optional": { + "trigger": ("TRIGGER",), + "input": ("BOOLEAN", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("BOOLEAN", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("value", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, value, trigger=None, input=None): + result = input if input is not None else value + json_result = json.dumps({"node": "BoolNode", "value": result, "type": "boolean"}) + return (result, json_result, "done") + + +NODE_CLASS_MAPPINGS = { + "IntPrimNode": IntNode, + "FloatPrimNode": FloatNode, + "StringPrimNode": StringNode, + "BoolPrimNode": BoolNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "IntPrimNode": "Int", + "FloatPrimNode": "Float", + "StringPrimNode": "String", + "BoolPrimNode": "Bool", +} \ No newline at end of file diff --git a/py/recolor.py b/py/recolor.py new file mode 100644 index 0000000..62a7254 --- /dev/null +++ b/py/recolor.py @@ -0,0 +1,115 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json +import torch + + +class RecolorNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mask": ("MASK",), + "R": ("FLOAT", {"default": 255.0, "min": 0.0, "max": 255.0, "step": 1.0}), + "G": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 255.0, "step": 1.0}), + "B": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 255.0, "step": 1.0}), + }, + "optional": { + "hex_color": ("STRING", {"default": ""}), + "preserve_luminance": ("BOOLEAN", {"default": True}), + "blend": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05}), + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("recolored_image", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Image" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, image, mask, R, G, B, hex_color="", + preserve_luminance=True, blend=1.0, trigger=None): + + # --- Hex override (same pattern as WCAG node) --- + h = hex_color.strip().lstrip("#") + if len(h) == 6: + try: + R = float(int(h[0:2], 16)) + G = float(int(h[2:4], 16)) + B = float(int(h[4:6], 16)) + except ValueError: + pass + + # --- Normalize target color to 0-1 --- + t_r = R / 255.0 + t_g = G / 255.0 + t_b = B / 255.0 + + # --- Work on clone --- + img = image.clone() # (batch, H, W, C) + # Mask shape: (batch, H, W) or (H, W) + m = mask.clone() + if m.dim() == 2: + m = m.unsqueeze(0) + + for i in range(img.shape[0]): + frame = img[i] # (H, W, C) + frame_mask = m[i] if i < m.shape[0] else m[0] # (H, W) + frame_mask = frame_mask.unsqueeze(-1) # (H, W, 1) + + # Clamp mask 0-1 + frame_mask = frame_mask.clamp(0.0, 1.0) + + if preserve_luminance: + # BT.709 luminance from original pixel + lum = (0.2126 * frame[:, :, 0] + + 0.7152 * frame[:, :, 1] + + 0.0722 * frame[:, :, 2]) # (H, W) + + # Target color scaled by luminance + new_r = t_r * lum + new_g = t_g * lum + new_b = t_b * lum + colored = torch.stack([new_r, new_g, new_b], dim=-1) # (H, W, 3) + else: + # Flat color fill + colored = torch.zeros_like(frame) + colored[:, :, 0] = t_r + colored[:, :, 1] = t_g + colored[:, :, 2] = t_b + + # Blend: original → colored based on blend strength + blended = frame * (1.0 - blend) + colored * blend + + # Apply via mask: masked areas get recolor, unmasked stay original + img[i] = frame * (1.0 - frame_mask) + blended * frame_mask + + img = img.clamp(0.0, 1.0) + + json_result = json.dumps({ + "node": "RecolorNode", + "R": int(R), + "G": int(G), + "B": int(B), + "hex_color": hex_color if hex_color else f"#{int(R):02X}{int(G):02X}{int(B):02X}", + "preserve_luminance": preserve_luminance, + "blend": blend + }) + + return (img, json_result, "done") + + +NODE_CLASS_MAPPINGS = { + "RecolorNode": RecolorNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "RecolorNode": "Recolor (Mask)", +} \ No newline at end of file diff --git a/py/svg_tools.py b/py/svg_tools.py new file mode 100644 index 0000000..2592bc8 --- /dev/null +++ b/py/svg_tools.py @@ -0,0 +1,256 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Paid Node, credit values may change. +# Augmentstudio.app + +import io +import json +import os +import requests +import time +import folder_paths +import numpy as np +import torch +from PIL import Image + + +NODE_ID = "svg_to_png" +API_URL = "https://augmentstudio.app/api" + +UPLOAD_DIR = folder_paths.get_input_directory() + + +class AugmentSVGToPNG: + @classmethod + def INPUT_TYPES(cls): + files = sorted([ + f for f in os.listdir(UPLOAD_DIR) + if f.lower().endswith(".svg") + ]) + return { + "required": { + "svg_file": (files, {"image_upload": True}), + "api_key": ("STRING", {"default": ""}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK", "TRIGGER") + RETURN_NAMES = ("image", "alpha", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Vector" + + @classmethod + def IS_CHANGED(cls, svg_file="", **kwargs): + path = os.path.join(UPLOAD_DIR, svg_file) + if os.path.isfile(path): + return os.path.getmtime(path) + return float("nan") + + @classmethod + def VALIDATE_INPUTS(cls, svg_file="", **kwargs): + path = os.path.join(UPLOAD_DIR, svg_file) + if not os.path.isfile(path): + return f"File not found: {svg_file}" + return True + + def execute(self, svg_file, api_key, trigger=None): + api_url = API_URL.rstrip("/") + auth = {"Authorization": f"Bearer {api_key}"} + + path = os.path.join(UPLOAD_DIR, svg_file) + with open(path, "rb") as f: + svg_bytes = f.read() + + print(f"[Augment API] Submitting SVG: {svg_file} ({len(svg_bytes)} bytes)") + try: + r = requests.post( + f"{api_url}/process", + files={"image": (svg_file, svg_bytes, "image/svg+xml")}, + data={"node_id": NODE_ID}, + headers=auth, + timeout=30, + ) + except requests.exceptions.RequestException as e: + print(f"[Augment API] Submit failed: {e}") + raise + + if r.status_code != 200: + raise RuntimeError(f"Submit error {r.status_code}: {r.text[:500]}") + + request_id = r.json().get("request_id") + if not request_id: + raise RuntimeError(f"No request_id: {r.text[:500]}") + + print(f"[Augment API] Job submitted: {request_id}") + + max_wait = 60 + elapsed = 0 + job_status = "unknown" + + while elapsed < max_wait: + time.sleep(1) + elapsed += 1 + try: + status_r = requests.get( + f"{api_url}/job/{request_id}/status", + headers=auth, timeout=10, + ) + job_status = status_r.json().get("status", "unknown") + except Exception as e: + print(f"[Augment API] Poll error: {e}") + continue + + if job_status == "done": + break + elif job_status == "error": + raise RuntimeError(f"Job failed: {status_r.json().get('error')}") + + if job_status != "done": + raise RuntimeError(f"Timed out after {max_wait}s") + + img_r = requests.get( + f"{api_url}/job/{request_id}/image", + headers=auth, timeout=60, + ) + if img_r.status_code != 200: + raise RuntimeError(f"Image fetch error: {img_r.status_code}") + + img = Image.open(io.BytesIO(img_r.content)).convert("RGBA") + arr = np.array(img).astype(np.float32) / 255.0 + + image_tensor = torch.from_numpy(arr[:, :, :3]).unsqueeze(0) + mask_tensor = torch.from_numpy(arr[:, :, 3]).unsqueeze(0) + + print(f"[Augment API] Done! {img.size[0]}x{img.size[1]}") + return (image_tensor, mask_tensor, "done") + +class AugmentPNGToSVG: + @classmethod + def INPUT_TYPES(cls): + files = sorted([ + f for f in os.listdir(UPLOAD_DIR) + if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp")) + ]) + return { + "required": { + "api_key": ("STRING", {"default": ""}), + }, + "optional": { + "image": ("IMAGE",), + "image_file": (files, {"image_upload": True}), + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("SVG", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("svg", "json", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Vector" + OUTPUT_NODE = True + + @classmethod + def IS_CHANGED(cls, **kwargs): + return time.time() + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, api_key, image=None, image_file=None, trigger=None): + api_url = API_URL.rstrip("/") + auth = {"Authorization": f"Bearer {api_key}"} + + if image is not None: + from PIL import Image as PILImage + img_np = (image[0].cpu().numpy() * 255).astype(np.uint8) + pil_img = PILImage.fromarray(img_np, "RGB") + buf = io.BytesIO() + pil_img.save(buf, format="PNG") + img_bytes = buf.getvalue() + source_name = "image_input.png" + mime = "image/png" + elif image_file: + path = os.path.join(UPLOAD_DIR, image_file) + with open(path, "rb") as f: + img_bytes = f.read() + source_name = image_file + ext = os.path.splitext(image_file)[1].lower() + mime_types = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"} + mime = mime_types.get(ext, "image/png") + else: + raise RuntimeError("No image provided — connect an image input or select a file") + + print(f"[Augment API] Submitting image: {source_name} ({len(img_bytes)} bytes)") + try: + r = requests.post( + f"{api_url}/process", + files={"image": (source_name, img_bytes, mime)}, + data={"node_id": "png_to_svg"}, + headers=auth, + timeout=30, + ) + except requests.exceptions.RequestException as e: + print(f"[Augment API] Submit failed: {e}") + raise + + if r.status_code != 200: + raise RuntimeError(f"Submit error {r.status_code}: {r.text[:500]}") + + request_id = r.json().get("request_id") + if not request_id: + raise RuntimeError(f"No request_id: {r.text[:500]}") + + print(f"[Augment API] Job submitted: {request_id}") + + max_wait = 60 + elapsed = 0 + job_status = "unknown" + + while elapsed < max_wait: + time.sleep(1) + elapsed += 1 + try: + status_r = requests.get( + f"{api_url}/job/{request_id}/status", + headers=auth, timeout=10, + ) + job_status = status_r.json().get("status", "unknown") + except Exception as e: + print(f"[Augment API] Poll error: {e}") + continue + + if job_status == "done": + break + elif job_status == "error": + raise RuntimeError(f"Job failed: {status_r.json().get('error')}") + + if job_status != "done": + raise RuntimeError(f"Timed out after {max_wait}s") + + svg_r = requests.get( + f"{api_url}/job/{request_id}/image", + headers=auth, timeout=60, + ) + if svg_r.status_code != 200: + raise RuntimeError(f"SVG fetch error: {svg_r.status_code}") + + from comfy_api.latest._util.image_types import SVG + svg_bytes = io.BytesIO(svg_r.content) + size = svg_bytes.getbuffer().nbytes + print(f"[Augment API] Done! Received SVG ({size} bytes)") + json_result = json.dumps({"node": "AugmentPNGToSVG", "size_bytes": size}) + return {"ui": {"text": [svg_r.text[:500]]}, "result": (SVG([svg_bytes]), json_result, "done")} + + +NODE_CLASS_MAPPINGS = { + "AugmentSVGToPNG": AugmentSVGToPNG, + "AugmentPNGToSVG": AugmentPNGToSVG, +} +NODE_DISPLAY_NAME_MAPPINGS = { + "AugmentSVGToPNG": "Augment SVG to PNG", + "AugmentPNGToSVG": "Augment PNG to SVG", +} + diff --git a/py/switch.py b/py/switch.py new file mode 100644 index 0000000..0bcd3fe --- /dev/null +++ b/py/switch.py @@ -0,0 +1,31 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +from comfy_execution.graph_utils import ExecutionBlocker + + +class SwitchNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("BOOLEAN", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("TRIGGER", "TRIGGER", "TRIGGER") + RETURN_NAMES = ("on_true", "on_false", "trigger") + OUTPUT_NODE = True + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, input): + on_true = "done" if input else ExecutionBlocker(None) + on_false = ExecutionBlocker(None) if input else "done" + ui_text = f"Input: {input}\nPath: {'on_true' if input else 'on_false'}" + return {"ui": {"text": [ui_text]}, "result": (on_true, on_false, "done")} diff --git a/py/trigger.py b/py/trigger.py new file mode 100644 index 0000000..fc6fb60 --- /dev/null +++ b/py/trigger.py @@ -0,0 +1,99 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import json + + +class GateNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("*",), + }, + } + + RETURN_TYPES = ("TRIGGER", "*", "STRING") + RETURN_NAMES = ("trigger", "value", "json") + OUTPUT_NODE = True + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, input): + json_result = json.dumps({"node": "Convert Any", "type": type(input).__name__, "value": str(input)}) + return {"ui": {"text": [str(input)]}, "result": ("done", input, json_result)} + + +WAIT_DEFAULTS = { + "INT": {"default": 0}, + "FLOAT": {"default": 0.0, "step": 0.01}, + "STRING": {"default": ""}, + "BOOLEAN": {"default": False}, +} + + +def make_wait_node(type_name, type_str): + class WaitNode: + @classmethod + def INPUT_TYPES(cls): + has_widget = type_str in WAIT_DEFAULTS + inputs = { + "required": {}, + "optional": { + "trigger": ("TRIGGER",), + }, + } + if has_widget: + inputs["required"]["value"] = (type_str, WAIT_DEFAULTS[type_str]) + inputs["optional"]["input"] = (type_str, {"forceInput": True}) + else: + inputs["required"]["value"] = (type_str,) + return inputs + + RETURN_TYPES = (type_str, "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("value", "json", "trigger") + OUTPUT_NODE = True + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, value=None, trigger=None, input=None): + v = input if input is not None else value + json_result = json.dumps({"node": type_str, "type": type_str.lower(), "value": str(v)}) + return {"ui": {"text": [str(v)]}, "result": (v, json_result, "done")} + + WaitNode.__name__ = f"Wait{type_name}Node" + WaitNode.__qualname__ = f"Wait{type_name}Node" + return WaitNode + + +WAIT_TYPES = { + "Int": "INT", + "Float": "FLOAT", + "String": "STRING", + "Bool": "BOOLEAN", + "Image": "IMAGE", + "Latent": "LATENT", + "Condition": "CONDITIONING", + "VAE": "VAE", + "Model": "MODEL", + "Clip": "CLIP", + "Mask": "MASK", +} + +NODE_CLASS_MAPPINGS = {"GateNode": GateNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GateNode": "Trigger"} + +for name, type_str in WAIT_TYPES.items(): + cls_name = f"Wait{name}Node" + node_cls = make_wait_node(name, type_str) + NODE_CLASS_MAPPINGS[cls_name] = node_cls + NODE_DISPLAY_NAME_MAPPINGS[cls_name] = f"{name} (Improved)" diff --git a/py/utils.py b/py/utils.py new file mode 100644 index 0000000..a3a5a54 --- /dev/null +++ b/py/utils.py @@ -0,0 +1,256 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +""" +Smart shape-edge detection for construction lines. +Extracts meaningful structural lines, not boundary noise. +""" +import numpy as np + +try: + from scipy import ndimage + HAS_SCIPY = True +except ImportError: + HAS_SCIPY = False + + +def compute_all_edges(mask_2d, edge_min=None, merge_tol=None): + """ + Main entry point — replaces the old boundary-tracing approach. + edge_min and merge_tol are kept for API compatibility but ignored. + """ + return compute_smart_edges(mask_2d) + + +def compute_smart_edges(mask, symmetry_threshold=0.85): + """ + Extract meaningful construction lines from a mask. + Returns structural lines, not boundary noise. + """ + binary = (mask > 0.5).astype(np.uint8) + + if binary.sum() == 0: + return { + "h_lines": [], + "v_lines": [], + "d45_lines": [], + "d135_lines": [], + } + + ys, xs = np.where(binary) + + # === BOUNDING BOX === + x_min, x_max = int(xs.min()), int(xs.max()) + y_min, y_max = int(ys.min()), int(ys.max()) + + bbox_w = x_max - x_min + bbox_h = y_max - y_min + + # === CENTERS === + center_x = (x_min + x_max) // 2 + center_y = (y_min + y_max) // 2 + + # === CENTER OF MASS === + if HAS_SCIPY: + com_y, com_x = ndimage.center_of_mass(binary) + com_x, com_y = int(com_x), int(com_y) + else: + com_x, com_y = center_x, center_y + + # === BUILD LINES === + h_lines = [y_min, y_max] + v_lines = [x_min, x_max] + + # Add geometric center + if bbox_h > 20: + h_lines.append(center_y) + if bbox_w > 20: + v_lines.append(center_x) + + # Add center of mass if different from geometric center + if bbox_h > 40 and abs(com_y - center_y) > bbox_h * 0.05: + h_lines.append(com_y) + if bbox_w > 40 and abs(com_x - center_x) > bbox_w * 0.05: + v_lines.append(com_x) + + # === DENSITY-BASED DIVISIONS === + # Find where the shape has significant internal gaps + h_divisions = _find_density_divisions(binary, axis='horizontal', min_gap=max(8, bbox_h // 20)) + v_divisions = _find_density_divisions(binary, axis='vertical', min_gap=max(8, bbox_w // 20)) + + for y in h_divisions: + if y not in h_lines and y_min < y < y_max: + h_lines.append(y) + + for x in v_divisions: + if x not in v_lines and x_min < x < x_max: + v_lines.append(x) + + # === DIAGONALS (only for symmetric shapes) === + d45_lines = [] + d135_lines = [] + + if _is_roughly_square(bbox_w, bbox_h, tolerance=0.25): + # Check for diagonal symmetry + crop = binary[y_min:y_max+1, x_min:x_max+1] + + d45_score = _diagonal_symmetry_score(crop, axis='d45') + d135_score = _diagonal_symmetry_score(crop, axis='d135') + + if d45_score > symmetry_threshold: + d45_lines.append(center_x - center_y) + + if d135_score > symmetry_threshold: + d135_lines.append(center_x + center_y) + + # === CORNER DIAGONALS (useful for any shape) === + # Lines from bbox corners through center + if bbox_w > 50 and bbox_h > 50: + # Only add if the shape has significant diagonal extent + diag_coverage_45 = _check_diagonal_coverage(binary, x_min, y_min, x_max, y_max, direction=45) + diag_coverage_135 = _check_diagonal_coverage(binary, x_min, y_min, x_max, y_max, direction=135) + + if diag_coverage_45 > 0.3: + d45_lines.append(x_min - y_min) # top-left to bottom-right + d45_lines.append(x_max - y_max) # if different + + if diag_coverage_135 > 0.3: + d135_lines.append(x_min + y_max) # bottom-left to top-right + d135_lines.append(x_max + y_min) # if different + + # === CLEANUP === + h_lines = sorted(set(h_lines)) + v_lines = sorted(set(v_lines)) + d45_lines = sorted(set(d45_lines)) + d135_lines = sorted(set(d135_lines)) + + # Merge lines that are too close + h_lines = _merge_close(h_lines, min_dist=max(5, bbox_h // 30)) + v_lines = _merge_close(v_lines, min_dist=max(5, bbox_w // 30)) + d45_lines = _merge_close(d45_lines, min_dist=10) + d135_lines = _merge_close(d135_lines, min_dist=10) + + return { + "h_lines": h_lines, + "v_lines": v_lines, + "d45_lines": d45_lines, + "d135_lines": d135_lines, + } + + +def _find_density_divisions(binary, axis='horizontal', min_gap=8): + """ + Find lines where pixel density drops significantly. + These indicate structural divisions (baselines, gaps between elements, etc.) + """ + divisions = [] + + if axis == 'horizontal': + density = binary.sum(axis=1).astype(float) + else: + density = binary.sum(axis=0).astype(float) + + if len(density) < 10: + return divisions + + max_d = density.max() + if max_d == 0: + return divisions + + density = density / max_d + + # Find valleys (low density regions) + threshold = 0.15 + in_gap = False + gap_start = 0 + + for i, d in enumerate(density): + if d < threshold and not in_gap: + in_gap = True + gap_start = i + elif d >= threshold and in_gap: + in_gap = False + gap_size = i - gap_start + if gap_size >= min_gap: + divisions.append(gap_start + gap_size // 2) + + return divisions + + +def _diagonal_symmetry_score(binary, axis='d45'): + """Check how symmetric a shape is along a diagonal.""" + h, w = binary.shape + if h != w: + # Pad to square for comparison + size = max(h, w) + padded = np.zeros((size, size), dtype=binary.dtype) + padded[:h, :w] = binary + binary = padded + + if axis == 'd45': + flipped = binary.T + else: # d135 + flipped = np.flipud(np.fliplr(binary)).T + + intersection = np.logical_and(binary, flipped).sum() + union = np.logical_or(binary, flipped).sum() + + if union == 0: + return 0.0 + + return intersection / union + + +def _is_roughly_square(w, h, tolerance=0.25): + """Check if aspect ratio is close to 1:1""" + if w == 0 or h == 0: + return False + ratio = min(w, h) / max(w, h) + return ratio > (1 - tolerance) + + +def _check_diagonal_coverage(binary, x_min, y_min, x_max, y_max, direction=45): + """ + Check what fraction of a diagonal line through the bbox is covered by the shape. + """ + samples = 20 + hits = 0 + + for i in range(samples): + t = i / (samples - 1) + + if direction == 45: + x = int(x_min + t * (x_max - x_min)) + y = int(y_min + t * (y_max - y_min)) + else: # 135 + x = int(x_min + t * (x_max - x_min)) + y = int(y_max - t * (y_max - y_min)) + + if 0 <= y < binary.shape[0] and 0 <= x < binary.shape[1]: + if binary[y, x]: + hits += 1 + + return hits / samples + + +def _merge_close(lines, min_dist=5): + """Merge lines that are within min_dist of each other.""" + if len(lines) <= 1: + return lines + + result = [lines[0]] + for line in lines[1:]: + if line - result[-1] >= min_dist: + result.append(line) + else: + # Keep the average? Or just skip? Let's keep first. + pass + + return result + + +# === LEGACY API (kept for compatibility) === + +def build_alpha_grid(mask_2d): + return (mask_2d > 0).astype(np.uint8) \ No newline at end of file diff --git a/py/variables.py b/py/variables.py new file mode 100644 index 0000000..6dbe96c --- /dev/null +++ b/py/variables.py @@ -0,0 +1,330 @@ +# "Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +# Augmentstudio.app + +import os +import json + + +VARS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "input") +VARS_FILE = os.path.join(VARS_DIR, "vars.txt") + + +def load_vars(): + if os.path.exists(VARS_FILE): + try: + with open(VARS_FILE, "r") as f: + return json.load(f) + except: + return {} + return {} + + +def save_vars(data): + os.makedirs(VARS_DIR, exist_ok=True) + with open(VARS_FILE, "w") as f: + json.dump(data, f, indent=2) + + +def vars_summary(): + data = load_vars() + if not data: + return "(no variables saved)" + return "\n".join(f"{k}: {v}" for k, v in data.items()) + + +# ── SET ────────────────────────────────────────────── +class NumberVarSetNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "variable_name": ("STRING", {"default": "my_variable"}), + "value": ("FLOAT", {"default": 0.0, "step": 0.01}), + }, + "optional": { + "trigger": ("TRIGGER",), + "input": ("FLOAT", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("FLOAT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("number", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, variable_name, value, trigger=None, input=None): + v = input if input is not None else value + data = load_vars() + data[variable_name] = v + save_vars(data) + json_result = json.dumps({"node": "NumberVarSetNode", "variable": variable_name, "value": v, "type": "number"}) + return {"ui": {"text": [vars_summary()]}, "result": (v, json_result, "done")} + + +# ── GET ────────────────────────────────────────────── +class NumberVarGetNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "variable_name": ("STRING", {"default": "my_variable"}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("FLOAT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("number", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, variable_name, trigger=None): + data = load_vars() + value = data.get(variable_name, 0.0) + json_result = json.dumps({"node": "NumberVarGetNode", "variable": variable_name, "value": value, "type": "number"}) + return {"ui": {"text": [vars_summary()]}, "result": (value, json_result, "done")} + + +# ── INCREMENT ──────────────────────────────────────── +class NumberVarIncrementNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "variable_name": ("STRING", {"default": "my_variable"}), + "amount": ("FLOAT", {"default": 1.0, "step": 0.01}), + }, + "optional": { + "trigger": ("TRIGGER",), + "input": ("FLOAT", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("FLOAT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("number", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, variable_name, amount, trigger=None, input=None): + data = load_vars() + current = input if input is not None else data.get(variable_name, 0.0) + new_value = current + amount + data[variable_name] = new_value + save_vars(data) + json_result = json.dumps({"node": "NumberVarIncrementNode", "variable": variable_name, "value": new_value, "amount": amount, "type": "number"}) + return {"ui": {"text": [vars_summary()]}, "result": (new_value, json_result, "done")} + + +# ── DECREMENT ──────────────────────────────────────── +class NumberVarDecrementNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "variable_name": ("STRING", {"default": "my_variable"}), + "amount": ("FLOAT", {"default": 1.0, "step": 0.01}), + }, + "optional": { + "trigger": ("TRIGGER",), + "input": ("FLOAT", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("FLOAT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("number", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, variable_name, amount, trigger=None, input=None): + data = load_vars() + current = input if input is not None else data.get(variable_name, 0.0) + new_value = current - amount + data[variable_name] = new_value + save_vars(data) + json_result = json.dumps({"node": "NumberVarDecrementNode", "variable": variable_name, "value": new_value, "amount": amount, "type": "number"}) + return {"ui": {"text": [vars_summary()]}, "result": (new_value, json_result, "done")} + + +# ── RESET ──────────────────────────────────────────── +class NumberVarResetNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "variable_name": ("STRING", {"default": "my_variable"}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("FLOAT", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("number", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, variable_name, trigger=None): + data = load_vars() + data[variable_name] = 0.0 + save_vars(data) + json_result = json.dumps({"node": "NumberVarResetNode", "variable": variable_name, "value": 0.0, "type": "number"}) + return {"ui": {"text": [vars_summary()]}, "result": (0.0, json_result, "done")} + + +# ── STRING SET ──────────────────────────────────────── +class StringVarSetNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "variable_name": ("STRING", {"default": "api_key"}), + "value": ("STRING", {"default": ""}), + }, + "optional": { + "trigger": ("TRIGGER",), + "input": ("STRING", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("STRING", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("text", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, variable_name, value, trigger=None, input=None): + v = input if input is not None else value + data = load_vars() + data[variable_name] = v + save_vars(data) + masked = _masked_summary(load_vars()) + json_result = json.dumps({"node": "StringVarSetNode", "variable": variable_name, "value": v, "type": "string"}) + return {"ui": {"text": [masked]}, "result": (v, json_result, "done")} + + +# ── STRING GET ──────────────────────────────────────── +class StringVarGetNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "variable_name": ("STRING", {"default": "api_key"}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("STRING", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("text", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, variable_name, trigger=None): + data = load_vars() + value = data.get(variable_name, "") + masked = _masked_summary(data) + json_result = json.dumps({"node": "StringVarGetNode", "variable": variable_name, "value": value, "type": "string"}) + return {"ui": {"text": [masked]}, "result": (value, json_result, "done")} + + +# ── STRING DELETE ───────────────────────────────────── +class StringVarDeleteNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "variable_name": ("STRING", {"default": "api_key"}), + }, + "optional": { + "trigger": ("TRIGGER",), + }, + } + + RETURN_TYPES = ("STRING", "AUGMENT_JSON", "TRIGGER") + RETURN_NAMES = ("text", "json_result", "trigger") + FUNCTION = "execute" + CATEGORY = "Augment/Utils" + OUTPUT_NODE = True + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def execute(self, variable_name, trigger=None): + data = load_vars() + removed = data.pop(variable_name, "") + save_vars(data) + json_result = json.dumps({"node": "StringVarDeleteNode", "variable": variable_name, "deleted_value": removed, "type": "string"}) + return {"ui": {"text": [vars_summary()]}, "result": (removed, json_result, "done")} + + +# ── helpers ────────────────────────────────────────── +def _masked_summary(data): + """Mask string values that look like keys/secrets in the UI.""" + lines = [] + for k, v in data.items(): + if isinstance(v, str) and len(v) > 12: + lines.append(f"{k}: {v[:4]}...{v[-4:]}") + else: + lines.append(f"{k}: {v}") + return "\n".join(lines) if lines else "(no variables saved)" + + +NODE_CLASS_MAPPINGS = { + "NumberVarSetNode": NumberVarSetNode, + "NumberVarGetNode": NumberVarGetNode, + "NumberVarIncrementNode": NumberVarIncrementNode, + "NumberVarDecrementNode": NumberVarDecrementNode, + "NumberVarResetNode": NumberVarResetNode, + "StringVarSetNode": StringVarSetNode, + "StringVarGetNode": StringVarGetNode, + "StringVarDeleteNode": StringVarDeleteNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "NumberVarSetNode": "Variable Set (Number)", + "NumberVarGetNode": "Variable Get (Number)", + "NumberVarIncrementNode": "Variable Increment (Number)", + "NumberVarDecrementNode": "Variable Decrement (Number)", + "NumberVarResetNode": "Variable Reset (Number)", + "StringVarSetNode": "Variable Set (String)", + "StringVarGetNode": "Variable Get (String)", + "StringVarDeleteNode": "Variable Delete (String)", +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4b7fcd9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "augment-ComfyUI" +description = "Design-focused node suite for ComfyUI. Convert raster to vector with a built-in PNG to SVG pipeline — bring true vectorization to your generative workflows and stop exporting pixel-locked assets. Includes precision utilities for grid systems, construction lines, edge detection, masking, image transforms, cropping, JSON tools, and flow control. Built for logo design, brand asset pipelines, and anyone who needs clean scalable output from their design based workflows. API tokens for vector nodes available at [augment studio](https://augmentstudio.app/pricing)" +version = "1.0.0" + +dependencies = [ + "opencv-python>=4.5.0", + "numpy>=1.19.0", + "torch>=1.9.0", + "Pillow>=8.0.0", + "requests>=2.25.0" +] + +[project.urls] +Repository = "https://github.com/augmentstudio/augment" + +[tool.comfy] +PublisherId = "augment" +DisplayName = "augment-ComfyUI"" +Icon = "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cf2f0af --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +opencv-python>=4.5.0 +numpy>=1.19.0 +torch>=1.9.0 +Pillow>=8.0.0 +requests>=2.25.0 diff --git a/web/js/augment_style.js b/web/js/augment_style.js new file mode 100644 index 0000000..f310894 --- /dev/null +++ b/web/js/augment_style.js @@ -0,0 +1,28 @@ +/*"Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +Augmentstudio.app */ + +import { app } from "../../../scripts/app.js"; + +const TITLE_BG = "#443322"; +const NODE_BG = "#665533"; + +const PAID_NODES = [ + "AugmentSVGToPNG", + "AugmentPNGToSVG", +]; + +app.registerExtension({ + name: "augment.NodeStyle", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (!PAID_NODES.includes(nodeData.name)) return; + + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + this.color = TITLE_BG; + this.bgcolor = NODE_BG; + onDrawForeground?.apply(this, arguments); + }; + }, +}); diff --git a/web/js/json_viewer.js b/web/js/json_viewer.js new file mode 100644 index 0000000..81746b7 --- /dev/null +++ b/web/js/json_viewer.js @@ -0,0 +1,89 @@ +/*"Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +Augmentstudio.app */ + +import { app } from "../../../scripts/app.js"; + +function syntaxHighlight(json) { + json = json.replace(/&/g, "&").replace(//g, ">"); + return json.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, + function (match) { + let cls = "json-number"; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = "json-key"; + } else { + cls = "json-string"; + } + } else if (/true|false/.test(match)) { + cls = "json-boolean"; + } else if (/null/.test(match)) { + cls = "json-null"; + } + return '' + match + ""; + } + ); +} + +app.registerExtension({ + name: "augment.JsonViewer", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "AugmentJsonViewer" || nodeData.name === "AugmentJsonExtract") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated ? onNodeCreated.apply(this, []) : undefined; + + const container = document.createElement("div"); + container.style.cssText = ` + background:rgb(9, 9, 9); + border-radius: 6px; + padding: 8px 10px; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 11px; + line-height: 1.4; + overflow: auto; + max-height: 300px; + white-space: pre-wrap; + word-break: break-word; + color: #ccc; + `; + + const style = document.createElement("style"); + style.textContent = ` + .json-key { color:rgb(255, 255, 255); font-weight: 600; } + .json-string { color: #fb971d; } + .json-number { color: #fb971d; } + .json-boolean { color: #fb971d; } + .json-null { color: #666; font-style: italic; } + `; + container.appendChild(style); + + const pre = document.createElement("pre"); + pre.style.cssText = "margin: 0; font: inherit;"; + pre.innerHTML = 'waiting for data...'; + container.appendChild(pre); + + const widget = this.addDOMWidget("json_display", "customtext", container, { + getValue() { return ""; }, + setValue() {}, + serialize: false, + }); + widget.computeSize = function () { + return [220, Math.min(container.scrollHeight + 16, 320)]; + }; + + this._jsonContainer = pre; + }; + + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, [message]); + if (message?.text?.[0] !== undefined && this._jsonContainer) { + this._jsonContainer.innerHTML = syntaxHighlight(message.text[0]); + this.setSize(this.computeSize()); + } + }; + } + }, +}); diff --git a/web/js/preview_any.js b/web/js/preview_any.js new file mode 100644 index 0000000..b131c22 --- /dev/null +++ b/web/js/preview_any.js @@ -0,0 +1,34 @@ +/*"Design-focused node suite for ComfyUI." +# Copyright 2026 Augment Studio +Augmentstudio.app */ + +import { app } from "../../../scripts/app.js"; +import { ComfyWidgets } from "../../../scripts/widgets.js"; + +app.registerExtension({ + name: "augment.PreviewAny", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + const previewNodes = [ + "AugmentPreviewAny", "AugmentSwitch", + "AugmentSHA256", "AugmentRandomNumber", "AugmentUUID", + "AugmentLoadImage", + ]; + if (previewNodes.includes(nodeData.name)) { + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated ? onNodeCreated.apply(this, []) : undefined; + this.showValueWidget = ComfyWidgets["STRING"](this, "output", ["STRING", { multiline: true }], app).widget; + this.showValueWidget.inputEl.readOnly = true; + this.showValueWidget.serializeValue = async () => ""; + }; + + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, [message]); + if (message?.text?.[0] !== undefined) { + this.showValueWidget.value = message.text[0]; + } + }; + } + }, +}); diff --git a/workflow_examples/augment_logo_crop.json b/workflow_examples/augment_logo_crop.json new file mode 100644 index 0000000..b332fc0 --- /dev/null +++ b/workflow_examples/augment_logo_crop.json @@ -0,0 +1,649 @@ +{ + "id": "0844657a-3ed3-4e2a-a7c9-54a6f113a60c", + "revision": 0, + "last_node_id": 13, + "last_link_id": 20, + "nodes": [ + { + "id": 2, + "type": "AugmentLogoMask", + "pos": [ + -100.25392017898614, + 1025.3065279520267 + ], + "size": [ + 270, + 234 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "mask", + "type": "MASK", + "links": [ + 4 + ] + }, + { + "name": "mask_image", + "type": "IMAGE", + "links": null + }, + { + "name": "masked_rgb", + "type": "IMAGE", + "links": null + }, + { + "name": "json_result", + "type": "AUGMENT_JSON", + "links": null + }, + { + "name": "trigger", + "type": "TRIGGER", + "links": null + } + ], + "properties": { + "Node name for S&R": "AugmentLogoMask" + }, + "widgets_values": [ + 50, + 150, + true, + 3, + false + ] + }, + { + "id": 5, + "type": "AugmentMaskBoundingBox", + "pos": [ + 232.100684634451, + 945.6714392681223 + ], + "size": [ + 270, + 226 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "mask", + "type": "MASK", + "link": 4 + } + ], + "outputs": [ + { + "name": "mask", + "type": "MASK", + "links": null + }, + { + "name": "x", + "type": "INT", + "links": [ + 5 + ] + }, + { + "name": "y", + "type": "INT", + "links": [ + 6 + ] + }, + { + "name": "width", + "type": "INT", + "links": [ + 8 + ] + }, + { + "name": "height", + "type": "INT", + "links": [ + 9 + ] + }, + { + "name": "json_result", + "type": "AUGMENT_JSON", + "links": null + }, + { + "name": "trigger", + "type": "TRIGGER", + "links": null + } + ], + "properties": { + "Node name for S&R": "AugmentMaskBoundingBox" + }, + "widgets_values": [ + 0, + false, + 0.1 + ] + }, + { + "id": 13, + "type": "Reroute", + "pos": [ + 939.6250943735594, + 1024.304073871377 + ], + "size": [ + 75, + 26 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "", + "type": "*", + "link": 18 + } + ], + "outputs": [ + { + "name": "", + "type": "IMAGE", + "links": [ + 19, + 20 + ] + } + ], + "properties": { + "showOutputText": false, + "horizontal": false + } + }, + { + "id": 4, + "type": "AugmentImageCrop", + "pos": [ + 612.3988632473819, + 1026.1191308977811 + ], + "size": [ + 270, + 250 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 11 + }, + { + "name": "x", + "type": "INT", + "widget": { + "name": "x" + }, + "link": 5 + }, + { + "name": "y", + "type": "INT", + "widget": { + "name": "y" + }, + "link": 6 + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 8 + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 9 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 18 + ] + }, + { + "name": "x", + "type": "INT", + "links": null + }, + { + "name": "y", + "type": "INT", + "links": null + }, + { + "name": "width", + "type": "INT", + "links": null + }, + { + "name": "height", + "type": "INT", + "links": null + }, + { + "name": "json_result", + "type": "AUGMENT_JSON", + "links": null + }, + { + "name": "trigger", + "type": "TRIGGER", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AugmentImageCrop" + }, + "widgets_values": [ + 0, + 0, + 256, + 256 + ] + }, + { + "id": 12, + "type": "AugmentPreviewImage", + "pos": [ + 1407.4440840935729, + 1190.1110326830787 + ], + "size": [ + 265.1408536461356, + 286 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 20 + }, + { + "name": "trigger", + "shape": 7, + "type": "TRIGGER", + "link": 16 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": null + }, + { + "name": "json", + "type": "AUGMENT_JSON", + "links": null + }, + { + "name": "trigger", + "type": "TRIGGER", + "links": null + } + ], + "properties": { + "Node name for S&R": "AugmentPreviewImage" + }, + "widgets_values": [] + }, + { + "id": 1, + "type": "LoadImage", + "pos": [ + -486.9632087162334, + 1026.1191308977811 + ], + "size": [ + 282.8222198486328, + 314.0000000000001 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1, + 11 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.17.0", + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "augment_logo.png", + "image" + ] + }, + { + "id": 11, + "type": "AugmentBoolPrim", + "pos": [ + 612.3988632473819, + 1342.0527501870952 + ], + "size": [ + 270, + 98 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "trigger", + "shape": 7, + "type": "TRIGGER", + "link": null + }, + { + "name": "input", + "shape": 7, + "type": "BOOLEAN", + "link": null + } + ], + "outputs": [ + { + "name": "value", + "type": "BOOLEAN", + "links": [ + 14 + ] + }, + { + "name": "json_result", + "type": "AUGMENT_JSON", + "links": null + }, + { + "name": "trigger", + "type": "TRIGGER", + "links": null + } + ], + "title": "SAVE_AS_VECTOR?", + "properties": { + "Node name for S&R": "AugmentBoolPrim" + }, + "widgets_values": [ + false + ] + }, + { + "id": 10, + "type": "AugmentSwitchNode", + "pos": [ + 1170.143532336275, + 1048.0987133289495 + ], + "size": [ + 150.4999984741211, + 66 + ], + "flags": { + "collapsed": true + }, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "input", + "type": "BOOLEAN", + "link": 14 + } + ], + "outputs": [ + { + "name": "on_true", + "type": "TRIGGER", + "links": [ + 17 + ] + }, + { + "name": "on_false", + "type": "TRIGGER", + "links": [ + 16 + ] + }, + { + "name": "trigger", + "type": "TRIGGER", + "links": null + } + ], + "properties": { + "Node name for S&R": "AugmentSwitchNode" + }, + "widgets_values": [] + }, + { + "id": 7, + "type": "AugmentPNGToSVG", + "pos": [ + 1407.4440840935729, + 793.8371232979359 + ], + "size": [ + 270, + 122 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "image", + "shape": 7, + "type": "IMAGE", + "link": 19 + }, + { + "name": "trigger", + "shape": 7, + "type": "TRIGGER", + "link": 17 + } + ], + "outputs": [ + { + "name": "svg", + "type": "SVG", + "links": [] + }, + { + "name": "json", + "type": "AUGMENT_JSON", + "links": null + }, + { + "name": "trigger", + "type": "TRIGGER", + "links": null + } + ], + "properties": { + "Node name for S&R": "AugmentPNGToSVG" + }, + "widgets_values": [ + "", + "example.png" + ], + "color": "#443322", + "bgcolor": "#665533" + } + ], + "links": [ + [ + 1, + 1, + 0, + 2, + 0, + "IMAGE" + ], + [ + 4, + 2, + 0, + 5, + 0, + "MASK" + ], + [ + 5, + 5, + 1, + 4, + 1, + "INT" + ], + [ + 6, + 5, + 2, + 4, + 2, + "INT" + ], + [ + 8, + 5, + 3, + 4, + 3, + "INT" + ], + [ + 9, + 5, + 4, + 4, + 4, + "INT" + ], + [ + 11, + 1, + 0, + 4, + 0, + "IMAGE" + ], + [ + 14, + 11, + 0, + 10, + 0, + "BOOLEAN" + ], + [ + 16, + 10, + 1, + 12, + 1, + "TRIGGER" + ], + [ + 17, + 10, + 0, + 7, + 1, + "TRIGGER" + ], + [ + 18, + 4, + 0, + 13, + 0, + "IMAGE" + ], + [ + 19, + 13, + 0, + 7, + 0, + "IMAGE" + ], + [ + 20, + 13, + 0, + 12, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.573085533011682, + "offset": [ + 541.1457978789277, + -558.8768228145503 + ] + }, + "frontendVersion": "1.41.18" + }, + "version": 0.4 +} \ No newline at end of file