From 868602f7659c33aca1570cbae48baa9afc3112e5 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 17:04:23 -0700 Subject: [PATCH 1/6] videocore: allow 1 bit per pixel sources Packed pixels with each row padded to a whole byte, so a 1-bit source can stream into a plain buffer. Framebuffer targets stay whole bytes per pixel. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videocore/VideoBuffer.c | 26 +++++++++++++++---------- shared-module/videocore/VideoBuffer.c | 15 ++++++++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index 7c9ab908b3e..da37a0c16b7 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -29,9 +29,13 @@ //| unsupported position directly raises an error, but moving a containing //| Group may give garbled output instead. //| -//| When the target is a plain buffer, frames are written compactly (row -//| stride equals ``width * bits_per_pixel / 8``) and can be read from -//| Python at any time, for example to process or save camera pixels. +//| When the target is a plain buffer, frames are written with no gap +//| between rows, so each row takes ``(width * bits_per_pixel + 7) // 8`` +//| bytes. Python code can read the buffer at any time, for example to +//| process or save camera pixels. 1-bit pixels are packed together, and +//| each row is padded out to a whole byte. +//| A Group shows a plain buffer target only when its pixels are 16 bits +//| and the display uses 16-bit color. //| //| See the `videocore` module overview for complete examples. //| """ @@ -67,15 +71,17 @@ static mp_obj_t videocore_videobuffer_make_new(const mp_obj_type_t *type, size_t //| """Begin streaming the given video source into the target. //| //| The source streams continuously, writing each new frame where the -//| VideoBuffer tells it to, replacing the previous one. The source's -//| frame format must be byte aligned. The source's size and format are -//| then fixed for the duration of the ``play()``; sources that change -//| them require ``stop()`` and a new ``play()``. +//| VideoBuffer tells it to, replacing the previous one. The source can +//| use 1 bit per pixel, or a whole number of bytes per pixel. +//| The source's size and format are then fixed for the duration of the +//| ``play()``; sources that change them require ``stop()`` and a new +//| ``play()``. //| //| When the target is a framebuffer, its bits per pixel must match the -//| framebuffer color depth and the whole frame must fit within the -//| framebuffer at the current ``x``/``y`` position. An exception may be -//| raised if the video can't be played directly into the framebuffer. +//| source and be a whole number of bytes, and the whole frame must fit +//| within the framebuffer at the current ``x``/``y`` position. An +//| exception may be raised if the video can't be played directly into +//| the framebuffer. //| //| When the target is a plain buffer, it must be large enough for a //| whole frame. diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 72b669269db..76d4379f729 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -141,9 +141,15 @@ static void videocore_videobuffer_update_area(videocore_videobuffer_obj_t *self) self->current_area.next = NULL; } -// Bytes in one row of a source frame. +// Bytes in one row of a source frame. 1-bit pixels are packed and each row is +// padded to a whole byte. Framebuffer targets always use whole bytes (see +// read_source_format). +static size_t videocore_videobuffer_row_bytes(mp_int_t width, mp_int_t bits_per_pixel) { + return ((size_t)width * bits_per_pixel + 7) / 8; +} + static size_t videocore_videobuffer_source_row_bytes(videocore_videobuffer_obj_t *self) { - return (size_t)self->source_width * self->source_bits_per_pixel / 8; + return videocore_videobuffer_row_bytes(self->source_width, self->source_bits_per_pixel); } // Bytes a frame covers in a framebuffer with this row stride, from its first @@ -321,12 +327,13 @@ static void videocore_videobuffer_read_source_format(videocore_videobuffer_obj_t const videosource_p_t *protocol = videosource_check(source); mp_int_t bits_per_pixel = protocol->get_bits_per_pixel(source); - if (bits_per_pixel % 8 != 0) { + // Whole bytes per pixel, or 1 bit packed into each byte. + if (bits_per_pixel % 8 != 0 && bits_per_pixel != 1) { mp_raise_ValueError(MP_ERROR_TEXT("video format must be byte aligned")); } mp_int_t width = protocol->get_width(source); mp_int_t height = protocol->get_height(source); - size_t frame_bytes = (size_t)((width * height * bits_per_pixel) / 8); + size_t frame_bytes = (size_t)height * videocore_videobuffer_row_bytes(width, bits_per_pixel); if (self->framebuffer_protocol != NULL) { mp_int_t color_depth = FB_GETTER(get_color_depth, 16); From 2faca5c49440420282eb01a70464b457e5bd70c6 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 17:04:24 -0700 Subject: [PATCH 2/6] videogif: decode GIFs to 1 bit per pixel A bits_per_pixel argument picks RGB565 or packed 1-bit frames, for e-paper. A pixel is white when its luma is at least half, so a black and white GIF converts exactly. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videogif/GifDecoder.c | 25 +++++++++--- shared-bindings/videogif/GifDecoder.h | 7 ++-- shared-module/videogif/GifDecoder.c | 58 +++++++++++++++++++++++---- shared-module/videogif/GifDecoder.h | 5 +++ 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index 6a97e5724b4..a5bd589542b 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -72,35 +72,48 @@ //| self, //| file: Union[str, typing.BinaryIO], //| *, +//| bits_per_pixel: int = 16, //| reverse_bytes_in_word: bool = False, //| ) -> None: //| """Open a GIF file so it can be played. //| //| :param file: The name of a GIF file, such as ``"/animation.gif"``, //| or a GIF file opened in binary mode. +//| :param int bits_per_pixel: The pixel format of each frame. Use 16 +//| for color pixels (RGB565), or 1 for black and white pixels. +//| With 1, eight pixels are packed into each byte, with the +//| leftmost pixel in the most significant bit. A pixel is white +//| (1) when its brightness is at least 128 out of 255, so a GIF +//| that uses only black and white converts exactly. //| :param bool reverse_bytes_in_word: Swap the two bytes of each 16-bit //| pixel. Use the same value as your display, such as -//| `busdisplay.BusDisplay`'s ``reverse_bytes_in_word``. +//| `busdisplay.BusDisplay`'s ``reverse_bytes_in_word``. It has no +//| effect when ``bits_per_pixel`` is 1. //| -//| Frames are RGB565. Raises `ValueError` if the file is not a GIF or if -//| the GIF is wider than 320 pixels. +//| Raises `ValueError` if ``bits_per_pixel`` is not 1 or 16, if the +//| file is not a GIF, or if the GIF is wider than 320 pixels. //| """ //| ... //| static mp_obj_t videogif_gifdecoder_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { - enum { ARG_file, ARG_reverse_bytes_in_word, NUM_ARGS }; + enum { ARG_file, ARG_bits_per_pixel, ARG_reverse_bytes_in_word, NUM_ARGS }; static const mp_arg_t allowed_args[] = { { MP_QSTR_file, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_bits_per_pixel, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 16} }, { MP_QSTR_reverse_bytes_in_word, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, }; MP_STATIC_ASSERT(MP_ARRAY_SIZE(allowed_args) == NUM_ARGS); mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + const mp_int_t bits_per_pixel = args[ARG_bits_per_pixel].u_int; + if (bits_per_pixel != 1 && bits_per_pixel != 16) { + mp_arg_error_invalid(MP_QSTR_bits_per_pixel); + } mp_obj_t file = gifio_open_file(args[ARG_file].u_obj); videogif_gifdecoder_obj_t *self = mp_obj_malloc_with_finaliser(videogif_gifdecoder_obj_t, &videogif_gifdecoder_type); - common_hal_videogif_gifdecoder_construct(self, file, args[ARG_reverse_bytes_in_word].u_bool); + common_hal_videogif_gifdecoder_construct(self, file, bits_per_pixel, args[ARG_reverse_bytes_in_word].u_bool); return MP_OBJ_FROM_PTR(self); } @@ -206,7 +219,7 @@ static mp_int_t videogif_gifdecoder_videosource_get_height(mp_obj_t self_in) { } static mp_int_t videogif_gifdecoder_get_bits_per_pixel(mp_obj_t self_in) { - return 16; + return common_hal_videogif_gifdecoder_get_bits_per_pixel(native_gifdecoder(self_in)); } static const videosource_p_t videogif_gifdecoder_videosource_protocol = { diff --git a/shared-bindings/videogif/GifDecoder.h b/shared-bindings/videogif/GifDecoder.h index a5421c35726..90d5d605465 100644 --- a/shared-bindings/videogif/GifDecoder.h +++ b/shared-bindings/videogif/GifDecoder.h @@ -10,16 +10,17 @@ extern const mp_obj_type_t videogif_gifdecoder_type; -// Open the GIF in file (a file opened in binary mode). Frames are RGB565. -// Raises when the file isn't a GIF or is wider than AnimatedGIF supports. +// Open the GIF in file (a file opened in binary mode). bits_per_pixel is 1 or +// 16. Raises when the file isn't a GIF or is wider than AnimatedGIF supports. void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file, - bool reverse_bytes_in_word); + mp_int_t bits_per_pixel, bool reverse_bytes_in_word); // Stop playing, free the decoder and drop the file. Safe to call more than // once. void common_hal_videogif_gifdecoder_deinit(videogif_gifdecoder_obj_t *self); bool common_hal_videogif_gifdecoder_deinited(videogif_gifdecoder_obj_t *self); mp_int_t common_hal_videogif_gifdecoder_get_width(videogif_gifdecoder_obj_t *self); mp_int_t common_hal_videogif_gifdecoder_get_height(videogif_gifdecoder_obj_t *self); +mp_int_t common_hal_videogif_gifdecoder_get_bits_per_pixel(videogif_gifdecoder_obj_t *self); // Videosource protocol steps, driven by videocore.VideoBuffer. Frames are // decoded by the supervisor background tick, never in an interrupt. play diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index 57c52bddb4f..5ec39abeec5 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -10,6 +10,7 @@ #include "py/mpstate.h" #include "py/runtime.h" +#include "shared-module/displayio/ColorConverter.h" #include "shared-module/gifio/OnDiskGif.h" #include "supervisor/port_heap.h" #include "supervisor/shared/tick.h" @@ -20,8 +21,23 @@ // decoding falls behind the GIF's delays. #define VIDEOGIF_MIN_GAP_MS 5 -static inline void videogif_put_pixel(uint8_t *row, int x, uint16_t value) { - memcpy(row + x * 2, &value, 2); +// 1 bit per pixel: an RGB888 palette entry is white when its luma is at least +// half, so any black and white palette maps exactly. +static bool videogif_is_white(const uint8_t *rgb) { + return displayio_colorconverter_compute_luma((rgb[0] << 16) | (rgb[1] << 8) | rgb[2]) >= 128; +} + +static inline void videogif_put_pixel(videogif_gifdecoder_obj_t *self, uint8_t *row, int x, uint16_t value) { + if (self->bits_per_pixel == 1) { + const uint8_t mask = 0x80 >> (x % 8); + if (value) { + row[x / 8] |= mask; + } else { + row[x / 8] &= ~mask; + } + } else { + memcpy(row + x * 2, &value, 2); + } } static displayio_area_t videogif_canvas(videogif_gifdecoder_obj_t *self) { @@ -36,11 +52,16 @@ static displayio_area_t videogif_canvas(videogif_gifdecoder_obj_t *self) { // screen descriptor names. static void videogif_fill(videogif_gifdecoder_obj_t *self, const displayio_area_t *area) { const uint8_t index = self->gif->ucBackground; - const uint16_t value = self->gif->pPalette[index]; + uint16_t value; + if (self->bits_per_pixel == 1) { + value = videogif_is_white((const uint8_t *)self->gif->pPalette + index * 3); + } else { + value = self->gif->pPalette[index]; + } for (int y = area->y1; y < area->y2; y++) { uint8_t *row = self->destination.base + y * self->destination.stride; for (int x = area->x1; x < area->x2; x++) { - videogif_put_pixel(row, x, value); + videogif_put_pixel(self, row, x, value); } } } @@ -66,6 +87,14 @@ static void videogif_draw_line(GIFDRAW *draw) { if (self->new_frame) { self->new_frame = false; videogif_start_frame(self); + if (self->bits_per_pixel == 1) { + memset(self->white, 0, sizeof(self->white)); + for (int i = 0; i < 256; i++) { + if (videogif_is_white(draw->pPalette24 + i * 3)) { + self->white[i / 8] |= 1 << (i % 8); + } + } + } } const int y = draw->iY + draw->y; @@ -83,13 +112,23 @@ static void videogif_draw_line(GIFDRAW *draw) { if (draw->ucHasTransparency && index == draw->ucTransparent) { continue; } - videogif_put_pixel(row, draw->iX + i, draw->pPalette[index]); + uint16_t value; + if (self->bits_per_pixel == 1) { + value = (self->white[index / 8] >> (index % 8)) & 1; + } else { + value = draw->pPalette[index]; + } + videogif_put_pixel(self, row, draw->iX + i, value); } } void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file, - bool reverse_bytes_in_word) { - const uint8_t palette_type = reverse_bytes_in_word ? GIF_PALETTE_RGB565_BE : GIF_PALETTE_RGB565_LE; + mp_int_t bits_per_pixel, bool reverse_bytes_in_word) { + // 1 bit per pixel needs the RGB888 palette to compute luma. + uint8_t palette_type = GIF_PALETTE_RGB888; + if (bits_per_pixel == 16) { + palette_type = reverse_bytes_in_word ? GIF_PALETTE_RGB565_BE : GIF_PALETTE_RGB565_LE; + } // Outside the VM heap so it doesn't need a large gap there or grow it. Its // only heap pointers, the file and self, are held by self too. The // finaliser frees it. @@ -100,6 +139,7 @@ void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, m GIF_begin(self->gif, palette_type); self->gif->pfnDraw = videogif_draw_line; self->file = file; + self->bits_per_pixel = bits_per_pixel; gifio_open_gif(self->gif, file); } @@ -134,6 +174,10 @@ mp_int_t common_hal_videogif_gifdecoder_get_height(videogif_gifdecoder_obj_t *se return self->gif != NULL ? self->gif->iCanvasHeight : 0; } +mp_int_t common_hal_videogif_gifdecoder_get_bits_per_pixel(videogif_gifdecoder_obj_t *self) { + return self->bits_per_pixel; +} + // Keep the supervisor tick on only while a frame can come due. static void videogif_update_tick(videogif_gifdecoder_obj_t *self) { const bool ticking = self->playing && !self->paused && !self->finished; diff --git a/shared-module/videogif/GifDecoder.h b/shared-module/videogif/GifDecoder.h index 3efa7919fd4..c63d0e6b20a 100644 --- a/shared-module/videogif/GifDecoder.h +++ b/shared-module/videogif/GifDecoder.h @@ -37,6 +37,11 @@ typedef struct videogif_gifdecoder_obj { int32_t repeat_count; int32_t loops_done; uint8_t disposal; + // 1 or 16. + uint8_t bits_per_pixel; + // For 1 bit per pixel: which palette entries are white, one bit each, + // for the palette of the frame being decoded. + uint8_t white[256 / 8]; bool playing; bool paused; // Played its last loop, or stopped on an error: no more frames are due. From d922e31354b83ece65e0ad1b793f4c34f7a490b4 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 17:04:24 -0700 Subject: [PATCH 3/6] gifio: let a board raise the GIF width limit AnimatedGIF refuses anything wider than 320 pixels, so 400x300 and 800x480 e-paper animations would not open. CIRCUITPY_GIFIO_MAX_WIDTH stays 320 by default, so other boards don't pay for a longer line buffer. Co-Authored-By: Claude Opus 5.5 --- lib/AnimatedGIF/AnimatedGIF_circuitpy.h | 7 ++++++- py/circuitpy_mpconfig.mk | 4 ++++ shared-bindings/gifio/OnDiskGif.c | 2 +- shared-bindings/videogif/GifDecoder.c | 3 ++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/AnimatedGIF/AnimatedGIF_circuitpy.h b/lib/AnimatedGIF/AnimatedGIF_circuitpy.h index c1f359194f5..a09aabbc1ab 100644 --- a/lib/AnimatedGIF/AnimatedGIF_circuitpy.h +++ b/lib/AnimatedGIF/AnimatedGIF_circuitpy.h @@ -33,7 +33,12 @@ #define MAX_CHUNK_SIZE 255 #define LZW_BUF_SIZE (6 * MAX_CHUNK_SIZE) #define LZW_HIGHWATER (4 * MAX_CHUNK_SIZE) -#define MAX_WIDTH 320 +// Make ports set the width from mpconfigboard.mk. gif.c includes only this header, and the +// zephyr-cp build passes no width, so it needs this default. +#ifndef CIRCUITPY_GIFIO_MAX_WIDTH +#define CIRCUITPY_GIFIO_MAX_WIDTH 320 +#endif +#define MAX_WIDTH CIRCUITPY_GIFIO_MAX_WIDTH #define FILE_BUF_SIZE 4096 #define PIXEL_FIRST 0 diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 516a0f89b90..de5fab18d0a 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -393,6 +393,10 @@ CFLAGS += -DCIRCUITPY_GETPASS=$(CIRCUITPY_GETPASS) CIRCUITPY_GIFIO ?= $(CIRCUITPY_DISPLAYIO) CFLAGS += -DCIRCUITPY_GIFIO=$(CIRCUITPY_GIFIO) +# Widest GIF that can be decoded. Each pixel costs a byte per open GIF. +CIRCUITPY_GIFIO_MAX_WIDTH ?= 320 +CFLAGS += -DCIRCUITPY_GIFIO_MAX_WIDTH=$(CIRCUITPY_GIFIO_MAX_WIDTH) + CIRCUITPY_GNSS ?= 0 CFLAGS += -DCIRCUITPY_GNSS=$(CIRCUITPY_GNSS) diff --git a/shared-bindings/gifio/OnDiskGif.c b/shared-bindings/gifio/OnDiskGif.c index 3c25c7ffab4..d94d7c184d6 100644 --- a/shared-bindings/gifio/OnDiskGif.c +++ b/shared-bindings/gifio/OnDiskGif.c @@ -108,7 +108,7 @@ mp_obj_t gifio_open_file(mp_obj_t file) { //| //| If the image is too large it will be cropped at the bottom and right when displayed. //| -//| **Limitations**: The image width is limited to 320 pixels at present. `ValueError` +//| **Limitations**: The image width is limited to 320 pixels on most boards. `ValueError` //| will be raised if the image is too wide. The height //| is not limited but images that are too large will cause a memory exception. //| """ diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index a5bd589542b..3b9c45c5a6f 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -91,7 +91,8 @@ //| effect when ``bits_per_pixel`` is 1. //| //| Raises `ValueError` if ``bits_per_pixel`` is not 1 or 16, if the -//| file is not a GIF, or if the GIF is wider than 320 pixels. +//| file is not a GIF, or if the GIF is wider than the board supports +//| (320 pixels on most boards). //| """ //| ... //| From 34870bdd77630a8562f0c696d572518b11794366 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 13:05:48 -0700 Subject: [PATCH 4/6] videocore: show 1 bit plain buffers on a monochrome display Copy whole bytes when the rects line up and no layer above claimed the pixels. Polarity and bit order already match a monochrome EPaperDisplay: MSB first, 1 = white. Falls back to a per-pixel copy that honors the mask. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videocore/VideoBuffer.c | 4 +- shared-module/videocore/VideoBuffer.c | 89 ++++++++++++++++++++----- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index da37a0c16b7..819ae8eb291 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -35,7 +35,9 @@ //| process or save camera pixels. 1-bit pixels are packed together, and //| each row is padded out to a whole byte. //| A Group shows a plain buffer target only when its pixels are 16 bits -//| and the display uses 16-bit color. +//| and the display uses 16-bit color, or its pixels are 1 bit and the +//| display is monochrome with the leftmost pixel in the top bit of each +//| byte, such as `epaperdisplay.EPaperDisplay`. A 1 bit means white. //| //| See the `videocore` module overview for complete examples. //| """ diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 76d4379f729..c2c4b078ea7 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -592,6 +592,75 @@ static mp_obj_t videocore_videobuffer_draw_protocol_self(mp_obj_t protocol_conta return protocol_container; } +// Whether a layer above already claimed a pixel near the overlap. Checks whole words, which may +// include pixels between overlap rows or just outside the overlap. That can only make the caller +// take its slower path; it never misses a claimed pixel. +static bool videocore_videobuffer_overlap_claimed(const uint32_t *mask, const displayio_area_t *area, + const displayio_area_t *overlap) { + const size_t span_px = displayio_area_width(area); + const size_t first_px = (size_t)(overlap->y1 - area->y1) * span_px + (overlap->x1 - area->x1); + const size_t end_px = (size_t)(overlap->y2 - 1 - area->y1) * span_px + (overlap->x2 - area->x1); + for (size_t word = first_px / 32; word <= (end_px - 1) / 32; word++) { + if (mask[word] != 0) { + return true; + } + } + return false; +} + +// Copy a 1 bit per pixel plain buffer into a 1 bit display's buffer. Both are MSB first with +// 1 = white, the same as a monochrome EPaperDisplay's first RAM pass before any inversion. +// Whole bytes are copied when the rects line up and nothing above claimed these pixels. +static bool videocore_videobuffer_fill_area_1bit(videocore_videobuffer_obj_t *self, + const _displayio_colorspace_t *colorspace, const displayio_area_t *area, const displayio_area_t *overlap, + bool full_coverage, uint32_t *mask, uint32_t *buffer) { + // Every grayscale bit of a 1 bit pixel is that pixel, so this also serves each grayscale pass. + if (!colorspace->pixels_in_byte_share_row || !colorspace->reverse_pixels_in_byte || + !colorspace->grayscale) { + return false; + } + const size_t span_px = displayio_area_width(area); + const size_t width = displayio_area_width(overlap); + const size_t row_bytes = videocore_videobuffer_source_row_bytes(self); + const size_t x_in_frame = overlap->x1 - self->current_area.x1; + const size_t x_in_area = overlap->x1 - area->x1; + const uint8_t *frame = self->stream_base; + uint8_t *out = (uint8_t *)buffer; + + const bool whole_bytes = span_px % 8 == 0 && x_in_area % 8 == 0 && x_in_frame % 8 == 0 && width % 8 == 0 && + !videocore_videobuffer_overlap_claimed(mask, area, overlap); + for (int16_t y = overlap->y1; y < overlap->y2; y++) { + const size_t px = (size_t)(y - area->y1) * span_px + x_in_area; + const uint8_t *row = frame + (size_t)(y - self->current_area.y1) * row_bytes; + if (whole_bytes) { + memcpy(out + px / 8, row + x_in_frame / 8, width / 8); + // Full coverage ends the layer walk, so filling the mask once below is enough. + if (!full_coverage) { + for (size_t p = px; p < px + width; p++) { + mask[p / 32] |= 1u << (p % 32); + } + } + continue; + } + for (size_t i = 0; i < width; i++) { + const size_t p = px + i; + if (mask[p / 32] & (1u << (p % 32))) { + continue; + } + const size_t f = x_in_frame + i; + if (row[f / 8] & (0x80 >> (f % 8))) { + out[p / 8] |= 0x80 >> (p % 8); + } + mask[p / 32] |= 1u << (p % 32); + } + } + if (whole_bytes && full_coverage) { + // A FramebufferDisplay writes nothing back when the mask is empty. + memset(mask, 0xff, (displayio_area_size(area) + 31) / 32 * sizeof(uint32_t)); + } + return full_coverage; +} + static bool videocore_videobuffer_draw_fill_area(mp_obj_t self_in, const _displayio_colorspace_t *colorspace, const displayio_area_t *area, uint32_t *mask, uint32_t *buffer) { videocore_videobuffer_obj_t *self = MP_OBJ_TO_PTR(self_in); // buffer isn't necessarily the framebuffer. It may be a scratch buffer. @@ -619,21 +688,7 @@ static bool videocore_videobuffer_draw_fill_area(mp_obj_t self_in, const _displa const uint8_t *frame = (const uint8_t *)self->stream_base; uint8_t *out = (uint8_t *)buffer; - // Check whole words. This may include pixels between overlap rows or - // just outside the overlap, which can only make us take the slower - // per-pixel path below; it never misses a claimed pixel. - const size_t first_px = (size_t)(overlap.y1 - area->y1) * span_px + - (overlap.x1 - area->x1); - const size_t end_px = (size_t)(overlap.y2 - 1 - area->y1) * span_px + - (overlap.x2 - area->x1); - bool masked = false; - for (size_t word = first_px / 32; word <= (end_px - 1) / 32; word++) { - if (mask[word] != 0) { - masked = true; - break; - } - } - if (full_coverage && !masked) { + if (full_coverage && !videocore_videobuffer_overlap_claimed(mask, area, &overlap)) { // Sole coverer: nothing was composed, so the driver skips the copy. return true; } @@ -665,6 +720,10 @@ static bool videocore_videobuffer_draw_fill_area(mp_obj_t self_in, const _displa return full_coverage; } + if (colorspace->depth == 1 && self->source_bits_per_pixel == 1) { + return videocore_videobuffer_fill_area_1bit(self, colorspace, area, &overlap, full_coverage, mask, buffer); + } + // Raw buffer target: bulk copy, no color conversion: the display depth // must match the video's pixel format byte for byte. if (colorspace->depth != self->source_bits_per_pixel || colorspace->depth != 16) { From 7a50f9e7f9702979b35059eaaf2ffeee816e77eb Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 14:41:24 -0700 Subject: [PATCH 5/6] videocore: document that a rotated display raises ValueError Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videocore/VideoBuffer.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index 819ae8eb291..6c8439c7b0d 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -29,6 +29,9 @@ //| unsupported position directly raises an error, but moving a containing //| Group may give garbled output instead. //| +//| A VideoBuffer can only be translated. Showing it on a rotated display, or +//| in a scaled Group, raises `ValueError`. +//| //| When the target is a plain buffer, frames are written with no gap //| between rows, so each row takes ``(width * bits_per_pixel + 7) // 8`` //| bytes. Python code can read the buffer at any time, for example to @@ -38,6 +41,9 @@ //| and the display uses 16-bit color, or its pixels are 1 bit and the //| display is monochrome with the leftmost pixel in the top bit of each //| byte, such as `epaperdisplay.EPaperDisplay`. A 1 bit means white. +//| On a tricolor display with separate black and color RAM, a 1 bit video +//| only draws black and white, and a highlight colored layer below it shows +//| through. Other tricolor displays don't draw it. //| //| See the `videocore` module overview for complete examples. //| """ From d88f2afc3d3bfe09ec36df0dca287ddbf8539ef2 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 14:41:24 -0700 Subject: [PATCH 6/6] adafruit_feather_rp2040_thinkink: enable videocore This is the board the 1 bit GIF numbers come from. Co-Authored-By: Claude Opus 5.5 --- .../boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk | 2 ++ py/circuitpy_mpconfig.mk | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk index 1caf2f862ee..64d4852ebf6 100644 --- a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk +++ b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk @@ -9,3 +9,5 @@ CHIP_FAMILY = rp2 EXTERNAL_FLASH_DEVICES = "GD25Q64C,W25Q64JVxQ" CIRCUITPY_EPAPERDISPLAY_LAG = 1 +CIRCUITPY_GIFIO_MAX_WIDTH = 800 +CIRCUITPY_VIDEOCORE = 1 diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index de5fab18d0a..64d0b6f3b2c 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -859,9 +859,8 @@ CFLAGS += -DCIRCUITPY_ULAB=$(CIRCUITPY_ULAB) # element-wise array arithmetic roughly 1.5x slower. CIRCUITPY_ULAB_OPTIMIZE_SIZE ?= 0 -# Video pipeline: playing video sources into framebuffers. Requires a port -# with a video source such as mipicsi and a framebuffer output such as -# mipidsi. +# Video pipeline: playing video sources into framebuffers or plain buffers. +# A source can be a port's camera, such as mipicsi, or a GIF file with videogif. CIRCUITPY_VIDEOCORE ?= 0 CFLAGS += -DCIRCUITPY_VIDEOCORE=$(CIRCUITPY_VIDEOCORE)