Files
DemonGatanjieu-Anomalous_Mo…/api/voice_covers.py
T
DemonGatanjieuandClaude Opus 5.5 9e0062f331 Give voice characters a cover, and lay their cards out like a profile
A click on a card's avatar picks a picture of the gallery or the computer; the
page cuts it square (a tall one keeps its top) and the server keeps it small in
user/anomalous/voice_covers, one per character's name, never in Anomalous_TTS's
folders; one taken away goes to the Recycle Bin. Without one, the name's first
letter shows, and "Change the cover" shows over it under the pointer. The card:
cover, name big, version in grey, one blue line of facts, plain buttons, voices
as thin-line rows; flat, no glow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:32:28 +08:00

156 lines
5.3 KiB
Python

"""Covers of the voice characters (the Voices page's cards, web/modules/voice_covers.js): a picture the user picks
for a character, from the gallery or the computer, cropped square and made small by the page and checked and
made again here (no metadata kept). One per character's name: its versions ("角色/日配", "角色/中配") share it.
Kept in user/anomalous/voice_covers, a PNG per name by its hash and index.json naming them, never in
Anomalous_TTS's character folders. A cover taken away goes to the Recycle Bin (trash.py).
GET /anomalous/voice_covers -> {covers: {name: stamp}}; GET /anomalous/voice_cover?name= -> the PNG;
POST /anomalous/voice_cover {name, image: data URL} -> {stamp}; POST /anomalous/voice_cover/remove {name}."""
import asyncio
import base64
import hashlib
import io
import json
import os
import threading
import time
from aiohttp import web
from PIL import Image
from . import ai_tasks
from .path_utils import atomic_write_json
SIZE = 256
MAX_BYTES = 8 * 1024 * 1024
MAX_NAME = 200
INDEX = "index.json"
_lock = threading.Lock()
class CoverError(ValueError):
pass
def _folder():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "voice_covers")
def base_name(character):
"""The character's name without its version ("角色/日配" -> "角色")."""
name = str(character or "").strip()
return name.rsplit("/", 1)[0].strip() if "/" in name else name
def _file(name):
return os.path.join(_folder(), hashlib.sha1(name.encode("utf-8")).hexdigest()[:20] + ".png")
def _index():
try:
with open(os.path.join(_folder(), INDEX), encoding="utf-8") as source:
data = json.load(source)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def covers():
"""{name: stamp} of the covers there are; a stamp changes with the picture."""
return {name: stamp for name, stamp in _index().items() if os.path.isfile(_file(name))}
def _checked(name):
name = base_name(name)
if not name or len(name) > MAX_NAME:
raise CoverError("name is a character's name")
return name
def set_cover(character, data_url):
"""Keeps the picture in `data_url` as `character`'s cover: a square, SIZE across, made again as a PNG."""
name = _checked(character)
if not isinstance(data_url, str) or "," not in data_url or not data_url.startswith("data:image/"):
raise CoverError("image is a picture as a data URL")
try:
raw = base64.b64decode(data_url.split(",", 1)[1], validate=True)
except (ValueError, TypeError) as error:
raise CoverError("The picture could not be read") from error
if len(raw) > MAX_BYTES:
raise CoverError("The picture is too big")
try:
with Image.open(io.BytesIO(raw)) as source:
source.load()
picture = source.convert("RGBA" if "A" in source.getbands() else "RGB")
except Exception as error: # noqa: BLE001 - whatever it is, it is no picture to keep
raise CoverError("The picture could not be read") from error
side = min(picture.size)
left, top = (picture.width - side) // 2, (picture.height - side) // 2
picture = picture.crop((left, top, left + side, top + side)).resize((SIZE, SIZE), Image.LANCZOS)
os.makedirs(_folder(), exist_ok=True)
stamp = str(int(time.time() * 1000))
with _lock:
target = _file(name)
temp = f"{target}.tmp"
picture.save(temp, "PNG")
os.replace(temp, target)
index = _index()
index[name] = stamp
atomic_write_json(os.path.join(_folder(), INDEX), index)
return {"name": name, "stamp": stamp}
def remove_cover(character):
name = _checked(character)
from .trash import move_to_trash
with _lock:
if os.path.isfile(_file(name)):
move_to_trash(_file(name))
index = _index()
index.pop(name, None)
if os.path.isdir(_folder()):
atomic_write_json(os.path.join(_folder(), INDEX), index)
return {"name": name}
async def _body(request):
try:
body = await request.json()
except ValueError:
body = None
if not isinstance(body, dict):
raise CoverError("Expected a JSON object")
return body
async def api_list(request):
return web.json_response({"covers": await asyncio.to_thread(covers)})
async def api_get(request):
name = base_name(request.query.get("name", ""))
path = _file(name) if name else ""
if not name or not os.path.isfile(path):
return web.json_response({"error": "No cover"}, status=404)
return web.FileResponse(path, headers={"Cache-Control": "no-cache"})
async def api_set(request):
try:
body = await _body(request)
return web.json_response(await asyncio.to_thread(set_cover, body.get("name"), body.get("image")))
except CoverError as error:
return web.json_response({"error": str(error)}, status=400)
async def api_remove(request):
try:
body = await _body(request)
return web.json_response(await asyncio.to_thread(remove_cover, body.get("name")))
except CoverError as error:
return web.json_response({"error": str(error)}, status=400)
except Exception as error: # noqa: BLE001 - no Recycle Bin here (trash.py): it stays, and the page says why
return web.json_response({"error": str(error)[:300]}, status=500)