Files
matrix-screen-controller/核桃派软件源代码/app/templates/store.py
T

672 lines
27 KiB
Python

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<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P<digest>[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