commit cf12fa6161531110c17706b7e00e878eb48e9827
parent 2e1c81770a0e15940f72095e081f19e6f9d11b4e
Author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Date: Sat, 11 Jun 2022 18:52:55 +0200
Add animated GIF support
Note that this is for GIFs only (and not Webp).
Fixes #5030
Diffstat:
6 files changed, 105 insertions(+), 27 deletions(-)
diff --git a/resources/image.go b/resources/image.go
@@ -19,6 +19,7 @@ import (
"image"
"image/color"
"image/draw"
+ "image/gif"
_ "image/gif"
_ "image/png"
"io"
@@ -346,6 +347,15 @@ func (i *imageResource) decodeImageConfig(action, spec string) (images.ImageConf
return conf, nil
}
+type giphy struct {
+ image.Image
+ gif *gif.GIF
+}
+
+func (g *giphy) GIF() *gif.GIF {
+ return g.gif
+}
+
// DecodeImage decodes the image source into an Image.
// This an internal method and may change.
func (i *imageResource) DecodeImage() (image.Image, error) {
@@ -354,6 +364,14 @@ func (i *imageResource) DecodeImage() (image.Image, error) {
return nil, fmt.Errorf("failed to open image for decode: %w", err)
}
defer f.Close()
+
+ if i.Format == images.GIF {
+ g, err := gif.DecodeAll(f)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode gif: %w", err)
+ }
+ return &giphy{gif: g, Image: g.Image[0]}, nil
+ }
img, _, err := image.Decode(f)
return img, err
}
diff --git a/resources/image_test.go b/resources/image_test.go
@@ -16,6 +16,7 @@ package resources
import (
"fmt"
"image"
+ "image/gif"
"io/ioutil"
"math/big"
"math/rand"
@@ -24,6 +25,7 @@ import (
"path/filepath"
"runtime"
"strconv"
+ "strings"
"sync"
"testing"
"time"
@@ -641,7 +643,7 @@ func TestImageOperationsGolden(t *testing.T) {
resized, err := orig.Resize(resizeSpec)
c.Assert(err, qt.IsNil)
rel := resized.RelPermalink()
- c.Log("resize", rel)
+
c.Assert(rel, qt.Not(qt.Equals), "")
}
}
@@ -652,7 +654,15 @@ func TestImageOperationsGolden(t *testing.T) {
resized, err := orig.Resize(resizeSpec)
c.Assert(err, qt.IsNil)
rel := resized.RelPermalink()
- c.Log("resize", rel)
+ c.Assert(rel, qt.Not(qt.Equals), "")
+ }
+
+ // Animated GIF
+ orig = fetchImageForSpec(spec, c, "giphy.gif")
+ for _, resizeSpec := range []string{"200x", "512x"} {
+ resized, err := orig.Resize(resizeSpec)
+ c.Assert(err, qt.IsNil)
+ rel := resized.RelPermalink()
c.Assert(rel, qt.Not(qt.Equals), "")
}
@@ -663,7 +673,6 @@ func TestImageOperationsGolden(t *testing.T) {
resized, err := orig.Resize(resizeSpec)
c.Assert(err, qt.IsNil)
rel := resized.RelPermalink()
- c.Log("resize", rel)
c.Assert(rel, qt.Not(qt.Equals), "")
}
@@ -671,7 +680,6 @@ func TestImageOperationsGolden(t *testing.T) {
resized, err := orig.Fill(fillSpec)
c.Assert(err, qt.IsNil)
rel := resized.RelPermalink()
- c.Log("fill", rel)
c.Assert(rel, qt.Not(qt.Equals), "")
}
@@ -679,7 +687,6 @@ func TestImageOperationsGolden(t *testing.T) {
resized, err := orig.Fit(fitSpec)
c.Assert(err, qt.IsNil)
rel := resized.RelPermalink()
- c.Log("fit", rel)
c.Assert(rel, qt.Not(qt.Equals), "")
}
@@ -713,14 +720,12 @@ func TestImageOperationsGolden(t *testing.T) {
resized, err := resized.Filter(filter)
c.Assert(err, qt.IsNil)
rel := resized.RelPermalink()
- c.Logf("filter: %v %s", filter, rel)
c.Assert(rel, qt.Not(qt.Equals), "")
}
resized, err = resized.Filter(filters[0:4])
c.Assert(err, qt.IsNil)
rel := resized.RelPermalink()
- c.Log("filter all", rel)
c.Assert(rel, qt.Not(qt.Equals), "")
}
@@ -753,24 +758,47 @@ func assetGoldenDirs(c *qt.C, dir1, dir2 string) {
f2, err := os.Open(filepath.Join(dir2, fi2.Name()))
c.Assert(err, qt.IsNil)
- img1, _, err := image.Decode(f1)
- c.Assert(err, qt.IsNil)
- img2, _, err := image.Decode(f2)
- c.Assert(err, qt.IsNil)
+ decodeAll := func(f *os.File) []image.Image {
+ var images []image.Image
+
+ if strings.HasSuffix(f.Name(), ".gif") {
+ gif, err := gif.DecodeAll(f)
+ c.Assert(err, qt.IsNil)
+ images = make([]image.Image, len(gif.Image))
+ for i, img := range gif.Image {
+ images[i] = img
+ }
+ } else {
+ img, _, err := image.Decode(f)
+ c.Assert(err, qt.IsNil)
+ images = append(images, img)
+ }
+ return images
+ }
- nrgba1 := image.NewNRGBA(img1.Bounds())
- gift.New().Draw(nrgba1, img1)
- nrgba2 := image.NewNRGBA(img2.Bounds())
- gift.New().Draw(nrgba2, img2)
-
- if !goldenEqual(nrgba1, nrgba2) {
- switch fi1.Name() {
- case "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_73c19c5f80881858a85aa23cd0ca400d.png",
- "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_ae631e5252bb5d7b92bc766ad1a89069.png",
- "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d1bbfa2629bffb90118cacce3fcfb924.png":
- c.Log("expectedly differs from golden due to dithering:", fi1.Name())
- default:
- c.Errorf("resulting image differs from golden: %s", fi1.Name())
+ imgs1 := decodeAll(f1)
+ imgs2 := decodeAll(f2)
+ c.Assert(len(imgs1), qt.Equals, len(imgs2))
+
+ LOOP:
+ for i, img1 := range imgs1 {
+ img2 := imgs2[i]
+ nrgba1 := image.NewNRGBA(img1.Bounds())
+ gift.New().Draw(nrgba1, img1)
+ nrgba2 := image.NewNRGBA(img2.Bounds())
+ gift.New().Draw(nrgba2, img2)
+
+ if !goldenEqual(nrgba1, nrgba2) {
+ switch fi1.Name() {
+ case "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_73c19c5f80881858a85aa23cd0ca400d.png",
+ "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_ae631e5252bb5d7b92bc766ad1a89069.png",
+ "gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_d1bbfa2629bffb90118cacce3fcfb924.png",
+ "giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gif":
+ c.Log("expectedly differs from golden due to dithering:", fi1.Name())
+ default:
+ c.Errorf("resulting image differs from golden: %s", fi1.Name())
+ break LOOP
+ }
}
}
diff --git a/resources/images/image.go b/resources/images/image.go
@@ -86,6 +86,10 @@ func (i *Image) EncodeTo(conf ImageConfig, img image.Image, w io.Writer) error {
return encoder.Encode(w, img)
case GIF:
+ if giphy, ok := img.(Giphy); ok {
+ g := giphy.GIF()
+ return gif.EncodeAll(w, g)
+ }
return gif.Encode(w, img, &gif.Options{
NumColors: 256,
})
@@ -252,8 +256,29 @@ func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfi
}
func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.Image, error) {
- g := gift.New(filters...)
- bounds := g.Bounds(src.Bounds())
+
+ filter := gift.New(filters...)
+
+ if giph, ok := src.(Giphy); ok && len(giph.GIF().Image) > 1 {
+ g := giph.GIF()
+ var bounds image.Rectangle
+ firstFrame := g.Image[0]
+ tmp := image.NewNRGBA(firstFrame.Bounds())
+ for i := range g.Image {
+ gift.New().DrawAt(tmp, g.Image[i], g.Image[i].Bounds().Min, gift.OverOperator)
+ bounds = filter.Bounds(tmp.Bounds())
+ dst := image.NewPaletted(bounds, g.Image[i].Palette)
+ filter.Draw(dst, tmp)
+ g.Image[i] = dst
+ }
+ g.Config.Width = bounds.Dx()
+ g.Config.Height = bounds.Dy()
+
+ return giph, nil
+ }
+
+ bounds := filter.Bounds(src.Bounds())
+
var dst draw.Image
switch src.(type) {
case *image.RGBA:
@@ -265,7 +290,8 @@ func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.
default:
dst = image.NewNRGBA(bounds)
}
- g.Draw(dst, src)
+ filter.Draw(dst, src)
+
return dst, nil
}
@@ -376,3 +402,9 @@ type ImageSource interface {
DecodeImage() (image.Image, error)
Key() string
}
+
+// Giphy represents a GIF Image that may be animated.
+type Giphy interface {
+ image.Image // The first frame.
+ GIF() *gif.GIF // All frames.
+}
diff --git a/resources/testdata/giphy.gif b/resources/testdata/giphy.gif
Binary files differ.
diff --git a/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gif b/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gif
Binary files differ.
diff --git a/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_512x0_resize_box.gif b/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_512x0_resize_box.gif
Binary files differ.