169 lines
4.7 KiB
Go
169 lines
4.7 KiB
Go
// xgif is a helper package to create GIF animations based on PNG images
|
|
// The resulting animations have the following properties:
|
|
// 1. All frames have the same size (max(pngs.width), max(pngs.height))
|
|
// 2. All PNGs are centered in the given frame
|
|
// 3. The frame background is plain white
|
|
// Note that to convert from a PNG to a GIF compatible image (Bitmap), the PNG image must be quantized (colors are aggregated in median buckets)
|
|
// so that it has at most 255 colors.
|
|
// This is required because GIFs support only 256 colors and we must keep 1 slot for the white background.
|
|
package xgif
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/gif"
|
|
"image/png"
|
|
"time"
|
|
|
|
"github.com/ericpauley/go-quantize/quantize"
|
|
|
|
"oss.terrastruct.com/d2/lib/background"
|
|
"oss.terrastruct.com/util-go/go2"
|
|
"oss.terrastruct.com/util-go/xmain"
|
|
)
|
|
|
|
const INFINITE_LOOP = 0
|
|
|
|
var BG_COLOR = color.White
|
|
|
|
func AnimatePNGs(ms *xmain.State, pngs [][]byte, animIntervalMs int) ([]byte, error) {
|
|
if ms != nil {
|
|
cancel := background.Repeat(func() {
|
|
ms.Log.Info.Printf("generating GIF...")
|
|
}, time.Second*5)
|
|
defer cancel()
|
|
}
|
|
|
|
var width, height int
|
|
pngImgs := make([]image.Image, len(pngs))
|
|
for i, pngBytes := range pngs {
|
|
img, err := png.Decode(bytes.NewBuffer(pngBytes))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pngImgs[i] = img
|
|
bounds := img.Bounds()
|
|
width = go2.Max(width, bounds.Dx())
|
|
height = go2.Max(height, bounds.Dy())
|
|
}
|
|
|
|
interval := animIntervalMs / 10 // gif animation interval is in 100ths of a second
|
|
anim := &gif.GIF{
|
|
LoopCount: INFINITE_LOOP,
|
|
Config: image.Config{
|
|
Width: width,
|
|
Height: height,
|
|
},
|
|
}
|
|
|
|
for _, pngImage := range pngImgs {
|
|
// 1. convert the PNG into a GIF compatible image (Bitmap) by quantizing it to 255 colors
|
|
buf := bytes.NewBuffer(nil)
|
|
err := gif.Encode(buf, pngImage, &gif.Options{
|
|
NumColors: 256, // GIFs can have up to 256 colors
|
|
Quantizer: quantize.MedianCutQuantizer{},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
gifImg, err := gif.Decode(buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
palettedImg, ok := gifImg.(*image.Paletted)
|
|
if !ok {
|
|
return nil, fmt.Errorf("decoded gif image could not be cast as *image.Paletted")
|
|
}
|
|
|
|
// 2. make GIF frames of the same size, keeping images centered and with a white background
|
|
bounds := pngImage.Bounds()
|
|
top := (height - bounds.Dy()) / 2
|
|
bottom := top + bounds.Dy()
|
|
left := (width - bounds.Dx()) / 2
|
|
right := left + bounds.Dx()
|
|
|
|
var bgIndex int
|
|
if len(palettedImg.Palette) == 256 {
|
|
bgIndex = findWhiteIndex(palettedImg.Palette)
|
|
palettedImg.Palette[bgIndex] = BG_COLOR
|
|
} else {
|
|
bgIndex = len(palettedImg.Palette)
|
|
palettedImg.Palette = append(palettedImg.Palette, BG_COLOR)
|
|
}
|
|
frame := image.NewPaletted(image.Rect(0, 0, width, height), palettedImg.Palette)
|
|
for x := 0; x < width; x++ {
|
|
for y := 0; y < height; y++ {
|
|
if x <= left || y <= top || x >= right || y >= bottom {
|
|
frame.SetColorIndex(x, y, uint8(bgIndex))
|
|
} else {
|
|
frame.SetColorIndex(x, y, palettedImg.ColorIndexAt(x-left, y-top))
|
|
}
|
|
}
|
|
}
|
|
|
|
anim.Image = append(anim.Image, frame)
|
|
anim.Delay = append(anim.Delay, interval)
|
|
}
|
|
|
|
buf := bytes.NewBuffer(nil)
|
|
err := gif.EncodeAll(buf, anim)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func findWhiteIndex(palette color.Palette) int {
|
|
nearestIndex := 0
|
|
nearestScore := 0.
|
|
for i, c := range palette {
|
|
r, g, b, _ := c.RGBA()
|
|
if r == 255 && g == 255 && b == 255 {
|
|
return i
|
|
}
|
|
|
|
avg := float64(r+g+b) / 255.
|
|
if avg > nearestScore {
|
|
nearestScore = avg
|
|
nearestIndex = i
|
|
}
|
|
}
|
|
return nearestIndex
|
|
}
|
|
|
|
func Validate(gifBytes []byte, nFrames int, intervalMS int) error {
|
|
anim, err := gif.DecodeAll(bytes.NewBuffer(gifBytes))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if nFrames > 1 && anim.LoopCount != INFINITE_LOOP {
|
|
return fmt.Errorf("expected infinite loop, got=%d", anim.LoopCount)
|
|
} else if nFrames == 1 && anim.LoopCount != -1 {
|
|
return fmt.Errorf("wrong loop count for single frame gif, got=%d", anim.LoopCount)
|
|
}
|
|
|
|
if len(anim.Image) != nFrames {
|
|
return fmt.Errorf("expected %d frames, got=%d", nFrames, len(anim.Image))
|
|
}
|
|
|
|
interval := intervalMS / 10
|
|
width, height := anim.Config.Width, anim.Config.Height
|
|
for i, frame := range anim.Image {
|
|
w := frame.Bounds().Dx()
|
|
if w != width {
|
|
return fmt.Errorf("expected all frames to have the same width=%d, got=%d at frame=%d", width, w, i)
|
|
}
|
|
h := frame.Bounds().Dy()
|
|
if h != height {
|
|
return fmt.Errorf("expected all frames to have the same height=%d, got=%d at frame=%d", height, h, i)
|
|
}
|
|
if anim.Delay[i] != interval {
|
|
return fmt.Errorf("expected interval between frames to be %d, got=%d at frame=%d", interval, anim.Delay[i], i)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|