Files
neon-chat/comfyui_integration.py
T
arch_agent ea1b231fea v2.4 — Character Editor + Image-to-Image Portrait Consistency
Features:
- /edit/{name} route: Character nachträglich bearbeiten
- /api/character/update/{name}: API zum Aktualisieren
- create.html dient als Editor und Creator (mode=edit/create)
- Index-Seite: ✏️ Button pro Character
- Portrait-Vorschau im Editor
- Image-to-Image: Folge-Portraits nutzen bestehendes Bild als Referenz
  - LoadImage → ImageScale → VAEEncode → KSampler (denoise=0.45)
  - Aussehen bleibt konsistent über mehrere Generierungen
  - Fallback auf Text-to-Image wenn kein Referenzbild existiert
- ComfyUI Image Upload via /upload/image API
- Jinja2 if-Expressions für vorausgefüllte Formularfelder
2026-07-24 22:08:15 +02:00

281 lines
9.4 KiB
Python

"""
NeonChat ComfyUI Integration — Profilbild-Generierung für Characters
Basiert auf dem Character-Aussehen (appearance)
"""
import json, os, time, shutil
from pathlib import Path
import httpx
COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://localhost:8188")
COMFYUI_OUTPUT_DIR = Path(os.environ.get("COMFYUI_OUTPUT", str(Path.home() / "comfy" / "ComfyUI" / "output")))
AVATARS_DIR = Path(__file__).parent / "static" / "avatars"
def is_comfyui_running() -> bool:
try:
with httpx.Client(timeout=3) as client:
resp = client.get(f"{COMFYUI_URL}/system_stats")
return resp.status_code == 200
except:
return False
def build_prompt_from_appearance(character: dict, context: str = "") -> str:
"""Baut einen ComfyUI-Prompt aus dem Character-Aussehen."""
app = character.get("appearance", {})
style = app.get("style", "anime")
gender = app.get("gender", "female")
age = app.get("age", "25")
height = app.get("height", "")
build = app.get("build", "")
hair_color = app.get("hair_color", "")
hair_style = app.get("hair_style", "")
eye_color = app.get("eye_color", "")
skin = app.get("skin", "")
clothing = app.get("clothing", "")
distinctive = app.get("distinctive", "")
# Style prefix
style_map = {
"anime": "anime style, anime art, ",
"realistic": "photorealistic, realistic, ",
"comic": "comic book style, western comic, ",
"cartoon": "cartoon style, ",
"pixel": "pixel art, ",
}
style_prefix = style_map.get(style, "anime style, ")
# Gender
if gender == "female":
gender_tag = "1girl, woman, "
elif gender == "male":
gender_tag = "1boy, man, "
else:
gender_tag = "person, "
# Build prompt
parts = [
f"portrait, headshot, {style_prefix}{gender_tag}",
f"{age} years old",
]
if build:
parts.append(build)
if hair_color and hair_style:
parts.append(f"{hair_color} hair, {hair_style}")
elif hair_color:
parts.append(f"{hair_color} hair")
elif hair_style:
parts.append(hair_style)
if eye_color:
parts.append(f"{eye_color} eyes")
if skin:
parts.append(f"{skin} skin")
if clothing:
parts.append(f"wearing {clothing}")
if distinctive:
parts.append(distinctive)
if context:
parts.append(context)
prompt = ", ".join(parts)
prompt += ", detailed face, high quality, masterpiece, best quality, simple background"
return prompt
def build_portrait_workflow(prompt: str, negative: str = "", width: int = 512, height: int = 512) -> dict:
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": "NoobAI-XL-v1.1.safetensors"}
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1}
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": prompt,
"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, blurry, deformed, ugly, {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:
with httpx.Client(timeout=10) as client:
resp = client.post(f"{COMFYUI_URL}/prompt", json={"prompt": workflow})
result = resp.json()
return result.get("prompt_id", "")
def get_history(prompt_id: str) -> dict:
try:
with httpx.Client(timeout=5) as client:
resp = client.get(f"{COMFYUI_URL}/history/{prompt_id}")
return resp.json()
except:
return {}
def wait_for_image(prompt_id: str, timeout: int = 180) -> str | None:
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(3)
return None
def build_i2i_workflow(prompt: str, negative: str, reference_image_path: str, width: int = 512, height: int = 512, denoise: float = 0.45) -> dict:
"""Image-to-Image Workflow — nutzt ein Referenzbild für Konsistenz."""
return {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": int(time.time()) % (2**32),
"steps": 25,
"cfg": 7.0,
"sampler_name": "dpmpp_2m",
"scheduler": "karras",
"denoise": denoise,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["10", 0]
}
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "NoobAI-XL-v1.1.safetensors"}
},
"5": {
"class_type": "LoadImage",
"inputs": {"image": reference_image_path}
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": prompt,
"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, blurry, deformed, ugly, {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]}
},
"10": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["11", 0], "vae": ["4", 2]}
},
"11": {
"class_type": "ImageScale",
"inputs": {
"image": ["5", 0],
"upscale_method": "bilinear",
"width": width,
"height": height,
"crop": "center"
}
}
}
def upload_image_to_comfy(image_path: str) -> str:
"""Lädt ein Bild zu ComfyUI hoch und gibt den Dateinamen zurück."""
import os
filename = os.path.basename(image_path)
with open(image_path, "rb") as f:
files = {"image": (filename, f, "image/png")}
with httpx.Client(timeout=30) as client:
resp = client.post(f"{COMFYUI_URL}/upload/image", files=files)
data = resp.json()
return data.get("name", filename)
def generate_portrait(character_name: str, character: dict, context: str = "") -> str | None:
"""Generiert ein Profilbild. Nutzt Image-to-Image wenn ein Referenzbild existiert."""
if not is_comfyui_running():
return None
AVATARS_DIR.mkdir(parents=True, exist_ok=True)
prompt = build_prompt_from_appearance(character, context)
negative = "realistic, 3d, render"
# Check if we have a reference image for consistency
safe_name = character_name.lower().replace(" ", "_")
ref_image = AVATARS_DIR / f"{safe_name}.png"
if ref_image.exists():
# Image-to-Image for consistency
try:
uploaded_name = upload_image_to_comfy(str(ref_image))
workflow = build_i2i_workflow(prompt, negative, uploaded_name, 512, 512, denoise=0.45)
except Exception as e:
print(f"I2I upload failed, falling back to T2I: {e}")
workflow = build_portrait_workflow(prompt, negative, 512, 512)
else:
# First generation — Text-to-Image
workflow = build_portrait_workflow(prompt, negative, 512, 512)
try:
prompt_id = queue_prompt(workflow)
if not prompt_id:
return None
filename = wait_for_image(prompt_id, timeout=180)
if not filename:
return None
comfy_output = COMFYUI_OUTPUT_DIR / filename
if not comfy_output.exists():
return None
dest = AVATARS_DIR / f"{safe_name}.png"
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