ea1b231fea
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
281 lines
9.4 KiB
Python
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 |