56c86df1b5
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.
1243 lines
55 KiB
TypeScript
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>
|
|
);
|
|
}
|