/* Copyright 2016 Software Freedom Conservancy Inc. * * This software is licensed under the GNU LGPL (version 2.1 or later). * See the COPYING file in this distribution. */ public class Thumbnails { private Gee.HashMap map = new Gee.HashMap(); 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); } } public class ThumbnailCache : Object { public const Gdk.InterpType DEFAULT_INTERP = Gdk.InterpType.HYPER; public const Jpeg.Quality DEFAULT_QUALITY = Jpeg.Quality.HIGH; public const int MAX_INMEMORY_DATA_SIZE = 512 * 1024; // Some code relies on Size's pixel values being manipulated and then using Size's methods, // so be careful before changing any of these values (and especially careful before arbitrarily // manipulating a Size enum) public enum Size { LARGEST = 512, LARGE = 512, BIG = 360, MEDIUM = 128, SMALLEST = 128; public int get_scale() { return (int) this; } public Scaling get_scaling() { return Scaling.for_best_fit(get_scale(), true); } public static Size get_best_size(int scale) { var real_scale = Application.get_scale() * scale; if (real_scale <= MEDIUM.get_scale()) return MEDIUM; if (real_scale <= BIG.get_scale()) return BIG; return LARGE; } } private static Size[] ALL_SIZES = { Size.LARGE, Size.BIG, Size.MEDIUM }; public delegate void AsyncFetchCallback(Gdk.Pixbuf? pixbuf, Gdk.Pixbuf? unscaled, Dimensions dim, Gdk.InterpType interp, Error? err); 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(); } ~ImageData() { cycle_dropped_bytes += bytes; schedule_debug(); } } private class AsyncFetchJob : BackgroundJob { public ThumbnailCache cache; public string thumbnail_name; public ThumbnailSource source; public PhotoFileFormat source_format; public Dimensions dim; public Gdk.InterpType interp; public unowned AsyncFetchCallback callback; public Gdk.Pixbuf unscaled; public Gdk.Pixbuf scaled = null; public Error err = null; public bool fetched = false; public bool replace = false; public AsyncFetchJob(ThumbnailCache cache, string thumbnail_name, ThumbnailSource source, Gdk.Pixbuf? prefetched, Dimensions dim, Gdk.InterpType interp, AsyncFetchCallback callback, Cancellable? cancellable) { base(cache, async_fetch_completion_callback, cancellable); this.cache = cache; this.thumbnail_name = thumbnail_name; this.source = source; this.source_format = source.get_preferred_thumbnail_format(); this.unscaled = prefetched; this.dim = dim; this.interp = interp; this.callback = callback; } 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; } } public override void execute() { try { // load-and-decode if not already prefetched if (unscaled == null) { unscaled = cache.read_pixbuf(thumbnail_name, source_format); fetched = true; } if (is_cancelled()) return; // scale if specified scaled = dim.has_area() ? resize_pixbuf(unscaled, dim, interp) : unscaled; } catch (Error err) { if (err is FileError) { try { generate_thumbnail(); } catch (Error generr) { // save thumbnail generation error, not original, for processing in callback err = generr; } } else { // save error for processing in callback this.err = err; } } } private void generate_thumbnail() throws Error { Photo? photo = source as Photo; if (photo != null) { unscaled = photo.get_pixbuf(Scaling.for_best_fit(dim.major_axis(), true)); } else { Video? video = source as Video; if (video != null) unscaled = video.create_thumbnail(dim.width); } if (unscaled != null) { scaled = resize_pixbuf(unscaled, dim, interp); replace = true; } } } private static Workers fetch_workers = null; public const ulong MAX_BIG_CACHED_BYTES = 40 * 1024 * 1024; public const ulong MAX_MEDIUM_CACHED_BYTES = 30 * 1024 * 1024; public const ulong MAX_LARGE_CACHED_BYTES = 15 * 1024 * 1024; private static ThumbnailCache big = null; private static ThumbnailCache medium = null; private static ThumbnailCache large = null; private static OneShotScheduler debug_scheduler = null; private static int cycle_fetched_thumbnails = 0; private static int cycle_async_fetched_thumbnails = 0; private static int cycle_async_resized_thumbnails = 0; private static int cycle_overflow_thumbnails = 0; private static ulong cycle_dropped_bytes = 0; private File cache_dir; private Size size; private ulong max_cached_bytes; private Gdk.InterpType interp; private Jpeg.Quality quality; private Gee.HashMap cache_map = new Gee.HashMap(); private Gee.ArrayList cache_lru = new Gee.ArrayList(); private ulong cached_bytes = 0; private ThumbnailCache(Size size, ulong max_cached_bytes, Gdk.InterpType interp = DEFAULT_INTERP, Jpeg.Quality quality = DEFAULT_QUALITY) { cache_dir = AppDirs.get_cache_subdir("thumbs", "thumbs%d".printf(size.get_scale())); this.size = size; this.max_cached_bytes = max_cached_bytes; this.interp = interp; this.quality = quality; } // Doing this because static construct {} not working nor new'ing in the above statement public static void init() { debug_scheduler = new OneShotScheduler("ThumbnailCache cycle reporter", report_cycle); fetch_workers = new Workers(Workers.threads_per_cpu(1), true); big = new ThumbnailCache(Size.BIG, MAX_BIG_CACHED_BYTES); medium = new ThumbnailCache(Size.MEDIUM, MAX_MEDIUM_CACHED_BYTES); large = new ThumbnailCache(Size.LARGE, MAX_LARGE_CACHED_BYTES); } public static void terminate() { } public static void import_from_source(ThumbnailSource source, bool force = false) throws Error { debug("import from source: %s", source.to_string()); big._import_from_source(source, force); medium._import_from_source(source, force); large._import_from_source(source, force); } public static void import_thumbnails(ThumbnailSource source, Thumbnails thumbnails, bool force = false) throws Error { big._import_thumbnail(source, thumbnails.get(Size.BIG), force); medium._import_thumbnail(source, thumbnails.get(Size.MEDIUM), force); large._import_thumbnail(source, thumbnails.get(Size.LARGE), force); } public static void duplicate(ThumbnailSource src_source, ThumbnailSource dest_source) { big._duplicate(src_source, dest_source); medium._duplicate(src_source, dest_source); large._duplicate(src_source, dest_source); } public static void remove(ThumbnailSource source) { big._remove(source); medium._remove(source); large._remove(source); } private static ThumbnailCache get_best_cache(int scale) { Size size = Size.get_best_size(scale); if (size == Size.LARGE) { return large; } else if (size == Size.BIG) { return big; } else { assert(size == Size.MEDIUM); return medium; } } private static ThumbnailCache get_cache_for(Size size) { switch (size) { case Size.LARGE: return large; case Size.BIG: return big; case Size.MEDIUM: return medium; default: error("Unknown thumbnail size %d", size.get_scale()); } } public static Gdk.Pixbuf fetch(ThumbnailSource source, int scale) throws Error { return get_best_cache(scale)._fetch(source); } public static void fetch_async(ThumbnailSource source, int scale, AsyncFetchCallback callback, Cancellable? cancellable = null) { get_best_cache(scale)._fetch_async(source, source.get_preferred_thumbnail_format(), Dimensions(), DEFAULT_INTERP, callback, cancellable); } public static void fetch_async_scaled(ThumbnailSource source, int scale, Dimensions dim, Gdk.InterpType interp, AsyncFetchCallback callback, Cancellable? cancellable = null) { get_best_cache(scale)._fetch_async(source, source.get_preferred_thumbnail_format(), dim, interp, callback, cancellable); } public static void replace(ThumbnailSource source, Size size, Gdk.Pixbuf replacement) throws Error { get_cache_for(size)._replace(source, replacement); } public static bool exists(ThumbnailSource source) { return big._exists(source) && medium._exists(source); } public static void rotate(ThumbnailSource source, Rotation rotation) throws Error { foreach (Size size in ALL_SIZES) { Gdk.Pixbuf thumbnail = fetch(source, size); thumbnail = rotation.perform(thumbnail); replace(source, size, thumbnail); } } // This does not add the thumbnails to the ThumbnailCache, merely generates them for the // supplied image file. public static void generate_for_photo(Thumbnails thumbnails, PhotoFileReader reader, Orientation orientation, Dimensions original_dim) throws Error { // Taking advantage of Size's values matching their pixel size Size max_size = Size.BIG * 2; Dimensions dim = max_size.get_scaling().get_scaled_dimensions(original_dim); Gdk.Pixbuf? largest_thumbnail = null; try { largest_thumbnail = reader.scaled_read(original_dim, dim); } catch (Error err) { // if the scaled read generated an error, catch it and try to do an unscaled read // followed by a downsample. If the call to unscaled_read() below throws an error, // just propagate it up to the caller largest_thumbnail = reader.unscaled_read(); } largest_thumbnail = orientation.rotate_pixbuf(largest_thumbnail); Dimensions largest_thumb_dimensions = Dimensions.for_pixbuf(largest_thumbnail); foreach (Size size in ALL_SIZES) { dim = size.get_scaling().get_scaled_dimensions(largest_thumb_dimensions); thumbnails.set(size, largest_thumbnail.scale_simple(dim.width, dim.height, Gdk.InterpType.HYPER)); } } 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); } } // 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() { #if MONITOR_THUMBNAIL_CACHE debug_scheduler.priority_after_timeout(Priority.LOW, 500, true); #endif } private static void report_cycle() { #if MONITOR_THUMBNAIL_CACHE if (cycle_fetched_thumbnails > 0) { debug("%d thumbnails fetched into memory", cycle_fetched_thumbnails); cycle_fetched_thumbnails = 0; } 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; } if (cycle_overflow_thumbnails > 0) { debug("%d thumbnails overflowed from memory cache", cycle_overflow_thumbnails); cycle_overflow_thumbnails = 0; } 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); ulong avg = (cache.cache_lru.size != 0) ? cache.cached_bytes / cache.cache_lru.size : 0; debug("thumbnail cache %d: %d thumbnails, %lu/%lu bytes, %lu bytes/thumbnail", cache.size.get_scale(), cache.cache_lru.size, cache.cached_bytes, cache.max_cached_bytes, avg); } #endif } private Gdk.Pixbuf _fetch(ThumbnailSource source) throws Error { // use JPEG in memory cache if available Gdk.Pixbuf pixbuf = fetch_from_memory(source.get_source_id()); if (pixbuf != null) return pixbuf; pixbuf = read_pixbuf(source.get_source_id(), source.get_preferred_thumbnail_format()); cycle_fetched_thumbnails++; schedule_debug(); // stash in memory for next time store_in_memory(source.get_source_id(), pixbuf); return pixbuf; } private void _fetch_async(ThumbnailSource source, PhotoFileFormat format, Dimensions dim, Gdk.InterpType interp, AsyncFetchCallback callback, Cancellable? cancellable) { // check if the pixbuf is already in memory string thumbnail_name = source.get_source_id(); Gdk.Pixbuf pixbuf = fetch_from_memory(thumbnail_name); if (pixbuf != null && (!dim.has_area() || Dimensions.for_pixbuf(pixbuf).equals(dim))) { // 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) callback(pixbuf, pixbuf, dim, interp, null); return; } // 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 // situation. This may change in the future, and the caching situation will need to be // handled. fetch_workers.enqueue(new AsyncFetchJob(this, thumbnail_name, source, pixbuf, dim, interp, callback, cancellable)); } // Called within Gtk.main's thread context private static void async_fetch_completion_callback(BackgroundJob background_job) { AsyncFetchJob job = (AsyncFetchJob) background_job; // Is the problem that the thumbnail couldn't be read? If so, it's recoverable; // we'll just create it and leave this.err as null if creation works. if (job.replace && job.unscaled != null) { try { replace(job.source, job.cache.size, job.unscaled); } catch (Error err) { job.err = err; } } 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(); } } job.callback(job.scaled, job.unscaled, job.dim, job.interp, job.err); } private void _import_from_source(ThumbnailSource source, bool force = false) throws Error { File file = get_source_cached_file(source); // if not forcing the cache operation, check if file exists and is represented in the // database before continuing if (!force) { if (_exists(source)) return; } else { // wipe from system and continue _remove(source); } LibraryPhoto photo = (LibraryPhoto) source; save_thumbnail(file, photo.get_pixbuf(Scaling.for_best_fit(size.get_scale(), true)), source); // See note in _import_with_pixbuf for reason why this is not maintained in in-memory // cache } private void _import_thumbnail(ThumbnailSource source, Gdk.Pixbuf? scaled, bool force = false) 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) { if (_exists(source)) return; } else { // wipe previous from system and continue _remove(source); } save_thumbnail(get_source_cached_file(source), scaled, source); // 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 } private void _duplicate(ThumbnailSource src_source, ThumbnailSource dest_source) { File src_file = get_source_cached_file(src_source); File dest_file = get_cached_file(dest_source.get_representative_id(), src_source.get_preferred_thumbnail_format()); bool success = false; try { src_file.copy(dest_file, FileCopyFlags.ALL_METADATA | FileCopyFlags.OVERWRITE, null, null); success = true; } catch (Error err) { debug("Failed to duplicate thumbnail: %s", err.message); } if (success) { return; } try { _import_from_source(dest_source, true); _import_from_source(src_source, false); } catch (Error err) { debug("Failed to duplicate thumbnail: %s", err.message); } // Do NOT store in memory cache, for similar reasons as stated in _import(). } private void _replace(ThumbnailSource source, Gdk.Pixbuf original) throws Error { File file = get_source_cached_file(source); // Remove from in-memory cache, if present remove_from_memory(source.get_source_id()); // scale to cache's parameters Gdk.Pixbuf scaled = scale_pixbuf(original, size.get_scale(), interp, true); // save scaled image to disk save_thumbnail(file, scaled, source); // Store in in-memory cache; a _replace() probably represents a user-initiated // action (rotate) and the thumbnail will probably be fetched immediately. // This means the thumbnail will be cached in scales that aren't immediately needed, but // the benefit seems to outweigh the side-effects store_in_memory(source.get_source_id(), scaled); } private void _remove(ThumbnailSource source) { File file = get_source_cached_file(source); // remove from in-memory cache remove_from_memory(source.get_source_id()); // remove from disk try { file.delete(null); } catch (Error err) { // ignored } } private bool _exists(ThumbnailSource source) { return get_source_cached_file(source).query_exists(null); } // This method is thread-safe. private Gdk.Pixbuf read_pixbuf(string thumbnail_name, PhotoFileFormat format) throws Error { return format.create_reader(get_cached_file(thumbnail_name, format).get_path()).unscaled_read(); } private File get_source_cached_file(ThumbnailSource source) { return get_cached_file(source.get_representative_id(), source.get_preferred_thumbnail_format()); } private File get_cached_file(string thumbnail_name, PhotoFileFormat thumbnail_format) { return cache_dir.get_child(thumbnail_format.get_default_basename(thumbnail_name)); } private Gdk.Pixbuf? fetch_from_memory(string thumbnail_name) { ImageData data = cache_map.get(thumbnail_name); return (data != null) ? data.pixbuf : null; } private void store_in_memory(string thumbnail_name, Gdk.Pixbuf thumbnail) { if (max_cached_bytes <= 0) return; remove_from_memory(thumbnail_name); ImageData data = new ImageData(thumbnail); // see if this is too large to keep in memory if(data.bytes > MAX_INMEMORY_DATA_SIZE) { debug("Persistent thumbnail [%s] too large to cache in memory", thumbnail_name); return; } cache_map.set(thumbnail_name, data); cache_lru.insert(0, thumbnail_name); cached_bytes += data.bytes; // trim cache while (cached_bytes > max_cached_bytes) { assert(cache_lru.size > 0); int index = cache_lru.size - 1; string victim_name = cache_lru.get(index); cache_lru.remove_at(index); data = cache_map.get(victim_name); cycle_overflow_thumbnails++; schedule_debug(); bool removed = cache_map.unset(victim_name); assert(removed); assert(data.bytes <= cached_bytes); cached_bytes -= data.bytes; } } private bool remove_from_memory(string thumbnail_name) { ImageData data = cache_map.get(thumbnail_name); if (data == null) return false; assert(cached_bytes >= data.bytes); cached_bytes -= data.bytes; // remove data from in-memory cache bool removed = cache_map.unset(thumbnail_name); assert(removed); // remove from LRU removed = cache_lru.remove(thumbnail_name); assert(removed); return true; } 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); } }