// 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*float blown = smoothstep\(0\.99, 1\.02, 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, /^ *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 tone = blocks && ref \? toneMatch\(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'); // Not a 3x3 any more. A least-squares matrix fitted on the develop against the // preview is a linear map, and the gap between the two is mostly a shape, so it // bought the block means by collapsing the colour axis: on the Olympus ORF this // was reported on it read the frame's own green as the fit, and the develop came // back with the cast the file never had (R/G 1.021 B/G 0.920 against the file's // own preview's 1.013 / 0.841). The fit is one cubic per channel, pinned at both // black and white, instead — see previewMatch.ts. 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'); // 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 = tone \? develop\(tone\) : 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. // // 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 body = src.match(/const XYZ_TO_SRGB = \[[\s\S]*?\n\}\n/)?.[0]; assert.ok(body, 'getCamToSrgbMatrix not found'); // The file is TypeScript; the two annotations in this block are all that stop // `new Function` from reading it. const js = body.replace(/: any/g, '').replace(/: number\[\]\[\]/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`); } } } console.log('raw-develop-check ok');