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

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

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

CLARITY and LIGHT GLOW existed only on the camera worklet and the export
engine, so a photo opened from the library ignored both. They now run in all
three library preview branches (libPhoto), and the preview clips them to the
drawn image - the export engine always clipped its bloom to the canvas, the
preview drew it over the whole rect, so the bloom bled past the photo onto the
black letterbox and over the frame's mat.
2026-09-14 19:48:44 +07:00

3104 lines
138 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
[]
);
useEffect(() => {
colorMatrixSync.setBlocking(colorMatrix);
toneSync.setBlocking(toneParams);
cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat());
hdfSync.setBlocking(adjustments.hdf ?? 0);
claritySync.setBlocking(adjustments.clarity ?? 0);
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync]);
// 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 {
await cam.focusTo(
{ x, y },
{ responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null }
);
} 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. Applied to the base paint so it sits under the glow and
// the panel actually shows what the file will get.
try {
const clarityV = claritySync.getDirty();
if (typeof clarityV === 'number' && Number.isFinite(clarityV) && clarityV !== 0) {
const clarityFilter =
clarityV > 0
? (() => {
const a = (clarityV / 10) * 0.8;
return Skia.ImageFilter.MakeMatrixConvolution(
3,
3,
[0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0],
1,
0,
0,
0,
T_CLAMP,
true,
null
);
})()
: Skia.ImageFilter.MakeBlur(
Math.abs(clarityV / 10) * 4,
Math.abs(clarityV / 10) * 4,
T_CLAMP,
null
);
if (clarityFilter != null) paint.setImageFilter(clarityFilter);
}
} catch (e) {
console.warn('Clarity filter build failed: ' + e);
}
const tone = toneSync.getDirty();
const cinema = cinemaSync.getDirty();
const hasTone =
toneEffect != null &&
(tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0 ||
tone[4] !== 0 || tone[5] !== 0 || tone[6] !== 0 ||
tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0 ||
tone[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]
);
const grainOpacity = adjustments.grain / 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: 108, 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 + LIGHT GLOW on the LIBRARY preview -----------------------
// The camera worklet and exportEngine both have these two passes; the three
// library branches below draw the photo declaratively and had neither, so a
// photo opened from the library ignored both knobs. Same maths as the camera:
// `sx`/`sy` turn one ORIGINAL image pixel into canvas units (the library
// preview is laid out in dp, the camera surface is in sensor px), keeping the
// unsharp radius and the bloom sigma identical to the file.
const libClarity = adjustments.clarity ?? 0;
const libGlow = (adjustments.hdf ?? 0) / 10;
const libShaderPath = useShaderPass || (libClarity > 0 && clarityEffect != null);
const libPhoto = (
r: { x: number; y: number; w: number; h: number },
fit: 'cover' | 'fill' | 'contain'
) => {
const iw = skiaImage ? skiaImage.width() : r.w;
const ih = skiaImage ? skiaImage.height() : r.h;
// cover/contain scale uniformly (max/min), fill stretches each axis.
const sw = r.w / iw;
const sh = r.h / ih;
const uniform = fit === 'cover' ? Math.max(sw, sh) : fit === 'contain' ? Math.min(sw, sh) : null;
const sx = uniform ?? sw;
const sy = uniform ?? sh;
const rectNode = rect(r.x, r.y, r.w, r.h);
let graded: React.ReactNode = <ImageShader image={skiaImage} fit={fit} tx="clamp" ty="clamp" rect={rectNode} />;
if (toneEffect != null && toneOn) {
graded = <Shader source={toneEffect} uniforms={toneUniforms}>{graded}</Shader>;
}
if (cinemaOn) {
graded = <Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>{graded}</Shader>;
}
// The Rect must cover exactly the photo area, never the full canvas: a
// fullscreen ImageShader with fit="contain" clamps the photo edges across
// the black letterbox (reads as "the background is a zoomed copy").
let photo: React.ReactNode = libShaderPath ? (
<Rect x={r.x} y={r.y} width={r.w} height={r.h}>
{libClarity > 0 && clarityEffect != null ? (
<Shader source={clarityEffect} uniforms={clarityUniforms((libClarity / 10) * 0.8, sx, sy)}>
{graded}
</Shader>
) : (
graded
)}
</Rect>
) : (
<SkiaImage image={skiaImage} fit={fit} rect={rectNode} />
);
// Mist (negative clarity) = export pass 4's blur; sigma in original px.
if (libClarity < 0) {
photo = (
<Group>
<Blur blur={Math.abs(libClarity / 10) * 4 * sx} mode="clamp" />
{photo}
</Group>
);
}
// Export clips the bloom and the mist to the canvas, which IS the photo.
// The preview must clip too: the HDF glow's blur (and negative clarity's)
// otherwise bleeds past the photo onto the black letterbox and over the
// frame's mat. Clip to the drawn image, not to the rect: on "contain" the
// rect is wider than the picture.
const cw = Math.min(r.w, iw * sx);
const ch = Math.min(r.h, ih * sy);
const photoClip = rect(r.x + (r.w - cw) / 2, r.y + (r.h - ch) / 2, cw, ch);
return (
<Group clip={photoClip}>
{photo}
{/* HDF bloom, the export pass 5b twin: bright pass on the luminance,
blur, Screen back at 0.15..0.5 x strength. Radius is a fraction of
the drawn width, so any image size blooms like the camera does. */}
{libGlow > 0 && glowEffect != null ? (
<Rect
x={r.x}
y={r.y}
width={r.w}
height={r.h}
blendMode="screen"
opacity={0.15 + 0.35 * libGlow}
>
<Blur blur={cw * (0.004 + 0.015 * libGlow)} mode="clamp" />
<Shader source={glowEffect} uniforms={GLOW_UNIFORMS}>{graded}</Shader>
</Rect>
) : null}
</Group>
);
};
// RETRO INSTANT (library): the whole picture is contain-fitted into the
// card's photo window — nothing is masked or cropped, and the card is
// centered on the black background like a physical print. The window keeps
// 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;