Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0e4d831
Report GIF-to-video conversion progress to the upload store
adamsilverstein Jul 15, 2026
6523072
Image block: show determinate progress during GIF-to-video conversion
adamsilverstein Jul 15, 2026
45c2892
Time-gate GIF conversion progress dispatches so the editor can paint
adamsilverstein Jul 15, 2026
ff68ec5
Merge branch 'trunk' into add/gif-conversion-progress
adamsilverstein Jul 15, 2026
f514ebc
Merge trunk into add/gif-conversion-progress
adamsilverstein Jul 15, 2026
b23082e
Merge remote-tracking branch 'origin/add/gif-conversion-progress' int…
adamsilverstein Jul 15, 2026
204bcf4
Merge branch 'trunk' into add/gif-conversion-progress
adamsilverstein Jul 16, 2026
c552c96
Improve conversion progress bar visibility over media
adamsilverstein Jul 16, 2026
e6e379c
Merge remote-tracking branch 'origin/add/gif-conversion-progress' int…
adamsilverstein Jul 16, 2026
2682cc7
Merge remote-tracking branch 'origin/trunk' into add/gif-conversion-p…
adamsilverstein Jul 17, 2026
2970d39
Show GIF conversion progress in the upload snackbar instead of on the…
adamsilverstein Jul 20, 2026
e52f99d
Keep the ProgressBar's default hairline height in the upload snackbar
adamsilverstein Jul 21, 2026
196eb4a
Merge remote-tracking branch 'origin/trunk' into add/gif-conversion-p…
adamsilverstein Aug 11, 2026
11e567c
Merge remote-tracking branch 'origin/trunk' into HEAD
adamsilverstein Sep 15, 2026
c64ed2e
Tests: Replace leftover Jest globals with their Vitest equivalents
adamsilverstein Sep 15, 2026
7e38a52
Merge branch 'trunk' into add/gif-conversion-progress
adamsilverstein Sep 15, 2026
58447a6
Merge remote-tracking branch 'origin/trunk' into add/gif-conversion-p…
adamsilverstein Sep 18, 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
1 change: 1 addition & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@

