scrollkit 0.8.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scrollkit/__init__.py +16 -0
- scrollkit/app/__init__.py +6 -0
- scrollkit/app/base.py +918 -0
- scrollkit/app/memory.py +70 -0
- scrollkit/config/__init__.py +1 -0
- scrollkit/config/settings_manager.py +215 -0
- scrollkit/config/transition_names.py +31 -0
- scrollkit/dev/__init__.py +41 -0
- scrollkit/dev/capabilities.py +374 -0
- scrollkit/dev/harness.py +383 -0
- scrollkit/dev/metrics.py +91 -0
- scrollkit/dev/performance.py +174 -0
- scrollkit/dev/validation.py +245 -0
- scrollkit/display/__init__.py +14 -0
- scrollkit/display/_graphics.py +299 -0
- scrollkit/display/_recording.py +165 -0
- scrollkit/display/_sim_backend.py +99 -0
- scrollkit/display/bitmap_text.py +408 -0
- scrollkit/display/boards.py +186 -0
- scrollkit/display/colors.py +176 -0
- scrollkit/display/content.py +604 -0
- scrollkit/display/gradient_text.py +167 -0
- scrollkit/display/interface.py +126 -0
- scrollkit/display/simulator.py +80 -0
- scrollkit/display/text_fill.py +59 -0
- scrollkit/display/text_pixels.py +250 -0
- scrollkit/display/unified.py +595 -0
- scrollkit/effects/__init__.py +28 -0
- scrollkit/effects/drip_splash.py +253 -0
- scrollkit/effects/easing.py +131 -0
- scrollkit/effects/overlay.py +92 -0
- scrollkit/effects/particles.py +355 -0
- scrollkit/effects/reveal_splash.py +132 -0
- scrollkit/effects/scrolling.py +363 -0
- scrollkit/effects/swarm_reveal.py +512 -0
- scrollkit/effects/text_render.py +25 -0
- scrollkit/effects/transitions.py +873 -0
- scrollkit/exceptions.py +55 -0
- scrollkit/network/__init__.py +1 -0
- scrollkit/network/http_client.py +505 -0
- scrollkit/network/mdns.py +44 -0
- scrollkit/network/wifi_manager.py +382 -0
- scrollkit/ota/__init__.py +13 -0
- scrollkit/ota/client.py +528 -0
- scrollkit/ota/display_progress.py +125 -0
- scrollkit/ota/manifest.py +206 -0
- scrollkit/ota/publish.py +379 -0
- scrollkit/simulator/ATTRIBUTION.md +20 -0
- scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
- scrollkit/simulator/LICENSE +176 -0
- scrollkit/simulator/README.md +87 -0
- scrollkit/simulator/__init__.py +11 -0
- scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
- scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
- scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
- scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
- scrollkit/simulator/adafruit_display_text/label.py +336 -0
- scrollkit/simulator/bitmaptools.py +50 -0
- scrollkit/simulator/core/__init__.py +8 -0
- scrollkit/simulator/core/color_utils.py +62 -0
- scrollkit/simulator/core/device_benchmarks.json +254 -0
- scrollkit/simulator/core/feasibility.py +160 -0
- scrollkit/simulator/core/hardware_profile.py +191 -0
- scrollkit/simulator/core/led_matrix.py +307 -0
- scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
- scrollkit/simulator/core/performance_manager.py +253 -0
- scrollkit/simulator/core/pixel_buffer.py +188 -0
- scrollkit/simulator/devices/__init__.py +7 -0
- scrollkit/simulator/devices/base_device.py +79 -0
- scrollkit/simulator/devices/matrixportal_s3.py +87 -0
- scrollkit/simulator/displayio/__init__.py +12 -0
- scrollkit/simulator/displayio/bitmap.py +158 -0
- scrollkit/simulator/displayio/display.py +195 -0
- scrollkit/simulator/displayio/fourwire.py +54 -0
- scrollkit/simulator/displayio/group.py +125 -0
- scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
- scrollkit/simulator/displayio/palette.py +115 -0
- scrollkit/simulator/displayio/tilegrid.py +155 -0
- scrollkit/simulator/fonts/3x5.bdf +2474 -0
- scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
- scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
- scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
- scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
- scrollkit/simulator/fonts/viii.bdf +2659 -0
- scrollkit/simulator/terminalio/__init__.py +20 -0
- scrollkit/utils/__init__.py +1 -0
- scrollkit/utils/color_utils.py +54 -0
- scrollkit/utils/diagnostics.py +227 -0
- scrollkit/utils/error_handler.py +347 -0
- scrollkit/utils/system_utils.py +245 -0
- scrollkit/utils/url_utils.py +46 -0
- scrollkit/web/__init__.py +6 -0
- scrollkit/web/settings_server.py +328 -0
- scrollkit/web/wifi_setup.py +331 -0
- scrollkit-0.8.3.dist-info/METADATA +248 -0
- scrollkit-0.8.3.dist-info/RECORD +111 -0
- scrollkit-0.8.3.dist-info/WHEEL +5 -0
- scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
- scrollkit-0.8.3.dist-info/top_level.txt +1 -0
scrollkit/dev/harness.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Headless verification harness — the AI agent's primary tool.
|
|
3
|
+
|
|
4
|
+
``run_headless(app, frames=N)`` drives a ``ScrollKitApp``'s display loop
|
|
5
|
+
deterministically in a dummy (no-window) pygame, captures a screenshot plus
|
|
6
|
+
pixel/feasibility metrics, and returns a JSON-able ``RunResult`` — no human and
|
|
7
|
+
no real hardware required. The whole point: an AI can write an app, run it here,
|
|
8
|
+
see that it actually rendered and advanced, read an honest estimate of whether
|
|
9
|
+
it would survive on the real (slow, RAM-tiny) MatrixPortal S3, and iterate.
|
|
10
|
+
|
|
11
|
+
Determinism: the loop is stepped exactly ``frames`` times with no inter-frame
|
|
12
|
+
sleep, so the same app + same frame count always reaches the same visual state
|
|
13
|
+
(reproducible compare across edits) and the run finishes fast. ``seconds=S`` is
|
|
14
|
+
sugar for ``frames = round(S * 20)`` (the display loop targets 20 FPS).
|
|
15
|
+
|
|
16
|
+
Desktop-only — imported via ``scrollkit.dev``, which raises on CircuitPython.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from . import metrics as _metrics
|
|
22
|
+
|
|
23
|
+
# The display loop in app/base.py paces itself at 20 FPS; seconds->frames uses it.
|
|
24
|
+
TARGET_FPS = 20
|
|
25
|
+
DEFAULT_FRAMES = 120
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RunResult:
|
|
29
|
+
"""JSON-able summary of a headless run (what the AI inspects)."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, **kw):
|
|
32
|
+
self.frames = kw.get("frames", 0)
|
|
33
|
+
self.estimated_hardware_fps = kw.get("estimated_hardware_fps")
|
|
34
|
+
self.bright_pixels = kw.get("bright_pixels", 0)
|
|
35
|
+
self.lit_pixels = kw.get("lit_pixels", 0)
|
|
36
|
+
self.coverage = kw.get("coverage", 0.0)
|
|
37
|
+
self.is_blank = kw.get("is_blank", True)
|
|
38
|
+
self.advanced = kw.get("advanced", False)
|
|
39
|
+
self.current_content = kw.get("current_content")
|
|
40
|
+
self.memory = kw.get("memory")
|
|
41
|
+
self.hardware = kw.get("hardware") # FeasibilityReport dict or None
|
|
42
|
+
self.hardware_text = kw.get("hardware_text") # human-readable report or None
|
|
43
|
+
self.screenshot = kw.get("screenshot") # saved path or None
|
|
44
|
+
self.gif = kw.get("gif") # saved animated-GIF path or None
|
|
45
|
+
self.video = kw.get("video") # saved MP4 path or None
|
|
46
|
+
self.errors = kw.get("errors") or []
|
|
47
|
+
self.warnings = kw.get("warnings") or []
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def ok(self):
|
|
51
|
+
"""A quick "did it render anything without errors" boolean."""
|
|
52
|
+
return not self.errors and not self.is_blank
|
|
53
|
+
|
|
54
|
+
def as_dict(self):
|
|
55
|
+
return {
|
|
56
|
+
"frames": self.frames,
|
|
57
|
+
"estimated_hardware_fps": self.estimated_hardware_fps,
|
|
58
|
+
"bright_pixels": self.bright_pixels,
|
|
59
|
+
"lit_pixels": self.lit_pixels,
|
|
60
|
+
"coverage": self.coverage,
|
|
61
|
+
"is_blank": self.is_blank,
|
|
62
|
+
"advanced": self.advanced,
|
|
63
|
+
"current_content": self.current_content,
|
|
64
|
+
"memory": self.memory,
|
|
65
|
+
"hardware": self.hardware,
|
|
66
|
+
"screenshot": self.screenshot,
|
|
67
|
+
"gif": self.gif,
|
|
68
|
+
"video": self.video,
|
|
69
|
+
"errors": list(self.errors),
|
|
70
|
+
"warnings": list(self.warnings),
|
|
71
|
+
"ok": self.ok,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def as_text(self):
|
|
75
|
+
lines = ["=== run_headless: %d frames ===" % self.frames]
|
|
76
|
+
lines.append(" Rendered: %s (bright=%d, lit=%d, coverage=%.1f%%, advanced=%s)"
|
|
77
|
+
% ("yes" if not self.is_blank else "BLANK",
|
|
78
|
+
self.bright_pixels, self.lit_pixels,
|
|
79
|
+
self.coverage * 100.0, self.advanced))
|
|
80
|
+
if self.current_content is not None:
|
|
81
|
+
lines.append(" Content: %s" % (self.current_content,))
|
|
82
|
+
if self.screenshot:
|
|
83
|
+
lines.append(" Screenshot: %s" % self.screenshot)
|
|
84
|
+
if self.gif:
|
|
85
|
+
lines.append(" GIF: %s" % self.gif)
|
|
86
|
+
if self.video:
|
|
87
|
+
lines.append(" Video: %s" % self.video)
|
|
88
|
+
if self.errors:
|
|
89
|
+
lines.append(" ERRORS:")
|
|
90
|
+
for e in self.errors:
|
|
91
|
+
lines.append(" - " + e)
|
|
92
|
+
if self.hardware_text:
|
|
93
|
+
lines.append("")
|
|
94
|
+
lines.append(self.hardware_text)
|
|
95
|
+
elif self.warnings:
|
|
96
|
+
lines.append(" Warnings:")
|
|
97
|
+
for w in self.warnings:
|
|
98
|
+
lines.append(" - " + w)
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
def __repr__(self):
|
|
102
|
+
return "RunResult(frames=%d, bright=%d, advanced=%s, errors=%d)" % (
|
|
103
|
+
self.frames, self.bright_pixels, self.advanced, len(self.errors))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_headless(app, frames=None, seconds=None, screenshot=None,
|
|
107
|
+
hardware=True, warmup_data=False, strict=False, gif=None,
|
|
108
|
+
gif_opts=None, video=None, video_opts=None):
|
|
109
|
+
"""Run ``app`` headlessly for a fixed number of frames; return a ``RunResult``.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
app: a ``ScrollKitApp`` instance whose ``setup()`` populates content.
|
|
113
|
+
frames: number of display frames to render (default 120).
|
|
114
|
+
seconds: alternative to ``frames``; converted at 20 FPS.
|
|
115
|
+
screenshot: path to save the final frame as a PNG (optional).
|
|
116
|
+
gif: path to save the whole run as an animated GIF (optional). Records
|
|
117
|
+
every rendered frame from the simulator's LED panel and encodes it
|
|
118
|
+
via ``display.save_gif``; the saved path lands on ``result.gif``.
|
|
119
|
+
Ignored if the display can't record (e.g. real hardware).
|
|
120
|
+
gif_opts: optional dict of keyword args forwarded to ``display.save_gif``
|
|
121
|
+
(e.g. ``{"target_width": 320, "frame_step": 2, "max_colors": 48}``)
|
|
122
|
+
to tune the encoded GIF's size/quality. Ignored unless ``gif`` is set.
|
|
123
|
+
video: path to save the whole run as an MP4 (optional), via
|
|
124
|
+
``display.save_video``; the saved path lands on ``result.video``.
|
|
125
|
+
Mutually exclusive with ``gif`` in one run (the recording is consumed
|
|
126
|
+
by the first save). Ignored if the display can't record.
|
|
127
|
+
video_opts: optional dict forwarded to ``display.save_video`` (e.g.
|
|
128
|
+
``{"crf": 20, "border": 22}``). Ignored unless ``video`` is set.
|
|
129
|
+
hardware: model real-hardware timing/RAM and include a feasibility
|
|
130
|
+
report (default True).
|
|
131
|
+
warmup_data: call ``update_data()`` once after ``setup()`` (default
|
|
132
|
+
False — kept off so headless runs never block on the network).
|
|
133
|
+
strict: enforce the feasibility gate. A sustained over-budget run (or a
|
|
134
|
+
catastrophic single frame, or a RAM breach) raises FeasibilityError,
|
|
135
|
+
which is caught, recorded in ``result.errors`` (tagged ``feasibility:``)
|
|
136
|
+
and stops the run, so ``result.ok`` is False. Implies hardware
|
|
137
|
+
modeling. Off by default.
|
|
138
|
+
|
|
139
|
+
Synchronous wrapper around :func:`run_headless_async`. Call the async form
|
|
140
|
+
directly from inside an existing event loop.
|
|
141
|
+
"""
|
|
142
|
+
import asyncio
|
|
143
|
+
return asyncio.run(run_headless_async(
|
|
144
|
+
app, frames=frames, seconds=seconds, screenshot=screenshot,
|
|
145
|
+
hardware=hardware, warmup_data=warmup_data, strict=strict, gif=gif,
|
|
146
|
+
gif_opts=gif_opts, video=video, video_opts=video_opts))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def record_gif(app, path, *, seconds=4.0, hardware=False, **gif_opts):
|
|
150
|
+
"""Render ``app`` headlessly and save an animated GIF of the run to ``path``.
|
|
151
|
+
|
|
152
|
+
A thin convenience over :func:`run_headless` with ``gif=path`` — the natural
|
|
153
|
+
way to make a shareable preview of an app (docs, README, a bug report).
|
|
154
|
+
Extra keyword args (``target_width``, ``max_colors``, ``frame_step``, ``fps``)
|
|
155
|
+
are forwarded to ``display.save_gif`` to tune size/quality. Returns the saved
|
|
156
|
+
GIF path, or None if the display can't record (e.g. real hardware / no pygame).
|
|
157
|
+
"""
|
|
158
|
+
return run_headless(app, seconds=seconds, gif=path, hardware=hardware,
|
|
159
|
+
gif_opts=gif_opts or None).gif
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def record_video(app, path, *, seconds=4.0, hardware=False, **video_opts):
|
|
163
|
+
"""Render ``app`` headlessly and save an MP4 of the run to ``path``.
|
|
164
|
+
|
|
165
|
+
The MP4 sibling of :func:`record_gif` (via ``display.save_video``) — far
|
|
166
|
+
smaller and smoother than a GIF, the right format for a site hero. Extra
|
|
167
|
+
keyword args (``crf``, ``target_width``, ``border``, ``fps``, ``preset``) are
|
|
168
|
+
forwarded to ``display.save_video``. Returns the saved path, or None if the
|
|
169
|
+
display can't record (e.g. real hardware / no ffmpeg).
|
|
170
|
+
"""
|
|
171
|
+
return run_headless(app, seconds=seconds, video=path, hardware=hardware,
|
|
172
|
+
video_opts=video_opts or None).video
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def run_headless_async(app, frames=None, seconds=None, screenshot=None,
|
|
176
|
+
hardware=True, warmup_data=False, strict=False,
|
|
177
|
+
gif=None, gif_opts=None, video=None, video_opts=None):
|
|
178
|
+
"""Async core of :func:`run_headless` (use inside a running event loop)."""
|
|
179
|
+
if frames is None and seconds is not None:
|
|
180
|
+
frames = max(1, int(round(seconds * TARGET_FPS)))
|
|
181
|
+
if frames is None:
|
|
182
|
+
frames = DEFAULT_FRAMES
|
|
183
|
+
|
|
184
|
+
# strict enforcement needs the timing model active, so it implies hardware.
|
|
185
|
+
if strict:
|
|
186
|
+
hardware = True
|
|
187
|
+
|
|
188
|
+
# Headless pygame + opt the simulator into hardware timing via its env hook,
|
|
189
|
+
# so this works regardless of how the app builds its display. All are
|
|
190
|
+
# restored afterwards so we don't perturb the caller's environment.
|
|
191
|
+
prev_sdl = os.environ.get("SDL_VIDEODRIVER")
|
|
192
|
+
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
|
193
|
+
prev_hw = os.environ.get("SCROLLKIT_HW_SIM")
|
|
194
|
+
prev_strict = os.environ.get("SCROLLKIT_HW_STRICT")
|
|
195
|
+
if hardware:
|
|
196
|
+
os.environ["SCROLLKIT_HW_SIM"] = "1"
|
|
197
|
+
if strict:
|
|
198
|
+
os.environ["SCROLLKIT_HW_STRICT"] = "1"
|
|
199
|
+
|
|
200
|
+
errors = []
|
|
201
|
+
warnings = []
|
|
202
|
+
first_sig = None
|
|
203
|
+
last_sig = None
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
await app._initialize_display()
|
|
207
|
+
|
|
208
|
+
# If a GIF was requested, start capturing frames from the simulator's
|
|
209
|
+
# LED panel now (no-op on displays that can't record, e.g. hardware).
|
|
210
|
+
if (gif or video) and app.display is not None \
|
|
211
|
+
and hasattr(app.display, "start_recording"):
|
|
212
|
+
app.display.start_recording()
|
|
213
|
+
|
|
214
|
+
# Headless runs must stay fast, deterministic, and quiet: never crawl in
|
|
215
|
+
# real time and never print ambient nags, even if the app built its
|
|
216
|
+
# display with throttle=True or SCROLLKIT_HW_THROTTLE is set.
|
|
217
|
+
try:
|
|
218
|
+
from scrollkit.simulator.core.performance_manager import get_active
|
|
219
|
+
_pm = get_active()
|
|
220
|
+
if _pm is not None:
|
|
221
|
+
_pm.throttle = False
|
|
222
|
+
_pm.ambient_warnings = False
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
import time
|
|
227
|
+
app.running = True
|
|
228
|
+
app._run_start = time.monotonic() if hasattr(time, "monotonic") else None
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
await app.setup()
|
|
232
|
+
except Exception as e: # surface, don't crash the harness
|
|
233
|
+
errors.append("setup() failed: %r" % (e,))
|
|
234
|
+
|
|
235
|
+
if warmup_data:
|
|
236
|
+
try:
|
|
237
|
+
await app.update_data()
|
|
238
|
+
except Exception as e:
|
|
239
|
+
warnings.append("update_data() failed: %r" % (e,))
|
|
240
|
+
|
|
241
|
+
# Step the display loop deterministically through the app's OWN frame
|
|
242
|
+
# implementation (SLDKApp.step_frame — settings application, transition
|
|
243
|
+
# firing/rendering, content render, show), minus the 20 FPS sleep and
|
|
244
|
+
# periodic memory report. Never a copy of the loop: the strict gate
|
|
245
|
+
# must exercise exactly the code path that ships, transitions included.
|
|
246
|
+
app._reset_frame_state()
|
|
247
|
+
for i in range(frames):
|
|
248
|
+
try:
|
|
249
|
+
closed = await app.step_frame() is False
|
|
250
|
+
if closed:
|
|
251
|
+
warnings.append("display closed at frame %d" % (i + 1))
|
|
252
|
+
break
|
|
253
|
+
if app._current_content is not None and app.display:
|
|
254
|
+
buf = _metrics.buffer_from_display(app.display)
|
|
255
|
+
sig = _metrics.signature(buf) if buf is not None else None
|
|
256
|
+
if first_sig is None:
|
|
257
|
+
first_sig = sig
|
|
258
|
+
last_sig = sig
|
|
259
|
+
except Exception as e:
|
|
260
|
+
# The strict feasibility gate raises FeasibilityError mid-render
|
|
261
|
+
# (deep inside display.show()); surface it with a clear tag so
|
|
262
|
+
# callers can tell a budget bust from an ordinary crash.
|
|
263
|
+
from scrollkit.exceptions import FeasibilityError
|
|
264
|
+
if isinstance(e, FeasibilityError):
|
|
265
|
+
errors.append("feasibility: frame %d busts the device budget: %s"
|
|
266
|
+
% (i + 1, e))
|
|
267
|
+
else:
|
|
268
|
+
errors.append("frame %d failed: %r" % (i + 1, e))
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
shot = None
|
|
272
|
+
if screenshot and app.display and hasattr(app.display, "screenshot"):
|
|
273
|
+
try:
|
|
274
|
+
shot = app.display.screenshot(screenshot)
|
|
275
|
+
if shot is None:
|
|
276
|
+
warnings.append("screenshot unavailable (no pygame surface)")
|
|
277
|
+
except Exception as e:
|
|
278
|
+
warnings.append("screenshot failed: %r" % (e,))
|
|
279
|
+
|
|
280
|
+
gif_path = None
|
|
281
|
+
if gif and app.display and hasattr(app.display, "save_gif"):
|
|
282
|
+
try:
|
|
283
|
+
gif_path = app.display.save_gif(gif, **(gif_opts or {}))
|
|
284
|
+
if gif_path is None:
|
|
285
|
+
warnings.append("gif unavailable (no recorded frames / no pygame)")
|
|
286
|
+
except Exception as e:
|
|
287
|
+
warnings.append("gif save failed: %r" % (e,))
|
|
288
|
+
|
|
289
|
+
video_path = None
|
|
290
|
+
if video and app.display and hasattr(app.display, "save_video"):
|
|
291
|
+
try:
|
|
292
|
+
video_path = app.display.save_video(video, **(video_opts or {}))
|
|
293
|
+
if video_path is None:
|
|
294
|
+
warnings.append("video unavailable (no recorded frames / no ffmpeg)")
|
|
295
|
+
except Exception as e:
|
|
296
|
+
warnings.append("video save failed: %r" % (e,))
|
|
297
|
+
|
|
298
|
+
buf = _metrics.buffer_from_display(app.display)
|
|
299
|
+
snap = _metrics.snapshot(buf)
|
|
300
|
+
if not snap["available"]:
|
|
301
|
+
warnings.append("no pixel buffer — pixel metrics unavailable "
|
|
302
|
+
"(display is not the pygame simulator)")
|
|
303
|
+
|
|
304
|
+
hw_dict = None
|
|
305
|
+
hw_text = None
|
|
306
|
+
if hardware:
|
|
307
|
+
try:
|
|
308
|
+
from scrollkit.simulator.core.performance_manager import get_active
|
|
309
|
+
pm = get_active()
|
|
310
|
+
if pm is not None:
|
|
311
|
+
report = pm.report()
|
|
312
|
+
hw_dict = report.as_dict()
|
|
313
|
+
hw_text = report.as_text()
|
|
314
|
+
for w in report.warnings:
|
|
315
|
+
if w not in warnings:
|
|
316
|
+
warnings.append(w)
|
|
317
|
+
else:
|
|
318
|
+
warnings.append("hardware timing requested but no model is "
|
|
319
|
+
"active (display is not the simulator?)")
|
|
320
|
+
except Exception as e:
|
|
321
|
+
warnings.append("feasibility report unavailable: %r" % (e,))
|
|
322
|
+
|
|
323
|
+
advanced = (first_sig is not None and last_sig is not None
|
|
324
|
+
and first_sig != last_sig)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
content_desc = app.describe().get("current_content")
|
|
328
|
+
except Exception:
|
|
329
|
+
content_desc = None
|
|
330
|
+
try:
|
|
331
|
+
memory = app.memory_estimate()
|
|
332
|
+
except Exception:
|
|
333
|
+
memory = None
|
|
334
|
+
|
|
335
|
+
return RunResult(
|
|
336
|
+
frames=app._frame_count,
|
|
337
|
+
estimated_hardware_fps=(hw_dict or {}).get("estimated_hardware_fps"),
|
|
338
|
+
bright_pixels=snap["bright_pixels"],
|
|
339
|
+
lit_pixels=snap["lit_pixels"],
|
|
340
|
+
coverage=snap["coverage"],
|
|
341
|
+
is_blank=snap["is_blank"],
|
|
342
|
+
advanced=advanced,
|
|
343
|
+
current_content=content_desc,
|
|
344
|
+
memory=memory,
|
|
345
|
+
hardware=hw_dict,
|
|
346
|
+
hardware_text=hw_text,
|
|
347
|
+
screenshot=shot,
|
|
348
|
+
gif=gif_path,
|
|
349
|
+
video=video_path,
|
|
350
|
+
errors=errors,
|
|
351
|
+
warnings=warnings,
|
|
352
|
+
)
|
|
353
|
+
finally:
|
|
354
|
+
app.running = False
|
|
355
|
+
# Release any still-open recording (save_gif already clears it on the
|
|
356
|
+
# happy path; this covers an early error before the save).
|
|
357
|
+
try:
|
|
358
|
+
if app.display is not None and hasattr(app.display, "stop_recording"):
|
|
359
|
+
app.display.stop_recording()
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
try:
|
|
363
|
+
await app.cleanup()
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
# Don't leak the module-global active perf manager across runs/tests.
|
|
367
|
+
try:
|
|
368
|
+
from scrollkit.simulator.core.performance_manager import set_active
|
|
369
|
+
set_active(None)
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
if prev_hw is None:
|
|
373
|
+
os.environ.pop("SCROLLKIT_HW_SIM", None)
|
|
374
|
+
else:
|
|
375
|
+
os.environ["SCROLLKIT_HW_SIM"] = prev_hw
|
|
376
|
+
if prev_strict is None:
|
|
377
|
+
os.environ.pop("SCROLLKIT_HW_STRICT", None)
|
|
378
|
+
else:
|
|
379
|
+
os.environ["SCROLLKIT_HW_STRICT"] = prev_strict
|
|
380
|
+
# Restore SDL too: leaving "dummy" set would make a LATER live-window
|
|
381
|
+
# run in the same process silently headless.
|
|
382
|
+
if prev_sdl is None:
|
|
383
|
+
os.environ.pop("SDL_VIDEODRIVER", None)
|
scrollkit/dev/metrics.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Pixel-level metrics for headless verification (desktop-only, numpy).
|
|
3
|
+
|
|
4
|
+
Reads the simulator's true-color RGB888 buffer
|
|
5
|
+
(``matrix.pixel_buffer.get_buffer()`` -> ndarray ``(H, W, 3)`` uint8 — the same
|
|
6
|
+
array ``demos/.../rainbow.py::calibrate()`` inspects) and reduces it to a few
|
|
7
|
+
numbers an AI agent can reason about: is anything lit, how much of the panel is
|
|
8
|
+
covered, how many "bright" pixels there are, and a cheap signature for detecting
|
|
9
|
+
motion between frames.
|
|
10
|
+
|
|
11
|
+
These read the *logical* pixel buffer (pre-brightness, pre-LED-spacing), so the
|
|
12
|
+
numbers are independent of how the simulator happens to draw LEDs to the window.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
# A pixel counts as "bright" when its channels sum above this — matches the
|
|
18
|
+
# ``r + g + b > 250`` rule the render smoke test uses.
|
|
19
|
+
BRIGHT_SUM_THRESHOLD = 250
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def buffer_from_display(display):
|
|
23
|
+
"""Return the ``(H, W, 3)`` uint8 RGB buffer for a simulator display, or None.
|
|
24
|
+
|
|
25
|
+
None when the display isn't a pixel-buffer simulator (e.g. a terminal or
|
|
26
|
+
real hardware), so callers degrade gracefully instead of crashing.
|
|
27
|
+
"""
|
|
28
|
+
matrix = getattr(display, "matrix", None)
|
|
29
|
+
if matrix is None:
|
|
30
|
+
return None
|
|
31
|
+
pb = getattr(matrix, "pixel_buffer", None)
|
|
32
|
+
if pb is None or not hasattr(pb, "get_buffer"):
|
|
33
|
+
return None
|
|
34
|
+
return pb.get_buffer()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def lit_mask(buffer):
|
|
38
|
+
"""Boolean ``(H, W)`` mask of pixels that are not pure black."""
|
|
39
|
+
return np.any(buffer > 0, axis=2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bright_pixels(buffer, threshold=BRIGHT_SUM_THRESHOLD):
|
|
43
|
+
"""Count pixels whose ``R + G + B`` exceeds ``threshold``."""
|
|
44
|
+
sums = buffer.astype(np.int32).sum(axis=2)
|
|
45
|
+
return int(np.count_nonzero(sums > threshold))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def lit_pixels(buffer):
|
|
49
|
+
"""Count pixels that are lit at all (any channel > 0)."""
|
|
50
|
+
return int(np.count_nonzero(lit_mask(buffer)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def coverage(buffer):
|
|
54
|
+
"""Fraction (0..1) of the panel that is lit."""
|
|
55
|
+
height, width = buffer.shape[0], buffer.shape[1]
|
|
56
|
+
total = height * width
|
|
57
|
+
if total == 0:
|
|
58
|
+
return 0.0
|
|
59
|
+
return lit_pixels(buffer) / total
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_blank(buffer):
|
|
63
|
+
"""True when nothing at all is lit."""
|
|
64
|
+
return lit_pixels(buffer) == 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def signature(buffer):
|
|
68
|
+
"""Cheap content fingerprint for detecting change/motion between frames.
|
|
69
|
+
|
|
70
|
+
Sensitive to any pixel change; stable within a process. Compare the
|
|
71
|
+
signature of an early frame to a late frame to tell whether anything moved.
|
|
72
|
+
"""
|
|
73
|
+
return hash(buffer.tobytes())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def snapshot(buffer):
|
|
77
|
+
"""All scalar metrics for one frame as a JSON-able dict.
|
|
78
|
+
|
|
79
|
+
``available`` is False (and everything zero/blank) when no pixel buffer was
|
|
80
|
+
available, so the harness can report honestly rather than inventing numbers.
|
|
81
|
+
"""
|
|
82
|
+
if buffer is None:
|
|
83
|
+
return {"bright_pixels": 0, "lit_pixels": 0, "coverage": 0.0,
|
|
84
|
+
"is_blank": True, "available": False}
|
|
85
|
+
return {
|
|
86
|
+
"bright_pixels": bright_pixels(buffer),
|
|
87
|
+
"lit_pixels": lit_pixels(buffer),
|
|
88
|
+
"coverage": round(coverage(buffer), 4),
|
|
89
|
+
"is_blank": is_blank(buffer),
|
|
90
|
+
"available": True,
|
|
91
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Measured performance trade-offs for the MatrixPortal S3 — for AI authoring.
|
|
3
|
+
|
|
4
|
+
Turns the raw microbenchmark table (``simulator/core/device_benchmarks.json``,
|
|
5
|
+
captured on a real device by ``test/claude/device_benchmarks.py``) into a curated
|
|
6
|
+
guide an agent can reason about: which calls are cheap C vs slow interpreted
|
|
7
|
+
Python, the bit_depth/refresh ladder, allocation costs, and the cardinal rules
|
|
8
|
+
that follow. Numbers come straight from the device, so the guidance can't drift.
|
|
9
|
+
|
|
10
|
+
Desktop-only (imported via ``scrollkit.dev``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
_BENCH_DIR = os.path.join(os.path.dirname(__file__), "..", "simulator", "core")
|
|
17
|
+
_DEFAULT_BOARD_ID = "adafruit_matrixportal_s3"
|
|
18
|
+
# The S3 benchmark file keeps its historical name; others follow the canonical-id
|
|
19
|
+
# convention (matching test/claude/device_benchmarks.py --board output).
|
|
20
|
+
_BENCH_FILENAMES = {
|
|
21
|
+
"adafruit_matrixportal_s3": "device_benchmarks.json",
|
|
22
|
+
"pimoroni_interstate75_w": "pimoroni_interstate75_w_benchmarks.json",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bench_path(board_id):
|
|
27
|
+
filename = _BENCH_FILENAMES.get(board_id, "%s_benchmarks.json" % board_id)
|
|
28
|
+
return os.path.join(_BENCH_DIR, filename)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_doc(board_id):
|
|
32
|
+
with open(os.path.abspath(_bench_path(board_id))) as f:
|
|
33
|
+
return json.load(f)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _by_name(rows):
|
|
37
|
+
return {r["name"]: r for r in rows}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def performance_guide(board_id=None):
|
|
41
|
+
"""Return a JSON-able, device-measured performance guide for ``board_id``.
|
|
42
|
+
|
|
43
|
+
Sections: ``pixel_write`` (the C-vs-interpreted spread), ``refresh`` (the
|
|
44
|
+
bit_depth ladder + FPS ceiling), ``allocation``, ``compute``, ``gc``, and
|
|
45
|
+
``rules`` (the actionable cardinal rules). ``board_id`` defaults to the
|
|
46
|
+
MatrixPortal S3. Falls back to an empty/quiet guide if that board has no
|
|
47
|
+
captured benchmark file yet (e.g. a board wired in code but not yet
|
|
48
|
+
calibrated).
|
|
49
|
+
"""
|
|
50
|
+
if board_id is None:
|
|
51
|
+
board_id = _DEFAULT_BOARD_ID
|
|
52
|
+
try:
|
|
53
|
+
doc = _load_doc(board_id)
|
|
54
|
+
except Exception:
|
|
55
|
+
return {"available": False, "board": board_id,
|
|
56
|
+
"note": "no device benchmark data for %s; run "
|
|
57
|
+
"test/claude/device_benchmarks.py --board %s"
|
|
58
|
+
% (board_id, board_id)}
|
|
59
|
+
rows = doc.get("benchmarks", [])
|
|
60
|
+
b = _by_name(rows)
|
|
61
|
+
src_board = doc.get("board", board_id)
|
|
62
|
+
src_cp = doc.get("cp")
|
|
63
|
+
source = ("measured on %s, CircuitPython %s" % (src_board, src_cp)
|
|
64
|
+
if src_cp else "measured on %s" % src_board)
|
|
65
|
+
|
|
66
|
+
def val(name):
|
|
67
|
+
r = b.get(name)
|
|
68
|
+
return r["value"] if r and r.get("iters") else None
|
|
69
|
+
|
|
70
|
+
# Pixel-write spread (per pixel): interpreted set vs C blit vs C fill.
|
|
71
|
+
set_px = val("bitmap_setpixel") # ns/px (interpreted)
|
|
72
|
+
blit = val("bitmaptools_blit_16x16")
|
|
73
|
+
blit_px = (blit / 256.0) if blit else None # 16x16 region
|
|
74
|
+
fill = val("bitmap_fill_64x32")
|
|
75
|
+
fill_px = (fill / 2048.0) if fill else None # 64x32 region
|
|
76
|
+
|
|
77
|
+
# Refresh / bit_depth ladder.
|
|
78
|
+
refresh = {}
|
|
79
|
+
for bd in (1, 2, 4, 6):
|
|
80
|
+
v = val("refresh_full_bd%d" % bd)
|
|
81
|
+
if v:
|
|
82
|
+
refresh["bit_depth_%d" % bd] = {
|
|
83
|
+
"full_refresh_ms": round(v / 1e6, 2),
|
|
84
|
+
"fps_ceiling": int(1e9 / v),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
guide = {
|
|
88
|
+
"available": True,
|
|
89
|
+
"board": src_board,
|
|
90
|
+
"source": source,
|
|
91
|
+
"pixel_write_ns_per_px": {
|
|
92
|
+
"interpreted_setpixel": _r(set_px),
|
|
93
|
+
"c_bitmaptools_blit": _r(blit_px),
|
|
94
|
+
"c_bitmap_fill": _r(fill_px),
|
|
95
|
+
"note": _ratio_note(set_px, blit_px, fill_px),
|
|
96
|
+
},
|
|
97
|
+
"refresh": refresh,
|
|
98
|
+
"allocation_ns": {
|
|
99
|
+
"bitmap_32x16": val("bitmap_alloc_32x16"),
|
|
100
|
+
"tilegrid": val("tilegrid_create"),
|
|
101
|
+
"bytearray_256": val("bytearray_alloc_256"),
|
|
102
|
+
"note": "Allocating objects every frame is a real per-frame tax; "
|
|
103
|
+
"create once and mutate.",
|
|
104
|
+
},
|
|
105
|
+
"compute_ns_per_op": {
|
|
106
|
+
"loop_iter": val("noop_loop_iter"),
|
|
107
|
+
"int_op": val("int_add"),
|
|
108
|
+
"func_call": val("func_call"),
|
|
109
|
+
"note": "~500k simple ops/sec. CircuitPython is interpreted; a "
|
|
110
|
+
"1000-op calculation costs ~1.5 ms straight out of the frame "
|
|
111
|
+
"budget (it's cooperative — there is no background thread).",
|
|
112
|
+
},
|
|
113
|
+
"gc_collect_ns": {
|
|
114
|
+
"value": val("gc_collect"),
|
|
115
|
+
"note": "Scales with live objects / heap size (~0.3 ms clean, ~0.9 ms "
|
|
116
|
+
"after churn). Fewer live allocations = shorter GC pauses.",
|
|
117
|
+
},
|
|
118
|
+
"rules": _rules(),
|
|
119
|
+
}
|
|
120
|
+
return guide
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _rules():
|
|
124
|
+
return [
|
|
125
|
+
"Reuse a Label and change .text only when the value actually changes; "
|
|
126
|
+
"for scrolling, move .x and leave .text alone (a text change rebuilds the "
|
|
127
|
+
"glyph bitmap pixel-by-pixel — the dominant per-frame cost).",
|
|
128
|
+
"Never push pixels in a Python loop. Use the C bulk calls — bitmap.fill "
|
|
129
|
+
"for solids, bitmaptools.blit to copy — they are ~11x (blit) to ~1500x "
|
|
130
|
+
"(fill) faster per pixel than interpreted bitmap[x,y]=... writes.",
|
|
131
|
+
"Keep bit_depth at 4 unless you need smooth color gradients: a full "
|
|
132
|
+
"refresh is ~4.5 ms at bit_depth<=4 but ~13.7 ms at 6 (~3x slower, so "
|
|
133
|
+
"~220 vs ~73 FPS ceiling).",
|
|
134
|
+
"Don't allocate per frame (Label/Bitmap/TileGrid/Group). Create the "
|
|
135
|
+
"display objects once and mutate them; allocation is tens of microseconds "
|
|
136
|
+
"each and feeds the GC.",
|
|
137
|
+
"Heavy computation competes directly with rendering (cooperative "
|
|
138
|
+
"multitasking, ~500k Python ops/sec). Break long work into chunks across "
|
|
139
|
+
"frames so scrolling keeps moving.",
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _r(x):
|
|
144
|
+
return round(x, 1) if x is not None else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ratio_note(set_px, blit_px, fill_px):
|
|
148
|
+
if not (set_px and blit_px and fill_px):
|
|
149
|
+
return "interpreted per-pixel writes are far slower than the C bulk ops"
|
|
150
|
+
return ("interpreted bitmap[x,y]= is ~%dx slower than a C blit and ~%dx "
|
|
151
|
+
"slower than a C fill, per pixel"
|
|
152
|
+
% (round(set_px / blit_px), round(set_px / fill_px)))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def performance_text(guide=None):
|
|
156
|
+
"""Render the performance guide as a compact human/AI summary."""
|
|
157
|
+
guide = guide or performance_guide()
|
|
158
|
+
if not guide.get("available"):
|
|
159
|
+
return "Performance guide unavailable (no device benchmark data)."
|
|
160
|
+
lines = ["=== %s performance (measured) ===" % guide.get("board", "device")]
|
|
161
|
+
pw = guide["pixel_write_ns_per_px"]
|
|
162
|
+
lines.append("Pixel write ns/px: interpreted=%s | C blit=%s | C fill=%s"
|
|
163
|
+
% (pw["interpreted_setpixel"], pw["c_bitmaptools_blit"],
|
|
164
|
+
pw["c_bitmap_fill"]))
|
|
165
|
+
lines.append(" -> %s" % pw["note"])
|
|
166
|
+
if guide["refresh"]:
|
|
167
|
+
parts = ["%s=%sms(~%dfps)" % (k.replace("bit_depth_", "bd"),
|
|
168
|
+
v["full_refresh_ms"], v["fps_ceiling"])
|
|
169
|
+
for k, v in guide["refresh"].items()]
|
|
170
|
+
lines.append("Full refresh by depth: " + " | ".join(parts))
|
|
171
|
+
lines.append("Rules:")
|
|
172
|
+
for r in guide["rules"]:
|
|
173
|
+
lines.append(" - " + r)
|
|
174
|
+
return "\n".join(lines)
|