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 ); + } ); + } ); } );