PluginProbe
FrontBlocks Site Tools / trunk
FrontBlocks Site Tools vtrunk
1.5.3 1.5.2 1.5.1 1.4.0 1.5.0 trunk 0.2.0 0.2.1 0.2.2 0.2.3 0.2.4 0.2.5 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.2.0 1.2.1 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 All 28 releases
frontblocks / includes / Frontend / ImageManagement.php

ImageManagement.php in FrontBlocks Site Tools trunk, at includes/Frontend/ImageManagement.php

693 lines 23.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Image Management
4 *
5 * @package FrontBlocks
6 * @author Closemarketing
7 * @copyright 2026 Closemarketing
8 * @version 1.0.0
9 */
10
11 namespace FrontBlocks\Frontend;
12
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * Registers/overrides image sizes, generates modern-format variants on
17 * upload, and rewrites image markup on the frontend to serve them.
18 */
19 class ImageManagement {
20
21 /**
22 * Metadata key used to store generated modern-format variants, keyed
23 * per size and then per MIME type: array( 'file' => basename, 'filesize' => bytes ).
24 *
25 * @var string
26 */
27 const VARIANTS_META_KEY = 'frbl_image_variants';
28
29 /**
30 * Core image sizes whose dimensions live in wp_options rather than
31 * $_wp_additional_image_sizes.
32 *
33 * @var string[]
34 */
35 const CORE_SIZES = array( 'thumbnail', 'medium', 'medium_large', 'large' );
36
37 /**
38 * Map of short format key => MIME type.
39 *
40 * @var array
41 */
42 const FORMAT_MIME_TYPES = array(
43 'avif' => 'image/avif',
44 'webp' => 'image/webp',
45 );
46
47 /**
48 * Constructor.
49 */
50 public function __construct() {
51 $this->init_hooks();
52 }
53
54 /**
55 * Register hooks.
56 *
57 * @return void
58 */
59 private function init_hooks() {
60 add_action( 'after_setup_theme', array( $this, 'register_custom_and_override_sizes' ), 999 );
61 add_filter( 'intermediate_image_sizes_advanced', array( $this, 'filter_intermediate_sizes_advanced' ) );
62 add_filter( 'intermediate_image_sizes', array( $this, 'filter_intermediate_sizes' ) );
63 add_filter( 'image_size_names_choose', array( $this, 'filter_image_size_names_choose' ) );
64 add_filter( 'big_image_size_threshold', array( $this, 'filter_big_image_size_threshold' ) );
65 add_filter( 'wp_generate_attachment_metadata', array( $this, 'maybe_generate_modern_formats' ), 10, 2 );
66 add_action( 'delete_attachment', array( $this, 'delete_variant_files' ) );
67
68 if ( ! is_admin() ) {
69 add_filter( 'wp_content_img_tag', array( $this, 'filter_content_img_tag' ), 10, 3 );
70 add_filter( 'post_thumbnail_html', array( $this, 'filter_post_thumbnail_html' ), 10, 3 );
71 }
72 }
73
74 /**
75 * Get the plugin's shared settings array.
76 *
77 * @return array
78 */
79 private function get_options() {
80 return get_option( 'frontblocks_settings', array() );
81 }
82
83 /**
84 * Register custom image sizes and re-apply non-core size overrides.
85 * Core size overrides (thumbnail/medium/medium_large/large) are
86 * written to their wp_options directly when settings are saved, so
87 * they don't need to be re-applied on every request.
88 *
89 * @return void
90 */
91 public function register_custom_and_override_sizes() {
92 $options = $this->get_options();
93 $overrides = (array) ( $options['image_sizes_overrides'] ?? array() );
94 $custom = (array) ( $options['image_sizes_custom'] ?? array() );
95
96 foreach ( $overrides as $name => $size ) {
97 if ( in_array( $name, self::CORE_SIZES, true ) ) {
98 continue;
99 }
100 add_image_size( $name, (int) ( $size['width'] ?? 0 ), (int) ( $size['height'] ?? 0 ), (bool) ( $size['crop'] ?? false ) );
101 }
102
103 foreach ( $custom as $size ) {
104 if ( empty( $size['name'] ) ) {
105 continue;
106 }
107 add_image_size( $size['name'], (int) ( $size['width'] ?? 0 ), (int) ( $size['height'] ?? 0 ), (bool) ( $size['crop'] ?? false ) );
108 }
109 }
110
111 /**
112 * Override how large an uploaded original can be before WordPress
113 * downscales the stored file, so oversized source images don't bloat
114 * storage or slow down thumbnail generation. Defaults to 2048px,
115 * overriding core's own default of 2560px; can be set to a different
116 * value, or disabled entirely (returning false leaves full-size
117 * originals untouched, same as WordPress core's own behavior when this
118 * filter isn't hooked at all).
119 *
120 * @param int $threshold Core's default threshold in pixels.
121 * @return int|false
122 */
123 public function filter_big_image_size_threshold( $threshold ) {
124 $options = $this->get_options();
125
126 if ( empty( $options['image_max_upload_dimension_enabled'] ) && isset( $options['image_max_upload_dimension_enabled'] ) ) {
127 return false;
128 }
129
130 $max = absint( $options['image_max_upload_dimension'] ?? 2048 );
131
132 return $max > 0 ? $max : $threshold;
133 }
134
135 /**
136 * Add custom sizes flagged "show in picker" to the image-size dropdown
137 * shown when inserting/editing an image in the block and media editors.
138 * Sizes are otherwise invisible there unless a theme explicitly labels them.
139 *
140 * @param array $sizes Map of size name => label.
141 * @return array
142 */
143 public function filter_image_size_names_choose( $sizes ) {
144 $custom = (array) ( $this->get_options()['image_sizes_custom'] ?? array() );
145
146 foreach ( $custom as $size ) {
147 if ( empty( $size['name'] ) || empty( $size['show_in_picker'] ) ) {
148 continue;
149 }
150 $sizes[ $size['name'] ] = ! empty( $size['label'] ) ? $size['label'] : $size['name'];
151 }
152
153 return $sizes;
154 }
155
156 /**
157 * Remove disabled sizes from the "advanced" registered-sizes list
158 * (name => width/height/crop) consumed by wp_generate_attachment_metadata().
159 *
160 * @param array $sizes Registered sizes.
161 * @return array
162 */
163 public function filter_intermediate_sizes_advanced( $sizes ) {
164 $disabled = (array) ( $this->get_options()['image_sizes_disabled'] ?? array() );
165 foreach ( $disabled as $name ) {
166 unset( $sizes[ $name ] );
167 }
168
169 return $sizes;
170 }
171
172 /**
173 * Remove disabled sizes from the plain list of registered size names.
174 *
175 * @param string[] $sizes Registered size names.
176 * @return string[]
177 */
178 public function filter_intermediate_sizes( $sizes ) {
179 $disabled = (array) ( $this->get_options()['image_sizes_disabled'] ?? array() );
180
181 return array_values( array_diff( $sizes, $disabled ) );
182 }
183
184 /**
185 * After core generates intermediate sizes, also generate modern-format
186 * (WebP/AVIF) variants for the full image and each generated size.
187 *
188 * @param array $metadata Attachment metadata.
189 * @param int $attachment_id Attachment ID.
190 * @return array
191 */
192 public function maybe_generate_modern_formats( $metadata, $attachment_id ) {
193 $options = $this->get_options();
194 $target = (string) ( $options['image_format_target'] ?? 'none' );
195
196 if ( 'none' === $target || empty( $metadata ) ) {
197 return $metadata;
198 }
199
200 return self::generate_variants_for_metadata( $attachment_id, $metadata, $target, self::get_quality_settings( $options ) );
201 }
202
203 /**
204 * Per-format quality settings, each falling back to its own default when
205 * unset.
206 *
207 * @param array $options Plugin settings array.
208 * @return array Map of format key ('webp'/'avif') => quality (1-100).
209 */
210 public static function get_quality_settings( $options ) {
211 return array(
212 'webp' => (int) ( $options['image_format_quality_webp'] ?? 82 ),
213 'avif' => (int) ( $options['image_format_quality_avif'] ?? 60 ),
214 );
215 }
216
217 /**
218 * Generate WebP/AVIF variants for every file referenced in an
219 * attachment's metadata (full size + intermediate sizes). The original
220 * file is never modified or deleted — generated variants are always
221 * additional files alongside it.
222 *
223 * Shared by the upload-time hook and the bulk-convert AJAX handler.
224 *
225 * @param int $attachment_id Attachment ID.
226 * @param array $metadata Attachment metadata.
227 * @param string $target 'webp', 'avif', or 'both'.
228 * @param array $quality Map of format key ('webp'/'avif') => quality (1-100),
229 * as returned by get_quality_settings().
230 * @return array Updated metadata.
231 */
232 public static function generate_variants_for_metadata( $attachment_id, $metadata, $target, $quality ) {
233 $file = get_attached_file( $attachment_id );
234 if ( ! $file || ! file_exists( $file ) ) {
235 return $metadata;
236 }
237
238 $formats = 'both' === $target ? array( 'avif', 'webp' ) : array( $target );
239 $dir = trailingslashit( dirname( $file ) );
240
241 // Remove previously generated variants first, so switching formats
242 // (e.g. "both" -> "webp") or re-running after a size change doesn't
243 // leave stale files behind.
244 self::delete_variant_files_from_map( $dir, (array) ( $metadata[ self::VARIANTS_META_KEY ] ?? array() ) );
245
246 $variants = array();
247 $variants['full'] = self::generate_variant_files( $file, $formats, $quality );
248
249 if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
250 foreach ( $metadata['sizes'] as $size_name => $size_data ) {
251 if ( empty( $size_data['file'] ) ) {
252 continue;
253 }
254 $variants[ $size_name ] = self::generate_variant_files( $dir . $size_data['file'], $formats, $quality );
255 }
256 }
257
258 $metadata[ self::VARIANTS_META_KEY ] = array_filter( $variants );
259
260 wp_update_attachment_metadata( $attachment_id, $metadata );
261
262 return $metadata;
263 }
264
265 /**
266 * Generate one variant file per requested format for a single source image.
267 *
268 * @param string $source_path Absolute path to the source image.
269 * @param string[] $formats 'webp' and/or 'avif'.
270 * @param array $quality Map of format key => quality (1-100), as
271 * returned by get_quality_settings().
272 * @return array Map of MIME type => array( 'file' => basename, 'filesize' => bytes ).
273 */
274 private static function generate_variant_files( $source_path, $formats, $quality ) {
275 $generated = array();
276
277 foreach ( $formats as $format ) {
278 $mime = self::FORMAT_MIME_TYPES[ $format ] ?? '';
279
280 if ( '' === $mime || ! wp_image_editor_supports( array( 'mime_type' => $mime ) ) ) {
281 continue;
282 }
283
284 $editor = wp_get_image_editor( $source_path );
285 if ( is_wp_error( $editor ) ) {
286 continue;
287 }
288
289 $editor->set_quality( $quality[ $format ] ?? 82 );
290
291 $target_path = preg_replace( '/\.[^.]+$/', '.' . $format, $source_path );
292 $saved = $editor->save( $target_path, $mime );
293
294 if ( is_wp_error( $saved ) || empty( $saved['path'] ) ) {
295 continue;
296 }
297
298 $generated[ $mime ] = array(
299 'file' => basename( $saved['path'] ),
300 'filesize' => file_exists( $saved['path'] ) ? filesize( $saved['path'] ) : 0, // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_filesize -- local media library file we just created, not a remote/user-controlled path.
301 );
302 }
303
304 return $generated;
305 }
306
307 /**
308 * Regenerate variants for an existing attachment (used by the bulk
309 * "convert existing images" admin action).
310 *
311 * @param int $attachment_id Attachment ID.
312 * @return bool True on success, false if the attachment has no usable file.
313 */
314 public static function convert_attachment( $attachment_id ) {
315 $metadata = wp_get_attachment_metadata( $attachment_id );
316 if ( empty( $metadata ) ) {
317 return false;
318 }
319
320 $options = get_option( 'frontblocks_settings', array() );
321 $target = (string) ( $options['image_format_target'] ?? 'none' );
322
323 if ( 'none' === $target ) {
324 return false;
325 }
326
327 self::generate_variants_for_metadata( $attachment_id, $metadata, $target, self::get_quality_settings( $options ) );
328
329 return true;
330 }
331
332 /**
333 * Delete the on-disk files (and any modern-format variants) for sizes
334 * that are currently disabled, and remove them from the attachment's
335 * metadata. Disabling a size only stops *future* generation — without
336 * this, the disk-usage savings shown in the settings table are never
337 * actually reclaimed for existing media.
338 *
339 * @param int $attachment_id Attachment ID.
340 * @return bool True if any file was removed, false otherwise.
341 */
342 public static function cleanup_disabled_size_files( $attachment_id ) {
343 $options = get_option( 'frontblocks_settings', array() );
344 $disabled = (array) ( $options['image_sizes_disabled'] ?? array() );
345
346 if ( empty( $disabled ) ) {
347 return false;
348 }
349
350 $metadata = wp_get_attachment_metadata( $attachment_id );
351 $file = get_attached_file( $attachment_id );
352
353 if ( ! $file || empty( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) ) {
354 return false;
355 }
356
357 $dir = trailingslashit( dirname( $file ) );
358 $variants = (array) ( $metadata[ self::VARIANTS_META_KEY ] ?? array() );
359 $removed = array();
360
361 foreach ( $disabled as $size_name ) {
362 if ( empty( $metadata['sizes'][ $size_name ]['file'] ) ) {
363 continue;
364 }
365
366 $size_path = $dir . $metadata['sizes'][ $size_name ]['file'];
367 if ( file_exists( $size_path ) ) {
368 wp_delete_file( $size_path );
369 }
370
371 if ( ! empty( $variants[ $size_name ] ) ) {
372 self::delete_variant_files_from_map( $dir, array( $variants[ $size_name ] ) );
373 }
374
375 unset( $metadata['sizes'][ $size_name ] );
376 $removed[] = $size_name;
377 }
378
379 $changed = ! empty( $removed );
380
381 if ( $changed ) {
382 $metadata[ self::VARIANTS_META_KEY ] = array_diff_key( $variants, array_flip( $removed ) );
383 wp_update_attachment_metadata( $attachment_id, $metadata );
384 }
385
386 return $changed;
387 }
388
389 /**
390 * Delete generated WebP/AVIF variant files when their attachment is
391 * deleted. WordPress core has no knowledge of these files (they aren't
392 * part of the standard intermediate-size metadata it manages), so
393 * without this they would be orphaned on disk forever.
394 *
395 * @param int $attachment_id Attachment ID being deleted.
396 * @return void
397 */
398 public function delete_variant_files( $attachment_id ) {
399 $metadata = wp_get_attachment_metadata( $attachment_id );
400 $variants = (array) ( $metadata[ self::VARIANTS_META_KEY ] ?? array() );
401
402 $file = get_attached_file( $attachment_id );
403 if ( ! $file || empty( $variants ) ) {
404 return;
405 }
406
407 self::delete_variant_files_from_map( trailingslashit( dirname( $file ) ), $variants );
408 }
409
410 /**
411 * Delete a set of previously generated variant files on disk.
412 *
413 * @param string $dir Directory the files live in (trailing slash included).
414 * @param array $variants Map of size name => MIME type => array( 'file' => basename, ... ),
415 * as stored in the VARIANTS_META_KEY metadata entry.
416 * @return void
417 */
418 private static function delete_variant_files_from_map( $dir, $variants ) {
419 foreach ( $variants as $size_variants ) {
420 foreach ( (array) $size_variants as $variant ) {
421 if ( empty( $variant['file'] ) ) {
422 continue;
423 }
424 $path = $dir . $variant['file'];
425 if ( file_exists( $path ) ) {
426 wp_delete_file( $path );
427 }
428 }
429 }
430 }
431
432 /**
433 * Same rewrite as filter_content_img_tag(), applied to featured images.
434 * the_post_thumbnail() markup never passes through wp_content_img_tag
435 * (that filter only covers the_content/the_excerpt), so without this,
436 * hero/archive-card featured images would never get modern-format delivery.
437 *
438 * @param string $html Featured image <img> markup.
439 * @param int $post_id Post ID (unused).
440 * @param int $post_thumbnail_id Attachment ID of the featured image.
441 * @return string
442 */
443 public function filter_post_thumbnail_html( $html, $post_id, $post_thumbnail_id ) {
444 return $this->filter_content_img_tag( $html, 'post_thumbnail', $post_thumbnail_id );
445 }
446
447 /**
448 * Serve generated modern-format variants for a content/featured image by
449 * rewriting its src/srcset in place. WebP is used unconditionally when a
450 * variant exists — browser support is effectively universal. AVIF is
451 * only used when the visitor's `Accept` request header confirms support
452 * for it; otherwise delivery falls back to WebP, or to the original
453 * format if no WebP variant exists either.
454 *
455 * @param string $filtered_image Full <img ...> tag markup.
456 * @param string $context Filter context (unused).
457 * @param int $attachment_id Attachment ID.
458 * @return string
459 */
460 public function filter_content_img_tag( $filtered_image, $context, $attachment_id ) {
461 if ( ! $attachment_id ) {
462 return $filtered_image;
463 }
464
465 $metadata = wp_get_attachment_metadata( $attachment_id );
466 $variants = (array) ( $metadata[ self::VARIANTS_META_KEY ] ?? array() );
467
468 if ( empty( $variants ) ) {
469 return $filtered_image;
470 }
471
472 if ( ! preg_match( '/src=["\']([^"\']+)["\']/', $filtered_image, $src_match ) ) {
473 return $filtered_image;
474 }
475
476 $size_key = $this->match_variant_size( $src_match[1], $metadata );
477 if ( null === $size_key || empty( $variants[ $size_key ] ) ) {
478 return $filtered_image;
479 }
480
481 $mime = $this->best_mime_for_request( $variants[ $size_key ] );
482 if ( null === $mime ) {
483 return $filtered_image;
484 }
485
486 $base_url = trailingslashit( dirname( $src_match[1] ) );
487
488 return $this->rewrite_src_in_place( $filtered_image, $variants, $metadata, $base_url, $mime );
489 }
490
491 /**
492 * Pick the best available modern format for the current request: AVIF
493 * only when the visitor's `Accept` header confirms support for it,
494 * WebP otherwise (near-universal support, so no negotiation needed),
495 * or null if the size has no variant in either format.
496 *
497 * @param array $size_variants Map of MIME type => file data for one size.
498 * @return string|null
499 */
500 private function best_mime_for_request( $size_variants ) {
501 if ( ! empty( $size_variants['image/avif']['file'] ) && $this->accepts( 'image/avif' ) ) {
502 return 'image/avif';
503 }
504
505 if ( ! empty( $size_variants['image/webp']['file'] ) ) {
506 return 'image/webp';
507 }
508
509 return null;
510 }
511
512 /**
513 * Whether the current request's `Accept` header explicitly lists the
514 * given MIME type as acceptable. Deliberately doesn't treat a generic
515 * image or wildcard range as a match: those are common on requests that
516 * don't actually confirm AVIF decode support (plain img fetches from
517 * older browsers, non-browser clients, etc.), and this is the one format
518 * worth being conservative about — unlike WebP, which is rewritten
519 * unconditionally.
520 *
521 * @param string $mime MIME type to check for, e.g. 'image/avif'.
522 * @return bool
523 */
524 private function accepts( $mime ) {
525 $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? (string) $_SERVER['HTTP_ACCEPT'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only substring check, never output or used in a query.
526
527 return false !== strpos( $accept, $mime );
528 }
529
530 /**
531 * Rewrite the <img> tag's src and every URL in its srcset to point at
532 * the matching modern-format variant, falling back to leaving a
533 * candidate URL untouched if no variant was generated for its size
534 * (e.g. a size added after this attachment was last converted).
535 *
536 * @param string $image_tag Full <img ...> tag markup.
537 * @param array $variants Full variants map for the attachment (size => mime => file data).
538 * @param array $metadata Attachment metadata, used to resolve each srcset URL's size.
539 * @param string $base_url Directory URL the variant files live in.
540 * @param string $mime Target MIME type to switch to.
541 * @return string
542 */
543 private function rewrite_src_in_place( $image_tag, $variants, $metadata, $base_url, $mime ) {
544 $image_tag = preg_replace_callback(
545 '/\bsrc=(["\'])([^"\']+)\1/',
546 function ( $matches ) use ( $variants, $metadata, $base_url, $mime ) {
547 return 'src=' . $matches[1] . esc_url( $this->variant_url_for( $matches[2], $variants, $metadata, $base_url, $mime ) ) . $matches[1];
548 },
549 $image_tag,
550 1
551 );
552
553 return preg_replace_callback(
554 '/\bsrcset=(["\'])([^"\']+)\1/',
555 function ( $matches ) use ( $variants, $metadata, $base_url, $mime ) {
556 return 'srcset=' . $matches[1] . esc_attr( $this->rewrite_srcset( $matches[2], $variants, $metadata, $base_url, $mime ) ) . $matches[1];
557 },
558 $image_tag,
559 1
560 );
561 }
562
563 /**
564 * Rewrite every "url widthDescriptor" candidate in a srcset attribute
565 * value, swapping each one for its matching modern-format variant.
566 *
567 * @param string $srcset Original srcset attribute value.
568 * @param array $variants Full variants map for the attachment.
569 * @param array $metadata Attachment metadata.
570 * @param string $base_url Directory URL the variant files live in.
571 * @param string $mime Target MIME type to switch to.
572 * @return string
573 */
574 private function rewrite_srcset( $srcset, $variants, $metadata, $base_url, $mime ) {
575 $candidates = array_map( 'trim', explode( ',', $srcset ) );
576
577 foreach ( $candidates as &$candidate ) {
578 if ( ! preg_match( '/^(\S+)(\s+.+)?$/', $candidate, $parts ) ) {
579 continue;
580 }
581 $variant_url = $this->variant_url_for( $parts[1], $variants, $metadata, $base_url, $mime );
582 $candidate = $variant_url . ( $parts[2] ?? '' );
583 }
584
585 return implode( ', ', $candidates );
586 }
587
588 /**
589 * Resolve a single image URL to its matching modern-format variant URL,
590 * or return it unchanged if its size has no variant in the target format.
591 *
592 * @param string $url Original image URL.
593 * @param array $variants Full variants map for the attachment.
594 * @param array $metadata Attachment metadata.
595 * @param string $base_url Directory URL the variant files live in.
596 * @param string $mime Target MIME type to switch to.
597 * @return string
598 */
599 private function variant_url_for( $url, $variants, $metadata, $base_url, $mime ) {
600 $size_key = $this->match_variant_size( $url, $metadata );
601
602 if ( null === $size_key || empty( $variants[ $size_key ][ $mime ]['file'] ) ) {
603 return $url;
604 }
605
606 return $base_url . $variants[ $size_key ][ $mime ]['file'];
607 }
608
609 /**
610 * Determine which registered size (or "full") a content image's src
611 * URL corresponds to, so the matching variant set can be looked up.
612 *
613 * @param string $src The <img> tag's src URL.
614 * @param array $metadata Attachment metadata.
615 * @return string|null
616 */
617 private function match_variant_size( $src, $metadata ) {
618 $basename = basename( wp_parse_url( $src, PHP_URL_PATH ) );
619
620 if ( ! empty( $metadata['sizes'] ) ) {
621 foreach ( $metadata['sizes'] as $size_name => $size_data ) {
622 if ( ! empty( $size_data['file'] ) && $size_data['file'] === $basename ) {
623 return $size_name;
624 }
625 }
626 }
627
628 if ( ! empty( $metadata['file'] ) && basename( $metadata['file'] ) === $basename ) {
629 return 'full';
630 }
631
632 return null;
633 }
634
635 /**
636 * Estimate disk usage per registered size, sampling the most recent
637 * attachments and extrapolating for large libraries.
638 *
639 * @param string[] $size_names Registered size names to estimate.
640 * @return array Map of size name => estimated bytes.
641 */
642 public static function estimate_disk_usage_by_size( $size_names ) {
643 $sample_limit = 200;
644
645 $query = new \WP_Query(
646 array(
647 'post_type' => 'attachment',
648 'post_status' => 'inherit',
649 'post_mime_type' => 'image',
650 'posts_per_page' => $sample_limit,
651 'orderby' => 'ID',
652 'order' => 'DESC',
653 'fields' => 'ids',
654 'no_found_rows' => false,
655 )
656 );
657
658 $sample_count = count( $query->posts );
659 $totals = array_fill_keys( $size_names, 0 );
660
661 if ( 0 === $sample_count ) {
662 return $totals;
663 }
664
665 foreach ( $query->posts as $attachment_id ) {
666 $metadata = wp_get_attachment_metadata( $attachment_id );
667 $file = get_attached_file( $attachment_id );
668 if ( ! $file || empty( $metadata['sizes'] ) ) {
669 continue;
670 }
671 $dir = trailingslashit( dirname( $file ) );
672
673 foreach ( $size_names as $size_name ) {
674 if ( empty( $metadata['sizes'][ $size_name ]['file'] ) ) {
675 continue;
676 }
677 $size_path = $dir . $metadata['sizes'][ $size_name ]['file'];
678 if ( file_exists( $size_path ) ) {
679 $totals[ $size_name ] += filesize( $size_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_filesize -- local media library file, not a remote/user-controlled path.
680 }
681 }
682 }
683
684 $scale = $query->found_posts > $sample_count && $sample_count > 0 ? $query->found_posts / $sample_count : 1;
685
686 foreach ( $totals as $size_name => $bytes ) {
687 $totals[ $size_name ] = (int) round( $bytes * $scale );
688 }
689
690 return $totals;
691 }
692 }
693