Repository: gohugoio/hugo (language: go)

Issue:
Support processing of animated GIFs
See https://github.com/disintegration/imaging/issues/23


Discussion / hints:
👍 I can look at trying to integrate the example code at disintegration/imaging#23 but my go fu is not strong.
This issue has been automatically marked as stale because it has not had recent activity. The resources of the Hugo team are limited, and so we are asking for your help.
If this is a **bug** and you can still reproduce this error on the <code>master</code> branch, please reply with all of the information you have about it in order to keep the issue open.
If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why.
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.

Since the bot asked...I can see why this feature may not be a priority but it is preventing me from updating to the newest version of hugo-academic ever since it started using image processing of header images (see linked issue). I really think animated images are an appealing feature for header images in blogs and other types of websites.
Yes, I have flagged this issue as "keep" and we will eventually get to it ...
Would it take a lot of work to fix this issue?
In my comment above (https://github.com/gohugoio/hugo/issues/5030#issuecomment-410286337) I think I'd found that it is just a case of doing some extra work around how animated images are processed - which was covered in the disintegration example file.

I've completely forgotten the details though so maybe it's more involved.
>Would it take a lot of work to fix this issue?

Probably not, but any feature will take at least "some hours" when you consider implementation + tests + docs. 
I really wanted this, and took a stab at it. I am way too new to Go and hugo in general to get this working unfortunately, but any advice and pointing in the right direction would help

https://github.com/gohugoio/hugo/compare/master...xconverge:animated-imageprocessing

I am having trouble successfully getting the right io.Writer to use

This seems to be modifying the source image as well (the non resized version)
+1 to the requests for gifs to be supported as featured images for hugo-academic. 
Is it possible to disable image processing for gifs only? So that animation is not lost by using the original file?
@avanc not by configuration. I would attempt to do that by checking the path to the file and look if the extension is gif. See `.File.Ext` at https://gohugo.io/variables/files/#prose
@davidsneighbour Thanks.
With your hint I came up with the following workaround:
```
{{- $ext := path.Ext $image.RelPermalink -}}
{{- if eq $ext ".gif" -}}
  <!-- Do something (or nothing) with gifs -->
{{- else -}}
  <!-- Do something with other images -->
{{- end -}}
```
Animated gifs don't get any love, but animated **PNGs** are already supported I suspect (it worked in the single small test that I did).  That file is still just a .png file. Use an online converter to get yours into that format
https://ezgif.com/apng-maker
@avanc a more cleaner solution would probably be:

```
{{ if eq $image.MediaType.SubType "gif" }}
```
@bep Thanks for the hint. Looks much cleaner and works as expected.

@dmilkie What version did you use? I have the same problems with APNGs as with the GIFs.
I am using the Wowchemy Website Builder, academic template, deploying with netlify.com.  From the deploy log it looks like it is using this version:
 Hugo Static Site Generator v0.80.0-792EF0F4/extended linux/amd64 BuildDate: 2020-12-31T13:46:18Z


Testing with hugo v0.96.0 I don't see support for neither GIF nor APNG (contrary to what @dmilkie mentioned). This would be a fantastic feature to have.
I am waiting for this too. Would be nice to be able to convert animated GIFs to WEBP.
>Is it possible to disable image processing for gifs only? So that animation is not lost by using the original file?

I just tested this, and it animates nicely:

```htmbars
  {{ $giphy := resources.Get "images/misc/giphy.gif" }}
  <img src="{{ $giphy.RelPermalink }}" />
```

We don't edit the original.

Fix patch:
diff --git a/resources/image.go b/resources/image.go
index 3eda709d9b1..8551cc2ab48 100644
--- 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/images/image.go b/resources/images/image.go
index b12f03b4e33..4ffbaa2295a 100644
--- 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.
+}


Test patch:
diff --git a/resources/image_test.go b/resources/image_test.go
index 8f7c95e157b..153a4e8c452 100644
--- 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/testdata/giphy.gif b/resources/testdata/giphy.gif
new file mode 100644
index 00000000000..f82b32cbebb
Binary files /dev/null and b/resources/testdata/giphy.gif 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
new file mode 100644
index 00000000000..ca826432c82
Binary files /dev/null and b/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_200x0_resize_box.gif 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
new file mode 100644
index 00000000000..590d2a7801d
Binary files /dev/null and b/resources/testdata/golden/giphy_hu3eafc418e52414ace6236bf1d31f82e1_52213_512x0_resize_box.gif differ