hugo

Unnamed repository; edit this file 'description' to name the repository.

git clone git://git.shimmy1996.com/hugo.git
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:
Mresources/image.go | 18++++++++++++++++++
Mresources/image_test.go | 76++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mresources/images/image.go | 38+++++++++++++++++++++++++++++++++++---
Aresources/testdata/giphy.gif | 0
Aresources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gif | 0
Aresources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_512x0_resize_box.gif | 0
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.