Skip to content

Play animated GIF files as video - #11718

Closed
mikeysklar wants to merge 11 commits into
adafruit:mainfrom
mikeysklar:video-gif
Closed

mikeysklar wants to merge 11 commits into
adafruit:mainfrom
mikeysklar:video-gif

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

videogif.GifDecoder plays a GIF into a videocore.VideoBuffer.

Why

GIFs store which area changed and how long each frame lasts, so slow displays can redraw less.

Part 1 of 3 toward GIF video on e-paper, followed by #11719 and #11720. Merges first, needs no other PR.

gif = videogif.GifDecoder("/animation.gif")
video = videocore.VideoBuffer(bytearray(gif.width * gif.height * 2))
root.append(video)
video.play(gif)
p4_7in_gif_plainbuffer_landscape.mp4

Metro ESP32-P4 with Raspberry Pi Touch Display 2, filmed on a dev build of all 3 PRs. The teapot is teapot_1bit_320.gif (320x240, 120 frames), decoded at 16 bit into a plain buffer.

Clip code: code.py
CASE = "inf"

import time
import displayio
import framebufferio
import mipidsi

_INIT_SEQUENCE = bytes.fromhex(
    "ff 03 98 81 03 01 01 00 02 01 00 03 01 73 04 01 00 05 01 00 06 01 0a 07"
    "01 00 08 01 00 09 01 61 0a 01 00 0b 01 00 0c 01 01 0d 01 00 0e 01 00 0f"
    "01 61 10 01 61 11 01 00 12 01 00 13 01 00 14 01 00 15 01 00 16 01 00 17"
    "01 00 18 01 00 19 01 00 1a 01 00 1b 01 00 1c 01 00 1d 01 00 1e 01 40 1f"
    "01 80 20 01 06 21 01 01 22 01 00 23 01 00 24 01 00 25 01 00 26 01 00 27"
    "01 00 28 01 33 29 01 03 2a 01 00 2b 01 00 2c 01 00 2d 01 00 2e 01 00 2f"
    "01 00 30 01 00 31 01 00 32 01 00 33 01 00 34 01 04 35 01 00 36 01 00 37"
    "01 00 38 01 3c 39 01 00 3a 01 00 3b 01 00 3c 01 00 3d 01 00 3e 01 00 3f"
    "01 00 40 01 00 41 01 00 42 01 00 43 01 00 44 01 00 50 01 10 51 01 32 52"
    "01 54 53 01 76 54 01 98 55 01 ba 56 01 10 57 01 32 58 01 54 59 01 76 5a"
    "01 98 5b 01 ba 5c 01 dc 5d 01 fe 5e 01 00 5f 01 0e 60 01 0f 61 01 0c 62"
    "01 0d 63 01 06 64 01 07 65 01 02 66 01 02 67 01 02 68 01 02 69 01 01 6a"
    "01 00 6b 01 02 6c 01 15 6d 01 14 6e 01 02 6f 01 02 70 01 02 71 01 02 72"
    "01 02 73 01 02 74 01 02 75 01 0e 76 01 0f 77 01 0c 78 01 0d 79 01 06 7a"
    "01 07 7b 01 02 7c 01 02 7d 01 02 7e 01 02 7f 01 01 80 01 00 81 01 02 82"
    "01 14 83 01 15 84 01 02 85 01 02 86 01 02 87 01 02 88 01 02 89 01 02 8a"
    "01 02 ff 03 98 81 04 6c 01 15 6e 01 2a 6f 01 33 3b 01 98 3a 01 94 8d 01"
    "14 87 01 ba 26 01 76 b2 01 d1 b5 01 06 38 01 01 39 01 00 ff 03 98 81 01"
    "22 01 0a 31 01 00 53 01 7d 55 01 8f 40 01 33 50 01 96 51 01 96 60 01 23"
    "a0 01 08 a1 01 1d a2 01 2a a3 01 10 a4 01 15 a5 01 28 a6 01 1c a7 01 1d"
    "a8 01 7e a9 01 1d aa 01 29 ab 01 6b ac 01 1a ad 01 18 ae 01 4b af 01 20"
    "b0 01 27 b1 01 50 b2 01 64 b3 01 39 c0 01 08 c1 01 1d c2 01 2a c3 01 10"
    "c4 01 15 c5 01 28 c6 01 1c c7 01 1d c8 01 7e c9 01 1d ca 01 29 cb 01 6b"
    "cc 01 1a cd 01 18 ce 01 4b cf 01 20 d0 01 27 d1 01 50 d2 01 64 d3 01 39"
    "ff 03 98 81 00 35 01 00 11 80 78 29 00"
)