- The "Apply globally" control now opens a review modal so you can choose which of a block's modified styles are pushed to Global Styles, showing each style's current and new value ([#79839](https://github.com/WordPress/gutenberg/pull/79839)).
- Add a `responsiveEditingEnabled` editor setting, defaulting to `true`, which hides the "Responsive styles" option in the View menu and the viewport state control in Global Styles when set to `false` ([#80814](https://github.com/WordPress/gutenberg/pull/80814)).
- The upload progress snackbar now shows a determinate progress bar and a "Processing" label while a long-running client-side operation (currently GIF-to-video conversion) reports its progress ([#80325](https://github.com/WordPress/gutenberg/issues/80325)).

### Bug Fixes

Expand Down
79 changes: 72 additions & 7 deletions packages/editor/src/components/upload-progress-snackbar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { __, sprintf } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
import { store as uploadStore } from '@wordpress/upload-media';
import { store as noticesStore } from '@wordpress/notices';
import { Icon as WCIcon, Spinner } from '@wordpress/components';
import { Icon as WCIcon, ProgressBar, Spinner } from '@wordpress/components';
import { check } from '@wordpress/icons';
import {
useTracker,
Expand Down Expand Up @@ -60,6 +60,30 @@ export const UPLOAD_DONE = (
</span>
);

/**
* Icon-slot markup for the determinate state: a small progress bar shown in
* place of the spinner while a long-running client-side operation (currently
* GIF-to-video conversion) reports its progress.
*
* Exported for the Storybook story, like the icons above.
*
* @param {number} progress Progress from 0 to 100.
* @return {JSX.Element} The icon-slot markup.
*/
export function uploadProgressBar( progress ) {
return (
<span
className="editor-upload-progress-snackbar__progress"
aria-hidden="true"
>
<ProgressBar
className="editor-upload-progress-snackbar__progress-bar"
value={ progress }
/>
</span>
);
}

/**
* Manages a snackbar notice that shows media upload progress while uploads are
* in progress. It creates/updates a notice via the notices store so that it
Expand Down Expand Up @@ -95,6 +119,23 @@ export default function UploadProgressSnackbar() {
);
const csmRemaining = csmOriginals.length;

/*
* Determinate progress reported by a long-running client-side operation
* (currently GIF-to-video conversion). The reporting item is a sideload
* companion of an original (it has a `parentId`), so it is looked up in
* the full queue rather than in `csmOriginals`. Progress of 100 means the
* operation finished and the item moved on to uploading its result, so
* the snackbar falls back to the indeterminate uploading state.
*/
const progressItem = useMemo(
() =>
items.find(
( item ) => item.progress !== undefined && item.progress < 100
),
[ items ]
);
const processingProgress = progressItem?.progress;

// Non-CSM path: files tracked by the editor's mediaUpload wrapper.
const trackedRemaining = tracker ? tracker.total - tracker.completed : 0;

Expand Down Expand Up @@ -223,20 +264,39 @@ export default function UploadProgressSnackbar() {
const total = peakRef.current;
const current = total - remaining + 1;

// Prefer the CSM queue's first original filename, then fall back to
// the tracker's first pending filename.
const isProcessing = processingProgress !== undefined;

// Prefer the filename of the item reporting progress, then the CSM
// queue's first original filename, then the tracker's first pending
// filename.
const filename = truncateFilename(
csmOriginals[ 0 ]?.sourceFile?.name ||
( isProcessing && progressItem.sourceFile?.name ) ||
csmOriginals[ 0 ]?.sourceFile?.name ||
tracker?.pending[ 0 ] ||
__( 'Uploading' )
);

const content =
total === 1
let content;
if ( total === 1 ) {
content = isProcessing
? sprintf(
/* translators: %s: filename. */
__( 'Processing — %s' ),
filename
)
: sprintf(
/* translators: %s: filename. */
__( 'Uploading — %s' ),
filename
);
} else {
content = isProcessing
? sprintf(
/* translators: 1: current upload number, 2: total uploads, 3: filename. */
__( 'Processing %1$d of %2$d — %3$s' ),
current,
total,
filename
)
: sprintf(
/* translators: 1: current upload number, 2: total uploads, 3: filename. */
Expand All @@ -245,14 +305,17 @@ export default function UploadProgressSnackbar() {
total,
filename
);
}

createNotice( 'info', content, {
id: NOTICE_ID,
type: 'snackbar',
isDismissible: false,
explicitDismiss: true,
speak: false,
icon: UPLOAD_SPINNER,
icon: isProcessing
? uploadProgressBar( processingProgress )
: UPLOAD_SPINNER,
onDismiss: () => {
dismissedRef.current = true;
},
Expand All @@ -261,6 +324,8 @@ export default function UploadProgressSnackbar() {
remaining,
sessionTotal,
csmOriginals,
progressItem,
processingProgress,
csmFailureCount,
tracker,
createNotice,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Snackbar } from '@wordpress/components';
import { UPLOAD_DONE, UPLOAD_SPINNER } from '../index';
import { UPLOAD_DONE, UPLOAD_SPINNER, uploadProgressBar } from '../index';

/**
* The `UploadProgressSnackbar` component itself renders no UI: it manages a
Expand Down Expand Up @@ -41,6 +41,19 @@ export const UploadingMultiple: Story = {
},
};

/**
* Shown while a long-running client-side operation (currently GIF-to-video
* conversion) reports determinate progress: the spinner is replaced with a
* progress bar and the verb changes to "Processing".
*/
export const Processing: Story = {
args: {
icon: uploadProgressBar( 60 ),
children: 'Processing — dancing-megaman.gif',
explicitDismiss: true,
},
};

export const Complete: Story = {
args: {
icon: UPLOAD_DONE,
Expand Down Expand Up @@ -68,6 +81,9 @@ export const AllStates: Story = {
<Snackbar icon={ UPLOAD_SPINNER } explicitDismiss>
Uploading 1 of 3 — sunset-over-the-bay.jpg
</Snackbar>
<Snackbar icon={ uploadProgressBar( 60 ) } explicitDismiss>
Processing — dancing-megaman.gif
</Snackbar>
<Snackbar icon={ UPLOAD_DONE }>Upload complete</Snackbar>
</div>
),
Expand Down
27 changes: 27 additions & 0 deletions packages/editor/src/components/upload-progress-snackbar/style.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@use "@wordpress/base-styles/colors" as *;
@use "@wordpress/base-styles/variables" as *;

.editor-upload-progress-snackbar__spinner,
Expand All @@ -19,6 +20,32 @@
margin: 0;
}

.editor-upload-progress-snackbar__progress {
display: inline-flex;
align-items: center;
// Wider than the spinner slot so the determinate bar is readable, same
// height so it stays vertically centered against the notice text.
width: $icon-size * 2;
height: $icon-size;
}

// The snackbar's icon slot is tuned for a square $icon-size icon. When the
// slot holds the wider determinate bar, indent the notice text to clear it.
.components-snackbar__content-with-icon:has(.editor-upload-progress-snackbar__progress) {
padding-left: $icon-size * 2;
}

// Nested under .components-snackbar so the override outranks the
// ProgressBar's own single-class emotion styles, which are injected later in
// the document and would otherwise win the tie.
.components-snackbar .editor-upload-progress-snackbar__progress-bar {
width: 100%;
// The ProgressBar derives both its track and indicator colors from the
// theme foreground, which is near-black and disappears on the snackbar's
// dark surface. Use the snackbar's white foreground instead.
--wp-components-color-foreground: #{$white};
}

.editor-upload-progress-snackbar__check svg {
// Render at the full icon size: the check glyph only occupies the center of
// its viewBox, so a smaller size makes the mark look faint next to the text.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, act } from '@testing-library/react';
import { render, act, screen } from '@testing-library/react';
import { useSelect } from '@wordpress/data';
import { speak } from '@wordpress/a11y';
import UploadProgressSnackbar from '../';
import UploadProgressSnackbar, { UPLOAD_SPINNER } from '../';
import { lock } from '../../../lock-unlock';
import { addFiles, advance, advanceFailed, reset } from '../tracker';

Expand Down Expand Up @@ -38,12 +38,13 @@ function mockQueue( items, failureCount = 0 ) {
);
}

function makeItem( id, name, { parentId } = {} ) {
function makeItem( id, name, { parentId, progress } = {} ) {
return {
id,
sourceFile: { name },
status: 'PROCESSING',
parentId,
progress,
};
}

Expand Down Expand Up @@ -285,6 +286,47 @@ describe( 'UploadProgressSnackbar', () => {
);
} );

it( 'shows a determinate Processing state while an item reports progress', () => {
mockQueue( [
makeItem( '1', 'dancing.gif' ),
// Sideload companion performing the GIF-to-video conversion.
makeItem( '1-video', 'dancing.gif', {
parentId: '1',
progress: 42,
} ),
] );
render( <UploadProgressSnackbar /> );
expect( mockCreateNotice.mock.calls[ 0 ][ 1 ] ).toBe(
'Processing — dancing.gif'
);
// The icon slot holds the progress bar rather than the spinner.
const { icon } = mockCreateNotice.mock.calls[ 0 ][ 2 ];
expect( icon ).not.toBe( UPLOAD_SPINNER );
render( icon );
// `hidden` because the icon-slot wrapper is aria-hidden.
expect(
screen.getByRole( 'progressbar', { hidden: true } )
).toHaveValue( 42 );
} );

it( 'returns to the uploading state once reported progress reaches 100', () => {
mockQueue( [
makeItem( '1', 'dancing.gif' ),
// Conversion finished; the companion is now uploading its result.
makeItem( '1-video', 'dancing.gif', {
parentId: '1',
progress: 100,
} ),
] );
render( <UploadProgressSnackbar /> );
expect( mockCreateNotice.mock.calls[ 0 ][ 1 ] ).toBe(
'Uploading — dancing.gif'
);
expect( mockCreateNotice.mock.calls[ 0 ][ 2 ].icon ).toBe(
UPLOAD_SPINNER
);
} );

it( 'includes a spinner icon on the in-progress notice', () => {
mockQueue( [ makeItem( '1', 'photo.jpg' ) ] );
render( <UploadProgressSnackbar /> );
Expand Down
4 changes: 4 additions & 0 deletions packages/upload-media/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@

- `vipsResizeImage`, `vipsCompressImage`, and `vipsConvertImageFormat` now accept their optional parameters (`smartCrop`, `addSuffix`, `signal`, `scaledSuffix`, `quality`, `interlaced`, `stripMeta`, `maxBitdepth`) as a single trailing `options` object instead of positional arguments ([#80328](https://github.com/WordPress/gutenberg/issues/80328)).

### New Features

- GIF-to-video conversion now reports per-item progress to the store (readable off the queue items returned by `getItems()`), so consumers such as the editor's upload progress snackbar can render a determinate progress bar ([#80325](https://github.com/WordPress/gutenberg/issues/80325)).

### Enhancements

- Honor the `image_strip_meta` and `image_max_bit_depth` filters for client-side processed images via the new `imageStripMeta` and `imageMaxBitDepth` settings, carried in the REST API root index ([#80216](https://github.com/WordPress/gutenberg/issues/80216)).
Expand Down
22 changes: 22 additions & 0 deletions packages/upload-media/src/store/private-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,27 @@ export function transcodeGifItem(
*/
const gifFile = item.file;

/*
* The worker already throttles reports to whole-percent increments,
* but a fast conversion can still emit ~100 reports in a couple of
* seconds. Each dispatch synchronously re-renders every upload-store
* consumer in the editor, which can saturate the main thread and
* starve painting - the progress bar would be in the DOM but never
* drawn. Time-gate dispatches so the browser gets to paint between
* them. Progress is stored on this (sideload) queue item, where
* consumers reading the queue (e.g. the editor's upload progress
* snackbar) can pick it up.
*/
let lastProgressDispatch = 0;
const onProgress = ( progress: number ) => {
const now = Date.now();
if ( progress < 1 && now - lastProgressDispatch < 250 ) {
return;
}
lastProgressDispatch = now;
dispatch.updateItemProgress( id, Math.round( progress * 100 ) );
};

try {
const file = await convertGifToVideo(
item.id,
Expand All @@ -1441,6 +1462,7 @@ export function transcodeGifItem(
{
timeout: args?.timeout,
maxTotalPixels: args?.maxTotalPixels,
onProgress,
}
);

Expand Down
Loading
Loading