初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .store import AnimationStore
|
||||
|
||||
__all__ = ["AnimationStore"]
|
||||
@@ -0,0 +1,3 @@
|
||||
DEFAULT_DURATION_MS = 500
|
||||
MIN_DURATION_MS = 50
|
||||
MAX_DURATION_MS = 7 * 24 * 60 * 60 * 1000
|
||||
@@ -0,0 +1,711 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.animations.constants import DEFAULT_DURATION_MS, MAX_DURATION_MS, MIN_DURATION_MS
|
||||
from app.display.text_renderer import FontResolver
|
||||
from app.persistence import atomic_write_bytes
|
||||
from app.templates.store import (
|
||||
TemplateConflictError, TemplateError, TemplateNotFoundError,
|
||||
TemplateStorageFullError, TemplateValidationError,
|
||||
render_scene_thumbnail, scene_digest, validate_scene,
|
||||
)
|
||||
|
||||
ANIMATION_SCHEMA_VERSION = 2
|
||||
NAME_LIMIT = 80
|
||||
RECORD_FIELDS = frozenset({"id", "name", "created_at", "updated_at", "frames"})
|
||||
FRAME_FIELDS = frozenset({
|
||||
"id", "name", "duration_ms", "created_at", "updated_at",
|
||||
"digest", "thumbnail", "scene_file",
|
||||
})
|
||||
V1_FRAME_FIELDS = frozenset((FRAME_FIELDS - {"scene_file"}) | {"scene"})
|
||||
THUMBNAIL_RE = re.compile(r"^[0-9a-f-]{36}-[0-9a-f]{16}\.png$")
|
||||
SCENE_RE = re.compile(r"^[0-9a-f-]{36}-[0-9a-f]{16}\.json$")
|
||||
|
||||
|
||||
def _uuid(value: Any, message: str) -> str:
|
||||
try:
|
||||
return str(UUID(str(value)))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise TemplateNotFoundError(message) from exc
|
||||
|
||||
|
||||
def _name(value: Any, *, optional: bool = False) -> str | None:
|
||||
if optional and (value is None or value == ""):
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise TemplateValidationError("name must be a string")
|
||||
checked = value.strip()
|
||||
if not checked or len(checked) > NAME_LIMIT:
|
||||
raise TemplateValidationError(f"name must contain 1..{NAME_LIMIT} characters")
|
||||
return checked
|
||||
|
||||
|
||||
def _duration(value: Any) -> int:
|
||||
if type(value) is not int or not MIN_DURATION_MS <= value <= MAX_DURATION_MS:
|
||||
raise TemplateValidationError(
|
||||
f"duration_ms must be in {MIN_DURATION_MS}..{MAX_DURATION_MS}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _persisted(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": record["id"], "name": record["name"],
|
||||
"created_at": record["created_at"], "updated_at": record["updated_at"],
|
||||
"frames": [{key: frame[key] for key in FRAME_FIELDS} for frame in record["frames"]],
|
||||
}
|
||||
|
||||
|
||||
def _revision(record: dict[str, Any]) -> str:
|
||||
raw = json.dumps(_persisted(record), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
class AnimationStore:
|
||||
"""Schema-v2 animations: compact metadata plus one strict scene per frame."""
|
||||
|
||||
def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None,
|
||||
cleanup_playback: bool = True) -> None:
|
||||
self.root = Path(data_dir) / "animations"
|
||||
self._font_resolver = font_resolver
|
||||
self._lock = threading.RLock()
|
||||
self._metadata_size_cache: dict[str, tuple[str, int]] = {}
|
||||
self._list_cache: dict[str, Any] | None = None
|
||||
self._list_signature: tuple[tuple[str, int, int], ...] = ()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self._playback_root = self.root / ".playback"
|
||||
if cleanup_playback:
|
||||
shutil.rmtree(self._playback_root, ignore_errors=True)
|
||||
self._playback_root.mkdir(parents=True, exist_ok=True)
|
||||
records: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for child in self.root.iterdir():
|
||||
if child.is_dir() and child.name.startswith(".staging-"):
|
||||
if cleanup_playback and child.stat().st_mtime < time.time() - 24 * 60 * 60:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
elif child.is_dir() and child.name != ".playback":
|
||||
record = self._read(child.name)
|
||||
self._cleanup_orphans(record)
|
||||
records.append(record)
|
||||
# Startup already performed the expensive strict frame/thumbnail
|
||||
# validation. Build the first listing from those same records so a
|
||||
# large library is immediately usable instead of doing all I/O a
|
||||
# second time on the first browser request.
|
||||
self._prime_list_cache(records)
|
||||
self._list_signature = self._library_signature()
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _dir(self, animation_id: str) -> Path:
|
||||
return self.root / animation_id
|
||||
|
||||
def _path(self, animation_id: str) -> Path:
|
||||
return self._dir(animation_id) / "animation.json"
|
||||
|
||||
def _thumb_dir(self, animation_id: str) -> Path:
|
||||
return self._dir(animation_id) / "thumbnails"
|
||||
|
||||
def _scene_dir(self, animation_id: str) -> Path:
|
||||
return self._dir(animation_id) / "scenes"
|
||||
|
||||
@staticmethod
|
||||
def _translate(exc: OSError) -> None:
|
||||
if exc.errno in {errno.ENOSPC, errno.EDQUOT}:
|
||||
raise TemplateStorageFullError("animation storage is full") from exc
|
||||
raise TemplateError(str(exc)) from exc
|
||||
|
||||
def _write_at(self, record: dict[str, Any], directory_id: str) -> None:
|
||||
self._dir(directory_id).mkdir(parents=True, exist_ok=True)
|
||||
data = json.dumps(
|
||||
{"schema_version": 2, **_persisted(record)},
|
||||
ensure_ascii=False, sort_keys=True, indent=2,
|
||||
).encode("utf-8") + b"\n"
|
||||
try:
|
||||
atomic_write_bytes(self._path(directory_id), data)
|
||||
except OSError as exc:
|
||||
self._translate(exc)
|
||||
|
||||
def _write(self, record: dict[str, Any]) -> None:
|
||||
self._write_at(record, record["id"])
|
||||
self._list_cache = None
|
||||
|
||||
def _write_scene(self, animation_id: str, frame: dict[str, Any]) -> None:
|
||||
scene = validate_scene(frame.get("scene"), strict_schema=True)
|
||||
self._scene_dir(animation_id).mkdir(parents=True, exist_ok=True)
|
||||
data = json.dumps(scene, ensure_ascii=False, sort_keys=True, indent=2).encode("utf-8") + b"\n"
|
||||
try:
|
||||
atomic_write_bytes(self._scene_dir(animation_id) / frame["scene_file"], data)
|
||||
except OSError as exc:
|
||||
self._translate(exc)
|
||||
|
||||
def _load_scene(self, animation_id: str, frame: dict[str, Any]) -> dict[str, Any]:
|
||||
path = self._scene_dir(animation_id) / frame["scene_file"]
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise TemplateValidationError(f"animation frame {frame['id']} is unreadable") from exc
|
||||
scene = validate_scene(value, strict_schema=True)
|
||||
if scene_digest(scene) != frame["digest"]:
|
||||
raise TemplateValidationError("animation frame digest does not match scene")
|
||||
return scene
|
||||
|
||||
def _write_thumbnail(self, animation_id: str, frame: dict[str, Any]) -> None:
|
||||
self._thumb_dir(animation_id).mkdir(parents=True, exist_ok=True)
|
||||
path = self._thumb_dir(animation_id) / frame["thumbnail"]
|
||||
if path.exists():
|
||||
return
|
||||
scene = frame.get("scene") or self._load_scene(animation_id, frame)
|
||||
try:
|
||||
atomic_write_bytes(path, render_scene_thumbnail(scene, font_resolver=self._font_resolver))
|
||||
except OSError as exc:
|
||||
self._translate(exc)
|
||||
|
||||
def _v1_record(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
|
||||
document = dict(value)
|
||||
if document.pop("schema_version", None) != 1 or set(document) != RECORD_FIELDS:
|
||||
raise TemplateValidationError("animation schema_version is unsupported")
|
||||
if document.get("id") != expected_id:
|
||||
raise TemplateValidationError("animation id does not match its directory")
|
||||
if not document.get("created_at") or not document.get("updated_at"):
|
||||
raise TemplateValidationError("animation timestamps are required")
|
||||
if not isinstance(document.get("frames"), list):
|
||||
raise TemplateValidationError("animation frames must be an array")
|
||||
frames, ids = [], set()
|
||||
for raw in document["frames"]:
|
||||
if not isinstance(raw, dict) or set(raw) != V1_FRAME_FIELDS:
|
||||
raise TemplateValidationError("animation frame fields do not match schema v1")
|
||||
frame_id = _uuid(raw.get("id"), "animation frame not found")
|
||||
if frame_id in ids:
|
||||
raise TemplateValidationError("animation frame ids must be unique")
|
||||
ids.add(frame_id)
|
||||
scene = validate_scene(raw.get("scene"), strict_schema=True)
|
||||
digest = scene_digest(scene)
|
||||
if raw.get("digest") != digest or raw.get("thumbnail") != f"{frame_id}-{digest}.png":
|
||||
raise TemplateValidationError("animation frame digest does not match scene")
|
||||
frames.append({
|
||||
"id": frame_id, "name": _name(raw.get("name"), optional=True),
|
||||
"duration_ms": _duration(raw.get("duration_ms")),
|
||||
"created_at": str(raw.get("created_at") or ""),
|
||||
"updated_at": str(raw.get("updated_at") or ""),
|
||||
"digest": digest, "thumbnail": f"{frame_id}-{digest}.png",
|
||||
"scene_file": f"{frame_id}-{digest}.json", "scene": scene,
|
||||
})
|
||||
return {
|
||||
"id": expected_id, "name": _name(document.get("name")),
|
||||
"created_at": str(document["created_at"]),
|
||||
"updated_at": str(document["updated_at"]), "frames": frames,
|
||||
}
|
||||
|
||||
def _migrate_v1(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
|
||||
record = self._v1_record(value, expected_id)
|
||||
# The entire v1 document is validated before authoritative metadata changes.
|
||||
for frame in record["frames"]:
|
||||
self._write_scene(expected_id, frame)
|
||||
self._write_thumbnail(expected_id, frame)
|
||||
self._write(record)
|
||||
return {"schema_version": 2, **_persisted(record)}
|
||||
|
||||
def _validate_record(self, value: Any, expected_id: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise TemplateValidationError("animation record must be an object")
|
||||
if value.get("schema_version") == 1:
|
||||
value = self._migrate_v1(value, expected_id)
|
||||
document = dict(value)
|
||||
if document.pop("schema_version", None) != 2:
|
||||
raise TemplateValidationError("animation schema_version is unsupported")
|
||||
if set(document) != RECORD_FIELDS or document.get("id") != expected_id:
|
||||
raise TemplateValidationError("animation fields do not match the current schema")
|
||||
if not document.get("created_at") or not document.get("updated_at"):
|
||||
raise TemplateValidationError("animation timestamps are required")
|
||||
if not isinstance(document.get("frames"), list):
|
||||
raise TemplateValidationError("animation frames must be an array")
|
||||
frames, ids = [], set()
|
||||
for raw in document["frames"]:
|
||||
if not isinstance(raw, dict) or set(raw) != FRAME_FIELDS:
|
||||
raise TemplateValidationError("animation frame fields do not match the current schema")
|
||||
frame_id = _uuid(raw.get("id"), "animation frame not found")
|
||||
if frame_id in ids:
|
||||
raise TemplateValidationError("animation frame ids must be unique")
|
||||
ids.add(frame_id)
|
||||
digest = str(raw.get("digest") or "")
|
||||
thumbnail, scene_file = f"{frame_id}-{digest}.png", f"{frame_id}-{digest}.json"
|
||||
if raw.get("thumbnail") != thumbnail or raw.get("scene_file") != scene_file:
|
||||
raise TemplateValidationError("animation frame paths do not match its digest")
|
||||
frame = {
|
||||
"id": frame_id, "name": _name(raw.get("name"), optional=True),
|
||||
"duration_ms": _duration(raw.get("duration_ms")),
|
||||
"created_at": str(raw.get("created_at") or ""),
|
||||
"updated_at": str(raw.get("updated_at") or ""),
|
||||
"digest": digest, "thumbnail": thumbnail, "scene_file": scene_file,
|
||||
}
|
||||
self._load_scene(expected_id, frame)
|
||||
if not (self._thumb_dir(expected_id) / thumbnail).is_file():
|
||||
self._write_thumbnail(expected_id, frame)
|
||||
frames.append(frame)
|
||||
return {
|
||||
"id": expected_id, "name": _name(document.get("name")),
|
||||
"created_at": str(document["created_at"]),
|
||||
"updated_at": str(document["updated_at"]), "frames": frames,
|
||||
}
|
||||
|
||||
def _read(self, animation_id: Any) -> dict[str, Any]:
|
||||
normalized = _uuid(animation_id, "animation not found")
|
||||
path = self._path(normalized)
|
||||
if not path.is_file():
|
||||
raise TemplateNotFoundError("animation not found")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise TemplateValidationError(f"animation {normalized} is unreadable") from exc
|
||||
return self._validate_record(value, normalized)
|
||||
|
||||
def _cleanup_orphans(self, record: dict[str, Any]) -> None:
|
||||
for directory, expected, pattern in (
|
||||
(self._thumb_dir(record["id"]), {f["thumbnail"] for f in record["frames"]}, THUMBNAIL_RE),
|
||||
(self._scene_dir(record["id"]), {f["scene_file"] for f in record["frames"]}, SCENE_RE),
|
||||
):
|
||||
if directory.exists():
|
||||
for path in directory.iterdir():
|
||||
if path.is_file() and path.name not in expected and pattern.fullmatch(path.name):
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _all(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
self._read(child.name) for child in self.root.iterdir()
|
||||
if child.is_dir() and not child.name.startswith(".")
|
||||
]
|
||||
|
||||
def _library_signature(self) -> tuple[tuple[str, int, int], ...]:
|
||||
"""Cheaply identify committed library changes made by another process."""
|
||||
entries: list[tuple[str, int, int]] = []
|
||||
for child in self.root.iterdir():
|
||||
if not child.is_dir() or child.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
stat = self._path(child.name).stat()
|
||||
entries.append((child.name, stat.st_mtime_ns, stat.st_size))
|
||||
except FileNotFoundError:
|
||||
# Keep the in-progress directory transition visible to the
|
||||
# before/after stability check in _refresh_list_cache().
|
||||
entries.append((child.name, -1, -1))
|
||||
return tuple(sorted(entries))
|
||||
|
||||
def _refresh_list_cache(self) -> None:
|
||||
# An isolated media worker commits animations with an atomic directory
|
||||
# rename. Read only across a stable signature so that a concurrent
|
||||
# commit or delete cannot become the new long-lived cached snapshot.
|
||||
for _attempt in range(3):
|
||||
before = self._library_signature()
|
||||
try:
|
||||
records = self._all()
|
||||
except (TemplateNotFoundError, TemplateValidationError):
|
||||
if before != self._library_signature():
|
||||
continue
|
||||
raise
|
||||
after = self._library_signature()
|
||||
if before == after:
|
||||
self._prime_list_cache(records)
|
||||
self._list_signature = after
|
||||
return
|
||||
|
||||
# Continuous external changes are exceptional. Return a fresh scan and
|
||||
# leave the signature from after that scan so the next request checks it
|
||||
# again instead of trusting an older snapshot.
|
||||
self._prime_list_cache(self._all())
|
||||
self._list_signature = self._library_signature()
|
||||
|
||||
def _prime_list_cache(self, records: Iterable[dict[str, Any]]) -> None:
|
||||
ordered = sorted(records, key=lambda record: record["updated_at"], reverse=True)
|
||||
metadata = [self._metadata(record) for record in ordered]
|
||||
usage = shutil.disk_usage(self.root)
|
||||
self._list_cache = {
|
||||
"animations": metadata,
|
||||
"storage": {
|
||||
"animations_bytes": sum(item["size_bytes"] for item in metadata),
|
||||
"total_bytes": usage.total,
|
||||
"used_bytes": usage.used,
|
||||
"free_bytes": usage.free,
|
||||
},
|
||||
}
|
||||
|
||||
def _unique(self, name: str, exclude: str | None = None) -> None:
|
||||
folded = name.casefold()
|
||||
if any(r["id"] != exclude and r["name"].casefold() == folded for r in self._all()):
|
||||
raise TemplateConflictError("animation name already exists")
|
||||
|
||||
def name_available(self, name: Any) -> bool:
|
||||
checked = _name(name)
|
||||
with self._lock:
|
||||
return all(r["name"].casefold() != checked.casefold() for r in self._all())
|
||||
|
||||
def thumbnail_url(self, animation_id: str, frame: dict[str, Any]) -> str:
|
||||
return f"/api/animations/{animation_id}/frames/{frame['id']}/thumbnail?v={frame['digest']}"
|
||||
|
||||
def _metadata(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
revision = _revision(record)
|
||||
cached = self._metadata_size_cache.get(record["id"])
|
||||
if cached is not None and cached[0] == revision:
|
||||
size = cached[1]
|
||||
else:
|
||||
size = self._path(record["id"]).stat().st_size
|
||||
for frame in record["frames"]:
|
||||
for path in (
|
||||
self._thumb_dir(record["id"]) / frame["thumbnail"],
|
||||
self._scene_dir(record["id"]) / frame["scene_file"],
|
||||
):
|
||||
if path.exists():
|
||||
size += path.stat().st_size
|
||||
self._metadata_size_cache[record["id"]] = (revision, size)
|
||||
first = record["frames"][0] if record["frames"] else None
|
||||
return {
|
||||
"id": record["id"], "name": record["name"],
|
||||
"created_at": record["created_at"], "updated_at": record["updated_at"],
|
||||
"revision": revision, "frame_count": len(record["frames"]),
|
||||
"total_duration_ms": sum(f["duration_ms"] for f in record["frames"]),
|
||||
"size_bytes": size,
|
||||
"thumbnail_url": self.thumbnail_url(record["id"], first) if first else None,
|
||||
}
|
||||
|
||||
def _detail(self, record: dict[str, Any], *, scenes: bool = False,
|
||||
offset: int | None = None, limit: int | None = None) -> dict[str, Any]:
|
||||
if offset is None and limit is None:
|
||||
start, end, page = 0, len(record["frames"]), {}
|
||||
else:
|
||||
start = max(0, int(offset or 0))
|
||||
checked_limit = max(1, min(500, int(limit or 100)))
|
||||
end = min(len(record["frames"]), start + checked_limit)
|
||||
page = {
|
||||
"frame_offset": start, "frame_limit": checked_limit,
|
||||
"frame_total": len(record["frames"]),
|
||||
}
|
||||
frames = []
|
||||
for index in range(start, end):
|
||||
frame = record["frames"][index]
|
||||
item = {
|
||||
"id": frame["id"], "name": frame["name"], "position": index + 1,
|
||||
"duration_ms": frame["duration_ms"], "created_at": frame["created_at"],
|
||||
"updated_at": frame["updated_at"], "digest": frame["digest"],
|
||||
"thumbnail_url": self.thumbnail_url(record["id"], frame),
|
||||
}
|
||||
if scenes:
|
||||
item["scene"] = self._load_scene(record["id"], frame)
|
||||
frames.append(item)
|
||||
return {**self._metadata(record), **page, "frames": frames}
|
||||
|
||||
def list(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
signature = self._library_signature()
|
||||
if self._list_cache is None or signature != self._list_signature:
|
||||
self._refresh_list_cache()
|
||||
return {
|
||||
"animations": [dict(item) for item in self._list_cache["animations"]],
|
||||
"storage": dict(self._list_cache["storage"]),
|
||||
}
|
||||
|
||||
def get(self, animation_id: Any, *, scenes: bool = False,
|
||||
offset: int | None = None, limit: int | None = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return self._detail(self._read(animation_id), scenes=scenes, offset=offset, limit=limit)
|
||||
|
||||
def create(self, name: Any) -> dict[str, Any]:
|
||||
checked = _name(name)
|
||||
with self._lock:
|
||||
self._unique(checked)
|
||||
animation_id, now = str(uuid4()), self._now()
|
||||
record = {
|
||||
"id": animation_id, "name": checked,
|
||||
"created_at": now, "updated_at": now, "frames": [],
|
||||
}
|
||||
self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def _new_frame(self, source: dict[str, Any], now: str) -> dict[str, Any]:
|
||||
frame_id = str(uuid4())
|
||||
scene = validate_scene(source.get("scene"), strict_schema=True)
|
||||
digest = scene_digest(scene)
|
||||
return {
|
||||
"id": frame_id, "name": _name(source.get("name"), optional=True),
|
||||
"duration_ms": _duration(source.get("duration_ms")),
|
||||
"created_at": now, "updated_at": now, "digest": digest,
|
||||
"thumbnail": f"{frame_id}-{digest}.png",
|
||||
"scene_file": f"{frame_id}-{digest}.json", "scene": scene,
|
||||
}
|
||||
|
||||
def create_from_frames(self, name: Any, frames: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
||||
checked = _name(name)
|
||||
with self._lock:
|
||||
self._unique(checked)
|
||||
animation_id, now = str(uuid4()), self._now()
|
||||
staging_id = ".staging-" + animation_id
|
||||
record = {
|
||||
"id": animation_id, "name": checked,
|
||||
"created_at": now, "updated_at": now, "frames": [],
|
||||
}
|
||||
try:
|
||||
for source in frames:
|
||||
frame = self._new_frame(source, now)
|
||||
self._write_scene(staging_id, frame)
|
||||
self._write_thumbnail(staging_id, frame)
|
||||
record["frames"].append(frame)
|
||||
if not record["frames"]:
|
||||
raise TemplateValidationError("animation must contain at least one frame")
|
||||
self._write_at(record, staging_id)
|
||||
self._dir(staging_id).replace(self._dir(animation_id))
|
||||
self._list_cache = None
|
||||
except Exception:
|
||||
shutil.rmtree(self._dir(staging_id), ignore_errors=True)
|
||||
raise
|
||||
return self._detail(record)
|
||||
|
||||
def copy_from_frames(self, source_name: Any, frames: Any) -> dict[str, Any]:
|
||||
source_name = _name(source_name)
|
||||
if not isinstance(frames, list) or not frames:
|
||||
raise TemplateValidationError("animation copy source must contain frames")
|
||||
with self._lock:
|
||||
base, sequence = f"{source_name} - 副本", 2
|
||||
candidate = base
|
||||
names = {r["name"].casefold() for r in self._all()}
|
||||
while candidate.casefold() in names:
|
||||
candidate = f"{base} {sequence}"
|
||||
sequence += 1
|
||||
return self.create_from_frames(candidate, frames)
|
||||
|
||||
def _match(self, record: dict[str, Any], expected: str) -> None:
|
||||
if _revision(record) != expected:
|
||||
raise TemplateConflictError("animation was modified")
|
||||
|
||||
def rename(self, animation_id: Any, name: Any, expected: str) -> dict[str, Any]:
|
||||
checked = _name(name)
|
||||
with self._lock:
|
||||
record = self._read(animation_id)
|
||||
self._match(record, expected); self._unique(checked, record["id"])
|
||||
record["name"] = checked; record["updated_at"] = self._now(); self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def add_frame(self, animation_id: Any, scene: Any, expected: str, *,
|
||||
name: Any = None, duration_ms: Any = DEFAULT_DURATION_MS) -> dict[str, Any]:
|
||||
source = {
|
||||
"scene": validate_scene(scene), "name": _name(name, optional=True),
|
||||
"duration_ms": _duration(duration_ms),
|
||||
}
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected); now = self._now()
|
||||
frame = self._new_frame(source, now)
|
||||
self._write_scene(record["id"], frame); self._write_thumbnail(record["id"], frame)
|
||||
record["frames"].append(frame); record["updated_at"] = now; self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def _frame(self, record: dict[str, Any], frame_id: Any) -> dict[str, Any]:
|
||||
normalized = _uuid(frame_id, "animation frame not found")
|
||||
for frame in record["frames"]:
|
||||
if frame["id"] == normalized:
|
||||
return frame
|
||||
raise TemplateNotFoundError("animation frame not found")
|
||||
|
||||
def _frames(self, record: dict[str, Any], frame_ids: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(frame_ids, list) or not frame_ids:
|
||||
raise TemplateValidationError("frame_ids must be a non-empty array")
|
||||
normalized = [_uuid(v, "animation frame not found") for v in frame_ids]
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise TemplateValidationError("frame_ids must not contain duplicates")
|
||||
selected = set(normalized)
|
||||
ordered = [f for f in record["frames"] if f["id"] in selected]
|
||||
if len(ordered) != len(normalized):
|
||||
raise TemplateNotFoundError("animation frame not found")
|
||||
return ordered
|
||||
|
||||
def get_frame(self, animation_id: Any, frame_id: Any) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); frame = self._frame(record, frame_id)
|
||||
return {
|
||||
"animation_id": record["id"], "animation_name": record["name"],
|
||||
"animation_revision": _revision(record),
|
||||
"position": record["frames"].index(frame) + 1,
|
||||
"id": frame["id"], "name": frame["name"],
|
||||
"duration_ms": frame["duration_ms"], "created_at": frame["created_at"],
|
||||
"updated_at": frame["updated_at"], "digest": frame["digest"],
|
||||
"scene": self._load_scene(record["id"], frame),
|
||||
"thumbnail_url": self.thumbnail_url(record["id"], frame),
|
||||
}
|
||||
|
||||
def update_frame(self, animation_id: Any, frame_id: Any, expected: str, *,
|
||||
scene: Any | None = None, name: Any = ..., duration_ms: Any = ...) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
frame = self._frame(record, frame_id); now = self._now()
|
||||
old_thumb, old_scene = frame["thumbnail"], frame["scene_file"]
|
||||
if scene is not None:
|
||||
checked, digest = validate_scene(scene), scene_digest(validate_scene(scene))
|
||||
frame.update({
|
||||
"scene": checked, "digest": digest,
|
||||
"thumbnail": f"{frame['id']}-{digest}.png",
|
||||
"scene_file": f"{frame['id']}-{digest}.json",
|
||||
})
|
||||
self._write_scene(record["id"], frame); self._write_thumbnail(record["id"], frame)
|
||||
if name is not ...:
|
||||
frame["name"] = _name(name, optional=True)
|
||||
if duration_ms is not ...:
|
||||
frame["duration_ms"] = _duration(duration_ms)
|
||||
frame["updated_at"] = now; record["updated_at"] = now; self._write(record)
|
||||
if old_thumb != frame["thumbnail"]:
|
||||
(self._thumb_dir(record["id"]) / old_thumb).unlink(missing_ok=True)
|
||||
(self._scene_dir(record["id"]) / old_scene).unlink(missing_ok=True)
|
||||
return self._detail(record)
|
||||
|
||||
def reorder(self, animation_id: Any, frame_ids: Any, expected: str) -> dict[str, Any]:
|
||||
if not isinstance(frame_ids, list):
|
||||
raise TemplateValidationError("frame_ids must be an array")
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
normalized = [_uuid(v, "animation frame not found") for v in frame_ids]
|
||||
current = [f["id"] for f in record["frames"]]
|
||||
if len(normalized) != len(set(normalized)) or set(normalized) != set(current):
|
||||
raise TemplateValidationError("frame_ids must be an exact permutation")
|
||||
by_id = {f["id"]: f for f in record["frames"]}
|
||||
record["frames"] = [by_id[v] for v in normalized]
|
||||
record["updated_at"] = self._now(); self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def move_frames(self, animation_id: Any, frame_ids: Any, expected: str, *,
|
||||
before_frame_id: Any = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
moved = self._frames(record, frame_ids); moved_ids = {f["id"] for f in moved}
|
||||
remaining = [f for f in record["frames"] if f["id"] not in moved_ids]
|
||||
if before_frame_id is None:
|
||||
insert_at = len(remaining)
|
||||
else:
|
||||
before = _uuid(before_frame_id, "animation frame not found")
|
||||
if before in moved_ids:
|
||||
raise TemplateValidationError("before_frame_id must not be moved")
|
||||
try:
|
||||
insert_at = next(i for i, frame in enumerate(remaining) if frame["id"] == before)
|
||||
except StopIteration as exc:
|
||||
raise TemplateNotFoundError("animation frame not found") from exc
|
||||
remaining[insert_at:insert_at] = moved
|
||||
record["frames"] = remaining; record["updated_at"] = self._now(); self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def set_durations(self, animation_id: Any, duration_ms: Any, expected: str, *,
|
||||
frame_ids: Any = None) -> dict[str, Any]:
|
||||
checked = _duration(duration_ms)
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected); now = self._now()
|
||||
targets = record["frames"] if frame_ids is None else self._frames(record, frame_ids)
|
||||
for frame in targets:
|
||||
frame["duration_ms"] = checked; frame["updated_at"] = now
|
||||
record["updated_at"] = now; self._write(record)
|
||||
return self._detail(record)
|
||||
|
||||
def set_all_durations(self, animation_id: Any, duration_ms: Any, expected: str) -> dict[str, Any]:
|
||||
return self.set_durations(animation_id, duration_ms, expected)
|
||||
|
||||
def copy_frames(self, source_animation_id: Any, frame_ids: Any,
|
||||
source_expected: str, destination_animation_id: Any,
|
||||
destination_expected: str, insert_after_frame_id: Any = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
source = self._read(source_animation_id); self._match(source, source_expected)
|
||||
source_frames = self._frames(source, frame_ids)
|
||||
destination = source if source["id"] == str(destination_animation_id) else self._read(destination_animation_id)
|
||||
self._match(destination, destination_expected)
|
||||
insert_at = 0 if insert_after_frame_id is None else (
|
||||
destination["frames"].index(self._frame(destination, insert_after_frame_id)) + 1
|
||||
)
|
||||
now, copied, created = self._now(), [], []
|
||||
try:
|
||||
for original in source_frames:
|
||||
frame = self._new_frame({
|
||||
"scene": self._load_scene(source["id"], original),
|
||||
"name": original["name"], "duration_ms": original["duration_ms"],
|
||||
}, now)
|
||||
created += [
|
||||
self._thumb_dir(destination["id"]) / frame["thumbnail"],
|
||||
self._scene_dir(destination["id"]) / frame["scene_file"],
|
||||
]
|
||||
self._write_scene(destination["id"], frame)
|
||||
self._write_thumbnail(destination["id"], frame)
|
||||
copied.append(frame)
|
||||
destination["frames"][insert_at:insert_at] = copied
|
||||
destination["updated_at"] = now; self._write(destination)
|
||||
except Exception:
|
||||
for path in created:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
return {
|
||||
"animation": self._detail(destination),
|
||||
"copied_frame_ids": [frame["id"] for frame in copied],
|
||||
}
|
||||
|
||||
def delete_frames(self, animation_id: Any, frame_ids: Any, expected: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
removed = self._frames(record, frame_ids); ids = {f["id"] for f in removed}
|
||||
record["frames"] = [f for f in record["frames"] if f["id"] not in ids]
|
||||
record["updated_at"] = self._now(); self._write(record)
|
||||
for frame in removed:
|
||||
(self._thumb_dir(record["id"]) / frame["thumbnail"]).unlink(missing_ok=True)
|
||||
(self._scene_dir(record["id"]) / frame["scene_file"]).unlink(missing_ok=True)
|
||||
return self._detail(record)
|
||||
|
||||
def delete_frame(self, animation_id: Any, frame_id: Any, expected: str) -> dict[str, Any]:
|
||||
return self.delete_frames(animation_id, [frame_id], expected)
|
||||
|
||||
def delete(self, animation_id: Any, expected: str) -> None:
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
shutil.rmtree(self._dir(record["id"]))
|
||||
self._list_cache = None
|
||||
|
||||
def thumbnail_path(self, animation_id: Any, frame_id: Any, digest: str | None = None) -> Path:
|
||||
if digest is not None:
|
||||
normalized_animation = _uuid(animation_id, "animation not found")
|
||||
normalized_frame = _uuid(frame_id, "animation frame not found")
|
||||
checked_digest = str(digest)
|
||||
if re.fullmatch(r"[0-9a-f]{16}", checked_digest) is None:
|
||||
raise TemplateNotFoundError("animation thumbnail not found")
|
||||
path = self._thumb_dir(normalized_animation) / f"{normalized_frame}-{checked_digest}.png"
|
||||
if not path.is_file():
|
||||
raise TemplateNotFoundError("animation thumbnail not found")
|
||||
return path
|
||||
with self._lock:
|
||||
record = self._read(animation_id); frame = self._frame(record, frame_id)
|
||||
path = self._thumb_dir(record["id"]) / frame["thumbnail"]
|
||||
if not path.is_file():
|
||||
raise TemplateNotFoundError("animation thumbnail not found")
|
||||
return path
|
||||
|
||||
def playback_snapshot(self, animation_id: Any, expected: str | None = None) -> dict[str, Any]:
|
||||
"""Create immutable hard-linked PNG descriptors for lazy display playback."""
|
||||
with self._lock:
|
||||
record = self._read(animation_id); revision = _revision(record)
|
||||
if expected is not None and revision != expected:
|
||||
raise TemplateConflictError("animation was modified")
|
||||
snapshot_dir = self._playback_root / f"{record['id']}-{revision}"
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
frames = []
|
||||
for frame in record["frames"]:
|
||||
source = self._thumb_dir(record["id"]) / frame["thumbnail"]
|
||||
target = snapshot_dir / frame["thumbnail"]
|
||||
if not target.exists():
|
||||
try:
|
||||
os.link(source, target)
|
||||
except OSError:
|
||||
shutil.copyfile(source, target)
|
||||
frames.append({
|
||||
"id": frame["id"], "duration_ms": frame["duration_ms"], "path": target,
|
||||
})
|
||||
return {**self._metadata(record), "frames": frames}
|
||||
Reference in New Issue
Block a user