feat(easy_pattern): add test-pattern node + auto-restart dev-sync (#135)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
bf02db8ad8
commit
9dab85c695
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
+109
-2
@@ -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 <sha> · <time>` (and an italic <scope> on a second
|
||||
@@ -133,6 +212,7 @@ RUNTIME_PATHS: tuple[str, ...] = (
|
||||
"config.json",
|
||||
"k_ai_pipeline.py",
|
||||
"k_easy_image_batch.py",
|
||||
"k_easy_pattern.py",
|
||||
"k_easy_resize.py",
|
||||
"k_easy_track.py",
|
||||
"k_easy_wan22_prompt.py",
|
||||
@@ -286,6 +366,30 @@ def main() -> int:
|
||||
"the footer renders just identifier + timestamp."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-restart",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Skip the auto-restart of the live ComfyUI server. By default "
|
||||
"the script POSTs to ComfyUI-Manager's reboot endpoint after a "
|
||||
"successful sync so the new Python files are actually loaded "
|
||||
"(custom-node `.py` files load once at server start; without a "
|
||||
"restart the maintainer's changes won't be visible). Use this "
|
||||
"flag when you want to stage files without disturbing a live "
|
||||
"session (rare — `dev-sync` is normally invoked precisely "
|
||||
"because you want to see new code)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restart-url",
|
||||
type=str,
|
||||
default=DEFAULT_RESTART_URL,
|
||||
help=(
|
||||
f"Override the restart endpoint. Default: {DEFAULT_RESTART_URL}. "
|
||||
f"Adjust if your ComfyUI listens on a non-standard host/port. "
|
||||
f"Ignored when --no-restart is set or --dry-run is used."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
load_dotenv(REPO_ROOT / ".env")
|
||||
@@ -314,6 +418,9 @@ def main() -> int:
|
||||
print(f"{verb} {n} entries -> {target}")
|
||||
if not args.dry_run:
|
||||
write_build_info(target, args.scope)
|
||||
if not args.no_restart:
|
||||
ok, msg = trigger_restart(args.restart_url)
|
||||
print(msg)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user