• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cshum / imagor / 22022082076

14 Feb 2026 06:18PM UTC coverage: 91.811% (-0.2%) from 91.968%
22022082076

Pull #713

github

mevinbabuc
feat: add in-memory watermark cache with ristretto

Adds an optional in-memory cache for processed watermark images using
dgraph-io/ristretto. When enabled via -imagor-watermark-cache-size,
watermarks are cached after resize, colorspace conversion, and alpha
application as vips.Image objects for fast copy-on-write retrieval.

Also adds singleflight deduplication for concurrent image loads to
prevent redundant fetches of the same watermark.
Pull Request #713: feat: add watermark caching for improved performance

81 of 99 new or added lines in 5 files covered. (81.82%)

2 existing lines in 2 files now uncovered.

5864 of 6387 relevant lines covered (91.81%)

1.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.78
/processor/vipsprocessor/processor.go
1
package vipsprocessor
2

3
import (
4
        "context"
5
        "math"
6
        "runtime"
7
        "strings"
8
        "sync"
9

10
        "github.com/cshum/imagor"
11
        "github.com/cshum/vipsgen/vips"
12
        "github.com/dgraph-io/ristretto"
13
        "go.uber.org/zap"
14
)
15

16
// FilterFunc filter handler function
17
type FilterFunc func(ctx context.Context, img *vips.Image, load imagor.LoadFunc, args ...string) (err error)
18

19
// FilterMap filter handler map
20
type FilterMap map[string]FilterFunc
21

22
var processorLock sync.RWMutex
23
var processorCount int
24

25
// Processor implements imagor.Processor interface
26
type Processor struct {
27
        Filters            FilterMap
28
        FallbackFunc       FallbackFunc
29
        DisableBlur        bool
30
        DisableFilters     []string
31
        MaxFilterOps       int
32
        Logger             *zap.Logger
33
        Concurrency        int
34
        MaxCacheFiles      int
35
        MaxCacheMem        int
36
        MaxCacheSize       int
37
        MaxWidth           int
38
        MaxHeight          int
39
        MaxResolution      int
40
        MaxAnimationFrames int
41
        MozJPEG            bool
42
        StripMetadata      bool
43
        AvifSpeed          int
44
        Unlimited          bool
45
        Debug              bool
46

47
        WatermarkCacheSize int64
48

49
        disableFilters map[string]bool
50
        watermarkCache *ristretto.Cache
51
}
52

53
// NewProcessor create Processor
54
func NewProcessor(options ...Option) *Processor {
1✔
55
        v := &Processor{
1✔
56
                MaxWidth:           9999,
1✔
57
                MaxHeight:          9999,
1✔
58
                MaxResolution:      81000000,
1✔
59
                Concurrency:        1,
1✔
60
                MaxFilterOps:       -1,
1✔
61
                MaxAnimationFrames: -1,
1✔
62
                Logger:             zap.NewNop(),
1✔
63
                disableFilters:     map[string]bool{},
1✔
64
        }
1✔
65
        v.Filters = FilterMap{
1✔
66
                "image":            v.image,
1✔
67
                "watermark":        v.watermark,
1✔
68
                "round_corner":     roundCorner,
1✔
69
                "rotate":           rotate,
1✔
70
                "label":            label,
1✔
71
                "grayscale":        grayscale,
1✔
72
                "brightness":       brightness,
1✔
73
                "background_color": backgroundColor,
1✔
74
                "contrast":         contrast,
1✔
75
                "modulate":         modulate,
1✔
76
                "hue":              hue,
1✔
77
                "saturation":       saturation,
1✔
78
                "rgb":              rgb,
1✔
79
                "blur":             blur,
1✔
80
                "sharpen":          sharpen,
1✔
81
                "strip_icc":        stripIcc,
1✔
82
                "strip_exif":       stripExif,
1✔
83
                "to_colorspace":    toColorspace,
1✔
84
                "trim":             trim,
1✔
85
                "padding":          v.padding,
1✔
86
                "proportion":       proportion,
1✔
87
                "crop":             crop,
1✔
88
        }
1✔
89
        for _, option := range options {
2✔
90
                option(v)
1✔
91
        }
1✔
92
        if v.DisableBlur {
2✔
93
                v.DisableFilters = append(v.DisableFilters, "blur", "sharpen")
1✔
94
        }
1✔
95
        for _, name := range v.DisableFilters {
2✔
96
                v.disableFilters[name] = true
1✔
97
        }
1✔
98
        if v.Concurrency == -1 {
2✔
99
                v.Concurrency = runtime.NumCPU()
1✔
100
        }
1✔
101
        return v
1✔
102
}
103

