From cc116cce1ad445c0e0147faee9a681ceabdc5bfb Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 29 Aug 2026 16:00:36 -0700 Subject: [PATCH] Media: Sideload and clean up the web-safe companion of a transcoded video upload. Add server-side support for the client-side video transcoding flow. When client-side media processing is enabled, the editor transcodes an uploaded video that is not already web-safe to MP4/WebM in the browser and sideloads the result as a companion of the original video attachment, which remains the attachment itself. - Accept a video attachment as the sideload parent for the new `optimized_video` companion size, and only for that size; every other size still requires an image or PDF parent. - Record the companion under the `optimized_video` attachment metadata key on finalize, and treat it as a name the request is allowed to store. - Delete the companion file alongside the attachment in `wp_delete_attachment_files()`. - Expose the new `wp_video_transcoding_keep_original` filter (default `true`) on the REST API index as `video_keep_original`, so the editor knows whether to keep the original upload or transcode before uploading, and preload it with the other media processing settings. See https://github.com/WordPress/gutenberg/pull/79375. --- src/wp-admin/edit-form-blocks.php | 1 + src/wp-admin/site-editor.php | 1 + src/wp-includes/post.php | 15 +- .../rest-api/class-wp-rest-server.php | 14 ++ .../class-wp-rest-attachments-controller.php | 58 ++++++-- .../wpDeleteAttachmentOptimizedVideo.php | 118 ++++++++++++++++ .../rest-api/rest-attachments-controller.php | 131 ++++++++++++++++++ tests/phpunit/tests/rest-api/rest-server.php | 5 + 8 files changed, 327 insertions(+), 16 deletions(-) create mode 100644 tests/phpunit/tests/media/wpDeleteAttachmentOptimizedVideo.php diff --git a/src/wp-admin/edit-form-blocks.php b/src/wp-admin/edit-form-blocks.php index a6d198ff15343..b6eb16d21ee56 100644 --- a/src/wp-admin/edit-form-blocks.php +++ b/src/wp-admin/edit-form-blocks.php @@ -106,6 +106,7 @@ static function ( $classes ) { 'site_logo', 'timezone_string', 'url', + 'video_keep_original', 'page_for_posts', 'page_on_front', 'show_on_front', diff --git a/src/wp-admin/site-editor.php b/src/wp-admin/site-editor.php index 4289f89f7102c..81308e6bc12e1 100644 --- a/src/wp-admin/site-editor.php +++ b/src/wp-admin/site-editor.php @@ -232,6 +232,7 @@ static function ( $classes ) { 'site_logo', 'timezone_string', 'url', + 'video_keep_original', 'page_for_posts', 'page_on_front', 'show_on_front', diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 49ab472d37184..f70d78f6e35fd 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6998,13 +6998,16 @@ 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. + * Delete the 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. + * When it transcodes a video upload to a web-safe format, the transcode is + * sideloaded alongside the original video and recorded under the + * 'optimized_video' key. These are kept separate from 'original_image', + * which continues to point at the upload itself. */ - foreach ( array( 'animated_video', 'animated_video_poster' ) as $companion_key ) { + foreach ( array( 'animated_video', 'animated_video_poster', 'optimized_video' ) as $companion_key ) { if ( empty( $meta[ $companion_key ] ) || ! is_string( $meta[ $companion_key ] ) ) { continue; } diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index c7ffb5b7677d0..5f6799c527664 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -1397,6 +1397,20 @@ public function get_index( $request ) { */ /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ $available['image_max_bit_depth'] = (int) apply_filters( 'image_max_bit_depth', 16, 16 ); + + /** + * Filters whether the original video upload is kept when a video is transcoded. + * + * When true (default), the original video is stored as the attachment and + * the transcoded web-safe version is sideloaded as a companion file. When + * false, the video is transcoded before upload so only the optimized file + * is stored. + * + * @since 7.2.0 + * + * @param bool $keep_original Whether to keep the original video upload. Default true. + */ + $available['video_keep_original'] = (bool) apply_filters( 'wp_video_transcoding_keep_original', true ); } $response = new WP_REST_Response( $available ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index c17193acfc916..d71114d3d68dd 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -2603,6 +2603,8 @@ private static function get_special_image_sizes(): array { // Converted-video companions for an animated GIF (the MP4/WebM and its poster). 'animated_video', 'animated_video_poster', + // Web-safe transcode kept alongside the original video upload. + 'optimized_video', ); } @@ -2758,13 +2760,28 @@ public function sideload_item( WP_REST_Request $request ) { return $post; } + /** @var non-empty-string|non-empty-list $image_size */ + $image_size = $request['image_size']; + + /* + * Sideloads extend an existing attachment with a file derived from it, + * which is an image or PDF sub-size in every case but one: the web-safe + * transcode of a video is sideloaded as a companion of the video + * attachment it was produced from, so a video parent is valid for that + * size and no other. + */ + $is_optimized_video_companion = + 'optimized_video' === $image_size && + wp_attachment_is( 'video', $post ); + if ( ! wp_attachment_is_image( $post ) && - ! wp_attachment_is( 'pdf', $post ) + ! wp_attachment_is( 'pdf', $post ) && + ! $is_optimized_video_companion ) { return new WP_Error( 'rest_post_invalid_id', - __( 'Invalid post ID. Only images and PDFs can be sideloaded.' ), + __( 'Invalid post ID. Only images, PDFs and videos can be sideloaded.' ), array( 'status' => 400 ) ); } @@ -2847,21 +2864,23 @@ public function sideload_item( WP_REST_Request $request ) { $type = $file['type']; $path = $file['file']; - /** @var non-empty-string|non-empty-list $image_size */ - $image_size = $request['image_size']; - /* - * Validate raster sub-sizes before storing them. Two companion sizes + * Validate raster sub-sizes before storing them. Three companion sizes * are exempt because wp_getimagesize() may not be able to read the - * file at all: the 'animated_video' companion of an animated GIF is a - * video (MP4/WebM), and a source-format original (e.g. a HEIC or JXL - * kept next to its JPEG derivative) may be an unreadable format. Their + * file at all: the 'animated_video' companion of an animated GIF and + * the 'optimized_video' companion of a video upload are videos + * (MP4/WebM), and a source-format original (e.g. a HEIC or JXL kept + * next to its JPEG derivative) may be an unreadable format. Their * dimensions are neither validated nor recorded. The * 'animated_video_poster' companion is a real image, so it is still * read and rejected if unreadable; validate_image_dimensions() skips * only the registered-size constraint for it. */ - $skip_dimension_read = self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size || 'animated_video' === $image_size; + $skip_dimension_read = in_array( + $image_size, + array( self::IMAGE_SIZE_SOURCE_ORIGINAL, 'animated_video', 'optimized_video' ), + true + ); $size = false; if ( ! $skip_dimension_read ) { @@ -2935,6 +2954,13 @@ public function sideload_item( WP_REST_Request $request ) { * finalize_item can store it under its dedicated meta key. */ $sub_size_data['file'] = wp_basename( $path ); + } elseif ( 'optimized_video' === $image_size ) { + /* + * Web-safe transcode of a video upload. The original video stays + * the attachment; record the companion's filename so finalize_item + * can store it under its dedicated meta key. + */ + $sub_size_data['file'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size || 'original' === $image_size ) { /* * 'scaled' and 'original' both replace the attachment's main file @@ -3165,6 +3191,7 @@ protected function get_sideloaded_file_names( int $attachment_id, bool $include_ $metadata[ self::META_KEY_SOURCE_IMAGE ] ?? null, $metadata['animated_video'] ?? null, $metadata['animated_video_poster'] ?? null, + $metadata['optimized_video'] ?? null, ); if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) { @@ -3379,6 +3406,17 @@ public function finalize_item( WP_REST_Request $request ) { // Static first-frame poster for the converted video. $metadata['animated_video_poster'] = $sub_size['file']; + } elseif ( 'optimized_video' === $image_size ) { + if ( empty( $sub_size['file'] ) ) { + continue; + } + + /* + * Web-safe transcode of a video upload. Stored under its own + * meta key; the original video stays the attachment. Cleanup on + * attachment delete is handled by wp_delete_attachment_files(). + */ + $metadata['optimized_video'] = $sub_size['file']; } else { if ( empty( $sub_size['file'] ) ) { continue; diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentOptimizedVideo.php b/tests/phpunit/tests/media/wpDeleteAttachmentOptimizedVideo.php new file mode 100644 index 0000000000000..72c1ae13c23ff --- /dev/null +++ b/tests/phpunit/tests/media/wpDeleteAttachmentOptimizedVideo.php @@ -0,0 +1,118 @@ +remove_added_uploads(); + + parent::tear_down(); + } + + /** + * @ticket 65998 + */ + public function test_deletes_companion_recorded_in_metadata(): void { + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/uploads/small-video.mov' ); + $this->assertIsInt( $attachment_id ); + + $attached_file = get_attached_file( $attachment_id, true ); + $this->assertIsString( $attached_file ); + $dir = dirname( $attached_file ); + $video_name = 'optimized-' . wp_generate_password( 6, false ) . '.mp4'; + $video_path = $dir . '/' . $video_name; + + // Create a dummy companion file on disk. + file_put_contents( $video_path, 'test' ); + $this->assertFileExists( $video_path, 'Video fixture should be on disk.' ); + + // Record the companion as the finalize route does. + $metadata = (array) wp_get_attachment_metadata( $attachment_id, true ); + $metadata['optimized_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.' ); + $this->assertFileDoesNotExist( $attached_file, 'The original video should be deleted as before.' ); + } + + /** + * @ticket 65998 + */ + public function test_noop_when_no_companion_metadata(): void { + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/uploads/small-video.mov' ); + $this->assertIsInt( $attachment_id ); + + $metadata = (array) wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayNotHasKey( 'optimized_video', $metadata ); + + // Deletion should complete cleanly even though no companion file is recorded. + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + } + + /** + * A path-traversal value in the metadata only ever resolves inside the + * attachment's own directory, so a file outside it is never touched. + * + * @ticket 65998 + */ + public function test_traversal_value_does_not_delete_outside_attachment_dir(): void { + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/uploads/small-video.mov' ); + $this->assertIsInt( $attachment_id ); + + $attached_file = get_attached_file( $attachment_id, true ); + $this->assertIsString( $attached_file ); + + // A file one level above the attachment's directory. + $outside_path = dirname( $attached_file, 2 ) . '/outside-' . wp_generate_password( 6, false ) . '.mp4'; + file_put_contents( $outside_path, 'test' ); + $this->assertFileExists( $outside_path, 'Test fixture should be on disk.' ); + + $metadata = (array) wp_get_attachment_metadata( $attachment_id, true ); + $metadata['optimized_video'] = '../' . wp_basename( $outside_path ); + wp_update_attachment_metadata( $attachment_id, $metadata ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + $this->assertFileExists( $outside_path, 'A file outside the attachment directory must not be deleted.' ); + + wp_delete_file( $outside_path ); + } + + /** + * Guards against the companion key holding a non-string value. + * + * @ticket 65998 + */ + public function test_noop_when_companion_metadata_is_not_a_string(): void { + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/uploads/small-video.mov' ); + $this->assertIsInt( $attachment_id ); + $attached_file = get_attached_file( $attachment_id, true ); + $this->assertIsString( $attached_file ); + + $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 = (array) wp_get_attachment_metadata( $attachment_id, true ); + $metadata['optimized_video'] = array( 'file' => 'should-not-delete.mp4' ); + wp_update_attachment_metadata( $attachment_id, $metadata ); + + 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.' ); + + wp_delete_file( $bystander_path ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 4dd0b60172cb4..c96d9fc71266b 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -4145,6 +4145,10 @@ public function test_sideload_route_accepts_expected_image_sizes() { $validate( WP_REST_Attachments_Controller::IMAGE_SIZE_SOURCE_ORIGINAL, $request, $param_name ), 'image_size validation should accept the source_original source-format size.' ); + $this->assertTrue( + $validate( 'optimized_video', $request, $param_name ), + 'image_size validation should accept the optimized_video companion size.' + ); $this->assertTrue( $validate( array( 'thumbnail', 'medium' ), $request, $param_name ), 'image_size validation should accept an array of size names.' @@ -4303,6 +4307,133 @@ public function test_sideload_animated_video_companions_write_metadata(): void { $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the companion sideloads." ); } + /** + * Creates a video attachment through the REST API, in the state the + * client-side video transcoding flow sideloads its companion into. + * + * @return int Attachment ID. + */ + private function create_video_attachment_for_sideload(): int { + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'video/quicktime' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=small-video.mov' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/uploads/small-video.mov' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status(), 'Creating the video attachment should succeed.' ); + + $id = $response->get_data()['id']; + $this->assertIsInt( $id ); + + return $id; + } + + /** + * A video is the one non-image, non-PDF parent the sideload endpoint + * accepts: the web-safe transcode of an uploaded video is stored as a + * companion of the video it was produced from, not as its own attachment. + * + * @ticket 65998 + */ + public function test_sideload_accepts_optimized_video_for_a_video_attachment(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + $attachment_id = $this->create_video_attachment_for_sideload(); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'video/mp4' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=small-video-optimized.mp4' ); + $request->set_param( 'image_size', 'optimized_video' ); + $request->set_body( (string) file_get_contents( self::$test_video_file ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the transcoded companion should succeed.' ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertSame( 'optimized_video', $data['image_size'], 'Response should echo the image_size.' ); + $this->assertStringEndsWith( '.mp4', $data['file'] ); + } + + /** + * Storing the companion's name on finalize consumes its sideload record, + * like every other name a request is allowed to store, and records it + * under its own metadata key rather than 'original_image'. + * + * @ticket 65998 + */ + public function test_finalize_writes_optimized_video_metadata_and_consumes_provenance_record(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + $attachment_id = $this->create_video_attachment_for_sideload(); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'video/mp4' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=small-video-optimized.mp4' ); + $request->set_param( 'image_size', 'optimized_video' ); + $request->set_body( (string) file_get_contents( self::$test_video_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading the transcoded companion should succeed.' ); + $companion = $response->get_data(); + $this->assertIsArray( $companion ); + $this->assertNotEmpty( + get_post_meta( $attachment_id, WP_REST_Attachments_Controller::META_KEY_SIDELOAD_FILE_NAME ), + 'The sideload should record the companion name.' + ); + + // Sideload must not write metadata; that happens in finalize. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayNotHasKey( 'optimized_video', (array) $metadata, 'Sideload should not write optimized_video metadata.' ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $request->set_param( 'sub_sizes', array( $companion ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Finalizing with the companion should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertIsArray( $metadata ); + $this->assertArrayHasKey( 'optimized_video', $metadata, "Metadata should contain 'optimized_video'." ); + $this->assertSame( $companion['file'], $metadata['optimized_video'], 'The companion name should be stored in the attachment metadata.' ); + $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the companion sideload." ); + $this->assertSame( + array(), + get_post_meta( $attachment_id, WP_REST_Attachments_Controller::META_KEY_SIDELOAD_FILE_NAME ), + 'Storing the companion name should consume its sideload record.' + ); + } + + /** + * The video exemption is scoped to the transcoded companion: every other + * size still needs an image or PDF parent, since a video has no sub-sizes. + * + * @ticket 65998 + */ + public function test_sideload_rejects_other_sizes_for_a_video_attachment(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + $attachment_id = $this->create_video_attachment_for_sideload(); + + $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=small-video-150x150.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 400 ); + } + /** * Tests the filter_wp_unique_filename method handles the -scaled suffix. * diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index dce5a045f73db..58da80580d5d1 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -1230,6 +1230,8 @@ public function test_get_index_should_include_media_processing_settings(): void $this->assertTrue( $data['image_strip_meta'] ); $this->assertArrayHasKey( 'image_max_bit_depth', $data ); $this->assertSame( 16, $data['image_max_bit_depth'] ); + $this->assertArrayHasKey( 'video_keep_original', $data ); + $this->assertTrue( $data['video_keep_original'] ); } /** @@ -1250,6 +1252,7 @@ public function test_get_index_should_not_include_media_processing_settings_with $this->assertArrayNotHasKey( 'image_size_threshold', $data ); $this->assertArrayNotHasKey( 'image_strip_meta', $data ); $this->assertArrayNotHasKey( 'image_max_bit_depth', $data ); + $this->assertArrayNotHasKey( 'video_keep_original', $data ); } /** @@ -1267,6 +1270,7 @@ public function test_get_index_should_honor_media_processing_filters(): void { 'image_max_bit_depth', static fn ( int $max_depth ) => min( 8, $max_depth ) ); + add_filter( 'wp_video_transcoding_keep_original', '__return_false' ); $server = new WP_REST_Server(); $request = new WP_REST_Request( 'GET', '/' ); @@ -1275,6 +1279,7 @@ public function test_get_index_should_honor_media_processing_filters(): void { $this->assertFalse( $data['image_strip_meta'] ); $this->assertSame( 8, $data['image_max_bit_depth'] ); + $this->assertFalse( $data['video_keep_original'] ); } /**