Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 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
9cbaa02
Media: Delete animated GIF video companions with their attachment.
adamsilverstein Jul 1, 2026
242b9cf
REST API: Support animated GIF video companion sideloads.
adamsilverstein Jul 1, 2026
0079be6
Merge remote-tracking branch 'origin/trunk' into backport/78410-anima…
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
27 changes: 27 additions & 0 deletions src/wp-includes/post.php
Original file line number Diff line number Diff line change
Expand Up @@ -6869,6 +6869,33 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) {
}
}

/*
* Delete the animated-GIF video companions. When the client-side media flow
* converts an opaque animated GIF to a web-safe video, the converted MP4/WebM
* and a static first-frame JPEG poster are sideloaded alongside the GIF and
* recorded under the 'animated_video' and 'animated_video_poster' keys. These
* are kept separate from 'original_image', which continues to point at the GIF.
*/
foreach ( array( 'animated_video', 'animated_video_poster' ) as $companion_key ) {
if ( empty( $meta[ $companion_key ] ) || ! is_string( $meta[ $companion_key ] ) ) {
continue;
}

if ( empty( $intermediate_dir ) ) {
$intermediate_dir = path_join( $uploadpath['basedir'], dirname( $file ) );
}

$companion_file = str_replace( wp_basename( $file ), $meta[ $companion_key ], $file );

if ( ! empty( $companion_file ) ) {
$companion_file = path_join( $uploadpath['basedir'], $companion_file );

if ( ! wp_delete_file_from_directory( $companion_file, $intermediate_dir ) ) {
$deleted = false;
}
}
}

if ( is_array( $backup_sizes ) ) {
$del_dir = path_join( $uploadpath['basedir'], dirname( $meta['file'] ) );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ public function register_routes() {
$valid_sizes[] = 'full';
// Source-format original (e.g. the HEIC kept alongside its JPEG derivative).
$valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
// Converted-video companions for an animated GIF (the MP4/WebM and its poster).
$valid_sizes[] = 'animated_video';
$valid_sizes[] = 'animated_video_poster';

$items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null );
if ( null === $items ) {
Expand Down Expand Up @@ -2225,6 +2228,13 @@ public function sideload_item( WP_REST_Request $request ) {
* under the dedicated source-image meta key.
*/
$sub_size_data['file'] = wp_basename( $path );
} elseif ( 'animated_video' === $image_size || 'animated_video_poster' === $image_size ) {
/*
* Converted-video companion of an animated GIF (the MP4/WebM or
* its static first-frame poster). Record the filename so
* finalize_item can store it under its dedicated meta key.
*/
$sub_size_data['file'] = wp_basename( $path );
} elseif ( 'scaled' === $image_size ) {
// Record the current attached file as the original.
$current_file = get_attached_file( $attachment_id, true );
Expand Down Expand Up @@ -2389,6 +2399,16 @@ public function finalize_item( WP_REST_Request $request ) {
* is handled by wp_delete_attachment_files().
*/
$metadata[ self::META_KEY_SOURCE_IMAGE ] = $sub_size['file'];
} elseif ( 'animated_video' === $image_size ) {
/*
* Converted-video companion of an animated GIF. Stored under its
* own meta key; 'original_image' keeps pointing at the GIF. Cleanup
* on attachment delete is handled by wp_delete_attachment_files().
*/
$metadata['animated_video'] = $sub_size['file'];
} elseif ( 'animated_video_poster' === $image_size ) {
// Static first-frame poster for the converted video.
$metadata['animated_video_poster'] = $sub_size['file'];
} elseif ( 'scaled' === $image_size ) {
if ( ! empty( $sub_size['original_image'] ) ) {
$metadata['original_image'] = $sub_size['original_image'];
Expand Down
129 changes: 129 additions & 0 deletions tests/phpunit/tests/media/wpDeleteAttachmentAnimatedGifVideo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

/**
* Tests that wp_delete_attachment_files() removes the animated-GIF video companions.
*
* @group media
* @covers ::wp_delete_attachment_files
*/
class Tests_Media_wpDeleteAttachmentAnimatedGifVideo extends WP_UnitTestCase {

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

parent::tear_down();
}

/**
* @ticket 65549
*/
public function test_deletes_video_and_poster_companions(): 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 );
$video_name = 'companion-' . wp_generate_password( 6, false ) . '.mp4';
$poster_name = 'companion-' . wp_generate_password( 6, false ) . '.jpg';
$video_path = $dir . '/' . $video_name;
$poster_path = $dir . '/' . $poster_name;

// Create dummy companion files on disk.
file_put_contents( $video_path, 'test' );
file_put_contents( $poster_path, 'test' );
$this->assertFileExists( $video_path, 'Video fixture should be on disk.' );
$this->assertFileExists( $poster_path, 'Poster fixture should be on disk.' );

// Record the companions as the sideload route does.
$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertIsArray( $metadata );
$metadata['animated_video'] = $video_name;
$metadata['animated_video_poster'] = $poster_name;
wp_update_attachment_metadata( $attachment_id, $metadata );

wp_delete_attachment( $attachment_id, true );

$this->assertNull( get_post( $attachment_id ) );
$this->assertFileDoesNotExist( $video_path, 'Video companion should be deleted alongside the attachment.' );
$this->assertFileDoesNotExist( $poster_path, 'Poster companion should be deleted alongside the attachment.' );
}

/**
* @ticket 65549
*/
public function test_deletes_only_video_when_no_poster_recorded(): 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 );
$video_name = 'companion-' . wp_generate_password( 6, false ) . '.mp4';
$video_path = $dir . '/' . $video_name;

file_put_contents( $video_path, 'test' );
$this->assertFileExists( $video_path, 'Video fixture should be on disk.' );

$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertIsArray( $metadata );
$metadata['animated_video'] = $video_name;
wp_update_attachment_metadata( $attachment_id, $metadata );

wp_delete_attachment( $attachment_id, true );

$this->assertNull( get_post( $attachment_id ) );
$this->assertFileDoesNotExist( $video_path, 'Video companion should be deleted alongside the attachment.' );
}

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

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

// Deletion should complete cleanly even though no companion file is recorded.
wp_delete_attachment( $attachment_id, true );

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

/**
* Guards against a companion key holding a non-string value (e.g. the array
* form some metadata flows write).
*
* @ticket 65549
*/
public function test_noop_when_companion_metadata_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.mp4';
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['animated_video'] = array( 'file' => 'should-not-delete.mp4' );
wp_update_attachment_metadata( $attachment_id, $metadata );

// Deletion should not raise (no str_replace() / file deletion on an array).
wp_delete_attachment( $attachment_id, true );

$this->assertNull( get_post( $attachment_id ) );
$this->assertFileExists( $bystander_path, 'The non-string guard must prevent any file deletion.' );
}
}
71 changes: 71 additions & 0 deletions tests/phpunit/tests/rest-api/rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -3574,6 +3574,77 @@ public function test_sideload_source_original_writes_metadata_source_image(): vo
$this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." );
}

