v2.2 — ComfyUI Portrait Generation

Features:
- 🖼️ Portrait Button im Chat-Header
- /api/comfyui/status: Prüft ob ComfyUI läuft
- /api/character/{name}/portrait: Generiert Profilbild via ComfyUI
- comfyui_integration.py: ComfyUI API Client
  - NoobAI-XL Checkpoint
  - 25 Steps, DPM++ 2M Karras
  - 512x512 Portrait
  - Auto-Copy zu static/avatars/
- UI: Avatar wird nach Generierung ersetzt
- UI: Button zeigt Status (ComfyUI nicht erreichbar = ausgegraut)
- Workflow: Character-Beschreibung → ComfyUI Prompt → Bild → Avatar

Voraussetzung: ComfyUI muss auf localhost:8188 laufen
This commit is contained in:
arch_agent
2026-07-24 21:49:43 +02:00
parent c60d1219b2
commit 52612970c5
3 changed files with 213 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
"""
NeonChat ComfyUI Integration — Profilbild-Generierung für Characters
"""
import json, os, time, urllib.request, urllib.error
from pathlib import Path
COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://localhost:8188")
COMFYUI_OUTPUT_DIR = Path(os.environ.get("COMFYUI_OUTPUT", str(Path.home() / "comfy" / "output")))
CHARACTERS_DIR = Path(__file__).parent / "characters"
AVATARS_DIR = Path(__file__).parent / "static" / "avatars"
def is_comfyui_running() -> bool:
try:
req = urllib.request.Request(f"{COMFYUI_URL}/system_stats", method="GET")
with urllib.request.urlopen(req, timeout=3) as resp:
return resp.status == 200
except:
return False
def build_portrait_workflow(prompt: str, negative: str = "", width: int = 512, height: int = 512) -> dict:
"""Baut einen ComfyUI Workflow für ein Porträtfoto."""
return {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": int(time.time()) % (2**32),
"steps": 25,
"cfg": 7.0,
"sampler_name": "dpmpp_2m",
"scheduler": "karras",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0]
}
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "noobaiXLNAIFP_vPred10Version.safetensors"}
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1}
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": f"portrait, {prompt}, detailed face, high quality, masterpiece, best quality, 1girl, solo",
"clip": ["4", 1]
}
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": f"lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, {negative}",
"clip": ["4", 1]
}
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]}
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "neonchat_portrait", "images": ["8", 0]}
}
}
def queue_prompt(workflow: dict) -> str:
"""Schickt den Workflow an ComfyUI und gibt die Prompt-ID zurück."""
data = json.dumps({"prompt": workflow}).encode("utf-8")
req = urllib.request.Request(
f"{COMFYUI_URL}/prompt",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
return result.get("prompt_id", "")
def get_history(prompt_id: str) -> dict:
"""Holt den Verlauf eines Prompts um das fertige Bild zu finden."""
try:
req = urllib.request.Request(f"{COMFYUI_URL}/history/{prompt_id}", method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read())
except:
return {}
def wait_for_image(prompt_id: str, timeout: int = 120) -> str | None:
"""Wartet bis das Bild fertig ist und gibt den Dateinamen zurück."""
start = time.time()
while time.time() - start < timeout:
history = get_history(prompt_id)
if prompt_id in history:
outputs = history[prompt_id].get("outputs", {})
if "9" in outputs:
images = outputs["9"].get("images", [])
if images:
return images[0].get("filename", "")
time.sleep(2)
return None
def generate_portrait(character_name: str, description: str) -> str | None:
"""
Generiert ein Profilbild für einen Character via ComfyUI.
Gibt den Pfad zur gespeicherten Datei zurück, oder None bei Fehler.
"""
if not is_comfyui_running():
return None
AVATARS_DIR.mkdir(parents=True, exist_ok=True)
# Build prompt from character description
prompt = f"anime style, {description}, simple background, headshot"
negative = "blurry, deformed, ugly, realistic"
workflow = build_portrait_workflow(prompt, negative, width=512, height=512)
try:
prompt_id = queue_prompt(workflow)
if not prompt_id:
return None
filename = wait_for_image(prompt_id, timeout=120)
if not filename:
return None
# Find the image in ComfyUI output
comfy_output = COMFYUI_OUTPUT_DIR / filename
if not comfy_output.exists():
return None
# Copy to avatars dir
safe_name = character_name.lower().replace(" ", "_")
dest = AVATARS_DIR / f"{safe_name}.png"
import shutil
shutil.copy2(str(comfy_output), str(dest))
return f"/static/avatars/{safe_name}.png"
except Exception as e:
print(f"ComfyUI error: {e}")
return None