Ship LITE and PRO, and let CLARITY and LIGHT GLOW reach the library

Both flavors build from one JS tree: `lite` hands out the free apk
(com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label
"RecipesCam Pro"). Release is signed from android/keystore.properties, both
files gitignored - lose either and no later build can install over this one.
The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs
rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor
alone would ship a Pro apk that thinks it is Lite.

The dev scaffolding goes: the three src/dev probes and their App effects, the
EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix
side-by-side install hook. 'balanced' stays the shipped capture mode, which is
what those probes measured on the 12S Ultra.

Looking at the picture: WB now converts kelvin through the Planckian locus into
a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old
one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a
recipe loads every knob it carries over the defaults and stays editable, and
RESET hands the panel back to the sim's own values. The in-app library picker,
the ultra-wide native module and recipe share/import land here too.

CLARITY and LIGHT GLOW existed only on the camera worklet and the export
engine, so a photo opened from the library ignored both. They now run in all
three library preview branches (libPhoto), and the preview clips them to the
drawn image - the export engine always clipped its bloom to the canvas, the
preview drew it over the whole rect, so the bloom bled past the photo onto the
black letterbox and over the frame's mat.
This commit is contained in:
2026-09-14 19:48:44 +07:00
parent 99405514c6
commit 97949e449d
49 changed files with 2050 additions and 659 deletions
+12 -1
View File
@@ -108,6 +108,9 @@ interface AdjustmentPanelProps {
onToggleWallLandscape: () => void;
onToggleGeotag: (enabled: boolean) => void;
onSaveRecipe: (name: string) => void;
// IMPORT: pick a recipe file shared from another phone and store it as a
// normal custom recipe.
onImportRecipe: () => void;
onDeleteRecipe: (id: string) => void;
// WATERMARK tab state — lifted to App so the live Viewfinder overlay, this
// input and the export options all read the same object.
@@ -171,7 +174,7 @@ const WB_PAIRS: Record<string, { t: number; ti: number }> = {
daylight: { t: 5500, ti: 0 },
'daylight-3r': { t: 5500, ti: -3 },
cloudy: { t: 6500, ti: 1 },
shade: { t: 8000, ti: 2 },
shade: { t: 7500, ti: 2 },
tungsten: { t: 3200, ti: 0 },
fluorescent: { t: 4000, ti: 3 },
};
@@ -239,6 +242,7 @@ export default function AdjustmentPanel({
wallLandscape,
onToggleWallLandscape,
onToggleGeotag,
onImportRecipe,
onDeleteRecipe,
customWm,
onUpdateCustomWm,
@@ -791,6 +795,12 @@ export default function AdjustmentPanel({
active: false,
onPress: () => setCreateVisible(true),
},
{
key: 'import',
label: 'IMPORT',
active: false,
onPress: onImportRecipe,
},
],
8
);
@@ -1024,6 +1034,7 @@ export default function AdjustmentPanel({
<RecipeCreateModal
visible={createVisible || !!editingRecipe}
currentBase={currentBaseFilter}
current={adjustments}
initial={editingRecipe ?? null}
onClose={() => {
setCreateVisible(false);
+11
View File
@@ -6,6 +6,10 @@ import { Image as ImageIcon, Eye, Download, Camera, Settings, RefreshCw } from '
interface CameraControlsProps {
lastPhotoUri: string | null;
onCapture: () => void;
// Held shutter = burst: fire on long-press, stop when the finger lifts.
// Absent handlers (library mode) leave the button a plain tap.
onBurstStart?: () => void;
onBurstStop?: () => void;
onPickImage: () => void;
onOpenPreview: () => void;
onOpenSettings: () => void;
@@ -17,6 +21,8 @@ interface CameraControlsProps {
export default function CameraControls({
lastPhotoUri,
onCapture,
onBurstStart,
onBurstStop,
onPickImage,
onOpenPreview,
onOpenSettings,
@@ -55,6 +61,11 @@ export default function CameraControls({
{/* Dynamic Center Button: Shutter or Save */}
<TouchableOpacity
onPress={onCapture}
// Pressability drops onPress once onLongPress has fired, so releasing a
// hold never appends a stray single shot after the burst.
onLongPress={onBurstStart}
onPressOut={onBurstStop}
delayLongPress={300}
className="relative items-center justify-center"
activeOpacity={0.8}
>
+46 -16
View File
@@ -26,6 +26,10 @@ interface RecipeCreateModalProps {
// Id of the currently applied film sim (or bundled recipe whose look should
// prefill the SIMULATION row); undefined → default to PROVIPES.
currentBase?: BaseFilter;
// The look live on screen. CREATE opens on THESE values (not on the sim's
// standard ones) so a recipe is a snapshot of what the user is looking at;
// every knob the form has no row for rides along on SAVE.
current?: ColorAdjustments | null;
// When set the form opens as an EDITOR: every row (name included) is
// prefilled from this recipe instead of the sim's standard values, and
// SAVE hands the edited copy back for the caller to store.
@@ -144,16 +148,24 @@ const NumRow = ({ label, value, onChange, placeholder, width = 58 }: NumRowProps
export default function RecipeCreateModal({
visible,
currentBase,
current,
initial,
onClose,
onSave,
}: RecipeCreateModalProps) {
const insets = useSafeAreaInsets();
// Seed from the current film sim (or PROVIPES) so "other params" start at the
// sim's standard values and the user only types what they want to change.
// Seed from what the user is LOOKING AT: the recipe being edited, else the live
// adjustments, else the current sim's standard values. Seeding from the sim's
// standard values alone (the old behaviour) meant the form showed — and SAVE
// wrote — neutral numbers for knobs the user had already dialled in.
const seedBase: BaseFilter =
currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia';
const seedAdj = FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS;
initial?.baseFilter ??
(currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia');
const seedAdj: ColorAdjustments =
initial?.adjustments ??
current ??
FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ??
DEFAULT_ADJUSTMENTS;
// Every sim's standard values as form state — used to seed the modal and to
// reload the form when the SIMULATION chip changes.
@@ -161,13 +173,19 @@ export default function RecipeCreateModal({
const s = src ?? FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS;
return {
dr: String(s.dynamicRange ?? 'auto'),
grain: String(s.grain === 0 ? 0 : s.grain >= 6 ? 6 : 3),
// Raw, not rounded to the three chips: a look sitting on grain 2 must not
// become 3 just because the form was opened (and the chip row below shows
// that value as its own chip when it is none of OFF/WEAK/STRONG).
grain: String(s.grain ?? 0),
cc: s.colorChrome ?? 'none',
ccb: s.colorChromeBlue ?? 'none',
// 'custom' = a WB the six chips do not name (a hand-set 6300K, say). It
// keeps its own chip and its own value; the old AUTO fallback rewrote the
// recipe's WB to 5500K on every save.
wbKey: WB_KEYS.find((k) => {
const p = WB_PRESETS.find((w) => w.key === k)!;
return p.temperature === s.temperature && p.tint === s.tint;
}) ?? 'auto',
}) ?? 'custom',
highlight: String(s.highlight),
shadow: String(s.shadow),
color: String(s.saturation),
@@ -181,14 +199,14 @@ export default function RecipeCreateModal({
const [sim, setSim] = useState<BaseFilter>(seedBase);
const [name, setName] = useState('');
const [dr, setDr] = useState<string>(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange));
const [grain, setGrain] = useState<string>(String(seedAdj.grain === 0 ? 0 : seedAdj.grain >= 6 ? 6 : 3));
const [grain, setGrain] = useState<string>(String(seedAdj.grain ?? 0));
const [cc, setCc] = useState<string>(seedAdj.colorChrome ?? 'none');
const [ccb, setCcb] = useState<string>(seedAdj.colorChromeBlue ?? 'none');
const [wbKey, setWbKey] = useState<string>(
WB_KEYS.find((k) => {
const p = WB_PRESETS.find((w) => w.key === k)!;
return p.temperature === seedAdj.temperature && p.tint === seedAdj.tint;
}) ?? 'auto'
}) ?? 'custom'
);
const [wbRed, setWbRed] = useState('0');
const [wbBlue, setWbBlue] = useState('0');
@@ -208,15 +226,15 @@ export default function RecipeCreateModal({
// Editing a stored recipe wins over the live sim seed: the form must show
// that recipe's own values, not the sim's standard ones.
const base = initial?.baseFilter ?? seedBase;
const v = valuesOf(base, initial?.adjustments);
const v = valuesOf(base, seedAdj);
setSim(base);
setDr(v.dr);
setGrain(v.grain);
setCc(v.cc);
setCcb(v.ccb);
setWbKey(v.wbKey);
setWbRed(String(initial?.adjustments.wbRed ?? 0));
setWbBlue(String(initial?.adjustments.wbBlue ?? 0));
setWbRed(String(seedAdj.wbRed ?? 0));
setWbBlue(String(seedAdj.wbBlue ?? 0));
setFHigh(v.highlight);
setFShadow(v.shadow);
setFColor(v.color);
@@ -250,13 +268,19 @@ export default function RecipeCreateModal({
};
const handleSave = () => {
const wb = WB_PRESETS.find((w) => w.key === wbKey) ?? WB_PRESETS[0];
const wb = WB_PRESETS.find((w) => w.key === wbKey);
const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS;
// The base is what the user was looking at (CREATE) or the recipe being
// edited. Spreading the sim's neutral values here instead — the old
// behaviour — silently zeroed every knob the form has no row for: exposure,
// contrast, vibrance, vignetting, glow, the WB red/blue shift. That is what
// made a saved recipe apply a different look from the one on screen.
const adj: ColorAdjustments = {
...DEFAULT_ADJUSTMENTS,
...simAdj,
temperature: wb.temperature,
tint: wb.tint,
...seedAdj,
// A WB with no preset of its own keeps the value it was seeded with.
temperature: wb ? wb.temperature : seedAdj.temperature,
tint: wb ? wb.tint : seedAdj.tint,
wbRed: int(wbRed, 0, -9, 9),
wbBlue: int(wbBlue, 0, -9, 9),
highlight: int(fHigh, simAdj.highlight, -10, 10),
@@ -265,7 +289,7 @@ export default function RecipeCreateModal({
denoise: int(fNr, simAdj.denoise, 0, 10),
sharpening: int(fSharp, simAdj.sharpening, 0, 10),
clarity: int(fClarity, simAdj.clarity, -10, 10),
grain: parseInt(grain, 10),
grain: int(grain, seedAdj.grain, 0, 10),
colorChrome: cc as ColorAdjustments['colorChrome'],
colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'],
dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'],
@@ -354,6 +378,9 @@ export default function RecipeCreateModal({
{GRAIN_OPTS.map((o) => (
<Chip key={o.v} label={o.d} active={grain === o.v} onPress={() => setGrain(o.v)} />
))}
{!GRAIN_OPTS.some((o) => o.v === grain) && (
<Chip label={grain} active onPress={() => {}} />
)}
</ChipRow>
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT</Text>
<ChipRow>
@@ -375,6 +402,9 @@ export default function RecipeCreateModal({
{WB_PRESETS.map((w) => (
<Chip key={w.key} label={w.label} active={wbKey === w.key} onPress={() => setWbKey(w.key)} />
))}
{wbKey === 'custom' && (
<Chip label={`${seedAdj.temperature}K`} active onPress={() => setWbKey('custom')} />
)}
</ChipRow>
<View className="flex-row items-center mt-1">
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
+24
View File
@@ -31,6 +31,8 @@ interface SettingsModalProps {
onMeterModeChange: (mode: MeterMode) => void;
shutterSound: ShutterSound;
onShutterSound: (sound: ShutterSound) => void;
touchToShutter: boolean;
onTouchToShutter: (enabled: boolean) => void;
gpsEnabled: boolean;
onGpsEnabled: (enabled: boolean) => void;
startupMode: StartupMode;
@@ -95,6 +97,8 @@ export default function SettingsModal({
onMeterModeChange,
shutterSound,
onShutterSound,
touchToShutter,
onTouchToShutter,
gpsEnabled,
onGpsEnabled,
startupMode,
@@ -200,6 +204,26 @@ export default function SettingsModal({
</View>
</View>
{/* Touch to shutter */}
<View>
<SectionLabel>TOUCH TO SHUTTER</SectionLabel>
<ChipRow
chips={[
cameraMode
? { key: 'tts-on', label: 'ON', active: touchToShutter, onPress: () => onTouchToShutter(true) }
: { key: 'tts-on', label: 'ON', active: touchToShutter, disabled: true, onPress: () => {} },
cameraMode
? { key: 'tts-off', label: 'OFF', active: !touchToShutter, onPress: () => onTouchToShutter(false) }
: { key: 'tts-off', label: 'OFF', active: !touchToShutter, disabled: true, onPress: () => {} },
]}
/>
<Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">
Tap the live image to lock focus on that point, then take the photo the moment focus settles.
OFF: taps only focus.
</Text>
<CameraOnlyNote cameraMode={cameraMode} />
</View>
{/* Composition ratio + RAW */}
<View>
<SectionLabel>PHOTO RATIO</SectionLabel>
+23 -6
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native';
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 * as Haptics from 'expo-haptics';
@@ -13,9 +13,12 @@ interface TopBarProps {
// Also custom-only: opens the recipe editor — the CREATE form prefilled
// with this recipe's parameters and name.
onEditRecipe?: () => void;
// 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;
}
export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteRecipe, onEditRecipe }: TopBarProps) {
export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteRecipe, onEditRecipe, onExportShare }: TopBarProps) {
const insets = useSafeAreaInsets();
const [modalVisible, setModalVisible] = useState(false);
const [newRecipeName, setNewRecipeName] = useState('');
@@ -27,6 +30,20 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci
setModalVisible(true);
};
// SAVE & SHARE: two ways out of the same look — keep it as a recipe on this
// phone, or export it as a file another phone can import.
const openSaveMenu = () => {
Alert.alert(
'SAVE & SHARE',
'Save the look on this screen as a recipe, or export it as a file to share.',
[
{ text: 'CANCEL', style: 'cancel' },
{ text: 'EXPORT & SHARE', onPress: () => onExportShare(recipeName) },
{ text: 'SAVE RECIPE', onPress: openModal },
],
);
};
const run = (fn: (name: string) => void) => {
const name = newRecipeName.trim();
if (!name) return;
@@ -68,13 +85,13 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci
</TouchableOpacity>
)}
<TouchableOpacity
onPress={openModal}
className="flex-row items-center rounded-full border border-zinc-700/80 bg-black/45 px-4 py-2.5"
onPress={openSaveMenu}
className="flex-row items-center rounded-full border border-zinc-700/80 bg-black/45 px-3 py-2.5"
activeOpacity={0.7}
>
<Plus size={14} color="#f59e0b" />
<Text className="ml-1 font-mono text-xs font-bold tracking-widest text-amber-500">
SAVE
<Text className="ml-1 font-mono text-[10px] font-bold tracking-wider text-amber-500">
SAVE & SHARE
</Text>
</TouchableOpacity>
</View>
+399 -162
View File
@@ -4,6 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake';
import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera';
import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia';
import UltraWide, { UltraWidePreview } from '../../modules/recipescam-ultrawide';
import { createSynchronizable } from 'react-native-worklets';
import {
Canvas,
@@ -18,6 +19,7 @@ import {
Rect,
RadialGradient,
ColorMatrix,
Blur,
Image as SkiaImage,
ImageShader,
BlendMode,
@@ -29,7 +31,7 @@ import {
import { Lock } from 'lucide-react-native';
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, MIN_CROP_FRAC } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
import { TONE_SKSL, getToneUniforms, toneUniformArray } from '../utils/toneShader';
import { TONE_SKSL, getToneUniforms, toneUniformArray, GLOW_SKSL, glowUniformArray, GLOW_UNIFORMS, CLARITY_SKSL, clarityUniforms } from '../utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils';
import { applyPhotoRotation } from '../utils/skiaImage';
@@ -111,6 +113,9 @@ interface ViewfinderProps {
// undone; the finger coming off asks for the edited look back, so the user
// can flick between the before and the after. Absent prop = feature off.
onComparePeek?: (on: boolean) => void;
// Touch-to-shutter (gear sheet): a plain tap focuses as always, then fires a
// capture once the focus promise settles. Absent prop = tap only focuses.
onTouchShutter?: () => void;
// Custom text watermark (WATERMARK tab -> CUSTOM chip). Drawn live on the
// image like the GPS mark; dragging/tapping the image moves it. Fractions are
// 0..1 of the photo/window area. Absent prop = feature off.
@@ -157,6 +162,12 @@ export interface ViewfinderHandle {
* exactly the crop the user framed after pinch/drag.
*/
getFrameWindowZoom(): { s: number; u: number; v: number } | null;
/**
* True while the 0.5x chip holds the native ultra-wide lens. The CameraX
* session is off then, so App must capture through the native module instead
* of `photoOutput`.
*/
isUltraWide(): boolean;
/**
* FRAME-tab CROP with a fixed ratio: the part of the photo the on-screen
* keep-band shows, as 0..1 fractions of the (post-rotation) source image, so
@@ -205,6 +216,29 @@ const cinemaEffect = (() => {
}
})();
// Bright Pass Filter effect for the HDF glow (GLOW_SKSL) — hoisted with the
// two above so the preview worklet can capture it instead of compiling per
// frame.
const glowEffect = (() => {
try {
return Skia.RuntimeEffect.Make(GLOW_SKSL);
} catch (e) {
console.error('GLOW_SKSL compile failed: ' + e);
return null;
}
})();
// Unsharp twin of the camera worklet's MakeMatrixConvolution (see CLARITY_SKSL)
// for the library preview, which draws through the declarative tree instead.
const clarityEffect = (() => {
try {
return Skia.RuntimeEffect.Make(CLARITY_SKSL);
} catch (e) {
console.error('CLARITY_SKSL compile failed: ' + e);
return null;
}
})();
// Plain numeric primitives (captured by value into the preview worklet — the
// worklet runtime can't read RNSkia's enum namespace objects directly).
const T_CLAMP = TileMode.Clamp;
@@ -237,6 +271,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
meteringAdjustments,
imageAdjustTarget,
onComparePeek,
onTouchShutter,
customWm = { enabled: false, text: '', x: 0.5, y: 0.5 },
onWmPositionChange,
gpsWm,
@@ -426,12 +461,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Clarity (-10..10): local unsharp mask / mist, the same numbers exportEngine
// uses, so the live preview and the exported file agree. 0 = no filter.
const claritySync = useMemo(
() => createSynchronizable<number>(adjustments.clarity ?? 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);
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, colorMatrixSync, toneSync, cinemaSync, hdfSync]);
claritySync.setBlocking(adjustments.clarity ?? 0);
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync]);
// Camera controller lives behind SkiaCamera. The camera AE bias is never
// written: EV (user + metering offset) is purely software matrix gain, for
@@ -500,13 +543,123 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
useEffect(() => {
zoomRef.current = zoomRatio;
}, [zoomRatio]);
const applyZoom = useCallback((ratio: number) => {
setZoomRatio(ratio);
const ctl = skiaCameraRef.current?.controller;
if (ctl) {
ctl.setZoom(ratio).catch((e) => console.warn('setZoom failed', String(e).slice(0, 120)));
}
// The back ultra-wide lens is a hidden Camera2 id (see the native module), so
// 0.5x is not a CameraX zoom ratio: the chip swaps the live preview over to a
// native Camera2 session on that lens. Only the back side has one.
const [ultraWideId, setUltraWideId] = useState('');
const [ultraWideOn, setUltraWideOn] = useState(false);
const ultraWideOnRef = useRef(false);
ultraWideOnRef.current = ultraWideOn;
useEffect(() => {
let alive = true;
UltraWide.availableAsync()
.then((id) => {
if (alive) setUltraWideId(id || '');
})
.catch((e) => console.warn('Ultra-wide probe failed', String(e).slice(0, 120)));
return () => {
alive = false;
};
}, []);
const ultraWideReady = ultraWideId !== '' && facing === 'back';
const ultraWideReadyRef = useRef(false);
ultraWideReadyRef.current = ultraWideReady;
// Handoff cover. The two back lenses cannot be open at once, so leaving 0.5x
// closes the native session first and the 1x preview only then reopens: for
// ~1s the viewfinder shows a frozen stale frame and then the reopening
// session's out-of-focus, shifted first frames. The ultra-wide keeps drawing
// its own last frame over that instead (the native `cover` prop), so the swap
// reads as one clean cut from 0.5x to 1x.
const [uwCover, setUwCover] = useState(false);
const uwCoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const uwFramePoll = useRef<ReturnType<typeof setInterval> | null>(null);
const stopCoverTimer = useCallback(() => {
if (uwCoverTimer.current) clearTimeout(uwCoverTimer.current);
uwCoverTimer.current = null;
if (uwFramePoll.current) clearInterval(uwFramePoll.current);
uwFramePoll.current = null;
}, []);
const holdCover = useCallback(
(ms?: number) => {
stopCoverTimer();
if (ms == null) {
setUwCover(true);
return;
}
uwCoverTimer.current = setTimeout(() => {
uwCoverTimer.current = null;
setUwCover(false);
}, ms);
},
[stopCoverTimer]
);
useEffect(() => stopCoverTimer, [stopCoverTimer]);
// Bumped on every lens handoff: a slow release belonging to an older one must
// not touch the state a newer zoom choice already owns.
const uwHandoff = useRef(0);
const applyZoom = useCallback(
(ratio: number) => {
const native = ratio === 0.5 && ultraWideReadyRef.current;
// The reticle points at a region of a lens that is about to go away.
if (native !== ultraWideOnRef.current) setFocusPoint(null);
setZoomRatio(ratio);
if (!native && ultraWideOnRef.current) {
// Hold the outgoing 0.5x frame until the main preview really paints
// again, instead of a fixed 700ms: measured on this device the fresh
// 1x session lands anywhere from 0.2s to 0.9s after the tap, so the
// old ceiling left a black hole when it ran slow and a stale frame
// when it ran fast. The frame beat advances on every delivered frame
// of the main camera, so it is the honest "1x is live" signal.
holdCover(1500); // ceiling: never keep the stale frame up for good
const beatAtSwap = frameBeatSync.getDirty();
uwFramePoll.current = setInterval(() => {
if (frameBeatSync.getDirty() - beatAtSwap < 3) return;
stopCoverTimer();
setUwCover(false);
}, 50);
// The main session is only switched back on once the native lens has
// actually let go of the back camera slot. Activating it earlier makes
// CameraX open the 1x while the HAL still holds that lens, and the
// refused open is retried on a fixed ~520ms timeout - the bulk of the
// handoff time.
const handoff = ++uwHandoff.current;
UltraWide.closeAsync()
.catch(() => {})
.then(() => {
if (uwHandoff.current !== handoff) return;
setUltraWideOn(false);
});
} else {
uwHandoff.current++; // a release still in flight belongs to the past now
setUltraWideOn(native);
if (native) {
holdCover();
// Back to 0.5x while the previous handoff was still releasing the
// lens: it is closed by now, so ask for it back.
if (ultraWideOnRef.current) UltraWide.reopenAsync().catch(() => {});
}
}
// The native lens is a different camera: the main session is off while
// it is live, so never carry the 0.5 over as a zoom ratio onto it.
zoomRef.current = native ? 1 : ratio;
const ctl = skiaCameraRef.current?.controller;
if (ctl && !native) {
ctl.setZoom(ratio).catch((e) => console.warn('setZoom failed', String(e).slice(0, 120)));
}
},
[holdCover, stopCoverTimer]
);
// 0.5x is a chip wherever an ultra-wide can actually be shown: the hidden
// Camera2 lens above, or a bound CameraX device whose minZoomRatio reaches
// below 1 (a logical multi-camera with the ultra-wide wired in). A plain
// single-lens device reports 1 (and 0.0 before the session binds, which is
// why the gate is `> 0`), and setZoom rejects anything below min.
const zoomPresets = useMemo(() => {
const base = [1, 2, 3, 5, 10];
const min = device?.minZoom ?? 1;
if (ultraWideReady) return [0.5, ...base];
return min > 0 && min <= 0.5 ? [0.5, ...base] : base;
}, [device, ultraWideReady]);
// Re-apply after the camera session restarts (background resume, camera↔
// library switch): a fresh session starts back at 1x.
useEffect(() => {
@@ -520,7 +673,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
useEffect(() => {
zoomRef.current = 1;
setZoomRatio(1);
}, [facing]);
setUltraWideOn(false);
stopCoverTimer();
setUwCover(false);
}, [facing, stopCoverTimer]);
// The quick-EV bubble/drag is the recipe's OWN EV: `adjustments` above also
// carries the metering-mode dip (highlight-weighted), so reading it here
// showed EV -0.5 while the LIGHT tab slider sat at 0 — and a drag then wrote
@@ -534,6 +690,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
getFrameWindowZoom() {
return frameZoomRef.current;
},
isUltraWide() {
return ultraWideOnRef.current;
},
getCropRatioRect() {
return cropRatioRectRef.current;
},
@@ -553,6 +712,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// preview (lift highlights -> AE underexposes -> looks unchanged), which is
// why HL/SH edits read as "no effect" on the live preview.
const handleTapFocus = async (x: number, y: number) => {
// 0.5x is a native lens whose CameraX session is stopped, so focusTo()
// has no camera behind it and the tap has to go to the module.
if (ultraWideOnRef.current) {
setFocusPoint({ x, y });
try {
await UltraWide.focusAsync(x, y);
} catch (e) {
console.warn('Ultra-wide focus failed: ' + String(e).slice(0, 120));
}
return;
}
if (!cameraActive) return;
const cam = skiaCameraRef.current;
const d = cam?.controller?.device;
@@ -572,6 +742,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
};
const handleUnlockFocus = async () => {
if (ultraWideOnRef.current) {
setFocusPoint(null);
try {
await UltraWide.resetFocusAsync();
} catch (e) {
console.warn('Ultra-wide focus reset failed: ' + String(e).slice(0, 120));
}
return;
}
const cam = skiaCameraRef.current;
if (!cam) return;
try {
@@ -865,6 +1044,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
g.lastSent = rounded;
g.param.onChange(rounded);
};
// Latest prop through a ref: the responder props read it on every event, but
// a ref keeps a stale closure from ever firing a capture after the toggle
// was switched off mid-gesture.
const onTouchShutterRef = useRef(onTouchShutter);
onTouchShutterRef.current = onTouchShutter;
const onImageDragEnd = (e: any) => {
const g = imgDragRef.current;
imgDragRef.current = null;
@@ -890,7 +1074,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
if (!g.moved && !peeked) {
// Plain tap: lock focus at the tapped point (drag never re-locks). A
// hold-to-compare is not a tap either.
handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY);
// Touch-to-shutter chains the capture AFTER the focus promise: focusTo()
// only resolves once focus has fully settled (rejects on timeout/cancel),
// so waiting on it is exactly "focus first, then shoot" — no timer.
void handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY).then(() =>
onTouchShutterRef.current?.()
);
}
};
@@ -944,13 +1133,48 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
} catch (e) {
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.
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,
T_CLAMP,
null
);
if (clarityFilter != null) paint.setImageFilter(clarityFilter);
}
} catch (e) {
console.warn('Clarity filter build failed: ' + e);
}
const tone = toneSync.getDirty();
const cinema = cinemaSync.getDirty();
const hasTone =
toneEffect != null &&
(tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0 ||
tone[4] !== 0 || tone[5] !== 0 || tone[6] !== 0 ||
tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0);
tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0 ||
tone[10] !== 0 || tone[11] !== 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
@@ -985,45 +1209,67 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
} else {
canvas.drawImage(frameTexture, 0, 0, paint);
}
// HDF: mirror of export pass 5b — extract highlights with a hard
// contrast curve, blur that selection, Screen it back on top. Screen
// against near-black is a no-op, so shadows stay put. Same source
// (shader or image) the base draw used, so the glow sits on processed
// pixels. Radius is a fraction of width so preview/export bloom alike.
// HDF: mirror of export pass 5b — Bright Pass Filter on the LUMINANCE,
// blurred, Screened back on top. Below the knee the pass returns exact
// 0.0 and Screen against black is a no-op, so the shadows stay put
// while only the lit areas spread their light. Same source (shader or
// image) the base draw used, so the glow sits on processed pixels.
// Radius is a fraction of width so preview/export bloom alike.
const hdfV = hdfSync.getDirty();
if (hdfV > 0) {
const strength = hdfV / 10;
const width = frameTexture.width();
const sigma = width * (0.004 + 0.015 * strength);
const hiMatrix = [
2.5, 0, 0, 0, -1.5,
0, 2.5, 0, 0, -1.5,
0, 0, 2.5, 0, -1.5,
0, 0, 0, 1, 0,
];
const hiFilter = Skia.ColorFilter.MakeMatrix(hiMatrix);
// Order is load-bearing (see exportEngine pass 5b): chain both as
// image filters so the highlight selection runs BEFORE the blur.
const selection = Skia.ImageFilter.MakeColorFilter(
baseColorFilter != null
? Skia.ColorFilter.MakeCompose(hiFilter, baseColorFilter)
: hiFilter,
null
);
const srcShader =
fullShader != null
? fullShader
: frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
const glowShader =
glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null;
const glowPaint = Skia.Paint();
glowPaint.setBlendMode(BlendMode.Screen);
glowPaint.setAlphaf(0.15 + 0.35 * strength);
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
selection
)
);
if (fullShader != null) {
glowPaint.setShader(fullShader);
if (glowShader != null) {
glowPaint.setShader(glowShader);
// Recipe matrix stays where it always was — inside the filter
// chain, under the blur — so the bloom lands on GRADED pixels
// (a monochrome recipe glows white, not colour).
const glowBlur = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null);
glowPaint.setImageFilter(
baseColorFilter != null
? Skia.ImageFilter.MakeCompose(glowBlur, Skia.ImageFilter.MakeColorFilter(baseColorFilter, null))
: glowBlur
);
canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint);
} else {
canvas.drawImage(frameTexture, 0, 0, glowPaint);
// GLOW_SKSL unavailable: old per-channel curve (composed on TOP of
// the recipe matrix, the order that keeps a monochrome look white).
const hiMatrix = [
2.5, 0, 0, 0, -1.5,
0, 2.5, 0, 0, -1.5,
0, 0, 2.5, 0, -1.5,
0, 0, 0, 1, 0,
];
// Order is load-bearing (see exportEngine pass 5b): chain both as
// image filters so the highlight selection runs BEFORE the blur.
const selection = Skia.ImageFilter.MakeColorFilter(
baseColorFilter != null
? Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), baseColorFilter)
: Skia.ColorFilter.MakeMatrix(hiMatrix),
null
);
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
selection
)
);
if (fullShader != null) {
glowPaint.setShader(fullShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint);
} else {
canvas.drawImage(frameTexture, 0, 0, glowPaint);
}
}
}
};
@@ -1144,7 +1390,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
});
frame.dispose();
},
[colorMatrixSync, toneSync, cinemaSync, hdfSync, toneFailLogged, feedRectSync, frameBeatSync]
[colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, toneFailLogged, feedRectSync, frameBeatSync]
);
const grainOpacity = adjustments.grain / 20;
@@ -1456,7 +1702,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
stallTicks = 0;
}
// Backgrounding pauses the session on purpose — never watchdog that.
if (!startedOnceRef.current || !appActiveRef.current) return;
if (!startedOnceRef.current || !appActiveRef.current || ultraWideOnRef.current) return;
// Two stale ticks give an in-flight (flip/resume/remount) restart time
// to land.
if (stallTicks < 2) return;
@@ -2409,7 +2655,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
ref={skiaCameraRef}
style={StyleSheet.absoluteFill}
device={device}
isActive={appActive}
isActive={appActive && !ultraWideOn}
outputs={cameraOutputs}
pixelFormat="yuv"
targetResolution={frameTarget}
@@ -2418,6 +2664,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
onFrame={handleFrame}
onError={onSessionError}
/>
{/* 0.5x: a native Camera2 session on the hidden ultra-wide lens, drawn
over the (stopped) SkiaCamera feed. The main session has to stay
mounted but inactive — the two lenses are mutually exclusive, so
turning one on means turning the other off, never both at once. */}
<UltraWidePreview
style={StyleSheet.absoluteFill}
active={appActive && ultraWideOn}
cover={uwCover}
/>
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
the open panel parameter (or EV while AE is locked). Sits below the
quick-EV strip, which keeps its own narrower direct-drag handle. */}
@@ -2484,18 +2739,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
)}
{renderCameraOverlays()}
{/* Native zoom presets — snap to a controller zoom ratio. 1x is the
only optical stop on the main back lens; 2x-10x are digital crop
(CameraX exposes no tele/ultra-wide logical device here). Row sits
main lens; 0.5x appears only on a device whose CameraX zoom range
reaches below 1 (ultra-wide), and 2x-10x are digital crop. Row sits
under the TopBar, clear of the AE-lock chip (top-right) and the EV
strip (right edge). */}
{controller && (
strip (right edge). uwCover keeps the row mounted across the
0.5x handoff: the 1x session has not restarted yet, so
`controller` is still undefined and the row used to blink. */}
{(controller || ultraWideOn || uwCover) && (
<View
pointerEvents="box-none"
className="absolute left-0 right-0 items-center"
style={{ top: insets.top + 62 }}
>
<View className="flex-row rounded-full border border-zinc-700/70 bg-black/50 p-1">
{[1, 2, 3, 5, 10].map((z) => (
{zoomPresets.map((z) => (
<TouchableOpacity
key={z}
onPress={() => applyZoom(z)}
@@ -2534,10 +2791,98 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
hlTr: toneParams[7],
hlTg: toneParams[8],
hlTb: toneParams[9],
cc: toneParams[10],
ccb: toneParams[11],
};
const cinemaOn = cinemaEffect != null && !!cinemaParams;
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
// ---- CLARITY + LIGHT GLOW on the LIBRARY preview -----------------------
// The camera worklet and exportEngine both have these two passes; the three
// library branches below draw the photo declaratively and had neither, so a
// photo opened from the library ignored both knobs. Same maths as the camera:
// `sx`/`sy` turn one ORIGINAL image pixel into canvas units (the library
// preview is laid out in dp, the camera surface is in sensor px), keeping the
// unsharp radius and the bloom sigma identical to the file.
const libClarity = adjustments.clarity ?? 0;
const libGlow = (adjustments.hdf ?? 0) / 10;
const libShaderPath = useShaderPass || (libClarity > 0 && clarityEffect != null);
const libPhoto = (
r: { x: number; y: number; w: number; h: number },
fit: 'cover' | 'fill' | 'contain'
) => {
const iw = skiaImage ? skiaImage.width() : r.w;
const ih = skiaImage ? skiaImage.height() : r.h;
// cover/contain scale uniformly (max/min), fill stretches each axis.
const sw = r.w / iw;
const sh = r.h / ih;
const uniform = fit === 'cover' ? Math.max(sw, sh) : fit === 'contain' ? Math.min(sw, sh) : null;
const sx = uniform ?? sw;
const sy = uniform ?? sh;
const rectNode = rect(r.x, r.y, r.w, r.h);
let graded: React.ReactNode = <ImageShader image={skiaImage} fit={fit} tx="clamp" ty="clamp" rect={rectNode} />;
if (toneEffect != null && toneOn) {
graded = <Shader source={toneEffect} uniforms={toneUniforms}>{graded}</Shader>;
}
if (cinemaOn) {
graded = <Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>{graded}</Shader>;
}
// The Rect must cover exactly the photo area, never the full canvas: a
// fullscreen ImageShader with fit="contain" clamps the photo edges across
// the black letterbox (reads as "the background is a zoomed copy").
let photo: React.ReactNode = libShaderPath ? (
<Rect x={r.x} y={r.y} width={r.w} height={r.h}>
{libClarity > 0 && clarityEffect != null ? (
<Shader source={clarityEffect} uniforms={clarityUniforms((libClarity / 10) * 0.8, sx, sy)}>
{graded}
</Shader>
) : (
graded
)}
</Rect>
) : (
<SkiaImage image={skiaImage} fit={fit} rect={rectNode} />
);
// Mist (negative clarity) = export pass 4's blur; sigma in original px.
if (libClarity < 0) {
photo = (
<Group>
<Blur blur={Math.abs(libClarity / 10) * 4 * sx} mode="clamp" />
{photo}
</Group>
);
}
// Export clips the bloom and the mist to the canvas, which IS the photo.
// The preview must clip too: the HDF glow's blur (and negative clarity's)
// otherwise bleeds past the photo onto the black letterbox and over the
// frame's mat. Clip to the drawn image, not to the rect: on "contain" the
// rect is wider than the picture.
const cw = Math.min(r.w, iw * sx);
const ch = Math.min(r.h, ih * sy);
const photoClip = rect(r.x + (r.w - cw) / 2, r.y + (r.h - ch) / 2, cw, ch);
return (
<Group clip={photoClip}>
{photo}
{/* HDF bloom, the export pass 5b twin: bright pass on the luminance,
blur, Screen back at 0.15..0.5 x strength. Radius is a fraction of
the drawn width, so any image size blooms like the camera does. */}
{libGlow > 0 && glowEffect != null ? (
<Rect
x={r.x}
y={r.y}
width={r.w}
height={r.h}
blendMode="screen"
opacity={0.15 + 0.35 * libGlow}
>
<Blur blur={cw * (0.004 + 0.015 * libGlow)} mode="clamp" />
<Shader source={glowEffect} uniforms={GLOW_UNIFORMS}>{graded}</Shader>
</Rect>
) : null}
</Group>
);
};
// RETRO INSTANT (library): the whole picture is contain-fitted into the
// card's photo window — nothing is masked or cropped, and the card is
// centered on the black background like a physical print. The window keeps
@@ -2556,33 +2901,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<Group clip={winRect}>
<Group matrix={libZoomMatrix}>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
{cinemaOn ? (
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={toneUniforms}
>
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : (
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
)}
</Shader>
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={toneUniforms}
>
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : null}
</Rect>
) : (
<SkiaImage image={skiaImage} fit={polaroidFit} rect={winRect} />
)}
{libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, polaroidFit)}
</Group>
</Group>
{grainOpacity > 0 && noiseEffect && (
@@ -2640,33 +2959,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<Group clip={winRect}>
<Group matrix={libZoomMatrix}>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
{cinemaOn ? (
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={toneUniforms}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : (
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
)}
</Shader>
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={toneUniforms}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : null}
</Rect>
) : (
<SkiaImage image={skiaImage} fit="cover" rect={winRect} />
)}
{libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, 'cover')}
</Group>
</Group>
{grainOpacity > 0 && noiseEffect && (
@@ -2709,66 +3002,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<Group matrix={viewZoomMatrix}>
<Group matrix={libViewMatrix}>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
// DR/Highlight/Shadow tone + Cinema seasonal grade, both live
// over the photo. The Rect must cover exactly the contain-fitted
// photo area (not the full canvas): a fullscreen ImageShader
// with fit="contain" clamps/smears the photo edges across the
// black letterbox (user-visible as "the background is a zoomed
// copy of the photo"). Cinema samples the tone output when both
// are on (chain: cinema -> tone -> image), matrix applies after.
<Rect
x={dispRect!.dx}
y={dispRect!.dy}
width={dispRect!.dw}
height={dispRect!.dh}
>
{cinemaOn ? (
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={toneUniforms}
>
<ImageShader
image={skiaImage}
fit={dispFit}
tx="clamp"
ty="clamp"
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
/>
</Shader>
) : (
<ImageShader
image={skiaImage}
fit={dispFit}
tx="clamp"
ty="clamp"
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
/>
)}
</Shader>
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={toneUniforms}
>
<ImageShader
image={skiaImage}
fit={dispFit}
tx="clamp"
ty="clamp"
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
/>
</Shader>
) : null}
</Rect>
) : dispRect ? (
<SkiaImage
image={skiaImage}
fit={dispFit}
rect={rect(dispRect.dx, dispRect.dy, dispRect.dw, dispRect.dh)}
/>
{dispRect ? (
// Tone/Cinema chain, CLARITY and HDF bloom all live in
// libPhoto — see its comment for the rect caveat.
libPhoto({ x: dispRect.dx, y: dispRect.dy, w: dispRect.dw, h: dispRect.dh }, dispFit)
) : (
<SkiaImage image={skiaImage} fit="contain" rect={rect(0, 0, vw, vh)} />
)}
+6 -9
View File
@@ -6,8 +6,8 @@ import { Animated, PanResponder } from 'react-native';
// While zoomed in, responder termination is refused so the parent ScrollView
// cannot steal the gesture — the photo pans instead of paging; swiping to the
// next photo resumes after zooming back out (double tap or pinch below 1.15).
// Two-finger pinch only zooms once the photo is already zoomed in: at 100% the
// pinch stays inert, so the paging strip still behaves like a plain gallery.
// Pinch zooms from any scale: a two-finger gesture never pages the strip, so
// pinching out of 100% cannot be mistaken for a swipe.
//
// ponytail: pinch zooms around the image center, not the finger midpoint;
// focal zoom needs per-touch location math against the image's screen rect.
@@ -116,13 +116,10 @@ export default function ZoomableImage({ uri, width }: ZoomableImageProps) {
const b = touches[1];
const dist = Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
if (!st.pinchStart) st.pinchStart = { scale: st.scale, dist: dist || 1 };
const next =
st.pinchStart.scale > 1.001
? Math.max(
1,
Math.min(MAX_SCALE, st.pinchStart.scale * (dist / st.pinchStart.dist))
)
: 1;
const next = Math.max(
1,
Math.min(MAX_SCALE, st.pinchStart.scale * (dist / st.pinchStart.dist))
);
st.scale = next;
if (next <= 1.001) {
st.tx = 0;