ThumbnailCache.vala 21.1 KB
Newer Older
Eric Gregory's avatar
Eric Gregory committed
1
/* Copyright 2009-2011 Yorba Foundation
2 3 4 5
 *
 * This software is licensed under the GNU LGPL (version 2.1 or later).
 * See the COPYING file in this distribution. 
 */
6

7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
public class Thumbnails {
    private Gee.HashMap<ThumbnailCache.Size, Gdk.Pixbuf> map = new Gee.HashMap<ThumbnailCache.Size,
        Gdk.Pixbuf>();
    
    public Thumbnails() {
    }
    
    public void set(ThumbnailCache.Size size, Gdk.Pixbuf pixbuf) {
        map.set(size, pixbuf);
    }
    
    public void remove(ThumbnailCache.Size size) {
        map.unset(size);
    }
    
    public Gdk.Pixbuf? get(ThumbnailCache.Size size) {
        return map.get(size);
    }
}

27
public class ThumbnailCache : Object {
28
    public const Gdk.InterpType DEFAULT_INTERP = Gdk.InterpType.HYPER;
29 30
    public const Jpeg.Quality DEFAULT_QUALITY = Jpeg.Quality.HIGH;
    public const int MAX_INMEMORY_DATA_SIZE = 512 * 1024;
31
    
32
    public enum Size {
33
        LARGEST = 360,
34
        BIG = 360,
35 36
        MEDIUM = 128,
        SMALLEST = 128;
37 38 39 40 41
        
        public int get_scale() {
            return (int) this;
        }
        
42 43 44 45
        public Scaling get_scaling() {
            return Scaling.for_best_fit(get_scale(), true);
        }
        
46 47 48 49
        public static Size get_best_size(int scale) {
            return scale <= MEDIUM.get_scale() ? MEDIUM : BIG;
        }
    }
50
    
51
    private static Size[] ALL_SIZES = { Size.BIG, Size.MEDIUM };
52
    
53 54
    public delegate void AsyncFetchCallback(Gdk.Pixbuf? pixbuf, Gdk.Pixbuf? unscaled, Dimensions dim,
        Gdk.InterpType interp, Error? err);
55 56 57 58 59 60 61 62 63 64 65 66
    
    private class ImageData {
        public Gdk.Pixbuf pixbuf;
        public ulong bytes;
        
        public ImageData(Gdk.Pixbuf pixbuf) {
            this.pixbuf = pixbuf;

            // This is not entirely accurate (see Gtk doc note on pixbuf Image Data), but close enough
            // for government work
            bytes = (ulong) pixbuf.get_rowstride() * (ulong) pixbuf.get_height();
        }
67 68 69 70 71
        
        ~ImageData() {
            cycle_dropped_bytes += bytes;
            schedule_debug();
        }
72 73 74 75
    }

    private class AsyncFetchJob : BackgroundJob {
        public ThumbnailCache cache;
76
        public string thumbnail_name;
77
        public PhotoFileFormat source_format;
78
        public Dimensions dim;
79
        public Gdk.InterpType interp;
80
        public unowned AsyncFetchCallback callback;
81
        public Gdk.Pixbuf unscaled;
82 83
        public Gdk.Pixbuf scaled = null;
        public Error err = null;
84
        public bool fetched = false;
85
        
86 87 88
        public AsyncFetchJob(ThumbnailCache cache, string thumbnail_name,
            PhotoFileFormat source_format, Gdk.Pixbuf? prefetched, Dimensions dim,
            Gdk.InterpType interp, AsyncFetchCallback callback, Cancellable? cancellable) {
89
            base(cache, async_fetch_completion_callback, cancellable);
90 91
            
            this.cache = cache;
92
            this.thumbnail_name = thumbnail_name;
93
            this.source_format = source_format;
94
            this.unscaled = prefetched;
95
            this.dim = dim;
96 97 98 99
            this.interp = interp;
            this.callback = callback;
        }
        
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
        public override BackgroundJob.JobPriority get_priority() {
            // lower-quality interps are scheduled first; this is interpreted as a "quick" thumbnail
            // fetch, versus higher-quality, which are to clean up the display
            switch (interp) {
                case Gdk.InterpType.NEAREST:
                case Gdk.InterpType.TILES:
                    return JobPriority.HIGH;
                
                case Gdk.InterpType.BILINEAR:
                case Gdk.InterpType.HYPER:
                default:
                    return JobPriority.NORMAL;
            }
        }
        
115
        public override void execute() {
116 117
            try {
                // load-and-decode if not already prefetched
118
                if (unscaled == null) {
119
                    unscaled = cache.read_pixbuf(thumbnail_name, source_format);
120 121
                    fetched = true;
                }
122
                
123 124 125
                if (is_cancelled())
                    return;
                
126
                // scale if specified
127
                scaled = dim.has_area() ? resize_pixbuf(unscaled, dim, interp) : unscaled;
128 129 130 131 132 133 134 135
            } catch (Error err) {
                this.err = err;
            }
        }
    }
        
