Skip to main content
Auric Artisan · Documentation

LUT Lab Developer Reference

Maintain the LUT workbench: the .cube reader and writer and the one axis order they share, trilinear interpolation, the clipping predicate that replaced an out-of-gamut flag, the looks and what each one actually is, exports and URL state — and the two features that were withdrawn rather than fixed.

Published: June 3, 2026 Updated: June 3, 2026 Category: Reference Author: Chirag Bansal
Back to Documentation Auric Artisan Home

Overview

The bench is a static page plus four plain scripts: a numeric core that touches no document, a register of what every figure rests on, a page half that draws the pair, and a views half for the Method, Looks, Data, Export and Reference tabs. The interpolator underneath was correct and is unchanged. What sat around it was not: an overlay that flagged pure red before any LUT ran, a look naming two standards it never applied, a tone-map operating on the wrong quantity, and four wide-gamut standards listed under Reference with zero occurrences in the engine.

Table of contents

  1. 1. Code map
  2. 2. What was found, and how
  3. 3. The .cube format
  4. 4. Interpolation and the domain
  5. 5. Clipping, and what it replaced
  6. 6. The looks, and the two withdrawn
  7. 7. Linear light and the tone-map
  8. 8. The hue sweep and the shift readout
  9. 9. The round trip, both halves of it
  10. 10. UI contract
  11. 11. Exports and URL state
  12. 12. Public API
  13. 13. Tests

1. Code map

+
FileHoldsReaches the DOM
js/tool/lt/lt-core.js Every calculation. Installs as AALTCore. No — deliberately, so the suite runs the shipped file.
js/tool/lt/lt-sources.js The 17-entry register, as AALTSources. No.
js/tool/lut-lab.js State, the two canvases, the readouts, exports, tab switching. Yes. AALTPage.
js/tool/lut-lab-views.js Method, Looks, Data, Export, Reference, as AALTViews. Yes.
css/lut-lab-shell.css The workbench shell plus this route’s blocks under §V. —

Load order matters and none of these is a module: lt-core.js must be present before the page half, which throws a clear error rather than failing silently if it is not. The route is in the PAGES list of scripts/extract-anz-shell.mjs, so a class used only here still survives the cut into css/anz-shell.css.

The shell sheet is a checked port of css/dye-chemistry-shell.css, itself ported back through material lighting and psychophysics to the illuminants sheet. Lines 1–1581 are that shared chrome with the dy- prefix renamed; everything under §V is this route’s. Where a selector looks over-specified it is answering a rule in css/color-science-lab.css, and the comment beside it says which.

2. What was found, and how

+

Seven findings, each measured against the shipped code rather than reasoned about. The first, checked again against the old exporter, turned out not to be a defect.

The .cube axis order now has one definition

The reader indexed r + g*size + b*size*size, which is the specification, and the old writer looped b outermost and r innermost, which writes the same order, so its tables read back correctly here and elsewhere. An earlier version of this page said the writer exchanged the red and blue axes; it did not — the loop that runs red outermost draws the scatter plot, not the file. The order is still the most common place a .cube tool disagrees with itself, so reader and writer now call one cubeIndex() and cannot drift apart, and the Method tab prints the eight rows of a 2³ table in the order the file writes them.

The out-of-gamut overlay flagged pure red

The predicate was after[c] <= 0 || after[c] >= 255 for any channel, tested on the output alone. All six saturated primaries, black and white satisfy it before any LUT runs. On the neutral wedge scene it reports 80% of the picture. What a colourist is asking is what the transform crushed, which needs the input as well: clippedPixel(before, after) is true only where a channel was driven to an endpoint it did not already sit on. Both predicates remain in the engine and both run on every pixel, so the Lab tab reports the difference as a number rather than describing it.

“Cine Log → Rec.709” named two standards and applied neither

The function is a 0.45 power with a lift and a gain. No log encoding is named anywhere in it, no 709 matrix is applied, and the sRGB primaries this tool already works in are the 709 primaries — so there was never a conversion to perform. Same arithmetic, honest name: log-lift, “Log-ish lift”, labelled a stylisation.

The Reinhard tone-map ran on encoded values

