fix(tone): read the ramp's base from the pixel's own neighbourhood, so highlights and shadows move a region and keep the detail in it

This commit is contained in:
2026-10-15 00:55:09 +07:00
parent 8809eb84b0
commit ba181da86b
5 changed files with 1017 additions and 90 deletions
+115 -39
View File
@@ -21,12 +21,13 @@
// alone keeps the print size metadata.
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
import { Skia } from './skiaShim';
import { Skia, CK } from './skiaShim';
import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../shared/types';
import { ASPECT_RATIO_W_H } from '../../shared/types';
import { getSkiaColorMatrix, exposureStops } from '../../shared/utils/colorUtils';
import {
TONE_SKSL,
TONE_BASE_SKSL,
EXPOSURE_SKSL,
GLOW_SKSL,
SHARPEN_SKSL,
@@ -37,8 +38,8 @@ import {
DEHAZE_SKSL,
dehazeUniformArray,
getToneUniforms,
TONE_BASE_RADIUS,
TONE_BASE_SIGMA,
toneBaseSurface,
TONE_BASE_SCALE,
toneIsActive,
toneUniformArray,
glowUniformArray,
@@ -186,6 +187,7 @@ let glowEffect: any = null;
let halationEffect: any = null;
let curveEffect: any = null;
let clarityBlurEffect: any = null;
let toneBaseEffect: any = null;
let clarityBlendEffect: any = null;
let nrEffect: any = null;
let dehazeEffect: any = null;
@@ -201,6 +203,7 @@ function effects() {
if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL);
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL);
if (!toneBaseEffect) toneBaseEffect = Skia.RuntimeEffect.Make(TONE_BASE_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);
@@ -215,6 +218,7 @@ function effects() {
halationEffect,
curveEffect,
clarityBlurEffect,
toneBaseEffect,
clarityBlendEffect,
nrEffect,
dehazeEffect,
@@ -331,40 +335,73 @@ function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: num
disposeAll([paint, filter, snap]);
}
// The BASE layer of the tone ramp: the frame blurred out to TONE_BASE_RADIUS, as
// an image the tone shader takes as its second child and reads once per pixel.
// Blurred with Skia's own MakeBlur — the same draw drawBlurred makes for the
// sharpening pass — because what the ramp needs is an AVERAGE of the
// neighbourhood and a ring of point samples is not one: sampled on a fine ring
// the luma aliases, the gain o(base)/base inherits the alias, and the
// reconstruction paints it back as mottle (a 1160x774 frame at SHADOW +100 came
// out with 0.063 of high-frequency gain against 0.005 for this blur — see
// TONE_BASE_RADIUS in toneShader.ts). `shaderOf` is left owned by the caller: the
// tone pass reads the same shader as its first child.
function blurredBase(
shaderOf: () => any,
// The BASE layer of the tone ramp: the frame's own neighbourhood level out to
// TONE_BASE_RADIUS, as an image the tone shader takes as its second child and
// reads once per pixel. NOT a blur — a blur is an average of whatever is in the
// neighbourhood, so at the frame's strong edges it lands across them and the
// pixels beside a bright sky take the shadow side's move (the halo of
// docker/halo_highlight_shadow_white_black.md). TONE_BASE_SKSL is the edge-aware
// pass instead: one bilateral 1xN per axis, over a 1 px line-blind med3 of the
// same axis, so the base is the level of the pixel's OWN side of an edge and a
// twig finer than a couple of pixels is detail rather than tone (see
// TONE_BASE_RADIUS in toneShader.ts), on a surface TONE_BASE_SCALE of the frame
// so the two passes cost a quarter of what they do at full size and the base
// carries exactly the detail a level that wide can. Two axis passes rather than
// one NxN kernel is the same 2n+1 reads CLARITY_BLUR_SKSL buys its reference
// with.
//
// Null only if the effect or a surface will not come up, in which case the caller
// hands the tone shader the sharp child as its base: the base then lands on the
// pixel and the ramp falls back to the global move. `shaderOf` is left owned by
// the caller — the tone pass reads the same shader as its first child.
function toneBaseAxis(
effect: any,
child: any,
w: number,
h: number,
sigma: number
dx: number,
dy: number
): any | null {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const shader = shaderOf();
const shader = effect.makeShaderWithChildren([dx, dy], [child]);
if (!shader) return null;
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
const paint = Skia.Paint();
paint.setImageFilter(filter);
paint.setShader(shader);
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
flush(surf);
disposeAll([paint, filter]);
disposeAll([paint, shader]);
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
// The base the ramp reads, at TONE_BASE_SCALE of the frame: two axis passes over
// toneBaseSurface's own step — the horizontal one reading the frame's full-size
// child through baseDownscale, the vertical one reading what it left at 1:1. A
// 1:1 draw is what keeps `dir` in the pass's OWN pixels (TONE_BASE_SKSL): the
// scale lives in the child's local matrix, not in the canvas, so the tap offsets
// are the half's own and the second pass needs no coordinate of its own.
function toneBase(shaderOf: (lm?: any) => any, w: number, h: number): any | null {
const { toneBaseEffect: effect } = effects();
if (!effect) return null;
const { width: bw, height: bh, step } = toneBaseSurface(w, h);
const frame = shaderOf(baseDownscale());
if (!frame) return null;
const horizontal = toneBaseAxis(effect, frame, bw, bh, step, 0);
if (!horizontal) return null;
const child = imageShaderChild(horizontal);
try {
if (!child) return null;
return toneBaseAxis(effect, child, bw, bh, 0, step);
} finally {
disposeAll([child]);
horizontal.dispose();
}
}
// 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 —
@@ -666,10 +703,37 @@ function dehazeDarkChannel(
// 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
);
function imageShaderChild(image: any, localMatrix?: any, fm = Skia.FilterMode.Linear): any {
const tx = Skia.TileMode.Clamp;
return localMatrix
? image.makeShaderOptions(tx, tx, fm, Skia.MipmapMode.None, localMatrix)
: image.makeShaderOptions(tx, tx, fm, Skia.MipmapMode.None);
}
// The base is built on a surface TONE_BASE_SCALE of the frame, so the frame's own
// child is handed to it with the matrix that puts the frame's coordinates on that
// surface (the local matrix runs the child's reads through its own space, i.e.
// twice where the pixel is). Skia's sampler then does the box downscale for free,
// to the same bytes as the equivalent 2:1 drawImageRectOptions — measured in the
// head of tone-local-contrast-check.mjs. `CK` rather than the shim's `Skia`: the
// RN API this file is a port of has no local matrix, so the shim has none.
function baseDownscale(): any {
return CK.Matrix.scaled(TONE_BASE_SCALE, TONE_BASE_SCALE);
}
// ...and the base comes back the same way: the tone pass reads it at the frame's
// own size through the inverse matrix, and the sampler does the 2x upscale —
// NEAREST, not linear. The half the pass leaves is one texel per two frame
// pixels, so a linear 2x read weights the texel across a step edge by a quarter
// on the pixel beside it, and that quarter of the far side's level is exactly the
// halo the edge-aware base exists to avoid (2 px at -20.9/255 on the step-edge
// frame, measured in tone-local-contrast-check.mjs). Nearest replicates the texel
// instead and the step lands on the edge; the base is a smoothed signal, so the
// 2x2 blocks it costs are the levels the bilateral already had, and every pixel
// still reads toneCurve at its own base. The same sampler calibrated: nearest at
// 2x is floor(X / 2) exactly, 0/256 mismatches on a 16x16 field.
function baseUpscale(): any {
return CK.Matrix.scaled(1 / TONE_BASE_SCALE, 1 / TONE_BASE_SCALE);
}
// The same child, read in the frame's coordinates rather than its own: a pass
@@ -914,11 +978,14 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
// 3b. Tone shader.
// TONE_BASE_RADIUS of the FRAME is the neighbourhood the ramp is drawn
// through, so the preview and the file look at the same size of one — this is
// the one place that knows how big the frame is. The base itself is a blurred
// copy of the tone shader's own child (blurredBase), not a ring of samples
// inside it: see TONE_BASE_RADIUS for why a ring was the mottle bug. A frame
// too small or a surface that will not come up leaves the sharp child as the
// base, which lands the base on the pixel and keeps the global ramp.
// the one place that knows how big the frame is. The base itself is an
// edge-aware copy of the tone shader's own child (toneBase), not a ring of
// samples inside it and not a blur either: see TONE_BASE_RADIUS for why a
// ring was the mottle bug and a blur is the halo one. It comes back at
// TONE_BASE_SCALE and is upscaled by its own shader's local matrix (see
// baseUpscale). A frame too small or a
// surface that will not come up leaves the sharp child as the base, which
// lands the base on the pixel and keeps the global ramp.
const tone = getToneUniforms(adjustments, recipe.baseFilter);
let toneShader: any = null;
// 3c. Cinema seasonal grade (cinema → tone → image).
@@ -942,32 +1009,41 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
gradeImage = snapshot;
}
}
const imageShaderOf = () =>
own(
gradeImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
);
const imageShaderOf = (lm?: any) => own(imageShaderChild(gradeImage, lm));
// Exposure takes the place of the colour filter on the draw, so it too needs
// the matrix to be IN its child — hence expOn in `graded` above. Everything
// downstream that samples "the image" now samples after the exposure.
let exposeShader: any = null;
let exposeEffect: any = null;
if (expOn) {
const { exposureEffect: effect } = effects();
exposeEffect = effect;
if (effect) exposeShader = own(effect.makeShaderWithChildren([evStops], [imageShaderOf()]));
}
const baseShaderOf = () => exposeShader ?? imageShaderOf();
// The frame's own child, at the coordinate space the caller asks for: the
// base pass reads it at TONE_BASE_SCALE of the frame (see toneBase), every
// other caller reads the frame itself. The exposure, if there is one, has to
// be rebuilt over the scaled child — it is a per-pixel map, so its output is
// the same either way, but the pixels it reads are not.
const baseShaderOf = (lm?: any): any => {
if (!lm) return exposeShader ?? imageShaderOf();
if (!exposeShader || !exposeEffect) return imageShaderOf(lm);
return own(exposeEffect.makeShaderWithChildren([evStops], [imageShaderOf(lm)]));
};
if (toneIsActive(tone)) {
const { toneEffect: effect } = effects();
if (effect) {
// The base is built HERE and not earlier: it draws the same shader the
// tone pass reads, and both have to see the pixels after the exposure.
const sigma = width * TONE_BASE_RADIUS * TONE_BASE_SIGMA;
const base = own(blurredBase(baseShaderOf, width, height, sigma));
// It comes back TONE_BASE_SCALE of the frame, which is why the tone pass
// takes it with baseUpscale()'s local matrix rather than as it stands —
// and reads it NEAREST, so the upscale replicates the half's texels
// instead of weighting the far side of an edge into the pixel beside it.
const base = own(toneBase(baseShaderOf, width, height));
toneShader = own(
effect.makeShaderWithChildren(toneUniformArray(tone), [
baseShaderOf(),
base ? own(imageShaderChild(base)) : baseShaderOf(),
base ? own(imageShaderChild(base, baseUpscale(), Skia.FilterMode.Nearest)) : baseShaderOf(),
])
);
}