// SPDX-License-Identifier: Apache-2.0 /** * useCapture — React hook that encapsulates the camera capture state * machine so host apps get a drop-in replacement for the ad-hoc * vision-camera wiring they used to have inline on each screen. * * Responsibilities: * - Holds the Camera ref for ``takePhoto``. * - Tracks the device permission state and exposes a request helper. * - Manages torch / flash state + a toggle helper. * - Wraps takePhoto with a single-flight guard so a double-tap on * the shutter button doesn't spawn two captures in parallel. * - Runs an optional JS-side quality check on the captured image * before resolving; the host app sees the QualityReport on the * returned CaptureResult. * * Non-goals: * - This hook does NOT persist captures. Host apps hand the * returned CaptureResult to their own storage layer (WatermelonDB * insert, Redux dispatch, whatever). * - Video recording lives in useVideoCapture. * * The public API is designed to be minimal and replaceable: host apps * that prefer the raw vision-camera API can opt out of this hook and * still use the SDK's quality + stitching modules. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform } from 'react-native'; import { Camera, useCameraDevice, useCameraDevices, useCameraPermission, type PhotoFile, type PhysicalCameraDeviceType, type TakePhotoOptions, } from 'react-native-vision-camera'; import { selectCaptureDevice, zoomForLens, type CaptureDeviceMode, type DeviceLike, } from './selectCaptureDevice'; import { runQualityCheck } from '../quality/runQualityCheck'; import { normaliseOrientation } from '../quality/normaliseOrientation'; import { extractPhotoDepth } from '../quality/extractPhotoDepth'; import { toBareFilePath } from '../utils/paths'; import { defaultPhotoFilename, getDefaultCaptureDir, moveFile, } from '../utils/files'; import type { CaptureResult, QualityReport, QualityThresholds, } from '../types'; /** * Hook input. Everything optional; sensible defaults are applied * so simple call-sites can write ``useCapture()`` and get a usable * back-camera pipeline with ``flash=off`` and no quality checking. */ export interface UseCaptureOptions { /** 'back' | 'front' — defaults to 'back' (shelf photos). */ cameraPosition?: 'back' | 'front'; /** Quality check toggle + thresholds. */ enableQualityChecks?: boolean; qualityThresholds?: QualityThresholds; /** * Extra TakePhotoOptions to pass through to vision-camera. * The SDK merges these with its defaults; host-supplied values win. */ takePhotoOptions?: TakePhotoOptions; /** * 2026-05-14 — preferred physical-lens type for the chosen * `cameraPosition`. Maps to vision-camera's `physicalDevices` * filter on `useCameraDevice`. * * undefined (default) — use vision-camera's selection algorithm, * which picks the device that combines * the most lenses (typically the "main" * multi-lens virtual camera). Existing * behaviour; backwards-compatible. * 'wide-angle-camera' — 1× physical lens (the standard rear * camera most users think of as "the * camera"). * 'ultra-wide-angle-camera' — 0.5× ultra-wide lens (only on * devices with one; Samsung A35 has one; * iPhone 11 Pro and later have one). * 'telephoto-camera' — 2× / 3× telephoto if the device has * one. Rare on field-rep deployments; * exposed for symmetry. * * When the preferred type isn't available on the device, the * hook falls back to vision-camera's default selection (i.e., * behaves as if `preferredPhysicalDevice` was undefined). The * returned `availablePhysicalDevices` exposes what the device * actually offers so the host can render an appropriate switcher. * * v0.13.2 — superseded by `lens` for ``'s own use (see * `selectCaptureDevice`). Still honoured for direct Layer-2 hosts. */ preferredPhysicalDevice?: PhysicalCameraDeviceType; /** * v0.13.2 — the active UI lens (`1×` / `0.5×`). When supplied, the * hook uses capability-aware selection (`selectCaptureDevice`): it * prefers a multi-cam device spanning both FOVs (lens switched via * `zoom`, torch available on every lens), and falls back to a * standalone ultra-wide device-swap only where no such multi-cam * device exists. Fixes the "0.5× shows wide-angle on some phones" * and "flash unavailable on 0.5×" bugs. When omitted, the legacy * `preferredPhysicalDevice` path is used (backwards-compatible). */ lens?: '1x' | '0.5x'; /** * iOS: save the still's AVDepthData as a `.depth.bin` sidecar * (float32 metres + JSON header; see `extractPhotoDepth`) and return its * path as `CaptureResult.depthPath`. Requires the mounted `` * to also set `captureDepthData` (that's what turns on depth delivery and * biases the format pick); this option adds the extraction leg, which * MUST run before the orientation re-encode strips the embedded depth. * Works on dual-camera iPhones (stereo disparity) and LiDAR models * (absolute depth). No-op on Android and on depth-less devices — * `depthPath` is simply absent. Default off. */ captureDepthData?: boolean; } /** * Per-call options for `takePhoto`. Separate from `UseCaptureOptions` * (the hook-level config) so callers can vary the destination * filename per capture without re-creating the hook. */ export interface TakePhotoCallOptions { /** * Move the captured JPEG to this fully-resolved path after EXIF * orientation correction. Requires `expo-file-system` in the * host (declared as an OPTIONAL peer — only needed when * `outputPath` is set). Host is responsible for the destination * directory's existence and writability; lib rejects loudly on * disk failure rather than silently falling back to a tmp path. * * Format: bare path (e.g. `/data/.../foo.jpg`) or `file://`-prefixed * URI — both accepted; lib normalises internally. */ outputPath?: string; } /** * Hook output. Intentionally flat so destructuring a subset is * cheap and the API doesn't force callers to drill into nested * objects for common concerns. */ export interface UseCaptureReturn { /** Pass to (or the raw Camera directly). */ cameraRef: React.RefObject; /** The currently selected device — null while vision-camera hasn't picked one. */ device: ReturnType; /** True once the user has granted camera permission. */ hasPermission: boolean; /** Trigger the system permission sheet. Resolves to the new state. */ requestPermission: () => Promise; /** Current flash mode — controlled from host code. */ flash: 'off' | 'on'; toggleFlash: () => void; /** True while takePhoto is in flight. Use to disable the shutter button. */ isCapturing: boolean; /** * Take a photo. Single-flight: parallel calls return the in-flight * promise. Returns a CaptureResult (with an optional QualityReport * when ``enableQualityChecks`` is on). * * `outputPath` (optional): a fully-resolved destination path. When * set, the lib moves the captured JPEG to that path after EXIF * orientation correction, and the returned `compressedUri` points * at the moved file. The host is responsible for ensuring the * destination directory exists and is writable; on disk failure, * the promise rejects with an error referencing `outputPath`. * * Requires `expo-file-system` to be installed in the host app * (declared as an OPTIONAL peer dep — consumers that don't pass * `outputPath` aren't required to have it). */ takePhoto: (options?: TakePhotoCallOptions) => Promise; /** * 2026-05-14 — physical lens types available on the chosen * `cameraPosition`. Computed once at the first vision-camera * device-list emission; useful for the host to decide whether to * render a 0.5×/1× camera switcher chip (only show if both * `wide-angle-camera` AND `ultra-wide-angle-camera` are present). * * Empty array on platforms that haven't enumerated devices yet * (very brief — vision-camera resolves the device list at module * load). Always populated by the time the camera is mountable. */ availablePhysicalDevices: PhysicalCameraDeviceType[]; /** * v0.13.2 — how lenses are switched for the mounted device: * 'multicam' — one device spans both FOVs; switch via `deviceZoom`. * 'standalone-uw' — separate ultra-wide device; switch by remounting. * 'wide-only' — no ultra-wide; no 0.5× chooser. */ captureMode: CaptureDeviceMode; /** * v0.13.2 — whether the device can offer a 0.5× ultra-wide lens AT ALL * (real capability, replacing the old hardcoded assumption). Drives * whether `` renders the lens chooser. */ has0_5x: boolean; /** * The device's real ultra-wide factor for the lens chip's LABEL (0.6 on a * Galaxy S24 Ultra, 0.5 on a typical iPhone); null when the platform never * advertises it, so the UI keeps its historical `0.5×` text. See * `CaptureDeviceSelection.ultraWideFactor`. */ ultraWideFactor: number | null; /** * v0.13.2 — whether the currently-MOUNTED device has a torch. Drives * the flash control's availability (the standalone ultra-wide has none). */ deviceHasTorch: boolean; /** * v0.13.2 — the `zoom` value to apply for the active lens in * `multicam` mode (0.5× → ultra-wide end, 1× → wide baseline). * `undefined` in standalone/wide-only modes (lens = device identity, * no zoom needed). Pass to ``. */ deviceZoom: number | undefined; } function makeCaptureResult( photo: PhotoFile, qualityReport: QualityReport | undefined, depthPath?: string, depthUnavailableReason?: string, ): CaptureResult { const capturedAt = new Date().toISOString(); return { ...(depthPath ? { depthPath } : {}), ...(depthUnavailableReason ? { depthUnavailableReason } : {}), // The device UUID the host wants to identify this capture with is // app-specific. We synthesise a deterministic ish value so the // host gets a placeholder; most hosts will swap it out for a uuid // library (react-native-uuid or similar) before persisting. deviceUuid: `${capturedAt}-${photo.path.split('/').pop() ?? 'photo'}`, compressedUri: `file://${photo.path}`, // vision-camera reports width/height post-orientation-correction, // matching what `` renders. Forwarding them lets the // SDK's thumbnail strip / preview modal lay out at the correct // aspect ratio instead of forcing square crops. width: photo.width, height: photo.height, isStitched: false, capturedAt, qualityReport, deviceMetadata: { platform: 'ios', osVersion: '', deviceModel: '', cameraId: '', flashEnabled: false, }, }; } export function useCapture(options: UseCaptureOptions = {}): UseCaptureReturn { const { cameraPosition = 'back', enableQualityChecks = false, qualityThresholds, takePhotoOptions, preferredPhysicalDevice, lens, captureDepthData = false, } = options; const cameraRef = useRef(null); const allDevices = useCameraDevices(); // v0.13.2 — capability-aware selection (`lens` supplied) vs legacy // per-lens physical-device swap (`preferredPhysicalDevice`). // // Capability-aware: `selectCaptureDevice` prefers a multi-cam device // that spans wide + ultra-wide (so 0.5× is reached via `zoom` and the // torch works on every lens), falling back to a standalone ultra-wide // device-swap only where the platform has no such multi-cam grouping. // This fixes (a) 0.5× silently showing the wide-angle FOV on phones // where the ultra-wide is only inside a multi-cam device, and (b) // flash being unavailable on the torchless standalone ultra-wide. const selection = useMemo( () => selectCaptureDevice(allDevices as unknown as DeviceLike[], { // captureDepthData: AVDepthData needs a multi-lens mount — prefer // a depth-capable virtual device as the 1× primary (iOS only; see // SelectCaptureDeviceOptions.preferDepth for the wide+tele FOV // trade). Without the opt-in the plain-wide pick is unchanged. preferDepth: captureDepthData === true && Platform.OS === 'ios', // Galaxy S24 Ultra field finding (SCG26, 2026-07-27): on Android, // don't trust a multicam device's zoom-reach claim to the // ultra-wide when a real standalone ultra-wide id exists — see // SelectCaptureDeviceOptions.platform. platform: Platform.OS, }), [allDevices, captureDepthData], ); // Legacy path (no `lens`): preserve the pre-v0.13.2 per-physical-lens // request so direct Layer-2 hosts that pass `preferredPhysicalDevice` // are unaffected. const legacyDevice = useCameraDevice(cameraPosition, { physicalDevices: preferredPhysicalDevice ? [preferredPhysicalDevice] : undefined, }); const legacyFallback = useCameraDevice(cameraPosition); // The mounted device: // - lens supplied + multicam → the single multi-cam device // (lens switched via `zoom`, computed below). // - lens supplied + standalone-uw→ swap to the ultra-wide device on // 0.5×, else the wide primary (matches the legacy swap, but with // correct device identity from `selectCaptureDevice`). // - lens supplied + wide-only → the wide device (0.5× hidden). // - no lens → legacy behaviour. let device: ReturnType; let activeZoom: number | undefined; if (lens != null) { if (selection.mode === 'standalone-uw' && lens === '0.5x') { device = (selection.ultraWideDevice as typeof legacyDevice) ?? selection.device as typeof legacyDevice ?? legacyFallback; } else { device = (selection.device as typeof legacyDevice) ?? legacyFallback; } activeZoom = selection.mode === 'multicam' && selection.device ? zoomForLens(selection.device, lens) : undefined; } else { device = legacyDevice ?? legacyFallback; activeZoom = undefined; } // v0.15 diagnostic (dev-only) — for the "0.5× pill shows but tapping // doesn't switch the camera" report on Android (Samsung). Logs the // resolved capture mode + the mounted device's zoom range so logcat // reveals whether `minZoom` actually reaches the ultra-wide. On // Camera2 the logical multi-camera's zoom range usually starts at 1.0 // (the ultra-wide is a separate physical id, not a zoom target), so a // zoom-based 0.5× switch is a silent no-op. useEffect(() => { if (!__DEV__) return; const summarise = (d: DeviceLike | null) => d ? { id: d.id, physical: d.physicalDevices, isMultiCam: d.isMultiCam, minZoom: d.minZoom, neutralZoom: d.neutralZoom, maxZoom: d.maxZoom, hasTorch: d.hasTorch, } : null; const back = (allDevices as unknown as DeviceLike[]).filter( (d) => d.position === 'back', ); // eslint-disable-next-line no-console console.log( '[rnimagestitcher] lens-select ' + JSON.stringify({ lens: lens ?? null, mode: selection.mode, has0_5x: selection.has0_5x, ultraWideFactor: selection.ultraWideFactor, activeZoom: activeZoom ?? null, selected: summarise(selection.device), ultraWide: summarise(selection.ultraWideDevice), // Full back-camera enumeration — reveals whether a multicam // device merely *lists* the ultra-wide while its zoom range // can't reach it (minZoom ~1.0), and whether a STANDALONE // ultra-wide device exists for the standalone-uw fallback. allBack: back.map(summarise), }), ); }, [allDevices, selection, lens, activeZoom]); // Enumerate ALL physical lens types available on the chosen // position so the host can decide whether to render a switcher. // Vision-camera's `useCameraDevices()` returns CameraDevice[]; each // has `physicalDevices: PhysicalCameraDeviceType[]`. We dedupe the // union across all devices at `position` so the host sees the full // set the platform exposes (some phones expose ultra-wide only via // a separate logical camera, not the main one). `allDevices` is // computed once above (shared with `selectCaptureDevice`). const availablePhysicalDevices = useMemo(() => { const seen = new Set(); for (const d of allDevices) { if (d.position !== cameraPosition) continue; for (const pd of d.physicalDevices ?? []) { seen.add(pd); } } return Array.from(seen); }, [allDevices, cameraPosition]); const { hasPermission, requestPermission } = useCameraPermission(); const [flash, setFlash] = useState<'off' | 'on'>('off'); const [isCapturing, setIsCapturing] = useState(false); // Holds the in-flight takePhoto promise so we don't kick off a second // call while the first is still settling. Cleared in the finally. const inFlightRef = useRef | null>(null); const toggleFlash = useCallback(() => { setFlash((prev) => (prev === 'off' ? 'on' : 'off')); }, []); const takePhoto = useCallback(async (callOptions?: TakePhotoCallOptions): Promise => { if (inFlightRef.current) { return inFlightRef.current; } if (!cameraRef.current) { throw new Error( 'useCapture.takePhoto: cameraRef is not yet attached. ' + 'Render or the raw Camera with this ref first.', ); } const promise = (async () => { setIsCapturing(true); try { const photo = await cameraRef.current!.takePhoto({ flash, ...takePhotoOptions, }); // iOS depth sidecar — MUST run BEFORE normaliseOrientation below: // the OpenCV re-encode strips the auxiliary depth image embedded by // vision-camera's `enableDepthData`, so this is the only window to // save it. Failure/absence never blocks the capture — the sidecar // is an advisory extra (`depthPath` simply stays unset). let depthTmpPath: string | undefined; let depthUnavailableReason: string | undefined; if (captureDepthData && Platform.OS === 'ios') { const depth = await extractPhotoDepth( photo.path, `${photo.path}.depth.bin`, ); if (depth?.found) { depthTmpPath = depth.sidecarPath ?? `${photo.path}.depth.bin`; } else { // Surface WHY on the result (`depthUnavailableReason`): the // silent-omission form left consumers staring at an absent // `depthPath` with zero signal outside a dev console. // `native-module-missing` = the JS is newer than the installed // binary (extractPhotoDepth resolved null). // 0.5× gets its own slug: stereo depth exists only in the lens // pair's OVERLAP (= the narrower lens's FOV), so no current // hardware can cover an ultra-wide capture — a physics // limitation, not a per-device failure. depthUnavailableReason = lens === '0.5x' ? 'ultra-wide-no-depth' : depth ? depth.reason ?? 'no-depth-aux' : 'native-module-missing'; if (__DEV__) { // eslint-disable-next-line no-console console.log( '[rnimagestitcher] captureDepthData: no depth in this capture ' + `(${depthUnavailableReason}) — is the mounted device ` + 'depth-capable (dual-camera / LiDAR)?', ); } } } // Bake EXIF rotation into pixels so the file on disk matches // what the operator just saw on the preview, regardless of // how downstream consumers handle EXIF. Returns the // post-rotation dimensions; we override the photo's // width/height before constructing the CaptureResult so // the SDK contract reports "what's actually saved". const normalised = await normaliseOrientation(photo.path, { width: photo.width, height: photo.height, }); let orientedPhoto: PhotoFile = { ...photo, width: normalised.width || photo.width, height: normalised.height || photo.height, }; // Move the orientation-corrected file to its final location. // If the caller passed `outputPath`, use that. Otherwise, the // lib publishes captures into its canonical default dir so // returned paths are predictable across consumers (vs. // vision-camera's auto-generated UUID-named tmp file). The // move is performed via the `RNImageStitcherFileUtils` native // bridge — no peer-dep on `expo-file-system` etc. let depthPath: string | undefined; try { const dstPath = callOptions?.outputPath ? toBareFilePath(callOptions.outputPath) : `${await getDefaultCaptureDir()}/${defaultPhotoFilename()}`; await moveFile(orientedPhoto.path, dstPath); orientedPhoto = { ...orientedPhoto, path: dstPath }; // Keep the depth sidecar NEXT TO the photo (`.depth.bin`) // wherever it lands. A sidecar move failure only drops the depth // (advisory), never the capture. if (depthTmpPath) { const sidecarDst = `${dstPath}.depth.bin`; try { await moveFile(depthTmpPath, sidecarDst); depthPath = sidecarDst; } catch (sidecarErr) { // eslint-disable-next-line no-console console.warn( '[rnimagestitcher] captureDepthData: failed to move the depth ' + `sidecar to ${sidecarDst}; continuing without depth.`, sidecarErr, ); } } } catch (e) { throw new Error( 'useCapture.takePhoto: failed to move captured photo to its ' + `destination${callOptions?.outputPath ? ` (${callOptions.outputPath})` : ' (default capture dir)'}. ` + `Underlying: ${e instanceof Error ? e.message : String(e)}`, ); } let report: QualityReport | undefined; if (enableQualityChecks && qualityThresholds) { report = await runQualityCheck(orientedPhoto.path, qualityThresholds); } return makeCaptureResult( orientedPhoto, report, depthPath, depthUnavailableReason, ); } finally { setIsCapturing(false); inFlightRef.current = null; } })(); inFlightRef.current = promise; return promise; }, [flash, enableQualityChecks, qualityThresholds, takePhotoOptions, captureDepthData, lens]); return { cameraRef, device, hasPermission, requestPermission, flash, toggleFlash, isCapturing, takePhoto, availablePhysicalDevices, captureMode: selection.mode, has0_5x: selection.has0_5x, ultraWideFactor: selection.ultraWideFactor, deviceHasTorch: device?.hasTorch ?? false, deviceZoom: activeZoom, }; }