Save UltraHDR Image
A JPEG that glows on your phone, and forgets your workflow
- images
- hdr_params
What it actually does
Every other save node in ComfyUI hands you a PNG with the entire workflow welded inside it. This one hands you a JPEG that looks brighter on a modern phone - and the author's own node description says the quiet part out loud: "UltraHDR JPG image don't contain workflow metadata!"
What you get is an Ultra HDR / gain-map JPEG, the same container Android phones and Google Photos use. On an HDR display, in a viewer that understands gain maps, the highlights sit roughly 2.3 stops above normal SDR white. Everywhere else it's a perfectly ordinary JPEG.
Be clear about what this is, though, because it's easy to oversell. ComfyUI's IMAGE is an 8-bit-ish, sRGB-encoded float tensor in a 0–1 range (post-processing.md). There is no scene-referenced HDR in your decode - the model never had any. So this node runs inverse tone mapping: it guesses a plausible expansion into HDR nits and stores the difference as a gain map. A blown-out window in your source stays blown out, just brighter.
That's fine. The use case is display, not data: wallpapers, photo-ish shots you'll view on an HDR screen, anything posted somewhere that keeps your original file.
How the file gets built
The node is a port of hanfeisun/pyultrahdr (the source says so in its first line), and it does the whole thing in Python with numpy and PIL - no CUDA, no extra models. Six steps:
- Save the base JPEG at your
quality, with chroma subsampling disabled (4:4:4), so the SDR layer is already about as clean as JPEG gets. - Decode sRGB → linear.
- Inverse tone map. Luminance is Rec.709-weighted, and the boost is
shadow_boost + (peak_boost - shadow_boost) × L^hi_gamma, wherepeak_boostispeak_nits ÷ sdr_white. Then linear RGB × white × boost → nits. - Convert BT.709 primaries to BT.2020, the wide-gamut container HDR JPEGs expect.
- Compute the gain map as
log2(HDR luminance) - log2(SDR luminance), clamped at zero - a one-way lift that can only ever add light. - Pack it: base JPEG, an XMP
hdrgmblock (holdingGainMapMaxandHDRCapacityMax = log2(peak_nits / sdr_white)), an MPF APP2 segment declaring a second image, then the grayscale gain-map JPEG appended after it. That's the standard Ultra HDR layout Google's libultrahdr writes.
The payoff of that structure: nothing else has to change. A viewer that doesn't understand gain maps reads the base JPEG and shows exactly the picture you've been looking at in the node preview.
The inputs that matter
images- theIMAGEbatch. You get one.jpgper image in it.filename_prefix- defaults toUltraHDR. It behaves likeSaveImage's: slashes make subfolders and the counter auto-increments, so you landUltraHDR_00001_.jpg(trailing underscore included) inComfyUI/output/.hdr_params(optional) - wire anUltraHDRParametersnode here. Leave it empty and the node falls back to its built-in defaults (1000 nits peak, 203 white, 1.3 shadow boost, gamma 1.0, quality 92, gain map 85).
There are no outputs - it's an output node, so ComfyUI caches backwards from it and it always runs when the graph runs (comfyui-node-plumbing.md).
Installing it
The pack installs the normal way. In ComfyUI Manager search lhyNodes, or manually:
cd ComfyUI/custom_nodes
git clone https://github.com/lihaoyun6/ComfyUI-lhyNodes.git
python -m pip install -r ComfyUI-lhyNodes/requirements.txt
Then restart ComfyUI. The UltraHDR code itself needs nothing beyond numpy, torch and PIL - but do not skip the requirements step. The pack's loader imports every module in its nodes/ folder, and nodes/nodes.py imports cv2 and ultralytics at the top. Miss those and the whole pack fails to load, UltraHDR nodes included, with an IMPORT FAILED line in the console and no nodes in the menu.
One navigation note: the README doesn't list these nodes at all - SaveUltraHDRImage is the newest commit in the repo - so type UltraHDR into the node search instead of browsing the tree.
Where people get burned
The metadata loss is real and deliberate. JPEG has no PNG text chunks, so the graph can't travel - image-io-metadata.md makes the general point that PNG is the master and JPEG is a derivative. Treat this node as a publishing step: keep the PNG from a normal SaveImage alongside it, and don't expect CivitAI to parse a generation string out of the upload.
Judge it on an HDR screen, not in the node preview. The preview in the canvas is the SDR base. If you tune parameters staring at that, you're tuning something you can't see.
Don't use it as an EXR substitute. If the destination is a color-managed comp, you want linear float EXR via something like comfy_oiio, not a gain-map JPEG.
Watch the OpenCV install on headless boxes. The requirements pull plain opencv-python; when a server or Docker box won't import the pack, mixed cv2 builds or a missing libGL are the usual suspects and the headless build is the standard fix. That's an import failure with no node in the menu - read the console.
Inputs (3)
| Name | Type | Default | Description |
|---|---|---|---|
| images | IMAGE | — | |
| filename_prefix | STRING | UltraHDR | — |
| hdr_paramsopt | HDR_PARAMS | — |
Outputs (0)
No outputs