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:
@@ -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}>
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ColorAdjustments {
|
||||
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)
|
||||
vignette?: number; // 0 to 10 (darken the four corners; 0 = off)
|
||||
vibrance?: number; // -10 to +10 (chroma-masked saturation: vivid colours move least)
|
||||
exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain.
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ export async function processAndExportPhoto(
|
||||
FilterMode.Linear,
|
||||
MipmapMode.None
|
||||
);
|
||||
toneShader = toneEffect.makeShaderWithChildren([tone.dr, tone.hl, tone.sh], [imageShader]);
|
||||
toneShader = toneEffect.makeShaderWithChildren([tone.dr, tone.hl, tone.sh, tone.vib], [imageShader]);
|
||||
if (toneShader) {
|
||||
paint.setShader(toneShader);
|
||||
} else {
|
||||
@@ -371,6 +371,30 @@ export async function processAndExportPhoto(
|
||||
}
|
||||
}
|
||||
|
||||
// 6b. Vignetting: darken the four corners of the PHOTO. Radial gradient
|
||||
// (clear through the mid-frame, black at the corners) whose radius is the
|
||||
// photo diagonal/2, so the falloff stays circular on any aspect. Runs
|
||||
// before the frame step, so on a polaroid/wall export the darkening lands
|
||||
// on the picture inside the opening, never on the mat.
|
||||
// ponytail: one amount knob (the Fuji-style VIGNETTING control); split into
|
||||
// amount + radius/midpoint only if users ask to move the falloff.
|
||||
if ((adjustments.vignette ?? 0) > 0) {
|
||||
const v = Math.min(10, adjustments.vignette ?? 0) / 10;
|
||||
const vignetteShader = Skia.Shader.MakeRadialGradient(
|
||||
Skia.Point(width / 2, height / 2),
|
||||
Math.hypot(width, height) / 2,
|
||||
[Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,1)')],
|
||||
[0, 0.45, 1],
|
||||
TileMode.Clamp
|
||||
);
|
||||
if (vignetteShader) {
|
||||
const vignettePaint = Skia.Paint();
|
||||
vignettePaint.setShader(vignetteShader);
|
||||
vignettePaint.setAlphaf(0.9 * v);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Frame. RETRO POLAROID is not a border drawn on the photo: the whole
|
||||
// processed picture is composited, uncropped, into the window of a bigger
|
||||
// off-white card (real print proportions, see frameUtils) — the export IS
|
||||
|
||||
+23
-1
@@ -60,7 +60,7 @@ export const PARAM_DEFS: {
|
||||
},
|
||||
{
|
||||
key: 'color',
|
||||
label: 'COLOR',
|
||||
label: 'SATURATION',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
@@ -69,6 +69,17 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.saturation,
|
||||
set: (v) => ({ saturation: v }),
|
||||
},
|
||||
{
|
||||
key: 'vibrance',
|
||||
label: 'VIBRANCE',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.vibrance ?? 0,
|
||||
set: (v) => ({ vibrance: v }),
|
||||
},
|
||||
{
|
||||
key: 'highlight',
|
||||
label: 'HIGHLIGHT',
|
||||
@@ -172,5 +183,16 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.hdf ?? 0,
|
||||
set: (v) => ({ hdf: v }),
|
||||
},
|
||||
{
|
||||
key: 'vignette',
|
||||
label: 'VIGNETTING',
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: String,
|
||||
get: (a) => a.vignette ?? 0,
|
||||
set: (v) => ({ vignette: v }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+17
-3
@@ -22,11 +22,16 @@ import { ColorAdjustments } from '../types';
|
||||
// (Fuji extended DR); 0/auto/DR100 = no extra curve.
|
||||
// hl - highlight -1..1: + lifts toward white, - rolls the bright side down.
|
||||
// sh - shadow -1..1: + lifts the dark side, - deepens it.
|
||||
// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader
|
||||
// (rather than the colour matrix) because it needs per-pixel chroma:
|
||||
// already-vivid pixels move least, so skins/skies deepen without the neon
|
||||
// clip a plain Saturation boost causes. All-zero still = no pass.
|
||||
export const TONE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float dr;
|
||||
uniform float hl;
|
||||
uniform float sh;
|
||||
uniform float vib;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
|
||||
@@ -50,7 +55,14 @@ vec4 main(vec2 xy) {
|
||||
o += dr * 0.12 * shMask * (1.0 - t);
|
||||
o -= dr * 0.18 * hlMask * t;
|
||||
float f = clamp(o / max(t, 0.0004), 0.0, 4.0);
|
||||
return vec4(clamp(c.rgb * f, 0.0, 1.0), c.a);
|
||||
vec3 rgb = clamp(c.rgb * f, 0.0, 1.0);
|
||||
// Vibrance: push the LESS-saturated pixels harder than the vivid ones.
|
||||
float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
float mx = max(max(rgb.r, rgb.g), rgb.b);
|
||||
float mn = min(min(rgb.r, rgb.g), rgb.b);
|
||||
float chroma = mx > 0.0001 ? (mx - mn) / mx : 0.0;
|
||||
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
|
||||
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -59,6 +71,7 @@ export interface ToneUniforms {
|
||||
dr: number; // 0..1
|
||||
hl: number; // -1..1 (adjustments.highlight / 10)
|
||||
sh: number; // -1..1 (adjustments.shadow / 10)
|
||||
vib: number; // -1..1 (adjustments.vibrance / 10)
|
||||
}
|
||||
|
||||
export function getToneUniforms(adj: ColorAdjustments): ToneUniforms {
|
||||
@@ -66,9 +79,10 @@ export function getToneUniforms(adj: ColorAdjustments): ToneUniforms {
|
||||
const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300;
|
||||
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10));
|
||||
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10));
|
||||
return { dr, hl, sh };
|
||||
const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10));
|
||||
return { dr, hl, sh, vib };
|
||||
}
|
||||
|
||||
export function toneIsActive(u: ToneUniforms): boolean {
|
||||
return u.dr !== 0 || u.hl !== 0 || u.sh !== 0;
|
||||
return u.dr !== 0 || u.hl !== 0 || u.sh !== 0 || u.vib !== 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user