web: open a RAW at the resolution of its sensor, not at the quarter of it

LibRaw's half-size demosaic was on. The Ricoh GR's own DNG (D0004128.DNG)
developed to 3010x2012 while the JPEG written beside it in the same second is
6000x4000, and the Fuji's RAF to 3008x2007 against its own 6000x4000 -- the
quarter was the flag, not the file. With `halfSize: false` the same develop
returns 6020x4024 and it is the sensor's frame on every body tried:

  D0004128.DNG  6020x4024   IMGP6916.DNG        6028x4024
  DSCF1701.RAF  6016x4014   _DSC0009.ARW        6024x4024
  AFXT2721.RAF  6246x4170   Nikon-D850 NEF      6216x4136
  _GDN0447.NEF  4284x2844   P1010607.RW2        3472x3472
  5G4A9396.CR2  2880x1920

Nine files, 27s to 155s a develop on one core. Checked through the app
itself, not only through LibRaw: photo-dims 6020x4024 on the DNG against
6000x4000 on the JPEG, both err none.

The colour it opens with is now fitted per file to the preview the camera wrote
into it (previewMatch.ts): a 3x3 over a block grid of the develop against the
same grid of that preview, then one cubic a channel for what the 3x3 leaves.
The offline per-body table this replaces (cameraMatch.ts) stopped matching the
moment the path under it changed -- its rows no longer summed to 1 once the
highlight knee landed ahead of it -- and a body with a row opened with a cast
one without did not. The file's own preview does not age.

The white level the gain carries is the frame's own plateau rather than
`maximum` (sensorWhite.ts), a factor of 1.89 to 2.00 out; without it every
frame opened a stop bright and a body that sat lower (X-Trans, 1.892) never
reached the highlight desaturation at all.

