web: import the camera's RAW, and grade it like the phone

The studio took JPEG, PNG and HEIC and nothing else, so a photographer's own
negatives never reached it. A RAW now loads the way any other file does —
`isRawName` reads the extension off a 24-entry list, the file goes into OPFS
under one slot (`current_image.raw`, beside `current_image.name`, so a reload
finds it again) and `rawDevelop` runs it through LibRaw-wasm: half size, 16-bit
output, camera white balance and the camera's own 3x3 matrix, in bands of 2M
pixels so a 30MB file never holds a second copy of itself. `example-sony.ARW`
(30.3MB) lands as a 3120x2084 picture, no page error.

DEHAZE joins the FX tab, where Lightroom keeps it: a chip off the same
PARAM_DEFS entry (`dehaze`, 0..10) so nothing new renders chips, and the pass is
the dark channel prior — `atmosphericLight` reads A off a 32x32 draw of the
photo, `DEHAZE_SKSL` takes omega up to 0.95 over a floor of 0.1 — measured at
71.8% of the stage's pixels moved between 0 and 10.

The gradient mask grows the six knobs the phone's has: HIGHLIGHT, SHADOW,
WHITE, BLACK, CLARITY and DEHAZE. The mask's falloff is a smoothstep rather than
a line, and CLARITY/DEHAZE inside a mask get a blurred copy of the photo plus
the air A as a second child of the mask shader — so a mask's clarity is clarity
and not a flat brightness lift. The column shows all nine rulers; CLARITY 9
moves 42.2% of the stage, DEHAZE 9 moves 27.9%.

CLARITY stops reading the whole photo per pixel: the single pass that sampled a
15x15 box 225 times is now the three passes the same math wants — 1x15, then
15x1, then a blend, `orig + (orig - B) * 3.2` — about 30 reads. Both signs work
(77.4% of the stage moves at +10, 79.6% at -10), and the negative branch keeps
its mist as it was.

The pointer reviews a look before it is taken: resting on a PHOTO STYLE chip or
a recipe chip lays that look on the photo while it stays there and gives it back
the moment it leaves — byte-identical, measured on four of them (24.9%, 23.8%,
24.5%, 25.3% of the stage moves on, 0.00% off) — while the recipe, the UNDO
stack and the session stay on the look the click left. A hovered look brings its
colour alone: the masks, the dust spots and the mosaic of the photo being edited
ride along, or a pointer crossing a chip row would rub them off. A PRO sim is
left out, since a hover that showed its look would hand over what the click
gates.

