From 9dab85c69574c1ba12ffc93f1f49faccc488641a Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Wed, 13 May 2026 21:00:34 +0200 Subject: [PATCH] feat(easy_pattern): add test-pattern node + auto-restart dev-sync (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(easy_pattern): add test-pattern node + auto-restart dev-sync Adds an `Easy_Pattern` node (Koolook/Testing) for solid-color frame batches with optional per-frame index overlays — verifies sequence merging, frame insertion, batch indexing, and video-pipeline frame order at a glance. Color inputs use the same `White/Black/Gray/Custom` dropdown idiom `EasyResize_Koolook` already exposes for pad/panel colors, so "Gray" means the same 50% sRGB across nodes. `scripts/sync_to_dev.py` now restarts the live ComfyUI server after a successful sync (via ComfyUI-Manager's `/manager/reboot`). Default-on because dev-sync is only ever invoked to see new code, and Python custom-node files load once at server start — `--no-restart` is the escape hatch. The `RUNTIME_PATHS` manifest also picks up `k_easy_pattern.py` so adding a new top-level node doesn't silently break the pack at import time on the next sync. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sync-dev): restrict restart URL to http(s) for Bandit B310 `urllib.request.urlopen` accepts arbitrary schemes including `file://`, which Bandit B310 flags as a path-traversal vector. The script's typical caller is the maintainer's own shell, but a typo in `--restart-url` could surprise — and the static gate is right to flag it. Validate the scheme is http/https before the urlopen call and annotate it so Bandit accepts the now-truthful narrowing. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 30 +++++++ README.md | 1 + __init__.py | 5 ++ k_easy_pattern.py | 192 +++++++++++++++++++++++++++++++++++++++++ scripts/sync_to_dev.py | 111 +++++++++++++++++++++++- 5 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 k_easy_pattern.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb100f..525c330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,21 @@ The format is inspired by Keep a Changelog and SemVer. cleanly into another `easy_ImageBatch` instance for chaining or re-using selections. Outputs are appended at slot positions 3 and 4, so existing wires on `image_batch` and `alpha_batch` are unaffected. +- **`Easy_Pattern` node** under category `Koolook/Testing`. Generates a + batch of solid-color frames `[B, H, W, 3]` with optional per-frame + index overlay; configurable `start_from` / `step` / `zero_pad` / + `prefix` / `suffix` for the stamped text, and five overlay positions + (`center` / `top-left` / `top-right` / `bottom-left` / `bottom-right`). + Background and text colors each have a `*_color_mode` dropdown + (`White` / `Black` / `Gray` / `Custom`) backed by an optional hex + string consulted only when the mode is `Custom` — same idiom + `EasyResize_Koolook` already uses for `pad_color_mode` and + `panel_color_mode`, so "Gray" means the same `#808080` 50%-gray across + nodes. Defaults out-of-box to an 81-frame magenta pattern with + white centered text — useful for verifying sequence merging, frame + insertion, batch indexing, and video-pipeline frame order at a glance. + Drafted in an external AI chat, ingested in-tree, relicensed from the + AI's MIT boilerplate to the project's GPL-3.0-or-later. ### Changed - **Easy Image Batch second output renamed `mask_batch` → `alpha_batch`, @@ -93,6 +108,21 @@ The format is inspired by Keep a Changelog and SemVer. result is still what you want — under the old code, `start_frame=N` also offset the source-batch index, which the new model intentionally does not. +- **`scripts/sync_to_dev.py` auto-restarts the live ComfyUI server** by + hitting ComfyUI-Manager's `/manager/reboot` endpoint after a successful + sync. Default-on because dev-sync is invoked precisely when the + maintainer wants new code visible, and Python custom-node modules load + once at server start — without a restart, `.py` changes stay invisible. + Use `--no-restart` to stage files without disturbing the live session; + `--restart-url` overrides the endpoint for non-default ComfyUI + hosts/ports. Restart failures (Manager missing, server not running, + etc.) print a diagnostic but do not fail the sync. The + `RUNTIME_PATHS` manifest also gains `k_easy_pattern.py` so new + top-level node files reach the dev install instead of being silently + dropped — without this entry the modified `__init__.py` would import + a module the live install doesn't have, aborting the whole pack's + registration. + ### Fixed - **Snapshot Settings folder save no longer writes a snapshot.** Saving the library-path field in the Settings dialog no longer writes the in-memory diff --git a/README.md b/README.md index b8f0edd..bc0565f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Using it inside ComfyUI? Open the operational guide: | `KoolookLoadCameraPosesAbsolute` | Koolook Load Camera Poses (Absolute Path) | `Koolook/Camera` | | `Easy_hdr_VAE_encode` | Easy HDR VAE Encode (Koolook) | `Koolook/VAE` | | `Easy_hdr_VAE_decode` | Easy HDR VAE Decode (Koolook) | `Koolook/VAE` | +| `Easy_Pattern` | Easy Pattern (Koolook) | `Koolook/Testing` | The root Koolook nodes and the slim Radiance Koolook VAE wrappers have no extra Python dependencies beyond `torch`, which ComfyUI already requires. diff --git a/__init__.py b/__init__.py index 3417136..dd6c02c 100644 --- a/__init__.py +++ b/__init__.py @@ -13,6 +13,9 @@ from .k_easy_image_batch import NODE_DISPLAY_NAME_MAPPINGS as batch_display # A from .k_easy_track import NODE_CLASS_MAPPINGS as cam_loader_mappings from .k_easy_track import NODE_DISPLAY_NAME_MAPPINGS as cam_loader_display +from .k_easy_pattern import NODE_CLASS_MAPPINGS as pattern_mappings +from .k_easy_pattern import NODE_DISPLAY_NAME_MAPPINGS as pattern_display + from .forks.radiance_koolook import NODE_CLASS_MAPPINGS as radiance_koolook_mappings from .forks.radiance_koolook import NODE_DISPLAY_NAME_MAPPINGS as radiance_koolook_display @@ -40,6 +43,7 @@ NODE_CLASS_MAPPINGS = { **pipeline_mappings, **batch_mappings, **cam_loader_mappings, + **pattern_mappings, **radiance_koolook_mappings, } @@ -49,6 +53,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { **pipeline_display, **batch_display, **cam_loader_display, + **pattern_display, **radiance_koolook_display, } diff --git a/k_easy_pattern.py b/k_easy_pattern.py new file mode 100644 index 0000000..14d7522 --- /dev/null +++ b/k_easy_pattern.py @@ -0,0 +1,192 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# ComfyUI-Koolook — Easy Pattern (Koolook variant) +# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs). +# +# This file is part of ComfyUI-Koolook, licensed under GPL-3.0-or-later. +# See the LICENSE file at the repo root for the full text. +# +# Originally drafted in an external AI-assisted chat session as a small +# standalone MIT-licensed test node, then ingested into ComfyUI-Koolook +# and relicensed under the project's GPL-3.0-or-later. No third-party +# upstream repository — this is in-house code. + +""" +Easy Pattern node for ComfyUI-Koolook. + +Generates a batch of solid-color images, optionally stamped with a per-frame +index number. Useful for testing sequence merging, frame insertion, batch +indexing, video pipeline order — anywhere a more flexible alternative to +KJNodes' `ImageBatchTestPattern` is needed. +""" + +import os +import numpy as np +import torch +from PIL import Image, ImageDraw, ImageFont + + +def _hex_to_rgb(hex_str): + """Accepts '#RRGGBB', 'RRGGBB', '#RGB', or a 3-tuple-like string. Falls back to black.""" + if not isinstance(hex_str, str): + return (0, 0, 0) + s = hex_str.strip().lstrip("#") + try: + if len(s) == 3: + return tuple(int(c * 2, 16) for c in s) + if len(s) == 6: + return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)) + except ValueError: + pass + return (0, 0, 0) + + +# 50% Gray = sRGB (128, 128, 128); matches the `Gray` option in +# `EasyResize_Koolook`'s `pad_color_mode` and `easy_ImageBatch`'s +# `placeholder_color`, so the meaning of "Gray" is consistent across nodes. +PRESET_RGB = { + "White": (255, 255, 255), + "Black": (0, 0, 0), + "Gray": (128, 128, 128), +} + + +def _resolve_color(mode, custom_hex): + """Mode wins; custom hex is only consulted when mode == 'Custom'.""" + if mode in PRESET_RGB: + return PRESET_RGB[mode] + return _hex_to_rgb(custom_hex) + + +def _load_font(size): + """Try a few common font paths; fall back to PIL default if none work.""" + candidates = [ + # Linux + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + # macOS + "/System/Library/Fonts/Helvetica.ttc", + "/Library/Fonts/Arial.ttf", + # Windows + "C:/Windows/Fonts/arialbd.ttf", + "C:/Windows/Fonts/arial.ttf", + ] + for path in candidates: + if os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except OSError: + continue + return ImageFont.load_default() + + +class EasyPattern: + """ + Configurable test-pattern batch. + + Outputs: IMAGE tensor of shape [B, H, W, 3] with values in [0, 1]. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "batch_size": ("INT", {"default": 81, "min": 1, "max": 4096, "step": 1}), + "width": ("INT", {"default": 512, "min": 16, "max": 8192, "step": 8}), + "height": ("INT", {"default": 512, "min": 16, "max": 8192, "step": 8}), + "bg_color_mode": (["White", "Black", "Gray", "Custom"], {"default": "Custom"}), + "show_text": ("BOOLEAN", {"default": True}), + "text_color_mode": (["White", "Black", "Gray", "Custom"], {"default": "White"}), + "start_from": ("INT", {"default": 1, "min": 0, "max": 999999, "step": 1}), + "step": ("INT", {"default": 1, "min": -1000, "max": 1000, "step": 1}), + "font_size": ("INT", {"default": 256, "min": 8, "max": 1024, "step": 1}), + "position": ( + ["center", "top-left", "top-right", "bottom-left", "bottom-right"], + {"default": "center"}, + ), + "zero_pad": ("INT", {"default": 0, "min": 0, "max": 8, "step": 1}), + }, + "optional": { + "bg_color": ("STRING", {"default": "#C71585"}), + "text_color": ("STRING", {"default": "#FFFFFF"}), + "prefix": ("STRING", {"default": "", "multiline": False}), + "suffix": ("STRING", {"default": "", "multiline": False}), + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("images",) + FUNCTION = "generate" + CATEGORY = "Koolook/Testing" + + def generate( + self, + batch_size, + width, + height, + bg_color_mode, + show_text, + text_color_mode, + start_from, + step, + font_size, + position, + zero_pad=0, + bg_color="#C71585", + text_color="#FFFFFF", + prefix="", + suffix="", + ): + bg_rgb = _resolve_color(bg_color_mode, bg_color) + fg_rgb = _resolve_color(text_color_mode, text_color) + font = _load_font(font_size) if show_text else None + + frames = [] + for i in range(batch_size): + img = Image.new("RGB", (width, height), bg_rgb) + + if show_text: + n = start_from + i * step + num_str = f"{n:0{zero_pad}d}" if zero_pad > 0 else str(n) + text = f"{prefix}{num_str}{suffix}" + + draw = ImageDraw.Draw(img) + # Measure with textbbox so it works on modern Pillow; fall back + # to the deprecated textsize on very old installs. + try: + bbox = draw.textbbox((0, 0), text, font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + bx, by = bbox[0], bbox[1] + except AttributeError: + tw, th = draw.textsize(text, font=font) + bx, by = 0, 0 + + pad = max(8, font_size // 8) + if position == "center": + x = (width - tw) // 2 - bx + y = (height - th) // 2 - by + elif position == "top-left": + x, y = pad - bx, pad - by + elif position == "top-right": + x, y = width - tw - pad - bx, pad - by + elif position == "bottom-left": + x, y = pad - bx, height - th - pad - by + else: # bottom-right + x, y = width - tw - pad - bx, height - th - pad - by + + draw.text((x, y), text, font=font, fill=fg_rgb) + + arr = np.array(img, dtype=np.float32) / 255.0 + frames.append(arr) + + batch = np.stack(frames, axis=0) # [B, H, W, 3] + return (torch.from_numpy(batch),) + + +NODE_CLASS_MAPPINGS = { + "Easy_Pattern": EasyPattern, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Easy_Pattern": "Easy Pattern (Koolook)", +} diff --git a/scripts/sync_to_dev.py b/scripts/sync_to_dev.py index b538504..32db531 100644 --- a/scripts/sync_to_dev.py +++ b/scripts/sync_to_dev.py @@ -22,15 +22,26 @@ Example layouts: Windows: C:/ComfyUI_portable/ComfyUI/custom_nodes/ComfyUI-Koolook Usage: - python scripts/sync_to_dev.py # copy all runtime files + python scripts/sync_to_dev.py # copy + auto-restart ComfyUI python scripts/sync_to_dev.py --dry-run # show what would copy + python scripts/sync_to_dev.py --no-restart # copy only; don't restart python scripts/sync_to_dev.py --init # first-run: create the # target folder if missing # (parent custom_nodes/ must # already exist), then sync +After copying, the script POSTs to ComfyUI-Manager's reboot endpoint +(`http://127.0.0.1:8188/manager/reboot` by default) so the live server +re-execs and picks up the new Python files. This is on by default +because the only reason to invoke this script is to see new code — +file-only sync without a restart leaves Python `.py` changes invisible +(custom-node modules load once at server start). Use `--no-restart` to +opt out, e.g. when staging files for a session you don't want to disturb. +Restart failures (Manager not installed, ComfyUI not running, etc.) +print a diagnostic but do NOT fail the sync. + Exit codes: - 0 success + 0 success (sync completed; restart is best-effort and won't change this) 2 KOLOOK_COMFYUI_DEV_PATH unset, parent missing, or target missing (without --init) 3 --init refused: parent is not an existing directory or doesn't @@ -48,8 +59,19 @@ import os import shutil import subprocess import sys +import urllib.error +import urllib.request from datetime import datetime from pathlib import Path +from urllib.parse import urlparse + +# Default ComfyUI-Manager reboot endpoint. ComfyUI-Manager registers this +# as a GET route; hitting it makes the server `os.execv` itself, so the +# same terminal/process picks the new node code up without a manual kill +# + relaunch cycle. Override via `--restart-url` or skip via +# `--no-restart` if your install runs on a different port / has no +# Manager / you want to stage files without disturbing the live session. +DEFAULT_RESTART_URL = "http://127.0.0.1:8188/manager/reboot" REPO_ROOT = Path(__file__).resolve().parent.parent @@ -101,6 +123,63 @@ def _build_line() -> str: return f"{sha} - {_get_worktree_name()}" +def trigger_restart(url: str, timeout: float = 2.0) -> tuple[bool, str]: + """Best-effort POST/GET to ComfyUI-Manager's reboot endpoint. + + Returns ``(success, human_message)``. Never raises — the sync + itself succeeded by the time we call this, and a failed restart + should never mask that with a non-zero exit. The maintainer can + always restart manually if the auto-restart didn't take. + + Failure-mode mapping: + - Connection refused → ComfyUI not running; nothing to do. + - HTTP 404 → Manager not installed at this URL. + - HTTP 2xx → restart triggered cleanly. + - Connection reset / timeout → the server died mid-response (the + restart's `os.execv` raced our read); the request still landed, + so we treat this as success. + """ + # Restrict to http/https. `urlopen` would otherwise accept `file://`, + # `ftp://`, etc. — a typo in `--restart-url` could surprise the + # maintainer by opening a local file via the same code path that + # normally talks to ComfyUI-Manager. Static analyzers (Bandit B310) + # also flag the broader urlopen surface; this guard makes the + # `# nosec` annotation below truthful rather than dismissive. + scheme = urlparse(url).scheme.lower() + if scheme not in ("http", "https"): + return False, ( + f"restart skipped: --restart-url must be http(s); got " + f"scheme '{scheme}' in {url}" + ) + + try: + # ComfyUI-Manager registers /manager/reboot as GET, but POST + # also lands harmlessly — using GET keeps it simple. + urllib.request.urlopen(url, timeout=timeout) # nosec B310 - scheme validated above + return True, "restart triggered" + except urllib.error.HTTPError as e: + if e.code == 404: + return False, ( + f"restart skipped: ComfyUI-Manager not detected at {url} " + f"(404). Install Manager or pass --no-restart." + ) + return False, f"restart skipped: endpoint returned HTTP {e.code}" + except urllib.error.URLError as e: + reason = e.reason + if isinstance(reason, ConnectionRefusedError): + return False, f"restart skipped: ComfyUI not running at {url}" + # Read timeout / connection reset usually means the server + # received the request and rebooted before completing the + # response — desired outcome. + if isinstance(reason, (TimeoutError, ConnectionResetError)) or "timed out" in str(reason).lower(): + return True, "restart triggered (server stopped responding during reboot — normal)" + return False, f"restart skipped: unreachable ({reason})" + except (ConnectionResetError, OSError): + # Same race as above: server began rebooting before we finished + # reading. The request landed; that's what matters. + return True, "restart triggered (connection reset during reboot)" + + def write_build_info(target: Path, scope: str | None) -> None: """Drop a tiny JSON next to the sidebar JS so the in-browser footer can render `dev ·