def make_display(i2c):
    """Initialize the 7-inch Touch Display 2 at 1280 x 720 landscape."""
    displayio.release_displays()
    while not i2c.try_lock():
        pass
    try:
        # The display MCU requires a STOP and delay before register reads.
        i2c.writeto(0x45, bytes((1,)))
        time.sleep(0.005)
        identity = bytearray(1)
        i2c.readfrom_into(0x45, identity)
        if identity[0] & 15 not in (1, 4):
            raise RuntimeError("Connect the 7-inch Raspberry Pi Touch Display 2")
        print("Display controller ID:", hex(identity[0]))
        i2c.writeto(0x45, bytes((3, 0)))
        i2c.writeto(0x45, bytes((2, 0)))
        time.sleep(0.02)
        bus = mipidsi.Bus(frequency=1_000_000_000, num_lanes=2)
        i2c.writeto(0x45, bytes((2, 3)))
        time.sleep(0.02)
        framebuffer = mipidsi.Display(
            bus, _INIT_SEQUENCE,
            width=720, height=1280, color_depth=16,
            hsync_pulse_width=33, hsync_back_porch=50, hsync_front_porch=239,
            vsync_pulse_width=2, vsync_back_porch=30, vsync_front_porch=20,
            pixel_clock_frequency=83_330_000, native_frames_per_second=60,
        )
        display = framebufferio.FramebufferDisplay(framebuffer, rotation=0)
        i2c.writeto(0x45, bytes((3, 0x88)))
        return display
    finally:
        i2c.unlock()
import gc, os, board, displayio, supervisor, videocore, videogif
print("VER", os.uname().version, "CASE", CASE)
EXP = [4066, 6022, 10027, 11983, 15988, 17945, 21949, 23874, 27879, 29835, 33840, 35796, 39801, 41758, 45730, 47687, 51692, 53648, 57653, 59609]
i2c = board.I2C()
display = make_display(i2c)
pass
print("size", display.width, display.height)
PY = 200
def show(path, seconds):
    gif = videogif.GifDecoder(path)
    video = videocore.VideoBuffer(bytearray(gif.width * gif.height * 2))
    video.y = PY
    root = displayio.Group()
    root.append(video)
    video.play(gif)
    display.root_group = root
    print("playing", path, gif.width, gif.height, video.playing)
    t0 = time.monotonic()
    while time.monotonic() - t0 < seconds:
        time.sleep(0.05)
    print("after", seconds, "s playing", video.playing)
    video.stop()
    gif.deinit()
    display.root_group = None
while True:
    show("/teapot_1bit_320.gif", 30)
    show("/tg_inf.gif", 8)

Changes

Commit Change
Size the draw area when play starts Fixes a video added to a shown Group before play() staying blank.
Draw a moved plain buffer at its new position The move only marked the old area.
Report which part of a frame changed on_frame gains changed, NULL for the whole frame. The camera passes NULL.
Check a frame with row gaps fits Needed by this source. The camera can't trigger it on main.
Factor out GIF file opening Shared with OnDiskGif, same behavior.
Add the GIF source New module, on wherever CIRCUITPY_VIDEOCORE and CIRCUITPY_GIFIO are on.
Report end of stream playing goes False after the last loop, a read error or deinit().
Choice Behavior
Pixels 16 bit (RGB565)
Targets Plain buffer (a bytearray), or framebuffer (FramebufferDisplay, read back to check pixels)
Timing Background tick, GIF frame delays
Loops GIF loop count; none plays once
End Last frame stays shown
Firmware size vs main Value
Metro ESP32-P4 +2,832 B
Feather RP2040, no video +24 B

Tested

