Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
85 changes: 83 additions & 2 deletions packages/upload-media/src/store/private-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 >;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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[] = [
[
Expand Down
238 changes: 236 additions & 2 deletions packages/upload-media/src/store/test/private-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
/**
* 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', () => ( {
Expand All @@ -19,6 +24,7 @@
// Mock vips utilities
jest.mock( '../utils', () => ( {
vipsHasTransparency: jest.fn(),
vipsConvertImageFormat: jest.fn(),
} ) );

describe( 'private actions', () => {
Expand Down Expand Up @@ -379,4 +385,232 @@
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(

Check failure on line 514 in packages/upload-media/src/store/test/private-actions.js

View workflow job for this annotation

GitHub Actions / JavaScript (Node.js 20) 3/4

Error: expect(jest.fn()).toHaveBeenCalledWith(...expected) Expected: "item-1", Any<File>, "image/png", 1, false Number of calls: 0 at Object.toHaveBeenCalledWith (/home/runner/work/gutenberg/gutenberg/packages/upload-media/src/store/test/private-actions.js:514:37) at processTicksAndRejections (node:internal/process/task_queues:95:5)

Check failure on line 514 in packages/upload-media/src/store/test/private-actions.js

View workflow job for this annotation

GitHub Actions / JavaScript (Node.js 20) 2/4

Error: expect(jest.fn()).toHaveBeenCalledWith(...expected) Expected: "item-1", Any<File>, "image/png", 1, false Number of calls: 0 at Object.toHaveBeenCalledWith (/home/runner/work/gutenberg/gutenberg/packages/upload-media/src/store/test/private-actions.js:514:37) at processTicksAndRejections (node:internal/process/task_queues:95:5)

Check failure on line 514 in packages/upload-media/src/store/test/private-actions.js

View workflow job for this annotation

GitHub Actions / JavaScript (Node.js 20) 4/4

Error: expect(jest.fn()).toHaveBeenCalledWith(...expected) Expected: "item-1", Any<File>, "image/png", 1, false Number of calls: 0 at Object.toHaveBeenCalledWith (/home/runner/work/gutenberg/gutenberg/packages/upload-media/src/store/test/private-actions.js:514:37) at processTicksAndRejections (node:internal/process/task_queues:95:5)

Check failure on line 514 in packages/upload-media/src/store/test/private-actions.js

View workflow job for this annotation

GitHub Actions / JavaScript (Node.js 20) 1/4

Error: expect(jest.fn()).toHaveBeenCalledWith(...expected) Expected: "item-1", Any<File>, "image/png", 1, false Number of calls: 0 at Object.toHaveBeenCalledWith (/home/runner/work/gutenberg/gutenberg/packages/upload-media/src/store/test/private-actions.js:514:37) at processTicksAndRejections (node:internal/process/task_queues:95:5)
'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 =
'<x:xmpmeta>' +
'<rdf:Description xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/">' +
'<hdrgm:Version>1.0</hdrgm:Version>' +
'</rdf:Description>' +
'</x:xmpmeta>';
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 =
'<rdf:Description xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/">';
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 );
} );
} );
} );
Loading