web: NOISE REDUCTION takes the colour speckle out of the frame and leaves every strand of it where it was

The knob was a `MakeBlur` image filter on the draw of the graded photo — one
sigma over all three channels — so at NOISE REDUCTION 100 a 1024-pixel preview
lost every edge finer than 0.6 of a pixel of its own and nothing brought the
luminance detail back. That is thay_doi_thong_so_giong_lightroom.md §4's own
warning ("Noise Reduction sẽ làm nhòe toàn bộ chi tiết sợi tóc và vân da") written
into the engine, and the filter had a second cost: a paint filter is handed the
shader's INPUT, so the pass could never read the graded pixels it was supposed to
correct.

It is a two-child pass now, NR_SKSL, run after the draw: the frame, and a blurred
copy of it (blurredFrame — the snapshot read back through the same MakeBlur the
ramp's base and the negative sharpening use). The output takes its CHROMA from
the blurred child and its LUMA from the frame, the split the tone ramp's header
already describes (`rgb - luma`), so the output's brightness is the input's at
every amount by construction — the eye is nearly blind to a hue change at that
scale, which is the whole reason the colour half is free. The reference reaches
NR_CHROMA_SPAN = 0.4% of the frame's width, the doc's own 3..5 pixels of a
full-resolution frame, where the knob's blur was 0.6 of a pixel.

The luminance half of §4.1 (its bilateral filter) is deliberately not here: it is
the half that costs detail and no frame has shown grain the colour half left
behind. ponytail: add it as a second child of this same pass when one does.

Checked: `tsc --noEmit` clean; `denoise-check.mjs` (new) compiles NR_SKSL on
CanvasKit, asserts the engine still wires both children and no longer blurs the
draw, and renders the pass at five amounts against a flat pair — amount 0 is the
pixel exactly, amount 1 carries the neighbourhood's colour difference, and the
luma never moves at any of them; `highlight-knee-check.mjs`, `tone-base-check.mjs`
and `mask-wb-check.mjs` still green.
This commit is contained in:
2026-10-02 08:14:25 +07:00
parent e4f5407c19
commit 90a7ec9e46
3 changed files with 226 additions and 7 deletions
+62 -7
View File
@@ -14,7 +14,8 @@
// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot
// be composited over the un-blurred content, so those passes snapshot, clear
// and redraw. Order matches RN's nested filter chain: denoise → clarity →
// mist → soften.
// mist → soften. NOISE REDUCTION's chroma reference is one of those blurs,
// and only its chroma (NR_SKSL) — the knob used to smear all three channels.
// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)`
// alone keeps the print size metadata.
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
@@ -30,6 +31,8 @@ import {
CLARITY_SKSL,
CLARITY_BLUR_SKSL,
CLARITY_BLEND_SKSL,
NR_SKSL,
NR_CHROMA_SPAN,
DEHAZE_SKSL,
dehazeUniformArray,
getToneUniforms,
@@ -166,6 +169,7 @@ let halationEffect: any = null;
let curveEffect: any = null;
let clarityBlurEffect: any = null;
let clarityBlendEffect: any = null;
let nrEffect: any = null;
let dehazeEffect: any = null;
function effects() {
@@ -178,6 +182,7 @@ function effects() {
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL);
if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL);
if (!nrEffect) nrEffect = Skia.RuntimeEffect.Make(NR_SKSL);
if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL);
return {
sharpenEffect,
@@ -189,6 +194,7 @@ function effects() {
curveEffect,
clarityBlurEffect,
clarityBlendEffect,
nrEffect,
dehazeEffect,
};
}
@@ -344,6 +350,34 @@ function blurredBase(
}
}
// A blurred copy of what the surface holds right now, as an image a pass can
// take as a child. Same `MakeBlur` as the ramp's base and the negative blurs,
// but built from a SNAPSHOT rather than from a shader the caller still holds —
// so the snapshot's own shader is freed here, and NOISE REDUCTION can read the
// graded pixels of the frame without re-deriving the draw that made them.
function blurredFrame(surface: any, w: number, h: number, sigma: number): any | null {
flush(surface);
const snap = surface.makeImageSnapshot();
if (!snap) return null;
const surf = createSurface(w, h);
const child = surf ? imageShaderChild(snap) : null;
try {
if (!surf || !child) return null;
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
const paint = Skia.Paint();
paint.setImageFilter(filter);
paint.setShader(child);
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
flush(surf);
disposeAll([paint, filter]);
return surf.makeImageSnapshot() ?? null;
} finally {
if (child) child.dispose();
surf?.dispose();
snap.dispose();
}
}
// --- spatial passes: CLARITY and DEHAZE -------------------------------------
//
// Both of the scratchpad docs' algorithms (raw_parameter_processing... §3 and
@@ -943,12 +977,6 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
if (paintShader) paint.setShader(paintShader);
else if (!graded) paint.setColorFilter(colorFilter);
// 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
if (adjustments.denoise > 0) {
const sigma = (adjustments.denoise / 10) * 0.6;
paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
}
// 5. Draw the graded photo.
if (paintShader) {
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
@@ -956,6 +984,33 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
canvas.drawImage(skImage, 0, 0, paint);
}
// 4. NOISE REDUCTION — thay_doi_thong_so_giong_lightroom.md §4.1, the colour
// half of it (NR_SKSL). It runs HERE and not on the paint, because it needs
// the finished pixels as one child and a blurred copy of them as the other,
// and a `setImageFilter` on the draw can only offer the shader's input. The
// knob WAS that filter — one sigma over all three channels — so the pass that
// took a colour speckle out took the picture's edges with it. Only the chroma
// is blurred now; the luma is the frame's own, so a strand of hair comes back
// where it was whatever the knob says.
const denoiseKnob = adjustments.denoise ?? 0;
if (denoiseKnob > 0) {
const { nrEffect: denoiseEffect } = effects();
const sigma = (denoiseKnob / 10) * NR_CHROMA_SPAN * width;
// The blur is built before the redraw because the redraw overwrites the
// frame the reference is read out of (the same reason DEHAZE reads its dark
// channel and its light first).
const blurred = denoiseEffect ? own(blurredFrame(surface, width, height, sigma)) : null;
if (denoiseEffect && blurred) {
replaceThrough(canvas, surface, width, height, (snap) => {
const shader = denoiseEffect.makeShaderWithChildren(
[Math.min(1, denoiseKnob / 10)],
[own(imageShaderChild(snap)), own(imageShaderChild(blurred))]
);
return shader ? own(shader) : null;
});
}
}
// 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