Skip to content
Closed
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
bf9f644
Media: Allow HEIC/HEIF uploads when server lacks support
adamsilverstein Mar 20, 2026
71a9e8b
Merge remote-tracking branch 'origin/trunk' into add/heic-canvas-fall…
adamsilverstein Apr 22, 2026
630266f
Merge remote-tracking branch 'origin/trunk' into add/heic-canvas-fall…
adamsilverstein May 28, 2026
b261f30
REST API: Add HEIC client-side support to the sideload route.
adamsilverstein May 28, 2026
c711280
Media: Delete HEIC companion file when its attachment is deleted.
adamsilverstein May 28, 2026
b20bbca
Tests: Cover the HEIC client-side sideload and companion-delete flow.
adamsilverstein May 28, 2026
eea07d2
Tests: Use HEIC fixture and convert_format=false for original-heic si…
adamsilverstein May 28, 2026
d976d2e
Tests: Refresh wp-api-generated.js fixture for the sideload route.
adamsilverstein May 28, 2026
d01bab6
Add void return type hints
westonruter May 28, 2026
f728cfb
Use non-deprecated factory
westonruter May 28, 2026
d6f69d6
Add assertions for successful attachment creation
westonruter May 28, 2026
beb9ffe
Add types and type assertions to tests
westonruter May 28, 2026
9b6b768
Update src/wp-includes/media.php
adamsilverstein May 28, 2026
7f976bb
REST API: Address review feedback on HEIC client-side sideload support.
adamsilverstein May 28, 2026
df33a07
Tests: Sync wp-api-generated.js fixture after dropping sideload gener…
adamsilverstein May 28, 2026
c3c02f9
Apply suggestions from code review
adamsilverstein Jun 18, 2026
ef1767d
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Jun 18, 2026
a07580e
Fix Squiz.WhiteSpace.SuperfluousWhitespace.EndLine
westonruter Jun 18, 2026
9f40ed4
Incorporate file.php phpstan-return types from https://github.com/Wor…
westonruter Jun 18, 2026
b11a774
Add return types for wp_get_upload_dir() and wp_upload_dir()
westonruter Jun 18, 2026
18cc9e3
Add types to get_file_params()/set_file_params() in WP_REST_Request
westonruter Jun 18, 2026
509309f
Add return type for WP_REST_Attachments_Controller::upload_from_data(…
westonruter Jun 18, 2026
546d470
Add return type definition for wp_get_attachment_metadata()
westonruter Jun 19, 2026
08b4fa5
Add source_image to possible keys returned by wp_get_attachment_metad…
westonruter Jun 19, 2026
46bccdb
Add phpstan/phpstan-phpunit for PHPUnit assertion type narrowing
westonruter Jun 19, 2026
6dfdba3
Combine sideload route tests to avoid having to re-assert shape
westonruter Jun 19, 2026
5b2b81b
Add assertions that response is array with id key
westonruter Jun 19, 2026
1e42037
Restore return description
westonruter Jun 19, 2026
707326c
Add missing nullable original_image to metadata array shape
westonruter Jun 19, 2026
1b7c752
Indicate attachment metadata shape is unsealed (since filterable)
westonruter Jun 19, 2026
be9dd94
Fix unsealed array syntax
westonruter Jun 19, 2026
24676d1
Make wp_get_attachment_metadata() return shape reflect all attachment…
westonruter Jun 19, 2026
e26b99d
Tests: Read sideload route args from the endpoint, not the endpoint i…
adamsilverstein Jun 19, 2026
5f0c396
REST API: Extract PHPStan type-coverage changes to a separate PR.
adamsilverstein Jun 25, 2026
4ff3b5b
Merge branch 'trunk' into add/heic-canvas-fallback
adamsilverstein Jun 25, 2026
2c2c1be
Media: Use block comment style for multi-line explanatory comments
adamsilverstein Jun 29, 2026
234d252
Media: Fold source_image companion cleanup into wp_delete_attachment_…
adamsilverstein Jun 29, 2026
16a014e
Media: Limit the HEIC/HEIF upload bypass to still images, not sequences.
adamsilverstein Jun 29, 2026
1f1b267
Merge branch 'trunk' into add/heic-canvas-fallback
adamsilverstein Jul 1, 2026
a36689b
Media: Rename the source-format sideload token to `source_original`.
adamsilverstein Jul 1, 2026
42255f2
Media: Use block comment form for the source-original sideload note.
adamsilverstein Jul 1, 2026
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
1 change: 1 addition & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@
add_action( 'transition_post_status', '_wp_customize_publish_changeset', 10, 3 );
add_action( 'admin_enqueue_scripts', '_wp_customize_loader_settings' );
add_action( 'delete_attachment', '_delete_attachment_theme_mod' );
add_action( 'delete_attachment', 'wp_delete_attachment_heic_companion_file' );
add_action( 'transition_post_status', '_wp_keep_alive_customize_changeset_dependent_auto_drafts', 20, 3 );

// Block Theme Previews.
Expand Down
44 changes: 44 additions & 0 deletions src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -5760,6 +5760,50 @@ function wp_show_heic_upload_error( $plupload_settings ) {
return $plupload_settings;
}

/**
* Deletes the source-format companion file when its attachment is deleted.
*
* When the client-side media flow sideloads a source-format original (such as
* a HEIC file) alongside a web-viewable derivative, the original's filename is
* recorded in the 'source_image' metadata key. WordPress only tracks
* 'original_image' in wp_delete_attachment_files(), so without this hook the
* companion file would linger on disk after the attachment is deleted.
*
* @since 7.1.0
*
* @param int $post_id Attachment ID being deleted.
* @return bool Whether a companion file was deleted.
*/
function wp_delete_attachment_heic_companion_file( $post_id ): bool {
Comment thread
swissspidy marked this conversation as resolved.
Outdated
Comment thread
adamsilverstein marked this conversation as resolved.
Outdated
$metadata = wp_get_attachment_metadata( $post_id, true );
Comment thread
adamsilverstein marked this conversation as resolved.
Outdated

if ( empty( $metadata['source_image'] ) || ! is_string( $metadata['source_image'] ) ) {
Comment thread
adamsilverstein marked this conversation as resolved.
Outdated
return false;
}

$attached_file = get_attached_file( $post_id, true );

if ( ! $attached_file ) {
return false;
}

$uploads = wp_get_upload_dir();

if ( empty( $uploads['basedir'] ) ) {
return false;
}

$companion_path = path_join( dirname( $attached_file ), wp_basename( $metadata['source_image'] ) );
Comment thread
adamsilverstein marked this conversation as resolved.
Outdated

if ( ! file_exists( $companion_path ) ) {
return false;
}

wp_delete_file_from_directory( $companion_path, $uploads['basedir'] );

return true;
Comment thread
adamsilverstein marked this conversation as resolved.
Outdated
}

/**
* Allows PHP's getimagesize() to be debuggable when necessary.
*
Expand Down
2 changes: 1 addition & 1 deletion src/wp-includes/rest-api/class-wp-rest-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -1380,7 +1380,7 @@ public function get_index( $request ) {
$available['image_size_threshold'] = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 );

// Image output formats.
$input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' );
$input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence' );
Comment thread
adamsilverstein marked this conversation as resolved.
Outdated
$output_formats = array();
foreach ( $input_formats as $mime_type ) {
/** This filter is documented in wp-includes/media.php */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
*/
protected $allow_batch = false;

/**
* Image size token for the source-format original preserved alongside a
* client-generated derivative (e.g. the HEIC file kept next to its JPEG).
*
* Used both in the `/sideload` route schema and when dispatching the
* sideloaded file to its metadata key, so the two never drift apart.
*
* @since 7.1.0
* @var string
*/
const IMAGE_SIZE_SOURCE_ORIGINAL = 'original-heic';

/**
* Metadata key holding the basename of the source-format original.
*
* Deliberately specific so it never collides with the generic `original`
* or `original_image` keys other flows write to.
*
* @since 7.1.0
* @var string
*/
const META_KEY_SOURCE_IMAGE = 'source_image';

/**
* Registers the routes for attachments.
*
Expand Down Expand Up @@ -68,6 +91,12 @@ public function register_routes() {
$valid_image_sizes = array_keys( wp_get_registered_image_subsizes() );
// Special case to set 'original_image' in attachment metadata.
$valid_image_sizes[] = 'original';
// Source-format original preserved alongside a client-generated
// derivative (e.g. the HEIC kept next to its JPEG). Stored under
// the dedicated self::META_KEY_SOURCE_IMAGE key so it never
// collides with 'original_image' (which the scaled-sideload flow
// also writes to).
$valid_image_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
// Used for PDF thumbnails.
$valid_image_sizes[] = 'full';
// Client-side big image threshold: sideload the scaled version.
Expand Down Expand Up @@ -258,6 +287,17 @@ public function create_item_permissions_check( $request ) {
$prevent_unsupported_uploads = false;
}

// Always allow HEIC/HEIF uploads through even if the server's image
// editor doesn't support them. The client-side canvas fallback will
// handle processing using the browser's native HEVC decoder.
if (
$prevent_unsupported_uploads &&
! empty( $files['file']['type'] ) &&
wp_is_heic_image_mime_type( $files['file']['type'] )
) {
$prevent_unsupported_uploads = false;
}

// If the upload is an image, check if the server can handle the mime type.
if (
$prevent_unsupported_uploads &&
Expand Down Expand Up @@ -2090,6 +2130,13 @@ public function sideload_item( WP_REST_Request $request ) {

if ( 'original' === $image_size ) {
$metadata['original_image'] = wp_basename( $path );
} elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) {
// Source-format original: stored under its own meta key so the
// scaled-sideload flow (which writes 'original_image') cannot
// clobber it. 'original_image' keeps pointing at the
// web-viewable JPEG derivative. Cleanup on attachment delete
// is handled by wp_delete_attachment_heic_companion_file().
$metadata[ self::META_KEY_SOURCE_IMAGE ] = wp_basename( $path );
} elseif ( 'scaled' === $image_size ) {
// The current attached file is the original; record it as original_image.
$current_file = get_attached_file( $attachment_id, true );
Expand Down
96 changes: 96 additions & 0 deletions tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

/**
* Tests for the `wp_delete_attachment_heic_companion_file()` function.
*
* @group media
* @covers ::wp_delete_attachment_heic_companion_file
*/
class Tests_Media_wpDeleteAttachmentHeicCompanionFile extends WP_UnitTestCase {

public function tear_down(): void {
$this->remove_added_uploads();

parent::tear_down();
}

/**
* @ticket 64915
*/
public function test_deletes_companion_file_recorded_in_metadata_source_image(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
$this->assertIsInt( $attachment_id );

$attached_file = get_attached_file( $attachment_id, true );
$this->assertIsString( $attached_file );
$dir = dirname( $attached_file );
$heic_name = 'companion-' . wp_generate_password( 6, false ) . '.heic';
$heic_path = $dir . '/' . $heic_name;

// Create a dummy companion file on disk.
file_put_contents( $heic_path, 'test' );
$this->assertFileExists( $heic_path, 'Test fixture should be on disk.' );

// Record the companion under metadata['source_image'] as the sideload route does.
$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertIsArray( $metadata );
$metadata['source_image'] = $heic_name;
wp_update_attachment_metadata( $attachment_id, $metadata );

$this->assertTrue(
wp_delete_attachment_heic_companion_file( $attachment_id ),
'Function should report that a companion file was deleted.'
);
$this->assertFileDoesNotExist( $heic_path, 'Companion file should be deleted alongside the attachment.' );
}

/**
* @ticket 64915
*/
public function test_noop_when_metadata_source_image_is_missing(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
$this->assertIsInt( $attachment_id );

// Sanity: no 'source_image' key on freshly-created metadata.
$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertIsArray( $metadata );
$this->assertArrayNotHasKey( 'source_image', $metadata );

// Should report no deletion and not raise even though the hook fires.
$this->assertFalse( wp_delete_attachment_heic_companion_file( $attachment_id ) );

wp_delete_attachment( $attachment_id, true );

$this->assertNull( get_post( $attachment_id ) );
}

/**
* Guards against $metadata['source_image'] holding a non-string value (e.g.
* the array form some flows write). Regression coverage for GB #78128.
*
* @ticket 64915
*/
public function test_noop_when_metadata_source_image_is_not_a_string(): void {
$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
$this->assertIsInt( $attachment_id );
$attached_file = get_attached_file( $attachment_id, true );
$this->assertIsString( $attached_file );

// Place a real file that a buggy, guard-less implementation could try to
// delete after running wp_basename() over the array value below.
$bystander_path = dirname( $attached_file ) . '/should-not-delete.heic';
file_put_contents( $bystander_path, 'test' );
$this->assertFileExists( $bystander_path, 'Test fixture should be on disk.' );

$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertIsArray( $metadata );
$metadata['source_image'] = array( 'file' => 'should-not-delete.heic' );
wp_update_attachment_metadata( $attachment_id, $metadata );

// Should report no deletion and not raise (no path_join() / file_exists() on an array).
$this->assertFalse( wp_delete_attachment_heic_companion_file( $attachment_id ) );

$this->assertFileExists( $bystander_path, 'The non-string guard must prevent any file deletion.' );
$this->assertFileExists( $attached_file, 'Attached file should still be on disk.' );
}
}
113 changes: 97 additions & 16 deletions tests/phpunit/tests/rest-api/rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,53 +9,53 @@
*/
class WP_Test_REST_Attachments_Controller extends WP_Test_REST_Post_Type_Controller_Testcase {

protected static $superadmin_id;
protected static $editor_id;
protected static $author_id;
protected static $contributor_id;
protected static $uploader_id;
protected static $rest_after_insert_attachment_count;
protected static $rest_insert_attachment_count;
protected static int $superadmin_id;
protected static int $editor_id;
protected static int $author_id;
protected static int $contributor_id;
protected static int $uploader_id;
protected static int $rest_after_insert_attachment_count;
protected static int $rest_insert_attachment_count;

/**
* @var string The path to a test file.
*/
private static $test_file;
private static string $test_file;

/**
* @var string The path to a second test file.
*/
private static $test_file2;
private static string $test_file2;

/**
* @var string The path to the AVIF test image.
*/
private static $test_avif_file;
private static string $test_avif_file;

/**
* @var string The path to the SVG test image.
*/
private static $test_svg_file;
private static string $test_svg_file;

/**
* @var string The path to the test video.
*/
private static $test_video_file;
private static string $test_video_file;

/**
* @var string The path to the test audio.
*/
private static $test_audio_file;
private static string $test_audio_file;

/**
* @var string The path to the test RTF file.
*/
private static $test_rtf_file;
private static string $test_rtf_file;

/**
* @var array The recorded posts query clauses.
* @var string[] The recorded posts query clauses.
*/
protected $posts_clauses;
protected array $posts_clauses;

public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
self::$superadmin_id = $factory->user->create(
Expand Down Expand Up @@ -3351,6 +3351,87 @@ public function test_sideload_route_includes_scaled_enum() {
$this->assertContains( 'scaled', $args[ $param_name ]['enum'], 'image_size enum should include scaled.' );
}

/**
* Tests that the sideload endpoint includes 'original-heic' in the image_size enum.
*
* @ticket 64915
*/
public function test_sideload_route_includes_original_heic_enum(): void {
$this->enable_client_side_media_processing();

$routes = rest_get_server()->get_routes();
$endpoint = $routes['/wp/v2/media/(?P<id>[\d]+)/sideload'][0];
$args = $endpoint['args'];

$this->assertArrayHasKey( 'image_size', $args, 'Route should have image_size arg.' );
$this->assertContains( 'original-heic', $args['image_size']['enum'], 'image_size enum should include original-heic.' );
}

/**
* Tests that the sideload endpoint does not expose a generate_sub_sizes arg.
*
* sideload_item() never reads the parameter, so advertising it on the route
* would silently mislead clients into expecting server-side sub-size
* generation. The arg only does real work on create_item() (POST /wp/v2/media).
*
* @ticket 64915
*/
public function test_sideload_route_excludes_generate_sub_sizes_arg(): void {
$this->enable_client_side_media_processing();

$routes = rest_get_server()->get_routes();
$endpoint = $routes['/wp/v2/media/(?P<id>[\d]+)/sideload'][0];
$args = $endpoint['args'];
$this->assertIsArray( $args );

$this->assertArrayNotHasKey( 'generate_sub_sizes', $args, 'Sideload route should not advertise the unused generate_sub_sizes arg.' );
}

/**
* Tests sideloading an 'original-heic' companion file alongside its JPEG
* derivative. The HEIC filename is recorded under $metadata['source_image']
* so it does not collide with 'original_image', which the scaled-sideload
* flow owns.
*
* @ticket 64915
* @requires function imagejpeg
*/
public function test_sideload_original_heic_writes_metadata_source_image(): void {
$this->enable_client_side_media_processing();

wp_set_current_user( self::$author_id );

// Create the JPEG attachment that the HEIC will be a companion to.
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
$request->set_body( (string) file_get_contents( self::$test_file ) );
$response = rest_get_server()->dispatch( $request );
$attachment_id = $response->get_data()['id'];
$this->assertIsInt( $attachment_id );

$this->assertSame( 201, $response->get_status() );

// Sideload the HEIC companion using the real HEIC fixture. `convert_format`
// is disabled so the default HEIC -> JPEG output mapping does not rename
// the file or append an alt-extension suffix.
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
$request->set_header( 'Content-Type', 'image/heic' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.heic' );
$request->set_param( 'image_size', 'original-heic' );
$request->set_param( 'convert_format', false );
$request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.heic' ) );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 200, $response->get_status(), 'Sideloading original-heic should succeed.' );

$metadata = wp_get_attachment_metadata( $attachment_id );
$this->assertIsArray( $metadata );
$this->assertArrayHasKey( 'source_image', $metadata, "Metadata should contain 'source_image' for the HEIC companion." );
$this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['source_image'], "Metadata 'source_image' should reference the HEIC filename." );
$this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." );
}

/**
* Tests the filter_wp_unique_filename method handles the -scaled suffix.
*
Expand Down
Loading
Loading