commit aa9f7e583de3cb210c90a52b3e96de410fd4e94f Author: chookg Date: Fri Oct 2 19:52:46 2026 +0800 Initial release diff --git a/.comfyignore b/.comfyignore new file mode 100644 index 0000000..8df5eee --- /dev/null +++ b/.comfyignore @@ -0,0 +1,3 @@ +tests/ +tools/ +docs/ diff --git a/.github/ISSUE_TEMPLATE/domain-request.yml b/.github/ISSUE_TEMPLATE/domain-request.yml new file mode 100644 index 0000000..55e5979 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/domain-request.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a24ced1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +*.egg-info/ +.venv/ +build/ +dist/ +.local/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8b3025e --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..abd0d9d --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..92d52ea --- /dev/null +++ b/README.md @@ -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. diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..3d11a14 --- /dev/null +++ b/README_CN.md @@ -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。 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..6f680ee --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/assets/chick.png b/assets/chick.png new file mode 100644 index 0000000..244731c Binary files /dev/null and b/assets/chick.png differ diff --git a/assets/chick.svg b/assets/chick.svg new file mode 100644 index 0000000..5dce36e --- /dev/null +++ b/assets/chick.svg @@ -0,0 +1,30 @@ + + ChickTools little chick + A round yellow baby chicken with a feather tuft, orange beak, rosy cheeks and tiny feet on an ivory background. + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/roundtrip-input.png b/assets/roundtrip-input.png new file mode 100644 index 0000000..a7a3f5d Binary files /dev/null and b/assets/roundtrip-input.png differ diff --git a/assets/roundtrip-input.svg b/assets/roundtrip-input.svg new file mode 100644 index 0000000..7e77442 --- /dev/null +++ b/assets/roundtrip-input.svg @@ -0,0 +1,10 @@ + + ChickTools image round-trip sample + + + + + + + + diff --git a/chick/__init__.py b/chick/__init__.py new file mode 100644 index 0000000..9ed4e2c --- /dev/null +++ b/chick/__init__.py @@ -0,0 +1 @@ +"""Reserved for the independently implemented media codec.""" diff --git a/chick/codec.py b/chick/codec.py new file mode 100644 index 0000000..a222116 --- /dev/null +++ b/chick/codec.py @@ -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() diff --git a/chick/media.py b/chick/media.py new file mode 100644 index 0000000..e7ce9e5 --- /dev/null +++ b/chick/media.py @@ -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(" 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() diff --git a/docs/CARRIER_FORMAT.md b/docs/CARRIER_FORMAT.md new file mode 100644 index 0000000..fbd000d --- /dev/null +++ b/docs/CARRIER_FORMAT.md @@ -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. diff --git a/docs/DOMAIN_REQUESTS.md b/docs/DOMAIN_REQUESTS.md new file mode 100644 index 0000000..641870d --- /dev/null +++ b/docs/DOMAIN_REQUESTS.md @@ -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. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..e721b58 --- /dev/null +++ b/docs/INTEGRATION.md @@ -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. diff --git a/docs/LOCAL_CALLBACK.md b/docs/LOCAL_CALLBACK.md new file mode 100644 index 0000000..3891789 --- /dev/null +++ b/docs/LOCAL_CALLBACK.md @@ -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`,并指向载体输出目录,结果页会显示解码后的视频。 +通知分支遵守正式域名白名单;隔离测试进程外的回调不会发给本地接收页。 diff --git a/docs/REVIEW_SERVICE.md b/docs/REVIEW_SERVICE.md new file mode 100644 index 0000000..862522a --- /dev/null +++ b/docs/REVIEW_SERVICE.md @@ -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. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..e984d19 --- /dev/null +++ b/docs/VALIDATION.md @@ -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. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..425f2e8 --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/examples/chick_audio_example.json b/examples/chick_audio_example.json new file mode 100644 index 0000000..da3c2fb --- /dev/null +++ b/examples/chick_audio_example.json @@ -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 +} diff --git a/examples/chick_image_example.json b/examples/chick_image_example.json new file mode 100644 index 0000000..66108c1 --- /dev/null +++ b/examples/chick_image_example.json @@ -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 +} diff --git a/examples/chick_image_roundtrip_example.json b/examples/chick_image_roundtrip_example.json new file mode 100644 index 0000000..e4eb0c4 --- /dev/null +++ b/examples/chick_image_roundtrip_example.json @@ -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" +} diff --git a/examples/chick_notify_local_example.json b/examples/chick_notify_local_example.json new file mode 100644 index 0000000..50d1da2 --- /dev/null +++ b/examples/chick_notify_local_example.json @@ -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 +} diff --git a/examples/chick_notify_video_example.json b/examples/chick_notify_video_example.json new file mode 100644 index 0000000..7103860 --- /dev/null +++ b/examples/chick_notify_video_example.json @@ -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 +} diff --git a/examples/chick_video_example.json b/examples/chick_video_example.json new file mode 100644 index 0000000..ab3d959 --- /dev/null +++ b/examples/chick_video_example.json @@ -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 +} diff --git a/examples/notify_me_empty_example.json b/examples/notify_me_empty_example.json new file mode 100644 index 0000000..217936a --- /dev/null +++ b/examples/notify_me_empty_example.json @@ -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 +} diff --git a/examples/notify_me_example.json b/examples/notify_me_example.json new file mode 100644 index 0000000..c82cf7f --- /dev/null +++ b/examples/notify_me_example.json @@ -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 +} diff --git a/examples/notify_me_local_example.json b/examples/notify_me_local_example.json new file mode 100644 index 0000000..d5ed5bd --- /dev/null +++ b/examples/notify_me_local_example.json @@ -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 +} diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 0000000..30a5a0c --- /dev/null +++ b/nodes/__init__.py @@ -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"] diff --git a/nodes/chick_hide.py b/nodes/chick_hide.py new file mode 100644 index 0000000..519da21 --- /dev/null +++ b/nodes/chick_hide.py @@ -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 diff --git a/nodes/chick_restore.py b/nodes/chick_restore.py new file mode 100644 index 0000000..1ff82c6 --- /dev/null +++ b/nodes/chick_restore.py @@ -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 diff --git a/nodes/chick_save.py b/nodes/chick_save.py new file mode 100644 index 0000000..0e69021 --- /dev/null +++ b/nodes/chick_save.py @@ -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, + ) diff --git a/nodes/notify_me.py b/nodes/notify_me.py new file mode 100644 index 0000000..ee0dab2 --- /dev/null +++ b/nodes/notify_me.py @@ -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,) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9a22024 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b061ea4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests>=2.31,<3 +urllib3>=1.26,<3 +numpy>=1.24,<3 +Pillow>=10.4 +imageio-ffmpeg>=0.5,<1 diff --git a/tests/callback_fixture.py b/tests/callback_fixture.py new file mode 100644 index 0000000..cd2313a --- /dev/null +++ b/tests/callback_fixture.py @@ -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 diff --git a/tests/plugin_loader.py b/tests/plugin_loader.py new file mode 100644 index 0000000..64d4f58 --- /dev/null +++ b/tests/plugin_loader.py @@ -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"] diff --git a/tests/test_chick_codec.py b/tests/test_chick_codec.py new file mode 100644 index 0000000..98b9ea0 --- /dev/null +++ b/tests/test_chick_codec.py @@ -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)) diff --git a/tests/test_chick_media.py b/tests/test_chick_media.py new file mode 100644 index 0000000..9b29824 --- /dev/null +++ b/tests/test_chick_media.py @@ -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="alert(1) '} + 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) diff --git a/tests/test_url_security.py b/tests/test_url_security.py new file mode 100644 index 0000000..0dd0824 --- /dev/null +++ b/tests/test_url_security.py @@ -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") diff --git a/tools/build_video_example.py b/tools/build_video_example.py new file mode 100644 index 0000000..177a4a5 --- /dev/null +++ b/tools/build_video_example.py @@ -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() diff --git a/tools/local_callback_server.py b/tools/local_callback_server.py new file mode 100644 index 0000000..6b50929 --- /dev/null +++ b/tools/local_callback_server.py @@ -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 = """ + +NotifyMe 本地测试结果 + +

本地测试结果

回调地址:

+

测试模式:回调固定返回 HTTP 200,NotifyMe 可继续执行后续节点。处理详情见接收记录和响应正文。

+

点击开启监听后,新结果会自动追加,正在播放的视频会保持播放。

+ 尚未开启监听 +

+

回调内容

等待 NotifyMe 消息…

+

解码媒体

载体从指定的本地输出目录读取;回调包含任务 id 和文本 msg,两条分支独立执行。

+

等待载体 PNG…

+""" + + +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() diff --git a/tools/local_results.js b/tools/local_results.js new file mode 100644 index 0000000..625e63b --- /dev/null +++ b/tools/local_results.js @@ -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. +})(); diff --git a/tools/prepare_workflow.py b/tools/prepare_workflow.py new file mode 100644 index 0000000..a8c1544 --- /dev/null +++ b/tools/prepare_workflow.py @@ -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()) diff --git a/tools/review_receiver/Dockerfile b/tools/review_receiver/Dockerfile new file mode 100644 index 0000000..cd20042 --- /dev/null +++ b/tools/review_receiver/Dockerfile @@ -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"] diff --git a/tools/review_receiver/app.js b/tools/review_receiver/app.js new file mode 100644 index 0000000..f8515e6 --- /dev/null +++ b/tools/review_receiver/app.js @@ -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; }); diff --git a/tools/review_receiver/index.html b/tools/review_receiver/index.html new file mode 100644 index 0000000..b896145 --- /dev/null +++ b/tools/review_receiver/index.html @@ -0,0 +1,31 @@ + + + + +NotifyMe · Review receiver + + +
+

