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:
2026-09-16 06:48:22 +07:00
parent 27a9dfb65f
commit a85ebf0a81
21 changed files with 860 additions and 183 deletions
+14 -7
View File
@@ -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,
+115
View File
@@ -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>
);
}
+4 -4
View File
@@ -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' },
];
+9 -3
View File
@@ -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>
+27 -2
View File
@@ -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}
+61 -30
View File
@@ -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>
);
+2 -2
View File
@@ -71,13 +71,13 @@ export interface ColorAdjustments {
wbBlue?: number; // Fuji WB fine-tune, Blue axis (-9..9) — extra blue-channel gain
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
denoise: number; // 0 to 10 (mapped to blur sigma)
denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back)
clarity: number; // -10 to +10 (mapped to matrix convolution / bloom)
grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale)
colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect
colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue)
dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve)
sharpening: number; // 0 to 10 (output screen sharpen strength)
sharpening: number; // -10 to +10 (+ = screen sharpen; - = soften the frame)
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)
+7
View File
@@ -101,6 +101,13 @@ export const CREDITS: CreditEntry[] = [
copyright: "Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)",
url: "https://docs.expo.dev/versions/latest/sdk/haptics/",
},
{
name: "expo-iap",
version: "5.6.0",
license: "MIT",
copyright: "Copyright (c) 2025 hyochan",
url: "https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap#readme",
},
{
name: "expo-keep-awake",
version: "57.0.1",
+5 -9
View File
@@ -1,14 +1,10 @@
import { FrameId } from '../types';
import { IS_PRO_BUILD } from '../provariant';
// LITE (free) vs PRO (the paid build). The two are separate apks built from this
// one tree: the tier is fixed at build time by src/provariant.ts (rewritten by
// tools/set-variant.mjs, `npm run apk:lite|apk:pro`) and nothing in a LITE apk
// can change it afterwards — no key, no server, no stored flag.
// Constant for the life of the build, so every `!IS_PRO` branch below is dead
// code the bundler is free to drop.
export const IS_PRO = IS_PRO_BUILD;
// LITE (free) vs PRO. Two apks come out of this one tree: the PRO apk
// (src/provariant.ts, rewritten by tools/set-variant.mjs) is born owned, while
// the LITE apk is the free build and buys the tier through Google Play. The
// tier itself is runtime state, so it lives in src/utils/proUnlock.ts and every
// caller takes it from there (useProUnlock) or by prop.
// The gate is on the FILE, not on the preview: a LITE user composes with any
// look and compares it before/after — only the render/export of a PRO look is
+34 -4
View File
@@ -335,6 +335,24 @@ export async function processAndExportPhoto(
}
}
// Negative SHARPENING is the amount of digital edge enhancement to take
// back OUT. It runs HERE, inside the photo's own filter chain, because the
// post-grain twin (step 9) blurred the grain overlay away: NR < 0 plus
// SHARPENING < 0 exported as flat mush with no grain at all.
const sharpKnob = adjustments.sharpening ?? 0;
if (sharpKnob < 0) {
const softenSigma = (Math.abs(sharpKnob) / 10) * 3;
const softenFilter = Skia.ImageFilter.MakeBlur(
softenSigma,
softenSigma,
TileMode.Clamp,
imageFilter
);
if (softenFilter) {
imageFilter = softenFilter;
}
}
if (imageFilter) {
paint.setImageFilter(imageFilter);
}
@@ -423,8 +441,18 @@ export async function processAndExportPhoto(
// 6. Add Monochrome Grain Overlay
if (adjustments.grain > 0) {
const grainOpacity = adjustments.grain / 20; // up to 0.5 opacity
// NOISE REDUCTION below 0 works the other way round: it hands back the grain
// the denoiser would have flattened (a JPEG cannot be un-smoothed, but it
// can be re-grained). Same overlay as GRAIN, capped at the GRAIN slider's
// own ceiling so a negative NR plus GRAIN never double up.
// ponytail: re-grain, not de-noise; add a real grain-preserving NR the day
// the pipeline gets a pre-ISP raw frame.
const grainAmount = Math.min(
10,
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
);
if (grainAmount > 0) {
const grainOpacity = grainAmount / 20; // up to 0.5 opacity
const grainPaint = Skia.Paint();
grainPaint.setBlendMode(BlendMode.Overlay);
grainPaint.setAlphaf(grainOpacity);
@@ -732,8 +760,10 @@ export async function processAndExportPhoto(
let resultImage = outputSurface.makeImageSnapshot();
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
const sharpKnob = adjustments.sharpening ?? 0;
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0;
// A negative knob means "no sharpening at all" (its blur already ran at
// step 4, under the grain), so the legacy 0.5 fallback must not fire.
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
if (sharpenAmount > 0) {
resultImage = screenSharpenImage(resultImage, sharpenAmount);
}
+5 -2
View File
@@ -73,10 +73,11 @@ export async function processAndExportPhotoNative(
// (EXPO_PUBLIC_NATIVE_EXPORT is not inlined into a release HBC bundle).
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinema = getCinemaUniforms(recipe.cinema);
// Final screen sharpen (engine #9): the sharpening knob (0..10) overrides
// Final screen sharpen (engine #9): the sharpening knob (-10..10) overrides
// the legacy boolean sharpen flag (camera captures keep it true → 0.5).
const sharpKnob = adjustments.sharpening ?? 0;
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0;
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
const aspect: '4:3' | '3:2' | undefined =
options?.aspect && options.aspect !== 'full' && frameId !== 'wallframe'
? options.aspect
@@ -99,6 +100,8 @@ export async function processAndExportPhotoNative(
? { locality: gpsInfo.locality || 'STREET VIEW', timestamp: gpsInfo.timestamp }
: null,
sharpen: sharpenAmount > 0 ? sharpenAmount : undefined,
// Negative knob -> Kotlin soften blur, pre-grain (engine step 4).
soften: sharpKnob < 0 ? (Math.abs(sharpKnob) / 10) * 3 : undefined,
dpi: options?.dpi && options.dpi > 0 ? options.dpi : undefined,
aspect,
};
+2 -2
View File
@@ -134,7 +134,7 @@ export const PARAM_DEFS: {
{
key: 'denoise',
label: 'NOISE REDUCTION',
min: 0,
min: -10,
max: 10,
step: 1,
defaultValue: 0,
@@ -156,7 +156,7 @@ export const PARAM_DEFS: {
{
key: 'sharpening',
label: 'SHARPENING',
min: 0,
min: -10,
max: 10,
step: 1,
defaultValue: 0,
+111
View File
@@ -0,0 +1,111 @@
import { useSyncExternalStore } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
fetchProducts,
finishTransaction,
getAvailablePurchases,
initConnection,
purchaseErrorListener,
purchaseUpdatedListener,
requestPurchase,
restorePurchases,
} from 'expo-iap';
import { IS_PRO_BUILD } from '../provariant';
// One product buys the whole tier: PRO removes the mark and opens every gated
// look. Google Play owns the purchase — the app only reads it back.
export const PRO_SKU = 'recipescam_pro_unlock';
// Last known answer, so the app opens unlocked while the store query is still
// in flight (and offline). The store is the authority: it can revoke it below.
const CACHE_KEY = '@recipescam_pro:unlock';
export interface ProState {
owned: boolean;
price: string | null;
busy: boolean;
error: string | null;
}
// The PRO apk is sold as-is: that build is born owned and never talks to Play.
let state: ProState = { owned: IS_PRO_BUILD, price: null, busy: false, error: null };
const listeners = new Set<() => void>();
const set = (patch: Partial<ProState>) => {
state = { ...state, ...patch };
listeners.forEach((l) => l());
};
const subscribe = (l: () => void) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
};
export const getProState = () => state;
export const useProUnlock = () => useSyncExternalStore(subscribe, getProState);
const grant = async () => {
set({ owned: true, error: null });
await AsyncStorage.setItem(CACHE_KEY, '1').catch(() => {});
};
// Play hands the purchase to the listener. Grant it, then finalise: an Android
// purchase left unacknowledged for three days is automatically refunded.
// ponytail: the grant is client-side. Server-side verification of
// purchaseToken through the Play Developer API is the upgrade if piracy shows up.
const handlePurchase = async (purchase: { productId: string } & Record<string, unknown>) => {
if (purchase.productId !== PRO_SKU) return;
await grant();
set({ busy: false });
await finishTransaction({ purchase: purchase as never, isConsumable: false }).catch(() => {});
};
let started = false;
// Called once at startup. A sideloaded build, a phone with no Play account or a
// store hiccup must not take the app down: every failure leaves the tier LITE
// and lands in `error` for the paywall to show.
export async function initProUnlock() {
if (started || IS_PRO_BUILD) return;
started = true;
if ((await AsyncStorage.getItem(CACHE_KEY).catch(() => null)) === '1') set({ owned: true });
try {
await initConnection();
purchaseUpdatedListener((p) => handlePurchase(p as never));
purchaseErrorListener((e) => set({ busy: false, error: e.message }));
const [products, purchases] = await Promise.all([
fetchProducts({ skus: [PRO_SKU], type: 'in-app' }),
getAvailablePurchases(),
]);
const listed = (Array.isArray(products) ? products : []).find((p) => p.id === PRO_SKU);
set({ price: listed?.displayPrice ?? null });
if (purchases.some((p) => p.productId === PRO_SKU)) await grant();
} catch (e) {
set({ error: e instanceof Error ? e.message : 'Store unavailable' });
}
}
export async function buyPro() {
if (state.owned || state.busy) return;
set({ busy: true, error: null });
try {
// Event-based: the result arrives on purchaseUpdatedListener above.
await requestPurchase({ request: { google: { skus: [PRO_SKU] } }, type: 'in-app' });
} catch (e) {
set({ busy: false, error: e instanceof Error ? e.message : 'Purchase failed' });
}
}
export async function restorePro() {
if (IS_PRO_BUILD || state.busy) return;
set({ busy: true, error: null });
try {
await restorePurchases();
const purchases = await getAvailablePurchases();
if (purchases.some((p) => p.productId === PRO_SKU)) await grant();
set({ busy: false, error: purchases.some((p) => p.productId === PRO_SKU) ? null : 'No PRO purchase found on this account' });
} catch (e) {
set({ busy: false, error: e instanceof Error ? e.message : 'Restore failed' });
}
}