tts-voice-creator-clone-and.../routes/characters.py
mARTin-B78 ea50267c30 Add unified Studio casting workflow and fix voice/casting pipeline bugs
Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 02:03:56 +02:00

101 lines
3.6 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 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
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.
chars = char_get_all()
for c in chars:
if c.get("image"):
c["image"] = f"/api/characters/{c['id']}/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))
@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
return char_put(body)
@router.delete("/api/characters/{char_id:path}")
async def characters_delete(char_id: str):
char_delete(char_id)
return {"ok": 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"):
char_put(rec)
n += 1
return {"imported": n}