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
+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>
);