文章目錄

ComfyUI server 內建 API,程式無須操作介面,也能把 workflow 送進佇列並取回圖片。當我們要替同一份 workflow 換一整批文字時,在介面裡每張圖都要重複修改 Prompt、按下「執行」,再去尋找檔案,自己的工具想在背後產圖時也無法這樣操作。只要匯出 API 格式的 workflow,再用一支 Python 腳本替換每張圖的文字與隨機種子,就能一次產出整批圖片。
測試環境是 Windows 11、RTX 3070 8 GB 與 ComfyUI 0.43.2。呼叫本機的 ComfyUI 不需要 API key,ComfyUI 帳號的 API key 是呼叫雲端付費的 Partner Nodes 時才會用到。本文用 MOSS-Image 的文字生圖範本,替貓咪咖啡廳產出三張店員介紹貼文:

匯出 API 格式的 workflow
API 要收的是 API 格式的 workflow,介面「儲存」或「匯出」的檔案不能直接送出。在 ComfyUI 打開已經能正常產圖的 workflow,先調整好每張圖都相同的設定,我們把「解析度選擇器」改成 3:4。接著按左上角的圖示,選「檔案」的「匯出(API)」,這個項目不用開啟開發者模式就會顯示:

輸入檔名 cat-post-api 並按「確認」後,瀏覽器會下載 cat-post-api.json。把這個檔案放到一個新的資料夾,接下來的腳本也放在這裡。
找出要替換的節點
用文字編輯器打開 cat-post-api.json,最外層的每個 key 是節點編號,節點的設定值放在 inputs 裡。搜尋 workflow 裡原本的 Prompt、KSampler 和 SaveImage,就能找到要替換的節點。本文的檔案節錄如下,這次要換的是 Prompt 的 value、KSampler 的 seed,以及決定存檔名稱的 filename_prefix:
1{
2 "9": {
3 "inputs": { "filename_prefix": "moss_image_t2i", "images": ["18:8", 0] },
4 "class_type": "SaveImage"
5 },
6 "18:7": {
7 "inputs": { "seed": 438829726369282, "steps": 25, "cfg": 2.5 },
8 "class_type": "KSampler"
9 },
10 "18:17": {
11 "inputs": { "value": "Cinematic night street photograph: ..." },
12 "class_type": "PrimitiveStringMultiline"
13 }
14}
節點編號與欄位名稱每份 workflow 都不同,例如有些 workflow 的 Prompt 放在 CLIPTextEncode 的 text,因此下一節的腳本要換成自己的編號與欄位。18:7 這種帶冒號的編號代表這個節點在子圖裡。
批次產圖的腳本
腳本會對每張圖重複下面的流程,等待完成的方式和官方 API 範例的 WebSocket 做法相同。開始前要先啟動 ComfyUI,並裝好 uv:
在同一個資料夾新增 generate_cat_posts.py,內容如下。POSTS 是每張圖的名稱、隨機種子與 Prompt,SERVER 要和 ComfyUI 的網址相同:
1import json
2import pathlib
3import urllib.error
4import urllib.parse
5import urllib.request
6import uuid
7
8import websocket
9
10SERVER = "127.0.0.1:8188"
11OUTPUT_DIR = pathlib.Path("output")
12
13POSTS = [
14 {"name": "mochi", "seed": 20261103, "prompt": "貓咪咖啡廳的 Instagram 店員介紹貼文,3:4 直式。上方寫「本週店員」,中間是一隻戴小圍裙的橘貓照片,下方大字寫名字「麻糬」,名字下面寫職稱「店長」。暖色、溫馨的咖啡廳氛圍,版面乾淨。"},
15 {"name": "inky", "seed": 20261104, "prompt": "貓咪咖啡廳的 Instagram 店員介紹貼文,3:4 直式。上方寫「本週店員」,中間是一隻戴小圍裙的黑貓照片,下方大字寫名字「墨墨」,名字下面寫職稱「咖啡師」。暖色、溫馨的咖啡廳氛圍,版面乾淨。"},
16 {"name": "bean", "seed": 20261105, "prompt": "貓咪咖啡廳的 Instagram 店員介紹貼文,3:4 直式。上方寫「本週店員」,中間是一隻戴小領結的賓士貓照片,下方大字寫名字「豆豆」,名字下面寫職稱「保全」。暖色、溫馨的咖啡廳氛圍,版面乾淨。"},
17]
18
19
20def queue_workflow(workflow, client_id):
21 body = json.dumps({"prompt": workflow, "client_id": client_id}).encode()
22 request = urllib.request.Request(
23 f"http://{SERVER}/prompt",
24 data=body,
25 headers={"Content-Type": "application/json"},
26 )
27 try:
28 with urllib.request.urlopen(request) as response:
29 result = json.load(response)
30 except urllib.error.HTTPError as error:
31 raise RuntimeError(error.read().decode()) from error
32 if result["node_errors"]:
33 raise RuntimeError(result["node_errors"])
34 return result["prompt_id"]
35
36
37def wait_until_done(ws, prompt_id):
38 while True:
39 message = ws.recv()
40 if isinstance(message, bytes):
41 continue # 開啟預覽時,取樣中的預覽圖會以二進位傳來
42 event = json.loads(message)
43 data = event.get("data", {})
44 if data.get("prompt_id") != prompt_id:
45 continue
46 if event["type"] == "execution_error":
47 raise RuntimeError(data.get("exception_message"))
48 if event["type"] == "executing" and data.get("node") is None:
49 return
50
51
52def download_images(prompt_id, name):
53 with urllib.request.urlopen(f"http://{SERVER}/history/{prompt_id}") as response:
54 outputs = json.load(response)[prompt_id]["outputs"]
55 for node_output in outputs.values():
56 for index, image in enumerate(node_output.get("images", [])):
57 if image["type"] != "output":
58 continue
59 query = urllib.parse.urlencode(
60 {"filename": image["filename"], "subfolder": image["subfolder"], "type": image["type"]}
61 )
62 with urllib.request.urlopen(f"http://{SERVER}/view?{query}") as response:
63 path = OUTPUT_DIR / f"{name}_{index}.png"
64 path.write_bytes(response.read())
65 print(f"{name}: {path}")
66
67
68workflow = json.loads(pathlib.Path("cat-post-api.json").read_text(encoding="utf-8"))
69OUTPUT_DIR.mkdir(exist_ok=True)
70client_id = str(uuid.uuid4())
71ws = websocket.create_connection(f"ws://{SERVER}/ws?clientId={client_id}")
72
73for post in POSTS:
74 workflow["18:17"]["inputs"]["value"] = post["prompt"]
75 workflow["18:7"]["inputs"]["seed"] = post["seed"]
76 workflow["9"]["inputs"]["filename_prefix"] = f"cats/{post['name']}"
77 prompt_id = queue_workflow(workflow, client_id)
78 wait_until_done(ws, prompt_id)
79 download_images(prompt_id, post["name"])
80
81ws.close()
腳本只在記憶體裡替換內容,不會修改 cat-post-api.json。接著在這個資料夾執行腳本,websocket-client 套件會由 uv 一起準備,ComfyUI 的介面也無須開啟:
1uv run --with websocket-client generate_cat_posts.py
每完成一張,終端機就會印出檔案路徑。全部跑完後,output 資料夾會有三張 880 × 1184 的 PNG,就是本文開頭的貼文,ComfyUI 自己的 output\cats 資料夾也會留一份。在 RTX 3070 8 GB 上,第一張要載入模型,約 150 秒,之後每張約 40 秒。
呼叫失敗的情況
呼叫失敗時會看到下面這些情況,腳本會停下並印出錯誤訊息:
| 情況 | 原因 | 處理方式 |
|---|---|---|
腳本出現 KeyError,或自己送出時 /prompt 回傳 HTTP 500 |
檔案是介面存檔格式的 workflow | 改用「匯出(API)」的檔案 |
/prompt 回傳 HTTP 400,或回傳 HTTP 200 但 node_errors 有內容 |
節點的設定值有錯,例如模型名稱不在清單裡,這時不會產出圖片 | 依 node_errors 的 details 修正該欄位 |
收到 execution_error |
節點執行到一半發生錯誤,例如 VAE 和模型不相符 | 依 exception_message 與 node_id 檢查該節點 |
下面兩種情況沒有錯誤訊息,要從現象判斷:
- 腳本一直等待,ComfyUI 卻已經產完圖:
/prompt沒有帶client_id時,server 不會把這個工作的事件傳給 WebSocket 連線。client_id要和連線網址的clientId相同。 - 終端機立刻印出路徑,ComfyUI 的
output\cats卻沒有新檔案:這次送出的內容和上一次完全相同,ComfyUI 直接回傳上一次的結果。要產出新的圖,就換一個隨機種子。
總結
把 ComfyUI 接進腳本或自己的工具時,先從介面匯出 API 格式的 workflow,再由程式替換節點的設定值、送進佇列並取回圖片。實測用同一支腳本連續產出三張貼文,圖中的文字都正確。