From 6e915426f76106ec24feb548618d17c522988fe9 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 08:08:28 -0700 Subject: [PATCH 01/14] videocore: size the rendered rect when play starts The rect was only computed on a move or transform, using the source's size. A VideoBuffer added to a Group before play() kept an empty rect. A plain buffer target drew nothing until it moved. A framebuffer target still got its frames, but compositing and overlay repaint were wrong. This also clears a stale rect when a source of another size is played again. Co-Authored-By: Claude Opus 5.5 --- shared-module/videocore/VideoBuffer.c | 1 + 1 file changed, 1 insertion(+) diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index e86d1a51e8e..713d8783b59 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -382,6 +382,7 @@ void common_hal_videocore_videobuffer_play(videocore_videobuffer_obj_t *self, mp // in between. Plain buffer targets always stream. const bool start_paused = self->framebuffer_protocol != NULL && self->paused; videocore_videobuffer_start_source(self, start_paused); + videocore_videobuffer_update_area(self); self->playing = true; if (start_paused) { // No destination is set until the VideoBuffer is shown, so the source From 30acdaf58e517a9a5f3af19157dd4576e6aa30f8 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 13:19:47 -0700 Subject: [PATCH 02/14] videocore: draw a moved plain buffer at its new position A plain buffer move only repainted the area it left. The new location stayed stale until the next frame repainted it. Mark it dirty as well. Co-Authored-By: Claude Opus 5.5 --- shared-module/videocore/VideoBuffer.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 713d8783b59..d0327e6899f 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -406,6 +406,10 @@ void common_hal_videocore_videobuffer_set_position(videocore_videobuffer_obj_t * self->x = x; self->y = y; videocore_videobuffer_update_area(self); + if (self->framebuffer_protocol == NULL) { + // Draw the last frame at its new position. + videocore_videobuffer_dirty_current_area(self); + } if (!self->playing || self->paused || self->framebuffer_protocol == NULL) { // Raw buffer targets are rendered by displayio; their capture // destination doesn't move with the position. A paused framebuffer @@ -634,6 +638,10 @@ static void videocore_videobuffer_draw_update_transform(mp_obj_t self_in, displa !displayio_area_equal(&previous_area, &self->current_area)) { displayio_area_union( &self->ephemeral_dirty_area, &previous_area, &self->ephemeral_dirty_area); + if (self->framebuffer_protocol == NULL) { + // Draw the last frame at its new position. + videocore_videobuffer_dirty_current_area(self); + } } // Framebuffer targets stream straight into the buffer: move the From 82b66a2ba3ee0b351e630394f959dcc8a2c3ff13 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 08:02:36 -0700 Subject: [PATCH 03/14] videocore: let a source report which part of a frame changed on_frame gets a changed rect in source coordinates, and the refresh only repaints that part. NULL means the whole frame changed and the next frame repaints all of it. A rect gives a source a protocol type with no displayio dependency. mipicsi passes NULL. get_refresh_areas takes the landed frame and vacated area in one critical section. A frame landing during a refresh stays for the next one, unless it passed NULL. Co-Authored-By: Claude Opus 5.5 --- ports/espressif/common-hal/mipicsi/Camera.c | 4 +- ports/espressif/common-hal/mipicsi/Camera.h | 7 +- shared-bindings/mipicsi/Camera.h | 2 +- shared-module/videocore/VideoBuffer.c | 76 ++++++++++++++++----- shared-module/videocore/VideoBuffer.h | 10 ++- shared-module/videocore/__init__.h | 24 ++++--- 6 files changed, 91 insertions(+), 32 deletions(-) diff --git a/ports/espressif/common-hal/mipicsi/Camera.c b/ports/espressif/common-hal/mipicsi/Camera.c index 8fbd9a3df49..cc6a0c79e29 100644 --- a/ports/espressif/common-hal/mipicsi/Camera.c +++ b/ports/espressif/common-hal/mipicsi/Camera.c @@ -113,7 +113,7 @@ static IRAM_ATTR bool mipicsi_on_trans_finished( // Report where the frame actually landed: after a destination change // (or a pause) the frame that was in flight lands at the previous one. if (self->dest_base != NULL && self->dest_on_frame != NULL) { - self->dest_on_frame(self->dest_ctx, trans->buffer); + self->dest_on_frame(self->dest_ctx, trans->buffer, NULL); } return false; } @@ -406,7 +406,7 @@ bool common_hal_mipicsi_camera_set_destination( mipicsi_camera_obj_t *self, uint8_t *base, size_t stride, - void (*on_frame)(void *ctx, const void *frame), + videocore_on_frame_fun on_frame, void *ctx) { const size_t row_bytes = ((size_t)self->width * mipicsi_bits_per_pixel_for(self->output_format) + 7) / 8; const size_t frame_bytes = (size_t)self->height * row_bytes; diff --git a/ports/espressif/common-hal/mipicsi/Camera.h b/ports/espressif/common-hal/mipicsi/Camera.h index 18147c64b9c..b709fee30c3 100644 --- a/ports/espressif/common-hal/mipicsi/Camera.h +++ b/ports/espressif/common-hal/mipicsi/Camera.h @@ -21,6 +21,7 @@ #endif #include "shared-bindings/mipicsi/__init__.h" +#include "shared-module/videocore/__init__.h" typedef struct mipicsi_camera_obj mipicsi_camera_obj_t; @@ -56,10 +57,10 @@ struct mipicsi_camera_obj { // Streaming destination, set by videocore.VideoBuffer. The CSI controller // only runs while dest_base is set. Frames are DMA'd contiguously into // dest_base, which must be cache-line aligned. dest_on_frame(dest_ctx, - // frame) is called from the ISR with where each frame landed; a frame in - // flight when the destination changes lands at the previous one. + // frame, NULL) is called from the ISR with where each frame landed; a + // frame in flight when the destination changes lands at the previous one. volatile void *dest_base; - void (*dest_on_frame)(void *ctx, const void *frame); + videocore_on_frame_fun dest_on_frame; void *dest_ctx; bool ctlr_started; // True between pause and resume: only the CSI controller is stopped. diff --git a/shared-bindings/mipicsi/Camera.h b/shared-bindings/mipicsi/Camera.h index 4edc6507db0..c43f08590ea 100644 --- a/shared-bindings/mipicsi/Camera.h +++ b/shared-bindings/mipicsi/Camera.h @@ -55,7 +55,7 @@ bool common_hal_mipicsi_camera_set_destination( mipicsi_camera_obj_t *self, uint8_t *base, size_t stride, - void (*on_frame)(void *ctx, const void *frame), + videocore_on_frame_fun on_frame, void *ctx); mipicsi_pixel_format_t common_hal_mipicsi_camera_get_pixel_format(mipicsi_camera_obj_t *self); diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index d0327e6899f..c7ad18c0aa8 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -55,11 +55,33 @@ static void videocore_videobuffer_add_vacated_frame(videocore_videobuffer_obj_t displayio_area_union(&self->vacated_area, &area, &self->vacated_area); } -// Called from interrupt context when the source lands a frame, so keep it -// tiny. -static void videocore_videobuffer_on_frame(void *ctx, const void *frame) { +// Called from interrupt context or a background task when the source lands a +// frame, so keep it tiny. +static void videocore_videobuffer_on_frame(void *ctx, const void *frame, const videocore_area_t *changed) { videocore_videobuffer_obj_t *self = ctx; if (frame == self->stream_base) { + const displayio_area_t whole_frame = { + .x2 = self->source_width, + .y2 = self->source_height, + }; + if (changed == NULL) { + self->frame_changed_area = whole_frame; + self->frame_replaceable = true; + } else { + // Keep a bad source rect from dirtying other layers. + const displayio_area_t reported = { + .x1 = changed->x1, + .y1 = changed->y1, + .x2 = changed->x2, + .y2 = changed->y2, + }; + displayio_area_t clipped; + if (!displayio_area_compute_overlap(&reported, &whole_frame, &clipped)) { + return; + } + displayio_area_union(&self->frame_changed_area, &clipped, &self->frame_changed_area); + self->frame_replaceable = false; + } self->frame_ready = true; return; } @@ -69,6 +91,15 @@ static void videocore_videobuffer_on_frame(void *ctx, const void *frame) { videocore_videobuffer_add_vacated_frame(self, frame); } +// Drop any landed frame the refresh hasn't taken yet. +static void videocore_videobuffer_forget_frame(videocore_videobuffer_obj_t *self) { + displayio_area_t empty = {}; + common_hal_mcu_disable_interrupts(); + self->frame_ready = false; + self->frame_changed_area = empty; + common_hal_mcu_enable_interrupts(); +} + // Cache the framebuffer geometry for on_frame (see fb_pixels). static void videocore_videobuffer_cache_fb_geometry(videocore_videobuffer_obj_t *self, const mp_buffer_info_t *bufinfo, mp_int_t fb_width, mp_int_t row_stride, mp_int_t first_pixel_offset) { @@ -246,6 +277,9 @@ void common_hal_videocore_videobuffer_construct(videocore_videobuffer_obj_t *sel self->current_area = empty; self->ephemeral_dirty_area = empty; self->vacated_area = empty; + self->frame_changed_area = empty; + self->frame_replaceable = false; + self->frame_dirty_area = empty; displayio_buffer_transform_t empty_transform = {}; self->absolute_transform = empty_transform; // Nothing is shown until a Group attached to a display updates our @@ -318,7 +352,7 @@ static void videocore_videobuffer_pause_stream(videocore_videobuffer_obj_t *self videosource_pause(self->source); nlr_pop(); } - self->frame_ready = false; + videocore_videobuffer_forget_frame(self); } // Re-show a framebuffer target by pointing its paused source at the current @@ -343,7 +377,7 @@ static void videocore_videobuffer_resume_stream(videocore_videobuffer_obj_t *sel self->stream_base = dest.base; self->paused = false; // Wait for the first frame at the new destination. - self->frame_ready = false; + videocore_videobuffer_forget_frame(self); } // Start, resume or pause a framebuffer target's source to match whether it is @@ -387,7 +421,7 @@ void common_hal_videocore_videobuffer_play(videocore_videobuffer_obj_t *self, mp if (start_paused) { // No destination is set until the VideoBuffer is shown, so the source // has nowhere to put a frame while paused. - self->frame_ready = false; + videocore_videobuffer_forget_frame(self); self->stream_base = NULL; } else { videocore_videobuffer_start_stream(self); @@ -438,7 +472,7 @@ void common_hal_videocore_videobuffer_set_position(videocore_videobuffer_obj_t * static void videocore_videobuffer_clear(videocore_videobuffer_obj_t *self) { self->playing = false; - self->frame_ready = false; + videocore_videobuffer_forget_frame(self); self->stream_base = NULL; self->source = mp_const_none; } @@ -658,21 +692,30 @@ static void videocore_videobuffer_draw_update_transform(mp_obj_t self_in, displa static void videocore_videobuffer_draw_finish_refresh(mp_obj_t self_in) { videocore_videobuffer_obj_t *self = MP_OBJ_TO_PTR(self_in); - // The frame (or move) rendered; stop flagging it. - self->frame_ready = false; displayio_area_t empty = {}; self->ephemeral_dirty_area = empty; + // A frame that landed during the refresh waits for the next one, unless its + // successor repaints everything anyway. + if (self->frame_replaceable) { + videocore_videobuffer_forget_frame(self); + } } static displayio_area_t *videocore_videobuffer_draw_get_refresh_areas(mp_obj_t self_in, displayio_area_t *tail) { videocore_videobuffer_obj_t *self = MP_OBJ_TO_PTR(self_in); - // Frames that landed where the video used to be, possibly over the repaint - // of that area: repaint there again. + // Take what landed so far in one step: a frame landing from here on waits + // for the next refresh. + displayio_area_t empty = {}; common_hal_mcu_disable_interrupts(); displayio_area_t vacated = self->vacated_area; - displayio_area_t empty = {}; + const bool frame_ready = self->frame_ready; + self->frame_dirty_area = self->frame_changed_area; self->vacated_area = empty; + self->frame_ready = false; + self->frame_changed_area = empty; common_hal_mcu_enable_interrupts(); + // Frames that landed where the video used to be, possibly over the repaint + // of that area: repaint there again. displayio_area_union(&self->ephemeral_dirty_area, &vacated, &self->ephemeral_dirty_area); // Always repaint the area the video vacated, even once it is hidden or // stopped, so its last frame doesn't stay on the target. @@ -684,10 +727,11 @@ static displayio_area_t *videocore_videobuffer_draw_get_refresh_areas(mp_obj_t s if (!self->playing || self->paused || self->hidden || self->stale_size) { return tail; } - if (self->frame_ready) { - // New frame: bulk copy it in. - self->current_area.next = tail; - tail = &self->current_area; + if (frame_ready) { + // New frame: bulk copy in the part that changed. + displayio_area_shift(&self->frame_dirty_area, self->current_area.x1, self->current_area.y1); + self->frame_dirty_area.next = tail; + tail = &self->frame_dirty_area; } return tail; } diff --git a/shared-module/videocore/VideoBuffer.h b/shared-module/videocore/VideoBuffer.h index e65a357965e..76f3e4a6819 100644 --- a/shared-module/videocore/VideoBuffer.h +++ b/shared-module/videocore/VideoBuffer.h @@ -36,9 +36,15 @@ typedef struct { // is none. const void *stream_base; // Set from interrupt context when the source lands a frame in the - // destination; cleared by the refresh pass once the new frame has been - // rendered into the display. + // destination; cleared when the refresh takes the changed area. volatile bool frame_ready; + // What changed in the frames landed since the last refresh, in source + // coordinates. Grown with frame_ready and cleared with it. + displayio_area_t frame_changed_area; + // The last frame landed passed NULL, so the next one repaints everything. + volatile bool frame_replaceable; + // frame_changed_area in screen coordinates, as handed to the refresh. + displayio_area_t frame_dirty_area; // Screen area of frames that landed where the video used to be: the frame // in flight when the destination moves or the source pauses still lands // at the old destination, possibly after that area was repainted. Grown diff --git a/shared-module/videocore/__init__.h b/shared-module/videocore/__init__.h index 09ab9e74fc3..7ea1b81ebe6 100644 --- a/shared-module/videocore/__init__.h +++ b/shared-module/videocore/__init__.h @@ -26,11 +26,20 @@ typedef struct { size_t height; } videocore_destination_t; -// Called (in interrupt context) when a frame has landed. frame is the -// destination base it landed at: usually the latest accepted destination, but -// a frame already being written when the destination changed (or when the -// source was paused) still lands at the previous one. -typedef void (*videocore_on_frame_fun)(void *ctx, const void *frame); +// A rect in source frame coordinates. x2 and y2 are exclusive. +typedef struct { + int16_t x1, y1, x2, y2; +} videocore_area_t; + +// Called from interrupt context or a background task when a frame has landed. +// frame is the destination base it landed at: usually the latest accepted +// destination, but a frame already being written when the destination changed +// (or when the source was paused) still lands at the previous one. changed is +// the part that differs from the previous frame at that destination. NULL means +// the whole frame changed and the next frame repaints all of it, so this one may +// be dropped. A source whose next frame may be partial reports the whole frame +// as a rect instead. The first frame at a new destination must cover it all. +typedef void (*videocore_on_frame_fun)(void *ctx, const void *frame, const videocore_area_t *changed); // Protocol implemented by video streamers such as mipicsi.Camera. A source // streams continuously while playing (unless paused); the target sets (and can @@ -64,9 +73,8 @@ typedef struct _videosource_p_t { void (*resume)(mp_obj_t self); // Direct frames into dest, starting with the next frame. Never raises: - // returns whether the source accepted dest. on_frame(ctx, frame) is - // called from interrupt context each time a frame lands, with the base - // it landed at. + // returns whether the source accepted dest. on_frame(ctx, frame, changed) + // is called each time a frame lands, with the base it landed at. bool (*set_destination)(mp_obj_t self, const videocore_destination_t *dest, videocore_on_frame_fun on_frame, void *ctx); From 4ec36a11a3250c09c190d9654a5c358a1599cbb6 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 08:02:38 -0700 Subject: [PATCH 04/14] videocore: check a strided frame fits in the framebuffer The fit check assumed a contiguous frame of height * row_bytes. A source that writes each row at the framebuffer stride ends (height - 1) strides in, so a narrow frame low in the framebuffer could run past its end. Co-Authored-By: Claude Opus 5.5 --- shared-module/videocore/VideoBuffer.c | 33 +++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index c7ad18c0aa8..be98612afb5 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -133,14 +133,29 @@ static void videocore_videobuffer_update_area(videocore_videobuffer_obj_t *self) self->current_area.next = NULL; } +// Bytes in one row of a source frame. +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; +} + +// Bytes a frame covers in a framebuffer with this row stride, from its first +// pixel. A contiguous DMA (the camera) writes frame_bytes in a row; a source +// that honors the stride (a GIF) ends its last row (height - 1) strides in. +// The frame must fit either way. +static mp_int_t videocore_videobuffer_frame_span(videocore_videobuffer_obj_t *self, mp_int_t row_stride) { + const mp_int_t strided = (self->source_height - 1) * row_stride + + videocore_videobuffer_source_row_bytes(self); + return MAX((mp_int_t)self->source_frame_bytes, strided); +} + // Compute the destination for the current position, including a containing // group's translation. Raises unless the whole frame fits in the target, -// since sources write contiguous frames. move_dest() is the non-raising -// variant used from displayio. +// whether the source writes it contiguously or at the target's stride. +// move_dest() is the non-raising variant used from displayio. static void videocore_videobuffer_compute_destination( videocore_videobuffer_obj_t *self, videocore_destination_t *dest) { const size_t bytes_per_pixel = self->source_bits_per_pixel / 8; - const size_t row_bytes = (size_t)self->source_width * bytes_per_pixel; + const size_t row_bytes = videocore_videobuffer_source_row_bytes(self); if (self->framebuffer_protocol == NULL) { // Raw buffer mode: frames are written compactly from the start. @@ -173,7 +188,7 @@ static void videocore_videobuffer_compute_destination( const mp_int_t shift_y = self->y + t->y; if (self->x < 0 || self->y < 0 || shift_x < 0 || shift_y < 0 || first_pixel_offset + shift_y * row_stride + shift_x * bytes_per_pixel + - (mp_int_t)self->source_frame_bytes > + videocore_videobuffer_frame_span(self, row_stride) > bufinfo.len) { mp_raise_ValueError(MP_ERROR_TEXT("video doesn't fit in framebuffer")); } @@ -200,8 +215,7 @@ static void videocore_videobuffer_move_dest( return; } const size_t bytes_per_pixel = self->source_bits_per_pixel / 8; - const size_t row_bytes = (size_t)self->source_width * bytes_per_pixel; - const size_t frame_bytes = (size_t)self->source_height * row_bytes; + const size_t row_bytes = videocore_videobuffer_source_row_bytes(self); const mp_int_t fb_width = FB_GETTER(get_width, 0); const mp_int_t row_stride = FB_GETTER(get_row_stride, fb_width * self->source_bits_per_pixel / 8); const mp_int_t first_pixel_offset = FB_GETTER(get_first_pixel_offset, 0); @@ -214,7 +228,8 @@ static void videocore_videobuffer_move_dest( } // Cap the start and the end. Cap the rows first: enough rows must fit // above the frame for it to fit at all. - const mp_int_t max_end = (mp_int_t)bufinfo.len - first_pixel_offset - (mp_int_t)frame_bytes; + const mp_int_t max_end = (mp_int_t)bufinfo.len - first_pixel_offset - + videocore_videobuffer_frame_span(self, row_stride); if (shift_y > max_end / row_stride) { shift_y = max_end / row_stride; } @@ -430,8 +445,8 @@ void common_hal_videocore_videobuffer_play(videocore_videobuffer_obj_t *self, mp void common_hal_videocore_videobuffer_set_position(videocore_videobuffer_obj_t *self, mp_int_t x, mp_int_t y) { if (self->framebuffer_protocol != NULL && x != 0) { - // The source DMAs whole, contiguous frames with no stride, so every - // frame row must land on a framebuffer row: the video's x must be 0. + // A contiguous source (the camera) ignores the stride, so every frame + // row must land on a framebuffer row: the video's x must be 0. mp_raise_ValueError(MP_ERROR_TEXT("video in a framebuffer must be at x == 0")); } // Repaint where the video used to be. Union (rather than overwrite) so a From 76ff34a202d823d06b19ca52f2b4d5d0dbf729b1 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 29 Oct 2026 12:54:43 -0700 Subject: [PATCH 05/14] gifio: factor out GIF file opening Move opening a file by name and reading the GIF header into helpers so another GIF user can share them. OnDiskGif behaves the same. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/gifio/OnDiskGif.c | 20 +++++++------ shared-bindings/gifio/OnDiskGif.h | 4 +++ shared-module/gifio/OnDiskGif.c | 48 +++++++++++++++++-------------- shared-module/gifio/OnDiskGif.h | 5 ++++ 4 files changed, 47 insertions(+), 30 deletions(-) diff --git a/shared-bindings/gifio/OnDiskGif.c b/shared-bindings/gifio/OnDiskGif.c index 0ee0365f5d8..3c25c7ffab4 100644 --- a/shared-bindings/gifio/OnDiskGif.c +++ b/shared-bindings/gifio/OnDiskGif.c @@ -15,6 +15,17 @@ #include "shared-bindings/util.h" #include "shared-bindings/gifio/OnDiskGif.h" +mp_obj_t gifio_open_file(mp_obj_t file) { + if (mp_obj_is_str(file)) { + file = mp_call_function_2(MP_OBJ_FROM_PTR(&mp_builtin_open_obj), file, MP_ROM_QSTR(MP_QSTR_rb)); + } + + if (!mp_obj_is_fileio(file)) { + mp_raise_TypeError(MP_ERROR_TEXT("file must be a file opened in byte mode")); + } + return file; +} + //| class OnDiskGif: //| """Loads one frame of a GIF into memory at a time. //| @@ -113,14 +124,7 @@ static mp_obj_t gifio_ondiskgif_make_new(const mp_obj_type_t *type, size_t n_arg 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); - mp_obj_t filename = args[ARG_filename].u_obj; - if (mp_obj_is_str(filename)) { - filename = mp_call_function_2(MP_OBJ_FROM_PTR(&mp_builtin_open_obj), filename, MP_ROM_QSTR(MP_QSTR_rb)); - } - - if (!mp_obj_is_fileio(filename)) { - mp_raise_TypeError(MP_ERROR_TEXT("file must be a file opened in byte mode")); - } + mp_obj_t filename = gifio_open_file(args[ARG_filename].u_obj); gifio_ondiskgif_t *self = mp_obj_malloc_with_finaliser(gifio_ondiskgif_t, &gifio_ondiskgif_type); common_hal_gifio_ondiskgif_construct(self, filename, args[ARG_use_palette].u_bool); diff --git a/shared-bindings/gifio/OnDiskGif.h b/shared-bindings/gifio/OnDiskGif.h index 69833145c06..708e36dd8d6 100644 --- a/shared-bindings/gifio/OnDiskGif.h +++ b/shared-bindings/gifio/OnDiskGif.h @@ -11,6 +11,10 @@ extern const mp_obj_type_t gifio_ondiskgif_type; +// Return file, opened for reading in binary mode when it is a file name. +// Raises TypeError unless the result is a file opened in binary mode. +mp_obj_t gifio_open_file(mp_obj_t file); + void common_hal_gifio_ondiskgif_construct(gifio_ondiskgif_t *self, mp_obj_t file, bool use_palette); uint32_t common_hal_gifio_ondiskgif_get_pixel(gifio_ondiskgif_t *bitmap, diff --git a/shared-module/gifio/OnDiskGif.c b/shared-module/gifio/OnDiskGif.c index 132220e98ad..7f561cfd9e3 100644 --- a/shared-module/gifio/OnDiskGif.c +++ b/shared-module/gifio/OnDiskGif.c @@ -130,41 +130,32 @@ static void GIFDraw(GIFDRAW *pDraw) { } } -void common_hal_gifio_ondiskgif_construct(gifio_ondiskgif_t *self, mp_obj_t file, bool use_palette) { - self->file = file; - - if (use_palette == true) { - GIF_begin(&self->gif, GIF_PALETTE_RGB888); - } else { - GIF_begin(&self->gif, GIF_PALETTE_RGB565_BE); - } - - self->gif.iError = GIF_SUCCESS; - self->gif.pfnRead = GIFReadFile; - self->gif.pfnSeek = GIFSeekFile; - self->gif.pfnDraw = GIFDraw; - self->gif.pfnClose = NULL; - self->gif.pfnOpen = NULL; - self->gif.GIFFile.fHandle = MP_OBJ_TO_PTR(self->file); +void gifio_open_gif(GIFIMAGE *gif, mp_obj_t file) { + gif->iError = GIF_SUCCESS; + gif->pfnRead = GIFReadFile; + gif->pfnSeek = GIFSeekFile; + gif->pfnClose = NULL; + gif->pfnOpen = NULL; + gif->GIFFile.fHandle = MP_OBJ_TO_PTR(file); int errcode = 0; - mp_stream_seek(self->file, 0, MP_SEEK_SET, &errcode); + mp_stream_seek(file, 0, MP_SEEK_SET, &errcode); if (errcode != 0) { mp_raise_OSError(errcode); } - mp_off_t size = mp_stream_seek(self->file, 0, MP_SEEK_END, &errcode); + mp_off_t size = mp_stream_seek(file, 0, MP_SEEK_END, &errcode); if (size < 0) { mp_raise_OSError(errcode); } - mp_stream_seek(self->file, 0, MP_SEEK_SET, &errcode); + mp_stream_seek(file, 0, MP_SEEK_SET, &errcode); if (errcode != 0) { mp_raise_OSError(errcode); } - self->gif.GIFFile.iSize = (int32_t)size; + gif->GIFFile.iSize = (int32_t)size; - int result = GIF_init(&self->gif); + int result = GIF_init(gif); if (result != 1) { - switch (self->gif.iError) { + switch (gif->iError) { case GIF_TOO_WIDE: mp_raise_ValueError_varg(MP_ERROR_TEXT("%q must be <= %d"), MP_QSTR_width, MAX_WIDTH); break; @@ -173,6 +164,19 @@ void common_hal_gifio_ondiskgif_construct(gifio_ondiskgif_t *self, mp_obj_t file break; } } +} + +void common_hal_gifio_ondiskgif_construct(gifio_ondiskgif_t *self, mp_obj_t file, bool use_palette) { + self->file = file; + + if (use_palette == true) { + GIF_begin(&self->gif, GIF_PALETTE_RGB888); + } else { + GIF_begin(&self->gif, GIF_PALETTE_RGB565_BE); + } + + self->gif.pfnDraw = GIFDraw; + gifio_open_gif(&self->gif, self->file); int bpp = 16; if (use_palette == true) { diff --git a/shared-module/gifio/OnDiskGif.h b/shared-module/gifio/OnDiskGif.h index 808b6baa89f..8c33a321302 100644 --- a/shared-module/gifio/OnDiskGif.h +++ b/shared-module/gifio/OnDiskGif.h @@ -26,3 +26,8 @@ typedef struct { int32_t min_delay; int32_t max_delay; } gifio_ondiskgif_t; + +// Point gif, set up by GIF_begin, at file (a stream object) and read the GIF +// header through it. Raises when the file can't be read, isn't a GIF or is +// wider than MAX_WIDTH. +void gifio_open_gif(GIFIMAGE *gif, mp_obj_t file); From bdb44e9eef71e3956c26665e3f0843a61d4d17fd Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 08:03:42 -0700 Subject: [PATCH 06/14] videocore: let a source report end of stream A source calls the destination's on_eos when it will write no more frames. VideoBuffer then stops it the next time playing, source or a refresh looks, like audio outputs stop a finished sample, and keeps its last frame shown. A framebuffer target drops that frame when moved or hidden, since the repaint overwrites it. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videocore/VideoBuffer.c | 6 +- shared-module/videocore/VideoBuffer.c | 88 ++++++++++++++++++++----- shared-module/videocore/VideoBuffer.h | 3 + shared-module/videocore/__init__.h | 6 ++ 4 files changed, 84 insertions(+), 19 deletions(-) diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index 34b9a623d42..0960a884ab2 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -103,7 +103,11 @@ static mp_obj_t videocore_videobuffer_stop(mp_obj_t self_in) { static MP_DEFINE_CONST_FUN_OBJ_1(videocore_videobuffer_stop_obj, videocore_videobuffer_stop); //| playing: bool -//| """True when a video source is being played, False otherwise. (read-only)""" +//| """True when a video source is being played, False otherwise. It also +//| becomes False when the source has no more frames, such as a GIF that +//| has finished its last loop. The last frame then stays shown until +//| ``stop()`` or the next ``play()``. A framebuffer target also drops it +//| when moved or hidden. (read-only)""" //| static mp_obj_t videocore_videobuffer_get_playing(mp_obj_t self_in) { videocore_videobuffer_obj_t *self = MP_OBJ_TO_PTR(self_in); diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index be98612afb5..72b669269db 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -91,6 +91,14 @@ static void videocore_videobuffer_on_frame(void *ctx, const void *frame, const v videocore_videobuffer_add_vacated_frame(self, frame); } +// Called from interrupt context or a background task when the source reached +// the end of its stream. Only flag it: end_stream stops the source later, the +// way audio outputs stop a finished sample. +static void videocore_videobuffer_on_eos(void *ctx) { + videocore_videobuffer_obj_t *self = ctx; + self->source_ended = true; +} + // Drop any landed frame the refresh hasn't taken yet. static void videocore_videobuffer_forget_frame(videocore_videobuffer_obj_t *self) { displayio_area_t empty = {}; @@ -163,6 +171,7 @@ static void videocore_videobuffer_compute_destination( dest->stride = row_bytes; dest->row_bytes = row_bytes; dest->height = self->source_height; + dest->on_eos = videocore_videobuffer_on_eos; if (self->bufinfo.len < self->source_frame_bytes) { mp_raise_ValueError(MP_ERROR_TEXT("Buffer too small")); } @@ -199,6 +208,7 @@ static void videocore_videobuffer_compute_destination( dest->stride = row_stride; dest->row_bytes = row_bytes; dest->height = self->source_height; + dest->on_eos = videocore_videobuffer_on_eos; } // Move the framebuffer destination for a new absolute position, capping the @@ -250,6 +260,7 @@ static void videocore_videobuffer_move_dest( .stride = row_stride, .row_bytes = row_bytes, .height = self->source_height, + .on_eos = videocore_videobuffer_on_eos, }; // A rejected destination leaves the stream where it was. if (videosource_set_destination(self->source, &dest, videocore_videobuffer_on_frame, self)) { @@ -288,6 +299,7 @@ void common_hal_videocore_videobuffer_construct(videocore_videobuffer_obj_t *sel self->in_group = false; self->hidden = false; self->stale_size = false; + self->source_ended = false; displayio_area_t empty = {}; self->current_area = empty; self->ephemeral_dirty_area = empty; @@ -302,15 +314,12 @@ void common_hal_videocore_videobuffer_construct(videocore_videobuffer_obj_t *sel self->paused = true; } -// Start the source's hardware and record its frame format, which is fixed -// until stop. The source has no destination yet, so it doesn't write frames. -// When paused is true it starts paused. May raise. -static void videocore_videobuffer_start_source(videocore_videobuffer_obj_t *self, bool paused) { +// Record the started source's frame format, which is fixed until stop. May +// raise. +static void videocore_videobuffer_read_source_format(videocore_videobuffer_obj_t *self) { mp_obj_t source = self->source; const videosource_p_t *protocol = videosource_check(source); - videosource_play(source, paused); - mp_int_t bits_per_pixel = protocol->get_bits_per_pixel(source); if (bits_per_pixel % 8 != 0) { mp_raise_ValueError(MP_ERROR_TEXT("video format must be byte aligned")); @@ -395,6 +404,21 @@ static void videocore_videobuffer_resume_stream(videocore_videobuffer_obj_t *sel videocore_videobuffer_forget_frame(self); } +// Stop a source that reached the end of its stream. stream_base stays, so the +// last frame stays shown until stop() or the next play(). Never raises. +static void videocore_videobuffer_end_stream(videocore_videobuffer_obj_t *self) { + if (!self->playing || !self->source_ended) { + return; + } + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + videosource_stop(self->source); + nlr_pop(); + } + self->playing = false; + self->source = mp_const_none; +} + // Start, resume or pause a framebuffer target's source to match whether it is // shown (on a display and not hidden). While hidden the source stays active // but paused, so showing it again is quick. @@ -402,11 +426,17 @@ static void videocore_videobuffer_update_stream(videocore_videobuffer_obj_t *sel if (self->framebuffer_protocol == NULL) { return; // Plain buffer targets always stream. } + // An ended source must not resume. + videocore_videobuffer_end_stream(self); const bool shown = self->absolute_transform.scale != 0 && !self->hidden; if (!self->playing) { // Remember whether the target is shown so play() can start the source // in the right state. self->paused = !shown; + if (!shown) { + // A repaint overwrites an ended stream's last frame. + self->stream_base = NULL; + } return; } if (shown && !self->stale_size) { @@ -419,27 +449,37 @@ static void videocore_videobuffer_update_stream(videocore_videobuffer_obj_t *sel } void common_hal_videocore_videobuffer_play(videocore_videobuffer_obj_t *self, mp_obj_t source) { + videocore_videobuffer_end_stream(self); if (self->playing) { mp_raise_RuntimeError(MP_ERROR_TEXT("already playing")); } videosource_check(source); - self->source = source; self->stale_size = false; + self->source_ended = false; + videocore_videobuffer_forget_frame(self); + // An ended stream's last frame stays until repainted, and the format is about to change. + self->stream_base = NULL; // A framebuffer target only streams while shown. When it isn't, start the // source paused rather than pausing it afterwards, so no frame slips out // in between. Plain buffer targets always stream. const bool start_paused = self->framebuffer_protocol != NULL && self->paused; - videocore_videobuffer_start_source(self, start_paused); - videocore_videobuffer_update_area(self); - self->playing = true; - if (start_paused) { - // No destination is set until the VideoBuffer is shown, so the source - // has nowhere to put a frame while paused. - videocore_videobuffer_forget_frame(self); - self->stream_base = NULL; + // If this raises the source may be playing elsewhere, so leave it alone. + videosource_play(source, start_paused); + self->source = source; + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + videocore_videobuffer_read_source_format(self); + videocore_videobuffer_update_area(self); + self->playing = true; + // A paused source gets its destination when the VideoBuffer is shown. + if (!start_paused) { + videocore_videobuffer_start_stream(self); + } + nlr_pop(); } else { - videocore_videobuffer_start_stream(self); + videocore_videobuffer_stop_quiet(self); + nlr_raise(nlr.ret_val); } } @@ -449,6 +489,7 @@ void common_hal_videocore_videobuffer_set_position(videocore_videobuffer_obj_t * // row must land on a framebuffer row: the video's x must be 0. mp_raise_ValueError(MP_ERROR_TEXT("video in a framebuffer must be at x == 0")); } + videocore_videobuffer_end_stream(self); // Repaint where the video used to be. Union (rather than overwrite) so a // move before the previous one was refreshed isn't lost. videocore_videobuffer_dirty_current_area(self); @@ -459,6 +500,10 @@ void common_hal_videocore_videobuffer_set_position(videocore_videobuffer_obj_t * // Draw the last frame at its new position. videocore_videobuffer_dirty_current_area(self); } + if (!self->playing && self->framebuffer_protocol != NULL) { + // A repaint overwrites an ended stream's last frame. + self->stream_base = NULL; + } if (!self->playing || self->paused || self->framebuffer_protocol == NULL) { // Raw buffer targets are rendered by displayio; their capture // destination doesn't move with the position. A paused framebuffer @@ -523,10 +568,12 @@ void videocore_videobuffer_stop_quiet(videocore_videobuffer_obj_t *self) { } bool common_hal_videocore_videobuffer_get_playing(videocore_videobuffer_obj_t *self) { + videocore_videobuffer_end_stream(self); return self->playing; } mp_obj_t common_hal_videocore_videobuffer_get_source(videocore_videobuffer_obj_t *self) { + videocore_videobuffer_end_stream(self); return self->source; } @@ -541,7 +588,7 @@ static mp_obj_t videocore_videobuffer_draw_protocol_self(mp_obj_t protocol_conta 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. - if (!self->playing || self->stream_base == NULL || self->hidden) { + if (self->stream_base == NULL || self->hidden) { return false; } @@ -690,6 +737,9 @@ static void videocore_videobuffer_draw_update_transform(mp_obj_t self_in, displa if (self->framebuffer_protocol == NULL) { // Draw the last frame at its new position. videocore_videobuffer_dirty_current_area(self); + } else if (!self->playing) { + // A repaint overwrites an ended stream's last frame. + self->stream_base = NULL; } } @@ -714,6 +764,7 @@ static void videocore_videobuffer_draw_finish_refresh(mp_obj_t self_in) { if (self->frame_replaceable) { videocore_videobuffer_forget_frame(self); } + videocore_videobuffer_end_stream(self); } static displayio_area_t *videocore_videobuffer_draw_get_refresh_areas(mp_obj_t self_in, displayio_area_t *tail) { @@ -739,7 +790,7 @@ static displayio_area_t *videocore_videobuffer_draw_get_refresh_areas(mp_obj_t s self->ephemeral_dirty_area.next = tail; tail = &self->ephemeral_dirty_area; } - if (!self->playing || self->paused || self->hidden || self->stale_size) { + if (self->stream_base == NULL || self->paused || self->hidden || self->stale_size) { return tail; } if (frame_ready) { @@ -760,6 +811,7 @@ static void videocore_videobuffer_draw_set_hidden(mp_obj_t self_in, bool hidden) if (self->hidden == hidden) { return; } + videocore_videobuffer_end_stream(self); self->hidden = hidden; // Repaint what the video showed or vacated. self->ephemeral_dirty_area = self->current_area; diff --git a/shared-module/videocore/VideoBuffer.h b/shared-module/videocore/VideoBuffer.h index 76f3e4a6819..9f48cc001e2 100644 --- a/shared-module/videocore/VideoBuffer.h +++ b/shared-module/videocore/VideoBuffer.h @@ -71,6 +71,9 @@ typedef struct { // the video won't show until stop and play again. bool stale_size; bool playing; + // Set from interrupt context or a background task when the source reached + // the end of its stream; the VideoBuffer then stops it lazily. + volatile bool source_ended; // True while a framebuffer target isn't shown, so its source is paused. // Tracked while stopped too, so play() can start the source paused. // Plain buffer targets are never paused. diff --git a/shared-module/videocore/__init__.h b/shared-module/videocore/__init__.h index 7ea1b81ebe6..68dfde0b21c 100644 --- a/shared-module/videocore/__init__.h +++ b/shared-module/videocore/__init__.h @@ -13,6 +13,10 @@ #include "py/obj.h" #include "py/proto.h" +// Called from interrupt context or a background task, with the ctx given to +// set_destination, when the source will write no more frames. +typedef void (*videocore_on_eos_fun)(void *ctx); + // Destination for a video stream. The caller checks that the whole frame // fits before handing it to a source. typedef struct { @@ -24,6 +28,8 @@ typedef struct { size_t row_bytes; // Source frame height, in rows. size_t height; + // Never NULL. Sources that never end, such as a camera, don't call it. + videocore_on_eos_fun on_eos; } videocore_destination_t; // A rect in source frame coordinates. x2 and y2 are exclusive. From 91d74717355bbf576396fdf17a1f3902ee195527 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 08:02:57 -0700 Subject: [PATCH 07/14] videogif: add a GIF video source GifDecoder plays an animated GIF into a videocore.VideoBuffer as RGB565 frames, reporting each frame's changed rect. It follows the frame delays and loop count, and reports the end of the stream on the last loop or a read error. Co-Authored-By: Claude Opus 5.5 --- main.c | 9 + py/circuitpy_defns.mk | 5 + py/circuitpy_mpconfig.mk | 4 + shared-bindings/videocore/VideoBuffer.c | 2 +- shared-bindings/videocore/__init__.c | 7 +- shared-bindings/videogif/GifDecoder.c | 231 ++++++++++++++++ shared-bindings/videogif/GifDecoder.h | 32 +++ shared-bindings/videogif/__init__.c | 30 +++ shared-module/videogif/GifDecoder.c | 342 ++++++++++++++++++++++++ shared-module/videogif/GifDecoder.h | 67 +++++ supervisor/shared/tick.c | 8 + 11 files changed, 733 insertions(+), 4 deletions(-) create mode 100644 shared-bindings/videogif/GifDecoder.c create mode 100644 shared-bindings/videogif/GifDecoder.h create mode 100644 shared-bindings/videogif/__init__.c create mode 100644 shared-module/videogif/GifDecoder.c create mode 100644 shared-module/videogif/GifDecoder.h diff --git a/main.c b/main.c index 40d40ad14cd..255efe67e9c 100644 --- a/main.c +++ b/main.c @@ -101,6 +101,10 @@ #include "shared-module/usb_host_audio/USBIn.h" #endif +#if CIRCUITPY_VIDEOGIF +#include "shared-module/videogif/GifDecoder.h" +#endif + #if CIRCUITPY_MEMORYMONITOR #include "shared-module/memorymonitor/__init__.h" #endif @@ -396,6 +400,11 @@ static void cleanup_after_vm(mp_obj_t exception) { picogame_reset(); #endif + // Stop GIF playback before the displays and their framebuffers go away. + #if CIRCUITPY_VIDEOGIF + videogif_reset(); + #endif + // Turn off the display and flush the filesystem before the heap disappears. #if CIRCUITPY_DISPLAYIO reset_displays(); diff --git a/py/circuitpy_defns.mk b/py/circuitpy_defns.mk index f5c2f635151..99a54623a06 100755 --- a/py/circuitpy_defns.mk +++ b/py/circuitpy_defns.mk @@ -507,6 +507,9 @@ endif ifeq ($(CIRCUITPY_VIDEOCORE),1) SRC_PATTERNS += videocore/% endif +ifeq ($(CIRCUITPY_VIDEOGIF),1) +SRC_PATTERNS += videogif/% +endif ifeq ($(CIRCUITPY_WARNINGS),1) SRC_PATTERNS += warnings/% endif @@ -715,6 +718,7 @@ $(filter $(SRC_PATTERNS), \ supervisor/RunReason.c \ supervisor/Runtime.c \ supervisor/StatusBar.c \ + videogif/__init__.c \ wifi/AuthMode.c \ wifi/Packet.c \ wifi/PowerManagement.c \ @@ -914,6 +918,7 @@ SRC_SHARED_MODULE_ALL = \ vectorio/__init__.c \ videocore/VideoBuffer.c \ videocore/__init__.c \ + videogif/GifDecoder.c \ warnings/__init__.c \ watchdog/__init__.c \ zlib/__init__.c \ diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index ed5cc25d150..e6903fcf6a0 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -857,6 +857,10 @@ CIRCUITPY_ULAB_OPTIMIZE_SIZE ?= 0 CIRCUITPY_VIDEOCORE ?= 0 CFLAGS += -DCIRCUITPY_VIDEOCORE=$(CIRCUITPY_VIDEOCORE) +# Animated GIF files as a videocore video source. Shares gifio's decoder. +CIRCUITPY_VIDEOGIF ?= $(call enable-if-all,$(CIRCUITPY_VIDEOCORE) $(CIRCUITPY_GIFIO)) +CFLAGS += -DCIRCUITPY_VIDEOGIF=$(CIRCUITPY_VIDEOGIF) + CIRCUITPY_WARNINGS ?= $(CIRCUITPY_FULL_BUILD) CFLAGS += -DCIRCUITPY_WARNINGS=$(CIRCUITPY_WARNINGS) diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index 0960a884ab2..7c9ab908b3e 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -81,7 +81,7 @@ static mp_obj_t videocore_videobuffer_make_new(const mp_obj_type_t *type, size_t //| whole frame. //| //| :param ~circuitpython_typing.VideoSource source: The video source to -//| play, such as `mipicsi.Camera` +//| play, such as `mipicsi.Camera` or `videogif.GifDecoder` //| """ //| static mp_obj_t videocore_videobuffer_play(mp_obj_t self_in, mp_obj_t source) { diff --git a/shared-bindings/videocore/__init__.c b/shared-bindings/videocore/__init__.c index 83c8487941a..ae142e4e693 100644 --- a/shared-bindings/videocore/__init__.c +++ b/shared-bindings/videocore/__init__.c @@ -15,9 +15,10 @@ //| This module provides the video pipeline shared between video sources and //| video outputs. It is the video equivalent of the `audiocore` module. //| -//| Video sources, such as `mipicsi.Camera`, implement the internal videosource -//| protocol. A `VideoBuffer` tells a video source where to put its frames in a -//| framebuffer, such as `mipidsi.Display`, and can move it as needed. +//| A video source, such as `mipicsi.Camera` or `videogif.GifDecoder`, makes +//| video frames. A `VideoBuffer` plays a video source and tells it where to +//| put each frame: in a display's memory, such as `mipidsi.Display`, or in a +//| ``bytearray`` that a displayio Group shows. //| //| Stream a MIPI CSI camera live into the board's display (a MIPI DSI panel //| on ESP32-P4-Function-EV, for example): diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c new file mode 100644 index 00000000000..6a97e5724b4 --- /dev/null +++ b/shared-bindings/videogif/GifDecoder.c @@ -0,0 +1,231 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Mikey Sklar for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include "py/obj.h" +#include "py/objproperty.h" +#include "py/runtime.h" + +#include "shared-bindings/gifio/OnDiskGif.h" +#include "shared-bindings/util.h" +#include "shared-bindings/videogif/GifDecoder.h" +#include "shared/runtime/context_manager_helpers.h" + +//| class GifDecoder: +//| """Decode an animated GIF file for video playback. +//| +//| Pass a GifDecoder to ``videocore.VideoBuffer.play()`` to show the GIF +//| on a display or to write its frames into a buffer. +//| +//| Each frame is shown for the time the GIF file gives it. A few cases +//| work the same way as in web browsers: +//| +//| - A frame time of 0 or 0.01 seconds is shown for 0.1 seconds. +//| - A GIF with no loop count plays once. +//| - A loop count of 0 repeats the GIF forever. Any other loop count is +//| the number of extra times to play it, so a loop count of 2 plays the +//| GIF three times. +//| +//| When the GIF finishes its last loop, its last frame stays on the screen +//| and `videocore.VideoBuffer.playing` becomes False. The same happens if +//| a frame or the file can't be read. +//| +//| Frames are decoded in the background, in short breaks between your +//| Python code. If a frame takes longer to decode than its frame time, +//| the animation slows down so your Python code still gets time to run. +//| +//| The GIF starts again from its first frame, filled with its background +//| color, at each loop and whenever the VideoBuffer showing it directly +//| on a display is moved or shown again. +//| +//| When a frame asks for its area to be cleared after it is shown (GIF +//| disposal method 2), that area is filled with the background color. +//| GIFs that ask to restore the previous image (disposal method 3) may +//| show leftover pixels, because that is not supported. +//| +//| Play a GIF in a Group on a display: +//| +//| .. code-block:: python +//| +//| import board +//| import displayio +//| import videocore +//| import videogif +//| +//| # Match your display's byte order. A BusDisplay usually swaps the +//| # bytes of each pixel (True). Framebuffer displays such as +//| # mipidsi.Display don't (False). +//| gif = videogif.GifDecoder("/animation.gif", reverse_bytes_in_word=False) +//| video = videocore.VideoBuffer(bytearray(gif.width * gif.height * 2)) +//| video.play(gif) +//| +//| root = displayio.Group() +//| root.append(video) +//| board.DISPLAY.root_group = root +//| while True: +//| pass +//| """ +//| +//| def __init__( +//| self, +//| file: Union[str, typing.BinaryIO], +//| *, +//| 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 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``. +//| +//| Frames are RGB565. Raises `ValueError` 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 }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_file, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { 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); + + 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); + return MP_OBJ_FROM_PTR(self); +} + +static videogif_gifdecoder_obj_t *native_gifdecoder(mp_obj_t self_in) { + return MP_OBJ_TO_PTR(mp_obj_cast_to_native_base(self_in, &videogif_gifdecoder_type)); +} + +static void check_for_deinit(videogif_gifdecoder_obj_t *self) { + if (common_hal_videogif_gifdecoder_deinited(self)) { + raise_deinited_error(); + } +} + +//| def deinit(self) -> None: +//| """Stop playing and free the memory used to decode the GIF. The +//| GifDecoder can't be used after this.""" +//| ... +//| +static mp_obj_t videogif_gifdecoder_deinit(mp_obj_t self_in) { + common_hal_videogif_gifdecoder_deinit(native_gifdecoder(self_in)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(videogif_gifdecoder_deinit_obj, videogif_gifdecoder_deinit); + +//| def __enter__(self) -> GifDecoder: +//| """No-op used by Context Managers.""" +//| ... +//| +//| def __exit__(self) -> None: +//| """Automatically deinitializes when exiting a context. See +//| :ref:`lifetime-and-contextmanagers` for more info.""" +//| ... +//| + +//| width: int +//| """Width of the GIF in pixels. (read-only)""" +//| +static mp_obj_t videogif_gifdecoder_get_width(mp_obj_t self_in) { + videogif_gifdecoder_obj_t *self = native_gifdecoder(self_in); + check_for_deinit(self); + return MP_OBJ_NEW_SMALL_INT(common_hal_videogif_gifdecoder_get_width(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(videogif_gifdecoder_get_width_obj, videogif_gifdecoder_get_width); + +MP_PROPERTY_GETTER(videogif_gifdecoder_width_obj, + (mp_obj_t)&videogif_gifdecoder_get_width_obj); + +//| height: int +//| """Height of the GIF in pixels. (read-only)""" +//| +//| +static mp_obj_t videogif_gifdecoder_get_height(mp_obj_t self_in) { + videogif_gifdecoder_obj_t *self = native_gifdecoder(self_in); + check_for_deinit(self); + return MP_OBJ_NEW_SMALL_INT(common_hal_videogif_gifdecoder_get_height(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(videogif_gifdecoder_get_height_obj, videogif_gifdecoder_get_height); + +MP_PROPERTY_GETTER(videogif_gifdecoder_height_obj, + (mp_obj_t)&videogif_gifdecoder_get_height_obj); + +static const mp_rom_map_elem_t videogif_gifdecoder_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&videogif_gifdecoder_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR___enter__), MP_ROM_PTR(&default___enter___obj) }, + { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&default___exit___obj) }, + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&videogif_gifdecoder_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR_height), MP_ROM_PTR(&videogif_gifdecoder_height_obj) }, + { MP_ROM_QSTR(MP_QSTR_width), MP_ROM_PTR(&videogif_gifdecoder_width_obj) }, +}; +static MP_DEFINE_CONST_DICT(videogif_gifdecoder_locals_dict, videogif_gifdecoder_locals_dict_table); + +// Implementation of the videocore videosource protocol so a VideoBuffer can +// tell the GifDecoder where to put frames. +static void videogif_gifdecoder_play(mp_obj_t self_in, bool paused) { + videogif_gifdecoder_obj_t *self = native_gifdecoder(self_in); + check_for_deinit(self); + common_hal_videogif_gifdecoder_play(self, paused); +} + +static void videogif_gifdecoder_stop(mp_obj_t self_in) { + common_hal_videogif_gifdecoder_stop(native_gifdecoder(self_in)); +} + +static void videogif_gifdecoder_pause(mp_obj_t self_in) { + common_hal_videogif_gifdecoder_pause(native_gifdecoder(self_in)); +} + +static void videogif_gifdecoder_resume(mp_obj_t self_in) { + common_hal_videogif_gifdecoder_resume(native_gifdecoder(self_in)); +} + +static bool videogif_gifdecoder_set_destination(mp_obj_t self_in, const videocore_destination_t *destination, + videocore_on_frame_fun on_frame, void *context) { + return common_hal_videogif_gifdecoder_set_destination(native_gifdecoder(self_in), destination, on_frame, context); +} + +static mp_int_t videogif_gifdecoder_videosource_get_width(mp_obj_t self_in) { + return common_hal_videogif_gifdecoder_get_width(native_gifdecoder(self_in)); +} + +static mp_int_t videogif_gifdecoder_videosource_get_height(mp_obj_t self_in) { + return common_hal_videogif_gifdecoder_get_height(native_gifdecoder(self_in)); +} + +static mp_int_t videogif_gifdecoder_get_bits_per_pixel(mp_obj_t self_in) { + return 16; +} + +static const videosource_p_t videogif_gifdecoder_videosource_protocol = { + MP_PROTO_IMPLEMENT(MP_QSTR_protocol_videosource) + .play = videogif_gifdecoder_play, + .pause = videogif_gifdecoder_pause, + .resume = videogif_gifdecoder_resume, + .stop = videogif_gifdecoder_stop, + .set_destination = videogif_gifdecoder_set_destination, + .get_width = videogif_gifdecoder_videosource_get_width, + .get_height = videogif_gifdecoder_videosource_get_height, + .get_bits_per_pixel = videogif_gifdecoder_get_bits_per_pixel, +}; + +MP_DEFINE_CONST_OBJ_TYPE( + videogif_gifdecoder_type, + MP_QSTR_GifDecoder, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, videogif_gifdecoder_make_new, + locals_dict, &videogif_gifdecoder_locals_dict, + protocol, &videogif_gifdecoder_videosource_protocol + ); diff --git a/shared-bindings/videogif/GifDecoder.h b/shared-bindings/videogif/GifDecoder.h new file mode 100644 index 00000000000..a5421c35726 --- /dev/null +++ b/shared-bindings/videogif/GifDecoder.h @@ -0,0 +1,32 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Mikey Sklar for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/videogif/GifDecoder.h" + +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. +void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file, + 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); + +// Videosource protocol steps, driven by videocore.VideoBuffer. Frames are +// decoded by the supervisor background tick, never in an interrupt. play +// raises when the decoder is already playing; the others never raise. +void common_hal_videogif_gifdecoder_play(videogif_gifdecoder_obj_t *self, bool paused); +void common_hal_videogif_gifdecoder_stop(videogif_gifdecoder_obj_t *self); +void common_hal_videogif_gifdecoder_pause(videogif_gifdecoder_obj_t *self); +void common_hal_videogif_gifdecoder_resume(videogif_gifdecoder_obj_t *self); +bool common_hal_videogif_gifdecoder_set_destination(videogif_gifdecoder_obj_t *self, + const videocore_destination_t *destination, videocore_on_frame_fun on_frame, void *context); diff --git a/shared-bindings/videogif/__init__.c b/shared-bindings/videogif/__init__.c new file mode 100644 index 00000000000..4128eeadc6a --- /dev/null +++ b/shared-bindings/videogif/__init__.c @@ -0,0 +1,30 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Mikey Sklar for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include "py/obj.h" +#include "py/runtime.h" + +#include "shared-bindings/videogif/GifDecoder.h" + +//| """Support for playing animated GIF files as video +//| +//| The `videogif` module reads animated GIF files so that a +//| `videocore.VideoBuffer` can play them on a display or into a buffer. +//| """ + +static const mp_rom_map_elem_t videogif_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_videogif) }, + { MP_ROM_QSTR(MP_QSTR_GifDecoder), MP_ROM_PTR(&videogif_gifdecoder_type) }, +}; + +static MP_DEFINE_CONST_DICT(videogif_module_globals, videogif_module_globals_table); + +const mp_obj_module_t videogif_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&videogif_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_videogif, videogif_module); diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c new file mode 100644 index 00000000000..57c52bddb4f --- /dev/null +++ b/shared-module/videogif/GifDecoder.c @@ -0,0 +1,342 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Mikey Sklar for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include "shared-bindings/videogif/GifDecoder.h" + +#include + +#include "py/mpstate.h" +#include "py/runtime.h" +#include "shared-module/gifio/OnDiskGif.h" +#include "supervisor/port_heap.h" +#include "supervisor/shared/tick.h" + +#define PLAYING_HEAD MP_STATE_VM(videogif_playing_list) + +// Python code always gets at least this long between two frames, even when +// 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); +} + +static displayio_area_t videogif_canvas(videogif_gifdecoder_obj_t *self) { + const displayio_area_t canvas = { + .x2 = self->gif->iCanvasWidth, + .y2 = self->gif->iCanvasHeight, + }; + return canvas; +} + +// Fill area with the background color: the global palette entry the logical +// 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]; + 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); + } + } +} + +// Clear what the next frame is drawn over: the whole canvas after a restart, +// or the last frame's rect when its disposal method is 2. Waits until the +// frame is known to exist and AnimatedGIF has read the palette. +static void videogif_start_frame(videogif_gifdecoder_obj_t *self) { + if (self->fill_canvas) { + const displayio_area_t canvas = videogif_canvas(self); + videogif_fill(self, &canvas); + } else if (self->erase_last_frame) { + videogif_fill(self, &self->frame_area); + } + self->fill_canvas = false; + self->erase_last_frame = false; +} + +// AnimatedGIF calls this for each decoded line of a frame. Transparent pixels +// are skipped: the destination still holds the previous frame there. +static void videogif_draw_line(GIFDRAW *draw) { + videogif_gifdecoder_obj_t *self = draw->pUser; + if (self->new_frame) { + self->new_frame = false; + videogif_start_frame(self); + } + + const int y = draw->iY + draw->y; + int width = draw->iWidth; + if (draw->iX + width > self->gif->iCanvasWidth) { + width = self->gif->iCanvasWidth - draw->iX; + } + if (y >= self->gif->iCanvasHeight || width < 1) { + return; + } + uint8_t *row = self->destination.base + y * self->destination.stride; + const uint8_t *pixels = draw->pPixels; + for (int i = 0; i < width; i++) { + const uint8_t index = pixels[i]; + if (draw->ucHasTransparency && index == draw->ucTransparent) { + continue; + } + videogif_put_pixel(row, draw->iX + i, draw->pPalette[index]); + } +} + +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; + // 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. + self->gif = port_malloc(sizeof(GIFIMAGE), false); + if (self->gif == NULL) { + m_malloc_fail(sizeof(GIFIMAGE)); + } + GIF_begin(self->gif, palette_type); + self->gif->pfnDraw = videogif_draw_line; + self->file = file; + gifio_open_gif(self->gif, file); +} + +bool common_hal_videogif_gifdecoder_deinited(videogif_gifdecoder_obj_t *self) { + return self->file == NULL; +} + +// Tell the destination no more frames are coming, once per play. +static void videogif_end_stream(videogif_gifdecoder_obj_t *self) { + if (self->destination.on_eos != NULL) { + self->destination.on_eos(self->on_frame_context); + self->destination.on_eos = NULL; + } +} + +void common_hal_videogif_gifdecoder_deinit(videogif_gifdecoder_obj_t *self) { + videogif_end_stream(self); + common_hal_videogif_gifdecoder_stop(self); + if (self->gif != NULL) { + port_free(self->gif); + self->gif = NULL; + } + self->file = NULL; +} + +// The VideoBuffer may still ask a deinitialized decoder for its size. +mp_int_t common_hal_videogif_gifdecoder_get_width(videogif_gifdecoder_obj_t *self) { + return self->gif != NULL ? self->gif->iCanvasWidth : 0; +} + +mp_int_t common_hal_videogif_gifdecoder_get_height(videogif_gifdecoder_obj_t *self) { + return self->gif != NULL ? self->gif->iCanvasHeight : 0; +} + +// 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; + if (ticking == self->ticking) { + return; + } + self->ticking = ticking; + if (ticking) { + supervisor_enable_tick(); + } else { + supervisor_disable_tick(); + } +} + +// Start again from the first frame as soon as possible. +static void videogif_restart(videogif_gifdecoder_obj_t *self) { + self->restart = true; + self->finished = false; + self->next_frame_ms = supervisor_ticks_ms64(); + videogif_update_tick(self); +} + +// No more frames: the last loop played, or a frame or the file can't be read. +static void videogif_finish(videogif_gifdecoder_obj_t *self) { + self->finished = true; + videogif_update_tick(self); + videogif_end_stream(self); +} + +void common_hal_videogif_gifdecoder_play(videogif_gifdecoder_obj_t *self, bool paused) { + if (self->playing) { + mp_raise_RuntimeError(MP_ERROR_TEXT("already playing")); + } + self->playing = true; + self->paused = paused; + self->loops_done = 0; + const videocore_destination_t none = {}; + self->destination = none; + self->next = PLAYING_HEAD; + PLAYING_HEAD = self; + videogif_restart(self); +} + +void common_hal_videogif_gifdecoder_stop(videogif_gifdecoder_obj_t *self) { + if (!self->playing) { + return; + } + self->playing = false; + const videocore_destination_t none = {}; + self->destination = none; + videogif_gifdecoder_obj_t **link = &PLAYING_HEAD; + while (*link != self) { + link = &(*link)->next; + } + *link = self->next; + self->next = NULL; + videogif_update_tick(self); +} + +void common_hal_videogif_gifdecoder_pause(videogif_gifdecoder_obj_t *self) { + self->paused = true; + videogif_update_tick(self); +} + +void common_hal_videogif_gifdecoder_resume(videogif_gifdecoder_obj_t *self) { + // The destination may have been drawn over while paused. + self->paused = false; + videogif_restart(self); +} + +bool common_hal_videogif_gifdecoder_set_destination(videogif_gifdecoder_obj_t *self, + const videocore_destination_t *destination, videocore_on_frame_fun on_frame, void *context) { + // A new destination doesn't hold the previous frame to draw the next one + // over, so start again from the first frame. + if (destination->base != self->destination.base || destination->stride != self->destination.stride) { + videogif_restart(self); + } + self->destination = *destination; + self->on_frame = on_frame; + self->on_frame_context = context; + return true; +} + +// The end of a loop: start over, unless that was the last one. Like web +// browsers, a GIF without a loop count plays once and a loop count of n +// repeats it n times. +static void videogif_end_loop(videogif_gifdecoder_obj_t *self) { + self->restart = true; + if (self->repeat_count != 0 && ++self->loops_done > self->repeat_count) { + videogif_finish(self); + } +} + +// Draw the next frame into the destination and report what changed. +static void videogif_step(videogif_gifdecoder_obj_t *self) { + const displayio_area_t canvas = videogif_canvas(self); + const bool whole_frame = self->restart; + displayio_area_t changed = {}; + if (whole_frame) { + self->restart = false; + self->fill_canvas = true; + GIF_reset(self->gif); + } else if (self->disposal == 2) { + // Restore to background before the next frame, if there is one. + self->erase_last_frame = true; + changed = self->frame_area; + } + + self->new_frame = true; + self->gif->iError = GIF_SUCCESS; + int delay_ms; + const bool more = GIF_playFrame(self->gif, &delay_ms, self) == 1; + if (self->gif->iError == GIF_EMPTY_FRAME && !whole_frame) { + // Data after the last frame: the loop is over without another frame, + // so the last frame isn't disposed of and nothing changed. + self->erase_last_frame = false; + videogif_end_loop(self); + return; + } + // A frame missing lines is broken, even when no error is set. + if (self->gif->iError != GIF_SUCCESS || self->gif->iYCount > 0) { + // A broken frame: stop without reporting it. + videogif_finish(self); + return; + } + // In case the frame drew no lines. + videogif_start_frame(self); + if (whole_frame) { + // AnimatedGIF reads the loop count with the first frame. + self->repeat_count = self->gif->iRepeatCount; + } + + const displayio_area_t frame = { + .x1 = self->gif->iX, + .y1 = self->gif->iY, + .x2 = self->gif->iX + self->gif->iWidth, + .y2 = self->gif->iY + self->gif->iHeight, + }; + if (!displayio_area_compute_overlap(&frame, &canvas, &self->frame_area)) { + self->frame_area.x1 = self->frame_area.x2; + } + // The disposal method is bits 2-4 of the graphic control extension's + // packed fields. + self->disposal = (self->gif->ucGIFBits >> 2) & 7; + displayio_area_union(&changed, &self->frame_area, &changed); + if (whole_frame) { + // A rect, not NULL: the next frame may be partial, so this one must + // not be dropped. + changed = canvas; + } + + // Like web browsers, show frames with a delay of 0 or 1 centiseconds for + // 100 ms. AnimatedGIF already reports 0 as 100 ms. Keep to the GIF's + // timing, but when decoding falls behind, leave Python a gap first. + if (delay_ms <= 10) { + delay_ms = 100; + } + self->next_frame_ms = MAX(self->next_frame_ms + delay_ms, supervisor_ticks_ms64() + VIDEOGIF_MIN_GAP_MS); + const videocore_area_t reported = { + .x1 = changed.x1, + .y1 = changed.y1, + .x2 = changed.x2, + .y2 = changed.y2, + }; + self->on_frame(self->on_frame_context, self->destination.base, &reported); + if (!more) { + videogif_end_loop(self); + } +} + +void videogif_background(void) { + if (PLAYING_HEAD == NULL) { + return; + } + const uint64_t now = supervisor_ticks_ms64(); + for (videogif_gifdecoder_obj_t *self = PLAYING_HEAD; self != NULL; self = self->next) { + if (!self->ticking || self->destination.base == NULL || now < self->next_frame_ms) { + continue; + } + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + videogif_step(self); + nlr_pop(); + } else { + // The file can't be read: stop where it is. + videogif_finish(self); + } + } +} + +void videogif_reset(void) { + videogif_gifdecoder_obj_t *self = PLAYING_HEAD; + while (self != NULL) { + videogif_gifdecoder_obj_t *next = self->next; + self->playing = false; + // Forget on_eos too: at gc_deinit the VideoBuffer may be swept first. + const videocore_destination_t none = {}; + self->destination = none; + self->next = NULL; + videogif_update_tick(self); + self = next; + } + PLAYING_HEAD = NULL; +} + +MP_REGISTER_ROOT_POINTER(struct videogif_gifdecoder_obj *videogif_playing_list); diff --git a/shared-module/videogif/GifDecoder.h b/shared-module/videogif/GifDecoder.h new file mode 100644 index 00000000000..3efa7919fd4 --- /dev/null +++ b/shared-module/videogif/GifDecoder.h @@ -0,0 +1,67 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Mikey Sklar for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +#include "py/obj.h" + +#include "lib/AnimatedGIF/AnimatedGIF_circuitpy.h" +#include "shared-module/displayio/area.h" +#include "shared-module/videocore/__init__.h" + +typedef struct videogif_gifdecoder_obj { + mp_obj_base_t base; + // Next decoder in the list of playing decoders that the background task + // steps (see videogif_background). + struct videogif_gifdecoder_obj *next; + // The open GIF file, or NULL once deinitialized. + mp_obj_t file; + // Where frames go, set by videocore.VideoBuffer. base is NULL until the + // first set_destination of a play. + videocore_destination_t destination; + videocore_on_frame_fun on_frame; + void *on_frame_context; + // When the next frame is due, in supervisor milliseconds. + uint64_t next_frame_ms; + // The last frame's rect and disposal method, needed to restore it to the + // background before the next frame when its disposal method is 2. + displayio_area_t frame_area; + // The GIF's NETSCAPE loop count (0 loops forever, -1 when there is none) + // and how many times it has played through. + int32_t repeat_count; + int32_t loops_done; + uint8_t disposal; + bool playing; + bool paused; + // Played its last loop, or stopped on an error: no more frames are due. + bool finished; + // The supervisor tick is enabled for this decoder. + bool ticking; + // Start again from the first frame: on play, at a new destination, on + // resume and after the last frame. + bool restart; + // Before the first line of the next frame, fill the canvas with the + // background color, or restore the last frame's rect to it (disposal + // method 2). See videogif_start_frame. + bool fill_canvas; + bool erase_last_frame; + // The next line drawn is the first of its frame. + bool new_frame; + // AnimatedGIF decoder state, freed by deinit. NULL once deinitialized. + GIFIMAGE *gif; +} videogif_gifdecoder_obj_t; + +// Step every playing decoder whose next frame is due. Runs from the +// supervisor background tick, which is enabled while any decoder has frames +// to show. +void videogif_background(void); + +// Stop every playing decoder without touching its file or destination. Called +// on soft reset, before the displays and their framebuffers go away. +void videogif_reset(void); diff --git a/supervisor/shared/tick.c b/supervisor/shared/tick.c index f53145a7a24..271f78f9d09 100644 --- a/supervisor/shared/tick.c +++ b/supervisor/shared/tick.c @@ -35,6 +35,10 @@ #include "shared-module/_airlift/socketpool.h" #endif +#if CIRCUITPY_VIDEOGIF +#include "shared-module/videogif/GifDecoder.h" +#endif + #include "shared-bindings/microcontroller/__init__.h" #if CIRCUITPY_WATCHDOG @@ -71,6 +75,10 @@ static void supervisor_background_tick(void *unused) { audiofilewriter_background(); #endif + #if CIRCUITPY_VIDEOGIF + videogif_background(); + #endif + port_background_tick(); assert_heap_ok(); From ddcf2b1c61a6c91fa526cdf3d687e46df27da6a6 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Mon, 2 Nov 2026 16:41:13 -0800 Subject: [PATCH 08/14] Update circuitpython.pot after merging main Co-Authored-By: Claude Opus 5.5 --- locale/circuitpython.pot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index 24c9901fa8a..5c541171090 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -1553,7 +1553,7 @@ msgstr "" #: ports/espressif/common-hal/audioio/AudioOut.c #: ports/espressif/common-hal/mipicsi/Camera.c -#: shared-module/videocore/VideoBuffer.c +#: shared-module/videocore/VideoBuffer.c shared-module/videogif/GifDecoder.c msgid "already playing" msgstr "" From be3b5060b344c1ca4550d32f1f543efc92624597 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 3 Nov 2026 16:37:02 -0800 Subject: [PATCH 09/14] videogif: take only a filename and leave gifio unchanged GifDecoder opens the file itself and closes it on deinit. The GIF open helpers move back out of gifio so the two modules stay separate. Co-Authored-By: Claude Opus 5.5 --- locale/circuitpython.pot | 1 + shared-bindings/gifio/OnDiskGif.c | 20 ++++----- shared-bindings/gifio/OnDiskGif.h | 4 -- shared-bindings/videogif/GifDecoder.c | 15 ++++--- shared-bindings/videogif/GifDecoder.h | 5 ++- shared-module/gifio/OnDiskGif.c | 48 +++++++++----------- shared-module/gifio/OnDiskGif.h | 5 --- shared-module/videogif/GifDecoder.c | 65 ++++++++++++++++++++++++++- 8 files changed, 105 insertions(+), 58 deletions(-) diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index 5c541171090..0740a09326b 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -2555,6 +2555,7 @@ msgstr "" #: py/argcheck.c shared-bindings/gifio/GifWriter.c #: shared-module/_airlift/__init__.c shared-module/gifio/OnDiskGif.c +#: shared-module/videogif/GifDecoder.c msgid "%q must be <= %d" msgstr "" diff --git a/shared-bindings/gifio/OnDiskGif.c b/shared-bindings/gifio/OnDiskGif.c index 3c25c7ffab4..0ee0365f5d8 100644 --- a/shared-bindings/gifio/OnDiskGif.c +++ b/shared-bindings/gifio/OnDiskGif.c @@ -15,17 +15,6 @@ #include "shared-bindings/util.h" #include "shared-bindings/gifio/OnDiskGif.h" -mp_obj_t gifio_open_file(mp_obj_t file) { - if (mp_obj_is_str(file)) { - file = mp_call_function_2(MP_OBJ_FROM_PTR(&mp_builtin_open_obj), file, MP_ROM_QSTR(MP_QSTR_rb)); - } - - if (!mp_obj_is_fileio(file)) { - mp_raise_TypeError(MP_ERROR_TEXT("file must be a file opened in byte mode")); - } - return file; -} - //| class OnDiskGif: //| """Loads one frame of a GIF into memory at a time. //| @@ -124,7 +113,14 @@ static mp_obj_t gifio_ondiskgif_make_new(const mp_obj_type_t *type, size_t n_arg 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); - mp_obj_t filename = gifio_open_file(args[ARG_filename].u_obj); + mp_obj_t filename = args[ARG_filename].u_obj; + if (mp_obj_is_str(filename)) { + filename = mp_call_function_2(MP_OBJ_FROM_PTR(&mp_builtin_open_obj), filename, MP_ROM_QSTR(MP_QSTR_rb)); + } + + if (!mp_obj_is_fileio(filename)) { + mp_raise_TypeError(MP_ERROR_TEXT("file must be a file opened in byte mode")); + } gifio_ondiskgif_t *self = mp_obj_malloc_with_finaliser(gifio_ondiskgif_t, &gifio_ondiskgif_type); common_hal_gifio_ondiskgif_construct(self, filename, args[ARG_use_palette].u_bool); diff --git a/shared-bindings/gifio/OnDiskGif.h b/shared-bindings/gifio/OnDiskGif.h index 708e36dd8d6..69833145c06 100644 --- a/shared-bindings/gifio/OnDiskGif.h +++ b/shared-bindings/gifio/OnDiskGif.h @@ -11,10 +11,6 @@ extern const mp_obj_type_t gifio_ondiskgif_type; -// Return file, opened for reading in binary mode when it is a file name. -// Raises TypeError unless the result is a file opened in binary mode. -mp_obj_t gifio_open_file(mp_obj_t file); - void common_hal_gifio_ondiskgif_construct(gifio_ondiskgif_t *self, mp_obj_t file, bool use_palette); uint32_t common_hal_gifio_ondiskgif_get_pixel(gifio_ondiskgif_t *bitmap, diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index 6a97e5724b4..01f67454237 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -4,11 +4,11 @@ // // SPDX-License-Identifier: MIT +#include "py/builtin.h" #include "py/obj.h" #include "py/objproperty.h" #include "py/runtime.h" -#include "shared-bindings/gifio/OnDiskGif.h" #include "shared-bindings/util.h" #include "shared-bindings/videogif/GifDecoder.h" #include "shared/runtime/context_manager_helpers.h" @@ -70,14 +70,14 @@ //| //| def __init__( //| self, -//| file: Union[str, typing.BinaryIO], +//| filename: str, //| *, //| 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 str filename: The name of a GIF file, such as ``"/animation.gif"``. +//| The GifDecoder closes the file when it is deinitialized. //| :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``. @@ -88,16 +88,17 @@ //| ... //| 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_filename, 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_filename, MP_ARG_REQUIRED | MP_ARG_OBJ }, { 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); - mp_obj_t file = gifio_open_file(args[ARG_file].u_obj); + mp_obj_t filename = mp_arg_validate_type_string(args[ARG_filename].u_obj, MP_QSTR_filename); + mp_obj_t file = mp_call_function_2(MP_OBJ_FROM_PTR(&mp_builtin_open_obj), filename, MP_ROM_QSTR(MP_QSTR_rb)); 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); diff --git a/shared-bindings/videogif/GifDecoder.h b/shared-bindings/videogif/GifDecoder.h index a5421c35726..4bdfa17e876 100644 --- a/shared-bindings/videogif/GifDecoder.h +++ b/shared-bindings/videogif/GifDecoder.h @@ -10,11 +10,12 @@ extern const mp_obj_type_t videogif_gifdecoder_type; -// Open the GIF in file (a file opened in binary mode). Frames are RGB565. +// Open the GIF in file, which the constructor opened from its filename. Frames +// are RGB565. // 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); -// Stop playing, free the decoder and drop the file. Safe to call more than +// Stop playing, free the decoder and close 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); diff --git a/shared-module/gifio/OnDiskGif.c b/shared-module/gifio/OnDiskGif.c index 7f561cfd9e3..132220e98ad 100644 --- a/shared-module/gifio/OnDiskGif.c +++ b/shared-module/gifio/OnDiskGif.c @@ -130,32 +130,41 @@ static void GIFDraw(GIFDRAW *pDraw) { } } -void gifio_open_gif(GIFIMAGE *gif, mp_obj_t file) { - gif->iError = GIF_SUCCESS; - gif->pfnRead = GIFReadFile; - gif->pfnSeek = GIFSeekFile; - gif->pfnClose = NULL; - gif->pfnOpen = NULL; - gif->GIFFile.fHandle = MP_OBJ_TO_PTR(file); +void common_hal_gifio_ondiskgif_construct(gifio_ondiskgif_t *self, mp_obj_t file, bool use_palette) { + self->file = file; + + if (use_palette == true) { + GIF_begin(&self->gif, GIF_PALETTE_RGB888); + } else { + GIF_begin(&self->gif, GIF_PALETTE_RGB565_BE); + } + + self->gif.iError = GIF_SUCCESS; + self->gif.pfnRead = GIFReadFile; + self->gif.pfnSeek = GIFSeekFile; + self->gif.pfnDraw = GIFDraw; + self->gif.pfnClose = NULL; + self->gif.pfnOpen = NULL; + self->gif.GIFFile.fHandle = MP_OBJ_TO_PTR(self->file); int errcode = 0; - mp_stream_seek(file, 0, MP_SEEK_SET, &errcode); + mp_stream_seek(self->file, 0, MP_SEEK_SET, &errcode); if (errcode != 0) { mp_raise_OSError(errcode); } - mp_off_t size = mp_stream_seek(file, 0, MP_SEEK_END, &errcode); + mp_off_t size = mp_stream_seek(self->file, 0, MP_SEEK_END, &errcode); if (size < 0) { mp_raise_OSError(errcode); } - mp_stream_seek(file, 0, MP_SEEK_SET, &errcode); + mp_stream_seek(self->file, 0, MP_SEEK_SET, &errcode); if (errcode != 0) { mp_raise_OSError(errcode); } - gif->GIFFile.iSize = (int32_t)size; + self->gif.GIFFile.iSize = (int32_t)size; - int result = GIF_init(gif); + int result = GIF_init(&self->gif); if (result != 1) { - switch (gif->iError) { + switch (self->gif.iError) { case GIF_TOO_WIDE: mp_raise_ValueError_varg(MP_ERROR_TEXT("%q must be <= %d"), MP_QSTR_width, MAX_WIDTH); break; @@ -164,19 +173,6 @@ void gifio_open_gif(GIFIMAGE *gif, mp_obj_t file) { break; } } -} - -void common_hal_gifio_ondiskgif_construct(gifio_ondiskgif_t *self, mp_obj_t file, bool use_palette) { - self->file = file; - - if (use_palette == true) { - GIF_begin(&self->gif, GIF_PALETTE_RGB888); - } else { - GIF_begin(&self->gif, GIF_PALETTE_RGB565_BE); - } - - self->gif.pfnDraw = GIFDraw; - gifio_open_gif(&self->gif, self->file); int bpp = 16; if (use_palette == true) { diff --git a/shared-module/gifio/OnDiskGif.h b/shared-module/gifio/OnDiskGif.h index 8c33a321302..808b6baa89f 100644 --- a/shared-module/gifio/OnDiskGif.h +++ b/shared-module/gifio/OnDiskGif.h @@ -26,8 +26,3 @@ typedef struct { int32_t min_delay; int32_t max_delay; } gifio_ondiskgif_t; - -// Point gif, set up by GIF_begin, at file (a stream object) and read the GIF -// header through it. Raises when the file can't be read, isn't a GIF or is -// wider than MAX_WIDTH. -void gifio_open_gif(GIFIMAGE *gif, mp_obj_t file); diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index 57c52bddb4f..652fc8d1834 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -10,7 +10,7 @@ #include "py/mpstate.h" #include "py/runtime.h" -#include "shared-module/gifio/OnDiskGif.h" +#include "py/stream.h" #include "supervisor/port_heap.h" #include "supervisor/shared/tick.h" @@ -87,6 +87,64 @@ static void videogif_draw_line(GIFDRAW *draw) { } } +static int32_t videogif_read_file(GIFFILE *gif_file, uint8_t *buffer, int32_t length) { + // A read that would reach the end stops one byte short, the same as gifio: + // seeking stops working once the last byte is read. + if (gif_file->iSize - gif_file->iPos < length) { + length = gif_file->iSize - gif_file->iPos - 1; + } + if (length <= 0) { + return 0; + } + int errcode = 0; + mp_uint_t bytes_read = mp_stream_rw(MP_OBJ_FROM_PTR(gif_file->fHandle), buffer, length, + &errcode, MP_STREAM_RW_ONCE); + if (errcode != 0) { + mp_raise_OSError(errcode); + } + gif_file->iPos += bytes_read; + return bytes_read; +} + +static int32_t videogif_seek_file(GIFFILE *gif_file, int32_t position) { + int errcode = 0; + mp_off_t new_position = mp_stream_seek(MP_OBJ_FROM_PTR(gif_file->fHandle), position, + MP_SEEK_SET, &errcode); + if (new_position < 0) { + mp_raise_OSError(errcode); + } + gif_file->iPos = new_position; + return new_position; +} + +// Read the GIF header from file, which is already open. +static void videogif_open_gif(GIFIMAGE *gif, mp_obj_t file) { + gif->iError = GIF_SUCCESS; + gif->pfnRead = videogif_read_file; + gif->pfnSeek = videogif_seek_file; + gif->pfnClose = NULL; + gif->pfnOpen = NULL; + gif->GIFFile.fHandle = MP_OBJ_TO_PTR(file); + + int errcode = 0; + mp_off_t size = mp_stream_seek(file, 0, MP_SEEK_END, &errcode); + if (size < 0) { + mp_raise_OSError(errcode); + } + mp_stream_seek(file, 0, MP_SEEK_SET, &errcode); + if (errcode != 0) { + mp_raise_OSError(errcode); + } + gif->GIFFile.iSize = (int32_t)size; + + if (GIF_init(gif) != 1) { + if (gif->iError == GIF_TOO_WIDE) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q must be <= %d"), MP_QSTR_width, MAX_WIDTH); + } + mp_arg_error_invalid(MP_QSTR_filename); + } +} + 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; @@ -100,7 +158,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; - gifio_open_gif(self->gif, file); + videogif_open_gif(self->gif, file); } bool common_hal_videogif_gifdecoder_deinited(videogif_gifdecoder_obj_t *self) { @@ -122,6 +180,9 @@ void common_hal_videogif_gifdecoder_deinit(videogif_gifdecoder_obj_t *self) { port_free(self->gif); self->gif = NULL; } + if (self->file != NULL) { + mp_stream_close(self->file); + } self->file = NULL; } From 8b663e8d0d844d70c39a1b413c4a2c596615e390 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 3 Nov 2026 16:43:50 -0800 Subject: [PATCH 10/14] videocore: rename on_eos to on_stream_end Co-Authored-By: Claude Opus 5.5 --- shared-module/videocore/VideoBuffer.c | 8 ++++---- shared-module/videocore/__init__.h | 4 ++-- shared-module/videogif/GifDecoder.c | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 72b669269db..4145e382944 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -94,7 +94,7 @@ static void videocore_videobuffer_on_frame(void *ctx, const void *frame, const v // Called from interrupt context or a background task when the source reached // the end of its stream. Only flag it: end_stream stops the source later, the // way audio outputs stop a finished sample. -static void videocore_videobuffer_on_eos(void *ctx) { +static void videocore_videobuffer_on_stream_end(void *ctx) { videocore_videobuffer_obj_t *self = ctx; self->source_ended = true; } @@ -171,7 +171,7 @@ static void videocore_videobuffer_compute_destination( dest->stride = row_bytes; dest->row_bytes = row_bytes; dest->height = self->source_height; - dest->on_eos = videocore_videobuffer_on_eos; + dest->on_stream_end = videocore_videobuffer_on_stream_end; if (self->bufinfo.len < self->source_frame_bytes) { mp_raise_ValueError(MP_ERROR_TEXT("Buffer too small")); } @@ -208,7 +208,7 @@ static void videocore_videobuffer_compute_destination( dest->stride = row_stride; dest->row_bytes = row_bytes; dest->height = self->source_height; - dest->on_eos = videocore_videobuffer_on_eos; + dest->on_stream_end = videocore_videobuffer_on_stream_end; } // Move the framebuffer destination for a new absolute position, capping the @@ -260,7 +260,7 @@ static void videocore_videobuffer_move_dest( .stride = row_stride, .row_bytes = row_bytes, .height = self->source_height, - .on_eos = videocore_videobuffer_on_eos, + .on_stream_end = videocore_videobuffer_on_stream_end, }; // A rejected destination leaves the stream where it was. if (videosource_set_destination(self->source, &dest, videocore_videobuffer_on_frame, self)) { diff --git a/shared-module/videocore/__init__.h b/shared-module/videocore/__init__.h index 68dfde0b21c..5d0846a89ea 100644 --- a/shared-module/videocore/__init__.h +++ b/shared-module/videocore/__init__.h @@ -15,7 +15,7 @@ // Called from interrupt context or a background task, with the ctx given to // set_destination, when the source will write no more frames. -typedef void (*videocore_on_eos_fun)(void *ctx); +typedef void (*videocore_on_stream_end_fun)(void *ctx); // Destination for a video stream. The caller checks that the whole frame // fits before handing it to a source. @@ -29,7 +29,7 @@ typedef struct { // Source frame height, in rows. size_t height; // Never NULL. Sources that never end, such as a camera, don't call it. - videocore_on_eos_fun on_eos; + videocore_on_stream_end_fun on_stream_end; } videocore_destination_t; // A rect in source frame coordinates. x2 and y2 are exclusive. diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index 652fc8d1834..af860f3023f 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -167,9 +167,9 @@ bool common_hal_videogif_gifdecoder_deinited(videogif_gifdecoder_obj_t *self) { // Tell the destination no more frames are coming, once per play. static void videogif_end_stream(videogif_gifdecoder_obj_t *self) { - if (self->destination.on_eos != NULL) { - self->destination.on_eos(self->on_frame_context); - self->destination.on_eos = NULL; + if (self->destination.on_stream_end != NULL) { + self->destination.on_stream_end(self->on_frame_context); + self->destination.on_stream_end = NULL; } } @@ -390,7 +390,7 @@ void videogif_reset(void) { while (self != NULL) { videogif_gifdecoder_obj_t *next = self->next; self->playing = false; - // Forget on_eos too: at gc_deinit the VideoBuffer may be swept first. + // Forget on_stream_end too: at gc_deinit the VideoBuffer may be swept first. const videocore_destination_t none = {}; self->destination = none; self->next = NULL; From c5c9284628343ed0a7ebb7d3e1d72cf64fe91007 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 3 Nov 2026 16:46:49 -0800 Subject: [PATCH 11/14] videocore: report changed areas as displayio areas Co-Authored-By: Claude Opus 5.5 --- shared-module/videocore/VideoBuffer.c | 10 ++-------- shared-module/videocore/__init__.h | 11 ++++------- shared-module/videogif/GifDecoder.c | 8 +------- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 4145e382944..82d41f7b284 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -57,7 +57,7 @@ static void videocore_videobuffer_add_vacated_frame(videocore_videobuffer_obj_t // Called from interrupt context or a background task when the source lands a // frame, so keep it tiny. -static void videocore_videobuffer_on_frame(void *ctx, const void *frame, const videocore_area_t *changed) { +static void videocore_videobuffer_on_frame(void *ctx, const void *frame, const displayio_area_t *changed) { videocore_videobuffer_obj_t *self = ctx; if (frame == self->stream_base) { const displayio_area_t whole_frame = { @@ -69,14 +69,8 @@ static void videocore_videobuffer_on_frame(void *ctx, const void *frame, const v self->frame_replaceable = true; } else { // Keep a bad source rect from dirtying other layers. - const displayio_area_t reported = { - .x1 = changed->x1, - .y1 = changed->y1, - .x2 = changed->x2, - .y2 = changed->y2, - }; displayio_area_t clipped; - if (!displayio_area_compute_overlap(&reported, &whole_frame, &clipped)) { + if (!displayio_area_compute_overlap(changed, &whole_frame, &clipped)) { return; } displayio_area_union(&self->frame_changed_area, &clipped, &self->frame_changed_area); diff --git a/shared-module/videocore/__init__.h b/shared-module/videocore/__init__.h index 5d0846a89ea..189cdad6964 100644 --- a/shared-module/videocore/__init__.h +++ b/shared-module/videocore/__init__.h @@ -12,6 +12,7 @@ #include "py/obj.h" #include "py/proto.h" +#include "shared-module/displayio/area.h" // Called from interrupt context or a background task, with the ctx given to // set_destination, when the source will write no more frames. @@ -32,20 +33,16 @@ typedef struct { videocore_on_stream_end_fun on_stream_end; } videocore_destination_t; -// A rect in source frame coordinates. x2 and y2 are exclusive. -typedef struct { - int16_t x1, y1, x2, y2; -} videocore_area_t; - // Called from interrupt context or a background task when a frame has landed. // frame is the destination base it landed at: usually the latest accepted // destination, but a frame already being written when the destination changed // (or when the source was paused) still lands at the previous one. changed is -// the part that differs from the previous frame at that destination. NULL means +// the part that differs from the previous frame at that destination, in source +// frame coordinates. NULL means // the whole frame changed and the next frame repaints all of it, so this one may // be dropped. A source whose next frame may be partial reports the whole frame // as a rect instead. The first frame at a new destination must cover it all. -typedef void (*videocore_on_frame_fun)(void *ctx, const void *frame, const videocore_area_t *changed); +typedef void (*videocore_on_frame_fun)(void *ctx, const void *frame, const displayio_area_t *changed); // Protocol implemented by video streamers such as mipicsi.Camera. A source // streams continuously while playing (unless paused); the target sets (and can diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index af860f3023f..2f374c5f11d 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -353,13 +353,7 @@ static void videogif_step(videogif_gifdecoder_obj_t *self) { delay_ms = 100; } self->next_frame_ms = MAX(self->next_frame_ms + delay_ms, supervisor_ticks_ms64() + VIDEOGIF_MIN_GAP_MS); - const videocore_area_t reported = { - .x1 = changed.x1, - .y1 = changed.y1, - .x2 = changed.x2, - .y2 = changed.y2, - }; - self->on_frame(self->on_frame_context, self->destination.base, &reported); + self->on_frame(self->on_frame_context, self->destination.base, &changed); if (!more) { videogif_end_loop(self); } From 63e26e802475634081c56bc1fbee1e4e73ffea2b Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 3 Nov 2026 16:54:56 -0800 Subject: [PATCH 12/14] videogif: stop instead of replaying when moved or shown again Once a GIF has shown a frame, a new destination or a resume ends the stream, so playing goes False and play() starts it again. The first destination of a play still starts the GIF. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videogif/GifDecoder.c | 6 ++++-- shared-module/videogif/GifDecoder.c | 23 +++++++++++++++++------ shared-module/videogif/GifDecoder.h | 7 +++++-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index 01f67454237..ee1835a5fab 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -37,8 +37,10 @@ //| the animation slows down so your Python code still gets time to run. //| //| The GIF starts again from its first frame, filled with its background -//| color, at each loop and whenever the VideoBuffer showing it directly -//| on a display is moved or shown again. +//| color, at each loop. When a VideoBuffer that draws straight into a +//| framebuffer is moved, or hidden and shown again, the GIF stops and +//| `videocore.VideoBuffer.playing` becomes False. Call +//| `videocore.VideoBuffer.play()` to start it again. //| //| When a frame asks for its area to be cleared after it is shown (GIF //| disposal method 2), that area is filled with the background color. diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index 2f374c5f11d..da10dd5ae22 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -230,6 +230,7 @@ void common_hal_videogif_gifdecoder_play(videogif_gifdecoder_obj_t *self, bool p } self->playing = true; self->paused = paused; + self->shown = false; self->loops_done = 0; const videocore_destination_t none = {}; self->destination = none; @@ -260,21 +261,30 @@ void common_hal_videogif_gifdecoder_pause(videogif_gifdecoder_obj_t *self) { } void common_hal_videogif_gifdecoder_resume(videogif_gifdecoder_obj_t *self) { - // The destination may have been drawn over while paused. self->paused = false; - videogif_restart(self); + if (self->shown) { + // The destination may have been drawn over while paused, and frames + // only draw what changed, so end rather than draw over a stale image. + videogif_finish(self); + } else { + videogif_restart(self); + } } bool common_hal_videogif_gifdecoder_set_destination(videogif_gifdecoder_obj_t *self, const videocore_destination_t *destination, videocore_on_frame_fun on_frame, void *context) { // A new destination doesn't hold the previous frame to draw the next one - // over, so start again from the first frame. - if (destination->base != self->destination.base || destination->stride != self->destination.stride) { - videogif_restart(self); - } + // over. The first one of a play starts the GIF; a later one ends it. + const bool moved = destination->base != self->destination.base || + destination->stride != self->destination.stride; self->destination = *destination; self->on_frame = on_frame; self->on_frame_context = context; + if (moved && self->shown) { + videogif_finish(self); + } else if (moved) { + videogif_restart(self); + } return true; } @@ -354,6 +364,7 @@ static void videogif_step(videogif_gifdecoder_obj_t *self) { } self->next_frame_ms = MAX(self->next_frame_ms + delay_ms, supervisor_ticks_ms64() + VIDEOGIF_MIN_GAP_MS); self->on_frame(self->on_frame_context, self->destination.base, &changed); + self->shown = true; if (!more) { videogif_end_loop(self); } diff --git a/shared-module/videogif/GifDecoder.h b/shared-module/videogif/GifDecoder.h index 3efa7919fd4..0acb093fce5 100644 --- a/shared-module/videogif/GifDecoder.h +++ b/shared-module/videogif/GifDecoder.h @@ -43,8 +43,11 @@ typedef struct videogif_gifdecoder_obj { bool finished; // The supervisor tick is enabled for this decoder. bool ticking; - // Start again from the first frame: on play, at a new destination, on - // resume and after the last frame. + // A frame has been shown since play. After that, a new destination or a + // resume ends the stream instead of starting the GIF again. + bool shown; + // Start again from the first frame: on play, at the first destination or + // resume of a play, and after the last frame. bool restart; // Before the first line of the next frame, fill the canvas with the // background color, or restore the last frame's rect to it (disposal From 2cc6ae22d94e84eade66355205c25596af6f9295 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 3 Nov 2026 17:10:15 -0800 Subject: [PATCH 13/14] videogif: take the byte order from the display, not an argument A framebuffer target passes its byte order to the source with the destination. A plain buffer target gets the source's native byte order and VideoBuffer swaps the bytes as it copies to a display that needs it. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videogif/GifDecoder.c | 18 +++++------------- shared-bindings/videogif/GifDecoder.h | 3 +-- shared-module/videocore/VideoBuffer.c | 16 ++++++++++++---- shared-module/videocore/__init__.h | 2 ++ shared-module/videogif/GifDecoder.c | 10 ++++++---- 5 files changed, 26 insertions(+), 23 deletions(-) diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index ee1835a5fab..3c2ced53221 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -56,10 +56,7 @@ //| import videocore //| import videogif //| -//| # Match your display's byte order. A BusDisplay usually swaps the -//| # bytes of each pixel (True). Framebuffer displays such as -//| # mipidsi.Display don't (False). -//| gif = videogif.GifDecoder("/animation.gif", reverse_bytes_in_word=False) +//| gif = videogif.GifDecoder("/animation.gif") //| video = videocore.VideoBuffer(bytearray(gif.width * gif.height * 2)) //| video.play(gif) //| @@ -73,27 +70,22 @@ //| def __init__( //| self, //| filename: str, -//| *, -//| reverse_bytes_in_word: bool = False, //| ) -> None: //| """Open a GIF file so it can be played. //| //| :param str filename: The name of a GIF file, such as ``"/animation.gif"``. //| The GifDecoder closes the file when it is deinitialized. -//| :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``. //| -//| Frames are RGB565. Raises `ValueError` if the file is not a GIF or if +//| Frames are RGB565, in the byte order of the display or framebuffer +//| that shows them. Raises `ValueError` 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_filename, ARG_reverse_bytes_in_word, NUM_ARGS }; + enum { ARG_filename, NUM_ARGS }; static const mp_arg_t allowed_args[] = { { MP_QSTR_filename, MP_ARG_REQUIRED | MP_ARG_OBJ }, - { 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)]; @@ -103,7 +95,7 @@ static mp_obj_t videogif_gifdecoder_make_new(const mp_obj_type_t *type, size_t n mp_obj_t file = mp_call_function_2(MP_OBJ_FROM_PTR(&mp_builtin_open_obj), filename, MP_ROM_QSTR(MP_QSTR_rb)); 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); return MP_OBJ_FROM_PTR(self); } diff --git a/shared-bindings/videogif/GifDecoder.h b/shared-bindings/videogif/GifDecoder.h index 4bdfa17e876..d7ac18e7ab7 100644 --- a/shared-bindings/videogif/GifDecoder.h +++ b/shared-bindings/videogif/GifDecoder.h @@ -13,8 +13,7 @@ extern const mp_obj_type_t videogif_gifdecoder_type; // Open the GIF in file, which the constructor opened from its filename. Frames // are RGB565. // 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); +void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file); // Stop playing, free the decoder and close the file. Safe to call more than // once. void common_hal_videogif_gifdecoder_deinit(videogif_gifdecoder_obj_t *self); diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 82d41f7b284..312438e2ce1 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -165,6 +165,8 @@ static void videocore_videobuffer_compute_destination( dest->stride = row_bytes; dest->row_bytes = row_bytes; dest->height = self->source_height; + // draw_fill_area swaps the bytes for displays that need it. + dest->reverse_bytes_in_word = false; dest->on_stream_end = videocore_videobuffer_on_stream_end; if (self->bufinfo.len < self->source_frame_bytes) { mp_raise_ValueError(MP_ERROR_TEXT("Buffer too small")); @@ -202,6 +204,7 @@ static void videocore_videobuffer_compute_destination( dest->stride = row_stride; dest->row_bytes = row_bytes; dest->height = self->source_height; + dest->reverse_bytes_in_word = FB_GETTER(get_reverse_pixels_in_word, false); dest->on_stream_end = videocore_videobuffer_on_stream_end; } @@ -254,6 +257,7 @@ static void videocore_videobuffer_move_dest( .stride = row_stride, .row_bytes = row_bytes, .height = self->source_height, + .reverse_bytes_in_word = FB_GETTER(get_reverse_pixels_in_word, false), .on_stream_end = videocore_videobuffer_on_stream_end, }; // A rejected destination leaves the stream where it was. @@ -673,10 +677,14 @@ static bool videocore_videobuffer_draw_fill_area(mp_obj_t self_in, const _displa for (int16_t y = overlap.y1; y < overlap.y2; y++) { const size_t px = (size_t)(y - area->y1) * span_px + (overlap.x1 - area->x1); const int16_t fy = y - self->current_area.y1; - memcpy( - (uint16_t *)buffer + px, - frame + (size_t)fy * row_bytes + (size_t)(overlap.x1 - self->current_area.x1) * bytes_per_pixel, - overlap_width * bytes_per_pixel); + uint16_t *out = (uint16_t *)buffer + px; + const uint8_t *in = frame + (size_t)fy * row_bytes + (size_t)(overlap.x1 - self->current_area.x1) * bytes_per_pixel; + memcpy(out, in, overlap_width * bytes_per_pixel); + if (colorspace->reverse_bytes_in_word) { + for (size_t i = 0; i < overlap_width; i++) { + out[i] = __builtin_bswap16(out[i]); + } + } } return full_coverage; } diff --git a/shared-module/videocore/__init__.h b/shared-module/videocore/__init__.h index 189cdad6964..f07c81edae9 100644 --- a/shared-module/videocore/__init__.h +++ b/shared-module/videocore/__init__.h @@ -29,6 +29,8 @@ typedef struct { size_t row_bytes; // Source frame height, in rows. size_t height; + // 16-bit pixels go high byte first instead of low byte first. + bool reverse_bytes_in_word; // Never NULL. Sources that never end, such as a camera, don't call it. videocore_on_stream_end_fun on_stream_end; } videocore_destination_t; diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index da10dd5ae22..d562b7260d5 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -145,9 +145,7 @@ static void videogif_open_gif(GIFIMAGE *gif, mp_obj_t file) { } } -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; +void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file) { // 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. @@ -155,7 +153,7 @@ void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, m if (self->gif == NULL) { m_malloc_fail(sizeof(GIFIMAGE)); } - GIF_begin(self->gif, palette_type); + GIF_begin(self->gif, GIF_PALETTE_RGB565_LE); self->gif->pfnDraw = videogif_draw_line; self->file = file; videogif_open_gif(self->gif, file); @@ -283,6 +281,10 @@ bool common_hal_videogif_gifdecoder_set_destination(videogif_gifdecoder_obj_t *s if (moved && self->shown) { videogif_finish(self); } else if (moved) { + // Restarting reads the GIF header again, which converts the global + // color table to this byte order. + self->gif->ucPaletteType = destination->reverse_bytes_in_word ? + GIF_PALETTE_RGB565_BE : GIF_PALETTE_RGB565_LE; videogif_restart(self); } return true; From 22625458f0802e1016af83ac39628bb4370b94d0 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Wed, 4 Nov 2026 11:57:38 -0800 Subject: [PATCH 14/14] videogif: catch up on late frames; say where play() starts When background calls come late, decode up to 3 due frames in one call and redraw them once. Still behind after that, slow down as before. The docstring now says play() always starts at the first frame, that there is no pause and resume, and what hiding and moving do. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videogif/GifDecoder.c | 15 ++++++++++----- shared-module/videogif/GifDecoder.c | 20 ++++++++++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index 3c2ced53221..57a912bf6d7 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -33,14 +33,19 @@ //| a frame or the file can't be read. //| //| Frames are decoded in the background, in short breaks between your -//| Python code. If a frame takes longer to decode than its frame time, +//| Python code. When those breaks come late, up to 3 late frames are +//| decoded in one break to catch up. If it is still behind after that, //| the animation slows down so your Python code still gets time to run. //| //| The GIF starts again from its first frame, filled with its background -//| color, at each loop. When a VideoBuffer that draws straight into a -//| framebuffer is moved, or hidden and shown again, the GIF stops and -//| `videocore.VideoBuffer.playing` becomes False. Call -//| `videocore.VideoBuffer.play()` to start it again. +//| color, at each loop. `videocore.VideoBuffer.play()` always starts it +//| from its first frame. There is no pause and resume. +//| +//| When a VideoBuffer that draws straight into a framebuffer is hidden, +//| the GIF waits and its time does not advance. When the VideoBuffer is +//| shown again or moved, the GIF stops and `videocore.VideoBuffer.playing` +//| becomes False, since the screen under it may have been drawn over. +//| Call `videocore.VideoBuffer.play()` to start it again. //| //| When a frame asks for its area to be cleared after it is shown (GIF //| disposal method 2), that area is filled with the background color. diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index d562b7260d5..0b264d212b7 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -20,6 +20,10 @@ // decoding falls behind the GIF's delays. #define VIDEOGIF_MIN_GAP_MS 5 +// Late frames decoded in one background call to catch up. Each one holds up +// USB and audio for one decode. +#define VIDEOGIF_MAX_LATE_FRAMES 3 + static inline void videogif_put_pixel(uint8_t *row, int x, uint16_t value) { memcpy(row + x * 2, &value, 2); } @@ -359,12 +363,13 @@ static void videogif_step(videogif_gifdecoder_obj_t *self) { } // Like web browsers, show frames with a delay of 0 or 1 centiseconds for - // 100 ms. AnimatedGIF already reports 0 as 100 ms. Keep to the GIF's - // timing, but when decoding falls behind, leave Python a gap first. + // 100 ms. AnimatedGIF already reports 0 as 100 ms. if (delay_ms <= 10) { delay_ms = 100; } - self->next_frame_ms = MAX(self->next_frame_ms + delay_ms, supervisor_ticks_ms64() + VIDEOGIF_MIN_GAP_MS); + self->next_frame_ms += delay_ms; + // Only marks the area: frames decoded in one background call are redrawn + // once, together. self->on_frame(self->on_frame_context, self->destination.base, &changed); self->shown = true; if (!more) { @@ -383,7 +388,14 @@ void videogif_background(void) { } nlr_buf_t nlr; if (nlr_push(&nlr) == 0) { - videogif_step(self); + // Keep to the GIF's timing: decode the frames that are due, up to + // a limit. Still behind after that, fall behind and leave Python a + // gap first. + for (int i = 0; i < VIDEOGIF_MAX_LATE_FRAMES && self->ticking && + now >= self->next_frame_ms; i++) { + videogif_step(self); + } + self->next_frame_ms = MAX(self->next_frame_ms, supervisor_ticks_ms64() + VIDEOGIF_MIN_GAP_MS); nlr_pop(); } else { // The file can't be read: stop where it is.