Add HDF (Highlight Diffusion Filter) to the FX tab

The built-in lens filter, simulated the way it is done in Photoshop: pull the
highlights out with a hard contrast curve (out = 2.5*in - 1.5, black below 0.6),
Gaussian-blur that selection, then Screen it back over the photo at reduced
opacity. Screen against a near-black pixel is a no-op, so areas with no
highlight within the blur radius come back byte-identical while lit areas
spread their light.

One 0..10 knob drives both the radius (4%..1.9% of the width, ~10..50px at
2560 wide) and the opacity (0.15..0.5), matching the recipe's 15-50px radius
range. The extraction composes on top of the recipe colour matrix so a
monochrome look glows white instead of leaking the original colours back.

Runs in the Skia export engine only; the P5 native path is not wired into the
app (dev parity probe only), so it is left alone for now.
This commit is contained in:
2026-09-10 09:45:51 +07:00
parent 3a8b110c1b
commit 28e18893cd
3 changed files with 61 additions and 0 deletions
+1
View File
@@ -54,6 +54,7 @@ export interface ColorAdjustments {
colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue)
dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve)
sharpening: number; // 0 to 10 (output screen sharpen strength)
hdf?: number; // 0 to 10 (Highlight Diffusion Filter: glow on the highlights only)
exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain.
}
+49
View File
@@ -300,6 +300,55 @@ export async function processAndExportPhoto(
canvas.drawImage(skImage, 0, 0, paint);
}
// 5b. Highlight Diffusion Filter (HDF): bloom on the bright areas only —
// the software twin of the lens' built-in diffusion filter, Photoshopped
// exactly as described: extract the highlights with a hard contrast curve,
// Gaussian-blur that selection (radius 15..50px), Screen it back over the
// photo at reduced opacity. Screen against a near-black pixel is a no-op, so
// shadows stay where they were while lit areas spread their light.
// One 0..10 knob drives both the blur radius and the opacity, and the radius
// is a fraction of the width so any source size blooms alike.
// ponytail: split into radius/amount knobs only if the 1D ramp reads coarse.
if ((adjustments.hdf ?? 0) > 0) {
const strength = (adjustments.hdf ?? 0) / 10;
const sigma = width * (0.004 + 0.015 * strength); // ~10..50px at 2560 wide
// Highlight extraction: out = 2.5*in - 1.5 -> black below 0.6, speculars
// saturate white. Composed on TOP of the recipe matrix so a monochrome look
// glows white instead of leaking the original colours back through.
const hiMatrix = [
2.5, 0, 0, 0, -1.5,
0, 2.5, 0, 0, -1.5,
0, 0, 2.5, 0, -1.5,
0, 0, 0, 1, 0,
];
// The order is load-bearing and NOT the paint's: a paint color filter runs
// after the paint's image filter, i.e. the blur would smear the recipe
// matrix into the selection instead of pulling the highlights out first —
// measured as a flat +6/255 haze in the shadows with no glow on the
// speculars. Chain both as image filters instead (inner runs first).
const selection = Skia.ImageFilter.MakeColorFilter(
Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), colorFilter),
null
);
const glowPaint = Skia.Paint();
glowPaint.setBlendMode(BlendMode.Screen);
glowPaint.setAlphaf(0.15 + 0.35 * strength);
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
selection
)
);
// Same source the base draw used, so the glow sits on the processed pixels.
if (paintShader) {
glowPaint.setShader(paintShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
} else {
canvas.drawImage(skImage, 0, 0, glowPaint);
}
}
// 6. Add Monochrome Grain Overlay
if (adjustments.grain > 0) {
const grainOpacity = adjustments.grain / 20; // up to 0.5 opacity
+11
View File
@@ -161,5 +161,16 @@ export const PARAM_DEFS: {
get: (a) => a.grain,
set: (v) => ({ grain: v }),
},
{
key: 'hdf',
label: 'HDF',
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.hdf ?? 0,
set: (v) => ({ hdf: v }),
},
],
};