diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts
index 3befa57f685e27..66994fc99e16c9 100644
--- a/packages/upload-media/src/store/private-actions.ts
+++ b/packages/upload-media/src/store/private-actions.ts
@@ -6,6 +6,34 @@ import { v4 as uuidv4 } from 'uuid';
/**
* WordPress dependencies
*/
+
+// XMP namespace used by ISO 21496-1 / UltraHDR gain maps.
+const GAIN_MAP_XMP_NAMESPACE = 'http://ns.adobe.com/hdr-gain-map/1.0/';
+
+// Latin1 decoder reused across calls — decodes bytes 1:1 to code points.
+const latin1Decoder = new TextDecoder( 'latin1' );
+
+/**
+ * Detects whether a file contains an HDR gain map by scanning for
+ * the Adobe HDR gain map XMP namespace in the file's binary data.
+ * XMP metadata is located near the start of JPEG files, so we only
+ * need to scan the first 256 KB.
+ *
+ * @param file The image file to check.
+ * @return Whether the file contains a gain map.
+ */
+export async function hasGainMap( file: File ): Promise< boolean > {
+ try {
+ // XMP metadata is in early JPEG markers; 256 KB is more than enough.
+ const slice = file.slice( 0, 256 * 1024 );
+ const buffer = await slice.arrayBuffer();
+ return latin1Decoder
+ .decode( buffer )
+ .includes( GAIN_MAP_XMP_NAMESPACE );
+ } catch {
+ return false;
+ }
+}
import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob';
import type { createRegistry } from '@wordpress/data';
type WPDataRegistry = ReturnType< typeof createRegistry >;
@@ -1175,6 +1203,37 @@ export function generateThumbnails( id: QueueItemId ) {
);
}
+ const sourceType = thumbnailSource.type;
+
+ // When the output format differs from the source, convert to a
+ // lossless PNG intermediate before resizing. This avoids
+ // expensive encoding in the source format (e.g. AVIF ~2s per
+ // encode) for each sub-size, since those buffers are
+ // immediately transcoded to the output format anyway.
+ // PNG is lossless, so there is no generational quality loss.
+ // Skip for images with HDR gain maps — PNG cannot carry gain
+ // map data, so the intermediate would destroy it.
+ let thumbnailFile = file;
+ if (
+ thumbnailTranscodeOperation &&
+ sourceType !== 'image/png' &&
+ sourceType !== 'image/gif' &&
+ ! ( await hasGainMap( file ) )
+ ) {
+ try {
+ thumbnailFile = await vipsConvertImageFormat(
+ item.id,
+ file,
+ 'image/png',
+ 1,
+ false
+ );
+ } catch {
+ // If conversion fails, fall back to the original file.
+ thumbnailFile = file;
+ }
+ }
+
// Group sizes by dimensions to avoid creating duplicate files.
// When multiple size names have the same width/height/crop,
// only one physical file is generated and registered under
@@ -1219,7 +1278,7 @@ export function generateThumbnails( id: QueueItemId ) {
const imageSizeParam = names.length === 1 ? names[ 0 ] : names;
dispatch.addSideloadItem( {
- file,
+ file: thumbnailFile,
batchId,
parentId: item.id,
additionalData: {
@@ -1248,10 +1307,32 @@ export function generateThumbnails( id: QueueItemId ) {
if ( needsScaling ) {
// Rename sourceFile to match the server attachment filename.
- const sourceForScaled = attachment.filename
+ let sourceForScaled = attachment.filename
? renameFile( thumbnailSource, attachment.filename )
: thumbnailSource;
+ // Use PNG intermediate for scaled version too when
+ // format conversion is configured (same rationale as
+ // thumbnails above). Skip for gain map images.
+ if (
+ thumbnailTranscodeOperation &&
+ sourceType !== 'image/png' &&
+ sourceType !== 'image/gif' &&
+ ! ( await hasGainMap( sourceForScaled ) )
+ ) {
+ try {
+ sourceForScaled = await vipsConvertImageFormat(
+ item.id,
+ sourceForScaled,
+ 'image/png',
+ 1,
+ false
+ );
+ } catch {
+ // Fall back to original file on failure.
+ }
+ }
+
// Add scaling to queue.
const scaledOperations: Operation[] = [
[
diff --git a/packages/upload-media/src/store/test/private-actions.js b/packages/upload-media/src/store/test/private-actions.js
index 0928751b317463..91b80e6562bb67 100644
--- a/packages/upload-media/src/store/test/private-actions.js
+++ b/packages/upload-media/src/store/test/private-actions.js
@@ -6,9 +6,14 @@ import { createBlobURL, revokeBlobURL } from '@wordpress/blob';
/**
* Internal dependencies
*/
-import { getTranscodeImageOperation, finalizeItem } from '../private-actions';
+import {
+ getTranscodeImageOperation,
+ finalizeItem,
+ generateThumbnails,
+ hasGainMap,
+} from '../private-actions';
import { OperationType } from '../types';
-import { vipsHasTransparency } from '../utils';
+import { vipsHasTransparency, vipsConvertImageFormat } from '../utils';
// Mock @wordpress/blob
jest.mock( '@wordpress/blob', () => ( {
@@ -19,6 +24,7 @@ jest.mock( '@wordpress/blob', () => ( {
// Mock vips utilities
jest.mock( '../utils', () => ( {
vipsHasTransparency: jest.fn(),
+ vipsConvertImageFormat: jest.fn(),
} ) );
describe( 'private actions', () => {
@@ -379,4 +385,232 @@ describe( 'private actions', () => {
expect( finishOperation ).not.toHaveBeenCalled();
} );
} );
+
+ describe( 'generateThumbnails - PNG intermediate conversion', () => {
+ let addSideloadItem;
+ let finishOperation;
+ let dispatch;
+
+ const pngFile = new File( [ 'png-data' ], 'converted.png', {
+ type: 'image/png',
+ } );
+
+ function makeSelect( {
+ sourceType = 'image/avif',
+ outputMimeType = 'image/jpeg',
+ filename = 'photo.avif',
+ } = {} ) {
+ const sourceFile = new File( [ 'test' ], filename, {
+ type: sourceType,
+ } );
+ return {
+ getItem: () => ( {
+ id: 'item-1',
+ file: sourceFile,
+ sourceFile,
+ attachment: {
+ id: 42,
+ filename,
+ missing_image_sizes: [ 'thumbnail', 'medium' ],
+ },
+ } ),
+ getSettings: () => ( {
+ allImageSizes: {
+ thumbnail: { width: 150, height: 150, crop: true },
+ medium: { width: 300, height: 300, crop: false },
+ },
+ imageOutputFormats: outputMimeType
+ ? { [ sourceType ]: outputMimeType }
+ : {},
+ jpegInterlaced: false,
+ pngInterlaced: false,
+ gifInterlaced: false,
+ } ),
+ };
+ }
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ addSideloadItem = jest.fn();
+ finishOperation = jest.fn();
+ dispatch = { addSideloadItem, finishOperation };
+ vipsConvertImageFormat.mockResolvedValue( pngFile );
+ } );
+
+ it( 'should convert AVIF to PNG intermediate when output format differs', async () => {
+ const select = makeSelect( {
+ sourceType: 'image/avif',
+ outputMimeType: 'image/jpeg',
+ } );
+
+ const thunk = generateThumbnails( 'item-1' );
+ await thunk( { select, dispatch } );
+
+ expect( vipsConvertImageFormat ).toHaveBeenCalledWith(
+ 'item-1',
+ expect.any( File ),
+ 'image/png',
+ 1,
+ false
+ );
+ // Each thumbnail sideload item should use the PNG file.
+ const thumbnailCall = addSideloadItem.mock.calls.find(
+ ( call ) => call[ 0 ].additionalData?.image_size === 'thumbnail'
+ );
+ expect( thumbnailCall ).toBeDefined();
+ expect( thumbnailCall[ 0 ].file ).toBe( pngFile );
+ } );
+
+ it( 'should skip PNG conversion when source is already PNG', async () => {
+ vipsHasTransparency.mockResolvedValue( false );
+ const select = makeSelect( {
+ sourceType: 'image/png',
+ outputMimeType: 'image/jpeg',
+ filename: 'photo.png',
+ } );
+
+ const thunk = generateThumbnails( 'item-1' );
+ await thunk( { select, dispatch } );
+
+ expect( vipsConvertImageFormat ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should skip PNG conversion when source and output formats match', async () => {
+ const select = makeSelect( {
+ sourceType: 'image/jpeg',
+ outputMimeType: undefined,
+ filename: 'photo.jpg',
+ } );
+
+ const thunk = generateThumbnails( 'item-1' );
+ await thunk( { select, dispatch } );
+
+ expect( vipsConvertImageFormat ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should skip PNG conversion for GIF source (animated format)', async () => {
+ const select = makeSelect( {
+ sourceType: 'image/gif',
+ outputMimeType: 'image/jpeg',
+ filename: 'animation.gif',
+ } );
+
+ const thunk = generateThumbnails( 'item-1' );
+ await thunk( { select, dispatch } );
+
+ expect( vipsConvertImageFormat ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should convert WebP to PNG intermediate when output format differs', async () => {
+ const select = makeSelect( {
+ sourceType: 'image/webp',
+ outputMimeType: 'image/jpeg',
+ filename: 'image.webp',
+ } );
+
+ const thunk = generateThumbnails( 'item-1' );
+ await thunk( { select, dispatch } );
+
+ expect( vipsConvertImageFormat ).toHaveBeenCalledWith(
+ 'item-1',
+ expect.any( File ),
+ 'image/png',
+ 1,
+ false
+ );
+ } );
+
+ it( 'should fall back to original file when PNG conversion fails', async () => {
+ vipsConvertImageFormat.mockRejectedValue(
+ new Error( 'Conversion failed' )
+ );
+ const select = makeSelect( {
+ sourceType: 'image/avif',
+ outputMimeType: 'image/jpeg',
+ } );
+
+ const thunk = generateThumbnails( 'item-1' );
+ await thunk( { select, dispatch } );
+
+ // Should still create sideload items with original file.
+ const thumbnailCall = addSideloadItem.mock.calls.find(
+ ( call ) => call[ 0 ].additionalData?.image_size === 'thumbnail'
+ );
+ expect( thumbnailCall ).toBeDefined();
+ expect( thumbnailCall[ 0 ].file.type ).toBe( 'image/avif' );
+ } );
+
+ it( 'should skip PNG conversion for images with HDR gain maps', async () => {
+ // Build a JPEG file that contains the gain map XMP namespace.
+ const gainMapXmp =
+ '' +
+ '' +
+ '1.0' +
+ '' +
+ '';
+ const sourceFile = new File( [ gainMapXmp ], 'photo.jpg', {
+ type: 'image/jpeg',
+ } );
+
+ const select = {
+ getItem: () => ( {
+ id: 'item-1',
+ file: sourceFile,
+ sourceFile,
+ attachment: {
+ id: 42,
+ filename: 'photo.jpg',
+ missing_image_sizes: [ 'thumbnail' ],
+ },
+ } ),
+ getSettings: () => ( {
+ allImageSizes: {
+ thumbnail: { width: 150, height: 150, crop: true },
+ },
+ imageOutputFormats: { 'image/jpeg': 'image/avif' },
+ jpegInterlaced: false,
+ pngInterlaced: false,
+ gifInterlaced: false,
+ } ),
+ };
+
+ const thunk = generateThumbnails( 'item-1' );
+ await thunk( { select, dispatch } );
+
+ // PNG conversion should NOT be called because the file has a gain map.
+ expect( vipsConvertImageFormat ).not.toHaveBeenCalled();
+
+ // Thumbnail should use the original source file.
+ const thumbnailCall = addSideloadItem.mock.calls.find(
+ ( call ) => call[ 0 ].additionalData?.image_size === 'thumbnail'
+ );
+ expect( thumbnailCall ).toBeDefined();
+ expect( thumbnailCall[ 0 ].file.type ).toBe( 'image/jpeg' );
+ } );
+ } );
+
+ describe( 'hasGainMap', () => {
+ it( 'should return true for files containing gain map XMP namespace', async () => {
+ const xmpData =
+ '';
+ const file = new File( [ xmpData ], 'hdr.jpg', {
+ type: 'image/jpeg',
+ } );
+ expect( await hasGainMap( file ) ).toBe( true );
+ } );
+
+ it( 'should return false for files without gain map data', async () => {
+ const file = new File( [ 'regular jpeg data' ], 'photo.jpg', {
+ type: 'image/jpeg',
+ } );
+ expect( await hasGainMap( file ) ).toBe( false );
+ } );
+
+ it( 'should return false for empty files', async () => {
+ const file = new File( [], 'empty.jpg', {
+ type: 'image/jpeg',
+ } );
+ expect( await hasGainMap( file ) ).toBe( false );
+ } );
+ } );
} );