"""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()