"""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 可继续执行后续节点。处理详情见接收记录和响应正文。
点击开启监听后,新结果会自动追加,正在播放的视频会保持播放。
开启监听 尚未开启监听
回调内容
解码媒体 载体从指定的本地输出目录读取;回调包含任务 id 和文本 msg,两条分支独立执行。
"""
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()