diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 3b7c6e5..e0d5b24 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -20,6 +20,7 @@ import { ColorMatrix, Image as SkiaImage, ImageShader, + BlendMode, TileMode, FilterMode, MipmapMode, @@ -353,11 +354,18 @@ const Viewfinder = forwardRef(function Viewfi // eslint-disable-next-line react-hooks/exhaustive-deps [] ); + // Highlight Diffusion Filter strength (0..10); 0 = no glow pass. + const hdfSync = useMemo( + () => createSynchronizable(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(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 | 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(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(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(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(function Viewfi }); frame.dispose(); }, - [colorMatrixSync, toneSync, cinemaSync, toneFailLogged, feedRectSync, frameBeatSync] + [colorMatrixSync, toneSync, cinemaSync, hdfSync, toneFailLogged, feedRectSync, frameBeatSync] ); const grainOpacity = adjustments.grain / 20;