Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions php/class-media.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
27 changes: 27 additions & 0 deletions php/class-sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand Down Expand Up @@ -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;
}
}
59 changes: 59 additions & 0 deletions php/sync/class-upload-sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,65 @@ 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).
$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 );
Comment thread
utkarshcloudinary marked this conversation as resolved.
// Persist the new signature, otherwise this would re-trigger on every subsequent render.
$this->sync->set_signature_item( $attachment_id, 'sizes' );
Comment on lines +330 to +334

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: A regeneration failure is saved as a success here, and it cannot be retried.

_wp_make_subsizes() does not give back a WP_Error. If wp_get_image_editor() fails, it returns $image_meta without changes. If make_subsize() fails for one size, that size is skipped (// TODO: Log errors.). Some causes: the uploads dir cannot be written to for a short time, there is no memory, or the image editor is missing.

The missing entries were already unset at L311, so in these cases:

  • L332 saves metadata without those sizes.
  • L334 saves the signature, so the sync shows as a success.
  • On the next request, has_outdated_sizes() finds no size records that are missing, so it never tries again.

The comment at L327-329 says that a size is left out only when the image is too small for it. That is one cause, but not the only one.

Suggestion: after the call, check each requested size. Keep the entries that failed, and return an error if a size that the image could have had was not made:

$original = $metadata; // before the unset loop
...
$metadata = _wp_make_subsizes( $new_sizes, $file, $metadata, $attachment_id );

$failed = array();
foreach ( $new_sizes as $size_name => $size_data ) {
	if ( isset( $metadata['sizes'][ $size_name ] ) ) {
		continue;
	}
	// Too small for this size: leaving it out is expected.
	if ( ! image_resize_dimensions( $metadata['width'], $metadata['height'], $size_data['width'], $size_data['height'], $size_data['crop'] ) ) {
		continue;
	}
	$failed[] = $size_name;
}

if ( $failed ) {
	wp_update_attachment_metadata( $attachment_id, $original );
	return new \WP_Error( 'cloudinary_regenerate_sizes_failed', ... );
}

Note: _wp_make_subsizes() calls wp_update_attachment_metadata() after each size it makes. So if this fails part of the way, the database already has a partial record. That is why we must restore $original explicitly, and not only return.


return $metadata;
}

/**
* Upload an asset to Cloudinary.
*
Expand Down
Loading