L / (1 + L) is defined on linear radiance. It was being handed an sRGB code value and then given a 1/2.2 gamma on top of a value that already carried an sRGB curve — encoding twice. Mid grey came out at code 183 where it should be 141, and a scene whose shadows started at code 13 started at 86. The chain is now decode, scale, tone-map, encode. Both placements are in the engine so the Method tab can print the two columns side by side.

“ACEScct → ACEScg” decoded a scene-referred encoding into 8 bits

ACEScg is linear, scene-referred and wide-gamut; the specification runs to 65504. The look decoded ACEScct correctly and then clamped the result to [0,1] and wrote it into an 8-bit channel. 114 of the 256 input codes — everything from code 142 up — came out pure white, so 44.5% of the input range collapsed onto a single output. Input code 255 decodes to 222.86. The decode is kept in the engine (it is four lines and it is right) so the Looks tab can show exactly this; the look is withdrawn.

The old constants also carried one slip: the log segment divided by 0.057023 where the specification’s slope is 1/17.52 = 0.0570776, putting mid grey 0.165% low and the top of the range 0.5% high. acescctToLinearAsShipped() reproduces it.

The 32-bit float export checkbox had two identical branches

It branched on use32 and both sides pushed (out.r / 255).toFixed(6) — character for character the same line. It could not have worked either way: out.r is an integer from 0 to 255, so the value written is k/255 whichever branch runs. Offering a float switch on an 8-bit pipeline promises a precision the tool has nowhere to get. Withdrawn; the export header now states the 256 levels instead.

Four standards were listed and none was present

BT.2100 PQ, BT.2020, the ACES primaries and a “wide gamut” path appeared under Standards. Searched in the old engine by name: zero occurrences of 2084, of PQ, of 2020, of AP0 or AP1. The only matrix in the file is the sRGB one. BT.709 appears once, inside the label string 'Cine Log to Rec.709' — so of four claimed standards, three appear nowhere and the fourth appears only as a word in a menu. A LUT tool needs none of them, and the Reference tab now says so.

And one more, found while porting

The ΔE₀₀ mean-hue term folded the two >180° cases into a single “add 360”. CIE 142-2001 splits them: add 360 when the two hues sum below 360°, subtract when above. The two answers differ by exactly 360°, which the T weighting cannot tell apart because it reads the hue through cosines — but RT reads it through a Gaussian centred on 275°, which is not periodic. This is the fourth file in the repository found with the same slip.

3. The .cube format

+

parseCube(text, fileName) reads TITLE, LUT_1D_SIZE, LUT_3D_SIZE, DOMAIN_MIN and DOMAIN_MAX, and returns a record rather than throwing:

FieldMeaning
okfalse with error set if the file cannot be used at all.
kind"1D" or "3D".
size, rows, tableThe grid, and one [r,g,b] per row.
domainMin, domainMaxAs declared, not as assumed.
warningsEverything the file says that does not match what it contains.
reachableThe fraction of the declared domain an 8-bit input can address.

A .cube that disagrees with itself is the ordinary case, not the exception, so the warnings list is a permanent part of the rail rather than an error state. It reports a missing size keyword (and what was guessed), a body shorter or longer than the header promises, and a domain this tool cannot reach — a LUT declaring DOMAIN_MAX 4 4 4 is a valid file that an 8-bit input can only address a quarter of, and reachableFraction() says so instead of letting the tool silently use a corner of it.

serialiseCube(opts) writes the 3D form and serialiseCube1D(opts) the 1D form. The loop is b outermost, then g, then r — red innermost, so red varies fastest, matching cubeIndex().

4. Interpolation and the domain

+

trilinear(table, size, r01, g01, b01) is seven linear interpolations per channel: eight corners collapse to four along red, to two along green, to one along blue. The top index is clamped, so a sample sitting exactly on the last node reads that node instead of running off the end of the table. Note the signature takes the table, not the parsed record — identityCube(size) returns the record, so pass identityCube(n).table.

linear1D() is the same idea on one axis, and each output channel reads its own column: a 1D LUT is three curves, not one applied three times. Every 1D fixture in the suite used to be a neutral ramp, in which the three columns are equal, so a defect here was invisible; there is now a three-curve fixture.