/**
* Tests sideloading the animated-GIF video companions ('animated_video' and
* 'animated_video_poster'). Each filename is recorded under its own metadata
* key by the finalize endpoint and does not collide with 'original_image',
* which keeps pointing at the GIF.
*
* The uploaded bytes are a JPEG stand-in: the sideload branch only records
* wp_basename() of the stored file, so the metadata plumbing can be exercised
* without depending on video upload support in the test environment.
*
* @ticket 65549
* @requires function imagejpeg
*/
public function test_sideload_animated_video_companions_write_metadata(): void {
$this->enable_client_side_media_processing();

wp_set_current_user( self::$author_id );

// Create the (GIF) attachment the companions belong 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->assertSame( 201, $response->get_status() );

// Sideload the converted-video companion.
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-video.jpg' );
$request->set_param( 'image_size', 'animated_video' );
$request->set_body( (string) file_get_contents( self::$test_file ) );
$video_response = rest_get_server()->dispatch( $request );
$this->assertSame( 200, $video_response->get_status(), 'Sideloading animated_video should succeed.' );
$video_sub_size = $video_response->get_data();
$this->assertIsArray( $video_sub_size );
$this->assertSame( 'animated_video', $video_sub_size['image_size'], 'Response should echo the image_size.' );

// Sideload the poster companion.
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-poster.jpg' );
$request->set_param( 'image_size', 'animated_video_poster' );
$request->set_body( (string) file_get_contents( self::$test_file ) );
$poster_response = rest_get_server()->dispatch( $request );
$this->assertSame( 200, $poster_response->get_status(), 'Sideloading animated_video_poster should succeed.' );
$poster_sub_size = $poster_response->get_data();
$this->assertIsArray( $poster_sub_size );
$this->assertSame( 'animated_video_poster', $poster_sub_size['image_size'], 'Response should echo the image_size.' );

// Sideload must not write metadata; that happens in finalize.
$metadata = wp_get_attachment_metadata( $attachment_id, true );
$this->assertArrayNotHasKey( 'animated_video', $metadata, 'Sideload should not write animated_video metadata.' );
$this->assertArrayNotHasKey( 'animated_video_poster', $metadata, 'Sideload should not write animated_video_poster metadata.' );

// Finalize with both collected sub-sizes, which writes the metadata.
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
$request->set_param( 'sub_sizes', array( $video_sub_size, $poster_sub_size ) );
$response = rest_get_server()->dispatch( $request );
$this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' );

$metadata = wp_get_attachment_metadata( $attachment_id );
$this->assertIsArray( $metadata );
$this->assertArrayHasKey( 'animated_video', $metadata, "Metadata should contain 'animated_video'." );
$this->assertMatchesRegularExpression( '/canola-video.*\.jpg$/', $metadata['animated_video'], "Metadata 'animated_video' should reference the video companion filename." );
$this->assertArrayHasKey( 'animated_video_poster', $metadata, "Metadata should contain 'animated_video_poster'." );
$this->assertMatchesRegularExpression( '/canola-poster.*\.jpg$/', $metadata['animated_video_poster'], "Metadata 'animated_video_poster' should reference the poster filename." );
$this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the companion sideloads." );
}

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