From 6e915426f76106ec24feb548618d17c522988fe9 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 08:08:28 -0700 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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 ""