Skip to content

Media: Add JPEG XL (JXL) upload support (backport GB #77584) - #12006

Draft
adamsilverstein wants to merge 21 commits into
WordPress:trunkfrom
adamsilverstein:backport/77584-jxl-lazy-load
Draft

adamsilverstein wants to merge 21 commits into
WordPress:trunkfrom
adamsilverstein:backport/77584-jxl-lazy-load

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented May 28, 2026 •

Copy link
Copy Markdown
Member

Claude reworked this description to match the new direction in the Gutenberg PR:

Adds server-side support for JPEG XL (JXL) uploads, so the client-side media pipeline can keep a .jxl as the attachment and generate its sub-sizes as JXL.

Trac ticket: https://core.trac.wordpress.org/ticket/52788

This is the server-only backport of WordPress/gutenberg#77584. The lazy-loaded JXL WASM module, the vips worker, and the upload store changes ship through the normal Gutenberg package sync.

Why

Chrome 155 and Firefox 158 display JXL by default, and Safari has partial support since 17, so the Gutenberg PR no longer converts JXL uploads to JPEG. A JXL upload is handled like a WebP or AVIF: the .jxl is uploaded as-is with server-side sub-sizes off, and the browser generates JXL sub-sizes with vips and sideloads them.

Core does not understand .jxl today:

  • image/jxl is not in the default upload_mimes list, so the editor's allowed-types check rejects the file.
  • PHP's fileinfo reports JXL as image/x-jxl rather than image/jxl, so wp_check_filetype_and_ext() treats the upload as a MIME mismatch and rejects it.
  • getimagesize() cannot read JXL at all, so the attachment gets no width or height. Without them, core reports no missing sub-sizes, the client generates none, and the sideload endpoint cannot read the dimensions of the sub-sizes it is sent.

Changes

src/wp-includes/media.php

  • New wp_get_jxl_info( $filename ) - decodes the width and height from the codestream SizeHeader (ISO/IEC 18181-1, A.3), for both a naked codestream and one in an ISOBMFF container (jxlc or jxlp box). Swaps them for orientations that transpose the image, since decoders report the upright size. Shaped like wp_get_webp_info() and wp_get_avif_info().
  • wp_getimagesize() falls back to wp_get_jxl_info() for a JXL file, returning 'mime' => 'image/jxl'. This is what makes the sideload endpoint and wp_get_missing_image_subsizes() work for JXL.
  • New wp_is_jxl_file( $file ) - recognizes both JXL flavors by their magic bytes. It shares the codestream reader, so a container must actually hold a codestream.
  • New wp_add_jxl_upload_mimes( $mimes ) - adds 'jxl' => 'image/jxl'.
  • New wp_filter_jxl_filetype_and_ext( $data, $file, $filename, $mimes ) - restores image/jxl when core could not identify the file, the extension is .jxl, the magic bytes match, and image/jxl is one of the allowed MIME types. Arguments that are not strings are passed through untouched.

src/wp-admin/includes/image.php

  • wp_generate_attachment_metadata() builds image metadata for image/jxl, as it already does for image/heic, so a JXL attachment records its width, height and file.

src/wp-includes/default-filters.php

  • add_filter( 'upload_mimes', 'wp_add_jxl_upload_mimes', 5 ); - ahead of check_upload_mimes(), so a network's "Upload file types" setting still decides whether JXL is allowed.
  • add_filter( 'wp_check_filetype_and_ext', 'wp_filter_jxl_filetype_and_ext', 10, 4 );

Security notes

  • The filetype filter only acts when core has already failed to identify the file (empty( $data['type'] )), the extension is .jxl, and the magic bytes match. A JPEG renamed to .jxl is not rescued.
  • It checks the passed $mimes (or get_allowed_mime_types() when none are passed) first, so it never overrides a site or network that disallows JXL.
  • The header reads open the file read-only and read at most a few dozen bytes per box while walking the container.

Testing

Test in WordPress Playground

  • vendor/bin/phpunit tests/phpunit/tests/media/wpJxlUpload.php covers the magic bytes (codestream, container, a container with no codestream, JPEG, PNG, short and missing files), wp_get_jxl_info() (sizes stored as multiples of eight, an aspect ratio, a rotated image, jxlc and jxlp containers, a non-JXL file), wp_getimagesize() on a JXL, the attachment metadata of an uploaded JXL, and the filetype filter cases. A multisite-only test checks the network's upload file types. Passes locally on single site and multisite, and the media, image and upload groups still pass.
  • Manual: with client-side media processing enabled, upload a real .jxl. The attachment should be image/jxl with its real width and height, and every sub-size should be a .jxl.
  • Manual: rename a JPEG to fake.jxl and upload it. It should be rejected.
  • Manual: remove jxl with an upload_mimes filter and upload a real .jxl. It should be rejected with "Sorry, you are not allowed to upload this file type."

Notes

AI Use

Code and description both written with 🤖 Claude Code. I will review and test.

Bypass the `wp_prevent_unsupported_mime_type_uploads` check
for HEIC/HEIF images so they can be stored even when the
server's image editor doesn't support them. The client-side
canvas fallback handles processing using the browser's
native HEVC decoder via createImageBitmap().
…back

# Conflicts:
#	src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
…back

# Conflicts:
#	src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
Extends the /wp/v2/media/<id>/sideload route so the client-side media
flow can upload a HEIC/HEIF companion original alongside the JPEG
derivative:

- Adds 'original-heic' to the allowed image_size enum. The companion
  filename is recorded under $metadata['original'] so it never
  collides with 'original_image', which the scaled-sideload flow owns.
- Adds a 'generate_sub_sizes' boolean arg (default false) so callers
  that handle processing client-side can suppress server-side sub-size
  generation per request.
- Adds 'image/heif' to the image_output_formats input list returned by
  the REST API root index.

Backport of GB #76731.
When the client-side media flow sideloads a HEIC original alongside a
JPEG derivative, the HEIC filename is stored in $metadata['original'].
wp_delete_attachment_files() only tracks 'original_image', so without
this hook the HEIC file would linger on disk after the attachment is
removed.

wp_delete_attachment_heic_companion_file() reads the meta key, guards
against non-string values (e.g. arrays written by other flows), and
deletes the file when present. Hooked into the delete_attachment
action via default-filters.php.

Backport of GB #76731, with the is_string() guard from GB #78128.
Adds REST API controller tests:
- The sideload route exposes 'original-heic' in the image_size enum.
- The sideload route exposes a 'generate_sub_sizes' boolean arg
  defaulting to false.
- Sideloading an 'original-heic' image writes the filename to
  $metadata['original'] and leaves 'original_image' untouched.

Adds wp_delete_attachment_heic_companion_file() unit tests:
- The companion HEIC is removed when the attachment is deleted.
- The hook is a no-op when $metadata['original'] is missing.
- The hook bails when $metadata['original'] is not a string
  (regression coverage for the guard added in GB #78128).
…deload.

The test was sending JPEG bytes with a .heic filename, which wp_check_filetype_and_ext() corrected to canola-1.jpg before the metadata assertion ran. Switch to the real test-image.heic fixture, set Content-Type accordingly, and pass convert_format=false to disable the default HEIC -> JPEG output mapping so the .heic extension is preserved.
Add 'original-heic' to the image_size enum and the missing generate_sub_sizes arg so the schema fixture matches what the live REST index now reports. Without this the test-fixtures step fails the git diff --exit-code check.
…rvation.

Add server-side support for the client-side media flow that decodes
JPEG XL uploads to JPEG in the browser via lazy-loaded VIPS/WASM and
preserves the original .jxl as a companion file (under
$metadata['original'], the same slot the HEIC companion uses).

* Register `image/jxl` as an allowed upload MIME type. WordPress core
  does not include JXL in its default MIME list, so without this filter
  the editor's allowed-types check rejects the original .jxl before it
  can be converted.

* Add `wp_is_jxl_file()`, a magic-bytes check that recognizes both JXL
  flavors (the naked codestream beginning 0xFF 0x0A and the 12-byte
  ISOBMFF container signature).

* Add `wp_filter_jxl_filetype_and_ext` on `wp_check_filetype_and_ext`.
  PHP's fileinfo reports JXL as `image/x-jxl` (and getimagesize() can't
  identify it at all), so core's MIME-mismatch check otherwise rejects
  the upload. When the file is genuinely JXL, restore the expected
  ext/type so the upload is allowed.

* Accept `original-jxl` as a sideload `image_size`. The branch is
  collapsed with `original-heic` since both write to
  $metadata['original'] and are mutually exclusive.

* Rename `wp_delete_attachment_heic_companion_file()` to
  `wp_delete_attachment_preserved_original_companion_file()` since the
  same hook now covers both HEIC and JXL companions. The implementation
  is unchanged; only the name and docblock are generalized. The test
  file is renamed alongside.

Backport of WordPress/gutenberg#77584. Stacked on WordPress#11323 (HEIC canvas
fallback).
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Adding JPEG XL upload support exposes the original-jxl size in the attachment
schema, so the generated QUnit fixture must be rebuilt to match. Keeps the
"version-controlled files not modified" CI check green.
@adamsilverstein
adamsilverstein marked this pull request as ready for review June 18, 2026 09:02
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props adamsilverstein.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

