// AUTO's three readings are pure arithmetic on the binned ramp, so they can be // checked here rather than in a browser: hand the functions a histogram whose // answer is known by construction and read it back. The module is TypeScript // (and a .tsx), so it is transpiled on the fly out of the installed compiler and // the React/i18n imports — which only the overlay component needs — are dropped // before it is evaluated (same convention as preview-match-check.mjs). // // node scripts/auto-tone-check.mjs import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import ts from 'typescript'; const transpile = (path) => ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, }, }).outputText; const dir = mkdtempSync(join(tmpdir(), 'auto-check-')); // colorUtils only imports types, so the compiler drops that line on its own. writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts')); writeFileSync( join(dir, 'histogram.mjs'), transpile('../src/ui/Histogram.tsx') .replace( /^import .*from ['"]\.\.\/\.\.\/shared\/utils\/colorUtils['"];$/m, 'import { kelvinToRGB } from "./colorUtils.mjs";', ) // The overlay's own imports: the component is never rendered here, so React // and the i18n provider are dead weight node cannot resolve. .replace(/^import .*from ['"]react[^'"]*['"];$/gm, '') .replace(/^import .*from ['"]\.\.\/i18n\/I18nProvider['"];$/m, ''), ); const { autoExposureStops, autoTone, autoWhiteBalance, AUTO_EV_MAX } = await import( pathToFileURL(join(dir, 'histogram.mjs')).href ); const BINS = 256; // One pixel per bin: the whole 0..255 ramp, evenly, so every quantile of it is // known in closed form (quantile q of a flat ramp is q). const flat = () => new Array(BINS).fill(1); // Every pixel at one level: a frame with a single tone in it, and nothing else. const spike = (v, n = 1000) => { const bins = new Array(BINS).fill(0); bins[v] = n; return bins; }; // --- Exposure: the mean luma against the 0.48 a grey card lands on, in stops. const stops = (lum) => autoExposureStops(lum); assert.equal(stops(spike(255)), Math.log2(0.48 / 1), 'white is pulled back by log2(0.48)'); assert.equal(stops(spike(0)), AUTO_EV_MAX, "black is pushed to the knob own ceiling"); // Half a stop down from the target is 0.48 / 2^0.5 = 0.339 -> bin 87. assert.equal(Math.round(stops(spike(87)) * 10) / 10, 0.5, 'a dark frame is asked for +0.5 stop'); // The flat ramp averages exactly mid-grey, which is 0.0589 stop under target. assert.ok(Math.abs(stops(flat()) + 0.0589) < 5e-4, 'a flat ramp sits just under mid-grey'); assert.equal(stops(spike(20, 0)), AUTO_EV_MAX, 'an empty histogram takes the ceiling, not infinity'); // --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±5. assert.deepEqual(autoTone(spike(255)), { highlight: -5, shadow: 0 }, 'blown frame only pulls highlights'); assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 5 }, 'black frame only opens shadows'); // The flat ramp is the interesting one: its own top 1% really is clipped and its // own bottom 1% really is on the floor, so both ends move. p99 = 253/255 = 0.992 // (5 units), p01 = 2/255 = 0.008 (3 units). assert.deepEqual(autoTone(flat()), { highlight: -5, shadow: 3 }, 'an even ramp has both ends in play'); // 1% of the frame at white over an otherwise mid ramp: the spec's small blown // window, and the shadow side stays put because the ramp's floor is not black. const blown = flat(); blown[255] += 3; assert.deepEqual(autoTone(blown), { highlight: -5, shadow: 3 }, 'a blown 1% is enough to pull the top'); // p99 landed just over the threshold: 989 pixels mid-ramp and 11 at bin 243 puts // p99 at 0.953, 0.053 over 0.9 -> 2.65 units -> 3. Proof the pull is graded. const shoulder = spike(100, 989); shoulder[243] = 11; assert.deepEqual(autoTone(shoulder), { highlight: -3, shadow: 0 }, 'p99 just over 0.9 pulls a third of the way'); // 2% of pixels two bins off the floor: p01 = 0.008, so the shadow side moves — // but only 3 units, because the tail is barely under the 0.02 threshold. const crushed = spike(128, 980); crushed[2] = 20; assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 3 }, 'a crushed 2% opens the shadows part way'); // 0.4% on the floor is NOT the bottom 1%: the tenth-darkest pixel is the mid // ramp, so the threshold is not crossed and the knob stays put. const speck = spike(128, 996); speck[0] = 4; assert.deepEqual(autoTone(speck), { highlight: 0, shadow: 0 }, 'a speck of black is not a crushed frame'); assert.deepEqual(autoTone(spike(0, 0)), { highlight: 0, shadow: 0 }, 'an empty histogram is left alone'); // --- White Balance: gray-world with G as the anchor, matched against the // engine's own kelvinToRGB, so a neutral frame must come back on 5500K / 0. assert.deepEqual(autoWhiteBalance(flat(), flat(), flat()), { temperature: 5500, tint: 0 }, 'neutral stays put'); assert.deepEqual( autoWhiteBalance(spike(0), spike(0), spike(0)), { temperature: 5500, tint: 0 }, 'a frame with no light in it has no cast to read', ); // A mildly warm frame (R above G above B) is a frame lit too warmly, so the // ruler is asked to cool it — below 5500K. The cast sits on the planckian // locus, so no green/magenta trim is needed to reach it. const warm = autoWhiteBalance(spike(160), spike(150), spike(140)); assert.ok(warm.temperature < 5500, `a warm frame cools: got ${warm.temperature}K`); assert.equal(warm.tint, 0, `and stays on the locus: got tint ${warm.tint}`); // A cast far warmer than the Kelvin ruler's travel cannot be cancelled, and the // two axes overlap once the temperature pins at its end — so this only asks for // the direction and for the trim staying small. const veryWarm = autoWhiteBalance(spike(180), spike(150), spike(120)); assert.ok(veryWarm.temperature < 3000, `a harder warm cast cools further: got ${veryWarm.temperature}K`); assert.ok(Math.abs(veryWarm.tint) <= 5, `without a wild trim: got ${veryWarm.tint}`); // A frame that is only green is the one the TINT axis exists for: green is // trimmed toward magenta (positive), which no Kelvin move can do. const green = autoWhiteBalance(spike(120), spike(160), spike(120)); assert.ok(green.tint > 5, `a green frame is trimmed toward magenta: got ${green.tint}`); console.log('auto-tone-check ok');