Files
RecipesCam/src/components/Viewfinder.tsx
T
admin 39487ffd67 Park the quick-EV readout against its track
The EV value sat 52dp clear of the AE-lock slider it labels, on the far side of the thumb travel. 8dp now.
2026-09-16 07:08:37 +07:00

3137 lines
139 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle, type ComponentProps } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native';
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,
ClipOp,
useImage,
useFont,
Text as SkiaText,
rect,
Shader,
Skia,
Group,
Rect,
RadialGradient,
ColorMatrix,
Blur,
Image as SkiaImage,
ImageShader,
BlendMode,
TileMode,
FilterMode,
MipmapMode,
FontStyle,
} from '@shopify/react-native-skia';
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, 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';
import { detectTilt } from '../utils/horizon';
interface ViewfinderProps {
mode: 'camera' | 'library';
// Camera lens facing for the live viewfinder; flipping restarts the session
// on the other device.
facing?: 'back' | 'front';
recipe: Recipe;
selectedFrame: FrameId;
useGeotag: boolean;
gpsInfo: GPSInfo | null;
libraryImageUri: string | null;
// Library only: screen height still visible above the bottom deck. The whole
// viewfinder geometry is letterboxed into it so no part of the photo (and no
// custom-watermark position) hides under an open panel. Camera ignores this:
// its feed rects are normalized against the real window.
availableHeight?: number | null;
// Library only: user rotation of the inserted photo — the quarter turn in
// degrees clockwise (0/90/180/270) plus the fine STRAIGHTEN offset, applied
// on top of that turn.
photoRotation?: 0 | 90 | 180 | 270;
photoStraighten?: number;
// WATERMARK tab: double-tap ON the mark re-opens the text editor (App bumps
// a nonce so the panel puts the caret back in its input).
onWmEdit?: () => void;
cameraPermissionGranted: boolean;
onRequestCameraPermission: () => void;
photoOutput: CameraPhotoOutput;
// Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band
// and the photo-area crop. Wall frame ignores it (fixed frame opening).
aspectRatio: AspectRatio;
// FRAME-tab CROP (library plain frames only — the camera composes through
// aspectRatio above, polaroid/wall through their own framed window). A fixed
// ratio previews the exact center-crop the export writes; 'free' shows the
// whole photo with a draggable keep-rectangle. Zoom/pan is suspended while a
// crop is active so the preview and the file can never disagree.
cropRatio?: CropRatio;
// The ratio the CROP strip's APPLY committed. Equal to cropRatio only while
// the crop is confirmed: the viewer then shows the crop alone (no band
// outline, no dim surround). Picking any other ratio drops back to framing.
cropApplied?: CropRatio | null;
cropRect?: CropRect;
onCropRectChange?: (r: CropRect) => void;
// RAW DNG toggle: while on, the DNG output below is attached to the session
// (a second ImageCapture use case). Kept null-free at the App level; this is
// nullable only because the session may not exist yet.
rawEnabled: boolean;
rawOutput: CameraPhotoOutput | null;
// Session-level error from the camera (e.g. DNG output failed to bind). App
// auto-disables RAW on such errors and alerts the user.
onSessionError?: (error: Error) => void;
// Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV.
onExposureChange?: (ev: number) => void;
// Metering-mode tonal look (highlight-weighted): extra adjustments merged on
// top of the recipe for BOTH the preview matrix/tone and the export. Real
// highlight-weighted metering (GR / Sony) doesn't only underexpose — it meters
// for the BRIGHTEST area to keep its detail (absolute no-blowout): highlight
// roll, deep shadow crush, raised contrast/clarity. Carries the small EV dip
// too (exposureCompensation), so preview and export share ONE EV source; the
// UI sliders keep showing the raw recipe.
meteringAdjustments?: Partial<ColorAdjustments>;
// Continuous panel parameter whose slider row is open (App lifts it from the
// AdjustmentPanel). While set, a vertical drag ANYWHERE on the live image
// adjusts this parameter (up = increase) — no need to grab the small slider.
// Null while no param row is open; then a locked-AE drag adjusts EV instead.
imageAdjustTarget?: {
key: string;
min: number;
max: number;
step: number;
value: number;
onChange: (v: number) => void;
} | null;
// Hold-to-compare: while armed (a LIGHT/WB/FX panel is open) pressing and
// holding anywhere on the picture asks App to draw it with the last edit
// 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.
customWm?: {
enabled: boolean;
text: string;
x: number;
y: number;
};
onWmPositionChange?: (x: number, y: number) => void;
// GPS mark placement (WATERMARK tab drag + pinch): the same 0..1 fractions
// of the same area customWm uses, TOP-LEFT anchored; size multiplies the
// base 3.2%-of-width size. Absent = the historical fixed top-left look.
gpsWm?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean };
onGpsWmChange?: (x: number, y: number, size: number) => void;
// Pinch on the custom mark: its SIZE, exactly like the panel's SIZE chip.
onWmSizeChange?: (size: number) => void;
// WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270). Rotates the
// custom text AND the GPS lines around their own anchors — same angle the
// export burns in.
wmRotation?: number;
// True while the WATERMARK panel is open — that panel IS the mark's edit
// session. The mark draws whenever the CUSTOM chip is ON with text, but it
// only CAPTURES image touches (placement drags in camera mode, click-through
// to focus / zoom) while this is set, so a watermark left ON can never
// swallow the camera's tap-to-focus or the library's pinch/double-tap.
wmEditing?: boolean;
// True while the FRAME tab is the open deck tab. Pinching then magnifies the
// WHOLE preview — frame, photo and watermarks together — instead of the
// photo: a preview-only loupe for placing the mark. The saved file never
// changes, because the export reads the photo zoom, not this view zoom.
frameTabActive?: boolean;
// WALL FRAME hung landscape (4000x3117) instead of portrait.
wallLandscape?: boolean;
}
export interface ViewfinderHandle {
/**
* Current interactive photo reposition inside a framed library window
* (RETRO INSTANT / WALL FRAME): scale s (>=1) and the visible window-center
* as 0..1 fractions of the photo's cover plane (the area that fills the
* window at 1x). Null when no framed window is showing or the photo is
* unzoomed. App passes it to the export engine so the saved file shows
* 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
* the saved file matches the framing after pinch/drag. Null when no
* fixed-ratio crop is active.
*/
getCropRatioRect(): CropRect | null;
/**
* FRAME-tab AUTO straighten: tilt, in degrees, of the dominant near-
* horizontal line in the library photo — the horizon it was shot with.
* Null when the picture holds no line worth trusting, so App can leave the
* fine angle alone. Read from the quarter-turned source WITHOUT the current
* straighten, so pressing AUTO twice answers the same both times.
*/
detectHorizon(): number | null;
}
const noiseEffect = Skia.RuntimeEffect.Make(`
vec4 main(vec2 pos) {
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
return vec4(vec3(r), 1.0);
}
`);
// Tone runtime effect (DR/Highlight/Shadow) for the live camera preview worklet.
// Hoisted at module scope so the worklet runtime can capture it — same pattern
// as the official vision-camera-skia shader examples. Skia caches compiled
// effects by source string, so Make here runs once per app launch.
// Compile failure at module scope throws (JavascriptException → SIGABRT), so
// swallow it into null — callers already fall back to matrix-only.
const toneEffect = (() => {
try {
return Skia.RuntimeEffect.Make(TONE_SKSL);
} catch (e) {
console.error('TONE_SKSL compile failed: ' + e);
return null;
}
})();
const cinemaEffect = (() => {
try {
return Skia.RuntimeEffect.Make(CINEMA_SKSL);
} catch (e) {
console.error('CINEMA_SKSL compile failed: ' + e);
return null;
}
})();
// 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;
const F_LINEAR = FilterMode.Linear;
const M_NONE = MipmapMode.None;
const CLIP_INTERSECT = ClipOp.Intersect;
const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfinder(
{
mode,
facing = 'back',
recipe,
selectedFrame,
useGeotag,
gpsInfo,
libraryImageUri,
availableHeight,
cameraPermissionGranted,
onRequestCameraPermission,
photoOutput,
aspectRatio,
cropRatio = 'none',
cropApplied = null,
cropRect = DEFAULT_CROP_RECT,
onCropRectChange,
rawEnabled,
rawOutput,
onSessionError,
onExposureChange,
meteringAdjustments,
imageAdjustTarget,
onComparePeek,
onTouchShutter,
customWm = { enabled: false, text: '', x: 0.5, y: 0.5 },
onWmPositionChange,
gpsWm,
onGpsWmChange,
onWmSizeChange,
onWmEdit,
wmEditing = false,
frameTabActive = false,
photoRotation = 0,
photoStraighten = 0,
wmRotation = 0,
wallLandscape = false,
}: ViewfinderProps,
ref
) {
// Fullscreen hero: the viewfinder fills the entire screen, every overlay
// rect derives from window size.
const { width: vw, height: winH } = useWindowDimensions();
// Library: everything below this line is the bottom deck, so the photo is
// letterboxed above it (top-anchored: same fractions, same export).
const vh = mode === 'library' && availableHeight && availableHeight > 0 ? availableHeight : winH;
const insets = useSafeAreaInsets();
// Composition ratio band. With a ratio active the photo area is a centered
// portrait strip (w/h = ratioWH) and everything outside is letterboxed;
// exports crop to the same ratio. Only the plain full-picture frames use the
// band — polaroid keeps its own card layout (window aspect follows the ratio)
// and wall frame deliberately ignores the ratio entirely (its opening is a
// fixed 2:3). When no band is active the region is just the full screen, so
// every consumer below degrades to the old fullscreen behavior untouched.
const ratioWH = aspectRatio === 'full' ? null : ASPECT_RATIO_W_H[aspectRatio];
const regionBand =
mode === 'camera' &&
ratioWH !== null &&
(selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic');
const region = useMemo(() => {
if (!regionBand || ratioWH === null) return { x: 0, y: 0, w: vw, h: vh };
// The band is a same-zoom crop WINDOW over the full sensor view, not the
// photo re-scaled to the screen width: anchor on the native sensor box
// (upright 3:4, contain-fitted to the screen) and cut the ratio strip out
// of it at the same scale. 4:3 (== native) therefore fills the full sensor
// width at the reference zoom, and 3:2 is the identical vertical view with
// a narrower horizontal crop — switching ratio never zooms the scene, so a
// 3:2 band never shows a taller image than 4:3.
// ponytail: sensor assumed upright 3:4 (quality-mode captures); if a device
// negotiates another photo aspect, derive it from the session instead.
const nativeWH = 3 / 4;
const nativeH = Math.min(vh, vw / nativeWH);
const nativeW = nativeH * nativeWH;
const h = ratioWH <= nativeWH ? nativeH : nativeW / ratioWH;
const w = ratioWH * h;
return { x: (vw - w) / 2, y: (vh - h) / 2, w, h };
}, [regionBand, ratioWH, vw, vh]);
// FRAME-tab CROP geometry. Only the plain full-picture frames crop the photo
// directly (same set the camera's ratio band serves); polaroid/wall already
// crop through their own window and keep their framed ratio crop.
const libCropPlainFrame =
selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic';
const libCropActive = mode === 'library' && cropRatio !== 'none' && libCropPlainFrame;
const libFreeCrop = libCropActive && cropRatio === 'free';
// CROP's second step: once APPLY has committed the ratio the band is no
// longer an outline over the whole picture — the crop itself is the view.
const libCropApplied = libCropActive && cropApplied === cropRatio;
// Camera outputs: attach the DNG output only while RAW is on — CameraX
// cannot bind two ImageCapture use cases at once, and App's session-error
// handler turns RAW off again when that ever fails.
const cameraOutputs = useMemo(
() => (rawEnabled && rawOutput ? [photoOutput, rawOutput] : [photoOutput]),
[photoOutput, rawOutput, rawEnabled]
);
// useCameraDevice resolves the back camera fresh on every render, so once the
// permission lands (first-run flow: GRANT button → OS dialog) a re-render is
// enough to pick the device up — no manual polling needed.
const device = useCameraDevice(facing);
// CameraX negotiates the CameraFrameOutput aspect on its own (default 16:9 on
// this device), which center-crops the 4:3 sensor — so the ratio band could
// never display 100% of the sensor pixels. Force the frame stream to a 4:3
// (sensor aspect) resolution supported for 'stream' outputs; the photo output
// keeps its own full-res 4:3 size and FOV, so the viewfinder then matches the
// captured photo exactly. Best supported 4:3 size closest to 1280x960
// (720p-class, keeps the frame pipeline light); CameraX may still round it.
const frameTarget = useMemo(() => {
if (!device) return undefined;
try {
let best: { width: number; height: number } | undefined;
let bestScore = Infinity;
for (const s of device.getSupportedResolutions('stream')) {
const w = s.width;
const h = s.height;
if (!(w > 0 && h > 0)) continue;
if (Math.abs(w / h - 4 / 3) > 0.03) continue;
const score = Math.abs(Math.sqrt(w * h) - Math.sqrt(1280 * 960));
if (score < bestScore) {
bestScore = score;
best = { width: w, height: h };
}
}
return best ?? undefined;
} catch {
return undefined;
}
}, [device]);
const loadedImage = useImage(libraryImageUri || '');
// FRAME-tab rotation: pre-rotate the source, so the width()/height() swap
// flows through every derived rect (polaroid window aspect, cover crop,
// watermark area) and the export matches the preview for free.
const skiaImage = useMemo(
() => applyPhotoRotation(loadedImage, photoRotation, photoStraighten),
[loadedImage, photoRotation, photoStraighten]
);
// Mirror of the two inputs of the above, for detectHorizon: the imperative
// handle is captured once (deps [ref]) and must read the LIVE source.
const horizonSrcRef = useRef({ image: loadedImage, quarter: photoRotation });
horizonSrcRef.current = { image: loadedImage, quarter: photoRotation };
// Wall-frame PNG artwork (project root). Loaded lazily so other frames pay
// no asset cost; source switches to '' when unselected (useImage → null).
const wallImage = useImage(selectedFrame === 'wallframe' ? require('../../wallframe.png') : '');
const customFont = useFont(
require('../../assets/Cousine-Regular.ttf'),
Math.round(vw * 0.032)
);
// Monochrome Noto Emoji subset (📍 U+1F4CD, 📷 U+1F4F7) for the watermark
// icons — glyphs painted with the same color as the text next to them.
const iconFont = useFont(
require('../../assets/NotoEmoji-GPS.ttf'),
Math.round(vw * 0.032)
);
// GPS mark SIZE (pinch while placing it): the same two faces, re-sized, so
// the preview grows with the multiplier the export burns in.
const gpsIconSize = Math.round(vw * 0.032 * (gpsWm?.size ?? 1));
const gpsFont = useMemo(
() => (customFont ? Skia.Font(customFont.getTypeface(), gpsIconSize) : null),
[customFont, gpsIconSize]
);
const gpsIconFont = useMemo(
() => (iconFont ? Skia.Font(iconFont.getTypeface(), gpsIconSize) : null),
[iconFont, gpsIconSize]
);
const adjustments = meteringAdjustments
? { ...recipe.adjustments, ...meteringAdjustments }
: recipe.adjustments;
// Exposure compensation, applied as a software 2^EV matrix gain for BOTH
// modes — the camera's hardware AE bias is never touched. setExposureBias
// re-locks AE on the Xiaomi and flashes the preview, and the bias lands
// asynchronously, so a shot exposed through it could disagree with what the
// viewfinder showed at shutter time. The software gain is instant and
// flicker-free, and it is what the export applies too (no evFromCamera), so
// preview and file always match. EV_RANGE ±3 caps the gain.
const clampEvApplied = (v: number) => Math.max(-3, Math.min(3, v));
const evApplied = clampEvApplied(adjustments.exposureCompensation ?? 0);
const colorMatrix = applyExposureGain(
getSkiaColorMatrix(recipe.baseFilter, adjustments),
evApplied
);
// The Skia onFrame worklet runs on a separate runtime — keep the latest color
// matrix in a cross-thread synchronizable value instead of a Reanimated
// SharedValue (which lives on the UI runtime).
const colorMatrixSync = useMemo(
() => createSynchronizable<number[]>(colorMatrix),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Tone uniforms [dr, hl, sh, vib, shadow split RGB, highlight split RGB] for
// the preview worklet; all-zero = no tone pass. A stock may keep the pass on
// with every user knob at 0 (Classic Neg split tone, Classic Chrome crush).
const toneParams = toneUniformArray(getToneUniforms(adjustments, recipe.baseFilter));
const toneSync = useMemo(
() => createSynchronizable<number[]>(new Array(10).fill(0)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Cinema seasonal grade params (16 flat floats; all-zero = off).
const cinemaParams = recipe.cinema ? getCinemaUniforms(recipe.cinema) : null;
const cinemaSync = useMemo(
() => createSynchronizable<number[]>(cinemaOffFlat()),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Highlight Diffusion Filter strength (0..10); 0 = no glow pass.
const hdfSync = useMemo(
() => createSynchronizable<number>(adjustments.hdf ?? 0),
// 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
[]
);
// 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);
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
// the preview and for the export alike.
const skiaCameraRef = useRef<SkiaCameraRef>(null);
const [cameraActive, setCameraActive] = useState(false);
// Re-render kicker bumped on every onStarted. A flip remounts <SkiaCamera>
// (key change) while cameraActive never drops (unmount fires no onStopped),
// so the reopen would not re-render and controller-derived UI (zoom chips)
// stayed stuck hidden until the next touch — the new controller only becomes
// readable from the ref once the fresh session has started.
const [, bumpSessionStart] = useState(0);
// Stable identities so React.memo(SkiaCameraImpl) does not re-render (and
// re-run its frame-output setup) on every adjustments change during slider
// drags — inline lambdas here used to break memo and stall the main thread.
const handleCameraStarted = useCallback(() => {
startedOnceRef.current = true;
watchdogBudgetRef.current = 0;
setCameraActive(true);
bumpSessionStart((n) => n + 1);
}, []);
const handleCameraStopped = useCallback(() => {
setCameraActive(false);
}, []);
// Camera session pauses when the app backgrounds; toggling isActive back on
// resume restarts it. Without this the preview stayed black after unlock.
const [appActive, setAppActive] = useState(AppState.currentState === 'active');
const startedOnceRef = useRef(false);
const cameraActiveRef = useRef(false);
cameraActiveRef.current = cameraActive;
const appActiveRef = useRef(appActive);
appActiveRef.current = appActive;
// Watchdog bookkeeping — see the camera-watchdog effect near handleFrame.
const bouncePendingRef = useRef(false);
const watchdogBudgetRef = useRef(0);
// Full SkiaCamera remount counter. Bumping unmounts <SkiaCamera> and mounts a
// fresh one (its unmount clears the lib's offscreen surface cache), the only
// reliable revive on this device — an isActive toggle or a plain session
// reconfigure (flip) does NOT clear the wedged Skia/EGL state.
const [cameraMountNonce, setCameraMountNonce] = useState(0);
useEffect(() => {
const sub = AppState.addEventListener('change', (s) => setAppActive(s === 'active'));
return () => sub.remove();
}, []);
// Keep the screen lit while the live camera is showing. Without this the
// display sleeps after the system timeout; when the user wakes it much later
// (deep idle/doze) the preview surface is gone but the JS AppState never saw
// a background event, so the session is never restarted and the viewfinder
// (and capture) stay black until the camera is re-mounted (flip/switch).
useEffect(() => {
if (mode === 'camera') {
activateKeepAwake('camera-viewfinder');
return () => deactivateKeepAwake('camera-viewfinder');
}
}, [mode]);
// Tap-to-focus / AE-AF-AWB lock point (view coords). null = auto continuous.
const [focusPoint, setFocusPoint] = useState<{ x: number; y: number } | null>(null);
const controller = cameraActive ? skiaCameraRef.current?.controller : undefined;
// Native zoom. CameraX on this class of device exposes one back logical
// camera (1x main physical + digital crop to 10x); the ultra-wide/tele
// logical cameras are not in CameraX's device list, so preset chips snap the
// controller zoom ratio (1x optical, 2x-10x digital). Applied ratio survives
// session restarts (CameraX resets to 1x on each fresh session).
const [zoomRatio, setZoomRatio] = useState(1);
const zoomRef = useRef(1);
useEffect(() => {
zoomRef.current = zoomRatio;
}, [zoomRatio]);
// 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(() => {
if (!controller) return;
if (zoomRef.current !== 1) {
controller.setZoom(zoomRef.current).catch(() => {});
}
}, [controller]);
// Switching back/front restarts the session at the new lens: never carry a
// digital zoom across the switch.
useEffect(() => {
zoomRef.current = 1;
setZoomRatio(1);
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
// that leaked value back as the user's EV.
const evStops = recipe.adjustments.exposureCompensation ?? 0;
useImperativeHandle(
ref,
() => ({
// Frame-window (polaroid/wall) zoom, for export parity. Read
// live through the ref — the handle is captured once with deps [ref].
getFrameWindowZoom() {
return frameZoomRef.current;
},
isUltraWide() {
return ultraWideOnRef.current;
},
getCropRatioRect() {
return cropRatioRectRef.current;
},
// AUTO straighten: the angle the picture asks for. Computed on demand
// (one <=256px thumbnail, tens of ms) — never on a slider drag.
detectHorizon() {
const src = horizonSrcRef.current;
return detectTilt(applyPhotoRotation(src.image, src.quarter, 0));
},
}),
[ref]
);
// Tap-to-focus: lock AE/AF/AWB onto the tapped region (SceneAdaptiveness
// 'locked' + autoResetAfter null = stays locked until another tap or unlock).
// CameraX AE otherwise keeps re-metering the scene and cancels out the tone
// 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;
if (!d) return;
if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) {
console.warn('Focus/exposure metering unsupported on this device');
return;
}
setFocusPoint({ x, y });
try {
const t0 = Date.now();
await cam.focusTo(
{ x, y },
{ responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null }
);
console.log(`[focus] AE/AF locked at ${Math.round(x)},${Math.round(y)} in ${Date.now() - t0}ms`);
} catch (e) {
console.warn('Focus lock failed: ' + String(e).slice(0, 120));
}
};
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 {
await cam.resetFocus();
setFocusPoint(null);
} catch (e) {
console.warn('Focus reset failed: ' + String(e).slice(0, 120));
}
};
// Quick exposure slider — appears while AE/AF is locked. Drag up/down maps
// directly to the recipe EV in stops (same live software-matrix path as the
// EV slider, and the same EV the export applies).
const EV_RANGE = 3; // matches the ±24 index range at 1 index = 1/8 EV
const evTrackTop = vh * 0.24;
const evTrackH = vh * 0.42;
const evPxPerStop = evTrackH / (2 * EV_RANGE);
const clampEv = (v: number) => Math.max(-EV_RANGE, Math.min(EV_RANGE, v));
const evDragRef = useRef<{ y0: number; ev0: number } | null>(null);
// Last EV value actually pushed to App. Quick-EV drags otherwise fire a full
// adjustments re-render on EVERY pointer move (30-60Hz), even while the
// finger holds a step without the value changing — that render storm stalls
// the main thread and beats the Skia preview. Only push real nấc changes.
const evLastSentRef = useRef<number | null>(null);
const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH;
// Screen-absolute Y of the gesture. locationY is relative to whichever view
// the finger currently hit-tests over, so crossing the small track's edge
// mid-drag changed the origin by the track offset and snapped EV by ~3 stops
// (the "jumps to the other end" bug). pageY never moves.
const pageYOf = (e: any): number => {
const t = e?.nativeEvent?.touches?.[0];
if (t && typeof t.pageY === 'number') return t.pageY;
const p = e?.nativeEvent?.pageY;
if (typeof p === 'number') return p;
return e?.nativeEvent?.locationY ?? 0;
};
const onEvSliderGrant = (e: any) => {
const y0 = pageYOf(e);
evDragRef.current = { y0, ev0: evStops };
evLastSentRef.current = evStops;
};
const onEvSliderMove = (e: any) => {
const g = evDragRef.current;
if (!g || !onExposureChange) return;
const dy = g.y0 - pageYOf(e); // drag up = brighter
const ev = clampEv(g.ev0 + dy / evPxPerStop);
const rounded = Math.round(ev * 10) / 10;
if (rounded === evLastSentRef.current) return; // same step: skip re-render
evLastSentRef.current = rounded;
onExposureChange(rounded);
};
const onEvSliderRelease = () => {
evDragRef.current = null;
};
// ---- Drag-anywhere-on-the-image adjustment --------------------------------
// Priority: an open panel param row (imageAdjustTarget) > EV (AE locked).
// A plain tap (no movement) always falls through to tap-to-focus.
const TAP_SLOP = 14; // px of movement before a touch counts as a drag
// ---- Hold-to-compare (see onComparePeek) ---------------------------------
// The timer only arms when App has something to compare against; moving past
// TAP_SLOP (or a second finger) hands the touch back to its normal job —
// adjust / pinch / pan — and drops the peek.
const HOLD_MS = 350;
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdAnchorRef = useRef<{ x: number; y: number } | null>(null);
const peekingRef = useRef(false);
const onComparePeekRef = useRef(onComparePeek);
onComparePeekRef.current = onComparePeek;
const endPeek = () => {
if (!peekingRef.current) return;
peekingRef.current = false;
onComparePeekRef.current?.(false);
};
const cancelHold = () => {
if (holdTimerRef.current) {
clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
};
const startHold = (x: number, y: number) => {
cancelHold();
holdAnchorRef.current = { x, y };
if (!onComparePeekRef.current) return;
holdTimerRef.current = setTimeout(() => {
holdTimerRef.current = null;
peekingRef.current = true;
onComparePeekRef.current?.(true);
}, HOLD_MS);
};
// Ends any hold and reports whether this gesture WAS peeking — a peek is not
// a tap, so its release must skip the tap actions below.
const endHold = () => {
cancelHold();
holdAnchorRef.current = null;
const was = peekingRef.current;
endPeek();
return was;
};
const holdNoteMove = (x: number, y: number) => {
const a = holdAnchorRef.current;
if (!a) return;
if (Math.abs(x - a.x) <= TAP_SLOP && Math.abs(y - a.y) <= TAP_SLOP) return;
holdAnchorRef.current = null;
cancelHold();
endPeek();
};
const imageAdjustTargetRef = useRef(imageAdjustTarget);
imageAdjustTargetRef.current = imageAdjustTarget;
type ImgDragSession = {
y0: number;
kind: 'param' | 'ev' | 'wm' | 'focus';
v0: number;
param: { min: number; max: number; step: number; onChange: (v: number) => void };
moved: boolean;
lastSent: number | null;
};
const imgDragRef = useRef<ImgDragSession | null>(null);
const onImageDragGrant = (e: any) => {
const t = imageAdjustTargetRef.current;
let kind: ImgDragSession['kind'] = 'focus';
let v0 = 0;
let param: ImgDragSession['param'] = { min: 0, max: 0, step: 0, onChange: () => {} };
if (t) {
kind = 'param';
v0 = t.value;
param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange };
} else if (wmArmed || gpsWmArmed) {
// Watermark armed: every touch drags the mark (an open param slider keeps
// priority; the AE-lock EV drag yields while armed). Anchor the drag —
// the mark only MOVES once the finger does, it never jumps under it.
kind = 'wm';
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
const tp = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 };
const tgt = wmTargetAt(tp.x, tp.y) ?? (wmArmed ? 'custom' : 'gps');
wmDragTgtRef.current = tgt;
beginWmDrag(tgt, tp.x, tp.y);
} else if (focusPoint && onExposureChange) {
kind = 'ev';
v0 = evStops;
}
// Framed-window pinch/pan anchors (camera): seeded at touch-down like the
// library's onLibTouchStart, consumed in onImageDragMove below.
const gts = e?.nativeEvent?.touches ?? [];
panRef.current =
gts.length === 1
? {
x: gts[0].locationX,
y: gts[0].locationY,
px: gts[0].pageX,
py: gts[0].pageY,
tx: viewZoomed ? viewZoom.tx : libZoom.tx,
ty: viewZoomed ? viewZoom.ty : libZoom.ty,
}
: null;
panUsedRef.current = false;
if (gts.length < 2) pinchRef.current = null;
imgDragRef.current = { y0: pageYOf(e), kind, v0, param, moved: false, lastSent: null };
const ht = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
startHold(ht?.pageX ?? 0, pageYOf(e));
};
const onImageDragMove = (e: any) => {
const g = imgDragRef.current;
const holdTs = e?.nativeEvent?.touches ?? [];
if (holdTs.length >= 2) endHold();
else holdNoteMove(holdTs[0]?.pageX ?? 0, pageYOf(e));
// Framed window (polaroid card / wall opening) on the live camera: two
// fingers pinch the picture and drag it inside the frame — exactly the crop
// the export takes (frameWindowZoom). One finger drags it too, but only a
// touch that no parameter row and no AE-lock EV drag has claimed.
const wts = e?.nativeEvent?.touches;
// Two fingers: a mark under them resizes (the WATERMARK panel's own pinch);
// anything else on the FRAME tab magnifies the whole view.
if (wts && wts.length >= 2) {
const mx = (wts[0].locationX + wts[1].locationX) / 2;
const my = (wts[0].locationY + wts[1].locationY) / 2;
const d = Math.hypot(
wts[0].locationX - wts[1].locationX,
wts[0].locationY - wts[1].locationY
);
const mi = { x: mx, y: my };
const mark = pinchMarkAt(mi.x, mi.y) ?? wmPinchRef.current?.tgt ?? null;
if (mark) {
const p = wmPinchRef.current;
if (!p) wmPinchRef.current = { tgt: mark, d, size: wmSizeOf(mark) };
else {
applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1));
if (g) g.moved = true;
}
return;
}
if (frameTabActive) {
applyViewPinch(wts);
if (g) g.moved = true;
return;
}
}
// FRAME tab, magnified view: one finger drags the picture inside the
// viewport (preview only, like the pinch). An armed mark keeps the touch.
if (viewZoomed && wts?.length === 1 && g?.kind === 'focus' && !pinchRef.current) {
const pan0 = panRef.current;
if (pan0) {
// pageX/pageY: absolute screen dp, immune to the view transform, so the
// picture tracks the finger 1:1 (locationX is scaled by the matrix).
const dx = wts[0].pageX - pan0.px;
const dy = wts[0].pageY - pan0.py;
if (Math.abs(dx) > TAP_SLOP || Math.abs(dy) > TAP_SLOP) panUsedRef.current = true;
if (panUsedRef.current) {
setViewZoom(clampViewZoom({ s: viewZoom.s, tx: pan0.tx + dx, ty: pan0.ty + dy }));
g.moved = true;
}
}
return;
}
if (g && g.kind !== 'wm' && !wmArmed && zoomWindowRect()) {
if (wts && wts.length >= 2) {
const [a, b] = wts;
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
let p = pinchRef.current;
if (!p) {
p = pinchRef.current = {
d,
s: libZoom.s,
tx: libZoom.tx,
ty: libZoom.ty,
fx: (a.locationX + b.locationX) / 2,
fy: (a.locationY + b.locationY) / 2,
};
}
const ns = clampZoom((p.s * d) / Math.max(p.d, 1));
const k = ns / p.s;
setLibZoom(clampZoomToWindow(ns, p.fx - k * (p.fx - p.tx), p.fy - k * (p.fy - p.ty)));
g.moved = true;
return;
}
if (wts && wts.length === 1 && g.kind === 'focus' && !pinchRef.current) {
const t = wts[0];
const pan0 = panRef.current;
if (pan0) {
const dx = t.locationX - pan0.x;
const dyP = t.locationY - pan0.y;
if (Math.abs(dx) > TAP_SLOP || Math.abs(dyP) > TAP_SLOP) panUsedRef.current = true;
if (panUsedRef.current) {
setLibZoom(clampZoomToWindow(libZoom.s, pan0.tx + dx, pan0.ty + dyP));
g.moved = true;
}
}
return;
}
}
if (!g || g.kind === 'focus') return;
if (g.kind === 'wm') {
// Watermark drag: the mark keeps its place under the finger and travels
// by the finger's DELTA — it must never jump to the touch point. The
// second finger (pinch, above) resizes it instead; the commit is below.
const ts = e?.nativeEvent?.touches ?? [];
const t = ts[0] ?? e?.nativeEvent;
const { x, y } = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 };
const d0 = wmDragRef.current;
if (d0 && (Math.abs(x - d0.px) > TAP_SLOP || Math.abs(y - d0.py) > TAP_SLOP)) {
d0.moved = true;
}
if (d0?.moved) {
moveWmDrag(x, y);
g.moved = true;
}
return;
}
const dy = g.y0 - pageYOf(e); // up = positive / increase
if (!g.moved) {
if (Math.abs(dy) < TAP_SLOP) return;
g.moved = true;
}
if (g.kind === 'ev') {
if (!onExposureChange) return;
const ev = clampEv(g.v0 + dy / evPxPerStop);
const rounded = Math.round(ev * 10) / 10;
if (rounded === g.lastSent) return;
g.lastSent = rounded;
evLastSentRef.current = rounded;
onExposureChange(rounded);
return;
}
const range = g.param.max - g.param.min;
let v = g.v0 + (dy * range) / evTrackH; // same feel as the EV track
v = Math.max(g.param.min, Math.min(g.param.max, v));
if (g.param.step > 0) v = Math.round(v / g.param.step) * g.param.step;
const rounded = Math.round(v * 1000) / 1000;
if (rounded === g.lastSent) return;
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;
const peeked = endHold();
wmPinchRef.current = null;
wmDragTgtRef.current = null;
wmDragRef.current = null;
viewPinchRef.current = null;
pinchRef.current = null;
panRef.current = null;
panUsedRef.current = false;
if (!g) return;
if (g.kind === 'wm') {
// A tap (never a drag) drops the text at the tapped point; a drag has
// already moved it. Never lock AE/AF while the watermark is armed.
if (!g.moved) {
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
placeWm(t?.locationX ?? 0, t?.locationY ?? 0);
}
commitWm();
return;
}
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.
// 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?.()
);
}
};
// Camera frame renderer. Stable identity (empty-ish deps): SkiaCamera re-runs
// its frame pipeline whenever onFrame changes, which during slider drags made
// the preview flicker. All live values flow through the synchronizables.
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
// Frame heartbeat for the camera watchdog: bumped in handleFrame on every
// delivered frame. When the whole pipeline dies the session still reports
// OPEN but frame delivery stops, so the heartbeat freezes.
const frameBeatSync = useMemo(() => createSynchronizable<number>(0), []);
// Composition band fed to the frame worklet: [active, viewAspect, fx, fy,
// fw, fh, z, u, v] where (fx,fy,fw,fh) is the photo-area rect (ratio band /
// polaroid window / wall window) normalized to the view, and (z,u,v) is the
// framed-window pinch/drag (cover-plane magnification + the plane fraction at
// the window center, [6]<=1 meaning none). While active the worklet redraws
// the feed cover-mapped INTO that rect instead of the default fullscreen
// cover-fit — with a 4:3 ratio (== native sensor) the whole sensor shows,
// narrower ratios crop exactly like the export. [0]=0 keeps the legacy
// fullscreen draw untouched.
const feedRectSync = useMemo(
() => createSynchronizable<number[]>([0, 0, 0, 0, 0, 0, 0, 0.5, 0.5]),
[]
);
// RETRO INSTANT on the live camera is NOT drawn in the frame pipeline: that
// canvas is an offscreen surface in frame-pixel space which RNSkia later
// cover-fits onto the view, so frame-space geometry overflows the screen and
// cannot be aligned with the dp-space overlays. The camera just renders the
// plain fullscreen feed here; the polaroid card is composited by the JS
// overlay (renderFrameOverlay) in view coordinates, like every other frame.
const handleFrame = useCallback<SkiaCameraOnFrame>(
(frame, render) => {
'worklet';
frameBeatSync.setBlocking(frameBeatSync.getDirty() + 1);
render(({ canvas, frameTexture }) => {
// Quick-EV drags write colorMatrixSync from JS on every tick; a torn
// read can throw inside MakeMatrix (or yield NaN -> black surface) for
// one frame. Validate + try/catch, fall back to identity (no filter):
// a 1-frame gain error beats a 1-frame black beat. DEBUG-EV.
const paint = Skia.Paint();
let baseColorFilter: ReturnType<typeof Skia.ColorFilter.MakeMatrix> | null = null;
try {
const cm = colorMatrixSync.getDirty();
if (cm != null && cm.length === 20 && cm.every((v) => typeof v === 'number' && Number.isFinite(v))) {
baseColorFilter = Skia.ColorFilter.MakeMatrix(cm);
paint.setColorFilter(baseColorFilter);
} else {
console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped');
}
} catch (e) {
console.warn('Color matrix build failed: ' + e);
}
// CLARITY: the export pass 4 twin — unsharp convolution above 0, mist
// 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();
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
)
: 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('Lens 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[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
// ignores the paint shader, which is why the DR/Highlight/Shadow tone
// never showed on the live preview) or as a plain color-matrix image.
const drawFullFrame = () => {
let fullShader = null;
if (hasTone || hasCinema) {
try {
const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
let final = null;
if (hasTone) {
final = toneEffect.makeShaderWithChildren(tone, [imageShader]);
}
if (hasCinema) {
const cinemaShader = cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]);
if (cinemaShader != null) final = cinemaShader;
}
if (final != null) fullShader = final;
} catch (e) {
if (!toneFailLogged.getBlocking()) {
toneFailLogged.setBlocking(true);
console.error('Tone shader failed, matrix-only: ' + e);
}
}
}
if (fullShader != null) {
const w = frameTexture.width();
const h = frameTexture.height();
paint.setShader(fullShader);
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
} else {
canvas.drawImage(frameTexture, 0, 0, paint);
}
// 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 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);
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 {
// 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);
}
}
}
};
// Composition band: without this the SkiaCamera layer later cover-fits
// the upright snapshot onto the fullscreen view, so a 4:3 ratio band
// only revealed a center slice of the sensor. Redraw the feed INTO the
// band rect: black the surface, clip to the rect's raw-frame inverse
// (under the renderToTexture rotation/mirror CTM) and cover-map the
// sensor there. Equal-aspect bands (ratio 4:3 == native 3:4 sensor)
// then show the ENTIRE sensor; narrower bands crop like the export.
const feed = feedRectSync.getDirty();
const ftW = frameTexture.width();
const ftH = frameTexture.height();
const orient = frame.orientation;
const bandActive = feed[0] > 0;
if (!bandActive) {
drawFullFrame();
return;
}
const isLand = orient === 'left' || orient === 'right';
const ow = isLand ? ftH : ftW;
const oh = isLand ? ftW : ftH;
const ac = ow / oh;
const viewAspect = feed[1];
if (!(ow > 0 && oh > 0 && ac > 0 && viewAspect > 0 && feed[4] > 0 && feed[5] > 0)) {
drawFullFrame();
return;
}
// View -> upright-snapshot mapping: the snapshot is cover-fitted to the
// view, so the view shows a centered visX x visY window of it.
const visX = Math.min(1, viewAspect / ac);
const visY = Math.min(1, ac / viewAspect);
const ox0 = (1 - visX) / 2 + feed[2] * visX;
const ox1 = ox0 + feed[4] * visX;
const oy0 = (1 - visY) / 2 + feed[3] * visY;
const oy1 = oy0 + feed[5] * visY;
const X0 = ox0 * ow;
const X1 = ox1 * ow;
const Y0 = oy0 * oh;
const Y1 = oy1 * oh;
// Invert the renderToTexture CTM (out-space -> raw frame space) by
// walking its concats in reverse: A translate(ow/2,oh/2), mirror
// scale(-1,1), rotate(-orientation), D translate(-oh/2,-ow/2).
const mirrored = frame.isMirrored === true;
const deg =
orient === 'down' ? 180 : orient === 'left' ? 90 : orient === 'right' ? 270 : 0;
const toRaw = (X: number, Y: number): [number, number] => {
let x = X - ow / 2;
let y = Y - oh / 2;
if (mirrored) x = -x;
const r = (-deg * Math.PI) / 180;
const c = Math.cos(r);
const s = Math.sin(r);
const rx = x * c - y * s;
const ry = x * s + y * c;
if (isLand) return [rx + oh / 2, ry + ow / 2];
return [rx + ow / 2, ry + oh / 2];
};
let u0 = ftW;
let u1 = 0;
let v0 = ftH;
let v1 = 0;
const c0 = toRaw(X0, Y0);
const c1 = toRaw(X1, Y0);
const c2 = toRaw(X0, Y1);
const c3 = toRaw(X1, Y1);
const cs = [c0, c1, c2, c3];
for (let i = 0; i < 4; i++) {
u0 = Math.min(u0, cs[i][0]);
u1 = Math.max(u1, cs[i][0]);
v0 = Math.min(v0, cs[i][1]);
v1 = Math.max(v1, cs[i][1]);
}
const rw = u1 - u0;
const rh = v1 - v0;
if (!(rw > 0 && rh > 0)) {
drawFullFrame();
return;
}
// Cover-map the full sensor into the raw rect (dst preserves the sensor
// aspect, centered; the clip below trims the overflow) — translate then
// scale: Skia concat applies the later call first (p -> s*p + d).
const s = Math.max(rw / ftW, rh / ftH);
const dw = s * ftW;
const dh = s * ftH;
const dx = u0 + (rw - dw) / 2;
const dy = v0 + (rh - dh) / 2;
// Framed-window pinch/drag (feed[6..8] = z + the crop centre as a
// fraction of the window): show that slice of the cover plane — the
// same crop the export takes (frameWindowZoom). The centre is in VIEW
// axes, so it goes through the same out-space -> raw mapping as the
// window rect (toRaw); handing it straight to the raw plane swapped
// the axes whenever the sensor is rotated. With z at 1 the centre is
// forced to the middle and this collapses to the plain draw above.
const z = feed[6] > 1.001 ? feed[6] : 1;
const half = 0.5 / z;
const cu = Math.min(1 - half, Math.max(half, feed[7]));
const cv = Math.min(1 - half, Math.max(half, feed[8]));
const cX = X0 + cu * (X1 - X0);
const cY = Y0 + cv * (Y1 - Y0);
const hX = (X1 - X0) / (2 * z);
const hY = (Y1 - Y0) / (2 * z);
const b0 = toRaw(cX - hX, cY - hY);
const b1 = toRaw(cX + hX, cY + hY);
const zwX = (Math.min(b0[0], b1[0]) - dx) / s;
const zwY = (Math.min(b0[1], b1[1]) - dy) / s;
const k = s * z;
// Fresh paint; its default color is opaque black (Skia default), so
// the surface fill below needs no explicit color set.
const blackPaint = Skia.Paint();
canvas.drawRect(Skia.XYWHRect(0, 0, ftW, ftH), blackPaint);
canvas.save();
canvas.clipRect(Skia.XYWHRect(u0, v0, rw, rh), CLIP_INTERSECT, false);
canvas.translate(u0 - k * zwX, v0 - k * zwY);
canvas.scale(k, k);
drawFullFrame();
canvas.restore();
});
frame.dispose();
},
[colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, toneFailLogged, feedRectSync, frameBeatSync]
);
// 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
// P' = scale*P + (tx,ty); gesture math keeps the focal point stationary.
const [libZoom, setLibZoom] = useState({ s: 1, tx: 0, ty: 0 });
// Framed photo zoom survives a tab switch to WATERMARK: the mark editor
// grabs a touch only when it lands on a mark box (see onLibTouchStart), so
// the photo no longer has to be reset to 1x for the marks to stay draggable.
// A crop starts from the whole picture inside its band/keep-rect, so entering
// one — or switching to another ratio — drops the previous zoom/pan. Inside
// the crop the photo can be pinched/dragged again to pick the framing.
useEffect(() => {
if (libCropActive) setLibZoom({ s: 1, tx: 0, ty: 0 });
}, [libCropActive, cropRatio]);
// FRAME-tab PREVIEW zoom: while that tab is open two fingers magnify the
// whole view (frame + photo + marks together) so a watermark can be placed
// and sized against a big picture. Preview only — the export reads libZoom,
// never this — and leaving the tab (a mode switch closes it) drops it to 1x.
const [viewZoom, setViewZoom] = useState({ s: 1, tx: 0, ty: 0 });
// Magnified preview (FRAME tab only): one finger then drags the picture
// inside the viewport, so it is the pan that pairs with the pinch above.
const viewZoomed = frameTabActive && viewZoom.s > 1.01;
// Keep the scaled view covering the viewport: no edge may pull inside it.
const clampViewZoom = (z: { s: number; tx: number; ty: number }) => {
const s = clampZoom(z.s);
return {
s,
tx: Math.min(0, Math.max(-(s - 1) * vw, z.tx)),
ty: Math.min(0, Math.max(-(s - 1) * vh, z.ty)),
};
};
const viewPinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null);
useEffect(() => {
if (!frameTabActive) setViewZoom({ s: 1, tx: 0, ty: 0 });
}, [frameTabActive]);
const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null);
const pinchUsedRef = useRef(false);
// Framed-window drag: anchor at touch-down (finger + transform origin), so a
// move never accumulates rounding across a slow drag.
const panRef = useRef<{ x: number; y: number; px: number; py: number; tx: number; ty: number } | null>(null);
const panUsedRef = useRef(false);
const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null);
const clampZoom = (s: number) => Math.max(1, Math.min(6, s));
// Contain-fit rect of the loaded photo in canvas coordinates (image is
// centered with black letterbox above/below on a portrait screen).
const imageFitRect = useMemo(() => {
const img = skiaImage;
if (!img) return null;
const w = img.width();
const h = img.height();
if (!(w > 0 && h > 0)) return null;
const s = Math.min(vw / w, vh / h);
const dw = w * s;
const dh = h * s;
return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh };
}, [skiaImage, vw, vh]);
// Fixed-ratio CROP: the keep-rectangle is the largest centred rect of that
// shape inside the photo, and the photo is NOT re-scaled to it — the whole
// picture stays on screen (contain) with the band marking what the export
// keeps, so the user can pinch/drag the picture under the band to choose the
// framing. Sizing the band off the photo (not the viewport) keeps it inside
// the picture at 1x, so the band never shows letterbox either.
const libCropBand = useMemo(() => {
if (cropRatio === 'none' || cropRatio === 'free' || !libCropActive || !imageFitRect) return null;
const r = CROP_W_H[cropRatio];
let dw = imageFitRect.dw;
let dh = dw / r;
if (dh > imageFitRect.dh) {
dh = imageFitRect.dh;
dw = dh * r;
}
return {
dx: imageFitRect.dx + (imageFitRect.dw - dw) / 2,
dy: imageFitRect.dy + (imageFitRect.dh - dh) / 2,
dw,
dh,
};
}, [libCropActive, cropRatio, imageFitRect]);
// Committed crop geometry: the band's contents scaled up so the ratio fills
// the viewer (centred, black around it). Frame, marks, grain and vignette all
// hug the band from inside the photo group, so one extra matrix over that
// group carries them along; the touch layer takes the same transform so its
// canvas coordinates still land on the pixel under the finger.
const libCropView = useMemo(() => {
if (!libCropApplied || !libCropBand) return null;
const k = Math.min(vw / libCropBand.dw, vh / libCropBand.dh);
const w = libCropBand.dw * k;
const h = libCropBand.dh * k;
return {
k,
ox: (vw - w) / 2 - k * libCropBand.dx,
oy: (vh - h) / 2 - k * libCropBand.dy,
rect: { x: (vw - w) / 2, y: (vh - h) / 2, w, h },
};
}, [libCropApplied, libCropBand, vw, vh]);
// Photo display rect + draw fit in the plain library viewer: the full photo
// is contain-fitted to the screen (fill into its own aspect rect — no
// distortion, identical to the old fullscreen contain). The ratio band only
// shapes the CAMERA composition; a library export is the photo as framed.
const dispRect = regionBand
? { dx: region.x, dy: region.y, dw: region.w, dh: region.h }
: imageFitRect;
const dispFit: 'cover' | 'fill' = regionBand ? 'cover' : 'fill';
// Rect the frame border / cinematic bars / grain hug: the ratio band on the
// camera, the contain rect of the loaded library photo, fullscreen otherwise.
// ponytail: dispRect speaks {dx,dy,dw,dh}; every reader of frameRect
// wants {x,y,w,h}. Normalise here or the native Recorder gets undefined
// width/height and hard-crashes ("Invalid float prop value received").
// FREE crop keeps the whole photo on screen (black masks mark the keep-zone),
// so the keep-rectangle is its own rect: the frame border, the vignette and
// the grain hug what the export will actually keep.
const freeCropPx = useMemo(() => {
if (!libFreeCrop || !imageFitRect) return null;
return {
x: imageFitRect.dx + cropRect.x * imageFitRect.dw,
y: imageFitRect.dy + cropRect.y * imageFitRect.dh,
w: cropRect.w * imageFitRect.dw,
h: cropRect.h * imageFitRect.dh,
};
}, [libFreeCrop, imageFitRect, cropRect]);
// Keep-rectangle drawn on screen: the draggable FREE box or the fixed band.
const cropWinPx = libCropBand
? { x: libCropBand.dx, y: libCropBand.dy, w: libCropBand.dw, h: libCropBand.dh }
: freeCropPx;
// What the screen-space dark mask surrounds: the same band, or the rect the
// committed crop was scaled onto.
const cropScreenPx = libCropView ? libCropView.rect : cropWinPx;
const frameRect =
cropWinPx
? cropWinPx
: mode === 'library' && dispRect
? { x: dispRect.dx, y: dispRect.dy, w: dispRect.dw, h: dispRect.dh }
: region;
// Polaroid print geometry (JS/overlay space) for the CURRENT mode. The card
// always fills/holds to the screen container; the photo WINDOW keeps the
// *composition* aspect: with a ratio active that ratio (window content is
// center-cropped to it), otherwise the library photo's own aspect or the
// camera's upright capture aspect (4:3 sensor → 3:4 portrait, NOT the screen
// aspect: a screen-aspect window made the card nearly fullscreen and
// pole-like, whereas the real print is a shorter 3:4 card letterboxed on
// screen). ponytail: verified on Xiaomi quality-mode captures (4:3); if the
// session negotiates 16:9 photos, change to 9/16.
// The frame keys off the ORIGINAL (pre-rotation) source: rotating the print
// inside the frame must never resize the frame itself.
const framePhotoAspect =
loadedImage && loadedImage.width() > 0 && loadedImage.height() > 0
? loadedImage.width() / loadedImage.height()
: 0;
const polaroidAspect =
ratioWH ?? (mode === 'library' && framePhotoAspect > 0 ? framePhotoAspect : 3 / 4);
// Photo draw fit inside the polaroid window: cover when the window aspect
// (ratio) differs from the source photo, fill otherwise (aspects match). A
// print rotated inside the frame no longer matches the (un-rotated) window
// aspect, so it is contain-fitted — letterboxed inside the window, never
// stretched, and the card keeps its dimensions.
const polaroidFit: 'cover' | 'contain' | 'fill' = ratioWH
? 'cover'
: photoRotation === 90 || photoRotation === 270
? 'contain'
: 'fill';
const polaroidGeo = useMemo(() => {
if (selectedFrame !== 'polaroid') return null;
return polaroidLayout(vw, vh, polaroidAspect, 'contain');
}, [selectedFrame, polaroidAspect, vw, vh]);
// Wall-frame PNG geometry (JS/overlay space). Fixed landscape 3:2 window —
// the PNG is contain-fitted like a physical frame; photo content behind it
// is cover-cropped by the mat opening (never letterboxed inside the window).
const wallGeo = useMemo(() => {
if (selectedFrame !== 'wallframe') return null;
return wallframeLayout(vw, vh, 'contain', wallLandscape);
}, [selectedFrame, vw, vh, wallLandscape]);
// Framed photo window whose content the user may pinch/drag: polaroid card
// window or wall-frame opening. Null for plain frames (their whole
// composition region is the photo) and for the camera's ratio bands (the
// export never crops those through a window).
const zoomWindowRect = (): { x: number; y: number; w: number; h: number } | null => {
// Fixed-ratio crop: the band is the window the picture is positioned in.
if (libCropBand)
return { x: libCropBand.dx, y: libCropBand.dy, w: libCropBand.dw, h: libCropBand.dh };
if (polaroidGeo) return polaroidGeo.window;
if (selectedFrame === 'wallframe' && wallGeo && wallImage) return wallGeo.window;
return null;
};
// Framed photo windows move when the deck grows (slider) or shrinks: the
// photo content is scaled about its own top-left, so a plain move of the
// window under a zoomed photo slides the visible crop away from the card.
// Re-anchor the transform when the window rect changes so the SAME part of
// the photo stays inside the frame (same u/v math the export uses).
const winRectRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
useEffect(() => {
const win = zoomWindowRect();
const prev = winRectRef.current;
winRectRef.current = win ? { ...win } : null;
if (!prev || !win) return;
if (prev.x === win.x && prev.y === win.y && prev.w === win.w && prev.h === win.h) return;
setLibZoom((z) => {
if (z.s <= 1.001) return z;
const tlx = z.tx + (z.s - 1) * prev.x;
const tly = z.ty + (z.s - 1) * prev.y;
const u = (prev.w / 2 - tlx) / (z.s * prev.w);
const v = (prev.h / 2 - tly) / (z.s * prev.h);
return {
s: z.s,
tx: win.w / 2 - u * z.s * win.w - (z.s - 1) * win.x,
ty: win.h / 2 - v * z.s * win.h - (z.s - 1) * win.y,
};
});
});
// Camera: picking another frame — or leaving the camera — starts from the
// whole picture inside its window (the library has its own reset rules).
useEffect(() => {
if (mode === 'camera') setLibZoom({ s: 1, tx: 0, ty: 0 });
}, [mode, selectedFrame]);
// Clamp pan/zoom so the photo always keeps COVERING the window: the framed
// opening (photo cover-drawn into it) or the ratio-crop band (photo
// contain-drawn over the whole viewer). Transform is P' = s*P + t; the rect
// the photo is drawn into, [dx,dx+dw], must land over [x, x+w], i.e.
// t in [x + w - s*(dx+dw), x - s*dx] (y likewise). At 1x the polaroid/wall
// case collapses to t = 0 (the photo already fills its opening exactly),
// while a crop band still allows sliding along the overflowing axis.
const clampZoomToWindow = (s: number, tx: number, ty: number) => {
const win = zoomWindowRect();
if (!win) return { s, tx, ty }; // plain library/camera: unchanged
const src =
libCropBand && imageFitRect
? { x: imageFitRect.dx, y: imageFitRect.dy, w: imageFitRect.dw, h: imageFitRect.dh }
: win;
const cl = (v: number, lo: number, hi: number) =>
lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v));
return {
s,
tx: cl(tx, win.x + win.w - s * (src.x + src.w), win.x - s * src.x),
ty: cl(ty, win.y + win.h - s * (src.y + src.h), win.y - s * src.y),
};
};
// Live feed band rect for the CAMERA preview: the photo-area that must show
// the export-equivalent sensor crop — the ratio band region (plain frames),
// the polaroid photo window or the wall-frame window. When present the frame
// worklet redraws the feed into it (native 4:3 shows the whole sensor);
// when null ([0]=0) the feed stays the legacy fullscreen cover draw.
const cameraPhotoRect =
mode === 'camera'
? polaroidGeo
? polaroidGeo.window
: selectedFrame === 'wallframe' && wallGeo && wallImage
? wallGeo.window
: regionBand
? region
: null
: null;
useEffect(() => {
const fz = frameZoomRef.current;
feedRectSync.setBlocking(
cameraPhotoRect
? [
1,
vw / vh,
cameraPhotoRect.x / vw,
cameraPhotoRect.y / vh,
cameraPhotoRect.w / vw,
cameraPhotoRect.h / vh,
fz?.s ?? 1,
fz?.u ?? 0.5,
fz?.v ?? 0.5,
]
: [0, 0, 0, 0, 0, 0, 0, 0.5, 0.5]
);
}, [cameraPhotoRect, vw, vh, feedRectSync, libZoom]);
// Camera watchdog: the Xiaomi wedges the preview — horizontal banding then
// black, while the native session still logs OPEN (no error, no onStopped)
// and only a full app restart revives it (a flip = session reconfigure does
// NOT clear the wedged Skia state). Every 3s, while the app is foregrounded
// in camera mode and a session has started at least once: remount the whole
// SkiaCamera (unmount clears the lib surface cache, fresh mount == restart)
// when the session reports inactive, or when the frame heartbeat has not
// advanced for two ticks (~6s) — frame delivery is dead even though the
// session claims to be OPEN. Budgeted to 3 restarts per successful start so
// a genuinely broken session logs out instead of restart-looping forever.
const remountCameraSession = useCallback(() => {
if (bouncePendingRef.current) return;
bouncePendingRef.current = true;
setAppActive(false);
setTimeout(() => {
// Fresh mount while inactive, so the new instance starts clean and
// CLOSED; reopen below once it exists.
setCameraMountNonce((n) => n + 1);
setTimeout(() => {
bouncePendingRef.current = false;
// Never force the camera on while the app is backgrounded.
if (AppState.currentState === 'active') setAppActive(true);
}, 300);
}, 300);
}, []);
useEffect(() => {
if (mode !== 'camera') return;
let lastBeat = -1;
let stallTicks = 0;
const id = setInterval(() => {
const beat = frameBeatSync.getDirty();
if (beat === lastBeat) stallTicks++;
else {
lastBeat = beat;
stallTicks = 0;
}
// Backgrounding pauses the session on purpose — never watchdog that.
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;
const reason = cameraActiveRef.current ? 'no frames for ~6s' : 'session inactive';
if (watchdogBudgetRef.current >= 3) {
console.warn('[watchdog] camera looks dead (' + reason + ') — restart budget exhausted, giving up');
return;
}
watchdogBudgetRef.current++;
console.warn('[watchdog] remounting camera session: ' + reason);
lastBeat = -1;
stallTicks = 0;
remountCameraSession();
}, 3000);
return () => clearInterval(id);
}, [mode, frameBeatSync, remountCameraSession]);
// ---- Custom text watermark: photo area, drag math, library overlay --------
// Armed = CUSTOM chip ON + non-empty text + live position callback. While
// armed, touches on the image place/move the text (see the wm branches in the
// camera + library gesture handlers); this is the only way to position it.
// Drawn in both modes whenever the chip is ON with text; TOUCHED only while
// the WATERMARK panel is open (wmEditing) — see the prop comment.
const wmVisible = !!(customWm.enabled && customWm.text.trim().length > 0);
const wmArmed = !!(wmVisible && wmEditing && onWmPositionChange);
const [wmDragPos, setWmDragPos] = useState<{ x: number; y: number } | null>(null);
// GPS lines: same deal, but they have no text editor — armed whenever the
// panel is open and the mark is drawn.
const gpsWmVisible = !!(useGeotag && gpsInfo && customFont && iconFont);
const gpsWmArmed = !!(gpsWmVisible && wmEditing && onGpsWmChange);
const [gpsDragPos, setGpsDragPos] = useState<{ x: number; y: number } | null>(null);
// Photo/window area the 0..1 fractions map onto — identical rule to
// renderGPSWatermark so both marks share the user-visible coordinate space.
const wmArea = () => {
// The FRAME, not just the photo window: the mark may be dragged anywhere
// on the card / frame (the flat bottom deck is where a caption belongs).
// A framed export's canvas IS that frame, so the same 0..1 fractions
// carry over to the burn-in.
const box = polaroidGeo?.card ?? wallGeo?.frame ?? null;
if (box) return { dx: box.x, dy: box.y, dw: box.w, dh: box.h };
if (regionBand) return { dx: region.x, dy: region.y, dw: region.w, dh: region.h };
if (mode === 'library' && imageFitRect) return imageFitRect;
return { dx: 0, dy: 0, dw: vw, dh: vh };
};
// The drawn mark's baseline origin. Everything that has to agree with the
// glyphs — the render, the double-tap box, the export — goes through here,
// and the ROTATE chip spins around this same point.
const wmAnchor = () => {
const area = wmArea();
const fracX = wmDragPos?.x ?? customWm.x;
const fracY = wmDragPos?.y ?? customWm.y;
return {
ax: area.dx + fracX * area.dw,
ay: area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35),
};
};
// The GPS block's TOP-LEFT corner (the two lines grow right and down from
// there) — the same 0..1 fractions the export burns in, so preview and file
// agree. Defaults are the old fixed top-left look.
const gpsAnchor = () => {
const area = wmArea();
const fracX = gpsDragPos?.x ?? gpsWm?.x ?? 0.05;
const fracY = gpsDragPos?.y ?? gpsWm?.y ?? 0.1;
return {
ax: area.dx + fracX * area.dw,
ay: area.dy + fracY * area.dh,
iconSize: gpsIconSize,
};
};
const wmRad = (wmRotation * Math.PI) / 180;
const wmFontSize = Math.round(vw * 0.032 * (customWm.size ?? 1));
const wmColor = customWm.color ?? '#f59e0b';
// FONT chip: an OS family resolved through the platform font manager; no
// family picked -> the bundled face customFont was built from, re-sized to
// the current SIZE so one code path serves both.
const wmFont = useMemo(() => {
const face = customWm.font
? Skia.FontMgr.System().matchFamilyStyle(customWm.font, FontStyle.Normal)
: customFont?.getTypeface() ?? null;
return face ? Skia.Font(face, wmFontSize) : null;
}, [customWm.font, wmFontSize, customFont]);
const wmFracFromXY = (x: number, y: number) => {
const area = wmArea();
const dw = area.dw || 1;
const dh = area.dh || 1;
return {
x: Math.max(0, Math.min(1, (x - area.dx) / dw)),
y: Math.max(0, Math.min(1, (y - area.dy) / dh)),
};
};
// placeWm/commitWm are called back-to-back inside one gesture handler, so
// reading the wmDragPos state would commit the PREVIOUS render's value (a
// tap with no move events committed nothing at all). Mirror it in a ref.
const wmDragPosRef = useRef<{ x: number; y: number } | null>(null);
const gpsDragPosRef = useRef<{ x: number; y: number } | null>(null);
// The mark a running gesture grabbed, decided at grant time (the box under
// the first touch) so a drag keeps hold of the mark it started on.
const wmDragTgtRef = useRef<'custom' | 'gps' | null>(null);
const setWmFrac = (frac: { x: number; y: number }, tgt: 'custom' | 'gps') => {
if (tgt === 'custom') {
if (!wmArmed) return;
wmDragPosRef.current = frac;
setWmDragPos(frac);
return;
}
if (!gpsWmArmed) return;
gpsDragPosRef.current = frac;
setGpsDragPos(frac);
};
const placeWm = (x: number, y: number, tgt: 'custom' | 'gps') =>
setWmFrac(wmFracFromXY(x, y), tgt);
// The mark drags RELATIVELY: it keeps its spot when the finger lands and only
// travels by the finger's delta. Placing it AT the touch (the old behavior)
// yanked the mark under the finger, so a pinch that started on it threw it
// across the frame. Anchor its fraction + the touch-down point here.
const wmDragRef = useRef<{
tgt: 'custom' | 'gps';
fx: number;
fy: number;
px: number;
py: number;
moved: boolean;
} | null>(null);
const beginWmDrag = (tgt: 'custom' | 'gps', x: number, y: number) => {
const frac =
tgt === 'custom'
? { x: wmDragPosRef.current?.x ?? customWm.x, y: wmDragPosRef.current?.y ?? customWm.y }
: {
x: gpsDragPosRef.current?.x ?? gpsWm?.x ?? 0.05,
y: gpsDragPosRef.current?.y ?? gpsWm?.y ?? 0.1,
};
wmDragRef.current = { tgt, fx: frac.x, fy: frac.y, px: x, py: y, moved: false };
};
const moveWmDrag = (x: number, y: number) => {
const d = wmDragRef.current;
if (!d) return;
const area = wmArea();
setWmFrac(
{
x: Math.max(0, Math.min(1, d.fx + (x - d.px) / (area.dw || 1))),
y: Math.max(0, Math.min(1, d.fy + (y - d.py) / (area.dh || 1))),
},
d.tgt
);
};
const commitWm = () => {
const pos = wmDragPosRef.current;
wmDragPosRef.current = null;
setWmDragPos(null);
if (wmArmed && onWmPositionChange && pos) onWmPositionChange(pos.x, pos.y);
const gpos = gpsDragPosRef.current;
gpsDragPosRef.current = null;
setGpsDragPos(null);
if (gpsWmArmed && onGpsWmChange && gpos) onGpsWmChange(gpos.x, gpos.y, gpsWm?.size ?? 1);
};
// Two fingers on a mark: scale THAT mark (custom SIZE / GPS mark size)
// instead of zooming the photo. 0.4..4x keeps it on the card.
const wmPinchRef = useRef<{ tgt: 'custom' | 'gps'; d: number; size: number } | null>(null);
const wmSizeOf = (tgt: 'custom' | 'gps') =>
tgt === 'custom' ? customWm.size ?? 1 : gpsWm?.size ?? 1;
const applyWmScale = (tgt: 'custom' | 'gps', size: number) => {
const s = Math.max(0.4, Math.min(4, size));
if (tgt === 'custom') onWmSizeChange?.(s);
else onGpsWmChange?.(gpsWm?.x ?? 0.05, gpsWm?.y ?? 0.1, s);
};
// Bounding box of the drawn mark (same anchor maths as renderCustomWatermark)
// so a double-tap ON the text re-opens the editor instead of moving it.
const wmBox = () => {
if (!wmVisible || !wmFont) return null;
const { ax, ay } = wmAnchor();
const w = Math.max(wmFont.measureText(customWm.text.trim()).width, wmFontSize * 2);
return { x: ax, y: ay - wmFontSize, w, h: Math.round(wmFontSize * 1.4) };
};
// A rotated mark needs the tap un-spun around the same anchor, otherwise
// the box stops covering the visible glyphs.
const boxHitAt = (
box: { x: number; y: number; w: number; h: number } | null,
x: number,
y: number,
origin: { x: number; y: number }
) => {
if (!box) return false;
if (wmRotation) {
const c = Math.cos(-wmRad);
const s = Math.sin(-wmRad);
const dx = x - origin.x;
const dy = y - origin.y;
x = origin.x + dx * c - dy * s;
y = origin.y + dx * s + dy * c;
}
return (
x >= box.x - 8 && x <= box.x + box.w + 8 && y >= box.y - 8 && y <= box.y + box.h + 8
);
};
const wmBoxHit = (
box: { x: number; y: number; w: number; h: number } | null,
x: number,
y: number
) => boxHitAt(box, x, y, wmAnchor());
// The GPS block covers both lines out to the widest one (the timestamp).
const gpsBox = () => {
const { ax, ay, iconSize } = gpsAnchor();
return { x: ax, y: ay, w: Math.round(iconSize * 8.5), h: Math.round(iconSize * 2.6) };
};
const gpsBoxHit = (x: number, y: number) => boxHitAt(gpsBox(), x, y, gpsAnchor());
// Which mark a touch is on. A box hit always wins; with neither box hit the
// lone armed mark takes the touch, so a tap anywhere still places it.
const wmTargetAt = (x: number, y: number): 'custom' | 'gps' | null => {
const custom = wmArmed && wmBoxHit(wmBox(), x, y);
const gps = gpsWmArmed && gpsBoxHit(x, y);
if (custom) return 'custom';
if (gps) return 'gps';
if (wmArmed) return 'custom';
if (gpsWmArmed) return 'gps';
return null;
};
// Touch coordinates are CANVAS coordinates: RN hands locationX/Y back with the
// touch layer's own transform undone, and that layer now carries the very same
// matrix as the Skia canvas (see touchZoomStyle), so a mark hit-test or a drag
// line reads the touch straight off — no inverse at any view zoom.
// The armed mark a two-finger pinch is ON. Boxes only: a pinch on empty space
// must not steal the gesture from the FRAME tab's view zoom.
const pinchMarkAt = (x: number, y: number): 'custom' | 'gps' | null => {
if (!wmEditing) return null;
if (wmArmed && wmBoxHit(wmBox(), x, y)) return 'custom';
if (gpsWmArmed && gpsBoxHit(x, y)) return 'gps';
return null;
};
// Two fingers on the FRAME tab magnify the whole VIEW about their midpoint
// (the photo zoom's maths, aimed at the view instead).
const applyViewPinch = (ts: { locationX: number; locationY: number }[]) => {
const [a, b] = ts;
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
const mx = (a.locationX + b.locationX) / 2;
const my = (a.locationY + b.locationY) / 2;
const p = viewPinchRef.current;
if (!p) {
viewPinchRef.current = { d, s: viewZoom.s, tx: viewZoom.tx, ty: viewZoom.ty, fx: mx, fy: my };
return;
}
const s = clampZoom((p.s * d) / Math.max(p.d, 1));
// f* is the canvas point the fingers pinch on: keep s*f + t constant with
// the OLD s/t (at 1x this is the old fx*(1-k), now correct when zoomed too).
setViewZoom(clampViewZoom({ s, tx: p.tx + p.fx * (p.s - s), ty: p.ty + p.fy * (p.s - s) }));
};
// Library framed (polaroid/wall) returns reuse the SAME responder handlers as
// plain library (see the framed JSX below): single-finger drags place/move
// the text watermark while armed, and otherwise DRAG the photo inside the
// framed window (same cover clamp as pinch, so the opening never shows the
// card/mat through it); pinch + double-tap zoom that same repositioning.
// The pan only bites once the photo is zoomed past 1x: at the cover scale it
// already fills the opening exactly, so the clamp has nowhere to move.
// FREE CROP gesture: grab an edge/corner to resize, the inside to move. Drag
// maths runs in SOURCE fractions (0..1 of the photo) so the exported crop is
// exactly the rectangle the user sees, independent of the display scale.
const cropDragRef = useRef<{ mode: string; fx: number; fy: number; r: CropRect } | null>(null);
const cropHitMode = (x: number, y: number): string | null => {
const px = freeCropPx;
if (!px) return null;
const T = 28;
if (x < px.x - T || x > px.x + px.w + T || y < px.y - T || y > px.y + px.h + T) return null;
let m = '';
if (Math.abs(y - px.y) <= T) m += 't';
else if (Math.abs(y - (px.y + px.h)) <= T) m += 'b';
if (Math.abs(x - px.x) <= T) m += 'l';
else if (Math.abs(x - (px.x + px.w)) <= T) m += 'r';
return m || 'move';
};
const beginCropDrag = (e: any) => {
const t = e.nativeEvent.touches?.[0] ?? e.nativeEvent;
const fit = imageFitRect;
if (!fit || !(fit.dw > 0 && fit.dh > 0)) return;
const mode = cropHitMode(t.locationX, t.locationY);
if (!mode) return;
cropDragRef.current = {
mode,
fx: (t.locationX - fit.dx) / fit.dw,
fy: (t.locationY - fit.dy) / fit.dh,
r: cropRect,
};
};
const moveCropDrag = (e: any) => {
const d = cropDragRef.current;
const fit = imageFitRect;
if (!d || !fit || !(fit.dw > 0 && fit.dh > 0)) return;
const t = e.nativeEvent.touches?.[0] ?? e.nativeEvent;
const dfx = (t.locationX - fit.dx) / fit.dw - d.fx;
const dfy = (t.locationY - fit.dy) / fit.dh - d.fy;
const cl = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
let { x: rx, y: ry, w: rw, h: rh } = d.r;
if (d.mode === 'move') {
rx = cl(rx + dfx, 0, 1 - rw);
ry = cl(ry + dfy, 0, 1 - rh);
} else {
if (d.mode.includes('l')) {
const nx = cl(rx + dfx, 0, rx + rw - MIN_CROP_FRAC);
rw += rx - nx;
rx = nx;
}
if (d.mode.includes('r')) rw = cl(rw + dfx, MIN_CROP_FRAC, 1 - rx);
if (d.mode.includes('t')) {
const ny = cl(ry + dfy, 0, ry + rh - MIN_CROP_FRAC);
rh += ry - ny;
ry = ny;
}
if (d.mode.includes('b')) rh = cl(rh + dfy, MIN_CROP_FRAC, 1 - ry);
}
onCropRectChange?.({ x: rx, y: ry, w: rw, h: rh });
};
const onLibTouchStart = (e: any) => {
if (libFreeCrop) {
beginCropDrag(e);
return;
}
const ts = e.nativeEvent.touches;
if (ts.length === 1) startHold(ts[0].pageX, ts[0].pageY);
else endHold();
panRef.current =
ts.length === 1
? {
x: ts[0].locationX,
y: ts[0].locationY,
px: ts[0].pageX,
py: ts[0].pageY,
tx: viewZoomed ? viewZoom.tx : libZoom.tx,
ty: viewZoomed ? viewZoom.ty : libZoom.ty,
}
: null;
panUsedRef.current = false;
if (ts.length === 1 && (wmArmed || gpsWmArmed)) {
const { x, y } = { x: ts[0].locationX, y: ts[0].locationY };
const boxHit = (wmArmed && wmBoxHit(wmBox(), x, y)) || (gpsWmArmed && gpsBoxHit(x, y));
// Anything zoomed (photo or view): only a touch ON a mark takes the
// gesture, the rest keeps panning / magnifying. At 1x the lone armed mark
// takes any touch so a plain tap still places it.
wmDragTgtRef.current =
(libZoom.s <= 1.01 && viewZoom.s <= 1.01) || boxHit ? wmTargetAt(x, y) : null;
if (wmDragTgtRef.current) beginWmDrag(wmDragTgtRef.current, x, y);
} else wmDragTgtRef.current = null;
if (ts.length >= 2) {
const [a, b] = ts;
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
const mx = (a.locationX + b.locationX) / 2;
const my = (a.locationY + b.locationY) / 2;
pinchUsedRef.current = true;
lastTapRef.current = null;
if (wmEditing) {
// WATERMARK tab open: a two-finger gesture ON a mark scales it. One
// that lands off the marks falls through to the FRAME tab's view zoom
// (seeded in the move handler), so it never zooms the photo either.
const mi = { x: mx, y: my };
const tgt = pinchMarkAt(mi.x, mi.y);
wmPinchRef.current = tgt ? { tgt, d, size: wmSizeOf(tgt) } : null;
pinchRef.current = null;
return;
}
// FRAME tab open: the pinch magnifies the VIEW, not the photo — the
// export keeps the framing the user sees at 1x.
if (frameTabActive) {
pinchRef.current = null;
return;
}
pinchRef.current = { d, s: libZoom.s, tx: libZoom.tx, ty: libZoom.ty, fx: mx, fy: my };
}
};
const onLibTouchMove = (e: any) => {
if (libFreeCrop) {
moveCropDrag(e);
return;
}
const ts = e.nativeEvent.touches;
if (ts.length === 1) holdNoteMove(ts[0].pageX, ts[0].pageY);
else endHold();
// WATERMARK tab open: two fingers scale the mark under them (or do
// nothing) — the photo itself only zooms once the editor is closed.
if (ts.length >= 2 && wmEditing) {
// The second finger normally lands on its own, so the START handler never
// saw the pair and wmPinchRef is still null -- which is why pinching a
// mark worked on the camera (that path seeds here) but not in the library
// editor. Seed the baseline the same way.
let p = wmPinchRef.current;
if (!p) {
const mi = {
x: (ts[0].locationX + ts[1].locationX) / 2,
y: (ts[0].locationY + ts[1].locationY) / 2,
};
const tgt = pinchMarkAt(mi.x, mi.y);
if (tgt) {
p = {
tgt,
d: Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY),
size: wmSizeOf(tgt),
};
wmPinchRef.current = p;
}
}
if (p) {
const d = Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY);
applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1));
pinchUsedRef.current = true;
return;
}
// Off the marks on the FRAME tab: fall through to the view zoom below.
}
// FRAME tab: two fingers magnify the whole view — frame, photo and marks
// together — so a watermark can be dragged against a big picture. Preview
// only; nothing export-facing reads viewZoom.
if (ts.length >= 2 && frameTabActive) {
applyViewPinch(ts);
pinchUsedRef.current = true;
return;
}
// Watermark armed + one finger + unzoomed: the drag moves the mark (not the
// photo). Zoomed-in keeps pinch/pan so the user can inspect detail first.
if (ts.length === 1 && wmDragTgtRef.current) {
const t = { x: ts[0].locationX, y: ts[0].locationY };
const d = wmDragRef.current;
if (d && (Math.abs(t.x - d.px) > 6 || Math.abs(t.y - d.py) > 6)) {
d.moved = true;
}
if (d?.moved) moveWmDrag(t.x, t.y);
return;
}
// FRAME tab, magnified view: one finger drags the picture inside the
// viewport instead of the framed photo (preview only).
if (ts.length === 1 && !pinchRef.current && viewZoomed) {
const t = ts[0];
const pan0 = panRef.current;
if (pan0) {
// pageX/pageY: absolute screen dp, immune to the view transform, so the
// picture tracks the finger 1:1 (locationX is scaled by the matrix).
const dx = t.pageX - pan0.px;
const dy = t.pageY - pan0.py;
if (Math.abs(dx) > 6 || Math.abs(dy) > 6) panUsedRef.current = true;
if (panUsedRef.current) {
setViewZoom(clampViewZoom({ s: viewZoom.s, tx: pan0.tx + dx, ty: pan0.ty + dy }));
}
}
return;
}
// One finger, no pinch running: drag the photo inside the framed window
// (polaroid card / wall-frame opening). Goes through the same clamp as
// pinch, so the opening always stays covered by the picture.
if (ts.length === 1 && !pinchRef.current && zoomWindowRect()) {
const t = ts[0];
const pan0 = panRef.current;
if (pan0) {
const dx = t.locationX - pan0.x;
const dy = t.locationY - pan0.y;
if (Math.abs(dx) > 6 || Math.abs(dy) > 6) panUsedRef.current = true;
if (panUsedRef.current) {
setLibZoom(clampZoomToWindow(libZoom.s, pan0.tx + dx, pan0.ty + dy));
}
}
return;
}
let p = pinchRef.current;
if (ts.length >= 2 && !p) {
// Responder grant fires once on the first finger; a second finger that
// lands later only produces move events, so seed the pinch baseline here.
const [a, b] = ts;
pinchUsedRef.current = true;
lastTapRef.current = null;
p = pinchRef.current = {
d: Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY),
s: libZoom.s,
tx: libZoom.tx,
ty: libZoom.ty,
fx: (a.locationX + b.locationX) / 2,
fy: (a.locationY + b.locationY) / 2,
};
}
if (ts.length >= 2 && p) {
const [a, b] = ts;
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
const s = clampZoom((p.s * d) / Math.max(p.d, 1));
const k = s / p.s;
const next = clampZoomToWindow(
s,
p.fx - k * (p.fx - p.tx),
p.fy - k * (p.fy - p.ty)
);
setLibZoom(next);
}
};
const onLibTouchEnd = (e: any) => {
if (libFreeCrop) {
cropDragRef.current = null;
return;
}
const peeked = endHold();
const wasPinch = pinchUsedRef.current;
const wasPan = panUsedRef.current;
// Decided at touch-down; the reset below would lose it.
const wmTgt = wmDragTgtRef.current;
const wmMoved = !!wmDragRef.current?.moved;
pinchRef.current = null;
wmPinchRef.current = null;
wmDragTgtRef.current = null;
wmDragRef.current = null;
viewPinchRef.current = null;
pinchUsedRef.current = false;
panRef.current = null;
panUsedRef.current = false;
// A drag is neither a tap nor the first half of a double-tap.
if (wasPan || wmMoved || peeked) lastTapRef.current = null;
if (wasPinch || wasPan || peeked) return; // a double-tap is always a single finger
const x = e.nativeEvent.locationX;
const y = e.nativeEvent.locationY;
// Mark drawn but its editor CLOSED: a double-tap ON the mark re-opens the
// editor (and the placement layer with it). A single tap is NOT consumed —
// this block records nothing, so the viewer rules below still run and the
// photo keeps pinch / double-tap zoom / pan.
if (!wmArmed && !gpsWmArmed && (wmVisible || gpsWmVisible) && libZoom.s <= 1.01) {
const prev = lastTapRef.current;
if (
prev &&
Date.now() - prev.t < 350 &&
Math.hypot(x - prev.x, y - prev.y) < 50 &&
(wmBoxHit(wmBox(), x, y) || gpsBoxHit(x, y))
) {
lastTapRef.current = null;
onWmEdit?.();
return;
}
}
// Watermark armed: a tap places + commits the text and skips the double-tap
// zoom bookkeeping (double-tap zoom is disabled while placing).
if ((wmArmed || gpsWmArmed) && ((libZoom.s <= 1.01 && viewZoom.s <= 1.01) || wmTgt)) {
const now = Date.now();
const prev = lastTapRef.current;
lastTapRef.current = { x, y, t: now };
// Second tap ON a mark (keyboard hidden, still placing): back to the
// editor — the mark is only embedded by ACCEPT in the panel.
if (
prev &&
now - prev.t < 350 &&
Math.hypot(x - prev.x, y - prev.y) < 50 &&
(wmBoxHit(wmBox(), x, y) || gpsBoxHit(x, y))
) {
lastTapRef.current = null;
onWmEdit?.();
return;
}
// A drag has already moved the mark where the finger left it; only a
// plain tap drops it at the tapped point.
if (!wmMoved) placeWm(x, y, wmTgt ?? wmTargetAt(x, y) ?? 'custom');
commitWm();
return;
}
const now = Date.now();
const prev = lastTapRef.current;
lastTapRef.current = { x, y, t: now };
if (prev && now - prev.t < 350 && Math.hypot(x - prev.x, y - prev.y) < 50) {
lastTapRef.current = null;
setLibZoom((z) => {
if (z.s > 1.01) return { s: 1, tx: 0, ty: 0 }; // double-tap again -> 100%
// Clamp the focal point into the photo's display rect — the framed
// window when one is showing (polaroid/wall), the composition region
// under a ratio band, or the contain rect — so a double-tap that lands
// in the black letterbox cannot fling the photo away.
let fx = x;
let fy = y;
const win = zoomWindowRect();
const fit = win
? { dx: win.x, dy: win.y, dw: win.w, dh: win.h }
: regionBand
? { dx: region.x, dy: region.y, dw: region.w, dh: region.h }
: imageFitRect;
if (fit) {
fx = Math.min(Math.max(fx, fit.dx), fit.dx + fit.dw);
fy = Math.min(Math.max(fy, fit.dy), fit.dy + fit.dh);
}
const s = 2.5;
// zoom into the tapped point, then clamp so the photo keeps covering
// the framed window (plain library keeps the raw focal transform).
return clampZoomToWindow(s, fx - s * fx, fy - s * fy);
});
}
};
// Skia affine 3x3, row-major per SkMatrix getMatrix():
// [s, 0, tx] x' = s*x + tx
// [0, s, ty] y' = s*y + ty
// [0, 0, 1]
// Deliberately NOT built via Skia.Matrix().translate().scale(): on the
// native Android side both are pre-concats (M = T·M then M = S·M) so the
// final matrix is S·T and the translation gets scaled (P' = s·(P + t)) —
// zooming then flings the photo up/left instead of holding the focal point.
const libZoomMatrix = useMemo(
() => [libZoom.s, 0, libZoom.tx, 0, libZoom.s, libZoom.ty, 0, 0, 1],
[libZoom]
);
// Committed crop: one more matrix ON TOP of the photo group's zoom/pan, so
// the band's framed contents land on the viewer rect — outer·inner =
// [ks, 0, k·tx + s·ox, 0, ks, k·ty + s·oy, 0, 0, 1].
const libViewMatrix = useMemo(
() =>
libCropView
? [
libZoom.s * libCropView.k,
0,
libZoom.s * libCropView.ox + libCropView.k * libZoom.tx,
0,
libZoom.s * libCropView.k,
libZoom.s * libCropView.oy + libCropView.k * libZoom.ty,
0,
0,
1,
]
: libZoomMatrix,
[libCropView, libZoom, libZoomMatrix]
);
// FRAME-tab view zoom as a Skia matrix, drawn as an OUTER group around a
// branch's whole canvas. Preview only: nothing export-facing reads it.
const viewZoomMatrix = useMemo(
() => [viewZoom.s, 0, viewZoom.tx, 0, viewZoom.s, viewZoom.ty, 0, 0, 1],
[viewZoom]
);
// Same transform on the touch layer, and that is the point of it: RN scales
// about the view's CENTRE by default, which dragged the layer's local space
// off the canvas — and every mark hit-test with it — as soon as the view was
// zoomed. Pinning the origin to the top left makes the layer map exactly like
// the Skia matrix the canvas draws with (P' = s·P + t), which is also what
// lets the gesture handlers use their coordinates as canvas coordinates.
const touchZoomStyle = (z: { s: number; tx: number; ty: number }) =>
z.s === 1 && z.tx === 0 && z.ty === 0
? undefined
: {
transformOrigin: 'top left',
transform: [{ translateX: z.tx }, { translateY: z.ty }, { scale: z.s }],
};
// The view zoom composes OVER the committed crop (outer·inner), exactly like
// the canvas draws it.
const libCropTouchStyle = touchZoomStyle(
libCropView
? {
s: viewZoom.s * libCropView.k,
tx: viewZoom.s * libCropView.ox + viewZoom.tx,
ty: viewZoom.s * libCropView.oy + viewZoom.ty,
}
: viewZoom
);
// Export-facing summary of the current framed-window photo reposition. The
// gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction
// of the photo's cover plane sitting at the WINDOW CENTER — layout units the
// export engine can apply at its own pixel size (the Skia render draws the
// source sub-rectangle that stays visible through the frame's opening). The
// handle method reads this ref because useImperativeHandle captures once.
const frameZoomRef = useRef<{ s: number; u: number; v: number } | null>(null);
frameZoomRef.current = (() => {
const win = zoomWindowRect();
// A ratio crop reports through cropRatioRectRef instead (its band is the
// window here, and one export is only ever one kind of crop).
if (libCropActive) return null;
if (!win || libZoom.s <= 1.001) return null;
const tlx = libZoom.tx + (libZoom.s - 1) * win.x;
const tly = libZoom.ty + (libZoom.s - 1) * win.y;
return {
s: libZoom.s,
u: (win.w / 2 - tlx) / (libZoom.s * win.w),
v: (win.h / 2 - tly) / (libZoom.s * win.h),
};
})();
// Fixed-ratio CROP, for export: the export crops to its own band, but the
// user may have pushed the picture around inside it, so report the part of
// the photo the band is currently SHOWING as fractions of the
// (post-rotation) source — the same currency as the FREE rectangle, so the
// export engine needs to know nothing about bands or zoom.
const cropRatioRectRef = useRef<CropRect | null>(null);
cropRatioRectRef.current = (() => {
if (!libCropBand || !imageFitRect) return null;
const s = Math.max(1, libZoom.s);
const fx = (x: number) => ((x - libZoom.tx) / s - imageFitRect.dx) / imageFitRect.dw;
const fy = (y: number) => ((y - libZoom.ty) / s - imageFitRect.dy) / imageFitRect.dh;
const cl = (v: number) => Math.min(1, Math.max(0, v));
const x1 = cl(fx(libCropBand.dx));
const y1 = cl(fy(libCropBand.dy));
const x2 = cl(fx(libCropBand.dx + libCropBand.dw));
const y2 = cl(fy(libCropBand.dy + libCropBand.dh));
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
})();
// Border/bars scale with the visible photo rect so a framed export stays
// proportional to the picture (band on the camera, contain rect in library).
const borderWidth = Math.min(frameRect.w, frameRect.h) * 0.05;
const barHeight = frameRect.h * 0.12;
const renderFrameOverlay = () => {
if (selectedFrame === 'none') return null;
// Library polaroid is drawn by its own contain-fit canvas (see the early
// return further down); the overlay here handles the frames that sit on top
// of an already fullscreen picture.
if (selectedFrame === 'polaroid') {
const geo = polaroidGeo;
if (!geo) return null;
const { card, window: win } = geo;
// Camera polaroid, composited in view (dp) coordinates: letterbox the
// live feed black outside the card, paint the white card around the photo
// window and a thin seam at the window edge. The window itself stays
// transparent so the fullscreen feed shows through underneath — drawn
// this way the card can never overflow the screen, and it lines up
// exactly with the grain clip / GPS anchor (same polaroidGeo rects).
const black = '#000000';
const white = '#faf9f6';
const seam = '#e5e5e5';
return (
<Group>
{/* black letterbox bars around the card */}
<Rect x={0} y={0} width={vw} height={card.y} color={black} />
<Rect x={0} y={card.y + card.h} width={vw} height={vh - card.y - card.h} color={black} />
<Rect x={0} y={card.y} width={card.x} height={card.h} color={black} />
<Rect x={card.x + card.w} y={card.y} width={vw - card.x - card.w} height={card.h} color={black} />
{/* white card area around the photo window */}
<Rect x={card.x} y={card.y} width={card.w} height={win.y - card.y} color={white} />
<Rect x={card.x} y={win.y + win.h} width={card.w} height={card.y + card.h - win.y - win.h} color={white} />
<Rect x={card.x} y={win.y} width={win.x - card.x} height={win.h} color={white} />
<Rect x={win.x + win.w} y={win.y} width={card.x + card.w - win.x - win.w} height={win.h} color={white} />
{/* seam between photo window and card */}
<Rect
x={win.x + 0.5}
y={win.y + 0.5}
width={win.w - 1}
height={win.h - 1}
color={seam}
style="stroke"
strokeWidth={1}
/>
</Group>
);
}
if (selectedFrame === 'wallframe') {
const geo = wallGeo;
if (!geo || !wallImage) return null; // PNG still loading → bare feed
const { frame, drawMatrix } = geo;
// Letterbox the live feed black outside the frame rect, then draw the
// artwork ROTATED into that rect: its transparent window shows the feed
// (cover-cropped center, WYSIWYG with the export crop) and the mat covers
// the rest of the screen — the feed never fills the whole display.
const black = '#000000';
return (
<Group>
<Rect x={0} y={0} width={vw} height={frame.y} color={black} />
<Rect x={0} y={frame.y + frame.h} width={vw} height={vh - frame.y - frame.h} color={black} />
<Rect x={0} y={frame.y} width={frame.x} height={frame.h} color={black} />
<Rect x={frame.x + frame.w} y={frame.y} width={vw - frame.x - frame.w} height={frame.h} color={black} />
<Group matrix={drawMatrix}>
<SkiaImage image={wallImage} x={0} y={0} width={WALLFRAME_PNG_W} height={WALLFRAME_PNG_H} fit="fill" />
</Group>
</Group>
);
}
if (selectedFrame === 'classic-white') {
// Border drawn on the visible photo rect (frameRect), so it always sits
// at the picture's edge — including the letterboxed library viewer.
return (
<Group color="white">
<Rect x={frameRect.x} y={frameRect.y} width={frameRect.w} height={borderWidth} />
<Rect x={frameRect.x} y={frameRect.y + frameRect.h - borderWidth} width={frameRect.w} height={borderWidth} />
<Rect x={frameRect.x} y={frameRect.y} width={borderWidth} height={frameRect.h} />
<Rect x={frameRect.x + frameRect.w - borderWidth} y={frameRect.y} width={borderWidth} height={frameRect.h} />
</Group>
);
}
if (selectedFrame === 'cinematic') {
// Letterbox bars hug the visible photo rect's top/bottom edges.
return (
<Group color="black">
<Rect x={frameRect.x} y={frameRect.y} width={frameRect.w} height={barHeight} />
<Rect x={frameRect.x} y={frameRect.y + frameRect.h - barHeight} width={frameRect.w} height={barHeight} />
</Group>
);
}
return null;
};
const renderCustomWatermark = () => {
if (!wmVisible || !wmFont) return null;
// Baseline = dragged anchor + ~0.35em drop (the grabbed anchor reads as the
// text's visual center). Must match exportEngine step 8's formula.
const { ax, ay } = wmAnchor();
return (
<Group origin={{ x: ax, y: ay }} transform={wmRotation ? [{ rotate: wmRad }] : undefined}>
<SkiaText x={ax} y={ay} text={customWm.text.trim()} font={wmFont} color={wmColor} />
</Group>
);
};
// Vignetting (FX): darken the four corners of the PHOTO area. A radial
// gradient (clear centre → black corners) sized to the given rect, mirroring
// the export pass (exportEngine 6b) so preview and file match. Drawn on the
// photo only — never on a polaroid/wall mat.
const vignetteAmount = (adjustments.vignette ?? 0) / 10;
const renderVignette = (x: number, y: number, w: number, h: number) => {
if (vignetteAmount <= 0 || w <= 0 || h <= 0) return null;
// <Shader> takes a RuntimeEffect, not a gradient SkShader (passing the latter
// throws "Object is not a HostObject"). RadialGradient is the declarative form.
return (
<Rect x={x} y={y} width={w} height={h} opacity={0.9 * vignetteAmount}>
<RadialGradient
c={{ x: x + w / 2, y: y + h / 2 }}
r={Math.hypot(w, h) / 2}
colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0)', 'rgba(0,0,0,1)']}
positions={[0, 0.45, 1]}
mode={TileMode.Clamp}
/>
</Rect>
);
};
const renderGPSWatermark = () => {
if (!gpsWmVisible || !gpsFont || !gpsIconFont) return null;
const locationName = gpsInfo.locality || 'STREET VIEW';
const d = new Date(gpsInfo.timestamp);
const pad = (n: number) => String(n).padStart(2, '0');
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
// TOP-LEFT anchored block at the dragged fraction of the same area the
// custom mark uses (frame card / photo window / screen). A framed export's
// canvas IS that frame, so the fractions carry straight to the burn-in -
// exportEngine step 8 draws with the identical formula. Each enabled line
// (place name / shot time) stacks under the previous one.
const lines: { icon: string; text: string }[] = [];
if (gpsWm?.showName !== false) lines.push({ icon: '📍', text: locationName });
if (gpsWm?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
if (!lines.length) return null;
const { ax, ay, iconSize } = gpsAnchor();
const textColor = gpsWm?.color ?? '#f59e0b';
// One clear space (0.6em ~ a mono space) between each icon and its text.
const gap = (w: number) => Math.round(Math.max(w, iconSize) + iconSize * 0.6);
// The old fixed row1/row2 spacing, generalised to N rows.
const rowY = (i: number) => ay + Math.round(iconSize * (1.1 + i * 1.2));
return (
<Group origin={{ x: ax, y: ay }} transform={wmRotation ? [{ rotate: wmRad }] : undefined}>
{lines.map((line, i) => (
<React.Fragment key={line.icon}>
{/* Icons white so they read as glyphs, not part of the coloured text */}
<SkiaText x={ax} y={rowY(i)} text={line.icon} font={gpsIconFont} color="#ffffff" />
<SkiaText x={ax + gap(gpsIconFont.measureText(line.icon).width)} y={rowY(i)} text={line.text} font={gpsFont} color={textColor} />
</React.Fragment>
))}
</Group>
);
};
const renderCameraOverlays = () => {
const framedWin = polaroidGeo?.window ?? wallGeo?.window ?? null;
return (
<>
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
<Group matrix={viewZoomMatrix}>
{/* Aspect-ratio band: black out the live feed outside the composition
region. Frames with their own geometry (polaroid/wall) draw their
own bars later, so this only fires for the plain frames. */}
{regionBand && (
<Group color="#000000">
<Rect x={0} y={0} width={vw} height={region.y} />
<Rect x={0} y={region.y + region.h} width={vw} height={vh - region.y - region.h} />
<Rect x={0} y={region.y} width={region.x} height={region.h} />
<Rect x={region.x + region.w} y={region.y} width={vw - region.x - region.w} height={region.h} />
</Group>
)}
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
{/* Grain belongs to the photo; on a framed photo it must not spill
onto the mat, so it is clipped to the photo window. */}
<Rect
x={framedWin ? framedWin.x : region.x}
y={framedWin ? framedWin.y : region.y}
width={framedWin ? framedWin.w : region.w}
height={framedWin ? framedWin.h : region.h}
opacity={grainOpacity}
>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{renderVignette(
framedWin ? framedWin.x : region.x,
framedWin ? framedWin.y : region.y,
framedWin ? framedWin.w : region.w,
framedWin ? framedWin.h : region.h
)}
{renderFrameOverlay()}
{renderGPSWatermark()}
{renderCustomWatermark()}
</Group>
</Canvas>
{/* Rule-of-thirds guide, clipped to the composition region (fullscreen
when no ratio band). */}
<View
pointerEvents="none"
className="absolute opacity-20 overflow-hidden"
style={{ left: region.x, top: region.y, width: region.w, height: region.h }}
>
<View style={{ position: 'absolute', left: region.w / 3, top: 0, bottom: 0, width: 1, backgroundColor: '#ffffff' }} />
<View style={{ position: 'absolute', right: region.w / 3, top: 0, bottom: 0, width: 1, backgroundColor: '#ffffff' }} />
<View style={{ position: 'absolute', top: region.h / 3, left: 0, right: 0, height: 1, backgroundColor: '#ffffff' }} />
<View style={{ position: 'absolute', bottom: region.h / 3, left: 0, right: 0, height: 1, backgroundColor: '#ffffff' }} />
</View>
</>
);
};
if (mode === 'camera') {
if (!cameraPermissionGranted) {
return (
<View className="flex-1 bg-black items-center justify-center p-8">
<Text className="text-zinc-400 font-mono text-center mb-4">
Camera permission is required to use the viewfinder.
</Text>
<TouchableOpacity
onPress={onRequestCameraPermission}
className="bg-amber-500 px-6 py-3 rounded-full"
activeOpacity={0.7}
>
<Text className="text-black font-mono text-xs font-bold">GRANT PERMISSION</Text>
</TouchableOpacity>
</View>
);
}
if (!device) {
return (
<View className="flex-1 bg-black items-center justify-center p-8">
<Text className="text-zinc-500 font-mono text-center text-xs">
Camera not available on this device
</Text>
</View>
);
}
return (
<View className="flex-1 relative overflow-hidden bg-black">
{/* FRAME-tab view zoom: the feed and its gesture layer magnify together
(preview only — a capture is untouched) so the watermark can be
dragged against a big picture. The overlays mirror the same matrix. */}
<View style={[StyleSheet.absoluteFill, touchZoomStyle(viewZoom)]}>
<SkiaCamera
key={'cam-' + facing + '-' + cameraMountNonce}
ref={skiaCameraRef}
style={StyleSheet.absoluteFill}
device={device}
isActive={appActive && !ultraWideOn}
outputs={cameraOutputs}
pixelFormat="yuv"
targetResolution={frameTarget}
onStarted={handleCameraStarted}
onStopped={handleCameraStopped}
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. */}
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onResponderGrant={onImageDragGrant}
onResponderMove={onImageDragMove}
onResponderRelease={onImageDragEnd}
onResponderTerminate={onImageDragEnd}
/>
</View>
{focusPoint && (
/* The locked-AE/AF reticle IS the unlock control: the old
"AE/AF LOCKED — TAP TO UNLOCK" banner was removed from the preview
(it sat under the zoom-preset row), so tapping the lock reticle
releases the lock. */
<TouchableOpacity
onPress={handleUnlockFocus}
activeOpacity={0.7}
className="absolute"
style={{ left: focusPoint.x - 26, top: focusPoint.y - 26 }}
>
<View className="w-[52px] h-[52px] rounded-full border-[1.5px] border-amber-400 items-center justify-center">
<View className="w-9 h-9 rounded-full bg-amber-400/15 items-center justify-center">
<Lock size={14} color="#f59e0b" strokeWidth={2.5} />
</View>
</View>
</TouchableOpacity>
)}
{focusPoint && onExposureChange && (
<>
{/* EV value readout (left of the slider) */}
<View pointerEvents="none" className="absolute" style={{ right: 64, top: evThumbY - 12 }}>
<Text className="text-amber-400 font-mono text-xs font-bold bg-black/50 px-1.5 py-0.5 rounded">
EV {(evStops > 0 ? '+' : '') + evStops.toFixed(1)}
</Text>
</View>
{/* Vertical track: drag up = brighter, drag down = darker */}
<View
className="absolute"
style={{ left: vw - 56, top: evTrackTop, width: 40, height: evTrackH }}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onEvSliderGrant}
onResponderMove={onEvSliderMove}
onResponderRelease={onEvSliderRelease}
onResponderTerminate={onEvSliderRelease}
>
<View pointerEvents="none" className="absolute left-1/2 top-0 bottom-0 w-[2px] -ml-px bg-white/20 rounded-full" />
<View
pointerEvents="none"
className="absolute w-8 h-8 rounded-full border-2 border-amber-400 bg-black/60"
style={{ top: evThumbY - evTrackTop - 16, left: 4 }}
/>
</View>
<View pointerEvents="none" className="absolute" style={{ left: vw - 48, top: evTrackTop - 22 }}>
<Text className="text-zinc-400 font-mono text-[10px] text-center w-9">+3</Text>
</View>
<View pointerEvents="none" className="absolute" style={{ left: vw - 48, top: evTrackTop + evTrackH + 6 }}>
<Text className="text-zinc-400 font-mono text-[10px] text-center w-9">-3</Text>
</View>
</>
)}
{renderCameraOverlays()}
{/* Native zoom presets — snap to a controller zoom ratio. 1x is the
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). 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">
{zoomPresets.map((z) => (
<TouchableOpacity
key={z}
onPress={() => applyZoom(z)}
className={'px-3.5 py-1.5 rounded-full ' + (zoomRatio === z ? 'bg-amber-500' : '')}
activeOpacity={0.7}
>
<Text
className={
'font-mono text-xs font-bold ' +
(zoomRatio === z ? 'text-black' : 'text-white')
}
>
{z}x
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
);
}
// Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom.
const toneOn = toneParams.some((v) => v !== 0);
// One object for every <Shader> below; keys must match TONE_SKSL's uniforms.
const toneUniforms = {
dr: toneParams[0],
hl: toneParams[1],
sh: toneParams[2],
vib: toneParams[3],
shTr: toneParams[4],
shTg: toneParams[5],
shTb: toneParams[6],
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 + HDF EFFECT 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.
// 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={libBlur} 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
// the photo aspect ratio, so fit="fill" never distorts.
if (mode === 'library' && polaroidGeo && skiaImage && libraryImageUri) {
const win = polaroidGeo.window;
const card = polaroidGeo.card;
const winRect = rect(win.x, win.y, win.w, win.h);
return (
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={viewZoomMatrix}>
<Rect x={card.x} y={card.y} width={card.w} height={card.h} color="#faf9f6" />
{/* Photo is clipped to the window and repositioned by pinch/drag;
the card, seam, grain and watermarks stay put. */}
<Group clip={winRect}>
<Group matrix={libZoomMatrix}>
<ColorMatrix matrix={colorMatrix} />
{libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, polaroidFit)}
</Group>
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{renderVignette(win.x, win.y, win.w, win.h)}
{/* Thin seam between image window and card */}
<Rect
x={win.x + 0.5}
y={win.y + 0.5}
width={win.w - 1}
height={win.h - 1}
color="#e5e5e5"
style="stroke"
strokeWidth={1}
/>
{renderGPSWatermark()}
{renderCustomWatermark()}
</Group>
</Canvas>
<View
className="absolute inset-0"
style={libCropTouchStyle}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
onResponderTerminate={onLibTouchEnd}
/>
</View>
);
}
// WALL FRAME (library): the photo is cover-cropped (center) into the frame's
// fixed 2:3 window (rotated artwork) — the mat opening of a real frame hides
// the print edges. The window never letterboxes a mismatched aspect; the
// artwork PNG is drawn rotated over the crop afterwards (its transparent
// window reveals the photo).
if (mode === 'library' && wallGeo && wallImage && skiaImage && libraryImageUri) {
const win = wallGeo.window;
const drawMatrix = wallGeo.drawMatrix;
const winRect = rect(win.x, win.y, win.w, win.h);
return (
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={viewZoomMatrix}>
<Rect x={0} y={0} width={vw} height={vh} color="#000000" />
{/* Photo is clipped to the mat window and repositioned by pinch/
drag; the artwork, grain and watermarks stay put. */}
<Group clip={winRect}>
<Group matrix={libZoomMatrix}>
<ColorMatrix matrix={colorMatrix} />
{libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, 'cover')}
</Group>
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{renderVignette(win.x, win.y, win.w, win.h)}
{/* Artwork (rotated) over the crop: transparent window reveals it, mat
covers the rest. Drawn last so semi-transparent mat edges blend over
the photo instead of the reverse. */}
<Group matrix={drawMatrix}>
<SkiaImage image={wallImage} x={0} y={0} width={WALLFRAME_PNG_W} height={WALLFRAME_PNG_H} fit="fill" />
</Group>
{renderGPSWatermark()}
{renderCustomWatermark()}
</Group>
</Canvas>
<View
className="absolute inset-0"
style={libCropTouchStyle}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
onResponderTerminate={onLibTouchEnd}
/>
</View>
);
}
return (
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
{libraryImageUri && skiaImage ? (
<>
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={viewZoomMatrix}>
<Group matrix={libViewMatrix}>
<ColorMatrix matrix={colorMatrix} />
{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)} />
)}
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
{/* Grain covers the whole photo area — the contain rect of the
library photo, the ratio band on the camera. */}
<Rect x={frameRect.x} y={frameRect.y} width={frameRect.w} height={frameRect.h} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{/* Vignette follows the PHOTO, not the screen: in the plain library
viewer the image is contain-fitted with letterbox, so a
screen-sized gradient would only reveal its clear middle. */}
{renderVignette(frameRect.x, frameRect.y, frameRect.w, frameRect.h)}
{/* Frame + GPS watermark belong to the photo → zoom with it. Grain
is a fullscreen screen effect → stays fixed above. */}
<Group matrix={libViewMatrix}>
{renderFrameOverlay()}
{renderGPSWatermark()}
{renderCustomWatermark()}
</Group>
{/* Crop keep-rectangle: dim everything the export will drop and
outline what it keeps in amber. Drawn last so it masks every
overlay (frame, marks, grain) outside the crop. */}
{cropScreenPx && (
<Group>
<Rect x={0} y={0} width={vw} height={Math.max(0, cropScreenPx.y)} color="#000000" opacity={libCropView ? 1 : 0.55} />
<Rect
x={0}
y={cropScreenPx.y + cropScreenPx.h}
width={vw}
height={Math.max(0, vh - cropScreenPx.y - cropScreenPx.h)}
color="#000000" opacity={libCropView ? 1 : 0.55}
/>
<Rect
x={0}
y={cropScreenPx.y}
width={Math.max(0, cropScreenPx.x)}
height={cropScreenPx.h}
color="#000000" opacity={libCropView ? 1 : 0.55}
/>
<Rect
x={cropScreenPx.x + cropScreenPx.w}
y={cropScreenPx.y}
width={Math.max(0, vw - cropScreenPx.x - cropScreenPx.w)}
height={cropScreenPx.h}
color="#000000" opacity={libCropView ? 1 : 0.55}
/>
{/* Committed crop: the surround is opaque (it hides the photo
the transform pushed outside the ratio) and the amber
outline is gone — nothing left to frame. */}
{!libCropView && (
<Rect
x={cropScreenPx.x}
y={cropScreenPx.y}
width={cropScreenPx.w}
height={cropScreenPx.h}
color="#f59e0b"
style="stroke"
strokeWidth={2}
/>
)}
</Group>
)}
</Group>
</Canvas>
<View
className="absolute inset-0"
style={libCropTouchStyle}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
onResponderTerminate={onLibTouchEnd}
/>
</>
) : (
<View className="items-center justify-center p-6">
<Text className="text-zinc-500 font-mono text-center text-xs">
{libraryImageUri ? 'LOADING IMAGE...' : 'NO IMAGE SELECTED'}
</Text>
<Text className="text-zinc-600 font-mono text-center text-[10px] mt-1">
Tap the gallery icon below to load a photo
</Text>
</View>
)}
</View>
);
});
export default Viewfinder;