    private static Workers fetch_workers = null;
    
136 137
    public const ulong MAX_BIG_CACHED_BYTES = 40 * 1024 * 1024;
    public const ulong MAX_MEDIUM_CACHED_BYTES = 30 * 1024 * 1024;
138

139 140
    private static ThumbnailCache big = null;
    private static ThumbnailCache medium = null;
141
    
142
    private static OneShotScheduler debug_scheduler = null;
143
    private static int cycle_fetched_thumbnails = 0;
144 145
    private static int cycle_async_fetched_thumbnails = 0;
    private static int cycle_async_resized_thumbnails = 0;
146
    private static int cycle_overflow_thumbnails = 0;
147
    private static ulong cycle_dropped_bytes = 0;
148 149
    
    private File cache_dir;
150
    private Size size;
151 152
    private ulong max_cached_bytes;
    private Gdk.InterpType interp;
153
    private Jpeg.Quality quality;
154 155 156
    private Gee.HashMap<string, ImageData> cache_map = new Gee.HashMap<string, ImageData>(
        str_hash, str_equal, direct_equal);
    private Gee.ArrayList<string> cache_lru = new Gee.ArrayList<string>(str_equal);
157 158
    private ulong cached_bytes = 0;
    
159 160
    private ThumbnailCache(Size size, ulong max_cached_bytes, Gdk.InterpType interp = DEFAULT_INTERP,
        Jpeg.Quality quality = DEFAULT_QUALITY) {
161
        cache_dir = AppDirs.get_data_subdir("thumbs", "thumbs%d".printf(size.get_scale()));
162
        this.size = size;
163 164
        this.max_cached_bytes = max_cached_bytes;
        this.interp = interp;
165
        this.quality = quality;
166 167
    }
    
168
    // Doing this because static construct {} not working nor new'ing in the above statement
169
    public static void init() {
170
        debug_scheduler = new OneShotScheduler("ThumbnailCache cycle reporter", report_cycle);
171
        fetch_workers = new Workers(Workers.threads_per_cpu(1), true);
172
        
173 174
        big = new ThumbnailCache(Size.BIG, MAX_BIG_CACHED_BYTES);
        medium = new ThumbnailCache(Size.MEDIUM, MAX_MEDIUM_CACHED_BYTES);
175 176
    }
    
177
    public static void terminate() {
178 179
    }
    
180
    public static void import_from_source(ThumbnailSource source, bool force = false)
181
        throws Error {
Eric Gregory's avatar
Eric Gregory committed
182
        debug("import from source: %s", source.to_string());
183 184
        big._import_from_source(source, force);
        medium._import_from_source(source, force);
185 186
    }
    
187 188
    public static void import_thumbnails(ThumbnailSource source, Thumbnails thumbnails,
        bool force = false) throws Error {
189 190
        big._import_thumbnail(source, thumbnails.get(Size.BIG), force);
        medium._import_thumbnail(source, thumbnails.get(Size.MEDIUM), force);
191
    }
192
    
193
    public static void duplicate(ThumbnailSource src_source, ThumbnailSource dest_source) {
194 195
        big._duplicate(src_source, dest_source);
        medium._duplicate(src_source, dest_source);
196 197
    }
    
198
    public static void remove(ThumbnailSource source) {
199 200
        big._remove(source);
        medium._remove(source);
201
    }
202
    
203
    private static ThumbnailCache get_best_cache(int scale) {
204 205
        Size size = Size.get_best_size(scale);
        if (size == Size.BIG) {
206
            return big;
207
        } else {
208 209
            assert(size == Size.MEDIUM);
            
210
            return medium;
211 212 213
        }
    }
    
214 215 216 217 218 219 220 221 222 223 224 225 226
    private static ThumbnailCache get_cache_for(Size size) {
        switch (size) {
            case Size.BIG:
                return big;
            
            case Size.MEDIUM:
                return medium;
            
            default:
                error("Unknown thumbnail size %d", size.get_scale());
        }
    }
    
227
    public static Gdk.Pixbuf fetch(ThumbnailSource source, int scale) throws Error {
228
        return get_best_cache(scale)._fetch(source);
229
    }
230
    
231
    public static void fetch_async(ThumbnailSource source, int scale, AsyncFetchCallback callback,
232
        Cancellable? cancellable = null) {
233 234
        get_best_cache(scale)._fetch_async(source.get_source_id(), source.get_preferred_thumbnail_format(),
            Dimensions(), DEFAULT_INTERP, callback, cancellable);
235 236
    }
    
237
    public static void fetch_async_scaled(ThumbnailSource source, int scale, Dimensions dim, 
238
        Gdk.InterpType interp, AsyncFetchCallback callback, Cancellable? cancellable = null) {
239
        get_best_cache(scale)._fetch_async(source.get_source_id(),
240
            source.get_preferred_thumbnail_format(), dim, interp, callback, cancellable);
241
    }
242
    
243 244
    public static void replace(ThumbnailSource source, Size size, Gdk.Pixbuf replacement)
        throws Error {
245
        get_cache_for(size)._replace(source, replacement);
246 247
    }
    
248
    public static bool exists(ThumbnailSource source) {
249
        return big._exists(source) && medium._exists(source);
250 251
    }
    
252
    public static void rotate(ThumbnailSource source, Rotation rotation) throws Error {
253
        foreach (Size size in ALL_SIZES) {
254
            Gdk.Pixbuf thumbnail = fetch(source, size);
255
            thumbnail = rotation.perform(thumbnail);
256
            replace(source, size, thumbnail);
257 258 259 260 261
        }
    }
    
