API Reference

circuitpython_rv565

Play RV565 video files on CircuitPython displays.

RV565 (https://github.com/elixpo/rv565, arXiv:2610.35935) stores video as frames whose decoded bytes are already in the display’s native pixel format. This module reads format version 7 (nine pixel formats, stored or zlib frames, keyframes, XOR deltas, partial deltas and repeats) and version 6 (big-endian RGB565, zlib, every frame a keyframe), and shows clips with displayio (Player) or straight over an ST7789’s bus (DirectPlayer).

This is the easy route, not the fast one: the C library plays 24 FPS with dual-core decoding, while this pure-Python reader inflates with CircuitPython’s zlib and draws through displayio, so expect a few frames per second. Frames that fall behind are skipped, so a clip still plays at its own speed.

  • Author: Ayushman Bhattacharya

Implementation Notes

Hardware:

  • Any CircuitPython board whose build includes zlib (most ESP32-S3 and RP2350 boards; clips with the stored codec need no zlib), with a display such as an ST7789.

Software and Dependencies:

class circuitpython_rv565.Clip(source, decompress=None)

An RV565 file: header, a lazily built frame index, and frame decoding.

Use open() rather than constructing this directly. Only the header is read and checked on open; the frame index is built on first use by one pass over the record headers (4 bytes of RAM per frame, plus 1 per frame for the frame types in version 7), which also validates the rest of the container: every length is nonzero and inside the file, no bytes follow the last record, frame types are known, every K-th frame is a keyframe, repeats have no body, partial deltas have a valid band height and mask, and stored bodies are exactly one frame (or, for a partial delta, exactly its changed bands). Whether zlib data inflates to its expected size is checked when it is decoded.

Decoding keeps one frame buffer of frame_bytes bytes and returns it from frame(); it is overwritten by the next call.

Parameters:
  • source – an internal byte source (see open())

  • decompress – function inflating a zlib stream to bytes; defaults to the built-in zlib.decompress

build_index()

Scan the record headers once and validate the container.

Called automatically by frame(), payload(), frame_type() and payload_sizes(); call it yourself to fail early on a damaged file. Raises RV565Error if the container is invalid.

changed_lines(i)

Line runs (first, count) that frame i changes from frame i - 1: every line for a keyframe or delta, none for a repeat, the changed bands merged into runs for a partial delta. Lines are pixel rows, or pages of 8 rows for mono_vpage (see lines).

close()

Close the underlying file, if open() opened it.

content_index(i)

The frame whose picture frame i shows: i itself unless it is a repeat, else the latest earlier frame that is not one.

dirty_lines(since, i)

Line runs that differ between frame since (what a display shows) and frame i, from the frame types and band masks alone: the union of changed_lines() of frames since + 1 to i. Every line when since is None or after i, or a keyframe or delta lies between.

property duration

Length in seconds.

frame(i)

Frame i as bytes in the clip’s pixel format (frame_bytes).

The result is the clip’s own frame buffer: the next call to frame() overwrites it, so copy it with bytes() to keep it. Decoding the next frame applies one record; any other frame starts at the latest keyframe at or before it (at most K - 1 records back).

Raises RV565Error if zlib data does not inflate to exactly frame_bytes bytes (for a partial delta, its changed bands).

frame_type(i)

"key", "delta", "repeat" or "partial".

property indexed

True once the frame index has been built.

payload(i)

The encoded body of frame i (bytes or a memoryview; empty for a repeat).

payload_sizes()

Body length of every frame (0 for repeats), in order.

verify()

Decode every frame; raise RV565Error on the first bad one.

Returns:

the number of frames checked

class circuitpython_rv565.DirectPlayer(clip, bus, panel_width, panel_height, x_offset=0, y_offset=0, display=None, clock=None, sleep=None, madctl=None, restore_madctl=MADCTL_ADAFRUIT_ST7789, restore_colmod=COLMOD_RGB565)

Experimental: send frames straight to an ST7789 over its display bus.

RV565 frames are already in the byte order an ST7789 expects on SPI, so this skips displayio entirely: it sets the address window and sends the decoded bytes with one bus.send() per frame. It plays the pixel formats in DIRECT_FORMATS: rgb565be as the panel is set up by adafruit_st7789, and rgb666 by switching the controller to 18-bit pixels (COLMOD 0x66) while playing and back to restore_colmod afterwards. Repeat frames are not decoded or sent during play(); the panel keeps showing the picture. It is much faster than Player but bypasses displayio, so the display must be initialised first (for example by adafruit_st7789.ST7789) and then left alone: this class sets root_group = None and turns off auto_refresh.

displayio rotation is a software transform: it never reaches the controller, whose RAM stays 240 columns x 320 rows. So for a landscape panel (panel_width > panel_height) play() first sends MADCTL madctl (default MADCTL_LANDSCAPE, 0x60, as the RV565 benchmarks do), which makes the controller’s columns run along the long side; the address window is then in landscape coordinates. When play ends, MADCTL is set back to restore_madctl so displayio can draw again. A portrait panel sends no MADCTL unless madctl is given.

Parameters:
  • clip – a Clip

  • bus – the fourwire.FourWire (CircuitPython 9+) or displayio.FourWire bus the display was created on

  • panel_width (int) – width of the panel as the clip should appear

  • panel_height (int) – height of the panel as the clip should appear

  • x_offset (int) – controller column offset for this module and MADCTL (0 for 320x240 panels in the benchmarks; 40 or 52/53 for 240x135 ones, depending on orientation)

  • y_offset (int) – controller row offset, likewise

  • display – the display object, so it can be quieted while playing

  • madctl – MADCTL byte to send before playing; None picks MADCTL_LANDSCAPE for a landscape panel and nothing for a portrait one. Use 0xA0 to turn a landscape picture upside down.

  • restore_madctl – MADCTL byte to send after playing, or None to leave it; default MADCTL_ADAFRUIT_ST7789

  • restore_colmod – COLMOD byte to send after playing an rgb666 clip; default COLMOD_RGB565, adafruit_st7789’s value

begin()

Take the panel from displayio and set MADCTL (and COLMOD for rgb666) for streaming.

end()

Put MADCTL and COLMOD back (where begin() changed them).

play(loops=1, start=0, on_frame=None)

Play at the clip’s rate, skipping late frames.

Sends MADCTL before the first frame and restores it afterwards, also when playing stops with an exception.

show(i)

Decode frame i and send it to the panel.

Sends only the rows that differ from the picture on the panel (Clip.dirty_lines()): the whole frame after a keyframe or delta, and after a partial delta one row window and write per run of changed bands; last_rows says how many rows went out.

Called on its own, outside play(), it does not send MADCTL; call begin() first.

class circuitpython_rv565.PlayStats(fps)

What Player.play() achieved.

Variables:
  • shown – frames shown on time (repeats included)

  • skipped – frames dropped to keep up with the clip’s rate

  • repeated – frames shown at no cost because the screen already held their picture (repeat frames)

  • rows_updated – pixel rows written to the bitmap or sent to the panel; partial deltas update only their changed rows

  • elapsed – wall time in seconds

  • decode_time – seconds spent inflating

  • draw_time – seconds spent filling the bitmap and refreshing

property fps

Frames actually shown per second.

class circuitpython_rv565.Player(clip, display=None, scale=1, x=None, y=None, clock=None, sleep=None)

Show an RV565 clip through displayio.

Shows the pixel formats in PLAYER_FORMATS; other formats raise RV565Error. Each frame is copied into a displayio.Bitmap with one bitmaptools.readinto() call, with no per-pixel work in Python:

  • rgb565be: 65536-value bitmap through a ColorConverter whose input colorspace is RGB565_SWAPPED. The file’s big-endian bytes, read into the bitmap’s little-endian 16-bit storage, are byte-swapped values, which is what RGB565_SWAPPED describes.

  • rgb565le: the same bitmap through Colorspace.RGB565.

  • gray8, gray4, mono: a 256-, 16- or 2-value bitmap through a grey displayio.Palette (mono: 0 black, 1 white).

Repeat frames are not decoded or drawn during play().

Parameters:
  • clip – a Clip

  • display – a display object (board.DISPLAY, busdisplay.BusDisplay or adafruit_st7789.ST7789), or None to only build group

  • scale (int) – integer zoom for the group (1 = native size)

  • x – left edge on the display; centred when omitted

  • y – top edge on the display; centred when omitted

  • clock – function returning seconds (for tests)

  • sleep – function sleeping for seconds (for tests)

blit(frame, runs=None)

Copy one decoded frame (bytes in the clip’s format) into bitmap.

Parameters:

runs – row runs (first, count) to copy; all rows if None. Only those rows of the bitmap change, so displayio refreshes only that area.

play(loops=1, start=0, on_frame=None)

Play at the clip’s rate, skipping late frames; see PlayStats.

auto_refresh is turned off while playing so each frame is pushed once, and restored afterwards.

show(i)

Decode frame i, draw it, and refresh the display.

Only the rows that differ from the picture already in the bitmap are copied (Clip.dirty_lines()): after a partial delta, its changed bands; last_rows says how many.

Returns:

(decode_seconds, draw_seconds)

exception circuitpython_rv565.RV565Error

The data is not a valid RV565 file, or a frame does not decode.

circuitpython_rv565.frame_lines(pixel_format, width, height)

(P, R): a frame is P lines of R bytes, pixel rows for formats 0-7 and pages of 8 rows for mono_vpage. Partial deltas divide the frame into bands of lines.

circuitpython_rv565.frame_size(pixel_format, width, height)

Decoded size in bytes of one frame.

Parameters:

pixel_format – a name from PIXEL_FORMATS or its index

circuitpython_rv565.open(source, decompress=None)

Open an RV565 clip.

Parameters:
  • source – a path such as "/clip.rv565" or "/sd/clip.rv565", a file object opened in binary mode, or bytes/bytearray

  • decompress – optional replacement for zlib.decompress

Returns:

a Clip

A path is opened and read frame by frame, so the clip does not have to fit in RAM; Clip.close() (or a with block) closes it again.

circuitpython_rv565.parse_header(data, size=None)

Parse and range-check a version 6 or 7 header.

Parameters:
  • data – the first 16 bytes of the file (12 are enough for version 6)

  • size – the file’s length, to check the header size against

Returns:

(width, height, fps, flags, frames, version, header_size, pixel_format, codec, keyframe_interval), the last three as indexes into PIXEL_FORMATS, CODECS and keyframe_interval 1 for version 6