// 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. // The gate opens below the white level, not at it: 0.82 against 0.98 was measured // after the pairs above, so the tint reads on the way up rather than only once the // frame has already run out of range. 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*float blown = smoothstep\(0\.82, 0\.98, hi\);\s*rgb = mix\(rgb, float3\(mx\), blown\);/ ); // 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, /^ *const white = sensorWhite\(data, cd\.maximum, cd\.black\);$/m); assert.match(src, /^ *uniforms\[0\] = SAMPLE_MAX \/ white;$/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, /extractEmbeddedJpeg\(bytes\);/); assert.doesNotMatch(src, /cameraMatch/, 'the body table is back'); assert.doesNotMatch(code, /uniform float4 w0;/, 'the body table is back in the shader'); assert.doesNotMatch(src, /fitMatch/, 'the 3x3 preview match is back'); assert.doesNotMatch(code, /uniform float4 f0;/, 'the 3x3 preview match is back in the shader'); assert.match(src, /const first = develop\(null\);/); assert.match(src, /const jpeg = finalShot\.encodeToBytes\(Skia\.ImageFormat\.JPEG, 92\);/); assert.match(src, /preview && preview\.length > 10000/); // The balance the frame opens with is read off the file's own preview — the same // reference the tone curve is fitted to — and only falls back to LibRaw's tags when // the preview has nothing neutral and bright left to read. The tags alone were not // the balance: on the Fujifilm RAF this was reported on, `pre_mul` was short of blue // twice over (R/G 2.296 against the frame's 1.795, B/G 1.307 against 1.626) and left // every white pink, `cam_mul` came closer, and the pair read off the preview matched // it: block a* -1.9 against the preview's -2.1 where the tags gave 0.0 and -1.0, // dE00 4.93 where they gave 6.73 and 5.27. And it holds across bodies for the same // reason — it is read on the frame, not on the tags. assert.ok( src.indexOf('return cd.cam_mul;') < src.indexOf('return cd.pre_mul;'), 'pre_mul is preferred over the frame\'s own balance again' ); assert.match(src, /^ *const refGrid = preview \? previewGrid\(preview, w, h, MATCH_GRID\) : null;$/m); assert.match( src, /const rawMul = \(refGrid && previewWhiteBalance\(data, w, h, refGrid, MATCH_GRID, white\)\)\s*\?\? getBrandWbMultipliers\(brand, cd\);/ ); assert.match(src, /^export function previewWhiteBalance\($/m); // ...and a balance read off too little of the frame is no balance: a frame that // offers fewer than this many sensor pixels across its neutral bright blocks keeps // the tags rather than a ratio read off noise. assert.match(src, /if \(count < 4096 \|\| sums\[0\] <= 0 \|\| sums\[1\] <= 0 \|\| sums\[2\] <= 0\) 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. // // The curve is pinned at both ends, so white is one of the values it is fitted on // and a blown pixel still lands on white — there is no rolloff after it to pull a // channel back down (the 3x3 this replaced needed one, and it turned every // highlight cyan: 0.0% of the frame reached white on all three channels against // 2.3% now and 3.3% in the camera's preview, dE00 7.1 against 6.6). assert.doesNotMatch(src, /drawMatched|colorMatrix/, 'the fit is painted through a colour filter again'); assert.match(code, /float tone\(float4 w, float x\) \{/); assert.match(code, /return clamp\(w\.x \+ x \* \(w\.y \+ x \* \(w\.z \+ x \* w\.w\)\), 0\.0, 1\.0\);/); // Pinned at both ends is not pinned at the plateau. The three curves agree at 1.0 // and part company either side of it, and where the frame ran out there is no block // left to fit them together — every blown block is dropped, and every block the // develop blows is dropped too — so the neutral the gate above drew comes apart on // the way out: measured on the Olympus ORF, a 255,255,255 sky came back 254,255,255 // (149k px) and 253,255,254 (122k px), red under green across a quarter of the // frame. The gate is read again on the value that leaves, so the pixel the develop // itself called blown leaves at the neutral it was drawn as. assert.match(code, /float3 o = float3\(tone\(t0, e\.r\), tone\(t1, e\.g\), tone\(t2, e\.b\)\);/); assert.match( code, /return half4\(half3\(mix\(o, float3\(max\(max\(o\.r, o\.g\), o\.b\)\), blown\)\), 1\.0\);/ ); assert.match(code, /uniform float4 t0;/); assert.doesNotMatch(code, /float mq = /, 'the rolloff after the fit is back'); // ...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\(tone \?\? FLAT_TONE, 21\);$/m); assert.match(src, /const uniforms = new Float32Array\(33\);/); assert.match(src, /^import \{ toneMatch, FLAT_TONE, MATCH_GRID \} from '\.\/previewMatch';$/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'); // The matrix the develop applies is the camera->sRGB one LibRaw already hands // back, not one derived from `cam_xyz` beside it. They disagree on the Olympus // ORF this was reported on: rgb_cam x cam_mul lands on R/G 1.021 B/G 0.920 // against the file's own preview's 1.013 / 0.841, the cam_xyz chain on // 0.921 / 0.886 — green, and blue for a scene the camera left neutral, which is // the cast the frame opened with. And the matrix must not be divided by `pre_mul` // again: that undoes the row-sum-to-one normalisation dcraw built into rgb_cam. { const xyz = src.match(/^const XYZ_TO_SRGB = \[[\s\S]*?\n\];$/m)?.[0]; const fn = src.match(/^export function getCamToSrgbMatrix\([\s\S]*?\n\}\n/m)?.[0]; assert.ok(xyz && fn, 'getCamToSrgbMatrix not found'); // The file is TypeScript; the annotations in this block are all that stop // `new Function` from reading it. const js = (xyz + '\n' + fn) .replace(/^export /m, '') .replace(/: any/g, '') .replace(/: number\[\]\[\]/g, '') .replace(/\?: CameraBrand/g, ''); const getCamToSrgbMatrix = new Function(`${js}; return getCamToSrgbMatrix;`)(); const rgb_cam = [ [1.631760597229004, -0.39896339178085327, -0.23279713094234467], [-0.298429936170578, 1.7056032419204712, -0.4071732759475708], [0.02502557262778282, -0.45518237352371216, 1.4301568269729614], ]; const cam_xyz = [ [0.9422, -0.3258, -0.0711], [-0.2655, 1.0898, 0.2015], [-0.0512, 0.1354, 0.5512], ]; const pre_mul = [2.0312116146087646, 0.9462084174156189, 1.4558180570602417]; assert.deepEqual( getCamToSrgbMatrix({ rgb_cam, cam_xyz, pre_mul }), rgb_cam.map((row) => row.slice(0, 3)), 'rgb_cam is no longer applied as LibRaw handed it back' ); // A neutral camera triple stays neutral through what is left: every row of the // matrix has to sum to one, or the frame opens with a cast of its own. for (const [name, cd] of [ ['rgb_cam', { rgb_cam }], ['cam_xyz', { cam_xyz }], ['nothing', {}], ]) { const rows = getCamToSrgbMatrix(cd); for (const row of rows) { assert.ok(Math.abs(row[0] + row[1] + row[2] - 1) < 1e-6, `${name}: row sums to what white is not`); } } } // The balance read off the preview is arithmetic on the plane and the preview // map, so both can be handed to it here. A plane whose red stands 1.5x its green // and whose blue 1.2x, under a map the camera left bright and neutral, has one // balance that would equalise those blocks, and it is the one that comes back. { const fn = src.match(/^export function previewWhiteBalance\([\s\S]*?\n\}\n/m)?.[0]; assert.ok(fn, 'previewWhiteBalance not found'); const previewWhiteBalance = new Function( `${fn.replace(/^export /m, '').replace(/:\s*(ArrayLike|Uint8Array|number\[\] \| null|number)/g, '')}; return previewWhiteBalance;` )(); const N = 4; const W = 128; const H = 128; const WHITE = 10000; const plane = (r, g, b) => { const d = new Uint16Array(W * H * 3); for (let i = 0; i < W * H; i++) { d[i * 3] = r; d[i * 3 + 1] = g; d[i * 3 + 2] = b; } return d; }; const block = (g, bx, by, r, gg, b) => { const i = (by * N + bx) * 4; g[i] = r; g[i + 1] = gg; g[i + 2] = b; g[i + 3] = 255; }; const map = (f) => { const g = new Uint8Array(N * N * 4); for (let by = 0; by < N; by++) for (let bx = 0; bx < N; bx++) f(g, bx, by); return g; }; const allWhite = map((g, bx, by) => block(g, bx, by, 200, 200, 200)); const mul = previewWhiteBalance(plane(3000, 2000, 2400), W, H, allWhite, N, WHITE); assert.ok(mul, 'a frame of bright neutral blocks has to read a balance'); assert.ok( Math.abs(mul[0] - 2 / 3) < 1e-6 && mul[1] === 1 && Math.abs(mul[2] - 5 / 6) < 1e-6, `read ${mul} off a 1.5x red / 1.2x blue plane` ); // A block the camera did not leave neutral is not read, however bright it is: // it carries the scene's colour, not the frame's balance. const tinted = previewWhiteBalance( plane(3000, 2000, 2400), W, H, map((g, bx, by) => (bx ? block(g, bx, by, 200, 200, 200) : block(g, bx, by, 200, 120, 200))), N, WHITE ); assert.deepEqual(tinted, mul, 'a tinted block moved the balance'); // Too little of the frame left to read is no balance at all — the caller keeps // the tags rather than a ratio read off a handful of pixels. assert.equal(previewWhiteBalance(plane(3000, 2000, 2400), 30, 30, allWhite, N, WHITE), null); } console.log('raw-develop-check ok');