# WAI Extension: Exact Tone Mapping > Status: Draft. Registers two companions and a record. `wai.meta.gainmap` carries a gain map of > ISO 21496-1:2025 (its metadata and its gain map image) as a companion of an image payload, and > `wai.meta.st2094_50` carries the dynamic metadata of SMPTE ST 2094-50:2026-08 (Application #5) > as a companion of a video payload. A sink presents the picture it decoded at its display's > **headroom**, the ratio of its peak to the luminance of HDR reference white, as a `log2` value. > Both standards state the computation from the base, the companion and the headroom in real > numbers, so two sinks at one headroom compute it in their own floating point and present > different pixels. This extension pins it in integer arithmetic: `wai-tonemap:gainmap` and > `wai-tonemap:st2094-50` map the base's `RGB8` samples, the companion's bytes and a headroom, > an integer in units of 1/10000 of a stop, to one `RGB16` rendition, with a stated > fixed-point `log2`, a stated fixed-point `exp2`, and a stated rounding at every step. Every > sink at a headroom presents the same bytes, and a verifier holding the base and the companion > recomputes them. A sink that presents a rendition signs a **rendition record** naming the > base, the companion, each headroom and the digest of each rendition. The derivation is a > `convert` step of [synthesis](synthesis.md): it rebuilds what the base and the companion hold > and generates nothing. No new transport. The reference implementation is the `exact_tonemap` > module of `wai-rs` (feature `exact_tonemap`); the corpus is > [`live-conformance/tonemap/`](../live-conformance/tonemap/), and its second implementation is > `live-conformance/tonemap_verify.py`. > Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. > > Texts read for this revision: SMPTE ST 2094-50:2026-08, *Dynamic Metadata for Color Volume > Transform — Application #5*, approved 2026-08-24, in its published text, every clause and > annex (its figures were not read); the WAI texts this one cites; RFC 8032 and RFC 8785. > **ISO 21496-1:2025** (first edition, *Digital photography — Gain map metadata for image > conversion — Part 1: Dynamic range conversion*) **was not read**: its text is sold, and no > part of it was obtained for this revision. What this extension states of it is what > ST 2094-50:2026-08 says of it (its terms 3.1 to 3.11 cite ISO 21496-1:2025 clauses 3.1, 3.2, > 3.4, 3.6, 5.2.6, 5.2.7 and 6.3) and what public, open-source readers of its binary metadata > implement: the field list of §4.2 and the computation of §4.5. Each is marked where it is > used. A revision that reads the standard confirms each against its text or corrects it. ## 1. Scope In scope: the payloads of the two companions (§4.1, §5.1) and how their metadata reads (§4.2, §5.2); the integer arithmetic the derivations share (§3); the two derivations (§4.4, §5.3 to §5.6), each step mapped onto the standard's own computation (§4.5, §5.7); their error against a floating-point evaluation of the same formulas (§6); the rendition record and its place in synthesis's class lattice (§7); verification (§8). Out of scope: a transport; how a sink measures its own headroom; a base in another transfer than sRGB, a gain application colour space whose primaries differ from the base's, and the HDR reference white's luminance in candelas, each of which this revision does not compute (§4.2, §5.2); how a gain map is made from an HDR original, which is not part of the derivation (the corpus's encoder is the reference's own, §10); the presentation of a rendition on a panel, which takes the rendition's code values as linear drive values relative to the display's peak (§3.5) and does with them what the panel does. Relationship to the core spec: `wai.meta.gainmap` and `wai.meta.st2094_50` are **companion-only** capabilities ([SPEC](../SPEC.md) §5, §7.1): each is applied with the rendition it binds to and never decoded on its own. `wai.meta.gainmap` applies to a payload of the `image` media class, `wai.meta.st2094_50` to one of the `video` media class, as SPEC §7.1's rule 2 states. `wai.media.tonemap` is a **record** ([SPEC](../SPEC.md) §5): a signed record a WAI tool emits and verifies, which names no payload format and is not dispatchable. The two derivations are terms of this extension, not `wai.derive.*` strings: the registry's derivation kind is the operations of ISO/IEC 23001-16 ([derived-tracks](derived-tracks.md)), and a tone map is none of them. Relationship to [synthesis](synthesis.md): a rendition is a `convert` of its inputs (synthesis §3.2): it rebuilds, at the display's headroom, what the base and the companion together hold, names no model and generates nothing; its class is the greatest of `reconstructed` and its inputs' classes, so a rendition of a synthesized picture is synthesized (§7.2). Relationship to [determinism-tiers](determinism-tiers.md): display adaptation changes the decoded medium, so a tier covers a rendition only if its computation is pinned (determinism-tiers §2, *What a tier covers*). The derivations are integer throughout, so a rendition is a pure function of the base's samples, the companion's bytes and the headroom, and a sink that presents it holds the tier of the decode it ran on. ## 2. Records and buffers The record of this extension (§7) is a JSON object under the encodings of [synthesis](synthesis.md) §2: byte strings in lowercase hexadecimal, unsigned integers of at most 2^53 − 1 in plain decimal, no other number, no boolean, terms and date-times as [jwp-receipts](jwp-receipts.md) §2.4.3 defines them, no member named twice, at most 32 levels of nesting, exactly the members §7.1 lists at every level, and a `kind` naming the record; one that does not is `record_malformed`. It carries `signer_pubkey` (32 bytes) and `sig` (64 bytes): ``` sig = Ed25519(signer_key, "wai:tonemap\x01" || JCS(record without "sig")) link_hash = BLAKE3("wai:tonemap-link\x01" || JCS(record)) ``` JCS is RFC 8785. The signature is verified strictly ([SPEC](../SPEC.md) §7 *Signatures*): a key that fails step 1 or 2 is `bad_key`, and a signature that fails a later step `bad_signature`. A **base** picture is an `RGB8` buffer (rows top to bottom, pixels left to right, three bytes a pixel, R, G, B, no padding), read as sRGB-coded (IEC 61966-2-1, as ST 2094-50:2026-08 Formula (A.2) restates it). A **rendition** is an `RGB16` buffer: the same order, three unsigned 16-bit samples a pixel, each written little-endian. A buffer's **digest** is SHA-256 (FIPS 180-4) over it. An envelope's **content hash** is [jwp-receipts](jwp-receipts.md) §2.1's. ## 3. Integer arithmetic ### 3.1 Notation Every value is an integer. ⌊*a* / *b*⌋, for *b* > 0, is the floor of the quotient, also for a negative *a*. rnd(*a*, *b*) = ⌊(*a* + ⌊*b* / 2⌋) / *b*⌋ for *b* > 0: the integer nearest *a* / *b*, halves upward, for either sign of *a*. clamp(*v*, *lo*, *hi*) = min(max(*v*, *lo*), *hi*). A value in **Q*n*** stands for itself divided by 2^*n*: a `log2` value in Q16 is in units of 1/65536 of a stop, a linear light value in Q30 is in units of 2^−30 of HDR reference white. No intermediate value of this section or of §4 and §5 needs more than 127 bits and a sign; an implementation computes each exactly. ### 3.2 `L2`: a fixed-point `log2` For an integer *v* ≥ 1, L2(*v*) is a Q16 approximation of log2(*v*) from below: 1. *n* is the bit length of *v* less 1, so 2^*n* ≤ *v* < 2^(*n*+1). 2. *m* = *v* · 2^(30 − *n*) where *n* ≤ 30, and ⌊*v* / 2^(*n* − 30)⌋ otherwise: the mantissa in Q30, 2^30 ≤ *m* < 2^31. 3. *f* = 0; for *i* = 1 to 16 in turn: *m* = ⌊*m*² / 2^30⌋; where *m* ≥ 2^31, *m* = ⌊*m* / 2⌋ and *f* = *f* + 2^(16 − *i*). 4. L2(*v*) = *n* · 2^16 + *f*. Step 3 is the bitwise logarithm: each squaring doubles the fractional part, and a mantissa past 2 gives the next bit. Every squaring truncates. ### 3.3 `E2`: a fixed-point `exp2`, and scaling by it For a Q16 integer *y*, E2(*y*) is a pair (*p*, *e*) with 2^(*y* / 2^16) ≈ *p* · 2^(*e* − 30): 1. *e* = ⌊*y* / 2^16⌋ and *f* = *y* − 2^16 · *e*, 0 ≤ *f* < 2^16. 2. *p* = 2^30; for *i* = 1 to 16 in turn, where bit 16 − *i* of *f* is 1, *p* = rnd(*p* · *K*ᵢ, 2^30). *K*ᵢ is 2^(2^−*i*) in Q30, made by integer square roots: *R*₀ = 2^61, *R*ᵢ = ⌊√(*R*ᵢ₋₁ · 2^60)⌋ (each a Q60 value, the integer square root rounded down), and *K*ᵢ = rnd(*R*ᵢ, 2^30): | *i* | *K*ᵢ | *i* | *K*ᵢ | *i* | *K*ᵢ | *i* | *K*ᵢ | |---|---|---|---|---|---|---|---| | 1 | 1518500250 | 5 | 1097253708 | 9 | 1075196443 | 13 | 1073832680 | | 2 | 1276901417 | 6 | 1085434106 | 10 | 1074468888 | 14 | 1073787251 | | 3 | 1170923762 | 7 | 1079572136 | 11 | 1074105294 | 15 | 1073764537 | | 4 | 1121280436 | 8 | 1076653033 | 12 | 1073923544 | 16 | 1073753181 | `scale`(*X*, *y*, *F*), for an integer *X* and a Q16 *y*, is *X* · 2^(*y* / 2^16) / 2^*F* as an integer: with (*p*, *e*) = E2(*y*) and *t* = *e* − 30 − *F*, it is *X* · *p* · 2^*t* where *t* ≥ 0, and rnd(*X* · *p*, 2^−*t*) otherwise. Since both functions are functions of one integer, an implementation MAY tabulate them, or compute them any other way that gives the same integers: a table of E2 over the 2^16 values of *f* is the same function. ### 3.4 `S`: the base's samples in linear light S[*k*], for each sample value *k* from 0 to 255, is the sRGB decode of *k* / 255 in Q30, with HDR reference white at 2^30 (an sRGB-coded sample of 255 is reference white, ST 2094-50:2026-08 Note 4 to Formula (A.1)): - for *k* ≤ 10: S[*k*] = rnd(*k* · 100 · 2^30, 255 · 1292), the linear segment *k* / 255 / 12.92 (*k* / 255 ≤ 0.04045 exactly when *k* ≤ 10); - for *k* ≥ 11: with λ = L2(1000 · *k* + 14025) − L2(269025), the Q16 `log2` of (*k* / 255 + 0.055) / 1.055, S[*k*] = `scale`(2^30, rnd(12 · λ, 5), 0): that value to the power 2.4. S[255] = 2^30 exactly. ### 3.5 The rendition's code values A sink presents a rendition to a display of headroom *h*, an integer from 0 to 60000 in units of 1/10000 of a stop (the unit ST 2094-50:2026-08 Annex C encodes headrooms in): the display's peak is 2^(*h* / 10000) times HDR reference white. *H*ₜ = rnd(*h* · 2^16, 10000) is *h* in Q16. A rendition's code value is linear light relative to the display's peak, at 16 bits: a linear value *T* (Q30, relative to reference white) is presented as code(*T*) = clamp(`scale`(*T* · 65535, −*H*ₜ, 30), 0, 65535), so the display's peak is 65535 and a value past it is clamped, the projection of ST 2094-50:2026-08 Clause A.4, Note. Each derivation forms its value and its exponent together and rounds once (§4.4 step 7, §5.6). ## 4. Gain maps: `wai.meta.gainmap` and `wai-tonemap:gainmap` ### 4.1 The payload: `WGM1` A `wai.meta.gainmap` payload is a `WGM1` frame. WAI's own fields are little-endian. | off | size | field | rule | |---|---|---|---| | 0 | 4 | magic `WGM1` | | | 4 | 1 | version | `1` | | 5 | 1 | reserved | `0` | | 6 | 2 | `md_len` | 1 to 4096: the metadata's length | | 8 | `md_len` | metadata | ISO 21496-1:2025 metadata in its binary form (§4.2), as written | | 8 + `md_len` | the rest | gain map image | one PNG file (W3C PNG, Third Edition) of 8-bit samples, colour type 0 (one channel) or 2 (three), no interlace | A frame that breaks a rule of this table, or whose image is not such a PNG, or does not decode, is `companion_malformed`. Nothing follows the PNG's `IEND` chunk. ### 4.2 The metadata The metadata is ISO 21496-1:2025's binary form. **This revision did not read ISO 21496-1**; the fields below are the ones public, open-source readers of the form read, in their order, with the names they give. Its multi-byte fields are big-endian. | size | field | type | |---|---|---| | 2 | `minimum_version` | unsigned | | 2 | `writer_version` | unsigned | | 1 | `is_multichannel` (bit 7), `use_base_colour_space` (bit 6), six reserved bits | | | 4 + 4 | `base_hdr_headroom` numerator, denominator | unsigned, unsigned | | 4 + 4 | `alternate_hdr_headroom` numerator, denominator | unsigned, unsigned | | per channel, 1 or 3 times (3 where `is_multichannel` is 1) | | | | 4 + 4 | `gain_map_min` numerator, denominator | signed, unsigned | | 4 + 4 | `gain_map_max` numerator, denominator | signed, unsigned | | 4 + 4 | `gamma` numerator, denominator | unsigned, unsigned | | 4 + 4 | `base_offset` numerator, denominator | signed, unsigned | | 4 + 4 | `alternate_offset` numerator, denominator | signed, unsigned | Each pair is the rational numerator / denominator. Headrooms, `gain_map_min` and `gain_map_max` are `log2` values. A reader, in this order: 1. refuses metadata whose `minimum_version` is not 0 with `metadata_unsupported`: a reader does not apply metadata whose minimum version it does not know; 2. refuses metadata shorter than its fields, or whose `writer_version` is below its `minimum_version`, or, with a `writer_version` of 0, longer than its fields, with `metadata_malformed`; bytes after the fields of metadata of a later `writer_version` are read past, and the reserved bits are not read; 3. refuses with `metadata_out_of_range` metadata outside the range this revision computes: a denominator of 0; a headroom past 6 (the range of ST 2094-50:2026-08 §6.2.2); two equal headrooms, between which no weight is defined; a `gain_map_min` or `gain_map_max` past 16 in magnitude, or a `gain_map_min` greater than its `gain_map_max`; a `gamma` of 0 or outside 1/64 to 64; a `base_offset` or `alternate_offset` past 1 in magnitude; 4. refuses metadata whose `use_base_colour_space` is 0 with `gain_space_unsupported`: its gain is applied in the alternate image's colour space, which a conversion of primaries reaches, and this revision computes none. Then, for each channel *j*: min*ⱼ* and max*ⱼ* in Q16 are rnd(*n* · 2^16, *d*) of `gain_map_min` and `gain_map_max`; *bo*ⱼ and *ao*ⱼ in Q30 are rnd(*n* · 2^30, *d*) of `base_offset` and `alternate_offset`; γⱼ is the rational `gamma` itself. A single-channel map applies its one set to all three channels. ### 4.3 The gain map image The PNG's colour type is 0 for metadata whose `is_multichannel` is 0, and 2 where it is 1 (`map_channels_mismatch`). Its size is *g*w × *g*h, and the base's *w* × *h*: *s* = *w* / *g*w is an integer of 1, 2, 4 or 8, and *h* = *s* · *g*h (`map_dimensions_mismatch`). *g*(*x*, *y*) is its sample at column *x* and row *y*, of channel *j*. ### 4.4 `wai-tonemap:gainmap` `wai-tonemap:gainmap` maps a base of *w* × *h*, a `wai.meta.gainmap` payload that reads (§4.1 to §4.3) and a headroom *h*ᵈ (§3.5) to a rendition of *w* × *h*. With *H*b = `base_hdr_headroom` = *b*n / *b*d, *H*a = `alternate_hdr_headroom` = *a*n / *a*d and *H*ₜ = rnd(*h*ᵈ · 2^16, 10000): 1. **Weight.** *A* = *h*ᵈ · *b*d · *a*d − 10000 · *b*n · *a*d and *D* = 10000 · (*a*n · *b*d − *b*n · *a*d), so *A* / *D* = (*h*ᵈ / 10000 − *H*b) / (*H*a − *H*b). Where *D* > 0, *W* = clamp(rnd(*A* · 2^16, *D*), 0, 2^16); where *D* < 0, *W* = −clamp(rnd(−*A* · 2^16, −*D*), 0, 2^16). *W* is in Q16, and is negative where the alternate's headroom is below the base's. For each pixel (*x*, *y*) and each of its channels *c*, with *j* = *c* for a three-channel map and *j* = 0 for a single-channel one: 2. **Base.** *B* = S[base(*x*, *y*)[*c*]]. 3. **The map's sample at the pixel.** *X* = max(0, 2*x* + 1 − *s*), *x*₀ = ⌊*X* / 2*s*⌋, *ax* = *X* − 2*s* · *x*₀ and *x*₁ = min(*x*₀ + 1, *g*w − 1); *Y*, *y*₀, *ay* and *y*₁ the same from *y* and *g*h. Then *N* = *g*(*x*₀, *y*₀) · (2*s* − *ax*) · (2*s* − *ay*) + *g*(*x*₁, *y*₀) · *ax* · (2*s* − *ay*) + *g*(*x*₀, *y*₁) · (2*s* − *ax*) · *ay* + *g*(*x*₁, *y*₁) · *ax* · *ay*, and *Q* = 255 · 4*s*²: the map's normalised sample is *N* / *Q*, a centre-aligned bilinear interpolation of its codes, the edges repeated. With *s* = 1 it is the sample itself. 4. **Recovery.** *v* (Q30) = rnd(*N* · 2^30, *Q*) where γⱼ = 1; 0 where *N* = 0; otherwise, with γⱼ = *γ*n / *γ*d, λ = L2(*N*) − L2(*Q*) and *v* = `scale`(2^30, rnd(λ · *γ*d, *γ*n), 0): the sample raised to 1/γⱼ. 5. **Log gain.** *L* (Q16) = rnd(minⱼ · (2^30 − *v*) + maxⱼ · *v*, 2^30). 6. **Weighted gain.** *G* (Q16) = rnd(*L* · *W*, 2^16). 7. **Output.** *P* = `scale`((*B* + *bo*ⱼ) · 65535, *G* − *H*ₜ, 0) − `scale`(*ao*ⱼ · 65535, −*H*ₜ, 0), and the rendition's sample is clamp(rnd(*P*, 2^30), 0, 65535): code((*B* + *bo*) · 2^(*L* · *W*) − *ao*) of §3.5, both terms in Q30 of a code value and rounded once. The rendition at a headroom at or past the alternate's is the alternate image the map was made from, within the error of §6; at the base's headroom (*W* = 0) it is the base, offsets cancelling where they are equal. ### 4.5 Against ISO 21496-1's computation The computation the integer steps reproduce is the one the public readers of §4.2 implement: with the base's linear value *B*, the map's sample *g* in [0, 1] at the pixel, and the weight *W* = clamp((*H*ₜ − *H*b) / (*H*a − *H*b), 0, 1), negated where *H*a < *H*b, *L* = min + (max − min) · *g*^(1/γ), *T* = (*B* + *bo*) · 2^(*L* · *W*) − *ao*. ST 2094-50:2026-08 term 3.7 names ISO 21496-1:2025 clause 6.3 as the algorithm that produces an image at a targeted HDR headroom from per-pixel metadata; this revision did not read that clause (header note). Step by step: | step | real-number form | integer form (§4.4) | what is pinned | |---|---|---|---| | base to linear | sRGB decode of *k* / 255 | S[*k*] (§3.4) | `L2`, `E2`, one rounding each | | the map at the pixel | upsampled by a filter each implementation chooses | step 3: bilinear, centre-aligned, edges repeated, exact | the filter itself | | recovery | *g*^(1/γ) | step 4 | Q30, through Q16 `log2` | | log gain | min + (max − min) · *g*^(1/γ) | step 5 | Q16 | | weight | clamp of a ratio of headrooms | step 1, exact rational, rounded once | Q16 | | gain and offsets | (*B* + *bo*) · 2^(*L* · *W*) − *ao* | step 7 | Q30, through Q16 `exp2` | | presentation | *T* / 2^*H*ₜ at the display's peak | §3.5 | 16-bit code values | The upsampling filter is where two floating-point sinks differ most, and the standard, as far as this revision knows it, leaves it to the implementation. The derivation fixes it. ## 5. ST 2094-50: `wai.meta.st2094_50` and `wai-tonemap:st2094-50` ### 5.1 The payload: `WD50` A `wai.meta.st2094_50` payload is a `WD50` frame: the metadata sets of a video, each with the frames it applies to. ST 2094-50:2026-08 carries one metadata set with the frames it applies to by ITU-T T.35 messages in a codec or a container (its §7.3), and its Annex C encoding holds no time; the frame gives each set its frames, the TimeInterval of its §7.1, in frames of the base. WAI's own fields are little-endian. | off | size | field | rule | |---|---|---|---| | 0 | 4 | magic `WD50` | | | 4 | 1 | version | `1` | | 5 | 1 | reserved | `0` | | 6 | 2 | `n_sets` | 1 to 256 | | 8 | … | the sets | `n_sets` sets, one after another; nothing follows the last | A set: | size | field | rule | |---|---|---| | 4 | `first_frame` | the index of its first frame, frames counted from 0 | | 4 | `frame_count` | 1 or more | | 2 | `len` | 1 to 4096 | | `len` | the set | one `smpte_st_2094_50_application_info()` of ST 2094-50:2026-08 Annex C, as written | Each set's `first_frame` is at least the end of the set before it (its `first_frame` plus its `frame_count`), and no end passes 2^32 − 1. A frame that breaks a rule of these tables is `companion_malformed`. ### 5.2 Reading a metadata set A set reads as ST 2094-50:2026-08 Annex C has it: each syntax element of type u(N) an N-bit unsigned integer, most significant bit first, and the structures of its Tables C.1 to C.5 in order. The reader, in this order: 1. refuses a set whose `minimum_application_version` is not 0 with `metadata_unsupported`: Annex C.2.1 has a parser ignore the whole bitstream; 2. refuses a set that ends inside a structure, or holds a `reserved_zero` element that is not 0 (Annex C.1), with `metadata_malformed`. Bytes after the last element are padding and are not read (Annex C.2.1); 3. assigns the metadata items as Annex C.3 does: HDR reference white, 203 or clamp(·, 1, 50000) / 5 (C.3.3); headrooms min(·, 60000) / 10000 (C.3.4); `NumAlternateImages` min(·, 4); component mixing (C.3.6); gain curves, with the sign *s* of C.3.7, *x* = min(·, 64000) / 1000 and *y* = *s* · min(·, 60000) / 10000, and each slope from its angle or by C.3.9 (§5.4); 4. refuses with `metadata_out_of_range` a set that breaks a constraint of ST 2094-50:2026-08 §6.2.2, §6.4.2 or §6.5.2 (an alternate headroom equal to the baseline's, alternate headrooms not increasing, mixing weights that sum to 0, control points whose *x* decreases, or two of one *x* with different *y*), or whose gain curve's last control point has *x* = 0, beyond which Formula (12) is not finite; 5. refuses with `gain_space_unsupported` a set whose `use_reference_white_tone_mapping_flag` is 1, whose gain application colour space C.3.8 sets to the primaries of ITU-R BT.2020, or whose `gain_application_space_chromaticities_mode` is not 0, the primaries and white point of ITU-R BT.709: this revision applies gains in the base's primaries, those of sRGB, and converts none. The recommended constraints of §6.2.3 are not checked: §6.2.3 lets an implementation adjust metadata to meet them, and the derivation applies the metadata as it reads. For a base read as sRGB-coded (§2), the conversion of ST 2094-50:2026-08 Formula (A.3) divides the luminance of Formula (A.2) by the HDR reference white it was formed with, so the value in the gain application colour space is S[*k*] of §3.4 and the reference white's value in candelas does not enter. A set with no headroom-adaptive tone map (`has_adaptive_tone_map_flag` 0) leaves the choice to the output system (its Clause A.3); the derivation then applies none, as a set with no alternate images does (its Clause B.4): the rendition is the base, projected (§3.5). ### 5.3 The parameters as integers - **Headrooms** *H* in units of 1/10000 of a stop: the Annex C values, min(·, 60000). - **Mixing weights** in units of 1/150000: type 0, *k*max = 150000; type 1, *k*component = 150000; type 2, *k*red = *k*green = *k*blue = 25000 and *k*max = 75000; type 3, each coefficient 3 · min(·, 50000); every other weight 0. *k*sum is their sum. - **Control points**: *X*ᵢ = min(·, 64000), *x* in units of 1/1000; *Y*ᵢ = *s* · min(·, 60000), *y* in units of 1/10000; slopes *m*ᵢ in Q30 (§5.4). ### 5.4 Slopes **From an angle** (C.3.7, `gain_curve_use_pchip_slope_flag` 0): *m* = tan((clamp(θ, 1, 35999) − 18000) · π / 36000). With *d* = clamp(θ, 1, 35999) − 18000, *P* = ⌊π · 2^62⌋ = 14488038916154245684 and the angle in Q62 *a* = rnd(*d* · *P*, 36000), and *a*² in Q62 *q* = rnd(*a* · *a*, 2^62): - sine: *t* = *a*, *σ* = *a*; for *k* = 1 to 15 in turn, *t* = rnd(−*t* · *q*, 2^62 · 2*k* · (2*k* + 1)) and *σ* = *σ* + *t*; - cosine: *t* = 2^62, *κ* = 2^62; for *k* = 1 to 15 in turn, *t* = rnd(−*t* · *q*, 2^62 · (2*k* − 1) · 2*k*) and *κ* = *κ* + *t*; - *m* = rnd(*σ* · 2^30, *κ*). The angle is within π/2 of 0, so *κ* > 0, and fifteen terms of each series leave a remainder below 2^−62. **By PCHIP** (C.3.9, flag 1): each slope of C.3.9 is a rational function of the control points, computed exactly, and rounded once to Q30. With *h*ᵢ = *X*ᵢ₊₁ − *X*ᵢ and Δᵢ = *Y*ᵢ₊₁ − *Y*ᵢ, the slope of interval *i* is *s*ᵢ = Δᵢ / (10 · *h*ᵢ) stops per unit of *x*, and 0 where *h*ᵢ = 0 (Formula (C.7)). The interior rule (C.8), the endpoint rules (C.9, C.10) with their sign tests and their cap at 3 · |*s*|, and the degenerate case are C.3.9's, each comparison made on the exact rationals; the slope chosen, a rational, is *m* = rnd(numerator · 2^30, denominator) with a positive denominator. ### 5.5 Component mixing and the gain curve **Mixing** (Formulas (9), (10)): for a colour *C* = (*c*r, *c*g, *c*b) in Q30, *M*ⱼ = rnd(*c*ⱼ · *k*component + *k*red · *c*r + *k*green · *c*g + *k*blue · *c*b + *k*max · max(*C*) + *k*min · min(*C*), *k*sum), in Q30. **The gain curve** (Formulas (11), (12)) at *M* in Q30, against 1000 · *M* and *X*ᵢ · 2^30, its value *G* in Q16: - 1000 · *M* < *X*₀ · 2^30: *G* = rnd(*Y*₀ · 2^16, 10000); - 1000 · *M* = *X*ᵢ · 2^30 for some *i*: *G* = rnd(*Y*ᵢ · 2^16, 10000); - *X*ᵢ · 2^30 < 1000 · *M* < *X*ᵢ₊₁ · 2^30: the cubic of Formula (11). With *t* = rnd(1000 · *M* − *X*ᵢ · 2^30, *h*ᵢ) (Q30), *Y*′ᵢ = rnd(*Y*ᵢ · 2^30, 10000) (Q30) and *m̂*ᵢ = rnd(*h*ᵢ · *m*ᵢ, 1000) (Q30), the coefficients *c*₃ = 2*Y*′ᵢ + *m̂*ᵢ − 2*Y*′ᵢ₊₁ + *m̂*ᵢ₊₁, *c*₂ = −3*Y*′ᵢ + 3*Y*′ᵢ₊₁ − 2*m̂*ᵢ − *m̂*ᵢ₊₁, *c*₁ = *m̂*ᵢ and *c*₀ = *Y*′ᵢ are evaluated by Horner's rule, *u* = rnd(*c*₃ · *t*, 2^30) + *c*₂, then *u* = rnd(*u* · *t*, 2^30) + *c*₁, then *u* = rnd(*u* · *t*, 2^30) + *c*₀, and *G* = rnd(*u*, 2^14); - 1000 · *M* > *X*ₙ₋₁ · 2^30, past the last control point: *G* = rnd(*Y*ₙ₋₁ · 2^16, 10000) + L2(*X*ₙ₋₁ · 2^30) − L2(1000 · *M*), the term log2(*x*ₙ₋₁ / *x*) that holds the tone curve *x* · 2^*G* level. The colour gain function of an alternate image is the curve at each of *M*'s three components (Formula (7)); the zero function's is 0 (Formula (8)). ### 5.6 `wai-tonemap:st2094-50` `wai-tonemap:st2094-50` maps a frame of a base of *w* × *h*, a `wai.meta.st2094_50` payload that reads (§5.1, §5.2), the set whose frames include the frame's index (`frame_uncovered` where none does), and a headroom *h*ᵈ (§3.5) to a rendition of *w* × *h*. Following §6.2.5: the baseline headroom is placed among the alternates' in sorted position, *H*₀ < … < *H*ₙ₋₁, with the zero gain function at its place; *h*c = clamp(*h*ᵈ, *H*₀, *H*ₙ₋₁). For each pixel, *C* = (S[*r*], S[*g*], S[*b*]) of its samples, and the gain *G* of each component (Q16) is: - Gainᵢ(*C*) where *h*c = *H*ᵢ (Formula (2)); - otherwise, for the one *i* with *H*ᵢ < *h*c < *H*ᵢ₊₁, rnd(Gainᵢ(*C*) · (*H*ᵢ₊₁ − *h*c) + Gainᵢ₊₁(*C*) · (*h*c − *H*ᵢ), *H*ᵢ₊₁ − *H*ᵢ) (Formulas (3), (4), exact in the weights and rounded once). Each component of the rendition is clamp(`scale`(*c* · 65535, *G* − *H*ₜ, 30), 0, 65535) with *H*ₜ = rnd(*h*ᵈ · 2^16, 10000): code(*C* · 2^*G*) of §3.5 (Formula (5)), rounded once. ### 5.7 Against ST 2094-50's computation | ST 2094-50:2026-08 | real-number form | integer form | what is pinned | |---|---|---|---| | Formula (A.2), (A.3) | sRGB decode, over reference white | S[*k*] (§3.4) | `L2`, `E2` | | C.3.7 | *m* = tan of the angle | §5.4, series in Q62, *m* in Q30 | the series and its rounding | | C.3.9 | PCHIP slopes | §5.4, exact rationals, rounded once | Q30 | | (9), (10) | weighted mix over *k*sum | §5.5, rounded once | Q30 | | (11) | cubic Hermite in *t* | §5.5, Horner in Q30 | each product's rounding | | (12) | *y* + log2(*x*ₙ₋₁ / *x*) | `L2` difference | Q16 | | (2) to (4) | weights of headrooms | §5.6, exact, rounded once | Q16 | | (5) and A.4 | *C* · 2^*G*, projected at the display | §5.6 and §3.5 | 16-bit code values | Every formula of §6 of ST 2094-50:2026-08 has an integer step above, and every value in it is the one Annex C assigns from the bits. ## 6. Error against floating point The derivations round where the standards compute in real numbers, so a rendition differs from a floating-point evaluation of the same formulas by a little. The reference measures it over every rendition of the corpus: the same steps in IEEE 754 binary64 (sRGB decoding by `pow`, `log2`, `exp2` and `tan` of the platform library, the same bilinear weights, the PCHIP slopes in binary64), each code value rounded to nearest and clamped as §3.5 has it, against the integer rendition's: | derivation | renditions | samples | largest difference | mean difference | |---|---|---|---|---| | `wai-tonemap:gainmap` | 10 | 41472 | 2 | 0.1269 | | `wai-tonemap:st2094-50` | 25 | 172800 | 2 | 0.1642 | in code values of 65535. The corpus states the same figures (`measurement`), and its test fails when they change. A difference is the integer derivation's distance from that one floating-point evaluation; another floating-point implementation differs from it as well, by its own rounding and its own upsampling filter, and it is that spread which the derivation removes. ## 7. The rendition record ### 7.1 The record A sink that presents renditions signs a **rendition record**, `kind` `"wai-tonemap"`, for them, under a key of its session or of its own (the record names no viewer). A party that publishes a rendition signs one for what it published. | member | type | meaning | |---|---|---| | `kind` | string | `"wai-tonemap"` | | `derivation` | term | `wai-tonemap:gainmap` or `wai-tonemap:st2094-50` | | `base` | object | `{"object", "rendition", "width", "height"}`: the content hash of the envelope that carries the base and the companion; the index of the base's entry among its `renditions` (0 for a `WAI1`); and the base's size, each 1 to 4096 | | `companion` | object | `{"index", "sha256"}`: the index of the companion's entry, and the SHA-256 of its payload | | `pictures` | array | 1 to 256 entries, each `{"frame", "headroom", "input", "output"}`: the frame's index (0 for a still), the headroom (§3.5, 0 to 60000), the digest of the base frame's `RGB8` samples, and the digest of the rendition's `RGB16` samples; no two entries with the same frame and headroom, and entries in increasing order of frame, then of headroom | | `class` | string | the class of the renditions (§7.2) | | `inputs` | array | 1 to 16 entries, each `{"content_hash", "origin", "class"}`: an object the derivation read, the link hash of its origin claim ([synthesis](synthesis.md) §3.1) or `null`, and that claim's class, restated; one of them is `base.object`, and no content hash appears twice | | `issued_at` | date-time | when it signed it | | `signer_pubkey`, `sig` | | §2 | A `wai-tonemap:gainmap` record's pictures are all of frame 0. `class` and an input's `class` are one of `captured`, `reconstructed`, `assistive` and `synthesized`. A record that breaks a rule of this table is `record_malformed`; one whose headroom passes 60000 is `headroom_out_of_range`. ### 7.2 Provenance A rendition is a `convert` step of [synthesis](synthesis.md) §3.2. It rebuilds what its inputs hold: the base's samples, and, at a headroom, the alternate rendering the companion states of them. It names no model, asserts no source of its own and makes no evidence claim: there is no edit to check, only a computation to repeat, and §8 repeats it. Its class is the greatest of `reconstructed` and its inputs' classes, so a rendition never stands lower than what it was made from: a rendition of a captured photograph is `reconstructed`, and one of a synthesized picture is `synthesized`. A record whose `class` is another is `class_mismatch`. Where a rendition is published as an object, its origin claim is a `convert` claim whose inputs are the inputs of its record (synthesis §3.4, the row for a decoder's reconstruction), and its record is the claim's `record`. ## 8. Verification ### 8.1 Inputs A verifier is given, each in a list: rendition records; the envelopes it holds, each named by its content hash; the decoded frames it holds, each the `RGB8` samples of one frame of the base of an envelope, named by the envelope's content hash and the frame's index (a verifier that decodes a base itself holds its frames; the base of a `wai.image.png` entry is decoded by its PNG); and a **policy**: | member | values | default | meaning | |---|---|---|---| | `tonemap` | `accept-unrecomputed`, `require-proof` | `accept-unrecomputed` | `require-proof`: a picture the verifier does not recompute is refused (step 6) | ### 8.2 Steps The verifier runs these steps over each record in order and stops at the first refusal, which it reports with the reason and where: the record by its index (`["record", i]`), a picture of it by both (`["record", i, "picture", j]`). 1. **Reading.** In this order: the record reads (§2, §7.1, `record_malformed`), its signature verifies (`bad_key`, `bad_signature`), and no picture's headroom passes 60000 (`headroom_out_of_range`). 2. **Class.** The record's `class` is §7.2's: `class_mismatch`. 3. **The envelope**, where the verifier holds the one `base.object` names: 1. it parses ([SPEC](../SPEC.md) §2, §7.1) and every component conforms to SPEC §7.1's rules and to the role SPEC §5 gives its capability (the reference `WaiMulti::check_component_roles`): otherwise `envelope_nonconforming`; 2. entry `base.rendition` is a selectable rendition, entry `companion.index` a companion bound to it (SPEC §7.1, *Companion components*) whose capability is the derivation's (`wai.meta.gainmap` for `wai-tonemap:gainmap`, `wai.meta.st2094_50` for `wai-tonemap:st2094-50`), and the SHA-256 of the companion's payload is `companion.sha256`: otherwise `companion_mismatch`; 3. the companion reads: §4.1 to §4.3, or §5.1 and §5.2, in their order, each with its reason. 4. **Each picture**, in order, where the verifier holds its frame: 1. for `wai-tonemap:st2094-50`, a set of the companion covers the frame (`frame_uncovered`); 2. the frame's samples are `width` · `height` · 3 bytes and their digest is `input` (`input_samples_mismatch`); for `wai-tonemap:gainmap`, the map's size fits the base's (§4.3, `map_dimensions_mismatch`); 3. the derivation of §4.4 or §5.6 at the picture's headroom gives a rendition whose digest is `output` (`rendition_mismatch`). The picture is then **proved**. A picture whose frame or companion the verifier does not hold is **not recomputed**. 5. Nothing else is read: a picture's output is never compared with anything but its recomputation. 6. Under `tonemap` `require-proof`, a picture that is not proved: `rendition_not_proved`. ### 8.3 What an accepted case states For each record, in order: its link hash, its class, and for each picture, in order, `proved` or `not-recomputed`. ## 9. Reasons The codes this extension adds, each with where it is raised: | reason | raised at | |---|---| | `record_malformed` | §2, §7.1, §8.2 step 1 | | `bad_key` | §2 | | `bad_signature` | §2 | | `headroom_out_of_range` | §7.1, §8.2 step 1 | | `class_mismatch` | §7.2, §8.2 step 2 | | `envelope_nonconforming` | §8.2 step 3.1 | | `companion_mismatch` | §8.2 step 3.2 | | `companion_malformed` | §4.1, §5.1 | | `metadata_unsupported` | §4.2 step 1, §5.2 step 1 | | `metadata_malformed` | §4.2 step 2, §5.2 step 2 | | `metadata_out_of_range` | §4.2 step 3, §5.2 step 4 | | `gain_space_unsupported` | §4.2 step 4, §5.2 step 5 | | `map_channels_mismatch` | §4.3 | | `frame_uncovered` | §5.6, §8.2 step 4.1 | | `input_samples_mismatch` | §8.2 step 4.2 | | `map_dimensions_mismatch` | §4.3, §8.2 step 4.2 | | `rendition_mismatch` | §8.2 step 4.3 | | `rendition_not_proved` | §8.2 step 6 | A sink that is given an envelope which breaks SPEC §7.1's component rules reports SPEC's own code for the break (`companion-only-as-capability`, `companion-only-as-rendition`, `incompatible-base`, …); a verifier reports the record over it as `envelope_nonconforming`. ## 10. Conformance [`live-conformance/tonemap/`](../live-conformance/tonemap/) holds the corpus, written by `wai_tonemap_vectors gen` from fixed seeds and from the integer video corpus: `vectors.json` (with the arithmetic of §3 and §5.4 at stated points and over whole ranges: `L2`, `E2`, the table S, `tan` of every angle and the slopes of the video's curves, which a rendition of the corpus would not always expose), the envelopes it names in `envelopes/`, and `frames.rgb8`, the five frames of the `int_video` clip of `wai-rs/tests/vectors/int_video/` as `wai.video.int_motion` decodes them (their digests are that clip's sidecar's). Its stills are generated: an HDR scene made from integer formulas, rendered to an sRGB base and a gain map by the reference's own encoder, which is integer throughout and pinned by its code, not by this text. It has: - **Accepted**: a single-channel map at a quarter of the base's size and a three-channel map at full size with a `gamma` of 2, each at several headrooms from the base's to past the alternate's; a map whose alternate's headroom is below the base's (a negative weight); and the int_video clip under two metadata sets, one with slopes from angles and two alternates, one with PCHIP slopes, component mixing of types 2 and 3, at headrooms between, on and past the alternates'; a record whose base is a synthesized picture, `synthesized`; and the envelopes that carry each, which conform. - **Refused**: each reason of §9 at least once, each a control of one defect: among them a rendition at another headroom than the one stated (`rendition_mismatch`), a headroom past 6 stops (`headroom_out_of_range`), a map whose size does not divide the base's (`map_dimensions_mismatch`), metadata out of range in each companion (`metadata_out_of_range`), a rendition one sample of which was changed (`rendition_mismatch`), and a gain map carried as a rendition rather than a companion (`envelope_nonconforming`); and envelopes that break SPEC §7.1, each with SPEC's code: a gain map as a `WAI1`'s capability, a gain map as a selectable rendition, a gain map bound to a video, ST 2094-50 metadata bound to an image. A conforming verifier gives every case the outcome, the reason, the place and the statements the corpus states. `wai_tonemap_vectors verify` and the test `tests/exact_tonemap.rs` check that the corpus regenerates byte for byte, that `wai-rs` gives every case its outcome, that §9 lists exactly the reference's codes, that §6's figures are the corpus's, and that `frames.rgb8` is the decode of the `int_video` clip. `live-conformance/tonemap_verify.py` is the second implementation, written from this text: it shares no code with `wai-rs`; it computes both derivations in integer arithmetic of its own, reads PNG, the envelopes and both companions itself, recomputes §6's figures within one code value of the largest and 0.01 of the mean, reads §9's codes from this text, mutates copies of the corpus and requires every copy to be refused, and drops each of its own checks in turn and requires the corpus to catch the weakened verifier. ## 11. Security and privacy considerations - A rendition is a function of the base, the companion and the headroom. A proved record says that the sink presented that function at that headroom; it does not say the companion is the one the content's owner made. Who may supply a companion is the envelope's: a companion rides in the envelope of its base, bound by SPEC §7.1, so a third party cannot attach one without changing the envelope's content hash. - A record names the display's headroom. Headroom is a property of the display and its settings at the time, and a sequence of records from one key can show how a viewer's display was adjusted. A sink that signs records under a session key reveals no more than the session's. - A companion's metadata can drive the gain to 16 stops and the offsets to ±1. The derivation is defined over all of it, and §3.5 clamps every code value to the display's range, so no value it reads makes a sink present past its peak. - The readers of §4.2 and §5.2 bound every count and length before they read, and a set or a frame outside them is refused before anything sized from it is allocated.