Make WordPress Core

Opened 7 weeks ago

Closed 5 weeks ago

#65708 closed defect (bug) (fixed)

Media: HEIC to JPG conversion can create an orphaned full size JPG file and incorrectly versioned files

Reported by: ianmjones Owned by: adamsilverstein
Priority: normal Milestone: 7.1
Component: Media Version:
Severity: normal Keywords: has-patch has-unit-tests commit
Cc: Focuses:

Description

When adding a large HEIC image to an Image block, the client side conversion can result in a "-scaled.jpg" file as the "original_image", but also "-scaled-1.jpg" as the "file", along with the thumbnails all having the "-1" suffix.

Along with this, a JPG with the same base file name as the HEIC is created, but not added to the "_wp_attachment_metadata" options record, meaning it is effectively orphaned.

When you "Delete Permanently" the Media Library item, the JPG without "-scaled" is left on the server.

This can be demonstrated by adding the "shelf-christmas-decoration.heic" image from https://heic.digital/samples/ to an Image block.

I'll attached some screenshots that show the metadata, generated files, and orphaned file after delete of the Media Library item.

Attachments (3)

attachment-metadata.png (288.7 KB ) - added by ianmjones 7 weeks ago.
attachments-files-on-disk.png (284.4 KB ) - added by ianmjones 7 weeks ago.
orphaned-file-after-media-library-item-deleted.png (60.2 KB ) - added by ianmjones 7 weeks ago.

Download all attachments as: .zip

Change History (11)

#1 @ianmjones
7 weeks ago

I'm guessing that what should happen for this example, is ...

  • shelf-christmas-decoration.heic converted to shelf-christmas-decoration.jpg
  • shelf-christmas-decoration.jpg scaled to shelf-christmas-decoration-scaled.jpg
  • Thumbnails generated with shelf-christmas-decoration-WxH.jpg name format.

Giving ...

source_image: shelf-christmas-decoration.heic
original_image: shelf-christmas-decoration.jpg
file: 2026/07/shelf-christmas-decoration-scaled.jpg
thumbnail: shelf-christmas-decoration-150x150.jpg

Etc.

This ticket was mentioned in PR #12689 on WordPress/wordpress-develop by @khokansardar.


7 weeks ago
#2

  • Keywords has-patch has-unit-tests added

When an attachment is uploaded with generate_sub_sizes set to false, the client generates all derivatives itself — including the scaled full-size image it later supplies through the sideload endpoint. create_item() already suppressed thumbnail generation and EXIF rotation in this case, but not the "big image" downscaling gated by the big_image_size_threshold filter.

What the problem was:

  • A large client-converted image (e.g. a HEIC converted to JPEG in the browser) was still scaled server-side to a -scaled file, which became the attached file while the untouched upload was recorded as original_image.
  • The client's subsequent scaled sideload then collided with that -scaled file and was renamed -scaled-1; the thumbnails inherited the numbered name.
  • The server-generated full-size file was left orphaned on disk and remained after the Media Library item was permanently deleted.

What the fix does:

  • Disables big_image_size_threshold alongside the existing client-side processing filters in create_item(), and removes it again in remove_client_side_media_processing_filters().
  • The uploaded full-size image is stored untouched, so the client's scaled sideload records it as original_image and keeps the plain -scaled name.

Approach and why:

  • generate_sub_sizes = false is the contract that the client owns all derivative generation, which includes the scaled full-size image. Server-side scaling in that mode duplicates the client's work and causes the collision. Suppressing it is the minimal, consistent change — it sits directly beside the three filters already applied for the same purpose.

Trac ticket: https://core.trac.wordpress.org/ticket/65708

## Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Ticket analysis, code implementation, and tests. All changes were reviewed and validated by me.

#3 @adamsilverstein
6 weeks ago

  • Owner set to adamsilverstein
  • Status newassigned

Thanks for the detailed test and bug report @ianmjones - I'll take a look. This probably needs a fix in Gutenberg as well as core.

@adamsilverstein commented on PR #12689:


6 weeks ago
#4

Thanks for the PR @itzmekhokan - I'll take a look.

#5 @adamsilverstein
6 weeks ago

  • Keywords commit added
  • Milestone Awaiting Review7.1

I dug into this and can confirm the diagnosis, plus the reason it shows up on Core but not with the Gutenberg plugin active.

