├── src ├── workflows │ ├── __init__.py │ ├── clean_local_file.py │ ├── img2img.py │ └── text2img.py ├── main.py ├── s3 │ └── __init__.py ├── config │ └── __init__.py ├── database │ ├── repository.py │ └── __init__.py ├── api │ ├── service.py │ └── __init__.py └── comfyui │ └── __init__.py ├── .DS_Store ├── images ├── export_api.png └── flow_chart.png ├── test ├── client_example.py └── stress_test.py ├── LICENSE ├── requirements.txt ├── .gitignore └── README.md /src/workflows/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Poseidon-fan/ComfyUI-server/HEAD/.DS_Store -------------------------------------------------------------------------------- /images/export_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Poseidon-fan/ComfyUI-server/HEAD/images/export_api.png -------------------------------------------------------------------------------- /images/flow_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Poseidon-fan/ComfyUI-server/HEAD/images/flow_chart.png -------------------------------------------------------------------------------- /src/workflows/clean_local_file.py: -------------------------------------------------------------------------------- 1 | from string import Template 2 | 3 | _CLEAN_LOCAL_FILE_PROMPT = """{ 4 | "6": { 5 | "inputs": { 6 | "type": "$type", 7 | "path": "$path" 8 | }, 9 | "class_type": "Clean input and output file", 10 | "_meta": { 11 | "title": "file_cleaner" 12 | } 13 | } 14 | }""" 15 | 16 | CLEAN_LOCAL_FILE_PROMPT_TEMPLATE = Template(_CLEAN_LOCAL_FILE_PROMPT) 17 | -------------------------------------------------------------------------------- /test/client_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | this is an example of the client code written by fastapi that will interact with the server 3 | """ 4 | import uvicorn 5 | from fastapi import FastAPI 6 | 7 | app = FastAPI() 8 | 9 | 10 | @app.post("/callback") 11 | async def callback(data: dict): 12 | print(f"callback client data: {data}") 13 | 14 | 15 | if __name__ == "__main__": 16 | uvicorn.run("client_example:app", host="0.0.0.0", port=9000) 17 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import asynccontextmanager 3 | 4 | import uvicorn 5 | from fastapi import FastAPI 6 | 7 | from api import router 8 | from comfyui import comfyui_servers, logger 9 | from config import SERVICE_PORT 10 | from database import init_rdb 11 | 12 | init_rdb() 13 | 14 | 15 | @asynccontextmanager 16 | async def lifespan(app: FastAPI): 17 | tasks = [] 18 | for comfy_server in comfyui_servers: 19 | task = asyncio.create_task(comfy_server.listen()) 20 | tasks.append(task) 21 | 22 | yield 23 | 24 | for task in tasks: 25 | task.cancel() 26 | try: 27 | await task 28 | except asyncio.CancelledError: 29 | logger.info(f"task {task.get_name()} cancelled") 30 | 31 | 32 | app = FastAPI(lifespan=lifespan) 33 | 34 | app.include_router(router) 35 | 36 | if __name__ == "__main__": 37 | uvicorn.run("main:app", host="0.0.0.0", port=int(SERVICE_PORT)) 38 | -------------------------------------------------------------------------------- /src/s3/__init__.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from io import BytesIO 3 | 4 | from aiobotocore.session import get_session 5 | 6 | from config import AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET, S3_REGION_NAME 7 | 8 | session = get_session() 9 | 10 | 11 | async def upload_image_to_s3(image: bytes) -> dict: 12 | image_stream = BytesIO(image) 13 | key = f"{uuid.uuid4()}.png" 14 | async with session.create_client( 15 | "s3", 16 | region_name=S3_REGION_NAME, 17 | aws_secret_access_key=AWS_SECRET_ACCESS_KEY, 18 | aws_access_key_id=AWS_ACCESS_KEY_ID, 19 | ) as client: 20 | try: 21 | resp = await client.put_object(Bucket=S3_BUCKET, Key=key, Body=image_stream) 22 | if resp["ResponseMetadata"]["HTTPStatusCode"] == 200: 23 | return {"success": True, "key": key} 24 | return {"success": False, "key": key} 25 | except Exception: 26 | return {"success": False, "key": key} 27 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | COMFYUI_ENDPOINTS = [url.strip() for url in os.getenv("COMFYUI_ENDPOINTS", "localhost:8188").split(",")] 9 | 10 | AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "") 11 | AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "") 12 | S3_BUCKET = os.getenv("S3_BUCKET", "") 13 | S3_REGION_NAME = os.getenv("S3_REGION_NAME", "") 14 | 15 | RDB_USERNAME = os.getenv("RDB_USERNAME", "root") 16 | RDB_PASSWORD = os.getenv("RDB_PASSWORD", "123456") 17 | RDB_HOST = os.getenv("RDB_HOST", "localhost") 18 | RDB_PORT = os.getenv("RDB_PORT", 5432) 19 | RDB_NAME = os.getenv("RDB_NAME", "comfyui") 20 | 21 | DEFAULT_FAILED_IMAGE_PATH = os.getenv("DEFAULT_FAILED_IMAGE_PATH", "../tmp/failed_images") 22 | SERVICE_PORT = os.getenv("SERVICE_PORT", 8000) 23 | ROUTE_PREFIX = os.getenv("ROUTE_PREFIX", "/api/v1") 24 | 25 | logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") 26 | -------------------------------------------------------------------------------- /test/stress_test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import httpx 4 | 5 | url = "http://localhost:8000/api/v1" 6 | 7 | 8 | async def send_request(text: str, task_id: int): 9 | data = { 10 | "service_type": "text2img", 11 | "params": { 12 | "text": text, 13 | }, 14 | "client_task_id": task_id, 15 | } 16 | async with httpx.AsyncClient() as client: 17 | resp = await client.post(url, json=data) 18 | print(resp.json()) 19 | await asyncio.sleep(0.2) 20 | 21 | 22 | async def test(): 23 | await send_request("1boy", 100) 24 | await send_request("2boys", 101) 25 | await send_request("3boys", 102) 26 | await send_request("4boys", 103) 27 | await send_request("5boys", 104) 28 | await send_request("6boys", 105) 29 | await send_request("7boys", 106) 30 | await send_request("8boys", 107) 31 | await send_request("9boys", 108) 32 | await send_request("10boys", 109) 33 | 34 | 35 | if __name__ == "__main__": 36 | asyncio.run(test()) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Poseidon-fan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aio-pika==9.4.3 2 | aiobotocore==2.15.2 3 | aiofiles==24.1.0 4 | aiohappyeyeballs==2.4.3 5 | aiohttp==3.11.2 6 | aioitertools==0.12.0 7 | aiormq==6.8.1 8 | aiosignal==1.3.1 9 | annotated-types==0.7.0 10 | anyio==4.6.2.post1 11 | attrs==24.2.0 12 | botocore==1.35.36 13 | certifi==2024.8.30 14 | click==8.1.7 15 | dnspython==2.7.0 16 | email_validator==2.2.0 17 | fastapi==0.115.4 18 | fastapi-cli==0.0.5 19 | frozenlist==1.5.0 20 | greenlet==3.1.1 21 | h11==0.14.0 22 | httpcore==1.0.6 23 | httptools==0.6.4 24 | httpx==0.27.2 25 | idna==3.10 26 | Jinja2==3.1.4 27 | jmespath==1.0.1 28 | markdown-it-py==3.0.0 29 | MarkupSafe==3.0.2 30 | mdurl==0.1.2 31 | multidict==6.1.0 32 | pamqp==3.3.0 33 | propcache==0.2.0 34 | psycopg==3.2.3 35 | psycopg-binary==3.2.3 36 | pydantic==2.9.2 37 | pydantic_core==2.23.4 38 | Pygments==2.18.0 39 | python-dateutil==2.9.0.post0 40 | python-dotenv==1.0.1 41 | python-multipart==0.0.17 42 | PyYAML==6.0.2 43 | rich==13.9.4 44 | shellingham==1.5.4 45 | six==1.16.0 46 | sniffio==1.3.1 47 | SQLAlchemy==2.0.36 48 | starlette==0.41.2 49 | typer==0.12.5 50 | typing_extensions==4.12.2 51 | urllib3==2.2.3 52 | uvicorn==0.32.0 53 | uvloop==0.21.0 54 | watchfiles==0.24.0 55 | websockets==13.1 56 | wrapt==1.16.0 57 | yarl==1.17.1 58 | -------------------------------------------------------------------------------- /src/database/repository.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | from sqlalchemy.ext.asyncio import async_sessionmaker 3 | 4 | from database import ComfyUIRecord, sql_engine 5 | 6 | 7 | class RecordRepository: 8 | @staticmethod 9 | async def create(comfyui_record: ComfyUIRecord) -> ComfyUIRecord: 10 | async_session = async_sessionmaker(sql_engine) 11 | async with async_session() as session: 12 | session.add(comfyui_record) 13 | await session.commit() 14 | await session.refresh(comfyui_record) 15 | return comfyui_record 16 | 17 | @staticmethod 18 | async def retrieve_by_comfyui_task_id(comfy_task_id: str) -> ComfyUIRecord: 19 | async_session = async_sessionmaker(sql_engine) 20 | async with async_session() as session: 21 | stmt = select(ComfyUIRecord).where(ComfyUIRecord.comfyui_task_id == comfy_task_id) 22 | result = await session.execute(stmt) 23 | record = result.scalars().first() 24 | return record 25 | 26 | @staticmethod 27 | async def update(comfyui_record: ComfyUIRecord) -> ComfyUIRecord: 28 | async_session = async_sessionmaker(sql_engine) 29 | async with async_session() as session: 30 | session.add(comfyui_record) 31 | await session.commit() 32 | await session.refresh(comfyui_record) 33 | return comfyui_record 34 | -------------------------------------------------------------------------------- /src/api/service.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | 4 | from comfyui import comfyui_servers 5 | from database import ComfyUIRecord 6 | from workflows.img2img import IMG2IMG_COMFYUI_PROMPT_TEMPLATE 7 | from workflows.text2img import TEXT2IMG_COMFYUI_PROMPT_TEMPLATE 8 | 9 | 10 | def _get_comfyui_server(): 11 | """schedule the comfyui server with the least queue remaining""" 12 | return min(comfyui_servers, key=lambda x: x.queue_remaining) 13 | 14 | 15 | class Service: 16 | @staticmethod 17 | async def text2img(client_task_id: str, client_callback_url: str, params: dict) -> ComfyUIRecord: 18 | comfyui_server = _get_comfyui_server() 19 | text = params.get("text") 20 | prompt_str = TEXT2IMG_COMFYUI_PROMPT_TEMPLATE.substitute(text=text) 21 | prompt_json = json.loads(prompt_str) 22 | return await comfyui_server.queue_prompt(client_task_id, client_callback_url, prompt_json) 23 | 24 | @staticmethod 25 | async def img2img(client_task_id: str, client_callback_url: str, params: dict) -> ComfyUIRecord: 26 | comfyui_server = _get_comfyui_server() 27 | text = params.get("text") 28 | image_base64 = params.get("image") 29 | 30 | # upload image to comfyui 31 | image_bytes = base64.b64decode(image_base64) 32 | resp = await comfyui_server.upload_image(image_bytes) 33 | image_path = resp["name"] 34 | if resp["subfolder"]: 35 | image_path = f"{resp['subfolder']}/{image_path}" 36 | 37 | # create prompt 38 | prompt_str = IMG2IMG_COMFYUI_PROMPT_TEMPLATE.substitute(text=text, image=image_path) 39 | prompt_json = json.loads(prompt_str) 40 | try: 41 | return await comfyui_server.queue_prompt(client_task_id, client_callback_url, prompt_json) 42 | finally: 43 | # clean up the input file after the prompt is queued 44 | await comfyui_server.clean_local_file(is_input=True, image_path=image_path) 45 | -------------------------------------------------------------------------------- /src/api/__init__.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Any, Callable 3 | 4 | from fastapi import APIRouter 5 | from pydantic import BaseModel 6 | 7 | from api.service import Service 8 | from config import ROUTE_PREFIX 9 | 10 | 11 | class CustomAPIRouter(APIRouter): 12 | """ 13 | Custom APIRouter that excludes None values from response models by default. 14 | """ 15 | 16 | def get(self, path: str, *, response_model_exclude_none: bool = True, **kwargs: Any) -> Callable: 17 | return super().get(path, response_model_exclude_none=response_model_exclude_none, **kwargs) 18 | 19 | def post(self, path: str, *, response_model_exclude_none: bool = True, **kwargs: Any) -> Callable: 20 | return super().post(path, response_model_exclude_none=response_model_exclude_none, **kwargs) 21 | 22 | def put(self, path: str, *, response_model_exclude_none: bool = True, **kwargs: Any) -> Callable: 23 | return super().put(path, response_model_exclude_none=response_model_exclude_none, **kwargs) 24 | 25 | def delete(self, path: str, *, response_model_exclude_none: bool = True, **kwargs: Any) -> Callable: 26 | return super().delete(path, response_model_exclude_none=response_model_exclude_none, **kwargs) 27 | 28 | def patch(self, path: str, *, response_model_exclude_none: bool = True, **kwargs: Any) -> Callable: 29 | return super().patch(path, response_model_exclude_none=response_model_exclude_none, **kwargs) 30 | 31 | 32 | router = CustomAPIRouter(prefix=ROUTE_PREFIX) 33 | 34 | 35 | class ServiceType(Enum): 36 | TEXT2IMG = "text2img" 37 | IMG2IMG = "img2img" 38 | 39 | 40 | class RequestDTO(BaseModel): 41 | service_type: ServiceType 42 | client_task_id: str 43 | client_callback_url: str 44 | params: dict 45 | 46 | 47 | @router.post("") 48 | async def queue_prompt(request_dto: RequestDTO): 49 | """commit a prompt to the comfyui server""" 50 | service_func = getattr(Service, request_dto.service_type.value) 51 | return await service_func(request_dto.client_task_id, request_dto.client_callback_url, request_dto.params) 52 | -------------------------------------------------------------------------------- /src/database/__init__.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from sqlalchemy import Index, Integer, String, create_engine, text 4 | from sqlalchemy.ext.asyncio import create_async_engine 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy.orm import Mapped, mapped_column 7 | 8 | from config import RDB_HOST, RDB_NAME, RDB_PASSWORD, RDB_PORT, RDB_USERNAME 9 | 10 | _url = f"postgresql+psycopg://{RDB_USERNAME}:{RDB_PASSWORD}@{RDB_HOST}:{RDB_PORT}/{RDB_NAME}" 11 | sql_engine = create_async_engine(_url, pool_pre_ping=True) 12 | 13 | Base = declarative_base() 14 | 15 | 16 | class ErrorCode(Enum): 17 | SUCCESS = 0 18 | COMFYUI_QUEUE_PROMPT_ERROR = 1 19 | S3_UPLOAD_ERROR = 2 20 | COMFYUI_RETRIEVE_IMAGE_ERROR = 3 21 | UNKNOWN_ERROR = 10 22 | 23 | 24 | class ComfyUIRecord(Base): 25 | __tablename__ = "comfyui_records" 26 | __table_args__ = (Index("idx_comfyui_task_id", "comfyui_task_id"),) 27 | 28 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 29 | client_task_id: Mapped[str] = mapped_column(String) 30 | client_callback_url: Mapped[str] = mapped_column(String) 31 | comfyui_task_id: Mapped[str | None] = mapped_column(String) 32 | comfyui_filepath: Mapped[str | None] = mapped_column(String) 33 | s3_key: Mapped[str | None] = mapped_column(String) 34 | error_code: Mapped[ErrorCode | None] = mapped_column( 35 | Integer, 36 | nullable=True, 37 | default=ErrorCode.SUCCESS.value, 38 | server_default=str(ErrorCode.SUCCESS.value), 39 | ) 40 | 41 | def to_dict(self): 42 | result = {} 43 | for key, value in vars(self).items(): 44 | if key.startswith("_"): 45 | continue 46 | if isinstance(value, ErrorCode): 47 | result[key] = value.value 48 | else: 49 | result[key] = value 50 | return result 51 | 52 | def __repr__(self): 53 | return ( 54 | f"" 56 | ) 57 | 58 | 59 | def init_rdb(): 60 | engine = create_engine(_url, echo=True, pool_pre_ping=True) 61 | with engine.begin() as conn: 62 | # Base.metadata.drop_all(conn) 63 | conn.execute(text("CREATE SCHEMA IF NOT EXISTS app")) 64 | Base.metadata.create_all(conn) 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | .idea/ 163 | -------------------------------------------------------------------------------- /src/workflows/img2img.py: -------------------------------------------------------------------------------- 1 | from string import Template 2 | 3 | _IMG2IMG_COMFYUI_PROMPT = """{ 4 | "6": { 5 | "inputs": { 6 | "text": "$text", 7 | "clip": [ 8 | "11", 9 | 0 10 | ] 11 | }, 12 | "class_type": "CLIPTextEncode", 13 | "_meta": { 14 | "title": "CLIP Text Encode (Prompt)" 15 | } 16 | }, 17 | "8": { 18 | "inputs": { 19 | "samples": [ 20 | "13", 21 | 0 22 | ], 23 | "vae": [ 24 | "10", 25 | 0 26 | ] 27 | }, 28 | "class_type": "VAEDecode", 29 | "_meta": { 30 | "title": "VAE Decode" 31 | } 32 | }, 33 | "10": { 34 | "inputs": { 35 | "vae_name": "ae.safetensors" 36 | }, 37 | "class_type": "VAELoader", 38 | "_meta": { 39 | "title": "Load VAE" 40 | } 41 | }, 42 | "11": { 43 | "inputs": { 44 | "clip_name1": "t5xxl_fp8_e4m3fn.safetensors", 45 | "clip_name2": "clip_l.safetensors", 46 | "type": "flux" 47 | }, 48 | "class_type": "DualCLIPLoader", 49 | "_meta": { 50 | "title": "DualCLIPLoader" 51 | } 52 | }, 53 | "12": { 54 | "inputs": { 55 | "unet_name": "flux1-dev.safetensors", 56 | "weight_dtype": "fp8_e4m3fn" 57 | }, 58 | "class_type": "UNETLoader", 59 | "_meta": { 60 | "title": "Load Diffusion Model" 61 | } 62 | }, 63 | "13": { 64 | "inputs": { 65 | "noise": [ 66 | "50", 67 | 0 68 | ], 69 | "guider": [ 70 | "22", 71 | 0 72 | ], 73 | "sampler": [ 74 | "16", 75 | 0 76 | ], 77 | "sigmas": [ 78 | "38", 79 | 1 80 | ], 81 | "latent_image": [ 82 | "94", 83 | 0 84 | ] 85 | }, 86 | "class_type": "SamplerCustomAdvanced", 87 | "_meta": { 88 | "title": "SamplerCustomAdvanced" 89 | } 90 | }, 91 | "16": { 92 | "inputs": { 93 | "sampler_name": "deis" 94 | }, 95 | "class_type": "KSamplerSelect", 96 | "_meta": { 97 | "title": "KSamplerSelect" 98 | } 99 | }, 100 | "17": { 101 | "inputs": { 102 | "scheduler": "sgm_uniform", 103 | "steps": 4, 104 | "denoise": 0.5, 105 | "model": [ 106 | "12", 107 | 0 108 | ] 109 | }, 110 | "class_type": "BasicScheduler", 111 | "_meta": { 112 | "title": "BasicScheduler" 113 | } 114 | }, 115 | "22": { 116 | "inputs": { 117 | "model": [ 118 | "12", 119 | 0 120 | ], 121 | "conditioning": [ 122 | "6", 123 | 0 124 | ] 125 | }, 126 | "class_type": "BasicGuider", 127 | "_meta": { 128 | "title": "BasicGuider" 129 | } 130 | }, 131 | "38": { 132 | "inputs": { 133 | "step": 0, 134 | "sigmas": [ 135 | "17", 136 | 0 137 | ] 138 | }, 139 | "class_type": "SplitSigmas", 140 | "_meta": { 141 | "title": "SplitSigmas" 142 | } 143 | }, 144 | "50": { 145 | "inputs": { 146 | "noise_seed": 820514797502403 147 | }, 148 | "class_type": "RandomNoise", 149 | "_meta": { 150 | "title": "RandomNoise" 151 | } 152 | }, 153 | "82": { 154 | "inputs": { 155 | "image": "$image", 156 | "upload": "image" 157 | }, 158 | "class_type": "LoadImage", 159 | "_meta": { 160 | "title": "Load Image" 161 | } 162 | }, 163 | "94": { 164 | "inputs": { 165 | "pixels": [ 166 | "82", 167 | 0 168 | ], 169 | "vae": [ 170 | "10", 171 | 0 172 | ] 173 | }, 174 | "class_type": "VAEEncode", 175 | "_meta": { 176 | "title": "VAE Encode" 177 | } 178 | }, 179 | "102": { 180 | "inputs": { 181 | "filename_prefix": "ComfyUI", 182 | "images": [ 183 | "8", 184 | 0 185 | ] 186 | }, 187 | "class_type": "SaveImage", 188 | "_meta": { 189 | "title": "Save Image" 190 | } 191 | } 192 | } 193 | """ 194 | 195 | IMG2IMG_COMFYUI_PROMPT_TEMPLATE = Template(_IMG2IMG_COMFYUI_PROMPT) 196 | -------------------------------------------------------------------------------- /src/workflows/text2img.py: -------------------------------------------------------------------------------- 1 | from string import Template 2 | 3 | _TEXT2IMG_COMFYUI_PROMPT = """{ 4 | "6": { 5 | "inputs": { 6 | "text": "$text", 7 | "clip": [ 8 | "11", 9 | 0 10 | ] 11 | }, 12 | "class_type": "CLIPTextEncode", 13 | "_meta": { 14 | "title": "CLIP Text Encode (Positive Prompt)" 15 | } 16 | }, 17 | "8": { 18 | "inputs": { 19 | "samples": [ 20 | "13", 21 | 0 22 | ], 23 | "vae": [ 24 | "10", 25 | 0 26 | ] 27 | }, 28 | "class_type": "VAEDecode", 29 | "_meta": { 30 | "title": "VAE Decode" 31 | } 32 | }, 33 | "9": { 34 | "inputs": { 35 | "filename_prefix": "ComfyUI", 36 | "images": [ 37 | "8", 38 | 0 39 | ] 40 | }, 41 | "class_type": "SaveImage", 42 | "_meta": { 43 | "title": "Save Image" 44 | } 45 | }, 46 | "10": { 47 | "inputs": { 48 | "vae_name": "ae.safetensors" 49 | }, 50 | "class_type": "VAELoader", 51 | "_meta": { 52 | "title": "Load VAE" 53 | } 54 | }, 55 | "11": { 56 | "inputs": { 57 | "clip_name1": "t5xxl_fp8_e4m3fn.safetensors", 58 | "clip_name2": "clip_l.safetensors", 59 | "type": "flux" 60 | }, 61 | "class_type": "DualCLIPLoader", 62 | "_meta": { 63 | "title": "DualCLIPLoader" 64 | } 65 | }, 66 | "12": { 67 | "inputs": { 68 | "unet_name": "flux1-dev.safetensors", 69 | "weight_dtype": "default" 70 | }, 71 | "class_type": "UNETLoader", 72 | "_meta": { 73 | "title": "Load Diffusion Model" 74 | } 75 | }, 76 | "13": { 77 | "inputs": { 78 | "noise": [ 79 | "25", 80 | 0 81 | ], 82 | "guider": [ 83 | "22", 84 | 0 85 | ], 86 | "sampler": [ 87 | "16", 88 | 0 89 | ], 90 | "sigmas": [ 91 | "17", 92 | 0 93 | ], 94 | "latent_image": [ 95 | "27", 96 | 0 97 | ] 98 | }, 99 | "class_type": "SamplerCustomAdvanced", 100 | "_meta": { 101 | "title": "SamplerCustomAdvanced" 102 | } 103 | }, 104 | "16": { 105 | "inputs": { 106 | "sampler_name": "euler" 107 | }, 108 | "class_type": "KSamplerSelect", 109 | "_meta": { 110 | "title": "KSamplerSelect" 111 | } 112 | }, 113 | "17": { 114 | "inputs": { 115 | "scheduler": "simple", 116 | "steps": 20, 117 | "denoise": 1, 118 | "model": [ 119 | "30", 120 | 0 121 | ] 122 | }, 123 | "class_type": "BasicScheduler", 124 | "_meta": { 125 | "title": "BasicScheduler" 126 | } 127 | }, 128 | "22": { 129 | "inputs": { 130 | "model": [ 131 | "30", 132 | 0 133 | ], 134 | "conditioning": [ 135 | "26", 136 | 0 137 | ] 138 | }, 139 | "class_type": "BasicGuider", 140 | "_meta": { 141 | "title": "BasicGuider" 142 | } 143 | }, 144 | "25": { 145 | "inputs": { 146 | "noise_seed": 220436814890502 147 | }, 148 | "class_type": "RandomNoise", 149 | "_meta": { 150 | "title": "RandomNoise" 151 | } 152 | }, 153 | "26": { 154 | "inputs": { 155 | "guidance": 3.5, 156 | "conditioning": [ 157 | "6", 158 | 0 159 | ] 160 | }, 161 | "class_type": "FluxGuidance", 162 | "_meta": { 163 | "title": "FluxGuidance" 164 | } 165 | }, 166 | "27": { 167 | "inputs": { 168 | "width": 1024, 169 | "height": 1024, 170 | "batch_size": 1 171 | }, 172 | "class_type": "EmptySD3LatentImage", 173 | "_meta": { 174 | "title": "EmptySD3LatentImage" 175 | } 176 | }, 177 | "30": { 178 | "inputs": { 179 | "max_shift": 1.15, 180 | "base_shift": 0.5, 181 | "width": 1024, 182 | "height": 1024, 183 | "model": [ 184 | "12", 185 | 0 186 | ] 187 | }, 188 | "class_type": "ModelSamplingFlux", 189 | "_meta": { 190 | "title": "ModelSamplingFlux" 191 | } 192 | } 193 | } 194 | """ 195 | 196 | TEXT2IMG_COMFYUI_PROMPT_TEMPLATE = Template(_TEXT2IMG_COMFYUI_PROMPT) 197 | -------------------------------------------------------------------------------- /src/comfyui/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import uuid 6 | 7 | import aiofiles 8 | import httpx 9 | import websockets 10 | 11 | from config import COMFYUI_ENDPOINTS, DEFAULT_FAILED_IMAGE_PATH 12 | from database import ComfyUIRecord, ErrorCode 13 | from database.repository import RecordRepository 14 | from s3 import upload_image_to_s3 15 | from workflows.clean_local_file import CLEAN_LOCAL_FILE_PROMPT_TEMPLATE 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class ComfyUIServer: 21 | def __init__(self, endpoint: str): 22 | self.async_http_client = httpx.AsyncClient(timeout=30.0) 23 | self.queue_remaining = 0 24 | self.endpoint = endpoint 25 | self.client_id = uuid.uuid4().hex 26 | self.default_failed_image_path = DEFAULT_FAILED_IMAGE_PATH 27 | 28 | async def queue_prompt(self, client_task_id: str, client_callback_url: str, prompt: dict) -> ComfyUIRecord: 29 | """commit a prompt to the comfyui server""" 30 | comfyui_record = ComfyUIRecord(client_task_id=client_task_id, client_callback_url=client_callback_url) 31 | comfyui_record = await RecordRepository.create(comfyui_record) 32 | 33 | uri = f"http://{self.endpoint}/prompt" 34 | payload = {"prompt": prompt, "client_id": self.client_id} 35 | try: 36 | response = await self.async_http_client.post(uri, json=payload) 37 | logger.debug(f"queue prompt response: {response.text}") 38 | comfyui_task_id = response.json()["prompt_id"] 39 | comfyui_record.comfyui_task_id = comfyui_task_id 40 | comfyui_record = await RecordRepository.update(comfyui_record) 41 | except Exception: 42 | logger.exception("queue prompt error") 43 | comfyui_record.error_code = ErrorCode.COMFYUI_QUEUE_PROMPT_ERROR.value 44 | comfyui_record = await RecordRepository.update(comfyui_record) 45 | return comfyui_record 46 | 47 | async def listen(self): 48 | """listen messages from the comfyui server""" 49 | uri = f"ws://{self.endpoint}/ws?clientId={self.client_id}" 50 | async with websockets.connect(uri) as websocket: 51 | logger.info("connected to comfyui server") 52 | while True: 53 | try: 54 | message = await websocket.recv() 55 | json_data = json.loads(message) 56 | if json_data.get("type") == "executing" and json_data.get("data", {}).get("node") is None: 57 | # comfyui server has finished the prompt task 58 | try: 59 | # 1. retrieve the image from comfyui 60 | comfyui_task_id = json_data["data"]["prompt_id"] 61 | comfyui_record = await RecordRepository.retrieve_by_comfyui_task_id(comfyui_task_id) 62 | retrieve_resp = await self._retrieve_image(comfyui_task_id) 63 | if not retrieve_resp["success"]: 64 | comfyui_record.error_code = ErrorCode.COMFYUI_RETRIEVE_IMAGE_ERROR.value 65 | comfyui_record = await RecordRepository.update(comfyui_record) 66 | await self.client_callback(comfyui_record) 67 | continue 68 | comfyui_record = await RecordRepository.retrieve_by_comfyui_task_id(comfyui_task_id) 69 | comfyui_filepath = comfyui_record.comfyui_filepath 70 | 71 | # 2. upload the image to s3 72 | image = retrieve_resp["image"] 73 | s3_resp = await upload_image_to_s3(image) 74 | logger.info(f"uploaded image to s3: {s3_resp}") 75 | if not s3_resp["success"]: 76 | logger.error(f"upload image to s3 error: {s3_resp}") 77 | comfyui_record.error_code = ErrorCode.S3_UPLOAD_ERROR.value 78 | comfyui_record = await RecordRepository.update(comfyui_record) 79 | await self.store_failed_image(comfyui_record, image) 80 | await self.client_callback(comfyui_record) 81 | continue 82 | 83 | # 3. success, callback to the client 84 | comfyui_record.s3_key = s3_resp["key"] 85 | comfyui_record = await RecordRepository.update(comfyui_record) 86 | await self.client_callback(comfyui_record) 87 | 88 | except Exception: 89 | logger.exception("webhook or s3 error") 90 | await self.store_failed_image(comfyui_record, image) 91 | if comfyui_record.error_code == 0: 92 | comfyui_record.error_code = ErrorCode.UNKNOWN_ERROR.value 93 | comfyui_record = await RecordRepository.update(comfyui_record) 94 | await self.client_callback(comfyui_record) 95 | finally: 96 | await self.clean_local_file(is_input=False, image_path=comfyui_filepath) 97 | 98 | elif json_data["type"] == "status": 99 | # update queue remaining num 100 | self.queue_remaining = json_data["data"]["status"]["exec_info"]["queue_remaining"] 101 | logger.info(f"server {self.client_id} remaining: {self.queue_remaining}") 102 | 103 | except websockets.exceptions.ConnectionClosed: 104 | logger.warning("connection closed, reconnecting...") 105 | await asyncio.sleep(5) 106 | await self.listen() 107 | except Exception: 108 | logger.exception(f"server {self.client_id} websocket error") 109 | 110 | async def _retrieve_image(self, comfyui_task_id: str) -> dict: 111 | """retrieve prompt task result(image) from comfyui""" 112 | try: 113 | # 1. get the image path from the comfyui server 114 | history_uri = f"http://{self.endpoint}/history/{comfyui_task_id}" 115 | response = await self.async_http_client.get(history_uri) 116 | history = response.json() 117 | output_info = history[comfyui_task_id]["outputs"] 118 | for key in output_info: 119 | if "images" not in output_info[key]: 120 | continue 121 | image_info = output_info[key]["images"][0] # note now only retrieve the first image 122 | image_path = image_info["filename"] 123 | if image_info["subfolder"]: 124 | image_path = f"{image_info['subfolder']}/{image_path}" 125 | 126 | comfyui_record = await RecordRepository.retrieve_by_comfyui_task_id(comfyui_task_id) 127 | comfyui_record.comfyui_filepath = image_path 128 | await RecordRepository.update(comfyui_record) 129 | 130 | # 2. retrieve the image from the comfyui server 131 | view_uri = f"http://{self.endpoint}/view" 132 | params = {"filename": image_path} 133 | response = await self.async_http_client.get(view_uri, params=params) 134 | return {"success": True, "image": response.content} 135 | 136 | except Exception: 137 | logger.exception("retrieve image error") 138 | return {"success": False} 139 | 140 | async def client_callback(self, comfyui_record: ComfyUIRecord): 141 | """callback to the client server""" 142 | response = await self.async_http_client.post(comfyui_record.client_callback_url, json=comfyui_record.to_dict()) 143 | logger.debug(f"callback response: {response.text}") 144 | return response.json() 145 | 146 | async def clean_local_file(self, is_input: bool, image_path: str): 147 | """clean input or output file from the comfyui server""" 148 | prompt = CLEAN_LOCAL_FILE_PROMPT_TEMPLATE.substitute(type="input" if is_input else "output", path=image_path) 149 | prompt_json = json.loads(prompt) 150 | uri = f"http://{self.endpoint}/prompt" 151 | payload = { 152 | "prompt": prompt_json, 153 | # NOTE ignore client_id for now, in case of tracking the clean file system message 154 | } 155 | response = await self.async_http_client.post(uri, json=payload) 156 | logger.debug(f"clean file response: {response.text}") 157 | 158 | async def upload_image(self, image: bytes): 159 | """upload image to the comfyui server""" 160 | file_name = f"{uuid.uuid4()}.png" 161 | uri = f"http://{self.endpoint}/upload/image" 162 | response = await self.async_http_client.post(uri, files={"image": (file_name, image, "image/png")}) 163 | logger.debug(f"upload image response: {response.text}") 164 | return response.json() 165 | 166 | async def store_failed_image(self, comfyui_record: ComfyUIRecord, image: bytes): 167 | """Store failure prompt result in the fallback path.""" 168 | file_path = os.path.join(self.default_failed_image_path, comfyui_record.comfyui_filepath) 169 | os.makedirs(os.path.dirname(file_path), exist_ok=True) 170 | 171 | async with aiofiles.open(file_path, "wb") as f: 172 | await f.write(image) 173 | 174 | 175 | comfyui_servers = [ComfyUIServer(endpoint) for endpoint in COMFYUI_ENDPOINTS] 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ComfyUI-server 2 | Due to the incomplete backend API interface provided by ComfyUI, it is very inconvenient to deploy ComfyUI related workflow microservices in one's own application. Therefore, I wrote this project as an enhanced backend implementation solution for ComfyUI. 3 | 4 | **Contributions are welcome! Feel free to star, fork, and submit PRs to help improve this project. 😊** 5 | 6 | ## Features 7 | - Support deploying **any** ComfyUI workflow in a factory-like manner. 8 | - Integrate and schedule multiple Comfyui services, provide services to multiple clients. 9 | - Store the generated image in S3. 10 | - Monitor ComfyUI service and notify the client through **webhook** when image generation is complete. 11 | - Automatically clean up excess local input and output image files. 12 | - Record task flow and save error files. 13 | 14 | The system architecture diagram is as follows: 15 | ![flow_chart](./images/flow_chart.png) 16 | 17 | ## Usage 18 | 1. clone this repository 19 | ```bash 20 | git clone https://github.com/Poseidon-fan/ComfyUI-server.git 21 | ``` 22 | 23 | 2. configure your environment variables 24 | 25 | Create `.env` file in ComfyUI-server root folder with the following variables: 26 | ```text 27 | COMFYUI_ENDPOINTS = "localhost:8188,localhost:8189,localhost:8190" # comfyui endpoints, separated by commas 28 | 29 | AWS_SECRET_ACCESS_KEY = "secret_access_key" # your aws secret access key 30 | AWS_ACCESS_KEY_ID = "access_key_id" # your aws access key id 31 | S3_BUCKET = "comfyui-server" # your s3 bucket name 32 | S3_REGION_NAME = "us-west-1" # your s3 region name 33 | 34 | RDB_USERNAME = "root" # postgres username 35 | RDB_PASSWORD = "123456" # postgres password 36 | RDB_HOST = "localhost" # postgres host 37 | RDB_PORT = "5432" # postgres port 38 | RDB_NAME = "comfy" # postgres database name 39 | 40 | DEFAULT_FAILED_IMAGE_PATH = "../tmp/failed_images" # path of the failed images 41 | 42 | SERVICE_PORT = "8000" # service port 43 | ROUTE_PREFIX = "/api/v1" # api route prefix 44 | ``` 45 | 46 | 3. install [fileCleaner node](https://github.com/Poseidon-fan/ComfyUI-fileCleaner) 47 | 48 | make sure your comfyUI has already installed this custom node. 49 | 50 | 4. run the server 51 | ```bash 52 | cd ComfyUI-server 53 | python -m venv venv # create virtual environment 54 | # activate virtual environment 55 | # on windows, use venv\Scripts\activate 56 | source venv/bin/activate 57 | pip install -r requirements.txt 58 | cd src 59 | python main.py 60 | ``` 61 | 62 | ## How it works 63 | You could refer to the flow chart above to understand the workflow of this project. Here is a brief introduction to the main components: 64 | 65 | ### interact with ComfyUI 66 | By default, the APi working mode of ComfyUI is: 67 | - The information sent by the user to ComfyUI contains two fields: 68 | - `prompt`: contains workflow information for ComfyUI 69 | - `clientId`: request sender's identification 70 | 71 | Once received a prompt, ComfyUI will generate a prompt_id and send back. I call it `comfy_task_id`. 72 | 73 | - You can establish a websocket connection to ComfyUI based on the clientId. ComfyUI will send the processing information for requests with the same clientId. 74 | - The SaveImage and LoadImage nodes native to ComfyUI can only retrieve/output files from local folders. 75 | 76 | So, for each `ComfyServer`, specify a unique clientId, and use the clientId to establish a websocket connection with ComfyUI. 77 | For the messages sent from the ComfyUI, I manually filtered out the information of task completion and traced back to the results of the task. 78 | 79 | ### schedule multiple ComfyUI services 80 | Monitor the remaining number of tasks in the current queue of each Comfyui service through the websocket link, 81 | and select the service with the smallest number of tasks to send the prompt. 82 | 83 | ### Provide external interfaces 84 | I use fastapi, which is a python web framework, to provide external interfaces. 85 | The core code is in `src/api` folder, which contains the following files: 86 | ```python 87 | # src/api/__init__.py 88 | 89 | class ServiceType(Enum): 90 | TEXT2IMG = "text2img" 91 | IMG2IMG = "img2img" 92 | 93 | 94 | class RequestDTO(BaseModel): 95 | service_type: ServiceType 96 | client_task_id: str 97 | client_callback_url: str 98 | params: dict 99 | 100 | 101 | @router.post("") 102 | async def queue_prompt(request_dto: RequestDTO): 103 | """commit a prompt to the comfyui server""" 104 | service_func = getattr(Service, request_dto.service_type.value) 105 | return await service_func(request_dto.client_task_id, request_dto.client_callback_url, request_dto.params) 106 | ``` 107 | The ServiceType enum class contains the service types that can be provided, and the RequestDTO class is used to receive the request parameters. The `queue_prompt` function is the main entry point for the external interface, which will call the corresponding service function according to the service type. 108 | 109 | ```python 110 | # src/api/service.py 111 | def _get_comfyui_server(): 112 | """schedule the comfyui server with the least queue remaining""" 113 | return min(comfyui_servers, key=lambda x: x.queue_remaining) 114 | 115 | 116 | class Service: 117 | @staticmethod 118 | async def text2img(client_task_id: str, client_callback_url: str, params: dict) -> ComfyUIRecord: 119 | comfyui_server = _get_comfyui_server() 120 | text = params.get("text") 121 | prompt_str = TEXT2IMG_COMFYUI_PROMPT_TEMPLATE.substitute(text=text) 122 | prompt_json = json.loads(prompt_str) 123 | return await comfyui_server.queue_prompt(client_task_id, client_callback_url, prompt_json) 124 | 125 | @staticmethod 126 | async def img2img(client_task_id: str, client_callback_url: str, params: dict) -> ComfyUIRecord: 127 | comfyui_server = _get_comfyui_server() 128 | text = params.get("text") 129 | image_base64 = params.get("image") 130 | 131 | # upload image to comfyui 132 | image_bytes = base64.b64decode(image_base64) 133 | resp = await comfyui_server.upload_image(image_bytes) 134 | image_path = resp["name"] 135 | if resp["subfolder"]: 136 | image_path = f"{resp['subfolder']}/{image_path}" 137 | 138 | # create prompt 139 | prompt_str = IMG2IMG_COMFYUI_PROMPT_TEMPLATE.substitute(text=text, image=image_path) 140 | prompt_json = json.loads(prompt_str) 141 | try: 142 | return await comfyui_server.queue_prompt(client_task_id, client_callback_url, prompt_json) 143 | finally: 144 | # clean up the input file after the prompt is queued 145 | await comfyui_server.clean_local_file(is_input=True, image_path=image_path) 146 | ``` 147 | The `Service` class contains the service functions that can be provided. The above are two examples. 148 | 149 | The workflow templates are stored in `src/workflows`, which can be configured according to your workflow requirements. 150 | 151 | ### Upload result image to S3 152 | The code could be found in `src/s3`, it's just a basic encapsulation of the aiobotocore library. 153 | 154 | ### Webhook 155 | When the image generation is complete, the ComfyUI server will send a message to the client through the websocket. 156 | After retrieving the image and, record in database and upload to s3, the server will send a request to the client's callback url. 157 | 158 | ### clean local input and output images 159 | Please refer to this custom node of ComfyUI: [https://github.com/Poseidon-fan/ComfyUI-fileCleaner](https://github.com/Poseidon-fan/ComfyUI-fileCleaner) 160 | 161 | ### record task flow and save error files 162 | I use postgres as rdb to record these information: 163 | 164 | | field | introduction | 165 | |---------------------|------------------------------------| 166 | | id | primary key | 167 | | client_task_id | the task id sent from the client | 168 | | client_callback_url | web hook url | 169 | | comfyui_task_id | the prompt_id generated by ComfyUI | 170 | | s3_key | key of the s3 object | 171 | | comfy_filepath | image path locally | 172 | | error_code | error code of process | | 173 | 174 | when there's an error in the process, the error file will be saved in the fallback path and record according error_code. 175 | Its path is the same as comfy_filepath. 176 | 177 | ## How to add a new workflow 178 | Here, I take the example of the text production workflow of the flux model in the repository. 179 | 1. go to your comfyui and export workflow API: 180 | 181 | ![export_api](./images/export_api.png) 182 | 183 | 2. create a new file in `src/workflows` folder, and paste the exported API into it. Replace the parameters you want to configure with $ (python template string format). 184 | 3. add an enum member in `src/api/__init__.py` ServiceType class. 185 | 4. create a function in `src/api/service.py` Service class, make sure the function name is the same as the enum member you added. And implement the function according to the workflow you exported. 186 | 187 | Now you can use the new workflow in your application. 188 | 189 | The base request json format is: 190 | ```json 191 | { 192 | "service_type": "text2img", // service type 193 | "client_callback_url": "http://localhost:9000/callback", // callback url 194 | "params": { 195 | // based on your workflow 196 | "text": "2capys", 197 | "image": "base64_format " 198 | }, 199 | "client_task_id": "commonly a uuid" // client task id 200 | } 201 | ``` 202 | The immediate response and the webhook request format is: 203 | ```json 204 | { 205 | "client_task_id": "commonly a uuid", 206 | "comfyui_task_id": "d7622ce4-e89d-4f25-a83d-9a21337267f9", 207 | "s3_key": "4270f289-7610-4782-9047-0b0b3899ddfc.png", 208 | "client_callback_url": "http://localhost:9000/callback", 209 | "comfyui_filepath": "ComfyUI_00256_.png", 210 | "id": 3, 211 | "error_code": 0 212 | } 213 | ``` --------------------------------------------------------------------------------