104
// Startup implements imagor.Processor interface
105
func (v *Processor) Startup(_ context.Context) error {
1✔
106
        processorLock.Lock()
1✔
107
        defer processorLock.Unlock()
1✔
108
        processorCount++
1✔
109
        if processorCount <= 1 {
2✔
110
                if v.Debug {
2✔
111
                        vips.SetLogging(func(domain string, level vips.LogLevel, msg string) {
2✔
112
                                switch level {
1✔
113
                                case vips.LogLevelDebug:
1✔
114
                                        v.Logger.Debug(domain, zap.String("log", msg))
1✔
115
                                case vips.LogLevelMessage, vips.LogLevelInfo:
1✔
116
                                        v.Logger.Info(domain, zap.String("log", msg))
1✔
117
                                case vips.LogLevelWarning, vips.LogLevelCritical, vips.LogLevelError:
1✔
118
                                        v.Logger.Warn(domain, zap.String("log", msg))
1✔
119
                                }
120
                        }, vips.LogLevelDebug)
121
                } else {
×
122
                        vips.SetLogging(func(domain string, level vips.LogLevel, msg string) {
×
123
                                v.Logger.Warn(domain, zap.String("log", msg))
×
124
                        }, vips.LogLevelError)
×
125
                }
126
                vips.Startup(&vips.Config{
1✔
127
                        MaxCacheFiles:    v.MaxCacheFiles,
1✔
128
                        MaxCacheMem:      v.MaxCacheMem,
1✔
129
                        MaxCacheSize:     v.MaxCacheSize,
1✔
130
                        ConcurrencyLevel: v.Concurrency,
1✔
131
                })
1✔
132
        }
133
        if v.FallbackFunc == nil {
2✔
134
                if vips.HasOperation("magickload_buffer") {
1✔
135
                        v.FallbackFunc = bufferFallbackFunc
×
136
                        v.Logger.Debug("source fallback", zap.String("fallback", "magickload_buffer"))
×
137
                } else {
1✔
138
                        v.FallbackFunc = v.bmpFallbackFunc
1✔
139
                        v.Logger.Debug("source fallback", zap.String("fallback", "bmp"))
1✔
140
                }
1✔
141
        }
142
        if v.WatermarkCacheSize > 0 {
2✔
143
                cache, err := ristretto.NewCache(&ristretto.Config{
1✔
144
                        NumCounters: 1e4,
1✔
145
                        MaxCost:     v.WatermarkCacheSize,
1✔
146
                        BufferItems: 64,
1✔
147
                        OnEvict: func(item *ristretto.Item) {
2✔
148
                                if img, ok := item.Value.(*vips.Image); ok {
2✔
149
                                        img.Close()
1✔
150
                                }
1✔
151
                        },
152
                })
153
                if err != nil {
1✔
NEW
154
                        return err
×
NEW
155
                }
×
156
                v.watermarkCache = cache
1✔
157
                v.Logger.Info("watermark cache enabled", zap.Int64("max_size", v.WatermarkCacheSize))
1✔
158
        }
159
        return nil
1✔
160
}
161

162
// Shutdown implements imagor.Processor interface
163
func (v *Processor) Shutdown(_ context.Context) error {
1✔
164
        processorLock.Lock()
1✔
165
        defer processorLock.Unlock()
1✔
166
        if v.watermarkCache != nil {
2✔
167
                v.watermarkCache.Close()
1✔
168
        }
1✔
169
        if processorCount <= 0 {
1✔
170
                return nil
×
171
        }
×
172
        processorCount--
1✔
173
        if processorCount == 0 {
2✔
174
                vips.Shutdown()
1✔
175
        }
1✔
176
        return nil
1✔
177
}
178

