Add Android animation library browsing
This commit is contained in:
@@ -1 +1 @@
|
||||
2026-10-10T10:18+08:00
|
||||
2026-10-10T14:17+08:00
|
||||
|
||||
@@ -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'})
|
||||
|
||||
Reference in New Issue
Block a user