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.
This commit is contained in:
2026-10-12 07:59:24 +07:00
parent 807c271b6b
commit 97fd181cfa
25 changed files with 2402 additions and 616 deletions
+41 -32
View File
@@ -105,41 +105,50 @@ assert.match(src, /^export function previewWhiteBalance\($/m);
// 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.
// 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.
//
// 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\)\);/);
// 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(
code,
/return half4\(half3\(mix\(o, float3\(max\(max\(o\.r, o\.g\), o\.b\)\), blown\)\), 1\.0\);/
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 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);
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