120 lines
6.5 KiB
JavaScript
120 lines
6.5 KiB
JavaScript
// 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 ±10.
|
|
assert.deepEqual(autoTone(spike(255)), { highlight: -10, shadow: 0 }, 'blown frame only pulls highlights');
|
|
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 10 }, '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
|
|
// (9 units), p01 = 2/255 = 0.008 (6 units).
|
|
assert.deepEqual(autoTone(flat()), { highlight: -9, shadow: 6 }, '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: -10, shadow: 6 }, '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 -> 5.3 units -> 5. Proof the pull is graded.
|
|
const shoulder = spike(100, 989);
|
|
shoulder[243] = 11;
|
|
assert.deepEqual(autoTone(shoulder), { highlight: -5, shadow: 0 }, 'p99 just over 0.9 pulls half the way');
|
|
// 2% of pixels two bins off the floor: p01 = 0.008, so the shadow side moves —
|
|
// but only 6 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: 6 }, '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');
|