Files
RecipesCam/docker/frontend/scripts/raw-develop-check.mjs
T
3dtours 97fd181cfa feat: the camera's own colour, a reading that keeps its place, and cloud links in Settings
- RAW opens at the colour the camera wrote into it: the develop carries the
  difference against the file's own preview (previewResidual) instead of the
  per-channel cubic fit, dE00 6.04 -> 0.54; previewMatch.ts keeps only the grid.
- Immich reading: keyset position kept across visits, one reading per node,
  a page in flight retried, timeline buckets for the years on offer.
- The wall draws a server frame at the address the proxy serves it (no blob
  minted for it), and the 80-thumbnail pre-cache on open is gone.
- Settings lists the cloud links, flags two that answer at the same address,
  and forgets one.
- Custom frames imported from a file (engine/customFrame.ts, IndexedDB).
- Upload an edited frame from Studio to Immich Cloud.
2026-10-12 07:59:24 +07:00

271 lines
14 KiB
JavaScript

// 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;/);
// What a RAW opens as is the colour the camera wrote into it. Not fitted: the
// develop carries the difference between the file's own preview and this develop's
// rendering of the same frame, at the preview's resolution, and adds it back on
// the value it left. So there is no curve to come back folded, no body table, and
// nothing that can disagree with the file it is opening.
//
// Measured against the file's own preview: dE00 6.04 for the per-channel cubic
// this replaced, 0.54 for the residual, and 0.00 on the two bodies (a Nikon NEF
// and a DNG) that write a full-size preview — where the two frames agree block for
// block and the residual is nothing.
const res = src.match(/const RESIDUAL_SKSL = `([\s\S]*?)`;/)?.[1];
assert.ok(res, 'RESIDUAL_SKSL not found');
const resCode = res.replace(/\/\/[^\n]*/g, '');
assert.match(
resCode,
/return half4\(half3\(clamp\(ref\.eval\(p\)\.rgb - dev\.eval\(p\)\.rgb \+ 0\.5, 0\.0, 1\.0\)\), 1\.0\);/
);
assert.match(code, /uniform shader res;/);
assert.match(code, /uniform float2 rscl;/);
// The 0.5 that keeps a negative difference inside a byte, taken back off on the
// way out, and read at the frame's own coordinates — `pos` is local to this band.
assert.match(code, /float2 rp = pos \* rscl;/);
assert.match(code, /o = clamp\(o \+ \(res\.eval\(rp\)\.rgb - 0\.5\), 0\.0, 1\.0\);/);
// The residual is read at the develop's own resolution, so it is filtered up
// rather than tapped: one byte a channel, which is finer than the shading it
// carries, is the whole difference between two renderings.
assert.match(src, /const resChild = \(residual\?\.img \?\? noResidual\(\)\)\.makeShaderOptions\(/);
assert.match(src, /const shader = effect\.makeShaderWithChildren\(uniforms, \[child, resChild\]\);/);
// A file with nothing to carry opens on a residual that says nothing — 0.5 in
// half — rather than down a branch of its own in the shader.
assert.match(src, /NO_RESIDUAL = Skia\.Image\.MakeImage\(/);
assert.match(src, /new Uint16Array\(\[0x3800, 0x3800, 0x3800, 0x3c00\]\)/);
// The develop runs twice, not once: the residual is read off the first frame and
// carried by the second.
assert.match(src, /^ *const second = develop\(residual\);$/m);
// The per-channel curve this replaced is gone, and so is the buffer slot it rode
// in: 33 floats became 23, the last two of them the residual's own scale.
assert.doesNotMatch(code, /float tone\(float4 w, float x\) \{/, 'the per-channel curve is back');
assert.doesNotMatch(code, /uniform float4 t0;/, 'the per-channel curve is back in the shader');
assert.doesNotMatch(src, /FLAT_TONE/, 'the flat tone is back');
assert.doesNotMatch(src, /toneMatch/, 'the tone fit is back');
assert.match(src, /const uniforms = new Float32Array\(23\);/);
assert.match(src, /^ *uniforms\[21\] = residual \? residual\.sx : 0;$/m);
assert.match(src, /^import \{ 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<number>|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');