Skip to content
Open
51 changes: 37 additions & 14 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,14 @@ 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
* 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 +654,13 @@ 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,
* 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 @@ -853,16 +865,19 @@ public function sideload_item( WP_REST_Request $request ) {

$image_size = $request['image_size'];

// Read dimensions once up-front. Needed both for early-error handling
// (corrupted/unsupported files) and for populating the sub-size payload
// 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 );
/*
* Read dimensions once up-front. Needed both for early-error handling
* (corrupted/unsupported files) and for populating the sub-size payload
* below. Scalar 'original' is a byte-only passthrough and does not need
* dimensions, but reading them here is harmless.
*
* 'animated-video' and 'optimized-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 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 +932,14 @@ 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
* 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
8 changes: 8 additions & 0 deletions lib/media/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
// render-time filtering is needed.
require_once __DIR__ . '/animated-gif-to-video.php';

/*
* Video transcoding: clean up the sideloaded web-safe companion when its
* 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
112 changes: 112 additions & 0 deletions lib/media/video-transcoding.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?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
*/

/**
* Deletes the transcoded video companion when its attachment is deleted.
*
* When the client-side media flow transcodes an uploaded video to a web-safe
* format, the original stays the attachment and the transcoded version is
* sideloaded as a companion file whose filename is recorded in the
* 'optimized_video' 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.
*
* @param int $post_id Attachment ID being deleted.
* @return bool Whether a companion file was deleted.
*/
function gutenberg_delete_optimized_video( int $post_id ): bool {
$metadata = wp_get_attachment_metadata( $post_id, true );

$optimized_video = $metadata['optimized_video'] ?? null;
if ( ! is_string( $optimized_video ) || '' === $optimized_video ) {
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( $optimized_video ) );

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

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

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,15 @@ function useMediaUploadSettings( settings = {} ) {
allowedMimeTypes: settings.allowedMimeTypes,
allImageSizes: settings.allImageSizes,
bigImageSizeThreshold: settings.bigImageSizeThreshold,
/*
* Developer opt-out for keeping the original video upload, exposed
* 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
23 changes: 22 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,25 @@ function VideoEdit( {
return;
}

/*
* Prefer the web-safe transcoded companion when available: the
* 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 +238,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