diff --git a/php/class-media.php b/php/class-media.php index cf4a3370b..0fe10b6ca 100644 --- a/php/class-media.php +++ b/php/class-media.php @@ -1693,6 +1693,44 @@ public function has_public_id( $attachment_id ) { return ! empty( $new_id ) || ! empty( $id ); } + /** + * 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 + */ + public function has_outdated_sizes( $attachment_id ) { + $metadata = wp_get_attachment_metadata( $attachment_id ); + $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( $path ); + + foreach ( $metadata['sizes'] as $size ) { + if ( empty( $size['file'] ) || ! file_exists( path_join( $dir, $size['file'] ) ) ) { + 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..1c690cc74 100644 --- a/php/sync/class-upload-sync.php +++ b/php/sync/class-upload-sync.php @@ -277,6 +277,89 @@ 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_make_subsizes' ) ) { + require_once ABSPATH . 'wp-admin/includes/image.php'; + } + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $file = get_attached_file( $attachment_id ); + + if ( empty( $metadata['sizes'] ) || empty( $file ) || ! file_exists( $file ) ) { + return new \WP_Error( 'cloudinary_regenerate_sizes_failed', __( 'Could not regenerate attachment sizes.', 'cloudinary' ) ); + } + + // 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). + $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; + } + 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. + $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' ); + + return $metadata; + } + /** * Upload an asset to Cloudinary. *