    // This does not add the thumbnails to the ThumbnailCache, merely generates them for the
    // supplied image file.
262 263
    public static void generate_for_photo(Thumbnails thumbnails, PhotoFileReader reader,
        Orientation orientation, Dimensions original_dim) throws Error {
264 265 266
        foreach (Size size in ALL_SIZES) {
            Dimensions dim = size.get_scaling().get_scaled_dimensions(original_dim);
            
267
            Gdk.Pixbuf thumbnail = reader.scaled_read(original_dim, dim);
268 269 270 271 272 273
            thumbnail = orientation.rotate_pixbuf(thumbnail);
            
            thumbnails.set(size, thumbnail);
        }
    }
    
274 275 276 277 278 279 280 281 282
    public static void generate_for_video_frame(Thumbnails thumbnails, Gdk.Pixbuf preview_frame) {
        foreach (Size size in ALL_SIZES) {
            Scaling current_scaling = size.get_scaling();
            Gdk.Pixbuf current_thumbnail = current_scaling.perform_on_pixbuf(preview_frame,
                Gdk.InterpType.HYPER, true);
            thumbnails.set(size, current_thumbnail);
        }
    }
    
283 284 285 286
    // Displaying a debug message for each thumbnail loaded and dropped can cause a ton of messages
    // and slow down scrolling operations ... this delays reporting them, and only then reporting
    // them in one aggregate sum
    private static void schedule_debug() {
287
#if MONITOR_THUMBNAIL_CACHE
288
        debug_scheduler.priority_after_timeout(Priority.LOW, 500, true);
289
#endif
290 291
    }

292
    private static void report_cycle() {
293
#if MONITOR_THUMBNAIL_CACHE
294 295 296 297 298
        if (cycle_fetched_thumbnails > 0) {
            debug("%d thumbnails fetched into memory", cycle_fetched_thumbnails);
            cycle_fetched_thumbnails = 0;
        }
        
299 300 301 302 303 304 305 306 307 308
        if (cycle_async_fetched_thumbnails > 0) {
            debug("%d thumbnails fetched async into memory", cycle_async_fetched_thumbnails);
            cycle_async_fetched_thumbnails = 0;
        }
        
        if (cycle_async_resized_thumbnails > 0) {
            debug("%d thumbnails resized async into memory", cycle_async_resized_thumbnails);
            cycle_async_resized_thumbnails = 0;
        }
        
309 310 311 312
        if (cycle_overflow_thumbnails > 0) {
            debug("%d thumbnails overflowed from memory cache", cycle_overflow_thumbnails);
            cycle_overflow_thumbnails = 0;
        }
313 314 315 316 317 318 319 320
        
        if (cycle_dropped_bytes > 0) {
            debug("%lu bytes freed", cycle_dropped_bytes);
            cycle_dropped_bytes = 0;
        }
        
        foreach (Size size in ALL_SIZES) {
            ThumbnailCache cache = get_cache_for(size);
Jim Nelson's avatar
Jim Nelson committed
321
            ulong avg = (cache.cache_lru.size != 0) ? cache.cached_bytes / cache.cache_lru.size : 0;
322
            debug("thumbnail cache %d: %d thumbnails, %lu/%lu bytes, %lu bytes/thumbnail", 
323
                cache.size.get_scale(), cache.cache_lru.size, cache.cached_bytes,
Jim Nelson's avatar
Jim Nelson committed
324
                cache.max_cached_bytes, avg);
325
        }
326
#endif
327 328
    }
    
329
    private Gdk.Pixbuf _fetch(ThumbnailSource source) throws Error {
330
        // use JPEG in memory cache if available
331
        Gdk.Pixbuf pixbuf = fetch_from_memory(source.get_source_id());
332
        if (pixbuf != null)
333
            return pixbuf;
334
        
335
        pixbuf = read_pixbuf(source.get_source_id(), source.get_preferred_thumbnail_format());
336
        
337 338 339
        cycle_fetched_thumbnails++;
        schedule_debug();
        
340
        // stash in memory for next time
341
        store_in_memory(source.get_source_id(), pixbuf);
342
        
343
        return pixbuf;
344 345
    }
    
346
    private void _fetch_async(string thumbnail_name, PhotoFileFormat format, Dimensions dim, 
347
        Gdk.InterpType interp, AsyncFetchCallback callback, Cancellable? cancellable) {
348
        // check if the pixbuf is already in memory
349
        Gdk.Pixbuf pixbuf = fetch_from_memory(thumbnail_name);
350
        if (pixbuf != null && (!dim.has_area() || Dimensions.for_pixbuf(pixbuf).equals(dim))) {
351 352
            // if no scaling operation required, callback in this context and done (otherwise,
            // let the background threads perform the scaling operation, to spread out the work)
353 354
            callback(pixbuf, pixbuf, dim, interp, null);
            
355
            return;
356 357 358 359 360 361 362 363 364
        }
        
        // TODO: Note that there exists a cache condition in this current implementation.  It's
        // possible for two requests for the same thumbnail to come in back-to-back.  Since there's
        // no "reservation" system to indicate that an outstanding job is fetching that thumbnail
        // (and the other should wait until it's done), two (or more) fetches could occur on the
        // same thumbnail file.
        //
        // Due to the design of Shotwell, with one thumbnail per page, this is seen as an unlikely
365 366
        // situation.  This may change in the future, and the caching situation will need to be 
        // handled.
367
        
368
        fetch_workers.enqueue(new AsyncFetchJob(this, thumbnail_name, format, pixbuf, dim, interp, 
369
            callback, cancellable));
370 371 372 373 374 375
    }
    
