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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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()