The desaturation gate reads the gain-lifted levels as well as the sensor's,
which is the whole of the magenta: on a body whose cam_mul lifts red and blue
(the GR's [2.64, 1, 1.73]) a blown sky crosses the white level at 0.38 of the
raw range in red while green crosses at 1.0, so a gate read on the sensor's
levels alone stayed shut across it. Measured in the app against the camera's
own JPEG, mean dRGB over a 16x16 block grid: +1.20, -5.95, -6.11 with the
sensor's clip alone, +0.21, +0.24, +0.47 with both, mean |dL| 21.5 against
10.3. The same grid on the Fuji comes back balanced (+4.7, +5.0, +3.6) and best
aligned at offset 0,0.

-HL is recovery and +HL is a lift, so they are different moves now: recovery is
the doc's soft knee in linear light over the top half, which is the only term
in the tone shader that is not a shift and the only one that can put detail
back into a blown sky rather than merely darken it.

The four checks pin the develop down where it can only run in a browser:
raw-develop-check, preview-match-check, white-level-check, highlight-knee-check.
This commit is contained in:
2026-09-28 15:24:37 +07:00
parent b824308182
commit 224ff0b935
9 changed files with 940 additions and 163 deletions
@@ -0,0 +1,122 @@
// LibRaw subtracts the black level itself, whatever `noAutoScale` says, so the
// develop must not do it again: a second subtraction drained red and blue — the
// channels `cam_mul` lifts most — and turned every Sony ARW green. This pins that
// down on the source, since the develop itself only runs in a browser (LibRaw
// worker + CanvasKit).
//
// node scripts/raw-develop-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const src = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
const sksl = src.match(/const RAW_DEVELOP_SKSL = `([\s\S]*?)`;/)?.[1];
assert.ok(sksl, 'RAW_DEVELOP_SKSL not found');
// The prose above the code talks about the black level, so drop the comments.
const code = sksl.replace(/\/\/[^\n]*/g, '');
// The plane comes in already black-subtracted, so its only scale is the white
// level less the black level, handed over as one inverse.
assert.match(code, /uniform float gain;/);
assert.doesNotMatch(code, /uniform float4 black;/);
assert.doesNotMatch(code, /-\s*black/, 'the shader subtracts the black level again');
assert.match(code, /float3 n = max\(p\.rgb \* gain, 0\.0\);/);
assert.match(code, /float3 lin = n \* mul\.rgb;/);
// The WB gains, applied where the sensor has already clipped, are what leaves the
// blown areas magenta, so the shader has to read the sensor's own levels — before
// the gains — and desaturate the pixel towards its own value as the clip is
// approached. Measured on the FX30 ARW: the camera's own JPEG has an all-white top
// percentile (R/G 0.995, B/G 0.999), the develop without this line came back at
// R/G 1.017, B/G 0.862 — a warm tint on every blown area.
//
// The clip to read is the one the gains make as well as the sensor's own: on a body
// whose gains lift red and blue (the Ricoh GR: `cam_mul` [2.64, 1, 1.73]) a blown
// sky crosses the white level at 0.38 of the raw range in red and 0.58 in blue while
// green only crosses at 1.0, so a gate read on the sensor's levels alone stayed shut
// across the whole sky. Measured on that frame in the app against the camera's own
// JPEG: mean dRGB +1.2, -5.9, -6.1 with the sensor's clip alone, +0.8, +1.0, +1.1
// with both, mean |dL| 21.5 against 11.1.
assert.match(
code,
/float hi = max\(max\(n\.r, n\.g\), n\.b\);\s*hi = max\(hi, max\(max\(lin\.r, lin\.g\), lin\.b\)\);\s*rgb = mix\(rgb, float3\(mx\), smoothstep\(0\.95, 1\.0, hi\)\);/
);
// The inverse is built on the CPU side and has to be the first uniform of the
// buffer the shader reads as `gain`, and its divisor has to be the white level
// the frame itself ran out at — `maximum` alone left every frame a stop bright,
// and a fixed factor two only fitted the two bodies it was measured on. The
// probe and its own check live in src/engine/sensorWhite.ts.
assert.match(src, /^ *uniforms\[0\] = SAMPLE_MAX \/ sensorWhite\(data, cd\.maximum, cd\.black\);$/m);
assert.match(src, /^import \{ sensorWhite \} from '\.\/sensorWhite';$/m);
// On the FX30 that inverse is 2.065 (65535 / (2 (16380 - 512))), against the
// 4.13 the white level alone gives. The camera's own JPEG lands on the 2.065:
// 0.05% of its pixels at pure white against the develop's 0.76% at 4.13 — which
// is also what the probe has to fall back to on a frame with nothing blown.
assert.ok(65535 / (2 * (16380 - 512)) - 2.065 < 0.001, 'the white level factor dropped out');
const probe = readFileSync(new URL('../src/engine/sensorWhite.ts', import.meta.url), 'utf8');
assert.match(
probe,
/if \(top < maximum - black\) return legacy;/,
'a frame with nothing blown no longer falls back to the fixed factor'
);
// What a RAW opens as is the develop of its own sensor data, fitted to the
// preview the camera wrote into the file — so the file opens at the colour the
// body chose and at the resolution its sensor has. The preview is the reference
// and the fallback, never the frame: a file with no preview of its own is not
// fitted, and there is no body table behind the fit any more (a table fitted on
// one develop stops matching when the develop changes under it, and it did: the
// highlight knee left every body that had one with a cast).
assert.match(src, /preview = await cameraPreview\(raw\);/);
assert.match(src, /const match = blocks && ref \? fitMatch\(blocks, ref\) : null;/);
assert.doesNotMatch(src, /cameraMatch/, 'the body table is back');
assert.doesNotMatch(code, /uniform float4 w0;/, 'the body table is back in the shader');
// The fit is drawn by the develop that was fitted, so the frame goes through it
// twice: once on the sensor alone, to fit against the preview, and then again
// with the fit in the shader.
assert.match(src, /const first = develop\(null\);/);
assert.match(src, /const matched = match \? develop\(match\) : null;/);
assert.match(src, /const jpeg = \(matched \?\? first\)\.encodeToBytes/);
assert.match(src, /if \(preview\) return preview;/);
assert.match(src, /if \(thumb\?\.format !== 'jpeg' \|\| !thumb\.data\?\.length\) return null;/);
// The fit has to land on the encoded value in float, not as an 8-bit colour filter
// painted over the frame afterwards: the fit carries an exposure, and a channel it
// lifted past the white level was cut where it stood — blue first, the channel the
// WB gains lift most — which is what left the bright end of the frame short of
// blue. Measured on the A5100 frame, pixels at 255: 5.25% blue, 2.10% of them blue
// alone through the filter, against 2.46% / 2.44% through this path.
//
// No rolloff after the fit. The fit is luma-preserving, not white-preserving —
// its rows sum to ~0.79 / 1.07 / 0.92, so white leaves it past 1.0 in green only —
// and dividing by that max pulled red and blue down with it: highlights came out
// cyan and not one cell of the frame reached white on all three channels (0.0%)
// against 2.3% now and 3.3% in the camera's preview, dE00 7.1 against 6.6.
assert.match(code, /uniform float4 f0;/);
assert.doesNotMatch(src, /drawMatched|colorMatrix/, 'the fit is painted through a colour filter again');
assert.match(code, /float3 q = clamp\(float3\(dot\(f0\.xyz, e\), dot\(f1\.xyz, e\), dot\(f2\.xyz, e\)\), 0\.0, 1\.0\);/);
assert.doesNotMatch(code, /float mq = /, 'the rolloff after the fit is back');
// A 3x3 can only scale a channel and the gap to the camera is mostly a shape, so
// the frame leaves through the per-channel curve the fit carries as well. Without
// it the shadows stayed bright and green: measured on the A5100 frame, dE00 6.6
// against 4.4, and at the bottom of the lightness range dL +13.8 with green
// +0.128 against +4.5 and +0.017 through the curve (both against the camera's own
// JPEG).
assert.match(code, /float tone\(float4 w, float x\) \{/);
assert.match(code, /return half4\(half3\(tone\(t0, q\.r\), tone\(t1, q\.g\), tone\(t2, q\.b\)\), 1\.0\);/);
assert.match(code, /uniform float4 t0;/);
// ...and it is the tail of the uniform buffer, one float4 per channel, which the
// second develop overwrites on the buffer it already built.
assert.match(src, /^ *uniforms\.set\(fit\?\.tone \?\? FLAT_TONE, 33\);$/m);
assert.match(src, /const uniforms = new Float32Array\(45\);/);
assert.match(src, /^ *uniforms\[21\] = f\[0\]; uniforms\[22\] = f\[1\]; uniforms\[23\] = f\[2\]; uniforms\[24\] = 0;$/m);
// A RAW opens at the sensor's own resolution, not at the quarter the half-size
// demosaic reports: the GR's DNG came back 3010x2012 against the 6000x4000 of the
// camera's own JPEG beside it. With the flag off the same develop returns
// 6020x4024, and every one of the eight bodies checked doubled its frame.
assert.match(src, /^ *halfSize: false,$/m, 'the develop is half-size again');
console.log('raw-develop-check ok');