…ate delete

Align the JPEG XL backport with the single companion-cleanup convention:
the preserved source-format original (HEIC or JXL) is recorded under the
`source_image` attachment-metadata key and deleted by one `delete_attachment`
hook that lands in the HEIC upload backport.

- Write the sideloaded `original-jxl` companion to `$metadata['source_image']`
  instead of a bare `original` key.
- Drop `wp_delete_attachment_preserved_original_companion_file()` and its hook;
  the HEIC backport registers the single cleanup hook that reads `source_image`.
- Drop the `original-heic` image_size enum value and branch (owned by the HEIC
  backport); this PR carries only `original-jxl`.
- Update the route enum test to assert `original-jxl`, remove the orphaned
  preserved-original delete test, and drop the HEIC sideload test (its path is
  identical to the JXL one and is covered by the HEIC backport).
@adamsilverstein
adamsilverstein marked this pull request as draft June 27, 2026 18:26
@adamsilverstein

Copy link
Copy Markdown
Member Author

Marking as draft since we are punting JXL support for client side media for now, see WordPress/gutenberg#77584

adamsilverstein and others added 5 commits September 18, 2026 11:04
# Conflicts:
#	src/wp-includes/rest-api/class-wp-rest-server.php
#	src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
#	tests/qunit/fixtures/wp-api-generated.js
Nothing in sideload_item() reads it - the only readers are create_item()
and its permissions check, which use the argument registered by
get_endpoint_args_for_item_schema(). Trunk now asserts the sideload route
does not advertise it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGarM6qGwHbEknVuo9tTHS
The blanket bypass also covered image/heic-sequence and
image/heif-sequence, which trunk asserts must stay rejected because
neither the server nor the client fallback can handle them. The
still-image bypass belongs to its own backport, WordPress#13042.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGarM6qGwHbEknVuo9tTHS
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGarM6qGwHbEknVuo9tTHS
The branch adds image_output_formats to the REST index; the entry was
lost while resolving the trunk merge in wp-api-generated.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGarM6qGwHbEknVuo9tTHS
adamsilverstein and others added 5 commits October 22, 2026 16:17
# Conflicts:
#	src/wp-includes/rest-api/class-wp-rest-server.php
Core also empties the type when image/jxl is not an allowed MIME type, so
restoring it whenever the type was empty let JXL uploads through on sites
that removed it, such as a network whose upload file types leave it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcYRm3egtiB9yfX1VFaatb
7.2 is in beta, so the JXL support lands in the next release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcYRm3egtiB9yfX1VFaatb
Gutenberg now keeps a JXL upload in its own format and generates its sub-sizes as JXL in the browser, instead of converting it to JPEG. That needs the server to know the image's size, which getimagesize() cannot read for JXL. Teach wp_getimagesize() to read it from the codestream header, and build attachment metadata for image/jxl, so core reports the missing sub-sizes and the sideload endpoint accepts them.

Also register JXL ahead of check_upload_mimes(), leave untyped arguments in the filetype filter alone, and have wp_is_jxl_file() share the codestream reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uBiB3PfgBJpX6dbD1mN4B
@adamsilverstein adamsilverstein changed the title Media: Add JPEG XL (JXL) upload support with companion-original preservation (backport GB #77584) Media: Add JPEG XL (JXL) upload support (backport GB #77584) Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant