Download utils/workflow_submission.py from turtle89431/Moxie-Multimedia: direct link, hf CLI and curl.
- Browser
- Download file 9.7 kB
-
https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/utils/workflow_submission.py
- Command line
-
hf download hf://turtle89431/Moxie-Multimedia/utils/workflow_submission.py
-
curl -L -o workflow_submission.py https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/utils/workflow_submission.py
9.7 kB
| """In-memory handoff from API callers to a single ComfyUI browser page.""" | |
| import hashlib | |
| import json | |
| import re | |
| import time | |
| import uuid | |
| from collections.abc import Callable | |
| from aiohttp import web | |
| class WorkflowSubmissions: | |
| def __init__(self, connected: Callable[[str], bool]) -> None: | |
| self.connected = connected | |
| self.clients: dict[str, dict] = {} | |
| self.jobs: dict[str, dict] = {} | |
| def _prune(self) -> None: | |
| now = time.time() | |
| self.clients = {key: value for key, value in self.clients.items() | |
| if now - value["last_seen"] < 30 and self.connected(value["session_id"])} | |
| for job in self.jobs.values(): | |
| if job["status"] == "pending" and now - job["created_at"] > 60: | |
| job.update(status="failed", error="Browser did not accept the workflow within 60 seconds") | |
| job.pop("workflow", None) | |
| if job["status"] == "loading" and now - job["claimed_at"] > 120: | |
| job.update(status="unknown", error="Browser result unavailable; check queue/history before retrying") | |
| self.jobs = {key: value for key, value in self.jobs.items() | |
| if now - value["created_at"] < 3600 or value["status"] in {"loading", "unknown"}} | |
| def list_clients(self) -> list[dict]: | |
| self._prune() | |
| return list(self.clients.values()) | |
| def _identifier(value: object, field: str) -> str: | |
| if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value): | |
| raise ValueError(f"{field} must be 1-128 letters, digits, underscores or hyphens") | |
| return value | |
| def summary(job: dict) -> dict: | |
| return {key: value for key, value in job.items() | |
| if key not in {"workflow", "fingerprint", "claim_token"}} | |
| def submit(self, payload: dict) -> dict: | |
| self._prune() | |
| workflow = payload.get("workflow") | |
| if not isinstance(workflow, dict) or not isinstance(workflow.get("nodes"), list) or not isinstance(workflow.get("links"), list): | |
| raise ValueError("workflow must be UI workflow JSON with nodes and links; API prompt alone cannot preserve the editor layout") | |
| if any(not isinstance(node, dict) or "id" not in node or not isinstance(node.get("type"), str) for node in workflow["nodes"]): | |
| raise ValueError("Every workflow node must have an id and type") | |
| mode = payload.get("mode", "new_tab") | |
| if mode not in ("new_tab", "replace"): | |
| raise ValueError("mode must be new_tab or replace") | |
| auto_queue = payload.get("auto_queue", True) | |
| if type(auto_queue) is not bool: | |
| raise ValueError("auto_queue must be a boolean") | |
| name = payload.get("name", "Skill workflow") | |
| if not isinstance(name, str) or not name.strip() or len(name) > 200 or any(c in name for c in "/\\\x00"): | |
| raise ValueError("name must be a filename without directory separators, up to 200 characters") | |
| request_id = self._identifier(payload.get("request_id", str(uuid.uuid4())), "request_id") | |
| client_id = payload.get("client_id") | |
| if client_id is not None: | |
| self._identifier(client_id, "client_id") | |
| fingerprint = hashlib.sha256(json.dumps( | |
| [workflow, mode, auto_queue, name, client_id], sort_keys=True, allow_nan=False, | |
| ).encode()).hexdigest() | |
| previous = self.jobs.get(request_id) | |
| if previous: | |
| if previous["fingerprint"] != fingerprint: | |
| raise web.HTTPConflict(text="request_id was already used with a different payload") | |
| return self.summary(previous) | |
| if client_id is None: | |
| if len(self.clients) > 1: | |
| raise web.HTTPConflict(text="Multiple browser pages available; select client_id from GET /easy-media/workflow/clients") | |
| client_id = next(iter(self.clients), None) | |
| if client_id not in self.clients: | |
| raise web.HTTPServiceUnavailable(text="Open or refresh a ComfyUI page with the Easy Media extension first") | |
| if any(job["client_id"] == client_id and job["status"] in {"pending", "loading", "unknown"} for job in self.jobs.values()): | |
| raise web.HTTPConflict(text="This browser already has an unfinished submission; check its status before retrying") | |
| if len(self.jobs) >= 256: | |
| raise web.HTTPServiceUnavailable(text="Submission history is full; retry after old requests expire") | |
| job = dict(request_id=request_id, client_id=client_id, workflow=workflow, | |
| mode=mode, auto_queue=auto_queue, name=name, status="pending", | |
| created_at=time.time(), fingerprint=fingerprint) | |
| self.jobs[request_id] = job | |
| return self.summary(job) | |
| def poll(self, payload: dict) -> dict | None: | |
| self._prune() | |
| client_id = self._identifier(payload.get("client_id"), "client_id") | |
| session_id = self._identifier(payload.get("session_id"), "session_id") | |
| if not self.connected(session_id): | |
| raise web.HTTPConflict(text="ComfyUI WebSocket is not connected") | |
| title = payload.get("title", "ComfyUI") | |
| if not isinstance(title, str) or len(title) > 300: | |
| raise ValueError("title must be a string up to 300 characters") | |
| self.clients[client_id] = dict(client_id=client_id, session_id=session_id, title=title, last_seen=time.time()) | |
| for job in self.jobs.values(): | |
| if job["client_id"] == client_id and job["status"] == "pending": | |
| job.update(status="loading", claimed_at=time.time(), claim_token=str(uuid.uuid4())) | |
| delivery = {**self.summary(job), "workflow": job.pop("workflow"), "claim_token": job["claim_token"]} | |
| return delivery | |
| return None | |
| def get(self, request_id: str) -> dict: | |
| self._prune() | |
| if request_id not in self.jobs: | |
| raise web.HTTPNotFound(text="Unknown or expired request_id") | |
| return self.summary(self.jobs[request_id]) | |
| def complete(self, request_id: str, payload: dict) -> dict: | |
| self.get(request_id) | |
| job = self.jobs[request_id] | |
| if not job.get("claim_token") or payload.get("claim_token") != job["claim_token"]: | |
| raise web.HTTPForbidden(text="Invalid claim token") | |
| status = payload.get("status") | |
| if status not in ("loaded", "queued", "failed", "unknown"): | |
| raise ValueError("Invalid result status") | |
| result = {"status": status} | |
| if status == "queued": | |
| result["prompt_id"] = self._identifier(payload.get("prompt_id"), "prompt_id") | |
| if status in ("failed", "unknown"): | |
| error = payload.get("error") | |
| if not isinstance(error, str) or not error or len(error) > 4000: | |
| raise ValueError("error must be a non-empty string up to 4000 characters") | |
| result["error"] = error | |
| if job["status"] not in {"loading", "unknown"}: | |
| if all(job.get(key) == value for key, value in result.items()): | |
| return self.summary(job) | |
| raise web.HTTPConflict(text="Submission already finished") | |
| job.pop("error", None) | |
| job.update(result) | |
| return self.summary(job) | |
| def register_workflow_routes(routes: web.RouteTableDef, connected: Callable[[str], bool]) -> None: | |
| submissions = WorkflowSubmissions(connected) | |
| async def read_payload(request: web.Request) -> dict: | |
| # Reject cross-site form posts even when the server permits broad CORS. | |
| if request.content_type != "application/json": | |
| raise web.HTTPUnsupportedMediaType(text="Content-Type must be application/json") | |
| origin = request.headers.get("Origin") | |
| if origin and origin != f"{request.scheme}://{request.host}": | |
| raise web.HTTPForbidden(text="Cross-origin workflow control is not allowed") | |
| try: | |
| payload = await request.json() | |
| except (ValueError, UnicodeError) as error: | |
| raise web.HTTPBadRequest(text="Invalid JSON") from error | |
| if not isinstance(payload, dict): | |
| raise web.HTTPBadRequest(text="Request must contain a JSON object") | |
| return payload | |
| async def respond(action: Callable[..., object], *args: object) -> web.Response: | |
| try: | |
| return web.json_response(action(*args)) | |
| except ValueError as error: | |
| return web.json_response({"error": str(error)}, status=400) | |
| except web.HTTPException as error: | |
| return web.json_response({"error": error.text}, status=error.status) | |
| async def clients(_request: web.Request) -> web.Response: | |
| return web.json_response({"clients": submissions.list_clients()}) | |
| async def submit(request: web.Request) -> web.Response: | |
| return await respond(submissions.submit, await read_payload(request)) | |
| async def poll(request: web.Request) -> web.Response: | |
| return await respond(lambda data: {"job": submissions.poll(data)}, await read_payload(request)) | |
| async def status(request: web.Request) -> web.Response: | |
| return await respond(submissions.get, request.match_info["request_id"]) | |
| async def result(request: web.Request) -> web.Response: | |
| return await respond(submissions.complete, request.match_info["request_id"], await read_payload(request)) | |