applierFor(lut) honours the declared domain, mapping an 8-bit input through (v - domainMin) / (domainMax - domainMin) before the interpolation, and returns identityApply for a LUT that did not load — so a failed file leaves the picture alone rather than producing something.

5. Clipping, and what it replaced

+

Two predicates, both shipped, both run on every pixel:

  • clippedPixel(before, after) — true where a channel reached 0 or 255 and did not start there.
  • legacyOutOfGamut(after) — the old test, kept so the page can print what it flagged.

The overlay hatches clipped pixels in two colours, because crushing a highlight and crushing a shadow are different failures and a colourist treats them differently. analyseImage(before, after) returns meanDE, p95DE, maxDE, clippedFraction and legacyFlaggedFraction over every pixel of the pair.

When touching the predicate, note that each of its six clauses needs its own fixture. Black, white and the saturated primaries all have at least two channels at an endpoint, so five of the six clauses could go missing and a suite built from those still passed. The tests now use single-channel probes such as [0, 128, 128].

6. The looks, and the two withdrawn

+

Every entry in LOOKS carries a kind, and the distinction is the point of the tab. A stylisation is a curve somebody liked; there is nothing to check it against. A transform claims to convert between two defined encodings and can be checked against the definition. Of the original seven: four were always honest stylisations, one was a stylisation wearing a transform’s name, one was a real transform applied in the wrong place, and one is withdrawn.

idKindNote
teal-orangestylisationHand-tuned split-tone.
bleach-bypassstylisationDesaturates toward luminance, lifts contrast.
film-softstylisationLifted toe, shoulder short of white, per-channel warmth.
mono-contraststylisationRec.709 luminance weights in linear light, then an S-curve.
log-liftstylisationWas “Cine Log → Rec.709”. A 0.45 power with a lift and a gain.
reinhardtransformReinhard et al. (2002), applied in linear light.

WITHDRAWN holds the two removed features with a why and a wouldTake each, and the Looks tab renders them from that array — so a withdrawal cannot quietly lose its reason.

A look must return three integers in 0–255. encode() clamps, which means a look whose curve has been broken still returns valid bytes: a test asserting only totality passes on a look that has stopped being the look. Each stylisation therefore has an assertion pinning its character — that film-soft lifts its black, keeps its white below 255 and stays warm and monotone, for instance.

7. Linear light and the tone-map

+

toneMapReinhard(r, g, b, exposure) decodes sRGB to linear, multiplies by the exposure, applies L / (1 + L) and encodes back. The exposure defaults to 2 and is a stated argument rather than a hidden constant — the register carries it as a stand-in, because a one-stop lift before the operator is this tool’s choice and not anyone’s specification.

srgbEotf and srgbOetf take and return 0–1, not 0–255. That is the trap this repository has fallen into before.

8. The hue sweep and the shift readout

+

hueSweep({lightness, chroma, step}) walks a circle of constant L* and C* in CIELAB — 72 samples at 5° by default. sRGB cannot hold all of that circle, so a sample outside it is pulled in along the radius until it fits, and the sample records clipped: true when it had to be. The page prints how many were pulled. Reporting a hue shift without saying which samples had to be moved first would credit the transform with a displacement the gamut caused.

analyseShift(apply, opts) reports hueShift (wrapped to ±180°), lightnessShift, chromaShift, dE00 and clipped together. Hue alone is not enough: a transform can hold the hue angle exactly while moving everything else, and the readout it replaced reported an HSL hue — a hexagonal restatement of sRGB, which a transform can move while leaving the perceptual hue still.

9. The round trip, both halves of it

+

“Does it round-trip?” hides two questions, and stating either one alone gets it read as the other. Both are measured, and both are on the Method tab.

Does an identity give me my picture back? Yes, at every size this page writes. Measured through the whole path — serialise, parse, apply — over the full neutral ramp and 4,000 random colours at each of nine sizes: the worst output difference is 0 code values.

Does the file describe an identity exactly? Only when the grid lands on 8-bit steps. The writer samples each node as an 8-bit code, and a size-n grid has levels i/(n-1), which sit on k/255 only when (n-1) divides 255 = 3 × 5 × 17 — true of 2, 4, 6, 16, 18, 52, 86 and 256, and not of the conventional 17, 33 and 65, whose stored node values sit up to half a code (1.96e-3) off the exact grid. Rounding back to 8 bits removes that; an application reading the file at higher precision does not.

