-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Client-side media: Add video transcoding to web-safe formats #79375
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: add/gif-to-video-mediabunny
Are you sure you want to change the base?
Changes from 6 commits
9a62558
fbbcca3
fe0425b
621241d
84413dc
1cc51f2
22e3520
0b4afd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
||
|
|
@@ -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 | ||
| // 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']; | ||
|
|
@@ -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, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please use the /* */ convention for multiline comments
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please use the /* */ convention for multiline comments
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). | ||
|
|
@@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please use the /* */ convention for multiline comments
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please use the /* */ convention for multiline comments
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) ───────────────────── | ||
|
|
||
| /** | ||
|
|
||
| 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 |
|---|---|---|
|
|
@@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please use the /* */ convention for multiline comments
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ] | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' ]; | ||
|
|
||
|
|
@@ -111,11 +112,23 @@ function VideoEdit( { | |
| return; | ||
| } | ||
|
|
||
| // Prefer the web-safe transcoded companion when available: the | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please use the /* */ convention for multiline comments
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
@@ -223,6 +236,12 @@ function VideoEdit( { | |
| clientId={ clientId } | ||
| /> | ||
| ) } | ||
| { ! isGif && ( | ||
| <VideoOriginalControl | ||
| attributes={ attributes } | ||
| setAttributes={ setAttributes } | ||
| /> | ||
| ) } | ||
| </> | ||
| ) } | ||
| <InspectorControls> | ||
|
|
||
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 22e3520.