aboutsummaryrefslogtreecommitdiff
path: root/mediaapi/thumbnailer/thumbnailer.go
blob: 58407ce8b7aba9b7edacb6fd9c45119609807344 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
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/mediaapi/storage"
	"github.com/matrix-org/dendrite/mediaapi/types"
	"github.com/matrix-org/dendrite/setup/config"
	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
}