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

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}