diff options
Diffstat (limited to 'fiv-thumbnail.c')
| -rw-r--r-- | fiv-thumbnail.c | 494 | 
1 files changed, 494 insertions, 0 deletions
diff --git a/fiv-thumbnail.c b/fiv-thumbnail.c new file mode 100644 index 0000000..1384213 --- /dev/null +++ b/fiv-thumbnail.c @@ -0,0 +1,494 @@ +// +// fiv-thumbnail.c: thumbnail management +// +// Copyright (c) 2021, Přemysl Eric Janouch <p@janouch.name> +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +#include <spng.h> +#include <webp/encode.h> +#include <webp/mux.h> +#include <glib/gstdio.h> + +#include <math.h> +#include <stdbool.h> + +#include "fiv-io.h" +#include "fiv-thumbnail.h" +#include "xdg.h" + +#ifndef __linux__ +#define st_mtim st_mtimespec +#endif  // ! __linux__ + +// TODO(p): Consider merging back with fiv-io. +#define FIV_THUMBNAIL_ERROR fiv_thumbnail_error_quark() + +G_DEFINE_QUARK(fiv-thumbnail-error-quark, fiv_thumbnail_error) + +enum FivThumbnailError { +	FIV_THUMBNAIL_ERROR_IO +}; + +static void +set_error(GError **error, const char *message) +{ +	g_set_error_literal( +		error, FIV_THUMBNAIL_ERROR, FIV_THUMBNAIL_ERROR_IO, message); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +GType +fiv_thumbnail_size_get_type(void) +{ +	static gsize guard; +	if (g_once_init_enter(&guard)) { +#define XX(name, value, dir) {FIV_THUMBNAIL_SIZE_ ## name, \ +	"FIV_THUMBNAIL_SIZE_" #name, #name}, +		static const GEnumValue values[] = {FIV_THUMBNAIL_SIZES(XX) {}}; +#undef XX +		GType type = g_enum_register_static( +			g_intern_static_string("FivThumbnailSize"), values); +		g_once_init_leave(&guard, type); +	} +	return guard; +} + +#define XX(name, value, dir) {value, dir}, +FivThumbnailSizeInfo +	fiv_thumbnail_sizes[FIV_THUMBNAIL_SIZE_COUNT] = { +		FIV_THUMBNAIL_SIZES(XX)}; +#undef XX + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +cairo_user_data_key_t fiv_thumbnail_key_lq; + +static void +mark_thumbnail_lq(cairo_surface_t *surface) +{ +	cairo_surface_set_user_data( +		surface, &fiv_thumbnail_key_lq, (void *) (intptr_t) 1, NULL); +} + +gchar * +fiv_thumbnail_get_root(void) +{ +	gchar *cache_dir = get_xdg_home_dir("XDG_CACHE_HOME", ".cache"); +	gchar *thumbnails_dir = g_build_filename(cache_dir, "thumbnails", NULL); +	g_free(cache_dir); +	return thumbnails_dir; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// In principle similar to rescale_thumbnail() from fiv-browser.c. +static cairo_surface_t * +rescale_thumbnail(cairo_surface_t *thumbnail, double row_height) +{ +	cairo_format_t format = cairo_image_surface_get_format(thumbnail); +	int width = cairo_image_surface_get_width(thumbnail); +	int height = cairo_image_surface_get_height(thumbnail); + +	double scale_x = 1; +	double scale_y = 1; +	if (width > FIV_THUMBNAIL_WIDE_COEFFICIENT * height) { +		scale_x = FIV_THUMBNAIL_WIDE_COEFFICIENT * row_height / width; +		scale_y = round(scale_x * height) / height; +	} else { +		scale_y = row_height / height; +		scale_x = round(scale_y * width) / width; +	} +	if (scale_x == 1 && scale_y == 1) +		return cairo_surface_reference(thumbnail); + +	int projected_width = round(scale_x * width); +	int projected_height = round(scale_y * height); +	cairo_surface_t *scaled = cairo_image_surface_create( +		(format == CAIRO_FORMAT_RGB24 || format == CAIRO_FORMAT_RGB30) +			? CAIRO_FORMAT_RGB24 +			: CAIRO_FORMAT_ARGB32, +		projected_width, projected_height); + +	cairo_t *cr = cairo_create(scaled); +	cairo_scale(cr, scale_x, scale_y); + +	cairo_set_source_surface(cr, thumbnail, 0, 0); +	cairo_pattern_t *pattern = cairo_get_source(cr); +	cairo_pattern_set_filter(pattern, CAIRO_FILTER_BEST); +	cairo_pattern_set_extend(pattern, CAIRO_EXTEND_PAD); + +	cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE); +	cairo_paint(cr); +	cairo_destroy(cr); +	mark_thumbnail_lq(scaled); +	return scaled; +} + +static WebPData +encode_thumbnail(cairo_surface_t *surface) +{ +	WebPData bitstream = {}; +	WebPConfig config = {}; +	if (!WebPConfigInit(&config) || !WebPConfigLosslessPreset(&config, 6)) +		return bitstream; + +	config.near_lossless = 95; +	config.thread_level = true; +	if (!WebPValidateConfig(&config)) +		return bitstream; + +	bitstream.bytes = fiv_io_encode_webp(surface, &config, &bitstream.size); +	return bitstream; +} + +static void +save_thumbnail(cairo_surface_t *thumbnail, const char *path, GString *thum) +{ +	WebPMux *mux = WebPMuxNew(); +	WebPData bitstream = encode_thumbnail(thumbnail); +	gboolean ok = WebPMuxSetImage(mux, &bitstream, true) == WEBP_MUX_OK; +	WebPDataClear(&bitstream); + +	WebPData data = {.bytes = (const uint8_t *) thum->str, .size = thum->len}; +	ok = ok && WebPMuxSetChunk(mux, "THUM", &data, false) == WEBP_MUX_OK; + +	WebPData assembled = {}; +	WebPDataInit(&assembled); +	ok = ok && WebPMuxAssemble(mux, &assembled) == WEBP_MUX_OK; +	WebPMuxDelete(mux); +	if (!ok) { +		g_warning("thumbnail encoding failed"); +		return; +	} + +	GError *e = NULL; +	while (!g_file_set_contents( +		path, (const gchar *) assembled.bytes, assembled.size, &e)) { +		bool missing_parents = +			e->domain == G_FILE_ERROR && e->code == G_FILE_ERROR_NOENT; +		g_debug("%s: %s", path, e->message); +		g_clear_error(&e); +		if (!missing_parents) +			break; + +		gchar *dirname = g_path_get_dirname(path); +		int err = g_mkdir_with_parents(dirname, 0755); +		if (err) +			g_debug("%s: %s", dirname, g_strerror(errno)); + +		g_free(dirname); +		if (err) +			break; +	} + +	// It would be possible to create square thumbnails as well, +	// but it seems like wasted effort. +	WebPDataClear(&assembled); +} + +gboolean +fiv_thumbnail_produce(GFile *target, FivThumbnailSize max_size, GError **error) +{ +	g_return_val_if_fail(max_size >= FIV_THUMBNAIL_SIZE_MIN && +		max_size <= FIV_THUMBNAIL_SIZE_MAX, FALSE); + +	// Local files only, at least for now. +	gchar *path = g_file_get_path(target); +	if (!path) +		return FALSE; + +	GMappedFile *mf = g_mapped_file_new(path, FALSE, error); +	if (!mf) { +		g_free(path); +		return FALSE; +	} + +	GStatBuf st = {}; +	if (g_stat(path, &st)) { +		set_error(error, g_strerror(errno)); +		g_free(path); +		return FALSE; +	} + +	// TODO(p): Add a flag to avoid loading all pages and frames. +	FivIoProfile sRGB = fiv_io_profile_new_sRGB(); +	gsize filesize = g_mapped_file_get_length(mf); +	cairo_surface_t *surface = fiv_io_open_from_data( +		g_mapped_file_get_contents(mf), filesize, path, sRGB, FALSE, error); + +	g_free(path); +	g_mapped_file_unref(mf); +	if (sRGB) +		fiv_io_profile_free(sRGB); +	if (!surface) +		return FALSE; + +	// Boilerplate copied from fiv_thumbnail_lookup(). +	gchar *uri = g_file_get_uri(target); +	gchar *sum = g_compute_checksum_for_string(G_CHECKSUM_MD5, uri, -1); +	gchar *thumbnails_dir = fiv_thumbnail_get_root(); + +	GString *thum = g_string_new(""); +	g_string_append_printf( +		thum, "%s%c%s%c", "Thumb::URI", 0, uri, 0); +	g_string_append_printf( +		thum, "%s%c%ld%c", "Thumb::Mtime", 0, (long) st.st_mtim.tv_sec, 0); +	g_string_append_printf( +		thum, "%s%c%ld%c", "Thumb::Size", 0, (long) filesize, 0); +	g_string_append_printf(thum, "%s%c%d%c", "Thumb::Image::Width", 0, +		cairo_image_surface_get_width(surface), 0); +	g_string_append_printf(thum, "%s%c%d%c", "Thumb::Image::Height", 0, +		cairo_image_surface_get_height(surface), 0); + +	// Without a CMM, no conversion is attempted. +	if (sRGB) { +		g_string_append_printf( +			thum, "%s%c%s%c", "Thumb::ColorSpace", 0, "sRGB", 0); +	} + +	for (int use = max_size; use >= FIV_THUMBNAIL_SIZE_MIN; use--) { +		cairo_surface_t *scaled = +			rescale_thumbnail(surface, fiv_thumbnail_sizes[use].size); +		gchar *path = g_strdup_printf("%s/wide-%s/%s.webp", thumbnails_dir, +			fiv_thumbnail_sizes[use].thumbnail_spec_name, sum); +		save_thumbnail(scaled, path, thum); +		cairo_surface_destroy(scaled); +		g_free(path); +	} + +	g_string_free(thum, TRUE); + +	g_free(thumbnails_dir); +	g_free(sum); +	g_free(uri); +	cairo_surface_destroy(surface); +	return TRUE; +} + +static cairo_surface_t * +read_wide_thumbnail( +	const gchar *path, const gchar *uri, time_t mtime, GError **error) +{ +	// TODO(p): Validate fiv_io_key_thum. +	(void) uri; +	(void) mtime; +	return fiv_io_open(path, NULL, FALSE, error); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int  // tri-state +check_spng_thumbnail_texts(struct spng_text *texts, uint32_t texts_len, +	const gchar *target, time_t mtime) +{ +	// May contain Thumb::Image::Width Thumb::Image::Height, +	// but those aren't interesting currently (would be for fast previews). +	bool need_uri = true, need_mtime = true; +	for (uint32_t i = 0; i < texts_len; i++) { +		struct spng_text *text = texts + i; +		if (!strcmp(text->keyword, "Thumb::URI")) { +			need_uri = false; +			if (strcmp(target, text->text)) +				return false; +		} +		if (!strcmp(text->keyword, "Thumb::MTime")) { +			need_mtime = false; +			if (atol(text->text) != mtime) +				return false; +		} +	} +	return need_uri || need_mtime ? -1 : true; +} + +static int  // tri-state +check_spng_thumbnail(spng_ctx *ctx, const gchar *target, time_t mtime, int *err) +{ +	uint32_t texts_len = 0; +	if ((*err = spng_get_text(ctx, NULL, &texts_len))) +		return false; + +	int result = false; +	struct spng_text *texts = g_malloc0_n(texts_len, sizeof *texts); +	if (!(*err = spng_get_text(ctx, texts, &texts_len))) +		result = check_spng_thumbnail_texts(texts, texts_len, target, mtime); +	g_free(texts); +	return result; +} + +static cairo_surface_t * +read_spng_thumbnail( +	const gchar *path, const gchar *uri, time_t mtime, GError **error) +{ +	FILE *fp; +	cairo_surface_t *result = NULL; +	if (!(fp = fopen(path, "rb"))) { +		set_error(error, g_strerror(errno)); +		return NULL; +	} + +	errno = 0; +	spng_ctx *ctx = spng_ctx_new(0); +	if (!ctx) { +		set_error(error, g_strerror(errno)); +		goto fail_init; +	} + +	int err; +	size_t size = 0; +	if ((err = spng_set_png_file(ctx, fp)) || +		(err = spng_set_image_limits(ctx, INT16_MAX, INT16_MAX)) || +		(err = spng_decoded_image_size(ctx, SPNG_FMT_RGBA8, &size))) { +		set_error(error, spng_strerror(err)); +		goto fail; +	} +	if (check_spng_thumbnail(ctx, uri, mtime, &err) == false) { +		set_error(error, err ? spng_strerror(err) : "mismatch"); +		goto fail; +	} + +	struct spng_ihdr ihdr = {}; +	struct spng_trns trns = {}; +	spng_get_ihdr(ctx, &ihdr); +	bool may_be_translucent = !spng_get_trns(ctx, &trns) || +		ihdr.color_type == SPNG_COLOR_TYPE_GRAYSCALE_ALPHA || +		ihdr.color_type == SPNG_COLOR_TYPE_TRUECOLOR_ALPHA; + +	cairo_surface_t *surface = cairo_image_surface_create( +		may_be_translucent ? CAIRO_FORMAT_ARGB32 : CAIRO_FORMAT_RGB24, +		ihdr.width, ihdr.height); + +	cairo_status_t surface_status = cairo_surface_status(surface); +	if (surface_status != CAIRO_STATUS_SUCCESS) { +		set_error(error, cairo_status_to_string(surface_status)); +		goto fail_data; +	} + +	uint32_t *data = (uint32_t *) cairo_image_surface_get_data(surface); +	g_assert((size_t) cairo_image_surface_get_stride(surface) * +		cairo_image_surface_get_height(surface) == size); + +	cairo_surface_flush(surface); +	if ((err = spng_decode_image(ctx, data, size, SPNG_FMT_RGBA8, +		SPNG_DECODE_TRNS | SPNG_DECODE_GAMMA))) { +		set_error(error, spng_strerror(err)); +		goto fail_data; +	} + +	// The specification does not say where the required metadata should be, +	// it could very well be broken up into two parts. +	if (check_spng_thumbnail(ctx, uri, mtime, &err) != true) { +		set_error( +			error, err ? spng_strerror(err) : "mismatch or not a thumbnail"); +		goto fail_data; +	} + +	// pixman can be mildly abused to do this operation, but it won't be faster. +	if (may_be_translucent) { +		for (size_t i = size / sizeof *data; i--; ) { +			const uint8_t *unit = (const uint8_t *) &data[i]; +			uint32_t a = unit[3], +				b = PREMULTIPLY8(a, unit[2]), +				g = PREMULTIPLY8(a, unit[1]), +				r = PREMULTIPLY8(a, unit[0]); +			data[i] = a << 24 | r << 16 | g << 8 | b; +		} +	} else { +		for (size_t i = size / sizeof *data; i--; ) { +			uint32_t rgba = g_ntohl(data[i]); +			data[i] = rgba << 24 | rgba >> 8; +		} +	} + +	cairo_surface_mark_dirty((result = surface)); + +fail_data: +	if (!result) +		cairo_surface_destroy(surface); +fail: +	spng_ctx_free(ctx); +fail_init: +	fclose(fp); +	return result; +} + +cairo_surface_t * +fiv_thumbnail_lookup(GFile *target, FivThumbnailSize size) +{ +	g_return_val_if_fail(size >= FIV_THUMBNAIL_SIZE_MIN && +		size <= FIV_THUMBNAIL_SIZE_MAX, NULL); + +	// Local files only, at least for now. +	gchar *path = g_file_get_path(target); +	if (!path) +		return NULL; + +	GStatBuf st = {}; +	int err = g_stat(path, &st); +	g_free(path); +	if (err) +		return NULL; + +	gchar *uri = g_file_get_uri(target); +	gchar *sum = g_compute_checksum_for_string(G_CHECKSUM_MD5, uri, -1); +	gchar *thumbnails_dir = fiv_thumbnail_get_root(); + +	// The lookup sequence is: nominal..max, then mirroring back to ..min. +	cairo_surface_t *result = NULL; +	GError *error = NULL; +	for (int i = 0; i < FIV_THUMBNAIL_SIZE_COUNT; i++) { +		FivThumbnailSize use = size + i; +		if (use > FIV_THUMBNAIL_SIZE_MAX) +			use = FIV_THUMBNAIL_SIZE_MAX - i; + +		const char *name = fiv_thumbnail_sizes[use].thumbnail_spec_name; +		gchar *wide = +			g_strdup_printf("%s/wide-%s/%s.webp", thumbnails_dir, name, sum); +		result = read_wide_thumbnail(wide, uri, st.st_mtim.tv_sec, &error); +		if (error) { +			g_debug("%s: %s", wide, error->message); +			g_clear_error(&error); +		} +		g_free(wide); +		if (result) { +			// Higher up we can't distinguish images smaller than the thumbnail. +			// Also, try not to rescale the already rescaled. +			if (use != size) +				mark_thumbnail_lq(result); +			break; +		} + +		gchar *path = +			g_strdup_printf("%s/%s/%s.png", thumbnails_dir, name, sum); +		result = read_spng_thumbnail(path, uri, st.st_mtim.tv_sec, &error); +		if (error) { +			g_debug("%s: %s", path, error->message); +			g_clear_error(&error); +		} +		g_free(path); +		if (result) { +			// Whatever produced it, we may be able to outclass it. +			mark_thumbnail_lq(result); +			break; +		} +	} + +	// TODO(p): We can definitely extract embedded thumbnails, but it should be +	// done as a separate stage--the file may be stored on a slow device. + +	g_free(thumbnails_dir); +	g_free(sum); +	g_free(uri); +	return result; +}  | 
