diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index d86746c..c6120d2 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -69,6 +69,10 @@ const { TONE_BLACK_EDGE, TONE_BASE_RADIUS, TONE_BASE_SIGMA, + TONE_BASE_TAPS, + TONE_BASE_RANGE_EV, + TONE_BASE_SKSL, + toneBaseStep, toneUniformArray, } = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href); @@ -187,11 +191,24 @@ assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238'); assert.doesNotMatch(sksl, /float lin\(/, 'the linear-light knee ahead of the knobs came back'); assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again'); -assert.match(sksl, /float target = clamp\(o, 0\.0, 1\.0\);/); -assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again'); +// The ramp is read at the BASE (Base' + Detail), and the pixel's own difference +// from it rides through un-scaled. These pin the two lines the whole tone pass +// hangs on: read at `t`, a knob is a global curve again (the HIGHLIGHT/SHADOW +// local-contrast report), and scaled by the neighbourhood's gain, a knob softens +// the frame's texture into its level (the report before that). +assert.match(sksl, /float o = toneCurve\(base, blVal, shVal, hlVal, whVal\);/); +assert.match(sksl, /float target = clamp\(o \+ \(t - base\), 0\.0, 1\.0\);/); +assert.doesNotMatch(sksl, /float o = toneCurve\(t, /, 'the ramp is read at the pixel again — a global curve'); assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/); assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again'); -assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/); +// The base layer itself: an edge-aware pass, not the gaussian the ramp fell back +// to (a gaussian average lands ACROSS an edge, and the halo follows the knob). +assert.match(TONE_BASE_SKSL, /uniform float2 dir;/); +assert.match(TONE_BASE_SKSL, /float ref = toneBaseMed3\(/); +assert.match(TONE_BASE_SKSL, /float d1 = \(a1 - ref\) \/ \(ref \+ TONE_BASE_PED\) \/ TONE_BASE_RANGE;/); +assert.doesNotMatch(TONE_BASE_SKSL, /toneBaseMed3\(l0, ref, /, 'the reference is the median of the wrong triple'); +assert.ok(toneBaseStep(1200, 600) === 4 && toneBaseStep(400, 300) === 1 && toneBaseStep(4000, 3000) === 13, + `the base step drifted: ${toneBaseStep(1200, 600)}, ${toneBaseStep(400, 300)}, ${toneBaseStep(4000, 3000)}`); assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve'); assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again'); // The linear-light knee that used to run ahead of all this is GONE from the tone @@ -447,8 +464,8 @@ function lightMove(rgb, t, o) { function rebuild(rgb, k, base) { const t = lumaOf(rgb); const b = base ?? t; - const o = ramp(t, k).o; - const target = clamp01(o); + const o = ramp(b, k).o; + const target = clamp01(o + (t - b)); const out = lightMove(rgb, t, target); return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target }; } @@ -543,8 +560,9 @@ for (const [rgb, knobs] of [ // // read at the pixel every pixel lands on its own o, so the band's spread // comes out multiplied by whatever slope the curve has -// over it — 1.2347 of its own spread at this deflection, -// the SHADOW +100 table steepening through 0.26..0.50. +// over it — 0.779 of its own spread at this deflection, +// the SHADOW +100 table flattening through 0.26..0.50 +// (its +0.25 lift saturates and the knots bend over). // read at the base every pixel of ONE neighbourhood takes the same move, // o(base) - base, and its own difference from the base is // added to it, so the texture inside the region comes out @@ -560,6 +578,14 @@ const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31)); const spread = (xs) => Math.max(...xs) - Math.min(...xs); const bandBase = band.reduce((a, b) => a + b, 0) / band.length; const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]); +// ...and measured, so the two readings cannot drift apart in the source while +// the numbers above stay prose: the global one scales the band by the curve's +// slope (0.78 at SHADOW +100 — the lift saturates over this quarter and the +// texture shrinks with it), the base one moves it and leaves its spread alone. +const movedOnBase = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]); +const slopeOverBand = spread(movedGlobally) / spread(band); +assert.ok(slopeOverBand < 0.9, `the SHADOW +100 table stopped flattening through 0.26..0.50: ${slopeOverBand}`); +near4(spread(movedOnBase), spread(band)); // Every knob on zero is the identity, whatever base is handed in for (const b of [0.01, 0.1, 0.35, 0.7, 0.99]) diff --git a/docker/frontend/scripts/tone-base-check.mjs b/docker/frontend/scripts/tone-base-check.mjs index cd97844..2fd5ec8 100644 --- a/docker/frontend/scripts/tone-base-check.mjs +++ b/docker/frontend/scripts/tone-base-check.mjs @@ -1,6 +1,8 @@ -// The tone ramp is drawn through a BASE layer, and that base is now a blurred -// CHILD of the pass rather than a ring of point samples inside it — see -// TONE_BASE_RADIUS in toneShader.ts for the mottle that ring caused. Two things +// The tone ramp is drawn through a BASE layer, and that base is a CHILD of the +// pass — the edge-aware pass of TONE_BASE_SKSL, run by the caller — rather than a +// ring of point samples inside it or a blur of it: see TONE_BASE_RADIUS in +// toneShader.ts for the mottle a ring caused and for the halo a gaussian one +// does. Two things // can go wrong with that and neither is a crash: the shader stops compiling once // it takes a second child (SkSL is only checked at runtime, and no other check // here compiles TONE_SKSL as a whole), or the second child is wired to the wrong @@ -34,12 +36,16 @@ writeFileSync( ), ); const { - TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive, - TONE_KNOBS, + TONE_SKSL, TONE_BASE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, TONE_BASE_TAPS, toneBaseStep, + getToneUniforms, toneUniformArray, toneIsActive, TONE_KNOBS, } = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href); -// The shader has to NAME a base child — a ring of taps would not need one. +// The shader has to NAME a base child — a ring of taps would not need one — and +// the base pass has to take its own two, one per axis. assert.match(TONE_SKSL, /uniform shader base;/); +assert.match(TONE_BASE_SKSL, /uniform shader src;/); +assert.match(TONE_BASE_SKSL, /uniform float2 dir;/); +assert.ok(TONE_BASE_TAPS > 0 && toneBaseStep(1200, 600) * TONE_BASE_TAPS >= 1200 * TONE_BASE_RADIUS - 0.5); const CanvasKit = await CanvasKitInit({ locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)), @@ -118,17 +124,34 @@ const t = srcValue / 255; const b = baseValue / 255; // The neighbourhood's new luma, plus the pixel's own DIFFERENCE from the base — // Base' + Detail, the reconstruction the shader emits (see the note in -// toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and -// it is what took the texture out of the frame at BLACK -100. -const diff = Math.abs(t - b); -const smooth = (x) => x * x * (3 - 2 * x); -const expected = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o)); +// toneRamp). Two other reconstructions were here before it: the ramp read at the +// PIXEL (a global curve, and the local-contrast report), and Base' * (Input / +// Base), the gain-weighted detail that took the texture out of the frame at +// BLACK -100. +const detail = (pixel, base) => clamp01(ramp(base, { sh: 1 }).o + (pixel - base)); +const expected = Math.round(255 * detail(t, b)); const got = render(srcValue, baseValue); assert.ok( Math.abs(got - expected) <= 2, `a base of ${b.toFixed(4)} under a pixel of ${t.toFixed(4)} gave ${got}, the ramp predicts ${expected}`, ); +// The same instruction on the other side of the pixel: a base BRIGHTER than it +// takes the neighbourhood's move and hands the pixel its own difference back, so +// the pixel lands BELOW the neighbourhood's new luma. One move per +// neighbourhood, not one per pixel — which is exactly what a global curve +// cannot say. +const brighterValue = 180; +const brighter = render(srcValue, brighterValue); +const brighterWant = Math.round(255 * detail(t, brighterValue / 255)); +assert.ok( + Math.abs(brighter - brighterWant) <= 2, + `a base of ${(brighterValue / 255).toFixed(4)} over a pixel of ${t.toFixed(4)} gave ${brighter}, Base'+Detail predicts ${brighterWant}`, +); +assert.ok( + brighter < got, + `a brighter base did not pull the pixel down (${brighter} against ${got}) — the base is not in the move`, +); // With the base handed in as the sharp image the difference is exactly zero and // the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no // neighbourhood of its own) needs it to be. It is NOT the identity any more: @@ -161,6 +184,8 @@ assert.equal( console.log( `tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` + - `(ramp predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` + - `radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`, + `(Base'+Detail predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` + + `over a base of ${brighterValue} -> ${brighter} (predicts ${brighterWant}); ` + + `radius ${TONE_BASE_RADIUS} of the frame, ${TONE_BASE_TAPS} taps per side, step ` + + `${toneBaseStep(1200, 600)} px at 1200, sigma ${TONE_BASE_SIGMA} of the radius`, ); diff --git a/docker/frontend/scripts/tone-local-contrast-check.mjs b/docker/frontend/scripts/tone-local-contrast-check.mjs new file mode 100644 index 0000000..1ac3c64 --- /dev/null +++ b/docker/frontend/scripts/tone-local-contrast-check.mjs @@ -0,0 +1,638 @@ +// LOCAL CONTRAST AND HALO of the tone ramp, on the reference pipeline's own +// test scene — and the twin below is checked against the real SkSL, compiled and +// rendered through CanvasKit, not against a reading of the source. +// +// THE SCENE is crates/pipeline/src/tests_tone.rs's, in LINEAR light: a bright +// half (sky 0.85) carrying 1 px dark twig lines (0.14) and a dark half (ground +// 0.011) carrying 1 px bright stalk lines (0.06), each pixel modulated by a +// ±3% 8-bit texture step. Everything is measured on the ENCODED 8-bit output, +// like the reference does, because that is what a knob's damage looks like. +// +// THE BASE PASS RUNS ON A SURFACE TONE_BASE_SCALE OF THE FRAME (toneBaseSurface +// in toneShader.ts), and both sides of this file mirror that: the real side +// draws the frame's child through a canvas-scale and the twin box-averages the +// frame to match, then the real side's tone pass reads the half through the +// local matrix exportEngine.baseUpscale hands it — NEAREST, so the twin replicates +// the half's texels back. A Node/CanvasKit calibration pinned both samplers +// against drawImageRectOptions (the frame's own 2:1 downscale and its nearest +// 1:2 upscale), and the asserts below re-pin them here: `downAt` and `upscale2` +// disagree with Skia by less than one code value, and would disagree by several +// at a half-texel off. +// +// WHAT THE FOUR KNOBS MUST NOT DO (the report this pass was rewritten for): +// +// LOCAL CONTRAST. A 1 px twig is detail, not tone: HIGHLIGHT -100 and +// SHADOW +100 may move a region, they may not flattern the twig into the sky +// it sits in. Read at the PIXEL, every pixel lands on the ramp read at its own +// luma, so a knob's slope compresses the detail with the tone — the twig lost +// 0.79 of its contrast against the sky at HIGHLIGHT -100 (scratchpad +// proto3/proto.mjs). Read at the BASE with the pixel's own difference added +// back (Base' + Detail), the region takes one move and the twig keeps its own +// size. Measured here as the high-pass energy of a half (std of the pixel +// against its 9x9 mean, in code values) and as the twig/stalk line contrast, +// |log2(mean off line / mean on line)|, both against the GLOBAL reading of the +// same knob and against EXPOSURE +/-1 EV. +// +// NO HALO. At a step edge the base must be the level of the pixel's OWN side, +// or the pixels beside the sky take the shadow side's move and a bright rim +// follows the knob (docker/halo_highlight_shadow_white_black.md). A gaussian +// base fails this by construction and is measured here to prove the test +// bites: base 1.0/255, global 4.0/255, gaussian 37.0/255 on the same frame. +// +// TWO CAVEATS, both measured rather than argued, and both printed below: +// +// The "opposite half unchanged" criterion cannot hold ABSOLUTELY with this +// app's measured tables: HIGHLIGHT -100 moves a MID luma by -0.031 and SHADOW +// +100 lifts a mid luma by +0.17 (TONE_KNOBS), so the half the knob is not +// aimed at moves -7.8/255 and +43/255 — as does a global curve, and by +// construction: the fix is that the base does not ADD a leak of its own on top +// of the curve, which is what the flat-region check asserts (|base - global| +// <= 1.5/255 there, i.e. the base stops moving where the curve does). +// +// SHADOW +100 cannot hold 0.9x EXPOSURE +1 EV on the dark half either, with +// ANY implementation: this app's SHADOW is an absolute lift (slope 1 on the +// dark half) while the reference's is a ratio in log space, so the exposure +// reading is the stronger one. The check asserts what the design promises +// (>= 0.9x the plain frame, and >= 1.1x the global reading) and PRINTS the +// exposure ratio for the record. +// +// node scripts/tone-local-contrast-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 { fileURLToPath, pathToFileURL } from 'node:url'; +import ts from 'typescript'; +import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js'; + +const transpile = (path) => + ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText; + +const tmp = mkdtempSync(join(tmpdir(), 'tone-local-contrast-')); +writeFileSync(join(tmp, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts')); +writeFileSync( + join(tmp, 'toneShader.mjs'), + transpile('../shared/utils/toneShader.ts').replace( + /^import .*from ['"]\.\/colorUtils['"];$/m, + 'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";', + ), +); +const { TONE_SKSL, TONE_BASE_SKSL, TONE_KNOBS, toneBaseSurface, TONE_BASE_SCALE, toneBaseStep, getToneUniforms, toneUniformArray } = await import( + pathToFileURL(join(tmp, 'toneShader.mjs')).href +); + +// --------------------------------------------------------------------------- +// The scene, the size the reference measures at, as 8-bit ENCODED bytes: the +// twin reads the same bytes the renderer is handed, so the two cannot diverge on +// the input either. +const W = 1200; +const H = 600; +const STEP = toneBaseStep(W, H); +// The surface the base pass runs on, and its own tap step: the radius is the +// FRAME's fraction either way, the step is what changes (see toneBaseSurface). +const HALF = toneBaseSurface(W, H); +const HSTEP = HALF.step; +const HALF_N = HALF.width * HALF.height; +const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055); +const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const onLine = (x, y) => (Math.floor(x / 48) + Math.floor(y / 48)) % 2 === 0 && (x % 4 === 1 || y % 5 === 2); +const q8 = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255); +function sceneBytes(L) { + const b = new Uint8Array(W * H * 4); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const tex = 1 + 0.06 * ((((x * 7 + y * 13) % 5) - 2) / 2); + const base = x < W / 2 ? (onLine(x, y) ? L.twig : L.sky) : onLine(x, y) ? L.stalk : L.ground; + const v = q8(enc(Math.min(1, base * tex))); + const i = (y * W + x) * 4; + b[i] = b[i + 1] = b[i + 2] = v; + b[i + 3] = 255; + } + return b; +} +const toFloat = (bytes, w = W, h = H) => { + const f = new Float32Array(w * h); + for (let i = 0; i < w * h; i++) f[i] = bytes[i * 4] / 255; + return f; +}; +const SCENE = sceneBytes({ sky: 0.85, twig: 0.14, ground: 0.011, stalk: 0.06 }); +// The halo frame: the SAME chequer with the lines taken out of the values, so the +// only edge left to test is the one at x = W/2. +const FLAT = sceneBytes({ sky: 0.85, twig: 0.85, ground: 0.011, stalk: 0.011 }); + +// --------------------------------------------------------------------------- +// THE TWIN: TONE_BASE_SKSL's two axis passes and TONE_MATH_SKSL's ramp, in +// arithmetic — the twin of tone-base-check.mjs, one frame wide. The base it +// produces is quantized to 8 bits between the passes, because the renderer's +// surfaces are: that is the one place the two could drift, so it is modelled. +const clamp01 = (x) => Math.min(1, Math.max(0, x)); +const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); +const TAPS = 8; +const SIGMA = 0.35; +const RANGE_EV = 0.25; +const PED = 0.02; +const RADIUS = 0.025; +const LN2R = Math.LN2 * RANGE_EV; +const med3 = (a, b, c) => Math.max(Math.min(a, b), Math.min(Math.max(a, b), c)); + +// The frame as the half-res surface sees it. The pass draws its full-size child +// onto that surface through a 2:1 canvas scale, and Skia's linear sampler at 2:1 +// IS the 2x2 average (calibrated against drawImageRectOptions: same bytes), so +// the twin averages the same four pixels and lets the pass quantize the result — +// the average itself is a sampler reading, not a surface write, so it is NOT +// rounded here. The four pixels are CLAMPED ONE BY ONE, not the average's own +// half index: a tap that falls off the frame is a truncated box in the real +// sampler, and averaging a clamped half index instead reads a box that does not +// exist there (one code value at the frame's first and last rows/columns, which +// is exactly what the asserts below caught). +function downAt(src) { + const g = (X, Y) => src[clamp(Y, 0, H - 1) * W + clamp(X, 0, W - 1)]; + return (x, y) => + (g(2 * x, 2 * y) + g(2 * x + 1, 2 * y) + g(2 * x, 2 * y + 1) + g(2 * x + 1, 2 * y + 1)) / 4; +} + +// ...and back, as the tone pass's own read of it: the half is handed to the tone +// shader with the local matrix baseUpscale builds (2x) and a NEAREST sampler, so +// frame pixel X reads half pixel floor(X / 2) — a 2x2 replication of the half's +// texels, calibrated against Skia's own sampler (calib2.mjs: 0/256 mismatches on +// a 16x16 field; linear's quarter-texel weights instead put the far side of a +// step edge into the pixel beside it, which is the halo this pass must not have). +function upscale2(half) { + const out = new Float32Array(W * H); + for (let y = 0; y < H; y++) { + const y0 = Math.min(Math.floor(y * TONE_BASE_SCALE), HALF.height - 1) * HALF.width; + for (let x = 0; x < W; x++) + out[y * W + x] = half[y0 + Math.min(Math.floor(x * TONE_BASE_SCALE), HALF.width - 1)]; + } + return out; +} + +function axis(src, w, h, step, dx, dy, at = (x, y) => src[clamp(y, 0, h - 1) * w + clamp(x, 0, w - 1)]) { + const out = new Float32Array(w * h); + const gs = []; + for (let i = 1; i <= TAPS; i++) gs.push(Math.exp(-0.5 * (i / (TAPS * SIGMA)) ** 2)); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const c = at(x, y); + const ref = med3(at(x - dx * step, y - dy * step), c, at(x + dx * step, y + dy * step)); + let sum = ref; + let total = 1; + for (let i = 1; i <= TAPS; i++) { + const g = gs[i - 1]; + for (const sgn of [1, -1]) { + const a = at(x + sgn * dx * step * i, y + sgn * dy * step * i); + const d = (a - ref) / (ref + PED) / LN2R; + const r = Math.exp(-d * d); + sum += g * r * a; + total += g * r; + } + } + out[y * w + x] = sum / total; + } + return out; +} +const quant = (f) => { + const o = new Float32Array(f.length); + for (let i = 0; i < f.length; i++) o[i] = Math.round(f[i] * 255) / 255; + return o; +}; +// The base at HALF the frame, as the passes leave it (8-bit between them), and +// put back at the frame's own size — which is what the ramp in the tone pass +// reads. +const halfBaseOf = (src) => + quant(axis(quant(axis(null, HALF.width, HALF.height, HSTEP, 1, 0, downAt(src))), HALF.width, HALF.height, HSTEP, 0, 1)); +// The half keeps the NEIGHBOURHOOD, which is the whole point of the scale: its +// step read out at the frame's own size is the frame's own step, so the radius +// toneShader names is the same fraction of the frame on either surface. +assert.equal(HSTEP / TONE_BASE_SCALE, STEP, 'the half surface lost the base radius'); +// The hedge the ramp used to have: a gaussian of TONE_BASE_RADIUS, run through +// the same ramp, to show what this test is FOR (it fails the halo bound). +function gaussian(src, sigma) { + const r = Math.ceil(sigma * 3); + const k = []; + for (let i = -r; i <= r; i++) k.push(Math.exp(-0.5 * (i / sigma) ** 2)); + const ksum = k.reduce((a, b) => a + b, 0); + const tmpf = new Float32Array(src.length); + const out = new Float32Array(src.length); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let s = 0; + for (let i = -r; i <= r; i++) s += k[i + r] * src[y * W + clamp(x + i, 0, W - 1)]; + tmpf[y * W + x] = s / ksum; + } + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let s = 0; + for (let i = -r; i <= r; i++) s += k[i + r] * tmpf[clamp(y + i, 0, H - 1) * W + x]; + out[y * W + x] = s / ksum; + } + return out; +} + +const poly = (c, L) => { + let v = c.v[0]; + for (let i = 1; i < c.p.length; i++) + v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1); + return v; +}; +const knob = (L, k, amt) => + clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L))); +const ramp = (L, k = {}) => + knob(knob(knob(knob(L, 'bl', k.bl ?? 0), 'sh', k.sh ?? 0), 'hl', k.hl ?? 0), 'wh', k.wh ?? 0); +// Base' + Detail: the neighbourhood's new luma plus the pixel's own difference +// from the base, un-scaled (TONE_MATH_SKSL's toneRamp). +const apply = (frame, baseArr, k) => { + const out = new Float32Array(frame.length); + for (let i = 0; i < frame.length; i++) { + const t = frame[i]; + const b = baseArr ? baseArr[i] : t; + out[i] = clamp01(ramp(b, k) + (t - b)); + } + return out; +}; +const applyGlobal = (frame, k) => apply(frame, null, k); +// EXPOSURE as EXPOSURE_SKSL runs it: the stop is a RATIO on the pixel's own +// encoded luma, so the frame is the knob's identity at 0 EV (see toneShader.ts). +const exposure = (frame, ev) => { + const out = new Float32Array(frame.length); + for (let i = 0; i < frame.length; i++) { + const y = Math.max(lin(frame[i]), 1e-6); + out[i] = clamp01(frame[i] * (enc(Math.min(1, y * 2 ** ev)) / enc(y))); + } + return out; +}; + +// --------------------------------------------------------------------------- +// THE REAL PIPELINE, through CanvasKit: TONE_BASE_SKSL twice — the horizontal +// pass on a surface TONE_BASE_SCALE of the frame, drawing the frame's own child +// through a canvas-scale, the vertical one reading that half at 1:1, exactly as +// exportEngine.toneBase builds them — then TONE_SKSL with the frame and the half +// base as its two children, the base read through baseUpscale's local matrix. +const CanvasKit = await CanvasKitInit({ + locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)), +}); +const infoAt = (w, h) => ({ + width: w, height: h, colorType: CanvasKit.ColorType.RGBA_8888, + alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB, +}); +const info = infoAt(W, H); +const halfInfo = infoAt(HALF.width, HALF.height); +const imageOf = (bytes) => CanvasKit.MakeImage(info, bytes, W * 4); +const halfImageOf = (bytes) => CanvasKit.MakeImage(halfInfo, bytes, HALF.width * 4); +const childOf = (image, localMatrix, fm = CanvasKit.FilterMode.Linear) => + localMatrix + ? image.makeShaderOptions(CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, fm, CanvasKit.MipmapMode.None, localMatrix) + : image.makeShaderOptions(CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, fm, CanvasKit.MipmapMode.None); +const readOut = (surface, w = W, h = H) => + surface.makeImageSnapshot().readPixels(0, 0, infoAt(w, h)).slice(0, w * h * 4); + +function pass(effect, uniforms, children) { + const shader = effect.makeShaderWithChildren(uniforms, children); + assert.ok(shader, 'the pass did not take its children'); + const surf = CanvasKit.MakeSurface(W, H); + const paint = new CanvasKit.Paint(); + paint.setShader(shader); + surf.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, W, H), paint); + const out = readOut(surf); + paint.delete(); + shader.delete(); + surf.delete(); + return out; +} +// One axis of the base pass, drawn 1:1 on the half — the scale is the CHILD's +// local matrix, not the canvas (see exportEngine.baseDownscale), which is what +// keeps `dir` in the half surface's own pixels rather than in the frame's. +function baseAxis(effect, child, dx, dy) { + const shader = effect.makeShaderWithChildren([dx, dy], [child]); + assert.ok(shader, 'the base pass did not take its child'); + const surf = CanvasKit.MakeSurface(HALF.width, HALF.height); + const paint = new CanvasKit.Paint(); + paint.setShader(shader); + surf.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, HALF.width, HALF.height), paint); + const out = readOut(surf, HALF.width, HALF.height); + paint.delete(); + shader.delete(); + surf.delete(); + return out; +} +function realBase(bytes) { + const effect = CanvasKit.RuntimeEffect.Make(TONE_BASE_SKSL); + assert.ok(effect, 'TONE_BASE_SKSL does not compile'); + const img = imageOf(bytes); + const h = baseAxis(effect, childOf(img, CanvasKit.Matrix.scaled(TONE_BASE_SCALE, TONE_BASE_SCALE)), HSTEP, 0); + const hImg = halfImageOf(h); + const v = baseAxis(effect, childOf(hImg), 0, HSTEP); + effect.delete(); + img.delete(); + hImg.delete(); + return v; +} +function realTone(bytes, baseHalfBytes, adjustments) { + const effect = CanvasKit.RuntimeEffect.Make(TONE_SKSL); + assert.ok(effect, 'TONE_SKSL does not compile'); + const img = imageOf(bytes); + const uniforms = getToneUniforms(adjustments); + const up = CanvasKit.Matrix.scaled(1 / TONE_BASE_SCALE, 1 / TONE_BASE_SCALE); + const bImg = baseHalfBytes ? halfImageOf(baseHalfBytes) : null; + const out = pass(effect, toneUniformArray(uniforms), [ + childOf(img), + bImg ? childOf(bImg, up, CanvasKit.FilterMode.Nearest) : childOf(img), + ]); + effect.delete(); + img.delete(); + if (bImg) bImg.delete(); + return out; +} + +// --------------------------------------------------------------------------- +// THE MEASUREMENTS, the reference's own: a HALF is 40 px clear of the frame's +// own edge, the line contrast is measured on the lines against the flat pixels +// of the same half (the 9x9 statistic skips the lines, since a 9x9 window over a +// 1 px line is mostly line). +const lumaAt = (f, x, y) => f[y * W + x]; +function lineContrast(f, x0, x1) { + let on = 0, onc = 0, off = 0, offc = 0; + for (let y = 40; y < H - 40; y++) + for (let x = x0; x < x1; x++) { + const v = lumaAt(f, x, y); + if (onLine(x, y)) { on += v; onc++; } else { off += v; offc++; } + } + return Math.abs(Math.log2(off / offc / (on / onc))); +} +function stats(f, x0, x1, skipLines = false) { + let sum = 0, n = 0, hp2 = 0; + for (let y = 40; y < H - 40; y++) + for (let x = x0; x < x1; x++) { + if (skipLines && onLine(x, y)) continue; + let m = 0, mn = 0; + for (let dy = 0; dy < 9; dy++) + for (let dx = 0; dx < 9; dx++) { + const sx = clamp(x + dx - 4, 0, W - 1); + const sy = clamp(y + dy - 4, 0, H - 1); + if (skipLines && onLine(sx, sy)) continue; + m += lumaAt(f, sx, sy); + mn++; + } + m /= mn; + const hp = lumaAt(f, x, y) - m; + sum += lumaAt(f, x, y); + hp2 += hp * hp; + n++; + } + return { mean: sum / n, hp: (Math.sqrt(hp2 / n) * 255) }; +} +// Halo: the deviation of the moved frame from the SAME-side pixel 100 px clear of +// the edge, over +-40 px around it, in code values. +function halo(plain, moved) { + const edge = W / 2, row = H / 2; + const farB = lumaAt(moved, 100, row) - lumaAt(plain, 100, row); + const farD = lumaAt(moved, W - 100, row) - lumaAt(plain, W - 100, row); + let worst = 0; + for (let x = edge - 40; x < edge + 40; x++) { + const d = lumaAt(moved, x, row) - lumaAt(plain, x, row); + worst = Math.max(worst, Math.abs(d - (x < edge ? farB : farD))); + } + return worst * 255; +} +const maxDiff = (a, b, n = W * H) => { + let m = 0; + for (let i = 0; i < n; i++) m = Math.max(m, Math.abs(a[i] - b[i])); + return m * 255; +}; +const meanDiff = (a, b, n = W * H) => { + let s = 0; + for (let i = 0; i < n; i++) s += Math.abs(a[i] - b[i]); + return (s / n) * 255; +}; +const halfOf = (f) => ({ bright: stats(f, 40, 540), dark: stats(f, 700, 1160) }); + +// --------------------------------------------------------------------------- +// The twin and the real pipeline, once each per frame and knob. `sceneTwinBase` +// is the twin's base AT THE FRAME'S OWN SIZE (the half bilineared back by the +// local matrix the tone pass reads it through) — the ramp is fed the frame's +// coordinates, exactly like the shader's own child. +const sceneF = toFloat(SCENE); +const sceneTwinHalf = halfBaseOf(sceneF); +const sceneTwinBase = upscale2(sceneTwinHalf); +const plain = sceneF; + +// The half the real passes leave, and the tone passes that read it. +const SCENE_BASE_BYTES = realBase(SCENE); +const twin = {}; +const real = {}; +for (const [name, knobK, adj] of [ + ['hl-100', { hl: -1 }, { highlights: -100 }], + ['sh+100', { sh: 1 }, { shadows: 100 }], +]) { + twin[name] = { base: apply(sceneF, sceneTwinBase, knobK), global: applyGlobal(sceneF, knobK) }; + real[name] = toFloat(realTone(SCENE, SCENE_BASE_BYTES, adj)); +} +const twinExp = { 'exp-1': exposure(sceneF, -1), 'exp+1': exposure(sceneF, 1) }; + +// The twin against the real shader, per pixel: the numbers below are only worth +// reading if the two agree, so this is asserted before anything is measured. The +// tone reading carries the upscale with it (the twin bilinears the half back, the +// real pass reads it through its local matrix), so a half-texel out in either +// would show here as tens of code values, not as the fraction below. +// The base itself, at the size the passes leave it: the bilateral (the twin) +// against TONE_BASE_SKSL compiled and rendered (the real), on the same frame. +const sceneRealBase = toFloat(SCENE_BASE_BYTES, HALF.width, HALF.height); +const i2j = (i, stride = W) => (i / stride) | 0; +const worst = (a, b, stride = W) => { + let mi = 0, mv = -1, n = 0; + const par = [0, 0, [0, 0]]; + for (let i = 0; i < a.length; i++) { + const d = Math.abs(a[i] - b[i]); + if (d > mv) { mv = d; mi = i; } + par[i % 2] += d; + par[2][i2j(i, stride) % 2] += d; + n++; + } + return `worst ${(mv * 255).toFixed(3)}/255 at ${mi % stride},${i2j(mi, stride)}; mean-even-col ${((par[0] / (n / 2)) * 255).toFixed(3)} odd-col ${((par[1] / (n / 2)) * 255).toFixed(3)} even-row ${((par[2][0] / (n / 2)) * 255).toFixed(3)} odd-row ${((par[2][1] / (n / 2)) * 255).toFixed(3)}`; +}; +assert.ok( + maxDiff(sceneTwinHalf, sceneRealBase, HALF_N) <= 1.001, + `the twin base is not TONE_BASE_SKSL: max ${maxDiff(sceneTwinHalf, sceneRealBase, HALF_N).toFixed(3)}/255 — ${worst(sceneTwinHalf, sceneRealBase, HALF.width)}` +); +for (const name of ['hl-100', 'sh+100']) { + const d = meanDiff(twin[name].base, real[name]); + const mx = maxDiff(twin[name].base, real[name]); + assert.ok( + d <= 1.0 && mx <= 4, + `the twin is not the shader at ${name}: mean ${d.toFixed(3)}/255, max ${mx.toFixed(3)}/255 — ${worst(twin[name].base, real[name])}` + ); +} + +// --------------------------------------------------------------------------- +// Halo, on the step-edge frame. +const flatF = toFloat(FLAT); +const flatTwinHalf = halfBaseOf(flatF); +const flatTwinBase = upscale2(flatTwinHalf); +const flatGauss = gaussian(flatF, W * RADIUS * SIGMA); +const flatRealBaseBytes = realBase(FLAT); +const flatRealBase = toFloat(flatRealBaseBytes, HALF.width, HALF.height); +const flatReal = toFloat(realTone(FLAT, flatRealBaseBytes, { highlights: -100 })); +const haloBase = halo(flatF, apply(flatF, flatTwinBase, { hl: -1 })); +const haloGlobal = halo(flatF, applyGlobal(flatF, { hl: -1 })); +const haloGauss = halo(flatF, apply(flatF, flatGauss, { hl: -1 })); +const haloReal = halo(flatF, flatReal); +// The base stops where the curve stops: on a patch 60 px clear of the edge the +// two readings agree to within the 8-bit quantization of one base, because the +// base's own texture averages out there — the mean is what is compared, since +// the per-pixel difference on such a patch IS the +-3% texture the base exists +// to hand back to the pixel (that is contrast kept, not a leak). +function patchMean(f) { + let s = 0, n = 0; + for (let y = 60; y < 160; y++) + for (let x = 60; x < 160; x++) { s += lumaAt(f, x, y); n++; } + return s / n; +} +const flatLeak = Math.abs(patchMean(apply(flatF, flatTwinBase, { hl: -1 })) - patchMean(applyGlobal(flatF, { hl: -1 }))) * 255; +const flatTexture = (() => { + const a = apply(flatF, flatTwinBase, { hl: -1 }); + const g = applyGlobal(flatF, { hl: -1 }); + let m = 0; + for (let y = 60; y < 160; y++) + for (let x = 60; x < 160; x++) m = Math.max(m, Math.abs(lumaAt(a, x, y) - lumaAt(g, x, y))); + return m * 255; +})(); + +assert.ok(haloBase <= 5, `the base haloed: ${haloBase.toFixed(3)}/255`); +assert.ok(haloReal <= 5, `the REAL pipeline haloed: ${haloReal.toFixed(3)}/255`); +assert.ok(haloGauss > 10, `the gaussian hedge did not halo (${haloGauss.toFixed(3)}/255) — the test no longer bites`); +assert.ok(flatLeak <= 1.5, `the base leaks into the flat region: ${flatLeak.toFixed(3)}/255`); +assert.ok( + maxDiff(flatTwinHalf, flatRealBase, HALF_N) <= 1.001, + `the real base is not TONE_BASE_SKSL on the step edge: max ${maxDiff(flatTwinHalf, flatRealBase, HALF_N).toFixed(3)}/255` +); + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Local contrast, knob by knob and half by half, with each assertion read off +// the measurement it is about rather than hard-coded. A half is CRUSHED when the +// GLOBAL reading of the knob leaves less high-pass energy there than the plain +// frame does, and that is the half where the two readings have to part: there the +// base reading must beat the global one (the report this pass was rewritten for), +// and elsewhere it must hold the frame's own detail. `hp` is the high-pass energy +// of the half in code values, `lc` the 1 px line contrast, |log2(off/on)|. +const pc = (x) => `${x.toFixed(3)}x`; +// How far a half's level may move before its log-contrast reading is read as the +// knob's own lift rather than as damage (see the note in the loop). +const LC_SHIFT = 10; +const HALVES = [['bright', 40, 540], ['dark', 700, 1160]]; +const KNOBS = [ + ['HIGHLIGHT -100', 'hl-100', { hl: -1 }, twinExp['exp-1'], 'EXPOSURE -1 EV'], + ['SHADOW +100', 'sh+100', { sh: 1 }, twinExp['exp+1'], 'EXPOSURE +1 EV'], +]; +const bTex = (f) => stats(f, 40, 540, true).hp; +const rows = []; +for (const [name, key, knobK, exp, expName] of KNOBS) { + const moved = twin[key].base; + const glob = twin[key].global; + const realF = real[key]; + for (const [half, x0, x1] of HALVES) { + const hp = { + base: stats(moved, x0, x1).hp, + real: stats(realF, x0, x1).hp, + global: stats(glob, x0, x1).hp, + plain: stats(plain, x0, x1).hp, + exp: stats(exp, x0, x1).hp, + }; + const lc = { + base: lineContrast(moved, x0, x1), + global: lineContrast(glob, x0, x1), + plain: lineContrast(plain, x0, x1), + exp: lineContrast(exp, x0, x1), + }; + const crushed = hp.global < hp.plain; + // ...and how far the knob moves the LEVEL of this half. A lift squeezes a + // ratio: SHADOW +100 puts the dark half up 61 code values, so a stalk line + // whose luma difference is untouched comes out at 0.58 where it was 1.34 — + // the ratio is what moves, not the detail. So the log reading is asserted on + // halves the knob leaves level, and the absolute one (hp) everywhere. + const shift = (stats(moved, x0, x1).mean - stats(plain, x0, x1).mean) * 255; + const level = Math.abs(shift) <= LC_SHIFT; + if (crushed) { + // The pixel reading crushes this half; the base reading must not follow it. + assert.ok( + hp.base >= 1.1 * hp.global, + `${name}: the ${half} half is where the global reading crushes (${hp.global.toFixed(3)} against ${hp.plain.toFixed(3)} plain) and the base reading followed it: ${pc(hp.base / hp.global)}`, + ); + assert.ok( + hp.real >= 1.1 * hp.global, + `${name}: the REAL pass followed the crush on the ${half} half: ${pc(hp.real / hp.global)} (${hp.real.toFixed(3)} against ${hp.global.toFixed(3)})`, + ); + assert.ok( + lc.base >= 1.0 * lc.global, + `${name}: the base reading lost the ${half} half's 1 px lines to the global one: ${lc.base.toFixed(3)} against ${lc.global.toFixed(3)}`, + ); + } else { + // The pixel reading holds or expands this half; the base reading still has + // to keep the detail the untouched frame has. + assert.ok( + hp.base >= 0.9 * hp.plain, + `${name}: the ${half} half lost detail against the plain frame: ${hp.base.toFixed(3)} against ${hp.plain.toFixed(3)} code values`, + ); + assert.ok( + !level || lc.base >= 0.9 * lc.plain, + `${name}: the ${half} half's 1 px lines lost contrast against the plain frame: ${lc.base.toFixed(3)} against ${lc.plain.toFixed(3)} (level moved ${shift.toFixed(2)}/255)`, + ); + assert.ok( + level || lc.base >= 0.8 * lc.global, + `${name}: the ${half} half's 1 px lines lost contrast to the global reading: ${lc.base.toFixed(3)} against ${lc.global.toFixed(3)} (level moved ${shift.toFixed(2)}/255)`, + ); + // The caller's exposure criterion, where EXPOSURE is the MILD reading of + // the knob: a stop that leaves the frame less texture than the plain frame + // does is a reading of the lift, not of the detail (caveat at the head). + if (hp.exp < hp.plain) + assert.ok( + hp.base >= 0.9 * hp.exp, + `${name}: the ${half} half fell under ${expName}: ${pc(hp.base / hp.exp)} (${hp.base.toFixed(3)} against ${hp.exp.toFixed(3)})`, + ); + } + rows.push({ name, half, hp, lc, crushed, expName, shift, level }); + } +} + +// --------------------------------------------------------------------------- +// THE NUMBERS, so a drift in any of them is visible in the log and not only in a +// threshold. The opposite half's mean shift — the "the half the knob is not aimed +// at moved too" observation — is printed and not asserted, for the reason at the +// head of this file. +console.log( + `tone local contrast ok — scene ${W}x${H}, base ${HALF.width}x${HALF.height} ` + + `(TONE_BASE_SCALE ${TONE_BASE_SCALE}), base step ${HSTEP} px there = ${RADIUS} of the FRAME's long edge, ` + + `${TAPS} taps per side, range sigma ${RANGE_EV} EV, and 8-bit in and out`, +); +console.log( + ` twin vs real shader: max ${Math.max(maxDiff(twin['hl-100'].base, real['hl-100']), maxDiff(twin['sh+100'].base, real['sh+100'])).toFixed(3)}/255, ` + + `mean ${Math.max(meanDiff(twin['hl-100'].base, real['hl-100']), meanDiff(twin['sh+100'].base, real['sh+100'])).toFixed(4)}/255; ` + + `base image max ${Math.max(maxDiff(sceneTwinHalf, sceneRealBase, HALF_N), maxDiff(flatTwinHalf, flatRealBase, HALF_N)).toFixed(3)}/255 ` + + `(half vs half, ${HALF.width}x${HALF.height})`, +); +console.log( + ` halo, step edge: base ${haloBase.toFixed(2)}/255, real ${haloReal.toFixed(2)}/255, global ${haloGlobal.toFixed(2)}/255, ` + + `gaussian ${haloGauss.toFixed(2)}/255 (bound 5); flat-region leak ${flatLeak.toFixed(3)}/255 (bound 1.5) ` + + `with ${flatTexture.toFixed(2)}/255 of texture kept per pixel there`, +); +for (const { name, half, hp, lc, crushed, expName, shift, level } of rows) + console.log( + ` ${name}, ${half} half${crushed ? ' (CRUSHED by the global reading)' : ''}${level ? '' : ` (level moved ${shift.toFixed(2)}/255 — the log reading below is the lift's, not detail)`}: hp base ${hp.base.toFixed(3)} ` + + `(real ${hp.real.toFixed(3)}), global ${hp.global.toFixed(3)}, plain ${hp.plain.toFixed(3)}, ${expName} ${hp.exp.toFixed(3)} ` + + `— base ${pc(hp.base / hp.global)} global, ${pc(hp.base / hp.plain)} plain${hp.exp < hp.plain ? `, ${pc(hp.base / hp.exp)} ${expName}` : ` (${expName} is ${pc(hp.exp / hp.plain)} the plain frame — a lift, not detail)`}; ` + + `line contrast base ${lc.base.toFixed(3)} vs global ${lc.global.toFixed(3)}, plain ${lc.plain.toFixed(3)}`, + ); +console.log( + ` texture the base keeps (line-free patch of the bright half): ${bTex(plain).toFixed(3)} code values in, ` + + `${bTex(twin['sh+100'].base).toFixed(3)} with SHADOW +100 (${pc(bTex(twin['sh+100'].base) / bTex(plain))})`, +); +for (const [name, key, half] of [['HIGHLIGHT -100', 'hl-100', 'dark'], ['SHADOW +100', 'sh+100', 'bright']]) { + const x = half === 'dark' ? [700, 1160] : [40, 540]; + const shift = (f) => ((stats(f, x[0], x[1]).mean - stats(plain, x[0], x[1]).mean) * 255).toFixed(2); + console.log( + ` ${name}, opposite half mean shift: base ${shift(twin[key].base)}/255, global ${shift(twin[key].global)}/255 ` + + `(the curve's own move on a luma it is not aimed at; printed, not asserted)`, + ); +} diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index f882059..1e71de1 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -244,26 +244,95 @@ const KNOB_SKSL = (['bl', 'sh', 'hl', 'wh'] as const) // deflection on the sample frame keeps 0.77 of the band's spread at 0.7%, 0.80 at // 2.5%, 0.81 at 4.8%. // -// The caller turns this into a BLUR, and it took a bug to make that a blur in -// fact and not only in name. The base used to be nine point samples of the child -// out at plus or minus this radius, and point samples are not an average: on a -// frame with texture at the sampling scale — a waterfall, a mountainside — the -// nine-tap luma aliases, the gain o(base)/base inherits the alias, and the -// reconstruction paints it as mottle. Measured on a 1160x774 frame at -// SHADOW +100, the high-frequency (9px high-pass) part of that gain field was -// 0.063 against 0.005 for a real blur of the same radius — 13x. So the base is -// now a real gaussian blur of the child, made by the caller (Skia's own -// MakeBlur, see blurredBase() in exportEngine.ts) and read here as ONE tap. That -// is also the cheaper pass: nine child evals walk the whole exposure/matrix -// chain nine times, one eval does not. +// The base used to be nine point samples of the child out at plus or minus this +// radius, and point samples are not an average: on a frame with texture at the +// sampling scale — a waterfall, a mountainside — the nine-tap luma aliases, the +// gain o(base)/base inherits the alias, and the reconstruction paints it as +// mottle. Measured on a 1160x774 frame at SHADOW +100, the high-frequency (9px +// high-pass) part of that gain field was 0.063 against 0.005 for a real blur of +// the same radius — 13x. +// +// So the base is made by the caller (TONE_BASE_SKSL, see toneBase() in +// exportEngine.ts): one bilateral pass per axis, the same 1xN/Nx1 frame +// CLARITY_BLUR_SKSL runs (`spatialReference`), read here as ONE tap. A GAUSSIAN — +// which is what the caller drew next — averages the neighbourhood whatever is in +// it, so at a hard edge it lands halfway across: the pixels beside a bright sky +// take the shadow side's move and the halo the doc reports +// (docker/halo_highlight_shadow_white_black.md) comes back with the knob. The +// range weight is what keeps a bright field's move off the dark side of it: a tap +// counts while its luma is within TONE_BASE_RANGE_EV of the base's own, and an +// edge of ~0.6 EV is three of those out, so the base stops there. +// +// A bilateral alone still is not enough, and the second half of the pass answers +// it: a range-weighted average PRESERVES a 1 px line (the line's own taps carry +// all the weight; its neighbours are cut), so a 1 px twig lands in the base and +// every pixel of it takes its own move — the ramp then compresses the twig with +// the sky exactly as a global curve would, which is the local contrast this pass +// exists to keep. The reference filter cuts the base of the MEDIAN of the +// neighbourhood for that reason (lightcraft #632). The centre reference here is +// that median — med3 along the pass's own axis, three taps a step apart — so a +// line one or two pixels wide is detail and not tone: its own base comes out at +// its surroundings' level, it takes the neighbourhood's move, and its own +// difference from the base is added back whole (Base' + Detail, TONE_MATH_SKSL). export const TONE_BASE_RADIUS = 0.025; -// One gaussian sigma of the base blur, as a fraction of TONE_BASE_RADIUS. A box +// Taps per side of each axis pass. The step follows from the radius, so the +// neighbourhood is still the one the radius names at 2 * TAPS + 1 taps. +export const TONE_BASE_TAPS = 8; + +// One gaussian sigma of the base pass, as a fraction of TONE_BASE_RADIUS. A box // of the same radius and a gaussian of this sigma carry the same weight at the -// radius, so the neighbourhood is the one the radius has always named while the -// cuts are smooth instead of hard. +// radius, so the spatial falloff still reaches the neighbourhood the radius +// names while the cuts are smooth instead of hard — 2 * 0.35 * TONE_BASE_RADIUS, +// the gaussian the base used to be. export const TONE_BASE_SIGMA = 0.35; +// The range sigma of the base's bilateral weight, in stops of the pixel's own +// luma: a tap counts while log2(tap / reference) is inside it. 0.25 EV puts an +// edge of 0.6 EV (the reference's "an edge of > ~0.6 EV is preserved") three +// sigmas out and cuts it; a texture of 0.05..0.1 EV is inside one sigma and is +// averaged away, which is what a base has to do with texture. +export const TONE_BASE_RANGE_EV = 0.25; + +// The base's own pedestal, in luma: the luma a ratio is taken against, so a pixel +// with almost no light on it still divides by something. The same 0.005 floor +// lightMove reads, at the one place a base can be read on a black frame. +const TONE_BASE_PEDESTAL = 0.02; + +// One sample step of the base pass, in px, for a surface this size: TONE_BASE_TAPS +// of them per side reach TONE_BASE_RADIUS of the LONG edge. A whole number of +// pixels, so a tap lands ON a pixel — the pass then reads the same pixels on the +// preview and on the file, and a harness's twin can add the same step. +export function toneBaseStep(width: number, height: number): number { + return Math.max(1, Math.round((Math.max(width, height) * TONE_BASE_RADIUS) / TONE_BASE_TAPS)); +} + +// The base pass runs on a surface this fraction of the frame per axis, because +// the base IS the level of a neighbourhood TONE_BASE_RADIUS of the long edge +// wide (toneBaseStep of the half above): half the samples are all of the detail +// that level can carry, and the pass costs a quarter of what it does on the +// frame. Measured on the 1067x1600 preview with the same drag: the two axis +// passes at 8 taps made a 715 ms render on the FRAME and a 410 ms one on this +// half — against 405 ms for the MakeBlur they replaced (scratchpad +// preview-render-measure.mjs, timed in one session), which is what the quarter +// buys. The base is unmoved to the eye at that scale +// (tone-local-contrast-check.mjs measures the base and the halo). +export const TONE_BASE_SCALE = 0.5; + +// The surface the base pass draws on, and the tap step that goes with it: the +// step is taken from the RADIUS and the half's own long edge, so the +// neighbourhood is the one TONE_BASE_RADIUS names of the FRAME either way. The +// caller scales the pass's draw to reach it (see toneBase in exportEngine.ts), +// which is also what box-downscales the frame's child into it. +export function toneBaseSurface( + width: number, + height: number +): { width: number; height: number; step: number } { + const w = Math.max(1, Math.round(width * TONE_BASE_SCALE)); + const h = Math.max(1, Math.round(height * TONE_BASE_SCALE)); + return { width: w, height: h, step: toneBaseStep(w, h) }; +} + // The tone and exposure maths, in ONE copy, because two passes ask it: the // whole-frame passes here and a gradient mask, which moves the same knobs on the // shape the user drew. What "HIGHLIGHT" or "EXPOSURE" means must not depend on @@ -323,10 +392,21 @@ vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float w float hlVal = clamp(hl - dr * 0.09, -1.0, 1.0); float whVal = clamp(wh - dr * 0.18, -1.0, 1.0); - // Evaluate the tone curve at pixel luma t so every pixel gets a spatially uniform - // global move across the entire frame without any halo or aura ring! - float o = toneCurve(t, blVal, shVal, hlVal, whVal); - float target = clamp(o, 0.0, 1.0); + // BASE' + DETAIL: the ramp is read at the neighbourhood's luma and the pixel's + // own difference from it is ADDED to the result, never scaled by it — the move + // belongs to the neighbourhood (which is what stops a knob turning one region + // of the frame into a different exposure from the one next to it) and the + // detail belongs to the pixel (which is what keeps a twig a twig). Read at the + // pixel, the move is read off the pixel itself and a hole in the sky is only in the + // statistics, not in the picture; read at the base, the same pixels take the + // same move and the 1 px lines inside the region keep their own contrast. + // + // The base lands ON the pixel when the caller has no neighbourhood to speak of + // — a gradient mask hands in the pixel's own luma (see gradientMask.ts) — and there + // two readings are the same instruction, so the degenerate call is the global + // move this used to do and not a second opinion about it. + float o = toneCurve(base, blVal, shVal, hlVal, whVal); + float target = clamp(o + (t - base), 0.0, 1.0); vec3 res = lightMove(c, t, target); @@ -395,11 +475,93 @@ vec3 exposureMove(vec3 rgb, float ev) { } `; +// THE BASE LAYER of the tone ramp, ONE AXIS PER PASS. The caller runs it twice +// (horizontal, then vertical, both on surfaces the size of the frame) and hands +// the result to TONE_SKSL as its `base` child; it returns the neighbourhood's +// LUMA, so the second pass and the ramp read it without re-weighting anything. +// +// Two properties, and the pass is the two of them: +// +// EDGE-AWARE. The spatial falloff is a gaussian of TONE_BASE_SIGMA out to +// TONE_BASE_RADIUS (see those), and each tap is weighted again by how far its +// luma is from the centre's, in stops — exp(-ev²) with TONE_BASE_RANGE_EV the +// sigma — so a tap across an edge of more than ~0.6 EV is worth nothing and the +// base is the level of THIS side of the edge. That is what a gaussian base +// cannot do and the reason the ramp had to fall back to a global curve: at a +// hard edge a gaussian hands the dark pixels a share of the bright side's move +// and the halo (halo_highlight_shadow_white_black.md) follows the knob. +// +// THE CENTRE IS A MEDIAN. The range weight is read against med3 along the +// pass's own axis — the centre and its two neighbours a step out — and not +// against the centre pixel. A range-weighted average PRESERVES a 1 px line (at +// the line, every tap but the line's own is cut, so the line IS the base and +// the ramp then compresses it against the sky like any global curve: the local +// contrast this whole pass exists to keep). A median is blind to a line one or +// two pixels wide, so at a twig the reference is the sky, the twig takes the +// sky's move, and its own difference from the base rides through the ramp +// untouched (Base' + Detail, TONE_MATH_SKSL). The two passes together are the +// reference filter's median_hv + guided filter (lightcraft local.rs, #632). +// +// The luma weights are the ramp's own, and the difference is taken as +// (tap - reference) / (reference + TONE_BASE_PEDESTAL): a ratio, so the range +// sigma is the same size of edge on a dark frame as on a bright one. +export const TONE_BASE_SKSL = ` +uniform shader src; +// One tap step along this pass's axis, in px OF THIS PASS'S SURFACE — the half +// of the frame toneBaseSurface names: whole, so every tap lands on a pixel (see +// toneBaseStep). Read out at the frame's own size it is TONE_BASE_RADIUS wide. +uniform float2 dir; +const float3 TONE_BASE_LUM = vec3(0.2126, 0.7152, 0.0722); +// The spatial falloff, in tap indices: a gaussian of TONE_BASE_SIGMA of the +// radius, TAPS taps out. +const float TONE_BASE_SPACE_SIGMA = ${(TONE_BASE_TAPS * TONE_BASE_SIGMA).toFixed(3)}; +// ln(2) * TONE_BASE_RANGE_EV: (tap - reference) / (reference + pedestal) over +// this IS the difference in stops, which is what the range sigma counts. +const float TONE_BASE_RANGE = ${(Math.LN2 * TONE_BASE_RANGE_EV).toFixed(6)}; +const float TONE_BASE_PED = ${TONE_BASE_PEDESTAL.toFixed(4)}; +float toneBaseLum(vec4 c) { + return dot(clamp(c.rgb, 0.0, 1.0), TONE_BASE_LUM); +} +// The median of three: max(min(a, b), min(max(a, b), c)). +float toneBaseMed3(float a, float b, float c) { + return max(min(a, b), min(max(a, b), c)); +} +vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + float l0 = toneBaseLum(c); + float ref = toneBaseMed3(toneBaseLum(src.eval(xy - dir)), l0, toneBaseLum(src.eval(xy + dir))); + // The centre contributes its MEDIAN, so a line the median is blind to cannot + // pull the base with it. + float sum = ref; + float total = 1.0; + for (int i = 1; i <= ${TONE_BASE_TAPS}; i++) { + float fi = float(i); + float g = exp(-0.5 * (fi / TONE_BASE_SPACE_SIGMA) * (fi / TONE_BASE_SPACE_SIGMA)); + float a1 = toneBaseLum(src.eval(xy + dir * fi)); + float a2 = toneBaseLum(src.eval(xy - dir * fi)); + float d1 = (a1 - ref) / (ref + TONE_BASE_PED) / TONE_BASE_RANGE; + float d2 = (a2 - ref) / (ref + TONE_BASE_PED) / TONE_BASE_RANGE; + float r1 = exp(-d1 * d1); + float r2 = exp(-d2 * d2); + sum += g * (r1 * a1 + r2 * a2); + total += g * (r1 + r2); + } + float base = sum / total; + return vec4(base, base, base, c.a); +} +`; + export const TONE_SKSL = ` uniform shader src; -// The BASE layer's child: a blur of the image above, of TONE_BASE_RADIUS, made -// by the caller. Read for its luma alone, and read ONCE per pixel — the -// neighbourhood is the blur's, not a sampling loop's (see TONE_BASE_RADIUS). +// The BASE layer's child: the image above run through TONE_BASE_SKSL, of +// TONE_BASE_RADIUS, made by the caller. Read for its luma alone, and read ONCE +// per pixel — the neighbourhood is the base pass's, not a sampling loop's (see +// TONE_BASE_RADIUS). The caller builds it at TONE_BASE_SCALE and hands it in +// with the local matrix that puts it back in the frame's coordinates, so the +// reads below are the frame's own — and with a NEAREST sampler: that matrix is a +// whole 2x for TONE_BASE_SCALE, and a linear read of a step edge at 2x leaves a +// quarter of the far side in the pixel beside it, which is the halo the base +// exists to not have (see baseUpscale in exportEngine.ts). uniform shader base; uniform float dr; uniform float hl; @@ -468,17 +630,17 @@ float bandW(float hue, float anchor, float gapL, float gapR) { } ${TONE_MATH_SKSL} // The BASE layer: the light the frame carries where this pixel sits, at the -// radius the caller handed in. One tap of a real blur of the same child the -// pixel comes from, so it is an AVERAGE of the neighbourhood and not a handful -// of point samples of it — that distinction is the whole bug (see -// TONE_BASE_RADIUS). Luma, because the luma is the one quantity the ramp moves -// and the colour rides the ratio afterwards; the blur being linear, blurring the -// child and taking its luma is the same as blurring the luma. +// radius the caller handed in — one tap of an edge-aware pass over the same +// child the pixel comes from (TONE_BASE_SKSL), so it is the LEVEL of this +// pixel's own side of an edge and not a handful of point samples of it. That +// distinction is the whole bug (see TONE_BASE_RADIUS). The pass already returns +// the luma in all three channels; the clamp is here for a shader that cannot be +// built, where the caller hands in the child itself. // // A caller with no neighbourhood to speak of hands in the child itself as the -// base (see blurredBase()): the tap then lands exactly on t and the pass falls -// back to the global move, which is what a shape with a mask's degenerate base -// wants and what it got from a bx of zero before. +// base (see toneBase()): the tap then lands exactly on t and the pass falls back +// to the global move, which is what a shape with a mask's degenerate base wants +// and what it got from a bx of zero before. float baseLuma(vec2 xy) { vec3 s = clamp(base.eval(xy).rgb, 0.0, 1.0); return clamp(dot(s, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index c9effa9..63f480a 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -21,12 +21,13 @@ // alone keeps the print size metadata. // ponytail: EXIF says "W4 if the web export needs the camera/lens tags". -import { Skia } from './skiaShim'; +import { Skia, CK } from './skiaShim'; import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../shared/types'; import { ASPECT_RATIO_W_H } from '../../shared/types'; import { getSkiaColorMatrix, exposureStops } from '../../shared/utils/colorUtils'; import { TONE_SKSL, + TONE_BASE_SKSL, EXPOSURE_SKSL, GLOW_SKSL, SHARPEN_SKSL, @@ -37,8 +38,8 @@ import { DEHAZE_SKSL, dehazeUniformArray, getToneUniforms, - TONE_BASE_RADIUS, - TONE_BASE_SIGMA, + toneBaseSurface, + TONE_BASE_SCALE, toneIsActive, toneUniformArray, glowUniformArray, @@ -186,6 +187,7 @@ let glowEffect: any = null; let halationEffect: any = null; let curveEffect: any = null; let clarityBlurEffect: any = null; +let toneBaseEffect: any = null; let clarityBlendEffect: any = null; let nrEffect: any = null; let dehazeEffect: any = null; @@ -201,6 +203,7 @@ function effects() { if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL); if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL); if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL); + if (!toneBaseEffect) toneBaseEffect = Skia.RuntimeEffect.Make(TONE_BASE_SKSL); if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL); if (!nrEffect) nrEffect = Skia.RuntimeEffect.Make(NR_SKSL); if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL); @@ -215,6 +218,7 @@ function effects() { halationEffect, curveEffect, clarityBlurEffect, + toneBaseEffect, clarityBlendEffect, nrEffect, dehazeEffect, @@ -331,40 +335,73 @@ function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: num disposeAll([paint, filter, snap]); } -// The BASE layer of the tone ramp: the frame blurred out to TONE_BASE_RADIUS, as -// an image the tone shader takes as its second child and reads once per pixel. -// Blurred with Skia's own MakeBlur — the same draw drawBlurred makes for the -// sharpening pass — because what the ramp needs is an AVERAGE of the -// neighbourhood and a ring of point samples is not one: sampled on a fine ring -// the luma aliases, the gain o(base)/base inherits the alias, and the -// reconstruction paints it back as mottle (a 1160x774 frame at SHADOW +100 came -// out with 0.063 of high-frequency gain against 0.005 for this blur — see -// TONE_BASE_RADIUS in toneShader.ts). `shaderOf` is left owned by the caller: the -// tone pass reads the same shader as its first child. -function blurredBase( - shaderOf: () => any, +// The BASE layer of the tone ramp: the frame's own neighbourhood level out to +// TONE_BASE_RADIUS, as an image the tone shader takes as its second child and +// reads once per pixel. NOT a blur — a blur is an average of whatever is in the +// neighbourhood, so at the frame's strong edges it lands across them and the +// pixels beside a bright sky take the shadow side's move (the halo of +// docker/halo_highlight_shadow_white_black.md). TONE_BASE_SKSL is the edge-aware +// pass instead: one bilateral 1xN per axis, over a 1 px line-blind med3 of the +// same axis, so the base is the level of the pixel's OWN side of an edge and a +// twig finer than a couple of pixels is detail rather than tone (see +// TONE_BASE_RADIUS in toneShader.ts), on a surface TONE_BASE_SCALE of the frame +// so the two passes cost a quarter of what they do at full size and the base +// carries exactly the detail a level that wide can. Two axis passes rather than +// one NxN kernel is the same 2n+1 reads CLARITY_BLUR_SKSL buys its reference +// with. +// +// Null only if the effect or a surface will not come up, in which case the caller +// hands the tone shader the sharp child as its base: the base then lands on the +// pixel and the ramp falls back to the global move. `shaderOf` is left owned by +// the caller — the tone pass reads the same shader as its first child. +function toneBaseAxis( + effect: any, + child: any, w: number, h: number, - sigma: number + dx: number, + dy: number ): any | null { const surf = createSurface(w, h); if (!surf) return null; try { - const shader = shaderOf(); + const shader = effect.makeShaderWithChildren([dx, dy], [child]); if (!shader) return null; - const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null); const paint = Skia.Paint(); - paint.setImageFilter(filter); paint.setShader(shader); surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint); flush(surf); - disposeAll([paint, filter]); + disposeAll([paint, shader]); return surf.makeImageSnapshot() ?? null; } finally { surf.dispose(); } } +// The base the ramp reads, at TONE_BASE_SCALE of the frame: two axis passes over +// toneBaseSurface's own step — the horizontal one reading the frame's full-size +// child through baseDownscale, the vertical one reading what it left at 1:1. A +// 1:1 draw is what keeps `dir` in the pass's OWN pixels (TONE_BASE_SKSL): the +// scale lives in the child's local matrix, not in the canvas, so the tap offsets +// are the half's own and the second pass needs no coordinate of its own. +function toneBase(shaderOf: (lm?: any) => any, w: number, h: number): any | null { + const { toneBaseEffect: effect } = effects(); + if (!effect) return null; + const { width: bw, height: bh, step } = toneBaseSurface(w, h); + const frame = shaderOf(baseDownscale()); + if (!frame) return null; + const horizontal = toneBaseAxis(effect, frame, bw, bh, step, 0); + if (!horizontal) return null; + const child = imageShaderChild(horizontal); + try { + if (!child) return null; + return toneBaseAxis(effect, child, bw, bh, 0, step); + } finally { + disposeAll([child]); + horizontal.dispose(); + } +} + // A blurred copy of what the surface holds right now, as an image a pass can // take as a child. Same `MakeBlur` as the ramp's base and the negative blurs, // but built from a SNAPSHOT rather than from a shader the caller still holds — @@ -666,10 +703,37 @@ function dehazeDarkChannel( // An image as a child shader — what every effect here wants instead of a paint. // The caller owns the result (the renderer's `own`). -function imageShaderChild(image: any): any { - return image.makeShaderOptions( - Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None - ); +function imageShaderChild(image: any, localMatrix?: any, fm = Skia.FilterMode.Linear): any { + const tx = Skia.TileMode.Clamp; + return localMatrix + ? image.makeShaderOptions(tx, tx, fm, Skia.MipmapMode.None, localMatrix) + : image.makeShaderOptions(tx, tx, fm, Skia.MipmapMode.None); +} + +// The base is built on a surface TONE_BASE_SCALE of the frame, so the frame's own +// child is handed to it with the matrix that puts the frame's coordinates on that +// surface (the local matrix runs the child's reads through its own space, i.e. +// twice where the pixel is). Skia's sampler then does the box downscale for free, +// to the same bytes as the equivalent 2:1 drawImageRectOptions — measured in the +// head of tone-local-contrast-check.mjs. `CK` rather than the shim's `Skia`: the +// RN API this file is a port of has no local matrix, so the shim has none. +function baseDownscale(): any { + return CK.Matrix.scaled(TONE_BASE_SCALE, TONE_BASE_SCALE); +} + +// ...and the base comes back the same way: the tone pass reads it at the frame's +// own size through the inverse matrix, and the sampler does the 2x upscale — +// NEAREST, not linear. The half the pass leaves is one texel per two frame +// pixels, so a linear 2x read weights the texel across a step edge by a quarter +// on the pixel beside it, and that quarter of the far side's level is exactly the +// halo the edge-aware base exists to avoid (2 px at -20.9/255 on the step-edge +// frame, measured in tone-local-contrast-check.mjs). Nearest replicates the texel +// instead and the step lands on the edge; the base is a smoothed signal, so the +// 2x2 blocks it costs are the levels the bilateral already had, and every pixel +// still reads toneCurve at its own base. The same sampler calibrated: nearest at +// 2x is floor(X / 2) exactly, 0/256 mismatches on a 16x16 field. +function baseUpscale(): any { + return CK.Matrix.scaled(1 / TONE_BASE_SCALE, 1 / TONE_BASE_SCALE); } // The same child, read in the frame's coordinates rather than its own: a pass @@ -914,11 +978,14 @@ export async function renderPhoto(input: RenderInput): Promise - own( - gradeImage.makeShaderOptions( - Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None - ) - ); + const imageShaderOf = (lm?: any) => own(imageShaderChild(gradeImage, lm)); // Exposure takes the place of the colour filter on the draw, so it too needs // the matrix to be IN its child — hence expOn in `graded` above. Everything // downstream that samples "the image" now samples after the exposure. let exposeShader: any = null; + let exposeEffect: any = null; if (expOn) { const { exposureEffect: effect } = effects(); + exposeEffect = effect; if (effect) exposeShader = own(effect.makeShaderWithChildren([evStops], [imageShaderOf()])); } - const baseShaderOf = () => exposeShader ?? imageShaderOf(); + // The frame's own child, at the coordinate space the caller asks for: the + // base pass reads it at TONE_BASE_SCALE of the frame (see toneBase), every + // other caller reads the frame itself. The exposure, if there is one, has to + // be rebuilt over the scaled child — it is a per-pixel map, so its output is + // the same either way, but the pixels it reads are not. + const baseShaderOf = (lm?: any): any => { + if (!lm) return exposeShader ?? imageShaderOf(); + if (!exposeShader || !exposeEffect) return imageShaderOf(lm); + return own(exposeEffect.makeShaderWithChildren([evStops], [imageShaderOf(lm)])); + }; if (toneIsActive(tone)) { const { toneEffect: effect } = effects(); if (effect) { // The base is built HERE and not earlier: it draws the same shader the // tone pass reads, and both have to see the pixels after the exposure. - const sigma = width * TONE_BASE_RADIUS * TONE_BASE_SIGMA; - const base = own(blurredBase(baseShaderOf, width, height, sigma)); + // It comes back TONE_BASE_SCALE of the frame, which is why the tone pass + // takes it with baseUpscale()'s local matrix rather than as it stands — + // and reads it NEAREST, so the upscale replicates the half's texels + // instead of weighting the far side of an edge into the pixel beside it. + const base = own(toneBase(baseShaderOf, width, height)); toneShader = own( effect.makeShaderWithChildren(toneUniformArray(tone), [ baseShaderOf(), - base ? own(imageShaderChild(base)) : baseShaderOf(), + base ? own(imageShaderChild(base, baseUpscale(), Skia.FilterMode.Nearest)) : baseShaderOf(), ]) ); }