From 9bc8743b5e508a2ac3bfdec095a06de6053a1752 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 25 May 2026 09:27:15 -0700 Subject: [PATCH] Handle AVIF source images in thumb pipeline Decode AVIF via avifdec when Go's image.Decode can't, and pass-through small AVIFs (<=800px wide) by caching the original bytes instead of re-encoding. --- internal/web/thumbs.go | 98 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/internal/web/thumbs.go b/internal/web/thumbs.go index 063db1a..18f4140 100644 --- a/internal/web/thumbs.go +++ b/internal/web/thumbs.go @@ -150,6 +150,18 @@ func buildAvifThumb(src, outPath string) error { return fmt.Errorf("image too big (>%d bytes)", thumbMaxBytes) } + if isAvif(body) { + // If the source AVIF is already within our width budget, skip the + // decode/resize/re-encode roundtrip and cache the original bytes. + w, _, infoErr := avifInfo(body) + if infoErr == nil && w > 0 && w <= thumbWidth { + return writeFile(outPath, body) + } + body, err = avifToPNG(body) + if err != nil { + return fmt.Errorf("avifdec: %w", err) + } + } src2, _, err := image.Decode(bytes.NewReader(body)) if err != nil { return fmt.Errorf("decode: %w", err) @@ -202,3 +214,89 @@ func buildAvifThumb(src, outPath string) error { } return os.Rename(stagedOut, outPath) } + +// isAvif reports whether body looks like an AVIF file via the ISO-BMFF "ftyp" +// brand at bytes 4..12 ("ftypavif"/"ftypavis"). +func isAvif(body []byte) bool { + if len(body) < 12 { + return false + } + if string(body[4:8]) != "ftyp" { + return false + } + brand := string(body[8:12]) + return brand == "avif" || brand == "avis" +} + +// avifInfo returns the width and height of an AVIF by shelling out to +// `avifdec --info`. It still decodes the image internally but doesn't write +// pixels to disk, so it's cheaper than full avifToPNG. +func avifInfo(body []byte) (int, int, error) { + in, err := os.CreateTemp("", "pete-avif-info-*.avif") + if err != nil { + return 0, 0, err + } + inPath := in.Name() + defer os.Remove(inPath) + if _, err := in.Write(body); err != nil { + in.Close() + return 0, 0, err + } + if err := in.Close(); err != nil { + return 0, 0, err + } + out, err := exec.Command("avifdec", "--info", inPath).CombinedOutput() + if err != nil { + return 0, 0, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "* Resolution") + if !ok { + continue + } + _, dims, ok := strings.Cut(rest, ":") + if !ok { + continue + } + var w, h int + if _, err := fmt.Sscanf(strings.TrimSpace(dims), "%dx%d", &w, &h); err == nil { + return w, h, nil + } + } + return 0, 0, fmt.Errorf("no resolution in avifdec output") +} + +// writeFile atomically writes body to path via a sibling temp + rename. +func writeFile(path string, body []byte) error { + staged := path + ".tmp" + if err := os.WriteFile(staged, body, 0o644); err != nil { + os.Remove(staged) + return err + } + return os.Rename(staged, path) +} + +// avifToPNG decodes AVIF bytes to PNG bytes by shelling out to avifdec. +func avifToPNG(body []byte) ([]byte, error) { + in, err := os.CreateTemp("", "pete-avif-in-*.avif") + if err != nil { + return nil, err + } + inPath := in.Name() + defer os.Remove(inPath) + if _, err := in.Write(body); err != nil { + in.Close() + return nil, err + } + if err := in.Close(); err != nil { + return nil, err + } + outPath := inPath + ".png" + defer os.Remove(outPath) + cmd := exec.Command("avifdec", inPath, outPath) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return os.ReadFile(outPath) +}