179
func (v *Processor) newImageFromBlob(
180
        ctx context.Context, blob *imagor.Blob, options *vips.LoadOptions,
181
) (*vips.Image, error) {
1✔
182
        if blob == nil || blob.IsEmpty() {
1✔
183
                return nil, imagor.ErrNotFound
×
184
        }
×
185
        if blob.BlobType() == imagor.BlobTypeMemory {
2✔
186
                buf, width, height, bands, _ := blob.Memory()
1✔
187
                return vips.NewImageFromMemory(buf, width, height, bands)
1✔
188
        }
1✔
189
        reader, _, err := blob.NewReader()
1✔
190
        if err != nil {
1✔
191
                return nil, err
×
192
        }
×
193
        src := vips.NewSource(reader)
1✔
194
        contextDefer(ctx, src.Close)
1✔
195
        img, err := vips.NewImageFromSource(src, options)
1✔
196
        if err != nil && v.FallbackFunc != nil {
2✔
197
                src.Close()
1✔
198
                return v.FallbackFunc(blob, options)
1✔
199
        }
1✔
200
        return img, err
1✔
201
}
202

203
func newThumbnailFromBlob(
204
        ctx context.Context, blob *imagor.Blob,
205
        width, height int, crop vips.Interesting, size vips.Size, options *vips.LoadOptions,
206
) (*vips.Image, error) {
1✔
207
        if blob == nil || blob.IsEmpty() {
1✔
208
                return nil, imagor.ErrNotFound
×
209
        }
×
210
        reader, _, err := blob.NewReader()
1✔
211
        if err != nil {
1✔
212
                return nil, err
×
213
        }
×
214
        src := vips.NewSource(reader)
1✔
215
        contextDefer(ctx, src.Close)
1✔
216
        var optionString string
1✔
217
        if options != nil {
2✔
218
                optionString = options.OptionString()
1✔
219
        }
1✔
220
        return vips.NewThumbnailSource(src, width, &vips.ThumbnailSourceOptions{
1✔
221
                Height:       height,
1✔
222
                Crop:         crop,
1✔
223
                Size:         size,
1✔
224
                OptionString: optionString,
1✔
225
        })
1✔
226
}
227

228
// NewThumbnail creates new thumbnail with resize and crop from imagor.Blob
229
func (v *Processor) NewThumbnail(
230
        ctx context.Context, blob *imagor.Blob, width, height int, crop vips.Interesting,
231
        size vips.Size, n, page int, dpi int,
232
) (*vips.Image, error) {
1✔
233
        var options = &vips.LoadOptions{}
1✔
234
        if dpi > 0 {
1✔
235
                options.Dpi = dpi
×
236
        }
×
237
        options.Unlimited = v.Unlimited
1✔
238
        var err error
1✔
239
        var img *vips.Image
1✔
240
        if isMultiPage(blob, n, page) {
2✔
241
                applyMultiPageOptions(options, n, page)
1✔
242
                if crop == vips.InterestingNone || size == vips.SizeForce {
2✔
243
                        if img, err = v.newImageFromBlob(ctx, blob, options); err != nil {
1✔
244
                                return nil, WrapErr(err)
×
245
                        }
×
246
                        if n > 1 || page > 1 {
2✔
247
                                // reload image to restrict frames loaded
1✔
248
                                n, page = recalculateImage(img, n, page)
1✔
249
                                return v.NewThumbnail(ctx, blob, width, height, crop, size, -n, -page, dpi)
1✔
250
                        }
1✔
251
                        if _, err = v.CheckResolution(img, nil); err != nil {
2✔
252
                                return nil, err
1✔
253
                        }
1✔
254
                        if err = img.ThumbnailImage(width, &vips.ThumbnailImageOptions{
1✔
255
                                Height: height, Size: size, Crop: crop,
1✔
256
                        }); err != nil {
1✔
257
                                img.Close()
×
258
                                return nil, WrapErr(err)
×
259
                        }
×
260
                } else {
1✔
261
                        if img, err = v.CheckResolution(v.newImageFromBlob(ctx, blob, options)); err != nil {
1✔
262
                                return nil, WrapErr(err)
×
263
                        }
×
264
                        if n > 1 || page > 1 {
2✔
265
                                // reload image to restrict frames loaded
1✔
266
                                n, page = recalculateImage(img, n, page)
1✔
267
                                return v.NewThumbnail(ctx, blob, width, height, crop, size, -n, -page, dpi)
1✔
268
                        }
1✔
269
                        if err = v.animatedThumbnailWithCrop(img, width, height, crop, size); err != nil {
1✔
270
                                img.Close()
×
271
                                return nil, WrapErr(err)
×
272
                        }
×
273
                }
274
        } else {
1✔
275
                switch blob.BlobType() {
1✔
276
                case imagor.BlobTypeJPEG, imagor.BlobTypeGIF, imagor.BlobTypeWEBP:
1✔
277
                        // only allow real thumbnail for jpeg gif webp
1✔
278
                        img, err = newThumbnailFromBlob(ctx, blob, width, height, crop, size, options)
1✔
279
                default:
1✔
280
                        img, err = v.newThumbnailFallback(ctx, blob, width, height, crop, size, options)
1✔
281
                }
282
        }
283
        return v.CheckResolution(img, WrapErr(err))
1✔
284
}
285