    // Called within Gtk.main's thread context
    private static void async_fetch_completion_callback(BackgroundJob background_job) {
        AsyncFetchJob job = (AsyncFetchJob) background_job;
        
376 377 378 379 380 381 382 383 384 385 386 387
        if (job.unscaled != null) {
            if (job.fetched) {
                // only store in cache if fetched, not pre-fetched
                job.cache.store_in_memory(job.thumbnail_name, job.unscaled);
                
                cycle_async_fetched_thumbnails++;
                schedule_debug();
            } else {
                cycle_async_resized_thumbnails++;
                schedule_debug();
            }
        }
388
        
389
        job.callback(job.scaled, job.unscaled, job.dim, job.interp, job.err);
390 391
    }
    
392
    private void _import_from_source(ThumbnailSource source, bool force = false)
393
        throws Error {
394
        File file = get_source_cached_file(source);
395
        
396 397
        // if not forcing the cache operation, check if file exists and is represented in the
        // database before continuing
398
        if (!force) {
399
            if (_exists(source))
400
                return;
401
        } else {
402
            // wipe from system and continue
403
            _remove(source);
404
        }
405

406 407
        LibraryPhoto photo = (LibraryPhoto) source;
        save_thumbnail(file, photo.get_pixbuf(Scaling.for_best_fit(size.get_scale(), true)), source);
408
        
409 410 411 412
        // See note in _import_with_pixbuf for reason why this is not maintained in in-memory
        // cache
    }
    
413
    private void _import_thumbnail(ThumbnailSource source, Gdk.Pixbuf? scaled, bool force = false) 
414 415 416 417 418 419 420
        throws Error {
        assert(scaled != null);
        assert(Dimensions.for_pixbuf(scaled).approx_scaled(size.get_scale()));
        
        // if not forcing the cache operation, check if file exists and is represented in the
        // database before continuing
        if (!force) {
421
            if (_exists(source))
422 423 424
                return;
        } else {
            // wipe previous from system and continue
425
            _remove(source);
426
        }
427 428
        
        save_thumbnail(get_source_cached_file(source), scaled, source);
429
        
430 431 432 433 434
        // do NOT store in the in-memory cache ... if a lot of photos are being imported at
        // once, this will blow cache locality, especially when the user is viewing one portion
        // of the collection while new photos are added far off the viewport
    }
    
435
    private void _duplicate(ThumbnailSource src_source, ThumbnailSource dest_source) {
436 437
        File src_file = get_source_cached_file(src_source);
        File dest_file = get_cached_file(dest_source.get_representative_id(),
438
            src_source.get_preferred_thumbnail_format());
439 440 441 442
        
        try {
            src_file.copy(dest_file, FileCopyFlags.ALL_METADATA, null, null);
        } catch (Error err) {
443
            AppWindow.panic("%s".printf(err.message));
444 445 446 447 448
        }
        
        // Do NOT store in memory cache, for similar reasons as stated in _import().
    }
    
449 450
    private void _replace(ThumbnailSource source, Gdk.Pixbuf original) throws Error {
        File file = get_source_cached_file(source);
451 452
        
        // Remove from in-memory cache, if present
453
        remove_from_memory(source.get_source_id());
454 455
        
        // scale to cache's parameters
456
        Gdk.Pixbuf scaled = scale_pixbuf(original, size.get_scale(), interp, true);
457
        
458
        // save scaled image to disk
459
        save_thumbnail(file, scaled, source);
460 461 462
        
        // Store in in-memory cache; a _replace() probably represents a user-initiated
        // action (<cough>rotate</cough>) and the thumbnail will probably be fetched immediately.
463 464
        // This means the thumbnail will be cached in scales that aren't immediately needed, but
        // the benefit seems to outweigh the side-effects
465
        store_in_memory(source.get_source_id(), scaled);
466 467
    }
    
468
    private void _remove(ThumbnailSource source) {
469
        File file = get_source_cached_file(source);
470
        
471
        // remove from in-memory cache
472
        remove_from_memory(source.get_source_id());
473
        
474
        // remove from disk
475 476 477
        try {
            file.delete(null);
        } catch (Error err) {
478
            // ignored
479 480 481
        }
    }
    
482
    private bool _exists(ThumbnailSource source) {
483
        return get_source_cached_file(source).query_exists(null);
484
    }
485 486
    
