97fd181cfa
- 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.
271 lines
14 KiB
JavaScript
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');
|