1e1ead6f54
The landing page's QR card now carries a link to `GET /api/photos/:id/preset.recipe`, so the app can pick a look up off a screen instead of a file. PRESETS gains a SCAN QR chip next to IMPORT: the code is decoded, checked against the preset route, fetched, and stored through the same `storeImportedRecipe` path IMPORT already uses, so an imported look and a scanned look land in one place. Scanning runs on `expo-camera`, not the viewfinder's vision-camera: v5's object output is iOS-only, Android's `createObjectOutput` throws. That is a second camera library in the app, so the sheet pauses the viewfinder (`paused` prop) — CameraX will not let two clients hold one lens. A native rebuild is required for the new module. The code holds a URL, never the recipe, so anything that is not the preset route is refused by name rather than silently dropped. On a release APK the plain-HTTP link is blocked by Android's cleartext policy; debug builds have it on. Noted in 7_SCAN_QR.md along with the web side, the regex, and the paused-viewfinder rule.
3331 lines
149 KiB
TypeScript
3331 lines
149 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: bottom edge of the floating top strip. The photo and the
|
|
// ratio band are laid out BELOW it — a keep-rectangle whose top edge hides
|
|
// under the header is one the user cannot aim.
|
|
topInset?: 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;
|
|
// Another camera session wants the lens (the QR scanner's own sheet): the
|
|
// live session stops until it is up, exactly as it does for the app going
|
|
// to the background — two CameraX clients cannot hold one camera.
|
|
paused?: 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;
|
|
// Live session ready flag for the shutter gate: true once the CameraX
|
|
// session is bound (onStarted), false when it goes down (onStopped). App
|
|
// queues a shutter press that lands before it — firing one into a dead
|
|
// session produced no file at all.
|
|
onCameraReady?: (ready: boolean) => 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;
|
|
}
|
|
|
|
// Grain: value noise on a CELL of `u` canvas px rather than on the raw pixel
|
|
// grid. A per-pixel hash gave the preview (a ~1080px rect) and the export (a
|
|
// 4000px image) two different grains — the file's came out ~4x finer than
|
|
// what was tuned on screen. Callers pass the rect's width / GRAIN_REF so both
|
|
// hash at the same size relative to the picture; u = 1 (the preview) is exactly
|
|
// the old per-pixel hash, since the smoothed cell fraction is 0 there. The
|
|
// bilinear over the four cell corners is what makes a clump instead of a pixel
|
|
// of static — at cell 1 it collapses back to the plain hash.
|
|
// Every Skia object the per-frame worklet builds (paint, filter, shader) is a
|
|
// native allocation; the UI runtime's GC does not reach them promptly, so a
|
|
// preview left open piles up ~24 MB/minute that the device pays back as swap.
|
|
// The frame pushes each object here and this releases them after the last draw
|
|
// that could still reference them. Reverse order: children were handed refs by
|
|
// the parents built after them.
|
|
type SkTrash = { dispose: () => void };
|
|
const disposeTrash = (trash: SkTrash[]) => {
|
|
'worklet';
|
|
for (let i = trash.length - 1; i >= 0; i--) {
|
|
try {
|
|
trash[i].dispose();
|
|
} catch {
|
|
// A dispose failure must never take the frame down with it.
|
|
}
|
|
}
|
|
};
|
|
const noiseEffect = Skia.RuntimeEffect.Make(`
|
|
uniform float u;
|
|
float grainHash(vec2 q) {
|
|
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
|
|
}
|
|
vec4 main(vec2 pos) {
|
|
vec2 p = pos.xy / max(u, 1.0);
|
|
vec2 i = floor(p);
|
|
vec2 f = p - i;
|
|
f = f * f * (3.0 - 2.0 * f);
|
|
float g00 = grainHash(i);
|
|
float g10 = grainHash(i + vec2(1.0, 0.0));
|
|
float g01 = grainHash(i + vec2(0.0, 1.0));
|
|
float g11 = grainHash(i + vec2(1.0, 1.0));
|
|
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 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,
|
|
topInset,
|
|
cameraPermissionGranted,
|
|
paused = false,
|
|
onRequestCameraPermission,
|
|
photoOutput,
|
|
aspectRatio,
|
|
cropRatio = 'none',
|
|
cropApplied = null,
|
|
cropRect = DEFAULT_CROP_RECT,
|
|
onCropRectChange,
|
|
rawEnabled,
|
|
rawOutput,
|
|
onSessionError,
|
|
onCameraReady,
|
|
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;
|
|
// The top strip floats over the hero, so the usable picture area starts at
|
|
// its bottom edge. Clamped to a third of the viewport: a stale or absurd
|
|
// measurement must not squeeze the photo into a sliver.
|
|
const vhTop =
|
|
mode === 'library' ? Math.max(0, Math.min(topInset ?? 0, vh / 3)) : 0;
|
|
const insets = useSafeAreaInsets();
|
|
|
|
// Composition ratio band. With a ratio active the photo area is a centered
|
|
// portrait strip (w/h = ratioWH) and everything outside is letterboxed;
|
|
// exports crop to the same ratio. Only the plain full-picture frames use the
|
|
// band — polaroid keeps its own card layout (window aspect follows the ratio)
|
|
// and wall frame deliberately ignores the ratio entirely (its opening is a
|
|
// fixed 2:3). When no band is active the region is just the full screen, so
|
|
// every consumer below degrades to the old fullscreen behavior untouched.
|
|
const ratioWH = aspectRatio === 'full' ? null : ASPECT_RATIO_W_H[aspectRatio];
|
|
const regionBand =
|
|
mode === 'camera' &&
|
|
ratioWH !== null &&
|
|
(selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic');
|
|
const region = useMemo(() => {
|
|
if (!regionBand || ratioWH === null) return { x: 0, y: 0, w: vw, h: vh };
|
|
// The band is a same-zoom crop WINDOW over the full sensor view, not the
|
|
// photo re-scaled to the screen width: anchor on the native sensor box
|
|
// (upright 3:4, contain-fitted to the screen) and cut the ratio strip out
|
|
// of it at the same scale. 4:3 (== native) therefore fills the full sensor
|
|
// width at the reference zoom, and 3:2 is the identical vertical view with
|
|
// a narrower horizontal crop — switching ratio never zooms the scene, so a
|
|
// 3:2 band never shows a taller image than 4:3.
|
|
// ponytail: sensor assumed upright 3:4 (quality-mode captures); if a device
|
|
// negotiates another photo aspect, derive it from the session instead.
|
|
const nativeWH = 3 / 4;
|
|
const nativeH = Math.min(vh, vw / nativeWH);
|
|
const nativeW = nativeH * nativeWH;
|
|
const h = ratioWH <= nativeWH ? nativeH : nativeW / ratioWH;
|
|
const w = ratioWH * h;
|
|
return { x: (vw - w) / 2, y: (vh - h) / 2, w, h };
|
|
}, [regionBand, ratioWH, vw, vh]);
|
|
// FRAME-tab CROP geometry. Only the plain full-picture frames crop the photo
|
|
// directly (same set the camera's ratio band serves); polaroid/wall already
|
|
// crop through their own window and keep their framed ratio crop.
|
|
const libCropPlainFrame =
|
|
selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic';
|
|
const libCropActive = mode === 'library' && cropRatio !== 'none' && libCropPlainFrame;
|
|
const libFreeCrop = libCropActive && cropRatio === 'free';
|
|
// CROP's second step: once APPLY has committed the ratio the band is no
|
|
// longer an outline over the whole picture — the crop itself is the view.
|
|
const libCropApplied = libCropActive && cropApplied === cropRatio;
|
|
// Camera outputs: attach the DNG output only while RAW is on — CameraX
|
|
// cannot bind two ImageCapture use cases at once, and App's session-error
|
|
// handler turns RAW off again when that ever fails.
|
|
const cameraOutputs = useMemo(
|
|
() => (rawEnabled && rawOutput ? [photoOutput, rawOutput] : [photoOutput]),
|
|
[photoOutput, rawOutput, rawEnabled]
|
|
);
|
|
|
|
// useCameraDevice resolves the back camera fresh on every render, so once the
|
|
// permission lands (first-run flow: GRANT button → OS dialog) a re-render is
|
|
// enough to pick the device up — no manual polling needed.
|
|
const device = useCameraDevice(facing);
|
|
|
|
// CameraX negotiates the CameraFrameOutput aspect on its own (default 16:9 on
|
|
// this device), which center-crops the 4:3 sensor — so the ratio band could
|
|
// never display 100% of the sensor pixels. Force the frame stream to a 4:3
|
|
// (sensor aspect) resolution supported for 'stream' outputs; the photo output
|
|
// keeps its own full-res 4:3 size and FOV, so the viewfinder then matches the
|
|
// captured photo exactly. Best supported 4:3 size closest to 1280x960
|
|
// (720p-class, keeps the frame pipeline light); CameraX may still round it.
|
|
const frameTarget = useMemo(() => {
|
|
if (!device) return undefined;
|
|
try {
|
|
let best: { width: number; height: number } | undefined;
|
|
let bestScore = Infinity;
|
|
for (const s of device.getSupportedResolutions('stream')) {
|
|
const w = s.width;
|
|
const h = s.height;
|
|
if (!(w > 0 && h > 0)) continue;
|
|
if (Math.abs(w / h - 4 / 3) > 0.03) continue;
|
|
const score = Math.abs(Math.sqrt(w * h) - Math.sqrt(1280 * 960));
|
|
if (score < bestScore) {
|
|
bestScore = score;
|
|
best = { width: w, height: h };
|
|
}
|
|
}
|
|
return best ?? undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}, [device]);
|
|
|
|
const loadedImage = useImage(libraryImageUri || '');
|
|
// FRAME-tab rotation: pre-rotate the source, so the width()/height() swap
|
|
// flows through every derived rect (polaroid window aspect, cover crop,
|
|
// watermark area) and the export matches the preview for free.
|
|
const skiaImage = useMemo(
|
|
() => applyPhotoRotation(loadedImage, photoRotation, photoStraighten),
|
|
[loadedImage, photoRotation, photoStraighten]
|
|
);
|
|
// Mirror of the two inputs of the above, for detectHorizon: the imperative
|
|
// handle is captured once (deps [ref]) and must read the LIVE source.
|
|
const horizonSrcRef = useRef({ image: loadedImage, quarter: photoRotation });
|
|
horizonSrcRef.current = { image: loadedImage, quarter: photoRotation };
|
|
// Wall-frame PNG artwork (project root). Loaded lazily so other frames pay
|
|
// no asset cost; source switches to '' when unselected (useImage → null).
|
|
const wallImage = useImage(selectedFrame === 'wallframe' ? require('../../wallframe.png') : '');
|
|
const customFont = useFont(
|
|
require('../../assets/Cousine-Regular.ttf'),
|
|
Math.round(vw * 0.032)
|
|
);
|
|
// Monochrome Noto Emoji subset (📍 U+1F4CD, 📷 U+1F4F7) for the watermark
|
|
// icons — glyphs painted with the same color as the text next to them.
|
|
const iconFont = useFont(
|
|
require('../../assets/NotoEmoji-GPS.ttf'),
|
|
Math.round(vw * 0.032)
|
|
);
|
|
// GPS mark SIZE (pinch while placing it): the same two faces, re-sized, so
|
|
// the preview grows with the multiplier the export burns in.
|
|
const gpsIconSize = Math.round(vw * 0.032 * (gpsWm?.size ?? 1));
|
|
const gpsFont = useMemo(
|
|
() => (customFont ? Skia.Font(customFont.getTypeface(), gpsIconSize) : null),
|
|
[customFont, gpsIconSize]
|
|
);
|
|
const gpsIconFont = useMemo(
|
|
() => (iconFont ? Skia.Font(iconFont.getTypeface(), gpsIconSize) : null),
|
|
[iconFont, gpsIconSize]
|
|
);
|
|
|
|
const adjustments = meteringAdjustments
|
|
? { ...recipe.adjustments, ...meteringAdjustments }
|
|
: recipe.adjustments;
|
|
// Exposure compensation, applied as a software 2^EV matrix gain for BOTH
|
|
// modes — the camera's hardware AE bias is never touched. setExposureBias
|
|
// re-locks AE on the Xiaomi and flashes the preview, and the bias lands
|
|
// asynchronously, so a shot exposed through it could disagree with what the
|
|
// viewfinder showed at shutter time. The software gain is instant and
|
|
// flicker-free, and it is what the export applies too (no evFromCamera), so
|
|
// preview and file always match. EV_RANGE ±3 caps the gain.
|
|
const clampEvApplied = (v: number) => Math.max(-3, Math.min(3, v));
|
|
const evApplied = clampEvApplied(adjustments.exposureCompensation ?? 0);
|
|
const colorMatrix = applyExposureGain(
|
|
getSkiaColorMatrix(recipe.baseFilter, adjustments),
|
|
evApplied
|
|
);
|
|
|
|
// The Skia onFrame worklet runs on a separate runtime — keep the latest color
|
|
// matrix in a cross-thread synchronizable value instead of a Reanimated
|
|
// SharedValue (which lives on the UI runtime).
|
|
const colorMatrixSync = useMemo(
|
|
() => createSynchronizable<number[]>(colorMatrix),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[]
|
|
);
|
|
// Tone uniforms [dr, hl, sh, vib, shadow split RGB, highlight split RGB] for
|
|
// the preview worklet; all-zero = no tone pass. A stock may keep the pass on
|
|
// with every user knob at 0 (Classic Neg split tone, Classic Chrome crush).
|
|
const toneParams = toneUniformArray(getToneUniforms(adjustments, recipe.baseFilter));
|
|
const toneSync = useMemo(
|
|
() => createSynchronizable<number[]>(new Array(10).fill(0)),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[]
|
|
);
|
|
// Cinema seasonal grade params (16 flat floats; all-zero = off).
|
|
const cinemaParams = recipe.cinema ? getCinemaUniforms(recipe.cinema) : null;
|
|
const cinemaSync = useMemo(
|
|
() => createSynchronizable<number[]>(cinemaOffFlat()),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[]
|
|
);
|
|
// Highlight Diffusion Filter strength (0..10); 0 = no glow pass.
|
|
const hdfSync = useMemo(
|
|
() => createSynchronizable<number>(adjustments.hdf ?? 0),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[]
|
|
);
|
|
// Clarity (-10..10): local unsharp mask / mist, the same numbers exportEngine
|
|
// uses, so the live preview and the exported file agree. 0 = no filter.
|
|
const claritySync = useMemo(
|
|
() => createSynchronizable<number>(adjustments.clarity ?? 0),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[]
|
|
);
|
|
// Sharpening (-10..10): below 0 the knob softens the frame, the same blur the
|
|
// export applies. Own sync so it wraps the clarity filter instead of the two
|
|
// knobs clobbering each other.
|
|
const softenSync = useMemo(
|
|
() => createSynchronizable<number>(adjustments.sharpening ?? 0),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[]
|
|
);
|
|
useEffect(() => {
|
|
colorMatrixSync.setBlocking(colorMatrix);
|
|
toneSync.setBlocking(toneParams);
|
|
cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat());
|
|
hdfSync.setBlocking(adjustments.hdf ?? 0);
|
|
claritySync.setBlocking(adjustments.clarity ?? 0);
|
|
softenSync.setBlocking(adjustments.sharpening ?? 0);
|
|
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, adjustments.sharpening, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, softenSync]);
|
|
|
|
// Camera controller lives behind SkiaCamera. The camera AE bias is never
|
|
// written: EV (user + metering offset) is purely software matrix gain, for
|
|
// the preview and for the export alike.
|
|
const skiaCameraRef = useRef<SkiaCameraRef>(null);
|
|
const [cameraActive, setCameraActive] = useState(false);
|
|
// Re-render kicker bumped on every onStarted. A flip remounts <SkiaCamera>
|
|
// (key change) while cameraActive never drops (unmount fires no onStopped),
|
|
// so the reopen would not re-render and controller-derived UI (zoom chips)
|
|
// stayed stuck hidden until the next touch — the new controller only becomes
|
|
// readable from the ref once the fresh session has started.
|
|
const [, bumpSessionStart] = useState(0);
|
|
// Stable identities so React.memo(SkiaCameraImpl) does not re-render (and
|
|
// re-run its frame-output setup) on every adjustments change during slider
|
|
// drags — inline lambdas here used to break memo and stall the main thread.
|
|
const handleCameraStarted = useCallback(() => {
|
|
startedOnceRef.current = true;
|
|
watchdogBudgetRef.current = 0;
|
|
setCameraActive(true);
|
|
bumpSessionStart((n) => n + 1);
|
|
onCameraReady?.(true);
|
|
}, [onCameraReady]);
|
|
const handleCameraStopped = useCallback(() => {
|
|
setCameraActive(false);
|
|
onCameraReady?.(false);
|
|
}, [onCameraReady]);
|
|
// 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;
|
|
// The 0.5x feed is a bare Camera2 surface — the Skia matrix gain that carries
|
|
// EV on the 1x preview never touches it, so the lens gets the same EV as a
|
|
// hardware AE bias instead. The still is captured unbiased (see the module),
|
|
// so the export applies its software gain once and the file matches.
|
|
useEffect(() => {
|
|
if (!ultraWideOn) return;
|
|
UltraWide.setEvAsync(evApplied).catch(() => {});
|
|
}, [ultraWideOn, evApplied]);
|
|
useEffect(() => {
|
|
let alive = true;
|
|
UltraWide.availableAsync()
|
|
.then((id) => {
|
|
if (alive) setUltraWideId(id || '');
|
|
})
|
|
.catch((e) => console.warn('Ultra-wide probe failed', String(e).slice(0, 120)));
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, []);
|
|
const ultraWideReady = ultraWideId !== '' && facing === 'back';
|
|
const ultraWideReadyRef = useRef(false);
|
|
ultraWideReadyRef.current = ultraWideReady;
|
|
// Handoff cover. The two back lenses cannot be open at once, so leaving 0.5x
|
|
// closes the native session first and the 1x preview only then reopens: for
|
|
// ~1s the viewfinder shows a frozen stale frame and then the reopening
|
|
// session's out-of-focus, shifted first frames. The ultra-wide keeps drawing
|
|
// its own last frame over that instead (the native `cover` prop), so the swap
|
|
// reads as one clean cut from 0.5x to 1x.
|
|
const [uwCover, setUwCover] = useState(false);
|
|
const uwCoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const uwFramePoll = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const stopCoverTimer = useCallback(() => {
|
|
if (uwCoverTimer.current) clearTimeout(uwCoverTimer.current);
|
|
uwCoverTimer.current = null;
|
|
if (uwFramePoll.current) clearInterval(uwFramePoll.current);
|
|
uwFramePoll.current = null;
|
|
}, []);
|
|
const holdCover = useCallback(
|
|
(ms?: number) => {
|
|
stopCoverTimer();
|
|
if (ms == null) {
|
|
setUwCover(true);
|
|
return;
|
|
}
|
|
uwCoverTimer.current = setTimeout(() => {
|
|
uwCoverTimer.current = null;
|
|
setUwCover(false);
|
|
}, ms);
|
|
},
|
|
[stopCoverTimer]
|
|
);
|
|
useEffect(() => stopCoverTimer, [stopCoverTimer]);
|
|
// Bumped on every lens handoff: a slow release belonging to an older one must
|
|
// not touch the state a newer zoom choice already owns.
|
|
const uwHandoff = useRef(0);
|
|
const applyZoom = useCallback(
|
|
(ratio: number) => {
|
|
const native = ratio === 0.5 && ultraWideReadyRef.current;
|
|
// The reticle points at a region of a lens that is about to go away.
|
|
if (native !== ultraWideOnRef.current) setFocusPoint(null);
|
|
setZoomRatio(ratio);
|
|
if (!native && ultraWideOnRef.current) {
|
|
// Hold the outgoing 0.5x frame until the main preview really paints
|
|
// again, instead of a fixed 700ms: measured on this device the fresh
|
|
// 1x session lands anywhere from 0.2s to 0.9s after the tap, so the
|
|
// old ceiling left a black hole when it ran slow and a stale frame
|
|
// when it ran fast. The frame beat advances on every delivered frame
|
|
// of the main camera, so it is the honest "1x is live" signal.
|
|
holdCover(1500); // ceiling: never keep the stale frame up for good
|
|
const beatAtSwap = frameBeatSync.getDirty();
|
|
uwFramePoll.current = setInterval(() => {
|
|
if (frameBeatSync.getDirty() - beatAtSwap < 3) return;
|
|
stopCoverTimer();
|
|
setUwCover(false);
|
|
}, 50);
|
|
// The main session is only switched back on once the native lens has
|
|
// actually let go of the back camera slot. Activating it earlier makes
|
|
// CameraX open the 1x while the HAL still holds that lens, and the
|
|
// refused open is retried on a fixed ~520ms timeout - the bulk of the
|
|
// handoff time.
|
|
const handoff = ++uwHandoff.current;
|
|
UltraWide.closeAsync()
|
|
.catch(() => {})
|
|
.then(() => {
|
|
if (uwHandoff.current !== handoff) return;
|
|
setUltraWideOn(false);
|
|
});
|
|
} else {
|
|
uwHandoff.current++; // a release still in flight belongs to the past now
|
|
setUltraWideOn(native);
|
|
if (native) {
|
|
holdCover();
|
|
// Back to 0.5x while the previous handoff was still releasing the
|
|
// lens: it is closed by now, so ask for it back.
|
|
if (ultraWideOnRef.current) UltraWide.reopenAsync().catch(() => {});
|
|
}
|
|
}
|
|
// The native lens is a different camera: the main session is off while
|
|
// it is live, so never carry the 0.5 over as a zoom ratio onto it.
|
|
zoomRef.current = native ? 1 : ratio;
|
|
const ctl = skiaCameraRef.current?.controller;
|
|
if (ctl && !native) {
|
|
ctl.setZoom(ratio).catch((e) => console.warn('setZoom failed', String(e).slice(0, 120)));
|
|
}
|
|
},
|
|
[holdCover, stopCoverTimer]
|
|
);
|
|
// 0.5x is a chip wherever an ultra-wide can actually be shown: the hidden
|
|
// Camera2 lens above, or a bound CameraX device whose minZoomRatio reaches
|
|
// below 1 (a logical multi-camera with the ultra-wide wired in). A plain
|
|
// single-lens device reports 1 (and 0.0 before the session binds, which is
|
|
// why the gate is `> 0`), and setZoom rejects anything below min.
|
|
const zoomPresets = useMemo(() => {
|
|
const base = [1, 2, 3, 5, 10];
|
|
const min = device?.minZoom ?? 1;
|
|
if (ultraWideReady) return [0.5, ...base];
|
|
return min > 0 && min <= 0.5 ? [0.5, ...base] : base;
|
|
}, [device, ultraWideReady]);
|
|
// Re-apply after the camera session restarts (background resume, camera↔
|
|
// library switch): a fresh session starts back at 1x.
|
|
useEffect(() => {
|
|
if (!controller) return;
|
|
if (zoomRef.current !== 1) {
|
|
controller.setZoom(zoomRef.current).catch(() => {});
|
|
}
|
|
}, [controller]);
|
|
// Switching back/front restarts the session at the new lens: never carry a
|
|
// digital zoom across the switch.
|
|
useEffect(() => {
|
|
zoomRef.current = 1;
|
|
setZoomRatio(1);
|
|
setUltraWideOn(false);
|
|
stopCoverTimer();
|
|
setUwCover(false);
|
|
}, [facing, stopCoverTimer]);
|
|
// The quick-EV bubble/drag is the recipe's OWN EV: `adjustments` above also
|
|
// carries the metering-mode dip (highlight-weighted), so reading it here
|
|
// showed EV -0.5 while the LIGHT tab slider sat at 0 — and a drag then wrote
|
|
// that leaked value back as the user's EV.
|
|
const evStops = recipe.adjustments.exposureCompensation ?? 0;
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
// Frame-window (polaroid/wall) zoom, for export parity. Read
|
|
// live through the ref — the handle is captured once with deps [ref].
|
|
getFrameWindowZoom() {
|
|
return frameZoomRef.current;
|
|
},
|
|
isUltraWide() {
|
|
return ultraWideOnRef.current;
|
|
},
|
|
getCropRatioRect() {
|
|
return cropRatioRectRef.current;
|
|
},
|
|
// AUTO straighten: the angle the picture asks for. Computed on demand
|
|
// (one <=256px thumbnail, tens of ms) — never on a slider drag.
|
|
detectHorizon() {
|
|
const src = horizonSrcRef.current;
|
|
return detectTilt(applyPhotoRotation(src.image, src.quarter, 0));
|
|
},
|
|
}),
|
|
[ref]
|
|
);
|
|
|
|
// Tap-to-focus: lock AE/AF/AWB onto the tapped region (SceneAdaptiveness
|
|
// 'locked' + autoResetAfter null = stays locked until another tap or unlock).
|
|
// CameraX AE otherwise keeps re-metering the scene and cancels out the tone
|
|
// preview (lift highlights -> AE underexposes -> looks unchanged), which is
|
|
// why HL/SH edits read as "no effect" on the live preview.
|
|
const handleTapFocus = async (x: number, y: number) => {
|
|
// 0.5x is a native lens whose CameraX session is stopped, so focusTo()
|
|
// has no camera behind it and the tap has to go to the module.
|
|
if (ultraWideOnRef.current) {
|
|
setFocusPoint({ x, y });
|
|
try {
|
|
await UltraWide.focusAsync(x, y);
|
|
} catch (e) {
|
|
console.warn('Ultra-wide focus failed: ' + String(e).slice(0, 120));
|
|
}
|
|
return;
|
|
}
|
|
if (!cameraActive) return;
|
|
const cam = skiaCameraRef.current;
|
|
const d = cam?.controller?.device;
|
|
if (!d) return;
|
|
if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) {
|
|
console.warn('Focus/exposure metering unsupported on this device');
|
|
return;
|
|
}
|
|
setFocusPoint({ x, y });
|
|
try {
|
|
const t0 = Date.now();
|
|
await cam.focusTo(
|
|
{ x, y },
|
|
{ responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null }
|
|
);
|
|
console.log(`[focus] AE/AF locked at ${Math.round(x)},${Math.round(y)} in ${Date.now() - t0}ms`);
|
|
} catch (e) {
|
|
console.warn('Focus lock failed: ' + String(e).slice(0, 120));
|
|
}
|
|
};
|
|
const handleUnlockFocus = async () => {
|
|
if (ultraWideOnRef.current) {
|
|
setFocusPoint(null);
|
|
try {
|
|
await UltraWide.resetFocusAsync();
|
|
} catch (e) {
|
|
console.warn('Ultra-wide focus reset failed: ' + String(e).slice(0, 120));
|
|
}
|
|
return;
|
|
}
|
|
const cam = skiaCameraRef.current;
|
|
if (!cam) return;
|
|
try {
|
|
await cam.resetFocus();
|
|
setFocusPoint(null);
|
|
} catch (e) {
|
|
console.warn('Focus reset failed: ' + String(e).slice(0, 120));
|
|
}
|
|
};
|
|
|
|
// Quick exposure slider — appears while AE/AF is locked. Drag up/down maps
|
|
// directly to the recipe EV in stops (same live software-matrix path as the
|
|
// EV slider, and the same EV the export applies).
|
|
const EV_RANGE = 3; // matches the ±24 index range at 1 index = 1/8 EV
|
|
const evTrackTop = vh * 0.24;
|
|
const evTrackH = vh * 0.42;
|
|
const evPxPerStop = evTrackH / (2 * EV_RANGE);
|
|
const clampEv = (v: number) => Math.max(-EV_RANGE, Math.min(EV_RANGE, v));
|
|
const evDragRef = useRef<{ y0: number; ev0: number } | null>(null);
|
|
// Last EV value actually pushed to App. Quick-EV drags otherwise fire a full
|
|
// adjustments re-render on EVERY pointer move (30-60Hz), even while the
|
|
// finger holds a step without the value changing — that render storm stalls
|
|
// the main thread and beats the Skia preview. Only push real nấc changes.
|
|
const evLastSentRef = useRef<number | null>(null);
|
|
const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH;
|
|
// Screen-absolute Y of the gesture. locationY is relative to whichever view
|
|
// the finger currently hit-tests over, so crossing the small track's edge
|
|
// mid-drag changed the origin by the track offset and snapped EV by ~3 stops
|
|
// (the "jumps to the other end" bug). pageY never moves.
|
|
const pageYOf = (e: any): number => {
|
|
const t = e?.nativeEvent?.touches?.[0];
|
|
if (t && typeof t.pageY === 'number') return t.pageY;
|
|
const p = e?.nativeEvent?.pageY;
|
|
if (typeof p === 'number') return p;
|
|
return e?.nativeEvent?.locationY ?? 0;
|
|
};
|
|
const onEvSliderGrant = (e: any) => {
|
|
const y0 = pageYOf(e);
|
|
evDragRef.current = { y0, ev0: evStops };
|
|
evLastSentRef.current = evStops;
|
|
};
|
|
const onEvSliderMove = (e: any) => {
|
|
const g = evDragRef.current;
|
|
if (!g || !onExposureChange) return;
|
|
const dy = g.y0 - pageYOf(e); // drag up = brighter
|
|
const ev = clampEv(g.ev0 + dy / evPxPerStop);
|
|
const rounded = Math.round(ev * 10) / 10;
|
|
if (rounded === evLastSentRef.current) return; // same step: skip re-render
|
|
evLastSentRef.current = rounded;
|
|
onExposureChange(rounded);
|
|
};
|
|
const onEvSliderRelease = () => {
|
|
evDragRef.current = null;
|
|
};
|
|
|
|
// ---- Drag-anywhere-on-the-image adjustment --------------------------------
|
|
// Priority: an open panel param row (imageAdjustTarget) > EV (AE locked).
|
|
// A plain tap (no movement) always falls through to tap-to-focus.
|
|
const TAP_SLOP = 14; // px of movement before a touch counts as a drag
|
|
|
|
// ---- Hold-to-compare (see onComparePeek) ---------------------------------
|
|
// The timer only arms when App has something to compare against; moving past
|
|
// TAP_SLOP (or a second finger) hands the touch back to its normal job —
|
|
// adjust / pinch / pan — and drops the peek.
|
|
const HOLD_MS = 350;
|
|
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const holdAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
|
const peekingRef = useRef(false);
|
|
const onComparePeekRef = useRef(onComparePeek);
|
|
onComparePeekRef.current = onComparePeek;
|
|
const endPeek = () => {
|
|
if (!peekingRef.current) return;
|
|
peekingRef.current = false;
|
|
onComparePeekRef.current?.(false);
|
|
};
|
|
const cancelHold = () => {
|
|
if (holdTimerRef.current) {
|
|
clearTimeout(holdTimerRef.current);
|
|
holdTimerRef.current = null;
|
|
}
|
|
};
|
|
const startHold = (x: number, y: number) => {
|
|
cancelHold();
|
|
holdAnchorRef.current = { x, y };
|
|
if (!onComparePeekRef.current) return;
|
|
holdTimerRef.current = setTimeout(() => {
|
|
holdTimerRef.current = null;
|
|
peekingRef.current = true;
|
|
onComparePeekRef.current?.(true);
|
|
}, HOLD_MS);
|
|
};
|
|
// Ends any hold and reports whether this gesture WAS peeking — a peek is not
|
|
// a tap, so its release must skip the tap actions below.
|
|
const endHold = () => {
|
|
cancelHold();
|
|
holdAnchorRef.current = null;
|
|
const was = peekingRef.current;
|
|
endPeek();
|
|
return was;
|
|
};
|
|
const holdNoteMove = (x: number, y: number) => {
|
|
const a = holdAnchorRef.current;
|
|
if (!a) return;
|
|
if (Math.abs(x - a.x) <= TAP_SLOP && Math.abs(y - a.y) <= TAP_SLOP) return;
|
|
holdAnchorRef.current = null;
|
|
cancelHold();
|
|
endPeek();
|
|
};
|
|
const imageAdjustTargetRef = useRef(imageAdjustTarget);
|
|
imageAdjustTargetRef.current = imageAdjustTarget;
|
|
type ImgDragSession = {
|
|
y0: number;
|
|
kind: 'param' | 'ev' | 'wm' | 'focus';
|
|
v0: number;
|
|
param: { min: number; max: number; step: number; onChange: (v: number) => void };
|
|
moved: boolean;
|
|
lastSent: number | null;
|
|
};
|
|
const imgDragRef = useRef<ImgDragSession | null>(null);
|
|
const onImageDragGrant = (e: any) => {
|
|
const t = imageAdjustTargetRef.current;
|
|
let kind: ImgDragSession['kind'] = 'focus';
|
|
let v0 = 0;
|
|
let param: ImgDragSession['param'] = { min: 0, max: 0, step: 0, onChange: () => {} };
|
|
if (t) {
|
|
kind = 'param';
|
|
v0 = t.value;
|
|
param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange };
|
|
} else if (wmArmed || gpsWmArmed) {
|
|
// Watermark armed: every touch drags the mark (an open param slider keeps
|
|
// priority; the AE-lock EV drag yields while armed). Anchor the drag —
|
|
// the mark only MOVES once the finger does, it never jumps under it.
|
|
kind = 'wm';
|
|
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
|
|
const tp = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 };
|
|
const tgt = wmTargetAt(tp.x, tp.y) ?? (wmArmed ? 'custom' : 'gps');
|
|
wmDragTgtRef.current = tgt;
|
|
beginWmDrag(tgt, tp.x, tp.y);
|
|
} else if (focusPoint && onExposureChange) {
|
|
kind = 'ev';
|
|
v0 = evStops;
|
|
}
|
|
// Framed-window pinch/pan anchors (camera): seeded at touch-down like the
|
|
// library's onLibTouchStart, consumed in onImageDragMove below.
|
|
const gts = e?.nativeEvent?.touches ?? [];
|
|
panRef.current =
|
|
gts.length === 1
|
|
? {
|
|
x: gts[0].locationX,
|
|
y: gts[0].locationY,
|
|
px: gts[0].pageX,
|
|
py: gts[0].pageY,
|
|
tx: viewZoomed ? viewZoom.tx : libZoom.tx,
|
|
ty: viewZoomed ? viewZoom.ty : libZoom.ty,
|
|
}
|
|
: null;
|
|
panUsedRef.current = false;
|
|
if (gts.length < 2) pinchRef.current = null;
|
|
imgDragRef.current = { y0: pageYOf(e), kind, v0, param, moved: false, lastSent: null };
|
|
const ht = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
|
|
startHold(ht?.pageX ?? 0, pageYOf(e));
|
|
};
|
|
const onImageDragMove = (e: any) => {
|
|
const g = imgDragRef.current;
|
|
const holdTs = e?.nativeEvent?.touches ?? [];
|
|
if (holdTs.length >= 2) endHold();
|
|
else holdNoteMove(holdTs[0]?.pageX ?? 0, pageYOf(e));
|
|
// Framed window (polaroid card / wall opening) on the live camera: two
|
|
// fingers pinch the picture and drag it inside the frame — exactly the crop
|
|
// the export takes (frameWindowZoom). One finger drags it too, but only a
|
|
// touch that no parameter row and no AE-lock EV drag has claimed.
|
|
const wts = e?.nativeEvent?.touches;
|
|
// Two fingers: a mark under them resizes (the WATERMARK panel's own pinch);
|
|
// anything else on the FRAME tab magnifies the whole view.
|
|
if (wts && wts.length >= 2) {
|
|
const mx = (wts[0].locationX + wts[1].locationX) / 2;
|
|
const my = (wts[0].locationY + wts[1].locationY) / 2;
|
|
const d = Math.hypot(
|
|
wts[0].locationX - wts[1].locationX,
|
|
wts[0].locationY - wts[1].locationY
|
|
);
|
|
const mi = { x: mx, y: my };
|
|
const mark = pinchMarkAt(mi.x, mi.y) ?? wmPinchRef.current?.tgt ?? null;
|
|
if (mark) {
|
|
const p = wmPinchRef.current;
|
|
if (!p) wmPinchRef.current = { tgt: mark, d, size: wmSizeOf(mark) };
|
|
else {
|
|
applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1));
|
|
if (g) g.moved = true;
|
|
}
|
|
return;
|
|
}
|
|
if (frameTabActive) {
|
|
applyViewPinch(wts);
|
|
if (g) g.moved = true;
|
|
return;
|
|
}
|
|
}
|
|
// FRAME tab, magnified view: one finger drags the picture inside the
|
|
// viewport (preview only, like the pinch). An armed mark keeps the touch.
|
|
if (viewZoomed && wts?.length === 1 && g?.kind === 'focus' && !pinchRef.current) {
|
|
const pan0 = panRef.current;
|
|
if (pan0) {
|
|
// pageX/pageY: absolute screen dp, immune to the view transform, so the
|
|
// picture tracks the finger 1:1 (locationX is scaled by the matrix).
|
|
const dx = wts[0].pageX - pan0.px;
|
|
const dy = wts[0].pageY - pan0.py;
|
|
if (Math.abs(dx) > TAP_SLOP || Math.abs(dy) > TAP_SLOP) panUsedRef.current = true;
|
|
if (panUsedRef.current) {
|
|
setViewZoom(clampViewZoom({ s: viewZoom.s, tx: pan0.tx + dx, ty: pan0.ty + dy }));
|
|
g.moved = true;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (g && g.kind !== 'wm' && !wmArmed && zoomWindowRect()) {
|
|
if (wts && wts.length >= 2) {
|
|
const [a, b] = wts;
|
|
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
|
|
let p = pinchRef.current;
|
|
if (!p) {
|
|
p = pinchRef.current = {
|
|
d,
|
|
s: libZoom.s,
|
|
tx: libZoom.tx,
|
|
ty: libZoom.ty,
|
|
fx: (a.locationX + b.locationX) / 2,
|
|
fy: (a.locationY + b.locationY) / 2,
|
|
};
|
|
}
|
|
const ns = clampZoom((p.s * d) / Math.max(p.d, 1));
|
|
const k = ns / p.s;
|
|
setLibZoom(clampZoomToWindow(ns, p.fx - k * (p.fx - p.tx), p.fy - k * (p.fy - p.ty)));
|
|
g.moved = true;
|
|
return;
|
|
}
|
|
if (wts && wts.length === 1 && g.kind === 'focus' && !pinchRef.current) {
|
|
const t = wts[0];
|
|
const pan0 = panRef.current;
|
|
if (pan0) {
|
|
const dx = t.locationX - pan0.x;
|
|
const dyP = t.locationY - pan0.y;
|
|
if (Math.abs(dx) > TAP_SLOP || Math.abs(dyP) > TAP_SLOP) panUsedRef.current = true;
|
|
if (panUsedRef.current) {
|
|
setLibZoom(clampZoomToWindow(libZoom.s, pan0.tx + dx, pan0.ty + dyP));
|
|
g.moved = true;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
if (!g || g.kind === 'focus') return;
|
|
if (g.kind === 'wm') {
|
|
// Watermark drag: the mark keeps its place under the finger and travels
|
|
// by the finger's DELTA — it must never jump to the touch point. The
|
|
// second finger (pinch, above) resizes it instead; the commit is below.
|
|
const ts = e?.nativeEvent?.touches ?? [];
|
|
const t = ts[0] ?? e?.nativeEvent;
|
|
const { x, y } = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 };
|
|
const d0 = wmDragRef.current;
|
|
if (d0 && (Math.abs(x - d0.px) > TAP_SLOP || Math.abs(y - d0.py) > TAP_SLOP)) {
|
|
d0.moved = true;
|
|
}
|
|
if (d0?.moved) {
|
|
moveWmDrag(x, y);
|
|
g.moved = true;
|
|
}
|
|
return;
|
|
}
|
|
const dy = g.y0 - pageYOf(e); // up = positive / increase
|
|
if (!g.moved) {
|
|
if (Math.abs(dy) < TAP_SLOP) return;
|
|
g.moved = true;
|
|
}
|
|
if (g.kind === 'ev') {
|
|
if (!onExposureChange) return;
|
|
const ev = clampEv(g.v0 + dy / evPxPerStop);
|
|
const rounded = Math.round(ev * 10) / 10;
|
|
if (rounded === g.lastSent) return;
|
|
g.lastSent = rounded;
|
|
evLastSentRef.current = rounded;
|
|
onExposureChange(rounded);
|
|
return;
|
|
}
|
|
const range = g.param.max - g.param.min;
|
|
let v = g.v0 + (dy * range) / evTrackH; // same feel as the EV track
|
|
v = Math.max(g.param.min, Math.min(g.param.max, v));
|
|
if (g.param.step > 0) v = Math.round(v / g.param.step) * g.param.step;
|
|
const rounded = Math.round(v * 1000) / 1000;
|
|
if (rounded === g.lastSent) return;
|
|
g.lastSent = rounded;
|
|
g.param.onChange(rounded);
|
|
};
|
|
// Latest prop through a ref: the responder props read it on every event, but
|
|
// a ref keeps a stale closure from ever firing a capture after the toggle
|
|
// was switched off mid-gesture.
|
|
const onTouchShutterRef = useRef(onTouchShutter);
|
|
onTouchShutterRef.current = onTouchShutter;
|
|
const onImageDragEnd = (e: any) => {
|
|
const g = imgDragRef.current;
|
|
imgDragRef.current = null;
|
|
const peeked = endHold();
|
|
wmPinchRef.current = null;
|
|
wmDragTgtRef.current = null;
|
|
wmDragRef.current = null;
|
|
viewPinchRef.current = null;
|
|
pinchRef.current = null;
|
|
panRef.current = null;
|
|
panUsedRef.current = false;
|
|
if (!g) return;
|
|
if (g.kind === 'wm') {
|
|
// A tap (never a drag) drops the text at the tapped point; a drag has
|
|
// already moved it. Never lock AE/AF while the watermark is armed.
|
|
if (!g.moved) {
|
|
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
|
|
placeWm(t?.locationX ?? 0, t?.locationY ?? 0);
|
|
}
|
|
commitWm();
|
|
return;
|
|
}
|
|
if (!g.moved && !peeked) {
|
|
// Plain tap: lock focus at the tapped point (drag never re-locks). A
|
|
// hold-to-compare is not a tap either.
|
|
// Touch-to-shutter chains the capture AFTER the focus promise: focusTo()
|
|
// only resolves once focus has fully settled (rejects on timeout/cancel),
|
|
// so waiting on it is exactly "focus first, then shoot" — no timer.
|
|
void handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY).then(() =>
|
|
onTouchShutterRef.current?.()
|
|
);
|
|
}
|
|
};
|
|
|
|
// Camera frame renderer. Stable identity (empty-ish deps): SkiaCamera re-runs
|
|
// its frame pipeline whenever onFrame changes, which during slider drags made
|
|
// the preview flicker. All live values flow through the synchronizables.
|
|
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
|
|
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
|
|
// Frame heartbeat for the camera watchdog: bumped in handleFrame on every
|
|
// delivered frame. When the whole pipeline dies the session still reports
|
|
// OPEN but frame delivery stops, so the heartbeat freezes.
|
|
const frameBeatSync = useMemo(() => createSynchronizable<number>(0), []);
|
|
// Composition band fed to the frame worklet: [active, viewAspect, fx, fy,
|
|
// fw, fh, z, u, v] where (fx,fy,fw,fh) is the photo-area rect (ratio band /
|
|
// polaroid window / wall window) normalized to the view, and (z,u,v) is the
|
|
// framed-window pinch/drag (cover-plane magnification + the plane fraction at
|
|
// the window center, [6]<=1 meaning none). While active the worklet redraws
|
|
// the feed cover-mapped INTO that rect instead of the default fullscreen
|
|
// cover-fit — with a 4:3 ratio (== native sensor) the whole sensor shows,
|
|
// narrower ratios crop exactly like the export. [0]=0 keeps the legacy
|
|
// fullscreen draw untouched.
|
|
const feedRectSync = useMemo(
|
|
() => createSynchronizable<number[]>([0, 0, 0, 0, 0, 0, 0, 0.5, 0.5]),
|
|
[]
|
|
);
|
|
// RETRO INSTANT on the live camera is NOT drawn in the frame pipeline: that
|
|
// canvas is an offscreen surface in frame-pixel space which RNSkia later
|
|
// cover-fits onto the view, so frame-space geometry overflows the screen and
|
|
// cannot be aligned with the dp-space overlays. The camera just renders the
|
|
// plain fullscreen feed here; the polaroid card is composited by the JS
|
|
// overlay (renderFrameOverlay) in view coordinates, like every other frame.
|
|
const handleFrame = useCallback<SkiaCameraOnFrame>(
|
|
(frame, render) => {
|
|
'worklet';
|
|
frameBeatSync.setBlocking(frameBeatSync.getDirty() + 1);
|
|
render(({ canvas, frameTexture }) => {
|
|
// Quick-EV drags write colorMatrixSync from JS on every tick; a torn
|
|
// read can throw inside MakeMatrix (or yield NaN -> black surface) for
|
|
// one frame. Validate + try/catch, fall back to identity (no filter):
|
|
// a 1-frame gain error beats a 1-frame black beat. DEBUG-EV.
|
|
const trash: SkTrash[] = [];
|
|
const keep = <T extends SkTrash>(o: T): T => {
|
|
trash.push(o);
|
|
return o;
|
|
};
|
|
const paint = keep(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 = keep(Skia.ColorFilter.MakeMatrix(cm));
|
|
paint.setColorFilter(baseColorFilter);
|
|
} else {
|
|
console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped');
|
|
}
|
|
} catch (e) {
|
|
console.warn('Color matrix build failed: ' + e);
|
|
}
|
|
// CLARITY: the export pass 4 twin — unsharp convolution above 0, mist
|
|
// blur below. SHARPENING < 0 rides the same slot: it softens the frame
|
|
// the exported file gets too. Both sit on the base paint, under the
|
|
// glow, and compose (soften wraps whatever clarity built).
|
|
try {
|
|
const clarityV = claritySync.getDirty();
|
|
const softenV = softenSync.getDirty();
|
|
const cv = typeof clarityV === 'number' && Number.isFinite(clarityV) ? clarityV : 0;
|
|
const sv = typeof softenV === 'number' && Number.isFinite(softenV) ? softenV : 0;
|
|
if (cv !== 0 || sv < 0) {
|
|
const a = (cv / 10) * 0.8;
|
|
let lensFilter =
|
|
cv > 0
|
|
? keep(
|
|
Skia.ImageFilter.MakeMatrixConvolution(
|
|
3,
|
|
3,
|
|
[0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0],
|
|
1,
|
|
0,
|
|
0,
|
|
0,
|
|
T_CLAMP,
|
|
true,
|
|
null
|
|
)
|
|
)
|
|
: cv < 0
|
|
? keep(
|
|
Skia.ImageFilter.MakeBlur(
|
|
Math.abs(cv / 10) * 4,
|
|
Math.abs(cv / 10) * 4,
|
|
T_CLAMP,
|
|
null
|
|
)
|
|
)
|
|
: null;
|
|
if (sv < 0) {
|
|
lensFilter = keep(
|
|
Skia.ImageFilter.MakeBlur(
|
|
Math.abs(sv / 10) * 3,
|
|
Math.abs(sv / 10) * 3,
|
|
T_CLAMP,
|
|
lensFilter
|
|
)
|
|
);
|
|
}
|
|
if (lensFilter != null) paint.setImageFilter(lensFilter);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Lens filter build failed: ' + e);
|
|
}
|
|
const tone = toneSync.getDirty();
|
|
const cinema = cinemaSync.getDirty();
|
|
const hasTone =
|
|
toneEffect != null &&
|
|
(tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0 ||
|
|
tone[4] !== 0 || tone[5] !== 0 || tone[6] !== 0 ||
|
|
tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0 ||
|
|
tone[10] !== 0 || tone[11] !== 0);
|
|
const hasCinema = cinemaEffect != null && cinema[0] > 0;
|
|
// Draws the full frame with the tone/cinema shader when active (drawRect,
|
|
// not drawImage: paint shaders only apply to geometry fills — drawImage
|
|
// ignores the paint shader, which is why the DR/Highlight/Shadow tone
|
|
// never showed on the live preview) or as a plain color-matrix image.
|
|
const drawFullFrame = () => {
|
|
let fullShader = null;
|
|
if (hasTone || hasCinema) {
|
|
try {
|
|
const imageShader = keep(frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE));
|
|
let final = null;
|
|
if (hasTone) {
|
|
final = keep(toneEffect.makeShaderWithChildren(tone, [imageShader]));
|
|
}
|
|
if (hasCinema) {
|
|
const cinemaShader = keep(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
|
|
: keep(frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE));
|
|
const glowShader =
|
|
glowEffect != null ? keep(glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
|
|
const glowPaint = keep(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 = keep(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null));
|
|
glowPaint.setImageFilter(
|
|
baseColorFilter != null
|
|
? keep(
|
|
Skia.ImageFilter.MakeCompose(
|
|
glowBlur,
|
|
keep(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 = keep(
|
|
Skia.ImageFilter.MakeColorFilter(
|
|
baseColorFilter != null
|
|
? keep(
|
|
Skia.ColorFilter.MakeCompose(keep(Skia.ColorFilter.MakeMatrix(hiMatrix)), baseColorFilter)
|
|
)
|
|
: keep(Skia.ColorFilter.MakeMatrix(hiMatrix)),
|
|
null
|
|
)
|
|
);
|
|
glowPaint.setImageFilter(
|
|
keep(
|
|
Skia.ImageFilter.MakeCompose(
|
|
keep(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 disposeTrash(trash);
|
|
}
|
|
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 disposeTrash(trash);
|
|
}
|
|
// 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 disposeTrash(trash);
|
|
}
|
|
// 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 = keep(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();
|
|
disposeTrash(trash);
|
|
});
|
|
frame.dispose();
|
|
},
|
|
[colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, toneFailLogged, feedRectSync, frameBeatSync]
|
|
);
|
|
|
|
// NOISE REDUCTION below 0 puts grain back instead of smoothing it away, so a
|
|
// negative NR and GRAIN share one overlay (capped at 10 → 0.5), exactly as
|
|
// the export pass does.
|
|
const grainOpacity =
|
|
Math.min(10, Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))) / 20;
|
|
|
|
// Grain cell size: the noise hashes one cell per `rect.w / GRAIN_REF` canvas
|
|
// px, so a ~1080px preview rect hashes per pixel and a 4000px export per ~4px
|
|
// cell — the same grain at the same size relative to the picture (see
|
|
// noiseEffect). Without this the exported grain was ~4x finer than the grain
|
|
// the user had just tuned on screen.
|
|
const GRAIN_REF = 1080;
|
|
|
|
// 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.
|
|
// CROP claims the two fingers for the photo instead — the band must not move.
|
|
const viewZoomed = frameTabActive && !libCropActive && 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 || libCropActive) setViewZoom({ s: 1, tx: 0, ty: 0 });
|
|
}, [frameTabActive, libCropActive]);
|
|
|
|
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 - vhTop) / h);
|
|
const dw = w * s;
|
|
const dh = h * s;
|
|
return { dx: (vw - dw) / 2, dy: vhTop + (vh - vhTop - dh) / 2, dw, dh };
|
|
}, [skiaImage, vw, vh, vhTop]);
|
|
// CROP gestures — the BAND moves, the picture does not. That is the rule
|
|
// FREE always used and the only one that keeps the preview honest: the export
|
|
// is then exactly the band over the picture as drawn, with no transform left
|
|
// to un-do. One finger drags the band (or an edge/corner of a FREE band,
|
|
// which resizes it); two fingers scale it about the point under them.
|
|
// Maths runs in SOURCE fractions (0..1 of the photo), so the exported crop is
|
|
// the rectangle the user sees whatever the display scale.
|
|
// A fixed ratio keeps w/h fixed, so the band can never drift off its ratio;
|
|
// it is also why its edge hit-test is skipped below.
|
|
const cropRatioWH: number | null =
|
|
libCropActive && cropRatio !== 'free' ? CROP_W_H[cropRatio] : null;
|
|
const clampCropRect = (r: CropRect): CropRect => {
|
|
const cl = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
|
if (cropRatioWH != null) {
|
|
// Fractions are of the PHOTO's width and height, not of a square: a
|
|
// fraction-space square is only square on a square photo, so the
|
|
// wanted PIXEL ratio has to be restated as w/h = ratioWH / (dw/dh).
|
|
// Reading it as w/h = ratioWH drew a 3:4 band on a 3:4 photo and a
|
|
// square one on anything else — and the export followed the band.
|
|
const k = imageFitRect && imageFitRect.dh > 0 ? imageFitRect.dw / imageFitRect.dh : 1;
|
|
const ratio = cropRatioWH / k;
|
|
const cx = r.x + r.w / 2;
|
|
const cy = r.y + r.h / 2;
|
|
let w = cl(r.w, MIN_CROP_FRAC, 1);
|
|
let h = w / ratio;
|
|
if (h > 1) {
|
|
h = 1;
|
|
w = Math.min(1, h * ratio);
|
|
} else if (h < MIN_CROP_FRAC) {
|
|
h = MIN_CROP_FRAC;
|
|
w = Math.min(1, h * ratio);
|
|
}
|
|
return { x: cl(cx - w / 2, 0, 1 - w), y: cl(cy - h / 2, 0, 1 - h), w, h };
|
|
}
|
|
const w = cl(r.w, MIN_CROP_FRAC, 1);
|
|
const h = cl(r.h, MIN_CROP_FRAC, 1);
|
|
return { x: cl(r.x, 0, 1 - w), y: cl(r.y, 0, 1 - h), w, h };
|
|
};
|
|
// Fixed-ratio CROP: the band is a rectangle OF THE PICTURE, held in the same
|
|
// 0..1 fractions FREE uses, and the user resizes it (a pinch scales it about
|
|
// the point under the fingers). The picture itself never moves and is never
|
|
// re-scaled, so what the band encloses IS the export, with no zoom transform
|
|
// left to un-do, and the ratio reads exactly like FREE does. Its default,
|
|
// dropped in when the ratio is picked, is still the largest centred rect of
|
|
// that shape — now measured against the photo rather than the screen, which
|
|
// (photo aspect == screen aspect) is the identical rectangle.
|
|
const libCropBand = useMemo(() => {
|
|
if (cropRatio === 'none' || cropRatio === 'free' || !libCropActive || !imageFitRect) return null;
|
|
const r = clampCropRect(cropRect);
|
|
return {
|
|
dx: imageFitRect.dx + r.x * imageFitRect.dw,
|
|
dy: imageFitRect.dy + r.y * imageFitRect.dh,
|
|
dw: r.w * imageFitRect.dw,
|
|
dh: r.h * imageFitRect.dh,
|
|
};
|
|
}, [libCropActive, cropRatio, imageFitRect, cropRect]);
|
|
// Picking a ratio drops the band in at the largest centred rectangle of that
|
|
// shape the PICTURE can hold — the same "fully visible in the frame" default
|
|
// the old screen-sized band gave (a 3:4 photo on a 3:4 screen lands on the
|
|
// identical rect), just measured against the photo so the band can never sit
|
|
// outside it. R is the wanted aspect restated in fraction space: a rect of
|
|
// fraction w/h covers pixels of aspect (w*dw)/(h*dh), so R = rw / (dw/dh).
|
|
const cropRatioDefaultRef = useRef<CropRatio | null>(null);
|
|
useEffect(() => {
|
|
if (!imageFitRect || !(imageFitRect.dw > 0 && imageFitRect.dh > 0)) return;
|
|
if (cropRatio === 'none' || cropRatio === 'free' || !onCropRectChange) return;
|
|
if (cropRatioDefaultRef.current === cropRatio) return;
|
|
// First look at the loaded picture: a ratio already selected here came from
|
|
// a restored session, whose stored rectangle is in this same currency —
|
|
// keep it, and only re-default on the NEXT ratio change.
|
|
if (cropRatioDefaultRef.current === null) {
|
|
cropRatioDefaultRef.current = cropRatio;
|
|
return;
|
|
}
|
|
cropRatioDefaultRef.current = cropRatio;
|
|
const R = CROP_W_H[cropRatio] / (imageFitRect.dw / imageFitRect.dh);
|
|
const w = R >= 1 ? 1 : R;
|
|
const h = R >= 1 ? 1 / R : 1;
|
|
onCropRectChange({ x: (1 - w) / 2, y: (1 - h) / 2, w, h });
|
|
}, [cropRatio, imageFitRect, onCropRectChange]);
|
|
// 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). A CROP band is drawn
|
|
// ON that picture, never over a re-scaled copy of it.
|
|
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), for the camera's ratio bands (the export
|
|
// never crops those through a window) and for a library CROP (there the BAND
|
|
// is what the gestures move and the picture stays where it is).
|
|
const zoomWindowRect = (): { x: number; y: number; w: number; h: number } | null => {
|
|
if (libCropActive) return null;
|
|
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 (polaroid card / wall frame) it is cover-drawn into. 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
|
|
// case collapses to t = 0 (the photo already fills its opening exactly), so
|
|
// a polaroid/wall window never shows the card through a gap.
|
|
// A CROP band is NOT one of these windows any more (the band moves, the
|
|
// picture does not), and zoomWindowRect returns null for it, so this is a
|
|
// no-op there.
|
|
const clampZoomToWindow = (s: number, tx: number, ty: number) => {
|
|
const win = zoomWindowRect();
|
|
if (!win) return { s, tx, ty }; // plain library/camera/crop: unchanged
|
|
const src = 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.
|
|
const cropDragRef = useRef<
|
|
| { kind: 'drag'; mode: string; fx: number; fy: number; r: CropRect }
|
|
| { kind: 'pinch'; d: number; fx: number; fy: number; r: CropRect }
|
|
| null
|
|
>(null);
|
|
const cropHitMode = (x: number, y: number): string | null => {
|
|
const px = cropWinPx;
|
|
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;
|
|
// Fixed ratio: every touch on the band moves it. An edge drag that scaled
|
|
// one axis alone would be the one gesture that could break the ratio.
|
|
if (cropRatioWH != null) return 'move';
|
|
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 = {
|
|
kind: 'drag',
|
|
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 || d.kind !== 'drag' || !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 beginCropPinch = (ts: any[]) => {
|
|
const fit = imageFitRect;
|
|
if (!fit || !(fit.dw > 0 && fit.dh > 0)) return;
|
|
const d = Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY);
|
|
if (d < 1) return;
|
|
const mx = (ts[0].locationX + ts[1].locationX) / 2;
|
|
const my = (ts[0].locationY + ts[1].locationY) / 2;
|
|
cropDragRef.current = {
|
|
kind: 'pinch',
|
|
d,
|
|
fx: (mx - fit.dx) / fit.dw,
|
|
fy: (my - fit.dy) / fit.dh,
|
|
r: cropRect,
|
|
};
|
|
};
|
|
const moveCropPinch = (ts: any[]) => {
|
|
const g = cropDragRef.current;
|
|
if (!g || g.kind !== 'pinch') return;
|
|
const d = Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY);
|
|
const k = d / Math.max(g.d, 1);
|
|
// Scale about the point under the fingers, so the band grows where the
|
|
// user pinched instead of jumping to the middle.
|
|
onCropRectChange?.(
|
|
clampCropRect({
|
|
x: g.fx - (g.fx - g.r.x) * k,
|
|
y: g.fy - (g.fy - g.r.y) * k,
|
|
w: g.r.w * k,
|
|
h: g.r.h * k,
|
|
})
|
|
);
|
|
};
|
|
const onLibTouchStart = (e: any) => {
|
|
if (libCropActive) {
|
|
const cts = e.nativeEvent.touches;
|
|
if (cts.length >= 2) beginCropPinch(cts);
|
|
else 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. CROP is the exception:
|
|
// the band is pinned, so the fingers zoom the PICTURE inside it.
|
|
if (frameTabActive && !libCropActive) {
|
|
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 (libCropActive) {
|
|
const ts = e.nativeEvent.touches;
|
|
// A second finger that lands after the grant never re-fires START, so
|
|
// seed the pinch baseline from the first move that sees the pair.
|
|
if (ts.length >= 2) {
|
|
if (cropDragRef.current?.kind !== 'pinch') beginCropPinch(ts);
|
|
moveCropPinch(ts);
|
|
} else if (cropDragRef.current?.kind === 'drag') {
|
|
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. Under CROP they fall through
|
|
// to the seeded pinch below instead — the picture zooms, the band does not.
|
|
if (ts.length >= 2 && frameTabActive && !libCropActive) {
|
|
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 (libCropActive) {
|
|
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 band IS the crop, in the same 0..1
|
|
// fractions of the source as the FREE rectangle, so the export engine still
|
|
// needs to know nothing about bands — and there is no zoom transform left
|
|
// to un-do either. clampCropRect keeps the reported rect on its ratio even if
|
|
// the stored value arrived off-aspect (a restored session).
|
|
const cropRatioRectRef = useRef<CropRect | null>(null);
|
|
cropRatioRectRef.current = libCropBand ? clampCropRect(cropRect) : null;
|
|
|
|
// 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}
|
|
uniforms={{ u: (framedWin ? framedWin.w : region.w) / GRAIN_REF }}
|
|
/>
|
|
</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 && !paused}
|
|
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 && !paused}
|
|
cover={uwCover}
|
|
/>
|
|
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
|
|
the open panel parameter (or EV while AE is locked). Sits below the
|
|
quick-EV strip, which keeps its own narrower direct-drag handle. */}
|
|
<View
|
|
className="absolute inset-0"
|
|
onStartShouldSetResponder={() => true}
|
|
onResponderGrant={onImageDragGrant}
|
|
onResponderMove={onImageDragMove}
|
|
onResponderRelease={onImageDragEnd}
|
|
onResponderTerminate={onImageDragEnd}
|
|
/>
|
|
</View>
|
|
{focusPoint && (
|
|
/* The locked-AE/AF reticle IS the unlock control: the old
|
|
"AE/AF LOCKED — TAP TO UNLOCK" banner was removed from the preview
|
|
(it sat under the zoom-preset row), so tapping the lock reticle
|
|
releases the lock. */
|
|
<TouchableOpacity
|
|
onPress={handleUnlockFocus}
|
|
activeOpacity={0.7}
|
|
className="absolute"
|
|
style={{ left: focusPoint.x - 26, top: focusPoint.y - 26 }}
|
|
>
|
|
<View className="w-[52px] h-[52px] rounded-full border-[1.5px] border-amber-400 items-center justify-center">
|
|
<View className="w-9 h-9 rounded-full bg-amber-400/15 items-center justify-center">
|
|
<Lock size={14} color="#f59e0b" strokeWidth={2.5} />
|
|
</View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
)}
|
|
{focusPoint && onExposureChange && (
|
|
<>
|
|
{/* EV value readout (left of the slider) */}
|
|
<View pointerEvents="none" className="absolute" style={{ right: 64, top: evThumbY - 12 }}>
|
|
<Text className="text-amber-400 font-mono text-xs font-bold bg-black/50 px-1.5 py-0.5 rounded">
|
|
EV {(evStops > 0 ? '+' : '') + evStops.toFixed(1)}
|
|
</Text>
|
|
</View>
|
|
{/* Vertical track: drag up = brighter, drag down = darker */}
|
|
<View
|
|
className="absolute"
|
|
style={{ left: vw - 56, top: evTrackTop, width: 40, height: evTrackH }}
|
|
onStartShouldSetResponder={() => true}
|
|
onMoveShouldSetResponder={() => true}
|
|
onResponderGrant={onEvSliderGrant}
|
|
onResponderMove={onEvSliderMove}
|
|
onResponderRelease={onEvSliderRelease}
|
|
onResponderTerminate={onEvSliderRelease}
|
|
>
|
|
<View pointerEvents="none" className="absolute left-1/2 top-0 bottom-0 w-[2px] -ml-px bg-white/20 rounded-full" />
|
|
<View
|
|
pointerEvents="none"
|
|
className="absolute w-8 h-8 rounded-full border-2 border-amber-400 bg-black/60"
|
|
style={{ top: evThumbY - evTrackTop - 16, left: 4 }}
|
|
/>
|
|
</View>
|
|
<View pointerEvents="none" className="absolute" style={{ left: vw - 48, top: evTrackTop - 22 }}>
|
|
<Text className="text-zinc-400 font-mono text-[10px] text-center w-9">+3</Text>
|
|
</View>
|
|
<View pointerEvents="none" className="absolute" style={{ left: vw - 48, top: evTrackTop + evTrackH + 6 }}>
|
|
<Text className="text-zinc-400 font-mono text-[10px] text-center w-9">-3</Text>
|
|
</View>
|
|
</>
|
|
)}
|
|
{renderCameraOverlays()}
|
|
{/* Native zoom presets — snap to a controller zoom ratio. 1x is the
|
|
main lens; 0.5x appears only on a device whose CameraX zoom range
|
|
reaches below 1 (ultra-wide), and 2x-10x are digital crop. Row sits
|
|
under the TopBar, clear of the AE-lock chip (top-right) and the EV
|
|
strip (right edge). uwCover keeps the row mounted across the
|
|
0.5x handoff: the 1x session has not restarted yet, so
|
|
`controller` is still undefined and the row used to blink. */}
|
|
{(controller || ultraWideOn || uwCover) && (
|
|
<View
|
|
pointerEvents="box-none"
|
|
className="absolute left-0 right-0 items-center"
|
|
style={{ top: insets.top + 62 }}
|
|
>
|
|
<View className="flex-row rounded-full border border-zinc-700/70 bg-black/50 p-1">
|
|
{zoomPresets.map((z) => (
|
|
<TouchableOpacity
|
|
key={z}
|
|
onPress={() => applyZoom(z)}
|
|
className={'px-3.5 py-1.5 rounded-full ' + (zoomRatio === z ? 'bg-amber-500' : '')}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text
|
|
className={
|
|
'font-mono text-xs font-bold ' +
|
|
(zoomRatio === z ? 'text-black' : 'text-white')
|
|
}
|
|
>
|
|
{z}x
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom.
|
|
const toneOn = toneParams.some((v) => v !== 0);
|
|
// One object for every <Shader> below; keys must match TONE_SKSL's uniforms.
|
|
const toneUniforms = {
|
|
dr: toneParams[0],
|
|
hl: toneParams[1],
|
|
sh: toneParams[2],
|
|
vib: toneParams[3],
|
|
shTr: toneParams[4],
|
|
shTg: toneParams[5],
|
|
shTb: toneParams[6],
|
|
hlTr: toneParams[7],
|
|
hlTg: toneParams[8],
|
|
hlTb: toneParams[9],
|
|
cc: toneParams[10],
|
|
ccb: toneParams[11],
|
|
};
|
|
const cinemaOn = cinemaEffect != null && !!cinemaParams;
|
|
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
|
|
|
|
// ---- CLARITY + HDF EFFECT on the LIBRARY preview -----------------------
|
|
// The camera worklet and exportEngine both have these two passes; the three
|
|
// library branches below draw the photo declaratively and had neither, so a
|
|
// photo opened from the library ignored both knobs. Same maths as the camera:
|
|
// `sx`/`sy` turn one ORIGINAL image pixel into canvas units (the library
|
|
// preview is laid out in dp, the camera surface is in sensor px), keeping the
|
|
// unsharp radius and the bloom sigma identical to the file.
|
|
const libClarity = adjustments.clarity ?? 0;
|
|
const libGlow = (adjustments.hdf ?? 0) / 10;
|
|
const libShaderPath = useShaderPass || (libClarity > 0 && clarityEffect != null);
|
|
const libPhoto = (
|
|
r: { x: number; y: number; w: number; h: number },
|
|
fit: 'cover' | 'fill' | 'contain'
|
|
) => {
|
|
const iw = skiaImage ? skiaImage.width() : r.w;
|
|
const ih = skiaImage ? skiaImage.height() : r.h;
|
|
// cover/contain scale uniformly (max/min), fill stretches each axis.
|
|
const sw = r.w / iw;
|
|
const sh = r.h / ih;
|
|
const uniform = fit === 'cover' ? Math.max(sw, sh) : fit === 'contain' ? Math.min(sw, sh) : null;
|
|
const sx = uniform ?? sw;
|
|
const sy = uniform ?? sh;
|
|
const rectNode = rect(r.x, r.y, r.w, r.h);
|
|
let graded: React.ReactNode = <ImageShader image={skiaImage} fit={fit} tx="clamp" ty="clamp" rect={rectNode} />;
|
|
if (toneEffect != null && toneOn) {
|
|
graded = <Shader source={toneEffect} uniforms={toneUniforms}>{graded}</Shader>;
|
|
}
|
|
if (cinemaOn) {
|
|
graded = <Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>{graded}</Shader>;
|
|
}
|
|
// The Rect must cover exactly the photo area, never the full canvas: a
|
|
// fullscreen ImageShader with fit="contain" clamps the photo edges across
|
|
// the black letterbox (reads as "the background is a zoomed copy").
|
|
let photo: React.ReactNode = libShaderPath ? (
|
|
<Rect x={r.x} y={r.y} width={r.w} height={r.h}>
|
|
{libClarity > 0 && clarityEffect != null ? (
|
|
<Shader source={clarityEffect} uniforms={clarityUniforms((libClarity / 10) * 0.8, sx, sy)}>
|
|
{graded}
|
|
</Shader>
|
|
) : (
|
|
graded
|
|
)}
|
|
</Rect>
|
|
) : (
|
|
<SkiaImage image={skiaImage} fit={fit} rect={rectNode} />
|
|
);
|
|
// Mist (negative clarity) = export pass 4's blur; sigma in original px.
|
|
// Sharpening < 0 rides the same wrap: the export runs a second Gaussian
|
|
// after the mist, and two Gaussians in series are one of sqrt(a^2 + b^2),
|
|
// so a single pass with that sigma matches the file.
|
|
const libMist = libClarity < 0 ? Math.abs(libClarity / 10) * 4 * sx : 0;
|
|
const libSoften = (Math.max(0, -(adjustments.sharpening ?? 0)) / 10) * 3 * sx;
|
|
const libBlur = Math.sqrt(libMist * libMist + libSoften * libSoften);
|
|
if (libBlur > 0) {
|
|
photo = (
|
|
<Group>
|
|
<Blur blur={libBlur} mode="clamp" />
|
|
{photo}
|
|
</Group>
|
|
);
|
|
}
|
|
// Export clips the bloom and the mist to the canvas, which IS the photo.
|
|
// The preview must clip too: the HDF glow's blur (and negative clarity's)
|
|
// otherwise bleeds past the photo onto the black letterbox and over the
|
|
// frame's mat. Clip to the drawn image, not to the rect: on "contain" the
|
|
// rect is wider than the picture.
|
|
const cw = Math.min(r.w, iw * sx);
|
|
const ch = Math.min(r.h, ih * sy);
|
|
const photoClip = rect(r.x + (r.w - cw) / 2, r.y + (r.h - ch) / 2, cw, ch);
|
|
return (
|
|
<Group clip={photoClip}>
|
|
{photo}
|
|
{/* HDF bloom, the export pass 5b twin: bright pass on the luminance,
|
|
blur, Screen back at 0.15..0.5 x strength. Radius is a fraction of
|
|
the drawn width, so any image size blooms like the camera does. */}
|
|
{libGlow > 0 && glowEffect != null ? (
|
|
<Rect
|
|
x={r.x}
|
|
y={r.y}
|
|
width={r.w}
|
|
height={r.h}
|
|
blendMode="screen"
|
|
opacity={0.15 + 0.35 * libGlow}
|
|
>
|
|
<Blur blur={cw * (0.004 + 0.015 * libGlow)} mode="clamp" />
|
|
<Shader source={glowEffect} uniforms={GLOW_UNIFORMS}>{graded}</Shader>
|
|
</Rect>
|
|
) : null}
|
|
</Group>
|
|
);
|
|
};
|
|
|
|
// RETRO INSTANT (library): the whole picture is contain-fitted into the
|
|
// card's photo window — nothing is masked or cropped, and the card is
|
|
// centered on the black background like a physical print. The window keeps
|
|
// the photo aspect ratio, so fit="fill" never distorts.
|
|
if (mode === 'library' && polaroidGeo && skiaImage && libraryImageUri) {
|
|
const win = polaroidGeo.window;
|
|
const card = polaroidGeo.card;
|
|
const winRect = rect(win.x, win.y, win.w, win.h);
|
|
return (
|
|
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
|
<Canvas style={StyleSheet.absoluteFill}>
|
|
<Group matrix={viewZoomMatrix}>
|
|
<Rect x={card.x} y={card.y} width={card.w} height={card.h} color="#faf9f6" />
|
|
{/* Photo is clipped to the window and repositioned by pinch/drag;
|
|
the card, seam, grain and watermarks stay put. */}
|
|
<Group clip={winRect}>
|
|
<Group matrix={libZoomMatrix}>
|
|
<ColorMatrix matrix={colorMatrix} />
|
|
{libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, polaroidFit)}
|
|
</Group>
|
|
</Group>
|
|
{grainOpacity > 0 && noiseEffect && (
|
|
<Group blendMode="overlay">
|
|
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
|
|
<Shader source={noiseEffect} uniforms={{ u: win.w / GRAIN_REF }} />
|
|
</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} uniforms={{ u: win.w / GRAIN_REF }} />
|
|
</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} uniforms={{ u: frameRect.w / GRAIN_REF }} />
|
|
</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;
|