diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index 9f438cd37c3..1ceb67a9475 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 "" @@ -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/main.c b/main.c index a80391af6df..1ffc9f953d8 100644 --- a/main.c +++ b/main.c @@ -97,6 +97,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 @@ -392,6 +396,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/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/py/circuitpy_defns.mk b/py/circuitpy_defns.mk index 74f18588f21..0eaa5d85dc3 100755 --- a/py/circuitpy_defns.mk +++ b/py/circuitpy_defns.mk @@ -510,6 +510,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 @@ -719,6 +722,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 \ @@ -926,6 +930,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 e3ec3618e00..021a923ab78 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -860,6 +860,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/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-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index 34b9a623d42..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) { @@ -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-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..57a912bf6d7 --- /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/builtin.h" +#include "py/obj.h" +#include "py/objproperty.h" +#include "py/runtime.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. 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. `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. +//| 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 +//| +//| gif = videogif.GifDecoder("/animation.gif") +//| 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, +//| filename: str, +//| ) -> 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. +//| +//| 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, NUM_ARGS }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_filename, MP_ARG_REQUIRED | MP_ARG_OBJ }, + }; + 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 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); + 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..d7ac18e7ab7 --- /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, 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); +// 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); +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/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index e86d1a51e8e..312438e2ce1 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -55,11 +55,27 @@ 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 displayio_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. + displayio_area_t clipped; + if (!displayio_area_compute_overlap(changed, &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 +85,23 @@ static void videocore_videobuffer_on_frame(void *ctx, const void *frame) { 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_stream_end(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 = {}; + 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) { @@ -102,14 +135,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. @@ -117,6 +165,9 @@ 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")); } @@ -142,7 +193,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")); } @@ -153,6 +204,8 @@ 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; } // Move the framebuffer destination for a new absolute position, capping the @@ -169,8 +222,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); @@ -183,7 +235,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; } @@ -204,6 +257,8 @@ 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. if (videosource_set_destination(self->source, &dest, videocore_videobuffer_on_frame, self)) { @@ -242,10 +297,14 @@ 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; 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 @@ -253,15 +312,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")); @@ -318,7 +374,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 +399,22 @@ 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); +} + +// 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 @@ -353,11 +424,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) { @@ -370,41 +447,61 @@ 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); - 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. - self->frame_ready = false; - 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); } } 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")); } + 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); 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->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 @@ -433,7 +530,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; } @@ -469,10 +566,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; } @@ -487,7 +586,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; } @@ -578,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; } @@ -633,6 +736,13 @@ 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); + } else if (!self->playing) { + // A repaint overwrites an ended stream's last frame. + self->stream_base = NULL; + } } // Framebuffer targets stream straight into the buffer: move the @@ -649,21 +759,31 @@ 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); + } + videocore_videobuffer_end_stream(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. @@ -672,13 +792,14 @@ 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 (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; } @@ -692,6 +813,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 e65a357965e..9f48cc001e2 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 @@ -65,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 09ab9e74fc3..f07c81edae9 100644 --- a/shared-module/videocore/__init__.h +++ b/shared-module/videocore/__init__.h @@ -12,6 +12,11 @@ #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. +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. @@ -24,13 +29,22 @@ 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; -// 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); +// 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, 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 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 @@ -64,9 +78,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); diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c new file mode 100644 index 00000000000..0b264d212b7 --- /dev/null +++ b/shared-module/videogif/GifDecoder.c @@ -0,0 +1,422 @@ +// 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 "py/stream.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 + +// 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); +} + +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]); + } +} + +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) { + // 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, GIF_PALETTE_RGB565_LE); + self->gif->pfnDraw = videogif_draw_line; + self->file = file; + videogif_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_stream_end != NULL) { + self->destination.on_stream_end(self->on_frame_context); + self->destination.on_stream_end = 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; + } + if (self->file != NULL) { + mp_stream_close(self->file); + } + 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->shown = false; + 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) { + self->paused = false; + 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. 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) { + // 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; +} + +// 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. + if (delay_ms <= 10) { + delay_ms = 100; + } + 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) { + 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) { + // 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. + 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_stream_end 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..0acb093fce5 --- /dev/null +++ b/shared-module/videogif/GifDecoder.h @@ -0,0 +1,70 @@ +// 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; + // 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 + // 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 603146d3643..86eda75e436 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 + #if CIRCUITPY_WEB_WORKFLOW #include "supervisor/shared/web_workflow/display_stream.h" #endif @@ -75,6 +79,10 @@ static void supervisor_background_tick(void *unused) { audiofilewriter_background(); #endif + #if CIRCUITPY_VIDEOGIF + videogif_background(); + #endif + port_background_tick(); assert_heap_ok();