672 lines
27 KiB
Python
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
|