优化缩略图

This commit is contained in:
truewhile
2026-09-20 15:16:40 +08:00
parent c24ebe181d
commit 47899284a5
27 changed files with 1959 additions and 244 deletions
+173 -12
View File
@@ -34,7 +34,16 @@ const (
// N 像素的图在内存中约需 4N 字节;没有上限时,一张异常的超大图
// 就能在多张并发缩略图请求下打爆小内存主机。超过该上限时直接
// 回退为原图直出,宁可不缩放也不冒 OOM 风险。
imageResizeMaxSourcePixels = 30_000_000
//
// 1200 万像素覆盖常见的高清海报(2892×4096 约 1180 万),单张解码
// 峰值约 48MB RGBA;原先的 3000 万在 2 核 2GB 的机器上意味着单张
// 就可能吃掉 120MB 以上,两个并发槽足以触发 OOM/大量换页。
imageResizeMaxSourcePixels = 12_000_000
// imageResizeCheapScaleRatio 是启用低成本插值的缩小比例阈值:目标尺寸
// 小于源图一半时,CatmullRom 的收益肉眼不可见,但耗时和临时缓冲明显
// 更高(电视端海报墙会同时请求几十张缩略图)。
imageResizeCheapScaleRatio = 0.5
// imageResizeCacheSubdir 存放缩放结果,与远程原图缓存分开放,
// 便于单独清理且不与原始字节流缓存互相覆盖。
@@ -156,7 +165,7 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
return nil, "", false, err
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
resizeScaled(dst, src, cfg.Width, cfg.Height)
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
@@ -176,12 +185,42 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
return buf.Bytes(), "image/jpeg", false, nil
}
// isOpaqueImage 逐像素确认图像不含透明像素。
// useCheapScaleInterpolator 报告这次缩放是否该用低成本插值:目标尺寸在宽高
// 两个方向都缩到源图一半以下时,CatmullRom 的额外采样换来的观感差异不可见。
func useCheapScaleInterpolator(dstW, dstH, srcW, srcH int) bool {
if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 {
return false
}
return float64(dstW)/float64(srcW) <= imageResizeCheapScaleRatio &&
float64(dstH)/float64(srcH) <= imageResizeCheapScaleRatio
}
// resizeScaled 把 src 缩放到 dst。大幅缩小时改用低成本插值:CatmullRom
// 与 ApproxBiLinear 在大比例缩小下观感差异看不出来,但前者要遍历更多
// 邻域样本,在 2 核机器上会明显拖慢海报墙的并发缩略图请求。
func resizeScaled(dst *image.RGBA, src image.Image, srcW, srcH int) {
if dst == nil {
return
}
bounds := dst.Bounds()
interp := draw.Interpolator(draw.CatmullRom)
if useCheapScaleInterpolator(bounds.Dx(), bounds.Dy(), srcW, srcH) {
interp = draw.ApproxBiLinear
}
interp.Scale(dst, bounds, src, src.Bounds(), draw.Src, nil)
}
// isOpaqueImage 逐像素确认图像不含透明像素。dst 已知是 *image.RGBA,用
// RGBAAt 直取字段可以避免 At() 的接口分派与颜色模型换算(PNG 海报每次
// 生成缩略图都要走一遍全图扫描)。
func isOpaqueImage(img *image.RGBA) bool {
if img == nil {
return false
}
bounds := img.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if _, _, _, a := img.At(x, y).RGBA(); a != 0xffff {
if img.RGBAAt(x, y).A != 0xff {
return false
}
}
@@ -189,19 +228,91 @@ func isOpaqueImage(img *image.RGBA) bool {
return true
}
// resizeCacheKey 生成缩放结果的缓存键,覆盖源文件身份(路径 + 大小 +
// 修改时间)与全部影响输出的参数,源文件被替换后不会命中陈旧缩略图。
func (o imageResizeOptions) resizeCacheKey(sourceID string, stat os.FileInfo) string {
// resizeSourceKey 生成源图身份键:只覆盖源文件身份(路径 + 大小 + mtime),不含
// 目标尺寸。同一源图的所有尺寸档位共用这个前缀,才能互相列举与派生(小图直接
// 从已缓存的大图缩小,而不是重新解码原图)。
func (o imageResizeOptions) resizeSourceKey(sourceID string, stat os.FileInfo) string {
h := sha256.New()
_, _ = fmt.Fprintf(h, "v1|%s|%dx%d|q%d", sourceID, o.MaxWidth, o.MaxHeight, o.encodingQuality())
_, _ = fmt.Fprintf(h, "v2|%s", sourceID)
if stat != nil {
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
}
return hex.EncodeToString(h.Sum(nil))
}
func (p *ImageProxy) resizeCachePath(key string) string {
return filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".img")
// resizeCacheKey 是「源图身份 + 档位」的稳定标识,用于 ETag 等需要区分档位的
// 场合(磁盘路径只用源图身份 + 文件名后缀)。
func (o imageResizeOptions) resizeCacheKey(sourceID string, stat os.FileInfo) string {
return o.resizeSourceKey(sourceID, stat) + "." + o.resizeVariantSuffix()
}
func (p *ImageProxy) resizeCachePath(sourceKey string, o imageResizeOptions) string {
return filepath.Join(p.cacheDir, imageResizeCacheSubdir, sourceKey+"."+o.resizeVariantSuffix()+".img")
}
// resizeVariantSuffix 把目标尺寸与质量写进缩放结果的文件名。这样同一源图的
// 各档尺寸可以被列举出来:请求小图时若已有更大的档位缓存,直接从它缩小即可,
// 不必再解码多兆字节的原图(解码 480×600 约 1MB RGBA,解码原图可达数十 MB)。
func (o imageResizeOptions) resizeVariantSuffix() string {
return strconv.Itoa(o.MaxWidth) + "x" + strconv.Itoa(o.MaxHeight) + "q" + strconv.Itoa(o.encodingQuality())
}
// parseResizeVariantSuffix 解析 "<sourceKey>.<w>x<h>q<q>.img" 文件名中的尺寸。
func parseResizeVariantSuffix(name, sourceKey string) (width, height int, ok bool) {
prefix := sourceKey + "."
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".img") {
return 0, 0, false
}
spec := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".img")
dimensions, quality, found := strings.Cut(spec, "q")
if !found {
return 0, 0, false
}
w, h, found := strings.Cut(dimensions, "x")
if !found {
return 0, 0, false
}
width, err := strconv.Atoi(w)
if err != nil || width < 0 {
return 0, 0, false
}
height, err = strconv.Atoi(h)
if err != nil || height < 0 {
return 0, 0, false
}
if _, err := strconv.Atoi(quality); err != nil {
return 0, 0, false
}
return width, height, true
}
// largerCachedVariant 找出同一源图已缓存的、能覆盖目标尺寸的最小档位(宽高
// 都不小于目标)。返回空串表示没有可用的档位,调用方回退到解码原图。
func (p *ImageProxy) largerCachedVariant(key string, o imageResizeOptions) string {
pattern := filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".*.img")
matches, err := filepath.Glob(pattern)
if err != nil {
return ""
}
best := ""
bestPixels := 0
for _, path := range matches {
width, height, ok := parseResizeVariantSuffix(filepath.Base(path), key)
if !ok {
continue
}
if width == o.MaxWidth && height == o.MaxHeight {
continue // 精确档位,本该在上面就命中
}
if width < o.MaxWidth || height < o.MaxHeight {
continue
}
pixels := width * height
if best == "" || pixels < bestPixels {
best, bestPixels = path, pixels
}
}
return best
}
// acquireResizeSlot bounds CPU-heavy decode/resize work. Returning false means
@@ -265,7 +376,7 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
// 缓存命中必须发生在读原图和解码之前。否则电视端每次刷新海报墙都会
// 把已经是缩略图缓存的原图重新解码、缩放一遍,造成明显的 CPU 抖动。
key := o.resizeCacheKey(srcPath, stat)
cachePath := p.resizeCachePath(key)
cachePath := p.resizeCachePath(o.resizeSourceKey(srcPath, stat), o)
if serveCachedImageFile(w, r, key, cachePath) {
return true
}
@@ -286,7 +397,15 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
return true
}
data, err := os.ReadFile(srcPath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path.
// 优先从同一源图已缓存的大尺寸档位缩小:小图(如 160px 模糊占位图)
// 往往能直接由已缓存的卡片图派生,代价从“解码多兆字节原图”降为
// “解码几百 KB 的档位文件”。
sourcePath := srcPath
if derived := p.largerCachedVariant(o.resizeSourceKey(srcPath, stat), o); derived != "" {
sourcePath = derived
}
data, err := os.ReadFile(sourcePath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path.
if err != nil {
return false
}
@@ -306,6 +425,48 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
return true
}
// ensureResizeCache 在请求路径之外预先生成某档缩略图(刮削预取用)。已存在
// 时直接返回,无需调用方再次解码。
func (p *ImageProxy) ensureResizeCache(ctx context.Context, srcPath string, o imageResizeOptions) error {
if p == nil || !o.active() {
return nil
}
stat, err := os.Stat(srcPath)
if err != nil || stat.IsDir() || stat.Size() <= 0 {
return errors.New("image source unavailable")
}
sourceKey := o.resizeSourceKey(srcPath, stat)
cachePath := p.resizeCachePath(sourceKey, o)
if _, err := os.Stat(cachePath); err == nil {
return nil
}
release, ok := p.acquireResizeSlotFor(ctx, stat.Size())
if !ok {
return errors.New("image resize slot unavailable")
}
defer release()
if _, err := os.Stat(cachePath); err == nil {
return nil
}
sourcePath := srcPath
if derived := p.largerCachedVariant(sourceKey, o); derived != "" {
sourcePath = derived
}
data, err := os.ReadFile(sourcePath) // #nosec G304 -- srcPath is an allowed local path or a SHA-derived cache path.
if err != nil {
return err
}
out, _, unchanged, err := resizeImageData(data, o)
if err != nil {
return err
}
if unchanged {
return nil // 源图已在目标尺寸内,客户端会直接使用原文件。
}
p.writeResizeCache(cachePath, out)
return nil
}
// compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节
// 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。