├── 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 | 
21 |
22 | 
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 | 
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 | 
49 |
50 | 
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 | 
69 |
70 | 
71 |
72 | 
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 | 
88 |
89 |
90 | 
91 |
92 |
93 |
94 | # 🥵 Comfly的QQ群 / my wechat
95 |
96 | 
97 |
98 | 
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 |
--------------------------------------------------------------------------------