From 65d0850317a48107178414dababe781d75c4ac2e Mon Sep 17 00:00:00 2001 From: locphamtran Date: Thu, 10 Sep 2026 10:22:55 +0700 Subject: [PATCH] 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: takes a RuntimeEffect, not a gradient SkShader; the preview vignette threw "Object is not a HostObject" when it got one. Use the declarative 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. --- src/components/Viewfinder.tsx | 75 +++++++++++++++++++++++++---------- src/types/index.ts | 2 + src/utils/exportEngine.ts | 26 +++++++++++- src/utils/paramDefs.ts | 24 ++++++++++- src/utils/toneShader.ts | 20 ++++++++-- 5 files changed, 121 insertions(+), 26 deletions(-) diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 3f737a7..3b7c6e5 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -16,6 +16,7 @@ import { Skia, Group, Rect, + RadialGradient, ColorMatrix, Image as SkiaImage, ImageShader, @@ -338,7 +339,7 @@ const Viewfinder = forwardRef(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([0, 0, 0]), @@ -725,7 +726,7 @@ const Viewfinder = forwardRef(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(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; + // takes a RuntimeEffect, not a gradient SkShader (passing the latter + // throws "Object is not a HostObject"). RadialGradient is the declarative form. + return ( + + + + ); + }; + const renderGPSWatermark = () => { if (!useGeotag || !gpsInfo || !customFont || !iconFont) return null; @@ -1453,6 +1476,12 @@ const Viewfinder = forwardRef(function Viewfi )} + {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(function Viewfi onResponderTerminate={onImageDragEnd} /> {focusPoint && ( - @@ -1540,16 +1574,6 @@ const Viewfinder = forwardRef(function Viewfi - - )} - {focusPoint && ( - - - AE/AF LOCKED — TAP TO UNLOCK )} {focusPoint && onExposureChange && ( @@ -1625,7 +1649,8 @@ const Viewfinder = forwardRef(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(function Viewfi {toneEffect && toneOn ? ( @@ -1672,7 +1697,7 @@ const Viewfinder = forwardRef(function Viewfi ) : toneEffect && toneOn ? ( @@ -1690,6 +1715,7 @@ const Viewfinder = forwardRef(function Viewfi )} + {renderVignette(win.x, win.y, win.w, win.h)} {/* Thin seam between image window and card */} (function Viewfi {toneEffect && toneOn ? ( @@ -1751,7 +1777,7 @@ const Viewfinder = forwardRef(function Viewfi ) : toneEffect && toneOn ? ( @@ -1769,6 +1795,7 @@ const Viewfinder = forwardRef(function Viewfi )} + {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(function Viewfi {toneEffect && toneOn ? ( (function Viewfi ) : toneEffect && toneOn ? ( (function Viewfi )} + {/* 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. */} diff --git a/src/types/index.ts b/src/types/index.ts index 70b6e77..4406e9f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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. } diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index a4cc888..67aff2e 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -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 diff --git a/src/utils/paramDefs.ts b/src/utils/paramDefs.ts index 398a32e..5c5eddc 100644 --- a/src/utils/paramDefs.ts +++ b/src/utils/paramDefs.ts @@ -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 }), + }, ], }; diff --git a/src/utils/toneShader.ts b/src/utils/toneShader.ts index 44a8729..a6e928b 100644 --- a/src/utils/toneShader.ts +++ b/src/utils/toneShader.ts @@ -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; }