237 lines
11 KiB
Python
237 lines
11 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)
|
|
names_by_id = {r.get("id"): re.sub(r"\s+", " ", str(r.get("name") or "")).strip().lower() for r in records}
|
|
for owners in digest_owners.values():
|
|
character_ids = {owner.rsplit(":", 1)[0] for owner in owners}
|
|
# The same character carried into a later book legitimately keeps its picture; only
|
|
# different names sharing one image (a copy/paste or mix-up) is worth reviewing.
|
|
if len({names_by_id.get(cid) for cid in 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}
|