Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

p5.cursedHDR

A p5.js project that supports live HDR display, Ultra HDR JPEG export, and OpenEXR image export, in the P3 color space.

"This one goes to eleven."

A deliberately cursed hack that gives ordinary p5.js sketches:

  • Extended dynamic range — brighter-than-white "super-white" colors on HDR displays
  • Extended gamut — Display-P3 color
  • Ultra HDR JPEG export — stills with an embedded gain map (readable by Chrome, Android, macOS Photos)
  • OpenEXR frame-sequence recording — true 16-bit-float linear HDR frames, exported straight from the browser, suitable for grading/encoding into HDR video

Code:

Note: This is not physically accurate HDR. It is a teaching hack: p5 draws normally into a hidden 8-bit canvas, and a WebGPU shader re-expands that into HDR output. Normal screenshots of the page are not HDR; the EXR/JPEG export paths exist for exactly that reason.


Requirements

Intended viewing environment:

  • macOS 15.6+
  • Chrome 148+ (WebGPU with toneMapping: { mode: "extended" })
  • An HDR-capable display, e.g. a MacBook Pro XDR
  • Display settings preset: "Apple XDR Display (P3-1600 nits)"

The sketch still runs on non-HDR displays — the super-white content simply clips to regular white. EXR recording works regardless of the attached display (the exported values come from an offscreen render target, not the screen).

Quick start

No server or build step is needed — double-click p5_hdr/index.html or open it via file:// in Chrome. (file:// is a secure context in Chrome, so WebGPU works.)

The only network dependency is p5.js from the jsdelivr CDN. For fully offline use, edit index.html to load the bundled copy instead:

<script src="p5.js"></script>

You should see two circles: a white one (drawn with fill(255)) and a noticeably brighter "super-white" one (drawn with fill(510)), plus P3-vs-sRGB comparison swatches.

Controls

Key Button Action
s Save JPG Save an Ultra HDR JPEG (cursed-hdr.jpg)
e Save EXR Save a single OpenEXR frame (cursed-hdr.exr)

Frame-sequence recording is not wired to any key or button in the demo, but is fully available from the dev console or your own sketch code: beginHDRFrameRecord(options)endHDRFrameRecord() (see OpenEXR frame-sequence recording).

When sequence-recording, Chrome will ask permission for "multiple downloads" on the second file — allow it, or the recording's downloads silently stop after the first frame.

Project files

File Purpose
index.html Demo page; loads p5, the EXR writer, the library, and the sketch
sketch.js Demo sketch: super-white circles, P3 swatches, key/button controls
p5.cursedHDR.js The library: p5 function traps, WebGPU HDR display, Ultra HDR JPEG export, EXR recording
minimal-exr-writer.js Standalone minimal OpenEXR encoder (no dependencies)
p5.js Bundled p5.js 2.x (offline fallback)
style.css Page styling

How it works

  1. createCanvas(512, 512, P2DHDR) creates a normal p5 canvas. After createCursedHDRGraphics(width, height) is called, that canvas is hidden and used as a dim carrier image.
  2. The library traps fill(), stroke(), background(), colorMode(), save(), and draw(). When you write fill(510), the trap divides the RGB values by maxHDR (default 2.0) before p5 draws them, so p5 actually renders fill(255) into the 8-bit carrier — but the library remembers it should decode back to 2× white.
  3. Each frame, the trapped draw() calls loadPixels(), uploads the carrier pixels to WebGPU as an rgba8unorm texture, and renders a fullscreen shader into a visible WebGPU canvas configured with:
    • format: "rgba16float"
    • colorSpace: "display-p3"
    • toneMapping: { mode: "extended" } ← the part that permits brighter-than-white output
  4. The shader decodes the carrier back to scene values via the transfer function below. Values above 1.0 display as super-white on HDR-capable hardware.

pixelDensity(2) is the default, so a 512×512 sketch has a 1024×1024 backing store, and exports are 1024×1024.