Probes: e2e-raw-verify, e2e-dehaze-mask, e2e-mask-verify, e2e-clarity-verify,
e2e-hover-preview2.
This commit is contained in:
2026-09-26 18:17:18 +07:00
parent 178bc78bbb
commit 97bdf605e2
15 changed files with 835 additions and 96 deletions
+254 -47
View File
@@ -28,6 +28,11 @@ import {
EXPOSURE_SKSL,
GLOW_SKSL,
CLARITY_SKSL,
CLARITY_BLUR_SKSL,
CLARITY_BLEND_SKSL,
CLARITY_GAIN,
DEHAZE_SKSL,
dehazeUniformArray,
getToneUniforms,
toneIsActive,
toneUniformArray,
@@ -37,7 +42,7 @@ import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/uti
import { CURVE_SKSL, CURVE_LUT_SIZE, curveIsActive, curveLut } from '../../shared/utils/toneCurve';
import { healSkSL, healUniforms, readHeal } from '../../shared/utils/heal';
import { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic';
import { gradientMaskSkSL, maskUniforms, readMasks } from '../../shared/utils/gradientMask';
import { gradientMaskSkSL, maskUniforms, readMasks, masksHaveSpatial } from '../../shared/utils/gradientMask';
import {
GRAIN_SKSL,
HALATION_SKSL,
@@ -158,6 +163,9 @@ let cinemaEffect: any = null;
let glowEffect: any = null;
let halationEffect: any = null;
let curveEffect: any = null;
let clarityBlurEffect: any = null;
let clarityBlendEffect: any = null;
let dehazeEffect: any = null;
function effects() {
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
@@ -167,7 +175,21 @@ function effects() {
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL);
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
return { sharpenEffect, toneEffect, exposureEffect, cinemaEffect, glowEffect, halationEffect, curveEffect };
if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL);
if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL);
if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL);
return {
sharpenEffect,
toneEffect,
exposureEffect,
cinemaEffect,
glowEffect,
halationEffect,
curveEffect,
clarityBlurEffect,
clarityBlendEffect,
dehazeEffect,
};
}
// FX's HEAL is the one effect whose shader depends on the recipe rather than on
@@ -199,13 +221,16 @@ function mosaicEffectFor(count: number): any {
// FX's gradient masks are the third shader of that kind — the shapes and the
// knobs are the recipe's, so it is built for the count it is handed and cached
// by count the same way (shared/utils/gradientMask.ts).
const maskEffects = new Map<number, any>();
function maskEffectFor(count: number): any {
let effect = maskEffects.get(count);
// by count the same way (shared/utils/gradientMask.ts). Two variants per count:
// the masks that carry CLARITY or DEHAZE read a second child (the frame's own
// blurred reference), the rest do not, and neither pays for the other.
const maskEffects = new Map<string, any>();
function maskEffectFor(count: number, spatial: boolean): any {
const key = `${count}:${spatial ? 1 : 0}`;
let effect = maskEffects.get(key);
if (effect === undefined) {
effect = Skia.RuntimeEffect.Make(gradientMaskSkSL(count)) ?? null;
maskEffects.set(count, effect);
effect = Skia.RuntimeEffect.Make(gradientMaskSkSL(count, spatial)) ?? null;
maskEffects.set(key, effect);
}
return effect;
}
@@ -229,17 +254,6 @@ function convolvePaint(srcImage: any, amount: number): any {
return { paint, shader, child };
}
// The convolution must sample the ALREADY GRADED pixels (RN runs it as the
// outermost filter of the draw), so the pass reads a snapshot of the surface
// and writes the sharpened result back over the same rect.
function convPass(canvas: any, surface: any, w: number, h: number, amount: number): void {
flush(surface);
const snap = surface.makeImageSnapshot();
if (!snap) return;
drawConvolved(canvas, snap, w, h, amount);
snap.dispose();
}
// Replaces the pixels under the rect with the convolved result.
function drawConvolved(canvas: any, srcImage: any, w: number, h: number, amount: number): void {
const built = convolvePaint(srcImage, amount);
@@ -295,6 +309,162 @@ function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: num
disposeAll([paint, filter, snap]);
}
// --- spatial passes: CLARITY and DEHAZE -------------------------------------
//
// Both of the scratchpad docs' algorithms (raw_parameter_processing... §3 and
// ki_n_tr_c_multiple_passes... entirely) compare a pixel against a blurred
// reference of its own surroundings. The reference is built once here, the doc's
// way: a separable pair of passes — 1x15 across, 15x1 down — writes two offscreen
// surfaces the screen never shows, so a pixel costs 30 reads instead of the 225
// one 15x15 kernel would take. Offscreen surfaces rather than the doc's
// `rgba16float` textures because this renderer is 8-bit end to end (CanvasKit
// surfaces, see skiaShim.ts); the format is the only thing the doc and this file
// disagree about.
// How far out the reference looks, as a fraction of the frame's width, so the
// same CLARITY is the same size of neighbourhood on the preview and on the file.
const CLARITY_BLUR_SPAN = 0.06;
// One axis of the reference. `image` is the input, the result is a new image the
// caller owns.
function blurAxis(image: any, w: number, h: number, dx: number, dy: number): any | null {
const { clarityBlurEffect: effect } = effects();
const surf = createSurface(w, h);
if (!surf || !effect) {
surf?.dispose();
return null;
}
let child: any = null;
try {
child = image.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
);
const shader = child ? effect.makeShaderWithChildren([dx, dy], [child]) : null;
if (!shader) return null;
const paint = Skia.Paint();
paint.setShader(shader);
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
flush(surf);
disposeAll([paint, shader]);
return surf.makeImageSnapshot() ?? null;
} finally {
if (child) disposeAll([child]);
surf.dispose();
}
}
// The frame's own blurred reference, B in both docs: what the pixel is compared
// against for CLARITY, and the patch average the dark channel is read out of for
// DEHAZE. Null only if the effect could not be built.
function spatialReference(surface: any, w: number, h: number): any | null {
flush(surface);
const snap = surface.makeImageSnapshot();
if (!snap) return null;
try {
const step = Math.max(1, (w * CLARITY_BLUR_SPAN) / 15);
const horizontal = blurAxis(snap, w, h, step, 0);
if (!horizontal) return null;
try {
return blurAxis(horizontal, w, h, 0, step);
} finally {
horizontal.dispose();
}
} finally {
snap.dispose();
}
}
// The doc's atmospheric light A, the "amount of scattered light" DEHAZE takes
// back out. Read off the frame the pass is about to correct, through one 32x32
// draw rather than the histogram the doc describes: the Dark Channel Prior's own
// answer is the pixel whose DARKEST channel is brightest, so the 4096-pixel copy
// only has to be searched for that one. Null when the readback is unavailable —
// the caller then leaves the pass out rather than guessing a value.
function atmosphericLight(surface: any, w: number, h: number): [number, number, number] | null {
const n = 32;
const small = createSurface(n, n);
if (!small) return null;
let snap: any = null;
let paint: any = null;
try {
flush(surface);
snap = surface.makeImageSnapshot();
if (!snap) return null;
paint = Skia.Paint();
small.getCanvas().drawImageRect(
snap,
Skia.XYWHRect(0, 0, w, h),
Skia.XYWHRect(0, 0, n, n),
paint
);
flush(small);
const px = small.getCanvas().readPixels(0, 0, {
width: n,
height: n,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul,
colorSpace: Skia.ColorSpace.SRGB,
});
if (!px) return null;
let best = -1;
let air: [number, number, number] = [1, 1, 1];
for (let i = 0; i < n * n; i++) {
const r = px[i * 4] / 255;
const g = px[i * 4 + 1] / 255;
const b = px[i * 4 + 2] / 255;
const dark = Math.min(r, g, b);
if (dark > best) {
best = dark;
air = [r, g, b];
}
}
return air;
} catch {
// No readback (a surface that refuses one, a shader that failed): DEHAZE
// stays off for this render.
return null;
} finally {
disposeAll([paint, snap].filter(Boolean) as SkDisposable[]);
small.dispose();
}
}
// An image as a child shader — what every effect here wants instead of a paint.
// The caller owns the result (the renderer's `own`).
function imageShaderChild(image: any): any {
return image.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
);
}
// Snapshot → clear → redraw through the shader the callback builds FROM that
// snapshot: the shape every pass that reads the frame it is about to overwrite
// uses (the negative blurs, the gradient masks, HEAL), in one place so none of
// them has to remember to clear first. The callback owns whatever children it
// puts in the shader; the snapshot is freed here.
function replaceThrough(
canvas: any,
surface: any,
w: number,
h: number,
shaderOf: (snap: any) => any
): void {
flush(surface);
const snap = surface.makeImageSnapshot();
if (!snap) return;
try {
const shader = shaderOf(snap);
if (!shader) return;
canvas.clear(Skia.Color('transparent'));
const paint = Skia.Paint();
paint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
disposeAll([paint, shader]);
} finally {
snap.dispose();
}
}
// --- fonts / artwork --------------------------------------------------------
let faces: { typeface: any; iconTypeface: any } | null = null;
@@ -584,10 +754,55 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
canvas.drawImage(skImage, 0, 0, paint);
}
// 4b. CLARITY (positive) — convolution outside the denoise blur, the same
// nesting RN builds with MakeMatrixConvolution(input = previous filter).
if (adjustments.clarity > 0) {
convPass(canvas, surface, width, height, (adjustments.clarity / 10) * 0.8);
// 4b. DEHAZE and CLARITY — the frame-wide half of the two docs
// (raw_parameter_processing... §3, ki_n_tr_c_multiple_passes... in full). Both
// compare a pixel against a blurred reference of its own surroundings, so the
// reference is built once out of the frame the pass is about to overwrite
// (spatialReference, the doc's separable bilateral pair) and handed in as the
// effect's second child. DEHAZE runs first: it takes the scattered light out,
// and sharpening haze instead would only make it read as detail.
//
// The masks are read here rather than at 6d because a mask's CLARITY/DEHAZE
// asks for the same reference and the same atmospheric light, and the light is
// cheapest to read before the masks have graded the frame.
const masks = readMasks(adjustments.masks);
const maskSpatial = masksHaveSpatial(masks);
let air: [number, number, number] | null = null;
let airRead = false;
const airOf = () => {
if (!airRead) {
airRead = true;
air = atmosphericLight(surface, width, height);
}
return air;
};
const dehazeAmount = (adjustments.dehaze ?? 0) / 10;
if (dehazeAmount > 0 && dehazeEffect) {
replaceThrough(canvas, surface, width, height, (snap) => {
const a = airOf();
const reference = spatialReference(surface, width, height);
if (!a || !reference) return null;
own(reference);
const shader = dehazeEffect.makeShaderWithChildren(dehazeUniformArray(a, dehazeAmount), [
own(imageShaderChild(snap)),
own(imageShaderChild(reference)),
]);
return shader ? own(shader) : null;
});
}
if (adjustments.clarity > 0 && clarityBlendEffect) {
replaceThrough(canvas, surface, width, height, (snap) => {
const reference = spatialReference(surface, width, height);
if (!reference) return null;
own(reference);
const shader = clarityBlendEffect.makeShaderWithChildren(
[(adjustments.clarity / 10) * CLARITY_GAIN],
[own(imageShaderChild(snap)), own(imageShaderChild(reference))]
);
return shader ? own(shader) : null;
});
} else if (adjustments.clarity < 0) {
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
drawBlurred(canvas, surface, width, height, mistSigma);
@@ -711,30 +926,22 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
// reads what the first left. It sits here, after the frame-wide grade and
// before HEAL, because a local adjustment is part of the look and not a
// repair: the pixels a repair borrows are meant to carry it already.
const masks = readMasks(adjustments.masks);
// Same shape as the passes below — the shader reads the frame it is about to
// overwrite — and, when a mask carries CLARITY or DEHAZE, the frame's own
// blurred reference and the atmospheric light come in exactly as they did for
// the frame-wide pair at 4b.
if (masks.length) {
const effect = maskEffectFor(masks.length);
flush(surface);
const snap = own(surface.makeImageSnapshot());
const child = snap
? own(
snap.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
)
: null;
const shader =
effect && child
? own(effect.makeShaderWithChildren(maskUniforms(masks, width, height), [child]))
: null;
if (shader) {
// Same shape as the two passes below: the shader reads the frame it is
// about to overwrite, so the surface is snapshotted, cleared and redrawn.
canvas.clear(Skia.Color('transparent'));
const maskPaint = own(Skia.Paint());
maskPaint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), maskPaint);
}
const effect = maskEffectFor(masks.length, maskSpatial);
const a = maskSpatial ? airOf() : null;
replaceThrough(canvas, surface, width, height, (snap) => {
const reference = maskSpatial ? spatialReference(surface, width, height) : null;
if (!effect || (maskSpatial && !reference)) return null;
own(reference);
const children = [own(imageShaderChild(snap))];
if (reference) children.push(own(imageShaderChild(reference)));
const shader = effect.makeShaderWithChildren(maskUniforms(masks, width, height, a), children);
return shader ? own(shader) : null;
});
}
// 6e. HEAL — the FX tab's dust brush (see heal.ts). It is the last thing
@@ -765,7 +972,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
: null;
if (shader) {
// Snapshot, clear, redraw — the pass reads the frame it is about to
// overwrite (the same shape convPass uses for CLARITY).
// overwrite (see replaceThrough).
canvas.clear(Skia.Color('transparent'));
const healPaint = own(Skia.Paint());
healPaint.setShader(shader);