FX vignetting, LIGHT vibrance/saturation, reticle unlock

- FX: new VIGNETTING 0-10 control (chip + slider). Darkens the four
  corners of the photo with a radial gradient whose radius is the photo
  diagonal/2, so the falloff stays circular on any aspect. Applied in
  preview (camera overlay, polaroid/wall window, plain library) and in
  exportEngine pass 6b, always on the picture, never on the mat.
- LIGHT: new VIBRANCE -10..+10 (chroma-masked saturation in the tone
  shader: vivid pixels move least). Renamed the existing saturation
  control chip from COLOR to SATURATION - same param, clearer label.
- Fix: <Shader> takes a RuntimeEffect, not a gradient SkShader; the
  preview vignette threw "Object is not a HostObject" when it got one.
  Use the declarative <RadialGradient> child on the Rect instead.
- Preview: drop the "AE/AF LOCKED - TAP TO UNLOCK" banner; the lock
  reticle is now itself the unlock touch target.
This commit is contained in:
2026-09-10 10:22:55 +07:00
parent d7a8e0681e
commit 65d0850317
5 changed files with 121 additions and 26 deletions
+54 -21
View File
@@ -16,6 +16,7 @@ import {
Skia,
Group,
Rect,
RadialGradient,
ColorMatrix,
Image as SkiaImage,
ImageShader,
@@ -338,7 +339,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// Tone uniforms [dr, hl, sh] for the preview worklet; all-zero = no tone pass.
const toneParams = (() => {
const u = getToneUniforms(adjustments);
return [u.dr, u.hl, u.sh];
return [u.dr, u.hl, u.sh, u.vib];
})();
const toneSync = useMemo(
() => createSynchronizable<number[]>([0, 0, 0]),
@@ -725,7 +726,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
const tone = toneSync.getDirty();
const cinema = cinemaSync.getDirty();
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0);
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0);
const hasCinema = cinemaEffect != null && cinema[0] > 0;
// Draws the full frame with the tone/cinema shader when active (drawRect,
// not drawImage: paint shaders only apply to geometry fills — drawImage
@@ -1380,6 +1381,28 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
);
};
// Vignetting (FX): darken the four corners of the PHOTO area. A radial
// gradient (clear centre → black corners) sized to the given rect, mirroring
// the export pass (exportEngine 6b) so preview and file match. Drawn on the
// photo only — never on a polaroid/wall mat.
const vignetteAmount = (adjustments.vignette ?? 0) / 10;
const renderVignette = (x: number, y: number, w: number, h: number) => {
if (vignetteAmount <= 0 || w <= 0 || h <= 0) return null;
// <Shader> takes a RuntimeEffect, not a gradient SkShader (passing the latter
// throws "Object is not a HostObject"). RadialGradient is the declarative form.
return (
<Rect x={x} y={y} width={w} height={h} opacity={0.9 * vignetteAmount}>
<RadialGradient
c={{ x: x + w / 2, y: y + h / 2 }}
r={Math.hypot(w, h) / 2}
colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0)', 'rgba(0,0,0,1)']}
positions={[0, 0.45, 1]}
mode={TileMode.Clamp}
/>
</Rect>
);
};
const renderGPSWatermark = () => {
if (!useGeotag || !gpsInfo || !customFont || !iconFont) return null;
@@ -1453,6 +1476,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
</Rect>
</Group>
)}
{renderVignette(
framedWin ? framedWin.x : region.x,
framedWin ? framedWin.y : region.y,
framedWin ? framedWin.w : region.w,
framedWin ? framedWin.h : region.h
)}
{renderFrameOverlay()}
{renderGPSWatermark()}
{renderCustomWatermark()}
@@ -1530,8 +1559,13 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
onResponderTerminate={onImageDragEnd}
/>
{focusPoint && (
<View
pointerEvents="none"
/* The locked-AE/AF reticle IS the unlock control: the old
"AE/AF LOCKED — TAP TO UNLOCK" banner was removed from the preview
(it sat under the zoom-preset row), so tapping the lock reticle
releases the lock. */
<TouchableOpacity
onPress={handleUnlockFocus}
activeOpacity={0.7}
className="absolute"
style={{ left: focusPoint.x - 26, top: focusPoint.y - 26 }}
>
@@ -1540,16 +1574,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<Lock size={14} color="#f59e0b" strokeWidth={2.5} />
</View>
</View>
</View>
)}
{focusPoint && (
<TouchableOpacity
onPress={handleUnlockFocus}
className="absolute right-4 top-24 bg-black/70 border border-amber-400/70 rounded-full px-3 py-1.5 flex-row items-center space-x-1.5"
activeOpacity={0.7}
>
<Lock size={11} color="#f59e0b" />
<Text className="text-amber-400 font-mono text-[10px] font-bold">AE/AF LOCKED — TAP TO UNLOCK</Text>
</TouchableOpacity>
)}
{focusPoint && onExposureChange && (
@@ -1625,7 +1649,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
// Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom.
const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0;
const toneOn =
toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0 || toneParams[3] !== 0;
const cinemaOn = cinemaEffect != null && !!cinemaParams;
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
// Photo display rect + draw fit in the plain library viewer: with a ratio
@@ -1661,7 +1686,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
>
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
@@ -1672,7 +1697,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
>
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
@@ -1690,6 +1715,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
</Rect>
</Group>
)}
{renderVignette(win.x, win.y, win.w, win.h)}
{/* Thin seam between image window and card */}
<Rect
x={win.x + 0.5}
@@ -1740,7 +1766,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
@@ -1751,7 +1777,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
@@ -1769,6 +1795,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
</Rect>
</Group>
)}
{renderVignette(win.x, win.y, win.w, win.h)}
{/* Artwork (rotated) over the crop: transparent window reveals it, mat
covers the rest. Drawn last so semi-transparent mat edges blend over
the photo instead of the reverse. */}
@@ -1816,7 +1843,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
>
<ImageShader
image={skiaImage}
@@ -1839,7 +1866,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
>
<ImageShader
image={skiaImage}
@@ -1870,6 +1897,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
</Rect>
</Group>
)}
{/* Vignette follows the PHOTO, not the screen: in the plain library
viewer the image is contain-fitted with letterbox, so a
screen-sized gradient would only reveal its clear middle. */}
{dispRect
? renderVignette(dispRect.dx, dispRect.dy, dispRect.dw, dispRect.dh)
: renderVignette(region.x, region.y, region.w, region.h)}
{/* Frame + GPS watermark belong to the photo → zoom with it. Grain
is a fullscreen screen effect → stays fixed above. */}
<Group matrix={libZoomMatrix}>