gotosocial/internal/media/thumbnail.go
tobi e10aa76612
[chore] Only call imaging.Resize when necessary, use even tinier blurhashes (#3247)
* [chore] Use `imaging.Fit`, use even tinier blurhashes

* avoid calling resize if not necessary

* update blurhashes + thumb
2024-08-29 17:43:14 +02:00

420 lines
10 KiB
Go

// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package media
import (
"context"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"os"
"strings"
"github.com/buckket/go-blurhash"
"github.com/disintegration/imaging"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
"golang.org/x/image/webp"
)
const (
maxThumbWidth = 512
maxThumbHeight = 512
)
// thumbSize returns the dimensions to use for an input
// image of given width / height, for its outgoing thumbnail.
// This attempts to maintains the original image aspect ratio.
func thumbSize(width, height int, aspect float32) (int, int) {
switch {
// Simplest case, within bounds!
case width < maxThumbWidth &&
height < maxThumbHeight:
return width, height
// Width is larger side.
case width > height:
// i.e. height = newWidth * (height / width)
height = int(float32(maxThumbWidth) / aspect)
return maxThumbWidth, height
// Height is larger side.
case height > width:
// i.e. width = newHeight * (width / height)
width = int(float32(maxThumbHeight) * aspect)
return width, maxThumbHeight
// Square.
default:
return maxThumbWidth, maxThumbHeight
}
}
// generateThumb generates a thumbnail for the
// input file at path, resizing it to the given
// dimensions and generating a blurhash if needed.
// This wraps much of the complex thumbnailing
// logic in which where possible we use native
// Go libraries for generating thumbnails, else
// always falling back to slower but much more
// widely supportive ffmpeg.
func generateThumb(
ctx context.Context,
filepath string,
width, height int,
orientation int,
pixfmt string,
needBlurhash bool,
) (
outpath string,
blurhash string,
err error,
) {
var ext string
// Generate thumb output path REPLACING extension.
if i := strings.IndexByte(filepath, '.'); i != -1 {
outpath = filepath[:i] + "_thumb.webp"
ext = filepath[i+1:] // old extension
} else {
return "", "", gtserror.New("input file missing extension")
}
// Check for the few media types we
// have native Go decoding that allow
// us to generate thumbs natively.
switch {
case ext == "jpeg":
// Replace the "webp" with "jpeg", as we'll
// use our native Go thumbnailing generation.
outpath = outpath[:len(outpath)-4] + "jpeg"
log.Debug(ctx, "generating thumb from jpeg")
blurhash, err := generateNativeThumb(
filepath,
outpath,
width,
height,
orientation,
jpeg.Decode,
needBlurhash,
)
return outpath, blurhash, err
// We specifically only allow generating native
// thumbnails from gif IF it doesn't contain an
// alpha channel. We'll ultimately be encoding to
// jpeg which doesn't support transparency layers.
case ext == "gif" && !containsAlpha(pixfmt):
// Replace the "webp" with "jpeg", as we'll
// use our native Go thumbnailing generation.
outpath = outpath[:len(outpath)-4] + "jpeg"
log.Debug(ctx, "generating thumb from gif")
blurhash, err := generateNativeThumb(
filepath,
outpath,
width,
height,
orientation,
gif.Decode,
needBlurhash,
)
return outpath, blurhash, err
// We specifically only allow generating native
// thumbnails from png IF it doesn't contain an
// alpha channel. We'll ultimately be encoding to
// jpeg which doesn't support transparency layers.
case ext == "png" && !containsAlpha(pixfmt):
// Replace the "webp" with "jpeg", as we'll
// use our native Go thumbnailing generation.
outpath = outpath[:len(outpath)-4] + "jpeg"
log.Debug(ctx, "generating thumb from png")
blurhash, err := generateNativeThumb(
filepath,
outpath,
width,
height,
orientation,
png.Decode,
needBlurhash,
)
return outpath, blurhash, err
// We specifically only allow generating native
// thumbnails from webp IF it doesn't contain an
// alpha channel. We'll ultimately be encoding to
// jpeg which doesn't support transparency layers.
case ext == "webp" && !containsAlpha(pixfmt):
// Replace the "webp" with "jpeg", as we'll
// use our native Go thumbnailing generation.
outpath = outpath[:len(outpath)-4] + "jpeg"
log.Debug(ctx, "generating thumb from webp")
blurhash, err := generateNativeThumb(
filepath,
outpath,
width,
height,
orientation,
webp.Decode,
needBlurhash,
)
return outpath, blurhash, err
}
// The fallback for thumbnail generation, which
// encompasses most media types is with ffmpeg.
log.Debug(ctx, "generating thumb with ffmpeg")
if err := ffmpegGenerateWebpThumb(ctx,
filepath,
outpath,
width,
height,
pixfmt,
); err != nil {
return outpath, "", err
}
if needBlurhash {
// Generate new blurhash from webp output thumb.
blurhash, err = generateWebpBlurhash(outpath)
if err != nil {
return outpath, "", gtserror.Newf("error generating blurhash: %w", err)
}
}
return outpath, blurhash, err
}
// generateNativeThumb generates a thumbnail
// using native Go code, using given decode
// function to get image, resize to given dimens,
// and write to output filepath as JPEG. If a
// blurhash is required it will also generate
// this from the image.Image while in-memory.
func generateNativeThumb(
inpath, outpath string,
width, height int,
orientation int,
decode func(io.Reader) (image.Image, error),
needBlurhash bool,
) (
string, // blurhash
error,
) {
// Open input file at given path.
infile, err := os.Open(inpath)
if err != nil {
return "", gtserror.Newf("error opening input file %s: %w", inpath, err)
}
// Decode image into memory.
img, err := decode(infile)
// Done with file.
_ = infile.Close()
if err != nil {
return "", gtserror.Newf("error decoding file %s: %w", inpath, err)
}
// Apply orientation BEFORE any resize,
// as our image dimensions are calculated
// taking orientation into account.
switch orientation {
case orientationFlipH:
img = imaging.FlipH(img)
case orientationFlipV:
img = imaging.FlipV(img)
case orientationRotate90:
img = imaging.Rotate90(img)
case orientationRotate180:
img = imaging.Rotate180(img)
case orientationRotate270:
img = imaging.Rotate270(img)
case orientationTranspose:
img = imaging.Transpose(img)
case orientationTransverse:
img = imaging.Transverse(img)
}
// Resize image to dimens only if necessary.
if img.Bounds().Dx() > maxThumbWidth ||
img.Bounds().Dy() > maxThumbHeight {
// Note: We could call "imaging.Fit" here
// but there's no point, as we've already
// calculated target dimensions beforehand.
img = imaging.Resize(img,
width, height,
imaging.Linear,
)
}
// Open output file at given path.
outfile, err := os.Create(outpath)
if err != nil {
return "", gtserror.Newf("error opening output file %s: %w", outpath, err)
}
// Encode in-memory image to output file.
// (nil uses defaults, i.e. quality=75).
err = jpeg.Encode(outfile, img, nil)
// Done with file.
_ = outfile.Close()
if err != nil {
return "", gtserror.Newf("error encoding image: %w", err)
}
if needBlurhash {
// for generating blurhashes, it's more cost effective to
// lose detail since it's blurry, so make a tiny version.
tiny := imaging.Resize(img, 32, 0, imaging.NearestNeighbor)
// Drop the larger image
// ref as soon as possible
// to allow GC to claim.
img = nil //nolint
// Generate blurhash for the tiny thumbnail.
blurhash, err := blurhash.Encode(4, 3, tiny)
if err != nil {
return "", gtserror.Newf("error generating blurhash: %w", err)
}
return blurhash, nil
}
return "", nil
}
// generateWebpBlurhash generates a blurhash for Webp at filepath.
func generateWebpBlurhash(filepath string) (string, error) {
// Open the file at given path.
file, err := os.Open(filepath)
if err != nil {
return "", gtserror.Newf("error opening input file %s: %w", filepath, err)
}
// Decode image from file.
img, err := webp.Decode(file)
// Done with file.
_ = file.Close()
if err != nil {
return "", gtserror.Newf("error decoding file %s: %w", filepath, err)
}
// for generating blurhashes, it's more cost effective to
// lose detail since it's blurry, so make a tiny version.
tiny := imaging.Resize(img, 32, 0, imaging.NearestNeighbor)
// Drop the larger image
// ref as soon as possible
// to allow GC to claim.
img = nil //nolint
// Generate blurhash for the tiny thumbnail.
blurhash, err := blurhash.Encode(4, 3, tiny)
if err != nil {
return "", gtserror.Newf("error generating blurhash: %w", err)
}
return blurhash, nil
}
// List of pixel formats that have an alpha layer.
// Derived from the following very messy command:
//
// for res in $(ffprobe -show_entries pixel_format=name:flags=alpha | grep -B1 alpha=1 | grep name); do echo $res | sed 's/name=//g' | sed 's/^/"/g' | sed 's/$/",/g'; done
var alphaPixelFormats = []string{
"pal8",
"argb",
"rgba",
"abgr",
"bgra",
"yuva420p",
"ya8",
"yuva422p",
"yuva444p",
"yuva420p9be",
"yuva420p9le",
"yuva422p9be",
"yuva422p9le",
"yuva444p9be",
"yuva444p9le",
"yuva420p10be",
"yuva420p10le",
"yuva422p10be",
"yuva422p10le",
"yuva444p10be",
"yuva444p10le",
"yuva420p16be",
"yuva420p16le",
"yuva422p16be",
"yuva422p16le",
"yuva444p16be",
"yuva444p16le",
"rgba64be",
"rgba64le",
"bgra64be",
"bgra64le",
"ya16be",
"ya16le",
"gbrap",
"gbrap16be",
"gbrap16le",
"ayuv64le",
"ayuv64be",
"gbrap12be",
"gbrap12le",
"gbrap10be",
"gbrap10le",
"gbrapf32be",
"gbrapf32le",
"yuva422p12be",
"yuva422p12le",
"yuva444p12be",
"yuva444p12le",
}
// containsAlpha returns whether given pixfmt
// (i.e. colorspace) contains an alpha channel.
func containsAlpha(pixfmt string) bool {
if pixfmt == "" {
return false
}
for _, checkfmt := range alphaPixelFormats {
if pixfmt == checkfmt {
return true
}
}
return false
}