When the client handles media processing it uploads the full-size image with generate_sub_sizes set to false, which is the contract that says the client owns every derivative - including the scaled-down full-size copy it sideloads afterwards. WP_REST_Attachments_Controller::create_item() already suppresses sub-size generation and EXIF rotation for that case, but not the "big image" downscaling in wp_create_image_subsizes(). So the server scales the upload anyway, and then:

  • the server's -scaled file becomes file and the untouched upload becomes original_image
  • the client's own scaled sideload collides with that name and lands as -scaled-1
  • original_image is rewritten to point at the server's -scaled file, so the plain .jpg is no longer referenced by the metadata and is orphaned on disk
  • the sub-sizes inherit the numbered name, because filter_wp_unique_filename() only strips the numeric suffix when the un-suffixed name is free, and it is not

That last one is the mechanism behind the -1 thumbnails you saw @ianmjones.

The Gutenberg plugin has suppressed this since gutenberg#75817, which is why the bug only reproduces on Core. So this is really Core catching up to the plugin.

@itzmekhokan's patch in PR 12689 does exactly that, and I verified it locally - the test fails on trunk and passes with the fix, and the full WP_Test_REST_Attachments_Controller suite stays green. I have approved it and suggested one extra test covering the two symptoms the current test does not: the numbered sub-size names and the orphaned file surviving "Delete Permanently".

I opened gutenberg#81061 alongside it. The plugin's fix was untested (every existing test in this area used an image below the threshold), so that PR adds PHP coverage plus an end-to-end test that uploads a large image in the editor and asserts the exact file names. With the plugin's filter removed the e2e test fails on -scaled-1.jpeg, so it will catch a regression here in either project.

Worth noting for anyone testing: this is not HEIC specific. Any image over the threshold hits it once the client owns the sub-sizes, so a large JPEG reproduces it too and is easier to test with, since Chromium has no native HEIC decode.

Expected result after the fix, matching what you laid out in comment:1:

source_image:    shelf-christmas-decoration.heic
original_image:  shelf-christmas-decoration.jpg
file:            2026/07/shelf-christmas-decoration-scaled.jpg
thumbnail:       shelf-christmas-decoration-150x150.jpg

@adamsilverstein commented on PR #12689:


6 weeks ago
#6

Adding testing steps for anyone picking this up.

[[Image(https://img.shields.io/badge/Test_in-WP_Playground-blue?style=for-the-badge&logo=wordpress)]]

Note that this one needs client-side media processing to be active, which means Chrome 137+ and a cross-origin isolated page. I have not confirmed Playground gives you that, so a local build is the reliable way to see it.

Automated, fails on trunk and passes with the patch:

npm run test:php -- --filter test_create_item_skips_big_image_scaling_when_client_generates_sub_sizes

Manually, with the site on https or localhost so client-side processing kicks in:

  1. Add an Image block and upload a JPEG larger than 2560px on its longest side.
  2. Check the metadata: wp post meta get <id> _wp_attachment_metadata.
  3. Before the patch, file is <name>-scaled-1.jpg, original_image is <name>-scaled.jpg, and the sub-sizes are <name>-WxH-1.jpg. After the patch, file is <name>-scaled.jpg, original_image is <name>.jpg, and the sub-sizes are <name>-WxH.jpg.
  4. Delete the attachment permanently and look in wp-content/uploads. Before the patch <name>.jpg is left behind; after, nothing is.

Step 4 is the orphan @ianmjones reported. Step 3 is worth eyeballing in the Media Library too, since the -1 names are what a user actually notices.

The ticket describes this with a HEIC upload but it is not HEIC specific - any image over the threshold hits it once the client owns the sub-sizes, so a large JPEG is easier to test with.

@khokansardar commented on PR #12689:


6 weeks ago
#7

Thanks for the review @adamsilverstein - I have lifted your test, adapted to the Core test class (enable_client_side_media_processing(), 33772.jpg with the threshold forced to 1000, self::$test_file for the scaled sideload). Pushed in 292c57879d.

I checked each symptom lands on its own by temporarily relaxing the earlier assertions: on trunk the thumbnail comes back as big-photo-150x150-1.jpg, the flow writes four files instead of three (big-photo-150x150-1.jpg, big-photo-scaled-1.jpg, big-photo-scaled.jpg, big-photo.jpg), and big-photo.jpg is still on disk after wp_delete_attachment( $id, true ). All three pass with the patch, and the full WP_Test_REST_Attachments_Controller suite is green at 190 tests.

Good catch on the incomplete backport from #11015 - that explains why the sideload half was already in place.

#8 @adamsilverstein
5 weeks ago

  • Resolutionfixed
  • Status assignedclosed

In 63014:

Media: Skip server-side image scaling during client-side media processing.

Disable the big_image_size_threshold filter alongside the existing client-side processing filters so the upload is stored untouched. The client's scaled sideload then keeps the plain -scaled name and records the untouched upload as original_image. Uploads that leave generate_sub_sizes enabled are unaffected.

Props khokansardar, ianmjones.
Fixes #65708.

Note: See TracTickets for help on using tickets.