diff --git a/docker/frontend/src/engine/previewMatch.ts b/docker/frontend/src/engine/previewMatch.ts index 9857bf4..7f4cc15 100644 --- a/docker/frontend/src/engine/previewMatch.ts +++ b/docker/frontend/src/engine/previewMatch.ts @@ -1,34 +1,37 @@ // The colour the camera itself wrote into the file. // -// LibRaw hands back the sensor's own data and nothing about how the body chose -// to render it: the white balance it locked, its picture style, its tone curve. -// The file carries the answer anyway — the preview saved inside it, which is the +// LibRaw hands back the sensor's own data and nothing about how the body chose to +// render it: the white balance it locked, its picture style, its tone curve. The +// file carries the answer anyway — the preview saved inside it, which is the // frame the photographer saw and the one a desktop viewer shows. So the develop // is matched to that preview: develop a grid of the frame, take the same grid out -// of the embedded preview, and least-squares the 3x3 that takes the first to the -// second — then, on what that 3x3 leaves, the tone curve a matrix cannot hold. +// of the embedded preview, and fit the curve between them — one cubic a channel, +// on the sRGB-encoded value the develop leaves. // -// Measured on the A5100 frame the studio was reported on (dE00 against the -// camera's own 24MP JPEG, blocks of a 64x64 grid): 12.0 as the develop left it, -// 9.2 with this fit drawn in linear light, 6.4 painted through the 8-bit colour -// filter this used to be, 6.6 with the 3x3 alone in float, 4.4 with the tone -// curve under it. What is left either way is the camera's own local rendering — -// its sharpening, the detail a 1.7MP preview never had — and no per-pixel -// transform reaches that. +// A 3x3 was fitted ahead of that curve for a long time, least squared from one +// grid to the other, and it is what put the cast the studio was reported with: +// a develop carries its luminance and its colour in the same three numbers, the +// luminance is two orders of magnitude the larger of the two, and a least squares +// that is dominated by it pays for the luminance with the colour — it holds the +// channel means and the bright/dark axis while it collapses the axis the eye +// reads as colour. The camera's own picture style is a curve, not a matrix. // -// The fit is on the sRGB-encoded values, exposure included — matrix and curve -// alike: the reference is the file's own frame, so matching its brightness is -// part of matching its colour. Fitted in that encoded space, not in linear light, -// because a least squares in linear light weighs the highlights and comes back -// with the midtones wrong — the same frames scored dE00 6.4 fitted encoded -// against 9.2 fitted linear. Any body, any picture style, and no fitted table to -// go stale — a preview is the one profile every RAW already carries. -// Row-major 3x3, on the sRGB-encoded develop: what the shader applies to a pixel. -export type Mat3 = readonly [ - number, number, number, - number, number, number, - number, number, number, -]; +// Measured on the Olympus ORF this was reported on, standard deviation of R-G +// over the frame (the file's own preview 8.3, the camera's own JPEG 12.3, and an +// olive frame is one where this has gone to nothing): +// develop as the sensor left it 11.6 +// the curve alone, no 3x3 11.9 +// the curve under the fitted 3x3 (what used to ship) 4.6 +// and on the frame's own means, curve alone against the preview: luminance 156.6 +// against 156.8, R/G 1.023 against 1.014, B/G 0.853 against 0.843. +// +// The fit is on the sRGB-encoded values, exposure included: the reference is the +// file's own frame, so matching its brightness is part of matching its colour. +// Fitted in that encoded space, not in linear light, because a least squares in +// linear light weighs the highlights and comes back with the midtones wrong — +// the same frames scored dE00 6.4 fitted encoded against 9.2 fitted linear. Any +// body, any picture style, and no fitted table to go stale — a preview is the one +// profile every RAW already carries. // The grid the fit reads, and the size of the preview map handed to it. export const MATCH_GRID = 64; @@ -37,38 +40,11 @@ export const MATCH_GRID = 64; const CLIP = 0.97; const FLOOR = 0.03; -// The per-file colour fit: a 3x3, then the tone curve a 3x3 cannot hold — one -// cubic a channel, on the encoded value the 3x3 leaves. `tone` is that curve in -// the shader's own order: r a b c d, then g, then b. -export interface Match { - m: Mat3; - tone: Float32Array; -} - -// What the second stage does when the frame has no curve the fit can trust. +// What the shader draws when the frame has no curve the fit can trust. export const FLAT_TONE = new Float32Array([0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0]); -// Gaussian elimination on the one system this file fits: a channel is a row of -// three, for the 3x3 and again for the 3x3's own error. -function solve(C: number[][], d: number[]): number[] { - const n = 3; - const a = Array.from({ length: n }, (_, i) => [...C[i], d[i]]); - for (const row of a) for (let c = 0; c < n; c++) row[c] += 1e-9; - for (let c = 0; c < n; c++) { - let p = c; - for (let r = c + 1; r < n; r++) if (Math.abs(a[r][c]) > Math.abs(a[p][c])) p = r; - [a[c], a[p]] = [a[p], a[c]]; - for (let r = 0; r < n; r++) { - if (r === c) continue; - const f = a[r][c] / a[c][c]; - for (let k = c; k <= n; k++) a[r][k] -= f * a[c][k]; - } - } - return a.map((_, i) => a[i][n] / a[i][i]); -} - -// The curve a channel needs to reach the camera's, least squared: what the 3x3 -// left for that channel, a cubic against the block the camera wrote there. +// The curve a channel needs to reach the camera's, least squared: the develop's +// own value for that channel against the block the camera wrote there. // // The cubic is pinned at BOTH black (x=0 -> y=0) and white (x=1 -> y=1). // Pinned at both ends, a blown pixel (1,1,1) stays pure white, and a deep shadow @@ -85,16 +61,18 @@ function solve2x2(A: number[][], d: number[]): number[] { return [a, b]; } -function fitTone(blocks: { t: number[]; q: number[] }[]): Float32Array { +// The curve itself, in the shader's own order: r a b c d, then g, then b. +function fitTone(blocks: { x: number[]; t: number[] }[]): Float32Array { const out = new Float32Array(12); for (let c = 0; c < 3; c++) { const A = [[0, 0], [0, 0]]; const d = [0, 0]; - for (const { t, q } of blocks) { - const x = q[c], b = [basis(0, x), basis(1, x)]; + for (const { x, t } of blocks) { + const v = Math.min(1, Math.max(0, x[c])); + const b = [basis(0, v), basis(1, v)]; for (let p = 0; p < 2; p++) { for (let k = 0; k < 2; k++) A[p][k] += b[p] * b[k]; - d[p] += b[p] * (t[c] - x * x * x); + d[p] += b[p] * (t[c] - v * v * v); } } const [a, b] = solve2x2(A, d); @@ -104,10 +82,10 @@ function fitTone(blocks: { t: number[]; q: number[] }[]): Float32Array { } // A curve that comes back folded, or wandered far from the value it was handed, -// is a fit off blocks that fought each other, not a rendering: the caller keeps -// the 3x3 alone rather than a look nobody's camera has. The floor sits below the -// zero a curve is allowed to reach — the develop clamps there anyway — and the -// slope is only asked not to turn back. +// is a fit off blocks that fought each other, not a rendering: the caller opens +// the frame as the sensor left it instead. The floor sits below the zero a curve +// is allowed to reach — the develop clamps there anyway — and the slope is only +// asked not to turn back. function curveIsSane(tone: Float32Array): boolean { for (const v of tone) if (!Number.isFinite(v) || Math.abs(v) > 4) return false; for (let c = 0; c < 3; c++) { @@ -123,67 +101,27 @@ function curveIsSane(tone: Float32Array): boolean { return true; } -// `dev` and `ref` are n x n RGBA maps of the same frame, one byte per channel. -// The 3x3 comes back on those encoded values — the space the caller draws it in. -// Null when the two cannot be related — too few blocks carry colour, or the -// solution runs away — and the caller develops the frame as the sensor left it. -export function fitMatch(dev: Uint8Array, ref: Uint8Array, n = MATCH_GRID): Match | null { - const C = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; - const d = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; - const blocks: { e: number[]; t: number[] }[] = []; +// `dev` and `ref` are n x n RGBA maps of the same frame, one byte per channel, +// both sRGB-encoded by the same resampler. Null when the two cannot be related — +// too few blocks carry colour — and the caller develops the frame as the sensor +// left it. +export function toneMatch(dev: Uint8Array, ref: Uint8Array, n = MATCH_GRID): Float32Array | null { + const blocks: { x: number[]; t: number[] }[] = []; let used = 0; for (let i = 0; i < n * n; i++) { - const e = [dev[i * 4] / 255, dev[i * 4 + 1] / 255, dev[i * 4 + 2] / 255]; + const x = [dev[i * 4] / 255, dev[i * 4 + 1] / 255, dev[i * 4 + 2] / 255]; const t = [ref[i * 4] / 255, ref[i * 4 + 1] / 255, ref[i * 4 + 2] / 255]; // A block the camera clipped has no colour left to copy, one at the floor is // only the pedestal, and one the develop clipped has lost the ratio between // its channels — none of them say anything about the rendering. if (t[0] > CLIP && t[1] > CLIP && t[2] > CLIP) continue; - if (e[0] > CLIP && e[1] > CLIP && e[2] > CLIP) continue; + if (x[0] > CLIP && x[1] > CLIP && x[2] > CLIP) continue; if (Math.max(t[0], t[1], t[2]) < FLOOR) continue; used++; - blocks.push({ e, t }); - for (let x = 0; x < 3; x++) { - for (let y = 0; y < 3; y++) C[x][y] += e[x] * e[y]; - for (let c = 0; c < 3; c++) d[x][c] += e[c] * t[x]; - } + blocks.push({ x, t }); } // A fit off a handful of blocks is a fit off noise. if (used < (n * n) / 8) return null; - // Ridge towards identity, scaled to the data. The three channels of a develop - // are near-collinear — a camera's own rendering of a neutral scene is close to - // neutral — so the plain normal equations come back singular-ish, and the frame's - // own noise decides a matrix whose diagonal lands at or below zero. λ of 1e-3 of - // the data's scale lifts that back to a definite system and moves the fitted - // matrix only in the third decimal (measured: 0.001 kept the tone the same to - // 0.3% where 0.1 began flattening it). - const lambda = 1e-3 * ((C[0][0] + C[1][1] + C[2][2]) / 3); - for (let r = 0; r < 3; r++) { - C[r][r] += lambda; - d[r][r] += lambda; - } - const m = [...solve(C, d[0]), ...solve(C, d[1]), ...solve(C, d[2])]; - // A row that ran off is a degenerate fit, not a look. A diagonal element at or - // below zero is not one: what these matrices hold is the difference between two - // near-identical channels (X-T3 -0.04, X100V -1.67 on a row that carries +1.05 - // and -1.67), and refusing those refused the whole fit — those bodies opened - // byte-identical to an unfitted develop. - for (const v of m) if (!Number.isFinite(v) || Math.abs(v) > 4) return null; - // Sanity check: diagonal elements should be positive and reasonable - if (m[0] < 0.1 || m[4] < 0.1 || m[8] < 0.1 || m[0] > 3.0 || m[4] > 3.0 || m[8] > 3.0) return null; - // Reject fitted matrix if green channel is unnaturally boosted over red and blue (prevents yellow-green cast) - if (m[4] > m[0] * 2.5 || m[4] > m[8] * 2.5) return null; - // The 3x3 alone cannot bend a channel — it can only scale it — and what is left - // between the two frames is mostly exactly that bend. So the residual after the - // 3x3 is fitted a cubic a channel, on the value the 3x3 leaves. - const q = blocks.map(({ e, t }) => { - const v = [ - m[0] * e[0] + m[1] * e[1] + m[2] * e[2], - m[3] * e[0] + m[4] * e[1] + m[5] * e[2], - m[6] * e[0] + m[7] * e[1] + m[8] * e[2], - ]; - return { t, q: v.map((x) => Math.min(1, Math.max(0, x))) }; - }); - const tone = fitTone(q); - return { m: m as unknown as Mat3, tone: curveIsSane(tone) ? tone : FLAT_TONE }; + const tone = fitTone(blocks); + return curveIsSane(tone) ? tone : null; } diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index 0464cc7..d00311e 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -5,22 +5,22 @@ // balance and tone (below) and knows nothing of the body's picture style, so the // develop is fitted to the one rendering the file does carry with it: the preview // the camera wrote inside it, which is the frame the photographer saw and the one -// a desktop viewer shows. Develop the sensor, least-squares a 3x3 from a block -// grid of each onto the other on the encoded values, fit what that 3x3 leaves a -// cubic a channel, and develop the sensor through both (see previewMatch.ts). +// a desktop viewer shows. Develop the sensor, take a block grid out of each on +// the encoded values, fit the cubic a channel between them, and develop the +// sensor through that curve (see previewMatch.ts). // -// Measured against the camera's own 24MP JPEG of the A5100 frame this was -// reported on, blocks of a 64x64 grid, dE00: 12.0 as the develop left it, 19.7 -// with no white balance at all, 6.6 matched to the file's own preview by the 3x3 -// alone and 4.4 through the 3x3 and the curve together. What neither reaches is -// the camera's own sharpening, which no per-pixel transform holds. +// The curve is the whole fit. A 3x3 was least squared ahead of it for a long +// time and is what put the olive cast on the Olympus frame this was reported on: +// a develop keeps its luminance and its colour in the same three numbers and the +// luminance is two orders of magnitude the larger, so a least squares dominated +// by it holds the channel means and buys them with the colour axis. The camera's +// picture style is a curve, not a matrix. // -// The fit is the only profile: a body-used-to-be table of six matrices fitted on -// this develop was dropped, because a table fitted on one path stops matching the -// moment the path changes under it — its rows no longer summed to 1 once the -// highlight knee landed ahead of it, and every body that had one opened with a -// cast the body that had none did not. Offline-fitted matrices age; the file's -// own preview does not. +// Measured on that frame, standard deviation of R-G over it (the file's own +// preview 8.3, the camera's own JPEG 12.3, an olive frame being one where this +// has gone to nothing): 11.6 as the develop left it, 11.9 through the curve +// alone, 4.6 through the curve under the fitted 3x3. What no per-pixel transform +// reaches either way is the camera's own sharpening. // // Developing the sensor is also what keeps the highlights: 2.3% of the frame at // pure white against 3.3% in the preview itself, since the 8-bit preview threw @@ -53,7 +53,7 @@ import LibRaw from 'libraw-wasm'; import { sensorWhite } from './sensorWhite'; import { f32ToF16 } from './halfFloat'; -import { fitMatch, FLAT_TONE, MATCH_GRID, type Match, type Mat3 } from './previewMatch'; +import { toneMatch, FLAT_TONE, MATCH_GRID } from './previewMatch'; import { Skia } from './skiaShim'; // What `imageData()` returns for the settings below: 16-bit, 3 channels, black @@ -85,8 +85,6 @@ const SAMPLE_MAX = 65535; // Pixels per band, held under the point where the Float32 copy dominates the // memory the page is allowed to use. const BAND_PIXELS = 2_000_000; -// Leaves the develop alone when the body has no fitted profile. -const IDENTITY: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]; const RAW_DEVELOP_SKSL = ` uniform shader raw; @@ -96,15 +94,12 @@ uniform float4 m0; // camera -> sRGB, the first three columns of rgb_cam uniform float4 m1; uniform float4 m2; uniform float4 crop; // (y offset of this band, 0, 0, 0) -uniform float4 f0; // the per-file fit, on the encoded value (identity when -uniform float4 f1; // the frame carries no preview to be fitted to) -uniform float4 f2; -uniform float4 t0; // what that fit then leaves a channel: one cubic a -uniform float4 t1; // channel, r a b c d per float4 (flat when there is no fit) -uniform float4 t2; +uniform float4 t0; // the file's own rendering, one cubic a channel on the +uniform float4 t1; // encoded value: r a b c d per float4 (flat when there is +uniform float4 t2; // no preview to fit to) -// A 3x3 can only scale a channel; the gap to the camera is mostly a shape, and -// this is that shape, read on the value the 3x3 left. +// The camera's rendering is a shape, and this is that shape, read on the value +// the develop left. float tone(float4 w, float x) { return clamp(w.x + x * (w.y + x * (w.z + x * w.w)), 0.0, 1.0); } @@ -142,7 +137,7 @@ half4 main(float2 pos) { // (227,184,245 at the gate's own value against 245,245,245 read where the gains // put the clip, the camera's preview white at that block). Both are read, so a // body whose gains do not lift a channel keeps the sensor's own clip as its gate. - // The matrix was fitted luma-preserving, so mx is the value to hold. + // mx is the value to hold: it is the pixel's own lightness. float hi = max(max(n.r, n.g), n.b); hi = max(hi, max(max(lin.r, lin.g), lin.b)); rgb = mix(rgb, float3(mx), smoothstep(0.99, 1.02, hi)); @@ -159,7 +154,7 @@ half4 main(float2 pos) { // spends 0.85..0.94. Below T the frame is untouched and the curve leaves T with // the slope it arrived with (1), so there is no seam to mask; above it the frame // darkens, which is the one move no later pass can undo — which is the point, - // and what the per-file fit below then measures the REST of the frame back from. + // and what the per-file curve below then measures the REST of the frame back from. // // ponytail: 4.7 stops of headroom now share ~5% of the ramp, and the develop // still leaves as an 8-bit JPEG. Give it a float16 output when RAW highlights @@ -169,24 +164,16 @@ half4 main(float2 pos) { rgb *= (0.7 + over / (1.0 + over * 3.3333)) / mx; } float3 e = encode(rgb); - // The file's own colour: the fit, in float, on the encoded value it was fitted + // The file's own colour: the curve, in float, on the encoded value it was fitted // on — and the last step the frame leaves through. Not the 8-bit colour filter - // this used to be painted through: the fit carries an exposure (the preview is - // the reference, so matching its brightness is part of matching its colour). - // No rolloff here either. The fit is fitted whole to the preview's own values, - // and white is one of them, so it already maps the frame's white to the preview's - // — while a divide by the row max is a white-preserving move the fit does not - // need: every highlight came back at ~0.74 / 1.0 / 0.86, cyan, and not one cell - // of the frame reached white on all three channels (0.0% against the preview's - // 3.3%). Past the white level only the clamp is left, exactly as the develop - // above lets its own overflow run. - float3 q = clamp(float3(dot(f0.xyz, e), dot(f1.xyz, e), dot(f2.xyz, e)), 0.0, 1.0); - // ...and then the curve, one cubic a channel, which is what carries the body's - // own tone: a 3x3 can only scale, so the frame without it came back bright and - // green in the shadows (dL +13.8 and green +0.128 at the bottom of the range, - // dE00 6.6) instead of matching (dL +4.5, green +0.017, 4.4). The curve is - // pinned at white, so a blown pixel still lands on white. - return half4(half3(tone(t0, q.r), tone(t1, q.g), tone(t2, q.b)), 1.0); + // this used to be painted through: the curve carries an exposure (the preview is + // the reference, so matching its brightness is part of matching its colour), and + // it is what carries the body's own tone — a 3x3 can only scale, and the one that + // used to sit here bought the channel means by collapsing the colour axis it was + // fitted against (see previewMatch.ts). No rolloff: white is one of the values + // the curve is fitted on, and it is pinned there, so a blown pixel still lands on + // white while the frame's own highlights stay where the knee above left them. + return half4(half3(tone(t0, e.r), tone(t1, e.g), tone(t2, e.b)), 1.0); } `; @@ -204,11 +191,12 @@ export function isRawName(name: string): boolean { return name.includes('.') && RAW_EXT.includes(ext); } -// The camera's own preview, when the file carries one: the colour reference to -// fit against, and what the file opens as when the sensor does not decode. +// The camera's own preview, when the file carries one: the colour reference the +// curve below is fitted to, and what the file opens as when the sensor does not +// decode. // ponytail: it is 1616x1080 on an A5100 and 1620x1080 on an FX30, so handing it // back is a 1.7MP frame — a print past it has to come off the develop, which is -// what a fitted file already gives. +// what a developed file already gives. async function cameraPreview(raw: LibRaw): Promise { const thumb = await raw.thumbnailData().catch(() => undefined); if (thumb?.format !== 'jpeg' || !thumb.data?.length) return null; @@ -354,7 +342,7 @@ function gridOf(image: any, n = MATCH_GRID): Uint8Array | null { if (!surface) return null; const canvas = surface.getCanvas(); // Cubic, not a linear tap: this is a 45x reduction and linear reads a handful - // of source pixels per block — noise for the least squares to fit. + // of source pixels per block — noise for the curve to be fitted on. canvas.drawImageRectCubic( image, Skia.XYWHRect(0, 0, image.width(), image.height()), @@ -376,11 +364,11 @@ function gridOf(image: any, n = MATCH_GRID): Uint8Array | null { // The same grid out of the preview, which the file carries as a JPEG. Decoded and // reduced through the same Skia call as the develop's own grid, because the two -// grids are only comparable — and the fit only meaningful — when one resampler -// made both. A 2D canvas here instead left the fit following its own smoothing: -// on the A5100 frame the same develop scored dE00 4.7 against 4.4, and the dark -// end of the frame came out 6 L further from the preview than the fit it was -// handed asked for. +// grids are only comparable — and a curve between them only meaningful — when one +// resampler made both. A 2D canvas here instead left the curve following its own +// smoothing: on the A5100 frame the same develop scored dE00 4.7 against 4.4, and +// the dark end of the frame came out 6 L further from the preview than the curve +// it was handed asked for. function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Uint8Array | null { const bmp = Skia.Image.MakeImageFromEncoded(jpeg); if (!bmp) return null; @@ -503,26 +491,21 @@ export async function developRaw(bytes: Uint8Array): Promise { // The band goes up as half, not float32: the GPU backend puts an F32 image // on the 1/255 grid and the shadows quantise to black (see halfFloat.ts). const half = new Uint16Array(w * bandH * 4); - // Every uniform but the crop and the fit, which are the two the passes change: - // the shader's own order is gain, mul, rgb_cam, crop, the fit, then the fit's - // tone curve. - const uniforms = new Float32Array(45); + // Every uniform but the crop and the curve, which are the two the passes change: + // the shader's own order is gain, mul, rgb_cam, crop, then the curve. + const uniforms = new Float32Array(33); uniforms[0] = SAMPLE_MAX / sensorWhite(data, cd.maximum, cd.black); uniforms.set([mul[0], mul[1], mul[2], 0, r0[0], r0[1], r0[2], 0, r1[0], r1[1], r1[2], 0, r2[0], r2[1], r2[2], 0], 1); - // One develop of the frame, band by band, through `fit` when there is one. A - // function because the frame is drawn twice: once on the sensor alone, to fit - // against the preview, and then again with the fit in the shader. + // One develop of the frame, band by band, through `tone` when the file carried a + // preview to fit a curve to. A function because the frame is drawn twice: once + // as the sensor left it, to fit that curve against, and then again through it. // ponytail: two full band passes, on the main thread. Give develop an F16 // intermediate (one develop, one colour pass) if the second pass ever shows. - const develop = (fit: Match | null) => { + const develop = (tone: Float32Array | null) => { const surface = Skia.Surface.MakeOffscreen(w, h) ?? Skia.Surface.Make(w, h); if (!surface) return null; - const f = fit?.m ?? IDENTITY; - uniforms[21] = f[0]; uniforms[22] = f[1]; uniforms[23] = f[2]; uniforms[24] = 0; - uniforms[25] = f[3]; uniforms[26] = f[4]; uniforms[27] = f[5]; uniforms[28] = 0; - uniforms[29] = f[6]; uniforms[30] = f[7]; uniforms[31] = f[8]; uniforms[32] = 0; - uniforms.set(fit?.tone ?? FLAT_TONE, 33); + uniforms.set(tone ?? FLAT_TONE, 21); for (let y0 = 0; y0 < h; y0 += bandH) { const rows = Math.min(bandH, h - y0); let o = 0; @@ -563,15 +546,15 @@ export async function developRaw(bytes: Uint8Array): Promise { }; // The file's own colour: a grid of the develop as it stands, the same grid out - // of the preview the camera wrote into the file, and the 3x3 and the curve - // between them — which the second develop then draws in the shader. No - // preview, no fit: the frame opens as the sensor left it. + // of the preview the camera wrote into the file, and the curve between them — + // which the second develop then draws in the shader. No preview, no curve: the + // frame opens as the sensor left it. const first = develop(null); if (!first) throw new Error('no surface for the develop'); const blocks = preview ? gridOf(first) : null; const ref = blocks ? previewGrid(preview as Uint8Array, first.width(), first.height()) : null; - const match = blocks && ref ? fitMatch(blocks, ref) : null; - const matched = match ? develop(match) : null; + const tone = blocks && ref ? toneMatch(blocks, ref) : null; + const matched = tone ? develop(tone) : null; const jpeg = (matched ?? first).encodeToBytes(Skia.ImageFormat.JPEG, 92); (matched ?? first).dispose(); if (matched) first.dispose();