Repository: gohugoio/hugo (language: go)

Issue:
Changed imaging config not reflected in the cache hash for some image filters
I'm pretty sure this isn't true in general for the image resize methods etc., which is probably why I haven't seen this, but if you e.g. only apply `images.Mask` to an image, this becomes an issue.

I just tried to change the `bgColor` in `hugo.toml` for http://localhost:1313/functions/images/mask/, but the change wasn't reflected until I cleared the image cache.


Discussion / hints:
While updating the docs yesterday I tried a few different mask images... all with the same name (mask.png). I had to clear the image cache to see the changes. I was guessing that the hash did not include the mask content, just its original file name. But maybe there's something else going on.
> I was guessing that the hash did not include the mask content, just its original file name. But maybe there's something else going on.

I'm taking this from memory, but for the resize ops (including `imaging.Process`) we add the global image config to the hash. I'm guessing this somehow got lost when we added other filters. For most of them this isn't an issue (they don't depend on global config), but `images.Mask` do (`bgColor`). Anyhow, i will have a look and fix this.
>While updating the docs yesterday I tried a few different mask images... all with the same name (mask.png). 

OK, I didn't read your post, but the above is a different issue; which looks like a bug with the `images.Mask` filter. I'll create a separate issue for this.

Fix patch:
diff --git a/config/namespace.go b/config/namespace.go
index 46b5014c34c..e41b56e2d9e 100644
--- 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)
diff --git a/resources/image.go b/resources/image.go
index 686f70e274d..c1f107b592f 100644
--- 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
 }
diff --git a/resources/image_cache.go b/resources/image_cache.go
index d824c5d1a06..1fc7226095e 100644
--- 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
 
diff --git a/resources/images/config.go b/resources/images/config.go
index 9655e9a51ce..6fcd2e334ba 100644
--- 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)
diff --git a/resources/images/filters.go b/resources/images/filters.go
index 64776affee5..9c2b9b46f9e 100644
--- 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,
 		},
 	}
 }
diff --git a/resources/images/image.go b/resources/images/image.go
index 4d5285acdd2..c891b016844 100644
--- 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,
 	}
diff --git a/resources/images/smartcrop.go b/resources/images/smartcrop.go
index 864c6de0ae0..af45c241c18 100644
--- 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 {
diff --git a/resources/resource.go b/resources/resource.go
index 29b9e5ddd82..6ef9bdae050 100644
--- 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
diff --git a/resources/resource_spec.go b/resources/resource_spec.go
index 912a0d786f0..f1c30e0a209 100644
--- 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
 
 	}
 


Test patch:
diff --git a/config/namespace_test.go b/config/namespace_test.go
index 5eacdeac7dd..df27ae05cf0 100644
--- 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))
 }
diff --git a/hugolib/hugo_sites_multihost_test.go b/hugolib/hugo_sites_multihost_test.go
index 39504202b2b..37f7ab92791 100644
--- 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) {
diff --git a/hugolib/image_test.go b/hugolib/image_test.go
index 7dcd9fc26ab..09a5b841e90 100644
--- 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()
diff --git a/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go
index b033aad2b65..64ee7039731 100644
--- 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
diff --git a/hugolib/resource_chain_test.go b/hugolib/resource_chain_test.go
index 669114c8a4a..942873ae4c9 100644
--- 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"|
diff --git a/resources/image_extended_test.go b/resources/image_extended_test.go
index e570c4a3d24..f481be9bb40 100644
--- 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)
 }
diff --git a/resources/image_test.go b/resources/image_test.go
index 5639d457e07..1ba5a149a32 100644
--- 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)
 }
 
diff --git a/resources/images/config_test.go b/resources/images/config_test.go
index 6dd545f2cfe..d3c9827bd55 100644
--- 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
 		}
 	}
 
diff --git a/resources/images/images_golden_integration_test.go b/resources/images/images_golden_integration_test.go
index 526e3ba534c..c49de7bd191 100644
--- 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)
+}
diff --git a/resources/images/imagetesting/testing.go b/resources/images/imagetesting/testing.go
index 8e00751e0c2..22a2317a100 100644
--- 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
diff --git a/resources/images/testdata/images_golden/filters/mask2/green.jpg b/resources/images/testdata/images_golden/filters/mask2/green.jpg
new file mode 100644
index 00000000000..48a9dd0830f
Binary files /dev/null and b/resources/images/testdata/images_golden/filters/mask2/green.jpg differ
diff --git a/resources/images/testdata/images_golden/filters/mask2/pink.jpg b/resources/images/testdata/images_golden/filters/mask2/pink.jpg
new file mode 100644
index 00000000000..640e41ab156
Binary files /dev/null and b/resources/images/testdata/images_golden/filters/mask2/pink.jpg differ
diff --git a/resources/images/testdata/images_golden/methods/crop-sunsetjpg-200x200.jpg b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-200x200.jpg
new file mode 100644
index 00000000000..10569ab2c55
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-200x200.jpg differ
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
new file mode 100644
index 00000000000..1b316ff17c5
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-q20.jpg differ
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
new file mode 100644
index 00000000000..9e3bc4bf570
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center-r90.jpg differ
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
new file mode 100644
index 00000000000..aa89eedf28d
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-center.jpg differ
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
new file mode 100644
index 00000000000..7a203920e1b
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/crop-sunsetjpg-350x400-smart.jpg differ
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
new file mode 100644
index 00000000000..39f1823f5db
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-left.jpg differ
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
new file mode 100644
index 00000000000..4afcb6decdb
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/fill-sunsetjpg-90x120-right.jpg differ
diff --git a/resources/images/testdata/images_golden/methods/fit-sunsetjpg-200x200.jpg b/resources/images/testdata/images_golden/methods/fit-sunsetjpg-200x200.jpg
new file mode 100644
index 00000000000..75c6a30b024
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/fit-sunsetjpg-200x200.jpg differ
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
new file mode 100644
index 00000000000..2e78ce461c1
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-03fc56-jpg.jpg differ
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
new file mode 100644
index 00000000000..44af253036f
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x-fc03ec.png differ
diff --git a/resources/images/testdata/images_golden/methods/resize-gopherpng-100x.png b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x.png
new file mode 100644
index 00000000000..9bca47b147f
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-gopherpng-100x.png differ
diff --git a/resources/images/testdata/images_golden/methods/resize-sunsetjpg-300x.jpg b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-300x.jpg
new file mode 100644
index 00000000000..319385e9a5a
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-300x.jpg differ
diff --git a/resources/images/testdata/images_golden/methods/resize-sunsetjpg-x200.jpg b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-x200.jpg
new file mode 100644
index 00000000000..77b2555cb6e
Binary files /dev/null and b/resources/images/testdata/images_golden/methods/resize-sunsetjpg-x200.jpg differ
diff --git a/resources/resources_integration_test.go b/resources/resources_integration_test.go
index c26eb8fffab..0c45b775a06 100644
--- 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|",
 	)
 }
 
diff --git a/resources/testdata/mask2.png b/resources/testdata/mask2.png
new file mode 100644
index 00000000000..b58a5e4b071
Binary files /dev/null and b/resources/testdata/mask2.png differ
diff --git a/resources/transform_test.go b/resources/transform_test.go
index b036a44d778..79d4841b1b2 100644
--- 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)
 	})
diff --git a/tpl/resources/resources_integration_test.go b/tpl/resources/resources_integration_test.go
index 563b3c45582..da3ff116850 100644
--- 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;|