Writing a GIF Player in Go

July 31, 2022

I'm reimplementing one of my projects, movemygif, in Go. In short, movemygif is a digital picture frame I've hung on my bedroom wall that plays a GIF and tracks the motion of the viewer looking at the frame. When the viewer moves towards the frame, the GIF plays forward, if they move backwards the GIF reverses. I wanted to benchmark the Go implementation against the original Clojurescript/Javascript solution in hopes of getting much better performance outside of a browser. I'm also learning Go and this project seemed more motiviating than practice problems.

For starters, I need a GIF player that lets me rewind, pause, and play a GIF whenever needed. A default GIF viewer like what you'd get from most GUI libraries won't do the trick here. I also haven't been able to find a GUI agnostic GIF player available for Go that I could extend. So, I'm left with the option of writing my own.

The first stop I made to build this was the Go standard library gif.GIF helper, which makes it easy to parse a raw GIF file into a list of images. Perfect, I'm half way done.

func loadGIF(path string) (*gif.GIF, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	g, err := gif.DecodeAll(f)
	if err != nil {
		return nil, err
	}

	return g, nil
}

The gif.GIF instance that is returned from DecodeAll contains a property, Image, that stores an array of image objects. So, it seems as simple as looping over the image list, and displaying each image on a canvas in rapid succession to get an animated GIF.

// Using fyne.io GUI library - You can ignore all this setup
a := app.New()
w := a.NewWindow("GIF Player")
display := canvas.NewImageFromImage(nil)
display.FillMode = canvas.ImageFillOriginal
w.SetContent(display)
// end of fyne GUI setup

// Start of GIF player logic
g, err := loadGIF("path/to/gif") // loadGIF function from above
if err != nil {
  fmt.Println("Failed to load gif: ", err)
}

go func() {
  frame := 0 // index of the GIF image we want to show next
  for range time.Tick(time.Millisecond * time.Duration(100)) {
    // Update fyne display
    display.Image = g.Image[frame]
    canvas.Refresh(display)

    // To create looping effect, go back to 0th frame when the last frame in GIF has been displayed
    if frame >= (len(g.Image) - 1) {
      frame = 0
    } else {
      // Otherwise go to next frame
      frame += 1
    }
  }
}()
// end of GIF player logic

w.ShowAndRun()

Time to put it to the test. Drum roll please...

🙁

For reference, this is the source GIF

So yeah, not great. After an hour of poor googling I found this. It's a page from an old O'Reilly Web Design textbook that discusses how animated GIFs work. Generally, if you land on a webpage like this when researching things, it's been a bad day. But I'll admit, O'Reilly wins this round. The page was a bit of a breakthrough.

All I really knew about GIF files before writing this player was that they contained an ordered list of images. This is true. However, those images aren't necessarily independent images. If you followed the link to that textbook snippet, you may have seen a subsection titled, "Transparency" where it reads, "You can set transparency for each frame within an animation. Previous frames will show through the transparent area of a later frame if disposal methods are set correctly." The "aha" here is that individual GIF images often rely on previous images to complete their full picture. In a way, it's like they're layered on top of one another when played so that invariants can remain transparent on succesive images. Why repaint the same pixels over and over when they remain the same between images?

This is a clever performance boost for regular GIF players, but this doesn't work for my case. The "layering" technique breaks my ability to play GIFs in reverse. So, my first step needs to be unapplying any transparency between images.

func unlayerFrames(g *gif.GIF) []image.Image {
	bounds := g.Image[0].Bounds()
	images := make([]image.Image, len(g.Image))
	// The first image in a GIF will always be a complete image (no previous layers will shadow over it)
	images[0] = g.Image[0]
	for i := 1; i < len(g.Image); i++ {
		unlayered := image.NewRGBA(bounds)
		curr := g.Image[i]
		prev := images[i-1]
		// Overwrites any transparent pixels with the previous image's pixels to create stand-alone image
		for x := 0; x < bounds.Dx(); x++ {
			for y := 0; y < bounds.Dy(); y++ {
				if isTransparent(curr.At(x, y)) {
					unlayered.Set(x, y, prev.At(x, y))
				} else {
					unlayered.Set(x, y, curr.At(x, y))
				}
			}
		}
		images[i] = unlayered
	}
	return images
}

func isTransparent(c color.Color) bool {
	_, _, _, a := c.RGBA()
	return a == 0
}

I thought this was going to be the end-all solution, but alas I was left with this:

This looks slightly better. No more scratchy white lines, but we're still only seeing the moving part of the GIF instead of the whole picture. If you watch closely you'll see a single frame where the whole picture is visible. This should clue you in on what's happening here. A similar performance method to the transparency hack is being used, but with a slightly different approach. The transparency trick is used for the blob of moving pixels that contain redundancy. What we're missing now are those pixels that are never updated with the balls motion. The background. A single frame in this GIF contains the background and the remaining images are responsible for updating the small area of the background image where the ball is moving.

Fortunately, this is also a pretty easy fix. Each Image specifies the area of non-null pixels it has updates for in a property called Rect. Relating this back to our case, the Rect property tells us which area of the image contains updates for the GIF. Anything outside those bounds should rely on the previous image's pixels instead of the current image's.

To add this logic to the unlayerFrames function only requires a couple small changes

func isInBounds(x int, y int, rect image.Rectangle) bool {
	return rect.Min.X <= x && x < rect.Max.X && rect.Min.Y <= y && y < rect.Max.Y
}

// ... Inside the unlayerFrames function
if !isTransparent(curr.At(x, y)) && isInBounds(x, y, curr.Rect) {
	unlayered.Set(x, y, curr.At(x, y))
} else {
	unlayered.Set(x, y, prev.At(x, y))
}
// ...

The above code snippet just contains the individual updates needed to get the existing unlayerFrames function to work. I'll include a link to the complete solution at the end.

I give you...a working GIF player 🎉 Sure, a little boring, it's a reinvented wheel. BUT this wheel can easily be extended to stop, reverse, change speed, and play again programatically. So, that's what I did. I've bundled all this into a GUI agnostic GIF player library called gof.

Try it out if you're interested! You can also find the complete code solution from above inside this library's repo. I'll continue working on gof as I continue movemygif, so you may see some breaking changes as the months go by.

Tags: go