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/2] 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/2] 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; }