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:
@@ -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
|
||||||
@@ -15,6 +15,8 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from comfyui_integration import is_comfyui_running, generate_portrait
|
||||||
|
|
||||||
# === Konfiguration ===
|
# === Konfiguration ===
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
CHARACTERS_DIR = BASE_DIR / "characters"
|
CHARACTERS_DIR = BASE_DIR / "characters"
|
||||||
@@ -493,6 +495,30 @@ async def api_list_models():
|
|||||||
except:
|
except:
|
||||||
return JSONResponse({"models": [], "error": "Ollama nicht erreichbar"})
|
return JSONResponse({"models": [], "error": "Ollama nicht erreichbar"})
|
||||||
|
|
||||||
|
@app.get("/api/comfyui/status")
|
||||||
|
async def api_comfyui_status():
|
||||||
|
return JSONResponse({"running": is_comfyui_running()})
|
||||||
|
|
||||||
|
@app.post("/api/character/{name}/portrait")
|
||||||
|
async def api_generate_portrait(name: str, request: Request):
|
||||||
|
"""Generiert ein Profilbild via ComfyUI."""
|
||||||
|
char = load_character(name)
|
||||||
|
|
||||||
|
if not is_comfyui_running():
|
||||||
|
return JSONResponse({"error": "ComfyUI läuft nicht. Starte ComfyUI zuerst."}, status_code=503)
|
||||||
|
|
||||||
|
body = await request.json() if request.headers.get("content-type") == "application/json" else {}
|
||||||
|
custom_prompt = body.get("prompt", "")
|
||||||
|
|
||||||
|
# Build description from character data
|
||||||
|
desc = custom_prompt or f"{char.get('personality', '')}, {char.get('background', '')}"
|
||||||
|
|
||||||
|
image_path = generate_portrait(name, desc)
|
||||||
|
if image_path:
|
||||||
|
return JSONResponse({"status": "ok", "image": image_path})
|
||||||
|
else:
|
||||||
|
return JSONResponse({"error": "Bildgenerierung fehlgeschlagen"}, status_code=500)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=5252)
|
uvicorn.run(app, host="0.0.0.0", port=5252)
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<span class="session-info" id="sessionInfo" title="Session-ID">Session: ...</span>
|
<span class="session-info" id="sessionInfo" title="Session-ID">Session: ...</span>
|
||||||
<span class="session-info" id="passcodeInfo" title="Passcode für andere Geräte" style="border-color: var(--accent); color: var(--accent);">Code: ...</span>
|
<span class="session-info" id="passcodeInfo" title="Passcode für andere Geräte" style="border-color: var(--accent); color: var(--accent);">Code: ...</span>
|
||||||
|
<button class="btn" id="portraitBtn" onclick="generatePortrait()">🖼️ Portrait</button>
|
||||||
<button class="btn" onclick="toggleMemory()">🧠 Gedächtnis</button>
|
<button class="btn" onclick="toggleMemory()">🧠 Gedächtnis</button>
|
||||||
<button class="btn btn-danger" onclick="clearMemory()">🗑️ Vergessen</button>
|
<button class="btn btn-danger" onclick="clearMemory()">🗑️ Vergessen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,6 +292,46 @@
|
|||||||
{% if not history %}
|
{% if not history %}
|
||||||
loadHistory();
|
loadHistory();
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
// Check ComfyUI status on load
|
||||||
|
fetch('/api/comfyui/status').then(r => r.json()).then(data => {
|
||||||
|
if (!data.running) {
|
||||||
|
const btn = document.getElementById('portraitBtn');
|
||||||
|
btn.title = 'ComfyUI läuft nicht — starte ComfyUI zuerst';
|
||||||
|
btn.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function generatePortrait() {
|
||||||
|
const btn = document.getElementById('portraitBtn');
|
||||||
|
const oldText = btn.textContent;
|
||||||
|
btn.textContent = '⏳ Generiert...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/character/${encodeURIComponent(charFilename)}/portrait`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok && data.image) {
|
||||||
|
// Update avatar in header
|
||||||
|
const avatarEl = document.querySelector('.avatar');
|
||||||
|
avatarEl.innerHTML = `<img src="${data.image}?t=${Date.now()}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">`;
|
||||||
|
btn.textContent = '✅ Fertig!';
|
||||||
|
setTimeout(() => { btn.textContent = oldText; btn.disabled = false; }, 2000);
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Generierung fehlgeschlagen');
|
||||||
|
btn.textContent = oldText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Verbindungsfehler: ' + e.message);
|
||||||
|
btn.textContent = oldText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user