Files
hair/hairline/comfyui.py
T
xslandCursor 659c037270 feat: ComfyUI 改走 10.60.74.221,测试页上传图超阈值自动降采样
将 worker 默认 ComfyUI 地址改为远端 10.60.74.221:8188;前端测试页在像素超过 1536000 时等比缩小到 786432 以内。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 02:23:35 +08:00

183 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到远端 ComfyUI
(默认 http://10.60.74.221:8188,可用环境变量 COMFYUI_URL 覆盖),
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
ComfyUI 若开启了 HTTP Basic Authuser `admin` + 密码),所有请求都带凭据。
支持多工作流:run() 可通过 workflow_path 指定不同工作流 JSON,自动检测 SaveImage 输出节点。
"""
from __future__ import annotations
import copy
import json
import os
import random
import time
import uuid
import httpx
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://10.60.74.221:8188").rstrip("/")
_WORKFLOW_DEFAULT = os.getenv(
"ADD_HAIR_WORKFLOW",
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
)
COMFY_TIMEOUT = float(os.getenv("COMFYUI_TIMEOUT", "600")) # 单张出图最长等待(秒)
_REPO = os.path.dirname(os.path.dirname(__file__))
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
_SEED_NODE = "6" # RandomNoise
_PROMPT_NODE = "60" # JjkText:提示词
_wf_cache: dict[str, dict] = {} # path → workflow JSON
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
def _comfy_auth():
"""ComfyUI Basic Auth 凭据 (user, password)。
user:环境变量 COMFYUI_USER,默认 admin。
password:环境变量 COMFYUI_PASSWORD → worker_config.json.comfyui_password → password.txt。
无密码则返回 None(不带鉴权,兼容未开启 auth 的实例)。
"""
user = os.getenv("COMFYUI_USER", "admin")
pw = os.getenv("COMFYUI_PASSWORD")
if not pw:
cfg = os.path.join(_REPO, "worker_config.json")
if os.path.isfile(cfg):
try:
with open(cfg, encoding="utf-8") as f:
pw = json.load(f).get("comfyui_password")
except Exception: # noqa: BLE001
pw = None
if not pw:
pwfile = os.path.join(_REPO, "password.txt")
if os.path.isfile(pwfile):
with open(pwfile, encoding="utf-8") as f:
pw = f.read().strip()
return (user, pw) if pw else None
def _load_workflow(workflow_path: str | None = None) -> dict:
"""加载工作流 JSON(按路径缓存)。自动检测 SaveImage 节点 ID。"""
path = workflow_path or _WORKFLOW_DEFAULT
if path not in _wf_cache:
with open(path, encoding="utf-8") as f:
wf = json.load(f)
_wf_cache[path] = wf
# 自动检测 SaveImage 输出节点
for node_id, node in wf.items():
if node.get("class_type") == "SaveImage":
_wf_output_node[path] = node_id
break
else:
raise ValueError(f"工作流 {path} 中未找到 SaveImage 节点")
return _wf_cache[path]
def _get_output_node(workflow_path: str | None = None) -> str:
"""返回指定工作流的 SaveImage 节点 ID。"""
path = workflow_path or _WORKFLOW_DEFAULT
if path not in _wf_output_node:
_load_workflow(path) # 触发检测
return _wf_output_node[path]
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
workflow_path: str | None = None) -> bytes:
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
"""
path = workflow_path or _WORKFLOW_DEFAULT
output_node = _get_output_node(path)
client_id = uuid.uuid4().hex
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0, auth=_comfy_auth()) as cli:
# 1. 上传输入图(含 alpha 遮罩)到 ComfyUI input 目录
fname = f"hair_{client_id}.png"
r = cli.post("/upload/image", files={"image": (fname, rgba_png_bytes, "image/png")},
data={"overwrite": "true", "type": "input"})
r.raise_for_status()
up = r.json()
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
# 2. 改工作流:节点26 输入图 + 随机 seed
wf = copy.deepcopy(_load_workflow(path))
wf[_INPUT_NODE]["inputs"]["image"] = name
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
if prompt is not None:
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
try:
import os as _os
_diag = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
"log", "comfyui_last_submit")
_os.makedirs(_diag, exist_ok=True)
with open(_os.path.join(_diag, "workflow.json"), "w", encoding="utf-8") as _f:
json.dump(wf, _f, ensure_ascii=False, indent=2)
with open(_os.path.join(_diag, "input.png"), "wb") as _f:
_f.write(rgba_png_bytes)
with open(_os.path.join(_diag, "prompt.txt"), "w", encoding="utf-8") as _f:
_f.write(prompt if prompt is not None else "(None=用工作流内置默认)")
except Exception: # noqa: BLE001
pass
# 3. 提交
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
r.raise_for_status()
prompt_id = r.json()["prompt_id"]
# 4. 轮询 /history
deadline = time.time() + timeout
outputs = None
while time.time() < deadline:
hr = cli.get(f"/history/{prompt_id}")
hr.raise_for_status()
hist = hr.json()
if prompt_id in hist:
entry = hist[prompt_id]
status = entry.get("status", {})
if status.get("status_str") == "error":
raise RuntimeError(f"ComfyUI 执行报错: {status}")
outputs = entry.get("outputs")
if outputs and output_node in outputs:
break
time.sleep(1.0)
if not outputs or output_node not in outputs:
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
# 5. 取回输出图
imgs = outputs[output_node].get("images") or []
if not imgs:
raise RuntimeError("ComfyUI 输出无图像")
info = imgs[0]
vr = cli.get("/view", params={"filename": info["filename"],
"subfolder": info.get("subfolder", ""),
"type": info.get("type", "output")})
vr.raise_for_status()
return vr.content
def ping() -> bool:
"""探测 ComfyUI 是否在线(/system_stats)。"""
try:
with httpx.Client(base_url=COMFYUI_URL, timeout=3.0, auth=_comfy_auth()) as cli:
return cli.get("/system_stats").status_code == 200
except Exception: # noqa: BLE001
return False
if __name__ == "__main__":
import sys
inp = sys.argv[1] if len(sys.argv) > 1 else "tests/output/comfy_input.png"
print("ComfyUI:", COMFYUI_URL, "online:", ping())
with open(inp, "rb") as f:
png = run(f.read())
out = "tests/output/grown.png"
with open(out, "wb") as f:
f.write(png)
print(f"生发图已存 {out}{len(png)} bytes")