Metro ESP32-P4, Raspberry Pi Touch Display 2, color_depth=16, Ubuntu host, 3 trials per row.

CircuitPython 11.0.0-alpha.2 dev builds of this branch, before the rebase.

Earlier build, before the rebase onto main main This branch
Shown Group, append, play, plain buffer No draw until video.x = 0 Draws at once
Camera frames per second, framebuffer 16.1 16.0-16.1
Camera frames per second, plain buffer 15.1-15.4 15.3-15.7
Earlier build, GIF, 16 bit, framebuffer Result
Plays once, 2 loops, forever 1, 3, still playing; playing False at the end
Hide and show No frames while hidden, restarts
Ctrl-C, reload 0 failures
deinit() Raises on use, safe twice, replay works. Freeing not shown by mem_free
OnDiskGif on Metro RP2040, no video, main vs this branch Result
Frame CRCs, frame count, delays, sizes, errors Identical
next_frame, teapot in palette mode 31.43 ms vs 31.45 ms
Heap used, leak over 20 opens Identical
Host test, real decoder, VM stubbed Result
Test GIFs, 16 bit 71/71 frames match
Frames landing during a refresh 12/12
End of stream 16/16
deinit() at VM end, ASan Clean, simulated sequence
Failed play() after a GIF ended 12/12

pre-commit passes on the final branch.

Test files

Test GIFs: make_gifs.py and the three GIFs tg_once tg_loop2 tg_inf

tg_once.gif plays once, tg_loop2.gif loops twice, tg_inf.gif loops forever.

