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
This commit is contained in:
Bjørn Erik Pedersen
2025-01-16 19:15:30 +01:00
parent 8897113666
commit 1f5a15aba0
38 changed files with 342 additions and 233 deletions

View File

@@ -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
}