Improve Android app UI and controller flows
This commit is contained in:
@@ -19,10 +19,10 @@ from app.persistence import atomic_write_bytes
|
||||
from app.templates.store import (
|
||||
TemplateConflictError, TemplateError, TemplateNotFoundError,
|
||||
TemplateStorageFullError, TemplateValidationError,
|
||||
render_scene_thumbnail, scene_digest, validate_scene,
|
||||
render_scene_thumbnail, scene_digest, validate_scene, prepare_scene,
|
||||
)
|
||||
|
||||
ANIMATION_SCHEMA_VERSION = 2
|
||||
ANIMATION_SCHEMA_VERSION = 3
|
||||
NAME_LIMIT = 80
|
||||
RECORD_FIELDS = frozenset({"id", "name", "created_at", "updated_at", "frames"})
|
||||
FRAME_FIELDS = frozenset({
|
||||
@@ -74,7 +74,7 @@ def _revision(record: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
class AnimationStore:
|
||||
"""Schema-v2 animations: compact metadata plus one strict scene per frame."""
|
||||
"""Schema-v3 animations: compact metadata plus one scene-v2 file per frame."""
|
||||
|
||||
def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None,
|
||||
cleanup_playback: bool = True) -> None:
|
||||
@@ -131,7 +131,7 @@ class AnimationStore:
|
||||
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)},
|
||||
{"schema_version": 3, **_persisted(record)},
|
||||
ensure_ascii=False, sort_keys=True, indent=2,
|
||||
).encode("utf-8") + b"\n"
|
||||
try:
|
||||
@@ -210,22 +210,54 @@ class AnimationStore:
|
||||
"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 _migrate_legacy(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
|
||||
if value["schema_version"] == 1:
|
||||
record = self._v1_record(value, expected_id)
|
||||
else:
|
||||
# Validate all old paths, digests and metadata before creating candidates.
|
||||
document = dict(value)
|
||||
document.pop("schema_version")
|
||||
if set(document) != RECORD_FIELDS or document.get("id") != expected_id:
|
||||
raise TemplateValidationError("legacy animation fields are invalid")
|
||||
frames, ids = [], set()
|
||||
if not isinstance(document["frames"], list) or not document["created_at"] or not document["updated_at"]:
|
||||
raise TemplateValidationError("legacy animation metadata is invalid")
|
||||
_name(document["name"])
|
||||
for raw in document["frames"]:
|
||||
if not isinstance(raw, dict) or set(raw) != FRAME_FIELDS:
|
||||
raise TemplateValidationError("legacy frame fields are invalid")
|
||||
ident = _uuid(raw["id"], "animation frame not found")
|
||||
if ident in ids or raw["scene_file"] != f"{ident}-{raw['digest']}.json" or raw["thumbnail"] != f"{ident}-{raw['digest']}.png":
|
||||
raise TemplateValidationError("legacy frame paths or ids are invalid")
|
||||
ids.add(ident); _duration(raw["duration_ms"]); _name(raw["name"], optional=True)
|
||||
frames.append(dict(raw, scene=self._load_scene(expected_id, raw)))
|
||||
record = {**document, "frames": frames}
|
||||
created = []
|
||||
try:
|
||||
for frame in record["frames"]:
|
||||
frame["scene"] = prepare_scene(frame["scene"])
|
||||
frame["digest"] = scene_digest(frame["scene"])
|
||||
frame["scene_file"] = f"{frame['id']}-{frame['digest']}.json"
|
||||
frame["thumbnail"] = f"{frame['id']}-{frame['digest']}.png"
|
||||
for path in (self._scene_dir(expected_id)/frame["scene_file"], self._thumb_dir(expected_id)/frame["thumbnail"]):
|
||||
if not path.exists(): created.append(path)
|
||||
self._write_scene(expected_id, frame)
|
||||
self._write_thumbnail(expected_id, frame)
|
||||
self._write(record)
|
||||
except Exception:
|
||||
for path in created: path.unlink(missing_ok=True)
|
||||
raise
|
||||
return {"schema_version": 3, **_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)
|
||||
if type(value.get("schema_version")) is not int:
|
||||
raise TemplateValidationError("animation schema_version must be an integer")
|
||||
if value.get("schema_version") in (1, 2):
|
||||
value = self._migrate_legacy(value, expected_id)
|
||||
document = dict(value)
|
||||
if document.pop("schema_version", None) != 2:
|
||||
if document.pop("schema_version", None) != 3:
|
||||
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")
|
||||
@@ -252,7 +284,8 @@ class AnimationStore:
|
||||
"updated_at": str(raw.get("updated_at") or ""),
|
||||
"digest": digest, "thumbnail": thumbnail, "scene_file": scene_file,
|
||||
}
|
||||
self._load_scene(expected_id, frame)
|
||||
if self._load_scene(expected_id, frame)["version"] != 2:
|
||||
raise TemplateValidationError("animation schema v3 requires scene v2")
|
||||
if not (self._thumb_dir(expected_id) / thumbnail).is_file():
|
||||
self._write_thumbnail(expected_id, frame)
|
||||
frames.append(frame)
|
||||
@@ -435,7 +468,7 @@ class AnimationStore:
|
||||
|
||||
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)
|
||||
scene = prepare_scene(source.get("scene"))
|
||||
digest = scene_digest(scene)
|
||||
return {
|
||||
"id": frame_id, "name": _name(source.get("name"), optional=True),
|
||||
@@ -499,7 +532,7 @@ class AnimationStore:
|
||||
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),
|
||||
"scene": prepare_scene(scene), "name": _name(name, optional=True),
|
||||
"duration_ms": _duration(duration_ms),
|
||||
}
|
||||
with self._lock:
|
||||
@@ -549,7 +582,8 @@ class AnimationStore:
|
||||
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))
|
||||
checked = prepare_scene(scene)
|
||||
digest = scene_digest(checked)
|
||||
frame.update({
|
||||
"scene": checked, "digest": digest,
|
||||
"thumbnail": f"{frame['id']}-{digest}.png",
|
||||
@@ -614,6 +648,37 @@ class AnimationStore:
|
||||
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 append_layer(self, animation_id: Any, layer: Any, frame_ids: Any, expected: str) -> dict[str, Any]:
|
||||
from app.scene_layers import copy_layer, validate_layer
|
||||
from app.templates.store import _validate_scene_v1
|
||||
try:
|
||||
checked = validate_layer(layer, _validate_scene_v1)
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
raise TemplateValidationError(str(exc)) from exc
|
||||
with self._lock:
|
||||
record = self._read(animation_id); self._match(record, expected)
|
||||
targets = self._frames(record, frame_ids)
|
||||
created, now = [], self._now()
|
||||
try:
|
||||
for frame in targets:
|
||||
scene = prepare_scene(self._load_scene(record["id"], frame))
|
||||
scene["layers"].append(copy_layer(checked, scene["layers"]))
|
||||
digest = scene_digest(scene)
|
||||
frame.update(scene=scene, digest=digest, updated_at=now,
|
||||
scene_file=f"{frame['id']}-{digest}.json",
|
||||
thumbnail=f"{frame['id']}-{digest}.png")
|
||||
for path in (self._scene_dir(record["id"])/frame["scene_file"], self._thumb_dir(record["id"])/frame["thumbnail"]):
|
||||
if not path.exists(): created.append(path)
|
||||
self._write_scene(record["id"], frame)
|
||||
self._write_thumbnail(record["id"], frame)
|
||||
record["updated_at"] = now
|
||||
self._write(record)
|
||||
except Exception:
|
||||
for path in created: path.unlink(missing_ok=True)
|
||||
raise
|
||||
self._cleanup_orphans(record)
|
||||
return self._detail(record)
|
||||
|
||||
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]:
|
||||
|
||||
Reference in New Issue
Block a user