Separately, a table written and re-read by this parser reproduces every sample to 4.9e-7, which is the six-decimal text rounding and nothing else.

10. UI contract

+

Things that will break the page quietly if changed:

  • <main> must carry class="anz-main ax-lab". The control styling is scoped to .ax-lab.
  • Rails are class="panel lt-rail" and stages class="panel lt-stage", both inside div.lt-band.
  • .lt-row__head and .lt-cite are four-column grids and need exactly four direct children. Wrap two of them in a div and they share one cell and overflow it.
  • .lt-cap is a flex callout expecting a <p> child. A bare paragraph makes every text node and <em> a separate flex item. Plain prose uses .lt-caption.
  • A run carrying a Greek letter needs class="lt-sym"; uppercasing λ gives Λ, which means something else.
  • The Data tab’s count badge is written from counts().all, never typed into the markup.

Setting a <select> value from script needs setSelectValue(), not sel.value = v. The site replaces every select with a custom picker that follows the native element through a MutationObserver on its selected attributes; assigning .value moves the property only, which no observer can see, so the visible label would keep showing whatever was last clicked.

11. Exports and URL state

+

Four exports: the .cube itself, the same text to the clipboard, the hue sweep as CSV, and a JSON report that carries the image statistics, the transform, whether the scene was drawn or supplied, and the whole register.

The header is not decoration. A .cube carries no statement of its own precision, so a table written from an 8-bit pipeline looks exactly like one written from 32 — six decimals either way. headerLines() writes the transform, its kind, the strength and exposure if they apply, the sample order, the number of levels the pipeline actually has, and which side of the 8-bit grid question this size falls on.

?scene=&look=&s=&e=&n=&k= restores a setting. Every value is validated against what exists — an unknown look id or an out-of-range size is ignored rather than applied.

12. Public API

+

window.AALTPage:

MemberDoes
stateThe live state object.
SCENESThe four drawn scenes.
activeApply()The transform currently in force, look or file.
activeLabel()What to call it.
cubeText()The file as it would be written.
setLook(id)Selects a look; false for an unknown id.
refresh()Schedules a redraw.

window.AALTSources exposes all(), byId(), byStatus(), reportable(id), counts(), statusLabel(), markOwnImage() and resetScene(). A drop zone is the only path from synthesised to verbatim, and resetScene() must put back what it would take — there is a test for that, because a reset that leaves the entry claiming a real image is how a register starts lying.

13. Tests

+

npm run test:colorimetry runs the whole colorimetry suite, including tests/colorimetry/lt-reference.test.js and lt-sources.test.js — 44 assertions between them. lt-harness.js loads the shipped core and register in a vm context, so every assertion runs the code the page runs. Arrays built inside that realm are not reference-equal to the test’s, so array comparisons go through the harness’s same() rather than deepStrictEqual.

The ones that matter most when changing this file:

  • the sample ordering — the regression guard for the defect this rebuild exists for, checked on a hand-written file rather than on something the writer produced;
  • trilinear against an independent implementation;
  • a 1D LUT built from three different curves;
  • the old and new clipping predicates on probes that separate them, each clause with a case only it can answer;
  • ΔE₀₀ against an independent transcription of CIE 142-2001 — the published pairs pin seven points, not the surface between them;
  • the ACEScct toe as well as its log segment, and the encode inverting the decode across the whole range;
  • both halves of the round trip, above.

The suite was proved by injecting 54 defects — a transposed axis order, a writer looping the other way, a parser that accepts a short file, a domain not reported, the red fraction dropped from the interpolation, the old out-of-gamut predicate restored, Reinhard back on encoded values, the ACEScct constants moved, a stylisation relabelled a transform, a stand-in relabelled verbatim — and 52 were caught. Six survived the first run; four were gaps in the tests and are closed. The remaining two are the blend() early-outs at strength 0 and 1, and those were measured rather than argued: 1,968,512 comparisons against a shortcut-free blend across every look, zero disagreements. They are an optimisation, not a behaviour — and their safety rests on every look returning integers in range, which is now itself a test.