Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e781637
Media: Add e2e coverage for a HEIC file uploaded with the wrong exten…
adamsilverstein Aug 17, 2026
90d400a
Media: Detect HEIC uploads from the file header rather than the file …
adamsilverstein Aug 17, 2026
5afd7f0
Merge remote-tracking branch 'origin/trunk' into fix/81707-heic-wrong…
adamsilverstein Aug 17, 2026
ab15cfa
Media: Cover the HEIC-only upload path in Chromium instead of WebKit
adamsilverstein Aug 17, 2026
668178a
Merge remote-tracking branch 'origin/trunk' into fix/81707-heic-wrong…
adamsilverstein Aug 23, 2026
7d3b7a6
Merge remote-tracking branch 'origin/trunk' into fix/81707-heic-wrong…
adamsilverstein Aug 27, 2026
7c150a6
Merge remote-tracking branch 'origin/trunk' into fix/81707-heic-wrong…
adamsilverstein Sep 15, 2026
1914176
Tests: Import Vitest globals in the HEIC buffer test
adamsilverstein Sep 15, 2026
60767e6
Tests: Replace leftover Jest globals with their Vitest equivalents
adamsilverstein Sep 15, 2026
0dd3ce9
Tests: Expect a processing error for an unparseable HEIC fixture
adamsilverstein Sep 15, 2026
b02af6c
Merge branch 'trunk' into fix/81707-heic-wrong-extension
adamsilverstein Sep 15, 2026
8545a61
Merge remote-tracking branch 'origin/trunk' into fix/81707-heic-wrong…
adamsilverstein Sep 16, 2026
66c4e0b
Media: Bound the ImageDecoder HEIC decode with a timeout
singhakanshu00 Sep 16, 2026
a10c5de
Media: Fall through only when the ImageDecoder decode times out
adamsilverstein Sep 16, 2026
06558bf
Media: Fix prettier formatting in prepareItem
adamsilverstein Sep 16, 2026
4d7046c
Update packages/upload-media/src/store/test/actions.ts
adamsilverstein Sep 18, 2026
673f897
Update packages/upload-media/src/store/test/actions.ts
adamsilverstein Sep 18, 2026
d3baab4
Apply batched suggestions from code review
adamsilverstein Sep 18, 2026
426f9fb
Merge remote-tracking branch 'origin/trunk' into fix/81707-heic-wrong…
adamsilverstein Sep 18, 2026
5b1a83c
Media: Link the HEIC changelog entries to the PR and file them under …
adamsilverstein Sep 18, 2026
6143369
Media: Read HEIC brands only from inside the File Type Box
adamsilverstein Sep 18, 2026
d9be887
Merge branch 'trunk' into fix/81707-heic-wrong-extension
t-hamano Sep 22, 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/block-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- Block Patterns, Block Visibility, and Block Lock: Preserve the intended colors of icons converted to strokes. ([#82540](https://github.com/WordPress/gutenberg/pull/82540), [#82754](https://github.com/WordPress/gutenberg/pull/82754))
- Layout: Treat a missing `spacing.blockGap` setting as no block gap support, as the server does. A theme that does not opt into block gap has the setting stored as `null`, which the block settings resolve to `undefined`, so per-block layout styles applied block gap values the front end never renders. An editor that never provides the setting must set it to `true` to keep rendering block gap values ([#82401](https://github.com/WordPress/gutenberg/pull/82401)).
- Block Toolbar: Show the parent block selector for blocks inside patterns and `contentOnly` locked blocks. It selects the nearest parent shown in List View and the breadcrumb ([#82912](https://github.com/WordPress/gutenberg/pull/82912)).
- In HEIC-only upload mode, route files to the HEIC conversion by what they contain rather than by the MIME type the browser infers from their name, so a HEIC photo saved as `.jpg` or `.png` is no longer handed to a server-side path that cannot convert it either ([#81737](https://github.com/WordPress/gutenberg/pull/81737)).

### Internal

Expand Down
26 changes: 13 additions & 13 deletions packages/block-editor/src/components/provider/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
store as uploadStore,
detectClientSideMediaSupport,
isHeicCanvasSupported,
isHeicFile,
} from '@wordpress/upload-media';
import withRegistryProvider from './with-registry-provider';
import useBlockSync from './use-block-sync';
Expand Down Expand Up @@ -39,12 +40,6 @@ let isClientSideMediaEnabledCache = null;
*/
let isHeicCanvasEnabledCache = null;

/**
* HEIC MIME types that should be routed through the upload-media pipeline
* when in HEIC-only mode.
*/
const HEIC_MIME_TYPES = [ 'image/heic', 'image/heif' ];

/**
* Refuses a batch of more than one file on behalf of a caller that only takes
* one - a Cover block's placeholder, say.
Expand Down Expand Up @@ -224,7 +219,7 @@ function mediaUpload(
* @param {Function} $3.onBatchSuccess Function called once all files in a group have completely finished uploading, including thumbnails.
* @param {boolean} $3.multiple Whether the caller accepts more than one file.
*/
function heicMediaUpload(
async function heicMediaUpload(
registry,
settings,
{
Expand All @@ -243,12 +238,17 @@ function heicMediaUpload(
}

const files = Array.from( filesList );
const heicFiles = files.filter( ( file ) =>
HEIC_MIME_TYPES.includes( file.type )
);
const otherFiles = files.filter(
( file ) => ! HEIC_MIME_TYPES.includes( file.type )
);

/*
* Sorted by content rather than by `File.type`, which a browser derives
* from the file extension: a HEIC photo saved as .jpg would otherwise be
* handed to the server-side path, which cannot convert it either.
*/
const heicFiles = [];
const otherFiles = [];
for ( const file of files ) {
( ( await isHeicFile( file ) ) ? heicFiles : otherFiles ).push( file );
}

// When the batch contains both HEIC and non-HEIC files, coordinate
// onBatchSuccess so it fires only after *both* paths have completed.
Expand Down
5 changes: 5 additions & 0 deletions packages/upload-media/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

## Unreleased

### New Features

- Add the `isHeicFile` export, which recognizes a HEIC/HEIF image from its file header rather than from the MIME type the browser infers from the file name ([#81737](https://github.com/WordPress/gutenberg/pull/81737)).

### Bug Fixes

- Detect HEIC uploads from the file header rather than the file name, so a HEIC file with a wrong extension or an empty MIME type is converted instead of leaving the upload stuck, and abandon a stalled `ImageDecoder` decode after a timeout instead of hanging the upload ([#81737](https://github.com/WordPress/gutenberg/pull/81737)).
- An upload step is no longer silently skipped when the same queue item is processed twice. `processItem` started the next operation without checking whether one was already running, so a re-entrant dispatch (a finishing child sideload pinging its parent, or `resumeQueue` walking the whole queue) ran the same handler a second time, and each run finished the operation, shifting two steps off the item's pipeline ([#83031](https://github.com/WordPress/gutenberg/pull/83031)).

## 0.40.0 (2026-09-10)
Expand Down
55 changes: 52 additions & 3 deletions packages/upload-media/src/canvas-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import { getFileBasename } from './utils';
import { parseHeic, type HeicImageData } from './heic-parser';
import { getHeicUnsupportedMessage } from './heic-support';

/**
* How long an `ImageDecoder` decode may run before it is abandoned.
*
* `ImageDecoder.isTypeSupported()` can report HEIC support from the container
* format alone. When the platform codec behind it is missing, `decode()` never
* settles rather than rejecting, and nothing downstream would ever run. The
* ceiling is generous because it applies to every decode, not only a stalled
* one: a large photo on slow hardware has to finish well inside it, or a file
* the browser was decoding fine is abandoned and reported as undecodable.
*/
export const IMAGE_DECODER_TIMEOUT = 15000;

/**
* Raised when no decoding strategy could be used at all.
*
Expand Down Expand Up @@ -79,8 +91,47 @@ export async function canvasConvertToJpeg(
type: file.type,
data: file.stream(),
} );
let videoFrame: VideoFrame | undefined;
let timeoutId: ReturnType< typeof setTimeout > | undefined;
let timedOut = false;
try {
const { image: videoFrame } = await decoder.decode();
/*
* Closing the decoder rejects its pending decode, so a decode
* that never settles becomes a rejection. Which rejection
* wins the race is not defined, so the flag decides below.
*/
const decoded = await Promise.race( [
decoder.decode(),
new Promise< never >( ( _resolve, reject ) => {
timeoutId = setTimeout( () => {
timedOut = true;
decoder.close();
reject(
new Error( 'ImageDecoder decode timed out' )
);
}, IMAGE_DECODER_TIMEOUT );
} ),
] );
videoFrame = decoded.image;
} catch ( error ) {
/*
* A decode that rejects on its own has read the file and found
* it damaged, which the caller reports as such. Only a decode
* that never answered falls through: the browser claimed the
* type and could not deliver, which is what a missing platform
* codec looks like, and strategy 3 decides for itself.
*/
if ( ! timedOut ) {
throw error;
}
} finally {
clearTimeout( timeoutId );
if ( ! timedOut ) {
decoder.close();
}
}

if ( videoFrame ) {
try {
const canvas = new OffscreenCanvas(
videoFrame.displayWidth,
Expand All @@ -105,8 +156,6 @@ export async function canvasConvertToJpeg(
} finally {
videoFrame.close();
}
} finally {
decoder.close();
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/upload-media/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
isHeicCanvasSupported,
clearFeatureDetectionCache,
} from './feature-detection';
export { isHeicFile } from './utils';

export type { ImageFormat } from './store/types';
export type { FeatureDetectionResult } from './feature-detection';
Expand Down
37 changes: 29 additions & 8 deletions packages/upload-media/src/store/private-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ type WPDataRegistry = ReturnType< typeof createRegistry >;
import {
cloneFile,
convertBlobToFile,
getFileBasename,
isAnimatedGif,
isHeicFile,
renameFile,
} from '../utils';
import { canvasConvertToJpeg, HeicUnsupportedError } from '../canvas-utils';
Expand Down Expand Up @@ -843,11 +845,30 @@ export function prepareItem( id: QueueItemId ) {

let heicJpeg: File | null = null;

const isImage = file.type.startsWith( 'image/' );
const isVipsSupported = CLIENT_SIDE_SUPPORTED_MIME_TYPES.includes(
file.type
);
const isHeic = HEIC_MIME_TYPES.includes( file.type );
// A HEIC photo can arrive with a .jpg or .png name, which is all the
// browser has to go on when it types the file, so this goes by content.
const isHeic = await isHeicFile( file );
const isMisnamedHeic =
isHeic && ! HEIC_MIME_TYPES.includes( file.type );

const isImage = file.type.startsWith( 'image/' ) || isHeic;

// A misnamed HEIC file claims a vips-processable type. vips cannot
// decode HEIC, and taking that branch is what strands the upload.
const isVipsSupported =
! isHeic && CLIENT_SIDE_SUPPORTED_MIME_TYPES.includes( file.type );

/*
* Re-label a misnamed file so the rest of the pipeline sees HEIC: the
* WebCodecs decoder is configured from `File.type`, and the untouched
* original is sideloaded under its own name once the attachment exists.
*/
const heicFile = isMisnamedHeic
? new File( [ file ], `${ getFileBasename( file.name ) }.heic`, {
type: HEIC_MIME_TYPES[ 0 ],
lastModified: file.lastModified,
} )
: file;

// Gate very large images out of client-side processing. wasm-vips is
// capped at 1 GiB of memory, so high-megapixel images, especially
Expand All @@ -867,7 +888,7 @@ export function prepareItem( id: QueueItemId ) {
// images routed to the server: the gain map is only preserved by the
// client-side resize path, and the probe runs wasm-vips, which the
// large-image gate above is specifically meant to avoid.
if ( file.type === 'image/jpeg' && ! tooLargeForClient ) {
if ( file.type === 'image/jpeg' && ! isHeic && ! tooLargeForClient ) {
operations.push( OperationType.DetectUltraHdr );
}

Expand Down Expand Up @@ -900,7 +921,7 @@ export function prepareItem( id: QueueItemId ) {
// This matches iOS behavior where HEIC is converted on the fly.
try {
heicJpeg = await canvasConvertToJpeg(
file,
heicFile,
settings.imageQuality ?? DEFAULT_OUTPUT_QUALITY
);
} catch ( error ) {
Expand Down Expand Up @@ -961,7 +982,7 @@ export function prepareItem( id: QueueItemId ) {
updates = {
file: heicJpeg,
sourceFile: heicJpeg,
originalHeicFile: item.file,
originalHeicFile: heicFile,
additionalData: {
...item.additionalData,
generate_sub_sizes: ! vipsAvailable,
Expand Down
91 changes: 90 additions & 1 deletion packages/upload-media/src/store/test/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { createRegistry } from '@wordpress/data';
import { store as uploadStore } from '..';
import { ItemStatus, OperationType, type QueueItem } from '../types';
import { unlock } from '../../lock-unlock';
import { UploadError } from '../../upload-error';
import { ErrorCode, UploadError } from '../../upload-error';
import { vipsCancelOperations, vipsResizeImage } from '../utils';
import { cancelGifToVideoOperations } from '../utils/video-conversion';
type WPDataRegistry = ReturnType< typeof createRegistry >;
Expand Down Expand Up @@ -414,6 +414,95 @@ describe( 'actions', () => {
);
expect( updatedItem.additionalData.convert_format ).toBe( true );
} );

/**
* A HEIC File Type Box, under whatever name and type the test wants.
*
* jsdom exposes none of the decoders canvasConvertToJpeg tries, so
* reaching the HEIC path means failing to decode. The point of these
* tests is that the failure is reported rather than the file uploaded.
*
* The box has no meta box behind it, so it fails the container parse
* rather than the codec lookup, and is reported as a processing error.
* `HEIC_DECODE_ERROR` is reserved for the case where no decoding
* strategy exists at all.
* See https://github.com/WordPress/gutenberg/issues/81123.
*
* @param name File name.
* @param type MIME type the browser would report.
*/
function heicFileTypeBox( name: string, type: string ) {
const ftyp = 'ftypheic\0\0\0\0mif1miaf';
return new File(
[
new Uint8Array( [
0x00,
0x00,
0x00,
4 + ftyp.length,
...[ ...ftyp ].map( ( character ) =>
character.charCodeAt( 0 )
),
] ),
],
name,
{ type }
);
}

it( 'routes a HEIC file the browser could not type through the HEIC conversion path', async () => {
// Windows without the HEVC extension reports no type at all for a
// .heic file. Going by the type alone uploads it as-is, to a server
// that cannot convert it either.
// See https://github.com/WordPress/gutenberg/issues/81043.
const onError = vi.fn();

unlock( registry.dispatch( uploadStore ) ).addItem( {
file: heicFileTypeBox( 'IMG_1250.HEIC', '' ),
onError,
} );

const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];

await unlock( registry.dispatch( uploadStore ) ).prepareItem(
item.id
);

expect( onError ).toHaveBeenCalledWith(
expect.objectContaining( {
code: ErrorCode.IMAGE_TRANSCODING_ERROR,
} )
);
} );

it( 'routes a HEIC file named .jpg through the HEIC conversion path', async () => {
// A name that makes the browser report the file as a JPEG. Taking
// it at its word sends undecodable bytes down the vips path, where
// the upload strands.
// See https://github.com/WordPress/gutenberg/issues/81707.
const onError = vi.fn();

unlock( registry.dispatch( uploadStore ) ).addItem( {
file: heicFileTypeBox( 'example.jpg', 'image/jpeg' ),
onError,
} );

const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];

await unlock( registry.dispatch( uploadStore ) ).prepareItem(
item.id
);

expect( onError ).toHaveBeenCalledWith(
expect.objectContaining( {
code: ErrorCode.IMAGE_TRANSCODING_ERROR,
} )
);
} );
} );

describe( 'concurrent sideloads', () => {
Expand Down
Loading
Loading