1// Copyright 2017 Vector Creations Ltd 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15//go:build bimg 16// +build bimg 17 18package thumbnailer 19 20import ( 21 "context" 22 "os" 23 "time" 24 25 "github.com/matrix-org/dendrite/mediaapi/storage" 26 "github.com/matrix-org/dendrite/mediaapi/types" 27 "github.com/matrix-org/dendrite/setup/config" 28 log "github.com/sirupsen/logrus" 29 "gopkg.in/h2non/bimg.v1" 30) 31 32// GenerateThumbnails generates the configured thumbnail sizes for the source file 33func GenerateThumbnails( 34 ctx context.Context, 35 src types.Path, 36 configs []config.ThumbnailSize, 37 mediaMetadata *types.MediaMetadata, 38 activeThumbnailGeneration *types.ActiveThumbnailGeneration, 39 maxThumbnailGenerators int, 40 db *storage.Database, 41 logger *log.Entry, 42) (busy bool, errorReturn error) { 43 buffer, err := bimg.Read(string(src)) 44 if err != nil { 45 logger.WithError(err).WithField("src", src).Error("Failed to read src file") 46 return false, err 47 } 48 img := bimg.NewImage(buffer) 49 for _, config := range configs { 50 // Note: createThumbnail does locking based on activeThumbnailGeneration 51 busy, err = createThumbnail( 52 ctx, src, img, config, mediaMetadata, activeThumbnailGeneration, 53 maxThumbnailGenerators, db, logger, 54 ) 55 if err != nil { 56 logger.WithError(err).WithField("src", src).Error("Failed to generate thumbnails") 57 return false, err 58 } 59 if busy { 60 return true, nil 61 } 62 } 63 return false, nil 64} 65 66// GenerateThumbnail generates the configured thumbnail size for the source file 67func GenerateThumbnail( 68 ctx context.Context, 69 src types.Path, 70 config types.ThumbnailSize, 71 mediaMetadata *types.MediaMetadata, 72 activeThumbnailGeneration *types.ActiveThumbnailGeneration, 73 maxThumbnailGenerators int, 74 db *storage.Database, 75 logger *log.Entry, 76) (busy bool, errorReturn error) { 77 buffer, err := bimg.Read(string(src)) 78 if err != nil { 79 logger.WithError(err).WithFields(log.Fields{ 80 "src": src, 81 }).Error("Failed to read src file") 82 return false, err 83 } 84 img := bimg.NewImage(buffer) 85 // Note: createThumbnail does locking based on activeThumbnailGeneration 86 busy, err = createThumbnail( 87 ctx, src, img, config, mediaMetadata, activeThumbnailGeneration, 88 maxThumbnailGenerators, db, logger, 89 ) 90 if err != nil { 91 logger.WithError(err).WithFields(log.Fields{ 92 "src": src, 93 }).Error("Failed to generate thumbnails") 94 return false, err 95 } 96 if busy { 97 return true, nil 98 } 99 return false, nil 100} 101 102// createThumbnail checks if the thumbnail exists, and if not, generates it 103// Thumbnail generation is only done once for each non-existing thumbnail. 104func createThumbnail( 105 ctx context.Context, 106 src types.Path, 107 img *bimg.Image, 108 config types.ThumbnailSize, 109 mediaMetadata *types.MediaMetadata, 110 activeThumbnailGeneration *types.ActiveThumbnailGeneration, 111 maxThumbnailGenerators int, 112 db *storage.Database, 113 logger *log.Entry, 114) (busy bool, errorReturn error) { 115 logger = logger.WithFields(log.Fields{ 116 "Width": config.Width, 117 "Height": config.Height, 118 "ResizeMethod": config.ResizeMethod, 119 }) 120 121 // Check if request is larger than original 122 if isLargerThanOriginal(config, img) { 123 return false, nil 124 } 125 126 dst := GetThumbnailPath(src, config) 127 128 // Note: getActiveThumbnailGeneration uses mutexes and conditions from activeThumbnailGeneration 129 isActive, busy, err := getActiveThumbnailGeneration(dst, config, activeThumbnailGeneration, maxThumbnailGenerators, logger) 130 if err != nil { 131 return false, err 132 } 133 if busy { 134 return true, nil 135 } 136 137 if isActive { 138 // Note: This is an active request that MUST broadcastGeneration to wake up waiting goroutines! 139 // Note: broadcastGeneration uses mutexes and conditions from activeThumbnailGeneration 140 defer func() { 141 // Note: errorReturn is the named return variable so we wrap this in a closure to re-evaluate the arguments at defer-time 142 if err := recover(); err != nil { 143 broadcastGeneration(dst, activeThumbnailGeneration, config, err.(error), logger) 144 panic(err) 145 } 146 broadcastGeneration(dst, activeThumbnailGeneration, config, errorReturn, logger) 147 }() 148 } 149 150 exists, err := isThumbnailExists(ctx, dst, config, mediaMetadata, db, logger) 151 if err != nil || exists { 152 return false, err 153 } 154 155 start := time.Now() 156 width, height, err := resize(dst, img, config.Width, config.Height, config.ResizeMethod == "crop", logger) 157 if err != nil { 158 return false, err 159 } 160 logger.WithFields(log.Fields{ 161 "ActualWidth": width, 162 "ActualHeight": height, 163 "processTime": time.Now().Sub(start), 164 }).Info("Generated thumbnail") 165 166 stat, err := os.Stat(string(dst)) 167 if err != nil { 168 return false, err 169 } 170 171 thumbnailMetadata := &types.ThumbnailMetadata{ 172 MediaMetadata: &types.MediaMetadata{ 173 MediaID: mediaMetadata.MediaID, 174 Origin: mediaMetadata.Origin, 175 // Note: the code currently always creates a JPEG thumbnail 176 ContentType: types.ContentType("image/jpeg"), 177 FileSizeBytes: types.FileSizeBytes(stat.Size()), 178 }, 179 ThumbnailSize: types.ThumbnailSize{ 180 Width: config.Width, 181 Height: config.Height, 182 ResizeMethod: config.ResizeMethod, 183 }, 184 } 185 186 err = db.StoreThumbnail(ctx, thumbnailMetadata) 187 if err != nil { 188 logger.WithError(err).WithFields(log.Fields{ 189 "ActualWidth": width, 190 "ActualHeight": height, 191 }).Error("Failed to store thumbnail metadata in database.") 192 return false, err 193 } 194 195 return false, nil 196} 197 198func isLargerThanOriginal(config types.ThumbnailSize, img *bimg.Image) bool { 199 imgSize, err := img.Size() 200 if err == nil && config.Width >= imgSize.Width && config.Height >= imgSize.Height { 201 return true 202 } 203 return false 204} 205 206// resize scales an image to fit within the provided width and height 207// If the source aspect ratio is different to the target dimensions, one edge will be smaller than requested 208// If crop is set to true, the image will be scaled to fill the width and height with any excess being cropped off 209func resize(dst types.Path, inImage *bimg.Image, w, h int, crop bool, logger *log.Entry) (int, int, error) { 210 inSize, err := inImage.Size() 211 if err != nil { 212 return -1, -1, err 213 } 214 215 options := bimg.Options{ 216 Type: bimg.JPEG, 217 Quality: 85, 218 } 219 if crop { 220 options.Width = w 221 options.Height = h 222 options.Crop = true 223 } else { 224 inAR := float64(inSize.Width) / float64(inSize.Height) 225 outAR := float64(w) / float64(h) 226 227 if inAR > outAR { 228 // input has wider AR than requested output so use requested width and calculate height to match input AR 229 options.Width = w 230 options.Height = int(float64(w) / inAR) 231 } else { 232 // input has narrower AR than requested output so use requested height and calculate width to match input AR 233 options.Width = int(float64(h) * inAR) 234 options.Height = h 235 } 236 } 237 238 newImage, err := inImage.Process(options) 239 if err != nil { 240 return -1, -1, err 241 } 242 243 if err = bimg.Write(string(dst), newImage); err != nil { 244 logger.WithError(err).Error("Failed to resize image") 245 return -1, -1, err 246 } 247 248 return options.Width, options.Height, nil 249} 250