DiffHDR (Image / Video)
Get the window back out of a blown-out clip
- model
- vae
- images
- clip
- mask
- reference_image
- hdr
- mask
You have footage where the sky is a flat white shape and the room is a flat black one. Nothing in those pixels is recoverable with a curve, because the data isn't there - it was clipped when it was shot or encoded. DiffHDR (Image / Video) is the node that goes and invents it back with a video diffusion model, and hands you scene-referred linear HDR at the other end instead of another 8-bit PNG.
That's the whole pitch. It's not an upscaler and not a grade, and it's not a wrapper around somebody else's inference engine: it's a port of Eyeline Labs' DiffHDR onto ComfyUI's native Wan2.1-VACE-14B objects, so it runs on the same checkpoints, VAE and loaders as your other VACE graphs.
How it works
DiffHDR treats LDR-to-HDR as generative radiance inpainting in the video model's latent space. Inside this node:
- Frames are converted to linear light and squeezed through a log-gamma curve (
log(2.2·x + 1), normalised to 0–1) so several stops of highlight fit in a sane range. - A mask detector finds the over-exposed regions - soft luma threshold, plus a nudge for pixels pinned in two or more channels - with a temporal pass so the mask doesn't strobe.
- Those frames go in as the VACE control video with that mask as
control_masks, and the DiffHDR LoRA (auto-downloaded, ~60 MB, 80 patches) is applied to the model. - VACE samples at cfg 1 with the trained text embedding baked in; the latent is decoded and the log curve is inverted. What comes out is float linear Rec.709 with values legitimately above 1.0.
One frame in → image mode. Note that "image mode" is not cheaper: the single frame is replicated up to the trained 33-frame window, so you pay the same sampling cost as a 33-frame video. That surprises people.
The inputs you actually touch
preset(defaultfast) andsteps(20).fastisres_multistep/simple/ shift 8;originalreproduces the reference implementation (euler/simple/ shift 5) and needs twice the steps to match it. 20 steps is the shipped default, 10 is fine, 6 is for seed hunting - andsampler/scheduler/shiftonly do anything whenpresetiscustom.resize_mode-crop_to_720p(default) centre-crops to the 1280×720 training resolution,nativekeeps your size floored to multiples of 16,customuseswidth/height. The model was trained at 720p on 33-frame windows; everything else is extrapolation, though it generally behaves.mask_overexposed(on) andmask_underexposed(off) pick what gets regenerated. The optionalmaskinput overrides detection entirely - white means regenerate.reference_image+reference_ev(default 5 stops): feed the node a shot of what was behind the glass and it will guide the reconstruction toward it.- Long clips: frames past
window_size(33, must be 4n+1) get sampled as overlapping sliding windows everywindow_stride(16) and blended. Leaveuse_prev_window_referenceon - it's on by default since 0.3.0 and it's the difference between a background that stays put and one that cross-fades into a different invention every window.attention/vae_precision: leave them atautoandfp32.as_loadedsaves memory and can band your highlights.
Outputs are hdr (float linear, values > 1.0) and mask. Wire hdr to DiffHDR Save EXR, or to DiffHDR Tonemap Preview if you just want to look at it. Do not feed hdr straight to a SaveImage - an 8-bit save clips exactly the data you paid for.
Install
cd ComfyUI/custom_nodes
git clone https://github.com/claussteinmassl/ComfyUI-DiffHDR.git
pip install -r ComfyUI-DiffHDR/requirements.txt # OpenEXR + huggingface_hub
Or search DiffHDR in ComfyUI Manager. Then drop wan2.1_vace_14B_fp16.safetensors and wan_2.1_vae.safetensors from Comfy-Org/Wan_2.1_ComfyUI_repackaged into models/diffusion_models and models/vae, and restart. The text encoder is optional - DiffHDR was trained with an empty prompt and its embeddings ship in the repo, so leaving clip unconnected saves you the ~11 GB umT5 download. workflows/diffhdr_video.json is the two-loaders-and-done graph.
Where people get burned
- Wrong model. Connect anything that isn't Wan2.1-VACE-14B - the 1.3B VACE, a plain Wan 2.1, Wan 2.2 - and you get a
ValueError. The node checks for 8 VACE blocks and a hidden size of 5120. - VRAM. Measured peaks for a 720p 33-frame run: 50.9 GB at plain fp16, 34.9 GB at
fp8_e4m3fn, 28.7 GB as GGUFQ4_K_Mvia city96's ComfyUI-GGUF (UnetLoaderGGUF). This is not a 8 GB-card node. GGUF Q8 is the sweet spot if fp16 doesn't fit; Q4_K_M is the last stop. - Turbo / speed LoRAs don't work here. Thirteen Wan 2.1 step-distillation LoRAs were tested: they load fine but shift the value of the log-encoded output, and the log curve turns that into exponentially blown highlights - one measured +3,894 % highlight energy at 6 steps. They also save zero time per step. Leave them out.
- It feels slow. Every execution logs a
DiffHDR timing [...]line. Ifmasksorcontroldwarfssample, your PyTorch thread pool is sized from the host's advertised cores - setOMP_NUM_THREADSto what you actually have before launching ComfyUI. - The nodes don't appear at all. This pack registers through ComfyUI's newer
comfy_apiV3 node interface. On an older ComfyUI it imports, silently registers nothing, and you'd swear the install failed. Update ComfyUI first. - The Windows GPU path is untested by the author - Linux and macOS CI only.
And set expectations: there is no ground-truth HDR here. Two 50-step runs of the same clip, differing only in seed, sit 25–32 dB apart inside the mask.
Inputs (24)
| Name | Type | Default | Description |
|---|---|---|---|
| preset | COMBO | fast | fast = res_multistep / simple / shift 8 (recommended, measured on Wan2.1-VACE-14B: matches the 50-step reference at 10-20 steps, 4-7x faster). original = euler / simple / shift 5 (the reference implementation's sampler). custom = use the sampler / scheduler / shift widgets below. |
| model | MODEL | Wan2.1-VACE-14B diffusion model (bf16, fp8 or GGUF). The DiffHDR LoRA is downloaded and applied automatically. | |
| vae | VAE | Wan 2.1 VAE. Used in float32 by default to avoid banding in the log-encoded output. | |
| images | IMAGE | sRGB LDR image or frame batch. | |
| prompt | STRING | Optional prompt (DiffHDR was trained with an empty prompt). Only used when a CLIP is connected. | |
| reference_ev | FLOAT | 5.0-10–10 | Exposure boost in stops applied to the reference image. |
| resize_mode | COMBO | crop_to_720p | crop_to_720p: centre-crop and resize to 1280x720 (training resolution; 720x1280 for portrait). native: keep size, floored to multiples of 16. custom: use width/height. |
| width | INT | 128016–8192 | Width for resize_mode=custom. |
| height | INT | 72016–8192 | Height for resize_mode=custom. |
| steps | INT | 201–200 | Sampling steps. 20 is the tuned default; 10 is enough with the fast preset; 50 = reference-implementation default. |
| seed | INT | 100–18446744073709550000 | Noise seed. Long videos use the same seed for every window. |
| sampler | COMBO | res_multistep | Sampler, used when preset = custom. res_multistep and dpmpp_2m are equivalent and reach the 50-step reference in far fewer steps than euler. |
| scheduler | COMBO | simple | Scheduler, used when preset = custom. Only simple was measured; beta is deliberately not offered because it crushes highlights. |
| shift | FLOAT | 8.01–12 | Flow-matching shift, used when preset = custom. 8 measured best with every sampler; 5 is the reference implementation's value. |
| mask_overexposed | BOOLEAN | true | Detect and regenerate over-exposed (clipped) regions. |
| mask_underexposed | BOOLEAN | false | Also detect and regenerate under-exposed (crushed) regions. |
| window_size | INT | 335–129 | Frames per window (4n+1). 33 is the training length. |
| window_stride | INT | 161–128 | Frames between window starts for long videos. Must be smaller than window_size. |
| use_prev_window_reference | BOOLEAN | true | Long videos: feed each sliding window the previous window's output frame (at the next window's start) as its reference image, so the windows reconstruct the same content in the clipped regions. Measured to reduce the window-to-window change of the reconstructed background a lot. Off: every window is reconstructed on its own and the blend can visibly cross-fade between different reconstructions. |
| attention | COMBO | auto | auto: SageAttention, then flash-attn if installed, else ComfyUI's default. Unavailable backends fall back to PyTorch SDPA. SageAttention is quantised attention: choose sdpa or flash_attn for bit-reproducible results. |
| vae_precision | COMBO | fp32 | fp32 is recommended. as_loaded saves memory but can cause banding in highlights. |
| clipopt | CLIP | Optional umT5-xxl text encoder. If not connected, the bundled DiffHDR embeddings are used and the prompt is ignored. | |
| maskopt | MASK | Optional mask of regions to regenerate (white = regenerate). Overrides automatic detection. | |
| reference_imageopt | IMAGE | Optional sRGB reference guiding the content of over-exposed regions. |
Outputs (2)
| Name | Type | Description |
|---|---|---|
| hdr | IMAGE | Linear scene-referred HDR (Rec.709 primaries, float32, values above 1.0). Do not route through 8-bit save nodes. |
| mask | MASK | Mask of regenerated regions. |