Add CYD photo album with jpg/png/gif viewer and sample assets.

Ship TF-card album UI, half-res decode canvas, prebuilt firmware, and documented flash/upload workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-19 18:07:56 +08:00
commit 99618a46b0
35 changed files with 3211 additions and 0 deletions

75
tools/make_test_photos.py Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Generate 320x240 JPG/PNG/GIF samples into assets/photos."""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "assets" / "photos"
W, H = 320, 240
def font(size: int = 28) -> ImageFont.ImageFont:
for name in (
r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\arial.ttf",
r"C:\Windows\Fonts\consola.ttf",
):
try:
return ImageFont.truetype(name, size)
except OSError:
pass
return ImageFont.load_default()
def card(bg, accent, title: str, subtitle: str, path: Path, fmt: str) -> None:
im = Image.new("RGB", (W, H), bg)
d = ImageDraw.Draw(im)
for i in range(0, H, 12):
c = tuple(max(0, min(255, v + (i % 24) - 12)) for v in accent)
d.rectangle((0, i, W, i + 6), fill=c)
d.rectangle((16, 40, W - 16, H - 40), fill=bg)
d.rectangle((16, 40, W - 16, H - 40), outline=accent, width=3)
d.text((28, 70), title, fill=(234, 240, 246), font=font(36))
d.text((28, 130), subtitle, fill=(122, 139, 156), font=font(20))
d.text((28, 170), "CYD ALBUM TEST", fill=accent, font=font(16))
if fmt == "JPEG":
im.save(path, "JPEG", quality=90)
else:
im.save(path, "PNG")
print(f"wrote {path} ({path.stat().st_size} bytes)")
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
card((10, 16, 22), (79, 195, 247), "01 JPG", "tap left / right", OUT / "01_blue.jpg", "JPEG")
card((20, 12, 28), (232, 93, 76), "02 JPG", "red sample", OUT / "02_red.jpg", "JPEG")
card((8, 28, 20), (61, 220, 151), "03 PNG", "green sample", OUT / "03_green.png", "PNG")
frames = []
for i in range(6):
im = Image.new("RGB", (W, H), (10, 16, 22))
d = ImageDraw.Draw(im)
d.text((24, 24), "04 GIF", fill=(234, 240, 246), font=font(32))
d.text((24, 70), "animated", fill=(122, 139, 156), font=font(18))
x = 20 + i * 40
d.rectangle((x, 120, x + 80, 180), fill=(255, 200, 40))
d.rectangle((20, 200, 300, 210), fill=(36, 49, 64))
frames.append(im)
gif_path = OUT / "04_bounce.gif"
frames[0].save(
gif_path,
save_all=True,
append_images=frames[1:],
duration=120,
loop=0,
optimize=False,
)
print(f"wrote {gif_path} ({gif_path.stat().st_size} bytes)")
print("DONE")
if __name__ == "__main__":
main()

150
tools/sd_put.py Normal file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Upload a file to the CYD TF card over serial (#PUT + ACK window)."""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
try:
import serial
from serial.tools import list_ports
except ImportError:
print("Need pyserial: pip install pyserial", file=sys.stderr)
sys.exit(1)
WIN = 4096
def pick_port(preferred: str | None) -> str:
if preferred:
return preferred
ports = list(list_ports.comports())
for p in ports:
desc = (p.description or "") + (p.hwid or "")
if "USB" in desc.upper() or "UART" in desc.upper() or "CP210" in desc.upper() or "CH340" in desc.upper():
return p.device
if ports:
return ports[0].device
raise SystemExit("No serial port found")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("file", type=Path, help="Local file to upload")
ap.add_argument("--port", default=None)
ap.add_argument("--baud", type=int, default=460800)
ap.add_argument("--remote", default="/paomo.mp3")
args = ap.parse_args()
path: Path = args.file
if not path.is_file():
print(f"Missing file: {path}", file=sys.stderr)
return 1
size = path.stat().st_size
port = pick_port(args.port)
print(f"Port {port} {path.name} -> {args.remote} ({size} bytes)", flush=True)
ser = serial.Serial()
ser.port = port
ser.baudrate = args.baud
ser.timeout = 1
ser.dtr = False
ser.rts = False
ser.open()
# 硬复位 ESP32
ser.rts = True
time.sleep(0.1)
ser.rts = False
print("waiting BOOT_OK...", flush=True)
deadline = time.time() + 12
booted = False
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if line:
print("<", line, flush=True)
if "BOOT_OK" in line:
booted = True
break
if not booted:
print("warn: BOOT_OK not seen, continuing", flush=True)
time.sleep(0.5)
ser.reset_input_buffer()
cmd = f"#PUT {args.remote} {size}\n".encode("ascii")
print(f"send {cmd!r}", flush=True)
ser.write(cmd)
ser.flush()
deadline = time.time() + 20
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if line:
print("<", line, flush=True)
if line == "READY":
break
if line.startswith("PUT_ERR"):
return 2
else:
print("Timeout waiting READY", file=sys.stderr, flush=True)
return 3
sent = 0
t0 = time.time()
with path.open("rb") as f:
while sent < size:
chunk = f.read(min(WIN, size - sent))
if not chunk:
break
ser.write(chunk)
ser.flush()
sent += len(chunk)
# 等 ACK <sent>
ack_deadline = time.time() + 30
acked = False
while time.time() < ack_deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if not line:
continue
print("<", line, flush=True)
if line.startswith("PUT_ERR"):
return 4
if line.startswith("ACK "):
try:
got = int(line.split()[1])
except (IndexError, ValueError):
continue
if got >= sent:
acked = True
break
if line == "DONE" and sent >= size:
print("OK", flush=True)
return 0
if not acked:
print(f"Timeout waiting ACK after {sent}", file=sys.stderr, flush=True)
return 4
if sent % (256 * 1024) < WIN or sent == size:
rate = sent / max(time.time() - t0, 0.001)
print(f"sent {sent}/{size} ({rate/1024:.1f} KB/s)", flush=True)
deadline = time.time() + 30
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if line:
print("<", line, flush=True)
if line == "DONE":
print("OK", flush=True)
return 0
if line.startswith("PUT_ERR"):
return 5
print("Timeout waiting DONE", file=sys.stderr, flush=True)
return 6
if __name__ == "__main__":
sys.exit(main())