Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2b2f629
GIF to video: replace automatic conversion with block transforms
adamsilverstein Jul 9, 2026
f553eb2
GIF to video: update e2e test for the opt-in transform flow
adamsilverstein Jul 9, 2026
bac4294
upload-media: add a 'prompt' mode for GIF-to-video conversion
adamsilverstein Jul 9, 2026
419d3e3
Editor: prompt before converting animated GIF uploads to video
adamsilverstein Jul 9, 2026
90afea0
upload-media: cover prompt mode and resolveGifConversion with tests
adamsilverstein Jul 9, 2026
e8940dd
upload-media: finalize user-requested GIF conversions through a paren…
adamsilverstein Jul 9, 2026
42346be
GIF conversion prompt: fire immediately on drop, before the upload
adamsilverstein Jul 11, 2026
5d4d7b0
GIF conversion prompt: suppress the upload snackbar while open
adamsilverstein Jul 11, 2026
89087ae
GIF conversion prompt: assert snackbar suppression in e2e
adamsilverstein Jul 11, 2026
4d218c2
Merge trunk: #80072 landed with the reverse transform removed
adamsilverstein Jul 11, 2026
d980bfd
GIF conversion prompt: shorten the modal copy
adamsilverstein Jul 13, 2026
3709fd2
GIF conversion prompt: drop the 'Remember my choice' preference
adamsilverstein Jul 15, 2026
68fb755
Merge remote-tracking branch 'origin/trunk' into add/animated-gif-upl…
adamsilverstein Jul 15, 2026
459a84b
Revert unrelated formatting change to release workflow
adamsilverstein Jul 15, 2026
f828828
Upload media: add a generic uploadPrompts store primitive
adamsilverstein Jul 15, 2026
afe7870
GIF conversion: skip multi-file drops and trigger the prompt via an a…
adamsilverstein Jul 15, 2026
90457ce
Editor: render upload prompts through a generic action-triggered host
adamsilverstein Jul 15, 2026
a2223bb
GIF conversion: e2e for multi-file drops; type the requestUploadPromp…
adamsilverstein Jul 15, 2026
f2703b9
Merge remote-tracking branch 'origin/trunk' into add/animated-gif-upl…
adamsilverstein Jul 15, 2026
6841a9e
GIF conversion: defer multi-file (gallery) drop suppression to a foll…
adamsilverstein Jul 16, 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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function useMediaUploadSettings( settings = {} ) {
allowedMimeTypes: settings.allowedMimeTypes,
allImageSizes: settings.allImageSizes,
bigImageSizeThreshold: settings.bigImageSizeThreshold,
gifConvert: settings.gifConvert,
imageStripMeta: settings.imageStripMeta,
imageMaxBitDepth: settings.imageMaxBitDepth,
} ),
Expand Down
11 changes: 9 additions & 2 deletions packages/edit-post/src/components/layout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,13 @@ import { useMetaBoxInitialization } from '../meta-boxes/use-meta-box-initializat
const { useCommandContext } = unlock( commandsPrivateApis );
/** @type {{} & {useDrag: import('@use-gesture/react').useDrag}} */
const { useDrag } = unlock( componentsPrivateApis );
const { Editor, FullscreenMode, UploadProgressSnackbar } =
unlock( editorPrivateApis );
const {
Editor,
FullscreenMode,
UploadProgressSnackbar,
UploadPromptHost,
GifConversionBlockSwapper,
} = unlock( editorPrivateApis );
const { BlockKeyboardShortcuts } = unlock( blockLibraryPrivateApis );
const DESIGN_POST_TYPES = [
'wp_template',
Expand Down Expand Up @@ -608,6 +613,8 @@ function Layout( {
{ backButton }
<SnackbarNotices className="edit-post-layout__snackbar" />
<UploadProgressSnackbar />
<UploadPromptHost />
<GifConversionBlockSwapper />
</Editor>
</div>
</ErrorBoundary>
Expand Down
1 change: 1 addition & 0 deletions packages/edit-post/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function initializeEditor(
dispatch( preferencesStore ).setDefaults( 'core/media', {
requireApproval: true,
optimizeOnUpload: true,
animatedGifUploads: 'ask',
} );
}

Expand Down
9 changes: 8 additions & 1 deletion packages/edit-site/src/components/layout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ import SaveHub from '../save-hub';
import SavePanel from '../save-panel';

const { useLocation } = unlock( routerPrivateApis );
const { useStyle, UploadProgressSnackbar } = unlock( editorPrivateApis );
const {
useStyle,
UploadProgressSnackbar,
UploadPromptHost,
GifConversionBlockSwapper,
} = unlock( editorPrivateApis );

const ANIMATION_DURATION = 0.3;
const CONTENT_COLOR = { background: '#ffffff' };
Expand Down Expand Up @@ -166,6 +171,8 @@ function Layout() {

<SnackbarNotices className="edit-site-layout__snackbar" />
<UploadProgressSnackbar />
<UploadPromptHost />
<GifConversionBlockSwapper />

{ isMobileViewport && hasMobileAreas && (
<div className="edit-site-layout__mobile">
Expand Down
1 change: 1 addition & 0 deletions packages/edit-site/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function initializeEditor( id, settings ) {
dispatch( preferencesStore ).setDefaults( 'core/media', {
requireApproval: true,
optimizeOnUpload: true,
animatedGifUploads: 'ask',
} );
}

Expand Down
133 changes: 133 additions & 0 deletions packages/editor/src/components/gif-conversion-prompt/block-swapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* WordPress dependencies
*/
import { store as blockEditorStore } from '@wordpress/block-editor';
import { switchToBlockType } from '@wordpress/blocks';
import { store as coreStore } from '@wordpress/core-data';
import { useRegistry, useSelect } from '@wordpress/data';
import { useEffect, useMemo, useRef } from '@wordpress/element';
import { store as uploadStore } from '@wordpress/upload-media';

/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';

/**
* Swaps every Image block using the given attachment to the Video block's
* GIF variation, now that the attachment's companion video exists.
*
* The attachment record is re-fetched first: the companion is recorded on
* the server after the original upload completed, so the cached editor
* record does not include it yet. The swap itself runs the regular
* Image → Video block transform, which reads that record synchronously.
*
* @param {Object} registry Data registry.
* @param {number} attachmentId Attachment ID of the converted GIF.
*/
async function swapImageBlocksToVideo( registry, attachmentId ) {
const queryArgs = [
'postType',
'attachment',
attachmentId,
{ context: 'view' },
];
registry
.dispatch( coreStore )
.invalidateResolution( 'getEntityRecord', queryArgs );
const record = await registry
.resolveSelect( coreStore )
.getEntityRecord( ...queryArgs );

if ( ! record?.media_details?.animated_video ) {
return;
}

const {
canInsertBlockType,
getBlockAttributes,
getBlockName,
getBlockRootClientId,
getBlocksByClientId,
getClientIdsWithDescendants,
} = registry.select( blockEditorStore );

const clientIds = getClientIdsWithDescendants().filter(
( clientId ) =>
getBlockName( clientId ) === 'core/image' &&
getBlockAttributes( clientId )?.id === attachmentId
);

for ( const clientId of clientIds ) {
// Skip contexts that disallow the Video block (e.g. a Gallery),
// matching the availability of the block switcher transform.
if (
! canInsertBlockType(
'core/video',
getBlockRootClientId( clientId )
)
) {
continue;
}

const block = getBlocksByClientId( [ clientId ] )[ 0 ];
if ( ! block ) {
continue;
}

const newBlocks = switchToBlockType( block, 'core/video' );
if ( newBlocks?.length ) {
registry
.dispatch( blockEditorStore )
.replaceBlocks( clientId, newBlocks );
}
}
}

/**
* Watches for completed GIF conversions and swaps the corresponding Image
* blocks to the Video block's GIF variation, then drops the record.
*
* Always mounted and deliberately separate from GifConversionPrompt: the
* transcode finishes after the user has answered and the prompt modal has
* closed, so the swap cannot live inside the (transient) prompt component.
*/
export default function GifConversionBlockSwapper() {
const registry = useRegistry();
const conversions = useSelect(
( select ) => unlock( select( uploadStore ) ).getGifConversions(),
[]
);
// Records whose block swap is already running, so a re-render while the
// async swap is in flight doesn't start a second one.
const swappingRef = useRef( new Set() );

const converted = useMemo(
() =>
conversions.filter(
( conversion ) => conversion.status === 'converted'
),
[ conversions ]
);

useEffect( () => {
if ( ! converted.length ) {
return;
}
const { removeGifConversion } = unlock(
registry.dispatch( uploadStore )
);
for ( const { itemId, attachmentId } of converted ) {
if ( swappingRef.current.has( itemId ) ) {
continue;
}
swappingRef.current.add( itemId );
swapImageBlocksToVideo( registry, attachmentId ).finally( () => {
swappingRef.current.delete( itemId );
removeGifConversion( itemId );
} );
}
}, [ converted, registry ] );

return null;
}
2 changes: 2 additions & 0 deletions packages/editor/src/components/gif-conversion-prompt/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as GifConversionPrompt } from './prompt';
export { default as GifConversionBlockSwapper } from './block-swapper';
86 changes: 86 additions & 0 deletions packages/editor/src/components/gif-conversion-prompt/prompt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* WordPress dependencies
*/
import { Button, Flex, FlexItem, Modal } from '@wordpress/components';
import { Stack } from '@wordpress/ui';
import { useRegistry } from '@wordpress/data';
import { useEffect, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { store as uploadStore } from '@wordpress/upload-media';

/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';

/**
* Modal asking whether a single dropped animated GIF should be converted to a
* video (the Video block's GIF variation) or kept as a GIF.
*
* Rendered by UploadPromptHost while a `gif-conversion` upload prompt is
* pending — the prompt appears as soon as the file is identified as a
* conversion candidate, before the upload finishes, so it reads as part of
* the drop. The GIF uploads as a plain image in the background regardless;
* the answer only decides whether a companion video is transcoded (and the
* block later swapped by GifConversionBlockSwapper).
*
* @param {Object} props
* @param {Object} props.prompt The upload prompt record ({ id, type, itemId }).
*/
export default function GifConversionPrompt( { prompt } ) {
const registry = useRegistry();
const convertButtonRef = useRef();

// Steer towards converting: focus the primary button so Enter converts.
useEffect( () => {
convertButtonRef.current?.focus();
}, [] );

const resolve = ( decision ) => {
const { resolveGifConversion, resolveUploadPrompt } = unlock(
registry.dispatch( uploadStore )
);
// Act on the answer, then dismiss the prompt.
resolveGifConversion( prompt.itemId, decision );
resolveUploadPrompt( prompt.id );
};

return (
<Modal
title={ __( 'Convert to video' ) }
// Closing the dialog keeps the GIF for this upload.
onRequestClose={ () => resolve( 'gif' ) }
size="small"
focusOnMount={ false }
>
<Stack direction="column" gap="lg">
<p>
{ __(
'This will save space without changing the visuals. The GIF stays in your media library.'
) }
</p>
<Flex direction="row" justify="flex-end">
<FlexItem>
<Button
variant="tertiary"
__next40pxDefaultSize
onClick={ () => resolve( 'gif' ) }
>
{ __( 'Not now' ) }
</Button>
</FlexItem>
<FlexItem>
<Button
variant="primary"
__next40pxDefaultSize
ref={ convertButtonRef }
onClick={ () => resolve( 'video' ) }
>
{ __( 'Convert' ) }
</Button>
</FlexItem>
</Flex>
</Stack>
</Modal>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,12 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
: undefined,
mediaFinalize: hasUploadPermissions ? mediaFinalize : undefined,
mediaDelete: hasUploadPermissions ? mediaDelete : undefined,
/*
* Animated GIFs upload as a plain image and record a pending
* conversion; the editor's GifConversionPrompt asks the user
* whether to convert each one to a video.
*/
gifConvert: 'prompt',
__experimentalBlockPatterns: blockPatterns,
[ selectBlockPatternsKey ]: ( select ) => {
const { hasFinishedResolution, getBlockPatternsForPostType } =
Expand Down
27 changes: 25 additions & 2 deletions packages/editor/src/components/upload-progress-snackbar/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { check } from '@wordpress/icons';
* Internal dependencies
*/
import { useTracker } from './tracker';
import { useHasActiveUploadPrompt } from '../upload-prompt';

const NOTICE_ID = 'upload-progress';

Expand Down Expand Up @@ -110,6 +111,12 @@ export default function UploadProgressSnackbar() {

const { createNotice, removeNotice } = useDispatch( noticesStore );

// While an upload prompt is open the snackbar stays hidden: the prompt
// should be the single point of attention during the drop action, and it
// already implies the upload is underway. Audible announcements are kept
// so screen reader users still hear about the upload itself.
const isPromptVisible = useHasActiveUploadPrompt();

// Track whether the user has dismissed the notice. If so, don't re-create
// it until the current batch finishes and a new one starts.
const dismissedRef = useRef( false );
Expand Down Expand Up @@ -144,7 +151,7 @@ export default function UploadProgressSnackbar() {
} else if ( ! isUploading && wasUploadingRef.current ) {
speak( __( 'Media upload complete' ), 'polite' );

if ( ! dismissedRef.current ) {
if ( ! dismissedRef.current && ! isPromptVisible ) {
createNotice( 'info', __( 'Upload complete' ), {
id: NOTICE_ID,
type: 'snackbar',
Expand Down Expand Up @@ -173,6 +180,14 @@ export default function UploadProgressSnackbar() {
return;
}

// Suppress (and retract) the progress notice while the prompt is
// open; it reappears if uploads are still running once the prompt
// is answered.
if ( isPromptVisible ) {
removeNotice( NOTICE_ID );
return;
}

const total = peak;
const current = total - remaining + 1;

Expand Down Expand Up @@ -210,7 +225,15 @@ export default function UploadProgressSnackbar() {
dismissedRef.current = true;
},
} );
}, [ remaining, peak, csmOriginals, tracker, createNotice, removeNotice ] );
}, [
remaining,
peak,
csmOriginals,
tracker,
createNotice,
removeNotice,
isPromptVisible,
] );

return null;
}
Loading
Loading