v2.3 — Character-Aussehen + Portrait-Fix
Features: - Character-Bogen: Aussehen-Sektion mit Stil, Geschlecht, Alter, Größe, Statur, Haarfarbe, Frisur, Augenfarbe, Haut, Kleidung, Merkmale - Stil-Optionen: Anime, Realistisch, Comic, Cartoon, Pixel Art - ComfyUI Prompt wird aus appearance-Objekt gebaut - Portrait-Button: Async mit Polling (kein Timeout mehr) - Portrait-Button: 5-Minuten Timeout als Fallback - Poll-Errors werden ignoriert (kein vorzeitiger Abbruch) - Mara.json aktualisiert mit appearance-Objekt
This commit is contained in:
+14
-1
@@ -10,5 +10,18 @@
|
||||
"nsfw": "Keine Einschränkungen. Kann über alles sprechen. Flirten und Anzügliches sind ok — Mara ist nicht prüde, aber auch nicht leicht zu kriegen.",
|
||||
"greeting": "*wischt den Tresen ab, schickt kurz hoch* Na. Der übliche? Oder willst du mich heute überraschen?",
|
||||
"model": "",
|
||||
"temperature": 0.85
|
||||
"temperature": 0.85,
|
||||
"appearance": {
|
||||
"style": "anime",
|
||||
"gender": "female",
|
||||
"age": "28",
|
||||
"height": "168cm",
|
||||
"build": "schlank",
|
||||
"hair_color": "dunkelbraun",
|
||||
"hair_style": "kurz, ungeordnet",
|
||||
"eye_color": "braun",
|
||||
"skin": "hell",
|
||||
"clothing": "Café-Schürze über schwarzem T-Shirt, Jeans",
|
||||
"distinctive": "müde Augen, leichte Augenringe, immer ein Kaugummi im Mund"
|
||||
}
|
||||
}
|
||||
+71
-7
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
NeonChat ComfyUI Integration — Profilbild-Generierung für Characters
|
||||
Basiert auf dem Character-Aussehen (appearance)
|
||||
"""
|
||||
|
||||
import json, os, time, shutil
|
||||
@@ -8,7 +9,6 @@ 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")))
|
||||
CHARACTERS_DIR = Path(__file__).parent / "characters"
|
||||
AVATARS_DIR = Path(__file__).parent / "static" / "avatars"
|
||||
|
||||
def is_comfyui_running() -> bool:
|
||||
@@ -19,6 +19,69 @@ def is_comfyui_running() -> bool:
|
||||
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": {
|
||||
@@ -47,14 +110,14 @@ def build_portrait_workflow(prompt: str, negative: str = "", width: int = 512, h
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": f"portrait, {prompt}, detailed face, high quality, masterpiece, best quality, 1girl, solo",
|
||||
"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, {negative}",
|
||||
"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]
|
||||
}
|
||||
},
|
||||
@@ -95,14 +158,15 @@ def wait_for_image(prompt_id: str, timeout: int = 180) -> str | None:
|
||||
time.sleep(3)
|
||||
return None
|
||||
|
||||
def generate_portrait(character_name: str, description: str) -> str | None:
|
||||
def generate_portrait(character_name: str, character: dict, context: str = "") -> str | None:
|
||||
"""Generiert ein Profilbild basierend auf dem Character-Aussehen."""
|
||||
if not is_comfyui_running():
|
||||
return None
|
||||
|
||||
AVATARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prompt = f"anime style, {description}, simple background, headshot"
|
||||
negative = "blurry, deformed, ugly, realistic"
|
||||
prompt = build_prompt_from_appearance(character, context)
|
||||
negative = "realistic, 3d, render"
|
||||
|
||||
workflow = build_portrait_workflow(prompt, negative, width=512, height=512)
|
||||
|
||||
@@ -126,4 +190,4 @@ def generate_portrait(character_name: str, description: str) -> str | None:
|
||||
return f"/static/avatars/{safe_name}.png"
|
||||
except Exception as e:
|
||||
print(f"ComfyUI error: {e}")
|
||||
return None
|
||||
return None
|
||||
@@ -454,6 +454,7 @@ async def api_create_character(request: Request):
|
||||
"greeting": body.get("greeting", ""),
|
||||
"model": body.get("model", ""),
|
||||
"temperature": body.get("temperature", 0.8),
|
||||
"appearance": body.get("appearance", {}),
|
||||
}
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
@@ -499,9 +500,12 @@ async def api_list_models():
|
||||
async def api_comfyui_status():
|
||||
return JSONResponse({"running": is_comfyui_running()})
|
||||
|
||||
# In-memory tracking for portrait generation
|
||||
_portrait_jobs = {}
|
||||
|
||||
@app.post("/api/character/{name}/portrait")
|
||||
async def api_generate_portrait(name: str, request: Request):
|
||||
"""Generiert ein Profilbild via ComfyUI."""
|
||||
"""Startet Portrait-Generierung — gibt sofort eine Job-ID zurück."""
|
||||
char = load_character(name)
|
||||
|
||||
if not is_comfyui_running():
|
||||
@@ -510,14 +514,27 @@ async def api_generate_portrait(name: str, request: Request):
|
||||
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', '')}"
|
||||
import uuid
|
||||
job_id = str(uuid.uuid4())[:8]
|
||||
_portrait_jobs[job_id] = {"status": "generating", "image": None, "error": None}
|
||||
|
||||
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)
|
||||
async def run_generation():
|
||||
image_path = generate_portrait(name, char, custom_prompt)
|
||||
if image_path:
|
||||
_portrait_jobs[job_id] = {"status": "done", "image": image_path, "error": None}
|
||||
else:
|
||||
_portrait_jobs[job_id] = {"status": "error", "image": None, "error": "Generierung fehlgeschlagen"}
|
||||
|
||||
asyncio.create_task(run_generation())
|
||||
|
||||
return JSONResponse({"status": "started", "job_id": job_id})
|
||||
|
||||
@app.get("/api/portrait/status/{job_id}")
|
||||
async def api_portrait_status(job_id: str):
|
||||
"""Fragt den Status einer Portrait-Generierung ab."""
|
||||
if job_id not in _portrait_jobs:
|
||||
return JSONResponse({"status": "not_found"}, status_code=404)
|
||||
return JSONResponse(_portrait_jobs[job_id])
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 496 KiB After Width: | Height: | Size: 2.1 KiB |
+39
-7
@@ -315,16 +315,48 @@
|
||||
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 {
|
||||
|
||||
if (!resp.ok) {
|
||||
alert(data.error || 'Generierung fehlgeschlagen');
|
||||
btn.textContent = oldText;
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.job_id) {
|
||||
const jobId = data.job_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const statusResp = await fetch(`/api/portrait/status/${jobId}`);
|
||||
const statusData = await statusResp.json();
|
||||
|
||||
if (statusData.status === 'done' && statusData.image) {
|
||||
clearInterval(pollInterval);
|
||||
const avatarEl = document.querySelector('.avatar');
|
||||
avatarEl.innerHTML = `<img src="${statusData.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 if (statusData.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
alert(statusData.error || 'Generierung fehlgeschlagen');
|
||||
btn.textContent = oldText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
// ignore poll errors, keep trying
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
btn.textContent = oldText;
|
||||
btn.disabled = false;
|
||||
}, 300000);
|
||||
} else if (data.error) {
|
||||
alert(data.error);
|
||||
btn.textContent = oldText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Verbindungsfehler: ' + e.message);
|
||||
|
||||
+89
-18
@@ -7,7 +7,7 @@
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0a0a0f; color: #e0e0e8; min-height: 100vh; }
|
||||
.container { max-width: 700px; margin: 0 auto; padding: 30px 20px; }
|
||||
.container { max-width: 750px; margin: 0 auto; padding: 30px 20px; }
|
||||
h1 { font-size: 1.8rem; margin-bottom: 6px; background: linear-gradient(135deg, #ff006e, #8338ec); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.subtitle { color: #6b6b80; margin-bottom: 24px; font-size: 0.9rem; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
@@ -22,14 +22,20 @@
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.btn-back { background: transparent; border: 1px solid #252535; color: #6b6b80; margin-right: 10px; }
|
||||
#models { margin-top: 4px; }
|
||||
|
||||
.section-header { font-size: 1rem; font-weight: 700; color: #8338ec; margin: 24px 0 12px; padding-bottom: 8px; border-bottom: 1px solid #252535; }
|
||||
.appearance-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
|
||||
.appearance-grid .form-group { margin-bottom: 12px; }
|
||||
@media (max-width: 600px) { .appearance-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✦ Neuer Charakter</h1>
|
||||
<p class="subtitle">Definiere Persönlichkeit, Verhalten und Hintergrund. Der Bot bleibt immer in Rolle.</p>
|
||||
<p class="subtitle">Definiere Persönlichkeit, Verhalten, Hintergrund und Aussehen.</p>
|
||||
|
||||
<form id="createForm">
|
||||
<div class="section-header">Basis</div>
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
@@ -40,50 +46,103 @@
|
||||
<input type="text" id="avatar" value="👤" maxlength="4" style="text-align: center; font-size: 1.5rem;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Kurze Beschreibung <span class="hint">(für die Übersicht)</span></label>
|
||||
<input type="text" id="description" placeholder="z.B. Barista, 28, zynisch aber warmherzig">
|
||||
</div>
|
||||
|
||||
<div class="section-header">Persönlichkeit</div>
|
||||
<div class="form-group">
|
||||
<label>Persönlichkeit <span class="hint">(wie ist der Charakter?)</span></label>
|
||||
<textarea id="personality" placeholder="z.B. Trocken, sarkastisch, aber loyal. Hilfsbereit auf ihre Art. Zeigt Gefühle selten direkt."></textarea>
|
||||
<textarea id="personality" placeholder="z.B. Trocken, sarkastisch, aber loyal."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Hintergrund <span class="hint">(Wer ist der Charakter?)</span></label>
|
||||
<textarea id="background" placeholder="z.B. Arbeitet seit 5 Jahren in einem Café. Hat mal Kunst studiert, abgebrochen. Lebt allein mit einer Katze."></textarea>
|
||||
<textarea id="background" placeholder="z.B. Arbeitet seit 5 Jahren in einem Café..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Verhalten <span class="hint">(wie verhält sich der Charakter?)</span></label>
|
||||
<textarea id="behavior" placeholder="z.B. Flucht in Wortwitze wenn nervös. Klopft auf den Tresen. Unterbricht nicht. Stellt Gegenfragen."></textarea>
|
||||
<textarea id="behavior" placeholder="z.B. Flucht in Wortwitze wenn nervös."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Sprachstil <span class="hint">(wie spricht der Charakter?)</span></label>
|
||||
<textarea id="tone" placeholder="z.B. Umgangssprachlich, 'ne' statt 'nicht', 'mal' statt 'einmal', flucht gelegentlich, kurze Sätze"></textarea>
|
||||
<textarea id="tone" placeholder="z.B. Umgangssprachlich, 'ne' statt 'nicht'..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Szenario <span class="hint">(optional — wo spielt das Gespräch?)</span></label>
|
||||
<input type="text" id="scenario" placeholder="z.B. Du sitzt in einem kleinen Café, es regnet draußen.">
|
||||
<input type="text" id="scenario" placeholder="z.B. Du sitzt in einem kleinen Café...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>NSFW / Soziale Regeln <span class="hint">(optional)</span></label>
|
||||
<textarea id="nsfw" placeholder="z.B. Keine Einschränkungen. Kann über alles sprechen. Flirten erwünscht."></textarea>
|
||||
<textarea id="nsfw" placeholder="z.B. Keine Einschränkungen."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Begrüßung <span class="hint">(erste Nachricht vom Bot)</span></label>
|
||||
<textarea id="greeting" placeholder="z.B. *schaut auf als du das Café betrittst* Na, wieder da? Der übliche?"></textarea>
|
||||
<textarea id="greeting" placeholder="z.B. *schaut auf* Na, wieder da?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="section-header">Aussehen <span class="hint" style="font-weight:400;">(für Portrait-Generierung)</span></div>
|
||||
<div class="appearance-grid">
|
||||
<div class="form-group">
|
||||
<label>Stil</label>
|
||||
<select id="app_style">
|
||||
<option value="anime">Anime</option>
|
||||
<option value="realistic">Realistisch</option>
|
||||
<option value="comic">Comic</option>
|
||||
<option value="cartoon">Cartoon</option>
|
||||
<option value="pixel">Pixel Art</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Geschlecht</label>
|
||||
<select id="app_gender">
|
||||
<option value="female">Weiblich</option>
|
||||
<option value="male">Männlich</option>
|
||||
<option value="other">Divers</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Alter</label>
|
||||
<input type="text" id="app_age" placeholder="z.B. 28">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Größe</label>
|
||||
<input type="text" id="app_height" placeholder="z.B. 168cm">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Statur</label>
|
||||
<input type="text" id="app_build" placeholder="z.B. schlank, athletisch">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Haarfarbe</label>
|
||||
<input type="text" id="app_hair_color" placeholder="z.B. dunkelbraun">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Frisur</label>
|
||||
<input type="text" id="app_hair_style" placeholder="z.B. kurz, ungeordnet">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Augenfarbe</label>
|
||||
<input type="text" id="app_eye_color" placeholder="z.B. braun">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Haut</label>
|
||||
<input type="text" id="app_skin" placeholder="z.B. hell, gebräunt">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kleidung</label>
|
||||
<input type="text" id="app_clothing" placeholder="z.B. Café-Schürze, schwarzes T-Shirt, Jeans">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Besondere Merkmale</label>
|
||||
<input type="text" id="app_distinctive" placeholder="z.B. müde Augen, Augenringe, Kaugummi">
|
||||
</div>
|
||||
|
||||
<div class="section-header">Modell</div>
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label>Modell <span class="hint">(Ollama)</span></label>
|
||||
<label>Ollama Modell</label>
|
||||
<select id="model">
|
||||
<option value="">Default</option>
|
||||
</select>
|
||||
@@ -103,7 +162,6 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load models
|
||||
fetch('/api/models').then(r => r.json()).then(data => {
|
||||
const select = document.getElementById('model');
|
||||
if (data.models) {
|
||||
@@ -131,6 +189,19 @@
|
||||
greeting: document.getElementById('greeting').value,
|
||||
model: document.getElementById('model').value,
|
||||
temperature: parseFloat(document.getElementById('temperature').value),
|
||||
appearance: {
|
||||
style: document.getElementById('app_style').value,
|
||||
gender: document.getElementById('app_gender').value,
|
||||
age: document.getElementById('app_age').value,
|
||||
height: document.getElementById('app_height').value,
|
||||
build: document.getElementById('app_build').value,
|
||||
hair_color: document.getElementById('app_hair_color').value,
|
||||
hair_style: document.getElementById('app_hair_style').value,
|
||||
eye_color: document.getElementById('app_eye_color').value,
|
||||
skin: document.getElementById('app_skin').value,
|
||||
clothing: document.getElementById('app_clothing').value,
|
||||
distinctive: document.getElementById('app_distinctive').value,
|
||||
}
|
||||
};
|
||||
const resp = await fetch('/api/character/create', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user