circuitpython-rv565 1.2.0__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.
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: circuitpython-rv565
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Play RV565 frame-independent RGB565 video files on CircuitPython displays.
|
|
5
|
+
Author-email: Ayushman Bhattacharya <ayushman.elixpo@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/elixpo/CircuitPython_RV565
|
|
8
|
+
Project-URL: Documentation, https://circuitpython-rv565.readthedocs.io/
|
|
9
|
+
Project-URL: RV565 project, https://github.com/elixpo/rv565
|
|
10
|
+
Project-URL: Paper, https://arxiv.org/abs/2610.35935
|
|
11
|
+
Keywords: adafruit,blinka,circuitpython,micropython,rv565,video,rgb565,displayio,st7789
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
15
|
+
Classifier: Topic :: System :: Hardware
|
|
16
|
+
Classifier: Topic :: Multimedia :: Video :: Display
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Description-Content-Type: text/x-rst
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: Adafruit-Blinka
|
|
21
|
+
Provides-Extra: optional
|
|
22
|
+
Requires-Dist: adafruit-circuitpython-st7789; extra == "optional"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
Introduction
|
|
26
|
+
============
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
.. image:: https://readthedocs.org/projects/circuitpython-rv565/badge/?version=latest
|
|
30
|
+
:target: https://circuitpython-rv565.readthedocs.io/
|
|
31
|
+
:alt: Documentation Status
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
.. image:: https://img.shields.io/discord/327254708534116352.svg
|
|
35
|
+
:target: https://adafru.it/discord
|
|
36
|
+
:alt: Discord
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
.. image:: https://github.com/elixpo/CircuitPython_RV565/workflows/Build%20CI/badge.svg
|
|
40
|
+
:target: https://github.com/elixpo/CircuitPython_RV565/actions
|
|
41
|
+
:alt: Build Status
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
|
|
45
|
+
:target: https://github.com/astral-sh/ruff
|
|
46
|
+
:alt: Code Style: Ruff
|
|
47
|
+
|
|
48
|
+
Play `RV565 <https://github.com/elixpo/rv565>`_ video files on CircuitPython
|
|
49
|
+
displays.
|
|
50
|
+
|
|
51
|
+
RV565 stores video as independently zlib-compressed frames whose decoded
|
|
52
|
+
bytes are already big-endian RGB565, the native pixel format of ST7789-class
|
|
53
|
+
SPI panels (`format specification
|
|
54
|
+
<https://github.com/elixpo/rv565/blob/main/docs/FORMAT.md>`_, paper:
|
|
55
|
+
`arXiv:2610.35935 <https://arxiv.org/abs/2610.35935>`_). This library reads
|
|
56
|
+
them in pure Python with CircuitPython's built-in ``zlib`` and shows them with
|
|
57
|
+
``displayio``, or sends them straight to an ST7789.
|
|
58
|
+
|
|
59
|
+
It is the easy way to try RV565 on a board without a C toolchain. The
|
|
60
|
+
`RV565 C library <https://github.com/elixpo/rv565>`_ (Arduino, ESP-IDF,
|
|
61
|
+
Pico SDK) is the fast way: it reaches the clip's full 24 FPS.
|
|
62
|
+
|
|
63
|
+
Dependencies
|
|
64
|
+
=============
|
|
65
|
+
This library depends on:
|
|
66
|
+
|
|
67
|
+
* `Adafruit CircuitPython <https://github.com/adafruit/circuitpython>`_ 9 or
|
|
68
|
+
later (8.x should work but is untested), on a board whose build includes
|
|
69
|
+
``zlib``, ``displayio`` and ``bitmaptools``. Most ESP32-S3 and RP2350 builds
|
|
70
|
+
include all three; check the `module support matrix
|
|
71
|
+
<https://docs.circuitpython.org/en/latest/shared-bindings/support_matrix.html>`_
|
|
72
|
+
for your board.
|
|
73
|
+
* `Adafruit CircuitPython ST7789
|
|
74
|
+
<https://github.com/adafruit/Adafruit_CircuitPython_ST7789>`_, only for the
|
|
75
|
+
examples that drive an external ST7789.
|
|
76
|
+
|
|
77
|
+
Please ensure all dependencies are available on the CircuitPython filesystem.
|
|
78
|
+
This is easily achieved by downloading
|
|
79
|
+
`the Adafruit library and driver bundle <https://circuitpython.org/libraries>`_
|
|
80
|
+
or individual libraries can be installed using
|
|
81
|
+
`circup <https://github.com/adafruit/circup>`_.
|
|
82
|
+
|
|
83
|
+
Performance
|
|
84
|
+
===========
|
|
85
|
+
|
|
86
|
+
Measured on a Raspberry Pi Pico 2 (RP2350, CircuitPython 10.3.1), playing
|
|
87
|
+
the 180x135, 24 FPS test pattern to a 320x240 ST7789 on SPI at 40 MHz.
|
|
88
|
+
Colours, orientation and position were correct with both players:
|
|
89
|
+
|
|
90
|
+
================== ======= =================== =================
|
|
91
|
+
Player FPS inflate per frame draw per frame
|
|
92
|
+
================== ======= =================== =================
|
|
93
|
+
``Player`` 8.0 51.0 ms 73.3 ms
|
|
94
|
+
``DirectPlayer`` 16.5 47.7 ms 12.6 ms
|
|
95
|
+
================== ======= =================== =================
|
|
96
|
+
|
|
97
|
+
``DirectPlayer``'s draw is close to the SPI wire time (9.7 ms for the frame),
|
|
98
|
+
so inflating with CircuitPython's ``zlib`` (about 48 ms a frame, against about
|
|
99
|
+
16 ms for the C library's ``tinfl`` on the same chip) is what limits it.
|
|
100
|
+
Film clips compress less than the test pattern and inflate more slowly.
|
|
101
|
+
|
|
102
|
+
**Expect a fraction of the clip's rate, not 24.** The C library reaches 24
|
|
103
|
+
FPS by inflating on one core while DMA sends the previous frame from the
|
|
104
|
+
other. Here, inflating and drawing happen one after the other in a single
|
|
105
|
+
Python thread. Frames that fall behind are skipped, so playback keeps the
|
|
106
|
+
clip's timing, and ``play()`` returns the frame rate it actually achieved.
|
|
107
|
+
|
|
108
|
+
Installing from PyPI
|
|
109
|
+
=====================
|
|
110
|
+
|
|
111
|
+
On supported GNU/Linux systems like the Raspberry Pi, you can install the
|
|
112
|
+
library locally `from PyPI <https://pypi.org/project/circuitpython-rv565/>`_.
|
|
113
|
+
To install for current user:
|
|
114
|
+
|
|
115
|
+
.. code-block:: shell
|
|
116
|
+
|
|
117
|
+
pip3 install circuitpython-rv565
|
|
118
|
+
|
|
119
|
+
To install system-wide (this may be required in some cases):
|
|
120
|
+
|
|
121
|
+
.. code-block:: shell
|
|
122
|
+
|
|
123
|
+
sudo pip3 install circuitpython-rv565
|
|
124
|
+
|
|
125
|
+
To install in a virtual environment in your current project:
|
|
126
|
+
|
|
127
|
+
.. code-block:: shell
|
|
128
|
+
|
|
129
|
+
mkdir project-name && cd project-name
|
|
130
|
+
python3 -m venv .venv
|
|
131
|
+
source .venv/bin/activate
|
|
132
|
+
pip3 install circuitpython-rv565
|
|
133
|
+
|
|
134
|
+
The reader (``rv565.open()``, ``Clip.frame()``) works under CPython too; the
|
|
135
|
+
players need ``displayio``.
|
|
136
|
+
|
|
137
|
+
Installing to a Connected CircuitPython Device with Circup
|
|
138
|
+
==========================================================
|
|
139
|
+
|
|
140
|
+
Make sure that you have ``circup`` installed in your Python environment.
|
|
141
|
+
Install it with the following command if necessary:
|
|
142
|
+
|
|
143
|
+
.. code-block:: shell
|
|
144
|
+
|
|
145
|
+
pip3 install circup
|
|
146
|
+
|
|
147
|
+
With ``circup`` installed and your CircuitPython device connected use the
|
|
148
|
+
following command to install:
|
|
149
|
+
|
|
150
|
+
.. code-block:: shell
|
|
151
|
+
|
|
152
|
+
circup install circuitpython_rv565
|
|
153
|
+
|
|
154
|
+
Or the following command to update an existing version:
|
|
155
|
+
|
|
156
|
+
.. code-block:: shell
|
|
157
|
+
|
|
158
|
+
circup update
|
|
159
|
+
|
|
160
|
+
Without circup, copy ``circuitpython_rv565.mpy`` from the
|
|
161
|
+
`CircuitPython Community Bundle <https://github.com/adafruit/CircuitPython_Community_Bundle/releases>`_
|
|
162
|
+
(or from this repository's
|
|
163
|
+
`releases <https://github.com/elixpo/CircuitPython_RV565/releases/latest>`_)
|
|
164
|
+
to the ``lib`` folder on your ``CIRCUITPY`` drive.
|
|
165
|
+
|
|
166
|
+
Usage Example
|
|
167
|
+
=============
|
|
168
|
+
|
|
169
|
+
Copy ``examples/testpattern.rv565`` (180x135, 24 FPS, 24 frames, 6 KB) to the
|
|
170
|
+
root of ``CIRCUITPY``, then run this as ``code.py``:
|
|
171
|
+
|
|
172
|
+
.. code-block:: python
|
|
173
|
+
|
|
174
|
+
import board
|
|
175
|
+
|
|
176
|
+
import circuitpython_rv565 as rv565
|
|
177
|
+
|
|
178
|
+
clip = rv565.open("/testpattern.rv565") # path, file object or bytes
|
|
179
|
+
print(clip.width, clip.height, clip.fps, len(clip))
|
|
180
|
+
|
|
181
|
+
player = rv565.Player(clip, board.DISPLAY)
|
|
182
|
+
stats = player.play() # paced to clip.fps, late frames skipped
|
|
183
|
+
print(stats) # <PlayStats x.xx of 24 FPS: n shown, m skipped ...>
|
|
184
|
+
|
|
185
|
+
``rv565.open()`` reads only the 12-byte header. The frame index is built on
|
|
186
|
+
first use with one pass over the length fields (4 bytes of RAM per frame),
|
|
187
|
+
which also validates the whole container; call ``clip.build_index()`` to do
|
|
188
|
+
that before playback. Frames are read from the file one at a time, so a clip
|
|
189
|
+
on ``CIRCUITPY`` or an SD card does not have to fit in RAM. ``clip.frame(i)``
|
|
190
|
+
returns frame ``i`` as big-endian RGB565 bytes and raises ``RV565Error`` if it
|
|
191
|
+
does not inflate to exactly ``2 * width * height`` bytes.
|
|
192
|
+
|
|
193
|
+
The examples folder has:
|
|
194
|
+
|
|
195
|
+
* ``rv565_simpletest.py``: any board with ``board.DISPLAY``.
|
|
196
|
+
* ``rv565_pico2_st7789.py``: Raspberry Pi Pico 2 with a 2.0" 320x240 ST7789
|
|
197
|
+
on SPI0 (SCK GP18, MOSI GP19, CS GP17, DC GP20, RST GP21, BL GP22).
|
|
198
|
+
* ``rv565_pico2_direct.py``: the same wiring with ``DirectPlayer``.
|
|
199
|
+
* ``rv565_esp32s3_tft.py``: Adafruit ESP32-S3 TFT Feather (built-in 240x135
|
|
200
|
+
ST7789).
|
|
201
|
+
|
|
202
|
+
Making clips
|
|
203
|
+
------------
|
|
204
|
+
|
|
205
|
+
Use the `online converter <https://oreo.elixpo.com/rv565/convert/>`_, which
|
|
206
|
+
runs in your browser, or the Python tool:
|
|
207
|
+
|
|
208
|
+
.. code-block:: shell
|
|
209
|
+
|
|
210
|
+
pip install rv565
|
|
211
|
+
rv565 encode input.mp4 clip.rv565 --size 180x135 --fps 12
|
|
212
|
+
rv565 verify clip.rv565
|
|
213
|
+
|
|
214
|
+
Because this library will not reach 24 FPS, a lower rate such as 8 or 12 FPS
|
|
215
|
+
wastes less of the file on frames that will be skipped. Keep clips at 180x135
|
|
216
|
+
or smaller on boards without PSRAM, such as the Pico 2: a 320x240 frame needs
|
|
217
|
+
150 KiB inflated and as much again for the bitmap.
|
|
218
|
+
|
|
219
|
+
How a frame reaches the display
|
|
220
|
+
-------------------------------
|
|
221
|
+
|
|
222
|
+
``Player`` inflates each frame into a ``displayio.Bitmap`` with 65536 values,
|
|
223
|
+
drawn through ``ColorConverter(input_colorspace=Colorspace.RGB565_SWAPPED)``.
|
|
224
|
+
The bitmap stores each value as a little-endian 16-bit word, so copying the
|
|
225
|
+
file's big-endian bytes in unchanged gives byte-swapped values, which is
|
|
226
|
+
exactly what ``RGB565_SWAPPED`` describes (the same convention ``gifio``
|
|
227
|
+
uses). The copy is one ``bitmaptools.readinto()`` call; boards without it fall
|
|
228
|
+
back to a much slower per-pixel loop.
|
|
229
|
+
|
|
230
|
+
``DirectPlayer`` skips ``displayio``: since RV565 bytes are already what an
|
|
231
|
+
ST7789 expects, it sets the address window and sends each frame with one
|
|
232
|
+
``bus.send()``. It needs the display to be initialised first (for example by
|
|
233
|
+
``adafruit_st7789``) and takes the panel over while playing. ``displayio``
|
|
234
|
+
rotation is done in software and never reaches the controller, so for a
|
|
235
|
+
landscape panel ``DirectPlayer`` sets MADCTL to ``0x60`` before playing and
|
|
236
|
+
back to ``0xC0`` (``adafruit_st7789``'s value) afterwards; pass ``madctl=``
|
|
237
|
+
and ``restore_madctl=`` for other orientations or drivers. Modules smaller
|
|
238
|
+
than the controller's 240x320 RAM need ``x_offset``/``y_offset`` as well.
|
|
239
|
+
|
|
240
|
+
Testing on a computer
|
|
241
|
+
---------------------
|
|
242
|
+
|
|
243
|
+
The tests run with CPython and stand-ins for ``displayio`` and
|
|
244
|
+
``bitmaptools``:
|
|
245
|
+
|
|
246
|
+
.. code-block:: shell
|
|
247
|
+
|
|
248
|
+
python3 -m pytest
|
|
249
|
+
|
|
250
|
+
They check the reader against ``examples/testpattern.rv565`` (all 24 frames
|
|
251
|
+
must equal CPython's zlib output), every validation rule of the format, that
|
|
252
|
+
the bitmap shows the right colour for every pixel, frame pacing and skipping,
|
|
253
|
+
the bytes ``DirectPlayer`` sends, and that every example runs.
|
|
254
|
+
|
|
255
|
+
Documentation
|
|
256
|
+
=============
|
|
257
|
+
API documentation for this library can be found on `Read the Docs <https://circuitpython-rv565.readthedocs.io/>`_.
|
|
258
|
+
|
|
259
|
+
For information on building library documentation, please check out
|
|
260
|
+
`this guide <https://learn.adafruit.com/creating-and-sharing-a-circuitpython-library/sharing-our-docs-on-readthedocs#sphinx-5-1>`_.
|
|
261
|
+
|
|
262
|
+
RV565
|
|
263
|
+
=====
|
|
264
|
+
|
|
265
|
+
* Project, C library and encoder: https://github.com/elixpo/rv565
|
|
266
|
+
* Format specification: https://github.com/elixpo/rv565/blob/main/docs/FORMAT.md
|
|
267
|
+
* Paper: Ayushman Bhattacharya, *RV565: Cross-Layer Co-Design for
|
|
268
|
+
Frame-Independent Video Playback on MicroPython-Class ESP32-S3 Systems*,
|
|
269
|
+
`arXiv:2610.35935 <https://arxiv.org/abs/2610.35935>`_
|
|
270
|
+
|
|
271
|
+
Contributing
|
|
272
|
+
============
|
|
273
|
+
|
|
274
|
+
Contributions are welcome! Please read our `Code of Conduct
|
|
275
|
+
<https://github.com/elixpo/CircuitPython_RV565/blob/HEAD/CODE_OF_CONDUCT.md>`_
|
|
276
|
+
before contributing to help this project stay welcoming.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
circuitpython_rv565.py,sha256=CPKFGY8EtyqSeaaVdDHJHY3mZLZCW69JSCTio3tyDYA,25030
|
|
2
|
+
circuitpython_rv565-1.2.0.dist-info/licenses/LICENSE,sha256=BXO38hlTF-aysz3-sU_ftcbq6NJTBhrYEaqrX0X7ANQ,1088
|
|
3
|
+
circuitpython_rv565-1.2.0.dist-info/METADATA,sha256=euDybVXq8nwDHtRUnesJltYRYCAbNEOjErPm9hdrQV0,11013
|
|
4
|
+
circuitpython_rv565-1.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
circuitpython_rv565-1.2.0.dist-info/top_level.txt,sha256=G2dB9_aa83kbQsipC4BNkH6g3ksSiHkKMA6h6irzVAk,20
|
|
6
|
+
circuitpython_rv565-1.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ayushman Bhattacharya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
circuitpython_rv565
|
circuitpython_rv565.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Ayushman Bhattacharya
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
`circuitpython_rv565`
|
|
5
|
+
================================================================================
|
|
6
|
+
|
|
7
|
+
Play RV565 video files on CircuitPython displays.
|
|
8
|
+
|
|
9
|
+
RV565 (https://github.com/elixpo/rv565, arXiv:2610.35935) stores video as
|
|
10
|
+
independently zlib-compressed frames whose decoded bytes are big-endian
|
|
11
|
+
RGB565, the native pixel format of ST7789-class SPI panels. This module reads
|
|
12
|
+
version 6 files and shows them with ``displayio``.
|
|
13
|
+
|
|
14
|
+
This is the easy route, not the fast one: the C library plays 24 FPS with
|
|
15
|
+
dual-core decoding, while this pure-Python reader inflates with CircuitPython's
|
|
16
|
+
``zlib`` and draws through ``displayio``, so expect a few frames per second.
|
|
17
|
+
Frames that fall behind are skipped, so a clip still plays at its own speed.
|
|
18
|
+
|
|
19
|
+
* Author: Ayushman Bhattacharya
|
|
20
|
+
|
|
21
|
+
Implementation Notes
|
|
22
|
+
--------------------
|
|
23
|
+
|
|
24
|
+
**Hardware:**
|
|
25
|
+
|
|
26
|
+
* Any CircuitPython board whose build includes ``zlib`` (most ESP32-S3 and
|
|
27
|
+
RP2350 boards), with an RGB565 display such as an ST7789.
|
|
28
|
+
|
|
29
|
+
**Software and Dependencies:**
|
|
30
|
+
|
|
31
|
+
* CircuitPython firmware: https://circuitpython.org/downloads
|
|
32
|
+
* ``zlib``, ``displayio`` and ``bitmaptools`` built-in modules
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import struct
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from zlib import decompress as _zlib_decompress
|
|
39
|
+
except ImportError: # boards built without zlib
|
|
40
|
+
_zlib_decompress = None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from time import monotonic_ns as _monotonic_ns
|
|
44
|
+
except ImportError: # boards without long integers
|
|
45
|
+
_monotonic_ns = None
|
|
46
|
+
|
|
47
|
+
__version__ = "1.2.0"
|
|
48
|
+
__repo__ = "https://github.com/elixpo/CircuitPython_RV565.git"
|
|
49
|
+
|
|
50
|
+
MAGIC = b"RV5"
|
|
51
|
+
VERSION = 6
|
|
52
|
+
MAX_FPS = 30
|
|
53
|
+
HEADER_SIZE = 12
|
|
54
|
+
_HEADER = "<3sBHHBBH" # magic, version, width, height, fps, flags, frames
|
|
55
|
+
_LENGTH = "<I"
|
|
56
|
+
|
|
57
|
+
# ST7789 / ILI9341 commands used by DirectPlayer.
|
|
58
|
+
_CASET = 0x2A
|
|
59
|
+
_RASET = 0x2B
|
|
60
|
+
_RAMWR = 0x2C
|
|
61
|
+
_MADCTL = 0x36
|
|
62
|
+
|
|
63
|
+
# MADCTL values. CircuitPython rotates displayio in software and never
|
|
64
|
+
# changes MADCTL, so after adafruit_st7789's init the controller still
|
|
65
|
+
# addresses its RAM as 240 columns x 320 rows (MADCTL 0xC0). DirectPlayer
|
|
66
|
+
# writes the controller directly, so for a landscape panel it must turn on
|
|
67
|
+
# row/column exchange (MV) itself.
|
|
68
|
+
MADCTL_LANDSCAPE = 0x60 # MV | MX: what the RV565 C and MicroPython benchmarks use
|
|
69
|
+
MADCTL_ADAFRUIT_ST7789 = 0xC0 # adafruit_st7789's last init value (portrait)
|
|
70
|
+
|
|
71
|
+
_builtin_open = open # noqa: A001 - this module defines its own open()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RV565Error(ValueError):
|
|
75
|
+
"""The data is not a valid RV565 file, or a frame does not decode."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_header(data):
|
|
79
|
+
"""Parse and range-check the 12-byte header.
|
|
80
|
+
|
|
81
|
+
:param data: at least the first 12 bytes of the file
|
|
82
|
+
:return: ``(width, height, fps, flags, frames)``
|
|
83
|
+
"""
|
|
84
|
+
if len(data) < HEADER_SIZE:
|
|
85
|
+
raise RV565Error("file too short")
|
|
86
|
+
magic, version, width, height, fps, flags, frames = struct.unpack_from(_HEADER, data, 0)
|
|
87
|
+
if bytes(magic) != MAGIC:
|
|
88
|
+
raise RV565Error("not an RV565 file")
|
|
89
|
+
if version != VERSION:
|
|
90
|
+
raise RV565Error("unsupported RV565 version %d" % version)
|
|
91
|
+
if not width or not height:
|
|
92
|
+
raise RV565Error("invalid header: zero width or height")
|
|
93
|
+
if not 1 <= fps <= MAX_FPS:
|
|
94
|
+
raise RV565Error("invalid header: fps %d outside 1-30" % fps)
|
|
95
|
+
if not frames:
|
|
96
|
+
raise RV565Error("invalid header: no frames")
|
|
97
|
+
return width, height, fps, flags, frames
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _BytesSource:
|
|
101
|
+
"""Random access over bytes already in RAM (no copies)."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, data):
|
|
104
|
+
self._view = memoryview(data)
|
|
105
|
+
self.size = len(self._view)
|
|
106
|
+
|
|
107
|
+
def read(self, offset, size):
|
|
108
|
+
return self._view[offset : offset + size]
|
|
109
|
+
|
|
110
|
+
def close(self):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _FileSource:
|
|
115
|
+
"""Random access over a file object opened in binary mode."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, stream, owned):
|
|
118
|
+
self._stream = stream
|
|
119
|
+
self._owned = owned
|
|
120
|
+
stream.seek(0, 2)
|
|
121
|
+
self.size = stream.tell()
|
|
122
|
+
|
|
123
|
+
def read(self, offset, size):
|
|
124
|
+
self._stream.seek(offset)
|
|
125
|
+
return self._stream.read(size)
|
|
126
|
+
|
|
127
|
+
def close(self):
|
|
128
|
+
if self._owned:
|
|
129
|
+
self._stream.close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Clip:
|
|
133
|
+
"""An RV565 file: header, a lazily built frame index, and frame decoding.
|
|
134
|
+
|
|
135
|
+
Use :func:`open` rather than constructing this directly. Only the header
|
|
136
|
+
is read on open; the frame index is built on first use by one pass over
|
|
137
|
+
the length fields (4 bytes of RAM per frame), which also validates the
|
|
138
|
+
whole container: every length is nonzero and inside the file, and no
|
|
139
|
+
bytes follow the last frame.
|
|
140
|
+
|
|
141
|
+
:param source: an internal byte source (see :func:`open`)
|
|
142
|
+
:param decompress: function inflating a zlib stream to bytes; defaults to
|
|
143
|
+
the built-in ``zlib.decompress``
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, source, decompress=None):
|
|
147
|
+
self._source = source
|
|
148
|
+
try:
|
|
149
|
+
header = bytes(source.read(0, HEADER_SIZE))
|
|
150
|
+
(
|
|
151
|
+
self.width,
|
|
152
|
+
self.height,
|
|
153
|
+
self.fps,
|
|
154
|
+
self.flags,
|
|
155
|
+
self.frame_count,
|
|
156
|
+
) = parse_header(header)
|
|
157
|
+
except Exception:
|
|
158
|
+
source.close()
|
|
159
|
+
raise
|
|
160
|
+
self.frame_bytes = 2 * self.width * self.height
|
|
161
|
+
self._offsets = None
|
|
162
|
+
self._decompress = decompress or _zlib_decompress
|
|
163
|
+
self._sized_inflate = decompress is None # try passing the output size
|
|
164
|
+
|
|
165
|
+
# -- container -----------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def __len__(self):
|
|
168
|
+
return self.frame_count
|
|
169
|
+
|
|
170
|
+
def __enter__(self):
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
def __exit__(self, *exc):
|
|
174
|
+
self.close()
|
|
175
|
+
|
|
176
|
+
def close(self):
|
|
177
|
+
"""Close the underlying file, if :func:`open` opened it."""
|
|
178
|
+
self._source.close()
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def duration(self):
|
|
182
|
+
"""Length in seconds."""
|
|
183
|
+
return self.frame_count / self.fps
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def indexed(self):
|
|
187
|
+
"""True once the frame index has been built."""
|
|
188
|
+
return self._offsets is not None
|
|
189
|
+
|
|
190
|
+
def build_index(self):
|
|
191
|
+
"""Scan the length fields once and validate the container.
|
|
192
|
+
|
|
193
|
+
Called automatically by :meth:`frame`, :meth:`payload` and
|
|
194
|
+
:meth:`payload_sizes`; call it yourself to fail early on a damaged
|
|
195
|
+
file. Raises :class:`RV565Error` if the container is invalid.
|
|
196
|
+
"""
|
|
197
|
+
if self._offsets is not None:
|
|
198
|
+
return
|
|
199
|
+
import array # noqa: PLC0415 - imported only when needed
|
|
200
|
+
|
|
201
|
+
offsets = array.array("L")
|
|
202
|
+
size = self._source.size
|
|
203
|
+
offset = HEADER_SIZE
|
|
204
|
+
for i in range(self.frame_count):
|
|
205
|
+
if size - offset < 4:
|
|
206
|
+
raise RV565Error("truncated frame table at frame %d" % i)
|
|
207
|
+
(length,) = struct.unpack_from(_LENGTH, bytes(self._source.read(offset, 4)), 0)
|
|
208
|
+
offset += 4
|
|
209
|
+
if length == 0 or size - offset < length:
|
|
210
|
+
raise RV565Error("invalid length for frame %d" % i)
|
|
211
|
+
offsets.append(offset)
|
|
212
|
+
offset += length
|
|
213
|
+
if offset != size:
|
|
214
|
+
raise RV565Error("trailing bytes after the last frame")
|
|
215
|
+
self._offsets = offsets
|
|
216
|
+
|
|
217
|
+
def _span(self, i):
|
|
218
|
+
if not 0 <= i < self.frame_count:
|
|
219
|
+
raise IndexError("frame %d out of range (0-%d)" % (i, self.frame_count - 1))
|
|
220
|
+
self.build_index()
|
|
221
|
+
start = self._offsets[i]
|
|
222
|
+
if i + 1 < self.frame_count:
|
|
223
|
+
end = self._offsets[i + 1] - 4
|
|
224
|
+
else:
|
|
225
|
+
end = self._source.size
|
|
226
|
+
return start, end - start
|
|
227
|
+
|
|
228
|
+
def payload_sizes(self):
|
|
229
|
+
"""Compressed length of every frame, in order."""
|
|
230
|
+
return [self._span(i)[1] for i in range(self.frame_count)]
|
|
231
|
+
|
|
232
|
+
def payload(self, i):
|
|
233
|
+
"""The zlib payload of frame ``i`` (bytes or a memoryview)."""
|
|
234
|
+
offset, length = self._span(i)
|
|
235
|
+
return self._source.read(offset, length)
|
|
236
|
+
|
|
237
|
+
# -- decoding ------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def _inflate(self, data):
|
|
240
|
+
if self._decompress is None:
|
|
241
|
+
raise RV565Error(
|
|
242
|
+
"this CircuitPython build has no zlib module; use a board "
|
|
243
|
+
"whose build includes zlib (most ESP32-S3 and RP2350 boards), "
|
|
244
|
+
"or pass decompress= to open()"
|
|
245
|
+
)
|
|
246
|
+
if self._sized_inflate:
|
|
247
|
+
# CPython and some CircuitPython builds accept (data, wbits,
|
|
248
|
+
# bufsize); wbits 15 means "zlib header, any window up to 32 KiB".
|
|
249
|
+
# Builds that only take (data) raise TypeError once, and from then
|
|
250
|
+
# on we call the one-argument form.
|
|
251
|
+
try:
|
|
252
|
+
return self._decompress(data, 15, self.frame_bytes)
|
|
253
|
+
except TypeError:
|
|
254
|
+
self._sized_inflate = False
|
|
255
|
+
return self._decompress(data)
|
|
256
|
+
|
|
257
|
+
def frame(self, i):
|
|
258
|
+
"""Frame ``i`` as big-endian RGB565 bytes (``2 * width * height``).
|
|
259
|
+
|
|
260
|
+
Raises :class:`RV565Error` if the payload does not inflate to exactly
|
|
261
|
+
that many bytes.
|
|
262
|
+
"""
|
|
263
|
+
payload = self.payload(i)
|
|
264
|
+
try:
|
|
265
|
+
data = self._inflate(payload)
|
|
266
|
+
except MemoryError:
|
|
267
|
+
raise
|
|
268
|
+
except RV565Error:
|
|
269
|
+
raise
|
|
270
|
+
except Exception as exc: # zlib.error, ValueError, OSError by port
|
|
271
|
+
raise RV565Error("frame %d does not inflate: %s" % (i, exc))
|
|
272
|
+
if len(data) != self.frame_bytes:
|
|
273
|
+
raise RV565Error(
|
|
274
|
+
"frame %d inflates to %d bytes, expected %d" % (i, len(data), self.frame_bytes)
|
|
275
|
+
)
|
|
276
|
+
return data
|
|
277
|
+
|
|
278
|
+
def __iter__(self):
|
|
279
|
+
for i in range(self.frame_count):
|
|
280
|
+
yield self.frame(i)
|
|
281
|
+
|
|
282
|
+
def verify(self):
|
|
283
|
+
"""Inflate every frame; raise :class:`RV565Error` on the first bad one.
|
|
284
|
+
|
|
285
|
+
:return: the number of frames checked
|
|
286
|
+
"""
|
|
287
|
+
for i in range(self.frame_count):
|
|
288
|
+
self.frame(i)
|
|
289
|
+
return self.frame_count
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def open(source, decompress=None): # noqa: A001 - mirrors the built-in open()
|
|
293
|
+
"""Open an RV565 clip.
|
|
294
|
+
|
|
295
|
+
:param source: a path such as ``"/clip.rv565"`` or ``"/sd/clip.rv565"``,
|
|
296
|
+
a file object opened in binary mode, or ``bytes``/``bytearray``
|
|
297
|
+
:param decompress: optional replacement for ``zlib.decompress``
|
|
298
|
+
:return: a :class:`Clip`
|
|
299
|
+
|
|
300
|
+
A path is opened and read frame by frame, so the clip does not have to fit
|
|
301
|
+
in RAM; :meth:`Clip.close` (or a ``with`` block) closes it again.
|
|
302
|
+
"""
|
|
303
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
304
|
+
return Clip(_BytesSource(source), decompress)
|
|
305
|
+
if hasattr(source, "read"):
|
|
306
|
+
return Clip(_FileSource(source, False), decompress)
|
|
307
|
+
return Clip(_FileSource(_builtin_open(source, "rb"), True), decompress)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# -- pacing ------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class _Clock:
|
|
314
|
+
"""Seconds since the last reset(), as a float.
|
|
315
|
+
|
|
316
|
+
CircuitPython floats have about 22 bits of mantissa, so absolute uptime in
|
|
317
|
+
seconds loses millisecond precision after an hour or so. The origin is
|
|
318
|
+
kept in integer nanoseconds and the play loop moves it forward with
|
|
319
|
+
:meth:`rebase` at every loop of the clip, so the floats it sees stay
|
|
320
|
+
within one clip's length even when ``loops=0`` plays for days.
|
|
321
|
+
|
|
322
|
+
Boards without ``time.monotonic_ns`` (no long integers) fall back to
|
|
323
|
+
``time.monotonic()``, whose own value is absolute uptime as a float;
|
|
324
|
+
rebasing cannot restore precision it has already lost there.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def __init__(self):
|
|
328
|
+
self._t0 = 0
|
|
329
|
+
self.reset()
|
|
330
|
+
|
|
331
|
+
def reset(self):
|
|
332
|
+
if _monotonic_ns is not None:
|
|
333
|
+
self._t0 = _monotonic_ns()
|
|
334
|
+
else:
|
|
335
|
+
import time # noqa: PLC0415 - imported only when needed
|
|
336
|
+
|
|
337
|
+
self._t0 = time.monotonic()
|
|
338
|
+
|
|
339
|
+
def rebase(self, frames, fps):
|
|
340
|
+
"""Move the origin forward by ``frames`` frames at ``fps``."""
|
|
341
|
+
if _monotonic_ns is not None:
|
|
342
|
+
self._t0 += frames * 1_000_000_000 // fps
|
|
343
|
+
else:
|
|
344
|
+
self._t0 += frames / fps
|
|
345
|
+
|
|
346
|
+
def __call__(self):
|
|
347
|
+
if _monotonic_ns is not None:
|
|
348
|
+
return (_monotonic_ns() - self._t0) / 1e9
|
|
349
|
+
import time # noqa: PLC0415 - imported only when needed
|
|
350
|
+
|
|
351
|
+
return time.monotonic() - self._t0
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _default_sleep(seconds):
|
|
355
|
+
import time # noqa: PLC0415 - imported only when needed
|
|
356
|
+
|
|
357
|
+
time.sleep(seconds)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class PlayStats:
|
|
361
|
+
"""What :meth:`Player.play` achieved.
|
|
362
|
+
|
|
363
|
+
:ivar shown: frames decoded and drawn
|
|
364
|
+
:ivar skipped: frames dropped to keep up with the clip's rate
|
|
365
|
+
:ivar elapsed: wall time in seconds
|
|
366
|
+
:ivar decode_time: seconds spent inflating
|
|
367
|
+
:ivar draw_time: seconds spent filling the bitmap and refreshing
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
def __init__(self, fps):
|
|
371
|
+
self.target_fps = fps
|
|
372
|
+
self.shown = 0
|
|
373
|
+
self.skipped = 0
|
|
374
|
+
self.elapsed = 0.0
|
|
375
|
+
self.decode_time = 0.0
|
|
376
|
+
self.draw_time = 0.0
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def fps(self):
|
|
380
|
+
"""Frames actually shown per second."""
|
|
381
|
+
return self.shown / self.elapsed if self.elapsed > 0 else 0.0
|
|
382
|
+
|
|
383
|
+
def __repr__(self):
|
|
384
|
+
per = self.shown or 1
|
|
385
|
+
return (
|
|
386
|
+
"<PlayStats %.2f of %d FPS: %d shown, %d skipped in %.2f s; "
|
|
387
|
+
"%.1f ms inflate + %.1f ms draw per frame>"
|
|
388
|
+
% (
|
|
389
|
+
self.fps,
|
|
390
|
+
self.target_fps,
|
|
391
|
+
self.shown,
|
|
392
|
+
self.skipped,
|
|
393
|
+
self.elapsed,
|
|
394
|
+
1000 * self.decode_time / per,
|
|
395
|
+
1000 * self.draw_time / per,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class _Pacer:
|
|
401
|
+
"""Shared play loop: shows frames on the clip's timeline, skipping late ones."""
|
|
402
|
+
|
|
403
|
+
def __init__(self, clip, clock=None, sleep=None):
|
|
404
|
+
self.clip = clip
|
|
405
|
+
self._clock = clock or _Clock()
|
|
406
|
+
self._sleep = sleep or _default_sleep
|
|
407
|
+
|
|
408
|
+
def show(self, i):
|
|
409
|
+
"""Decode and draw frame ``i``; return (decode_s, draw_s)."""
|
|
410
|
+
raise NotImplementedError
|
|
411
|
+
|
|
412
|
+
def play(self, loops=1, start=0, on_frame=None): # noqa: PLR0914 - one pacing loop
|
|
413
|
+
"""Play the clip at its frame rate, skipping frames that are late.
|
|
414
|
+
|
|
415
|
+
:param loops: how many times to play the clip; 0 loops forever
|
|
416
|
+
:param start: first frame, ``0 <= start < len(clip)``
|
|
417
|
+
:param on_frame: optional ``callback(index, stats)``; return ``False``
|
|
418
|
+
to stop
|
|
419
|
+
:return: :class:`PlayStats`
|
|
420
|
+
:raises IndexError: if ``start`` is not a frame of the clip
|
|
421
|
+
"""
|
|
422
|
+
clip = self.clip
|
|
423
|
+
n = len(clip)
|
|
424
|
+
if not 0 <= start < n:
|
|
425
|
+
raise IndexError("start frame %d out of range 0..%d" % (start, n - 1))
|
|
426
|
+
fps = clip.fps
|
|
427
|
+
total = n * loops - start if loops else None
|
|
428
|
+
stats = PlayStats(fps)
|
|
429
|
+
clock = self._clock
|
|
430
|
+
if hasattr(clock, "reset"):
|
|
431
|
+
clock.reset()
|
|
432
|
+
rebase = getattr(clock, "rebase", None)
|
|
433
|
+
t0 = clock()
|
|
434
|
+
played = 0.0 # seconds of timeline moved out of the clock by rebase
|
|
435
|
+
position = 0 # frames since t0 on the clip's timeline
|
|
436
|
+
while total is None or position < total:
|
|
437
|
+
if rebase is not None and position >= n:
|
|
438
|
+
# Keep the clock's floats small on long runs: move its origin
|
|
439
|
+
# forward by whole clips and count from there.
|
|
440
|
+
shift = position // n * n
|
|
441
|
+
rebase(shift, fps)
|
|
442
|
+
position -= shift
|
|
443
|
+
if total is not None:
|
|
444
|
+
total -= shift
|
|
445
|
+
played += shift / fps
|
|
446
|
+
index = (start + position) % n
|
|
447
|
+
decode_s, draw_s = self.show(index)
|
|
448
|
+
stats.shown += 1
|
|
449
|
+
stats.decode_time += decode_s
|
|
450
|
+
stats.draw_time += draw_s
|
|
451
|
+
if on_frame is not None and on_frame(index, stats) is False:
|
|
452
|
+
break
|
|
453
|
+
position += 1
|
|
454
|
+
now = clock()
|
|
455
|
+
due = int((now - t0) * fps) # frame the timeline has reached
|
|
456
|
+
if due > position:
|
|
457
|
+
# Behind: drop the frames that are already late.
|
|
458
|
+
if total is not None and due > total:
|
|
459
|
+
due = total
|
|
460
|
+
stats.skipped += due - position
|
|
461
|
+
position = due
|
|
462
|
+
else:
|
|
463
|
+
wait = t0 + position / fps - now
|
|
464
|
+
if wait > 0:
|
|
465
|
+
self._sleep(wait)
|
|
466
|
+
stats.elapsed = played + clock() - t0
|
|
467
|
+
return stats
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class Player(_Pacer):
|
|
471
|
+
"""Show an RV565 clip through ``displayio``.
|
|
472
|
+
|
|
473
|
+
Frames are inflated into a ``displayio.Bitmap`` with 65536 values and
|
|
474
|
+
drawn through a ``ColorConverter`` whose input colorspace is
|
|
475
|
+
``RGB565_SWAPPED``. RV565 stores pixels big-endian; copying those bytes
|
|
476
|
+
into the bitmap's little-endian 16-bit storage gives byte-swapped values,
|
|
477
|
+
which is what ``RGB565_SWAPPED`` describes, so no per-pixel conversion
|
|
478
|
+
happens in Python.
|
|
479
|
+
|
|
480
|
+
:param clip: a :class:`Clip`
|
|
481
|
+
:param display: a display object (``board.DISPLAY``, ``busdisplay.BusDisplay``
|
|
482
|
+
or ``adafruit_st7789.ST7789``), or ``None`` to only build :attr:`group`
|
|
483
|
+
:param int scale: integer zoom for the group (1 = native size)
|
|
484
|
+
:param x: left edge on the display; centred when omitted
|
|
485
|
+
:param y: top edge on the display; centred when omitted
|
|
486
|
+
:param clock: function returning seconds (for tests)
|
|
487
|
+
:param sleep: function sleeping for seconds (for tests)
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
def __init__(self, clip, display=None, scale=1, x=None, y=None, clock=None, sleep=None): # noqa: PLR0913, PLR0917
|
|
491
|
+
super().__init__(clip, clock, sleep)
|
|
492
|
+
import displayio # noqa: PLC0415 - imported only when needed
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
import bitmaptools # noqa: PLC0415 - imported only when needed
|
|
496
|
+
except ImportError:
|
|
497
|
+
bitmaptools = None
|
|
498
|
+
self._readinto = getattr(bitmaptools, "readinto", None)
|
|
499
|
+
try:
|
|
500
|
+
from io import BytesIO # noqa: PLC0415 - imported only when needed
|
|
501
|
+
except ImportError:
|
|
502
|
+
BytesIO = None
|
|
503
|
+
if BytesIO is None:
|
|
504
|
+
self._readinto = None
|
|
505
|
+
self._bytesio = BytesIO
|
|
506
|
+
|
|
507
|
+
self.display = display
|
|
508
|
+
self.bitmap = displayio.Bitmap(clip.width, clip.height, 65536)
|
|
509
|
+
self.converter = displayio.ColorConverter(
|
|
510
|
+
input_colorspace=displayio.Colorspace.RGB565_SWAPPED
|
|
511
|
+
)
|
|
512
|
+
self.tilegrid = displayio.TileGrid(self.bitmap, pixel_shader=self.converter)
|
|
513
|
+
if display is not None:
|
|
514
|
+
if x is None:
|
|
515
|
+
x = max(0, (display.width - clip.width * scale) // 2)
|
|
516
|
+
if y is None:
|
|
517
|
+
y = max(0, (display.height - clip.height * scale) // 2)
|
|
518
|
+
self.group = displayio.Group(scale=scale, x=x or 0, y=y or 0)
|
|
519
|
+
self.group.append(self.tilegrid)
|
|
520
|
+
if display is not None:
|
|
521
|
+
display.root_group = self.group
|
|
522
|
+
|
|
523
|
+
def blit(self, frame):
|
|
524
|
+
"""Copy one decoded frame (big-endian RGB565 bytes) into :attr:`bitmap`."""
|
|
525
|
+
if self._readinto is not None:
|
|
526
|
+
# One C call: reads 16-bit elements into the bitmap row by row,
|
|
527
|
+
# handling the bitmap's row padding.
|
|
528
|
+
self._readinto(self.bitmap, self._bytesio(frame), bits_per_pixel=16, element_size=2)
|
|
529
|
+
return
|
|
530
|
+
# Fallback without bitmaptools.readinto: one Python step per pixel,
|
|
531
|
+
# far slower, but correct.
|
|
532
|
+
bitmap = self.bitmap
|
|
533
|
+
for i in range(len(frame) // 2):
|
|
534
|
+
bitmap[i] = frame[2 * i] | (frame[2 * i + 1] << 8)
|
|
535
|
+
|
|
536
|
+
def show(self, i):
|
|
537
|
+
"""Decode frame ``i``, draw it, and refresh the display.
|
|
538
|
+
|
|
539
|
+
:return: ``(inflate_seconds, draw_seconds)``
|
|
540
|
+
"""
|
|
541
|
+
clock = self._clock
|
|
542
|
+
t0 = clock()
|
|
543
|
+
frame = self.clip.frame(i)
|
|
544
|
+
t1 = clock()
|
|
545
|
+
self.blit(frame)
|
|
546
|
+
if self.display is not None and not getattr(self.display, "auto_refresh", True):
|
|
547
|
+
self.display.refresh()
|
|
548
|
+
return t1 - t0, clock() - t1
|
|
549
|
+
|
|
550
|
+
def play(self, loops=1, start=0, on_frame=None):
|
|
551
|
+
"""Play at the clip's rate, skipping late frames; see :class:`PlayStats`.
|
|
552
|
+
|
|
553
|
+
``auto_refresh`` is turned off while playing so each frame is pushed
|
|
554
|
+
once, and restored afterwards.
|
|
555
|
+
"""
|
|
556
|
+
display = self.display
|
|
557
|
+
restore = None
|
|
558
|
+
if display is not None and hasattr(display, "auto_refresh"):
|
|
559
|
+
restore = display.auto_refresh
|
|
560
|
+
display.auto_refresh = False
|
|
561
|
+
try:
|
|
562
|
+
return super().play(loops, start, on_frame)
|
|
563
|
+
finally:
|
|
564
|
+
if restore is not None:
|
|
565
|
+
display.auto_refresh = restore
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class DirectPlayer(_Pacer):
|
|
569
|
+
"""Experimental: send frames straight to an ST7789 over its display bus.
|
|
570
|
+
|
|
571
|
+
RV565 frames are already in the byte order an ST7789 expects on SPI, so
|
|
572
|
+
this skips ``displayio`` entirely: it sets the address window and sends
|
|
573
|
+
the inflated bytes with one ``bus.send()`` per frame. It is much faster
|
|
574
|
+
than :class:`Player` but bypasses ``displayio``, so the display must be
|
|
575
|
+
initialised first (for example by ``adafruit_st7789.ST7789``) and then left
|
|
576
|
+
alone: this class sets ``root_group = None`` and turns off
|
|
577
|
+
``auto_refresh``.
|
|
578
|
+
|
|
579
|
+
``displayio`` rotation is a software transform: it never reaches the
|
|
580
|
+
controller, whose RAM stays 240 columns x 320 rows. So for a landscape
|
|
581
|
+
panel (``panel_width > panel_height``) :meth:`play` first sends MADCTL
|
|
582
|
+
``madctl`` (default :data:`MADCTL_LANDSCAPE`, ``0x60``, as the RV565
|
|
583
|
+
benchmarks do), which makes the controller's columns run along the long
|
|
584
|
+
side; the address window is then in landscape coordinates. When play
|
|
585
|
+
ends, MADCTL is set back to ``restore_madctl`` so ``displayio`` can draw
|
|
586
|
+
again. A portrait panel sends no MADCTL unless ``madctl`` is given.
|
|
587
|
+
|
|
588
|
+
:param clip: a :class:`Clip`
|
|
589
|
+
:param bus: the ``fourwire.FourWire`` (CircuitPython 9+) or
|
|
590
|
+
``displayio.FourWire`` bus the display was created on
|
|
591
|
+
:param int panel_width: width of the panel as the clip should appear
|
|
592
|
+
:param int panel_height: height of the panel as the clip should appear
|
|
593
|
+
:param int x_offset: controller column offset for this module and MADCTL
|
|
594
|
+
(0 for 320x240 panels in the benchmarks; 40 or 52/53 for 240x135
|
|
595
|
+
ones, depending on orientation)
|
|
596
|
+
:param int y_offset: controller row offset, likewise
|
|
597
|
+
:param display: the display object, so it can be quieted while playing
|
|
598
|
+
:param madctl: MADCTL byte to send before playing; ``None`` picks
|
|
599
|
+
:data:`MADCTL_LANDSCAPE` for a landscape panel and nothing for a
|
|
600
|
+
portrait one. Use ``0xA0`` to turn a landscape picture upside down.
|
|
601
|
+
:param restore_madctl: MADCTL byte to send after playing, or ``None`` to
|
|
602
|
+
leave it; default :data:`MADCTL_ADAFRUIT_ST7789`
|
|
603
|
+
"""
|
|
604
|
+
|
|
605
|
+
def __init__( # noqa: PLR0913, PLR0917
|
|
606
|
+
self,
|
|
607
|
+
clip,
|
|
608
|
+
bus,
|
|
609
|
+
panel_width,
|
|
610
|
+
panel_height,
|
|
611
|
+
x_offset=0,
|
|
612
|
+
y_offset=0,
|
|
613
|
+
display=None,
|
|
614
|
+
clock=None,
|
|
615
|
+
sleep=None,
|
|
616
|
+
madctl=None,
|
|
617
|
+
restore_madctl=MADCTL_ADAFRUIT_ST7789,
|
|
618
|
+
):
|
|
619
|
+
super().__init__(clip, clock, sleep)
|
|
620
|
+
if clip.width > panel_width or clip.height > panel_height:
|
|
621
|
+
raise RV565Error(
|
|
622
|
+
"clip is %dx%d, larger than the %dx%d panel"
|
|
623
|
+
% (clip.width, clip.height, panel_width, panel_height)
|
|
624
|
+
)
|
|
625
|
+
if madctl is None and panel_width > panel_height:
|
|
626
|
+
madctl = MADCTL_LANDSCAPE
|
|
627
|
+
self.madctl = madctl
|
|
628
|
+
self.restore_madctl = restore_madctl
|
|
629
|
+
self.bus = bus
|
|
630
|
+
self.display = display
|
|
631
|
+
x0 = (panel_width - clip.width) // 2 + x_offset
|
|
632
|
+
y0 = (panel_height - clip.height) // 2 + y_offset
|
|
633
|
+
self._caset = struct.pack(">HH", x0, x0 + clip.width - 1)
|
|
634
|
+
self._raset = struct.pack(">HH", y0, y0 + clip.height - 1)
|
|
635
|
+
|
|
636
|
+
def show(self, i):
|
|
637
|
+
"""Decode frame ``i`` and send it to the panel.
|
|
638
|
+
|
|
639
|
+
Called on its own, outside :meth:`play`, it does not send MADCTL;
|
|
640
|
+
call :meth:`begin` first.
|
|
641
|
+
"""
|
|
642
|
+
clock = self._clock
|
|
643
|
+
t0 = clock()
|
|
644
|
+
frame = self.clip.frame(i)
|
|
645
|
+
t1 = clock()
|
|
646
|
+
bus = self.bus
|
|
647
|
+
bus.send(_CASET, self._caset)
|
|
648
|
+
bus.send(_RASET, self._raset)
|
|
649
|
+
bus.send(_RAMWR, frame)
|
|
650
|
+
return t1 - t0, clock() - t1
|
|
651
|
+
|
|
652
|
+
def begin(self):
|
|
653
|
+
"""Take the panel from ``displayio`` and set MADCTL for streaming."""
|
|
654
|
+
display = self.display
|
|
655
|
+
if display is not None:
|
|
656
|
+
display.root_group = None
|
|
657
|
+
if hasattr(display, "auto_refresh"):
|
|
658
|
+
display.auto_refresh = False
|
|
659
|
+
if self.madctl is not None:
|
|
660
|
+
self.bus.send(_MADCTL, bytes((self.madctl,)))
|
|
661
|
+
|
|
662
|
+
def end(self):
|
|
663
|
+
"""Put MADCTL back to ``restore_madctl`` (if MADCTL was changed)."""
|
|
664
|
+
if self.madctl is not None and self.restore_madctl is not None:
|
|
665
|
+
self.bus.send(_MADCTL, bytes((self.restore_madctl,)))
|
|
666
|
+
|
|
667
|
+
def play(self, loops=1, start=0, on_frame=None):
|
|
668
|
+
"""Play at the clip's rate, skipping late frames.
|
|
669
|
+
|
|
670
|
+
Sends MADCTL before the first frame and restores it afterwards, also
|
|
671
|
+
when playing stops with an exception.
|
|
672
|
+
"""
|
|
673
|
+
self.begin()
|
|
674
|
+
try:
|
|
675
|
+
return super().play(loops, start, on_frame)
|
|
676
|
+
finally:
|
|
677
|
+
self.end()
|