From a100933ed5b3d40fef4c27d8b5b286635878a658 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 26 Mar 2026 09:55:16 -0700 Subject: [PATCH 1/4] Use lossless PNG intermediate when transcoding sub-sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the output format differs from the source (e.g. AVIF→JPEG), convert the source to PNG before resizing. This avoids expensive encoding in the source format 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. Animated formats (GIF, WebP) are excluded since PNG does not support animation. --- .../upload-media/src/store/private-actions.ts | 53 +++++- .../src/store/test/private-actions.js | 158 +++++++++++++++++- 2 files changed, 207 insertions(+), 4 deletions(-) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index b06af25f2d6d43..3828665337dfae 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -1167,6 +1167,33 @@ export function generateThumbnails( id: QueueItemId ) { ); } + // 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. + let thumbnailFile = file; + if ( + thumbnailTranscodeOperation && + sourceType !== 'image/png' && + sourceType !== 'image/gif' && + sourceType !== 'image/webp' + ) { + try { + thumbnailFile = await vipsConvertImageFormat( + item.id, + file, + 'image/png', + 1, + false + ); + } catch { + // If conversion fails, fall back to the original file. + thumbnailFile = file; + } + } + for ( const name of sizesToGenerate ) { const imageSize = allImageSizes[ name ]; if ( ! imageSize ) { @@ -1191,7 +1218,7 @@ export function generateThumbnails( id: QueueItemId ) { thumbnailOperations.push( OperationType.Upload ); dispatch.addSideloadItem( { - file, + file: thumbnailFile, onChange: ( [ updatedAttachment ] ) => { // If the sub-size is still being generated, there is no need // to invoke the callback below. It would just override @@ -1231,10 +1258,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( item.sourceFile, attachment.filename ) : item.sourceFile; + // Use PNG intermediate for scaled version too when + // format conversion is configured (same rationale as + // thumbnails above). + if ( + thumbnailTranscodeOperation && + sourceType !== 'image/png' && + sourceType !== 'image/gif' && + sourceType !== 'image/webp' + ) { + 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 5da1dace106aea..de7334fe954349 100644 --- a/packages/upload-media/src/store/test/private-actions.js +++ b/packages/upload-media/src/store/test/private-actions.js @@ -6,9 +6,13 @@ import { createBlobURL, revokeBlobURL } from '@wordpress/blob'; /** * Internal dependencies */ -import { getTranscodeImageOperation, finalizeItem } from '../private-actions'; +import { + getTranscodeImageOperation, + finalizeItem, + generateThumbnails, +} 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 +23,7 @@ jest.mock( '@wordpress/blob', () => ( { // Mock vips utilities jest.mock( '../utils', () => ( { vipsHasTransparency: jest.fn(), + vipsConvertImageFormat: jest.fn(), } ) ); describe( 'private actions', () => { @@ -346,4 +351,153 @@ 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 skip PNG conversion for WebP source (potentially animated)', async () => { + const select = makeSelect( { + sourceType: 'image/webp', + outputMimeType: 'image/jpeg', + filename: 'image.webp', + } ); + + const thunk = generateThumbnails( 'item-1' ); + await thunk( { select, dispatch } ); + + expect( vipsConvertImageFormat ).not.toHaveBeenCalled(); + } ); + + 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' ); + } ); + } ); } ); From 0bab5fdd736c5fec7a2a976a57ef9dc8173e2936 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 26 Mar 2026 10:03:14 -0700 Subject: [PATCH 2/4] Include WebP in PNG intermediate optimization WebP encoding is faster than AVIF but still benefits from the PNG intermediate when the output format differs. Only GIF is excluded since it is typically animated and PNG does not support animation. --- packages/upload-media/src/store/private-actions.ts | 6 ++---- .../upload-media/src/store/test/private-actions.js | 10 ++++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 3828665337dfae..36914de1a6dd28 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -1177,8 +1177,7 @@ export function generateThumbnails( id: QueueItemId ) { if ( thumbnailTranscodeOperation && sourceType !== 'image/png' && - sourceType !== 'image/gif' && - sourceType !== 'image/webp' + sourceType !== 'image/gif' ) { try { thumbnailFile = await vipsConvertImageFormat( @@ -1268,8 +1267,7 @@ export function generateThumbnails( id: QueueItemId ) { if ( thumbnailTranscodeOperation && sourceType !== 'image/png' && - sourceType !== 'image/gif' && - sourceType !== 'image/webp' + sourceType !== 'image/gif' ) { try { sourceForScaled = await vipsConvertImageFormat( diff --git a/packages/upload-media/src/store/test/private-actions.js b/packages/upload-media/src/store/test/private-actions.js index de7334fe954349..490f8150fb4e53 100644 --- a/packages/upload-media/src/store/test/private-actions.js +++ b/packages/upload-media/src/store/test/private-actions.js @@ -467,7 +467,7 @@ describe( 'private actions', () => { expect( vipsConvertImageFormat ).not.toHaveBeenCalled(); } ); - it( 'should skip PNG conversion for WebP source (potentially animated)', async () => { + it( 'should convert WebP to PNG intermediate when output format differs', async () => { const select = makeSelect( { sourceType: 'image/webp', outputMimeType: 'image/jpeg', @@ -477,7 +477,13 @@ describe( 'private actions', () => { const thunk = generateThumbnails( 'item-1' ); await thunk( { select, dispatch } ); - expect( vipsConvertImageFormat ).not.toHaveBeenCalled(); + expect( vipsConvertImageFormat ).toHaveBeenCalledWith( + 'item-1', + expect.any( File ), + 'image/png', + 1, + false + ); } ); it( 'should fall back to original file when PNG conversion fails', async () => { From 60d8fd5dce4c6869043c293df69459da9caf73d8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 29 Mar 2026 21:11:19 -0700 Subject: [PATCH 3/4] Skip PNG intermediate for images with HDR gain maps PNG cannot carry gain map data (ISO 21496-1 / UltraHDR), so using it as an intermediate format would strip HDR information from sub-sizes. Detect gain maps by scanning for the Adobe HDR gain map XMP namespace in the first 256 KB of the file and skip the PNG optimization when present. This preserves the full HDR pipeline for gain map images while keeping the performance win for the common case. --- .../upload-media/src/store/private-actions.ts | 45 ++++++++++- .../src/store/test/private-actions.js | 74 +++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 36914de1a6dd28..1801243e17ace2 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -6,6 +6,41 @@ 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/'; + +/** + * 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(); + const bytes = new Uint8Array( buffer ); + + // Search for the namespace string in the binary data. + const needle = new TextEncoder().encode( GAIN_MAP_XMP_NAMESPACE ); + outer: for ( let i = 0; i <= bytes.length - needle.length; i++ ) { + for ( let j = 0; j < needle.length; j++ ) { + if ( bytes[ i + j ] !== needle[ j ] ) { + continue outer; + } + } + return true; + } + return false; + } catch { + return false; + } +} import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; import type { createRegistry } from '@wordpress/data'; type WPDataRegistry = ReturnType< typeof createRegistry >; @@ -1173,11 +1208,14 @@ export function generateThumbnails( id: QueueItemId ) { // 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' + sourceType !== 'image/gif' && + ! ( await hasGainMap( file ) ) ) { try { thumbnailFile = await vipsConvertImageFormat( @@ -1263,11 +1301,12 @@ export function generateThumbnails( id: QueueItemId ) { // Use PNG intermediate for scaled version too when // format conversion is configured (same rationale as - // thumbnails above). + // thumbnails above). Skip for gain map images. if ( thumbnailTranscodeOperation && sourceType !== 'image/png' && - sourceType !== 'image/gif' + sourceType !== 'image/gif' && + ! ( await hasGainMap( sourceForScaled ) ) ) { try { sourceForScaled = await vipsConvertImageFormat( diff --git a/packages/upload-media/src/store/test/private-actions.js b/packages/upload-media/src/store/test/private-actions.js index 490f8150fb4e53..7f2de0e5585fdd 100644 --- a/packages/upload-media/src/store/test/private-actions.js +++ b/packages/upload-media/src/store/test/private-actions.js @@ -10,6 +10,7 @@ import { getTranscodeImageOperation, finalizeItem, generateThumbnails, + hasGainMap, } from '../private-actions'; import { OperationType } from '../types'; import { vipsHasTransparency, vipsConvertImageFormat } from '../utils'; @@ -505,5 +506,78 @@ describe( 'private actions', () => { 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 ); + } ); } ); } ); From b8a9769948e1665681f80ae16393fcd904cc8cc1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 30 Mar 2026 13:59:09 -0700 Subject: [PATCH 4/4] Simplify hasGainMap with TextDecoder and native string search Replace manual byte-by-byte loop with latin1 TextDecoder.decode() and String.includes() which delegates to the engine's optimized native search. Cache the decoder at module level. --- .../upload-media/src/store/private-actions.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 1801243e17ace2..22e3a4196c07e2 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -10,6 +10,9 @@ import { v4 as uuidv4 } from 'uuid'; // 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. @@ -24,19 +27,9 @@ export async function hasGainMap( file: File ): Promise< boolean > { // 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(); - const bytes = new Uint8Array( buffer ); - - // Search for the namespace string in the binary data. - const needle = new TextEncoder().encode( GAIN_MAP_XMP_NAMESPACE ); - outer: for ( let i = 0; i <= bytes.length - needle.length; i++ ) { - for ( let j = 0; j < needle.length; j++ ) { - if ( bytes[ i + j ] !== needle[ j ] ) { - continue outer; - } - } - return true; - } - return false; + return latin1Decoder + .decode( buffer ) + .includes( GAIN_MAP_XMP_NAMESPACE ); } catch { return false; }