// SPDX-License-Identifier: Apache-2.0
/**
* Camera — the public, props-based camera component for the
* `react-native-image-stitcher` library (publication target per the
* 2026-05-15 design doc).
*
* One component, both modes:
* - **Tap shutter** → single photo via vision-camera's takePhoto
* (non-AR) or ARFrame.capturedImage (AR).
* - **Hold shutter** → panorama capture; pan-and-release produces
* a stitched panorama JPEG via the incremental stitcher.
*
* One component, both capture sources:
* - **AR mode** (ARKit / ARCore) — used for pose-aware stitching
* when the device supports it.
* - **Non-AR mode** (vision-camera + IMU) — fallback path,
* forced when the 0.5× ultra-wide lens is selected (AR sessions
* are tied to a single physical lens; can't switch mid-session).
*
* The Camera component owns its runtime state (arPreference, lens,
* settings). Parent props are read as INITIAL VALUES at mount; the
* parent listens for state changes via the callback props. This
* "uncontrolled" model matches React's `` convention and
* matches the design doc's intent (NF — component owns runtime state,
* parent persists via callbacks if desired).
*
* Scope note (step 2 of the SDK extract plan):
* - Props-driven API for both photo + panorama modes — DONE here.
* - Lens chip + AR toggle UI (U1) — DONE here.
* - `showSettingsButton` gates the existing PanoramaSettingsModal — DONE.
* - Imperative ref methods (`takePhoto()`, `startPanorama()`,
* `stopPanorama()`) — deferred; the built-in shutter button is the
* primary affordance for v0.1.0.
* - Forward-looking props (`defaultCompositingResolMP`,
* `defaultRegistrationResolMP`, `defaultSeamEstimationResolMP`)
* are accepted but currently no-ops — those fields don't exist on
* PanoramaSettings yet. They're declared so the public API is
* stable before they wire through; the wiring is a follow-up.
*
* See: docs/site-content/design/2026-05-15-react-native-image-stitcher-publication.md
*/
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import {
AppState,
NativeModules,
Platform,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type {
Camera as VisionCamera,
DrawableFrameProcessor,
ReadonlyFrameProcessor,
} from 'react-native-vision-camera';
import { useARSession } from '../ar/useARSession';
import type { CameraFrameProcessor } from '../stitching/CameraFrame';
import type { ARFrameMeta, ARPluginResult } from '../stitching/ARFrameMeta';
import type { AROverlay } from '../stitching/AROverlay';
import type { AROverlayMethods } from './arOverlayController';
import { ARCameraView, type ARCameraViewHandle } from './ARCameraView';
import { CameraShutter } from './CameraShutter';
import { CameraView } from './CameraView';
import { CaptureHeader, type CaptureHeaderProps } from './CaptureHeader';
import { CapturePreview, type CapturePreviewAction } from './CapturePreview';
import {
CaptureThumbnailStrip,
type CaptureThumbnailItem,
} from './CaptureThumbnailStrip';
import { CaptureStatusOverlay, type CaptureStatusPhase } from './CaptureStatusOverlay';
import { classifyStitchError } from './classifyStitchError';
import { CaptureDebugOverlay } from './CaptureDebugOverlay';
import { CaptureMemoryPill } from './CaptureMemoryPill';
import { CaptureKeyframePill } from './CaptureKeyframePill';
import { CaptureOrientationPill } from './CaptureOrientationPill';
import { CaptureStitchStatsToast, useStitchStatsToast } from './CaptureStitchStatsToast';
import { PanoramaBandOverlay } from './PanoramaBandOverlay';
import { type PanoramaSettings, DEFAULT_FLOW_GATE_SETTINGS } from './PanoramaSettings';
import { panoramaSettingsToNativeConfig } from './PanoramaSettingsBridge';
import { PanoramaSettingsModal } from './PanoramaSettingsModal';
import {
buildPanoramaInitialSettings,
type PanoramaPropOverrides,
} from './buildPanoramaInitialSettings';
import { isLowMemDevice } from './lowMemDevice';
import { useCapture } from './useCapture';
import { shouldOfferNativeUltraWide } from './nativeUltraWide';
import { useDeviceOrientation, type DeviceOrientation } from './useDeviceOrientation';
import {
contentRotationDeg,
HostJsLandscapeContext,
type ContentRotationStyle,
} from './useContentRotation';
import { useOrientationDrift } from './useOrientationDrift';
import { OrientationDriftModal } from './OrientationDriftModal';
// ── Panorama GUIDANCE building blocks (feature/pano-ux-guidance) ─────
// Pure decision helpers + sensor hook + presentational surfaces for the
// first-time-user pan-capture guidance (items 1–7). All read directly
// from the new props below, NOT threaded through PanoramaSettings.
import {
shouldGateForPanMode,
gateTargetOrientation,
type PanMode,
} from './panModeGate';
import { cameraTransitionAction } from './cameraTransitionGate';
import { countdownSecondsFrom } from './captureCountdown';
import {
usePanMotion,
DEFAULT_LATERAL_BUDGET_CM,
DEFAULT_LATERAL_MOTION_MODEL,
} from './usePanMotion';
import type { LateralMotionModel } from './usePanMotion';
/** Dwell before the AR drift stop latches, ms — matches the IMU triggers. */
const AR_LATERAL_GRACE_MS = 500;
import {
_freshArDriftState, _advanceArDrift, _resetArDriftState,
DEFAULT_AR_LATERAL_BUDGET_CM, DEFAULT_AR_LATERAL_ROT_DEG,
DEFAULT_AR_LATERAL_RATIO, DEFAULT_AR_LATERAL_MAX_CM,
} from './arLateralDrift';
import type { Quad } from './cropGeometry';
import {
mergeGuidanceCopy,
captureWarningCopyFrom,
lateralStopCopyFor,
type GuidanceCopy,
} from './cameraGuidanceCopy';
import {
lateralStopOutcome as classifyLateralStop,
MIN_STITCHABLE_KEYFRAMES,
type LateralStopOutcome,
} from './lateralStopPolicy';
import {
lateralPopupShouldShow,
reviewSurfaceShouldShow,
} from './modalPresentation';
import { RotateToLandscapePrompt } from './RotateToLandscapePrompt';
import { PanHowToOverlay } from './PanHowToOverlay';
import { CaptureCountdownOverlay } from './CaptureCountdownOverlay';
import { CaptureFrameCounterOverlay } from './CaptureFrameCounterOverlay';
import { LateralMotionModal } from './LateralMotionModal';
import { RectCropPreview, type ImageRect } from './RectCropPreview';
import { buildStitchDebugInfo } from './stitchDebugInfo';
import { cropQuad } from '../stitching/cropQuad';
import { computeInscribedRect } from '../stitching/computeInscribedRect';
import {
buildCaptureWarnings,
type CaptureWarning,
} from './captureWarnings';
import {
getIncrementalNativeModule,
incrementalStitcherIsAvailable,
} from '../stitching/incremental';
import { useFrameProcessorDriver } from '../stitching/useFrameProcessorDriver';
import { useIncrementalStitcher } from '../stitching/useIncrementalStitcher';
import { useIMUTranslationGate } from '../sensors/useIMUTranslationGate';
import { toBareFilePath, toFileUri } from '../utils/paths';
import { normaliseOrientation } from '../quality/normaliseOrientation';
import {
defaultPanoramaFilename,
defaultPhotoFilename,
getDefaultCaptureDir,
moveFile,
} from '../utils/files';
// ─── Types ──────────────────────────────────────────────────────────
export type CaptureSource = 'ar' | 'non-ar';
/**
* v0.13.2 — which capture sources the host ALLOWS. A constraint on top
* of `defaultCaptureSource` (which picks the initial source within this
* constraint):
* 'both' — AR and non-AR both available; AR toggle is shown.
* 'ar' — AR only; AR toggle hidden (nothing to switch to), and the
* 0.5× lens chooser is hidden (ARKit/ARCore don't expose the
* ultra-wide).
* 'non-ar' — non-AR only; AR toggle hidden.
*/
export type CaptureSourcesMode = 'ar' | 'non-ar' | 'both';
export type CameraLens = '1x' | '0.5x';
export type StitchMode = 'auto' | 'panorama' | 'scans';
export type Blender = 'multiband' | 'feather';
export type SeamFinder = 'graphcut' | 'skip';
export type Warper = 'plane' | 'cylindrical' | 'spherical';
/**
* Result emitted via `onCapture`. Discriminated union keyed FIRST on
* `ok` (success vs. failure) and then on `type` (photo vs. panorama), so a
* host handles EVERY capture outcome — success, degraded success, and
* failure — through this one callback.
*
* ## v0.16 — unified success/failure + warnings (BREAKING)
*
* Previously `onCapture` fired only on success and carried no `ok` field;
* failures went *solely* to `onError`. Hosts therefore had no single place
* to learn whether a capture succeeded, and no programmatic signal that a
* stitch was *degraded* (e.g. most frames dropped). Now:
*
* - `onCapture` ALWAYS fires once per capture attempt, with `ok:true`
* (output present) or `ok:false` (carrying the `CameraError`).
* - both success and failure carry `warnings: CaptureWarning[]` — non-fatal
* quality signals (e.g. `LOW_FRAME_UTILIZATION` when <70 % of captured
* frames survived, `LATERAL_DRIFT_FINALIZE` when item-6 stopped early).
* - `onError` STILL fires on failure too (an unchanged mirror), so existing
* error handling keeps working.
*
* Migration: gate on `ok` before reading `uri`/`width`/`height` —
* `if (!result.ok) { handle(result.error); return; }`.
*
* Identifier `CameraCaptureResult` (vs. the SDK's existing `CaptureResult`
* from `../types`) is intentional — the existing CaptureResult shape has
* SDK-specific fields that don't belong in the public RN library's surface.
*/
export type CameraCaptureResult =
| {
ok: true;
type: 'photo';
uri: string;
width: number;
height: number;
/**
* iOS `captureDepthData` (NON-AR captures only) — path of the
* `.depth.bin` depth sidecar saved next to the photo
* (float32 metres row-major + JSON header with dims/intrinsics;
* format spec in `website/docs/photo-depth.md`). Absent on
* Android, in AR capture, on depth-less devices/formats, and
* whenever the opt-in is off.
*/
depthPath?: string;
/**
* WHY `depthPath` is absent although `captureDepthData` was
* requested (iOS non-AR): the extractor's reason slug
* (`no-depth-aux` = no auxiliary depth in the capture — typically
* a non-depth-capable mounted device; `native-module-missing` =
* JS newer than the installed binary). Diagnostic only.
*/
depthUnavailableReason?: string;
/** Non-fatal quality signals (empty when none). */
warnings: CaptureWarning[];
}
| {
ok: true;
type: 'panorama';
uri: string;
width: number;
height: number;
framesRequested: number;
framesIncluded: number;
framesDropped: number;
finalConfidenceThresh: number;
durationMs: number;
/**
* 2026-05-22 (audit F2g) — which cv::Stitcher pipeline the
* batch finalize ran (after auto-resolution if applicable).
* Useful for displaying a "Stitched as: scans" pill on the
* output preview. Undefined when the engine wasn't
* batch-keyframe (hybrid / slit-scan don't go through
* cv::Stitcher at finalize).
*/
stitchModeResolved?: 'panorama' | 'scans';
/**
* 2026-06-15 (DEV) — gyro rotation magnitude of the capture, in radians.
* Shown on the dev preview so the panorama-vs-SCANS rotation threshold can
* be tuned. `0` = no pose-derived rotation signal (non-AR with no poses).
*/
rRadians?: number;
/**
* 2026-06-16 (DEV) — translation magnitude (m) + auto decision ratio
* (`>=0.55` → SCANS) that drove panorama-vs-SCANS. Shown on the dev
* readout alongside `rRadians` to tune the threshold from real captures.
*/
tMeters?: number;
decisionRatio?: number;
/**
* 2026-06-14 (DEV overlay) — semicolon-separated `key=value` trace of the
* stitcher's runtime choices (pipe/warp/route/seam/blend) for this
* output. Shown on the preview in __DEV__. iOS only for now.
*/
debugSummary?: string;
/**
* 2026-06-15 (iOS) — keyframe JPEG paths used for this stitch, so the
* preview can re-stitch them on demand via `refinePanorama` (the
* high-level tab). iOS only; undefined elsewhere.
*/
keyframePaths?: string[];
/**
* 2026-06-15 (iOS) — orientation this stitch baked in. The on-demand
* high-level re-stitch passes it back so it matches the manual output's
* rotation (not the raw sensor landscape). iOS only.
*/
captureOrientation?: string;
/** Non-fatal quality signals (empty when none). */
warnings: CaptureWarning[];
}
| {
ok: false;
/** Which capture path failed. */
type: 'photo' | 'panorama';
/** The classified failure (same object handed to `onError`). */
error: CameraError;
/** Any warnings gathered before the failure (usually empty). */
warnings: CaptureWarning[];
};
/**
* The success-panorama variant of {@link CameraCaptureResult} — the exact
* shape stashed for the crop editor and re-emitted (with adjusted dims) once
* the user crops. Narrowed so the crop-confirm spread keeps `uri`/`width`/
* `height`/`ok` without a cast.
*/
export type PanoramaCaptureResult = Extract<
CameraCaptureResult,
{ ok: true; type: 'panorama' }
>;
/**
* Errors surfaced via `onError`. Classified codes so consumers can
* branch on the kind of failure (toast vs retry vs report).
*/
export type CameraErrorCode =
| 'CAMERA_PERMISSION_DENIED'
| 'CAMERA_DEVICE_UNAVAILABLE'
| 'PHOTO_CAPTURE_FAILED'
| 'PANORAMA_START_FAILED'
| 'PANORAMA_FINALIZE_FAILED'
| 'STITCH_NEED_MORE_IMGS'
| 'STITCH_HOMOGRAPHY_FAIL'
| 'STITCH_CAMERA_PARAMS_FAIL'
/**
* v0.16 — the native post-stitch validator rejected the output: the
* panorama came out disjoint / fragmented / wildly mis-proportioned
* (frames didn't connect into one coherent image). Recoverable by
* re-capturing, so it carries "try again" copy.
*/
| 'STITCH_LOW_QUALITY'
| 'STITCH_OOM'
| 'OUTPUT_WRITE_FAILED'
/**
* Vision-camera surfaced a runtime error that isn't a known
* transient lifecycle event (those are swallowed inside the SDK's
* ``). Examples that DO reach the host as this code:
* `format/invalid-format`, `capture/recording-canceled`,
* `device/microphone-permission-denied`, ... The full error
* object is on `.cause` for inspection.
*/
| 'VISION_CAMERA_RUNTIME'
| 'UNKNOWN';
export class CameraError extends Error {
public readonly code: CameraErrorCode;
public readonly cause?: unknown;
constructor(code: CameraErrorCode, message: string, cause?: unknown) {
super(message);
this.code = code;
this.cause = cause;
this.name = 'CameraError';
}
}
/**
* Frames-dropped info delivered via `onFramesDropped`. Fires once
* per panorama capture if the native stitch retry (the flattened
* 4-rung mode/threshold ladder since 2026-08-17) promoted a result
* that dropped one or more input frames.
*/
export interface FramesDroppedInfo {
requested: number;
included: number;
}
/**
* Camera component props. See the design doc's "Component API"
* section for the full rationale per field.
*/
export interface CameraProps {
// ── Initial values (uncontrolled — read once at mount) ────────────
/**
* Initial capture source. Default `'non-ar'`.
*
* `'ar'` feeds the engine natively from the ARKit/ARCore session, so
* it does NOT depend on the vision-camera frame-processor chain that
* non-AR capture requires (see the "Frame processors" section of the
* host-integration docs). If your fleet is AR-capable, opting in with
* `defaultCaptureSource="ar"` — or locking it with `captureSources="ar"`,
* which also hides the runtime AR toggle — avoids that whole class of
* build-time integration failure.
*
* Caveats when choosing AR: on Android, devices without "Google Play
* Services for AR" installed are prompted to install it, and a declined
* install currently leaves the AR preview blank (no automatic downgrade
* — fixed in a later release); AR tap-photos also come from the AR video
* stream rather than the full-resolution still pipeline, the flash is
* unavailable, and the iOS depth sidecar is not produced.
*
* Devices without AR support (and the 0.5× lens) always resolve to
* non-AR regardless of this value.
*/
defaultCaptureSource?: CaptureSource;
defaultLens?: CameraLens;
defaultStitchMode?: StitchMode;
defaultBlender?: Blender;
defaultWarper?: Warper;
defaultFlowNoveltyPercentile?: number;
defaultFlowEvalEveryNFrames?: number;
defaultFlowMaxTranslationCm?: number;
defaultKeyframeMaxCount?: number;
defaultKeyframeOverlapThreshold?: number;
/** Time-budget force-accept (ms) for the keyframe gate — accept a
* keyframe at least this often during a pan even if novelty is low,
* so slow / static pans don't leave temporal gaps. `0` disables it.
* Default 2000 (2 s). Applies to both AR and non-AR captures. */
defaultMaxKeyframeIntervalMs?: number;
/** Forward-looking — wires through to cv::Stitcher's compositingResol
* once PanoramaSettings exposes the field (currently a no-op). */
defaultCompositingResolMP?: number;
/** Forward-looking — see above. */
defaultRegistrationResolMP?: number;
/** Forward-looking — see above. */
defaultSeamEstimationResolMP?: number;
/**
* v0.16 — the stitch RECIPE as a JSON object (`stitchMode` / `warperType` /
* `blenderType` / `enableMaxInscribedRectCrop` / `debugPack`). Partial; wins
* over the flat `default*` props. v0.24 — the speed levers moved to {@link
* perf}. */
stitcher?: PanoramaPropOverrides['stitcher'];
/**
* v0.16 — the keyframe GATE as a JSON object (`mode` / `maxKeyframes` /
* `overlapThreshold` / `maxKeyframeIntervalMs` / `flow`). Partial; `flow` is
* deep-merged. v0.24 — the anti-blur controls moved to {@link blur}. */
frameSelection?: PanoramaPropOverrides['frameSelection'];
/**
* v0.24 — **anti-blur** controls in one group: `sharpnessWindow` (pick-
* sharpest-of-K) + exposure cap + motion gate + sharpness floor + hi-fps
* format. Deep-merged over the SDK defaults (all ON). Set a knob to 0 /
* false (or `sharpnessWindow: 1`) to disable it. */
blur?: PanoramaPropOverrides['blur'];
/**
* v0.24 — **perf** (stitch-speed) levers in one group: `seamFinderType`
* (default 'voronoi'), `rangeMatcherWidth` (3), `numThreads` (0 = multi),
* `adaptiveStitchMode` ('measured') + its `adaptiveMinOutputMP` /
* `adaptiveSlowStitchMsPerFrame`. Wins over `stitcher`. */
perf?: PanoramaPropOverrides['perf'];
// ── Inscribed-rect crop (v0.15) ───────────────────────────────────
/**
* Crop strategy for the stitched panorama. `false` (default) keeps the
* bounding-rect of non-black pixels, which preserves all stitched
* content but may leave black corners. `true` crops to the maximum
* axis-aligned rectangle inscribed in the coverage mask — clean edges,
* no black corners (slightly more CPU at finalize) — but it can shrink
* the output substantially on lopsided / ultra-wide masks, which is why
* it's opt-in.
*
* Implemented as a start-time stitcher config (like the other
* stitcher settings), so this value is read once at mount to seed the
* initial setting; the in-app settings modal can override it at
* runtime. It changes image geometry (the crop), not encoding.
*
* Since the default is `false`, only pass this prop to opt in:
* @example
* // Crop to a clean inscribed rectangle (no black corners):
*
*/
maxInscribedRectCrop?: boolean;
// ── UI knobs ──────────────────────────────────────────────────────
/**
* Default `true`. Set `false` to disable single-TAP photo capture
* entirely (`handleTap` no-ops, same as `shutterDisabled`) and hide the
* native-0.5× external-camera fallback (`offerNativeUW`) — that
* fallback captures ONE still via the OS camera, the same shape of
* action this flag disables.
*
* PANO-ONLY RECIPE: `enablePhotoMode={false}` with `enablePanoramaMode`
* left at its default `true` is already a pano-only `` — tap is
* disabled, hold-to-pan still fires a capture. No separate flag needed.
* Lens switching (the 0.5×/1× chip) is unaffected either way — it
* selects which device the eventual hold-to-pan uses, it does not
* itself capture.
*/
enablePhotoMode?: boolean;
enablePanoramaMode?: boolean;
/**
* Hide the built-in shutter + AR-toggle so a HOST can render its own capture
* controls and drive capture through the imperative handle
* ({@link CameraHandle.takePhoto} / {@link CameraHandle.startPanorama} /
* {@link CameraHandle.stopPanorama}). The lens chip is KEPT — it is a lens
* selector, not a capture control, and the whole point is that the library
* still owns lens selection. Default `false` (built-in shutter shown, every
* existing consumer unchanged).
*/
hideBuiltInShutter?: boolean;
/**
* When a device offers only ONE usable lens (no in-app ultra-wide and no
* native-0.5× fallback on offer), render NOTHING instead of a static "1×"
* label — there is nothing to switch, so the chip is noise. Default `false`
* keeps the "1×" label for back-compat.
*/
hideLensChipWhenSingle?: boolean;
/**
* Lift the bottom control cluster (lens chip + built-in shutter, if shown) by
* this many px, so a host chrome docked below the preview (e.g. a mode
* switcher) doesn't overlap it. Default `0`. Layout-only; no behaviour change.
*/
bottomBarOffset?: number;
showSettingsButton?: boolean;
/**
* v0.13.2 — which capture sources the host allows (default `'both'`).
* Constrains both the runtime AR toggle and `defaultCaptureSource`:
* - `'both'` : AR + non-AR; the AR toggle is shown so the user can
* switch at runtime.
* - `'ar'` : AR only. AR toggle hidden (nothing to toggle); the
* 0.5× lens chooser is also hidden (ARKit/ARCore can't use the
* ultra-wide), so the camera stays on the AR-capable 1× lens.
* - `'non-ar'`: non-AR only. AR toggle hidden.
* When set to a single source, that source wins regardless of
* `defaultCaptureSource`.
*/
captureSources?: CaptureSourcesMode;
style?: StyleProp;
/**
* Which stitcher engine to drive. Only `'batch-keyframe'` is
* supported (and the default): it collects accepted keyframe JPEGs
* during the hold-pan-release capture and runs the stitch once at
* finalize. The live engines (hybrid / slit-scan / firstwins) were
* archived in the batch-keyframe cleanup — see `archive/`.
*/
engine?: 'batch-keyframe';
/**
* Optional destination directory for captures. When set, the lib
* lands tap-photos at `${outputDir}/photo-${ts}.jpg` and panoramas
* at `${outputDir}/panorama-${ts}.jpg` and the returned uri points
* at the persisted file (vs. vision-camera's tmp dir, which is
* what you get when this prop is omitted).
*
* The host is solely responsible for:
* - Choosing a writable directory (the lib does NOT pick this for
* you on either platform — particularly relevant on Android,
* where scoped-storage rules differ between app-private storage
* and user-visible Documents/Pictures dirs).
* - Ensuring the directory exists. The lib will create it if it
* doesn't, but only inside paths the OS lets it write to.
* - Making the path user-visible if that matters (`UIFileSharingEnabled`
* on iOS for `FileSystem.documentDirectory`; MediaStore /
* `Documents/...` on Android — see your platform's docs).
*
* On disk failure the capture promise rejects via `onError` with
* `CameraError('OUTPUT_WRITE_FAILED', ...)`. No silent fallback to
* tmp — that hides bugs.
*
* Requires `expo-file-system` (declared as an OPTIONAL peer dep;
* only needed when this prop is set).
*
* Format: bare path or `file://` URI. Both accepted.
*/
outputDir?: string;
/**
* Disable the shutter — taps + holds are ignored and the button paints in
* its disabled visual. The host drives this for capture-gating use cases:
* e.g. a document scanner that only allows capture once the document fills
* the framing guide, or a fixture flow that has reached its max photo count.
* Independent of the SDK's own stitching-in-progress disable. Default
* `false`.
*/
shutterDisabled?: boolean;
// ── Callbacks ─────────────────────────────────────────────────────
onCapture?: (result: CameraCaptureResult) => void;
onCaptureSourceChange?: (source: CaptureSource) => void;
onLensChange?: (lens: CameraLens) => void;
onFramesDropped?: (info: FramesDroppedInfo) => void;
onError?: (err: CameraError) => void;
/**
* v0.12.0 — fires when the SDK auto-abandons an in-progress
* capture without producing output. `reason` is a string union
* so future reasons (network loss, low memory, etc.) can be added
* without breaking the callback signature.
*
* Currently the only reason in v0.12 is `'orientation-drift'`:
* the user rotated the device between Mode A (landscape + vertical
* pan) and Mode B (portrait + horizontal pan) mid-capture. The
* engine docstring at `incremental.ts:373-403` is explicit that
* cross-mode capture is "best-effort, not supported," so the SDK
* decisively cancels the capture (`incremental.cancel()`) and
* surfaces `OrientationDriftModal` to explain what happened.
*
* v0.16 adds `'lateral-drift'`: the user moved the phone perpendicular to
* the pan arrow before enough frames were captured to stitch. Rather than
* finalize into a misleading "need more images" error, the SDK abandons the
* capture and surfaces the `LateralMotionModal` with "follow the arrow"
* copy. (A lateral drift AFTER enough frames still finalizes what was
* captured and fires `onCapture` with a `LATERAL_DRIFT_FINALIZE` warning.)
*
* Hosts use this callback to clean up their own state (e.g., reset
* a wizard step, log telemetry, surface their own retry UX in
* addition to the SDK's built-in modal). No `onCapture` will fire
* for an abandoned capture.
*/
onCaptureAbandoned?: (reason: 'orientation-drift' | 'lateral-drift') => void;
/**
* v0.13.0 — flash (torch) state. Controlled-or-uncontrolled.
*
* - **Uncontrolled** (omit `flash`): `` owns the flash
* state internally. Tapping the built-in flash button toggles
* it on/off. `onFlashChange` (if supplied) fires for telemetry.
* - **Controlled** (supply `flash`): the parent owns the state.
* The built-in button still renders and fires `onFlashChange`
* on press, but it's a no-op unless the parent updates `flash`
* in response.
*
* Both shapes coexist with the v0.13 "flash button is on by default"
* built-in (see the bottom-left bar slot in the JSX). Hosts that
* want their own flash chrome can opt out via `showFlashButton={false}`
* and drive the underlying torch by controlling `flash` directly.
*
* ## AR-mode behaviour
*
* In AR mode (`defaultCaptureSource="ar"` or runtime-toggled),
* ARKit / ARCore own the `AVCaptureDevice` and don't expose the
* torch through vision-camera's pipeline. The built-in flash
* button renders as visibly disabled (a11y label "Flash unavailable
* in AR mode") and `flash` is forced to `'off'` regardless of
* controlled/uncontrolled state. Hosts that need flash should
* toggle to non-AR before enabling.
*/
flash?: 'on' | 'off';
/**
* v0.13.0 — fires when the user taps the built-in flash button.
* In uncontrolled mode, the internal state has already flipped
* (single render delay). In controlled mode, the parent must
* update the `flash` prop in response or the visual toggle is
* a no-op. Useful in either mode for telemetry.
*/
onFlashChange?: (next: 'on' | 'off') => void;
/**
* v0.13.0 — show the built-in flash button in the bottom-left
* slot. Defaults to `true`. Hosts that render their own flash
* chrome (and drive the underlying torch via the controlled
* `flash` prop) can opt out by setting this to `false`.
*/
showFlashButton?: boolean;
/**
* v0.13.0 — built-in CaptureHeader title. When set, ``
* renders a top-of-screen header showing this title (centred)
* with an optional back affordance + guidance subtitle + the
* existing settings gear absorbed into the header's right side.
*
* When `headerTitle` is undefined the header is not rendered
* (matches pre-v0.13 behaviour: top of preview is bare except
* for the standalone settings gear gated on `showSettingsButton`).
*
* Combine with `onHeaderBack`, `headerBackLabel`, `headerGuidance`,
* and `headerColors` to customise the rest of the header. Hosts
* that need richer header chrome can omit `headerTitle` and
* compose their own `` above ``.
*/
headerTitle?: string;
/**
* v0.13.0 — header back-button callback. When supplied (and
* `headerTitle` is set), the header renders a back affordance
* on the left. Omitted ⇒ no back button (the title stays
* centred).
*/
onHeaderBack?: () => void;
/**
* v0.13.0 — header back-button label. Defaults to "‹ Back".
* No effect unless `headerTitle` and `onHeaderBack` are both set.
*/
headerBackLabel?: string;
/**
* v0.13.0 — optional second-line subtitle shown below the
* header title. E.g. "Photograph the promotional cola end cap."
* Renders nothing when undefined. No effect unless `headerTitle`
* is set.
*/
headerGuidance?: string;
/**
* v0.13.0 — colour overrides for the built-in header. Defaults
* are white-on-black to stay legible over the camera preview.
* No effect unless `headerTitle` is set.
*/
headerColors?: CaptureHeaderProps['colors'];
/**
* v0.13.0 — when provided (even as `[]`), `` renders a
* built-in `CaptureThumbnailStrip` above the bottom controls
* showing the host's capture history. Each item is a plain
* `{ id, uri, width?, height? }` object; the strip handles
* aspect-ratio rendering, tap-to-preview, and the count line.
*
* Omit (`undefined`) to skip the strip entirely. Hosts using
* the strip independently (e.g. on a non-camera screen) can keep
* importing `CaptureThumbnailStrip` directly from the library —
* the prop here is the convenience wiring for in-`` use.
*
* Captures emitted by ``'s `onCapture` are NOT added to
* this array automatically — the host owns the canonical list
* (typically persisted to its own DB) and updates the prop in
* response. This matches the SDK's "Camera owns runtime state,
* host persists" pattern.
*/
thumbnails?: CaptureThumbnailItem[];
/**
* v0.13.0 — minimum-photos hint for the count line. Renders
* "n / minPhotos min" with the success colour when reached,
* warning colour otherwise.
*/
thumbnailsMin?: number;
/**
* v0.13.0 — maximum-photos hint for the count line. Renders
* "· maxPhotos max" suffix. No enforcement — the host decides
* what to do at the cap.
*/
thumbnailsMax?: number;
/**
* v0.13.0 — tap handler for thumbnails. When set, replaces the
* strip's built-in tap-to-preview modal; the host shows its own
* preview UI (e.g. with delete / recapture buttons gated on
* sync state). Omit to use the built-in preview.
*/
onThumbnailPress?: (item: CaptureThumbnailItem) => void;
/**
* v0.13.0 — when set, `` renders a built-in `CapturePreview`
* modal as `visible`. Use this for post-stitch confirmation:
* after `onCapture` emits, the host stores the result and sets
* `capturePreview` to the new image, with `capturePreviewActions`
* = `[Discard, Save]` (or similar). Setting `undefined` hides
* the modal.
*
* Hosts using the modal for thumbnail tap-to-preview can leave
* this undefined and let the built-in strip's preview handle
* that case.
*/
capturePreview?: {
imageUri: string;
imageWidth?: number;
imageHeight?: number;
title?: string;
};
/**
* v0.13.0 — action buttons rendered along the bottom of the
* `CapturePreview` modal. Empty array (or undefined) renders
* no buttons, only the close affordance.
*/
capturePreviewActions?: CapturePreviewAction[];
/**
* v0.13.0 — fires when the user dismisses the `capturePreview`
* modal (tap close, backdrop tap, hardware back on Android).
* The host is expected to clear the `capturePreview` prop in
* response.
*/
onCapturePreviewClose?: () => void;
/**
* Optional host-supplied vision-camera frame processor.
*
* ## When to set this prop
*
* v0.8.0+ canonical answer: use the lib's own `useFrameProcessor`
* hook, NOT `react-native-vision-camera`'s. The lib's hook:
*
* - **AR mode**: auto-registers the worklet in the native
* `__stitcherProxy` registry; the AR session's per-frame
* dispatch fans out to it alongside the lib's first-party
* stitching. No prop wiring needed — just mount the hook
* anywhere in the tree.
* - **Non-AR mode**: returns a vc processor object that this
* prop accepts. Wiring it through enables the host's
* worklet to fire on vc's Frame Processor runtime.
*
* ```tsx
* import { Camera, useFrameProcessor, type CameraFrame }
* from 'react-native-image-stitcher';
*
* function MyScreen() {
* const fp = useFrameProcessor((frame: CameraFrame) => {
* 'worklet';
* // ...
* }, []);
* return ;
* }
* ```
*
* ## Non-AR mode composition (v0.11.0+)
*
* vision-camera's `` accepts ONLY ONE frame processor.
* The lib's internal `useFrameProcessorDriver` produces the
* processor that drives first-party panorama stitching in non-AR
* mode. If you supply your own via this prop, the lib's
* default processor is REPLACED — but as of v0.11.0 you can
* COMPOSE first-party stitching back into your worklet body
* using `useStitcherWorklet`:
*
* ```tsx
* import {
* Camera, useFrameProcessor, useStitcherWorklet,
* type CameraFrame,
* } from 'react-native-image-stitcher';
*
* function MyScreen() {
* const stitcher = useStitcherWorklet();
* const fp = useFrameProcessor((frame: CameraFrame) => {
* 'worklet';
* hostPreLogic(frame);
* stitcher.call(frame); // ← first-party stitching
* hostPostLogic(frame);
* }, [stitcher.call]);
* return ;
* }
* ```
*
* Hosts that DON'T call `useStitcherWorklet` from their worklet
* body replace first-party stitching for non-AR captures (a
* one-shot console.info documents this when the prop is first
* supplied). AR mode is unaffected either way — the AR-mode
* dispatch path (v0.8.0 Phase 4b.i / 4b.iii) natively fans out
* to both the lib's first-party stitching AND every registered
* host worklet on every frame, with per-worklet failure
* isolation.
*
* ## AR mode behaviour
*
* In AR mode (`defaultCaptureSource="ar"` or runtime-toggled),
* vc's `` isn't mounted; this prop has no effect.
* Host worklets registered via the lib's `useFrameProcessor`
* fire automatically through the AR-session dispatch path
* (iOS Phase 4b.i / Android Phase 4b.iii).
*
* ## Backwards compatibility
*
* The pre-v0.8.0 behaviour (warn + ignore) is preserved when the
* supplied processor is recognisably from
* `react-native-vision-camera`'s `useFrameProcessor` directly
* (no `__stitcherFrame` marker). Hosts should migrate to the
* lib's `useFrameProcessor` to benefit from AR-mode dispatch.
*
* (v0.5 had a `legacyDriver` escape hatch that routed back to
* `useIncrementalJSDriver`. That hook + prop were removed in
* v0.6 per the deprecation timeline announced in the v0.5.0
* CHANGELOG.)
*/
frameProcessor?: ReadonlyFrameProcessor | DrawableFrameProcessor;
/**
* AR-mode host worklet, invoked once per ARKit / ARCore frame
* ALONGSIDE the lib's first-party stitching (composition, not
* replacement). Receives a `CameraFrame` tagged `source: 'ar'`
* with world-space `pose` + `arTrackingState`. Only fires in AR
* capture (`captureSource === 'ar'`); the non-AR equivalent is
* `frameProcessor` above (the two modes use different runtimes and
* frame shapes). Must be a `'worklet'`-prefixed function; if the
* native install is unavailable it silently never fires.
*/
arFrameProcessor?: CameraFrameProcessor;
/**
* Opt in to per-frame AR depth on the `arFrameProcessor` frame
* (`CameraFrame.arDepth`). Default `false` — depth is the costliest
* field (a per-frame buffer copy), so it's off until you need it.
*/
enableDepth?: boolean;
/**
* Opt in to high-resolution photo capture (iOS 16+, AR capture path).
* When `true`, the AR session runs on the smallest video format that
* supports `captureHighResolutionFrame`, so `takePhoto()` returns a true
* full-res still (for document OCR / detail capture). Default `false` —
* the live stream stays as small as possible; the panorama-stitch path is
* unaffected (its keyframes are downscaled to a fixed budget regardless).
* No-op on Android.
*/
highResCapture?: boolean;
/**
* PANORAMA-QUALITY keyframes (Android; see `ARCameraView` prop of the
* same name): larger ARCore CPU-image config (long-edge ≤ 1920) + a
* lifted keyframe encode budget (640 → 1280) so stitches stop being
* assembled from 0.3 MP tiles on devices whose sole 4:3 config is tiny
* (e.g. Galaxy A35). Costs stitch memory (~4× pixels per keyframe).
* Default `false`. No-op on iOS (native-res keyframes already) and on
* older binaries.
*/
keyframeQualityCapture?: boolean;
/**
* Native-camera 0.5× fallback (Android; default OFF). A list of device
* MODEL identifiers (matched case-insensitively as a PREFIX of
* `Platform.constants.Model`, so `"SM-A346"` covers every A34 SKU) or
* `"manufacturer:"` wildcards, on which the ultra-wide is a
* SYSTEM-ONLY camera unreachable by any third-party app (proven on the
* Galaxy A34). On a matching device that ALSO has no in-app 0.5×
* (`has0_5x=false`), the lens chip renders a "0.5×⤢" pill that fires
* {@link onRequestNativeUltraWide} instead of a dead 1× label — the host
* then hands off to the OS camera. Absent/empty → feature OFF, no change.
* iOS ignores this (its virtual devices already reach the ultra-wide).
*/
nativeUltraWideModels?: readonly string[];
/**
* Fired when the operator taps the "0.5×⤢" native-ultra-wide fallback pill
* (see {@link nativeUltraWideModels}). The host launches the OS camera
* (e.g. `react-native-image-picker` `launchCamera`) and routes the
* returned photo into its own capture flow marked as external provenance —
* the library does NOT launch anything or deliver the external photo.
*/
onRequestNativeUltraWide?: () => void;
/**
* iOS, NON-AR photo path — save each tap photo's AVDepthData as a
* `.depth.bin` sidecar (float32 metres row-major + JSON header
* with dims/intrinsics) and return its path as `depthPath` on the
* photo {@link CameraCaptureResult}. Enables vision-camera depth
* delivery, biases the format pick toward `supportsDepthCapture`
* formats, and extracts the depth BEFORE the orientation re-encode
* strips it. Produces stereo disparity-derived depth on dual-camera
* iPhones and absolute LiDAR-backed depth on Pro models; requires the
* mounted device to be depth-capable (the lens-driven multicam
* selection qualifies — a plain single wide-angle does not). Silently
* yields no sidecar on Android, in AR capture, and on depth-less
* hardware. Distinct from `enableDepth` above, which is the AR
* frame-processor's per-frame depth. Default `false` (depth delivery
* adds per-shot latency).
*/
captureDepthData?: boolean;
/**
* Opt in to per-frame AR anchors (`CameraFrame.arAnchors` — detected
* planes / images). Default `false`.
*/
enableAnchors?: boolean;
/**
* Opt in to scene-reconstruction mesh anchors (`type: 'mesh'` in
* `arAnchors`, with `meshGeometry`). Default `false`. iOS enables
* ARKit `sceneReconstruction` (LiDAR); Android reconstructs a rough
* mesh from the depth map. Expensive — only on when needed.
*/
enableMesh?: boolean;
/**
* Opt in to the SLAM feature-point cloud in AR plugin contexts. Default
* `false`. Available on ALL AR-capable devices — no LiDAR required.
* Consumed natively by AR plugins only; does not appear in
* {@link ARFrameMeta} or `CameraFrame`.
*
* - iOS → ARKit `rawFeaturePoints` in `RNISARFrameContext.featurePoints`
* as world-space `[simd_float3]` (bare `x, y, z`).
* - Android → ARCore `Frame.acquirePointCloud()` in
* `ARFrameContext.featurePoints` as a flat stride-4
* `[x, y, z, confidence]` world-space `FloatArray` (the extra
* per-point confidence lets native plugins filter ARCore's
* sparser cloud).
*/
enableFeaturePoints?: boolean;
/**
* Which plane orientations to surface in `CameraFrame.arAnchors`
* (requires `enableAnchors`; AR capture only). Default `'vertical'`
* — the orientation the plane-projected stitch path has always used.
* `'horizontal'` surfaces floors / tables; `'both'` surfaces every
* detected plane. See `ARCameraView` for the per-platform details.
*/
planeDetection?: 'vertical' | 'horizontal' | 'both';
/**
* v0.18.0 — LIGHT per-frame AR metadata callback, invoked on the JS
* MAIN thread (NOT a worklet). Only fires in AR capture
* (`captureSource === 'ar'`). Receives an {@link ARFrameMeta} carrying
* pose, tracking state, intrinsics, and (when the matching `enable*`
* prop is on) depth dimensions, anchors, and mesh counts.
*
* This is the recommended way to read AR metadata: it sidesteps the
* worklet path entirely (the `arFrameProcessor` worklet can only safely
* surface a worklets-core shared value, because capturing a host
* callback crashes the worklet closure-wrap). Native builds the meta
* and emits a device event; `` threads the handler through to
* ``, which subscribes and invokes it on the main thread.
*/
onArFrame?: (meta: ARFrameMeta) => void;
/**
* v0.18.0 — throttle interval (ms) for {@link onArFrame}. Default `100`
* (≈ 10 Hz). No effect unless `onArFrame` is provided.
*/
arFrameMetaInterval?: number;
/**
* v0.19.0 — ASYNCHRONOUS AR-plugin result callback (the AR plugin
* framework), invoked on the JS MAIN thread (NOT a worklet). Only fires in
* AR capture (`captureSource === 'ar'`). Host-registered native plugins
* (see `RNISARPluginRegistry` / `RNSARPluginRegistry`) that offload heavy
* per-frame work to their own queue push results via
* `registry.emit(name, result)`; `` threads this handler to
* ``, which subscribes to the `RNImageStitcherARPluginResult`
* device event and invokes it with `{ plugin, result }`.
*
* SYNCHRONOUS plugin results (computed inline on the AR thread) instead ride
* the throttled {@link onArFrame} event on {@link ARFrameMeta.plugins}.
* Use `onArFrame` for the in-band sync channel and `onArPluginResult` for
* the out-of-band async channel — a host can wire either or both.
*
* The SDK ships ONLY the generic plugin framework; there are no built-in
* plugins, so this never fires unless the host registers native plugins.
*/
onArPluginResult?: (e: ARPluginResult) => void;
/**
* v0.20.0 — AR OVERLAY / ANNOTATION renderer. A declarative array of 2D
* shapes drawn ON TOP of the AR camera preview, each anchored to WORLD
* positions and REPROJECTED to screen on every AR frame from the current
* camera pose + intrinsics (smooth display-rate tracking, no 3D engine).
* Only meaningful in AR capture (`captureSource === 'ar'`); ``
* threads this straight through to the underlying ``.
*
* State-driven: pass a React-state array and update it as your world points
* change (e.g. from {@link CameraProps.onArFrame} plane anchors). The set is
* diffed against the current overlays BY `id`. For zero-render-latency
* mutations use the imperative ref methods on the `` handle instead
* ({@link CameraHandle}: `setOverlays` / `addOverlay` / `updateOverlay` /
* `removeOverlay` / `clearOverlays`) — both paths funnel through the same
* native channel. JS-set overlays merge on the native side with overlays a
* registered AR plugin placed directly (namespaced so neither clobbers the
* other). See {@link AROverlay} for the shape.
*/
overlays?: AROverlay[];
// ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────────
/**
* Which device holds the non-AR panorama capture accepts.
*
* - `'vertical'` (DEFAULT) — LANDSCAPE-only, top→bottom pan. Starting a
* panorama in portrait is BLOCKED behind the rotate-to-landscape
* prompt (item 2); the capture starts the instant they rotate to
* landscape (either way up).
* - `'horizontal'` — PORTRAIT-only, left→right pan. Starting in
* landscape is BLOCKED behind the rotate-to-portrait prompt; capture
* starts on rotating to portrait (either way up).
* - `'both'` — landscape OR portrait; the rotate gate never fires, the
* user captures in whichever hold they're already in.
*
* **BREAKING (since the previous release accepted both holds ungated):**
* the default is now `'vertical'`. Hosts that want left→right (portrait)
* panoramas use `panMode='horizontal'` (portrait-only) or `'both'`. See
* CHANGELOG.
*/
panMode?: PanMode;
/**
* Master switch for the in-capture pan-guidance surfaces (rotate
* prompt, pan how-to overlay, too-fast pill, blinking countdown).
* Default `true`. Set `false` to suppress all of them (the lateral-
* drift FINALIZE behaviour and the crop preview are governed by their
* own props, not this flag).
*/
panGuidance?: boolean;
/**
* Optional hard recording-TIME ceiling for a non-AR panorama, in
* milliseconds, used as a SAFETY cap alongside the primary keyframe-count
* auto-stop. The default capture now finalizes when the configured
* keyframe count is reached (see the frame counter HUD), so this is `0`
* (disabled) by default. Set it to a positive value to ALSO cap the
* recording by wall-clock time; when > 0 a blinking countdown (item 5)
* shows the seconds remaining and the capture auto-finalizes at 0.
*
* v0.16 — default changed `9000` → `0` (time cap is now opt-in; the
* keyframe-count stop is the default UX).
*/
maxPanDurationMs?: number;
/**
* Gyro rate (rad/s) above which the pan is flagged "moving too fast"
* (item 4 — the transient amber pill). Optional; forwards to
* `usePanMotion`'s `warnMaxRadPerSec` (default 1.0 rad/s there).
*/
panTooFastThreshold?: number;
/**
* Cross-pan (lateral) drift budget in CENTIMETRES (item 6). Once the
* operator's integrated sideways translation exceeds this for the
* hook's grace window, the capture is STOPPED.
*
* **Default `8`** (v0.25.3 — was `4`). `0` disables the lateral-drift
* stop entirely.
*
* This is the SENSITIVITY knob: it decides how much sideways drift is
* tolerated before a capture is stopped at all. What HAPPENS at that
* stop — finalize the partial sweep, or discard it — is a separate
* decision controlled by `lateralStopFinalizeMinFrames`. The two are
* easy to confuse when tuning: if operators complain the stop fires
* too eagerly, raise this; if they complain that stopped captures are
* thrown away, lower that one.
*
* v0.25.3 raised the default from `4` after field reports of the stop
* firing on minor drift. 4 cm of integrated sideways translation is
* a small movement to hold to over a hand-held sweep — comfortably
* inside the natural arc of pivoting on the spot — so it tripped on
* captures the operator considered fine. The detector itself is
* unchanged; only the budget it is measured against moved.
*/
lateralBudgetCm?: number;
/**
* Cross-pan ROTATION rate, rad/s, above which the capture is stopped
* for lateral drift. Defaults to `DEFAULT_LATERAL_TURN_RAD_PER_SEC`
* (0.15 rad/s ≈ 8.6 °/s) — unset reproduces today's behaviour.
*
* `lateralBudgetCm` is NOT the only lateral trigger. This gyro EMA
* is a second, independent one, and historically the primary. A stop
* you attribute to "drifting sideways" may be this rotation trigger
* instead — check `latch=gyro|accel` in the `[panMotion]` telemetry
* (see `panMotionDebug`) before tuning either number.
*
* `0` (or negative) disables THIS trigger only; `lateralBudgetCm={0}`
* disables both.
*/
lateralTurnRateRadPerSec?: number;
/**
* Continuous over-threshold dwell, ms, before the ROTATION trigger stops the
* capture. Default 500 ms — matching the displacement trigger, which has
* always had one.
*
* Before v0.26.0 the rotation trigger latched on the FIRST sample over
* threshold, so one brief wobble ended a capture permanently. `0` comes as
* close to restoring that as the shared latch helper allows — it still costs
* one gyro sample (~33 ms), because the dwell clock starts on the first
* over-threshold sample and latches only on a later one.
*/
lateralTurnGraceMs?: number;
/**
* Absolute cross-pan ANGLE, DEGREES, at which the capture is stopped.
* Default 25; `0` disables while still measuring. Works in BOTH AR and
* non-AR (gyro-integrated), unlike `arLateralRotDeg` which needs the pose.
*
* Catches the slow pivot `lateralTurnRateRadPerSec` cannot: that is a rate
* gate, so 6 deg/s turns 90 degrees over 15 s without tripping it.
*/
lateralTurnAngleDeg?: number;
/**
* ABSOLUTE cross-pan drift budget in CENTIMETRES, measured from the AR
* camera POSE. AR captures only. Default 8 cm; `0` disables the stop
* while still measuring and logging the distance.
*
* A genuine displacement, unlike `lateralBudgetCm`, which gates a
* high-passed rate proxy. Recovering distance from an accelerometer needs
* double integration, whose bias growth must be high-passed away — and that
* same high-pass removes slow real motion, so the IMU guard structurally
* cannot see slow sideways drift however it is tuned. ARKit's VIO position
* needs no integration, so it can; and tilt cannot masquerade as
* translation, because a position is not an acceleration.
*
* The axis is chosen so the sweep's own ARC never projects onto it — a
* vertical pan measures the horizontal cross-view direction, a horizontal
* pan measures world up — so an operator pivoting cleanly in place reads ~0
* regardless of sweep angle or arm length (measured < 5 mm over an 0.8 rad
* sweep at pivot radii of 15-60 cm).
*/
arLateralBudgetCm?: number;
/**
* ABSOLUTE cross-pan ROTATION budget in DEGREES, from the AR camera pose.
* AR captures only. Default 25 deg; `0` disables the stop while still
* measuring.
*
* Closes the slow-pivot hole. `lateralTurnRateRadPerSec` is a RATE gate
* (0.15 rad/s = 8.6 deg/s), so it cannot see a slow turn however far it
* goes: 6 deg/s accumulates 90 DEGREES of yaw over 15 s and never trips it.
* That is the rotation twin of the slow-translation blind spot — a rate gate
* measures how FAST you turn, never how FAR you have turned.
*
* Measured on the camera's forward VECTOR, so the intended sweep contributes
* nothing (a vertical pan measures azimuth, a horizontal pan elevation) and
* ROLL about the view axis contributes nothing either — roll does not change
* where the camera points, and it is already the motion that corrupted the
* accelerometer channel.
*/
arLateralRotDeg?: number;
/**
* Cross-pan allowance as a RATIO of along-pan travel (AR only).
*
* A fixed centimetre budget cannot distinguish 6 cm of drift across a 60 cm
* sweep (10 % — still overwhelmingly a pan, and about as straight as a hand
* gets over half a metre) from the same 6 cm across a 10 cm one (60 % — not
* a pan at all). The effective budget is therefore
* `clamp(ratio * alongPanDistance, arLateralBudgetCm, arLateralMaxCm)`.
*
* `arLateralBudgetCm` becomes the FLOOR: short sweeps, and the opening of
* every capture where along-pan travel is still ~0, keep exactly the old
* absolute behaviour. `<= 0` disables the proportional term entirely.
*/
arLateralRatio?: number;
/** Ceiling on the ratio allowance, CENTIMETRES. `<= 0` = uncapped. */
arLateralMaxCm?: number;
/**
* Which lateral-drift physics to run. Default `'fused'`.
*
* `'fused'` subtracts the device's FUSED GRAVITY SENSOR from each
* accelerometer sample and derives the integration step from the
* sample's own timestamp. `'legacy'` restores the pre-0.25.4
* behaviour bit-for-bit: an IIR gravity estimate and a hardcoded
* 20 ms step.
*
* You almost certainly want the default. The legacy estimator cannot
* distinguish a wrist TILT from a sideways SLIDE — a change in how
* gravity projects onto the cross-pan axis is arithmetically
* identical to real lateral acceleration — so it fabricates ~1.1 cm
* of drift per degree of net tilt and latches the stop on ordinary
* hand movement. This prop exists as an ESCAPE HATCH so a host that
* hits an unexpected device-specific regression can back out without
* pinning an old version of the library, not as an opt-in gate.
*
* Degrades on its own: if the device has no fused gravity sensor, or
* it stops delivering mid-capture, the hook falls back to the legacy
* estimator automatically for exactly as long as it needs to.
*/
lateralMotionModel?: LateralMotionModel;
/**
* Emit the throttled `[panMotion.*]` diagnostic logs. Default
* `__DEV__`, i.e. unset behaves exactly as before. Set `true` to keep
* them in a release build while diagnosing a field report — they are
* the intended instrument for tuning `lateralBudgetCm` against real
* captures.
*/
panMotionDebug?: boolean;
/**
* The accepted-keyframe count at or above which a lateral-drift stop
* (item 6) FINALIZES the capture: the partial sweep is stitched and handed
* to `onCapture` with a `LATERAL_DRIFT_FINALIZE` warning. BELOW it the
* capture is DISCARDED instead — the engine is cancelled, nothing is
* stitched, and `onCaptureAbandoned('lateral-drift')` fires.
*
* **Default `5`. This is a BEHAVIOUR CHANGE, not a no-op** — the SDK used
* to hardcode `2`, so captures that accepted 2-4 keyframes previously
* finalized and now DISCARD. That is intentional: a 2-to-4-frame remnant
* of a sweep the operator drifted out of is not a usable shelf panorama,
* and asking for a clean re-shoot beats handing a host output it has to
* detect and reject downstream. **Pass `2` to restore the old
* behaviour exactly.**
*
* - `0` — **ALWAYS DISCARD**, however many keyframes were accepted. This
* is a genuine special case and NOT the `count >= 0` the arithmetic
* would otherwise give you (that is unconditionally true, i.e. the
* opposite). For hosts whose downstream pipeline treats any drifted
* sweep as garbage, discarding costs nothing and saves the stitch, the
* file, and the operator's attention on output they will bin anyway.
* - `N >= 1` — finalize iff `acceptedKeyframeCount >= N`.
* - `2` — the pre-policy behaviour: keep anything stitchable.
*
* Negative, `NaN` and infinite values normalise to the default, so a broken
* host config degrades to the standard threshold rather than to "throw
* every capture away"; fractional values round UP, as the prop counts whole
* frames.
*
* The discard path shows a THIRD popup state whose copy does not promise a
* stitch — `lateralStopDiscardedTitle` / `lateralStopDiscardedBody` in
* {@link guidanceCopy}. At the default that state covers the 2-to-4
* keyframe band. Below 2 accepted keyframes nothing stitchable was
* captured at all, so the popup keeps the existing "follow the arrow"
* wrong-direction copy regardless of this threshold.
*/
lateralStopFinalizeMinFrames?: number;
/**
* v0.25 — whether a mid-capture device rotation auto-ABANDONS the
* in-flight panorama (the OrientationDriftModal explains it to the
* user). Default `true` (the behaviour since v0.12). Set `false`
* to disable the guard entirely: the capture then continues across a
* rotation and the output is best-effort (cross-mode captures can
* stitch malformed — see `incremental.ts` stitch-mode notes).
*
* The detector itself is sensor-trust hardened as of v0.25: it never
* snapshots or compares orientation until the accelerometer has
* delivered a real sample, so hosts with broken/laggy
* react-native-sensors delivery no longer see phantom "rotation"
* abandons (field RCA: landscape captures auto-abandoning after one
* frame while portrait worked).
*/
orientationDriftAbandon?: boolean;
/**
* v0.25 — the keyframe count below which a finished capture is flagged
* with the `CAPTURE_TOO_SHORT` capture WARNING. **Default `1`, which
* never warns** and reproduces the previous behaviour exactly.
*
* Set `2` to be told when a capture produced a single frame. The
* capture still SUCCEEDS and still returns that frame — a one-shot
* capture is a legitimate result — but it is no longer silent. That
* silence is why the AR self-ending-hold failures were reported as
* stitching bugs: the SDK returned the lone frame as an ordinary
* panorama with `singleKeyframe: true` that nothing read.
*
* Evaluated from the FINALIZE RESULT, not from the live accepted count.
* The live count omits any keyframe whose anti-blur sharpness window is
* still open at release — the trailing keyframe of nearly every capture
* — and it means different things on iOS and Android, so judging
* "too short" from it would misfire constantly. An earlier draft of
* this feature did exactly that and would have destroyed valid
* captures; adversarial review caught it.
*/
minPanoramaKeyframes?: number;
/**
* Show the draggable-quad crop editor after a panorama finalizes, BEFORE
* emitting it via `onCapture`. Default `false`. When `true`, the user
* drags 4 corners over the stitched result; confirming crops in place
* (perspective-rectify when the quad isn't axis-aligned), "Use original"
* emits the un-cropped panorama, "Retake" discards it. Takes precedence
* over {@link showPreview}.
*/
rectCrop?: boolean;
/**
* Show a plain review screen after a panorama finalizes — the stitched
* image with [Retake] / [Confirm] and NO crop box. Default `false`.
* Ignored when {@link rectCrop} is on (the crop editor is itself the
* preview). With both off, `onCapture` fires immediately with no UI.
*/
showPreview?: boolean;
/**
* Copy overrides for every guidance string (rotate prompt, pan hint,
* too-fast warning, lateral-stop popup, crop buttons). Partial —
* unspecified keys fall back to {@link DEFAULT_GUIDANCE_COPY}. Hosts
* localise or re-word the whole guidance surface in one place here.
*/
guidanceCopy?: Partial;
}
/**
* v0.20.0 — imperative handle exposed via the `` ref.
*
* Currently scoped to the AR-overlay methods ({@link AROverlayMethods}:
* `setOverlays` / `addOverlay` / `updateOverlay` / `removeOverlay` /
* `clearOverlays`), which forward to the underlying ``'s overlay
* channel when AR mode is mounted. They are no-ops while the camera is in
* non-AR mode (no `` is mounted, and overlays only render over
* the AR preview) — use the declarative {@link CameraProps.overlays} prop for
* a set that survives AR↔non-AR transitions, since it re-applies automatically
* whenever `` (re)mounts.
*
* The shape is identical to {@link ARCameraViewHandle}'s overlay subset so a
* host can use either component with the same overlay code. Panorama capture
* remains driven by the built-in shutter; single-photo capture can also be
* triggered imperatively via {@link CameraHandle.takePhoto} (added 0.20.5 for
* hands-free / auto-capture flows like the document scanner).
*/
export interface CameraHandle extends AROverlayMethods {
/**
* Imperatively fire a single-photo capture — identical to the user tapping
* the shutter (same AR / non-AR routing, same `onCapture` callback, same
* output path rules). Respects `enablePhotoMode` and `shutterDisabled`: a
* no-op while photo mode is off or the shutter is gated, so callers can fire
* it freely and let the gate decide. Resolves once the capture attempt
* settles (success or handled error reported through `onCapture`).
*/
takePhoto(): Promise;
/**
* Imperatively START a panorama sweep — identical to the user beginning a
* hold on the shutter (the incremental stitcher starts ingesting AR frames).
* A no-op unless `enablePanoramaMode` is on and the shutter is not gated
* (`shutterDisabled`), and while a capture is already recording/stitching —
* the same gates `takePhoto` respects. Pair with {@link stopPanorama}.
* Added for hosts that render their OWN shutter (see `hideBuiltInShutter`)
* and drive capture through this handle instead of the built-in button.
*/
startPanorama(): void;
/**
* Imperatively STOP an in-flight panorama sweep — identical to releasing the
* shutter hold: finalize the stitch and emit `onCapture`. Idempotent (a safe
* no-op when nothing is recording). Resolves once the stop is dispatched.
*/
stopPanorama(): Promise;
/**
* Set the preferred capture source — for hosts that render their own chrome.
*
* `hideBuiltInShutter` hides the built-in shutter AND the AR toggle, on the
* documented premise that the host draws its own capture controls. But
* before v0.26.0 the host had no way to DRIVE the AR toggle: `arPreference`
* was internal state with no prop and no handle method. So such a host
* could replace the shutter (this handle covers capture) and could NOT
* replace the AR control — leaving it locked to whatever
* `defaultCaptureSource` resolved to at mount, with no indicator and no
* switch. That is exactly what shipped in a field build.
*
* Sets the same PREFERENCE the built-in pill flips, so host chrome and the
* built-in control are interchangeable rather than competing sources of
* truth. The EFFECTIVE source is still clamped by `captureSources`, by
* device AR support, and by the 0.5x lens (ARKit/ARCore cannot drive the
* ultra-wide) — so requesting `'ar'` on an unsupported device stays non-AR.
* Subscribe to `onCaptureSourceChange` for what actually took effect.
*/
setCaptureSource(source: CaptureSource): void;
}
// ─── Sub-components ─────────────────────────────────────────────────
/**
* Lens chip — toggles between 1× and 0.5× physical lenses.
*
* Placement: bottom-center of the preview, just above the shutter
* button. Standard iOS-camera-app convention so users know where to
* look. Two pills side-by-side, the active one filled.
*/
interface LensChipProps {
lens: CameraLens;
onChange: (lens: CameraLens) => void;
has0_5x: boolean;
/**
* v0.13.1 — counter-rotation applied to the label TEXT (not the pill
* container) so the "0.5×"/"1×" glyphs read upright when the device
* is held landscape under a portrait-locked host, while the pill
* itself stays fixed in the layout. `{}` (no-op) in the upright cases.
*/
contentRotation?: { transform?: ViewStyle['transform'] };
/**
* Native-camera 0.5× fallback (see `nativeUltraWide.ts`): when the device
* has NO in-app ultra-wide (`has0_5x=false`) but is a known system-only-UW
* model, render a "0.5× ⤢" pill that hands off to the OS camera instead of
* the plain "1×". Only consulted when `!has0_5x`.
*/
offerNativeUltraWide?: boolean;
onNativeUltraWide?: () => void;
/** When the device offers only ONE lens (no 0.5× and no native fallback),
* render null instead of the static "1×" label. Default false. */
hideWhenSingle?: boolean;
/**
* The device's REAL ultra-wide factor, for this chip's label only — `0.6`
* on a Galaxy S24 Ultra, `0.5` on a typical iPhone. The `CameraLens`
* identifier stays `'0.5x'` regardless (it is also the stitcher's
* warper-tree zoom signal). Null/absent ⇒ keep the historical `0.5×`
* text rather than invent a number.
*/
ultraWideFactor?: number | null;
}
function LensChip({
lens,
onChange,
has0_5x,
contentRotation,
offerNativeUltraWide = false,
onNativeUltraWide,
hideWhenSingle = false,
ultraWideFactor,
}: LensChipProps): React.JSX.Element | null {
// Label only — never the `CameraLens` value. `0.6` → "0.6×"; the fallback
// keeps every device that reports nothing on the text it has always shown.
const uwLabel =
ultraWideFactor != null && Number.isFinite(ultraWideFactor)
? `${ultraWideFactor}×`
: '0.5×';
if (!has0_5x) {
if (offerNativeUltraWide && onNativeUltraWide) {
// The ultra-wide is unreachable in-app on this device — offer a
// hand-off to the OS camera. The ⤢ glyph signals it leaves the app.
return (
0.5×⤢
);
}
// Only one usable lens. Nothing to switch — hide entirely, or keep the
// legacy static "1×" label.
if (hideWhenSingle) return null;
return (
1×
);
}
return (
onChange('0.5x')}
accessibilityRole="button"
accessibilityLabel={`${uwLabel.replace('×', 'x')} ultra-wide lens`}
accessibilityState={{ selected: lens === '0.5x' }}
style={[
lensChipStyles.pill,
lens === '0.5x' && lensChipStyles.pillActive,
]}
>
{uwLabel}
onChange('1x')}
accessibilityRole="button"
accessibilityLabel="1x wide-angle lens"
accessibilityState={{ selected: lens === '1x' }}
style={[
lensChipStyles.pill,
lens === '1x' && lensChipStyles.pillActive,
]}
>
1×
);
}
const lensChipStyles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: 'rgba(0,0,0,0.45)',
borderRadius: 18,
padding: 3,
alignSelf: 'center',
},
singleLens: {
paddingHorizontal: 12,
},
pill: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
minWidth: 44,
alignItems: 'center',
},
pillActive: {
backgroundColor: '#ffd34d',
},
nativeUwPill: {
// Distinct from the normal lens pill — a subtle outline so the ⤢
// hand-off reads as "opens your phone camera", not a live lens toggle.
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
},
label: {
color: '#ffffff',
fontSize: 13,
fontWeight: '600',
},
labelActive: {
color: '#1a1a1a',
},
});
/**
* AR toggle — switch between AR-backed and non-AR capture.
* Conditional on `lens === '1x'`; hidden when the user is on 0.5×
* (which forces non-AR).
*/
interface ARToggleProps {
arEnabled: boolean;
onToggle: () => void;
/**
* v0.13.1 — counter-rotation applied to the "AR" label TEXT (not the
* pill container) so the glyph reads upright when the device is held
* landscape under a portrait-locked host, while the pill stays fixed.
* `{}` no-op in the upright cases.
*/
contentRotation?: { transform?: ViewStyle['transform'] };
}
function ARToggle({ arEnabled, onToggle, contentRotation }: ARToggleProps): React.JSX.Element {
return (
AR
);
}
const arToggleStyles = StyleSheet.create({
container: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0.45)',
minWidth: 56,
alignItems: 'center',
},
containerOn: {
backgroundColor: '#ffd34d',
},
label: {
color: '#ffffff',
fontSize: 13,
fontWeight: '700',
letterSpacing: 1,
},
labelOn: {
color: '#1a1a1a',
},
});
/**
* Settings button — opens the internal PanoramaSettingsModal. Gated
* on the `showSettingsButton` prop (default false) so public
* consumers don't see it.
*/
interface SettingsButtonProps {
onPress: () => void;
topInset: number;
}
function SettingsButton({ onPress, topInset }: SettingsButtonProps): React.JSX.Element {
return (
⚙
);
}
const settingsButtonStyles = StyleSheet.create({
container: {
position: 'absolute',
right: 14,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0,0,0,0.45)',
alignItems: 'center',
justifyContent: 'center',
},
glyph: {
color: '#ffffff',
fontSize: 22,
lineHeight: 24,
},
});
// ─── Main component ─────────────────────────────────────────────────
/**
* Effective capture source derived from arPreference + lens + the
* device's AR support. On a device without ARKit / ARCore, AR mode
* is unavailable regardless of the user's preference, and the AR
* toggle is hidden in the UI (see the bottom-bar JSX). Selecting
* the 0.5x lens also forces non-AR because ARKit / ARCore sessions
* don't expose the ultra-wide camera.
*/
function deriveEffectiveCaptureSource(
arPreference: boolean,
lens: CameraLens,
isARSupportedOnDevice: boolean,
): CaptureSource {
if (!isARSupportedOnDevice) return 'non-ar';
if (lens === '0.5x') return 'non-ar';
return arPreference ? 'ar' : 'non-ar';
}
/**
* Pluck the props that influence the initial PanoramaSettings tree.
* Kept inline (vs. a wide structural type) so future Camera prop
* additions don't accidentally widen the settings-translation
* surface — the pure builder in `./buildPanoramaInitialSettings.ts`
* has the canonical interface; this just forwards the relevant
* fields.
*
* The `default*ResolMP` props on `CameraProps` are documented as
* forward-looking no-ops; the new PanoramaSettings tree has no home
* for them yet (the v0.3 audit found cv::Stitcher's resol knobs
* aren't reached by either platform's bridge). They're accepted on
* the prop interface for API stability and ignored here.
*/
function extractPanoramaOverrides(props: CameraProps): PanoramaPropOverrides {
return {
defaultCaptureSource: props.defaultCaptureSource,
defaultStitchMode: props.defaultStitchMode,
defaultBlender: props.defaultBlender,
defaultWarper: props.defaultWarper,
defaultFlowNoveltyPercentile: props.defaultFlowNoveltyPercentile,
defaultFlowEvalEveryNFrames: props.defaultFlowEvalEveryNFrames,
defaultFlowMaxTranslationCm: props.defaultFlowMaxTranslationCm,
defaultKeyframeMaxCount: props.defaultKeyframeMaxCount,
defaultKeyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold,
defaultMaxKeyframeIntervalMs: props.defaultMaxKeyframeIntervalMs,
// v0.16 — JSON-object form (wins over the flat default* props above).
stitcher: props.stitcher,
frameSelection: props.frameSelection,
// v0.24 — the grouped blur / perf props.
blur: props.blur,
perf: props.perf,
// Item 2 — the interactive crop editor OWNS cropping, so when it's on we
// force the native auto-crop OFF: the editor needs the full un-cropped
// panorama (black borders included) so the user can drag the inscribed-
// rect seed outward to keep more content. Letting the native auto-crop
// pre-trim would leave nothing to adjust.
maxInscribedRectCrop: props.rectCrop
? false
: props.maxInscribedRectCrop,
};
}
// `toFileUri` (used to be an inline `toFileUri` here) lives in
// `../utils/paths.ts` so every call-site in this lib funnels through
// one canonical implementation. Native bridges return paths in
// mixed shapes — useCapture.compressedUri already has `file://`,
// while ARCameraView.takePhoto + IncrementalStitcher.finalize +
// `batchKeyframeThumbnailPath` events all return bare paths — and we
// normalise to the URI form on the way out to JS consumers (Android
// `` requires the scheme; iOS is lenient).
/**
* The public `` component.
*
* v0.20.0 — now a `forwardRef`. The ref exposes {@link CameraHandle} (the AR
* overlay methods); existing callers that don't pass a ref are unaffected
* (`forwardRef` makes the ref optional).
*/
export const Camera = forwardRef(function Camera(
props: CameraProps,
ref,
): React.JSX.Element {
const {
defaultCaptureSource = 'non-ar',
defaultLens = '1x',
captureSources = 'both',
enablePhotoMode = true,
enablePanoramaMode = true,
hideBuiltInShutter = false,
hideLensChipWhenSingle = false,
bottomBarOffset = 0,
showSettingsButton = false,
style,
outputDir,
shutterDisabled = false,
onCapture,
onCaptureSourceChange,
onLensChange,
onFramesDropped,
onError,
onCaptureAbandoned,
flash: controlledFlash,
onFlashChange,
showFlashButton = true,
headerTitle,
onHeaderBack,
headerBackLabel,
headerGuidance,
headerColors,
thumbnails,
thumbnailsMin,
thumbnailsMax,
onThumbnailPress,
capturePreview,
capturePreviewActions,
onCapturePreviewClose,
frameProcessor: hostFrameProcessor,
arFrameProcessor,
enableDepth,
highResCapture,
keyframeQualityCapture,
nativeUltraWideModels,
onRequestNativeUltraWide,
captureDepthData,
enableAnchors,
enableMesh,
enableFeaturePoints,
planeDetection,
onArFrame,
arFrameMetaInterval,
onArPluginResult,
overlays,
engine = 'batch-keyframe',
// ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────
panMode = 'vertical',
panGuidance = true,
maxPanDurationMs = 0,
panTooFastThreshold,
lateralBudgetCm = DEFAULT_LATERAL_BUDGET_CM,
lateralTurnRateRadPerSec,
lateralTurnGraceMs,
lateralTurnAngleDeg,
arLateralBudgetCm = DEFAULT_AR_LATERAL_BUDGET_CM,
arLateralRotDeg = DEFAULT_AR_LATERAL_ROT_DEG,
arLateralRatio = DEFAULT_AR_LATERAL_RATIO,
arLateralMaxCm = DEFAULT_AR_LATERAL_MAX_CM,
lateralMotionModel = DEFAULT_LATERAL_MOTION_MODEL,
panMotionDebug,
// No destructuring default on purpose: `undefined` → default is owned by
// `normaliseLateralStopFinalizeMinFrames`, alongside the negative/NaN
// normalisation, so the default lives in exactly one place.
lateralStopFinalizeMinFrames,
orientationDriftAbandon = true,
minPanoramaKeyframes = 1,
rectCrop = false,
showPreview = false,
guidanceCopy,
} = props;
// Derived guidance state. The landscape-only gate decision itself is
// computed inline at the call sites via `shouldGateForPanMode(panMode,
// deviceOrientation)` (the rotate gate + resume effect), so there's no
// standalone `modeAOnly` flag to keep in sync. `guidanceCopyResolved`
// merges the host override onto the defaults once per `guidanceCopy`
// identity.
const guidanceCopyResolved = useMemo(
() => mergeGuidanceCopy(guidanceCopy),
[guidanceCopy],
);
// v0.13.2 — capture-source constraint (default 'both'). Derives which
// sources are permitted; `captureSources` overrides any conflicting
// `defaultCaptureSource`. Used to constrain the initial AR preference
// and to hide the AR toggle / lens chooser below.
const arAllowed = captureSources !== 'non-ar';
const nonArAllowed = captureSources !== 'ar';
const arOnly = captureSources === 'ar';
const insets = useSafeAreaInsets();
// v0.12.0 — JS-layout orientation independent of device-physical.
// `useWindowDimensions().width > height` tells us if the OS
// rotated the framebuffer (only happens for non-locked hosts in
// device-landscape). Combined with `useDeviceOrientation()` to
// pick the JS edge corresponding to the home-indicator side of
// the device — see `homeIndicatorEdge` below.
const jsWindow = useWindowDimensions();
// Measured size of our own root view. `useWindowDimensions` freezes
// at its open-time value inside an iOS RN `Modal` (the modal rotates
// but no dimension-change event fires), so modal hosts would pin the
// controls to the wrong edge after rotation. `onLayout` on our root
// view is reliable in every container; the window dims are only the
// pre-first-layout fallback.
const [measuredRoot, setMeasuredRoot] = useState<{
width: number;
height: number;
} | null>(null);
const jsLandscape = measuredRoot
? measuredRoot.width > measuredRoot.height
: jsWindow.width > jsWindow.height;
// ── State ───────────────────────────────────────────────────────
// v0.13.2 — initial AR preference honours `defaultCaptureSource` but
// is clamped to the `captureSources` constraint: 'ar' forces on,
// 'non-ar' forces off, 'both' uses the default.
const [arPreference, setArPreference] = useState(
!arAllowed ? false : !nonArAllowed ? true : defaultCaptureSource === 'ar',
);
// v0.13.2 — `arOnly` forces the 1× lens (the ultra-wide isn't usable
// in AR), and the lens chooser is hidden in that mode.
const [lens, setLens] = useState(arOnly ? '1x' : defaultLens);
// v0.13.0 — flash state. Controlled by `controlledFlash` when the
// host supplies the `flash` prop; otherwise owned internally and
// toggled by the built-in flash button. `effectiveFlash` below
// also forces 'off' in AR mode (ARKit / ARCore own the device's
// torch and don't surface it through vision-camera's pipeline).
const [internalFlash, setInternalFlash] = useState<'on' | 'off'>('off');
const [settings, setSettings] = useState(() =>
buildPanoramaInitialSettings(
extractPanoramaOverrides(props),
isLowMemDevice(),
),
);
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
const [statusPhase, setStatusPhase] = useState('idle');
const [recordingStartedAt, setRecordingStartedAt] = useState(
null,
);
// perf-3a change 4: `incrementalState` useState + its subscription were
// removed — Camera renders from `incremental.state` (coalesced by the
// useIncrementalStitcher hook). See the thumbnail effect below.
// ── Panorama GUIDANCE state (feature/pano-ux-guidance) ──────────
// Item 1/2 — a hold that was BLOCKED on the rotate-to-landscape gate.
// Latches when the user holds the shutter in portrait under Mode A;
// an effect below resumes the capture the instant they rotate.
const [pendingPanStart, setPendingPanStart] = useState(false);
// Item 6 — the latched lateral-drift popup. `null` = hidden; a non-null
// value both SHOWS the popup and says which of the three outcomes fired,
// so the visibility latch and the copy selection can never disagree:
//
// 'finalized' capture kept + stitched (the historical default path)
// 'discarded' stitchable, but `lateralStopFinalizeMinFrames` binned
// it — capture abandoned, no output. At the default
// threshold of 5 this is the 2-to-4-keyframe band.
// 'wrong-direction' too few frames to stitch anything — capture abandoned
//
// Replaces the previous `lateralStopVisible` + `lateralWrongDirection`
// boolean pair (a third state would have needed a third boolean, and three
// booleans encode five combinations that cannot happen).
const [lateralStop, setLateralStop] =
useState(null);
// Title/body for whichever outcome latched. Resolved once here rather
// than twice in the modal's JSX; the `?? 'finalized'` is inert (the modal
// is hidden while `lateralStop` is null) and only keeps the copy helper
// total.
const lateralStopCopy = lateralStopCopyFor(
lateralStop ?? 'finalized',
guidanceCopyResolved,
);
// Item 3 — the brief pan how-to overlay shown at the start of a
// recording, auto-dismissed after a timeout.
const [howToVisible, setHowToVisible] = useState(false);
// Item 5 — a ~250 ms ticking clock that drives the displayed countdown
// seconds while recording (the authoritative auto-stop is a setTimeout,
// not this tick).
const [nowTick, setNowTick] = useState(() => Date.now());
// Item 7 — a finalized panorama awaiting the user's crop decision.
// Non-null mounts the RectCropPreview; `captureResultObj` is the exact
// CameraCaptureResult we'd otherwise have emitted, stashed so cancel /
// crop-confirm can emit it (possibly with cropped dims) afterwards.
const [cropPending, setCropPending] = useState<{
uri: string;
width: number;
height: number;
captureResultObj: PanoramaCaptureResult;
/**
* Item 2 — max-inscribed-rect seed for the crop quad (image-pixel
* coords). Undefined → RectCropPreview falls back to its 8 %-inset
* default seed (native module absent / inscribed-rect call failed).
*/
initialRect?: ImageRect;
/** Warnings to surface as a banner on the crop editor. */
warnings: CaptureWarning[];
} | null>(null);
// 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
// exposes an imperative API; we fire `showResult(finalizeResult)`
// on every successful finalize when settings.debug is on (gated
// a few hundred lines below in handleHoldEnd's onCapture branch).
const stitchToast = useStitchStatsToast();
// perf-3a change 4 — keyframe thumbnails are owned by the hook now
// (incremental.keyframeThumbnails); see keyframeThumbnailUris below.
const [cameraTransitioning, setCameraTransitioning] = useState(false);
// ARKit / ARCore device-support probe. `isAvailable` is `false`
// initially and becomes `true` after the native isSupported() check
// resolves (~50-200 ms after mount). Devices without ARKit / ARCore
// (older iPhones, ARCore-less Androids, simulators) stay `false`
// forever, which forces non-AR capture everywhere and hides the
// AR toggle in the bottom bar (see JSX below).
const { isAvailable: isARSupportedOnDevice, supportProbed: isARSupportProbed } =
useARSession();
const effectiveCaptureSource = deriveEffectiveCaptureSource(
arPreference,
lens,
isARSupportedOnDevice,
);
const isAR = effectiveCaptureSource === 'ar';
const isNonAR = !isAR;
// v0.14.2 — camera-handoff race guard. While AR is the preferred
// source but the one-shot `isSupported()` probe hasn't resolved yet,
// `deriveEffectiveCaptureSource` returns 'non-ar' (because
// `isARSupportedOnDevice` is still false), which would mount
// and let vision-camera's AVCaptureSession grab the
// camera. The switch to AR ~200-500ms later then fails with ARKit
// "Required sensor failed" (ARKit and AVCaptureSession can't share the
// camera), leaving a blank AR preview — intermittent and timing-
// dependent. Defer the initial mount until the probe settles: while
// pending we render the "Switching camera…" placeholder instead of any
// camera, so vision-camera never contends for the device when AR is the
// intent. Conditions mirror deriveEffectiveCaptureSource's own
// non-support gates (arPreference, lens) so this is true in exactly the
// cases that resolve to AR once support is confirmed.
const arSupportPending =
arPreference && lens !== '0.5x' && !isARSupportProbed;
const deviceOrientation = useDeviceOrientation();
// ── Panorama GUIDANCE — shared motion signals (item 3/4/6) ──────
// One gyro + one accelerometer subscription, live while a capture is
// recording. Feeds the too-fast pill (`panSpeedBucket`) and the lateral-
// drift FINALIZE (`lateralExceeded`). `panTooFastThreshold` (if set) tunes
// the 'warn'→'bad' boundary; `lateralBudgetCm` tunes the drift latch (0
// disables the latch in the hook).
//
// v0.24.5 — was gated `&& isNonAR`, which silently dropped BOTH pan warnings
// ("keep the pan straight" + "moving too fast") for AR captures. Hosts that
// default to AR (captureSources="ar") therefore lost all pan guidance. The
// gyro/accel are hardware sensors independent of the frame source (AR frames
// come from ARKit/ARCore, but device rotation is measured the same way), so
// the guidance is now active for AR captures too — the axis mapping already
// keys off deviceOrientation, not the capture source.
// v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
// pill, flash icon, thumbnails) so their labels read upright relative
// to gravity when the device is held landscape under a PORTRAIT-LOCKED
// host (the recommended config — the JS framebuffer stays portrait, so
// without this the labels render at 90°). Returns `{}` (no-op) in the
// common upright cases, including non-locked hosts where the OS already
// rotated the framebuffer. See `useContentRotation` truth table.
// Computed from `contentRotationDeg` directly (not the hook) so it
// uses the measured `jsLandscape` above — the hook's own context
// fallback only reaches descendants of the provider below.
const contentRotationDegree = contentRotationDeg(jsLandscape, deviceOrientation);
const contentRotation: ContentRotationStyle =
contentRotationDegree === 0
? {}
: { transform: [{ rotate: `${contentRotationDegree}deg` }] };
// ── Camera handoff gate ─────────────────────────────────────────
//
// The placeholder rendered while the underlying camera identity
// changes (AR toggle, lens swap). Without this gap, Android
// vision-camera v4 races the new session's open against the old
// session's teardown → "Session has been closed"
// IllegalStateException OR "Maximum cameras in use"
// CameraAccessException.
//
// CRITICAL: A naive useState + useEffect approach DOESN'T WORK.
// useEffect runs AFTER the commit phase — so on the render where
// isAR/lens flips, the effect hasn't yet set the gate flag, the
// render branch already evaluated `flag ? placeholder : camera`
// against the STALE flag=false → the new camera mounts in that
// commit → race → crash.
//
// Fix (mirrors AuditCaptureScreen.tsx ~L695-766): track the
// "last fully settled" identity in refs and compare them
// SYNCHRONOUSLY during render. The gate closes on the FIRST
// render where isAR/lens differs from the settled refs. The
// useEffect below does the async work (explicit AR session stop +
// 250 ms grace) and then updates the refs + clears the flag
// together to drop the gate.
const settledIsARRef = useRef(isAR);
const settledLensRef = useRef(lens);
const inFlightTransition =
settledIsARRef.current !== isAR
|| settledLensRef.current !== lens
|| cameraTransitioning;
// ── v0.13.1 — Android portrait lock ─────────────────────────────
//
// Android lets a mounted view force its host Activity's orientation,
// so `` guarantees a portrait capture surface regardless of
// the host app's manifest (even a landscape/unlocked host gets a
// portrait camera while `` is mounted). The lock lives on
// the Activity via the native `RNSARSession` module, so it covers
// BOTH the AR (ARCore) and non-AR (vision-camera) capture paths.
//
// iOS is intentionally NOT locked here: iOS supported orientations
// are a static Info.plist declaration the host owns, and we want iOS
// hosts to be able to support landscape/unlocked capture. Hosts that
// want a portrait-only iOS app set UISupportedInterfaceOrientations
// themselves.
//
// Empty dep array — lock on mount, restore the host's PRIOR
// orientation on unmount (the native side captures it).
useEffect(() => {
if (Platform.OS !== 'android') return undefined;
const arModule = (NativeModules as Record)
.RNSARSession as
| { lockPortrait?: () => void; unlockOrientation?: () => void }
| undefined;
arModule?.lockPortrait?.();
return () => {
arModule?.unlockOrientation?.();
};
}, []);
// keyframeQualityCapture also drives the ANDROID keyframe ENCODE budget
// (RNSARSession's holder refcount → YuvImageConverter): in NON-AR mode
// no ARCameraView is mounted to hold it, yet the non-AR keyframe writes
// read the same global budget. Acquire per Camera mount / release on
// unmount or prop-off — the native refcount makes the double-hold
// (this + a mounted ARCameraView's own effect) safe, and overlapping
// Camera swaps can't downgrade a live pan. iOS / old binaries: no-op.
useEffect(() => {
if (keyframeQualityCapture !== true) return undefined;
const arModule = (NativeModules as Record)
.RNSARSession as
| { setKeyframeQualityCaptureEnabled?: (on: boolean) => void }
| undefined;
arModule?.setKeyframeQualityCaptureEnabled?.(true);
return () => {
arModule?.setKeyframeQualityCaptureEnabled?.(false);
};
}, [keyframeQualityCapture]);
// ── Notify parent of capture-source changes ─────────────────────
const lastEmittedSourceRef = useRef(null);
useEffect(() => {
if (lastEmittedSourceRef.current !== effectiveCaptureSource) {
lastEmittedSourceRef.current = effectiveCaptureSource;
onCaptureSourceChange?.(effectiveCaptureSource);
}
}, [effectiveCaptureSource, onCaptureSourceChange]);
// ── Capture hooks ───────────────────────────────────────────────
// v0.13.2 — pass the active `lens` so useCapture uses capability-aware
// selection (multi-cam zoom-switch where available, standalone-ultra-
// wide swap otherwise). Replaces the old per-lens
// `preferredPhysicalDevice` request that mis-selected on some phones.
const capture = useCapture({
cameraPosition: 'back',
enableQualityChecks: false,
lens,
// iOS depth sidecar for non-AR tap photos; the matching below turns on depth delivery + the format bias.
captureDepthData,
});
// ── Lens chip availability ──────────────────────────────────────
// v0.13.2 — real device capability from `useCapture` (which uses
// `selectCaptureDevice`). True only when the device actually exposes
// an ultra-wide reachable via a multi-cam zoom OR a standalone
// ultra-wide device; false on wide-only hardware (chip hides).
const has0_5x = capture.has0_5x;
// Native-camera 0.5× fallback: only when the host opted in (a non-empty
// model list) AND this Android device has no in-app 0.5× AND its model
// matches. `Platform.constants` carries Model/Manufacturer on Android.
const offerNativeUW = useMemo(() => {
// Photo-only: the fallback captures ONE external still, so it must not
// sit where a 0.5× PANORAMA would be expected. A pano-only
// never shows it.
if (!enablePhotoMode) return false;
const c = (
Platform as unknown as {
constants?: { Model?: string; Manufacturer?: string };
}
).constants;
return shouldOfferNativeUltraWide({
hasInAppUltraWide: has0_5x,
models: nativeUltraWideModels,
platformOS: Platform.OS,
deviceModel: c?.Model,
deviceManufacturer: c?.Manufacturer,
});
}, [has0_5x, nativeUltraWideModels, enablePhotoMode]);
// App foreground state drives the non-AR preview's `isActive` — but ONLY
// when the native-0.5× fallback is being offered (see `isActive=` below);
// otherwise `isActive` stays the constant `true` it always was (the shipped
// capture flow is byte-identical). vision-camera 4.x does NOT observe the
// host activity lifecycle — its session is driven only by `isActive` — so
// when the OS camera launched by the fallback comes to the foreground we
// must proactively release the device and re-acquire on return; relying on
// CameraX's opportunistic reopen is unreliable on the Samsung OEM devices
// that are the native-UW target class (a swallowed
// `camera-has-been-disconnected` → silent black preview).
//
// Release ONLY on a true `'background'` — NOT on iOS's transient
// `'inactive'` (Control Center, the notification shade, a permission/Face-ID
// prompt, the app-switcher peek): those must NOT cycle the session, or the
// preview black-flashes mid-capture. The OS-camera hand-off backgrounds our
// activity, which is exactly `'background'`.
const [appActive, setAppActive] = useState(
AppState.currentState !== 'background',
);
useEffect(() => {
const sub = AppState.addEventListener('change', (s) =>
setAppActive(s !== 'background'),
);
return () => sub.remove();
}, []);
const incremental = useIncrementalStitcher();
// The IMU guard's cm readout is a DOUBLE integration of the accelerometer,
// which cannot separate a tilt from a translation: re-projected gravity is
// arithmetically identical to real acceleration on the cross axis. Measured
// 2026-08-26: a 6 deg wrist roll reads 5.18 cm while STATIONARY, and a real
// 100 cm slide reads 2.00 cm -- anti-correlated with the truth (r = -0.28).
//
// Two consequences, both fixed here:
//
// 1. In AR, ARKit's VIO pose measures the same quantity directly, and the
// AR guard below enforces it. Running the IMU guard as well lets the
// WORSE sensor veto the better one -- and because it has no warm-up
// gate it fired within the first second, at 0-1 keyframes, which is
// exactly the `wrong-direction` ("follow the arrow") bucket. So the
// IMU distance guard is OFF in AR; pose-based guards do that job.
//
// 2. In non-AR there is no pose to fall back on, so the guard stays -- but
// only once the capture HAS something to protect. Stopping at 0-1
// keyframes cannot save a sweep that does not exist yet, and it is the
// one path that produces the misleading "follow the arrow" popup.
// Arming at MIN_STITCHABLE_KEYFRAMES also re-seeds the accumulator (the
// hook resets on a budget change), so the phantom drift banked during
// warm-up is discarded rather than counted against the operator.
//
// `0` is the hook's existing "disabled" value for ALL THREE IMU/gyro
// triggers (distance, turn rate, turn angle), so no new prop is needed.
const imuGuardArmed =
(incremental.state?.acceptedCount ?? 0) >= MIN_STITCHABLE_KEYFRAMES;
const effectiveLateralBudgetCm = isAR || !imuGuardArmed ? 0 : lateralBudgetCm;
const panMotion = usePanMotion({
active: statusPhase === 'recording',
warnMaxRadPerSec: panTooFastThreshold,
lateralBudgetCm: effectiveLateralBudgetCm,
lateralTurnRateRadPerSec,
lateralTurnGraceMs,
lateralTurnAngleDeg,
lateralMotionModel,
panMotionDebug,
});
// ── AR ABSOLUTE cross-pan drift ────────────────────────────────────
// Measured from ARKit's VIO POSE, not the accelerometer. The IMU guard
// above cannot see slow drift by construction (its bias high-pass removes
// exactly the slow band real drift lives in), and on a 2026-08-26 device
// session its readings correlated with the stitcher's own image-derived
// translation at r = -0.28 — anti-correlated. A position needs no
// integration, so this one sees arbitrarily slow movement.
const arDriftRef = useRef(_freshArDriftState());
const [arDriftExceeded, setArDriftExceeded] = useState(false);
// ARM ON THE FIRST KEYFRAME, NOT ON THE HOLD.
//
// Field report: in AR, a minute lateral movement BEFORE the first frame
// lands immediately shows the "follow the arrow" copy, and feels far more
// sensitive than the same movement mid-capture.
//
// It is more consequential, not more sensitive. The detection threshold is
// identical; what differs is the OUTCOME, because `lateralStopOutcome` keys
// on keyframe count: 0-1 keyframes => 'wrong-direction' (the arrow copy),
// 2-4 => 'discarded', 5+ => 'finalized'. Any trip before the first frame
// therefore lands in the harshest bucket.
//
// And that window is not short in AR. Finalize restarts the AR session, so
// every capture opens with ARKit relocalising; the native gate sets
// `poseTrusted = (trackingState == .tracking)` per frame, and while it is
// false the translation-budget accept is gated off entirely
// (`keyframe_gate.cpp`: `translationBudgetCrossed` requires
// `s.poseTrusted`). So the first AR keyframe waits for tracking to settle —
// exactly the interval the operator is holding still and being judged in.
//
// Stopping a capture for drift before it has captured ANYTHING is also
// pointless: there is no sweep to protect. Arming on the first accepted
// keyframe seeds the origin where the pan actually begins, and has the side
// effect that a stop can no longer land in the 0-keyframe bucket from the
// warm-up alone.
const arDriftArmed = isAR
&& statusPhase === 'recording'
&& (incremental.state?.acceptedCount ?? 0) > 0;
// Reset on CAPTURE START, not on arming. `arDriftExceeded` is a latch that
// stops the capture, and it used to be cleared only here, gated on
// `arDriftArmed` -- which requires `acceptedCount > 0`. That is a deadlock:
// once the latch is set, the NEXT capture is stopped before it can accept a
// keyframe, so `arDriftArmed` never goes true, so the latch is never
// cleared, so the next capture is stopped... Device trace 2026-08-26:
//
// 01:28:15 rot 30.8deg > 25deg budget -> latched
// 01:28:18 ONE gyro sample, no keyframes -> dead on arrival
// 01:28:21 ONE gyro sample, no keyframes -> dead on arrival
// (only a JS reload cleared it)
//
// Keying the reset to `statusPhase === 'recording'` means every new capture
// starts from a clean latch whether or not the previous one produced
// frames. The ORIGIN still re-seeds lazily from the first TRUSTED pose
// inside `_advanceArDrift` (finalize restarts the AR session, so a capture
// opens with the tracker relocalising and those poses must not be anchored
// to) -- that part was never the problem, only the latch was.
useEffect(() => {
if (!isAR || statusPhase !== 'recording') return;
_resetArDriftState(arDriftRef.current);
setArDriftExceeded(false);
}, [isAR, statusPhase]);
const arLastEmitRef = useRef(0);
const handleArFrame = useCallback((meta: ARFrameMeta) => {
if (arDriftArmed && meta.pose) {
const s = _advanceArDrift(
arDriftRef.current,
meta.pose.rotation as unknown as readonly [number, number, number, number],
meta.pose.translation as unknown as readonly [number, number, number],
meta.trackingState,
panMode === 'horizontal' ? 'horizontal' : 'vertical',
arLateralBudgetCm / 100.0,
// Same dwell as both IMU triggers, so a momentary pose glitch or a
// brief lean does not end a capture.
AR_LATERAL_GRACE_MS,
Date.now(),
// ROTATION budget, but only when the pan axis is KNOWN. The guard
// measures azimuth for a vertical sweep and elevation for a
// horizontal one -- whichever axis the pan is NOT meant to move
// along. Under `panMode: 'both'` there is no such axis, and the
// ternary above silently resolves 'both' to 'vertical', so a
// deliberate left-to-right sweep reads as pure off-course rotation
// and gets stopped for doing exactly what it was told to do.
// Measured 2026-08-26: a real horizontal sweep ramps azimuth
// smoothly to 28.5 deg while a vertical sweep holds it under 2 deg
// -- indistinguishable from the rotation alone without knowing
// which the operator intended. So 'both' disables the rotation
// trigger (0 = off) and leaves the axis-agnostic DISTANCE guard.
panMode === 'both' ? 0 : (arLateralRotDeg * Math.PI) / 180,
arLateralRatio,
arLateralMaxCm / 100.0,
);
if (s.exceeded) setArDriftExceeded((p) => (p ? p : true));
const now = Date.now();
if (now - arLastEmitRef.current >= 250) {
arLastEmitRef.current = now;
if (panMotionDebug ?? __DEV__) {
// eslint-disable-next-line no-console
console.log(
`[panMotion.ar] drift=${(s.driftM * 100).toFixed(1)}cm `
+ `peak=${(s.peakM * 100).toFixed(1)}cm `
+ `long=${(s.longM * 100).toFixed(1)}cm `
+ `allow=${(s.allowanceM * 100).toFixed(1)}cm `
+ `floor=${arLateralBudgetCm}cm ratio=${arLateralRatio} `
+ `rot=${(s.rotRad * 180 / Math.PI).toFixed(1)}deg `
+ `peakRot=${(s.peakRotRad * 180 / Math.PI).toFixed(1)}deg `
+ `rotBudget=${arLateralRotDeg}deg by=${s.latchedBy} `
+ `mode=${panMode} track=${meta.trackingState} `
+ `untracked=${s.untrackedCount} degenerate=${s.degenerateCount} `
+ `exceeded=${s.exceeded}`,
);
}
}
}
onArFrame?.(meta);
}, [arDriftArmed, panMode, arLateralBudgetCm, arLateralRotDeg,
arLateralRatio, arLateralMaxCm,
onArFrame, panMotionDebug]);
const visionCameraRef = useRef(null);
const arViewRef = useRef(null);
// Latest `handleTap` (the shutter handler), kept in a ref so the imperative
// `takePhoto()` always calls the current closure. `handleTap` is declared
// far below (after the refs/state it closes over), so the imperative handle
// can't reference it directly without a TDZ error — the ref bridges that.
const handleTapRef = useRef<(() => Promise) | null>(null);
// Latest hold-start callback, kept in a ref so the imperative `startPanorama`
// can reach it (handleHoldStart is defined below the handle). Mirrors
// handleTapRef; handleHoldEndRef (the stop side) already exists further down.
const handleHoldStartRef = useRef<(() => void) | null>(null);
// v0.20.0 — AR overlay imperative handle. `` itself renders no
// overlay layer; the overlay methods forward to the mounted
// ``'s handle (which owns the controller + native dispatch).
// No-op when AR mode isn't mounted (`arViewRef.current === null`), matching
// the CameraHandle docstring — the declarative `overlays` prop is the path
// that survives AR↔non-AR transitions. The `overlays` prop is also threaded
// straight to `` below, so a host can use either API.
useImperativeHandle(ref, (): CameraHandle => ({
setOverlays: (o) => arViewRef.current?.setOverlays(o),
addOverlay: (o) => arViewRef.current?.addOverlay(o),
updateOverlay: (id, patch) => arViewRef.current?.updateOverlay(id, patch),
removeOverlay: (id) => arViewRef.current?.removeOverlay(id),
clearOverlays: () => arViewRef.current?.clearOverlays(),
raycast: () => arViewRef.current?.raycast() ?? Promise.resolve(null),
takePhoto: () => handleTapRef.current?.() ?? Promise.resolve(),
startPanorama: () => handleHoldStartRef.current?.(),
stopPanorama: () => {
handleHoldEndRef.current?.();
return Promise.resolve();
},
// Same state the built-in AR pill flips — see the interface docs.
setCaptureSource: (source) => setArPreference(source === 'ar'),
}), []);
// Effect that does the async transition work whenever the settled
// refs disagree with the current isAR/lens. Order matters:
// 1. Set the cameraTransitioning state so the gate stays closed
// after the synchronous compare flips back to "settled" once
// we update the refs.
// 2. Explicitly stop the AR session if we were in AR mode — this
// releases ARCore's grip on Camera2 BEFORE vision-camera tries
// to open it. Without this on Android the next openCamera()
// call hits "Maximum cameras in use". The promise is ignored
// if RNSARSession.stop fails or isn't available.
// 3. Wait 250 ms (Camera2's HAL onClosed is async; this gives it
// time to fully release the handle).
// 4. Update settled refs + clear cameraTransitioning together so
// the gate opens on the same commit.
//
// v0.25.1 — LATCH FIX. The settled/stuck/start decision is now the
// pure `cameraTransitionAction` (see cameraTransitionGate.ts); this
// effect only executes it. What it fixes: the old code early-returned
// whenever the refs already matched, which a flip-BACK inside the
// 250 ms grace turns into a permanent wedge —
// 1. Lens 1x -> 0.5x: refs still say 1x, so the gate closes
// (cameraTransitioning = true) and finishTransition is scheduled
// for +250 ms. The refs are NOT updated until that callback.
// 2. Inside that window the lens goes BACK 0.5x -> 1x. The cleanup
// sets cancelled = true, so the pending finishTransition no-ops
// and never reaches its setCameraTransitioning(false).
// 3. The effect re-runs, the (never-updated) refs now MATCH the
// current lens, and the old code early-returned.
// `setCameraTransitioning(false)` exists at exactly ONE site — inside
// the callback step 2 just cancelled — so nothing recovers the flag.
// It latches true forever: cameraShouldUnmount() stays true (the live
// camera never remounts; stuck on "Switching camera…") and
// holdShouldDeferForCamera() stays true (every hold defers into
// pendingPanStart, which handleHoldEnd cancels on release) — i.e. a
// permanently dead shutter.
//
// FOUND BY INSPECTION, NOT FIELD-REPRODUCED: the operator tried to
// wedge it by hand and could not — the flip-back must land inside the
// ~250 ms window, which is hard to hit deliberately on a lens chip.
// The failure sequence is a code-reading result, sound as an argument
// but unconfirmed on-device; the tests are what pin it.
//
// DEPS STAY [isAR, lens] — deliberately, even though the body now
// reads `cameraTransitioning`. The stuck state is only ever REACHED
// by an isAR/lens change that flips back to the settled value, so the
// effect is guaranteed to re-run at the exact moment recovery is
// needed, with the flag still true in that render's closure. Adding
// `cameraTransitioning` would be a REGRESSION, not a safety net: the
// start path sets it true, which would re-run this effect, cancel the
// settle it just scheduled, re-issue RNSARSession.stop() and restart
// the 250 ms grace on every transition. Clearing the flag re-renders
// but does NOT re-run the effect (neither dep changed), so recovery
// converges in exactly one extra commit and stops.
useEffect(() => {
const action = cameraTransitionAction(
settledIsARRef.current,
settledLensRef.current,
isAR,
lens,
cameraTransitioning,
);
if (action !== 'start-transition') {
// 'clear-stuck-flag' — a cancelled settle left the gate latched
// closed; this is the only exit. 'noop' writes NOTHING: an
// unconditional clear here would set state on every settled run.
if (action === 'clear-stuck-flag') setCameraTransitioning(false);
return undefined;
}
setCameraTransitioning(true);
let cancelled = false;
const finishTransition = () => {
if (cancelled) return;
settledIsARRef.current = isAR;
settledLensRef.current = lens;
setCameraTransitioning(false);
};
const wasAR = settledIsARRef.current;
const arModule = (NativeModules as Record).RNSARSession as
| { stop?: () => Promise }
| undefined;
const stopPromise: Promise =
wasAR && arModule?.stop ? arModule.stop() : Promise.resolve();
stopPromise
.catch(() => undefined)
.then(() => {
setTimeout(finishTransition, 250);
});
return () => { cancelled = true; };
}, [isAR, lens]);
// IMU translation gate — only engaged in non-AR mode. Fires when
// the operator's lateral hand motion exceeds the budget, telling
// the C++ engine to force-accept the next frame. This is what
// keeps non-AR captures producing keyframes at all (the flow-
// novelty algorithm alone is too strict in practice).
//
// 2026-05-22 (audit F2f) — IMU translation gate. The gate's own
// `totalAbsMetres` accumulator (banks each segment's |displacement|
// at every anchor reset) is the right input for the finalize-time
// auto-resolver in non-AR mode (where pose-derived translation is
// 0). Pre-F2f this was reconstructed from `fires × budget +
// |residual|` — which undercounted any time a non-IMU accept
// (flow novelty, force-last) reset the integrator before the
// budget threshold was reached.
// The translation budget lives at `frameSelection.flow.maxTranslationCm`
// in the new hierarchical settings shape. When `flow` is undefined
// (the consumer opted out of the flow strategy entirely), the gate
// stays disabled — same observable behaviour as v0.3's `0` default.
const flowMaxTranslationCm =
settings.frameSelection.flow?.maxTranslationCm ?? 0;
const imuGate = useIMUTranslationGate({
enabled:
isNonAR
&& statusPhase === 'recording'
&& flowMaxTranslationCm > 0,
budgetMeters: Math.max(0.001, flowMaxTranslationCm / 100.0),
onBudgetExceeded: () => {
const mod = getIncrementalNativeModule();
mod?.markNextFrameAsLastKeyframe?.().catch(() => undefined);
},
});
// Frame Processor driver for non-AR captures (iOS + Android).
// In AR mode the engine consumes frames from the ARSession stream
// natively, so this hook stays idle.
//
// IMPORTANT: start()/stop() are called imperatively from the hold
// handlers below — NOT from a useEffect driven by statusPhase. The
// hook returns a fresh object identity on every render, and during
// a recording the engine emits IncrementalStateUpdate events that
// cause re-renders multiple times per second. An effect with the
// driver in its deps would teardown + restart on every event,
// resetting the gyro accumulator (yaw/pitch) to zero each cycle.
// User-visible symptom: "only the first keyframe is accepted, every
// subsequent ingest sees pose=(0,0) and is rejected as a duplicate".
// The imperative pattern (start on hold-start, stop on hold-end)
// avoids the re-render churn entirely.
// perf-3a change 2 — pass the eval cadence so the WORKLET decimates
// (before packNV21), paired with the bridge forcing native cadence to 1
// for frameProcessor mode (see the incremental.start config below). The
// effective product cadence is unchanged from today's native-only throttle.
const fpDriver = useFrameProcessorDriver({
evalEveryNFrames:
settings.frameSelection.flow?.evalEveryNFrames ??
DEFAULT_FLOW_GATE_SETTINGS.evalEveryNFrames,
});
// Safety: stop the driver AND clear the pan-duration auto-finalize
// timer if the component unmounts mid-recording (item 5 exit path #4).
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => () => { fpDriver.stop(); clearPanTimer(); }, []);
// ── Panorama GUIDANCE — auto-finalize timer + ref bridges ───────
// The 9 s pan-duration ceiling (item 5) is an authoritative
// `setTimeout` (not derived from the cosmetic countdown tick). Stored
// in a ref so the start logic can schedule it and ALL four capture-exit
// paths (manual release, drift cancel, lateral stop, unmount) clear it.
const panDurationTimerRef = useRef | null>(
null,
);
const clearPanTimer = useCallback(() => {
if (panDurationTimerRef.current) {
clearTimeout(panDurationTimerRef.current);
panDurationTimerRef.current = null;
}
}, []);
// `handleHoldEnd` / `startCapture` are defined further down but are
// referenced from effects + timers declared above them. Refs break
// the declaration-order + circular-useCallback-dep cycle: each is
// kept current by a commit-phase effect, and callers invoke via the
// ref (`handleHoldEndRef.current?.()`) — mirroring how the drift
// effect avoids putting these in its dep array.
const handleHoldEndRef = useRef<(() => void) | null>(null);
const startCaptureRef = useRef<(() => void) | null>(null);
// Synchronous re-entrancy latch for the finalize path: the auto-finalize
// timer and a manual release can both pass the async statusPhase guard in
// the same tick before React commits 'stitching'.
const finalizingRef = useRef(false);
// Item 6 — set by the lateral-drift effect just before it calls
// handleHoldEnd, so the finalize knows this stop was a sideways-drift
// auto-stop and can attach the LATERAL_DRIFT_FINALIZE warning. Consumed
// (reset) at the start of handleHoldEnd so it never leaks to the next pan.
const lateralFinalizeRef = useRef(false);
// Item 4 — latched true if the pan ever exceeded the recommended pace (the
// live "too fast" cue fired) during the capture, so the finalize attaches a
// HIGH_PAN_SPEED warning. Reset at capture start; consumed at finalize.
const fastPanRef = useRef(false);
// ── v0.12.0 — Orientation drift detection + auto-abandon ────────
//
// The incremental engine supports both portrait (Mode B, horizontal
// pan) and landscape (Mode A, vertical pan) capture as first-class,
// but the docstring at `incremental.ts:373-403` is explicit that
// mixing them mid-capture is "best-effort, not supported" — the
// output rotation becomes ambiguous and the stitched panorama is
// malformed. v0.12 protects against this by snapshotting the
// orientation at `start()` and auto-cancelling the capture the
// instant the user rotates to a different orientation mid-flight.
//
// The modal is informational only — by the time it renders, the
// capture is already stopped. No Continue/Resume affordance per
// the engine spec.
const drift = useOrientationDrift(statusPhase === 'recording');
const [driftModalDismissed, setDriftModalDismissed] = useState(false);
// 2026-08-18 field RCA — these three surfaces are RN s, and on iOS
// only ONE view-controller presentation can be in flight at a time. A
// second overlapping present is REFUSED and leaves an invisible host window
// that eats every touch (the dead-shutter bug: the review surface mounted
// ~550 ms after the lateral popup latched). modalPresentation.ts owns the
// arbitration; these are the only three `visible` inputs, so the invariant
// is enforced in one place rather than at each call site.
const reviewSurfaceEnabled = rectCrop || showPreview;
const lateralPopupVisible =
lateralPopupShouldShow(lateralStop, reviewSurfaceEnabled);
const driftPopupVisible = drift.drifted && !driftModalDismissed;
const guidanceModalVisible = lateralPopupVisible || driftPopupVisible;
// Reset the modal flags when a new capture STARTS (statusPhase →
// 'recording'), NOT when one stops. v0.16 fix: the old "any non-recording
// state" condition cleared the lateral-stop latch the instant a lateral
// stop moved statusPhase out of 'recording' — so the popup was hidden
// before it could ever show (the user only saw the downstream error).
// Clearing on capture START instead lets the lateral / drift popups persist
// after the stop until the user dismisses them, while still giving the next
// capture a clean slate.
useEffect(() => {
if (statusPhase === 'recording') {
setDriftModalDismissed(false);
setLateralStop(null);
}
}, [statusPhase]);
useEffect(() => {
// v0.25 — host opt-out (`orientationDriftAbandon={false}`): skip the
// auto-abandon entirely; the capture continues best-effort across a
// rotation. The detector itself is separately sensor-trust hardened
// (no snapshot/compare before the first real accelerometer sample).
if (!orientationDriftAbandon) return;
if (!drift.drifted || statusPhase !== 'recording') return;
// Auto-abandon the in-flight capture. Order matches handleHoldEnd's
// "stitch" path but skips finalize:
// 1. Stop pumping frames so no new keyframes arrive mid-cancel.
// 2. Tell the native engine to drop accumulated state
// (`incremental.cancel()`).
// 3. Reset statusPhase back to idle.
// 4. Notify the host via `onCaptureAbandoned`.
//
// Wrapped in an IIFE because useEffect callbacks can't be async
// directly. Errors from `incremental.cancel()` are caught + sent
// through `onError` — abandonment must succeed even if the engine
// is in a weird state.
void (async () => {
// item 5 exit path #2 — kill the pan-duration auto-finalize timer
// so it can't fire into an already-cancelled capture.
clearPanTimer();
fpDriver.stop();
try {
await incremental.cancel();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
onError?.(new CameraError(
'PANORAMA_FINALIZE_FAILED',
`cancel after orientation drift failed: ${message}`,
err,
));
} finally {
setStatusPhase('idle');
setRecordingStartedAt(null);
onCaptureAbandoned?.('orientation-drift');
}
})();
// Deps: re-run whenever drift latches OR recording state changes.
// Other deps are stable refs / setters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drift.drifted, statusPhase, orientationDriftAbandon]);
// v0.8.0 Phase 5 / v0.11.0 — frameProcessor prop semantics:
//
// - Host supplied? → use host's processor. The host's worklet
// body controls whether first-party stitching also fires:
// call `stitcher.call(frame)` (from `useStitcherWorklet`)
// inside the body to compose; omit to replace. One-shot
// console.info documents the choice so the host can spot a
// missing `useStitcherWorklet` call before they go hunting
// for "why is non-AR panorama capture not producing output".
// AR-mode capture is unaffected either way — the AR-session
// dispatch path fans out to BOTH first-party stitching AND
// every host worklet independently.
//
// - No host processor? → use `fpDriver.frameProcessor` which is
// the lib's internal worklet driving first-party stitching
// via `useFrameProcessorDriver`. Default behaviour for the
// common "I just want panorama capture" case.
const hostFrameProcessorAcceptedWarnedRef = useRef(false);
if (
hostFrameProcessor != null
&& !hostFrameProcessorAcceptedWarnedRef.current
) {
hostFrameProcessorAcceptedWarnedRef.current = true;
// eslint-disable-next-line no-console
console.info(
'[react-native-image-stitcher] Host frameProcessor supplied — '
+ 'non-AR mode will run YOUR composed worklet. If you want '
+ 'first-party panorama stitching alongside your own logic, '
+ 'call `useStitcherWorklet()` and invoke `stitcher.call(frame)` '
+ 'from your worklet body (see `` `frameProcessor` '
+ 'JSDoc for the composition pattern). AR-mode capture is '
+ 'unaffected (AR-session dispatch fans out to both '
+ 'first-party and host worklets independently).',
);
}
// The Frame Processor worklet bound to vision-camera's Camera.
// Host's wins if supplied; lib's internal driver otherwise.
const effectiveFrameProcessor = hostFrameProcessor ?? fpDriver.frameProcessor;
// ── Keyframe thumbnails ──────────────────────────────────────────────
// perf-3a change 4: Camera.tsx no longer keeps its OWN
// `subscribeIncrementalState` + `incrementalState` useState (the SECOND
// per-event re-render of this large tree). It renders from the hook's
// coalesced `incremental.state`, and the keyframe thumbnails are now
// OWNED BY THE HOOK too (`incremental.keyframeThumbnails`) — accumulated
// per RAW accept event with a functional updater so two accepts in one
// React batch both survive (the earlier draft accumulated off the
// coalesced state and dropped one — 3a review finding). Normalise to
// `file://` here for the Android band overlay.
const keyframeThumbnailUris = useMemo(
() => incremental.keyframeThumbnails.map(toFileUri),
[incremental.keyframeThumbnails],
);
// 2026-05-23 (race fix) — Previously this useEffect cleared
// `batchKeyframeThumbnails` + `incrementalState` when statusPhase
// transitioned to 'recording'. But handleHoldStart is async
// (`await incremental.start(...)`), and on Android the ARSession
// was already alive on the GL thread — it could emit an ACCEPT
// event during the await window, BEFORE the effect ran. Order
// observed in logcat:
// 1. setStatusPhase('recording') queued
// 2. await incremental.start() yields
// 3. ARCore frame → ingest → JS [state] emit
// 4. setBatchKeyframeThumbnails((prev=[]) => [keyframe-0.jpg])
// 5. React commits statusPhase change → THIS effect ran
// 6. setBatchKeyframeThumbnails([]) ← WIPED frame 0!
// 7. Frame 1 arrives → updater sees prev=[] → adds only frame 1
// ⇒ final array missing keyframe-0.jpg
// The reset is now done synchronously at the top of
// handleHoldStart, before any await, so the GL thread can't race
// ahead. This effect is intentionally removed.
// 2026-05-22 (audit F2f) — every accepted keyframe is a fresh
// anchor for the IMU translation gate, regardless of which
// mechanism qualified the frame (flow novelty, plane-overlap,
// angular fallback, IMU-budget force-accept, force-last). Reset
// the gate's per-segment integrator on every acceptedCount
// increment so the operator sees `imuΔ` reset to 0 in the debug
// overlay after every accept — consistent UX regardless of WHY
// the gate took the frame. Pre-F2f only the IMU-budget path
// reset the integrator; flow accepts left `posX` ticking up
// forever, which surprised the user.
//
// The gate's `totalAbsMetres` cumulative accumulator banks the
// |segment displacement| before zeroing, so finalize-time
// translation magnitude is preserved across non-IMU accepts.
const lastAcceptedCountRef = useRef(0);
useEffect(() => {
const accepted = incremental.state?.acceptedCount ?? 0;
if (accepted > lastAcceptedCountRef.current) {
lastAcceptedCountRef.current = accepted;
// F8.3 review-of-review (M3 revert): an earlier draft gated
// this on the pre-v0.6 `legacyDriver` prop because the Frame
// Processor driver doesn't consult `imuGate` for its own pose
// synthesis. That ignored a load-bearing side effect:
// `imuGate.resetAnchor()` bounds the IIR-integrator drift
// window per-accept, and `imuGate.getTotalAbsMetres()` is read
// at finalize time as `imuTranslationMetres` into the native
// stitchMode auto-resolver (PANORAMA vs SCANS). Without the
// per-accept reset, long FP-driver captures let IIR drift
// compound → inflated metres → biased toward SCANS. Now fires
// for ALL non-AR captures (the only non-AR driver post-v0.6).
if (isNonAR) {
imuGate.resetAnchor();
}
} else if (accepted === 0) {
// New capture (state cleared) — reset our edge-detect ref.
lastAcceptedCountRef.current = 0;
}
}, [incremental.state?.acceptedCount, isNonAR, imuGate]);
// ── Shutter handlers ────────────────────────────────────────────
const handleTap = useCallback(async () => {
if (!enablePhotoMode || shutterDisabled) return;
try {
let uri: string;
let width: number;
let height: number;
// iOS captureDepthData — set by the NON-AR branch only (the AR
// path never produces a depth sidecar).
let depthPath: string | undefined;
let depthUnavailableReason: string | undefined;
// Compose the destination path BEFORE the capture so both the
// AR and non-AR branches land at the same predictable location.
// If `outputDir` is set, the lib lands the file at a host-
// controlled path; otherwise, in the lib's canonical capture
// dir (`/react-native-image-stitcher/photo-.jpg`).
const photoOutputPath = outputDir
? `${toBareFilePath(outputDir).replace(/\/$/, '')}/${defaultPhotoFilename()}`
: `${await getDefaultCaptureDir()}/${defaultPhotoFilename()}`;
if (isAR && arViewRef.current) {
// ARCameraView writes to its own tmp location; relocate to
// photoOutputPath via the native FileBridge so both branches
// return paths under the same dir.
// v0.12.0 — pass deviceOrientation so the AR takePhoto's
// native CIImage rotation matches the user's view. Pre-
// v0.12 the native side hardcoded portrait, so landscape
// photos came out sideways.
//
// 0.20.5 — for HIGH-RES document capture, force 'portrait' instead of
// the live gyro. Scanning holds the phone FLAT over the doc, so the
// accelerometer (deviceOrientation) is ambiguous and the FIRST shot
// after entering AR came out sideways (gyro hadn't settled). The
// doc-scan UI is portrait, so a fixed 'portrait' is the stable,
// WYSIWYG choice — the AR analogue of the non-AR `'preview'` path.
const photo = await arViewRef.current.takePhoto({
quality: 90,
orientation: highResCapture ? 'portrait' : deviceOrientation,
});
try {
await moveFile(photo.path, photoOutputPath);
} catch (moveErr) {
throw new CameraError(
'OUTPUT_WRITE_FAILED',
`Failed to move AR photo to ${photoOutputPath}. The destination `
+ 'directory must be writable.',
moveErr,
);
}
// Bake EXIF orientation into pixels (parity with the non-AR path,
// which does this inside useCapture.takePhoto). Android's AR
// takePhoto can return a file with an EXIF orientation tag over
// un-rotated pixels; RN's honours the tag but OpenCV
// (detectDocument / cropQuad) does NOT — so a downstream crop preview
// would be squished and the detected quad rotated 90°.
// normaliseOrientation re-encodes upright with no tag and returns the
// true post-rotation dims. No-op on already-upright files.
const arNorm = await normaliseOrientation(photoOutputPath, {
width: photo.width,
height: photo.height,
});
// Android needs the `file://` scheme to render the
// returned uri; iOS is OK either way. Normalise once here.
uri = toFileUri(photoOutputPath);
width = arNorm.width;
height = arNorm.height;
} else {
if (!visionCameraRef.current) {
throw new CameraError(
'CAMERA_DEVICE_UNAVAILABLE',
'vision-camera ref is not attached',
);
}
// useCapture.takePhoto wraps the cameraRef internally;
// attach via assignment so the hook's ref points at our
// local ref. This works because RefObject is just { current }.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(capture.cameraRef as any).current = visionCameraRef.current;
// useCapture handles the move internally; the returned
// `compressedUri` already points at `photoOutputPath`.
const result = await capture.takePhoto({ outputPath: photoOutputPath });
uri = result.compressedUri;
width = result.width;
height = result.height;
depthPath = result.depthPath;
depthUnavailableReason = result.depthUnavailableReason;
}
onCapture?.({
ok: true,
type: 'photo',
uri,
width,
height,
...(depthPath ? { depthPath } : {}),
...(depthUnavailableReason ? { depthUnavailableReason } : {}),
warnings: [],
});
} catch (err) {
const e = err instanceof CameraError
? err
: new CameraError(
'PHOTO_CAPTURE_FAILED',
err instanceof Error ? err.message : String(err),
err,
);
// v0.16 — failures now reach `onCapture` too (ok:false), with
// `onError` kept as a mirror so existing handlers keep working.
onError?.(e);
onCapture?.({ ok: false, type: 'photo', error: e, warnings: [] });
}
}, [enablePhotoMode, shutterDisabled, isAR, capture, outputDir, onCapture, onError]);
// ── startCapture — the "actually start recording" logic ─────────
// Extracted from `handleHoldStart` so the rotate-to-landscape gate
// (item 1/2) can DEFER it: a portrait Mode-A hold latches
// `pendingPanStart` and an effect calls this once the user rotates.
// Identical behaviour to the inline body it replaced — the only new
// line is the item-5 auto-finalize timer scheduled right after
// `setRecordingStartedAt`.
const startCapture = useCallback(async () => {
// v0.24.3 guard — a NON-AR capture ingests frames ONLY through the
// vision-camera frame-processor worklet. When the
// `cv_flow_gate_process_frame` plugin can never be acquired in this
// build, the capture would run its whole UI lifecycle with ZERO
// frames and die at finalize with a misleading "0 keyframes saved".
// Fail fast BEFORE the recording UI mounts, naming the real problem.
//
// Two deliberate details:
// - keyed on the DRIVER's acquisition state, not on
// `effectiveFrameProcessor`: a host that supplies its own
// `frameProcessor` prop still depends on the same plugin (its
// `stitcher.call()` is a no-op without it), so checking the
// composed prop would let exactly the broken build through;
// - gated on `acquisitionFailed` (PERMANENT) rather than "plugin
// not resolved yet": the plugin resolves ~1 frame after mount in
// healthy apps, and a capture started in that window succeeds —
// erroring there would regress programmatic `startPanorama()`
// calls fired from a host mount effect.
// AR captures are unaffected (the AR session feeds the engine natively).
if (isNonAR && fpDriver.acquisitionFailed) {
onError?.(
new CameraError(
'PANORAMA_START_FAILED',
'Non-AR panorama capture cannot start: the frame-processor '
+ 'worklet is unavailable — the "cv_flow_gate_process_frame" '
+ 'vision-camera plugin is not present in this build, so no '
+ 'camera frames can reach the stitching engine. Check '
+ 'vision-camera >= 4.7 with frame processors enabled '
+ '(react-native-worklets-core installed before the native '
+ 'build), then rebuild. See the console error from '
+ '[react-native-image-stitcher] and the docs: Host '
+ 'integration -> "Frame processors".',
),
);
return;
}
try {
// perf-3a change 4 — thumbnails + engine state are cleared inside the
// hook's start() (resetCoalescer + setState(null)) BEFORE its native
// start await, preserving the 2026-05-23 race fix: a frame the GL/FP
// thread ingests during the await window accumulates from the just-
// cleared array (functional updater) instead of being wiped by a
// post-await reset. So no synchronous clear is needed here.
// Item 4 — fresh capture: clear the latched too-fast flag.
fastPanRef.current = false;
// Item 6 — and the AR drift latch, SYNCHRONOUSLY, batched with the
// phase change below. Clearing it from an effect keyed on
// `statusPhase === 'recording'` is one render too late: the lateral-stop
// effect reads `arDriftExceeded` on the very render that turns
// `statusPhase` to 'recording', sees the PREVIOUS capture's latch, and
// ends this one before the reset effect has run. Device trace
// 2026-08-26 -- after each stop, exactly one capture died on arrival
// (a single sensor sample, no keyframes, no AR frames) and the one
// after it succeeded, which is the signature of a one-render stale read
// rather than a stuck latch. Batching it here means the render that
// first sees 'recording' also sees a cleared latch.
_resetArDriftState(arDriftRef.current);
setArDriftExceeded(false);
setStatusPhase('recording');
setRecordingStartedAt(Date.now());
// Item 5 — schedule the hard-ceiling auto-finalize. Fires
// `handleHoldEnd` (via ref to dodge the circular useCallback dep),
// which finalizes what's captured — the FINALIZE-on-zero product
// decision. Cleared on every other capture-exit path. Skipped
// when the feature is disabled (`maxPanDurationMs <= 0`).
clearPanTimer();
if (maxPanDurationMs > 0) {
panDurationTimerRef.current = setTimeout(() => {
handleHoldEndRef.current?.();
}, maxPanDurationMs);
}
const orientationRotation: 0 | 90 | 180 | 270 =
deviceOrientation === 'portrait' ? 90
: deviceOrientation === 'portrait-upside-down' ? 270
: 0;
// v0.4 — the inline-flat config dict that v0.3 maintained here
// moved into `panoramaSettingsToNativeConfig` (see
// PanoramaSettingsBridge.ts). That adapter is the single source
// of truth for the JS→native wire format; both this call site
// AND the modal's reset-to-defaults preview agree on the same
// mapping. Audit fixes F1 / F4 / F6 from v0.3 are now properties
// of the bridge (verified by the unit tests in
// src/camera/__tests__/PanoramaSettingsBridge.test.ts).
//
// 2026-05-23 — override `captureSource` with the runtime-derived
// `effectiveCaptureSource` (from `arPreference + lens +
// AR-device-support`). Pre-this change the camera-screen AR
// toggle wrote ONLY to local `arPreference` state while the
// bridge read `settings.captureSource` — so native could think
// the capture was AR while the operator had toggled it off (or
// vice-versa). Single source of truth now: whatever camera the
// operator can see is what native is told it is. The settings
// modal's `captureSource` control has been removed for the same
// reason — see PanoramaSettingsModal.tsx for the rationale.
//
// perf-3a change 1 (open early) — start the FP driver (non-AR) BEFORE
// the native start await, so its ingest gate opens and its cadence
// counter is anchored (via reset()) at the hold-start moment rather
// than one bridge round-trip later. Interval-containment guarantees
// pixel neutrality: the gate-open window strictly contains native's
// ingest-enabled window (native flips its AtomicBoolean INSIDE
// incremental.start), so no frame native would accept is gated;
// pre-enable strays are dropped by that AtomicBoolean anyway. On any
// start failure the catch below closes the gate. See docs/perf-3a §4.1.
if (isNonAR) {
fpDriver.start();
}
await incremental.start({
snapshotJpegQuality: 75,
snapshotEveryNAccepts: 1,
frameRotationDegrees: orientationRotation,
captureOrientation: deviceOrientation,
// Non-AR captures use the Frame Processor driver
// (vision-camera producer-thread worklet → cv_flow_gate
// plugin → IncrementalStitcher.consumeFrame). AR captures
// use the ARSession-driven path.
frameSourceMode: isNonAR ? 'frameProcessor' : 'arSession',
composeWidth: 1920,
composeHeight: 1080,
canvasWidth: 5000,
canvasHeight: 5000,
engine,
// perf-3a change 2 — pass the frame-source mode so the bridge emits
// flowEvalEveryNFrames=1 for the frameProcessor path: the worklet now
// does the decimation (before the ~3-4 MB packNV21 copy) and native's
// cadence is 1, so the effective product cadence is unchanged. AR mode
// keeps native-side decimation (AR frames never pass the worklet).
config: panoramaSettingsToNativeConfig(
{ ...settings, captureSource: effectiveCaptureSource },
{ frameSourceMode: isNonAR ? 'frameProcessor' : 'arSession' },
),
});
// F8.3 review-of-review (M3 revert): `imuGate.resetAnchor()`
// is load-bearing for the stitchMode auto-resolver (see the
// matching comment on the per-accept reset useEffect above).
// Keep firing it on every capture start, not just legacy mode.
imuGate.resetAnchor();
// perf-3a change 2 (review fix) — re-anchor the worklet decimation
// grid NOW that native has enabled ingestion. The gate was opened
// before this await (open-early, so no keyframe is lost), which let
// await-window frames advance the counter; re-zeroing it here anchors
// the {0,N,2N,…} grid at native-ingest-enable, matching native's old
// post-enable anchor → frame-identical decimation (not a phase offset).
if (isNonAR) {
fpDriver.resetCadence();
}
} catch (err) {
// perf-3a change 1 — native start failed: close the ingest gate we
// opened before the await (below), so a failed start doesn't leave the
// worklet feeding a not-started engine.
if (isNonAR) {
fpDriver.stop();
}
setStatusPhase('idle');
clearPanTimer();
onError?.(
new CameraError(
'PANORAMA_START_FAILED',
err instanceof Error ? err.message : String(err),
err,
),
);
}
}, [
incremental,
isNonAR,
deviceOrientation,
settings,
effectiveCaptureSource,
imuGate,
fpDriver,
engine,
onError,
maxPanDurationMs,
clearPanTimer,
]);
// Bridge the latest `handleTap` to the imperative `takePhoto()` (declared
// above the imperative handle, so it can't be referenced there directly).
// Assigning a ref during render is the canonical "latest callback" pattern.
handleTapRef.current = handleTap;
// Keep the ref current so the auto-finalize timer + the rotate-resume
// effect can invoke the latest `startCapture` without taking it as a
// dep (which would re-run them on every recording-driven re-render).
useEffect(() => {
startCaptureRef.current = () => { void startCapture(); };
});
// ── handleHoldStart — early guards + the rotate-to-landscape gate ─
// The "actually start" body lives in `startCapture`; this wrapper only
// decides WHETHER to start now. Under Mode A in portrait it latches
// `pendingPanStart` instead (item 1/2) and the resume effect below
// starts the capture once the user rotates to landscape.
const handleHoldStart = useCallback(() => {
// Gate symmetrically with handleTap (shutterDisabled) AND guard
// re-entrancy: the built-in shutter's phase machine prevents a
// double-start, but the imperative startPanorama() has no such machine, so
// a repeat call — or one while a capture is recording/stitching — would
// re-enter incremental.start() on a live engine.
if (!enablePanoramaMode || shutterDisabled) return;
if (statusPhase === 'recording' || statusPhase === 'stitching') return;
if (!incrementalStitcherIsAvailable()) {
onError?.(
new CameraError(
'PANORAMA_START_FAILED',
'Native incremental stitcher module not available',
),
);
return;
}
if (shouldGateForPanMode(panMode, deviceOrientation)) {
// Mode-A + portrait — block the start and show the rotate prompt.
// The resume effect picks this up the instant the device rotates.
setPendingPanStart(true);
return;
}
// v0.24.3 — no camera is mounted while the AR-support probe is still
// resolving (the v0.14.2 handoff guard renders the "Switching camera…"
// placeholder instead). Starting here would run a capture against no
// frame source and finalize with "0 keyframes saved". DEFER like the
// rotate gate: `pendingPanStart` resumes the start the moment the
// probe settles (the resume effect below re-evaluates both gates).
// v0.25 — ALSO defer while a camera transition is in flight. The
// render gate already unmounts the camera for `inFlightTransition`;
// starting a capture here anyway is what produced the resumed-into-
// a-dead-window failure described on `holdShouldDeferForCamera`.
if (holdShouldDeferForCamera(inFlightTransition, arSupportPending)) {
setPendingPanStart(true);
return;
}
void startCapture();
}, [
enablePanoramaMode,
shutterDisabled,
statusPhase,
onError,
panMode,
arSupportPending,
// v0.25 — read by holdShouldDeferForCamera above; without it this
// callback closes over a stale `false` and the new gate never fires.
inFlightTransition,
deviceOrientation,
startCapture,
]);
// ── Rotate-to-landscape resume (item 1/2) ───────────────────────
// When a hold was gated (`pendingPanStart`) and the user has since
// rotated so the gate no longer fires, start the deferred capture.
// Invoked through `startCaptureRef` (kept current above) so this
// effect's deps don't churn on every recording re-render.
useEffect(() => {
if (
pendingPanStart
&& !shouldGateForPanMode(panMode, deviceOrientation)
// v0.24.3 — also the "camera still initialising" defer (above).
// v0.25 — and the in-flight transition, without which this effect
// resumed the capture at the exact moment the camera unmounted.
&& !holdShouldDeferForCamera(inFlightTransition, arSupportPending)
) {
setPendingPanStart(false);
startCaptureRef.current?.();
}
}, [
pendingPanStart,
deviceOrientation,
panMode,
arSupportPending,
inFlightTransition,
]);
const handleHoldEnd = useCallback(async () => {
// Item 5 exit path #1 — always kill the auto-finalize timer on
// release, even on the early-return below (it's idempotent).
clearPanTimer();
// Item 1/2 — if the shutter is released while a rotate-gated hold is
// pending (user let go before rotating to landscape), abandon the
// deferred start rather than starting on the next rotation.
if (pendingPanStart) setPendingPanStart(false);
if (statusPhase !== 'recording') return;
// Re-entrancy latch — close the timer-vs-release double-finalize window
// synchronously so incremental.finalize()/onCapture fire exactly once.
if (finalizingRef.current) return;
finalizingRef.current = true;
// Consume the lateral-drift flag once, here, so it's cleared on BOTH the
// success and failure paths and never leaks into the next capture.
const wasLateralFinalize = lateralFinalizeRef.current;
lateralFinalizeRef.current = false;
const wasFastPan = fastPanRef.current;
fastPanRef.current = false;
if (__DEV__) {
// eslint-disable-next-line no-console
console.log(
`[capture] finalize: wasFastPan=${wasFastPan} `
+ `wasLateralFinalize=${wasLateralFinalize}`,
);
}
setStatusPhase('stitching');
// perf-3a change 1 — `fpDriver.stop()` moved from HERE to the finally
// below (close-late), so the ingest gate stays open through the 50 ms
// yield + finalize bridge hop (the window native still ingests). The
// unmount driven by statusPhase==='stitching' (just set)
// stops frame delivery within a frame or two regardless, so no frames
// race the stitch — the engine still isn't fed late keyframes.
// V12.14.8 restore (regressed in the SDK camera extraction): the
// render below unmounts / while
// statusPhase==='stitching'. Yield a macrotask so React commits that
// unmount and vision-camera tears down the AVCaptureSession + preview
// buffers (~150-250 MB) BEFORE the memory-heavy stitch runs. Without
// it the live-camera footprint and the stitch peak coexist and
// jetsam (iOS) / lmkd (Android) OOM-kill the app — the exact
// WatchdogTermination crash V12.14.8 originally fixed.
await new Promise((resolve) => setTimeout(resolve, 50));
try {
// Compose the panorama output path: host-controlled if
// `outputDir` is set, else the lib's canonical capture dir
// (`/react-native-image-stitcher/panorama-.jpg`).
// `incremental.finalize` writes the stitched JPEG straight to
// this path natively (no JS-side move needed for panoramas).
const panoOutputPath = outputDir
? `${toBareFilePath(outputDir).replace(/\/$/, '')}/${defaultPanoramaFilename()}`
: `${await getDefaultCaptureDir()}/${defaultPanoramaFilename()}`;
// 2026-05-22 (audit F2f) — total IMU translation directly from
// the gate's cumulative accumulator (banks |segment displacement|
// at every anchor reset, including non-IMU-driven resets like
// flow-novelty accepts). No more fires × budget + residual
// reconstruction. Only meaningful in non-AR mode (in AR the
// native side uses pose-derived translation and ignores this).
const imuTotalTranslationM =
isNonAR ? imuGate.getTotalAbsMetres() : 0;
const result = await incremental.finalize(
panoOutputPath,
90, // default JPEG quality
deviceOrientation,
imuTotalTranslationM,
lens, // 2026-06-16 — explicit '1x'|'0.5x' for the high-level warper tree
);
if (
typeof result.framesRequested === 'number'
&& typeof result.framesIncluded === 'number'
&& result.framesIncluded < result.framesRequested
) {
onFramesDropped?.({
requested: result.framesRequested,
included: result.framesIncluded,
});
}
// v0.16 — non-fatal quality signals attached to the result + (when
// the crop editor shows) the crop banner. LOW_FRAME_UTILIZATION when
// <70 % of captured frames survived; LATERAL_DRIFT_FINALIZE when item-6
// stopped this capture early.
const warnings = buildCaptureWarnings({
framesRequested: result.framesRequested,
framesIncluded: result.framesIncluded,
// v0.25 — judged from the FINALIZE result, not the live accepted
// count: the live count omits any keyframe whose sharpness window
// is still open at release (the trailing keyframe of nearly every
// capture) and differs between iOS and Android.
minPanoramaKeyframes,
lateralFinalize: wasLateralFinalize,
highPanSpeed: wasFastPan,
copy: captureWarningCopyFrom(guidanceCopyResolved),
});
const captureResultObj: PanoramaCaptureResult = {
ok: true,
type: 'panorama',
// Native finalize() returns a bare `/data/.../foo.jpg` path;
// normalise to `file://` for Android .
uri: toFileUri(result.panoramaPath),
width: result.width,
height: result.height,
framesRequested: result.framesRequested ?? -1,
framesIncluded: result.framesIncluded ?? -1,
framesDropped:
(result.framesRequested ?? 0) - (result.framesIncluded ?? 0),
finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
stitchModeResolved: result.stitchModeResolved,
rRadians: result.rRadians,
tMeters: result.tMeters,
decisionRatio: result.decisionRatio,
debugSummary: result.debugSummary,
keyframePaths: result.batchKeyframePaths,
captureOrientation: result.captureOrientation,
warnings,
};
// When the crop editor OR a plain preview is enabled AND the panorama
// has valid intrinsic dims, defer `onCapture`: stash the result and
// mount RectCropPreview (crop mode when `rectCrop`, preview-only when
// just `showPreview`). The modal's confirm / use-original / retake
// decision emits the final result. Otherwise emit immediately.
if (
(rectCrop || showPreview)
&& result.width > 0
&& result.height > 0
) {
// Crop mode only — seed the quad from the max-inscribed rectangle of
// the (un-cropped) panorama so the editor opens on the tightest clean
// rectangle, not a blind 8 % inset. Best-effort: an absent native
// module / decode failure falls back to the default seed. Skipped in
// preview-only mode (no quad to seed).
let initialRect: ImageRect | undefined;
if (rectCrop) {
try {
const inscribed = await computeInscribedRect(captureResultObj.uri);
if (inscribed && inscribed.width > 0 && inscribed.height > 0) {
initialRect = {
x: inscribed.x,
y: inscribed.y,
width: inscribed.width,
height: inscribed.height,
};
}
} catch {
// No seed — RectCropPreview uses its default inset.
}
}
setCropPending({
uri: captureResultObj.uri,
width: result.width,
height: result.height,
captureResultObj,
initialRect,
warnings,
});
} else {
onCapture?.(captureResultObj);
}
// 2026-05-22 (audit F9) — fire the debug stitch-stats toast on
// every successful finalize when settings.debug is on. Shows
// the leaveBiggestComponent retry telemetry + resolved mode so
// the operator can see what choice the auto-resolver made.
if (settings.debug) {
stitchToast.showResult(result);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Classify the raw native failure string → typed code. The chain
// lives in classifyStitchError() (the load-bearing C++↔JS contract,
// unit-tested against the actual native strings) so a future reword
// of a cpp throw can't silently drop the "pan more slowly" path.
const code = classifyStitchError(message);
const error = new CameraError(code, message, err);
// v0.16 — surface the failure on BOTH callbacks: `onError` (unchanged
// mirror) and `onCapture` (ok:false) so a host has one place to learn
// the outcome. A lateral-drift stop that then failed to stitch still
// reports that cause via the warning.
onError?.(error);
onCapture?.({
ok: false,
type: 'panorama',
error,
warnings: buildCaptureWarnings({
lateralFinalize: wasLateralFinalize,
highPanSpeed: wasFastPan,
copy: captureWarningCopyFrom(guidanceCopyResolved),
}),
});
} finally {
// perf-3a change 1 (close late) — stop the FP driver (→ close the
// ingest gate) AFTER finalize settles, not before the 50 ms yield.
// The gate-open interval must strictly CONTAIN native's ingest-enabled
// interval (native cuts ingest synchronously at the top of finalize):
// closing earlier would gate the 1-3 tail frames that native still
// ingests during the yield + finalize bridge hop and that feed the
// sharpness window / trailing keyframe → keyframe loss. No stitch-phase
// waste: unmounts at statusPhase==='stitching' (set above)
// so no frames reach the still-open gate during the multi-second stitch.
fpDriver.stop();
finalizingRef.current = false;
setStatusPhase('idle');
setRecordingStartedAt(null);
}
}, [
statusPhase,
incremental,
deviceOrientation,
onCapture,
onFramesDropped,
onError,
recordingStartedAt,
fpDriver,
// F10 Phase 2 review N1 — these four were missing pre-fix. The
// callback reads `settings.debug` (to gate the stitchToast),
// `isNonAR` (to decide whether to read IMU totalAbs translation),
// `imuGate` (the read itself), and `stitchToast` (the toast hook
// object). If any of those identities change between the user
// pressing-and-holding the shutter and the release, the stale-
// closure read could disagree with the actual current state.
// Pre-existing v0.3 bug; v0.4 was the natural time to address it.
settings,
isNonAR,
imuGate,
stitchToast,
// 2026-06-16 — the finalize passes `lens` (the high-level warper tree's zoom
// signal); without it here the closure would send a STALE lens if the user
// switched 1x↔0.5x after this callback was last memoized.
lens,
// feature/pano-ux-guidance — the release also tears down the
// pan-duration timer + a pending rotate-gate, and decides whether to
// route the result through the crop editor.
clearPanTimer,
pendingPanStart,
rectCrop,
showPreview,
]);
// Keep `handleHoldEndRef` current so the auto-finalize timer + the
// lateral-drift effect invoke the latest `handleHoldEnd` without
// adding it as a dep (it changes identity on every recording tick).
useEffect(() => {
handleHoldEndRef.current = () => { void handleHoldEnd(); };
// Assigned here (not with handleTapRef) because handleHoldStart is declared
// below that point — this site is after both hold handlers exist.
handleHoldStartRef.current = handleHoldStart;
});
// ── Item 6 — lateral drift → FINALIZE + popup ───────────────────
// Mirrors the orientation-drift effect, but FINALIZES the capture
// (keeps what was stitched) rather than cancelling it: clear the
// pan-duration timer, latch the popup, then call handleHoldEnd via
// its ref. Gated off when the budget is disabled (`<= 0`).
useEffect(() => {
if (
!(panMotion.lateralExceeded || arDriftExceeded)
|| statusPhase !== 'recording'
|| lateralBudgetCm <= 0
// v0.24.6 port — orientation-drift auto-cancel wins. A physical ~90°
// turn trips BOTH latches from the same accelerometer excursion; if this
// lateral FINALIZE also fired, the racing statusPhase
// recording→stitching→idle churn unmounts+remounts the live camera
// (cameraShouldUnmount is true for 'stitching') faster than the native
// camera can hand off — wedging the pipeline into an ANR (the field
// "freezes, nothing works" bug). Deferred ONLY when the drift-abandon
// effect will actually run (host hasn't opted out via
// orientationDriftAbandon={false}); with the opt-out active the drift
// latch must not suppress the lateral stop, or a turned capture would
// have no stop at all. See the mid-capture-freeze RCA.
|| (orientationDriftAbandon && drift.drifted)
) {
return;
}
clearPanTimer();
// Whether a lateral stop KEEPS what was captured is the HOST's call, via
// `lateralStopFinalizeMinFrames`. Its default is 5, NOT the 2 this
// branch used to hardcode as MIN_STITCHABLE_KEYFRAMES: a 2-to-4-frame
// remnant of a drifted sweep is waste for shelf capture, so it now
// discards. The arithmetic lives in lateralStopPolicy.ts — including
// the `0` = ALWAYS-DISCARD special case, which is emphatically NOT the
// `count >= 0` a naive comparison would give — so it is unit-testable
// without mounting a render. Three outcomes:
//
// 'finalized' keep + stitch the partial sweep; handleHoldEnd
// attaches the LATERAL_DRIFT_FINALIZE warning.
// 'discarded' stitchable, but policy binned it (at the default
// threshold of 5: the 2-to-4-keyframe band).
// 'wrong-direction' #3 — the user veered off before enough frames were
// captured to stitch anything. Finalizing that would
// fail with a misleading "need more images" error,
// which is why this branch predates the policy.
//
// BOTH discard outcomes take the identical ABANDON path (no stitch → no
// error) and must NOT set `lateralFinalizeRef`: there is no result to
// hang a warning on, and a leaked flag would mislabel the NEXT capture.
const outcome = classifyLateralStop(
acceptedKeyframeCount,
lateralStopFinalizeMinFrames,
);
setLateralStop(outcome);
if (outcome !== 'finalized') {
void (async () => {
fpDriver.stop();
try {
await incremental.cancel();
} catch {
// best-effort — abandonment must succeed even in a weird state.
} finally {
setStatusPhase('idle');
setRecordingStartedAt(null);
onCaptureAbandoned?.('lateral-drift');
}
})();
return;
}
// Mark this finalize as lateral-drift-triggered so handleHoldEnd attaches
// the LATERAL_DRIFT_FINALIZE warning to the result.
lateralFinalizeRef.current = true;
handleHoldEndRef.current?.();
// Deps mirror the drift effect: re-run when the latch trips or the
// recording state changes. Other reads are stable setters / refs, plus
// two deliberate non-deps — `acceptedKeyframeCount` and
// `lateralStopFinalizeMinFrames`. The effect BODY is rebuilt every
// render, so the run that the latch triggers already closes over the
// current values of both; listing them would instead let a mid-capture
// prop/count change re-enter this stop and abandon twice.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panMotion.lateralExceeded, arDriftExceeded, statusPhase, lateralBudgetCm,
drift.drifted, orientationDriftAbandon]);
// ── Item 7 — auto-finalize when the configured keyframe count is hit ─
// The engine caps accepted keyframes at `keyframeMaxCount`; once it
// reports that many, no more frames will be accepted, so stop + stitch
// (same finalize path as releasing the shutter). `handleHoldEnd`'s
// re-entrancy latch makes this idempotent vs. a manual release in the
// same tick. This is the PRIMARY auto-stop (the time cap is opt-in).
const keyframeMaxCount = settings.frameSelection.maxKeyframes;
const acceptedKeyframeCount = incremental.state?.acceptedCount ?? 0;
// Item 4 — speed cue routed into the REC banner/border colour (green→red).
// Gated on panGuidance so opting out keeps the banner calm/green.
const recordingTooFast =
panGuidance && panMotion.panSpeedBucket !== 'good';
// Latch the too-fast flag for the HIGH_PAN_SPEED warning (shown on the crop
// editor + returned in onCapture.warnings). Latches when the live cue is
// active OR — per the user's request — when a KEYFRAME the stitch will use
// is accepted while the pan is too fast (so a captured frame was actually
// taken at speed). Depending on panSpeedBucket + acceptedKeyframeCount
// directly (not just the derived `recordingTooFast`) makes the effect run
// on every bucket / keyframe change, so a brief red window can't be missed.
// Reset at capture start.
const prevAcceptedForSpeedRef = useRef(0);
useEffect(() => {
if (statusPhase !== 'recording') {
prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
return;
}
const newKeyframe = acceptedKeyframeCount > prevAcceptedForSpeedRef.current;
prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
if (recordingTooFast) {
if (__DEV__ && !fastPanRef.current) {
// eslint-disable-next-line no-console
console.log(
`[panMotion] HIGH_PAN_SPEED latched (bucket=`
+ `${panMotion.panSpeedBucket} acceptedCount=${acceptedKeyframeCount}`
+ `${newKeyframe ? ' on a keyframe' : ''})`,
);
}
fastPanRef.current = true;
}
}, [
statusPhase,
recordingTooFast,
acceptedKeyframeCount,
panMotion.panSpeedBucket,
]);
useEffect(() => {
if (
statusPhase === 'recording'
&& keyframeMaxCount > 0
&& acceptedKeyframeCount >= keyframeMaxCount
) {
handleHoldEndRef.current?.();
}
}, [statusPhase, keyframeMaxCount, acceptedKeyframeCount]);
// ── Item 3 — brief pan how-to overlay at recording start ────────
// Show the how-to GIF + direction arrow for a short window when a
// recording begins, then auto-fade. The component never self-times;
// this effect owns the lifecycle.
useEffect(() => {
if (statusPhase !== 'recording') {
setHowToVisible(false);
return;
}
setHowToVisible(true);
const t = setTimeout(() => setHowToVisible(false), 2500);
return () => clearTimeout(t);
}, [statusPhase]);
// ── Item 5 — cosmetic countdown tick ────────────────────────────
// While recording, bump `nowTick` ~4×/s so `countdownSecondsFrom`
// recomputes the displayed whole-seconds. The authoritative auto-stop
// is the `panDurationTimerRef` setTimeout, NOT this interval. Skipped
// when the countdown feature is disabled (`maxPanDurationMs <= 0`).
useEffect(() => {
if (statusPhase !== 'recording' || maxPanDurationMs <= 0) return;
const id = setInterval(() => setNowTick(Date.now()), 250);
return () => clearInterval(id);
}, [statusPhase, maxPanDurationMs]);
// Whole seconds remaining for the countdown overlay (item 5). Pure
// helper; clamps to [0, round(maxPanDurationMs/1000)].
const countdownSeconds = countdownSecondsFrom(
recordingStartedAt,
nowTick,
maxPanDurationMs,
);
// ── Lens / AR-toggle handlers ───────────────────────────────────
const handleLensChange = useCallback((next: CameraLens) => {
setLens(next);
onLensChange?.(next);
}, [onLensChange]);
const handleARToggle = useCallback(() => {
setArPreference((prev) => !prev);
}, []);
// ── v0.13.0 — Flash control ─────────────────────────────────────
//
// `flashRequested` is what the host / built-in button asks for.
// `effectiveFlash` is what we drive into vision-camera (non-AR). AR
// mode forces 'off' (flash is hidden in AR; ARKit/ARCore own the
// device) so vision-camera — which isn't the active camera in AR —
// doesn't fight for it.
//
// v0.13.1 — the ACTIVE device's torch capability is the source of
// truth. The ultra-wide (0.5×) lens has no flash/torch unit on most
// phones, so vision-camera throws `flash-not-available` if we pass
// flash="on" while it's selected. `capture.device.hasTorch` (from
// vision-camera's device list) tells us definitively; we hide the
// flash control and force 'off' when the device can't flash.
// v0.13.2 — `capture.deviceHasTorch` reflects the MOUNTED device. In
// multi-cam mode this is the multi-cam device (has a torch → flash
// works on both 1× and 0.5× via zoom). In standalone-uw mode on 0.5×
// the mounted device is the torchless ultra-wide → flash hides.
const deviceHasTorch = capture.deviceHasTorch;
const flashRequested: 'on' | 'off' = controlledFlash ?? internalFlash;
const effectiveFlash: 'on' | 'off' =
isAR || !deviceHasTorch ? 'off' : flashRequested;
const toggleFlash = useCallback(() => {
const next: 'on' | 'off' = flashRequested === 'on' ? 'off' : 'on';
if (controlledFlash == null) setInternalFlash(next);
onFlashChange?.(next);
}, [flashRequested, controlledFlash, onFlashChange]);
// v0.13.1 — top-right control pills (flash + AR) stack vertically
// UNDER the settings affordance. Anchor depends on what's above:
// - headerTitle set → pills clear the CaptureHeader bar
// (title row ≈ topInset + ~36; guidance pill adds ~28 when present)
// - standalone gear → pills clear the 40px gear at topInset + 8
// - neither → pills start where the gear would be
const pillStackTop =
headerTitle != null
? insets.top + (headerGuidance != null ? 72 : 40)
: showSettingsButton
? insets.top + 8 + 44
: insets.top + 8;
// ── JSX ─────────────────────────────────────────────────────────
return (
{
const { width, height } = e.nativeEvent.layout;
if (width <= 0 || height <= 0) return;
setMeasuredRoot((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
}}
>
{/* Preview — AR or non-AR (or the brief "switching…" placeholder
while the previous session tears down). Conditional mount so
only ONE camera component is alive at a time; matches the
monorepo's working pattern and avoids the Camera2-in-use
conflict that "always mount both" caused on Android. */}
{cameraShouldUnmount(inFlightTransition, arSupportPending, statusPhase) ? (
// statusPhase==='stitching' UNMOUNTS the camera so vision-camera
// frees the AVCaptureSession + preview buffers during the stitch
// (V12.14.8 OOM fix). The CaptureStatusOverlay renders the
// "Stitching…" state on top, so no placeholder label is needed
// in that case — only for the camera-switch transition.
{statusPhase === 'stitching' ? null : (
Switching camera…
)}
) : isAR ? (
) : (
{
// CameraView already filters known transient lifecycle
// errors (screen-lock, etc.) before invoking this. What
// reaches here is a real vision-camera runtime issue:
// pull `code`/`message` defensively (the type is
// `unknown` from CameraView's perspective) and wrap in
// a SDK-typed `CameraError` so hosts get a stable shape.
const e = err as { code?: string; message?: string };
const codeStr = e?.code ?? 'unknown';
const msg = e?.message ?? String(err);
onError?.(new CameraError(
'VISION_CAMERA_RUNTIME',
`${codeStr}: ${msg}`,
err,
));
}}
/>
)}
{/* REC banner + record border (during recording / stitching). v0.16
— the banner + border are GREEN normally and turn RED (with the
too-fast copy) when the pan is too fast: the single speed cue that
replaced the always-red border + separate amber pill. */}
{/* v0.13.1 — the built-in pan-guidance overlays
(IncrementalPanGuide drift marker + PanoramaGuidance speed
pill) were removed from the public surface. They remain in
the tree as internal-only components but no longer
renders them and the `panGuide` / `panoramaGuidance` props
are gone. Re-wire here if a host need resurfaces. */}
{/* feature/pano-ux-guidance — in-capture guidance overlays.
All gated on `panGuidance`; each renders null when not
visible so they can mount unconditionally. */}
{/* Item 6 — live keyframe counter "k / n" (top-centre). The primary
capture HUD; the capture auto-finalizes when k reaches n. */}
{/* Item 5 — optional blinking time countdown (top corner), shown only
when the host opts into a wall-clock cap via maxPanDurationMs. */}
0}
secondsRemaining={countdownSeconds}
orientation={deviceOrientation}
/>
{/* Item 3 — brief pan how-to graphic + direction arrow. */}
{/* Item 4 — "moving too fast" feedback is no longer a separate pill.
v0.16 — it's consolidated into the CaptureStatusOverlay banner +
border above, which turn from GREEN to RED (with the too-fast copy)
when `recordingTooFast` — one calm cue instead of an always-red
border plus a second amber pill. */}
{/*
2026-05-22 (audit F9 + F3) — debug UI suite, all gated on
settings.debug. Mounts in automatically; Layer-2
hosts can import the individual components from the public
API and compose their own debug surface. Layout:
- top-left: orientation pill (purple)
- top-center: keyframes pill (green/amber)
- top-right: memory pill (green/amber/red)
- top-center: stitch-stats toast (dark capsule, transient)
- left-mid: detailed metrics block (overlap, processing,
imuΔ, etc.) — uses CaptureDebugOverlay
*/}
{settings.debug && (
<>
>
)}
{/* Toast renders regardless of `settings.debug` — toast hook
* is only ever fired from the debug-gated path, but mounting
* unconditionally lets Layer-2 hosts wire their own showFor()
* callers without needing a separate mount. */}
{/* v0.13.0 — built-in CaptureHeader, gated on `headerTitle`.
When the header is mounted, it absorbs the settings gear
on its right side (avoids stacking with the standalone
gear). Hosts that DON'T set `headerTitle` get the legacy
standalone gear, still gated on `showSettingsButton`. */}
{headerTitle != null ? (
setSettingsModalVisible(true)
: undefined
}
/>
) : (
showSettingsButton && (
setSettingsModalVisible(true)}
/>
)
)}
{/*
v0.12.0 — Orientation-aware bottom controls anchored to the
physical home-indicator edge. The shutter follows the home-
indicator regardless of host portrait-lock state:
- locked + any device → JS-bottom (locked
framebuffer maps device-bottom to JS-bottom always)
- non-locked + device-portrait → JS-bottom
- non-locked + device-landscape-L → JS-right
- non-locked + device-landscape-R → JS-left
Computed in `homeIndicatorEdge` which combines `jsLandscape`
(from window dims) with `deviceOrientation` (sensor).
*/}
{/* Live-frame band — only visible while recording. `vertical`
is true when the home-indicator anchor is on a side edge
(left or right), in which case the band is a vertical
column. Otherwise it's a horizontal strip. */}
{statusPhase === 'recording' && (
)}
{/* v0.13.0 — built-in capture-history thumbnail strip. Lives
INSIDE the orientation-aware bottomArea container so it
rides along to the home-indicator edge in landscape rather
than sitting at a hard-coded `bottom: 160` mid-screen.
Hidden during recording so the PanoramaBandOverlay above
it has room without overlap. Strip is intrinsically
horizontal; v0.13.1 will add orientation-aware rotation
for the thumbnails + tablet "user-bottom" placement. */}
{thumbnails != null && statusPhase !== 'recording' && (
)}
{/* Shutter row. Horizontal row when home-indicator is on
top/bottom (lens left / shutter center / AR right);
vertical column when on left/right (slots stack along
the narrow strip). Touch targets stay axis-aligned. */}
0 && { transform: [{ translateY: -bottomBarOffset }] },
]}
>
{/* v0.13.1 — flash + AR moved to the top-right pill stack (see
below). Left/right slots stay as flex spacers so the shutter
+ lens chip remain centred. */}
{/* v0.13.2 — lens chooser hidden in AR-only mode (ARKit/ARCore
can't use the ultra-wide, so there's nothing to choose). */}
{!arOnly && (
)}
{!hideBuiltInShutter && (
)}
{/* v0.13.1 — top-right control pill stack, anchored UNDER the
settings affordance. Vertical column; pills match the AR
toggle's shape. ORDER MATTERS: AR pill is FIRST (top) so it
stays anchored when the flash pill below it shows/hides
(flash is hidden in AR mode, and when the active device has no
torch — e.g. the ultra-wide 0.5× lens). AR toggle shows only
when the lens is 1× (ARKit/ARCore don't expose the ultra-wide)
and the device supports AR. */}
{/* v0.13.2 — AR toggle only when BOTH sources are allowed
(captureSources='both'); a single-source constraint has
nothing to toggle. Still gated on 1× + device AR support. */}
{!hideBuiltInShutter && arAllowed && nonArAllowed && lens === '1x' && isARSupportedOnDevice && (
)}
{showFlashButton && !isAR && deviceHasTorch && (
⚡
)}
{/* Settings modal (rendered always, visible-gated). */}
setSettingsModalVisible(false)}
/>
{/* Item 1/2 — rotate prompt. Shown while a gated hold is blocked on
the user rotating to the target orientation (landscape for
panMode='vertical', portrait for 'horizontal'). The resume effect
starts the deferred capture the instant they do. */}
{/* The rotate prompt is the ONLY feedback for the mode gate, so it is
NOT gated on `panGuidance` — otherwise panGuidance={false} +
a gated panMode would block the hold with a dead, silent shutter.
`panGuidance` governs only the cosmetic in-capture overlays. */}
{/* v0.12.0 — Orientation drift modal. Shows AFTER the SDK has
auto-abandoned the capture (the useEffect above stops the
engine + transitions to idle + fires onCaptureAbandoned).
Modal exists purely to explain WHY the capture was
cancelled. Single OK button (no Continue) per the engine
spec on cross-mode capture being best-effort, not supported. */}
setDriftModalDismissed(true)}
/>
{/* Item 6 — lateral-drift popup. Latched by the lateral effect AFTER
it has already finalized OR abandoned the capture; informational
only, and dismiss just clears the latch so the next capture starts
fresh. Which of the three outcomes fired picks the copy — see
`lateralStopCopyFor`, which exists so the "we stitched what you
captured" body can never be paired with a discarded capture. */}
setLateralStop(null)}
/>
{/* v0.13.0 — built-in post-stitch / tap-to-preview modal.
Visible when the host supplies `capturePreview`. When
undefined the modal stays hidden (visible=false) so it
doesn't intercept touches. Host is expected to clear
`capturePreview` via `onCapturePreviewClose` on dismiss. */}
{/* Post-capture review surface, shown after a panorama finalizes when
`rectCrop` OR `showPreview` is on (handleHoldEnd stashed the pending
result instead of emitting it). `showCropControls={rectCrop}`:
- crop mode (rectCrop) → draggable quad seeded on the max-inscribed
rectangle; any capture warnings banner on top.
- Use original → emit the original, un-cropped panorama.
- Crop → cropQuad (perspective-rectify when the quad
isn't axis-aligned) overwrites the file in place; emit with
the rectified dims + a cache-busting query so reloads
it. On any crop failure, fall back to the original.
- preview-only mode (showPreview, no rectCrop) → bare image with
[Retake]/[Confirm]; Confirm routes through onUseOriginal. */}
w.message) ?? []}
showCropControls={rectCrop}
topInset={insets.top}
bottomInset={insets.bottom}
copy={guidanceCopyResolved}
// Carry the live memory pill onto the preview too (same settings.debug
// gate as the camera), so the operator can watch the RSS spike when the
// on-demand high-level re-stitch fires.
showMemoryPill={settings.debug}
// DEV overlay — show the stitcher's runtime choices (pipeline / warper /
// route / seam / blend) + score / frames / size for this output, so the
// operator can see HOW it was built. __DEV__ only.
debugInfo={
__DEV__ && cropPending
? buildStitchDebugInfo(cropPending.captureResultObj)
: undefined
}
onUseOriginal={(altUri) => {
if (cropPending) {
// altUri set → the user picked the alt (manual) pipeline's output
// in the A/B toggle; emit THAT image (cache-bust for ).
onCapture?.(
altUri
? {
...cropPending.captureResultObj,
uri: `${altUri}?t=${Date.now()}`,
}
: cropPending.captureResultObj,
);
}
setCropPending(null);
}}
onRetake={() => {
// Discard this capture entirely — no onCapture — and return to
// the live camera (statusPhase is already 'idle' post-finalize).
setCropPending(null);
}}
onConfirm={async ({ quad, perspective }) => {
if (!cropPending) return;
const pending = cropPending;
// perspective=true → rectify the dragged quad to an upright
// rectangle (cropToQuad). perspective=false (the user dragged a
// ~rectangular quad) → crop to the quad's axis-aligned bounding box
// — a plain crop, no warp.
const xs = quad.map((p) => p.x);
const ys = quad.map((p) => p.y);
const cropPoints: Quad = perspective
? quad
: [
{ x: Math.min(...xs), y: Math.min(...ys) },
{ x: Math.max(...xs), y: Math.min(...ys) },
{ x: Math.max(...xs), y: Math.max(...ys) },
{ x: Math.min(...xs), y: Math.max(...ys) },
];
try {
// cropQuad takes a BARE path; the stashed uri is a file://
// URI. Overwrites in place (pass the same path).
const cropped = await cropQuad(
toBareFilePath(pending.uri),
cropPoints,
undefined,
{ quality: 90 },
);
onCapture?.({
...pending.captureResultObj,
// Cache-bust so reloads the overwritten file.
uri: `${toFileUri(cropped.outputPath)}?t=${Date.now()}`,
width: cropped.width,
height: cropped.height,
});
} catch (err) {
onError?.(
new CameraError(
'OUTPUT_WRITE_FAILED',
err instanceof Error ? err.message : String(err),
err,
),
);
// Fall back to the un-cropped panorama so the capture isn't
// lost on a crop failure.
onCapture?.(pending.captureResultObj);
} finally {
setCropPending(null);
}
}}
/>
);
});
function noop(): void {
/* no-op handler used when panorama mode is disabled */
}
/**
* v0.12.0 — JS edge corresponding to the physical home-indicator
* side of the device. This is where the shutter + controls anchor
* to so they're always within thumb reach of the user's grip
* (matching iOS Camera's behaviour).
*
* Combines two signals:
* - `jsLandscape`: whether the OS rotated the framebuffer. True
* only for non-locked hosts in device-landscape.
* - `deviceOrient`: physical device orientation from the sensor.
*
* Truth table:
* | jsLandscape | deviceOrient | edge |
* |--- |--- |--- |
* | false | any | bottom | (portrait JS coords —
* | | | | device-bottom = JS-bottom
* | | | | in both locked and
* | | | | non-locked-portrait)
* | true | landscape-left | right | (screen rotated, home
* | | | | indicator on user-right)
* | true | landscape-right | left | (mirror)
*
* Caveats:
* - Non-locked + upside-down doesn't surface JS-top here because
* upside-down doesn't change window dimensions; we can't
* distinguish locked-portrait-with-device-flipped from
* non-locked-portrait-with-screen-flipped-180°. Defaults to
* JS-bottom which matches the more common locked case. Add
* handling here when a host needs upside-down support.
* - jsLandscape=true with non-landscape device shouldn't happen
* in steady state — only during a transition mid-rotation.
* Falls through to 'right' as a defensive default.
*/
type HomeIndicatorEdge = 'bottom' | 'top' | 'left' | 'right';
function homeIndicatorEdge(
jsLandscape: boolean,
deviceOrient: DeviceOrientation,
): HomeIndicatorEdge {
if (!jsLandscape) return 'bottom';
if (deviceOrient === 'landscape-left') return 'right';
if (deviceOrient === 'landscape-right') return 'left';
return 'right';
}
/**
* v0.12.0 — true when the anchor edge is on a side (left/right), so
* the band + shutter row need to be vertical strips. Top/bottom
* anchors yield horizontal strips.
*/
function isSideEdge(edge: HomeIndicatorEdge): boolean {
return edge === 'left' || edge === 'right';
}
// v0.13.1 — test-only exports of the pure orientation-decision
// functions. `homeIndicatorEdge` + `isSideEdge` together produce the
// `vertical` flag that drives PanoramaBandOverlay and
// CaptureThumbnailStrip layout, so they carry the orientation contract.
// Unit-tested via these handles (the lib's jest config is pure-TS and
// can't mount ; see jest.config.js).
/** @internal test-only — see `homeIndicatorEdge`. */
export const _homeIndicatorEdgeForTests = homeIndicatorEdge;
/** @internal test-only — see `isSideEdge`. */
export const _isSideEdgeForTests = isSideEdge;
/**
* cameraShouldUnmount — whether the live camera ( /
* ) should be UNMOUNTED (replaced by the placeholder) this
* render rather than mounted.
*
* True while a camera-switch transition or AR-support probe is in flight,
* OR during the stitch (statusPhase==='stitching'). The stitching case is
* the V12.14.8 OOM fix: unmounting frees vision-camera's AVCaptureSession +
* preview buffers (~150-250 MB) BEFORE the memory-heavy stitch, so the
* live-camera footprint and the stitch peak never coexist and jetsam (iOS)
* / lmkd (Android) don't OOM-kill the app.
*
* Pure + exported for test — the lib's jest config can't mount ,
* so this boolean is the unit-testable core of the OOM render gate.
*/
function cameraShouldUnmount(
inFlightTransition: boolean,
arSupportPending: boolean,
statusPhase: CaptureStatusPhase,
): boolean {
return inFlightTransition || arSupportPending || statusPhase === 'stitching';
}
/** @internal test-only — see `cameraShouldUnmount`. */
export const _cameraShouldUnmountForTests = cameraShouldUnmount;
/**
* v0.25 — must a hold DEFER because there is no camera to capture from?
*
* This is deliberately the first two terms of `cameraShouldUnmount`
* above, and that is the whole point: the render gate and the hold gate
* were reading different conditions, so a hold could start a capture
* against a camera the renderer had just deliberately unmounted.
*
* The hole this closes: `arSupportPending` clears in the SAME render
* that flips `isAR` false→true, which makes `inFlightTransition` true,
* unmounts the camera and (on iOS) stops the AR session with a 250 ms
* grace before the AR view may mount again. The v0.24.3 defer resumed
* on `!arSupportPending` alone — i.e. at exactly the moment the
* transition BEGAN — so the resumed capture ran against no frame source
* and finalized with "0 keyframes saved".
*
* `statusPhase === 'stitching'` is intentionally NOT included:
* `handleHoldStart` already rejects that phase outright rather than
* queueing a deferred start.
*/
function holdShouldDeferForCamera(
inFlightTransition: boolean,
arSupportPending: boolean,
): boolean {
return inFlightTransition || arSupportPending;
}
/** @internal test-only — see `holdShouldDeferForCamera`. */
export const _holdShouldDeferForCameraForTests = holdShouldDeferForCamera;
/**
* v0.12.0 — bottom-controls outer container positioning. Anchors
* to the home-indicator JS edge with the appropriate flex direction
* so the band sits on the viewport side of the shutter (toward the
* camera preview centre).
*/
function bottomAreaStyleForEdge(
edge: HomeIndicatorEdge,
bottomInsetPx: number,
topInsetPx: number,
): ViewStyle {
switch (edge) {
case 'bottom':
// Band above shutter row, both at JS-bottom. JSX order
// [band, shutter] + flexDirection 'column' = band at top of
// stack (closer to screen centre), shutter at JS-bottom.
return {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
flexDirection: 'column',
alignItems: 'stretch',
paddingBottom: bottomInsetPx,
};
case 'top':
// Mirror of bottom. column-reverse so JSX [band, shutter]
// renders [shutter, band] in JS, shutter at JS-top, band
// below it (toward screen centre).
return {
position: 'absolute',
left: 0,
right: 0,
top: 0,
flexDirection: 'column-reverse',
alignItems: 'stretch',
paddingTop: topInsetPx,
};
case 'right':
// Band to the left of shutter column, both at JS-right.
// flexDirection 'row' + JSX [band, shutter] = band at JS-left
// of container (screen centre side), shutter at JS-right.
return {
position: 'absolute',
top: 0,
bottom: 0,
right: 0,
flexDirection: 'row',
alignItems: 'stretch',
paddingRight: 12,
};
case 'left':
// Mirror of right. row-reverse so JSX [band, shutter] gives
// band at JS-right (screen centre side), shutter at JS-left.
return {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
flexDirection: 'row-reverse',
alignItems: 'stretch',
paddingLeft: 12,
};
}
}
/**
* v0.12.0 — inner shutter-row flex direction. Horizontal row for
* top/bottom anchors; vertical column for left/right anchors so
* the three slots (lens / shutter / AR) stack along the narrow
* side strip. Buttons don't rotate — touch targets and text
* orient correctly via either (a) un-rotated framebuffer under
* portrait-lock or (b) OS-rotated framebuffer under non-locked.
*/
function bottomBarStyleForEdge(edge: HomeIndicatorEdge): ViewStyle {
const vertical = isSideEdge(edge);
return {
flexDirection: vertical ? 'column' : 'row',
paddingHorizontal: vertical ? 0 : 18,
paddingVertical: vertical ? 18 : 0,
alignItems: 'center',
};
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
transitionPlaceholder: {
backgroundColor: '#000',
alignItems: 'center',
justifyContent: 'center',
},
transitionLabel: {
color: 'rgba(255,255,255,0.6)',
fontSize: 13,
},
bottomArea: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
flexDirection: 'column',
alignItems: 'stretch',
},
bottomBar: {
flexDirection: 'row',
paddingHorizontal: 18,
alignItems: 'flex-end',
},
bottomBarLeft: {
flex: 1,
alignItems: 'flex-start',
justifyContent: 'flex-end',
},
bottomBarCenter: {
flex: 1,
alignItems: 'center',
// v0.25.2 — CROSS-AXIS vs MAIN-AXIS. `alignItems` centres on the cross
// axis, which is horizontal while this slot lays out as a column — so on
// its own it left-right centres the cluster and leaves the vertical
// (main) axis at the default `flex-start`. In the SIDE-EDGE layout
// (bottomBarStyleForEdge -> flexDirection 'column' for a left/right home
// indicator) the three flex:1 slots split the height into thirds and the
// cluster pinned to the TOP of the middle third, i.e. from 33% rather
// than centred on 50%. The displacement scales with height, so it read
// as centred on an iPhone (~390-430pt landscape) and visibly high on an
// iPad (~834-1024pt) — the field report. A no-op in the row layout:
// there the parent's `alignItems: 'center'` sizes this slot to its
// content height, leaving no free space to distribute.
justifyContent: 'center',
},
bottomBarRight: {
flex: 1,
alignItems: 'flex-end',
justifyContent: 'flex-end',
},
shutterWrap: {
marginTop: 12,
},
headerWrap: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
// v0.13.1 — `thumbnailStripWrap` removed. The strip now renders
// inside the orientation-aware bottomArea container (alongside
// PanoramaBandOverlay and the bottom bar) rather than as a
// position-absolute overlay at hard-coded `bottom: 160`.
//
// v0.13.1 — top-right control pill stack (flash + AR). Absolute,
// pinned to the right edge under the settings affordance; `top` is
// set inline from `pillStackTop`. Column so the pills stack
// vertically; gap keeps them from touching.
pillStack: {
position: 'absolute',
right: 14,
alignItems: 'flex-end',
gap: 10,
},
});
// v0.13.1 — shared pill style for the top-right control stack. The
// flash pill matches the AR toggle's shape (same padding / radius /
// background) so the two read as a set.
const pillStyles = StyleSheet.create({
pill: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0.45)',
minWidth: 56,
alignItems: 'center',
justifyContent: 'center',
},
pillActive: {
backgroundColor: '#ffd34d',
},
flashGlyph: {
color: '#ffffff',
fontSize: 18,
},
glyphActive: {
color: '#1a1a1a',
},
});