/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- * * Copyright (C) 2016-2024 Matthias Klumpp * * Licensed under the GNU Lesser General Public License Version 2.1 * * This library is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 2.1 of the license, or * (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this library. If not, see . */ /** * SECTION:asc-utils-screenshots * @short_description: Screenshots processing functions for appstream-compose * @include: appstream-compose.h */ #include "config.h" #include "asc-utils-screenshots.h" #include #include #include #include "as-utils-private.h" #include "asc-globals.h" #include "asc-utils.h" #include "asc-image.h" struct { gint width; gint height; } target_screenshot_sizes[] = { {1248, 702}, { 752, 423}, { 624, 351}, { 224, 126}, { 0, 0 } }; static AscVideoInfo * asc_video_info_new (void) { AscVideoInfo *vinfo; vinfo = g_new0 (AscVideoInfo, 1); return vinfo; } void asc_video_info_free (AscVideoInfo *vinfo) { if (vinfo == NULL) return; g_free (vinfo->codec_name); g_free (vinfo->audio_codec_name); g_free (vinfo->format_name); g_free (vinfo); } static gsize asc_get_filesize (const gchar *filename) { struct stat st; if (lstat (filename, &st) == -1) { g_debug ("Unable to determine size of file '%s': %s", filename, g_strerror (errno)); return 0; } return st.st_size; } /** * asc_extract_video_info: (skip): */ AscVideoInfo * asc_extract_video_info (AscResult *cres, AsComponent *cpt, const gchar *vid_fname) { AscVideoInfo *vinfo = NULL; gboolean ret = FALSE; g_autofree gchar *ff_stdout = NULL; g_autofree gchar *ff_stderr = NULL; g_autofree gchar *vid_basename = NULL; gint exit_status = 0; g_auto(GStrv) lines = NULL; g_autofree gchar *prev_codec_name = NULL; gboolean audio_okay = FALSE; g_autoptr(GError) error = NULL; const gchar *ffprobe_argv[] = { asc_globals_get_ffprobe_binary (), "-v", "quiet", "-show_entries", "stream=width,height,codec_name,codec_type", "-show_entries", "format=format_name", "-of", "default=noprint_wrappers=1", vid_fname, NULL }; vinfo = asc_video_info_new (); if (vid_fname == NULL) return vinfo; if (asc_globals_get_ffprobe_binary () == NULL) return NULL; vid_basename = g_path_get_basename (vid_fname); ret = g_spawn_sync (NULL, /* working directory */ (gchar **) ffprobe_argv, NULL, /* envp */ G_SPAWN_LEAVE_DESCRIPTORS_OPEN, NULL, /* child setup */ NULL, /* user data */ &ff_stdout, &ff_stderr, &exit_status, &error); if (!ret) { g_warning ("Failed to spawn ffprobe: %s", error->message); asc_result_add_hint (cres, cpt, "screenshot-video-check-failed", "fname", vid_basename, "msg", error->message, NULL); return vinfo; } if (exit_status != 0) { g_autofree gchar *msg = NULL; if (ff_stderr == NULL) { ff_stderr = g_strdup (ff_stdout); } else { g_autofree gchar *dummy = ff_stderr; ff_stderr = g_strconcat (ff_stderr, "\n", ff_stdout, NULL); } g_warning ("FFprobe on '%s' failed with error code %i: %s", vid_fname, exit_status, ff_stderr); msg = g_strdup_printf ("Code %i, %s", exit_status, ff_stderr); asc_result_add_hint (cres, cpt, "screenshot-video-check-failed", "fname", vid_basename, "msg", msg, NULL); return vinfo; } /* NOTE: We are currently extracting information from ffprobe's simple output, but it also has a JSON * mode. Parsing JSON is a bit slower, but if it is more reliable we should switch to that. */ lines = g_strsplit (ff_stdout, "\n", -1); for (guint i = 0; lines[i] != NULL; i++) { gchar *tmp; gchar *value; gchar *key; tmp = g_strstr_len (lines[i], -1, "="); if (tmp == NULL) continue; value = tmp + 1; key = lines[i]; tmp[0] = '\0'; if (g_strcmp0 (key, "codec_name") == 0) { g_free (prev_codec_name); prev_codec_name = g_strdup (value); continue; } if (g_strcmp0 (key, "codec_type") == 0) { if (g_strcmp0 (value, "video") == 0) { if (vinfo->codec_name == NULL) vinfo->codec_name = g_strdup (prev_codec_name); } else if (g_strcmp0 (value, "audio") == 0) { if (vinfo->audio_codec_name == NULL) vinfo->audio_codec_name = g_strdup (prev_codec_name); } continue; } if (g_strcmp0 (key, "format_name") == 0) { if (vinfo->format_name == NULL) vinfo->format_name = g_strdup (value); continue; } if (g_strcmp0 (key, "width") == 0) { if (g_strcmp0 (value, "N/A") != 0) vinfo->width = g_ascii_strtoll (value, NULL, 10); continue; } if (g_strcmp0 (key, "height") == 0) { if (g_strcmp0 (value, "N/A") != 0) vinfo->height = g_ascii_strtoll (value, NULL, 10); continue; } } /* Check whether the video container is a supported format * Since WebM is a subset of Matroska, FFmpeg lists them as one thing * and us distinguishing by file extension here is a bit artificial. */ if (g_strstr_len (vinfo->format_name, -1, "matroska") != NULL) vinfo->container_kind = AS_VIDEO_CONTAINER_KIND_MKV; if (g_strstr_len (vinfo->format_name, -1, "webm") != NULL) { if (g_str_has_suffix (vid_basename, ".webm")) vinfo->container_kind = AS_VIDEO_CONTAINER_KIND_WEBM; } /* check codec */ if (g_strcmp0 (vinfo->codec_name, "av1") == 0) vinfo->codec_kind = AS_VIDEO_CODEC_KIND_AV1; else if (g_strcmp0 (vinfo->codec_name, "vp9") == 0) vinfo->codec_kind = AS_VIDEO_CODEC_KIND_VP9; /* check for audio */ audio_okay = TRUE; if (vinfo->audio_codec_name != NULL) { /* this video has an audio track... meh. */ asc_result_add_hint (cres, cpt, "screenshot-video-has-audio", "fname", vid_basename, NULL); if (g_strcmp0 (vinfo->audio_codec_name, "opus") != 0) { asc_result_add_hint (cres, cpt, "screenshot-video-audio-codec-unsupported", "fname", vid_basename, "codec", vinfo->audio_codec_name, NULL); audio_okay = FALSE; } } /* A video file may contain multiple streams, so this check isn't extensive, but it protects against 99% of cases where * people were using unsupported formats. */ vinfo->is_acceptable = (vinfo->container_kind != AS_VIDEO_CONTAINER_KIND_UNKNOWN) && (vinfo->codec_kind != AS_VIDEO_CODEC_KIND_UNKNOWN) && audio_okay; if (!vinfo->is_acceptable) { asc_result_add_hint (cres, cpt, "screenshot-video-format-unsupported", "fname", vid_basename, "codec", vinfo->codec_name, "container", vinfo->format_name, NULL); } return vinfo; } /** * asc_process_screenshot_videos: (skip): */ static AsScreenshot * asc_process_screenshot_videos (AscResult *cres, AsComponent *cpt, AsScreenshot *scr, AsCurl *acurl, const gchar *scr_export_dir, const gchar *scr_base_url, const gssize max_size_bytes, gboolean store_screenshots, guint scr_no) { GPtrArray *vids = NULL; g_autoptr(GPtrArray) valid_vids = NULL; vids = as_screenshot_get_videos_all (scr); if (vids->len == 0) { asc_result_add_hint_simple (cres, cpt, "metainfo-screenshot-but-no-media"); return NULL; } /* if size is zero, we can't store any screenshots */ if (max_size_bytes == 0) store_screenshots = FALSE; /* ensure export dir exists */ if (g_mkdir_with_parents (scr_export_dir, 0755) != 0) g_warning ("Failed to create directory tree '%s'", scr_export_dir); valid_vids = g_ptr_array_new_with_free_func (g_object_unref); for (guint i = 0; i < vids->len; i++) { const gchar *orig_vid_url = NULL; const gchar *video_locale = NULL; g_autofree gchar *scr_vid_name = NULL; g_autofree gchar *scr_vid_path = NULL; g_autofree gchar *scr_vid_url = NULL; g_autofree gchar *fname_from_url = NULL; AscVideoInfo *vinfo = NULL; g_autoptr(GError) error = NULL; gsize video_size; AsVideo *vid = AS_VIDEO (g_ptr_array_index (vids, i)); orig_vid_url = as_video_get_url (vid); if (orig_vid_url == NULL) continue; video_locale = as_video_get_locale (vid); fname_from_url = asc_filename_from_url (orig_vid_url); if (g_strcmp0 (video_locale, "C") == 0) scr_vid_name = g_strdup_printf ("vid%i-%i_%s", scr_no, i, fname_from_url); else scr_vid_name = g_strdup_printf ("vid%i-%i_%s_%s", scr_no, i, fname_from_url, video_locale); scr_vid_path = g_build_filename (scr_export_dir, scr_vid_name, NULL); scr_vid_url = g_build_filename (scr_base_url, scr_vid_name, NULL); if (!as_curl_download_to_filename (acurl, orig_vid_url, scr_vid_path, &error)) { asc_result_add_hint (cres, cpt, "screenshot-download-error", "url", orig_vid_url, "error", error->message, NULL); return NULL; } video_size = asc_get_filesize (scr_vid_path); if (max_size_bytes > 0 && video_size > (gsize) max_size_bytes) { g_autofree gchar *max_vid_size_str = NULL; g_autofree gchar *vid_size_str = NULL; max_vid_size_str = g_format_size (max_size_bytes); vid_size_str = g_format_size (video_size); asc_result_add_hint (cres, cpt, "screenshot-video-too-big", "fname", scr_vid_name, "max_size", max_vid_size_str, "size", vid_size_str, NULL); g_unlink (scr_vid_path); continue; } vinfo = asc_extract_video_info (cres, cpt, scr_vid_path); /* if vinfo is NULL, we couldn't gather the required info because ffprobe is missing. * continue with incomplete metadata in that case */ if (vinfo != NULL) { if (!vinfo->is_acceptable) { asc_video_info_free (vinfo); g_remove (scr_vid_path); /* we already marked the screenshot to be ignored at this point */ continue; } as_video_set_codec_kind (vid, vinfo->codec_kind); as_video_set_container_kind (vid, vinfo->container_kind); as_video_set_width (vid, vinfo->width); as_video_set_height (vid, vinfo->height); asc_video_info_free (vinfo); } /* if we should not create a screenshots store, we'll later delete the just-downloaded file * and set the original upstream URL as source. * we still needed to download the video to get information about its size and ensure * its metadata is correct. */ if (store_screenshots) as_video_set_url (vid, scr_vid_url); else as_video_set_url (vid, orig_vid_url); g_ptr_array_add (valid_vids, g_object_ref (vid)); } /* if we don't store screenshots, the export dir is only a temporary cache */ if (!store_screenshots) as_utils_delete_dir_recursive (scr_export_dir); /* if we have no valid videos, ignore the screenshot */ if (valid_vids->len == 0) return NULL; /* drop all videos */ g_ptr_array_remove_range (vids, 0, vids->len); /* add the valid ones back */ for (guint i = 0; i < valid_vids->len; i++) as_screenshot_add_video (scr, AS_VIDEO (g_ptr_array_index (valid_vids, i))); return g_object_ref (scr); } /** * asc_process_screenshot_images_lang: * * Process image @orig_img for screenshot @scr and language @locale * * Returns: %TRUE on success, %FALSE if we rejected this screenshot. */ static gboolean asc_process_screenshot_images_lang (AscResult *cres, AsComponent *cpt, AsScreenshot *scr, AsImage *orig_img, const gchar *locale, AsCurl *acurl, const gchar *scr_export_dir, const gchar *scr_base_url, const gssize max_size_bytes, gboolean store_screenshots, guint scr_no) { const gchar *orig_img_url = NULL; const gchar *gcid = NULL; g_autoptr(GBytes) img_bytes = NULL; gconstpointer img_data; gsize img_data_len; guint source_scr_width; guint source_scr_height; guint source_scr_scale; gboolean thumbnails_generated = FALSE; g_autoptr(GError) error = NULL; orig_img_url = as_image_get_url (orig_img); if (orig_img_url == NULL) return FALSE; /* if size is zero, we can't store any screenshots */ if (max_size_bytes == 0) store_screenshots = FALSE; /* download our image */ img_bytes = as_curl_download_bytes (acurl, orig_img_url, &error); if (img_bytes == NULL) { asc_result_add_hint (cres, cpt, "screenshot-download-error", "url", orig_img_url, "error", error->message, NULL); return FALSE; } img_data = g_bytes_get_data (img_bytes, &img_data_len); gcid = asc_result_gcid_for_component (cres, cpt); if (gcid == NULL) { asc_result_add_hint ( cres, cpt, "internal-error", "msg", "No global ID could be found for component when processing screenshot images.", NULL); return FALSE; } if (max_size_bytes > 0 && img_data_len > (gsize) max_size_bytes) { g_autofree gchar *max_img_size_str = NULL; g_autofree gchar *img_size_str = NULL; max_img_size_str = g_format_size (max_size_bytes); img_size_str = g_format_size (img_data_len); asc_result_add_hint (cres, cpt, "screenshot-image-too-big", "fname", orig_img_url, "max_size", max_img_size_str, "size", img_size_str, NULL); return FALSE; } /* ensure export dir exists */ if (g_mkdir_with_parents (scr_export_dir, 0755) != 0) g_warning ("Failed to create directory tree '%s'", scr_export_dir); { g_autoptr(AscImage) src_image = NULL; g_autofree gchar *src_img_name = NULL; g_autofree gchar *src_img_path = NULL; g_autofree gchar *src_img_url = NULL; g_autoptr(AsImage) simg = NULL; if (g_strcmp0 (locale, "C") == 0) src_img_name = g_strdup_printf ("image-%i_orig.png", scr_no); else src_img_name = g_strdup_printf ("image-%i_%s_orig.png", scr_no, locale); src_img_path = g_build_filename (scr_export_dir, src_img_name, NULL); src_img_url = g_build_filename (scr_base_url, src_img_name, NULL); /* save the source screenshot as PNG image */ src_image = asc_image_new_from_data (img_data, img_data_len, 0, /* destination size */ FALSE, /* compressed */ ASC_IMAGE_LOAD_FLAG_NONE, &error); if (error != NULL) { g_autofree gchar *msg = g_strdup_printf ( "Could not load source screenshot for storing: %s", error->message); asc_result_add_hint (cres, cpt, "screenshot-save-error", "url", orig_img_url, "error", msg, NULL); return FALSE; } if (!asc_image_save_filename (src_image, src_img_path, 0, 0, ASC_IMAGE_SAVE_FLAG_OPTIMIZE, &error)) { g_autofree gchar *msg = g_strdup_printf ( "Can not store source screenshot: %s", error->message); asc_result_add_hint (cres, cpt, "screenshot-save-error", "url", orig_img_url, "error", msg, NULL); return FALSE; } simg = as_image_new (); as_image_set_kind (simg, AS_IMAGE_KIND_SOURCE); as_image_set_locale (simg, locale); source_scr_width = asc_image_get_width (src_image); source_scr_height = asc_image_get_height (src_image); source_scr_scale = as_image_get_scale (orig_img); as_image_set_width (simg, source_scr_width); as_image_set_height (simg, source_scr_height); as_image_set_scale (simg, source_scr_scale); /* if we should not create a screenshots store, delete the just-downloaded file and set * the original upstream URL as source. * we still needed to download the screenshot to get information about its size. */ if (!store_screenshots) { as_image_set_url (simg, orig_img_url); as_screenshot_add_image (scr, simg); /* drop screenshot storage directory, in this mode it is only ever used temporarily */ as_utils_delete_dir_recursive (scr_export_dir); return TRUE; } as_image_set_url (simg, src_img_url); as_screenshot_add_image (scr, simg); } /* generate & save thumbnails for the screenshot image */ thumbnails_generated = FALSE; for (guint i = 0; target_screenshot_sizes[i].width != 0; i++) { g_autoptr(AscImage) thumb = NULL; g_autoptr(AsImage) img = NULL; g_autofree gchar *thumb_img_name = NULL; g_autofree gchar *thumb_img_path = NULL; g_autofree gchar *thumb_img_url = NULL; guint target_width = target_screenshot_sizes[i].width; guint target_height = target_screenshot_sizes[i].height; /* ensure we will only downscale the screenshot for thumbnailing */ if (target_width > source_scr_width) continue; if (target_height > source_scr_height) continue; /* NOTE: we ignore higher scaling factors for thumbnailing for now */ if (source_scr_scale > 1) continue; thumb = asc_image_new_from_data (img_data, img_data_len, 0, /* destination size */ FALSE, /* compressed */ ASC_IMAGE_LOAD_FLAG_NONE, &error); if (error != NULL) { g_autofree gchar *msg = g_strdup_printf ( "Could not load source screenshot for thumbnailing: %s", error->message); asc_result_add_hint (cres, cpt, "screenshot-save-error", "url", orig_img_url, "error", msg, NULL); g_error_free (g_steal_pointer (&error)); continue; } if (target_width > target_height) asc_image_scale_to_width (thumb, target_width); else asc_image_scale_to_height (thumb, target_height); /* create thumbnail storage path and URL component*/ if (g_strcmp0 (locale, "C") == 0) thumb_img_name = g_strdup_printf ("image-%i_%ix%i@%i.png", scr_no, asc_image_get_width (thumb), asc_image_get_height (thumb), 1); else thumb_img_name = g_strdup_printf ("image-%i_%ix%i@%i_%s.png", scr_no, asc_image_get_width (thumb), asc_image_get_height (thumb), 1, locale); thumb_img_path = g_build_filename (scr_export_dir, thumb_img_name, NULL); thumb_img_url = g_build_filename (scr_base_url, thumb_img_name, NULL); /* store the thumbnail image on disk */ if (!asc_image_save_filename (thumb, thumb_img_path, 0, 0, ASC_IMAGE_SAVE_FLAG_OPTIMIZE, &error)) { g_autofree gchar *msg = g_strdup_printf ( "Can not store thumbnail image: %s", error->message); asc_result_add_hint (cres, cpt, "screenshot-save-error", "url", orig_img_url, "error", msg, NULL); g_error_free (g_steal_pointer (&error)); continue; } /* finally prepare the thumbnail definition and add it to the metadata */ img = as_image_new (); as_image_set_locale (img, locale); as_image_set_kind (img, AS_IMAGE_KIND_THUMBNAIL); as_image_set_width (img, asc_image_get_width (thumb)); as_image_set_height (img, asc_image_get_height (thumb)); as_image_set_url (img, thumb_img_url); as_screenshot_add_image (scr, img); thumbnails_generated = TRUE; } if (!thumbnails_generated) asc_result_add_hint (cres, cpt, "screenshot-no-thumbnails", "url", orig_img_url, NULL); return TRUE; } /** * asc_process_screenshot_images: * * Returns: (transfer full): The processed screenshot. */ static AsScreenshot * asc_process_screenshot_images (AscResult *cres, AsComponent *cpt, AsScreenshot *scr, AsCurl *acurl, const gchar *scr_export_dir, const gchar *scr_base_url, const gssize max_size_bytes, gboolean store_screenshots, guint scr_no) { GPtrArray *imgs = NULL; GHashTableIter ht_iter; gpointer ht_key, ht_value; g_autoptr(GHashTable) ht_lang_img = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_object_unref); imgs = as_screenshot_get_images_all (scr); if (imgs->len == 0) { asc_result_add_hint_simple (cres, cpt, "metainfo-screenshot-but-no-media"); return NULL; } /* try to find the source images, in case upstream has provided their own thumbnails. * We use a hash-table to remove any possible duplicate original images of the same locale */ for (guint i = 0; i < imgs->len; i++) { AsImage *img = AS_IMAGE (g_ptr_array_index (imgs, i)); if (as_image_get_kind (img) == AS_IMAGE_KIND_SOURCE) g_hash_table_insert (ht_lang_img, g_strdup (as_image_get_locale (img)), g_object_ref (img)); } /* just take the first image if we couldn't find a source */ if (g_hash_table_size (ht_lang_img) == 0) g_hash_table_insert (ht_lang_img, g_strdup ("C"), g_object_ref (AS_IMAGE (g_ptr_array_index (imgs, 0)))); /* drop metainfo images */ as_screenshot_clear_images (scr); /* process images per locale */ g_hash_table_iter_init (&ht_iter, ht_lang_img); while (g_hash_table_iter_next (&ht_iter, &ht_key, &ht_value)) if (!asc_process_screenshot_images_lang (cres, cpt, scr, AS_IMAGE (ht_value), (const gchar *) ht_key, acurl, scr_export_dir, scr_base_url, max_size_bytes, store_screenshots, scr_no)) return NULL; return g_object_ref (scr); } /** * asc_process_screenshots: * * Download and resize screenshots and store them in our media export cache. */ void asc_process_screenshots (AscResult *cres, AsComponent *cpt, AsCurl *acurl, const gchar *media_export_root, const gchar *media_url_prefix, const gssize max_size_bytes, gboolean process_videos, gboolean store_screenshots) { GPtrArray *screenshots = NULL; g_autoptr(GPtrArray) valid_scrs = NULL; const gchar *gcid = NULL; g_autofree gchar *scr_export_dir = NULL; g_autofree gchar *scr_base_url = NULL; /* sanity check */ if (media_export_root == NULL) store_screenshots = FALSE; screenshots = as_component_get_screenshots_all (cpt); if (screenshots->len == 0) return; gcid = asc_result_gcid_for_component (cres, cpt); if (gcid == NULL) { asc_result_add_hint ( cres, cpt, "internal-error", "msg", "No global ID could be found for component when processing screenshots.", NULL); return; } /* if we shouldn't export screenshots, we store downloads in a temporary directory */ if (store_screenshots) scr_export_dir = g_build_filename (media_export_root, gcid, "screenshots", NULL); else scr_export_dir = g_build_filename (asc_globals_get_tmp_dir (), gcid, NULL); /* the media URL prefix is used to embed absolute URLs for screenshots, if needed */ if (media_url_prefix == NULL) scr_base_url = g_build_path (G_DIR_SEPARATOR_S, gcid, "screenshots", NULL); else scr_base_url = g_build_path (G_DIR_SEPARATOR_S, media_url_prefix, gcid, "screenshots", NULL); /* process screenshots */ valid_scrs = g_ptr_array_new_with_free_func (g_object_unref); for (guint i = 0; i < screenshots->len; i++) { AsScreenshot *scr = AS_SCREENSHOT (g_ptr_array_index (screenshots, i)); AsScreenshot *res_scr = NULL; if (as_screenshot_get_media_kind (scr) == AS_SCREENSHOT_MEDIA_KIND_VIDEO) { if (process_videos) res_scr = asc_process_screenshot_videos (cres, cpt, scr, acurl, scr_export_dir, scr_base_url, max_size_bytes, store_screenshots, i + 1); } else { res_scr = asc_process_screenshot_images (cres, cpt, scr, acurl, scr_export_dir, scr_base_url, max_size_bytes, store_screenshots, i + 1); } if (res_scr != NULL) g_ptr_array_add (valid_scrs, res_scr); } /* drop all preexisting screenshots from the MetaInfo data */ g_ptr_array_remove_range (screenshots, 0, screenshots->len); /* add valid screenshots back */ for (guint i = 0; i < valid_scrs->len; i++) as_component_add_screenshot (cpt, AS_SCREENSHOT (g_ptr_array_index (valid_scrs, i))); }