Easier GIF creation in Golang
10/14/2024
The Golang Standard Library
You and your friend were talking last night and came up with a great idea for a meme turned into a GIF.
Let's say you want to use the Lord Of the Rings scene where Boromir says "One does not simply walk into Mordor".
So, first we get a screenshot of one of the frames from the video just to start with.
Then the question is, how should we make it? Well, you know some golang, so maybe there are some tools that exist for go.
Doing a quick search shows that the standard library has support built in! Great! image/gif.
The library doesn't seem to have any examples though... Well, with some quick searching you are able to cobble something together.
You are required to make a palette, which is a little mysterious, but it is pretty easy to put together a basic list of colors.
Then using the StackOverflow examples we create a GIF with a single frame.
The delay is a little odd since it is an integer for hundredths of seconds, but whatever.
Hand Made Palette and Choose Nearest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
package main
import (
"bytes"
"image"
"image/color"
"image/color/palette"
"image/draw"
"image/gif"
"image/jpeg"
"os"
)
// https://www.w3schools.com/colors/colors_names.asp
var (
// Black to white
Black color.RGBA = color.RGBA{0x00, 0x00, 0x00, 0xFF} // #000000
DarkGray color.RGBA = color.RGBA{0x26, 0x26, 0x26, 0xFF} // #262626
Gray color.RGBA = color.RGBA{0x80, 0x80, 0x80, 0xFF} // #808080
LightGray color.RGBA = color.RGBA{0xD3, 0xD3, 0xD3, 0xFF} // #D3D3D3
White color.RGBA = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF} // #FFFFFF
// Primary Colors
Red color.RGBA = color.RGBA{0xFF, 0x00, 0x00, 0xFF} // #FF0000
Lime color.RGBA = color.RGBA{0x00, 0xFF, 0x00, 0xFF} // #00FF00
Blue color.RGBA = color.RGBA{0x00, 0x00, 0xFF, 0xFF} // #0000FF
// half strength primary colors
Maroon color.RGBA = color.RGBA{0x80, 0x00, 0x00, 0xFF} // #800000
Green color.RGBA = color.RGBA{0x00, 0x80, 0x00, 0xFF} // #008000
NavyBlue color.RGBA = color.RGBA{0x00, 0x00, 0x80, 0xFF} // #000080
// full strength primary mixes
Yellow color.RGBA = color.RGBA{0xFF, 0xFF, 0x00, 0xFF} // #FFFF00
Aqua color.RGBA = color.RGBA{0x00, 0xFF, 0xFF, 0xFF} // #00FFFF
Magenta color.RGBA = color.RGBA{0xFF, 0x00, 0xFF, 0xFF} // #FF00FF
// half strength primary mixes
Olive color.RGBA = color.RGBA{0x80, 0x80, 0x00, 0xFF} // #808000
Purple color.RGBA = color.RGBA{0x80, 0x00, 0x80, 0xFF} // #800080
Teal color.RGBA = color.RGBA{0x00, 0x80, 0x80, 0xFF} // #008080
)
var simplePalette color.Palette = color.Palette{Black, DarkGray, Gray, LightGray, White,
Red, Lime, Blue, Maroon, Green, NavyBlue, Yellow, Aqua, Magenta, Olive, Purple, Teal}
func try1() {
fileData, _ := os.ReadFile("OneDoesNotSimply_Template.jpg")
img, _ := jpeg.Decode(bytes.NewReader(fileData))
bound := img.Bounds()
palettedImg := image.NewPaletted(bound, simplePalette)
draw.Draw(palettedImg, bound, img, image.Point{}, draw.Src)
anim := gif.GIF{}
anim.Image = append(anim.Image, palettedImg)
anim.Delay = append(anim.Delay, 100)
file, _ := os.Create("OneDoesNotSimply_try1.gif")
defer file.Close()
_ = gif.EncodeAll(file, &anim)
}
func main() {
try1()
}
|
And... The results are terrible!
Did we do something wrong? Or is the golang package just not very good?
With some more searching you find that most people don't actually create their own palette. But instead use either Plan9 or WebSafe.
Let's give Plan9 a try.
Plan9 Palette and Choose Nearest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
func try2() {
fileData, _ := os.ReadFile("OneDoesNotSimply_Template.jpg")
img, _ := jpeg.Decode(bytes.NewReader(fileData))
bound := img.Bounds()
palettedImg := image.NewPaletted(bound, palette.Plan9)
draw.Draw(palettedImg, bound, img, image.Point{}, draw.Src)
anim := gif.GIF{}
anim.Image = append(anim.Image, palettedImg)
anim.Delay = append(anim.Delay, 100)
file, _ := os.Create("OneDoesNotSimply_try2.gif")
defer file.Close()
_ = gif.EncodeAll(file, &anim)
}
func main() {
try2()
}
|
Slightly better, but still terrible!
You did notice that most of the examples online were using something called dithering.
So let's try that with our first hand crafted palette. Instead of using draw.Draw
we have to use draw.FloydSteinberg
.
Hand Made Palette and Dithering
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
func try3() {
fileData, _ := os.ReadFile("OneDoesNotSimply_Template.jpg")
img, _ := jpeg.Decode(bytes.NewReader(fileData))
bound := img.Bounds()
palettedImg := image.NewPaletted(bound, simplePalette)
drawer := draw.FloydSteinberg
drawer.Draw(palettedImg, bound, img, image.Point{})
anim := gif.GIF{}
anim.Image = append(anim.Image, palettedImg)
anim.Delay = append(anim.Delay, 100)
file, _ := os.Create("OneDoesNotSimply_try3.gif")
defer file.Close()
_ = gif.EncodeAll(file, &anim)
}
func main() {
try3()
}
|
This is a lot better than it was, but the picture still isn't great. The colors are washed out and there are these strange red and green dots all over the image.
Let's try with the Plan9 palette again.
Plan9 Palette and Dithering
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
func try4() {
fileData, _ := os.ReadFile("OneDoesNotSimply_Template.jpg")
img, _ := jpeg.Decode(bytes.NewReader(fileData))
bound := img.Bounds()
palettedImg := image.NewPaletted(bound, palette.Plan9)
drawer := draw.FloydSteinberg
drawer.Draw(palettedImg, bound, img, image.Point{})
anim := gif.GIF{}
anim.Image = append(anim.Image, palettedImg)
anim.Delay = append(anim.Delay, 100)
file, _ := os.Create("OneDoesNotSimply_try4.gif")
defer file.Close()
_ = gif.EncodeAll(file, &anim)
}
func main() {
try4()
}
|
Well, that looks pretty good! I guess we should throw away our custom made palette.
There is a strange graininess to the image though.
Now we are actually ready to make the GIF. Using some other code we took a series of screenshots and have them available as a slice of images.
We need to add the text now.
Moving GIF With Plan9 Palette and Dithering
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
func makeGif(frames []image.Image) {
s1 := "ONE DOES NOT SIMPLY"
s2 := "MAKE A GIF"
AddMemeText(frames, s1, s2, easygif.Crimson)
hundredthOfSecondDelay := 10
// Process the images.
imagesPal := make([]*image.Paletted, 0, len(frames))
delays := make([]int, 0, len(frames))
// Fill the request channel with images to convert
for frameIndex := range frames {
screenShot := frames[frameIndex]
bounds := screenShot.Bounds()
ssPaletted := image.NewPaletted(bounds, palette.Plan9)
imagesPal = append(imagesPal, ssPaletted)
delays = append(delays, hundredthOfSecondDelay)
draw.FloydSteinberg.Draw(ssPaletted, bounds, screenShot, image.Point{})
}
// Create the GIF struct and write it to a file.
g := &gif.GIF{
Image: imagesPal,
Delay: delays,
}
file, _ := os.Create("OneDoesNotSimply_try5.gif")
defer file.Close()
_ = gif.EncodeAll(file, g)
}
func AddMemeText(frames []image.Image, s1, s2 string, c color.Color) {
fontSize := 60.0
font, err := truetype.Parse(goregular.TTF)
if err != nil {
panic("")
}
face := truetype.NewFace(font, &truetype.Options{
Size: fontSize,
})
for i := range frames {
frame := frames[i]
dc := gg.NewContextForImage(frame)
bound := frame.Bounds()
dc.SetFontFace(face)
dc.SetColor(c)
dc.DrawStringAnchored(s1, float64(bound.Dx())/2, float64(bound.Dy())*.10, 0.5, 0.5)
dc.DrawStringAnchored(s2, float64(bound.Dx())/2, float64(bound.Dy())*.90, 0.5, 0.5)
frames[i] = dc.Image()
}
}
|
The GIF looks... ok... The image is super grainy. and the graininess seems to dance around as the image moves.
As far as I can tell, yes, this is as good as it gets using the standard library.
The Problems
The main two problems are that mysterious palette and dithering.
The Palette
For a GIF, the palette contains every color that can be used in the GIF.
And there can only be up to 256 colors in the palette. That is quite the limitation.
The Plan9 palette chose colors that are evenly distributed around the color space, Which means that approximately 0% of them will be the actual colors in the source image.
That is why the choose nearest color examples looked terrible.
Dithering
Dithering solves the problem of not having enough colors in your palette by swapping between the two nearest colors.
The mix is determined by the relative distance to the two colors. If the pixel is somewhere between red and maroon, but closer to red,
then when dithering we would mainly use red, with maroon mixed in randomly at a lower rate.
That swapping between the two nearest colors also creates the main drawback of dithering that we saw in the examples above.
Dithering creates the graininess and dancing pixels. To make the problem as clear as possible I created a really simple gif with a circle than changes size.
Dithering is used.
The darker blue dots dance around and sometimes make lines and other shapes.
The problem isn't nearly as obvious on the moving GIF of Boromir above, but you can still see it on his forehead pretty easily.
The Standard Library Is Not Easy
All you wanted to do was to quickly create the funny GIF you and your friend thought up last night,
but instead you have wasted 2 hours learning about the GIF package and some of the troublesome nuances of the GIF image format.
You probably didn't want to know that a GIF can only have 256 colors in it, or that the delay between frames has to be set as an int that represents hundredth of seconds...
And at the end, it does not look very good. It is grainy and Boromir's forehead looks funny if you look too close.
Making a GIF was supposed to be quick, easy, and look like this!
Introducing EasyGif
To make the GIF creation process easier in go, I created the package easygif
. With it you can create a GIF faster and easier.
github.com/gary23b/easygif
To get started, first install the package with the command:
1
2
|
go get github.com/gary23b/easygif@latest
|
The package has built in functionality to:
- Make a GIF using the nearest color found in the Plan9 palette
- Make a GIF using dithering and the Plan9 palette
- Make an even better GIF by computing the most common colors in the image and then not use dithering.
- Take a screenshot or trimmed screenshot
- Take a screenshot video with or without trimming
- Save and load a slice of images to a Blob file so you can get the GIF generation right without having to re-capture the frames each time
All the work we went through above can be boiled down to two steps.
Collect the frames
This code saves the frames to a binary file with the encoding/gob
package.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
package main
import (
"fmt"
"time"
"github.com/gary23b/easygif"
)
func collectFrames() {
time.Sleep(time.Second * 3)
fmt.Println("GO!")
frames, _ := easygif.ScreenshotVideoTrimmed(30, time.Millisecond*50, 150, 1050, 380, 1270)
_ = easygif.NearestWrite(frames, time.Millisecond*100, "./examples/globsave/globsave1.gif")
fmt.Println("Collection Done.")
err := easygif.SaveFramesToFile(frames, "save.bin")
if err != nil {
panic(err)
}
}
func main() {
collectFrames()
}
|
Make the GIF
Here I make the same GIF with 3 different configurations. The best choice is the most common color algorithm though.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
package main
import (
"fmt"
"image"
"image/color"
"time"
"github.com/gary23b/easygif"
"github.com/fogleman/gg"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font/gofont/goregular"
)
func createGifs() {
frames, err = easygif.LoadFramesToFile("save.bin")
// frames, err := easygif.LoadFramesToFile("./save.bin")
if err != nil {
panic(err)
}
fmt.Println("Adding Text.")
s1 := "ONE DOES NOT SIMPLY"
s2 := "MAKE A GIF"
AddMemeText(frames, s1, s2, easygif.Crimson)
fmt.Println("Encoding GIF.")
_ = easygif.NearestWrite(frames, time.Millisecond*100, "OneDoesNotSimplyMakeAGIF_Nearest.gif")
_ = easygif.DitheredWrite(frames, time.Millisecond*100, "OneDoesNotSimplyMakeAGIF_Dithered.gif")
_ = easygif.MostCommonColorsWrite(frames, time.Millisecond*100, "OneDoesNotSimplyMakeAGIF_MostCommon.gif")
}
func AddMemeText(frames []image.Image, s1, s2 string, c color.Color) {
fontSize := 60.0
font, err := truetype.Parse(goregular.TTF)
if err != nil {
panic("")
}
face := truetype.NewFace(font, &truetype.Options{
Size: fontSize,
})
for i := range frames {
frame := frames[i]
dc := gg.NewContextForImage(frame)
bound := frame.Bounds()
dc.SetFontFace(face)
dc.SetColor(c)
dc.DrawStringAnchored(s1, float64(bound.Dx())/2, float64(bound.Dy())*.10, 0.5, 0.5)
dc.DrawStringAnchored(s2, float64(bound.Dx())/2, float64(bound.Dy())*.90, 0.5, 0.5)
frames[i] = dc.Image()
}
}
func main() {
createGifs()
}
|
easygif.Nearest
easygif.Dithered
easygif.MostCommonColors
Conclusion
As you can see, the amount of code required to create a great looking GIF has been reduced from what we saw above with the standard library to just 1 line:
1
2
|
easygif.MostCommonColorsWrite(frames, time.Millisecond*100, "SuperEasy.gif")
|
What are you waiting for? Go GIF with EasyGif!
Thanks for reading!
Subscribe for free and receive updates and support my work.
Some text some message..