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