tts-voice-creator-clone-and.../routes/characters.py
mARTin-B78 5877aa2edc fix(characters): persist base-appearance image pairs, deterministic prompts, form-aware review; add forms panel
- fix: base-appearance matched pair lost its concept image + metadata on reload; visual save
  wrote base concept art to the record root; one save path for base and forms now
- fix: extraction no longer lets the LLM author image_prompt (source of avatar/concept drift);
  avatar-only generation uses the deterministic prompt and flags an existing pair
- fix: prompt writes and Physical/Clothing edits go to the active form; review endpoint checks
  each form's own pair; fingerprint parity with the browser incl. visual_* overrides
- feat: appearance forms panel (add/rename/delete/show, per-form stale flag, generate one/all)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:27:21 +02:00

234 lines
10 KiB
Python

"""Character library REST API — backed by SQLite via core.database.
Mirrors the IndexedDB API in characters-library.js so the JS swap is mechanical:
GET /api/characters → { characters: [...] }
GET /api/characters/{id} → record | 404
PUT /api/characters/{id} → upsert, returns record
DELETE /api/characters/{id} → { ok: true }
POST /api/characters/migrate → bulk import from JS (one-time migration)
"""
from __future__ import annotations
import base64
import hashlib
import re
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from core.database import char_get_all, char_get, char_put, char_delete
from core.validation import _validate_character_record
router = APIRouter()
_DATA_URL_RE = re.compile(r"^data:(image/[\w.+-]+);base64,(.+)$", re.DOTALL)
@router.get("/api/characters")
async def characters_list():
# Swap each character's raw base64 portrait for a lightweight URL —
# the list endpoint is fetched in bulk (e.g. rendering a full cast grid),
# and re-sending every character's full image blob inline made that
# payload/DOM balloon to tens of megabytes for a book with dozens of
# portraits, blocking rendering with no visual feedback. Single-record
# fetches (characters_get below) still return the real base64.
#
# sheet.concept_art_image (the reference-sheet image) needed the exact
# same treatment and never got it: after the "Consistent profile +
# reference" feature started populating it for a whole cast in one run,
# this endpoint's payload measured 190MB across the library (mostly this
# one field) — confirmed live as the cause of every character-list
# action (including just clicking a table column to sort) appearing to
# hang for minutes while the browser fetched and parsed it.
chars = char_get_all()
for c in chars:
if c.get("image"):
c["image"] = f"/api/characters/{c['id']}/image"
sheet = c.get("sheet")
if isinstance(sheet, dict) and sheet.get("concept_art_image"):
c["sheet"] = {**sheet, "concept_art_image": f"/api/characters/{c['id']}/concept-art-image"}
return {"characters": chars}
@router.get("/api/characters/{char_id:path}/image")
async def characters_image(char_id: str):
# Character portraits are stored inline as base64 data: URLs (clUpsert/
# clSetImage write straight into the `image` column) — fine for a single
# avatar per card, but Script Rehearser's Stage view renders one avatar
# PER DIALOGUE LINE, and a character can speak hundreds of lines. Inlining
# the raw data URL into every line's HTML re-embeds the same multi-KB/MB
# blob hundreds of times, ballooning the page to hundreds of megabytes and
# silently failing to render at all (confirmed live on a 1968-line book).
# Serving it as a real URL means the browser fetches/caches it once.
rec = char_get(char_id)
if rec is None or not rec.get("image"):
raise HTTPException(404, "No image")
m = _DATA_URL_RE.match(rec["image"])
if not m:
raise HTTPException(404, "Invalid image data")
try:
raw = base64.b64decode(m.group(2))
except Exception:
raise HTTPException(404, "Invalid image data")
return Response(
content=raw,
media_type=m.group(1),
headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
)
@router.get("/api/characters/{char_id:path}/concept-art-image")
async def characters_concept_art_image(char_id: str):
"""Same lightweight-URL treatment as /image, for the reference sheet."""
rec = char_get(char_id)
sheet = (rec or {}).get("sheet") or {}
concept_art_image = sheet.get("concept_art_image")
if rec is None or not concept_art_image:
raise HTTPException(404, "No concept art image")
m = _DATA_URL_RE.match(concept_art_image)
if not m:
raise HTTPException(404, "Invalid image data")
try:
raw = base64.b64decode(m.group(2))
except Exception:
raise HTTPException(404, "Invalid image data")
return Response(
content=raw,
media_type=m.group(1),
headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
)
def _visual_asset_digest(value: object) -> str:
match = _DATA_URL_RE.match(str(value or ""))
if not match:
return ""
try:
return hashlib.sha256(base64.b64decode(match.group(2), validate=True)).hexdigest()
except Exception:
return ""
def _pair_scopes(record: dict) -> list[tuple[dict, str, str, str, dict, str]]:
"""The visual pairs one character can hold, as
(fingerprint sheet, form id, form name, profile image, stored metadata, reference image).
The base appearance keeps its pair on the record/sheet; every appearance form that
has images keeps its own on the form entry, so each is judged against its own
identity fingerprint (base fields overlaid with that form) and its own metadata.
"""
sheet = record.get("sheet") if isinstance(record.get("sheet"), dict) else {}
scopes = [(
{**sheet, "active_form_id": ""}, "", "",
record.get("image") or sheet.get("image"), sheet, sheet.get("concept_art_image"),
)]
forms = sheet.get("forms") if isinstance(sheet.get("forms"), list) else []
for form in forms:
if not isinstance(form, dict) or not form.get("id"):
continue
if not (form.get("image") or form.get("concept_art_image")):
continue
scopes.append((
{**sheet, "active_form_id": str(form["id"])}, str(form["id"]), str(form.get("name") or form["id"]),
form.get("image"), form, form.get("concept_art_image"),
))
return scopes
@router.get("/api/characters/visual-pair-review")
async def characters_visual_pair_review():
"""Report stored profile/reference identity problems without rewriting records."""
from routes.conversation import _CHARACTER_VISUAL_PROMPT_VERSION, _character_identity_fingerprint
records = char_get_all()
digest_owners: dict[str, list[str]] = {}
reports = []
for record in records:
for fp_sheet, form_id, form_name, profile, meta, reference in _pair_scopes(record):
profile_digest = _visual_asset_digest(profile)
reference_digest = _visual_asset_digest(reference)
for kind, digest in (("profile", profile_digest), ("reference", reference_digest)):
if digest:
digest_owners.setdefault(digest, []).append(f"{record.get('id')}:{kind}")
issues = []
if bool(profile) != bool(reference):
issues.append("incomplete_pair")
if profile and reference:
if profile_digest and profile_digest == reference_digest:
issues.append("same_asset_for_profile_and_reference")
stored_profile_digest = str(meta.get("profile_asset_digest") or "")
stored_reference_digest = str(meta.get("reference_asset_digest") or "")
if stored_profile_digest and profile_digest and stored_profile_digest != profile_digest:
issues.append("profile_asset_changed_since_generation")
if stored_reference_digest and reference_digest and stored_reference_digest != reference_digest:
issues.append("reference_asset_changed_since_generation")
expected = _character_identity_fingerprint(fp_sheet)
if meta.get("consistent_prompt_version") != _CHARACTER_VISUAL_PROMPT_VERSION:
issues.append("legacy_or_missing_prompt_version")
if not meta.get("consistent_identity_fingerprint"):
issues.append("missing_identity_fingerprint")
elif meta.get("consistent_identity_fingerprint") != expected:
issues.append("appearance_changed_since_generation")
if meta.get("identity_consistent") is False:
issues.append("marked_inconsistent")
if issues:
report = {"id": record.get("id"), "name": record.get("name", ""), "issues": issues}
if form_id:
report["form_id"], report["form_name"] = form_id, form_name
reports.append(report)
for owners in digest_owners.values():
character_ids = {owner.rsplit(":", 1)[0] for owner in owners}
if len(character_ids) > 1:
for owner in owners:
char_id = owner.rsplit(":", 1)[0]
report = next((item for item in reports if item["id"] == char_id and "form_id" not in item), None)
if report is None:
report = {"id": char_id, "name": next((r.get("name", "") for r in records if r.get("id") == char_id), ""), "issues": []}
reports.append(report)
if "asset_reused_across_characters" not in report["issues"]:
report["issues"].append("asset_reused_across_characters")
return {"ok": not reports, "checked": len(records), "issues": reports}
@router.get("/api/characters/{char_id:path}")
async def characters_get(char_id: str):
rec = char_get(char_id)
if rec is None:
raise HTTPException(404, "Character not found")
return rec
@router.put("/api/characters/{char_id:path}")
async def characters_put(char_id: str, request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON")
body["id"] = char_id
_validate_character_record(body)
return char_put(body)
@router.delete("/api/characters/{char_id:path}")
async def characters_delete(char_id: str):
if not char_delete(char_id):
raise HTTPException(404, "Character not found")
return {"ok": True, "backup_created": True}
@router.post("/api/characters/migrate")
async def characters_migrate(request: Request):
"""Accept a batch of character records from the browser's IndexedDB dump."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON")
records = body if isinstance(body, list) else body.get("characters", [])
n = 0
for rec in records:
if rec.get("id"):
_validate_character_record(rec)
char_put(rec)
n += 1
return {"imported": n}