├── Comflyapi.json ├── requirements.txt ├── __init__.py ├── utils.py ├── README.md ├── .gitignore ├── workflow └── comfyui-chatgpt-api.json ├── LICENSE └── chatgpt_api.py /Comflyapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_key": "" 3 | } 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | aiohttp-cors 3 | GitPython 4 | numpy 5 | Pillow 6 | requests 7 | torch 8 | 9 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .chatgpt_api import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS 2 | 3 | __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] 4 | 5 | 6 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import torch 3 | from PIL import Image 4 | from typing import List, Union 5 | 6 | def pil2tensor(image: Union[Image.Image, List[Image.Image]]) -> torch.Tensor: 7 | """ 8 | Convert PIL image(s) to tensor, matching ComfyUI's implementation. 9 | 10 | Args: 11 | image: Single PIL Image or list of PIL Images 12 | 13 | Returns: 14 | torch.Tensor: Image tensor with values normalized to [0, 1] 15 | """ 16 | if isinstance(image, list): 17 | if len(image) == 0: 18 | return torch.empty(0) 19 | 20 | tensors = [] 21 | for img in image: 22 | if img.mode == 'RGBA': 23 | img = img.convert('RGB') 24 | elif img.mode != 'RGB': 25 | img = img.convert('RGB') 26 | 27 | img_array = np.array(img).astype(np.float32) / 255.0 28 | tensor = torch.from_numpy(img_array)[None,] 29 | tensors.append(tensor) 30 | 31 | if len(tensors) == 1: 32 | return tensors[0] 33 | else: 34 | shapes = [t.shape[1:3] for t in tensors] 35 | if all(shape == shapes[0] for shape in shapes): 36 | return torch.cat(tensors, dim=0) 37 | else: 38 | max_h = max(t.shape[1] for t in tensors) 39 | max_w = max(t.shape[2] for t in tensors) 40 | 41 | padded_tensors = [] 42 | for t in tensors: 43 | h, w = t.shape[1:3] 44 | if h == max_h and w == max_w: 45 | padded_tensors.append(t) 46 | else: 47 | padded = torch.zeros((1, max_h, max_w, 3), dtype=t.dtype) 48 | padded[0, :h, :w, :] = t[0, :h, :w, :] 49 | padded_tensors.append(padded) 50 | 51 | return torch.cat(padded_tensors, dim=0) 52 | 53 | # Convert PIL image to RGB if needed 54 | if image.mode == 'RGBA': 55 | image = image.convert('RGB') 56 | elif image.mode != 'RGB': 57 | image = image.convert('RGB') 58 | 59 | # Convert to numpy array and normalize to [0, 1] 60 | img_array = np.array(image).astype(np.float32) / 255.0 61 | 62 | # Return tensor with shape [1, H, W, 3] 63 | return torch.from_numpy(img_array)[None,] 64 | 65 | 66 | def tensor2pil(image: torch.Tensor) -> List[Image.Image]: 67 | """ 68 | Convert tensor to PIL image(s), matching ComfyUI's implementation. 69 | 70 | Args: 71 | image: Tensor with shape [B, H, W, 3] or [H, W, 3], values in range [0, 1] 72 | 73 | Returns: 74 | List[Image.Image]: List of PIL Images 75 | """ 76 | batch_count = image.size(0) if len(image.shape) > 3 else 1 77 | if batch_count > 1: 78 | out = [] 79 | for i in range(batch_count): 80 | out.extend(tensor2pil(image[i])) 81 | return out 82 | 83 | # Convert tensor to numpy array, scale to [0, 255], and clip values 84 | numpy_image = np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8) 85 | 86 | # Convert numpy array to PIL Image 87 | return [Image.fromarray(numpy_image)] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | > **Warning** 5 | > 6 | > 插件只在windows11上测试,mac电脑后续我也会测试,如果其他系统有任何问题,可以提交issues 7 | > 速度不快,因为官网速度也不快,所以需要点耐心。 8 | 9 | # 插件 Node: 10 | 11 | 20250429: 12 | 13 | `Chatgpt节点`: Comfyui_gpt_image_1_edit新增chats输出口,输出多轮对话。 14 | 新增clear_chats,当为Ture的时候,只能image输入什么图片修改什么图片,不支持显示上下文对话。 15 | 当为Flase的时候,支持对上一次生成的图片进行二次修改。支持显示上下文对话。并且支持多图模式下新增图片参考。 16 | 17 |
18 | 查看更新/Update 19 | 20 | ![2eaf76b077612170647f6861e43e2af](https://github.com/user-attachments/assets/1c4c484f-c3c6-48c6-96c5-58c4ef4e59d5) 21 | 22 | ![6a43cb051fece84815ac6036bee3a4c](https://github.com/user-attachments/assets/f0fbf71e-8cfb-448e-87cd-1e147bb2f552) 23 | 24 |
25 | 26 | 27 | 20250425: 28 | 29 | `Chatgpt节点`: 30 | 新增Comfyui_gpt_image_1和Comfyui_gpt_image_1_edit官方gpt_image_1模型api接口节点。 31 | 32 | ![image](https://github.com/user-attachments/assets/9d08d5fc-dde9-4523-955c-31652a74f1a5) 33 | 34 | 模型名都是gpt_image_1,区别只是分组不同: 35 | 36 | 一共四个分组:default默认分组为官方逆向,价格便宜,缺点就是不稳定,速度慢。按次收费。不支持额外参数选择。这个分组的apikey只能用于ComfyuiChatGPTApi节点。 37 | 38 | 其他三个组都是官方api组,最优惠的目前是ssvip组。分组需要再令牌里面去修改选择。这3个官方分组优点就是速度快,稳定性高。支持官方参数调整。 39 | 缺点就是贵,但是也比官方便宜。大家可以按照自己的情况选择。这3个分组的令牌的apikey只能用在下面2个新节点上面!!! 40 | 41 | 1. Comfyui_gpt_image_1 节点:文生图,有耕读参数调整,支持调整生图限制为low。 42 | 43 | 2. Comfyui_gpt_image_1_edit 节点:图生图,支持mask遮罩,支持多图参考。 44 | 45 |
46 | 查看更新/Update 47 | 48 | ![3bc790641c44e373aca97ea4a1de47e](https://github.com/user-attachments/assets/1a7a0615-46e5-46b3-af04-32246a23d6f4) 49 | 50 | ![5efe58fcf7055d675962f40c1ad1cbb](https://github.com/user-attachments/assets/8a90eab5-4242-43bb-ae01-74493b90b6ce) 51 | 52 |
53 | 54 | 55 | 56 | 20250424: 57 | 58 | `Chatgpt节点`: 59 | 60 | ComfyuiChatGPTApi节点新增官方gpt-image-1,按次计费 0.06, 61 | ComfyuiChatGPTApi节点新增chats输出口,输出多轮对话。 62 | 新增clear_chats,当为Ture的时候,只能image输入什么图片修改什么图片,不支持显示上下文对话。 63 | 当为Flase的时候,支持对上一次生成的图片进行二次修改。支持显示上下文对话。 64 | 65 |
66 | 查看更新/Update 67 | 68 | ![cad243f2bf4a3aa11163f1a007db469](https://github.com/user-attachments/assets/ef0f6a34-3de7-42a2-8543-c1930575e1bb) 69 | 70 | ![bd6493050affdf156143c8dc5286988](https://github.com/user-attachments/assets/0906caf3-35ec-4061-bfc9-5f611a19abf2) 71 | 72 | ![e5b3d375b700dcbf921b12a8aa527c4](https://github.com/user-attachments/assets/75537100-e5d2-403c-b2e0-1f662680092f) 73 | 74 | 75 |
76 | 77 | 78 | `Chatgpt节点`: 79 | 80 | 新增openai的comfyui-chatgpt-api节点,。 81 | 目前单图和多图输入,文本输入,生成图片,图片编辑.使用的是 https://ai.comfly.chat 的 api key 82 | 固定一次生成消耗0.06元(显示是逆向api,稳定性还不高,想尝鲜的可以注册网站用免费送的0.2美金玩玩) 83 | 速度不快,因为官网速度也不快,所以需要点耐心。 files输入接口还没有完善,先忽略。 84 | 85 | 86 | 87 | ![fdedd73cffa278d2a8cf81478b58e90](https://github.com/user-attachments/assets/36e78cdd-33b2-41ed-a15c-ad9c1886bede) 88 | 89 | 90 | ![0a2394c0b41efe190a5d0880f4c584b](https://github.com/user-attachments/assets/267fbe73-7113-4120-a829-a7aa2247bd4d) 91 | 92 | 93 | 94 | # 🥵 Comfly的QQ群 / my wechat 95 | 96 | ![86601b471a343671e7240c74aa8e1fd](https://github.com/ainewsto/Comfyui_Comfly/assets/113163264/3e1c2d15-ba5b-4aa5-a76b-08f87e7c8e2c) 97 | 98 | ![86601b471a343671e7240c74aa8e1fd](https://github.com/ainewsto/Comfyui_Comfly/assets/113163264/fdc2f849-5937-4cce-a36d-8444ecca3030) 99 | 100 | 101 | # :dizzy:插件有参考项目 Comfy Resources: 102 | 103 | 感谢原项目: 104 | https://github.com/comfyanonymous/ComfyUI 105 | 106 | 107 | ## 🚀 About me 108 | * website: https://comfly.chat 109 | * Welcome valuable suggestions! 📧 **Email**: [3508432500@qq.com](mailto:1544007699@qq.com) 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # API key 2 | Comflyapi.json merge=ours 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # UV 101 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | #uv.lock 105 | 106 | # poetry 107 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 108 | # This is especially recommended for binary packages to ensure reproducibility, and is more 109 | # commonly ignored for libraries. 110 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 111 | #poetry.lock 112 | 113 | # pdm 114 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 115 | #pdm.lock 116 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 117 | # in version control. 118 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 119 | .pdm.toml 120 | .pdm-python 121 | .pdm-build/ 122 | 123 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 124 | __pypackages__/ 125 | 126 | # Celery stuff 127 | celerybeat-schedule 128 | celerybeat.pid 129 | 130 | # SageMath parsed files 131 | *.sage.py 132 | 133 | # Environments 134 | .env 135 | .venv 136 | env/ 137 | venv/ 138 | ENV/ 139 | env.bak/ 140 | venv.bak/ 141 | 142 | # Spyder project settings 143 | .spyderproject 144 | .spyproject 145 | 146 | # Rope project settings 147 | .ropeproject 148 | 149 | # mkdocs documentation 150 | /site 151 | 152 | # mypy 153 | .mypy_cache/ 154 | .dmypy.json 155 | dmypy.json 156 | 157 | # Pyre type checker 158 | .pyre/ 159 | 160 | # pytype static type analyzer 161 | .pytype/ 162 | 163 | # Cython debug symbols 164 | cython_debug/ 165 | 166 | # PyCharm 167 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 168 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 169 | # and can be added to the global gitignore or merged into this file. For a more nuclear 170 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 171 | #.idea/ 172 | 173 | # Ruff stuff: 174 | .ruff_cache/ 175 | 176 | # PyPI configuration file 177 | .pypirc 178 | -------------------------------------------------------------------------------- /workflow/comfyui-chatgpt-api.json: -------------------------------------------------------------------------------- 1 | { 2 | "last_node_id": 28, 3 | "last_link_id": 44, 4 | "nodes": [ 5 | { 6 | "id": 17, 7 | "type": "ShowText|pysssss", 8 | "pos": [ 9 | -890.126708984375, 10 | -759.1605224609375 11 | ], 12 | "size": [ 13 | 404.01727294921875, 14 | 116.56401824951172 15 | ], 16 | "flags": {}, 17 | "order": 7, 18 | "mode": 0, 19 | "inputs": [ 20 | { 21 | "name": "text", 22 | "type": "STRING", 23 | "link": 44, 24 | "widget": { 25 | "name": "text" 26 | } 27 | } 28 | ], 29 | "outputs": [ 30 | { 31 | "name": "STRING", 32 | "type": "STRING", 33 | "links": null, 34 | "shape": 6 35 | } 36 | ], 37 | "properties": { 38 | "Node name for S&R": "ShowText|pysssss" 39 | }, 40 | "widgets_values": [ 41 | "" 42 | ] 43 | }, 44 | { 45 | "id": 24, 46 | "type": "LoadImage", 47 | "pos": [ 48 | -1287.1475830078125, 49 | -968.3455810546875 50 | ], 51 | "size": [ 52 | 315, 53 | 314 54 | ], 55 | "flags": {}, 56 | "order": 0, 57 | "mode": 0, 58 | "inputs": [], 59 | "outputs": [ 60 | { 61 | "name": "IMAGE", 62 | "type": "IMAGE", 63 | "links": [ 64 | 37 65 | ] 66 | }, 67 | { 68 | "name": "MASK", 69 | "type": "MASK", 70 | "links": null 71 | } 72 | ], 73 | "properties": { 74 | "Node name for S&R": "LoadImage" 75 | }, 76 | "widgets_values": [ 77 | "1.jpg", 78 | "image" 79 | ] 80 | }, 81 | { 82 | "id": 22, 83 | "type": "LoadImage", 84 | "pos": [ 85 | -1285.79931640625, 86 | -597.0714111328125 87 | ], 88 | "size": [ 89 | 315, 90 | 314 91 | ], 92 | "flags": {}, 93 | "order": 1, 94 | "mode": 0, 95 | "inputs": [], 96 | "outputs": [ 97 | { 98 | "name": "IMAGE", 99 | "type": "IMAGE", 100 | "links": [ 101 | 35 102 | ] 103 | }, 104 | { 105 | "name": "MASK", 106 | "type": "MASK", 107 | "links": null 108 | } 109 | ], 110 | "properties": { 111 | "Node name for S&R": "LoadImage" 112 | }, 113 | "widgets_values": [ 114 | "2.jpg", 115 | "image" 116 | ] 117 | }, 118 | { 119 | "id": 23, 120 | "type": "LoadImage", 121 | "pos": [ 122 | -1278.814453125, 123 | -218.6844940185547 124 | ], 125 | "size": [ 126 | 315, 127 | 314 128 | ], 129 | "flags": {}, 130 | "order": 2, 131 | "mode": 0, 132 | "inputs": [], 133 | "outputs": [ 134 | { 135 | "name": "IMAGE", 136 | "type": "IMAGE", 137 | "links": [ 138 | 36 139 | ] 140 | }, 141 | { 142 | "name": "MASK", 143 | "type": "MASK", 144 | "links": null 145 | } 146 | ], 147 | "properties": { 148 | "Node name for S&R": "LoadImage" 149 | }, 150 | "widgets_values": [ 151 | "3.jpg", 152 | "image" 153 | ] 154 | }, 155 | { 156 | "id": 25, 157 | "type": "PreviewImage", 158 | "pos": [ 159 | -428.7579345703125, 160 | -751.380615234375 161 | ], 162 | "size": [ 163 | 420.0105285644531, 164 | 576.1502685546875 165 | ], 166 | "flags": {}, 167 | "order": 5, 168 | "mode": 0, 169 | "inputs": [ 170 | { 171 | "name": "images", 172 | "type": "IMAGE", 173 | "link": 42 174 | } 175 | ], 176 | "outputs": [], 177 | "properties": { 178 | "Node name for S&R": "PreviewImage" 179 | }, 180 | "widgets_values": [] 181 | }, 182 | { 183 | "id": 26, 184 | "type": "ShowText|pysssss", 185 | "pos": [ 186 | 29.915327072143555, 187 | -748.803955078125 188 | ], 189 | "size": [ 190 | 436.1102600097656, 191 | 570.7864379882812 192 | ], 193 | "flags": {}, 194 | "order": 6, 195 | "mode": 0, 196 | "inputs": [ 197 | { 198 | "name": "text", 199 | "type": "STRING", 200 | "link": 43, 201 | "widget": { 202 | "name": "text" 203 | } 204 | } 205 | ], 206 | "outputs": [ 207 | { 208 | "name": "STRING", 209 | "type": "STRING", 210 | "links": null, 211 | "shape": 6 212 | } 213 | ], 214 | "properties": { 215 | "Node name for S&R": "ShowText|pysssss" 216 | }, 217 | "widgets_values": [ 218 | "", 219 | "Error calling ChatGPT API: Error in streaming response: API Error 500: {\"error\":{\"message\":\"当前模型负载较高,请稍候重试,或者切换其他模型 (request id: B20250328235605730741257q8H1EMCx)\",\"type\":\"new_api_error\",\"param\":\"\",\"code\":null}}" 220 | ] 221 | }, 222 | { 223 | "id": 21, 224 | "type": "MultiImagesInput", 225 | "pos": [ 226 | -816.4220581054688, 227 | -958.3441772460938 228 | ], 229 | "size": [ 230 | 210, 231 | 122 232 | ], 233 | "flags": {}, 234 | "order": 3, 235 | "mode": 0, 236 | "inputs": [ 237 | { 238 | "name": "image_1", 239 | "type": "IMAGE", 240 | "link": 37, 241 | "shape": 7 242 | }, 243 | { 244 | "name": "image_2", 245 | "type": "IMAGE", 246 | "link": 35, 247 | "shape": 7 248 | }, 249 | { 250 | "name": "image_3", 251 | "type": "IMAGE", 252 | "link": null 253 | } 254 | ], 255 | "outputs": [ 256 | { 257 | "name": "images", 258 | "type": "IMAGE", 259 | "links": [ 260 | 41 261 | ], 262 | "slot_index": 0 263 | } 264 | ], 265 | "properties": {}, 266 | "widgets_values": [ 267 | 3, 268 | null 269 | ] 270 | }, 271 | { 272 | "id": 28, 273 | "type": "ComfyuiChatGPTApi", 274 | "pos": [ 275 | -889.3751831054688, 276 | -567.8804321289062 277 | ], 278 | "size": [ 279 | 400, 280 | 356 281 | ], 282 | "flags": {}, 283 | "order": 4, 284 | "mode": 0, 285 | "inputs": [ 286 | { 287 | "name": "files", 288 | "type": "FILES", 289 | "link": null, 290 | "shape": 7 291 | }, 292 | { 293 | "name": "images", 294 | "type": "IMAGE", 295 | "link": 41, 296 | "shape": 7 297 | } 298 | ], 299 | "outputs": [ 300 | { 301 | "name": "images", 302 | "type": "IMAGE", 303 | "links": [ 304 | 42 305 | ], 306 | "slot_index": 0 307 | }, 308 | { 309 | "name": "response", 310 | "type": "STRING", 311 | "links": [ 312 | 43 313 | ], 314 | "slot_index": 1 315 | }, 316 | { 317 | "name": "image_urls", 318 | "type": "STRING", 319 | "links": [ 320 | 44 321 | ], 322 | "slot_index": 2 323 | } 324 | ], 325 | "properties": { 326 | "Node name for S&R": "ComfyuiChatGPTApi" 327 | }, 328 | "widgets_values": [ 329 | "以图片image_3为背景,图片image_2的模特手拿着图片image_1的化妆瓶", 330 | "gpt-4o-all", 331 | "", 332 | 0.7, 333 | 4096, 334 | 1, 335 | 0, 336 | 0, 337 | -1, 338 | "randomize", 339 | 120 340 | ] 341 | } 342 | ], 343 | "links": [ 344 | [ 345 | 35, 346 | 22, 347 | 0, 348 | 21, 349 | 1, 350 | "IMAGE" 351 | ], 352 | [ 353 | 36, 354 | 23, 355 | 0, 356 | 21, 357 | 2, 358 | "IMAGE" 359 | ], 360 | [ 361 | 37, 362 | 24, 363 | 0, 364 | 21, 365 | 0, 366 | "IMAGE" 367 | ], 368 | [ 369 | 41, 370 | 21, 371 | 0, 372 | 28, 373 | 1, 374 | "IMAGE" 375 | ], 376 | [ 377 | 42, 378 | 28, 379 | 0, 380 | 25, 381 | 0, 382 | "IMAGE" 383 | ], 384 | [ 385 | 43, 386 | 28, 387 | 1, 388 | 26, 389 | 0, 390 | "STRING" 391 | ], 392 | [ 393 | 44, 394 | 28, 395 | 2, 396 | 17, 397 | 0, 398 | "STRING" 399 | ] 400 | ], 401 | "groups": [], 402 | "config": {}, 403 | "extra": { 404 | "ds": { 405 | "scale": 0.6830134553650711, 406 | "offset": [ 407 | 1500.863251417137, 408 | 1108.4863459186506 409 | ] 410 | } 411 | }, 412 | "version": 0.4 413 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /chatgpt_api.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os 3 | import io 4 | import math 5 | import random 6 | import torch 7 | import requests 8 | import time 9 | from PIL import Image 10 | from io import BytesIO 11 | import json 12 | import comfy.utils 13 | import re 14 | import aiohttp 15 | import asyncio 16 | import base64 17 | import uuid 18 | import numpy as np 19 | import folder_paths 20 | from .utils import pil2tensor, tensor2pil 21 | from comfy.utils import common_upscale 22 | 23 | 24 | def get_config(): 25 | try: 26 | config_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'Comflyapi.json') 27 | with open(config_path, 'r') as f: 28 | config = json.load(f) 29 | return config 30 | except: 31 | return {} 32 | 33 | def save_config(config): 34 | config_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'Comflyapi.json') 35 | with open(config_path, 'w') as f: 36 | json.dump(config, f, indent=4) 37 | 38 | 39 | # reference: OpenAIGPTImage1 node from comfyui node 40 | def downscale_input(image): 41 | samples = image.movedim(-1,1) 42 | 43 | total = int(1536 * 1024) 44 | scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2])) 45 | if scale_by >= 1: 46 | return image 47 | width = round(samples.shape[3] * scale_by) 48 | height = round(samples.shape[2] * scale_by) 49 | 50 | s = common_upscale(samples, width, height, "lanczos", "disabled") 51 | s = s.movedim(1,-1) 52 | return s 53 | 54 | class Comfyui_gpt_image_1_edit: 55 | 56 | _last_edited_image = None 57 | _conversation_history = [] 58 | 59 | @classmethod 60 | def INPUT_TYPES(cls): 61 | return { 62 | "required": { 63 | "image": ("IMAGE",), 64 | "prompt": ("STRING", {"multiline": True}), 65 | }, 66 | "optional": { 67 | "mask": ("MASK",), 68 | "api_key": ("STRING", {"default": ""}), 69 | "model": ("STRING", {"default": "gpt-image-1"}), 70 | "n": ("INT", {"default": 1, "min": 1, "max": 10}), 71 | "quality": (["auto", "high", "medium", "low"], {"default": "auto"}), 72 | "size": (["auto", "1024x1024", "1536x1024", "1024x1536"], {"default": "auto"}), 73 | "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), 74 | "clear_chats": ("BOOLEAN", {"default": True}), 75 | "background": (["auto", "transparent", "opaque"], {"default": "auto"}), 76 | "output_compression": ("INT", {"default": 100, "min": 0, "max": 100}), 77 | "output_format": (["png", "jpeg", "webp"], {"default": "png"}), 78 | "max_retries": ("INT", {"default": 5, "min": 1, "max": 10}), 79 | "initial_timeout": ("INT", {"default": 900, "min": 60, "max": 1200}), 80 | } 81 | } 82 | 83 | RETURN_TYPES = ("IMAGE", "STRING", "STRING") 84 | RETURN_NAMES = ("edited_image", "response", "chats") 85 | FUNCTION = "edit_image" 86 | CATEGORY = "ainewsto/Chatgpt" 87 | 88 | def __init__(self): 89 | self.api_key = get_config().get('api_key', '') 90 | self.timeout = 900 91 | self.session = requests.Session() 92 | retry_strategy = requests.packages.urllib3.util.retry.Retry( 93 | total=3, 94 | backoff_factor=1, 95 | status_forcelist=[429, 500, 502, 503, 504], 96 | allowed_methods=["GET", "POST"] 97 | ) 98 | adapter = requests.adapters.HTTPAdapter(max_retries=retry_strategy) 99 | self.session.mount("http://", adapter) 100 | self.session.mount("https://", adapter) 101 | 102 | def get_headers(self): 103 | return { 104 | "Authorization": f"Bearer {self.api_key}" 105 | } 106 | 107 | def format_conversation_history(self): 108 | """Format the conversation history for display""" 109 | if not Comfyui_gpt_image_1_edit._conversation_history: 110 | return "" 111 | formatted_history = "" 112 | for entry in Comfyui_gpt_image_1_edit._conversation_history: 113 | formatted_history += f"**User**: {entry['user']}\n\n" 114 | formatted_history += f"**AI**: {entry['ai']}\n\n" 115 | formatted_history += "---\n\n" 116 | return formatted_history.strip() 117 | 118 | def make_request_with_retry(self, url, data=None, files=None, max_retries=5, initial_timeout=300): 119 | """Make a request with automatic retries and exponential backoff""" 120 | for attempt in range(1, max_retries + 1): 121 | current_timeout = min(initial_timeout * (1.5 ** (attempt - 1)), 1200) 122 | 123 | try: 124 | if files: 125 | response = self.session.post( 126 | url, 127 | headers=self.get_headers(), 128 | data=data, 129 | files=files, 130 | timeout=current_timeout 131 | ) 132 | else: 133 | response = self.session.post( 134 | url, 135 | headers=self.get_headers(), 136 | json=data, 137 | timeout=current_timeout 138 | ) 139 | 140 | response.raise_for_status() 141 | return response 142 | 143 | except requests.exceptions.Timeout as e: 144 | if attempt == max_retries: 145 | raise TimeoutError(f"Request timed out after {max_retries} attempts. Last timeout: {current_timeout}s") 146 | wait_time = min(2 ** (attempt - 1), 60) 147 | time.sleep(wait_time) 148 | 149 | except requests.exceptions.ConnectionError as e: 150 | if attempt == max_retries: 151 | raise ConnectionError(f"Connection error after {max_retries} attempts: {str(e)}") 152 | wait_time = min(2 ** (attempt - 1), 60) 153 | time.sleep(wait_time) 154 | 155 | except requests.exceptions.HTTPError as e: 156 | if e.response.status_code in (400, 401, 403): 157 | print(f"Client error: {str(e)}") 158 | raise 159 | if attempt == max_retries: 160 | raise 161 | wait_time = min(2 ** (attempt - 1), 60) 162 | time.sleep(wait_time) 163 | 164 | except Exception as e: 165 | if attempt == max_retries: 166 | raise 167 | wait_time = min(2 ** (attempt - 1), 60) 168 | time.sleep(wait_time) 169 | 170 | def edit_image(self, image, prompt, model="gpt-image-1", n=1, quality="auto", 171 | seed=0, mask=None, api_key="", size="auto", clear_chats=True, 172 | background="auto", output_compression=100, output_format="png", 173 | max_retries=5, initial_timeout=300): 174 | if api_key.strip(): 175 | self.api_key = api_key 176 | config = get_config() 177 | config['api_key'] = api_key 178 | save_config(config) 179 | 180 | original_image = image 181 | original_batch_size = image.shape[0] 182 | use_saved_image = False 183 | 184 | if not clear_chats and Comfyui_gpt_image_1_edit._last_edited_image is not None: 185 | if original_batch_size > 1: 186 | last_batch_size = Comfyui_gpt_image_1_edit._last_edited_image.shape[0] 187 | last_image_first = Comfyui_gpt_image_1_edit._last_edited_image[0:1] 188 | if last_image_first.shape[1:] == original_image.shape[1:]: 189 | image = torch.cat([last_image_first, original_image[1:]], dim=0) 190 | use_saved_image = True 191 | else: 192 | image = Comfyui_gpt_image_1_edit._last_edited_image 193 | use_saved_image = True 194 | 195 | if clear_chats: 196 | Comfyui_gpt_image_1_edit._conversation_history = [] 197 | 198 | 199 | try: 200 | if not self.api_key: 201 | error_message = "API key not found in Comflyapi.json" 202 | print(error_message) 203 | return (original_image, error_message, self.format_conversation_history()) 204 | 205 | pbar = comfy.utils.ProgressBar(100) 206 | pbar.update_absolute(10) 207 | 208 | files = {} 209 | 210 | if image is not None: 211 | batch_size = image.shape[0] 212 | for i in range(batch_size): 213 | single_image = image[i:i+1] 214 | scaled_image = downscale_input(single_image).squeeze() 215 | 216 | image_np = (scaled_image.numpy() * 255).astype(np.uint8) 217 | img = Image.fromarray(image_np) 218 | img_byte_arr = io.BytesIO() 219 | img.save(img_byte_arr, format='PNG') 220 | img_byte_arr.seek(0) 221 | 222 | if batch_size == 1: 223 | files['image'] = ('image.png', img_byte_arr, 'image/png') 224 | else: 225 | if 'image[]' not in files: 226 | files['image[]'] = [] 227 | files['image[]'].append(('image_{}.png'.format(i), img_byte_arr, 'image/png')) 228 | 229 | if mask is not None: 230 | if image.shape[0] != 1: 231 | raise Exception("Cannot use a mask with multiple images") 232 | if image is None: 233 | raise Exception("Cannot use a mask without an input image") 234 | if mask.shape[1:] != image.shape[1:-1]: 235 | raise Exception("Mask and Image must be the same size") 236 | 237 | batch, height, width = mask.shape 238 | rgba_mask = torch.zeros(height, width, 4, device="cpu") 239 | rgba_mask[:,:,3] = (1-mask.squeeze().cpu()) 240 | scaled_mask = downscale_input(rgba_mask.unsqueeze(0)).squeeze() 241 | mask_np = (scaled_mask.numpy() * 255).astype(np.uint8) 242 | mask_img = Image.fromarray(mask_np) 243 | mask_byte_arr = io.BytesIO() 244 | mask_img.save(mask_byte_arr, format='PNG') 245 | mask_byte_arr.seek(0) 246 | files['mask'] = ('mask.png', mask_byte_arr, 'image/png') 247 | 248 | data = { 249 | 'prompt': prompt, 250 | 'model': model, 251 | 'n': str(n), 252 | 'quality': quality 253 | } 254 | 255 | if size != "auto": 256 | data['size'] = size 257 | 258 | if background != "auto": 259 | data['background'] = background 260 | 261 | if output_compression != 100: 262 | data['output_compression'] = str(output_compression) 263 | 264 | if output_format != "png": 265 | data['output_format'] = output_format 266 | 267 | pbar.update_absolute(30) 268 | 269 | try: 270 | if 'image[]' in files: 271 | image_files = [] 272 | for file_tuple in files['image[]']: 273 | image_files.append(('image', file_tuple)) 274 | 275 | if 'mask' in files: 276 | image_files.append(('mask', files['mask'])) 277 | 278 | response = self.make_request_with_retry( 279 | "https://ai.comfly.chat/v1/images/edits", 280 | data=data, 281 | files=image_files, 282 | max_retries=max_retries, 283 | initial_timeout=initial_timeout 284 | ) 285 | else: 286 | request_files = [] 287 | if 'image' in files: 288 | request_files.append(('image', files['image'])) 289 | if 'mask' in files: 290 | request_files.append(('mask', files['mask'])) 291 | 292 | response = self.make_request_with_retry( 293 | "https://ai.comfly.chat/v1/images/edits", 294 | data=data, 295 | files=request_files, 296 | max_retries=max_retries, 297 | initial_timeout=initial_timeout 298 | ) 299 | 300 | except TimeoutError as e: 301 | error_message = f"API timeout error: {str(e)}" 302 | print(error_message) 303 | return (original_image, error_message, self.format_conversation_history()) 304 | except Exception as e: 305 | error_message = f"API request error: {str(e)}" 306 | print(error_message) 307 | return (original_image, error_message, self.format_conversation_history()) 308 | 309 | pbar.update_absolute(50) 310 | result = response.json() 311 | 312 | if "data" not in result or not result["data"]: 313 | error_message = "No image data in response" 314 | print(error_message) 315 | return (original_image, error_message, self.format_conversation_history()) 316 | 317 | edited_images = [] 318 | image_urls = [] 319 | 320 | for item in result["data"]: 321 | if "b64_json" in item: 322 | image_data = base64.b64decode(item["b64_json"]) 323 | edited_image = Image.open(BytesIO(image_data)) 324 | edited_tensor = pil2tensor(edited_image) 325 | edited_images.append(edited_tensor) 326 | elif "url" in item: 327 | image_urls.append(item["url"]) 328 | try: 329 | for download_attempt in range(1, max_retries + 1): 330 | try: 331 | img_response = requests.get( 332 | item["url"], 333 | timeout=min(initial_timeout * (1.5 ** (download_attempt - 1)), 900) 334 | ) 335 | img_response.raise_for_status() 336 | 337 | edited_image = Image.open(BytesIO(img_response.content)) 338 | edited_tensor = pil2tensor(edited_image) 339 | edited_images.append(edited_tensor) 340 | break 341 | except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: 342 | if download_attempt == max_retries: 343 | print(f"Failed to download image after {max_retries} attempts: {str(e)}") 344 | continue 345 | wait_time = min(2 ** (download_attempt - 1), 60) 346 | print(f"Image download error (attempt {download_attempt}/{max_retries}). Retrying in {wait_time} seconds...") 347 | time.sleep(wait_time) 348 | except Exception as e: 349 | print(f"Error downloading image from URL: {str(e)}") 350 | break 351 | except Exception as e: 352 | print(f"Error processing image URL: {str(e)}") 353 | 354 | pbar.update_absolute(90) 355 | 356 | if edited_images: 357 | combined_tensor = torch.cat(edited_images, dim=0) 358 | response_info = f"Successfully edited {len(edited_images)} image(s)\n" 359 | response_info += f"Prompt: {prompt}\n" 360 | response_info += f"Model: {model}\n" 361 | response_info += f"Quality: {quality}\n" 362 | 363 | if use_saved_image: 364 | response_info += "[Using previous edited image as input]\n" 365 | 366 | if size != "auto": 367 | response_info += f"Size: {size}\n" 368 | 369 | if background != "auto": 370 | response_info += f"Background: {background}\n" 371 | 372 | if output_compression != 100: 373 | response_info += f"Output Compression: {output_compression}%\n" 374 | 375 | if output_format != "png": 376 | response_info += f"Output Format: {output_format}\n" 377 | 378 | Comfyui_gpt_image_1_edit._conversation_history.append({ 379 | "user": f"Edit image with prompt: {prompt}", 380 | "ai": f"Generated edited image with {model}" 381 | }) 382 | 383 | Comfyui_gpt_image_1_edit._last_edited_image = combined_tensor 384 | 385 | pbar.update_absolute(100) 386 | return (combined_tensor, response_info, self.format_conversation_history()) 387 | else: 388 | error_message = "No edited images in response" 389 | print(error_message) 390 | return (original_image, error_message, self.format_conversation_history()) 391 | 392 | except Exception as e: 393 | error_message = f"Error in image editing: {str(e)}" 394 | import traceback 395 | print(traceback.format_exc()) 396 | print(error_message) 397 | return (original_image, error_message, self.format_conversation_history()) 398 | 399 | 400 | class Comfyui_gpt_image_1: 401 | @classmethod 402 | def INPUT_TYPES(cls): 403 | return { 404 | "required": { 405 | "prompt": ("STRING", {"multiline": True}), 406 | }, 407 | "optional": { 408 | "api_key": ("STRING", {"default": ""}), 409 | "model": ("STRING", {"default": "gpt-image-1"}), 410 | "n": ("INT", {"default": 1, "min": 1, "max": 10}), 411 | "quality": (["auto", "high", "medium", "low"], {"default": "auto"}), 412 | "size": (["auto", "1024x1024", "1536x1024", "1024x1536"], {"default": "auto"}), 413 | "background": (["auto", "transparent", "opaque"], {"default": "auto"}), 414 | "output_format": (["png", "jpeg", "webp"], {"default": "png"}), 415 | "moderation": (["auto", "low"], {"default": "auto"}), 416 | "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), 417 | } 418 | } 419 | 420 | RETURN_TYPES = ("IMAGE", "STRING") 421 | RETURN_NAMES = ("generated_image", "response") 422 | FUNCTION = "generate_image" 423 | CATEGORY = "ainewsto/Chatgpt" 424 | 425 | def __init__(self): 426 | self.api_key = get_config().get('api_key', '') 427 | self.timeout = 300 428 | 429 | def get_headers(self): 430 | return { 431 | "Content-Type": "application/json", 432 | "Authorization": f"Bearer {self.api_key}" 433 | } 434 | 435 | def generate_image(self, prompt, model="gpt-image-1", n=1, quality="auto", 436 | size="auto", background="auto", output_format="png", 437 | moderation="auto", seed=0, api_key=""): 438 | 439 | if api_key.strip(): 440 | self.api_key = api_key 441 | config = get_config() 442 | config['api_key'] = api_key 443 | save_config(config) 444 | 445 | try: 446 | if not self.api_key: 447 | error_message = "API key not found in Comflyapi.json" 448 | print(error_message) 449 | blank_image = Image.new('RGB', (1024, 1024), color='white') 450 | blank_tensor = pil2tensor(blank_image) 451 | return (blank_tensor, error_message) 452 | pbar = comfy.utils.ProgressBar(100) 453 | pbar.update_absolute(10) 454 | payload = { 455 | "prompt": prompt, 456 | "model": model, 457 | "n": n, 458 | "quality": quality, 459 | "background": background, 460 | "output_format": output_format, 461 | "moderation": moderation, 462 | } 463 | 464 | if size != "auto": 465 | payload["size"] = size 466 | 467 | response = requests.post( 468 | "https://ai.comfly.chat/v1/images/generations", 469 | headers=self.get_headers(), 470 | json=payload, 471 | timeout=self.timeout 472 | ) 473 | 474 | pbar.update_absolute(50) 475 | if response.status_code != 200: 476 | error_message = f"API Error: {response.status_code} - {response.text}" 477 | print(error_message) 478 | blank_image = Image.new('RGB', (1024, 1024), color='white') 479 | blank_tensor = pil2tensor(blank_image) 480 | return (blank_tensor, error_message) 481 | 482 | result = response.json() 483 | 484 | timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 485 | response_info = f"**GPT-image-1 Generation ({timestamp})**\n\n" 486 | response_info += f"Prompt: {prompt}\n" 487 | response_info += f"Model: {model}\n" 488 | response_info += f"Quality: {quality}\n" 489 | if size != "auto": 490 | response_info += f"Size: {size}\n" 491 | response_info += f"Background: {background}\n" 492 | response_info += f"Seed: {seed} (Note: Seed not used by API)\n\n" 493 | 494 | generated_images = [] 495 | image_urls = [] 496 | 497 | if "data" in result and result["data"]: 498 | for i, item in enumerate(result["data"]): 499 | pbar.update_absolute(50 + (i+1) * 50 // len(result["data"])) 500 | 501 | if "b64_json" in item: 502 | b64_data = item["b64_json"] 503 | if b64_data.startswith("data:image/png;base64,"): 504 | b64_data = b64_data[len("data:image/png;base64,"):] 505 | image_data = base64.b64decode(b64_data) 506 | generated_image = Image.open(BytesIO(image_data)) 507 | generated_tensor = pil2tensor(generated_image) 508 | generated_images.append(generated_tensor) 509 | elif "url" in item: 510 | image_urls.append(item["url"]) 511 | try: 512 | img_response = requests.get(item["url"]) 513 | if img_response.status_code == 200: 514 | generated_image = Image.open(BytesIO(img_response.content)) 515 | generated_tensor = pil2tensor(generated_image) 516 | generated_images.append(generated_tensor) 517 | except Exception as e: 518 | print(f"Error downloading image from URL: {str(e)}") 519 | else: 520 | error_message = "No generated images in response" 521 | print(error_message) 522 | response_info += f"Error: {error_message}\n" 523 | blank_image = Image.new('RGB', (1024, 1024), color='white') 524 | blank_tensor = pil2tensor(blank_image) 525 | return (blank_tensor, response_info) 526 | 527 | if "usage" in result: 528 | response_info += "Usage Information:\n" 529 | if "total_tokens" in result["usage"]: 530 | response_info += f"Total Tokens: {result['usage']['total_tokens']}\n" 531 | if "input_tokens" in result["usage"]: 532 | response_info += f"Input Tokens: {result['usage']['input_tokens']}\n" 533 | if "output_tokens" in result["usage"]: 534 | response_info += f"Output Tokens: {result['usage']['output_tokens']}\n" 535 | 536 | if "input_tokens_details" in result["usage"]: 537 | response_info += "Input Token Details:\n" 538 | details = result["usage"]["input_tokens_details"] 539 | if "text_tokens" in details: 540 | response_info += f" Text Tokens: {details['text_tokens']}\n" 541 | if "image_tokens" in details: 542 | response_info += f" Image Tokens: {details['image_tokens']}\n" 543 | 544 | if generated_images: 545 | combined_tensor = torch.cat(generated_images, dim=0) 546 | 547 | pbar.update_absolute(100) 548 | return (combined_tensor, response_info) 549 | else: 550 | error_message = "No images were successfully processed" 551 | print(error_message) 552 | response_info += f"Error: {error_message}\n" 553 | blank_image = Image.new('RGB', (1024, 1024), color='white') 554 | blank_tensor = pil2tensor(blank_image) 555 | return (blank_tensor, response_info) 556 | 557 | except Exception as e: 558 | error_message = f"Error in image generation: {str(e)}" 559 | print(error_message) 560 | blank_image = Image.new('RGB', (1024, 1024), color='white') 561 | blank_tensor = pil2tensor(blank_image) 562 | return (blank_tensor, error_message) 563 | 564 | 565 | class ComfyuiChatGPTApi: 566 | 567 | _last_generated_image_urls = "" 568 | 569 | @classmethod 570 | def INPUT_TYPES(cls): 571 | return { 572 | "required": { 573 | "prompt": ("STRING", {"multiline": True}), 574 | "model": ("STRING", {"default": "gpt-4o-image", "multiline": False}), 575 | }, 576 | "optional": { 577 | "api_key": ("STRING", {"default": ""}), 578 | "files": ("FILES",), 579 | "image_url": ("STRING", {"multiline": False, "default": ""}), 580 | "images": ("IMAGE", {"default": None}), 581 | "temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0, "step": 0.01}), 582 | "max_tokens": ("INT", {"default": 4096, "min": 1, "max": 16384, "step": 1}), 583 | "top_p": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), 584 | "frequency_penalty": ("FLOAT", {"default": -2.0, "min": -2.0, "max": 2.0, "step": 0.01}), 585 | "presence_penalty": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.01}), 586 | "seed": ("INT", {"default": -1, "min": -1, "max": 2147483647}), 587 | "image_download_timeout": ("INT", {"default": 600, "min": 300, "max": 1200, "step": 10}), 588 | "clear_chats": ("BOOLEAN", {"default": True}), 589 | } 590 | } 591 | 592 | RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING") 593 | RETURN_NAMES = ("images", "response", "image_urls", "chats") 594 | FUNCTION = "process" 595 | CATEGORY = "ainewsto/Chatgpt" 596 | 597 | def __init__(self): 598 | self.api_key = get_config().get('api_key', '') 599 | self.timeout = 800 600 | self.image_download_timeout = 600 601 | self.api_endpoint = "https://ai.comfly.chat/v1/chat/completions" 602 | self.conversation_history = [] 603 | 604 | def get_headers(self): 605 | return { 606 | "Content-Type": "application/json", 607 | "Authorization": f"Bearer {self.api_key}" 608 | } 609 | 610 | def image_to_base64(self, image): 611 | """Convert PIL image to base64 string""" 612 | buffered = BytesIO() 613 | image.save(buffered, format="PNG") 614 | return base64.b64encode(buffered.getvalue()).decode('utf-8') 615 | 616 | def file_to_base64(self, file_path): 617 | """Convert file to base64 string and return appropriate MIME type""" 618 | try: 619 | with open(file_path, "rb") as file: 620 | file_content = file.read() 621 | encoded_content = base64.b64encode(file_content).decode('utf-8') 622 | mime_type, _ = mimetypes.guess_type(file_path) 623 | if not mime_type: 624 | mime_type = "application/octet-stream" 625 | return encoded_content, mime_type 626 | except Exception as e: 627 | print(f"Error encoding file: {str(e)}") 628 | return None, None 629 | 630 | def extract_image_urls(self, response_text): 631 | """Extract image URLs from markdown format in response""" 632 | 633 | image_pattern = r'!\[.*?\]\((.*?)\)' 634 | matches = re.findall(image_pattern, response_text) 635 | 636 | if not matches: 637 | url_pattern = r'https?://\S+\.(?:jpg|jpeg|png|gif|webp)' 638 | matches = re.findall(url_pattern, response_text) 639 | 640 | if not matches: 641 | all_urls_pattern = r'https?://\S+' 642 | matches = re.findall(all_urls_pattern, response_text) 643 | return matches if matches else [] 644 | 645 | def download_image(self, url, timeout=30): 646 | """Download image from URL and convert to tensor with improved error handling""" 647 | try: 648 | 649 | headers = { 650 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 651 | 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 652 | 'Accept-Language': 'en-US,en;q=0.9', 653 | 'Referer': 'https://comfyui.com/' 654 | } 655 | 656 | response = requests.get(url, headers=headers, timeout=timeout) 657 | response.raise_for_status() 658 | 659 | content_type = response.headers.get('Content-Type', '') 660 | if not content_type.startswith('image/'): 661 | print(f"Warning: URL doesn't point to an image. Content-Type: {content_type}") 662 | 663 | image = Image.open(BytesIO(response.content)) 664 | return pil2tensor(image) 665 | except requests.exceptions.Timeout: 666 | print(f"Timeout error downloading image from {url} (timeout: {timeout}s)") 667 | return None 668 | except requests.exceptions.SSLError as e: 669 | print(f"SSL Error downloading image from {url}: {str(e)}") 670 | return None 671 | except requests.exceptions.ConnectionError: 672 | print(f"Connection error downloading image from {url}") 673 | return None 674 | except requests.exceptions.RequestException as e: 675 | print(f"Request error downloading image from {url}: {str(e)}") 676 | return None 677 | except Exception as e: 678 | print(f"Error downloading image from {url}: {str(e)}") 679 | return None 680 | 681 | def format_conversation_history(self): 682 | """Format the conversation history for display""" 683 | if not self.conversation_history: 684 | return "" 685 | formatted_history = "" 686 | for entry in self.conversation_history: 687 | formatted_history += f"**User**: {entry['user']}\n\n" 688 | formatted_history += f"**AI**: {entry['ai']}\n\n" 689 | formatted_history += "---\n\n" 690 | return formatted_history.strip() 691 | 692 | def send_request_synchronous(self, payload, pbar): 693 | """Send a synchronous streaming request to the API""" 694 | full_response = "" 695 | session = requests.Session() 696 | 697 | try: 698 | response = session.post( 699 | self.api_endpoint, 700 | headers=self.get_headers(), 701 | json=payload, 702 | stream=True, 703 | timeout=self.timeout 704 | ) 705 | response.raise_for_status() 706 | 707 | for line in response.iter_lines(): 708 | if line: 709 | line_text = line.decode('utf-8').strip() 710 | if line_text.startswith('data: '): 711 | data = line_text[6:] 712 | if data == '[DONE]': 713 | break 714 | try: 715 | chunk = json.loads(data) 716 | if 'choices' in chunk and chunk['choices']: 717 | delta = chunk['choices'][0].get('delta', {}) 718 | if 'content' in delta: 719 | content = delta['content'] 720 | full_response += content 721 | 722 | pbar.update_absolute(min(40, 20 + len(full_response) // 100)) 723 | except json.JSONDecodeError: 724 | continue 725 | 726 | return full_response 727 | 728 | except requests.exceptions.Timeout: 729 | raise TimeoutError(f"API request timed out after {self.timeout} seconds") 730 | except Exception as e: 731 | raise Exception(f"Error in streaming response: {str(e)}") 732 | 733 | def process(self, prompt, model, clear_chats=True, files=None, image_url="", images=None, temperature=0.7, 734 | max_tokens=4096, top_p=1.0, frequency_penalty=0.0, presence_penalty=0.0, seed=-1, 735 | image_download_timeout=100, api_key=""): 736 | 737 | if model.lower() == "gpt-image-1": 738 | error_message = "不支持此模型,请使用 gpt-4o-image,gpt-4o-image-vip,sora_image,sora_image-vip 这4个模型。" 739 | print(error_message) 740 | 741 | if images is not None: 742 | return (images, error_message, "", self.format_conversation_history()) 743 | else: 744 | blank_img = Image.new('RGB', (512, 512), color='white') 745 | return (pil2tensor(blank_img), error_message, "", self.format_conversation_history()) 746 | 747 | if api_key.strip(): 748 | self.api_key = api_key 749 | config = get_config() 750 | config['api_key'] = api_key 751 | save_config(config) 752 | 753 | try: 754 | self.image_download_timeout = image_download_timeout 755 | 756 | if clear_chats: 757 | self.conversation_history = [] 758 | 759 | if not self.api_key: 760 | error_message = "API key not found in Comflyapi.json" 761 | print(error_message) 762 | 763 | blank_img = Image.new('RGB', (512, 512), color='white') 764 | return (pil2tensor(blank_img), error_message, "", self.format_conversation_history()) 765 | 766 | pbar = comfy.utils.ProgressBar(100) 767 | pbar.update_absolute(10) 768 | timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 769 | 770 | if seed < 0: 771 | seed = random.randint(0, 2147483647) 772 | print(f"Using random seed: {seed}") 773 | 774 | content = [] 775 | 776 | content.append({"type": "text", "text": prompt}) 777 | 778 | 779 | if not clear_chats and ComfyuiChatGPTApi._last_generated_image_urls: 780 | prev_image_url = ComfyuiChatGPTApi._last_generated_image_urls.split('\n')[0].strip() 781 | if prev_image_url: 782 | print(f"Using previous image URL: {prev_image_url}") 783 | content.append({ 784 | "type": "image_url", 785 | "image_url": {"url": prev_image_url} 786 | }) 787 | 788 | elif clear_chats: 789 | if images is not None: 790 | batch_size = images.shape[0] 791 | max_images = min(batch_size, 4) 792 | for i in range(max_images): 793 | pil_image = tensor2pil(images)[i] 794 | image_base64 = self.image_to_base64(pil_image) 795 | content.append({ 796 | "type": "image_url", 797 | "image_url": {"url": f"data:image/png;base64,{image_base64}"} 798 | }) 799 | if batch_size > max_images: 800 | content.append({ 801 | "type": "text", 802 | "text": f"\n(Note: {batch_size-max_images} additional images were omitted due to API limitations)" 803 | }) 804 | 805 | elif image_url: 806 | content.append({ 807 | "type": "image_url", 808 | "image_url": {"url": image_url} 809 | }) 810 | 811 | elif image_url: 812 | content.append({ 813 | "type": "image_url", 814 | "image_url": {"url": image_url} 815 | }) 816 | 817 | if files: 818 | file_paths = files if isinstance(files, list) else [files] 819 | for file_path in file_paths: 820 | encoded_content, mime_type = self.file_to_base64(file_path) 821 | if encoded_content and mime_type: 822 | 823 | if mime_type.startswith('image/'): 824 | 825 | content.append({ 826 | "type": "image_url", 827 | "image_url": {"url": f"data:{mime_type};base64,{encoded_content}"} 828 | }) 829 | else: 830 | 831 | content.append({ 832 | "type": "text", 833 | "text": f"\n\nI've attached a file ({os.path.basename(file_path)}) for analysis." 834 | }) 835 | content.append({ 836 | "type": "file_url", 837 | "file_url": { 838 | "url": f"data:{mime_type};base64,{encoded_content}", 839 | "name": os.path.basename(file_path) 840 | } 841 | }) 842 | 843 | messages = [] 844 | 845 | messages.append({ 846 | "role": "user", 847 | "content": content 848 | }) 849 | 850 | payload = { 851 | "model": model, 852 | "messages": messages, 853 | "temperature": temperature, 854 | "max_tokens": max_tokens, 855 | "top_p": top_p, 856 | "frequency_penalty": frequency_penalty, 857 | "presence_penalty": presence_penalty, 858 | "seed": seed, 859 | "stream": True 860 | } 861 | 862 | response_text = self.send_request_synchronous(payload, pbar) 863 | 864 | self.conversation_history.append({ 865 | "user": prompt, 866 | "ai": response_text 867 | }) 868 | 869 | technical_response = f"**Model**: {model}\n**Temperature**: {temperature}\n**Seed**: {seed}\n**Time**: {timestamp}" 870 | 871 | image_urls = self.extract_image_urls(response_text) 872 | image_urls_string = "\n".join(image_urls) if image_urls else "" 873 | 874 | if image_urls: 875 | ComfyuiChatGPTApi._last_generated_image_urls = image_urls_string 876 | 877 | chat_history = self.format_conversation_history() 878 | if image_urls: 879 | try: 880 | 881 | img_tensors = [] 882 | successful_downloads = 0 883 | for i, url in enumerate(image_urls): 884 | print(f"Attempting to download image {i+1}/{len(image_urls)} from: {url}") 885 | 886 | pbar.update_absolute(min(80, 40 + (i+1) * 40 // len(image_urls))) 887 | img_tensor = self.download_image(url, self.image_download_timeout) 888 | if img_tensor is not None: 889 | img_tensors.append(img_tensor) 890 | successful_downloads += 1 891 | print(f"Successfully downloaded {successful_downloads} out of {len(image_urls)} images") 892 | if img_tensors: 893 | 894 | combined_tensor = torch.cat(img_tensors, dim=0) 895 | pbar.update_absolute(100) 896 | return (combined_tensor, technical_response, image_urls_string, chat_history) 897 | except Exception as e: 898 | print(f"Error processing image URLs: {str(e)}") 899 | 900 | if images is not None: 901 | pbar.update_absolute(100) 902 | return (images, technical_response, image_urls_string, chat_history) 903 | else: 904 | blank_img = Image.new('RGB', (512, 512), color='white') 905 | blank_tensor = pil2tensor(blank_img) 906 | pbar.update_absolute(100) 907 | return (blank_tensor, technical_response, image_urls_string, chat_history) 908 | 909 | except Exception as e: 910 | error_message = f"Error calling ChatGPT API: {str(e)}" 911 | print(error_message) 912 | 913 | if images is not None: 914 | return (images, error_message, "", self.format_conversation_history()) 915 | else: 916 | blank_img = Image.new('RGB', (512, 512), color='white') 917 | blank_tensor = pil2tensor(blank_img) 918 | return (blank_tensor, error_message, "", self.format_conversation_history()) 919 | 920 | 921 | NODE_CLASS_MAPPINGS = { 922 | "ComfyuiChatGPTApi": ComfyuiChatGPTApi, 923 | "Comfyui_gpt_image_1_edit": Comfyui_gpt_image_1_edit, 924 | "Comfyui_gpt_image_1": Comfyui_gpt_image_1, 925 | } 926 | 927 | NODE_DISPLAY_NAME_MAPPINGS = { 928 | "ComfyuiChatGPTApi": "ComfyuiChatGPTApi", 929 | "Comfyui_gpt_image_1_edit": "Comfyui_gpt_image_1_edit", 930 | "Comfyui_gpt_image_1": "Comfyui_gpt_image_1", 931 | } 932 | 933 | --------------------------------------------------------------------------------