"""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}