    // This method is thread-safe.
487 488
    private Gdk.Pixbuf read_pixbuf(string thumbnail_name, PhotoFileFormat format) throws Error {
        return format.create_reader(get_cached_file(thumbnail_name,
489 490
            format).get_path()).unscaled_read();
    }
491
    
492 493 494 495 496
    private File get_source_cached_file(ThumbnailSource source) {
        return get_cached_file(source.get_representative_id(),
            source.get_preferred_thumbnail_format());
    }
    
497 498
    private File get_cached_file(string thumbnail_name, PhotoFileFormat thumbnail_format) {
        return cache_dir.get_child(thumbnail_format.get_default_basename(thumbnail_name));
499
    }
500
    
501 502
    private Gdk.Pixbuf? fetch_from_memory(string thumbnail_name) {
        ImageData data = cache_map.get(thumbnail_name);
503 504 505 506
        
        return (data != null) ? data.pixbuf : null;
    }
    
507
    private void store_in_memory(string thumbnail_name, Gdk.Pixbuf thumbnail) {
508 509 510
        if (max_cached_bytes <= 0)
            return;
        
511
        remove_from_memory(thumbnail_name);
512 513
        
        ImageData data = new ImageData(thumbnail);
514 515 516

        // see if this is too large to keep in memory
        if(data.bytes > MAX_INMEMORY_DATA_SIZE) {
517
            debug("Persistant thumbnail [%s] too large to cache in memory", thumbnail_name);
518 519 520 521

            return;
        }
        
522 523
        cache_map.set(thumbnail_name, data);
        cache_lru.insert(0, thumbnail_name);
524 525 526 527 528 529 530 531
        
        cached_bytes += data.bytes;
        
        // trim cache
        while (cached_bytes > max_cached_bytes) {
            assert(cache_lru.size > 0);
            int index = cache_lru.size - 1;
            
532
            string victim_name = cache_lru.get(index);
533 534
            cache_lru.remove_at(index);
            
535
            data = cache_map.get(victim_name);
536
            
537 538
            cycle_overflow_thumbnails++;
            schedule_debug();
539
            
540
            bool removed = cache_map.unset(victim_name);
541
            assert(removed);
542 543 544

            assert(data.bytes <= cached_bytes);
            cached_bytes -= data.bytes;
545 546 547
        }
    }
    
548 549
    private bool remove_from_memory(string thumbnail_name) {
        ImageData data = cache_map.get(thumbnail_name);
550 551 552 553 554 555 556
        if (data == null)
            return false;
        
        assert(cached_bytes >= data.bytes);
        cached_bytes -= data.bytes;

        // remove data from in-memory cache
557
        bool removed = cache_map.unset(thumbnail_name);
558 559 560
        assert(removed);
        
        // remove from LRU
561
        removed = cache_lru.remove(thumbnail_name);
562 563 564 565 566
        assert(removed);
        
        return true;
    }
    
567 568 569
    private void save_thumbnail(File file, Gdk.Pixbuf pixbuf, ThumbnailSource source) throws Error {
        source.get_preferred_thumbnail_format().create_writer(file.get_path()).write(pixbuf,
            DEFAULT_QUALITY);
570
    }
571
}
572