tts-voice-creator-clone-and.../routes/conversation.py
mARTin-B78 d4099e45c6 fix(characters): remove the gutters between the concept sheet panels
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:12:15 +02:00

5947 lines
313 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Conversation playground, LLM refinement, audio effects, export/import, speak, MCP."""
from __future__ import annotations
import asyncio
import base64
import contextlib
import copy
import hashlib
import io
import json
import re
import threading
import time
import uuid
import wave
from pathlib import Path
from urllib.parse import quote
import requests
from typing import Optional
from PIL import Image, ImageDraw, ImageOps
from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
from fastapi.responses import Response, StreamingResponse
from core.config import _load_settings, _save_settings, _clean_preview_backend
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES
from core.registry import _registry_get, TEMP_DIR
from core.validation import _copy_limited
from core.audio import _to_wav_16k
from core.voice import (
_AUDIO_EXTS, _UPLOAD_EXTS, _PICTURE_EXTS,
_find_voice_audio, _load_meta, _active_voices_dir,
_voice_audio_files, _is_internal_voice_file,
)
from core.voice_index import rebuild_voice_index
from core.database import background_job_create, background_job_get, background_job_store_artifact, background_job_update
from core.retry import request_with_retries
from core.character_image_check import checker_unavailable, failure_count, summarise
from core.attribution_spans import (
SpanRequestError, build_messages as _span_build_messages, max_tokens_for as _span_max_tokens,
parse_response as _span_parse_response, validate_spans as _span_validate,
validate_only_ids as _span_validate_only_ids,
)
from core.tts_helpers import _preview_request_audio
from routes.stt import _transcribe_audio, _clean_stt_backend
router = APIRouter()
# Serializes outbound calls to the audiobook-attribution LLM. A client-side
# timeout aborts the browser fetch but can't kill the backend's blocking
# requests.post thread, so a slow/cold model leaves "ghost" requests still
# occupying the LLM's single processing slot. Without this lock, the next
# chunk (or the retry-in-halves) fires into that busy slot and the LLM
# answers with 429s that cascade until the ghosts drain.
# threading.Lock (not asyncio.Lock) because the streaming endpoint's SSE
# generator runs in a plain thread (StreamingResponse iterates a sync
# generator via a threadpool), so both the blocking and streaming endpoints
# must share ONE lock object to actually serialize on the LLM's single slot —
# two separate locks would let a stream call and its own blocking fallback
# fire into that slot concurrently, which is exactly the "ghost request"
# scenario this lock exists to prevent.
_attribution_llm_lock = threading.Lock()
# Tracks in-progress "consistent profile + reference" image generations so the
# UI can poll for live status instead of staring at a blocking fetch for up to
# ~20 minutes with zero feedback — that silence is what made a legitimately
# slow-but-working generation look identical to a hung one. Keyed by job id;
# entries are pruned an hour after completion so this can't grow unbounded.
_consistent_image_jobs: dict[str, dict] = {}
_consistent_image_jobs_lock = threading.Lock()
_consistent_image_cancel_events: dict[str, threading.Event] = {}
_consistent_image_idempotency: dict[str, str] = {}
_consistent_image_slots = threading.BoundedSemaphore(2)
_CAST_IDEMPOTENCY: dict[str, tuple[str, dict, float]] = {}
_CAST_IDEMPOTENCY_LOCK = threading.Lock()
_CAST_JOBS: dict[str, dict] = {}
_CAST_JOB_IDEMPOTENCY: dict[str, tuple[str, float]] = {}
_CAST_JOB_MAX = 128
_CAST_JOB_TIMEOUT = 15 * 60
_CHARACTER_VISUAL_PROMPT_VERSION = "v4"
_CHARACTER_IDENTITY_FIELDS = (
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
"gender", "marks", "scars", "nationality_background", "clothing", "era", "profession",
)
# User overrides that _character_visual_facts prefers over the extracted text. They are
# appended to the fingerprint only when set, so a pair made before they existed keeps a
# valid fingerprint while editing an override now marks the pair stale. The browser keeps
# the same two lists (static/js/library-characters.js); tests assert they match.
_CHARACTER_IDENTITY_OVERRIDE_FIELDS = ("visual_appearance", "visual_species", "visual_costume")
class _CastJobRequest:
"""Small internal request adapter used to reuse the validated cast route."""
def __init__(self, data: dict, headers: dict[str, str]):
self._data = data
self.headers = headers
async def json(self):
return self._data
class _ConsistentImageRetryRequest:
def __init__(self, data: dict):
self._data = data
self.headers = {}
async def json(self):
return self._data
def _cast_job_set(job_id: str, **fields) -> None:
job = _CAST_JOBS.get(job_id)
if job is not None:
job.update(fields)
job["updated"] = time.time()
background_job_update(job_id, stage=fields.get("stage"), detail=fields.get("detail"),
done=fields.get("done"), cancelled=fields.get("cancelled"),
error=fields.get("error"), result=fields.get("result"))
def _cast_job_trim() -> None:
if len(_CAST_JOBS) <= _CAST_JOB_MAX:
return
for job_id, job in sorted(_CAST_JOBS.items(), key=lambda item: item[1].get("updated", 0)):
if job.get("done"):
_CAST_JOBS.pop(job_id, None)
if len(_CAST_JOBS) <= _CAST_JOB_MAX:
break
def _cast_public_job(job: dict) -> dict:
return {key: value for key, value in dict(job).items() if key not in {"retry_data", "request_fingerprint"}}
def _cast_job_run_sync(data: dict, headers: dict[str, str]):
return asyncio.run(match_characters_voices(_CastJobRequest(data, headers)))
async def _run_cast_job(job_id: str, data: dict, headers: dict[str, str]) -> None:
if _CAST_JOBS.get(job_id, {}).get("cancel_requested"):
_cast_job_set(job_id, stage="cancelled", detail="Cancelled", done=True, cancelled=True,
error="Casting cancelled")
return
_cast_job_set(job_id, stage="running", detail="Matching characters to voices…")
call = asyncio.create_task(asyncio.to_thread(_cast_job_run_sync, data, headers))
try:
result = await asyncio.wait_for(asyncio.shield(call), timeout=_CAST_JOB_TIMEOUT)
if _CAST_JOBS.get(job_id, {}).get("cancel_requested"):
_cast_job_set(job_id, stage="cancelled", detail="Cancelled", done=True, cancelled=True,
error="Casting cancelled")
else:
_cast_job_set(job_id, stage="done", detail="Done", done=True, result=result)
except asyncio.TimeoutError:
call.add_done_callback(lambda task: task.exception() if not task.cancelled() else None)
_cast_job_set(job_id, stage="error", detail="Timed out", done=True, error="Voice casting timed out")
except Exception as exc:
message = str(exc.detail) if isinstance(exc, HTTPException) else str(exc)
_cast_job_set(job_id, stage="error", detail="Failed", done=True, error=message)
def _fingerprint_text(value) -> str:
"""Text exactly as the browser's _libStr renders it, so both sides hash the same string."""
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (list, tuple)):
return ", ".join(_fingerprint_text(item) for item in value if item)
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
def _character_identity_fingerprint(sheet: dict) -> str:
sheet = _character_form_sheet(sheet)
raw = "|".join(f"{key}:{_fingerprint_text(sheet.get(key))}" for key in _CHARACTER_IDENTITY_FIELDS)
for key in _CHARACTER_IDENTITY_OVERRIDE_FIELDS:
text = _fingerprint_text(sheet.get(key))
if text:
raw += f"|{key}:{text}"
# Same compact hash as the browser-side stale check (which walks UTF-16 code
# units); the fingerprint is only a change detector, not a security token.
value = 0
units = raw.encode("utf-16-le")
for i in range(0, len(units), 2):
value = ((value << 5) - value + int.from_bytes(units[i:i + 2], "little")) & 0xFFFFFFFF
return format(value, "x")
def _consistent_image_result_metadata(sheet: dict, result: dict | None) -> dict:
"""Attach the identity contract to every completed visual pair."""
payload = dict(result or {})
payload["prompt_version"] = _CHARACTER_VISUAL_PROMPT_VERSION
payload["identity_consistent"] = True
payload["identity_fingerprint"] = _character_identity_fingerprint(sheet)
payload["profile_asset_digest"] = _data_image_digest(payload.get("profile"))
payload["reference_asset_digest"] = _data_image_digest(payload.get("reference"))
return payload
def _character_form_sheet(sheet: dict) -> dict:
"""Overlay the selected appearance form without changing the character ID or voice."""
if not isinstance(sheet, dict):
return {}
forms = sheet.get("forms") if isinstance(sheet.get("forms"), list) else []
active_id = str(sheet.get("active_form_id") or "").strip()
if not active_id:
return dict(sheet)
selected = next((form for form in forms if isinstance(form, dict) and str(form.get("id") or "") == active_id), None)
if not selected:
return dict(sheet)
return {**sheet, **{key: value for key, value in selected.items() if key not in {"id", "name"}}}
def _cij_new(job_id: str, name: str) -> None:
with _consistent_image_jobs_lock:
now = time.time()
_consistent_image_jobs[job_id] = {
"name": name, "stage": "queued", "detail": "Queued…",
"started": now, "updated": now, "done": False, "error": None, "result": None,
}
_consistent_image_cancel_events[job_id] = threading.Event()
cutoff = now - 3600
for stale_id in [j for j, v in _consistent_image_jobs.items() if v.get("done") and v.get("updated", 0) < cutoff]:
_consistent_image_jobs.pop(stale_id, None)
_consistent_image_cancel_events.pop(stale_id, None)
for key, mapped_id in list(_consistent_image_idempotency.items()):
if mapped_id == stale_id:
_consistent_image_idempotency.pop(key, None)
background_job_create(job_id, "consistent_images", created=now)
def _cij_set(job_id: str, **fields) -> None:
with _consistent_image_jobs_lock:
job = _consistent_image_jobs.get(job_id)
if job is None:
return
job.update(fields)
job["updated"] = time.time()
if fields:
background_job_update(job_id, stage=fields.get("stage"), detail=fields.get("detail"),
done=fields.get("done"), cancelled=fields.get("cancelled"),
error=fields.get("error"), result=fields.get("result"))
def _persist_consistent_image_artifacts(job_id: str, result: dict) -> dict:
"""Move generated data-URI images to bounded durable files before status storage."""
persisted = dict(result or {})
artifacts = []
for key, filename in (("profile", "profile.png"), ("reference", "reference.png")):
value = persisted.get(key)
if not isinstance(value, str) or not value.startswith("data:") or ";base64," not in value:
continue
header, encoded = value.split(",", 1)
media_type = header[5:].split(";", 1)[0] or "image/png"
try:
payload = base64.b64decode(encoded, validate=True)
artifacts.append({"kind": key, **background_job_store_artifact(job_id, filename, payload, media_type)})
# Keep the data URI in the short-lived in-memory job so the active
# browser can finish its current poll. background_job_update()
# applies its own field-level sanitizer before SQLite storage.
except Exception as exc:
persisted.pop(key, None)
persisted.setdefault("artifact_error", "One or more generated images could not be persisted")
print(f"[background-artifact] {key} persistence failed: {exc}")
if artifacts:
persisted["artifacts"] = artifacts
return persisted
def _data_image_digest(value: object) -> str:
match = re.match(r"^data:image/[\w.+-]+;base64,(.+)$", str(value or ""), re.DOTALL)
if not match:
return ""
try:
return hashlib.sha256(base64.b64decode(match.group(1), validate=True)).hexdigest()
except Exception:
return ""
def _strip_image_payloads(value):
if isinstance(value, dict):
return {key: _strip_image_payloads(item) for key, item in value.items()
if key not in {"image", "concept_art_image", "identity_image", "profile_asset_digest", "reference_asset_digest"}}
if isinstance(value, list):
return [_strip_image_payloads(item) for item in value[:20]]
return value
class _ConsistentImageCancelled(Exception):
"""Internal cooperative cancellation signal for a consistent-image worker."""
def _cij_check_cancelled(job_id: str) -> None:
with _consistent_image_jobs_lock:
event = _consistent_image_cancel_events.get(job_id)
if event is not None and event.is_set():
raise _ConsistentImageCancelled()
@contextlib.contextmanager
def _watchdog_close(resp, timeout_seconds):
"""Force-close `resp`'s connection if the wrapped block hasn't finished
within timeout_seconds.
requests' own `timeout=` on a stream=True call only guards the connect
+ first byte — NOT the gaps between subsequent body reads. If the LLM
backend goes silent mid-stream (no more chunks, connection left open),
the blocking socket recv() inside iter_lines() can hang forever, and
since that's a native blocking call rather than a Python-level `yield`
point, neither a wall-clock check in the loop body nor GeneratorExit from
a disconnected client can interrupt it — both only take effect at the
next bytecode boundary, which never arrives. Observed in production: a
stuck stream held the shared attribution lock for 11+ minutes, silently
starving every other passage. Closing the connection from a separate
watchdog thread forces the blocked recv() to raise, unblocking the
generator so the normal except/finally cleanup (incl. releasing the
lock) actually runs.
"""
done = threading.Event()
def _kill():
if not done.wait(timeout_seconds):
try:
resp.close()
except Exception:
pass
t = threading.Thread(target=_kill, daemon=True)
t.start()
try:
yield
finally:
done.set()
# ── STT hallucination filter ──────────────────────────────────────────────────
# Whisper commonly hallucinates these phrases on silence/noise.
# Treat them as "no speech detected" rather than passing them to the LLM.
_HALLUCINATIONS: frozenset[str] = frozenset([
"reich", "danke", "danke schön", "danke schoen", "vielen dank",
"thank you", "thank you.", "thanks", "thanks.", "you", "you.",
"copyright", "abonnieren", "untertitel", "subscribe", "subscribing",
])
def _is_hallucination(text: str) -> bool:
t = text.strip().lower().rstrip(".!?,;:-").strip()
return len(t) <= 2 or t in _HALLUCINATIONS
# ── Sentence-boundary helpers for pipelined TTS ───────────────────────────────
_SENT_RE = re.compile(r'(?<=[.!?])\s+')
_MIN_SENTENCE = 30 # min chars in buffer before we split
def _sentence_split(buf: str) -> int:
"""Return the index after the first sentence boundary, or -1."""
if len(buf) < _MIN_SENTENCE:
return -1
for m in _SENT_RE.finditer(buf):
if m.end() >= _MIN_SENTENCE:
return m.end()
return -1
# ── LLM helpers ───────────────────────────────────────────────────────────────
def _reasoning_text(obj: dict) -> str:
"""Extract chain-of-thought text from a message or delta dict. Most
OpenAI-compatible backends use `reasoning_content`, but some vLLM builds
(observed: vllm-0.23.1rc1) use a plain `reasoning` key instead — check both."""
return obj.get("reasoning_content") or obj.get("reasoning") or ""
def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str:
"""Inline synchronous persona rewrite; raises RuntimeError on failure."""
settings = _load_settings()
system = (
f"Rephrase the user's text as if spoken by this character: {persona}\n"
"Keep the same meaning but adapt vocabulary, tone, and style to the character. "
"Return ONLY the rephrased text — no quotes, no explanation."
)
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": text},
],
"temperature": 0.3,
"max_tokens": 512,
}
if model:
payload["model"] = model
resp = requests.post(
f"{llm_url.rstrip('/')}/chat/completions",
json=payload,
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=60,
)
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
result = (_msg.get("content") or _reasoning_text(_msg) or "").strip()
if result.startswith('"') and result.endswith('"'):
result = result[1:-1].strip()
return result
def _request_timeout_seconds(value, default: float = 600.0, minimum: float = 5.0, maximum: float = 600.0) -> float:
"""Clamp caller-provided LLM timeouts so UI recovery cannot hang indefinitely."""
try:
timeout = float(value)
except Exception:
timeout = default
return max(minimum, min(maximum, timeout))
def _is_router_model_alias(model: str) -> bool:
return bool(re.match(r"^\s*auto[-_ ]?router", model or "", re.I))
def _response_error_text(resp) -> str:
try:
text = resp.text or ""
except Exception:
text = ""
return text.strip()[:2000] or getattr(resp, "reason", "") or "Unknown upstream error"
def _llm_chat_completion_urls(llm_url: str) -> list[str]:
"""Return compatible chat-completions routes for an LLM base URL.
Most OpenAI-compatible servers use a ``/v1`` prefix, but a few routing
gateways expose the same API at their root. In particular, gateways can
reply 405 (rather than 404) when the version-prefixed route is disabled.
Trying the root route after a versioned route prevents a single passage
from being lost just because the configured gateway uses that variant.
"""
base = (llm_url or "http://localhost:11434/v1").rstrip("/")
versioned = bool(re.search(r"/v\d+(?:\.\d+)?$", base))
root = re.sub(r"/v\d+(?:\.\d+)?$", "", base) if versioned else base
urls: list[str] = []
if not versioned:
urls.append(f"{base}/v1/chat/completions")
urls.append(f"{base}/chat/completions")
if versioned:
urls.append(f"{root}/chat/completions")
return list(dict.fromkeys(urls))
def _post_llm_chat_completion(llm_url: str, payload: dict, headers: dict, timeout: float):
last_resp = None
last_exc = None
for url in _llm_chat_completion_urls(llm_url):
try:
resp = request_with_retries("POST", url, json=payload, headers=headers, timeout=timeout, attempts=2)
last_resp = resp
if resp.status_code in (404, 405) and url != _llm_chat_completion_urls(llm_url)[-1]:
continue
return resp
except Exception as exc:
last_exc = exc
if last_resp is not None:
return last_resp
if last_exc:
raise last_exc
raise RuntimeError("LLM request failed")
def _fallback_attribute_response(text: str) -> dict:
"""Deterministic fallback for attribution when an upstream LLM fails."""
quote_re = re.compile(
r"»([^«‹]+)[«‹]|«([^»›]+)[»›]|„([^“”]+)[“”]|“([^”]+)”|\"([^\"]+)\"|「([^」]+)」|『([^』]+)』",
re.DOTALL,
)
segments = []
pos = 0
for match in quote_re.finditer(text):
if match.start() > pos:
narr = text[pos:match.start()].strip()
if narr:
segments.append({"speaker": "Narrator", "type": "narration", "text": narr, "emotion": ""})
spoken = next((g for g in match.groups() if g), "")
if spoken.strip():
segments.append({"speaker": "Unknown", "type": "dialogue", "text": spoken.strip(), "emotion": ""})
pos = match.end()
tail = text[pos:].strip()
if tail:
segments.append({"speaker": "Narrator", "type": "narration", "text": tail, "emotion": ""})
if not segments:
segments = [{"speaker": "Narrator", "type": "narration", "text": text, "emotion": ""}]
chars = []
for seg in segments:
sp = seg.get("speaker")
if seg.get("type") == "dialogue" and sp and sp not in ("Narrator", "Unknown") and sp not in chars:
chars.append(sp)
return {"segments": segments, "characters": chars, "fallback": True}
def _repair_unclosed_dialogue_segments(segments: list) -> list:
"""Join dialogue with a following narration fragment split before its quote close.
OCR and LLM output occasionally mix German guillemet pairs (``»…‹`` and
``«…›``). When that happens, a long epigraph can be split at an arbitrary
chunk boundary. Only repair a dialogue segment whose opening quote is
still unmatched, and only consume the following narration through the
corresponding close marker; unrelated narration remains untouched.
"""
pairs = (("»", ("«", "‹")), ("«", ("»", "›")), ("„", ("“", "”")), ("“", ("”",)), ("「", ("」",)), ("『", ("』",)))
repaired = [dict(seg) if isinstance(seg, dict) else seg for seg in segments]
index = 0
while index < len(repaired):
current = repaired[index]
if not isinstance(current, dict) or str(current.get("type") or "").lower() != "dialogue":
index += 1
continue
text = str(current.get("text") or "")
pair = next(((open_mark, closes) for open_mark, closes in pairs if open_mark in text), None)
if not pair:
index += 1
continue
open_mark, close_marks = pair
opening = text.find(open_mark)
if any(mark in text[opening + 1:] for mark in close_marks):
index += 1
continue
for following in range(index + 1, len(repaired)):
candidate = repaired[following]
if not isinstance(candidate, dict):
continue
if str(candidate.get("type") or "").lower() != "narration":
break
candidate_text = str(candidate.get("text") or "")
close_at = min((candidate_text.find(mark) for mark in close_marks if candidate_text.find(mark) >= 0), default=-1)
if close_at < 0:
continue
current["text"] = text + " " + candidate_text[:close_at + 1]
remainder = candidate_text[close_at + 1:].strip()
if remainder:
candidate["text"] = remainder
else:
repaired.pop(following)
break
index += 1
return repaired
def _resolve_speak_voice(settings: dict, client_id: str, explicit_voice: str) -> str:
if explicit_voice:
return explicit_voice
bindings: dict = settings.get("client_voice_bindings") or {}
if client_id and client_id in bindings:
return bindings[client_id]
return settings.get("captures_default_voice") or ""
# ── Routes ────────────────────────────────────────────────────────────────────
@router.post("/api/refine-text")
async def refine_text(request: Request):
"""Clean up raw STT transcription using a local OpenAI-compatible LLM."""
data = await request.json()
settings = _load_settings()
text: str = (data.get("text") or "").strip()
llm_url: str = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or settings.get("refine_model") or settings.get("llm_model") or "").strip()
toggles: dict = data.get("toggles") or {}
if not text:
raise HTTPException(400, "No text to refine")
rules = []
if toggles.get("fillers", True):
rules.append("Remove filler words (um, uh, like, you know, basically, literally, I mean, so, right, etc.)")
if toggles.get("repetitions", True):
rules.append("Remove repeated words and false starts (e.g. 'the the dog' → 'the dog', 'I was- I was going' → 'I was going')")
if toggles.get("corrections", True):
rules.append("Remove self-corrections and restarts, keeping only the final intended phrasing")
if toggles.get("punctuation", True):
rules.append("Fix punctuation, capitalisation, and sentence boundaries")
if not rules:
return {"text": text, "original": text}
system = (
"You are a transcription cleanup assistant. "
"Apply ONLY the following rules to the user's text. "
"Return ONLY the cleaned text — no explanations, no quotes, no markdown:\n"
+ "\n".join(f"- {r}" for r in rules)
)
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": text},
],
"temperature": 0.1,
"max_tokens": 2048,
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=60,
)
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
refined = (_msg.get("content") or _reasoning_text(_msg) or "").strip()
if refined.startswith('"') and refined.endswith('"'):
refined = refined[1:-1].strip()
return {"text": refined, "original": text}
except Exception as e:
raise HTTPException(502, f"LLM refinement failed: {e}")
@router.post("/api/rewrite-with-persona")
async def rewrite_with_persona(request: Request):
"""Rewrite user text in a voice persona's character using a local LLM."""
data = await request.json()
settings = _load_settings()
text: str = (data.get("text") or "").strip()
persona: str = (data.get("persona") or "").strip()
llm_url: str = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or settings.get("llm_model") or "").strip()
mode: str = (data.get("mode") or "rewrite").strip()
if not persona:
raise HTTPException(400, "No persona defined for this voice")
if not text and mode != "compose":
raise HTTPException(400, "No text provided")
if mode == "compose":
system = (
f"You are a voice assistant with this character: {persona}\n"
"Write a single natural utterance in this character's voice about the topic given. "
"Return ONLY the utterance — no quotes, no explanation."
)
user_msg = text or "Introduce yourself briefly."
temp = 0.9
else:
system = (
f"Rephrase the user's text as if spoken by this character: {persona}\n"
"Keep the same meaning but adapt vocabulary, tone, and style to the character. "
"Return ONLY the rephrased text — no quotes, no explanation."
)
user_msg = text
temp = 0.3
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_msg},
],
"temperature": temp,
"max_tokens": 512,
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600,
)
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
result = (_msg.get("content") or _reasoning_text(_msg) or "").strip()
if result.startswith('"') and result.endswith('"'):
result = result[1:-1].strip()
return {"text": result, "original": text, "persona": persona}
except Exception as e:
raise HTTPException(502, f"LLM persona rewrite failed: {e}")
@router.post("/api/analyze-characters")
async def analyze_characters(request: Request):
"""Analyze a script with an LLM and return per-character voice descriptions.
Used by the Script Rehearser to auto-design voices that match each role.
Returns: {"characters": [{name, gender, language, age, description}, ...]}
"""
data = await request.json()
script: str = (data.get("script") or "").strip()
names: list = data.get("names") or []
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not script:
raise HTTPException(400, "No script provided")
if not names:
raise HTTPException(400, "No character names provided")
# Truncate script to leave room for output within typical model context windows.
# German/non-English text tokenises at ~2.5–3 chars/token, so 6000 chars ≈ 2000–2400 tokens.
if len(script) > 6000:
script = script[:6000]
lang_hint = f" The script language is {language}." if language else ""
def _build_system() -> str:
return (
"You are a casting director and TTS voice-design expert. "
"Your job is to read a script, understand each character deeply from their "
"dialogue, role, and context, then write a voice description that a "
"text-to-speech model can use to generate a matching voice.\n"
f"{lang_hint}\n"
"For EVERY character in the provided list output:\n"
"- name: exact name as given\n"
"- gender: M, F, or N\n"
"- language: spoken language of this character\n"
"- age: estimated age range (e.g. 20s, 40s, elderly)\n"
"- description: 2–3 sentences covering pitch (high/mid/low), pace, "
"timbre, accent/dialect, emotional default, and any distinctive speech trait "
"that fits the character's personality and role.\n"
"Treat gender and age as hard casting constraints, not suggestions. Do not "
"default every character to an adult neutral American voice. Preserve the "
"script's language and specify a native accent/dialect when supported by "
"the text or setting; do not invent a US accent. Give every character a "
"different audible fingerprint (pitch register, rhythm, resonance, texture, "
"or articulation) from the other characters in this response.\n"
"Characters with few lines: infer cautiously from their role name and context, "
"and mark uncertain age/gender as N/unknown rather than guessing confidently.\n"
"Respond with STRICT JSON only — no markdown, no explanation:\n"
'{"characters":[{"name":"NAME","gender":"M|F|N","language":"LANG",'
'"age":"30s","description":"voice description"}]}\n'
"Use the exact character names provided.\n/no-think"
)
def _call_llm(batch: list[str]) -> list[dict]:
"""Call the LLM for one batch of character names; return list of character dicts."""
user_msg = (
"Character names: " + ", ".join(str(n) for n in batch) + "\n\n"
"Script:\n" + script
)
# 150 tokens per character output, capped at 3072 to stay within 16K context
mt = min(3072, max(512, len(batch) * 150))
payload: dict = {
"messages": [
{"role": "system", "content": _build_system()},
{"role": "user", "content": user_msg},
],
"temperature": 0.4,
"max_tokens": mt,
}
if model:
payload["model"] = model
resp = requests.post(
f"{llm_url}/chat/completions",
json=payload,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600,
)
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip()
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
for candidate in (content, _extract_json_block(content)):
if not candidate:
continue
try:
parsed = json.loads(candidate)
if isinstance(parsed, dict) and "characters" in parsed:
return parsed["characters"]
except Exception:
continue
return []
# Process cast in batches of 10 to stay well within 16 K context
BATCH = 10
all_characters: list[dict] = []
try:
for i in range(0, len(names), BATCH):
batch = names[i:i + BATCH]
all_characters.extend(_call_llm(batch))
except Exception as e:
raise HTTPException(502, f"LLM character analysis failed: {e}")
if not all_characters:
raise HTTPException(502, "LLM did not return valid character JSON")
return {"characters": all_characters}
@router.post("/api/match-characters-voices")
async def match_characters_voices(request: Request):
"""Pick the best EXISTING library voice for each character (instead of designing new ones).
Body: {script, names:[...], voices:[{id, gender, language, tags, description}], llm_url, model, language}
Returns: {"assignments": [{name, voice_id, reason}]}
"""
data = await request.json()
idempotency_key = str(request.headers.get("idempotency-key") or "").strip()
if len(idempotency_key) > 200:
raise HTTPException(400, "Idempotency-Key is too long")
if data.get("background") is True:
job_fingerprint = hashlib.sha256(json.dumps({k: v for k, v in data.items() if k != "background"}, sort_keys=True, default=str).encode()).hexdigest()
if idempotency_key:
with _CAST_IDEMPOTENCY_LOCK:
previous_job_id, created = _CAST_JOB_IDEMPOTENCY.get(idempotency_key, ("", 0))
if previous_job_id and time.time() - created <= 1800:
previous_job = _CAST_JOBS.get(previous_job_id)
if previous_job is not None:
if previous_job.get("request_fingerprint") != job_fingerprint:
raise HTTPException(409, "Idempotency-Key was already used for a different request")
return {"job_id": previous_job_id, "deduplicated": True}
_CAST_JOB_IDEMPOTENCY.pop(idempotency_key, None)
job_id = uuid.uuid4().hex
job_data = dict(data)
job_data.pop("background", None)
now = time.time()
_CAST_JOBS[job_id] = {
"job_id": job_id, "stage": "queued", "detail": "Queued", "started": now, "updated": now,
"done": False, "cancel_requested": False, "cancelled": False, "error": None, "result": None,
"request_fingerprint": job_fingerprint,
"retry_data": dict(job_data),
}
background_job_create(job_id, "casting", fingerprint=job_fingerprint, created=now)
if idempotency_key:
with _CAST_IDEMPOTENCY_LOCK:
_CAST_JOB_IDEMPOTENCY[idempotency_key] = (job_id, now)
if len(_CAST_JOB_IDEMPOTENCY) > 256:
oldest = min(_CAST_JOB_IDEMPOTENCY, key=lambda key: _CAST_JOB_IDEMPOTENCY[key][1])
_CAST_JOB_IDEMPOTENCY.pop(oldest, None)
_cast_job_trim()
asyncio.create_task(_run_cast_job(job_id, job_data, {"idempotency-key": idempotency_key}))
return {"job_id": job_id, "stage": "queued"}
request_fingerprint = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
if idempotency_key:
now = time.time()
with _CAST_IDEMPOTENCY_LOCK:
for old_key, (_, _, created) in list(_CAST_IDEMPOTENCY.items()):
if now - created > 1800:
_CAST_IDEMPOTENCY.pop(old_key, None)
previous = _CAST_IDEMPOTENCY.get(idempotency_key)
if previous:
if previous[0] != request_fingerprint:
raise HTTPException(409, "Idempotency-Key was already used for a different request")
return {**previous[1], "deduplicated": True}
script: str = (data.get("script") or "").strip()
names: list = data.get("names") or []
voices: list = data.get("voices") or []
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not names:
raise HTTPException(400, "No character names provided")
if not voices:
raise HTTPException(400, "No candidate voices provided")
if len(script) > 5000:
script = script[:5000]
def _vline(v: dict) -> str:
meta = [str(v.get(k)) for k in ("gender", "age", "language") if v.get(k)]
if v.get("tags"):
meta.append("tags:" + str(v["tags"]))
desc = str(v.get("description") or v.get("name") or "")[:120]
return f"- {v.get('id')} [{', '.join(meta)}] {desc}".rstrip()
catalogue = "\n".join(_vline(v) for v in voices[:300])
valid_ids = {str(v.get("id")) for v in voices if v.get("id")}
system = (
"You are a casting director assigning existing TTS voices to script characters. "
f"{('Script language: ' + language + '. ') if language else ''}"
"For EACH character, choose the single BEST voice_id from the CATALOGUE. "
"RULE 1 — GENDER FIRST: the voice's gender MUST match the character's gender whenever the "
"character's gender is clear from the script; only pick a different gender if no same-gender "
"voice exists in the catalogue. "
"RULE 2 — then match apparent age, language/accent, personality, and the voice's description/tags. "
"RULE 3 — maximize cast diversity: do not reuse a voice when another suitable candidate exists; "
"avoid voices with the same gender, age, accent, and timbre as an already assigned character. "
"You MUST choose a voice_id that appears verbatim in the catalogue — never invent one. "
"Reuse a voice for two characters only if no better distinct option exists. "
"Respond with STRICT JSON only, no markdown:\n"
'{"assignments":[{"name":"NAME","voice_id":"ID","reason":"short reason"}]}\n/no-think'
)
user = (
"Characters to cast: " + ", ".join(str(n) for n in names) + "\n\n"
"CATALOGUE (voice_id [gender, language, tags] description):\n" + catalogue + "\n\n"
"Script excerpt:\n" + script
)
payload: dict = {
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
"temperature": 0.3,
"max_tokens": min(2048, max(512, len(names) * 60)),
}
if model:
payload["model"] = model
import time as _time
raw = None
last_err: Exception | None = None
for attempt in range(3):
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip()
break
except requests.exceptions.ConnectionError as e:
# llama-swap (and similar) often drop the first request while swapping/loading
# the model — wait and retry rather than failing the cast.
last_err = e
_time.sleep(4 + attempt * 3)
except Exception as e:
last_err = e
break
if raw is None:
raise HTTPException(502, f"LLM voice matching failed: {last_err}")
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
assignments: list = []
for cand in (content, _extract_json_block(content)):
if not cand:
continue
try:
parsed = json.loads(cand)
if isinstance(parsed, dict) and isinstance(parsed.get("assignments"), list):
assignments = parsed["assignments"]
break
except Exception:
continue
# Keep only assignments that reference a real catalogue voice
clean = []
used_ids = set()
for a in assignments:
if not isinstance(a, dict) or str(a.get("voice_id")) not in valid_ids:
continue
# A voice may be reused only as a last resort. Most books have enough
# suitable voices, and reusing the same one is the main source of the
# user's "all 3-star voices sound alike" complaint.
vid = str(a.get("voice_id"))
if vid in used_ids and len(used_ids) < len(valid_ids):
continue
used_ids.add(vid)
clean.append(a)
result = {"assignments": clean}
if idempotency_key:
with _CAST_IDEMPOTENCY_LOCK:
_CAST_IDEMPOTENCY[idempotency_key] = (request_fingerprint, result, time.time())
if len(_CAST_IDEMPOTENCY) > 256:
oldest = min(_CAST_IDEMPOTENCY, key=lambda k: _CAST_IDEMPOTENCY[k][2])
_CAST_IDEMPOTENCY.pop(oldest, None)
return result
@router.get("/api/match-characters-voices/status/{job_id}")
async def match_characters_voices_status(job_id: str):
job = _CAST_JOBS.get(job_id)
if job is None:
job = background_job_get(job_id)
if job is None:
raise HTTPException(404, "Unknown or expired casting job")
payload = dict(job)
payload["elapsed"] = round(time.time() - payload.pop("started", payload.get("created", time.time())), 1)
payload.pop("cancel_requested", None)
payload.pop("request_fingerprint", None)
payload.pop("retry_data", None)
return payload
@router.post("/api/match-characters-voices/cancel/{job_id}")
async def match_characters_voices_cancel(job_id: str):
job = _CAST_JOBS.get(job_id)
if job is None:
raise HTTPException(404, "Unknown or expired casting job")
if job.get("done"):
return {"ok": True, "already_done": True, "cancelled": bool(job.get("cancelled"))}
job["cancel_requested"] = True
_cast_job_set(job_id, stage="cancelling", detail="Cancelling…")
return {"ok": True, "cancelled": True}
@router.post("/api/match-characters-voices/retry/{job_id}")
async def match_characters_voices_retry(job_id: str):
job = _CAST_JOBS.get(job_id)
if job is None:
raise HTTPException(404, "Original casting input is no longer available")
if not job.get("done") or not job.get("error"):
raise HTTPException(409, "Only failed casting jobs can be retried")
data = dict(job.get("retry_data") or {})
if not data.get("names") or not data.get("voices"):
raise HTTPException(410, "Original casting input is no longer available")
data["background"] = True
return await match_characters_voices(_CastJobRequest(data, {}))
def _extract_json_block(text: str) -> str:
"""Pull the first {...} JSON object out of an LLM response."""
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
if text.startswith("```"):
text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
text = re.sub(r"\n?```$", "", text).strip()
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start:end + 1]
return ""
def _charsheets_prepare(data: dict) -> dict:
"""Resolve settings and build the chat payload for one character-sheets
extraction request. Shared by the blocking endpoint and the streaming
(watch-it-fill-out) endpoint so the prompt can never drift between them."""
text: str = (data.get("text") or "").strip()
known: list = data.get("known_characters") or []
target_mode: bool = bool(data.get("target_mode"))
existing: str = (data.get("existing") or "").strip() # partial sheets so far (progressive fill)
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
timeout_seconds = _request_timeout_seconds(data.get("timeout_seconds"), 600.0)
if not text:
raise HTTPException(400, "No text provided")
lang_instruction = (
f"⚠️ SPRACHE / LANGUAGE — OBLIGATORISCH / MANDATORY:\n"
f"The source text is in {language}.\n"
f"YOU MUST write EVERY descriptive field value in {language}. This is non-negotiable.\n"
f"Fields that MUST be in {language}: physical, clothing, alignment, arc_note, skills, "
f"capabilities, backstory, relationships, motivation, fears, mannerisms, communication_style, voice_pattern, "
f"secret, conflict_style, win_condition, archetype, aliases, first_name, last_name, title, age_estimate, "
f"race_species, languages, nationality_background, social_class, profession, reputation, religious_beliefs, notes.\n"
f"voice_design_prompt should be a concise English tool prompt for generation tools.\n"
f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n"
f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n"
) if language else ""
target_instruction = (
"CAST TARGET MODE:\n"
"- The known-characters list is the already recognized cast roster from the audiobook pass. "
"Treat it as TARGET PROFILES to fill, not as a list of aliases and not as proof that those names are the same person.\n"
"- Prefer outputting sheets for those target names when this passage reveals usable profile details. "
"Do not output a blank sheet just because a target is listed.\n"
"- If the passage proves that a target has another name/title/nickname, keep ONE profile using the best known target name "
"and put only the proven alternate form in aliases/title/full_name.\n"
"- Do NOT invent brand-new profiles in this pass. Only refine the already-casted roster; ignore places, institutions, and other non-person entities.\n"
"- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n"
"- Before deciding a passage is about someone else: check whether the person could BE one of the targets "
"under an alias, title, role, or nickname instead of their literal listed name — check both this passage's "
"own context (action beats, who's being addressed, profession mentioned) AND the 'existing sheets so far' "
"below, which may already record that alias for a target (e.g. a target's existing sheet says "
"'aliases: der Schmied' — a passage about 'der Schmied' with no other name given IS that target, attribute "
"it there, do not skip it just because the passage never says the target's literal name).\n"
"- Only if, after that check, the passage is clearly about a DIFFERENT person who is NOT one of the listed "
"targets and NOT an alias/role already recorded for one of them (a scene that doesn't actually involve any "
"target), output NO sheet for this passage at all — an empty 'sheets' array is correct and expected. Never "
"force unrelated content about someone else into a target's profile just because a target happens to be "
"listed and you can't create a new one — but do not use this as an excuse to skip a passage that genuinely "
"is about a target under an alias; skipping a real match is just as wrong as gluing a fact onto the wrong "
"character.\n\n"
) if target_mode and known else ""
# A user-editable prompt (client-side "Prompt" panel, mirroring the
# audiobook casting one) replaces the extraction-instructions/schema body
# below wholesale — the language/target-mode prefixes above still apply
# regardless, since they depend on per-request state the user's saved
# prompt text can't know about.
prompt_override = data.get("character_sheets_prompt")
if isinstance(prompt_override, str) and prompt_override.strip():
system = f"{lang_instruction}{target_instruction}{prompt_override.strip()}"
else:
system = (
f"{lang_instruction}"
f"{target_instruction}"
"You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich "
"character sheets passage by passage as a book is read. Extract playable, action-oriented sheets "
"an actor can use to immediately know how to PLAY the character.\n"
"PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any "
"NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or "
"blank out a field you cannot improve. In this cast-character pass, only refine the already-casted "
"roster and do NOT invent new profiles, places, or institutions. The passage may be a focused "
"evidence window around a character mention, so use nearby paragraphs as context. Leave a field "
"empty if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from "
"dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'.\n"
"For each character output these fields:\n"
"- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.\n"
"- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'the Executioner, Bloodfang, Marcus the Executioner'). Leave empty when uncertain.\n"
"- first_name, last_name: split the character identity when known. Leave unknown parts empty.\n"
"- title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter').\n"
"- profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest').\n"
"- age_estimate: estimated age or age range, if inferable\n"
"- race_species: species, race, or kind (human, elf, ork, vampire, etc.) if relevant\n"
"- languages: spoken languages / dialects / tongues, comma-separated if multiple\n"
"- nationality_background: homeland, culture, origin, or social background if known\n"
"- social_class: rank or class if the text makes it clear (noble, soldier, slave, merchant, priesthood, etc.)\n"
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
"- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.\n"
"- physical: height, weight, build, hair, eyes, skin, posture, gait, distinguishing features, physical disabilities, fantasy-specific extras, and any other bodily appearance details. Use metric units when size/weight is known.\n"
"- clothing: day-to-day wear, work attire, formal wear, sleepwear, undergarments, accessories, and visible weapons/gear if they define the look\n"
"- alignment: strict moral code + the one line they will never cross\n"
"- moral_alignment_score: integer 0–100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous\n"
"- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'\n"
"- arc_note: one sentence explaining the arc or moral position visible so far\n"
"- attribute_high / attribute_low: highest and lowest natural attribute (Charisma, Intelligence, Wisdom, Agility…)\n"
"- skills: what they are demonstrably good at in the story\n"
"- capabilities: combat, magic, social, technical, or other demonstrated abilities\n"
"- backstory: origin, formative background and history revealed in the text\n"
"- relationships: key allies, family, rivals and enemies — name them and how they relate\n"
"- motivation: the inner drive — WHY they pursue what they pursue (distinct from the win condition)\n"
"- fears: their deepest fears, phobias or dread\n"
"- mannerisms: habitual gestures, tics, body language, habits and quirks\n"
"- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.\n"
"- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\n"
"- voice_design_prompt: concise English voice-design prompt, maximum 480 characters. Use only objective, audible traits; no lore-only labels or repeated synonyms. Include gender as a hard audible constraint, age impression, pitch, timbre, pace, accent/register, emotion and audiobook use. Translate species/role into audible cues (orc: chest-led/gravel-edged; vampire: cool/velvety/controlled; elf: airy/precise/adult; demon: dark/subtly otherworldly; warrior: disciplined command cadence; blacksmith: work-worn/practical; mage: measured/precise). Give one distinctive vocal fingerprint. "
+ ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though the prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ")
+ "Do NOT mention plot spoilers.\n"
"- inventory: 1-3 defining items/props/clothing (array of short strings)\n"
"- secret: dark secret or fatal flaw\n"
"- conflict_style: fight, flight, or manipulate — how they act when cornered\n"
"- win_condition: the specific event that would make them feel they have won\n"
"- reputation: how other characters or society see them\n"
"- religious_beliefs: faith, religion, worship, or lack of belief if the text shows it\n"
"- notes: brief catch-all notes for useful details that do not fit elsewhere\n"
"- tier: 'main' or 'supporting'\n"
"- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, "
"a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). "
"Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.\n"
"IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). "
"Examples: 'Marcus', 'the Executioner', and 'Bloodfang' may all refer to ONE profile if context shows they are the same person. "
"Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title and keep one canonical name.\n"
"Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. "
"Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.\n"
"Respond with STRICT JSON only:\n"
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"",'
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"",'
'"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"",'
'"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think'
)
lang_reminder = (
f"⚠️ WICHTIG: Alle beschreibenden Feldwerte MÜSSEN auf {language} geschrieben werden. "
f"Kein einziges beschreibendes Feld darf auf Englisch sein. Nur JSON-Schlüssel bleiben Englisch.\n\n"
) if language else ""
user = (
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
+ ("Sheets so far (fill gaps / refine; keep solid facts):\n" + existing + "\n\n" if existing else "")
+ lang_reminder
+ "Passage:\n" + text
)
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.4,
"max_tokens": 4096,
}
if model:
payload["model"] = model
return {
"payload": payload, "llm_url": llm_url, "model": model,
"api_key": _settings.get("llm_api_key") or "sk-dummy-key",
"timeout_seconds": timeout_seconds,
}
def _charsheets_parse(raw: str) -> dict:
"""Turn the LLM's raw answer into normalised {sheets, characters}."""
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
sheets = []
for cand in (content, _extract_json_block(content)):
if not cand:
continue
try:
parsed = json.loads(cand)
if isinstance(parsed, dict) and isinstance(parsed.get("sheets"), list):
sheets = parsed["sheets"]
break
except Exception:
continue
clean, names = [], []
alias_splitter = re.compile(r"[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b", re.I)
for s in sheets:
if not isinstance(s, dict):
continue
name = str(s.get("name") or "").strip()
if not name:
continue
alias_raw = str(s.get("aliases") or "").strip()
alias_parts = [p.strip() for p in alias_splitter.split(alias_raw) if p.strip()]
alias_parts = [p for p in alias_parts if len(p) <= 80 and not re.match(r"^(needs?:|complete$)", p, re.I)]
if len(alias_raw) > 500 or len(alias_parts) > 12:
aliases = ""
else:
aliases = ", ".join(alias_parts[:6])
inv = s.get("inventory")
if isinstance(inv, str):
inv = [x.strip() for x in inv.split(",") if x.strip()]
elif not isinstance(inv, list):
inv = []
src_raw = s.get("sources") if isinstance(s.get("sources"), list) else []
src = []
for item in src_raw:
if not isinstance(item, dict):
continue
quote = str(item.get("quote") or "").strip()
if not quote:
continue
try:
page = int(item.get("page")) if item.get("page") is not None else None
except (TypeError, ValueError):
page = None
src.append({
"page": page,
"quote": quote[:240],
"line_hint": str(item.get("line_hint") or "").strip()[:60],
})
# Clamp moral alignment score
try:
mas = int(s.get("moral_alignment_score") or 50)
mas = max(0, min(100, mas))
except (TypeError, ValueError):
mas = 50
arc = str(s.get("arc_direction") or "neutral").strip()
if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"):
arc = "neutral"
# The model answers in whatever language the source text is in (this
# app is used heavily with German books), so a strict English-only
# whitelist silently discarded valid answers like "weiblich"/"männlich"
# instead of normalizing them — every non-German-speaking character's
# gender was quietly wiped to blank.
gender_raw = str(s.get("gender") or "").strip().lower()
_GENDER_NORMALIZE = {
"male": "male", "m": "male", "man": "male", "mann": "male", "männlich": "male",
"female": "female", "f": "female", "woman": "female", "frau": "female", "weiblich": "female",
"nonbinary": "nonbinary", "non-binary": "nonbinary", "n": "nonbinary",
"nichtbinär": "nonbinary", "nicht-binär": "nonbinary", "divers": "nonbinary",
}
gender = _GENDER_NORMALIZE.get(gender_raw, "")
s.update({
"name": name, "aliases": aliases, "inventory": inv[:3],
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
"sources": src[:12],
"moral_alignment_score": mas,
"arc_direction": arc,
"gender": gender,
})
clean.append(s)
names.append(name)
return {"sheets": clean, "characters": names}
@router.post("/api/character-sheets")
async def character_sheets(request: Request):
"""Extract actor-facing RPG-style character sheets from a passage.
Body: {text, known_characters:[...], language, llm_url, model}
The text may contain "[p.N]" page markers so the model can cite sources.
Returns: {sheets:[{name, aliases, first_name, last_name, full_name, title, archetype, physical, alignment,
attribute_high, attribute_low, skills, inventory:[...], secret,
conflict_style, win_condition, tier:"main"|"supporting",
sources:[{page, quote}]}], characters:[names]}
Deduced (not explicit) values are marked with a trailing " *".
"""
data = await request.json()
prep = _charsheets_prepare(data)
try:
if not await asyncio.to_thread(_attribution_llm_lock.acquire, True, prep["timeout_seconds"]):
raise HTTPException(503, "Attribution engine busy")
try:
resp = await asyncio.to_thread(
_post_llm_chat_completion,
prep["llm_url"], prep["payload"],
{"Authorization": f"Bearer {prep['api_key']}"}, prep["timeout_seconds"],
)
finally:
_attribution_llm_lock.release()
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or "").strip()
if not raw:
# A reasoning model can spend the whole token budget thinking and return no answer at all
# (the prompt's /no-think is ignored). Ask once more with thinking switched off.
retry_payload = dict(prep["payload"], chat_template_kwargs={"enable_thinking": False})
headers = {"Authorization": f"Bearer {prep['api_key']}"}
if not await asyncio.to_thread(_attribution_llm_lock.acquire, True, prep["timeout_seconds"]):
raise HTTPException(503, "Attribution engine busy")
try:
resp = await asyncio.to_thread(_post_llm_chat_completion, prep["llm_url"], retry_payload, headers, prep["timeout_seconds"])
if resp.status_code in (400, 422): # a backend that rejects the switch
resp = await asyncio.to_thread(_post_llm_chat_completion, prep["llm_url"], prep["payload"], headers, prep["timeout_seconds"])
finally:
_attribution_llm_lock.release()
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip()
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"LLM character-sheet generation failed: {e}")
return _charsheets_parse(raw)
@router.post("/api/book-profile/generate")
async def generate_book_profile(request: Request):
"""Infer missing book context from the source text and identified cast.
The result is deliberately a small, grounded metadata object rather than
another character-sheet response. Existing user-entered fields win.
"""
data = await request.json()
book = str(data.get("book") or "").strip()
text = str(data.get("text") or "").strip()
roster = [str(x).strip() for x in (data.get("known_characters") or []) if str(x).strip()]
if not book or not text:
raise HTTPException(400, "book and text are required")
from core.database import state_get, state_put
key = "book_profile::" + book.lower()
current = state_get(key, {}) or {}
fields = ("genre", "setting", "era", "language")
missing = [f for f in fields if not str(current.get(f) or "").strip()]
if not missing:
return {"ok": True, "profile": {f: str(current.get(f) or "").strip() for f in fields}, "generated": False}
settings = _load_settings()
llm_url = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model = str(data.get("model") or settings.get("llm_model") or "").strip()
language_hint = str(data.get("language") or "").strip()
roster_text = ", ".join(roster[:120]) or "(no cast roster available)"
prompt = f"""You are preparing production metadata for an audiobook and its character profiles.
Infer ONLY the missing book-context fields from the source excerpt and the recognized cast.
Do not invent a setting merely because a character name sounds familiar. Prefer evidence in the text.
This is the German fantasy series Das Jahr des Greifen, but still use the text as the authority.
Missing fields: {', '.join(missing)}
Recognized cast: {roster_text}
Language hint: {language_hint or '(detect from the source)'}
Return ONLY one JSON object with exactly these keys:
{{"genre":"...","setting":"...","era":"...","language":"..."}}
Use concise values (at most a short phrase per field). Write metadata in the source language,
except that language should be a plain language name such as German. Leave a field empty if the
text does not support a reliable answer. Do not describe individual characters and do not include
markdown or explanation.
SOURCE TEXT:
{text[:120000]}"""
payload = {"messages": [
{"role": "system", "content": "Return valid JSON only. Ground every field in the supplied book text."},
{"role": "user", "content": prompt},
], "temperature": 0.1, "max_tokens": 300}
if model:
payload["model"] = model
timeout = _request_timeout_seconds(data.get("timeout_seconds"), 300.0)
try:
if not await asyncio.to_thread(_attribution_llm_lock.acquire, True, timeout):
raise HTTPException(503, "Attribution engine busy")
try:
resp = await asyncio.to_thread(
_post_llm_chat_completion, llm_url, payload,
{"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout,
)
finally:
_attribution_llm_lock.release()
resp.raise_for_status()
msg = resp.json()["choices"][0]["message"]
raw = (msg.get("content") or _reasoning_text(msg) or "").strip()
parsed = json.loads(_extract_json_block(raw))
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"Book context generation failed: {e}")
generated = {f: str(parsed.get(f) or "").strip()[:200 if f != "language" else 60] for f in fields}
merged = {f: str(current.get(f) or "").strip() for f in fields}
for f in missing:
if generated[f]:
merged[f] = generated[f]
state_put(key, merged)
return {"ok": True, "profile": merged, "generated": True}
@router.post("/api/character-sheets/stream")
async def character_sheets_stream(request: Request):
"""Same job as /api/character-sheets, but streams the LLM's raw output
(its JSON answer being written field by field) as SSE `{"t": "..."}`
events, ending with `{"done": true, "result": {...}}` — lets the UI show
the sheet actually filling out passage by passage instead of just a
progress bar. On upstream failure it emits `{"error": "..."}` and the
client falls back to the blocking endpoint."""
data = await request.json()
prep = _charsheets_prepare(data)
payload = dict(prep["payload"])
payload["stream"] = True
headers = {"Authorization": f"Bearer {prep['api_key']}"}
def gen():
if not _attribution_llm_lock.acquire(timeout=prep["timeout_seconds"]):
yield 'data: {"error": "Attribution engine busy"}\n\n'
return
upstream = None
try:
urls = _llm_chat_completion_urls(prep["llm_url"])
last_err = None
for u in urls:
try:
upstream = requests.post(
u, json=payload, headers=headers,
timeout=(15, prep["timeout_seconds"]), stream=True,
)
if upstream.status_code in (404, 405) and u != urls[-1]:
upstream.close()
upstream = None
continue
break
except Exception as exc:
last_err = exc
upstream = None
if upstream is None:
print(f"[character-sheets/stream] LLM connect failed: {last_err}")
yield f'data: {json.dumps({"error": str(last_err or "LLM connect failed")})}\n\n'
return
if upstream.status_code >= 400:
print(
f"[character-sheets/stream] LLM failed ({upstream.status_code}) for model "
f"{payload.get('model') or '(default)'}: {_response_error_text(upstream)[:300]}"
)
yield f'data: {json.dumps({"error": f"HTTP {upstream.status_code}: {_response_error_text(upstream)[:300]}"})}\n\n'
return
raw_parts = []
upstream.encoding = "utf-8"
# Two layers against a stuck stream: the wall-clock check catches
# a model that keeps trickling chunks past its budget, and
# _watchdog_close catches the connection going fully silent (which
# the in-loop check can't see, since a blocked socket read never
# reaches it — see _watchdog_close's docstring for why).
deadline = time.monotonic() + prep["timeout_seconds"]
with _watchdog_close(upstream, prep["timeout_seconds"]):
for line in upstream.iter_lines(decode_unicode=True):
if time.monotonic() > deadline:
print(f"[character-sheets/stream] wall-clock deadline hit after {prep['timeout_seconds']}s, aborting stream")
yield 'data: {"error": "LLM stream exceeded timeout"}\n\n'
return
if not line or not line.startswith("data:"):
continue
chunk = line[5:].strip()
if chunk == "[DONE]":
break
try:
delta = json.loads(chunk)["choices"][0]["delta"]
except Exception:
continue
t = _reasoning_text(delta) or delta.get("content") or ""
if delta.get("content"):
raw_parts.append(delta["content"])
if t:
yield f'data: {json.dumps({"t": t})}\n\n'
result = _charsheets_parse("".join(raw_parts))
yield f'data: {json.dumps({"done": True, "result": result})}\n\n'
except GeneratorExit:
raise
except Exception as e:
yield f'data: {json.dumps({"error": str(e)})}\n\n'
finally:
try:
if upstream is not None:
upstream.close()
except Exception:
pass
_attribution_llm_lock.release()
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@router.post("/api/character-deep-analysis")
async def character_deep_analysis(request: Request):
"""Run a deep 5-area psychological analysis of a single character.
Body: {name, role, goal, summary, text_excerpt, language, llm_url, model}
Returns: {analysis: {core_flaw, agency, dialogue_voice, narrative_arc, paradox}}
"""
data = await request.json()
name: str = (data.get("name") or "").strip()
role: str = (data.get("role") or "unknown").strip()
goal: str = (data.get("goal") or "").strip()
summary: str = (data.get("summary") or "").strip()
excerpt: str = (data.get("text_excerpt") or "").strip()
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not name:
raise HTTPException(400, "Character name required")
lang_note = f" Write the analysis in {language}." if language else ""
system = (
f"You are an expert developmental editor, literary coach, and psychologist specializing in "
f"profound character studies.{lang_note} Provide a deep, multi-layered psychological and "
f"narrative analysis of the character in exactly this JSON structure:\n"
'{"core_flaw":"<analysis of primary flaw, its psychological origin, and conflict with their desire>",'
'"agency":"<are they active or reactive? where does their momentum stall? what harder choices could force growth?>",'
'"dialogue_voice":"<speech pattern analysis: how does their dialogue reflect background, anxieties, hidden motives? how do they sound distinct?>",'
'"narrative_arc":"<how does their worldview shift? what is the lie they believe vs the truth they must accept?>",'
'"paradox":"<3 specific contradictions in behaviour/personality that make them feel authentic and unpredictable>"}\n'
"Be ruthlessly comprehensive and honest. Use concrete examples from the text. "
"Map where character development might break down or feel cliché. "
"Respond with STRICT JSON only — no markdown, no explanation./no-think"
)
user = (
f'CHARACTER: "{name}"\n'
f"Role in story: {role}\n"
f"Core goal: {goal or 'not specified'}\n"
f"Background/key actions: {summary or 'not specified'}\n\n"
+ (f"Text excerpt for analysis:\n{excerpt[:4000]}\n\n" if excerpt else "")
+ "Provide the deep psychological analysis now."
)
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.6,
"max_tokens": 2048,
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip()
except Exception as e:
raise HTTPException(502, f"LLM deep analysis failed: {e}")
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
analysis = {}
for cand in (content, _extract_json_block(content)):
if not cand:
continue
try:
parsed = json.loads(cand)
if isinstance(parsed, dict) and "core_flaw" in parsed:
analysis = parsed
break
except Exception:
continue
if not analysis:
analysis = {"core_flaw": raw, "agency": "", "dialogue_voice": "", "narrative_arc": "", "paradox": ""}
return {"name": name, "analysis": analysis}
def _silly_tavern_prompt_instruction(sheet: dict) -> str:
"""Full SillyTavern character-card template (card + scenario + first
message), adapted from a user-supplied prompt originally written for
building a card from scratch via internet research (fandom/wikipedia).
Here the character's profile is already fully known from the book, so
the internet-research instructions are dropped and replaced with
'use ONLY the profile provided' (already stated in the shared preamble
this gets appended to) — everything else (exact field structure, the
generic-but-deliberately-open Scenario block, the First Message rules)
is kept close to the original since it's a well-tested format."""
gender = str((sheet or {}).get("gender") or "").strip().lower()
is_female = gender.startswith("f")
is_male = gender.startswith("m")
if is_female:
appearance = (
'hair: [COLOR, PICK FROM:straight/wavy/curly, PICK FROM:long (mid-back length)/long (waist-length)/'
'long (arms-length)/short (chin-length)], eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, '
'body: [PICK FROM:slim/curvy, PICK FROM:perfect figure/sensual/abs, PICK FROM:light skin/tanned skin/'
'brown skin/green skin/blue skin/red skin], breasts: [SIZE, CUP, PICK FROM:big areolas/medium-sized '
'areolas/small areolas, PICK FROM:cherry-tan nipples/cherry-pink nipples/honey-tan nipples/'
'golden-brown nipples/dark-brown nipples], armpit hair: PICK FROM:shaved/natural, '
'pubic hair: PICK FROM:shaved/natural, fingernails: PICK FROM:natural/painted (color), '
'toenails: PICK FROM:natural/painted (color)'
)
outfits = (
'{{"Main Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), '
'DESCRIBE SHOES (COLOR), lingerie: [lace bra (COLOR), lace thong (COLOR)]}\n'
'{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), '
'DESCRIBE SHOES (COLOR), lingerie: [lace bra (color), lace thong (color)]}\n'
'{{"Sleeping Outfit"}}:{nightgown (COLOR), thong (COLOR), soft slippers (white)}\n'
'{{"Running Outfit"}}:{sports bra (COLOR), leggings (COLOR), sports shoes (white), lingerie: thong (COLOR)}\n'
'{{"Exercise Outfit"}}:{sports bra (COLOR), leggings (COLOR), bare feet, lingerie: lace thong (COLOR)}\n'
'{{"Swimsuit"}}:{PICK FROM: bikini/one-piece (COLOR), DESCRIBE SHOES (COLOR)}'
)
elif is_male:
appearance = (
'hair: [COLOR, PICK FROM:straight/wavy/curly, PICK FROM:long (mid-back length)/long (waist-length)/'
'long (arms-length)/short (chin-length)], facial hair: PICK FROM:beard/goatie/beard & moustache/'
'moustache/clean-shaven, eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, '
'body: [PICK FROM:slim/muscular/bulky/fat, PICK FROM:light skin/tanned skin/brown skin/green skin/'
'blue skin/red skin], penis: [SIZE, LENGTH cm, PICK FROM:big balls/medium-sized balls/small balls, '
'PICK FROM:circumcised/uncircumcised], armpit hair: PICK FROM:shaved/natural, '
'pubic hair: PICK FROM:shaved/natural'
)
outfits = (
'{{"Main Outfit"}}:{DESCRIBE TOP (color), DESCRIBE BOTTOM (color), DESCRIBE SHOES (COLOR), '
'lingerie: DESCRIBE LINGERIE (COLOR)}\n'
'{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE LEGS (COLOR), '
'DESCRIBE SHOES (COLOR), lingerie: DESCRIBE LINGERIE (COLOR)}\n'
'{{"Sleeping Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, soft slippers (white)}\n'
'{{"Running Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, sports shoes (white), lingerie: DESCRIBE LINGERIE (COLOR)}\n'
'{{"Exercise Outfit"}}:{DESCRIBE TOP, DESCRIBE BOTTOM, bare feet, lingerie: DESCRIBE LINGERIE (COLOR)}\n'
'{{"Swimsuit"}}:{DESCRIBE BOTTOM, DESCRIBE SHOES (COLOR)}'
)
else:
# Gender unknown/non-binary/narrator role — keep the same card
# shape but skip the anatomy-specific appearance fields entirely
# rather than guessing a binary that doesn't fit.
appearance = (
'hair: [COLOR, STYLE, LENGTH], eyes: COLOR, height: HEIGHT cm, weight: WEIGHT kg, '
'body: [BUILD, SKIN TONE], distinguishing features: DESCRIBE'
)
outfits = (
'{{"Main Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE SHOES (COLOR)}\n'
'{{"Formal Outfit"}}:{DESCRIBE TOP (COLOR), DESCRIBE BOTTOM (COLOR), DESCRIBE SHOES (COLOR)}\n'
'{{"Sleeping Outfit"}}:{DESCRIBE SLEEPWEAR, soft slippers (white)}'
)
return (
"Produce exactly this field:\n"
"- silly_tavern_prompt: a complete SillyTavern character card for this character, in the EXACT format "
"below — a card body, then a Scenario block, then a First Message. Fill every field from the character "
"profile above; only invent a value when the profile truly has nothing for it, and mark anything invented "
"with a trailing '*'. Do not add bullet points, extra spaces, or commentary — follow the formatting "
"exactly. Do not replace '{{char}}' with the character's actual name — keep it literal. Keep every '{', "
"'}', '[', ']', '(', ')' character exactly as shown.\n\n"
"{{char}}:\n"
"{\n"
'{{"Personal Information"}}:{name: NAME, surname: SURNAME, race: PICK FROM PROFILE OR INFER, '
"nationality: NATIONALITY, gender: GENDER, age: AGE, profession: PROFESSION, "
"residence: [PLACE, TYPE OF DWELLING], marital status: MARITAL STATUS}\n\n"
f'{{{{"Appearance"}}}}:{{{appearance}}}\n\n'
'{{"Personality"}}:{A DETAILED, SPECIFIC DESCRIPTION OF THIS CHARACTER\'S OWN PERSONALITY, SPEECH PATTERN '
"AND QUIRKS FROM THE PROFILE ABOVE — NOT A GENERIC PERSONALITY TYPE. BE SPECIFIC TO THIS CHARACTER.}\n\n"
'{{"Likes"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
'{{"Dislikes"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
'{{"Goals"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
'{{"Skills"}}:{LIST FROM PROFILE, INFER IF NEEDED}\n\n'
'{{"Weapons"}}:{LIST ONLY IF THIS CHARACTER PLAUSIBLY CARRIES ONE PER THE PROFILE — OMIT THIS FIELD '
"ENTIRELY OTHERWISE}\n\n"
f"{outfits}\n"
"}\n\n"
"Then, in the SAME string, add a scenario block as clear instructions/definitions for the LLM, not "
"narration — {{char}}'s relationship with {{user}}, everyday routine, current mood, current plans. Keep "
"it open-ended (many different stories could start from it) rather than building one specific scene. "
"Use this exact structure:\n\n"
'{{"Scenario"}}:{"{{char}} is living everyday life","{{char}} and {{user}} keep crossing each other\'s '
'paths as {{char}} and {{user}} relationship develops","everyday routine":["mornings":"{{char}} GENERATE",'
'"days":"{{char}} GENERATE","evenings":"{{char}} GENERATE"],"current mood":"{{char}} GENERATE"]}\n\n'
"Then add a section literally titled 'First Message:' on its own line, followed by the message itself: "
"maximum 3 paragraphs, balancing narration with {{char}} dialogue, true to the profile's personality and "
"the scenario above. Never decide what {{user}} does or says. Avoid describing eyes. Use direct speech "
"with no markdown for dialogue, and *asterisks* for narration.\n\n"
"The finished silly_tavern_prompt string MUST contain all three parts, in this order: the {{char}} card "
"block, the {{\"Scenario\"}} block, and the 'First Message:' section — never stop after the card or the "
"Scenario alone.\n\n"
'Respond with STRICT JSON only: {"silly_tavern_prompt":""}/no-think'
)
def _visual_prompt_value(value) -> str:
"""Flatten character-sheet values without leaking Python reprs into prompts."""
if isinstance(value, list):
return ", ".join(_visual_prompt_value(item) for item in value if _visual_prompt_value(item))
if isinstance(value, dict):
return ", ".join(
f"{key}: {_visual_prompt_value(item)}"
for key, item in value.items() if _visual_prompt_value(item)
)
return str(value or "").strip()
def _primary_visual_role(sheet: dict) -> str:
"""Choose the role most directly supported by the established costume.
Extracted profiles often accumulate plot roles (guard, commander, parent)
alongside the day-to-day occupation. Sending every role to an image model
caused it to add unrelated armor and weapons. Prefer the shortest role
explicitly echoed by the clothing description, otherwise the first role.
"""
profession = _visual_prompt_value(sheet.get("profession"))
roles = [part.strip() for part in re.split(r"[,;/\n]+", profession) if part.strip()]
if not roles:
return ""
explicit = _visual_prompt_value(sheet.get("visual_role")).strip()
if explicit:
return explicit
clothing = _established_visual_value(sheet, "visual_costume", "clothing", 320).casefold()
supported = [role for role in roles if len(role) >= 4 and role.casefold() in clothing]
return min(supported, key=len) if supported else roles[0]
_TRANSIENT_INJURY_RE = re.compile(
r"\b(blut\w*|wunden?|verwundet\w*|verletz\w*|geschwollen|entstellt|blaue\s+flecken|pr[eä]llung\w*|"
r"schnittwunde\w*|messerschnitt\w*|riss(?:e|en)?|durchbohrt\w*|zerschunden|erschöpfung|"
r"verbrannt\w*|verbrennung\w*|gebrochen\w*|humpel\w*|hinkt\w*|verband\w*|verbunden\w*|"
r"schramm\w*|abschürfung\w*|striemen|zerfetzt\w*|zerrissen\w*|angeschlagen|"
r"blood\w*|wound\w*|bruis\w*|injur\w*|bleeding|swollen|disfigured|gash\w*|laceration\w*|"
r"bandag\w*|limp\w*|torn|scrape\w*|burn(?:s|ed|t)|broken|"
r"ru[ßs]\w*|verschmiert\w*|schmutz\w*|dreck\w*|verdreckt\w*|staubig\w*|soot\w*|smear\w*|smudge\w*|grim(?:e|y)|dirt\w*|muddy|"
r"exhaust(?:ed|ion)|"
# A passing emotion or reaction from one scene is not how someone looks: "rote Stirn bei Wut,
# Tränen in den Augen vor Wut" rendered as red marks on the face, every seed (Darrag).
r"(?:bei|vor)\s+(?:wut|zorn|angst|schreck|aufregung|anstrengung|schmerz\w*|scham)|tr[äa]nen\w*|weint\w*|"
r"zitter\w*|zuck\w*|err[öo]t\w*|schwitz\w*|keuch\w*|when angry|in tears|trembl\w*|twitch\w*|blush\w*|sweat\w*|"
r"zunge\w*|leck\w*|tongue\w*|lick\w*|"
# Scars are left out of image prompts altogether. Measured on Eolan (4 seeds each): a scar his
# own sheet calls almost faded rendered as a fresh red gash, and the sentence "every scar is an
# old, fully healed, pale line" put red slashes on his face, hands and robe. This model cannot
# draw a subtle scar; any mention becomes a wound.
r"narben?|scars?)\b",
re.IGNORECASE,
)
_INJURY_CLAUSE_RE = re.compile(
r"(?:[,;]\s*|\s+(?:und|and|aber|but)\s+)?"
r"(?:(?!\s(?:und|and|aber|but)\s)[^,;.!?])*?"
+ _TRANSIENT_INJURY_RE.pattern +
r"(?:(?!\s(?:und|and|aber|but)\s)[^,;.!?])*",
re.IGNORECASE,
)
def _strip_injury_clauses(text: str) -> str:
"""Drop only the clauses about an injury or a passing state; the rest of the text stays.
When the dropped clause starts a sentence, the whole sentence goes: what follows it ("und die
rechte Augenbraue") only makes sense with it. In the middle of a sentence ("Grüne Augen,
blutverschmierte Stirn und schwarzes Haar") only that clause goes.
"""
kept = []
for sentence in re.split(r"(?<=[.!?])\s+", text or ""):
match = _INJURY_CLAUSE_RE.search(sentence)
if not match:
kept.append(sentence)
continue
if not sentence[:match.start()].strip(" ,;:"):
continue
cleaned = _INJURY_CLAUSE_RE.sub("", sentence)
cleaned = re.sub(r"^\s*[,;]\s*", "", cleaned)
cleaned = re.sub(r"\s+([,;.])", r"\1", cleaned)
cleaned = re.sub(r"([:])\s*[,;]\s*", r"\1 ", cleaned)
cleaned = re.sub(r"\.{2,}", ".", cleaned).strip()
if cleaned.strip(" .,;:"):
kept.append(cleaned)
return re.sub(r"\s{2,}", " ", " ".join(kept)).strip()
# Extraction copies the book's own words, and some of them read as modern or
# Wild-West/wartime wardrobe to an image model ("Uniform", "Weste", "Hut",
# "Sporen"). Rewrite them to period garments before they reach a prompt;
# the stored sheet is not changed.
_PERIOD_WORDING = (
(r"offiziers[- ]?uniform\w*|officer'?s? uniform", "long belted tunic under a heraldic surcoat"),
(r"(?:soldaten|milit[äa]r|wach|garde)[- ]?uniform\w*|soldier'?s? uniform", "mail shirt under a wool surcoat in house colours"),
(r"uniformjacke\w*|uniform jacket", "quilted gambeson"),
(r"uniform(?:en|e)?\b|uniformiert\w*", "mail shirt under a wool surcoat in house colours"),
(r"soldatenkleidung|soldatische\w*", "period men-at-arms' dress"),
(r"soldatenstiefel", "stitched leather boots"),
(r"milit[äa]rische\w*|milit[äa]r\w*", "martial"),
(r"jacken?\b|jacket\w*", "wool tunic"),
(r"westen?\b|waistcoat\w*|vest\b", "sleeveless leather jerkin"),
(r"schals?\b|scarf|scarves", "wool neck-wrap"),
(r"dreispitz\w*|dreimaster|tricorn\w*|dreieckshut|three-cornered hat", "plain iron helm"),
(r"h[üu]te?\b(?!\w)|\bhats?\b", "soft wool hood"),
(r"sporen|spurs?", "stitched leather boots"),
(r"hosen?\b|trousers|pants", "wool leggings"),
(r"orden\b|medals?", "guild badge"),
(r"kragen\b|collar\w*", "standing collar"),
(r"epauletten|schulterklappen|epaulet\w*", "leather shoulder guard"),
)
_PERIOD_WORDING_RES = tuple((re.compile(r"\b(?:" + pat + r")", re.IGNORECASE), repl) for pat, repl in _PERIOD_WORDING)
def _period_wording(text: str) -> str:
for pattern, repl in _PERIOD_WORDING_RES:
text = pattern.sub(repl, text)
return text
def _established_visual_value(sheet: dict, override_key: str, source_key: str,
limit: int = 320, strip_injury: bool = False) -> str:
"""Return visual facts that are established rather than speculative prose.
Character extraction is intentionally generous and sometimes records its
reasoning ("probably wears...", "not described, but suitable for...").
Those guesses are useful in the profile, but they must not become costume
facts. A user-supplied visual override is authoritative; otherwise only
non-speculative sentences survive.
strip_injury additionally drops sentences describing an acute wound,
bleeding, bruising, or exhaustion — extraction often pulls a character's
"physical" description from whatever passage happened to describe them
most vividly, frequently a violent scene late in the book, baking a
one-time injury into what should be an evergreen baseline appearance.
Confirmed live: a profile portrait meant to be a neutral productio
reference instead showed a bloody facial wound and a chest blood stain,
spoiling a plot beat that hadn't happened yet when read at that point in
the book. A permanent mark (a scar, a missing finger) uses different
words ("Narbe"/scar implies healed) and is unaffected by this filter.
"""
override = _visual_prompt_value(sheet.get(override_key)).strip()
if override:
return _period_wording(override)[:limit]
raw = _visual_prompt_value(sheet.get(source_key)).strip()
if not raw:
return ""
uncertain = re.compile(
r"\b(probably|possibly|perhaps|likely|assumed|inferred|uncertain|not described|"
r"wahrscheinlich|vermutlich|vielleicht|möglicherweise|angenommen|unsicher|"
r"nicht (?:näher |genau )?beschrieben|nicht detailliert beschrieben|"
r"nicht spezifiziert|nicht festgelegt|nicht genannt|je nach kontext|sofern|falls|"
r"unspecified|not specified|depending on|if applicable|"
r"wir müssen vorsichtig|es ist unklar|laut (?:dem )?text|im kontext|"
r"wird bei einem anderen|could be|might be|unklar|wird erwähnt|unclear|is mentioned)\b",
re.IGNORECASE,
)
sentences = re.split(r"(?<=[.!?])\s+", raw)
grounded = [
sentence.strip() for sentence in sentences
if sentence.strip() and not uncertain.search(sentence)
]
value = " ".join(grounded).strip()
if strip_injury:
value = _strip_injury_clauses(value)
value = _period_wording(value)
if len(value) > limit:
value = value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"
return value
def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) -> str:
"""Build the replaceable Subject block used by every character image."""
sheet = _character_form_sheet(sheet)
def clipped(key: str, limit: int) -> str:
value = _visual_prompt_value(sheet.get(key))
if len(value) <= limit:
return value
return value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"
species = _established_visual_value(sheet, "visual_species", "race_species", 100)
appearance = _established_visual_value(sheet, "visual_appearance", "physical", 320, strip_injury=True)
costume = _established_visual_value(sheet, "visual_costume", "clothing", 320, strip_injury=True)
rows = [
("name", name),
("gender", clipped("gender", 40)),
("age", clipped("age_estimate", 100)),
("species", species),
("title", clipped("title", 100)),
("role", clipped("profession", 180) if include_items else _primary_visual_role(sheet)),
("archetype", clipped("archetype", 100)),
("appearance", appearance),
("established worn costume", costume),
]
if include_items:
props = _character_prop_item_list(sheet)
if props:
rows.append(("verified portable items", ", ".join(props)))
return "; ".join(f"{label}: {value}" for label, value in rows if value)
def _book_visual_context(book: str, book_profile: dict) -> str:
values = [
_visual_prompt_value(book_profile.get("genre")),
_visual_prompt_value(book_profile.get("setting")),
_visual_prompt_value(book_profile.get("era")),
_visual_prompt_value(book_profile.get("visual_context")),
]
context = "; ".join(value for value in values if value)
# Stored book profiles still say "early Renaissance 1420-1500", which pulls musketeer/wartime dress.
context = re.sub(r"(?:early[- ]?)?renaissance|fr[üu]h(?:-)?renaissance", "high-fantasy medieval", context, flags=re.IGNORECASE)
context = re.sub(r"\b1420\s*[–-]\s*1500\b", "roughly 900-1300 AD", context)
return context or (
f"the established world and period of {book}" if book else
"the story's established world and historical period"
)
_ELF_SPECIES_RE = re.compile(r"\b(elf|elfe|elben|elbisch|half-elf|halbelfe)\b", re.IGNORECASE)
_ORC_SPECIES_RE = re.compile(r"\b(ork|orc|orken|orkisch)\b", re.IGNORECASE)
def _character_ear_shape(sheet: dict) -> str:
"""Positive-only ear-shape phrase, gated on the character's own species.
Nothing previously told the model an ear's SHAPE — every bareheaded
render just said to keep "ears" visible, with no species qualifier, and
the model's fantasy-genre default is pointed elf ears regardless of
species. Confirmed live: characters explicitly marked "Mensch" (human)
in their own sheet still rendered with pointed ears. Elf/half-elf and
orc characters keep the pointed phrasing; everyone else — including
dwarves, vampires-in-human-form, and anyone with no species set at all —
gets ordinary rounded human ears, since a missing/uncertain species is
far more likely human than elf in this cast.
"""
species_text = _established_visual_value(sheet, "visual_species", "race_species", 100)
if _ELF_SPECIES_RE.search(species_text) or _ORC_SPECIES_RE.search(species_text):
return "pointed elf-shaped ears"
return "ordinary rounded human ears"
_LEATHER_ONLY_ARMOR_RE = re.compile(r"\b(leder(?:r[üu]stung|panzer)?|leather)\b", re.IGNORECASE)
# Broad on purpose: this only has to rule OUT the leather-only claim, so a
# false positive here (calling something "metal" that technically isn't)
# just falls back to the old, already-shipped blanket wording — safe. A
# false NEGATIVE (missing a real metal-armor mention) is what actually
# breaks a character, as it did for Lysandra: her established armor is a
# bronze muscle cuirass with polished bronze greaves, but the original list
# only covered mail/plate/steel by name, and a stray "she used to wear plain
# leather clothing" backstory clause made her match leather-only instead —
# confirmed live while picking validation characters, before it ever reached
# a real image generation.
_METAL_ARMOR_RE = re.compile(
r"\b(kettenhemd|kettenr[üu]stung|plattenr[üu]stung|schuppenpanzer|harnisch|"
r"stahlr[üu]stung|eisenr[üu]stung|bronzer[üu]stung|kupferr[üu]stung|"
r"metallr[üu]stung|erzr[üu]stung|"
r"(?:stahl|eisen|bronze|kupfer|erz|silber|gold|metall)[- ]?panzer|"
r"muskelpanzer|brustpanzer|beinschiene|armschiene|schienenpanzer|"
r"chain\s*mail|chainmail|plate\s*armor|plate-armou?r|steel\s*armor|steel-armou?r|"
r"scale\s*mail|bronze\s*(?:armor|cuirass|breastplate|greaves)|"
r"metal\s*(?:armor|armour|cuirass|breastplate|greaves|plate)|greaves|vambraces|pauldrons?|cuirass)\b",
re.IGNORECASE,
)
# Backstory ("she used to wear leather before joining the guard") is not the
# CURRENT established costume — strip clauses introduced by a past-reference
# word before checking, the same way headwear detection already treats
# inventory as non-authoritative for what's actually worn now.
_PAST_REFERENCE_CLAUSE_RE = re.compile(
r"\b(?:zuvor|früher|einst|vorher|damals|ehemals|formerly|previously|used to wear)\b[^.;]*[.;]?",
re.IGNORECASE,
)
def _character_is_leather_only_armor(sheet: dict) -> bool:
"""True when the character's OWN established costume names leather armor
and never mentions any metal-armor term.
The blanket book-era material sentence ("wool, linen, leather, forged
metal, period armor") used to apply identically to every character
regardless of what they actually wear, so a leather-armor character still
rendered in metal plate/chainmail — the blanket wording mentioned "forged
metal"/"period armor" more prominently than one German "Lederrüstung"
mention — confirmed live on Karyla. Same authority rule as headwear: the
established appearance/costume text wins over generic period vocabulary.
Callers must phrase what they do with this as a POSITIVE statement, not a
"do not add X" sentence — Qwen's positive text encoder still visualizes
a noun mentioned after a negator (see _positive_conditioning_text), so a
negative instruction here would just add another mention of metal armor.
"""
costume = _visual_prompt_value(sheet.get("visual_costume")).strip()
established = " ".join(filter(None, (
_visual_prompt_value(sheet.get("visual_appearance") or sheet.get("physical")),
costume or _visual_prompt_value(sheet.get("clothing")),
)))
established = _PAST_REFERENCE_CLAUSE_RE.sub("", established)
return bool(_LEATHER_ONLY_ARMOR_RE.search(established) and not _METAL_ARMOR_RE.search(established))
_COMBAT_GEAR_RE = re.compile(
# No trailing \b on the German terms: they compound with a suffix and no
# word break ("Armbrustbolzen", "Kriegsaxt", "Bogenschütze") — the same
# miss that let Glombo Brohm's crossbow render as a rifle (see
# _RANGED_WEAPON_RE above).
r"\b(r[üu]stung|panzer|harnisch|kettenhemd|schild|schwert|dolch|messer|speer|lanze|"
r"armbrust|bogen|axt|streitaxt|keule|hellebarde|"
r"armor\b|armour\b|shield\b|sword\b|dagger\b|blade\b|spear\b|lance\b|crossbow\b|bow\b|axe\b|mace\b|halberd\b)",
re.IGNORECASE,
)
def _character_has_established_combat_gear(sheet: dict) -> bool:
"""True only when the character's OWN costume/inventory actually names
armor or a weapon — a job title alone ("Unterführerin", "General",
"Kommandant") does not.
authority_lock (below) used to apply to any character whose title/role
just READ as military rank, which meant a civilian militia deputy whose
established costume is explicitly "practical clothes for housework and
smithing" — no armor at all — still rendered in full plate with a spear,
because the rank word alone triggered the lock. Confirmed live on Misira.
"""
costume = _visual_prompt_value(sheet.get("visual_costume")).strip()
established = " ".join(filter(None, (
costume or _visual_prompt_value(sheet.get("clothing")),
", ".join(_character_prop_item_list(sheet)),
)))
return bool(_COMBAT_GEAR_RE.search(established))
# No trailing \b on armbrust/bogen: German compounds attach a suffix directly
# with no word break ("Armbrustbolzen", "Bogenschütze") — confirmed live on
# Glombo Brohm, whose "Köcher... für Armbrustbolzen" clothing text never
# matched the old \barmbrust\b, so no ranged-weapon shape lock reached the
# render and his crossbow came out rifle-shaped.
_RANGED_WEAPON_RE = re.compile(r"\b(crossbow|longbow|shortbow|bow\b|armbrust|bogen)", re.IGNORECASE)
def _character_ranged_weapon_lock(sheet: dict) -> str:
"""Positive shape lock for an established bow/crossbow.
Without this, an action scene where the character "aims"/"shoots" fell
back on the base model's default for that verb — a modern firearm —
confirmed live on Kolon, whose established "schwere Armbrust" rendered as
a rifle in the story-moment panel even though the isolated prop shot
(which already has this same silhouette lock, see _prop_render_description)
came out correctly. Reuses that same wording so the two panels agree.
"""
costume = _visual_prompt_value(sheet.get("visual_costume")).strip()
established = " ".join(filter(None, (
costume or _visual_prompt_value(sheet.get("clothing")),
", ".join(_character_prop_item_list(sheet)),
)))
match = _RANGED_WEAPON_RE.search(established)
if not match:
return ""
shape = _prop_render_description(match.group(0))
return f"Any ranged weapon shown is {shape}, drawn and aimed by hand exactly like that object. "
_EXPLICIT_AGE_RE = re.compile(
r"\b(\d{1,2})[\s-]*(?:jahre|jährig|j[äa]hrige[rns]?|years?[\s-]*old|y\.?o\.?)\b", re.IGNORECASE,
)
_CHILD_KEYWORD_RE = re.compile(
r"\b(kind|kleinkind|kindlich|s[äa]ugling|kleinkindalter|"
r"child|infant|toddler|little\s+(?:boy|girl|kid))\b", re.IGNORECASE,
)
_TEEN_KEYWORD_RE = re.compile(
r"\b(jugendlich(?:e|er|es)?|teenager|halbw[üu]chsig(?:e|er|es)?|heranwachsend(?:e|er|es)?|"
r"teen(?:ager)?|adolescent)\b", re.IGNORECASE,
)
def _character_age_category(sheet: dict) -> str:
"""Return 'child', 'teen', or 'adult' (the default) from whatever age
signal the sheet actually has.
Every render template previously hard-coded "EXACTLY ONE ADULT PERSON"
for every character with zero exceptions, so a genuinely child or
teenage cast member was always rendered as a full adult — confirmed as a
real gap, not just an edge case, once asked about directly. An explicit
numeric age wins when present; otherwise this falls back to age-bearing
words anywhere in the established profile text.
"""
text = " ".join(filter(None, (
_visual_prompt_value(sheet.get("age")),
_visual_prompt_value(sheet.get("physical")),
_visual_prompt_value(sheet.get("archetype")),
_visual_prompt_value(sheet.get("profession")),
_visual_prompt_value(sheet.get("title")),
_visual_prompt_value(sheet.get("aliases")),
)))
age_match = _EXPLICIT_AGE_RE.search(text)
if age_match:
age = int(age_match.group(1))
if age < 13:
return "child"
if age < 18:
return "teen"
return "adult"
if _CHILD_KEYWORD_RE.search(text):
return "child"
if _TEEN_KEYWORD_RE.search(text):
return "teen"
return "adult"
# (subject clause, "identical <noun>" noun, proportion clause) per age
# category — all phrased positively (no "not an adult"/"no adult" wording),
# since this encoder still visualizes a noun mentioned after a negator (see
# _character_is_leather_only_armor's docstring for the same reasoning).
_AGE_BODY_LOCK = {
"child": (
"EXACTLY ONE CHILD.",
"child",
"Natural child body proportions: roughly five head-heights tall, a proportionally larger head, "
"shorter limbs, and a soft, undeveloped child physique. ",
),
"teen": (
"EXACTLY ONE TEENAGE PERSON.",
"teenage person",
"Natural teenage body proportions: roughly six to six and a half head-heights tall, and a slighter, "
"still-developing build. ",
),
"adult": (
"EXACTLY ONE ADULT PERSON.",
"adult person",
# "seven and a half head-heights tall" alone still rendered stocky,
# dwarf-like proportions in practice — confirmed live. Figure-drawing
# convention gets a tall, slender adult look by also specifying leg
# length as a fraction of total height and a slim waist, not just an
# overall height ratio, so this states that explicitly.
"Natural adult human body proportions: a fully grown adult stands about eight head-heights tall, "
"with a small head relative to the body, long legs making up slightly more than half of total height, "
"long straight limbs, a slender waist, and a correctly proportioned torso and arms. ",
),
}
_DWARF_SPECIES_RE = re.compile(r"zwerg|dwarf", re.IGNORECASE)
def _character_is_dwarf(sheet: dict) -> bool:
species = _visual_prompt_value(sheet.get("race_species")).strip()
return bool(_DWARF_SPECIES_RE.search(species))
# An established dwarf (race_species "Erzzwerg") still rendered with the tall,
# long-legged adult proportions below — confirmed live on Himgi and Kolon,
# both explicitly written as dwarf-built but rendered as ordinary tall men.
# _AGE_BODY_LOCK's "adult" entry exists specifically to stop an unrelated
# short/stubby rendering bug, which fights a genuine dwarf body in the
# opposite direction. Positive-only per the same reasoning as that entry.
_DWARF_BODY_LOCK = (
"EXACTLY ONE ADULT DWARF.",
"adult dwarf",
"Natural adult dwarf body proportions: roughly five and a half head-heights tall, a broad, dense, "
"muscular build, a wide chest, short strong legs, and a thick, full beard. ",
)
def _age_body_lock(sheet: dict) -> tuple[str, str, str]:
if _character_is_dwarf(sheet) and _character_age_category(sheet) == "adult":
return _DWARF_BODY_LOCK
return _AGE_BODY_LOCK[_character_age_category(sheet)]
def _character_has_established_headwear(sheet: dict) -> bool:
# Inventory is deliberately excluded. A helmet/hood listed as a prop or
# recovered object does not mean the character wears it. Treat only the
# established appearance/costume as authority for worn headgear.
# A user/visual costume correction is more authoritative than an older
# extracted clothing paragraph. This matters for characters such as
# Lysandra, whose early armor description must not override the selected
# forest-fighter design.
costume = _visual_prompt_value(sheet.get("visual_costume")).strip()
established = " ".join(filter(None, (
_visual_prompt_value(sheet.get("visual_appearance") or sheet.get("physical")).lower(),
(costume or _visual_prompt_value(sheet.get("clothing"))).lower(),
)))
return bool(re.search(
# Do not treat German "hat" ("has") as the English noun "hat" —
# that exact false positive let Gamba keep his modern felt hat.
r"\b(headwear|hood|cap|helmet|coif|veil|turban|hut|m[üu]tze|kapuze|haube|helm|schleier)\b|"
r"\b(?:wears?|wearing|with)\s+(?:an?\s+)?(?:[a-z-]+\s+){0,2}hat\b",
established,
))
def _period_lock(book: str, book_profile: dict, sheet: dict) -> str:
headwear = (
"Use only the explicitly established period headwear and reproduce it unchanged in every view."
if _character_has_established_headwear(sheet) else
"The scalp, crown, hairline, ears, and a full, thick head of hair reaching at least to the ears or collar "
"are visibly exposed and unobstructed, grown out and trimmed by hand with a knife or shears rather than a "
"modern barbershop cut or a bald/receding head."
)
leather_only = _character_is_leather_only_armor(sheet)
materials = (
"wool, linen, leather, hand stitching, tools, weapons, fastenings, and footwear"
if leather_only else
"wool, linen, leather, forged metal, hand stitching, period armor, tools, weapons, fastenings, and footwear"
)
armor_note = (
"This character's armor and everyday wear are leather and cloth, with small forged-metal buckles and fastenings only. "
if leather_only else ""
)
return (
f"Book lock: {_book_visual_context(book, book_profile)}. "
f"Use museum-authentic {_FANTASY_DRESS} {materials}. "
"Garments use ties, lacing, toggles, hand-sewn buttons, hooks, or simple forged buckles; footwear uses stitched leather soles and period construction. "
"Every visible design element is hand-forged or hand-sewn in the manner of that world, with natural materials, hand-worked construction, flat stitched-leather soles, and age-appropriate wear. "
f"{armor_note}"
f"{headwear}"
)
def _character_prop_item_list(sheet: dict) -> list[str]:
"""Return zero to three verified, visually distinct portable items.
Companions, creatures and worn costume pieces belong neither in a prop
callout nor in the identity pass. Empty slots stay empty: inventing a
generic tool is worse than showing fewer props. Near-synonyms also share
a category, so three sword variants cannot fill all three slots.
"""
explicit = sheet.get("visual_props")
raw = explicit if isinstance(explicit, list) else sheet.get("inventory")
values = raw if isinstance(raw, list) else re.split(r"[,;\n]+", str(raw or ""))
excluded = re.compile(
r"\b(location|room|place|viewpoint|observation point|residence|stairs?|tower|"
r"post|outpost|station|ort|raum|platz|posten|beobachtungspunkt|beobachtungsposten|wendeltreppe|wohnort|turm|"
r"person|people|child|baby|infant|companion|horse|battlehorse|pony|animal|creature|hair|wig|"
r"kind|baby|s[äa]ugling|begleiter|pferd|schlachtro(?:ss|ß)|pony|tier|kreatur|haar|per[üu]cke|"
r"hat|headwear|hood|cap|helmet|veil|cloak|coat|shirt|robe|armor set|armour set|"
r"clothing|riding clothes|forest clothes|hut|m[üu]tze|kapuze|haube|helm|schleier|umhang|mantel|hemd|waldkleidung|reiterkleidung|robe|r[üu]stung|panzer|"
# Vague plural/collective nouns describe a CATEGORY, not one
# renderable object ("household items", "belongings", "gear") — the
# existing philosophy here is that an empty slot beats a wrong
# render, and these have no single concrete shape to lock onto.
r"items?|belongings|gear|equipment|supplies|sachen|habseligkeiten|utensilien)\b|"
# These trail onto a compound prefix with no word boundary in
# between ("Kinderkleidung", "Haushaltsgegenstände") — a leading \b
# silently let those slip past this exclusion entirely, which is
# exactly why "children's clothing" and "household items" both
# rendered as a small person instead of being dropped as a
# non-object category. Suffix-only match (no leading \b) catches
# the compound form too.
r"kleidung\b|gegenst[äa]nde\b|ausr[üu]stung\b",
re.IGNORECASE,
)
uncertain = re.compile(r"\b(implied|inferred|possible|impliziert|vermutlich|wahrscheinlich)\b", re.IGNORECASE)
def category(item: str) -> str:
lower = item.casefold()
groups = (
("edged-weapon", r"\b(sword|blade|sabre|saber|rapier|dagger|knife|zweih[aä]nder)\b|(?:schwert|s[äa]bel|degen|dolch|messer)\b"),
("ranged-weapon", r"\b(bow|crossbow|longbow|shortbow)\b|(?:bogen|armbrust)\b"),
("staff", r"\b(staff|wand|rod|stave)\b|(?:stab|st[äa]be|rute)\b"),
("key", r"\b(key|keys)\b|schl[üu]ssel\b"),
("hammer", r"hammer\b"),
("tongs", r"\btongs\b|zange(?:n)?\b"),
("document", r"\b(scroll|map|book|letter|seal|document|rolle|karte|buch|brief|siegel)\b"),
("vial", r"\b(vial|flask|bottle|phiole|fl[äa]schchen|flasche)\b"),
("light", r"\b(torch|lantern|fackel|laterne)\b"),
)
for label, pattern in groups:
if re.search(pattern, lower):
return label
# Unknown items remain eligible, but duplicate normalized wording is
# still collapsed below.
return re.sub(r"\W+", "-", lower).strip("-") or "item"
clean: list[str] = []
seen_categories: set[str] = set()
for value in values:
item = _visual_prompt_value(value).strip(" .;,")
item_category = category(item)
if (item and not excluded.search(item) and not uncertain.search(item)
and item.casefold() not in {v.casefold() for v in clean}
and item_category not in seen_categories):
clean.append(item)
seen_categories.add(item_category)
if len(clean) == 3:
break
return clean
def _character_prop_items(sheet: dict) -> str:
"""Backward-compatible text form used by prompt previews/tests."""
return ", ".join(_character_prop_item_list(sheet))
def _prop_render_description(item: str) -> str:
"""Add an English silhouette lock to short, grounded prop labels."""
value = _visual_prompt_value(item).strip()
lowered = value.lower()
rules = (
(r"\bcrossbow\b|armbrust\b", "one complete medieval crossbow: a horizontal wooden bow mounted across a wooden stock, with visible string, nut, trigger, and stirrup"),
(r"\b(longbow|shortbow|bow)\b|bogen\b", "one complete traditional wooden archery bow: one long curved wooden stave with one taut bowstring running continuously from tip to tip"),
(r"\b(zweihänder|sword|blade)\b|schwert\b", "one complete forged sword with exactly one blade, one guard, one grip, and one pommel"),
(r"\b(dagger|knife)\b|(?:dolch|messer)\b", "one complete short dagger with exactly one blade, one guard, one grip, and one pommel"),
(r"\bkey\b|schlüssel\b", "one single large hand-forged iron key with one circular bow, one straight shaft, and one toothed bit"),
(r"\b(staff|rod|wand)\b|stab\b", "one complete 180-centimeter ash-wood walking staff: one continuous straight round wooden shaft from its plain ferrule to one modest carved spiral handgrip, shown vertically at full length"),
(r"hammer\b", "one complete blacksmith's forging hammer with one dark forged-iron hammer head fixed to one worn ash-wood handle"),
(r"\b(tongs|pliers)\b|zange(?:n)?\b", "one complete pair of long blacksmith's forging tongs: two forged arms joined at one pivot with gripping jaws"),
(r"\bhorseshoe\b|hufeisen\b", "one single U-shaped hand-forged iron horseshoe with evenly spaced nail holes"),
(r"\b(rolle|schriftrolle|scroll|document|map|karte)\b", "one complete rolled parchment document tied with a plain cord, its surface blank and unlettered"),
(r"\b(phiole|vial|fläschchen|bottle)\b", "one small complete period glass vial with a plain fitted stopper"),
(r"\b(laterne|lantern|lamp)\b", "one complete medieval hand lantern of forged iron and translucent horn panels"),
# Worn/held items are the ones most likely to pull a whole person into
# frame — an amulet or crown alone is a weak object cue, but strongly
# associated with someone wearing it. Naming the object as a
# tabletop still-life object, resting on a surface rather than worn,
# is a positive framing (never "no person"/"no body") that keeps the
# same reasoning as the rest of this file's locks.
(r"\b(amulett|amulet|anh[äa]nger|kette|halskette|pendant|necklace)\b", "one small pendant on its own plain cord or chain, coiled flat on the sweep as a tabletop still-life object"),
(r"\b(feder|federn|quill|feather)\b", "one single bird feather lying flat on the sweep as a tabletop still-life object"),
(r"\b(buch|tome|book)\b", "one closed leather-bound book resting flat and closed on the sweep as a tabletop still-life object"),
(r"\b(decke|blanket|cloak|umhang|mantel)\b", "one folded woven blanket or cloak, laid flat and folded on the sweep as a tabletop still-life object"),
(r"\b(krone|circlet|diadem|crown)\b", "one crown or circlet alone, resting on a plain stand or cushion as a tabletop still-life object"),
(r"\bring\b", "one single metal ring resting on the sweep as a tabletop still-life object"),
(r"\b(weinbecher|becher|goblet|chalice|cup)\b", "one single empty metal or wooden drinking goblet standing upright on the sweep as a tabletop still-life object"),
(r"\b(kr[äa]uter|herb|herbs)\b", "one small bundle of dried herbs tied with cord, resting on the sweep as a tabletop still-life object"),
(r"\b(gew[äa]nder|robe|robes|garment|garments|kleidung|clothing|gown)\b", "one folded garment, laid flat and folded on the sweep as an empty textile still-life object"),
)
for pattern, description in rules:
if re.search(pattern, lowered, re.IGNORECASE):
return f"{value} — {description}"
return f"{value} — one complete portable object matching this exact noun, silhouette, and function, resting on the sweep as a tabletop still-life object"
def _character_scene_evidence(sheet: dict) -> str:
"""Choose one representative, source-grounded action for the lower panel."""
override = _visual_prompt_value(sheet.get("visual_scene")).strip()
if override:
return override[:700]
sources = sheet.get("sources") if isinstance(sheet.get("sources"), list) else []
priorities = {
"action": 12, "mannerisms": 10, "capabilities": 9,
"conflict_style": 8, "communication_style": 7,
"profession": 5, "motivation": 4,
}
action_words = re.compile(
r"\b(said|spoke|shouted|ordered|fought|raised|led|watched|looked|signaled|"
r"sprach|sagte|rief|schrie|befahl|k[äa]mpfte|hob|f[üu]hrte|blickte|beobachtete|winkte|griff|zog|st[üu]rzte|schlug)\b",
re.IGNORECASE,
)
best: tuple[int, str] | None = None
for source in sources:
if not isinstance(source, dict):
continue
quote_text = _visual_prompt_value(source.get("quote")).strip()
if not quote_text:
continue
hint = _visual_prompt_value(source.get("line_hint")).casefold()
score = max((value for key, value in priorities.items() if key in hint), default=0)
if action_words.search(quote_text):
score += 3
else:
score -= 8
if any(word in hint for word in ("physical", "inventory", "identity")):
score -= 3
candidate = (score, quote_text)
if best is None or candidate[0] > best[0]:
best = candidate
if best:
return best[1][:700]
for key in ("mannerisms", "capabilities", "conflict_style", "profession"):
value = _visual_prompt_value(sheet.get(key)).strip()
if value:
return value[:700]
return "The character performs their established everyday role in a natural, in-character moment."
def _build_character_portrait_prompt(name: str, book: str, sheet: dict, book_profile: dict,
visual_style: str = "cinematic") -> str:
# Tools belong in the equipment panel, not in the portrait identity
# anchor. Including inventory here made the image model hang every tool
# from the costume even when the composition explicitly requested empty
# hands.
facts = _character_visual_facts(name, sheet, include_items=False)
if visual_style == "concept":
style = (
"detailed semi-realistic hand-painted fantasy production portrait from the same fixed studio artist as the cast sheets, "
"with consistent facial construction, restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, "
"and believable weathered materials; no photorealism, anime, cartoon, glossy 3D render, or mixed media"
)
else:
style = (
"photorealistic documentary-editorial historical fantasy photography from one fixed production photographer, "
"with honest skin texture, natural hair, weathered period materials, restrained warm-neutral color science, "
"subtle film grain, and no illustration, anime, painterly rendering, beauty retouching, plastic skin, or glossy game-render finish"
)
return (
"Task: Generate one historical-fantasy audiobook cast portrait. "
f"Subject: original fictional character — {facts}. "
f"Context: cast portrait for {book or 'this audiobook'}; {_period_lock(book, book_profile, sheet)} "
f"Style: {style}. "
"Composition: one person, chest-up square at eye level, 70 mm, calm three-quarter gaze, warm-gray studio background, face unobstructed. "
"Lighting: fixed soft north-window key camera-right, restrained fill and rim, natural shadows, no color cast. "
"Constraints: identity, gender, age, species, occupation, clothing, and marks must be unmistakable; no extra people, text of any kind (including Chinese characters, other non-Latin writing, symbols, labels, or pseudo-text), logos, watermark, modern styling, or real-person likeness. "
"Output: production-ready 1024x1024 portrait, safe for a circular crop."
)
def _build_character_sheet_prompt(name: str, book: str, sheet: dict, book_profile: dict,
visual_style: str = "concept") -> str:
facts = _character_visual_facts(name, sheet)
if visual_style == "cinematic":
style = (
"photorealistic historical-fantasy costume-continuity photography from the same fixed production photographer as the cast portraits, "
"with a real performer, honest skin texture, restrained warm-neutral color science, grounded anatomy, and physically believable "
"weathered wool, linen, leather, wood, and forged metal; no illustration, anime, cartoon, glossy 3D render, photo collage, or mixed media"
)
else:
style = (
"detailed semi-realistic hand-painted fantasy production concept art from one fixed studio artist, with consistent facial construction, "
"restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, and believable weathered wool, linen, leather, wood, "
"and forged metal; no anime, cartoon, glossy 3D render, photo collage, or mixed media"
)
return (
"Task: Generate one production character turnaround and equipment reference sheet. "
f"Subject: the same original fictional character — {facts}. "
f"Context: costume and continuity handoff for {book or 'this audiobook'}; {_period_lock(book, book_profile, sheet)} "
f"Style: {style}. "
"Composition: clean production board; three equal-scale, fully visible full-body orthographic views across the top: strict front, true left profile, strict back, same neutral stance. "
"Below, one source-grounded story moment showing the character acting, speaking, working, or fighting as they do in the book, plus zero to three verified portable items; each item is visually distinct, complete, shown once, assembled, isolated, and unlabeled. Empty prop slots remain empty. "
"Lighting: identical neutral museum-soft light, gentle contact shadows, no color cast. "
"Constraints: identical face, age, species, proportions, silhouette, garment seams, palette, and wear in every view; inventory never implies that armor or clothing is worn; no invented or duplicate gear, no repeated weapon category, no disassembled/floating fragments, extra limbs, generated text of any kind (especially Chinese characters, other non-Latin writing, symbols, labels, or pseudo-text), logos, or watermark. "
"Output: crisp production-ready square continuity sheet assembled from individually controlled panels."
)
def _saved_prompt_subject(prompt: str, fallback: str, limit: int = 1100) -> str:
"""Keep user edits to the Subject clause without re-sending a long master.
Layout/style/period are application invariants; the editable Subject is
the character-specific portion users reasonably need to refine.
"""
match = re.search(r"\bSubject:\s*(.*?)(?=\s+Context:)", prompt or "", flags=re.DOTALL | re.IGNORECASE)
value = (match.group(1).strip() if match else "") or fallback
if len(value) > limit:
value = value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"
return value
def _positive_conditioning_text(value: str) -> str:
"""Remove negated visual concepts before positive image conditioning.
Qwen's positive encoder can visualize nouns even when they occur after
words such as ``no``, ``without`` or ``ohne``. Structured positive facts
remain authoritative; exclusions belong in the workflow's negative
conditioning.
"""
value = re.sub(
r"\b(?:no|without|never|not\s+wearing|do\s+not|don't|"
r"kein(?:e|en|em|er|es)?|ohne)\b[^;,.·]*",
"",
value,
flags=re.IGNORECASE,
)
value = re.sub(
r"\b(?:absolutely\s+)?bareheaded\b",
"with the complete natural hairstyle visible",
value,
flags=re.IGNORECASE,
)
value = re.sub(r"\s+([,;.])", r"\1", value)
value = re.sub(r"([,;])\s*([,;])", r"\1", value)
return re.sub(r"\s{2,}", " ", value).strip(" ,;")
_FANTASY_DRESS = (
"high-fantasy medieval (roughly 900-1300 AD, The Dark Eye) dress: wool and linen tunics, belted robes, "
"leather belts and jerkins, and simple boots, made from"
)
_GARMENT_CUTS = (
"a quilted gambeson under a knee-length surcoat",
"a boiled-leather cuirass over a linen shirt",
"a mail hauberk under a hooded surcoat",
"a scale-lined leather cuirass with a short fur-trimmed cloak",
"a padded aketon with a heavy wool cloak clasped at one shoulder",
"a lamellar leather harness over a long-sleeved tunic",
"a short belted tunic with laced sleeves and a wide leather belt",
"a long belted surcoat over a mail coif and wool leggings",
)
_GARMENT_COLOURS = (
"faded indigo and undyed linen", "oxblood red and dark brown leather", "moss green and buff",
"charcoal grey and ochre", "rust orange and black", "slate blue and cream",
"forest green and tan", "plum and grey", "mustard yellow and brown", "teal and bone white",
"russet and olive", "black and dull silver",
)
def _character_design_variation(name: str, sheet: dict) -> str:
"""A stable, per-character garment cut and palette, so that characters with the
same job do not all get the same armour. Colours the sheet already names win."""
digest = hashlib.sha256(_fingerprint_text(name or "").encode("utf-8")).digest()
cut = _GARMENT_CUTS[digest[0] % len(_GARMENT_CUTS)]
established = _visual_prompt_value(sheet.get("visual_costume") or sheet.get("clothing"))
if re.search(r"\b(rot|blau|gr[üu]n|schwarz|wei[ßs]|grau|braun|gelb|golden|silbern|purpur|violett|orange|"
r"red|blue|green|black|white|grey|gray|brown|yellow|gold|silver|purple)\w*", established, re.IGNORECASE):
return f"{cut}, in the colours already described"
return f"{cut}, in {_GARMENT_COLOURS[digest[1] % len(_GARMENT_COLOURS)]}"
def _character_identity_phrase(name: str, sheet: dict) -> str:
"""One short "who this is" clause (gender, age, species) for panels that drift."""
sheet = _character_form_sheet(sheet)
gender = _visual_prompt_value(sheet.get("gender")).strip().lower()
who = {"female": "one adult woman", "weiblich": "one adult woman",
"male": "one adult man", "männlich": "one adult man"}.get(gender, "one adult person")
category = _character_age_category(sheet)
if category == "child":
who = who.replace("adult woman", "girl").replace("adult man", "boy").replace("adult person", "child")
elif category == "teen":
who = who.replace("adult ", "teenage ")
species = _established_visual_value(sheet, "visual_species", "race_species", 60)
parts = [f"{who} named {name}" if name else who]
if species:
parts.append(species)
return ", ".join(parts)
# established costume text is restated verbatim in CLOTHING LOCK below, so a
# loanword the book itself uses for a military-adjacent role (Offizier,
# Uniform, Korporal, General) reaches the render as-is and outweighs the
# generic period-lock wording — confirmed live on Uriens (ex-corporal) and
# Oberst Alrik von Blautann (title "Oberst"), both of whom rendered in
# 20th-century-style wool uniforms with peaked caps. This is a backstop for
# characters whose sheet text hasn't been hand-corrected yet; the real fix is
# still to write concrete period costume into the sheet.
_ANACHRONISTIC_COSTUME_WORD_RE = re.compile(
r"\boffiziers?(?:uniform)?\b|\buniform\b|\bkorporal\b|\bkorporals\b",
re.IGNORECASE,
)
def _sanitize_anachronistic_costume_words(text: str) -> str:
return _ANACHRONISTIC_COSTUME_WORD_RE.sub("commander's dress", text)
def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
book_profile: dict, visual_style: str,
saved_profile: str, saved_reference: str,
portrait_look: str = "") -> tuple[str, str, str, str, str, list[str]]:
"""Short, layout-first prompts for the local Qwen/Flux encoder.
The detailed masters remain visible/editable in the UI. These render
prompts avoid truncating the constraints and stop the edit model turning
a requested turnaround into another portrait poster.
"""
facts = _character_visual_facts(name, sheet, include_items=False)
portrait_subject = _saved_prompt_subject(saved_profile, facts)
sheet_subject = _saved_prompt_subject(saved_reference, facts)
# Standard generated prompts append inventory as a final labelled clause.
# Strip that clause from the shared person/costume source; the dedicated
# equipment pass below remains the sole place where those objects appear.
portrait_subject = re.sub(
r"\s*;\s*(?:signature items|verified portable items)\s*:\s*.*$", "", portrait_subject,
flags=re.IGNORECASE | re.DOTALL,
).strip()
sheet_subject = re.sub(
r"\s*;\s*(?:signature items|verified portable items)\s*:\s*.*$", "", sheet_subject,
flags=re.IGNORECASE | re.DOTALL,
).strip()
# A saved v3 Subject is the user's editable character direction. Do not
# silently replace its carefully chosen visual role with an extracted
# profession token (for example "Vampir" or "Inquisitor"), because those
# broad labels strongly trigger generic costume stereotypes. The
# structured fallback already uses _primary_visual_role when no edited
# Subject is available.
props = _character_prop_item_list(sheet)
scene_evidence = _positive_conditioning_text(_period_wording(_strip_injury_clauses(_character_scene_evidence(sheet))))
has_headwear = _character_has_established_headwear(sheet)
# Saved prompts were written before the wording/injury clean-up existed.
portrait_subject = _positive_conditioning_text(_period_wording(_strip_injury_clauses(portrait_subject)))
sheet_subject = _positive_conditioning_text(_period_wording(_strip_injury_clauses(sheet_subject)))
ear_shape = _character_ear_shape(sheet)
no_headwear = (
"Use only the explicitly established period headwear."
if has_headwear else
f"The scalp, crown, hairline, {ear_shape}, and complete natural hairstyle are visibly exposed and unobstructed."
)
bareheaded = not has_headwear
head_lock = (
f"HEAD CONTINUITY: In all three figures, clearly show the exposed scalp, crown, hairline, {ear_shape}, and complete natural hairstyle in front, profile, and rear views. "
if bareheaded else
"HEADWEAR LOCK: reproduce only the explicitly established period headwear, identically in all three views. "
)
leather_only = _character_is_leather_only_armor(sheet)
period_materials = (
"wool, linen, leather, hand-worked fastenings, and flat stitched-leather footwear"
if leather_only else
"wool, linen, leather, hand-worked fastenings, restrained forged-metal details, and flat stitched-leather footwear"
)
period_armor_note = (
"This character's armor and clothing are leather and cloth, with small forged-metal buckles and fastenings only. "
if leather_only else ""
)
# _positive_conditioning_text strips the book era's "no modern or
# 20th-century clothing" clause outright (it starts with "no", and
# negated nouns still get visualized by this encoder — see that
# function's docstring), so that ban never actually reached the model.
# Confirmed live: any character whose title/role reads as military rank
# ("General", "Kommandant", "Offizier") rendered in modern uniforms, and
# some carried a gun/rifle instead of a period weapon. A negative
# replacement ("no rifle", "not a uniform") would be actively
# counterproductive for the same reason the ban itself was silently
# dropped — it would just add the first mention of "rifle" to the
# prompt. This states the period equivalents positively instead, never
# naming a modern item at all.
# Gated: this used to fire for ANY military-sounding title/role text,
# which meant a civilian militia deputy whose own established costume is
# "practical clothes for housework and smithing" — no armor at all —
# still rendered in full plate with a spear, because a rank WORD alone
# triggered it regardless of what the character is actually established
# to wear. Confirmed live on Misira. Only fires when armor or a weapon is
# actually established.
authority_lock = (
(
"Any rank or authority is shown only through the cut and colours of the clothing, individual to this character: "
f"{_character_design_variation(name, sheet)}. "
)
if _character_has_established_combat_gear(sheet) else ""
)
# A single short "established worn costume" clause (many characters have
# only a few established words, e.g. just "sleepwear") competes with a
# strong base-model bias toward generic fantasy-adventurer armor and a
# sword — confirmed live: a character established in nothing but plain
# sleepwear and a blanket still rendered in full leather armor with
# pauldrons and a sword. Restating the costume as the COMPLETE outfit
# (not "no other clothing" — same reasoning as authority_lock above)
# gives that one clause more weight without naming anything to avoid.
# Referring back to "the costume above" abstractly wasn't concrete
# enough competition for the model's own armor-heavy fantasy bias — for
# a civilian with no armor established, repeating the actual clothing
# words here (not just pointing at them) gives that description a
# second, closer mention instead of relying on the Subject clause alone.
established_costume = _sanitize_anachronistic_costume_words(
_established_visual_value(sheet, "visual_costume", "clothing", 320, strip_injury=True)
)
clothing_lock = (
f"CLOTHING LOCK: this character's complete outfit in every view is exactly {established_costume} — "
"its own established materials, and nothing more elaborate. "
if established_costume else
"CLOTHING LOCK: the established worn costume above is this character's complete outfit in every view — "
"exactly that clothing, its own established materials, and nothing more elaborate. "
)
# A character's "physical" field is often written from ONE specific scene
# (frequently a late, dramatic one) rather than a neutral baseline — a
# profile portrait meant as a evergreen production reference then shows
# injuries, blood, or a distressed expression from a moment that hasn't
# even happened yet in the book, spoiling it before the reader gets
# there. Naming what to avoid ("no blood", "no wounds") would just
# visualize those words per the same encoder behavior documented above,
# so this states only the desired neutral baseline positively; any
# permanently established mark (an old scar, tattoo, missing finger) is
# still covered by "appearance" above and isn't contradicted by this.
baseline_lock = (
"PORTRAIT BASELINE: a calm, composed, healthy resting expression and healthy skin, showing the "
"character's everyday default appearance — not any single dramatic moment from the story. Clean skin and clean, well-kept garments. "
)
# baseline_lock covers the character's own skin/expression; the model's
# "gritty realistic fantasy" style bias separately adds blood staining to
# ARMOR on its own, even once the injury text feeding it is gone —
# confirmed live: Baronin Ira's stored appearance text no longer mentions
# any wound (the strip_injury filter above removed it), yet the render
# still showed a bloodstain across her chest armor. Same positive-only
# approach, aimed at the armor's surface condition instead of the skin.
# A sword was rendered shouldered and aimed forward like a firearm (confirmed live on
# Karyla's scene panel). Positive-only wording for how period arms actually sit in the hand.
# A single sword rendered both drawn in the hand and sheathed at the belt
# in the same figure (confirmed live on Lysandra and Oberst Alrik) — the
# existing "no duplicate gear" wording lives only in the prop-panel
# constraints, never in the render prompt that actually reaches the
# figure itself. Positive-only per the reasoning above: state the single
# weapon as a fact of the figure rather than warning against a second one.
weapon_count_lock = (
"This character carries exactly one bladed or hafted weapon, worn or held in a single place. "
) if _character_has_established_combat_gear(sheet) else ""
weapon_handling_lock = (
"Any blade or hafted weapon is held as it was truly used: gripped low by its hilt or haft with a relaxed "
"wrist, resting against the shoulder, hanging at the side, or sheathed at the belt. Each weapon is one "
"single complete object with exactly one hilt and one blade or head, correctly proportioned and "
"physically attached. "
) if _character_has_established_combat_gear(sheet) else ""
ranged_weapon_lock = _character_ranged_weapon_lock(sheet)
gear_condition_lock = (
"Any worn armor or equipment is clean, freshly polished, and well-kept, showing only ordinary wear from "
"daily use. "
)
# Hair kept coming out precision-tapered and blow-dried straight out of a
# modern barbershop (confirmed live on Darrag, Oberst Alrik, and Eolan),
# and in other cases outright bald or balding (confirmed live on Odalbert)
# — nothing told the model this world has no salons, clippers, or razors,
# and nothing ever stated hair COVERAGE, only its texture, so the model's
# own "gritty medieval warrior" bias defaulted to a shaved or receding
# head. Positive-only per the reasoning above: state the full coverage
# and hand-cut texture actually wanted rather than naming the modern or
# bald look to avoid.
hair_texture_lock = (
"A full, thick head of hair covering the entire scalp, reaching at least to the ears or collar, grown out "
"and trimmed by hand with a knife or shears, natural uneven ends and texture, simply combed, tied back, or "
"left loose, with a lived-in, handmade, slightly imperfect look. "
) if bareheaded else ""
# A woman in armor kept rendering with a shaved or buzzed head and a
# masculinized face (confirmed live on Misira and Baronin Ira von
# Seewiesen) — the base model's "female knight" bias reads combat gear as
# a stronger cue than the stated gender. Positive-only: state the long
# hair and feminine features as a fact of this figure, same reasoning as
# every other lock above.
female_presentation_lock = (
"Long hair reaching past the shoulders, worn loose, braided, or tied back, and clearly feminine facial "
"features and build, exactly as a woman looks wearing this armor. "
) if bareheaded and _visual_prompt_value(sheet.get("gender")).strip().lower() in {"female", "weiblich"} and _character_has_established_combat_gear(sheet) else ""
period = (
f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. high-fantasy medieval costume design (The Dark Eye), roughly 900-1300 AD: homespun wool and linen tunics, long belted robes, leather belts and jerkins, and simple stitched-leather boots only: "
f"{period_materials}. The Subject alone defines occupation-specific costume elements. Every visible element is pre-industrial and period-authentic. "
f"{period_armor_note}"
f"{authority_lock}"
f"{baseline_lock}"
f"{gear_condition_lock}"
f"{weapon_count_lock}"
f"{weapon_handling_lock}"
f"{ranged_weapon_lock}"
f"{clothing_lock}"
f"{hair_texture_lock}"
f"{female_presentation_lock}"
f"{no_headwear}"
)
if visual_style == "concept":
portrait_style = "One fixed semi-realistic hand-painted fantasy production artist with grounded anatomy and weathered materials."
sheet_style = portrait_style
else:
portrait_style = "Strict live-action photorealistic historical-fantasy production photography; real actor, honest skin, natural hair, restrained warm-neutral film color."
sheet_style = "Strict live-action photorealistic costume-continuity studio photography by that same production photographer, with a real actor and physically believable period materials."
# Nothing previously told the model what a correctly-proportioned body
# looks like, and the turnaround triptych in particular squeezes each
# full-body figure into a narrow 512-wide third of the frame — confirmed
# live as a recurring "short/stubby" look across most characters. Every
# template also used to hard-code "EXACTLY ONE ADULT PERSON" with zero
# exceptions, so a genuinely child or teenage cast member was always
# rendered as a full adult; _age_body_lock picks age-appropriate wording
# instead. Everything here is phrased positively (no negator) per the
# same reasoning as the armor lock above: this encoder still visualizes a
# noun mentioned after "no"/"not", so "adult proportions, not dwarfed"
# would still cue dwarfism rather than prevent it.
subject_clause, identical_noun, proportion_lock = _age_body_lock(sheet)
# Short and framing-first. The long version (~3 000 characters, framing rules buried in the middle)
# was measured against this one on the same character, 5 seeds each, scored by the image check:
# full bodies 0/5 vs 5/5 on a 1536x1024 canvas (also 5/5 at 1536x1280), plain backdrop 2/5 vs 5/5.
# The fast 4-step model follows the start of a prompt and loses the tail, so wording changes inside
# the long prompt ("visible floor") did nothing. Only the rules a sheet actually needs are added,
# one short sentence each.
def short_subject(text: str) -> str:
text = text if len(text) <= 700 else text[:700].rsplit(" ", 1)[0]
return text.rstrip(" .;,")
portrait_subject_short = short_subject(portrait_subject)
turnaround_subject = short_subject(sheet_subject)
if portrait_look:
# What the finished profile portrait shows, so the sheet follows it where the sheet is silent.
turnaround_subject += f"; exactly as in the portrait: {portrait_look}"
turnaround_rules = " ".join(filter(None, (
"Leather and cloth armour only, with small metal buckles." if leather_only else "",
# Characters with armour or weapons each get their own cut and colours, so a cast of soldiers
# does not all wear the same armour.
f"This character's own costume cut and colours: {_character_design_variation(name, sheet)}."
if _character_has_established_combat_gear(sheet) else "",
"Bare head, the whole hairstyle visible." if bareheaded else "Only the established headwear, the same in every view.",
hair_texture_lock.strip(),
f"{ear_shape.capitalize()}." if ear_shape.startswith("pointed") else "",
proportion_lock.strip() if identical_noun != "adult person" else "",
"Clean healthy skin and clean, well-kept clothes.",
weapon_count_lock.strip(),
female_presentation_lock.strip(),
)))
turnaround_style = ("Photorealistic, medieval high-fantasy costume in the world of The Dark Eye, hand-made materials."
if visual_style != "concept" else
"Semi-realistic hand-painted concept art, medieval high-fantasy costume in the world of The Dark Eye, hand-made materials.")
portrait = (
f"Full-length studio photograph of one {identical_noun} standing upright in a relaxed front three-quarter stance, "
"the whole figure visible from the top of the head to the shoes, the head in the upper third of the frame, "
"both hands empty. Plain empty white seamless studio background, soft even light, calm composed expression. "
f"The {identical_noun}: {portrait_subject_short}. {turnaround_rules} {turnaround_style}"
)
# Generate the three orthographic views together in one T2I frame. The
# edit model preserved a source portrait too literally: it frequently
# returned three frontal portraits and even copied a stick-figure pose
# guide into the costume. A single native triptych gives Qwen explicit
# spatial slots while keeping one face/costume interpretation across all
# three views. The app then crops the fixed thirds and assembles them.
front_view = (
f"Character turnaround reference sheet: three full-body views of the same one {identical_noun} standing side by side, "
"left a front view, middle a side profile, right a back view. Each figure is shown whole from the top of the head "
"to the soles of the shoes, feet standing on the floor, with empty space above the head and below the feet. "
"Plain empty white seamless studio background, soft even light. "
f"The {identical_noun}: {turnaround_subject}. {turnaround_rules} {turnaround_style}"
)
side_view = (
"Unused: the side view is generated in the controlled turnaround triptych."
)
back_view = (
"Unused: the rear view is generated in the controlled turnaround triptych."
)
scene_prompt = _build_story_moment_prompt(
name, sheet, book_profile, visual_style, turnaround_subject,
turnaround_rules, scene_evidence, weapon_handling_lock + ranged_weapon_lock,
)
prop_prompts = [
_build_signature_item_prompt(name, sheet, book_profile, visual_style, item, established_costume)
for item in props
]
return portrait, front_view, side_view, back_view, scene_prompt, prop_prompts
# ── The two lower panels of the reference sheet: story moment and signature items ──
#
# The story moment used to be a ~5 400-character prompt (the whole portrait rule set appended to
# it) that opened with "every person's ... ears ... clearly visible" and asked for
# "PHOTOREALISTIC ... costume-continuity studio photography by that same production
# photographer, with a real actor". The 4-step model keeps the start of a prompt and loses the
# tail (measured on the turnaround, see above), so the period rules at the end never arrived:
# for Marcian (a knight in plate and a red cloak, Das Jahr des Greifen) it drew a modern man in
# a polo shirt with headphones over the "visible ears" and a camera, in a suburban garden,
# captioned with pseudo-text ("Keep all surfaces unlettered", "Output 768x512" and the
# all-caps "EVIDENCE"/"LOCK" headings are text cues in themselves). The prop tile was a stock
# catalog photograph on a white sweep, with nothing tying it to its owner or his world.
#
# Both are now short, world-and-identity first, and positive only: the Qwen encoder draws a noun
# it reads even after "no"/"without" (see _positive_conditioning_text), so nothing to avoid is
# named in them. Exclusions go to the negative prompt (_LOWER_PANEL_NEGATIVE) instead.
# Merged into the scene and prop negative prompts. Note: those KSamplers run the 4-step
# Lightning model at cfg 1.0, where ComfyUI skips the negative conditioning entirely, so this
# only takes effect if the cfg is ever raised; the positive wording carries the load.
_LOWER_PANEL_NEGATIVE = (
"text, letters, words, writing, caption, subtitle, label, sign, banner text, pseudo-text, "
"Chinese characters, watermark, logo, signature, frame, border, collage, split screen, "
"headphones, headset, earbuds, camera, microphone, phone, wristwatch, eyeglasses, "
"modern clothing, t-shirt, polo shirt, jeans, hoodie, zipper, modern hairstyle, "
"modern building, street, car, power line, lamp post, suburban garden, plastic"
)
_LOWER_PANEL_WORLD = "the medieval high-fantasy world of The Dark Eye (Aventuria), roughly 900-1300 AD"
def _lower_panel_world(book_profile: dict) -> str:
"""The book's world for the lower panels. The book title is left out on purpose: a quoted
title is read as lettering to draw, and "Der Sturm" as weather."""
values = [_visual_prompt_value((book_profile or {}).get(key)) for key in ("setting", "era", "visual_context")]
context = "; ".join(value for value in values if value)
context = re.sub(r"(?:early[- ]?)?renaissance|fr[üu]h(?:-)?renaissance", "high-fantasy medieval", context, flags=re.IGNORECASE)
context = re.sub(r"\b1420\s*[–-]\s*1500\b", "roughly 900-1300 AD", context)
context = _positive_conditioning_text(_period_wording(context))[:240].rstrip(" ,;")
return f"{_LOWER_PANEL_WORLD} ({context})" if context else _LOWER_PANEL_WORLD
def _lower_panel_medium(visual_style: str) -> str:
# "Film still", not "photograph": photo-shoot words drew a photographer's camera and a modern
# model into the frame.
if visual_style == "concept":
return "A semi-realistic hand-painted medieval high-fantasy illustration"
return "A cinematic film still from a medieval high-fantasy epic, live action, natural light"
_LOWER_PANEL_CRAFT = (
"Everything in the picture is hand-made and pre-industrial: forged iron and steel, carved wood, "
"stitched leather, wool, linen, stone. All surfaces are plain and unmarked, and the image is one "
"single seamless picture."
)
def _build_story_moment_prompt(name: str, sheet: dict, book_profile: dict, visual_style: str,
subject: str, rules: str, evidence: str,
weapon_rules: str) -> str:
"""The lower-left panel: the same character as the turnaround, in a moment from the book."""
sheet = _character_form_sheet(sheet)
gender = _visual_prompt_value(sheet.get("gender")).strip().lower()
pronoun = {"female": "her", "weiblich": "her", "male": "him", "männlich": "him", "maennlich": "him"}.get(gender, "them")
evidence = evidence.strip().rstrip(" .")
if len(evidence) > 360:
evidence = evidence[:360].rsplit(" ", 1)[0].rstrip(" ,;:")
return " ".join(filter(None, (
f"{_lower_panel_medium(visual_style)}: one moment from the story, set in {_lower_panel_world(book_profile)}.",
f"In the foreground, seen from the waist up with the whole head in frame: {_character_identity_phrase(name, sheet)}, "
f"exactly the character of the turnaround, with the same face, hair, costume and colours — {subject}.",
rules,
f"The moment: {evidence}." if evidence else "",
f"Behind {pronoun}: a place from this world — stone walls, timber, thatch, field tents or open country — "
"lit by daylight, torches or candles; any other people stay small in the background and wear medieval clothing.",
weapon_rules.strip(),
_LOWER_PANEL_CRAFT,
)))
def _build_signature_item_prompt(name: str, sheet: dict, book_profile: dict, visual_style: str,
item: str, costume: str) -> str:
"""A lower-right tile: the character's own item, at home in the book's world."""
sheet = _character_form_sheet(sheet)
description = _prop_render_description(item).replace("on the sweep", "on the table")
role = _primary_visual_role(sheet)
owner = f"{name}, {role}" if name and role else (name or role or "its owner")
costume = costume.strip().rstrip(" .…")
if len(costume) > 200:
costume = costume[:200].rsplit(" ", 1)[0].rstrip(" ,;:")
match = f" ({costume})" if costume else ""
return " ".join((
f"{_lower_panel_medium(visual_style)}: a still life of a single object in {_lower_panel_world(book_profile)}.",
f"The object: {description}. It is the personal, well-used possession of {owner}, made with the same "
f"craftsmanship, materials and colours as the owner's costume{match}.",
"It lies whole and fully visible in the centre of the frame on a rough oak table in a medieval room, "
"the object alone on the table, with a softly blurred background of stone, timber and candle light.",
_LOWER_PANEL_CRAFT,
))
# ── Automatic image check ───────────────────────────────────────────────────
_IMAGE_CHECK_MAX_URI = 12_000_000 # a data URI; a 1536x1536 PNG is ~3-5 MB
def _image_check_expected(sheet: dict) -> dict:
from core.character_image_check import expected_from_sheet
sheet = _character_form_sheet(sheet or {})
return expected_from_sheet(
sheet,
is_elf=_character_ear_shape(sheet).startswith("pointed"),
age_category=_character_age_category(sheet),
)
def _image_check_run(kind: str, image_uris: list[str], sheet: dict, settings: dict,
model: str = "", timeout: float = 180) -> dict:
"""Ask the vision model what it sees, then judge that against the sheet. Never raises:
an unreachable or unreadable checker is reported as a failed check, not a pass."""
from core.character_image_check import build_messages, judge, parse_observation
llm_url = str(settings.get("llm_url") or "").rstrip("/")
# The active LLM by default: the one already loaded reads images, so a check never forces
# llama-swap to unload it for a separate vision model on a GPU that has no room for both.
model = model or str(settings.get("image_check_model") or settings.get("llm_model") or "")
expected = _image_check_expected(sheet)
# Thinking off: a reasoning model spent the whole budget deliberating over a confusing image
# (a wireframe in a costume) and returned no answer. Observation needs no deliberation.
payload = {
"model": model,
"messages": build_messages(kind, image_uris),
"temperature": 0,
"max_tokens": 800,
"chat_template_kwargs": {"enable_thinking": False},
}
headers = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
try:
resp = _post_llm_chat_completion(llm_url, payload, headers, timeout)
if resp.status_code in (400, 422): # a backend that rejects the (Qwen-style) thinking switch
payload = {k: v for k, v in payload.items() if k != "chat_template_kwargs"}
resp = _post_llm_chat_completion(llm_url, payload, headers, timeout)
if resp.status_code >= 400:
raise RuntimeError(f"HTTP {resp.status_code}: {_response_error_text(resp)[:160]}")
msg = resp.json()["choices"][0]["message"]
raw = (msg.get("content") or _reasoning_text(msg) or "").strip()
except Exception as exc:
return {"passed": False, "failures": [f"checker unavailable: {exc}"], "observed": {},
"expected": expected, "model": model}
observed = parse_observation(raw, kind)
verdict = judge(kind, observed, expected)
if not observed:
# Say what came back, so an unreadable answer is diagnosable rather than just "not reported".
verdict["failures"] = [f"checker gave an unreadable answer: {raw[:300]!r}"] + verdict["failures"]
return dict(verdict, expected=expected, model=model)
def _profile_crop_uri(identity_uri: str) -> str:
"""The profile picture as the consistent-image workflow cuts it (node 5: 768x768 at x=128,
y=0, then scaled to 1024), so the portrait check sees exactly what the user will see."""
raw = base64.b64decode(identity_uri.split(",", 1)[1])
with Image.open(io.BytesIO(raw)) as source:
crop = source.convert("RGB").crop((128, 0, 896, 768)).resize((1024, 1024), Image.Resampling.LANCZOS)
out = io.BytesIO()
crop.save(out, format="PNG")
return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii")
def _front_panel_uri(turnaround_uri: str) -> str:
"""The left third of the turnaround: the front view, used for the same-person check."""
raw = base64.b64decode(turnaround_uri.split(",", 1)[1])
with Image.open(io.BytesIO(raw)) as source:
img = source.convert("RGB")
front = img.crop((0, 0, img.width // 3, img.height))
out = io.BytesIO()
front.save(out, format="PNG")
return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii")
def _describe_portrait_look(portrait_uri: str, settings: dict, timeout: float = 120) -> str:
"""What the finished profile portrait shows (hair, facial hair, eyes), as a short phrase for the
sheet prompts. Best effort: an unavailable or unreadable answer gives "" and nothing changes."""
from core.character_image_check import LOOK_KIND, build_messages, look_phrase, parse_observation
payload = {
"model": str(settings.get("image_check_model") or settings.get("llm_model") or ""),
"messages": build_messages(LOOK_KIND, [portrait_uri]),
"temperature": 0, "max_tokens": 300, "chat_template_kwargs": {"enable_thinking": False},
}
headers = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
try:
resp = _post_llm_chat_completion(str(settings.get("llm_url") or "").rstrip("/"), payload, headers, timeout)
if resp.status_code in (400, 422):
payload.pop("chat_template_kwargs", None)
resp = _post_llm_chat_completion(str(settings.get("llm_url") or "").rstrip("/"), payload, headers, timeout)
if resp.status_code >= 400:
return ""
msg = resp.json()["choices"][0]["message"]
return look_phrase(parse_observation((msg.get("content") or _reasoning_text(msg) or ""), LOOK_KIND))
except Exception:
return ""
def _check_sheet_panels(panels: dict, sheet: dict, settings: dict) -> dict:
"""Check the rendered sheet panels. The story-moment gender is judged after the face
transfer, on what a viewer actually sees."""
verdicts = {}
if panels.get("turnaround"):
verdicts["turnaround"] = _image_check_run("turnaround", [panels["turnaround"]], sheet, settings)
if panels.get("scene"):
verdicts["scene"] = _image_check_run("scene", [panels["scene"]], sheet, settings)
if panels.get("profile") and panels.get("turnaround"):
verdicts["same_person"] = _image_check_run(
"same_person", [panels["profile"], _front_panel_uri(panels["turnaround"])], sheet, settings)
return verdicts
@router.post("/api/character-image-check")
async def character_image_check(request: Request):
"""Check one generated panel against the character sheet.
Body: {kind: "portrait"|"turnaround"|"scene"|"same_person", images: [data URI, ...],
sheet: {...}, model?: str}
Returns: {passed, failures:[str], observed:{...}, expected:{...}, model}
"""
from core.character_image_check import PANEL_KINDS
data = await request.json()
if not isinstance(data, dict):
raise HTTPException(400, "Body must be an object")
kind = str(data.get("kind") or "")
if kind not in PANEL_KINDS:
raise HTTPException(400, f"kind must be one of {', '.join(PANEL_KINDS)}")
images = data.get("images")
if not isinstance(images, list) or not images or not all(
isinstance(i, str) and i.startswith("data:image/") and len(i) <= _IMAGE_CHECK_MAX_URI for i in images
):
raise HTTPException(400, "images must be a list of image data URIs")
if len(images) != (2 if kind == "same_person" else 1):
raise HTTPException(400, f"{kind} takes {2 if kind == 'same_person' else 1} image(s)")
sheet = data.get("sheet") if isinstance(data.get("sheet"), dict) else {}
model = str(data.get("model") or "").strip()[:200]
return await asyncio.to_thread(_image_check_run, kind, images, sheet, _load_settings(), model)
@router.post("/api/character-generate-prompts")
async def character_generate_prompts(request: Request):
"""Turn an already-extracted character sheet into four ready-to-use external
prompts, in one LLM call over the character's full profile (rather than
generating them incrementally passage-by-passage, where the model only
sees a fraction of the character at a time).
Body: {name, book, sheet: {...character sheet fields...}, language, llm_url, model}
Returns: {voice_design_prompt, image_prompt, silly_tavern_prompt, concept_art_prompt}
"""
data = await request.json()
name: str = (data.get("name") or "").strip()
book: str = (data.get("book") or "").strip()
sheet: dict = _character_form_sheet(data.get("sheet") or {})
book_profile: dict = data.get("book_profile") or {}
voice_feedback: str = (data.get("voice_feedback") or "").strip()
visual_style: str = (data.get("visual_style") or "cinematic").strip().lower()
if visual_style not in {"cinematic", "concept"}:
visual_style = "cinematic"
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not name:
raise HTTPException(400, "Character name required")
def _f(key: str) -> str:
v = sheet.get(key)
if isinstance(v, list):
return ", ".join(str(x) for x in v if x)
return str(v or "").strip()
profile = "\n".join(f"{label}: {v}" for label, v in [
("Name", name), ("Aliases / noble title", _f("aliases") or _f("title")),
("Occupation", _f("profession")), ("Archetype", _f("archetype")), ("Physical", _f("physical")), ("Clothing", _f("clothing")),
("Mannerisms", _f("mannerisms")), ("Voice/speech pattern", _f("voice_pattern")),
("Backstory", _f("backstory")), ("Motivation", _f("motivation")), ("Fears", _f("fears")),
("Relationships", _f("relationships")), ("Conflict style", _f("conflict_style")),
("Secret/flaw", _f("secret")), ("Inventory", _f("inventory")),
] if v)
if voice_feedback:
profile += "\nPrevious voice review feedback — correct these issues explicitly: " + voice_feedback
feedback_lower = voice_feedback.lower()
hard_corrections = []
if "us accent" in feedback_lower or "american" in feedback_lower:
hard_corrections.append("HARD CORRECTION: use an authentic native German accent with zero American or English accent coloring")
if "female" in feedback_lower and ("male" in feedback_lower or "not female" in feedback_lower):
hard_corrections.append("HARD CORRECTION: the result must unmistakably sound female, never male or androgynous")
elif "male" in feedback_lower and ("female" in feedback_lower or "not male" in feedback_lower):
hard_corrections.append("HARD CORRECTION: the result must unmistakably sound male, never female or androgynous")
if hard_corrections:
profile += "\n" + "\n".join(hard_corrections)
lang_note = f" Write every prompt in {language} EXCEPT where told to use English." if language else ""
preamble = (
"You are a prompt engineer who turns a fiction character's profile into ready-to-paste prompts for "
"other tools. Use ONLY details present in the profile below; mark anything you must reasonably infer "
f"with a trailing '*'. Never invent plot spoilers not implied by the profile.{lang_note}\n"
"CONTENT VS LOOK RULE: The profile is the sole source of truth for who this character is and what they "
"visually possess. The prompt controls how that content is presented: sheet purpose, composition, camera, "
"lighting, rendering medium, palette treatment, background, annotation policy, and output ratio. Never let "
"a chosen art style silently change the character's gender, age, anatomy, species, face, clothing, colors, "
"weapons, or props. Never add lifestyle items, underwear, cosmetics, brands, or extra gear unless the profile "
"explicitly establishes them.\n\n"
)
context = ", ".join(str(book_profile.get(k) or "").strip() for k in ("genre", "setting", "era") if str(book_profile.get(k) or "").strip())
context_block = (
f"BOOK PRODUCTION CONTEXT: {context}\n"
"Treat this as a hard continuity constraint. Clothing, uniforms, hats, tools, architecture, materials, and props must belong to this period and setting. Do not introduce modern or 19th/20th-century items unless the book context explicitly says so.\n\n"
if context else ""
)
user = f"BOOK: {book or 'unspecified'}\n\n{context_block}CHARACTER PROFILE:\n{profile or name}\n\n"
visual_identity_rule = (
"VISUAL IDENTITY LOCK: Treat the profile as a production design bible. "
"Gender is mandatory and must be stated explicitly; never reinterpret a male character as female or vice versa. "
"Extract every visual fact available in the profile: gender, age impression, species, face shape, skin tone, "
"hair, eyes, facial hair, scars or distinguishing marks, height/build, clothing layers, armor, colors, "
"jewelry, weapons, tools, and signature props. Use one consistent face identity anchor across the portrait, "
"turnaround, expression sheet, and concept art. Do not add visual details that contradict the profile.\n\n"
)
# One independent call per field (not one four-field JSON object, nor even
# the two-field pairing this used briefly) — the UI now has a Generate
# button per prompt box, so a click on just one must not also burn tokens
# regenerating the other three. Singling them out also further shrinks
# each response, since a four-field JSON object reliably truncated the
# LAST fields regardless of the token ceiling.
all_groups = {
"voice_design_prompt": (
preamble
+ "Produce exactly this field:\n"
"- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (25-60 words, one paragraph, "
"no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, "
"accent or register, emotional baseline, and suitability for audiobook dialogue delivery. "
+ ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though this prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ")
+
"Generic category labels alone ('young female voice, energetic tone, clear pitch') describe a whole "
"demographic, not a person, and different characters who happen to share an age/gender end up "
"sounding like the same person — confirmed live as a real problem with several young-female "
"characters in one book. Every one of these fields needs a SPECIFIC, CONCRETE choice, not the safe "
"generic default: pick a distinctive timbre (breathy/husky/bright/nasal/silvery/gravelly/reedy, not "
"just 'clear'), a specific pace/rhythm quirk (clipped consonants, unhurried drawl, rapid-fire, "
"deliberate pauses before key words), and a specific emotional baseline drawn from THIS character's "
"own personality/backstory (guarded warmth, brittle confidence, weary sarcasm — not just 'friendly' "
"or 'energetic'). Two characters with the same age and gender in your profile should still end up "
"with visibly different prompts once you've done this. If previous voice review feedback appears in the profile, treat every item as a hard correction and make the new prompt audibly address it. Do not mention plot events — describe only "
"how the voice should SOUND.\n\n"
'Respond with STRICT JSON only: {"voice_design_prompt":""}/no-think'
),
"image_prompt": (
preamble
+ visual_identity_rule
+ "Produce exactly this field:\n"
"- image_prompt: an English image-generation prompt for a photorealistic audiobook character portrait, "
"written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
"(not a list). You MUST use the following STRICT stylistic constraints for the clauses, but replace the Subject facts with the current character profile:\n"
" Task: Generate a Character portrait.\n"
" Subject: an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
"content. Include every relevant visual fact from the profile.\n"
" Context: a believable live-action character in the book's established original world and era; if the book context says medieval, use historically plausible medieval fantasy clothing and materials only.\n"
" Style: photorealistic cinematic character photography, as a premium live-action fantasy-film portrait; natural skin texture and pores, anatomically plausible face and hands, real cloth, leather and metal materials, and no illustration, painting, anime, game render, or plastic skin.\n"
" Composition: a simple square headshot/bust on a subdued neutral studio background, facing forward with a subtle in-character expression.\n"
" Lighting: flattering neutral photographic key light with soft rim separation, realistic shadow falloff, and no stylized color cast.\n"
" Constraints: preserve the exact gender, age impression, species traits, face, hairstyle, garment layers and signature details established in the profile; no modern shirts, jeans, business suits, neckties, wristwatches, firearms, zippers, synthetic sportswear, 1950s hats, or other anachronistic items unless explicitly established by the book; no existing game references, logos, readable labels, floating fragments, or watermark.\n"
" Output: one production-ready square live-action character portrait.\n\n"
'Respond with STRICT JSON only: {"image_prompt":""}/no-think'
),
"silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet),
"concept_art_prompt": (
preamble
+ visual_identity_rule
+ "Produce exactly this field:\n"
"- concept_art_prompt: an English image-generation prompt for a production-ready character/NPC "
"reference sheet, written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
"(not a list). You MUST use the following STRICT stylistic constraints for the clauses, but replace the Subject and Prop facts with the current character profile:\n"
" Task: create a three-view character continuity sheet with a source-grounded story moment and optional verified prop callouts.\n"
" Subject: write an original adult [EXACT GENDER] [role/archetype] using ONLY established profile content. Include age, species, face, skin, hair, eyes, facial hair, marks, build, and established worn clothing. Inventory does NOT prove that clothing or armor is worn. Include only portable props explicitly established by the profile; omit uncertain and inferred objects. Never copy the herbalist/cartographer examples.\n"
" Context: state the book's profile-derived genre, setting, era, and the production purpose of the sheet.\n"
" Style: retain the reference style principles — detailed hand-painted or painterly game concept art, grounded leather/metal/cloth/wood materials, clear readable shape language, and coherent wear — but choose the visual medium and palette treatment appropriate to this book's profile.\n"
" Composition: aligned full-body strict front, true left-side profile, and strict back views across the top; below them, one representative action/dialogue/work scene supported by the book evidence and zero to three isolated portable props. Keep empty prop slots empty. Use generous safe margins and keep every head, foot, hand, and object fully visible.\n"
" Lighting: use warm-neutral or neutral studio light that reveals material texture, with soft contact shadows and no cinematic color cast unless the profile or book context explicitly requires one.\n"
" Constraints: same exact gender, species, face identity anchor, body proportions, silhouette, garment seams, colors, and wear in every view; each prop appears once; choose at most one item from each visual category so several swords or near-identical tools cannot fill the row; no invented gear, duplicate gear, disassembled fragments, floating objects, extra limbs, generated text, logos, real-world emblems, franchise references, or watermark.\n"
" Output: one organized, production-ready square character reference sheet assembled from separately controlled panels.\n\n"
'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think'
),
}
requested = [f for f in (data.get("fields") or []) if f in all_groups]
wanted = requested or list(all_groups.keys())
# Camera, lighting, layout, artist and period are fixed across the cast;
# only structured character facts vary. This is intentionally
# deterministic instead of asking the LLM to redesign each production.
deterministic = {}
if "image_prompt" in wanted:
deterministic["image_prompt"] = _build_character_portrait_prompt(name, book, sheet, book_profile, visual_style)
if "concept_art_prompt" in wanted:
deterministic["concept_art_prompt"] = _build_character_sheet_prompt(name, book, sheet, book_profile, visual_style)
field_groups = [((f,), all_groups[f]) for f in wanted if f not in deterministic]
# The SillyTavern card is a full structured card + scenario + first
# message now, not a few short labelled lines — needs a much bigger
# budget than the other three (single-paragraph) prompt fields or it
# reliably truncates mid-card.
_field_max_tokens = {"silly_tavern_prompt": 3000}
def _call_group(fields: tuple, system: str) -> dict:
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user + f"Generate the {' and '.join(fields)} now."},
],
"temperature": 0.7,
"max_tokens": _field_max_tokens.get(fields[0], 1536),
}
if model:
payload["model"] = model
try:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip()
except Exception as e:
print(f"[character-generate-prompts] LLM call failed for {fields}: {e}")
return {}
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
block = _extract_json_block(content)
# If the model still got cut off mid-string, closing the dangling string +
# object often salvages the field that DID complete.
candidates = [content, block]
if block:
candidates.extend([block + "\"}", block + "}"])
for cand in candidates:
if not cand:
continue
try:
parsed = json.loads(cand)
if isinstance(parsed, dict) and fields[0] in parsed:
return parsed
except Exception:
continue
return {}
results = await asyncio.gather(*[
asyncio.to_thread(_call_group, fields, system) for fields, system in field_groups
])
out = dict(deterministic)
for (fields, _), result in zip(field_groups, results):
for f in fields:
out[f] = str(result.get(f) or "").strip()
if not any(out.values()):
# A silent all-empty response looked identical to success in the UI —
# fail loudly so the client can show a real error instead.
raise HTTPException(502, "LLM returned no parseable prompts — try again (or a different model)")
try:
from core.database import generation_history_add
generation_history_add(
kind="character_prompts", target_id=f"{book}:{name}", provider=model or "configured_llm",
result={"fields": sorted(out), "non_empty_fields": sorted(k for k, v in out.items() if v)},
)
except Exception as exc:
print(f"[generation-history] prompt audit failed: {exc}")
return out
@router.post("/api/audiobook-consistency-check")
async def audiobook_consistency_check(request: Request):
"""Check whether every line already attributed to ONE character across the
whole book actually sounds like them — using the character's OWN other
lines as the reference, not a chunk-local judgement. Unlike the casting/
verification passes (which re-read the source text chunk by chunk), this
works purely over already-attributed lines gathered from anywhere in the
book, so it can catch a character who briefly "borrows" someone else's
voice in a way no single passage-sized chunk would ever expose.
Body: {character, lines: [{index, text}], known_characters: [...], llm_url, model}
Returns: {outliers: [{index, reason, suggested_speaker}]}
"""
data = await request.json()
character: str = (data.get("character") or "").strip()
lines: list = data.get("lines") or []
known: list = data.get("known_characters") or []
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
if not character:
raise HTTPException(400, "Character name required")
if not lines:
raise HTTPException(400, "No lines to check")
numbered = "\n".join(
f"[{l.get('index')}] {str(l.get('text') or '').strip()}"
for l in lines if str(l.get("text") or "").strip()
)
known_note = (
f"\n\nOther characters already recognized in this book (only suggest one of these, or 'Unknown' — "
f"never invent a new name): {', '.join(str(n) for n in known)}"
) if known else ""
system = (
f"You are a dramaturge auditing dialogue attribution in a novel already cast for audiobook production. "
f"Below are ALL the lines currently attributed to ONE character, '{character}', gathered from across the "
f"whole book (not necessarily consecutive). Your job: read them as a whole and judge whether each line "
f"actually sounds like the SAME person speaking — same tone, vocabulary, register, and personality — or "
f"whether one or more lines sound like they were misattributed from someone else (a different tone, "
f"formality, vocabulary, or a statement that contradicts what the rest of {character}'s lines establish "
f"about them).\n\n"
f"Be conservative: most lines are correctly attributed. Only flag a line if it genuinely reads like a "
f"different voice compared to the REST of {character}'s own lines here — not just because it's short, "
f"blunt, or otherwise unremarkable.{known_note}\n\n"
'Respond with STRICT JSON only: {"outliers":[{"index":0,"quote":"","reason":"","suggested_speaker":""}]}\n'
"index: the EXACT number shown in [square brackets] right before the flagged line below — copy that "
"number verbatim, do NOT count lines yourself or renumber them (the brackets are the book's real line "
"numbers, not a 0/1/2/3 sequence). quote: the first few words of the flagged line, verbatim, so the "
"index can be double-checked. reason: one short sentence explaining why this line doesn't fit. "
"suggested_speaker: your best guess at who actually said it (exact name from the list above), or "
"'Unknown' if you can't tell. Omit any line that isn't an outlier — do not list every line.\n/no-think"
)
user = f"{character}'s lines (index — text):\n{numbered}"
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.3,
"max_tokens": 2048,
}
if model:
payload["model"] = model
def _call() -> dict:
resp = requests.post(
f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
)
resp.raise_for_status()
msg = resp.json()["choices"][0]["message"]
raw = (msg.get("content") or _reasoning_text(msg) or "").strip()
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
for cand in (content, _extract_json_block(content)):
if not cand:
continue
try:
parsed = json.loads(cand)
if isinstance(parsed, dict) and isinstance(parsed.get("outliers"), list):
return parsed
except Exception:
continue
return {"outliers": []}
try:
result = await asyncio.to_thread(_call)
except Exception as e:
raise HTTPException(502, f"Consistency check failed: {e}")
clean = []
for o in result.get("outliers", []):
if not isinstance(o, dict):
continue
try:
idx = int(o.get("index"))
except (TypeError, ValueError):
continue
clean.append({
"index": idx,
"quote": str(o.get("quote") or "").strip()[:120],
"reason": str(o.get("reason") or "").strip()[:200],
"suggested_speaker": str(o.get("suggested_speaker") or "").strip(),
})
return {"outliers": clean}
def _bundled_comfyui_workflow(image_kind: str) -> tuple[str, str, str, str]:
"""Return the validated local Flux workflows shipped with Voice Creator.
A custom API-format graph saved in Settings still takes precedence. The
bundled workflows mean that selecting Local ComfyUI works immediately on
the supported local installation instead of leaving every bulk run to
fail because the workflow textarea was empty.
"""
name = "comfyui-character-concept-sheet.json" if image_kind == "concept_art" else "comfyui-character-portrait.json"
path = Path(__file__).resolve().parent.parent / "workflows" / name
try:
return path.read_text(encoding="utf-8"), "2", "text", "7"
except OSError as exc:
raise HTTPException(500, f"Bundled ComfyUI workflow is unavailable: {exc}")
# Prompts saved before the field guidance was tightened embed their own style,
# medium, framing and background ("painterly game concept art", "plain neutral
# parchment background", "high quality painterly fantasy illustration"). The
# rendering directive appended below then demands the opposite — a photorealistic
# live-action portrait that is explicitly "not an illustration, painting…" —
# so the model receives two contradictory briefs in one prompt and splits the
# difference. Confirmed live on stored prompts. Strip the style clauses out so
# the directive is the single source of truth for how the image should look;
# everything describing WHO the character is survives untouched.
# Clause labels and style words that describe HOW to render rather than WHO the
# character is. Matching whole sentences (rather than nibbling substrings) keeps
# the result readable — an earlier substring version left orphans like
# "Style: Composition:" behind.
_IMG_DIRECTIVE_LABEL = re.compile(
r"^\s*(?:style|composition|lighting|context|task|background|framing|camera|render(?:ing)?|"
r"mood|genre|setting|scene|atmosphere|environment)\s*:",
re.IGNORECASE,
)
# Whole sentences that describe the surroundings rather than the person. The
# LLM narrates the scene it read in the book ("standing in a grassy landscape
# at dusk", "the background shows a campfire"), which turns a casting portrait
# into a full-body environmental shot — confirmed on every generated portrait.
_IMG_SCENE_SENTENCE = re.compile(
r"^\s*(?:the\s+)?(?:background|setting|scene|environment|atmosphere|surroundings)\b"
r"|\b(?:in the distance|dark,?\s*stormy atmosphere)\b",
re.IGNORECASE,
)
_IMG_STYLE_WORDS = re.compile(
r"\b(?:painterly|illustration|illustrated|concept\s+art|game\s+npc|stylized|cartoon|anime|"
r"digital\s+painting|watercolou?r|headshot|bust|plain\s+neutral|parchment\s+background|"
r"neutral\s+background|square\s+portrait)\b",
re.IGNORECASE,
)
def _strip_conflicting_image_style(prompt: str) -> str:
"""Drop clauses that dictate style/framing, keep those describing the character.
Prompts saved before the field guidance was tightened embed their own style,
medium, framing and background. The rendering directive appended afterwards
demands the opposite, so the model gets two contradictory briefs at once and
splits the difference. Stripping them makes the directive authoritative.
"""
text = str(prompt or "")
parts = re.split(r"(?<=[.!?])\s+|\n+", text)
kept = [p for p in parts
if p.strip()
and not _IMG_DIRECTIVE_LABEL.search(p)
and not _IMG_SCENE_SENTENCE.search(p)
and not _IMG_STYLE_WORDS.search(p)]
cleaned = " ".join(x.strip() for x in kept).strip()
cleaned = re.sub(r"\s{2,}", " ", cleaned)
# Never return an empty prompt: if a prompt was nothing but style clauses,
# the original is still better than no description at all.
return cleaned or text.strip()
def _world_clause(book_context: str) -> str:
"""Period/world grounding for image prompts.
The client normally appends this, but direct API callers (scripts, batch
regeneration) bypass the client entirely — which is how a colonel in a
1420-1500 setting was rendered in a 19th-century dress uniform.
"""
ctx = str(book_context or "").strip()
if not ctx:
return ""
return (
f"WORLD AND PERIOD (authoritative, overrides any era implied above): {ctx}. "
"Clothing, materials, tailoring, armour, weapons and insignia must fit that period. "
"A rank title must NOT become a modern or 19th-century military uniform — no epaulettes, "
"braided frogging, peaked caps or industrial-era tailoring. "
)
def _apply_character_image_rendering_style(prompt: str, image_kind: str, book_context: str = "") -> str:
# Concept sheets have their own illustrative directive, so only the
# photoreal portrait path needs the conflicting style words removed.
if image_kind != "concept_art":
prompt = _strip_conflicting_image_style(prompt)
"""Keep asset style deterministic, including for prompts saved before v1.20.36.
A profile and a concept sheet serve different purposes. The LLM provides
character-specific content, while this final rendering directive makes the
visual contract explicit to ComfyUI and prevents an old "painterly" prompt
from silently turning every newly regenerated portrait into game art.
"""
if image_kind == "concept_art":
return (
f"{prompt}\n\n"
"FINAL RENDERING DIRECTIVE: make this a refined hand-drawn production design sheet: "
"pencil and ink linework, restrained watercolor washes, readable costume construction and props, "
"subtle paper texture. It must be illustrative but not cartoonish; do not render it as a photograph, "
"3D game art, anime, glossy digital painting, or a collage. "
f"{_world_clause(book_context)}"
)
# The directive is appended last, where diffusion models weight it most, and
# states framing explicitly. Stripping scene text from the prompt is not
# enough on its own: a residual clause like "riding a horse" still pulled the
# camera back to a full-body environmental shot instead of a casting portrait.
return (
f"{prompt}\n\n"
"FINAL RENDERING DIRECTIVE: render this as a photorealistic live-action cinematic casting portrait, "
"with natural human skin texture and pores, realistic hair, physically plausible anatomy, authentic "
"fabric/leather/metal, and photographic lens lighting. "
"FRAMING (overrides anything above): a tight head-and-shoulders portrait of ONE person, face clearly "
"visible and facing the camera, against a plain uncluttered neutral studio backdrop. Ignore any "
"landscape, room, background, weather, action or scene mentioned above — show only the person. "
f"{_world_clause(book_context)}"
"No modern or anachronistic objects. "
"Do not make an illustration, painting, anime, cartoon, game render, CGI character, or plastic-skinned doll."
)
def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
prompt_field: str, output_node_id: str, prompt: str,
seed_key: str = "", progress=None) -> str:
"""Submits a pre-exported ComfyUI API-format workflow with the given
prompt text injected into one designated node/field, waits for it to
finish, and returns the resulting image as a data: URI.
The workflow itself is opaque to us — the user exports it from ComfyUI's
own UI (Workflow > Export (API Format)), since converting the editor's
graph format ourselves risks silently mis-wiring virtual-routing addons
(e.g. rgthree Get/Set nodes) that don't show up as real graph edges.
"""
if not comfyui_url:
raise HTTPException(400, "ComfyUI URL not set (Settings > Engines > Image Generation)")
if not prompt_node_id or not output_node_id:
raise HTTPException(400, "ComfyUI prompt/output node IDs not set (Settings > Engines > Image Generation)")
try:
workflow = json.loads(workflow_json)
except Exception as e:
raise HTTPException(400, f"Saved ComfyUI workflow isn't valid JSON: {e}")
if prompt_node_id not in workflow:
raise HTTPException(400, f"Prompt node id '{prompt_node_id}' not found in the saved workflow")
if output_node_id not in workflow:
raise HTTPException(400, f"Output node id '{output_node_id}' not found in the saved workflow")
graph = copy.deepcopy(workflow)
graph[prompt_node_id].setdefault("inputs", {})[prompt_field or "text"] = prompt
# Bundled diffusion graphs used to ship with seed 0 for every character.
# Combined with recurring fantasy terms, that repeatedly selected the
# same unwanted broad-brimmed-hat composition across the cast. Derive a
# stable prompt-specific seed instead: regenerating an unchanged prompt
# remains reproducible while different identities no longer share one
# latent stereotype.
prompt_seed = int.from_bytes(
hashlib.sha256(("voice-creator-image-v5|" + (seed_key or prompt)).encode("utf-8")).digest()[:8],
"big",
)
for node in graph.values():
if node.get("class_type") == "KSampler" and "seed" in node.get("inputs", {}):
node["inputs"]["seed"] = prompt_seed
client_id = str(uuid.uuid4())
base = comfyui_url.rstrip("/")
try:
resp = requests.post(f"{base}/prompt", json={"prompt": graph, "client_id": client_id}, timeout=30)
if not resp.ok:
detail = resp.text[:500]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise HTTPException(resp.status_code, f"ComfyUI rejected the workflow: {detail}")
prompt_id = resp.json().get("prompt_id")
if not prompt_id:
raise HTTPException(502, "ComfyUI didn't return a prompt_id")
except HTTPException:
raise
except requests.exceptions.ConnectionError:
raise HTTPException(502, f"Cannot reach ComfyUI at {base} — is the container running?")
except Exception as e:
raise HTTPException(502, f"ComfyUI submission failed: {e}")
# Poll history — generation on a real workflow (multi-sampler, upscale,
# etc.) can take minutes, so this waits longer than a typical API call.
start = time.time()
deadline = start + 300
history = None
while time.time() < deadline:
if progress:
progress(time.time() - start)
try:
hr = requests.get(f"{base}/history/{prompt_id}", timeout=10)
if hr.ok:
data = hr.json()
if prompt_id in data:
history = data[prompt_id]
status = history.get("status", {})
if status.get("completed") is True or status.get("status_str") == "success":
break
if status.get("status_str") == "error":
msgs = status.get("messages", [])
raise HTTPException(502, f"ComfyUI generation failed: {msgs[-1] if msgs else 'unknown error'}")
except HTTPException:
raise
except Exception:
pass
time.sleep(2)
if not history:
raise HTTPException(504, "ComfyUI generation timed out after 5 minutes")
outputs = history.get("outputs", {}).get(output_node_id, {})
images = outputs.get("images") or []
if not images:
raise HTTPException(502, f"Output node '{output_node_id}' produced no images — check it's the right SaveImage/PreviewImage node id")
img = images[0]
try:
vr = requests.get(f"{base}/view", params={
"filename": img.get("filename", ""), "subfolder": img.get("subfolder", ""), "type": img.get("type", "output"),
}, timeout=30)
vr.raise_for_status()
except Exception as e:
raise HTTPException(502, f"Fetching the generated image from ComfyUI failed: {e}")
b64 = base64.b64encode(vr.content).decode("ascii")
mime = vr.headers.get("Content-Type", "image/png")
return f"data:{mime};base64,{b64}"
def _comfyui_upload_data_uri(comfyui_url: str, data_uri: str) -> str:
"""Upload a browser-stored identity image into ComfyUI's input folder."""
if not data_uri or not data_uri.startswith("data:"):
raise HTTPException(400, "A valid identity image is required")
try:
header, encoded = data_uri.split(",", 1)
raw = base64.b64decode(encoded)
except Exception as exc:
raise HTTPException(400, f"Identity image is not valid base64: {exc}")
mime = header.split(";", 1)[0].split(":", 1)[-1].lower()
ext = ".jpg" if mime in {"image/jpeg", "image/jpg"} else ".webp" if mime == "image/webp" else ".png"
filename = f"voice_creator_identity_{uuid.uuid4().hex}.png"
try:
resp = requests.post(
f"{comfyui_url.rstrip('/')}/upload/image",
files={"image": (filename, raw, mime or "image/png")},
data={"overwrite": "true", "type": "input"},
timeout=60,
)
if not resp.ok:
raise HTTPException(resp.status_code, f"ComfyUI rejected the identity image: {resp.text[:400]}")
result = resp.json()
return str(result.get("name") or filename)
except HTTPException:
raise
except requests.exceptions.RequestException as exc:
raise HTTPException(502, f"Cannot upload the identity image to ComfyUI: {exc}")
def _pose_guide_data_uri(view: str) -> str:
"""Create a neutral mannequin guide for Qwen's second reference image.
Text-only camera directions were not reliable enough: a front-facing
identity frequently stayed front-facing even when the prompt requested a
side or rear view. The guide carries orientation only; the first Qwen
image remains the identity/costume authority.
"""
width, height = 512, 1024
image = Image.new("RGB", (width, height), (245, 241, 233))
draw = ImageDraw.Draw(image)
ink = (76, 79, 82)
shade = (151, 155, 158)
floor = (205, 199, 188)
draw.line((74, 930, 438, 930), fill=floor, width=4)
if view == "side":
draw.ellipse((222, 72, 282, 162), fill=ink)
draw.polygon([(222, 105), (205, 115), (222, 126)], fill=ink) # nose points left
draw.rectangle((238, 154, 268, 194), fill=ink)
draw.rounded_rectangle((215, 185, 285, 565), radius=30, fill=shade, outline=ink, width=5)
draw.rounded_rectangle((216, 220, 246, 610), radius=14, fill=ink)
draw.rounded_rectangle((252, 220, 282, 610), radius=14, fill=(105, 108, 111))
draw.rounded_rectangle((220, 545, 252, 900), radius=15, fill=ink)
draw.rounded_rectangle((255, 545, 287, 900), radius=15, fill=(105, 108, 111))
draw.ellipse((192, 884, 252, 924), fill=ink)
draw.ellipse((226, 884, 288, 924), fill=(105, 108, 111))
else:
draw.ellipse((211, 72, 301, 162), fill=ink)
draw.rectangle((233, 154, 279, 200), fill=ink)
draw.polygon([(190, 195), (322, 195), (302, 570), (210, 570)], fill=shade, outline=ink)
draw.rounded_rectangle((145, 205, 205, 625), radius=25, fill=ink)
draw.rounded_rectangle((307, 205, 367, 625), radius=25, fill=ink)
draw.rounded_rectangle((206, 548, 252, 900), radius=20, fill=ink)
draw.rounded_rectangle((260, 548, 306, 900), radius=20, fill=ink)
draw.ellipse((170, 880, 252, 925), fill=ink)
draw.ellipse((260, 880, 342, 925), fill=ink)
if view == "front":
draw.ellipse((232, 108, 240, 116), fill=(230, 230, 230))
draw.ellipse((272, 108, 280, 116), fill=(230, 230, 230))
else: # rear guide: visible center-back seam, no facial marks
draw.line((256, 198, 256, 565), fill=ink, width=5)
out = io.BytesIO()
image.save(out, format="PNG")
return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii")
def _contain_image(raw: bytes, width: int, height: int, padding: int = 12) -> Image.Image:
panel = Image.new("RGB", (width, height), (246, 242, 234))
with Image.open(io.BytesIO(raw)) as source:
fitted = ImageOps.contain(source.convert("RGB"), (max(1, width - 2 * padding), max(1, height - 2 * padding)), Image.Resampling.LANCZOS)
panel.paste(fitted, ((width - fitted.width) // 2, (height - fitted.height) // 2))
return panel
def _compose_character_reference(images: dict[str, bytes], prop_count: int) -> str:
"""Assemble panels flush (no gutters, margins or dividers) without cropping or asking the model to draw a layout."""
canvas = Image.new("RGB", (1536, 1536), (246, 242, 234))
# The three poses are one seamless 3:2 image: pasted whole (aspect kept, no per-third crop and
# no inset), so a cape or sword reaching across a third boundary is not clipped or interrupted.
canvas.paste(_contain_image(images["turnaround"], 1536, 1024, 0), (0, 0))
if prop_count == 0:
scene_width = 1536
elif prop_count == 1:
scene_width = 1024
else:
scene_width = 768
scene = _contain_image(images["scene"], scene_width, 512, 0)
canvas.paste(scene, (0, 1024))
remaining = 1536 - scene_width
if prop_count:
tile_width = remaining // prop_count
for index in range(prop_count):
width = remaining - tile_width * index if index == prop_count - 1 else tile_width
panel = _contain_image(images[f"prop{index + 1}"], width, 512, 0)
x = scene_width + tile_width * index
canvas.paste(panel, (x, 1024))
# No divider lines between panels: they cut through capes and swords at the panel edges.
out = io.BytesIO()
canvas.save(out, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii")
def _graph_add_face_transfer(graph: dict) -> None:
"""Put the profile portrait's face onto the turnaround and the story panel (ReActor).
The turnaround and the story moment are text-to-image renders that never see the portrait,
so the face drifted between the profile picture and the sheet. Drawing them *from* the
portrait with the edit model was tried and reverted (three front views, pose guide copied
into the costume); swapping only the face after the text-to-image render keeps the poses and
fixes the identity. Turnaround: faces left to right (the rear view has none). Story panel:
the largest face, i.e. the character in the foreground rather than an extra.
"""
def swap(target: str, order: str, index: str, key: str) -> str:
graph[f"{key}_face_options"] = {"class_type": "ReActorOptions", "inputs": {
"input_faces_order": order, "input_faces_index": index, "detect_gender_input": "no",
"source_faces_order": "large-small", "source_faces_index": "0", "detect_gender_source": "no",
"console_log_level": 1, "restore_swapped_only": True,
}}
graph[f"{key}_face"] = {"class_type": "ReActorFaceSwapOpt", "inputs": {
"enabled": True, "input_image": [target, 0], "source_image": ["7", 0],
"swap_model": "inswapper_128.onnx", "facedetection": "retinaface_resnet50",
"face_restore_model": "codeformer-v0.1.0.pth", "face_restore_visibility": 1.0,
"codeformer_weight": 0.5, "options": [f"{key}_face_options", 0],
}}
return f"{key}_face"
if "turnaround_save" in graph and "turnaround_decode" in graph:
graph["turnaround_save"]["inputs"]["images"] = [swap("turnaround_decode", "left-right", "0,1", "turnaround"), 0]
if "scene_t2i_save" in graph and "scene_t2i_decode" in graph:
graph["scene_t2i_save"]["inputs"]["images"] = [swap("scene_t2i_decode", "large-small", "0", "scene"), 0]
def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str,
identity_image: str, profile_prompt: str,
front_prompt: str, side_prompt: str,
back_prompt: str, scene_prompt: str,
prop_prompts: list[str], style_key: str = "",
progress=None, face_transfer: bool = True) -> dict:
"""Generate independently controlled views, scene and unique prop tiles."""
try:
workflow = json.loads(workflow_json)
except Exception as exc:
raise HTTPException(400, f"Consistent-image workflow isn't valid JSON: {exc}")
graph = copy.deepcopy(workflow)
graph["4"].setdefault("inputs", {})["image"] = _comfyui_upload_data_uri(comfyui_url, identity_image)
graph["66"].setdefault("inputs", {})["text"] = front_prompt
graph["70"].setdefault("inputs", {})["text"] = scene_prompt
# Scene (71) and prop (24) negatives: see _LOWER_PANEL_NEGATIVE for why this alone is not enough.
for negative_node in ("71", "24"):
if negative_node in graph:
inputs = graph[negative_node].setdefault("inputs", {})
inputs["text"] = ", ".join(filter(None, (str(inputs.get("text") or "").strip(" ,"), _LOWER_PANEL_NEGATIVE)))
# Disconnect the failed image-edit camera branches. Structural camera and
# action changes are handled by native T2I branches below.
for old_output in ("front_save", "side_save", "back_save", "scene_save"):
graph.pop(old_output, None)
prop_prompts = prop_prompts[:3]
for index, (prompt_node, save_node) in enumerate((("23", "prop1_save"), ("50", "prop2_save"), ("54", "prop3_save"))):
if index < len(prop_prompts):
graph[prompt_node].setdefault("inputs", {})["text"] = prop_prompts[index]
else:
# A disconnected branch is not executed by ComfyUI. Empty slots
# therefore stay truly empty instead of becoming invented props.
graph.pop(save_node, None)
# Stable view-specific seeds keep the cast reproducible without forcing
# front, side and rear edits toward the same latent camera pose.
if style_key:
digest = hashlib.sha256(style_key.encode("utf-8")).digest()
shared_seed = int.from_bytes(digest[:8], "big")
else:
shared_seed = int(uuid.uuid4().int % 18446744073709551615)
def view_seed(label: str) -> int:
return int.from_bytes(
hashlib.sha256(f"{shared_seed}|{label}".encode("utf-8")).digest()[:8],
"big",
)
graph["68"].setdefault("inputs", {})["seed"] = view_seed("identity-turnaround")
for node_id, label in (("26", "prop-1"), ("52", "prop-2"), ("56", "prop-3")):
graph[node_id].setdefault("inputs", {})["seed"] = view_seed(label)
if face_transfer:
_graph_add_face_transfer(graph)
client_id = str(uuid.uuid4())
base = comfyui_url.rstrip("/")
try:
resp = requests.post(f"{base}/prompt", json={"prompt": graph, "client_id": client_id}, timeout=30)
if not resp.ok:
detail = resp.text[:500]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise HTTPException(resp.status_code, f"ComfyUI rejected the consistent-image workflow: {detail}")
prompt_id = resp.json().get("prompt_id")
if not prompt_id:
raise HTTPException(502, "ComfyUI didn't return a prompt_id")
except HTTPException:
raise
except requests.exceptions.RequestException as exc:
raise HTTPException(502, f"Cannot reach ComfyUI at {base}: {exc}")
# Qwen Image Edit runs the profile and the larger 3:2 sheet serially on
# this local GPU. At the current model/runtime speed a pair can exceed
# ten minutes; keep the HTTP request alive so a successful Comfy job is
# not reported as a misleading 502 just after it finishes.
start = time.time()
deadline = start + 1800
history = None
while time.time() < deadline:
if progress:
progress(time.time() - start)
try:
hr = requests.get(f"{base}/history/{prompt_id}", timeout=10)
if hr.ok:
data = hr.json()
if prompt_id in data:
history = data[prompt_id]
status = history.get("status", {})
if status.get("completed") is True or status.get("status_str") == "success":
break
if status.get("status_str") == "error":
msgs = status.get("messages", [])
raise HTTPException(502, f"ComfyUI consistent-image generation failed: {msgs[-1] if msgs else 'unknown error'}")
except HTTPException:
raise
except Exception:
pass
time.sleep(2)
if not history:
raise HTTPException(504, "ComfyUI consistent-image generation timed out after 30 minutes")
raw_images: dict[str, bytes] = {}
output_nodes = [
("profile", "profile_save"),
("turnaround", "turnaround_save"),
("scene", "scene_t2i_save"),
] + [(f"prop{index + 1}", f"prop{index + 1}_save") for index in range(len(prop_prompts))]
result: dict[str, str] = {}
for key, node_id in output_nodes:
images = (history.get("outputs", {}).get(node_id, {}) or {}).get("images") or []
if not images:
raise HTTPException(502, f"ComfyUI output '{node_id}' produced no image")
img = images[0]
try:
vr = requests.get(f"{base}/view", params={
"filename": img.get("filename", ""), "subfolder": img.get("subfolder", ""),
"type": img.get("type", "output"),
}, timeout=30)
vr.raise_for_status()
except Exception as exc:
raise HTTPException(502, f"Fetching the ComfyUI {key} image failed: {exc}")
raw_images[key] = vr.content
if key == "profile":
result["profile"] = f"data:{vr.headers.get('Content-Type', 'image/png')};base64,{base64.b64encode(vr.content).decode('ascii')}"
result["reference"] = _compose_character_reference(raw_images, len(prop_prompts))
# Internal: the job checks these, then drops them before anything is persisted or returned.
result["_panels"] = {
key: "data:image/png;base64," + base64.b64encode(raw_images[key]).decode("ascii")
for key in ("profile", "turnaround", "scene") if key in raw_images
}
return result
def _bundled_consistent_comfyui_workflow() -> str:
path = Path(__file__).resolve().parent.parent / "workflows" / "comfyui-character-consistent-images.json"
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise HTTPException(500, f"Bundled consistent-image workflow is unavailable: {exc}")
def _bundled_character_identity_workflow() -> str:
path = Path(__file__).resolve().parent.parent / "workflows" / "comfyui-character-identity-v3.json"
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise HTTPException(500, f"Bundled character-identity workflow is unavailable: {exc}")
@router.post("/api/character-image-from-url")
async def character_image_from_url(request: Request):
"""Download an image from a user-supplied URL server-side and return it
as a data: URI, so pasting a link works the same as an upload — fetching
an arbitrary third-party image directly from the browser would usually
fail on CORS, since most image hosts don't send permissive headers.
Body: {url}
Returns: {image: "data:image/...;base64,...."}
"""
data = await request.json()
url: str = (data.get("url") or "").strip()
if not url:
raise HTTPException(400, "Image URL required")
try:
resp = requests.get(url, timeout=30, headers={"User-Agent": "TTS-Voice-Creator/image-fetch"}, stream=True)
resp.raise_for_status()
ctype = resp.headers.get("Content-Type", "")
if not ctype.startswith("image/"):
raise HTTPException(400, f"That URL didn't return an image (got {ctype or 'unknown content type'})")
content = resp.raw.read(15 * 1024 * 1024, decode_content=True)
if not content:
raise HTTPException(502, "Empty response from that URL")
b64 = base64.b64encode(content).decode("ascii")
return {"image": f"data:{ctype};base64,{b64}"}
except HTTPException:
raise
except requests.exceptions.RequestException as e:
raise HTTPException(502, f"Couldn't fetch that URL: {e}")
@router.post("/api/character-generate-image")
async def character_generate_image(request: Request):
"""Generate a character profile picture from a text prompt via a cloud
image-gen provider (OpenAI, Google, OpenRouter) or a local ComfyUI
workflow.
Body: {prompt, provider?, model?} — provider/model default to the
Settings > Engines > Image Generation choice if not passed explicitly.
Returns: {image: "data:image/png;base64,...."}
"""
data = await request.json()
prompt: str = (data.get("prompt") or "").strip()
if not prompt:
raise HTTPException(400, "Image prompt required")
_settings = _load_settings()
keys: dict = _settings.get("engine_api_keys") or {}
provider: str = (data.get("provider") or _settings.get("image_gen_provider") or "").strip().lower()
model: str = (data.get("model") or _settings.get("image_gen_model") or "").strip()
if not provider:
raise HTTPException(400, "No image generation provider configured — set one in Settings > Engines > Image Generation")
# Every branch below does blocking requests.post/get (plus, for ComfyUI, a
# polling loop with time.sleep for up to 5 minutes on a real multi-stage
# workflow) with no asyncio.to_thread wrapper — unlike every other
# blocking call in this file. On a single-worker uvicorn process (see
# server.py) that blocked the ENTIRE event loop: every other user's
# request (TTS, page loads, /api/characters) would hang unresponsive for
# as long as one image generation took, which for a bulk "auto-generate
# images" run across a whole cast could be tens of minutes of site-wide
# freeze with no error, just silence. Runs in a worker thread instead.
image_kind = "concept_art" if str(data.get("image_kind") or "").strip().lower() in {"concept", "concept_art", "sheet", "character_sheet"} else "portrait"
book_context: str = (data.get("book_context") or "").strip()
prompt = _apply_character_image_rendering_style(prompt, image_kind, book_context)
result = await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings, image_kind)
try:
from core.database import generation_history_add
generation_history_add(
kind=f"character_{image_kind}", target_id=str(data.get("name") or "").strip(),
provider=provider, result={"model": model, "has_image": bool((result or {}).get("image"))},
)
except Exception as exc:
print(f"[generation-history] image audit failed: {exc}")
return result
@router.post("/api/character-generate-consistent-images")
async def character_generate_consistent_images(request: Request):
"""Generate a matching profile portrait and character reference sheet.
The identity image is face-cropped first so source backgrounds, extra
people, and unrelated props cannot leak into the result. The clean profile
output then becomes the identity source for the reference sheet, keeping
the face consistent across both deliverables.
"""
data = await request.json()
idempotency_key = str(request.headers.get("idempotency-key") or "").strip()
if len(idempotency_key) > 200:
raise HTTPException(400, "Idempotency-Key is too long")
if idempotency_key:
with _consistent_image_jobs_lock:
previous_job_id = _consistent_image_idempotency.get(idempotency_key)
if previous_job_id and previous_job_id in _consistent_image_jobs:
return {"job_id": previous_job_id, "deduplicated": True}
name = str(data.get("name") or "").strip()
book = str(data.get("book") or "").strip()
sheet: dict = _character_form_sheet(data.get("sheet") or {})
saved_profile_prompt = str(data.get("profile_prompt") or data.get("image_prompt") or "").strip()
saved_reference_prompt = str(data.get("reference_prompt") or data.get("concept_art_prompt") or "").strip()
book_profile: dict = data.get("book_profile") or {}
visual_style = str(data.get("visual_style") or "cinematic").strip().lower()
if visual_style not in {"cinematic", "concept"}:
visual_style = "cinematic"
if not saved_profile_prompt or not saved_reference_prompt:
raise HTTPException(400, "Both a profile image prompt and a character reference prompt are required")
settings = _load_settings()
comfy_url = str(settings.get("comfyui_url") or "").strip()
if not comfy_url:
raise HTTPException(400, "ComfyUI URL not set (Settings > Engines > Image Generation)")
# Rebuild the fixed production frame from structured data. Saved prompts
# stay usable as user-editable direction, but cannot override the book
# lock, headwear rule, camera, or turnaround layout.
portrait_master = _build_character_portrait_prompt(name, book, sheet, book_profile, visual_style)
sheet_master = _build_character_sheet_prompt(name, book, sheet, book_profile, visual_style)
# Prompts created before the standardized v3 masters may contain the old
# hats, modern clothing, variable layouts and conflicting art styles. Do
# not import those into the corrected render. Once a v3 prompt has been
# generated, user edits retain its marker and are honored here.
profile_direction = saved_profile_prompt if sheet.get("image_prompt_user_edited") is True and saved_profile_prompt.startswith(
"Task: Generate one historical-fantasy audiobook cast portrait."
) else ""
reference_direction = saved_reference_prompt if sheet.get("concept_art_prompt_user_edited") is True and saved_reference_prompt.startswith(
"Task: Generate one production character turnaround and equipment reference sheet."
) else ""
# The UI stores the full editable production masters. The local image
# encoder receives compact, layout-first versions so its limited context
# cannot truncate FRONT/SIDE/BACK or the period/headwear constraints.
profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts = _build_compact_character_render_prompts(
name, book, sheet, book_profile, visual_style,
profile_direction or portrait_master,
reference_direction or sheet_master,
)
# Do not perpetuate the modern hats, clothing, drinks, or scenery present
# in a bad legacy portrait. Reuse is opt-in only after visual review.
identity_image_input = str(data.get("identity_image") or "").strip() if data.get("reuse_identity") is True else ""
# Each click gets a fresh latent family so a rejected composition is not
# reproduced forever. The same family key is shared by the identity,
# turnaround, scene and prop branches to preserve within-run cohesion.
generation_key = f"voice-creator-cast-v6|{book}|{visual_style}|{name}|{uuid.uuid4().hex}"
# The whole render can legitimately take 10-20+ minutes on a local GPU
# (identity, then front/side/back/scene/props run serially). Returning a
# job id immediately and doing the actual work in a background thread —
# instead of blocking this request for the full duration — lets the UI
# poll /status and show live progress rather than a silent hung-looking
# fetch that a page reload would then throw away mid-render.
job_id = uuid.uuid4().hex
_cij_new(job_id, name or "Character")
with _consistent_image_jobs_lock:
_consistent_image_jobs[job_id]["retry_data"] = {
"name": name, "book": book, "sheet": _strip_image_payloads(sheet),
"profile_prompt": saved_profile_prompt, "reference_prompt": saved_reference_prompt,
"visual_style": visual_style, "book_profile": book_profile,
"identity_image": "", "reuse_identity": False,
}
if idempotency_key:
with _consistent_image_jobs_lock:
_consistent_image_idempotency[idempotency_key] = job_id
def _worker() -> None:
acquired = False
try:
for _ in range(900):
_cij_check_cancelled(job_id)
if _consistent_image_slots.acquire(timeout=1):
acquired = True
break
except _ConsistentImageCancelled:
_cij_set(job_id, stage="cancelled", detail="Cancelled", done=True,
cancelled=True, error="Generation cancelled")
with _consistent_image_jobs_lock:
_consistent_image_cancel_events.pop(job_id, None)
return
if not acquired:
_cij_set(job_id, stage="error", detail="Image queue timed out", done=True,
error="No image-generation slot became available within 15 minutes")
with _consistent_image_jobs_lock:
_consistent_image_cancel_events.pop(job_id, None)
return
try:
_cij_check_cancelled(job_id)
identity_image = identity_image_input
if identity_image.startswith("/"):
# Library records store same-origin image URLs rather than
# embedding large data URIs. Resolve those URLs server-side
# before uploading the image to ComfyUI.
_cij_set(job_id, stage="identity", detail="Loading existing identity image…")
try:
local_image = requests.get(f"http://127.0.0.1:7890{identity_image}", timeout=30)
local_image.raise_for_status()
identity_image = "data:" + (local_image.headers.get("Content-Type") or "image/png") + ";base64," + base64.b64encode(local_image.content).decode("ascii")
except Exception as exc:
raise RuntimeError(f"Could not load the existing identity image: {exc}")
# Just a heartbeat (refreshes "updated" so /status's stale_seconds doesn't fire a false
# "may be stuck" warning) — the elapsed counter itself is computed client-side.
heartbeat = lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id))
settings_now = _load_settings()
checking = settings_now.get("image_check_enabled", True) is not False
max_attempts = max(1, min(5, int(settings_now.get("image_check_max_attempts") or 3))) if checking else 1
verdicts: dict = {}
verified = checking
attempts_used = 0
# 1) The identity image, from which the profile picture is cut. It is checked first
# because every later panel takes its face from it.
if not identity_image:
best = None
for attempt in range(max_attempts):
_cij_check_cancelled(job_id)
_cij_set(job_id, stage="identity",
detail=f"Generating the identity portrait (attempt {attempt + 1} of {max_attempts})…")
candidate = _comfyui_run(
comfy_url, _bundled_character_identity_workflow(), "5", "text", "identity_save",
profile_prompt, f"{generation_key}|identity|{attempt}", progress=heartbeat,
)
if not checking:
best = (0, candidate, None)
break
_cij_set(job_id, stage="identity", detail=f"Checking the portrait (attempt {attempt + 1})…")
verdict = _image_check_run("portrait", [_profile_crop_uri(candidate)], sheet, settings_now)
if checker_unavailable(verdict):
best, verified = (0, candidate, verdict), False
break
score = len(verdict.get("failures") or [])
if best is None or score < best[0]:
best = (score, candidate, verdict)
if verdict.get("passed"):
break
identity_image = best[1]
if best[2] is not None:
verdicts["portrait"] = best[2]
# 2) The sheet follows the finished portrait: what it shows (hair, facial hair, eyes) goes
# into the turnaround and story prompts, and its face is transferred onto them.
sheet_front, sheet_scene = front_prompt, scene_prompt
if checking and verified:
look = _describe_portrait_look(_profile_crop_uri(identity_image), settings_now)
if look:
rebuilt = _build_compact_character_render_prompts(
name, book, sheet, book_profile, visual_style,
profile_direction or portrait_master, reference_direction or sheet_master, look)
sheet_front, sheet_scene = rebuilt[1], rebuilt[4]
best_sheet = None
for attempt in range(max_attempts):
_cij_check_cancelled(job_id)
attempts_used = attempt + 1
_cij_set(job_id, stage="render",
detail=f"Rendering turnaround, scene & props (attempt {attempt + 1} of {max_attempts})…")
candidate = _comfyui_run_consistent_images(
comfy_url, _bundled_consistent_comfyui_workflow(), identity_image,
profile_prompt, sheet_front, side_prompt, back_prompt, sheet_scene, prop_prompts,
f"{generation_key}|sheet|{attempt}", progress=heartbeat,
)
panels = candidate.pop("_panels", {})
if not (checking and verified):
best_sheet = (0, candidate, {})
break
_cij_set(job_id, stage="render", detail=f"Checking the character sheet (attempt {attempt + 1})…")
sheet_verdicts = _check_sheet_panels(panels, sheet, settings_now)
if any(checker_unavailable(v) for v in sheet_verdicts.values()):
# An unchecked sheet must never replace a previously validated character
# image. The renderer can drift into modern scenery or unrelated people even
# when the prompt contains the correct period and identity constraints; the
# checker-unavailable state is therefore a hard stop, not an implicit pass.
unavailable = next(
v for v in sheet_verdicts.values() if checker_unavailable(v)
)
raise RuntimeError(
"Character sheet validation is unavailable; the generated sheet was not saved. "
"Please restore the image checker and try again. "
+ "; ".join(str(f) for f in (unavailable.get("failures") or [])[:2])
)
score = failure_count(sheet_verdicts)
if best_sheet is None or score < best_sheet[0]:
best_sheet = (score, candidate, sheet_verdicts)
if score == 0:
break
result = best_sheet[1]
verdicts.update(best_sheet[2])
if checking:
result["image_check"] = summarise(verdicts, attempts_used, verified)
result = _consistent_image_result_metadata(sheet, result)
result = _persist_consistent_image_artifacts(job_id, result)
_cij_set(job_id, stage="done", detail="Done", done=True, result=result)
except HTTPException as exc:
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc.detail))
except _ConsistentImageCancelled:
_cij_set(job_id, stage="cancelled", detail="Cancelled", done=True,
cancelled=True, error="Generation cancelled")
except Exception as exc:
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc))
finally:
_consistent_image_slots.release()
with _consistent_image_jobs_lock:
_consistent_image_cancel_events.pop(job_id, None)
threading.Thread(target=_worker, daemon=True, name=f"consistent-images-{job_id}").start()
return {"job_id": job_id}
@router.get("/api/character-generate-consistent-images/status/{job_id}")
async def character_generate_consistent_images_status(job_id: str):
"""Poll target for the background job started above.
`result` stays null until `done` is true, so intermediate polls never ship
the multi-megabyte base64 image payload — only the final one does.
"""
with _consistent_image_jobs_lock:
job = _consistent_image_jobs.get(job_id)
payload = dict(job) if job is not None else None
if payload is None:
payload = background_job_get(job_id)
if payload is None:
raise HTTPException(404, "Unknown or expired job id")
now = time.time()
payload["elapsed"] = round(now - payload.pop("started", payload.get("created", now)), 1)
payload["stale_seconds"] = round(now - payload.pop("updated"), 1)
payload.pop("retry_data", None)
return payload
@router.post("/api/character-generate-consistent-images/cancel/{job_id}")
async def character_generate_consistent_images_cancel(job_id: str):
"""Request cooperative cancellation of a queued or running image job.
ComfyUI does not provide a safe per-client interrupt in all supported
versions, so the worker stops at its next stage/poll checkpoint without
interrupting another user's GPU job.
"""
with _consistent_image_jobs_lock:
job = _consistent_image_jobs.get(job_id)
event = _consistent_image_cancel_events.get(job_id)
if job is None:
raise HTTPException(404, "Unknown or expired job id")
if job.get("done"):
return {"ok": True, "already_done": True, "cancelled": bool(job.get("cancelled"))}
if event is not None:
event.set()
job.update({"stage": "cancelling", "detail": "Cancelling…", "updated": time.time()})
return {"ok": True, "cancelled": True}
@router.post("/api/character-generate-consistent-images/retry/{job_id}")
async def character_generate_consistent_images_retry(job_id: str):
with _consistent_image_jobs_lock:
job = _consistent_image_jobs.get(job_id)
retry_data = dict(job.get("retry_data") or {}) if job else {}
if job is None:
raise HTTPException(404, "Original image-generation input is no longer available")
if not job.get("done") or not job.get("error"):
raise HTTPException(409, "Only failed image jobs can be retried")
if not retry_data.get("profile_prompt") or not retry_data.get("reference_prompt"):
raise HTTPException(410, "Original image-generation input is no longer available")
return await character_generate_consistent_images(_ConsistentImageRetryRequest({**retry_data, "background": False}))
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict, image_kind: str = "portrait") -> dict:
if provider == "openai":
api_key = (keys.get("openai_image") or "").strip()
if not api_key:
raise HTTPException(400, "OpenAI API key not set (Settings > Engines > Image Generation)")
try:
resp = requests.post(
"https://api.openai.com/v1/images/generations",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": model or "gpt-image-1", "prompt": prompt, "size": "1024x1024", "n": 1},
timeout=120,
)
if not resp.ok:
detail = resp.text[:400]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise HTTPException(resp.status_code, f"OpenAI image generation failed: {detail}")
b64 = resp.json()["data"][0]["b64_json"]
return {"image": f"data:image/png;base64,{b64}"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"OpenAI image generation failed: {e}")
if provider == "google":
api_key = (keys.get("google_image") or "").strip()
if not api_key:
raise HTTPException(400, "Google API key not set (Settings > Engines > Image Generation)")
gmodel = model or "gemini-3.1-flash-image"
# Google retired the preview alias and moved image models to Interactions API.
if gmodel in ["gemini-2.5-flash-image-preview", "gemini-2.5-flash-image"]:
gmodel = "gemini-3.1-flash-image"
try:
api_url = "https://generativelanguage.googleapis.com/v1beta/interactions"
payload = {
"model": gmodel,
"input": [{"type": "user_input", "content": [{"type": "text", "text": prompt}]}],
"response_modalities": ["image"]
}
resp = requests.post(
api_url,
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
json=payload,
timeout=120,
)
# Fallback to the older generateContent endpoint if the model is not supported by Interactions API
if resp.status_code == 404:
api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{gmodel}:generateContent"
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"responseModalities": ["IMAGE"]},
}
resp = requests.post(
api_url,
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
json=payload,
timeout=120,
)
if not resp.ok:
detail = resp.text[:400]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise HTTPException(resp.status_code, f"Google image generation failed: {detail}")
payload_json = resp.json()
def _find_inline_data(obj):
if isinstance(obj, dict):
if "inlineData" in obj: return obj["inlineData"]
if "inline_data" in obj: return obj["inline_data"]
for k, v in obj.items():
res = _find_inline_data(v)
if res: return res
elif isinstance(obj, list):
for item in obj:
res = _find_inline_data(item)
if res: return res
return None
inline = _find_inline_data(payload_json)
if not inline:
# Preserve Google's useful finish/safety text in the error;
# this makes quota, blocked prompts, and unavailable models
# distinguishable from a client parsing bug.
raise HTTPException(502, f"Google returned no image data — check the selected image model, quota, or prompt.")
mime = inline.get("mimeType", "image/png")
return {"image": f"data:{mime};base64,{inline['data']}"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"Google image generation failed: {e}")
if provider == "openrouter":
# Reuses the same key as the OpenRouter LLM card (Settings > Engines >
# Language Models) — OpenRouter serves image-output models through
# the same chat/completions endpoint and account as text models,
# unlike OpenAI/Google where images are a separate API surface with
# their own key.
api_key = (keys.get("openrouter") or "").strip()
if not api_key:
raise HTTPException(400, "OpenRouter API key not set (Settings > Engines > Language Models > OpenRouter)")
omodel = model or "google/gemini-3.1-flash-image"
if omodel in ["google/gemini-2.5-flash-image-preview:free", "google/gemini-2.5-flash-image"]:
omodel = "google/gemini-3.1-flash-image"
try:
resp = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": omodel, "messages": [{"role": "user", "content": prompt}], "modalities": ["image", "text"]},
timeout=120,
)
if not resp.ok:
detail = resp.text[:400]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise HTTPException(resp.status_code, f"OpenRouter image generation failed: {detail}")
msg = (resp.json().get("choices") or [{}])[0].get("message", {})
# OpenRouter returns generated images in message.images (not the
# OpenAI Images API shape) — each entry is
# {"type": "image_url", "image_url": {"url": "data:...;base64,..."}}.
images = msg.get("images") or []
url = next((im.get("image_url", {}).get("url") for im in images if im.get("image_url", {}).get("url")), None)
if not url:
# Some models inline the image as a data URI in the text content instead.
content = msg.get("content")
text = content if isinstance(content, str) else " ".join(
p.get("text", "") for p in (content or []) if isinstance(p, dict)
)
m = re.search(r"data:image/\w+;base64,[A-Za-z0-9+/=]+", text or "")
url = m.group(0) if m else None
if not url:
raise HTTPException(502, "OpenRouter returned no image data — this model may not support image output, try another")
return {"image": url}
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"OpenRouter image generation failed: {e}")
if provider == "comfyui":
workflow_json = _settings.get("comfyui_workflow") or ""
prompt_node_id = (_settings.get("comfyui_prompt_node_id") or "").strip()
prompt_field = (_settings.get("comfyui_prompt_field") or "text").strip()
output_node_id = (_settings.get("comfyui_output_node_id") or "").strip()
# Advanced users may still paste their own API workflow. Otherwise
# use the paired local Flux workflows validated against ComfyUI.
# A legacy/custom workflow is normally a square portrait graph. Do
# not stretch that graph into a character sheet: concept art needs the
# dedicated 3:2 composition workflow to keep the turnaround layout
# readable. Portraits still honour the user's advanced override.
if image_kind == "concept_art":
workflow_json = ""
if not workflow_json:
workflow_json, prompt_node_id, prompt_field, output_node_id = _bundled_comfyui_workflow(image_kind)
image = _comfyui_run(
(_settings.get("comfyui_url") or "").strip(),
workflow_json,
prompt_node_id,
prompt_field,
output_node_id,
prompt,
)
return {"image": image}
if provider == "pollinations":
# Pollinations.ai — free, no API key, no account. A GET request that
# returns the image directly. Third-party public service: no SLA, no
# control over the model behind it, prompts leave the server — fine
# as a free stopgap, not a permanent guarantee.
pmodel = model or "flux"
last_err = ""
# The free queue (50 concurrent slots server-wide, shared across all
# of Pollinations' users) fills up under load and rejects with a
# transient error rather than queueing — a short retry clears most
# of these without the user having to click "Generate" again.
for attempt in range(3):
try:
resp = requests.get(
f"https://image.pollinations.ai/prompt/{quote(prompt)}",
params={"model": pmodel, "width": 1024, "height": 1024, "nologo": "true"},
timeout=120,
)
if resp.ok and resp.content:
b64 = base64.b64encode(resp.content).decode("ascii")
mime = resp.headers.get("Content-Type", "image/jpeg")
return {"image": f"data:{mime};base64,{b64}"}
last_err = resp.text[:300]
except Exception as e:
last_err = str(e)
if attempt < 2:
time.sleep(4)
raise HTTPException(502, f"Pollinations.ai request failed after retries: {last_err}")
raise HTTPException(400, f"Unknown image generation provider: {provider}")
def _attribution_prepare(data: dict) -> dict:
"""Resolve settings and build the chat payload for one attribution request.
Shared by the blocking endpoint and the streaming (watch-the-LLM-think)
endpoint so the prompt logic can never drift between the two."""
text: str = (data.get("text") or "").strip()
known: list = data.get("known_characters") or []
recent: str = (data.get("recent") or "").strip() # last few attributed lines, for continuity
language: str = (data.get("language") or "").strip()
_settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
timeout_seconds = _request_timeout_seconds(data.get("timeout_seconds"), 600.0)
if not text:
raise HTTPException(400, "No text provided")
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
prompt_override = data.get("audiobook_prompt")
if not isinstance(prompt_override, str):
prompt_override = data.get("prompt")
base_prompt = (prompt_override if isinstance(prompt_override, str) else "") or _settings.get("audiobook_prompt") or ""
# A user-saved custom prompt can predate (or have replaced) the deduction
# rules that cut down false Unknown/Narrator attributions — the client-side
# prompt migration only upgrades prompts still containing the original
# anchor text. Guarantee the rules ride along regardless of what prompt is
# in play, so attribution quality never silently depends on prompt history.
if base_prompt.strip() and "Doppelpunkt-Regel" not in base_prompt:
base_prompt += (
"\n\nANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (zusätzlich, immer anwenden):\n"
"1. Pronomen (er/sie) IMMER zum zuletzt genannten Charakter passenden Geschlechts auflösen.\n"
"2. Doppelpunkt-Regel: Endet ein Erzählersatz mit \":\", spricht dessen Subjekt das folgende Zitat "
"(z.B. \"Dann richtete er sich auf und rief in die Runde:\" → der zuvor genannte Charakter spricht).\n"
"3. Nachgestellte Zuordnung: Der Erzählersatz NACH einem Zitat verrät oft den Sprecher — auch bei "
"unpersönlicher Formel (\"»Was machst du da?« ertönte es über ihm. Karyla war herübergekommen.\" → Karyla sprach).\n"
"4. Adressaten-Regel: \"X wandte sich an Y\" / \"X sah Y an\" → X spricht das nächste Zitat, Y antwortet.\n"
"5. Ping-Pong: Zwei Personen im Gespräch wechseln sich strikt ab, auch über viele Zitate ohne Tags. "
"In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch.\n"
"6. Rollenbezeichnungen sind gültige Sprecher ('Ork', 'Der Fremde', 'Nachbar', 'Wächter') — nutze sie statt 'Unknown'.\n"
"7. 'Unknown' NUR, wenn eine Zuordnung trotz aller Regeln absolut unmöglich ist.\n"
"8. Gedankenstrich-Pause-Regel: Ein \" - \" MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, "
"kein Zitatende — die Rede desselben Charakters geht danach unverändert weiter, bis das tatsächliche "
"schließende Anführungszeichen erscheint.\n"
"9. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die "
"Stimme oder das (beginnende) Sprechen einer bestimmten Person — auch in indirekter/idiomatischer Form, "
"nicht nur mit einem wörtlichen Sprechverb (z.B. \"Marcians Stimme wirkte nicht mehr so fest\", \"X setzte "
"zum Sprechen an\", \"X fand als erster seine Stimme wieder\", \"ihre ersten Worte waren\", \"X brach das "
"Schweigen\"), gehört die folgende Zeile dieser Person — nicht 'Unknown'.\n"
"10. Selbstvorstellung: Nennt eine Zitat-Zeile selbst den Namen der sprechenden Person als Vorstellung "
"(z.B. \"Man nennt mich Andra\", \"Ich bin X\", \"Mein Name ist X\", \"Ich heiße X\"), ist diese genannte "
"Person die Sprecherin dieser Zeile — nicht 'Unknown', auch ohne separaten Sprecher-Tag."
)
# Appended to EVERY prompt, with its own anchor, and deliberately last so it
# overrides anything above it.
#
# The block above is skipped whenever a stored prompt already contains
# "Doppelpunkt-Regel", and the saved German prompt does — so neither it nor
# the English fallback below actually reaches the model on a configured
# instance. Both the stored prompt and the client default still carry
# "Bevorzuge immer einen genannten Charakter oder eine Rollenbezeichnung
# gegenüber 'Unknown'", which measurably produced 268 wrong names against
# only 4 Unknowns, and made role labels ("Der Fremde", "Schmied") into
# first-class cast members with their own voices. Correcting it in either
# of those places alone would leave every already-saved prompt biased, so
# the correction rides along unconditionally instead.
if "ADRESSATEN-VORRANG" not in base_prompt:
base_prompt += (
"\n\nVORRANG-REGELN (überschreiben alle vorherigen Regeln bei Widerspruch):\n"
"A. ADRESSATEN-VORRANG: Die Person, die in der Nähe eines Zitats genannt wird, ist sehr oft die "
"ANGESPROCHENE — nicht die sprechende. Prüfe ausdrücklich: zeigt der Text, dass diese Person SPRICHT "
"(Sprechverb, oder eine Handlung von ihr, die das Zitat rahmt), oder wird sie nur angesprochen, "
"angesehen, beantwortet oder beschrieben? Besonders die Hauptfigur zieht fälschlich Zeilen an sich, "
"die den Personen gehören, die MIT ihr sprechen — in beide Richtungen, sodass in einem Gespräch die "
"Zeilen zweier Figuren vertauscht werden. Halte den Sprecherwechsel über eine ganze Szene konsistent, "
"statt Zeile für Zeile neu zu entscheiden.\n"
"B. Ist der EINZIGE Anhaltspunkt für einen Namen, dass die Figur in der Nähe steht oder in der Szene "
"wichtig ist, gib 'Unknown' aus. Das überschreibt jede frühere Regel, die einen genannten Charakter "
"oder eine Rollenbezeichnung gegenüber 'Unknown' bevorzugt. Unsichere Zeilen werden markiert und sind "
"billig zu korrigieren; ein selbstsicher falscher Name ist es nicht, denn er gibt einer Figur "
"unbemerkt die Stimme einer anderen. Wende Sprecherwechsel und eindeutige Tags weiterhin zuerst an — "
"'Unknown' ist die Antwort bei echter Mehrdeutigkeit, kein Ersatz für Nachdenken.\n"
"C. Ist dieselbe Person im Text mit einem Eigennamen bekannt, benutze IMMER diesen Namen, nie eine "
"Rollenbezeichnung ('der Schmied', 'der Fremde', 'der Vampir'). Eine Rollenbezeichnung ist nur dann "
"ein gültiger Sprecher, wenn die Person im bisherigen Text überhaupt keinen Eigennamen hat."
)
if not base_prompt.strip():
base_prompt = (
"You attribute dialogue in prose fiction for a multi-voice audiobook. "
"Split the passage into consecutive segments in reading order. For each segment output:\n"
"- speaker: 'Narrator' for narration/description, or the character's name for spoken dialogue. "
"Use the exact English word 'Unknown' ONLY as an absolute last resort.\n"
"- type: 'narration' or 'dialogue'\n"
"- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration\n"
"- emotion: for dialogue, one or two words in the SAME LANGUAGE as the text (e.g. for German: wütend, traurig, flüsternd); '' for narration\n"
f"{lang_hint}\n"
"QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:\n"
" English straight \"...\" and curly “...”; German »...« (guillemets pointing inward) and „...“; "
"French «...» (pointing outward); single ‘...’; CJK 「...」 『...』; and em-dash speech where a line "
"starts with — or – (Spanish/French/Polish style).\n"
"PARAGRAPH/CHAPTER STRUCTURE: a blank line marks a real paragraph break or a chapter/scene start. "
"A very short standalone line right before a blank line (e.g. 'Chapter 1', 'Prologue', a bare number) "
"is a chapter heading — always narration/'Narrator', never dialogue. Keep blank lines as part of the "
"surrounding narration segment or their own narration segment; never invent dialogue from them and "
"never drop them from the reconstructed text.\n"
"German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.\n"
"ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):\n"
"1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns "
"(er/sie/he/she) to the actual name from nearby context.\n"
"2. UNTAGGED lines: use **conversational turn-taking**. In a two-person exchange the speaker ALTERNATES "
"every line — if Riskan just spoke, the next untagged quote is the other person, then back to Riskan, and so on.\n"
"3. Use the scene context, action beats around a quote (the person doing the action usually speaks), the "
"'Recent dialogue' below (continue the same conversation/alternation across the passage boundary), and the "
"known-characters list. Reuse the EXACT known names.\n"
"4. Only output 'Unknown' if the speaker is genuinely indeterminable even after applying turn-taking and "
"context. See the VORRANG-REGELN appended below, which take precedence.\n"
"RULES:\n"
"- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.\n"
"- GRAMMAR CHECK FOR NARRATION: if a fragment is a reporting clause about speech "
"(finite speech verb + speaker/pronoun/name, e.g. 'murmelte er mit erstickter Stimme', "
"', entgegnete Marcian kalt', 'asked Peter quietly'), it is narration, never dialogue.\n"
"- Action beats are narration: a character looks, walks, laughs, stays silent, raises a hand, "
"turns away, etc. Only the actual quoted words are dialogue.\n"
"- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts "
"into ONE dialogue segment ('Die Pause ist vorbei.') with the tag as a separate narration segment.\n"
"- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.\n"
"- DO NOT hallucinate, summarize, or alter the text. The combined text of your segments MUST exactly match the original passage, word for word, except for dropped quotation marks.\n"
"- MID-QUOTE DASH RULE: a \" - \" (em-dash/hyphen used as a pause) in the MIDDLE of a quotation does NOT "
"end it — the SAME speaker's line continues unchanged after the dash, until the actual closing quotation "
"mark appears. Do not split it into narration or a new speaker at the dash.\n"
"- VOICE-ANNOUNCEMENT RULE: if narration mentions a specific character's voice or that they are about to "
"speak, shortly before an unattributed line, attribute that line to that character instead of 'Unknown' "
"— this includes indirect/idiomatic phrasing, not just a literal speech verb (e.g. \"Marcian's voice "
"sounded...\", \"X began to say\", \"X found his voice first\", \"her first words were\", \"X broke the "
"silence\").\n"
"- SELF-INTRODUCTION RULE: if a quoted line itself names the speaker as an introduction (e.g. \"They "
"call me Andra\", \"I am X\", \"My name is X\"), that named person is the speaker of THIS line — never "
"'Unknown', even with no separate speaker tag."
)
else:
if lang_hint:
base_prompt += f"\n\n{lang_hint}"
# Only the streaming ("watch it think") call asks for a reasoning preamble —
# it costs extra tokens/latency, which the blocking/fallback path can't
# afford when it's already the retry-in-halves path for a slow model.
if data.get("want_reasoning"):
system = (
f"{base_prompt}\n\n"
"First, in a <think>...</think> block, briefly reason (2-4 short sentences) about who speaks each "
"untagged or ambiguous quote in this passage — mention the names/pronouns you're resolving. Keep it short.\n"
"Then, after </think>, respond with a single, valid JSON object containing a 'segments' array. "
"Nothing else outside the <think> block and the JSON object:\n"
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}'
)
else:
system = (
f"{base_prompt}\n\n"
"You MUST respond with a single, valid JSON object containing a 'segments' array.\n"
"Do NOT output any reasoning, chain of thought, or conversational text. Output ONLY the raw JSON object:\n"
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}'
)
user = (
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
+ ("Recent dialogue (the immediately preceding lines — continue the same conversation/turn-taking):\n" + recent + "\n\n" if recent else "")
+ "Passage to attribute:\n" + text + "\n\n"
+ "Return ONLY valid JSON. Do not include markdown blocks or any other text."
)
payload: dict = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.2,
# Dialogue-dense passages need much more output than input: every quoted
# line becomes a full JSON object ({"speaker":...,"type":...,"text":...,
# "emotion":...}), so budgeting off raw character count alone (as a
# tighter cap here once did) truncates mid-JSON on exchange-heavy scenes
# — which the parser can't always repair, degrading the whole chunk to
# naive quote-splitting with every speaker labelled "Unknown".
"max_tokens": min(8192, max(2048, len(text) + 1000)) + (250 if data.get("want_reasoning") else 0),
}
if model:
payload["model"] = model
return {
"payload": payload, "llm_url": llm_url, "model": model,
"api_key": _settings.get("llm_api_key") or "sk-dummy-key",
"timeout_seconds": timeout_seconds, "text": text,
"fallback_model": (_settings.get("llm_model") or "").strip(),
}
@router.post("/api/attribute-dialogue")
async def attribute_dialogue(request: Request):
"""Split a prose passage into attributed segments for a multi-voice audiobook.
Body: {text, known_characters:[...], language, llm_url, model}
Returns: {segments:[{speaker, type:"narration"|"dialogue", text, emotion}], characters:[names]}
The frontend calls this per chunk, passing the running character roster so the
same speaker keeps the same name across the whole book.
"""
data = await request.json()
prep = _attribution_prepare(data)
payload, llm_url, model = prep["payload"], prep["llm_url"], prep["model"]
timeout_seconds, text = prep["timeout_seconds"], prep["text"]
api_key = prep["api_key"]
try:
if not await asyncio.to_thread(_attribution_llm_lock.acquire, True, timeout_seconds):
return _fallback_attribute_response(text)
try:
resp = await asyncio.to_thread(
_post_llm_chat_completion,
llm_url, payload,
{"Authorization": f"Bearer {api_key}"}, timeout_seconds,
)
fallback_model = prep["fallback_model"]
if (
resp.status_code >= 400
and _is_router_model_alias(model)
and fallback_model
and fallback_model != model
and not _is_router_model_alias(fallback_model)
):
retry_payload = dict(payload)
retry_payload["model"] = fallback_model
resp = await asyncio.to_thread(
_post_llm_chat_completion,
llm_url, retry_payload,
{"Authorization": f"Bearer {api_key}"}, timeout_seconds,
)
payload = retry_payload
finally:
_attribution_llm_lock.release()
if resp.status_code >= 400:
print(
f"[attribute-dialogue] LLM failed ({resp.status_code}) for model "
f"{payload.get('model') or '(default)'}: {_response_error_text(resp)}"
)
return _fallback_attribute_response(text)
msg = resp.json()["choices"][0]["message"]
raw = (msg.get("content") or _reasoning_text(msg) or "").strip()
except HTTPException:
raise
except Exception as e:
print(f"[attribute-dialogue] LLM failed, using deterministic fallback: {e}")
return _fallback_attribute_response(text)
return _attribution_parse(raw, text)
def _attribution_parse(raw: str, text: str) -> dict:
"""Turn the LLM's raw answer into normalised {segments, characters}."""
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
segments = []
block = _extract_json_block(content)
candidates = [content, block]
if block:
# If the JSON was truncated by token limits, appending ]} to the last complete object often salvages it.
candidates.append(block + "]}")
for cand in candidates:
if not cand:
continue
try:
parsed = json.loads(cand)
if isinstance(parsed, dict) and isinstance(parsed.get("segments"), list):
segments = parsed["segments"]
break
except Exception:
continue
if not segments:
# If the LLM completely failed to output JSON despite the strict system prompt,
# it means it dropped into a conversational/reasoning hallucination.
# Do NOT try to parse its reasoning as a script. Abort and preserve the book text.
segments = [{"speaker": "Narrator", "type": "narration", "text": text, "emotion": ""}]
segments = _repair_unclosed_dialogue_segments(segments)
# Normalise + collect speaker roster
clean, chars = [], []
for seg in segments:
if not isinstance(seg, dict):
continue
t = str(seg.get("text") or "").strip()
if not t:
continue
sp = str(seg.get("speaker") or "Narrator").strip() or "Narrator"
typ = "dialogue" if str(seg.get("type") or "").lower().startswith("dial") else "narration"
if typ == "narration":
sp = "Narrator"
elif sp.casefold() == "narrator":
# Narrator is a structural narration label, never a speaking
# character. Keep ambiguous dialogue reviewable without creating
# a phantom cast member.
sp = "Unknown"
emo = str(seg.get("emotion") or "").strip()
clean.append({"speaker": sp, "type": typ, "text": t, "emotion": emo})
if typ == "dialogue" and sp.lower() != "narrator" and sp not in chars:
chars.append(sp)
return {"segments": clean, "characters": chars}
def _span_llm_answer(payload: dict, llm_url: str, model: str, api_key: str, timeout_seconds: float, fallback_model: str) -> str:
"""One blocking chat-completion for span attribution; returns the raw text
or raises RuntimeError. Serialised with every other attribution call."""
if not _attribution_llm_lock.acquire(True, timeout_seconds):
raise RuntimeError("Attribution engine busy")
try:
headers = {"Authorization": f"Bearer {api_key}"}
resp = _post_llm_chat_completion(llm_url, payload, headers, timeout_seconds)
if resp.status_code in (400, 422) and "chat_template_kwargs" in payload:
# A backend that rejects the (Qwen-style) thinking switch: retry without it.
payload = {k: v for k, v in payload.items() if k != "chat_template_kwargs"}
resp = _post_llm_chat_completion(llm_url, payload, headers, timeout_seconds)
if (
resp.status_code >= 400 and _is_router_model_alias(model) and fallback_model
and fallback_model != model and not _is_router_model_alias(fallback_model)
):
resp = _post_llm_chat_completion(llm_url, dict(payload, model=fallback_model), headers, timeout_seconds)
finally:
_attribution_llm_lock.release()
if resp.status_code >= 400:
raise RuntimeError(f"LLM HTTP {resp.status_code}: {_response_error_text(resp)[:200]}")
msg = resp.json()["choices"][0]["message"]
return (msg.get("content") or _reasoning_text(msg) or "").strip()
@router.post("/api/attribute-spans")
async def attribute_spans(request: Request):
"""Attribute speakers for one window of pre-segmented spans.
Body: {spans:[{id, kind:"narration"|"dialogue", text}], known_characters, recent,
following, language, llm_url, model, timeout_seconds, want_reasoning}
Returns: {speakers:{id:{speaker, emotion}}, missing:[ids], characters:[names]}
or {failed:true, error} when the LLM is unavailable (the client then falls back
to tag-based guessing). The text itself is never sent back: the caller already
holds it, so the LLM cannot alter it.
"""
data = await request.json()
if not isinstance(data, dict):
raise HTTPException(400, "Body must be an object")
try:
spans = _span_validate(data.get("spans"))
except SpanRequestError as exc:
raise HTTPException(400, str(exc))
known = [str(n) for n in (data.get("known_characters") or []) if isinstance(n, (str, int))][:80]
recent = str(data.get("recent") or "")[:4000]
following = str(data.get("following") or "")[:1500]
language = str(data.get("language") or "")[:40]
want_reasoning = bool(data.get("want_reasoning"))
only = _span_validate_only_ids(data.get("attribute_only"), spans) or None
settings = _load_settings()
llm_url = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model = (data.get("model") or settings.get("llm_model") or "").strip()
timeout_seconds = _request_timeout_seconds(data.get("timeout_seconds"), 600.0)
api_key = settings.get("llm_api_key") or "sk-dummy-key"
fallback_model = (settings.get("llm_model") or "").strip()
def ask(only_ids=None, token_boost: int = 0) -> dict:
only_ids = only_ids or only
system, user = _span_build_messages(spans, known, recent, following, language, want_reasoning, only_ids)
payload: dict = {
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
"temperature": 0.1,
"max_tokens": _span_max_tokens(spans, want_reasoning) + token_boost,
}
if not want_reasoning:
# Who-speaks-this is a short lookup: skip the model's chain of thought
# (roughly 8x faster). Reasoning stays available via want_reasoning.
payload["chat_template_kwargs"] = {"enable_thinking": False}
if model:
payload["model"] = model
raw = _span_llm_answer(payload, llm_url, model, api_key, timeout_seconds, fallback_model)
result = _span_parse_response(raw, spans)
asked = only_ids or None
if asked: # lines that were not requested are not "missing"
result["missing"] = [i for i in result["missing"] if i in asked]
return result
try:
result = await asyncio.to_thread(ask)
if not result["speakers"]:
# Nothing parsed at all: a backend that ignores the thinking switch spends the
# whole budget on reasoning. Give it room to finish once.
result = await asyncio.to_thread(ask, None, 3000)
requested = len(only) if only else sum(1 for s in spans if s["kind"] == "dialogue")
if result["missing"] and len(result["missing"]) < requested:
# Partial answer (truncated or skipped lines): one focused retry for just those lines.
retry = await asyncio.to_thread(ask, list(result["missing"]))
result["speakers"].update({k: v for k, v in retry["speakers"].items() if k not in result["speakers"]})
result["missing"] = [i for i in result["missing"] if i not in result["speakers"]]
result["characters"] = retry["characters"] + [c for c in result["characters"] if c not in retry["characters"]]
return result
except Exception as exc:
print(f"[attribute-spans] LLM failed: {exc}")
return {"speakers": {}, "missing": [s["id"] for s in spans if s["kind"] == "dialogue"], "characters": [], "failed": True, "error": str(exc)[:200]}
@router.post("/api/attribute-dialogue/stream")
async def attribute_dialogue_stream(request: Request):
"""Same job as /api/attribute-dialogue, but streams the LLM's live output
(reasoning + the JSON being written) as SSE `{"t": "..."}` events so the UI
can show what the model is thinking, ending with `{"done": true, "result"}`.
On upstream failure it emits `{"error": "..."}` — the client then falls
back to the blocking endpoint."""
data = await request.json()
prep = _attribution_prepare(data)
payload = dict(prep["payload"])
payload["stream"] = True
headers = {"Authorization": f"Bearer {prep['api_key']}"}
def gen():
if not _attribution_llm_lock.acquire(timeout=prep["timeout_seconds"]):
yield 'data: {"error": "Attribution engine busy"}\n\n'
return
upstream = None
try:
urls = _llm_chat_completion_urls(prep["llm_url"])
last_err = None
for u in urls:
try:
upstream = requests.post(
u, json=payload, headers=headers,
timeout=(15, prep["timeout_seconds"]), stream=True,
)
if upstream.status_code in (404, 405) and u != urls[-1]:
upstream.close()
upstream = None
continue
break
except Exception as exc:
last_err = exc
upstream = None
if upstream is None:
print(f"[attribute-dialogue/stream] LLM connect failed: {last_err}")
yield f'data: {json.dumps({"error": str(last_err or "LLM connect failed")})}\n\n'
return
if upstream.status_code >= 400:
print(
f"[attribute-dialogue/stream] LLM failed ({upstream.status_code}) for model "
f"{payload.get('model') or '(default)'}: {_response_error_text(upstream)[:300]}"
)
yield f'data: {json.dumps({"error": f"HTTP {upstream.status_code}: {_response_error_text(upstream)[:300]}"})}\n\n'
return
raw_parts = []
# The LLM's SSE response usually omits a charset on its Content-Type,
# so requests falls back to Latin-1 (the old HTTP default) instead of
# UTF-8 when decode_unicode=True guesses the encoding — every umlaut
# then comes out mojibake'd ("Häfen" -> "Häfen"). Force UTF-8.
upstream.encoding = "utf-8"
# Two layers against a stuck stream: the wall-clock check catches
# a model that keeps trickling chunks past its budget, and
# _watchdog_close catches the connection going fully silent (which
# the in-loop check can't see, since a blocked socket read never
# reaches it — see _watchdog_close's docstring for why). Observed
# in production without the watchdog: a stuck stream held the
# shared lock for 11+ minutes, silently starving every other
# passage/request.
deadline = time.monotonic() + prep["timeout_seconds"]
with _watchdog_close(upstream, prep["timeout_seconds"]):
for line in upstream.iter_lines(decode_unicode=True):
if time.monotonic() > deadline:
print(f"[attribute-dialogue/stream] wall-clock deadline hit after {prep['timeout_seconds']}s, aborting stream")
yield 'data: {"error": "LLM stream exceeded timeout"}\n\n'
return
if not line or not line.startswith("data:"):
continue
chunk = line[5:].strip()
if chunk == "[DONE]":
break
try:
delta = json.loads(chunk)["choices"][0]["delta"]
except Exception:
continue
# Reasoning models emit thoughts via reasoning_content (or, on
# some vLLM builds, a plain "reasoning" key — see _reasoning_text);
# the answer JSON arrives via content. Forward both to the
# viewer, but only content counts toward the parseable answer.
t = _reasoning_text(delta) or delta.get("content") or ""
if delta.get("content"):
raw_parts.append(delta["content"])
if t:
yield f'data: {json.dumps({"t": t})}\n\n'
result = _attribution_parse("".join(raw_parts), prep["text"])
yield f'data: {json.dumps({"done": True, "result": result})}\n\n'
except GeneratorExit:
raise
except Exception as e:
yield f'data: {json.dumps({"error": str(e)})}\n\n'
finally:
try:
if upstream is not None:
upstream.close()
except Exception:
pass
_attribution_llm_lock.release()
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# ── Audio effects ─────────────────────────────────────────────────────────────
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
try:
from pedalboard import Pedalboard, Reverb, Chorus, Delay, Compressor, Gain, HighpassFilter, LowpassFilter, PitchShift, Limiter # type: ignore
import numpy as np # type: ignore
except ImportError:
raise RuntimeError("pedalboard is not installed — run: pip install pedalboard numpy")
with io.BytesIO(audio_bytes) as buf:
with wave.open(buf, "rb") as wf:
n_channels = wf.getnchannels()
sample_rate = wf.getframerate()
n_frames = wf.getnframes()
raw = wf.readframes(n_frames)
sampwidth = wf.getsampwidth()
import numpy as np # noqa: F811
dtype = {1: np.int8, 2: np.int16, 4: np.int32}.get(sampwidth, np.int16)
samples = np.frombuffer(raw, dtype=dtype).astype(np.float32) / float(np.iinfo(dtype).max)
samples = samples.reshape(1, -1) if n_channels == 1 else samples.reshape(-1, n_channels).T
board = []
for fx in effects:
t = fx.get("type", "")
p = fx.get("params", {})
if t == "reverb":
board.append(Reverb(
room_size=float(p.get("room_size", 0.35)),
damping=float(p.get("damping", 0.5)),
wet_level=float(p.get("wet", 0.25)),
dry_level=float(p.get("dry", 0.8)),
))
elif t == "chorus":
board.append(Chorus(
rate_hz=float(p.get("rate_hz", 1.0)),
depth=float(p.get("depth", 0.25)),
mix=float(p.get("mix", 0.5)),
))
elif t == "delay":
board.append(Delay(
delay_seconds=float(p.get("delay_s", 0.25)),
feedback=float(p.get("feedback", 0.3)),
mix=float(p.get("mix", 0.4)),
))
elif t == "compressor":
threshold_db = float(p.get("threshold_db", -20.0))
ratio = float(p.get("ratio", 4.0))
board.append(Compressor(
threshold_db=threshold_db,
ratio=ratio,
attack_ms=float(p.get("attack_ms", 10.0)),
release_ms=float(p.get("release_ms", 100.0)),
))
# A pure compressor with no makeup gain only ever shaves peaks
# quieter — it never produces the louder, "punchier" sound people
# actually associate with compression (broadcast/telephone/radio
# effects). Confirmed live: applying the "Telephone" preset
# (threshold -10dB, ratio 8:1) to normal TTS speech (normalized to
# ~-20dBFS) changed the output by less than 0.1% RMS — with
# nothing pushing the result back up, a compressor engaging only
# on brief peaks is nearly imperceptible over a whole clip. Makeup
# gain restores perceived loudness to roughly what an
# unprocessed signal peaking at the threshold would have, which
# is the standard way compressors are actually used.
makeup_db = float(p.get("makeup_db", -threshold_db * (1 - 1 / max(ratio, 1.0)) * 0.5))
if makeup_db:
board.append(Gain(gain_db=makeup_db))
# Verified live: makeup gain routinely pushed peaks to ~1.9
# (well past ±1.0), and the hard np.clip() at the end of this
# function turned that into audible digital clipping —
# broadband harmonic distortion that swamped whatever the
# rest of the chain (e.g. telephone bandpass) was supposed to
# sound like. A limiter catches the overshoot smoothly
# instead of slicing it off.
board.append(Limiter(threshold_db=-1.0, release_ms=100.0))
elif t == "gain":
board.append(Gain(gain_db=float(p.get("gain_db", 0.0))))
elif t == "highpass":
# pedalboard's HighpassFilter is a single-pole design (~6 dB/octave)
# — confirmed live it was too gentle to meaningfully shape a full-
# bandwidth voice recording (e.g. barely touched content an octave
# above the cutoff). Cascading 3 independent stages gives a much
# steeper, actually audible roll-off (~18 dB/octave).
cutoff = float(p.get("cutoff_hz", 80.0))
board.extend(HighpassFilter(cutoff_frequency_hz=cutoff) for _ in range(3))
elif t == "lowpass":
cutoff = float(p.get("cutoff_hz", 8000.0))
board.extend(LowpassFilter(cutoff_frequency_hz=cutoff) for _ in range(3))
elif t == "pitch_shift":
board.append(PitchShift(semitones=float(p.get("semitones", 0.0))))
if board:
samples = Pedalboard(board)(samples, sample_rate)
out = np.clip(samples, -1.0, 1.0)
pcm = ((out[0] if out.shape[0] == 1 else out.T.reshape(-1)) * 32767).astype(np.int16).tobytes()
buf_out = io.BytesIO()
with wave.open(buf_out, "wb") as wf:
wf.setnchannels(n_channels)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(pcm)
return buf_out.getvalue()
@router.post("/api/audio/effects")
async def audio_effects(request: Request):
"""Apply an effects chain to a WAV file."""
form = await request.form()
audio_file = form.get("audio")
effects_json = str(form.get("effects") or "[]")
if audio_file is None:
raise HTTPException(400, "No audio file provided")
audio_bytes = await audio_file.read()
try:
effects = json.loads(effects_json)
except Exception:
raise HTTPException(400, "Invalid effects JSON")
try:
result = await asyncio.to_thread(_apply_audio_effects, audio_bytes, effects)
return Response(content=result, media_type="audio/wav")
except RuntimeError as e:
raise HTTPException(501, str(e))
except Exception as e:
raise HTTPException(500, f"Effects processing failed: {e}")
# ── Voices export / import ────────────────────────────────────────────────────
_EXPORT_SKIP_KEYS = {
"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key",
"elevenlabs_api_key", "llm_api_key", "engine_api_keys",
}
_IMPORT_ALLOWED_SUFFIXES = set(_AUDIO_EXTS + [".reference.txt", ".meta.json", ".jpg", ".jpeg", ".png", ".webp"])
@router.get("/api/voices/export")
async def voices_export():
"""Export all voices + non-sensitive settings as a ZIP archive."""
import zipfile
from datetime import datetime
settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
safe_settings = {k: v for k, v in settings.items() if k not in _EXPORT_SKIP_KEYS}
zf.writestr("settings.json", json.dumps(safe_settings, indent=2))
if scan_dir.exists():
for f in scan_dir.rglob("*"):
if f.is_file():
try:
zf.write(f, str(f.relative_to(scan_dir.parent)))
except Exception:
pass
buf.seek(0)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return Response(
content=buf.read(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="voices_export_{ts}.zip"'},
)
@router.post("/api/voices/import")
async def voices_import(file: UploadFile = File(...)):
"""Import voices from a ZIP archive (skips settings.json and unsafe paths)."""
import zipfile
settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
scan_dir.mkdir(parents=True, exist_ok=True)
content = await file.read()
if len(content) > _MAX_UPLOAD_BYTES:
raise HTTPException(413, "ZIP file too large")
try:
imported = 0
with zipfile.ZipFile(io.BytesIO(content)) as zf:
for info in zf.infolist():
if info.is_dir():
continue
archive_name = str(info.filename or "")
parts = tuple(archive_name.replace("\\", "/").split("/"))
if (not archive_name or archive_name.startswith(("/", "\\"))
or any(p in ("..", "") for p in parts)
or any(ord(char) < 32 or ord(char) == 127 for char in archive_name)
or Path(parts[-1]).name == "settings.json"):
continue
if Path(parts[-1]).suffix.lower() not in _IMPORT_ALLOWED_SUFFIXES:
continue
rel = parts[1:] if len(parts) > 1 else parts
dest = scan_dir / Path(*rel)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(info))
imported += 1
except zipfile.BadZipFile:
raise HTTPException(400, "Not a valid ZIP file")
except Exception as e:
raise HTTPException(500, f"Import failed: {e}")
await asyncio.to_thread(rebuild_voice_index, settings)
return {"ok": True, "imported": imported}
# ── /speak REST endpoint ──────────────────────────────────────────────────────
@router.post("/speak")
async def speak(request: Request):
data = await request.json()
text: str = str(data.get("text") or "").strip()
if not text:
raise HTTPException(400, "text is required")
explicit_voice: str = str(data.get("voice") or data.get("profile_id") or data.get("profile") or "").strip()
backend: str = _clean_preview_backend(str(data.get("backend") or "voice_clone"))
apply_persona: bool = bool(data.get("apply_persona") or data.get("personality"))
client_id: str = request.headers.get("X-Voice-Creator-Client-Id", "").strip()
settings = _load_settings()
voice = _resolve_speak_voice(settings, client_id, explicit_voice)
if not voice:
raise HTTPException(400, "No voice specified and no default voice configured")
if apply_persona:
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
wav = _find_voice_audio(voice, scan_dir)
if wav:
persona = _load_meta(wav).get("persona", "")
if persona:
llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
llm_model = settings.get("llm_model") or ""
try:
text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url, llm_model)
except Exception as e:
raise HTTPException(502, f"Persona rewrite failed: {e}")
instruct = str(data.get("instruct") or "")
try:
audio, media_type = await asyncio.to_thread(
_preview_request_audio, text, voice, settings, instruct, backend
)
except Exception as e:
raise HTTPException(502, f"TTS error: {e}")
return Response(content=audio, media_type=media_type)
@router.get("/speak/bindings")
async def get_speak_bindings():
s = _load_settings()
return {"bindings": s.get("client_voice_bindings") or {}}
@router.put("/speak/bindings/{client_id}")
async def put_speak_binding(client_id: str, request: Request):
data = await request.json()
voice = str(data.get("voice") or "").strip()
if not voice:
raise HTTPException(400, "voice is required")
s = _load_settings()
bindings: dict = dict(s.get("client_voice_bindings") or {})
bindings[client_id] = voice
s["client_voice_bindings"] = bindings
_save_settings(s)
return {"ok": True, "client_id": client_id, "voice": voice}
@router.delete("/speak/bindings/{client_id}")
async def delete_speak_binding(client_id: str):
s = _load_settings()
bindings: dict = dict(s.get("client_voice_bindings") or {})
if client_id not in bindings:
raise HTTPException(404, f"No binding for client '{client_id}'")
del bindings[client_id]
s["client_voice_bindings"] = bindings
_save_settings(s)
return {"ok": True, "client_id": client_id}
# ── MCP JSON-RPC 2.0 server ───────────────────────────────────────────────────
_MCP_SERVER_INFO = {"name": "tts-voice-creator", "version": "1.0.0"}
_MCP_TOOLS = [
{
"name": "speak",
"description": "Generate speech audio from text using a cloned voice. Returns a data URI with the WAV audio.",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Text to synthesize"},
"voice": {"type": "string", "description": "Voice ID (optional — uses per-client binding or default if omitted)"},
"apply_persona": {"type": "boolean", "description": "Rewrite text through the voice's persona before synthesis"},
},
"required": ["text"],
},
},
{
"name": "transcribe",
"description": "Transcribe base64-encoded WAV audio to text.",
"inputSchema": {
"type": "object",
"properties": {
"audio_base64": {"type": "string", "description": "Base64-encoded audio bytes (WAV preferred)"},
},
"required": ["audio_base64"],
},
},
{
"name": "list_captures",
"description": "List the 20 most recently generated audio files.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "list_profiles",
"description": "List all available voice profiles with their language, persona, and enabled state.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "list_books",
"description": "List all Read Aloud books/documents (title, id, character count metadata).",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "list_characters",
"description": "List character library records, optionally filtered to one book/production.",
"inputSchema": {
"type": "object",
"properties": {"book": {"type": "string", "description": "Book/production title to filter by (optional — omit for every character across every book)"}},
},
},
{
"name": "get_character",
"description": "Get one character's full record (sheet, voice, image, tags) by id.",
"inputSchema": {
"type": "object",
"properties": {"id": {"type": "string", "description": "Character record id"}},
"required": ["id"],
},
},
{
"name": "update_character",
"description": "Update fields on a character's sheet (e.g. backstory, archetype, gender) or top-level record fields (name, voice, tags). Merges with the existing record — only send the fields you want to change.",
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Character record id"},
"sheet": {"type": "object", "description": "Partial sheet fields to merge in (e.g. {\"backstory\": \"...\", \"archetype\": \"...\"})"},
"voice": {"type": "string", "description": "Voice id to assign (shortcut for updating just the voice)"},
"name": {"type": "string", "description": "Rename the character (rare — usually leave unset)"},
},
"required": ["id"],
},
},
{
"name": "list_rehearsals",
"description": "List all saved Script Rehearser sessions (title, id, character/line counts).",
"inputSchema": {"type": "object", "properties": {}},
},
]
async def _mcp_tool_speak(args: dict, client_id: str) -> dict:
text = str(args.get("text") or "").strip()
if not text:
raise ValueError("text is required")
explicit_voice = str(args.get("voice") or "").strip()
apply_persona = bool(args.get("apply_persona", False))
settings = _load_settings()
voice = _resolve_speak_voice(settings, client_id, explicit_voice)
if not voice:
raise ValueError("No voice specified and no default voice configured")
if apply_persona:
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
wav = _find_voice_audio(voice, scan_dir)
if wav:
persona = _load_meta(wav).get("persona", "")
if persona:
llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
llm_model = settings.get("llm_model") or ""
text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url, llm_model)
audio, _media_type = await asyncio.to_thread(
_preview_request_audio, text, voice, settings, "", "voice_clone"
)
audio_b64 = base64.b64encode(audio).decode()
return {
"content": [
{"type": "text", "text": f"Generated {len(audio)} bytes of speech audio for voice '{voice}'."},
{"type": "resource", "resource": {"uri": f"data:audio/wav;base64,{audio_b64}", "mimeType": "audio/wav"}},
]
}
async def _mcp_tool_transcribe(args: dict) -> dict:
raw = args.get("audio_base64") or ""
try:
audio_bytes = base64.b64decode(raw)
except Exception:
raise ValueError("audio_base64 is not valid base64")
tmp = TEMP_DIR / f"{uuid.uuid4().hex}_mcp_transcribe.wav"
tmp.write_bytes(audio_bytes)
settings = _load_settings()
try:
text, used_backend = await asyncio.to_thread(_transcribe_audio, tmp, settings, "configured")
finally:
try:
tmp.unlink(missing_ok=True)
except Exception:
pass
return {"content": [{"type": "text", "text": text}], "backend": used_backend}
async def _mcp_tool_list_captures() -> dict:
settings = _load_settings()
out_dir = _active_voices_dir(settings)
if not out_dir.exists():
return {"content": [{"type": "text", "text": "[]"}]}
files = sorted(
(p for p in out_dir.rglob("*.wav") if not _is_internal_voice_file(p)),
key=lambda p: p.stat().st_mtime,
reverse=True,
)[:20]
result = [{"name": p.stem, "path": str(p), "mtime": p.stat().st_mtime} for p in files]
return {"content": [{"type": "text", "text": json.dumps(result)}]}
async def _mcp_tool_list_profiles() -> dict:
settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
profiles = []
seen: set[str] = set()
for audio in sorted(_voice_audio_files(scan_dir), key=lambda p: p.stem.lower()):
if audio.stem in seen:
continue
seen.add(audio.stem)
meta = _load_meta(audio)
parts = audio.stem.split("_", 2)
lang = parts[0].upper() if parts else ""
profiles.append({
"id": audio.stem,
"name": audio.stem,
"lang": lang,
"persona": meta.get("persona", ""),
"enabled": meta.get("enabled", True),
})
return {"content": [{"type": "text", "text": json.dumps(profiles)}]}
async def _mcp_tool_list_books() -> dict:
from routes.reader import reader_list_docs
data = await reader_list_docs()
return {"content": [{"type": "text", "text": json.dumps(data.get("docs", []))}]}
async def _mcp_tool_list_characters(args: dict) -> dict:
from core.database import char_get_all
book = str(args.get("book") or "").strip().lower()
recs = char_get_all()
if book:
recs = [r for r in recs if str(r.get("book") or "").strip().lower() == book
or book in [t.strip().lower() for t in str(r.get("tags") or "").split(",")]]
return {"content": [{"type": "text", "text": json.dumps(recs)}]}
async def _mcp_tool_get_character(args: dict) -> dict:
from core.database import char_get
char_id = str(args.get("id") or "").strip()
if not char_id:
raise ValueError("id is required")
rec = char_get(char_id)
if rec is None:
raise ValueError(f"Character '{char_id}' not found")
return {"content": [{"type": "text", "text": json.dumps(rec)}]}
async def _mcp_tool_update_character(args: dict) -> dict:
from core.database import char_get, char_put
char_id = str(args.get("id") or "").strip()
if not char_id:
raise ValueError("id is required")
rec = char_get(char_id)
if rec is None:
raise ValueError(f"Character '{char_id}' not found")
if "sheet" in args and isinstance(args["sheet"], dict):
rec["sheet"] = {**(rec.get("sheet") or {}), **args["sheet"]}
if "voice" in args:
rec["voice"] = args["voice"]
if "name" in args:
rec["name"] = args["name"]
rec["id"] = char_id
updated = char_put(rec)
return {"content": [{"type": "text", "text": json.dumps(updated)}]}
async def _mcp_tool_list_rehearsals() -> dict:
from core.database import reh_get_all, reh_compact_titles
reh_compact_titles()
return {"content": [{"type": "text", "text": json.dumps(reh_get_all())}]}
def _mcp_error_response(code: int, message: str, rpc_id) -> Response:
import logging
body = {"jsonrpc": "2.0", "error": {"code": code, "message": message}, "id": rpc_id}
return Response(content=json.dumps(body), media_type="application/json")
@router.post("/mcp")
async def mcp_jsonrpc(request: Request):
import logging
logger = logging.getLogger("uvicorn.error")
try:
body = await request.json()
except Exception:
return _mcp_error_response(-32700, "Parse error", None)
rpc_id = body.get("id")
method = body.get("method", "")
params = body.get("params") or {}
client_id = request.headers.get("X-Voice-Creator-Client-Id", "").strip()
try:
if method == "initialize":
result = {
"protocolVersion": "2024-11-05",
"serverInfo": _MCP_SERVER_INFO,
"capabilities": {"tools": {}},
}
elif method == "notifications/initialized":
return Response(status_code=204)
elif method == "tools/list":
result = {"tools": _MCP_TOOLS}
elif method == "tools/call":
tool_name = str(params.get("name") or "")
tool_args = params.get("arguments") or {}
if tool_name == "speak":
result = await _mcp_tool_speak(tool_args, client_id)
elif tool_name == "transcribe":
result = await _mcp_tool_transcribe(tool_args)
elif tool_name == "list_captures":
result = await _mcp_tool_list_captures()
elif tool_name == "list_profiles":
result = await _mcp_tool_list_profiles()
elif tool_name == "list_books":
result = await _mcp_tool_list_books()
elif tool_name == "list_characters":
result = await _mcp_tool_list_characters(tool_args)
elif tool_name == "get_character":
result = await _mcp_tool_get_character(tool_args)
elif tool_name == "update_character":
result = await _mcp_tool_update_character(tool_args)
elif tool_name == "list_rehearsals":
result = await _mcp_tool_list_rehearsals()
else:
return _mcp_error_response(-32601, f"Unknown tool: {tool_name}", rpc_id)
else:
return _mcp_error_response(-32601, f"Method not found: {method}", rpc_id)
except ValueError as e:
return _mcp_error_response(-32602, str(e), rpc_id)
except Exception as e:
logger.exception("MCP tool error in method %s", method)
return _mcp_error_response(-32000, f"Server error: {e}", rpc_id)
return Response(
content=json.dumps({"jsonrpc": "2.0", "result": result, "id": rpc_id}),
media_type="application/json",
)
@router.get("/mcp")
async def mcp_sse(request: Request):
"""SSE keep-alive stream (satisfies MCP spec GET /mcp requirement)."""
from typing import AsyncGenerator
async def _keepalive() -> AsyncGenerator[str, None]:
yield ": mcp-sse-ready\n\n"
while True:
await asyncio.sleep(15)
yield ": ping\n\n"
return StreamingResponse(
_keepalive(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ── Conversation playground ───────────────────────────────────────────────────
def _litellm_model_aliases(base: str, key: str) -> list[str]:
"""Names of a LiteLLM gateway's router aliases (Model Group Alias), which its /models list omits.
Best effort: the settings endpoint needs an admin-capable key, and other gateways do not have it.
"""
root = base[:-3] if base.endswith("/v1") else base
try:
r = requests.get(f"{root}/router/settings", timeout=5, headers={"Authorization": f"Bearer {key}"})
if r.status_code != 200:
return []
data = r.json()
except Exception:
return []
if not isinstance(data, dict):
return []
for holder in (data.get("current_values"), data.get("router_settings"), data):
aliases = holder.get("model_group_alias") if isinstance(holder, dict) else None
if isinstance(aliases, dict):
return [str(name) for name in aliases if name]
return []
@router.get("/api/conversation/llm-models")
async def conversation_llm_models(
url: str = "",
provider_api_key: str = Header("", alias="X-Provider-API-Key"),
):
"""List models from a local LLM endpoint (Ollama / vLLM / LM Studio)."""
from core.validation import _validate_http_url
settings = _load_settings()
base = (url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
key_to_use = provider_api_key or settings.get("llm_api_key") or "sk-dummy-key"
try:
base = _validate_http_url(base, allow_private=True)
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"})
# Fallback for gateways like LiteLLM that might prefer /models over /v1/models
if r.status_code != 200 and base.endswith("/v1"):
fallback_base = base[:-3]
r2 = requests.get(f"{fallback_base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"})
if r2.status_code == 200:
r = r2
if r.status_code == 200:
payload = r.json()
data = payload.get("data", []) if isinstance(payload, dict) else []
models = [
str(item["id"]) if isinstance(item, dict) and item.get("id") else str(item)
for item in data if item
]
aliases = [a for a in _litellm_model_aliases(base, key_to_use) if a not in models]
return {"models": aliases + models, "url": base, "aliases": aliases}
else:
return {"models": [], "url": base, "error": r.text}
except Exception as e:
return {"models": [], "url": base, "error": str(e)}
# Conversation had no style/emotion control at all before this — every reply
# synthesized flat regardless of backend. Mirrors the same backend-aware split
# used for the Rehearser/Studio pipeline: Fish-Speech only reacts to an inline
# [tag] in the text itself (the instruct field is ignored), other backends
# take the descriptive phrase directly as instruct. `emotion` here is always
# already-English (the REH_EMOTIONS quick-pick list), so no translation table
# is needed the way the German-templated audiobook instruct sentences needed one.
def _conv_tts_text_and_instruct(text: str, emotion: str, backend: str) -> tuple[str, str]:
emotion = (emotion or "").strip()
if not emotion:
return text, ""
if re.search(r"fish", backend or "", re.I):
tag = emotion.split(",")[0].strip().lower()
if re.fullmatch(r"[a-z\- ]+", tag):
return f"[{tag}] {text}", ""
return text, ""
return text, emotion
def _make_tts_task(
text: str,
voice: str,
settings: dict,
backend: str,
sem: "asyncio.Semaphore | None",
emotion: str = "",
) -> "asyncio.Task":
tts_text, instruct = _conv_tts_text_and_instruct(text, emotion, backend)
if sem is None:
return asyncio.create_task(
asyncio.to_thread(_preview_request_audio, tts_text, voice, settings, instruct, backend)
)
async def _guarded() -> tuple[bytes, str]:
async with sem:
return await asyncio.to_thread(_preview_request_audio, tts_text, voice, settings, instruct, backend)
return asyncio.create_task(_guarded())
@router.post("/api/conversation/turn")
async def conversation_turn(
audio: Optional[UploadFile] = None,
text: str = Form(""),
stt_backend: str = Form("configured"),
llm_url: str = Form(""),
llm_model: str = Form(""),
tts_backend: str = Form("voice_clone"),
tts_voice: str = Form(""),
tts_emotion: str = Form(""),
system_prompt: str = Form("You are a helpful voice assistant. Keep replies short and conversational."),
history: str = Form("[]"),
):
"""Stream a full conversation turn (STT → LLM → TTS) as Server-Sent Events.
Pass either an audio file (runs STT first) or a plain text string (skips STT).
"""
direct_text = text.strip()
if not direct_text and (audio is None or not getattr(audio, "filename", None)):
raise HTTPException(400, "Provide either an audio file or a text field")
settings = _load_settings()
eff_llm_url = (llm_url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
# Prepare audio temp files only when an audio upload was provided
_tmp: Path | None = None
_wav: Path | None = None
if not direct_text and audio is not None:
suffix = Path(audio.filename or "audio.webm").suffix.lower() or ".webm"
if suffix not in _UPLOAD_EXTS:
suffix = ".webm"
_tmp = TEMP_DIR / f"{uuid.uuid4().hex}_conv{suffix}"
try:
with _tmp.open("wb") as f:
_copy_limited(audio.file, f, _MAX_UPLOAD_BYTES)
_wav = _to_wav_16k(_tmp)
except Exception as e:
_tmp.unlink(missing_ok=True)
raise HTTPException(400, f"Audio upload failed: {e}")
try:
hist = json.loads(history) if history else []
if not isinstance(hist, list):
hist = []
except Exception:
hist = []
stt_be = _clean_stt_backend(stt_backend)
tts_be = _clean_preview_backend(tts_backend)
async def generate():
t0 = time.monotonic()
stt_ms = llm_ttft_ms = llm_total_ms = tts_ms = None
transcript = llm_text = ""
def sse(obj: dict) -> str:
return f"data: {json.dumps(obj)}\n\n"
# 1. STT — skipped when caller sends direct text
if direct_text:
transcript = direct_text
yield sse({"type": "transcript", "text": transcript, "stt_ms": None})
else:
try:
t_stt = time.monotonic()
transcript, _ = await asyncio.to_thread(_transcribe_audio, _wav, settings, stt_be)
stt_ms = int((time.monotonic() - t_stt) * 1000)
yield sse({"type": "transcript", "text": transcript, "stt_ms": stt_ms})
except Exception as e:
yield sse({"type": "error", "stage": "stt", "message": str(e)})
return
finally:
for p in filter(None, {_tmp, _wav}):
with contextlib.suppress(Exception):
p.unlink(missing_ok=True)
if not transcript.strip() or _is_hallucination(transcript):
yield sse({"type": "error", "stage": "stt", "message": "No speech detected."})
return
# 2. LLM stream — runs in a thread; tokens arrive via asyncio.Queue
# so the event loop is never blocked and TTS can start on sentence 1
# while the LLM is still generating sentences 2, 3, …
messages = [{"role": "system", "content": system_prompt}]
messages.extend(hist[-20:])
messages.append({"role": "user", "content": transcript})
llm_payload: dict = {"messages": messages, "stream": True, "temperature": 0.7, "max_tokens": 512}
eff_llm_model = llm_model or settings.get("llm_model") or ""
if eff_llm_model:
llm_payload["model"] = eff_llm_model
_loop = asyncio.get_event_loop()
_token_q: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue()
def _llm_thread() -> None:
try:
resp = requests.post(
f"{eff_llm_url}/chat/completions", json=llm_payload,
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, stream=True, timeout=120,
)
resp.raise_for_status()
for raw_line in resp.iter_lines():
if not raw_line:
continue
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line)
if not line.startswith("data:"):
continue
payload_str = line[5:].strip()
if payload_str == "[DONE]":
break
try:
obj = json.loads(payload_str)
d_obj = ((obj.get("choices") or [{}])[0].get("delta") or {})
reasoning = _reasoning_text(d_obj)
content = d_obj.get("content") or ""
if not content and not reasoning and isinstance(obj.get("message"), dict):
content = obj["message"].get("content") or ""
if reasoning:
_loop.call_soon_threadsafe(_token_q.put_nowait, ("reasoning", reasoning))
if content:
_loop.call_soon_threadsafe(_token_q.put_nowait, ("content", content))
except Exception:
continue
except Exception as exc:
_loop.call_soon_threadsafe(_token_q.put_nowait, ("error", str(exc)))
finally:
_loop.call_soon_threadsafe(_token_q.put_nowait, None)
threading.Thread(target=_llm_thread, daemon=True).start()
# Some models (e.g. Qwen3 in "thinking" mode) don't use a separate
# reasoning_content field — they emit a <think>...</think> block inline
# inside content, split across arbitrary delta chunks. Many chat
# templates also inject the opening "<think>" as a fixed prompt prefix,
# so it never appears in the generated stream at all — only the closing
# "</think>" does. This tracks both explicit-tag and implicit-open
# styles so the answer stays clean for chat history/TTS and the
# reasoning can be shown in a collapsible "thinking" panel instead.
_THINK_SNIFF_LIMIT = 300 # chars buffered before assuming "not a reasoning response"
_think_state = {"buf": "", "resolved": False, "in_think": False}
def _explicit_tag_split(text: str) -> tuple[str, str]:
# Assumes _think_state["resolved"] is True — scans for <think>/</think>
# tags that may be split across delta chunks.
_think_state["buf"] += text
thinking_parts: list[str] = []
answer_parts: list[str] = []
buf = _think_state["buf"]
while True:
tag = "</think>" if _think_state["in_think"] else "<think>"
idx = buf.find(tag)
if idx == -1:
break
before = buf[:idx]
(thinking_parts if _think_state["in_think"] else answer_parts).append(before)
buf = buf[idx + len(tag):]
_think_state["in_think"] = not _think_state["in_think"]
# Hold back a short suffix that could be the start of a split tag
hold = 0
for cut in range(1, min(8, len(buf)) + 1):
suffix = buf[-cut:]
if "<think>".startswith(suffix) or "</think>".startswith(suffix):
hold = cut
flush = buf[:len(buf) - hold] if hold else buf
_think_state["buf"] = buf[len(buf) - hold:] if hold else ""
(thinking_parts if _think_state["in_think"] else answer_parts).append(flush)
return "".join(thinking_parts), "".join(answer_parts)
def _split_think(text: str) -> tuple[str, str]:
if _think_state["resolved"]:
return _explicit_tag_split(text)
_think_state["buf"] += text
buf = _think_state["buf"]
open_idx = buf.find("<think>")
close_idx = buf.find("</think>")
if close_idx != -1 and (open_idx == -1 or close_idx < open_idx):
# No opening tag before this close — the model (or its chat
# template) started inside a think block implicitly.
thinking_text = buf[:close_idx]
rest = buf[close_idx + len("</think>"):]
_think_state["resolved"] = True
_think_state["in_think"] = False
_think_state["buf"] = ""
_, rest_answer = _explicit_tag_split(rest) if rest else ("", "")
return thinking_text, rest_answer
if open_idx != -1:
before = buf[:open_idx]
rest = buf[open_idx:]
_think_state["resolved"] = True
_think_state["in_think"] = False
_think_state["buf"] = ""
thinking_rest, answer_rest = _explicit_tag_split(rest) if rest else ("", "")
return thinking_rest, before + answer_rest
if len(buf) >= _THINK_SNIFF_LIMIT:
# No think tag within the sniff window — treat as a plain,
# non-reasoning response from here on.
_think_state["resolved"] = True
_think_state["in_think"] = False
_think_state["buf"] = ""
return "", buf
return "", "" # still buffering — undecided
def _finish_split_think() -> tuple[str, str]:
if not _think_state["buf"]:
return "", ""
leftover = _think_state["buf"]
_think_state["buf"] = ""
if not _think_state["resolved"]:
return "", leftover # stream ended before we saw any think tag
return (leftover, "") if _think_state["in_think"] else ("", leftover)
# Streaming backend (8023) has per-process voice state — serialize to prevent
# concurrent requests from clobbering each other's voice context.
tts_sem: asyncio.Semaphore | None = asyncio.Semaphore(1) if tts_be == "streaming" else None
t_llm = time.monotonic()
llm_ttft_ms: int | None = None
ttft_done = False
sent_buf = ""
tts_tasks: list[asyncio.Task] = []
tts_texts: list[str] = [] # sentence text corresponding to each task
tts_first_start: float | None = None
try:
while True:
item = await _token_q.get()
if item is None:
break
kind, text = item
if kind == "error":
yield sse({"type": "error", "stage": "llm", "message": text})
return
if not ttft_done:
llm_ttft_ms = int((time.monotonic() - t_llm) * 1000)
ttft_done = True
thinking_piece, answer_piece = (text, "") if kind == "reasoning" else _split_think(text)
if thinking_piece:
yield sse({"type": "thinking", "delta": thinking_piece})
if not answer_piece:
continue
llm_text += answer_piece
sent_buf += answer_piece
yield sse({"type": "token", "delta": answer_piece})
# Fire TTS on sentence boundary — runs concurrently with LLM
split = _sentence_split(sent_buf)
if split > 0:
chunk_text = sent_buf[:split].strip()
sent_buf = sent_buf[split:]
if chunk_text:
if tts_first_start is None:
tts_first_start = time.monotonic()
tts_texts.append(chunk_text)
tts_tasks.append(_make_tts_task(chunk_text, tts_voice, settings, tts_be, tts_sem, tts_emotion))
except Exception as exc:
yield sse({"type": "error", "stage": "llm", "message": str(exc)})
return
# Flush any text still held back (partial tag, or sniff buffer never resolved)
_thinking_tail, _answer_tail = _finish_split_think()
if _thinking_tail:
yield sse({"type": "thinking", "delta": _thinking_tail})
if _answer_tail:
llm_text += _answer_tail
sent_buf += _answer_tail
yield sse({"type": "token", "delta": _answer_tail})
# Flush any remaining text as a final TTS task
if sent_buf.strip():
if tts_first_start is None:
tts_first_start = time.monotonic()
tts_texts.append(sent_buf.strip())
tts_tasks.append(_make_tts_task(sent_buf.strip(), tts_voice, settings, tts_be, tts_sem, tts_emotion))
llm_total_ms = int((time.monotonic() - t_llm) * 1000)
yield sse({"type": "llm_done", "text": llm_text,
"llm_ttft_ms": llm_ttft_ms, "llm_total_ms": llm_total_ms})
if not llm_text.strip():
yield sse({"type": "error", "stage": "llm",
"message": "LLM returned empty response. "
"If using a Qwen3 model, try adding /no-think to your system prompt "
"or pick a non-thinking model in the Language Model dropdown."})
return
# 3. Stream audio chunks in order — each chunk's TTS ran concurrently
# with LLM generation, so first audio arrives much sooner than
# waiting for the full response.
tts_start = tts_first_start or time.monotonic()
for i, task in enumerate(tts_tasks):
try:
audio_bytes, mime = await task
except Exception as exc:
yield sse({"type": "error", "stage": "tts", "message": str(exc)})
return
if i == 0:
tts_ms = int((time.monotonic() - tts_start) * 1000)
sentence_text = tts_texts[i] if i < len(tts_texts) else ""
yield sse({"type": "audio", "b64": base64.b64encode(audio_bytes).decode(),
"mime": mime, "text": sentence_text})
total_ms = int((time.monotonic() - t0) * 1000)
yield sse({"type": "stats", "stt_ms": stt_ms, "llm_ttft_ms": llm_ttft_ms,
"llm_total_ms": llm_total_ms, "tts_ms": tts_ms, "total_ms": total_ms})
yield sse({"type": "done"})
return StreamingResponse(generate(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})