from __future__ import annotations import base64 import binascii import errno import hashlib import json import logging import re import shutil import threading from datetime import datetime, timezone from io import BytesIO from pathlib import Path from typing import Any, Callable from uuid import UUID, uuid4 from PIL import Image from app.display.colors import parse_color from app.display.text_renderer import FontResolver, TextOptions, render_text_layer from app.persistence import atomic_write_bytes, atomic_write_many_bytes logger = logging.getLogger(__name__) SCENE_WIDTH = 64 SCENE_HEIGHT = 64 RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * 3 NAME_LIMIT = 80 TEMPLATE_SCHEMA_VERSION = 2 TEMPLATE_RECORD_FIELDS = frozenset({ "id", "name", "created_at", "updated_at", "digest", "thumbnail", "scene", }) SCENE_FIELDS = frozenset({"version", "width", "height", "pixelRgb", "elements"}) TEXT_ELEMENT_FIELDS = frozenset({ "id", "type", "text", "font", "size", "x", "y", "align", "color", }) HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$") THUMBNAIL_RE = re.compile( r"^(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P[0-9a-f]{16})\.png$" ) class TemplateError(RuntimeError): """Base error for template operations.""" class TemplateValidationError(TemplateError): pass class TemplateConflictError(TemplateError): pass class TemplateNotFoundError(TemplateError): pass class TemplateStorageFullError(TemplateError): pass def _migrate_template_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]: return dict(document) TEMPLATE_MIGRATIONS = {0: _migrate_template_v0_to_v1, 1: lambda document: dict(document)} def _is_record(value: Any) -> bool: return isinstance(value, dict) def normalize_name(value: Any) -> str: if not isinstance(value, str): raise TemplateValidationError("template name must be a string") name = value.strip() if not name or len(name) > NAME_LIMIT: raise TemplateValidationError(f"template name must contain 1..{NAME_LIMIT} characters") return name def normalize_template_id(value: Any) -> str: try: return str(UUID(str(value))) except (ValueError, TypeError, AttributeError) as exc: raise TemplateNotFoundError("template not found") from exc def _validate_scene_v1(value: Any, *, strict_schema: bool = False) -> dict[str, Any]: if not _is_record(value): raise TemplateValidationError("scene must be an object") if strict_schema: unknown = set(value) - SCENE_FIELDS missing = SCENE_FIELDS - set(value) if unknown or missing: raise TemplateValidationError("scene fields do not match the current schema") for field in ("version", "width", "height"): if type(value[field]) is not int: raise TemplateValidationError(f"scene {field} must be an integer") if value.get("version") != 1 or value.get("width") != 64 or value.get("height") != 64: raise TemplateValidationError("scene must be version 1 with a 64x64 frame") pixel_rgb = value.get("pixelRgb") if not isinstance(pixel_rgb, str): raise TemplateValidationError("scene pixelRgb must be base64 RGB888") try: decoded = base64.b64decode(pixel_rgb, validate=True) except (ValueError, binascii.Error) as exc: raise TemplateValidationError("scene pixelRgb must be valid base64 RGB888") from exc if len(decoded) != RGB_BYTE_LENGTH: raise TemplateValidationError("scene pixelRgb must contain 64x64 RGB888 bytes") raw_elements = value.get("elements") if not isinstance(raw_elements, list): raise TemplateValidationError("scene elements must be an array") elements: list[dict[str, Any]] = [] ids: set[str] = set() for raw in raw_elements: if not _is_record(raw) or raw.get("type") != "text": raise TemplateValidationError("scene contains an unsupported element type") if strict_schema: unknown = set(raw) - TEXT_ELEMENT_FIELDS missing = TEXT_ELEMENT_FIELDS - set(raw) if unknown or missing: raise TemplateValidationError("text element fields do not match the current schema") for field in ("size", "x", "y"): if type(raw[field]) is not int: raise TemplateValidationError(f"text element {field} must be an integer") element_id = raw.get("id") if not isinstance(element_id, str) or not element_id.strip() or len(element_id.strip()) > 128: raise TemplateValidationError("scene element id must contain 1..128 characters") element_id = element_id.strip() if element_id in ids: raise TemplateValidationError("scene element ids must be unique") ids.add(element_id) text = raw.get("text") font = raw.get("font") align = raw.get("align") color = raw.get("color") if not isinstance(text, str) or len(text) > 512: raise TemplateValidationError("text element text must contain at most 512 characters") if not isinstance(font, str) or not font.strip() or len(font.strip()) > 512: raise TemplateValidationError("text element font must contain 1..512 characters") if align not in {"left", "center", "right"}: raise TemplateValidationError("text element align must be left, center, or right") if not isinstance(color, str) or not HEX_COLOR_RE.fullmatch(color.strip()): raise TemplateValidationError("text element color must be #RRGGBB") try: size = int(raw.get("size")) x = int(raw.get("x")) y = int(raw.get("y")) except (TypeError, ValueError) as exc: raise TemplateValidationError("text element size and coordinates must be integers") from exc if not 1 <= size <= 64: raise TemplateValidationError("text element size must be in 1..64") if not -128 <= x <= 128 or not -128 <= y <= 128: raise TemplateValidationError("text element coordinates must be in -128..128") elements.append({ "id": element_id, "type": "text", "text": text, "font": font.strip(), "size": size, "x": x, "y": y, "align": align, "color": color.strip().upper(), }) return { "version": 1, "width": 64, "height": 64, "pixelRgb": base64.b64encode(decoded).decode("ascii"), "elements": elements, } def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]: if strict_schema and isinstance(value, dict): for field in ("version", "width", "height"): if field in value and type(value[field]) is not int: raise TemplateValidationError(f"scene {field} must be an integer") if isinstance(value, dict) and value.get("version") == 2: from app.scene_layers import validate_v2 try: return validate_v2(value, _validate_scene_v1) except (ValueError, TypeError, KeyError) as exc: raise TemplateValidationError(str(exc)) from exc return _validate_scene_v1(value, strict_schema=strict_schema) def prepare_scene(value: Any) -> dict[str, Any]: from app.scene_layers import upgrade_scene return upgrade_scene(validate_scene(value, strict_schema=True)) def scene_digest(scene: dict[str, Any]) -> str: canonical = json.dumps(scene, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] def record_revision(record: dict[str, Any]) -> str: canonical = json.dumps( { "id": record["id"], "name": record["name"], "created_at": record["created_at"], "updated_at": record["updated_at"], "digest": record["digest"], "scene": record["scene"], }, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32] ThumbnailRenderer = Callable[[Image.Image, dict[str, Any], FontResolver | None], Image.Image] def _render_text( base: Image.Image, element: dict[str, Any], font_resolver: FontResolver | None = None, ) -> Image.Image: layer = render_text_layer(TextOptions( text=element["text"], font=element["font"], size=element["size"], x=element["x"], y=element["y"], align=element["align"], color=parse_color(element["color"]), ), size=(SCENE_WIDTH, SCENE_HEIGHT), font_resolver=font_resolver) return Image.alpha_composite(base, layer) ELEMENT_RENDERERS: dict[str, ThumbnailRenderer] = {"text": _render_text} def render_scene_image( scene: dict[str, Any], *, font_resolver: FontResolver | None = None, ) -> Image.Image: checked = validate_scene(scene) if checked["version"] == 2: from app.scene_layers import render_v2 return render_v2(checked, font_resolver) pixels = base64.b64decode(checked["pixelRgb"], validate=True) image = Image.frombytes("RGB", (SCENE_WIDTH, SCENE_HEIGHT), pixels).convert("RGBA") for element in checked["elements"]: renderer = ELEMENT_RENDERERS.get(element["type"]) if renderer is None: raise TemplateValidationError(f"no thumbnail renderer for {element['type']}") image = renderer(image, element, font_resolver) return image.convert("RGB") def render_scene_thumbnail( scene: dict[str, Any], *, font_resolver: FontResolver | None = None, ) -> bytes: image = render_scene_image(scene, font_resolver=font_resolver) output = BytesIO() image.save(output, format="PNG", optimize=True) return output.getvalue() class TemplateStore: def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None) -> None: self.root = Path(data_dir) / "templates" self._font_resolver = font_resolver self.records_dir = self.root / "records" self.thumbnails_dir = self.root / "thumbnails" self._lock = threading.RLock() self.records_dir.mkdir(parents=True, exist_ok=True) self.thumbnails_dir.mkdir(parents=True, exist_ok=True) with self._lock: records, migrations = self._preflight_records() self._write_migrations_atomically(migrations) self._reconcile(records) def _record_path(self, template_id: str) -> Path: return self.records_dir / f"{template_id}.json" def _thumbnail_path(self, template_id: str, digest: str) -> Path: return self.thumbnails_dir / f"{template_id}-{digest}.png" @staticmethod def _timestamp() -> str: return datetime.now(timezone.utc).isoformat() @staticmethod def _json_bytes(record: dict[str, Any]) -> bytes: persisted = {"schema_version": TEMPLATE_SCHEMA_VERSION, **record} return (json.dumps(persisted, ensure_ascii=False, indent=2) + "\n").encode("utf-8") @staticmethod def _translate_write_error(exc: OSError) -> None: if exc.errno == errno.ENOSPC: raise TemplateStorageFullError("not enough disk space to save template") from exc raise TemplateError(str(exc)) from exc def _write_bytes_atomic(self, path: Path, content: bytes) -> None: try: atomic_write_bytes(path, content) except OSError as exc: self._translate_write_error(exc) def _write_migrations_atomically( self, migrations: list[tuple[Path, dict[str, Any]]], ) -> None: if not migrations: return try: atomic_write_many_bytes( (path, self._json_bytes(record)) for path, record in migrations ) except OSError as exc: self._translate_write_error(exc) def _write_thumbnail(self, template_id: str, digest: str, scene: dict[str, Any]) -> Path: path = self._thumbnail_path(template_id, digest) if path.exists(): return path content = render_scene_thumbnail(scene, font_resolver=self._font_resolver) self._write_bytes_atomic(path, content) try: with Image.open(path) as image: image.load() if image.size != (64, 64) or image.format != "PNG": raise TemplateError("generated thumbnail is invalid") except Exception: path.unlink(missing_ok=True) raise return path def _parse_record_document( self, path: Path, raw: Any, ) -> tuple[dict[str, Any], bool]: if not _is_record(raw): raise TemplateValidationError("template record must be an object") document = dict(raw) if "schema_version" in document: version = document.pop("schema_version") if type(version) is not int: raise TemplateValidationError("template schema_version must be an integer") else: version = 0 if version > TEMPLATE_SCHEMA_VERSION: raise TemplateValidationError( f"template schema version {version} is newer than supported version " f"{TEMPLATE_SCHEMA_VERSION}" ) if version < 0: raise TemplateValidationError(f"unsupported template schema version {version}") source_version = version while version < TEMPLATE_SCHEMA_VERSION: migrator = TEMPLATE_MIGRATIONS.get(version) if migrator is None: raise TemplateValidationError( f"no template migration is registered for version {version}" ) document = migrator(document) version += 1 unknown = set(document) - TEMPLATE_RECORD_FIELDS if unknown: raise TemplateValidationError( f"unknown template fields: {', '.join(sorted(unknown))}" ) missing = TEMPLATE_RECORD_FIELDS - set(document) if missing: raise TemplateValidationError( f"template record is missing fields: {', '.join(sorted(missing))}" ) try: template_id = normalize_template_id(document.get("id")) if path.stem != template_id: raise TemplateValidationError("template id does not match filename") name = normalize_name(document.get("name")) scene = validate_scene( document.get("scene"), strict_schema=True, ) digest = scene_digest(scene) if ( document.get("digest") != digest or document.get("thumbnail") != f"{template_id}-{digest}.png" ): raise TemplateValidationError("template digest is invalid") created_at = str(document.get("created_at", "")) updated_at = str(document.get("updated_at", "")) if not created_at or not updated_at: raise TemplateValidationError("template timestamps are missing") record = { "id": template_id, "name": name, "created_at": created_at, "updated_at": updated_at, "digest": digest, "thumbnail": document["thumbnail"], "scene": scene, } except TemplateValidationError: raise except TemplateError as exc: raise TemplateValidationError(str(exc)) from exc except Exception as exc: raise TemplateValidationError("template record is invalid") from exc if source_version == TEMPLATE_SCHEMA_VERSION and record != document: raise TemplateValidationError( f"template record does not match schema version {TEMPLATE_SCHEMA_VERSION}" ) if source_version != TEMPLATE_SCHEMA_VERSION: record["scene"] = prepare_scene(record["scene"]) record["digest"] = scene_digest(record["scene"]) record["thumbnail"] = f"{template_id}-{record['digest']}.png" elif record["scene"]["version"] != 2: raise TemplateValidationError("current template requires scene v2") return record, source_version != TEMPLATE_SCHEMA_VERSION def _load_record_path(self, path: Path) -> tuple[dict[str, Any], bool]: try: raw = json.loads(path.read_bytes().decode("utf-8")) return self._parse_record_document(path, raw) except TemplateValidationError as exc: raise TemplateValidationError(f"invalid template record {path.name}: {exc}") from exc except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise TemplateValidationError(f"invalid template record {path.name}: unreadable JSON") from exc def _read_record_path(self, path: Path) -> dict[str, Any] | None: if not path.exists(): return None record, migrated = self._load_record_path(path) if migrated: self._write_bytes_atomic(path, self._json_bytes(record)) return record def _preflight_records( self, ) -> tuple[list[dict[str, Any]], list[tuple[Path, dict[str, Any]]]]: records: list[dict[str, Any]] = [] migrations: list[tuple[Path, dict[str, Any]]] = [] for path in sorted(self.records_dir.glob("*.json")): record, migrated = self._load_record_path(path) records.append(record) if migrated: migrations.append((path, record)) return records, migrations def _records(self) -> list[dict[str, Any]]: records: list[dict[str, Any]] = [] for path in sorted(self.records_dir.glob("*.json")): record = self._read_record_path(path) if record is not None: records.append(record) return records def _reconcile(self, records: list[dict[str, Any]] | None = None) -> None: if records is None: records = self._records() expected: set[str] = set() for record in records: expected.add(record["thumbnail"]) path = self.thumbnails_dir / record["thumbnail"] if not path.exists(): try: self._write_thumbnail(record["id"], record["digest"], record["scene"]) except Exception: logger.exception("Failed to rebuild template thumbnail: %s", record["id"]) for path in self.thumbnails_dir.iterdir(): if not path.is_file(): continue if path.name.startswith(".") and path.name.endswith(".tmp"): path.unlink(missing_ok=True) elif THUMBNAIL_RE.fullmatch(path.name) and path.name not in expected: path.unlink(missing_ok=True) def _ensure_unique_name(self, name: str, exclude_id: str | None = None) -> None: key = name.casefold() if any(record["id"] != exclude_id and record["name"].casefold() == key for record in self._records()): raise TemplateConflictError("template name already exists") def _metadata(self, record: dict[str, Any]) -> dict[str, Any]: record_path = self._record_path(record["id"]) thumbnail_path = self.thumbnails_dir / record["thumbnail"] size_bytes = (record_path.stat().st_size if record_path.exists() else 0) size_bytes += thumbnail_path.stat().st_size if thumbnail_path.exists() else 0 return { "id": record["id"], "name": record["name"], "created_at": record["created_at"], "updated_at": record["updated_at"], "digest": record["digest"], "revision": record_revision(record), "thumbnail_url": f"/api/templates/{record['id']}/thumbnail?v={record['digest']}", "size_bytes": size_bytes, } def list(self) -> dict[str, Any]: with self._lock: records = sorted(self._records(), key=lambda item: item["updated_at"], reverse=True) templates = [self._metadata(record) for record in records] usage = shutil.disk_usage(self.root) return { "templates": templates, "storage": { "templates_bytes": sum(item["size_bytes"] for item in templates), "total_bytes": usage.total, "used_bytes": usage.used, "free_bytes": usage.free, }, } def get(self, template_id: Any) -> dict[str, Any]: normalized_id = normalize_template_id(template_id) with self._lock: record = self._read_record_path(self._record_path(normalized_id)) if record is None: raise TemplateNotFoundError("template not found") return {**self._metadata(record), "scene": record["scene"]} def create(self, name: Any, scene: Any) -> dict[str, Any]: checked_name = normalize_name(name) checked_scene = prepare_scene(scene) with self._lock: self._ensure_unique_name(checked_name) template_id = str(uuid4()) timestamp = self._timestamp() digest = scene_digest(checked_scene) record = { "id": template_id, "name": checked_name, "created_at": timestamp, "updated_at": timestamp, "digest": digest, "thumbnail": f"{template_id}-{digest}.png", "scene": checked_scene, } try: self._write_thumbnail(template_id, digest, checked_scene) self._write_bytes_atomic(self._record_path(template_id), self._json_bytes(record)) except Exception: self._thumbnail_path(template_id, digest).unlink(missing_ok=True) raise self._reconcile() return {**self._metadata(record), "scene": record["scene"]} @staticmethod def _ensure_revision(record: dict[str, Any], expected_revision: str) -> None: if record_revision(record) != expected_revision: raise TemplateConflictError("template was changed by another client") def update_scene(self, template_id: Any, scene: Any, expected_revision: str) -> dict[str, Any]: normalized_id = normalize_template_id(template_id) checked_scene = prepare_scene(scene) with self._lock: current = self._read_record_path(self._record_path(normalized_id)) if current is None: raise TemplateNotFoundError("template not found") self._ensure_revision(current, expected_revision) digest = scene_digest(checked_scene) updated = { **current, "updated_at": self._timestamp(), "digest": digest, "thumbnail": f"{normalized_id}-{digest}.png", "scene": checked_scene, } self._write_thumbnail(normalized_id, digest, checked_scene) try: self._write_bytes_atomic(self._record_path(normalized_id), self._json_bytes(updated)) except Exception: if digest != current["digest"]: self._thumbnail_path(normalized_id, digest).unlink(missing_ok=True) raise self._reconcile() return {**self._metadata(updated), "scene": updated["scene"]} def append_layer(self, template_id: Any, layer: Any, expected_revision: str) -> dict[str, Any]: from app.scene_layers import copy_layer, validate_layer try: checked = validate_layer(layer, _validate_scene_v1) except (ValueError, TypeError, KeyError) as exc: raise TemplateValidationError(str(exc)) from exc with self._lock: current = self.get(template_id) scene = prepare_scene(current["scene"]) scene["layers"].append(copy_layer(checked, scene["layers"])) return self.update_scene(template_id, scene, expected_revision) def rename(self, template_id: Any, name: Any, expected_revision: str) -> dict[str, Any]: normalized_id = normalize_template_id(template_id) checked_name = normalize_name(name) with self._lock: current = self._read_record_path(self._record_path(normalized_id)) if current is None: raise TemplateNotFoundError("template not found") self._ensure_revision(current, expected_revision) self._ensure_unique_name(checked_name, exclude_id=normalized_id) updated = {**current, "name": checked_name, "updated_at": self._timestamp()} self._write_bytes_atomic(self._record_path(normalized_id), self._json_bytes(updated)) self._reconcile() return {**self._metadata(updated), "scene": updated["scene"]} def copy(self, template_id: Any) -> dict[str, Any]: current = self.get(template_id) with self._lock: base = f"{current['name']} - 副本" candidate = base sequence = 2 names = {record["name"].casefold() for record in self._records()} while candidate.casefold() in names: candidate = f"{base} {sequence}" sequence += 1 return self.create(candidate, current["scene"]) def delete(self, template_id: Any, expected_revision: str) -> None: normalized_id = normalize_template_id(template_id) with self._lock: path = self._record_path(normalized_id) current = self._read_record_path(path) if current is None: raise TemplateNotFoundError("template not found") self._ensure_revision(current, expected_revision) try: path.unlink() for thumbnail in self.thumbnails_dir.glob(f"{normalized_id}-*.png"): if THUMBNAIL_RE.fullmatch(thumbnail.name): thumbnail.unlink(missing_ok=True) except OSError as exc: self._translate_write_error(exc) self._reconcile() def thumbnail_path(self, template_id: Any, digest: str | None = None) -> Path: normalized_id = normalize_template_id(template_id) with self._lock: record = self._read_record_path(self._record_path(normalized_id)) if record is None: raise TemplateNotFoundError("template not found") if digest is not None and digest != record["digest"]: raise TemplateNotFoundError("template thumbnail not found") path = self.thumbnails_dir / record["thumbnail"] if not path.exists(): raise TemplateNotFoundError("template thumbnail not found") return path