class-wc-regenerate-images.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. <?php
  2. /**
  3. * Regenerate Images Functionality
  4. *
  5. * All functionality pertaining to regenerating product images in realtime.
  6. *
  7. * @package WooCommerce/Classes
  8. * @version 3.3.0
  9. * @since 3.3.0
  10. */
  11. defined( 'ABSPATH' ) || exit;
  12. /**
  13. * Regenerate Images Class
  14. */
  15. class WC_Regenerate_Images {
  16. /**
  17. * Background process to regenerate all images
  18. *
  19. * @var WC_Regenerate_Images_Request
  20. */
  21. protected static $background_process;
  22. /**
  23. * Stores size being generated on the fly.
  24. *
  25. * @var string
  26. */
  27. protected static $regenerate_size;
  28. /**
  29. * Init function
  30. */
  31. public static function init() {
  32. add_action( 'image_get_intermediate_size', array( __CLASS__, 'filter_image_get_intermediate_size' ), 10, 3 );
  33. add_filter( 'wp_generate_attachment_metadata', array( __CLASS__, 'add_uncropped_metadata' ) );
  34. // Resize WooCommerce images on the fly when browsing site through customizer as to showcase image setting changes in real time.
  35. if ( is_customize_preview() ) {
  36. add_filter( 'wp_get_attachment_image_src', array( __CLASS__, 'maybe_resize_image' ), 10, 4 );
  37. }
  38. // Not required when Jetpack Photon is in use.
  39. if ( method_exists( 'Jetpack', 'is_module_active' ) && Jetpack::is_module_active( 'photon' ) ) {
  40. return;
  41. }
  42. if ( apply_filters( 'woocommerce_background_image_regeneration', true ) ) {
  43. include_once WC_ABSPATH . 'includes/class-wc-regenerate-images-request.php';
  44. self::$background_process = new WC_Regenerate_Images_Request();
  45. add_action( 'admin_init', array( __CLASS__, 'regenerating_notice' ) );
  46. add_action( 'woocommerce_hide_regenerating_thumbnails_notice', array( __CLASS__, 'dismiss_regenerating_notice' ) );
  47. // Regenerate thumbnails in the background after settings changes. Not ran on multisite to avoid multiple simultanious jobs.
  48. if ( ! is_multisite() ) {
  49. add_action( 'customize_save_after', array( __CLASS__, 'maybe_regenerate_images' ) );
  50. add_action( 'after_switch_theme', array( __CLASS__, 'maybe_regenerate_images' ) );
  51. }
  52. }
  53. }
  54. /**
  55. * If an intermediate size meta differs from the actual image size (settings were changed?) return false so the wrong size is not used.
  56. *
  57. * @param array $data Size data.
  58. * @param int $attachment_id Attachment ID.
  59. * @param string $size Size name.
  60. * @return array
  61. */
  62. public static function filter_image_get_intermediate_size( $data, $attachment_id, $size ) {
  63. if ( ! is_string( $size ) || ! in_array( $size, apply_filters( 'woocommerce_image_sizes_to_resize', array( 'woocommerce_thumbnail', 'woocommerce_gallery_thumbnail', 'woocommerce_single', 'shop_thumbnail', 'shop_catalog', 'shop_single' ) ), true ) ) {
  64. return $data;
  65. }
  66. // If we don't have sizes, we cannot proceed.
  67. if ( ! isset( $data['width'], $data['height'] ) ) {
  68. return $data;
  69. }
  70. // See if the image size has changed from our settings.
  71. if ( ! self::image_size_matches_settings( $data, $size ) ) {
  72. // If Photon is running we can just return false and let Jetpack handle regeneration.
  73. if ( method_exists( 'Jetpack', 'is_module_active' ) && Jetpack::is_module_active( 'photon' ) ) {
  74. return false;
  75. } else {
  76. // If we get here, Jetpack is not running and we don't have the correct image sized stored. Try to return closest match.
  77. $size_data = wc_get_image_size( $size );
  78. return image_get_intermediate_size( $attachment_id, array( absint( $size_data['width'] ), absint( $size_data['height'] ) ) );
  79. }
  80. return false;
  81. }
  82. return $data;
  83. }
  84. /**
  85. * We need to track if uncropped was on or off when generating the images.
  86. *
  87. * @param array $meta_data Array of meta data.
  88. * @return array
  89. */
  90. public static function add_uncropped_metadata( $meta_data ) {
  91. $size_data = wc_get_image_size( 'woocommerce_thumbnail' );
  92. if ( isset( $meta_data['sizes'], $meta_data['sizes']['woocommerce_thumbnail'] ) ) {
  93. $meta_data['sizes']['woocommerce_thumbnail']['uncropped'] = empty( $size_settings['height'] );
  94. }
  95. return $meta_data;
  96. }
  97. /**
  98. * See if an image's dimensions match actual settings.
  99. *
  100. * @param array $image Image dimensions array.
  101. * @param string $size Named size.
  102. * @return bool
  103. */
  104. protected static function image_size_matches_settings( $image, $size ) {
  105. $size_data = wc_get_image_size( $size );
  106. // Size is invalid if the widths or crop setting don't match.
  107. if ( $size_data['width'] !== $image['width'] ) {
  108. return false;
  109. }
  110. // Size is invalid if the heights don't match.
  111. if ( $size_data['height'] && $size_data['height'] !== $image['height'] ) {
  112. return false;
  113. }
  114. // If cropping mode has changed, regenerate the image.
  115. if ( '' === $size_data['height'] && empty( $image['uncropped'] ) ) {
  116. return false;
  117. }
  118. return true;
  119. }
  120. /**
  121. * Show notice when job is running in background.
  122. */
  123. public static function regenerating_notice() {
  124. if ( ! self::$background_process->is_running() ) {
  125. WC_Admin_Notices::add_notice( 'regenerating_thumbnails' );
  126. } else {
  127. WC_Admin_Notices::remove_notice( 'regenerating_thumbnails' );
  128. }
  129. }
  130. /**
  131. * Dismiss notice and cancel jobs.
  132. */
  133. public static function dismiss_regenerating_notice() {
  134. if ( self::$background_process ) {
  135. self::$background_process->kill_process();
  136. $log = wc_get_logger();
  137. $log->info(
  138. __( 'Cancelled product image regeneration job.', 'woocommerce' ),
  139. array(
  140. 'source' => 'wc-image-regeneration',
  141. )
  142. );
  143. }
  144. WC_Admin_Notices::remove_notice( 'regenerating_thumbnails' );
  145. }
  146. /**
  147. * Regenerate images if the settings have changed since last re-generation.
  148. *
  149. * @return void
  150. */
  151. public static function maybe_regenerate_images() {
  152. $size_hash = md5(
  153. wp_json_encode(
  154. array(
  155. wc_get_image_size( 'thumbnail' ),
  156. wc_get_image_size( 'single' ),
  157. wc_get_image_size( 'gallery_thumbnail' ),
  158. )
  159. )
  160. );
  161. if ( update_option( 'woocommerce_maybe_regenerate_images_hash', $size_hash ) ) {
  162. // Size settings have changed. Trigger regen.
  163. self::queue_image_regeneration();
  164. }
  165. }
  166. /**
  167. * Check if we should maybe generate a new image size if not already there.
  168. *
  169. * @param array $image Properties of the image.
  170. * @param int $attachment_id Attachment ID.
  171. * @param string|array $size Image size.
  172. * @param bool $icon If icon or not.
  173. * @return array
  174. */
  175. public static function maybe_resize_image( $image, $attachment_id, $size, $icon ) {
  176. if ( ! apply_filters( 'woocommerce_resize_images', true ) ) {
  177. return $image;
  178. }
  179. // Use a whitelist of sizes we want to resize. Ignore others.
  180. if ( ! in_array( $size, apply_filters( 'woocommerce_image_sizes_to_resize', array( 'woocommerce_thumbnail', 'woocommerce_gallery_thumbnail', 'woocommerce_single', 'shop_thumbnail', 'shop_catalog', 'shop_single' ) ), true ) ) {
  181. return $image;
  182. }
  183. // Get image metadata - we need it to proceed.
  184. $imagemeta = wp_get_attachment_metadata( $attachment_id );
  185. if ( empty( $imagemeta ) ) {
  186. return $image;
  187. }
  188. if ( ! isset( $imagemeta['sizes'], $imagemeta['sizes'][ $size ] ) || ! self::image_size_matches_settings( $imagemeta['sizes'][ $size ], $size ) ) {
  189. return self::resize_and_return_image( $attachment_id, $image, $size, $icon );
  190. }
  191. return $image;
  192. }
  193. /**
  194. * Ensure we are dealing with the correct image attachment
  195. *
  196. * @param WP_Post $attachment Attachment object.
  197. * @return boolean
  198. */
  199. public static function is_regeneratable( $attachment ) {
  200. if ( 'site-icon' === get_post_meta( $attachment->ID, '_wp_attachment_context', true ) ) {
  201. return false;
  202. }
  203. if ( wp_attachment_is_image( $attachment ) ) {
  204. return true;
  205. }
  206. return false;
  207. }
  208. /**
  209. * Only regenerate images for the requested size.
  210. *
  211. * @param array $sizes Array of image sizes.
  212. * @return array
  213. */
  214. public static function adjust_intermediate_image_sizes( $sizes ) {
  215. return array( self::$regenerate_size );
  216. }
  217. /**
  218. * Generate the thumbnail filename and dimensions for a given file.
  219. *
  220. * @param string $fullsizepath Path to full size image.
  221. * @param int $thumbnail_width The width of the thumbnail.
  222. * @param int $thumbnail_height The height of the thumbnail.
  223. * @param bool $crop Whether to crop or not.
  224. * @return array|false An array of the filename, thumbnail width, and thumbnail height, or false on failure to resize such as the thumbnail being larger than the fullsize image.
  225. */
  226. private static function get_image( $fullsizepath, $thumbnail_width, $thumbnail_height, $crop ) {
  227. list( $fullsize_width, $fullsize_height ) = getimagesize( $fullsizepath );
  228. $dimensions = image_resize_dimensions( $fullsize_width, $fullsize_height, $thumbnail_width, $thumbnail_height, $crop );
  229. $editor = wp_get_image_editor( $fullsizepath );
  230. if ( is_wp_error( $editor ) ) {
  231. return false;
  232. }
  233. if ( ! $dimensions || ! is_array( $dimensions ) ) {
  234. return false;
  235. }
  236. list( , , , , $dst_w, $dst_h ) = $dimensions;
  237. $suffix = "{$dst_w}x{$dst_h}";
  238. $file_ext = strtolower( pathinfo( $fullsizepath, PATHINFO_EXTENSION ) );
  239. return array(
  240. 'filename' => $editor->generate_filename( $suffix, null, $file_ext ),
  241. 'width' => $dst_w,
  242. 'height' => $dst_h,
  243. );
  244. }
  245. /**
  246. * Regenerate the image according to the required size
  247. *
  248. * @param int $attachment_id Attachment ID.
  249. * @param array $image Original Image.
  250. * @param string $size Size to return for new URL.
  251. * @param bool $icon If icon or not.
  252. * @return string
  253. */
  254. private static function resize_and_return_image( $attachment_id, $image, $size, $icon ) {
  255. $image_size = wc_get_image_size( $size );
  256. $wp_uploads = wp_upload_dir( null, false );
  257. $wp_uploads_dir = $wp_uploads['basedir'];
  258. $wp_uploads_url = $wp_uploads['baseurl'];
  259. $attachment = get_post( $attachment_id );
  260. if ( ! $attachment || 'attachment' !== $attachment->post_type || ! self::is_regeneratable( $attachment ) ) {
  261. return $image;
  262. }
  263. $fullsizepath = get_attached_file( $attachment_id );
  264. if ( false === $fullsizepath || is_wp_error( $fullsizepath ) || ! file_exists( $fullsizepath ) ) {
  265. return $image;
  266. }
  267. if ( ! function_exists( 'wp_crop_image' ) ) {
  268. include ABSPATH . 'wp-admin/includes/image.php';
  269. }
  270. $thumbnail = self::get_image( $fullsizepath, $image_size['width'], $image_size['height'], $image_size['crop'] );
  271. // If the file is already there perhaps just load it.
  272. if ( $thumbnail && file_exists( $thumbnail['filename'] ) ) {
  273. $wp_uploads = wp_upload_dir( null, false );
  274. $wp_uploads_dir = $wp_uploads['basedir'];
  275. $wp_uploads_url = $wp_uploads['baseurl'];
  276. return array(
  277. 0 => str_replace( $wp_uploads_dir, $wp_uploads_url, $thumbnail['filename'] ),
  278. 1 => $thumbnail['width'],
  279. 2 => $thumbnail['height'],
  280. );
  281. }
  282. $metadata = wp_get_attachment_metadata( $attachment_id );
  283. // Make sure registered image size matches the size we're requesting.
  284. add_image_size( $size . '_preview', $image_size['width'], $image_size['height'], $image_size['crop'] );
  285. self::$regenerate_size = $size . '_preview';
  286. // We only want to regen a specific image size.
  287. add_filter( 'intermediate_image_sizes', array( __CLASS__, 'adjust_intermediate_image_sizes' ) );
  288. // This function will generate the new image sizes.
  289. $new_metadata = wp_generate_attachment_metadata( $attachment_id, $fullsizepath );
  290. // Remove custom filter.
  291. remove_filter( 'intermediate_image_sizes', array( __CLASS__, 'adjust_intermediate_image_sizes' ) );
  292. // If something went wrong lets just return the original image.
  293. if ( is_wp_error( $new_metadata ) || empty( $new_metadata ) ) {
  294. return $image;
  295. }
  296. // Since this is only a preview we should not update the actual size. That will be done later by the background job.
  297. if ( isset( $new_metadata['sizes'][ $size . '_preview' ] ) ) {
  298. $metadata['sizes'][ $size . '_preview' ] = $new_metadata['sizes'][ $size . '_preview' ];
  299. wp_update_attachment_metadata( $attachment_id, $metadata );
  300. }
  301. // Now we've done our regen, attempt to return the new size.
  302. $new_image = image_downsize( $attachment_id, $size . '_preview' );
  303. return $new_image ? $new_image : $image;
  304. }
  305. /**
  306. * Get list of images and queue them for regeneration
  307. *
  308. * @return void
  309. */
  310. public static function queue_image_regeneration() {
  311. global $wpdb;
  312. // First lets cancel existing running queue to avoid running it more than once.
  313. self::$background_process->kill_process();
  314. // Now lets find all product image attachments IDs and pop them onto the queue.
  315. $images = $wpdb->get_results( // @codingStandardsIgnoreLine
  316. "SELECT ID
  317. FROM $wpdb->posts
  318. WHERE post_type = 'attachment'
  319. AND post_mime_type LIKE 'image/%'
  320. ORDER BY ID DESC"
  321. );
  322. foreach ( $images as $image ) {
  323. self::$background_process->push_to_queue(
  324. array(
  325. 'attachment_id' => $image->ID,
  326. )
  327. );
  328. }
  329. // Lets dispatch the queue to start processing.
  330. self::$background_process->save()->dispatch();
  331. }
  332. }
  333. add_action( 'init', array( 'WC_Regenerate_Images', 'init' ) );