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
@@ -0,0 +1,168 @@
"""Scene-v2 validation, legacy upgrade and logical composition. No driver I/O."""
from __future__ import annotations
import base64
import binascii
import copy
import math
from io import BytesIO
from uuid import uuid4
from PIL import Image
from app.display.text_renderer import TextOptions, render_text_layer
from app.display.colors import parse_color
SIDE = 192
ORIGIN = 64
def decode_pixels(value):
if not isinstance(value, str) or len(value) > 300000:
raise ValueError("brush PNG must be bounded base64")
try:
data = base64.b64decode(value, validate=True)
if (data[:8] != b"\x89PNG\r\n\x1a\n" or data[8:16] != b"\x00\x00\x00\rIHDR"
or data[24:29] != b"\x08\x06\x00\x00\x00"):
raise ValueError("brush PNG must be noninterlaced RGBA8")
with Image.open(BytesIO(data)) as image:
image.verify()
with Image.open(BytesIO(data)) as image:
if image.format != "PNG" or image.size != (SIDE, SIDE) or image.mode != "RGBA":
raise ValueError("brush PNG must be 192x192 RGBA")
image.load()
return image.copy()
except (OSError, binascii.Error) as exc:
raise ValueError("invalid brush PNG") from exc
def encode_pixels(image):
out = BytesIO()
image.save(out, format="PNG", compress_level=6)
return base64.b64encode(out.getvalue()).decode("ascii")
def upgrade_scene(scene):
if scene["version"] == 2:
return copy.deepcopy(scene)
pixels = Image.frombytes("RGB", (64, 64), base64.b64decode(scene["pixelRgb"])).convert("RGBA")
canvas = Image.new("RGBA", (SIDE, SIDE))
canvas.paste(pixels, (ORIGIN, ORIGIN))
layers = [dict(id="layer-1", name="图层1", type="brush", x=0, y=0,
background=None, pixelPng=encode_pixels(canvas))]
if scene["elements"]:
layers.append(dict(id="layer-2", name="图层2", type="text", x=0, y=0,
elements=[dict(element, clip=None) for element in scene["elements"]]))
return dict(version=2, width=64, height=64, layers=layers)
def validate_v2(value, validate_legacy):
if set(value) != {"version", "width", "height", "layers"} or any(type(value[k]) is not int for k in ("version", "width", "height")) or (value["version"], value["width"], value["height"]) != (2, 64, 64):
raise ValueError("scene v2 fields or dimensions are invalid")
if not isinstance(value["layers"], list) or not value["layers"]:
raise ValueError("scene must contain a brush layer")
ids, element_ids, layers = set(), set(), []
for layer in value["layers"]:
layers.append(validate_layer(layer, validate_legacy, ids, element_ids))
if not any(layer["type"] == "brush" for layer in layers):
raise ValueError("scene must retain at least one brush layer")
return dict(version=2, width=64, height=64, layers=layers)
def validate_layer(layer, validate_legacy, ids=None, element_ids=None):
ids = ids if ids is not None else set()
element_ids = element_ids if element_ids is not None else set()
if not isinstance(layer, dict) or layer.get("type") not in {"brush", "text"}:
raise ValueError("unsupported layer type")
fields = {"id", "name", "type", "x", "y"} | ({"background", "pixelPng"} if layer["type"] == "brush" else {"elements"})
if layer["type"] == "brush" and "backgroundColor" in layer:
fields.add("backgroundColor")
if set(layer) != fields:
raise ValueError("layer fields do not match schema")
ident = layer["id"]
if not isinstance(ident, str) or not ident.strip() or len(ident) > 128 or ident in ids:
raise ValueError("layer ids must be unique nonempty strings")
ids.add(ident)
if not isinstance(layer["name"], str) or not layer["name"].strip() or len(layer["name"]) > 80:
raise ValueError("layer name must contain 1..80 characters")
if any(type(layer[k]) is not int or not -64 <= layer[k] <= 64 for k in ("x", "y")):
raise ValueError("layer offset must be an integer in -64..64")
result = copy.deepcopy(layer)
if layer["type"] == "brush":
decode_pixels(layer["pixelPng"])
if "backgroundColor" in layer:
color = layer["backgroundColor"]
if not isinstance(color, str) or len(color) != 7 or not color.startswith("#"):
raise ValueError("backgroundColor must be #RRGGBB")
parse_color(color)
if layer["background"] is not None:
if not isinstance(layer["background"], str) or len(layer["background"]) != 7 or not layer["background"].startswith("#"):
raise ValueError("background must be #RRGGBB or null")
parse_color(layer["background"])
else:
if not isinstance(layer["elements"], list):
raise ValueError("text elements must be an array")
checked = []
for element in layer["elements"]:
if not isinstance(element, dict) or "clip" not in element:
raise ValueError("text clip is required")
raw = {k: v for k, v in element.items() if k != "clip"}
legacy = dict(version=1, width=64, height=64, pixelRgb=base64.b64encode(bytes(12288)).decode("ascii"), elements=[raw])
text = validate_legacy(legacy, strict_schema=True)["elements"][0]
if text["id"] in element_ids:
raise ValueError("text ids must be globally unique")
element_ids.add(text["id"])
clip = element["clip"]
if clip is not None and (not isinstance(clip, list) or len(clip) != 4 or any(type(n) not in (int, float) or not math.isfinite(n) or abs(n) > 4096 for n in clip) or clip[2] < clip[0] or clip[3] < clip[1]):
raise ValueError("text clip must be a finite rectangle or null")
checked.append(dict(text, clip=clip))
result["elements"] = checked
return result
def copy_layer(layer, existing):
result = copy.deepcopy(layer)
result["id"] = str(uuid4())
sequence = 1
names = {item["name"] for item in existing}
while f"图层{sequence}" in names:
sequence += 1
result["name"] = f"图层{sequence}"
for element in result.get("elements", []):
element["id"] = str(uuid4())
return result
def render_element(element, font_resolver=None):
options = TextOptions(**{k: element[k] for k in ("text", "font", "size", "align")},
x=element["x"] + ORIGIN, y=element["y"] + ORIGIN,
color=parse_color(element["color"]))
image = render_text_layer(options, size=(SIDE, SIDE), font_resolver=font_resolver)
if element.get("clip") is not None:
# Rectangle is relative to the text anchor, in units of its font size.
left, top, right, bottom = element["clip"]
pixels = image.load()
for y in range(SIDE):
for x in range(SIDE):
u=(x-ORIGIN-element["x"])/element["size"]
v=(y-ORIGIN-element["y"])/element["size"]
if not left <= u < right or not top <= v < bottom:
pixels[x,y]=(0,0,0,0)
return image
def render_v2(scene, font_resolver=None):
image = Image.new("RGBA", (SIDE, SIDE), (0, 0, 0, 255))
def paint(source, layer):
nonlocal image
shifted = Image.new("RGBA", (SIDE, SIDE))
shifted.paste(source, (layer["x"], layer["y"]))
image = Image.alpha_composite(image, shifted)
for layer in scene["layers"]:
if layer["type"] == "brush":
if layer["background"]:
paint(Image.new("RGBA", (SIDE, SIDE), parse_color(layer["background"]) + (255,)), layer)
paint(decode_pixels(layer["pixelPng"]), layer)
else:
# Match browser source-over rounding after every text element.
for element in layer["elements"]:
paint(render_element(element, font_resolver), layer)
return image.crop((ORIGIN, ORIGIN, ORIGIN+64, ORIGIN+64)).convert("RGB")