Drawing with extended range

Specify RGB colors in the range 0–511 instead of 0–255:

  • fill(255) → regular SDR white (1.0)
  • fill(511) → super-white at peak brightness (superWhiteMax, default = maxHDR = 2.0)
  • fill(255 * MAXHDR) → idiomatic way to write "peak white" (the global MAXHDR holds the active maxHDR)

Globals provided: MAXHDR, P2DHDR (alias of P2D), RGBHDR (alias of RGB).

Only numeric RGB / grayscale colors encode correctly. HSB, HSL, OKLCH, CSS strings, and p5.Color values print a one-time console warning and will not encode correctly for HDR.

The super-white transfer

Let v be the linearly decoded scene value (carrier × maxHDR, so sketch color 0–511 → v 0–2 by default). The shader applies, per channel:

v ≤ 1            →  v                                          (0–255: linear, untouched)
v > 1            →  1 + (superWhiteMax − 1) · t^superWhiteGamma
                    where t = (v − 1) / (maxHDR − 1)            (256–511: settable curve)
  • superWhiteMax — signal level reached by color value 511 (default: maxHDR)
  • superWhiteGamma — curve shape of the super-white range; 1.0 = linear (default)

With the defaults this is bit-identical to plain rgb × maxHDR, so existing sketches look the same. The transfer applies to both the live display and the EXR export, so they always match. The Ultra HDR JPEG path assumes linear maxHDR scaling and ignores these options.

Signal vs. linear light

