Files
RecipesCam/src/components/Viewfinder.tsx
T
3dtours 56c86df1b5 Show native sensor in viewfinder composition band
SkiaCamera cover-fits the upright frame snapshot onto the fullscreen view,
so a 4:3 ratio band (aspect == native 3:4 sensor) only revealed a center
slice of the sensor instead of the whole frame. Redraw the feed inside the
frame worklet: when a photo-area rect is active (ratio band / polaroid
window / wall window) 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 now show the full sensor; narrower ratios
(3:2) crop center like the export. Inactive feed keeps the legacy draw.
2026-09-08 19:34:32 +07:00

1243 lines
55 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera';
import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia';
import { createSynchronizable } from 'react-native-worklets';
import {
Canvas,
ClipOp,
useImage,
useFont,
Text as SkiaText,
rect,
Shader,
Skia,
Group,
Rect,
ColorMatrix,
Image as SkiaImage,
ImageShader,
TileMode,
FilterMode,
MipmapMode,
} from '@shopify/react-native-skia';
import { Lock } from 'lucide-react-native';
import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils';
interface ViewfinderProps {
mode: 'camera' | 'library';
recipe: Recipe;
selectedFrame: FrameId;
useGeotag: boolean;
gpsInfo: GPSInfo | null;
libraryImageUri: string | null;
cameraPermissionGranted: boolean;
onRequestCameraPermission: () => void;
photoOutput: CameraPhotoOutput;
// Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band
// and the photo-area crop. Wall frame ignores it (fixed frame opening).
aspectRatio: AspectRatio;
// RAW DNG toggle: while on, the DNG output below is attached to the session
// (a second ImageCapture use case). Kept null-free at the App level; this is
// nullable only because the session may not exist yet.
rawEnabled: boolean;
rawOutput: CameraPhotoOutput | null;
// Session-level error from the camera (e.g. DNG output failed to bind). App
// auto-disables RAW on such errors and alerts the user.
onSessionError?: (error: Error) => void;
// Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV.
onExposureChange?: (ev: number) => void;
}
const noiseEffect = Skia.RuntimeEffect.Make(`
vec4 main(vec2 pos) {
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
return vec4(vec3(r), 1.0);
}
`);
// Tone runtime effect (DR/Highlight/Shadow) for the live camera preview worklet.
// Hoisted at module scope so the worklet runtime can capture it — same pattern
// as the official vision-camera-skia shader examples. Skia caches compiled
// effects by source string, so Make here runs once per app launch.
// Compile failure at module scope throws (JavascriptException → SIGABRT), so
// swallow it into null — callers already fall back to matrix-only.
const toneEffect = (() => {
try {
return Skia.RuntimeEffect.Make(TONE_SKSL);
} catch (e) {
console.error('[camtone] TONE_SKSL compile failed: ' + e);
return null;
}
})();
const cinemaEffect = (() => {
try {
return Skia.RuntimeEffect.Make(CINEMA_SKSL);
} catch (e) {
console.error('[cinema] CINEMA_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;
export default function Viewfinder({
mode,
recipe,
selectedFrame,
useGeotag,
gpsInfo,
libraryImageUri,
cameraPermissionGranted,
onRequestCameraPermission,
photoOutput,
aspectRatio,
rawEnabled,
rawOutput,
onSessionError,
onExposureChange,
}: ViewfinderProps) {
// Fullscreen hero: the viewfinder fills the entire screen, every overlay
// rect derives from window size.
const { width: vw, height: vh } = useWindowDimensions();
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 =
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 };
const h = vw / ratioWH;
if (h <= vh) {
return { x: 0, y: (vh - h) / 2, w: vw, h };
}
// Screen narrower than the ratio: fit by height, letterbox the sides.
const w = vh * ratioWH;
return { x: (vw - w) / 2, y: 0, w, h };
}, [regionBand, ratioWH, vw, vh]);
// 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('back');
const skiaImage = useImage(libraryImageUri || '');
// 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)
);
const adjustments = recipe.adjustments;
// Camera captures apply exposure compensation as a hardware AE bias (see the
// setExposureBias effect below) — never via the matrix here. Library
// stills have no hardware bias, so the preview matrix gains 2^EV.
const colorMatrix =
mode === 'library'
? applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), adjustments.exposureCompensation ?? 0)
: getSkiaColorMatrix(recipe.baseFilter, adjustments);
// 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] for the preview worklet; all-zero = no tone pass.
const toneParams = (() => {
const u = getToneUniforms(adjustments);
return [u.dr, u.hl, u.sh];
})();
const toneSync = useMemo(
() => createSynchronizable<number[]>([0, 0, 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
[]
);
useEffect(() => {
colorMatrixSync.setBlocking(colorMatrix);
toneSync.setBlocking(toneParams);
cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat());
}, [colorMatrix, toneParams, cinemaParams, colorMatrixSync, toneSync, cinemaSync]);
// Exposure compensation → hardware AE bias. Passed imperatively (never as a
// declarative prop): v5 calls setExposureBias while the session is still
// starting up and throws "Camera is not active" — seen on the Xiaomi at boot.
// The controller only exists after onStarted, so it is safe to bias then.
// Deps on the controller identity (not just cameraActive): a session restart
// swaps the controller and silently resets the bias to 0.
const skiaCameraRef = useRef<SkiaCameraRef>(null);
const [cameraActive, setCameraActive] = useState(false);
// Camera session pauses when the app backgrounds; toggling isActive back on
// resume restarts it. Without this the preview stayed black after unlock.
const [appActive, setAppActive] = useState(AppState.currentState === 'active');
useEffect(() => {
const sub = AppState.addEventListener('change', (s) => setAppActive(s === 'active'));
return () => sub.remove();
}, []);
// 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;
const evStops = adjustments.exposureCompensation ?? 0;
// Latest slider value, so stale retries from a previous value back off when
// the user has already dragged further (dragging fast used to queue 5 retries
// per intermediate value — each one cancelled by the next, spamming [ev]).
const evStopsRef = useRef(evStops);
evStopsRef.current = evStops;
useEffect(() => {
if (!controller) return;
const d = controller.device;
if (d && d.supportsExposureBias) {
console.error(
`[ev] device range=${d.minExposureBias}..${d.maxExposureBias} current=${controller.exposureBias}`
);
} else {
console.error(`[ev] device has no exposure bias support`);
}
}, [controller]);
useEffect(() => {
if (!controller) return;
const d = controller.device;
if (!d || !d.supportsExposureBias) {
console.error(`[ev] device has no exposure bias support`);
return;
}
// CameraX exposes *compensation indexes*, not EV stops: the Android
// implementation runs setExposureCompensationIndex(exposure.toInt()), and
// 1 index = 1/8 EV on this device class (range -24..24 -> ±3 EV). The UI
// slider is real EV, so map EV -> index and clamp to the sensor range.
// Without this, +0.5 EV truncated to index 0 (no change) and +3 EV became
// index 3 = 0.375 EV — the "EV barely changes" report.
const index = Math.round(evStops * 8);
const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index));
// CameraX cancels an in-flight bias change whenever another compensation
// request lands (session reconfig, AE restart) — retry a few times, but
// only while this value is still the latest one the user asked for.
// The slider emits many values per drag: fire setExposureBias only after a
// short quiet period, so a stream of EV changes collapses into one camera
// call. Without this, each intermediate value queued its own retry chain
// and the in-flight cancels (with AE re-lock on the Xiaomi) caused preview
// flicker while dragging EV.
const timers: ReturnType<typeof setTimeout>[] = [];
let attempt = 0;
let disposed = false;
const trySet = () => {
if (disposed || evStopsRef.current !== evStops) return; // stale — a newer drag is pending
attempt += 1;
controller
.setExposureBias(clamped)
.then(() => {
console.error(`[ev] ev=${evStops} -> idx=${clamped} ok, read=${controller.exposureBias}`);
})
.catch((e) => {
console.error(`[ev] ev=${evStops} idx=${clamped} attempt=${attempt} failed: ${String(e).slice(0, 100)}`);
if (attempt < 5 && String(e).includes('Cancel')) {
timers.push(setTimeout(trySet, 150));
}
});
};
const debounce = setTimeout(trySet, 110);
return () => {
disposed = true;
clearTimeout(debounce);
timers.forEach((t) => clearTimeout(t));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller, evStops]);
// 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) => {
if (!cameraActive) return;
const cam = skiaCameraRef.current;
const d = cam?.controller?.device;
if (!d) return;
if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) {
console.error('[focus] metering unsupported on this device');
return;
}
setFocusPoint({ x, y });
try {
await cam.focusTo(
{ x, y },
{ responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null }
);
console.error(`[focus] tap=${Math.round(x)},${Math.round(y)} AE/AF/AWB locked`);
} catch (e) {
console.error('[focus] lock failed: ' + String(e).slice(0, 120));
}
};
const handleUnlockFocus = async () => {
const cam = skiaCameraRef.current;
if (!cam) return;
try {
await cam.resetFocus();
setFocusPoint(null);
console.error('[focus] reset to continuous auto');
} catch (e) {
console.error('[focus] reset failed: ' + String(e).slice(0, 120));
}
};
// Quick exposure slider — appears while AE/AF is locked. Drag up/down maps
// directly to the camera AE bias in EV stops (same path as the recipe EV
// slider: Viewfinder already syncs adjustments.exposureCompensation to the
// controller via setExposureBias, and captures apply the bias on-device).
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);
const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH;
const onEvSliderGrant = (e: any) => {
evDragRef.current = { y0: e.nativeEvent.locationY, ev0: evStops };
};
const onEvSliderMove = (e: any) => {
const g = evDragRef.current;
if (!g || !onExposureChange) return;
const dy = g.y0 - e.nativeEvent.locationY; // drag up = brighter
const ev = clampEv(g.ev0 + dy / evPxPerStop);
onExposureChange(Math.round(ev * 10) / 10);
};
const onEvSliderRelease = () => {
evDragRef.current = null;
};
// Instrumentation for device logs (user captures only logcat E level, so
// console.log/warn never show up — these are error-level on purpose).
const lastPreviewLog = useRef(0);
useEffect(() => {
const now = Date.now();
if (now - lastPreviewLog.current < 400) return;
lastPreviewLog.current = now;
console.error(
`[preview] matrix=(${colorMatrix[0].toFixed(3)},${colorMatrix[5].toFixed(3)},${colorMatrix[10].toFixed(3)}) tone=[${toneParams.join(',')}] ev=${evStops}`
);
}, [colorMatrix, toneParams, evStops]);
// 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), []);
// Composition band fed to the frame worklet: [active, viewAspect, fx, fy,
// fw, fh] where (fx,fy,fw,fh) is the photo-area rect (ratio band / polaroid
// window / wall window) normalized to the view. 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]), []);
// RETRO POLAROID 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';
render(({ canvas, frameTexture }) => {
const paint = Skia.Paint();
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSync.getBlocking()));
const tone = toneSync.getBlocking();
const cinema = cinemaSync.getBlocking();
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 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 = () => {
if (hasTone || hasCinema) {
try {
const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
let final = null;
if (hasTone) {
final = toneEffect.makeShaderWithChildren(tone, [imageShader]);
}
if (hasCinema) {
const cinemaShader = cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]);
if (cinemaShader != null) final = cinemaShader;
}
if (final != null) {
const w = frameTexture.width();
const h = frameTexture.height();
paint.setShader(final);
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
return;
}
} catch (e) {
if (!toneFailLogged.getBlocking()) {
toneFailLogged.setBlocking(true);
console.error('[camtone] shader failed, matrix-only: ' + e);
}
}
}
canvas.drawImage(frameTexture, 0, 0, paint);
};
// 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.getBlocking();
const bandActive = feed[0] > 0;
if (!bandActive) {
drawFullFrame();
return;
}
const ftW = frameTexture.width();
const ftH = frameTexture.height();
const orient = frame.orientation;
const isLand = orient === 'left' || orient === 'right';
const ow = isLand ? ftH : ftW;
const oh = isLand ? ftW : ftH;
const ac = ow / oh;
const viewAspect = feed[1];
if (!(ow > 0 && oh > 0 && ac > 0 && viewAspect > 0 && feed[4] > 0 && feed[5] > 0)) {
drawFullFrame();
return;
}
// View -> upright-snapshot mapping: the snapshot is cover-fitted to the
// view, so the view shows a centered visX x visY window of it.
const visX = Math.min(1, viewAspect / ac);
const visY = Math.min(1, ac / viewAspect);
const ox0 = (1 - visX) / 2 + feed[2] * visX;
const ox1 = ox0 + feed[4] * visX;
const oy0 = (1 - visY) / 2 + feed[3] * visY;
const oy1 = oy0 + feed[5] * visY;
const X0 = ox0 * ow;
const X1 = ox1 * ow;
const Y0 = oy0 * oh;
const Y1 = oy1 * oh;
// Invert the renderToTexture CTM (out-space -> raw frame space) by
// walking its concats in reverse: A translate(ow/2,oh/2), mirror
// scale(-1,1), rotate(-orientation), D translate(-oh/2,-ow/2).
const mirrored = frame.isMirrored === true;
const deg =
orient === 'down' ? 180 : orient === 'left' ? 90 : orient === 'right' ? 270 : 0;
const toRaw = (X: number, Y: number): [number, number] => {
let x = X - ow / 2;
let y = Y - oh / 2;
if (mirrored) x = -x;
const r = (-deg * Math.PI) / 180;
const c = Math.cos(r);
const s = Math.sin(r);
const rx = x * c - y * s;
const ry = x * s + y * c;
if (isLand) return [rx + oh / 2, ry + ow / 2];
return [rx + ow / 2, ry + oh / 2];
};
let u0 = ftW;
let u1 = 0;
let v0 = ftH;
let v1 = 0;
const c0 = toRaw(X0, Y0);
const c1 = toRaw(X1, Y0);
const c2 = toRaw(X0, Y1);
const c3 = toRaw(X1, Y1);
const cs = [c0, c1, c2, c3];
for (let i = 0; i < 4; i++) {
u0 = Math.min(u0, cs[i][0]);
u1 = Math.max(u1, cs[i][0]);
v0 = Math.min(v0, cs[i][1]);
v1 = Math.max(v1, cs[i][1]);
}
const rw = u1 - u0;
const rh = v1 - v0;
if (!(rw > 0 && rh > 0)) {
drawFullFrame();
return;
}
// Cover-map the full sensor into the raw rect (dst preserves the sensor
// aspect, centered; the clip below trims the overflow) — translate then
// scale: Skia concat applies the later call first (p -> s*p + d).
const s = Math.max(rw / ftW, rh / ftH);
const dw = s * ftW;
const dh = s * ftH;
const dx = u0 + (rw - dw) / 2;
const dy = v0 + (rh - dh) / 2;
// Fresh paint; its default color is opaque black (Skia default), so
// the surface fill below needs no explicit color set.
const blackPaint = Skia.Paint();
canvas.drawRect(Skia.XYWHRect(0, 0, ftW, ftH), blackPaint);
canvas.save();
canvas.clipRect(Skia.XYWHRect(u0, v0, rw, rh), CLIP_INTERSECT, false);
canvas.translate(dx, dy);
canvas.scale(s, s);
drawFullFrame();
canvas.restore();
});
frame.dispose();
},
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged, feedRectSync]
);
const grainOpacity = adjustments.grain / 20;
// Library-still viewer: pinch-zoom (2 fingers) + double-tap zoom at the tap
// point / double-tap again to reset to 100%. Transform is a Skia matrix
// P' = scale*P + (tx,ty); gesture math keeps the focal point stationary.
const [libZoom, setLibZoom] = useState({ s: 1, tx: 0, ty: 0 });
const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null);
const pinchUsedRef = useRef(false);
const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null);
const clampZoom = (s: number) => Math.max(1, Math.min(6, s));
// Contain-fit rect of the loaded photo in canvas coordinates (image is
// centered with black letterbox above/below on a portrait screen).
const imageFitRect = useMemo(() => {
const img = skiaImage;
if (!img) return null;
const w = img.width();
const h = img.height();
if (!(w > 0 && h > 0)) return null;
const s = Math.min(vw / w, vh / h);
const dw = w * s;
const dh = h * s;
return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh };
}, [skiaImage, vw, vh]);
// 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.
const polaroidAspect =
ratioWH ??
(mode === 'library' && skiaImage && skiaImage.width() > 0 && skiaImage.height() > 0
? skiaImage.width() / skiaImage.height()
: 3 / 4);
// Photo draw fit inside the polaroid window: cover when the window aspect
// (ratio) differs from the source photo, fill otherwise (aspects match).
const polaroidFit: 'cover' | 'fill' = ratioWH ? 'cover' : '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');
}, [selectedFrame, vw, vh]);
// 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(() => {
feedRectSync.setBlocking(
cameraPhotoRect
? [1, vw / vh, cameraPhotoRect.x / vw, cameraPhotoRect.y / vh, cameraPhotoRect.w / vw, cameraPhotoRect.h / vh]
: [0, 0, 0, 0, 0, 0]
);
}, [cameraPhotoRect, vw, vh, feedRectSync]);
const onLibTouchStart = (e: any) => {
const ts = e.nativeEvent.touches;
if (ts.length >= 2) {
const [a, b] = ts;
pinchUsedRef.current = true;
lastTapRef.current = null;
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,
};
}
};
const onLibTouchMove = (e: any) => {
const ts = e.nativeEvent.touches;
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;
setLibZoom({
s,
tx: p.fx - k * (p.fx - p.tx),
ty: p.fy - k * (p.fy - p.ty),
});
}
};
const onLibTouchEnd = (e: any) => {
const wasPinch = pinchUsedRef.current;
pinchRef.current = null;
pinchUsedRef.current = false;
if (wasPinch) return; // a double-tap is always a single finger
const x = e.nativeEvent.locationX;
const y = e.nativeEvent.locationY;
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 (composition
// region under a ratio band, contain rect otherwise) so a double-tap
// that lands in the black letterbox cannot fling the photo away.
let fx = x;
let fy = y;
const fit = 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;
return { s, tx: fx - s * fx, ty: fy - s * fy }; // zoom into the tapped point
});
}
};
// 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]
);
// Border/bars scale with the composition region so framed exports stay
// proportional to the cropped photo (region == fullscreen when no ratio).
const borderWidth = Math.min(region.w, region.h) * 0.05;
const barHeight = region.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 composition region (region == fullscreen when no
// ratio band), so it always sits at the photo's edge.
return (
<Group color="white">
<Rect x={region.x} y={region.y} width={region.w} height={borderWidth} />
<Rect x={region.x} y={region.y + region.h - borderWidth} width={region.w} height={borderWidth} />
<Rect x={region.x} y={region.y} width={borderWidth} height={region.h} />
<Rect x={region.x + region.w - borderWidth} y={region.y} width={borderWidth} height={region.h} />
</Group>
);
}
if (selectedFrame === 'cinematic') {
// Letterbox bars hug the composition region's top/bottom edges.
return (
<Group color="black">
<Rect x={region.x} y={region.y} width={region.w} height={barHeight} />
<Rect x={region.x} y={region.y + region.h - barHeight} width={region.w} height={barHeight} />
</Group>
);
}
return null;
};
const renderGPSWatermark = () => {
if (!useGeotag || !gpsInfo || !customFont || !iconFont) 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())}`;
// Anchor to the PHOTO's top-left, not the screen. Bottom anchoring put the
// mark under the floating chip rows (open WB/FRAME panel covered it), so it
// now lives at the top of the photo/window: the bottom sheet can never reach
// it. In camera mode the photo fills the screen; in library mode it is
// contain-fitted with letterbox — use imageFitRect so the mark sits on the
// photo corner. With a polaroid frame the photo lives inside the card
// window — anchor to that. Always clear the floating TopBar (recipe name +
// SAVE), which ends around insets.top + 56 dp.
const win = polaroidGeo?.window ?? wallGeo?.window ?? null;
const area = 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 }
: mode === 'library' && imageFitRect
? imageFitRect
: { dx: 0, dy: 0, dw: vw, dh: vh };
const iconSize = Math.round(vw * 0.032); // matches customFont/iconFont size
const topBarBottom = insets.top + 56;
const topBase = Math.max(area.dy + Math.round(iconSize * 0.6), topBarBottom);
const xOffset =
area.dx + (win ? area.dw * 0.05 : Math.min(16, area.dw * 0.04));
// Two stacked lines (pin/location above camera/timestamp).
const row1Y = topBase + Math.round(iconSize * 1.1);
const row2Y = topBase + Math.round(iconSize * 2.3);
// 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);
const pinW = iconFont.measureText('📍');
const camW = iconFont.measureText('📷');
return (
<Group>
{/* Icons white so they read as glyphs, not part of the amber text */}
<SkiaText x={xOffset} y={row1Y} text="📍" font={iconFont} color="#ffffff" />
<SkiaText x={xOffset + gap(pinW.width)} y={row1Y} text={locationName} font={customFont} color="#f59e0b" />
<SkiaText x={xOffset} y={row2Y} text="📷" font={iconFont} color="#ffffff" />
<SkiaText x={xOffset + gap(camW.width)} y={row2Y} text={timestampStr} font={customFont} color="#f59e0b" />
</Group>
);
};
const renderCameraOverlays = () => {
const framedWin = polaroidGeo?.window ?? wallGeo?.window ?? null;
return (
<>
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
{/* Aspect-ratio band: black out the live feed outside the composition
region. Frames with their own geometry (polaroid/wall) draw their
own bars later, so this only fires for the plain frames. */}
{regionBand && (
<Group color="#000000">
<Rect x={0} y={0} width={vw} height={region.y} />
<Rect x={0} y={region.y + region.h} width={vw} height={vh - region.y - region.h} />
<Rect x={0} y={region.y} width={region.x} height={region.h} />
<Rect x={region.x + region.w} y={region.y} width={vw - region.x - region.w} height={region.h} />
</Group>
)}
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
{/* Grain belongs to the photo; on a framed photo it must not spill
onto the mat, so it is clipped to the photo window. */}
<Rect
x={framedWin ? framedWin.x : region.x}
y={framedWin ? framedWin.y : region.y}
width={framedWin ? framedWin.w : region.w}
height={framedWin ? framedWin.h : region.h}
opacity={grainOpacity}
>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{renderFrameOverlay()}
{renderGPSWatermark()}
</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">
<SkiaCamera
ref={skiaCameraRef}
style={StyleSheet.absoluteFill}
device={device}
isActive={appActive}
outputs={cameraOutputs}
pixelFormat="yuv"
onStarted={() => setCameraActive(true)}
onFrame={handleFrame}
onError={onSessionError}
/>
{/* Tap-to-focus layer: fullscreen responder (all visual overlays above
are pointerEvents none). locationX/Y are relative to this view. */}
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)}
/>
{focusPoint && (
<View
pointerEvents="none"
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>
</View>
)}
{focusPoint && (
<TouchableOpacity
onPress={handleUnlockFocus}
className="absolute right-4 top-24 bg-black/70 border border-amber-400/70 rounded-full px-3 py-1.5 flex-row items-center space-x-1.5"
activeOpacity={0.7}
>
<Lock size={11} color="#f59e0b" />
<Text className="text-amber-400 font-mono text-[10px] font-bold">AE/AF LOCKED — TAP TO UNLOCK</Text>
</TouchableOpacity>
)}
{focusPoint && onExposureChange && (
<>
{/* EV value readout (left of the slider) */}
<View pointerEvents="none" className="absolute" style={{ right: 108, top: evThumbY - 12 }}>
<Text className="text-amber-400 font-mono text-xs font-bold bg-black/50 px-1.5 py-0.5 rounded">
EV {(evStops > 0 ? '+' : '') + evStops.toFixed(1)}
</Text>
</View>
{/* Vertical track: drag up = brighter, drag down = darker */}
<View
className="absolute"
style={{ left: vw - 56, top: evTrackTop, width: 40, height: evTrackH }}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onEvSliderGrant}
onResponderMove={onEvSliderMove}
onResponderRelease={onEvSliderRelease}
onResponderTerminate={onEvSliderRelease}
>
<View pointerEvents="none" className="absolute left-1/2 top-0 bottom-0 w-[2px] -ml-px bg-white/20 rounded-full" />
<View
pointerEvents="none"
className="absolute w-8 h-8 rounded-full border-2 border-amber-400 bg-black/60"
style={{ top: evThumbY - evTrackTop - 16, left: 4 }}
/>
</View>
<View pointerEvents="none" className="absolute" style={{ left: vw - 48, top: evTrackTop - 22 }}>
<Text className="text-zinc-400 font-mono text-[10px] text-center w-9">+3</Text>
</View>
<View pointerEvents="none" className="absolute" style={{ left: vw - 48, top: evTrackTop + evTrackH + 6 }}>
<Text className="text-zinc-400 font-mono text-[10px] text-center w-9">-3</Text>
</View>
</>
)}
{renderCameraOverlays()}
</View>
);
}
// Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom.
const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0;
const cinemaOn = cinemaEffect != null && !!cinemaParams;
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
// Photo display rect + draw fit in the plain library viewer: with a ratio
// band the photo is center-cropped (cover) into the composition region;
// otherwise the full photo is contain-fitted to the screen (fill into its
// own aspect rect — no distortion, identical to the old fullscreen contain).
const dispRect = regionBand
? { dx: region.x, dy: region.y, dw: region.w, dh: region.h }
: imageFitRect;
const dispFit: 'cover' | 'fill' = regionBand ? 'cover' : 'fill';
// RETRO POLAROID (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}>
<Rect x={card.x} y={card.y} width={card.w} height={card.h} color="#faf9f6" />
<Group>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
{cinemaOn ? (
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : (
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
)}
</Shader>
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : null}
</Rect>
) : (
<SkiaImage image={skiaImage} fit={polaroidFit} rect={winRect} />
)}
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{/* 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()}
</Canvas>
</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}>
<Rect x={0} y={0} width={vw} height={vh} color="#000000" />
<Group>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
{cinemaOn ? (
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : (
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
)}
</Shader>
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : null}
</Rect>
) : (
<SkiaImage image={skiaImage} fit="cover" rect={winRect} />
)}
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{/* 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()}
</Canvas>
</View>
);
}
return (
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
{libraryImageUri && skiaImage ? (
<>
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={libZoomMatrix}>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
// DR/Highlight/Shadow tone + Cinema seasonal grade, both live
// over the photo. The Rect must cover exactly the contain-fitted
// photo area (not the full canvas): a fullscreen ImageShader
// with fit="contain" clamps/smears the photo edges across the
// black letterbox (user-visible as "the background is a zoomed
// copy of the photo"). Cinema samples the tone output when both
// are on (chain: cinema -> tone -> image), matrix applies after.
<Rect
x={dispRect!.dx}
y={dispRect!.dy}
width={dispRect!.dw}
height={dispRect!.dh}
>
{cinemaOn ? (
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader
image={skiaImage}
fit={dispFit}
tx="clamp"
ty="clamp"
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
/>
</Shader>
) : (
<ImageShader
image={skiaImage}
fit={dispFit}
tx="clamp"
ty="clamp"
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
/>
)}
</Shader>
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader
image={skiaImage}
fit={dispFit}
tx="clamp"
ty="clamp"
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
/>
</Shader>
) : null}
</Rect>
) : dispRect ? (
<SkiaImage
image={skiaImage}
fit={dispFit}
rect={rect(dispRect.dx, dispRect.dy, dispRect.dw, dispRect.dh)}
/>
) : (
<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 composition region
under a ratio band, the full screen otherwise. */}
<Rect x={region.x} y={region.y} width={region.w} height={region.h} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{/* Frame + GPS watermark belong to the photo → zoom with it. Grain
is a fullscreen screen effect → stays fixed above. */}
<Group matrix={libZoomMatrix}>
{renderFrameOverlay()}
{renderGPSWatermark()}
</Group>
</Canvas>
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
onResponderMove={onLibTouchMove}
onResponderRelease={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>
);
}