COMFYUI · CHICKTOOLS

+

Test a notification.

+

This receiver lets you verify the task ID and text sent by Notify Me. No models are needed.

+
    +
  1. Create a session and copy its HTTPS callback URL.
  2. +
  3. Paste it into Notify Me → callback_url. Enter a test id and optionally connect a STRING to msg.
  4. +
  5. Run your workflow, then select Check results below.
  6. +
+

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.

+ + +

+ +
+ diff --git a/tools/review_receiver/requirements.txt b/tools/review_receiver/requirements.txt new file mode 100644 index 0000000..60b09fc --- /dev/null +++ b/tools/review_receiver/requirements.txt @@ -0,0 +1 @@ +aiohttp>=3.12,<4 diff --git a/tools/review_receiver/server.py b/tools/review_receiver/server.py new file mode 100644 index 0000000..ebc9eca --- /dev/null +++ b/tools/review_receiver/server.py @@ -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) diff --git a/tools/review_receiver/style.css b/tools/review_receiver/style.css new file mode 100644 index 0000000..108e38c --- /dev/null +++ b/tools/review_receiver/style.css @@ -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%; } } diff --git a/tools/smoke_comfy.py b/tools/smoke_comfy.py new file mode 100644 index 0000000..cfa925e --- /dev/null +++ b/tools/smoke_comfy.py @@ -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(" 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) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e1ad7e0 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +"""Shared utility functions for ChickTools.""" diff --git a/utils/callback_http.py b/utils/callback_http.py new file mode 100644 index 0000000..15d4b79 --- /dev/null +++ b/utils/callback_http.py @@ -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 diff --git a/utils/callback_policy.py b/utils/callback_policy.py new file mode 100644 index 0000000..1b4f5b5 --- /dev/null +++ b/utils/callback_policy.py @@ -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"}) diff --git a/utils/url_security.py b/utils/url_security.py new file mode 100644 index 0000000..bd45b09 --- /dev/null +++ b/utils/url_security.py @@ -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