resources: Fix 2 image file cache key issues - hugo - [fork] hugo port for 9front
 (HTM) git clone git@git.drkhsh.at/hugo.git
 (DIR) Log
 (DIR) Files
 (DIR) Refs
 (DIR) Submodules
 (DIR) README
 (DIR) LICENSE
       ---
 (DIR) commit 1f5a15aba0fc7cc4f4cf4ce1afc8d6db81c63e06
 (DIR) parent 8897113666c9e7e62fed945602910ebcd0ec6421
 (HTM) Author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
       Date:   Thu, 16 Jan 2025 19:15:30 +0100
       
       resources: Fix 2 image file cache key issues
       
       * Always include the content hash in the cache key for unprocessed images.
       * Always include the image config hash in the cache key.
       
       This is also a major cleanup/simplification of the implementation in this area.
       
       Note that this, unfortunately, forces new hashes/filenames for generated images.
       
       Fixes #13273
       Fixes #13272
       
       Diffstat:
         M config/namespace.go                 |       2 +-
         M config/namespace_test.go            |       2 +-
         M hugolib/hugo_sites_multihost_test.… |       4 ++--
         M hugolib/image_test.go               |      16 ++++++++--------
         M hugolib/pagesfromdata/pagesfromgot… |       2 +-
         M hugolib/resource_chain_test.go      |       4 ++--
         M resources/image.go                  |      96 +++++++++++--------------------
         M resources/image_cache.go            |       2 +-
         M resources/image_extended_test.go    |       2 +-
         M resources/image_test.go             |      26 +++++++++++++-------------
         M resources/images/config.go          |     127 +++++++++++++------------------
         M resources/images/config_test.go     |      28 ++++++++++++++--------------
         M resources/images/filters.go         |       5 +++--
         M resources/images/image.go           |       8 ++++----
         M resources/images/images_golden_int… |     142 +++++++++++++++++++++++++++++++
         M resources/images/imagetesting/test… |      25 ++++++++++++++++++++++---
         M resources/images/smartcrop.go       |       6 +++---
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         A resources/images/testdata/images_g… |       0 
         M resources/resource.go               |       5 +++++
         M resources/resource_spec.go          |      42 +++++++++++++++++--------------
         M resources/resources_integration_te… |      12 ++++++------
         A resources/testdata/mask2.png        |       0 
         M resources/transform_test.go         |      15 ++++-----------
         M tpl/resources/resources_integratio… |       2 +-
       
       38 files changed, 341 insertions(+), 232 deletions(-)
       ---
 (DIR) diff --git a/config/namespace.go b/config/namespace.go
       @@ -22,7 +22,7 @@ import (
        func DecodeNamespace[S, C any](configSource any, buildConfig func(any) (C, any, error)) (*ConfigNamespace[S, C], error) {
                // Calculate the hash of the input (not including any defaults applied later).
                // This allows us to introduce new config options without breaking the hash.
       -        h := hashing.HashString(configSource)
       +        h := hashing.HashStringHex(configSource)
        
                // Build the config
                c, ext, err := buildConfig(configSource)
 (DIR) diff --git a/config/namespace_test.go b/config/namespace_test.go
       @@ -43,7 +43,7 @@ func TestNamespace(t *testing.T) {
                c.Assert(err, qt.IsNil)
                c.Assert(ns, qt.Not(qt.IsNil))
                c.Assert(ns.SourceStructure, qt.DeepEquals, map[string]interface{}{"foo": "bar"})
       -        c.Assert(ns.SourceHash, qt.Equals, "1450430416588600409")
       +        c.Assert(ns.SourceHash, qt.Equals, "1420f6c7782f7459")
                c.Assert(ns.Config, qt.DeepEquals, &tstNsExt{Foo: "bar"})
                c.Assert(ns.Signature(), qt.DeepEquals, []*tstNsExt(nil))
        }
 (DIR) diff --git a/hugolib/hugo_sites_multihost_test.go b/hugolib/hugo_sites_multihost_test.go
       @@ -205,9 +205,9 @@ title: mybundle-en
                b.AssertFileExists("public/de/mybundle/pixel.png", true)
                b.AssertFileExists("public/en/mybundle/pixel.png", true)
        
       -        b.AssertFileExists("public/de/mybundle/pixel_hu8581513846771248023.png", true)
       +        b.AssertFileExists("public/de/mybundle/pixel_hu_58204cbc58507d74.png", true)
                // failing test below
       -        b.AssertFileExists("public/en/mybundle/pixel_hu8581513846771248023.png", true)
       +        b.AssertFileExists("public/en/mybundle/pixel_hu_58204cbc58507d74.png", true)
        }
        
        func TestMultihostResourceOneBaseURLWithSuPath(t *testing.T) {
 (DIR) diff --git a/hugolib/image_test.go b/hugolib/image_test.go
       @@ -72,20 +72,20 @@ SUNSET2: {{ $resized2.RelPermalink }}/{{ $resized2.Width }}/Lat: {{ $resized2.Ex
        
                b.Build(BuildCfg{})
        
       -        b.AssertFileContent("public/index.html", "SUNSET FOR: en: /bundle/sunset_hu13235715490294913361.jpg/200/Lat: 36.59744166666667")
       -        b.AssertFileContent("public/fr/index.html", "SUNSET FOR: fr: /bundle/sunset_hu13235715490294913361.jpg/200/Lat: 36.59744166666667")
       -        b.AssertFileContent("public/index.html", " SUNSET2: /images/sunset_hu1573057890424052540.jpg/123/Lat: 36.59744166666667")
       -        b.AssertFileContent("public/nn/index.html", " SUNSET2: /images/sunset_hu1573057890424052540.jpg/123/Lat: 36.59744166666667")
       +        b.AssertFileContent("public/index.html", "SUNSET FOR: en: /bundle/sunset_hu_77061c65c31d2244.jpg/200/Lat: 36.59744166666667")
       +        b.AssertFileContent("public/fr/index.html", "SUNSET FOR: fr: /bundle/sunset_hu_77061c65c31d2244.jpg/200/Lat: 36.59744166666667")
       +        b.AssertFileContent("public/index.html", " SUNSET2: /images/sunset_hu_b52e3343ea6a8764.jpg/123/Lat: 36.59744166666667")
       +        b.AssertFileContent("public/nn/index.html", " SUNSET2: /images/sunset_hu_b52e3343ea6a8764.jpg/123/Lat: 36.59744166666667")
        
       -        b.AssertImage(200, 200, "public/bundle/sunset_hu13235715490294913361.jpg")
       +        b.AssertImage(200, 200, "public/bundle/sunset_hu_77061c65c31d2244.jpg")
        
                // Check the file cache
       -        b.AssertImage(200, 200, "resources/_gen/images/bundle/sunset_hu13235715490294913361.jpg")
       +        b.AssertImage(200, 200, "resources/_gen/images/bundle/sunset_hu_77061c65c31d2244.jpg")
        
       -        b.AssertFileContent("resources/_gen/images/bundle/sunset_17710516992648092201.json",
       +        b.AssertFileContent("resources/_gen/images/bundle/sunset_d209dcdc6b875e26.json",
                        "FocalLengthIn35mmFormat|uint16", "PENTAX")
        
       -        b.AssertFileContent("resources/_gen/images/images/sunset_17710516992648092201.json",
       +        b.AssertFileContent("resources/_gen/images/images/sunset_d209dcdc6b875e26.json",
                        "FocalLengthIn35mmFormat|uint16", "PENTAX")
        
                b.AssertNoDuplicateWrites()
 (DIR) diff --git a/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go
       @@ -119,7 +119,7 @@ docs/p1/sub/mymixcasetext2.txt
                        "RelPermalink: /docs/p1/sub/mymixcasetext2.txt|Name: sub/mymixcasetext2.txt|",
                        "RelPermalink: /mydata.yaml|Name: sub/data1.yaml|Title: Sub data|Params: map[]|",
                        "Featured Image: /a/pixel.png|featured.png|",
       -                "Resized Featured Image: /a/pixel_hu16809842526914527184.png|10|",
       +                "Resized Featured Image: /a/pixel_hu_a32b3e361d55df1.png|10|",
                        // Resource from string
                        "RelPermalink: /docs/p1/mytext.txt|Name: textresource|Title: My Text Resource|Params: map[param1:param1v]|",
                        // Dates
 (DIR) diff --git a/hugolib/resource_chain_test.go b/hugolib/resource_chain_test.go
       @@ -106,12 +106,12 @@ FAILED REMOTE ERROR DETAILS CONTENT: {{ with $failedImg }}{{ with .Err }}{{ with
                        b.AssertFileContent("public/index.html",
                                fmt.Sprintf(`
        SUNSET: /images/sunset.jpg|/images/sunset.a9bf1d944e19c0f382e0d8f51de690f7d0bc8fa97390c4242a86c3e5c0737e71.jpg|900|90587
       -FIT: /images/sunset.jpg|/images/sunset_hu15210517121918042184.jpg|200
       +FIT: /images/sunset.jpg|/images/sunset_hu_f2aae87288f3c13b.jpg|200
        CSS integrity Data first: sha256-od9YaHw8nMOL8mUy97Sy8sKwMV3N4hI3aVmZXATxH&#43;8= /styles.min.a1df58687c3c9cc38bf26532f7b4b2f2c2b0315dcde212376959995c04f11fef.css
        CSS integrity Data last:  /styles2.min.1cfc52986836405d37f9998a63fd6dd8608e8c410e5e3db1daaa30f78bc273ba.css sha256-HPxSmGg2QF03&#43;ZmKY/1t2GCOjEEOXj2x2qow94vCc7o=
        
        SUNSET REMOTE: /sunset_%[1]s.jpg|/sunset_%[1]s.a9bf1d944e19c0f382e0d8f51de690f7d0bc8fa97390c4242a86c3e5c0737e71.jpg|900|90587
       -FIT REMOTE: /sunset_%[1]s.jpg|/sunset_%[1]s_hu15210517121918042184.jpg|200
       +FIT REMOTE: /sunset_%[1]s.jpg|/sunset_%[1]s_hu_f2aae87288f3c13b.jpg|200
        REMOTE NOT FOUND: OK
        LOCAL NOT FOUND: OK
        PRINT PROTOCOL ERROR DETAILS: Err: template: index.html:22:36: executing "index.html" at <resources.GetRemote>: error calling GetRemote: Get "gopher://example.org": unsupported protocol scheme "gopher"|
 (DIR) diff --git a/resources/image.go b/resources/image.go
       @@ -30,7 +30,6 @@ import (
        
                "github.com/gohugoio/hugo/cache/filecache"
                "github.com/gohugoio/hugo/common/hashing"
       -        "github.com/gohugoio/hugo/common/hstrings"
                "github.com/gohugoio/hugo/common/paths"
        
                "github.com/disintegration/gift"
       @@ -205,15 +204,12 @@ func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource,
                }, nil
        }
        
       -var imageActions = []string{images.ActionResize, images.ActionCrop, images.ActionFit, images.ActionFill}
       -
        // Process processes the image with the given spec.
        // The spec can contain an optional action, one of "resize", "crop", "fit" or "fill".
        // This makes this method a more flexible version that covers all of Resize, Crop, Fit and Fill,
        // but it also supports e.g. format conversions without any resize action.
        func (i *imageResource) Process(spec string) (images.ImageResource, error) {
       -        action, options := i.resolveActionOptions(spec)
       -        return i.processActionOptions(action, options)
       +        return i.processActionSpec("", spec)
        }
        
        // Resize resizes the image to the specified width and height using the specified resampling
       @@ -243,7 +239,7 @@ func (i *imageResource) Fill(spec string) (images.ImageResource, error) {
        }
        
        func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
       -        var conf images.ImageConfig
       +        var confMain images.ImageConfig
        
                var gfilters []gift.Filter
        
       @@ -251,47 +247,30 @@ func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
                        gfilters = append(gfilters, images.ToFilters(f)...)
                }
        
       -        var (
       -                targetFormat images.Format
       -                configSet    bool
       -        )
       +        var options []string
       +
                for _, f := range gfilters {
                        f = images.UnwrapFilter(f)
                        if specProvider, ok := f.(images.ImageProcessSpecProvider); ok {
       -                        action, options := i.resolveActionOptions(specProvider.ImageProcessSpec())
       -                        var err error
       -                        conf, err = images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
       -                        if err != nil {
       -                                return nil, err
       -                        }
       -                        configSet = true
       -                        if conf.TargetFormat != 0 {
       -                                targetFormat = conf.TargetFormat
       -                                // We only support one target format, but prefer the last one,
       -                                // so we keep going.
       -                        }
       +                        options = append(options, strings.Fields(specProvider.ImageProcessSpec())...)
                        }
                }
        
       -        if !configSet {
       -                conf = images.GetDefaultImageConfig("filter", i.Proc.Cfg)
       +        confMain, err := images.DecodeImageConfig(options, i.Proc.Cfg, i.Format)
       +        if err != nil {
       +                return nil, err
                }
        
       -        conf.Action = "filter"
       -        conf.Key = hashing.HashString(gfilters)
       -        conf.TargetFormat = targetFormat
       -        if conf.TargetFormat == 0 {
       -                conf.TargetFormat = i.Format
       -        }
       +        confMain.Action = "filter"
       +        confMain.Key = hashing.HashString(gfilters)
        
       -        return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
       +        return i.doWithImageConfig(confMain, func(src image.Image) (image.Image, error) {
                        var filters []gift.Filter
                        for _, f := range gfilters {
                                f = images.UnwrapFilter(f)
                                if specProvider, ok := f.(images.ImageProcessSpecProvider); ok {
       -                                processSpec := specProvider.ImageProcessSpec()
       -                                action, options := i.resolveActionOptions(processSpec)
       -                                conf, err := images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
       +                                options := strings.Fields(specProvider.ImageProcessSpec())
       +                                conf, err := images.DecodeImageConfig(options, i.Proc.Cfg, i.Format)
                                        if err != nil {
                                                return nil, err
                                        }
       @@ -313,25 +292,13 @@ func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
                })
        }
        
       -func (i *imageResource) resolveActionOptions(spec string) (string, []string) {
       -        var action string
       -        options := strings.Fields(spec)
       -        for i, p := range options {
       -                if hstrings.InSlicEqualFold(imageActions, p) {
       -                        action = p
       -                        options = append(options[:i], options[i+1:]...)
       -                        break
       -                }
       -        }
       -        return action, options
       -}
       -
        func (i *imageResource) processActionSpec(action, spec string) (images.ImageResource, error) {
       -        return i.processActionOptions(action, strings.Fields(spec))
       +        options := append([]string{action}, strings.Fields(strings.ToLower(spec))...)
       +        return i.processOptions(options)
        }
        
       -func (i *imageResource) processActionOptions(action string, options []string) (images.ImageResource, error) {
       -        conf, err := images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
       +func (i *imageResource) processOptions(options []string) (images.ImageResource, error) {
       +        conf, err := images.DecodeImageConfig(options, i.Proc.Cfg, i.Format)
                if err != nil {
                        return nil, err
                }
       @@ -343,13 +310,12 @@ func (i *imageResource) processActionOptions(action string, options []string) (i
                        return nil, err
                }
        
       -        if action == images.ActionFill {
       -                if conf.Anchor == 0 && img.Width() == 0 || img.Height() == 0 {
       +        if conf.Action == images.ActionFill {
       +                if conf.Anchor == images.SmartCropAnchor && img.Width() == 0 || img.Height() == 0 {
                                // See https://github.com/gohugoio/hugo/issues/7955
                                // Smartcrop fails silently in some rare cases.
                                // Fall back to a center fill.
       -                        conf.Anchor = gift.CenterAnchor
       -                        conf.AnchorStr = "center"
       +                        conf = conf.Reanchor(gift.CenterAnchor)
                                return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
                                        return i.Proc.ApplyFiltersFromConfig(src, conf)
                                })
       @@ -417,7 +383,7 @@ func (i *imageResource) doWithImageConfig(conf images.ImageConfig, f func(src im
                        }
        
                        ci := i.clone(converted)
       -                targetPath := i.relTargetPathFromConfig(conf)
       +                targetPath := i.relTargetPathFromConfig(conf, i.getSpec().imaging.Cfg.SourceHash)
                        ci.setTargetPath(targetPath)
                        ci.Format = conf.TargetFormat
                        ci.setMediaType(conf.TargetFormat.MediaType())
       @@ -485,26 +451,30 @@ func (i *imageResource) getImageMetaCacheTargetPath() string {
                df := i.getResourcePaths()
                p1, _ := paths.FileAndExt(df.File)
                h := i.hash()
       -        idStr := hashing.HashString(h, i.size(), imageMetaVersionNumber, cfgHash)
       +        idStr := hashing.HashStringHex(h, i.size(), imageMetaVersionNumber, cfgHash)
                df.File = fmt.Sprintf("%s_%s.json", p1, idStr)
                return df.TargetPath()
        }
        
       -func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig) internal.ResourcePaths {
       +func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig, imagingConfigSourceHash string) internal.ResourcePaths {
                p1, p2 := paths.FileAndExt(i.getResourcePaths().File)
                if conf.TargetFormat != i.Format {
                        p2 = conf.TargetFormat.DefaultExtension()
                }
       -        const prefix = "_hu"
       -        huIdx := strings.LastIndex(p1, prefix)
       -        incomingID := "i"
       +
       +        // Do not change.
       +        const imageHashPrefix = "_hu_"
       +
       +        huIdx := strings.LastIndex(p1, imageHashPrefix)
       +        incomingID := ""
                if huIdx > -1 {
       -                incomingID = p1[huIdx+len(prefix):]
       +                incomingID = p1[huIdx+len(imageHashPrefix):]
                        p1 = p1[:huIdx]
                }
       -        hash := hashing.HashUint64(incomingID, i.hash(), conf.GetKey(i.Format))
       +
       +        hash := hashing.HashStringHex(incomingID, i.hash(), conf.Key, imagingConfigSourceHash)
                rp := i.getResourcePaths()
       -        rp.File = fmt.Sprintf("%s%s%d%s", p1, prefix, hash, p2)
       +        rp.File = fmt.Sprintf("%s%s%s%s", p1, imageHashPrefix, hash, p2)
        
                return rp
        }
 (DIR) diff --git a/resources/image_cache.go b/resources/image_cache.go
       @@ -37,7 +37,7 @@ func (c *ImageCache) getOrCreate(
                parent *imageResource, conf images.ImageConfig,
                createImage func() (*imageResource, image.Image, error),
        ) (*resourceAdapter, error) {
       -        relTarget := parent.relTargetPathFromConfig(conf)
       +        relTarget := parent.relTargetPathFromConfig(conf, parent.getSpec().imaging.Cfg.SourceHash)
                relTargetPath := relTarget.TargetPath()
                memKey := relTargetPath
        
 (DIR) diff --git a/resources/image_extended_test.go b/resources/image_extended_test.go
       @@ -42,6 +42,6 @@ func TestImageResizeWebP(t *testing.T) {
                resized, err := image.Resize("123x")
                c.Assert(err, qt.IsNil)
                c.Assert(image.MediaType(), qt.Equals, media.Builtin.WEBPType)
       -        c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunrise_hu544374262273649331.webp")
       +        c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunrise_hu_a1deb893888915d9.webp")
                c.Assert(resized.Width(), qt.Equals, 123)
        }
 (DIR) diff --git a/resources/image_test.go b/resources/image_test.go
       @@ -113,28 +113,28 @@ func TestImageTransformBasic(t *testing.T) {
                assertWidthHeight(resizedAndRotated, 125, 200)
        
                assertWidthHeight(resized, 300, 200)
       -        c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunset_hu2082030801149749592.jpg")
       +        c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunset_hu_d2115125d9324a79.jpg")
        
                fitted, err := resized.Fit("50x50")
                c.Assert(err, qt.IsNil)
       -        c.Assert(fitted.RelPermalink(), qt.Equals, "/a/sunset_hu16263619592447877226.jpg")
       +        c.Assert(fitted.RelPermalink(), qt.Equals, "/a/sunset_hu_c2c98e06123b048e.jpg")
                assertWidthHeight(fitted, 50, 33)
        
                // Check the MD5 key threshold
                fittedAgain, _ := fitted.Fit("10x20")
                fittedAgain, err = fittedAgain.Fit("10x20")
                c.Assert(err, qt.IsNil)
       -        c.Assert(fittedAgain.RelPermalink(), qt.Equals, "/a/sunset_hu847809310637164306.jpg")
       +        c.Assert(fittedAgain.RelPermalink(), qt.Equals, "/a/sunset_hu_dc9e89c10109de72.jpg")
                assertWidthHeight(fittedAgain, 10, 7)
        
                filled, err := image.Fill("200x100 bottomLeft")
                c.Assert(err, qt.IsNil)
       -        c.Assert(filled.RelPermalink(), qt.Equals, "/a/sunset_hu18289448341423092707.jpg")
       +        c.Assert(filled.RelPermalink(), qt.Equals, "/a/sunset_hu_b9f6d350738928fe.jpg")
                assertWidthHeight(filled, 200, 100)
        
                smart, err := image.Fill("200x100 smart")
                c.Assert(err, qt.IsNil)
       -        c.Assert(smart.RelPermalink(), qt.Equals, "/a/sunset_hu11649371610839769766.jpg")
       +        c.Assert(smart.RelPermalink(), qt.Equals, "/a/sunset_hu_6fd390e7b0d26f0b.jpg")
                assertWidthHeight(smart, 200, 100)
        
                // Check cache
       @@ -144,12 +144,12 @@ func TestImageTransformBasic(t *testing.T) {
        
                cropped, err := image.Crop("300x300 topRight")
                c.Assert(err, qt.IsNil)
       -        c.Assert(cropped.RelPermalink(), qt.Equals, "/a/sunset_hu2242042514052853140.jpg")
       +        c.Assert(cropped.RelPermalink(), qt.Equals, "/a/sunset_hu_3df036e11f4ddd43.jpg")
                assertWidthHeight(cropped, 300, 300)
        
                smartcropped, err := image.Crop("200x200 smart")
                c.Assert(err, qt.IsNil)
       -        c.Assert(smartcropped.RelPermalink(), qt.Equals, "/a/sunset_hu12983255101170993571.jpg")
       +        c.Assert(smartcropped.RelPermalink(), qt.Equals, "/a/sunset_hu_12e2d26de89b464b.jpg")
                assertWidthHeight(smartcropped, 200, 200)
        
                // Check cache
       @@ -216,7 +216,7 @@ func TestImageTransformFormat(t *testing.T) {
        
                imagePng, err := image.Resize("450x png")
                c.Assert(err, qt.IsNil)
       -        c.Assert(imagePng.RelPermalink(), qt.Equals, "/a/sunset_hu11737890885216583918.png")
       +        c.Assert(imagePng.RelPermalink(), qt.Equals, "/a/sunset_hu_e8b9444dcf2e75ef.png")
                c.Assert(imagePng.ResourceType(), qt.Equals, "image")
                assertExtWidthHeight(imagePng, ".png", 450, 281)
                c.Assert(imagePng.Name(), qt.Equals, "sunset.jpg")
       @@ -224,7 +224,7 @@ func TestImageTransformFormat(t *testing.T) {
        
                imageGif, err := image.Resize("225x gif")
                c.Assert(err, qt.IsNil)
       -        c.Assert(imageGif.RelPermalink(), qt.Equals, "/a/sunset_hu1431827106749674475.gif")
       +        c.Assert(imageGif.RelPermalink(), qt.Equals, "/a/sunset_hu_f80842d4c3789345.gif")
                c.Assert(imageGif.ResourceType(), qt.Equals, "image")
                assertExtWidthHeight(imageGif, ".gif", 225, 141)
                c.Assert(imageGif.Name(), qt.Equals, "sunset.jpg")
       @@ -247,7 +247,7 @@ func TestImagePermalinkPublishOrder(t *testing.T) {
                                }()
        
                                check1 := func(img images.ImageResource) {
       -                                resizedLink := "/a/sunset_hu7919355342577096259.jpg"
       +                                resizedLink := "/a/sunset_hu_3910bca82e28c9d6.jpg"
                                        c.Assert(img.RelPermalink(), qt.Equals, resizedLink)
                                        assertImageFile(c, spec.PublishFs, resizedLink, 100, 50)
                                }
       @@ -288,12 +288,12 @@ func TestImageBugs(t *testing.T) {
                        c.Assert(err, qt.IsNil)
                        c.Assert(resized, qt.Not(qt.IsNil))
                        c.Assert(resized.Width(), qt.Equals, 200)
       -                c.Assert(resized.RelPermalink(), qt.Equals, "/a/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph_hu9514381480012510326.jpg")
       +                c.Assert(resized.RelPermalink(), qt.Equals, "/a/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph_hu_951d3980b18c52a9.jpg")
                        resized, err = resized.Resize("100x")
                        c.Assert(err, qt.IsNil)
                        c.Assert(resized, qt.Not(qt.IsNil))
                        c.Assert(resized.Width(), qt.Equals, 100)
       -                c.Assert(resized.RelPermalink(), qt.Equals, "/a/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph_hu1776700126481066216.jpg")
       +                c.Assert(resized.RelPermalink(), qt.Equals, "/a/1234567890qwertyuiopasdfghjklzxcvbnm5to6eeeeee7via8eleph_hu_1daa203572ecd6ec.jpg")
                })
        
                // Issue #6137
       @@ -391,7 +391,7 @@ func TestImageResize8BitPNG(t *testing.T) {
                resized, err := image.Resize("800x")
                c.Assert(err, qt.IsNil)
                c.Assert(resized.MediaType().Type, qt.Equals, "image/png")
       -        c.Assert(resized.RelPermalink(), qt.Equals, "/a/gohugoio_hu8582372628235034388.png")
       +        c.Assert(resized.RelPermalink(), qt.Equals, "/a/gohugoio_hu_fe2b762e9cac406c.png")
                c.Assert(resized.Width(), qt.Equals, 800)
        }
        
 (DIR) diff --git a/resources/images/config.go b/resources/images/config.go
       @@ -20,6 +20,7 @@ import (
                "strconv"
                "strings"
        
       +        "github.com/gohugoio/hugo/common/hashing"
                "github.com/gohugoio/hugo/common/maps"
                "github.com/gohugoio/hugo/config"
                "github.com/gohugoio/hugo/media"
       @@ -37,6 +38,13 @@ const (
                ActionFill   = "fill"
        )
        
       +var Actions = map[string]bool{
       +        ActionResize: true,
       +        ActionCrop:   true,
       +        ActionFit:    true,
       +        ActionFill:   true,
       +}
       +
        var (
                imageFormats = map[string]Format{
                        ".jpg":  JPEG,
       @@ -64,9 +72,9 @@ var (
                // Add or increment if changes to an image format's processing requires
                // re-generation.
                imageFormatsVersions = map[Format]int{
       -                PNG:  3, // Fix transparency issue with 32 bit images.
       -                WEBP: 2, // Fix transparency issue with 32 bit images.
       -                GIF:  1, // Fix resize issue with animated GIFs when target != GIF.
       +                PNG:  0,
       +                WEBP: 0,
       +                GIF:  0,
                }
        
                // Increment to mark all processed images as stale. Only use when absolutely needed.
       @@ -84,6 +92,7 @@ var anchorPositions = map[string]gift.Anchor{
                strings.ToLower("BottomLeft"):  gift.BottomLeftAnchor,
                strings.ToLower("Bottom"):      gift.BottomAnchor,
                strings.ToLower("BottomRight"): gift.BottomRightAnchor,
       +        smartCropIdentifier:            SmartCropAnchor,
        }
        
        // These encoding hints are currently only relevant for Webp.
       @@ -176,7 +185,7 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
                                return i, nil, err
                        }
        
       -                if i.Imaging.Anchor != "" && i.Imaging.Anchor != smartCropIdentifier {
       +                if i.Imaging.Anchor != "" {
                                anchor, found := anchorPositions[i.Imaging.Anchor]
                                if !found {
                                        return i, nil, fmt.Errorf("invalid anchor value %q in imaging config", i.Anchor)
       @@ -201,36 +210,34 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
                return ns, nil
        }
        
       -func DecodeImageConfig(action string, options []string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], sourceFormat Format) (ImageConfig, error) {
       +func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], sourceFormat Format) (ImageConfig, error) {
                var (
       -                c   ImageConfig = GetDefaultImageConfig(action, defaults)
       +                c   ImageConfig = GetDefaultImageConfig(defaults)
                        err error
                )
        
       -        action = strings.ToLower(action)
       -
       -        c.Action = action
       -
       -        if options == nil {
       -                return c, errors.New("image options cannot be empty")
       +        // Make to lower case, trim space and remove any empty strings.
       +        n := 0
       +        for _, s := range options {
       +                s = strings.TrimSpace(s)
       +                if s != "" {
       +                        options[n] = strings.ToLower(s)
       +                        n++
       +                }
                }
       +        options = options[:n]
        
                for _, part := range options {
       -                part = strings.ToLower(part)
       -
       -                if part == smartCropIdentifier {
       -                        c.AnchorStr = smartCropIdentifier
       +                if _, ok := Actions[part]; ok {
       +                        c.Action = part
                        } else if pos, ok := anchorPositions[part]; ok {
                                c.Anchor = pos
       -                        c.AnchorStr = part
                        } else if filter, ok := imageFilters[part]; ok {
                                c.Filter = filter
       -                        c.FilterStr = part
                        } else if hint, ok := hints[part]; ok {
                                c.Hint = hint
                        } else if part[0] == '#' {
       -                        c.BgColorStr = part[1:]
       -                        c.BgColor, err = hexStringToColorGo(c.BgColorStr)
       +                        c.BgColor, err = hexStringToColorGo(part[1:])
                                if err != nil {
                                        return c, err
                                }
       @@ -291,8 +298,7 @@ func DecodeImageConfig(action string, options []string, defaults *config.ConfigN
                        }
                }
        
       -        if action != "" && c.FilterStr == "" {
       -                c.FilterStr = defaults.Config.Imaging.ResampleFilter
       +        if c.Action != "" && c.Filter == nil {
                        c.Filter = defaults.Config.ResampleFilter
                }
        
       @@ -300,8 +306,7 @@ func DecodeImageConfig(action string, options []string, defaults *config.ConfigN
                        c.Hint = webpoptions.EncodingPresetPhoto
                }
        
       -        if action != "" && c.AnchorStr == "" {
       -                c.AnchorStr = defaults.Config.Imaging.Anchor
       +        if c.Action != "" && c.Anchor == -1 {
                        c.Anchor = defaults.Config.Anchor
                }
        
       @@ -318,10 +323,23 @@ func DecodeImageConfig(action string, options []string, defaults *config.ConfigN
                if c.BgColor == nil && c.TargetFormat != sourceFormat {
                        if sourceFormat.SupportsTransparency() && !c.TargetFormat.SupportsTransparency() {
                                c.BgColor = defaults.Config.BgColor
       -                        c.BgColorStr = defaults.Config.Imaging.BgColor
                        }
                }
        
       +        if mainImageVersionNumber > 0 {
       +                options = append(options, strconv.Itoa(mainImageVersionNumber))
       +        }
       +
       +        if v, ok := imageFormatsVersions[sourceFormat]; ok && v > 0 {
       +                options = append(options, strconv.Itoa(v))
       +        }
       +
       +        if smartCropVersionNumber > 0 && c.Anchor == SmartCropAnchor {
       +                options = append(options, strconv.Itoa(smartCropVersionNumber))
       +        }
       +
       +        c.Key = hashing.HashStringHex(options)
       +
                return c, nil
        }
        
       @@ -350,8 +368,7 @@ type ImageConfig struct {
                // not support transparency.
                // When set per image operation, it's used even for formats that does support
                // transparency.
       -        BgColor    color.Color
       -        BgColorStr string
       +        BgColor color.Color
        
                // Hint about what type of picture this is. Used to optimize encoding
                // when target is set to webp.
       @@ -360,57 +377,15 @@ type ImageConfig struct {
                Width  int
                Height int
        
       -        Filter    gift.Resampling
       -        FilterStr string
       +        Filter gift.Resampling
        
       -        Anchor    gift.Anchor
       -        AnchorStr string
       +        Anchor gift.Anchor
        }
        
       -func (i ImageConfig) GetKey(format Format) string {
       -        if i.Key != "" {
       -                return i.Action + "_" + i.Key
       -        }
       -
       -        k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height)
       -        if i.Action != "" {
       -                k += "_" + i.Action
       -        }
       -        // This slightly odd construct is here to preserve the old image keys.
       -        if i.qualitySetForImage || i.TargetFormat.RequiresDefaultQuality() {
       -                k += "_q" + strconv.Itoa(i.Quality)
       -        }
       -        if i.Rotate != 0 {
       -                k += "_r" + strconv.Itoa(i.Rotate)
       -        }
       -        if i.BgColorStr != "" {
       -                k += "_bg" + i.BgColorStr
       -        }
       -
       -        if i.TargetFormat == WEBP {
       -                k += "_h" + strconv.Itoa(int(i.Hint))
       -        }
       -
       -        anchor := i.AnchorStr
       -        if anchor == smartCropIdentifier {
       -                anchor = anchor + strconv.Itoa(smartCropVersionNumber)
       -        }
       -
       -        k += "_" + i.FilterStr
       -
       -        if i.Action == ActionFill || i.Action == ActionCrop {
       -                k += "_" + anchor
       -        }
       -
       -        if v, ok := imageFormatsVersions[format]; ok {
       -                k += "_" + strconv.Itoa(v)
       -        }
       -
       -        if mainImageVersionNumber > 0 {
       -                k += "_" + strconv.Itoa(mainImageVersionNumber)
       -        }
       -
       -        return k
       +func (cfg ImageConfig) Reanchor(a gift.Anchor) ImageConfig {
       +        cfg.Anchor = a
       +        cfg.Key = hashing.HashStringHex(cfg.Key, "reanchor", a)
       +        return cfg
        }
        
        type ImagingConfigInternal struct {
       @@ -429,7 +404,7 @@ func (i *ImagingConfigInternal) Compile(externalCfg *ImagingConfig) error {
                        return err
                }
        
       -        if externalCfg.Anchor != "" && externalCfg.Anchor != smartCropIdentifier {
       +        if externalCfg.Anchor != "" {
                        anchor, found := anchorPositions[externalCfg.Anchor]
                        if !found {
                                return fmt.Errorf("invalid anchor value %q in imaging config", i.Anchor)
 (DIR) diff --git a/resources/images/config_test.go b/resources/images/config_test.go
       @@ -19,6 +19,7 @@ import (
                "testing"
        
                qt "github.com/frankban/quicktest"
       +        "github.com/gohugoio/hugo/common/hashing"
        )
        
        func TestDecodeConfig(t *testing.T) {
       @@ -106,7 +107,8 @@ func TestDecodeImageConfig(t *testing.T) {
                        if err != nil {
                                t.Fatal(err)
                        }
       -                result, err := DecodeImageConfig(this.action, strings.Fields(this.in), cfg, PNG)
       +                options := append([]string{this.action}, strings.Fields(this.in)...)
       +                result, err := DecodeImageConfig(options, cfg, PNG)
                        if b, ok := this.expect.(bool); ok && !b {
                                if err == nil {
                                        t.Errorf("[%d] parseImageConfig didn't return an expected error", i)
       @@ -115,15 +117,19 @@ func TestDecodeImageConfig(t *testing.T) {
                                if err != nil {
                                        t.Fatalf("[%d] err: %s", i, err)
                                }
       -                        if fmt.Sprint(result) != fmt.Sprint(this.expect) {
       -                                t.Fatalf("[%d] got\n%v\n but expected\n%v", i, result, this.expect)
       +                        expect := this.expect.(ImageConfig)
       +                        expect.Key = hashing.HashStringHex(options)
       +
       +                        if fmt.Sprint(result) != fmt.Sprint(expect) {
       +                                t.Fatalf("[%d] got\n%v\n but expected\n%v", i, result, expect)
                                }
                        }
                }
        }
        
        func newImageConfig(action string, width, height, quality, rotate int, filter, anchor, bgColor string) ImageConfig {
       -        var c ImageConfig = GetDefaultImageConfig(action, nil)
       +        var c ImageConfig = GetDefaultImageConfig(nil)
       +        c.Action = action
                c.TargetFormat = PNG
                c.Hint = 2
                c.Width = width
       @@ -131,26 +137,20 @@ func newImageConfig(action string, width, height, quality, rotate int, filter, a
                c.Quality = quality
                c.qualitySetForImage = quality != 75
                c.Rotate = rotate
       -        c.BgColorStr = bgColor
                c.BgColor, _ = hexStringToColorGo(bgColor)
       +        c.Anchor = SmartCropAnchor
        
                if filter != "" {
                        filter = strings.ToLower(filter)
                        if v, ok := imageFilters[filter]; ok {
                                c.Filter = v
       -                        c.FilterStr = filter
                        }
                }
        
                if anchor != "" {
       -                if anchor == smartCropIdentifier {
       -                        c.AnchorStr = anchor
       -                } else {
       -                        anchor = strings.ToLower(anchor)
       -                        if v, ok := anchorPositions[anchor]; ok {
       -                                c.Anchor = v
       -                                c.AnchorStr = anchor
       -                        }
       +                anchor = strings.ToLower(anchor)
       +                if v, ok := anchorPositions[anchor]; ok {
       +                        c.Anchor = v
                        }
                }
        
 (DIR) diff --git a/resources/images/filters.go b/resources/images/filters.go
       @@ -36,10 +36,11 @@ type Filters struct{}
        
        // Process creates a filter that processes an image using the given specification.
        func (*Filters) Process(spec any) gift.Filter {
       +        specs := strings.ToLower(cast.ToString(spec))
                return filter{
       -                Options: newFilterOpts(spec),
       +                Options: newFilterOpts(specs),
                        Filter: processFilter{
       -                        spec: cast.ToString(spec),
       +                        spec: specs,
                        },
                }
        }
 (DIR) diff --git a/resources/images/image.go b/resources/images/image.go
       @@ -217,7 +217,7 @@ func (p *ImageProcessor) FiltersFromConfig(src image.Image, conf ImageConfig) ([
                case "resize":
                        filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter))
                case "crop":
       -                if conf.AnchorStr == smartCropIdentifier {
       +                if conf.Anchor == SmartCropAnchor {
                                bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter)
                                if err != nil {
                                        return nil, err
       @@ -232,7 +232,7 @@ func (p *ImageProcessor) FiltersFromConfig(src image.Image, conf ImageConfig) ([
                                filters = append(filters, gift.CropToSize(conf.Width, conf.Height, conf.Anchor))
                        }
                case "fill":
       -                if conf.AnchorStr == smartCropIdentifier {
       +                if conf.Anchor == SmartCropAnchor {
                                bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter)
                                if err != nil {
                                        return nil, err
       @@ -329,12 +329,12 @@ func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters 
                return dst, nil
        }
        
       -func GetDefaultImageConfig(action string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) ImageConfig {
       +func GetDefaultImageConfig(defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) ImageConfig {
                if defaults == nil {
                        defaults = defaultImageConfig
                }
                return ImageConfig{
       -                Action:  action,
       +                Anchor:  -1, // The real values start at 0.
                        Hint:    defaults.Config.Hint,
                        Quality: defaults.Config.Imaging.Quality,
                }
 (DIR) diff --git a/resources/images/images_golden_integration_test.go b/resources/images/images_golden_integration_test.go
       @@ -15,6 +15,7 @@ package images_test
        
        import (
                _ "image/jpeg"
       +        "strings"
                "testing"
        
                "github.com/gohugoio/hugo/resources/images/imagetesting"
       @@ -158,6 +159,76 @@ the last entry will win.
                imagetesting.RunGolden(opts)
        }
        
       +// Issue 13272, 13273.
       +func TestImagesGoldenFiltersMaskCacheIssues(t *testing.T) {
       +        if imagetesting.SkipGoldenTests {
       +                t.Skip("Skip golden test on this architecture")
       +        }
       +
       +        // Will be used as the base folder for generated images.
       +        name := "filters/mask2"
       +
       +        files := `
       +-- hugo.toml --
       +[caches]
       +  [caches.images]
       +    dir = ':cacheDir/golden_images'
       +        maxAge = "30s"
       +[imaging]
       +  bgColor = '#33ff44'
       +  hint = 'photo'
       +  quality = 75
       +  resampleFilter = 'Lanczos'
       +-- assets/sunset.jpg --
       +sourcefilename: ../testdata/sunset.jpg
       +-- assets/mask.png --
       +sourcefilename: ../testdata/mask.png
       +
       +-- layouts/index.html --
       +Home.
       +{{ $sunset := resources.Get "sunset.jpg" }}
       +{{ $mask := resources.Get "mask.png" }}
       +
       +
       +{{ template "mask" (dict "name" "green.jpg" "base" $sunset  "mask" $mask) }}
       +
       +{{ define "mask"}}
       +{{ $ext := path.Ext .name }}
       +{{ if lt (len (path.Ext .name)) 4 }}
       +        {{ errorf "No extension in %q" .name }}
       +{{ end }}
       +{{ $format := strings.TrimPrefix "." $ext }}
       +{{ $spec := .spec | default (printf "resize x300 %s" $format) }}
       +{{ $filters := slice (images.Process $spec) (images.Mask .mask) }}
       +{{ $name := printf "images/%s" .name  }}
       +{{ $img := .base.Filter $filters }}
       +{{ with $img | resources.Copy $name }}
       +{{ .Publish }}
       +{{ end }}
       +{{ end }}
       +`
       +
       +        tempDir := t.TempDir()
       +
       +        opts := imagetesting.DefaultGoldenOpts
       +        opts.WorkingDir = tempDir
       +        opts.T = t
       +        opts.Name = name
       +        opts.Files = files
       +        opts.SkipAssertions = true
       +
       +        imagetesting.RunGolden(opts)
       +
       +        files = strings.Replace(files, "#33ff44", "#a83269", -1)
       +        files = strings.Replace(files, "green", "pink", -1)
       +        files = strings.Replace(files, "mask.png", "mask2.png", -1)
       +        opts.Files = files
       +        opts.SkipAssertions = false
       +        opts.Rebuild = true
       +
       +        imagetesting.RunGolden(opts)
       +}
       +
        func TestImagesGoldenFiltersText(t *testing.T) {
                t.Parallel()
        
       @@ -263,3 +334,74 @@ Home.
        
                imagetesting.RunGolden(opts)
        }
       +
       +func TestImagesGoldenMethods(t *testing.T) {
       +        t.Parallel()
       +
       +        if imagetesting.SkipGoldenTests {
       +                t.Skip("Skip golden test on this architecture")
       +        }
       +
       +        // Will be used as the base folder for generated images.
       +        name := "methods"
       +
       +        files := `
       +-- hugo.toml --
       +[imaging]
       +  bgColor = '#ebcc34'
       +  hint = 'photo'
       +  quality = 75
       +  resampleFilter = 'MitchellNetravali'
       +-- assets/sunset.jpg --
       +sourcefilename: ../testdata/sunset.jpg
       +-- assets/gopher.png --
       +sourcefilename: ../testdata/gopher-hero8.png
       +
       +-- layouts/index.html --
       +Home.
       +{{ $sunset := resources.Get "sunset.jpg" }}
       +{{ $gopher := resources.Get "gopher.png" }}
       +
       +
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "resize"  "spec" "300x" ) }}
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "resize" "spec" "x200" ) }}
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "fill"  "spec" "90x120 left" ) }}
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "fill"  "spec" "90x120 right" ) }}
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "fit"  "spec" "200x200" ) }}
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "200x200" ) }}
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "350x400 center" ) }}
       + {{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "350x400 smart" ) }}
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "350x400 center r90" ) }}
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop"  "spec" "350x400 center q20" ) }}
       +{{ template "invoke" (dict "copyFormat" "png" "base" $gopher "method" "resize"  "spec" "100x" ) }}
       +{{ template "invoke" (dict "copyFormat" "png" "base" $gopher "method" "resize"  "spec" "100x #fc03ec" ) }}
       +{{ template "invoke" (dict "copyFormat" "jpg" "base" $gopher "method" "resize"  "spec" "100x #03fc56 jpg" ) }}
       +
       +{{ define "invoke"}}
       +{{ $spec := .spec }}
       +{{ $name := printf "images/%s-%s-%s.%s" .method ((trim .base.Name "/") | lower | anchorize) ($spec | anchorize) .copyFormat  }}
       +{{ $img := ""}}
       +{{ if eq .method "resize" }}
       +        {{ $img = .base.Resize $spec }}
       +{{ else if eq .method "fill" }}
       +        {{ $img = .base.Fill $spec }}
       +{{ else if eq .method "fit" }}
       +        {{ $img = .base.Fit $spec }}
       +{{ else if eq .method "crop" }}
       +        {{ $img = .base.Crop $spec }}
       +{{ else }}
       +        {{ errorf "Unknown method %q" .method }}
       +{{ end }}
       +{{ with $img | resources.Copy $name }}
       +{{ .Publish }}
       +{{ end }}
       +{{ end }}
       +`
       +
       +        opts := imagetesting.DefaultGoldenOpts
       +        opts.T = t
       +        opts.Name = name
       +        opts.Files = files
       +
       +        imagetesting.RunGolden(opts)
       +}
 (DIR) diff --git a/resources/images/imagetesting/testing.go b/resources/images/imagetesting/testing.go
       @@ -63,8 +63,18 @@ type GoldenImageTestOpts struct {
                // Set to true to write golden files to disk.
                WriteFiles bool
        
       +        // If not set, a temporary directory will be created.
       +        WorkingDir string
       +
                // Set to true to skip any assertions. Useful when adding new golden variants to a test.
                DevMode bool
       +
       +        // Set to skip any assertions.
       +        SkipAssertions bool
       +
       +        // Whether this represents a rebuild of the same site.
       +        // Setting this to true will keep the previous golden image set.
       +        Rebuild bool
        }
        
        // To rebuild all Golden image tests, toggle WriteFiles=true and run:
       @@ -78,7 +88,10 @@ var DefaultGoldenOpts = GoldenImageTestOpts{
        func RunGolden(opts GoldenImageTestOpts) *hugolib.IntegrationTestBuilder {
                opts.T.Helper()
        
       -        c := hugolib.Test(opts.T, opts.Files, hugolib.TestOptWithOSFs()) // hugolib.TestOptWithPrintAndKeepTempDir(true))
       +        c := hugolib.Test(opts.T, opts.Files, hugolib.TestOptWithConfig(func(conf *hugolib.IntegrationTestConfig) {
       +                conf.NeedsOsFS = true
       +                conf.WorkingDir = opts.WorkingDir
       +        }))
                c.AssertFileContent("public/index.html", "Home.")
        
                outputDir := filepath.Join(c.H.Conf.WorkingDir(), "public", "images")
       @@ -86,12 +99,18 @@ func RunGolden(opts GoldenImageTestOpts) *hugolib.IntegrationTestBuilder {
                goldenDir := filepath.Join(goldenBaseDir, filepath.FromSlash(opts.Name))
                if opts.WriteFiles {
                        c.Assert(htesting.IsRealCI(), qt.IsFalse)
       -                c.Assert(os.MkdirAll(goldenBaseDir, 0o777), qt.IsNil)
       -                c.Assert(os.RemoveAll(goldenDir), qt.IsNil)
       +                if !opts.Rebuild {
       +                        c.Assert(os.MkdirAll(goldenBaseDir, 0o777), qt.IsNil)
       +                        c.Assert(os.RemoveAll(goldenDir), qt.IsNil)
       +                }
                        c.Assert(hugio.CopyDir(hugofs.Os, outputDir, goldenDir, nil), qt.IsNil)
                        return c
                }
        
       +        if opts.SkipAssertions {
       +                return c
       +        }
       +
                if opts.DevMode {
                        c.Assert(htesting.IsRealCI(), qt.IsFalse)
                        return c
 (DIR) diff --git a/resources/images/smartcrop.go b/resources/images/smartcrop.go
       @@ -25,10 +25,10 @@ import (
        const (
                // Do not change.
                smartCropIdentifier = "smart"
       -
       -        // This is just a increment, starting on 1. If Smart Crop improves its cropping, we
       +        SmartCropAnchor     = 1000
       +        // This is just a increment, starting on 0. If Smart Crop improves its cropping, we
                // need a way to trigger a re-generation of the crops in the wild, so increment this.
       -        smartCropVersionNumber = 1
       +        smartCropVersionNumber = 0
        )
        
        func (p *ImageProcessor) newSmartCropAnalyzer(filter gift.Resampling) smartcrop.Analyzer {
 (DIR) diff --git a/resources/images/testdata/images_golden/filters/mask2/green.jpg b/resources/images/testdata/images_golden/filters/mask2/green.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/filters/mask2/pink.jpg b/resources/images/testdata/images_golden/filters/mask2/pink.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-200x200.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-200x200.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-q20.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-q20.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-r90.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-r90.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-smart.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-smart.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-left.jpg b/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-left.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-right.jpg b/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-right.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/fit-sunsetjpg-200x200.jpg b/resources/images/testdata/images_golden/methods/fit-sunsetjpg-200x200.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-03fc56-jpg.jpg b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-03fc56-jpg.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-fc03ec.png b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-fc03ec.png
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/resize-gopherpng-100x.png b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x.png
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/resize-sunsetjpg-300x.jpg b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-300x.jpg
       Binary files differ.
 (DIR) diff --git a/resources/images/testdata/images_golden/methods/resize-sunsetjpg-x200.jpg b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-x200.jpg
       Binary files differ.
 (DIR) diff --git a/resources/resource.go b/resources/resource.go
       @@ -363,6 +363,7 @@ type genericResource struct {
                sd    ResourceSourceDescriptor
                paths internal.ResourcePaths
        
       +        includeHashInKey     bool
                sourceFilenameIsHash bool
        
                h *resourceHash // A hash of the source content. Is only calculated in caching situations.
       @@ -452,6 +453,10 @@ func (l *genericResource) Key() string {
                        if l.spec.Cfg.IsMultihost() {
                                l.key = l.spec.Lang() + l.key
                        }
       +
       +                if l.includeHashInKey && !l.sourceFilenameIsHash {
       +                        l.key += fmt.Sprintf("_%d", l.hash())
       +                }
                })
        
                return l.key
 (DIR) diff --git a/resources/resource_spec.go b/resources/resource_spec.go
       @@ -183,29 +183,33 @@ func (r *Spec) NewResource(rd ResourceSourceDescriptor) (resource.Resource, erro
                        TargetBasePaths: rd.TargetBasePaths,
                }
        
       +        isImage := rd.MediaType.MainType == "image"
       +        var imgFormat images.Format
       +        if isImage {
       +                imgFormat, isImage = images.ImageFormatFromMediaSubType(rd.MediaType.SubType)
       +        }
       +
                gr := &genericResource{
       -                Staler:      &AtomicStaler{},
       -                h:           &resourceHash{},
       -                publishInit: &sync.Once{},
       -                keyInit:     &sync.Once{},
       -                paths:       rp,
       -                spec:        r,
       -                sd:          rd,
       -                params:      rd.Params,
       -                name:        rd.NameOriginal,
       -                title:       rd.Title,
       +                Staler:           &AtomicStaler{},
       +                h:                &resourceHash{},
       +                publishInit:      &sync.Once{},
       +                keyInit:          &sync.Once{},
       +                includeHashInKey: isImage,
       +                paths:            rp,
       +                spec:             r,
       +                sd:               rd,
       +                params:           rd.Params,
       +                name:             rd.NameOriginal,
       +                title:            rd.Title,
                }
        
       -        if rd.MediaType.MainType == "image" {
       -                imgFormat, ok := images.ImageFormatFromMediaSubType(rd.MediaType.SubType)
       -                if ok {
       -                        ir := &imageResource{
       -                                Image:        images.NewImage(imgFormat, r.imaging, nil, gr),
       -                                baseResource: gr,
       -                        }
       -                        ir.root = ir
       -                        return newResourceAdapter(gr.spec, rd.LazyPublish, ir), nil
       +        if isImage {
       +                ir := &imageResource{
       +                        Image:        images.NewImage(imgFormat, r.imaging, nil, gr),
       +                        baseResource: gr,
                        }
       +                ir.root = ir
       +                return newResourceAdapter(gr.spec, rd.LazyPublish, ir), nil
        
                }
        
 (DIR) diff --git a/resources/resources_integration_test.go b/resources/resources_integration_test.go
       @@ -62,9 +62,9 @@ anigif: {{ $anigif.RelPermalink }}|{{ $anigif.Width }}|{{ $anigif.Height }}|{{ $
        
                assertImages := func() {
                        b.AssertFileContent("public/index.html", `
       -                gif: /mybundle/pixel_hu14657638653019978294.gif|}|1|2|image/gif|
       -                bmp: /mybundle/pixel_hu14705577916774115224.bmp|}|2|3|image/bmp|
       -                anigif: /mybundle/giphy_hu3665406585348417395.gif|4|5|image/gif|
       + gif: /mybundle/pixel_hu_93429543fc146fce.gif|}|1|2|image/gif|
       +bmp: /mybundle/pixel_hu_f9bf2acd6578e2c6.bmp|}|2|3|image/bmp|
       +anigif: /mybundle/giphy_hu_652d28653068b48f.gif|4|5|image/gif|
                        `)
                }
        
       @@ -160,9 +160,9 @@ resize 2|RelPermalink: {{ $image.RelPermalink }}|MediaType: {{ $image.MediaType 
                b := hugolib.Test(t, files)
        
                b.AssertFileContent("public/index.html",
       -                "jpg|RelPermalink: /images/pixel_hu13683954895608450100.jpg|MediaType: image/jpeg|Width: 1|Height: 1|",
       -                "resize 1|RelPermalink: /images/pixel_hu3453403302435331853.jpg|MediaType: image/jpeg|Width: 20|Height: 30|",
       -                "resize 2|RelPermalink: /images/pixel_hu3453403302435331853.jpg|MediaType: image/jpeg|Width: 20|Height: 30|",
       +                "jpg|RelPermalink: /images/pixel_hu_38c3f257174fc757.jpg|MediaType: image/jpeg|Width: 1|Height: 1|",
       +                "resize 1|RelPermalink: /images/pixel_hu_b5c2a3d88991f65a.jpg|MediaType: image/jpeg|Width: 20|Height: 30|",
       +                "resize 2|RelPermalink: /images/pixel_hu_b5c2a3d88991f65a.jpg|MediaType: image/jpeg|Width: 20|Height: 30|",
                )
        }
        
 (DIR) diff --git a/resources/testdata/mask2.png b/resources/testdata/mask2.png
       Binary files differ.
 (DIR) diff --git a/resources/transform_test.go b/resources/transform_test.go
       @@ -386,22 +386,15 @@ func TestTransform(t *testing.T) {
                        resizedPublished1, err := img.Resize("40x40")
                        c.Assert(err, qt.IsNil)
                        c.Assert(resizedPublished1.Height(), qt.Equals, 40)
       -                c.Assert(resizedPublished1.RelPermalink(), qt.Equals, "/gopher.changed_hu16988682630555427117.png")
       -                assertShouldExist(c, spec, "public/gopher.changed_hu16988682630555427117.png", true)
       +                c.Assert(resizedPublished1.RelPermalink(), qt.Equals, "/gopher.changed_hu_85920388a7ff96fa.png")
       +                assertShouldExist(c, spec, "public/gopher.changed_hu_85920388a7ff96fa.png", true)
        
                        // Permalink called.
                        resizedPublished2, err := img.Resize("30x30")
                        c.Assert(err, qt.IsNil)
                        c.Assert(resizedPublished2.Height(), qt.Equals, 30)
       -                c.Assert(resizedPublished2.Permalink(), qt.Equals, "https://example.com/gopher.changed_hu14141325020798305104.png")
       -                assertShouldExist(c, spec, "public/gopher.changed_hu14141325020798305104.png", true)
       -
       -                // Not published because none of RelPermalink or Permalink was called.
       -                resizedNotPublished, err := img.Resize("50x50")
       -                c.Assert(err, qt.IsNil)
       -                c.Assert(resizedNotPublished.Height(), qt.Equals, 50)
       -                // c.Assert(resized.RelPermalink(), qt.Equals, "/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_50x50_resize_linear_2.png")
       -                assertShouldExist(c, spec, "public/gopher.changed_hu2e827f5a78333ebc04166dd643235dea_1462_50x50_resize_linear_3.png", false)
       +                c.Assert(resizedPublished2.Permalink(), qt.Equals, "https://example.com/gopher.changed_hu_c8d8163c08643a7f.png")
       +                assertShouldExist(c, spec, "public/gopher.changed_hu_c8d8163c08643a7f.png", true)
        
                        assertNoDuplicateWrites(c, spec)
                })
 (DIR) diff --git a/tpl/resources/resources_integration_test.go b/tpl/resources/resources_integration_test.go
       @@ -60,7 +60,7 @@ Copy3: {{ $copy3.RelPermalink}}|{{ $copy3.MediaType }}|{{ $copy3.Content | safeJ
        
                b.AssertFileContent("public/index.html", `
        Image Orig:  /blog/images/pixel.png|image/png|1|1|
       -Image Copy1:  /blog/images/copy_hu2891316072287293157.png|image/png|3|4|
       +Image Copy1:  /blog/images/copy_hu_1d9addfff177f388.png|image/png|3|4|
        Image Copy2:  /blog/images/copy2.png|image/png|3|4
        Image Copy3:  image/png|3|4|
        Orig: /blog/js/foo.js|text/javascript|let foo;|