# 320x240 test GIFs: white field, a 40x40 block whose color encodes the frame, 50 ms per frame.
import json
from PIL import Image, ImageDraw
N = 20
colors = [((i * 12 + 8) & 0xF8, (255 - i * 12) & 0xFC, (i * 37 + 16) & 0xF8) for i in range(N)]
exp = [((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) for r, g, b in colors]
assert len(set(exp)) == N
frames = []
for c in colors:
    im = Image.new("RGB", (320, 240), (255, 255, 255))
    ImageDraw.Draw(im).rectangle((0, 0, 39, 39), fill=c)
    frames.append(im.convert("P", palette=Image.ADAPTIVE, colors=8))
for name, loop in (("tg_once", None), ("tg_loop2", 2), ("tg_inf", 0)):
    kw = {} if loop is None else {"loop": loop}
    frames[0].save(name + ".gif", save_all=True, append_images=frames[1:], duration=50, disposal=1, optimize=False, **kw)
    r = Image.open(name + ".gif")
    print(name, r.n_frames, r.info.get("loop"), r.info.get("duration"))
print("EXP =", exp)
json.dump(exp, open("tg_expected.json", "w"))
Test code: g_basic on Metro ESP32-P4 (display setup, helpers, case)
CASE = "basic"

import time
import displayio
import framebufferio
import mipidsi

_INIT_SEQUENCE = bytes.fromhex(
    "ff 03 98 81 03 01 01 00 02 01 00 03 01 73 04 01 00 05 01 00 06 01 0a 07"
    "01 00 08 01 00 09 01 61 0a 01 00 0b 01 00 0c 01 01 0d 01 00 0e 01 00 0f"
    "01 61 10 01 61 11 01 00 12 01 00 13 01 00 14 01 00 15 01 00 16 01 00 17"
    "01 00 18 01 00 19 01 00 1a 01 00 1b 01 00 1c 01 00 1d 01 00 1e 01 40 1f"
    "01 80 20 01 06 21 01 01 22 01 00 23 01 00 24 01 00 25 01 00 26 01 00 27"
    "01 00 28 01 33 29 01 03 2a 01 00 2b 01 00 2c 01 00 2d 01 00 2e 01 00 2f"
    "01 00 30 01 00 31 01 00 32 01 00 33 01 00 34 01 04 35 01 00 36 01 00 37"
    "01 00 38 01 3c 39 01 00 3a 01 00 3b 01 00 3c 01 00 3d 01 00 3e 01 00 3f"
    "01 00 40 01 00 41 01 00 42 01 00 43 01 00 44 01 00 50 01 10 51 01 32 52"
    "01 54 53 01 76 54 01 98 55 01 ba 56 01 10 57 01 32 58 01 54 59 01 76 5a"
    "01 98 5b 01 ba 5c 01 dc 5d 01 fe 5e 01 00 5f 01 0e 60 01 0f 61 01 0c 62"
    "01 0d 63 01 06 64 01 07 65 01 02 66 01 02 67 01 02 68 01 02 69 01 01 6a"
    "01 00 6b 01 02 6c 01 15 6d 01 14 6e 01 02 6f 01 02 70 01 02 71 01 02 72"
    "01 02 73 01 02 74 01 02 75 01 0e 76 01 0f 77 01 0c 78 01 0d 79 01 06 7a"
    "01 07 7b 01 02 7c 01 02 7d 01 02 7e 01 02 7f 01 01 80 01 00 81 01 02 82"
    "01 14 83 01 15 84 01 02 85 01 02 86 01 02 87 01 02 88 01 02 89 01 02 8a"
    "01 02 ff 03 98 81 04 6c 01 15 6e 01 2a 6f 01 33 3b 01 98 3a 01 94 8d 01"
    "14 87 01 ba 26 01 76 b2 01 d1 b5 01 06 38 01 01 39 01 00 ff 03 98 81 01"
    "22 01 0a 31 01 00 53 01 7d 55 01 8f 40 01 33 50 01 96 51 01 96 60 01 23"
    "a0 01 08 a1 01 1d a2 01 2a a3 01 10 a4 01 15 a5 01 28 a6 01 1c a7 01 1d"
    "a8 01 7e a9 01 1d aa 01 29 ab 01 6b ac 01 1a ad 01 18 ae 01 4b af 01 20"
    "b0 01 27 b1 01 50 b2 01 64 b3 01 39 c0 01 08 c1 01 1d c2 01 2a c3 01 10"
    "c4 01 15 c5 01 28 c6 01 1c c7 01 1d c8 01 7e c9 01 1d ca 01 29 cb 01 6b"
    "cc 01 1a cd 01 18 ce 01 4b cf 01 20 d0 01 27 d1 01 50 d2 01 64 d3 01 39"
    "ff 03 98 81 00 35 01 00 11 80 78 29 00"
)


def make_display(i2c):
    """Initialize the 7-inch Touch Display 2 at 1280 x 720 landscape."""
    displayio.release_displays()
    while not i2c.try_lock():
        pass
    try:
        # The display MCU requires a STOP and delay before register reads.
        i2c.writeto(0x45, bytes((1,)))
        time.sleep(0.005)
        identity = bytearray(1)
        i2c.readfrom_into(0x45, identity)
        if identity[0] & 15 not in (1, 4):
            raise RuntimeError("Connect the 7-inch Raspberry Pi Touch Display 2")
        print("Display controller ID:", hex(identity[0]))
        i2c.writeto(0x45, bytes((3, 0)))
        i2c.writeto(0x45, bytes((2, 0)))
        time.sleep(0.02)
        bus = mipidsi.Bus(frequency=1_000_000_000, num_lanes=2)
        i2c.writeto(0x45, bytes((2, 3)))
        time.sleep(0.02)
        framebuffer = mipidsi.Display(
            bus, _INIT_SEQUENCE,
            width=720, height=1280, color_depth=16,
            hsync_pulse_width=33, hsync_back_porch=50, hsync_front_porch=239,
            vsync_pulse_width=2, vsync_back_porch=30, vsync_front_porch=20,
            pixel_clock_frequency=83_330_000, native_frames_per_second=60,
        )
        display = framebufferio.FramebufferDisplay(framebuffer, rotation=0)
        i2c.writeto(0x45, bytes((3, 0x88)))
        return display
    finally:
        i2c.unlock()
import gc, os, board, displayio, supervisor, videocore, videogif
print("VER", os.uname().version, "CASE", CASE)
EXP = [4066, 6022, 10027, 11983, 15988, 17945, 21949, 23874, 27879, 29835, 33840, 35796, 39801, 41758, 45730, 47687, 51692, 53648, 57653, 59609]
i2c = board.I2C()
display = make_display(i2c)
w = display.width
fbm = display.framebuffer
fb = memoryview(fbm)
print("size", w, display.height, "fb", len(fb), "depth", len(fb) * 8 // (w * display.height))
PY = 200
def px(x, y):
    o = (y * w + x) * 2
    return fb[o] | fb[o + 1] << 8
def poll(video, seconds, stop_at_end=True):
    # Frame number from the block color, logged on each change. -1 means a color not in the table.
    seq = []; t0 = time.monotonic(); last = None; end_t = None
    while time.monotonic() - t0 < seconds:
        v = px(10, PY + 10)
        if v != last:
            last = v
            seq.append(EXP.index(v) if v in EXP else ("%04x" % v))
        if not video.playing:
            end_t = time.monotonic() - t0
            if stop_at_end:
                break
    return seq, end_t
def loops(seq):
    return sum(1 for a, b in zip(seq, seq[1:]) if a == 19 and b == 0) + 1
def inorder(seq):
    bad = [(a, b) for a, b in zip(seq, seq[1:]) if not (isinstance(a, int) and isinstance(b, int) and (b == a + 1 or (a == 19 and b == 0)))]
    return bad
def start(path):
    gif = videogif.GifDecoder(path)
    print("gif", gif.width, gif.height)
    video = videocore.VideoBuffer(fbm)
    video.y = PY
    root = displayio.Group()
    root.append(video)
    video.play(gif)
    display.root_group = root
    return gif, video, root
for name, secs, want in (("/tg_once.gif", 10, 1), ("/tg_loop2.gif", 10, 3)):
    gif, video, root = start(name)
    seq, end_t = poll(video, secs)
    print(name, "frames_seen", len(seq), "loops", loops(seq), "want", want, "bad_order", inorder(seq)[:5], "first", seq[:3], "last", seq[-1:], "end_t", end_t, "playing", video.playing, "source", video.source)
    last = px(10, PY + 10); time.sleep(1.5)
    print(" last frame kept", last == px(10, PY + 10), "=frame19", last == EXP[19], "white field", hex(px(100, PY + 100)), "outside gif", hex(px(500, PY + 100)), hex(px(10, PY + 300)))
    video.stop(); gif.deinit(); display.root_group = None; time.sleep(0.2)
print("END")
Serial output from that run
soft reboot
code.py output:
VER 11.0.0-alpha.2-64-g5634c2411d-dirty on 2026-11-01 CASE basic
Display controller ID: 0x1
size 720 1280 fb 1843200 depth 16
gif 320 240
/tg_once.gif frames_seen 19 loops 1 want 1 bad_order [] first [1, 2, 3] last [19] end_t 0.929 playing False source None
 last frame kept True =frame19 True white field 0xffff outside gif 0x0 0x0
gif 320 240
/tg_loop2.gif frames_seen 58 loops 3 want 3 bad_order [] first [1, 2, 3] last [18] end_t 2.951008 playing False source None
 last frame kept True =frame19 True white field 0xffff outside gif 0x0 0x0
END
Code done running.
Auto-reload is on. Simply save files over USB to run them or enter REPL to disable.
Press any key to enter the REPL. Use CTRL-D to reload.

Limits

Case Result
Frame decode Runs in the background tick, blocking USB and audio for that time
Python holding the SD bus The read can fail and end the GIF, as with audiocore.WaveFile (from source)
Damaged GIF Usually ends the stream; some still show as a frame
Wider than 320 pixels ValueError, as with OnDiskGif
A failed play() after a GIF ended Raises, keeps the last frame, and source is None
GIF frame landing during a refresh Kept, so nothing goes stale (host test)
Camera frame landing during a refresh Dropped, as on main. A paused camera may not show its last frame (from source)

Panel safety

This PR doesn't change how any display is driven. The fast e-paper updates in #11719 go past panel makers' refresh limits, and its EPaperDisplay docs now carry a warning.

AI assistance

Written with Claude Code.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@mikeysklar
mikeysklar marked this pull request as ready for review November 2, 2026 20:18
mikeysklar and others added 4 commits November 2, 2026 16:20
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Replaced by #11738, opened from an adafruit branch so the three PRs stack.

@mikeysklar mikeysklar closed this Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant