tts-voice-creator-clone-and.../routes/characters.py

201 lines
8.4 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 ""
@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:
sheet = record.get("sheet") if isinstance(record.get("sheet"), dict) else {}
profile = record.get("image") or sheet.get("image")
reference = sheet.get("concept_art_image")
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")
expected = _character_identity_fingerprint(sheet)
if sheet.get("consistent_prompt_version") != _CHARACTER_VISUAL_PROMPT_VERSION:
issues.append("legacy_or_missing_prompt_version")
if not sheet.get("consistent_identity_fingerprint"):
issues.append("missing_identity_fingerprint")
elif sheet.get("consistent_identity_fingerprint") != expected:
issues.append("appearance_changed_since_generation")
if sheet.get("identity_consistent") is False:
issues.append("marked_inconsistent")
if issues:
reports.append({"id": record.get("id"), "name": record.get("name", ""), "issues": issues})
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), 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}