Media: Add UltraHDR (ISO 21496-1) gain map support#74873
Conversation
|
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 Unlinked AccountsThe following contributors have not linked their GitHub and WordPress.org accounts: @kleisauke, @gregbenz. Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases. If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
Flaky tests detected in e847253. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/27102056948
|
|
Size Change: +228 kB (+2.77%) Total Size: 8.44 MB 📦 View Changed
|
andrewserong
left a comment
There was a problem hiding this comment.
Nice, this is testing well for me! I was going to ask how heavy the upstream library is, and then I noticed that it's from your own repo, so I assume you're quite comfortable with it 😄
The main question I have is to do with the idea that if most uploaded JPEGs are not UltraHDR, how expensive is the operation to detect it? If it's very cheap, then this seems like a great addition. I've left a comment, but before I looked at the code, my assumption is that this kind of operation would flag if it succeeds / does something, but wouldn't warn if it can't find an UltraHDR data.
What do you think?
This is all still behind an experiment of course, so don't let my questions here stop you from progressing! (Also I'll be AFK early next week, so apologies if my reply is delayed until mid-week)
I am comfortable with it working based on the tests, although I also plan to ask for help doing manual testing to verify it properly handles real world images.
That is a great question and worth investigating more. I will review the detection logic to see if it can be optimized. Also, I'm still not certain about what we should output. My assumption is that UltraHDR JPEGs are decoder compatible with existing JPEGs so we would want to output UltraHDR for uploaded UltraHDR. Then again, some users may prefer to use the SDR version for their website exclusively because they want smaller file sizes and don't care about HDR. We might need a new way for developers to implement that since the mime type is still image/jpeg and out=r
Hopefully for something fun! |
|
@adamsilverstein libultrahdr includes a probe() method to validate that required components are present without decoding. I haven't tested it for performance, but it may serve as inspiration. Gain maps require an auxiliary image. You could check the header and reject if that's missing. If it exists, you could then further check for a supported gain map (ie a more robust check, but you've already quickly skipped most images that aren't relevant). There are two kinds of map encoding supported by libultra: (a) ISO standard gain maps and (b) gain maps encoded with the Android XMP spec. The ISO encoding is widely used for new images now and has been for a while in most encoders. Many now dual encode (as the auxiliary image is the same and you're just writing redundant data - binary in the aux image for ISO and standard XMP for the Android spec). The only thing you'd miss by skipping XMP would be older images captured on Android or exported with old versions of Adobe software (Apple has a proprietary method they used pre-ISO and it is not supported by libultra). I believe skipping XMP would generally be ok to do, as it is a very limited edge case (and the impact would be that transcoding falls back to SDR rather than total failure). |
|
@adamsilverstein If the source includes a supported gain map, the output should preserve it by default. SharpJS is working towards a keepGainMap() method which should do this, however, I'm not sure how much guidance libvips needs. Coordinating with SharpJS may make sense here, as both efforts are likely chasing the same goals for default transcoding (ie ability to do basic crop/resize/compress while retaining an output which shows high fidelity to the original). Questions for transcoding will inevitably come up around tuning (compression of the base image / map). Testing to make sure the final result is ok based on the compression applied to both the base and map will be an important step to validate quality vs size objectives. Existing approaches for the base image probably translate well, and this is probably mostly a question of how to compress the map. Aside from that, most of the options chosen for encoding should remain as they were in the source. The metadata should be unchanged (HDR capacity, offsets, ranges, etc). The map scaling (1:1 vs 1:2 or 1:4) and number of channels (1 for luminosity only map vs 3 for full color) should remain the same. It should not alter sub-sampling (ie keep 444 if that's how it was encoded as the map is not a color image and the assumptions behind sub-sampling are not applicable in this domain, and can cause artifacts if altered). An affordance for aggressive compression of the image (including loss of HDR) may be useful for some users / workflows, but is not required and could get complicated. There are also other ways beyond stripping the map which can compress the image without stripping HDR, but they involve quality tradeoffs that would need careful assessment. For example, you could downgrade the map from 1:1 resolution in full color to a 1:2 map with luminosity only. That will cause loss of high frequency detail in the HDR rendition, as well as color error (which may be a small or significant issue depending on the relationship of the SDR to the HDR rendition). Due to the effort, complexity, and risk of confusion here, it may be best that initial support just preserve the gain map and not use more complex options to compress the image further. That can be managed now via the encoding of the file uploaded to WordPress (upload SDR if you only want SDR, phone captures are already low resolution luminosity maps, etc). Advanced transcoding for compression might best be left for 3rd-party plugins to address. |
great, thanks for the tip - i will take a look at that.
👍🏼
Right, be default we will always try to use the uploaded format for the output (so uploaded UltraHDR should output UltraHDR). My pondering was more about how would enable developers to choose to extract only the SDR image for output/front end if thats what they wanted. The current output mapping won't work, so we may need a more explicit filter to choose HDR or SDR encoding when an UltraHDR is uploaded. |
I created an issue to add a probe mode to lib-open-ultrahdr: |
Thanks again for the tip @gregbenz - I added the probe feature in adamsilverstein/lib-open-ultrahdr#6 based on the libultrahdr approach |
Thanks again for the question @andrewserong - it turns out libultrahdr has a |
|
@adamsilverstein See gainmapQuality in gregbenzphotography.com/test/wasm-vips-hdr-demo/wasm-vips-hdr-transcode.js for how I'm controlling it independently of the base quality (I set same as default but my demo page allows independent control). The "scope:" in the image URL shows up with standard JPGs, I'm assuming this is the nature of the playground environment. |
|
hmmm. i thought we already returned the filtered with that in place, we can:
|
# Conflicts: # packages/upload-media/src/store/private-actions.ts
Resolve conflicts: - packages/vips/src/index.ts: keep getUltraHdrInfo; drop batchResizeImage (removed in trunk's 2.0.0 breaking change, #77247). - packages/vips/CHANGELOG.md: keep UltraHDR entries under Unreleased; place trunk's 2.0.0 (2026-05-27) breaking change section below. - packages/upload-media/CHANGELOG.md: keep UltraHDR enhancement under Unreleased; place trunk's 0.32.0 (2026-05-27) bug fix section below.
|
Thanks for the careful reviews, @kleisauke and @andrewserong! Just rebased on @kleisauke —
|
getVips() calls vipsInstance.Cache.max(0) to disable libvips's operation cache. Without it in the wasm-vips mock, the first getUltraHdrInfo test threw inside getVips, never invoking uhdrloadBuffer. Because the queued mockReturnValueOnce values were not consumed and jest.clearAllMocks() does not drain them, the next tests then read stale mock returns and produced unexpected results.
kleisauke
left a comment
There was a problem hiding this comment.
I'm not a WordPress expert, but the wasm-vips part and its usage w.r.t. UltraHDR processing looks good to me.
|
@gregbenz this is ready for additional testing |
|
Thanks for the updates @adamsilverstein! Just double-checking but is the intention that generated sub-size images retain their gain maps? In manual testing (with one Greg's lovely photos: https://gregbenzphotography.com/hdr-gain-map-gallery/) it appears that the uploaded file has the gain map, but if I go to view the generated subsize jpegs on my phone (the only HDR display I have unfortunately!), they appear to be rendering in SDR and the highlights are no longer super bright. Is it working for you? I tried running (Code-wise this is looking nice, good work simplifying things!) |
Thanks for testing! Yes - the intention is for output files to also include a gain map. I haven't had a chance to manually test that though so appreciate you doing so. I'll dig in here to see if I can figure out why its failing and ideally add some tests that validates gain maps are present in sub-sized images. |
Move the ultraHdrItems set cleanup from finalizeItem to removeItem so the entry is cleared on both successful completion and cancellation, preventing the set from growing over a long editing session.
The standalone open-ultrahdr/open-ultrahdr-wasm dependencies were never landed, so the changelog should not mention removing them.
Add coverage proving UltraHDR gain maps survive into generated sub-sizes: - upload-media: generateThumbnails routes every sub-size and the -scaled copy through resize without transcoding for UltraHDR sources (transcoding would strip the gain map), and transcodes normally once the item is no longer tracked. - vips: resizeImage preserves the gain map on simple downscales and boolean crops (relying on libvips thumbnail) without cropping it manually, in addition to the existing positional-crop coverage. - e2e: assert both the cropped thumbnail and the proportional medium sub-size carry the gain map and match their registered dimensions.
…ents Restore the file to match trunk; the reordering was an unrelated leftover and is not needed for UltraHDR support.
Sub-size gain map output: investigated and confirmed ✅The headline question here was whether generated sub-sizes actually carry the ISO 21496-1 gain map. I traced the full pipeline and confirmed they do, for every size:
Tests added to confirm it
Other feedback@kleisauke — all three of your points are in:
|
|
Thanks for the update @adamsilverstein. How did you go with the manual testing? The workflow I've been using has been to download one of @gregbenz' photos from https://gregbenzphotography.com/hdr-gain-map-gallery/ then upload it to my test site, and then download the generated JPEGs onto my phone (the only HDR display I have). The original image displays ultra bright areas as expected, but the generated sub-sizes don't. (I retested and the generated ones still appear to be rendering in SDR). I'm slightly wary of trying to review this PR or approve it without being able to verify that it works on one of my devices. So I might need to defer to you and @gregbenz on this one! Edit: one other question: are there some testing steps I can use to inspect the generated JPEG that it really does include the gain map data? |
|
@andrewserong The free Adobe gain map demo app will tell you (in text on the right column) if the image is still a valid gain map. You can hover over the image to read decoded RGB pixel values, which should be consistent with the source (at least not near edges where resampling will affect results). And you can click the radio button to see the gain map itself. The demo app has a quirk where opening similar images at launch may not show all, you can right click the filmstrip at the bottom and add there via file picker to work around that. You can test with this image, which is built to catch a range of transcoding / quality issues. Feel free to share a result if you need 3rd party review. Chromium browsers on a MacBook Pro are an ideal way to review. Set brightness to more than 50% and you'll have the full 4 stops of headroom. Viewing on a phone is helpful, but Apple decodes in a non-standard way and Android is limited to 2.3 stops (even though the hardware could do much more). So it's great for consumption and basic checks, but you won't see the full HDR rendering. Any failure of quality is likely still visible for this image on both iOS and Android, so the full and proper 4 stops decoding is just nice to have - but not required for visual testing. |
andrewserong
left a comment
There was a problem hiding this comment.
Wonderful, thanks so much for the testing instructions @gregbenz and that test image! I currently use a Macbook Air and retired my old Macbook Pro as I wanted something lighter for travel. Sadly this means I am currently without a proper HDR screen of any kind other than my iPhone 😆
But that testing app and your testing image worked perfectly, and I can now confirm in my local env that the sub-sizes contain the gain maps and they're resized as expected:
And thanks again for all the back and forth @adamsilverstein, this LGTM now. I left a couple of nit comments, but nothing blocking 🙂
detectUltraHdr wrote `ultrahdr`/`hdr_capacity` onto the item's local attachment meta, but uploadItem only sends `additionalData` to the server and the upload response overwrites the local attachment anyway, so the meta was never persisted or read. All gain-map routing is driven by the `ultraHdrItems` set, so the thunk now just probes and tracks the item.
Scaling the crop rect to the gain map resolution produced floats, which were passed straight to `crop`. Round to integer pixel coordinates and clamp the width/height to the gain map bounds so the rect never extends past its edges, avoiding sub-pixel alignment ambiguity.
Relocate the one-shot UltraHDR fixture generator from the e2e assets directory into the e2e bin directory alongside the other utility scripts, dropping the leading dot from the filename and the now-unused no-console eslint exception.
# Conflicts: # packages/upload-media/CHANGELOG.md # packages/upload-media/src/store/private-actions.ts
# Conflicts: # packages/upload-media/src/store/private-actions.ts

Summary
@wordpress/vipsworker — no extra WASM module is bundledCloses #74874
Similar to adamsilverstein/client-side-media-experiments#15
Approach
UltraHDR support comes from
wasm-vips(libvips compiled to WebAssembly), which gained native UltraHDR (uhdrload/uhdrsave) in0.0.17. This was unblocked bygoogle/libultrahdr#386, which dual-licensed libultrahdr underApache-2.0 OR MITand made it compatible with WordPress's GPLv2-or-later codebase.@wordpress/vipsis bumped towasm-vips@^0.0.17and gains:getUltraHdrInfo(buffer)helper that probes a JPEG for an embedded gain map and reports HDR headroomisUltraHdrflag onresizeImagethat routes throughuhdrloadBuffer+uhdrsaveBufferso the gain map is downsized in lockstep with the base image and re-embedded on save@wordpress/upload-mediauses these helpers in its existingDetectUltraHdroperation and tags sub-size resizes withisUltraHdrso the resize step transparently preserves the gain map. Thanks to @kleisauke for landing native UltraHDR support inwasm-vips.Removed
open-ultrahdrandopen-ultrahdr-wasmdependencies are no longer needed.EncodeUltraHdrqueue operation type is gone — there's no separate re-encode step; resize handles it inline.encodeUltraHdrItem) is removed.Follow-up
wasm-vips@0.0.18will land a docs-only update toTHIRD-PARTY-NOTICES.mdto make the dual-licensing explicit. We can bump to^0.0.18when it's published.Test plan