Initial release

This commit is contained in:
chookg
2026-10-04 11:00:22 +08:00
commit aa9f7e583d
65 changed files with 5504 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
tests/
tools/
docs/
+35
View File
@@ -0,0 +1,35 @@
name: Callback domain request / 回调域名申请
description: Request review of one additional callback hostname.
title: "[Domain Request] "
body:
- type: input
id: hostname
attributes:
label: Exact hostname / 完整域名
description: One hostname, without a wildcard, path or credentials.
placeholder: notify.example.com
validations:
required: true
- type: textarea
id: ownership
attributes:
label: Domain control / 域名控制权
description: Explain your relationship to the domain and how control can be verified. Do not post secrets.
validations:
required: true
- type: textarea
id: purpose
attributes:
label: Use and data handling / 用途与数据处理
description: Describe the receiver, intended notifications, data retention and any onward sharing. Use a token-free example endpoint if helpful.
validations:
required: true
- type: checkboxes
id: review
attributes:
label: Review process / 审核流程
options:
- label: I control this receiver or have permission to operate it.
required: true
- label: I understand repository approval and platform review are separate; cloud availability is not guaranteed.
required: true
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
*.egg-info/
.venv/
build/
dist/
.local/
+9
View File
@@ -0,0 +1,9 @@
# Repository scope
- Keep this repository focused on the standalone ComfyUI-ChickTools package.
- Describe its node interfaces, supported behavior, installation and reproducible tests.
- Use synthetic examples and generic installation paths in all tracked files.
- Keep private integrations, unrelated projects, machine-specific troubleshooting and operational records outside this repository.
- Keep ChickHideNode and NotifyMeNode independent; compose them only through standard ComfyUI connections.
- Run the existing unit tests for code changes and use only small, model-free workflows for local validation.
- Commit and push only files that belong to this package.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ChickTools contributors
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.
+57
View File
@@ -0,0 +1,57 @@
# ComfyUI-ChickTools
[中文说明](README_CN.md)
**NotifyMe** (`NotifyMeNode`) sends a task ID and upstream text to an allowlisted HTTPS callback, with optional passthrough of any connected data type. Node names, descriptions and tooltips default to English.
## Installation
Run from ComfyUI's `custom_nodes` directory:
```sh
git clone https://github.com/chookg/ComfyUI-ChickTools.git
cd ComfyUI-ChickTools
python -m pip install -r requirements.txt
```
Use the Python interpreter that runs ComfyUI, then restart ComfyUI and search for `Notify Me` in the node menu.
## Inputs
| Input | Description |
| --- | --- |
| `callback_url` | Required HTTPS URL on `notify.bro3.net`; fill in your actual receiving endpoint |
| `id` | Editable task identifier, sent unchanged as text; defaults to an empty string |
| `msg` | Optional STRING connection; sends an empty string when unconnected |
| `passthrough` | Optional input of any type; returned unchanged through the matching output after a successful callback |
## Usage
```text
Text node → NotifyMe.msg
Upstream data → NotifyMe.passthrough → Downstream node
```
Set `callback_url` and your task `id`, then run. The node sends an HTTP POST with a JSON body containing only:
```json
{"id": "task-001", "msg": "Upstream text"}
```
The `msg` text is preserved exactly. Passthrough data stays inside the workflow and is not sent as a callback attachment. Only **HTTP 200** is accepted.
To notify after a particular operation, connect its output to `msg` or `passthrough`; notification runs after the connected upstream nodes finish.
Every queued run sends another notification. Connect and read timeouts are 15 seconds each, with no automatic retries or redirects.
## Allowed domains
The only allowed host is **`notify.bro3.net`**. Matching is exact after hostname normalization; subdomains, other domains, IP literals and private DNS results are rejected. The allowlist is maintained in [callback_policy.py](utils/callback_policy.py) and cannot be changed by workflow inputs. Callback URLs default to blank; use a receiver you control or have permission to use.
To request another domain, [open a domain request](https://github.com/chookg/ComfyUI-ChickTools/issues/new?template=domain-request.yml) or submit a PR with ownership evidence and the intended use. The maintainer reviews the request, then submits accepted changes for upstream/platform review. Repository acceptance does not mean ComfyUI-Manager or RunningHub approval; cloud availability depends on their review and installed version. See [domain requests](docs/DOMAIN_REQUESTS.md).
When upgrading from 0.5.x, restart ComfyUI and recreate NotifyMe or import an updated example. The old `allow_localhost` option has been removed. Local automated tests use isolated fixtures; deployed nodes always enforce the allowlist.
[Text notification example](examples/notify_me_example.json) · [Empty message example](examples/notify_me_empty_example.json) · [License](LICENSE)
[Reviewer test receiver](https://notify.bro3.net/) · [Test instructions](docs/REVIEW_SERVICE.md) — create a temporary session to verify synthetic id/msg notifications.
+57
View File
@@ -0,0 +1,57 @@
# ComfyUI-ChickTools
[English](README.md)
**通知我 / NotifyMe**(`NotifyMeNode`)将任务 ID 和上游文本发送到白名单内的 HTTPS 回调地址,并支持任意类型数据透传。节点名称、说明及提示默认使用英文。
## 安装
在 ComfyUI 的 `custom_nodes` 目录执行:
```sh
git clone https://github.com/chookg/ComfyUI-ChickTools.git
cd ComfyUI-ChickTools
python -m pip install -r requirements.txt
```
请使用 ComfyUI 实际运行的 Python,安装后重启 ComfyUI,搜索 `Notify Me`。
## 参数
| 输入 | 说明 |
| --- | --- |
| `callback_url` | 必填,`notify.bro3.net` 域名下的实际 HTTPS 接收地址 |
| `id` | 可手动填写的任务编号,按文本原样发送;留空时发送空字符串 |
| `msg` | 可选 STRING 连线输入;未连接时发送空字符串 |
| `passthrough` | 可选任意类型输入;回调成功后从同名输出原样传递 |
## 用法
```text
文本节点 → NotifyMe.msg
上游数据 → NotifyMe.passthrough → 下游节点
```
填写 `callback_url` 和任务 `id` 后运行,请求使用 HTTP POST,JSON 正文仅包含:
```json
{"id": "task-001", "msg": "上游文本"}
```
`msg` 中的文本原样发送。`passthrough` 数据仅在工作流内部传递,不作为回调附件发送。只有 **HTTP 200** 被视为成功。
需要在目标处理完成后通知时,将其输出接到 `msg` 或 `passthrough`;通知会在相连的上游执行完成后发送。
每次排队运行都会再次发送通知;连接与读取超时各为 15 秒,无自动重试或重定向。
## 域名白名单
当前仅允许 **`notify.bro3.net`**。主机名规范化后精确匹配,其他域名、子域名、IP 地址及解析到内网的地址均会被拒绝。白名单由仓库的 [callback_policy.py](utils/callback_policy.py) 维护,工作流参数不支持扩展白名单。回调地址默认留空,请填写你有权使用的实际接口。
需要新增域名,可向本仓库[提交域名申请](https://github.com/chookg/ComfyUI-ChickTools/issues/new?template=domain-request.yml)或 PR,说明域名控制权及用途。仓库维护者审核后,再将接受的变更提交上游及平台复审。仓库合并与 ComfyUI-Manager、RunningHub 审核相互独立,云端可用性以平台审核及部署版本为准。
从 0.5.x 升级后,请重启 ComfyUI,重新创建 NotifyMe 或导入新版示例。旧 `allow_localhost` 开关已移除;本地自动测试使用隔离测试环境,正式节点始终执行白名单限制。
[文本通知示例](examples/notify_me_example.json) · [空消息示例](examples/notify_me_empty_example.json) · [许可证](LICENSE)
[审核测试页面](https://notify.bro3.net/) · [测试说明](docs/REVIEW_SERVICE.md):创建临时会话,复制 HTTPS 回调地址,即可验证测试 id/msg。
+5
View File
@@ -0,0 +1,5 @@
"""ComfyUI-ChickTools custom nodes."""
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640" role="img" aria-labelledby="title description">
<title id="title">ChickTools little chick</title>
<desc id="description">A round yellow baby chicken with a feather tuft, orange beak, rosy cheeks and tiny feet on an ivory background.</desc>
<rect width="640" height="640" rx="56" fill="#fff8e9"/>
<ellipse cx="320" cy="538" rx="150" ry="19" fill="#eddfc3"/>
<g stroke="#714629" stroke-width="10" stroke-linecap="round" stroke-linejoin="round">
<g fill="none">
<path d="M274 486v38m0-1-25 14m25-14 21 14" stroke="#d88636"/>
<path d="M366 486v38m0-1-21 14m21-14 25 14" stroke="#d88636"/>
</g>
<path d="M192 324c-27-21-68-7-70 25-3 32 23 65 57 66l32-48z" fill="#efb935"/>
<path d="M452 323c30-23 62-66 83-52 19 14 3 52-15 77-19 26-44 41-68 39l-18-34z" fill="#efb935"/>
<path d="M294 178c-17-23-29-42-19-53 14-14 35 11 43 27-3-38 7-58 23-53 17 5 10 40 6 56 15-20 35-30 45-18 12 16-17 39-30 48z" fill="#ffd35c"/>
<path d="M320 174c99 0 168 88 168 193 0 99-69 139-168 139s-168-40-168-139c0-105 69-193 168-193z" fill="#ffda62"/>
</g>
<path d="M194 332c4-65 45-115 94-127" fill="none" stroke="#ffedaa" stroke-width="17" stroke-linecap="round"/>
<ellipse cx="320" cy="425" rx="85" ry="50" fill="#ffe798"/>
<ellipse cx="220" cy="355" rx="25" ry="16" fill="#f2a078"/>
<ellipse cx="420" cy="355" rx="25" ry="16" fill="#f2a078"/>
<g fill="#4d3427">
<ellipse cx="260" cy="308" rx="16" ry="21"/>
<ellipse cx="380" cy="308" rx="16" ry="21"/>
</g>
<g fill="#fffdf5">
<circle cx="255" cy="300" r="5"/>
<circle cx="375" cy="300" r="5"/>
</g>
<path d="M300 345q20-12 40 0l-20 25z" fill="#ed923d" stroke="#714629" stroke-width="7" stroke-linejoin="round"/>
<path d="M184 359q14 28 39 35" fill="none" stroke="#d5a237" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="192" viewBox="0 0 256 192">
<title>ChickTools image round-trip sample</title>
<rect width="256" height="192" fill="#b8e2ea"/>
<circle cx="199" cy="47" r="24" fill="#ffcb60"/>
<path d="M0 135 70 58 145 151 196 93 256 145V192H0Z" fill="#417c79"/>
<path d="m48 82 22-24 24 30-17-5-9 6-8-12Z" fill="#fff8e9"/>
<path d="M0 149q65-36 134 3t122-5v45H0Z" fill="#81ac76"/>
<path d="M0 178q77-37 160-2t96 0v16H0Z" fill="#346760"/>
<path d="M30 46q8-9 16 0m-1 0q8-9 16 0M96 34q6-7 12 0m0 0q6-7 12 0" fill="none" stroke="#3d6671" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 643 B

+1
View File
@@ -0,0 +1 @@
"""Reserved for the independently implemented media codec."""
+239
View File
@@ -0,0 +1,239 @@
"""Lossless carrier codec used by ChickHideNode.
The carrier is a lossless RGB PNG. A four-byte big-endian header length is
stored first, followed by a binary file header and its payload. The header
contains an optional password marker, extension and payload length.
"""
from __future__ import annotations
from hashlib import sha256
from hmac import compare_digest
from io import BytesIO
from numbers import Integral
from pathlib import Path
import os
import struct
from typing import Optional
import numpy as np
from PIL import Image, ImageDraw, ImageFont
SKIP_W_RATIO = 0.40
SKIP_H_RATIO = 0.08
CHANNELS = 3
MINIMUM_SIDE = 640
SUPPORTED_LSB_BITS = (2, 6, 8)
class CarrierCodecError(ValueError):
"""Raised when a carrier cannot be encoded or decoded."""
def _key_stream(password: str, salt: bytes, length: int) -> bytes:
material = (password + salt.hex()).encode("utf-8")
output = bytearray()
counter = 0
while len(output) < length:
output.extend(sha256(material + str(counter).encode("ascii")).digest())
counter += 1
return bytes(output[:length])
def _protect(raw: bytes, password: str) -> tuple[bytes, bytes, bytes, bool]:
if not password:
return raw, b"", b"", False
salt = os.urandom(16)
check = sha256((password + salt.hex()).encode("utf-8")).digest()
stream = _key_stream(password, salt, len(raw))
return bytes(left ^ right for left, right in zip(raw, stream)), salt, check, True
def build_file_header(raw: bytes, password: str, extension: str) -> bytes:
extension = extension.lstrip(".")
extension_bytes = extension.encode("utf-8")
if not extension_bytes or len(extension_bytes) > 255:
raise CarrierCodecError("extension must contain 1-255 UTF-8 bytes")
protected, salt, check, has_password = _protect(raw, password)
header = bytearray((1 if has_password else 0,))
if has_password:
header.extend(check)
header.extend(salt)
header.append(len(extension_bytes))
header.extend(extension_bytes)
header.extend(struct.pack(">I", len(protected)))
header.extend(protected)
return bytes(header)
def parse_file_header(header: bytes, password: str = "") -> tuple[bytes, str]:
if len(header) < 1:
raise CarrierCodecError("carrier header is empty")
cursor = 1
has_password = header[0] == 1
if header[0] not in (0, 1):
raise CarrierCodecError("carrier password marker is invalid")
check = salt = b""
if has_password:
if len(header) < cursor + 48:
raise CarrierCodecError("carrier password header is truncated")
check = header[cursor:cursor + 32]
salt = header[cursor + 32:cursor + 48]
cursor += 48
if len(header) < cursor + 1:
raise CarrierCodecError("carrier extension is missing")
extension_length = header[cursor]
cursor += 1
if len(header) < cursor + extension_length + 4:
raise CarrierCodecError("carrier metadata is truncated")
try:
extension = header[cursor:cursor + extension_length].decode("utf-8")
except UnicodeDecodeError as exc:
raise CarrierCodecError("carrier extension is invalid") from exc
cursor += extension_length
payload_length = struct.unpack(">I", header[cursor:cursor + 4])[0]
cursor += 4
payload = header[cursor:]
if len(payload) != payload_length:
raise CarrierCodecError("carrier payload length does not match")
if not has_password:
return payload, extension
if not password:
raise CarrierCodecError("password is required")
expected = sha256((password + salt.hex()).encode("utf-8")).digest()
if not compare_digest(expected, check):
raise CarrierCodecError("password is incorrect")
stream = _key_stream(password, salt, len(payload))
return bytes(left ^ right for left, right in zip(payload, stream)), extension
def _lsb_bits(compress: int) -> int:
if isinstance(compress, bool) or not isinstance(compress, Integral) or compress not in SUPPORTED_LSB_BITS:
raise CarrierCodecError("compress must be 2, 6 or 8")
return int(compress)
def _channel_count(height: int, width: int) -> int:
return (height * width - int(height * SKIP_H_RATIO) * int(width * SKIP_W_RATIO)) * CHANNELS
def _channel_chunks(array):
"""Yield writable row-major views, omitting the reserved rectangle."""
height, width, _ = array.shape
skip_h, skip_w = int(height * SKIP_H_RATIO), int(width * SKIP_W_RATIO)
flat = array.reshape(-1)
spans = ((row * width * 3 + skip_w * 3, (row + 1) * width * 3) for row in range(skip_h))
for start, stop in spans:
yield flat[start:stop]
for start in range(skip_h * width * 3, len(flat), 65536):
yield flat[start:start + 65536]
def required_side(header_length: int, compress: int, minimum: int = MINIMUM_SIDE) -> int:
bits = _lsb_bits(compress)
side = max(MINIMUM_SIDE, int(minimum))
needed = (header_length + 4) * 8
while _channel_count(side, side) * bits < needed:
side += 64
return side
def _embed(canvas: Image.Image, header: bytes, compress: int) -> Image.Image:
bits_per_channel = _lsb_bits(compress)
payload = struct.pack(">I", len(header)) + header
array = np.array(canvas.convert("RGB"), dtype=np.uint8)
if len(payload) * 8 > _channel_count(*array.shape[:2]) * bits_per_channel:
raise CarrierCodecError("carrier capacity is too small")
keep_mask = np.uint8(255 ^ ((1 << bits_per_channel) - 1))
weights = 1 << np.arange(bits_per_channel - 1, -1, -1, dtype=np.uint8)
position = 0
for channels in _channel_chunks(array):
count = min(len(channels), (len(payload) * 8 - position + bits_per_channel - 1) // bits_per_channel)
if count <= 0:
break
end = min(position + count * bits_per_channel, len(payload) * 8)
bits = np.unpackbits(np.frombuffer(payload[position // 8:(end + 7) // 8], dtype=np.uint8))
bits = bits[position % 8:position % 8 + end - position]
bits = np.pad(bits, (0, count * bits_per_channel - len(bits)))
values = (bits.reshape(count, bits_per_channel) * weights).sum(axis=1).astype(np.uint8)
channels[:count] = (channels[:count] & keep_mask) | values
position += count * bits_per_channel
return Image.fromarray(array)
def extract_file_header(image: Image.Image, compress: int) -> bytes:
bits_per_channel = _lsb_bits(compress)
array = np.array(image.convert("RGB"), dtype=np.uint8)
output, pending = bytearray(), np.empty(0, dtype=np.uint8)
total_bytes = None
for channels in _channel_chunks(array):
values = channels & ((1 << bits_per_channel) - 1)
bits = np.unpackbits(values).reshape(-1, 8)[:, -bits_per_channel:].reshape(-1)
bits = np.concatenate((pending, bits))
end = len(bits) // 8 * 8
output.extend(np.packbits(bits[:end]).tobytes())
pending = bits[end:]
if total_bytes is None and len(output) >= 4:
total_bytes = 4 + struct.unpack(">I", output[:4])[0]
if total_bytes <= 4 or total_bytes * 8 > _channel_count(*array.shape[:2]) * bits_per_channel:
raise CarrierCodecError("carrier header length is invalid")
if total_bytes is not None and len(output) >= total_bytes:
return bytes(output[4:total_bytes])
raise CarrierCodecError("carrier is too small")
def decode_bytes(image: Image.Image, password: str = "") -> tuple[bytes, str]:
errors = []
for compress in SUPPORTED_LSB_BITS:
try:
return parse_file_header(extract_file_header(image, compress), password)
except CarrierCodecError as exc:
errors.append(str(exc))
raise CarrierCodecError("carrier decode failed: " + "; ".join(errors))
def _make_canvas(side: int, title: str) -> Image.Image:
image = Image.new("RGB", (side, side), (255, 248, 233))
with Image.open(Path(__file__).resolve().parents[1] / "assets" / "chick.png") as source:
cover = source.convert("RGBA").resize((side, side), Image.Resampling.LANCZOS)
image.paste(cover, (0, 0), cover.getchannel("A"))
draw = ImageDraw.Draw(image)
if title:
font = ImageFont.load_default()
draw.text((int(side * .05), int(side * .04)), title[:40], fill=(20, 40, 50), font=font)
return image
def encode_bytes(raw: bytes, extension: str, password: str = "", compress: int = 2,
title: str = "", fixed_side: Optional[int] = None) -> Image.Image:
header = build_file_header(raw, password, extension)
side = required_side(len(header), compress, fixed_side or MINIMUM_SIDE)
return _embed(_make_canvas(side, title), header, compress)
def image_pixels(image: object) -> np.ndarray:
if hasattr(image, "detach"):
image = image.detach().cpu()
image = (image.float() if image.is_floating_point() else image).numpy()
array = np.asarray(image)
if array.ndim == 4:
if array.shape[0] != 1:
raise CarrierCodecError("image_to_png_bytes accepts one frame")
array = array[0]
if array.ndim != 3 or array.shape[-1] not in (3, 4) or min(array.shape) < 1:
raise CarrierCodecError("images must have shape HxWx3 or HxWx4")
if array.dtype.kind not in "fiu" or not np.isfinite(array).all():
raise CarrierCodecError("image pixels must be finite numbers")
if np.issubdtype(array.dtype, np.floating):
array = np.rint(np.clip(array, 0.0, 1.0) * 255.0).astype(np.uint8)
else:
array = np.clip(array, 0, 255).astype(np.uint8)
return array
def image_to_png_bytes(image: object) -> bytes:
image = Image.fromarray(image_pixels(image))
output = BytesIO()
image.save(output, format="PNG", optimize=True, compress_level=9)
return output.getvalue()
+104
View File
@@ -0,0 +1,104 @@
"""CPU media serialization. All temporary files belong to one invocation."""
from io import BytesIO
from numbers import Integral
from pathlib import Path
import subprocess
import tempfile
import wave
import numpy as np
from .codec import CarrierCodecError, image_pixels
def image_batch(images):
"""Validate batch geometry without copying all frames to the CPU at once."""
if not hasattr(images, "shape"):
images = np.asarray(images)
if len(images.shape) == 3:
images = images[None, ...]
if (len(images.shape) != 4 or any(size < 1 for size in images.shape)
or images.shape[-1] not in (3, 4)):
raise CarrierCodecError("images must have shape NxHxWx3 or NxHxWx4 with non-empty dimensions")
return images
def audio_to_wav(audio) -> bytes:
"""Serialize standard ComfyUI AUDIO (1, channels, samples) as PCM16 WAV."""
if not isinstance(audio, dict) or "waveform" not in audio or "sample_rate" not in audio:
raise CarrierCodecError("audio requires waveform and sample_rate")
rate = audio["sample_rate"]
if isinstance(rate, bool) or not isinstance(rate, Integral) or not 8000 <= rate <= 192000:
raise CarrierCodecError("sample_rate must be an integer between 8000 and 192000")
waveform = audio["waveform"]
if hasattr(waveform, "detach"):
waveform = waveform.detach().cpu().float().numpy()
samples = np.asarray(waveform)
if samples.ndim != 3 or samples.shape[0] != 1 or samples.shape[1] not in (1, 2) or samples.shape[2] < 1:
raise CarrierCodecError("audio waveform must have shape 1x1xN or 1x2xN")
if samples.dtype.kind not in "fiu" or not np.isfinite(samples).all():
raise CarrierCodecError("audio samples must be finite numbers")
pcm = np.rint(np.clip(samples[0].T, -1.0, 1.0) * 32768.0)
pcm = np.clip(pcm, -32768, 32767).astype("<i2")
output = BytesIO()
with wave.open(output, "wb") as wav:
wav.setnchannels(samples.shape[1])
wav.setsampwidth(2)
wav.setframerate(int(rate))
wav.writeframes(pcm.tobytes())
return output.getvalue()
def ffmpeg_executable() -> str:
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except (ImportError, RuntimeError) as exc:
raise CarrierCodecError("install imageio-ffmpeg or set IMAGEIO_FFMPEG_EXE to an FFmpeg executable") from exc
def images_to_mp4(images, fps: int, audio=None) -> bytes:
"""Encode H.264/AAC MP4; loop or trim audio to the exact frame duration."""
frames = image_batch(images)
if isinstance(fps, bool) or not isinstance(fps, Integral) or not 1 <= fps <= 60:
raise CarrierCodecError("fps must be an integer between 1 and 60")
audio_bytes = audio_to_wav(audio) if audio is not None else None
executable = ffmpeg_executable()
count, height, width, _ = frames.shape
with tempfile.TemporaryDirectory(prefix="chicktools-media-") as directory:
root = Path(directory)
raw_path, output_path = root / "frames.rgb", root / "media.mp4"
# Only one converted frame is resident in addition to the caller's IMAGE batch.
with raw_path.open("wb") as raw:
for frame in frames:
raw.write(np.ascontiguousarray(image_pixels(frame)[..., :3]).tobytes())
command = [executable, "-hide_banner", "-loglevel", "error", "-nostdin", "-y",
"-filter_threads", "1", "-f", "rawvideo", "-pixel_format", "rgb24",
"-video_size", f"{width}x{height}", "-framerate", str(fps), "-i", str(raw_path)]
if audio_bytes is not None:
audio_path = root / "audio.wav"
audio_path.write_bytes(audio_bytes)
command += ["-stream_loop", "-1", "-i", str(audio_path)]
command += ["-map", "0:v:0", "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2",
"-c:v", "libx264", "-threads", "2", "-preset", "medium", "-crf", "16",
"-pix_fmt", "yuv420p"]
if audio_bytes is not None:
command += ["-map", "1:a:0", "-c:a", "aac", "-b:a", "192k", "-threads:a", "1"]
else:
command += ["-an"]
command += ["-t", f"{count / fps:.9f}", "-movflags", "+faststart", str(output_path)]
try:
result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, timeout=300, check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except subprocess.TimeoutExpired as exc:
raise CarrierCodecError("video encoding exceeded 300 seconds") from exc
except OSError as exc:
raise CarrierCodecError("FFmpeg could not be started") from exc
if result.returncode != 0:
detail = result.stderr.decode("utf-8", errors="replace")[-1500:].strip()
raise CarrierCodecError(f"FFmpeg encoding failed ({result.returncode}): {detail}")
if not output_path.exists() or output_path.stat().st_size == 0:
raise CarrierCodecError("FFmpeg produced no video")
return output_path.read_bytes()
+47
View File
@@ -0,0 +1,47 @@
# Carrier Format
ChickHideNode writes a lossless RGB PNG. The image carries one binary payload and
can be decoded without the ComfyUI runtime.
The payload starts with a four-byte big-endian header length. The header contains:
1. One byte: `0` for an unprotected payload or `1` for a password-protected payload.
2. For protected payloads, a 32-byte password check and a 16-byte random salt.
3. One byte containing the UTF-8 extension length, followed by the extension.
4. A four-byte big-endian payload length, followed by the payload bytes.
When a password is present, payload bytes are XORed with a SHA-256 counter stream
derived from the password and salt. The password check is verified before decoding.
Payload bits are written to the least significant bits of RGB channels. The supported
channel widths are 2, 6 and 8 bits. The top-left decoration area is excluded from the
payload so the carrier can display a title without changing its data.
The node uses extensions `png`, `wav` and `mp4`. WAV is PCM16. Video is H.264
with optional AAC audio. MP4 bytes are stored directly with their exact length; they are
not converted to an intermediate pixel image or stripped of trailing zero bytes.
Each carrier contains one file, with no embedded file name or multi-file archive.
An independent image batch yields multiple same-sized carriers.
Bit order is most-significant bit first, across row-major RGB channels. The excluded
rectangle is `floor(width * 0.40)` by `floor(height * 0.08)` at the top left.
The canvas starts at 640x640 and grows in 64-pixel steps until the packet fits.
The final channel group is zero-padded. `compress` selects LSB width, not a media codec
or a general-purpose compression level. The password stream concatenates SHA-256 hashes
of `UTF8(password + salt.hex() + decimal_counter)`, starting at counter zero; the stored
check hashes `UTF8(password + salt.hex())`. This format has no authenticated integrity tag.
The carrier preserves packaged bytes exactly. Media conversion has its own quality limits:
PNG quantizes IMAGE floats to 8 bits, WAV quantizes to signed PCM16, and H.264/AAC is lossy.
The node does not fetch or upload source media. FFmpeg receives only locally generated
temporary files, uses CPU encoding, and leaves no media files after the invocation finishes.
## Saved PNG metadata
The binary header stores the password check and random salt, never the plaintext password.
This is separate from ComfyUI PNG metadata: generic SaveImage and PreviewImage can embed the
entire execution prompt and editor workflow, including ChickHideNode's password input.
Use ChickSaveImage for carrier outputs. It omits the prompt and passes only AIGC provenance
metadata to the host saver without altering pixels, encrypted bytes or shared execution data.
The new PNG does not embed a recoverable editor workflow. Manually exported workflow JSON,
execution requests and old PNG files are outside this saving change.
+26
View File
@@ -0,0 +1,26 @@
# Requesting a callback domain
The current host allowlist contains only `notify.bro3.net`. This is a repository
policy, not a claim of platform approval. URLs remain blank by default; supplying
a host does not provision a receiver or grant access to that service.
1. Open a [domain request](https://github.com/chookg/ComfyUI-ChickTools/issues/new?template=domain-request.yml)
or a PR. Provide the exact hostname, the intended notification use, how you can
demonstrate control of the domain, and how received data is handled. Use example
paths without credentials; keep private tokens out of issues and workflows.
2. The repository maintainer reviews the domain and endpoint behavior. A domain
should resolve to public addresses and receive callbacks without redirects.
Wildcards, broad parent domains and uncontrolled forwarding destinations are
outside this policy.
3. Accepted additions update `ALLOWED_CALLBACK_HOSTS` in
`utils/callback_policy.py`, the node help, documentation and security tests.
There is no automatic discovery, remote list download or workflow override.
4. The maintainer submits the changed policy and source revision to the relevant
upstream/platform reviewers. ComfyUI-Manager catalog review and RunningHub
installation review are separate processes. Each platform decides whether and
when to accept and install the updated version; a merged repository PR alone
does not authorize additional cloud callback destinations.
Existing installations keep the policy bundled with their installed revision.
Normal users can edit their endpoint path, task `id` and `msg`, but cannot expand
the accepted host list through a workflow.
+86
View File
@@ -0,0 +1,86 @@
# Preparing an installation test
The four nodes remain independent. Use the Python interpreter from the target ComfyUI.
## Read the installed node interfaces
```sh
python tools/prepare_workflow.py --base-url http://127.0.0.1:8188 --out-dir .local/preparation
```
Alternatively, export the target instance's `/object_info` response through its authenticated
session and inspect the saved JSON offline:
```sh
python tools/prepare_workflow.py --object-info /path/to/object_info.json --out-dir .local/preparation
```
Choose a new output directory on each run. Reports include node presence and output slots,
but omit schema defaults, endpoints and credentials. The tool only reads schema data;
it does not queue workflows, install nodes or send notifications. Exit code 0 means the
carrier and standalone notification templates are ready; 2 means at least one has missing
or incompatible prerequisites. The text example has its own readiness flag because its
sample source, PrimitiveStringMultiline, is not required for the notification node itself.
None of these flags establishes that the instance executed a workflow successfully.
## Generated workflows
Each available workflow is written in both API and canvas JSON formats:
- `carrier-smoke`: `EmptyImage(16x16) -> ChickHideNode -> ChickSaveImage`.
- `notify-text-smoke`: `PrimitiveStringMultiline -> NotifyMeNode.msg` and the same STRING value
through `NotifyMeNode.passthrough`.
- `notify-empty-smoke`: NotifyMeNode alone, with its optional msg socket unconnected.
The carrier template follows the Duck media wiring: IMAGE frames enter `images`, optional
AUDIO enters `audio`, and `chick_image` connects to ChickSaveImage. Text payload encoding
is omitted. Download the saved PNG and decode it with `chick.codec.decode_bytes`; it should
contain a 16x16 blue image. When a password is set, check that the payload requires that
password and that PNG metadata contains no execution prompt, workflow or password.
ChickSaveImage inherits the target installation's SaveImage implementation, omits its
hidden `prompt` input and filters `extra_pnginfo` to preserve only `AIGC` provenance.
Prompt and workflow snapshots are never forwarded to the PNG writer.
The notification templates are independent of the carrier template and SaveImage outputs.
Fill `callback_url` with an actual receiver on the allowlisted host `notify.bro3.net`,
and `id` with your task identifier before executing. The plugin does not provision a receiver.
The text example sends exactly `{"id":"demo-task-001","msg":"Sample message"}`;
the unconnected example sends `{"id":"demo-task-001","msg":""}`. Older workflows that omit
`id` send an empty string for that field. The receiver must
return HTTP 200. Queue the same notification again to verify another POST is sent even
when the text source is cached. Unicode, newlines and whitespace must be preserved.
Local automated tests use an isolated DNS/socket fixture and the [bundled receiver](LOCAL_CALLBACK.md).
The installed node has no loopback option; workflow inputs cannot extend the host allowlist.
For real workflows, connect the STRING output whose completion should precede the
notification. The node sends that string as msg without interpreting URLs or JSON inside
it. The separate optional wildcard `passthrough` socket accepts any ComfyUI value; after the
callback succeeds, that exact object is returned through the wildcard output. An unconnected
passthrough output is `None`, and an unconnected msg socket does not establish ordering with
other workflow branches.
## Upgrade from 0.5.x
Restart ComfyUI and recreate NotifyMe or import updated examples. The removed
`allow_localhost` widget changes saved widget ordering; re-enter the task ID and callback URL.
All callbacks must use the exact allowlisted host, including workflows created before this update.
## Upgrade from 0.3.x
Restart ComfyUI, recreate old NotifyMe nodes and reconnect upstream text to msg. The old
image and preview_url inputs and typed IMAGE output have been removed; the current node has
a wildcard passthrough input/output. Connect carrier images directly to ChickSaveImage, and
remove any old ChickHide text_input connections.
## Installation handoff
Provide the repository or a source ZIP with its exact commit and SHA-256, the dependency
list, the four registered node names, and the minimal workflows. A private repository
requires access to be arranged before installation. Registry publication additionally
requires a real PublisherId; an empty placeholder is not a completed registration.
Keep credentials, private endpoint values and environment-specific reports outside tracked
files. Examples contain synthetic data; generated templates leave callback_url empty.
All notification examples leave callback_url empty, including examples with legacy local filenames.
+57
View File
@@ -0,0 +1,57 @@
# 本地回调测试
正式 NotifyMe 节点仅允许白名单域名 `notify.bro3.net` 的 HTTPS 地址,旧 `allow_localhost` 开关已移除。
手动验证可使用[审核测试页面](https://notify.bro3.net/)创建临时会话,详见[说明](REVIEW_SERVICE.md)。
本服务用于本地测试及载体预览;回环地址不会因此加入正式节点白名单。
在仓库目录使用 ComfyUI 的 Python 启动接收服务:
```sh
python tools/local_callback_server.py --port 8765 \
--carrier-dir /path/to/ComfyUI/output/ChickTools \
--password demo-password
```
浏览器打开 `http://127.0.0.1:8765/`,点击“开启监听”,页面通过 WebSocket 显示最近 100 条消息,以及载体解码出的 PNG、MP4 或 WAV。
新结果直接追加,视频保持播放;点击“停止监听”暂停页面更新,再次开启时补上已有结果。接收服务持续接收回调。
完整消息以 JSONL 追加保存到 `.local/callback-received.jsonl`,可用 `--log` 指定路径。
重启服务时从磁盘日志恢复消息和计数。按 Ctrl+C 停止服务。
`--carrier-dir` 指向 ComfyUI 输出载体 PNG 所在目录,`--password` 是 ChickHideNode
使用的测试密码。服务会扫描新 PNG,解码载体内的原始媒体,并在结果页提供图片、视频或音频控件;
密码只用于本地解码,不会显示在页面或写回媒体文件。
可以使用脚本或 HTTP 客户端向 `http://127.0.0.1:8765/callback` 直接发送测试 JSON:
`{"id":"demo-task-001","msg":"Test message"}`。
接收页面显示任务 id 和文本,包括中文、换行和空格。
同一任务 id 的多次通知会分别显示;日志中的 `event_id` 是接收记录编号,与任务 id 分开。
重复运行相同工作流会再次发送通知。当前接收服务用于 HTTP 200 测试:
`POST /callback` 固定返回 200,有效消息写入日志并通过 WebSocket 推送。
NotifyMe 收到 200 后可继续执行后续节点。
请求校验、日志保存或推送发生异常时,该测试接口也返回 200,响应正文保留错误信息;
确认接收结果时同时查看页面记录。页面、健康检查和媒体接口照常响应。
服务接口:`POST /callback` 接收 `{"id":"demo-task-001","msg":"文本"}`(兼容省略 id 的旧请求);`GET /events` 返回消息;
`GET /health` 返回状态和本次启动的接收数量。单次请求正文上限为 1 MiB。
所有测试都无需模型。
自动化验证使用以下命令:
```sh
python -X utf8 -m unittest discover -s tests -v
python -X utf8 tools/smoke_comfy.py --comfy /path/to/ComfyUI
```
测试使用 `tests/callback_fixture.py` 提供合成 DNS 结果,并仅在隔离测试进程中
将底层连接导向使用临时测试证书的本地 HTTPS 接收器。测试证书只在隔离进程中受信任,
域名白名单、内网地址校验和 TLS 验证仍执行;辅助代码不被正式节点导入。
测试数据不发送到真实域名,也不加载模型。
所有通知示例的 callback_url 默认留空,包括文件名含 `local` 的旧示例。
手动运行时填写白名单域名下有权使用的实际接口;从旧版升级后请重新创建 NotifyMe
或导入新版示例,并重新填写任务 ID。
视频结果测试:导入 [`chick_notify_video_example.json`](../examples/chick_notify_video_example.json)。
它使用 `ImageBatchTestPattern` 生成 8 帧 64×64 测试图案,再连接
`ChickHideNode → ChickSaveImage` 生成带密码的 MP4 载体,同时将文本发送到 NotifyMe。
启动接收服务时使用同一个 `demo-password`,并指向载体输出目录,结果页会显示解码后的视频。
通知分支遵守正式域名白名单;隔离测试进程外的回调不会发给本地接收页。
+51
View File
@@ -0,0 +1,51 @@
# NotifyMe review receiver
Open **https://notify.bro3.net/** and select **Create test session**.
Copy the generated HTTPS URL into `Notify Me → callback_url`, enter a synthetic
task `id`, and optionally connect text to `msg`. Run the workflow and select
**Check results** on the page. A valid callback returns **HTTP 200**.
The [text example](../examples/notify_me_example.json) uses only
`PrimitiveStringMultiline → NotifyMeNode`; it loads no models. Fill its blank
callback URL with your session URL. Repeating the workflow creates another event;
omitting `msg` sends an empty string. The node accepts only HTTPS and the exact
approved host, verifies TLS certificates, and does not follow redirects.
For a direct protocol check, replace `SESSION_TOKEN` with the token from your page:
```sh
curl https://notify.bro3.net/callback/SESSION_TOKEN \
-H 'Content-Type: application/json' \
--data '{"id":"review-001","msg":"Hello from NotifyMe"}'
```
Expected response:
```json
{"ok":true,"received":{"id":"review-001","msg":"Hello from NotifyMe"}}
```
Sessions are separate and possession of the unguessable session URL grants access
to that session. Use test data only. Sessions expire after one hour; up to 20
events / 64 KiB are kept in memory per session. **Delete session** removes them
immediately, and a service restart clears all sessions. The service does not log
request bodies, persist messages, fetch URLs, forward data, or list sessions.
The page updates on demand without reloading. Standard hosting/CDN request metadata
may still be processed by the hosting providers.
Only `id` and `msg` are accepted; both are optional strings. Limits: 256 characters
for `id`, 8,192 for `msg`, 16 KiB per request, and 100 active sessions. Invalid
requests return 4xx; only successfully accepted callbacks return 200.
Source: [`tools/review_receiver`](../tools/review_receiver). The receiver is an
optional standalone review tool, not imported by the node package. To run it:
```sh
python -m pip install -r tools/review_receiver/requirements.txt
python tools/review_receiver/server.py --host 127.0.0.1 --port 8766
```
For public hosting, place it behind an HTTPS reverse proxy for the approved domain.
The included Dockerfile runs as an unprivileged user. Keep the backend private;
only the HTTPS proxy should expose it. `GET /health` provides a health check.
Availability of this demo does not imply Manager or RunningHub approval.
+94
View File
@@ -0,0 +1,94 @@
# Validation
Run the following commands from the repository directory using the Python interpreter
configured for your ComfyUI installation:
```sh
python -m pip install -r requirements.txt "av>=14" "cryptography>=43" "aiohttp>=3.12,<4"
python -X utf8 -m unittest discover -s tests -v
python -m compileall -q __init__.py nodes utils chick tests tools
```
The tests cover independent node registration and the carrier encoder,
exact host allowlisting, private DNS rejection, HTTP status handling, text task identifiers,
optional text messages, repeated notifications, timeouts, URL and DNS
validation, pinned connections, TLS hostname verification, and proxy isolation.
They use synthetic inputs and a local HTTPS fixture without loading models. The test
certificate is generated per run and trusted only by the isolated test process;
certificate/hostname checks remain enabled. Tests also reject HTTP before DNS and
reject untrusted certificates and mismatched TLS hostnames.
Version 0.6.1 passed 65 unit tests and 16 CPU workflow checks (12 successful runs
and 4 expected rejections with zero requests). It adds the standalone review receiver,
HTTPS-only callbacks, and tests for session isolation, exact text, expiry, deletion,
bounded retention and invalid requests.
Version 0.6.0 passed 58 unit tests and all 15 CPU workflow checks: 12 successful workflows
and 3 expected host-policy rejections with no callback request. All four node interfaces
were checked for English display names, descriptions and tooltips.
Version 0.5.0 passed 55 unit tests, including PNG restoration of RGB/RGBA batches at all
supported LSB widths and rejection of incorrect passwords and non-image payloads.
The connected image example was also executed on CPU: LoadImage → ChickHideNode →
NotifyMeNode → ChickRestoreImage → ChickSaveImage. Its 256×192 landscape was recovered
pixel-for-pixel, the carrier used the bundled chick cover, both saved PNGs excluded
prompt/workflow metadata, and the loopback receiver accepted the message. The distributable
wheel includes the cover assets and passed a codec round trip after extraction.
The carrier tests cover all supported LSB widths, image round trips, password
verification, and invalid batch input handling. Media tests independently read generated
MP4 files with PyAV to verify frame count, frame order, dimensions, timestamps, audio
channels and duration. Fixtures use up to eight 16x16 frames and two seconds of synthetic
audio. WAV tests check PCM values, sample rate and trailing silence. Failure and timeout
tests verify temporary-file cleanup and that failed audio encoding is never retried silently.
The compatibility check also exercises both directions against an independently installed
reference decoder/encoder during development; that reference is not packaged or imported
by this repository. Version 0.3.0 was also checked by passing an eight-frame MP4 with audio
through a reference decode node at all three LSB widths, with and without passwords:
the exported file bytes, eight frames, 8 fps and audio were recovered. Standalone WAV,
PNG files were recovered byte-for-byte. The checked reference exports a WAV file
without populating its AUDIO output; consumers can load the recovered file separately.
## Optional ComfyUI integration test
Install this repository as `ComfyUI-ChickTools` in your ComfyUI `custom_nodes` directory.
Replace `/path/to/ComfyUI` with your installation directory:
```sh
python -X utf8 tools/smoke_comfy.py --comfy /path/to/ComfyUI
```
The test starts an isolated CPU process on port 8191, with its own user, output and
temporary directories and an in-memory database. Use `--port` to choose another available port.
It runs sixteen workflows: eight carrier workflows (image and repeat-image, silent video,
video with audio, audio, an independent image batch, and a password-protected saved carrier
twice) plus four standalone notifications (connected text, a repeated queued run, no message
connection, and whitespace-only text), plus four rejected notifications (loopback, another
domain, a subdomain, and HTTP on the allowed domain). Rejected cases also inject the removed allow_localhost field into
the API prompt and verify it grants no exception. The media workflows connect ChickHide directly to
ChickSaveImage. The text workflows connect PrimitiveStringMultiline to NotifyMe.msg.
It downloads the actual PNGs, decodes their payloads and checks images, WAV samples and
MP4 frames/audio, as well as the exact four JSON callback bodies containing id and msg.
Notification cases verify Unicode task IDs with leading zeros, a changed task ID, and
backward compatibility with a workflow that omits id. Receiver tests also verify repeated
task IDs remain distinct events and that old logs restore with an empty task ID.
Every carrier request deliberately includes a workflow snapshot and an additional metadata field;
the saved PNG must contain only the synthetic AIGC provenance marker. Protected payloads
must decode with the correct password and reject both missing and incorrect passwords.
The repeated protected run also exercises cached encoder output.
The audio file is synthesized in an isolated input directory and loaded by LoadAudio.
The test uses `tests/callback_fixture.py` only in its isolated child process: synthetic DNS
returns a public address for the approved host, then the test socket routes to a real loopback
HTTPS receiver with a temporary test certificate. The production host allowlist, IP
validation and TLS verification run unchanged. No notifications
are sent to a real external service. Installed nodes always enforce the bundled host policy.
Receiver tests check exact Unicode/empty message logs, invalid requests and log-write failure.
The test loads no models and stops its own server when finished. Generated reports,
logs and preview images are stored under the ignored `.local/smoke` directory.
Version 0.4.2 passed the 51 unit tests and all twelve CPU workflows on ComfyUI 0.37.0.
No models were loaded. This verifies the local ComfyUI saving contract; installations that
independently add PNG metadata should check their actual returned files as well.
+52
View File
@@ -0,0 +1,52 @@
# Examples
All examples are importable ComfyUI canvas workflows and require no models.
Notification URLs are blank by default. Fill in an actual receiver on `notify.bro3.net`
that you control or have permission to use. The plugin does not provision a receiver.
Legacy filenames containing `local` follow this same host policy.
Recreate old NotifyMe nodes or import updated examples after upgrading from 0.5.x.
`chick_image_roundtrip_example.json` connects the two main nodes and restores the original
image at the end: LoadImage → ChickHideNode → NotifyMeNode.passthrough → ChickRestoreImage
→ ChickSaveImage. A separate ChickSaveImage writes the carrier from NotifyMe's output.
Copy `assets/roundtrip-input.png` to the ComfyUI input directory as
`chicktools-roundtrip-input.png`, or choose your own uploaded image. Configure the approved callback
and use the same `demo-password` in the encoder and restore node. The final preview is decoded
from the carrier, and both savers omit prompt/workflow/password metadata.
`chick_notify_local_example.json` displays both main nodes in one canvas: a 16x16 image
passes through ChickHide and ChickSaveImage, while a separate text branch sends msg through
NotifyMe to the approved callback. The branches are independent; the text is a sample message,
not a claim that another branch has completed. Configure the callback before running it.
`chick_notify_video_example.json` is the model-free video test: `ImageBatchTestPattern` creates
eight 64x64 numbered frames, ChickHide packages them as an encrypted MP4, ChickSaveImage writes
the carrier PNG, and the separate text branch sends the result message through NotifyMe. The
synthetic password is `demo-password`; point the local receiver at the ComfyUI output directory
with the same password to see the decrypted MP4 in its web page.
- `notify_me_example.json`: Text (Multiline) connects to the optional NotifyMe `msg` socket.
- `notify_me_empty_example.json`: NotifyMe without a text connection sends `{"id":"demo-task-001","msg":""}`.
- `notify_me_local_example.json`: text notification template with a blank callback URL;
its legacy filename does not enable loopback access.
Fill in callback_url and your task id before running the first two notification examples. Their callback must use the allowlisted host, resolve to public addresses
and return HTTP 200. Text is sent as the JSON msg value, preserving newlines and whitespace.
The notification node also has a wildcard passthrough input/output. Connect any value to
passthrough to continue the workflow with that exact value after the callback succeeds.
For local automated verification, use the [isolated test fixture](../docs/LOCAL_CALLBACK.md).
The installed node never permits loopback callbacks.
The following media workflows make no HTTP requests:
- `chick_image_example.json`: two 8x8 images, encoded as independent PNG payloads.
- `chick_audio_example.json`: 0.125 seconds of synthetic silence in a WAV payload.
- `chick_video_example.json`: eight 16x16 frames at 8 fps, with synthetic audio in an MP4 payload.
Media wiring follows the Duck encoder: IMAGE frames connect to `images`, optional AUDIO
connects to `audio`, and the carrier IMAGE connects to ChickSaveImage. Text encoding is omitted.
The audio/video examples use ComfyUI's built-in EmptyAudio node. Substitute LoadAudio
or another standard AUDIO producer for real sound. Save carriers as PNG without resizing.
Each media example uses ChickSaveImage, which excludes Prompt, workflow and password
metadata from the PNG. Displayed carrier previews show the carrier, not the original media.
+153
View File
@@ -0,0 +1,153 @@
{
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 4,
"type": "EmptyAudio",
"pos": [
80,
360
],
"size": [
260,
160
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": [
1
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyAudio"
},
"widgets_values": [
0.125,
16000,
1
]
},
{
"id": 2,
"type": "ChickHideNode",
"pos": [
430,
150
],
"size": [
300,
230
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
},
{
"name": "audio",
"type": "AUDIO",
"link": 1
}
],
"outputs": [
{
"name": "chick_image",
"type": "IMAGE",
"links": [
2
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ChickHideNode"
},
"widgets_values": [
"",
"Sample",
8,
6,
false
]
},
{
"id": 3,
"type": "ChickSaveImage",
"pos": [
860,
150
],
"size": [
330,
370
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
}
}
],
"outputs": [],
"properties": {
"Node name for S&R": "ChickSaveImage"
},
"widgets_values": [
"ChickTools/carrier"
]
}
],
"links": [
[
1,
4,
0,
2,
1,
"AUDIO"
],
[
2,
2,
0,
3,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.85,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+154
View File
@@ -0,0 +1,154 @@
{
"last_node_id": 3,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "EmptyImage",
"pos": [
80,
80
],
"size": [
260,
160
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyImage"
},
"widgets_values": [
8,
8,
2,
255
]
},
{
"id": 2,
"type": "ChickHideNode",
"pos": [
430,
150
],
"size": [
300,
230
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
},
{
"name": "audio",
"type": "AUDIO",
"link": null
}
],
"outputs": [
{
"name": "chick_image",
"type": "IMAGE",
"links": [
2
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ChickHideNode"
},
"widgets_values": [
"",
"Sample",
8,
6,
false
]
},
{
"id": 3,
"type": "ChickSaveImage",
"pos": [
860,
150
],
"size": [
330,
370
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
}
}
],
"outputs": [],
"properties": {
"Node name for S&R": "ChickSaveImage"
},
"widgets_values": [
"ChickTools/carrier"
]
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
2,
2,
0,
3,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.85,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+349
View File
@@ -0,0 +1,349 @@
{
"last_node_id": 7,
"last_link_id": 6,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [
30,
130
],
"size": [
280,
360
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
1
]
},
{
"name": "MASK",
"type": "MASK",
"slot_index": 1,
"links": []
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"chicktools-roundtrip-input.png",
"image"
],
"title": "LoadImage"
},
{
"id": 2,
"type": "ChickHideNode",
"pos": [
350,
130
],
"size": [
300,
280
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
},
{
"name": "audio",
"type": "AUDIO",
"link": null
}
],
"outputs": [
{
"name": "chick_image",
"type": "IMAGE",
"slot_index": 0,
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "ChickHideNode"
},
"widgets_values": [
"demo-password",
"ChickTools",
8,
2,
false
],
"title": "ChickHideNode"
},
{
"id": 3,
"type": "PrimitiveStringMultiline",
"pos": [
350,
520
],
"size": [
300,
180
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"slot_index": 0,
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": [
"小鸡图片载体已生成,正在还原原图。"
],
"title": "PrimitiveStringMultiline"
},
{
"id": 4,
"type": "NotifyMeNode",
"pos": [
710,
130
],
"size": [
310,
260
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "msg",
"type": "STRING",
"link": 2
},
{
"name": "passthrough",
"type": "*",
"link": 3
}
],
"outputs": [
{
"name": "passthrough",
"type": "*",
"slot_index": 0,
"links": [
4,
5
]
}
],
"properties": {
"Node name for S&R": "NotifyMeNode"
},
"widgets_values": [
"",
"demo-task-001"
],
"title": "NotifyMeNode"
},
{
"id": 5,
"type": "ChickSaveImage",
"pos": [
710,
520
],
"size": [
310,
410
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"outputs": [
{
"name": "images",
"type": "IMAGE",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "ChickSaveImage"
},
"widgets_values": [
"ChickTools/image-roundtrip"
],
"title": "ChickSaveImage"
},
{
"id": 6,
"type": "ChickRestoreImage",
"pos": [
1080,
130
],
"size": [
260,
210
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "chick_image",
"type": "IMAGE",
"link": 5
}
],
"outputs": [
{
"name": "images",
"type": "IMAGE",
"slot_index": 0,
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "ChickRestoreImage"
},
"widgets_values": [
"demo-password"
],
"title": "ChickRestoreImage"
},
{
"id": 7,
"type": "ChickSaveImage",
"pos": [
1400,
130
],
"size": [
340,
410
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 6
}
],
"outputs": [
{
"name": "images",
"type": "IMAGE",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "ChickSaveImage"
},
"widgets_values": [
"ChickToolsRestored/original"
],
"title": "ChickSaveImage"
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
2,
3,
0,
4,
0,
"STRING"
],
[
3,
2,
0,
4,
1,
"IMAGE"
],
[
4,
4,
0,
5,
0,
"*"
],
[
5,
4,
0,
6,
0,
"*"
],
[
6,
6,
0,
7,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.65,
"offset": [
0,
0
]
}
},
"version": 0.4,
"id": "00787f6c-c0e6-46d6-8bdf-e0fd584ac7bc"
}
+268
View File
@@ -0,0 +1,268 @@
{
"last_node_id": 5,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "EmptyImage",
"pos": [
70,
90
],
"size": [
240,
165
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
1
]
}
],
"properties": {
"Node name for S&R": "EmptyImage"
},
"widgets_values": [
16,
16,
1,
255
],
"title": "EmptyImage"
},
{
"id": 2,
"type": "ChickHideNode",
"pos": [
390,
90
],
"size": [
320,
240
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
},
{
"name": "audio",
"type": "AUDIO",
"link": null
}
],
"outputs": [
{
"name": "chick_image",
"type": "IMAGE",
"slot_index": 0,
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "ChickHideNode"
},
"widgets_values": [
"",
"ChickTools demo",
16,
2,
true
],
"title": "ChickHideNode"
},
{
"id": 3,
"type": "ChickSaveImage",
"pos": [
790,
90
],
"size": [
340,
350
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [
{
"name": "images",
"type": "IMAGE",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "ChickSaveImage"
},
"widgets_values": [
"ChickTools/local-demo"
],
"title": "ChickSaveImage"
},
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [
70,
550
],
"size": [
560,
180
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"slot_index": 0,
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": [
"来自 NotifyMe 的测试消息\n这段文本通过 msg 发给本地接收服务。"
],
"title": "PrimitiveStringMultiline"
},
{
"id": 5,
"type": "NotifyMeNode",
"pos": [
790,
550
],
"size": [
340,
200
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "msg",
"type": "STRING",
"link": 3
},
{
"name": "passthrough",
"type": "*",
"link": null
}
],
"outputs": [
{
"name": "passthrough",
"type": "*",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "NotifyMeNode"
},
"widgets_values": [
"",
"demo-task-001"
],
"title": "NotifyMeNode"
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
2,
2,
0,
3,
0,
"IMAGE"
],
[
3,
4,
0,
5,
0,
"STRING"
]
],
"groups": [
{
"id": 1,
"title": "Media processing",
"bounding": [
40,
20,
1120,
440
],
"color": "#3b7184",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Text notification",
"bounding": [
40,
480,
1120,
300
],
"color": "#596c95",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.75,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+272
View File
@@ -0,0 +1,272 @@
{
"last_node_id": 5,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "ImageBatchTestPattern",
"pos": [
70,
90
],
"size": [
330,
300
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
1
]
}
],
"properties": {
"Node name for S&R": "ImageBatchTestPattern"
},
"widgets_values": [
8,
0,
5,
5,
64,
64,
"FreeMono.ttf",
12
],
"title": "ImageBatchTestPattern"
},
{
"id": 2,
"type": "ChickHideNode",
"pos": [
460,
90
],
"size": [
320,
240
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
},
{
"name": "audio",
"type": "AUDIO",
"link": null
}
],
"outputs": [
{
"name": "chick_image",
"type": "IMAGE",
"slot_index": 0,
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "ChickHideNode"
},
"widgets_values": [
"demo-password",
"ChickTools video demo",
8,
2,
true
],
"title": "ChickHideNode"
},
{
"id": 3,
"type": "ChickSaveImage",
"pos": [
850,
90
],
"size": [
340,
350
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [
{
"name": "images",
"type": "IMAGE",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "ChickSaveImage"
},
"widgets_values": [
"ChickTools/video-demo"
],
"title": "ChickSaveImage"
},
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [
70,
520
],
"size": [
560,
180
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"slot_index": 0,
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": [
"视频载体测试已完成\n这条文本由 NotifyMe 发送到本地结果页。"
],
"title": "PrimitiveStringMultiline"
},
{
"id": 5,
"type": "NotifyMeNode",
"pos": [
850,
520
],
"size": [
340,
200
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "msg",
"type": "STRING",
"link": 3
},
{
"name": "passthrough",
"type": "*",
"link": null
}
],
"outputs": [
{
"name": "passthrough",
"type": "*",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "NotifyMeNode"
},
"widgets_values": [
"",
"demo-task-001"
],
"title": "NotifyMeNode"
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
2,
2,
0,
3,
0,
"IMAGE"
],
[
3,
4,
0,
5,
0,
"STRING"
]
],
"groups": [
{
"id": 1,
"title": "Media processing",
"bounding": [
40,
20,
1190,
440
],
"color": "#3b7184",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Text notification",
"bounding": [
40,
450,
1190,
300
],
"color": "#596c95",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.72,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+196
View File
@@ -0,0 +1,196 @@
{
"last_node_id": 4,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "EmptyImage",
"pos": [
80,
80
],
"size": [
260,
160
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyImage"
},
"widgets_values": [
16,
16,
8,
255
]
},
{
"id": 4,
"type": "EmptyAudio",
"pos": [
80,
360
],
"size": [
260,
160
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": [
2
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyAudio"
},
"widgets_values": [
0.125,
16000,
1
]
},
{
"id": 2,
"type": "ChickHideNode",
"pos": [
430,
150
],
"size": [
300,
230
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
},
{
"name": "audio",
"type": "AUDIO",
"link": 2
}
],
"outputs": [
{
"name": "chick_image",
"type": "IMAGE",
"links": [
3
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ChickHideNode"
},
"widgets_values": [
"",
"Sample",
8,
6,
true
]
},
{
"id": 3,
"type": "ChickSaveImage",
"pos": [
860,
150
],
"size": [
330,
370
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
}
}
],
"outputs": [],
"properties": {
"Node name for S&R": "ChickSaveImage"
},
"widgets_values": [
"ChickTools/carrier"
]
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
2,
4,
0,
2,
1,
"AUDIO"
],
[
3,
2,
0,
3,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.85,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+61
View File
@@ -0,0 +1,61 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "NotifyMeNode",
"pos": [
60,
140
],
"size": [
300,
240
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "msg",
"type": "STRING",
"link": null
},
{
"name": "passthrough",
"type": "*",
"link": null
}
],
"outputs": [
{
"name": "passthrough",
"type": "*",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "NotifyMeNode"
},
"widgets_values": [
"",
"demo-task-001"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.7,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+102
View File
@@ -0,0 +1,102 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [
60,
140
],
"size": [
300,
240
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"slot_index": 0,
"links": [
1
]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": [
"Sample message"
]
},
{
"id": 2,
"type": "NotifyMeNode",
"pos": [
390,
140
],
"size": [
300,
240
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "msg",
"type": "STRING",
"link": 1
},
{
"name": "passthrough",
"type": "*",
"link": null
}
],
"outputs": [
{
"name": "passthrough",
"type": "*",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "NotifyMeNode"
},
"widgets_values": [
"",
"demo-task-001"
]
}
],
"links": [
[
1,
1,
0,
2,
0,
"STRING"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.7,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+102
View File
@@ -0,0 +1,102 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [
60,
140
],
"size": [
300,
240
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"slot_index": 0,
"links": [
1
]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": [
"本地回调测试\n保留文本内容"
]
},
{
"id": 2,
"type": "NotifyMeNode",
"pos": [
390,
140
],
"size": [
300,
240
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "msg",
"type": "STRING",
"link": 1
},
{
"name": "passthrough",
"type": "*",
"link": null
}
],
"outputs": [
{
"name": "passthrough",
"type": "*",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "NotifyMeNode"
},
"widgets_values": [
"",
"demo-task-001"
]
}
],
"links": [
[
1,
1,
0,
2,
0,
"STRING"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.7,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+22
View File
@@ -0,0 +1,22 @@
"""Independent ChickTools node implementations."""
from .chick_hide import ChickHideNode
from .chick_save import ChickSaveImage
from .chick_restore import ChickRestoreImage
from .notify_me import NotifyMeNode
NODE_CLASS_MAPPINGS = {
"ChickHideNode": ChickHideNode,
"ChickSaveImage": ChickSaveImage,
"ChickRestoreImage": ChickRestoreImage,
"NotifyMeNode": NotifyMeNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ChickHideNode": "Chick Hide",
"ChickSaveImage": "Chick Private Save",
"ChickRestoreImage": "Chick Restore Image",
"NotifyMeNode": "Notify Me",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+78
View File
@@ -0,0 +1,78 @@
"""Independent carrier-image encoder node."""
from __future__ import annotations
from typing import Any
import numpy as np
from ..chick.codec import CarrierCodecError, encode_bytes, image_to_png_bytes, required_side
from ..chick.media import audio_to_wav, image_batch, images_to_mp4
class ChickHideNode:
"""Serialize media and return carrier images without network or output-file writes."""
DESCRIPTION = "Pack images, audio or video into carrier images. Connect image frames to images and sound to audio. Save with Chick Private Save to exclude prompt, workflow and password metadata."
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"password": ("STRING", {"default": "", "multiline": False,
"tooltip": "Carrier password. Use Chick Private Save instead of standard SaveImage or PreviewImage to exclude password metadata."}),
"title": ("STRING", {"default": "", "multiline": False}),
"fps": ("INT", {"default": 16, "min": 1, "max": 60, "step": 1}),
"compress": ([2, 6, 8], {"default": 2}),
"combine_video": ("BOOLEAN", {"default": True}),
},
"optional": {
"images": ("IMAGE",),
"audio": ("AUDIO",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("chick_image",)
FUNCTION = "encode"
CATEGORY = "ChickTools/Media"
def encode(
self,
password: str,
title: str,
fps: int,
compress: int,
combine_video: bool,
images: Any = None,
audio: Any = None,
) -> tuple[Any]:
try:
import torch
if not all(isinstance(value, str) for value in (password, title)):
raise CarrierCodecError("password and title must be strings")
if images is None:
if audio is None:
raise CarrierCodecError("provide images or audio")
payloads = [(audio_to_wav(audio), "wav")]
else:
batch = image_batch(images)
if audio is not None and not combine_video:
raise CarrierCodecError("enable combine_video to include audio with images")
if combine_video and (len(batch) > 1 or audio is not None):
payloads = [(images_to_mp4(batch, fps, audio), "mp4")]
else:
payloads = [(image_to_png_bytes(frame), "png") for frame in batch]
# Size the headers without encrypting or rendering a disposable carrier.
side = max(required_side(len(raw) + 6 + len(ext.encode("utf-8")) + (48 if password else 0), compress)
for raw, ext in payloads)
output = torch.empty((len(payloads), side, side, 3), dtype=torch.float32, device="cpu")
for index, (raw, ext) in enumerate(payloads):
caption = f"{title} ({index + 1}/{len(payloads)})" if len(payloads) > 1 else title
carrier = encode_bytes(raw, ext, password, compress, caption, fixed_side=side)
output[index] = torch.from_numpy(np.asarray(carrier, dtype=np.float32) / 255.0)
return (output,)
except (CarrierCodecError, OSError, ValueError) as exc:
raise RuntimeError(f"[CHICK_HIDE_ERROR] {exc}") from exc
except ImportError as exc:
raise RuntimeError("[CHICK_HIDE_ERROR] ComfyUI torch is required") from exc
+45
View File
@@ -0,0 +1,45 @@
"""Restore image pixels from Chick carrier images for downstream preview or processing."""
from io import BytesIO
import numpy as np
from PIL import Image
from ..chick.codec import CarrierCodecError, decode_bytes, image_pixels
from ..chick.media import image_batch
class ChickRestoreImage:
DESCRIPTION = "Restore original PNG images from Chick carriers. Use the encoding password. For video and audio, decode with the local result viewer."
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"chick_image": ("IMAGE",),
"password": ("STRING", {"default": "", "multiline": False}),
}}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "restore"
CATEGORY = "ChickTools/Media"
def restore(self, chick_image, password=""):
import torch
try:
if not isinstance(password, str):
raise CarrierCodecError("password must be a string")
restored = []
for frame in image_batch(chick_image):
raw, extension = decode_bytes(Image.fromarray(image_pixels(frame)), password)
if extension != "png":
raise CarrierCodecError("image restoration requires a PNG payload; use the local viewer for video/audio")
with Image.open(BytesIO(raw)) as image:
pixels = np.array(image.convert("RGBA" if "A" in image.getbands() else "RGB"), dtype=np.float32) / 255.0
if restored and pixels.shape != restored[0].shape:
raise CarrierCodecError("restored images must have matching dimensions and channels")
restored.append(pixels)
return (torch.from_numpy(np.stack(restored)),)
except (CarrierCodecError, OSError, ValueError) as exc:
raise RuntimeError(f"[CHICK_RESTORE_ERROR] {exc}") from exc
+64
View File
@@ -0,0 +1,64 @@
"""ComfyUI saver for ChickHideNode carriers.
ComfyUI injects the complete original prompt and ``extra_pnginfo`` into every
standard SaveImage node. A ChickHideNode password is an input in that prompt,
so a standard saver can copy the password into PNG tEXt chunks even though the
carrier payload itself is encrypted. This saver forwards only an allowlisted
provenance marker to the host saver.
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
try: # ComfyUI's top-level nodes module is available when the plugin loads.
from nodes import SaveImage as _ComfySaveImage
except ImportError: # Keep package registration and unit tests model-free.
class _ComfySaveImage: # type: ignore[no-redef]
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {"required": {"images": ("IMAGE",), "filename_prefix": ("STRING",)},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}}
def save_images(self, images: Any, filename_prefix: str = "ComfyUI",
prompt: Any = None, extra_pnginfo: Any = None) -> dict[str, Any]:
raise RuntimeError("ComfyUI SaveImage is required to write carriers")
class ChickSaveImage(_ComfySaveImage):
"""Save a Chick carrier without putting the password in PNG metadata."""
CATEGORY = "ChickTools/Media"
FUNCTION = "save_images"
RETURN_TYPES = getattr(_ComfySaveImage, "RETURN_TYPES", ())
RETURN_NAMES = getattr(_ComfySaveImage, "RETURN_NAMES", RETURN_TYPES)
OUTPUT_NODE = getattr(_ComfySaveImage, "OUTPUT_NODE", True)
DESCRIPTION = (
"Save carrier PNGs without execution prompt or workflow metadata, keeping passwords out of metadata."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
schema = deepcopy(super().INPUT_TYPES())
hidden = schema.get("hidden")
if isinstance(hidden, dict):
hidden.pop("prompt", None)
if not hidden:
schema.pop("hidden", None)
return schema
def save_images(self, images: Any, filename_prefix: str = "ChickTools/carrier",
prompt: Any = None, extra_pnginfo: Any = None, **kwargs: Any) -> Any:
# Do not mutate the execution graph: ComfyUI may use it for other nodes.
provenance: dict[str, Any] = {}
if isinstance(extra_pnginfo, dict) and "AIGC" in extra_pnginfo:
provenance["AIGC"] = deepcopy(extra_pnginfo["AIGC"])
return super().save_images(
images,
filename_prefix=filename_prefix,
prompt=None,
extra_pnginfo=provenance or None,
**kwargs,
)
+65
View File
@@ -0,0 +1,65 @@
"""HTTP text notification, independent from media inputs and the codec."""
from typing import Any
import requests
from ..utils.callback_http import post_completion
from ..utils.url_security import CallbackURLValidationError, validate_callback_url
class AnyType(str):
def __ne__(self, other: object) -> bool:
return False
ANY_TYPE = AnyType("*")
class NotifyMeNode:
DESCRIPTION = ('Send JSON {"id":"task ID","msg":"upstream text"} to an approved HTTPS callback host. '
'Currently allowed: notify.bro3.net. Runs after connected upstream nodes finish. '
'Optional passthrough data is returned unchanged after HTTP 200.')
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {"required": {
"callback_url": ("STRING", {"default": "", "multiline": False,
"tooltip": "HTTPS receiver on notify.bro3.net. HTTP, other hosts, subdomains and IP addresses are rejected."}),
}, "optional": {
"msg": ("STRING", {"forceInput": True}),
"passthrough": (ANY_TYPE, {"forceInput": True}),
"id": ("STRING", {"default": "", "multiline": False,
"tooltip": "Task identifier sent unchanged with msg. Leave blank to send an empty string."}),
}}
RETURN_TYPES = (ANY_TYPE,)
RETURN_NAMES = ("passthrough",)
FUNCTION = "notify"
CATEGORY = "ChickTools/Network"
OUTPUT_NODE = True
@classmethod
def IS_CHANGED(cls, **kwargs):
# Each queued run notifies again even if its upstream text is cached.
return float("nan")
def notify(self, callback_url: str, msg: str = "",
passthrough: Any = None, id: str = "") -> tuple[Any, ...]:
if not isinstance(callback_url, str) or not callback_url.strip():
raise Exception("Please enter a valid callback URL")
if not isinstance(msg, str):
raise Exception("Please enter a valid message (msg must be text)")
if not isinstance(id, str):
raise Exception("Please enter a valid task ID (id must be text)")
try:
target = validate_callback_url(callback_url)
status = post_completion(target, msg, timeout=15, id=id)
except CallbackURLValidationError as exc:
raise Exception(f"Please enter a valid callback URL: {exc}") from exc
except (requests.RequestException, OSError, ValueError) as exc:
# Avoid echoing URLs which may contain private callback query tokens.
raise Exception(f"Callback request failed ({type(exc).__name__})") from exc
if status != 200:
raise Exception(f"Callback request failed (HTTP {status})")
return (passthrough,)
+32
View File
@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "chicktools"
version = "0.6.1"
description = "Independent ComfyUI carrier-image and HTTPS callback nodes"
readme = "README.md"
requires-python = ">=3.9"
license = { file = "LICENSE" }
dependencies = ["requests>=2.31,<3", "urllib3>=1.26,<3", "numpy>=1.24,<3", "Pillow>=10.4", "imageio-ffmpeg>=0.5,<1"]
authors = [{ name = "ChickTools contributors" }]
classifiers = ["Operating System :: OS Independent"]
[project.optional-dependencies]
test = ["av>=14", "cryptography>=43", "aiohttp>=3.12,<4"]
[project.urls]
Repository = "https://github.com/chookg/ComfyUI-ChickTools"
[tool.setuptools]
packages = ["chicktools", "chicktools.nodes", "chicktools.utils", "chicktools.chick"]
package-dir = { chicktools = "." }
[tool.setuptools.package-data]
chicktools = ["assets/*.png", "assets/*.svg"]
[tool.comfy]
# TODO: create a real publisher account; blank means not ready to publish.
PublisherId = ""
DisplayName = "ChickTools"
+5
View File
@@ -0,0 +1,5 @@
requests>=2.31,<3
urllib3>=1.26,<3
numpy>=1.24,<3
Pillow>=10.4
imageio-ffmpeg>=0.5,<1
+87
View File
@@ -0,0 +1,87 @@
"""Isolated test routing; never imported by the installed node package."""
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
import ipaddress
from pathlib import Path
import socket
import ssl
import tempfile
from unittest.mock import patch
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from urllib3.util import connection
class TLSFixture:
"""Fresh test-only certificate; production verification stays enabled."""
def __init__(self, directory=None):
self.temp = tempfile.TemporaryDirectory() if directory is None else None
root = Path(self.temp.name if self.temp else directory)
root.mkdir(parents=True, exist_ok=True)
self.cert = root / "test-cert.pem"
key_path = root / "test-key.pem"
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "NotifyMe isolated test")])
now = datetime.now(timezone.utc)
certificate = (x509.CertificateBuilder().subject_name(name).issuer_name(name)
.public_key(key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5)).not_valid_after(now + timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(x509.SubjectAlternativeName([
x509.DNSName("notify.bro3.net"), x509.DNSName("callback.example"),
x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
]), critical=False).sign(key, hashes.SHA256()))
self.cert.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
self.server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self.server_context.load_cert_chain(str(self.cert), str(key_path))
self.client_context = ssl.create_default_context(cafile=str(self.cert))
def close(self):
if self.temp:
self.temp.cleanup()
def trust(self):
return trust_test_certificate(self.cert)
@contextmanager
def trust_test_certificate(cert):
with patch("requests.certs.where", return_value=str(cert)), patch(
"requests.adapters.DEFAULT_CA_BUNDLE_PATH", str(cert)
):
yield
@contextmanager
def routed_callback(port, cert=None):
"""Use synthetic DNS and a loopback socket with production validation intact."""
original_resolve = socket.getaddrinfo
original_connect = connection.create_connection
public_address = "93.184.216.34"
def resolve(host, query_port, *args, **kwargs):
if host == "notify.bro3.net":
if int(query_port) != port:
raise OSError("Unexpected test callback port")
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (public_address, port))]
return original_resolve(host, query_port, *args, **kwargs)
def connect(address, *args, **kwargs):
if address[0] == public_address:
if int(address[1]) != port:
raise OSError("Unexpected test callback port")
address = ("127.0.0.1", port)
return original_connect(address, *args, **kwargs)
from contextlib import nullcontext
with trust_test_certificate(cert) if cert else nullcontext(), patch("socket.getaddrinfo", side_effect=resolve), patch(
"urllib3.util.connection.create_connection", side_effect=connect
):
yield
+16
View File
@@ -0,0 +1,16 @@
"""Load as a ComfyUI package without polluting its nodes/utils modules."""
import importlib.util
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
if "chicktools" not in sys.modules:
spec = importlib.util.spec_from_file_location(
"chicktools", ROOT / "__init__.py", submodule_search_locations=[str(ROOT)]
)
plugin = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = plugin
spec.loader.exec_module(plugin)
else:
plugin = sys.modules["chicktools"]
+61
View File
@@ -0,0 +1,61 @@
from io import BytesIO
import unittest
import numpy as np
from PIL import Image
import torch
from plugin_loader import plugin
from chicktools.chick.codec import CarrierCodecError, decode_bytes, encode_bytes, image_to_png_bytes
class CarrierCodecTests(unittest.TestCase):
def setUp(self):
self.source = Image.new("RGB", (8, 8), (12, 34, 56))
output = BytesIO()
self.source.save(output, format="PNG")
self.raw = output.getvalue()
def test_round_trip_all_lsb_widths(self):
for compress in (2, 6, 8):
with self.subTest(compress=compress):
carrier = encode_bytes(self.raw, "png", compress=compress, title="sample")
self.assertGreaterEqual(carrier.width, 640)
recovered, extension = decode_bytes(carrier)
self.assertEqual((recovered, extension), (self.raw, "png"))
def test_password_round_trip_and_rejection(self):
carrier = encode_bytes(self.raw, "png", password="local-test", compress=6)
self.assertEqual(decode_bytes(carrier, "local-test"), (self.raw, "png"))
with self.assertRaises(CarrierCodecError):
decode_bytes(carrier)
with self.assertRaises(CarrierCodecError):
decode_bytes(carrier, "wrong")
def test_large_payload_across_bit_chunk_boundaries_and_png_save(self):
raw = bytes(range(256)) * 1200 + b"\x00\x00"
for width in (2, 6, 8):
with self.subTest(width=width):
carrier = encode_bytes(raw, "bin", password="chunks", compress=width)
output = BytesIO()
carrier.save(output, format="PNG")
with Image.open(BytesIO(output.getvalue())) as saved:
self.assertEqual(decode_bytes(saved, "chunks"), (raw, "bin"))
def test_image_conversion_rejects_batches(self):
with self.assertRaises(CarrierCodecError):
image_to_png_bytes(np.zeros((2, 8, 8, 3), dtype=np.float32))
def test_node_encodes_a_real_image_batch(self):
source = torch.zeros((1, 8, 8, 3), dtype=torch.float32)
source[:, :, :, 0] = 0.25
carrier = plugin.NODE_CLASS_MAPPINGS["ChickHideNode"]().encode(
password="node-test", title="", fps=24, compress=2, combine_video=False, images=source
)[0]
self.assertEqual(tuple(carrier.shape[:1]), (1,))
carrier_image = Image.fromarray(np.rint(carrier[0].numpy() * 255).astype(np.uint8), mode="RGB")
recovered, extension = decode_bytes(carrier_image, "node-test")
self.assertEqual(extension, "png")
with Image.open(BytesIO(recovered)) as restored:
self.assertEqual(restored.size, (8, 8))
self.assertEqual(restored.getpixel((0, 0)), (64, 0, 0))
+165
View File
@@ -0,0 +1,165 @@
"""Small CPU-only media tests; PyAV independently inspects the generated MP4."""
from io import BytesIO
from pathlib import Path
import subprocess
import unittest
from unittest.mock import patch
import wave
import av
import numpy as np
from PIL import Image
import torch
from plugin_loader import plugin
from chicktools.chick.codec import CarrierCodecError, decode_bytes
from chicktools.chick.media import audio_to_wav, images_to_mp4
def tone(seconds=0.125, channels=1, rate=16000):
samples = np.arange(round(seconds * rate)) / rate
waveform = np.stack([0.4 * np.sin(2 * np.pi * (440 + channel * 220) * samples)
for channel in range(channels)])
return {"waveform": torch.from_numpy(waveform[None].astype(np.float32)), "sample_rate": rate}
def frames(count=8, height=16, width=16):
batch = torch.zeros((count, height, width, 3))
for index in range(count):
batch[index, :, :, index % 3] = 1
return batch
def encode_node(**inputs):
options = dict(password="", title="", fps=8, compress=6, combine_video=True)
options.update(inputs)
return plugin.NODE_CLASS_MAPPINGS["ChickHideNode"]().encode(**options)[0]
def extract(carrier, password=""):
# Exercise the same float -> PNG quantization used by a ComfyUI save node.
pixels = np.rint(carrier.detach().cpu().numpy() * 255).astype(np.uint8)
png = BytesIO()
Image.fromarray(pixels).save(png, format="PNG")
with Image.open(BytesIO(png.getvalue())) as image:
return decode_bytes(image, password)
class MediaTests(unittest.TestCase):
def test_wav_mono_stereo_and_sample_values(self):
for channels in (1, 2):
with self.subTest(channels=channels):
source = tone(channels=channels)
raw = audio_to_wav(source)
with wave.open(BytesIO(raw), "rb") as wav:
self.assertEqual((wav.getnchannels(), wav.getframerate(), wav.getsampwidth(), wav.getnframes()),
(channels, 16000, 2, 2000))
pcm = np.frombuffer(wav.readframes(2000), dtype="<i2").reshape(-1, channels) / 32768.0
np.testing.assert_allclose(pcm, source["waveform"][0].T.numpy(), atol=1 / 32768)
def test_wav_clipping_and_trailing_silence_preserved(self):
source = {"waveform": np.array([[[-2, -1, 0.5, 1, 2, 0, 0]]]), "sample_rate": 8000}
raw, ext = extract(encode_node(audio=source, password="p")[0], "p")
self.assertEqual(ext, "wav")
with wave.open(BytesIO(raw)) as wav:
self.assertEqual(wav.getnframes(), 7)
self.assertEqual(np.frombuffer(wav.readframes(7), dtype="<i2").tolist(),
[-32768, -32768, 16384, 32767, 32767, 0, 0])
def test_video_frame_count_timing_order_and_no_audio(self):
raw, ext = extract(encode_node(images=frames())[0])
self.assertEqual(ext, "mp4")
with av.open(BytesIO(raw)) as video:
self.assertEqual(len(video.streams.audio), 0)
stream = video.streams.video[0]
self.assertEqual((stream.codec_context.name, stream.width, stream.height, stream.average_rate),
("h264", 16, 16, 8))
decoded = list(video.decode(video=0))
self.assertEqual(len(decoded), 8)
for index, frame in enumerate(decoded):
self.assertAlmostEqual(float(frame.pts * frame.time_base), index / 8)
pixel = frame.to_ndarray(format="rgb24")[8, 8]
expected = np.eye(3, dtype=np.uint8)[index % 3] * 255
np.testing.assert_allclose(pixel, expected, atol=8)
def test_mux_audio_loop_and_trim_to_video_duration(self):
for seconds in (0.125, 2.0):
with self.subTest(seconds=seconds):
raw, ext = extract(encode_node(images=frames(), audio=tone(seconds, 2), password="mux")[0], "mux")
self.assertEqual(ext, "mp4")
with av.open(BytesIO(raw)) as video:
self.assertEqual(len(list(video.decode(video=0))), 8)
with av.open(BytesIO(raw)) as video:
stream = video.streams.audio[0]
self.assertEqual(stream.codec_context.name, "aac")
self.assertEqual((stream.sample_rate, len(stream.layout.channels)), (16000, 2))
self.assertAlmostEqual(float(stream.duration * stream.time_base), 1.0, delta=0.07)
samples = np.concatenate([frame.to_ndarray() for frame in video.decode(audio=0)], axis=1)
self.assertGreaterEqual(samples.shape[1], 16000)
self.assertLessEqual(samples.shape[1], 16000 + 1024)
# Sound at the end proves short audio was looped, not dropped or padded with silence.
self.assertGreater(float(np.sqrt(np.mean(samples[:, 12000:15000] ** 2))), 0.1)
def test_odd_dimensions_are_padded_without_resizing(self):
raw = images_to_mp4(frames(2, 15, 17), 8)
with av.open(BytesIO(raw)) as video:
stream = video.streams.video[0]
self.assertEqual((stream.width, stream.height), (18, 16))
self.assertEqual(len(list(video.decode(video=0))), 2)
def test_single_frame_with_audio_and_tiny_image(self):
raw, ext = extract(encode_node(images=frames(1, 1, 1), audio=tone(), fps=2)[0])
self.assertEqual(ext, "mp4")
with av.open(BytesIO(raw)) as video:
self.assertEqual(len(video.streams.audio), 1)
decoded = list(video.decode(video=0))
self.assertEqual(len(decoded), 1)
self.assertEqual((decoded[0].width, decoded[0].height), (2, 2))
def test_independent_images(self):
carriers = encode_node(images=frames(3, 1, 1), combine_video=False)
self.assertEqual(carriers.shape[0], 3)
for index, carrier in enumerate(carriers):
raw, ext = extract(carrier)
self.assertEqual(ext, "png")
with Image.open(BytesIO(raw)) as image:
self.assertEqual(image.size, (1, 1))
self.assertEqual(image.getpixel((0, 0)), tuple(np.eye(3, dtype=np.uint8)[index] * 255))
def test_invalid_media_and_ambiguous_inputs_fail_explicitly(self):
bad_audio = [None, {}, {"waveform": np.zeros((2, 1, 4)), "sample_rate": 8000},
{"waveform": np.zeros((1, 3, 4)), "sample_rate": 8000},
{"waveform": np.zeros((1, 1, 0)), "sample_rate": 8000},
{"waveform": np.array([[[float('nan')]]]), "sample_rate": 8000},
{"waveform": np.zeros((1, 1, 4)), "sample_rate": 0}]
for audio in bad_audio:
with self.subTest(audio_type=type(audio)), self.assertRaises(CarrierCodecError):
audio_to_wav(audio)
for options in ({}, {"images": frames(), "audio": tone(), "combine_video": False},
{"images": torch.empty((0, 16, 16, 3))},
{"images": torch.full((1, 1, 1, 3), float('nan'))}):
with self.subTest(keys=list(options)), self.assertRaisesRegex(RuntimeError, r"^\[CHICK_HIDE_ERROR\]"):
encode_node(**options)
for fps in (0, 1.5, True, 61):
with self.subTest(fps=fps), self.assertRaisesRegex(RuntimeError, "fps"):
encode_node(images=frames(2), fps=fps)
def test_encoder_failure_timeout_and_missing_output_clean_temp_files(self):
for mode in ("failure", "timeout", "empty"):
directories = []
def run(command, **kwargs):
root = Path(command[-1]).parent
directories.append(root)
self.assertTrue((root / "frames.rgb").exists())
self.assertTrue((root / "audio.wav").exists())
if mode == "timeout":
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
return subprocess.CompletedProcess(command, 1 if mode == "failure" else 0, stderr=b"fixture failure")
with self.subTest(mode=mode), patch("chicktools.chick.media.ffmpeg_executable", return_value="fixture"), \
patch("chicktools.chick.media.subprocess.run", side_effect=run) as runner:
with self.assertRaisesRegex(RuntimeError, r"^\[CHICK_HIDE_ERROR\]"):
encode_node(images=frames(2), audio=tone())
self.assertEqual(runner.call_count, 1)
self.assertTrue(directories)
self.assertTrue(all(not root.exists() for root in directories))
+39
View File
@@ -0,0 +1,39 @@
from io import BytesIO
import unittest
import numpy as np
from PIL import Image
import torch
from plugin_loader import plugin
from chicktools.chick.codec import encode_bytes
class RestoreImageTests(unittest.TestCase):
def test_restores_rgb_and_rgba_batches_pixel_for_pixel(self):
encoder = plugin.NODE_CLASS_MAPPINGS["ChickHideNode"]()
decoder = plugin.NODE_CLASS_MAPPINGS["ChickRestoreImage"]()
for channels in (3, 4):
source = torch.arange(2 * 8 * 12 * channels).reshape(2, 8, 12, channels).remainder(256).float() / 255
for compress in (2, 6, 8):
with self.subTest(channels=channels, compress=compress):
carrier, = encoder.encode("roundtrip", "", 8, compress, False, source)
restored, = decoder.restore(carrier, "roundtrip")
torch.testing.assert_close(restored, source, rtol=0, atol=0)
def test_wrong_password_and_non_image_payload_fail(self):
decoder = plugin.NODE_CLASS_MAPPINGS["ChickRestoreImage"]()
buffer = BytesIO()
Image.new("RGB", (8, 8), "red").save(buffer, format="PNG")
for raw, extension, password, expected in (
(buffer.getvalue(), "png", "wrong", "password"),
(b"synthetic video payload", "mp4", "secret", "PNG payload"),
):
carrier = np.asarray(encode_bytes(raw, extension, "secret"))[None]
with self.subTest(extension=extension), self.assertRaisesRegex(RuntimeError, expected):
decoder.restore(carrier, password)
def test_plain_image_is_not_silently_treated_as_a_carrier(self):
decoder = plugin.NODE_CLASS_MAPPINGS["ChickRestoreImage"]()
with self.assertRaisesRegex(RuntimeError, "CHICK_RESTORE_ERROR"):
decoder.restore(torch.zeros((1, 16, 16, 3)))
+49
View File
@@ -0,0 +1,49 @@
import copy
import unittest
from plugin_loader import plugin
import chicktools.nodes.chick_save as chick_save
class PrivateSaverTests(unittest.TestCase):
def test_schema_does_not_request_prompt_metadata(self):
schema = plugin.NODE_CLASS_MAPPINGS["ChickSaveImage"].INPUT_TYPES()
self.assertNotIn("prompt", schema.get("hidden", {}))
self.assertEqual(schema["hidden"]["extra_pnginfo"], "EXTRA_PNGINFO")
self.assertEqual(plugin.NODE_DISPLAY_NAME_MAPPINGS["ChickSaveImage"],
"Chick Private Save")
def test_allowlist_keeps_aigc_without_mutating_shared_metadata(self):
captured = {}
original = chick_save._ComfySaveImage.save_images
def host_save(node, images, filename_prefix="ComfyUI", prompt=None,
extra_pnginfo=None, **kwargs):
captured.update(images=images, filename_prefix=filename_prefix,
prompt=prompt, extra_pnginfo=extra_pnginfo, kwargs=kwargs)
return {"ui": {"images": [{"filename": "carrier.png"}]}}
chick_save._ComfySaveImage.save_images = host_save
try:
node = plugin.NODE_CLASS_MAPPINGS["ChickSaveImage"]()
prompt = {"2": {"class_type": "ChickHideNode",
"inputs": {"password": "secret-password"}}}
metadata = {
"workflow": {"nodes": [{"id": 2, "widgets_values": ["secret-password"]}]},
"AIGC": {"Label": "1"},
}
before_prompt, before_metadata = copy.deepcopy(prompt), copy.deepcopy(metadata)
result = node.save_images(["pixels"], "carrier", prompt, metadata)
finally:
chick_save._ComfySaveImage.save_images = original
self.assertEqual(result["ui"]["images"][0]["filename"], "carrier.png")
self.assertEqual(captured["prompt"], None)
self.assertEqual(captured["extra_pnginfo"], {"AIGC": {"Label": "1"}})
self.assertNotIn("secret-password", repr(captured["extra_pnginfo"]))
self.assertEqual(prompt, before_prompt)
self.assertEqual(metadata, before_metadata)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
import math
import unittest
from plugin_loader import plugin
class RegistrationTests(unittest.TestCase):
def test_independent_nodes(self):
self.assertEqual(set(plugin.NODE_CLASS_MAPPINGS), {"ChickHideNode", "ChickSaveImage", "ChickRestoreImage", "NotifyMeNode"})
self.assertEqual(set(plugin.NODE_DISPLAY_NAME_MAPPINGS), set(plugin.NODE_CLASS_MAPPINGS))
def test_chick_node_is_independently_registered(self):
node = plugin.NODE_CLASS_MAPPINGS["ChickHideNode"]()
self.assertEqual(node.RETURN_NAMES, ("chick_image",))
schema = node.INPUT_TYPES()
self.assertEqual(set(schema["optional"]), {"images", "audio"})
self.assertEqual(list(schema["required"]), ["password", "title", "fps", "compress", "combine_video"])
self.assertEqual(schema["required"]["fps"][1]["default"], 16)
self.assertTrue(schema["required"]["combine_video"][1]["default"])
def test_private_saver_is_independently_registered(self):
node = plugin.NODE_CLASS_MAPPINGS["ChickSaveImage"]()
schema = node.INPUT_TYPES()
self.assertNotIn("prompt", schema.get("hidden", {}))
self.assertEqual(schema["hidden"]["extra_pnginfo"], "EXTRA_PNGINFO")
def test_notice_runs_on_every_queue(self):
node = plugin.NODE_CLASS_MAPPINGS["NotifyMeNode"]
self.assertTrue(math.isnan(node.IS_CHANGED()))
self.assertTrue(node.OUTPUT_NODE)
self.assertEqual(node.RETURN_TYPES, ("*",))
self.assertEqual(node.RETURN_NAMES, ("passthrough",))
schema = node.INPUT_TYPES()
self.assertEqual(set(schema["required"]), {"callback_url"})
self.assertEqual(schema["optional"]["msg"], ("STRING", {"forceInput": True}))
self.assertEqual(schema["optional"]["passthrough"], ("*", {"forceInput": True}))
self.assertNotIn("allow_localhost", schema["optional"])
self.assertEqual(schema["optional"]["id"][0], "STRING")
self.assertEqual(schema["optional"]["id"][1]["default"], "")
self.assertFalse(schema["optional"]["id"][1].get("forceInput", False))
widgets = [key for group in ("required", "optional") for key, field in schema[group].items()
if not field[1].get("forceInput", False)]
self.assertEqual(widgets, ["callback_url", "id"])
+132
View File
@@ -0,0 +1,132 @@
import asyncio
import importlib.util
from io import BytesIO
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
from aiohttp.test_utils import TestClient, TestServer
from aiohttp import TCPConnector
from PIL import Image
from plugin_loader import plugin, ROOT
from callback_fixture import routed_callback, TLSFixture
from chicktools.chick.codec import encode_bytes
spec = importlib.util.spec_from_file_location("local_callback_server", ROOT / "tools/local_callback_server.py")
receiver = importlib.util.module_from_spec(spec)
spec.loader.exec_module(receiver)
class LocalReceiverTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temp = tempfile.TemporaryDirectory()
self.log_path = Path(self.temp.name) / "received.jsonl"
self.state = receiver.ReceiverState(log_path=self.log_path)
self.tls = TLSFixture()
self.addCleanup(self.tls.close)
test_server = TestServer(receiver.create_app(self.state))
await test_server.start_server(ssl=self.tls.server_context)
self.client = TestClient(test_server, connector=TCPConnector(ssl=self.tls.client_context))
await self.client.start_server()
self.base = str(self.client.make_url("/")).rstrip("/")
self.callback = f"https://notify.bro3.net:{self.client.server.port}/callback"
fixture = routed_callback(self.client.server.port, self.tls.cert)
fixture.__enter__()
self.addCleanup(fixture.__exit__, None, None, None)
async def asyncTearDown(self):
await self.client.close()
self.temp.cleanup()
async def test_node_succeeds_after_text_is_logged_and_published(self):
node = plugin.NODE_CLASS_MAPPINGS["NotifyMeNode"]()
message = '中文 🐥\n <script>message</script> '
ws = await self.client.ws_connect("/ws")
self.addAsyncCleanup(ws.close)
await ws.receive_json(timeout=2)
for text in (message, ""):
payload = object()
result = await asyncio.to_thread(node.notify, self.callback, text,
id="000123", passthrough=payload)
self.assertIs(result[0], payload)
update = await ws.receive_json(timeout=2)
self.assertEqual(update["messages"][-1]["msg"], text)
self.assertEqual(update["messages"][-1]["id"], "000123")
response = await self.client.get("/events")
data = await response.json()
records = [json.loads(line) for line in self.log_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(data["received"], 2)
self.assertEqual(data["messages"], records)
self.assertEqual([record["msg"] for record in records], [message, ""])
self.assertEqual([record["id"] for record in records], ["000123", "000123"])
self.assertEqual(len({record["event_id"] for record in records}), 2)
restored = receiver.ReceiverState(log_path=self.log_path)
self.assertEqual(list(restored.messages), records)
response = await self.client.get("/")
self.assertEqual(response.status, 200)
self.assertIn("NotifyMe", await response.text())
async def test_invalid_request_is_not_recorded(self):
for path, body, content_type, status in (
("/callback", b'{"msg":123}', "application/json", 200),
("/callback", b'{"id":123,"msg":"x"}', "application/json", 200),
("/callback", b'{"id":null,"msg":"x"}', "application/json", 200),
("/callback", b'invalid', "application/json", 200),
("/callback", b'{"msg":"x"}', "text/plain", 200),
("/other", b'{"msg":"x"}', "application/json", 404),
):
with self.subTest(path=path, body=body):
response = await self.client.post(path, data=body, headers={"Content-Type": content_type})
self.assertEqual(response.status, status)
self.assertEqual(self.state.received, 0)
self.assertFalse(self.log_path.exists())
async def test_legacy_callbacks_and_logs_have_empty_task_id(self):
old_record = {"id": "legacy-event", "received_at": "2026-01-01", "msg": "old"}
self.log_path.write_text(json.dumps(old_record) + "\n", encoding="utf-8")
restored = receiver.ReceiverState(log_path=self.log_path)
self.assertEqual(restored.messages[0]["event_id"], "legacy-event")
self.assertEqual(restored.messages[0]["id"], "")
response = await self.client.post("/callback", json={"msg": "legacy"})
self.assertEqual(response.status, 200)
self.assertEqual(self.state.messages[-1]["id"], "")
self.assertEqual(self.state.messages[-1]["msg"], "legacy")
async def test_log_failure_still_returns_200_without_recording_a_message(self):
self.state.log_path = Path(self.temp.name)
response = await self.client.post("/callback", json={"id": "test-log-failure", "msg": "message"})
self.assertEqual(response.status, 200)
self.assertIn("could not save the message log", await response.text())
self.assertEqual(self.state.received, 0)
self.assertEqual(list(self.state.messages), [])
async def test_publish_failure_still_returns_200_and_keeps_recorded_message(self):
with patch.object(self.state, "publish", new=AsyncMock(side_effect=RuntimeError("test publish failure"))):
with self.assertLogs(level="ERROR"):
response = await self.client.post("/callback", json={"msg": "retained"})
self.assertEqual(response.status, 200)
self.assertFalse((await response.json())["ok"])
self.assertEqual(self.state.messages[-1]["msg"], "retained")
self.assertEqual(json.loads(self.log_path.read_text(encoding="utf-8"))["msg"], "retained")
async def test_events_decode_saved_carrier_and_serve_media(self):
carrier_dir = Path(self.temp.name) / "carriers"
media_dir = Path(self.temp.name) / "decoded"
carrier_dir.mkdir()
source = Image.new("RGB", (8, 8), (12, 34, 56))
raw = BytesIO()
source.save(raw, format="PNG")
carrier = encode_bytes(raw.getvalue(), "png", password="demo", compress=2)
carrier.save(carrier_dir / "video-demo_00001_.png", format="PNG")
self.state.carrier_dir = carrier_dir
self.state.media_dir = media_dir
self.state.password = "demo"
response = await self.client.get("/events")
result = await response.json()
self.assertEqual(result["media"][0]["extension"], "png")
self.assertEqual(result["media"][0]["size"], len(raw.getvalue()))
response = await self.client.get(result["media"][0]["url"])
self.assertEqual(await response.read(), raw.getvalue())
+223
View File
@@ -0,0 +1,223 @@
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
import socket
import ssl
import threading
import time
import unittest
from unittest.mock import patch
import requests
from plugin_loader import plugin
from callback_fixture import routed_callback, TLSFixture
from chicktools.nodes.notify_me import NotifyMeNode
from chicktools.utils.callback_http import post_completion, _PinnedAdapter
from chicktools.utils.url_security import CallbackTarget
CALLBACK = "https://callback.example/finished"
MESSAGE = ' 完成通知 🐥\n第二行\t"quoted" \\ '
class Receiver(BaseHTTPRequestHandler):
calls = []
def log_message(self, *args):
pass
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
self.calls.append((self.path, self.headers["Host"], self.headers["Content-Type"], json.loads(body)))
if self.path == "/slow":
time.sleep(0.2)
status = int(self.path[1:]) if self.path[1:].isdigit() else 200
self.send_response(status)
if status == 302:
self.send_header("Location", "/200")
self.end_headers()
try:
self.wfile.write(b"OK") # deliberately plain text, never JSON
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, ssl.SSLEOFError):
pass
class NotifyTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Receiver)
cls.tls = TLSFixture()
cls.server.socket = cls.tls.server_context.wrap_socket(cls.server.socket, server_side=True)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join()
cls.tls.close()
def setUp(self):
self.node = NotifyMeNode()
Receiver.calls.clear()
self.trust = self.tls.trust()
self.trust.__enter__()
self.addCleanup(self.trust.__exit__, None, None, None)
def local_target(self, path="/200"):
port = self.server.server_port
# This transport fixture maps a synthetic hostname to its local receiver.
return CallbackTarget("https://callback.example" + path, "callback.example", "127.0.0.1", port, "https", "callback.example")
def test_real_post_preserves_text_and_accepts_plain_text_200(self):
payload = object()
with patch("chicktools.nodes.notify_me.validate_callback_url", return_value=self.local_target()):
result = self.node.notify(CALLBACK, MESSAGE, passthrough=payload)
self.assertIs(result[0], payload)
self.assertEqual(Receiver.calls, [("/200", "callback.example", "application/json", {"id": "", "msg": MESSAGE})])
def test_task_id_is_preserved_and_distinguishes_notifications(self):
url = f"https://notify.bro3.net:{self.server.server_port}/200"
task_ids = ["000123", ' 任务 🐥\t"quoted" ', "task-002", ""]
with routed_callback(self.server.server_port):
for task_id in task_ids:
self.node.notify(url, MESSAGE, id=task_id)
self.assertEqual([call[-1] for call in Receiver.calls],
[{"id": task_id, "msg": MESSAGE} for task_id in task_ids])
def test_non_text_id_never_sends(self):
for task_id in (None, 123, True, [], {}, b"bytes"):
with self.subTest(task_id=task_id), patch("chicktools.nodes.notify_me.post_completion") as post:
with self.assertRaisesRegex(Exception, r"^Please enter a valid task ID \(id must be text\)$") as raised:
self.node.notify(CALLBACK, MESSAGE, id=task_id)
self.assertIs(type(raised.exception), Exception)
post.assert_not_called()
def test_passthrough_preserves_arbitrary_values_by_identity(self):
for payload in (None, 42, "text", b"bytes", [], {}, object()):
with self.subTest(payload_type=type(payload).__name__), patch(
"chicktools.nodes.notify_me.validate_callback_url", return_value=self.local_target()
):
result = self.node.notify(CALLBACK, passthrough=payload)
self.assertEqual(len(result), 1)
self.assertIs(result[0], payload)
def test_unapproved_destinations_never_send(self):
for url in (f"https://127.0.0.1:{self.server.server_port}/200",
"https://callback.example/done", "https://a.notify.bro3.net"):
with self.subTest(url=url), patch("chicktools.nodes.notify_me.post_completion") as post:
with self.assertRaisesRegex(Exception, "approved allowlist") as raised:
self.node.notify(url, MESSAGE)
self.assertIs(type(raised.exception), Exception)
post.assert_not_called()
self.assertEqual(Receiver.calls, [])
def test_optional_message_and_blank_text_still_send(self):
for kwargs in ({}, {"msg": ""}, {"msg": " \n\t"}):
with self.subTest(kwargs=kwargs), patch("chicktools.nodes.notify_me.validate_callback_url", return_value=self.local_target()):
self.assertEqual(self.node.notify(CALLBACK, **kwargs), (None,))
self.assertEqual(Receiver.calls[-1][-1], {"id": "", "msg": kwargs.get("msg", "")})
self.assertEqual(len(Receiver.calls), 3)
def test_non_text_message_never_sends(self):
for msg in (None, 123, True, [], {}, b"bytes"):
with self.subTest(msg=msg), patch("chicktools.nodes.notify_me.post_completion") as post:
with self.assertRaisesRegex(Exception, r"^Please enter a valid message \(msg must be text\)$") as raised:
self.node.notify(CALLBACK, msg)
self.assertIs(type(raised.exception), Exception)
post.assert_not_called()
def test_exact_status_and_no_redirect(self):
for status in (201, 202, 204, 302, 400, 500):
with self.subTest(status=status):
Receiver.calls.clear()
with patch("chicktools.nodes.notify_me.validate_callback_url", return_value=self.local_target(f"/{status}")):
with self.assertRaisesRegex(Exception, rf"^Callback request failed \(HTTP {status}\)$") as raised:
self.node.notify(CALLBACK, MESSAGE)
self.assertIs(type(raised.exception), Exception)
self.assertEqual(len(Receiver.calls), 1)
def test_invalid_callback_never_sends(self):
for callback in ("", " ", None, 123):
with self.subTest(callback=callback), patch("chicktools.nodes.notify_me.post_completion") as post:
with self.assertRaisesRegex(Exception, r"^Please enter a valid callback URL$") as raised:
self.node.notify(callback, MESSAGE)
self.assertIs(type(raised.exception), Exception)
post.assert_not_called()
def test_security_errors_use_plain_exception(self):
for url in ["file:///a", "http://127.0.0.1", "http://[broken", "http://a:wrong"]:
with self.subTest(url=url), patch("chicktools.nodes.notify_me.post_completion") as post:
with self.assertRaisesRegex(Exception, r"^Please enter a valid callback URL: ") as raised:
self.node.notify(url, MESSAGE)
self.assertIs(type(raised.exception), Exception)
post.assert_not_called()
def test_network_errors_and_timeout_contract(self):
for error in (requests.Timeout, requests.ConnectionError, requests.exceptions.SSLError):
with self.subTest(error=error), patch("chicktools.nodes.notify_me.validate_callback_url", return_value=self.local_target()), patch(
"chicktools.nodes.notify_me.post_completion", side_effect=error("secret query token")
) as post:
with self.assertRaisesRegex(Exception, rf"^Callback request failed \({error.__name__}\)$") as raised:
self.node.notify(CALLBACK, MESSAGE)
self.assertIs(type(raised.exception), Exception)
self.assertEqual(post.call_args.kwargs["timeout"], 15)
def test_real_read_timeout(self):
with self.assertRaises(requests.Timeout):
post_completion(self.local_target("/slow"), MESSAGE, timeout=0.04)
def test_real_connection_failure(self):
# A bound socket that never listens reliably refuses a TCP connection.
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
target = CallbackTarget(CALLBACK, "callback.example", "127.0.0.1", sock.getsockname()[1], "https", "callback.example")
with self.assertRaises(requests.ConnectionError):
post_completion(target, MESSAGE, timeout=0.5)
def test_transport_ignores_ambient_proxy_and_never_resolves_original_host(self):
original = socket.getaddrinfo
queried = []
def resolve(host, *args, **kwargs):
queried.append(host)
return original(host, *args, **kwargs)
with patch.dict(os.environ, {"HTTPS_PROXY": "http://127.0.0.1:1", "ALL_PROXY": "http://127.0.0.1:1"}), patch("socket.getaddrinfo", side_effect=resolve):
self.assertEqual(post_completion(self.local_target(), MESSAGE), 200)
self.assertTrue(queried)
self.assertEqual(set(queried), {"127.0.0.1"})
def test_tls_uses_original_hostname_for_certificate_and_sni(self):
target = CallbackTarget("https://callback.example/done", "callback.example", "8.8.8.8", 443, "https", "callback.example")
adapter = _PinnedAdapter(target)
self.addCleanup(adapter.close)
self.assertEqual(adapter.pool.host, "8.8.8.8")
self.assertEqual(adapter.pool.assert_hostname, "callback.example")
self.assertEqual(adapter.pool.conn_kw["server_hostname"], "callback.example")
def test_http_rejected_before_dns_or_transport(self):
with patch("socket.getaddrinfo") as dns, patch("chicktools.nodes.notify_me.post_completion") as post:
with self.assertRaisesRegex(Exception, "must use HTTPS"):
self.node.notify("http://notify.bro3.net/callback", MESSAGE)
dns.assert_not_called()
post.assert_not_called()
for url, scheme in (("http://notify.bro3.net/", "http"),
("http://notify.bro3.net/", "https"),
("https://notify.bro3.net/", "http")):
with patch("urllib3.util.connection.create_connection") as connect:
with self.assertRaisesRegex(ValueError, "requires HTTPS"):
post_completion(CallbackTarget(url, "notify.bro3.net", "127.0.0.1", self.server.server_port,
scheme, "notify.bro3.net"), MESSAGE)
connect.assert_not_called()
self.assertEqual(Receiver.calls, [])
def test_real_tls_rejects_wrong_hostname_and_untrusted_certificate(self):
target = CallbackTarget("https://wrong.example/200", "wrong.example", "127.0.0.1",
self.server.server_port, "https", "wrong.example")
with self.assertRaises(requests.exceptions.SSLError):
post_completion(target, MESSAGE)
import certifi
with patch("requests.adapters.DEFAULT_CA_BUNDLE_PATH", certifi.where()), patch("requests.certs.where", return_value=certifi.where()):
with self.assertRaises(requests.exceptions.SSLError):
post_completion(self.local_target(), MESSAGE)
self.assertEqual(Receiver.calls, [])
+161
View File
@@ -0,0 +1,161 @@
import copy
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import importlib.util
import json
from pathlib import Path
import tempfile
import threading
import unittest
from plugin_loader import plugin, ROOT
spec = importlib.util.spec_from_file_location("prepare_workflow", ROOT / "tools/prepare_workflow.py")
prepare = importlib.util.module_from_spec(spec)
spec.loader.exec_module(prepare)
def schema(save_outputs=None, save_names=None):
result = {}
for name, cls in plugin.NODE_CLASS_MAPPINGS.items():
result[name] = {"input": cls.INPUT_TYPES(), "output": list(cls.RETURN_TYPES),
"output_name": list(cls.RETURN_NAMES), "output_node": getattr(cls, "OUTPUT_NODE", False)}
result["EmptyImage"] = {"input": {"required": {key: ["INT"] for key in ("width", "height", "batch_size", "color")}},
"output": ["IMAGE"], "output_name": ["IMAGE"]}
result["ChickSaveImage"] = {"input": {"required": {"images": ["IMAGE"], "filename_prefix": ["STRING", {"default": "private-default"}]}},
"output": save_outputs or [], "output_name": save_names or [], "output_node": True}
result["PrimitiveStringMultiline"] = {"input": {"required": {"value": ["STRING", {"default": "private-default"}]}},
"output": ["STRING"], "output_name": ["STRING"]}
return result
class PreparationTests(unittest.TestCase):
def test_notifications_do_not_depend_on_save_outputs(self):
for types, names in (([], []), (["IMAGE"], ["images"]), (["STRING"], ["filename"])):
info = schema(types, names)
report = prepare.analyze(info)
self.assertTrue(report["carrier_workflow_ready"])
self.assertTrue(report["callback_wiring_ready"])
prompts = prepare.build_prompts(info, report)
self.assertEqual(set(prompts), {"carrier-smoke", "notify-empty-smoke", "notify-text-smoke"})
self.assertEqual(prompts["carrier-smoke"]["3"]["class_type"], "ChickSaveImage")
self.assertEqual(prompts["notify-empty-smoke"]["1"]["inputs"], {"callback_url": "", "id": "demo-task-001"})
def test_text_source_slots_and_canvas_preserve_wiring(self):
for types, names, text_slot in ((["STRING"], ["STRING"], 0),
(["INT", "STRING"], ["length", "text"], 1)):
info = schema()
info["PrimitiveStringMultiline"].update(output=types, output_name=names)
report = prepare.analyze(info)
prompt = prepare.build_prompts(info, report)["notify-text-smoke"]
self.assertEqual(prompt["2"]["inputs"], {
"callback_url": "", "id": "demo-task-001", "msg": ["1", text_slot], "passthrough": ["1", text_slot]})
canvas = prepare.build_canvas(prompt, info)
nodes = {node["id"]: node for node in canvas["nodes"]}
self.assertEqual(nodes[2]["widgets_values"], ["", "demo-task-001"])
for id, source, slot, target, port, kind in canvas["links"]:
self.assertIn(id, nodes[source]["outputs"][slot]["links"])
self.assertEqual(nodes[target]["inputs"][port]["link"], id)
self.assertEqual(nodes[source]["outputs"][slot]["type"], kind)
self.assertIn(nodes[target]["inputs"][port]["type"], (kind, "*"))
self.assertEqual(nodes[2]["outputs"], [{"name": "passthrough", "type": "*", "slot_index": 0, "links": []}])
empty = prepare.build_canvas(prepare.build_prompts(info, report)["notify-empty-smoke"], info)
self.assertEqual(empty["nodes"][0]["inputs"], [
{"name": "msg", "type": "STRING", "link": None},
{"name": "passthrough", "type": "*", "link": None}])
with tempfile.TemporaryDirectory() as temp:
prepare.write_preparation(info, Path(temp))
self.assertEqual(len(list(Path(temp).glob("*.json"))), 7)
for file in Path(temp).glob("*.json"):
self.assertNotIn("private-default", file.read_text())
def test_missing_media_does_not_block_independent_notifications(self):
for name in ("ChickHideNode", "ChickSaveImage", "EmptyImage"):
info = schema()
del info[name]
report = prepare.analyze(info)
self.assertFalse(report["carrier_workflow_ready"])
self.assertTrue(report["callback_wiring_ready"])
self.assertEqual(set(prepare.build_prompts(info, report)), {"notify-empty-smoke", "notify-text-smoke"})
info = schema()
info["ChickSaveImage"]["input"]["required"]["private_key"] = ["STRING"]
self.assertFalse(prepare.analyze(info)["carrier_workflow_ready"])
def test_legacy_or_incompatible_notification_schema_is_blocked(self):
base = schema()
bad = []
missing = copy.deepcopy(base)
del missing["NotifyMeNode"]
bad.append(missing)
for change in ("legacy", "required_msg", "wrong_type", "missing_id", "wrong_id", "missing_passthrough", "wrong_passthrough",
"list", "output", "nonterminal"):
info = copy.deepcopy(base)
node = info["NotifyMeNode"]
if change == "legacy":
node["input"]["required"].update(image=["IMAGE"], preview_url=["STRING"])
elif change == "required_msg":
node["input"]["required"]["msg"] = node["input"]["optional"].pop("msg")
elif change == "wrong_type":
node["input"]["optional"]["msg"] = ["IMAGE"]
elif change == "missing_id":
del node["input"]["optional"]["id"]
elif change == "wrong_id":
node["input"]["optional"]["id"] = ["INT"]
elif change == "missing_passthrough":
del node["input"]["optional"]["passthrough"]
elif change == "wrong_passthrough":
node["input"]["optional"]["passthrough"] = ["IMAGE"]
elif change == "list":
node["is_input_list"] = True
elif change == "output":
node["output"] = ["IMAGE"]
else:
node["output_node"] = False
bad.append(info)
for info in bad:
report = prepare.analyze(info)
self.assertTrue(report["carrier_workflow_ready"])
self.assertFalse(report["callback_wiring_ready"])
self.assertEqual(set(prepare.build_prompts(info, report)), {"carrier-smoke"})
def test_missing_or_ambiguous_text_source_still_allows_empty_message(self):
for change in ("missing", "multiple", "list", "extra"):
info = schema()
node = info["PrimitiveStringMultiline"]
if change == "missing":
del info["PrimitiveStringMultiline"]
elif change == "multiple":
node["output"] = ["STRING", "STRING"]
elif change == "list":
node["output_is_list"] = [True]
else:
node["input"]["required"]["private_key"] = ["STRING"]
report = prepare.analyze(info)
self.assertTrue(report["callback_wiring_ready"])
self.assertFalse(report["text_callback_workflow_ready"])
self.assertEqual(set(prepare.build_prompts(info, report)), {"carrier-smoke", "notify-empty-smoke"})
def test_live_probe_only_reads_schema(self):
requests = []
info = schema()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
requests.append(("GET", self.path))
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(info).encode())
def do_POST(self):
requests.append(("POST", self.path))
self.send_error(405)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
loaded = prepare.read_info(f"http://127.0.0.1:{server.server_port}")
self.assertTrue(prepare.analyze(loaded)["carrier_workflow_ready"])
self.assertEqual(requests, [("GET", "/object_info")])
finally:
server.shutdown()
server.server_close()
thread.join()
+94
View File
@@ -0,0 +1,94 @@
import importlib.util
import unittest
from unittest.mock import patch
from aiohttp.test_utils import TestClient, TestServer
from plugin_loader import ROOT
spec = importlib.util.spec_from_file_location("review_receiver", ROOT / "tools/review_receiver/server.py")
receiver = importlib.util.module_from_spec(spec)
spec.loader.exec_module(receiver)
class ReviewReceiverTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.app = receiver.create_app()
self.client = TestClient(TestServer(self.app))
await self.client.start_server()
self.addAsyncCleanup(self.client.close)
async def new_session(self):
response = await self.client.post("/api/sessions")
self.assertEqual(response.status, 200)
data = await response.json()
self.assertTrue(data["callback_url"].startswith("https://notify.bro3.net/callback/"))
return data["token"]
async def test_sessions_isolate_exact_text_and_delete_results(self):
first, second = await self.new_session(), await self.new_session()
body = {"id": "000123", "msg": ' 中文 🐥\n<script>alert(1)</script> '}
for _ in range(2):
response = await self.client.post(f"/callback/{first}", json=body)
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())["received"], body)
response = await self.client.get(f"/api/sessions/{first}")
data = await response.json()
self.assertEqual(data["received"], 2)
self.assertEqual([{k: event[k] for k in body} for event in data["events"]], [body, body])
response = await self.client.get(f"/api/sessions/{second}")
self.assertEqual((await response.json())["events"], [])
response = await self.client.delete(f"/api/sessions/{first}")
self.assertEqual(response.status, 200)
for path in ("/api/sessions/", "/callback/"):
response = await (self.client.get(path + first) if path.startswith("/api") else self.client.post(path + first, json=body))
self.assertEqual(response.status, 404)
async def test_empty_and_omitted_optional_fields(self):
token = await self.new_session()
for body in ({}, {"id": "test"}, {"msg": ""}):
response = await self.client.post(f"/callback/{token}", json=body)
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())["received"], {"id": body.get("id", ""), "msg": ""})
async def test_bad_requests_do_not_create_events(self):
token = await self.new_session()
for body in ([], {"id": 12}, {"msg": None}, {"extra": "x"}):
response = await self.client.post(f"/callback/{token}", json=body)
self.assertEqual(response.status, 400)
response = await self.client.post(f"/callback/{token}", data="invalid", headers={"Content-Type": "application/json"})
self.assertEqual(response.status, 400)
response = await self.client.post(f"/callback/{token}", data="text")
self.assertEqual(response.status, 415)
response = await self.client.post(f"/callback/{token}", json={"msg": "x" * 17000})
self.assertEqual(response.status, 413)
response = await self.client.get(f"/api/sessions/{token}")
self.assertEqual((await response.json())["received"], 0)
async def test_expiry_capacity_and_bounded_memory(self):
token = await self.new_session()
for i in range(25):
response = await self.client.post(f"/callback/{token}", json={"id": str(i), "msg": "x" * 8000})
self.assertEqual(response.status, 200)
session = self.app[receiver.STATE][token]
self.assertLessEqual(session["bytes"], receiver.MAX_BYTES_PER_SESSION)
self.assertLessEqual(len(session["events"]), receiver.MAX_EVENTS)
self.assertEqual(session["received"], 25)
self.assertEqual(session["events"][-1][0]["id"], "24")
with patch.object(receiver, "MAX_SESSIONS", 1):
response = await self.client.post("/api/sessions")
self.assertEqual(response.status, 429)
session["expires"] = 0
response = await self.client.get(f"/api/sessions/{token}")
self.assertEqual(response.status, 404)
self.assertNotIn(token, self.app[receiver.STATE])
async def test_page_assets_health_and_origin(self):
for path in ("/", "/app.js", "/style.css", "/health"):
response = await self.client.get(path)
self.assertEqual(response.status, 200)
self.assertEqual(response.headers["Cache-Control"], "no-store")
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
response = await self.client.get("/api/sessions")
self.assertEqual(response.status, 405) # No listing of other sessions.
response = await self.client.post("/api/sessions", headers={"Origin": "https://unrelated.example"})
self.assertEqual(response.status, 403)
+79
View File
@@ -0,0 +1,79 @@
import socket
import unittest
from unittest.mock import patch
from plugin_loader import plugin
from chicktools.utils.callback_policy import ALLOWED_CALLBACK_HOSTS
from chicktools.utils.url_security import CallbackURLValidationError, validate_callback_url
def answer(address):
return (socket.AF_INET6 if ":" in address else socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, 443))
class URLTests(unittest.TestCase):
def test_only_reviewed_host_is_allowed(self):
self.assertEqual(ALLOWED_CALLBACK_HOSTS, frozenset({"notify.bro3.net"}))
for host in ("notify.bro3.net", "NOTIFY.BRO3.NET", "notify.bro3.net."):
for scheme in ("https",):
with self.subTest(host=host, scheme=scheme), patch("socket.getaddrinfo", return_value=[answer("8.8.8.8")]):
target = validate_callback_url(f"{scheme}://{host}:8443/path?q=1")
self.assertEqual(target.hostname, "notify.bro3.net")
self.assertEqual(target.url, f"{scheme}://notify.bro3.net:8443/path?q=1")
def test_unlisted_hosts_are_rejected_before_dns(self):
hosts = ("callback.example", "bro3.net", "a.notify.bro3.net", "notify.bro3.net.evil.example",
"evilnotify.bro3.net", "notify-bro3.net", "notify.bro3.net..", "notify%2ebro3.net",
"notifу.bro3.net", "8.8.8.8", "[2606:4700:4700::1111]", "2130706433")
for host in hosts:
with self.subTest(host=host), patch("socket.getaddrinfo") as resolver:
with self.assertRaises(CallbackURLValidationError):
validate_callback_url("https://" + host + "/callback")
resolver.assert_not_called()
def test_loopback_and_private_literals_are_rejected(self):
for host in ("127.0.0.1", "localhost", "localhost.", "a.localhost", "10.0.0.1", "172.16.0.1",
"192.168.1.1", "169.254.169.254", "100.64.0.1", "198.18.0.1", "0.0.0.0",
"224.0.0.1", "[::1]", "[::]", "[fc00::1]", "[fe80::1]", "[::ffff:127.0.0.1]",
"[64:ff9b::7f00:1]", "metadata.google.internal"):
with self.subTest(host=host), patch("socket.getaddrinfo") as resolver:
with self.assertRaises(CallbackURLValidationError):
validate_callback_url("https://" + host)
resolver.assert_not_called()
def test_malformed_urls(self):
for url in ("ftp://notify.bro3.net", "file:///x", "gopher://notify.bro3.net", "http://",
"https://notify.bro3.net:0", "https://notify.bro3.net:99999", "https://notify.bro3.net:wrong",
"http://[broken", "https://user:pass@notify.bro3.net", "https://notify.bro3.net@evil.example",
"https://notify.bro3.net/#fragment", "https://notify.bro3.net/a\nb", "https://notify.bro3.net\\foo"):
with self.subTest(url=url), self.assertRaises(CallbackURLValidationError):
validate_callback_url(url)
def test_allowed_host_resolving_to_private_or_special_address_is_rejected(self):
for ip in ("127.0.0.1", "10.0.0.1", "169.254.169.254", "100.64.0.1", "198.18.0.1",
"224.0.0.1", "::1", "fc00::1", "fe80::1", "::ffff:127.0.0.1", "64:ff9b::7f00:1"):
with self.subTest(ip=ip), patch("socket.getaddrinfo", return_value=[answer(ip)]):
with self.assertRaisesRegex(CallbackURLValidationError, "public addresses"):
validate_callback_url("https://notify.bro3.net/callback")
def test_dns_mixed_private_answers_rejected(self):
with patch("socket.getaddrinfo", return_value=[answer("8.8.8.8"), answer("127.0.0.1")]):
with self.assertRaisesRegex(CallbackURLValidationError, "public addresses"):
validate_callback_url("https://notify.bro3.net")
def test_dns_failure_and_empty_answers_fail_closed(self):
with patch("socket.getaddrinfo", side_effect=socket.gaierror("failed")):
with self.assertRaisesRegex(CallbackURLValidationError, "DNS resolution failed"):
validate_callback_url("https://notify.bro3.net")
with patch("socket.getaddrinfo", return_value=[]):
with self.assertRaisesRegex(CallbackURLValidationError, "public addresses"):
validate_callback_url("https://notify.bro3.net")
def test_dns_resolved_once_and_saved(self):
for address in ("8.8.8.8", "2606:4700:4700::1111"):
with patch("socket.getaddrinfo", return_value=[answer(address)]) as resolver:
target = validate_callback_url("https://notify.bro3.net/finished?q=1")
resolver.assert_called_once()
self.assertEqual(target.address, address)
self.assertEqual(target.hostname, "notify.bro3.net")
self.assertEqual(target.url, "https://notify.bro3.net/finished?q=1")
+56
View File
@@ -0,0 +1,56 @@
"""Build the small model-free video and callback demonstration workflow."""
from __future__ import annotations
import json
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
from prepare_workflow import build_canvas, read_info # noqa: E402
def main() -> None:
info = read_info("http://127.0.0.1:8188")
prompt = {
"1": {"class_type": "ImageBatchTestPattern", "inputs": {
"batch_size": 8, "start_from": 0, "text_x": 5, "text_y": 5,
"width": 64, "height": 64, "font": "FreeMono.ttf", "font_size": 12}},
"2": {"class_type": "ChickHideNode", "inputs": {
"password": "demo-password", "title": "ChickTools video demo", "fps": 8,
"compress": 2, "combine_video": True, "images": ["1", 0]}},
"3": {"class_type": "ChickSaveImage", "inputs": {
"filename_prefix": "ChickTools/video-demo", "images": ["2", 0]}},
"4": {"class_type": "PrimitiveStringMultiline", "inputs": {
"value": "视频载体测试已完成\n这条文本由 NotifyMe 发送到本地结果页。"}},
"5": {"class_type": "NotifyMeNode", "inputs": {
"callback_url": "",
"id": "demo-task-001",
"msg": ["4", 0]}},
}
workflow = build_canvas(prompt, info)
layout = {
1: ([70, 90], [330, 300], "Video test pattern - 8 frames - 64x64"),
2: ([460, 90], [320, 240], "Chick Hide - MP4"),
3: ([850, 90], [340, 350], "Chick Private Save"),
4: ([70, 520], [560, 180], "Message text - STRING"),
5: ([850, 520], [340, 200], "Notify Me"),
}
for node in workflow["nodes"]:
node["pos"], node["size"], node["title"] = layout[node["id"]]
workflow["groups"] = [
{"id": 1, "title": "1. Test frames -> Chick Hide -> MP4 carrier",
"bounding": [40, 20, 1190, 440], "color": "#3b7184", "font_size": 24, "flags": {}},
{"id": 2, "title": "2. Text -> Notify Me -> Callback",
"bounding": [40, 450, 1190, 300], "color": "#596c95", "font_size": 24, "flags": {}},
]
workflow["extra"]["ds"] = {"scale": 0.72, "offset": [0, 0]}
destination = ROOT / "examples" / "chick_notify_video_example.json"
destination.write_text(json.dumps(workflow, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8", newline="\n")
print(destination)
if __name__ == "__main__":
main()
+280
View File
@@ -0,0 +1,280 @@
"""Loopback callback receiver with live carrier previews. Run with ComfyUI's Python."""
from __future__ import annotations
import argparse
import asyncio
from collections import deque
from contextlib import suppress
from datetime import datetime, timezone
from hashlib import sha256
from io import BytesIO
import json
import logging
from pathlib import Path
import sys
from uuid import uuid4
from aiohttp import web
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from chick.codec import CarrierCodecError, decode_bytes
PAGE = """<!doctype html><html lang="zh-CN"><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NotifyMe 本地测试结果</title>
<style>
body{font:16px system-ui;margin:32px auto;max-width:860px;padding:0 20px;background:#f5f6fa;color:#18202b}
article{background:white;padding:20px;margin:16px 0;border:1px solid #e0e5ed;border-radius:10px}
pre{white-space:pre-wrap;overflow-wrap:anywhere}time,.muted{color:#536275;font-size:14px}
code{background:#e9edf4;padding:4px}video,img{display:block;max-width:100%;max-height:480px;margin-top:14px;background:#111}
video{width:100%;min-height:180px}audio{width:100%;margin-top:14px}
button{font:inherit;background:#245dba;color:white;border:0;border-radius:6px;padding:9px 16px;cursor:pointer}
button:disabled{opacity:.6;cursor:wait}.error{color:#a21d2a}a{color:#245dba}
</style>
<h1>本地测试结果</h1><p>回调地址:<code id="url"></code></p>
<p class="muted">测试模式:回调固定返回 HTTP 200,NotifyMe 可继续执行后续节点。处理详情见接收记录和响应正文。</p>
<p>点击开启监听后,新结果会自动追加,正在播放的视频会保持播放。</p>
<button id="listen" type="button" aria-pressed="false">开启监听</button> <span id="connection" role="status">尚未开启监听</span>
<p id="status" class="muted"></p>
<h2>回调内容</h2><div id="messages"><p id="messages-empty" class="muted">等待 NotifyMe 消息…</p></div>
<h2>解码媒体</h2><p class="muted">载体从指定的本地输出目录读取;回调包含任务 id 和文本 msg,两条分支独立执行。</p>
<div id="media"><p id="media-empty" class="muted">等待载体 PNG…</p></div>
<script src="/viewer.js" defer></script></html>"""
def now():
return datetime.now(timezone.utc).isoformat()
class ReceiverState:
def __init__(self, log_path=None, carrier_dir=None, media_dir=None, password=""):
self.log_path = Path(log_path) if log_path is not None else None
self.carrier_dir = Path(carrier_dir).resolve() if carrier_dir is not None else None
self.media_dir = Path(media_dir or ".local/decoded-media").resolve()
self.password = password
self.messages = deque(maxlen=100)
self.received = 0
self.media = {}
self.files = {}
self.clients = set()
self.scan_lock = asyncio.Lock()
self.publish_lock = asyncio.Lock()
self.revision = 0
self.session = uuid4().hex
if self.log_path is not None and self.log_path.is_file():
with self.log_path.open(encoding="utf-8") as log:
for index, line in enumerate(log):
try:
record = json.loads(line)
if not isinstance(record.get("msg"), str):
continue
if "event_id" not in record:
# Earlier logs used id for the receiver's event identity.
record["event_id"] = record.pop("id", sha256(f"{index}:{line}".encode()).hexdigest())
record["id"] = ""
self.messages.append(record)
self.received += 1
except (ValueError, AttributeError):
continue
def snapshot(self):
return {"ok": True, "service": "chicktools-callback-receiver",
"session": self.session, "revision": self.revision,
"received": self.received, "messages": list(self.messages),
"media": list(self.media.values())}
def receive(self, message, task_id=""):
record = {"event_id": uuid4().hex, "id": task_id,
"received_at": now(), "path": "/callback", "msg": message}
if self.log_path is not None:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
with self.log_path.open("a", encoding="utf-8", newline="\n") as log:
log.write(json.dumps(record, ensure_ascii=False) + "\n")
self.messages.append(record)
self.received += 1
self.revision += 1
def _scan(self):
"""Decode off the event loop; publish only complete, stable file versions."""
records, files = [], {}
if self.carrier_dir is None:
return records, files
self.media_dir.mkdir(parents=True, exist_ok=True)
candidates = []
for path in self.carrier_dir.glob("*.png"):
try:
if path.resolve().parent == self.carrier_dir:
stat = path.stat()
candidates.append((stat.st_mtime_ns, path, stat.st_size))
except OSError:
continue
for modified, path, size in sorted(candidates)[-100:]:
key = sha256(f"{path.name}:{modified}:{size}".encode()).hexdigest()
if key in self.media:
continue
record = {"id": key, "source": path.name, "extension": "", "size": 0, "decoded_at": now()}
try:
source = path.read_bytes()
current = path.stat()
if (current.st_mtime_ns, current.st_size) != (modified, size):
continue
with Image.open(BytesIO(source)) as image:
payload, extension = decode_bytes(image, self.password)
extension = extension.lower().lstrip(".")
if extension not in {"png", "jpg", "jpeg", "gif", "webp", "mp4", "webm", "wav", "mp3", "ogg"}:
raise CarrierCodecError("unsupported preview format")
name = f"{key}.{extension}"
target = self.media_dir / name
temporary = self.media_dir / f"{key}.tmp"
temporary.write_bytes(payload)
temporary.replace(target)
original = self.media_dir / f"{key}.carrier.png"
original.write_bytes(source)
record.update(extension=extension, size=len(payload), url="/media/" + name,
carrier_url="/media/" + original.name)
files[name] = target
files[original.name] = original
except (OSError, ValueError) as exc:
# A partially written PNG can be retried after its size or mtime changes.
record["error"] = str(exc)
records.append(record)
return records, files
async def scan(self):
async with self.scan_lock:
try:
records, files = await asyncio.to_thread(self._scan)
except OSError:
return False
for record in records:
self.media[record["id"]] = record
self.files.update(files)
if records:
self.revision += 1
return bool(records)
async def publish(self):
async with self.publish_lock:
snapshot = self.snapshot()
async def send(client):
try:
await asyncio.wait_for(client.send_json(snapshot), timeout=2)
except (OSError, RuntimeError, asyncio.TimeoutError):
self.clients.discard(client)
await asyncio.gather(*(send(client) for client in tuple(self.clients)))
def create_app(state=None, scan_interval=1.0):
state = state or ReceiverState()
app = web.Application(client_max_size=1024 * 1024)
async def home(request):
return web.Response(text=PAGE, content_type="text/html", headers={"Cache-Control": "no-store"})
async def script(request):
return web.FileResponse(Path(__file__).with_name("local_results.js"), headers={"Cache-Control": "no-store"})
async def receive_callback(request):
if request.content_type != "application/json":
raise web.HTTPUnsupportedMediaType(text="use application/json")
try:
body = json.loads((await request.read()).decode("utf-8"))
except (UnicodeError, ValueError):
raise web.HTTPBadRequest(text="request body must be UTF-8 JSON")
if (not isinstance(body, dict) or set(body) not in ({"msg"}, {"id", "msg"})
or not isinstance(body["msg"], str) or not isinstance(body.get("id", ""), str)):
raise web.HTTPBadRequest(text='body must be {"id": string, "msg": string}; id is optional')
try:
state.receive(body["msg"], body.get("id", ""))
except OSError:
raise web.HTTPInternalServerError(text="could not save the message log")
await state.publish()
return web.json_response({"ok": True})
async def callback(request):
# Keep the requested test status even if validation or storage fails.
try:
response = await receive_callback(request)
except web.HTTPException as exc:
response = web.Response(text=exc.text, content_type="text/plain")
except Exception:
logging.exception("Local callback processing failed")
response = web.json_response({"ok": False, "error": "callback processing failed"})
response.set_status(200)
return response
async def events(request):
if await state.scan():
await state.publish()
return web.json_response(state.snapshot(), headers={"Cache-Control": "no-store"})
async def health(request):
return web.json_response({"ok": True, "service": "chicktools-callback-receiver", "received": state.received})
async def media(request):
target = state.files.get(request.match_info["name"])
if target is None or not target.is_file():
raise web.HTTPNotFound()
# Range requests allow seeking without reading the whole video into memory.
return web.FileResponse(target, headers={"X-Content-Type-Options": "nosniff"})
async def websocket(request):
origin = request.headers.get("Origin")
if origin and origin != f"{request.scheme}://{request.host}":
raise web.HTTPForbidden()
ws = web.WebSocketResponse(heartbeat=20, max_msg_size=4096)
await ws.prepare(request)
state.clients.add(ws)
try:
await ws.send_json(state.snapshot())
async for _ in ws:
pass
finally:
state.clients.discard(ws)
return ws
async def watch():
while True:
await asyncio.sleep(scan_interval)
if await state.scan():
await state.publish()
async def lifecycle(app):
await state.scan()
watcher = asyncio.create_task(watch())
yield
watcher.cancel()
with suppress(asyncio.CancelledError):
await watcher
async def shutdown(app):
await asyncio.gather(*(ws.close(code=1001) for ws in tuple(state.clients)))
app.cleanup_ctx.append(lifecycle)
app.on_shutdown.append(shutdown)
app.add_routes([web.get("/", home), web.get("/viewer.js", script),
web.post("/callback", callback), web.get("/events", events),
web.get("/health", health), web.get("/ws", websocket), web.get("/media/{name}", media)])
return app
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="127.0.0.1", choices=["127.0.0.1"])
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--log", type=Path, default=Path(".local/callback-received.jsonl"))
parser.add_argument("--carrier-dir", type=Path, help="directory containing ChickSaveImage carrier PNGs")
parser.add_argument("--password", default="", help="local demo carrier password")
parser.add_argument("--media-dir", type=Path, default=Path(".local/decoded-media"))
args = parser.parse_args()
state = ReceiverState(args.log, args.carrier_dir, args.media_dir, args.password)
print(f"Result viewer: http://{args.host}:{args.port}/", flush=True)
web.run_app(create_app(state), host=args.host, port=args.port, access_log=None)
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
/* Append results without replacing existing media elements or changing playback state. */
(() => {
const byId = id => document.getElementById(id);
const messageCards = new Map();
const mediaCards = new Map();
let session = null;
let revision = -1;
let socket;
let reconnectTimer;
let stopped = false;
let listening = false;
function element(tag, text, className) {
const el = document.createElement(tag);
if (text !== undefined) el.textContent = text;
if (className) el.className = className;
return el;
}
function render(data) {
if (data.session === session && data.revision < revision) return;
session = data.session;
revision = data.revision;
const successes = data.media.filter(item => !item.error).length;
byId('status').textContent = `已接收 ${data.received} 条消息 · ${successes} 个媒体已解码`;
for (const item of data.messages) {
if (messageCards.has(item.event_id)) continue;
const card = element('article');
card.append(element('time', item.received_at), element('pre', JSON.stringify({id: item.id, msg: item.msg}, null, 2)));
messageCards.set(item.event_id, card);
byId('messages').append(card);
byId('messages-empty').hidden = true;
}
for (const item of data.media) {
if (mediaCards.has(item.id)) continue;
const card = element('article');
card.dataset.mediaId = item.id;
card.append(element('strong', item.source));
if (item.error) {
card.append(element('p', `解码失败:${item.error}`, 'error'));
} else {
let player;
if (['mp4', 'webm'].includes(item.extension)) player = element('video');
else if (['wav', 'mp3', 'ogg'].includes(item.extension)) player = element('audio');
else player = element('img');
if (player.tagName === 'IMG') player.alt = '解码后的图片';
else {
player.controls = true;
player.preload = 'metadata';
player.setAttribute('playsinline', '');
}
player.src = item.url;
card.append(element('p', `${item.extension.toUpperCase()} · ${item.size.toLocaleString()} bytes`, 'muted'), player);
const links = element('p');
const decoded = element('a', '下载解码文件');
decoded.href = item.url;
decoded.download = `${item.source}.${item.extension}`;
const carrier = element('a', '查看原始载体 PNG');
carrier.href = item.carrier_url;
carrier.target = '_blank';
carrier.rel = 'noopener';
links.append(decoded, element('span', ' · '), carrier);
card.append(links);
}
mediaCards.set(item.id, card);
byId('media').append(card);
byId('media-empty').hidden = true;
}
}
function disconnect() {
clearTimeout(reconnectTimer);
const previous = socket;
socket = null;
if (previous) previous.close();
}
function connect() {
if (stopped || !listening || socket) return;
byId('connection').textContent = '正在连接…';
const current = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
socket = current;
current.onopen = () => {
if (socket === current) byId('connection').textContent = '正在监听,新结果会自动显示';
};
current.onmessage = event => {
if (socket !== current) return;
try { render(JSON.parse(event.data)); }
catch { byId('connection').textContent = '结果格式异常,请停止后重新开启监听'; }
};
current.onclose = () => {
if (socket !== current) return;
socket = null;
if (stopped || !listening) return;
byId('connection').textContent = '连接中断,正在重连;已加载的视频可继续播放';
reconnectTimer = setTimeout(connect, 2000);
};
}
byId('url').textContent = `${location.origin}/callback`;
byId('listen').addEventListener('click', () => {
listening = !listening;
byId('listen').textContent = listening ? '停止监听' : '开启监听';
byId('listen').setAttribute('aria-pressed', String(listening));
if (listening) connect();
else {
disconnect();
byId('connection').textContent = '已停止监听,已加载的内容会保留';
}
});
window.addEventListener('pagehide', () => {
stopped = true;
disconnect();
});
window.addEventListener('pageshow', event => {
if (event.persisted) { stopped = false; connect(); }
});
// Start only on request. The WebSocket sends both the first snapshot and updates.
})();
+230
View File
@@ -0,0 +1,230 @@
"""Inspect a ComfyUI schema and prepare small workflows without executing them."""
import argparse
import json
from pathlib import Path
from urllib.parse import urlsplit
from urllib.request import urlopen
NODE_TYPES = ("EmptyImage", "ChickHideNode", "ChickSaveImage", "NotifyMeNode", "PrimitiveStringMultiline")
def inputs_of(node):
spec = node.get("input", {})
return {**spec.get("required", {}), **spec.get("optional", {})}
def output_slots(node):
types = node.get("output", [])
names = node.get("output_name", [])
lists = node.get("output_is_list", [])
return [{"slot": i, "type": kind, "name": names[i] if i < len(names) else kind,
"is_list": bool(lists[i]) if i < len(lists) else False}
for i, kind in enumerate(types)]
def image_slot(node):
slots = [out["slot"] for out in output_slots(node) if out["type"] == "IMAGE" and not out["is_list"]]
return slots[0] if len(slots) == 1 else None
def analyze(info):
if not isinstance(info, dict):
raise ValueError("object_info must be a JSON object")
for name in NODE_TYPES:
if name in info and not isinstance(info[name], dict):
raise ValueError("node schemas must be JSON objects")
nodes = {name: info.get(name, {}) for name in NODE_TYPES}
blockers = []
expected = {
"EmptyImage": {"width": "INT", "height": "INT", "batch_size": "INT", "color": "INT"},
"ChickHideNode": {"password": "STRING", "title": "STRING", "fps": "INT",
"compress": [2, 6, 8], "combine_video": "BOOLEAN", "images": "IMAGE"},
"ChickSaveImage": {"images": "IMAGE", "filename_prefix": "STRING"},
}
for name, required in expected.items():
if name not in info:
blockers.append(f"missing node: {name}")
continue
spec = nodes[name]
if spec.get("is_input_list", False):
blockers.append(f"list input contract requires review: {name}")
declared = inputs_of(spec)
for key, kind in required.items():
value = declared.get(key)
if not isinstance(value, (list, tuple)) or not value or value[0] != kind:
blockers.append(f"unexpected input contract: {name}.{key}")
supplied = set(required)
extra = set(spec.get("input", {}).get("required", {})) - supplied
if extra:
blockers.append(f"additional required inputs: {name}: {', '.join(sorted(extra))}")
for name in ("EmptyImage", "ChickHideNode"):
if name in info and image_slot(nodes[name]) is None:
blockers.append(f"expected one scalar IMAGE output: {name}")
if "ChickSaveImage" in info and not nodes["ChickSaveImage"].get("output_node", False):
blockers.append("ChickSaveImage must be an output node")
callback_blockers = []
notify = nodes["NotifyMeNode"]
if "NotifyMeNode" not in info:
callback_blockers.append("missing node: NotifyMeNode")
else:
spec = notify.get("input", {})
for group, key, kind in (("required", "callback_url", "STRING"),
("optional", "id", "STRING"),
("optional", "msg", "STRING"),
("optional", "passthrough", "*")):
field = spec.get(group, {}).get(key)
if not isinstance(field, (list, tuple)) or not field or field[0] != kind:
callback_blockers.append(f"unexpected input contract: NotifyMeNode.{key}")
if set(spec.get("required", {})) != {"callback_url"}:
callback_blockers.append("NotifyMeNode must require only callback_url")
if notify.get("is_input_list", False):
callback_blockers.append("NotifyMeNode must accept scalar inputs")
if (notify.get("output") != ["*"] or notify.get("output_name") != ["passthrough"]):
callback_blockers.append("NotifyMeNode must expose one wildcard passthrough output")
if not notify.get("output_node", False):
callback_blockers.append("NotifyMeNode must remain an output node")
text_blockers = list(callback_blockers)
source = nodes["PrimitiveStringMultiline"]
text_slot = None
if "PrimitiveStringMultiline" not in info:
text_blockers.append("missing example text source: PrimitiveStringMultiline")
else:
value = inputs_of(source).get("value")
if not isinstance(value, (list, tuple)) or not value or value[0] != "STRING":
text_blockers.append("unexpected input contract: PrimitiveStringMultiline.value")
if set(source.get("input", {}).get("required", {})) - {"value"} or source.get("is_input_list", False):
text_blockers.append("unsupported example text source inputs")
slots = [out["slot"] for out in output_slots(source) if out["type"] == "STRING" and not out["is_list"]]
if len(slots) == 1:
text_slot = slots[0]
else:
text_blockers.append("example text source must expose one scalar STRING output")
return {
"nodes": {name: {"registered": name in info, "outputs": output_slots(nodes[name])} for name in NODE_TYPES},
"carrier_workflow_ready": not blockers,
"carrier_blockers": blockers,
"callback_wiring_ready": not callback_blockers,
"callback_blockers": callback_blockers,
"text_callback_workflow_ready": not text_blockers,
"text_callback_blockers": text_blockers,
"text_output_slot": text_slot,
"execution_submitted": False,
"next_checks": ["Fill callback_url and set your task id before running a notification workflow.",
"Check that the receiver gets exactly the connected text in msg, or an empty string without a connection.",
"Download the saved carrier PNG and verify its payload and metadata."]}
def build_prompts(info, report):
results = {}
if report["carrier_workflow_ready"]:
results["carrier-smoke"] = {
"1": {"class_type": "EmptyImage", "inputs": {"width": 16, "height": 16, "batch_size": 1, "color": 255}},
"2": {"class_type": "ChickHideNode", "inputs": {
"images": ["1", image_slot(info["EmptyImage"])], "password": "", "title": "Smoke",
"fps": 16, "compress": 2, "combine_video": True}},
"3": {"class_type": "ChickSaveImage", "inputs": {
"images": ["2", image_slot(info["ChickHideNode"])], "filename_prefix": "ChickTools/smoke"}},
}
if report["callback_wiring_ready"]:
results["notify-empty-smoke"] = {
"1": {"class_type": "NotifyMeNode", "inputs": {"callback_url": "", "id": "demo-task-001"}},
}
if report["text_callback_workflow_ready"]:
results["notify-text-smoke"] = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "Sample message"}},
"2": {"class_type": "NotifyMeNode", "inputs": {
"callback_url": "", "id": "demo-task-001", "msg": ["1", report["text_output_slot"]],
"passthrough": ["1", report["text_output_slot"]]}},
}
return results
def build_canvas(prompt, info):
"""Render the minimal prompt as a canvas without copying schema defaults or secrets."""
widget_order = {"EmptyImage": ["width", "height", "batch_size", "color"],
"LoadImage": ["image"], "ChickRestoreImage": ["password"],
"ImageBatchTestPattern": ["batch_size", "start_from", "text_x", "text_y",
"width", "height", "font", "font_size"],
"ChickHideNode": ["password", "title", "fps", "compress", "combine_video"],
"ChickSaveImage": ["filename_prefix"], "NotifyMeNode": ["callback_url", "id"],
"PrimitiveStringMultiline": ["value"]}
nodes, links = {}, []
for order, (id, data) in enumerate(prompt.items()):
kind, inputs = data["class_type"], data["inputs"]
names = [name for name in widget_order[kind] if name in inputs_of(info[kind])]
ports = []
for key, field in inputs_of(info[kind]).items():
if key not in names or isinstance(inputs.get(key), list):
port = {"name": key, "type": field[0], "link": None}
if key in names:
port["widget"] = {"name": key}
ports.append(port)
nodes[id] = {"id": int(id), "type": kind, "pos": [60 + order * 330, 140], "size": [300, 240],
"flags": {}, "order": order, "mode": 0, "inputs": ports,
"outputs": [{"name": out["name"], "type": out["type"], "slot_index": out["slot"], "links": []}
for out in output_slots(info[kind])],
"properties": {"Node name for S&R": kind},
"widgets_values": [inputs.get(key, "")
if not isinstance(inputs.get(key), list) else "" for key in names]}
for id, data in prompt.items():
for input_index, port in enumerate(nodes[id]["inputs"]):
value = data["inputs"].get(port["name"])
if not isinstance(value, list):
continue
source, slot = value
link_id = len(links) + 1
source_type = nodes[source]["outputs"][slot]["type"]
links.append([link_id, int(source), slot, int(id), input_index, source_type])
port["link"] = link_id
nodes[source]["outputs"][slot]["links"].append(link_id)
return {"last_node_id": max(node["id"] for node in nodes.values()), "last_link_id": len(links),
"nodes": list(nodes.values()), "links": links, "groups": [], "config": {},
"extra": {"ds": {"scale": 0.7, "offset": [0, 0]}}, "version": 0.4}
def read_info(base_url):
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.query or parsed.fragment or parsed.username:
raise ValueError("base URL must be HTTP(S) without credentials, query or fragment")
# Read-only: this tool never posts to /prompt or calls a callback endpoint.
with urlopen(base_url.rstrip("/") + "/object_info", timeout=15) as response:
return json.load(response)
def write_preparation(info, output):
report = analyze(info)
output.mkdir(parents=True, exist_ok=True)
prompts = build_prompts(info, report)
for name, prompt in prompts.items():
for suffix, value in (("api", prompt), ("canvas", build_canvas(prompt, info))):
with (output / f"{name}.{suffix}.json").open("w", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, ensure_ascii=False, indent=2)
handle.write("\n")
with (output / "report.json").open("w", encoding="utf-8", newline="\n") as handle:
json.dump(report, handle, ensure_ascii=False, indent=2)
handle.write("\n")
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--base-url", help="ComfyUI API base URL")
source.add_argument("--object-info", type=Path, help="Saved /object_info response")
parser.add_argument("--out-dir", type=Path, required=True, help="New output directory for this environment")
args = parser.parse_args()
if args.out_dir.exists():
parser.error("choose a new output directory so old workflows cannot be mistaken for fresh results")
try:
info = json.loads(args.object_info.read_text(encoding="utf-8")) if args.object_info else read_info(args.base_url)
report = write_preparation(info, args.out_dir)
except (OSError, ValueError, TypeError) as exc:
parser.exit(1, f"Preparation failed ({type(exc).__name__}); check the schema or connection.\n")
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["carrier_workflow_ready"] and report["callback_wiring_ready"] else 2
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py index.html app.js style.css ./
USER 65534:65534
EXPOSE 8766
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8766/health', timeout=2)"
CMD ["python", "server.py", "--host", "0.0.0.0", "--port", "8766"]
+63
View File
@@ -0,0 +1,63 @@
"use strict";
const $ = (id) => document.getElementById(id);
let token = sessionStorage.getItem("notifyme-review-session") || "";
async function api(path, method = "GET") {
const response = await fetch(path, { method, cache: "no-store" });
if (!response.ok) throw new Error(await response.text());
return response.json();
}
function showSession() {
$("session").hidden = !token;
$("create").hidden = !!token;
$("callback").value = token ? `${location.origin}/callback/${token}` : "";
}
async function refresh() {
if (!token) return;
const data = await api(`/api/sessions/${encodeURIComponent(token)}`);
$("expiry").textContent = `Expires in ${Math.ceil(data.expires_in / 60)} minutes. This page updates only when you check results.`;
$("count").textContent = `${data.received} received · ${data.events.length} retained`;
const cards = data.events.slice().reverse().map((event) => {
const article = document.createElement("article");
const when = document.createElement("p");
when.className = "note";
when.textContent = event.received_at;
const body = document.createElement("pre");
body.textContent = JSON.stringify({ id: event.id, msg: event.msg }, null, 2);
article.append(when, body);
return article;
});
$("results").replaceChildren(...cards);
$("status").textContent = "Results checked.";
}
function action(id, handler) {
$(id).addEventListener("click", async () => {
$(id).disabled = true;
try { await handler(); }
catch (error) { $("status").textContent = error.message; }
finally { $(id).disabled = false; }
});
}
action("create", async () => {
const data = await api("/api/sessions", "POST");
token = data.token;
sessionStorage.setItem("notifyme-review-session", token);
showSession();
await refresh();
});
action("refresh", refresh);
action("copy", async () => {
await navigator.clipboard.writeText($("callback").value);
$("status").textContent = "Callback URL copied.";
});
action("delete", async () => {
// An expired session is already gone; still clear the browser's saved token.
const response = await fetch(`/api/sessions/${encodeURIComponent(token)}`, { method: "DELETE" });
if (!response.ok && response.status !== 404) throw new Error(await response.text());
token = "";
sessionStorage.removeItem("notifyme-review-session");
$("results").replaceChildren();
showSession();
$("status").textContent = "Session deleted. You can create a new session.";
});
showSession();
if (token) refresh().catch((error) => { $("status").textContent = error.message; });
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NotifyMe · Review receiver</title>
<link rel="stylesheet" href="/style.css">
<script src="/app.js" defer></script>
<main>
<p class="eyebrow">COMFYUI · CHICKTOOLS</p>
<h1>Test a notification.</h1>
<p>This receiver lets you verify the task ID and text sent by <strong>Notify Me</strong>. No models are needed.</p>
<ol>
<li>Create a session and copy its HTTPS callback URL.</li>
<li>Paste it into <code>Notify Me → callback_url</code>. Enter a test <code>id</code> and optionally connect a STRING to <code>msg</code>.</li>
<li>Run your workflow, then select <strong>Check results</strong> below.</li>
</ol>
<p class="note">Use synthetic test text only. Anyone holding the session URL can read its results. Messages stay in memory for up to one hour, or until you delete the session or the service restarts. The latest 20 messages (up to 64 KiB) are retained. Request bodies are not logged or forwarded.</p>
<button id="create">Create test session</button>
<section id="session" hidden aria-label="Test session">
<label for="callback">Callback URL</label>
<div class="url"><input id="callback" readonly spellcheck="false"><button id="copy" class="secondary">Copy URL</button></div>
<p id="expiry" class="note"></p>
<div class="actions"><button id="refresh">Check results</button><button id="delete" class="secondary">Delete session</button></div>
<h2>Received messages</h2>
<p id="count">No messages yet.</p>
<div id="results"></div>
</section>
<p id="status" role="status" aria-live="polite"></p>
<footer><a href="https://github.com/chookg/ComfyUI-ChickTools">Source & installation</a> · <a href="https://github.com/chookg/ComfyUI-ChickTools/blob/main/docs/REVIEW_SERVICE.md">Review guide</a></footer>
</main>
</html>
+1
View File
@@ -0,0 +1 @@
aiohttp>=3.12,<4
+149
View File
@@ -0,0 +1,149 @@
"""Small, ephemeral NotifyMe review receiver. No disk storage or outbound calls."""
import argparse
from collections import deque
from datetime import datetime, timezone
import json
from pathlib import Path
import secrets
import time
from aiohttp import web
TTL = 3600
MAX_SESSIONS = 100
MAX_EVENTS = 20
MAX_BYTES_PER_SESSION = 65536
ORIGIN = "https://notify.bro3.net"
STATE = web.AppKey("sessions", dict)
@web.middleware
async def headers(request, handler):
try:
response = await handler(request)
except web.HTTPException as exc:
response = web.Response(status=exc.status, text=exc.text, headers=exc.headers)
response.headers.update({
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
})
return response
def prune(app):
now = time.monotonic()
for token, session in list(app[STATE].items()):
if session["expires"] <= now:
del app[STATE][token]
def session_for(request):
prune(request.app)
session = request.app[STATE].get(request.match_info["token"])
if session is None:
raise web.HTTPNotFound(text="Session expired or unknown. Create a new test session.")
return session
async def create_session(request):
# Browser creation is same-origin. CLI users can omit Origin.
if request.headers.get("Origin") not in (None, ORIGIN, f"{request.scheme}://{request.host}"):
raise web.HTTPForbidden(text="Unexpected origin")
prune(request.app)
if len(request.app[STATE]) >= MAX_SESSIONS:
raise web.HTTPTooManyRequests(text="All test sessions are in use. Please try later.")
token = secrets.token_urlsafe(32)
request.app[STATE][token] = {"expires": time.monotonic() + TTL,
"events": deque(), "bytes": 0, "received": 0}
return web.json_response({"token": token, "callback_url": f"{ORIGIN}/callback/{token}",
"expires_in": TTL})
async def receive(request):
session = session_for(request)
if request.content_type != "application/json":
raise web.HTTPUnsupportedMediaType(text="Use application/json")
try:
body = await request.json()
except (ValueError, UnicodeError):
raise web.HTTPBadRequest(text="Expected a JSON object") from None
# Body reads can yield; recheck expiry/deletion before accepting a message.
session = session_for(request)
if not isinstance(body, dict) or set(body) - {"id", "msg"}:
raise web.HTTPBadRequest(text="Only id and msg fields are accepted")
task_id, msg = body.get("id", ""), body.get("msg", "")
if not isinstance(task_id, str) or not isinstance(msg, str):
raise web.HTTPBadRequest(text="id and msg must be strings")
if len(task_id) > 256 or len(msg) > 8192:
raise web.HTTPRequestEntityTooLarge(max_size=8192, actual_size=max(len(task_id), len(msg)))
event = {"received_at": datetime.now(timezone.utc).isoformat(), "id": task_id, "msg": msg}
size = len(json.dumps(event).encode("utf-8"))
session["events"].append((event, size))
session["bytes"] += size
session["received"] += 1
while len(session["events"]) > MAX_EVENTS or session["bytes"] > MAX_BYTES_PER_SESSION:
session["bytes"] -= session["events"].popleft()[1]
return web.json_response({"ok": True, "received": {"id": task_id, "msg": msg}})
async def events(request):
session = session_for(request)
return web.json_response({"received": session["received"],
"expires_in": max(0, int(session["expires"] - time.monotonic())),
"events": [event for event, _ in session["events"]]})
async def delete_session(request):
session_for(request)
del request.app[STATE][request.match_info["token"]]
return web.json_response({"ok": True})
async def health(request):
return web.json_response({"ok": True, "service": "NotifyMe review receiver"})
async def asset(request):
name = {"/": "index.html", "/app.js": "app.js", "/style.css": "style.css"}[request.path]
mime = {"index.html": "text/html", "app.js": "application/javascript", "style.css": "text/css"}[name]
return web.Response(text=Path(__file__).with_name(name).read_text(encoding="utf-8"), content_type=mime)
async def cleanup(app):
import asyncio
async def sweep():
while True:
await asyncio.sleep(30)
prune(app)
task = asyncio.create_task(sweep())
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def create_app():
app = web.Application(client_max_size=16384, middlewares=[headers])
app[STATE] = {}
app.cleanup_ctx.append(cleanup)
for path in ("/", "/app.js", "/style.css"):
app.router.add_get(path, asset)
app.router.add_get("/health", health)
app.router.add_post("/api/sessions", create_session)
app.router.add_get("/api/sessions/{token}", events)
app.router.add_delete("/api/sessions/{token}", delete_session)
app.router.add_post("/callback/{token}", receive)
return app
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8766)
args = parser.parse_args()
web.run_app(create_app(), host=args.host, port=args.port, access_log=None)
+23
View File
@@ -0,0 +1,23 @@
:root { color-scheme: light; font-family: system-ui, sans-serif; color: #25312a; background: #f6f7f3; }
* { box-sizing: border-box; }
body { margin: 0; }
main { max-width: 820px; margin: 64px auto; padding: 0 24px; line-height: 1.65; }
.eyebrow { color: #56684c; font-size: .75rem; font-weight: 700; letter-spacing: .14em; }
h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1.15; letter-spacing: -.035em; margin: 16px 0; }
h2 { font-size: 1.2rem; margin-top: 32px; }
ol { padding-left: 24px; } li { padding: 5px 0; }
.note { color: #5e685e; font-size: .875rem; }
button { background: #31573a; border: 1px solid #31573a; color: white; padding: 11px 18px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer; }
button:disabled { opacity: .55; cursor: wait; }
button.secondary { background: transparent; color: #31573a; }
:focus-visible { outline: 3px solid #b17828; outline-offset: 3px; }
section { margin-top: 30px; border-top: 1px solid #cdd4c8; padding-top: 24px; }
label { display: block; font-weight: 600; }
.url, .actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 10px 0; }
input { flex: 1; min-width: 200px; border: 1px solid #a8b4a3; border-radius: 6px; padding: 10px; font: .875rem monospace; background: white; color: inherit; }
article { border: 1px solid #d7ded1; background: white; border-radius: 6px; padding: 12px 18px; margin: 12px 0; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: .875rem; }
footer { border-top: 1px solid #cdd4c8; padding-top: 20px; margin-top: 40px; font-size: .875rem; }
a { color: #31573a; }
[hidden] { display: none !important; }
@media (max-width: 480px) { main { margin-top: 30px; padding: 0 18px; } .url input { width: 100%; flex-basis: 100%; } }
+306
View File
@@ -0,0 +1,306 @@
"""Model-free ComfyUI integration test with a loopback HTTP receiver.
Run with ComfyUI's Python:
python tools/smoke_comfy.py --comfy /path/to/ComfyUI
"""
import argparse
import copy
from contextlib import nullcontext
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
import json
import os
from pathlib import Path
import runpy
import socket
import subprocess
import sys
import threading
import time
import wave
from urllib.request import Request, urlopen
from urllib.parse import urlencode
import av
import numpy as np
from PIL import Image
# Embedded ComfyUI Python can run in isolated mode and omit the script folder
# from sys.path. Keep this helper importable in both regular and embedded runs.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from prepare_workflow import write_preparation, build_prompts
ROOT = Path(__file__).resolve().parents[1]
MESSAGE = ' 通知消息 🐥\n保留换行与空格 '
def get_json(url, payload=None):
request = Request(url, data=None if payload is None else json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urlopen(request, timeout=3) as response:
return json.load(response)
def child(args):
comfy = args.comfy.resolve()
sys.path.insert(0, str(comfy))
os.chdir(comfy)
sys.argv = [str(comfy / "main.py"), "--cpu", "--listen", "127.0.0.1",
"--port", str(args.port), "--disable-auto-launch", "--disable-api-nodes",
"--disable-all-custom-nodes", "--whitelist-custom-nodes", "ComfyUI-ChickTools",
"--user-directory", str(args.workspace / "user"),
"--input-directory", str(args.workspace / "input"),
"--output-directory", str(args.workspace / "output"),
"--temp-directory", str(args.workspace / "temp"),
"--database-url", "sqlite:///:memory:"]
import comfy.options
comfy.options.enable_args_parsing()
fixture = nullcontext()
if args.fixture_callback_port:
sys.path.insert(0, str(ROOT / "tests"))
from callback_fixture import routed_callback
fixture = routed_callback(args.fixture_callback_port, args.fixture_ca)
with fixture:
runpy.run_path(str(args.comfy.resolve() / "main.py"), run_name="__main__")
def main(args):
sys.path.insert(0, str(ROOT))
from chick.codec import CarrierCodecError, decode_bytes
args.workspace.mkdir(parents=True, exist_ok=True)
(args.workspace / "user").mkdir(exist_ok=True)
(args.workspace / "input").mkdir(exist_ok=True)
samples = np.arange(2000) / 16000
pcm = np.rint(np.sin(2 * np.pi * 440 * samples) * 12000).astype("<i2")
with wave.open(str(args.workspace / "input" / "tone.wav"), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
wav.writeframes(pcm.tobytes())
callbacks = []
class Receiver(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
callbacks.append(json.loads(self.rfile.read(int(self.headers["Content-Length"]))))
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
server = ThreadingHTTPServer(("127.0.0.1", 0), Receiver)
sys.path.insert(0, str(ROOT / "tests"))
from callback_fixture import TLSFixture
tls = TLSFixture(args.workspace / "tls")
server.socket = tls.server_context.wrap_socket(server.socket, server_side=True)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
callback_url = f"https://notify.bro3.net:{server.server_port}/callback"
command = [sys.executable, "-X", "utf8", str(Path(__file__).resolve()),
"--comfy", str(args.comfy.resolve()), "--workspace", str(args.workspace.resolve()),
"--port", str(args.port), "--server-child", "--fixture-callback-port", str(server.server_port),
"--fixture-ca", str(tls.cert.resolve())]
base = f"http://127.0.0.1:{args.port}"
with socket.socket() as probe:
probe.bind(("127.0.0.1", args.port))
try:
with (args.workspace / "server.log").open("w", encoding="utf-8") as log:
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
try:
deadline = time.monotonic() + 120
while True:
if process.poll() is not None:
raise RuntimeError("ComfyUI exited; see .local/smoke/server.log")
try:
stats = get_json(base + "/system_stats")
break
except (OSError, TimeoutError):
if time.monotonic() > deadline:
raise TimeoutError("ComfyUI startup timed out")
time.sleep(0.5)
assert stats["devices"][0]["type"] == "cpu", stats
info = get_json(base + "/object_info")
assert all(name in info for name in ("ChickHideNode", "NotifyMeNode", "ChickSaveImage"))
saver_type = "ChickSaveImage"
preparation = write_preparation(info, args.workspace / "preparation")
assert preparation["carrier_workflow_ready"], preparation
assert preparation["text_callback_workflow_ready"], preparation
assert "text_input" not in info["ChickHideNode"]["input"].get("optional", {})
assert info["NotifyMeNode"]["output"] == ["*"]
assert info["NotifyMeNode"]["output_name"] == ["passthrough"]
prompt = {
"1": {"class_type": "EmptyImage", "inputs": {"width": 8, "height": 8, "batch_size": 1, "color": 255}},
"2": {"class_type": "ChickHideNode", "inputs": {"images": ["1", 0], "password": "", "title": "smoke", "fps": 24, "compress": 2, "combine_video": False}},
"4": {"class_type": saver_type, "inputs": {"images": ["2", 0], "filename_prefix": "ChickTools/smoke"}}
}
cases = [("image", copy.deepcopy(prompt), "png", 1, False),
("image_repeat", copy.deepcopy(prompt), "png", 1, False)]
for with_audio in (False, True):
video = copy.deepcopy(prompt)
video["1"]["inputs"].update(width=16, height=16, batch_size=8)
video["2"]["inputs"].update(combine_video=True, fps=8, password="fixture-video-password", compress=6)
if with_audio:
video["5"] = {"class_type": "LoadAudio", "inputs": {"audio": "tone.wav"}}
video["2"]["inputs"]["audio"] = ["5", 0]
cases.append(("video_audio" if with_audio else "video", video, "mp4", 8, with_audio))
audio_only = copy.deepcopy(prompt)
del audio_only["1"]
del audio_only["2"]["inputs"]["images"]
audio_only["5"] = {"class_type": "LoadAudio", "inputs": {"audio": "tone.wav"}}
audio_only["2"]["inputs"].update(audio=["5", 0], compress=8)
cases.append(("audio", audio_only, "wav", 1, True))
batch = copy.deepcopy(prompt)
batch["1"]["inputs"]["batch_size"] = 2
cases.append(("image_batch", batch, "png", 2, False))
saved = build_prompts(info, preparation)["carrier-smoke"]
saved["2"]["inputs"]["password"] = "fixture-image-password-小鸡"
cases.append(("saved_image", saved, "png", 1, False))
cases.append(("saved_image_repeat", copy.deepcopy(saved), "png", 1, False))
runs = []
for name, workflow, extension, frame_count, with_audio in cases:
provenance = {"Label": "1", "ContentProducer": "synthetic-fixture"}
metadata = {"workflow": {"nodes": [{"id": 2, "type": "ChickHideNode",
"widgets_values": [workflow["2"]["inputs"]["password"]]}]},
"private_debug": workflow, "AIGC": provenance}
submitted = get_json(base + "/prompt", {"prompt": workflow, "client_id": "chicktools-smoke",
"extra_data": {"extra_pnginfo": metadata}})
prompt_id = submitted["prompt_id"]
deadline = time.monotonic() + 20
while True:
history = get_json(base + "/history/" + prompt_id)
if prompt_id in history:
result = history[prompt_id]
assert result["status"]["status_str"] == "success", result
assert result["status"]["completed"], result
outputs = result["outputs"]["3" if name.startswith("saved_image") else "4"]["images"]
assert len(outputs) == (frame_count if extension == "png" else 1)
for output in outputs:
with urlopen(base + "/view?" + urlencode(output), timeout=3) as preview:
png_bytes = preview.read()
with Image.open(BytesIO(png_bytes)) as stored:
assert set(stored.info) == {"AIGC"}, stored.info.keys()
assert json.loads(stored.info["AIGC"]) == provenance
image = stored.convert("RGB")
password = workflow["2"]["inputs"]["password"]
if password:
assert password.encode("utf-8") not in png_bytes
for invalid in ("", "fixture-wrong-password"):
try:
decode_bytes(image, invalid)
except CarrierCodecError:
pass
else:
raise AssertionError("Protected carrier decoded with an invalid password")
raw, ext = decode_bytes(image, password)
assert image.size == (640, 640) and ext == extension
if ext == "png":
with Image.open(BytesIO(raw)) as restored:
side = 16 if name.startswith("saved_image") else 8
assert restored.size == (side, side) and restored.getpixel((0, 0)) == (0, 0, 255)
elif ext == "wav":
with wave.open(BytesIO(raw)) as restored:
assert restored.getframerate() == 16000 and restored.getnframes() == 2000
assert restored.readframes(2000) == pcm.tobytes()
else:
with av.open(BytesIO(raw)) as restored:
assert len(restored.streams.audio) == int(with_audio)
stream = restored.streams.video[0]
assert stream.average_rate == 8 and (stream.width, stream.height) == (16, 16)
decoded = list(restored.decode(video=0))
assert len(decoded) == 8
np.testing.assert_allclose(decoded[-1].to_ndarray(format="rgb24")[8, 8], [0, 0, 255], atol=8)
if with_audio:
with av.open(BytesIO(raw)) as restored:
track = np.concatenate([f.to_ndarray() for f in restored.decode(audio=0)], axis=1)
assert track.shape[1] >= 16000 and np.max(np.abs(track[:, 12000:15000])) > 0.1
runs.append({"case": name, "prompt_id": prompt_id, "status": "success",
"payload_extension": extension, "preview_count": len(outputs),
"workflow_metadata_absent": True, "aigc_preserved": True,
"password_checks_passed": bool(password)})
break
if time.monotonic() > deadline:
raise TimeoutError("Tiny workflow timed out")
time.sleep(0.1)
assert callbacks == [], callbacks
prepared = build_prompts(info, preparation)
text_prompt = copy.deepcopy(prepared["notify-text-smoke"])
text_prompt["1"]["inputs"]["value"] = MESSAGE
text_prompt["2"]["inputs"].update(callback_url=callback_url, id="000123-任务🐥")
empty_prompt = copy.deepcopy(prepared["notify-empty-smoke"])
empty_prompt["1"]["inputs"].update(callback_url=callback_url)
empty_prompt["1"]["inputs"].pop("id") # Existing workflows omit the new field.
whitespace_prompt = copy.deepcopy(text_prompt)
whitespace_prompt["1"]["inputs"]["value"] = " \n\t"
whitespace_prompt["2"]["inputs"]["id"] = "000124"
notification_cases = [("notify_text", text_prompt, MESSAGE, "000123-任务🐥"),
("notify_text_repeat", copy.deepcopy(text_prompt), MESSAGE, "000123-任务🐥"),
("notify_unconnected", empty_prompt, "", ""),
("notify_whitespace", whitespace_prompt, " \n\t", "000124")]
for name, workflow, expected, task_id in notification_cases:
before = len(callbacks)
submitted = get_json(base + "/prompt", {"prompt": workflow, "client_id": "chicktools-smoke"})
prompt_id = submitted["prompt_id"]
deadline = time.monotonic() + 20
while True:
history = get_json(base + "/history/" + prompt_id)
if prompt_id in history:
result = history[prompt_id]
assert result["status"]["status_str"] == "success", result
assert result["status"]["completed"], result
assert callbacks[before:] == [{"id": task_id, "msg": expected}], callbacks
runs.append({"case": name, "prompt_id": prompt_id, "status": "success",
"message_exact": True, "id_exact": True, "callback_count": 1})
break
if time.monotonic() > deadline:
raise TimeoutError("Tiny notification workflow timed out")
time.sleep(0.1)
assert callbacks == [{"id": item[3], "msg": item[2]} for item in notification_cases], callbacks
for scheme, host in (("https", "127.0.0.1"), ("https", "callback.example"),
("https", "a.notify.bro3.net"), ("http", "notify.bro3.net")):
blocked = copy.deepcopy(text_prompt)
blocked["2"]["inputs"]["callback_url"] = f"{scheme}://{host}:{server.server_port}/callback"
# A legacy API prompt cannot re-enable the removed option.
blocked["2"]["inputs"]["allow_localhost"] = True
prompt_id = get_json(base + "/prompt", {"prompt": blocked})["prompt_id"]
deadline = time.monotonic() + 20
while True:
history = get_json(base + "/history/" + prompt_id)
if prompt_id in history:
result = history[prompt_id]
assert result["status"]["status_str"] == "error", result
assert ("must use HTTPS" if scheme == "http" else "approved allowlist") in json.dumps(result), result
assert len(callbacks) == len(notification_cases), callbacks
runs.append({"case": "blocked_" + host, "prompt_id": prompt_id,
"status": "rejected_as_expected", "callback_count": 0})
break
if time.monotonic() > deadline:
raise TimeoutError("Blocked notification workflow timed out")
time.sleep(0.1)
report = {"device": "cpu", "models_loaded": 0, "image_shape": [1, 640, 640, 3],
"registered_nodes": ["ChickHideNode", saver_type, "NotifyMeNode"],
"callback_count": len(callbacks), "runs": runs,
"fixture": "synthetic DNS and loopback socket routing in the isolated test child; production allowlist and IP validation unchanged"}
(args.workspace / "result.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
finally:
if process.poll() is None:
process.terminate()
process.wait(timeout=10)
finally:
server.shutdown()
server.server_close()
thread.join()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--comfy", type=Path, required=True)
parser.add_argument("--workspace", type=Path, default=ROOT / ".local" / "smoke")
parser.add_argument("--port", type=int, default=8191)
parser.add_argument("--server-child", action="store_true")
parser.add_argument("--fixture-callback-port", type=int, default=0, help=argparse.SUPPRESS)
parser.add_argument("--fixture-ca", type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.server_child:
child(args)
else:
main(args)
+1
View File
@@ -0,0 +1 @@
"""Shared utility functions for ChickTools."""
+47
View File
@@ -0,0 +1,47 @@
"""HTTP transport pinned to the address approved by URL validation."""
import ssl
import requests
from requests.adapters import HTTPAdapter
from urllib3 import HTTPSConnectionPool
from urllib.parse import urlsplit
from .url_security import CallbackTarget
class _PinnedAdapter(HTTPAdapter):
def __init__(self, target: CallbackTarget):
if target.scheme != "https" or urlsplit(target.url).scheme != "https":
raise ValueError("Callback transport requires HTTPS")
super().__init__(max_retries=0)
self.pool = HTTPSConnectionPool(
target.address, target.port, maxsize=1,
server_hostname=target.hostname, assert_hostname=target.hostname,
cert_reqs=ssl.CERT_REQUIRED, ca_certs=requests.certs.where(),
)
def get_connection(self, url, proxies=None):
return self.pool
def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
return self.pool
def close(self):
self.pool.close()
super().close()
def post_completion(target: CallbackTarget, msg: str, timeout: float = 15, *, id: str = "") -> int:
with requests.Session() as session:
# Ambient proxies/netrc must not redirect a destination or add credentials.
session.trust_env = False
session.mount(f"{target.scheme}://", _PinnedAdapter(target))
with session.post(
target.url,
json={"id": id, "msg": msg},
headers={"Host": target.authority},
timeout=timeout, allow_redirects=False, stream=True,
) as response:
# The body is intentionally neither required nor downloaded.
return response.status_code
+4
View File
@@ -0,0 +1,4 @@
"""Reviewed callback hosts. Changes require repository and platform review."""
# Exact DNS names only: no wildcards, subdomains, IP literals, or workflow overrides.
ALLOWED_CALLBACK_HOSTS = frozenset({"notify.bro3.net"})
+76
View File
@@ -0,0 +1,76 @@
"""Resolve once, reject non-public destinations, and keep the approved address."""
from dataclasses import dataclass
import ipaddress
import socket
from urllib.parse import urlsplit, urlunsplit
from .callback_policy import ALLOWED_CALLBACK_HOSTS
class CallbackURLValidationError(ValueError):
"""A callback URL failed validation."""
@dataclass(frozen=True)
class CallbackTarget:
url: str
hostname: str
address: str
port: int
scheme: str
authority: str
def _public_address(value: str) -> bool:
address = ipaddress.ip_address(value)
if isinstance(address, ipaddress.IPv6Address):
if address.ipv4_mapped is not None:
return _public_address(str(address.ipv4_mapped))
if address.sixtofour or address.teredo or address in ipaddress.ip_network("64:ff9b::/96"):
return False
return address.is_global and not (address.is_multicast or address.is_reserved)
def validate_callback_url(callback_url: str) -> CallbackTarget:
value = (callback_url or "").strip()
if not value:
raise CallbackURLValidationError("callback_url is required")
if any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value) or "\\" in value:
raise CallbackURLValidationError("callback_url contains invalid characters")
try:
parsed = urlsplit(value)
if parsed.scheme != "https":
raise CallbackURLValidationError("callback_url must use HTTPS")
if not parsed.hostname:
raise CallbackURLValidationError("callback_url must include a hostname")
if parsed.username is not None or parsed.password is not None:
raise CallbackURLValidationError("callback_url must not contain a username or password")
if parsed.fragment:
raise CallbackURLValidationError("callback_url must not contain a fragment")
hostname = parsed.hostname
if hostname.endswith("."):
hostname = hostname[:-1]
hostname = hostname.encode("idna").decode("ascii").lower()
# Check before DNS and HTTP; imported workflows cannot extend this policy.
if hostname not in ALLOWED_CALLBACK_HOSTS:
raise CallbackURLValidationError("callback_url host is not in the approved allowlist")
port = parsed.port if parsed.port is not None else 443
if port == 0:
raise CallbackURLValidationError("callback_url has an invalid port")
try:
addresses = [str(ipaddress.ip_address(hostname))]
except ValueError:
addresses = list(dict.fromkeys(item[4][0] for item in socket.getaddrinfo(
hostname, port, type=socket.SOCK_STREAM
)))
if not addresses or not all(_public_address(address) for address in addresses):
raise CallbackURLValidationError("callback_url must resolve only to public addresses")
host = f"[{hostname}]" if ":" in hostname else hostname
authority = f"{host}:{port}" if parsed.port is not None else host
normalized = urlunsplit((parsed.scheme, authority, parsed.path or "/", parsed.query, ""))
return CallbackTarget(normalized, hostname, addresses[0], port, parsed.scheme, authority)
except CallbackURLValidationError:
raise
except (ValueError, UnicodeError, OSError) as exc:
raise CallbackURLValidationError("callback_url is malformed or DNS resolution failed") from exc