286
func (v *Processor) newThumbnailFallback(
287
        ctx context.Context, blob *imagor.Blob, width, height int, crop vips.Interesting, size vips.Size, options *vips.LoadOptions,
288
) (img *vips.Image, err error) {
1✔
289
        if img, err = v.CheckResolution(v.newImageFromBlob(ctx, blob, options)); err != nil {
2✔
290
                return
1✔
291
        }
1✔
292
        if err = img.ThumbnailImage(width, &vips.ThumbnailImageOptions{
1✔
293
                Height: height, Size: size, Crop: crop,
1✔
294
        }); err != nil {
1✔
295
                img.Close()
×
296
                return
×
297
        }
×
298
        return img, WrapErr(err)
1✔
299
}
300

301
// NewImage creates new Image from imagor.Blob
302
func (v *Processor) NewImage(ctx context.Context, blob *imagor.Blob, n, page int, dpi int) (*vips.Image, error) {
1✔
303
        var options = &vips.LoadOptions{}
1✔
304
        if dpi > 0 {
1✔
305
                options.Dpi = dpi
×
306
        }
×
307
        options.Unlimited = v.Unlimited
1✔
308
        if isMultiPage(blob, n, page) {
2✔
309
                applyMultiPageOptions(options, n, page)
1✔
310
                img, err := v.CheckResolution(v.newImageFromBlob(ctx, blob, options))
1✔
311
                if err != nil {
1✔
312
                        return nil, WrapErr(err)
×
313
                }
×
314
                // reload image to restrict frames loaded
315
                if n > 1 || page > 1 {
2✔
316
                        n, page = recalculateImage(img, n, page)
1✔
317
                        return v.NewImage(ctx, blob, -n, -page, dpi)
1✔
318
                }
1✔
319
                return img, nil
1✔
320
        }
321
        img, err := v.CheckResolution(v.newImageFromBlob(ctx, blob, options))
1✔
322
        if err != nil {
1✔
323
                return nil, WrapErr(err)
×
324
        }
×
325
        return img, nil
1✔
326
}
327

328
// Thumbnail handles thumbnail operation
329
func (v *Processor) Thumbnail(
330
        img *vips.Image, width, height int, crop vips.Interesting, size vips.Size,
331
) error {
1✔
332
        if crop == vips.InterestingNone || size == vips.SizeForce || img.Height() == img.PageHeight() {
2✔
333
                return img.ThumbnailImage(width, &vips.ThumbnailImageOptions{
1✔
334
                        Height: height, Size: size, Crop: crop,
1✔
335
                })
1✔
336
        }
1✔
337
        return v.animatedThumbnailWithCrop(img, width, height, crop, size)
1✔
338
}
339

340
// FocalThumbnail handles thumbnail with custom focal point
341
func (v *Processor) FocalThumbnail(img *vips.Image, w, h int, fx, fy float64) (err error) {
1✔
342
        var imageWidth, imageHeight float64
1✔
343
        // exif orientation greater 5-8 are 90 or 270 degrees, w and h swapped
1✔
344
        if img.Orientation() > 4 {
2✔
345
                imageWidth = float64(img.PageHeight())
1✔
346
                imageHeight = float64(img.Width())
1✔
347
        } else {
2✔
348
                imageWidth = float64(img.Width())
1✔
349
                imageHeight = float64(img.PageHeight())
1✔
350
        }
1✔
351

352
        if float64(w)/float64(h) > float64(imageWidth)/float64(imageHeight) {
2✔
353
                if err = img.ThumbnailImage(w, &vips.ThumbnailImageOptions{
1✔
354
                        Height: v.MaxHeight, Crop: vips.InterestingNone,
1✔
355
                }); err != nil {
1✔
356
                        return
×
357
                }
×
358
        } else {
1✔
359
                if err = img.ThumbnailImage(v.MaxWidth, &vips.ThumbnailImageOptions{
1✔
360
                        Height: h, Crop: vips.InterestingNone,
1✔
361
                }); err != nil {
1✔
362
                        return
×
363
                }
×
364
        }
365
        var top, left float64
1✔
366
        left = float64(img.Width())*fx - float64(w)/2
1✔
367
        top = float64(img.PageHeight())*fy - float64(h)/2
1✔
368
        left = math.Max(0, math.Min(left, float64(img.Width()-w)))
1✔
369
        top = math.Max(0, math.Min(top, float64(img.PageHeight()-h)))
1✔
370
        return img.ExtractAreaMultiPage(int(left), int(top), w, h)
1✔
371
}
372