Everything above happens in gamma-encoded signal space: the carrier canvas holds sRGB-encoded pixels, and the WebGPU canvas interprets shader output as gamma-encoded Display-P3 signal (for values above 1.0, Chrome's extended tone mapping extends the same sRGB curve segment). That means the "scene value" v is a signal, not light: signal 2.0 is not twice the light of signal 1.0 — pushed through the extended sRGB EOTF it is about 4.95× linear light.

The live display works entirely in signal space. The EXR export additionally converts signal → linear with that same extended sRGB EOTF, because OpenEXR files hold linear light by convention. So the files are an exact linear-light record of what the display shows: fill(255) ≈ 0.991 linear, fill(511) = peakLinear in the manifest (≈4.95 by default).

createCursedHDRGraphics(width, height, options?)

Returns a CursedHDRTrap instance. The third argument may be a boolean (verbose flag) or an options object:

Option Default Meaning
maxHDR 2.0 Signal value that color 511 decodes to linearly; sets MAXHDR
superWhiteMax maxHDR Peak signal reached by color 511 after the transfer curve (linear light = the manifest's peakLinear)
superWhiteGamma 1.0 Nonlinearity of the 256–511 range (1 = linear)
pixelDensity 2 Backing-store density (export resolution = css size × density)
appendCanvas true Append the WebGPU canvas to the DOM
canvasParent null Where to append it (defaults next to the carrier canvas)
backgroundCSS "black" CSS background of the WebGPU canvas
canvasStyle {} Extra CSS applied to the WebGPU canvas
ultraHDRBoostStops 3.0 Default gain-map headroom for Ultra HDR JPEG export
verbose true Log a status report after WebGPU init
debug false Extra warnings + per-frame recording logs

Useful instance methods: isReady(), getError(), getCanvas(), getCarrierCanvas(), logStatus(), destroy(), plus the save/record methods below.

One trap per page. Calling createCursedHDRGraphics() twice stacks the function traps and double-encodes colors (destroy() does not restore the originals).

Ultra HDR JPEG export

save() / save("name.jpg") (and the s key) are trapped to export an Ultra HDR JPEG: an SDR base image plus a gain-map JPEG, joined with the Adobe hdrgm XMP metadata and an MPF index. Compatible viewers (Chrome, Android, macOS Photos/Preview) re-expand it to HDR; everything else sees a normal JPEG.

save(filename, options) accepts:

Option Default Meaning
quality 1 Base JPEG quality (0–1)
gainMapQuality quality Gain-map JPEG quality
boostStops / ultraHDRBoostStops trap option (3.0) hdrgm:GainMapMax / HDR capacity in stops
gainMapOffset 1/64 hdrgm:OffsetSDR / OffsetHDR
gainEpsilon maxHDR/255 Threshold below which gain is treated as zero
snapSaturatedChromaBlocks false Snap 2×2 saturated blocks to fight chroma-subsampling fringes

You can also call cursedHDR.saveUltraHDRJPEG(filename, options) directly; it returns a Promise<Uint8Array> of the final JPEG.


OpenEXR frame-sequence recording

Records true HDR frames as numbered, uncompressed half-float .exr files (RGB by default, optionally RGBA) plus a manifest — no screenshots, no MediaRecorder. The pixels come from a dedicated offscreen pipeline:

  1. loadPixels() snapshots the carrier canvas.
  2. The pixels are uploaded to a recorder-owned rgba8unorm texture (separate from the live display's, so they never race).
  3. An export variant of the fullscreen HDR shader renders into an offscreen rgba16float texture (RENDER_ATTACHMENT | COPY_SRC). It applies the same super-white transfer as the display, then converts the gamma-encoded signal to linear light (extended sRGB EOTF) — so the file is an exact linear record of what the display shows.
  4. The texture is copied to a MAP_READ buffer (rows padded to WebGPU's required 256-byte alignment), mapped, unpacked into a tight Uint16Array of halfs, encoded with encodeRGBA16FToEXR(), and downloaded.

Captures are serialized on an internal promise chain (one readback buffer, never double-mapped); frame numbers are assigned synchronously at capture time, so ordering is stable even though encoding is async.

API

Global functions (also available as methods on the trap instance):

beginHDRFrameRecord(options?)  // start a session; returns the session object
recordHDRFrame(filename?)      // capture one frame; returns Promise<filename | null>
endHDRFrameRecord()            // finish; returns Promise<manifest | null>
isHDRFrameRecording()          // boolean

While a session is active, every drawn frame is captured automatically by the trapped draw() — you do not need to call recordHDRFrame() in your loop. Called with no active session, recordHDRFrame() does a standalone one-shot grab; the optional filename argument overrides the generated numbered name (the demo's e key uses recordHDRFrame("cursed-hdr.exr")), otherwise standalone grabs use a persistent counter so repeated saves keep numbering upward.

Typical sequence recording from the dev console (or from keyPressed() in your own sketch):

// capture exactly 90 frames (3 s at 30 fps), then auto-end + manifest:
beginHDRFrameRecord({ basename: "cursed-hdr", fps: 30, maxFrames: 90 });

// or open-ended — stop it yourself later:
beginHDRFrameRecord({ basename: "cursed-hdr" });
endHDRFrameRecord(); // downloads cursed-hdr-manifest.json

beginHDRFrameRecord(options)

Option Default Meaning
basename "hdr-frame" Files are named basename_000000.exr, basename_000001.exr, …
fps 30 Recorded in the manifest (frames are captured per draw call, not timed)
startFrame 0 First frame number
downloadEachFrame true Download each frame as captured; if false, frames are held in memory and downloaded at endHDRFrameRecord()
maxFrames null Auto-end the session after this many frames
includeAlpha false Write RGB only (alpha is constant 1.0, so it carries no information and viewers like Photoshop stop asking how to interpret it); true writes RGBA
makeZip false Not implemented — individual files only (warns if set)
colorSpace "display-p3" Recorded in manifest; embeds P3 chromaticities in each EXR
transfer "linear_scene_values" Recorded in manifest
maxHDR trap's maxHDR Recorded in manifest

What's in the files

  • Linear-light half-floats (the final signal pushed through the extended sRGB EOTF, matching what the display shows): 0 = black, SDR white ≈ 0.991, peak super-white = the manifest's peakLinear (≈4.95 for the default maxHDR = 2). No tone mapping baked in.
  • RGB only by default — the canvas is opaque, so alpha would be a constant 1.0; includeAlpha: true writes it anyway if a pipeline requires RGBA.
  • Display-P3 (D65) primaries embedded as a standard chromaticities attribute, plus a comments attribute describing the source.
  • endHDRFrameRecord() downloads basename-manifest.json:
{
  "width": 1024, "height": 1024, "fps": 30,
  "frameCount": 3, "basename": "cursed-hdr", "startFrame": 0,
  "pixelFormat": "rgb16f", "container": "OpenEXR",
  "channels": ["R", "G", "B"],
  "colorSpace": "display-p3", "transfer": "linear_scene_values",
  "maxHDR": 2, "superWhiteMax": 2, "superWhiteGamma": 1,
  "peakLinear": 4.953846,
  "files": ["cursed-hdr_000000.exr", "..."],
  "note": "Generated from p5.cursedHDR offscreen rgba16float WebGPU render target."
}

The files open in Preview, Affinity, Nuke, Blender, DaVinci Resolve, exrheader/exrdisplay, etc. Import them as linear with Display-P3 primaries.


The minimal EXR writer

minimal-exr-writer.js is standalone (no p5, no DOM needed for encoding) and exposes:

encodeRGBA16FToEXR({
  width, height,                    // integer pixel dimensions
  rgba16f,                          // Uint16Array of interleaved binary16 halfs: R,G,B,A,...
  channels = ["R", "G", "B", "A"],  // any subset of R/G/B/A
  compression = "NO_COMPRESSION",   // the only supported value
  metadata = {}                     // optional extras, see below
}) // → Uint8Array containing a complete .exr file

Implemented EXR subset (and nothing else): single-part scanline, version 2, NO_COMPRESSION, HALF pixel type, pLinear = 0, sampling 1×1, lineOrder = INCREASING_Y, dataWindow = displayWindow = [0, 0, w−1, h−1], one scanline per chunk.

metadata string values become EXR string attributes; a chromaticities key with 8 numbers [rx,ry,gx,gy,bx,by,wx,wy] becomes a standard chromaticities attribute.

Channel-order note: OpenEXR requires the header's channel list to be sorted alphabetically, and per-scanline planar data follows header order — so RGBA data is laid out A, B, G, R within each scanline chunk. The writer handles this; it's called out because it's the easiest way to write an EXR that real readers misinterpret.

testMinimalEXRWriter() (run it in the dev console) builds a 2×2 file with known half bit-patterns, byte-verifies the magic number, version, offset table, chunk headers, and planar layout, then downloads test-minimal-rgba16f.exr. Pass false to skip the download.


Converting the EXR sequence to HDR video

Recipe (zscale/zimg, included in Homebrew ffmpeg) for an HLG HEVC .mov that QuickTime/macOS plays as HDR:

ffmpeg -framerate 30 -start_number 0 -i cursed-hdr_%06d.exr \
  -vf "format=gbrpf32le,exposure=exposure=-2.31,\
zscale=primariesin=smpte432:transferin=linear:matrixin=rgb:\
primaries=2020:transfer=arib-std-b67:matrix=2020_ncl:range=limited,\
format=yuv420p10le" \
  -c:v libx265 -crf 16 -preset slow \
  -color_primaries bt2020 -color_trc arib-std-b67 -colorspace bt2020nc \
  -tag:v hvc1 cursed-hdr-hlg.mov

Key decisions baked into that command:

  • exposure=-2.31: HLG's OETF expects normalized scene light in 0–1, but the frames are true linear light with a peak of peakLinear ≈ 4.95 (for the default maxHDR = 2) — without this, the super-whites clip hard. The right value is -log2(peakLinear), reading peakLinear from the manifest; −log2(4.953846) ≈ −2.31. With peak pinned to 1.0, SDR white (≈0.99 linear → 0.20 scene light) lands at ~69% HLG signal, just under HLG's 75% nominal reference white — slightly conservative, nothing clips.
  • Alternative exposure: to pin SDR white exactly at HLG reference white (75% signal = 0.265 scene light), use exposure=-1.9 instead — but the super-white peak then reaches ~1.32 and the very top of the highlights clips. Pick one: faithful highlights (−2.31) or nominal mid-level brightness (−1.9).
  • primariesin=smpte432 is Display-P3 D65; zimg does the P3→Rec.2020 gamut matrix. Needs a reasonably recent zimg — check with ffmpeg -h filter=zscale.
  • The output tags (bt2020 / arib-std-b67 / 2020nc + hvc1) are what make QuickTime/macOS actually engage HDR playback.

Caveats: frames are RGB-only by default (if recorded with includeAlpha: true, ffmpeg drops the constant alpha anyway); keep the chain in float (format=gbrpf32le first) so nothing clips before the transfer; -start_number should match the manifest's startFrame. EXRs recorded before the linearization fix (manifests without a peakLinear field) hold gamma-encoded values and look dull/chalky in linear workflows — re-record them rather than trying to compensate. If your ffmpeg has libplacebo enabled, its filter does the gamut+transfer mapping in one step with nicer highlight rolloff than zscale's colorimetric conversion. And if you'd rather not hand-manage color, DaVinci Resolve reads these EXR sequences directly and will do P3-linear → Rec.2020 HLG with proper color management.


Caveats & troubleshooting

  • Download throttling. Chrome prompts for "multiple downloads" on the second file; allow it once per origin or recordings stop after one frame. Long recordings fill your Downloads folder — use maxFrames.
  • File size / memory. Uncompressed 1024×1024 half-float frames are ~6.3 MB each (RGB; ~8.4 MB with includeAlpha: true). Captures are queued, never dropped, so if encoding falls behind the draw loop, pending snapshots accumulate in memory. For long takes, lower frameRate() or pixelDensity.
  • 8-bit carrier quantization. Scene values pass through an 8-bit canvas, so fill(255) round-trips to signal ≈0.996 (≈0.991 linear in the EXRs) rather than exactly 1.0 with maxHDR = 2. The dynamic range is extended; the precision is not.
  • EXR compatibility. One scanline per chunk is legal but unusual; an exotic reader tuned for compressed multi-row chunks may be slow, but standards-conformant readers are fine (validated against macOS's native parser).
  • Color modes. Only numeric RGB/grayscale colors HDR-encode correctly.
  • HDR display of the live canvas depends on browser, GPU, OS, and monitor; cursedHDR.logStatus() prints what the browser reports (dynamic range, gamut, canvas configuration, adapter info).

Validation

The implementation was verified end-to-end:

  • Writer: Node smoke test + macOS's native EXR parser (sips) decoding generated files; corner-pixel probes of a converted gradient confirmed row order, channel mapping, and linear values.
  • Recording: Playwright/Chromium with WebGPU recorded an auto-captured sequence + manifest + single-frame grab; half-floats probed inside the downloaded RGB-only EXRs matched the expected linear-light values exactly (SDR circle ≈ 0.991, super-white circle = 4.953 = eotf(2.0), background = 0), including a non-default superWhiteMax: 3, superWhiteGamma: 2 curve (12.828 = eotf(3.0)). A sips-decoded frame visually matches the sketch (no washout).
  • file:// operation (no server) verified, including WebGPU init and downloads.

claude --resume d25f78a1-3099-4585-9d48-2cfff4bdbaa9

About

A p5.js project that supports live HDR display, Ultra HDR JPEG export, and OpenEXR HDR file export, in the P3 color space..

Topics

Resources

Stars

8 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages