Sell PRO as an in-app unlock, take shared photos, and let FX run negative
- One free Play app (com.locphamtran.recipescamera) with an expo-iap PRO unlock, replacing the paid apk for the store channel - Android SEND/SEND_MULTIPLE share target feeds photos into the editor, on cold start and on resume - BACK button in the TopBar and press-and-hold PEEK replace the chip row - NOISE REDUCTION and SHARPENING accept -10: a negative SHARPENING softens the frame before the grain pass, a negative NOISE REDUCTION re-grains it, in the Skia engine, the live preview and the Kotlin export path alike
This commit is contained in:
@@ -6,7 +6,7 @@ import { Star, Trash2 } from 'lucide-react-native';
|
||||
import { Skia } from '@shopify/react-native-skia';
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
|
||||
import { FRAMES } from '../utils/frameUtils';
|
||||
import { IS_PRO, PRO_FRAMES } from '../utils/entitlement';
|
||||
import { PRO_FRAMES } from '../utils/entitlement';
|
||||
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
||||
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
|
||||
import RecipeCreateModal from './RecipeCreateModal';
|
||||
@@ -66,6 +66,10 @@ interface AdjustmentPanelProps {
|
||||
onApplyCrop: () => void;
|
||||
cropEnabled?: boolean;
|
||||
useGeotag: boolean;
|
||||
// Runtime tier: PRO apk or a LITE apk that bought the unlock. A LITE build
|
||||
// keeps every PRO chip on screen, greyed with its corner tag, so the panel
|
||||
// has to read the value at render time rather than at build time.
|
||||
pro: boolean;
|
||||
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
|
||||
// Settings sheet (gear between library picker and shutter) — no longer
|
||||
// tool-rail tabs.
|
||||
@@ -141,6 +145,9 @@ interface AdjustmentPanelProps {
|
||||
// FRAME → WATERMARK sub-panel is open: App needs this to arm the mark's
|
||||
// drag/pinch layer on the image (the old `activeTab === 'watermark'`).
|
||||
onWmPanelChange: (open: boolean) => void;
|
||||
// BACK (undo) and PEEK (hold to compare) both work on the whole LOOK, not on
|
||||
// one parameter, and both left this panel: BACK is a button in the top bar,
|
||||
// PEEK is a hold on the picture itself (TopBar, Viewfinder).
|
||||
}
|
||||
|
||||
interface ChipDef {
|
||||
@@ -229,6 +236,7 @@ export default function AdjustmentPanel({
|
||||
onApplyCrop,
|
||||
cropEnabled,
|
||||
useGeotag,
|
||||
pro,
|
||||
openParam,
|
||||
onOpenParamChange,
|
||||
currentBaseFilter,
|
||||
@@ -338,9 +346,8 @@ export default function AdjustmentPanel({
|
||||
? 'border-amber-500/70 bg-amber-500/15'
|
||||
: 'border-zinc-700/60 bg-titan/80';
|
||||
const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300';
|
||||
// LITE only: corner tag on every chip whose look LITE cannot export. PRO
|
||||
// never sees it (there the condition is a constant false).
|
||||
const proTag = c.pro && !IS_PRO;
|
||||
// LITE only: corner tag on every chip whose look LITE cannot export.
|
||||
const proTag = c.pro && !pro;
|
||||
return (
|
||||
<View
|
||||
key={c.key}
|
||||
@@ -424,7 +431,7 @@ export default function AdjustmentPanel({
|
||||
// A PRO-only parameter stays visible in LITE as a greyed chip with its
|
||||
// corner PRO tag — named where it is, which is the only place it can be.
|
||||
pro: p.pro,
|
||||
disabled: !!p.pro && !IS_PRO,
|
||||
disabled: !!p.pro && !pro,
|
||||
onPress: () => toggleParam(p.key),
|
||||
}));
|
||||
|
||||
@@ -560,7 +567,7 @@ export default function AdjustmentPanel({
|
||||
const wmFontOptions = useMemo(() => {
|
||||
// LITE ships a single basic face: the platform's own families (what
|
||||
// DEFAULT is not) are the paid half of the FONT strip.
|
||||
if (!IS_PRO) return [{ v: 'DEFAULT', d: 'DEFAULT' }];
|
||||
if (!pro) return [{ v: 'DEFAULT', d: 'DEFAULT' }];
|
||||
const mgr = Skia.FontMgr.System();
|
||||
const names = new Set<string>();
|
||||
for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) {
|
||||
@@ -571,7 +578,7 @@ export default function AdjustmentPanel({
|
||||
{ v: 'DEFAULT', d: 'DEFAULT' },
|
||||
...[...names].sort().map((n) => ({ v: n, d: n })),
|
||||
];
|
||||
}, []);
|
||||
}, [pro]);
|
||||
const groupDefs: Record<Exclude<GroupKey, 'wm'>, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
|
||||
cx: {
|
||||
label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Modal, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { X, Zap, RefreshCw } from 'lucide-react-native';
|
||||
|
||||
import { buyPro, restorePro, useProUnlock } from '../utils/proUnlock';
|
||||
import { PRO_FRAMES } from '../utils/entitlement';
|
||||
|
||||
// The paywall. LITE keeps every look previewable and editable; this sheet is
|
||||
// the only place the tier is sold, and Google Play does the selling — the price
|
||||
// shown is the one Play returned for the product.
|
||||
//
|
||||
// Play requires a way back to an existing purchase on any screen that sells
|
||||
// one, so RESTORE is a peer of BUY, not a footnote.
|
||||
|
||||
const PERKS = [
|
||||
'No RECIPESCAM mark on your exports',
|
||||
'HDF effect',
|
||||
`${PRO_FRAMES.length} printed frames`,
|
||||
'GPS location watermark',
|
||||
'Every platform font for your watermark',
|
||||
'Unlimited recipes and favorites',
|
||||
];
|
||||
|
||||
export default function ProModal({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||
const { owned, price, busy, error } = useProUnlock();
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black/70 items-center justify-center px-4">
|
||||
<TouchableOpacity className="absolute inset-0" activeOpacity={1} onPress={onClose} />
|
||||
<View
|
||||
className="w-full max-w-md rounded-2xl border border-zinc-800/70 p-5"
|
||||
style={{ backgroundColor: 'rgba(8,8,10,0.97)', maxHeight: '85%' }}
|
||||
>
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<View className="flex-row items-center">
|
||||
<Zap size={13} color="#f59e0b" strokeWidth={1.75} />
|
||||
<Text className="text-zinc-300 font-mono text-xs font-bold tracking-widest ml-2">
|
||||
RECIPESCAM PRO
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<X size={15} color="#71717a" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<Text className="text-zinc-500 font-mono text-[10px] leading-4">
|
||||
One payment, kept on your Google account. Nothing to type in, and it comes back on
|
||||
every phone that has the app.
|
||||
</Text>
|
||||
|
||||
<View className="mt-3 space-y-1.5">
|
||||
{PERKS.map((p) => (
|
||||
<View key={p} className="flex-row items-start">
|
||||
<Text className="text-amber-500 font-mono text-[11px] mr-2">+</Text>
|
||||
<Text className="text-zinc-300 font-mono text-[11px] flex-1">{p}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{owned ? (
|
||||
<View className="mt-4 rounded-xl border border-amber-500/40 bg-amber-500/10 px-3 py-2.5">
|
||||
<Text className="text-amber-500 font-mono text-[11px] font-bold tracking-widest">
|
||||
PRO ACTIVE
|
||||
</Text>
|
||||
<Text className="text-zinc-500 font-mono text-[10px] mt-1">
|
||||
Every look exports clean: no mark, no limits.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={buyPro}
|
||||
disabled={busy}
|
||||
activeOpacity={0.7}
|
||||
className={`mt-4 rounded-xl border px-3 py-3 items-center ${
|
||||
busy ? 'border-zinc-800/70 bg-black/30' : 'border-amber-500/70 bg-amber-500/15'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-mono text-[11px] font-bold tracking-widest ${
|
||||
busy ? 'text-zinc-500' : 'text-amber-500'
|
||||
}`}
|
||||
>
|
||||
{busy ? 'CONTACTING GOOGLE PLAY…' : price ? `UNLOCK PRO — ${price}` : 'UNLOCK PRO'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={restorePro}
|
||||
disabled={busy}
|
||||
activeOpacity={0.7}
|
||||
className="mt-2 flex-row items-center justify-center rounded-xl border border-zinc-800/70 bg-black/30 px-3 py-2.5"
|
||||
>
|
||||
<RefreshCw size={11} color="#a1a1aa" strokeWidth={1.75} />
|
||||
<Text className="text-zinc-400 font-mono text-[10px] font-bold tracking-widest ml-2">
|
||||
RESTORE PURCHASES
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-zinc-500 font-mono text-[10px] mt-2.5 leading-4">{error}</Text>
|
||||
) : null}
|
||||
|
||||
<Text className="text-zinc-600 font-mono text-[10px] mt-3 leading-4">
|
||||
Bought already but the app still says LITE? Restore, or check that you are signed in
|
||||
to the Google account that paid.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -286,8 +286,8 @@ export default function RecipeCreateModal({
|
||||
highlight: int(fHigh, simAdj.highlight, -10, 10),
|
||||
shadow: int(fShadow, simAdj.shadow, -10, 10),
|
||||
saturation: int(fColor, simAdj.saturation, -10, 10),
|
||||
denoise: int(fNr, simAdj.denoise, 0, 10),
|
||||
sharpening: int(fSharp, simAdj.sharpening, 0, 10),
|
||||
denoise: int(fNr, simAdj.denoise, -10, 10),
|
||||
sharpening: int(fSharp, simAdj.sharpening, -10, 10),
|
||||
clarity: int(fClarity, simAdj.clarity, -10, 10),
|
||||
grain: int(grain, seedAdj.grain, 0, 10),
|
||||
colorChrome: cc as ColorAdjustments['colorChrome'],
|
||||
@@ -313,8 +313,8 @@ export default function RecipeCreateModal({
|
||||
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
||||
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
||||
{ label: 'COLOR', value: fColor, set: setFColor },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '0-10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '0-10' },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
|
||||
{ label: 'CLARITY', value: fClarity, set: setFClarity },
|
||||
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
|
||||
];
|
||||
|
||||
@@ -37,8 +37,11 @@ interface SettingsModalProps {
|
||||
onGpsEnabled: (enabled: boolean) => void;
|
||||
startupMode: StartupMode;
|
||||
onStartupMode: (mode: StartupMode) => void;
|
||||
// LITE vs PRO, decided at build time (no key, nothing to enter here).
|
||||
// LITE vs PRO: the PRO apk is born owned, the free one buys the tier through
|
||||
// Google Play (see src/utils/proUnlock.ts). Nothing to enter here either way —
|
||||
// the LITE row just opens the paywall.
|
||||
pro: boolean;
|
||||
onUpgrade: () => void;
|
||||
}
|
||||
|
||||
function ChipRow({ chips }: { chips: Chip[] }) {
|
||||
@@ -101,6 +104,7 @@ export default function SettingsModal({
|
||||
startupMode,
|
||||
onStartupMode,
|
||||
pro,
|
||||
onUpgrade,
|
||||
}: SettingsModalProps) {
|
||||
const [showCredits, setShowCredits] = useState(false);
|
||||
// The RN Modal is its own dialog window, so the IME does not resize it and
|
||||
@@ -262,13 +266,15 @@ export default function SettingsModal({
|
||||
<SectionLabel>PRO</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'plan', label: pro ? 'PRO ACTIVE' : 'LITE', active: pro, disabled: true, onPress: () => {} },
|
||||
pro
|
||||
? { key: 'plan', label: 'PRO ACTIVE', active: true, disabled: true, onPress: () => {} }
|
||||
: { key: 'plan', label: 'UPGRADE TO PRO', active: false, onPress: onUpgrade },
|
||||
]}
|
||||
/>
|
||||
<Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">
|
||||
{pro
|
||||
? 'Every look exports clean: no mark, no limits.'
|
||||
: 'LITE is free forever. Any preset, frame and watermark can be previewed and edited, but a PRO look cannot be exported and every export carries the RECIPESCAM mark.'}
|
||||
: 'LITE is free forever. Any preset, frame and watermark can be previewed and edited, but a PRO look cannot be exported and every export carries the RECIPESCAM mark. One payment unlocks everything.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, TextInput, Modal, KeyboardAvoidingView, Platform, Alert } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Plus, Pencil } from 'lucide-react-native';
|
||||
import { Plus, Pencil, Undo2 } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
interface TopBarProps {
|
||||
@@ -16,9 +16,23 @@ interface TopBarProps {
|
||||
// SAVE & SHARE -> EXPORT: write this look to an encrypted recipe file and
|
||||
// hand it to the system share sheet so it can reach another phone.
|
||||
onExportShare: (name: string) => void;
|
||||
// BACK (undo): one step back through the look's history. PRO only — App
|
||||
// refuses and offers the paywall when a LITE build taps it. No count is
|
||||
// drawn: the icon turns amber while there is a step to go back to.
|
||||
onUndo: () => void;
|
||||
undoDepth: number;
|
||||
}
|
||||
|
||||
export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteRecipe, onEditRecipe, onExportShare }: TopBarProps) {
|
||||
export default function TopBar({
|
||||
mode,
|
||||
recipeName,
|
||||
onSaveRecipe,
|
||||
onOverwriteRecipe,
|
||||
onEditRecipe,
|
||||
onExportShare,
|
||||
onUndo,
|
||||
undoDepth,
|
||||
}: TopBarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [newRecipeName, setNewRecipeName] = useState('');
|
||||
@@ -75,6 +89,17 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center space-x-2">
|
||||
{/* BACK: the history control the chip row used to carry, sitting
|
||||
between the status block and SAVE & SHARE. Lucide (ISC) vector
|
||||
— no icon font to ship or license. */}
|
||||
<TouchableOpacity
|
||||
onPress={onUndo}
|
||||
accessibilityLabel="BACK"
|
||||
className="rounded-full border border-zinc-700/80 bg-black/45 p-2.5"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Undo2 size={13} color={undoDepth > 0 ? '#f59e0b' : '#52525b'} />
|
||||
</TouchableOpacity>
|
||||
{onEditRecipe && (
|
||||
<TouchableOpacity
|
||||
onPress={onEditRecipe}
|
||||
|
||||
@@ -468,13 +468,22 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
// Sharpening (-10..10): below 0 the knob softens the frame, the same blur the
|
||||
// export applies. Own sync so it wraps the clarity filter instead of the two
|
||||
// knobs clobbering each other.
|
||||
const softenSync = useMemo(
|
||||
() => createSynchronizable<number>(adjustments.sharpening ?? 0),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
colorMatrixSync.setBlocking(colorMatrix);
|
||||
toneSync.setBlocking(toneParams);
|
||||
cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat());
|
||||
hdfSync.setBlocking(adjustments.hdf ?? 0);
|
||||
claritySync.setBlocking(adjustments.clarity ?? 0);
|
||||
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync]);
|
||||
softenSync.setBlocking(adjustments.sharpening ?? 0);
|
||||
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, adjustments.sharpening, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, softenSync]);
|
||||
|
||||
// Camera controller lives behind SkiaCamera. The camera AE bias is never
|
||||
// written: EV (user + metering offset) is purely software matrix gain, for
|
||||
@@ -1134,38 +1143,50 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
console.warn('Color matrix build failed: ' + e);
|
||||
}
|
||||
// CLARITY: the export pass 4 twin — unsharp convolution above 0, mist
|
||||
// blur below. Applied to the base paint so it sits under the glow and
|
||||
// the panel actually shows what the file will get.
|
||||
// blur below. SHARPENING < 0 rides the same slot: it softens the frame
|
||||
// the exported file gets too. Both sit on the base paint, under the
|
||||
// glow, and compose (soften wraps whatever clarity built).
|
||||
try {
|
||||
const clarityV = claritySync.getDirty();
|
||||
if (typeof clarityV === 'number' && Number.isFinite(clarityV) && clarityV !== 0) {
|
||||
const clarityFilter =
|
||||
clarityV > 0
|
||||
? (() => {
|
||||
const a = (clarityV / 10) * 0.8;
|
||||
return Skia.ImageFilter.MakeMatrixConvolution(
|
||||
3,
|
||||
3,
|
||||
[0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0],
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
T_CLAMP,
|
||||
true,
|
||||
null
|
||||
);
|
||||
})()
|
||||
: Skia.ImageFilter.MakeBlur(
|
||||
Math.abs(clarityV / 10) * 4,
|
||||
Math.abs(clarityV / 10) * 4,
|
||||
const softenV = softenSync.getDirty();
|
||||
const cv = typeof clarityV === 'number' && Number.isFinite(clarityV) ? clarityV : 0;
|
||||
const sv = typeof softenV === 'number' && Number.isFinite(softenV) ? softenV : 0;
|
||||
if (cv !== 0 || sv < 0) {
|
||||
const a = (cv / 10) * 0.8;
|
||||
let lensFilter =
|
||||
cv > 0
|
||||
? Skia.ImageFilter.MakeMatrixConvolution(
|
||||
3,
|
||||
3,
|
||||
[0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0],
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
T_CLAMP,
|
||||
true,
|
||||
null
|
||||
);
|
||||
if (clarityFilter != null) paint.setImageFilter(clarityFilter);
|
||||
)
|
||||
: cv < 0
|
||||
? Skia.ImageFilter.MakeBlur(
|
||||
Math.abs(cv / 10) * 4,
|
||||
Math.abs(cv / 10) * 4,
|
||||
T_CLAMP,
|
||||
null
|
||||
)
|
||||
: null;
|
||||
if (sv < 0) {
|
||||
lensFilter = Skia.ImageFilter.MakeBlur(
|
||||
Math.abs(sv / 10) * 3,
|
||||
Math.abs(sv / 10) * 3,
|
||||
T_CLAMP,
|
||||
lensFilter
|
||||
);
|
||||
}
|
||||
if (lensFilter != null) paint.setImageFilter(lensFilter);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Clarity filter build failed: ' + e);
|
||||
console.warn('Lens filter build failed: ' + e);
|
||||
}
|
||||
const tone = toneSync.getDirty();
|
||||
const cinema = cinemaSync.getDirty();
|
||||
@@ -1393,7 +1414,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
[colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, toneFailLogged, feedRectSync, frameBeatSync]
|
||||
);
|
||||
|
||||
const grainOpacity = adjustments.grain / 20;
|
||||
// NOISE REDUCTION below 0 puts grain back instead of smoothing it away, so a
|
||||
// negative NR and GRAIN share one overlay (capped at 10 → 0.5), exactly as
|
||||
// the export pass does.
|
||||
const grainOpacity =
|
||||
Math.min(10, Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))) / 20;
|
||||
|
||||
// Library-still viewer: pinch-zoom (2 fingers) + double-tap zoom at the tap
|
||||
// point / double-tap again to reset to 100%. Transform is a Skia matrix
|
||||
@@ -2844,10 +2869,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<SkiaImage image={skiaImage} fit={fit} rect={rectNode} />
|
||||
);
|
||||
// Mist (negative clarity) = export pass 4's blur; sigma in original px.
|
||||
if (libClarity < 0) {
|
||||
// Sharpening < 0 rides the same wrap: the export runs a second Gaussian
|
||||
// after the mist, and two Gaussians in series are one of sqrt(a^2 + b^2),
|
||||
// so a single pass with that sigma matches the file.
|
||||
const libMist = libClarity < 0 ? Math.abs(libClarity / 10) * 4 * sx : 0;
|
||||
const libSoften = (Math.max(0, -(adjustments.sharpening ?? 0)) / 10) * 3 * sx;
|
||||
const libBlur = Math.sqrt(libMist * libMist + libSoften * libSoften);
|
||||
if (libBlur > 0) {
|
||||
photo = (
|
||||
<Group>
|
||||
<Blur blur={Math.abs(libClarity / 10) * 4 * sx} mode="clamp" />
|
||||
<Blur blur={libBlur} mode="clamp" />
|
||||
{photo}
|
||||
</Group>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user