Improve Android app UI and controller flows

This commit is contained in:
2026-10-09 16:43:58 +08:00
parent c0e26b97fe
commit 608229a497
90 changed files with 8103 additions and 1133 deletions
@@ -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]: