Skip to content

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

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

Media: Add JPEG XL (JXL) upload support with companion-original preservation (backport GB #77584)#12006
adamsilverstein wants to merge 9 commits into
WordPress:trunkfrom
adamsilverstein:backport/77584-jxl-lazy-load

Conversation

@adamsilverstein
Copy link
Copy Markdown
Member

Summary

  • Register image/jxl as an allowed upload MIME type.
  • Add wp_is_jxl_file(), a magic-bytes check that recognizes both JXL flavors (naked codestream + ISOBMFF container).
  • Add wp_filter_jxl_filetype_and_ext() on wp_check_filetype_and_ext to restore the canonical image/jxl MIME when PHP's fileinfo reports the non-canonical image/x-jxl form.
  • Accept original-jxl as a sideload image_size. The HEIC and JXL branches in sideload_item() are collapsed since both write to $metadata['original'] and are mutually exclusive.
  • Rename wp_delete_attachment_heic_companion_file()wp_delete_attachment_preserved_original_companion_file() since the same delete_attachment hook now covers both HEIC and JXL companions. Implementation unchanged; only the name and docblock are generalized. The test file is renamed alongside, and a JXL-companion deletion test is added.

Why

JPEG XL is a modern image format that can losslessly transcode existing JPEGs at significantly smaller size. WordPress core does not recognize .jxl uploads today:

  • image/jxl is not in the default upload_mimes list, so the upload is rejected before any conversion can happen.
  • getimagesize() cannot identify JXL files. PHP's fileinfo extension reports them as image/x-jxl (not the registered image/jxl), so wp_check_filetype_and_ext() flags the upload as MIME-mismatched and rejects it.

When client-side media processing is enabled, the browser decodes the uploaded JXL to a JPEG using a lazy-loaded VIPS/WASM module and uploads the JPEG as the primary attachment. The original .jxl is preserved as a companion file under $metadata['original'], exactly like the HEIC companion-original pattern in #11323.

This PR is the server-only backport of WordPress/gutenberg#77584. The lazy WASM loader, VIPS worker, store actions, and editor UI ship through the normal Gutenberg → Core package sync.

Base

Stacked on #11323 (HEIC canvas fallback) — this PR generalizes the HEIC cleanup function so it can serve both formats. Merge #11323 first, then rebase this branch onto trunk.

Backport scope

src/wp-includes/media.php

  • New wp_is_jxl_file( $file ) — 12-byte magic-bytes check; recognizes both JXL flavors.
  • New wp_add_jxl_upload_mimes( $mimes ) — adds 'jxl' => 'image/jxl'.
  • New wp_filter_jxl_filetype_and_ext( $data, $file, $filename ) — restores image/jxl when fileinfo returned image/x-jxl (or nothing) and the magic bytes confirm it.
  • wp_delete_attachment_heic_companion_file()wp_delete_attachment_preserved_original_companion_file(). Implementation identical; docblock generalized.

src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

  • register_routes() — adds original-jxl to the sideload image_size enum.
  • sideload_item() — collapses the original-heic branch with original-jxl (both write to $metadata['original']).

src/wp-includes/default-filters.php

  • add_filter( 'upload_mimes', 'wp_add_jxl_upload_mimes' );
  • add_filter( 'wp_check_filetype_and_ext', 'wp_filter_jxl_filetype_and_ext', 10, 3 );
  • delete_attachment action updated to call the renamed function.

Security model

The companion-file cleanup carries the existing HEIC hardening unchanged:

  1. wp_basename() always strips path components from the recorded metadata before joining.
  2. wp_delete_file_from_directory() requires the resolved path to be a regular file strictly inside wp_get_upload_dir()['basedir'].

The new magic-bytes check reads only the first 12 bytes via fopen( 'rb' ) / fread() and closes the handle immediately. It runs on the uploaded temp file before the file is moved into the uploads directory, so it never touches a user-controlled path.

The wp_filter_jxl_filetype_and_ext() filter is conservative: it only acts when core has already failed to identify the file (empty( $data['type'] )) AND the extension is .jxl AND the magic bytes match. A JPEG renamed to .jxl is not rescued.

Test plan

  • vendor/bin/phpunit tests/phpunit/tests/media/wpJxlUpload.php — 10 new tests pass (magic-bytes for naked + ISOBMFF + JPEG + PNG + short file + missing file; upload_mimes filter; filetype filter happy path, already-recognized passthrough, and JXL-extension-with-wrong-magic rejection).
  • vendor/bin/phpunit tests/phpunit/tests/media/wpDeleteAttachmentPreservedOriginalCompanionFile.php — existing HEIC tests still pass + new JXL companion deletion test passes.
  • Manual: with client-side media processing enabled in Chromium 137+, upload a real .jxl file. Confirm the JPEG derivative is the primary attachment and the .jxl is preserved on disk and recorded in _wp_attachment_metadata['original'].
  • Manual: rename a JPEG to evil.jxl and attempt to upload. Confirm the upload is rejected (magic bytes fail).
  • Manual: delete a JXL-derived attachment. Confirm both the JPEG and the .jxl are removed.
  • Manual: delete a HEIC-derived attachment (regression check). Confirm the HEIC companion is still removed via the renamed hook.

Related

  • Upstream: WordPress/gutenberg#77584 (OPEN, currently CONFLICTING with trunk). Keep this PR in draft until the GB PR merges.
  • Stacked on: #11323 — HEIC canvas fallback. This PR renames the function added there.
  • Part of the 7.1 client-side media reintroduction: #11324.

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.

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