mirror of
https://github.com/hoernschen/dendrite.git
synced 2025-08-02 14:12:47 +00:00
use go module for dependencies (#594)
This commit is contained in:
parent
4d588f7008
commit
74827428bd
6109 changed files with 216 additions and 1114821 deletions
249
mediaapi/thumbnailer/thumbnailer.go
Normal file
249
mediaapi/thumbnailer/thumbnailer.go
Normal 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
|
||||
}
|
248
mediaapi/thumbnailer/thumbnailer_bimg.go
Normal file
248
mediaapi/thumbnailer/thumbnailer_bimg.go
Normal 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
|
||||
}
|
272
mediaapi/thumbnailer/thumbnailer_nfnt.go
Normal file
272
mediaapi/thumbnailer/thumbnailer_nfnt.go
Normal 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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue