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.
This commit is contained in:
prosolis
2026-05-25 09:27:15 -07:00
parent 24103394ae
commit 9bc8743b5e

View File

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