Read and write EXR, HDR, and Ultra HDR images in pure JavaScript.
- 📦 Pure JavaScript, No Native Bindings
- 🌐 Node.js, Browsers, and Web Workers
- 🗜️ All Major EXR Compressions
- 📸 Adobe and Ultra HDR Gain Maps
- 🎨 Four Built-in Tone Mappers
- 🔷 Full TypeScript Support
HDRify reads and writes HDR (Radiance RGBE), EXR (OpenEXR), and JPEG with gain maps (JPEG-R / Ultra HDR) in pure JavaScript. It has no native bindings and works in Node.js and browsers.
Every reader returns a shared HdrifyImage (float RGBA pixels) and every writer accepts it, so converting between formats is a read followed by a write.
| Package | Version (click for changelogs) |
|---|---|
| hdrify | |
| hdrify-cli | |
| hdrify-react | |
| hdrify-vscode-extension |
- Read and write RGB, RGBA and Luminance EXR files (none, RLE, ZIPS, ZIP, PIZ, PXR24, B44, B44A, DWAA and DWAB compression)
- Read and write HDR (Radiance RGBE) files
- Read JPEG XL (
.jxl) files, lossless and lossy (VarDCT and Modular, 8/16-bit integer, float16 and float32, alpha, grayscale, patches, noise, layered frames; animation frame API, orientation, associated alpha and extra channels), and write lossless Modular or lossy DCT8 JPEG XL (float16, float32, uint16, uint8) - Read and write both Adobe Gain Map JPEGs and Ultra HDR JPEGS (Android compatible.)
- Tone mappers (ACES, Khronos Neutral, AgX and Reinhart)
- Full TypeScript support
- No DOM or Node.js dependencies (works in browser, web workers, and node.js)
- Written in a functional style to support tree-shaking
- Web app example of HDR, EXR and Ultra HDR conversion and viewing: https://hdrify.ben3d.ca/demo/
- hdrify CLI
- HDRify VS Code Extension — for VS Code and Cursor
pnpm add hdrifyAll read functions return HdrifyImage, and all write functions accept it (or derived types). This is the universal intermediate format used across the library.
interface HdrifyImage {
width: number; // Image width in pixels
height: number; // Image height in pixels
data: Float32Array; // RGBA pixel data: [R, G, B, A, R, G, B, A, ...]
metadata?: Record<string, unknown>; // Format-specific header metadata (e.g. compression, exposure)
}import { readExr } from 'hdrify';
const buffer = fs.readFileSync('image.exr');
const imageData = readExr(new Uint8Array(buffer));
console.log(`Image: ${imageData.width}x${imageData.height}`);
// imageData.data is a Float32Array with RGBA valuesimport { readHdr } from 'hdrify';
const buffer = fs.readFileSync('image.hdr');
const imageData = readHdr(new Uint8Array(buffer));
console.log(`Image: ${imageData.width}x${imageData.height}`);import { readJpegGainMap } from 'hdrify';
const buffer = fs.readFileSync('image.jpg');
const imageData = readJpegGainMap(new Uint8Array(buffer));
console.log(`Image: ${imageData.width}x${imageData.height}`);
// imageData.data is linear HDR Float32Array RGBA; metadata.format is 'ultrahdr' or 'adobe-gainmap'import { readJxl, readJxlFrames, writeJxl } from 'hdrify';
const imageData = readJxl(new Uint8Array(fs.readFileSync('image.jxl')));
// Return every coalesced animation frame, with seconds, optional timecodes and loop count.
const { frames, numLoops } = readJxlFrames(new Uint8Array(fs.readFileSync('animation.jxl')));
// Both APIs apply orientation and return straight alpha by default. To retain stored orientation and alpha association:
const raw = readJxl(new Uint8Array(fs.readFileSync('image.jxl')), {
applyOrientation: false,
keepPremultipliedAlpha: true,
});
// Full-resolution extra channels (including alpha) are retained as named, typed float planes:
const extraChannels = imageData.metadata?.extraChannels;
// Lossless; bitDepth: 'float16' (default), 'float32' (bit-exact), 'uint16' or 'uint8'
fs.writeFileSync('output.jxl', writeJxl(imageData, { bitDepth: 'float32' }));
// Lossy XYB / DCT8 output, preserving HDR range:
fs.writeFileSync('delivery.jxl', writeJxl(imageData, { lossy: true, distance: 1 }));For additional lossless compression, use writeJxl(imageData, { effort: 'high' })
or CLI --jxl-effort high. The default standard effort uses prefix codes;
high also tries ANS and learned prediction trees, retaining the smaller coded
model. Training uses at most 65,536 deterministic samples, depth at most 6,
and at most 255 leaf contexts, with predictors, residual offsets and proven
power-of-two multipliers. This search can take several times longer than
standard encoding. Representative half-float sizes improve from 953,620 to
898,343 bytes for memorial and from 52,952 to 28,161 bytes for the gradient.
High effort is currently supported for lossless output only.
Lossy encoding uses a fixed DCT8 strategy, default JPEG XL quantization tables,
uniform quantization, and lossless alpha. distance must be greater than 0 and at
most 25 (default 1); smaller values retain more detail and produce larger files.
This initial encoder's distance controls quantization strength and is not calibrated
to libjxl's perceptual distance. Adaptive quantization, larger transforms, and
restoration filters are future improvements. On the committed half-float synthetic,
gradient and memorial HDR corpus at distances 0.5–2, output is 2.7–6.3 times the
size of cjxl -e 3 at the same distance, with lower measured error. Synthetic
regression tests require linear RGB RMSE below 0.06 for values up to 8 at distance
1, and below 0.3 for values up to 100 at distance 0.5; these are corpus checks,
not a universal perceptual guarantee. Reproduce size and linear/sRGB error
measurements with node scripts/benchmark-jxl-lossy.mjs after building.
For the CLI, use
hdrify convert input.exr output.jxl --jxl-lossy or --jxl-distance 0.5.
JPEG XL extra channels are available in metadata.extraChannels as { name, type, width, height, data: Float32Array }, in the returned image orientation. Spot colours retain their RGB/solidity in each plane’s spotColor field; set { renderSpotColors: true } on either reader to composite them like djxl. CMYK ICC conversion is unsupported. readJxlFrames retains all decoded frames in memory.
metadata.originalChromaticities describes enum/custom colour declarations. For embedded ICC sources, the reader retains metadata.iccProfile and reports colour-transform support in metadata.iccColorManaged; it omits originalChromaticities.
import { encodeGainMap, readExr, writeExr, readHdr, writeHdr, writeJpegGainMap } from 'hdrify';
// Convert EXR to HDR
const exrBuffer = fs.readFileSync('input.exr');
const imageData = readExr(new Uint8Array(exrBuffer));
fs.writeFileSync('output.hdr', writeHdr(imageData));
// Convert HDR to EXR
const hdrBuffer2 = fs.readFileSync('input.hdr');
const imageData2 = readHdr(new Uint8Array(hdrBuffer2));
fs.writeFileSync('output.exr', writeExr(imageData2));
// Convert EXR or HDR to JPEG-R (JPEG with gain map—highly compressible HDR)
const imageData3 = readExr(new Uint8Array(fs.readFileSync('input.exr')));
const encoding = encodeGainMap(imageData3, { toneMapping: 'reinhard' });
fs.writeFileSync('output.jpg', writeJpegGainMap(encoding, { quality: 90 }));import { readExr } from 'hdrify';
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const arrayBuffer = await file.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer);
const imageData = readExr(buffer);
// Use imageData.data to render to canvas, etc.
});The hdrify-cli package is a companion command-line tool for converting and inspecting EXR, HDR, and JPEG gain map (Ultra HDR / Adobe) files. See packages/cli/README.md for full CLI documentation, or install from npm:
pnpm add -g hdrify-cli| Command | Description |
|---|---|
hdrify convert <input> <output> |
Convert between EXR, HDR, JPEG XL, JPEG gain map, PNG, WebP, and JPEG |
hdrify info <file> |
Display metadata (format, dimensions, compression) |
hdrify reference <output> |
Create synthetic reference test images |
hdrify convert input.exr output.hdr
hdrify convert input.hdr output.exr
hdrify convert input.exr output.jpg # JPEG-R with gain map (Ultra HDR, default)
hdrify convert input.exr output.jpg --format adobe-gainmap # Adobe gain map format
hdrify convert input.jpg output.exr # Read JPEG gain map as input
hdrify convert input.exr output.exr --size 1024,512 --filter lanczos # Resize
hdrify info input.exr
hdrify info input.jpg # JPEG gain map (Ultra HDR / Adobe)
hdrify reference output.exr --compression zip
hdrify convert input.exr output.exr --compression pxr24See the online demo. To run the website locally (packages/website, drag-and-drop and exposure slider):
cd packages/website
pnpm install
pnpm devOpen http://localhost:3000/hdrify/demo/ and drag-and-drop EXR or HDR files.
Read CONTRIBUTING.md before starting a change.
pnpm install
pnpm dev
pnpm tsc
pnpm build
pnpm lint # oxlint
pnpm lint:fix
pnpm format # oxfmt
pnpm test # vitestCI runs tests with coverage (pnpm test --coverage) and enforces coverage floors in addition to the checks in CONTRIBUTING.md:
- Core library coverage floors: 89% statements, 72% branches, 93% functions, 89% lines.
Change them only with an explanation in the PR.
This project would not have been possible without these two projects which were used a reference:
- Three.js - tone mapping, UltraHDR loader, EXR loader, example images.
- Gainmap.js - Ultra HDR loading, saving, XMP metadata structure and JPEG-R assembly (reference implementation).
- OpenEXR - EXR encoding/decoding for all the various compression formats, example images.
See RELEASING.md for how releases are published and GitHub Releases for changes.
See Contributing Guide.
MIT
Ben Houston, Sponsored by Land of Assets.