712 lines
35 KiB
Python
712 lines
35 KiB
Python
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}
|