HDF on the live camera preview

Mirror export pass 5b in the onFrame worklet: extract the highlights with
the same 2.5x-1.5 contrast curve, blur that selection, Screen it back at
0.15..0.5 alpha. The selection must be built as an image filter so the
highlight curve runs before the blur (a paint color filter would run after,
smearing the matrix into the selection).

Kept the tone/cinema path intact: the base draw now records the shader it
used and the glow pass reuses it (drawRect) or falls back to the plain
drawImage, so the glow always sits on the same processed pixels.

Measured on the Xiaomi 2203121C: 5 interleaved 0/10 pairs, the HDF=10 mean
luma is +0.70..0.84 above HDF=0 every time (AE metering does not track the
software pass, so the toggle is the only variable), and frame counts are
unchanged (488/489 vs 492/488 frames per 15s; GPU busy rises ~30%, still
inside the 3 ms median GPU frame). No fps loss, so the pass stays.
This commit is contained in:
2026-09-10 10:41:39 +07:00
parent 6c7d5b8f43
commit 6379c025c8
+64 -11
View File
@@ -20,6 +20,7 @@ import {
ColorMatrix,
Image as SkiaImage,
ImageShader,
BlendMode,
TileMode,
FilterMode,
MipmapMode,
@@ -353,11 +354,18 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Highlight Diffusion Filter strength (0..10); 0 = no glow pass.
const hdfSync = useMemo(
() => createSynchronizable<number>(adjustments.hdf ?? 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
colorMatrixSync.setBlocking(colorMatrix);
toneSync.setBlocking(toneParams);
cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat());
}, [colorMatrix, toneParams, cinemaParams, colorMatrixSync, toneSync, cinemaSync]);
hdfSync.setBlocking(adjustments.hdf ?? 0);
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, colorMatrixSync, toneSync, cinemaSync, hdfSync]);
// Camera controller lives behind SkiaCamera. AE bias is not tracked live:
// flushExposureBias() below applies it right before each photo capture so the
@@ -714,10 +722,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// one frame. Validate + try/catch, fall back to identity (no filter):
// a 1-frame gain error beats a 1-frame black beat. DEBUG-EV.
const paint = Skia.Paint();
let baseColorFilter: ReturnType<typeof Skia.ColorFilter.MakeMatrix> | null = null;
try {
const cm = colorMatrixSync.getDirty();
if (cm != null && cm.length === 20 && cm.every((v) => typeof v === 'number' && Number.isFinite(v))) {
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(cm));
baseColorFilter = Skia.ColorFilter.MakeMatrix(cm);
paint.setColorFilter(baseColorFilter);
} else {
console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped');
}
@@ -733,6 +743,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// ignores the paint shader, which is why the DR/Highlight/Shadow tone
// never showed on the live preview) or as a plain color-matrix image.
const drawFullFrame = () => {
let fullShader = null;
if (hasTone || hasCinema) {
try {
const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
@@ -744,13 +755,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const cinemaShader = cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]);
if (cinemaShader != null) final = cinemaShader;
}
if (final != null) {
const w = frameTexture.width();
const h = frameTexture.height();
paint.setShader(final);
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
return;
}
if (final != null) fullShader = final;
} catch (e) {
if (!toneFailLogged.getBlocking()) {
toneFailLogged.setBlocking(true);
@@ -758,7 +763,55 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
}
}
canvas.drawImage(frameTexture, 0, 0, paint);
if (fullShader != null) {
const w = frameTexture.width();
const h = frameTexture.height();
paint.setShader(fullShader);
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
} else {
canvas.drawImage(frameTexture, 0, 0, paint);
}
// HDF: mirror of export pass 5b — extract highlights with a hard
// contrast curve, blur that selection, Screen it back on top. Screen
// against near-black is a no-op, so shadows stay put. Same source
// (shader or image) the base draw used, so the glow sits on processed
// pixels. Radius is a fraction of width so preview/export bloom alike.
const hdfV = hdfSync.getDirty();
if (hdfV > 0) {
const strength = hdfV / 10;
const width = frameTexture.width();
const sigma = width * (0.004 + 0.015 * strength);
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,
];
const hiFilter = Skia.ColorFilter.MakeMatrix(hiMatrix);
// Order is load-bearing (see exportEngine pass 5b): chain both as
// image filters so the highlight selection runs BEFORE the blur.
const selection = Skia.ImageFilter.MakeColorFilter(
baseColorFilter != null
? Skia.ColorFilter.MakeCompose(hiFilter, baseColorFilter)
: hiFilter,
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
)
);
if (fullShader != null) {
glowPaint.setShader(fullShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint);
} else {
canvas.drawImage(frameTexture, 0, 0, glowPaint);
}
}
};
// Composition band: without this the SkiaCamera layer later cover-fits
// the upright snapshot onto the fullscreen view, so a 4:3 ratio band
@@ -857,7 +910,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
});
frame.dispose();
},
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged, feedRectSync, frameBeatSync]
[colorMatrixSync, toneSync, cinemaSync, hdfSync, toneFailLogged, feedRectSync, frameBeatSync]
);
const grainOpacity = adjustments.grain / 20;