Add Android animation library browsing

This commit is contained in:
2026-10-10 14:51:16 +08:00
parent 5db283c05a
commit c9dd03c23b
39 changed files with 1015 additions and 46 deletions
+1 -1
View File
@@ -1 +1 @@
2026-10-10T10:18+08:00
2026-10-10T14:17+08:00
+33 -3
View File
@@ -1,6 +1,7 @@
"""Explicit mobile capability surface, sharing existing device business operations."""
from __future__ import annotations
import hashlib
import base64
import json
import time
@@ -51,8 +52,8 @@ class MobileControl:
def identity(self):
record = self.identity_record
return dict(device_id=record["device_id"], device_name=record["name"],
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=2,
capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan", "wifi_transport"],
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=3,
capabilities=["status", "settings", "library", "library_progressive", "library_animation", "playback", "frame", "device_name", "wifi", "wifi_scan", "wifi_transport"],
limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000))
def settings(self):
@@ -97,7 +98,7 @@ class MobileControl:
("animation", [demo_animation()] + self.animations.list()["animations"])):
for item in records:
items.append(dict(type=kind, id=item["id"], name=item["name"], revision=item["revision"],
is_demo=bool(item.get("is_demo", False)), playable=kind == "template" or item.get("frame_count", 0) > 0,
frame_count=item.get("frame_count", 0), is_demo=bool(item.get("is_demo", False)), playable=kind == "template" or item.get("frame_count", 0) > 0,
thumbnail_revision=item["revision"]))
if self.library_order is not None:
user_items = [item for item in items if not item["is_demo"]]
@@ -205,6 +206,35 @@ class MobileControl:
next_offset = offset + limit
return dict(library_revision=revision, items=items[offset:next_offset],
next_cursor=f"{revision}:{next_offset}" if next_offset < len(items) else None)
if method in ("library.animation.info", "library.animation.chunk"):
content = self._content(params)
if content["type"] != "animation" or not content["frames"]:
raise RpcError("NOT_FOUND", "内容没有动画帧")
frames = content["frames"]
if method.endswith(".info"):
offset, limit = params.get("offset", 0), params.get("limit", 50)
if type(offset) is not int or not 0 <= offset < len(frames) or type(limit) is not int or not 1 <= limit <= 50:
raise RpcError("BAD_REQUEST", "帧分页参数无效")
end = min(offset + limit, len(frames))
return dict(revision=content["revision"], frame_count=len(frames),
frames=[dict(index=i, duration_ms=frames[i]["duration_ms"]) for i in range(offset, end)],
next_offset=end if end < len(frames) else None)
index, offset, tier = params.get("frame_index"), params.get("offset", 0), params.get("tier")
if type(index) is not int or not 0 <= index < len(frames) or type(offset) is not int or offset < 0 or tier not in ("preview", "full"):
raise RpcError("BAD_REQUEST", "帧读取参数无效")
image = self._preview_image(dict(type="animation", frames=[frames[index]]))
if tier == "preview":
image = image.resize((16, 16), Image.Resampling.LANCZOS)
output = BytesIO()
image.save(output, format="PNG", optimize=True)
data = output.getvalue()
if offset >= len(data):
raise RpcError("BAD_REQUEST", "帧字节偏移无效")
end = min(offset + 4096, len(data))
return dict(revision=content["revision"], tier=tier, frame_index=index, offset=offset,
total_bytes=len(data), sha256=hashlib.sha256(data).hexdigest(),
data_base64=base64.b64encode(data[offset:end]).decode("ascii"),
next_offset=end if end < len(data) else None)
if method == "library.thumbnail":
content = self._content(params)
return self._png(self._preview_image(content))
@@ -50,7 +50,7 @@ def test_progressive_preview_and_revision_cleanup(tmp_path):
from PIL import Image
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
assert mobile.identity()['protocol_minor'] == 2
assert mobile.identity()['protocol_minor'] == 3
assert 'wifi_transport' in mobile.identity()['capabilities']
assert 'library_progressive' in mobile.identity()['capabilities']
created = mobile.templates.create('预览清理', {
@@ -220,3 +220,46 @@ def test_task_returns_only_sanitized_failure_code(tmp_path, failure):
result = mobile.dispatch('task.get', {'task_id': task})
assert result == {'state': 'failed', 'stage': 'finished', 'error_code': failure}
assert 'private' not in str(result)
def test_animation_chunks_and_read_only(tmp_path):
import base64
import hashlib
from io import BytesIO
from PIL import Image
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
item = next(i for i in mobile.dispatch('library.list', {})['items'] if i['type'] == 'animation')
info = mobile.dispatch('library.animation.info', {**item, 'limit': 1})
assert info['frames'][0]['index'] == 0
frames = list(info['frames'])
while info['next_offset'] is not None:
info = mobile.dispatch('library.animation.info', {**item, 'limit': 1, 'offset': info['next_offset']})
frames += info['frames']
assert len(frames) == info['frame_count']
assert all(f['duration_ms'] > 0 for f in frames)
before = mobile.dispatch('frame.get', {})['frame_revision']
for tier, size in [('preview', 16), ('full', 64)]:
for frame in frames:
offset, data = 0, bytearray()
while True:
result = mobile.dispatch('library.animation.chunk', {**item, 'tier': tier, 'frame_index': frame['index'], 'offset': offset})
chunk = base64.b64decode(result['data_base64'], validate=True)
assert 0 < len(chunk) <= 4096 and result['offset'] == offset
data.extend(chunk)
if result['next_offset'] is None:
break
assert result['next_offset'] == offset + len(chunk)
offset = result['next_offset']
assert len(data) == result['total_bytes']
assert hashlib.sha256(data).hexdigest() == result['sha256']
with Image.open(BytesIO(data)) as image:
assert image.size == (size, size)
assert mobile.dispatch('frame.get', {})['frame_revision'] == before
for bad in [{'offset': -1}, {'limit': 51}, {'offset': True}]:
with pytest.raises(RpcError, match='BAD_REQUEST'):
mobile.dispatch('library.animation.info', {**item, **bad})
for bad in [{'frame_index': -1}, {'frame_index': True}, {'tier': 'invalid'}, {'offset': 999999}]:
with pytest.raises(RpcError, match='BAD_REQUEST'):
mobile.dispatch('library.animation.chunk', {**item, 'tier': 'full', 'frame_index': 0, **bad})
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('library.animation.info', {**item, 'revision': 'stale'})