373
func (v *Processor) animatedThumbnailWithCrop(
374
        img *vips.Image, w, h int, crop vips.Interesting, size vips.Size,
375
) (err error) {
1✔
376
        if size == vips.SizeDown && img.Width() < w && img.PageHeight() < h {
1✔
377
                return
×
378
        }
×
379
        var top, left int
1✔
380
        if float64(w)/float64(h) > float64(img.Width())/float64(img.PageHeight()) {
2✔
381
                if err = img.ThumbnailImage(w, &vips.ThumbnailImageOptions{
1✔
382
                        Height: v.MaxHeight, Crop: vips.InterestingNone, Size: size,
1✔
383
                }); err != nil {
1✔
384
                        return
×
385
                }
×
386
        } else {
1✔
387
                if err = img.ThumbnailImage(v.MaxWidth, &vips.ThumbnailImageOptions{
1✔
388
                        Height: h, Crop: vips.InterestingNone, Size: size,
1✔
389
                }); err != nil {
1✔
390
                        return
×
391
                }
×
392
        }
393
        if crop == vips.InterestingHigh {
2✔
394
                left = img.Width() - w
1✔
395
                top = img.PageHeight() - h
1✔
396
        } else if crop == vips.InterestingCentre || crop == vips.InterestingAttention {
3✔
397
                left = (img.Width() - w) / 2
1✔
398
                top = (img.PageHeight() - h) / 2
1✔
399
        }
1✔
400
        return img.ExtractAreaMultiPage(left, top, w, h)
1✔
401
}
402

403
// CheckResolution check image resolution for image bomb prevention
404
func (v *Processor) CheckResolution(img *vips.Image, err error) (*vips.Image, error) {
1✔
405
        if err != nil || img == nil {
2✔
406
                return img, err
1✔
407
        }
1✔
408
        if !v.Unlimited && (img.Width() > v.MaxWidth || img.PageHeight() > v.MaxHeight ||
1✔
409
                (img.Width()*img.Height()) > v.MaxResolution) {
2✔
410
                img.Close()
1✔
411
                return nil, imagor.ErrMaxResolutionExceeded
1✔
412
        }
1✔
413
        return img, nil
1✔
414
}
415

416
func isMultiPage(blob *imagor.Blob, n, page int) bool {
1✔
417
        return blob != nil && (blob.SupportsAnimation() || blob.BlobType() == imagor.BlobTypePDF) && ((n != 1 && n != 0) || (page != 1 && page != 0))
1✔
418
}
1✔
419

420
func applyMultiPageOptions(params *vips.LoadOptions, n, page int) {
1✔
421
        if page < -1 {
2✔
422
                params.Page = -page - 1
1✔
423
        } else if n < -1 {
3✔
424
                params.N = -n
1✔
425
        } else {
2✔
426
                params.N = -1
1✔
427
        }
1✔
428
}
429

430
func recalculateImage(img *vips.Image, n, page int) (int, int) {
1✔
431
        // reload image to restrict frames loaded
1✔
432
        numPages := img.Pages()
1✔
433
        img.Close()
1✔
434
        if page > 1 && page > numPages {
2✔
435
                page = numPages
1✔
436
        } else if n > 1 && n > numPages {
3✔
437
                n = numPages
1✔
438
        }
1✔
439
        return n, page
1✔
440
}
441

442
// WrapErr wraps error to become imagor.Error
443
func WrapErr(err error) error {
1✔
444
        if err == nil {
2✔
445
                return nil
1✔
446
        }
1✔
447
        if e, ok := err.(imagor.Error); ok {
2✔
448
                return e
1✔
449
        }
1✔
450
        msg := strings.TrimSpace(err.Error())
1✔
451
        if strings.HasPrefix(msg, "VipsForeignLoad:") &&
1✔
452
                strings.HasSuffix(msg, "is not in a known format") {
1✔
453
                return imagor.ErrUnsupportedFormat
×
454
        }
×
455
        return imagor.NewError(msg, 406)
1✔
456
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc