Skip to content
Open
33 changes: 24 additions & 9 deletions lib/media/class-gutenberg-rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public function register_routes(): void {
$valid_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL;
$valid_sizes[] = 'animated-video';
$valid_sizes[] = 'animated-video-poster';
$valid_sizes[] = 'optimized-video';
$valid_sizes[] = 'scaled';
$valid_sizes[] = 'full';

Expand Down Expand Up @@ -525,6 +526,12 @@ public function finalize_item( WP_REST_Request $request ) {
// the video block's poster and deleted alongside the video.
// See lib/media/animated-gif-to-video.php.
$metadata['animated_video_poster'] = $sub_size['file'];
} elseif ( 'optimized-video' === $image_size ) {
// Web-safe transcoded companion of an uploaded video. Stored

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

please use the /* */ convention for multiline comments

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 22e3520.

// under its own key; the original video stays the attachment.
// The editor reads this key to play the optimized version;
// companion cleanup lives in lib/media/video-transcoding.php.
$metadata['optimized_video'] = $sub_size['file'];
} elseif ( 'scaled' === $image_size ) {
if ( ! empty( $sub_size['original_image'] ) ) {
$metadata['original_image'] = $sub_size['original_image'];
Expand Down Expand Up @@ -645,10 +652,11 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at
* @return true|WP_Error True if valid, WP_Error if invalid.
*/
private function validate_image_dimensions( int $width, int $height, $image_size, int $attachment_id ) {
// 'animated-video' companion file: video, not an image. Skip *all*
// dimension checks (the caller passes (0, 0) for this case so the
// positive-dimension assertion below would otherwise fire).
if ( 'animated-video' === $image_size ) {
// 'animated-video' and 'optimized-video' companion files are videos,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

please use the /* */ convention for multiline comments

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 22e3520.

// not images. Skip *all* dimension checks (the caller passes (0, 0)
// for these so the positive-dimension assertion below would otherwise
// fire).
if ( 'animated-video' === $image_size || 'optimized-video' === $image_size ) {
return true;
}

Expand Down Expand Up @@ -858,11 +866,12 @@ public function sideload_item( WP_REST_Request $request ) {
// below. Scalar 'original' is a byte-only passthrough and does not need
// dimensions, but reading them here is harmless.
//
// 'animated-video' companions are video files (MP4/WebM); the image
// helpers can't read their dimensions and would falsely report the
// upload as "corrupted or unsupported". Skip the read for this case;
// validate_image_dimensions() also short-circuits it below.
$size = 'animated-video' === $image_size ? array( 0, 0 ) : wp_getimagesize( $path );
// 'animated-video' and 'optimized-video' companions are video files

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

please use the /* */ convention for multiline comments

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 22e3520.

// (MP4/WebM); the image helpers can't read their dimensions and would
// falsely report the upload as "corrupted or unsupported". Skip the
// read for these; validate_image_dimensions() also short-circuits them.
$is_video_companion = 'animated-video' === $image_size || 'optimized-video' === $image_size;
$size = $is_video_companion ? array( 0, 0 ) : wp_getimagesize( $path );

if ( ! $size ) {
// Could not determine dimensions (corrupted file, unsupported format).
Expand Down Expand Up @@ -917,6 +926,12 @@ public function sideload_item( WP_REST_Request $request ) {
// the filename to $metadata['animated_video_poster']; used as the
// video block's poster and deleted with the video.
$sub_size_data['file'] = wp_basename( $path );
} elseif ( 'optimized-video' === $image_size ) {
// Web-safe transcoded video companion. finalize_item() writes the

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

please use the /* */ convention for multiline comments

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 22e3520.

// filename to $metadata['optimized_video']; the editor reads it to
// play the optimized version, and a delete_attachment hook removes
// it. See lib/media/video-transcoding.php.
$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
6 changes: 6 additions & 0 deletions lib/media/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
// render-time filtering is needed.
require_once __DIR__ . '/animated-gif-to-video.php';

// Video transcoding: clean up the sideloaded web-safe companion when its

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

please use the /* */ convention for multiline comments

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 22e3520.

// video attachment is deleted, and expose the "keep original" opt-out filter.
// The swap to the optimized companion happens in the editor (the core/video
// block's src points at the companion), so no render-time filtering is needed.
require_once __DIR__ . '/video-transcoding.php';

// ── Tier 1: HEIC infrastructure (always loaded) ─────────────────────

/**
Expand Down
146 changes: 146 additions & 0 deletions lib/media/video-transcoding.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php
/**
* Video transcoding: clean up the web-safe companion of a transcoded video and
* expose the developer opt-out filter.
*
* When client-side media processing is enabled, an uploaded video that is not
* already web-safe (non-MP4/WebM container, non-web-safe codec, oversized, or
* over a bitrate budget) is transcoded in the browser to a web-safe format.
* The original upload is kept as the attachment - so it can be linked to or
* used to regenerate a new version later, the same principle behind the
* `-scaled` image original and the HEIC original. The transcoded, web-safe
* version is sideloaded as a *companion file* of that same attachment and
* recorded in the attachment metadata under the `optimized_video` key. It is
* never a separate attachment.
*
* The swap to the optimized companion is handled in the editor: the
* `core/video` block points its playback `src` at the companion while the
* attachment keeps pointing at the original. The author can restore the
* original from the block toolbar. The only thing left for PHP is removing the
* sideloaded companion when its attachment is deleted, which core's
* wp_delete_attachment_files() does not know about.
*
* Developers can opt out of keeping the original via the
* `gutenberg_video_transcoding_keep_original` filter (default true); when it
* returns false the editor transcodes before upload so only the optimized file
* is stored.
*
* @package gutenberg
*/

/**
* Returns the absolute path to an attachment's transcoded video companion
* file, if recorded.
*
* The path is rebuilt from the attachment's own (trusted) directory plus the
* recorded basename, so the stored metadata cannot point anywhere else.
*
* @param int $attachment_id Attachment ID.
* @return string|null Absolute file path, or null when there is no companion.
*/
function gutenberg_get_optimized_video_companion_path( int $attachment_id ): ?string {
$metadata = wp_get_attachment_metadata( $attachment_id, true );

if ( empty( $metadata['optimized_video'] ) || ! is_string( $metadata['optimized_video'] ) ) {
return null;
}

// Only ever trust the basename of the recorded value; strip any path
// components so the metadata can't reference another directory.
$name = wp_basename( $metadata['optimized_video'] );

if ( '' === $name ) {
return null;
}

$attached_file = get_attached_file( $attachment_id, true );

if ( ! $attached_file ) {
return null;
}

return path_join( dirname( $attached_file ), $name );
}

/**
* Deletes the transcoded video companion when its attachment is deleted.
*
* The companion is sideloaded next to the original video and recorded in
* $metadata['optimized_video']. WordPress core's wp_delete_attachment_files()
* does not know about it, so without this hook it would linger on disk after
* the attachment is deleted.
*
* The path is confirmed to resolve to a regular file strictly inside the
* uploads directory before deletion, so this can only ever remove a
* sideloaded companion.
*
* @param int $post_id Attachment ID being deleted.
*/
function gutenberg_delete_optimized_video( int $post_id ): void {
$path = gutenberg_get_optimized_video_companion_path( $post_id );

if ( ! $path || ! file_exists( $path ) ) {
return;
}

$real_path = realpath( $path );

if ( ! $real_path || ! is_file( $real_path ) ) {
return;
}

$uploads = wp_get_upload_dir();
$base_dir = empty( $uploads['error'] ) ? realpath( $uploads['basedir'] ) : false;

if ( ! $base_dir ) {
return;
}

// Must resolve to a regular file strictly inside the uploads directory.
if ( ! str_starts_with( $real_path, $base_dir . DIRECTORY_SEPARATOR ) ) {
return;
}

wp_delete_file( $real_path );
}

add_action( 'delete_attachment', 'gutenberg_delete_optimized_video' );

/**
* Exposes the "keep original video" preference to the editor.
*
* By default the original video upload is kept and the transcoded, web-safe
* version is served as a companion. Developers can return false from the
* `gutenberg_video_transcoding_keep_original` filter to transcode before
* upload instead, so only the optimized file is stored.
*
* The resolved value is exposed as `window.__videoTranscodingKeepOriginal`,
* read by the block editor's media upload settings.
*/
function gutenberg_set_video_transcoding_keep_original_flag(): void {
if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
return;
}

/**
* Filters whether to keep the original video upload when transcoding.
*
* When true (default), the original video is stored as the attachment and
* the transcoded web-safe version is served as a companion file. When
* false, the video is transcoded before upload so only the optimized file
* is stored.
*
* @since 21.9.0
*
* @param bool $keep_original Whether to keep the original video upload.
*/
$keep_original = (bool) apply_filters( 'gutenberg_video_transcoding_keep_original', true );

wp_add_inline_script(
'wp-block-editor',
'window.__videoTranscodingKeepOriginal = ' . ( $keep_original ? 'true' : 'false' ) . ';',
'before'
);
}

add_action( 'admin_init', 'gutenberg_set_video_transcoding_keep_original_flag' );
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ function useMediaUploadSettings( settings = {} ) {
allowedMimeTypes: settings.allowedMimeTypes,
allImageSizes: settings.allImageSizes,
bigImageSizeThreshold: settings.bigImageSizeThreshold,
// Developer opt-out for keeping the original video upload, exposed

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

please use the /* */ convention for multiline comments

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 22e3520.

// by lib/media/video-transcoding.php. When false, videos are
// transcoded before upload so only the optimized file is stored.
videoKeepOriginal:
typeof window !== 'undefined'
? window.__videoTranscodingKeepOriginal
: undefined,
} ),
[ settings ]
);
Expand Down
21 changes: 20 additions & 1 deletion packages/block-library/src/video/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { Caption } from '../utils/caption';
import PosterImage from '../utils/poster-image';
import { isGifVariation } from './variations';
import GifRestoreControl from './gif-restore-control';
import VideoOriginalControl from './video-original-control';

const ALLOWED_MEDIA_TYPES = [ 'video' ];

Expand Down Expand Up @@ -111,11 +112,23 @@ function VideoEdit( {
return;
}

// Prefer the web-safe transcoded companion when available: the

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

please use the /* */ convention for multiline comments

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 22e3520.

// original video stays the attachment (media.id) but the block plays
// the optimized version. `media_details.optimized_video` is only set
// on transcoded video attachments, so its presence is a sufficient
// signal to swap the playback source. The author can switch back to
// the original from the toolbar (see VideoOriginalControl).
let nextSrc = media.url;
if ( media.media_details?.optimized_video ) {
const dir = media.url.slice( 0, media.url.lastIndexOf( '/' ) + 1 );
nextSrc = dir + media.media_details.optimized_video;
}

// Sets the block's attribute and updates the edit component from the
// selected media.
setAttributes( {
blob: undefined,
src: media.url,
src: nextSrc,
id: media.id,
poster:
media.image?.src !== media.icon ? media.image?.src : undefined,
Expand Down Expand Up @@ -223,6 +236,12 @@ function VideoEdit( {
clientId={ clientId }
/>
) }
{ ! isGif && (
<VideoOriginalControl
attributes={ attributes }
setAttributes={ setAttributes }
/>
) }
</>
) }
<InspectorControls>
Expand Down
98 changes: 98 additions & 0 deletions packages/block-library/src/video/video-original-control.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* WordPress dependencies
*/
import { ToolbarButton, ToolbarGroup } from '@wordpress/components';
import { BlockControls } from '@wordpress/block-editor';
import { store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import { video as videoIcon } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';

/**
* Builds the URL of a companion file stored next to the attachment's original.
*
* @param {string} sourceUrl Attachment source URL.
* @param {string} basename Companion file basename.
*
* @return {string} Absolute companion URL.
*/
function companionUrl( sourceUrl, basename ) {
const dir = sourceUrl.slice( 0, sourceUrl.lastIndexOf( '/' ) + 1 );
return dir + basename;
}

/**
* Toolbar control that toggles a video block between the web-safe transcoded
* companion and the original upload.
*
* When a non-web-safe video is uploaded through the editor it is transcoded to
* a web-safe companion that the block plays by default, while the original
* stays the underlying attachment. This control lets the author switch the
* block's `src` to the original (e.g. to reference the untouched upload) and
* back to the optimized version.
*
* It only renders when the block's media is a video attachment that has an
* `optimized_video` companion, so it never appears on videos that were not
* transcoded.
*
* @param {Object} props Component props.
* @param {Object} props.attributes Video block attributes.
* @param {Function} props.setAttributes Block attribute setter.
*
* @return {Component|null} The control, or null when no companion applies.
*/
export default function VideoOriginalControl( { attributes, setAttributes } ) {
const { id, src } = attributes;

const video = useSelect(
( select ) => {
if ( ! id ) {
return null;
}
const record = select( coreStore ).getEntityRecord(
'postType',
'attachment',
id,
{ context: 'view' }
);
if ( ! record?.mime_type?.startsWith( 'video/' ) ) {
return null;
}
if ( ! record?.media_details?.optimized_video ) {
return null;
}
return record;
},
[ id ]
);

if ( ! video?.source_url ) {
return null;
}

const originalUrl = video.source_url;
const optimizedUrl = companionUrl(
originalUrl,
video.media_details.optimized_video
);
const isOptimized = src === optimizedUrl;

return (
<BlockControls group="other">
<ToolbarGroup>
<ToolbarButton
icon={ videoIcon }
onClick={ () =>
setAttributes( {
src: isOptimized ? originalUrl : optimizedUrl,
} )
}
>
{ isOptimized
? __( 'Use original video' )
: __( 'Use optimized video' ) }
</ToolbarButton>
</ToolbarGroup>
</BlockControls>
);
}
Loading
Loading