use go module for dependencies (#594)

This commit is contained in:
ruben 2019-05-21 22:56:55 +02:00 committed by Brendan Abolivier
parent 4d588f7008
commit 74827428bd
6109 changed files with 216 additions and 1114821 deletions

View file

@ -0,0 +1,249 @@
// Copyright 2017 Vector Creations Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package thumbnailer
import (
"context"
"fmt"
"math"
"os"
"path/filepath"
"sync"
"github.com/matrix-org/dendrite/common/config"
"github.com/matrix-org/dendrite/mediaapi/storage"
"github.com/matrix-org/dendrite/mediaapi/types"
log "github.com/sirupsen/logrus"
)
type thumbnailFitness struct {
isSmaller int
aspect float64
size float64
methodMismatch int
fileSize types.FileSizeBytes
}
// thumbnailTemplate is the filename template for thumbnails
const thumbnailTemplate = "thumbnail-%vx%v-%v"
// GetThumbnailPath returns the path to a thumbnail given the absolute src path and thumbnail size configuration
func GetThumbnailPath(src types.Path, config types.ThumbnailSize) types.Path {
srcDir := filepath.Dir(string(src))
return types.Path(filepath.Join(
srcDir,
fmt.Sprintf(thumbnailTemplate, config.Width, config.Height, config.ResizeMethod),
))
}
// SelectThumbnail compares the (potentially) available thumbnails with the desired thumbnail and returns the best match
// The algorithm is very similar to what was implemented in Synapse
// In order of priority unless absolute, the following metrics are compared; the image is:
// * the same size or larger than requested
// * if a cropped image is desired, has an aspect ratio close to requested
// * has a size close to requested
// * if a cropped image is desired, prefer the same method, if scaled is desired, absolutely require scaled
// * has a small file size
// If a pre-generated thumbnail size is the best match, but it has not been generated yet, the caller can use the returned size to generate it.
// Returns nil if no thumbnail matches the criteria
func SelectThumbnail(desired types.ThumbnailSize, thumbnails []*types.ThumbnailMetadata, thumbnailSizes []config.ThumbnailSize) (*types.ThumbnailMetadata, *types.ThumbnailSize) {
var chosenThumbnail *types.ThumbnailMetadata
var chosenThumbnailSize *types.ThumbnailSize
bestFit := newThumbnailFitness()
for _, thumbnail := range thumbnails {
if desired.ResizeMethod == types.Scale && thumbnail.ThumbnailSize.ResizeMethod != types.Scale {
continue
}
fitness := calcThumbnailFitness(thumbnail.ThumbnailSize, thumbnail.MediaMetadata, desired)
if isBetter := fitness.betterThan(bestFit, desired.ResizeMethod == types.Crop); isBetter {
bestFit = fitness
chosenThumbnail = thumbnail
}
}
for _, thumbnailSize := range thumbnailSizes {
if desired.ResizeMethod == types.Scale && thumbnailSize.ResizeMethod != types.Scale {
continue
}
fitness := calcThumbnailFitness(types.ThumbnailSize(thumbnailSize), nil, desired)
if isBetter := fitness.betterThan(bestFit, desired.ResizeMethod == types.Crop); isBetter {
bestFit = fitness
chosenThumbnailSize = (*types.ThumbnailSize)(&thumbnailSize)
}
}
return chosenThumbnail, chosenThumbnailSize
}
// getActiveThumbnailGeneration checks for active thumbnail generation
func getActiveThumbnailGeneration(dst types.Path, _ types.ThumbnailSize, activeThumbnailGeneration *types.ActiveThumbnailGeneration, maxThumbnailGenerators int, logger *log.Entry) (isActive bool, busy bool, errorReturn error) {
// Check if there is active thumbnail generation.
activeThumbnailGeneration.Lock()
defer activeThumbnailGeneration.Unlock()
if activeThumbnailGenerationResult, ok := activeThumbnailGeneration.PathToResult[string(dst)]; ok {
logger.Info("Waiting for another goroutine to generate the thumbnail.")
// NOTE: Wait unlocks and locks again internally. There is still a deferred Unlock() that will unlock this.
activeThumbnailGenerationResult.Cond.Wait()
// Note: either there is an error or it is nil, either way returning it is correct
return false, false, activeThumbnailGenerationResult.Err
}
// Only allow thumbnail generation up to a maximum configured number. Above this we fall back to serving the
// original. Or in the case of pre-generation, they maybe get generated on the first request for a thumbnail if
// load has subsided.
if len(activeThumbnailGeneration.PathToResult) >= maxThumbnailGenerators {
return false, true, nil
}
// No active thumbnail generation so create one
activeThumbnailGeneration.PathToResult[string(dst)] = &types.ThumbnailGenerationResult{
Cond: &sync.Cond{L: activeThumbnailGeneration},
}
return true, false, nil
}
// broadcastGeneration broadcasts that thumbnail generation completed and the error to all waiting goroutines
// Note: This should only be called by the owner of the activeThumbnailGenerationResult
func broadcastGeneration(dst types.Path, activeThumbnailGeneration *types.ActiveThumbnailGeneration, _ types.ThumbnailSize, errorReturn error, logger *log.Entry) {
activeThumbnailGeneration.Lock()
defer activeThumbnailGeneration.Unlock()
if activeThumbnailGenerationResult, ok := activeThumbnailGeneration.PathToResult[string(dst)]; ok {
logger.Info("Signalling other goroutines waiting for this goroutine to generate the thumbnail.")
// Note: errorReturn is a named return value error that is signalled from here to waiting goroutines
activeThumbnailGenerationResult.Err = errorReturn
activeThumbnailGenerationResult.Cond.Broadcast()
}
delete(activeThumbnailGeneration.PathToResult, string(dst))
}
func isThumbnailExists(
ctx context.Context,
dst types.Path,
config types.ThumbnailSize,
mediaMetadata *types.MediaMetadata,
db *storage.Database,
logger *log.Entry,
) (bool, error) {
thumbnailMetadata, err := db.GetThumbnail(
ctx, mediaMetadata.MediaID, mediaMetadata.Origin,
config.Width, config.Height, config.ResizeMethod,
)
if err != nil {
logger.Error("Failed to query database for thumbnail.")
return false, err
}
if thumbnailMetadata != nil {
return true, nil
}
// Note: The double-negative is intentional as os.IsExist(err) != !os.IsNotExist(err).
// The functions are error checkers to be used in different cases.
if _, err = os.Stat(string(dst)); !os.IsNotExist(err) {
// Thumbnail exists
return true, nil
}
return false, nil
}
// init with worst values
func newThumbnailFitness() thumbnailFitness {
return thumbnailFitness{
isSmaller: 1,
aspect: math.Inf(1),
size: math.Inf(1),
methodMismatch: 0,
fileSize: types.FileSizeBytes(math.MaxInt64),
}
}
func calcThumbnailFitness(size types.ThumbnailSize, metadata *types.MediaMetadata, desired types.ThumbnailSize) thumbnailFitness {
dW := desired.Width
dH := desired.Height
tW := size.Width
tH := size.Height
fitness := newThumbnailFitness()
// In all cases, a larger metric value is a worse fit.
// compare size: thumbnail smaller is true and gives 1, larger is false and gives 0
fitness.isSmaller = boolToInt(tW < dW || tH < dH)
// comparison of aspect ratios only makes sense for a request for desired cropped
fitness.aspect = math.Abs(float64(dW*tH - dH*tW))
// compare sizes
fitness.size = math.Abs(float64((dW - tW) * (dH - tH)))
// compare resize method
fitness.methodMismatch = boolToInt(size.ResizeMethod != desired.ResizeMethod)
if metadata != nil {
// file size
fitness.fileSize = metadata.FileSizeBytes
}
return fitness
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func (a thumbnailFitness) betterThan(b thumbnailFitness, desiredCrop bool) bool {
// preference means returning -1
// prefer images that are not smaller
// e.g. isSmallerDiff > 0 means b is smaller than desired and a is not smaller
if a.isSmaller > b.isSmaller {
return false
} else if a.isSmaller < b.isSmaller {
return true
}
// prefer aspect ratios closer to desired only if desired cropped
// only cropped images have differing aspect ratios
// desired scaled only accepts scaled images
if desiredCrop {
if a.aspect > b.aspect {
return false
} else if a.aspect < b.aspect {
return true
}
}
// prefer closer in size
if a.size > b.size {
return false
} else if a.size < b.size {
return true
}
// prefer images using the same method
// e.g. methodMismatchDiff > 0 means b's method is different from desired and a's matches the desired method
if a.methodMismatch > b.methodMismatch {
return false
} else if a.methodMismatch < b.methodMismatch {
return true
}
// prefer smaller files
if a.fileSize > b.fileSize {
return false
} else if a.fileSize < b.fileSize {
return true
}
return false
}

View file

@ -0,0 +1,248 @@
// Copyright 2017 Vector Creations Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// +build bimg
package thumbnailer
import (
"context"
"os"
"time"
"github.com/matrix-org/dendrite/common/config"
"github.com/matrix-org/dendrite/mediaapi/storage"
"github.com/matrix-org/dendrite/mediaapi/types"
log "github.com/sirupsen/logrus"
"gopkg.in/h2non/bimg.v1"
)
// GenerateThumbnails generates the configured thumbnail sizes for the source file
func GenerateThumbnails(
ctx context.Context,
src types.Path,
configs []config.ThumbnailSize,
mediaMetadata *types.MediaMetadata,
activeThumbnailGeneration *types.ActiveThumbnailGeneration,
maxThumbnailGenerators int,
db *storage.Database,
logger *log.Entry,
) (busy bool, errorReturn error) {
buffer, err := bimg.Read(string(src))
if err != nil {
logger.WithError(err).WithField("src", src).Error("Failed to read src file")
return false, err
}
img := bimg.NewImage(buffer)
for _, config := range configs {
// Note: createThumbnail does locking based on activeThumbnailGeneration
busy, err = createThumbnail(
ctx, src, img, config, mediaMetadata, activeThumbnailGeneration,
maxThumbnailGenerators, db, logger,
)
if err != nil {
logger.WithError(err).WithField("src", src).Error("Failed to generate thumbnails")
return false, err
}
if busy {
return true, nil
}
}
return false, nil
}
// GenerateThumbnail generates the configured thumbnail size for the source file
func GenerateThumbnail(
ctx context.Context,
src types.Path,
config types.ThumbnailSize,
mediaMetadata *types.MediaMetadata,
activeThumbnailGeneration *types.ActiveThumbnailGeneration,
maxThumbnailGenerators int,
db *storage.Database,
logger *log.Entry,
) (busy bool, errorReturn error) {
buffer, err := bimg.Read(string(src))
if err != nil {
logger.WithError(err).WithFields(log.Fields{
"src": src,
}).Error("Failed to read src file")
return false, err
}
img := bimg.NewImage(buffer)
// Note: createThumbnail does locking based on activeThumbnailGeneration
busy, err = createThumbnail(
ctx, src, img, config, mediaMetadata, activeThumbnailGeneration,
maxThumbnailGenerators, db, logger,
)
if err != nil {
logger.WithError(err).WithFields(log.Fields{
"src": src,
}).Error("Failed to generate thumbnails")
return false, err
}
if busy {
return true, nil
}
return false, nil
}
// createThumbnail checks if the thumbnail exists, and if not, generates it
// Thumbnail generation is only done once for each non-existing thumbnail.
func createThumbnail(
ctx context.Context,
src types.Path,
img *bimg.Image,
config types.ThumbnailSize,
mediaMetadata *types.MediaMetadata,
activeThumbnailGeneration *types.ActiveThumbnailGeneration,
maxThumbnailGenerators int,
db *storage.Database,
logger *log.Entry,
) (busy bool, errorReturn error) {
logger = logger.WithFields(log.Fields{
"Width": config.Width,
"Height": config.Height,
"ResizeMethod": config.ResizeMethod,
})
// Check if request is larger than original
if isLargerThanOriginal(config, img) {
return false, nil
}
dst := GetThumbnailPath(src, config)
// Note: getActiveThumbnailGeneration uses mutexes and conditions from activeThumbnailGeneration
isActive, busy, err := getActiveThumbnailGeneration(dst, config, activeThumbnailGeneration, maxThumbnailGenerators, logger)
if err != nil {
return false, err
}
if busy {
return true, nil
}
if isActive {
// Note: This is an active request that MUST broadcastGeneration to wake up waiting goroutines!
// Note: broadcastGeneration uses mutexes and conditions from activeThumbnailGeneration
defer func() {
// Note: errorReturn is the named return variable so we wrap this in a closure to re-evaluate the arguments at defer-time
if err := recover(); err != nil {
broadcastGeneration(dst, activeThumbnailGeneration, config, err.(error), logger)
panic(err)
}
broadcastGeneration(dst, activeThumbnailGeneration, config, errorReturn, logger)
}()
}
exists, err := isThumbnailExists(ctx, dst, config, mediaMetadata, db, logger)
if err != nil || exists {
return false, err
}
start := time.Now()
width, height, err := resize(dst, img, config.Width, config.Height, config.ResizeMethod == "crop", logger)
if err != nil {
return false, err
}
logger.WithFields(log.Fields{
"ActualWidth": width,
"ActualHeight": height,
"processTime": time.Now().Sub(start),
}).Info("Generated thumbnail")
stat, err := os.Stat(string(dst))
if err != nil {
return false, err
}
thumbnailMetadata := &types.ThumbnailMetadata{
MediaMetadata: &types.MediaMetadata{
MediaID: mediaMetadata.MediaID,
Origin: mediaMetadata.Origin,
// Note: the code currently always creates a JPEG thumbnail
ContentType: types.ContentType("image/jpeg"),
FileSizeBytes: types.FileSizeBytes(stat.Size()),
},
ThumbnailSize: types.ThumbnailSize{
Width: config.Width,
Height: config.Height,
ResizeMethod: config.ResizeMethod,
},
}
err = db.StoreThumbnail(ctx, thumbnailMetadata)
if err != nil {
logger.WithError(err).WithFields(log.Fields{
"ActualWidth": width,
"ActualHeight": height,
}).Error("Failed to store thumbnail metadata in database.")
return false, err
}
return false, nil
}
func isLargerThanOriginal(config types.ThumbnailSize, img *bimg.Image) bool {
imgSize, err := img.Size()
if err == nil && config.Width >= imgSize.Width && config.Height >= imgSize.Height {
return true
}
return false
}
// resize scales an image to fit within the provided width and height
// If the source aspect ratio is different to the target dimensions, one edge will be smaller than requested
// If crop is set to true, the image will be scaled to fill the width and height with any excess being cropped off
func resize(dst types.Path, inImage *bimg.Image, w, h int, crop bool, logger *log.Entry) (int, int, error) {
inSize, err := inImage.Size()
if err != nil {
return -1, -1, err
}
options := bimg.Options{
Type: bimg.JPEG,
Quality: 85,
}
if crop {
options.Width = w
options.Height = h
options.Crop = true
} else {
inAR := float64(inSize.Width) / float64(inSize.Height)
outAR := float64(w) / float64(h)
if inAR > outAR {
// input has wider AR than requested output so use requested width and calculate height to match input AR
options.Width = w
options.Height = int(float64(w) / inAR)
} else {
// input has narrower AR than requested output so use requested height and calculate width to match input AR
options.Width = int(float64(h) * inAR)
options.Height = h
}
}
newImage, err := inImage.Process(options)
if err != nil {
return -1, -1, err
}
if err = bimg.Write(string(dst), newImage); err != nil {
logger.WithError(err).Error("Failed to resize image")
return -1, -1, err
}
return options.Width, options.Height, nil
}

View file

@ -0,0 +1,272 @@
// Copyright 2017 Vector Creations Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// +build !bimg
package thumbnailer
import (
"context"
"image"
"image/draw"
// Imported for gif codec
_ "image/gif"
"image/jpeg"
// Imported for png codec
_ "image/png"
"os"
"time"
"github.com/matrix-org/dendrite/common/config"
"github.com/matrix-org/dendrite/mediaapi/storage"
"github.com/matrix-org/dendrite/mediaapi/types"
"github.com/nfnt/resize"
log "github.com/sirupsen/logrus"
)
// GenerateThumbnails generates the configured thumbnail sizes for the source file
func GenerateThumbnails(
ctx context.Context,
src types.Path,
configs []config.ThumbnailSize,
mediaMetadata *types.MediaMetadata,
activeThumbnailGeneration *types.ActiveThumbnailGeneration,
maxThumbnailGenerators int,
db *storage.Database,
logger *log.Entry,
) (busy bool, errorReturn error) {
img, err := readFile(string(src))
if err != nil {
logger.WithError(err).WithField("src", src).Error("Failed to read src file")
return false, err
}
for _, singleConfig := range configs {
// Note: createThumbnail does locking based on activeThumbnailGeneration
busy, err = createThumbnail(
ctx, src, img, types.ThumbnailSize(singleConfig), mediaMetadata,
activeThumbnailGeneration, maxThumbnailGenerators, db, logger,
)
if err != nil {
logger.WithError(err).WithField("src", src).Error("Failed to generate thumbnails")
return false, err
}
if busy {
return true, nil
}
}
return false, nil
}
// GenerateThumbnail generates the configured thumbnail size for the source file
func GenerateThumbnail(
ctx context.Context,
src types.Path,
config types.ThumbnailSize,
mediaMetadata *types.MediaMetadata,
activeThumbnailGeneration *types.ActiveThumbnailGeneration,
maxThumbnailGenerators int,
db *storage.Database,
logger *log.Entry,
) (busy bool, errorReturn error) {
img, err := readFile(string(src))
if err != nil {
logger.WithError(err).WithFields(log.Fields{
"src": src,
}).Error("Failed to read src file")
return false, err
}
// Note: createThumbnail does locking based on activeThumbnailGeneration
busy, err = createThumbnail(
ctx, src, img, config, mediaMetadata, activeThumbnailGeneration,
maxThumbnailGenerators, db, logger,
)
if err != nil {
logger.WithError(err).WithFields(log.Fields{
"src": src,
}).Error("Failed to generate thumbnails")
return false, err
}
if busy {
return true, nil
}
return false, nil
}
func readFile(src string) (image.Image, error) {
file, err := os.Open(src)
if err != nil {
return nil, err
}
defer file.Close() // nolint: errcheck
img, _, err := image.Decode(file)
if err != nil {
return nil, err
}
return img, nil
}
func writeFile(img image.Image, dst string) (err error) {
out, err := os.Create(dst)
if err != nil {
return err
}
defer (func() { err = out.Close() })()
return jpeg.Encode(out, img, &jpeg.Options{
Quality: 85,
})
}
// createThumbnail checks if the thumbnail exists, and if not, generates it
// Thumbnail generation is only done once for each non-existing thumbnail.
func createThumbnail(
ctx context.Context,
src types.Path,
img image.Image,
config types.ThumbnailSize,
mediaMetadata *types.MediaMetadata,
activeThumbnailGeneration *types.ActiveThumbnailGeneration,
maxThumbnailGenerators int,
db *storage.Database,
logger *log.Entry,
) (busy bool, errorReturn error) {
logger = logger.WithFields(log.Fields{
"Width": config.Width,
"Height": config.Height,
"ResizeMethod": config.ResizeMethod,
})
// Check if request is larger than original
if config.Width >= img.Bounds().Dx() && config.Height >= img.Bounds().Dy() {
return false, nil
}
dst := GetThumbnailPath(src, config)
// Note: getActiveThumbnailGeneration uses mutexes and conditions from activeThumbnailGeneration
isActive, busy, err := getActiveThumbnailGeneration(dst, config, activeThumbnailGeneration, maxThumbnailGenerators, logger)
if err != nil {
return false, err
}
if busy {
return true, nil
}
if isActive {
// Note: This is an active request that MUST broadcastGeneration to wake up waiting goroutines!
// Note: broadcastGeneration uses mutexes and conditions from activeThumbnailGeneration
defer func() {
// Note: errorReturn is the named return variable so we wrap this in a closure to re-evaluate the arguments at defer-time
// if err := recover(); err != nil {
// broadcastGeneration(dst, activeThumbnailGeneration, config, err.(error), logger)
// panic(err)
// }
broadcastGeneration(dst, activeThumbnailGeneration, config, errorReturn, logger)
}()
}
exists, err := isThumbnailExists(ctx, dst, config, mediaMetadata, db, logger)
if err != nil || exists {
return false, err
}
start := time.Now()
width, height, err := adjustSize(dst, img, config.Width, config.Height, config.ResizeMethod == types.Crop, logger)
if err != nil {
return false, err
}
logger.WithFields(log.Fields{
"ActualWidth": width,
"ActualHeight": height,
"processTime": time.Since(start),
}).Info("Generated thumbnail")
stat, err := os.Stat(string(dst))
if err != nil {
return false, err
}
thumbnailMetadata := &types.ThumbnailMetadata{
MediaMetadata: &types.MediaMetadata{
MediaID: mediaMetadata.MediaID,
Origin: mediaMetadata.Origin,
// Note: the code currently always creates a JPEG thumbnail
ContentType: types.ContentType("image/jpeg"),
FileSizeBytes: types.FileSizeBytes(stat.Size()),
},
ThumbnailSize: types.ThumbnailSize{
Width: config.Width,
Height: config.Height,
ResizeMethod: config.ResizeMethod,
},
}
err = db.StoreThumbnail(ctx, thumbnailMetadata)
if err != nil {
logger.WithError(err).WithFields(log.Fields{
"ActualWidth": width,
"ActualHeight": height,
}).Error("Failed to store thumbnail metadata in database.")
return false, err
}
return false, nil
}
// adjustSize scales an image to fit within the provided width and height
// If the source aspect ratio is different to the target dimensions, one edge will be smaller than requested
// If crop is set to true, the image will be scaled to fill the width and height with any excess being cropped off
func adjustSize(dst types.Path, img image.Image, w, h int, crop bool, logger *log.Entry) (int, int, error) {
var out image.Image
var err error
if crop {
inAR := float64(img.Bounds().Dx()) / float64(img.Bounds().Dy())
outAR := float64(w) / float64(h)
var scaleW, scaleH uint
if inAR > outAR {
// input has shorter AR than requested output so use requested height and calculate width to match input AR
scaleW = uint(float64(h) * inAR)
scaleH = uint(h)
} else {
// input has taller AR than requested output so use requested width and calculate height to match input AR
scaleW = uint(w)
scaleH = uint(float64(w) / inAR)
}
scaled := resize.Resize(scaleW, scaleH, img, resize.Lanczos3)
xoff := (scaled.Bounds().Dx() - w) / 2
yoff := (scaled.Bounds().Dy() - h) / 2
tr := image.Rect(0, 0, w, h)
target := image.NewRGBA(tr)
draw.Draw(target, tr, scaled, image.Pt(xoff, yoff), draw.Src)
out = target
} else {
out = resize.Thumbnail(uint(w), uint(h), img, resize.Lanczos3)
if err != nil {
return -1, -1, err
}
}
if err = writeFile(out, string(dst)); err != nil {
logger.WithError(err).Error("Failed to encode and write image")
return -1, -1, err
}
return out.Bounds().Max.X, out.Bounds().Max.Y, nil
}