From d65f11332667684756880d952f137986f453fc88 Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Mon, 12 Oct 2026 13:01:18 +0200 Subject: [PATCH 1/5] fix(sync): auto-regenerate thumbnail sizes left stale after an image edit Editing an image via the WP image editor can rename the main file without regenerating its registered thumbnail sizes, leaving `sizes` metadata pointing at filenames that no longer exist. This causes Delivery::find_attachment_size_urls() to run an uncached wp_postmeta lookup on every render of that attachment, since nothing resolves the stale URLs. Adds a `sizes` sync type (mirroring the existing `edit` type) that detects this drift on render via the existing realtime sync pipeline and regenerates the attachment metadata, self-healing both new edits and already-drifted attachments without any new cron job or bulk scan. --- php/class-media.php | 30 ++++++++++++++++++++++++++++++ php/class-sync.php | 27 +++++++++++++++++++++++++++ php/sync/class-upload-sync.php | 25 +++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/php/class-media.php b/php/class-media.php index cf4a3370b..3cd4acf78 100644 --- a/php/class-media.php +++ b/php/class-media.php @@ -1693,6 +1693,36 @@ public function has_public_id( $attachment_id ) { return ! empty( $new_id ) || ! empty( $id ); } + /** + * Check if an attachment's registered sizes reference filenames that no longer match the current attached file. + * + * This happens when an image is edited (e.g. cropped) via the WordPress image editor: the main file gets + * renamed (an `-e{timestamp}` suffix is added), but the `sizes` metadata can be left pointing at the + * pre-edit filenames, leaving no local file matching that size record. + * + * @param int $attachment_id The Attachment ID. + * + * @return bool + */ + public function has_outdated_sizes( $attachment_id ) { + $metadata = wp_get_attachment_metadata( $attachment_id ); + if ( empty( $metadata['file'] ) || empty( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) ) { + return false; + } + + $base = wp_basename( $metadata['file'] ); + $ext = pathinfo( $base, PATHINFO_EXTENSION ); + $base = ! empty( $ext ) ? substr( $base, 0, - ( strlen( $ext ) + 1 ) ) : $base; + + foreach ( $metadata['sizes'] as $size ) { + if ( empty( $size['file'] ) || 0 !== strpos( $size['file'], $base . '-' ) ) { + return true; + } + } + + return false; + } + /** * Get a Cloudinary ID which includes the file format extension. * diff --git a/php/class-sync.php b/php/class-sync.php index 30e73015d..e9bcd7d5e 100644 --- a/php/class-sync.php +++ b/php/class-sync.php @@ -568,6 +568,18 @@ public function setup_sync_base_struct() { 'required' => false, // Required to complete URL render flag. 'realtime' => true, ), + 'sizes' => array( + 'generate' => array( $this, 'generate_sizes_signature' ), + 'priority' => 5.25, + 'sync' => array( $this->managers['upload'], 'regenerate_sizes' ), + 'validate' => function ( $attachment_id ) { + return $this->managers['media']->has_outdated_sizes( $attachment_id ); + }, + 'state' => 'info syncing', + 'note' => __( 'Regenerating thumbnail sizes', 'cloudinary' ), + 'required' => false, + 'realtime' => true, + ), 'folder' => array( 'generate' => array( $this->managers['media'], 'get_cloudinary_folder' ), 'validate' => array( $this->managers['media'], 'is_folder_synced' ), @@ -1389,4 +1401,19 @@ public function generate_edit_signature( $attachment_id ) { return $str; } + + /** + * Generate a signature for an attachment's thumbnail sizes. + * + * @param int $attachment_id The attachment ID. + * + * @return string + */ + public function generate_sizes_signature( $attachment_id ) { + $metadata = wp_get_attachment_metadata( $attachment_id ); + $file = ! empty( $metadata['file'] ) ? wp_basename( $metadata['file'] ) : ''; + $sizes = ! empty( $metadata['sizes'] ) ? wp_json_encode( $metadata['sizes'] ) : ''; + + return $file . $sizes; + } } diff --git a/php/sync/class-upload-sync.php b/php/sync/class-upload-sync.php index aa8b7f9de..2c81adfe5 100644 --- a/php/sync/class-upload-sync.php +++ b/php/sync/class-upload-sync.php @@ -277,6 +277,31 @@ public function edit_upload( $attachment_id ) { return $this->upload_asset( $attachment_id, 'edit' ); } + /** + * Regenerate an attachment's thumbnail sizes when they no longer match the current attached file. + * + * @param int $attachment_id The attachment ID. + * + * @return array|\WP_Error + */ + public function regenerate_sizes( $attachment_id ) { + if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) { + require_once ABSPATH . 'wp-admin/includes/image.php'; + } + + $file = get_attached_file( $attachment_id ); + $metadata = wp_generate_attachment_metadata( $attachment_id, $file ); + if ( empty( $metadata ) ) { + return new \WP_Error( 'cloudinary_regenerate_sizes_failed', __( 'Could not regenerate attachment sizes.', 'cloudinary' ) ); + } + + wp_update_attachment_metadata( $attachment_id, $metadata ); + // Persist the new signature, otherwise this would re-trigger on every subsequent render. + $this->sync->set_signature_item( $attachment_id, 'sizes' ); + + return $metadata; + } + /** * Upload an asset to Cloudinary. * From 25bee78a9c449d1f6137e48975a5402a9c79a2dd Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Mon, 12 Oct 2026 13:13:57 +0200 Subject: [PATCH 2/5] fix(sync): drop redundant is_array check flagged by PHPStan wp_get_attachment_metadata()'s PHPDoc already types 'sizes' as array, making the is_array() check dead code; empty() already covers the missing-key and empty-array cases. --- php/class-media.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php/class-media.php b/php/class-media.php index 3cd4acf78..1064c15c2 100644 --- a/php/class-media.php +++ b/php/class-media.php @@ -1706,7 +1706,7 @@ public function has_public_id( $attachment_id ) { */ public function has_outdated_sizes( $attachment_id ) { $metadata = wp_get_attachment_metadata( $attachment_id ); - if ( empty( $metadata['file'] ) || empty( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) ) { + if ( empty( $metadata['file'] ) || empty( $metadata['sizes'] ) ) { return false; } From 41a37424bf4f5ca1d59f7924efd40873f20d8b64 Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Mon, 19 Oct 2026 12:36:26 +0200 Subject: [PATCH 3/5] fix(sync): address review feedback on thumbnail-size drift detection - Media::has_outdated_sizes() now checks that each registered size's file actually exists on disk, instead of comparing filename prefixes. A prefix check misflags valid WordPress states as broken -- e.g. editing with "all sizes except thumbnail" deliberately keeps the old thumbnail, and a size too small to regenerate from the edited image is legitimately left as-is. Both would have been wrongly overwritten. - Upload_Sync::regenerate_sizes() now preserves the rest of the attachment's metadata record instead of replacing it wholesale. wp_generate_attachment_metadata() writes its own metadata to the database as a side effect partway through execution, so "current" metadata must be captured before calling it, not after, or the capture already sees the clobbered record. Without this, keys like original_image (breaks wp_get_original_image_path()) and third-party plugin data were silently dropped. Reported in PR review (https://github.com/cloudinary/cloudinary_wordpress/pull/1314). --- php/class-media.php | 13 ++++++++----- php/sync/class-upload-sync.php | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/php/class-media.php b/php/class-media.php index 1064c15c2..b7e51dd15 100644 --- a/php/class-media.php +++ b/php/class-media.php @@ -1694,12 +1694,17 @@ public function has_public_id( $attachment_id ) { } /** - * Check if an attachment's registered sizes reference filenames that no longer match the current attached file. + * Check if an attachment's registered sizes reference files that no longer exist on disk. * * This happens when an image is edited (e.g. cropped) via the WordPress image editor: the main file gets * renamed (an `-e{timestamp}` suffix is added), but the `sizes` metadata can be left pointing at the * pre-edit filenames, leaving no local file matching that size record. * + * This intentionally checks file existence rather than filename prefix: WordPress core has valid states + * where a size's filename legitimately keeps the pre-edit base (e.g. editing with "all sizes except + * thumbnail" deliberately preserves the thumbnail, or a size too small to regenerate from the edited + * image is left as-is). A prefix-only check would misflag those as broken and overwrite valid files. + * * @param int $attachment_id The Attachment ID. * * @return bool @@ -1710,12 +1715,10 @@ public function has_outdated_sizes( $attachment_id ) { return false; } - $base = wp_basename( $metadata['file'] ); - $ext = pathinfo( $base, PATHINFO_EXTENSION ); - $base = ! empty( $ext ) ? substr( $base, 0, - ( strlen( $ext ) + 1 ) ) : $base; + $dir = dirname( get_attached_file( $attachment_id ) ); foreach ( $metadata['sizes'] as $size ) { - if ( empty( $size['file'] ) || 0 !== strpos( $size['file'], $base . '-' ) ) { + if ( empty( $size['file'] ) || ! file_exists( path_join( $dir, $size['file'] ) ) ) { return true; } } diff --git a/php/sync/class-upload-sync.php b/php/sync/class-upload-sync.php index 2c81adfe5..440f58c9e 100644 --- a/php/sync/class-upload-sync.php +++ b/php/sync/class-upload-sync.php @@ -289,12 +289,21 @@ public function regenerate_sizes( $attachment_id ) { require_once ABSPATH . 'wp-admin/includes/image.php'; } - $file = get_attached_file( $attachment_id ); - $metadata = wp_generate_attachment_metadata( $attachment_id, $file ); - if ( empty( $metadata ) ) { + // Captured before wp_generate_attachment_metadata(), which writes its own (incomplete) metadata + // to the database as a side effect while it runs -- reading "current" metadata after that call + // would already return the clobbered record. + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $file = get_attached_file( $attachment_id ); + $regenerated = wp_generate_attachment_metadata( $attachment_id, $file ); + if ( empty( $regenerated['sizes'] ) ) { return new \WP_Error( 'cloudinary_regenerate_sizes_failed', __( 'Could not regenerate attachment sizes.', 'cloudinary' ) ); } + // wp_generate_attachment_metadata() builds a fresh metadata array, so only merge in the + // regenerated sizes rather than overwriting the whole record -- other keys (e.g. original_image, + // third-party plugin data) must be preserved. + $metadata['sizes'] = $regenerated['sizes']; wp_update_attachment_metadata( $attachment_id, $metadata ); // Persist the new signature, otherwise this would re-trigger on every subsequent render. $this->sync->set_signature_item( $attachment_id, 'sizes' ); From b1f6322a001e2cfa33847d83df17e981bf446896 Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Tue, 20 Oct 2026 09:25:43 +0200 Subject: [PATCH 4/5] fix(sync): address second round of review feedback on size regeneration - Media::has_outdated_sizes() now also bails out when the attachment's main file doesn't exist on disk, not just when individual size files are missing. This covers cloud-only ('cld') offload mode, which intentionally deletes every local file without updating the stored metadata -- previously every such attachment was flagged as outdated on every render, and the resulting regeneration failure set the attachment's global sync-error meta, blocking all other sync types for it. - Upload_Sync::regenerate_sizes() now uses WP core's low-level _wp_make_subsizes() instead of wp_generate_attachment_metadata(), and only regenerates the specific sizes whose backing file is actually missing. Two problems this fixes together: - wp_generate_attachment_metadata() always rebuilds the full sizes set from the current (edited) image, which could silently overwrite a size a user deliberately kept (e.g. editing with "all sizes except thumbnail" intentionally preserves the old thumbnail). - wp_generate_attachment_metadata() also writes its own metadata to the database as a side effect partway through its own execution; _wp_make_subsizes() builds on the metadata array passed into it instead of a fresh one, so there's no clobber-then-restore step needed to keep keys like original_image or third-party plugin data intact. Reported in PR review (https://github.com/cloudinary/cloudinary_wordpress/pull/1314). --- php/class-media.php | 9 +++++-- php/sync/class-upload-sync.php | 47 ++++++++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/php/class-media.php b/php/class-media.php index b7e51dd15..0fe10b6ca 100644 --- a/php/class-media.php +++ b/php/class-media.php @@ -1711,11 +1711,16 @@ public function has_public_id( $attachment_id ) { */ public function has_outdated_sizes( $attachment_id ) { $metadata = wp_get_attachment_metadata( $attachment_id ); - if ( empty( $metadata['file'] ) || empty( $metadata['sizes'] ) ) { + $path = get_attached_file( $attachment_id ); + + // Also covers cloud-only storage mode, which intentionally removes every local file (see + // Storage::remove_local_assets()) without updating the stored metadata: if the main file itself + // is gone, there is nothing to regenerate from, so this is not "outdated", it's expected. + if ( empty( $metadata['file'] ) || empty( $metadata['sizes'] ) || empty( $path ) || ! file_exists( $path ) ) { return false; } - $dir = dirname( get_attached_file( $attachment_id ) ); + $dir = dirname( $path ); foreach ( $metadata['sizes'] as $size ) { if ( empty( $size['file'] ) || ! file_exists( path_join( $dir, $size['file'] ) ) ) { diff --git a/php/sync/class-upload-sync.php b/php/sync/class-upload-sync.php index 440f58c9e..f92a81e37 100644 --- a/php/sync/class-upload-sync.php +++ b/php/sync/class-upload-sync.php @@ -285,25 +285,50 @@ public function edit_upload( $attachment_id ) { * @return array|\WP_Error */ public function regenerate_sizes( $attachment_id ) { - if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) { + if ( ! function_exists( '_wp_make_subsizes' ) ) { require_once ABSPATH . 'wp-admin/includes/image.php'; } - // Captured before wp_generate_attachment_metadata(), which writes its own (incomplete) metadata - // to the database as a side effect while it runs -- reading "current" metadata after that call - // would already return the clobbered record. $metadata = wp_get_attachment_metadata( $attachment_id ); + $file = get_attached_file( $attachment_id ); - $file = get_attached_file( $attachment_id ); - $regenerated = wp_generate_attachment_metadata( $attachment_id, $file ); - if ( empty( $regenerated['sizes'] ) ) { + if ( empty( $metadata['sizes'] ) || empty( $file ) || ! file_exists( $file ) ) { return new \WP_Error( 'cloudinary_regenerate_sizes_failed', __( 'Could not regenerate attachment sizes.', 'cloudinary' ) ); } - // wp_generate_attachment_metadata() builds a fresh metadata array, so only merge in the - // regenerated sizes rather than overwriting the whole record -- other keys (e.g. original_image, - // third-party plugin data) must be preserved. - $metadata['sizes'] = $regenerated['sizes']; + // Only the sizes whose backing file is actually missing get regenerated -- every other size, + // including one a user deliberately kept (e.g. editing with "all sizes except thumbnail"), is + // left completely untouched. The missing entries are removed from $metadata first so + // _wp_make_subsizes() treats them as new sizes to create rather than skipping them as already + // present (it only checks whether the size *name* already has an entry, not whether the file + // behind it still exists). + $dir = dirname( $file ); + $missing = array(); + foreach ( $metadata['sizes'] as $size_name => $size_data ) { + if ( ! empty( $size_data['file'] ) && file_exists( path_join( $dir, $size_data['file'] ) ) ) { + continue; + } + unset( $metadata['sizes'][ $size_name ] ); + $missing[ $size_name ] = true; + } + + if ( empty( $missing ) ) { + return $metadata; + } + + $registered = wp_get_registered_image_subsizes(); + /** This filter is documented in wp-admin/includes/image.php */ + $registered = apply_filters( 'intermediate_image_sizes_advanced', $registered, $metadata, $attachment_id ); + $new_sizes = array_intersect_key( $registered, $missing ); + + // _wp_make_subsizes() is WP core's own low-level size-creation step (used internally by + // wp_create_image_subsizes()). Unlike wp_generate_attachment_metadata(), it builds on the + // $metadata we pass in rather than a fresh array, so unrelated keys (original_image, + // third-party plugin data) are never at risk of being dropped. It also silently leaves a size + // out of the result if the image is too small for it, rather than erroring, so that case never + // reaches the sync-error path below. + $metadata = _wp_make_subsizes( $new_sizes, $file, $metadata, $attachment_id ); + wp_update_attachment_metadata( $attachment_id, $metadata ); // Persist the new signature, otherwise this would re-trigger on every subsequent render. $this->sync->set_signature_item( $attachment_id, 'sizes' ); From 37cb590909e97783f268abe65d23b39f542af16e Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Tue, 20 Oct 2026 15:39:40 +0200 Subject: [PATCH 5/5] fix(sync): surface genuine size-regeneration failures instead of silently dropping them _wp_make_subsizes() never returns a WP_Error -- a size it can't create is just left out of the result, whether that's because the image is legitimately too small for it (expected) or because of a real, possibly transient failure (disk/permissions/memory/missing image editor). Since the missing entries were already unset before calling it, an unreported real failure was being saved as a success and the signature persisted, so it would never be retried. Distinguish the two with image_resize_dimensions() -- the same check core itself uses to decide whether a size applies to a given image. A size that could have been made but wasn't is now treated as a real failure: the original metadata is restored (_wp_make_subsizes() saves progress to the database after each size it does make, so a partial failure can leave a partial record there) and a WP_Error is returned, so the normal retry path picks it up again on the next render. Reported in PR review (https://github.com/cloudinary/cloudinary_wordpress/pull/1314). --- php/sync/class-upload-sync.php | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/php/sync/class-upload-sync.php b/php/sync/class-upload-sync.php index f92a81e37..1c690cc74 100644 --- a/php/sync/class-upload-sync.php +++ b/php/sync/class-upload-sync.php @@ -302,8 +302,9 @@ public function regenerate_sizes( $attachment_id ) { // _wp_make_subsizes() treats them as new sizes to create rather than skipping them as already // present (it only checks whether the size *name* already has an entry, not whether the file // behind it still exists). - $dir = dirname( $file ); - $missing = array(); + $original = $metadata; + $dir = dirname( $file ); + $missing = array(); foreach ( $metadata['sizes'] as $size_name => $size_data ) { if ( ! empty( $size_data['file'] ) && file_exists( path_join( $dir, $size_data['file'] ) ) ) { continue; @@ -324,11 +325,34 @@ public function regenerate_sizes( $attachment_id ) { // _wp_make_subsizes() is WP core's own low-level size-creation step (used internally by // wp_create_image_subsizes()). Unlike wp_generate_attachment_metadata(), it builds on the // $metadata we pass in rather than a fresh array, so unrelated keys (original_image, - // third-party plugin data) are never at risk of being dropped. It also silently leaves a size - // out of the result if the image is too small for it, rather than erroring, so that case never - // reaches the sync-error path below. + // third-party plugin data) are never at risk of being dropped. $metadata = _wp_make_subsizes( $new_sizes, $file, $metadata, $attachment_id ); + // _wp_make_subsizes() never returns a WP_Error: a size it couldn't create is just left out of + // the result, silently, whether that's because the image is legitimately too small for it (fine, + // expected) or because of a real, possibly transient failure (disk/permissions/memory/missing + // image editor). Since the "missing" entries were already unset above, an unreported real failure + // would otherwise be saved as if it were a success and never retried. Tell the two apart with + // image_resize_dimensions() -- the same check core itself uses to decide whether a size applies. + $failed = array(); + foreach ( $new_sizes as $size_name => $size_data ) { + if ( isset( $metadata['sizes'][ $size_name ] ) ) { + continue; + } + if ( ! image_resize_dimensions( $metadata['width'], $metadata['height'], $size_data['width'], $size_data['height'], $size_data['crop'] ) ) { + continue; // Too small for this size: leaving it out is expected. + } + $failed[] = $size_name; + } + + if ( ! empty( $failed ) ) { + // _wp_make_subsizes() saves metadata to the database after each size it successfully makes, + // so a partial failure may have already left a partial record there; restore the original. + wp_update_attachment_metadata( $attachment_id, $original ); + + return new \WP_Error( 'cloudinary_regenerate_sizes_failed', __( 'Could not regenerate attachment sizes.', 'cloudinary' ) ); + } + wp_update_attachment_metadata( $attachment_id, $metadata ); // Persist the new signature, otherwise this would re-trigger on every subsequent render. $this->sync->set_signature_item( $attachment_id, 'sizes' );